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
5 changes: 4 additions & 1 deletion docs/architecture/debugMCPServer.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ extension. To avoid debugging the wrong workspace when several windows are open:
- **Every window** starts a loopback `ControlServer` (`src/controlServer.ts`) that runs
debug operations against *its own* `DebuggingHandler`, and advertises its workspace
folders (plus control port + token) in a shared file registry
(`src/utils/workspaceRegistry.ts`).
(`src/utils/workspaceRegistry.ts`). The registry lives under VS Code's per-user
extension storage rather than the system temporary directory. On POSIX systems its
directory and entries are restricted to the owning user (`0700` and `0600`), and
entries are replaced atomically so a partial credential file is never exposed.
- **One window** wins the public MCP port and becomes the **router**. Its per-MCP-session
handler is a `RoutingDebuggingHandler` (`src/routingDebuggingHandler.ts`) that resolves
the target window from the request's `workingDirectory`/`fileFullPath` and forwards the
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "debugmcpextension",
"displayName": "DebugMCP — Agentic Debugging for VS Code, Cursor & More",
"description": "Your AI agent debugs for you — right inside VS Code, Cursor & other VS Code-based editors. Let Copilot, Cline, Cursor, Codex & any MCP agent set breakpoints, step through code, and inspect variables live instead of guessing from logs.",
"version": "2.3.6",
"version": "2.3.7",
"publisher": "ozzafar",
"author": {
"name": "Oz Zafar",
Expand Down
4 changes: 3 additions & 1 deletion src/extension.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft Corporation.

import * as vscode from 'vscode';
import * as path from 'path';
import { randomUUID } from 'node:crypto';
import { DebugMCPServer } from './debugMCPServer';
import { DebuggingExecutor, ConfigurationManager, DebuggingHandler } from '.';
Expand Down Expand Up @@ -72,7 +73,8 @@ export async function activate(context: vscode.ExtensionContext) {
controlServer = new ControlServer(localHandler, controlToken);
const controlPort = await controlServer.start();

registry = new WorkspaceRegistry();
const registryDir = path.join(context.globalStorageUri.fsPath, 'window-registry');
registry = new WorkspaceRegistry(process.pid, registryDir);
const registerSelf = () => {
registry?.register({
controlPort,
Expand Down
27 changes: 27 additions & 0 deletions src/test/workspaceRegistry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,33 @@ suite('WorkspaceRegistry', () => {
assert.strictEqual(entries[0].pid, process.pid);
});

test('register stores the token in an owner-only registry on POSIX', function () {
if (process.platform === 'win32') {
this.skip();
}

const reg = new WorkspaceRegistry(process.pid, dir);
reg.register({ controlPort: 1234, controlToken: 'secret', workspaceFolders: [], name: 'A' });

assert.strictEqual(fs.statSync(dir).mode & 0o777, 0o700);
assert.strictEqual(fs.statSync(path.join(dir, `window-${process.pid}.json`)).mode & 0o777, 0o600);
});

test('heartbeat repairs an overly permissive registry file on POSIX', function () {
if (process.platform === 'win32') {
this.skip();
}

const reg = new WorkspaceRegistry(process.pid, dir);
reg.register({ controlPort: 1234, controlToken: 'secret', workspaceFolders: [], name: 'A' });
const file = path.join(dir, `window-${process.pid}.json`);
fs.chmodSync(file, 0o644);

reg.heartbeat();

assert.strictEqual(fs.statSync(file).mode & 0o777, 0o600);
});

test('unregister removes the entry', () => {
const reg = new WorkspaceRegistry(process.pid, dir);
reg.register({ controlPort: 1, controlToken: 't', workspaceFolders: [], name: 'A' });
Expand Down
81 changes: 69 additions & 12 deletions src/utils/workspaceRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Copyright (c) Microsoft Corporation.

import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { randomUUID } from 'node:crypto';
import { logger } from './logger';

/** One VS Code window's advertisement in the shared registry. */
Expand All @@ -15,11 +15,10 @@ export interface WindowRegistration {
updatedAt: number;
}

/** Default directory holding one JSON file per live window. */
const DEFAULT_REGISTRY_DIR = path.join(os.tmpdir(), 'debugmcp-registry');

/** Entries not refreshed within this window are considered stale. */
const STALE_MS = 60_000;
const REGISTRY_DIR_MODE = 0o700;
const REGISTRY_FILE_MODE = 0o600;

/** Best-effort pid liveness check (EPERM means the process exists). */
function isProcessAlive(pid: number): boolean {
Expand Down Expand Up @@ -53,30 +52,33 @@ export class WorkspaceRegistry {
private readonly registryDir: string;
private readonly filePath: string;

constructor(private readonly pid: number = process.pid, registryDir: string = DEFAULT_REGISTRY_DIR) {
constructor(private readonly pid: number = process.pid, registryDir: string) {
this.registryDir = registryDir;
this.filePath = path.join(this.registryDir, `window-${this.pid}.json`);
this.ensureSecureDirectory();
}

/** Write (or overwrite) this window's registration. */
public register(reg: Omit<WindowRegistration, 'pid' | 'updatedAt'>): void {
try {
fs.mkdirSync(this.registryDir, { recursive: true });
const entry: WindowRegistration = { ...reg, pid: this.pid, updatedAt: Date.now() };
fs.writeFileSync(this.filePath, JSON.stringify(entry), 'utf8');
this.writeEntry(entry);
} catch (error) {
logger.error('Failed to write DebugMCP registry entry', error);
throw error;
}
}

/** Refresh `updatedAt` so other windows don't prune this one. */
public heartbeat(): void {
try {
const entry = JSON.parse(fs.readFileSync(this.filePath, 'utf8')) as WindowRegistration;
const entry = this.readEntry(this.filePath);
entry.updatedAt = Date.now();
fs.writeFileSync(this.filePath, JSON.stringify(entry), 'utf8');
} catch {
// Entry missing — caller re-registers on change.
this.writeEntry(entry);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
logger.error('Failed to refresh DebugMCP registry entry', error);
}
}
}

Expand All @@ -87,6 +89,7 @@ export class WorkspaceRegistry {

/** All live windows, pruning dead-pid and stale entries as a side effect. */
public list(): WindowRegistration[] {
this.ensureSecureDirectory();
let files: string[];
try {
files = fs.readdirSync(this.registryDir);
Expand All @@ -100,7 +103,7 @@ export class WorkspaceRegistry {
}
const full = path.join(this.registryDir, file);
try {
const entry = JSON.parse(fs.readFileSync(full, 'utf8')) as WindowRegistration;
const entry = this.readEntry(full);
const isStale = Date.now() - entry.updatedAt > STALE_MS;
if (!isProcessAlive(entry.pid) || (isStale && entry.pid !== this.pid)) {
this.tryUnlink(full);
Expand Down Expand Up @@ -144,6 +147,60 @@ export class WorkspaceRegistry {
return best;
}

private ensureSecureDirectory(): void {
fs.mkdirSync(this.registryDir, { recursive: true, mode: REGISTRY_DIR_MODE });
const stats = fs.lstatSync(this.registryDir);
if (stats.isSymbolicLink() || !stats.isDirectory()) {
throw new Error(`DebugMCP registry path is not a secure directory: ${this.registryDir}`);
}
this.assertOwnedByCurrentUser(stats, this.registryDir);
if (process.platform !== 'win32') {
fs.chmodSync(this.registryDir, REGISTRY_DIR_MODE);
}
}

private readEntry(full: string): WindowRegistration {
const stats = fs.lstatSync(full);
if (stats.isSymbolicLink() || !stats.isFile()) {
throw new Error(`DebugMCP registry entry is not a regular file: ${full}`);
}
this.assertOwnedByCurrentUser(stats, full);
if (process.platform !== 'win32') {
fs.chmodSync(full, REGISTRY_FILE_MODE);
}
return JSON.parse(fs.readFileSync(full, 'utf8')) as WindowRegistration;
}

private writeEntry(entry: WindowRegistration): void {
this.ensureSecureDirectory();
const temporaryPath = path.join(
this.registryDir,
`.${path.basename(this.filePath)}.${randomUUID()}.tmp`
);
try {
fs.writeFileSync(temporaryPath, JSON.stringify(entry), {
encoding: 'utf8',
flag: 'wx',
mode: REGISTRY_FILE_MODE
});
if (process.platform !== 'win32') {
fs.chmodSync(temporaryPath, REGISTRY_FILE_MODE);
}
fs.renameSync(temporaryPath, this.filePath);
if (process.platform !== 'win32') {
fs.chmodSync(this.filePath, REGISTRY_FILE_MODE);
}
} finally {
this.tryUnlink(temporaryPath);
}
}

private assertOwnedByCurrentUser(stats: fs.Stats, full: string): void {
if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) {
throw new Error(`DebugMCP registry path is not owned by the current user: ${full}`);
}
}

private tryUnlink(full: string): void {
try {
fs.unlinkSync(full);
Expand Down
Loading