From 7b4e0ad7b684b30b704e5a896ae088a05ff1b165 Mon Sep 17 00:00:00 2001 From: jdugat Date: Wed, 7 Oct 2026 14:44:13 -0500 Subject: [PATCH] fix: let the server start on Windows Three POSIX assumptions stopped server.py from starting on Windows: - usage_scan built its day bounds from naive datetimes, whose .timestamp() goes through local time and raises OSError before 1970 on Windows, at import. Build them UTC-aware, and floor at the epoch there since fromtimestamp refuses negative values too. - ensure_tool_token adopts an existing token via O_NOFOLLOW, getuid and fchmod, none of which Windows has. Add a Windows path that still refuses links and non-regular files via lstat. - brain and run_executor shlex.split the claude path, which drops the backslashes from C:\Users\...\claude.exe. claude_env.split_command takes an existing file path whole and splits anything else as before. Co-Authored-By: Claude Opus 5.5 --- brain.py | 3 +-- claude_env.py | 13 +++++++++++++ data_paths.py | 26 ++++++++++++++++++++++++++ run_executor.py | 3 +-- usage_scan.py | 15 ++++++++++++--- 5 files changed, 53 insertions(+), 7 deletions(-) diff --git a/brain.py b/brain.py index d0ec5ffe..95dc9961 100644 --- a/brain.py +++ b/brain.py @@ -13,7 +13,6 @@ import logging import os import re -import shlex import shutil import time from dataclasses import dataclass, field @@ -742,7 +741,7 @@ def launch_prompt(self) -> str: def command(self) -> list[str]: c = self.config - cmd = shlex.split(self._claude) + [ + cmd = claude_env.split_command(self._claude) + [ "-p", "--input-format", "stream-json", "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--model", c.model, "--effort", c.effort, "--name", "jarvis", diff --git a/claude_env.py b/claude_env.py index 2ac80869..117dfc06 100644 --- a/claude_env.py +++ b/claude_env.py @@ -21,6 +21,7 @@ from __future__ import annotations import os +import shlex # Every ANTHROPIC_* variable, not just the key: the base URL and the model # override redirect a child just as effectively as credentials do. @@ -56,3 +57,15 @@ def child_env(base: dict[str, str] | None = None) -> dict[str, str]: return {k: v for k, v in source.items() if not k.startswith(SCRUBBED_ENV_PREFIXES) and k not in SCRUBBED_ENV_KEYS} + + +def split_command(command: str) -> list[str]: + r"""The argv for a configured `claude` command, which may carry arguments. + + A path to an existing file is taken whole: POSIX `shlex.split` reads the + backslashes in a Windows path (`C:\Users\...\claude.exe`, exactly what + `shutil.which` returns there) as escapes and drops them, leaving a path + that does not exist.""" + if os.path.isfile(command): + return [command] + return shlex.split(command) diff --git a/data_paths.py b/data_paths.py index f20b175d..493995ce 100644 --- a/data_paths.py +++ b/data_paths.py @@ -8,6 +8,7 @@ import json import logging import os +import sys import tempfile import time from pathlib import Path @@ -386,6 +387,9 @@ def ensure_tool_token() -> str: os.close(fd) return token + if sys.platform == "win32": + return _adopt_tool_token_windows(path, token) + fd = os.open(str(path), os.O_RDWR | os.O_NOFOLLOW) try: info = os.fstat(fd) @@ -405,3 +409,25 @@ def ensure_tool_token() -> str: return token finally: os.close(fd) + + +def _adopt_tool_token_windows(path: Path, token: str) -> str: + """Windows has no O_NOFOLLOW, getuid or POSIX modes, so the fd-based + checks above cannot run. Refuse links and non-regular files via lstat; + access control is left to the user profile's ACLs, which already keep + the data directory private to this account.""" + import stat as _stat + info = os.lstat(path) + if _stat.S_ISLNK(info.st_mode) or not _stat.S_ISREG(info.st_mode): + raise OSError(f"{path} is not a regular file") + fd = os.open(str(path), os.O_RDWR | getattr(os, "O_BINARY", 0)) + try: + existing = os.read(fd, 4096).decode("utf-8", "ignore").strip() + if existing: + return existing + os.ftruncate(fd, 0) + os.lseek(fd, 0, os.SEEK_SET) + os.write(fd, token.encode("utf-8")) + return token + finally: + os.close(fd) diff --git a/run_executor.py b/run_executor.py index b9bc2927..7d1b645b 100644 --- a/run_executor.py +++ b/run_executor.py @@ -15,7 +15,6 @@ import logging import math import os -import shlex import shutil import time from typing import Callable @@ -441,7 +440,7 @@ async def wait_for(self, run_id: str, timeout: float = 30) -> dict: def _command(self, run_id: str, resume_from: str | None, model: str | None = None) -> list[str]: - base = shlex.split(self._claude_path) + base = claude_env.split_command(self._claude_path) cmd = base + ["-p", "--output-format", "stream-json", "--verbose", "--session-id", run_id] if resume_from: diff --git a/usage_scan.py b/usage_scan.py index 7aa2dd69..156a1e4f 100644 --- a/usage_scan.py +++ b/usage_scan.py @@ -65,9 +65,10 @@ import json import logging import os +import sys import time from dataclasses import dataclass, field -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path import session_watch @@ -158,8 +159,16 @@ def _tokens_from(usage) -> Tokens: # `fromisoformat` accepts that string happily, so catching only its ValueError # left the second call unguarded. Two years of slack at each end covers every # UTC offset without needing to know the local one. -_DAY_MIN = datetime(2, 1, 1).timestamp() -_DAY_MAX = datetime(9997, 1, 1).timestamp() +# +# The bounds are built UTC-aware: a naive datetime's `.timestamp()` goes +# through the platform's local-time conversion, which on Windows raises +# OSError for anything before 1970 — at import time. Windows' +# `fromtimestamp` refuses negative epochs for the same reason, so there the +# floor is the epoch itself. +_DAY_MIN = datetime(2, 1, 1, tzinfo=timezone.utc).timestamp() +if sys.platform == "win32": + _DAY_MIN = 0.0 +_DAY_MAX = datetime(9997, 1, 1, tzinfo=timezone.utc).timestamp() def _epoch(stamp) -> float | None: