Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion backend/cortex_backend/core/paths.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import sys
from pathlib import PureWindowsPath

from .win_paths import system32


ORGANIZATION_NAME = "ChatLLM"
APPLICATION_NAME = "ChatLLM-Assistant"
Expand Down Expand Up @@ -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",
Expand Down
76 changes: 76 additions & 0 deletions backend/cortex_backend/core/win_paths.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion backend/cortex_backend/launcher/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,6 +28,7 @@
"DesktopWindowError",
"FrontendBuildError",
"InstanceLock",
"InstanceLockError",
"InstanceRecord",
"WebViewInstallDeclined",
"WebViewRuntimeError",
Expand Down
102 changes: 75 additions & 27 deletions backend/cortex_backend/launcher/instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

from dataclasses import asdict, dataclass
from datetime import datetime, timezone
import errno
import json
import os
from pathlib import Path
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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)
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion backend/cortex_backend/launcher/supervisor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
)
Expand Down
5 changes: 4 additions & 1 deletion backend/cortex_backend/launcher/webview_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
6 changes: 3 additions & 3 deletions frontend/e2e/attachments.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down Expand Up @@ -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();

Expand All @@ -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();

Expand Down
4 changes: 2 additions & 2 deletions frontend/e2e/chat-groups.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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");
Expand Down
8 changes: 4 additions & 4 deletions frontend/e2e/chat.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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.
Expand Down
Loading
Loading