diff --git a/docs/ruby/README.md b/docs/ruby/README.md index edb7cff5..bc4c61a3 100644 --- a/docs/ruby/README.md +++ b/docs/ruby/README.md @@ -83,6 +83,14 @@ set `$stdout.sync = false` itself. Attach mode connects to a process the server start, so no prelude is injected there — set `$stdout.sync = true` in your program if you need mid-run output while attached. +### Environment variables + +Launch mode applies `dapLaunchArgs.env` to the debuggee's process environment at spawn +time (because `rdbg -c` starts the script immediately, the later DAP launch request +cannot carry it). An explicit value there wins over the server's inherited environment. +Attach mode cannot set env — the target process is already running; set variables before +starting it. + ### Bundler projects Pass `useBundler` through the launch configuration to run the target via `bundle exec`: diff --git a/packages/adapter-ruby/src/ruby-debug-adapter.ts b/packages/adapter-ruby/src/ruby-debug-adapter.ts index 196872f7..2caa900d 100644 --- a/packages/adapter-ruby/src/ruby-debug-adapter.ts +++ b/packages/adapter-ruby/src/ruby-debug-adapter.ts @@ -260,9 +260,14 @@ export class RubyDebugAdapter extends EventEmitter implements IDebugAdapter { return { command: invocation.command, args: invocation.args, + // rdbg -c starts the debuggee at spawn time, so the spawn env is the + // only channel for launchConfig.env — the later DAP launch request is + // an ack (issue #318). User env last: an explicit value wins over both + // inherited process.env and adapter defaults. env: { ...process.env, - RUBY_DEBUG_DAP_SHOW_PROTOCOL: process.env.DEBUG ? '1' : '0' + RUBY_DEBUG_DAP_SHOW_PROTOCOL: process.env.DEBUG ? '1' : '0', + ...(launchConfig.env ?? {}) } }; } diff --git a/packages/adapter-ruby/tests/unit/ruby-debug-adapter.test.ts b/packages/adapter-ruby/tests/unit/ruby-debug-adapter.test.ts index 1b72ad51..b8a35ac2 100644 --- a/packages/adapter-ruby/tests/unit/ruby-debug-adapter.test.ts +++ b/packages/adapter-ruby/tests/unit/ruby-debug-adapter.test.ts @@ -32,6 +32,7 @@ const createDependencies = () => ({ describe('RubyDebugAdapter', () => { afterEach(() => { vi.clearAllMocks(); + vi.unstubAllEnvs(); }); it('caches resolveExecutablePath results', async () => { @@ -133,6 +134,61 @@ describe('RubyDebugAdapter', () => { ]); }); + it('merges launchConfig.env into the spawn env (issue #318)', () => { + vi.mocked(ensureRubySyncHelper).mockReturnValue('/tmp/logs/mcp_stdout_sync.rb'); + vi.stubEnv('MCP_TEST_INHERITED', 'old'); + const adapter = new RubyDebugAdapter(createDependencies()); + (adapter as unknown as { rdbgPathCache: Map }) + .rdbgPathCache.set('default', { path: '/usr/bin/rdbg', timestamp: Date.now() }); + + const command = adapter.buildAdapterCommand({ + sessionId: 'ruby-session', + executablePath: '/usr/bin/ruby', + adapterHost: '127.0.0.1', + adapterPort: 8123, + logDir: '/tmp/logs', + scriptPath: '/workspace/app.rb', + scriptArgs: [], + launchConfig: { + env: { + RAILS_ENV: 'test', + MCP_TEST_INHERITED: 'new', + RUBY_DEBUG_DAP_SHOW_PROTOCOL: '1' + } + } + }); + + // User-supplied values reach the debuggee (rdbg -c starts it at spawn + // time, so the spawn env is the only channel). + expect(command.env?.RAILS_ENV).toBe('test'); + // An explicit user value wins over the inherited process.env value... + expect(command.env?.MCP_TEST_INHERITED).toBe('new'); + // ...and over adapter defaults. + expect(command.env?.RUBY_DEBUG_DAP_SHOW_PROTOCOL).toBe('1'); + }); + + it('keeps the default spawn env when no launchConfig.env is given', () => { + vi.mocked(ensureRubySyncHelper).mockReturnValue('/tmp/logs/mcp_stdout_sync.rb'); + vi.stubEnv('MCP_TEST_INHERITED', 'inherited'); + const adapter = new RubyDebugAdapter(createDependencies()); + (adapter as unknown as { rdbgPathCache: Map }) + .rdbgPathCache.set('default', { path: '/usr/bin/rdbg', timestamp: Date.now() }); + + const command = adapter.buildAdapterCommand({ + sessionId: 'ruby-session', + executablePath: '/usr/bin/ruby', + adapterHost: '127.0.0.1', + adapterPort: 8123, + logDir: '/tmp/logs', + scriptPath: '/workspace/app.rb', + scriptArgs: [], + launchConfig: {} + }); + + expect(command.env?.MCP_TEST_INHERITED).toBe('inherited'); + expect(command.env?.RUBY_DEBUG_DAP_SHOW_PROTOCOL).toBe('0'); + }); + it('launches without the prelude when the sync helper cannot be materialized', () => { vi.mocked(ensureRubySyncHelper).mockReturnValue(null); const adapter = new RubyDebugAdapter(createDependencies()); diff --git a/tests/e2e/mcp-server-smoke-ruby.test.ts b/tests/e2e/mcp-server-smoke-ruby.test.ts index 8e778032..b214dd92 100644 --- a/tests/e2e/mcp-server-smoke-ruby.test.ts +++ b/tests/e2e/mcp-server-smoke-ruby.test.ts @@ -268,4 +268,46 @@ describe('MCP Server Ruby Debugging Smoke Test @requires-ruby', () => { })) as { sessions?: Array<{ id: string; state: string }> }; expect(listResponse.sessions?.find(s => s.id === sessionId)?.state).toBe('paused'); }, 90000); + + it('applies dapLaunchArgs.env to the debuggee (issue #318)', async () => { + if (!(await rubyToolchainAvailable())) { + console.log('[Ruby Smoke Test] Ruby/rdbg not available, skipping launch env test'); + return; + } + + const testRubyFile = path.resolve(ROOT, 'examples', 'ruby', 'fizzbuzz.rb'); + + const createResponse = parseSdkToolResult(await mcpClient!.callTool({ + name: 'create_debug_session', + arguments: { language: 'ruby', name: 'ruby-launch-env-test' } + })); + expect(createResponse.sessionId).toBeDefined(); + sessionId = createResponse.sessionId as string; + + const bpResponse = await callToolSafely(mcpClient!, 'set_breakpoint', { + sessionId, + file: testRubyFile, + line: 15 + }); + expect(bpResponse.success).toBe(true); + + // rdbg -c starts the debuggee at spawn time, so env can only reach it + // through the spawn environment — the DAP launch request is an ack. + const startResponse = parseSdkToolResult(await mcpClient!.callTool({ + name: 'start_debugging', + arguments: { + sessionId, + scriptPath: testRubyFile, + dapLaunchArgs: { env: { MCP_TEST_ENV_318: 'hello-318' } } + } + })) as { state?: string }; + expect(startResponse.state).toBe('paused'); + + const evalResult = await callToolSafely(mcpClient!, 'evaluate_expression', { + sessionId, + expression: "ENV['MCP_TEST_ENV_318']" + }); + // rdbg inspects results, so a String comes back quoted. + expect(String(evalResult.result)).toContain('hello-318'); + }, 90000); });