diff --git a/.docker/app/Dockerfile b/.docker/app/Dockerfile index 97d9c49b3..62a89bf84 100644 --- a/.docker/app/Dockerfile +++ b/.docker/app/Dockerfile @@ -283,7 +283,7 @@ ENTRYPOINT ["/usr/bin/tini", "--", "/roomote/.docker/app/entrypoint.sh"] # control-plane calls) starts a managed OpenCode SDK server in-process. FROM runtime-base AS runtime-inference-base -ARG OPENCODE_CLI_VERSION=1.17.8 +ARG OPENCODE_CLI_VERSION=1.17.18 # The install and version check run as root; with the image's HOME=/tmp they # would bake root-owned dotdirs (npm cache, OpenCode's data/config/cache diff --git a/.docker/sandbox/install-worker.sh b/.docker/sandbox/install-worker.sh index 5c2e16425..65954b745 100644 --- a/.docker/sandbox/install-worker.sh +++ b/.docker/sandbox/install-worker.sh @@ -12,14 +12,15 @@ # # Installation modes: # -# Vercel Sandbox (production + local dev): +# Hosted sandbox (production + local dev): # - Controller uploads this script + archive to /sandbox/worker.tar.gz # - This script installs from the uploaded worker release archive # -# Vercel Sandbox (snapshot resume): +# Hosted sandbox (snapshot resume): # - Environment snapshot launches restore a cached sandbox first, then run # this script to refresh the shipped Roomote worker/runtime in place -# - Task snapshot launches preserve exact state and skip this script +# - Task snapshot launches also refresh the shipped worker/runtime while +# preserving repositories, harness sessions, and other snapshot state # # Build worker release archive: ./scripts/build-worker-release.sh diff --git a/.env.local.example b/.env.local.example index 23b40f044..a58795cff 100644 --- a/.env.local.example +++ b/.env.local.example @@ -25,6 +25,8 @@ # Optional: local integration credentials. Leave unset until the integration is # configured for this local instance. # R_ALLOWED_EMAILS=you@example.com,teammate@example.com +# Optional paid-seat license key (RMLK1.…). Takes precedence over Settings → Users. +# R_LICENSE_KEY= # Required for local tasks. Use models.dev-style provider/model ids. # R_MODEL=openrouter/anthropic/claude-sonnet-4 diff --git a/.env.production.example b/.env.production.example index 87ed182d2..a382920e1 100644 --- a/.env.production.example +++ b/.env.production.example @@ -43,9 +43,9 @@ PREVIEW_AUTH_PUBLIC_KEY= # Random single-line secrets generated by the operator. ENCRYPTION_KEY= # Shared internal secret for Discord-gateway→API event delivery (API + BullMQ). -# Required when Discord is enabled. Use a dedicated random value — do not reuse -# ENCRYPTION_KEY. The installer and Railway/Render/Coolify templates generate -# it; UI Discord save also mints it when missing. +# Required when Discord is enabled. The installer and Railway/Render/Coolify +# templates generate it; UI Discord save also mints it when missing, and the +# runtime auto-heals when Discord is already configured without one. # R_DISCORD_GATEWAY_SECRET= ARTIFACT_SIGNING_KEY= DASHBOARD_PASSWORD= @@ -71,7 +71,6 @@ OPENROUTER_API_KEY= # ANTHROPIC_API_KEY= # GOOGLE_GENERATIVE_AI_API_KEY= # GEMINI_API_KEY= -# MISTRAL_API_KEY= # MOONSHOT_API_KEY= # MINIMAX_API_KEY= # OPENCODE_API_KEY= @@ -120,6 +119,8 @@ DEFAULT_COMPUTE_PROVIDER=docker # Sign-in providers. # R_ALLOWED_EMAILS= +# Optional paid-seat license key (RMLK1.…). Takes precedence over Settings → Users. +# R_LICENSE_KEY= # R_SLACK_CLIENT_ID= # R_SLACK_CLIENT_SECRET= diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 044c12b10..abfb42436 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -7,6 +7,9 @@ on: branches: [main, develop] workflow_dispatch: +permissions: + contents: read + env: NODE_VERSION: 24.13.1 PNPM_VERSION: 10.29.3 diff --git a/.github/workflows/publish-ghcr.yml b/.github/workflows/publish-ghcr.yml index ceb691655..3cd583c45 100644 --- a/.github/workflows/publish-ghcr.yml +++ b/.github/workflows/publish-ghcr.yml @@ -28,6 +28,8 @@ jobs: prepare: name: Resolve build metadata runs-on: blacksmith-4vcpu-ubuntu-2404 + permissions: + contents: read outputs: docs_only: ${{ steps.changed-paths.outputs.docs_only }} owner: ${{ steps.vars.outputs.owner }} diff --git a/AGENTS.md b/AGENTS.md index 21e1dce7b..568bda979 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,6 +48,7 @@ This repository is open source. Treat GitHub and other public surfaces as fully - Treat absolute home-directory skill paths such as `/home/roomote/.agents/skills/...` as activated or installed runtime copies, not as the checked-in source of truth for repository changes. - Treat workflow prompts and instructions as a first-class control surface. When agent behavior is off, debug prompt clarity before defaulting to code enforcement. - `apps/docs/` is the public product documentation site (published at `https://docs.roomote.dev`) and should be kept in sync with user-facing product changes. +- **Schema N-1 rollback guarantee:** Roomote must always be able to roll application code back one release against the current database. Do not drop tables or columns that the previous release still reads or writes in the same release that removes the feature. Stop using the columns in app code first, keep them in `packages/db` with an explicit N-1 comment, and drop them only after the next release is the supported rollback target. See `packages/db/AGENTS.md` for the package-local rules. ## Slack message formatting diff --git a/CHANGELOG.md b/CHANGELOG.md index ccce0c40d..efa137ce8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,28 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 0.8.0 (2026-07-17) + +### Minor changes + +- Route task-sandbox inference through a control-plane gateway for every deployment: provider API keys and ChatGPT subscription auth stay server-side, sandboxes call `/api/inference` with a run-scoped token, and the InferenceGateway feature flag is removed so this is always on. +- Allow self-hosted operators to set the paid-seat license key via the `R_LICENSE_KEY` environment variable (takes precedence over Settings → Users). +- Add provider-local model mapping presets in Settings so operators can choose labeled mapping sets (including OpenRouter Balanced and Quick turnaround), confirm the selected mapping before it applies, and automatically add or enable referenced models. +- Remove the authorship-rules feature (settings UI, compiler, and enqueue-time evaluation). Task commit authors and PR assignees now always use default attribution. + +### Patch changes + +- Automation labels spell the CodeQL brand correctly, so `codeql_triage` surfaces as “CodeQL Triage” instead of “Codeql Triage” in task filters, analytics, and attribution. +- Temporarily disable Google Vertex AI and remove legacy direct Mistral execution. Model-provider credentials now enter task sandboxes only through the selected runtime provider allowlist, while unrelated task environment variables remain available. +- Clarify the Discord install flow (including dropping the permissions integer from operator-facing guidance) and recover the Discord gateway when a deployment never received a gateway secret instead of staying stuck offline. +- When both an OpenAI API key and a ChatGPT subscription are connected, Settings again shows a separate OpenAI provider section instead of folding every `openai/` model under ChatGPT (subscription). +- Tasks no longer abort when OpenCode surfaces a provider rate-limit as a terminal session error; the worker treats those limits as retryable and continues the run after backoff. +- OpenRouter Connect works for self-hosted deployments whose public app URL is a loopback address, instead of failing the OAuth handoff in that configuration. +- Refresh the shipped worker runtime when restoring task snapshots so snapshots created by an older release remain compatible with current runtime protocols such as the inference gateway. +- Shared links into the product app now resolve to short static page titles and one-line descriptions (task, settings, history, sign-in, setup, onboarding) instead of the generic global fallback. +- Harden high-confidence security gaps: shell-escape untrusted git and GitHub CLI arguments, tighten OAuth account linking, and strengthen run-token authentication used by sandbox runtime traffic. +- Slack transcript decoding no longer hangs when thread activity contains crafted or pathological input. + ## 0.7.1 (2026-07-16) ### Patch changes diff --git a/LOCAL_DEVELOPMENT.md b/LOCAL_DEVELOPMENT.md index ec348566b..95a01ad82 100644 --- a/LOCAL_DEVELOPMENT.md +++ b/LOCAL_DEVELOPMENT.md @@ -246,8 +246,8 @@ pnpm eval:router:share Eval configs and datasets live in `packages/cloud-agents/evals/router/`. They are not run in CI by default. Runtime routing, title generation, and summaries use `R_SMALL_MODEL`, falling back to `R_MODEL`. Set -`ROUTER_EVAL_PROVIDER`, `ROUTER_FOLLOWUP_EVAL_PROVIDER`, or -`AUTHORSHIP_RULES_EVAL_PROVIDER` to test a different promptfoo provider. +`ROUTER_EVAL_PROVIDER` or `ROUTER_FOLLOWUP_EVAL_PROVIDER` to test a +different promptfoo provider. ## Troubleshooting diff --git a/README.md b/README.md index 1583d0932..fd9093a7a 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,7 @@ cleans up after itself. your account directly. No API key needed, no separate billing. Roomote uses the models included in your subscription. 2. **API keys (BYOK).** Paste a key from OpenRouter, Anthropic, OpenAI, xAI, - Google Gemini, Amazon Bedrock, Google Vertex AI, Vercel AI Gateway, + Google Gemini, Amazon Bedrock, Vercel AI Gateway, Baseten, Together AI, Moonshot AI (Kimi), MiniMax, or OpenCode Zen / Go. **Sandbox compute:** Modal, E2B, Daytona, Blaxel, and Local Docker. @@ -238,7 +238,7 @@ it runs. **What models does it support?** Two options. Connect your ChatGPT Plus or Pro subscription directly (no API key needed), or paste an API key from OpenRouter, Anthropic, OpenAI, xAI, Google -Gemini, Amazon Bedrock, Google Vertex AI, Vercel AI Gateway, Baseten, +Gemini, Amazon Bedrock, Vercel AI Gateway, Baseten, Together AI, Moonshot AI (Kimi), MiniMax, or OpenCode Zen / Go. **What sandboxes does it support?** diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index 6ade6f848..98b682304 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -290,7 +290,6 @@ common provider keys into worker containers: - `AI_GATEWAY_API_KEY` (Vercel AI Gateway, `vercel/...` models) - `OPENAI_API_KEY` - `ANTHROPIC_API_KEY` -- `MISTRAL_API_KEY` - `MOONSHOT_API_KEY` - `MINIMAX_API_KEY` - `OPENCODE_API_KEY` @@ -484,7 +483,7 @@ base64 < preview-auth-private-pkcs8.pem | tr -d '\n' # PREVIEW_AUTH_PRIVATE_KEY base64 < preview-auth-public.pem | tr -d '\n' # PREVIEW_AUTH_PUBLIC_KEY openssl rand -base64 32 # ENCRYPTION_KEY -openssl rand -base64 32 # R_DISCORD_GATEWAY_SECRET (dedicated; do not reuse ENCRYPTION_KEY) +openssl rand -base64 32 # R_DISCORD_GATEWAY_SECRET openssl rand -base64 32 # ARTIFACT_SIGNING_KEY openssl rand -base64 24 # DASHBOARD_PASSWORD openssl rand -hex 16 # SETUP_TOKEN @@ -500,7 +499,7 @@ always take precedence. The random string secrets (`ENCRYPTION_KEY`, `R_DISCORD_GATEWAY_SECRET`, `ARTIFACT_SIGNING_KEY`, `DASHBOARD_PASSWORD`, `SETUP_TOKEN`) are still required and can come from the platform's secret generator. API and BullMQ must share the same `R_DISCORD_GATEWAY_SECRET` value -when Discord is enabled; that value must not be the same as `ENCRYPTION_KEY`. +when Discord is enabled. ### Port exposure and the queue dashboard @@ -790,8 +789,14 @@ registered user account counts toward the limit, whichever sign-in path or surface it uses. Removing a user frees their seat. To go beyond 10 users, obtain a license key from the Roomote maintainers and -enter it in **Settings → Users → License** as an admin. Keys are verified -offline; your deployment never phones home. +apply it in either of these ways: + +- Enter it in **Settings → Users → License** as an admin, or +- Set `R_LICENSE_KEY` in the deployment environment (for example + `.env.production` / Compose). When set, the env var takes precedence over + any key stored in Settings. + +Keys are verified offline; your deployment never phones home. When a deployment is at its seat limit, existing users are unaffected — only new sign-ups are blocked until a seat is freed (Settings → Users → remove a diff --git a/apps/api/src/handlers/index.ts b/apps/api/src/handlers/index.ts index 3c72a3a56..33ac2be76 100644 --- a/apps/api/src/handlers/index.ts +++ b/apps/api/src/handlers/index.ts @@ -22,6 +22,9 @@ export { trpc } from './trpc'; export { mcp } from './mcp'; export { mcpRouting } from './mcp/routing'; +// inference gateway +export { inference } from './inference'; + // task runs export { taskRunsRouter } from './task-runs'; diff --git a/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts new file mode 100644 index 000000000..998a33729 --- /dev/null +++ b/apps/api/src/handlers/inference/__tests__/inference-gateway.test.ts @@ -0,0 +1,548 @@ +import { Hono } from 'hono'; +import type { AuthTokenContext, RunTokenContext } from '@roomote/types'; + +import type { Variables } from '../../../types'; + +const { + mockFindTaskRun, + mockResolveModelProviderEnvValue, + mockGetFreshChatGptAccessToken, +} = vi.hoisted(() => ({ + mockFindTaskRun: vi.fn(), + mockResolveModelProviderEnvValue: vi.fn(), + mockGetFreshChatGptAccessToken: vi.fn(), +})); + +vi.mock('@roomote/db/server', () => ({ + db: { + query: { + taskRuns: { findFirst: mockFindTaskRun }, + }, + }, + taskRuns: { id: 'id' }, + eq: vi.fn((column: unknown, value: unknown) => ({ column, value })), + resolveModelProviderEnvValue: mockResolveModelProviderEnvValue, + getFreshChatGptAccessToken: mockGetFreshChatGptAccessToken, +})); + +import { inference } from '../index'; + +function createApp(authContext: Variables['authContext']) { + const app = new Hono<{ Variables: Variables }>(); + + app.use('*', async (c, next) => { + c.set('authContext', authContext); + await next(); + }); + + app.route('/api/inference', inference); + return app; +} + +function createRunToken(overrides?: Partial): RunTokenContext { + return { + runId: 42, + userId: null, + principal: 'deployment', + tokenType: 'run', + version: 1, + ...(overrides ?? {}), + }; +} + +function createUserToken(): AuthTokenContext { + return { + userId: 'user-1', + tokenType: 'auth', + } as AuthTokenContext; +} + +function stubUpstreamFetch(response?: Response) { + const fetchMock = vi.fn().mockResolvedValue( + response ?? + new Response(JSON.stringify({ id: 'msg_1' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; +} + +async function postMessages( + app: Hono<{ Variables: Variables }>, + path = '/api/inference/anthropic/v1/messages', + headers: Record = {}, +) { + return app.request(path, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer run-token-value', + ...headers, + }, + body: JSON.stringify({ model: 'claude-sonnet-5', max_tokens: 16 }), + }); +} + +describe('inference gateway', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllGlobals(); + mockFindTaskRun.mockResolvedValue({ id: 42 }); + mockResolveModelProviderEnvValue.mockImplementation( + async (names: string | readonly string[]) => { + const nameList = typeof names === 'string' ? [names] : names; + + // Region lookups resolve separately from API keys. + return nameList.includes('AWS_REGION') + ? undefined + : 'provider-secret-key'; + }, + ); + }); + + it('rejects user auth tokens', async () => { + const fetchMock = stubUpstreamFetch(); + const response = await postMessages(createApp(createUserToken())); + + expect(response.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects requests with no auth context', async () => { + const fetchMock = stubUpstreamFetch(); + const response = await postMessages(createApp(undefined)); + + expect(response.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects run tokens whose task run no longer exists', async () => { + const fetchMock = stubUpstreamFetch(); + mockFindTaskRun.mockResolvedValue(undefined); + + const response = await postMessages(createApp(createRunToken())); + + expect(response.status).toBe(404); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects unknown providers', async () => { + const fetchMock = stubUpstreamFetch(); + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/not-a-provider/v1/messages', + ); + + expect(response.status).toBe(404); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects upstream paths outside the provider allowlist', async () => { + const fetchMock = stubUpstreamFetch(); + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/anthropic/v1/organizations/members', + ); + + expect(response.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects sibling paths that share an allowed prefix without a separator', async () => { + const fetchMock = stubUpstreamFetch(); + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/anthropic/v1/messages-admin', + ); + + expect(response.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects provider account paths nested below former inference prefixes', async () => { + const fetchMock = stubUpstreamFetch(); + const app = createApp(createRunToken()); + + const [anthropicResponse, openRouterResponse] = await Promise.all([ + postMessages(app, '/api/inference/anthropic/v1/messages/batches'), + postMessages(app, '/api/inference/openrouter/v1/key'), + ]); + + expect(anthropicResponse.status).toBe(403); + expect(openRouterResponse.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects encoded-slash and dot-segment traversal under nested-path providers', async () => { + const fetchMock = stubUpstreamFetch(); + const app = createApp(createRunToken()); + + const [googleEncoded, googleDots, vercelEncoded] = await Promise.all([ + postMessages( + app, + '/api/inference/google/v1beta/models/..%2F..%2Fv1internal', + ), + postMessages(app, '/api/inference/google/v1beta/models/../admin'), + postMessages(app, '/api/inference/vercel/v1/ai/..%2Fadmin'), + ]); + + expect(googleEncoded.status).toBe(403); + expect(googleDots.status).toBe(403); + expect(vercelEncoded.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('proxies OpenAI-compatible aggregator providers with bearer auth', async () => { + const app = createApp(createRunToken()); + + const cases: Array<[string, string]> = [ + ['requesty', 'https://router.requesty.ai/v1/chat/completions'], + ['baseten', 'https://inference.baseten.co/v1/chat/completions'], + ['togetherai', 'https://api.together.xyz/v1/chat/completions'], + ['moonshotai', 'https://api.moonshot.ai/v1/chat/completions'], + ['opencode', 'https://opencode.ai/zen/v1/chat/completions'], + ['xai', 'https://api.x.ai/v1/chat/completions'], + ]; + + for (const [providerId, expectedUrl] of cases) { + // A Response body can only be consumed once, so re-stub per provider. + const fetchMock = stubUpstreamFetch(); + + const response = await postMessages( + app, + `/api/inference/${providerId}/v1/chat/completions`, + ); + + expect(response.status).toBe(200); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe(expectedUrl); + expect(new Headers(init.headers).get('authorization')).toBe( + 'Bearer provider-secret-key', + ); + } + }); + + it('proxies MiniMax through its Anthropic-compatible endpoint', async () => { + const fetchMock = stubUpstreamFetch(); + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/minimax/v1/messages', + ); + + expect(response.status).toBe(200); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.minimax.io/anthropic/v1/messages'); + expect(new Headers(init.headers).get('x-api-key')).toBe( + 'provider-secret-key', + ); + }); + + it('allows nested paths under the Vercel AI Gateway protocol base', async () => { + const fetchMock = stubUpstreamFetch(); + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/vercel/v1/ai/language-model', + ); + + expect(response.status).toBe(200); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toBe('https://ai-gateway.vercel.sh/v1/ai/language-model'); + }); + + it('substitutes the default region for Bedrock upstreams', async () => { + const fetchMock = stubUpstreamFetch(); + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/bedrock-mantle/v1/messages', + ); + + expect(response.status).toBe(200); + expect(mockResolveModelProviderEnvValue).toHaveBeenCalledWith([ + 'AWS_BEARER_TOKEN_BEDROCK', + ]); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + 'https://bedrock-mantle.us-east-1.api.aws/anthropic/v1/messages', + ); + expect(new Headers(init.headers).get('x-api-key')).toBe( + 'provider-secret-key', + ); + }); + + it('substitutes a configured region for Bedrock upstreams', async () => { + const fetchMock = stubUpstreamFetch(); + mockResolveModelProviderEnvValue.mockImplementation( + async (names: string | readonly string[]) => { + const nameList = typeof names === 'string' ? [names] : names; + + return nameList.includes('AWS_REGION') + ? 'eu-west-1' + : 'provider-secret-key'; + }, + ); + + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/bedrock-mantle/v1/messages', + ); + + expect(response.status).toBe(200); + + const [url] = fetchMock.mock.calls[0] as [string]; + expect(url).toBe( + 'https://bedrock-mantle.eu-west-1.api.aws/anthropic/v1/messages', + ); + }); + + it('rejects invalid Bedrock regions before building the upstream URL', async () => { + const fetchMock = stubUpstreamFetch(); + mockResolveModelProviderEnvValue.mockImplementation( + async (names: string | readonly string[]) => { + const nameList = typeof names === 'string' ? [names] : names; + + return nameList.includes('AWS_REGION') + ? 'https://evil.example.com/' + : 'provider-secret-key'; + }, + ); + + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/bedrock-mantle/v1/messages', + ); + + expect(response.status).toBe(500); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + describe('ChatGPT subscription (chatgpt-oauth strategy)', () => { + async function postChatGpt( + app: Hono<{ Variables: Variables }>, + path = '/api/inference/openai-chatgpt/v1/responses', + headers: Record = {}, + ) { + return app.request(path, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer run-token-value', + ...headers, + }, + body: JSON.stringify({ model: 'gpt-5-codex', input: 'hi' }), + }); + } + + it('mints a token, rewrites to the Codex backend, and injects account id', async () => { + const fetchMock = stubUpstreamFetch(); + mockGetFreshChatGptAccessToken.mockResolvedValue({ + access: 'oauth-access-token', + refresh: 'oauth-refresh', + expires: Date.now() + 3_600_000, + accountId: 'acct_123', + }); + + const response = await postChatGpt(createApp(createRunToken())); + + expect(response.status).toBe(200); + // The API key resolver must not be consulted for OAuth providers. + expect(mockResolveModelProviderEnvValue).not.toHaveBeenCalled(); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://chatgpt.com/backend-api/codex/responses'); + + const headers = new Headers(init.headers); + expect(headers.get('authorization')).toBe('Bearer oauth-access-token'); + expect(headers.get('ChatGPT-Account-Id')).toBe('acct_123'); + }); + + it('strips the run token and any smuggled account id from the sandbox', async () => { + const fetchMock = stubUpstreamFetch(); + mockGetFreshChatGptAccessToken.mockResolvedValue({ + access: 'oauth-access-token', + refresh: 'oauth-refresh', + expires: Date.now() + 3_600_000, + accountId: 'acct_real', + }); + + await postChatGpt(createApp(createRunToken()), undefined, { + 'chatgpt-account-id': 'acct_attacker', + }); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + const headers = new Headers(init.headers); + expect(headers.get('ChatGPT-Account-Id')).toBe('acct_real'); + expect(headers.get('authorization')).toBe('Bearer oauth-access-token'); + }); + + it('returns 404 when no ChatGPT subscription is connected', async () => { + const fetchMock = stubUpstreamFetch(); + mockGetFreshChatGptAccessToken.mockResolvedValue(null); + + const response = await postChatGpt(createApp(createRunToken())); + + expect(response.status).toBe(404); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('rejects non-inference paths on the ChatGPT segment', async () => { + const fetchMock = stubUpstreamFetch(); + const response = await postChatGpt( + createApp(createRunToken()), + '/api/inference/openai-chatgpt/v1/account', + ); + + expect(response.status).toBe(403); + expect(fetchMock).not.toHaveBeenCalled(); + }); + }); + + it('returns 404 when the provider key is not configured', async () => { + const fetchMock = stubUpstreamFetch(); + mockResolveModelProviderEnvValue.mockResolvedValue(undefined); + + const response = await postMessages(createApp(createRunToken())); + + expect(response.status).toBe(404); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('forwards to the upstream with the provider key and strips the run token', async () => { + const fetchMock = stubUpstreamFetch(); + const response = await postMessages( + createApp(createRunToken()), + undefined, + { + 'anthropic-version': '2023-06-01', + }, + ); + + expect(response.status).toBe(200); + expect(mockResolveModelProviderEnvValue).toHaveBeenCalledWith([ + 'ANTHROPIC_API_KEY', + ]); + expect(fetchMock).toHaveBeenCalledTimes(1); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://api.anthropic.com/v1/messages'); + + const headers = new Headers(init.headers); + expect(headers.get('x-api-key')).toBe('provider-secret-key'); + expect(headers.get('authorization')).toBeNull(); + expect(headers.get('anthropic-version')).toBe('2023-06-01'); + + const forwardedBody = await new Response(init.body).text(); + expect(JSON.parse(forwardedBody)).toEqual({ + model: 'claude-sonnet-5', + max_tokens: 16, + }); + }); + + it('sends Bearer-prefixed keys for bearer-auth providers', async () => { + const fetchMock = stubUpstreamFetch(); + const response = await postMessages( + createApp(createRunToken()), + '/api/inference/openrouter/v1/chat/completions', + ); + + expect(response.status).toBe(200); + expect(mockResolveModelProviderEnvValue).toHaveBeenCalledWith([ + 'OPENROUTER_API_KEY', + ]); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('https://openrouter.ai/api/v1/chat/completions'); + + const headers = new Headers(init.headers); + expect(headers.get('authorization')).toBe('Bearer provider-secret-key'); + }); + + it('preserves the query string on upstream requests', async () => { + const fetchMock = stubUpstreamFetch(); + const app = createApp(createRunToken()); + + const response = await app.request( + '/api/inference/google/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse', + { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer run-token-value', + }, + body: JSON.stringify({}), + }, + ); + + expect(response.status).toBe(200); + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe( + 'https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:streamGenerateContent?alt=sse', + ); + + const headers = new Headers(init.headers); + expect(headers.get('x-goog-api-key')).toBe('provider-secret-key'); + expect(mockResolveModelProviderEnvValue).toHaveBeenCalledWith([ + 'GOOGLE_GENERATIVE_AI_API_KEY', + 'GEMINI_API_KEY', + ]); + }); + + it('streams the upstream response body and status through', async () => { + const upstreamBody = 'event: message_start\ndata: {}\n\n'; + stubUpstreamFetch( + new Response(upstreamBody, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }), + ); + + const response = await postMessages(createApp(createRunToken())); + + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toBe('text/event-stream'); + expect(await response.text()).toBe(upstreamBody); + }); + + it('passes upstream error statuses through', async () => { + stubUpstreamFetch( + new Response(JSON.stringify({ error: { type: 'overloaded_error' } }), { + status: 529, + headers: { 'content-type': 'application/json' }, + }), + ); + + const response = await postMessages(createApp(createRunToken())); + + expect(response.status).toBe(529); + }); + + it('returns 502 when the upstream fetch fails', async () => { + const fetchMock = vi + .fn() + .mockRejectedValue(new Error('connect ECONNREFUSED')); + vi.stubGlobal('fetch', fetchMock); + + const response = await postMessages(createApp(createRunToken())); + + expect(response.status).toBe(502); + }); + + it('rejects unsupported methods', async () => { + const fetchMock = stubUpstreamFetch(); + const app = createApp(createRunToken()); + + const response = await app.request('/api/inference/anthropic/v1/messages', { + method: 'DELETE', + headers: { authorization: 'Bearer run-token-value' }, + }); + + expect(response.status).toBe(405); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/api/src/handlers/inference/index.ts b/apps/api/src/handlers/inference/index.ts new file mode 100644 index 000000000..8148f29ca --- /dev/null +++ b/apps/api/src/handlers/inference/index.ts @@ -0,0 +1,247 @@ +import { Hono } from 'hono'; + +import { formatSingleLineLog } from '@roomote/types'; +import { db, eq, taskRuns } from '@roomote/db/server'; + +import type { Variables } from '../../types'; +import { fetchWithLongLivedStreamDispatcher } from '../long-lived-fetch'; +import { createLoggedProxyResponseBody } from '../proxy-response-stream'; +import { + buildProxyResponseHeaders, + isRunTokenContext, +} from '../mcp/proxy-utils'; +import { + getInferenceProvider, + isInferencePathAllowed, + resolveGatewayUpstream, + type GatewayUpstreamResolution, +} from './registry'; + +/** + * Client headers never forwarded upstream. The client's own credential + * headers (its bearer token is the Roomote run token) are replaced with the + * provider key; hop-by-hop and proxy-added routing headers are dropped so the + * upstream sees a clean direct request. + */ +const REQUEST_HEADER_DENYLIST = new Set([ + 'authorization', + 'x-api-key', + 'x-goog-api-key', + // The gateway sets the ChatGPT account-id authoritatively from the OAuth + // record; a sandbox must not be able to smuggle its own. + 'chatgpt-account-id', + 'cookie', + 'host', + 'connection', + 'content-length', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', + 'expect', + 'forwarded', + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', + 'x-real-ip', +]); + +function buildUpstreamRequestHeaders( + requestHeaders: Headers, + injectedHeaders: Record, +): Headers { + const headers = new Headers(); + + for (const [key, value] of requestHeaders.entries()) { + if (!REQUEST_HEADER_DENYLIST.has(key.toLowerCase())) { + headers.set(key, value); + } + } + + for (const [key, value] of Object.entries(injectedHeaders)) { + headers.set(key, value); + } + + return headers; +} + +/** + * The upstream path is everything after the provider segment. The router + * only matches `/:provider/*`, so the marker is always present. + */ +function extractUpstreamPath(pathname: string, providerId: string): string { + const marker = `/${providerId}/`; + const markerIndex = pathname.indexOf(marker); + + return markerIndex === -1 + ? '' + : pathname.slice(markerIndex + marker.length - 1); +} + +/** + * Inference gateway: forwards LLM API traffic from task sandboxes to model + * providers, injecting the deployment's provider key server-side. Sandboxes + * authenticate with their run-scoped token and never hold the key itself. + * + * Requests stream through in both directions; inference responses are + * long-lived SSE streams, so upstream fetches use the no-body-timeout + * dispatcher and rely on caller disconnects for cleanup. + */ +export const inference = new Hono<{ Variables: Variables }>(); + +inference.on(['POST', 'GET'], '/:provider/*', async (c) => { + const startedAt = Date.now(); + const requestId = crypto.randomUUID(); + const method = c.req.method; + const providerId = c.req.param('provider'); + const pathname = new URL(c.req.url).pathname; + const logPrefix = `[Inference Gateway:${providerId}]`; + + const auth = c.get('authContext'); + + if (!auth || !isRunTokenContext(auth)) { + return c.json( + { error: 'The inference gateway requires a task run token' }, + 403, + ); + } + + const taskRun = await db.query.taskRuns.findFirst({ + columns: { id: true }, + where: eq(taskRuns.id, auth.runId), + }); + + if (!taskRun) { + return c.json({ error: 'Task run not found for this token' }, 404); + } + + const provider = getInferenceProvider(providerId); + + if (!provider) { + return c.json({ error: `Unknown inference provider "${providerId}"` }, 404); + } + + const upstreamPath = extractUpstreamPath(pathname, providerId); + + if (!isInferencePathAllowed(provider, upstreamPath)) { + console.warn( + formatSingleLineLog(`${logPrefix} Rejected disallowed upstream path`, { + requestId, + method, + runId: auth.runId, + upstreamPath, + }), + ); + + return c.json( + { + error: `Path is not allowed through the ${provider.name} inference gateway`, + }, + 403, + ); + } + + const search = new URL(c.req.url).search; + + let resolution: GatewayUpstreamResolution; + + try { + resolution = await resolveGatewayUpstream(provider, upstreamPath, search); + } catch (error) { + console.error( + formatSingleLineLog(`${logPrefix} Failed to resolve provider config`, { + requestId, + method, + runId: auth.runId, + error: error instanceof Error ? error.message : String(error), + }), + ); + + return c.json( + { error: `Failed to resolve the ${provider.name} configuration` }, + 500, + ); + } + + if (!resolution.ok) { + return c.json({ error: resolution.error }, resolution.status); + } + + const { upstreamUrl, headers: injectedHeaders } = resolution.resolved; + + try { + const upstreamResponse = await fetchWithLongLivedStreamDispatcher( + upstreamUrl, + { + method, + headers: buildUpstreamRequestHeaders( + c.req.raw.headers, + injectedHeaders, + ), + body: c.req.raw.body, + signal: c.req.raw.signal, + // Required by undici when streaming a request body. + ...(c.req.raw.body ? { duplex: 'half' as const } : {}), + }, + ); + + if (!upstreamResponse.ok) { + console.warn( + formatSingleLineLog(`${logPrefix} Upstream returned non-OK status`, { + requestId, + method, + runId: auth.runId, + upstreamPath, + status: upstreamResponse.status, + statusText: upstreamResponse.statusText, + elapsedMs: Date.now() - startedAt, + }), + ); + } + + return new Response( + createLoggedProxyResponseBody({ + body: upstreamResponse.body, + logPrefix: `${logPrefix} Upstream response stream failed`, + getLogFields: () => ({ + requestId, + method, + runId: auth.runId, + upstreamPath, + status: upstreamResponse.status, + elapsedMs: Date.now() - startedAt, + }), + trackingContext: { + route: `inference:${providerId}`, + method, + path: pathname, + requestId, + }, + }), + { + status: upstreamResponse.status, + headers: buildProxyResponseHeaders(upstreamResponse.headers), + }, + ); + } catch (error) { + console.error( + formatSingleLineLog(`${logPrefix} Upstream fetch failed`, { + requestId, + method, + runId: auth.runId, + upstreamPath, + elapsedMs: Date.now() - startedAt, + error: error instanceof Error ? error.message : String(error), + }), + ); + + return c.json({ error: `Failed to reach the ${provider.name} API` }, 502); + } +}); + +inference.all('/:provider/*', (c) => + c.json({ error: 'Method not allowed' }, 405), +); diff --git a/apps/api/src/handlers/inference/registry.ts b/apps/api/src/handlers/inference/registry.ts new file mode 100644 index 000000000..7e967e785 --- /dev/null +++ b/apps/api/src/handlers/inference/registry.ts @@ -0,0 +1,175 @@ +import { + CHATGPT_ACCOUNT_ID_HEADER, + getInferenceGatewayProvider, + INFERENCE_GATEWAY_REGION_PATTERN, + type InferenceGatewayProvider, +} from '@roomote/types'; +import { + getFreshChatGptAccessToken, + resolveModelProviderEnvValue, +} from '@roomote/db/server'; + +export function getInferenceProvider( + providerId: string, +): InferenceGatewayProvider | undefined { + return getInferenceGatewayProvider(providerId); +} + +/** The upstream URL and auth headers the gateway forwards for one request. */ +export interface ResolvedGatewayUpstream { + upstreamUrl: string; + headers: Record; +} + +export type GatewayUpstreamResolution = + | { ok: true; resolved: ResolvedGatewayUpstream } + | { ok: false; status: 404 | 500; error: string }; + +/** + * Resolve the upstream URL and auth headers for a gateway request, per the + * provider's auth strategy: + * - `api-key`: inject the deployment's static key at the request path. + * - `chatgpt-oauth`: mint a fresh subscription access token, add the + * account-id header, and collapse the request onto the Codex backend. + */ +export async function resolveGatewayUpstream( + provider: InferenceGatewayProvider, + upstreamPath: string, + search: string, +): Promise { + if (provider.authStrategy === 'chatgpt-oauth') { + return resolveChatGptUpstream(provider); + } + + const [apiKey, upstreamBaseUrl] = await Promise.all([ + resolveModelProviderEnvValue(provider.envVarNames), + resolveProviderUpstreamBaseUrl(provider), + ]); + + if (!apiKey) { + return { + ok: false, + status: 404, + error: `No ${provider.name} API key is configured for this deployment`, + }; + } + + return { + ok: true, + resolved: { + upstreamUrl: `${upstreamBaseUrl}${upstreamPath}${search}`, + headers: { + [provider.authHeader.name]: formatProviderAuthHeaderValue( + provider, + apiKey, + ), + }, + }, + }; +} + +async function resolveChatGptUpstream( + provider: InferenceGatewayProvider, +): Promise { + const token = await getFreshChatGptAccessToken(); + + if (!token) { + return { + ok: false, + status: 404, + error: 'No connected ChatGPT subscription is available', + }; + } + + const upstreamUrl = `${provider.upstreamBaseUrl}${provider.collapseToPath ?? ''}`; + const headers: Record = { + [provider.authHeader.name]: formatProviderAuthHeaderValue( + provider, + token.access, + ), + }; + + if (token.accountId) { + headers[CHATGPT_ACCOUNT_ID_HEADER] = token.accountId; + } + + return { ok: true, resolved: { upstreamUrl, headers } }; +} + +/** + * A path is allowed when it exactly matches an inference endpoint. Providers + * whose route shape requires it (Google model routes, Vercel's AI Gateway + * protocol) additionally allow nested paths; their upstreams serve no + * account surface below the allowed prefixes. + * + * Paths containing dot segments or percent-encoded slashes are always rejected + * first: the match runs on the WHATWG-parsed pathname (which preserves `%2F` + * and does not collapse `%2e%2e`), so a nested-path provider could otherwise + * pass `/v1beta/models/..%2F..%2Fadmin` through the `startsWith` check and + * have the upstream normalize it back out of the inference surface. + */ +export function isInferencePathAllowed( + provider: InferenceGatewayProvider, + upstreamPath: string, +): boolean { + if (hasTraversalOrEncodedSlash(upstreamPath)) { + return false; + } + + return provider.allowedPaths.some((path) => { + if (upstreamPath === path) { + return true; + } + + return ( + provider.allowNestedPaths === true && upstreamPath.startsWith(`${path}/`) + ); + }); +} + +function hasTraversalOrEncodedSlash(upstreamPath: string): boolean { + const lowered = upstreamPath.toLowerCase(); + + if (lowered.includes('%2f') || lowered.includes('%5c')) { + return true; + } + + // Any `.` or `..` path segment (literal or percent-encoded dots) is out of + // bounds for an inference endpoint allowlist. + return lowered + .replace(/%2e/g, '.') + .split('/') + .some((segment) => segment === '.' || segment === '..'); +} + +/** + * Resolve the provider's upstream base, substituting the `{region}` + * placeholder from the deployment's region env var (falling back to the + * provider default) for region-templated upstreams like Bedrock. + */ +async function resolveProviderUpstreamBaseUrl( + provider: InferenceGatewayProvider, +): Promise { + if (!provider.region) { + return provider.upstreamBaseUrl; + } + + const region = + (await resolveModelProviderEnvValue([provider.region.envVarName])) ?? + provider.region.default; + + if (!INFERENCE_GATEWAY_REGION_PATTERN.test(region)) { + throw new Error( + `${provider.region.envVarName} must be a valid region for ${provider.name}. Received "${region}".`, + ); + } + + return provider.upstreamBaseUrl.replace('{region}', region); +} + +function formatProviderAuthHeaderValue( + provider: InferenceGatewayProvider, + apiKey: string, +): string { + return provider.authHeader.scheme === 'bearer' ? `Bearer ${apiKey}` : apiKey; +} diff --git a/apps/api/src/handlers/mcp/chat-reply-helpers.ts b/apps/api/src/handlers/mcp/chat-reply-helpers.ts index d51660fcb..d6269b1bd 100644 --- a/apps/api/src/handlers/mcp/chat-reply-helpers.ts +++ b/apps/api/src/handlers/mcp/chat-reply-helpers.ts @@ -125,21 +125,28 @@ export function errorResponseForThreadReplyImageError( message: string, ): Response | null { if (message.startsWith('Unknown artifact id: ')) { - return new Response(JSON.stringify({ error: message }), { status: 404 }); + return new Response(JSON.stringify({ error: 'Unknown artifact id' }), { + status: 404, + }); } if ( message.includes('does not belong to the current task') || message.includes('does not belong to the current task run') ) { - return new Response(JSON.stringify({ error: message }), { status: 403 }); + return new Response( + JSON.stringify({ error: 'Artifact does not belong to the current task' }), + { status: 403 }, + ); } if ( message.includes('has not been uploaded yet') || message.includes('is not an image attachment') ) { - return new Response(JSON.stringify({ error: message }), { status: 400 }); + return new Response(JSON.stringify({ error: 'Invalid image artifact' }), { + status: 400, + }); } return null; diff --git a/apps/api/src/handlers/mcp/proxy-utils.ts b/apps/api/src/handlers/mcp/proxy-utils.ts index 5b728e392..96c4f790b 100644 --- a/apps/api/src/handlers/mcp/proxy-utils.ts +++ b/apps/api/src/handlers/mcp/proxy-utils.ts @@ -243,7 +243,7 @@ function buildProxyRequestHeaders( return headers; } -function buildProxyResponseHeaders(upstreamHeaders: Headers): Headers { +export function buildProxyResponseHeaders(upstreamHeaders: Headers): Headers { const headers = new Headers(); const excludedHeaders = new Set([ diff --git a/apps/api/src/middleware/__tests__/tokenAuthMiddleware.test.ts b/apps/api/src/middleware/__tests__/tokenAuthMiddleware.test.ts new file mode 100644 index 000000000..6ae22455d --- /dev/null +++ b/apps/api/src/middleware/__tests__/tokenAuthMiddleware.test.ts @@ -0,0 +1,129 @@ +import { Hono } from 'hono'; + +import type { Variables } from '../../types'; + +const { mockValidateRunToken, mockValidateAuthToken, mockFindDeployment } = + vi.hoisted(() => ({ + mockValidateRunToken: vi.fn(), + mockValidateAuthToken: vi.fn(), + mockFindDeployment: vi.fn(), + })); + +vi.mock('@roomote/auth', () => ({ + validateRunToken: mockValidateRunToken, + validateAuthToken: mockValidateAuthToken, +})); + +vi.mock('@roomote/db/server', () => ({ + db: { + query: { + deploymentSettings: { findFirst: mockFindDeployment }, + users: { + findFirst: vi.fn(async () => ({ id: 'user-1', deletedAt: null })), + }, + }, + }, + deploymentSettings: { id: 'id' }, + users: { id: 'id', deletedAt: 'deletedAt' }, + eq: vi.fn(), +})); + +import { tokenAuthMiddleware } from '../tokenAuthMiddleware'; + +const RUN_TOKEN_CONTEXT = { + runId: 42, + userId: null, + principal: 'deployment', + tokenType: 'run', + version: 1, +}; + +function createApp() { + const app = new Hono<{ Variables: Variables }>(); + + app.use('*', tokenAuthMiddleware()); + app.all('*', (c) => c.json({ authContext: c.get('authContext') ?? null })); + + return app; +} + +async function requestAuthContext( + path: string, + headers: Record, +): Promise { + const response = await createApp().request(path, { headers }); + const body = (await response.json()) as { authContext: unknown }; + + return body.authContext; +} + +describe('tokenAuthMiddleware token extraction', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFindDeployment.mockResolvedValue({ metadata: null }); + mockValidateRunToken.mockImplementation(async (token: string) => { + if (token !== 'valid-run-token') { + throw new Error('invalid token'); + } + + return RUN_TOKEN_CONTEXT; + }); + mockValidateAuthToken.mockRejectedValue(new Error('invalid token')); + }); + + it('accepts a bearer token on any path', async () => { + const authContext = await requestAuthContext('/api/task-runs/1', { + authorization: 'Bearer valid-run-token', + }); + + expect(authContext).toEqual(RUN_TOKEN_CONTEXT); + }); + + it('accepts the run token from x-api-key on the inference gateway', async () => { + const authContext = await requestAuthContext( + '/api/inference/anthropic/v1/messages', + { 'x-api-key': 'valid-run-token' }, + ); + + expect(authContext).toEqual(RUN_TOKEN_CONTEXT); + }); + + it('accepts the run token from x-goog-api-key on the inference gateway', async () => { + const authContext = await requestAuthContext( + '/api/inference/google/v1beta/models/gemini-2.5-pro:generateContent', + { 'x-goog-api-key': 'valid-run-token' }, + ); + + expect(authContext).toEqual(RUN_TOKEN_CONTEXT); + }); + + it('prefers the Authorization header over provider key headers', async () => { + const authContext = await requestAuthContext( + '/api/inference/anthropic/v1/messages', + { + authorization: 'Bearer valid-run-token', + 'x-api-key': 'some-other-value', + }, + ); + + expect(authContext).toEqual(RUN_TOKEN_CONTEXT); + }); + + it('ignores provider key headers outside the inference gateway', async () => { + const authContext = await requestAuthContext('/api/task-runs/1', { + 'x-api-key': 'valid-run-token', + }); + + expect(authContext).toBeNull(); + expect(mockValidateRunToken).not.toHaveBeenCalled(); + }); + + it('does not authenticate an invalid token from provider key headers', async () => { + const authContext = await requestAuthContext( + '/api/inference/anthropic/v1/messages', + { 'x-api-key': 'not-a-token' }, + ); + + expect(authContext).toBeNull(); + }); +}); diff --git a/apps/api/src/middleware/tokenAuthMiddleware.ts b/apps/api/src/middleware/tokenAuthMiddleware.ts index 84daa3394..733297771 100644 --- a/apps/api/src/middleware/tokenAuthMiddleware.ts +++ b/apps/api/src/middleware/tokenAuthMiddleware.ts @@ -2,7 +2,7 @@ import type { Context, Next } from 'hono'; import { createMiddleware } from 'hono/factory'; import { validateRunToken, validateAuthToken } from '@roomote/auth'; -import { db, deploymentSettings, eq } from '@roomote/db/server'; +import { db, deploymentSettings, eq, users } from '@roomote/db/server'; import type { Variables } from '../types'; @@ -26,13 +26,41 @@ async function deploymentAllowsTokenAuth(): Promise { return !isRoomoteDeploymentDisabled(deployment?.metadata); } +const INFERENCE_GATEWAY_PATH_PREFIX = '/api/inference'; + +/** + * Provider SDKs pointed at the inference gateway send their "API key" (the + * run token) through provider-specific headers: `x-api-key` for Anthropic, + * `x-goog-api-key` for Gemini. Accept the token from those headers on the + * inference gateway surface only; everywhere else the Authorization bearer + * header remains the single token transport. + */ +function extractBearerToken( + c: Context<{ Variables: Variables }>, +): string | undefined { + const authHeader = c.req.header('Authorization'); + + if (authHeader?.startsWith('Bearer ')) { + return authHeader.substring(7); + } + + const path = c.req.path; + + if ( + path === INFERENCE_GATEWAY_PATH_PREFIX || + path.startsWith(`${INFERENCE_GATEWAY_PATH_PREFIX}/`) + ) { + return c.req.header('x-api-key') ?? c.req.header('x-goog-api-key'); + } + + return undefined; +} + export const tokenAuthMiddleware = () => createMiddleware(async (c: Context<{ Variables: Variables }>, next: Next) => { - const authHeader = c.req.header('Authorization'); - - if (authHeader?.startsWith('Bearer ')) { - const token = authHeader.substring(7); + const token = extractBearerToken(c); + if (token) { // Try run token first (has more specific claims) let isRunToken = false; @@ -54,7 +82,15 @@ export const tokenAuthMiddleware = () => try { const authContext = await validateAuthToken(token); if (await deploymentAllowsTokenAuth()) { - c.set('authContext', authContext); + const user = await db.query.users.findFirst({ + where: eq(users.id, authContext.userId), + columns: { id: true, deletedAt: true }, + }); + + // Removed users keep no standing access: their API tokens die with them. + if (user && user.deletedAt == null) { + c.set('authContext', authContext); + } } } catch (error) { console.error( diff --git a/apps/api/src/route-policies.ts b/apps/api/src/route-policies.ts index 616b3db31..2f05e80aa 100644 --- a/apps/api/src/route-policies.ts +++ b/apps/api/src/route-policies.ts @@ -214,6 +214,15 @@ export const ROUTE_POLICY_RULES: readonly RoutePolicyRule[] = [ rateLimits: WEBHOOK_RATE_LIMITS, }, + // Inference gateway: task sandboxes call model providers through this + // proxy with their run-scoped token; the provider key is injected + // server-side and never enters the sandbox. + { + name: 'inference', + match: { type: 'prefix', path: '/api/inference' }, + policy: 'task-token', + }, + // Router-facing MCP endpoints: accept user auth tokens (LLM router // gathering context before a run exists) and task run tokens. { diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index b1fb553cf..0a51a1980 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -45,6 +45,7 @@ import { teams, telegram, discord, + inference, mcp, mcpRouting, taskRunsRouter, @@ -192,6 +193,7 @@ export function createApiApp(): ApiApp { app.route('/api/webhooks/teams', teams); app.route('/api/webhooks/telegram', telegram); app.route('/api/internal/discord', discord); + app.route('/api/inference', inference); app.route('/api/mcp', mcp); app.route('/api/mcp-routing', mcpRouting); app.route('/api/task-runs', taskRunsRouter); diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index e54a55dee..003ba8c68 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -188,10 +188,6 @@ as per-task auth tokens or workspace paths. | `GOOGLE_GENERATIVE_AI_API_KEY` | Provider key | Alternate Google/Gemini provider key forwarded when configured or inferred. | | `AWS_BEARER_TOKEN_BEDROCK` | Provider key | Amazon Bedrock Mantle API key. Can also be saved from **Settings > Models**. | | `AWS_REGION` | Provider key | AWS region where the Bedrock API key was created. Defaults to `us-east-1` at runtime when unset. | -| `GOOGLE_APPLICATION_CREDENTIALS` | Provider key | Google Vertex AI service account credentials: a file path, or pasted JSON contents that Roomote materializes to a file for the runtime. | -| `GOOGLE_VERTEX_PROJECT` | Provider key | Google Cloud project ID for Vertex AI models. | -| `GOOGLE_VERTEX_LOCATION` | Provider key | Vertex AI location. Defaults to `us-central1` at the runtime when unset. | -| `MISTRAL_API_KEY` | Provider key | Mistral provider key forwarded when configured. | ### Sandbox providers @@ -298,7 +294,7 @@ as per-task auth tokens or workspace paths. | `R_TELEGRAM_BOT_TOKEN` | Telegram | Telegram bot token. | | `R_TELEGRAM_WEBHOOK_SECRET` | Telegram | Optional. Telegram webhook secret; generated automatically when unset in the UI. | | `R_DISCORD_BOT_TOKEN` | Discord | Discord bot token. The bot and application identity are read from this token. | -| `R_DISCORD_GATEWAY_SECRET` | Discord | Shared internal secret for Discord event delivery between BullMQ and API. Auto-generated when Discord is saved in the UI if unset. Dedicated value — do not reuse `ENCRYPTION_KEY`. | +| `R_DISCORD_GATEWAY_SECRET` | Discord | Shared internal secret for Discord event delivery between BullMQ and API. Auto-generated when Discord is saved in the UI if unset, and auto-healed when Discord is already configured without one. | | `DISCORD_API_BASE_URL` | Optional | Discord REST API base URL override, primarily for testing. | | `R_SLACK_CLIENT_ID` | Slack sign-in | Slack sign-in client ID. `R_SLACK_CLIENT_ID` is also accepted. | | `R_SLACK_CLIENT_SECRET` | Slack sign-in | Slack sign-in client secret. `R_SLACK_CLIENT_SECRET` is also accepted. | diff --git a/apps/docs/index.mdx b/apps/docs/index.mdx index 4ebf82e92..88caef92d 100644 --- a/apps/docs/index.mdx +++ b/apps/docs/index.mdx @@ -52,7 +52,8 @@ repositories, inference provider, sandbox provider, and collaboration surfaces. Roomote is released under the Fair Core License 1.0 (FCL-1.0-ALv2) — source-available, with each release converting to Apache-2.0 two years after it is published. A deployment is free for up to 10 registered users; -adding more users requires a paid license key entered in Settings → Users. The -license prohibits disabling or circumventing the license key functionality. See +adding more users requires a paid license key (Settings → Users, or the +`R_LICENSE_KEY` environment variable). The license prohibits disabling or +circumventing the license key functionality. See the [LICENSE](https://github.com/RooCodeInc/Roomote/blob/main/LICENSE) file for details. diff --git a/apps/docs/models.mdx b/apps/docs/models.mdx index 47f0782a1..e330c3f8e 100644 --- a/apps/docs/models.mdx +++ b/apps/docs/models.mdx @@ -17,7 +17,7 @@ Configure models from **Settings > Models**. An inference provider is the service that hosts or routes model calls. Roomote supports providers such as OpenRouter, Vercel AI Gateway, Baseten, Together AI, OpenAI, Anthropic, Moonshot AI, MiniMax, OpenCode, Amazon Bedrock, -Google Vertex AI, Google Gemini, xAI, and ChatGPT subscriptions. +Google Gemini, xAI, and ChatGPT subscriptions. You can connect more than one inference provider in the same deployment. That lets you mix and match models by provider instead of betting the whole @@ -93,8 +93,8 @@ R_EXPLORE_MODEL=openrouter/openai/gpt-5.6-luna ``` Roomote automatically forwards common provider keys to task workers, including -OpenRouter, Vercel AI Gateway, OpenAI, Anthropic, Google Gemini, Mistral, -Moonshot, MiniMax, OpenCode, Amazon Bedrock, Google Vertex AI, and xAI keys. +OpenRouter, Vercel AI Gateway, OpenAI, Anthropic, Google Gemini, +Moonshot, MiniMax, OpenCode, Amazon Bedrock, and xAI keys. Use `R_MODEL_ENV_KEYS` when a provider key uses a custom env var name: ```sh diff --git a/apps/docs/providers/communications/discord.mdx b/apps/docs/providers/communications/discord.mdx index fa4772607..24c2e24bb 100644 --- a/apps/docs/providers/communications/discord.mdx +++ b/apps/docs/providers/communications/discord.mdx @@ -34,9 +34,9 @@ copy an application ID or bot name into Roomote. ## Add Roomote to a server -After saving the token, select **Add to Discord** in Roomote and choose the -server. The generated installation link requests the permissions Roomote needs; -administrator access is not required. +After you save the bot token, the **Add to Discord** button appears in Roomote. +Select it and choose the server. The generated installation link requests the +permissions Roomote needs; administrator access is not required. For tasks started in a server, the bot needs permission to: @@ -48,10 +48,6 @@ For tasks started in a server, the bot needs permission to: Roomote also uses reactions for lightweight acknowledgements when the bot has permission to add them, but reactions are not required to run tasks. -The generated invite uses permissions integer `309237763136`. You can use the -same value when configuring a manual install link or Discord's default guild -install settings. - A task started from a normal text or announcement channel runs in a public thread started on the message that requested it — like a threaded reply — so the conversation stays anchored where it began. A task started in a forum gets @@ -123,7 +119,6 @@ environment variable instead, set: ```sh R_DISCORD_BOT_TOKEN= -# Use a dedicated random value — do not reuse ENCRYPTION_KEY or other secrets. R_DISCORD_GATEWAY_SECRET= ``` @@ -133,10 +128,11 @@ port. It stays dormant until a token is saved, uses Redis to elect a single active replica, and disconnects if Discord is removed. Durable event delivery uses Redis and a dedicated internal shared secret. Saving Discord credentials in settings generates `R_DISCORD_GATEWAY_SECRET` when it is not already configured. -For env-var-only setups, set the same secret on the API and BullMQ processes -yourself (installers and platform templates also generate one). That value must -be unique to Discord transport — do not reuse `ENCRYPTION_KEY` or any other -deployment secret. +If Discord was already set up without one (for example after an upgrade), +Roomote generates and persists the secret automatically the next time the +gateway or API needs it, or when you use **Repair**. For env-var-only setups, +set the same secret on the API and BullMQ processes yourself (installers and +platform templates also generate one). Gateway session state is stored in the Roomote database. On a service restart or leader handoff, Roomote asks Discord to resume the previous session and diff --git a/apps/docs/users.mdx b/apps/docs/users.mdx index 4a2a0cb42..3788816e0 100644 --- a/apps/docs/users.mdx +++ b/apps/docs/users.mdx @@ -60,10 +60,15 @@ A Roomote deployment is free for up to 10 users. Every registered user account in the deployment counts toward the limit, whichever sign-in path or surface they use. Removed users free their seat. -To add more than 10 users, an admin enters a license key from the Roomote -maintainers in **Settings > Users > License**. The License section shows the -current seat usage, license status, and licensee. Keys are verified offline; -the deployment never contacts a license server. +To add more than 10 users, apply a license key from the Roomote maintainers in +either of these ways: + +- enter it in **Settings > Users > License** as an admin, or +- set `R_LICENSE_KEY` on the deployment (for example in Compose / `.env.production`); + when that env var is set, it takes precedence over any key stored in Settings + +The License section shows the current seat usage, license status, and licensee. +Keys are verified offline; the deployment never contacts a license server. When all seats are in use, existing users keep working normally — only new sign-ups are blocked until a seat is freed by removing a user or a license key diff --git a/apps/web/public/elements/welcome.png b/apps/web/public/elements/welcome.png new file mode 100644 index 000000000..d732900ea Binary files /dev/null and b/apps/web/public/elements/welcome.png differ diff --git a/apps/web/src/app/(authenticated)/settings/layout.tsx b/apps/web/src/app/(authenticated)/settings/layout.tsx new file mode 100644 index 000000000..46ff55bde --- /dev/null +++ b/apps/web/src/app/(authenticated)/settings/layout.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react'; + +import { PAGE_METADATA } from '@/lib/metadata'; + +export const metadata = PAGE_METADATA.settings; + +export default function SettingsLayout({ children }: { children: ReactNode }) { + return children; +} diff --git a/apps/web/src/app/(authenticated)/tasks/layout.tsx b/apps/web/src/app/(authenticated)/tasks/layout.tsx new file mode 100644 index 000000000..266a778c0 --- /dev/null +++ b/apps/web/src/app/(authenticated)/tasks/layout.tsx @@ -0,0 +1,9 @@ +import type { ReactNode } from 'react'; + +import { PAGE_METADATA } from '@/lib/metadata'; + +export const metadata = PAGE_METADATA.taskHistory; + +export default function TasksLayout({ children }: { children: ReactNode }) { + return children; +} diff --git a/apps/web/src/app/(centered)/github/callback/GitHubCallbackPage.tsx b/apps/web/src/app/(centered)/github/callback/GitHubCallbackPage.tsx index c6ca397fe..ad2e9ad30 100644 --- a/apps/web/src/app/(centered)/github/callback/GitHubCallbackPage.tsx +++ b/apps/web/src/app/(centered)/github/callback/GitHubCallbackPage.tsx @@ -13,6 +13,33 @@ import { import { decodeRecord } from '@/lib'; +function decodeOAuthState( + encodedState: string | null | undefined, +): Record | undefined { + if (!encodedState) { + return undefined; + } + + const fromRecord = decodeRecord>(encodedState); + if (fromRecord) { + return fromRecord; + } + + // Signed {payload}.{signature} form used by account-link OAuth. + const [encodedPayload] = encodedState.split('.'); + if (!encodedPayload) { + return undefined; + } + + try { + const base64 = encodedPayload.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + return JSON.parse(atob(padded)) as Record; + } catch { + return undefined; + } +} + import { useUser } from '@/hooks/useUser'; import { useRedirectToSignIn } from '@/hooks/useSignInRedirect'; import { @@ -38,9 +65,7 @@ export default function Page() { useRedirectToSignIn(authStatus === 'signed-out'); const encodedCallbackState = params.get('state'); - const decodedCallbackState = encodedCallbackState - ? decodeRecord>(encodedCallbackState) - : undefined; + const decodedCallbackState = decodeOAuthState(encodedCallbackState); // Failures here are usually bad saved GitHub App credentials, so route the // user back to where those can be fixed instead of dead-ending on the error. const cameFromSetup = @@ -51,10 +76,7 @@ export default function Page() { const navigateFromState = () => { const encodedState = params.get('state'); - - const decodedState = encodedState - ? decodeRecord>(encodedState) - : undefined; + const decodedState = decodeOAuthState(encodedState); const redirect = decodedState?.redirect; @@ -145,10 +167,7 @@ export default function Page() { const setupAction = params.get('setup_action'); const code = params.get('code'); const encodedState = params.get('state'); - - const decodedState = encodedState - ? decodeRecord>(encodedState) - : undefined; + const decodedState = decodeOAuthState(encodedState); const isAuthenticationFlow = decodedState?.mode === 'auth'; const isAppManifestFlow = decodedState?.mode === 'github-app-manifest'; @@ -174,11 +193,11 @@ export default function Page() { finishInstall.mutate(code); } } else if (isAuthenticationFlow) { - if (!code) { + if (!code || !encodedState) { setIsLoading(false); setError('Missing OAuth code. Please try again.'); } else { - finishAuthentication.mutate(code); + finishAuthentication.mutate({ code, state: encodedState }); } } else { // TODO: This path works for both `install` and `update` setup actions, diff --git a/apps/web/src/app/(centered)/slack/callback/SlackCallbackPage.tsx b/apps/web/src/app/(centered)/slack/callback/SlackCallbackPage.tsx index 7ddd7cca5..1674a7f84 100644 --- a/apps/web/src/app/(centered)/slack/callback/SlackCallbackPage.tsx +++ b/apps/web/src/app/(centered)/slack/callback/SlackCallbackPage.tsx @@ -48,8 +48,8 @@ export default function SlackCallbackPage() { }); const linkAccountMutation = useMutation({ - mutationFn: (code: string) => - trpcClient.slack.finishAuthenticateAccount.mutate({ code }), + mutationFn: (args: { code: string; state: string }) => + trpcClient.slack.finishAuthenticateAccount.mutate(args), onSuccess: (result, _variables, _context) => { setIsSuccess(result.success); setIsLoading(false); @@ -90,23 +90,48 @@ export default function SlackCallbackPage() { return; } - const decodedState = decodeRecord>(state); + const decodedRecord = decodeRecord>(state); + let decodedState = decodedRecord as + | { mode?: string; redirectPath?: string; redirect?: string } + | undefined; + + if (!decodedState && state.includes('.')) { + try { + const [encodedPayload] = state.split('.'); + if (encodedPayload) { + const base64 = encodedPayload.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4); + decodedState = JSON.parse(atob(padded)) as { + mode?: string; + redirectPath?: string; + redirect?: string; + }; + } + } catch { + decodedState = undefined; + } + } + const isLinkAccountFlow = decodedState?.mode === 'link_account'; if (isLinkAccountFlow) { - linkAccountMutation.mutate(code, { - onSuccess: (result) => { - if (result.success) { - const redirect = decodedState?.redirect; - const isValidRedirect = - redirect && - redirect.startsWith('/') && - !redirect.startsWith('//') && - !redirect.includes('://'); - router.push(isValidRedirect ? redirect : '/settings'); - } + linkAccountMutation.mutate( + { code, state }, + { + onSuccess: (result) => { + if (result.success) { + const redirect = + decodedState?.redirectPath ?? decodedState?.redirect; + const isValidRedirect = + redirect && + redirect.startsWith('/') && + !redirect.startsWith('//') && + !redirect.includes('://'); + router.push(isValidRedirect ? redirect : '/settings'); + } + }, }, - }); + ); } else { installMutation.mutate({ code, state }); } diff --git a/apps/web/src/app/(onboarding)/onboarding/OnboardingClient.tsx b/apps/web/src/app/(onboarding)/onboarding/OnboardingClient.tsx index 9d4c40bc0..3eb4983b9 100644 --- a/apps/web/src/app/(onboarding)/onboarding/OnboardingClient.tsx +++ b/apps/web/src/app/(onboarding)/onboarding/OnboardingClient.tsx @@ -4,11 +4,11 @@ import { AnimatePresence, motion } from 'motion/react'; import { Spinner } from '@/components/system'; +import { StepWelcome } from '../setup/StepWelcome'; import { StepGitHub } from './StepGitHub'; import { StepInvoke } from './StepInvoke'; import { StepLinear } from './StepLinear'; import { StepSlack } from './StepSlack'; -import { StepWelcome } from './StepWelcome'; import { useOnboardingFlow } from './hooks'; export function OnboardingClient({ githubAppSlug }: { githubAppSlug: string }) { @@ -47,7 +47,9 @@ export function OnboardingClient({ githubAppSlug }: { githubAppSlug: string }) { transition: { duration: 0.2, ease: [0.4, 0.0, 1, 1] }, }} > - {step === 'welcome' && } + {step === 'welcome' && ( + + )} {step === 'slack' && } {step === 'linear' && ( ({ ArrowRight: () => ArrowRight, })); -vi.mock('../setup/StepTitle', () => ({ - StepTitle: ({ text }: { text: string }) =>
{text}
, +vi.mock('@/components/layout', () => ({ + RoomoteWordmark: () => Roomote, })); -import { StepWelcome } from './StepWelcome'; +import { StepWelcome } from '../setup/StepWelcome'; describe('Onboarding StepWelcome', () => { it('renders the product intro copy', () => { - render( {}} />); + render( {}} />); expect( screen.getByText( @@ -24,4 +24,12 @@ describe('Onboarding StepWelcome', () => { ), ).toBeInTheDocument(); }); + + it('omits the onboarding-only copy by default', () => { + render( {}} />); + + expect( + screen.queryByText(/You don't even need to be an engineer/i), + ).not.toBeInTheDocument(); + }); }); diff --git a/apps/web/src/app/(onboarding)/onboarding/StepWelcome.tsx b/apps/web/src/app/(onboarding)/onboarding/StepWelcome.tsx deleted file mode 100644 index 60f24c638..000000000 --- a/apps/web/src/app/(onboarding)/onboarding/StepWelcome.tsx +++ /dev/null @@ -1,32 +0,0 @@ -'use client'; - -import { PRODUCT_NAME } from '@roomote/types'; - -import { Button, ArrowRight } from '@/components/system'; -import { StepTitle } from '../setup/StepTitle'; - -export function StepWelcome({ onContinue }: { onContinue: () => void }) { - return ( -
- -

- {PRODUCT_NAME} is your always-on engineer. Ask questions, parallelize - tasks, fix bugs, churn through chores, setup proactive work and more. -

-

- You don't even need to be an engineer. {PRODUCT_NAME} runs in - isolated sandboxes and verifies its own work, so it doesn't blindly - break things. You can be confident you're having an impact and not - making mistakes. -

- -
- ); -} diff --git a/apps/web/src/app/(onboarding)/onboarding/page.tsx b/apps/web/src/app/(onboarding)/onboarding/page.tsx index 7e26c42fb..9abf26ae9 100644 --- a/apps/web/src/app/(onboarding)/onboarding/page.tsx +++ b/apps/web/src/app/(onboarding)/onboarding/page.tsx @@ -1,7 +1,10 @@ import { Env } from '@/lib/server/env'; +import { PAGE_METADATA } from '@/lib/metadata'; import { OnboardingClient } from './OnboardingClient'; +export const metadata = PAGE_METADATA.onboarding; + export default function OnboardingPage() { return ; } diff --git a/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.client.test.tsx b/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.client.test.tsx index a5dc2996c..7efec1fef 100644 --- a/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.client.test.tsx @@ -1,11 +1,9 @@ import { render, screen } from '@testing-library/react'; -import { DISCORD_INSTALL_PERMISSIONS } from '@/lib/discord-install'; - import { ProviderSetupInstructions } from './ProviderSetupInstructions'; describe('ProviderSetupInstructions', () => { - it('shows the permissions requested by the Discord install link', () => { + it('explains Discord install permissions and that Add to Discord appears after save', () => { render( { ); expect(screen.getByText('Installation permissions')).toBeInTheDocument(); - expect(screen.getByText(DISCORD_INSTALL_PERMISSIONS)).toBeInTheDocument(); - expect( - screen.getByRole('button', { name: 'Copy Permissions integer' }), - ).toBeInTheDocument(); expect( - screen.getByText(/Add to Discord button requests these automatically/), + screen.getByText(/Add to Discord button appears and requests these/i), ).toBeInTheDocument(); + expect(screen.queryByText('Permissions integer')).not.toBeInTheDocument(); + expect(screen.queryByText('309237763136')).not.toBeInTheDocument(); }); }); diff --git a/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.tsx b/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.tsx index 6d7abde11..c42956515 100644 --- a/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.tsx +++ b/apps/web/src/app/(onboarding)/setup/ProviderSetupInstructions.tsx @@ -8,7 +8,6 @@ import { SLACK_APP_INSTALL_CALLBACK_PATH, SLACK_SIGN_IN_CALLBACK_PATH, } from '@/lib/slack-callback-paths'; -import { DISCORD_INSTALL_PERMISSIONS } from '@/lib/discord-install'; import { cn } from '@/lib/utils'; type ProviderSetupInstructionsProviderId = @@ -59,32 +58,6 @@ export function InstructionUrl({ ); } -function InstructionValue({ - heading, - value, -}: { - heading: string; - value: string; -}) { - return ( -
-

- {heading} -

-
- - {value} - - -
-
- ); -} - export function ProviderSetupInstructions({ providerId, publicOrigin, @@ -195,14 +168,10 @@ export function ProviderSetupInstructions({ Roomote needs View Channels, Send Messages, Read Message History, Embed Links, Attach Files, Create Public Threads, and Send Messages in - Threads. Add Reactions enables acknowledgements. The Add to Discord - button requests these automatically; if Discord asks for a permissions - integer, use: + Threads. Add Reactions enables acknowledgements. After you save the + bot token, the Add to Discord button appears and requests these + permissions automatically. - Paste the token below. Roomote derives the bot and application names from it, so there is no separate name or application ID to enter. diff --git a/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx b/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx new file mode 100644 index 000000000..50544fac9 --- /dev/null +++ b/apps/web/src/app/(onboarding)/setup/SetupLayoutClient.tsx @@ -0,0 +1,67 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useRouter } from 'next/navigation'; + +import { useSetupBootstrapOpen, useUser } from '@/hooks/useUser'; +import { + FramedSurface, + OriginMismatchAlert, + RoomoteWordmark, + UserMenu, +} from '@/components/layout'; +import { Spinner } from '@/components/system'; + +export function SetupLayoutClient({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const { authStatus, isSignedIn } = useUser(); + const setupBootstrapOpen = useSetupBootstrapOpen(); + const [userMenuPortalContainer, setUserMenuPortalContainer] = + useState(null); + + useEffect(() => { + if (authStatus === 'signed-out' && !setupBootstrapOpen) { + router.replace('/sign-in'); + } + }, [authStatus, router, setupBootstrapOpen]); + + if (!isSignedIn && !setupBootstrapOpen) { + return ( +
+ +
+ ); + } + + return ( +
+ +
+ + {isSignedIn ? ( + <> +
+ + +
+ + ) : null} + +
+
+
+ + {children} +
+
+ +
+ ); +} diff --git a/apps/web/src/app/(onboarding)/setup/StepWelcome.tsx b/apps/web/src/app/(onboarding)/setup/StepWelcome.tsx index a34d47e23..a4da133ae 100644 --- a/apps/web/src/app/(onboarding)/setup/StepWelcome.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepWelcome.tsx @@ -1,21 +1,48 @@ 'use client'; -import { Button, ArrowRight } from '@/components/system'; +import Image from 'next/image'; import { PRODUCT_NAME } from '@roomote/types'; -import { StepTitle } from './StepTitle'; -import { getSetupStepDefinition } from './types'; + +import { RoomoteWordmark } from '@/components/layout'; +import { ArrowRight, Button } from '@/components/system'; + import { markSetupWelcomeSeen } from './welcome-seen'; -const WELCOME_STEP = getSetupStepDefinition('welcome'); +type StepWelcomeProps = { + isOnboarding?: boolean; + onContinue: () => void; +}; -export function StepWelcome({ onContinue }: { onContinue: () => void }) { +export function StepWelcome({ + isOnboarding = false, + onContinue, +}: StepWelcomeProps) { return (
- + +

+ Welcome to + Roomote! + +

- {PRODUCT_NAME} is your always-on engineer. Ask questions, parallelize + {PRODUCT_NAME} is your very own coding agent. Ask questions, parallelize tasks, fix bugs, churn through chores, setup proactive work and more.

+ {isOnboarding ? ( +

+ You don't even need to be an engineer. {PRODUCT_NAME} runs in + isolated sandboxes and verifies its own work, so it doesn't + blindly break things. You can be confident you're having an + impact and not making mistakes. +

+ ) : null} - - - ); - - return ( -
-
-

- Natural-language rules that set the effective author and default PR - owner for autonomous work. When nothing matches, Roomote authors the - work, human-created tasks keep the human author, and PR ownership - follows the effective author. -

- {settingsQuery.isPending ? ( -
- - -
- ) : settingsQuery.isError ? ( -
- -

Failed to load authorship rules.

-
- ) : ( -
-
- -