diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 481959f..181aea1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1212,6 +1212,13 @@ jobs: echo no tracker file — no transport suite reached its first test ) + # Read the PE version resource back off the built .exe. + # + # resources/notepatra.rc carried a hardcoded FILEVERSION 0,1,0,0 for 124 + # releases, so winget / SCCM / Intune and File Properties reported 0.1.0 + # on every machine ever. It is now generated from project(Notepatra + # VERSION ...) — and this gate is what keeps it that way, because nothing + # else on any platform can observe an embedded Windows resource. # Separate step because the assertion needs pwsh string matching, and cmd # has no clean equivalent. `serve` on a std-only build exits 2 after # printing this, so we match on the text rather than the exit code. @@ -1471,6 +1478,32 @@ jobs: Copy-Item "installers\unregister-associations.bat" ".\notepatra-win\" -Force } + # MUST stay AFTER the bundle step. This asserts on a built artifact; + # sitting above "Build C++ with CMake" it failed every run with + # "not found" on a binary that did not exist yet. + - name: Assert PE FileVersion matches the project version + shell: pwsh + run: | + $cmakeVer = (Select-String -Path CMakeLists.txt ` + -Pattern 'project\(Notepatra VERSION ([0-9]+\.[0-9]+\.[0-9]+)' ` + | Select-Object -First 1).Matches.Groups[1].Value + if (-not $cmakeVer) { Write-Host "::error::could not read version from CMakeLists.txt"; exit 1 } + $expected = "$cmakeVer.0" + # The SHIPPED exe, not the one still in the build tree: the bundle + # step copies it out and embeds the icon, so this is the file a + # user right-clicks -> Properties -> Details on. + $exe = Get-ChildItem -Path notepatra-win -Filter notepatra.exe -ErrorAction SilentlyContinue | Select-Object -First 1 + if (-not $exe) { Write-Host "::error::notepatra.exe not found in notepatra-win/ - did the bundle step run?"; exit 1 } + $actual = (Get-Item $exe.FullName).VersionInfo.FileVersion + Write-Host "CMakeLists version : $cmakeVer" + Write-Host "expected FileVersion: $expected" + Write-Host "actual FileVersion: $actual" + if ($actual -ne $expected) { + Write-Host "::error::PE FileVersion '$actual' != expected '$expected' — the .rc template did not substitute" + exit 1 + } + Write-Host "OK: PE version resource matches the project version" + - name: Diagnose notepatra.exe imports + Qt plugin loading # Launches notepatra.exe: a Qt plugin-load stall or a modal error box would otherwise hang here forever. timeout-minutes: 5 diff --git a/CMakeLists.txt b/CMakeLists.txt index 267e10c..c9190cd 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -298,9 +298,29 @@ set(HEADERS # ── Platform-specific resources ── if(WIN32) - # Windows: embed icon via .rc resource file - if(EXISTS "${CMAKE_SOURCE_DIR}/resources/notepatra.rc") - list(APPEND SOURCES resources/notepatra.rc) + # Windows: embed the icon AND the version resource via a GENERATED .rc. + # + # The .rc used to be a static file with FILEVERSION hardcoded to 0,1,0,0, + # so every Windows build reported 0.1.0 to winget / SCCM / Intune and to + # File Properties > Details, regardless of the real version. Generating it + # from project(Notepatra VERSION ...) makes that impossible to drift: there + # is exactly one place the version is written. + if(EXISTS "${CMAKE_SOURCE_DIR}/resources/notepatra.rc.in") + # VERSIONINFO needs a 4-part comma form (a,b,c,d); PROJECT_VERSION is + # 3-part dotted, so pad the build field with 0. + set(NOTEPATRA_RC_VERSION_COMMA + "${PROJECT_VERSION_MAJOR},${PROJECT_VERSION_MINOR},${PROJECT_VERSION_PATCH},0") + set(NOTEPATRA_RC_VERSION_DOT + "${PROJECT_VERSION_MAJOR}.${PROJECT_VERSION_MINOR}.${PROJECT_VERSION_PATCH}.0") + # The generated .rc lives in the build tree, where a bare + # "notepatra.ico" would not resolve — point at the source copy. RC + # wants forward or escaped slashes, so hand it a forward-slash path. + set(NOTEPATRA_ICON_PATH "${CMAKE_SOURCE_DIR}/resources/notepatra.ico") + configure_file( + "${CMAKE_SOURCE_DIR}/resources/notepatra.rc.in" + "${CMAKE_BINARY_DIR}/notepatra.rc" + @ONLY) + list(APPEND SOURCES "${CMAKE_BINARY_DIR}/notepatra.rc") endif() endif() @@ -586,6 +606,7 @@ if(NOTEPATRA_BUILD_TESTS AND EXISTS "${CMAKE_SOURCE_DIR}/test_indent_guide_theme ${CMAKE_SOURCE_DIR}/src/rustbridge.cpp ${CMAKE_SOURCE_DIR}/src/gitgutter.cpp ${CMAKE_SOURCE_DIR}/src/git_hunk_apply.cpp + ${CMAKE_SOURCE_DIR}/src/path_denylist.cpp ${CMAKE_SOURCE_DIR}/src/gutter_hunk_popup.cpp ${CMAKE_SOURCE_DIR}/src/diff_view.cpp) add_dependencies(test_indent_guide_theme rust_core) @@ -1096,6 +1117,7 @@ if(NOTEPATRA_BUILD_TESTS AND EXISTS "${CMAKE_SOURCE_DIR}/test_ai_tools.cpp") add_executable(test_ai_tools test_ai_tools.cpp src/ai_tools.cpp + src/path_denylist.cpp src/git_tools.cpp src/csvanalyst.cpp src/dbconnections.cpp @@ -1118,6 +1140,7 @@ if(NOTEPATRA_BUILD_TESTS AND EXISTS "${CMAKE_SOURCE_DIR}/test_git_tools_e2e.cpp" add_executable(test_git_tools_e2e test_git_tools_e2e.cpp src/ai_tools.cpp + src/path_denylist.cpp src/git_tools.cpp src/csvanalyst.cpp src/dbconnections.cpp @@ -1140,6 +1163,7 @@ if(NOTEPATRA_BUILD_TESTS AND EXISTS "${CMAKE_SOURCE_DIR}/test_ai_dataanalyst.cpp test_ai_dataanalyst.cpp src/ai_systemprompt.cpp src/ai_tools.cpp + src/path_denylist.cpp src/git_tools.cpp src/chartrender.cpp src/chart_modal.cpp @@ -1839,6 +1863,19 @@ if(NOTEPATRA_BUILD_TESTS AND EXISTS "${CMAKE_SOURCE_DIR}/test_single_instance.cp message(STATUS "Regression test enabled — target: test_single_instance") endif() +# Workspace-root scoping. FileExplorer::workspaceRoot() must stay empty until +# the user opens a folder — conflating it with the tree's display root (which +# defaults to $HOME) made search_project walk the whole user profile and +# disarmed the AI CSV sandbox guard. +if(NOTEPATRA_BUILD_TESTS AND EXISTS "${CMAKE_SOURCE_DIR}/test_workspace_root.cpp") + add_executable(test_workspace_root test_workspace_root.cpp src/fileexplorer.cpp) + target_include_directories(test_workspace_root PRIVATE ${CMAKE_SOURCE_DIR}/src) + target_link_libraries(test_workspace_root PRIVATE Qt5::Widgets Qt5::Test) + notepatra_add_qt_test(test_workspace_root) + set_tests_properties(test_workspace_root PROPERTIES LABELS "smoke;privacy;mcp") + message(STATUS "Regression test enabled — target: test_workspace_root") +endif() + # D7 (win-open-ghost) — CLI arg parse contract (the Windows wide-argv fix # feeds QCoreApplication::arguments() into this parser). if(NOTEPATRA_BUILD_TESTS AND EXISTS "${CMAKE_SOURCE_DIR}/test_cli_args.cpp") @@ -2610,6 +2647,7 @@ if(NOTEPATRA_BUILD_TESTS AND EXISTS "${CMAKE_SOURCE_DIR}/test_mcp_bridge.cpp") add_executable(test_mcp_bridge test_mcp_bridge.cpp src/mcp_bridge.cpp + src/path_denylist.cpp src/singleinstance.cpp # v0.1.118 — list_notes/read_note go through the real NotesStorage # layer (QtCore-only; notes_template.cpp resolves shellHtml). @@ -2884,6 +2922,7 @@ if(NOTEPATRA_BUILD_TESTS AND EXISTS "${CMAKE_SOURCE_DIR}/test_options_actually_w src/editor_symbols.cpp # Editor::applySymbolSettings() links against it src/gitgutter.cpp src/git_hunk_apply.cpp + src/path_denylist.cpp src/gutter_hunk_popup.cpp src/diff_view.cpp src/lexerutils.cpp @@ -3034,6 +3073,7 @@ if(NOTEPATRA_BUILD_TESTS AND EXISTS "${CMAKE_SOURCE_DIR}/test_vega_chart.cpp") add_executable(test_vega_chart test_vega_chart.cpp src/ai_tools.cpp + src/path_denylist.cpp src/git_tools.cpp src/csvanalyst.cpp src/dbconnections.cpp diff --git a/notepatra-mcp/mcpb/build-mcpb.py b/notepatra-mcp/mcpb/build-mcpb.py index ac8557d..3dc1b64 100755 --- a/notepatra-mcp/mcpb/build-mcpb.py +++ b/notepatra-mcp/mcpb/build-mcpb.py @@ -104,6 +104,22 @@ def main(): dcmd = cfg.get("platform_overrides", {}).get("darwin", {}).get("command", "") if not dcmd.endswith("server/darwin/notepatra-mcp"): fail(f"manifest darwin override '{dcmd}' does not point at server/darwin/notepatra-mcp") + # win32 was the ONLY platform whose override was never checked — the same + # blind spot that let the Windows build go unverified everywhere else. + wcmd = cfg.get("platform_overrides", {}).get("win32", {}).get("command", "") + if not wcmd.endswith("server/win32-x64/notepatra-mcp.exe"): + fail(f"manifest win32 override '{wcmd}' does not point at server/win32-x64/notepatra-mcp.exe") + # Every platform must end up launching with --socket. Overrides that specify + # only `command` inherit the base args, so the effective args are the base + # ones unless a platform sets its own; assert on the resolved value rather + # than trusting that inheritance, because losing this flag silently swaps the + # real editor for the in-memory MOCK and every tool returns fabricated data. + base_args = cfg.get("args", []) + for plat, ov in list(cfg.get("platform_overrides", {}).items()) + [("", {})]: + eff = ov.get("args", base_args) + if "--socket" not in eff: + fail(f"platform '{plat}' resolves to args {eff} — missing --socket, " + f"so it would run the in-memory mock instead of the editor") if a.require_all: for plat in ALL_PLATFORMS: if not any(n.startswith(f"server/{plat}/") for n in names): diff --git a/notepatra-mcp/src/main.rs b/notepatra-mcp/src/main.rs index 2a224e0..fef77ed 100644 --- a/notepatra-mcp/src/main.rs +++ b/notepatra-mcp/src/main.rs @@ -2,15 +2,80 @@ use notepatra_mcp::server::Server; use notepatra_mcp::transport::{mock::MockEditor, socket::SocketEditor}; +const USAGE: &str = "\ +notepatra-mcp — Model Context Protocol server for the Notepatra editor + +USAGE: + notepatra-mcp [--socket] + notepatra-mcp (requires the `remote` feature) + +OPTIONS: + --socket Drive the RUNNING editor over its local MCP bridge. Without + this the server answers from an in-memory MOCK whose data is + fabricated — useful for exercising the protocol, never for + real work. + -h, --help Print this help and exit. + -V, --version Print the version and exit. + +This is a stdio server: with no flags it speaks JSON-RPC on stdin/stdout and +will appear to hang if you run it by hand. That is expected — it is meant to be +launched by an MCP client, not a terminal. + +Docs: https://notepatra.org/mcp.html"; + fn main() -> std::io::Result<()> { // Phase 3a subcommands (serve/pair/connect) are dispatched FIRST and only // when they are the first argument, so every existing invocation — no args, - // `--socket`, anything else — reaches the unchanged stdio path below, - // byte-for-byte identical to HEAD. + // `--socket`, anything else — reaches the unchanged stdio path below. if let Some(mode @ ("serve" | "pair" | "connect")) = std::env::args().nth(1).as_deref() { return run_remote_mode(mode); } + // A MISTYPED subcommand must fail loudly too. + // + // Rejecting only bad flags left half the hang in place: USAGE advertises + // bare-word subcommands, so `notepatra-mcp sevre` fell straight through to + // the stdio loop and blocked on stdin forever — the same "looks crashed" + // symptom, reached by the other spelling mistake. + if let Some(first) = std::env::args().nth(1) { + if !first.starts_with('-') && !matches!(first.as_str(), "serve" | "pair" | "connect") { + eprintln!("notepatra-mcp: unknown subcommand '{first}'"); + eprintln!("Expected one of: serve, pair, connect"); + eprintln!("Try 'notepatra-mcp --help' for usage."); + std::process::exit(2); + } + } + + // Discovery flags, and rejection of anything else that LOOKS like a flag. + // + // Before v0.1.125 none of this existed: every unrecognised argument fell + // through to the stdio loop below, which blocks reading stdin. So + // `--version` printed nothing and hung, and a typo like `--sokcet` silently + // started a MOCK server instead of failing. Both read as a crashed program. + // An unknown flag must be a loud error, never a hang. + for arg in std::env::args().skip(1) { + match arg.as_str() { + "-h" | "--help" => { + println!("{USAGE}"); + return Ok(()); + } + "-V" | "--version" => { + println!("notepatra-mcp {}", env!("CARGO_PKG_VERSION")); + return Ok(()); + } + "--socket" => {} + // Bare words are left alone: a client may pass a path or an + // extra positional we do not own. Only flag-shaped typos are + // fatal, because those are ours to get wrong. + other if other.starts_with('-') => { + eprintln!("notepatra-mcp: unrecognised option '{other}'"); + eprintln!("Try 'notepatra-mcp --help' for usage."); + std::process::exit(2); + } + _ => {} + } + } + // `--socket` targets the running editor over its dedicated MCP bridge // socket; default is the in-memory mock so any MCP client can exercise // the protocol without a running editor. diff --git a/notepatra-mcp/src/server.rs b/notepatra-mcp/src/server.rs index 382740c..a0e0e45 100644 --- a/notepatra-mcp/src/server.rs +++ b/notepatra-mcp/src/server.rs @@ -31,8 +31,12 @@ const NOTE_URI_PREFIX: &str = "notepatra://note/"; pub struct Server { transport: T, /// serverInfo.version, resolved once at startup: NOTEPATRA_MCP_VERSION - /// (set by the editor when it spawns the sidecar) wins over this crate's - /// own version. + /// wins over this crate's own version if it is set. + /// + /// NOTE: nothing in the repo currently SETS that variable — the editor does + /// not spawn the sidecar (MCP clients launch it), so in every shipped + /// configuration this is CARGO_PKG_VERSION. The override is kept as an + /// escape hatch for a packager who ships a differently-versioned binary. version: String, } diff --git a/notepatra-mcp/src/tools.rs b/notepatra-mcp/src/tools.rs index 1dc3506..1f6efe8 100644 --- a/notepatra-mcp/src/tools.rs +++ b/notepatra-mcp/src/tools.rs @@ -210,7 +210,7 @@ pub fn definitions() -> Value { }, { "name": "goto_line", - "description": "Move the editor cursor to a 1-based line number in a tab (defaults to the active tab). Use to point the user at a specific location, e.g. after finding a match.", + "description": "Move the editor cursor to a 1-based line number in a tab (defaults to the active tab). Use to point the user at a specific location, e.g. after finding a match. Lines past end-of-file are CLAMPED to the last line: the response's `line` is where the cursor actually landed and `clamped` is true, so check it before any cursor-relative write.", "inputSchema": { "type": "object", "properties": { @@ -779,23 +779,53 @@ fn optional_index(args: &Map, key: &str) -> Result, } } +/// The ONE message every 1-based-integer rejection uses. +/// +/// These helpers used to delegate to `optional_index`/`required_index`, whose +/// `as_u64()` rejected negatives with "must be a non-negative integer" before +/// the `>= 1` check was ever reached. So `line: 0` and `line: -5` — the same +/// mistake — came back with two different and mutually contradictory rules, one +/// of which explicitly permits 0. Validating here keeps it to one sentence. +fn one_based_error(key: &str) -> CallOutcome { + CallOutcome::InvalidParams(format!("{key} must be an integer >= 1")) +} + +/// The C++ bridge reads these through QJsonValue::toInt(0), which yields 0 — +/// and therefore the misleading "must be >= 1" — for anything above i32::MAX. +/// Reject out of range here instead, with a message that names the real limit. +const MAX_ONE_BASED: u64 = i32::MAX as u64; + +fn check_one_based(key: &str, n: u64) -> Result { + if n == 0 { + return Err(one_based_error(key)); + } + if n > MAX_ONE_BASED { + return Err(CallOutcome::InvalidParams(format!( + "{key} must be an integer >= 1 and <= {MAX_ONE_BASED}" + ))); + } + Ok(n as usize) +} + /// Optional 1-based integer (line/column numbers). fn optional_one_based(args: &Map, key: &str) -> Result, CallOutcome> { - match optional_index(args, key)? { - Some(0) => Err(CallOutcome::InvalidParams(format!( - "{key} must be an integer >= 1" - ))), - v => Ok(v), + match args.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_u64() { + None => Err(one_based_error(key)), + Some(n) => check_one_based(key, n).map(Some), + }, } } /// Required 1-based integer (line/column numbers). fn required_one_based(args: &Map, key: &str) -> Result { - match required_index(args, key)? { - 0 => Err(CallOutcome::InvalidParams(format!( - "{key} must be an integer >= 1" - ))), - n => Ok(n), + match args.get(key) { + None => Err(CallOutcome::InvalidParams(format!("{key} is required"))), + Some(v) => match v.as_u64() { + None => Err(one_based_error(key)), + Some(n) => check_one_based(key, n), + }, } } @@ -965,9 +995,12 @@ fn goto_line(transport: &mut dyn EditorTransport, args: &Map) -> if let Err(e) = reject_extras(args, &["line", "tab_index"]) { return e; } - let line = match required_index(args, "line") { - Ok(n) if n >= 1 => n, - Ok(_) => return CallOutcome::InvalidParams("line must be an integer >= 1".into()), + // required_one_based, NOT a hand-rolled required_index + `>= 1` check: + // select_range and insert_text already route every 1-based argument through + // the shared helper, and goto_line being the one exception is what produced + // two contradictory rules for the same mistake. + let line = match required_one_based(args, "line") { + Ok(n) => n, Err(e) => return e, }; let tab_index = match optional_index(args, "tab_index") { diff --git a/notepatra-mcp/src/transport/mock.rs b/notepatra-mcp/src/transport/mock.rs index 44809f8..6d9fcb2 100644 --- a/notepatra-mcp/src/transport/mock.rs +++ b/notepatra-mcp/src/transport/mock.rs @@ -485,8 +485,23 @@ impl EditorTransport for MockEditor { if line == 0 { return Err(TransportError("line numbers are 1-based".into())); } + // Clamp exactly as the C++ bridge does, and report the line LANDED on. + // + // This mock is the default transport of the shipped binary (no + // `--socket`) and every protocol test runs through it — so while it + // echoed the requested line it reproduced the NP-01 false report + // verbatim (`ok:true, line:99999` on a 4-line tab) and no Rust test + // could ever have observed the fixed response shape. + let total = self.tabs[i].content.lines().count().max(1); + let landed = line.min(total); self.selection.0 = i; - Ok(json!({ "ok": true, "tab_index": i, "line": line })) + Ok(json!({ + "ok": true, + "tab_index": i, + "line": landed, + "requested_line": line, + "clamped": landed != line, + })) } fn select_range( diff --git a/notepatra-mcp/src/transport/mod.rs b/notepatra-mcp/src/transport/mod.rs index fe44c6a..f4cc6c7 100644 --- a/notepatra-mcp/src/transport/mod.rs +++ b/notepatra-mcp/src/transport/mod.rs @@ -109,7 +109,10 @@ impl std::error::Error for TransportError {} /// * `list_recent_files` → `{files:[str]}` /// * `find_in_tab` → `{matches:[{line,text}],truncated}` /// * `new_tab` → `{tab_index}` -/// * `goto_line` → `{ok,tab_index,line}` +/// * `goto_line` → `{ok,tab_index,line,requested_line,clamped}` — `line` is +/// where the cursor LANDED, not what was asked for: a line past end-of-file +/// clamps to the last line and sets `clamped:true`. Check it before issuing a +/// cursor-relative write. /// * `set_language` → `{ok,tab_index,language}` /// * `compare_tabs` → `{opened}` /// * `format_text` → `{text}` diff --git a/notepatra-mcp/src/transport/socket.rs b/notepatra-mcp/src/transport/socket.rs index a6a704b..34b1cb3 100644 --- a/notepatra-mcp/src/transport/socket.rs +++ b/notepatra-mcp/src/transport/socket.rs @@ -141,9 +141,13 @@ impl SocketEditor { )) } - /// Targets an explicit socket path, BYPASSING discovery (`--socket-path` - /// and tests with a fake bridge). Exactly one candidate, so an explicit - /// path can never silently fall through to some other editor. + /// Targets an explicit socket path, BYPASSING discovery. Exactly one + /// candidate, so an explicit path can never silently fall through to some + /// other editor. + /// + /// Used by the tests' fake bridge. There is no `--socket-path` flag — an + /// earlier version of this comment named one, and main.rs now rejects + /// unknown flags outright, so passing it would exit 2. pub fn with_socket_path(path: impl Into) -> Self { Self::with_candidates(vec![path.into()]) } diff --git a/notepatra-mcp/tests/cli.rs b/notepatra-mcp/tests/cli.rs new file mode 100644 index 0000000..6a0811c --- /dev/null +++ b/notepatra-mcp/tests/cli.rs @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Command-line surface of the `notepatra-mcp` binary. +// +// This is a stdio server: with no arguments it blocks reading stdin, which is +// correct when an MCP client launched it and looks exactly like a crash when a +// human ran it in a terminal. Before v0.1.125 EVERY unrecognised argument fell +// into that loop — `--version` printed nothing and hung, `--sokcet` silently +// started a MOCK server against fabricated data, and a mistyped subcommand like +// `sevre` hung too. The rule these tests pin: an argument we do not understand +// is a loud exit, never a hang and never a silent fallback. +// +// Nothing here may wait on a child unbounded. Some arguments are SUPPOSED to +// block: built `--features remote`, `serve` binds a port and never returns, and +// CI runs the suite twice — once default, once with that feature. The first cut +// of this file called `.output()` on `serve`, which waits for an EOF that never +// comes; it passed locally on the default build and wedged four CI runners for +// 25 minutes each. Every spawn below is time-capped and killed. + +use std::io::Read; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +struct Outcome { + /// `false` means the child was still running when the budget expired and we + /// killed it. For a subcommand that legitimately blocks, that is a pass. + exited: bool, + code: Option, + stdout: String, + stderr: String, +} + +fn run_bounded(args: &[&str], budget: Duration) -> Outcome { + let mut child = Command::new(env!("CARGO_BIN_EXE_notepatra-mcp")) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .expect("failed to spawn notepatra-mcp"); + + // Drain both pipes on their own threads. A child that fills one of them + // would block before exiting, which would turn the bounded wait below into + // a lie about why it did not finish. + let mut out_pipe = child.stdout.take().expect("stdout was piped"); + let mut err_pipe = child.stderr.take().expect("stderr was piped"); + let out_reader = std::thread::spawn(move || { + let mut s = String::new(); + let _ = out_pipe.read_to_string(&mut s); + s + }); + let err_reader = std::thread::spawn(move || { + let mut s = String::new(); + let _ = err_pipe.read_to_string(&mut s); + s + }); + + let deadline = Instant::now() + budget; + let status = loop { + match child.try_wait().expect("try_wait on the child failed") { + Some(st) => break Some(st), + None if Instant::now() >= deadline => { + let _ = child.kill(); + let _ = child.wait(); + break None; + } + None => std::thread::sleep(Duration::from_millis(25)), + } + }; + + Outcome { + exited: status.is_some(), + code: status.and_then(|s| s.code()), + stdout: out_reader.join().unwrap_or_default(), + stderr: err_reader.join().unwrap_or_default(), + } +} + +/// For arguments that MUST exit. The budget is generous because it should never +/// be reached; when it is, the failure names a hang instead of timing out the job. +fn run(args: &[&str]) -> Outcome { + let o = run_bounded(args, Duration::from_secs(30)); + assert!( + o.exited, + "`{}` hung for 30s — it must exit, not fall into the stdio loop", + args.join(" ") + ); + o +} + +#[test] +fn help_and_version_print_and_exit_zero() { + for flag in ["-h", "--help"] { + let o = run(&[flag]); + assert_eq!(o.code, Some(0), "{flag} should exit 0"); + assert!( + o.stdout.contains("USAGE"), + "{flag} printed no usage block: {}", + o.stdout + ); + } + for flag in ["-V", "--version"] { + let o = run(&[flag]); + assert_eq!(o.code, Some(0), "{flag} should exit 0"); + assert!( + o.stdout.contains(env!("CARGO_PKG_VERSION")), + "{flag} printed no version: {}", + o.stdout + ); + } +} + +#[test] +fn a_mistyped_subcommand_exits_two_instead_of_hanging() { + // USAGE advertises bare-word subcommands, so this is a realistic typo — + // and it is the half that survived the first fix, which rejected only + // flag-shaped arguments. + let o = run(&["sevre"]); + assert_eq!(o.code, Some(2), "unknown subcommand must exit 2"); + assert!( + o.stderr.contains("unknown subcommand") && o.stderr.contains("sevre"), + "the error must name what was typed: {}", + o.stderr + ); + assert!( + o.stderr.contains("serve"), + "the error must list the real subcommands: {}", + o.stderr + ); +} + +#[test] +fn a_mistyped_flag_exits_two_rather_than_starting_a_mock() { + // The dangerous half: `--sokcet` used to start the in-memory MOCK, so the + // client got fabricated tabs that looked like the user's real editor. + let o = run(&["--sokcet"]); + assert_eq!(o.code, Some(2), "unknown flag must exit 2"); + assert!( + o.stderr.contains("--sokcet"), + "the error must name the offending flag: {}", + o.stderr + ); +} + +#[test] +fn the_real_subcommands_are_not_swallowed_by_the_typo_guard() { + // Vacuity guard for the two tests above: if the guard rejected everything, + // they would pass while the binary was broken. + // + // Two legitimate outcomes, and which one you get depends on the build. A + // std-only build exits 2 with "built without remote support"; a `--features + // remote` build accepts the subcommand and blocks. Still running when the + // budget expires IS the pass — the typo guard exits before it can bind + // anything, so a live process proves the argument was understood. + for mode in ["serve", "pair", "connect"] { + let o = run_bounded(&[mode], Duration::from_secs(3)); + assert!( + !o.stderr.contains("unknown subcommand"), + "{mode} is a real subcommand and must not hit the typo guard: {}", + o.stderr + ); + if o.exited { + assert_eq!( + o.code, + Some(2), + "{mode} exited {:?} — the only clean exit here is the \ + built-without-remote refusal: {}", + o.code, + o.stderr + ); + } + } +} diff --git a/notepatra-mcp/tests/protocol.rs b/notepatra-mcp/tests/protocol.rs index 033ec93..7e13254 100644 --- a/notepatra-mcp/tests/protocol.rs +++ b/notepatra-mcp/tests/protocol.rs @@ -454,6 +454,95 @@ fn wave2_malformed_arguments_are_invalid_params_errors() { } } +/// goto_line must report where the cursor LANDED, never echo the request. +/// +/// The mock is the default transport of the shipped binary, and every test in +/// this file runs through it — so while it echoed the requested line, the Rust +/// suite could pass with the NP-01 false report fully intact. It did, for the +/// whole of the fix that was supposed to remove it. +#[test] +fn goto_line_past_eof_clamps_and_reports_where_it_landed() { + // Mock tab 0 is a 3-line Rust file; derive the count rather than hardcode. + let read = run_lines(&[call_line(1, "read_tab", json!({ "tab_index": 0 }))]); + let total = text_of(&read[0]).lines().count(); + assert!(total > 0, "vacuity guard: mock tab 0 must have content"); + + let responses = run_lines(&[ + call_line(2, "goto_line", json!({ "line": 99999, "tab_index": 0 })), + call_line(3, "goto_line", json!({ "line": 1, "tab_index": 0 })), + ]); + + let past: Value = serde_json::from_str(text_of(&responses[0])).unwrap(); + assert_eq!( + past["line"].as_u64(), + Some(total as u64), + "expected a clamp to the last line, got {past}" + ); + assert_ne!( + past["line"].as_u64(), + Some(99999), + "goto_line echoed the requested line instead of the real one: {past}" + ); + assert_eq!(past["requested_line"].as_u64(), Some(99999)); + assert_eq!(past["clamped"].as_bool(), Some(true)); + + // An in-range jump must not be flagged as clamped. + let ok: Value = serde_json::from_str(text_of(&responses[1])).unwrap(); + assert_eq!(ok["line"].as_u64(), Some(1)); + assert_eq!(ok["clamped"].as_bool(), Some(false)); +} + +/// A 1-based argument must be rejected with ONE rule, whatever the bad value. +/// +/// `required_one_based` used to delegate to `required_index`, whose `as_u64()` +/// rejected negatives before the `>= 1` check ran. So the same mistake produced +/// two contradictory sentences — `line: 0` got "must be an integer >= 1" while +/// `line: -5` got "must be a non-negative integer", a rule that explicitly +/// PERMITS the value the other one rejects. A caller cannot correct against +/// guidance that disagrees with itself. +#[test] +fn one_based_arguments_have_a_single_consistent_rule() { + let responses = run_lines(&[ + call_line(1, "goto_line", json!({ "line": 0 })), + call_line(2, "goto_line", json!({ "line": -5 })), + call_line(3, "goto_line", json!({ "line": 1.5 })), + call_line(4, "goto_line", json!({ "line": "3" })), + ]); + + for r in &responses { + assert_eq!(r["error"]["code"], -32602, "expected -32602 in {r}"); + } + + let msgs: Vec = responses + .iter() + .map(|r| { + r["error"]["message"] + .as_str() + .unwrap_or_default() + .to_string() + }) + .collect(); + + // Vacuity guard: prove the messages are real before comparing them, or an + // all-empty result set would satisfy the equality check below for free. + assert!( + msgs.iter().all(|m| m.contains("line")), + "expected each message to name the offending key, got {msgs:?}" + ); + assert!( + msgs.iter().all(|m| m.contains(">= 1")), + "every 1-based rejection must state the >= 1 rule, got {msgs:?}" + ); + assert!( + !msgs.iter().any(|m| m.contains("non-negative")), + "\"non-negative\" permits 0, which is exactly what this rejects: {msgs:?}" + ); + assert!( + msgs.windows(2).all(|w| w[0] == w[1]), + "the same mistake must produce the same message, got {msgs:?}" + ); +} + // --------------------------------------------------------------------------- // Resources (spec 2025-06-18) // --------------------------------------------------------------------------- diff --git a/resources/notepatra.rc b/resources/notepatra.rc.in similarity index 64% rename from resources/notepatra.rc rename to resources/notepatra.rc.in index 3c4be1d..4672b1b 100644 --- a/resources/notepatra.rc +++ b/resources/notepatra.rc.in @@ -1,15 +1,20 @@ -// Windows resource script for Notepatra. +// Windows resource script for Notepatra — GENERATED from +// resources/notepatra.rc.in by CMake. Do not edit the generated copy. +// +// Until v0.1.125 this was a STATIC file carrying a hardcoded 0,1,0,0, +// so every Windows build ever shipped reported version 0.1.0 to winget, +// SCCM, Intune and File Properties, no matter what it actually was. // CMakeLists.txt picks this up via add_executable() on Windows targets and // passes it to MSVC's resource compiler. // IDI_ICON1 is what Windows Explorer uses as the .exe icon (lowest numbered // icon resource wins). Path is relative to this .rc file's location. -IDI_ICON1 ICON "notepatra.ico" +IDI_ICON1 ICON "@NOTEPATRA_ICON_PATH@" // Version info -- shows up in File Properties > Details on Windows. 1 VERSIONINFO -FILEVERSION 0,1,0,0 -PRODUCTVERSION 0,1,0,0 +FILEVERSION @NOTEPATRA_RC_VERSION_COMMA@ +PRODUCTVERSION @NOTEPATRA_RC_VERSION_COMMA@ FILEFLAGSMASK 0x3fL FILEFLAGS 0x0L FILEOS 0x40004L // VOS_NT_WINDOWS32 @@ -22,12 +27,12 @@ BEGIN BEGIN VALUE "CompanyName", "Prateek Singh" VALUE "FileDescription", "Notepatra native code editor for the AI era" - VALUE "FileVersion", "0.1.0.0" + VALUE "FileVersion", "@NOTEPATRA_RC_VERSION_DOT@" VALUE "InternalName", "notepatra" VALUE "LegalCopyright", "Copyright 2026 Prateek Singh. GPL-3.0." VALUE "OriginalFilename", "notepatra.exe" VALUE "ProductName", "Notepatra" - VALUE "ProductVersion", "0.1.0" + VALUE "ProductVersion", "@PROJECT_VERSION@" END END BLOCK "VarFileInfo" diff --git a/src/ai_tools.cpp b/src/ai_tools.cpp index 56d83c7..9320fdf 100644 --- a/src/ai_tools.cpp +++ b/src/ai_tools.cpp @@ -2,6 +2,8 @@ #include "ai_tools.h" +#include "path_denylist.h" + #include "csvanalyst.h" #include "dbconnections.h" #include "git_tools.h" @@ -47,65 +49,12 @@ namespace Limits { // ═══════════════════════════════════════════════════════════════════════ bool isHardDenied(const QString &absPath) { - const QString p = absPath.toLower(); - - // Substring matches — credential / secret directories. Both forward- - // and back-slash variants so this works on Windows where canonical - // paths sometimes preserve backslashes from the OS API. Match on - // path SEGMENTS (with leading + trailing separators) so we don't - // false-positive on a project named "ssh" at the workspace root. - static const QStringList denyContains = { - "/.ssh/", "\\.ssh\\", - "/.gnupg/", "\\.gnupg\\", - "/.aws/", "\\.aws\\", - "/.netrc", "\\.netrc", - "/etc/passwd", "/etc/shadow", - "/.npmrc", // npm authToken - "/.pypirc", // pypi credentials - "/.docker/config.json", - }; - for (const QString &needle : denyContains) { - if (p.contains(needle)) return true; - } - - // Suffix matches — private-key file extensions. *.pem and *.key are - // not always credentials (test fixtures sometimes use them) but the - // false-positive rate is low enough that "refuse and explain" is - // safer than "leak occasionally". - // - // v0.1.106: add Terraform variable / state files — *.tfvars routinely - // hold provider credentials and *.tfstate embeds resource secrets in - // plaintext. - static const QStringList denySuffix = { - ".pem", ".key", ".pfx", ".p12", ".tfvars", ".tfstate", - }; - for (const QString &suf : denySuffix) { - if (p.endsWith(suf)) return true; - } - - // Filename / segment patterns — id_rsa, id_ed25519, id_ecdsa, etc. - // Match on a leading path separator so a project named "secretsjson" - // or "myenv" at the workspace root doesn't false-positive. - // - // v0.1.106: add the dotenv family and secrets.json — the original - // deny-list never covered them, so read_file(".env") leaked its body - // verbatim to the backend. - static const QStringList denyFilename = { - "id_rsa", "id_ed25519", "id_ecdsa", "id_dsa", - "authorized_keys", "known_hosts", - ".env", "secrets.json", - }; - for (const QString &name : denyFilename) { - if (p.contains("/" + name) || p.contains("\\" + name)) return true; - } - - // v0.1.106: the dotenv convention also covers .env (app.env, - // database.env, prod.env) and *.env. (app.env.bak), which the - // leading-"/.env" match above misses. Any path ending in ".env" or - // containing ".env." is overwhelmingly credential-bearing — deny it. - if (p.endsWith(".env") || p.contains(".env.")) return true; - - return false; + // Delegates to PathDenylist — see src/path_denylist.h. This list used to + // live here AND in git_hunk_apply.cpp, and the two had drifted (this copy + // lacked *.jks; that one lacked the dotenv family, *.tfvars and the + // Windows separator variants). search_project consulted neither and could + // return matching LINES out of ~/.ssh/. One list, three callers. + return PathDenylist::isSecretPath(absPath); } bool resolveSafePath(const QString &pathArg, diff --git a/src/aipanel.cpp b/src/aipanel.cpp index a1182d6..6fc9d3c 100644 --- a/src/aipanel.cpp +++ b/src/aipanel.cpp @@ -3395,9 +3395,10 @@ void AIPanel::sendPrompt(const QString &action) { if (codingNow && m_workspaceRoot.isEmpty()) { appendErrorBubble( "Coding Mode needs an open folder so I can read/edit your " - "files. Open one via File → Open Folder… (or drag a folder " - "onto the window), then ask again. If you just want to chat " - "about code in general (no file ops), switch to Chat mode."); + "files. Open one via File → Open Folder as Workspace... (or " + "drag a folder onto the window), then ask again. If you just " + "want to chat about code in general (no file ops), switch to " + "Chat mode."); return; } if (dataNow && DbConnections::loadAll().isEmpty()) { @@ -6985,6 +6986,34 @@ void AIPanel::updateChatHistoryPath() { const QByteArray hash = QCryptographicHash::hash( m_workspaceRoot.toUtf8(), QCryptographicHash::Sha1).toHex(); m_chatHistoryPath = historyDir + "/" + QString::fromLatin1(hash) + ".json"; + + // v0.1.125 migration — adopt history stranded under sha1($HOME). + // + // Until this release the workspace root silently fell back to the file + // tree's display folder, which defaults to the home directory. So for every + // user who never opened a folder, their entire conversation history was + // written under sha1($HOME). Now that the root is derived correctly, that + // file is unreachable and the AI panel looks like it forgot everything. + // + // Adopt it ONCE, and only when the new location does not already exist, so + // this can never overwrite a real conversation. Copy rather than move: if + // anything here is wrong the original is still on disk, and a stale extra + // file costs the user nothing while a deleted one costs them their history. + if (QFileInfo::exists(m_chatHistoryPath)) return; + + const QString homeRoot = QDir::homePath(); + if (m_workspaceRoot == homeRoot) return; // already the legacy key + + const QByteArray legacyHash = QCryptographicHash::hash( + homeRoot.toUtf8(), QCryptographicHash::Sha1).toHex(); + const QString legacyPath = + historyDir + "/" + QString::fromLatin1(legacyHash) + ".json"; + if (!QFileInfo::exists(legacyPath)) return; + + if (QFile::copy(legacyPath, m_chatHistoryPath)) { + qInfo("chat history: adopted pre-v0.1.125 history from %s", + qUtf8Printable(QFileInfo(legacyPath).fileName())); + } } void AIPanel::scheduleChatSave() { diff --git a/src/editor.cpp b/src/editor.cpp index 304a1ba..e7b6ee3 100644 --- a/src/editor.cpp +++ b/src/editor.cpp @@ -1335,9 +1335,21 @@ void Editor::onMarginClicked(int margin, int line, Qt::KeyboardModifiers) { } } -void Editor::gotoLine(int line) { - setCursorPosition(line - 1, 0); - ensureLineVisible(line - 1); +// Jump to a 1-based line, CLAMPED to the buffer. Returns the line actually +// landed on, so callers can report the truth rather than echo the request. +// +// Unclamped, an out-of-range line was worse than a no-op: Scintilla rejects the +// position and leaves the cursor at 0, so asking for line 99999 in a 4-line file +// silently moved the cursor to the TOP. Over MCP that response still said +// ok:true, and insert_text defaults to the cursor — so an assistant aiming at +// the end of a file wrote at the beginning, with an approval card that looked +// entirely legitimate. +int Editor::gotoLine(int line) { + const int total = lines(); + const int target = qBound(1, line, total > 0 ? total : 1); + setCursorPosition(target - 1, 0); + ensureLineVisible(target - 1); + return target; } void Editor::duplicateLine() { diff --git a/src/editor.h b/src/editor.h index e336af6..cf40c5c 100644 --- a/src/editor.h +++ b/src/editor.h @@ -95,7 +95,8 @@ class Editor : public QsciScintilla { struct CommentSyntax { QString line; QString blockOpen; QString blockClose; }; static CommentSyntax commentSyntaxFor(const QString &lang); - void gotoLine(int line); + // Clamped to [1, lines()]; returns the line actually landed on. + int gotoLine(int line); void updateGitGutter(); void duplicateLine(); void deleteLine(); diff --git a/src/fileexplorer.cpp b/src/fileexplorer.cpp index 681c147..9a4b37a 100644 --- a/src/fileexplorer.cpp +++ b/src/fileexplorer.cpp @@ -99,6 +99,9 @@ class FileExplorer::HiddenPathProxy : public QSortFilterProxyModel { }; FileExplorer::FileExplorer(QWidget *parent) : QWidget(parent) { + // Display placeholder ONLY — deliberately leaves m_rootExplicit false, so + // workspaceRoot() stays empty until the user opens a folder. Do not treat + // this as a workspace; see the comment on workspaceRoot() in the header. m_rootPath = QDir::homePath(); auto *layout = new QVBoxLayout(this); @@ -143,13 +146,29 @@ FileExplorer::FileExplorer(QWidget *parent) : QWidget(parent) { m_tree->setStyle(new SsmsBranchStyle(qApp->style())); layout->addWidget(m_tree); - connect(m_pathCombo, &QComboBox::currentTextChanged, this, [this](const QString &path) { - if (QFileInfo(path).isDir()) { - m_rootPath = path; - m_model->setRootPath(path); - m_tree->setRootIndex(m_proxy->mapFromSource(m_model->index(path))); - } - }); + // Navigate on COMMIT only — a dropdown pick or Enter — never on + // currentTextChanged. + // + // currentTextChanged fires on every keystroke of an editable combo, so + // typing a path re-rooted the tree once per character AND, worse, latched + // m_rootExplicit on whatever prefix happened to be a directory. Typing a + // single "/" was enough to make "/" the user's "explicit workspace" + // permanently — which then anchors the AI file sandbox at the filesystem + // root and hands search_project the whole disk. The flag has no way back. + // + // Both signals below are user-commit events; neither can fire mid-typing. + auto navigateTo = [this](const QString &path) { + if (!QFileInfo(path).isDir()) return; + m_rootPath = path; + m_rootExplicit = true; // a deliberate, committed choice + m_model->setRootPath(path); + m_tree->setRootIndex(m_proxy->mapFromSource(m_model->index(path))); + }; + connect(m_pathCombo, QOverload::of(&QComboBox::activated), this, + [this, navigateTo](int) { navigateTo(m_pathCombo->currentText()); }); + if (auto *edit = m_pathCombo->lineEdit()) + connect(edit, &QLineEdit::returnPressed, this, + [this, navigateTo] { navigateTo(m_pathCombo->currentText()); }); connect(upBtn, &QPushButton::clicked, this, [this]() { QDir dir(m_rootPath); @@ -204,6 +223,7 @@ FileExplorer::FileExplorer(QWidget *parent) : QWidget(parent) { void FileExplorer::setRoot(const QString &path) { if (QFileInfo(path).isDir()) { m_rootPath = path; + m_rootExplicit = true; // Open Folder / restored workspace m_pathCombo->setCurrentText(QDir::toNativeSeparators(path)); } } diff --git a/src/fileexplorer.h b/src/fileexplorer.h index f363033..91743f2 100644 --- a/src/fileexplorer.h +++ b/src/fileexplorer.h @@ -17,10 +17,24 @@ class FileExplorer : public QWidget { public: explicit FileExplorer(QWidget *parent = nullptr); void setRoot(const QString &path); - // Expose the current root so MainWindow can seed the AI workspace - // context from it (so the AI knows *which* folder to reason about). + // The folder the TREE is currently displaying. Never empty — it starts at + // the home directory so the widget has something to render. Use this only + // for view concerns. QString rootPath() const { return m_rootPath; } + // The folder the user EXPLICITLY opened, or "" if they never opened one. + // + // These two are different questions and conflating them caused a privacy + // bug: search_project treated the display root as a workspace, so with no + // folder open it walked the user's entire home directory and returned + // line-level content from files they had never opened — including, in the + // report that found this, the transcript of the session driving the tool. + // Anything that decides "what is the user working on" MUST use this one, + // and must treat "" as "nothing is scoped", not as "start at home". + QString workspaceRoot() const { + return m_rootExplicit ? m_rootPath : QString(); + } + // v0.1.61 — hidden-path filter. Right-click any tree node to add it // to the hidden set; "Show hidden" empties the set. Persisted across // sessions via Config::explorerHiddenPaths so users don't have to @@ -40,6 +54,9 @@ class FileExplorer : public QWidget { QTreeView *m_tree; QComboBox *m_pathCombo; QString m_rootPath; + // False until the user actually picks a folder. The ctor's home-directory + // seed does NOT set it — that is a placeholder for the view, not a choice. + bool m_rootExplicit = false; // v0.1.61 — proxy that filters out anything in m_hiddenPaths so the // tree view doesn't render hidden entries. Operates on absolute file diff --git a/src/git_hunk_apply.cpp b/src/git_hunk_apply.cpp index 1cb246d..2ee0f32 100644 --- a/src/git_hunk_apply.cpp +++ b/src/git_hunk_apply.cpp @@ -2,6 +2,8 @@ #include "git_hunk_apply.h" +#include "path_denylist.h" + #include #include #include @@ -49,24 +51,12 @@ bool inlineResolveReadPath(const QString &absFilePath, return false; } // Hardcoded deny-list — never stage hunks against credentials / keys. - // Mirrors ai_tools.cpp::isHardDenied. - const QString lower = fileCanonical.toLower(); - static const QStringList denyExt = {".pem", ".key", ".p12", ".pfx", ".jks"}; - for (const QString &e : denyExt) { - if (lower.endsWith(e)) { - if (outErrorKind) *outErrorKind = "denied"; - return false; - } - } - static const QStringList denySubstr = { - "/.ssh/", "/.gnupg/", "/.aws/", "/.netrc", "/.npmrc", - "/.docker/config.json", "id_rsa", "/etc/passwd", "/etc/shadow" - }; - for (const QString &s : denySubstr) { - if (lower.contains(s)) { - if (outErrorKind) *outErrorKind = "denied"; - return false; - } + // Shared with ai_tools.cpp and search_project via PathDenylist; this used + // to be a hand-maintained MIRROR of the ai_tools list and had already + // drifted from it in both directions. + if (PathDenylist::isSecretPath(fileCanonical)) { + if (outErrorKind) *outErrorKind = "denied"; + return false; } if (outCanonical) *outCanonical = fileCanonical; return true; diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 564f214..a299dde 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -1321,8 +1321,25 @@ MainWindow::MainWindow(bool standaloneNoSession) // (or file) so the AI has something concrete to read/edit. One- // shot per session via m_codingFolderPromptShown — re-entering // Coding mode after dismissing doesn't re-pester. - if (on && !m_codingFolderPromptShown - && m_explorer->rootPath().isEmpty()) { + // Fire ONLY when the user genuinely has nothing to work on: no folder + // opened AND not a single file-backed tab anywhere. + // + // The tab scan matters: with a Terminal or Welcome tab focused, any + // "current file" check reads empty even though real files are open one + // tab over, and the dialog would contradict itself by demanding "a + // folder or file" while both are already there. + // + // Worth stating plainly: this prompt has never once fired in a shipped + // build. Its old gate was FileExplorer::rootPath().isEmpty(), and that + // defaults to $HOME, so the condition was permanently false. Fixing the + // workspace-root bug is what woke it up. + bool anyFileOpen = false; + for (int i = 0; m_tabs && i < m_tabs->count() && !anyFileOpen; ++i) + if (auto *ed = m_tabs->editorAt(i)) + anyFileOpen = !ed->filePath().isEmpty(); + + if (on && !m_codingFolderPromptShown && !anyFileOpen + && workspaceFolder().isEmpty()) { m_codingFolderPromptShown = true; QMessageBox box(this); box.setWindowTitle(tr("Coding Mode — open something to work on")); @@ -1769,18 +1786,11 @@ MainWindow::MainWindow(bool standaloneNoSession) auto *ed = currentEditor(); return ed ? ed->selectedText() : QString(); }; - // Explorer root, else the current file's directory (git resolves the - // enclosing repo from there); "" only when neither exists. - auto effectiveWorkspaceRoot = [this]() -> QString { - QString root = m_explorer ? m_explorer->rootPath() : QString(); - if (root.isEmpty()) { - if (auto *ed = currentEditor()) - if (!ed->filePath().isEmpty()) - root = QFileInfo(ed->filePath()).absolutePath(); - } - return root; - }; - host.workspaceRoot = effectiveWorkspaceRoot; + // STRICT folder-only. NOT a current-file fallback: with a single file + // open in $HOME that fallback resolves to $HOME, and search_project + // walks the user's entire profile — the exact leak this release fixes, + // reached through a one-file door instead of a no-file door. + host.workspaceRoot = [this] { return workspaceFolder(); }; // ── v0.1.118 expansive wave — every lambda routes through the SAME // code path the equivalent menu/status-bar surface uses. ── host.currentTabIndex = [this] { return m_tabs->currentIndex(); }; @@ -1811,14 +1821,15 @@ MainWindow::MainWindow(bool standaloneNoSession) } return m_tabs->currentIndex(); // newFile() focuses the new tab }; - host.gotoLine = [this](int tabIndex, int line) { - if (tabIndex < 0 || tabIndex >= m_tabs->count()) return false; + host.gotoLine = [this](int tabIndex, int line) -> int { + if (tabIndex < 0 || tabIndex >= m_tabs->count()) return -1; auto *ed = m_tabs->editorAt(tabIndex); - if (!ed) return false; + if (!ed) return -1; m_tabs->setCurrentIndex(tabIndex); - ed->gotoLine(line); // 1-based; ensures the line is visible + // 1-based; clamps to the buffer and ensures the line is visible. + const int landed = ed->gotoLine(line); ed->setFocus(); - return true; + return landed; }; // v0.1.121 (issue #5): move the selection to a 1-based range. Cols are // clamped to each line's text length (EOL excluded) so an over-long @@ -2025,14 +2036,13 @@ MainWindow::MainWindow(bool standaloneNoSession) return arr; }; // READ: read-only git — reuses git_tools.cpp (no new QProcess path). - host.runGit = [this, effectiveWorkspaceRoot]( - const QString &sub, const QJsonObject &args, - QString *err) -> QString { + host.runGit = [this](const QString &sub, const QJsonObject &args, + QString *err) -> QString { // Candidate roots: the workspace (Explorer) folder first, then the // current file's directory — so git "just works" on an open repo // file even when the workspace folder isn't itself a repository. QStringList roots; - const QString wsRoot = effectiveWorkspaceRoot(); + const QString wsRoot = workspaceFolder(); if (!wsRoot.isEmpty()) roots << wsRoot; if (auto *ed = currentEditor()) if (!ed->filePath().isEmpty()) { @@ -2135,7 +2145,7 @@ MainWindow::MainWindow(bool standaloneNoSession) // Deliberately NOT the workspaceRoot fallback: csv sandbox root // stays folder-open-only. const QString wsRoot = - m_explorer ? m_explorer->rootPath() : QString(); + m_explorer ? m_explorer->workspaceRoot() : QString(); QString csvCanon; if (!AiTools::resolveSafePath(csvPath, wsRoot, &csvCanon, nullptr) @@ -2533,6 +2543,44 @@ Editor *MainWindow::currentEditor() { return m_tabs->currentEditor(); } +// See the header for why these are four functions and not one. + +QString MainWindow::workspaceFolder() const { + return m_explorer ? m_explorer->workspaceRoot() : QString(); +} + +QString MainWindow::firstOpenFileDir() const { + for (int i = 0; m_tabs && i < m_tabs->count(); ++i) + if (auto *ed = m_tabs->editorAt(i)) + if (!ed->filePath().isEmpty()) + return QFileInfo(ed->filePath()).absolutePath(); + return QString(); +} + +QString MainWindow::suggestedDialogFolder() const { + const QString folder = workspaceFolder(); + if (!folder.isEmpty()) return folder; + if (auto *ed = m_tabs ? m_tabs->currentEditor() : nullptr) + if (!ed->filePath().isEmpty()) + return QFileInfo(ed->filePath()).absolutePath(); + return firstOpenFileDir(); +} + +QString MainWindow::aiWorkspaceRoot() { + // An explicit folder always wins and re-latches — that IS the user saying + // "this is my project now", and re-keying the conversation is correct there. + const QString folder = workspaceFolder(); + if (!folder.isEmpty()) { + m_aiWorkspaceLatched = folder; + return folder; + } + // Otherwise keep whatever we first settled on. Deriving this from the + // CURRENT tab made the root flap on every Ctrl+Tab. + if (m_aiWorkspaceLatched.isEmpty()) + m_aiWorkspaceLatched = firstOpenFileDir(); + return m_aiWorkspaceLatched; +} + // ── File operations ── Editor *MainWindow::newFile() { @@ -3735,8 +3783,8 @@ void MainWindow::buildMenus() { QString defaultFolder; if (auto *e = E(); e && !e->filePath().isEmpty()) defaultFolder = QFileInfo(e->filePath()).path(); - else if (m_explorer && !m_explorer->rootPath().isEmpty()) - defaultFolder = m_explorer->rootPath(); + else if (m_explorer && !m_explorer->workspaceRoot().isEmpty()) + defaultFolder = m_explorer->workspaceRoot(); if (!defaultFolder.isEmpty()) ps->setFolder(defaultFolder); connect(ps, &ProjectSearch::openFileAtLine, this, @@ -6434,7 +6482,21 @@ void MainWindow::dragEnterEvent(QDragEnterEvent *event) { void MainWindow::dropEvent(QDropEvent *event) { for (const QUrl &url : event->mimeData()->urls()) { - if (url.isLocalFile()) openFile(url.toLocalFile()); + if (!url.isLocalFile()) continue; + const QString path = url.toLocalFile(); + // A dropped DIRECTORY opens as the workspace, matching what the Coding + // Mode refusal has always told users to do ("drag a folder onto the + // window"). Until v0.1.125 that instruction was false: every drop went + // to openFile(), which rejects a directory — and the refusal only became + // reachable in this release, so the advice had never been exercised. + if (QFileInfo(path).isDir()) { + if (m_explorer) { + Config::instance().noteLastDir(path); + m_explorer->setRoot(path); + } + continue; + } + openFile(path); } } @@ -6713,10 +6775,9 @@ void MainWindow::populateAiContext(AIPanel *panel) { // 2. Directory of the current file // This way the AI reasons about the project the user actually opened, // not just the folder containing whichever file happens to be active. - if (m_explorer && !m_explorer->rootPath().isEmpty()) - workspace = m_explorer->rootPath(); - else if (!curPath.isEmpty()) - workspace = QFileInfo(curPath).absolutePath(); + // Sticky for the session — see aiWorkspaceRoot(). A per-tab root cancelled + // pending write approvals and swapped the chat history on every tab switch. + workspace = aiWorkspaceRoot(); // Walk the workspace root (shallow-but-recursive) to hand the AI a // codebase file listing. Lets the model reference files the user diff --git a/src/mainwindow.h b/src/mainwindow.h index 42a7c68..ae673b9 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -47,6 +47,43 @@ class MainWindow : public QMainWindow { // session.json or destroy its crash evidence. explicit MainWindow(bool standaloneNoSession = false); Editor *currentEditor(); + + // ── Workspace roots ────────────────────────────────────────────────── + // + // These are four DIFFERENT questions and a single answer cannot serve them. + // Collapsing them is what produced the original privacy bug, and collapsing + // them a second time (folder-else-current-file) reproduced it: with one file + // open in $HOME, "the current file's directory" IS $HOME, so a search that + // trusted it walked the whole profile again. + // + // Rule of thumb: anything that SCOPES A SEARCH OR A SANDBOX must use + // workspaceFolder() and must treat "" as a refusal to act — never as a cue + // to fall back to something broader. + + /// The folder the user explicitly opened. "" when they never opened one. + /// The only safe root for scoping a filesystem walk or a sandbox. + QString workspaceFolder() const; + + /// Directory of the FIRST file-backed tab, or "". Answers "does the user + /// have anything concrete open?" without caring which tab has focus — a + /// Terminal or Welcome tab being current must not mean "nothing is open". + QString firstOpenFileDir() const; + + /// Folder to pre-fill a file dialog with. May fall back to the current + /// file's directory: the user SEES and can change this value, so a broad + /// default is a convenience here rather than a silent scope. Never use it + /// to bound a search. + QString suggestedDialogFolder() const; + + /// The AI's project root, STICKY for the session. + /// + /// Latches the first non-empty root and keeps it until the user explicitly + /// opens a folder. It cannot be a function of the focused tab: AIPanel + /// treats any root change as a project switch — cancelling pending write + /// approvals and re-keying chat history by sha1(root) — so a per-tab root + /// made Ctrl+Tab silently swap the user's conversation, even between two + /// files in one repository. + QString aiWorkspaceRoot(); void openFile(const QString &path); SearchResultsPanel *searchResults() { return m_searchResults; } QSplitter *vertSplitter() { return m_vertSplitter; } @@ -274,6 +311,9 @@ class MainWindow : public QMainWindow { // Resets on app restart; within one session, the picker shows once // per Coding entry and then stays out of the way. bool m_codingFolderPromptShown = false; + // Sticky AI project root — see aiWorkspaceRoot(). Latched once, then only + // replaced when the user explicitly opens a different folder. + QString m_aiWorkspaceLatched; // Push current workspace state (all open editor tabs, current file, // selection, workspace root) into an AIPanel so the model can reason // about cross-file questions like Cursor / Copilot. diff --git a/src/mcp_bridge.cpp b/src/mcp_bridge.cpp index ef4065d..48f15c3 100644 --- a/src/mcp_bridge.cpp +++ b/src/mcp_bridge.cpp @@ -55,7 +55,12 @@ // // ACT tier — visible, non-destructive, NO approval card. // new_tab {text?} → {tab_index} -// goto_line {line,tab_index?} → {ok,tab_index,line} +// goto_line {line,tab_index?} → {ok,tab_index,line, +// requested_line,clamped} +// `line` = where the cursor +// LANDED (clamped to the +// buffer); clamped:true when it +// differs from requested_line. // select_range{start_line,start_col,end_line,end_col,tab_index?} // → {ok,tab_index} (1-based line // +col; v0.1.121) @@ -99,6 +104,7 @@ #include "config.h" #include "diagram/npd_parser.h" #include "notes_storage.h" +#include "path_denylist.h" #include "singleinstance.h" #include @@ -253,7 +259,33 @@ QJsonObject scanWorkspace(const QString &root, const QString &query, } const QFileInfo fi = it.fileInfo(); if (fi.size() > kMaxSearchFileBytes) continue; + // Skip VCS / dependency / build trees, the same set populateAiContext + // already excludes. These hold thousands of files that burn the scan + // budget for no user benefit, and .git in particular can echo the + // contents of files the user never opened. + { + const QString rel = QDir(root).relativeFilePath(path); + static const char *kSkipDirs[] = {".git/", "node_modules/", + "build/", "target/", + ".venv/", "__pycache__/"}; + bool skip = false; + for (const char *d : kSkipDirs) { + const QString seg = QLatin1String(d); + if (rel.startsWith(seg) || rel.contains(QLatin1Char('/') + seg)) { + skip = true; + break; + } + } + if (skip) continue; + } if (skipPaths.contains(fi.absoluteFilePath())) continue; + // The AI file tools refuse to READ ~/.ssh/id_rsa, *.pem, .aws/ + // credentials and friends (AiTools::isHardDenied). search_project + // walks the same tree with no such check, so a one-word query could + // return the matching LINES out of exactly those files — the deny-list + // was guarding the front door while this verb held the back one open. + // Same list, same thread-safe pure function, applied per file. + if (PathDenylist::isSecretPath(fi.absoluteFilePath())) continue; QFile f(path); if (!f.open(QIODevice::ReadOnly)) continue; if (f.peek(8192).contains('\0')) continue; // binary-looking @@ -284,6 +316,11 @@ QJsonObject scanWorkspace(const QString &root, const QString &query, QJsonObject result; result[QStringLiteral("results")] = hitsToJson(hits); result[QStringLiteral("truncated")] = truncated; + // Reaching here means the workspace really was walked. Every search_project + // response carries these two fields so the caller never has to infer scope + // from the result count. + result[QStringLiteral("workspace_searched")] = true; + result[QStringLiteral("scope")] = QStringLiteral("tabs_and_workspace"); return result; } @@ -832,11 +869,39 @@ void McpBridge::verbSearchProject(QLocalSocket *client, int id, const QString root = m_host.workspaceRoot ? m_host.workspaceRoot() : QString(); - if (root.isEmpty() || !QFileInfo(root).isDir() || - hits.size() >= maxResults) { + // Whether the workspace leg ran is a property of the WORKSPACE, never of + // whether the query happened to match an open tab. + // + // Gating the "no workspace" signal on `hits.isEmpty()` made the contract + // flip on match luck: miss the open tabs and you got a clear error, hit one + // and you got `{results:[...]}` shaped exactly like a completed project + // search — so a caller could not tell a searched project from an unsearched + // one. The `!isDir` case (folder deleted or renamed under us) was worse + // still: empty results, `truncated:false`, no error at all. + // + // Every response now states which legs actually ran, and the caller can + // trust that field instead of inferring from result count. + const bool haveWorkspace = !root.isEmpty() && QFileInfo(root).isDir(); + if (!haveWorkspace && hits.isEmpty()) { + sendError(client, id, + root.isEmpty() + ? QStringLiteral("No workspace folder is open, so there " + "is nothing to search beyond the open " + "tabs. Open a folder first.") + : QStringLiteral("The workspace folder no longer exists, " + "so only open tabs could be searched. " + "Re-open the folder.")); + return; + } + if (!haveWorkspace || hits.size() >= maxResults) { QJsonObject result; result[QStringLiteral("results")] = hitsToJson(hits); result[QStringLiteral("truncated")] = hits.size() >= maxResults; + // False here means "open tabs only" — the workspace was never walked. + result[QStringLiteral("workspace_searched")] = haveWorkspace; + result[QStringLiteral("scope")] = + haveWorkspace ? QStringLiteral("tabs_and_workspace") + : QStringLiteral("open_tabs_only"); sendResult(client, id, result); return; } @@ -1011,14 +1076,21 @@ void McpBridge::verbGotoLine(QLocalSocket *client, int id, QStringLiteral("tab index out of range: %1").arg(idx)); return; } - if (!m_host.gotoLine(idx, line)) { + const int landed = m_host.gotoLine(idx, line); + if (landed < 1) { sendError(client, id, QStringLiteral("could not move cursor")); return; } QJsonObject result; result[QStringLiteral("ok")] = true; result[QStringLiteral("tab_index")] = idx; - result[QStringLiteral("line")] = line; + // The line ACTUALLY landed on, which is not always the one requested: a + // line past end-of-file clamps to the last line. Echoing the request here + // was a lie that callers acted on — insert_text defaults to the cursor, so + // "ok, line 99999" meant a write at the top of the file. + result[QStringLiteral("line")] = landed; + result[QStringLiteral("requested_line")] = line; + result[QStringLiteral("clamped")] = (landed != line); sendResult(client, id, result); } @@ -1357,6 +1429,18 @@ void McpBridge::verbInsertText(QLocalSocket *client, int id, return; } int line = -1, col = -1; + // `col` alone is meaningless and used to be silently DISCARDED: the insert + // then went to the cursor, so an assistant asking for column 1 of wherever + // it thought it was landed somewhere else entirely — behind an approval + // card whose text said "at the cursor" and was therefore truthful but + // unread. Reject it and say which argument is missing. + if (args.contains(QLatin1String("col")) && + !args.contains(QLatin1String("line"))) { + sendError(client, id, + QStringLiteral("col requires line — pass both, or neither " + "to insert at the cursor")); + return; + } if (args.contains(QLatin1String("line"))) { line = args.value(QLatin1String("line")).toInt(0); col = args.contains(QLatin1String("col")) diff --git a/src/mcp_bridge.h b/src/mcp_bridge.h index 6734edb..7e21cf7 100644 --- a/src/mcp_bridge.h +++ b/src/mcp_bridge.h @@ -47,7 +47,11 @@ struct McpEditorHost { std::function cursorPosition; // 1-based line/col std::function recentFiles; std::function newTab; // initial text ("" = empty) → index, -1 on failure - std::function gotoLine; // (tab index, 1-based line) + // (tab index, 1-based line) → the line ACTUALLY landed on after clamping + // to the buffer, or -1 if the tab could not be targeted at all. Returning + // the clamped line (not a bool) is what lets goto_line report the truth + // instead of echoing a request it did not honour. + std::function gotoLine; std::function setLanguage; // false = unknown language std::function compareTabs; // opens the Compare dialog (non-modal) std::function diff --git a/src/path_denylist.cpp b/src/path_denylist.cpp new file mode 100644 index 0000000..39783cf --- /dev/null +++ b/src/path_denylist.cpp @@ -0,0 +1,73 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +#include "path_denylist.h" + +#include + +namespace PathDenylist { + +bool isSecretPath(const QString &absPath) { + const QString p = absPath.toLower(); + + // Segment matches — credential directories and system password files. + // Both forward- and back-slash variants, because canonical paths on + // Windows sometimes preserve the separators the OS API handed back. + // Matching on SEGMENTS (leading + trailing separator) keeps a project + // folder legitimately named "ssh" or "aws" out of the deny-list. + static const QStringList kSegments = { + QStringLiteral("/.ssh/"), QStringLiteral("\\.ssh\\"), + QStringLiteral("/.gnupg/"), QStringLiteral("\\.gnupg\\"), + QStringLiteral("/.aws/"), QStringLiteral("\\.aws\\"), + QStringLiteral("/.netrc"), QStringLiteral("\\.netrc"), + QStringLiteral("/.npmrc"), QStringLiteral("/.pypirc"), + QStringLiteral("/.docker/config.json"), + QStringLiteral("/etc/passwd"), QStringLiteral("/etc/shadow"), + }; + for (const QString &needle : kSegments) + if (p.contains(needle)) return true; + + // Extension matches — private keys, keystores, and Terraform files. + // *.pem / *.key are not ALWAYS credentials (test fixtures use them), but + // the false-positive cost is one refused read and the false-negative cost + // is a leaked key, so this errs toward refusing. + // + // .jks came from the git-hunk-apply copy of this list and was missing from + // the AI-tools copy — the exact drift this file exists to end. + static const QStringList kSuffixes = { + QStringLiteral(".pem"), QStringLiteral(".key"), + QStringLiteral(".pfx"), QStringLiteral(".p12"), + QStringLiteral(".jks"), QStringLiteral(".tfvars"), + QStringLiteral(".tfstate"), + }; + for (const QString &suffix : kSuffixes) + if (p.endsWith(suffix)) return true; + + // Filename patterns, separator-anchored so "secretsjson" or "myenv" at + // the workspace root does not false-positive. + static const QStringList kFilenames = { + QStringLiteral("id_rsa"), QStringLiteral("id_ed25519"), + QStringLiteral("id_ecdsa"), QStringLiteral("id_dsa"), + QStringLiteral("authorized_keys"), QStringLiteral("known_hosts"), + QStringLiteral(".env"), QStringLiteral("secrets.json"), + }; + for (const QString &name : kFilenames) + if (p.contains(QLatin1Char('/') + name) || + p.contains(QLatin1Char('\\') + name)) + return true; + + // Unanchored id_rsa, kept from the git-hunk-apply list: an SSH key copied + // into a build directory as `backup-id_rsa` is still an SSH key. Dropping + // it in favour of the anchored form above would have narrowed a guard that + // already shipped, so the union keeps both. + if (p.contains(QStringLiteral("id_rsa"))) return true; + + // The dotenv convention also covers .env (app.env, prod.env) and + // *.env. (app.env.bak), which the anchored "/.env" match misses. + if (p.endsWith(QStringLiteral(".env")) || + p.contains(QStringLiteral(".env."))) + return true; + + return false; +} + +} // namespace PathDenylist diff --git a/src/path_denylist.h b/src/path_denylist.h new file mode 100644 index 0000000..0bf4624 --- /dev/null +++ b/src/path_denylist.h @@ -0,0 +1,25 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +#pragma once + +#include + +// Single source of truth for "this path holds credentials — never read it, +// never write it, never quote its contents back to a model". +// +// This list existed in TWO places and they had drifted apart, each covering +// holes the other left open: ai_tools.cpp knew about *.tfvars / *.tfstate / +// .pypirc and Windows backslash separators; git_hunk_apply.cpp knew about +// id_rsa and *.jks. Neither knew everything, and search_project consulted +// neither. What lives here is the UNION, checked by every caller. +// +// QtCore only, no state, no allocation beyond the compare — safe to call from +// a QtConcurrent worker thread (search_project's filesystem leg does). +namespace PathDenylist { + +// True when `absPath` looks like a secret store: an SSH/GPG/AWS/Docker +// credential directory, a private-key or Terraform state extension, a netrc / +// npmrc / pypirc, or a system password file. Matched case-insensitively on +// path SEGMENTS so a project directory legitimately named "ssh" is not caught. +bool isSecretPath(const QString &absPath); + +} // namespace PathDenylist diff --git a/test_ai_fullscreen_exit.cpp b/test_ai_fullscreen_exit.cpp index db732cc..9fd9379 100644 --- a/test_ai_fullscreen_exit.cpp +++ b/test_ai_fullscreen_exit.cpp @@ -610,6 +610,93 @@ int main(int argc, char *argv[]) { reopenedW > 50); } + + // ─────────────────────────────────────────────────────────────── + // SCENARIO 18: the AI's project root is STABLE across tab switches. + // + // The AI root used to be recomputed per call as "explorer folder, else + // the CURRENT tab's directory". With no folder open that flapped on every + // Ctrl+Tab: the chat history is keyed on the root, so switching tabs + // silently swapped the conversation out from under the user and cancelled + // any pending write approval. aiWorkspaceRoot() latches once per session + // and only an EXPLICIT folder re-keys it. + // ─────────────────────────────────────────────────────────────── + std::printf("\n-- Scenario 18: AI workspace root stable across tab switches --\n"); + { + QTemporaryDir otherDir; + FileExplorer *explorer = mw.findChild(); + EXPECT_TRUE("S18 setup: FileExplorer located", explorer != nullptr); + EXPECT_TRUE("S18 setup: second temp dir valid", otherDir.isValid()); + + if (explorer && otherDir.isValid()) { + // Vacuity guard. Every "stayed the same" assertion below is + // meaningless if a folder is already open (then the root is + // pinned for the boring reason) or if the root is empty (then + // it trivially never changes). Both must be false to proceed. + EXPECT_TRUE("S18 guard: no explicit workspace folder is open", + mw.workspaceFolder().isEmpty()); + + const QString firstRoot = mw.aiWorkspaceRoot(); + EXPECT_TRUE("S18: root latched to the open file's directory", + firstRoot == workDir.path()); + EXPECT_FALSE("S18 guard: latched root is not empty", + firstRoot.isEmpty()); + + // Open a file living in a DIFFERENT directory and focus it. + const QString fileD = + writeTempFile(otherDir.path(), "d.txt", "delta\n"); + EXPECT_FALSE("S18 setup: wrote d.txt in the other dir", + fileD.isEmpty()); + mw.openFile(fileD); + QApplication::processEvents(); + + // Vacuity guard #2: prove the switch actually LANDED on the new + // directory. Without this, "root unchanged" would pass even if + // openFile had silently failed. + EXPECT_TRUE("S18 guard: current tab is now the other dir's file", + mw.suggestedDialogFolder() == otherDir.path()); + EXPECT_TRUE("S18: AI root UNCHANGED after opening a file " + "elsewhere", + mw.aiWorkspaceRoot() == firstRoot); + + // ...and unchanged again after a plain Ctrl+Tab-style switch. + // + // The switch must END on the OTHER directory's tab. Switching away + // to a workDir tab and asserting there is vacuous: the pre-fix + // recompute-per-call also returns workDir from a workDir tab, so + // the assertion passed against the very bug it exists to catch. + const int otherTab = tabs->currentIndex(); + int homeTab = -1; + for (int i = 0; i < tabs->count(); ++i) { + if (i == otherTab) continue; + tabs->setCurrentIndex(i); + QApplication::processEvents(); + if (mw.suggestedDialogFolder() == workDir.path()) { + homeTab = i; + break; + } + } + EXPECT_TRUE("S18 guard: switched away to a workDir tab", + homeTab >= 0); + tabs->setCurrentIndex(otherTab); // Ctrl+Tab back + QApplication::processEvents(); + EXPECT_TRUE("S18 guard: landed back on the other dir's tab", + tabs->currentIndex() == otherTab + && mw.suggestedDialogFolder() == otherDir.path()); + EXPECT_TRUE("S18: AI root UNCHANGED after a tab switch", + mw.aiWorkspaceRoot() == firstRoot); + + // An EXPLICIT folder is the one thing that may re-key it — that + // is the user saying "this is my project now". + explorer->setRoot(otherDir.path()); + QApplication::processEvents(); + EXPECT_TRUE("S18 guard: explorer now reports an explicit folder", + explorer->workspaceRoot() == otherDir.path()); + EXPECT_TRUE("S18: opening a folder DOES re-latch the AI root", + mw.aiWorkspaceRoot() == otherDir.path()); + } + } + // ─────────────────────────────────────────────────────────────── std::printf("\n=== test_ai_fullscreen_exit: %d passed, %d failed ===\n", g_passed, g_failed); diff --git a/test_mcp_bridge.cpp b/test_mcp_bridge.cpp index ee9b728..3ba3673 100644 --- a/test_mcp_bridge.cpp +++ b/test_mcp_bridge.cpp @@ -162,11 +162,21 @@ class TestMcpBridge : public QObject { m_currentIndex = m_fakeTabs.size() - 1; return m_currentIndex; }; - h.gotoLine = [this](int idx, int line) { - if (idx < 0 || idx >= m_fakeTabs.size() || line < 1) return false; + // Models the REAL Editor::gotoLine contract: clamp to the buffer and + // return the line actually landed on (-1 = could not target the tab). + // + // This stub used to return bool. Under the int-returning host hook that + // still COMPILES — true converts to 1 — so it would have reported + // "landed on line 1" for every successful jump and quietly asserted + // nothing about clamping. A stub that lies is worse than no stub. + h.gotoLine = [this](int idx, int line) -> int { + if (idx < 0 || idx >= m_fakeTabs.size() || line < 1) return -1; + const int total = + qMax(1, m_fakeTabs[idx].text.count(QLatin1Char('\n')) + 1); + const int landed = qBound(1, line, total); m_currentIndex = idx; - m_lastGotoLine = line; - return true; + m_lastGotoLine = landed; + return landed; }; // v0.1.121 (issue #5): flatten the 1-based range to char offsets, set // m_selection to the spanned text so a following replace_selection @@ -1079,6 +1089,199 @@ private slots: QCOMPARE(r.value(QLatin1String("truncated")).toBool(), true); } + + // Scope is a property of the WORKSPACE, never of match luck. + // + // The old guard was `if (root.isEmpty() && hits.isEmpty()) error`. So with + // no folder open, a query that missed the open tabs produced a clear + // "open a folder first" error, while a query that happened to hit one + // produced `{results:[...],truncated:false}` — byte-identical in shape to a + // completed project-wide search. A caller could not distinguish "searched + // your project, found 2" from "searched 2 buffers, never touched disk", + // and would report the second as if it were the first. + // + // Every response now states which legs actually ran. + void search_project_reports_scope_regardless_of_hits() { + QLocalSocket s; + QVERIFY(connectClient(s)); + readGreeting(s); + QVERIFY2(m_root.isEmpty(), "fixture must start with no workspace"); + + // (a) No workspace, query HITS the open tabs → success, but the + // response must admit the workspace leg never ran. + QJsonObject hitArgs; + hitArgs[QStringLiteral("query")] = QStringLiteral("needle"); + const QJsonObject hit = call(s, 60, QStringLiteral("search_project"), + hitArgs); + QVERIFY2(hit.value(QLatin1String("ok")).toBool(), + "an open-tab hit is still a valid result"); + const QJsonObject hr = hit.value(QLatin1String("result")).toObject(); + QVERIFY(hr.value(QLatin1String("results")).toArray().size() > 0); + QCOMPARE(hr.value(QLatin1String("workspace_searched")).toBool(), false); + QCOMPARE(hr.value(QLatin1String("scope")).toString(), + QStringLiteral("open_tabs_only")); + + // (b) No workspace, query misses everything → an actionable error, + // not a silent empty list. + QJsonObject missArgs; + missArgs[QStringLiteral("query")] = + QStringLiteral("zzz-no-such-token-zzz"); + const QJsonObject miss = call(s, 61, QStringLiteral("search_project"), + missArgs); + QCOMPARE(miss.value(QLatin1String("ok")).toBool(), false); + QVERIFY2(miss.value(QLatin1String("error")).toString().contains( + QStringLiteral("No workspace folder")), + "the no-workspace error must say what to do about it"); + } + + // A workspace folder that has been deleted or renamed under us is NOT the + // same as no workspace, and neither one may masquerade as a real search. + // This case used to return `{results:[],truncated:false}` with no error at + // all: an assistant reading that concluded the project contained no match. + void search_project_survives_a_deleted_workspace_root() { + QString gone; + { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + gone = dir.path(); + } // dir removed here + QVERIFY2(!QFileInfo(gone).isDir(), "temp dir must really be gone"); + m_root = gone; + + QLocalSocket s; + QVERIFY(connectClient(s)); + readGreeting(s); + + // With tab hits: succeeds, but must report the reduced scope. + QJsonObject hitArgs; + hitArgs[QStringLiteral("query")] = QStringLiteral("needle"); + const QJsonObject hr = call(s, 62, QStringLiteral("search_project"), + hitArgs) + .value(QLatin1String("result")).toObject(); + QCOMPARE(hr.value(QLatin1String("workspace_searched")).toBool(), false); + QCOMPARE(hr.value(QLatin1String("scope")).toString(), + QStringLiteral("open_tabs_only")); + + // Without tab hits: an error naming the real cause, distinct from the + // "no workspace is open" wording so the two are not confusable. + QJsonObject missArgs; + missArgs[QStringLiteral("query")] = + QStringLiteral("zzz-no-such-token-zzz"); + const QString err = call(s, 63, QStringLiteral("search_project"), + missArgs) + .value(QLatin1String("error")).toString(); + QVERIFY2(err.contains(QStringLiteral("no longer exists")), + qPrintable(QStringLiteral("wrong error for a vanished root: ") + + err)); + } + + // The happy path must claim the workspace leg — otherwise the two fields + // above would be constant-false and prove nothing. + void search_project_claims_the_workspace_when_it_walked_it() { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + { + QFile f(dir.path() + QStringLiteral("/found.txt")); + QVERIFY(f.open(QIODevice::WriteOnly)); + f.write("nothing\nneedle here\n"); + } + m_root = dir.path(); + QLocalSocket s; + QVERIFY(connectClient(s)); + readGreeting(s); + QJsonObject args; + args[QStringLiteral("query")] = QStringLiteral("needle"); + const QJsonObject r = call(s, 64, QStringLiteral("search_project"), + args) + .value(QLatin1String("result")).toObject(); + QCOMPARE(r.value(QLatin1String("workspace_searched")).toBool(), true); + QCOMPARE(r.value(QLatin1String("scope")).toString(), + QStringLiteral("tabs_and_workspace")); + } + + + // search_project must honour the SAME credential deny-list as the AI file + // tools. It walked the workspace with no such check, so `read_file` would + // refuse ~/.ssh/id_rsa while a one-word search_project happily returned the + // matching LINES out of it — the deny-list guarded the front door and this + // verb held the back one open. + void search_project_never_returns_credential_file_contents() { + QTemporaryDir dir; + QVERIFY(dir.isValid()); + const QStringList secrets = { + QStringLiteral("id_rsa"), QStringLiteral("server.pem"), + QStringLiteral(".env"), QStringLiteral("prod.tfvars"), + QStringLiteral("keystore.jks"), + }; + for (const QString &name : secrets) { + QFile f(dir.path() + QLatin1Char('/') + name); + QVERIFY(f.open(QIODevice::WriteOnly)); + f.write("PASSWORD=needle-in-a-secret\n"); + } + // Vacuity guard: an ordinary file with the SAME token must still be + // found, otherwise "no secret hits" could just mean the scan never ran. + { + QFile f(dir.path() + QStringLiteral("/readme.txt")); + QVERIFY(f.open(QIODevice::WriteOnly)); + f.write("harmless needle-in-a-secret mention\n"); + } + m_root = dir.path(); + + QLocalSocket s; + QVERIFY(connectClient(s)); + readGreeting(s); + QJsonObject args; + args[QStringLiteral("query")] = QStringLiteral("needle-in-a-secret"); + const QJsonObject r = call(s, 65, QStringLiteral("search_project"), + args) + .value(QLatin1String("result")).toObject(); + const QJsonArray results = r.value(QLatin1String("results")).toArray(); + + QVERIFY2(r.value(QLatin1String("workspace_searched")).toBool(), + "guard: the workspace leg must actually have run"); + bool sawReadme = false; + for (const QJsonValue &v : results) { + const QJsonObject hit = v.toObject(); + const QString p = hit.value(QLatin1String("path")).toString(); + if (p.endsWith(QLatin1String("readme.txt"))) sawReadme = true; + for (const QString &name : secrets) + QVERIFY2(!p.endsWith(name), + qPrintable(QStringLiteral("leaked contents of ") + + name)); + QVERIFY2(!hit.value(QLatin1String("text")).toString().contains( + QStringLiteral("PASSWORD=")), + "a credential line reached the wire"); + } + QVERIFY2(sawReadme, + "guard: the non-secret file with the same token was missed, " + "so this test proves nothing about the deny-list"); + } + + // `col` without `line` used to be silently DISCARDED — the insert went to + // the cursor instead, behind an approval card reading "at the cursor" that + // was truthful and therefore unremarkable. Silently relocating a write is + // the one thing an approval gate cannot protect the user from. + void insert_text_rejects_col_without_line() { + QLocalSocket s; + QVERIFY(connectClient(s)); + readGreeting(s); + QJsonObject args; + args[QStringLiteral("tab_index")] = 0; + args[QStringLiteral("text")] = QStringLiteral("MARKER-zq7"); + args[QStringLiteral("col")] = 3; // no "line" + const QJsonObject resp = call(s, 66, QStringLiteral("insert_text"), + args); + QCOMPARE(resp.value(QLatin1String("ok")).toBool(), false); + QVERIFY2(resp.value(QLatin1String("error")).toString().contains( + QStringLiteral("col requires line")), + qPrintable(QStringLiteral("wrong error: ") + + resp.value(QLatin1String("error")).toString())); + // ...and it must be rejected BEFORE an approval card is raised, so the + // user is never asked to approve a write that cannot be honoured. + QCOMPARE(m_fakeTabs[0].text.contains(QStringLiteral("MARKER-zq7")), + false); + } + // ── v0.1.118 expansive wave ────────────────────────────────────── void get_status_shape() { @@ -1294,6 +1497,47 @@ private slots: QCOMPARE(bad.value(QLatin1String("ok")).toBool(), false); } + // A line past end-of-file must CLAMP and say so — never claim the requested + // line. Unclamped, Scintilla rejects the position and leaves the cursor at + // the TOP, while the response still read {"line":99999,"ok":true}. Because + // insert_text defaults to the cursor, an assistant aiming at the end of a + // file wrote at the beginning, behind an approval card that looked correct. + void goto_line_past_eof_clamps_and_reports_truth() { + QLocalSocket s; + QVERIFY(connectClient(s)); + readGreeting(s); + + // Establish ground truth from the fake buffer rather than a literal, so + // the test cannot drift if the fixture text changes. + const int total = + qMax(1, m_fakeTabs[1].text.count(QLatin1Char('\n')) + 1); + + QJsonObject args; + args[QStringLiteral("line")] = 99999; + args[QStringLiteral("tab_index")] = 1; + QJsonObject resp = call(s, 37, QStringLiteral("goto_line"), args); + QVERIFY(resp.value(QLatin1String("ok")).toBool()); + const QJsonObject r = resp.value(QLatin1String("result")).toObject(); + + // The cursor really moved to the last line, not to line 1. + QCOMPARE(m_lastGotoLine, total); + // ...and the response reports where it LANDED, not what was asked. + QCOMPARE(r.value(QLatin1String("line")).toInt(), total); + QVERIFY2(r.value(QLatin1String("line")).toInt() != 99999, + "goto_line echoed the requested line instead of the real one"); + QCOMPARE(r.value(QLatin1String("requested_line")).toInt(), 99999); + QCOMPARE(r.value(QLatin1String("clamped")).toBool(), true); + + // An in-range jump must NOT be flagged as clamped. + QJsonObject ok2; + ok2[QStringLiteral("line")] = 1; + ok2[QStringLiteral("tab_index")] = 1; + const QJsonObject r2 = call(s, 38, QStringLiteral("goto_line"), ok2) + .value(QLatin1String("result")).toObject(); + QCOMPARE(r2.value(QLatin1String("line")).toInt(), 1); + QCOMPARE(r2.value(QLatin1String("clamped")).toBool(), false); + } + // v0.1.121 (issue #5): select_range moves the selection (ACT, no card) and // a following replace_selection acts on exactly that span. void select_range_then_replace() { diff --git a/test_workspace_root.cpp b/test_workspace_root.cpp new file mode 100644 index 0000000..d208c89 --- /dev/null +++ b/test_workspace_root.cpp @@ -0,0 +1,148 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// +// FileExplorer draws a folder tree AND answers "what is the user working on". +// Those are different questions, and answering the second with the first caused +// a privacy bug: search_project over MCP treated the tree's display folder as a +// workspace. With no folder open that folder is $HOME, so a one-word search +// walked the user's entire profile and returned line-level content from files +// they had never opened in the editor — in the report that found this, that +// included the transcript of the session driving the tool. +// +// The same single default silently disarmed three other things that were all +// written to test `rootPath().isEmpty()` and therefore could never fire: +// * the Coding Mode "open a folder" prompt, +// * the AI CSV sandbox root (a SECURITY guard — it was rooted at $HOME +// instead of being closed), +// * the Find-in-Files default folder, whose own comment says walking $HOME +// is "~always wrong". +// +// So the invariant under test is narrow and load-bearing: workspaceRoot() is +// empty until the user actually opens a folder, whatever the tree is showing. + +#include "fileexplorer.h" + +#include +#include +#include +#include +#include +#include + +#include + +static int g_pass = 0, g_fail = 0; +#define EXPECT(label, cond) \ + do { \ + if (cond) { ++g_pass; std::printf(" [PASS] %s\n", label); } \ + else { ++g_fail; std::printf(" [FAIL] %s\n", label); } \ + } while (0) + +int main(int argc, char *argv[]) { + // Isolate config/data writes: QStandardPaths must never touch the real + // profile from a test run. + QTemporaryDir home; + qputenv("XDG_CONFIG_HOME", home.path().toUtf8()); + qputenv("XDG_DATA_HOME", home.path().toUtf8()); + qputenv("QT_QPA_PLATFORM", "offscreen"); + + QApplication app(argc, argv); + + QTemporaryDir workspace; + if (!workspace.isValid()) { + std::printf(" [FAIL] could not create a temp workspace\n"); + return 1; + } + + // ── 1. Fresh explorer: showing something, scoping nothing ───────────── + { + FileExplorer ex; + + // Vacuity guard. If rootPath() were empty the workspaceRoot() assertion + // below would pass for the wrong reason and this test would be worth + // nothing — so prove the display root really is populated first. + EXPECT("precondition: a fresh explorer HAS a display root", + !ex.rootPath().isEmpty()); + EXPECT("precondition: that display root is the home directory", + QDir(ex.rootPath()).absolutePath() == + QDir(QDir::homePath()).absolutePath()); + + // The actual regression guard. + EXPECT("a fresh explorer scopes NO workspace", + ex.workspaceRoot().isEmpty()); + } + + // ── 2. After the user opens a folder, it IS the workspace ───────────── + { + FileExplorer ex; + ex.setRoot(workspace.path()); + + EXPECT("opening a folder sets the workspace root", + !ex.workspaceRoot().isEmpty()); + EXPECT("the workspace root is the folder that was opened", + QDir(ex.workspaceRoot()).absolutePath() == + QDir(workspace.path()).absolutePath()); + EXPECT("display root and workspace root now agree", + QDir(ex.rootPath()).absolutePath() == + QDir(ex.workspaceRoot()).absolutePath()); + } + + // ── 3. A rejected setRoot must not fabricate a workspace ────────────── + // + // setRoot() ignores anything that is not a directory. It must also leave + // the workspace unset, rather than marking it explicit while the path + // silently stays at $HOME — that would reintroduce the original bug + // through a different door. + { + FileExplorer ex; + ex.setRoot(workspace.path() + QStringLiteral("/does-not-exist")); + EXPECT("a non-existent path does not become the workspace", + ex.workspaceRoot().isEmpty()); + + const QString file = workspace.path() + QStringLiteral("/a-file.txt"); + QFile f(file); + if (f.open(QIODevice::WriteOnly)) { f.write("x"); f.close(); } + ex.setRoot(file); + EXPECT("a FILE does not become the workspace", + ex.workspaceRoot().isEmpty()); + } + + // ── 4. Typing must NOT latch a workspace ────────────────────────────── + // + // The path box is an editable QComboBox. Latching on currentTextChanged + // meant every KEYSTROKE committed a workspace: typing a path that passes + // through "/" made "/" the user's explicit workspace, permanently, which + // then anchors the AI file sandbox at the filesystem root and hands + // search_project the whole disk. Only a committed choice may latch. + { + FileExplorer ex; + auto *combo = ex.findChild(); + EXPECT("precondition: the path box is an editable combo", + combo != nullptr && combo->isEditable()); + if (combo && combo->lineEdit()) { + // Clear first: the box is pre-filled with the home path, so typing + // straight in appends and yields a path that does not exist — which + // would make the "does not latch" assertion pass for the wrong + // reason and prove nothing. + combo->lineEdit()->clear(); + combo->lineEdit()->setFocus(); + + // Type a REAL directory one character at a time. Every intermediate + // prefix that happens to be a directory (notably the leading "/") + // used to latch immediately. + QTest::keyClicks(combo->lineEdit(), workspace.path()); + EXPECT("precondition: the typed text really is an existing dir", + QFileInfo(combo->lineEdit()->text()).isDir()); + EXPECT("typing alone does not latch a workspace", + ex.workspaceRoot().isEmpty()); + + // Committing with Enter is a deliberate act, so it may latch. + QTest::keyClick(combo->lineEdit(), Qt::Key_Return); + EXPECT("pressing Enter DOES commit the typed folder", + QDir(ex.workspaceRoot()).absolutePath() == + QDir(workspace.path()).absolutePath()); + } + } + + std::printf("\n%d passed, %d failed\n", g_pass, g_fail); + return g_fail == 0 ? 0 : 1; +}