From da6c839ccea7db63c58b13f603c264116b815056 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:48:25 +0800 Subject: [PATCH 1/2] fix: configure Cloud provider MCP access --- .mcp.json | 8 +- docs/codex-cloud.md | 20 +++-- scripts/check-codex-cloud-setup.mjs | 114 ++++++++++++++++++++++++---- scripts/setup-codex-cloud.sh | 5 ++ tests/codex-cloud-setup.test.ts | 85 +++++++++++++++++++-- 5 files changed, 204 insertions(+), 28 deletions(-) diff --git a/.mcp.json b/.mcp.json index 0a21718142..8031e3a019 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,8 +1,12 @@ { "mcpServers": { "railway": { - "command": "npx", - "args": ["-y", "@railway/cli@5.30.1", "mcp"] + "type": "http", + "url": "https://mcp.railway.com" + }, + "supabase": { + "type": "http", + "url": "https://mcp.supabase.com/mcp?project_ref=sjrfecxgysukkwxsowpy&read_only=true&features=docs%2Cdatabase%2Cdebugging%2Cdevelopment" } } } diff --git a/docs/codex-cloud.md b/docs/codex-cloud.md index b3383d43b0..54bdcc308e 100644 --- a/docs/codex-cloud.md +++ b/docs/codex-cloud.md @@ -191,11 +191,21 @@ in the offline profile. Provider access is verified separately because a generic bootstrap must not make paid or production-like calls. For a connected environment, name each provider, use a read-only or minimal no-op endpoint, confirm the intended account/project by non-secret metadata, and -report cost or mutation risk before any write. Railway `whoami` and `status --json` require both -the CLI and the dedicated `RAILWAY_API_TOKEN`; never use project-scoped `RAILWAY_TOKEN` as a -fallback. Reduce Railway JSON to authenticated/project/workspace identity before output. OpenAI -generation, Supabase live data, Railway changes, hosted CI reruns, ingestion, deployment, and -release workflows remain separate explicit actions. +report cost or mutation risk before any write. The checked-in MCP configuration uses Railway's +hosted `https://mcp.railway.com` endpoint so fresh Cloud tasks authenticate through browser OAuth +instead of depending on machine-local CLI state. Authorize only workspace `bigsimmo's Projects` +and project `Database` (`5deaad0b-675a-4c13-978e-5ca2b5b877f9`), restart the MCP client after +consent, and reduce identity/status results to non-secret account, project, workspace, environment, +and service metadata. Railway's remote MCP does not accept project tokens; retain the pinned CLI +only for explicitly approved local/operator workflows. + +The Supabase MCP entry is scoped to production project `sjrfecxgysukkwxsowpy`, forces +`read_only=true`, and exposes only documentation, database, debugging, and development feature +groups. Complete its browser OAuth flow for the organization containing `Clinical KB Database` +and restart the client if tools do not appear. Schema writes, Edge Function deployment, branching, +and storage mutations require a separately configured non-production project or branch; do not +broaden the production entry. OpenAI generation, Supabase live data, Railway changes, hosted CI +reruns, ingestion, deployment, and release workflows remain separate explicit actions. ## Authenticated live testing diff --git a/scripts/check-codex-cloud-setup.mjs b/scripts/check-codex-cloud-setup.mjs index 0ca6205e20..06223a5801 100644 --- a/scripts/check-codex-cloud-setup.mjs +++ b/scripts/check-codex-cloud-setup.mjs @@ -19,6 +19,13 @@ export const expectedCloudCliVersions = Object.freeze({ codex: "0.146.0", }); +export const expectedMcpConfiguration = Object.freeze({ + railwayUrl: "https://mcp.railway.com/", + supabaseUrl: "https://mcp.supabase.com/mcp", + supabaseProjectRef: "sjrfecxgysukkwxsowpy", + supabaseFeatures: Object.freeze(["database", "debugging", "development", "docs"]), +}); + export const providerCredentialVariables = Object.freeze([ ...providerEnvironmentKeys, "RAILWAY_API_TOKEN", @@ -114,18 +121,85 @@ export function parseMcpServerMetadata(text) { if (!servers || Array.isArray(servers) || typeof servers !== "object") { throw new Error(".mcp.json must contain an mcpServers object."); } - return Object.entries(servers).map(([name, server]) => ({ - name, - command: typeof server?.command === "string" ? server.command : "invalid", - environmentNames: - server?.env && !Array.isArray(server.env) && typeof server.env === "object" ? Object.keys(server.env).sort() : [], - })); + return Object.entries(servers).map(([name, server]) => { + let endpoint = "none"; + let queryNames = []; + if (typeof server?.url === "string") { + const url = new URL(server.url); + endpoint = `${url.origin}${url.pathname}`; + queryNames = [...url.searchParams.keys()].sort(); + } + return { + name, + type: typeof server?.type === "string" ? server.type : typeof server?.command === "string" ? "stdio" : "invalid", + command: typeof server?.command === "string" ? server.command : "none", + endpoint, + queryNames, + environmentNames: + server?.env && !Array.isArray(server.env) && typeof server.env === "object" + ? Object.keys(server.env).sort() + : [], + }; + }); +} + +export function validateMcpConfiguration(text) { + const errors = []; + let parsed; + try { + parsed = JSON.parse(text); + } catch (error) { + return [`.mcp.json is invalid JSON: ${error instanceof Error ? error.message : String(error)}`]; + } + const servers = parsed?.mcpServers; + if (!servers || Array.isArray(servers) || typeof servers !== "object") { + return [".mcp.json must contain an mcpServers object."]; + } + + const railway = servers.railway; + if (railway?.type !== "http" || railway?.url !== expectedMcpConfiguration.railwayUrl.replace(/\/$/, "")) { + errors.push("Railway MCP must use the hosted OAuth endpoint."); + } + + const supabase = servers.supabase; + if (supabase?.type !== "http" || typeof supabase?.url !== "string") { + errors.push("Supabase MCP must use the hosted HTTP endpoint."); + return errors; + } + try { + const url = new URL(supabase.url); + if (`${url.origin}${url.pathname}` !== expectedMcpConfiguration.supabaseUrl) { + errors.push("Supabase MCP must use the official hosted endpoint."); + } + if (url.searchParams.get("project_ref") !== expectedMcpConfiguration.supabaseProjectRef) { + errors.push("Supabase MCP must be scoped to the expected project."); + } + if (url.searchParams.get("read_only") !== "true") { + errors.push("Supabase MCP must keep the production project read-only."); + } + const features = (url.searchParams.get("features") ?? "").split(",").filter(Boolean).sort(); + if (JSON.stringify(features) !== JSON.stringify(expectedMcpConfiguration.supabaseFeatures)) { + errors.push("Supabase MCP must expose only the approved read-only feature groups."); + } + } catch { + errors.push("Supabase MCP URL must be valid."); + } + return errors; } function approvedModeValue(value, allowed) { return allowed.includes(value) ? value : "invalid"; } +export function codexCloudValidationScope({ runtime = false, environment = false, browserInstallSkipped = false }) { + if (runtime && browserInstallSkipped) { + return "static, environment, and source-only runtime (browser validation skipped)"; + } + if (runtime) return "static, environment, and runtime"; + if (environment) return "static and environment"; + return "static"; +} + function commandAvailable(command) { return spawnSync(command, ["--version"], { encoding: "utf8", shell: false }).status === 0; } @@ -156,13 +230,13 @@ export function sanitizedCloudCapabilityLines(env = process.env, options = {}) { lines.push(`git.github_cli_helper_configured=${safeGitHelper}`); for (const server of mcpServers) { lines.push( - `mcp.server=${server.name} command=${server.command} environment_names=${server.environmentNames.join(",") || "none"}`, + `mcp.server=${server.name} type=${server.type} command=${server.command} endpoint=${server.endpoint} query_names=${server.queryNames.join(",") || "none"} environment_names=${server.environmentNames.join(",") || "none"}`, ); } return lines; } -export function localGitBaseline(root = process.cwd()) { +export function localGitBaseline(root = process.cwd(), env = process.env) { for (const ref of ["refs/remotes/origin/main", "refs/heads/main"]) { const result = spawnSync("git", ["show-ref", "--verify", "--quiet", ref], { cwd: root, @@ -170,6 +244,13 @@ export function localGitBaseline(root = process.cwd()) { }); if (result.status === 0) return ref; } + if (env.CODEX_CLOUD === "1") { + const result = spawnSync("git", ["rev-parse", "--verify", "--quiet", "HEAD"], { + cwd: root, + stdio: "ignore", + }); + if (result.status === 0) return "HEAD"; + } return null; } @@ -276,17 +357,12 @@ export function validateCodexCloudSetup() { requireMatch(errors, guide, /bash scripts\/setup-codex-cloud\.sh/, "The guide must provide the setup command."); requireMatch(errors, guide, /CODEX_CLOUD_ACCESS_PROFILE=connected/, "The guide must document connected access."); requireMatch(errors, guide, /GitHub connector/, "The guide must document GitHub connector access."); - requireMatch( - errors, - mcp, - new RegExp(`@railway/cli@${expectedCloudCliVersions.railway.replaceAll(".", "\\.")}`), - "Railway MCP and the installed Railway CLI must use the same stable version.", - ); try { parseMcpServerMetadata(mcp); } catch (error) { errors.push(error instanceof Error ? error.message : String(error)); } + errors.push(...validateMcpConfiguration(mcp)); for (const command of [ "check:supabase-project", @@ -373,7 +449,9 @@ export async function validateCodexCloudRuntime(env = process.env) { if (obsoleteProxyNames.length > 0) { errors.push(`Obsolete npm proxy variable names are set: ${obsoleteProxyNames.join(", ")}.`); } - if (!localGitBaseline(repoRoot)) errors.push("Neither local main nor origin/main is available."); + if (!localGitBaseline(repoRoot, env)) { + errors.push("Neither local main, origin/main, nor a Cloud task HEAD is available."); + } const origin = inspectOriginRemote(repoRoot); if (!origin.configured) errors.push("origin is unavailable in the Cloud checkout."); else if (origin.credentialsEmbedded) errors.push("origin contains embedded credentials."); @@ -395,7 +473,11 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me for (const error of errors) console.error(`[Codex Cloud Check] FAIL: ${error}`); process.exitCode = 1; } else { - const scope = runtime ? "static, environment, and runtime" : environment ? "static and environment" : "static"; + const scope = codexCloudValidationScope({ + runtime, + environment, + browserInstallSkipped: process.env.CODEX_CLOUD_SKIP_BROWSER_INSTALL === "1", + }); console.log(`[Codex Cloud Check] PASS: ${scope} Cloud contracts match.`); } } diff --git a/scripts/setup-codex-cloud.sh b/scripts/setup-codex-cloud.sh index a0832e0e30..1f27747512 100644 --- a/scripts/setup-codex-cloud.sh +++ b/scripts/setup-codex-cloud.sh @@ -21,6 +21,10 @@ codex_cli_version="0.146.0" [[ -n "$expected_node_major" ]] || fail "Could not read the Node major from .node-version." [[ -n "$expected_npm_version" ]] || fail "Could not read the npm version from package.json." +# Codex Cloud supplies standards-based proxy variables as well. Remove npm's +# deprecated lowercase aliases before the first npm invocation. +unset npm_config_http_proxy npm_config_https_proxy npm_config_proxy + install_npm_cli() { local package_name="$1" local expected_version="$2" @@ -68,6 +72,7 @@ export CODEX_CLOUD=1 export CODEX_CLOUD_ACCESS_PROFILE="\${CODEX_CLOUD_ACCESS_PROFILE:-offline}" export NEXT_PUBLIC_DEMO_MODE="\${NEXT_PUBLIC_DEMO_MODE:-true}" export PLAYWRIGHT_OFFLINE_MODE="\${PLAYWRIGHT_OFFLINE_MODE:-true}" +unset npm_config_http_proxy npm_config_https_proxy npm_config_proxy if [ "\$CODEX_CLOUD_ACCESS_PROFILE" = "connected" ]; then export RAG_PROVIDER_MODE="\${RAG_PROVIDER_MODE:-auto}" else diff --git a/tests/codex-cloud-setup.test.ts b/tests/codex-cloud-setup.test.ts index febe89721a..f8a43abee5 100644 --- a/tests/codex-cloud-setup.test.ts +++ b/tests/codex-cloud-setup.test.ts @@ -5,8 +5,11 @@ import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; import { + codexCloudValidationScope, configuredProviderCredentialNames, + expectedMcpConfiguration, executableFile, + localGitBaseline, obsoleteNpmProxyVariables, parseMcpServerMetadata, providerCredentialVariables, @@ -15,6 +18,7 @@ import { railwayReadCapability, sanitizedCloudCapabilityLines, validateCodexCloudEnvironment, + validateMcpConfiguration, } from "../scripts/check-codex-cloud-setup.mjs"; import { providerEnvironmentKeys } from "../scripts/test-environment.mjs"; import { @@ -110,12 +114,21 @@ describe("Codex Cloud environment contract", () => { railwayCliAvailable: true, codexCliAvailable: true, safeGitHelper: true, - mcpServers: [{ name: "railway", command: "npx", environmentNames: ["RAILWAY_API_TOKEN"] }], + mcpServers: [ + { + name: "railway", + type: "http", + command: "none", + endpoint: "https://mcp.railway.com/", + queryNames: [], + environmentNames: [], + }, + ], }, ); const report = lines.join("\n"); expect(report).toContain("OPENAI_API_KEY.present=true"); - expect(report).toContain("mcp.server=railway command=npx environment_names=RAILWAY_API_TOKEN"); + expect(report).toContain("mcp.server=railway type=http command=none endpoint=https://mcp.railway.com/"); expect(report).not.toContain(secret); expect(report).not.toContain("sensitive-test"); }); @@ -130,24 +143,86 @@ describe("Codex Cloud environment contract", () => { expect(railwayReadCapability({ RAILWAY_API_TOKEN: "configured" }, false).ready).toBe(false); }); - it("parses MCP server names, commands, and environment names without values", () => { + it("parses MCP transport metadata without query or environment values", () => { const secret = "never-print-mcp-value"; const metadata = parseMcpServerMetadata( - JSON.stringify({ mcpServers: { railway: { command: "npx", env: { RAILWAY_API_TOKEN: secret } } } }), + JSON.stringify({ + mcpServers: { + supabase: { + type: "http", + url: `https://mcp.supabase.com/mcp?project_ref=example&read_only=true&token=${secret}`, + env: { SUPABASE_ACCESS_TOKEN: secret }, + }, + }, + }), ); - expect(metadata).toEqual([{ name: "railway", command: "npx", environmentNames: ["RAILWAY_API_TOKEN"] }]); + expect(metadata).toEqual([ + { + name: "supabase", + type: "http", + command: "none", + endpoint: "https://mcp.supabase.com/mcp", + queryNames: ["project_ref", "read_only", "token"], + environmentNames: ["SUPABASE_ACCESS_TOKEN"], + }, + ]); expect(JSON.stringify(metadata)).not.toContain(secret); }); + it("requires hosted Railway OAuth and project-scoped read-only Supabase MCP", () => { + const valid = JSON.stringify({ + mcpServers: { + railway: { type: "http", url: expectedMcpConfiguration.railwayUrl.replace(/\/$/, "") }, + supabase: { + type: "http", + url: `${expectedMcpConfiguration.supabaseUrl}?project_ref=${expectedMcpConfiguration.supabaseProjectRef}&read_only=true&features=${expectedMcpConfiguration.supabaseFeatures.join(",")}`, + }, + }, + }); + expect(validateMcpConfiguration(valid)).toEqual([]); + expect(validateMcpConfiguration(valid.replace("read_only=true", "read_only=false"))).toContain( + "Supabase MCP must keep the production project read-only.", + ); + }); + it("keeps setup and maintenance repairs guarded for repeat execution", () => { const setup = readFileSync(new URL("../scripts/setup-codex-cloud.sh", import.meta.url), "utf8"); const maintenance = readFileSync(new URL("../scripts/maintain-codex-cloud.sh", import.meta.url), "utf8"); expect(setup).toContain("if ! grep -Fq '.clinical-kb-codex-cloud.sh'"); expect(setup).toContain('if [[ "$actual_version" != "$expected_version" ]]'); expect(setup).toContain('"$HOME/.bash_profile"'); + expect(setup.match(/unset npm_config_http_proxy npm_config_https_proxy npm_config_proxy/g)).toHaveLength(2); expect(maintenance).toContain("ensure-codex-cloud-git-remote.mjs"); }); + it("accepts a task-only HEAD only inside Codex Cloud", () => { + const directory = temporaryGitRepository(); + const commit = git( + directory, + "-c", + "user.name=Codex", + "-c", + "user.email=codex@example.test", + "commit", + "--allow-empty", + "-m", + "task", + ); + expect(commit.status, commit.stderr).toBe(0); + + expect(localGitBaseline(directory, {})).toBeNull(); + expect(localGitBaseline(directory, { CODEX_CLOUD: "1" })).toBe("HEAD"); + }); + + it("does not describe a source-only runtime as fully browser-ready", () => { + expect(codexCloudValidationScope({ runtime: true, environment: true, browserInstallSkipped: true })).toBe( + "static, environment, and source-only runtime (browser validation skipped)", + ); + expect(codexCloudValidationScope({ runtime: true, environment: true, browserInstallSkipped: false })).toBe( + "static, environment, and runtime", + ); + }); + it("distinguishes executable files from missing paths", () => { expect(executableFile(process.execPath)).toBe(true); expect(executableFile("/definitely/not/a/cloud/executable")).toBe(false); From 6e8531ebe5d0b00c1c094f9bbdaf6bc609f0f450 Mon Sep 17 00:00:00 2001 From: BigSimmo <87357024+BigSimmo@users.noreply.github.com> Date: Sat, 1 Aug 2026 23:57:43 +0800 Subject: [PATCH 2/2] fix: type Cloud baseline environment input --- scripts/check-codex-cloud-setup.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/check-codex-cloud-setup.mjs b/scripts/check-codex-cloud-setup.mjs index 06223a5801..0f0b1ee261 100644 --- a/scripts/check-codex-cloud-setup.mjs +++ b/scripts/check-codex-cloud-setup.mjs @@ -236,6 +236,7 @@ export function sanitizedCloudCapabilityLines(env = process.env, options = {}) { return lines; } +/** @param {NodeJS.ProcessEnv | Record} [env] */ export function localGitBaseline(root = process.cwd(), env = process.env) { for (const ref of ["refs/remotes/origin/main", "refs/heads/main"]) { const result = spawnSync("git", ["show-ref", "--verify", "--quiet", ref], {