mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-06 10:48:12 +02:00
feat(apps): add governed Railway operations and container access
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d351e08dee
commit
5d459199b0
30 files changed
+1730
-95
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
@@ -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[];
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -10,6 +10,7 @@ export const CONNECTABLE_APP_SLUGS = new Set([
|
||||
"zapier",
|
||||
"slack",
|
||||
"notion",
|
||||
"railway",
|
||||
"posthog",
|
||||
"linear",
|
||||
"google-sheets",
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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<typeof configureRailwaySshSchema>;
|
||||
export interface RailwaySshSetup {
|
||||
grantId: string;
|
||||
publicKey: string;
|
||||
knownHosts: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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 }] },
|
||||
};
|
||||
@@ -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<typeof createDb>;
|
||||
let temp: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>>;
|
||||
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" });
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import("node:child_process")>(), 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<typeof vi.fn> }, 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("</dev/null");
|
||||
const marker = script.match(/paperclip_railway_completed_[a-f0-9]+/)![0];
|
||||
child.stdout.write(`hello\n${marker}:7\n`);
|
||||
child.emit("close", 7);
|
||||
});
|
||||
await expect(runRailwaySshCommand(input())).resolves.toMatchObject({ exitCode: 7, stdout: "hello", timedOut: false, truncated: false });
|
||||
const args = spawnMock.mock.calls[0][1] as string[];
|
||||
const keyPath = args[args.indexOf("-i") + 1];
|
||||
await expect(access(path.dirname(keyPath))).rejects.toThrow();
|
||||
expect(spawnMock.mock.calls[0][2].env).toEqual({ PATH: "/usr/bin:/bin", LANG: "C.UTF-8" });
|
||||
});
|
||||
it("never treats local success or stdin failure as confirmed remote success", async () => {
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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} </dev/null\npaperclip_command_status=$?\nprintf '\\n${marker}:%d\\n' "$paperclip_command_status"\nexit "$paperclip_command_status"\n`);
|
||||
});
|
||||
} finally { await rm(directory, { recursive: true, force: true }); }
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
import { z } from "zod";
|
||||
import { redactSensitiveText } from "../redaction.js";
|
||||
import { initializeMcpHttpSession, mcpHttpRequestHeaders, parseMcpHttpResponseBody } from "./mcp-http.js";
|
||||
|
||||
export const RAILWAY_MCP_URL = "https://mcp.railway.com";
|
||||
export const RAILWAY_API_URL = "https://backboard.railway.com/graphql/v2";
|
||||
export const RAILWAY_TOOL_PREFIX = "paperclip-railway-";
|
||||
/** Reserve time for target checks while staying inside the gateway's 60s cap. */
|
||||
export function railwayCommandBudgetMs(parameters: unknown): number {
|
||||
const seconds = parameters && typeof parameters === "object" ? (parameters as Record<string, unknown>).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<string, unknown>;
|
||||
}): 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<Operation, string> = {
|
||||
"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<Operation, string> = {
|
||||
"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<Operation>(["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<string, unknown>,
|
||||
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<Response>;
|
||||
signal: AbortSignal;
|
||||
runCommand?: (input: RailwaySshInput) => Promise<unknown>;
|
||||
}
|
||||
|
||||
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<string, any> {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, any> : {};
|
||||
}
|
||||
|
||||
async function boundedResponseText(response: Response, signal: AbortSignal): Promise<string> {
|
||||
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<string> {
|
||||
const send = (init: RequestInit) => options.request(RAILWAY_MCP_URL, { ...init, redirect: "error", signal: options.signal });
|
||||
const headers = { Authorization: options.authorization };
|
||||
const list = (requestHeaders: Record<string, string>) => 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<string, any>;
|
||||
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<string, unknown>): Promise<Record<string, any>> {
|
||||
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<string, any>;
|
||||
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<string, any>) {
|
||||
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<string, any>) {
|
||||
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<unknown> {
|
||||
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<string, any>;
|
||||
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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<RailwaySshSetup | null> => {
|
||||
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,
|
||||
|
||||
@@ -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<RemoteHttpExecutionResult> {
|
||||
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(
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="67 15 144 144" role="img" aria-label="Railway"><path d="M204.443 116.854C193.075 141.7 168.049 158.997 138.924 158.997C90.2988 158.262 73.4029 117.029 73.3311 116.854H204.443Z" fill="#FFFFFF"></path><path d="M138.924 15C178.732 15.0003 211.001 47.2522 211 87.0293C210.993 94.7072 209.756 102.334 207.332 109.62H70.835C69.9423 106.835 69.3467 104.312 69.3467 104.312H169.343C175.731 104.311 181.006 101.043 183.458 95.5635C186.122 89.6007 184.994 83.3758 181.076 77.9619C176.838 72.1086 169.728 63.8922 164.805 60.1699C155.733 53.3073 144.205 52.3 134.825 51.9873L132.133 51.8877C127.784 51.7062 126.956 51.6064 104.693 51.6064V51.6152C104.693 51.6152 85.856 51.6272 76.1846 51.6475C88.5599 29.7872 111.994 15 138.924 15Z" fill="#FFFFFF"></path><path d="M177.241 91.1748C176.416 94.3667 173.877 96.9404 169.343 96.9404H67.6152C67.3529 95.0449 67.171 93.1222 67.0557 91.1748H177.241Z" fill="#FFFFFF"></path><path d="M104.673 58.8369C123.768 58.8369 127.022 58.915 131.819 59.1123C146.208 59.726 156.269 57.6306 174.968 81.7881C175.485 82.4465 175.998 83.122 176.38 83.8682H67C67.1026 81.4421 67.325 79.0211 67.668 76.6172H125.163V69.3164H69.1123C69.77 66.3355 71.1219 62.6702 72.5225 58.8896C83.342 58.8608 94.4528 58.8369 104.673 58.8369Z" fill="#FFFFFF"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="67 15 144 144" role="img" aria-label="Railway"><path d="M204.443 116.854C193.075 141.7 168.049 158.997 138.924 158.997C90.2988 158.262 73.4029 117.029 73.3311 116.854H204.443Z" fill="#100F13"></path><path d="M138.924 15C178.732 15.0003 211.001 47.2522 211 87.0293C210.993 94.7072 209.756 102.334 207.332 109.62H70.835C69.9423 106.835 69.3467 104.312 69.3467 104.312H169.343C175.731 104.311 181.006 101.043 183.458 95.5635C186.122 89.6007 184.994 83.3758 181.076 77.9619C176.838 72.1086 169.728 63.8922 164.805 60.1699C155.733 53.3073 144.205 52.3 134.825 51.9873L132.133 51.8877C127.784 51.7062 126.956 51.6064 104.693 51.6064V51.6152C104.693 51.6152 85.856 51.6272 76.1846 51.6475C88.5599 29.7872 111.994 15 138.924 15Z" fill="#100F13"></path><path d="M177.241 91.1748C176.416 94.3667 173.877 96.9404 169.343 96.9404H67.6152C67.3529 95.0449 67.171 93.1222 67.0557 91.1748H177.241Z" fill="#100F13"></path><path d="M104.673 58.8369C123.768 58.8369 127.022 58.915 131.819 59.1123C146.208 59.726 156.269 57.6306 174.968 81.7881C175.485 82.4465 175.998 83.122 176.38 83.8682H67C67.1026 81.4421 67.325 79.0211 67.668 76.6172H125.163V69.3164H69.1123C69.77 66.3355 71.1219 62.6702 72.5225 58.8896C83.342 58.8608 94.4528 58.8369 104.673 58.8369Z" fill="#100F13"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
@@ -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<ToolConnection>(`/companies/${companyId}/tools/connections`, input),
|
||||
updateConnection: (connectionId: string, input: UpdateToolConnectionInput) =>
|
||||
api.patch<ToolConnection>(`/tool-connections/${connectionId}`, input),
|
||||
configureRailwaySsh: (connectionId: string, input: ConfigureRailwaySsh) =>
|
||||
api.post<RailwaySshSetup | null>(`/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 } = {}) =>
|
||||
|
||||
@@ -2358,6 +2358,15 @@ export function ConnectionSetupFlow({
|
||||
)}
|
||||
|
||||
{step === "access" && (
|
||||
<>
|
||||
{entry?.slug === "railway" && (
|
||||
<div className="mb-6 space-y-3 text-sm text-muted-foreground">
|
||||
<p>{accessStepMethod?.guidanceMd}</p>
|
||||
<ul className="list-disc space-y-2 pl-5">
|
||||
{accessStepMethod?.warnings?.map((warning) => <li key={warning}>{warning}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
<AccessStep
|
||||
companyId={selectedCompanyId}
|
||||
authKind={accessStepAuthKind}
|
||||
@@ -2391,6 +2400,7 @@ export function ConnectionSetupFlow({
|
||||
else setStep("key");
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "success" && (
|
||||
|
||||
@@ -66,6 +66,10 @@ const APP_COPY: Record<string, AppCopy> = {
|
||||
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.",
|
||||
|
||||
@@ -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
|
||||
? <ToolsLoading />
|
||||
: <div className="space-y-10">
|
||||
{connection.config?.sourceTemplateKey === "railway" && <RailwayAccessPanel connection={connection} grants={grantsQuery.data} />}
|
||||
{connection.config?.provider === "agentmail" && <EmailConnectionInboxes companyId={connection.companyId} connectionId={connection.id} canConfigure={grantsQuery.data?.capabilities?.canConfigure ?? false} />}
|
||||
{connection.config?.provider === "agentmail" ? <EmailConnectionAccess companyId={connection.companyId} connectionId={connection.id} agents={agents} /> : <>
|
||||
<IdentitiesSection
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// @vitest-environment jsdom
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ConnectionGrantsResponse, ToolConnection } from "@paperclipai/shared";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { RailwayAccessPanel } from "./RailwayAccessPanel";
|
||||
const { configure } = vi.hoisted(() => ({ 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(<QueryClientProvider client={new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } } })}><RailwayAccessPanel connection={connection} grants={grants} /></QueryClientProvider>));
|
||||
}
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 <section className="space-y-4" aria-labelledby={`${id}-title`}>
|
||||
<div className="space-y-2">
|
||||
<h2 id={`${id}-title`} className="text-lg font-semibold">Railway operations</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{typeof connection.config?.railwayApiMessage === "string" ? connection.config?.railwayApiMessage : "Refresh actions after connecting to check service, log, and deployment access."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h3 className="font-medium">Container access</h3>
|
||||
<p className="text-sm text-muted-foreground">To allow Paperclip direct SSH access to Railway containers, you can optionally generate an SSH key pair. <a className="underline" href="https://docs.railway.com/cli/ssh" target="_blank" rel="noreferrer">Railway SSH documentation</a></p>
|
||||
</div>
|
||||
{!canConfigure && <p className="text-sm text-muted-foreground">The connection manager and authorization owner can configure container access.</p>}
|
||||
{(canConfigure || canRemove) && grantId && <>
|
||||
{!setup && eligible.length > 1 && <div className="space-y-2">
|
||||
<Label htmlFor={`${id}-grant`}>Authorization</Label>
|
||||
<select id={`${id}-grant`} className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm" value={grantId} onChange={(event) => setSelectedGrant(event.target.value)}>
|
||||
{eligible.map((grant) => <option key={grant.id} value={grant.id}>{grant.kind === "organization" ? "Shared account" : grant.kind === "user" ? "My account" : "Agent account"}</option>)}
|
||||
</select>
|
||||
</div>}
|
||||
{!setup && <Button variant="outline" disabled={mutation.isPending} onClick={() => mutation.mutate({ action: "prepare", grantId })}>Generate SSH key pair</Button>}
|
||||
{setup && <>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-public`}>Public key</Label>
|
||||
<Textarea id={`${id}-public`} readOnly value={setup.publicKey} className="font-mono text-xs" />
|
||||
<p className="text-sm text-muted-foreground">Register this public key in the Railway account used by this authorization. The private key stays in Paperclip’s vault. <a className="underline" href="https://docs.railway.com/cli/ssh#manage-ssh-keys" target="_blank" rel="noreferrer">Railway key setup</a></p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`${id}-host`}>Verified Railway host key</Label>
|
||||
<Textarea id={`${id}-host`} value={knownHosts} onChange={(event) => setKnownHosts(event.target.value)} placeholder="ssh.railway.com ssh-ed25519 …" className="font-mono text-xs" />
|
||||
<p className="text-sm text-muted-foreground">Paste the ssh.railway.com line from a known_hosts entry you have independently verified. Paperclip refuses untrusted or changed host keys.</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button disabled={!canConfigure || mutation.isPending || !knownHosts.trim()} onClick={() => mutation.mutate({ action: "enable", grantId, knownHosts })}>{setup.enabled ? "Update trusted host key" : "Enable container access"}</Button>
|
||||
<Button variant="outline" disabled={mutation.isPending} onClick={() => mutation.mutate({ action: "remove", grantId })}>Remove container key</Button>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{setup.enabled ? "Container access is enabled for this authorization." : "Register the public key and verify the host key before enabling access."} Removing the key stops new Paperclip connections. Also remove its public key from Railway.</p>
|
||||
</>}
|
||||
</>}
|
||||
{mutation.isError && <p role="alert" className="text-sm text-destructive">{mutation.error instanceof Error ? mutation.error.message : "Container access could not be updated."}</p>}
|
||||
</section>;
|
||||
}
|
||||
Reference in new issue
Block a user