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:
roboomp
2026-06-07 02:59:02 +00:00
parent fe607cf22c
commit 1745529368
7 changed files with 175 additions and 22 deletions
+4
View File
@@ -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
+32
View File
@@ -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",
+1
View File
@@ -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,
+10
View File
@@ -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 {
+19 -7
View File
@@ -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();
}
});