diff --git a/backend/cortex_backend/core/paths.py b/backend/cortex_backend/core/paths.py index 15f088a..6835626 100644 --- a/backend/cortex_backend/core/paths.py +++ b/backend/cortex_backend/core/paths.py @@ -12,6 +12,8 @@ import sys from pathlib import PureWindowsPath +from .win_paths import system32 + ORGANIZATION_NAME = "ChatLLM" APPLICATION_NAME = "ChatLLM-Assistant" @@ -97,7 +99,7 @@ def secure_private_path(path: str | os.PathLike[str], *, directory: bool) -> Pat rights = "(OI)(CI)F" if directory else "F" result = subprocess.run( [ - "icacls", + system32("icacls.exe"), str(target), "/inheritance:r", "/grant:r", diff --git a/backend/cortex_backend/core/win_paths.py b/backend/cortex_backend/core/win_paths.py new file mode 100644 index 0000000..29b49cc --- /dev/null +++ b/backend/cortex_backend/core/win_paths.py @@ -0,0 +1,76 @@ +"""Absolute paths for the Windows tools Cortex runs. + +Cortex starts a few of Windows' own executables: PowerShell (to check the +bundled WebView2 installer's signature before running it), ``icacls`` (to make +its data folders private) and ``taskkill`` (to end a development server's +process tree). Started by bare name they are whatever comes first on ``PATH``, +which the user, or anything they install, can change. No privilege boundary is +crossed either way, but the signature check is the gate before an installer +runs and should not depend on that, so these are resolved under the Windows +system directory instead. +""" + +from __future__ import annotations + +import ctypes +import os +from pathlib import Path +import sys + + +# Tools that live in a folder of their own under System32. +_SUBDIRECTORIES = { + "powershell.exe": ("WindowsPowerShell", "v1.0"), +} +_MAX_PATH = 260 + + +def _system_directory_from_api() -> Path | None: + """``GetSystemDirectoryW``: the real System32, whatever the environment says.""" + try: + buffer = ctypes.create_unicode_buffer(_MAX_PATH) + length = ctypes.windll.kernel32.GetSystemDirectoryW(buffer, _MAX_PATH) + except (AttributeError, OSError): + return None + if not 0 < length < _MAX_PATH: + return None + return Path(buffer.value) + + +def system_directory() -> Path | None: + """The Windows system directory (normally ``C:\\Windows\\System32``). + + Asked of Windows first, because ``%SystemRoot%`` is an environment variable + like ``PATH``; the variable (or ``%windir%``) is only the fallback. ``None`` + off Windows, or when neither answers with an absolute path. + """ + if sys.platform != "win32": + return None + directory = _system_directory_from_api() + if directory is not None and directory.is_absolute(): + return directory + for variable in ("SystemRoot", "windir"): + root = os.environ.get(variable, "").strip() + if root and Path(root).is_absolute(): + return Path(root) / "System32" + return None + + +def system32(name: str) -> str: + """The absolute path of the Windows tool ``name`` (for example ``"icacls.exe"``). + + PowerShell is found in its ``WindowsPowerShell\\v1.0`` folder. When the + absolute path does not exist (off Windows, or an unusual installation) the + bare ``name`` comes back, so the tool is still found the way it always was + rather than not at all. + """ + directory = system_directory() + if directory is None: + return name + candidate = directory.joinpath(*_SUBDIRECTORIES.get(name.lower(), ()), name) + try: + if candidate.is_file(): + return str(candidate) + except OSError: + pass + return name diff --git a/backend/cortex_backend/launcher/__init__.py b/backend/cortex_backend/launcher/__init__.py index 30af0cf..7882567 100644 --- a/backend/cortex_backend/launcher/__init__.py +++ b/backend/cortex_backend/launcher/__init__.py @@ -12,7 +12,7 @@ show_startup_failure, ) from .frontend import FrontendBuildError, ensure_frontend -from .instance import InstanceLock, InstanceRecord +from .instance import InstanceLock, InstanceLockError, InstanceRecord from .webview_runtime import ( WEBVIEW2_DOWNLOAD_URL, WebViewInstallDeclined, @@ -28,6 +28,7 @@ "DesktopWindowError", "FrontendBuildError", "InstanceLock", + "InstanceLockError", "InstanceRecord", "WebViewInstallDeclined", "WebViewRuntimeError", diff --git a/backend/cortex_backend/launcher/instance.py b/backend/cortex_backend/launcher/instance.py index 1d31c2e..7a8b07d 100644 --- a/backend/cortex_backend/launcher/instance.py +++ b/backend/cortex_backend/launcher/instance.py @@ -4,6 +4,7 @@ from dataclasses import asdict, dataclass from datetime import datetime, timezone +import errno import json import os from pathlib import Path @@ -27,6 +28,28 @@ fcntl = None +# What a non-blocking lock attempt reports when another handle holds the lock: +# msvcrt's LK_NBLCK and a read or write of the locked byte give EACCES (and +# LK_LOCK EDEADLOCK once it gives up); flock gives EWOULDBLOCK/EAGAIN. +_CONTENDED_ERRNOS = frozenset( + {errno.EACCES, errno.EAGAIN, errno.EWOULDBLOCK, errno.EDEADLK} +) + + +class InstanceLockError(RuntimeError): + """The instance lock could not be set up at all. + + Distinct from contention -- another Cortex holding the lock, which is what + ``InstanceLock.acquire`` returning ``None`` means. The message is fixed + text naming the step that failed (and the class of the OS error), never a + path or a secret, so it can go into the startup log as it stands. + """ + + +def _contended(error: OSError) -> bool: + return error.errno in _CONTENDED_ERRNOS + + @dataclass(frozen=True, slots=True) class InstanceRecord: pid: int @@ -65,14 +88,26 @@ def __init__(self, profile_dir: str | Path): self._record: InstanceRecord | None = None def acquire(self, *, port: int) -> InstanceRecord | None: + """Take the lock and publish this instance's record and handoff secret. + + Returns ``None`` only when another process holds the lock. Anything + else that stops the lock being taken -- a profile folder that cannot be + created or secured, a lock file that cannot be opened, a secret or + record that cannot be written -- raises ``InstanceLockError``: treating + those as contention sent the launch looking for a running instance + that did not exist, and the person saw a dialog about a log nobody had + tried to write. + """ try: self.profile_dir.mkdir(parents=True, exist_ok=True) secure_private_path(self.profile_dir, directory=True) secret_path = AppPaths.from_data_dir(self.secret_path).data_dir - if secret_path != self.secret_path: - return None - except (OSError, AppPathError): - return None + except (OSError, AppPathError) as exc: + raise InstanceLockError( + f"Cortex could not prepare its instance folder ({type(exc).__name__})." + ) from exc + if secret_path != self.secret_path: + raise InstanceLockError("Cortex's instance secret path does not resolve to itself.") try: # Do not use append mode here: every marker write would advance to # EOF and make this persistent lock grow once per launch. Opening @@ -82,35 +117,46 @@ def acquire(self, *, port: int) -> InstanceRecord | None: os.O_RDWR | os.O_CREAT, 0o600, ) - handle = os.fdopen(descriptor, "r+b") - except OSError: - return None + except OSError as exc: + raise InstanceLockError( + f"Cortex could not open its instance lock file ({type(exc).__name__})." + ) from exc try: # msvcrt requires a byte to exist before locking the range. Keep # normalization before locking: truncating a Windows file while a - # byte-range lock is held can invalidate that lock. - handle.seek(0) - if handle.read(1) == b"": - handle.seek(0) - handle.write(b"0") - handle.flush() - else: - handle.seek(0, os.SEEK_END) - if handle.tell() != 1: - handle.truncate(1) - handle.flush() - handle.seek(0) - except OSError: - handle.close() - return None + # byte-range lock is held can invalidate that lock. The size comes + # from fstat rather than a read, because reading a byte another + # process has locked fails, and the writes go straight to the + # descriptor so a failed one leaves nothing buffered to retry on + # close. A write that fails because the byte is locked is a + # contender that got there first. + size = os.fstat(descriptor).st_size + if size == 0: + os.lseek(descriptor, 0, os.SEEK_SET) + os.write(descriptor, b"0") + elif size != 1: + os.ftruncate(descriptor, 1) + os.lseek(descriptor, 0, os.SEEK_SET) + except OSError as exc: + os.close(descriptor) + if _contended(exc): + return None + raise InstanceLockError( + f"Cortex could not prepare its instance lock file ({type(exc).__name__})." + ) from exc + handle = os.fdopen(descriptor, "r+b") try: if msvcrt is not None: msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1) elif fcntl is not None: fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB) - except OSError: + except OSError as exc: handle.close() - return None + if _contended(exc): + return None + raise InstanceLockError( + f"Cortex could not lock its instance lock file ({type(exc).__name__})." + ) from exc try: secret = secrets.token_urlsafe(32) @@ -128,13 +174,15 @@ def acquire(self, *, port: int) -> InstanceRecord | None: json.dumps(record.as_dict(), indent=2), encoding="utf-8" ) os.replace(temporary, self.record_path) - except (OSError, AppPathError): + except (OSError, AppPathError) as exc: handle.close() try: self.secret_path.unlink() - except FileNotFoundError: + except OSError: pass - return None + raise InstanceLockError( + f"Cortex could not publish its instance record ({type(exc).__name__})." + ) from exc self._handle = handle self._record = record return record diff --git a/backend/cortex_backend/launcher/supervisor.py b/backend/cortex_backend/launcher/supervisor.py index 52ca877..78603a2 100644 --- a/backend/cortex_backend/launcher/supervisor.py +++ b/backend/cortex_backend/launcher/supervisor.py @@ -16,6 +16,7 @@ from urllib.request import ProxyHandler, Request, build_opener from cortex_backend.core.win_jobs import JobObjectError, KillOnCloseJob +from cortex_backend.core.win_paths import system32 DEV_SERVER_ID_HEADER = "X-Cortex-Dev-Server" @@ -201,7 +202,7 @@ def _kill_tree(self) -> None: return if os.name == "nt": subprocess.run( - ["taskkill", "/PID", str(self.process.pid), "/T", "/F"], + [system32("taskkill.exe"), "/PID", str(self.process.pid), "/T", "/F"], capture_output=True, check=False, ) diff --git a/backend/cortex_backend/launcher/webview_runtime.py b/backend/cortex_backend/launcher/webview_runtime.py index 0ec54ea..d78059f 100644 --- a/backend/cortex_backend/launcher/webview_runtime.py +++ b/backend/cortex_backend/launcher/webview_runtime.py @@ -16,6 +16,8 @@ import subprocess import sys +from cortex_backend.core.win_paths import system32 + WEBVIEW2_CLIENT_ID = "{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}" WEBVIEW2_BOOTSTRAPPER = "MicrosoftEdgeWebview2Setup.exe" @@ -113,7 +115,8 @@ def _verify_microsoft_signature(bootstrapper: Path) -> None: try: result = subprocess.run( [ - "powershell.exe", + # Windows' own PowerShell, not whichever comes first on PATH. + system32("powershell.exe"), "-NoProfile", "-NonInteractive", "-ExecutionPolicy", diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index f058985..78d6702 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -37,7 +37,7 @@ async function openWorkspace(page: Page, path: string) { await page.route("**/api/v1/models", async (route) => { await route.fulfill({ json: { required_models: [], optional_models: [], installed_models: ["local-chat:7b"], missing_models: [], optional_missing_models: [], models: [{ name: "local-chat:7b" }], connection: { success: true, status: "connected", message: "Connected to local runtime." } } }); }); - await page.goto(`${path}?bootstrap=launcher-token`); + await page.goto(`${path}#bootstrap=launcher-token`); } /** Move focus onto `target` the way a keyboard does, so :focus-visible applies to buttons too. */ diff --git a/frontend/e2e/attachments.spec.ts b/frontend/e2e/attachments.spec.ts index c758c06..da4e6c4 100644 --- a/frontend/e2e/attachments.spec.ts +++ b/frontend/e2e/attachments.spec.ts @@ -57,7 +57,7 @@ async function stubWorkspace(page: Page) { test("a file dropped on the window is kept out of the navigation path, and one dropped on the composer is attached", async ({ page }) => { const { staged } = await stubWorkspace(page); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); await expect(page.getByLabel("Message Cortex")).toBeVisible(); const url = page.url(); @@ -90,7 +90,7 @@ test("a file dropped on the window is kept out of the navigation path, and one d test("pasting a screenshot attaches it and pasting text does not", async ({ page }) => { const { staged } = await stubWorkspace(page); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); const composer = page.getByLabel("Message Cortex"); await composer.focus(); @@ -114,7 +114,7 @@ test("pasting a screenshot attaches it and pasting text does not", async ({ page test("settings asks before edits are lost and keeps them when told to keep editing", async ({ page }) => { await stubWorkspace(page); - await page.goto("/settings?bootstrap=launcher-token"); + await page.goto("/settings#bootstrap=launcher-token"); await expect(page.getByRole("heading", { name: "Settings" })).toBeVisible(); await expect(page.getByRole("button", { name: "Save settings" })).toBeDisabled(); diff --git a/frontend/e2e/chat-groups.spec.ts b/frontend/e2e/chat-groups.spec.ts index 5cee983..c9adb0e 100644 --- a/frontend/e2e/chat-groups.spec.ts +++ b/frontend/e2e/chat-groups.spec.ts @@ -57,7 +57,7 @@ test("loads chat groups successfully and files their chats", async ({ page }) => updated_at: "2026-01-01T00:00:00Z", }]); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); await expect(page.getByRole("button", { name: "Collapse Research" })).toBeVisible(); await expect(page.getByRole("button", { name: "Grouped chat", exact: true })).toBeVisible(); await expect(page.getByText("Research")).toBeVisible(); @@ -85,7 +85,7 @@ test("does not let a late initial group response erase a group created while loa } }); }); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); await expect(page.getByLabel("Message Cortex")).toBeVisible(); await page.getByRole("button", { name: "New group" }).click(); await page.getByRole("textbox", { name: "Group name" }).fill("Created while loading"); diff --git a/frontend/e2e/chat.spec.ts b/frontend/e2e/chat.spec.ts index dee0945..4ac4742 100644 --- a/frontend/e2e/chat.spec.ts +++ b/frontend/e2e/chat.spec.ts @@ -40,7 +40,7 @@ test("completes a streamed new-chat parity flow", async ({ page }) => { await route.fulfill({ status: 200, contentType: "text/event-stream", body: events }); }); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); await expect(page.getByLabel("Message Cortex")).toBeVisible(); await expect(page.getByText("What will you make today?")).toHaveCount(0); await expect(page.getByText("LOCAL, PRIVATE, YOURS")).toHaveCount(0); @@ -113,7 +113,7 @@ test("supports retry, regenerate, and fork without losing the persisted thread", await route.fulfill({ status: 200, contentType: "text/event-stream", body: event }); }); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); await expect(page.getByLabel("Message Cortex")).toBeVisible(); await page.getByLabel("Message Cortex").fill("fail"); await page.getByRole("button", { name: "Send message" }).click(); @@ -165,7 +165,7 @@ test("manages settings, permanent memory, and model pull progress", async ({ pag await route.fulfill({ status: 200, contentType: "text/event-stream", body: 'id: 1\nevent: progress\ndata: {"id":1,"job_id":"job-model","kind":"progress","status":"running","phase":"model_pull","data":{"model":"nemotron-3-nano:4b","message":"pulling","percent":50}}\n\nid: 2\nevent: completed\ndata: {"id":2,"job_id":"job-model","kind":"completed","status":"succeeded","data":{}}\n\n' }); }); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); await expect(page.getByRole("heading", { name: "New thread" })).toBeVisible(); await page.getByRole("button", { name: "Select a local model" }).click(); await page.getByRole("option", { name: /local-chat:7b/i }).click(); @@ -251,7 +251,7 @@ test("renders a structured workbench transcript with markdown and code blocks", await route.fulfill({ json: { required_models: [], optional_models: [], installed_models: ["local-chat:7b"], missing_models: [], optional_missing_models: [], models: [{ name: "local-chat:7b" }], connection: { success: true, status: "connected", message: "Connected to local runtime." } } }); }); - await page.goto("/chat/rich-thread?bootstrap=launcher-token"); + await page.goto("/chat/rich-thread#bootstrap=launcher-token"); await expect(page.getByText("The local calculation is complete.")).toBeVisible(); // The speaker is carried by form and by each article's accessible name, not // by a repeated visible nameplate above every turn. diff --git a/frontend/e2e/command-palette.spec.ts b/frontend/e2e/command-palette.spec.ts index 0e91531..f26689a 100644 --- a/frontend/e2e/command-palette.spec.ts +++ b/frontend/e2e/command-palette.spec.ts @@ -26,7 +26,7 @@ test("opens the command palette with Ctrl+K, filters, and navigates to settings" await route.fulfill({ json: { required_models: [], optional_models: [], installed_models: ["local-chat:7b"], missing_models: [], optional_missing_models: [], models: [{ name: "local-chat:7b" }], connection: { success: true, status: "connected", message: "Connected to local runtime." } } }); }); - await page.goto("/chat/thread-a?bootstrap=launcher-token"); + await page.goto("/chat/thread-a#bootstrap=launcher-token"); await expect(page.getByLabel("Message Cortex")).toBeVisible(); await page.keyboard.press("Control+k"); @@ -64,7 +64,7 @@ test("the shortcuts help dialog opens on '?' and closes on Escape", async ({ pag await route.fulfill({ json: { required_models: [], optional_models: [], installed_models: ["local-chat:7b"], missing_models: [], optional_missing_models: [], models: [{ name: "local-chat:7b" }], connection: { success: true, status: "connected", message: "Connected to local runtime." } } }); }); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); await expect(page.getByLabel("Message Cortex")).toBeVisible(); // "?" must not fire while focus is inside the composer, so blur it first. diff --git a/frontend/e2e/composer.spec.ts b/frontend/e2e/composer.spec.ts index 4c074eb..4a1f45f 100644 --- a/frontend/e2e/composer.spec.ts +++ b/frontend/e2e/composer.spec.ts @@ -67,7 +67,7 @@ test("keeps the workspace and composer picker usable when the inventory is talle await page.setViewportSize({ width: 800, height: 500 }); await stubWorkspace(page, { models, selectedModel: null }); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); await expect(page.getByRole("heading", { name: "New thread" })).toBeVisible(); await expect(page.getByLabel("Message Cortex")).toBeVisible(); @@ -91,7 +91,7 @@ test("keeps the workspace and composer picker usable when the inventory is talle test("renders chat parameters above the composer surface", async ({ page }) => { await stubWorkspace(page); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); await page.getByRole("button", { name: "Generation parameters for this chat" }).click(); await expect(page.getByRole("dialog", { name: "Generation parameters" })).toBeVisible(); @@ -137,7 +137,7 @@ test("keeps a multiline draft when generation acceptance fails", async ({ page } await route.fulfill({ status: 503, json: { detail: "Local runtime is unavailable." } }); }); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); const composer = page.getByLabel("Message Cortex"); await expect(composer).toBeVisible(); @@ -155,7 +155,7 @@ test("keeps a multiline draft when generation acceptance fails", async ({ page } test("selects and persists a model from the composer picker", async ({ page }) => { await stubWorkspace(page); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); const trigger = page.getByRole("button", { name: "Selected local model: local-chat:7b" }); await expect(trigger).toBeVisible(); @@ -177,7 +177,7 @@ test("selects and persists a model from the composer picker", async ({ page }) = test("keeps composer model options inside a compact viewport", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await stubWorkspace(page); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); const trigger = page.getByRole("button", { name: "Selected local model: local-chat:7b" }); await expect(trigger).toBeVisible(); @@ -225,7 +225,7 @@ test("keeps a next draft available while a response is stopped", async ({ page } }); }); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); const composer = page.getByLabel("Message Cortex"); await composer.fill("Start a response"); await composer.press("Enter"); @@ -254,7 +254,7 @@ test.describe("compact window", () => { // its own on mousedown and lift the toolbar before mouseup, so the click // landed on empty space and nothing opened. await stubWorkspace(page, { models: ["local-chat:7b"] }); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); const trigger = page.getByRole("button", { name: "Generation parameters for this chat" }); const before = await trigger.boundingBox(); @@ -267,7 +267,7 @@ test.describe("compact window", () => { test("keeps the composer usable without horizontal overflow", async ({ page }) => { await stubWorkspace(page, { models: ["local-chat:7b"] }); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); await expect(page.getByRole("button", { name: "Show chat history" })).toBeVisible(); const composer = page.getByLabel("Message Cortex"); @@ -292,7 +292,7 @@ test("clips a long runtime status before the send control", async ({ page }) => connectionSuccess: false, connectionMessage: "Could not connect to Ollama. Please start Ollama and retry this local runtime connection.", }); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); const surface = page.locator(".composer-surface"); const meta = page.locator(".composer-toolbar-trailing"); @@ -332,7 +332,7 @@ test("stages a document as a composer chip and sends only its opaque metadata", await route.fulfill({ status: 503, json: { detail: "Local runtime is unavailable." } }); }); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); await page.getByLabel("Attach images or documents").setInputFiles({ name: "notes.md", mimeType: "text/markdown", @@ -369,7 +369,7 @@ test("explains when the selected model cannot accept an image attachment", async }); }); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); await page.getByLabel("Attach images or documents").setInputFiles({ name: "photo.png", mimeType: "image/png", diff --git a/frontend/e2e/execution.spec.ts b/frontend/e2e/execution.spec.ts index 5252ea0..110c1ad 100644 --- a/frontend/e2e/execution.spec.ts +++ b/frontend/e2e/execution.spec.ts @@ -78,7 +78,7 @@ test("does not replay old completions and groups repeated task notifications", a } }); }); - await page.goto("/?bootstrap=launcher-token"); + await page.goto("/#bootstrap=launcher-token"); await expect(page.getByLabel("Message Cortex")).toBeVisible(); const tray = page.getByRole("complementary", { name: "Task activity" }); diff --git a/frontend/e2e/real-api.spec.ts b/frontend/e2e/real-api.spec.ts index 0c49af3..952ab96 100644 --- a/frontend/e2e/real-api.spec.ts +++ b/frontend/e2e/real-api.spec.ts @@ -78,7 +78,7 @@ async function sessionHeaders(request: APIRequestContext) { async function openWorkspace(page: Page, request: APIRequestContext): Promise { const token = await bootstrapToken(request); - await page.goto(`/?bootstrap=${encodeURIComponent(token)}`); + await page.goto(`/#bootstrap=${encodeURIComponent(token)}`); await expect(page.getByLabel("Message Cortex")).toBeVisible(); return token; } diff --git a/frontend/e2e/theme.spec.ts b/frontend/e2e/theme.spec.ts index 554e765..1aa4453 100644 --- a/frontend/e2e/theme.spec.ts +++ b/frontend/e2e/theme.spec.ts @@ -39,7 +39,7 @@ test("a returning light-theme user never sees the dark ground while settings loa await page.addInitScript(() => window.localStorage.setItem("cortex.theme", "light")); const release = await routeWorkspace(page, "light"); - await page.goto("/chat/new?bootstrap=launcher-token"); + await page.goto("/chat/new#bootstrap=launcher-token"); await expect(page.getByText("Loading local workspace")).toBeVisible(); expect(await documentTheme(page)).toBe("light"); @@ -69,7 +69,7 @@ test("system follows the operating system's colour scheme, and a first launch st const release = await routeWorkspace(page, "system"); // No cache yet: the fallback is dark, matching the backend's default. - await page.goto("/chat/new?bootstrap=launcher-token"); + await page.goto("/chat/new#bootstrap=launcher-token"); await expect(page.getByText("Loading local workspace")).toBeVisible(); expect(await documentTheme(page)).toBe("dark"); expect(await bodyBackground(page)).toBe(DARK_BG); diff --git a/frontend/e2e/virtualized-transcript.spec.ts b/frontend/e2e/virtualized-transcript.spec.ts index 06842a5..1343cf5 100644 --- a/frontend/e2e/virtualized-transcript.spec.ts +++ b/frontend/e2e/virtualized-transcript.spec.ts @@ -38,7 +38,7 @@ test("renders and scrolls a virtualized transcript once messages cross the thres await route.fulfill({ json: { required_models: [], optional_models: [], installed_models: ["local-chat:7b"], missing_models: [], optional_missing_models: [], models: [{ name: "local-chat:7b" }], connection: { success: true, status: "connected", message: "Connected to local runtime." } } }); }); - await page.goto(`/chat/${threadId}?bootstrap=launcher-token`); + await page.goto(`/chat/${threadId}#bootstrap=launcher-token`); // The virtualized container is in play, not the plain scrollable transcript. await expect(page.locator(".transcript-virtual")).toBeVisible(); diff --git a/frontend/src/app/App.test.tsx b/frontend/src/app/App.test.tsx index c8b5ec3..22388c7 100644 --- a/frontend/src/app/App.test.tsx +++ b/frontend/src/app/App.test.tsx @@ -118,8 +118,28 @@ describe("App", () => { expect(fetcher.mock.calls.some(([input]) => String(input).endsWith("/session/exchange"))).toBe(false); }); + it("never uses launcher credentials from the query string, only from the fragment", async () => { + // The launcher puts them in the fragment, which never reaches a server. A + // query-string copy used to be honoured too, for no flow that sends one. + window.sessionStorage.clear(); + window.history.replaceState({}, "", "/?bootstrap=query-token&handoff=query-handoff&view=compact"); + const { fetcher, exchanged } = bootstrapBackend( + ["query-token"], + { status: 401, detail: "Cortex bootstrap token invalid or already used." }, + ); + + render(); + + expect(screen.getByRole("heading", { name: "Start local workspace" })).toBeVisible(); + await waitFor(() => expect(window.location.search).toBe("?view=compact")); + expect(exchanged).toEqual([]); + expect(callsTo(fetcher, "/session/handoff")).toHaveLength(0); + expect(window.sessionStorage.getItem("cortex.session.handoff")).toBeNull(); + expect(screen.queryByRole("button", { name: "Reconnect workspace" })).not.toBeInTheDocument(); + }); + it("does not reuse a consumed bootstrap token after the local session expires", async () => { - window.history.replaceState({}, "", "/?bootstrap=desktop-handoff"); + window.history.replaceState({}, "", "/#bootstrap=desktop-handoff"); const fetcher = vi.fn<(input: RequestInfo | URL) => Promise>(async (input) => { const url = String(input); if (url.endsWith("/session/exchange")) { diff --git a/frontend/src/app/App.tsx b/frontend/src/app/App.tsx index 4721e80..4d27dbf 100644 --- a/frontend/src/app/App.tsx +++ b/frontend/src/app/App.tsx @@ -95,15 +95,18 @@ type LauncherCredentials = { bootstrapToken: string; handoffSecret: string }; * * Storing the secret is a side effect, so it happens in the same effect that * scrubs the URL -- see below for why an initialiser must stay pure. + * + * Both are read from the fragment only, which is the one place the launcher + * puts them: a fragment is never sent to a server, so it cannot reach a + * request line, a proxy or a log. A credential in the query string is not + * used (it is still scrubbed from the address bar, below). */ function readLauncherCredentials(): LauncherCredentials { const url = new URL(window.location.href); - const search = url.searchParams; const hash = new URLSearchParams(url.hash.replace(/^#/, "")); return { - bootstrapToken: search.get("bootstrap") || hash.get("bootstrap") || "", - handoffSecret: - search.get("handoff") || hash.get("handoff") || readPersistedHandoffSecret(), + bootstrapToken: hash.get("bootstrap") || "", + handoffSecret: hash.get("handoff") || readPersistedHandoffSecret(), }; } diff --git a/main.py b/main.py index da6ccb8..763f51b 100644 --- a/main.py +++ b/main.py @@ -39,6 +39,7 @@ DesktopWindowError, FrontendBuildError, InstanceLock, + InstanceLockError, InstanceRecord, WebViewInstallDeclined, WebViewRuntimeError, @@ -111,6 +112,9 @@ def build_app(**kwargs: Any) -> Any: "or make %APPDATA% point at a local folder." ) _last_startup_log_path: Path | None = None +# Whether this launch tried to write a startup diagnostic at all, so the dialog +# says the log could not be written only when a write was attempted and failed. +_startup_log_attempted = False _startup_dialog_hint: str | None = None # The window already showed the startup failure on its error page, so the # message box that follows a failed startup would only say it twice. @@ -381,7 +385,8 @@ def _write_startup_diagnostic( ) -> Path | None: """Append one bounded, privacy-safe startup record and return its path.""" - global _last_startup_log_path + global _last_startup_log_path, _startup_log_attempted + _startup_log_attempted = True path = _startup_log_path(data_dir) try: entry = ( @@ -398,6 +403,37 @@ def _write_startup_diagnostic( return None +class LauncherFailure(RuntimeError): + """A failure the launcher found itself rather than caught from something else. + + Its message is fixed text, so it goes into the startup log as it stands. + """ + + +def _fail( + stage: str, + message: str, + code: int, + *, + data_dir: Path | None, + error: BaseException | None = None, +) -> int: + """End a launch with ``code``, having recorded why in the startup log. + + Every failing exit goes through here, so the dialog that follows always has + a log to point at -- several exits used to print and return, and the dialog + then said the log "could not be written" when nothing had tried. ``error`` + is what was caught, when there is one; otherwise ``message`` is recorded. + """ + _write_startup_diagnostic( + stage=stage, + error=error if error is not None else LauncherFailure(message), + data_dir=data_dir, + ) + print(message if error is None else f"{message}: {error}", file=sys.stderr) + return code + + def _record_startup_note(*, stage: str, detail: str, data_dir: Path | None) -> None: """Add one bounded, redacted line to the startup log; never raises.""" @@ -657,13 +693,21 @@ def _attach_runtime_log_to_uvicorn() -> None: logger.addHandler(_runtime_file_handler) -def _startup_dialog_message(log_path: Path | None, hint: str | None = None) -> str: - """The startup-error dialog text: the cause the person can act on, then the log.""" +def _startup_dialog_message( + log_path: Path | None, hint: str | None = None, *, attempted: bool = True +) -> str: + """The startup-error dialog text: the cause the person can act on, then the log. + + With no log, the dialog says one could not be written only when a write was + ``attempted``; otherwise it says nothing about a log rather than something + untrue. + """ paragraphs = ["Cortex could not start."] if hint: paragraphs.append(hint) if log_path is None: - paragraphs.append("Cortex could not write its diagnostic log.") + if attempted: + paragraphs.append("Cortex could not write its diagnostic log.") else: paragraphs.append(f"A privacy-safe diagnostic log was written to:\n{log_path}") paragraphs.append("Press Ctrl+C in this dialog to copy this message.") @@ -825,11 +869,14 @@ def _run_headless(*, backend, frontend, server) -> int: f"Vite stopped unexpectedly with exit code {frontend.returncode}." ) time.sleep(0.1) - return 0 if server.should_exit else 1 + if server.should_exit: + return 0 + # The server ended without being asked to and without an error of its own. + raise RuntimeError("Cortex backend stopped unexpectedly.") def _acquire_or_hand_off( - instance: InstanceLock, *, port: int, headless: bool + instance: InstanceLock, *, port: int, headless: bool, data_dir: Path | None = None ) -> tuple[InstanceRecord | None, int]: """Take the instance lock, or hand this launch over to the instance that holds it. @@ -844,6 +891,10 @@ def _acquire_or_hand_off( If the first instance dies meanwhile its lock is free, and this launch starts normally. Nothing on this path is an error: the app is running or starting, which is what the user asked for. + + ``acquire`` returns ``None`` only when another process really holds the + lock; a lock that cannot be set up raises ``InstanceLockError`` instead, + which is a failure of this launch and not a running instance to look for. """ deadline = time.monotonic() + SECOND_LAUNCH_WAIT_SECONDS while True: @@ -852,11 +903,12 @@ def _acquire_or_hand_off( return record, 0 existing = instance.read_record() if existing is None: - print( + return None, _fail( + "instance lock", "Cortex could not acquire its instance lock and no valid running-instance record exists.", - file=sys.stderr, + 2, + data_dir=data_dir, ) - return None, 2 if headless: print(f"Cortex is already running on loopback port {existing.port}.") return None, 0 @@ -880,11 +932,12 @@ def _acquire_or_hand_off( # free: go round and take it. If it never is, the record is stale in a # way this cannot resolve, and that is a failure. if time.monotonic() >= deadline: - print( + return None, _fail( + "instance lock", "Cortex is not running, but its instance lock could not be taken.", - file=sys.stderr, + 2, + data_dir=data_dir, ) - return None, 2 time.sleep(SECOND_LAUNCH_RETRY_SECONDS) @@ -892,12 +945,26 @@ def _run_web(args: argparse.Namespace) -> int: """Run Cortex; a backend that had to be abandoned at exit is never exit 0.""" global _backend_abandoned_at_exit, _backend_stop_failed global _startup_dialog_hint, _startup_failure_displayed + global _last_startup_log_path, _startup_log_attempted _backend_abandoned_at_exit = False _backend_stop_failed = False _startup_dialog_hint = None _startup_failure_displayed = False + _last_startup_log_path = None + _startup_log_attempted = False try: result = _launch(args) + except InstanceLockError as exc: + # The lock file lives in the data folder, so a lock that cannot be set + # up is a data folder Cortex cannot use. + _startup_dialog_hint = DATA_PATH_REMEDY + result = _fail( + "instance lock", + "Cortex could not set up its instance lock", + 2, + data_dir=args.data_dir, + error=exc, + ) finally: _close_runtime_logging() if result == 0 and _backend_stop_failed: @@ -1301,9 +1368,13 @@ def _run_instance( print("The WebView2 Runtime was not installed; Cortex is closing.") return 0 except FrontendBuildError as exc: - _write_startup_diagnostic(stage="frontend preparation", error=exc, data_dir=args.data_dir) - print(f"Frontend preparation failed: {exc}", file=sys.stderr) - return 2 + return _fail( + "frontend preparation", + "Frontend preparation failed", + 2, + data_dir=args.data_dir, + error=exc, + ) except ( DesktopWindowError, OSError, @@ -1311,18 +1382,18 @@ def _run_instance( TimeoutError, WebViewRuntimeError, ) as exc: - _write_startup_diagnostic( - stage="desktop startup/runtime", - error=exc, - data_dir=args.data_dir, - ) if isinstance(exc, WebViewRuntimeError): # Fixed text that says what to do next, so show it. _startup_dialog_hint = str(exc) elif isinstance(exc, AppPathError): _startup_dialog_hint = DATA_PATH_REMEDY - print(f"Cortex startup/runtime error: {exc}", file=sys.stderr) - return 1 + return _fail( + "desktop startup/runtime", + "Cortex startup/runtime error", + 1, + data_dir=args.data_dir, + error=exc, + ) finally: # Whatever the window's worker is still doing should stop, and it is # waited for (bounded) so this never stops a backend it is midway @@ -1350,13 +1421,9 @@ def _launch(args: argparse.Namespace) -> int: cortex_version=CORTEX_VERSION, ) except FrontendBuildError as exc: - _write_startup_diagnostic( - stage="frontend build", - error=exc, - data_dir=args.data_dir, + return _fail( + "frontend build", "Frontend build failed", 2, data_dir=args.data_dir, error=exc ) - print(f"Frontend build failed: {exc}", file=sys.stderr) - return 2 print(f"Frontend bundle ready at {dist}") return 0 @@ -1364,13 +1431,13 @@ def _launch(args: argparse.Namespace) -> int: try: backend_listener = _reserve_port(0) if args.port == 0 else None except OSError as exc: - _write_startup_diagnostic( - stage="backend port reservation", - error=exc, + return _fail( + "backend port reservation", + "Cortex could not reserve its backend port", + 1, data_dir=args.data_dir, + error=exc, ) - print(f"Cortex could not reserve its backend port: {exc}", file=sys.stderr) - return 1 backend_port = ( int(backend_listener.getsockname()[1]) if backend_listener is not None @@ -1380,7 +1447,7 @@ def _launch(args: argparse.Namespace) -> int: try: with InstanceLock(paths.data_dir) as instance: record, handoff_exit = _acquire_or_hand_off( - instance, port=backend_port, headless=args.headless + instance, port=backend_port, headless=args.headless, data_dir=args.data_dir ) if record is None: return handoff_exit @@ -1389,16 +1456,13 @@ def _launch(args: argparse.Namespace) -> int: try: backend_listener = _reserve_port(backend_port) except OSError as exc: - _write_startup_diagnostic( - stage="backend port reservation", - error=exc, + return _fail( + "backend port reservation", + "Cortex could not reserve its backend port", + 1, data_dir=args.data_dir, + error=exc, ) - print( - f"Cortex could not reserve its backend port: {exc}", - file=sys.stderr, - ) - return 1 # Only the instance that owns the lock writes the runtime log, so a # second launch that hands off and exits never has the file open @@ -1408,11 +1472,12 @@ def _launch(args: argparse.Namespace) -> int: handoff_secret = instance.read_secret(record) if not handoff_secret: - print( + return _fail( + "handoff secret", "Cortex could not initialize its authenticated handoff secret.", - file=sys.stderr, + 2, + data_dir=args.data_dir, ) - return 2 return _run_instance( args, @@ -1436,22 +1501,18 @@ def main(argv: list[str] | None = None) -> int: try: result = _run_web(args) except AppPathError as exc: - _write_startup_diagnostic( - stage="data path resolution", - error=exc, + _startup_dialog_hint = DATA_PATH_REMEDY + result = _fail( + "data path resolution", + "Cortex data-path error", + 2, data_dir=args.data_dir, + error=exc, ) - print(f"Cortex data-path error: {exc}", file=sys.stderr) - _startup_dialog_hint = DATA_PATH_REMEDY - result = 2 except Exception as exc: - _write_startup_diagnostic( - stage="uncaught startup", - error=exc, - data_dir=args.data_dir, + result = _fail( + "uncaught startup", "Cortex startup error", 1, data_dir=args.data_dir, error=exc ) - print(f"Cortex startup error: {exc}", file=sys.stderr) - result = 1 # A backend abandoned at exit is not a startup failure, and a modal box # would keep the process alive after the user has already quit. if ( @@ -1466,7 +1527,11 @@ def main(argv: list[str] | None = None) -> int: ctypes.windll.user32.MessageBoxW( None, - _startup_dialog_message(_last_startup_log_path, _startup_dialog_hint), + _startup_dialog_message( + _last_startup_log_path, + _startup_dialog_hint, + attempted=_startup_log_attempted, + ), "Cortex startup error", 0x10, ) diff --git a/tests/test_launcher.py b/tests/test_launcher.py index 5b64c5e..e9e10c3 100644 --- a/tests/test_launcher.py +++ b/tests/test_launcher.py @@ -5,6 +5,7 @@ import asyncio from collections.abc import Callable, Iterator import ctypes +import errno import http.client import io import json @@ -28,6 +29,7 @@ import main as launcher_main from cortex_backend.api import create_app from cortex_backend.core import paths as paths_module +from cortex_backend.core import win_paths as win_paths_module from cortex_backend.core.paths import AppPathError, AppPaths from cortex_backend.testing import build_demo_dependencies from cortex_backend.launcher import frontend as frontend_module @@ -36,7 +38,8 @@ from cortex_backend.launcher import webview_runtime as runtime_module from cortex_backend.launcher.desktop import DesktopWindowConfig, DesktopWindowError, WindowActivation from cortex_backend.launcher.frontend import FrontendBuildError, FrontendManifest -from cortex_backend.launcher.instance import InstanceLock, InstanceRecord +from cortex_backend.launcher import instance as instance_module +from cortex_backend.launcher.instance import InstanceLock, InstanceLockError, InstanceRecord from cortex_backend.launcher.webview_runtime import WebViewRuntimeError @@ -1878,6 +1881,12 @@ def test_webview2_signature_check_uses_noninteractive_powershell( bootstrapper = tmp_path / "MicrosoftEdgeWebview2Setup.exe" bootstrapper.write_bytes(b"signed") calls: list[tuple[list[str], dict[str, object]]] = [] + # Windows' own PowerShell runs the check, not whichever is first on PATH. + system = tmp_path / "System32" + powershell = system / "WindowsPowerShell" / "v1.0" / "powershell.exe" + powershell.parent.mkdir(parents=True) + powershell.write_bytes(b"") + monkeypatch.setattr(win_paths_module, "system_directory", lambda: system) def fake_run(command, **kwargs): calls.append((command, kwargs)) @@ -1887,7 +1896,7 @@ def fake_run(command, **kwargs): runtime_module._verify_microsoft_signature(bootstrapper) - assert calls[0][0][0] == "powershell.exe" + assert calls[0][0][0] == str(powershell) assert "-NoProfile" in calls[0][0] assert "-NonInteractive" in calls[0][0] assert "-Command" in calls[0][0] @@ -2137,6 +2146,88 @@ def test_instance_lock_does_not_follow_a_record_to_an_arbitrary_secret(tmp_path: lock.release() +def _fail_lock_call(monkeypatch: pytest.MonkeyPatch, error_number: int) -> None: + """Make the OS lock call itself fail with ``error_number``.""" + + def refuse(*_args: object) -> None: + raise OSError(error_number, os.strerror(error_number)) + + if instance_module.msvcrt is not None: + fake = SimpleNamespace( + LK_NBLCK=instance_module.msvcrt.LK_NBLCK, + LK_UNLCK=instance_module.msvcrt.LK_UNLCK, + locking=refuse, + ) + monkeypatch.setattr(instance_module, "msvcrt", fake) + else: # pragma: no cover - Windows is the supported launcher target + fake = SimpleNamespace(LOCK_EX=0, LOCK_NB=0, LOCK_UN=0, flock=refuse) + monkeypatch.setattr(instance_module, "fcntl", fake) + + +def test_instance_lock_distinguishes_contention_from_setup_failure( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +): + """Only a lock another process holds is contention; anything else is a failure. + + Every failure used to come back as ``None``, exactly like "already running", + so a lock file that could not even be opened sent the launch looking for a + running instance that did not exist. + """ + # Genuine contention: another holder has the lock. + holder = InstanceLock(tmp_path) + assert holder.acquire(port=8765) is not None + try: + assert InstanceLock(tmp_path).acquire(port=8766) is None + finally: + holder.release() + + with monkeypatch.context() as patched: + _fail_lock_call(patched, errno.EACCES) + assert InstanceLock(tmp_path).acquire(port=8766) is None, "EACCES is a held lock" + + # A profile folder that cannot be secured. + with monkeypatch.context() as patched: + + def refuse_acl(_path, *, directory): + raise AppPathError("Cortex could not secure its private data permissions.") + + patched.setattr(instance_module, "secure_private_path", refuse_acl) + with pytest.raises(InstanceLockError, match="instance folder"): + InstanceLock(tmp_path).acquire(port=8766) + + # A lock file that cannot be opened. + lock_path = InstanceLock(tmp_path).lock_path + real_open = os.open + + def refuse_lock_file(path, *args, **kwargs): + if Path(path) == lock_path: + raise PermissionError(errno.EACCES, "denied") + return real_open(path, *args, **kwargs) + + with monkeypatch.context() as patched: + patched.setattr(instance_module.os, "open", refuse_lock_file) + with pytest.raises(InstanceLockError, match=r"open its instance lock file \(PermissionError\)"): + InstanceLock(tmp_path).acquire(port=8766) + + # A lock call that fails for a reason other than another holder. + with monkeypatch.context() as patched: + _fail_lock_call(patched, errno.EBADF) + with pytest.raises(InstanceLockError, match="lock its instance lock file"): + InstanceLock(tmp_path).acquire(port=8766) + + # A record that cannot be written: the lock is given back, no secret is left. + blocker = tmp_path / "cortex.instance.json.tmp" + blocker.mkdir() + failing = InstanceLock(tmp_path) + with pytest.raises(InstanceLockError, match="publish its instance record"): + failing.acquire(port=8766) + assert not failing.secret_path.exists() + blocker.rmdir() + recovered = InstanceLock(tmp_path) + assert recovered.acquire(port=8767) is not None, "a failed acquire kept the lock" + recovered.release() + + def test_frontend_manifest_detects_source_changes(tmp_path: Path, monkeypatch: pytest.MonkeyPatch): root = _frontend_fixture(tmp_path) monkeypatch.setattr(frontend_module, "_major_version", lambda command: 24 if command == "node" else 11) @@ -3838,6 +3929,158 @@ def test_second_headless_launch_reports_the_running_instance_without_waiting( assert "already running on loopback port 5555" in capsys.readouterr().out +def _lock_that_cannot_be_set_up(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> list[str]: + class BrokenInstance: + def __init__(self, _profile_dir): + pass + + def __enter__(self): + return self + + def __exit__(self, *_exc): + pass + + def acquire(self, *, port): + raise InstanceLockError("Cortex could not open its instance lock file (PermissionError).") + + _LaunchFakes(monkeypatch, tmp_path, instance_class=BrokenInstance) + return [] + + +def _lock_held_with_no_record(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> list[str]: + _second_launch(monkeypatch, tmp_path, activations=[WindowActivation.ACTIVATED], existing=None) + return [] + + +def _dead_holders_lock_never_frees(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> list[str]: + _second_launch( + monkeypatch, + tmp_path, + activations=[WindowActivation.NO_WINDOW], + alive=[False], + acquired=[False], + wait=0.05, + ) + return [] + + +def _handoff_secret_unreadable(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> list[str]: + held = _held_instance_class([True], existing=None) + held.read_secret = lambda _self, _record: None # type: ignore[method-assign] + _LaunchFakes(monkeypatch, tmp_path, instance_class=held) + return [] + + +def _explicit_port_taken(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> list[str]: + _LaunchFakes(monkeypatch, tmp_path) + + def taken(_port): + raise OSError(errno.EADDRINUSE, "address in use") + + monkeypatch.setattr(launcher_main, "_reserve_port", taken) + return ["--port", "50123"] + + +def _headless_frontend_preparation_fails(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> list[str]: + _LaunchFakes(monkeypatch, tmp_path) + + def refuse(*_args, **_kwargs): + raise FrontendBuildError("The packaged application is missing its frontend bundle.") + + monkeypatch.setattr(launcher_main, "ensure_frontend", refuse) + return ["--headless"] + + +def _headless_backend_stops_by_itself(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> list[str]: + fakes = _LaunchFakes(monkeypatch, tmp_path) + + def ready_then_gone(url, **_kwargs): + # Ready once, then the server thread ends without an error or a request to stop. + fakes.backends[-1].running = False + return True + + monkeypatch.setattr(launcher_main, "wait_for_http", ready_then_gone) + return ["--headless"] + + +def _native_window_fails(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> list[str]: + fakes = _LaunchFakes(monkeypatch, tmp_path) + + def failing_window(_config, _worker): + raise DesktopWindowError("synthetic window failure") + + fakes.on_window = failing_window + return [] + + +def _forced_frontend_build_fails(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> list[str]: + _LaunchFakes(monkeypatch, tmp_path) + + def refuse(*_args, **_kwargs): + raise FrontendBuildError("Frontend command failed with exit code 1.") + + monkeypatch.setattr(launcher_main, "ensure_frontend", refuse) + return ["--build-frontend"] + + +def _unexpected_exception(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> list[str]: + def explode(_data_dir): + raise ValueError("synthetic failure") + + monkeypatch.setattr(launcher_main, "_resolve_paths", explode) + return [] + + +@pytest.mark.parametrize( + ("arrange", "stage", "code"), + [ + pytest.param(_lock_that_cannot_be_set_up, "instance lock", 2, id="lock-setup"), + pytest.param(_lock_held_with_no_record, "instance lock", 2, id="held-no-record"), + pytest.param(_dead_holders_lock_never_frees, "instance lock", 2, id="lock-never-free"), + pytest.param(_handoff_secret_unreadable, "handoff secret", 2, id="handoff-secret"), + pytest.param(_explicit_port_taken, "backend port reservation", 1, id="port"), + pytest.param(_headless_frontend_preparation_fails, "frontend preparation", 2, id="frontend"), + pytest.param(_headless_backend_stops_by_itself, "desktop startup/runtime", 1, id="headless-stop"), + pytest.param(_native_window_fails, "desktop startup/runtime", 1, id="window"), + pytest.param(_forced_frontend_build_fails, "frontend build", 2, id="build-frontend"), + pytest.param(_unexpected_exception, "uncaught startup", 1, id="uncaught"), + ], +) +def test_every_failure_exit_writes_a_startup_diagnostic( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + arrange: Callable[[pytest.MonkeyPatch, Path], list[str]], + stage: str, + code: int, +): + """A failing exit always leaves a record, so the dialog is never wrong about it. + + Several exits printed to a console a windowed build does not have and + returned, and the dialog that followed said the diagnostic log "could not + be written" -- when nothing had tried to write one. + """ + data_dir = tmp_path / "data" + data_dir.mkdir() + extra = arrange(monkeypatch, data_dir) + shown = _record_startup_dialogs(monkeypatch, data_dir) + + assert launcher_main.main(["--data-dir", str(data_dir), *extra]) == code + + log_path = data_dir.resolve() / launcher_main.STARTUP_LOG_NAME + assert log_path.is_file(), "the failure left no startup diagnostic" + assert f"stage={stage} " in log_path.read_text(encoding="utf-8") + assert len(shown) == 1 + assert str(log_path) in shown[0] + assert "could not write" not in shown[0] + + +def test_the_dialog_only_says_the_log_could_not_be_written_when_a_write_was_tried(): + assert "could not write" in launcher_main._startup_dialog_message(None, attempted=True) + untried = launcher_main._startup_dialog_message(None, attempted=False) + assert "Cortex could not start." in untried + assert "log" not in untried + + class _FakeUser32: """Records the window calls ``activate_process_window`` makes.""" diff --git a/tests/test_win_paths.py b/tests/test_win_paths.py new file mode 100644 index 0000000..d165ce1 --- /dev/null +++ b/tests/test_win_paths.py @@ -0,0 +1,139 @@ +"""Windows' own tools are started from the system directory, never from PATH.""" + +from __future__ import annotations + +from pathlib import Path +import sys +from types import SimpleNamespace + +import pytest + +from cortex_backend.core import paths as paths_module +from cortex_backend.core import win_paths +from cortex_backend.launcher import supervisor as supervisor_module +from cortex_backend.launcher.supervisor import ChildProcessSupervisor + + +def _system_directory(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *tools: str) -> Path: + """A stand-in System32 holding ``tools`` (relative paths), and make it the one used.""" + system = tmp_path / "System32" + system.mkdir() + for tool in tools: + path = system / tool + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(b"") + monkeypatch.setattr(win_paths, "system_directory", lambda: system) + return system + + +def test_tools_resolve_to_absolute_paths_under_the_system_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + system = _system_directory( + tmp_path, + monkeypatch, + "icacls.exe", + "taskkill.exe", + "WindowsPowerShell/v1.0/powershell.exe", + ) + + assert win_paths.system32("icacls.exe") == str(system / "icacls.exe") + assert win_paths.system32("taskkill.exe") == str(system / "taskkill.exe") + assert win_paths.system32("powershell.exe") == str( + system / "WindowsPowerShell" / "v1.0" / "powershell.exe" + ) + + +def test_a_tool_missing_from_the_system_directory_falls_back_to_its_bare_name( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + _system_directory(tmp_path, monkeypatch) + + assert win_paths.system32("icacls.exe") == "icacls.exe" + + monkeypatch.setattr(win_paths, "system_directory", lambda: None) + assert win_paths.system32("powershell.exe") == "powershell.exe" + + +def test_the_system_directory_is_asked_of_windows_before_the_environment( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr(win_paths.sys, "platform", "win32") + api_answer = tmp_path / "Real" / "System32" + monkeypatch.setattr(win_paths, "_system_directory_from_api", lambda: api_answer) + monkeypatch.setenv("SystemRoot", str(tmp_path / "Planted")) + + assert win_paths.system_directory() == api_answer + + # Only when Windows does not answer does the variable count. + monkeypatch.setattr(win_paths, "_system_directory_from_api", lambda: None) + assert win_paths.system_directory() == tmp_path / "Planted" / "System32" + + monkeypatch.delenv("SystemRoot") + monkeypatch.setenv("windir", str(tmp_path / "Windir")) + assert win_paths.system_directory() == tmp_path / "Windir" / "System32" + + monkeypatch.delenv("windir") + assert win_paths.system_directory() is None + + +def test_there_is_no_system_directory_off_windows(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(win_paths.sys, "platform", "linux") + + assert win_paths.system_directory() is None + assert win_paths.system32("taskkill.exe") == "taskkill.exe" + + +@pytest.mark.skipif(sys.platform != "win32", reason="the real system directory exists only on Windows") +def test_on_this_machine_powershell_resolves_inside_the_real_system_directory( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # A planted PATH and %SystemRoot% change nothing: Windows is asked directly. + monkeypatch.setenv("PATH", str(tmp_path)) + monkeypatch.setenv("SystemRoot", str(tmp_path)) + + resolved = Path(win_paths.system32("powershell.exe")) + + assert resolved.is_absolute() + assert resolved.is_file() + assert resolved.parent.name.lower() == "v1.0" + assert tmp_path not in resolved.parents + + +def test_private_permissions_are_set_with_the_system_icacls( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + system = _system_directory(tmp_path, monkeypatch, "icacls.exe") + commands: list[list[str]] = [] + + def fake_run(command, **_kwargs): + commands.append(command) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(paths_module, "_running_on_windows", lambda: True) + monkeypatch.setattr(paths_module.getpass, "getuser", lambda: "synthetic-user") + monkeypatch.setattr(paths_module.subprocess, "run", fake_run) + + paths_module.secure_private_path(tmp_path / "data", directory=True) + + assert commands[0][0] == str(system / "icacls.exe") + + +def test_a_development_server_tree_is_ended_with_the_system_taskkill( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + system = _system_directory(tmp_path, monkeypatch, "taskkill.exe") + commands: list[list[str]] = [] + + def fake_run(command, **_kwargs): + commands.append(command) + return SimpleNamespace(returncode=0) + + monkeypatch.setattr(supervisor_module.os, "name", "nt") + monkeypatch.setattr(supervisor_module.subprocess, "run", fake_run) + child = ChildProcessSupervisor(["npm"], cwd=tmp_path) + child.process = SimpleNamespace(pid=4242) # type: ignore[assignment] + + child._kill_tree() + + assert commands == [[str(system / "taskkill.exe"), "/PID", "4242", "/T", "/F"]] diff --git a/tools/screenshots/capture.mjs b/tools/screenshots/capture.mjs index 9e88f38..ffad2bf 100644 --- a/tools/screenshots/capture.mjs +++ b/tools/screenshots/capture.mjs @@ -72,7 +72,7 @@ await page.addInitScript(() => { console.log("Capturing..."); // --- 1. Workspace: sidebar library + a real transcript ----------------------- -await page.goto(`${base}/?bootstrap=${encodeURIComponent(token)}`); +await page.goto(`${base}/#bootstrap=${encodeURIComponent(token)}`); await page.getByLabel("Message Cortex").waitFor({ state: "visible" }); // The sidebar is already expanded at this width, so open the hero diff --git a/tools/screenshots/capture_showcase.mjs b/tools/screenshots/capture_showcase.mjs index 3d9104c..28b66c5 100644 --- a/tools/screenshots/capture_showcase.mjs +++ b/tools/screenshots/capture_showcase.mjs @@ -150,7 +150,7 @@ console.log(`Capturing to ${outDir}`); // --------------------------------------------------------------------------- // Chat: transcript, code, reasoning, sources, tables // --------------------------------------------------------------------------- -await page.goto(`${base}/?bootstrap=${encodeURIComponent(browserToken)}`); +await page.goto(`${base}/#bootstrap=${encodeURIComponent(browserToken)}`); await page.getByLabel("Message Cortex").waitFor({ state: "visible" }); await page.waitForTimeout(500);