Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 6 additions & 2 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
20 changes: 15 additions & 5 deletions docs/codex-cloud.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
115 changes: 99 additions & 16 deletions scripts/check-codex-cloud-setup.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.");
Comment thread
BigSimmo marked this conversation as resolved.
}
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;
}
Expand Down Expand Up @@ -156,20 +230,28 @@ 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()) {
/** @param {NodeJS.ProcessEnv | Record<string, string | undefined>} [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], {
cwd: root,
stdio: "ignore",
});
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;
}

Expand Down Expand Up @@ -276,17 +358,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",
Expand Down Expand Up @@ -373,7 +450,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.");
Expand All @@ -395,7 +474,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.`);
}
}
5 changes: 5 additions & 0 deletions scripts/setup-codex-cloud.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
85 changes: 80 additions & 5 deletions tests/codex-cloud-setup.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -15,6 +18,7 @@ import {
railwayReadCapability,
sanitizedCloudCapabilityLines,
validateCodexCloudEnvironment,
validateMcpConfiguration,
} from "../scripts/check-codex-cloud-setup.mjs";
import { providerEnvironmentKeys } from "../scripts/test-environment.mjs";
import {
Expand Down Expand Up @@ -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");
});
Expand All @@ -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");
Comment thread
BigSimmo marked this conversation as resolved.
});

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);
Expand Down
Loading