Add native Windows compatibility layer

This commit is contained in:
pewdiepie-archdaemon
2026-06-01 15:09:47 +09:00
parent ead7c01822
commit 0888a3b3e6
54 changed files with 1104 additions and 267 deletions
+2 -2
View File
@@ -38,14 +38,14 @@ class APIKeyManager:
"""Save encrypted API key to file"""
keys = self.load()
keys[provider] = self.encrypt_api_key(api_key)
with open(self.api_keys_file, 'w') as f:
with open(self.api_keys_file, 'w', encoding="utf-8") as f:
json.dump(keys, f)
def load(self) -> Dict[str, str]:
"""Load and decrypt API keys"""
if not os.path.exists(self.api_keys_file):
return {}
with open(self.api_keys_file, 'r') as f:
with open(self.api_keys_file, 'r', encoding="utf-8") as f:
encrypted_keys = json.load(f)
return {
provider: self.decrypt_api_key(key)
+50 -29
View File
@@ -22,7 +22,7 @@ from __future__ import annotations
import json
import os
import signal
import shlex
import subprocess
import time
import uuid
@@ -30,6 +30,12 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from core.atomic_io import atomic_write_json
from core.platform_compat import (
detached_popen_kwargs,
find_bash,
kill_process_tree,
pid_alive,
)
_DATA_DIR = Path(os.environ.get("DATA_DIR", "data"))
_JOBS_DIR = _DATA_DIR / "bg_jobs"
@@ -49,7 +55,7 @@ _RETENTION_S = 3600 # 1 hour after follow-up
def _load() -> Dict[str, Dict[str, Any]]:
try:
if _STORE.exists():
return json.loads(_STORE.read_text()) or {}
return json.loads(_STORE.read_text(encoding="utf-8")) or {}
except Exception:
pass
return {}
@@ -60,13 +66,11 @@ def _save(jobs: Dict[str, Dict[str, Any]]) -> None:
def _pid_alive(pid: Optional[int]) -> bool:
if not pid:
return False
try:
os.kill(pid, 0)
return True
except (OSError, ProcessLookupError):
return False
# Delegates to the platform-safe probe. NB: a bare os.kill(pid, 0) is unsafe
# on Windows — CPython routes it to TerminateProcess, which would KILL the
# job we're only trying to check. core.platform_compat.pid_alive handles
# both OSes correctly.
return pid_alive(pid)
def launch(command: str, session_id: str, cwd: Optional[str] = None,
@@ -88,22 +92,46 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None,
# command in `( … )` — the wrapper can't be broken by an unbalanced paren or
# a trailing line-continuation in the command. `$?` is the child's real
# exit status.
cmd_path = _JOBS_DIR / f"{job_id}.cmd.sh"
cmd_path.write_text(command + "\n")
wrapper = (
f"bash {cmd_path} > {log_path} 2>&1\n"
f"echo $? > {exit_path}\n"
)
script_path = _JOBS_DIR / f"{job_id}.sh"
script_path.write_text(wrapper)
bash = find_bash()
if bash:
# POSIX, or Windows with Git Bash/WSL. The user command goes in its OWN
# script file, run as a child `bash` — an `exit` inside it only ends
# that child (so the wrapper still records the exit code), and an
# unbalanced paren / trailing line-continuation in the command can't
# break the wrapper. `$?` is the child's real exit status. Paths are
# emitted as POSIX (forward-slash) + shell-quoted so Git Bash on Windows
# handles drive paths and spaces correctly.
cmd_path = _JOBS_DIR / f"{job_id}.cmd.sh"
cmd_path.write_text(command + "\n", encoding="utf-8")
lp, xp, cp = (shlex.quote(p.as_posix()) for p in (log_path, exit_path, cmd_path))
script_path = _JOBS_DIR / f"{job_id}.sh"
script_path.write_text(
f"bash {cp} > {lp} 2>&1\n"
f"echo $? > {xp}\n",
encoding="utf-8",
)
argv = [bash, str(script_path)]
else:
# Windows without any bash installed: cmd.exe wrapper. The command runs
# in its own child .cmd so %ERRORLEVEL% is the command's real exit code.
child_path = _JOBS_DIR / f"{job_id}.child.cmd"
child_path.write_text("@echo off\r\n" + command + "\r\n", encoding="utf-8")
script_path = _JOBS_DIR / f"{job_id}.cmd"
script_path.write_text(
"@echo off\r\n"
f'call "{child_path}" > "{log_path}" 2>&1\r\n'
f'echo %ERRORLEVEL%> "{exit_path}"\r\n',
encoding="utf-8",
)
argv = [os.environ.get("ComSpec", "cmd.exe"), "/c", str(script_path)]
proc = subprocess.Popen(
["bash", str(script_path)],
argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
cwd=cwd or None,
start_new_session=True, # setsid — detach from the request lifecycle
**detached_popen_kwargs(), # detach from the request lifecycle (setsid / DETACHED_PROCESS)
)
rec = {
@@ -128,7 +156,7 @@ def launch(command: str, session_id: str, cwd: Optional[str] = None,
def _read_output(rec: Dict[str, Any]) -> str:
try:
txt = Path(rec["log_path"]).read_text(errors="replace")
txt = Path(rec["log_path"]).read_text(encoding="utf-8", errors="replace")
except Exception:
return ""
if len(txt) > _MAX_OUTPUT_CHARS:
@@ -198,15 +226,8 @@ def refresh() -> Dict[str, Dict[str, Any]]:
def _kill(pid: Optional[int]) -> None:
if not pid:
return
try:
os.killpg(os.getpgid(pid), signal.SIGTERM)
except Exception:
try:
os.kill(pid, signal.SIGTERM)
except Exception:
pass
# Cross-platform process-tree teardown (POSIX killpg / Windows taskkill /T).
kill_process_tree(pid)
def pending_followups() -> List[Dict[str, Any]]:
+10
View File
@@ -11,6 +11,7 @@ from datetime import datetime
from typing import Tuple
from src.auth_helpers import owner_filter
from core.platform_compat import IS_WINDOWS, find_bash
logger = logging.getLogger(__name__)
@@ -266,6 +267,11 @@ async def action_ssh_command(owner: str, command: str = "", host: str = "localho
if not command:
return "No command specified", False
if host in ("localhost", "127.0.0.1", "local"):
if IS_WINDOWS:
bash = find_bash()
if bash:
return await _run_subprocess([bash, "-c", command], timeout=120, label="Command")
return await _run_subprocess(command, shell=True, timeout=120, label="Command")
return await _run_subprocess(["bash", "-c", command], timeout=120, label="Command")
return await _run_subprocess(
["ssh", "-o", "ConnectTimeout=10", host, command], timeout=120, label="Command",
@@ -278,6 +284,8 @@ async def action_run_script(owner: str, script: str = "", host: str = "", **kwar
return "No script specified", False
target_host = (host or os.getenv("ODYSSEUS_SCRIPT_HOST", "localhost")).strip()
if target_host in ("", "localhost", "127.0.0.1", "local"):
if IS_WINDOWS and find_bash():
return await _run_subprocess([find_bash(), "-c", script], timeout=300, label="Script")
return await _run_subprocess(script, shell=True, timeout=300, label="Script")
return await _run_subprocess(["ssh", target_host, script], timeout=300, label="Script")
@@ -286,6 +294,8 @@ async def action_run_local(owner: str, script: str = "", **kwargs) -> Tuple[str,
"""Run a script locally (no SSH)."""
if not script:
return "No script specified", False
if IS_WINDOWS and find_bash():
return await _run_subprocess([find_bash(), "-c", script], timeout=300, label="Script")
return await _run_subprocess(script, shell=True, timeout=300, label="Script")
+24 -3
View File
@@ -11,15 +11,36 @@ import shutil
import sys
import asyncio
from core.platform_compat import IS_WINDOWS, which_tool
logger = logging.getLogger(__name__)
def _find_npx() -> str:
"""Find npx binary, checking common locations if not on PATH."""
npx = shutil.which("npx")
"""Find the npx binary, checking common locations if not on PATH.
On Windows the shim is `npx.cmd`, which `which_tool` resolves via PATHEXT.
"""
npx = which_tool("npx")
if npx:
return npx
# Common locations when PATH is minimal (e.g. systemd)
if IS_WINDOWS:
# Minimal-PATH fallbacks: npm's global bin lives under %APPDATA%\npm,
# and node's installer dir carries npx.cmd alongside node.exe.
appdata = os.environ.get("APPDATA", os.path.expanduser("~"))
for candidate in (
os.path.join(appdata, "npm", "npx.cmd"),
r"C:\Program Files\nodejs\npx.cmd",
):
if os.path.isfile(candidate):
return candidate
node = which_tool("node")
if node:
cand = os.path.join(os.path.dirname(node), "npx.cmd")
if os.path.isfile(cand):
return cand
return "npx.cmd" # fallback, will fail with a clear error
# Common POSIX locations when PATH is minimal (e.g. systemd)
for candidate in [
os.path.expanduser("~/.npm-global/bin/npx"),
os.path.expanduser("~/.local/bin/npx"),
+4 -4
View File
@@ -154,7 +154,7 @@ class ChatHandler:
if att_ids:
uploads_db_path = os.path.join(UPLOAD_DIR, "uploads.json")
try:
with open(uploads_db_path, "r") as f:
with open(uploads_db_path, "r", encoding="utf-8") as f:
_all_files = json.load(f)
files_by_id = {fi["id"]: fi for fi in _all_files.values() if "id" in fi}
except (FileNotFoundError, json.JSONDecodeError):
@@ -193,7 +193,7 @@ class ChatHandler:
_vcache = os.path.join(UPLOAD_DIR, ".vision", att_id + ".txt")
if os.path.exists(_vcache):
try:
with open(_vcache) as _vf:
with open(_vcache, encoding="utf-8") as _vf:
_vtext = _vf.read().strip()
if _vtext:
enhanced_message += f"\n[User-corrected caption / OCR for this image — treat as authoritative]:\n{_vtext}"
@@ -212,7 +212,7 @@ class ChatHandler:
vl_model = get_setting("vision_model", "") or ""
if os.path.exists(_vcache):
try:
with open(_vcache) as _vf:
with open(_vcache, encoding="utf-8") as _vf:
cached_desc = _vf.read().strip()
if cached_desc and not cached_desc.startswith("["):
vl_desc = cached_desc
@@ -225,7 +225,7 @@ class ChatHandler:
if vl_desc and not vl_desc.startswith("["):
try:
os.makedirs(os.path.join(UPLOAD_DIR, ".vision"), exist_ok=True)
with open(_vcache, "w") as _vf:
with open(_vcache, "w", encoding="utf-8") as _vf:
_vf.write(vl_desc)
except Exception:
pass
+11
View File
@@ -1,8 +1,19 @@
import os
from pathlib import Path
from typing import List, Optional
from pydantic_settings import BaseSettings, SettingsConfigDict
from pydantic import Field, field_validator
# Cross-platform OS flag, exposed here so callers can `from src.config import
# IS_WINDOWS`. Defined locally (a trivial `os.name == "nt"`) rather than imported
# from core.platform_compat, to keep this dependency-light config module from
# dragging in the whole core/__init__ + llm_core import chain. The platform
# *helper functions* (safe_chmod, pid_alive, find_bash, ...) live solely in
# core.platform_compat — that remains their single source of truth. Keep platform
# branches as small inline `if IS_WINDOWS:` deltas (never parallel *_windows.py
# files) so they stay easy to integrate with upstream changes.
IS_WINDOWS = os.name == "nt"
class DataConfig(BaseSettings):
"""Configuration for data storage and file handling."""
# Base directory
+41 -1
View File
@@ -13,6 +13,17 @@ Set EMBEDDING_URL in .env, e.g.:
"""
import os
# Windows: force HuggingFace/fastembed to COPY model files rather than symlink
# them. On a network-share/UNC cache dir Windows can't follow HF's symlinks
# ([WinError 1463] "symbolic link cannot be followed"), so ONNX fails to load the
# model and semantic memory dies. huggingface_hub reads this flag at import time,
# so it must be set before huggingface_hub is first imported — hence module-top.
# (app.py sets the same guard for the server entrypoint.)
if os.name == "nt":
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS", "1")
os.environ.setdefault("HF_HUB_DISABLE_SYMLINKS_WARNING", "1")
import logging
import numpy as np
import httpx
@@ -109,6 +120,35 @@ class FastEmbedClient:
"data", "fastembed_cache",
)
os.makedirs(cache_dir, exist_ok=True)
# Windows self-heal: the HuggingFace-hub cache stores model files as
# symlinks (snapshots/<rev>/model.onnx -> ../../blobs/<hash>). On a
# network-share / UNC data dir Windows refuses to follow them
# ([WinError 1463] "symbolic link cannot be followed because its type is
# disabled"), and a cache copied between machines can carry dead symlinks
# too. Either way fastembed tries to load a broken symlink and fails
# *without* re-downloading, leaving semantic memory degraded. Detect a
# broken-symlink model in the cache and drop the contaminated hub dir so
# fastembed re-fetches (it falls back to its CDN tarball of real files,
# which load fine). Best-effort; only ever removes a verifiably dead link.
if os.name == "nt":
try:
import glob, shutil
for _onnx in glob.glob(os.path.join(cache_dir, "**", "*.onnx"), recursive=True):
if os.path.islink(_onnx) and not os.path.exists(_onnx):
_root = _onnx
while os.path.basename(_root) and not os.path.basename(_root).startswith("models--"):
_parent = os.path.dirname(_root)
if _parent == _root:
break
_root = _parent
if os.path.basename(_root).startswith("models--"):
logger.warning(
"Embedding cache has a broken symlink (%s); clearing %s "
"so fastembed re-downloads real files", _onnx, _root,
)
shutil.rmtree(_root, ignore_errors=True)
except Exception as _e:
logger.debug("embedding cache symlink-heal skipped: %s", _e)
kwargs = {"model_name": self.model, "cache_dir": cache_dir}
self._embedding = TextEmbedding(**kwargs)
self._dim: Optional[int] = None
@@ -152,7 +192,7 @@ def _load_persisted_endpoint() -> dict:
)
if os.path.exists(endpoint_file):
import json
data = json.loads(open(endpoint_file).read())
data = json.loads(open(endpoint_file, encoding="utf-8").read())
if data.get("url"):
return data
except Exception:
+4 -4
View File
@@ -148,7 +148,7 @@ def load_integrations() -> List[Dict[str, Any]]:
if not os.path.exists(DATA_FILE):
return []
try:
with open(DATA_FILE, "r") as f:
with open(DATA_FILE, "r", encoding="utf-8") as f:
return json.load(f)
except (json.JSONDecodeError, IOError) as exc:
log.error("Failed to load integrations: %s", exc)
@@ -158,7 +158,7 @@ def load_integrations() -> List[Dict[str, Any]]:
def save_integrations(integrations: List[Dict[str, Any]]) -> None:
"""Persist integrations list to disk."""
_ensure_data_dir()
with open(DATA_FILE, "w") as f:
with open(DATA_FILE, "w", encoding="utf-8") as f:
json.dump(integrations, f, indent=2)
@@ -409,7 +409,7 @@ def migrate_from_settings() -> None:
return
try:
with open(settings_path, "r") as f:
with open(settings_path, "r", encoding="utf-8") as f:
settings = json.load(f)
except (json.JSONDecodeError, IOError):
return
@@ -436,7 +436,7 @@ def migrate_from_settings() -> None:
# Clear migrated keys
settings.pop("miniflux_url", None)
settings.pop("miniflux_api_key", None)
with open(settings_path, "w") as f:
with open(settings_path, "w", encoding="utf-8") as f:
json.dump(settings, f, indent=2)
log.info("Migrated Miniflux integration from settings.json")
+2 -2
View File
@@ -142,7 +142,7 @@ def save_field_sidecar(pdf_path: str, fields: list[dict[str, Any]]) -> str:
"""Persist the field schema next to its source PDF. Returns the sidecar path."""
path = sidecar_path(pdf_path)
try:
with open(path, "w") as f:
with open(path, "w", encoding="utf-8") as f:
json.dump(fields, f, indent=2)
except Exception as e:
logger.warning(f"Failed to write field sidecar {path}: {e}")
@@ -155,7 +155,7 @@ def load_field_sidecar(pdf_path: str) -> Optional[list[dict[str, Any]]]:
if not os.path.exists(path):
return None
try:
with open(path) as f:
with open(path, encoding="utf-8") as f:
return json.load(f)
except Exception as e:
logger.warning(f"Failed to read field sidecar {path}: {e}")
+4 -4
View File
@@ -178,7 +178,7 @@ class PersonalDocsManager:
"""Load the list of indexed directories from persistent storage."""
try:
if os.path.exists(self.directories_file):
with open(self.directories_file, 'r') as f:
with open(self.directories_file, 'r', encoding="utf-8") as f:
self.indexed_directories = json.load(f)
logger.info(f"Loaded {len(self.indexed_directories)} indexed directories")
else:
@@ -190,7 +190,7 @@ class PersonalDocsManager:
def save_directories(self):
"""Save the list of indexed directories to persistent storage."""
try:
with open(self.directories_file, 'w') as f:
with open(self.directories_file, 'w', encoding="utf-8") as f:
json.dump(self.indexed_directories, f, indent=2)
logger.info(f"Saved {len(self.indexed_directories)} indexed directories")
except Exception as e:
@@ -200,7 +200,7 @@ class PersonalDocsManager:
"""Load the set of excluded file paths from persistent storage."""
try:
if os.path.exists(self._excluded_file):
with open(self._excluded_file, 'r') as f:
with open(self._excluded_file, 'r', encoding="utf-8") as f:
self.excluded_files = set(json.load(f))
else:
self.excluded_files = set()
@@ -210,7 +210,7 @@ class PersonalDocsManager:
def _save_excluded(self):
try:
with open(self._excluded_file, 'w') as f:
with open(self._excluded_file, 'w', encoding="utf-8") as f:
json.dump(list(self.excluded_files), f)
except Exception as e:
logger.error(f"Error saving excluded files: {e}")
+2 -2
View File
@@ -75,7 +75,7 @@ Use precise language. Show causal relationships explicitly. Quantify uncertainty
return self.DEFAULT_PRESETS.copy()
try:
with open(self.presets_file, 'r') as f:
with open(self.presets_file, 'r', encoding="utf-8") as f:
presets = json.load(f)
custom = presets.get("custom") if isinstance(presets, dict) else None
if isinstance(custom, dict) and "enabled" not in custom:
@@ -101,7 +101,7 @@ Use precise language. Show causal relationships explicitly. Quantify uncertainty
"""Save presets to file"""
try:
os.makedirs(os.path.dirname(self.presets_file), exist_ok=True)
with open(self.presets_file, 'w') as f:
with open(self.presets_file, 'w', encoding="utf-8") as f:
json.dump(presets, f, indent=2)
self.presets = presets
return True
+14 -14
View File
@@ -299,7 +299,7 @@ class ResearchHandler:
path = RESEARCH_DATA_DIR / f"{session_id}.json"
if path.exists():
try:
data = json.loads(path.read_text())
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("consumed"):
return None
return {
@@ -338,7 +338,7 @@ class ResearchHandler:
path = RESEARCH_DATA_DIR / f"{session_id}.json"
if path.exists():
try:
data = json.loads(path.read_text())
data = json.loads(path.read_text(encoding="utf-8"))
if data.get("consumed"):
return None
return data.get("result")
@@ -360,7 +360,7 @@ class ResearchHandler:
path = RESEARCH_DATA_DIR / f"{session_id}.json"
if path.exists():
try:
data = json.loads(path.read_text())
data = json.loads(path.read_text(encoding="utf-8"))
return data.get("sources")
except Exception:
pass
@@ -377,7 +377,7 @@ class ResearchHandler:
path = RESEARCH_DATA_DIR / f"{session_id}.json"
if path.exists():
try:
data = json.loads(path.read_text())
data = json.loads(path.read_text(encoding="utf-8"))
return data.get("raw_findings")
except Exception as e:
logger.warning(f"Failed to read raw findings for {session_id}: {e}")
@@ -425,7 +425,7 @@ class ResearchHandler:
try:
for p in RESEARCH_DATA_DIR.glob("*.json"):
try:
data = json.loads(p.read_text())
data = json.loads(p.read_text(encoding="utf-8"))
if data.get("status") == "done":
started = data.get("started_at", 0)
completed = data.get("completed_at", 0)
@@ -448,9 +448,9 @@ class ResearchHandler:
path = RESEARCH_DATA_DIR / f"{session_id}.json"
if path.exists():
try:
data = json.loads(path.read_text())
data = json.loads(path.read_text(encoding="utf-8"))
data["consumed"] = True
path.write_text(json.dumps(data))
path.write_text(json.dumps(data), encoding="utf-8")
except Exception:
pass
@@ -481,7 +481,7 @@ class ResearchHandler:
# SECURITY: stamp owner so route handlers can filter by user.
"owner": entry.get("owner", ""),
}
path.write_text(json.dumps(data))
path.write_text(json.dumps(data), encoding="utf-8")
logger.info(f"Research result saved to {path}")
try:
from src.event_bus import fire_event
@@ -496,7 +496,7 @@ class ResearchHandler:
path = RESEARCH_DATA_DIR / f"{session_id}.json"
if path.exists():
try:
return json.loads(path.read_text())
return json.loads(path.read_text(encoding="utf-8"))
except Exception:
pass
return None
@@ -511,7 +511,7 @@ class ResearchHandler:
try:
from src.visual_report import generate_visual_report
data = json.loads(json_path.read_text())
data = json.loads(json_path.read_text(encoding="utf-8"))
report_md = data.get("raw_report") or data.get("result", "")
html_content = generate_visual_report(
question=data.get("query", ""),
@@ -534,12 +534,12 @@ class ResearchHandler:
if not path.exists():
return False
try:
data = json.loads(path.read_text())
data = json.loads(path.read_text(encoding="utf-8"))
hidden = data.get("hidden_images") or []
if image_url not in hidden:
hidden.append(image_url)
data["hidden_images"] = hidden
path.write_text(json.dumps(data))
path.write_text(json.dumps(data), encoding="utf-8")
logger.info(f"Hid image {image_url[:80]} for research {session_id}")
return True
except Exception as e:
@@ -552,9 +552,9 @@ class ResearchHandler:
if not path.exists():
return False
try:
data = json.loads(path.read_text())
data = json.loads(path.read_text(encoding="utf-8"))
data["hidden_images"] = []
path.write_text(json.dumps(data))
path.write_text(json.dumps(data), encoding="utf-8")
logger.info(f"Cleared hidden_images for research {session_id}")
return True
except Exception as e:
+5 -4
View File
@@ -24,6 +24,8 @@ from pathlib import Path
from cryptography.fernet import Fernet, InvalidToken
from core.platform_compat import safe_chmod
logger = logging.getLogger(__name__)
_KEY_PATH = Path(__file__).resolve().parent.parent / "data" / ".app_key"
@@ -37,10 +39,9 @@ def _load_or_create_key() -> bytes:
_KEY_PATH.parent.mkdir(parents=True, exist_ok=True)
key = Fernet.generate_key()
_KEY_PATH.write_bytes(key)
try:
os.chmod(_KEY_PATH, 0o600)
except Exception:
pass
# POSIX: lock the key to 0o600. Windows: no-op (the user-profile data dir is
# already ACL-restricted); safe_chmod swallows both cases.
safe_chmod(_KEY_PATH, 0o600)
logger.info(f"Generated new app key at {_KEY_PATH}")
return key
+2 -2
View File
@@ -140,7 +140,7 @@ def load_settings() -> dict:
if _settings_cache and (now - _settings_cache[0]) < _CACHE_TTL:
return _settings_cache[1]
try:
with open(SETTINGS_FILE, "r") as f:
with open(SETTINGS_FILE, "r", encoding="utf-8") as f:
saved = json.load(f)
merged = {**DEFAULT_SETTINGS, **saved}
except (FileNotFoundError, json.JSONDecodeError):
@@ -205,7 +205,7 @@ def load_features() -> dict:
if _features_cache and (now - _features_cache[0]) < _CACHE_TTL:
return _features_cache[1]
try:
with open(FEATURES_FILE, "r") as f:
with open(FEATURES_FILE, "r", encoding="utf-8") as f:
saved = json.load(f)
merged = {**DEFAULT_FEATURES, **saved}
except (FileNotFoundError, json.JSONDecodeError):
+2 -2
View File
@@ -1013,7 +1013,7 @@ class TaskScheduler:
from pathlib import Path as _P
integrations_file = _P("data/integrations.json")
if integrations_file.exists():
integrations = json.loads(integrations_file.read_text())
integrations = json.loads(integrations_file.read_text(encoding="utf-8"))
for integ in integrations:
if not integ.get("enabled"):
continue
@@ -1616,7 +1616,7 @@ class TaskScheduler:
"task_id": task.id,
"task_name": task.name,
}
(RESEARCH_DATA_DIR / f"{session_id}.json").write_text(json.dumps(payload))
(RESEARCH_DATA_DIR / f"{session_id}.json").write_text(json.dumps(payload), encoding="utf-8")
try:
from src.event_bus import fire_event
fire_event("research_completed", task.owner or None)
+4 -1
View File
@@ -12,6 +12,7 @@ import collections
import json
import logging
import os
import sys
import time
from typing import Any, Awaitable, Callable, Dict, Optional, Tuple
@@ -348,7 +349,9 @@ async def _direct_fallback(
# can't take the whole server down. -I = isolated mode (skip
# user site, no PYTHONPATH inheritance) for hygiene.
proc = await asyncio.create_subprocess_exec(
"python3", "-I", "-c", content,
# Use the running interpreter — there is no `python3.exe` on
# Windows, which made the agent's `python` tool fail there.
(sys.executable or "python"), "-I", "-c", content,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
env=_subproc_env,
+4 -4
View File
@@ -3639,7 +3639,7 @@ async def do_manage_research(content: str, owner: Optional[str] = None) -> Dict:
def _load(p):
try:
return _json.loads(p.read_text())
return _json.loads(p.read_text(encoding="utf-8"))
except Exception:
return None
@@ -3874,7 +3874,7 @@ def _load_vault_config() -> Dict:
p = Path("data/vault.json")
if p.exists():
try:
return json.loads(p.read_text())
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
pass
return {}
@@ -4027,13 +4027,13 @@ async def do_vault_unlock(content: str, owner: Optional[str] = None) -> Dict:
cfg = {}
if p.exists():
try:
cfg = json.loads(p.read_text())
cfg = json.loads(p.read_text(encoding="utf-8"))
except Exception:
pass
cfg["session"] = session
from datetime import datetime as _dt
cfg["unlocked_at"] = _dt.utcnow().isoformat()
p.write_text(json.dumps(cfg, indent=2))
p.write_text(json.dumps(cfg, indent=2), encoding="utf-8")
try:
import os as _os
_os.chmod(str(p), 0o600)
+5 -5
View File
@@ -269,7 +269,7 @@ class UploadHandler:
uploads_db_path = os.path.join(self.upload_dir, "uploads.json")
if os.path.exists(uploads_db_path):
with open(uploads_db_path, "r") as f:
with open(uploads_db_path, "r", encoding="utf-8") as f:
files = json.load(f)
total_files = len(files)
@@ -352,7 +352,7 @@ class UploadHandler:
if os.path.exists(uploads_db_path):
try:
with open(uploads_db_path, "r") as f:
with open(uploads_db_path, "r", encoding="utf-8") as f:
existing_files = json.load(f)
except Exception as e:
logger.warning(f"Failed to read uploads database: {e}")
@@ -374,7 +374,7 @@ class UploadHandler:
existing_files[existing_key] = existing_file
try:
with open(uploads_db_path, "w") as f:
with open(uploads_db_path, "w", encoding="utf-8") as f:
json.dump(existing_files, f, indent=2)
except Exception as e:
logger.warning(f"Failed to update uploads database: {e}")
@@ -439,7 +439,7 @@ class UploadHandler:
try:
if os.path.exists(uploads_db_path):
try:
with open(uploads_db_path, "r") as f:
with open(uploads_db_path, "r", encoding="utf-8") as f:
all_files = json.load(f)
except Exception:
all_files = {}
@@ -449,7 +449,7 @@ class UploadHandler:
storage_key = f"{owner}:{file_hash}" if owner else file_hash
all_files[storage_key] = file_metadata
with open(uploads_db_path, "w") as f:
with open(uploads_db_path, "w", encoding="utf-8") as f:
json.dump(all_files, f, indent=2)
except Exception as e: