Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e71679a
test: add windows CI baseline
hellices Aug 4, 2026
7ddfb9b
test: harden workflow pin checks
hellices Aug 4, 2026
ae4713d
test: mark POSIX-only tilde expansion cases
hellices Aug 4, 2026
8535fa9
fix(tests): Windows-compatible security/durability assertions (#173 T…
hellices Aug 4, 2026
e3b2810
refactor(tests): address review — deduplicate, use shared POSIX flag,…
hellices Aug 4, 2026
115c9d9
fix: _run_write takes op factory, not eager awaitable (#173 Task 4 re…
hellices Aug 4, 2026
7ca759f
chore: review cleanup — bounded wait, updated prose, remove inert rel…
hellices Aug 4, 2026
3a2638e
test: harden gated-audit cancellation test with try/finally gate rele…
hellices Aug 4, 2026
12a629d
fix: last 4 Windows failures — teardown guard, audit poll, path join …
hellices Aug 4, 2026
a4429cc
test: deterministic teardown regression — remove StatusBar, spy handl…
hellices Aug 4, 2026
23b84f7
fix: _refresh_status guards NoMatches internally; poll proposal state…
hellices Aug 4, 2026
a9b3359
docs: add Windows contributor notes (#173 Task 5)
hellices Aug 4, 2026
af824e3
fix: crash-cap message uses ASCII dash; regression asserts encodabili…
hellices Aug 4, 2026
aa8c41d
test: harden Windows review follow-ups (#184)
hellices Aug 4, 2026
96ab6b6
test: poll audit outcome inside run_test to prevent race (#173 Task 4)
hellices Aug 4, 2026
f1e871a
docs: correct Windows capability skip breakdown
hellices Aug 4, 2026
53bfa0d
test: narrow Windows chmod spy signature
hellices Aug 4, 2026
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
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,19 @@ jobs:
- run: uvx deptry src/
- run: uv run pytest --cov --cov-fail-under=80

windows-test:
runs-on: windows-latest
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
with:
persist-credentials: false
- uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
with:
python-version: "3.12"
enable-cache: true
- run: uv sync --locked --dev --all-extras
- run: uv run pytest -q
Comment thread
hellices marked this conversation as resolved.

pre-commit:
runs-on: korvid-runners
steps:
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,5 @@ uv sync --dev --all-extras # create .venv with locked deps + all extras
uv run korvid # run against your current kubeconfig context
make check # lint + mypy --strict + tach + tests
```

Contributor docs: [Windows contributor notes](docs/windows.md).
2 changes: 1 addition & 1 deletion docs/ops.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ protected contexts.
### Crash recovery

If a fatal exception escapes the TUI, korvid restores the terminal, logs
the traceback, and asks `korvid crashed restart? [Y/n]` instead of just
the traceback, and asks `korvid crashed -- restart? [Y/n]` instead of just
dying. A restart rebuilds everything from scratch — a fresh event loop,
Kubernetes client, agent provider, and MCP server; nothing from the crashed
run is reused, and no approval state or pending proposal survives (the
Expand Down
48 changes: 48 additions & 0 deletions docs/windows.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# Windows contributor notes

Native Windows development is supported for dependency sync and the full test
suite:

```sh
uv sync --dev --all-extras
uv run pytest -q
```

PRs that touch shared/runtime behavior must also keep the required
`windows-test` CI job green. The proving run for issue #173 was
`30936032385`: **3376 passed / 37 skipped / 0 failures**.

## Expected skips on Windows

- **21 opt-in contract-suite skips** when `KORVID_CONTRACT_RUN_ID` is unset.
- **16 capability skips**:
- **3 newly classified capability skips**: 2 `~user` POSIX account-lookup
cases (`tests/core/test_transfer.py`, `tests/ui/test_transfer_picker.py`)
and 1 POSIX directory-fsync failure case.
- **13 pre-existing platform skips** for POSIX-only permission semantics:
7 local transfer permission-bit cases, 3 audit-log mode cases,
2 transfer-stream late-permission-loss cases, and 1 unreadable CA bundle
permission case.

## Current Windows limits

- Symlink tests depend on Windows **Developer Mode** (or an elevated shell):
native `Path.symlink_to()` can fail before korvid logic runs if symlink
creation is not allowed. The shared helper turns that into a capability skip
only when Windows symlink privilege is absent; the final hosted runner had privilege, so the count remained 37.
- Interactive shell attach can hit Textual's `SuspendNotSupported` path on
Windows/non-suspending drivers. That refusal is expected; render-only tests
pin `legacy_windows=False` for deterministic Rich output.
- NTFS uses ACLs, not POSIX mode bits. Tests verify atomic create/replace, the requested `0o600` mode, and durability semantics, but they do not claim ACL confidentiality.

## Windows-specific fixes covered by the green run

- Log export opens files with `newline=""`, preserving exact LF bytes instead
of writing CRLF.
- Terminal/status text now stays ASCII-safe on cp1252-style consoles; the
literal replacements are `->` and `--`:
`korvid shell -> ...`, `korvid node shell -> ...`, and
`korvid crashed -- restart? [Y/n]`.
- The write gate now takes an `op_factory`, so blocked or cancelled writes
never create eager mutation coroutines; cancellation safety is
cross-platform.
4 changes: 2 additions & 2 deletions src/korvid/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1132,7 +1132,7 @@ def _run_with_recovery(
if len(crash_times) >= RESTART_CAP:
print(
f"korvid crashed {len(crash_times)} times within"
f" {RESTART_WINDOW_SECONDS:.0f}s not restarting.",
f" {RESTART_WINDOW_SECONDS:.0f}s -- not restarting.",
file=sys.stderr,
)
raise
Expand All @@ -1145,7 +1145,7 @@ def _run_with_recovery(
def _restart_prompt() -> str:
# Interactivity keys off stdin/stderr; a redirected stdout must neither
# swallow the question nor be contaminated by it.
print("korvid crashed restart? [Y/n] ", end="", file=sys.stderr, flush=True)
print("korvid crashed -- restart? [Y/n] ", end="", file=sys.stderr, flush=True)
return input()


Expand Down
11 changes: 8 additions & 3 deletions src/korvid/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

import json
import os
import re
import unicodedata
from collections.abc import Mapping
Expand Down Expand Up @@ -348,10 +349,14 @@ def _atomic_write_text(path: Path, text: str) -> None:
unrelated keys), a power loss cannot leave an empty file, and concurrent
writers cannot race on a shared temp name."""
try:
# Preserve an existing restrictive mode; default new files to 0600.
mode = S_IMODE(path.stat().st_mode)
existing_mode = S_IMODE(path.stat().st_mode)
except OSError:
mode = 0o600
existing_mode = None
# On Windows, POSIX stat mode emulation returns 0o666 for readable+writable
# files regardless of actual ACLs; we cannot trust it as a "preserve" signal
# and always request the restrictive 0o600. On POSIX the real mode is
# meaningful, so we honour it when present.
mode = existing_mode if os.name != "nt" and existing_mode is not None else 0o600
fd, tmp_name = mkstemp(dir=path.parent, prefix=path.name + ".", suffix=".tmp")
tmp = Path(tmp_name)
try:
Expand Down
2 changes: 1 addition & 1 deletion src/korvid/core/logexport.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ def export_log_lines(
# (cluster logs may be sensitive), and explicit UTF-8 so a
# non-UTF-8 locale can't fail on non-ASCII log text.
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
with open(fd, "w", encoding="utf-8") as fh:
with open(fd, "w", encoding="utf-8", newline="") as fh:
fh.write(content)
except FileExistsError:
continue
Expand Down
5 changes: 3 additions & 2 deletions src/korvid/core/transfer.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,9 +311,10 @@ def default_local_path(remote_path: str) -> str:
``validate_spec``'s parent checks.
"""
base = posixpath.basename(remote_path.rstrip("/")) or "download"
downloads = Path("~/Downloads").expanduser()
home = Path.home()
downloads = home / "Downloads"
usable = downloads.is_dir() and os.access(downloads, os.W_OK | os.X_OK)
directory = downloads if usable else Path("~").expanduser()
directory = downloads if usable else home
return str(directory / base)


Expand Down
2 changes: 1 addition & 1 deletion src/korvid/providers/net.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ async def send(self, request: httpx.Request, **kwargs: Any) -> httpx.Response:
if "CERTIFICATE_VERIFY_FAILED" in str(exc):
raise httpx.ConnectError(
f"TLS verification failed against network.ca_bundle"
f" {self._ca_bundle_path!r}: {exc}",
f" '{self._ca_bundle_path}': {exc}",
request=request,
) from exc
raise
Expand Down
77 changes: 46 additions & 31 deletions src/korvid/ui/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4243,7 +4243,7 @@ def _run_shell(self, namespace: str, name: str, container: str | None) -> None:
argv = build_exec_argv(namespace, name, container, context=self.config.kube_context)
target = f"{name}/{container}" if container else name
with self.suspend():
exit_code = self._run_interactive(argv, f"korvid shell {target} (exit to return)")
exit_code = self._run_interactive(argv, f"korvid shell -> {target} (exit to return)")
self.refresh()
if exit_code == 0:
return
Expand Down Expand Up @@ -5182,48 +5182,49 @@ async def _run_write(
meta: ResourceMeta,
namespace: str | None,
name: str,
op: Awaitable[None],
op_factory: Callable[[], Awaitable[None]],
detail: str = "",
) -> str:
"""Execute an approved write with fail-closed auditing (AGENTS.md):
the intent record must persist *before* the mutation - if it cannot,
the write is blocked. Returns a short outcome string ('done' /
'blocked: ...' / 'failed: ...') for callers that report back.

Takes an operation *factory* so the mutation coroutine is never
created until intent is audited — a declined or blocked write
produces no unawaited coroutine to leak.

The whole span publishes an in-flight progress label (issue #143):
between approval and the outcome toast there was previously no
visible state at all."""
kind = meta.plural
with self._progress(f"{action} {kind}/{name}"):
return await self._run_write_inner(action, meta, namespace, name, op, detail, kind)
return await self._run_write_inner(
action, meta, namespace, name, op_factory, detail, kind
)

async def _run_write_inner(
self,
action: str,
meta: ResourceMeta,
namespace: str | None,
name: str,
op: Awaitable[None],
op_factory: Callable[[], Awaitable[None]],
detail: str,
kind: str,
) -> str:
try:
await self._audit_write(action, meta, namespace, name, detail, "intent")
except Exception as exc:
close = getattr(op, "close", None)
if callable(close):
close() # avoid "coroutine was never awaited" for the blocked op
# The exception can embed the local audit path (home directory):
# log it here, but keep the notification and the tool result -
# which is sent to the LLM provider - free of filesystem details.
# Factory was never called — no coroutine to leak.
logger.exception("audit intent record failed; write blocked: %s", exc)
self.notify(
f"{action} {kind}/{name} blocked: audit log unavailable",
severity="error",
)
return "blocked: audit log unavailable"
try:
await op
await op_factory()
except ApiStatusError as exc:
with contextlib.suppress(Exception):
await self._audit_write(action, meta, namespace, name, detail, f"error: {exc}")
Expand Down Expand Up @@ -5294,7 +5295,7 @@ async def _push_write_confirmation(
def _done(confirmed: bool | None) -> None:
if confirmed:
self.run_worker(
self._run_write(action, meta, namespace, name, op_factory(), detail=detail)
self._run_write(action, meta, namespace, name, op_factory, detail=detail)
)

await self.push_screen(
Expand Down Expand Up @@ -6255,7 +6256,7 @@ async def _wait_and_attach_node_shell(self, node: str, namespace: str, pod_name:
try:
with self.suspend():
exit_code = self._run_interactive(
attach_argv, f"korvid node shell {node} (exit to return)"
attach_argv, f"korvid node shell -> {node} (exit to return)"
)
except SuspendNotSupported:
# Non-suspending drivers (e.g. Windows, web): refuse gracefully —
Expand Down Expand Up @@ -6666,7 +6667,7 @@ def _done(confirmed: bool | None) -> None:
sub_meta,
namespace,
facts.package,
ops.create_object(sub_meta, namespace, manifest),
lambda: ops.create_object(sub_meta, namespace, manifest),
detail=f"channel={channel} approval={approval} source={facts.catalog_source}",
)
)
Expand Down Expand Up @@ -6942,7 +6943,7 @@ async def _operator_apply_uninstall(
sub_meta,
ns,
name,
ops.delete_object(sub_meta, ns, name, uid=uid),
lambda: ops.delete_object(sub_meta, ns, name, uid=uid),
detail=f"csv={csv_name or '-'}",
)
if outcome != "done" or csv_meta is None or not csv_name:
Expand All @@ -6952,7 +6953,7 @@ async def _operator_apply_uninstall(
csv_meta,
ns,
csv_name,
ops.delete_object(csv_meta, ns, csv_name, uid=csv_uid),
lambda: ops.delete_object(csv_meta, ns, csv_name, uid=csv_uid),
detail=f"subscription={name}",
)

Expand Down Expand Up @@ -7978,17 +7979,22 @@ def _refresh_status(self) -> None:
mcp_label = self._mcp.status() if self._mcp is not None else ""
if mcp_label and self._mcp is not None and self._mcp.running and self._mcp_follow:
mcp_label += " ·follow"
self._status_bar.update_status(
self.config.kube_context,
self.current_scope,
label,
breadcrumb=self._drill.breadcrumb(),
mcp_label=mcp_label,
filter_label=self._resource_filter.describe(),
progress_label=" · ".join(label for label in self._progress_labels.values() if label),
proposals_label=self._proposals_label(),
protected=self._protected_context is not None,
)
try:
self._status_bar.update_status(
self.config.kube_context,
self.current_scope,
label,
breadcrumb=self._drill.breadcrumb(),
mcp_label=mcp_label,
filter_label=self._resource_filter.describe(),
progress_label=" · ".join(
label for label in self._progress_labels.values() if label
),
proposals_label=self._proposals_label(),
protected=self._protected_context is not None,
)
except NoMatches:
return # StatusBar unmounted during teardown

def _proposals_label(self) -> str:
"""Status-bar text for pending external write proposals (issue #110):
Expand Down Expand Up @@ -8998,7 +9004,9 @@ async def agent_request_write(
return (
f"denied: the user declined the {action} request for {self._gvr_label(meta)}/{name}"
)
outcome = await self._run_write_shielded(action, meta, ns, name, op(uid), detail=detail)
outcome = await self._run_write_shielded(
action, meta, ns, name, lambda: op(uid), detail=detail
)
if outcome != "done":
return f"ERROR: {action} {self._gvr_label(meta)}/{name} {outcome}"
self._mark_agent_action(f"{action} → {self._gvr_label(meta)}/{name}")
Expand All @@ -9010,7 +9018,7 @@ async def _run_write_shielded(
meta: ResourceMeta,
ns: str | None,
name: str,
op: Awaitable[None],
op_factory: Callable[[], Awaitable[None]],
*,
detail: str,
) -> str:
Expand All @@ -9019,7 +9027,9 @@ async def _run_write_shielded(
mutation is worse than finishing what the user explicitly approved.
Every wait stays shielded — repeated cancellations are absorbed until
the write reaches a terminal state, then cancellation is re-raised."""
write = asyncio.ensure_future(self._run_write(action, meta, ns, name, op, detail=detail))
write = asyncio.ensure_future(
self._run_write(action, meta, ns, name, op_factory, detail=detail)
)
interrupted = False
while not write.done():
try:
Expand Down Expand Up @@ -9587,7 +9597,12 @@ async def _execute_proposal(
detail = self._proposal_provenance(proposal)
write = asyncio.ensure_future(
self._run_write(
proposal.action, meta, ns, proposal.name, op(proposal.uid), detail=detail
proposal.action,
meta,
ns,
proposal.name,
lambda: op(proposal.uid),
detail=detail,
)
)
try:
Expand Down
2 changes: 1 addition & 1 deletion src/korvid/ui/debug.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ async def run(
with self._suspend():
exit_code, pull_failure = self.run_process(
argv,
f"korvid debug {target} (exit to return)",
f"korvid debug -> {target} (exit to return)",
namespace,
name,
image,
Expand Down
5 changes: 4 additions & 1 deletion src/korvid/ui/widgets/path_picker.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from __future__ import annotations

import os
import posixpath
import unicodedata
from collections.abc import Awaitable, Callable
Expand Down Expand Up @@ -121,7 +122,9 @@ def action_select_dir(self) -> None:
path = self._start if node is None or node.data is None else Path(node.data.path)
if not path.is_dir():
path = path.parent
self.dismiss(str(path).rstrip("/") + "/")
# rstrip(os.sep) avoids "//"; add trailing sep so the caller knows
# this is a directory (on root "/" or "C:\", rstrip + sep round-trips).
self.dismiss(str(path).rstrip(os.sep) + os.sep)

def action_cancel(self) -> None:
self.dismiss(None)
Expand Down
3 changes: 2 additions & 1 deletion src/korvid/ui/widgets/transfer_screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,8 @@ def _apply_local_pick(self, result: str | None) -> None:
# Basename taken verbatim — it may end in whitespace.
remote = self.query_one("#transfer-remote", Input).value
base = posixpath.basename(remote.rstrip("/")) if remote.strip() else ""
result += base
if base:
result = str(Path(result) / base)
field.value = result
field.focus()

Expand Down
Loading
Loading