From 5d459199b034906657434aefa9f496a676ccbd5b Mon Sep 17 00:00:00 2001 From: Devin Foley Date: Mon, 14 Sep 2026 07:17:51 -0700 Subject: [PATCH] feat(apps): add governed Railway operations and container access Co-Authored-By: Paperclip --- doc/connections/RAILWAY-REVIEW.md | 144 ++++++++ doc/connections/RAILWAY.md | 204 ++++++++++++ doc/plans/2026-09-13-railway-runtime.md | 34 ++ .../shared/src/app-definitions.generated.ts | 139 ++++---- packages/shared/src/app-definitions.test.ts | 23 +- 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 | 168 ++++++++++ server/src/__tests__/railway-ssh.test.ts | 69 ++++ server/src/__tests__/railway.test.ts | 149 +++++++++ .../src/__tests__/tool-access-service.test.ts | 2 +- server/src/routes/tool-access.ts | 15 + server/src/services/railway-ssh.ts | 91 +++++ server/src/services/railway.ts | 315 ++++++++++++++++++ server/src/services/tool-access.ts | 101 +++++- server/src/services/tool-gateway.ts | 53 ++- tests/e2e/railway-catalog.spec.ts | 19 ++ ui/public/brands/apps/manifest.json | 11 + 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 ++++ 30 files changed, 1730 insertions(+), 95 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..ee3caece31 --- /dev/null +++ b/doc/connections/RAILWAY-REVIEW.md @@ -0,0 +1,144 @@ +# Railway implementation verification + +Updated: 2026-09-14. Implementation and local verification were performed on +2026-09-13. The change is being published for review on a dedicated branch. +Live qualification and a green full suite remain open. + +## 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, + redeploy/restart/rollback, and deployment of an immutable Git revision. +- 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 + +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. +- Provider repository reconfiguration can race a source-deployment preflight. +- 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..b224341514 --- /dev/null +++ b/doc/connections/RAILWAY.md @@ -0,0 +1,204 @@ +# Railway + +Updated: 2026-09-14. 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` | Existing service repository, exact 40-character Git SHA, explicit environment/service | Destructive | +| `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/source deployment +return 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. + +## 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 uses only an already connected repository and immutable +commit. A concurrent provider repository reconfiguration can race the preflight; +Railway's API does not expose an atomic repository/revision binding for this call. + +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 are sanitized from Railway's own homepage inline SVG; provenance is +in `ui/public/brands/apps/manifest.json`. + +## 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. `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. 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. + +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..03f4866c95 --- /dev/null +++ b/doc/plans/2026-09-13-railway-runtime.md @@ -0,0 +1,34 @@ +# Railway direct operations and container runtime review + +Date: 2026-09-13. Scope: local preview authorized by the operator, without push. + +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 binds a configured repository and immutable commit, with the +provider reconfiguration race documented in RAILWAY.md. + +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 37cbc57045..89c1e324c5 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), ); @@ -698,7 +699,7 @@ describe("AppDefinition catalog", () => { expect(storeSlugs.has(slug), slug).toBe(false); } }); - it("ships complete local branding provenance for all 46 store-visible providers", () => { + it("ships complete local branding provenance for all 47 store-visible providers", () => { const uiPublic = path.resolve( path.dirname(fileURLToPath(import.meta.url)), "../../../ui/public", @@ -718,14 +719,14 @@ describe("AppDefinition catalog", () => { }>; }; const visible = manifest.providers.filter((entry) => entry.catalogVisible); - expect(visible).toHaveLength(46); + expect(visible).toHaveLength(47); expect(new Set(visible.map((entry) => entry.slug))).toHaveProperty( "size", - 46, + 47, ); expect(new Set(visible.map((entry) => entry.localAsset))).toHaveProperty( "size", - 46, + 47, ); expect(new Set(APP_STORE_DEFINITIONS.map((entry) => entry.slug))).toEqual( new Set(visible.map((entry) => entry.slug)), @@ -987,3 +988,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 6b17bf735e..ed3994505e 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -2763,3 +2763,4 @@ 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"; 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..522b16a416 --- /dev/null +++ b/server/src/__tests__/railway-connection.test.ts @@ -0,0 +1,168 @@ +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("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..f54093333d --- /dev/null +++ b/server/src/__tests__/railway.test.ts @@ -0,0 +1,149 @@ +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(12); + }); + + 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("binds source deployments to the current repository and immutable commit", async () => { + const f = fixture((q) => q === RAILWAY_QUERIES.deploy ? Response.json({ data: { serviceInstanceDeployV2: instanceId } }) : undefined); + const { deploymentId: _, ...ids } = target; + await expect(f.client.call("paperclip-railway-deploy-revision", { ...ids, repository: "other/repo", commitSha: "a".repeat(40) })).rejects.toMatchObject({ code: "railway_repository_mismatch" }); + await expect(f.client.call("paperclip-railway-deploy-revision", { ...ids, repository: "example/app", commitSha: "main" })).rejects.toMatchObject({ code: "railway_invalid_arguments" }); + await expect(f.client.call("paperclip-railway-deploy-revision", { ...ids, repository: "example/app", commitSha: "a".repeat(40) })).resolves.toMatchObject({ deploymentId: instanceId }); + }); + + 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 48b694029f..bbac58d4f4 100644 --- a/server/src/__tests__/tool-access-service.test.ts +++ b/server/src/__tests__/tool-access-service.test.ts @@ -5069,7 +5069,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/tool-access.ts b/server/src/routes/tool-access.ts index fffa85af70..10d56b8a7f 100644 --- a/server/src/routes/tool-access.ts +++ b/server/src/routes/tool-access.ts @@ -15,6 +15,7 @@ import { type ToolConnection, type ToolConnectionCreateCapabilities, connectToolAppSchema, + configureRailwaySshSchema, createConnectionGrantDelegationSchema, createToolStdioCommandTemplateSchema, createToolApplicationSchema, @@ -54,6 +55,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 { @@ -1669,6 +1671,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"]); +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(), + "deploy-revision": z.object({ ...target, repository: z.string().regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/), commitSha: z.string().regex(/^[a-f0-9]{40}$/i) }).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", + "deploy-revision": "Deploy a Git revision", + "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.", + "deploy-revision": "Deploy an immutable Git commit from the service's already-connected GitHub repository. Specify the exact repository, revision and target.", + "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); + 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) }`, + deploy: `mutation PaperclipRailwayDeployRevision($environmentId:String!,$serviceId:String!,$commitSha:String!) { serviceInstanceDeployV2(environmentId:$environmentId,serviceId:$serviceId,commitSha:$commitSha) }`, +}; + +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 { + 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 "deploy-revision": + if (record(instance.source).repo !== args.repository) throw new RailwayError("railway_repository_mismatch", "The repository does not match the service's configured source.", 409); + { + const deploymentId = (await query(RAILWAY_QUERIES.deploy, { environmentId: args.environmentId, serviceId: args.serviceId, commitSha: args.commitSha })).serviceInstanceDeployV2; + if (!id.safeParse(deploymentId).success) throw new RailwayError("railway_invalid_response", "Railway did not confirm a resulting deployment ID. Inspect deployment status before retrying."); + result = { deploymentId, commitSha: args.commitSha }; + } + 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 0192c64e6b..037c2780f4 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 ( @@ -5449,7 +5456,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, @@ -6943,9 +6952,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( @@ -7741,14 +7775,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); @@ -7760,11 +7795,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" @@ -7773,7 +7808,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 @@ -7839,10 +7874,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 @@ -9018,7 +9057,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( @@ -9457,7 +9496,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; @@ -12644,7 +12683,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 }; @@ -18475,6 +18514,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..a4902de84f 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 be individually governed. Use the dedicated deployment actions.", "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 45e49aa069..37e0088c4e 100644 --- a/ui/public/brands/apps/manifest.json +++ b/ui/public/brands/apps/manifest.json @@ -708,6 +708,17 @@ "upstreamAssetUrl": "https://framerusercontent.com/images/XbFmp7b9ze39qL1GdqVbmJXbiS8.png?height=512&width=512", "assetType": "png", "darkVariantRequired": false + }, + { + "slug": "railway", + "provider": "Railway", + "catalogVisible": true, + "localAsset": "/brands/apps/railway.svg", + "darkAsset": "/brands/apps/railway-dark.svg", + "officialSourceUrl": "https://railway.com", + "upstreamAssetUrl": "https://railway.com", + "assetType": "svg", + "darkVariantRequired": true } ] } 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 && <> +
+ +