fix(debug): accept directory programs for dlv and auto-select dlv mode
The debug tool ran validateLaunchProgram before adapter selection and rejected any directory program with `launch program resolves to a directory`, while dlv's default `mode=debug` requires a Go package path (a directory or .go source file). Every Go-module launch failed: passing the module dir was rejected outright, and passing the compiled binary failed at dlv with `not a valid go module`. - Add `acceptsDirectoryProgram` to DapAdapterConfig/DapResolvedAdapter and flag dlv in dap/defaults.json. - In DebugTool.execute(launch), resolve the adapter first, then call validateLaunchProgram with the resolved adapter — the directory rejection only fires when the adapter does not advertise the flag. - Add resolveLaunchOverrides in dap/config.ts: for dlv, derive `mode` from the program shape (directory or .go file → debug; other file → exec). Plumbed through DapLaunchSessionOptions.extraLaunchArguments and spread between adapter.launchDefaults and the hard-coded launch fields in session.ts. - Refresh tests: cover the no-dir-support adapter rejection, dlv on a package directory keeping mode=debug, and dlv on a binary switching to mode=exec. Fixes #2020
This commit is contained in:
@@ -2,6 +2,10 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed `debug` tool refusing every `dlv` launch on Go modules. The launch handler ran `validateLaunchProgram` before adapter selection and rejected any directory program with `launch program resolves to a directory`, while dlv's default `mode=debug` requires a Go package path (a directory or `.go` source file). Adapter resolution now precedes validation, the rejection only fires when the resolved adapter does not advertise `acceptsDirectoryProgram` (set on `dlv` in `dap/defaults.json`), and dlv's `mode` is derived from the program shape — directories and `.go` files launch as `mode=debug`, other files as `mode=exec` — so `omp` can debug both Go packages and pre-built binaries ([#2020](https://github.com/can1357/oh-my-pi/issues/2020)).
|
||||
|
||||
## [15.10.0] - 2026-06-06
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
@@ -27,6 +27,7 @@ function normalizeAdapterConfig(config: unknown): DapAdapterConfig | null {
|
||||
rootMarkers: normalizeStringArray(config.rootMarkers),
|
||||
launchDefaults: normalizeObject(config.launchDefaults),
|
||||
attachDefaults: normalizeObject(config.attachDefaults),
|
||||
acceptsDirectoryProgram: config.acceptsDirectoryProgram === true,
|
||||
...(connectMode ? { connectMode } : {}),
|
||||
};
|
||||
}
|
||||
@@ -64,6 +65,7 @@ export function resolveAdapter(adapterName: string, cwd: string): DapResolvedAda
|
||||
launchDefaults: config.launchDefaults ?? {},
|
||||
attachDefaults: config.attachDefaults ?? {},
|
||||
connectMode: config.connectMode ?? "stdio",
|
||||
acceptsDirectoryProgram: config.acceptsDirectoryProgram === true,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -148,3 +150,33 @@ export function selectAttachAdapter(cwd: string, adapterName?: string, port?: nu
|
||||
}
|
||||
return available[0] ?? null;
|
||||
}
|
||||
|
||||
/** How the launch `program` resolves on disk. `"missing"` is reserved for
|
||||
* programs the adapter creates on demand (rare); we treat them like files. */
|
||||
export type LaunchProgramKind = "file" | "directory" | "missing";
|
||||
|
||||
/** Compute adapter-specific launch arguments that depend on the resolved
|
||||
* program. Returned values are spread over `adapter.launchDefaults` so they
|
||||
* take precedence over the static defaults but can still be overridden by
|
||||
* the fields `DapSessionManager.launch` sets explicitly (program, cwd, args).
|
||||
*
|
||||
* Currently scoped to dlv, where `mode` selects how the program path is
|
||||
* interpreted: directories and `.go` source files debug as a Go package
|
||||
* (`mode=debug`), anything else is treated as a compiled binary (`mode=exec`).
|
||||
*/
|
||||
export function resolveLaunchOverrides(
|
||||
adapter: DapResolvedAdapter,
|
||||
program: string,
|
||||
programKind: LaunchProgramKind,
|
||||
): Record<string, unknown> {
|
||||
if (adapter.name === "dlv") {
|
||||
const extension = path.extname(program).toLowerCase();
|
||||
if (programKind === "directory" || extension === ".go") {
|
||||
return { mode: "debug" };
|
||||
}
|
||||
if (programKind === "file") {
|
||||
return { mode: "exec" };
|
||||
}
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
"languages": ["go"],
|
||||
"fileTypes": [".go"],
|
||||
"rootMarkers": ["go.mod", "go.sum"],
|
||||
"acceptsDirectoryProgram": true,
|
||||
"launchDefaults": {
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
|
||||
@@ -259,6 +259,7 @@ export class DapSessionManager {
|
||||
session.needsConfigurationDone = session.capabilities.supportsConfigurationDoneRequest === true;
|
||||
const launchArguments: DapLaunchArguments = {
|
||||
...options.adapter.launchDefaults,
|
||||
...(options.extraLaunchArguments ?? {}),
|
||||
program: options.program,
|
||||
cwd: options.cwd,
|
||||
args: options.args,
|
||||
|
||||
@@ -488,6 +488,10 @@ export interface DapAdapterConfig {
|
||||
* On Linux, connects via a unix domain socket.
|
||||
* On macOS, the adapter dials into a local TCP listener (--client-addr). */
|
||||
connectMode?: "stdio" | "socket";
|
||||
/** When true, the adapter accepts a directory as the launch `program`
|
||||
* (e.g. dlv treats it as a Go package path). When false/undefined, the
|
||||
* debug tool rejects directory programs upfront. */
|
||||
acceptsDirectoryProgram?: boolean;
|
||||
}
|
||||
|
||||
export interface DapResolvedAdapter {
|
||||
@@ -501,6 +505,7 @@ export interface DapResolvedAdapter {
|
||||
launchDefaults: Record<string, unknown>;
|
||||
attachDefaults: Record<string, unknown>;
|
||||
connectMode: "stdio" | "socket";
|
||||
acceptsDirectoryProgram: boolean;
|
||||
}
|
||||
|
||||
export interface DapBreakpointRecord {
|
||||
@@ -589,6 +594,11 @@ export interface DapLaunchSessionOptions {
|
||||
program: string;
|
||||
args?: string[];
|
||||
cwd: string;
|
||||
/** Per-launch overrides merged over `adapter.launchDefaults`. Used to
|
||||
* inject adapter-specific values that depend on the resolved program
|
||||
* (e.g. dlv's `mode` switches between `debug` and `exec` based on
|
||||
* whether `program` is a Go package path or a compiled binary). */
|
||||
extraLaunchArguments?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface DapAttachSessionOptions {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
type DapFunctionBreakpointRecord,
|
||||
type DapInstructionBreakpointRecord,
|
||||
type DapModule,
|
||||
type DapResolvedAdapter,
|
||||
type DapScope,
|
||||
type DapSessionSummary,
|
||||
type DapSource,
|
||||
@@ -30,6 +31,8 @@ import {
|
||||
type DapVariable,
|
||||
dapSessionManager,
|
||||
getAvailableAdapters,
|
||||
type LaunchProgramKind,
|
||||
resolveLaunchOverrides,
|
||||
selectAttachAdapter,
|
||||
selectLaunchAdapter,
|
||||
} from "../dap";
|
||||
@@ -489,16 +492,23 @@ function getConfiguredAdapters(cwd: string): string {
|
||||
const adapters = getAvailableAdapters(cwd).map(adapter => adapter.name);
|
||||
return adapters.length > 0 ? adapters.join(", ") : "none";
|
||||
}
|
||||
async function validateLaunchProgram(program: string, cwd: string): Promise<void> {
|
||||
let isDirectory: boolean;
|
||||
|
||||
async function classifyLaunchProgram(program: string): Promise<LaunchProgramKind> {
|
||||
try {
|
||||
isDirectory = (await fs.stat(program)).isDirectory();
|
||||
return (await fs.stat(program)).isDirectory() ? "directory" : "file";
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) return;
|
||||
if (isEnoent(error)) return "missing";
|
||||
throw error;
|
||||
}
|
||||
if (!isDirectory) return;
|
||||
}
|
||||
|
||||
function validateLaunchProgram(
|
||||
program: string,
|
||||
cwd: string,
|
||||
programKind: LaunchProgramKind,
|
||||
adapter: DapResolvedAdapter,
|
||||
): void {
|
||||
if (programKind !== "directory" || adapter.acceptsDirectoryProgram) return;
|
||||
const displayPath = formatPathRelativeToCwd(program, cwd, { trailingSlash: true });
|
||||
throw new ToolError(
|
||||
`launch program resolves to a directory: ${displayPath}. Pass an executable file path, or for Python use adapter "debugpy" with program set to the .py file.`,
|
||||
@@ -676,7 +686,7 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
|
||||
}
|
||||
const commandCwd = params.cwd ? resolveToCwd(params.cwd, this.session.cwd) : this.session.cwd;
|
||||
const program = resolveToCwd(params.program, commandCwd);
|
||||
await validateLaunchProgram(program, commandCwd);
|
||||
const programKind = await classifyLaunchProgram(program);
|
||||
const adapter = selectLaunchAdapter(program, commandCwd, params.adapter);
|
||||
if (!adapter) {
|
||||
if (params.adapter === "debugpy") {
|
||||
@@ -686,8 +696,10 @@ export class DebugTool implements AgentTool<typeof debugSchema, DebugToolDetails
|
||||
`No debugger adapter available. Installed adapters: ${getConfiguredAdapters(commandCwd)}`,
|
||||
);
|
||||
}
|
||||
validateLaunchProgram(program, commandCwd, programKind, adapter);
|
||||
const extraLaunchArguments = resolveLaunchOverrides(adapter, program, programKind);
|
||||
const snapshot = await dapSessionManager.launch(
|
||||
{ adapter, program, args: params.args, cwd: commandCwd },
|
||||
{ adapter, program, args: params.args, cwd: commandCwd, extraLaunchArguments },
|
||||
combinedSignal,
|
||||
timeoutSec * 1000,
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ const TEST_ADAPTER: DapResolvedAdapter = {
|
||||
launchDefaults: {},
|
||||
attachDefaults: {},
|
||||
connectMode: "stdio",
|
||||
acceptsDirectoryProgram: false,
|
||||
};
|
||||
|
||||
const DELAYED_UNIX_SOCKET_ADAPTER = `
|
||||
@@ -338,24 +339,116 @@ describe("DAP launch failure handling", () => {
|
||||
});
|
||||
|
||||
describe("DebugTool launch validation", () => {
|
||||
it("rejects directory-valued launch programs before adapter selection", async () => {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-program-"));
|
||||
it("rejects directory programs when the selected adapter cannot debug a directory", async () => {
|
||||
const dapModule = await import("../../src/dap");
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(TEST_ADAPTER);
|
||||
try {
|
||||
await fs.mkdir(path.join(cwd, "python"));
|
||||
const session: ToolSession = {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
settings: Settings.isolated({ "debug.enabled": true }),
|
||||
};
|
||||
const tool = new DebugTool(session);
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-program-"));
|
||||
try {
|
||||
await fs.mkdir(path.join(cwd, "python"));
|
||||
const session: ToolSession = {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
settings: Settings.isolated({ "debug.enabled": true }),
|
||||
};
|
||||
const tool = new DebugTool(session);
|
||||
|
||||
await expect(tool.execute("call", { action: "launch", program: "python" })).rejects.toThrow(
|
||||
/launch program resolves to a directory.*python/,
|
||||
);
|
||||
await expect(tool.execute("call", { action: "launch", program: "python" })).rejects.toThrow(
|
||||
/launch program resolves to a directory.*python/,
|
||||
);
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
launchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("allows directory programs when the selected adapter accepts them (dlv on a Go package)", async () => {
|
||||
const dapModule = await import("../../src/dap");
|
||||
const dlvAdapter: DapResolvedAdapter = {
|
||||
...TEST_ADAPTER,
|
||||
name: "dlv",
|
||||
command: "dlv",
|
||||
resolvedCommand: "dlv",
|
||||
launchDefaults: { request: "launch", mode: "debug", stopOnEntry: true },
|
||||
acceptsDirectoryProgram: true,
|
||||
};
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(dlvAdapter);
|
||||
const sessionLaunchSpy = spyOn(dapModule.dapSessionManager, "launch").mockImplementation(async opts => {
|
||||
throw Object.assign(new Error("captured launch"), { capturedOptions: opts });
|
||||
});
|
||||
try {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-dlv-dir-"));
|
||||
try {
|
||||
await fs.mkdir(path.join(cwd, "cmd"));
|
||||
const session: ToolSession = {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
settings: Settings.isolated({ "debug.enabled": true }),
|
||||
};
|
||||
const tool = new DebugTool(session);
|
||||
|
||||
// Validation must pass and propagate to dapSessionManager.launch; we
|
||||
// stop the actual spawn there and inspect the launch arguments.
|
||||
await expect(tool.execute("call", { action: "launch", program: "cmd", adapter: "dlv" })).rejects.toThrow(
|
||||
/captured launch/,
|
||||
);
|
||||
expect(sessionLaunchSpy).toHaveBeenCalledTimes(1);
|
||||
const [opts] = sessionLaunchSpy.mock.calls[0]!;
|
||||
expect(opts.extraLaunchArguments).toEqual({ mode: "debug" });
|
||||
expect(opts.program).toBe(path.join(cwd, "cmd"));
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
sessionLaunchSpy.mockRestore();
|
||||
launchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("dlv launch with a compiled binary switches mode from debug to exec", async () => {
|
||||
const dapModule = await import("../../src/dap");
|
||||
const dlvAdapter: DapResolvedAdapter = {
|
||||
...TEST_ADAPTER,
|
||||
name: "dlv",
|
||||
command: "dlv",
|
||||
resolvedCommand: "dlv",
|
||||
launchDefaults: { request: "launch", mode: "debug", stopOnEntry: true },
|
||||
acceptsDirectoryProgram: true,
|
||||
};
|
||||
const launchSpy = spyOn(dapModule, "selectLaunchAdapter").mockReturnValue(dlvAdapter);
|
||||
const sessionLaunchSpy = spyOn(dapModule.dapSessionManager, "launch").mockImplementation(async opts => {
|
||||
throw Object.assign(new Error("captured launch"), { capturedOptions: opts });
|
||||
});
|
||||
try {
|
||||
const cwd = await fs.mkdtemp(path.join(os.tmpdir(), "omp-debug-dlv-exec-"));
|
||||
try {
|
||||
await fs.writeFile(path.join(cwd, "hello"), "#!/usr/bin/env sh\necho hi\n");
|
||||
const session: ToolSession = {
|
||||
cwd,
|
||||
hasUI: false,
|
||||
getSessionFile: () => null,
|
||||
getSessionSpawns: () => "*",
|
||||
settings: Settings.isolated({ "debug.enabled": true }),
|
||||
};
|
||||
const tool = new DebugTool(session);
|
||||
|
||||
await expect(tool.execute("call", { action: "launch", program: "hello", adapter: "dlv" })).rejects.toThrow(
|
||||
/captured launch/,
|
||||
);
|
||||
const [opts] = sessionLaunchSpy.mock.calls[0]!;
|
||||
expect(opts.extraLaunchArguments).toEqual({ mode: "exec" });
|
||||
} finally {
|
||||
await fs.rm(cwd, { recursive: true, force: true });
|
||||
}
|
||||
} finally {
|
||||
sessionLaunchSpy.mockRestore();
|
||||
launchSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user