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
15 changes: 8 additions & 7 deletions docs/ruby/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,13 +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.
### Environment variables and working directory

Launch mode applies `dapLaunchArgs.env` and `dapLaunchArgs.cwd` to the debuggee's
process at spawn time (because `rdbg -c` starts the script immediately, the later DAP
launch request cannot carry them). An explicit env value there wins over the server's
inherited environment; without `cwd` the debuggee inherits the server's working
directory. Attach mode cannot set either — the target process is already running;
configure it before starting.

### Bundler projects

Expand Down
12 changes: 7 additions & 5 deletions packages/adapter-ruby/src/ruby-debug-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,15 +260,17 @@ 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.
// rdbg -c starts the debuggee at spawn time, so the spawn env/cwd are
// the only channels for launchConfig.env and .cwd — the later DAP
// launch request is an ack (issues #318, #320). 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',
...(launchConfig.env ?? {})
}
},
cwd: launchConfig.cwd
};
}

Expand Down
33 changes: 33 additions & 0 deletions packages/adapter-ruby/tests/unit/ruby-debug-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,39 @@ describe('RubyDebugAdapter', () => {
expect(command.env?.RUBY_DEBUG_DAP_SHOW_PROTOCOL).toBe('1');
});

it('carries launchConfig.cwd on the adapter command (issue #320)', () => {
vi.mocked(ensureRubySyncHelper).mockReturnValue('/tmp/logs/mcp_stdout_sync.rb');
const adapter = new RubyDebugAdapter(createDependencies());
(adapter as unknown as { rdbgPathCache: Map<string, { path: string; timestamp: number }> })
.rdbgPathCache.set('default', { path: '/usr/bin/rdbg', timestamp: Date.now() });

const withCwd = 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: { cwd: '/workspace/subdir' }
});
// rdbg -c starts the debuggee at spawn time, so the spawn cwd is the
// only channel — the DAP launch request's cwd is an ack.
expect(withCwd.cwd).toBe('/workspace/subdir');

const withoutCwd = 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(withoutCwd.cwd).toBeUndefined();
});

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');
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/interfaces/adapter-policy-ruby.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,9 @@ export const RubyAdapterPolicy: AdapterPolicy = {
port: payload.adapterPort,
logDir: payload.logDir,
env: payload.adapterCommand.env,
// The debuggee inherits this process's working directory (rdbg -c),
// so the user's launch cwd must be applied at spawn (issue #320).
cwd: payload.adapterCommand.cwd,
// rdbg -c runs the debuggee as a child of the adapter process with
// inherited stdio while DAP travels over TCP — the program's output
// only ever appears on the adapter's pipes (issue #222). rdbg's own
Expand Down
2 changes: 1 addition & 1 deletion packages/shared/src/interfaces/adapter-policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,7 @@ export interface AdapterSpawnPayload {
logDir: string;
scriptPath: string;
launchConfig?: LanguageSpecificLaunchConfig;
adapterCommand?: { command: string; args: string[]; env?: Record<string, string> };
adapterCommand?: { command: string; args: string[]; env?: Record<string, string>; cwd?: string };
}

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/interfaces/debug-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,12 @@ export interface AdapterCommand {
command: string;
args: string[];
env?: Record<string, string>;
/**
* Working directory for the adapter process. Needed by adapters whose
* debuggee starts at spawn time (rdbg -c), where the DAP launch request's
* cwd can no longer be applied (issue #320).
*/
cwd?: string;
}

/**
Expand Down
3 changes: 2 additions & 1 deletion src/utils/type-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ export function validateAdapterCommand(obj: unknown, source: string): AdapterCom
requiredStructure: {
command: 'string (required)',
args: 'string[] (required)',
env: 'Record<string, string> (optional)'
env: 'Record<string, string> (optional)',
cwd: 'string (optional)'
}
};

Expand Down
15 changes: 15 additions & 0 deletions tests/adapters/ruby/unit/adapter-policy-ruby.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,21 @@ describe('RubyAdapterPolicy.getAdapterSpawnConfig', () => {
expect(pattern!.test('log: DEBUGGER: mentioned mid-line')).toBe(false);
});

it('routes the adapter command cwd into the spawn config (issue #320)', () => {
const config = RubyAdapterPolicy.getAdapterSpawnConfig!({
...basePayload,
launchConfig: { request: 'launch' },
adapterCommand: {
command: '/usr/bin/rdbg',
args: ['--open'],
cwd: '/workspace/subdir'
}
});

expect(config.mode).toBe('spawn');
expect((config as { cwd?: string }).cwd).toBe('/workspace/subdir');
});

it('does not enable stdio forwarding for attach (no adapter process exists)', () => {
const config = RubyAdapterPolicy.getAdapterSpawnConfig!({
...basePayload,
Expand Down
45 changes: 45 additions & 0 deletions tests/e2e/mcp-server-smoke-ruby.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,4 +310,49 @@ describe('MCP Server Ruby Debugging Smoke Test @requires-ruby', () => {
// rdbg inspects results, so a String comes back quoted.
expect(String(evalResult.result)).toContain('hello-318');
}, 90000);

it('applies dapLaunchArgs.cwd to the debuggee (issue #320)', async () => {
if (!(await rubyToolchainAvailable())) {
console.log('[Ruby Smoke Test] Ruby/rdbg not available, skipping launch cwd test');
return;
}

const testRubyFile = path.resolve(ROOT, 'examples', 'ruby', 'fizzbuzz.rb');
const targetCwd = path.resolve(ROOT, 'examples', 'ruby');

const createResponse = parseSdkToolResult(await mcpClient!.callTool({
name: 'create_debug_session',
arguments: { language: 'ruby', name: 'ruby-launch-cwd-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 cwd can only reach it
// through the spawn options — the DAP launch request is an ack.
const startResponse = parseSdkToolResult(await mcpClient!.callTool({
name: 'start_debugging',
arguments: {
sessionId,
scriptPath: testRubyFile,
dapLaunchArgs: { cwd: targetCwd }
}
})) as { state?: string };
expect(startResponse.state).toBe('paused');

// Evaluate only the tail of the path: the full Dir.pwd is rooted in the
// user's home dir, which the #237 redaction (on by default) masks as
// <redacted:sensitive-name>, hiding the segments we assert on.
const evalResult = await callToolSafely(mcpClient!, 'evaluate_expression', {
sessionId,
expression: "Dir.pwd.split('/').last(2).join('/')"
});
expect(String(evalResult.result)).toContain('examples/ruby');
}, 90000);
});
Loading