diff --git a/.vscodeignore b/.vscodeignore index b6f16c7..50542eb 100644 --- a/.vscodeignore +++ b/.vscodeignore @@ -3,6 +3,7 @@ node_modules/** # Exclude test files tests/** +test/** # Exclude build scripts scripts/** diff --git a/CLAUDE.md b/CLAUDE.md index 1b1bc21..a23e651 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -304,7 +304,6 @@ FastEdge-vscode/ │ ├── FastEdgeDebugAdapterDescriptorFactory.ts │ ├── compiler/ # Rust/JS compilation │ ├── commands/ # VS Code commands -│ ├── dotenv/ # Dotenv handling │ └── autorun/ # File watching ├── fastedge-cli/ # Bundled FastEdge-run binary ├── package.json # Extension manifest diff --git a/context/CONTEXT_INDEX.md b/context/CONTEXT_INDEX.md index bbcc160..83cea55 100644 --- a/context/CONTEXT_INDEX.md +++ b/context/CONTEXT_INDEX.md @@ -73,6 +73,10 @@ Use this tree to find relevant documentation for your task: → Read: `features/MCP_INTEGRATION.md` → Read: `features/COMMANDS.md` (mcpJson command) +**Task: Bump the pinned MCP server Docker image version** +→ Edit: `mcp-server.version` (one line — the only file to change) +→ Read: `features/MCP_INTEGRATION.md` (explains the build-time injection) + **Task: Add new configuration option** → Read: `architecture/CONFIGURATION_SYSTEM.md` → Read: `BUNDLED_DEBUGGER.md` (fastedge-config.test.json section) @@ -134,7 +138,7 @@ Use this tree to find relevant documentation for your task: | **DOTENV_SYSTEM.md** | Dotenv file handling | Dotenv loading issues | | **CROSS_PLATFORM.md** | Linux/macOS/Windows support, CI matrix, spawn rules | Any platform-specific work or new process spawning | | **LAUNCH_CONFIG.md** | Launch.json generation | Launch config changes | -| **MCP_INTEGRATION.md** | MCP server configuration | MCP feature work | +| **MCP_INTEGRATION.md** | MCP server config, image version pinning, how to bump | MCP feature work or bumping the server version | | **AUTORUN_SYSTEM.md** | File watching, auto-trigger | Auto-run functionality | | **CODESPACE_SECRETS.md** | GitHub Codespaces integration | Codespaces features | @@ -234,7 +238,7 @@ See `SEARCH_GUIDE.md` for more patterns. - Run debugger (current file or package entry) - Setup Codespace secrets -5. **Configuration** (`src/dotenv/`, `src/utils/resolveAppRoot.ts`) +5. **Configuration** (`src/utils/resolveAppRoot.ts`) - Dotenv file auto-discovery from `configRoot` - `.fastedge-debug/` directory as app root marker; `fastedge-config.test.json` as runtime config store - `resolveConfigRoot()` (finds `.fastedge-debug/`) / `resolveBuildRoot()` for per-app isolation diff --git a/context/features/DOTENV_SYSTEM.md b/context/features/DOTENV_SYSTEM.md index 43d22f4..d993944 100644 --- a/context/features/DOTENV_SYSTEM.md +++ b/context/features/DOTENV_SYSTEM.md @@ -13,8 +13,6 @@ The dotenv system allows developers to: - Support large configuration sets - Share configurations across team -**File**: `src/dotenv/index.ts` - **See also**: `../DOTENV.md` (root) - User-facing documentation --- diff --git a/context/features/MCP_INTEGRATION.md b/context/features/MCP_INTEGRATION.md new file mode 100644 index 0000000..f945650 --- /dev/null +++ b/context/features/MCP_INTEGRATION.md @@ -0,0 +1,105 @@ +# MCP Integration + +The extension can generate a `.vscode/mcp.json` that wires up the +`fastedge-assistant` MCP server so AI clients (Claude, Codex, Cursor) can +use FastEdge tools from inside the workspace. + +--- + +## How it works + +Command: **FastEdge (Generate mcp.json)** → `src/commands/mcpJson.ts` + +1. Reads any existing `.vscode/mcp.json` and merges the new server entry in. +2. Detects Codespaces (`CODESPACE_NAME` env): offers `gh secret set` path + (stores key as a Codespace secret, emits `${env:GCORE_API_KEY}` in the + file) or falls back to inline key with a security notice. +3. Prompts for the API key (**masked input**, `password: true`). +4. Writes the file; sets `chmod 0600` on local `file://` URIs (no-op on + Windows / remote providers). +5. Offers to add `.vscode/mcp.json` to `.gitignore`. + +The generated entry looks like: + +```json +{ + "servers": { + "fastedge-assistant": { + "type": "stdio", + "command": "docker", + "args": ["run", "--rm", "-i", "--pull=always", + "-v", "${workspaceFolder}:/workspace", + "-e", "WORKSPACE_ROOT=/workspace", + "-e", "GCORE_API_KEY", + "ghcr.io/g-core/fastedge-mcp-server:0.2.9"], + "env": { "GCORE_API_KEY": "" } + } + } +} +``` + +--- + +## Pinned image version — how to bump it + +Tags on ghcr.io have no `v` prefix. The Docker image tag (`0.2.9` above) is **not hardcoded in source**. It is +read from a single file at build time and injected by esbuild: + +``` +mcp-server.version ← edit this file to bump the version +esbuild/build-ext.js ← reads the file, passes to esbuild define +src/globals.d.ts ← TypeScript ambient declaration +src/commands/mcpJson.ts ← uses __MCP_SERVER_VERSION__ (injected constant) +``` + +**To bump the version:** + +```bash +echo "0.3.0" > mcp-server.version +# rebuild — the new tag is baked into dist/extension.js +npm run build +``` + +A future CI job in the MCP server's release pipeline can automate this step +by committing the updated `mcp-server.version` file and triggering a new +extension release. + +**Do not edit the version string in `mcpJson.ts` directly** — it uses the +injected constant and will not reflect manual edits after a rebuild. + +--- + +## Key files + +| File | Role | +|------|------| +| `mcp-server.version` | Single source of truth for the pinned image tag | +| `esbuild/build-ext.js` | Reads version file, injects `__MCP_SERVER_VERSION__` via esbuild `define` | +| `src/globals.d.ts` | Ambient TS declaration for `__MCP_SERVER_VERSION__` | +| `src/commands/mcpJson.ts` | `createMCPJson` command + `getDockerCommand` builder | +| `src/commands/mcpJson.test.ts` | Unit tests for `getDockerCommand` argv shape | + +--- + +## `getDockerCommand` — security invariants + +The docker command is built as an **argv array** (no shell wrapper), so the +workspace path in `-v ${workspaceFolder}:/workspace` cannot inject shell +syntax. Tests in `mcpJson.test.ts` assert this. Do not add `bash -c` or +`cmd /c` wrappers. + +Credentials are forwarded with bare `-e GCORE_API_KEY` (value comes from the +MCP client's `env` block, never from shell expansion). + +--- + +## Codespace path + +When `CODESPACE_NAME` is set, the command offers to call `setupCodespaceSecret` +first. That function stores the key via `gh secret set` (spawned with `spawn`, +secret on stdin — not in argv). If the user takes this path, the generated +file uses `${env:GCORE_API_KEY}` instead of an inline key. + +--- + +**Last Updated**: 2026-09-01 diff --git a/esbuild/build-ext.js b/esbuild/build-ext.js index dbd64e4..0e7826b 100644 --- a/esbuild/build-ext.js +++ b/esbuild/build-ext.js @@ -1,8 +1,16 @@ const esbuild = require("esbuild"); +const fs = require("fs"); +const path = require("path"); const isProduction = process.argv.includes("--prod"); const isWatching = process.argv.includes("--watch"); +// Read the pinned MCP server image version from the version file. +// A future CI job can update this file on MCP server release. +const mcpServerVersion = fs + .readFileSync(path.join(__dirname, "../mcp-server.version"), "utf8") + .trim(); + async function main() { const ctx = await esbuild.context({ entryPoints: ["./src/extension.ts"], @@ -16,6 +24,9 @@ async function main() { external: ["vscode"], mainFields: ["module", "main"], logLevel: "info", + define: { + __MCP_SERVER_VERSION__: JSON.stringify(mcpServerVersion), + }, plugins: [ /* add to the end of plugins array */ esbuildProblemMatcherPlugin, diff --git a/mcp-server.version b/mcp-server.version new file mode 100644 index 0000000..d81f1c3 --- /dev/null +++ b/mcp-server.version @@ -0,0 +1 @@ +0.2.9 \ No newline at end of file diff --git a/package.json b/package.json index fd8c1ad..f2a009d 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "fastedge", "displayName": "FastEdge Launcher", - "version": "0.2.5", + "version": "0.2.7-rc1", "publisher": "g-corelabssa", "description": "Launcher for FastEdge apps", "icon": "images/fastedge.png", @@ -56,7 +56,7 @@ "fastedge.apiUrl": { "type": "string", "default": "https://api.gcore.com", - "description": "Advanced override for the Gcore API base URL used by the FastEdge MCP server. Leave at the default for normal use. Set to a non-prod URL (e.g. https://api.preprod.world) for in-house development \u2014 'FastEdge (Generate mcp.json)' will emit it as GCORE_API_BASE. See DEVELOPMENT.md.", + "description": "Advanced override for the Gcore API base URL used by the FastEdge MCP server. Leave at the default for normal use. Set to a non-prod URL (e.g. https://api.preprod.world) for in-house development — 'FastEdge (Generate mcp.json)' will emit it as GCORE_API_BASE. See DEVELOPMENT.md.", "scope": "application" } } diff --git a/src/autorun/triggerFileHandler.test.ts b/src/autorun/triggerFileHandler.test.ts new file mode 100644 index 0000000..f475639 --- /dev/null +++ b/src/autorun/triggerFileHandler.test.ts @@ -0,0 +1,69 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mocks must be declared with vi.hoisted() so they're available inside the +// vi.mock factory, which Vitest hoists above all import statements. +const mocks = vi.hoisted(() => ({ + state: { isTrusted: true }, + executeCommand: vi.fn().mockResolvedValue(undefined), + showWarningMessage: vi.fn(), + delete: vi.fn().mockResolvedValue(undefined), + readFile: vi.fn(), +})); + +vi.mock("vscode", () => ({ + workspace: { + get isTrusted() { return mocks.state.isTrusted; }, + fs: { readFile: mocks.readFile, delete: mocks.delete }, + }, + window: { + showWarningMessage: mocks.showWarningMessage, + showErrorMessage: vi.fn(), + showInformationMessage: vi.fn(), + }, + commands: { executeCommand: mocks.executeCommand }, +})); + +import { executeTriggerFile } from "./triggerFileHandler"; + +const fakeUri = { fsPath: "/ws/.vscode/.fastedge-run-command" } as any; +const fakeOutput = { appendLine: vi.fn() } as any; +const allowedCommand = "fastedge.setup-codespace-secret"; + +beforeEach(() => { + mocks.state.isTrusted = true; + vi.clearAllMocks(); + mocks.readFile.mockResolvedValue(Buffer.from(allowedCommand)); + mocks.delete.mockResolvedValue(undefined); + mocks.executeCommand.mockResolvedValue(undefined); +}); + +describe("executeTriggerFile — security guards", () => { + it("skips executeCommand in an untrusted workspace", async () => { + mocks.state.isTrusted = false; + await executeTriggerFile(fakeUri, fakeOutput); + expect(mocks.executeCommand).not.toHaveBeenCalled(); + expect(mocks.delete).toHaveBeenCalled(); // cleans up the trigger file + }); + + it("skips executeCommand when the user dismisses the confirmation", async () => { + mocks.showWarningMessage.mockResolvedValue("Ignore"); + await executeTriggerFile(fakeUri, fakeOutput); + expect(mocks.executeCommand).not.toHaveBeenCalled(); + expect(mocks.delete).toHaveBeenCalled(); + }); + + it("runs the command when trusted and user confirms, without workspace-supplied args", async () => { + // File carries args — they must NOT be forwarded to executeCommand. + mocks.readFile.mockResolvedValue( + Buffer.from(JSON.stringify({ command: allowedCommand, args: ["injected-arg"] })), + ); + mocks.showWarningMessage.mockResolvedValue("Run"); + + await executeTriggerFile(fakeUri, fakeOutput); + + expect(mocks.executeCommand).toHaveBeenCalledTimes(1); + expect(mocks.executeCommand).toHaveBeenCalledWith(allowedCommand); + // Confirm args were not spread in — call must have exactly one argument. + expect(mocks.executeCommand.mock.calls[0]).toHaveLength(1); + }); +}); diff --git a/src/autorun/triggerFileHandler.ts b/src/autorun/triggerFileHandler.ts index 9aeaf6a..78c4e8a 100644 --- a/src/autorun/triggerFileHandler.ts +++ b/src/autorun/triggerFileHandler.ts @@ -82,7 +82,7 @@ export function initializeTriggerFileHandler( /** * Execute command from trigger file */ -async function executeTriggerFile( +export async function executeTriggerFile( uri: vscode.Uri, outputChannel: vscode.OutputChannel, ): Promise { @@ -91,7 +91,7 @@ async function executeTriggerFile( // Read file content const content = await vscode.workspace.fs.readFile(uri); - const contentStr = content.toString().trim(); + const contentStr = Buffer.from(content).toString("utf8").trim(); if (!contentStr) { outputChannel.appendLine("Trigger file is empty, ignoring"); @@ -125,7 +125,30 @@ async function executeTriggerFile( return; } - // Execute command with timeout protection + // Never auto-execute in untrusted workspaces — a workspace-controlled file + // writing the trigger file would get a silent privileged command run. + if (!vscode.workspace.isTrusted) { + outputChannel.appendLine(`Skipping execution in untrusted workspace: ${commandId}`); + await vscode.workspace.fs.delete(uri); + return; + } + + // Require explicit user confirmation — the trigger file is workspace- + // authored, so execution without a click is an unintended privilege. + const ok = await vscode.window.showWarningMessage( + `This workspace is asking to run "${commandId}". Only allow this if you trust the workspace.`, + { modal: true }, + "Run", + "Ignore", + ); + if (ok !== "Run") { + await vscode.workspace.fs.delete(uri); + return; + } + + // Execute command with timeout protection. + // commandArgs from the file are never forwarded — the allowlisted command + // takes no arguments, and workspace-controlled args would be a privilege path. outputChannel.appendLine(`Executing command: ${commandId}`); let timeoutHandle: NodeJS.Timeout | undefined; const timeoutPromise = new Promise((_, reject) => { @@ -135,9 +158,7 @@ async function executeTriggerFile( ); }); - const executePromise = commandArgs - ? vscode.commands.executeCommand(commandId, ...commandArgs) - : vscode.commands.executeCommand(commandId); + const executePromise = vscode.commands.executeCommand(commandId); try { await Promise.race([executePromise, timeoutPromise]); diff --git a/src/commands/mcpJson.test.ts b/src/commands/mcpJson.test.ts index 07d4107..f6293f7 100644 --- a/src/commands/mcpJson.test.ts +++ b/src/commands/mcpJson.test.ts @@ -56,6 +56,18 @@ describe("getDockerCommand", () => { expect(getDockerCommand(false).args).not.toContain("GCORE_API_BASE"); }); + it("version file contains a bare semver tag with no v prefix", () => { + // Tags on ghcr.io have no v prefix; mcp-server.version must be x.y.z only. + expect(__MCP_SERVER_VERSION__).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it("uses a pinned version tag, not :latest", () => { + const { args } = getDockerCommand(false); + const imageArg = args[args.length - 1]; + expect(imageArg).not.toContain(":latest"); + expect(imageArg).toContain(__MCP_SERVER_VERSION__); + }); + it("is platform independent", () => { const original = Object.getOwnPropertyDescriptor(process, "platform")!; try { diff --git a/src/commands/mcpJson.ts b/src/commands/mcpJson.ts index 144fd18..0db8113 100644 --- a/src/commands/mcpJson.ts +++ b/src/commands/mcpJson.ts @@ -1,4 +1,6 @@ import * as vscode from "vscode"; +import * as fs from "fs"; +import * as path from "path"; import { MCPConfiguration } from "../types"; import { isCodespace, setupCodespaceSecret } from "./codespaceSecrets"; @@ -35,7 +37,9 @@ function getDockerCommand(includeBaseOverride: boolean): { if (includeBaseOverride) { args.push("-e", "GCORE_API_BASE"); } - args.push("ghcr.io/g-core/fastedge-mcp-server:latest"); + // Version is read from mcp-server.version at build time and injected by esbuild. + // Update that file (not this line) when the MCP server releases a new version. + args.push(`ghcr.io/g-core/fastedge-mcp-server:${__MCP_SERVER_VERSION__}`); return { command: "docker", args }; } @@ -246,6 +250,7 @@ async function createMCPJson(context?: vscode.ExtensionContext) { prompt: "Enter your FastEdge API Key", placeHolder: defaultApiKey || "Your API key here...", value: defaultApiKey, // Pre-fill with saved value + password: true, // Mask input — mirrors setupCodespaceSecret behavior validateInput: (value) => { if (!value || value.trim().length === 0) { return "API Key is required"; @@ -300,10 +305,89 @@ async function createMCPJson(context?: vscode.ExtensionContext) { }; try { - await vscode.workspace.fs.writeFile( - mcpJsonPath, - Buffer.from(JSON.stringify(mcpJsonContent, null, 2)), - ); + const jsonStr = JSON.stringify(mcpJsonContent, null, 2); + if (mcpJsonPath.scheme === "file") { + // Verify the .vscode parent directory is not a symlink pointing outside the workspace. + const vscodeDirPath = path.dirname(mcpJsonPath.fsPath); + try { + const realParent = fs.realpathSync(vscodeDirPath); + const realRoot = fs.realpathSync(workspaceFolder.uri.fsPath); + if (realParent !== realRoot && !realParent.startsWith(realRoot + path.sep)) { + vscode.window.showErrorMessage( + "Cannot write mcp.json: .vscode directory is a symlink outside the workspace.", + ); + return; + } + } catch { + // Directory doesn't exist yet — no symlink possible + } + // Ensure .vscode/ exists before opening the file (O_CREAT does not create parents). + fs.mkdirSync(vscodeDirPath, { recursive: true }); + // O_NOFOLLOW atomically rejects any symlink at the file path itself, + // eliminating the TOCTOU window that lstat+unlink+write has. On Windows + // (where O_NOFOLLOW is unavailable) fall back to an explicit lstat check. + // The lstat path has a narrow TOCTOU window, but a static malicious workspace + // cannot exploit it without active intervention between the check and the open. + const O_NOFOLLOW: number = (fs.constants.O_NOFOLLOW as number | undefined) ?? 0; + if (O_NOFOLLOW === 0) { + try { + if (fs.lstatSync(mcpJsonPath.fsPath).isSymbolicLink()) { + vscode.window.showErrorMessage( + "Cannot write mcp.json: the file is a symbolic link.", + ); + return; + } + } catch { /* file does not exist — OK */ } + } + // For new files, mode 0o600 applies immediately. + // For pre-existing files, fchmodSync on the fd locks down permissions before + // any credentials are written, closing the race that post-write chmod has. + const fd = fs.openSync( + mcpJsonPath.fsPath, + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_TRUNC | O_NOFOLLOW, + 0o600, + ); + try { + try { fs.fchmodSync(fd, 0o600); } catch { /* best-effort: chmod unsupported on Windows/some FSes */ } + fs.writeFileSync(fd, jsonStr); + } finally { fs.closeSync(fd); } + } else { + // Remote workspace (vscode-remote, Codespaces, etc.) — Node's fs.lstat + // targets the local host, not the remote; use VS Code's virtual FS API to + // check for symlinks on the parent directory and target file before writing. + // Use readDirectory() (lstat semantics) rather than stat() (which follows + // symlinks and throws FileNotFound for dangling ones) so that both live and + // dangling symlinks on the .vscode dir and on mcp.json itself are detected. + const vscodeDirUri = vscode.Uri.joinPath(workspaceFolder.uri, ".vscode"); + try { + const workspaceEntries = await vscode.workspace.fs.readDirectory(workspaceFolder.uri); + for (const [name, type] of workspaceEntries) { + if (name.toLowerCase() === ".vscode" && (type & vscode.FileType.SymbolicLink)) { + vscode.window.showErrorMessage( + "Cannot write mcp.json: .vscode directory is a symbolic link.", + ); + return; + } + } + } catch { /* workspace root not readable — proceed */ } + try { + const vscodeDirEntries = await vscode.workspace.fs.readDirectory(vscodeDirUri); + for (const [name, type] of vscodeDirEntries) { + if (name.toLowerCase() === "mcp.json" && (type & vscode.FileType.SymbolicLink)) { + vscode.window.showErrorMessage( + "Cannot write mcp.json: mcp.json is a symbolic link.", + ); + return; + } + } + } catch { /* .vscode doesn't exist yet — no symlink possible */ } + // Ensure .vscode/ exists (writeFile does not create parent directories). + try { + await vscode.workspace.fs.createDirectory(vscodeDirUri); + } catch { /* already exists — OK */ } + // Remote workspace permissions are best-effort (Node's fs.chmod targets local host). + await vscode.workspace.fs.writeFile(mcpJsonPath, Buffer.from(jsonStr)); + } } catch (error: any) { vscode.window.showErrorMessage( `Failed to write mcp.json: ${error?.message || error}`, diff --git a/src/compiler/index.ts b/src/compiler/index.ts index 41aca29..cd94e60 100644 --- a/src/compiler/index.ts +++ b/src/compiler/index.ts @@ -43,7 +43,7 @@ function getActiveFileLanguage(activeFile: string): ExtLanguage | null { */ function getProjectLanguage(activeFile: string): ExtLanguage | null { const buildRoot = resolveBuildRoot(activeFile); - if (!buildRoot) return null; + if (!buildRoot) {return null;} if (fs.existsSync(path.join(buildRoot, "Cargo.toml"))) { return "rust"; @@ -59,13 +59,13 @@ function getProjectLanguage(activeFile: string): ExtLanguage | null { async function compileActiveEditorsBinary( debugContext: DebugContext = "file", - logDebugConsole: LogToDebugConsole + logDebugConsole: LogToDebugConsole, ): Promise { const activeFile = vscode.window.activeTextEditor?.document.uri.fsPath; if (!activeFile) { throw new Error( - "No active file detected. Only Rust, JavaScript, or AssemblyScript files are supported." + "No active file detected. Only Rust, JavaScript, or AssemblyScript files are supported.", ); } @@ -78,13 +78,17 @@ async function compileActiveEditorsBinary( throw new Error( debugContext === "workspace" ? "Could not detect project language. Ensure your project has a package.json or Cargo.toml." - : "Language not supported. Only Rust, JavaScript, or AssemblyScript files are supported." + : "Language not supported. Only Rust, JavaScript, or AssemblyScript files are supported.", ); } if (activeFileLanguage === "javascript") { return { - path: await compileJavascriptBinary(activeFile, debugContext, logDebugConsole), + path: await compileJavascriptBinary( + activeFile, + debugContext, + logDebugConsole, + ), lang: activeFileLanguage, }; } else if (activeFileLanguage === "rust") { @@ -99,7 +103,7 @@ async function compileActiveEditorsBinary( }; } throw new Error( - "Invalid language. Only Rust, JavaScript, or AssemblyScript files are supported." + "Invalid language. Only Rust, JavaScript, or AssemblyScript files are supported.", ); } diff --git a/src/debugger/DebuggerServerManager.test.ts b/src/debugger/DebuggerServerManager.test.ts new file mode 100644 index 0000000..90dcb07 --- /dev/null +++ b/src/debugger/DebuggerServerManager.test.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { createHash } from "crypto"; + +vi.mock("vscode", () => ({ + workspace: { isTrusted: true }, + window: { showErrorMessage: vi.fn() }, +})); + +const readFileSyncMock = vi.fn(() => "5179"); +vi.mock("fs", () => ({ + existsSync: () => true, + readFileSync: (...args: any[]) => (readFileSyncMock as any)(...args), + unlinkSync: vi.fn(), +})); + +// Capture the fork env without actually spawning a process. +const forkMock = vi.fn(); +vi.mock("child_process", () => ({ + fork: (...args: any[]) => { + forkMock(...args); + const emitter = { + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + killed: false, + kill: vi.fn(), + }; + return emitter; + }, + execFile: vi.fn(), +})); + +import { DebuggerServerManager } from "./DebuggerServerManager"; + +/** Build the port file content the server would write: PORT:sha256(token) */ +function portFileFor(manager: DebuggerServerManager, port = 5179): string { + return `${port}:${createHash("sha256").update(manager.getToken()).digest("hex")}`; +} + +// ── isHealthyOnPort — unauthenticated /health probe ────────────────────────── + +describe("isHealthyOnPort — unauthenticated /health probe", () => { + beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); + forkMock.mockClear(); + }); + afterEach(() => { vi.unstubAllGlobals(); }); + + it("returns true (reuse) when server responds 200 OK", async () => { + (globalThis.fetch as any).mockResolvedValue({ ok: true }); + const manager = new DebuggerServerManager("/ext", "/app"); + (manager as any).port = 5179; + expect(await (manager as any).isHealthy()).toBe(true); + }); + + it("returns false (spawn fresh) when server responds non-OK", async () => { + (globalThis.fetch as any).mockResolvedValue({ ok: false, status: 401 }); + const manager = new DebuggerServerManager("/ext", "/app"); + (manager as any).port = 5179; + expect(await (manager as any).isHealthy()).toBe(false); + }); + + it("probes /health on 127.0.0.1 (not localhost)", async () => { + (globalThis.fetch as any).mockResolvedValue({ ok: true }); + const manager = new DebuggerServerManager("/ext", "/app"); + (manager as any).port = 5179; + await (manager as any).isHealthy(); + const url: string = (globalThis.fetch as any).mock.calls[0][0]; + expect(url).toContain("127.0.0.1"); + expect(url).toContain("/health"); + }); +}); + +// ── readPortFile — input validation ────────────────────────────────────────── + +describe("readPortFile — input validation", () => { + it("accepts a valid port with correct token hash", () => { + const m = new DebuggerServerManager("/ext", "/app"); + readFileSyncMock.mockReturnValue(portFileFor(m)); + expect((m as any).readPortFile()).toBe(5179); + }); + + it("accepts port 1 (min valid)", () => { + const m = new DebuggerServerManager("/ext", "/app"); + readFileSyncMock.mockReturnValue(portFileFor(m, 1)); + expect((m as any).readPortFile()).toBe(1); + }); + + it("accepts port 65535 (max valid)", () => { + const m = new DebuggerServerManager("/ext", "/app"); + readFileSyncMock.mockReturnValue(portFileFor(m, 65535)); + expect((m as any).readPortFile()).toBe(65535); + }); + + it("rejects legacy plain port format (no token hash)", () => { + readFileSyncMock.mockReturnValue("5179"); + expect((new DebuggerServerManager("/ext", "/app") as any).readPortFile()).toBeNull(); + }); + + it("rejects wrong token hash", () => { + readFileSyncMock.mockReturnValue("5179:wronghash"); + expect((new DebuggerServerManager("/ext", "/app") as any).readPortFile()).toBeNull(); + }); + + it("rejects port 0 even with correct hash", () => { + const m = new DebuggerServerManager("/ext", "/app"); + readFileSyncMock.mockReturnValue(portFileFor(m, 0)); + expect((m as any).readPortFile()).toBeNull(); + }); + + it("rejects port above 65535 even with correct hash", () => { + const m = new DebuggerServerManager("/ext", "/app"); + readFileSyncMock.mockReturnValue(portFileFor(m, 65536)); + expect((m as any).readPortFile()).toBeNull(); + }); +}); + +// ── fork env in Codespaces ──────────────────────────────────────────────────── + +describe("DebuggerServerManager — fork env in Codespaces", () => { + let originalEnv: NodeJS.ProcessEnv; + + beforeEach(() => { + originalEnv = { ...process.env }; + forkMock.mockClear(); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: true })); + }); + + afterEach(() => { + process.env = originalEnv; + vi.unstubAllGlobals(); + }); + + function setupForSpawn(manager: DebuggerServerManager) { + // No port file initially → adoption skipped → fork runs. + // After fork, waitForPortFile() polls readPortFile() + isHealthyOnPort(): + // set mock to the correct PORT:HASH format so it resolves on the first poll. + readFileSyncMock.mockImplementationOnce(() => { throw new Error("ENOENT"); }); + readFileSyncMock.mockReturnValue(portFileFor(manager)); + } + + it("sets FASTEDGE_EXPECTED_HOST when GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN is set", async () => { + process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN = "app.github.dev"; + const manager = new DebuggerServerManager("/ext", "/workspace"); + setupForSpawn(manager); + + await manager.start(); + + expect(forkMock).toHaveBeenCalled(); + const forkEnv = forkMock.mock.calls[0][2].env as Record; + expect(forkEnv.FASTEDGE_EXPECTED_HOST).toBe("app.github.dev"); + }); + + it("omits FASTEDGE_EXPECTED_HOST when GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN is not set", async () => { + delete process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN; + const manager = new DebuggerServerManager("/ext", "/workspace"); + setupForSpawn(manager); + + await manager.start(); + + expect(forkMock).toHaveBeenCalled(); + const forkEnv = forkMock.mock.calls[0][2].env as Record; + expect(forkEnv.FASTEDGE_EXPECTED_HOST).toBeUndefined(); + }); +}); diff --git a/src/debugger/DebuggerServerManager.ts b/src/debugger/DebuggerServerManager.ts index ca5c54b..ed4bb20 100644 --- a/src/debugger/DebuggerServerManager.ts +++ b/src/debugger/DebuggerServerManager.ts @@ -1,4 +1,5 @@ import { fork, execFile, ChildProcess } from "child_process"; +import { randomBytes, createHash } from "crypto"; import * as vscode from "vscode"; import * as path from "path"; import * as fs from "fs"; @@ -16,12 +17,19 @@ export class DebuggerServerManager { private serverProcess: ChildProcess | null = null; private port: number = 5179; private isStarting: boolean = false; + // Per-instance token: generated once, injected into the server via env and + // passed to the webview iframe via URL fragment so the frontend can auth. + private readonly token: string = randomBytes(16).toString("hex"); constructor( private extensionPath: string, private appRoot: string ) {} + getToken(): string { + return this.token; + } + private get portFilePath(): string { return path.join(this.appRoot, DEBUG_DIR, ".debug-port"); } @@ -29,8 +37,17 @@ export class DebuggerServerManager { private readPortFile(): number | null { try { const raw = fs.readFileSync(this.portFilePath, "utf8").trim(); - const port = parseInt(raw, 10); - return isNaN(port) ? null : port; + // Format: PORT:SHA256_OF_TOKEN — proves the server was spawned by this session. + // Legacy single-number format is rejected: can't verify server identity. + const sep = raw.indexOf(":"); + if (sep === -1) {return null;} + const portStr = raw.substring(0, sep); + const storedHash = raw.substring(sep + 1); + if (!/^\d{1,5}$/.test(portStr)) {return null;} + const port = Number(portStr); + if (port < 1 || port > 65535) {return null;} + const expectedHash = createHash("sha256").update(this.token).digest("hex"); + return storedHash === expectedHash ? port : null; } catch { return null; } @@ -53,11 +70,14 @@ export class DebuggerServerManager { private async isHealthyOnPort(port: number): Promise { try { - const response = await fetch(`http://localhost:${port}/health`, { + // Use the unauthenticated /health probe — sending our session token to an + // unverified endpoint would let a malicious listener steal it and impersonate + // the server. /health only tells us something is listening; subsequent authed + // requests will fail naturally if it's the wrong server. + const response = await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(500), }); - const data = await response.json(); - return response.ok && data.status === "ok" && data.service === "fastedge-debugger"; + return response.ok; } catch { return false; } @@ -69,17 +89,29 @@ export class DebuggerServerManager { * Port selection is delegated to fastedge-test's auto-increment logic. */ async start(): Promise { - // Step 1: Check if a server is already running for this app via port file - const filePort = this.readPortFile(); - if (filePort !== null) { - if (await this.isHealthyOnPort(filePort)) { - this.port = filePort; - console.log(`Reusing existing debugger server on port ${this.port} for ${this.appRoot}`); - return; - } else { - // Stale port file — clean it up - console.log(`Stale port file found for ${this.appRoot}, removing...`); - this.deletePortFile(); + // Step 1: Check if a server is already running for this app via port file. + // In an untrusted workspace the port file is workspace-controlled, so ignore + // it and always spawn a fresh server that this session owns. + if (vscode.workspace.isTrusted) { + const filePort = this.readPortFile(); + if (filePort !== null) { + if (await this.isHealthyOnPort(filePort)) { + // Only reuse servers we started in this extension host; otherwise the + // per-session token will not match and /api/* calls will fail auth. + if (this.serverProcess) { + this.port = filePort; + console.log(`Reusing existing debugger server on port ${this.port} for ${this.appRoot}`); + return; + } + console.log( + `Found existing debugger server on port ${filePort} for ${this.appRoot} but no owned process; spawning a fresh server...`, + ); + this.deletePortFile(); + } else { + // Stale port file — clean it up + console.log(`Stale port file found for ${this.appRoot}, removing...`); + this.deletePortFile(); + } } } @@ -116,6 +148,11 @@ export class DebuggerServerManager { // No PORT env var — let fastedge-test's startServer() resolve it via auto-increment. // WORKSPACE_PATH tells it where to write .fastedge-debug/.debug-port. + // In GitHub Codespaces the browser connects through a port-forwarded URL + // of the form -.. The server needs FASTEDGE_EXPECTED_HOST + // set to the forwarding domain so its suffix-match check allows the request + // (the full hostname cannot be known here because the server picks its own port). + const codespacesDomain = process.env.GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN; this.serverProcess = fork(bundledServerPath, [], { cwd: path.dirname(bundledServerPath), execPath: process.execPath, @@ -124,6 +161,9 @@ export class DebuggerServerManager { ...process.env, VSCODE_INTEGRATION: "true", WORKSPACE_PATH: this.appRoot, + FASTEDGE_DEBUG_TOKEN: this.token, + FASTEDGE_BIND_HOST: "127.0.0.1", + ...(codespacesDomain ? { FASTEDGE_EXPECTED_HOST: codespacesDomain } : {}), }, }); @@ -219,7 +259,7 @@ export class DebuggerServerManager { * Get the debugger server URL */ getUrl(): string { - return `http://localhost:${this.port}`; + return `http://127.0.0.1:${this.port}`; } /** @@ -253,6 +293,7 @@ export class DebuggerServerManager { method: "POST", headers: { "Content-Type": "application/json", + "x-fastedge-token": this.token, }, }); diff --git a/src/debugger/DebuggerWebviewProvider.savepicker.test.ts b/src/debugger/DebuggerWebviewProvider.savepicker.test.ts new file mode 100644 index 0000000..5059983 --- /dev/null +++ b/src/debugger/DebuggerWebviewProvider.savepicker.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// ── hoist mocks so vi.mock factory can reference them ───────────────────────── +const { + postMessageMock, + onDidReceiveMessageMock, + onDidDisposeMock, + showSaveDialogMock, + writeFileMock, +} = vi.hoisted(() => ({ + postMessageMock: vi.fn(), + onDidReceiveMessageMock: vi.fn(), + onDidDisposeMock: vi.fn(), + showSaveDialogMock: vi.fn(), + writeFileMock: vi.fn(), +})); + +vi.mock("vscode", () => ({ + window: { + showSaveDialog: showSaveDialogMock, + showErrorMessage: vi.fn(), + showWarningMessage: vi.fn(), + showInformationMessage: vi.fn(), + createWebviewPanel: vi.fn(() => ({ + webview: { + html: "", + postMessage: postMessageMock, + onDidReceiveMessage: onDidReceiveMessageMock, + options: {}, + asWebviewUri: (u: any) => u, + }, + onDidDispose: onDidDisposeMock, + reveal: vi.fn(), + })), + }, + workspace: { + fs: { writeFile: writeFileMock }, + }, + Uri: { + parse: (s: string) => ({ scheme: new URL(s).protocol.replace(":", ""), toString: () => s }), + file: (p: string) => ({ fsPath: p, toString: () => `file://${p}` }), + }, + ViewColumn: { One: 1 }, + env: { + openExternal: vi.fn(), + asExternalUri: vi.fn((uri: any) => Promise.resolve(uri)), + }, +})); + +import { DebuggerWebviewProvider } from "./DebuggerWebviewProvider"; + +const fakeServer = { + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + isRunning: vi.fn(() => false), + getPort: () => 5179, + getToken: () => "tok", + getAppRoot: () => "/project", + getUrl: () => "http://localhost:5179", +} as any; + +const fakeContext = { subscriptions: [] } as any; + +describe("DebuggerWebviewProvider — openSavePicker handler", () => { + let handler: (msg: any) => Promise; + + beforeEach(async () => { + vi.clearAllMocks(); + onDidReceiveMessageMock.mockImplementation((cb: any) => { handler = cb; }); + onDidDisposeMock.mockImplementation(() => {}); + + const provider = new DebuggerWebviewProvider(fakeContext, fakeServer); + await provider.showDebugger(); + }); + + it("writes the file and replies saved:true when user picks a path", async () => { + const pickedUri = { fsPath: "/project/.fastedge-debug/fastedge-config.test.json" }; + showSaveDialogMock.mockResolvedValue(pickedUri); + writeFileMock.mockResolvedValue(undefined); + + await handler({ type: "openSavePicker", config: '{"envVars":{}}' }); + + expect(writeFileMock).toHaveBeenCalledWith( + pickedUri, + Buffer.from('{"envVars":{}}') + ); + expect(postMessageMock).toHaveBeenCalledWith({ + type: "savePickerResult", + path: pickedUri.fsPath, + saved: true, + }); + }); + + it("replies saved:false when user cancels the dialog", async () => { + showSaveDialogMock.mockResolvedValue(undefined); // cancelled + + await handler({ type: "openSavePicker", config: "{}" }); + + expect(writeFileMock).not.toHaveBeenCalled(); + expect(postMessageMock).toHaveBeenCalledWith({ + type: "savePickerResult", + path: null, + saved: false, + }); + }); + + it("replies saved:false when writeFile throws", async () => { + const pickedUri = { fsPath: "/project/cfg.json" }; + showSaveDialogMock.mockResolvedValue(pickedUri); + writeFileMock.mockRejectedValue(new Error("disk full")); + + await handler({ type: "openSavePicker", config: "{}" }); + + expect(postMessageMock).toHaveBeenCalledWith({ + type: "savePickerResult", + path: null, + saved: false, + }); + }); +}); diff --git a/src/debugger/DebuggerWebviewProvider.test.ts b/src/debugger/DebuggerWebviewProvider.test.ts new file mode 100644 index 0000000..232f0e7 --- /dev/null +++ b/src/debugger/DebuggerWebviewProvider.test.ts @@ -0,0 +1,40 @@ +import { describe, it, expect, vi } from "vitest"; + +vi.mock("vscode", () => ({ workspace: {}, window: {}, Uri: {}, env: {} })); + +import { DebuggerWebviewProvider } from "./DebuggerWebviewProvider"; + +const fakeServer = { + getPort: () => 5179, + getToken: () => "deadbeeftoken", + getAppRoot: () => "/app", + getUrl: () => "http://localhost:5179", +} as any; + +// getWebviewContent is private — access via cast for testing HTML output. +const html = (new DebuggerWebviewProvider({} as any, fakeServer) as any) + .getWebviewContent("http://localhost:5179"); + +describe("DebuggerWebviewProvider.getWebviewContent — security properties", () => { + it("includes a Content-Security-Policy with a per-render nonce", () => { + expect(html).toMatch(/Content-Security-Policy/); + expect(html).toMatch(/script-src 'nonce-[A-Za-z0-9+/=]+'/); + }); + + it("restricts frame-src to the debugger origin, not a wildcard", () => { + expect(html).toMatch(/frame-src http:\/\/localhost:5179/); + }); + + it("never posts messages with target origin '*'", () => { + expect(html).not.toContain("postMessage(event.data, '*')"); + expect(html).not.toContain('postMessage(event.data,"*")'); + }); + + it("sets FRAME_ORIGIN to the debugger origin as a string literal", () => { + expect(html).toContain('const FRAME_ORIGIN = "http://localhost:5179"'); + }); + + it("injects the session token into the iframe src fragment", () => { + expect(html).toContain("#token=deadbeeftoken"); + }); +}); diff --git a/src/debugger/DebuggerWebviewProvider.ts b/src/debugger/DebuggerWebviewProvider.ts index eb192bf..d67a4c5 100644 --- a/src/debugger/DebuggerWebviewProvider.ts +++ b/src/debugger/DebuggerWebviewProvider.ts @@ -1,5 +1,6 @@ import * as vscode from "vscode"; import * as path from "path"; +import { randomBytes } from "crypto"; import { readFile } from "fs/promises"; import { DebuggerServerManager } from "./DebuggerServerManager"; @@ -12,7 +13,7 @@ export class DebuggerWebviewProvider { constructor( private context: vscode.ExtensionContext, - private serverManager: DebuggerServerManager + private serverManager: DebuggerServerManager, ) {} /** @@ -47,7 +48,7 @@ export class DebuggerWebviewProvider { { enableScripts: true, retainContextWhenHidden: true, - } + }, ); this.currentDebuggerUrl = debuggerUrl; @@ -58,7 +59,20 @@ export class DebuggerWebviewProvider { // Handle messages from the webview (forwarded from the debugger iframe) this.panel.webview.onDidReceiveMessage(async (message) => { if (message.command === "openExternal") { - await vscode.env.openExternal(vscode.Uri.parse(message.url)); + let uri: vscode.Uri; + try { + uri = vscode.Uri.parse(message.url, true); + } catch { + return; // unparseable → refuse + } + // Only allow http/https — no vscode:, file:, or other OS handlers. + if (uri.scheme !== "https" && uri.scheme !== "http") { + vscode.window.showWarningMessage( + `FastEdge: refused to open a non-web link (${uri.scheme}:).`, + ); + return; + } + await vscode.env.openExternal(uri); } if (message.command === "openFilePicker") { @@ -74,9 +88,17 @@ export class DebuggerWebviewProvider { const content = await readFile(uris[0].fsPath, "utf-8"); const fileName = path.basename(uris[0].fsPath); const configDir = path.dirname(uris[0].fsPath); - this.panel?.webview.postMessage({ command: "filePickerResult", content, fileName, configDir }); + this.panel?.webview.postMessage({ + command: "filePickerResult", + content, + fileName, + configDir, + }); } else { - this.panel?.webview.postMessage({ command: "filePickerResult", canceled: true }); + this.panel?.webview.postMessage({ + command: "filePickerResult", + canceled: true, + }); } } @@ -97,25 +119,52 @@ export class DebuggerWebviewProvider { title: "Select .env files directory", }); if (uris && uris.length > 0) { - this.panel?.webview.postMessage({ command: "folderPickerResult", folderPath: uris[0].fsPath }); + this.panel?.webview.postMessage({ + command: "folderPickerResult", + folderPath: uris[0].fsPath, + }); } else { - this.panel?.webview.postMessage({ command: "folderPickerResult", canceled: true }); + this.panel?.webview.postMessage({ + command: "folderPickerResult", + canceled: true, + }); } } - if (message.command === "openSavePicker") { + if (message.type === "openSavePicker") { const appRoot = this.serverManager.getAppRoot(); const debugDir = path.join(appRoot, ".fastedge-debug"); - const suggestedName = message.suggestedName ?? "fastedge-config.test.json"; const uri = await vscode.window.showSaveDialog({ - defaultUri: vscode.Uri.file(path.join(debugDir, suggestedName)), + defaultUri: vscode.Uri.file( + path.join(debugDir, "fastedge-config.test.json"), + ), filters: { "JSON Files": ["json"] }, title: "Save FastEdge Config", }); if (uri) { - this.panel?.webview.postMessage({ command: "savePickerResult", filePath: uri.fsPath }); + try { + await vscode.workspace.fs.writeFile( + uri, + Buffer.from(message.config), + ); + this.panel?.webview.postMessage({ + type: "savePickerResult", + path: uri.fsPath, + saved: true, + }); + } catch { + this.panel?.webview.postMessage({ + type: "savePickerResult", + path: null, + saved: false, + }); + } } else { - this.panel?.webview.postMessage({ command: "savePickerResult", canceled: true }); + this.panel?.webview.postMessage({ + type: "savePickerResult", + path: null, + saved: false, + }); } } }); @@ -140,7 +189,7 @@ export class DebuggerWebviewProvider { } } catch (error) { vscode.window.showErrorMessage( - `Failed to show debugger: ${(error as Error).message}` + `Failed to show debugger: ${(error as Error).message}`, ); throw error; } @@ -153,20 +202,18 @@ export class DebuggerWebviewProvider { try { // Load via REST API using path-based loading — server is local so the // path is always accessible, and avoids the "binary.wasm" placeholder filename - const response = await fetch( - `${this.serverManager.getUrl()}/api/load`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Source": "vscode", - }, - body: JSON.stringify({ - wasmPath, - dotenv: { enabled: true }, - }), - } - ); + const response = await fetch(`${this.serverManager.getUrl()}/api/load`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-fastedge-token": this.serverManager.getToken(), + "X-Source": "vscode", + }, + body: JSON.stringify({ + wasmPath, + dotenv: { enabled: true }, + }), + }); if (!response.ok) { const error = await response.json(); @@ -177,11 +224,11 @@ export class DebuggerWebviewProvider { console.log(`WASM loaded successfully: ${result.wasmType}`); vscode.window.showInformationMessage( - `WASM loaded successfully (${result.wasmType})` + `WASM loaded successfully (${result.wasmType})`, ); } catch (error) { vscode.window.showErrorMessage( - `Failed to load WASM: ${(error as Error).message}` + `Failed to load WASM: ${(error as Error).message}`, ); throw error; } @@ -202,10 +249,11 @@ export class DebuggerWebviewProvider { method: "POST", headers: { "Content-Type": "application/json", + "x-fastedge-token": this.serverManager.getToken(), "X-Source": "vscode", }, body: JSON.stringify({ config }), - } + }, ); if (!response.ok) { @@ -216,7 +264,7 @@ export class DebuggerWebviewProvider { console.log("Configuration updated successfully"); } catch (error) { vscode.window.showErrorMessage( - `Failed to set config: ${(error as Error).message}` + `Failed to set config: ${(error as Error).message}`, ); throw error; } @@ -230,15 +278,22 @@ export class DebuggerWebviewProvider { const start = Date.now(); while (Date.now() - start < timeoutMs) { try { - const response = await fetch(`${this.serverManager.getUrl()}/api/client-count`, { - signal: AbortSignal.timeout(2000), - }); + const response = await fetch( + `${this.serverManager.getUrl()}/api/client-count`, + { + headers: { + "x-fastedge-token": this.serverManager.getToken(), + "X-Source": "vscode", + }, + signal: AbortSignal.timeout(2000), + }, + ); const { count } = await response.json(); - if (count > 0) return; + if (count > 0) {return;} } catch { // Server may not be ready yet — keep polling } - await new Promise(resolve => setTimeout(resolve, 50)); + await new Promise((resolve) => setTimeout(resolve, 50)); } // Timeout — proceed anyway, load is better than no load } @@ -247,14 +302,21 @@ export class DebuggerWebviewProvider { * Get the webview HTML content */ private getWebviewContent(debuggerUrl: string): string { + const nonce = randomBytes(16).toString("base64"); + const frameOrigin = new URL(debuggerUrl).origin; + // Deliver the session token to the iframe via URL fragment — fragments are + // never sent in HTTP requests, so they don't appear in server logs, and only + // the same-origin iframe page can read location.hash. + const iframeUrl = `${debuggerUrl}#token=${encodeURIComponent(this.serverManager.getToken())}`; return ` + FastEdge Debugger -