From 9e99e9bceffb6c4fa7956d807987bb765ac5a140 Mon Sep 17 00:00:00 2001 From: Miya Date: Tue, 25 Aug 2026 09:30:18 +0200 Subject: [PATCH] fix: make YAML-relative cwd resolution opt-in Session-Id: 01a037bb-4e8c-7c20-8962-621515f5e335 --- packages/core/src/__tests__/step-cwd.test.ts | 101 +++++++++++++++++++ packages/core/src/runner.ts | 68 ++++++++++++- packages/core/src/schema.json | 16 ++- packages/core/src/schema.ts | 8 +- packages/core/src/types.ts | 3 + 5 files changed, 189 insertions(+), 7 deletions(-) diff --git a/packages/core/src/__tests__/step-cwd.test.ts b/packages/core/src/__tests__/step-cwd.test.ts index 21f9410..6d7d221 100644 --- a/packages/core/src/__tests__/step-cwd.test.ts +++ b/packages/core/src/__tests__/step-cwd.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; vi.mock('@relaycast/sdk', () => ({ @@ -27,6 +29,27 @@ vi.mock('@agent-relay/harness-driver', async (importOriginal) => { const { WorkflowRunner } = await import('../runner.js'); describe('WorkflowRunner step cwd resolution', () => { + const config = (cwdResolution?: 'workflow-file' | 'process') => ({ + version: '1', + name: 'cwd-resolution', + ...(cwdResolution ? { cwdResolution } : {}), + swarm: { pattern: 'dag' as const }, + agents: [{ name: 'worker', cli: 'claude' as const, cwd: '../agent-workspace' }], + workflows: [ + { + name: 'default', + steps: [ + { + name: 'generate', + agent: 'worker', + task: 'Generate', + cwd: '../step-workspace', + }, + ], + }, + ], + }); + it('prefers step.cwd over agent.cwd and runner cwd', () => { const runnerRoot = '/runner-root'; const runner = new WorkflowRunner({ cwd: runnerRoot }); @@ -83,4 +106,82 @@ describe('WorkflowRunner step cwd resolution', () => { (runner as any).resolveEffectiveCwd({ name: 's4', type: 'deterministic', command: 'pwd' }), ).toBe(runnerRoot); }); + + it('resolves agent and step cwd from the parsed workflow file when opted in', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'relay-cwd-resolution-')); + const runnerRoot = path.join(root, 'process', 'project'); + const workflowDir = path.join(root, 'workflow', 'config'); + const yamlPath = path.join(workflowDir, 'relay.yaml'); + mkdirSync(runnerRoot, { recursive: true }); + mkdirSync(workflowDir, { recursive: true }); + writeFileSync( + yamlPath, + [ + 'version: "1"', + 'name: cwd-resolution', + 'cwdResolution: workflow-file', + 'swarm:', + ' pattern: dag', + 'agents:', + ' - name: worker', + ' cli: claude', + ' cwd: ../agent-workspace', + 'workflows:', + ' - name: default', + ' steps:', + ' - name: generate', + ' agent: worker', + ' task: Generate', + ' cwd: ../step-workspace', + ].join('\n') + '\n' + ); + + try { + const runner = new WorkflowRunner({ cwd: runnerRoot }); + const parsed = await runner.parseYamlFile(yamlPath); + (runner as any).configureCwdResolution(parsed); + + expect((runner as any).resolveAgentCwd(parsed.agents[0])).toBe( + path.resolve(workflowDir, '../agent-workspace') + ); + expect((runner as any).resolveEffectiveCwd(parsed.workflows![0].steps[0])).toBe( + path.resolve(workflowDir, '../step-workspace') + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('preserves process-relative agent and step cwd when explicitly configured', () => { + const runnerRoot = '/process/project'; + const runner = new WorkflowRunner({ cwd: runnerRoot }); + (runner as any).workflowFileDir = '/workflow/config'; + (runner as any).configureCwdResolution(config('process')); + + expect((runner as any).resolveAgentCwd(config('process').agents[0])).toBe( + path.resolve(runnerRoot, '../agent-workspace') + ); + expect((runner as any).resolveEffectiveCwd(config('process').workflows[0].steps[0])).toBe( + path.resolve(runnerRoot, '../step-workspace') + ); + }); + + it('warns with both paths when the default is ambiguous', () => { + const runnerRoot = '/process/project'; + const workflowDir = '/workflow/config'; + const runner = new WorkflowRunner({ cwd: runnerRoot }); + (runner as any).workflowFileDir = workflowDir; + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + (runner as any).configureCwdResolution(config()); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(path.resolve(runnerRoot, '../agent-workspace')) + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(path.resolve(workflowDir, '../agent-workspace')) + ); + expect(warn).toHaveBeenCalledWith(expect.stringContaining('future major')); + warn.mockRestore(); + }); }); diff --git a/packages/core/src/runner.ts b/packages/core/src/runner.ts index 1f77d6f..de81d67 100644 --- a/packages/core/src/runner.ts +++ b/packages/core/src/runner.ts @@ -860,6 +860,8 @@ export class WorkflowRunner { private readonly workspaceId: string; private readonly relayOptions: RuntimeSpawnOptions; private readonly cwd: string; + private workflowFileDir?: string; + private cwdResolution: NonNullable = 'process'; private readonly summaryDir: string; private executor?: RunnerStepExecutor; private readonly envSecrets?: Record; @@ -1004,6 +1006,50 @@ export class WorkflowRunner { }); } + private resolveConfiguredCwd(configuredCwd: string): string { + const base = this.cwdResolution === 'workflow-file' ? this.workflowFileDir : this.cwd; + return path.resolve(base ?? this.cwd, configuredCwd); + } + + private configureCwdResolution(config: RelayYamlConfig): void { + const configuredMode = config.cwdResolution; + this.cwdResolution = configuredMode ?? 'process'; + + if (this.cwdResolution === 'workflow-file' && !this.workflowFileDir) { + throw new Error( + 'cwdResolution: "workflow-file" requires a workflow loaded from a YAML file so its directory is known' + ); + } + if (configuredMode || !this.workflowFileDir) return; + + const candidates: Array<{ label: string; cwd: string }> = []; + for (const agent of config.agents ?? []) { + if (agent.cwd) candidates.push({ label: `Agent "${agent.name}"`, cwd: agent.cwd }); + } + for (const workflow of config.workflows ?? []) { + for (const step of workflow.steps) { + if (step.cwd) { + candidates.push({ + label: `Step "${workflow.name}.${step.name}"`, + cwd: step.cwd, + }); + } + } + } + + for (const candidate of candidates) { + const processPath = path.resolve(this.cwd, candidate.cwd); + const workflowPath = path.resolve(this.workflowFileDir, candidate.cwd); + if (processPath === workflowPath) continue; + console.warn( + `[WorkflowRunner] DEPRECATION WARNING: ${candidate.label} cwd "${candidate.cwd}" ` + + `currently resolves from the process cwd to "${processPath}"; workflow-file resolution ` + + `would use "${workflowPath}". Set top-level cwdResolution to "process" or ` + + `"workflow-file" explicitly. The default will flip to "workflow-file" in a future major release.` + ); + } + } + /** * Resolve and validate the top-level `paths` definitions from the config. * Returns a map of name → absolute directory path. @@ -1466,7 +1512,7 @@ export class WorkflowRunner { return resolved; } if (agent.cwd) { - return path.resolve(this.cwd, agent.cwd); + return this.resolveConfiguredCwd(agent.cwd); } return this.cwd; } @@ -1488,7 +1534,7 @@ export class WorkflowRunner { private resolveEffectiveCwd(step: WorkflowStep, agentDef?: AgentDefinition): string { if (step.cwd) { - return path.resolve(this.cwd, step.cwd); + return this.resolveConfiguredCwd(step.cwd); } return this.resolveStepWorkdir(step) ?? (agentDef ? this.resolveAgentCwd(agentDef) : this.cwd); } @@ -2997,11 +3043,15 @@ export class WorkflowRunner { async parseYamlFile(filePath: string): Promise { const absPath = path.resolve(this.cwd, filePath); const raw = await readFile(absPath, 'utf-8'); + this.workflowFileDir = path.dirname(absPath); return this.parseYamlString(raw, absPath); } /** Parse a relay.yaml string. */ parseYamlString(raw: string, source = ''): RelayYamlConfig { + if (source !== '' && path.isAbsolute(source)) { + this.workflowFileDir = path.dirname(source); + } const parsed = parseYaml(raw); this.validateConfig(parsed, source); const config = this.normalizeLegacyPermissionConfig(parsed as RelayYamlConfig); @@ -3085,6 +3135,15 @@ export class WorkflowRunner { if (c.agents !== undefined && !Array.isArray(c.agents)) { throw new Error(`${source}: "agents" must be an array when provided`); } + if ( + c.cwdResolution !== undefined && + c.cwdResolution !== 'process' && + c.cwdResolution !== 'workflow-file' + ) { + throw new Error( + `${source}: "cwdResolution" must be either "process" or "workflow-file"` + ); + } // Approval gates that cannot reach a human fail OPEN, so they must be refused // on every path — not only when someone happens to run `--validate` first. @@ -3221,6 +3280,7 @@ export class WorkflowRunner { this.validateConfig(config); resolved = vars ? this.resolveVariables(config, vars) : config; resolved = this.applyPermissionProfiles(resolved); + this.configureCwdResolution(resolved); } catch (err) { errors.push(err instanceof Error ? err.message : String(err)); return { @@ -3335,7 +3395,7 @@ export class WorkflowRunner { // Validate cwd paths for (const agent of resolved.agents) { if (agent.cwd) { - const resolvedCwd = path.resolve(this.cwd, agent.cwd); + const resolvedCwd = this.resolveConfiguredCwd(agent.cwd); if (!existsSync(resolvedCwd)) { warnings.push( `Agent "${agent.name}" cwd "${agent.cwd}" resolves to "${resolvedCwd}" which does not exist` @@ -3845,6 +3905,7 @@ export class WorkflowRunner { // Validate config (catches cycles, missing deps, invalid steps, etc.) this.validateConfig(resolved); + this.configureCwdResolution(resolved); const runtimeConfig = this.applyReliabilityDefaults(resolved); const permissionResult = this.validatePermissions( @@ -4015,6 +4076,7 @@ export class WorkflowRunner { const resolvedConfig = this.applyReliabilityDefaults( vars ? this.resolveVariables(run.config, vars) : run.config ); + this.configureCwdResolution(resolvedConfig); // Resolve path definitions (same as execute()) so workdir lookups work on resume const pathResult = this.resolvePathDefinitions(resolvedConfig.paths, this.cwd); diff --git a/packages/core/src/schema.json b/packages/core/src/schema.json index 88a227c..5afa5f6 100644 --- a/packages/core/src/schema.json +++ b/packages/core/src/schema.json @@ -19,6 +19,12 @@ "type": "string", "description": "Optional description of the configuration" }, + "cwdResolution": { + "type": "string", + "enum": ["workflow-file", "process"], + "default": "process", + "description": "Base directory for relative agent and step cwd values. process preserves the current behavior; workflow-file resolves from the directory containing this YAML file." + }, "permission_profiles": { "type": "object", "description": "Reusable permission profiles that agents can reference via permissions.profile.", @@ -410,7 +416,7 @@ }, "cwd": { "type": "string", - "description": "Working directory for this agent, resolved relative to the runner's configured working directory (the `cwd` option, defaulting to process.cwd()) — not the YAML file's location." + "description": "Working directory for this agent, resolved according to the top-level cwdResolution setting." }, "workdir": { "type": "string", @@ -836,6 +842,10 @@ "type": "string", "description": "Sets this step's working directory to a named entry from the top-level paths array." }, + "cwd": { + "type": "string", + "description": "Working directory for this step, resolved according to the top-level cwdResolution setting." + }, "humanAssistance": { "anyOf": [ { @@ -892,6 +902,10 @@ "workdir": { "type": "string", "description": "Sets this step's working directory to a named entry from the top-level paths array." + }, + "cwd": { + "type": "string", + "description": "Working directory for this step, resolved according to the top-level cwdResolution setting." } } }, diff --git a/packages/core/src/schema.ts b/packages/core/src/schema.ts index 9a62e48..6a05117 100644 --- a/packages/core/src/schema.ts +++ b/packages/core/src/schema.ts @@ -60,11 +60,12 @@ interface AgentDefinitionBase { * It receives its task as a CLI prompt argument and returns stdout as output. * Default: true (interactive PTY mode). */ interactive?: boolean; - /** Working directory for this agent, resolved relative to the YAML file. */ + /** Working directory for this agent, resolved according to the top-level + * `cwdResolution` setting. */ cwd?: string; /** Sets this agent's working directory to a named entry from the top-level `paths` array. * Mutually exclusive with `cwd`. If omitted, the agent runs in the runner's - * working directory (the directory containing the workflow YAML file). */ + * working directory. */ workdir?: string; /** Additional paths the agent needs read/write access to. */ additionalPaths?: string[]; @@ -397,7 +398,8 @@ export interface WorkflowStep { retries?: number; /** Maximum iterations for steps that may need to retry (e.g., fix-failures). */ maxIterations?: number; - /** Explicit working directory for this step. */ + /** Explicit working directory for this step, resolved according to the + * top-level `cwdResolution` setting. */ cwd?: string; /** Step-level human assistance override. Set false to disable swarm defaults. */ humanAssistance?: HumanAssistanceConfig | false; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 0879a8e..da5fb8d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -49,6 +49,9 @@ export interface RelayYamlConfig { version: string; name: string; description?: string; + /** Base directory used to resolve relative agent and step `cwd` values. + * Defaults to `process` for backwards compatibility. */ + cwdResolution?: 'workflow-file' | 'process'; /** Reusable permission profiles that agents can reference via permissions.profile. */ permission_profiles?: Record; /** Named paths to external directories used by this workflow.