diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index be24da7d..f161135e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -107,6 +107,15 @@ jobs: echo "Next version: ${{ steps.version.outputs.next }}" echo "Tag: ${{ steps.version.outputs.tag }}" + # Build release assets before the first irreversible push. This also runs + # during dry-run so packaging failures are discovered without publishing + # a version commit or immutable tag. + - name: Package and validate Hermes runtime helpers + run: | + bash scripts/package-hermes-helpers.sh "${{ steps.version.outputs.tag }}" + test -s dist/hermes-helpers/SHA256SUMS + (cd dist/hermes-helpers && sha256sum --check SHA256SUMS) + - name: Update version if: ${{ !inputs.dry_run }} run: | @@ -151,4 +160,5 @@ jobs: --title "$TAG" \ --generate-notes \ $PRERELEASE \ + dist/hermes-helpers/* \ --notes-start-tag "$(git describe --tags --abbrev=0 "$TAG^" 2>/dev/null || echo '')" diff --git a/.gitignore b/.gitignore index 20d308c6..570e2aa5 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ test-results .claude/ .DS_Store *.log +__pycache__/ +*.py[cod] prisma/migrations/dev.db* next-env.d.ts tsconfig.tsbuildinfo diff --git a/DEVLOG.md b/DEVLOG.md index 78879629..ff3e727e 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -2,6 +2,43 @@ > Append-only session log. Read at session start. Update at session end. +## 2026-08-25 — Generic OIDC and recovery identity hardening + +- Required PKCE, state, and nonce checks explicitly for every generic OIDC + provider and added a strict local provider fixture that rejects weak state, + requires S256 PKCE, and binds the returned ID token to the request nonce. +- Made external identity linking an explicit, confirmed account-security flow, + clarified that login identities do not grant integration access, surfaced + provider-link collisions, and covered link, unlink, and relink behavior. +- Added a designated break-glass administrator to instance policy. Recovery + now uses an explicit route, records an instance audit event, exposes readiness + in admin settings, and protects the designated principal from demotion, + suspension, or deletion until recovery is reassigned or disabled. +- Documented reverse-proxy callback bypass, forwarded-origin, sealed-cookie + header-buffer, and sensitive callback logging requirements for OIDC. + +## 2026-08-25 — Managed runtime execution-plane and Hermes hardening + +- Moved operator-requested runtime probes and no-tool self-tests onto a + dedicated BullMQ worker queue so their DNS, routing, and firewall plane is + the same one that dispatches managed `/v1/runs` work. Added bounded, + tenant-scoped diagnostic history with worker executor and manual/scheduled + trigger provenance. +- Added a reviewable production Compose topology where the worker has outbound + egress without published ports or reverse-proxy membership, plus regression + coverage for the network contract and contradictory probe history. +- Replaced `REMOTE_HTTP`-derived “remote webhook” copy with adapter-aware + managed-runtime and transport identity, and collapsed duplicate chat + readiness warnings when runtime health already reports the same probe fault. +- Added a stable versioned User-Agent and sanitized HTTP/error classification + to the distributable Hermes Forge platform adapter. +- Shipped versioned, deterministic `forge-presence` and `forge-provision` + helper sources, release packaging/checksums, and operator docs. Presence is a + script-only recurring heartbeat and remains silent on success. +- Explicitly documented and tested that `runs.complete.completionCommentId` + accepts only a live BODY comment, and added one managed-runtime acceptance + path spanning assignment, external dispatch, inbox acknowledgement, output + start, final BODY comment, and terminal completion. ## 2026-08-25 — Restricted projects and explicit integration grants - Added workspace-visible and restricted projects with explicit Viewer, @@ -31,7 +68,6 @@ integration ceiling after defaults are resolved, and now revokes derived grants when credential source, exact app binding, or capability ceilings change. - ## 2026-08-25 — Canonical local and external user identity foundation - Added a provider-neutral instance authentication policy for local-only, @@ -14498,3 +14534,21 @@ reasoning output. Added integration coverage for delegated runtime completion, durable final comments, wait-request deduplication/resolution, waiting-reason preservation, and webhook relation precedence, plus lifecycle Playwright coverage for collapsed/expanded progress detail. + +## 2026-08-25 — AXI-182 final hardening review + +Closed the final managed-runtime and helper-distribution review gaps before +release. Unexpected diagnostic worker failures now finalize their provenance +row with a sanitized terminal result instead of leaving an indefinite pending +state. A separate-process regression proves that runtime verification reports +worker-plane failure even when the web/test process can reach the endpoint. + +Hermes presence heartbeats now authorize strictly through the authenticated +key's linked agent identity without requiring broad workspace-member read +access. Release automation builds and checksum-validates the versioned Hermes +helper archives before any version or tag push, including during dry runs, and +publishes those prevalidated assets after tagging. + +Verification: focused runtime-diagnostic, MCP-execution, and helper-distribution +tests passed (11/11); TypeScript passed; lint completed with only the existing +repository warnings; helper tarballs and `SHA256SUMS` verified successfully. diff --git a/RELEASE.md b/RELEASE.md index ecba41e6..a7de4d1d 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -144,6 +144,14 @@ docker compose up -d # entrypoint runs `prisma migrate deploy` # run any one-time data backfill explicitly (prod image has no tsx — copy a .cjs + `node`) ``` +The deployment Compose must preserve the checked-in +`docker/docker-compose.production.example.yml` network contract. In +particular, `forge-worker` needs the private data network plus a non-internal +egress network so managed-runtime probes and Runs dispatch use the same +reachable execution plane. It must not publish ports or join the public proxy +network. The web app may join the proxy network and must set +`FORGE_DISABLE_IN_PROCESS_WORKER=1` when the sidecar is present. + ### 4. Smoke test `forge.axiom-labs.dev` loads · sign-in works · core flows (issue create, agent dispatch, diff --git a/docker/docker-compose.production.example.yml b/docker/docker-compose.production.example.yml new file mode 100644 index 00000000..e33142e2 --- /dev/null +++ b/docker/docker-compose.production.example.yml @@ -0,0 +1,70 @@ +name: forge + +# Reviewable production topology. The worker receives outbound egress without +# publishing ports and without joining the public reverse-proxy network. +services: + postgres: + image: postgres:16-alpine + env_file: .env.production + volumes: ["forge-postgres:/var/lib/postgresql/data"] + networks: [forge-data] + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER:-forge}"] + interval: 5s + retries: 10 + + redis: + image: redis:7-alpine + volumes: ["forge-redis:/data"] + networks: [forge-data] + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + retries: 10 + + minio: + image: minio/minio:latest + command: server /data + env_file: .env.production + volumes: ["forge-minio:/data"] + networks: [forge-data] + + forge: + build: + context: ${FORGE_SOURCE_PATH:?set FORGE_SOURCE_PATH to the exact tagged checkout} + target: runner + args: { GIT_SHA: "${GIT_SHA:-}", BUILD_TIME: "${BUILD_TIME:-}" } + env_file: .env.production + environment: + FORGE_DISABLE_IN_PROCESS_WORKER: "1" + depends_on: + postgres: { condition: service_healthy } + redis: { condition: service_healthy } + expose: ["3000"] + networks: [forge-data, forge-egress, forge-proxy] + + forge-worker: + build: + context: ${FORGE_SOURCE_PATH:?set FORGE_SOURCE_PATH to the exact tagged checkout} + target: worker + args: { GIT_SHA: "${GIT_SHA:-}", BUILD_TIME: "${BUILD_TIME:-}" } + env_file: .env.production + depends_on: + postgres: { condition: service_healthy } + redis: { condition: service_healthy } + networks: [forge-data, forge-egress] + +networks: + forge-data: + internal: true + forge-egress: + # Non-internal bridge supplies outbound DNS/TCP without inbound exposure. + driver: bridge + forge-proxy: + external: true + name: ${FORGE_PROXY_NETWORK:-proxy} + +volumes: + forge-postgres: + forge-redis: + forge-minio: diff --git a/docs/agents/runtime-credentials.md b/docs/agents/runtime-credentials.md index e7c1aa2b..27192b4e 100644 --- a/docs/agents/runtime-credentials.md +++ b/docs/agents/runtime-credentials.md @@ -152,7 +152,10 @@ Where to run it: **Settings → Runtimes → (a runtime) → Provisioning** (download button + ready bootstrap), or wire it as a session-start step. - **Persistent Hermes host:** install the `forge-provision` Hermes skill - (`~/.hermes/skills/forge-provision/`) — `bin/setup.sh ` installs an + from the version-matched Forge release artifact + (`forge-provision-vX.Y.Z.tar.gz`, verified against `SHA256SUMS`) under + `~/.hermes/skills/forge-provision/`. Source installations may copy + `integrations/hermes/forge-provision/`. `bin/setup.sh ` installs an hourly cron that fetches + runs the script, keeping the token + checkouts fresh. Companion to the `forge-presence` heartbeat skill; shares the same `forge.env` (`FORGE_URL` + `FORGE_API_KEY`). diff --git a/docs/agents/runtime-modes.md b/docs/agents/runtime-modes.md index 42d88e50..5a30eec4 100644 --- a/docs/agents/runtime-modes.md +++ b/docs/agents/runtime-modes.md @@ -70,13 +70,20 @@ agent offline. ## The forge-presence Hermes skill +Forge releases publish a versioned `forge-presence-vX.Y.Z.tar.gz` artifact and +`SHA256SUMS`. Verify the checksum, unpack it below +`~/.hermes/skills/forge-presence/`, then use its setup script. The source ships +at `integrations/hermes/forge-presence/` for source-based installations. + `~/.hermes/skills/forge-presence/` is a small cron-driven script that calls `agents.heartbeat` on behalf of a Hermes profile. It requires only: - `FORGE_URL` — the Forge instance base URL. - `FORGE_API_KEY` — an AGENT-kind API key with `linkedAgentId` set. The agent id is inferred from the key's `linkedAgentId`; no agent id needs to be -hardcoded. +hardcoded. Self-heartbeat does not require `READ_USERS` or another broad +workspace scope: the linked agent identity is the complete authorization +boundary for this endpoint. ### Setup for the default agent (Victor) @@ -101,7 +108,8 @@ The `setup.sh` script registers a system cron entry that calls the heartbeat end every minute. Without the skill, a Hermes agent is still considered reachable whenever the worker successfully delivers a webhook (implicit heartbeat) — but chat shows "offline · queued" until the first delivery lands. With the skill, presence is honest -from the moment Hermes starts. +from the moment Hermes starts. The heartbeat is script-only, repeats indefinitely, +and is silent on success; it never opens a Hermes session or wakes an LLM. ## Cross-references diff --git a/docs/agents/runtimes.md b/docs/agents/runtimes.md index d1b2f752..5b6502ce 100644 --- a/docs/agents/runtimes.md +++ b/docs/agents/runtimes.md @@ -144,6 +144,17 @@ For containerised Codex app-server deployments, use the Docker bridge pattern in should call `runtimes.reportInfo` on boot so operators can see the bridge, Codex, container, and workspace versions from the runtime detail page. +## Diagnostics use the execution worker + +**Test connection** and the no-tool **Self-test** enqueue a diagnostic on the +same worker process that dispatches managed Runs. The runtime detail page keeps +the most recent diagnostic history and labels each result with its worker +executor plus its manual or scheduled trigger. This prevents a web-container +network pass from being presented as proof that the worker can dispatch work. +If an operator-requested diagnostic times out, inspect the worker queue and its +outbound DNS/network policy; Forge does not fall back to probing from the web +process. + ## MCP tools (for runtimes that auto-register) `runtimes.register`, `runtimes.heartbeat`, `runtimes.configure`, and diff --git a/docs/guide/instance-admin.md b/docs/guide/instance-admin.md index ca4404b5..e88112ed 100644 --- a/docs/guide/instance-admin.md +++ b/docs/guide/instance-admin.md @@ -90,6 +90,37 @@ minimum length, reset expiry, and lockout behavior. Provider/client secrets remain encrypted with `AUTH_SECRET`; the environment operator remains a separate recovery credential when break glass is enabled. +Break-glass recovery is mapped to one designated, active instance +administrator whose email matches `ADMIN_EMAIL`. Create and activate a +dedicated local administrator under `/admin/users`, select it under **Identity +& sign-in**, and keep it separate from a person's normal OIDC identity. The +recovery form is `/signin/local?breakGlass=1`; successful uses are recorded in +the instance security audit. Forge prevents demotion, suspension, or deletion +of the designated account until recovery is reassigned or disabled. + +### Reverse proxy requirements for OIDC + +An outer forward-auth layer must not intercept Forge's exact Auth.js callback +paths (`/api/auth/callback/`). The identity provider redirects the +browser directly to Forge so Auth.js can validate the sealed state, PKCE, and +nonce cookies. Keep the rest of the application behind the normal access +policy, but configure the callback path as an explicit bypass in Authelia, +Authentik, nginx `auth_request`, Traefik ForwardAuth, or an equivalent proxy. + +The proxy must also: + +- preserve the public host and scheme in `X-Forwarded-Host` and + `X-Forwarded-Proto`, with `AUTH_URL` set to that same public origin; +- allow request and response header buffers large enough for Auth.js's sealed + PKCE, state, nonce, and session cookies—do not truncate or silently drop + multiple `Set-Cookie` headers; +- avoid logging cookie values, authorization codes, or callback query strings. + +A provider redirect that repeatedly returns to sign-in, reports missing state, +or succeeds only when outer authentication is disabled usually indicates a +callback bypass or header-buffer problem rather than an IdP client-secret +failure. + All identity-policy and account-lifecycle mutations write the instance-wide security audit ledger with actor, target, request metadata, and timestamp. diff --git a/docs/guide/settings.md b/docs/guide/settings.md index d6a8aa15..36792cb1 100644 --- a/docs/guide/settings.md +++ b/docs/guide/settings.md @@ -282,8 +282,11 @@ Configure how people sign in, without a redeploy: attempt threshold, and lockout duration. Passwords are stored as versioned scrypt hashes; raw passwords and raw reset/setup tokens are never persisted. - **Break glass** — keeps only the `ADMIN_EMAIL` / `ADMIN_PASSWORD` operator - credential available at `/signin/local`. It does not turn every local user - into a break-glass administrator. + credential available through the explicit `/signin/local?breakGlass=1` + recovery flow. Select one active instance administrator whose email matches + `ADMIN_EMAIL`; Forge audits recovery sign-ins and protects that account from + lifecycle changes until recovery is reassigned or disabled. Use a dedicated + local administrator rather than a person's normal OIDC identity. - **Add a provider** — pick a type: - **OpenID Connect (OIDC)** — the generic, discovery-based type. Covers any OIDC IdP: self-hosted **Authelia**, Authentik, Keycloak, or hosted diff --git a/docs/reference/env.md b/docs/reference/env.md index 009c5369..71e020c1 100644 --- a/docs/reference/env.md +++ b/docs/reference/env.md @@ -49,6 +49,13 @@ instance administrator can use `/signin/local` when external identity is unavailable. Keep them unique, protected, and available to the operator; do not reuse a person's normal password. +After configuring these variables, create and activate a dedicated instance +administrator with the same email and designate it under **Identity & sign +in**. Recovery uses `/signin/local?breakGlass=1`; the ordinary local form never +accepts the environment credential implicitly. See [Instance Admin → Reverse +proxy requirements for OIDC](/guide/instance-admin.html#reverse-proxy-requirements-for-oidc) +for callback bypass and header-buffer requirements. + Authentication mode, registration, automatic provider redirect, password minimum length, reset expiry, and lockout thresholds are runtime database settings under **Identity & sign-in**. They are intentionally not environment @@ -287,6 +294,17 @@ The instrumentation hook is a no-op when it detects an external worker is already serving the queue — workers coordinate via Redis, so it is safe to leave the in-process boot enabled even with a sidecar. +### Worker outbound networking + +Managed runtime handshake checks, self-tests, periodic health sweeps, and +`/v1/runs` dispatch execute in the worker. The reference production topology is +`docker/docker-compose.production.example.yml`: the worker joins the internal +data network and a dedicated non-internal egress bridge, publishes no ports, +and does not join the public reverse-proxy network. A manual runtime diagnostic +is queued to that worker rather than executed from the web container, so its +result reflects the same DNS, routing, and firewall plane that dispatches real +work. + ## Cross-references - [/guide/architecture.html](/guide/architecture.html) — how these pieces diff --git a/docs/reference/mcp.md b/docs/reference/mcp.md index aded516e..c9f85943 100644 --- a/docs/reference/mcp.md +++ b/docs/reference/mcp.md @@ -502,7 +502,7 @@ heartbeat returns a missing-row error. | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `open` | Open (or resume) a tracked run on an issue **you** are working — `{ issueId, summary?, mode? }`. Requires an agent-linked key; the run belongs to the key's `linkedAgentId`. Resumes an existing ACTIVE/WAITING run for the `(issue, agent)` pair instead of stacking a duplicate, and stamps a STARTED event so it shows in Mission Control. An `EXECUTE` run also applies the workspace's configured In Progress status with audit evidence; `RESEARCH`, `REVIEW`, and `DISCUSS` do not change issue status. Lets an agent (e.g. a local CLI session) proactively self-report work rather than only getting a run as a dispatch side-effect. Issue-anchored; drive it afterward with `recordUsage` / `setWaiting` / `complete`. Scope `WRITE_ISSUES`. | | `recordUsage` | Update token + cost columns on an `AgentRun`. `{ runId, tokensIn?, tokensOut?, tokensCached?, costUsd? }`. Idempotent — latest call replaces (cumulative as reported by the agent). | -| `complete` | Close an active/waiting run. `{ runId, summary, producedArtifactIds?, verificationResult?, followUps?, confidence?, verdict? }`. Requires an agent-linked key matching the run. Execute enforces issue artifact/checklist gates and applies the configured In Review status after successful completion with audit evidence; Research requires `confidence`; Review requires `verdict`; Discuss is reply-only. Stores `completionMeta` with the Forge run contract version. | +| `complete` | Close an active/waiting run. `{ runId, summary, completionCommentId?, producedArtifactIds?, verificationResult?, followUps?, confidence?, verdict? }`. `completionCommentId`, when supplied, must identify a live **BODY** comment posted by this run's agent on the same issue after the run started; rolling STATUS comments are rejected. When omitted, Forge creates the final BODY comment from `summary`. Requires an agent-linked key matching the run. Execute enforces issue artifact/checklist gates and applies the configured In Review status after successful completion with audit evidence; Research requires `confidence`; Review requires `verdict`; Discuss is reply-only. Stores `completionMeta` with the Forge run contract version. | | `setWaiting` / `resumeWork` | Mark a run blocked on the operator, or resume it. Requires an agent-linked key matching the run. | | `list` | List `AgentRun` rows for "my recent history" introspection. `{ agentId?, issueId?, status?, limit? = 50, before? }` → newest-first by `startedAt`. Each row includes scalars (status, currentStep, startedAt, finishedAt, lastEventAt, tokensIn, tokensOut, tokensCached, costUsd) plus `issue { id, number, title, workspace: { key } }` and `agent { id, profileKey }`. | | `kick` | Operator-driven nudge for a stalled run. `{ runId }`. Re-fires the dispatch webhook for the underlying issue without changing assignment or `controlState`. Only kicks an `ACTIVE` run that's been quiet 5+ minutes; younger runs return `{ ok: true, kicked: false }`. Records `AGENT_RUN_KICKED`. Scope: `WRITE_ISSUES`. | diff --git a/integrations/hermes/forge-platform/adapter.py b/integrations/hermes/forge-platform/adapter.py index 431b7a03..4eefdff9 100644 --- a/integrations/hermes/forge-platform/adapter.py +++ b/integrations/hermes/forge-platform/adapter.py @@ -20,6 +20,7 @@ import logging import os from pathlib import Path +import re import sqlite3 import threading import time @@ -36,6 +37,8 @@ DEFAULT_BASE_URL = "http://127.0.0.1:3000" CONNECTOR_NAME = "hermes-forge-platform" CONNECTOR_VERSIONS = ("1.0",) +ADAPTER_VERSION = "2.0.0" +FORGE_USER_AGENT = f"Forge-Hermes-Platform/{ADAPTER_VERSION}" NEGOTIATE_TOOL = "chat.connector.negotiate" DELIVER_TOOL = "chat.connector.deliver" @@ -157,9 +160,40 @@ def close(self) -> None: class ForgeMcpError(RuntimeError): - def __init__(self, message: str, *, retryable: bool): + def __init__( + self, + message: str, + *, + retryable: bool, + status: int | None = None, + error_class: str = "connector_error", + ): super().__init__(message) self.retryable = retryable + self.status = status + self.error_class = error_class + + +def _sanitize_diagnostic(value: Any, maximum: int = 300) -> str: + text = str(value or "Connector request failed.") + text = re.sub(r"Bearer\s+[^\s,)]+", "Bearer [REDACTED]", text, flags=re.IGNORECASE) + text = re.sub( + r"(token|secret|key|authorization|signature)([\"'\s:=]+)[^\s\"'&}]+", + r"\1\2[REDACTED]", + text, + flags=re.IGNORECASE, + ) + text = re.sub(r"https?://[^\s\"']+", "[REDACTED_URL]", text, flags=re.IGNORECASE) + text = " ".join(text.split()) + return text if len(text) <= maximum else text[: maximum - 3] + "..." + + +def _connector_error_fields(exc: Exception) -> tuple[str, int | None, bool]: + return ( + str(getattr(exc, "error_class", exc.__class__.__name__)), + getattr(exc, "status", None), + bool(getattr(exc, "retryable", True)), + ) def _negotiation_tool_missing(exc: Exception) -> bool: @@ -260,7 +294,14 @@ async def connect(self, *, is_reconnect: bool = False) -> bool: "Forge connector negotiation failed", retryable=True, ) - logger.error("[Forge] Connector negotiation failed") + error_class, status, retryable = _connector_error_fields(exc) + logger.error( + "[Forge] Connector negotiation failed class=%s status=%s retryable=%s detail=%s", + error_class, + status if status is not None else "none", + retryable, + _sanitize_diagnostic(exc), + ) return False self._negotiated = False self._selected_version = None @@ -656,6 +697,7 @@ def _call_tool_once(self, name: str, arguments: Dict[str, Any]) -> Any: "Content-Type": "application/json", "Accept": "application/json, text/event-stream", "Authorization": f"Bearer {self.api_key}", + "User-Agent": FORGE_USER_AGENT, }, method="POST", ) @@ -663,13 +705,19 @@ def _call_tool_once(self, name: str, arguments: Dict[str, Any]) -> Any: with urllib.request.urlopen(req, timeout=30) as response: raw = response.read().decode("utf-8", "replace") except urllib.error.HTTPError as exc: - detail = exc.read().decode("utf-8", "replace")[:500] + detail = _sanitize_diagnostic(exc.read().decode("utf-8", "replace")) raise ForgeMcpError( f"Forge MCP HTTP {exc.code}: {detail}", retryable=exc.code == 408 or exc.code == 429 or exc.code >= 500, + status=exc.code, + error_class="http_error", ) from exc except urllib.error.URLError as exc: - raise ForgeMcpError("Forge MCP transport unavailable", retryable=True) from exc + raise ForgeMcpError( + "Forge MCP transport unavailable", + retryable=True, + error_class="transport_error", + ) from exc data = json.loads(raw) if data.get("error"): raise ForgeMcpError(f"Forge MCP error: {data['error']}", retryable=False) diff --git a/integrations/hermes/forge-platform/tests/test_adapter.py b/integrations/hermes/forge-platform/tests/test_adapter.py index f30ccd79..a384f694 100644 --- a/integrations/hermes/forge-platform/tests/test_adapter.py +++ b/integrations/hermes/forge-platform/tests/test_adapter.py @@ -2,6 +2,7 @@ import asyncio import importlib.util +import io from pathlib import Path import sys import tempfile @@ -114,6 +115,43 @@ def test_transport_negotiation_failure_does_not_claim_legacy_connection(self): self.assertFalse(adapter._negotiated) self.assertEqual(adapter.fatal_error[0][0], "connector_negotiation_failed") + def test_mcp_requests_send_stable_forge_user_agent(self): + adapter = self.make_adapter() + response = Mock() + response.__enter__ = Mock(return_value=response) + response.__exit__ = Mock(return_value=False) + response.read.return_value = b'{"result": {"ok": true}}' + + with patch.object(adapter_module.urllib.request, "urlopen", return_value=response) as opened: + adapter._call_tool_once("chat.connector.negotiate", {}) + + request = opened.call_args.args[0] + self.assertEqual(request.get_header("User-agent"), adapter_module.FORGE_USER_AGENT) + self.assertEqual(adapter_module.FORGE_USER_AGENT, "Forge-Hermes-Platform/2.0.0") + plugin = (MODULE_PATH.parent / "plugin.yaml").read_text(encoding="utf-8") + self.assertIn(f"version: {adapter_module.ADAPTER_VERSION}", plugin) + + def test_http_failures_are_classified_and_sanitized(self): + adapter = self.make_adapter() + error = adapter_module.urllib.error.HTTPError( + adapter.rpc_url, + 403, + "Forbidden", + {}, + io.BytesIO( + b'authorization: Bearer super-secret https://forge.example.test/private?token=bad' + ), + ) + with patch.object(adapter_module.urllib.request, "urlopen", side_effect=error): + with self.assertRaises(adapter_module.ForgeMcpError) as raised: + adapter._call_tool_once("chat.connector.negotiate", {}) + + self.assertEqual(raised.exception.status, 403) + self.assertEqual(raised.exception.error_class, "http_error") + self.assertFalse(raised.exception.retryable) + self.assertNotIn("super-secret", str(raised.exception)) + self.assertNotIn("forge.example.test", str(raised.exception)) + def test_negotiated_event_has_stable_contract_and_increasing_sequence(self): adapter = self.make_adapter() adapter._negotiated = True diff --git a/integrations/hermes/forge-presence/README.md b/integrations/hermes/forge-presence/README.md new file mode 100644 index 00000000..39597093 --- /dev/null +++ b/integrations/hermes/forge-presence/README.md @@ -0,0 +1,18 @@ +# forge-presence + +Deterministic Hermes presence helper for Forge. It is script-only: no model, +Hermes session, prompt, or tool loop is invoked. A cron entry calls the +heartbeat endpoint every minute forever and emits nothing on success. + +```bash +cp forge.env.example ~/.hermes/forge.env +$EDITOR ~/.hermes/forge.env +chmod 600 ~/.hermes/forge.env +bash bin/setup.sh victor +``` + +For an installed profile, `setup.sh ` uses +`~/.hermes/profiles//forge.env`. The API key must be an AGENT key with +`linkedAgentId` set. Run `bin/heartbeat.sh` directly to diagnose a failure; +successful calls remain silent. The heartbeat endpoint is self-scoped by that +linked agent identity and does not require broad `READ_USERS` access. diff --git a/integrations/hermes/forge-presence/VERSION b/integrations/hermes/forge-presence/VERSION new file mode 100644 index 00000000..3eefcb9d --- /dev/null +++ b/integrations/hermes/forge-presence/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/integrations/hermes/forge-presence/bin/heartbeat.sh b/integrations/hermes/forge-presence/bin/heartbeat.sh new file mode 100755 index 00000000..42e43d3d --- /dev/null +++ b/integrations/hermes/forge-presence/bin/heartbeat.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE=${FORGE_ENV_FILE:-${HERMES_HOME:-$HOME/.hermes}/forge.env} +if [[ ! -r "$ENV_FILE" ]]; then + echo "forge-presence: env file is not readable: $ENV_FILE" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +: "${FORGE_URL:?forge-presence: FORGE_URL is required}" +: "${FORGE_API_KEY:?forge-presence: FORGE_API_KEY is required}" + +curl --fail --silent --show-error --max-time 15 \ + --output /dev/null \ + --request POST "${FORGE_URL%/}/api/mcp/agents.heartbeat" \ + --header "Authorization: Bearer ${FORGE_API_KEY}" \ + --header "Content-Type: application/json" \ + --header "User-Agent: Forge-Presence/1.0.0" \ + --data '{}' diff --git a/integrations/hermes/forge-presence/bin/setup.sh b/integrations/hermes/forge-presence/bin/setup.sh new file mode 100755 index 00000000..856291e0 --- /dev/null +++ b/integrations/hermes/forge-presence/bin/setup.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROFILE=${1:-victor} +SKILL_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +if [[ "$PROFILE" == "victor" ]]; then + ENV_FILE=${FORGE_ENV_FILE:-$HOME/.hermes/forge.env} +else + ENV_FILE=${FORGE_ENV_FILE:-$HOME/.hermes/profiles/$PROFILE/forge.env} +fi + +if [[ ! -r "$ENV_FILE" ]]; then + echo "forge-presence: create $ENV_FILE from forge.env.example first" >&2 + exit 1 +fi + +MARKER="# forge-presence:${PROFILE}" +ENTRY="* * * * * FORGE_ENV_FILE='$ENV_FILE' '$SKILL_ROOT/bin/heartbeat.sh' >/dev/null ${MARKER}" +CURRENT=$(crontab -l 2>/dev/null || true) +FILTERED=$(printf '%s\n' "$CURRENT" | grep -Fv "$MARKER" || true) +printf '%s\n%s\n' "$FILTERED" "$ENTRY" | crontab - +echo "forge-presence: installed one-minute heartbeat for $PROFILE" diff --git a/integrations/hermes/forge-presence/forge.env.example b/integrations/hermes/forge-presence/forge.env.example new file mode 100644 index 00000000..f8ab551a --- /dev/null +++ b/integrations/hermes/forge-presence/forge.env.example @@ -0,0 +1,2 @@ +FORGE_URL=https://forge.example.com +FORGE_API_KEY=forge_sk_replace_me diff --git a/integrations/hermes/forge-provision/README.md b/integrations/hermes/forge-provision/README.md new file mode 100644 index 00000000..1ef5ef1b --- /dev/null +++ b/integrations/hermes/forge-provision/README.md @@ -0,0 +1,16 @@ +# forge-provision + +Scheduled wrapper for Forge's canonical provider-neutral provisioning script. +It deliberately does not duplicate credential or repository logic: each run +downloads `/api/integrations/provision-script` from the configured Forge +instance and executes that version. + +```bash +cp forge.env.example ~/.hermes/forge.env +$EDITOR ~/.hermes/forge.env +chmod 600 ~/.hermes/forge.env +bash bin/setup.sh victor +``` + +The installed hourly cron refreshes short-lived GitHub App credentials and +fast-forwards clean runtime checkouts. Failures are written to stderr. diff --git a/integrations/hermes/forge-provision/VERSION b/integrations/hermes/forge-provision/VERSION new file mode 100644 index 00000000..3eefcb9d --- /dev/null +++ b/integrations/hermes/forge-provision/VERSION @@ -0,0 +1 @@ +1.0.0 diff --git a/integrations/hermes/forge-provision/bin/run.sh b/integrations/hermes/forge-provision/bin/run.sh new file mode 100755 index 00000000..d392e013 --- /dev/null +++ b/integrations/hermes/forge-provision/bin/run.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +set -euo pipefail + +ENV_FILE=${FORGE_ENV_FILE:-${HERMES_HOME:-$HOME/.hermes}/forge.env} +if [[ ! -r "$ENV_FILE" ]]; then + echo "forge-provision: env file is not readable: $ENV_FILE" >&2 + exit 1 +fi + +set -a +# shellcheck disable=SC1090 +source "$ENV_FILE" +set +a + +: "${FORGE_URL:?forge-provision: FORGE_URL is required}" +: "${FORGE_API_KEY:?forge-provision: FORGE_API_KEY is required}" + +TMP=$(mktemp "${TMPDIR:-/tmp}/forge-provision.XXXXXX.cjs") +trap 'rm -f "$TMP"' EXIT +curl --fail --silent --show-error --max-time 30 \ + --header "User-Agent: Forge-Provision/1.0.0" \ + "${FORGE_URL%/}/api/integrations/provision-script" \ + --output "$TMP" +FORGE_BASE_URL="${FORGE_URL%/}" node "$TMP" diff --git a/integrations/hermes/forge-provision/bin/setup.sh b/integrations/hermes/forge-provision/bin/setup.sh new file mode 100755 index 00000000..526d2e76 --- /dev/null +++ b/integrations/hermes/forge-provision/bin/setup.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +PROFILE=${1:-victor} +SKILL_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +if [[ "$PROFILE" == "victor" ]]; then + ENV_FILE=${FORGE_ENV_FILE:-$HOME/.hermes/forge.env} +else + ENV_FILE=${FORGE_ENV_FILE:-$HOME/.hermes/profiles/$PROFILE/forge.env} +fi + +if [[ ! -r "$ENV_FILE" ]]; then + echo "forge-provision: create $ENV_FILE from forge.env.example first" >&2 + exit 1 +fi + +MARKER="# forge-provision:${PROFILE}" +ENTRY="17 * * * * FORGE_ENV_FILE='$ENV_FILE' '$SKILL_ROOT/bin/run.sh' ${MARKER}" +CURRENT=$(crontab -l 2>/dev/null || true) +FILTERED=$(printf '%s\n' "$CURRENT" | grep -Fv "$MARKER" || true) +printf '%s\n%s\n' "$FILTERED" "$ENTRY" | crontab - +echo "forge-provision: installed hourly refresh for $PROFILE" diff --git a/integrations/hermes/forge-provision/forge.env.example b/integrations/hermes/forge-provision/forge.env.example new file mode 100644 index 00000000..61f1f299 --- /dev/null +++ b/integrations/hermes/forge-provision/forge.env.example @@ -0,0 +1,3 @@ +FORGE_URL=https://forge.example.com +FORGE_API_KEY=forge_sk_replace_me +FORGE_WORKSPACE_ROOT=/work diff --git a/playwright.config.ts b/playwright.config.ts index 908c4cc7..d4ba1e1c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -9,6 +9,10 @@ import { defineConfig, devices } from "@playwright/test"; */ const PORT = Number(process.env.E2E_PORT ?? 3200); const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${PORT}`; +const OIDC_PORT = Number(process.env.E2E_OIDC_PORT ?? 3211); +const OIDC_URL = `http://127.0.0.1:${OIDC_PORT}`; +process.env.E2E_OIDC_ISSUER ??= OIDC_URL; +process.env.E2E_OIDC_EMAIL ??= "oidc-link-user@forge.local"; const windowsGitBash = process.env.FORGE_GIT_BASH_PATH ?? "C:\\Program Files\\Git\\bin\\bash.exe"; if (process.platform === "win32" && !existsSync(windowsGitBash)) { throw new Error( @@ -39,13 +43,23 @@ export default defineConfig({ screenshot: "only-on-failure", }, projects: [{ name: "chromium", use: devices["Desktop Chrome"] }], - webServer: { - command: `${bashCommand} scripts/e2e-web.sh`, - url: BASE_URL, - reuseExistingServer: !process.env.CI && process.env.E2E_FORCE_FRESH_SERVER !== "1", - // First boot migrates + seeds + runs a full `next build`, so allow headroom. - timeout: Number(process.env.E2E_WEB_TIMEOUT_MS ?? 360_000), - stdout: "pipe", - stderr: "pipe", - }, + webServer: [ + { + command: "node tests/e2e/fixtures/strict-oidc-provider.mjs", + url: `${OIDC_URL}/health`, + reuseExistingServer: !process.env.CI, + timeout: 30_000, + stdout: "pipe", + stderr: "pipe", + }, + { + command: `${bashCommand} scripts/e2e-web.sh`, + url: BASE_URL, + reuseExistingServer: !process.env.CI && process.env.E2E_FORCE_FRESH_SERVER !== "1", + // First boot migrates + seeds + runs a full `next build`, so allow headroom. + timeout: Number(process.env.E2E_WEB_TIMEOUT_MS ?? 360_000), + stdout: "pipe", + stderr: "pipe", + }, + ], }); diff --git a/prisma/migrations/20260825210000_runtime_diagnostic_provenance/migration.sql b/prisma/migrations/20260825210000_runtime_diagnostic_provenance/migration.sql new file mode 100644 index 00000000..503f036e --- /dev/null +++ b/prisma/migrations/20260825210000_runtime_diagnostic_provenance/migration.sql @@ -0,0 +1,35 @@ +-- Managed runtime diagnostics execute in the worker and retain bounded +-- provenance so manual checks and scheduled sweeps cannot look contradictory. +CREATE TYPE "RuntimeDiagnosticKind" AS ENUM ('PROBE', 'SELF_TEST'); +CREATE TYPE "RuntimeDiagnosticExecutor" AS ENUM ('WORKER'); +CREATE TYPE "RuntimeDiagnosticTrigger" AS ENUM ('MANUAL_RUNTIME', 'MANUAL_AGENT', 'SCHEDULED_SWEEP'); + +CREATE TABLE "RuntimeDiagnosticAttempt" ( + "id" TEXT NOT NULL, + "requestId" TEXT NOT NULL, + "workspaceId" TEXT NOT NULL, + "runtimeId" TEXT NOT NULL, + "kind" "RuntimeDiagnosticKind" NOT NULL, + "executor" "RuntimeDiagnosticExecutor" NOT NULL DEFAULT 'WORKER', + "trigger" "RuntimeDiagnosticTrigger" NOT NULL, + "requestedById" TEXT, + "attempted" BOOLEAN NOT NULL DEFAULT false, + "reachable" BOOLEAN, + "selfTestStatus" "RuntimeSelfTestStatus", + "detail" TEXT, + "durationMs" INTEGER, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "completedAt" TIMESTAMP(3), + CONSTRAINT "RuntimeDiagnosticAttempt_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "RuntimeDiagnosticAttempt_requestId_key" + ON "RuntimeDiagnosticAttempt"("requestId"); +CREATE INDEX "RuntimeDiagnosticAttempt_workspaceId_runtimeId_createdAt_idx" + ON "RuntimeDiagnosticAttempt"("workspaceId", "runtimeId", "createdAt" DESC); +CREATE INDEX "RuntimeDiagnosticAttempt_runtimeId_kind_createdAt_idx" + ON "RuntimeDiagnosticAttempt"("runtimeId", "kind", "createdAt" DESC); + +ALTER TABLE "RuntimeDiagnosticAttempt" + ADD CONSTRAINT "RuntimeDiagnosticAttempt_runtimeId_fkey" + FOREIGN KEY ("runtimeId") REFERENCES "Runtime"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260825211000_designated_break_glass/migration.sql b/prisma/migrations/20260825211000_designated_break_glass/migration.sql new file mode 100644 index 00000000..a562618e --- /dev/null +++ b/prisma/migrations/20260825211000_designated_break_glass/migration.sql @@ -0,0 +1,7 @@ +ALTER TABLE "InstanceAuthPolicy" +ADD COLUMN "breakGlassUserId" TEXT; + +ALTER TABLE "InstanceAuthPolicy" +ADD CONSTRAINT "InstanceAuthPolicy_breakGlassUserId_fkey" +FOREIGN KEY ("breakGlassUserId") REFERENCES "User"("id") +ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 51863007..78c0cb81 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -563,6 +563,21 @@ enum RuntimeSelfTestStatus { UNSUPPORTED } +enum RuntimeDiagnosticKind { + PROBE + SELF_TEST +} + +enum RuntimeDiagnosticExecutor { + WORKER +} + +enum RuntimeDiagnosticTrigger { + MANUAL_RUNTIME + MANUAL_AGENT + SCHEDULED_SWEEP +} + enum ChatSessionClass { INTERACTIVE BACKGROUND @@ -940,6 +955,7 @@ model User { avatar UserAvatar? instanceAuditActions InstanceAuditLog[] @relation("InstanceAuditActor") instanceAuditTargets InstanceAuditLog[] @relation("InstanceAuditTarget") + breakGlassPolicies InstanceAuthPolicy[] @relation("InstanceBreakGlassUser") memberships Membership[] invitationsSent WorkspaceInvitation[] @relation("InvitationSender") invitationsAccepted WorkspaceInvitation[] @relation("InvitationAcceptor") @@ -1054,6 +1070,10 @@ model InstanceAuthPolicy { mode AuthenticationMode @default(HYBRID) registrationMode RegistrationMode @default(INVITE_ONLY) breakGlassCredentialsEnabled Boolean @default(true) + /// Active instance administrator designated to receive the environment- + /// backed recovery credential. Keeping this separate from the human's OIDC + /// identity makes emergency access explicit and lifecycle-safe. + breakGlassUserId String? autoRedirectProviderId String? passwordMinLength Int @default(12) passwordResetTtlMinutes Int @default(30) @@ -1063,6 +1083,7 @@ model InstanceAuthPolicy { updatedAt DateTime @updatedAt autoRedirectProvider SsoProvider? @relation(fields: [autoRedirectProviderId], references: [id], onDelete: SetNull) + breakGlassUser User? @relation("InstanceBreakGlassUser", fields: [breakGlassUserId], references: [id], onDelete: Restrict) } /// Optional local sign-in method attached to the same canonical User used by @@ -2591,12 +2612,40 @@ model Runtime { githubApp GithubApp? @relation(fields: [githubAppId], references: [id], onDelete: SetNull) connectorSessions ConnectorSession[] agentConnections AgentConnection[] + diagnosticAttempts RuntimeDiagnosticAttempt[] @@index([workspaceId, archivedAt]) @@index([ownerId]) @@index([githubAppId]) } +/// Bounded diagnostic history for managed runtimes. Diagnostics execute in +/// the worker, which is also the execution plane that dispatches /v1/runs. +/// Keeping the request trigger and executor makes an operator-requested check +/// distinguishable from the periodic sweep that may follow it. +model RuntimeDiagnosticAttempt { + id String @id @default(cuid()) + requestId String @unique + workspaceId String + runtimeId String + kind RuntimeDiagnosticKind + executor RuntimeDiagnosticExecutor @default(WORKER) + trigger RuntimeDiagnosticTrigger + requestedById String? + attempted Boolean @default(false) + reachable Boolean? + selfTestStatus RuntimeSelfTestStatus? + detail String? + durationMs Int? + createdAt DateTime @default(now()) + completedAt DateTime? + + runtime Runtime @relation(fields: [runtimeId], references: [id], onDelete: Cascade) + + @@index([workspaceId, runtimeId, createdAt(sort: Desc)]) + @@index([runtimeId, kind, createdAt(sort: Desc)]) +} + /// A named secret injected into a runtime's environment at provision time /// (e.g. GH_TOKEN, GIT_SSH_KEY, a deploy credential). The value is encrypted /// at rest with AES-256-GCM (see `src/server/crypto.ts`) and is NEVER returned diff --git a/scripts/package-hermes-helpers.sh b/scripts/package-hermes-helpers.sh new file mode 100755 index 00000000..cdc90724 --- /dev/null +++ b/scripts/package-hermes-helpers.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +TAG=${1:-$(node -p "'v' + require('$ROOT/package.json').version")} +OUT=${2:-${OUT:-$ROOT/dist/hermes-helpers}} +mkdir -p "$OUT" +rm -f "$OUT"/forge-presence-*.tar.gz "$OUT"/forge-provision-*.tar.gz "$OUT/SHA256SUMS" + +for helper in forge-presence forge-provision; do + archive="$OUT/${helper}-${TAG}.tar.gz" + tar -C "$ROOT/integrations/hermes" -czf "$archive" "$helper" +done + +( + cd "$OUT" + sha256sum forge-presence-*.tar.gz forge-provision-*.tar.gz > SHA256SUMS +) + +printf 'Packaged Hermes helpers in %s\n' "$OUT" diff --git a/src/app/(app)/settings/security/page.tsx b/src/app/(app)/settings/security/page.tsx index 30720713..37da409e 100644 --- a/src/app/(app)/settings/security/page.tsx +++ b/src/app/(app)/settings/security/page.tsx @@ -46,6 +46,7 @@ export default function SecuritySettingsPage() { const [confirmPassword, setConfirmPassword] = useState(""); const [removeOpen, setRemoveOpen] = useState(false); const [removePassword, setRemovePassword] = useState(""); + const [linkingProviderId, setLinkingProviderId] = useState(null); function signedOut(message: string) { toast.success(message); @@ -104,6 +105,29 @@ export default function SecuritySettingsPage() { }); } + async function linkProvider(provider: { + id: string; + type: string; + name: string; + allowLinking: boolean; + }) { + const approved = await confirm({ + title: `Link ${provider.name} to this account?`, + description: `You are signed in as ${data?.user.email ?? "this Forge user"}. Continuing attaches the external identity to this same Forge account. It does not create an integration connection or grant repository access.`, + primaryLabel: `Continue to ${provider.name}`, + }); + if (!approved) return; + const providerId = provider.type === "OIDC" ? provider.id : provider.type.toLowerCase(); + const formData = new FormData(); + formData.set("providerId", providerId); + setLinkingProviderId(providerId); + try { + await linkIdentityAction(formData); + } finally { + setLinkingProviderId(null); + } + } + if (security.isLoading) { return ( <> @@ -328,12 +352,26 @@ export default function SecuritySettingsPage() { const providerId = provider.type === "OIDC" ? provider.id : provider.type.toLowerCase(); return ( -
- - -
+ ); })} diff --git a/src/app/(app)/w/[slug]/agents/[profileKey]/page.tsx b/src/app/(app)/w/[slug]/agents/[profileKey]/page.tsx index 446b4261..2b80bd32 100644 --- a/src/app/(app)/w/[slug]/agents/[profileKey]/page.tsx +++ b/src/app/(app)/w/[slug]/agents/[profileKey]/page.tsx @@ -40,6 +40,7 @@ import { trpc } from "@/lib/trpc"; import { useRealtime } from "@/hooks/use-realtime"; import { useWorkspace } from "@/hooks/use-workspace"; import { cn, formatIssueId, relativeTime } from "@/lib/utils"; +import { runtimeDisplayIdentity } from "@/lib/transport-display"; import type { AppRouter } from "@/server/routers/_app"; type RouterOutputs = inferRouterOutputs; @@ -1297,17 +1298,6 @@ function CrewsAndWorkSection({ agentId, slug }: { agentId: string; slug: string ); } -const ADAPTER_LABEL: Record = { - hermes: "Hermes gateway", - "codex-app-server": "Codex app server", - "local-daemon": "Local daemon", - "custom-http": "Custom webhook", - "claude-code": "Claude Code", - "claude-desktop": "Claude Desktop", - codex: "Codex CLI", - acp: "ACP session", -}; - function ConnectionCard({ agent }: { agent: AgentRow }) { const ws = useWorkspace(); const runtime = agent.runtime; @@ -1339,9 +1329,10 @@ function ConnectionCard({ agent }: { agent: AgentRow }) { : runtime?.kind === "CLOUD" ? Cloud : Server; - const adapterLabel = runtime?.adapterKey - ? (ADAPTER_LABEL[runtime.adapterKey] ?? runtime.adapterKey) - : null; + const runtimeIdentity = runtimeDisplayIdentity({ + adapterKey: runtime?.adapterKey, + kind: runtime?.kind, + }); const disabled = Boolean(runtime?.disabledAt); return ( @@ -1389,7 +1380,7 @@ function ConnectionCard({ agent }: { agent: AgentRow }) { {runtime.name} - {adapterLabel ?? runtime.kind.toLowerCase().replace("_", " ")} + {runtimeIdentity.runtimeLabel} · {runtimeIdentity.transportLabel} diff --git a/src/app/(app)/w/[slug]/settings/runtimes/[id]/page.tsx b/src/app/(app)/w/[slug]/settings/runtimes/[id]/page.tsx index fcd7e9f1..3a17c969 100644 --- a/src/app/(app)/w/[slug]/settings/runtimes/[id]/page.tsx +++ b/src/app/(app)/w/[slug]/settings/runtimes/[id]/page.tsx @@ -3,14 +3,7 @@ import { useEffect, useState } from "react"; import Link from "next/link"; import { notFound, useParams } from "next/navigation"; import { toast } from "sonner"; -import { - Bot, - ChevronLeft, - Cloud, - Globe, - HardDrive, - Server, -} from "lucide-react"; +import { Bot, ChevronLeft, Cloud, Globe, HardDrive, Server } from "lucide-react"; import type { RuntimeKind } from "@prisma/client"; import { Topbar } from "@/components/topbar"; import { Button } from "@/components/ui/button"; @@ -31,12 +24,7 @@ import { import { useWorkspace } from "@/hooks/use-workspace"; import { trpc } from "@/lib/trpc"; import { cn, relativeTime } from "@/lib/utils"; - -const KIND_LABEL: Record = { - LOCAL_DAEMON: "local daemon", - REMOTE_HTTP: "remote webhook", - CLOUD: "cloud", -}; +import { runtimeDisplayIdentity } from "@/lib/transport-display"; const KIND_ICON: Record = { LOCAL_DAEMON: HardDrive, @@ -45,9 +33,7 @@ const KIND_ICON: Record = { }; function agentPresenceText(input: Date | string | null | undefined): string { - return input - ? `presence heartbeat ${relativeTime(input)} ago` - : "no presence heartbeat yet"; + return input ? `presence heartbeat ${relativeTime(input)} ago` : "no presence heartbeat yet"; } /** @@ -64,10 +50,7 @@ export default function RuntimeDetailPage() { const id = params?.id ?? ""; const utils = trpc.useUtils(); - const { data: runtime, isLoading } = trpc.runtime.byId.useQuery( - { id }, - { enabled: !!id }, - ); + const { data: runtime, isLoading } = trpc.runtime.byId.useQuery({ id }, { enabled: !!id }); const [editOpen, setEditOpen] = useState(false); const [editName, setEditName] = useState(""); @@ -150,8 +133,7 @@ export default function RuntimeDetailPage() { if (!isLoading && !runtime) notFound(); const KindIcon = runtime ? KIND_ICON[runtime.kind] : Server; - const showConnectPane = - runtime?.kind === "LOCAL_DAEMON" && runtime.agents.length === 0; + const showConnectPane = runtime?.kind === "LOCAL_DAEMON" && runtime.agents.length === 0; return ( <> @@ -169,7 +151,11 @@ export default function RuntimeDetailPage() { {runtime?.name ?? "Runtime"} } - subtitle={runtime ? KIND_LABEL[runtime.kind] : undefined} + subtitle={ + runtime + ? `${runtimeDisplayIdentity({ adapterKey: runtime.adapterKey, kind: runtime.kind }).runtimeLabel} · ${runtimeDisplayIdentity({ adapterKey: runtime.adapterKey, kind: runtime.kind }).transportLabel}` + : undefined + } actions={ runtime && ( <> @@ -190,11 +176,7 @@ export default function RuntimeDetailPage() { > {runSelfTest.isPending ? "Self-testing…" : "Run self-test"} - {!runtime.archivedAt && ( @@ -217,11 +199,7 @@ export default function RuntimeDetailPage() { Unarchive ) : ( - )} @@ -236,17 +214,12 @@ export default function RuntimeDetailPage() { ) : ( <> {runtime.archivedAt && ( -
-
- This runtime is archived. -
+
+
This runtime is archived.
- It is hidden from the active list and rejects - heartbeats. Use{" "} - - Unarchive - {" "} - in the toolbar to restore it. + It is hidden from the active list and rejects heartbeats. Use{" "} + Unarchive in the toolbar to + restore it.
)} @@ -258,7 +231,7 @@ export default function RuntimeDetailPage() {

{runtime.name}

- + {runtime.providersAvailable.map((p) => ( @@ -270,7 +243,7 @@ export default function RuntimeDetailPage() { ))}
-
+
id {runtime.id} @@ -279,9 +252,7 @@ export default function RuntimeDetailPage() { {runtime.connectedAt && ( <> · - - connected {relativeTime(runtime.connectedAt)} ago - + connected {relativeTime(runtime.connectedAt)} ago )} {runtime.owner && ( @@ -298,7 +269,7 @@ export default function RuntimeDetailPage() { )}
-
+
Reason @@ -351,9 +322,7 @@ export default function RuntimeDetailPage() { {runtime.endpoint && (
endpoint{" "} - - {runtime.endpoint} - + {runtime.endpoint}
)} +
+ + {runtime.diagnosticAttempts.length ? ( + runtime.diagnosticAttempts.map((attempt) => { + const passed = + attempt.reachable === true || attempt.selfTestStatus === "PASSED"; + const failed = + attempt.reachable === false || attempt.selfTestStatus === "FAILED"; + return ( +
+ +
+
+ + {attempt.kind === "SELF_TEST" + ? "Runtime self-test" + : "Handshake probe"} + + + {attempt.executor.toLowerCase()} ·{" "} + {attempt.trigger.toLowerCase().replaceAll("_", " ")} + + + {relativeTime(attempt.completedAt ?? attempt.createdAt)} + +
+
+ {attempt.detail ?? "Waiting for the worker."} +
+
+
+ ); + }) + ) : ( +
+ No worker diagnostic has been recorded yet. +
+ )} +
+
+
Agents on this runtime - + {runtime.agents.length} } > {runtime.agents.length === 0 ? ( - + No agents are pointing at this runtime yet. {runtime.kind === "LOCAL_DAEMON" && ( <> {" "} - Once the daemon connects, attach an agent by setting - its runtimeId to{" "} - + Once the daemon connects, attach an agent by setting its{" "} + runtimeId to{" "} + {runtime.id} . @@ -400,10 +425,7 @@ export default function RuntimeDetailPage() {
    {runtime.agents.map((a) => ( -
  • +
  • {a.avatar ? ( {a.avatar} @@ -421,16 +443,12 @@ export default function RuntimeDetailPage() { > {a.name} - - @{a.profileKey} - + @{a.profileKey} {a.provider} - {a.runtimeMode === "PERSISTENT" - ? "persistent" - : "session"} + {a.runtimeMode === "PERSISTENT" ? "persistent" : "session"}
-
+
{agentPresenceText(a.lastHeartbeatAt)}
@@ -480,8 +498,8 @@ export default function RuntimeDetailPage() { hint={ <> The Forge CLI registers this runtime on{" "} - forge daemon start, - then opens an SSE subscription scoped to it. + forge daemon start, then opens an SSE + subscription scoped to it. } > @@ -491,10 +509,9 @@ export default function RuntimeDetailPage() { code={`forge login --workspace ${ws.slug}\nforge daemon start`} />
- Once connected, the daemon heartbeats every 60s and - this card is replaced by the agent list above. The SSE - channel id is{" "} - + Once connected, the daemon heartbeats every 60s and this card is replaced by + the agent list above. The SSE channel id is{" "} + runtime:{runtime.id} . @@ -671,7 +688,8 @@ function runtimeSelfTestFixDraft(runtime: RuntimeDetailForFix): string { ].join("\n"); } -function KindBadge({ kind }: { kind: RuntimeKind }) { +function KindBadge({ kind, adapterKey }: { kind: RuntimeKind; adapterKey?: string | null }) { + const identity = runtimeDisplayIdentity({ adapterKey, kind }); return ( - {KIND_LABEL[kind]} + {identity.runtimeLabel} · {identity.transportLabel} ); } @@ -737,7 +755,7 @@ function RuntimeEnvironmentSection({
{summary.label}
-
+
{summary.detail} {reported}.
@@ -777,7 +795,7 @@ function RuntimeEnvironmentSection({ ))}
) : ( -
+
Run Test connection or restart the runtime with a bridge/daemon that calls{" "} runtimes.reportInfo {adapterKey === "codex-app-server" @@ -801,7 +819,7 @@ docker compose logs -f`; >
-
+
Bridge directory{" "} ~/docker/codex-bridge @@ -813,8 +831,8 @@ docker compose logs -f`;
- Mounts Codex auth read-only, writes token refreshes to a named volume, and - exposes only the scoped /work workspace. + Mounts Codex auth read-only, writes token refreshes to a named volume, and exposes + only the scoped /work workspace.
= { - LOCAL_DAEMON: "local daemon", - REMOTE_HTTP: "remote webhook", - CLOUD: "cloud", -}; - const KIND_ICON: Record = { LOCAL_DAEMON: HardDrive, REMOTE_HTTP: Globe, CLOUD: Cloud, }; -/** Display label for an adapter key (mirrors src/server/runtimes/adapters.ts). */ -const ADAPTER_LABEL: Record = { - hermes: "Hermes · managed", - "local-daemon": "Local daemon · managed", - "custom-http": "Custom · webhook", - "claude-code": "Claude Code", - "claude-desktop": "Claude Desktop", - codex: "Codex", - "codex-app-server": "Codex app server · managed", -}; - /** Which connection tier a transport belongs to (see providers-and-transports.md). */ function tierForTransport(transport: string): { n: 1 | 2 | 3; label: string } { switch (transport) { @@ -315,16 +299,7 @@ export default function RuntimesPage() { > {rt.name} - {rt.adapterKey && ADAPTER_LABEL[rt.adapterKey] ? ( - - {ADAPTER_LABEL[rt.adapterKey]} - - ) : ( - - )} + {isArchived && ( - {KIND_LABEL[kind]} + {identity.runtimeLabel} · {identity.transportLabel} ); } diff --git a/src/app/(auth)/signin/local/page.tsx b/src/app/(auth)/signin/local/page.tsx index 6b4d1381..972978f3 100644 --- a/src/app/(auth)/signin/local/page.tsx +++ b/src/app/(auth)/signin/local/page.tsx @@ -13,9 +13,14 @@ import { getEnabledSsoRows } from "@/server/sso"; export default async function LocalSignInPage({ searchParams, }: { - searchParams: Promise<{ error?: string; callbackUrl?: string; notice?: string }>; + searchParams: Promise<{ + error?: string; + callbackUrl?: string; + notice?: string; + breakGlass?: string; + }>; }) { - const { error, callbackUrl, notice } = await searchParams; + const { error, callbackUrl, notice, breakGlass } = await searchParams; const target = safeAuthCallbackUrl(callbackUrl); const [policy, providers] = await Promise.all([getInstanceAuthPolicy(), getEnabledSsoRows()]); const presentation = deriveAuthPresentation(policy, providers); @@ -60,7 +65,9 @@ export default async function LocalSignInPage({ ); } - const isBreakGlass = !presentation.localCredentialsEnabled; + const isBreakGlass = + presentation.breakGlassCredentialsEnabled && + (!presentation.localCredentialsEnabled || breakGlass === "1"); return ( )} - {!presentation.localCredentialsEnabled && - presentation.breakGlassCredentialsEnabled && ( - - Instance administrator recovery - - )} + {presentation.breakGlassCredentialsEnabled && ( + + Instance administrator recovery + + )}
)}
diff --git a/src/components/admin-shell/auth-policy-settings.tsx b/src/components/admin-shell/auth-policy-settings.tsx index 0ac74fde..aef10adf 100644 --- a/src/components/admin-shell/auth-policy-settings.tsx +++ b/src/components/admin-shell/auth-policy-settings.tsx @@ -25,6 +25,7 @@ export function AuthPolicySettings() { const [mode, setMode] = useState("HYBRID"); const [registrationMode, setRegistrationMode] = useState("INVITE_ONLY"); const [breakGlass, setBreakGlass] = useState(true); + const [breakGlassUserId, setBreakGlassUserId] = useState(""); const [autoRedirectProviderId, setAutoRedirectProviderId] = useState(""); const [passwordMinLength, setPasswordMinLength] = useState(12); const [passwordResetTtlMinutes, setPasswordResetTtlMinutes] = useState(30); @@ -37,6 +38,7 @@ export function AuthPolicySettings() { setMode(policy.mode); setRegistrationMode(policy.registrationMode); setBreakGlass(policy.breakGlassCredentialsEnabled); + setBreakGlassUserId(policy.breakGlassUserId ?? ""); setAutoRedirectProviderId(policy.autoRedirectProviderId ?? ""); setPasswordMinLength(policy.passwordMinLength); setPasswordResetTtlMinutes(policy.passwordResetTtlMinutes); @@ -69,6 +71,7 @@ export function AuthPolicySettings() { mode, registrationMode, breakGlassCredentialsEnabled: breakGlass, + breakGlassUserId: breakGlass ? breakGlassUserId || null : null, autoRedirectProviderId: autoRedirectProviderId || null, passwordMinLength, passwordResetTtlMinutes, @@ -158,12 +161,49 @@ export function AuthPolicySettings() { + {breakGlass && ( + + )} + {breakGlass && query.data && !query.data.breakGlassConfigured && (
Configure ADMIN_EMAIL and ADMIN_PASSWORD before enabling break glass.
)} + {breakGlass && query.data?.breakGlassConfigured && !query.data.breakGlassReady && ( +
+ + Select the active instance administrator whose email matches ADMIN_EMAIL before saving. +
+ )} + {breakGlass && query.data?.breakGlassReady && query.data.breakGlassPrincipal && ( +
+ + Recovery is ready for{" "} + {query.data.breakGlassPrincipal.name ?? query.data.breakGlassPrincipal.email}. +
+ )}
diff --git a/src/components/chat/chat-status-rail.tsx b/src/components/chat/chat-status-rail.tsx index 8dacb13a..0d2a9cab 100644 --- a/src/components/chat/chat-status-rail.tsx +++ b/src/components/chat/chat-status-rail.tsx @@ -27,7 +27,7 @@ import { TransportChip } from "@/components/agents/transport-chip"; import { AgentPresenceDot } from "@/components/agent-presence-dot"; import { StatusDot } from "@/components/ui/status-dot"; import { ProjectChip } from "@/components/project-chip"; -import { presenceAvailability } from "@/lib/transport-display"; +import { presenceAvailability, runtimeDisplayIdentity } from "@/lib/transport-display"; import { useMaybeWorkspace } from "@/hooks/use-workspace"; import { buildChatDiagnosticReport } from "@/lib/chat-diagnostic-report"; import { useRealtime } from "@/hooks/use-realtime"; @@ -76,19 +76,6 @@ function runtimeHealthTextClass(tone: string | null | undefined) { return "text-muted-foreground"; } -function runtimeKindLabel(kind: string | null | undefined): string { - switch (kind) { - case "LOCAL_DAEMON": - return "local daemon"; - case "REMOTE_HTTP": - return "remote http"; - case "CLOUD": - return "cloud"; - default: - return (kind ?? "runtime").toLowerCase(); - } -} - function toneClass(tone: string | null | undefined): string { if (tone === "success") return "border-emerald-500/20 bg-emerald-500/5"; if (tone === "danger") return "border-danger/30 bg-danger/10"; @@ -301,7 +288,13 @@ export function ChatStatusRail({ const engine = readiness?.mode ?? null; const effectiveProvider = readiness?.provider ?? agent?.provider ?? null; const runtime = agent?.runtime ?? null; + const runtimeIdentity = runtimeDisplayIdentity({ + adapterKey: runtime?.adapterKey, + kind: runtime?.kind, + }); const runtimeHealth = runtime?.health ?? null; + const readinessRepeatsProbeFailure = + readiness?.reason === "runtime-probe-failed" && Boolean(runtimeHealth); const connectionOk = readiness ? readiness.ready && (!runtimeHealth || runtimeHealth.tone === "success" || runtimeHealth.tone === "muted") @@ -318,7 +311,7 @@ export function ChatStatusRail({ const deliveryBad = hasOpenTurn && diagnostics?.lastDelivery?.status === "FAILED"; const connectorBad = Boolean( diagnostics?.connectorSession && - ["ERROR", "DISCONNECTED"].includes(diagnostics.connectorSession.lifecycle), + ["ERROR", "DISCONNECTED"].includes(diagnostics.connectorSession.lifecycle), ); const streamBad = Boolean( diagnostics?.lastAgentStreamError || @@ -577,7 +570,7 @@ export function ChatStatusRail({ > {runtime.name} {" "} - · {runtimeKindLabel(runtime.kind)} + · {runtimeIdentity.runtimeLabel} · {runtimeIdentity.transportLabel} ) : ( no managed runtime attached @@ -600,7 +593,7 @@ export function ChatStatusRail({
)} - {readiness && !readiness.ready && ( + {readiness && !readiness.ready && !readinessRepeatsProbeFailure && (
{readiness.hint} @@ -672,8 +665,8 @@ export function ChatStatusRail({ mapping · {diagnostics.connectorSession.id}
- protocol · {diagnostics.connectorSession.protocolVersion ?? "not negotiated"} · class ·{" "} - {diagnostics.connectorSession.sessionClass.toLowerCase()} + protocol · {diagnostics.connectorSession.protocolVersion ?? "not negotiated"} · class + · {diagnostics.connectorSession.sessionClass.toLowerCase()}
ownership · {diagnostics.connectorSession.ownership.toLowerCase()} · retries ·{" "} @@ -694,7 +687,10 @@ export function ChatStatusRail({ .filter(([, enabled]) => enabled === true) .slice(0, 8) .map(([capability]) => ( - + {capability} ))} diff --git a/src/components/mission-control/agents-tab.tsx b/src/components/mission-control/agents-tab.tsx index dba77af8..60aa003c 100644 --- a/src/components/mission-control/agents-tab.tsx +++ b/src/components/mission-control/agents-tab.tsx @@ -16,7 +16,7 @@ import { trpc } from "@/lib/trpc"; import type { AppRouter } from "@/server/routers/_app"; import { cn } from "@/lib/utils"; import { TransportChip } from "@/components/agents/transport-chip"; -import { presenceAvailability } from "@/lib/transport-display"; +import { presenceAvailability, runtimeDisplayIdentity } from "@/lib/transport-display"; /** * Agents tab. Roster of workspace agents with status pill, runtime mode @@ -75,9 +75,7 @@ export function AgentsTab({ slug }: { slug: string }) { loadByAgent.set(run.agentId, (loadByAgent.get(run.agentId) ?? 0) + 1); } const costByAgent = new Map((costStats?.byAgent ?? []).map((c) => [c.agentId, c])); - const complianceByAgent = new Map( - (compliance?.agents ?? []).map((row) => [row.agentId, row]), - ); + const complianceByAgent = new Map((compliance?.agents ?? []).map((row) => [row.agentId, row])); // Sort: PERSISTENT+ONLINE first, PERSISTENT+BUSY, EPHEMERAL (by lastHeartbeatAt desc), then OFFLINE const sorted = [...(agents ?? [])].sort((a, b) => { @@ -193,6 +191,7 @@ export function AgentsTab({ slug }: { slug: string }) { runtimeId={a.runtime.id} name={a.runtime.name} kind={a.runtime.kind} + adapterKey={a.runtime.adapterKey} heartbeatAt={a.runtime.heartbeatAt} /> )} @@ -278,8 +277,14 @@ function ComplianceChips({ card }: { card: AgentCompliance }) { )} title={title} > - {risk === "ok" ? : } - {risk === "ok" ? "compliant" : `${card.signals.length} signal${card.signals.length === 1 ? "" : "s"}`} + {risk === "ok" ? ( + + ) : ( + + )} + {risk === "ok" + ? "compliant" + : `${card.signals.length} signal${card.signals.length === 1 ? "" : "s"}`} e.stopPropagation()} - title={`Runtime · ${kindLabel(kind)} · ${name} · ${presence}`} + title={`${identity.runtimeLabel} · ${identity.transportLabel} · ${name} · ${presence}`} className="inline-flex max-w-[10rem] items-center gap-1 rounded border border-border bg-subtle/40 px-1 py-0 text-[0.5625rem] text-muted-foreground hover:border-ember/40 hover:text-foreground" > @@ -399,19 +407,6 @@ function RuntimeChip({ ); } -function kindLabel(kind: RuntimeKind): string { - switch (kind) { - case "LOCAL_DAEMON": - return "local daemon"; - case "REMOTE_HTTP": - return "remote webhook"; - case "CLOUD": - return "cloud"; - default: - return String(kind); - } -} - function PresenceDot({ status, availability }: { status: string; availability?: string }) { const onDemand = availability === "on-demand"; const colorClass = onDemand diff --git a/src/lib/transport-display.ts b/src/lib/transport-display.ts index 8fc95cbd..ad1d7772 100644 --- a/src/lib/transport-display.ts +++ b/src/lib/transport-display.ts @@ -8,6 +8,45 @@ */ export type TransportMode = "sessions" | "runs" | "completions" | "dispatch" | "none"; +export type RuntimeDisplayIdentity = { + runtimeLabel: string; + transportLabel: string; +}; + +/** + * Client-safe runtime identity. `RuntimeKind.REMOTE_HTTP` is a storage and + * network shape, not operator-facing product identity: a Hermes Runs host is + * not a webhook. Prefer the adapter/transport and fall back to the old kind + * only for legacy rows whose adapter has not been reconciled. + */ +export function runtimeDisplayIdentity(input: { + adapterKey?: string | null; + kind?: string | null; +}): RuntimeDisplayIdentity { + switch (input.adapterKey) { + case "hermes": + return { runtimeLabel: "Hermes managed runtime", transportLabel: "Runs API" }; + case "codex-app-server": + return { runtimeLabel: "Codex managed runtime", transportLabel: "App server" }; + case "local-daemon": + return { runtimeLabel: "Forge local daemon", transportLabel: "Local daemon" }; + case "custom-http": + return { runtimeLabel: "Custom webhook runtime", transportLabel: "Webhook" }; + case "acp": + return { runtimeLabel: "ACP session", transportLabel: "ACP" }; + } + switch (input.kind) { + case "LOCAL_DAEMON": + return { runtimeLabel: "Local runtime", transportLabel: "Local daemon" }; + case "CLOUD": + return { runtimeLabel: "Cloud runtime", transportLabel: "Cloud" }; + case "REMOTE_HTTP": + return { runtimeLabel: "Remote runtime", transportLabel: "HTTP" }; + default: + return { runtimeLabel: "Runtime", transportLabel: "Unknown transport" }; + } +} + /** Tailwind classes for the chip, by mode. Uses warm-earthy / semantic tokens. */ export function transportTone(mode: TransportMode): string { switch (mode) { diff --git a/src/server/actions/identity-linking.ts b/src/server/actions/identity-linking.ts index cd2595d0..413bacaa 100644 --- a/src/server/actions/identity-linking.ts +++ b/src/server/actions/identity-linking.ts @@ -2,7 +2,7 @@ import { redirect } from "next/navigation"; import { auth, signIn } from "@/server/auth"; -import { getEnabledSsoRows, providerIdFor } from "@/server/sso"; +import { bustSsoCache, getEnabledSsoRows, providerIdFor } from "@/server/sso"; /** * Start an explicit provider-link flow from an authenticated account. Auth.js @@ -14,6 +14,10 @@ export async function linkIdentityAction(formData: FormData): Promise { const session = await auth(); if (!session?.user?.id) redirect("/signin?manual=1"); const requested = String(formData.get("providerId") ?? ""); + // The chooser is rendered from a direct account-security query while Auth.js + // uses a short provider cache. Refresh before resolving the request so a + // just-enabled provider is usable immediately. + bustSsoCache(); const provider = (await getEnabledSsoRows()).find( (candidate) => providerIdFor(candidate) === requested, ); diff --git a/src/server/auth.ts b/src/server/auth.ts index 27995607..59874183 100644 --- a/src/server/auth.ts +++ b/src/server/auth.ts @@ -13,6 +13,7 @@ import { db } from "@/server/db"; import { decryptSecret } from "@/server/crypto"; import { getEnabledSsoRows, providerIdFor } from "@/server/sso"; import { getInstanceAuthPolicy } from "@/server/services/auth-policy"; +import { genericOidcProviderConfig } from "@/server/services/sso-provider-config"; import { hashPassword, needsPasswordRehash, @@ -86,6 +87,11 @@ async function ensureBootstrapOperator(email: string): Promise new Date()) @@ -186,6 +209,18 @@ const credentialsProvider = Credentials({ where: { id: user.id }, data: { lastLoginAt: new Date(), normalizedEmail: email }, }); + if (bootstrapMatch) { + await db.instanceAuditLog.create({ + data: { + actorId: user.id, + targetUserId: user.id, + action: "BREAK_GLASS_SIGN_IN", + metadata: { designated: Boolean(policy.breakGlassUserId) }, + ipAddress: requestIp(request), + userAgent: request.headers.get("user-agent"), + }, + }); + } return { id: user.id, email: user.email, name: user.name, image: user.image }; }, }); @@ -204,22 +239,29 @@ async function ssoProvidersFromDb(): Promise { console.error(`[sso] skipping provider ${row.id} (${row.type}): bad secret`, error); continue; } - const common = { allowDangerousEmailAccountLinking: row.allowLinking }; if (row.type === "OIDC" && row.issuer) { - providers.push({ - id: providerIdFor(row), - name: row.name, - type: "oidc", - issuer: row.issuer, - clientId: row.clientId, - clientSecret, - ...common, - ...(row.scopes ? { authorization: { params: { scope: row.scopes } } } : {}), - }); + providers.push( + genericOidcProviderConfig( + { ...row, id: providerIdFor(row), issuer: row.issuer }, + clientSecret, + ), + ); } else if (row.type === "GITHUB") { - providers.push(GitHub({ clientId: row.clientId, clientSecret, ...common })); + providers.push( + GitHub({ + clientId: row.clientId, + clientSecret, + allowDangerousEmailAccountLinking: row.allowLinking, + }), + ); } else if (row.type === "GOOGLE") { - providers.push(Google({ clientId: row.clientId, clientSecret, ...common })); + providers.push( + Google({ + clientId: row.clientId, + clientSecret, + allowDangerousEmailAccountLinking: row.allowLinking, + }), + ); } } return providers; diff --git a/src/server/queues.ts b/src/server/queues.ts index 1513a030..ced09fa2 100644 --- a/src/server/queues.ts +++ b/src/server/queues.ts @@ -16,7 +16,7 @@ import { Queue } from "bullmq"; * call shape while opening Redis only when queue methods are actually used. */ -type QueueName = "webhooks" | "maintenance"; +type QueueName = "webhooks" | "maintenance" | "runtime-diagnostics"; type QueueCache = Partial>; const connection = { @@ -49,3 +49,5 @@ function lazyQueue(name: QueueName): Queue { export const webhookQueue = lazyQueue("webhooks"); export const maintenanceQueue = lazyQueue("maintenance"); + +export const runtimeDiagnosticQueue = lazyQueue("runtime-diagnostics"); diff --git a/src/server/routers/__tests__/identity-policy.test.ts b/src/server/routers/__tests__/identity-policy.test.ts index 15da3075..64827f68 100644 --- a/src/server/routers/__tests__/identity-policy.test.ts +++ b/src/server/routers/__tests__/identity-policy.test.ts @@ -2,6 +2,7 @@ import { InstanceRole } from "@prisma/client"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { ssoRouter } from "@/server/routers/sso"; import { userRouter } from "@/server/routers/user"; +import { instanceAdminRouter } from "@/server/routers/instance-admin"; import { buildContext, createWorkspaceFixture, @@ -15,14 +16,13 @@ const fixtures: TestFixture[] = []; afterEach(async () => { const db = getPrisma(); - while (fixtures.length) await fixtures.pop()!.cleanup(); - await db.ssoProvider.deleteMany({ where: { name: { startsWith: "Identity policy test" } } }); await db.instanceAuthPolicy.upsert({ where: { id: "default" }, update: { mode: "HYBRID", registrationMode: "INVITE_ONLY", breakGlassCredentialsEnabled: true, + breakGlassUserId: null, autoRedirectProviderId: null, passwordMinLength: 12, passwordResetTtlMinutes: 30, @@ -31,6 +31,8 @@ afterEach(async () => { }, create: { id: "default" }, }); + while (fixtures.length) await fixtures.pop()!.cleanup(); + await db.ssoProvider.deleteMany({ where: { name: { startsWith: "Identity policy test" } } }); vi.unstubAllEnvs(); }); @@ -50,6 +52,7 @@ const policyInput = { mode: "HYBRID" as const, registrationMode: "INVITE_ONLY" as const, breakGlassCredentialsEnabled: false, + breakGlassUserId: null, autoRedirectProviderId: null, passwordMinLength: 12, passwordResetTtlMinutes: 30, @@ -139,4 +142,57 @@ describe("identity policy guards", () => { /final sign-in method/i, ); }); + + it("designates an active matching administrator and reports recovery readiness", async () => { + const fixture = await adminFixture(); + vi.stubEnv("ADMIN_EMAIL", fixture.user.email); + vi.stubEnv("ADMIN_PASSWORD", "environment recovery password"); + const caller = ssoRouter.createCaller(await buildContext(fixture)); + + await expect( + caller.updatePolicy({ + ...policyInput, + breakGlassCredentialsEnabled: true, + breakGlassUserId: fixture.user.id, + }), + ).resolves.toMatchObject({ + breakGlassCredentialsEnabled: true, + breakGlassUserId: fixture.user.id, + }); + await expect(caller.policy()).resolves.toMatchObject({ + breakGlassConfigured: true, + breakGlassReady: true, + breakGlassPrincipal: { id: fixture.user.id, email: fixture.user.email }, + }); + }); + + it("rejects a mismatched principal and protects the designated recovery administrator", async () => { + const fixture = await adminFixture(); + vi.stubEnv("ADMIN_EMAIL", "recovery@example.test"); + vi.stubEnv("ADMIN_PASSWORD", "environment recovery password"); + const context = await buildContext(fixture); + const sso = ssoRouter.createCaller(context); + + await expect( + sso.updatePolicy({ + ...policyInput, + breakGlassCredentialsEnabled: true, + breakGlassUserId: fixture.user.id, + }), + ).rejects.toThrow(/email matches ADMIN_EMAIL/i); + + vi.stubEnv("ADMIN_EMAIL", fixture.user.email); + await sso.updatePolicy({ + ...policyInput, + breakGlassCredentialsEnabled: true, + breakGlassUserId: fixture.user.id, + }); + const admin = instanceAdminRouter.createCaller(context); + await expect( + admin.setInstanceRole({ userId: fixture.user.id, role: "MEMBER" }), + ).rejects.toThrow(/reassign or disable break-glass/i); + await expect(admin.suspendUser({ userId: fixture.user.id })).rejects.toThrow( + /reassign or disable break-glass/i, + ); + }); }); diff --git a/src/server/routers/__tests__/runtime-dispatch-contract.test.ts b/src/server/routers/__tests__/runtime-dispatch-contract.test.ts index 35498dde..4a0dd4b3 100644 --- a/src/server/routers/__tests__/runtime-dispatch-contract.test.ts +++ b/src/server/routers/__tests__/runtime-dispatch-contract.test.ts @@ -4,8 +4,9 @@ import type { AddressInfo } from "node:net"; import { WebSocketServer } from "ws"; import { runtimeRouter } from "@/server/routers/runtime"; import { getRunsConnectorForAgent } from "@/server/services/dispatch/registry"; +import { executeRuntimeDiagnostic } from "@/server/services/runtime-diagnostics"; import type { RunEvent } from "@/server/services/dispatch/types"; -import { createWorkspaceFixture, buildContext, type TestFixture } from "./helpers"; +import { createWorkspaceFixture, buildContext, getPrisma, type TestFixture } from "./helpers"; /** * API/MCP contract for the runtime + dispatch surface agents drive: @@ -34,6 +35,26 @@ describe("runtime dispatch contract", () => { endpoint: "ws://127.0.0.1:4505", }; + async function throughDiagnosticWorker(request: () => Promise): Promise { + const response = request(); + const prisma = getPrisma(); + let attempt = null; + for (let index = 0; index < 100 && !attempt; index += 1) { + attempt = await prisma.runtimeDiagnosticAttempt.findFirst({ + where: { workspaceId: fixture.workspace.id, completedAt: null }, + orderBy: { createdAt: "desc" }, + }); + if (!attempt) await new Promise((resolve) => setTimeout(resolve, 10)); + } + if (!attempt) throw new Error("Runtime diagnostic was not queued by the router."); + await executeRuntimeDiagnostic({ + requestId: attempt.requestId, + workspaceId: attempt.workspaceId, + runtimeId: attempt.runtimeId, + }); + return response; + } + async function startCodexSelfTestServer( complete: "pass" | "revoked-token", ): Promise<{ endpoint: string; close: () => Promise }> { @@ -49,7 +70,9 @@ describe("runtime dispatch contract", () => { return; } if (msg.method === "thread/start" && msg.id !== undefined) { - socket.send(JSON.stringify({ id: msg.id, result: { thread: { id: "thread-self-test" } } })); + socket.send( + JSON.stringify({ id: msg.id, result: { thread: { id: "thread-self-test" } } }), + ); return; } if (msg.method === "turn/start" && msg.id !== undefined) { @@ -96,7 +119,11 @@ describe("runtime dispatch contract", () => { it("validates + persists codex config and rejects unknown enum values", async () => { const rt = await caller.create({ ...codexInput, - config: { sandboxMode: "workspace-write", approvalPolicy: "on-request", workspaceRoot: "/work" }, + config: { + sandboxMode: "workspace-write", + approvalPolicy: "on-request", + workspaceRoot: "/work", + }, }); expect(rt.config).toMatchObject({ sandboxMode: "workspace-write", @@ -152,7 +179,7 @@ describe("runtime dispatch contract", () => { it("verifyConnection runs a handshake-only probe and persists sanitized diagnostics", async () => { const rt = await caller.create({ ...codexInput, endpoint: "ws://127.0.0.1:1" }); - const verified = await caller.verifyConnection({ id: rt.id }); + const verified = await throughDiagnosticWorker(() => caller.verifyConnection({ id: rt.id })); expect(verified.probe.attempted).toBe(true); expect(verified.probe.reachable).toBe(false); @@ -175,7 +202,7 @@ describe("runtime dispatch contract", () => { providersAvailable: ["CODEX"], }); - const res = await caller.runSelfTest({ id: rt.id }); + const res = await throughDiagnosticWorker(() => caller.runSelfTest({ id: rt.id })); expect(res.result.attempted).toBe(false); expect(res.result.status).toBe("UNSUPPORTED"); @@ -191,7 +218,7 @@ describe("runtime dispatch contract", () => { try { const rt = await caller.create({ ...codexInput, endpoint: server.endpoint }); - const res = await caller.runSelfTest({ id: rt.id }); + const res = await throughDiagnosticWorker(() => caller.runSelfTest({ id: rt.id })); expect(res.result.attempted).toBe(true); expect(res.result.status).toBe("PASSED"); @@ -217,7 +244,7 @@ describe("runtime dispatch contract", () => { secret: "super-secret-token", }); - const res = await caller.runSelfTest({ id: rt.id }); + const res = await throughDiagnosticWorker(() => caller.runSelfTest({ id: rt.id })); expect(res.result.attempted).toBe(true); expect(res.result.status).toBe("FAILED"); @@ -260,7 +287,14 @@ describe("runtime dispatch contract", () => { expect(enabled.disabledAt).toBeNull(); const live = getRunsConnectorForAgent({ provider: "CODEX", - runtime: { adapterKey: "codex-app-server", endpoint: rt.endpoint, secret: null, config: rt.config, disabledAt: null, name: rt.name }, + runtime: { + adapterKey: "codex-app-server", + endpoint: rt.endpoint, + secret: null, + config: rt.config, + disabledAt: null, + name: rt.name, + }, }); expect(live?.kind).toBe("codex-app-server"); }); @@ -269,7 +303,14 @@ describe("runtime dispatch contract", () => { process.env.FORGE_E2E = "1"; const connector = getRunsConnectorForAgent({ provider: "CUSTOM", - runtime: { adapterKey: "mock-runs", endpoint: "mock://e2e", secret: null, config: null, disabledAt: null, name: "Mock" }, + runtime: { + adapterKey: "mock-runs", + endpoint: "mock://e2e", + secret: null, + config: null, + disabledAt: null, + name: "Mock", + }, }); expect(connector?.kind).toBe("mock-runs"); @@ -285,7 +326,14 @@ describe("runtime dispatch contract", () => { process.env.FORGE_E2E = "1"; const connector = getRunsConnectorForAgent({ provider: "CUSTOM", - runtime: { adapterKey: "mock-runs", endpoint: "mock://e2e", secret: null, config: null, disabledAt: null, name: "Mock" }, + runtime: { + adapterKey: "mock-runs", + endpoint: "mock://e2e", + secret: null, + config: null, + disabledAt: null, + name: "Mock", + }, }); const { externalRunId } = await connector!.startRun({ message: "please approve this" }); const events: RunEvent[] = []; diff --git a/src/server/routers/agent.ts b/src/server/routers/agent.ts index be2a7aaa..c497da5f 100644 --- a/src/server/routers/agent.ts +++ b/src/server/routers/agent.ts @@ -9,6 +9,8 @@ import { EventKind, RelationKind, RunEngine, + RuntimeDiagnosticKind, + RuntimeDiagnosticTrigger, type Prisma, type PrismaClient, } from "@prisma/client"; @@ -23,8 +25,12 @@ import type { db as PrismaDb } from "@/server/db"; import { deliverWebhook } from "@/server/services/plugin-runtime"; import { resolveChatReadiness } from "@/server/services/chat-readiness"; import { workspaceChatProviderAvailability } from "@/server/services/ai-providers"; -import { probeRuntime } from "@/server/services/dispatch/runtime-probe"; import { deriveRuntimeHealthStatus } from "@/server/services/runtime-status"; +import { + diagnosticResult, + requestRuntimeDiagnostic, + waitForRuntimeDiagnostic, +} from "@/server/services/runtime-diagnostics"; import { agentAvailabilityModel } from "@/lib/transport-display"; import { STALE_RUN_MS } from "@/server/services/agent-presence"; import { agentIdSchema } from "@/server/validators"; @@ -297,6 +303,7 @@ export const agentRouter = router({ engagementMode: true, runtime: { select: { + id: true, adapterKey: true, endpoint: true, secret: true, @@ -364,6 +371,7 @@ export const agentRouter = router({ webhookUrl: true, runtime: { select: { + id: true, adapterKey: true, endpoint: true, secret: true, @@ -382,7 +390,7 @@ export const agentRouter = router({ (await ctx.db.apiKey.count({ where: { workspaceId: ctx.workspaceId, linkedAgentId: agent.id, revokedAt: null }, })) > 0; - const readiness = resolveChatReadiness({ + let readiness = resolveChatReadiness({ provider: agent.provider, runEngine: agent.runEngine, runtime: agent.runtime @@ -398,20 +406,58 @@ export const agentRouter = router({ daemonLinked, providerAvailable, }); - const probe = - readiness.mode === "runs" || readiness.mode === "sessions" - ? await probeRuntime({ - adapterKey: agent.runtime?.adapterKey, - endpoint: agent.runtime?.endpoint, - secret: agent.runtime?.secret, - }) - : { attempted: false, reachable: null, detail: "" }; + let probe: ReturnType | null = null; + if ((readiness.mode === "runs" || readiness.mode === "sessions") && agent.runtime?.id) { + const requestId = await requestRuntimeDiagnostic({ + workspaceId: ctx.workspaceId, + runtimeId: agent.runtime.id, + kind: RuntimeDiagnosticKind.PROBE, + trigger: RuntimeDiagnosticTrigger.MANUAL_AGENT, + requestedById: ctx.session.user.id, + }); + let attempt; + try { + attempt = await waitForRuntimeDiagnostic(requestId, { timeoutMs: 15_000 }); + } catch (error) { + throw new TRPCError({ + code: "TIMEOUT", + message: error instanceof Error ? error.message : "Runtime diagnostic timed out.", + }); + } + probe = diagnosticResult(attempt); + const refreshedRuntime = await ctx.db.runtime.findUniqueOrThrow({ + where: { id: agent.runtime.id }, + select: { + adapterKey: true, + endpoint: true, + secret: true, + kind: true, + lastProbeAttempted: true, + lastProbeReachable: true, + lastProbeDetail: true, + runtimeInfo: true, + }, + }); + readiness = resolveChatReadiness({ + provider: agent.provider, + runEngine: agent.runEngine, + runtime: refreshedRuntime, + webhookUrl: agent.webhookUrl, + runtimeKind: refreshedRuntime.kind, + daemonLinked, + providerAvailable, + }); + } return { mode: readiness.mode, transportLabel: readiness.transportLabel, ready: readiness.ready, hint: readiness.hint, - probe, + probe: probe ?? { + attempted: false, + reachable: null, + detail: "No worker diagnostic is required for this chat transport.", + }, }; }), diff --git a/src/server/routers/runtime.ts b/src/server/routers/runtime.ts index f43aaf6b..12af4910 100644 --- a/src/server/routers/runtime.ts +++ b/src/server/routers/runtime.ts @@ -1,25 +1,26 @@ import { z } from "zod"; import { TRPCError } from "@trpc/server"; -import { AgentProvider, RuntimeKind, type Runtime, type PrismaClient } from "@prisma/client"; +import { + AgentProvider, + RuntimeDiagnosticKind, + RuntimeDiagnosticTrigger, + RuntimeKind, + type Runtime, + type PrismaClient, +} from "@prisma/client"; import { router, workspaceProcedure, adminProcedure } from "@/server/trpc"; import { encryptSecret } from "@/server/crypto"; -import { - getRuntimeAdapter, - managedAdapters, - PLANNED_ADAPTERS, -} from "@/server/runtimes/adapters"; +import { getRuntimeAdapter, managedAdapters, PLANNED_ADAPTERS } from "@/server/runtimes/adapters"; import { recordRuntimeHeartbeatPresence } from "@/server/services/heartbeat"; -import { probeRuntime, type RuntimeProbeResult } from "@/server/services/dispatch/runtime-probe"; -import { runtimeInfoUpdateData, summarizeRuntimeInfo } from "@/server/services/runtime-info"; -import { - deriveRuntimeHealthStatus, - sanitizeRuntimeProbeDetail, -} from "@/server/services/runtime-status"; +import { summarizeRuntimeInfo } from "@/server/services/runtime-info"; +import { deriveRuntimeHealthStatus } from "@/server/services/runtime-status"; import { runtimeConfigStatus, validateRuntimeConfig } from "@/server/services/runtime-config"; +import { summarizeRuntimeSelfTest } from "@/server/services/runtime-self-test"; import { - runRuntimeSelfTest, - summarizeRuntimeSelfTest, -} from "@/server/services/runtime-self-test"; + diagnosticResult, + requestRuntimeDiagnostic, + waitForRuntimeDiagnostic, +} from "@/server/services/runtime-diagnostics"; /** Compute location a managed adapter's transport implies. */ function kindForAdapterTransport(transport: string): RuntimeKind { @@ -67,7 +68,9 @@ function assertEndpointTransport(endpoint: string | null | undefined): void { } /** Never leak the HMAC secret to clients — surface only whether one is set. */ -function redactRuntime>(rt: T): Omit & { hasSecret: boolean } { +function redactRuntime>( + rt: T, +): Omit & { hasSecret: boolean } { const { secret, ...rest } = rt as T & { secret?: string | null }; return { ...(rest as Omit), hasSecret: !!secret }; } @@ -98,7 +101,9 @@ type RuntimeForSelfTest = Pick< | "lastSelfTestDurationMs" >; -function withRuntimeHealth & RuntimeForHealth & RuntimeForSelfTest>(rt: T) { +function withRuntimeHealth & RuntimeForHealth & RuntimeForSelfTest>( + rt: T, +) { return { ...redactRuntime(rt), health: deriveRuntimeHealthStatus(rt), @@ -108,21 +113,6 @@ function withRuntimeHealth & RuntimeForHealth & Runti }; } -function probeData(res: RuntimeProbeResult, at = new Date()) { - return { - lastProbeAt: at, - lastProbeAttempted: res.attempted, - lastProbeReachable: res.reachable, - lastProbeDetail: sanitizeRuntimeProbeDetail(res.detail), - ...runtimeInfoUpdateData(res.runtimeInfo, at), - }; -} - -function shouldProbeHeartbeatCountAsRuntimeHeartbeat(rt: RuntimeForHealth): boolean { - const adapter = getRuntimeAdapter(rt.adapterKey); - return adapter?.transport === "app-server" && adapter.capabilities.presence === "runtime-heartbeat"; -} - /** * Runtime registry. A Runtime is the compute environment that hosts one * or more agents — the multi-host primitive Forge gained alongside @@ -186,9 +176,7 @@ const baseFields = { export const runtimeRouter = router({ list: workspaceProcedure .input( - z - .object({ includeArchived: z.boolean().default(false) }) - .default({ includeArchived: false }), + z.object({ includeArchived: z.boolean().default(false) }).default({ includeArchived: false }), ) .query(async ({ ctx, input }) => { const rows = await ctx.db.runtime.findMany({ @@ -205,82 +193,82 @@ export const runtimeRouter = router({ return rows.map(withRuntimeHealth); }), - byId: workspaceProcedure - .input(z.object({ id: runtimeId })) - .query(async ({ ctx, input }) => { - const runtime = await ctx.db.runtime.findFirst({ - where: { id: input.id, workspaceId: ctx.workspaceId }, - include: { - owner: { select: { id: true, name: true, image: true } }, - agents: { - select: { - id: true, - name: true, - profileKey: true, - avatar: true, - status: true, - provider: true, - runtimeMode: true, - lastHeartbeatAt: true, - }, + byId: workspaceProcedure.input(z.object({ id: runtimeId })).query(async ({ ctx, input }) => { + const runtime = await ctx.db.runtime.findFirst({ + where: { id: input.id, workspaceId: ctx.workspaceId }, + include: { + owner: { select: { id: true, name: true, image: true } }, + agents: { + select: { + id: true, + name: true, + profileKey: true, + avatar: true, + status: true, + provider: true, + runtimeMode: true, + lastHeartbeatAt: true, }, }, - }); - if (!runtime) throw new TRPCError({ code: "NOT_FOUND" }); - return withRuntimeHealth(runtime); - }), - - register: workspaceProcedure - .input(z.object(baseFields)) - .mutation(async ({ ctx, input }) => { - // Same transport guard as `create` / `update`: a REMOTE_HTTP endpoint - // on a public host must use TLS. `register` previously skipped this, so - // a plaintext public endpoint could slip in through this path. - assertEndpointTransport(input.endpoint || null); - const now = new Date(); - // LOCAL_DAEMON: "connected" the moment we register; REMOTE_HTTP gets - // connectedAt only on real heartbeats. - const liveTimes = - input.kind === RuntimeKind.LOCAL_DAEMON ? { connectedAt: now, heartbeatAt: now } : {}; - // Upsert on the natural key (workspaceId + name + kind). There's no - // unique index to Prisma-upsert against, and the daemon can lose its - // cached runtime id (fresh clone / config wipe), so a plain create would - // stack a duplicate row for the same host every re-register. Reuse a - // non-archived match instead. - const existing = await ctx.db.runtime.findFirst({ - where: { - workspaceId: ctx.workspaceId, - name: input.name, - kind: input.kind, - archivedAt: null, + diagnosticAttempts: { + orderBy: { createdAt: "desc" }, + take: 20, }, - select: { id: true }, - }); - if (existing) { - return ctx.db.runtime.update({ - where: { id: existing.id }, - data: { - endpoint: input.endpoint || null, - providersAvailable: input.providersAvailable, - ...liveTimes, - }, - }); - } - // For LOCAL_DAEMON the daemon registers itself and owns the row; - // for REMOTE_HTTP an admin typically registers it but the same - // attribution holds. ownerId is set from the calling user. - return ctx.db.runtime.create({ + }, + }); + if (!runtime) throw new TRPCError({ code: "NOT_FOUND" }); + return withRuntimeHealth(runtime); + }), + + register: workspaceProcedure.input(z.object(baseFields)).mutation(async ({ ctx, input }) => { + // Same transport guard as `create` / `update`: a REMOTE_HTTP endpoint + // on a public host must use TLS. `register` previously skipped this, so + // a plaintext public endpoint could slip in through this path. + assertEndpointTransport(input.endpoint || null); + const now = new Date(); + // LOCAL_DAEMON: "connected" the moment we register; REMOTE_HTTP gets + // connectedAt only on real heartbeats. + const liveTimes = + input.kind === RuntimeKind.LOCAL_DAEMON ? { connectedAt: now, heartbeatAt: now } : {}; + // Upsert on the natural key (workspaceId + name + kind). There's no + // unique index to Prisma-upsert against, and the daemon can lose its + // cached runtime id (fresh clone / config wipe), so a plain create would + // stack a duplicate row for the same host every re-register. Reuse a + // non-archived match instead. + const existing = await ctx.db.runtime.findFirst({ + where: { + workspaceId: ctx.workspaceId, + name: input.name, + kind: input.kind, + archivedAt: null, + }, + select: { id: true }, + }); + if (existing) { + return ctx.db.runtime.update({ + where: { id: existing.id }, data: { - workspaceId: ctx.workspaceId, - name: input.name, - kind: input.kind, endpoint: input.endpoint || null, providersAvailable: input.providersAvailable, - ownerId: ctx.session.user.id, ...liveTimes, }, }); - }), + } + // For LOCAL_DAEMON the daemon registers itself and owns the row; + // for REMOTE_HTTP an admin typically registers it but the same + // attribution holds. ownerId is set from the calling user. + return ctx.db.runtime.create({ + data: { + workspaceId: ctx.workspaceId, + name: input.name, + kind: input.kind, + endpoint: input.endpoint || null, + providersAvailable: input.providersAvailable, + ownerId: ctx.session.user.id, + ...liveTimes, + }, + }); + }), heartbeat: workspaceProcedure .input(z.object({ id: runtimeId })) @@ -410,10 +398,7 @@ export const runtimeRouter = router({ endpoint: z.string().url().max(500).nullable().optional().or(z.literal("")), // Empty string = leave the stored secret unchanged; explicit null clears it. secret: z.string().max(500).nullable().optional(), - providersAvailable: z - .array(z.nativeEnum(AgentProvider)) - .max(16) - .optional(), + providersAvailable: z.array(z.nativeEnum(AgentProvider)).max(16).optional(), config: z.record(z.unknown()).optional(), }), ) @@ -454,34 +439,32 @@ export const runtimeRouter = router({ }); if (!runtime) throw new TRPCError({ code: "NOT_FOUND" }); if (runtime.archivedAt) { - throw new TRPCError({ code: "BAD_REQUEST", message: "Runtime is archived; restore it before testing." }); + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Runtime is archived; restore it before testing.", + }); } - const probe = await probeRuntime({ - adapterKey: runtime.adapterKey, - endpoint: runtime.endpoint, - secret: runtime.secret, - }); - const now = new Date(); - const data = probeData(probe, now); - const heartbeatData = - probe.reachable && shouldProbeHeartbeatCountAsRuntimeHeartbeat(runtime) - ? { heartbeatAt: now } - : {}; - const updated = await ctx.db.runtime.update({ - where: { id: runtime.id }, - data: { ...data, ...heartbeatData }, + const requestId = await requestRuntimeDiagnostic({ + workspaceId: ctx.workspaceId, + runtimeId: runtime.id, + kind: RuntimeDiagnosticKind.PROBE, + trigger: RuntimeDiagnosticTrigger.MANUAL_RUNTIME, + requestedById: ctx.session.user.id, }); - if (probe.reachable && heartbeatData.heartbeatAt) { - await recordRuntimeHeartbeatPresence(runtime.id, now, ctx.db); + let attempt; + try { + attempt = await waitForRuntimeDiagnostic(requestId, { timeoutMs: 15_000 }); + } catch (error) { + throw new TRPCError({ + code: "TIMEOUT", + message: error instanceof Error ? error.message : "Runtime diagnostic timed out.", + }); } + const updated = await ctx.db.runtime.findUniqueOrThrow({ where: { id: runtime.id } }); const health = deriveRuntimeHealthStatus(updated); return { runtime: withRuntimeHealth(updated), - probe: { - attempted: probe.attempted, - reachable: probe.reachable, - detail: data.lastProbeDetail ?? probe.detail, - }, + probe: diagnosticResult(attempt), health, }; }), @@ -500,16 +483,24 @@ export const runtimeRouter = router({ }); } - const result = await runRuntimeSelfTest(runtime); - const updated = await ctx.db.runtime.update({ - where: { id: runtime.id }, - data: { - lastSelfTestAt: new Date(), - lastSelfTestStatus: result.status, - lastSelfTestDetail: result.detail, - lastSelfTestDurationMs: result.durationMs, - }, + const requestId = await requestRuntimeDiagnostic({ + workspaceId: ctx.workspaceId, + runtimeId: runtime.id, + kind: RuntimeDiagnosticKind.SELF_TEST, + trigger: RuntimeDiagnosticTrigger.MANUAL_RUNTIME, + requestedById: ctx.session.user.id, }); + let attempt; + try { + attempt = await waitForRuntimeDiagnostic(requestId, { timeoutMs: 55_000 }); + } catch (error) { + throw new TRPCError({ + code: "TIMEOUT", + message: error instanceof Error ? error.message : "Runtime self-test timed out.", + }); + } + const updated = await ctx.db.runtime.findUniqueOrThrow({ where: { id: runtime.id } }); + const result = diagnosticResult(attempt); return { runtime: withRuntimeHealth(updated), selfTest: summarizeRuntimeSelfTest(updated), @@ -575,17 +566,15 @@ export const runtimeRouter = router({ // deploy creds, …). Values are WRITE-ONLY — never returned to any client. // The runtime fetches its own decrypted values via `runtimes.provisioning`. - listSecrets: workspaceProcedure - .input(z.object({ runtimeId })) - .query(async ({ ctx, input }) => { - await assertRuntimeInWorkspace(ctx.db, ctx.workspaceId, input.runtimeId); - // valueEnc is intentionally never selected — only key + metadata leave. - return ctx.db.runtimeSecret.findMany({ - where: { runtimeId: input.runtimeId }, - orderBy: { key: "asc" }, - select: { id: true, key: true, description: true, createdAt: true, updatedAt: true }, - }); - }), + listSecrets: workspaceProcedure.input(z.object({ runtimeId })).query(async ({ ctx, input }) => { + await assertRuntimeInWorkspace(ctx.db, ctx.workspaceId, input.runtimeId); + // valueEnc is intentionally never selected — only key + metadata leave. + return ctx.db.runtimeSecret.findMany({ + where: { runtimeId: input.runtimeId }, + orderBy: { key: "asc" }, + select: { id: true, key: true, description: true, createdAt: true, updatedAt: true }, + }); + }), setSecret: adminProcedure .input( @@ -627,16 +616,14 @@ export const runtimeRouter = router({ // Repositories the runtime materializes (clone-or-pull) into its workspace // so a dispatched agent lands in a ready checkout. Auth comes from secrets. - listRepos: workspaceProcedure - .input(z.object({ runtimeId })) - .query(async ({ ctx, input }) => { - await assertRuntimeInWorkspace(ctx.db, ctx.workspaceId, input.runtimeId); - return ctx.db.runtimeRepo.findMany({ - where: { runtimeId: input.runtimeId }, - orderBy: { path: "asc" }, - select: { id: true, url: true, branch: true, path: true, createdAt: true, updatedAt: true }, - }); - }), + listRepos: workspaceProcedure.input(z.object({ runtimeId })).query(async ({ ctx, input }) => { + await assertRuntimeInWorkspace(ctx.db, ctx.workspaceId, input.runtimeId); + return ctx.db.runtimeRepo.findMany({ + where: { runtimeId: input.runtimeId }, + orderBy: { path: "asc" }, + select: { id: true, url: true, branch: true, path: true, createdAt: true, updatedAt: true }, + }); + }), setRepo: adminProcedure .input( @@ -682,31 +669,29 @@ export const runtimeRouter = router({ // Which workspace GitHub App (if any) this runtime uses for git auth. Apps // are managed workspace-wide (see the `githubApp` router); a runtime just // links to one. Returns the linked app's metadata (never the PEM). - getGithubApp: workspaceProcedure - .input(z.object({ runtimeId })) - .query(async ({ ctx, input }) => { - const rt = await ctx.db.runtime.findFirst({ - where: { id: input.runtimeId, workspaceId: ctx.workspaceId }, - select: { - githubAppId: true, - githubApp: { - select: { - id: true, - name: true, - appId: true, - installationId: true, - slug: true, - lastMintedAt: true, - lastError: true, - }, + getGithubApp: workspaceProcedure.input(z.object({ runtimeId })).query(async ({ ctx, input }) => { + const rt = await ctx.db.runtime.findFirst({ + where: { id: input.runtimeId, workspaceId: ctx.workspaceId }, + select: { + githubAppId: true, + githubApp: { + select: { + id: true, + name: true, + appId: true, + installationId: true, + slug: true, + lastMintedAt: true, + lastError: true, }, }, - }); - if (!rt) { - throw new TRPCError({ code: "NOT_FOUND", message: "Runtime not found in this workspace." }); - } - return rt.githubApp; // null = no app linked - }), + }, + }); + if (!rt) { + throw new TRPCError({ code: "NOT_FOUND", message: "Runtime not found in this workspace." }); + } + return rt.githubApp; // null = no app linked + }), // Link this runtime to a workspace GitHub App (or pass null to unlink). linkGithubApp: adminProcedure @@ -720,7 +705,10 @@ export const runtimeRouter = router({ select: { id: true }, }); if (!app) { - throw new TRPCError({ code: "NOT_FOUND", message: "GitHub App not found in this workspace." }); + throw new TRPCError({ + code: "NOT_FOUND", + message: "GitHub App not found in this workspace.", + }); } } await ctx.db.runtime.update({ diff --git a/src/server/routers/sso.ts b/src/server/routers/sso.ts index 06be8642..5b2dca22 100644 --- a/src/server/routers/sso.ts +++ b/src/server/routers/sso.ts @@ -32,15 +32,30 @@ function normalizeIssuer(raw: string): string { async function assertAdminRecoveryPath( database: Pick, - policy: Pick, + policy: Pick, providers: Array<{ id: string; type: SsoType; enabled: boolean; archivedAt: Date | null }>, ): Promise { if ( policy.breakGlassCredentialsEnabled && process.env.ADMIN_EMAIL && - process.env.ADMIN_PASSWORD + process.env.ADMIN_PASSWORD && + policy.breakGlassUserId ) { - return; + const designated = await database.user.findFirst({ + where: { + id: policy.breakGlassUserId, + instanceRole: "INSTANCE_ADMIN", + status: "ACTIVE", + disabledAt: null, + deletedAt: null, + OR: [ + { normalizedEmail: process.env.ADMIN_EMAIL.trim().toLowerCase() }, + { email: { equals: process.env.ADMIN_EMAIL, mode: "insensitive" } }, + ], + }, + select: { id: true }, + }); + if (designated) return; } const providerKeys = providers .filter((provider) => provider.enabled && !provider.archivedAt) @@ -73,17 +88,41 @@ async function assertAdminRecoveryPath( export const ssoRouter = router({ policy: instanceAdminProcedure.query(async ({ ctx }) => { - const [policy, providers] = await Promise.all([ + const [policy, providers, breakGlassCandidates] = await Promise.all([ getInstanceAuthPolicy(ctx.db), ctx.db.ssoProvider.findMany({ where: { archivedAt: null }, select: { id: true, type: true, enabled: true, archivedAt: true }, }), + ctx.db.user.findMany({ + where: { + instanceRole: "INSTANCE_ADMIN", + status: "ACTIVE", + disabledAt: null, + deletedAt: null, + }, + orderBy: [{ name: "asc" }, { email: "asc" }], + select: { id: true, name: true, email: true }, + }), ]); + const configuredEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase() ?? null; + const designated = breakGlassCandidates.find( + (candidate) => candidate.id === policy.breakGlassUserId, + ); + const breakGlassConfigured = Boolean(configuredEmail && process.env.ADMIN_PASSWORD); + const breakGlassReady = Boolean( + policy.breakGlassCredentialsEnabled && + breakGlassConfigured && + designated && + designated.email.trim().toLowerCase() === configuredEmail, + ); return { policy, presentation: deriveAuthPresentation(policy, providers), - breakGlassConfigured: Boolean(process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD), + breakGlassConfigured, + breakGlassReady, + breakGlassPrincipal: designated ?? null, + breakGlassCandidates, }; }), @@ -93,6 +132,7 @@ export const ssoRouter = router({ mode: z.nativeEnum(AuthenticationMode), registrationMode: z.nativeEnum(RegistrationMode), breakGlassCredentialsEnabled: z.boolean(), + breakGlassUserId: z.string().cuid().nullable(), autoRedirectProviderId: z.string().cuid().nullable(), passwordMinLength: z.number().int().min(8).max(128), passwordResetTtlMinutes: z.number().int().min(5).max(1440), @@ -108,6 +148,43 @@ export const ssoRouter = router({ validateAuthPolicyTransition({ id: "default", ...input }, providers, { breakGlassConfigured: Boolean(process.env.ADMIN_EMAIL && process.env.ADMIN_PASSWORD), }); + if (!input.breakGlassCredentialsEnabled && input.breakGlassUserId) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Disable break-glass recovery without retaining a designated principal.", + }); + } + if (input.breakGlassCredentialsEnabled) { + const configuredEmail = process.env.ADMIN_EMAIL?.trim().toLowerCase(); + if (!configuredEmail || !process.env.ADMIN_PASSWORD || !input.breakGlassUserId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "Break-glass recovery requires configured environment credentials and a designated active instance administrator.", + }); + } + const designated = await ctx.db.user.findFirst({ + where: { + id: input.breakGlassUserId, + instanceRole: "INSTANCE_ADMIN", + status: "ACTIVE", + disabledAt: null, + deletedAt: null, + OR: [ + { normalizedEmail: configuredEmail }, + { email: { equals: configuredEmail, mode: "insensitive" } }, + ], + }, + select: { id: true }, + }); + if (!designated) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: + "The designated break-glass principal must be an active instance administrator whose email matches ADMIN_EMAIL.", + }); + } + } await assertAdminRecoveryPath(ctx.db, input, providers); const policy = await ctx.db.instanceAuthPolicy.upsert({ where: { id: "default" }, diff --git a/src/server/routers/user.ts b/src/server/routers/user.ts index 61f26982..084beb6c 100644 --- a/src/server/routers/user.ts +++ b/src/server/routers/user.ts @@ -135,7 +135,7 @@ export const userRouter = router({ getInstanceAuthPolicy(ctx.db), ctx.db.ssoProvider.findMany({ where: { enabled: true, archivedAt: null }, - select: { id: true, type: true, name: true }, + select: { id: true, type: true, name: true, allowLinking: true }, orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], }), ]); diff --git a/src/server/services/__tests__/managed-runtime-lifecycle.test.ts b/src/server/services/__tests__/managed-runtime-lifecycle.test.ts new file mode 100644 index 00000000..f2dd109b --- /dev/null +++ b/src/server/services/__tests__/managed-runtime-lifecycle.test.ts @@ -0,0 +1,168 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { + AgentProvider, + AgentRunStatus, + EngagementMode, + EventKind, + RuntimeKind, + RunEngine, +} from "@prisma/client"; +import { recordChange } from "@/server/audit"; +import type { ApiKeyContext } from "@/server/services/api-key-auth"; +import { ingestRunsDispatch } from "@/server/services/dispatch/run-dispatcher"; +import { sharedMockRunsConnectorForTests } from "@/server/services/dispatch/mock-runs"; +import { mcpTools, type McpContext } from "@/server/services/mcp"; +import { + createIssue, + createWorkspaceFixture, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "@/server/routers/__tests__/helpers"; + +const fixtures: TestFixture[] = []; + +afterEach(async () => { + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(async () => disconnectPrisma()); + +async function call(name: keyof typeof mcpTools, input: unknown, ctx: McpContext) { + const definition = mcpTools[name]; + return definition.run(definition.input.parse(input) as never, ctx); +} + +function agentContext(fixture: TestFixture, agentId: string): McpContext { + const apiKey: ApiKeyContext = { + keyId: "managed-runtime-acceptance-key", + workspaceId: fixture.workspace.id, + userId: fixture.user.id, + pluginId: null, + scopes: ["READ_ISSUES", "WRITE_ISSUES", "READ_COMMENTS", "WRITE_COMMENTS"], + projectIds: [], + labelIds: [], + initiativeIds: [], + linkedAgentId: agentId, + }; + return { + workspaceId: fixture.workspace.id, + userId: fixture.user.id, + pluginId: null, + apiKey, + }; +} + +describe("managed runtime lifecycle acceptance", () => { + it("assignment -> external run -> ack -> output -> BODY final -> complete", async () => { + const priorE2e = process.env.FORGE_E2E; + process.env.FORGE_E2E = "1"; + try { + const fixture = await createWorkspaceFixture({ keyPrefix: "MRA" }); + fixtures.push(fixture); + const prisma = getPrisma(); + await prisma.workspace.update({ + where: { id: fixture.workspace.id }, + data: { assignmentEngagementMode: EngagementMode.DISCUSS }, + }); + const runtime = await prisma.runtime.create({ + data: { + workspaceId: fixture.workspace.id, + ownerId: fixture.user.id, + name: "Managed acceptance runtime", + kind: RuntimeKind.REMOTE_HTTP, + adapterKey: "mock-runs", + providersAvailable: [AgentProvider.HERMES], + }, + }); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Managed acceptance agent", + profileKey: `managed-acceptance-${Date.now()}`, + provider: AgentProvider.HERMES, + runEngine: RunEngine.RUNS, + runtimeId: runtime.id, + status: "ONLINE", + }, + }); + const issue = await createIssue(fixture, { + title: "Approve managed runtime acceptance", + }); + await prisma.issue.update({ + where: { id: issue.id }, + data: { assignedAgentId: agent.id }, + }); + await recordChange(prisma, { + workspaceId: fixture.workspace.id, + actorId: fixture.user.id, + entity: "Issue", + entityId: issue.id, + action: "assign", + eventKind: EventKind.AGENT_ASSIGNED, + subjectType: "issue", + subjectId: issue.id, + payload: { agentId: agent.id, agentProfileKey: agent.profileKey }, + }); + + const beforeDispatch = await prisma.agentRun.findFirstOrThrow({ + where: { issueId: issue.id, agentId: agent.id, status: AgentRunStatus.ACTIVE }, + }); + expect(beforeDispatch.externalRunId).toBeNull(); + + const dispatch = await ingestRunsDispatch(); + expect(dispatch.started).toBeGreaterThanOrEqual(1); + const run = await prisma.agentRun.findUniqueOrThrow({ where: { id: beforeDispatch.id } }); + expect(run.externalRunId).toMatch(/^mock-/); + + const ctx = agentContext(fixture, agent.id); + await call("agent.inbox.ack", { runId: run.id }, ctx); + await call("agent.inbox.outputStarted", { runId: run.id }, ctx); + const finalComment = (await call( + "comments.create", + { issueId: issue.id, body: "Managed runtime completed the requested verification." }, + ctx, + )) as { id: string }; + await call( + "runs.complete", + { + runId: run.id, + summary: "Managed runtime completed the requested verification.", + completionCommentId: finalComment.id, + }, + ctx, + ); + await sharedMockRunsConnectorForTests().approve?.(run.externalRunId!, "once"); + await new Promise((resolve) => setTimeout(resolve, 150)); + + const completed = await prisma.agentRun.findUniqueOrThrow({ where: { id: run.id } }); + expect(completed.status).toBe(AgentRunStatus.COMPLETED); + expect(completed.acknowledgedAt).not.toBeNull(); + expect(completed.outputStartedAt).not.toBeNull(); + expect(completed.externalRunId).toMatch(/^mock-/); + expect(completed.completionMeta).toMatchObject({ + completionCommentId: finalComment.id, + }); + await expect( + prisma.comment.findUniqueOrThrow({ where: { id: finalComment.id } }), + ).resolves.toMatchObject({ + issueId: issue.id, + authoringAgentId: agent.id, + kind: "BODY", + deletedAt: null, + }); + expect( + await prisma.activityEvent.count({ + where: { + workspaceId: fixture.workspace.id, + subjectId: run.id, + kind: EventKind.AGENT_RUN_COMPLETED, + }, + }), + ).toBe(1); + } finally { + if (priorE2e === undefined) delete process.env.FORGE_E2E; + else process.env.FORGE_E2E = priorE2e; + } + }); +}); diff --git a/src/server/services/__tests__/mcp-exec.test.ts b/src/server/services/__tests__/mcp-exec.test.ts index 3c492ecd..f6672dfb 100644 --- a/src/server/services/__tests__/mcp-exec.test.ts +++ b/src/server/services/__tests__/mcp-exec.test.ts @@ -124,6 +124,41 @@ describe("mcp execution wrapper", () => { } }); + it("allows an agent-linked key with no broad scopes to heartbeat only itself", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "MEX" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `presence-${Date.now()}`, + name: "Least privilege presence", + status: "OFFLINE", + }, + }); + + const exec = await executeMcpTool({ + name: "agents.heartbeat", + input: {}, + ctx: buildMcpCtx(fixture, { scopes: [], linkedAgentId: agent.id }).ctx, + source: "test", + }); + + expect(exec.ok).toBe(true); + await expect( + prisma.agent.findUniqueOrThrow({ where: { id: agent.id } }), + ).resolves.toMatchObject({ status: "ONLINE" }); + + const unlinked = await executeMcpTool({ + name: "agents.heartbeat", + input: {}, + ctx: buildMcpCtx(fixture, { scopes: [], linkedAgentId: null }).ctx, + source: "test", + }); + expect(unlinked.ok).toBe(false); + if (!unlinked.ok) expect(unlinked.error.message).toMatch(/linkedAgentId/); + }); + it("classifies explicit mode-policy denials before the raw tool run", async () => { const fixture = await createWorkspaceFixture({ keyPrefix: "MEX" }); fixtures.push(fixture); diff --git a/src/server/services/__tests__/mcp.test.ts b/src/server/services/__tests__/mcp.test.ts index d520274a..3bf59612 100644 --- a/src/server/services/__tests__/mcp.test.ts +++ b/src/server/services/__tests__/mcp.test.ts @@ -4361,6 +4361,52 @@ describe("mcp runs.complete + completion contract", () => { ).toBe(1); }); + it("runs.complete rejects a rolling STATUS comment as completionCommentId", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "CCB" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const { ctx } = buildMcpCtx(fixture); + const agent = await prisma.agent.create({ + data: { + workspaceId: fixture.workspace.id, + profileKey: `ccb-${Date.now()}`, + name: "Body contract closer", + }, + }); + const issue = await createIssue(fixture); + const run = await prisma.agentRun.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + agentId: agent.id, + engagementMode: EngagementMode.DISCUSS, + }, + }); + const status = await prisma.comment.create({ + data: { + workspaceId: fixture.workspace.id, + issueId: issue.id, + authoringAgentId: agent.id, + runId: run.id, + kind: "STATUS", + body: "Verifying the final response.", + }, + }); + const scopedCtx = { ...ctx, apiKey: { ...ctx.apiKey!, linkedAgentId: agent.id } }; + + await expect( + call( + "runs.complete", + { + runId: run.id, + summary: "Done.", + completionCommentId: status.id, + }, + scopedCtx, + ), + ).rejects.toThrow("completionCommentId must be a live BODY comment posted by this run's agent"); + }); + it("runs.complete atomically advances its execution step to review", async () => { const fixture = await createWorkspaceFixture({ keyPrefix: "CCS" }); fixtures.push(fixture); diff --git a/src/server/services/__tests__/runtime-diagnostics.test.ts b/src/server/services/__tests__/runtime-diagnostics.test.ts new file mode 100644 index 00000000..149890f3 --- /dev/null +++ b/src/server/services/__tests__/runtime-diagnostics.test.ts @@ -0,0 +1,263 @@ +import { afterAll, afterEach, describe, expect, it } from "vitest"; +import { spawn, type ChildProcess } from "node:child_process"; +import { createServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { resolve } from "node:path"; +import { RuntimeDiagnosticKind, RuntimeDiagnosticTrigger, RuntimeKind } from "@prisma/client"; +import { runtimeRouter } from "@/server/routers/runtime"; +import { + executeQueuedRuntimeDiagnostic, + executeRuntimeDiagnostic, + runScheduledRuntimeProbe, +} from "@/server/services/runtime-diagnostics"; +import { + createWorkspaceFixture, + buildContext, + disconnectPrisma, + getPrisma, + type TestFixture, +} from "@/server/routers/__tests__/helpers"; + +const fixtures: TestFixture[] = []; +const childWorkers: ChildProcess[] = []; + +afterEach(async () => { + while (childWorkers.length) { + const child = childWorkers.pop()!; + if (child.exitCode === null) child.kill(); + await new Promise((resolveExit) => { + if (child.exitCode !== null) return resolveExit(); + child.once("exit", () => resolveExit()); + setTimeout(resolveExit, 5_000).unref(); + }); + } + while (fixtures.length) await fixtures.pop()!.cleanup(); +}); + +afterAll(async () => disconnectPrisma()); + +describe("runtime diagnostic execution plane", () => { + async function startIsolatedWorker(blockedOrigin: string): Promise { + const serverOnlyShim = resolve(process.cwd(), "scripts/ignore-server-only.cjs"); + const child = spawn( + process.execPath, + [ + resolve(process.cwd(), "node_modules/tsx/dist/cli.mjs"), + resolve(process.cwd(), "tests/fixtures/runtime-diagnostic-worker.ts"), + ], + { + cwd: process.cwd(), + env: { + ...process.env, + FORGE_TEST_BLOCKED_RUNTIME_ORIGIN: blockedOrigin, + NODE_OPTIONS: `${process.env.NODE_OPTIONS ?? ""} --require=${JSON.stringify(serverOnlyShim)}`.trim(), + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + childWorkers.push(child); + await new Promise((ready, reject) => { + const timer = setTimeout( + () => reject(new Error("Isolated runtime diagnostic worker did not become ready.")), + 15_000, + ); + let stderr = ""; + child.stderr?.on("data", (chunk) => (stderr += chunk.toString())); + child.stdout?.on("data", (chunk) => { + if (!chunk.toString().includes("RUNTIME_DIAGNOSTIC_WORKER_READY")) return; + clearTimeout(timer); + ready(); + }); + child.once("exit", (code) => { + clearTimeout(timer); + reject(new Error(`Isolated runtime diagnostic worker exited ${code}: ${stderr}`)); + }); + }); + return child; + } + + it("records worker provenance and preserves manual-vs-sweep history", async () => { + const server = createServer((req, res) => { + if (req.url?.startsWith("/v1/models")) { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ data: [] })); + return; + } + if (req.url?.startsWith("/v1/runs")) { + res.writeHead(405); + res.end(); + return; + } + res.writeHead(404); + res.end(); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as AddressInfo).port; + + const fixture = await createWorkspaceFixture({ keyPrefix: "RDX" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const runtime = await prisma.runtime.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Diagnostic plane", + kind: RuntimeKind.REMOTE_HTTP, + adapterKey: "hermes", + endpoint: `http://127.0.0.1:${port}/v1`, + }, + }); + const manual = await prisma.runtimeDiagnosticAttempt.create({ + data: { + requestId: "manual-worker-diagnostic", + workspaceId: fixture.workspace.id, + runtimeId: runtime.id, + kind: RuntimeDiagnosticKind.PROBE, + trigger: RuntimeDiagnosticTrigger.MANUAL_RUNTIME, + requestedById: fixture.user.id, + }, + }); + + const passed = await executeRuntimeDiagnostic({ + requestId: manual.requestId, + workspaceId: fixture.workspace.id, + runtimeId: runtime.id, + }); + expect(passed).toMatchObject({ + executor: "WORKER", + trigger: "MANUAL_RUNTIME", + reachable: true, + requestedById: fixture.user.id, + }); + + await new Promise((resolve) => server.close(() => resolve())); + const failed = await runScheduledRuntimeProbe({ + workspaceId: fixture.workspace.id, + runtimeId: runtime.id, + }); + expect(failed).toMatchObject({ + executor: "WORKER", + trigger: "SCHEDULED_SWEEP", + reachable: false, + }); + + const history = await prisma.runtimeDiagnosticAttempt.findMany({ + where: { runtimeId: runtime.id }, + orderBy: { createdAt: "asc" }, + }); + expect(history).toHaveLength(2); + expect(history.map((attempt) => attempt.trigger)).toEqual([ + "MANUAL_RUNTIME", + "SCHEDULED_SWEEP", + ]); + await expect( + prisma.runtime.findUniqueOrThrow({ where: { id: runtime.id } }), + ).resolves.toMatchObject({ + lastProbeReachable: false, + }); + }); + + it("finalizes and sanitizes an unexpected worker executor failure", async () => { + const fixture = await createWorkspaceFixture({ keyPrefix: "RDF" }); + fixtures.push(fixture); + const prisma = getPrisma(); + const runtime = await prisma.runtime.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Failing diagnostic worker", + kind: RuntimeKind.REMOTE_HTTP, + adapterKey: "hermes", + endpoint: "https://runtime.example.test/v1", + }, + }); + const attempt = await prisma.runtimeDiagnosticAttempt.create({ + data: { + requestId: "failed-worker-diagnostic", + workspaceId: fixture.workspace.id, + runtimeId: runtime.id, + kind: RuntimeDiagnosticKind.PROBE, + trigger: RuntimeDiagnosticTrigger.MANUAL_RUNTIME, + requestedById: fixture.user.id, + }, + }); + + await expect( + executeQueuedRuntimeDiagnostic( + { + requestId: attempt.requestId, + workspaceId: fixture.workspace.id, + runtimeId: runtime.id, + }, + prisma, + async () => { + throw new Error( + "authorization: Bearer super-secret https://runtime.example.test/private?token=bad", + ); + }, + ), + ).rejects.toThrow("super-secret"); + + const finalized = await prisma.runtimeDiagnosticAttempt.findUniqueOrThrow({ + where: { id: attempt.id }, + }); + expect(finalized).toMatchObject({ + executor: "WORKER", + attempted: true, + reachable: false, + }); + expect(finalized.completedAt).not.toBeNull(); + expect(finalized.detail).toContain("Worker diagnostic failed"); + expect(finalized.detail).not.toContain("super-secret"); + expect(finalized.detail).not.toContain("token=bad"); + }); + + it("reports worker-plane failure even when the web process can reach the runtime", async () => { + const server = createServer((req, res) => { + if (req.url?.startsWith("/v1/models")) { + res.writeHead(200, { "content-type": "application/json" }); + res.end(JSON.stringify({ data: [] })); + return; + } + if (req.url?.startsWith("/v1/runs")) { + res.writeHead(405); + res.end(); + return; + } + res.writeHead(404); + res.end(); + }); + await new Promise((resolveListen) => server.listen(0, "127.0.0.1", resolveListen)); + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + + try { + // Establish the split explicitly: this Vitest/web process can reach the + // endpoint, while the separate worker process below denies that origin. + await expect(fetch(`${origin}/v1/models`)).resolves.toMatchObject({ ok: true }); + + const fixture = await createWorkspaceFixture({ keyPrefix: "RDP" }); + fixtures.push(fixture); + const runtime = await getPrisma().runtime.create({ + data: { + workspaceId: fixture.workspace.id, + name: "Split-plane diagnostic", + kind: RuntimeKind.REMOTE_HTTP, + adapterKey: "hermes", + endpoint: `${origin}/v1`, + }, + }); + await startIsolatedWorker(origin); + + const caller = runtimeRouter.createCaller(await buildContext(fixture)); + const result = await caller.verifyConnection({ id: runtime.id }); + + expect(result.probe).toMatchObject({ + attempted: true, + reachable: false, + executor: "WORKER", + trigger: "MANUAL_RUNTIME", + }); + expect(result.probe.detail).toContain("Worker test egress blocked"); + } finally { + await new Promise((resolveClose) => server.close(() => resolveClose())); + } + }); +}); diff --git a/src/server/services/auth-policy.ts b/src/server/services/auth-policy.ts index a7e4b624..90528be8 100644 --- a/src/server/services/auth-policy.ts +++ b/src/server/services/auth-policy.ts @@ -15,6 +15,7 @@ export type AuthPolicyConfig = Pick< | "mode" | "registrationMode" | "breakGlassCredentialsEnabled" + | "breakGlassUserId" | "autoRedirectProviderId" | "passwordMinLength" | "passwordResetTtlMinutes" @@ -27,6 +28,7 @@ export const DEFAULT_AUTH_POLICY: AuthPolicyConfig = { mode: "HYBRID", registrationMode: "INVITE_ONLY", breakGlassCredentialsEnabled: true, + breakGlassUserId: null, autoRedirectProviderId: null, passwordMinLength: 12, passwordResetTtlMinutes: 30, diff --git a/src/server/services/dispatch/mock-runs.ts b/src/server/services/dispatch/mock-runs.ts index a964db91..4861db9d 100644 --- a/src/server/services/dispatch/mock-runs.ts +++ b/src/server/services/dispatch/mock-runs.ts @@ -118,3 +118,15 @@ export function makeMockRunsConnector(): DispatchConnector { }, }; } + +let sharedMockRunsConnector: DispatchConnector | null = null; + +/** + * Worker dispatch resolves the connector separately for start, subscribe, and + * poll. Keep one in-process instance in E2E mode so those calls observe the + * same external run, matching a real managed runtime service. + */ +export function sharedMockRunsConnectorForTests(): DispatchConnector { + sharedMockRunsConnector ??= makeMockRunsConnector(); + return sharedMockRunsConnector; +} diff --git a/src/server/services/dispatch/registry.ts b/src/server/services/dispatch/registry.ts index 45545568..e1347fb7 100644 --- a/src/server/services/dispatch/registry.ts +++ b/src/server/services/dispatch/registry.ts @@ -11,7 +11,7 @@ import { makeCodexAppServerConnector, parseCodexRuntimeConfig, } from "./codex-app-server"; -import { makeMockRunsConnector } from "./mock-runs"; +import { sharedMockRunsConnectorForTests } from "./mock-runs"; import type { DispatchConnector, RunEngine, RunEvent } from "./types"; /** @@ -153,7 +153,7 @@ export function getRunsConnectorForAgent(agent: { // E2E-only: an in-process scripted connector so the RUNS path (streaming + // approvals) is testable with no external runtime. Never resolves in prod. if (process.env.FORGE_E2E === "1" && rt?.adapterKey === "mock-runs") { - return makeMockRunsConnector(); + return sharedMockRunsConnectorForTests(); } if (rt && rt.endpoint) { if (rt.adapterKey === "hermes") { diff --git a/src/server/services/mcp.ts b/src/server/services/mcp.ts index a9d261ae..0f7a9339 100644 --- a/src/server/services/mcp.ts +++ b/src/server/services/mcp.ts @@ -5220,7 +5220,10 @@ export const mcpTools = { }, "agents.heartbeat": { - scopes: ["READ_USERS"] as const, + // Self-presence is authorized by the authenticated key's linkedAgentId + // below. It must not require broad workspace-member read access merely to + // update that one agent binding. + scopes: [] as const, input: z.object({ status: z .nativeEnum(AgentStatus) diff --git a/src/server/services/runtime-diagnostics.ts b/src/server/services/runtime-diagnostics.ts new file mode 100644 index 00000000..3f0b0794 --- /dev/null +++ b/src/server/services/runtime-diagnostics.ts @@ -0,0 +1,287 @@ +import "server-only"; +import { + RuntimeDiagnosticExecutor, + RuntimeDiagnosticKind, + RuntimeDiagnosticTrigger, + type Prisma, + type PrismaClient, + type RuntimeSelfTestStatus, +} from "@prisma/client"; +import { randomUUID } from "node:crypto"; +import { db } from "@/server/db"; +import { runtimeDiagnosticQueue } from "@/server/queues"; +import { getRuntimeAdapter } from "@/server/runtimes/adapters"; +import { probeRuntime } from "@/server/services/dispatch/runtime-probe"; +import { recordRuntimeHeartbeatPresence } from "@/server/services/heartbeat"; +import { runtimeInfoUpdateData } from "@/server/services/runtime-info"; +import { runRuntimeSelfTest } from "@/server/services/runtime-self-test"; +import { sanitizeRuntimeProbeDetail } from "@/server/services/runtime-status"; + +export type RuntimeDiagnosticJob = { + requestId: string; + workspaceId: string; + runtimeId: string; +}; + +export type RequestRuntimeDiagnosticInput = { + workspaceId: string; + runtimeId: string; + kind: RuntimeDiagnosticKind; + trigger: RuntimeDiagnosticTrigger; + requestedById?: string | null; +}; + +const HISTORY_LIMIT = 200; + +export async function requestRuntimeDiagnostic( + input: RequestRuntimeDiagnosticInput, + client: PrismaClient | Prisma.TransactionClient = db, +): Promise { + const requestId = randomUUID(); + await client.runtimeDiagnosticAttempt.create({ + data: { + requestId, + workspaceId: input.workspaceId, + runtimeId: input.runtimeId, + kind: input.kind, + trigger: input.trigger, + requestedById: input.requestedById ?? null, + }, + }); + try { + await runtimeDiagnosticQueue.add( + "runtime-diagnostic", + { requestId, workspaceId: input.workspaceId, runtimeId: input.runtimeId }, + { + jobId: `runtime-diagnostic-${requestId}`, + removeOnComplete: { age: 3600, count: 200 }, + removeOnFail: { age: 86_400, count: 200 }, + }, + ); + } catch (error) { + await client.runtimeDiagnosticAttempt.update({ + where: { requestId }, + data: { + attempted: false, + detail: sanitizeRuntimeProbeDetail( + `Runtime diagnostic could not be queued: ${error instanceof Error ? error.message : "queue unavailable"}`, + ), + completedAt: new Date(), + }, + }); + throw error; + } + return requestId; +} + +export async function waitForRuntimeDiagnostic( + requestId: string, + opts: { + timeoutMs: number; + client?: PrismaClient | Prisma.TransactionClient; + pollMs?: number; + }, +) { + const client = opts.client ?? db; + const deadline = Date.now() + opts.timeoutMs; + do { + const attempt = await client.runtimeDiagnosticAttempt.findUnique({ where: { requestId } }); + if (attempt?.completedAt) return attempt; + await new Promise((resolve) => setTimeout(resolve, opts.pollMs ?? 125)); + } while (Date.now() < deadline); + throw new Error( + "The runtime worker did not finish the diagnostic in time. Check the worker and its outbound network before retrying.", + ); +} + +export async function executeRuntimeDiagnostic( + job: RuntimeDiagnosticJob, + client: PrismaClient | Prisma.TransactionClient = db, +) { + const attempt = await client.runtimeDiagnosticAttempt.findFirst({ + where: { + requestId: job.requestId, + workspaceId: job.workspaceId, + runtimeId: job.runtimeId, + }, + }); + if (!attempt) throw new Error("Runtime diagnostic request was not found."); + if (attempt.completedAt) return attempt; + + const runtime = await client.runtime.findFirst({ + where: { id: job.runtimeId, workspaceId: job.workspaceId }, + }); + if (!runtime) { + return client.runtimeDiagnosticAttempt.update({ + where: { id: attempt.id }, + data: { detail: "Runtime not found in this workspace.", completedAt: new Date() }, + }); + } + + const completedAt = new Date(); + if (attempt.kind === RuntimeDiagnosticKind.PROBE) { + const probe = await probeRuntime({ + adapterKey: runtime.adapterKey, + endpoint: runtime.endpoint, + secret: runtime.secret, + }); + const detail = sanitizeRuntimeProbeDetail(probe.detail); + const countsAsHeartbeat = + probe.reachable === true && + getRuntimeAdapter(runtime.adapterKey)?.transport === "app-server" && + getRuntimeAdapter(runtime.adapterKey)?.capabilities.presence === "runtime-heartbeat"; + await client.runtime.update({ + where: { id: runtime.id }, + data: { + lastProbeAt: completedAt, + lastProbeAttempted: probe.attempted, + lastProbeReachable: probe.reachable, + lastProbeDetail: detail, + ...runtimeInfoUpdateData(probe.runtimeInfo, completedAt), + ...(countsAsHeartbeat ? { heartbeatAt: completedAt } : {}), + }, + }); + await client.runtimeDiagnosticAttempt.update({ + where: { id: attempt.id }, + data: { + executor: RuntimeDiagnosticExecutor.WORKER, + attempted: probe.attempted, + reachable: probe.reachable, + detail, + completedAt, + }, + }); + if (countsAsHeartbeat) { + await recordRuntimeHeartbeatPresence(runtime.id, completedAt, client); + } + } else { + const result = await runRuntimeSelfTest(runtime); + await client.runtime.update({ + where: { id: runtime.id }, + data: { + lastSelfTestAt: completedAt, + lastSelfTestStatus: result.status, + lastSelfTestDetail: result.detail, + lastSelfTestDurationMs: result.durationMs, + }, + }); + await client.runtimeDiagnosticAttempt.update({ + where: { id: attempt.id }, + data: { + executor: RuntimeDiagnosticExecutor.WORKER, + attempted: result.attempted, + selfTestStatus: result.status, + detail: result.detail, + durationMs: result.durationMs, + completedAt, + }, + }); + } + + await pruneRuntimeDiagnosticHistory(runtime.id, client); + return client.runtimeDiagnosticAttempt.findUniqueOrThrow({ where: { id: attempt.id } }); +} + +/** + * Worker boundary for queued diagnostics. Network probes and connector + * self-tests normally resolve failures as data, but an unexpected executor or + * persistence error must still close the request so operators do not get an + * eternal "waiting" row and web callers do not misdiagnose it as worker + * silence. + */ +export async function executeQueuedRuntimeDiagnostic( + job: RuntimeDiagnosticJob, + client: PrismaClient | Prisma.TransactionClient = db, + execute: ( + job: RuntimeDiagnosticJob, + client: PrismaClient | Prisma.TransactionClient, + ) => Promise = executeRuntimeDiagnostic, +) { + try { + return await execute(job, client); + } catch (error) { + const attempt = await client.runtimeDiagnosticAttempt.findFirst({ + where: { + requestId: job.requestId, + workspaceId: job.workspaceId, + runtimeId: job.runtimeId, + completedAt: null, + }, + select: { id: true, kind: true }, + }); + if (attempt) { + const detail = sanitizeRuntimeProbeDetail( + `Worker diagnostic failed: ${error instanceof Error ? error.message : "unexpected executor error"}`, + ); + await client.runtimeDiagnosticAttempt.update({ + where: { id: attempt.id }, + data: { + executor: RuntimeDiagnosticExecutor.WORKER, + attempted: true, + reachable: attempt.kind === RuntimeDiagnosticKind.PROBE ? false : undefined, + selfTestStatus: attempt.kind === RuntimeDiagnosticKind.SELF_TEST ? "FAILED" : undefined, + detail, + completedAt: new Date(), + }, + }); + } + throw error; + } +} + +export async function runScheduledRuntimeProbe( + input: { workspaceId: string; runtimeId: string }, + client: PrismaClient | Prisma.TransactionClient = db, +) { + const requestId = randomUUID(); + await client.runtimeDiagnosticAttempt.create({ + data: { + requestId, + workspaceId: input.workspaceId, + runtimeId: input.runtimeId, + kind: RuntimeDiagnosticKind.PROBE, + trigger: RuntimeDiagnosticTrigger.SCHEDULED_SWEEP, + }, + }); + return executeRuntimeDiagnostic({ requestId, ...input }, client); +} + +async function pruneRuntimeDiagnosticHistory( + runtimeId: string, + client: PrismaClient | Prisma.TransactionClient, +) { + const stale = await client.runtimeDiagnosticAttempt.findMany({ + where: { runtimeId }, + orderBy: { createdAt: "desc" }, + skip: HISTORY_LIMIT, + select: { id: true }, + }); + if (stale.length) { + await client.runtimeDiagnosticAttempt.deleteMany({ + where: { id: { in: stale.map((row) => row.id) } }, + }); + } +} + +export function diagnosticResult(attempt: { + kind: RuntimeDiagnosticKind; + attempted: boolean; + reachable: boolean | null; + selfTestStatus: RuntimeSelfTestStatus | null; + detail: string | null; + durationMs: number | null; + executor: RuntimeDiagnosticExecutor; + trigger: RuntimeDiagnosticTrigger; + completedAt: Date | null; +}) { + return { + attempted: attempt.attempted, + reachable: attempt.reachable, + status: attempt.selfTestStatus, + detail: attempt.detail ?? "Runtime diagnostic completed without detail.", + durationMs: attempt.durationMs, + executor: attempt.executor, + trigger: attempt.trigger, + completedAt: attempt.completedAt, + }; +} diff --git a/src/server/services/runtime-health.ts b/src/server/services/runtime-health.ts index 04ec9549..7c2fb2fb 100644 --- a/src/server/services/runtime-health.ts +++ b/src/server/services/runtime-health.ts @@ -2,11 +2,8 @@ import "server-only"; import type { Prisma, PrismaClient } from "@prisma/client"; import { db } from "@/server/db"; import { logger } from "@/server/logger"; -import { getRuntimeAdapter } from "@/server/runtimes/adapters"; -import { probeRuntime } from "@/server/services/dispatch/runtime-probe"; -import { runtimeInfoUpdateData } from "@/server/services/runtime-info"; -import { sanitizeRuntimeProbeDetail, supportsRuntimeProbe } from "@/server/services/runtime-status"; -import { recordRuntimeHeartbeatPresence } from "@/server/services/heartbeat"; +import { runScheduledRuntimeProbe } from "@/server/services/runtime-diagnostics"; +import { supportsRuntimeProbe } from "@/server/services/runtime-status"; /** * Active health probe for managed runtimes with handshake probes. @@ -32,19 +29,12 @@ export interface RuntimeHealthSweepResult { reachable: number; } -const PROBE_TIMEOUT_MS = 6_000; - -function probeCountsAsRuntimeHeartbeat(adapterKey: string | null): boolean { - const adapter = getRuntimeAdapter(adapterKey); - return adapter?.transport === "app-server" && adapter.capabilities.presence === "runtime-heartbeat"; -} - export async function sweepRuntimeHealth( client: PrismaClient | Prisma.TransactionClient = db, ): Promise { const runtimes = await client.runtime.findMany({ where: { archivedAt: null, disabledAt: null, endpoint: { not: null } }, - select: { id: true, adapterKey: true, endpoint: true, secret: true }, + select: { id: true, workspaceId: true, adapterKey: true, endpoint: true }, }); const targets = runtimes.filter((rt) => supportsRuntimeProbe(rt.adapterKey)); @@ -53,36 +43,11 @@ export async function sweepRuntimeHealth( await Promise.all( targets.map(async (rt) => { try { - const res = await probeRuntime({ - adapterKey: rt.adapterKey, - endpoint: rt.endpoint, - secret: rt.secret, - timeoutMs: PROBE_TIMEOUT_MS, - }); - const now = new Date(); - const probeData = { - lastProbeAt: now, - lastProbeAttempted: res.attempted, - lastProbeReachable: res.reachable, - lastProbeDetail: sanitizeRuntimeProbeDetail(res.detail), - ...runtimeInfoUpdateData(res.runtimeInfo, now), - }; - if (!res.reachable) { - await client.runtime.updateMany({ - where: { id: rt.id }, - data: probeData, - }); - return; - } - reachable += 1; - const countsAsHeartbeat = probeCountsAsRuntimeHeartbeat(rt.adapterKey); - const updated = await client.runtime.updateMany({ - where: { id: rt.id }, - data: countsAsHeartbeat ? { ...probeData, heartbeatAt: now } : probeData, - }); - if (updated.count > 0 && countsAsHeartbeat) { - await recordRuntimeHeartbeatPresence(rt.id, now, client); - } + const res = await runScheduledRuntimeProbe( + { workspaceId: rt.workspaceId, runtimeId: rt.id }, + client, + ); + if (res.reachable) reachable += 1; } catch (err) { // A single bad endpoint shouldn't sink the whole sweep. logger.warn({ err, runtimeId: rt.id }, "runtime-health: probe failed"); @@ -91,10 +56,7 @@ export async function sweepRuntimeHealth( ); if (targets.length) { - logger.info( - { probed: targets.length, reachable }, - "runtime-health sweep", - ); + logger.info({ probed: targets.length, reachable }, "runtime-health sweep"); } return { probed: targets.length, reachable }; } diff --git a/src/server/services/sso-provider-config.ts b/src/server/services/sso-provider-config.ts new file mode 100644 index 00000000..ebf64cb5 --- /dev/null +++ b/src/server/services/sso-provider-config.ts @@ -0,0 +1,30 @@ +import type { Provider } from "next-auth/providers"; + +type GenericOidcRow = { + id: string; + name: string; + issuer: string; + clientId: string; + scopes: string | null; + allowLinking: boolean; +}; + +/** + * Build Forge's provider-neutral OIDC configuration. Explicit checks are + * deliberate: generic providers must prove the authorization response belongs + * to the initiating browser and token exchange, regardless of provider + * defaults in a particular Auth.js release. + */ +export function genericOidcProviderConfig(row: GenericOidcRow, clientSecret: string): Provider { + return { + id: row.id, + name: row.name, + type: "oidc", + issuer: row.issuer, + clientId: row.clientId, + clientSecret, + checks: ["pkce", "state", "nonce"], + allowDangerousEmailAccountLinking: row.allowLinking, + ...(row.scopes ? { authorization: { params: { scope: row.scopes } } } : {}), + }; +} diff --git a/src/server/services/user-lifecycle.ts b/src/server/services/user-lifecycle.ts index f6b0b1c8..c19f6f4e 100644 --- a/src/server/services/user-lifecycle.ts +++ b/src/server/services/user-lifecycle.ts @@ -121,6 +121,23 @@ function assertPasswordLength(password: string, minLength: number): void { } } +async function assertNotDesignatedBreakGlass( + tx: DatabaseClient, + userId: string, + action: string, +): Promise { + const policy = await tx.instanceAuthPolicy.findUnique({ + where: { id: "default" }, + select: { breakGlassCredentialsEnabled: true, breakGlassUserId: true }, + }); + if (policy?.breakGlassCredentialsEnabled && policy.breakGlassUserId === userId) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: `Reassign or disable break-glass recovery before ${action} its designated administrator.`, + }); + } +} + export async function createInvitedUser( db: PrismaClient, input: LifecycleActor & { email: string; name?: string | null; instanceRole?: InstanceRole }, @@ -175,6 +192,9 @@ export async function setUserInstanceRole( input: LifecycleActor & { userId: string; role: InstanceRole }, ) { return serializable(db, async (tx) => { + if (input.role !== InstanceRole.INSTANCE_ADMIN) { + await assertNotDesignatedBreakGlass(tx, input.userId, "demoting"); + } const target = await tx.user.findUnique({ where: { id: input.userId }, select: { id: true, instanceRole: true, status: true }, @@ -425,6 +445,7 @@ export function completeAccountSetup( } async function assertLifecycleQuorum(tx: Prisma.TransactionClient, userId: string): Promise { + await assertNotDesignatedBreakGlass(tx, userId, "disabling or deleting"); const user = await tx.user.findUnique({ where: { id: userId }, select: { instanceRole: true, status: true }, diff --git a/src/server/sso.ts b/src/server/sso.ts index 4582329f..64610a7c 100644 --- a/src/server/sso.ts +++ b/src/server/sso.ts @@ -20,22 +20,30 @@ import { encryptSecret } from "@/server/crypto"; // going stale for long; mutations call `bustSsoCache()` for instant effect // within the same process. const TTL_MS = 30_000; -let cache: { rows: SsoProvider[]; at: number } | null = null; +const SSO_CACHE = Symbol.for("forge.sso-provider-cache"); +type SsoGlobal = typeof globalThis & { + [SSO_CACHE]?: { rows: SsoProvider[]; at: number } | null; +}; + +function cacheStore(): SsoGlobal { + return globalThis as SsoGlobal; +} export function bustSsoCache() { - cache = null; + cacheStore()[SSO_CACHE] = null; } /** All *enabled* providers, cached. Includes the encrypted secret. */ export async function getEnabledSsoRows(): Promise { await seedSsoFromEnvOnce(); const now = Date.now(); + const cache = cacheStore()[SSO_CACHE]; if (cache && now - cache.at < TTL_MS) return cache.rows; const rows = await db.ssoProvider.findMany({ where: { enabled: true, archivedAt: null }, orderBy: [{ sortOrder: "asc" }, { createdAt: "asc" }], }); - cache = { rows, at: now }; + cacheStore()[SSO_CACHE] = { rows, at: now }; return rows; } diff --git a/src/server/worker.ts b/src/server/worker.ts index 2ee59a6a..bb2eb86c 100644 --- a/src/server/worker.ts +++ b/src/server/worker.ts @@ -38,9 +38,13 @@ import type { GitHubResourceReconcileJob } from "@/server/services/github/reconc import { sweepScheduledTasks } from "@/server/services/scheduled-task"; import { sweepStaleWorkSessions } from "@/server/services/work-session"; import { sweepHermesConnectorRetries } from "@/server/services/hermes-connector-retry"; +import { + executeQueuedRuntimeDiagnostic, + type RuntimeDiagnosticJob, +} from "@/server/services/runtime-diagnostics"; import { webhookDeliveryJobId } from "@/server/services/webhook-delivery-job-id"; import { logger } from "@/server/logger"; -import { webhookQueue, maintenanceQueue } from "@/server/queues"; +import { webhookQueue, maintenanceQueue, runtimeDiagnosticQueue } from "@/server/queues"; const connection = { url: process.env.REDIS_URL ?? "redis://localhost:6379" }; @@ -77,7 +81,7 @@ const SCHEDULED_TASK_SWEEP_JOB_ID = "scheduled-task-sweep"; const WORK_SESSION_SWEEP_INTERVAL_MS = 5 * 60_000; const WORK_SESSION_SWEEP_JOB_ID = "work-session-stale-sweep"; -export { webhookQueue, maintenanceQueue }; +export { webhookQueue, maintenanceQueue, runtimeDiagnosticQueue }; export const webhookEvents = new QueueEvents("webhooks", { connection }); export const webhookWorker = new Worker( @@ -318,6 +322,19 @@ webhookWorker.on("failed", (job, err) => { logger.warn({ jobId: job?.id, err }, "webhook job failed"); }); +// Operator-requested probes and self-tests run in this process so their DNS, +// routing, and firewall plane matches Runs dispatch. A dedicated queue keeps a +// 45-second self-test from blocking the single-concurrency maintenance loop. +export const runtimeDiagnosticWorker = new Worker( + "runtime-diagnostics", + async (job) => executeQueuedRuntimeDiagnostic(job.data as RuntimeDiagnosticJob), + { connection, concurrency: 2 }, +); + +runtimeDiagnosticWorker.on("failed", (job, err) => { + logger.warn({ jobId: job?.id, err }, "runtime diagnostic job failed"); +}); + // --------------------------------------------------------------------------- // Maintenance queue: periodic sweeps (heartbeat auto-offline, etc.) // diff --git a/tests/e2e/fixtures/strict-oidc-provider.mjs b/tests/e2e/fixtures/strict-oidc-provider.mjs new file mode 100644 index 00000000..65712bb8 --- /dev/null +++ b/tests/e2e/fixtures/strict-oidc-provider.mjs @@ -0,0 +1,132 @@ +import { createHash, generateKeyPairSync, randomBytes, sign } from "node:crypto"; +import { createServer } from "node:http"; + +const port = Number(process.env.E2E_OIDC_PORT ?? 3211); +const issuer = `http://127.0.0.1:${port}`; +const clientId = "forge-strict-oidc"; +const clientSecret = "forge-strict-oidc-secret"; +const { privateKey, publicKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); +const jwk = publicKey.export({ format: "jwk" }); +const codes = new Map(); + +function json(res, status, body) { + res.writeHead(status, { "content-type": "application/json", "cache-control": "no-store" }); + res.end(JSON.stringify(body)); +} + +function base64url(value) { + return Buffer.from(value).toString("base64url"); +} + +function idToken({ nonce }) { + const now = Math.floor(Date.now() / 1000); + const header = base64url(JSON.stringify({ alg: "RS256", typ: "JWT", kid: "strict-e2e" })); + const payload = base64url( + JSON.stringify({ + iss: issuer, + aud: clientId, + sub: "strict-user", + email: process.env.E2E_OIDC_EMAIL ?? "oidc-link-user@forge.local", + email_verified: true, + name: "Forge Owner", + nonce, + iat: now, + exp: now + 300, + }), + ); + const signature = sign("RSA-SHA256", Buffer.from(`${header}.${payload}`), privateKey); + return `${header}.${payload}.${signature.toString("base64url")}`; +} + +function validState(state) { + return Boolean(state && state.length >= 32 && new Set(state).size >= 8); +} + +function readBody(req) { + return new Promise((resolve, reject) => { + let body = ""; + req.setEncoding("utf8"); + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => resolve(body)); + req.on("error", reject); + }); +} + +createServer(async (req, res) => { + const url = new URL(req.url ?? "/", issuer); + if (url.pathname === "/health") return json(res, 200, { ok: true }); + if (url.pathname === "/.well-known/openid-configuration") { + return json(res, 200, { + issuer, + authorization_endpoint: `${issuer}/authorize`, + token_endpoint: `${issuer}/token`, + userinfo_endpoint: `${issuer}/userinfo`, + jwks_uri: `${issuer}/jwks`, + response_types_supported: ["code"], + subject_types_supported: ["public"], + id_token_signing_alg_values_supported: ["RS256"], + code_challenge_methods_supported: ["S256"], + scopes_supported: ["openid", "profile", "email"], + token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post"], + }); + } + if (url.pathname === "/jwks") { + return json(res, 200, { keys: [{ ...jwk, kid: "strict-e2e", use: "sig", alg: "RS256" }] }); + } + if (url.pathname === "/authorize") { + const state = url.searchParams.get("state"); + const nonce = url.searchParams.get("nonce"); + const challenge = url.searchParams.get("code_challenge"); + const method = url.searchParams.get("code_challenge_method"); + const redirectUri = url.searchParams.get("redirect_uri"); + if (!validState(state)) return json(res, 400, { error: "weak_state" }); + if (!nonce || nonce.length < 16) return json(res, 400, { error: "missing_nonce" }); + if (!challenge || method !== "S256") return json(res, 400, { error: "pkce_required" }); + if (!redirectUri) return json(res, 400, { error: "invalid_redirect_uri" }); + const code = randomBytes(24).toString("base64url"); + codes.set(code, { challenge, nonce }); + const callback = new URL(redirectUri); + callback.searchParams.set("code", code); + callback.searchParams.set("state", state); + res.writeHead(302, { location: callback.toString() }); + return res.end(); + } + if (url.pathname === "/token" && req.method === "POST") { + const params = new URLSearchParams(await readBody(req)); + const auth = req.headers.authorization; + const basic = auth?.startsWith("Basic ") + ? Buffer.from(auth.slice(6), "base64").toString("utf8").split(":") + : []; + const suppliedId = basic[0] ?? params.get("client_id"); + const suppliedSecret = basic[1] ?? params.get("client_secret"); + if (suppliedId !== clientId || suppliedSecret !== clientSecret) { + return json(res, 401, { error: "invalid_client" }); + } + const code = params.get("code"); + const record = code ? codes.get(code) : null; + const verifier = params.get("code_verifier") ?? ""; + const digest = createHash("sha256").update(verifier).digest("base64url"); + if (!record || digest !== record.challenge) { + return json(res, 400, { error: "invalid_grant" }); + } + codes.delete(code); + return json(res, 200, { + access_token: randomBytes(24).toString("base64url"), + token_type: "Bearer", + expires_in: 300, + id_token: idToken(record), + scope: "openid profile email", + }); + } + if (url.pathname === "/userinfo") { + return json(res, 200, { + sub: "strict-user", + email: process.env.E2E_OIDC_EMAIL ?? "oidc-link-user@forge.local", + email_verified: true, + name: "Forge Owner", + }); + } + return json(res, 404, { error: "not_found" }); +}).listen(port, "127.0.0.1", () => { + process.stdout.write(`[strict-oidc] listening on ${issuer}\n`); +}); diff --git a/tests/e2e/oidc-identity-linking.spec.ts b/tests/e2e/oidc-identity-linking.spec.ts new file mode 100644 index 00000000..7a924bae --- /dev/null +++ b/tests/e2e/oidc-identity-linking.spec.ts @@ -0,0 +1,139 @@ +import { createCipheriv, createHash, randomBytes, scrypt } from "node:crypto"; +import { PrismaClient } from "@prisma/client"; +import { expect, test } from "@playwright/test"; + +const localE2eDatabase = "postgresql://forge:forge@localhost:55432/forge_e2e?schema=public"; +const prisma = new PrismaClient({ + datasourceUrl: + process.env.E2E_DATABASE_URL ?? + (process.env.E2E_MANAGE_STACK === "0" ? process.env.DATABASE_URL : localE2eDatabase), +}); +const localUserEmail = process.env.E2E_OIDC_EMAIL ?? "oidc-link-user@forge.local"; +const localUserPassword = "forge strict OIDC local password"; +const issuer = process.env.E2E_OIDC_ISSUER ?? "http://127.0.0.1:3211"; +let providerId = ""; +let localUserId = ""; + +function encryptSecret(plaintext: string): string { + const secret = process.env.AUTH_SECRET ?? "e2e-secret-changeme-0000000000000000"; + const key = createHash("sha256").update(secret).digest(); + const iv = randomBytes(12); + const cipher = createCipheriv("aes-256-gcm", key, iv); + const encrypted = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return [iv, cipher.getAuthTag(), encrypted].map((part) => part.toString("base64")).join(":"); +} + +async function localSignIn(page: import("@playwright/test").Page) { + await page.goto("/signin?manual=1"); + await page.getByLabel("Email").fill(localUserEmail); + await page.getByLabel("Password").fill(localUserPassword); + await page.getByRole("button", { name: /^sign in/i }).click(); + await expect(page).not.toHaveURL(/\/signin/); +} + +async function linkStrictOidc(page: import("@playwright/test").Page) { + await page.goto("/settings/security"); + await page.getByRole("button", { name: "Link Strict OIDC" }).click(); + await expect(page.getByRole("alertdialog")).toContainText( + `You are signed in as ${localUserEmail}`, + ); + await expect(page.getByRole("alertdialog")).toContainText( + "does not create an integration connection", + ); + await page.getByRole("button", { name: "Continue to Strict OIDC" }).click(); + await expect(page).toHaveURL(/\/settings\/security/); + await expect(page.getByText("Strict OIDC", { exact: true }).first()).toBeVisible(); +} + +test.describe("strict generic OIDC identity linking", () => { + test.describe.configure({ mode: "serial" }); + test.use({ storageState: { cookies: [], origins: [] } }); + + test.beforeAll(async () => { + const workspace = await prisma.workspace.findUniqueOrThrow({ + where: { slug: "forge" }, + select: { id: true }, + }); + const salt = randomBytes(16); + const passwordKey = await new Promise((resolve, reject) => { + scrypt( + localUserPassword, + salt, + 32, + { N: 32_768, r: 8, p: 3, maxmem: 64 * 1024 * 1024 }, + (error, derived) => (error ? reject(error) : resolve(derived)), + ); + }); + const passwordHash = `$forge$scrypt$v=1$n=32768,r=8,p=3$${salt.toString("base64url")}$${passwordKey.toString("base64url")}`; + const localUser = await prisma.user.create({ + data: { + email: localUserEmail, + normalizedEmail: localUserEmail, + emailVerified: new Date(), + name: "OIDC Link Test User", + status: "ACTIVE", + localCredential: { create: { passwordHash } }, + memberships: { create: { workspaceId: workspace.id, role: "MEMBER" } }, + }, + select: { id: true }, + }); + localUserId = localUser.id; + const provider = await prisma.ssoProvider.create({ + data: { + type: "OIDC", + name: "Strict OIDC", + issuer, + clientId: "forge-strict-oidc", + clientSecret: encryptSecret("forge-strict-oidc-secret"), + scopes: "openid profile email", + allowLinking: true, + enabled: true, + }, + }); + providerId = provider.id; + }); + + test.afterAll(async () => { + if (providerId) { + await prisma.account.deleteMany({ where: { provider: providerId } }); + await prisma.ssoProvider.deleteMany({ where: { id: providerId } }); + } + if (localUserId) await prisma.user.deleteMany({ where: { id: localUserId } }); + await prisma.$disconnect(); + }); + + test("requires strong state, PKCE, and nonce while linking and relinking the same user", async ({ + page, + request, + }) => { + const weak = await request.get( + `${issuer}/authorize?client_id=forge-strict-oidc&redirect_uri=${encodeURIComponent("http://localhost:3200/api/auth/callback/example")}&response_type=code&scope=openid&state=weak&nonce=missing-pkce`, + ); + expect(weak.status()).toBe(400); + await expect(weak.json()).resolves.toEqual({ error: "weak_state" }); + + await localSignIn(page); + await linkStrictOidc(page); + + await expect( + prisma.account.findUniqueOrThrow({ + where: { + provider_providerAccountId: { provider: providerId, providerAccountId: "strict-user" }, + }, + select: { userId: true }, + }), + ).resolves.toEqual({ userId: localUserId }); + + await page.getByRole("button", { name: "Unlink" }).click(); + await expect(page.getByText("Unlink Strict OIDC?", { exact: true })).toBeVisible(); + await page.locator('[role="alertdialog"] button[type="submit"]').click(); + await expect(page).toHaveURL(/\/signin/); + await expect(prisma.account.count({ where: { provider: providerId } })).resolves.toBe(0); + + await localSignIn(page); + await linkStrictOidc(page); + await expect( + prisma.account.count({ where: { provider: providerId, userId: localUserId } }), + ).resolves.toBe(1); + }); +}); diff --git a/tests/fixtures/runtime-diagnostic-worker.ts b/tests/fixtures/runtime-diagnostic-worker.ts new file mode 100644 index 00000000..81f1553d --- /dev/null +++ b/tests/fixtures/runtime-diagnostic-worker.ts @@ -0,0 +1,42 @@ +import { Worker } from "bullmq"; + +const blockedOrigin = process.env.FORGE_TEST_BLOCKED_RUNTIME_ORIGIN; +if (!blockedOrigin) throw new Error("FORGE_TEST_BLOCKED_RUNTIME_ORIGIN is required."); + +const nativeFetch = globalThis.fetch; +globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url; + if (url.startsWith(blockedOrigin)) { + return Promise.reject(new TypeError("Worker test egress blocked.")); + } + return nativeFetch(input, init); +}) as typeof globalThis.fetch; + +async function main() { + const { executeQueuedRuntimeDiagnostic } = await import( + "../../src/server/services/runtime-diagnostics" + ); + + const connection = { url: process.env.REDIS_URL ?? "redis://localhost:6379" }; + const worker = new Worker( + "runtime-diagnostics", + async (job) => executeQueuedRuntimeDiagnostic(job.data), + { connection, concurrency: 1 }, + ); + + await worker.waitUntilReady(); + process.stdout.write("RUNTIME_DIAGNOSTIC_WORKER_READY\n"); + + async function shutdown() { + await worker.close(); + process.exit(0); + } + + process.on("SIGINT", () => void shutdown()); + process.on("SIGTERM", () => void shutdown()); +} + +void main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/tests/unit/hermes-helper-distribution.test.ts b/tests/unit/hermes-helper-distribution.test.ts new file mode 100644 index 00000000..c0bd9968 --- /dev/null +++ b/tests/unit/hermes-helper-distribution.test.ts @@ -0,0 +1,41 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +function source(path: string): string { + return readFileSync(resolve(process.cwd(), path), "utf8"); +} + +describe("Hermes helper distribution", () => { + it("ships a script-only recurring presence heartbeat that is silent on success", () => { + const heartbeat = source("integrations/hermes/forge-presence/bin/heartbeat.sh"); + const setup = source("integrations/hermes/forge-presence/bin/setup.sh"); + expect(heartbeat).toContain("/api/mcp/agents.heartbeat"); + expect(heartbeat).toContain("--output /dev/null"); + expect(heartbeat).toContain("--fail --silent --show-error"); + expect(heartbeat).not.toMatch(/hermes\s+(chat|run|session)|prompt|llm/i); + expect(setup).toContain("* * * * *"); + expect(setup).toContain("crontab -"); + }); + + it("keeps provisioning logic canonical by downloading the Forge-served script", () => { + const provision = source("integrations/hermes/forge-provision/bin/run.sh"); + expect(provision).toContain("/api/integrations/provision-script"); + expect(provision).toContain('node "$TMP"'); + expect(provision).not.toContain("git clone"); + }); + + it("packages both helpers as checksummed release assets", () => { + const packager = source("scripts/package-hermes-helpers.sh"); + const release = source(".github/workflows/release.yml"); + expect(packager).toContain("forge-presence"); + expect(packager).toContain("forge-provision"); + expect(packager).toContain("SHA256SUMS"); + expect(release).toContain("package-hermes-helpers.sh"); + expect(release).toContain("dist/hermes-helpers/*"); + expect(release).toContain("sha256sum --check SHA256SUMS"); + expect(release.indexOf("package-hermes-helpers.sh")).toBeLessThan( + release.indexOf("git push origin main"), + ); + }); +}); diff --git a/tests/unit/production-compose-network.test.ts b/tests/unit/production-compose-network.test.ts new file mode 100644 index 00000000..e36c86dc --- /dev/null +++ b/tests/unit/production-compose-network.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const compose = readFileSync( + resolve(process.cwd(), "docker/docker-compose.production.example.yml"), + "utf8", +); + +function serviceBlock(name: string, nextName: string): string { + const start = compose.indexOf(` ${name}:`); + const marker = nextName === "networks" ? "\nnetworks:" : `\n ${nextName}:`; + return compose.slice(start, compose.indexOf(marker, start + 1)); +} + +describe("reference production Compose runtime networking", () => { + it("gives the worker outbound egress without proxy membership or published ports", () => { + const worker = serviceBlock("forge-worker", "networks"); + expect(worker).toContain("networks: [forge-data, forge-egress]"); + expect(worker).not.toContain("forge-proxy"); + expect(worker).not.toMatch(/^\s+ports:/m); + expect(worker).not.toMatch(/^\s+expose:/m); + }); + + it("keeps data isolated while the dedicated egress network is non-internal", () => { + expect(compose).toMatch(/forge-data:\s*\n\s+internal: true/); + expect(compose).toMatch(/forge-egress:\s*\n(?:\s+#.*\n)*\s+driver: bridge/); + expect(compose).not.toMatch(/forge-egress:\s*\n(?:\s+#.*\n)*\s+internal: true/); + }); + + it("allows only the web app onto the public proxy network", () => { + const app = serviceBlock("forge", "forge-worker"); + expect(app).toContain("networks: [forge-data, forge-egress, forge-proxy]"); + expect(app).toContain('expose: ["3000"]'); + }); +}); diff --git a/tests/unit/sso-provider-config.test.ts b/tests/unit/sso-provider-config.test.ts new file mode 100644 index 00000000..cf0cc962 --- /dev/null +++ b/tests/unit/sso-provider-config.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from "vitest"; +import { genericOidcProviderConfig } from "@/server/services/sso-provider-config"; + +describe("genericOidcProviderConfig", () => { + it("requires PKCE, state, and nonce explicitly", () => { + const provider = genericOidcProviderConfig( + { + id: "company-oidc", + name: "Company SSO", + issuer: "https://id.example.test", + clientId: "forge", + scopes: "openid profile email", + allowLinking: false, + }, + "secret", + ); + + expect(provider).toMatchObject({ + id: "company-oidc", + type: "oidc", + checks: ["pkce", "state", "nonce"], + allowDangerousEmailAccountLinking: false, + authorization: { params: { scope: "openid profile email" } }, + }); + }); +}); diff --git a/tests/unit/transport-display.test.ts b/tests/unit/transport-display.test.ts index 75573ab8..cf34a3b8 100644 --- a/tests/unit/transport-display.test.ts +++ b/tests/unit/transport-display.test.ts @@ -5,12 +5,34 @@ import { transportModeWord, agentAvailabilityModel, presenceAvailability, + runtimeDisplayIdentity, type TransportMode, } from "@/lib/transport-display"; const MODES: TransportMode[] = ["sessions", "runs", "completions", "dispatch", "none"]; describe("transport-display", () => { + it("labels managed runtimes from adapter transport instead of REMOTE_HTTP storage kind", () => { + expect(runtimeDisplayIdentity({ adapterKey: "hermes", kind: "REMOTE_HTTP" })).toEqual({ + runtimeLabel: "Hermes managed runtime", + transportLabel: "Runs API", + }); + expect(runtimeDisplayIdentity({ adapterKey: "codex-app-server", kind: "REMOTE_HTTP" })).toEqual( + { + runtimeLabel: "Codex managed runtime", + transportLabel: "App server", + }, + ); + }); + + it("reserves webhook copy for the actual custom webhook adapter", () => { + expect(runtimeDisplayIdentity({ adapterKey: "custom-http", kind: "REMOTE_HTTP" })).toEqual({ + runtimeLabel: "Custom webhook runtime", + transportLabel: "Webhook", + }); + expect(runtimeDisplayIdentity({ kind: "REMOTE_HTTP" }).runtimeLabel).toBe("Remote runtime"); + }); + it("returns a tone for every mode", () => { for (const m of MODES) { expect(transportTone(m).length).toBeGreaterThan(0); @@ -77,22 +99,38 @@ describe("agentAvailabilityModel", () => { it("dispatch + completions agents with no heartbeat are on-demand", () => { expect( - agentAvailabilityModel({ runtimeMode: "PERSISTENT", lastHeartbeatAt: null, transportMode: "dispatch" }), + agentAvailabilityModel({ + runtimeMode: "PERSISTENT", + lastHeartbeatAt: null, + transportMode: "dispatch", + }), ).toBe("on-demand"); expect( - agentAvailabilityModel({ runtimeMode: "PERSISTENT", lastHeartbeatAt: null, transportMode: "completions" }), + agentAvailabilityModel({ + runtimeMode: "PERSISTENT", + lastHeartbeatAt: null, + transportMode: "completions", + }), ).toBe("on-demand"); }); it("ephemeral agents are session", () => { expect( - agentAvailabilityModel({ runtimeMode: "EPHEMERAL", lastHeartbeatAt: null, transportMode: "runs" }), + agentAvailabilityModel({ + runtimeMode: "EPHEMERAL", + lastHeartbeatAt: null, + transportMode: "runs", + }), ).toBe("session"); }); it("no chat path + no heartbeat falls back to heartbeat display", () => { expect( - agentAvailabilityModel({ runtimeMode: "PERSISTENT", lastHeartbeatAt: null, transportMode: "none" }), + agentAvailabilityModel({ + runtimeMode: "PERSISTENT", + lastHeartbeatAt: null, + transportMode: "none", + }), ).toBe("heartbeat"); }); }); @@ -106,7 +144,11 @@ describe("presenceAvailability (base-column derivation)", () => { it("a CUSTOM agent with a webhook, no heartbeat → on-demand", () => { expect( - presenceAvailability({ provider: "CUSTOM", runtimeMode: "PERSISTENT", webhookUrl: "https://x" }), + presenceAvailability({ + provider: "CUSTOM", + runtimeMode: "PERSISTENT", + webhookUrl: "https://x", + }), ).toBe("on-demand"); }); @@ -118,12 +160,19 @@ describe("presenceAvailability (base-column derivation)", () => { it("an agent that has heartbeat uses the heartbeat model", () => { expect( - presenceAvailability({ provider: "CODEX", runtimeMode: "PERSISTENT", lastHeartbeatAt: new Date(), runtimeId: "rt" }), + presenceAvailability({ + provider: "CODEX", + runtimeMode: "PERSISTENT", + lastHeartbeatAt: new Date(), + runtimeId: "rt", + }), ).toBe("heartbeat"); }); it("an unconfigured agent (no runtime/webhook/heartbeat) stays heartbeat (shows status)", () => { - expect(presenceAvailability({ provider: "CODEX", runtimeMode: "PERSISTENT" })).toBe("heartbeat"); + expect(presenceAvailability({ provider: "CODEX", runtimeMode: "PERSISTENT" })).toBe( + "heartbeat", + ); }); it("ephemeral → session; missing fields are null-safe", () => {