diff --git a/CHANGELOG.md b/CHANGELOG.md index 6cdd201985..c9724bdbd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ This file tracks product releases for Roomote (single monorepo version). Automated release entries are prepended by `pnpm run version`. +## 1.19.5 (2026-10-29) + +Roomote 1.19.5 improves GitHub work across organizations and restores integration-key access for scheduled and manually run custom automations. + +### Highlights + +- Connect additional GitHub organizations and use HTTPS Git across repositories available to a task. +- Start pull request reviews reliably when multiple GitHub organizations are connected. +- Use existing integration keys in scheduled and manually run custom automations. + +### Patch changes + +- Scheduled and manually run custom automations can now use their creator's available personal and deployment-wide integration keys instead of finding an empty integration list. New key approvals still require a human turn, and webhook-triggered runs do not receive integration keys. +- Work with GitHub repositories across organizations: Update GitHub now opens the account picker to add another installation, pull request reviews no longer fail to start when multiple organizations are connected, and tasks can clone, fetch, and push available repositories across installations using HTTPS Git. Task API and pull request delivery access retain their existing installation boundaries. + ## 1.19.4 (2026-10-27) Roomote 1.19.4 expands model choice with GPT-6.1 Terra and Kimi K3.1 Pro, including Highspeed options on supported Kimi providers. @@ -336,7 +351,7 @@ Roomote 1.13.1 improves automation triggers, judgment-model setup, model selecti ### Patch changes - Custom automations can now run on demand or from a private POST webhook, with bounded one-run input, independent runs, credential rotation, and rate limiting. -- Roomote Cloud now creates a managed email inbox when a deployment first sends email, so deployments that never send mail do not allocate an unused inbox. +- Managed deployments now create an email inbox when a deployment first sends email, so deployments that never send mail do not allocate an unused inbox. - Custom model mapping presets now save supported reasoning values without mutating provider recommendations, and validation names the Advisor role correctly. - Discord Fast replies now deliver selected task screenshots, including deferred and retry paths, instead of silently dropping image attachments. - Self-hosted deployments can run a CPU decision model beside the stack, so the Memory check, reply addressing, and Roomote's other typed decisions work without a Jev key or a GPU. The judgment sidecar serves `roomote/roomote-judgment-gliner`, a GLiNER 2.5 model fine-tuned on Roomote's decisions, behind the same decision contract Roomote's `roomote` judgment backend calls, capped at 4 GB and 4 CPUs by default. It is opt-in like Memory: the `judgment` compose profile in the self-host and production Compose files, and an idle service in the Railway, Render, and Coolify templates that stays unused until `R_JUDGMENT_UPSTREAM_URL` points at it. The image is published as `ghcr.io/roocodeinc/roomote-judgment`. `docker-compose.self-host.yml` now passes `R_JUDGMENT_MODEL`, `R_JUDGMENT_UPSTREAM_URL`, and `R_JUDGMENT_UPSTREAM_API_KEY` to the app services. @@ -670,8 +685,8 @@ Roomote 1.9.1 brings Session secrets to every hosted sandbox, adds cross-surface - Large task transcripts now open from a bounded recent window and load older messages as you scroll, avoiding unbounded initial loading while preserving live updates, position, and retry controls. - Memory disclosure is now consistent for everyone: Roomote identifies a materially useful remembered fact and explains how it informed the work without requiring the former per-user Therapist Mode setting. - Deployments now run MinIO from Roomote's own `ghcr.io/roocodeinc/roomote-minio` image, built from the final MinIO community source release, instead of the retired upstream images. The same image provides the `mc` client used to create the artifact bucket, so the separate `minio/mc` image is gone. Existing `/data` volumes are unaffected. -- Deliver Session service tokens to coding runs on every hosted sandbox provider. Daytona, E2B, Blaxel, Box, and Azure runs now receive substitute tokens and the API proxy base URL the same way Modal and Roomote Cloud runs do, gated by the Session owner's Session secret tools setting. No deployment configuration is required. -- Deliver owner-approved Session service tokens to coding runs on Modal and Roomote Cloud. When the Session owner has Session secret tools enabled, the controller registers the run after bootstrap and the worker receives substitute tokens, the service manifest, and the API proxy base URL; the model calls approved services through the proxy with ordinary HTTP clients while the real key stays in the API. No deployment configuration is required. +- Deliver Session service tokens to coding runs on every hosted sandbox provider. Daytona, E2B, Blaxel, Box, and Azure runs now receive substitute tokens and the API proxy base URL the same way Modal and deployment-managed runs do, gated by the Session owner's Session secret tools setting. No deployment configuration is required. +- Deliver owner-approved Session service tokens to coding runs on Modal and deployment-managed sandboxes. When the Session owner has Session secret tools enabled, the controller registers the run after bootstrap and the worker receives substitute tokens, the service manifest, and the API proxy base URL; the model calls approved services through the proxy with ordinary HTTP clients while the real key stays in the API. No deployment configuration is required. - Add the API-side session egress substitution proxy at `/api/session-egress//`, so attached coding runs on compute providers without a per-workload connector can call an owner-approved origin with an ordinary HTTP client and a substitute token while the real credential stays in the API. - Session views now include every ready Session in the Ready filter and render transcript timestamps without time-zone-dependent hydration failures. - Session secret grants can name any credential header, not only `authorization`, `x-api-key`, and `api-key`, so services that authenticate with their own header such as `private-token` or `x-shopify-access-token` can be approved. Request-shaping headers remain refused, and a scheme is still only accepted on `authorization`. @@ -708,11 +723,11 @@ Roomote 1.9 adds a native Telegram Fast experience, Session-owned Goal Mode, dir - Web Session notifications now arrive when an absent user needs to review a response or provide input, carry the actual reply into one personal provider thread, and accept replies that continue the same Session or pending task without duplicate or stale notifications. - Goal Mode now belongs to the session instead of one child task, so Roomote can pursue an objective across turns and delegated tasks, continue automatically within a bounded budget, and preserve goal state across web, Telegram, and Discord conversations. - Session owners can now approve narrowly scoped API keys through a secure form without placing credentials in chat or agent context. Fast and attached Docker coding runs can use approved keys only for the selected public HTTPS origin, allowed methods, and lifetime, with revocation and expiry enforced before requests and responses. -- Cloud users now explicitly accept the experimental Voice data flow before their first call, before microphone capture or OpenAI contact begins. The consent explains that audio, transcripts, and workspace context are sent to OpenAI, is stored per user, and does not change self-hosted Voice behavior. +- Users on managed deployments now explicitly accept the experimental Voice data flow before their first call, before microphone capture or OpenAI contact begins. The consent explains that audio, transcripts, and workspace context are sent to OpenAI, is stored per user, and does not change self-hosted Voice behavior. ### Patch changes -- Roomote Cloud hides managed Email configuration that members cannot change, while continuing to show the deployment-provided address and connection state. +- Managed deployments hide Email configuration that members cannot change, while continuing to show the deployment-provided address and connection state. - Deployment setup rejects malformed domain names before they can produce invalid routing or certificate configuration. - Fast now handles incomplete skill lookup arguments and unavailable skill sources without hiding valid skills, and integration discovery reports exact filter misses instead of returning an ambiguous empty result. - Mobile navigation makes recent Sessions easier to reach and moves Session switching into a responsive left rail that keeps the current conversation usable. @@ -1193,7 +1208,7 @@ Roomote 1.2 enables contextual Message Suggestions by default, streams Fast repl - Finalize hosted runs whose Modal sandbox is gone instead of repeatedly retrying maintenance for missing sandboxes. - Keep Fast and task prompts lean by discovering remote integration tools only when needed, while preserving permissions and showing the invoked integration action clearly in transcripts. - Show linked sender identities and readable display names in Fast and Fast-delegated Slack transcripts instead of anonymous avatars or raw Slack mention tokens. -- Expose timestamped provisioning and command output for new Roomote Cloud sandbox runs through compute-log lookups, including stdout, stderr, and failure diagnostics. +- Expose timestamped provisioning and command output for new deployment-managed sandbox runs through compute-log lookups, including stdout, stderr, and failure diagnostics. - Start self-hosted Memory from the published gbrain image matching the Roomote release by default while preserving custom image overrides. - Recover Sessions whose terminal subagent remains unsettled after a provider timeout instead of leaving the parent Session running indefinitely. - Stream Fast replies into web Session transcripts and Slack threads while the model writes them, then settle each stream into the finished reply with its usual content and links. @@ -1819,16 +1834,16 @@ This release adds new ways to connect and invoke Roomote, gives deployments clea ## 0.32.1 (2026-08-04) -This patch restores licensed Cloud seat limits and keeps custom automation destinations limited to connected communication providers. +This patch restores licensed seat limits on managed deployments and keeps custom automation destinations limited to connected communication providers. ### Highlights -- Restore licensed seat limits for Roomote Cloud deployments so valid provisioned licenses no longer fall back to the free-tier limit when no activation lease is present. +- Restore licensed seat limits for managed deployments so valid provisioned licenses no longer fall back to the free-tier limit when no activation lease is present. - Choose custom automation destinations from connected communication providers only. ### Patch changes -- Restore licensed seat limits for Roomote Cloud deployments so valid provisioned licenses no longer fall back to the free-tier limit when no activation lease is present. +- Restore licensed seat limits for managed deployments so valid provisioned licenses no longer fall back to the free-tier limit when no activation lease is present. - Show only connected communication providers when choosing a custom automation destination, while preserving task-view-only delivery and existing form edits as integration availability loads. ## 0.32.0 (2026-08-03) @@ -1840,7 +1855,7 @@ This release adds OpenCode Go subscriptions, restores convenient automation test - Let operators connect an OpenCode Go subscription independently from OpenCode Zen and use supported Go models across Roomote task roles. - Run enabled custom automations on demand and test newly created automations immediately. - Use current Qwen and DeepSeek recommendations across supported model providers. -- Retry license usage reporting after transient Roomote Cloud failures. +- Retry license usage reporting after transient licensing-service failures. ### Minor changes @@ -1850,7 +1865,7 @@ This release adds OpenCode Go subscriptions, restores convenient automation test - Let administrators run enabled custom automations on demand from Automations settings, and offer an immediate test run after an automation is created conversationally. - Recommend Qwen3.8 Max through supported providers and use OpenCode Zen's supported DeepSeek V4 Flash model identifier. -- Retry license usage reports after transient Roomote Cloud failures and report concurrent license-key changes accurately instead of dropping usage observations or reporting a false success. +- Retry license usage reports after transient licensing-service failures and report concurrent license-key changes accurately instead of dropping usage observations or reporting a false success. ## 0.31.0 (2026-08-03) @@ -1868,7 +1883,7 @@ This release expands Amazon Bedrock and deployment controls, refreshes Automatio - Redesign Automations settings around scannable Custom, Enabled, and Available sections, with permalinked configuration dialogs, responsive custom automation controls, and category and provider-aware filtering. - Let deployment operators disable the curated integrations catalog and its existing connections through an environment policy without deleting saved credentials or affecting other provider types. - Let operators use native Amazon Bedrock Runtime models alongside Mantle, including regional inference profiles and supported reasoning settings, without changing existing Mantle configurations. -- Add direct Roomote Cloud purchase and renewal paths to self-hosted License settings, including low-seat and upcoming-expiry guidance for administrators. +- Add license purchase and renewal paths to self-hosted License settings, including low-seat and upcoming-expiry guidance for administrators. - Show useful, dismissible product tips alongside task startup progress so users can discover Roomote capabilities while an environment boots. - Add a copyable Roomote Cookbook with ready-to-use recipes for vendor outage triage, support investigations, CI failure fixes, product update newsletters, and scheduled maintenance. @@ -2007,7 +2022,7 @@ This release expands Azure, ChatGPT, GitHub, and monday.com capabilities while i - Run tasks with Azure Container Apps Sandboxes, or connect Azure OpenAI and Azure AI Foundry for model inference. - Use monday.com context in tasks and invoke Roomote on GitHub with the shorter `@Roomote` mention. -- Enable Fast mode for ChatGPT subscriptions and manage optional Roomote Cloud analytics with new cookie consent controls. +- Enable Fast mode for ChatGPT subscriptions and manage optional hosted analytics with new cookie consent controls. - Keep Coolify Docker jobs, environment setup, Slack follow-ups, visual proof, task privacy, and pull request re-reviews working more reliably. ### Minor changes @@ -2015,7 +2030,7 @@ This release expands Azure, ChatGPT, GitHub, and monday.com capabilities while i - Add Azure Container Apps Sandboxes as a preview compute provider with fast suspend and resume, snapshots, stable preview URLs, configurable sizing, and guided worker image setup. Thanks to @tebieshi for contributing this improvement. - Add first-class Azure OpenAI and Azure AI Foundry inference provider setup and model routing. - Add Fast mode for ChatGPT subscriptions and improve subscription provider connection, model availability, and settings controls. -- Add cookie consent controls to Roomote Cloud and defer optional support and product analytics services until users grant consent. +- Add cookie consent controls to managed deployments and defer optional support and product analytics services until users grant consent. - Let GitHub users invoke Roomote with the shorter `@Roomote` mention, with an administrator setting to require the full bot name instead. - Add a read-only monday.com integration so Roomote tasks can use the current user's board and work-item context. @@ -2517,7 +2532,7 @@ Multi-SCM triage and mentions, richer chat and release UX, and more reliable sel - Analytics overview and cost views now avoid redundant queries and unnecessary data loading while preserving their existing filters and attribution. - Anthropic and Bedrock Mantle tasks using newer Claude models now use compatible adaptive thinking settings instead of failing on their first model call. -- New and retried Roomote Cloud tasks now use the deployment-managed compute provider consistently while existing snapshots remain resumable on their source provider. +- New and retried tasks on managed deployments now use the deployment-managed compute provider consistently while existing snapshots remain resumable on their source provider. - Cost analytics time filters now load correctly instead of failing for finite periods such as the default seven-day view. - Discord channel permission diagnostics now identify the connected bot correctly, so settings validation works against the live Discord API. - Managed Roomote compute now keeps its app naming separate from bring-your-own Modal configuration, preventing deployment-managed sandbox attribution conflicts. @@ -2536,7 +2551,7 @@ Multi-SCM triage and mentions, richer chat and release UX, and more reliable sel - Inference providers now carry recommended per-role model defaults (helper, vision, code review, explore, planning). Connecting a provider in the setup wizard applies its recommended defaults automatically, and a "Use recommended" action on the Default Models card in Settings > Models re-applies them at any time. Google Vertex AI now defaults to Claude models (Sonnet 5 coding, Haiku 4.5 helper/explore, Opus 4.8 review/planning), and Google Gemini defaults to Gemini 3.1 Pro for coding with Flash for helper/explore. Requesty is no longer offered for new connections; existing Requesty connections keep working. - GitHub App setup now creates public GitHub Apps so the install step can pick the target organization, instead of forcing private apps that can only install on the creating account. - Failed environment starts show the original prompt with a retry control so operators can relaunch without retyping the kickoff. -- Add Roomote Cloud analytics and support integrations behind deployment flags so hosted deployments can enable cloud analytics, the in-app support chat widget, and related remote telemetry wiring without requiring operators to build those surfaces themselves. +- Add hosted analytics and support integrations behind deployment flags so managed deployments can enable analytics, the in-app support chat widget, and related remote telemetry wiring without requiring operators to build those surfaces themselves. - Add the Roomote deployment-managed compute provider: deployments that ship managed sandbox credentials get a zero-setup sandbox option in setup and Settings while bring-your-own Modal, E2B, Daytona, and Blaxel remain available. - Slack setup can now create the Slack app for you: paste an app configuration token and Roomote creates the app through Slack's `apps.manifest.create` API, saves the client ID, client secret, and signing secret automatically, and advances straight to the Connect to Slack install step. Entering values manually and the prefilled-manifest path remain available as fallbacks, and the mock Slack harness now covers `apps.manifest.create` so the flow is testable without a real workspace. - Deliver spawned-task settle outcomes back to the launching run so follow-up work such as environment verification completion is pushed into the parent task instead of depending on agent-side polling that can go idle. @@ -2549,7 +2564,7 @@ Multi-SCM triage and mentions, richer chat and release UX, and more reliable sel - Give Discord its own gateway secret (Telegram-style) instead of reusing the shared public webhooks secret, reducing blast radius if one channel's secret is rotated or leaked. - Allow the production Docker socket proxy to create and remove managed task workspace volumes so Compose-based deployments can provision workers without 403 volume API failures. - Stop Gitea pull requests from flooding with bot review threads when the bot username does not start with `roomote`, by correctly recognizing bot comments without re-entering mention intake. -- Hide Sandboxes settings when Roomote Cloud is enabled: remove the nav entry and redirect direct visits so cloud deployments do not expose byo-sandbox configuration. +- Hide Sandboxes settings on managed deployments: remove the nav entry and redirect direct visits so those deployments do not expose byo-sandbox configuration. - Show provider headers in multi-provider model choosers and group ChatGPT subscription models under ChatGPT so long model lists are easier to scan in launch and Settings surfaces. - Default models is now Model mapping with a preset chooser and confirmation dialog before applying recommended provider defaults, so operators can review the mapping instead of it overwriting immediately. - Tasks no longer hang forever when OpenCode session creation never returns; the run fails closed with diagnostics instead of waiting indefinitely. diff --git a/README.md b/README.md index ef1567dd79..0926b54044 100644 --- a/README.md +++ b/README.md @@ -19,13 +19,11 @@ something else, all the way. → you review, merge, done ``` -Source-available. Self-hostable or our Cloud. Use your ChatGPT subscription or bring your own +Source-available. Self-hosted. Use your ChatGPT subscription or bring your own API keys. ![Roomote chat workflow demo](assets/roomote-hero.gif) -Deploy on Roomote Cloud -   Deploy on Railway    Deploy to Render @@ -96,10 +94,6 @@ cleans up after itself. Pick one path. You will have a working Roomote instance at the end. -### Roomote Cloud (fastest, ~2 min) - -Don't want to run infrastructure? We'll host your deployment: free 7-day trial, no credit card. Same single-tenant product — your own isolated instance, connect your ChatGPT subscription or bring an API key. Move to self-hosting anytime. [Start free →](https://cloud.roomote.dev/sign-up) - ### One-click deploy (Railway) New to Railway? [Sign up with our referral link](https://railway.com?referralCode=roomote) @@ -179,8 +173,8 @@ copy-pasting. or bring API keys from any supported provider. Switch models per task. No markup on tokens. - **Read every line.** The full source is here. Audit it, extend it, fork it. -- **No per-seat SaaS pricing.** Free for up to 10 users. Need more? [Buy a - self-hosted license on Roomote Cloud](https://cloud.roomote.dev/sign-up). +- **Free for up to 10 users.** Need more? [Contact Roomote about a + self-hosted license](mailto:help@roomote.dev). --- @@ -215,8 +209,7 @@ Need SSO, custom SLAs, or dedicated support? Get in touch: It is source-available under the [Fair Core License 1.0](LICENSE) (FCL-1.0-ALv2). You can read, modify, and self-host the code. Free for up to 10 users. Larger deployments need a license. Email -[help@roomote.dev](mailto:help@roomote.dev) for teams larger than 100 users, or -[buy a self-hosted license on Roomote Cloud](https://cloud.roomote.dev/sign-up). +[help@roomote.dev](mailto:help@roomote.dev) for a self-hosted license. After the license period, the code converts to Apache 2.0. @@ -256,9 +249,7 @@ GitHub, GitLab, Gitea, Azure DevOps, and Bitbucket Cloud. Connect one or many. **What does it cost?** Self-hosting is free for up to 10 registered users. You pay your own model provider for tokens (or use the models bundled with your ChatGPT subscription). -Cloud starts at $49/mo, depending on total user count. -For 11–100 users, [buy a self-hosted license on Roomote -Cloud](https://cloud.roomote.dev/sign-up). For larger teams, email +For more than 10 users, contact Roomote about a self-hosted license at [help@roomote.dev](mailto:help@roomote.dev). **I already pay for ChatGPT Plus. Do I need an API key too?** @@ -297,10 +288,9 @@ Report vulnerabilities privately. See [SECURITY.md](SECURITY.md). ## License [Fair Core License 1.0 (FCL-1.0-ALv2)](LICENSE). Free for up to 10 users. -Licenses for 11–100 users are available through [Roomote -Cloud](https://cloud.roomote.dev/sign-up); larger teams can email +For a self-hosted license, contact [help@roomote.dev](mailto:help@roomote.dev). Purchased licenses report their current user count for subscription billing. Refresh purchased keys annually in -the Roomote Cloud portal, then replace the key in Settings or `R_LICENSE_KEY`. +your existing license account, then replace the key in Settings or `R_LICENSE_KEY`. The license key functionality may not be disabled or circumvented. [TRADEMARKS.md](TRADEMARKS.md) covers trademark usage. diff --git a/SELF_HOSTING.md b/SELF_HOSTING.md index cb31876b43..66ad0d1fe8 100644 --- a/SELF_HOSTING.md +++ b/SELF_HOSTING.md @@ -980,8 +980,8 @@ Roomote is licensed under the Fair Core License 1.0 (FCL-1.0-ALv2, see 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, [buy a self-hosted license on Roomote -Cloud](https://cloud.roomote.dev/sign-up) and apply its key in either of these +To go beyond 10 users, [contact Roomote for a self-hosted +license](mailto:help@roomote.dev) and apply its key in either of these ways: - Enter it in **Settings → Users → License** as an admin, or @@ -990,7 +990,7 @@ ways: any key stored in Settings. Purchased keys are issued annually. While the subscription is active, refresh -the key in the Roomote Cloud portal and replace the value in Settings or +the key in your existing license account and replace the value in Settings or `R_LICENSE_KEY` before its displayed expiry date. Keys are verified offline; usage reporting does not activate or validate them. diff --git a/apps/api/package.json b/apps/api/package.json index a7782d9fc5..47e1c55656 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -49,7 +49,7 @@ "p-map": "^7.0.4", "snowflake-sdk": "3.3.0", "svix": "^1.99.1", - "undici": "^7.29.1", + "undici": "^7.30.1", "zod": "^3.25.76" }, "devDependencies": { diff --git a/apps/docs/cloud.mdx b/apps/docs/cloud.mdx deleted file mode 100644 index e20aa8795b..0000000000 --- a/apps/docs/cloud.mdx +++ /dev/null @@ -1,71 +0,0 @@ ---- -title: Roomote Cloud -icon: cloud -description: Create a hosted Roomote deployment, connect your tools, and complete your first task. ---- - -Roomote Cloud runs an isolated Roomote deployment for you. Roomote manages -hosting, networking, task sandboxes, and upgrades. You use the web dashboard to -choose models, connect your repositories, and start work. - -By the end of this guide and [Run your first task](/first-task), you will -have a working deployment and a completed task whose result you can review. - -## Before you start - -For your first repository task, have: - -- a repository and an account with permission to authorize Roomote's access -- an inference option: managed Roomote inference when offered during setup, - or a supported subscription or inference provider API key -- any credentials or setup instructions your repository needs to build and - run, if they are not already documented in the repository - -You can use a browser on Windows, macOS, or Linux. Cloud provides the server -and sandbox infrastructure, so there is no local installation or domain setup -to complete. - - - Start in the web dashboard. Slack and other communications providers are - optional and can be connected after your first task. - - -## 1. Create your Cloud deployment - -[Sign up for Roomote Cloud](https://cloud.roomote.dev/sign-up), or -[sign in to the Cloud portal](https://cloud.roomote.dev/) if you already have -an account. Follow the portal's deployment setup, then open your Roomote -deployment when it is ready. - -The Cloud portal manages hosting. Your Roomote deployment is where you -complete product setup, configure providers, and run tasks. Keep its URL so -you can return directly to the dashboard. - -## 2. Complete setup and run a task - -Continue with [Run your first task](/first-task). Start at the account setup -step, or continue from your current step if it is already complete. - -The guide helps you create the administrator account, choose inference, -connect a repository, and run a small task. For Cloud: - -- **Inference:** choose the Roomote provider when offered, or connect your own - provider. Managed inference has a credit limit; see - [Managed Roomote inference](/models#managed-roomote-inference) for how it - works and how to add your own provider. -- **Sandboxes:** keep the sandbox provider provisioned for your Cloud - deployment. You do not need to create a separate sandbox provider account - to run your first task. -- **Repository access:** authorize the repositories you want Roomote to work - on. Cloud hosting does not grant Roomote access to them automatically. - -You have completed the getting-started path when Roomote can access your -repository, run work in a sandbox, and return a result you can inspect in the -dashboard. The first-task guide includes those checks and help for each step. - -## After your first task - -Connect [Slack or another communications provider](/communications) to start -and follow work from chat, or [invite your team](/users#create-invites) to use -the dashboard. Add an [environment](/environments) when you want reusable -repository setup, secrets, and instructions for future tasks. diff --git a/apps/docs/docs.json b/apps/docs/docs.json index eead45df7d..232c3b5769 100644 --- a/apps/docs/docs.json +++ b/apps/docs/docs.json @@ -5,7 +5,7 @@ "description": "Documentation for Roomote, the open, self-hostable platform for cloud coding agents.", "markdown": { "instructions": [ - "Start new users at /index to choose Roomote Cloud or self-hosting. Use /cloud for managed hosting and /self-hosting for an operator deployment, including home networks at /homelab. Both paths continue through /first-task. Use Local development only when the user intends to modify Roomote itself.", + "Start new users at /index, then /self-hosting to choose and prepare a host, including home networks at /homelab or infrastructure providers at /self-hosting/platforms. Continue through /first-task. Use Local development only when the user intends to modify Roomote itself.", "When an agent is asked to follow /self-hosting or install Roomote autonomously, read and follow /self-hosting/agent-installation as the normative automation contract even though it is intentionally hidden from side navigation.", "Use the one-command Linux installer for both evaluation and production. On macOS or Windows, create or reuse a full Ubuntu VM and run the same installer inside it; do not replace the installer with the repository's development Compose files.", "For a local VM, prepare a stable public HTTPS domain before connecting providers. Use a named tunnel with internal origin TLS or public DNS with ports 80/443 forwarded to the Linux host. Cloudflare Quick Tunnels do not support the Server-Sent Events Roomote uses; do not use them for the complete setup path.", @@ -44,7 +44,6 @@ "group": "Getting started", "pages": [ "index", - "cloud", { "group": "Self-hosted", "icon": "server", @@ -228,6 +227,10 @@ ] }, "redirects": [ + { + "source": "/cloud", + "destination": "/self-hosting" + }, { "source": "/anonymous-analytics", "destination": "/privacy" diff --git a/apps/docs/environment-variables.mdx b/apps/docs/environment-variables.mdx index 7288e29c98..e53e4c83b3 100644 --- a/apps/docs/environment-variables.mdx +++ b/apps/docs/environment-variables.mdx @@ -132,7 +132,7 @@ as per-task auth tokens or workspace paths. | `R_API_SHUTDOWN_DRAIN_MS` | Optional | Milliseconds the API allows in-flight session turns to finish during shutdown before aborting the remainder so they resume on the next process. Defaults to `20000`; set to `0` to abort immediately. Keep it below the hosting platform's SIGTERM-to-SIGKILL grace period. Also the fallback for `R_BULLMQ_SHUTDOWN_DRAIN_MS`. | | `R_BULLMQ_SHUTDOWN_DRAIN_MS` | Optional | Same window for the bullmq service, which executes the session turns the queue resumes. Defaults to `R_API_SHUTDOWN_DRAIN_MS`, then `20000`. | | `R_FAST_DURABLE_RETRY_DISABLED` | Optional | Set to `true` to keep inference retry waits in the current process instead of parking in-flight session turns durably. Durable admission remains enabled. | -| `R_CLOUD_ENABLED` | Roomote Cloud only | Deployment-managed switch for Roomote Cloud behavior, including required anonymous analytics and Cloud support integrations. Do not set this for self-hosted deployments. | +| `R_CLOUD_ENABLED` | Managed deployments only | Deployment-managed switch for managed-service behavior, including required anonymous analytics and support integrations. Do not set this for self-hosted deployments. | | `R_CURATED_INTEGRATIONS_DISABLED` | Optional | Operator policy for the curated **Integrations** catalog, which is enabled by default. Set to `true` and restart Roomote to prevent those integrations from being configured or used. Existing connections remain stored while disabled and become available again once the value is unset. Communications, source-control, inference, sandbox providers, and environment-defined MCP servers are unaffected. | | `R_GITHUB_APP_SLUG` | GitHub setup | Primary GitHub App slug used by server-rendered setup, mentions, and GitHub integration flows. | | `R_GITHUB_ADDITIONAL_APP_SLUGS` | Optional GitHub setup | Comma-separated additional GitHub App slugs that Roomote should trust as managed bot identities for review attribution and notifications. | @@ -260,7 +260,7 @@ manifest changes. | Env var | Required | Used for | | ------------------------------------ | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | -| `DEFAULT_COMPUTE_PROVIDER` | Optional | Runtime default sandbox provider when no admin default is saved. Supported values are `docker`, `modal`, `e2b`, `daytona`, `blaxel`, `box`, `azure`, and the Roomote Cloud-only `roomote`. | +| `DEFAULT_COMPUTE_PROVIDER` | Optional | Runtime default sandbox provider when no admin default is saved. Supported values are `docker`, `modal`, `e2b`, `daytona`, `blaxel`, `box`, `azure`, and the deployment-managed `roomote`. | | `EXCLUDED_COMPUTE_PROVIDERS` | Optional | Comma-separated provider IDs to hide or exclude from default selection. | | `DOCKER_WORKER_IMAGE` | Optional | Worker image used by Docker. In production, prefer an immutable registry-qualified tag. | | `ROOMOTE_WORKER_IMAGE_REPO` | Optional | Registry repository used to derive the worker image from `RELEASE_VERSION` when `DOCKER_WORKER_IMAGE` is unset. | diff --git a/apps/docs/first-task.mdx b/apps/docs/first-task.mdx index c8184b0cc1..d9e13359e0 100644 --- a/apps/docs/first-task.mdx +++ b/apps/docs/first-task.mdx @@ -4,9 +4,8 @@ icon: play description: Connect a model and repository, run a small task, and review the result in your browser. --- -Follow this guide after you can open your Roomote deployment. The steps are -the same for Roomote Cloud and self-hosted deployments, with hosting -differences called out below. By the end, you will have a Roomote task that +Follow this guide after you can open your Roomote deployment. By the end, +you will have a Roomote task that reads a repository, runs commands in a sandbox, and reports what it found. ## Before you start @@ -16,13 +15,12 @@ Have these ready: - the deployment's initial setup link, or an administrator account if setup has already started - an inference provider account with usable credentials and available credit - or quota, unless you choose the Roomote inference offered during Cloud setup + or quota - permission to connect one repository for the first task An inference provider supplies the model. A sandbox provider supplies the isolated workspace where the agent edits files and runs commands. Both are -needed for the task in this guide. Cloud provisions a sandbox provider; -for a self-hosted deployment, the installer can configure Docker on your host. +needed for the task in this guide. The installer can configure Docker on your host. You can start a conversation after connecting inference alone. Repository @@ -33,7 +31,7 @@ can complete the whole guide in your browser. ## 1. Open your deployment and create your account -Open the deployment link from the Cloud portal or your self-hosted installer. +Open the deployment link from your installer or hosting platform. Follow the account setup prompts. If you have already created the account, sign in and continue from your current setup step. @@ -115,8 +113,8 @@ commands, services, tool versions, or preview ports for later work. ## 5. Confirm where the task will run If a sandbox provider is already configured, Roomote can start the task -without another setup step. Keep the provider provisioned for your Cloud -deployment or configured by your self-hosted installer. +without another setup step. Keep the provider configured by your installer +or deployment administrator. If Roomote asks for a sandbox, use its configuration controls to select and configure a provider: diff --git a/apps/docs/index.mdx b/apps/docs/index.mdx index 4a1d1b6a59..baa60ad6ae 100644 --- a/apps/docs/index.mdx +++ b/apps/docs/index.mdx @@ -1,7 +1,7 @@ --- title: Getting started icon: book-open -description: Choose Roomote Cloud or self-hosting, complete setup, and run your first task. +description: Deploy Roomote on your own infrastructure, complete setup, and run your first task. --- Roomote agents work on your repositories in isolated sandboxes and return @@ -9,21 +9,20 @@ results you can review. Start, follow, and steer their work from the web dashboard, with optional connections to your team's chat and other tools. This guide takes you from choosing where Roomote runs to completing your first -repository task. Both hosting paths run the same Roomote product. +repository task on infrastructure you control. ## 1. Choose where Roomote runs - - Roomote manages hosting, networking, sandboxes, and upgrades for your - isolated deployment. You choose inference, connect repositories, and - decide what work to run. - - + Run Roomote on your own hardware or infrastructure you control. You manage the host, networking, sandboxes, and maintenance, then connect inference and repositories. + + Deploy on infrastructure you manage through Railway, Render, or another + hosting provider. + A home server or **homelab is a self-hosted deployment**. So is a deployment on @@ -44,9 +43,8 @@ The guide walks through: 2. Connecting a repository and checking that a sandbox is ready to run work. 3. Running a small repository inspection task and reviewing the result. -Have a repository you can authorize Roomote to access. For inference, use -managed Roomote inference when offered, or bring a supported subscription or -provider API key. The hosting guide explains what is already supplied. +Have a repository you can authorize Roomote to access. For inference, bring a +supported subscription or provider API key. Slack and other communications providers are optional. You can complete diff --git a/apps/docs/integration-keys.mdx b/apps/docs/integration-keys.mdx index 6cf39c79ec..3a963da001 100644 --- a/apps/docs/integration-keys.mdx +++ b/apps/docs/integration-keys.mdx @@ -71,6 +71,12 @@ approved origin using any HTTP method approved for that integration. Roomote's API sends the request with the real key and returns the response. For scripts, SDKs, CLIs, or many calls, Roomote launches an attached coding task instead. +**In scheduled and manually run custom automations**, Roomote acts for the +user who created the automation. It can use that user's integration keys and +deployment-wide keys the same way, but cannot request new approvals; those still +need a human turn. Webhook-triggered runs do not get integration keys, because +their payloads come from outside callers. + **In coding tasks launched from the session**, the task receives each approved service as a substitute token in its environment, plus one base URL. The agent calls the service through that base URL with the substitute as its bearer diff --git a/apps/docs/integrations/http-integrations.mdx b/apps/docs/integrations/http-integrations.mdx index 9a9bd64628..a1a5d0e29d 100644 --- a/apps/docs/integrations/http-integrations.mdx +++ b/apps/docs/integrations/http-integrations.mdx @@ -10,7 +10,7 @@ path, and request body. Roomote checks the current actor's access, attaches the server-side credential, and sends the HTTPS request. This works through the same Roomote API for every sandbox provider, including -Roomote Cloud, Modal, Docker, E2B, Daytona, Azure, Blaxel, and Box. It requires no +Modal, Docker, E2B, Daytona, Azure, Blaxel, and Box, as well as deployment-managed sandboxes. It requires no provider-specific networking configuration or proxy service. Existing connected integration tools remain available and should be used first. diff --git a/apps/docs/memory.mdx b/apps/docs/memory.mdx index 649a503220..969b3cc33b 100644 --- a/apps/docs/memory.mdx +++ b/apps/docs/memory.mdx @@ -121,8 +121,7 @@ Memory runs alongside Roomote on your deployment's internal network. On the hosted templates (Railway, Render, Coolify), the memory component is already there after a deploy, sitting idle. -New Roomote Cloud deployments enable memory when initial setup completes. -Existing deployments keep their current setting. Administrators can change it +Administrators can enable or disable memory with the **Enable Memory** toggle at the top of **Settings → Memory**; no provider key is required. Query expansion and synthesis run through your deployment's helper model — the same small model that already writes task titles and summaries. diff --git a/apps/docs/models.mdx b/apps/docs/models.mdx index b56c7a2149..e72e453b1b 100644 --- a/apps/docs/models.mdx +++ b/apps/docs/models.mdx @@ -30,9 +30,8 @@ Some hosting deployments provision **Roomote inference** with a limited, spend-capped credit grant during setup. It is separate from your own provider connections: in particular, you can add an OpenRouter key in **Settings > Models** even when Roomote inference is active. If the hosting deployment does -not offer it, the option is not shown. The Roomote Cloud trial includes $5 of -inference, enough to complete several tasks before you connect your own -provider. Trial token usage and estimated model cost appear in task details and +not offer it, the option is not shown. Managed inference token usage and +estimated model cost appear in task details and [Cost Analytics](/cost-analytics), while the remaining credit line reflects the hosted grant's authoritative limit. Its key is stored with your other provider credentials, so deleting the Roomote inference provider in **Settings > Models** diff --git a/apps/docs/privacy.mdx b/apps/docs/privacy.mdx index 35f019f3c6..844584bc87 100644 --- a/apps/docs/privacy.mdx +++ b/apps/docs/privacy.mdx @@ -7,7 +7,7 @@ description: Understand Roomote data handling, anonymous telemetry, and the cont Roomote keeps the data boundaries for a deployment explicit. Product content, credentials, prompts, code, and repository names are not part of the default anonymous telemetry payload. Deployment administrators control whether a -self-hosted deployment shares that telemetry, while Roomote Cloud follows its +self-hosted deployment shares that telemetry, while a managed deployment follows its own privacy policy. This page covers the telemetry payload, its identifiers, the controls that turn @@ -89,9 +89,9 @@ The setting applies to everyone in the deployment. When disabled, no analytics code runs in the browser and no usage events or daily reports leave your servers. -## Roomote Cloud Specifics +## Managed deployment controls -Roomote Cloud deployments always keep Roomote anonymous analytics enabled and +Managed deployments always keep Roomote anonymous analytics enabled and do not show this switch, as described in our privacy policy. Cookie consent controls only optional support and product-experience services such as Intercom and PostHog. diff --git a/apps/docs/providers/communications/agentmail.mdx b/apps/docs/providers/communications/agentmail.mdx index 2785424cdc..a6684d1e7f 100644 --- a/apps/docs/providers/communications/agentmail.mdx +++ b/apps/docs/providers/communications/agentmail.mdx @@ -12,17 +12,14 @@ Roomote can receive email sent to a dedicated deployment inbox, start tasks from those messages, and reply in the same email thread. AgentMail delivers inbound mail through a `message.received` webhook, so Roomote must be reachable at a stable public HTTPS URL. Self-hosted deployments bring their own AgentMail -account and API key; Roomote Cloud provisions managed credentials. +account and API key. ## Enable the email channel Email is off by default. Set `R_EMAIL_CHANNEL_ENABLED=true` in the deployment's environment and restart. Until then the provider does not appear in settings, inbound webhook deliveries are acknowledged and dropped, -and Roomote never sends email. New Roomote Cloud deployments have a managed -`@roomote.me` inbox by default. Roomote Cloud creates it the -first time the deployment sends an email, such as a verification email or a -notification. Older deployments stay disabled until Roomote enables them. +and Roomote never sends email. Enabling the channel gives the deployment an email sender for the first time, so new password sign-ups also receive a verification email (see @@ -55,10 +52,10 @@ then: 3. In the Roomote UI (**Settings > Communications > Email (AgentMail)**), paste the key and save. -Roomote Cloud manages these credentials and the webhook. Its Communications -settings show the inbox and connection status without exposing setup, removal, -or repair controls. The configuration steps below apply to self-hosted -deployments. +If your deployment has managed email credentials, Communications settings show +the inbox and connection status without setup, removal, or repair controls. +Its inbox is created on the first outbound email. The configuration steps below +apply to deployments where you manage the credentials. Roomote uses the inbox the key is scoped to; there is nothing else to enter. On save it validates the key, registers a webhook on the inbox for diff --git a/apps/docs/providers/source-control/github.mdx b/apps/docs/providers/source-control/github.mdx index 7db7d67b48..d658777ce5 100644 --- a/apps/docs/providers/source-control/github.mdx +++ b/apps/docs/providers/source-control/github.mdx @@ -27,6 +27,13 @@ belong to. If your organization should own the app instead, click **Show advanced config** and enter the organization name before clicking **Create GitHub App**. +To install the app on another organization later, click **Update GitHub** in +**Settings > Source Control**. Roomote opens GitHub's account picker, where you +can choose a new organization or change the repositories on an existing +installation. Apps that are private on GitHub can only be installed on the +account that owns them; make the app public under its GitHub **Advanced** +settings to install it elsewhere. + Use the manual steps only when you already have a GitHub App or want to manage these values in deployment environment variables yourself. @@ -248,6 +255,24 @@ the deployment's first installation. With more than one installation, scope a search to the organization or repository you mean so it runs under the right one. +## Task Git access across installations + + +Cross-installation task Git access is available starting with Roomote 1.19.5. + + +A task with a primary GitHub repository can clone, fetch, and push other active +GitHub repositories available to that task even when they belong to another +installation of the configured app. Roomote supplies repository-scoped Git +credentials for those additional installations. If an additional installation +has lost access, its repositories can remain unavailable without blocking work +on the primary repository. + +This applies to HTTPS Git operations. The task's `gh` CLI and primary API +credential still use one installation; they do not automatically gain access +to the other organizations. GitHub repositories explicitly listed together in +an [environment](/environments) must still belong to one installation. + ## Daily GitHub management Roomote can use GitHub's native tools directly, without starting a coding task: @@ -255,7 +280,7 @@ pull request and issue edits, comments, replies and reactions, reviews, labels, branches, and small file changes. Their discovered descriptions and schemas define the supported arguments. These actions use the deployment GitHub App, require an active Roomote member, and run under a token that reaches only the -repositories connected on that installation, the same boundary a coding task +repositories connected on that installation, the same API boundary a coding task has. What the token may do there is set by the App's permissions on GitHub. No personal GitHub OAuth connection is required. Ask for the specific change and identify the repository and target issue, pull request, or comment. Every call @@ -302,9 +327,9 @@ An environment-backed task can also push branches and create or update pull requests in another active GitHub repository connected to the same GitHub App installation, even when that repository is not part of the environment workspace. This write access does not automatically add the repository to the -prepared workspace. Roomote rejects inactive repositories, repositories -connected through another provider, and repositories that belong to a different -GitHub App installation. +prepared workspace. These task delivery tools reject inactive repositories, +repositories connected through another provider, and repositories that belong +to a different GitHub App installation. Read-only requests, creations, and additive label actions run directly. Before editing, replacing, assigning, clearing, moving, or deleting an item, Roomote diff --git a/apps/docs/self-hosting/platforms.mdx b/apps/docs/self-hosting/platforms.mdx index 31444965bc..49d86a657e 100644 --- a/apps/docs/self-hosting/platforms.mdx +++ b/apps/docs/self-hosting/platforms.mdx @@ -6,8 +6,7 @@ description: Deploy Roomote in your Railway, Render, or other hosting account, t A deployment in your own hosting account is a self-hosted Roomote deployment. The platform runs the infrastructure; you manage the deployment, credentials, -updates, and costs. If you want Roomote to manage the deployment for you, -follow [Roomote Cloud](/cloud). +updates, and costs. ## Before you start diff --git a/apps/docs/users.mdx b/apps/docs/users.mdx index e8cac3864a..b4bd8087bc 100644 --- a/apps/docs/users.mdx +++ b/apps/docs/users.mdx @@ -77,8 +77,8 @@ 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, [buy a self-hosted license on Roomote -Cloud](https://cloud.roomote.dev/sign-up), then apply its key in either of these +To add more than 10 users, [contact Roomote for a self-hosted +license](mailto:help@roomote.dev), then apply its key in either of these ways: - enter it in **Settings > Users > License** as an admin, or @@ -87,14 +87,14 @@ ways: The License section shows the current seat usage, license status, licensee, and expiry date. Purchased keys are issued annually and can be refreshed from the -Roomote Cloud portal while the subscription is active. Replace the key in +existing license account while the subscription is active. Replace the key in Settings or `R_LICENSE_KEY` after refreshing it. Keys are signature-verified locally. On self-hosted deployments, they are then -activated with Roomote Cloud for the deployment's stable installation ID. A -paid seat limit remains active while the Cloud activation lease is current (up -to 72 hours); without it, the deployment uses the free-seat limit. Roomote -Cloud deployments use their provisioned valid license directly. Roomote Cloud +activated with Roomote's licensing service for the deployment's stable installation ID. A +paid seat limit remains active while the activation lease is current (up +to 72 hours); without it, the deployment uses the free-seat limit. Managed +deployments use their provisioned valid license directly. Roomote records the current user count and short-lived peaks for subscription billing, independently of anonymous analytics. License usage is not sent to the Ping analytics service. diff --git a/apps/docs/voice.mdx b/apps/docs/voice.mdx index 179a7a1e50..9d3e09b901 100644 --- a/apps/docs/voice.mdx +++ b/apps/docs/voice.mdx @@ -29,13 +29,13 @@ When the voice key is not configured the voice button does not appear. The key stays on the control plane. The browser sends its WebRTC connection offer to Roomote and receives only the negotiated session answer; it never receives the API key. -On Roomote Cloud, each user must accept the experimental voice data flow before -their first call. The confirmation explains that microphone audio, voice +If your deployment requires voice consent, accept the experimental voice data +flow before your first call. The confirmation explains that microphone audio, voice transcripts, and workspace context such as repository and integration names are sent to OpenAI. Roomote saves acceptance for that user across browsers and sessions. Canceling or dismissing the confirmation does not start microphone capture or contact OpenAI. Self-hosted deployments keep their existing voice -flow and do not show this Cloud consent step. +flow and do not show this consent step. ## Using voice diff --git a/apps/web/src/app/(onboarding)/setup/StepConfigureInference.client.test.tsx b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.client.test.tsx index d39461068c..3d0d5e7c67 100644 --- a/apps/web/src/app/(onboarding)/setup/StepConfigureInference.client.test.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.client.test.tsx @@ -98,10 +98,8 @@ describe('StepConfigureInference', () => { expect( screen.getByRole('heading', { name: 'Configure inference' }), ).toBeInTheDocument(); - expect( - screen.getByText(/Your Roomote Cloud trial includes/), - ).toHaveTextContent( - 'Your Roomote Cloud trial includes $5 of inference to try things out, or you can configure your own provider directly.', + expect(screen.getByText(/Your deployment includes/)).toHaveTextContent( + 'Your deployment includes $5 of inference to try things out, or you can configure your own provider directly.', ); expect( screen.getByText(/Some Roomote functionality uses fast judgement models/), diff --git a/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx index 16e5dc06a8..cff5fbf4aa 100644 --- a/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx +++ b/apps/web/src/app/(onboarding)/setup/StepConfigureInference.tsx @@ -47,8 +47,8 @@ export function StepConfigureInference({

- Your Roomote Cloud trial includes $5 of inference to try things out, - or you can configure your own provider directly. + Your deployment includes $5 of inference to try things out, or you can + configure your own provider directly.

Some Roomote functionality uses fast judgement models, not just LLMs. diff --git a/apps/web/src/components/settings/UsersSettings.client.test.tsx b/apps/web/src/components/settings/UsersSettings.client.test.tsx index 2972d11214..4d202f46d0 100644 --- a/apps/web/src/components/settings/UsersSettings.client.test.tsx +++ b/apps/web/src/components/settings/UsersSettings.client.test.tsx @@ -721,12 +721,16 @@ describe('UsersSettings', () => { expect(await screen.findByText('4 of 10 seats used')).toBeInTheDocument(); expect(screen.getByText('Free tier')).toBeInTheDocument(); + expect(screen.queryByText(/Roomote Cloud/)).not.toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: /Refresh the key/ }), + ).not.toBeInTheDocument(); const purchaseLink = screen.getByRole('link', { - name: 'Buy a license on Roomote Cloud', + name: 'Contact Roomote for a license', }); expect(purchaseLink).toHaveAttribute( 'href', - 'https://cloud.roomote.dev/sign-up?utm_source=self-host&utm_medium=settings_users&utm_campaign=license_purchase', + 'mailto:help@roomote.dev?subject=Roomote%20self-hosted%20license', ); fireEvent.click(purchaseLink); expect(mockCapture).toHaveBeenCalledWith('license_purchase_cta_clicked', { @@ -764,7 +768,7 @@ describe('UsersSettings', () => { expect(screen.queryByText('License')).not.toBeInTheDocument(); expect(screen.queryByLabelText('License key')).not.toBeInTheDocument(); expect( - screen.queryByRole('link', { name: 'Buy a license on Roomote Cloud' }), + screen.queryByRole('link', { name: 'Contact Roomote for a license' }), ).not.toBeInTheDocument(); }); @@ -864,7 +868,9 @@ describe('UsersSettings', () => { await screen.findByText('This license expires soon.', { exact: false }), ).toBeInTheDocument(); expect( - screen.getByRole('link', { name: 'Refresh the key in Roomote Cloud' }), + screen.getByRole('link', { + name: 'Refresh the key in your license account', + }), ).toHaveAttribute('href', 'https://cloud.roomote.dev/'); }); }); diff --git a/apps/web/src/components/settings/UsersSettings.tsx b/apps/web/src/components/settings/UsersSettings.tsx index 4a0d234027..a359fe02d5 100644 --- a/apps/web/src/components/settings/UsersSettings.tsx +++ b/apps/web/src/components/settings/UsersSettings.tsx @@ -43,7 +43,7 @@ import { AccountLinkHelpSection } from './AccountLinkHelpSection'; import { formatDistanceToNow } from 'date-fns'; const LICENSE_PURCHASE_URL = - 'https://cloud.roomote.dev/sign-up?utm_source=self-host&utm_medium=settings_users&utm_campaign=license_purchase'; + 'mailto:help@roomote.dev?subject=Roomote%20self-hosted%20license'; const LICENSE_PORTAL_URL = 'https://cloud.roomote.dev/'; const LICENSE_EXPIRY_WARNING_MS = 30 * 24 * 60 * 60 * 1000; @@ -306,7 +306,7 @@ export function UsersSettings() { }) } > - Buy a license on Roomote Cloud + Contact Roomote for a license ); @@ -378,7 +378,7 @@ export function UsersSettings() { target="_blank" rel="noreferrer" > - Refresh the key in Roomote Cloud + Refresh the key in your license account {' '} and replace it here before it expires.

@@ -428,9 +428,9 @@ export function UsersSettings() { )}

Deployments are free for up to {license.freeSeatLimit} users. A - license purchased on Roomote Cloud unlocks more seats. Apply it - here or set R_LICENSE_KEY in - the deployment environment. + self-hosted license unlocks more seats. Apply it here or set{' '} + R_LICENSE_KEY in the deployment + environment.

diff --git a/apps/web/src/trpc/commands/access-policy/index.ts b/apps/web/src/trpc/commands/access-policy/index.ts index d01f08884e..bdb02d4913 100644 --- a/apps/web/src/trpc/commands/access-policy/index.ts +++ b/apps/web/src/trpc/commands/access-policy/index.ts @@ -226,7 +226,7 @@ export async function setLicenseKeyCommand( if (state.status === 'expired') { throw new Error( - 'That license key has expired. Refresh it in Roomote Cloud or contact Roomote for help.', + 'That license key has expired. Refresh it in your license account or contact Roomote for help.', ); } @@ -244,12 +244,12 @@ export async function setLicenseKeyCommand( } if (activation.status === 'rejected') { throw new Error( - 'Roomote Cloud rejected the activation response. Contact Roomote support if this continues.', + 'The licensing service rejected the activation response. Contact Roomote support if this continues.', ); } if (activation.status !== 'synced') { throw new Error( - 'Roomote Cloud could not activate this license. Check your network connection and try again.', + 'The licensing service could not activate this license. Check your network connection and try again.', ); } } diff --git a/apps/web/src/trpc/commands/github/mutations.test.ts b/apps/web/src/trpc/commands/github/mutations.test.ts index 7e57cce0ec..ff38d33aaa 100644 --- a/apps/web/src/trpc/commands/github/mutations.test.ts +++ b/apps/web/src/trpc/commands/github/mutations.test.ts @@ -4,12 +4,14 @@ import { decodeRecord } from '@/lib'; const { mockDbTransaction, mockEnvState, + mockFindFirstInstallation, mockFetch, mockResolveDeploymentEnvVar, mockResolvePendingGitHubInstallations, mockUpsertDeploymentEnvironmentVariables, } = vi.hoisted(() => ({ mockDbTransaction: vi.fn(), + mockFindFirstInstallation: vi.fn(), mockEnvState: { R_APP_URL: 'https://roomote.example.com', R_PUBLIC_URL: undefined as string | undefined, @@ -46,6 +48,7 @@ vi.mock('@roomote/db/server', () => ({ transaction: mockDbTransaction, query: { githubInstallations: { + findFirst: mockFindFirstInstallation, findMany: vi.fn(), }, }, @@ -435,6 +438,33 @@ describe('GitHub App manifest commands', () => { ); }); + it('opens the account picker when the deployment already has an installation', async () => { + mockResolveDeploymentEnvVar.mockImplementation(async (name: string) => + name === 'R_GITHUB_APP_SLUG' + ? 'configured-roomote-app' + : 'configured-value', + ); + mockFindFirstInstallation.mockResolvedValueOnce({ installationId: 42 }); + + const result = await startCreateGitHubInstallationCommand(buildMockAuth(), { + redirect: '/settings/source-control', + }); + + expect(result.success).toBe(true); + + if (!result.success) { + return; + } + + const installUrl = new URL(result.url); + expect(installUrl.pathname).toBe( + '/apps/configured-roomote-app/installations/select_target', + ); + expect(decodeRecord(installUrl.searchParams.get('state') ?? '')).toEqual({ + redirect: '/settings/source-control', + }); + }); + it('prefers R_PUBLIC_URL for install redirect_url when R_APP_URL is loopback', async () => { mockEnvState.R_APP_URL = 'http://127.0.0.1:13000'; mockEnvState.R_PUBLIC_URL = 'https://customer.example'; diff --git a/apps/web/src/trpc/commands/github/mutations.ts b/apps/web/src/trpc/commands/github/mutations.ts index ca3c030cb4..bf0b9b59ea 100644 --- a/apps/web/src/trpc/commands/github/mutations.ts +++ b/apps/web/src/trpc/commands/github/mutations.ts @@ -540,9 +540,24 @@ export async function startCreateGitHubInstallationCommand( const query = params.toString(); + // Once the deployment has an installation, `installations/new` can jump + // straight to that installation's settings, leaving no way to install the + // App on another account. `select_target` always shows GitHub's account + // picker (and keeps `state`), so it covers adding another organization as + // well as changing repositories on an existing installation. + const hasActiveInstallation = Boolean( + await db.query.githubInstallations.findFirst({ + where: isNull(githubInstallations.suspendedAt), + columns: { installationId: true }, + }), + ); + const installPath = hasActiveInstallation + ? 'installations/select_target' + : 'installations/new'; + return { success: true, - url: `https://github.com/apps/${appSlug}/installations/new${query ? `?${query}` : ''}`, + url: `https://github.com/apps/${appSlug}/${installPath}${query ? `?${query}` : ''}`, }; } diff --git a/apps/worker/src/lib/github-token.ts b/apps/worker/src/lib/github-token.ts index 881b4c70d2..07170525d3 100644 --- a/apps/worker/src/lib/github-token.ts +++ b/apps/worker/src/lib/github-token.ts @@ -69,8 +69,10 @@ unset ADO_TOKEN /** * Custom git credential helper that reads provider credentials from files on - * every invocation. GitHub keeps using a single file-backed token so `gh auth - * setup-git` and HTTPS git operations see the freshest token. GitLab keeps + * every invocation. GitHub uses a single file-backed token so `gh auth + * setup-git` and HTTPS git operations see the freshest token, except for + * repositories on another GitHub App installation, which carry their own + * scoped credential in the repository credentials file. GitLab keeps * using scoped repository credentials written to a file because those tokens * are already short-lived and repository-scoped. */ @@ -102,6 +104,24 @@ case "$1" in get) case "$HOST" in github.com) + # Repositories on another GitHub App installation carry their own + # scoped credential; everything else uses the primary token. + REPOSITORY_PATH="$(normalize_repository_path "$PATH_VALUE")" + if [ -n "$REPOSITORY_PATH" ] && [ -r "$SOURCE_CONTROL_CREDENTIALS_FILE" ]; then + REPOSITORY_KEY="$(printf '%s' "$REPOSITORY_PATH" | tr '[:upper:]' '[:lower:]')" + while IFS=$'\\t' read -r credential_host repo username token; do + REPO_KEY="$(printf '%s' "$repo" | tr '[:upper:]' '[:lower:]')" + if [ "$credential_host" = "$HOST" ] && [ "$REPO_KEY" = "$REPOSITORY_KEY" ] && [ -n "$username" ] && [ -n "$token" ]; then + echo "protocol=\${PROTOCOL:-https}" + echo "host=$HOST" + echo "path=$PATH_VALUE" + echo "username=$username" + echo "password=$token" + echo "" + exit 0 + fi + done < "$SOURCE_CONTROL_CREDENTIALS_FILE" + fi if [ -r "$GH_TOKEN_FILE" ]; then TOKEN="$(tr -d '\\n' < "$GH_TOKEN_FILE")" if [ -n "$TOKEN" ]; then diff --git a/apps/worker/src/run-task/agent-home.test.ts b/apps/worker/src/run-task/agent-home.test.ts index 913e9d7823..4ca6f5fbec 100644 --- a/apps/worker/src/run-task/agent-home.test.ts +++ b/apps/worker/src/run-task/agent-home.test.ts @@ -1072,6 +1072,8 @@ describe('generateOpenCodeConfig provider support', () => { ).toEqual([ 'k3', 'k3-256k', + 'k3.1-pro', + 'k3.1-pro-highspeed', 'kimi-for-coding', 'kimi-for-coding-highspeed', ]); diff --git a/package.json b/package.json index 0b95c0ff0e..ec470dc342 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "roomote", - "version": "1.19.4", + "version": "1.19.5", "license": "FCL-1.0-ALv2", "packageManager": "pnpm@10.29.3", "engines": { @@ -117,7 +117,7 @@ "read-yaml-file@1>js-yaml": "3.15.2", "nise>path-to-regexp": "8.4.0", "protobufjs": "7.6.5", - "qs": "6.16.0", + "qs": "6.16.1", "router>path-to-regexp": "8.4.0", "socket.io>socket.io-adapter": "2.5.8", "socket.io-adapter>ws": "8.21.0", @@ -132,7 +132,7 @@ "jayson>uuid": "11.1.1", "preact": ">=10.26.10", "smol-toml": "1.9.0", - "undici": "^7.29.1", + "undici": "^7.30.1", "diff": ">=5.2.2", "flatted": "^3.4.2", "@protobufjs/utf8": "1.1.1", diff --git a/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts b/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts index 6b4571547b..5edcebd491 100644 --- a/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts +++ b/packages/cloud-agents/src/server/__tests__/opencode-runtime.test.ts @@ -572,6 +572,8 @@ describe('buildOpenCodeCliEnv', () => { ).toEqual([ 'k3', 'k3-256k', + 'k3.1-pro', + 'k3.1-pro-highspeed', 'kimi-for-coding', 'kimi-for-coding-highspeed', ]); diff --git a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts index 77c8f4c767..4c20c34ff5 100644 --- a/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts +++ b/packages/cloud-agents/src/server/fast-agent/__tests__/fast-agent-service.test.ts @@ -2031,9 +2031,36 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { }, ); - it.each(['human', 'platform_event'] as const)( - 'passes trusted %s context to operator HTTP requests without argument authority', - async (turnSource) => { + it.each([ + ['human', {}, true], + ['platform_event', {}, false], + [ + 'platform_event', + { + platformEventKind: 'automation', + serviceCredentialPlatformActorUserId: 'user-1', + }, + true, + ], + [ + 'platform_event', + { + platformEventKind: 'automation', + serviceCredentialPlatformActorUserId: 'user-2', + }, + false, + ], + [ + 'platform_event', + { + platformEventKind: 'delegated_task', + serviceCredentialPlatformActorUserId: 'user-1', + }, + false, + ], + ] as const)( + 'passes trusted %s context %o to operator HTTP requests without argument authority', + async (turnSource, eventParams, expectedHumanTurn) => { mocks.listIntegrations.mockResolvedValue([ { id: '_roomote_http_integrations', @@ -2073,6 +2100,7 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { ); await answerFastAgentQuestion({ ...baseParams, + ...eventParams, turnSource, adapter: callbacks(), }); @@ -2080,7 +2108,7 @@ describe('answerFastAgentQuestion native OpenCode tools', () => { expect.objectContaining({ sessionId: 'conversation-1', userId: 'user-1', - humanTurn: turnSource === 'human', + humanTurn: expectedHumanTurn, }), expect.any(Array), { diff --git a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts index a67c40b8fe..364ef0702a 100644 --- a/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts +++ b/packages/cloud-agents/src/server/fast-agent/fast-agent-service.ts @@ -4833,7 +4833,12 @@ export async function answerFastAgentQuestion({ privacy: currentSessionPrivacy, privateOwnerUserId: currentPrivateOwnerUserId, privateSessionsExperimentEnabled, - humanTurn: !platformEvent, + // Custom automation runs act for their creator once the parent + // event resolved that creator as the Session owner. + humanTurn: + !platformEvent || + (platformEventKind === 'automation' && + serviceCredentialPlatformActorUserId === userId), }, availableIntegrations, { diff --git a/packages/github/src/__tests__/task-run-token.test.ts b/packages/github/src/__tests__/task-run-token.test.ts index 60a91bc9ca..84088be187 100644 --- a/packages/github/src/__tests__/task-run-token.test.ts +++ b/packages/github/src/__tests__/task-run-token.test.ts @@ -445,6 +445,83 @@ describe('createTaskRunGitHubToken', () => { expect(mockCreateGitHubTokenWithMetadata).not.toHaveBeenCalled(); }); + it("mints the task repository's installation when a stamp without an environment spans installations", async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + mockFindMany.mockResolvedValue([ + { + fullName: 'owner-a/api', + installationId: 'install-a', + githubRepoId: 401, + }, + { + fullName: 'owner-b/web', + installationId: 'install-b', + githubRepoId: 402, + }, + { + fullName: 'owner-b/docs', + installationId: 'install-b', + githubRepoId: 403, + }, + ]); + + await expect( + createTaskRunGitHubToken( + buildTaskRun({ + repo: 'owner-b/web', + repositoryProviders: { + 'owner-a/api': 'github', + 'owner-b/web': 'github', + 'owner-b/docs': 'github', + }, + } as TaskRun['payload']), + ), + ).resolves.toBe('ghs_test_token'); + expect(mockCreateGitHubTokenWithMetadata).toHaveBeenCalledWith( + { + type: 'installationId', + installationId: 'install-b', + repositoryIds: [402, 403], + }, + undefined, + undefined, + ); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Not reachable with this token: owner-a/api'), + ); + consoleWarnSpy.mockRestore(); + }); + + it('keeps failing a spanning stamp without an environment when the task repository is not stamped', async () => { + mockFindMany.mockResolvedValue([ + { + fullName: 'owner-a/api', + installationId: 'install-a', + githubRepoId: 401, + }, + { + fullName: 'owner-b/web', + installationId: 'install-b', + githubRepoId: 402, + }, + ]); + + await expect( + createTaskRunGitHubToken( + buildTaskRun({ + repo: 'owner-c/other', + repositoryProviders: { + 'owner-a/api': 'github', + 'owner-b/web': 'github', + }, + } as TaskRun['payload']), + ), + ).rejects.toThrow(GitHubInstallationSpanError); + expect(mockCreateGitHubTokenWithMetadata).not.toHaveBeenCalled(); + }); + it('fails closed when a GitLab environment stamp spans GitHub installations', async () => { mockFindEnvironmentFirst.mockResolvedValue({ id: 'gitlab-environment', @@ -706,6 +783,187 @@ describe('createTaskRunGitHubToken', () => { }); }); + it('adds git credentials for stamped repositories on other installations', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + mockFindMany.mockResolvedValue([ + { + fullName: 'owner-a/api', + host: 'github.com', + installationId: 'install-a', + githubRepoId: 401, + }, + { + fullName: 'owner-b/web', + host: 'github.com', + installationId: 'install-b', + githubRepoId: 402, + }, + ]); + mockCreateGitHubTokenWithMetadata + .mockResolvedValueOnce({ + token: 'ghs_primary_token', + expiresAt: new Date('2030-01-01T01:00:00.000Z'), + }) + .mockResolvedValueOnce({ + token: 'ghs_other_org_token', + expiresAt: new Date('2030-01-01T00:30:00.000Z'), + }); + + await expect( + createTaskRunWorkerGitHubTokenWithMetadata( + buildTaskRun({ + repo: 'owner-b/web', + repositoryProviders: { + 'owner-a/api': 'github', + 'owner-b/web': 'github', + }, + } as TaskRun['payload']), + ), + ).resolves.toEqual({ + token: 'ghs_primary_token', + source: 'app', + expiresAt: new Date('2030-01-01T00:30:00.000Z'), + gitCredentials: [ + { + host: 'github.com', + repositoryFullName: 'owner-a/api', + username: 'x-access-token', + token: 'ghs_other_org_token', + }, + ], + }); + expect(mockCreateGitHubTokenWithMetadata).toHaveBeenNthCalledWith(2, { + type: 'installationId', + installationId: 'install-a', + repositoryIds: [401], + }); + consoleWarnSpy.mockRestore(); + }); + + it('skips another installation that fails to mint without failing the run', async () => { + const consoleWarnSpy = vi + .spyOn(console, 'warn') + .mockImplementation(() => undefined); + mockFindMany.mockResolvedValue([ + { + fullName: 'owner-a/api', + installationId: 'install-a', + githubRepoId: 401, + }, + { + fullName: 'owner-b/web', + installationId: 'install-b', + githubRepoId: 402, + }, + ]); + mockCreateGitHubTokenWithMetadata + .mockResolvedValueOnce({ + token: 'ghs_primary_token', + expiresAt: new Date('2030-01-01T01:00:00.000Z'), + }) + .mockRejectedValueOnce( + Object.assign(new Error('installation suspended'), { status: 403 }), + ); + + await expect( + createTaskRunWorkerGitHubTokenWithMetadata( + buildTaskRun({ + repo: 'owner-b/web', + repositoryProviders: { + 'owner-a/api': 'github', + 'owner-b/web': 'github', + }, + } as TaskRun['payload']), + ), + ).resolves.toEqual({ + token: 'ghs_primary_token', + source: 'app', + expiresAt: new Date('2030-01-01T01:00:00.000Z'), + }); + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('skipping: owner-a/api: installation suspended'), + ); + consoleWarnSpy.mockRestore(); + }); + + it('propagates a transient failure on another installation so refresh retries', async () => { + mockFindMany.mockResolvedValue([ + { + fullName: 'owner-a/api', + installationId: 'install-a', + githubRepoId: 401, + }, + { + fullName: 'owner-b/web', + installationId: 'install-b', + githubRepoId: 402, + }, + ]); + mockCreateGitHubTokenWithMetadata + .mockResolvedValueOnce({ + token: 'ghs_primary_token', + expiresAt: new Date('2030-01-01T01:00:00.000Z'), + }) + .mockRejectedValueOnce( + Object.assign(new Error('Service unavailable'), { status: 503 }), + ); + + await expect( + createTaskRunWorkerGitHubTokenWithMetadata( + buildTaskRun({ + repo: 'owner-b/web', + repositoryProviders: { + 'owner-a/api': 'github', + 'owner-b/web': 'github', + }, + } as TaskRun['payload']), + ), + ).rejects.toThrow('Service unavailable'); + }); + + it.each([ + Object.assign(new Error('API rate limit exceeded for installation'), { + status: 403, + }), + Object.assign(new Error('The endpoint has been spammed'), { status: 422 }), + ])( + 'treats a rate-limited mint on another installation as transient', + async (rateLimitError) => { + mockFindMany.mockResolvedValue([ + { + fullName: 'owner-a/api', + installationId: 'install-a', + githubRepoId: 401, + }, + { + fullName: 'owner-b/web', + installationId: 'install-b', + githubRepoId: 402, + }, + ]); + mockCreateGitHubTokenWithMetadata + .mockResolvedValueOnce({ + token: 'ghs_primary_token', + expiresAt: new Date('2030-01-01T01:00:00.000Z'), + }) + .mockRejectedValueOnce(rateLimitError); + + await expect( + createTaskRunWorkerGitHubTokenWithMetadata( + buildTaskRun({ + repo: 'owner-b/web', + repositoryProviders: { + 'owner-a/api': 'github', + 'owner-b/web': 'github', + }, + } as TaskRun['payload']), + ), + ).rejects.toBe(rateLimitError); + }, + ); + it('evicts a cached task token and retries once after a 401', async () => { mockCreateGitHubTokenWithMetadata .mockResolvedValueOnce({ diff --git a/packages/github/src/api.ts b/packages/github/src/api.ts index 4da5ddd95d..6bcab426a9 100644 --- a/packages/github/src/api.ts +++ b/packages/github/src/api.ts @@ -16,6 +16,7 @@ import { filterRepositoryNamesForSourceControlProvider, normalizePemEnvValue, resolveRepositoryNamesForSourceControlProviderFromPayload, + type SourceControlGitCredential, } from '@roomote/types'; import { type GitHubInstallation, @@ -281,6 +282,61 @@ export async function resolveTaskRunEnvironmentGitHubRepositories( }); } +/** + * Narrows a stamped repository scope that spans GitHub App installations to + * the repositories on the installation that owns the task's own repository + * (`payload.repo`). Returns null when that repository is not an active GitHub + * repository, so callers keep failing closed. + */ +async function resolvePrimaryRepositoryInstallationScope( + taskRun: TaskRun, + stampedRepositories: string[], +): Promise { + const primaryRepository = taskRun.payload.repo; + if ( + !primaryRepository || + primaryRepository === ALL_REPOSITORIES || + !stampedRepositories.includes(primaryRepository) + ) { + return null; + } + + const stampedRows = await db.query.repositories.findMany({ + where: and( + eq(repositories.sourceControlProvider, DEFAULT_SOURCE_CONTROL_PROVIDER), + eq(repositories.isActive, true), + inArray(repositories.fullName, stampedRepositories), + ), + }); + const primaryInstallationId = stampedRows.find( + (row) => row.fullName === primaryRepository, + )?.installationId; + if (!primaryInstallationId) { + return null; + } + + const sameInstallationRows = stampedRows.filter( + (row) => row.installationId === primaryInstallationId, + ); + const includedRepositories = new Set( + sameInstallationRows.map((row) => row.fullName), + ); + const excludedRepositories = stampedRepositories.filter( + (repository) => !includedRepositories.has(repository), + ); + console.warn( + `[resolveTaskRunGitHubTokenOptions] Stamped GitHub repositories for task run ${taskRun.id} span multiple installations; minting the ${primaryRepository} installation only. Not reachable with this token: ${excludedRepositories.join(', ')}`, + ); + + return resolveTokenOptionsForRepositoryNames({ + taskRun, + repositoryNames: sameInstallationRows.map((row) => row.fullName), + repositoryRows: sameInstallationRows, + missingMessagePrefix: 'Stamped repositories not found', + spanningMessagePrefix: 'Stamped repositories', + }); +} + async function resolveTaskRunGitHubTokenOptions( taskRun: TaskRun, ): Promise { @@ -314,12 +370,27 @@ async function resolveTaskRunGitHubTokenOptions( skipUnavailableRepositories: true, }); } catch (error) { + if (!isGitHubInstallationSpanError(error)) { + throw error; + } + + // Without an environment (all-repository workspaces, PR reviews), keep + // the stamped repositories on the task's own repository installation so + // a second connected organization does not block every run. + if (!repositoryRows?.length) { + const primaryOptions = await resolvePrimaryRepositoryInstallationScope( + taskRun, + stampedRepositories, + ); + if (!primaryOptions) { + throw error; + } + return primaryOptions; + } + // Extra advertised checkouts can span GitHub App installations. Mint the // environment's installation instead of blocking a workspace that already // has a single-installation GitHub scope. - if (!repositoryRows?.length || !isGitHubInstallationSpanError(error)) { - throw error; - } // The payload still advertises the excluded repositories as on-demand // checkouts, so name them: a later clone failure is otherwise opaque. @@ -460,6 +531,11 @@ export type TaskRunWorkerGitHubToken = { token: string; source: 'user' | 'app'; expiresAt: Date | null; + /** + * Per-repository git credentials for stamped repositories on other GitHub + * App installations. Git only: `gh` and API calls keep the primary token. + */ + gitCredentials?: SourceControlGitCredential[]; }; /** @@ -477,14 +553,136 @@ export type TaskRunWorkerGitHubToken = { export async function createTaskRunWorkerGitHubTokenWithMetadata( taskRun: TaskRun, ): Promise { - const metadata = await createTaskRunGitHubTokenWithMetadata(taskRun); + const options = await resolveTaskRunGitHubTokenOptions(taskRun); + const metadata = await createGitHubTokenWithMetadata(options); + const additional = + options.type === 'installationId' + ? await createAdditionalInstallationGitCredentials( + taskRun, + String(options.installationId), + ) + : null; + return { token: metadata.token, source: 'app', - expiresAt: metadata.expiresAt, + expiresAt: earliestTokenExpiry( + metadata.expiresAt, + additional?.expiresAt ?? null, + ), + ...(additional?.credentials.length + ? { gitCredentials: additional.credentials } + : {}), }; } +/** + * Errors that will not clear on retry: the installation was suspended, + * removed, or lost access to the repositories (401/403/404/422). GitHub also + * answers rate limits with 403 and token-endpoint spam protection with 422, + * so recognized rate limits stay transient. + */ +function isPermanentInstallationTokenError(error: unknown): boolean { + if (getGitHubRateLimitRetryAfterMs(error) !== null) { + return false; + } + const status = + typeof error === 'object' && error !== null && 'status' in error + ? Number((error as { status: unknown }).status) + : NaN; + return status === 401 || status === 403 || status === 404 || status === 422; +} + +function earliestTokenExpiry(left: Date | null, right: Date | null) { + if (!left) return right; + if (!right) return left; + return left.getTime() <= right.getTime() ? left : right; +} + +/** + * A GitHub App token covers one installation, so stamped repositories on + * other installations get their own repository-scoped tokens, delivered as + * per-repository git credentials. Best effort: an installation that fails to + * mint is logged and skipped rather than blocking the run, since the primary + * token already covers the task's own repository. + */ +async function createAdditionalInstallationGitCredentials( + taskRun: TaskRun, + primaryInstallationId: string, +): Promise<{ + credentials: SourceControlGitCredential[]; + expiresAt: Date | null; +}> { + const stampedRepositories = + resolveRepositoryNamesForSourceControlProviderFromPayload( + taskRun.payload, + DEFAULT_SOURCE_CONTROL_PROVIDER, + ) ?? []; + if (stampedRepositories.length === 0) { + return { credentials: [], expiresAt: null }; + } + + const rows = await db.query.repositories.findMany({ + where: and( + eq(repositories.sourceControlProvider, DEFAULT_SOURCE_CONTROL_PROVIDER), + eq(repositories.isActive, true), + inArray(repositories.fullName, [...new Set(stampedRepositories)]), + ), + }); + const rowsByInstallation = new Map(); + for (const row of rows) { + if ( + !row.installationId || + row.installationId === primaryInstallationId || + row.githubRepoId == null + ) { + continue; + } + const group = rowsByInstallation.get(row.installationId) ?? []; + group.push(row); + rowsByInstallation.set(row.installationId, group); + } + + const credentials: SourceControlGitCredential[] = []; + let expiresAt: Date | null = null; + for (const [installationId, group] of rowsByInstallation) { + try { + const metadata = await createGitHubTokenWithMetadata({ + type: 'installationId', + installationId, + repositoryIds: group + .map((row) => row.githubRepoId) + .filter((id): id is number => id != null), + }); + expiresAt = earliestTokenExpiry(expiresAt, metadata.expiresAt); + for (const row of group) { + credentials.push({ + host: row.host || 'github.com', + repositoryFullName: row.fullName, + username: 'x-access-token', + token: metadata.token, + }); + } + } catch (error) { + // Transient failures (5xx, rate limits, network) propagate so the + // caller retries and an hourly refresh keeps the worker's current + // credentials instead of dropping this organization's repositories. + if (!isPermanentInstallationTokenError(error)) { + throw error; + } + console.warn( + `[createAdditionalInstallationGitCredentials] Failed to mint git credentials for task run ${taskRun.id} on another GitHub installation; skipping: ${group + .map((row) => row.fullName) + .join( + ', ', + )}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + + return { credentials, expiresAt }; +} + export async function createTaskRunWorkerGitHubToken( taskRun: TaskRun, ): Promise { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 2a22786653..3c9e1ded12 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -148,7 +148,7 @@ "smol-toml": "1.9.0", "superjson": "^2.2.6", "turndown": "7.2.0", - "undici": "^7.29.1", + "undici": "^7.30.1", "unzipper": "^0.12.5", "yaml": "^2.9.0", "zod": "^3.25.76" diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts index 6f578f1c39..daecc4066a 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.test.ts @@ -3069,6 +3069,77 @@ describe('deliverFastAgentParentEvent', () => { }, ); + it.each(['schedule', 'manual'] as const)( + "lets a %s custom automation run use its creator's integration keys", + async (trigger) => { + mocks.findCustomAutomation.mockResolvedValue({ + id: 'automation-1', + name: 'Weekly scan', + createdByUserId: 'u1', + }); + + await deliverFastAgentParentEvent({ + parent, + event: { + type: 'automation_triggered', + eventId: 'occurrence-1', + automationId: 'automation-1', + automationName: 'Weekly scan', + prompt: 'Find actionable regressions.', + trigger, + }, + }); + + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ + platformEventKind: 'automation', + serviceCredentialPlatformActorUserId: 'u1', + }), + ); + }, + ); + + it.each([ + ['a webhook trigger', 'webhook', 'u1', 'no_acting_user'], + [ + 'a creator who does not own the Session', + 'schedule', + 'u2', + 'actor_owner_mismatch', + ], + ['no recorded creator', 'schedule', null, 'no_acting_user'], + ] as const)( + 'keeps integration keys unavailable for a custom automation run with %s', + async (_label, trigger, createdByUserId, denialReason) => { + mocks.findCustomAutomation.mockResolvedValue({ + id: 'automation-1', + name: 'Weekly scan', + createdByUserId, + }); + + await deliverFastAgentParentEvent({ + parent, + event: { + type: 'automation_triggered', + eventId: 'occurrence-1', + automationId: 'automation-1', + automationName: 'Weekly scan', + prompt: 'Find actionable regressions.', + trigger, + }, + }); + + expect(mocks.answerQuestion).toHaveBeenCalledWith( + expect.objectContaining({ + serviceCredentialPlatformDenialReason: denialReason, + }), + ); + expect(mocks.answerQuestion.mock.calls[0]![0]).not.toHaveProperty( + 'serviceCredentialPlatformActorUserId', + ); + }, + ); + it.each([undefined, 'anthropic/claude-sonnet-5'])( 'keeps child model selection independent of the automation session: %s', async (childModel) => { diff --git a/packages/sdk/src/server/lib/fast-agent-parent-event.ts b/packages/sdk/src/server/lib/fast-agent-parent-event.ts index 04c926bdea..041ceabb79 100644 --- a/packages/sdk/src/server/lib/fast-agent-parent-event.ts +++ b/packages/sdk/src/server/lib/fast-agent-parent-event.ts @@ -2903,10 +2903,7 @@ async function withUnsharedTaskUpdates( async function resolveDelegatedTaskActor( parent: FastAgentParent, - event: Extract< - FastAgentParentEvent, - { type: 'child_message' | 'task_activity' | 'task_settled' } - >, + event: { actingUserId?: string }, ): Promise<{ userId?: string; denialReason?: 'no_acting_user' | 'actor_owner_mismatch'; @@ -2927,6 +2924,27 @@ async function resolveDelegatedTaskActor( return actor ? { userId: actor.id } : { denialReason: 'no_acting_user' }; } +/** + * A scheduled or manual custom automation run acts for the user who created + * the automation, so it may use that user's integration keys, provided the + * run's Session is owned by the same active user. Webhook-triggered runs carry + * caller-supplied payloads and stay without a credential actor. + */ +async function resolveAutomationCredentialActor( + parent: FastAgentParent, + event: Extract, +): Promise<{ + userId?: string; + denialReason?: 'no_acting_user' | 'actor_owner_mismatch'; +}> { + if (event.trigger === 'webhook') return { denialReason: 'no_acting_user' }; + const automation = await getCustomAutomationById(event.automationId); + if (!automation?.createdByUserId) return { denialReason: 'no_acting_user' }; + return resolveDelegatedTaskActor(parent, { + actingUserId: automation.createdByUserId, + }); +} + export async function deliverFastAgentParentEvent( params: FastAgentParentEventDeliveryParams, ): Promise<'delivered' | 'skipped'> { @@ -3121,7 +3139,9 @@ export async function deliverFastAgentParentEventWithLock( params.event.type === 'task_activity' || params.event.type === 'task_settled' ? await resolveDelegatedTaskActor(params.parent, params.event) - : undefined; + : params.event.type === 'automation_triggered' + ? await resolveAutomationCredentialActor(params.parent, params.event) + : undefined; let parentTurn = await createFastAgentParentTurn({ parent: params.parent, event: params.event, diff --git a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts index f1fb4388c6..54f05fb18b 100644 --- a/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts +++ b/packages/sdk/src/server/lib/task-runs/dequeue-helpers.ts @@ -614,6 +614,9 @@ async function createProviderToken( await createTaskRunWorkerGitHubTokenWithMetadata(taskRun); return { ...buildSourceControlTokenMetadata(provider, metadata.token), + ...(metadata.gitCredentials?.length + ? { gitCredentials: metadata.gitCredentials } + : {}), source: metadata.source, expiresAt: metadata.expiresAt, }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b910373358..1231b98298 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -33,7 +33,7 @@ overrides: read-yaml-file@1>js-yaml: 3.15.2 nise>path-to-regexp: 8.4.0 protobufjs: 7.6.5 - qs: 6.16.0 + qs: 6.16.1 router>path-to-regexp: 8.4.0 socket.io>socket.io-adapter: 2.5.8 socket.io-adapter>ws: 8.21.0 @@ -48,7 +48,7 @@ overrides: jayson>uuid: 11.1.1 preact: '>=10.26.10' smol-toml: 1.9.0 - undici: ^7.29.1 + undici: ^7.30.1 diff: '>=5.2.2' flatted: ^3.4.2 '@protobufjs/utf8': 1.1.1 @@ -254,8 +254,8 @@ importers: specifier: ^1.99.1 version: 1.99.1 undici: - specifier: ^7.29.1 - version: 7.29.1 + specifier: ^7.30.1 + version: 7.30.1 zod: specifier: ^3.25.76 version: 3.25.76 @@ -1687,8 +1687,8 @@ importers: specifier: 7.2.0 version: 7.2.0 undici: - specifier: ^7.29.1 - version: 7.29.1 + specifier: ^7.30.1 + version: 7.30.1 unzipper: specifier: ^0.12.5 version: 0.12.5 @@ -10675,8 +10675,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qs@6.16.0: - resolution: {integrity: sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==} + qs@6.16.1: + resolution: {integrity: sha512-5Px6J7rfHBAGUhNQ3es4YzcOdBBw0SDcVtThJml4RdnKw4tKs2epA0kPZ+K8X+WiHktT93RHVcQ/3ym5aTCgtg==} engines: {node: '>=0.6'} quansync@0.2.10: @@ -11861,8 +11861,8 @@ packages: undici-types@7.18.2: resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==} - undici@7.29.1: - resolution: {integrity: sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==} + undici@7.30.1: + resolution: {integrity: sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==} engines: {node: '>=20.18.1'} unicorn-magic@0.2.0: @@ -13541,7 +13541,7 @@ snapshots: discord-api-types: 0.38.49 magic-bytes.js: 1.13.0 tslib: 2.8.1 - undici: 7.29.1 + undici: 7.30.1 '@discordjs/util@1.2.0': dependencies: @@ -18219,7 +18219,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.16.0 + qs: 6.16.1 raw-body: 3.0.2 type-is: 2.1.0 transitivePeerDependencies: @@ -18928,7 +18928,7 @@ snapshots: lodash.snakecase: 4.1.1 magic-bytes.js: 1.13.0 tslib: 2.8.1 - undici: 7.29.1 + undici: 7.30.1 transitivePeerDependencies: - bufferutil - utf-8-validate @@ -19046,7 +19046,7 @@ snapshots: openapi-fetch: 0.14.1 platform: 1.3.6 tar: 7.5.21 - undici: 7.29.1 + undici: 7.30.1 ecdsa-sig-formatter@1.0.11: dependencies: @@ -19520,7 +19520,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.8 - qs: 6.16.0 + qs: 6.16.1 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 @@ -22243,7 +22243,7 @@ snapshots: punycode@2.3.1: {} - qs@6.16.0: + qs@6.16.1: dependencies: es-define-property: 1.0.1 side-channel: 1.1.1 @@ -23366,7 +23366,7 @@ snapshots: formidable: 3.5.4 methods: 1.1.2 mime: 2.6.0 - qs: 6.16.0 + qs: 6.16.1 transitivePeerDependencies: - supports-color @@ -23713,7 +23713,7 @@ snapshots: undici-types@7.18.2: {} - undici@7.29.1: {} + undici@7.30.1: {} unicorn-magic@0.2.0: {}