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
6 changes: 6 additions & 0 deletions .changeset/cold-planets-pump.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@workflow/cli": patch
"@workflow/core": patch
---

Stop reading `WORKFLOW_VERCEL_*` env vars at runtime to prevent unintended proxy routing
39 changes: 30 additions & 9 deletions packages/cli/src/lib/inspect/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ import {
/**
* Overwrite values on process.env with the given values (if not undefined)
*
* We do this because the core world init code reads from environment
* (or workflow.config.ts soon) on first invocations, so CLI needs to
* overwrite the values.
* Used by the CLI to configure environment variables that are read by
* various subsystems (e.g., WORKFLOW_TARGET_WORLD, WORKFLOW_LOCAL_DATA_DIR).
* Note: WORKFLOW_VERCEL_* env vars are read back via getEnvVars() and passed
* to createVercelWorld() explicitly — they are NOT read by createWorld().
*/
export const writeEnvVars = (envVars: Record<string, string>) => {
Object.entries(envVars).forEach(([key, value]) => {
Expand Down Expand Up @@ -178,11 +179,22 @@ export const inferVercelProjectAndTeam = async () => {
};
};

export interface VercelEnvVars {
token?: string;
environment?: string;
projectId?: string;
projectName?: string;
teamId?: string;
}

/**
* Overwrites process.env variables related to Vercel World configuration,
* if relevant environment variables aren't set already.
* Infers Vercel World configuration from the local environment (`.vercel`
* folder, CLI auth file, Vercel API) and returns a resolved config object.
*
* Also writes the resolved values to `process.env` so the embedded web UI
* (which reads `process.env` as a fallback) can pick them up.
*/
export const inferVercelEnvVars = async () => {
export const inferVercelEnvVars = async (): Promise<VercelEnvVars> => {
const envVars = getEnvVars();

// Infer project/team from .vercel folder when:
Expand All @@ -206,7 +218,6 @@ export const inferVercelEnvVars = async () => {
// WORKFLOW_VERCEL_PROJECT_NAME is the project slug (e.g., my-app)
envVars.WORKFLOW_VERCEL_PROJECT_NAME = projectName || projectId;
envVars.WORKFLOW_VERCEL_TEAM = envVars.WORKFLOW_VERCEL_TEAM || teamId;
writeEnvVars(envVars);
} else {
logger.warn(
'Could not infer vercel project and team from .vercel folder, server authentication might fail.'
Expand All @@ -221,7 +232,6 @@ export const inferVercelEnvVars = async () => {
throw new Error('Could not find credentials. Run `vc login` to log in.');
}
envVars.WORKFLOW_VERCEL_AUTH_TOKEN = token;
writeEnvVars(envVars);
}

// Fetch team information from Vercel API to get the team slug
Expand All @@ -237,8 +247,19 @@ export const inferVercelEnvVars = async () => {
);
if (teamInfo) {
envVars.WORKFLOW_VERCEL_TEAM = teamInfo.teamSlug;
writeEnvVars(envVars);
logger.debug(`Found team slug: ${teamInfo.teamSlug}`);
}
}

// Write resolved values to process.env for the embedded web UI, which
// reads them as fallbacks in its server actions.
writeEnvVars(envVars);

return {
token: envVars.WORKFLOW_VERCEL_AUTH_TOKEN || undefined,
environment: envVars.WORKFLOW_VERCEL_ENV || undefined,
projectId: envVars.WORKFLOW_VERCEL_PROJECT || undefined,
projectName: envVars.WORKFLOW_VERCEL_PROJECT_NAME || undefined,
teamId: envVars.WORKFLOW_VERCEL_TEAM || undefined,
};
};
49 changes: 36 additions & 13 deletions packages/cli/src/lib/inspect/setup.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import { getWorld } from '@workflow/core/runtime';
import { createWorld, setWorld } from '@workflow/core/runtime';
import { isVercelWorldTarget } from '@workflow/utils';
import type { World } from '@workflow/world';
import { createVercelWorld } from '@workflow/world-vercel';
import chalk from 'chalk';
import terminalLink from 'terminal-link';
import { logger, setJsonMode, setVerboseMode } from '../config/log.js';
import { checkForUpdateCached } from '../update-check.js';
import {
inferLocalWorldEnvVars,
inferVercelEnvVars,
type VercelEnvVars,
writeEnvVars,
} from './env.js';

Expand Down Expand Up @@ -73,17 +77,19 @@ export const setupCliWorld = async (
writeEnvVars({
DEBUG: flags.verbose ? '1' : '',
WORKFLOW_TARGET_WORLD: flags.backend,
WORKFLOW_VERCEL_ENV: flags.env,
WORKFLOW_VERCEL_AUTH_TOKEN: flags.authToken,
WORKFLOW_VERCEL_PROJECT: flags.project,
WORKFLOW_VERCEL_TEAM: flags.team,
});

if (
flags.backend === 'vercel' ||
flags.backend === '@workflow/world-vercel'
) {
await inferVercelEnvVars();
let vercelEnvVars: VercelEnvVars | undefined;
if (isVercelWorldTarget(flags.backend)) {
// Seed the initial flags into process.env so inferVercelEnvVars() can
// read them via getEnvVars() as starting values before inference.
writeEnvVars({
WORKFLOW_VERCEL_ENV: flags.env,
WORKFLOW_VERCEL_AUTH_TOKEN: flags.authToken,
WORKFLOW_VERCEL_PROJECT: flags.project,
WORKFLOW_VERCEL_TEAM: flags.team,
});
vercelEnvVars = await inferVercelEnvVars();
} else if (
flags.backend === 'local' ||
flags.backend === '@workflow/world-local'
Expand All @@ -107,8 +113,25 @@ export const setupCliWorld = async (
}

logger.debug('Initializing world');
// Use getWorld() instead of createWorld() so the world is stored in the
// global cache. This allows BaseCommand.finally() to find and close it.
const world = getWorld();

let world: World;
if (vercelEnvVars) {
// Build the Vercel world directly from the inferred config, rather than
// relying on createWorld() reading process.env.
world = createVercelWorld({
token: vercelEnvVars.token,
projectConfig: {
environment: vercelEnvVars.environment,
projectId: vercelEnvVars.projectId,
projectName: vercelEnvVars.projectName,
teamId: vercelEnvVars.teamId,
},
});
} else {
world = createWorld();
}

// Store in the global cache so BaseCommand.finally() can find and close it.
setWorld(world);
return world;
};
15 changes: 14 additions & 1 deletion packages/core/e2e/bench.bench.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { createVercelWorld } from '@workflow/world-vercel';
import fs from 'fs';
import path from 'path';
import { bench, describe } from 'vitest';
import type { Run } from '../src/runtime';
import { start } from '../src/runtime';
import { setWorld, start } from '../src/runtime';
import {
getProtectionBypassHeaders,
getWorkbenchAppPath,
Expand All @@ -28,6 +29,18 @@ if (isLocalDeployment()) {
'VERCEL_DEPLOYMENT_ID is required for Vercel benchmarks but is not set'
);
}
// Build the Vercel world explicitly with CI-provided config
setWorld(
createVercelWorld({
token: process.env.WORKFLOW_VERCEL_AUTH_TOKEN || undefined,
projectConfig: {
environment: process.env.WORKFLOW_VERCEL_ENV || undefined,
projectId: process.env.WORKFLOW_VERCEL_PROJECT || undefined,
projectName: process.env.WORKFLOW_VERCEL_PROJECT_NAME || undefined,
teamId: process.env.WORKFLOW_VERCEL_TEAM || undefined,
},
})
);
}

// Manifest type and helpers (same as e2e tests)
Expand Down
18 changes: 17 additions & 1 deletion packages/core/e2e/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
WorkflowRunCancelledError,
WorkflowRunFailedError,
} from '@workflow/errors';
import { createVercelWorld } from '@workflow/world-vercel';
import { afterAll, assert, beforeAll, describe, expect, test } from 'vitest';
import type { Run } from '../src/runtime';
import {
Expand All @@ -13,6 +14,7 @@ import {
getWorld,
healthCheck,
resumeHook,
setWorld,
start,
} from '../src/runtime';
import {
Expand Down Expand Up @@ -269,8 +271,22 @@ describe('e2e', () => {
const isNextJs = appName.includes('nextjs') || appName.includes('next-');
const dataDirName = isNextJs ? '.next/workflow-data' : '.workflow-data';
process.env.WORKFLOW_LOCAL_DATA_DIR = path.join(appPath, dataDirName);
} else if (process.env.WORKFLOW_VERCEL_ENV) {
// For Vercel tests: WORKFLOW_VERCEL_AUTH_TOKEN, WORKFLOW_VERCEL_PROJECT, etc. are set by CI.
// Build the Vercel world explicitly with the CI-provided config rather than relying on
// createWorld() reading these env vars (which no longer happens at runtime).
setWorld(
createVercelWorld({
token: process.env.WORKFLOW_VERCEL_AUTH_TOKEN,
projectConfig: {
environment: process.env.WORKFLOW_VERCEL_ENV || undefined,
projectId: process.env.WORKFLOW_VERCEL_PROJECT || undefined,
projectName: process.env.WORKFLOW_VERCEL_PROJECT_NAME || undefined,
teamId: process.env.WORKFLOW_VERCEL_TEAM || undefined,
},
})
);
}
// For Vercel tests: WORKFLOW_VERCEL_AUTH_TOKEN, WORKFLOW_VERCEL_PROJECT, etc. are set by CI
// For Postgres tests: WORKFLOW_TARGET_WORLD and WORKFLOW_POSTGRES_URL are set by CI
});

Expand Down
52 changes: 32 additions & 20 deletions packages/core/src/runtime/world.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { createRequire } from 'node:module';
import { join } from 'node:path';
import {
isVercelWorldTarget,
resolveWorkflowTargetWorld,
} from '@workflow/utils';
import type { World } from '@workflow/world';
import { createLocalWorld } from '@workflow/world-local';
import { createVercelWorld } from '@workflow/world-vercel';
Expand All @@ -14,32 +18,40 @@ const globalSymbols: typeof globalThis & {
[StubbedWorldCache]?: World;
} = globalThis;

function defaultWorld(): 'vercel' | 'local' {
if (process.env.VERCEL_DEPLOYMENT_ID) {
return 'vercel';
}

return 'local';
}

/**
* Create a new world instance based on environment variables.
* WORKFLOW_TARGET_WORLD is used to determine the target world.
* All other environment variables are specific to the target world
*
* Note: WORKFLOW_VERCEL_* env vars (PROJECT, TEAM, AUTH_TOKEN, etc.) are
* intentionally NOT read here. Those are for CLI/observability tooling only
* and should not affect runtime behavior. The Vercel runtime provides
* authentication via OIDC tokens and project context via system env vars
* (VERCEL_DEPLOYMENT_ID, VERCEL_PROJECT_ID). Tooling that needs these env
* vars should call createVercelWorld() directly with an explicit config and
* use setWorld() to inject the instance.
*/
export const createWorld = (): World => {
const targetWorld = process.env.WORKFLOW_TARGET_WORLD || defaultWorld();
const targetWorld = resolveWorkflowTargetWorld();

if (targetWorld === 'vercel') {
return createVercelWorld({
token: process.env.WORKFLOW_VERCEL_AUTH_TOKEN,
projectConfig: {
environment: process.env.WORKFLOW_VERCEL_ENV,
projectId: process.env.WORKFLOW_VERCEL_PROJECT, // real ID (prj_xxx)
projectName: process.env.WORKFLOW_VERCEL_PROJECT_NAME, // slug (my-app)
teamId: process.env.WORKFLOW_VERCEL_TEAM,
},
});
if (isVercelWorldTarget(targetWorld)) {
// Warn if WORKFLOW_VERCEL_* env vars are set — they have no effect at
// runtime and likely indicate a misconfiguration (user manually added
// them as Vercel project env vars, which is not needed).
const staleEnvVars = [
'WORKFLOW_VERCEL_PROJECT',
'WORKFLOW_VERCEL_TEAM',
'WORKFLOW_VERCEL_AUTH_TOKEN',
'WORKFLOW_VERCEL_ENV',
].filter((key) => process.env[key]);
if (staleEnvVars.length > 0) {
console.warn(
`[workflow] Warning: ${staleEnvVars.join(', ')} env var(s) ` +
'are set but have no effect at runtime. These are only used by the Workflow CLI. ' +
'Remove them from your Vercel project environment variables.'
);
}

return createVercelWorld();
}

if (targetWorld === 'local') {
Expand Down