From 6fe8e306251cd3d59d0fa7b9cf6b3e0aae362a54 Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Wed, 16 Sep 2026 13:54:44 -0700 Subject: [PATCH] feat(apps): add Railway connection and governed deployment tools (#13415) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Apps gives agents governed access to external resources. > - Operators need to inspect Railway services, read logs, deploy code, and run container commands. > - Railway offers hosted MCP with OAuth, but broad remote actions hide their internal operations. > - This PR adds a branded connection and fixed direct operations through the existing gateway. > - Separate SSH keys enable container commands under the same grants and policies. > - Operators can require approval for an action and inspect the resulting audit record. ## Linked Issues or Issue Description **Subsystem affected** Apps catalog, connection setup, gateway execution, and connection documentation. **Problem or motivation** Agents need Railway access through Paperclip. Operators need to grant and revoke that access, inspect available actions, and govern deployment and container operations without giving agents provider credentials. **Proposed solution** Reuse hosted MCP OAuth, vault storage, catalog discovery, grants, and the gateway. Probe the actual credential before enabling fixed GraphQL operations. Use a dedicated grant-owned SSH key for bounded container commands. **Alternatives considered** A catalog entry alone cannot execute the missing operations. The hosted general agent has opaque internal effects. An unrestricted CLI runtime can bypass action policy and inherit ambient credentials. **Roadmap alignment** This extends the existing MCP Tool Gateway & Apps path and the Connected Apps direction in ROADMAP.md. It does not add a plugin or parallel connection service. Related PRs #311, #939, and #7861 concern hosting Paperclip on Railway. They do not add this outbound Apps connection. The separate shared agent-picker fix is #13414 and is not included here. ## What Changed - Add the generated Railway catalog entry, official marks, provenance, and OAuth setup guidance. - Add fixed service/deployment status, bounded logs, and redeploy/restart/rollback tools. Block source deployment until the provider can atomically bind the approved repository and commit. - Verify API access with an explicit workspace before exposing direct tools. - Add grant-owned SSH key setup and a bounded runner with host verification, target checks, isolated state, and cleanup. - Block the opaque hosted railway-agent and accept-deploy actions. Preserve normal Allowed defaults and Ask-first policies for other actions. - Quarantine new or changed Railway schemas after initial discovery, including reconnect. - Add provider, lifecycle, gateway, SSH, UI, and browser fixtures. Document setup, limitations, and the release checklist. ## Verification - Security follow-up: removed the unsafe source-deployment mutation. Direct calls and old active catalog entries are denied before any upstream request, including normalized aliases. Refresh marks retired entries disabled. All 386 focused Railway, catalog and gateway tests passed, and server TypeScript checking passed. Full [GitHub CI](https://github.com/paperclipai/paperclip/actions/runs/35139421144) passed on d86530ab9, including typecheck, build, all tests, runner checks, and browser tests. Superagent passed and confirmed the P2 fix. Greptile reviewed the same commit at 5/5 with no findings. - CI follow-up: fixed the missing Railway SSH operation in the OpenAPI document, including its request schema, operator-only authentication, and error responses. The failure reproduced locally before the fix; all 403 selected API, Railway, catalog, and artwork tests passed after it. Synced current master and resolved the catalog/artwork conflicts. - After rebase: 440 focused provider, lifecycle, gateway, catalog, and container-panel tests passed. AppDetail and AppsConnect passed another 196 tests. - Full typecheck, build, token gates, and the gallery browser check passed after rebase. - During implementation, full build and the gallery browser check passed. Shared generic-MCP fixtures covered OAuth callback/state/issuer binding and failure paths. - Local live consent and tools/list succeeded. There were 44 active hosted actions and two blocked actions. A workspace-bound API probe and direct project/service/environment reads succeeded. The inspected project had no deployed services. No provider mutation ran. - Full GitHub CI passed on commit 303340f19, including all server/workspace test groups, typecheck, build, runtime verification, release dry run, and browser tests. The original local full-run attempt was incomplete; the complete automated suite is now verified in CI. Manual review: connect Railway, review the actual actions, install for an agent, and run a resource read through the gateway. Choose Ask first before testing a deployment mutation. Configure a dedicated key only when container access is needed. **Release qualification is still open.** Live agent gateway reads/logs, rejected and approved deployment calls, refresh/revoke, public HTTPS consent, and SSH enrollment/commands/cleanup need an authorized disposable service. The passing API diagnostic does not replace those tests. See doc/connections/RAILWAY.md and RAILWAY-REVIEW.md. ## Risks Overall risk is medium. New runtime behavior is gated to Railway connections, but the PR changes shared catalog, credential lifecycle, and gateway code. A regression in those paths can affect other Apps connections. The highest-impact operations are Railway deployments and container commands. - Provider consent can authorize an entire workspace. Catalog labels are not local resource allowlists. Direct tools check target membership, and provider permissions still apply. - Shell commands have broad internal authority. Action policy cannot approve each internal shell step. Timeouts close the local connection but cannot guarantee remote child-process termination. - Log and command output may contain application secrets that pattern redaction cannot recognize. - Source deployment is unavailable until the provider supports atomic repository/commit binding. Existing deployments can still be redeployed, restarted or rolled back. - No database migration is required. Rollback can remove promotion and direct dispatch while preserving connection data and the generic MCP path. - Live Railway qualification must still pass before release acceptance. ## Model Used OpenAI Codex, based on GPT-6, with code execution and browser testing. An independent read-only security agent reviewed the local implementation. The exact serving model ID and context window were not exposed in this session. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/connections/RAILWAY-REVIEW.md | 155 +++++++++ doc/connections/RAILWAY.md | 218 ++++++++++++ doc/plans/2026-09-13-railway-runtime.md | 36 ++ .../shared/src/app-definitions.generated.ts | 139 ++++---- packages/shared/src/app-definitions.test.ts | 15 +- packages/shared/src/app-definitions.ts | 1 + .../shared/src/app-definitions/railway.json | 61 ++++ packages/shared/src/index.ts | 1 + packages/shared/src/railway-connection.ts | 14 + scripts/ingest-app-definitions.mjs | 48 ++- .../__tests__/fixtures/railway/provider.ts | 35 ++ .../src/__tests__/railway-connection.test.ts | 198 +++++++++++ server/src/__tests__/railway-ssh.test.ts | 69 ++++ server/src/__tests__/railway.test.ts | 153 +++++++++ .../src/__tests__/tool-access-service.test.ts | 2 +- server/src/routes/openapi.ts | 19 ++ server/src/routes/tool-access.ts | 15 + server/src/services/railway-ssh.ts | 91 +++++ server/src/services/railway.ts | 311 ++++++++++++++++++ server/src/services/tool-access.ts | 110 ++++++- server/src/services/tool-gateway.ts | 53 ++- tests/e2e/railway-catalog.spec.ts | 19 ++ ui/public/brands/apps/manifest.json | 7 + ui/public/brands/apps/railway-dark.svg | 1 + ui/public/brands/apps/railway.svg | 1 + ui/src/api/tools.ts | 4 + .../connections/ConnectionSetupFlow.tsx | 10 + ui/src/lib/app-gallery-copy.ts | 4 + ui/src/pages/apps/AppDetail.tsx | 2 + .../app-detail/RailwayAccessPanel.test.tsx | 37 +++ .../apps/app-detail/RailwayAccessPanel.tsx | 69 ++++ 31 files changed, 1807 insertions(+), 91 deletions(-) create mode 100644 doc/connections/RAILWAY-REVIEW.md create mode 100644 doc/connections/RAILWAY.md create mode 100644 doc/plans/2026-09-13-railway-runtime.md create mode 100644 packages/shared/src/app-definitions/railway.json create mode 100644 packages/shared/src/railway-connection.ts create mode 100644 server/src/__tests__/fixtures/railway/provider.ts create mode 100644 server/src/__tests__/railway-connection.test.ts create mode 100644 server/src/__tests__/railway-ssh.test.ts create mode 100644 server/src/__tests__/railway.test.ts create mode 100644 server/src/services/railway-ssh.ts create mode 100644 server/src/services/railway.ts create mode 100644 tests/e2e/railway-catalog.spec.ts create mode 100644 ui/public/brands/apps/railway-dark.svg create mode 100644 ui/public/brands/apps/railway.svg create mode 100644 ui/src/pages/apps/app-detail/RailwayAccessPanel.test.tsx create mode 100644 ui/src/pages/apps/app-detail/RailwayAccessPanel.tsx diff --git a/doc/connections/RAILWAY-REVIEW.md b/doc/connections/RAILWAY-REVIEW.md new file mode 100644 index 0000000000..1d74fc478d --- /dev/null +++ b/doc/connections/RAILWAY-REVIEW.md @@ -0,0 +1,155 @@ +# Railway implementation verification + +Updated: 2026-09-16. Implementation and local verification were performed on +2026-09-13. The change is published for review on a dedicated branch. +Live qualification remains open. Full GitHub CI passed on commit `303340f19` +before the source-deployment security follow-up below. + +## Thinking Path + +> - Paperclip controls the access that agents receive to external resources. +> - Apps already supplies remote MCP, OAuth, vault storage, grants and policies. +> - Operators need Railway service inspection, logs, deployments and container commands. +> - The hosted Railway server alone does not supply narrow governed tools for all these operations. +> - This change adds a branded connector and fixed direct operations inside the existing gateway. +> - Dedicated grant keys enable bounded commands in deployed containers. +> - Operators keep the existing action defaults and can require approval before execution. + +## Linked Issues or Issue Description + +**Subsystem affected** + +Apps catalog, connection setup, gateway execution, shared contracts and connection documentation. + +**Problem or motivation** + +An operator needs to authorize a Railway account once, grant access to selected +agents, and let them inspect and operate Railway resources through Paperclip. + +**Proposed solution** + +Use hosted OAuth for connection setup. Expose direct status/log/deployment tools +only after an actual API credential probe. Add separate container-key setup and +a fixed OpenSSH runner behind the same grant and policy checks. + +**Alternatives considered** + +A manifest alone cannot execute missing operational tools. The hosted general +agent has opaque internal effects. An unrestricted CLI runtime would bypass +per-action review and could inherit ambient credentials. + +## What Changed + +- Added Railway's generated definition, curated entry, official marks and provenance. +- Added fixed GraphQL operations for service/deployment status, bounded logs, + and redeploy/restart/rollback. Source deployment is blocked pending atomic + provider repository/revision binding. +- Added grant-owned SSH key setup and container commands with host verification, + target checks, deadlines, output caps and cleanup. +- Blocked the hosted general agent and staged-change acceptance. Preserved normal + Allowed defaults and Ask-first policies. Kept changed-schema quarantine across reconnect. +- Added setup guidance, provider fixtures, lifecycle/SSH/gateway tests and browser verification. + +## Verification + +Security follow-up on 2026-09-16: removed the source-deployment schema and mutation. +The provider block applies to old active catalog entries and normalized aliases +before refresh; refresh marks them disabled. Regression tests cover direct-client +and gateway denial before any upstream request. A repository preflight is no +longer used as authorization for source deployment. +All 386 focused Railway, catalog and gateway tests passed for this follow-up. + +After rebase onto master on 2026-09-14, 440 focused provider, connection, gateway, +catalog and container-panel tests passed. The AppDetail and AppsConnect suites +passed another 196 tests. The new Apps entries on master are preserved. + +Passing checks observed during the original implementation: + +- 284 tests across the final Railway API, SSH, lifecycle, tool-access service and shared-definition suites. +- 59 generic-MCP tests. These cover callback/state/issuer binding and OAuth error paths. +- 62 gateway tests and 3 container-panel tests. +- AppDetail and AppsConnect UI suites passed as part of a 224-test targeted run. +- The browser test `tests/e2e/railway-catalog.spec.ts` passed against a throwaway + instance. It checks the real gallery, logo, OAuth setup entry and visible limitations. + It does not complete Railway account consent. +- `pnpm -r typecheck`, `pnpm build`, and `pnpm check:token-gates` passed. +- Local port 3100 serves the dev checkout, health reports `ok`, bootstrap is ready, + the Railway brand asset returns 200, and the public OAuth metadata advertises + the loopback callback. An unauthenticated browser reaches the sign-in page. + +The full `pnpm test:run` attempt was stopped after failures outside the focused +Railway coverage and repeated database-startup timeouts. One related gallery +count expectation was fixed and the complete tool-access suite subsequently +passed. Other failures included five chat integration timeouts, three company +skills cases, native session-resume fixtures, and the CLI guidance scan finding +an existing local `.claude/settings.local.json` command. Native and CLI failures +were reproduced separately; no unrelated source or private settings were changed. +The full suite is **not green**, and later runner shards did not complete. + +The pinned Rust 1.97.1 toolchain was used for full typecheck/build. Browser +output and detailed logs are retained locally as ignored QA output. No provider +credentials, private configuration, or unsanitized live captures are committed. + +Follow-up after the operator connected locally: loopback consent and actual +tools/list succeeded. The initial direct API probe incorrectly requested projects +without a workspace ID. Railway returned HTTP 200 with a `Not Authorized` GraphQL +error; the same token accepted a query bound to the selected workspace. The fix +discovers a workspace through the hosted read, requires a workspace ID for direct +project listing, and recognizes GraphQL authorization errors without exposing +provider details or requesting broader OAuth scopes. + +The repaired local connection reports direct API access available. Its 44 hosted +actions remain active; the 12 newly discovered direct actions remain quarantined +for review. Direct project, service and environment reads succeeded; the inspected +project has no services. No deployment or container operation was attempted. +The follow-up Railway suites passed 30 tests, the shared tool-access/gateway suites +passed 293 tests, and server TypeScript checking passed. +The live evidence contains only status and resource counts; it remains local. + +Agent gateway proof, disposable service/deployment targets, HTTPS/customer-client +registration, logs, deployment, SSH enrollment/host trust, refresh and provider +cleanup still require operator-assisted proof. See +[RAILWAY.md](RAILWAY.md) for the exact release checklist and setup. + +## Risks + +- Live provider qualification remains outstanding. Advertised DCR is not proof + that a particular account/client/callback combination works. +- OAuth authorization can cover more resources than one service. Metadata filters + are not local authorization allowlists. +- Container commands have broad internal authority; timeout cannot guarantee remote + child termination. Provider-side key removal is a separate operator action. +- Source deployment is unavailable until the provider can atomically bind the + approved repository and commit. Existing deployments can still be redeployed, + restarted or rolled back. +- No schema migration is needed. Rollback can remove promotion and direct dispatch + while retaining connection data and the generic MCP path. +- The full test suite must be resolved before claiming release readiness. + +## Model Used + +OpenAI Codex, based on GPT-6, with tool execution and a separate read-only security +review agent. The exact serving deployment ID and context window were not exposed +in this session. The independent reviewer found no remaining concrete blocker +for a local preview; this is not a claim of completed live qualification. + +## Checklist + +- [x] I have included a thinking path that traces from project context to this change +- [x] I have specified the model used, with available version and capability details +- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work +- [x] I have searched GitHub for duplicate or related PRs and linked them above +- [x] I have described the issue using the feature-request fields +- [x] I have not referenced internal Paperclip issues +- [x] My branch name describes the change +- [ ] I have run all tests locally and they pass +- [x] I have added or updated relevant tests +- [x] I have updated the connection documentation +- [x] I have documented the risks +- [ ] All Paperclip CI gates are green +- [ ] Greptile is 5/5 with no open follow-ups +- [x] I will address all reviewer comments before requesting merge + +Unchecked release checks remain pending. Related Railway PRs #311, #939 and +#7861 concern hosting Paperclip on Railway, not governing Railway through Apps. +This implementation uses the existing governed Apps path described in ROADMAP.md. diff --git a/doc/connections/RAILWAY.md b/doc/connections/RAILWAY.md new file mode 100644 index 0000000000..a71c6332c9 --- /dev/null +++ b/doc/connections/RAILWAY.md @@ -0,0 +1,218 @@ +# Railway + +Updated: 2026-09-16. Status: implementation review; live provider qualification outstanding. + +Railway appears in Apps and uses Paperclip's shared remote-MCP OAuth connection, +vault, catalog, grants, policies, gateway, and audit trail. It is a resource +connection, not Paperclip sign-in. No plugin or database migration is required. + +## Connect and use + +1. Open Apps → Railway → Connect. +2. Sign in to Railway and choose the workspaces offered on its consent page. + If dynamic registration is rejected, supply a registered Railway OAuth client + in the existing customer-client setup. Local loopback consent has succeeded; + HTTPS and customer-client registration still require qualification. Do not supply a Railway project token to + the hosted MCP endpoint. +3. Review the discovered actions. Install the connection for selected agents. + Active actions start Allowed under the current product default. Choose Ask + first for deployment actions or commands that need operator review. +4. Refresh actions to check API access. Paperclip uses the hosted `list-workspaces` + read to discover a workspace, then makes a bounded project query with that + explicit workspace ID and the actual OAuth credential before adding direct tools. + Account-wide project queries are not valid probes for workspace-scoped consent. + Railway documents OAuth access to GraphQL, but a hosted-MCP token is not + assumed to have a suitable audience. Rejection leaves hosted tools available + and direct operations unavailable. No other credential is used as a fallback. +5. Have an agent list projects, services and environments through the gateway, + then inspect an explicit service/deployment target. Never paste OAuth tokens + or private SSH keys into agent prompts or runtime configuration. + +Use a public HTTPS Paperclip origin, or a loopback HTTP origin such as +`http://localhost:3100`. The shared callback is `/api/tools/oauth/callback`. +The configured canonical auth origin controls the callback. A plain HTTP tailnet +hostname is not loopback; use HTTPS or change the local canonical origin before +connecting. Loopback consent succeeded locally; HTTPS still needs live proof. + +## Capabilities and policy + +| Action | Scope and limits | Classification | +| --- | --- | --- | +| Hosted project/service listing and feature-flag reads | Actual discovered schemas; provider credential scope | Read for reviewed names | +| Other hosted actions | Actual discovered schemas; provider credential scope | Write or destructive | +| Hosted `railway-agent` and `accept-deploy` | Disabled at discovery and denied at dispatch, including normalized aliases | Destructive; unavailable | +| `paperclip-railway-list-projects`, `list-services`, `list-environments` | Explicit workspace ID for projects, project ID for services/environments; 1–100 results per page, cursor ≤512 characters | Read | +| `service-status`, `list-deployments`, `deployment-status` | Explicit project/environment/service IDs; deployment ID where applicable | Read | +| `read-logs` | Build/runtime; ≤500 lines; time bounds/filter; ≤64 KiB of log entries | Read; sensitive application data | +| `redeploy`, `restart`, `rollback` | Exact deployment membership checked before mutation | Destructive | +| `deploy-revision` | Unavailable: the provider mutation cannot atomically bind the approved repository and commit; old catalog entries and calls are blocked | Destructive; unavailable | +| `run-command` | Exact running deployment/container instance, ≤60 seconds, ≤64 KiB combined output | Destructive; broad privileged access | + +Direct tool names have the `paperclip-railway-` prefix. Railway may not shadow +this reserved namespace. These are fixed first-party gateway operations, not a +REST catalog entry or arbitrary GraphQL passthrough. GraphQL responses have a +1 MiB hard limit, redirects are refused, provider error bodies are not surfaced, +and deployment mutations are never automatically retried. After a timeout or +ambiguous error, inspect status before retrying. Redeploy returns the provider's +resulting deployment ID; restart/rollback use the provider's +boolean result and exact target ID rather than inventing a new deployment ID. + +Railway enforces the workspace/account permissions granted by consent. The +project/environment/service labels in the catalog are **not local allowlists**. +Dedicated operations verify that all supplied IDs belong to the same target. +They do not narrow a workspace-wide credential to one service. Use provider +access controls and explicit Paperclip action policies to constrain authorization. +Hosted tool arguments and filters do not establish authorization boundaries. + +The broad hosted Railway agent can perform multiple internal operations; a +request to read logs does not make it read-only. Staged changes accepted by +`accept-deploy` cannot be bound to the exact changes reviewed here. Both are +blocked by a narrow provider policy. Other providers and global defaults are +unchanged. New or changed Railway schemas are quarantined after initial discovery, +including reconnect flows that normally enable newly discovered actions. + +Source deployment (`paperclip-railway-deploy-revision`) is also blocked. The +`serviceInstanceDeployV2` mutation accepts a commit SHA but cannot atomically +verify the approved repository. A separate repository check can race a provider +configuration change. Paperclip therefore offers 11 direct actions and no source +deployment action. Calls saved by an older server are denied before upstream +execution, including normalized aliases; refreshing actions marks their catalog +entries disabled. Source deployment requires an atomic provider binding before +it can be re-enabled. Redeploy uses an existing deployment's previous image. + +## Container access + +The connection's Permissions page includes Container access: + +1. Select an authorization and generate a dedicated Ed25519 key. +2. Register its **public** key in the Railway account associated with that + authorization. Workspace key management can require workspace-admin rights. +3. Supply an independently verified `ssh.railway.com` known_hosts line. A key + collected over an untrusted connection is not verification. No trust-on-first-use + or host-key-check bypass is provided. +4. Enable access, then grant the Run command action to trusted agents. + +The private key stays in the instance vault, attached to one exact grant. +Personal keys retain their owner binding. Each command resolves that grant's key +after normal company/run/grant/policy checks. It uses a fresh temporary directory, +0600 key files, a fixed system OpenSSH executable, a minimal environment, and no +ambient SSH agent, user configuration, host directory, forwarding, or shared +control socket. Files are removed on success, error, timeout and cancellation. +The process must confirm remote command completion; SSH exit code zero alone is +insufficient. Noninteractive commands receive no stdin. + +SSH connects to a deployed service **container**, not the underlying Railway host. +The SSH username is a deployment **instance** ID, checked against that deployment. +Commands can read secrets, change data, and make network calls. They can accomplish +mutations internally even if a dedicated deployment action is Ask first. Per-tool +policy cannot approve each shell sub-operation. Log and command output is sensitive; +known credentials and recognized secret patterns are redacted, but arbitrary +application secrets cannot all be recognized. + +The default gateway budget includes the requested command timeout plus ten seconds +for target checks, capped at sixty seconds. An explicit caller deadline takes +precedence and can stop the command earlier. Timeout/cancellation terminates the +local SSH connection. Remote child process +termination is not guaranteed. Persistent interactive sessions, file upload, +unrestricted Railway CLI use and arbitrary local workspace deployment are out +of scope. Source deployment is unavailable as described above. + +Removing the container key deletes local private material and its grant binding +in one transaction, including for revoked grants or disconnected connections. +Reconnect preserves the key binding. Also remove the public key in Railway to +revoke provider-side enrollment. Revoking the grant blocks new upstream executions. The shared gateway can replay +already completed results from invocation history; a replay does not contact +Railway. Revocation does not recall commands already running remotely. + +## Protocol qualification record + +Public probes and official documentation checked 2026-09-13: + +| Property | Evidence / remaining qualification | +| --- | --- | +| Endpoint | Exact `https://mcp.railway.com` or root slash; other paths, query strings and lookalike hosts are not bridged | +| Transport | Provider documents hosted MCP; unauthenticated Streamable HTTP initialize POST with JSON/SSE Accept returns HTTP 401 | +| Challenge | `Bearer realm="mcp", resource_metadata="https://mcp.railway.com/.well-known/oauth-protected-resource"` | +| Protected resource | Resource `https://mcp.railway.com`, issuer `https://backboard.railway.com`, header bearer | +| Authorization | `/oauth/auth?resource=https%3A%2F%2Fbackboard.railway.com` on issuer; generic OAuth flow binds the requested MCP resource | +| Token / registration | `/oauth/token`, `/oauth/register` advertised on issuer | +| Revocation endpoint | Not advertised in observed metadata; local gateway revocation is enforced independently | +| Registration | DCR advertised, customer client supported by docs; no CIMD advertisement. Loopback automatic consent succeeded; public HTTPS and customer-client consent unproven | +| PKCE | S256 advertised and exercised by deterministic fixture | +| Scopes | Advertised: openid, profile, email, offline_access, workspace:member. Request openid/offline_access/workspace:member with prompt=consent | +| Refresh | Refresh grant advertised; docs require offline_access and explicit consent. Fixture covers failure; live refresh pending | +| Tool schemas | Live tools/list captured locally: 46 hosted actions, including narrow `get-status` and `get-logs`; 44 active after the two blocked opaque actions | +| Plan / approval | Account and appropriate workspace permissions required; plan limits, app approval and SSH enrollment permissions need verification on the test account | + +Sources: [hosted MCP](https://docs.railway.com/ai/mcp-server), +[OAuth](https://docs.railway.com/integrations/oauth), +[OAuth tokens](https://docs.railway.com/integrations/oauth/login-and-tokens), +[consent/scopes](https://docs.railway.com/integrations/oauth/scopes-and-user-consent), +[GraphQL](https://docs.railway.com/integrations/api), +[SSH](https://docs.railway.com/cli/ssh), and +[official CLI GraphQL schema and commands](https://github.com/railwayapp/cli/tree/ac4f16e5f3db047b941bf0b9ac3be388e7c73697). +Brand marks were sanitized from the inline SVG at +[Railway's official homepage](https://railway.com) on 2026-09-13. The original mark +contains the Railway train silhouette; the dark variant changes only its fill +for contrast. The public +manifest contains runtime artwork paths; this record retains source provenance. + +## Recovery + +- Cancelled consent: use Connect again; cancelled callback state cannot be reused. +- Expired/revoked OAuth or refresh failure: reconnect the affected authorization. + Raw provider error descriptions and tokens are not shown. +- Insufficient API permissions: verify workspace access, reconnect, then refresh + actions. Direct tools stay unavailable until the API probe succeeds. +- An older preview reported a generic deployment error immediately after consent: + its API probe omitted the workspace ID. Refresh actions with the current server; + reconnect is unnecessary when the selected workspace is already authorized. + New direct actions remain quarantined until reviewed. +- Missing service or mismatched IDs: list current resources and use one consistent + project/environment/service/deployment target. No mutation precedes validation. +- Railway unavailable/rate-limited: wait, inspect status, then retry deliberately. +- SSH not configured or host-key mismatch: verify enrollment and host identity + through the provider; update the connection setup. Do not disable host checks. + +## Verification and release gate + +`railway.test.ts` covers fixed API dispatch, bounds, errors, target checks and +credential redaction, including source-deployment denial with no upstream request. +`railway-ssh.test.ts` covers isolated SSH state, completion, +output limits, timeout, cancellation and cleanup. `railway-connection.test.ts` +uses observed metadata with synthetic provider responses to exercise the shared +OAuth/catalog/grant/gateway lifecycle and denies retired source-deployment catalog +entries before refresh. The fixture explicitly does not claim an +authenticated provider tool capture. Shared generic MCP suites cover callback +state/issuer binding, consent cancellation and credential handling. + +Independent security review accepted the architecture for local preview on +2026-09-13. The operator subsequently completed local consent. A follow-up live +check reproduced HTTP 200 with `Not Authorized` for account-wide projects, while +the same credential succeeded with an explicit workspace. After the fix, catalog +refresh reported API access available, 44 active hosted actions, two disabled +actions, and 12 new direct actions quarantined for review. Direct project, +service and environment reads succeeded; the inspected project had no services, +so deployment status and logs could not be exercised. No provider mutation ran. +That preview included source deployment; the 2026-09-16 security fix removes it +and blocks existing entries, leaving 11 supported direct actions. + +Full release acceptance remains outstanding. The operator must identify a +disposable service and deployment for the remaining checks. +Required live proof: HTTPS and supported loopback consent; actual catalog capture; +agent gateway read/logs; rejected Ask-first write with no upstream mutation; +approved scoped redeploy and resulting deployment; refresh/reconnect; revoked +grant denial; enrolled SSH key, harmless command, wrong-target denial, +timeout/cancellation, key removal and provider cleanup. The successful direct +read diagnostic does not replace the required agent-through-gateway proof. + +Regenerate with `pnpm connections:ingest-app-definitions --definitions-only` when +the external research corpus is unavailable. This preserves its ingestion report. +Run targeted suites, shared definitions, `pnpm check:token-gates`, then the full +repository checks before release. See [the verification record](RAILWAY-REVIEW.md) for actual results. + +Rollback: remove Railway's curated slug/promotion and setup panel to stop new +setup; disable direct runtime dispatch if needed. Preserve connection rows, +grants, vault records and the generic remote-MCP path. Existing connections must +remain recoverable and disconnectable. Remove registered SSH keys deliberately; +do not delete provider projects or application data as rollback. diff --git a/doc/plans/2026-09-13-railway-runtime.md b/doc/plans/2026-09-13-railway-runtime.md new file mode 100644 index 0000000000..1a13d026a3 --- /dev/null +++ b/doc/plans/2026-09-13-railway-runtime.md @@ -0,0 +1,36 @@ +# Railway direct operations and container runtime review + +Date: 2026-09-13. Scope: local preview authorized by the operator, without push. +Updated: 2026-09-16 for source-deployment security review. + +The hosted connector alone cannot provide governed direct logs and shell. The +chosen runtime is a first-party fixed-operation bridge inside the existing MCP +gateway. It reuses OAuth resolution, agent/company/run/grant isolation, policy, +argument snapshots and auditing. It has no separate HTTP service, plugin, +database schema or arbitrary GraphQL/CLI interface. + +The bridge verifies whether Railway accepts the actual grant credential for the +GraphQL API. Failed qualification disables direct capabilities. It never assumes +that OAuth tokens for one resource are valid for another or silently substitutes +ambient credentials. All mutations use fixed queries and check target membership. +Source deployment is blocked: a separate repository check can race the deployment +mutation. It requires an atomic provider repository/revision binding before it +can be enabled. Redeploy, restart and rollback target existing deployments. + +Container commands run a fixed system OpenSSH client with isolated temporary +state, a dedicated vault-backed grant key, verified host trust and explicit +container-instance membership. Enrollment is manual: hosted OAuth does not +advertise SSH-key management scope, and provider-side key deletion can require +2FA. Paperclip does not borrow a developer's CLI login or SSH directory. + +Independent read-only security review accepted this architecture for local +preview, subject to tests and live qualification. It required permanent blocks +for opaque hosted agent/staged-deployment actions, reconnect quarantine, key +preservation and teardown, no stale API execution, remote command confirmation, +and explicit disclosure that shell can perform arbitrary internal mutations. +These are narrow Railway rules; active actions otherwise retain Allowed defaults. + +Live runtime acceptance remains separate from fixture success. An authorized +disposable provider target, account consent, registered public SSH key and trusted +host key are still required. Detailed setup, risk matrix and release gates are in +[RAILWAY.md](../connections/RAILWAY.md). diff --git a/packages/shared/src/app-definitions.generated.ts b/packages/shared/src/app-definitions.generated.ts index fc275ec0a5..60690545b5 100644 --- a/packages/shared/src/app-definitions.generated.ts +++ b/packages/shared/src/app-definitions.generated.ts @@ -1,72 +1,73 @@ import a0 from "./app-definitions/agentmail.json" with { type: "json" }; import a1 from "./app-definitions/zapier.json" with { type: "json" }; -import a2 from "./app-definitions/github.json" with { type: "json" }; -import a3 from "./app-definitions/slack.json" with { type: "json" }; -import a4 from "./app-definitions/microsoft-teams.json" with { type: "json" }; -import a5 from "./app-definitions/imessage-photon.json" with { type: "json" }; -import a6 from "./app-definitions/telegram.json" with { type: "json" }; -import a7 from "./app-definitions/discord.json" with { type: "json" }; -import a8 from "./app-definitions/notion.json" with { type: "json" }; -import a9 from "./app-definitions/posthog.json" with { type: "json" }; -import a10 from "./app-definitions/linear.json" with { type: "json" }; -import a11 from "./app-definitions/context7.json" with { type: "json" }; -import a12 from "./app-definitions/shopify.json" with { type: "json" }; -import a13 from "./app-definitions/composio.json" with { type: "json" }; -import a14 from "./app-definitions/oauth-generic.json" with { type: "json" }; -import a15 from "./app-definitions/api-key-generic.json" with { type: "json" }; -import a16 from "./app-definitions/sentry.json" with { type: "json" }; -import a17 from "./app-definitions/vercel.json" with { type: "json" }; -import a18 from "./app-definitions/anthropic.json" with { type: "json" }; -import a19 from "./app-definitions/jira.json" with { type: "json" }; -import a20 from "./app-definitions/airtable.json" with { type: "json" }; -import a21 from "./app-definitions/beehiiv.json" with { type: "json" }; -import a22 from "./app-definitions/bitly.json" with { type: "json" }; -import a23 from "./app-definitions/candid.json" with { type: "json" }; -import a24 from "./app-definitions/cloudflare.json" with { type: "json" }; -import a25 from "./app-definitions/cloudinary.json" with { type: "json" }; -import a26 from "./app-definitions/coda.json" with { type: "json" }; -import a27 from "./app-definitions/hugging-face.json" with { type: "json" }; -import a28 from "./app-definitions/kernel.json" with { type: "json" }; -import a29 from "./app-definitions/local-falcon.json" with { type: "json" }; -import a30 from "./app-definitions/make.json" with { type: "json" }; -import a31 from "./app-definitions/manufact.json" with { type: "json" }; -import a32 from "./app-definitions/miro.json" with { type: "json" }; -import a33 from "./app-definitions/netlify.json" with { type: "json" }; -import a34 from "./app-definitions/oreilly.json" with { type: "json" }; -import a35 from "./app-definitions/planetscale.json" with { type: "json" }; -import a36 from "./app-definitions/resend.json" with { type: "json" }; -import a37 from "./app-definitions/ticktick.json" with { type: "json" }; -import a38 from "./app-definitions/todoist.json" with { type: "json" }; -import a39 from "./app-definitions/webflow.json" with { type: "json" }; -import a40 from "./app-definitions/wix.json" with { type: "json" }; -import a41 from "./app-definitions/brex.json" with { type: "json" }; -import a42 from "./app-definitions/clickhouse.json" with { type: "json" }; -import a43 from "./app-definitions/egnyte.json" with { type: "json" }; -import a44 from "./app-definitions/embat.json" with { type: "json" }; -import a45 from "./app-definitions/mixpanel.json" with { type: "json" }; -import a46 from "./app-definitions/postman.json" with { type: "json" }; -import a47 from "./app-definitions/razorpay.json" with { type: "json" }; -import a48 from "./app-definitions/sanity.json" with { type: "json" }; -import a49 from "./app-definitions/stripe.json" with { type: "json" }; -import a50 from "./app-definitions/supabase.json" with { type: "json" }; -import a51 from "./app-definitions/ticket-tailor.json" with { type: "json" }; -import a52 from "./app-definitions/asana.json" with { type: "json" }; -import a53 from "./app-definitions/box.json" with { type: "json" }; -import a54 from "./app-definitions/mem0.json" with { type: "json" }; -import a55 from "./app-definitions/pagerduty.json" with { type: "json" }; -import a56 from "./app-definitions/similarweb.json" with { type: "json" }; -import a57 from "./app-definitions/xero.json" with { type: "json" }; -import a58 from "./app-definitions/gmail.json" with { type: "json" }; -import a59 from "./app-definitions/google-drive.json" with { type: "json" }; -import a60 from "./app-definitions/google-docs.json" with { type: "json" }; -import a61 from "./app-definitions/google-sheets.json" with { type: "json" }; -import a62 from "./app-definitions/google-slides.json" with { type: "json" }; -import a63 from "./app-definitions/google-calendar.json" with { type: "json" }; -import a64 from "./app-definitions/google-chat.json" with { type: "json" }; -import a65 from "./app-definitions/google-people.json" with { type: "json" }; -import a66 from "./app-definitions/google-workspace-search.json" with { type: "json" }; -import a67 from "./app-definitions/openai.json" with { type: "json" }; -import a68 from "./app-definitions/openrouter.json" with { type: "json" }; -import a69 from "./app-definitions/xai.json" with { type: "json" }; +import a2 from "./app-definitions/railway.json" with { type: "json" }; +import a3 from "./app-definitions/github.json" with { type: "json" }; +import a4 from "./app-definitions/slack.json" with { type: "json" }; +import a5 from "./app-definitions/microsoft-teams.json" with { type: "json" }; +import a6 from "./app-definitions/imessage-photon.json" with { type: "json" }; +import a7 from "./app-definitions/telegram.json" with { type: "json" }; +import a8 from "./app-definitions/discord.json" with { type: "json" }; +import a9 from "./app-definitions/notion.json" with { type: "json" }; +import a10 from "./app-definitions/posthog.json" with { type: "json" }; +import a11 from "./app-definitions/linear.json" with { type: "json" }; +import a12 from "./app-definitions/context7.json" with { type: "json" }; +import a13 from "./app-definitions/shopify.json" with { type: "json" }; +import a14 from "./app-definitions/composio.json" with { type: "json" }; +import a15 from "./app-definitions/oauth-generic.json" with { type: "json" }; +import a16 from "./app-definitions/api-key-generic.json" with { type: "json" }; +import a17 from "./app-definitions/sentry.json" with { type: "json" }; +import a18 from "./app-definitions/vercel.json" with { type: "json" }; +import a19 from "./app-definitions/anthropic.json" with { type: "json" }; +import a20 from "./app-definitions/jira.json" with { type: "json" }; +import a21 from "./app-definitions/airtable.json" with { type: "json" }; +import a22 from "./app-definitions/beehiiv.json" with { type: "json" }; +import a23 from "./app-definitions/bitly.json" with { type: "json" }; +import a24 from "./app-definitions/candid.json" with { type: "json" }; +import a25 from "./app-definitions/cloudflare.json" with { type: "json" }; +import a26 from "./app-definitions/cloudinary.json" with { type: "json" }; +import a27 from "./app-definitions/coda.json" with { type: "json" }; +import a28 from "./app-definitions/hugging-face.json" with { type: "json" }; +import a29 from "./app-definitions/kernel.json" with { type: "json" }; +import a30 from "./app-definitions/local-falcon.json" with { type: "json" }; +import a31 from "./app-definitions/make.json" with { type: "json" }; +import a32 from "./app-definitions/manufact.json" with { type: "json" }; +import a33 from "./app-definitions/miro.json" with { type: "json" }; +import a34 from "./app-definitions/netlify.json" with { type: "json" }; +import a35 from "./app-definitions/oreilly.json" with { type: "json" }; +import a36 from "./app-definitions/planetscale.json" with { type: "json" }; +import a37 from "./app-definitions/resend.json" with { type: "json" }; +import a38 from "./app-definitions/ticktick.json" with { type: "json" }; +import a39 from "./app-definitions/todoist.json" with { type: "json" }; +import a40 from "./app-definitions/webflow.json" with { type: "json" }; +import a41 from "./app-definitions/wix.json" with { type: "json" }; +import a42 from "./app-definitions/brex.json" with { type: "json" }; +import a43 from "./app-definitions/clickhouse.json" with { type: "json" }; +import a44 from "./app-definitions/egnyte.json" with { type: "json" }; +import a45 from "./app-definitions/embat.json" with { type: "json" }; +import a46 from "./app-definitions/mixpanel.json" with { type: "json" }; +import a47 from "./app-definitions/postman.json" with { type: "json" }; +import a48 from "./app-definitions/razorpay.json" with { type: "json" }; +import a49 from "./app-definitions/sanity.json" with { type: "json" }; +import a50 from "./app-definitions/stripe.json" with { type: "json" }; +import a51 from "./app-definitions/supabase.json" with { type: "json" }; +import a52 from "./app-definitions/ticket-tailor.json" with { type: "json" }; +import a53 from "./app-definitions/asana.json" with { type: "json" }; +import a54 from "./app-definitions/box.json" with { type: "json" }; +import a55 from "./app-definitions/mem0.json" with { type: "json" }; +import a56 from "./app-definitions/pagerduty.json" with { type: "json" }; +import a57 from "./app-definitions/similarweb.json" with { type: "json" }; +import a58 from "./app-definitions/xero.json" with { type: "json" }; +import a59 from "./app-definitions/gmail.json" with { type: "json" }; +import a60 from "./app-definitions/google-drive.json" with { type: "json" }; +import a61 from "./app-definitions/google-docs.json" with { type: "json" }; +import a62 from "./app-definitions/google-sheets.json" with { type: "json" }; +import a63 from "./app-definitions/google-slides.json" with { type: "json" }; +import a64 from "./app-definitions/google-calendar.json" with { type: "json" }; +import a65 from "./app-definitions/google-chat.json" with { type: "json" }; +import a66 from "./app-definitions/google-people.json" with { type: "json" }; +import a67 from "./app-definitions/google-workspace-search.json" with { type: "json" }; +import a68 from "./app-definitions/openai.json" with { type: "json" }; +import a69 from "./app-definitions/openrouter.json" with { type: "json" }; +import a70 from "./app-definitions/xai.json" with { type: "json" }; import type { AppDefinition } from "./types/app-definition.js"; -export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69] as AppDefinition[]; +export const APP_DEFINITIONS=[a0,a1,a2,a3,a4,a5,a6,a7,a8,a9,a10,a11,a12,a13,a14,a15,a16,a17,a18,a19,a20,a21,a22,a23,a24,a25,a26,a27,a28,a29,a30,a31,a32,a33,a34,a35,a36,a37,a38,a39,a40,a41,a42,a43,a44,a45,a46,a47,a48,a49,a50,a51,a52,a53,a54,a55,a56,a57,a58,a59,a60,a61,a62,a63,a64,a65,a66,a67,a68,a69,a70] as AppDefinition[]; diff --git a/packages/shared/src/app-definitions.test.ts b/packages/shared/src/app-definitions.test.ts index 2f70bd73e5..7fd5e45630 100644 --- a/packages/shared/src/app-definitions.test.ts +++ b/packages/shared/src/app-definitions.test.ts @@ -9,6 +9,7 @@ import { CONNECTABLE_APP_DEFINITIONS, appSupportsCatalogSetup, getAvailableConnectionMethod, + getAppDefinitionForUrl, getRecommendedConnectionMethod, recommendedDefaultsForApp, resolveConnectionMethodServerUrl, @@ -686,7 +687,7 @@ describe("AppDefinition catalog", () => { "ticktick", "xero", ]); - expect(APP_STORE_DEFINITIONS).toHaveLength(46); + expect(APP_STORE_DEFINITIONS).toHaveLength(47); const connectableSlugs = new Set( CONNECTABLE_APP_DEFINITIONS.map((entry) => entry.slug), ); @@ -974,3 +975,15 @@ describe("AppDefinition catalog", () => { } }); }); + + +describe("Railway provider", () => { + it("matches only the hosted endpoint and exposes one vault-backed OAuth method", () => { + const app = APP_STORE_DEFINITIONS.find((entry) => entry.slug === "railway")!; + expect(getAppDefinitionForUrl("https://mcp.railway.com")?.slug).toBe("railway"); + for (const url of ["https://mcp.railway.com/path", "https://mcp.railway.com.evil.test", "http://mcp.railway.com"]) expect(getAppDefinitionForUrl(url)?.slug).not.toBe("railway"); + expect(app.methods).toHaveLength(1); + expect(app.methods[0]).toMatchObject({ key: "mcp-oauth", auth: "oauth", transport: "mcp_remote", ownershipModes: ["dcr", "customer"], riskTier: "S4", defaults: { serverUrl: "https://mcp.railway.com", scopesHint: ["openid", "offline_access", "workspace:member"], oauthAuthorizationParams: { prompt: "consent" } } }); + expect(JSON.stringify(app.methods)).toContain("Live Railway qualification is pending"); + }); +}); diff --git a/packages/shared/src/app-definitions.ts b/packages/shared/src/app-definitions.ts index d0ec6c2c02..0ff98f037e 100644 --- a/packages/shared/src/app-definitions.ts +++ b/packages/shared/src/app-definitions.ts @@ -10,6 +10,7 @@ export const CONNECTABLE_APP_SLUGS = new Set([ "zapier", "slack", "notion", + "railway", "posthog", "linear", "google-sheets", diff --git a/packages/shared/src/app-definitions/railway.json b/packages/shared/src/app-definitions/railway.json new file mode 100644 index 0000000000..96193b680a --- /dev/null +++ b/packages/shared/src/app-definitions/railway.json @@ -0,0 +1,61 @@ +{ + "schemaVersion": 1, + "slug": "railway", + "name": "Railway", + "description": "Inspect services and logs, deploy applications, and run commands in your Railway containers.", + "categories": [ + "developer" + ], + "featured": false, + "branding": { + "logoUrl": "/brands/apps/railway.svg", + "darkLogoUrl": "/brands/apps/railway-dark.svg" + }, + "urlPatterns": [ + "https://mcp.railway.com/" + ], + "methods": [ + { + "key": "mcp-oauth", + "transport": "mcp_remote", + "auth": "oauth", + "ownershipModes": [ + "dcr", + "customer" + ], + "whenToUse": "Authorize your Railway account in the browser.", + "defaults": { + "serverUrl": "https://mcp.railway.com", + "scopesHint": [ + "openid", + "offline_access", + "workspace:member" + ], + "oauthAuthorizationParams": { + "prompt": "consent" + } + }, + "guidanceMd": "Sign in to Railway and select the workspaces your agents may use. Paperclip adds direct service, deployment, and bounded log tools when Railway accepts the connection for API access. Container commands require the separate SSH setup on the connection. Project tokens are not supported by Railway's hosted connection.", + "riskTier": "S4", + "label": "Connect Railway", + "consoleLinks": { + "docs": "https://docs.railway.com/ai/mcp-server", + "register": "https://docs.railway.com/integrations/oauth/creating-an-app", + "settings": "https://railway.com/account" + }, + "warnings": [ + "Railway enforces the workspaces selected at consent. Selected actions start Allowed; choose Ask first for operations you want to approve.", + "Logs and container commands can expose application data and secrets. Grant access only to agents trusted with the selected services.", + "The general Railway agent and committing staged changes are unavailable because their internal changes cannot be individually reviewed in Paperclip.", + "Live Railway qualification is pending. If Railway rejects API access, reconnect with the required permissions; Paperclip never falls back to another credential." + ], + "requiredResourceFilters": [ + "workspace", + "project", + "environment", + "service" + ] + } + ], + "redirectConstraints": "https-or-loopback-http" +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5d73e226f7..a4e58a58fa 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2763,4 +2763,5 @@ export { EXECUTION_RECONCILIATION_CAUSES, requiresExecutionReconciliation } from export * from "./ai-connections.js"; export * from "./types/email.js"; export * from "./validators/email.js"; +export { configureRailwaySshSchema, type ConfigureRailwaySsh, type RailwaySshSetup } from "./railway-connection.js"; export * from "./announcements.js"; diff --git a/packages/shared/src/railway-connection.ts b/packages/shared/src/railway-connection.ts new file mode 100644 index 0000000000..6b63076ca3 --- /dev/null +++ b/packages/shared/src/railway-connection.ts @@ -0,0 +1,14 @@ +import { z } from "zod"; + +export const configureRailwaySshSchema = z.discriminatedUnion("action", [ + z.object({ action: z.literal("prepare"), grantId: z.string().uuid() }).strict(), + z.object({ action: z.literal("enable"), grantId: z.string().uuid(), knownHosts: z.string().min(1).max(8192) }).strict(), + z.object({ action: z.literal("remove"), grantId: z.string().uuid() }).strict(), +]); +export type ConfigureRailwaySsh = z.infer; +export interface RailwaySshSetup { + grantId: string; + publicKey: string; + knownHosts: string; + enabled: boolean; +} diff --git a/scripts/ingest-app-definitions.mjs b/scripts/ingest-app-definitions.mjs index 6abdf4af55..719a04d38c 100644 --- a/scripts/ingest-app-definitions.mjs +++ b/scripts/ingest-app-definitions.mjs @@ -1,6 +1,9 @@ import fs from "node:fs"; import path from "node:path"; const root = process.cwd(); +// Provider definitions can be regenerated without the external research corpus. +// This mode preserves the checked-in ingestion report. +const definitionsOnly = process.argv.includes("--definitions-only"); const corpus = process.env.PAPERCLIP_CONTENT_TEMPLATES ?? path.resolve( @@ -204,6 +207,44 @@ const apps = [ }, ), ], + [ + "railway", + "Railway", + "Inspect services and logs, deploy applications, and run commands in your Railway containers.", + "developer", + "railway.com", + ["https://mcp.railway.com/"], + method( + "mcp-oauth", + "mcp_remote", + "oauth", + { + serverUrl: "https://mcp.railway.com", + scopesHint: ["openid", "offline_access", "workspace:member"], + oauthAuthorizationParams: { prompt: "consent" }, + }, + "S4", + "Sign in to Railway and select the workspaces your agents may use. Paperclip adds direct service, deployment, and bounded log tools when Railway accepts the connection for API access. Container commands require the separate SSH setup on the connection. Project tokens are not supported by Railway's hosted connection.", + { + label: "Connect Railway", + ownershipModes: ["dcr", "customer"], + whenToUse: "Authorize your Railway account in the browser.", + consoleLinks: { + docs: "https://docs.railway.com/ai/mcp-server", + register: "https://docs.railway.com/integrations/oauth/creating-an-app", + settings: "https://railway.com/account", + }, + warnings: [ + "Railway enforces the workspaces selected at consent. Selected actions start Allowed; choose Ask first for operations you want to approve.", + "Logs and container commands can expose application data and secrets. Grant access only to agents trusted with the selected services.", + "The general Railway agent and committing staged changes are unavailable because their internal changes cannot be individually reviewed in Paperclip.", + "Live Railway qualification is pending. If Railway rejects API access, reconnect with the required permissions; Paperclip never falls back to another credential.", + ], + requiredResourceFilters: ["workspace", "project", "environment", "service"], + }, + ), + { redirectConstraints: "https-or-loopback-http" }, + ], [ "github", "GitHub", @@ -1386,6 +1427,7 @@ const reviewedGoogleSlugs = [ "google-chat", "google-people", "google-workspace-search", + ]; for (const slug of reviewedGoogleSlugs) { const existingIndex = apps.findIndex((app) => app.slug === slug); @@ -1534,11 +1576,11 @@ const validateApp = (app) => { ); } }; -const captureFiles = fs +const captureFiles = definitionsOnly ? [] : fs .readdirSync(corpus) .filter((fileName) => fileName.endsWith(".md") && fileName !== "INDEX.md") .sort(); -if (captureFiles.length !== 99) +if (!definitionsOnly && captureFiles.length !== 99) throw new Error(`Expected 99 captures, found ${captureFiles.length}`); const parsedCaptures = Object.fromEntries( captureFiles.map((fileName) => [ @@ -1575,7 +1617,7 @@ for (const app of apps) path.join(out, `${app.slug}.json`), JSON.stringify(app, null, 2) + "\n", ); -fs.writeFileSync( +if (!definitionsOnly) fs.writeFileSync( path.join(root, "packages/shared/src/app-definitions.ingestion-report.json"), JSON.stringify(reviewReport, null, 2) + "\n", ); diff --git a/server/src/__tests__/fixtures/railway/provider.ts b/server/src/__tests__/fixtures/railway/provider.ts new file mode 100644 index 0000000000..fa1a7c5154 --- /dev/null +++ b/server/src/__tests__/fixtures/railway/provider.ts @@ -0,0 +1,35 @@ +// Public discovery metadata observed 2026-09-13. Tool descriptors below are +// deterministic examples of documented tools, not an authenticated tools/list capture. +export const railwayResourceMetadata = { + resource: "https://mcp.railway.com", + authorization_servers: ["https://backboard.railway.com"], + scopes_supported: ["openid", "profile", "email", "offline_access", "workspace:member"], + bearer_methods_supported: ["header"], +}; +export const railwayAuthorizationMetadata = { + issuer: "https://backboard.railway.com", + authorization_endpoint: "https://backboard.railway.com/oauth/auth?resource=https%3A%2F%2Fbackboard.railway.com", + token_endpoint: "https://backboard.railway.com/oauth/token", + registration_endpoint: "https://backboard.railway.com/oauth/register", + scopes_supported: railwayResourceMetadata.scopes_supported, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"], + token_endpoint_auth_methods_supported: ["client_secret_basic", "client_secret_post", "none", "private_key_jwt"], + code_challenge_methods_supported: ["S256"], +}; +export const target = { + projectId: "11111111-1111-4111-8111-111111111111", + environmentId: "22222222-2222-4222-8222-222222222222", + serviceId: "33333333-3333-4333-8333-333333333333", + deploymentId: "44444444-4444-4444-8444-444444444444", +}; +export const instanceId = "55555555-5555-4555-8555-555555555555"; +export const targetData = { + project: { id: target.projectId }, + environment: { id: target.environmentId, projectId: target.projectId }, + service: { id: target.serviceId, projectId: target.projectId }, + serviceInstance: { environmentId: target.environmentId, serviceId: target.serviceId, source: { repo: "example/app" } }, +}; +export const deploymentData = { + deployment: { ...target, id: target.deploymentId, status: "SUCCESS", canRedeploy: true, canRollback: true, instances: [{ id: instanceId }] }, +}; diff --git a/server/src/__tests__/railway-connection.test.ts b/server/src/__tests__/railway-connection.test.ts new file mode 100644 index 0000000000..a283433849 --- /dev/null +++ b/server/src/__tests__/railway-connection.test.ts @@ -0,0 +1,198 @@ +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { agents, approvals, companies, companyMemberships, companySecrets, connectionGrants, createDb, heartbeatRuns, issues, toolCatalogEntries, toolActionRequests, toolConnections, toolPolicies, toolProfileBindings, toolProfiles, toolAccessAuditEvents } from "@paperclipai/db"; +import { toolAccessService } from "../services/tool-access.js"; +import { createToolGatewayService } from "../services/tool-gateway.js"; +import { RAILWAY_API_URL, RAILWAY_MCP_URL, RAILWAY_QUERIES } from "../services/railway.js"; +import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js"; +import { deploymentData, railwayAuthorizationMetadata, railwayResourceMetadata, target, targetData } from "./fixtures/railway/provider.js"; +const support = await getEmbeddedPostgresTestSupport(); +const actor = { actorType: "user" as const, actorId: "railway-reviewer" }; +const redirectUri = "http://localhost:3100/api/tools/oauth/callback"; +const token = "railway-fixture-opaque-access-token"; +const initialTools = [ + { name: "list-projects", inputSchema: { type: "object", properties: {} }, annotations: { readOnlyHint: true } }, + { name: "redeploy", inputSchema: { type: "object", properties: { deploymentId: { type: "string" } } }, annotations: { readOnlyHint: true } }, + { name: "railway-agent", annotations: { readOnlyHint: true } }, + { name: "accept-deploy", annotations: { readOnlyHint: true } }, +]; + +(support.supported ? describe : describe.skip)("Railway connection lifecycle and gateway", () => { + let db: ReturnType; + let temp: Awaited>; + beforeAll(async () => { temp = await startEmbeddedPostgresTestDatabase("paperclip-railway-"); db = createDb(temp.connectionString); }, 20000); + afterAll(async () => { await temp?.cleanup(); }); + + async function fixture() { + const [company] = await db.insert(companies).values({ name: "Railway fixture", issuePrefix: `RW${randomUUID().slice(0, 6)}` }).returning(); + await db.insert(companyMemberships).values({ companyId: company.id, principalType: "user", principalId: actor.actorId, status: "active", membershipRole: "admin" }); + let tools: unknown[] = [...initialTools]; + let apiStatus = 200; + let tokenStatus = 200; + const request = vi.fn(async (url: string, init: RequestInit) => { + const body = init.body ? String(init.body) : ""; + if (url === RAILWAY_API_URL) { + if (apiStatus !== 200) return new Response("private provider error", { status: apiStatus }); + const { query, variables } = JSON.parse(body); + // Workspace-scoped OAuth rejects account-wide projects even with HTTP 200. + if (query === RAILWAY_QUERIES.projects && !variables?.workspaceId) return Response.json({ errors: [{ message: "Not Authorized", extensions: { code: "INTERNAL_SERVER_ERROR" } }], data: null }); + return Response.json({ data: query === RAILWAY_QUERIES.target ? targetData : query === RAILWAY_QUERIES.deployment ? deploymentData : query === RAILWAY_QUERIES.restart ? { deploymentRestart: true } : { projects: { edges: [] } } }); + } + if (url.replace(/\/$/, "") === RAILWAY_MCP_URL && init.method === "POST") { + if (new Headers(init.headers).get("authorization") !== `Bearer ${token}`) return new Response("", { status: 401, headers: { "www-authenticate": 'Bearer resource_metadata="https://mcp.railway.com/.well-known/oauth-protected-resource"' } }); + if (JSON.parse(body).method === "tools/call") { + expect(JSON.parse(body).params).toEqual({ name: "list-workspaces", arguments: {} }); + return Response.json({ jsonrpc: "2.0", id: "paperclip-railway-workspace-probe", result: { structuredContent: { workspaces: [{ id: target.projectId }] } } }); + } + return Response.json({ jsonrpc: "2.0", id: "paperclip-catalog-refresh", result: { tools } }); + } + if (url.includes("oauth-protected-resource")) return Response.json(railwayResourceMetadata); + if (url.includes("oauth-authorization-server")) return Response.json(railwayAuthorizationMetadata); + if (url === railwayAuthorizationMetadata.registration_endpoint) return Response.json({ ...JSON.parse(body), client_id: "railway-fixture-client" }); + if (url === railwayAuthorizationMetadata.token_endpoint) return tokenStatus === 200 ? Response.json({ access_token: token, refresh_token: "railway-fixture-refresh", expires_in: 3600, token_type: "Bearer" }) : Response.json({ error: "invalid_grant", error_description: "private provider message" }, { status: tokenStatus }); + return new Response("", { status: 404 }); + }); + const service = toolAccessService(db, { remoteHttpRequest: request, remoteHttpEndpointLookup: async () => [{ address: "8.8.8.8", family: 4 }] }); + const connection = await service.connectGalleryApp(company.id, { galleryKey: "railway", methodKey: "mcp-oauth", name: "Railway" }, actor); + const start = await service.startOAuth(company.id, connection.connectionId, { redirectUri, actor }); + const url = new URL(start.authorizationUrl); + expect(start.registrationSource).toBe("dcr"); + expect(url.searchParams.get("prompt")).toBe("consent"); + expect(url.searchParams.get("scope")).toContain("offline_access"); + expect(url.searchParams.get("code_challenge_method")).toBe("S256"); + expect(url.searchParams.get("resource")).toBe(RAILWAY_MCP_URL); + await service.completeOAuthCallback({ state: url.searchParams.get("state")!, code: "fixture-code", iss: railwayAuthorizationMetadata.issuer, redirectUri, actor }); + return { company, service, connectionId: connection.connectionId, request, setTools: (next: unknown[]) => { tools = next; }, setApiStatus: (next: number) => { apiStatus = next; }, setTokenStatus: (next: number) => { tokenStatus = next; } }; + } + + it("discovers direct tools, blocks opaque actions and quarantines changed tools even on reconnect", async () => { + const f = await fixture(); + const rows = await f.service.listCatalog(f.connectionId); + expect((await f.service.getConnection(f.connectionId))?.config?.railwayApiStatus).toBe("available"); + expect(rows.find((r) => r.toolName === "paperclip-railway-read-logs")?.status).toBe("active"); + expect(rows.find((r) => r.toolName === "redeploy")?.riskLevel).toBe("destructive"); + expect(rows.filter((r) => ["railway-agent", "accept-deploy"].includes(r.toolName)).every((r) => r.status === "disabled")).toBe(true); + f.setTools([...initialTools.map((tool) => tool.name === "list-projects" ? { ...tool, inputSchema: { type: "object", properties: { changed: { type: "string" } } } } : tool), { name: "new-tool" }]); + await f.service.refreshCatalog(f.connectionId, actor); + const changed = await f.service.listCatalog(f.connectionId); + expect(changed.find((r) => r.toolName === "list-projects")?.status).toBe("quarantined"); + expect(changed.find((r) => r.toolName === "new-tool")?.status).toBe("quarantined"); + const start = await f.service.startOAuth(f.company.id, f.connectionId, { redirectUri, actor }); + await f.service.completeOAuthCallback({ state: new URL(start.authorizationUrl).searchParams.get("state")!, code: "reconnect-code", iss: railwayAuthorizationMetadata.issuer, redirectUri, actor }); + const after = await f.service.listCatalog(f.connectionId); + expect(after.find((r) => r.toolName === "new-tool")?.status).toBe("quarantined"); + expect(JSON.stringify(await f.service.getConnection(f.connectionId))).not.toContain(token); + f.setTools([{ name: "paperclip_railway_restart" }]); + await expect(f.service.refreshCatalog(f.connectionId, actor)).rejects.toThrow("Railway advertised a reserved Paperclip action"); + }); + + it("keeps direct operations unavailable when API acceptance fails and reports refresh failure safely", async () => { + const f = await fixture(); + f.setApiStatus(403); + await f.service.refreshCatalog(f.connectionId, actor); + expect((await f.service.getConnection(f.connectionId))?.config?.railwayApiStatus).toBe("unavailable"); + const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connectionId)); + f.setTokenStatus(400); + let failure: unknown; + try { await f.service.refreshOAuthGrantCredentials({ companyId: f.company.id, connectionId: f.connectionId, grantId: grant.id, forceRefresh: true, actor }); } + catch (error) { failure = error; } + expect(failure).toBeTruthy(); + expect(String(failure)).not.toContain("private provider message"); + expect(JSON.stringify(await f.service.getConnection(f.connectionId))).not.toContain(token); + }); + + it("denies retired source-deployment entries before refresh and disables them on refresh", async () => { + const f = await fixture(); + const [agent] = await db.insert(agents).values({ companyId: f.company.id, name: "Railway operator", role: "engineer", adapterType: "process", adapterConfig: {} }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: agent.id, invocationSource: "on_demand", status: "running" }).returning(); + const [profile] = await db.insert(toolProfiles).values({ companyId: f.company.id, name: "Railway tools", profileKey: randomUUID(), defaultAction: "allow" }).returning(); + await db.insert(toolProfileBindings).values({ companyId: f.company.id, profileId: profile.id, targetType: "agent", targetId: agent.id }); + const [existing] = await db.select().from(toolCatalogEntries).where(and(eq(toolCatalogEntries.connectionId, f.connectionId), eq(toolCatalogEntries.toolName, "paperclip-railway-restart"))); + const names = ["paperclip-railway-deploy-revision", "paperclip_railway_deploy_revision", "paperclipRailwayDeployRevision"]; + // Reproduce active catalog rows persisted by an older server, before refresh. + await db.insert(toolCatalogEntries).values(names.map((name) => ({ ...existing, id: randomUUID(), name, toolName: name, inputSchema: { type: "object" } }))); + const gateway = createToolGatewayService(db, { remoteHttpRequest: f.request }); + const session = await gateway.createSession({ companyId: f.company.id, agentId: agent.id, runId: run.id }); + const listed = await gateway.listToolsForSession(session.token); + expect(listed.some((tool) => names.includes(tool.upstreamToolName ?? ""))).toBe(false); + const restart = listed.find((tool) => tool.upstreamToolName === "paperclip-railway-restart")!; + expect(restart).toBeTruthy(); + f.request.mockClear(); + const { deploymentId: _, ...ids } = target; + const parameters = { ...ids, repository: "example/app", commitSha: "a".repeat(40) }; + await expect(gateway.executeTool({ sessionToken: session.token, tool: restart.name.replace("paperclip-railway-restart", names[0]), parameters, idempotencyKey: randomUUID() })).rejects.toMatchObject({ reasonCode: "tool_not_found" }); + for (const toolName of names) { + await expect(gateway.executeTestCall({ companyId: f.company.id, connectionId: f.connectionId, agentId: agent.id, userId: actor.actorId, toolName, parameters })).rejects.toMatchObject({ reasonCode: "tool_not_found" }); + } + expect(f.request).not.toHaveBeenCalled(); + await f.service.refreshCatalog(f.connectionId, actor); + const retired = (await f.service.listCatalog(f.connectionId)).filter((entry) => names.includes(entry.toolName)); + expect(retired).toHaveLength(names.length); + expect(retired.every((entry) => entry.status === "disabled")).toBe(true); + }); + + it("preserves the dedicated SSH grant key on reconnect and removes it while disconnected", async () => { + const f = await fixture(); + const [grant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connectionId)); + const setup = await f.service.configureRailwaySsh(f.connectionId, f.company.id, { action: "prepare", grantId: grant.id }, actor); + expect(setup?.publicKey).toMatch(/^ssh-ed25519 /); + const [keyGrant] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)); + const ref = keyGrant.credentialSecretRefs.find((r) => r.configPath === "railway.ssh_private_key")!; + expect(ref).toBeTruthy(); + const start = await f.service.startOAuth(f.company.id, f.connectionId, { redirectUri, actor }); + await f.service.completeOAuthCallback({ state: new URL(start.authorizationUrl).searchParams.get("state")!, code: "reconnect", iss: railwayAuthorizationMetadata.issuer, redirectUri, actor }); + const [reconnected] = await db.select().from(connectionGrants).where(eq(connectionGrants.id, grant.id)); + expect(reconnected.credentialSecretRefs).toContainEqual(ref); + await f.service.revokeConnectionGrant(f.connectionId, grant.id, actor); + await db.update(toolConnections).set({ status: "disabled" }).where(eq(toolConnections.id, f.connectionId)); + await f.service.configureRailwaySsh(f.connectionId, f.company.id, { action: "remove", grantId: grant.id }, actor); + expect(await db.select().from(companySecrets).where(eq(companySecrets.id, ref.secretId))).toHaveLength(0); + expect((await f.service.getConnection(f.connectionId))?.config?.railwaySsh).toBeNull(); + }); + + it("enforces policy, grant, run and company boundaries before API execution", async () => { + const f = await fixture(); + const [agent] = await db.insert(agents).values({ companyId: f.company.id, name: "Railway operator", role: "engineer", adapterType: "process", adapterConfig: {} }).returning(); + const [issue] = await db.insert(issues).values({ companyId: f.company.id, title: "Railway proof", assigneeAgentId: agent.id }).returning(); + const [run] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: agent.id, invocationSource: "on_demand", status: "running", contextSnapshot: { issueId: issue.id } }).returning(); + const [profile] = await db.insert(toolProfiles).values({ companyId: f.company.id, name: "Railway tools", profileKey: randomUUID(), defaultAction: "allow" }).returning(); + await db.insert(toolProfileBindings).values({ companyId: f.company.id, profileId: profile.id, targetType: "agent", targetId: agent.id }); + const gateway = createToolGatewayService(db, { remoteHttpRequest: f.request, toolActionSigningSecret: "railway-fixture-signing-key" }); + let session = await gateway.createSession({ companyId: f.company.id, agentId: agent.id, runId: run.id }); + const tool = (await gateway.listToolsForSession(session.token)).find((r) => r.upstreamToolName === "paperclip-railway-restart")!; + expect(tool).toBeTruthy(); + const [policy] = await db.insert(toolPolicies).values({ companyId: f.company.id, name: "Approve Railway restart", policyType: "require_approval", selectors: { connectionId: f.connectionId }, priority: 10 }).returning(); + f.request.mockClear(); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: target, idempotencyKey: randomUUID() })).rejects.toMatchObject({ reasonCode: "approval_required" }); + expect(f.request).not.toHaveBeenCalled(); + const [pending] = await db.select().from(toolActionRequests).where(eq(toolActionRequests.companyId, f.company.id)); + await gateway.declineActionRequest({ companyId: f.company.id, actionRequestId: pending.id, actor: { userId: actor.actorId } }); + expect(f.request).not.toHaveBeenCalled(); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: target, idempotencyKey: randomUUID() })).rejects.toMatchObject({ status: 409 }); + expect(f.request).not.toHaveBeenCalled(); + const [nextIssue] = await db.insert(issues).values({ companyId: f.company.id, title: "New Railway operation", assigneeAgentId: agent.id }).returning(); + const [nextRun] = await db.insert(heartbeatRuns).values({ companyId: f.company.id, agentId: agent.id, invocationSource: "on_demand", status: "running", contextSnapshot: { issueId: nextIssue.id } }).returning(); + session = await gateway.createSession({ companyId: f.company.id, agentId: agent.id, runId: nextRun.id }); + await db.update(toolPolicies).set({ policyType: "block" }).where(eq(toolPolicies.id, policy.id)); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: target, idempotencyKey: randomUUID() })).rejects.toMatchObject({ status: 403 }); + expect(f.request).not.toHaveBeenCalled(); + await db.update(toolPolicies).set({ policyType: "require_approval" }).where(eq(toolPolicies.id, policy.id)); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: target, idempotencyKey: randomUUID() })).rejects.toMatchObject({ reasonCode: "approval_required" }); + const [approved] = await db.select().from(toolActionRequests).where(and(eq(toolActionRequests.companyId, f.company.id), eq(toolActionRequests.status, "pending"))); + if (approved.approvalId) await db.update(approvals).set({ status: "approved", decidedByUserId: actor.actorId, decidedAt: new Date() }).where(eq(approvals.id, approved.approvalId)); + await gateway.approveActionRequest({ companyId: f.company.id, actionRequestId: approved.id, actor: { userId: actor.actorId } }); + expect(f.request.mock.calls.some(([, init]) => JSON.parse(String(init.body)).query === RAILWAY_QUERIES.restart)).toBe(true); + await db.delete(toolPolicies).where(eq(toolPolicies.id, policy.id)); + expect(f.request.mock.calls.filter(([, init]) => String(init.body).includes("mutation"))).toHaveLength(1); + expect(JSON.stringify(await db.select().from(toolAccessAuditEvents).where(eq(toolAccessAuditEvents.companyId, f.company.id)))).not.toContain(token); + f.request.mockClear(); + const [revokedGrant] = await db.select().from(connectionGrants).where(eq(connectionGrants.connectionId, f.connectionId)); + await f.service.revokeConnectionGrant(f.connectionId, revokedGrant.id, actor); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: { ...target, deploymentId: randomUUID() }, idempotencyKey: randomUUID() })).rejects.toMatchObject({ status: 409, reasonCode: "organization_authorization_required" }); + expect(f.request).not.toHaveBeenCalled(); + await expect(gateway.createSession({ companyId: randomUUID(), agentId: agent.id, runId: run.id })).rejects.toThrow(); + await db.update(heartbeatRuns).set({ status: "succeeded" }).where(eq(heartbeatRuns.id, nextRun.id)); + await expect(gateway.executeTool({ sessionToken: session.token, tool: tool.name, parameters: target, idempotencyKey: randomUUID() })).rejects.toMatchObject({ status: 401, reasonCode: "session_run_inactive" }); + }); +}); diff --git a/server/src/__tests__/railway-ssh.test.ts b/server/src/__tests__/railway-ssh.test.ts new file mode 100644 index 0000000000..04f72e4000 --- /dev/null +++ b/server/src/__tests__/railway-ssh.test.ts @@ -0,0 +1,69 @@ +import { EventEmitter } from "node:events"; +import { PassThrough } from "node:stream"; +import { access, readFile } from "node:fs/promises"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { instanceId } from "./fixtures/railway/provider.js"; +const { spawnMock } = vi.hoisted(() => ({ spawnMock: vi.fn() })); +vi.mock("node:child_process", async (original) => ({ ...await original(), spawn: spawnMock })); +import { generateRailwaySshKey, railwaySshArguments, runRailwaySshCommand, validateRailwayKnownHosts } from "../services/railway-ssh.js"; + +const host = "ssh.railway.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIFixture"; +function input(signal = new AbortController().signal) { return { deploymentInstanceId: instanceId, command: "printf 'hello'", timeoutSeconds: 1, privateKey: "-----BEGIN OPENSSH PRIVATE KEY-----\nfixture\n", knownHosts: host, signal }; } +function fakeProcess(action: (child: EventEmitter & { stdout: PassThrough; stderr: PassThrough; stdin: PassThrough; kill: ReturnType }, script: string) => void) { + spawnMock.mockImplementation(() => { + const child = Object.assign(new EventEmitter(), { stdout: new PassThrough(), stderr: new PassThrough(), stdin: new PassThrough(), kill: vi.fn(() => { queueMicrotask(() => child.emit("close", null)); return true; }) }); + let script = ""; + child.stdin.on("data", (chunk) => { script += chunk; }); + child.stdin.on("finish", () => action(child, script)); + return child; + }); +} +afterEach(() => { vi.clearAllMocks(); }); + +describe("Railway isolated container command runner", () => { + it("generates a fresh real key without retaining files", async () => { + const key = await generateRailwaySshKey(); + expect(key.publicKey).toMatch(/^ssh-ed25519 /); + expect(key.privateKey).toMatch(/^-----BEGIN OPENSSH PRIVATE KEY-----/); + }); + it("rejects untrusted aliases and ambient SSH state", () => { + for (const line of ["* ssh-ed25519 AAAA", "evil.test ssh-ed25519 AAAA", `${host}\nHost *`, "@cert-authority " + host]) expect(() => validateRailwayKnownHosts(line)).toThrow(); + const args = railwaySshArguments("/tmp/dedicated", instanceId); + expect(args).toEqual(expect.arrayContaining(["/dev/null", "IdentityAgent=none", "StrictHostKeyChecking=yes", "IdentitiesOnly=yes", "ForwardAgent=no", "ControlPath=none"])); + expect(args.slice(-3)).toEqual(["--", `${instanceId}@ssh.railway.com`, "sh -s"]); + }); + it("requires remote completion and cleans its isolated directory", async () => { + fakeProcess((child, script) => { + expect(script).toContain(" { + fakeProcess((child) => { child.stdin.emit("error", new Error("EPIPE")); child.emit("close", 0); }); + await expect(runRailwaySshCommand(input())).rejects.toMatchObject({ code: "railway_ssh_command_unconfirmed" }); + }); + it("kills commands that exceed the output limit", async () => { + fakeProcess((child) => { child.stdout.write("x".repeat(70000)); }); + const result = await runRailwaySshCommand(input()); + expect(result.truncated).toBe(true); + expect(Buffer.byteLength(result.stdout)).toBe(65536); + }); + it("kills on timeout and cancellation and still removes private material", async () => { + fakeProcess(() => {}); + const result = await runRailwaySshCommand(input()); + expect(result.timedOut).toBe(true); + const controller = new AbortController(); + fakeProcess(() => controller.abort()); + await expect(runRailwaySshCommand(input(controller.signal))).rejects.toMatchObject({ name: "AbortError" }); + const args = spawnMock.mock.calls.at(-1)![1] as string[]; + await expect(readFile(args[args.indexOf("-i") + 1])).rejects.toThrow(); + }); +}); diff --git a/server/src/__tests__/railway.test.ts b/server/src/__tests__/railway.test.ts new file mode 100644 index 0000000000..760aa55e5e --- /dev/null +++ b/server/src/__tests__/railway.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it, vi } from "vitest"; +import { railwayCommandBudgetMs, createRailwayClient, discoverRailwayWorkspace, isRailwayConnection, isRailwayEndpoint, isRailwayToolBlocked, RAILWAY_API_URL, RAILWAY_MCP_URL, RAILWAY_QUERIES, RAILWAY_TOOLS, railwayRisk } from "../services/railway.js"; +import { deploymentData, instanceId, target, targetData } from "./fixtures/railway/provider.js"; + +function fixture(responder?: (query: string) => Response | undefined) { + const request = vi.fn(async (_url: string, init: RequestInit) => { + const { query } = JSON.parse(String(init.body)); + return responder?.(query) ?? Response.json({ data: query === RAILWAY_QUERIES.target ? targetData : query === RAILWAY_QUERIES.deployment ? deploymentData : { deploymentRestart: true } }); + }); + const runCommand = vi.fn(async () => ({ exitCode: 0, stdout: "ok", stderr: "" })); + const controller = new AbortController(); + return { request, runCommand, controller, client: createRailwayClient({ authorization: "Bearer railway-fixture-secret", request, runCommand, signal: controller.signal }) }; +} + +describe("Railway governed operations", () => { + it("binds project listing and the access probe to an explicit workspace", async () => { + const f = fixture(() => Response.json({ data: { projects: { edges: [] } } })); + await expect(f.client.call("paperclip-railway-list-projects", {})).rejects.toMatchObject({ code: "railway_invalid_arguments" }); + expect(f.request).not.toHaveBeenCalled(); + await f.client.probe(target.projectId); + await f.client.call("paperclip-railway-list-projects", { workspaceId: target.projectId, first: 5 }); + expect(f.request.mock.calls.map(([, init]) => JSON.parse(String(init.body)).variables)).toEqual([ + { workspaceId: target.projectId, first: 1 }, { workspaceId: target.projectId, first: 5 }, + ]); + expect(RAILWAY_QUERIES.projects).toContain("projects(workspaceId:$workspaceId,"); + }); + + it.each(["structured", "sse"])("discovers workspace access from the hosted %s response without account profile scopes", async (format) => { + const data = { workspaces: [{ id: target.projectId }] }; + const payload = { jsonrpc: "2.0", id: "paperclip-railway-workspace-probe", result: format === "structured" ? { structuredContent: data } : { content: [{ type: "text", text: JSON.stringify(data) }] } }; + const request = vi.fn(async () => format === "structured" ? Response.json(payload) : new Response(`data: ${JSON.stringify(payload)}\n\n`, { headers: { "content-type": "text/event-stream" } })); + await expect(discoverRailwayWorkspace({ authorization: "Bearer fixture", request, signal: new AbortController().signal })).resolves.toBe(target.projectId); + expect(request).toHaveBeenCalledWith(RAILWAY_MCP_URL, expect.objectContaining({ redirect: "error", body: expect.stringContaining('"name":"list-workspaces"') })); + }); + + it("keeps operations unavailable for empty or failed workspace discovery without exposing provider output", async () => { + for (const result of [{ structuredContent: { workspaces: [] } }, { isError: true, content: [{ type: "text", text: "private provider details" }] }]) { + const request = vi.fn(async () => Response.json({ result })); + await expect(discoverRailwayWorkspace({ authorization: "Bearer fixture", request, signal: new AbortController().signal })).rejects.toThrow(/Railway/); + } + }); + + it("recognizes Railway's HTTP 200 authorization errors without echoing provider details", async () => { + const f = fixture(() => Response.json({ errors: [{ message: "Not Authorized", extensions: { code: "INTERNAL_SERVER_ERROR", private: "provider secret" } }], data: null })); + await expect(f.client.probe(target.projectId)).rejects.toMatchObject({ code: "railway_api_authorization_required", status: 403, message: expect.stringContaining("workspace selected during consent") }); + expect(f.request).toHaveBeenCalledTimes(1); + }); + it("requires exact provider identity and treats shell and remote agents as privileged", () => { + expect(isRailwayEndpoint("https://mcp.railway.com/")).toBe(true); + for (const url of ["https://mcp.railway.com/path", "https://mcp.railway.com?token=x", "https://mcp.railway.com.evil.test", "http://mcp.railway.com"]) expect(isRailwayEndpoint(url)).toBe(false); + expect(isRailwayConnection({ transport: "mcp_remote", authKind: "oauth", credentialSource: "paperclip_vault", config: { url: "https://mcp.railway.com", sourceTemplateKey: "railway", connectionMethodKey: "mcp-oauth" } })).toBe(true); + expect(railwayRisk("railwayAgent")).toBe("destructive"); + expect(isRailwayToolBlocked("accept_deploy")).toBe(true); + expect(railwayRisk("paperclip-railway-run-command")).toBe("destructive"); + expect(railwayRisk("unfamiliar-tool")).toBe("write"); + expect(RAILWAY_TOOLS).toHaveLength(11); + expect(RAILWAY_TOOLS.map((tool) => tool.name)).not.toContain("paperclip-railway-deploy-revision"); + }); + + it("gives container commands time for target checks without exceeding the gateway limit", () => { + expect(railwayCommandBudgetMs({})).toBe(40000); + expect(railwayCommandBudgetMs({ timeoutSeconds: 1 })).toBe(11000); + expect(railwayCommandBudgetMs({ timeoutSeconds: 60 })).toBe(60000); + expect(railwayCommandBudgetMs({ timeoutSeconds: 999 })).toBe(60000); + }); + + it("checks full deployment membership before a single fixed mutation", async () => { + const f = fixture(); + await expect(f.client.call("paperclip-railway-restart", target)).resolves.toEqual({ deploymentRestart: true, targetDeploymentId: target.deploymentId }); + expect(f.request.mock.calls.map(([, init]) => JSON.parse(String(init.body)).query)).toEqual([RAILWAY_QUERIES.target, RAILWAY_QUERIES.deployment, RAILWAY_QUERIES.restart]); + for (const [url, init] of f.request.mock.calls) { + expect(url).toBe(RAILWAY_API_URL); + expect(init.redirect).toBe("error"); + expect(init.headers).toMatchObject({ Authorization: "Bearer railway-fixture-secret" }); + } + expect(JSON.parse(String(f.request.mock.calls[2][1].body)).variables).toEqual({ deploymentId: target.deploymentId }); + }); + + it.each(["project", "environment", "service", "deployment"])("denies a mismatched %s before mutation", async (field) => { + const f = fixture((q) => { + if (field === "deployment" && q === RAILWAY_QUERIES.deployment) return Response.json({ data: { deployment: { ...deploymentData.deployment, serviceId: instanceId } } }); + if (field !== "deployment" && q === RAILWAY_QUERIES.target) return Response.json({ data: { ...targetData, [field]: { ...targetData[field as keyof typeof targetData], id: instanceId } } }); + }); + await expect(f.client.call("paperclip-railway-restart", target)).rejects.toMatchObject({ code: "railway_target_mismatch" }); + expect(f.request.mock.calls.every(([, init]) => !JSON.parse(String(init.body)).query.startsWith("mutation"))).toBe(true); + }); + + it("bounds and redacts logs without selecting variables", async () => { + const f = fixture((q) => q === RAILWAY_QUERIES.runtimeLogs ? Response.json({ data: { deploymentLogs: Array.from({ length: 20 }, () => ({ message: `railway-fixture-secret ${"x".repeat(9000)}`, severity: "INFO" })) } }) : undefined); + const result = await f.client.call("paperclip-railway-read-logs", { ...target, limit: 10 }); + expect(JSON.stringify(result)).not.toContain("railway-fixture-secret"); + expect(Buffer.byteLength(JSON.stringify(result))).toBeLessThan(66000); + expect(result).toMatchObject({ truncated: true }); + await expect(f.client.call("paperclip-railway-read-logs", { ...target, limit: 501 })).rejects.toMatchObject({ code: "railway_invalid_arguments" }); + expect(Object.values(RAILWAY_QUERIES).join(" ")).not.toMatch(/variableCollection|variables\s*\{/); + }); + + it("reports when a single long log line was cut", async () => { + const f = fixture((q) => q === RAILWAY_QUERIES.runtimeLogs ? Response.json({ data: { deploymentLogs: [{ message: "x".repeat(20000) }] } }) : undefined); + await expect(f.client.call("paperclip-railway-read-logs", target)).resolves.toMatchObject({ truncated: true, limitReached: false }); + }); + + it.each([401, 403, 429, 500])("sanitizes HTTP %s without retries", async (status) => { + const f = fixture(() => new Response("provider secret", { status })); + await expect(f.client.probe(target.projectId)).rejects.toThrow(/Railway/); + expect(f.request).toHaveBeenCalledTimes(1); + }); + + it("does not expose GraphQL error details or retry an ambiguous mutation", async () => { + const f = fixture((q) => q === RAILWAY_QUERIES.restart ? Response.json({ errors: [{ message: "sensitive provider payload" }] }) : undefined); + await expect(f.client.call("paperclip-railway-restart", target)).rejects.toMatchObject({ code: "railway_api_error" }); + expect(f.request).toHaveBeenCalledTimes(3); + }); + + it.each([ + ["restart", { deploymentRestart: false }], + ["rollback", { deploymentRollback: false }], + ["redeploy", { deploymentRedeploy: null }], + ["redeploy", { deploymentRedeploy: { id: "not-a-deployment-id" } }], + ])("does not report an unconfirmed %s as successful", async (operation, data) => { + const f = fixture((q) => q.startsWith("mutation") ? Response.json({ data }) : undefined); + await expect(f.client.call(`paperclip-railway-${operation}`, target)).rejects.toMatchObject({ code: "railway_operation_unconfirmed" }); + expect(f.request).toHaveBeenCalledTimes(3); + }); + + it("rejects oversized responses, cancelled calls and GraphQL passthrough", async () => { + const f = fixture(() => new Response("x".repeat(1024 * 1024 + 1))); + await expect(f.client.probe(target.projectId)).rejects.toMatchObject({ code: "railway_output_limit" }); + await expect(f.client.call("paperclip-railway-list-projects", { query: "mutation Evil" })).rejects.toMatchObject({ code: "railway_invalid_arguments" }); + f.controller.abort(); + await expect(f.client.probe(target.projectId)).rejects.toMatchObject({ name: "AbortError" }); + expect(f.request).toHaveBeenCalledTimes(1); + }); + + it.each(["paperclip-railway-deploy-revision", "paperclip_railway_deploy_revision", "paperclipRailwayDeployRevision"])("blocks %s before any repository preflight or deployment mutation", async (name) => { + // Even a matching repository in the preflight can change before mutation. + // Without an atomic provider binding, no upstream request is safe to send. + const f = fixture(); + const { deploymentId: _, ...ids } = target; + await expect(f.client.call(name, { ...ids, repository: "example/app", commitSha: "a".repeat(40) })).rejects.toMatchObject({ code: "railway_action_blocked", status: 403 }); + expect(f.request).not.toHaveBeenCalled(); + expect(isRailwayToolBlocked(name)).toBe(true); + expect(railwayRisk(name)).toBe("destructive"); + }); + + it("allows only an instance in the exact running deployment to reach SSH", async () => { + const f = fixture(); + await expect(f.client.call("paperclip-railway-run-command", { ...target, deploymentInstanceId: target.serviceId, command: "true" })).rejects.toMatchObject({ code: "railway_target_mismatch" }); + expect(f.runCommand).not.toHaveBeenCalled(); + await f.client.call("paperclip-railway-run-command", { ...target, deploymentInstanceId: instanceId, command: "true" }); + expect(f.runCommand).toHaveBeenCalledWith({ deploymentInstanceId: instanceId, command: "true", timeoutSeconds: 30, signal: f.controller.signal }); + }); +}); diff --git a/server/src/__tests__/tool-access-service.test.ts b/server/src/__tests__/tool-access-service.test.ts index af6aea1e87..91203224d6 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -5099,7 +5099,7 @@ describeEmbeddedPostgres("tool access service", () => { "github", ]), ); - expect(res.body.apps).toHaveLength(46); + expect(res.body.apps).toHaveLength(47); expect( res.body.apps.find((app: { slug: string }) => app.slug === "gmail") .ownershipAvailability, diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 3ba131044f..ec4b76f2d1 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -213,6 +213,7 @@ import { workspaceFileResourceQuerySchema, // Tool access connectToolAppSchema, + configureRailwaySshSchema, createToolApplicationSchema, updateToolApplicationSchema, createToolConnectionSchema, @@ -1400,6 +1401,7 @@ const BOARD_ONLY_OPERATIONS = new Set([ "DELETE /api/tool-connections/{connectionId}", "POST /api/tool-connections/{connectionId}/health-check", "POST /api/tool-connections/{connectionId}/reconnect", + "POST /api/tool-connections/{connectionId}/railway/ssh", "POST /api/tool-connections/{connectionId}/catalog/refresh", "GET /api/tool-connections/{connectionId}/catalog", "GET /api/tool-connections/{connectionId}/activity", @@ -10286,6 +10288,23 @@ registerCurrentRoute({ summary: "List the broker services behind a tool connection", }); +registerCurrentRoute({ + method: "post", + path: "/api/tool-connections/{connectionId}/railway/ssh", + tags: ["tool-access"], + summary: "Prepare, enable, or remove a Railway container SSH key", + body: configureRailwaySshSchema, + responses: { + 200: r.ok(), + 400: r.badRequest, + 401: r.unauthorized, + 403: r.forbidden, + 404: r.notFound, + 409: r.conflict, + 422: r.unprocessable, + }, +}); + registerCurrentRoute({ method: "post", path: "/api/tool-connections/{connectionId}/services/{toolkitSlug}/connect", diff --git a/server/src/routes/tool-access.ts b/server/src/routes/tool-access.ts index 8d327bb8ba..34184f288e 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -16,6 +16,7 @@ import { type ToolConnection, type ToolConnectionCreateCapabilities, connectToolAppSchema, + configureRailwaySshSchema, createConnectionGrantDelegationSchema, createToolStdioCommandTemplateSchema, createToolApplicationSchema, @@ -55,6 +56,7 @@ import { getActorInfo, assertBoard, assertCompanyAccess, assertInstanceAdmin, ge import { badRequest, forbidden, HttpError, notFound, unprocessable } from "../errors.js"; import { accessService, logActivity, toolAccessPolicyService, toolAccessService, vercelConnectIntegrationStatus } from "../services/index.js"; import { ToolGatewayHttpError, type ToolGatewayService } from "../services/tool-gateway.js"; +import { RailwayError } from "../services/railway.js"; import type { ComposioClient } from "../services/composio.js"; import type { VercelConnectClient } from "../services/vercel-connect.js"; import { @@ -1679,6 +1681,19 @@ function connectorEnrollmentPrincipal(req: Request): string { res.json(await svc.listComposioServices(connection.id, getActorInfo(req))); }); + router.post("/tool-connections/:connectionId/railway/ssh", validate(configureRailwaySshSchema), async (req, res) => { + assertBoard(req); + const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); + if (!connection) return; + await assertToolConnectionConfigureAccess(req, connection); + const setup = await svc.configureRailwaySsh(connection.id, connection.companyId, req.body, getActorInfo(req)).catch((error) => { + if (error instanceof RailwayError) throw new HttpError(error.status, error.message); + throw error; + }); + await logActivity(db, { companyId: connection.companyId, actorType: "user", actorId: req.actor.userId ?? "board", action: "tool_connection.railway_ssh_updated", entityType: "tool_connection", entityId: connection.id, details: { action: req.body.action, grantId: req.body.grantId, enabled: setup?.enabled ?? false } }); + res.json(setup); + }); + router.post("/tool-connections/:connectionId/services/:toolkitSlug/connect", async (req, res) => { const connection = await getAccessibleResource(req, res, svc.getConnection(req.params.connectionId as string), "Tool connection not found"); if (!connection) return; diff --git a/server/src/services/railway-ssh.ts b/server/src/services/railway-ssh.ts new file mode 100644 index 0000000000..605cc2e227 --- /dev/null +++ b/server/src/services/railway-ssh.ts @@ -0,0 +1,91 @@ +import { execFile, spawn } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { randomBytes } from "node:crypto"; +import { RailwayError, type RailwaySshInput } from "./railway.js"; +import { redactSensitiveText } from "../redaction.js"; + +export const RAILWAY_SSH_SECRET_PATH = "railway.ssh_private_key"; + +export function validateRailwayKnownHosts(value: string): string { + if (value.length > 8192) throw new RailwayError("railway_ssh_host_key_invalid", "The Railway host key is too long.", 400); + const lines = value.trim().split(/\r?\n/); + if (lines.length === 0 || lines.length > 5 || lines.some((line) => !/^ssh\.railway\.com (ssh-ed25519|ssh-rsa|ecdsa-sha2-nistp256) [A-Za-z0-9+/]+={0,2}$/.test(line))) { + throw new RailwayError("railway_ssh_host_key_invalid", "Paste verified known_hosts lines for ssh.railway.com only, without aliases, wildcards or comments.", 400); + } + return lines.join("\n") + "\n"; +} + +export async function generateRailwaySshKey(): Promise<{ publicKey: string; privateKey: string }> { + const directory = await mkdtemp(path.join(tmpdir(), "paperclip-railway-key-")); + try { + const keyPath = path.join(directory, "identity"); + await promisify(execFile)("/usr/bin/ssh-keygen", ["-q", "-t", "ed25519", "-N", "", "-C", "paperclip-railway", "-f", keyPath], { timeout: 10_000, env: { PATH: "/usr/bin:/bin" } }); + return { publicKey: (await readFile(`${keyPath}.pub`, "utf8")).trim(), privateKey: await readFile(keyPath, "utf8") }; + } catch { + throw new RailwayError("railway_ssh_unavailable", "Generating a Railway key requires system OpenSSH (ssh-keygen) on the Paperclip runtime.", 422); + } finally { await rm(directory, { recursive: true, force: true }); } +} + +export function railwaySshArguments(directory: string, instanceId: string): string[] { + if (!/^[a-f0-9-]{36}$/i.test(instanceId)) throw new RailwayError("railway_target_mismatch", "Invalid Railway container instance.", 400); + return [ + "-F", "/dev/null", "-T", "-i", path.join(directory, "identity"), + "-o", "BatchMode=yes", "-o", "IdentitiesOnly=yes", "-o", "IdentityAgent=none", + "-o", "ForwardAgent=no", "-o", "ClearAllForwardings=yes", "-o", "ControlMaster=no", + "-o", "ControlPath=none", "-o", "PermitLocalCommand=no", "-o", "StrictHostKeyChecking=yes", + "-o", `UserKnownHostsFile=${path.join(directory, "known_hosts")}`, "-o", "GlobalKnownHostsFile=/dev/null", + "-o", "ConnectTimeout=10", "-o", "ServerAliveInterval=5", "-o", "ServerAliveCountMax=2", + "--", `${instanceId}@ssh.railway.com`, "sh -s", + ]; +} + +export async function runRailwaySshCommand(input: RailwaySshInput & { privateKey: string; knownHosts: string }) { + input.signal.throwIfAborted(); + const knownHosts = validateRailwayKnownHosts(input.knownHosts); + if (!input.privateKey.startsWith("-----BEGIN OPENSSH PRIVATE KEY-----")) throw new RailwayError("railway_ssh_key_invalid", "Regenerate the Railway connection's SSH key.", 422); + const directory = await mkdtemp(path.join(tmpdir(), "paperclip-railway-command-")); + try { + await writeFile(path.join(directory, "identity"), input.privateKey, { mode: 0o600 }); + await writeFile(path.join(directory, "known_hosts"), knownHosts, { mode: 0o600 }); + input.signal.throwIfAborted(); + return await new Promise<{ exitCode: number | null; stdout: string; stderr: string; truncated: boolean; timedOut: boolean }>((resolve, reject) => { + // No developer SSH config/agent, CLI login, provider token or ambient env. + const child = spawn("/usr/bin/ssh", railwaySshArguments(directory, input.deploymentInstanceId), { env: { PATH: "/usr/bin:/bin", LANG: "C.UTF-8" }, stdio: ["pipe", "pipe", "pipe"] }); + let stdout = "", stderr = "", bytes = 0, truncated = false, timedOut = false, deliveryFailed = false; + const marker = `paperclip_railway_completed_${randomBytes(16).toString("hex")}`; + const stop = () => { child.kill("SIGKILL"); }; + const receive = (chunk: Buffer, stream: "out" | "err") => { + const remaining = Math.max(0, 64 * 1024 - bytes); + bytes += chunk.length; + const text = chunk.subarray(0, remaining).toString("utf8"); + if (stream === "out") stdout += text; else stderr += text; + if (bytes > 64 * 1024) { truncated = true; stop(); } + }; + child.stdout.on("data", (chunk: Buffer) => receive(chunk, "out")); + child.stderr.on("data", (chunk: Buffer) => receive(chunk, "err")); + const timer = setTimeout(() => { timedOut = true; stop(); }, input.timeoutSeconds * 1000); + const abort = () => stop(); + input.signal.addEventListener("abort", abort, { once: true }); + if (input.signal.aborted) abort(); + const cleanup = () => { clearTimeout(timer); input.signal.removeEventListener("abort", abort); }; + child.once("error", () => { cleanup(); reject(new RailwayError("railway_ssh_unavailable", "System OpenSSH is unavailable on this Paperclip runtime.", 422)); }); + child.once("close", (exitCode) => { + cleanup(); + if (input.signal.aborted) { reject(input.signal.reason); return; } + const completion = new RegExp(`\\n${marker}:(\\d+)\\r?\\n?$`).exec(stdout); + if (!truncated && !timedOut && (deliveryFailed || !completion)) { + reject(new RailwayError("railway_ssh_command_unconfirmed", "The container did not confirm command completion. Check SSH key registration, the trusted host key and deployment status before retrying.")); + return; + } + if (completion) stdout = stdout.slice(0, completion.index); + resolve({ exitCode: completion ? Number(completion[1]) : exitCode, stdout: redactSensitiveText(stdout), stderr: redactSensitiveText(stderr), truncated, timedOut }); + }); + child.stdin.on("error", () => { deliveryFailed = true; }); + const quotedCommand = "'" + input.command.replace(/'/g, "'\\''") + "'"; + child.stdin.end(`sh -c ${quotedCommand} ).timeoutSeconds : undefined; + const requested = typeof seconds === "number" && Number.isFinite(seconds) ? seconds : 30; + return Math.min(60_000, (Math.max(1, requested) + 10) * 1000); +} +export const RAILWAY_BLOCKED_TOOLS = new Set([ + "railway-agent", "accept-deploy", + // A separate repository preflight cannot bind serviceInstanceDeployV2 to the + // approved repository. Keep old catalog entries/calls blocked until Railway + // provides an atomic repository + revision mutation. + `${RAILWAY_TOOL_PREFIX}deploy-revision`, +]); +export function normalizeRailwayToolName(name: string): string { + return name.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().replace(/[:._-]+/g, "-"); +} +export function isRailwayToolBlocked(name: string): boolean { + return RAILWAY_BLOCKED_TOOLS.has(normalizeRailwayToolName(name)); +} + +/** Both branding and an exact endpoint are required for the built-in API bridge. */ +export function isRailwayConnection(connection: { + transport: string; authKind: string; credentialSource?: string; + config: Record; +}): boolean { + return connection.transport === "mcp_remote" && connection.authKind === "oauth" + && connection.credentialSource === "paperclip_vault" + && connection.config.sourceTemplateKey === "railway" + && connection.config.connectionMethodKey === "mcp-oauth" + && isRailwayEndpoint(connection.config.url); +} + +export function isRailwayEndpoint(value: unknown): boolean { + return value === RAILWAY_MCP_URL || value === `${RAILWAY_MCP_URL}/`; +} + +const id = z.string().uuid(); +const paging = { first: z.number().int().min(1).max(100).default(25), after: z.string().max(512).optional() }; +const target = { projectId: id, environmentId: id, serviceId: id }; +const deploymentTarget = { ...target, deploymentId: id }; +const timestamp = z.string().datetime({ offset: true }).optional(); +const schema = { + "list-projects": z.object({ workspaceId: id, ...paging }).strict(), + "list-services": z.object({ projectId: id, ...paging }).strict(), + "list-environments": z.object({ projectId: id, ...paging }).strict(), + "service-status": z.object(target).strict(), + "list-deployments": z.object({ ...target, ...paging }).strict(), + "deployment-status": z.object(deploymentTarget).strict(), + "read-logs": z.object({ ...deploymentTarget, kind: z.enum(["build", "runtime"]).default("runtime"), limit: z.number().int().min(1).max(500).default(100), startDate: timestamp, endDate: timestamp, filter: z.string().max(500).optional() }).strict(), + redeploy: z.object(deploymentTarget).strict(), + restart: z.object(deploymentTarget).strict(), + rollback: z.object(deploymentTarget).strict(), + "run-command": z.object({ ...deploymentTarget, deploymentInstanceId: id, command: z.string().min(1).max(8192), timeoutSeconds: z.number().int().min(1).max(60).default(30) }).strict(), +}; +type Operation = keyof typeof schema; +const titles: Record = { + "list-projects": "List projects (direct)", + "list-services": "List services (direct)", + "list-environments": "List environments", + "service-status": "Get service status", + "list-deployments": "List deployments", + "deployment-status": "Get deployment status", + "read-logs": "Read deployment logs", + redeploy: "Redeploy a deployment", + restart: "Restart a deployment", + rollback: "Roll back to a deployment", + "run-command": "Run a container command", +}; +const descriptions: Record = { + "list-projects": "List Railway projects in an explicit authorized workspace, with bounded pagination. Use the hosted list-workspaces action to find workspace IDs.", + "list-services": "List services in one Railway project. Use service-status to inspect a specific environment.", + "list-environments": "List environments in one Railway project.", + "service-status": "Inspect a service's running and latest deployments in an explicit project and environment.", + "list-deployments": "List deployments for one explicit project, environment, and service.", + "deployment-status": "Inspect an exact deployment and its container instance IDs.", + "read-logs": "Read at most 500 build or runtime log lines for an exact deployment. Output is bounded; logs may contain sensitive application data.", + redeploy: "Redeploy an exact deployment using its previous image. This changes a running service.", + restart: "Restart an exact deployment without rebuilding. This interrupts a running service.", + rollback: "Roll back to an exact eligible deployment. This changes a running service.", + "run-command": "Run a bounded noninteractive shell command in an exact deployed container using this connection's configured SSH key. Broad privileged access: commands can read secrets and mutate application data. Requires Container access setup. Timeout closes SSH; remote child termination is not guaranteed.", +}; +const reads = new Set(["list-projects", "list-services", "list-environments", "service-status", "list-deployments", "deployment-status", "read-logs"]); + +export const RAILWAY_TOOLS = Object.entries(schema).map(([operation, validator]) => ({ + name: `${RAILWAY_TOOL_PREFIX}${operation}`, + title: titles[operation as Operation], + description: descriptions[operation as Operation], + inputSchema: z.toJSONSchema(validator, { target: "draft-7", io: "input" }) as Record, + annotations: { readOnlyHint: reads.has(operation as Operation), destructiveHint: !reads.has(operation as Operation), idempotentHint: reads.has(operation as Operation), openWorldHint: true }, +})); + +export function railwayRisk(name: string): "read" | "write" | "destructive" { + name = normalizeRailwayToolName(name); + if (isRailwayToolBlocked(name)) return "destructive"; + const operation = name.slice(RAILWAY_TOOL_PREFIX.length) as Operation; + if (name.startsWith(RAILWAY_TOOL_PREFIX) && operation in schema) return reads.has(operation) ? "read" : "destructive"; + if (["whoami", "list-projects", "list-services", "list-feature-flags", "get-feature-flag"].includes(name)) return "read"; + if (["redeploy", "accept-deploy", "railway-agent", "delete-feature-flag"].includes(name)) return "destructive"; + return "write"; +} + +export class RailwayError extends Error { + constructor(readonly code: string, message: string, readonly status = 502) { super(message); this.name = "RailwayError"; } +} + +export interface RailwaySshInput { + deploymentInstanceId: string; command: string; timeoutSeconds: number; signal: AbortSignal; +} +export interface RailwayClientOptions { + authorization: string; + request: (url: string, init: RequestInit) => Promise; + signal: AbortSignal; + runCommand?: (input: RailwaySshInput) => Promise; +} + +const pageInfo = "pageInfo { hasNextPage endCursor }"; +const deploymentFields = "id projectId environmentId serviceId status createdAt url canRedeploy canRollback"; +const instanceFields = "id environmentId serviceId serviceName source { repo } latestDeployment { id status } activeDeployments { id status }"; + +/** All query documents are authored here. Caller input is only ever variables. */ +export const RAILWAY_QUERIES = { + projects: `query PaperclipRailwayProjects($workspaceId:String!,$first:Int!,$after:String) { projects(workspaceId:$workspaceId,first:$first,after:$after) { edges { node { id name workspaceId } } ${pageInfo} } }`, + services: `query PaperclipRailwayServices($projectId:String!,$first:Int!,$after:String) { project(id:$projectId) { id services(first:$first,after:$after) { edges { node { id name projectId } } ${pageInfo} } } }`, + environments: `query PaperclipRailwayEnvironments($projectId:String!,$first:Int!,$after:String) { project(id:$projectId) { id environments(first:$first,after:$after) { edges { node { id name projectId } } ${pageInfo} } } }`, + target: `query PaperclipRailwayTarget($projectId:String!,$environmentId:String!,$serviceId:String!) { project(id:$projectId) { id } environment(id:$environmentId) { id projectId } service(id:$serviceId) { id projectId } serviceInstance(environmentId:$environmentId,serviceId:$serviceId) { ${instanceFields} } }`, + deployment: `query PaperclipRailwayDeployment($deploymentId:String!) { deployment(id:$deploymentId) { ${deploymentFields} instances { id } } }`, + deployments: `query PaperclipRailwayDeployments($input:DeploymentListInput!,$first:Int!,$after:String) { deployments(input:$input,first:$first,after:$after) { edges { node { ${deploymentFields} } } ${pageInfo} } }`, + buildLogs: `query PaperclipRailwayBuildLogs($deploymentId:String!,$limit:Int!,$startDate:DateTime,$endDate:DateTime,$filter:String) { buildLogs(deploymentId:$deploymentId,limit:$limit,startDate:$startDate,endDate:$endDate,filter:$filter) { timestamp message severity } }`, + runtimeLogs: `query PaperclipRailwayRuntimeLogs($deploymentId:String!,$limit:Int!,$startDate:DateTime,$endDate:DateTime,$filter:String) { deploymentLogs(deploymentId:$deploymentId,limit:$limit,startDate:$startDate,endDate:$endDate,filter:$filter) { timestamp message severity } }`, + redeploy: `mutation PaperclipRailwayRedeploy($deploymentId:String!) { deploymentRedeploy(id:$deploymentId,usePreviousImageTag:true) { id status } }`, + restart: `mutation PaperclipRailwayRestart($deploymentId:String!) { deploymentRestart(id:$deploymentId) }`, + rollback: `mutation PaperclipRailwayRollback($deploymentId:String!) { deploymentRollback(id:$deploymentId) }`, +}; + +function record(value: unknown): Record { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +async function boundedResponseText(response: Response, signal: AbortSignal): Promise { + const reader = response.body?.getReader(); + if (!reader) throw new RailwayError("railway_invalid_response", "Railway returned an empty response."); + const chunks: Uint8Array[] = []; + let size = 0; + try { + for (;;) { + signal.throwIfAborted(); + const { done, value } = await reader.read(); + if (done) break; + size += value.byteLength; + if (size > 1024 * 1024) throw new RailwayError("railway_output_limit", "Railway's response exceeded the limit. Request fewer results or a shorter log interval."); + chunks.push(value); + } + } finally { await reader.cancel().catch(() => {}); } + return Buffer.concat(chunks).toString("utf8"); +} + +/** Discover a consented workspace without requesting account-wide API access. */ +export async function discoverRailwayWorkspace(options: RailwayClientOptions): Promise { + const send = (init: RequestInit) => options.request(RAILWAY_MCP_URL, { ...init, redirect: "error", signal: options.signal }); + const headers = { Authorization: options.authorization }; + const list = (requestHeaders: Record) => send({ + method: "POST", headers: mcpHttpRequestHeaders(requestHeaders), + body: JSON.stringify({ jsonrpc: "2.0", id: "paperclip-railway-workspace-probe", method: "tools/call", params: { name: "list-workspaces", arguments: {} } }), + }); + let response = await list(headers); + if (response.status === 400) { + await response.body?.cancel(); + response = await list(await initializeMcpHttpSession({ send, headers, requestId: "paperclip-railway-workspace-probe" })); + } + if (!response.ok) { + await response.body?.cancel(); + throw new RailwayError("railway_workspace_discovery_failed", "Railway's hosted connection is connected, but workspace access could not be checked. Refresh actions to try again."); + } + const body = await boundedResponseText(response, options.signal); + let data: Record; + try { + const payload = record(parseMcpHttpResponseBody(body, response.headers.get("content-type"))); + const result = record(payload.result); + if (payload.error || result.isError) throw new Error("Workspace discovery failed"); + data = record(result.structuredContent ?? JSON.parse(result.content?.find((item: any) => item.type === "text")?.text ?? "{}")); + } catch { throw new RailwayError("railway_workspace_discovery_failed", "Railway could not list authorized workspaces. Refresh actions or reconnect and select a workspace."); } + const workspaceId = Array.isArray(data.workspaces) ? data.workspaces.find((workspace) => id.safeParse(workspace?.id).success)?.id : undefined; + if (!workspaceId) throw new RailwayError("railway_workspace_required", "No authorized Railway workspace was found. Reconnect Railway and select a workspace to enable direct operations.", 403); + return workspaceId; +} + +export function createRailwayClient(options: RailwayClientOptions) { + if (!/^Bearer [^\r\n]+$/.test(options.authorization)) throw new RailwayError("railway_authorization_required", "Reconnect Railway to authorize API access.", 401); + const secret = options.authorization.slice(7); + const redact = (value: unknown) => JSON.parse(redactSensitiveText(JSON.stringify(value).split(secret).join("[REDACTED]"))); + + async function query(document: string, variables: Record): Promise> { + options.signal.throwIfAborted(); + let response: Response; + try { + response = await options.request(RAILWAY_API_URL, { method: "POST", redirect: "error", signal: options.signal, headers: { "content-type": "application/json", Authorization: options.authorization }, body: JSON.stringify({ query: document, variables }) }); + } catch (error) { + if (options.signal.aborted) throw options.signal.reason; + throw new RailwayError("railway_request_failed", "Railway could not be reached. A deployment request may have succeeded; inspect deployment status before retrying."); + } + if (response.status === 401 || response.status === 403) { + await response.body?.cancel(); + throw new RailwayError("railway_api_authorization_required", "Railway rejected API access. Reconnect with access to the required workspace or project. Hosted connection tokens are used only if Railway accepts them for API access.", response.status); + } + if (!response.ok) { + await response.body?.cancel(); + throw new RailwayError(response.status === 429 ? "railway_rate_limited" : "railway_api_unavailable", response.status === 429 ? "Railway is rate limiting requests. Wait before trying again." : "Railway is unavailable. Check deployment status before retrying a deployment operation."); + } + const body = await boundedResponseText(response, options.signal); + let payload: Record; + try { payload = record(JSON.parse(body)); } + catch { throw new RailwayError("railway_invalid_response", "Railway returned an invalid API response."); } + if (payload.errors) { + // Provider errors can echo variables, credentials or application secrets. + if (Array.isArray(payload.errors) && payload.errors.some((error) => ["UNAUTHENTICATED", "FORBIDDEN"].includes(error?.extensions?.code) || ["Not Authorized", "Unauthorized", "Forbidden"].includes(error?.message))) { + throw new RailwayError("railway_api_authorization_required", "Railway denied this API request. Use IDs from a workspace selected during consent, or reconnect to grant access to the required workspace.", 403); + } + throw new RailwayError("railway_api_error", "Railway could not complete the request. Check target IDs, resource permissions, and deployment eligibility. Inspect status before retrying a mutation."); + } + if (!payload.data || typeof payload.data !== "object") throw new RailwayError("railway_invalid_response", "Railway returned no API data."); + return payload.data; + } + + async function validateTarget(args: Record) { + const data = await query(RAILWAY_QUERIES.target, { projectId: args.projectId, environmentId: args.environmentId, serviceId: args.serviceId }); + if (data.project?.id !== args.projectId || data.environment?.id !== args.environmentId || data.environment?.projectId !== args.projectId || data.service?.id !== args.serviceId || data.service?.projectId !== args.projectId || data.serviceInstance?.environmentId !== args.environmentId || data.serviceInstance?.serviceId !== args.serviceId) { + throw new RailwayError("railway_target_mismatch", "The service and environment do not belong to the selected Railway project.", 403); + } + return data.serviceInstance; + } + + async function validateDeployment(args: Record) { + const data = await query(RAILWAY_QUERIES.deployment, { deploymentId: args.deploymentId }); + const d = record(data.deployment); + if (d.id !== args.deploymentId || d.projectId !== args.projectId || d.environmentId !== args.environmentId || d.serviceId !== args.serviceId) throw new RailwayError("railway_target_mismatch", "The deployment does not belong to the selected Railway target.", 403); + return d; + } + + return { + async probe(workspaceId: string) { + if (!id.safeParse(workspaceId).success) throw new RailwayError("railway_workspace_required", "Choose an authorized Railway workspace before checking API access.", 400); + await query(RAILWAY_QUERIES.projects, { workspaceId, first: 1 }); + }, + async call(name: string, parameters: unknown): Promise { + if (isRailwayToolBlocked(name)) throw new RailwayError("railway_action_blocked", "This Railway action cannot bind its effects to an approved target. Use redeploy, restart, or rollback for an existing deployment.", 403); + const operation = name.slice(RAILWAY_TOOL_PREFIX.length) as Operation; + if (!name.startsWith(RAILWAY_TOOL_PREFIX) || !Object.hasOwn(schema, operation)) throw new RailwayError("railway_unknown_tool", "Unknown Railway operation.", 400); + const parsed = schema[operation].safeParse(parameters); + if (!parsed.success) throw new RailwayError("railway_invalid_arguments", "Invalid Railway operation arguments. Use the exact IDs and limits in the action schema.", 400); + const args = parsed.data as Record; + let result: unknown; + if (operation === "list-projects") result = await query(RAILWAY_QUERIES.projects, args); + else if (operation === "list-services" || operation === "list-environments") result = await query(operation === "list-services" ? RAILWAY_QUERIES.services : RAILWAY_QUERIES.environments, args); + else { + const instance = await validateTarget(args); + const deployment = args.deploymentId ? await validateDeployment(args) : null; + switch (operation) { + case "service-status": result = instance; break; + case "deployment-status": result = deployment; break; + case "list-deployments": result = await query(RAILWAY_QUERIES.deployments, { input: { projectId: args.projectId, environmentId: args.environmentId, serviceId: args.serviceId }, first: args.first, after: args.after }); break; + case "read-logs": { + if (args.startDate && args.endDate && Date.parse(args.startDate) > Date.parse(args.endDate)) throw new RailwayError("railway_invalid_arguments", "Log start time must precede end time.", 400); + const data = await query(args.kind === "build" ? RAILWAY_QUERIES.buildLogs : RAILWAY_QUERIES.runtimeLogs, { deploymentId: args.deploymentId, limit: args.limit, startDate: args.startDate, endDate: args.endDate, filter: args.filter }); + const lines = data[args.kind === "build" ? "buildLogs" : "deploymentLogs"]; + if (!Array.isArray(lines)) throw new RailwayError("railway_invalid_response", "Railway returned invalid log data."); + let bytes = 0; + let messageTruncated = false; + const bounded = []; + for (const line of lines.slice(0, args.limit)) { + const message = String(line.message ?? ""); + if (message.length > 8192) messageTruncated = true; + const safe = redact({ timestamp: line.timestamp, severity: line.severity, message: message.slice(0, 8192) }); + bytes += Buffer.byteLength(JSON.stringify(safe)); + if (bytes > 64 * 1024) break; + bounded.push(safe); + } + result = { deploymentId: args.deploymentId, kind: args.kind, lines: bounded, truncated: messageTruncated || bounded.length < lines.length, limitReached: lines.length >= args.limit }; break; + } + case "redeploy": + if (!deployment?.canRedeploy) throw new RailwayError("railway_deployment_ineligible", "Railway does not allow this deployment to be redeployed.", 409); + result = await query(RAILWAY_QUERIES.redeploy, { deploymentId: args.deploymentId }); + if (!id.safeParse(record(record(result).deploymentRedeploy).id).success) throw new RailwayError("railway_operation_unconfirmed", "Railway did not confirm a resulting deployment. Inspect deployment status before retrying."); + break; + case "restart": + result = await query(RAILWAY_QUERIES.restart, { deploymentId: args.deploymentId }); + if (record(result).deploymentRestart !== true) throw new RailwayError("railway_operation_unconfirmed", "Railway did not confirm the restart. Inspect deployment status before retrying."); + result = { ...record(result), targetDeploymentId: args.deploymentId }; + break; + case "rollback": + if (!deployment?.canRollback) throw new RailwayError("railway_deployment_ineligible", "Railway does not allow rollback to this deployment.", 409); + result = await query(RAILWAY_QUERIES.rollback, { deploymentId: args.deploymentId }); + if (record(result).deploymentRollback !== true) throw new RailwayError("railway_operation_unconfirmed", "Railway did not confirm the rollback. Inspect deployment status before retrying."); + result = { ...record(result), targetDeploymentId: args.deploymentId }; + break; + case "run-command": + if (!Array.isArray(deployment?.instances) || !deployment.instances.some((entry: { id: string }) => entry.id === args.deploymentInstanceId) || deployment.status !== "SUCCESS") throw new RailwayError("railway_target_mismatch", "The container instance is not part of the selected running deployment.", 403); + if (!options.runCommand) throw new RailwayError("railway_ssh_setup_required", "Configure Container access on this Railway connection before running commands.", 422); + result = await options.runCommand({ deploymentInstanceId: args.deploymentInstanceId, command: args.command, timeoutSeconds: args.timeoutSeconds, signal: options.signal }); break; + } + } + return redact(result ?? null); + }, + }; +} diff --git a/server/src/services/tool-access.ts b/server/src/services/tool-access.ts index 4cac2ca499..9ff39a5531 100644 --- a/server/src/services/tool-access.ts +++ b/server/src/services/tool-access.ts @@ -205,6 +205,9 @@ import { } from "./remote-url-credentials.js"; import { secretService } from "./secrets.js"; import { agentmailApi } from "./agentmail-api.js"; +import type { ConfigureRailwaySsh, RailwaySshSetup } from "@paperclipai/shared"; +import { generateRailwaySshKey, RAILWAY_SSH_SECRET_PATH, validateRailwayKnownHosts } from "./railway-ssh.js"; +import { createRailwayClient, discoverRailwayWorkspace, isRailwayConnection, isRailwayEndpoint, isRailwayToolBlocked, normalizeRailwayToolName, RAILWAY_TOOLS, RAILWAY_TOOL_PREFIX, railwayRisk, RailwayError } from "./railway.js"; import { toolAccessPolicyService } from "./tool-access-policy.js"; import { readSignedToolArgumentsPayload, @@ -2312,6 +2315,10 @@ export function classifyRisk( if (annotations.destructiveHint === true || annotations.destructive === true) return "destructive"; const normalizedToolName = normalizedProviderToolName(tool.name); + if (sourceTemplateKey === "railway") { + const reviewed = railwayRisk(normalizedToolName); + return reviewed === "read" && (annotations.readOnlyHint === false || annotations.writeHint === true) ? "write" : reviewed; + } if (sourceTemplateKey === "posthog" && normalizedToolName === "exec") return "destructive"; if ( @@ -5453,7 +5460,9 @@ export function toolAccessService( const [updated] = await dbClient .update(connectionGrants) .set({ - credentialSecretRefs: connection.credentialSecretRefs, + credentialSecretRefs: isRailwayConnection(connection) + ? [...connection.credentialSecretRefs, ...existing.credentialSecretRefs.filter((ref) => ref.configPath === RAILWAY_SSH_SECRET_PATH && !connection.credentialSecretRefs.some((candidate) => candidate.configPath === ref.configPath))] + : connection.credentialSecretRefs, status: "active", revokedAt: null, revokedByAgentId: null, @@ -6947,9 +6956,34 @@ export function toolAccessService( : Array.isArray(payloadTools) ? payloadTools : []; - return tools + const descriptors = tools .map((tool) => normalizeToolDescriptor(tool)) .filter((tool): tool is McpToolDescriptor => Boolean(tool)); + if (!isRailwayConnection(connection)) return descriptors; + if (descriptors.some((tool) => normalizeRailwayToolName(tool.name).startsWith(RAILWAY_TOOL_PREFIX))) { + throw unprocessable("Railway advertised a reserved Paperclip action name. Refresh is blocked pending review.", { code: "railway_tool_name_collision" }); + } + let apiStatus = "available"; + let apiMessage = "Direct Railway service, log, and deployment tools are available."; + try { + const railwayOptions = { + authorization: headers.Authorization ?? "", + request: (url: string, init: RequestInit) => requestRemoteHttpEndpoint(new URL(url), init), + signal: AbortSignal.timeout(15_000), + }; + const workspaceId = await discoverRailwayWorkspace(railwayOptions); + await createRailwayClient(railwayOptions).probe(workspaceId); + } catch (error) { + apiStatus = "unavailable"; + apiMessage = error instanceof RailwayError ? error.message : "Railway API access could not be verified. Refresh actions or reconnect Railway."; + } + // API interoperability is verified with the actual credential; the presence + // of an OAuth token alone never enables the additional capability surface. + const nextConfig = { ...connection.config, railwayApiStatus: apiStatus, railwayApiMessage: apiMessage }; + await db.update(toolConnections).set({ config: nextConfig, transportConfig: nextConfig, updatedAt: now() }).where(and(eq(toolConnections.id, connection.id), eq(toolConnections.companyId, connection.companyId))); + connection.config = nextConfig; + connection.transportConfig = nextConfig; + return apiStatus === "available" ? [...descriptors, ...RAILWAY_TOOLS] : descriptors; } async function localTools( @@ -7729,6 +7763,15 @@ export function toolAccessService( const existingByName = new Map( existingRows.map((entry) => [entry.toolName, entry]), ); + // Retired native actions are absent from discovery, but old catalog rows + // still need to show as disabled. Gateway denial also applies before refresh. + const blockedRailwayEntryIds = isRailwayEndpoint(connection.config.url) + ? existingRows.filter((entry) => isRailwayToolBlocked(entry.toolName)).map((entry) => entry.id) + : []; + if (blockedRailwayEntryIds.length > 0) { + await db.update(toolCatalogEntries).set({ status: "disabled", updatedAt: refreshedAt }) + .where(and(eq(toolCatalogEntries.connectionId, connection.id), inArray(toolCatalogEntries.id, blockedRailwayEntryIds))); + } const updatedEntries: ToolCatalogEntry[] = []; let quarantinedCount = 0; const sourceTemplateKey = @@ -7755,14 +7798,15 @@ export function toolAccessService( ? googleProfileValue : null; const quarantineOnRefresh = - !refreshOptions.enableAllByDefault && + (!refreshOptions.enableAllByDefault || (isRailwayEndpoint(connection.config.url) && existingRows.length > 0)) && shouldQuarantineNewEntries(connection) && (connection.status === "active" || + (isRailwayEndpoint(connection.config.url) && existingRows.length > 0) || sourceTemplateKey === "posthog" || refreshOptions.quarantineManagedOAuthDraft === true); const safeDefault = asRecord(connection.config).safeDefault === true; for (const descriptor of descriptors) { - const riskLevel = classifyRisk(descriptor, sourceTemplateKey); + const riskLevel = classifyRisk(descriptor, isRailwayEndpoint(connection.config.url) ? "railway" : sourceTemplateKey); const hash = descriptorHash(descriptor, riskLevel); const schemaHash = stableHash(descriptor.inputSchema ?? {}); const existing = existingByName.get(descriptor.name); @@ -7774,11 +7818,11 @@ export function toolAccessService( (!existing || changed) && existing?.status !== "disabled" && (!safeDefault || riskLevel !== "read"); - const googlePermanentlyBlocked = Boolean( + const providerPermanentlyBlocked = Boolean( googleProfile && !isGoogleWorkspaceToolAllowed(googleProfile, descriptor), - ); - const status = googlePermanentlyBlocked + ) || (isRailwayEndpoint(connection.config.url) && isRailwayToolBlocked(descriptor.name)); + const status = providerPermanentlyBlocked ? "disabled" : shouldQuarantine ? "quarantined" @@ -7787,7 +7831,7 @@ export function toolAccessService( : quarantineOnRefresh && existing?.status === "quarantined" ? "quarantined" : "active"; - if (shouldQuarantine && !googlePermanentlyBlocked) quarantinedCount += 1; + if (shouldQuarantine && !providerPermanentlyBlocked) quarantinedCount += 1; if (existing) { const [updated] = await db @@ -7853,10 +7897,14 @@ export function toolAccessService( } } - const normalizedConfig = refreshOptions.enableAllByDefault + const normalizedConfig = isRailwayEndpoint(connection.config.url) + ? { ...connection.config, quarantineNewEntries: true } + : refreshOptions.enableAllByDefault ? { ...connection.config, quarantineNewEntries: false } : connection.config; - const normalizedTransportConfig = refreshOptions.enableAllByDefault + const normalizedTransportConfig = isRailwayEndpoint(connection.config.url) + ? { ...connection.transportConfig, quarantineNewEntries: true } + : refreshOptions.enableAllByDefault ? { ...connection.transportConfig, quarantineNewEntries: false } : connection.transportConfig; const [updatedConnection] = await db @@ -9032,7 +9080,7 @@ export function toolAccessService( function oauthSecretRef( connection: typeof toolConnections.$inferSelect, configPath: - "oauth.access_token" | "oauth.refresh_token" | "oauth.client_secret", + "oauth.access_token" | "oauth.refresh_token" | "oauth.client_secret" | "railway.ssh_private_key", ) { return ( connection.credentialSecretRefs.find( @@ -9471,7 +9519,7 @@ export function toolAccessService( companyId: string; connection: typeof toolConnections.$inferSelect; configPath: - "oauth.access_token" | "oauth.refresh_token" | "oauth.client_secret"; + "oauth.access_token" | "oauth.refresh_token" | "oauth.client_secret" | "railway.ssh_private_key"; label: string; value: string; actor?: ActorInfo; @@ -12658,7 +12706,7 @@ export function toolAccessService( // Grant-backed setup keeps the full discovered catalog selectable; // the wizard projects the app's action defaults into policies at // finish time instead of using catalog quarantine as access state. - quarantineNewEntries: false, + quarantineNewEntries: galleryEntry.slug === "railway", ...(galleryEntry.slug === "posthog" ? { safeDefault: true } : {}), } : { ...baseConfig, quarantineNewEntries: false, unverifiedServer: true }; @@ -18564,6 +18612,42 @@ export function toolAccessService( refreshCatalog, + configureRailwaySsh: async (connectionId: string, companyId: string, input: ConfigureRailwaySsh, actor: ActorInfo): Promise => { + const knownHosts = input.action === "enable" ? validateRailwayKnownHosts(input.knownHosts) : ""; + const setup = await db.transaction(async (tx) => { + const [connection] = await tx.select().from(toolConnections).where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId))).for("update"); + if (!connection || !isRailwayConnection(connection) || (connection.status !== "active" && input.action !== "remove")) throw unprocessable("Connect Railway before configuring container access."); + const [grant] = await tx.select().from(connectionGrants).where(and(eq(connectionGrants.id, input.grantId), eq(connectionGrants.connectionId, connectionId), eq(connectionGrants.companyId, companyId))).for("update"); + if (!grant || (grant.status !== "active" && input.action !== "remove")) throw unprocessable("Select an active Railway authorization."); + if (grant.kind === "user" && (actor.actorType !== "user" || actor.actorId !== grant.subjectUserId)) throw forbidden("Only this authorization's owner can configure its SSH key."); + const existing = asRecord(connection.config.railwaySsh); + if (existing.grantId && existing.grantId !== grant.id) throw conflict("Remove the existing container key before choosing another authorization."); + const currentRef = grant.credentialSecretRefs.find((ref) => ref.configPath === RAILWAY_SSH_SECRET_PATH); + let next: RailwaySshSetup | null = null; + let refs = grant.credentialSecretRefs; + if (input.action === "remove") { + if (currentRef) await secretService(tx).remove(currentRef.secretId); + refs = refs.filter((ref) => ref.configPath !== RAILWAY_SSH_SECRET_PATH); + } else if (input.action === "prepare") { + if (currentRef && typeof existing.publicKey === "string") return existing as unknown as RailwaySshSetup; + const key = await generateRailwaySshKey(); + const ref = await createOrRotateOAuthSecret({ companyId, connection, configPath: RAILWAY_SSH_SECRET_PATH, label: "Railway container SSH key", value: key.privateKey, existingRefs: [], ownerUserId: grant.kind === "user" ? grant.subjectUserId ?? undefined : undefined, actor }, { dbClient: tx, secretClient: secretService(tx) }); + refs = [...refs.filter((ref) => ref.configPath !== RAILWAY_SSH_SECRET_PATH), ref]; + next = { grantId: grant.id, publicKey: key.publicKey, knownHosts: "", enabled: false }; + } else { + if (!currentRef || typeof existing.publicKey !== "string") throw unprocessable("Generate and register a container key first."); + next = { grantId: grant.id, publicKey: existing.publicKey, knownHosts, enabled: true }; + } + await tx.update(connectionGrants).set({ credentialSecretRefs: refs, updatedAt: now() }).where(and(eq(connectionGrants.id, grant.id), eq(connectionGrants.companyId, companyId))); + const config = { ...connection.config, railwaySsh: next }; + const [updated] = await tx.update(toolConnections).set({ config, transportConfig: config, updatedAt: now() }).where(and(eq(toolConnections.id, connectionId), eq(toolConnections.companyId, companyId))).returning(); + await syncCredentialBindings(updated, [], tx); + return next; + }); + await audit({ companyId, connectionId, action: "tool_connection.railway_ssh_updated", outcome: "success", actor, details: { action: input.action, grantId: input.grantId, enabled: setup?.enabled ?? false } }); + return setup; + }, + listAppsNeedingAttention, sweepConnectionHealth, diff --git a/server/src/services/tool-gateway.ts b/server/src/services/tool-gateway.ts index 42c2c0d8bc..9c0c67e85c 100644 --- a/server/src/services/tool-gateway.ts +++ b/server/src/services/tool-gateway.ts @@ -85,6 +85,8 @@ import type { } from "./plugin-tool-dispatcher.js"; import { logActivity, type LogActivityInput } from "./activity-log.js"; import { secretService } from "./secrets.js"; +import { railwayCommandBudgetMs, createRailwayClient, isRailwayConnection, isRailwayEndpoint, isRailwayToolBlocked, normalizeRailwayToolName, RAILWAY_API_URL, RAILWAY_TOOL_PREFIX, RailwayError } from "./railway.js"; +import { RAILWAY_SSH_SECRET_PATH, runRailwaySshCommand } from "./railway-ssh.js"; import { initializeMcpHttpSession, mcpHttpRequestHeaders, @@ -378,7 +380,7 @@ type RemoteHttpExecutionResult = { type RemoteHttpExecutionAudit = { transport: "mcp_remote"; request: { - protocol: "MCP JSON-RPC 2.0"; + protocol: "MCP JSON-RPC 2.0" | "Railway GraphQL" | "Railway GraphQL + SSH"; httpMethod: "POST"; endpoint: string; mcpMethod: "tools/call"; @@ -1218,11 +1220,12 @@ export function createToolGatewayService( .orderBy(toolConnections.name, toolCatalogEntries.name); const eligibleRows = rows.filter( - ({ connection, application }) => - (connection.transport === "mcp_remote" && + ({ catalogEntry, connection, application }) => + !(isRailwayEndpoint(connection.config.url) && (isRailwayToolBlocked(catalogEntry.toolName) || (normalizeRailwayToolName(catalogEntry.toolName).startsWith(RAILWAY_TOOL_PREFIX) && connection.config.railwayApiStatus !== "available"))) && + ((connection.transport === "mcp_remote" && application.type === "mcp_http") || (connection.transport === "local_stdio" && - application.type === "mcp_stdio"), + application.type === "mcp_stdio")), ); const baseNames = eligibleRows.map( ({ catalogEntry, connection, application }) => { @@ -4672,6 +4675,9 @@ export function createToolGatewayService( }, ); } + if (isRailwayEndpoint(connection.config.url) && isRailwayToolBlocked(entry.toolName)) { + throw new ToolGatewayHttpError(403, "This Railway action cannot bind its effects to an approved target. Use redeploy, restart, or rollback for an existing deployment.", "railway_action_blocked"); + } return { entry, connection }; } @@ -5741,11 +5747,15 @@ export function createToolGatewayService( ms: number, invocationId: string, callerHeaders?: ExecuteGatewayToolInput["callerHeaders"], + useDefaultTimeout = false, ): Promise { const { entry, connection } = await resolveConnectedRemoteTool( session, tool, ); + if (useDefaultTimeout && isRailwayConnection(connection) && entry.toolName === `${RAILWAY_TOOL_PREFIX}run-command`) { + ms = railwayCommandBudgetMs(parameters); + } const grant = await resolveConnectionGrant(session, connection); const composioScopeRevision = `${grant.id}:${grant.status}:${grant.updatedAt.toISOString()}`; const composioChild = composioChildConfig(connection); @@ -5802,6 +5812,35 @@ export function createToolGatewayService( // letting the tighter default cut a legitimately slow tool short. responseTimeoutMs: ms, }); + if (isRailwayEndpoint(connection.config.url) && normalizeRailwayToolName(entry.toolName).startsWith(RAILWAY_TOOL_PREFIX)) { + if (!isRailwayConnection(connection) || connection.config.railwayApiStatus !== "available") { + throw new ToolGatewayHttpError(422, "Railway API access is not verified. Refresh actions or reconnect this Railway connection.", "railway_api_not_verified"); + } + const ssh = asRecord(connection.config.railwaySsh); + const sshRef = grant.credentialSecretRefs.find((ref) => ref.configPath === RAILWAY_SSH_SECRET_PATH); + execution.request.endpoint = RAILWAY_API_URL; + execution.request.protocol = entry.toolName === `${RAILWAY_TOOL_PREFIX}run-command` ? "Railway GraphQL + SSH" : "Railway GraphQL"; + const client = createRailwayClient({ + authorization: credentialHeaders.Authorization ?? "", + signal: controller.signal, + request: dispatchRemote, + runCommand: ssh?.grantId === grant.id && ssh?.enabled === true && sshRef + ? async (input) => runRailwaySshCommand({ + ...input, + privateKey: await resolveGrantSecretValue(session, connection, grant, sshRef), + knownHosts: typeof ssh.knownHosts === "string" ? ssh.knownHosts : "", + }) + : undefined, + }); + const data = await client.call(entry.toolName, parameters); + const record = asRecord(data); + const failedCommand = entry.toolName === `${RAILWAY_TOOL_PREFIX}run-command` && (record?.exitCode !== 0 || record?.timedOut === true || record?.truncated === true); + return { + result: normalizeMcpToolResult({ content: [{ type: "text", text: JSON.stringify(data) }], structuredContent: data, isError: failedCommand }, "mcp_http", entry.toolName === `${RAILWAY_TOOL_PREFIX}run-command`, "railway"), + headerSummary, + execution, + }; + } let requestHeaders = headers; if (connection.config.mcpSessionRequired === true) { requestHeaders = await initializeMcpHttpSession({ @@ -6078,6 +6117,9 @@ export function createToolGatewayService( ); return { result, headerSummary, execution }; } catch (error) { + if (error instanceof RailwayError) { + throw new ToolGatewayHttpError(error.status, error.message, error.code, { connectionId: connection.id, catalogEntryId: entry.id, execution }); + } if (error instanceof ToolGatewayHttpError) { throw new ToolGatewayHttpError( error.status, @@ -6979,6 +7021,8 @@ export function createToolGatewayService( args.parameters, executionTimeoutMs, args.invocationId, + undefined, + args.timeoutMs === undefined, ) : args.tool.providerType === "mcp_local_stdio" ? await executeLocalStdioTool( @@ -10212,6 +10256,7 @@ export function createToolGatewayService( executionTimeoutMs, invocationId, input.callerHeaders, + input.timeoutMs === undefined, ) : tool.providerType === "mcp_local_stdio" ? await executeLocalStdioTool( diff --git a/tests/e2e/railway-catalog.spec.ts b/tests/e2e/railway-catalog.spec.ts new file mode 100644 index 0000000000..0c6bad1d2d --- /dev/null +++ b/tests/e2e/railway-catalog.spec.ts @@ -0,0 +1,19 @@ +import { expect, test } from "@playwright/test"; + +// Uses the normal throwaway E2E instance. This checks the local setup entry, +// not account consent or a live Railway deployment. +test("Railway is discoverable and opens its OAuth setup", async ({ page, request }) => { + test.setTimeout(120000); + const response = await request.post("/api/companies", { data: { name: `Railway catalog QA ${Date.now()}` } }); + expect(response.ok()).toBe(true); + const company = await response.json(); + await page.goto(`/${company.issuePrefix}/apps`, { waitUntil: "domcontentloaded" }); + const card = page.getByRole("list", { name: "Connector list" }).getByRole("listitem").filter({ has: page.getByRole("heading", { name: "Railway", exact: true }) }); + await expect(card).toBeVisible({ timeout: 30000 }); + await card.getByRole("button", { name: /Connect/ }).click(); + await expect(page).toHaveURL(/\/apps\/connect\?/, { timeout: 20000 }); + await expect(page.getByRole("heading", { name: /Railway/ }).first()).toBeVisible(); + await expect(page.getByText(/Project tokens are not supported/)).toBeVisible(); + await expect(page.getByText(/Live Railway qualification is pending/)).toBeVisible(); + await page.screenshot({ path: "tests/e2e/test-results/railway-oauth-setup.png", fullPage: true }); +}); diff --git a/ui/public/brands/apps/manifest.json b/ui/public/brands/apps/manifest.json index 719f77dcfc..afe72336f7 100644 --- a/ui/public/brands/apps/manifest.json +++ b/ui/public/brands/apps/manifest.json @@ -459,6 +459,13 @@ "aliases": [ "jam" ] + }, + { + "slug": "railway", + "provider": "Railway", + "catalogVisible": true, + "localAsset": "/brands/apps/railway.svg", + "darkAsset": "/brands/apps/railway-dark.svg" } ] } diff --git a/ui/public/brands/apps/railway-dark.svg b/ui/public/brands/apps/railway-dark.svg new file mode 100644 index 0000000000..7adff91a97 --- /dev/null +++ b/ui/public/brands/apps/railway-dark.svg @@ -0,0 +1 @@ + diff --git a/ui/public/brands/apps/railway.svg b/ui/public/brands/apps/railway.svg new file mode 100644 index 0000000000..9460643f25 --- /dev/null +++ b/ui/public/brands/apps/railway.svg @@ -0,0 +1 @@ + diff --git a/ui/src/api/tools.ts b/ui/src/api/tools.ts index 4217bd6615..c6050a1c7e 100644 --- a/ui/src/api/tools.ts +++ b/ui/src/api/tools.ts @@ -6,6 +6,8 @@ import type { } from "@/pages/apps/composio-services"; import type { ToolApplication, + ConfigureRailwaySsh, + RailwaySshSetup, ToolConnection, ToolConnectionInstall, ToolConnectionInstallSnapshot, @@ -405,6 +407,8 @@ export const toolsApi = { api.post(`/companies/${companyId}/tools/connections`, input), updateConnection: (connectionId: string, input: UpdateToolConnectionInput) => api.patch(`/tool-connections/${connectionId}`, input), + configureRailwaySsh: (connectionId: string, input: ConfigureRailwaySsh) => + api.post(`/tool-connections/${connectionId}/railway/ssh`, input), // Removal is a credential-revoking teardown (PAP-17119), so the response // carries the cleanup receipt alongside the archived connection. archiveConnection: (connectionId: string, options: { confirmComposioChildren?: boolean } = {}) => diff --git a/ui/src/features/connections/ConnectionSetupFlow.tsx b/ui/src/features/connections/ConnectionSetupFlow.tsx index dbf7b7620f..618b4c0aab 100644 --- a/ui/src/features/connections/ConnectionSetupFlow.tsx +++ b/ui/src/features/connections/ConnectionSetupFlow.tsx @@ -2358,6 +2358,15 @@ export function ConnectionSetupFlow({ )} {step === "access" && ( + <> + {entry?.slug === "railway" && ( +
+

{accessStepMethod?.guidanceMd}

+
    + {accessStepMethod?.warnings?.map((warning) =>
  • {warning}
  • )} +
+
+ )} + )} {step === "success" && ( diff --git a/ui/src/lib/app-gallery-copy.ts b/ui/src/lib/app-gallery-copy.ts index 19ff687697..fb253b23c0 100644 --- a/ui/src/lib/app-gallery-copy.ts +++ b/ui/src/lib/app-gallery-copy.ts @@ -66,6 +66,10 @@ const APP_COPY: Record = { tagline: "Read and update pages in your workspace.", short: "Read and update pages in your workspace.", }, + railway: { + tagline: "Inspect services, read logs, and manage deployments.", + short: "Connect Railway for deployments, logs, and container access.", + }, posthog: { tagline: "Explore product usage, errors, flags, and experiments.", short: "Sign in with PostHog. Project pinning and access controls are optional.", diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx index 45328fbefe..ae948b0fc7 100644 --- a/ui/src/pages/apps/AppDetail.tsx +++ b/ui/src/pages/apps/AppDetail.tsx @@ -48,6 +48,7 @@ import { ServicesPanel } from "./app-detail/ServicesPanel"; import { ConnectionProvenanceChip } from "./ComposioProvenanceChip"; import { IdentitiesSection } from "./app-detail/IdentitiesSection"; import { PermissionsPanel } from "./app-detail/PermissionsPanel"; +import { RailwayAccessPanel } from "./app-detail/RailwayAccessPanel"; import { ReviewPanel } from "./app-detail/ReviewPanel"; import { ReconnectCard, @@ -579,6 +580,7 @@ export function AppDetail({ renderActions, onReconnect }: { : permissionsLoading ? :
+ {connection.config?.sourceTemplateKey === "railway" && } {connection.config?.provider === "agentmail" && } {connection.config?.provider === "agentmail" ? : <> ({ configure: vi.fn(async () => null) })); +vi.mock("@/api/tools", () => ({ toolsApi: { configureRailwaySsh: configure } })); +let root: Root | undefined; +let container: HTMLDivElement; +afterEach(async () => { if (root) await act(async () => root?.unmount()); container?.remove(); vi.clearAllMocks(); }); +async function render(status: string, canConfigure = true, owner = "operator") { + container = document.createElement("div"); document.body.append(container); root = createRoot(container); + const connection = { id: "connection", status: "disabled", config: { railwaySsh: { grantId: "grant", publicKey: "ssh-ed25519 public-fixture", knownHosts: "", enabled: false } } } as unknown as ToolConnection; + const grants = { currentUserId: "operator", capabilities: { canConfigure }, grants: [{ id: "grant", kind: "user", subjectUserId: owner, status }] } as unknown as ConnectionGrantsResponse; + await act(async () => root!.render()); +} +describe("Railway container setup", () => { + it("allows an owner to remove a revoked key without reauthorizing", async () => { + await render("revoked"); + const remove = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Remove container key")!; + const enable = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Enable container access")!; + expect(remove.disabled).toBe(false); + expect(enable.disabled).toBe(true); + await act(async () => remove.click()); + expect(configure).toHaveBeenCalledWith("connection", { action: "remove", grantId: "grant" }); + }); + it("does not show credential controls for another personal owner", async () => { + await render("active", true, "another-user"); + expect(container.querySelectorAll("button")).toHaveLength(0); + }); + it("requires connection configuration access for key changes", async () => { + await render("revoked", false); + expect(container.querySelectorAll("button")).toHaveLength(0); + }); +}); diff --git a/ui/src/pages/apps/app-detail/RailwayAccessPanel.tsx b/ui/src/pages/apps/app-detail/RailwayAccessPanel.tsx new file mode 100644 index 0000000000..364c47b023 --- /dev/null +++ b/ui/src/pages/apps/app-detail/RailwayAccessPanel.tsx @@ -0,0 +1,69 @@ +import { useEffect, useId, useState } from "react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import type { ConfigureRailwaySsh, ConnectionGrantsResponse, RailwaySshSetup, ToolConnection } from "@paperclipai/shared"; +import { toolsApi } from "@/api/tools"; +import { queryKeys } from "@/lib/queryKeys"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { Textarea } from "@/components/ui/textarea"; + +export function RailwayAccessPanel({ connection, grants }: { connection: ToolConnection; grants?: ConnectionGrantsResponse }) { + const queryClient = useQueryClient(); + const id = useId(); + const setup = connection.config?.railwaySsh as RailwaySshSetup | null | undefined; + const owned = (grants?.grants ?? []).filter((grant) => grant.kind !== "user" || grant.subjectUserId === grants?.currentUserId); + const eligible = owned.filter((grant) => grant.status === "active"); + const [selectedGrant, setSelectedGrant] = useState(""); + const [knownHosts, setKnownHosts] = useState(setup?.knownHosts ?? ""); + useEffect(() => { setKnownHosts(setup?.knownHosts ?? ""); }, [setup?.knownHosts]); + const grantId = setup?.grantId ?? (selectedGrant || eligible[0]?.id); + const canConfigure = grants?.capabilities.canConfigure && connection.status === "active" && eligible.some((grant) => grant.id === grantId); + const canRemove = grants?.capabilities.canConfigure && owned.some((grant) => grant.id === setup?.grantId); + const mutation = useMutation({ + mutationFn: (input: ConfigureRailwaySsh) => toolsApi.configureRailwaySsh(connection.id, input), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connection.id) }); + await queryClient.invalidateQueries({ queryKey: queryKeys.tools.connectionGrants(connection.id) }); + }, + }); + return
+
+

Railway operations

+

+ {typeof connection.config?.railwayApiMessage === "string" ? connection.config?.railwayApiMessage : "Refresh actions after connecting to check service, log, and deployment access."} +

+
+
+

Container access

+

To allow Paperclip direct SSH access to Railway containers, you can optionally generate an SSH key pair. Railway SSH documentation

+
+ {!canConfigure &&

The connection manager and authorization owner can configure container access.

} + {(canConfigure || canRemove) && grantId && <> + {!setup && eligible.length > 1 &&
+ + +
} + {!setup && } + {setup && <> +
+ +