Author SHA1 Message Date
Jon Wingender 35f7461ca4 fix(tui): stop a stream outlived by /new from leaking into the next conversation
_finish_stream appended the completed reply to self.history - whatever
list that name currently pointed at - not to the conversation the
stream was actually answering. /new reassigns self.history to a fresh
list; a stream still running when that happens finished by silently
appending the old conversation's trailing reply onto the new one, which
then rides along in that new conversation's next /chat/stream history
payload. The conversation_id was already captured by closure for this
exact reason (see the tool-denial path); self.history needed the same
treatment.
2026-08-26 14:01:19 -05:00
enderofwings 99381f7e9e Merge pull request 'fix(packaging): remove the memory-service integration this CLI reintroduced' (#12) from fix/packaging-drop-memory-service into main 2026-08-26 18:49:05 +00:00
Jon Wingender ef176dbb68 Merge public/main (PR #11) into packaging branch 2026-08-26 13:43:23 -05:00
Jon Wingender a0f033142f fix(packaging): remove the memory-service integration this CLI reintroduced
synapse/memory/service.py was deleted on 2026-08-25 when memory curation
moved in-process (curator.py) - there is no longer a second FastAPI app
to run on :8001. This CLI was evidently built against a pre-curator
baseline: `nexus serve` spawned `synapse.memory.service:app` (fails
with ModuleNotFoundError, logged only to memory.log where nobody would
see it), `nexus start memory`/`stop memory` had no handler at all
(silently fell through to show_help()), and doctor/status/monitor all
carried a "memory service" row that could never be anything but down.

Removed rather than repaired, since there's nothing to repair: the
service, its SERVICES entry, --memory-port/--no-memory, the -m/--memory
target everywhere it was offered (start/stop/logs/LEGACY_TARGETS), and
the memory_port/memory_url settings this PR had added. The `nexus
memory list|add|rm` data commands (nexus_api.py, hitting the backend's
own /memory REST endpoint) are untouched - unrelated, and still work.
2026-08-26 13:40:50 -05:00
enderofwings 8bc8123bfa Merge pull request 'fix(preview): hung-frame watchdog + require approval for guessed action calls' (#11) from fix/preview-watchdog-and-guard into main 2026-08-26 18:15:13 +00:00
Jon Wingender c4d7dc42f2 Merge public/main (PR #5) into preview branch, resolve test_smoke.py append conflict 2026-08-26 13:13:49 -05:00
Jon Wingender 0d26f630e6 fix(preview): hung-frame watchdog + require approval for guessed action calls
A runaway preview script (sync infinite loop, or a re-render loop
outpacing the bootstrap's own coalescing) had nothing detecting it -
the frame just spun. The bootstrap now heartbeats every second, and the
parent tears the iframe down if it goes _WATCHDOG_MS silent, whatever
the cause.

_coerce_tool_calls recovers a tool call guessed from `content` for
models with no native tool_calls field. That guess is weaker evidence
than the API's own structured field - a model can land on JSON shaped
like a call while only meaning to describe one - so an action tool
recovered this way now always requires approval, even under the
"allow" policy that lets a native tool_calls field run unattended.
2026-08-26 13:08:58 -05:00
enderofwings 3e89df142b Merge pull request 'fix(runtime): close SQLite handles and harden Ollama I/O' (#5) from Athena/NexusOS:codex/runtime-reliability into main 2026-08-26 17:58:23 +00:00
Athena Kaminsky 9ed2908170 fix(tui): prioritize interrupt and quit keys
Declare Ctrl+C and Ctrl+D as priority Textual bindings so the focused prompt cannot consume them. Drive exit and silent-stream cancellation regressions through Pilot key events instead of calling action handlers directly.
2026-08-26 08:17:31 -05:00
Athena Kaminsky 9ca37057eb fix(tui): cancel silent streams promptly
Move chat streaming onto a cancellable async task so Ctrl+C interrupts a pending socket read on macOS instead of waiting for the 120-second read timeout. Add a headless silent-stream regression that verifies prompt recovery and a successful next message.
2026-08-26 08:17:31 -05:00
Athena Kaminsky da3509eb04 fix(tui): retain stream conversation for tool denial
Capture each stream's conversation ID before starting its worker so /new cannot redirect a later action denial. Add a headless regression that mutates the active conversation while a tool request is in flight.
2026-08-26 08:17:31 -05:00
Athena Kaminsky 6f5094b5fc fix(tui): preserve errors and deny gated tools safely
Keep stream failures in the persistent transcript instead of clearing them with the live preview. Use each tool request's capability token to deny actions immediately until the TUI has an interactive approval flow, and cover both behaviors with focused regressions.
2026-08-26 08:17:31 -05:00
Athena Kaminsky 1449280fcd feat(cli): add interactive TUI chat
Add a Textual chat interface with threaded SSE streaming, slash commands, interrupt handling, and bare nexus dispatch. Package it behind the tui extra, document usage, and cover command routing, dependencies, and headless interaction with tests.
2026-08-26 08:17:31 -05:00
Athena 5f67d19e80 fix(packaging): include backend modules in distributions 2026-08-26 08:17:25 -05:00
Athena KaminskyandClaude Opus 5 9104c724c4 feat(packaging): move the CLI into nexusos_cli and make the wheel self-sufficient
The CLI shipped from `management/`, which also holds desktop-only pieces (the
Tk control panel, the XFCE panel wiring, the shell wrappers). Packaging that
directory meant the wheel either dragged in tkinter or shipped a broken import.
Split it: `nexusos_cli/` is what the wheel ships and what `nexus`/`ncp`/
`nexusos` dispatch to, `management/` keeps the desktop half.

Alongside the move:

* hatch_build.py decides the interface/web/dist include at build time. dist/
  is gitignored, so a static force-include aborts `pip install -e .` on a
  fresh clone - before the reader reaches the `npm run build` step. Editable
  installs now skip a missing dist; wheels and sdists hard-error naming the
  command to run.
* synapse/proc_util.py gives frontend_manager and ncp process inspection and
  termination without psutil, which became an optional extra when the wheel
  landed. It routes around Windows having no signals, where os.kill(pid, 15)
  is an unblockable TerminateProcess rather than a polite request.
* nexusos_cli/monitor.py adds `ncp monitor`, an ASCII dashboard with no curses
  or rich dependency so it works in Termux, plain SSH and Windows Terminal.
  Collector and renderer are separate so tests feed fixtures, no stack needed.
* tests/test_packaging_deps.py fails the gate when synapse or nexusos_cli
  import a distribution pyproject does not declare, and when an optional
  dependency is imported at module scope instead of lazily.
* bin/check.sh now builds the wheel, twine-checks it, and asserts the compiled
  UI and seed playbooks are actually inside it. A wheel that builds but ships
  no dist/ serves a blank page, which only shows up after release.

tests/test_nexus_api.py moves to tests/ with the module it covers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 08:11:39 -05:00
Athena d579502a5b feat: add portable NexusOS CLI and packaging 2026-08-26 08:11:39 -05:00
Athena 952ef8a0c4 refactor(preview): simplify compile and validation paths
Load Sucrase only when a JSX/TSX preview is opened, remove the hand-written transform, and leave subjective render evaluation to the reader while retaining structural fence validation.
2026-08-26 03:37:29 -05:00
Athena 7262e7730e fix(preview): harden sandbox and transformation 2026-08-26 03:34:52 -05:00
AthenaandCursor 656c14caf3 feat(preview): add sandboxed live code previews
Render validated HTML, SVG, JSX, and TSX fences locally while preserving tool context and preventing explanatory JSON from triggering actions.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 03:34:19 -05:00
Athena 2ebe93b4f7 fix(ollama): normalize hosts, errors, and reasoning output
Separate bind and client addresses, include Ollama's response body in HTTP failures, and strip inline <think> blocks from complete and streamed replies.
2026-08-26 03:32:45 -05:00
Athena d56d579755 fix(memory): sweep legacy orphaned message vectors
Repair vector rows left behind by older databases at store startup. Keep the existing single-statement delete path from main and avoid reintroducing the redundant batched helper.
2026-08-26 03:32:34 -05:00
Athena c3a7b6eefd fix(sync): close SQLite handles before restore
Use contextlib.closing for dump, comparison, and restore connections so Windows can unlink the live database immediately after the comparison step.
2026-08-26 03:32:15 -05:00
47 changed files with 465 additions and 6600 deletions
-1
View File
@@ -13,7 +13,6 @@ synapse/memory/memory.db
synapse/memory/memory.db-wal synapse/memory/memory.db-wal
synapse/memory/memory.db-shm synapse/memory/memory.db-shm
assets/gitnexus-logo.svg assets/gitnexus-logo.svg
/data/curry.db
*.db-wal *.db-wal
*.db-shm *.db-shm
.DS_Store .DS_Store
+1 -40
View File
@@ -37,17 +37,6 @@ desktop shortcut runs and still works directly.
``` ```
This activates the `Promethean` venv and starts the Synapse backend on port 8000 (which also serves the built UI). It additionally starts a **Vite dev server** for frontend hot-reload — a Linux-dev convenience, unlike the single-process Windows/production path where the backend serves `dist/` alone. It does **not** start Ollama. This activates the `Promethean` venv and starts the Synapse backend on port 8000 (which also serves the built UI). It additionally starts a **Vite dev server** for frontend hot-reload — a Linux-dev convenience, unlike the single-process Windows/production path where the backend serves `dist/` alone. It does **not** start Ollama.
**macOS (community-supported):**
```bash
./install-macos.sh # one-time: Homebrew packages + venv + web build, via bin/sync.py
./launch_nexus.sh # same script as Linux - it's plain bash, no Linux-only calls
```
No bundled Ollama binary (Linux x86-64 only) and no XFCE desktop branding — both
already no-op on macOS via `bin/sync.py`'s `linux_stage()`. Ollama is instead the
Homebrew-installed native binary, picked up automatically because
`OllamaManager` falls back to `ollama` on PATH when the bundled binary is
absent; that gets full Metal GPU acceleration with no extra config.
**Individual services via CLI:** **Individual services via CLI:**
```bash ```bash
# From nexus-core/ with Promethean venv active: # From nexus-core/ with Promethean venv active:
@@ -106,7 +95,7 @@ cd interface/web && npm run build
## Architecture ## Architecture
### Python venv ### Python venv
All Python code runs inside `Promethean/` (a local venv). Always activate it before running backend commands: `source Promethean/bin/activate`. Dependencies are layered: `requirements-base.txt` holds the GPU-agnostic core (nothing in it needs a GPU or imports torch), and a thin overlay per platform sets the right PyTorch package index — `requirements-amd.txt` (ROCm), `requirements-nvidia.txt` (CUDA, generated by `bin/gen-nvidia-reqs.py`), or `requirements-windows.txt` (CPU-only, standalone). `bin/sync.py` (`requirements()`) selects NVIDIA, AMD, CPU/Windows, or — via an explicit `sys.platform == "darwin"` check, since `os.name` alone can't tell macOS apart from Linux — `requirements-base.txt` with no overlay at all for macOS, from the host, and installs that alone by default — fast, no multi-GB downloads. All Python code runs inside `Promethean/` (a local venv). Always activate it before running backend commands: `source Promethean/bin/activate`. Dependencies are layered: `requirements-base.txt` holds the GPU-agnostic core (nothing in it needs a GPU or imports torch), and a thin overlay per platform sets the right PyTorch package index — `requirements-amd.txt` (ROCm), `requirements-nvidia.txt` (CUDA, generated by `bin/gen-nvidia-reqs.py`), or `requirements-windows.txt` (CPU-only, standalone). `bin/sync.py` (`requirements()`) selects NVIDIA, AMD, or CPU/Windows requirements from the host and installs that alone by default — fast, no multi-GB downloads.
`requirements-ml.txt` is a separate, **opt-in** overlay for local ML inference (transformers/accelerate/bitsandbytes + torch/torchaudio/torchvision) — nothing in `synapse/` imports any of it; Ollama does all inference over HTTP. Only pull it in for local model work outside Ollama: `pip install -r requirements-amd.txt -r requirements-ml.txt` (or `-nvidia`, or alone for CPU-only torch). Not installed by `bin/sync.py`/the installers. `requirements-ml.txt` is a separate, **opt-in** overlay for local ML inference (transformers/accelerate/bitsandbytes + torch/torchaudio/torchvision) — nothing in `synapse/` imports any of it; Ollama does all inference over HTTP. Only pull it in for local model work outside Ollama: `pip install -r requirements-amd.txt -r requirements-ml.txt` (or `-nvidia`, or alone for CPU-only torch). Not installed by `bin/sync.py`/the installers.
@@ -134,37 +123,9 @@ A bundled Ollama binary lives at `ollama/bin/ollama`. `OllamaManager` in `synaps
### Frontend (`interface/web/`) ### Frontend (`interface/web/`)
React 19 + Vite. No routing library — `App.jsx` manages page state in a single `currentPage` useState. All API calls hit `http://localhost:8000` (configured in `src/config.js`). Built to `dist/` (gitignored) via `npm run build` and served by the backend at `:8000` — the mount is in `synapse/main.py` (`_DIST` at `/`, guarded by `is_dir()`), so `dist/` must be built for the UI to appear. Pages: Chatbot, Playbook editor, Conversation History, Models, Memory, Settings, Logs. React 19 + Vite. No routing library — `App.jsx` manages page state in a single `currentPage` useState. All API calls hit `http://localhost:8000` (configured in `src/config.js`). Built to `dist/` (gitignored) via `npm run build` and served by the backend at `:8000` — the mount is in `synapse/main.py` (`_DIST` at `/`, guarded by `is_dir()`), so `dist/` must be built for the UI to appear. Pages: Chatbot, Playbook editor, Conversation History, Models, Memory, Settings, Logs.
### Code Tracks (`synapse/tools.py` + `synapse/code_run.py`)
Two separate tools, split by *where the code runs*:
- **`render_preview`** — validates markup and returns a fence the chat renders in
an opaque-origin `sandbox="allow-scripts"` iframe. Nothing executes
server-side. Languages: `PREVIEW_LANGS` in `synapse/tools.py`, mirrored by
`interface/web/src/preview/languages.js`.
- **`run_snippet`** — compiles and runs a single file on the host via
`synapse/code_run.py`, and returns a ```nexus-run fence carrying the source and
its captured output. Languages: `RUN_LANGS` in `synapse/code_run.py`, mirrored
by `interface/web/src/preview/run-langs.js`.
Each pair of registries is asserted equal by `tests/test_tools.py` — nothing
couples them at runtime, so drift fails the check gate instead of silently
degrading in the chat.
`run_snippet` is an **action tool**: `action_tool_policy` gates it (`off` by
default, `ask` = per-call Approve/Deny in chat). Read the `code_run.py` module
docstring before touching it — it runs code as the current user and is explicit
about which of its five layers are load-bearing and which are only a tripwire.
### Persistent Storage ### Persistent Storage
Most data lands in `synapse/memory/memory.db` (SQLite, WAL mode). Tables: memory facts, conversations, messages, app settings. `synapse/memory/store.py` (`PersistentMemoryStore`) owns the schema and all queries. Playbooks are the exception — they live as YAML files in `data/playbooks/` (see Playbook System). `nexus_config.py` defines all paths; it also ensures all required directories exist on import. Most data lands in `synapse/memory/memory.db` (SQLite, WAL mode). Tables: memory facts, conversations, messages, app settings. `synapse/memory/store.py` (`PersistentMemoryStore`) owns the schema and all queries. Playbooks are the exception — they live as YAML files in `data/playbooks/` (see Playbook System). `nexus_config.py` defines all paths; it also ensures all required directories exist on import.
### Curry (`synapse/curry_core.py` + `synapse/curry_store.py`)
`curry_core.py` is vendored from [Athena-Pro/Curry](https://github.com/Athena-Pro/Curry), with two deliberate deviations from upstream documented in the file's own docstring (a sandbox-escape fix and a `check_same_thread=False` connection fix) — an immutable, versioned fact store (constants, functions, model registrations, inference provenance) backed by its own SQLite file (`CURRY_DB` in `nexus_config.py`, separate from `memory.db`). `curry_store.py` opens it into a module-level singleton (`curry_db`) at import time — the same pattern as `memory.store.store` / `playbooks.store.playbook_store` — so it's preloaded and callable from anywhere in the backend without extra setup. It ships inside the wheel (`bin/check.sh`'s packaging gate asserts this) and has no external dependencies of its own. Ten `curry_*` tools in `tools.py` expose it to chat (`curry_declare_constant`, `curry_call_function`, etc.); the five that write or execute are ACTION tools in `ALWAYS_ASK_ACTION_TOOLS`, same approval floor as `edit_source`. Re-sync `curry_core.py` from upstream by hand, not by script.
### Direct tool invocation (`synapse/slash_commands.py`)
A chat message that's nothing but `/tool_name(arg=val, ...)` (Python-call-shaped, arguments parsed via `ast.literal_eval` only — no names, no calls, no attribute access) dispatches straight through `tools.dispatch()`, skipping model selection, context assembly, and the ask-policy approval round-trip. A human typing it is the approval. Wired into `chat_stream_endpoint` as an early short-circuit; the TUI's `_handle_slash` falls through to the backend for anything shaped like a tool call that isn't one of its own local meta-commands (`/help`, `/model`, `/new`).
### Logs & Runtime State ### Logs & Runtime State
- `runtime/backend.log`, `runtime/frontend.log`, `runtime/memory.log` — service stdout - `runtime/backend.log`, `runtime/frontend.log`, `runtime/memory.log` — service stdout
- `runtime/logs/ollama.log`, `runtime/logs/chat.log` - `runtime/logs/ollama.log`, `runtime/logs/chat.log`
+8 -39
View File
@@ -5,10 +5,10 @@
# NexusOS # NexusOS
**A local-first AI assistant platform.** Runs entirely on your machine — a **A local-first AI assistant platform.** Runs entirely on your machine — a
Python/FastAPI backend, a bundled Ollama instance for inference, persistent Python/FastAPI backend, an Ollama-compatible endpoint for inference, a
memory, and a React frontend. No external AI provider is called. Ollama is persistent memory service, and a React frontend. Ollama is local by default;
local by default; Termux and container installs can explicitly point at a Termux and container installs can explicitly point at a separately managed
separately managed endpoint. endpoint.
</div> </div>
@@ -134,8 +134,7 @@ ncp web
Python deps are layered: `requirements-base.txt` (GPU-agnostic core) plus one Python deps are layered: `requirements-base.txt` (GPU-agnostic core) plus one
GPU overlay — `requirements-amd.txt` (ROCm) or `requirements-nvidia.txt` (CUDA). GPU overlay — `requirements-amd.txt` (ROCm) or `requirements-nvidia.txt` (CUDA).
`requirements-windows.txt` is the standalone CPU-only runtime (no base overlay). `requirements-windows.txt` is the standalone CPU-only runtime (no base overlay).
macOS uses `requirements-base.txt` directly, no overlay — see the macOS section `bin/sync.py` picks the right one for the host.
below. `bin/sync.py` picks the right one for the host.
`./install.sh` is also the update path — re-run it any time to pull the latest `./install.sh` is also the update path — re-run it any time to pull the latest
and rebuild. `--check` dry-runs it; `--no-desktop` skips the XFCE panel/theme and rebuild. `--check` dry-runs it; `--no-desktop` skips the XFCE panel/theme
@@ -173,38 +172,10 @@ The app opens at `:8000`; click **Start AI** to launch Ollama. The installer
uses `requirements-windows.txt` (CPU-only, pure-Python — no ML stack, since Ollama uses `requirements-windows.txt` (CPU-only, pure-Python — no ML stack, since Ollama
does all inference over HTTP). does all inference over HTTP).
### macOS
Community-supported — no bundled Ollama binary or XFCE desktop branding (that
stage is Linux/XFCE-only and already skips itself here), but the
backend/frontend/Ollama stack itself runs natively, no VM or container needed.
```bash
# 1. Install Homebrew first if you don't have it: https://brew.sh
# 2. Build everything: Homebrew packages (Python, Node, git, Ollama), venv,
# web UI, memory DB.
./install-macos.sh
# 3. Launch (memory :8001, backend :8000 — backend also serves the built UI)
./launch_nexus.sh
```
Ollama here is the Homebrew-installed native binary, not the Linux-only bundled
one — `OllamaManager` already falls back to `ollama` on PATH when
`ollama/bin/ollama` doesn't exist, so **Start AI** in the sidebar (or `ollama
serve` in a terminal) uses it with full Metal GPU acceleration automatically,
no configuration needed.
`./install-macos.sh` is also the update path, same idea as Linux — re-run it
any time to pull the latest and rebuild; `--check` dry-runs it. It's a thin
wrapper over `bin/sync.py restore`, the same code Linux and `ncp restore`
(any platform) run.
### Individual services ### Individual services
```bash ```bash
# Linux / macOS # Linux
source Promethean/bin/activate source Promethean/bin/activate
uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload # backend (serves the UI too) uvicorn synapse.main:sio_app --host 127.0.0.1 --port 8000 --reload # backend (serves the UI too)
@@ -228,7 +199,7 @@ Nexus's dependencies out of the system Python. To add a package, activate it
and `pip install` as usual: and `pip install` as usual:
```bash ```bash
# Linux / macOS # Linux
source Promethean/bin/activate source Promethean/bin/activate
pip install <package> pip install <package>
``` ```
@@ -303,9 +274,7 @@ are the exception (YAML files in `data/playbooks/`). All paths are defined in
- **Filesystem paths** — `synapse/nexus_config.py` - **Filesystem paths** — `synapse/nexus_config.py`
- **Frontend API base URL** — `interface/web/src/config.js` - **Frontend API base URL** — `interface/web/src/config.js`
- **Python deps** — `requirements-base.txt` + amd/nvidia GPU overlay; - **Python deps** — `requirements-base.txt` + amd/nvidia GPU overlay;
`requirements-windows.txt` = standalone CPU runtime; macOS uses `requirements-windows.txt` = standalone CPU runtime
`requirements-base.txt` with no overlay (Ollama, not this venv, does
inference — natively, with Metal)
## Issues and feature requests ## Issues and feature requests
+7 -17
View File
@@ -9,18 +9,14 @@ cd "$(dirname "$0")/.."
fail=0 fail=0
if [ -x Promethean/bin/python ]; then if [ ! -x Promethean/bin/python ]; then
NEXUS_CHECK_PY=Promethean/bin/python
elif [ -x Promethean/Scripts/python.exe ]; then
NEXUS_CHECK_PY=Promethean/Scripts/python.exe
else
echo "!! no Promethean venv - run ./install.sh first" >&2 echo "!! no Promethean venv - run ./install.sh first" >&2
exit 1 exit 1
fi fi
echo "== pytest ==" echo "== pytest =="
# Explicit dirs: a bare `pytest` would walk Promethean/ and node_modules too. # Explicit dirs: a bare `pytest` would walk Promethean/ and node_modules too.
"$NEXUS_CHECK_PY" -m pytest -q tests management || fail=1 Promethean/bin/python -m pytest -q tests management || fail=1
echo "== eslint ==" echo "== eslint =="
if [ -d interface/web/node_modules ]; then if [ -d interface/web/node_modules ]; then
@@ -54,20 +50,20 @@ else
fi fi
echo "== shell parse ==" echo "== shell parse =="
for f in scripts/install-termux.sh install-macos.sh launch_nexus.sh management/nexus-cli.sh; do for f in scripts/install-termux.sh launch_nexus.sh management/nexus-cli.sh; do
[ -f "$f" ] && { bash -n "$f" || fail=1; } [ -f "$f" ] && { bash -n "$f" || fail=1; }
done done
echo "== packaging ==" echo "== packaging =="
# The wheel is the other shippable artifact, so it belongs in the same gate: # The wheel is the other shippable artifact, so it belongs in the same gate:
# a broken pyproject or a missing web build only shows up at build time. # a broken pyproject or a missing web build only shows up at build time.
if "$NEXUS_CHECK_PY" -c "import build, twine" 2>/dev/null; then if Promethean/bin/python -c "import build, twine" 2>/dev/null; then
rm -rf .build-check rm -rf .build-check
if "$NEXUS_CHECK_PY" -m build --outdir .build-check >/dev/null 2>&1; then if Promethean/bin/python -m build --outdir .build-check >/dev/null 2>&1; then
"$NEXUS_CHECK_PY" -m twine check .build-check/* || fail=1 Promethean/bin/python -m twine check .build-check/* || fail=1
# The compiled UI has to actually be inside the wheel - a wheel that # The compiled UI has to actually be inside the wheel - a wheel that
# builds but ships no dist/ serves a blank page. # builds but ships no dist/ serves a blank page.
"$NEXUS_CHECK_PY" - <<'PY' || fail=1 Promethean/bin/python - <<'PY' || fail=1
import glob, sys, zipfile import glob, sys, zipfile
wheels = glob.glob(".build-check/*.whl") wheels = glob.glob(".build-check/*.whl")
if not wheels: if not wheels:
@@ -77,12 +73,6 @@ if not any(n.startswith("synapse/_resources/web/") for n in names):
sys.exit("wheel is missing the compiled web UI (cd interface/web && npm run build)") sys.exit("wheel is missing the compiled web UI (cd interface/web && npm run build)")
if not any(n.startswith("synapse/_resources/playbooks/") for n in names): if not any(n.startswith("synapse/_resources/playbooks/") for n in names):
sys.exit("wheel is missing the seed playbooks") sys.exit("wheel is missing the seed playbooks")
if "synapse/curry_core.py" not in names or "synapse/curry_store.py" not in names:
sys.exit("wheel is missing vendored Curry (synapse/curry_core.py / curry_store.py)")
if "synapse/slash_commands.py" not in names:
sys.exit("wheel is missing synapse/slash_commands.py")
if not any(n.startswith("modules/") for n in names):
sys.exit("wheel is missing the modules/ package (mail, network) - check pyproject.toml packages=[...]")
print(f"wheel OK: {len(names)} files") print(f"wheel OK: {len(names)} files")
PY PY
else else
+3 -12
View File
@@ -66,11 +66,9 @@ def ensure_exec_bits() -> None:
def linux_stage(script: str, *args) -> None: def linux_stage(script: str, *args) -> None:
"""Run one of the Linux-only bash stages. A no-op on Windows and macOS, """Run one of the Linux-only bash stages. A no-op on Windows, where apt,
where apt, xfconf, plank and the rest have nothing to act on. os.name is xfconf, plank and the rest have nothing to act on."""
'posix' on both Linux and macOS, so the Windows-only os.name check alone if os.name == "nt":
doesn't exclude macOS - needs the explicit darwin check too."""
if os.name == "nt" or sys.platform == "darwin":
return return
path = ROOT / "bin" / script path = ROOT / "bin" / script
bash = shutil.which("bash") bash = shutil.which("bash")
@@ -111,13 +109,6 @@ def requirements() -> str:
"""Pick the PyTorch overlay for this host.""" """Pick the PyTorch overlay for this host."""
if os.name == "nt": if os.name == "nt":
return "requirements-windows.txt" # CPU / pure-Python, right for native Windows return "requirements-windows.txt" # CPU / pure-Python, right for native Windows
if sys.platform == "darwin":
# No ROCm/CUDA overlay applies here, and none is needed: Ollama does
# all inference over HTTP (see requirements-ml.txt), and on macOS
# that's a natively-installed, Metal-accelerated Ollama binary
# (OllamaManager falls back to it on PATH - see synapse/ollama_manager.py),
# entirely outside this venv.
return "requirements-base.txt"
if shutil.which("nvidia-smi"): if shutil.which("nvidia-smi"):
return "requirements-nvidia.txt" return "requirements-nvidia.txt"
lspci = shutil.which("lspci") lspci = shutil.which("lspci")
-43
View File
@@ -1,43 +0,0 @@
#!/bin/bash
# NexusOS installer, macOS. One painless command:
#
# git clone <repo> nexus-core && cd nexus-core && ./install-macos.sh
#
# Mirrors install.sh's philosophy: every portable step - git pull, venv, pip
# with the right overlay, npm build - lives in bin/sync.py, shared with Linux
# and Windows. This script only does what sync.py can't do for itself on a
# bare Mac: install the Homebrew packages needed before Python even exists to
# run sync.py with. Re-run any time to update; --check dry-runs it.
#
# Ollama itself is *not* fetched here - bin/fetch-ollama.sh only ships a Linux
# x86-64 binary, and linux_stage() in bin/sync.py already no-ops on macOS, so
# that stage is skipped entirely. The Homebrew `ollama` installed below is
# picked up automatically instead: synapse/ollama_manager.py prefers the
# bundled Linux binary and falls back to whatever `ollama` it finds on PATH,
# which on macOS is this one - with full Metal GPU acceleration, no flags
# needed. The XFCE desktop branding (theme/panel/splash) is Linux-only and
# already gated off macOS the same way; nothing to install for it here.
set -euo pipefail
cd "$(dirname "$0")"
if ! command -v brew >/dev/null; then
echo "Homebrew is required (it installs Python/Node/git/Ollama)." >&2
echo "Install it, then re-run this script: https://brew.sh" >&2
exit 1
fi
echo "Installing/checking system packages (python@3.12, node, git, ollama)..."
brew install python@3.12 node git ollama
py="$(brew --prefix python@3.12)/bin/python3.12"
if [ ! -x "$py" ]; then
echo "python3.12 not found at $py after brew install - check 'brew doctor'." >&2
exit 1
fi
# Prefer the venv interpreter once it exists, same as install.sh; the brewed
# interpreter above is only the bootstrap case on a fresh clone. sync.py is
# stdlib-only either way.
[ -x "Promethean/bin/python" ] && py="Promethean/bin/python"
exec "$py" bin/sync.py restore "$@"
+1 -1
View File
@@ -10,7 +10,7 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint .",
"test": "node --test \"src/preview/*.test.js\"", "test": "node --test src/preview/jsx-transform.test.js",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
+6 -108
View File
@@ -2,7 +2,6 @@ import { useState, useRef, useEffect } from "react";
import { API_BASE } from "./config"; import { API_BASE } from "./config";
import { Markdown } from "./Markdown"; import { Markdown } from "./Markdown";
import { diffLines, DIFF_LINE_COLOR, keyValueDiffLines } from "./preview/diff-view.js";
export function Chatbot({ visible = true, conversationId, setConversationId, onConversationChanged }) { export function Chatbot({ visible = true, conversationId, setConversationId, onConversationChanged }) {
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
@@ -735,16 +734,13 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
<div style={{ marginBottom: "0.5rem", padding: "0.7rem 0.9rem", background: "#2a2418", border: "1px solid #6a5a2a", borderRadius: "10px" }}> <div style={{ marginBottom: "0.5rem", padding: "0.7rem 0.9rem", background: "#2a2418", border: "1px solid #6a5a2a", borderRadius: "10px" }}>
<div style={{ color: "#e8c65a", fontSize: "0.9rem", marginBottom: "0.5rem" }}> <div style={{ color: "#e8c65a", fontSize: "0.9rem", marginBottom: "0.5rem" }}>
The assistant wants to run: The assistant wants to run:
{" "}
{pendingApproval.map((a, i) => (
<code key={i} style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px", marginRight: "0.35rem" }}>
{a.name}({a.arguments ? Object.values(a.arguments).join(", ") : ""})
</code>
))}
</div> </div>
{pendingApproval.map((a, i) =>
a.preview
? <ActionPreview key={i} action={a} />
: (
<code key={i} style={{ display: "inline-block", color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px", marginRight: "0.35rem", marginBottom: "0.4rem" }}>
{a.name}({a.arguments ? Object.values(a.arguments).join(", ") : ""})
</code>
)
)}
<div style={{ display: "flex", gap: "0.5rem" }}> <div style={{ display: "flex", gap: "0.5rem" }}>
<button onClick={() => resolveApproval(true)} <button onClick={() => resolveApproval(true)}
style={{ padding: "0.4rem 1rem", background: "#2a5a2a", color: "#8aff8a", border: "1px solid #3a7a3a", borderRadius: "8px", cursor: "pointer" }}> style={{ padding: "0.4rem 1rem", background: "#2a5a2a", color: "#8aff8a", border: "1px solid #3a7a3a", borderRadius: "8px", cursor: "pointer" }}>
@@ -860,102 +856,4 @@ export function Chatbot({ visible = true, conversationId, setConversationId, onC
</div> </div>
</div> </div>
); );
}
// One self-edit tool's approval preview: a real, server-computed diff instead
// of the flat "name(arg, arg)" one-liner used for every other action tool.
// This is what makes "always ask first" mean actual informed consent for
// edit_source/edit_playbook/edit_settings — the human reviews what will
// actually change, not a description of it. See synapse/self_edit.py's
// preview_* functions, which compute exactly what's rendered here.
function ActionPreview({ action }) {
const { name, preview } = action;
const banner = (text) => (
<div style={{
padding: "0.4rem 0.6rem", marginBottom: "0.4rem", background: "#3a2a10",
border: "1px solid #8a6a2a", borderRadius: "6px", color: "#ffd580",
fontSize: "0.8rem", fontWeight: 600,
}}>
{text}
</div>
);
const diffBox = (lines) => (
<pre style={{
background: "#0d0d0d", border: "1px solid #333", borderRadius: "6px",
padding: "0.5rem 0.7rem", margin: "0 0 0.4rem", fontSize: "0.8rem",
lineHeight: "1.4", maxHeight: "16rem", overflow: "auto",
}}>
{lines.length === 0 && <span style={{ color: "#666" }}>(no changes)</span>}
{lines.map((l, i) => (
<div key={i} style={{ color: DIFF_LINE_COLOR[l.kind], whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{l.text || " "}
</div>
))}
</pre>
);
if (!preview.ok) {
return (
<div style={{ marginBottom: "0.5rem" }}>
<div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
<code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>{name}</code>
{" — this will fail:"}
</div>
<div style={{ color: "#ff8a80", fontSize: "0.8rem", marginBottom: "0.4rem" }}>{preview.error}</div>
</div>
);
}
if (name === "edit_source") {
return (
<div style={{ marginBottom: "0.5rem" }}>
<div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
<code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>edit_source</code>
{" "}{preview.path}{preview.is_new_file ? " (new file)" : ""}
</div>
{diffBox(diffLines(preview.diff))}
</div>
);
}
if (name === "edit_playbook") {
const keys = ["title", "goal", "instructions", "tags", "tools", "model"];
const lines = keyValueDiffLines(preview.before, preview.after, keys);
return (
<div style={{ marginBottom: "0.5rem" }}>
<div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
<code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>edit_playbook</code>
{" "}{preview.is_new ? "(new playbook)" : preview.after?.title}
</div>
{preview.becomes_main_playbook && banner("this will become the active system prompt")}
{diffBox(lines)}
</div>
);
}
if (name === "edit_settings") {
const before = {}, after = {};
for (const [k, v] of Object.entries(preview.applied || {})) {
before[k] = v.before;
after[k] = v.after;
}
const lines = keyValueDiffLines(before, after, Object.keys(preview.applied || {}));
return (
<div style={{ marginBottom: "0.5rem" }}>
<div style={{ fontSize: "0.85rem", color: "#ccc", marginBottom: "0.3rem" }}>
<code style={{ color: "#fff", background: "#000", padding: "0.05rem 0.35rem", borderRadius: "4px" }}>edit_settings</code>
</div>
{preview.policy_change && banner("this changes the tool-approval policy itself")}
{preview.system_prompt_change && banner("this changes the fallback system prompt")}
{diffBox(lines)}
{preview.ignored_unknown && preview.ignored_unknown.length > 0 && (
<div style={{ fontSize: "0.75rem", color: "#888" }}>
ignored (not a real setting): {preview.ignored_unknown.join(", ")}
</div>
)}
</div>
);
}
return null;
} }
+40 -248
View File
@@ -6,14 +6,6 @@ import { useEffect, useRef, useState } from "react";
// auto-executing bare script isn't this feature's job (see RenderBlock's doc // auto-executing bare script isn't this feature's job (see RenderBlock's doc
// comment for the sandboxing model). // comment for the sandboxing model).
import { PREVIEW_LANGS, RENDERABLE_LANGS } from "./preview/languages.js"; import { PREVIEW_LANGS, RENDERABLE_LANGS } from "./preview/languages.js";
// The execution track's display half: a ```nexus-run fence is a run result
// (source + captured output), not a program to execute here. See run-langs.js.
import { RUN_FENCE_LANG, parseRunResult } from "./preview/run-langs.js";
// Self-modification's display half: a ```nexus-edit fence is the result of an
// already-applied, already-approved edit_source/edit_playbook/edit_settings
// call. See self-edit-langs.js.
import { EDIT_FENCE_LANG, parseEditResult } from "./preview/self-edit-langs.js";
import { diffLines, DIFF_LINE_COLOR, keyValueDiffLines } from "./preview/diff-view.js";
// Parse content into an array of {type, value, lang, streaming} blocks. // Parse content into an array of {type, value, lang, streaming} blocks.
// Handles: // Handles:
@@ -37,11 +29,9 @@ function parseBlocks(content) {
let j = fenceStart + 3; let j = fenceStart + 3;
let lang = ""; let lang = "";
// Language specifier is valid only when tag-chars are followed by a newline. // Language specifier is valid only when word-chars are followed by a newline.
// If there's no newline (e.g. ```pythonprint(...)) treat everything as code. // If there's no newline (e.g. ```pythonprint(...)) treat everything as code.
// Hyphens count: `nexus-run` is a tag this file dispatches on, and real const langMatch = content.slice(j).match(/^(\w+)(\r?\n)/);
// languages spell themselves that way too (objective-c, c-sharp).
const langMatch = content.slice(j).match(/^([\w-]+)(\r?\n)/);
if (langMatch) { if (langMatch) {
lang = langMatch[1]; lang = langMatch[1];
j += langMatch[0].length; j += langMatch[0].length;
@@ -71,16 +61,6 @@ export function Markdown({ content }) {
{blocks.map((block, i) => { {blocks.map((block, i) => {
if (block.type !== "code") return <TextBlock key={i} text={block.value} />; if (block.type !== "code") return <TextBlock key={i} text={block.value} />;
const lang = (block.lang || "").toLowerCase(); const lang = (block.lang || "").toLowerCase();
if (lang === RUN_FENCE_LANG) {
// A half-streamed envelope is not parseable JSON, so the block shows
// as code until the fence closes and then becomes the run panel.
const run = block.streaming ? null : parseRunResult(block.value);
if (run) return <RunBlock key={i} run={run} />;
}
if (lang === EDIT_FENCE_LANG) {
const edit = block.streaming ? null : parseEditResult(block.value);
if (edit) return <EditBlock key={i} edit={edit} />;
}
return RENDERABLE_LANGS.has(lang) return RENDERABLE_LANGS.has(lang)
? <RenderBlock key={i} lang={lang} value={block.value} streaming={block.streaming} /> ? <RenderBlock key={i} lang={lang} value={block.value} streaming={block.streaming} />
: <CodeBlock key={i} lang={block.lang} value={block.value} streaming={block.streaming} />; : <CodeBlock key={i} lang={block.lang} value={block.value} streaming={block.streaming} />;
@@ -146,229 +126,6 @@ function CodeBlock({ lang, value, streaming }) {
); );
} }
// A finished run: the source that was executed and what it printed, in one
// block with an Output/Code toggle.
//
// Nothing executes in the browser here, which is the whole difference from
// RenderBlock below. The program already ran on the host (synapse/code_run.py)
// under the user's per-call approval; by the time this renders, the result is
// history. So there is no iframe, no CSP and no sandbox in this component - the
// only untrusted thing present is *text*, and React escapes it.
//
// Output and stderr are shown together rather than on separate tabs: a program
// that printed three lines and then panicked is telling one story, and splitting
// it hides which half the reader needs. Exit code sits in the header because a
// silent non-zero exit is otherwise invisible.
function RunBlock({ run }) {
const [tab, setTab] = useState("output");
const [copied, setCopied] = useState(false);
const copy = () => {
navigator.clipboard.writeText(run.source.trimEnd()).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
const failed = run.exitCode !== null && run.exitCode !== 0;
const empty = !run.stdout.trim() && !run.stderr.trim();
return (
<div style={{
background: "#0d0d0d",
border: "1px solid #2a2a2a",
borderRadius: "6px",
margin: "0.5rem 0",
overflow: "hidden",
}}>
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.3rem 0.75rem",
background: "#161616",
borderBottom: "1px solid #2a2a2a",
}}>
<div style={{ display: "flex", alignItems: "center", gap: "0.25rem" }}>
<TabButton active={tab === "output"} onClick={() => setTab("output")}>
Output
</TabButton>
<TabButton active={tab === "code"} onClick={() => setTab("code")}>
Code
</TabButton>
<span style={{ fontSize: "0.7rem", color: "#555", fontFamily: "monospace", marginLeft: "0.25rem" }}>
{run.label}
</span>
</div>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
{run.exitCode !== null && (
<span style={{
fontSize: "0.7rem",
fontFamily: "monospace",
color: failed ? "#ff8a80" : "#4caf50",
}}>
exit {run.exitCode}
</span>
)}
<button onClick={copy} style={_chromeButtonStyle(copied ? "#4caf50" : "#555")}>
{copied ? "Copied!" : "Copy"}
</button>
</div>
</div>
{tab === "output" ? (
<div style={{
padding: "0.75rem 1rem",
fontSize: "0.85rem",
lineHeight: "1.5",
fontFamily: "monospace",
maxHeight: "24rem",
overflow: "auto",
}}>
{empty && (
<span style={{ color: "#555" }}>
(the program printed nothing)
</span>
)}
{run.stdout && (
<pre style={{ margin: 0, whiteSpace: "pre-wrap", wordBreak: "break-word", color: "#ddd" }}>
{run.stdout.replace(/\n$/, "")}
</pre>
)}
{run.stderr && (
<pre style={{
margin: run.stdout ? "0.5rem 0 0" : 0,
whiteSpace: "pre-wrap",
wordBreak: "break-word",
color: "#ff8a80",
}}>
{run.stderr.replace(/\n$/, "")}
</pre>
)}
</div>
) : (
<pre style={{
padding: "0.75rem 1rem",
overflowX: "auto",
fontSize: "0.85rem",
lineHeight: "1.5",
margin: 0,
fontFamily: "monospace",
}}>
<code>{run.source.trimEnd()}</code>
</pre>
)}
</div>
);
}
// A finished self-edit: the diff/change that was actually applied, plus (for
// edit_source) the git commit it landed as. Like RunBlock, nothing executes
// here the edit already happened on the backend under approval, and by the
// time this renders it's history. Styled the same way as CodeBlock/RunBlock
// for visual consistency, with diff lines colored via the shared
// diff-view.js vocabulary rather than a syntax-highlighting library.
function EditBlock({ edit }) {
const [copied, setCopied] = useState(false);
const copyText =
edit.kind === "source" ? edit.diff
: edit.kind === "playbook" ? edit.instructions
: JSON.stringify(edit.applied, null, 2);
const copy = () => {
navigator.clipboard.writeText((copyText || "").trimEnd()).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
};
const title =
edit.kind === "source" ? `edited ${edit.path}`
: edit.kind === "playbook" ? `playbook: ${edit.title || edit.id}`
: "settings changed";
const lines =
edit.kind === "source" ? diffLines(edit.diff)
: edit.kind === "settings"
? keyValueDiffLines(
Object.fromEntries(Object.entries(edit.applied).map(([k, v]) => [k, v.before])),
Object.fromEntries(Object.entries(edit.applied).map(([k, v]) => [k, v.after])),
Object.keys(edit.applied),
)
: [];
return (
<div style={{
background: "#0d0d0d",
border: "1px solid #2a2a2a",
borderRadius: "6px",
margin: "0.5rem 0",
overflow: "hidden",
}}>
<div style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "0.3rem 0.75rem",
background: "#161616",
borderBottom: "1px solid #2a2a2a",
}}>
<span style={{ fontSize: "0.75rem", color: "#888", fontFamily: "monospace" }}>
{title}
</span>
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
{edit.commit && (
<span style={{ fontSize: "0.7rem", fontFamily: "monospace", color: "#7a92a8" }}>
commit {edit.commit}
</span>
)}
<button onClick={copy} style={_chromeButtonStyle(copied ? "#4caf50" : "#555")}>
{copied ? "Copied!" : "Copy"}
</button>
</div>
</div>
{edit.kind === "playbook" && edit.isMainPlaybook && (
<div style={{
padding: "0.4rem 0.75rem", background: "#2a2418", color: "#e8c65a",
fontSize: "0.8rem", fontWeight: 600, borderBottom: "1px solid #2a2a2a",
}}>
this is now the active system prompt
</div>
)}
{(edit.kind === "settings") && (edit.policyChange || edit.systemPromptChange) && (
<div style={{
padding: "0.4rem 0.75rem", background: "#2a2418", color: "#e8c65a",
fontSize: "0.8rem", fontWeight: 600, borderBottom: "1px solid #2a2a2a",
}}>
{edit.policyChange && "changed the tool-approval policy"}
{edit.policyChange && edit.systemPromptChange && " and "}
{edit.systemPromptChange && "changed the fallback system prompt"}
</div>
)}
{edit.kind === "playbook" ? (
<pre style={{
padding: "0.75rem 1rem", overflowX: "auto", fontSize: "0.85rem",
lineHeight: "1.5", margin: 0, fontFamily: "monospace", color: "#ccc",
}}>
<code>{(edit.instructions || "").trimEnd()}</code>
</pre>
) : (
<pre style={{
padding: "0.75rem 1rem", overflowX: "auto", fontSize: "0.85rem",
lineHeight: "1.5", margin: 0, fontFamily: "monospace",
maxHeight: "24rem", overflowY: "auto",
}}>
{lines.map((l, i) => (
<div key={i} style={{ color: DIFF_LINE_COLOR[l.kind], whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{l.text || " "}
</div>
))}
</pre>
)}
</div>
);
}
// Content-Security-Policy for the rendered preview. Together with the iframe's // Content-Security-Policy for the rendered preview. Together with the iframe's
// `sandbox` attribute below, this is the entire trust boundary for model- // `sandbox` attribute below, this is the entire trust boundary for model-
// authored HTML/SVG, so it stays conservative rather than convenient: // authored HTML/SVG, so it stays conservative rather than convenient:
@@ -486,6 +243,9 @@ const _PREVIEW_BOOTSTRAP = `<script>
observers[observers.length - 1].observe(document.body); observers[observers.length - 1].observe(document.body);
} }
setTimeout(post, 300); // late paints: fonts, async draws, first rAF frame setTimeout(post, 300); // late paints: fonts, async draws, first rAF frame
// Heartbeat: the parent's watchdog needs a message even when nothing is
// changing, or an idle-but-alive frame reads the same as a hung one.
setInterval(post, 1000);
}); });
})(); })();
</script>`; </script>`;
@@ -547,6 +307,14 @@ const _MIN_PREVIEW_H = 160;
const _MAX_PREVIEW_H = 720; const _MAX_PREVIEW_H = 720;
const _MAX_H_STEPS = 60; const _MAX_H_STEPS = 60;
// A frame that never posts again a synchronous `while(true)` in the user's
// own script, or a runaway re-render loop the bootstrap's own coalescing
// can't outpace has nothing else to signal it. Silence past this long since
// mount (or since the last message) is treated as hung and the frame is torn
// down; the bootstrap's 1s heartbeat means a merely-idle-but-alive frame never
// gets close to this.
const _WATCHDOG_MS = 6000;
// Live preview for a renderable fenced block: a Preview/Code toggle rendered // Live preview for a renderable fenced block: a Preview/Code toggle rendered
// via a sandboxed iframe whose document is an encoded data: URL. // via a sandboxed iframe whose document is an encoded data: URL.
// //
@@ -650,9 +418,11 @@ function PreviewFrame({ lang, value, expanded }) {
const [doc, setDoc] = useState(""); const [doc, setDoc] = useState("");
const [buildError, setBuildError] = useState(""); const [buildError, setBuildError] = useState("");
const [height, setHeight] = useState(240); const [height, setHeight] = useState(240);
const [hung, setHung] = useState(false);
const frameRef = useRef(null); const frameRef = useRef(null);
const heightRef = useRef(240); // mirrors `height` so the listener needn't re-subscribe const heightRef = useRef(240); // mirrors `height` so the listener needn't re-subscribe
const stepsRef = useRef(0); const stepsRef = useRef(0);
const lastMsgRef = useRef(0); // set for real by the watchdog effect below
// Receive the bootstrap's reports. The frame is on an opaque origin, so // Receive the bootstrap's reports. The frame is on an opaque origin, so
// e.origin is the string "null" and proves nothing - identify the sender by // e.origin is the string "null" and proves nothing - identify the sender by
@@ -662,6 +432,7 @@ function PreviewFrame({ lang, value, expanded }) {
if (!frameRef.current || e.source !== frameRef.current.contentWindow) return; if (!frameRef.current || e.source !== frameRef.current.contentWindow) return;
const data = e.data; const data = e.data;
if (!data || data.__nexusPreview !== 1) return; if (!data || data.__nexusPreview !== 1) return;
lastMsgRef.current = Date.now();
if (typeof data.err === "string" && data.err) setError(data.err); if (typeof data.err === "string" && data.err) setError(data.err);
@@ -678,6 +449,23 @@ function PreviewFrame({ lang, value, expanded }) {
return () => window.removeEventListener("message", onMessage); return () => window.removeEventListener("message", onMessage);
}, []); }, []);
// Watchdog: a frame that goes silent past _WATCHDOG_MS most likely a
// synchronous infinite loop in the model's own script, which blocks even
// the bootstrap's heartbeat from ever running gets torn down rather than
// left spinning. Checked on an interval rather than a single timeout so a
// message arriving late (slow compile, heavy first paint) keeps resetting
// the clock instead of tripping early.
useEffect(() => {
lastMsgRef.current = Date.now();
const id = setInterval(() => {
if (Date.now() - lastMsgRef.current > _WATCHDOG_MS) {
setHung(true);
clearInterval(id);
}
}, 1000);
return () => clearInterval(id);
}, [lang, value]);
useEffect(() => { useEffect(() => {
let current = true; let current = true;
setDoc(""); setDoc("");
@@ -691,9 +479,13 @@ function PreviewFrame({ lang, value, expanded }) {
}, [lang, value]); }, [lang, value]);
// A build failure (JSX that doesn't parse) has no document to show at all, so // A build failure (JSX that doesn't parse) has no document to show at all, so
// the message stands in for the frame rather than sitting under it. // the message stands in for the frame rather than sitting under it. A hung
const frameUrl = doc ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : ""; // frame tears down the same way: dropping frameUrl unmounts the iframe,
const shown = buildError || error; // which is what actually stops a runaway script from holding the tab.
const frameUrl = doc && !hung ? `data:text/html;charset=utf-8,${encodeURIComponent(doc)}` : "";
const shown = hung
? "Preview stopped responding (likely an infinite loop) and was stopped."
: buildError || error;
return ( return (
<> <>
-56
View File
@@ -1,56 +0,0 @@
/*
* diff-view.js shared plain-text diff-line classification, no dependency.
*
* There is no diff/syntax-highlighting library in this project (see
* package.json), so a unified-diff string is colored by hand: split into
* lines, tag each by its leading character. Used by both the self-edit
* approval banner (Chatbot.jsx, reviewing a change BEFORE it's applied) and
* the nexus-edit result block (Markdown.jsx, showing one AFTER) one small
* shared vocabulary so both read the same way.
*/
/**
* Split unified-diff text (from difflib.unified_diff on the backend) into
* {text, kind} lines. kind is one of "add" | "remove" | "hunk" | "file" |
* "context". The "+++"/"---" file markers and "@@" hunk headers get their own
* kind so they aren't colored as if they were real added/removed lines (a
* bare "+++" line is not "code that was added").
*/
export function diffLines(text) {
return (text || "").replace(/\n$/, "").split("\n").map((line) => {
if (line.startsWith("+++") || line.startsWith("---")) return { text: line, kind: "file" };
if (line.startsWith("@@")) return { text: line, kind: "hunk" };
if (line.startsWith("+")) return { text: line, kind: "add" };
if (line.startsWith("-")) return { text: line, kind: "remove" };
return { text: line, kind: "context" };
});
}
export const DIFF_LINE_COLOR = {
add: "#8aff8a",
remove: "#ff8a80",
hunk: "#7a92a8",
file: "#7a92a8",
context: "#ccc",
};
/**
* before/after key-value pairs (playbook fields, settings keys) rendered
* through the same add/remove vocabulary as a real diff, so a settings or
* playbook change reads the same way a source diff does: one line removed,
* one line added, per changed key. Unchanged keys are omitted entirely
* this is "what's different," not a full dump.
*/
export function keyValueDiffLines(before, after, keys) {
const lines = [];
for (const key of keys) {
const b = before ? before[key] : undefined;
const a = after ? after[key] : undefined;
const bStr = JSON.stringify(b);
const aStr = JSON.stringify(a);
if (bStr === aStr) continue;
if (b !== undefined) lines.push({ text: `- ${key}: ${bStr}`, kind: "remove" });
if (a !== undefined) lines.push({ text: `+ ${key}: ${aStr}`, kind: "add" });
}
return lines;
}
@@ -1,45 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { diffLines, keyValueDiffLines } from "./diff-view.js";
test("diffLines tags add/remove/hunk/file/context lines", () => {
const text = "--- a/x.py\n+++ b/x.py\n@@ -1,1 +1,1 @@\n-old\n+new\n unchanged\n";
const lines = diffLines(text);
assert.equal(lines[0].kind, "file");
assert.equal(lines[1].kind, "file");
assert.equal(lines[2].kind, "hunk");
assert.equal(lines[3].kind, "remove");
assert.equal(lines[4].kind, "add");
assert.equal(lines[5].kind, "context");
});
test("diffLines drops exactly one trailing newline, not trailing blank lines", () => {
const lines = diffLines("a\nb\n");
assert.equal(lines.length, 2);
assert.equal(lines[1].text, "b");
});
test("diffLines on empty text returns one empty context line, not a crash", () => {
const lines = diffLines("");
assert.equal(lines.length, 1);
assert.equal(lines[0].text, "");
});
test("keyValueDiffLines only emits changed keys", () => {
const lines = keyValueDiffLines({ a: 1, b: 2 }, { a: 1, b: 3 }, ["a", "b"]);
assert.equal(lines.length, 2);
assert.match(lines[0].text, /^- b: 2$/);
assert.match(lines[1].text, /^\+ b: 3$/);
});
test("keyValueDiffLines handles a null before (brand-new object)", () => {
const lines = keyValueDiffLines(null, { title: "New" }, ["title"]);
assert.equal(lines.length, 1);
assert.equal(lines[0].kind, "add");
assert.match(lines[0].text, /title: "New"/);
});
test("keyValueDiffLines emits nothing when nothing changed", () => {
assert.deepEqual(keyValueDiffLines({ a: 1 }, { a: 1 }, ["a"]), []);
});
-60
View File
@@ -1,60 +0,0 @@
/*
* run-langs.js what the chat can display a *run result* for, one entry per
* language.
*
* This is the display half of the execution track, and the counterpart to
* languages.js. The distinction between the two is where the code runs:
*
* languages.js the fence IS the program; the browser runs it in a sandboxed
* iframe, and this side has to build a document for it.
* run-langs.js the program already ran, on the host, in synapse/code_run.py.
* Nothing executes here the fence carries source and captured
* output as JSON, and this side only labels and lays it out.
*
* So an entry needs far less than a preview entry does: no toBody, no line
* offsets, no runtime to inline. Just how to name the language to the reader.
*
* The backend keeps a matching registry (RUN_LANGS in synapse/code_run.py) that
* says how each language is *executed*. Neither depends on the other at runtime;
* tests/test_tools.py asserts the key sets stay equal.
*/
export const RUN_LANGS = {
python: { label: "Python" },
c: { label: "C" },
cpp: { label: "C++" },
rust: { label: "Rust" },
erlang: { label: "Erlang" },
};
// The fence tag run_snippet emits. Not a real language: the block's body is the
// JSON envelope { lang, source, stdout, stderr, exit_code }, which keeps a run's
// output attached to the source that produced it. A model pasting the fence
// cannot paste output without the code, or code with output it never produced.
export const RUN_FENCE_LANG = "nexus-run";
/**
* Parse a nexus-run fence body. Returns null for anything that isn't a
* well-formed envelope naming a known language the caller then falls back to
* showing the block as plain code, which is the honest thing to do with a
* result we can't vouch for.
*/
export function parseRunResult(text) {
let data;
try {
data = JSON.parse(text);
} catch {
return null;
}
if (!data || typeof data !== "object") return null;
if (!Object.prototype.hasOwnProperty.call(RUN_LANGS, data.lang)) return null;
if (typeof data.source !== "string") return null;
return {
lang: data.lang,
label: RUN_LANGS[data.lang].label,
source: data.source,
stdout: typeof data.stdout === "string" ? data.stdout : "",
stderr: typeof data.stderr === "string" ? data.stderr : "",
exitCode: Number.isInteger(data.exit_code) ? data.exit_code : null,
};
}
@@ -1,83 +0,0 @@
/*
* The run-result envelope: what parseRunResult will and won't accept.
*
* Everything this parses arrived as text a language model chose to paste into
* its reply, so the interesting cases are all the malformed ones. A bad envelope
* has to return null - the caller then shows the raw block as code, which is
* ugly but honest - rather than yield a half-built object that renders as a run
* that never happened.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { RUN_LANGS, RUN_FENCE_LANG, parseRunResult } from "./run-langs.js";
const envelope = (over = {}) => JSON.stringify({
lang: "python",
source: "print(1)",
stdout: "1\n",
stderr: "",
exit_code: 0,
...over,
});
test("the fence tag is the one the backend emits", () => {
assert.equal(RUN_FENCE_LANG, "nexus-run");
});
test("every language has a display label", () => {
for (const [name, spec] of Object.entries(RUN_LANGS)) {
assert.equal(typeof spec.label, "string", name);
assert.ok(spec.label.length, name);
}
});
test("a well-formed envelope parses into display fields", () => {
const run = parseRunResult(envelope());
assert.equal(run.lang, "python");
assert.equal(run.label, "Python");
assert.equal(run.source, "print(1)");
assert.equal(run.stdout, "1\n");
assert.equal(run.exitCode, 0);
});
test("a backtick-escaped source round-trips through JSON", () => {
// run_snippet re-encodes ` as ` so the source cannot close the fence.
const run = parseRunResult('{"lang":"python","source":"x = \\u0060a\\u0060","exit_code":0}');
assert.equal(run.source, "x = `a`");
});
test("a non-zero exit code is preserved, not coerced away", () => {
// `exitCode || null` would turn 0 into null and hide a clean exit; a plain
// falsy check on the other side would call a failing program successful.
assert.equal(parseRunResult(envelope({ exit_code: 2 })).exitCode, 2);
assert.equal(parseRunResult(envelope({ exit_code: 0 })).exitCode, 0);
});
test("a missing exit code becomes null rather than a guess", () => {
assert.equal(parseRunResult(envelope({ exit_code: undefined })).exitCode, null);
assert.equal(parseRunResult(envelope({ exit_code: "0" })).exitCode, null);
});
test("absent streams read as empty, never undefined", () => {
const run = parseRunResult('{"lang":"c","source":"int main(){}","exit_code":0}');
assert.equal(run.stdout, "");
assert.equal(run.stderr, "");
});
test("malformed or foreign envelopes are rejected", () => {
assert.equal(parseRunResult("not json at all"), null);
assert.equal(parseRunResult("null"), null);
assert.equal(parseRunResult("[1,2,3]"), null);
assert.equal(parseRunResult('"a string"'), null);
assert.equal(parseRunResult(envelope({ lang: "haskell" })), null);
assert.equal(parseRunResult(envelope({ source: undefined })), null);
assert.equal(parseRunResult(envelope({ source: 42 })), null);
});
test("a prototype key is not mistaken for a supported language", () => {
// `data.lang in RUN_LANGS` would be true for "toString" and read the label
// off Object.prototype - a run panel titled with a function body.
assert.equal(parseRunResult(envelope({ lang: "toString" })), null);
assert.equal(parseRunResult(envelope({ lang: "constructor" })), null);
});
@@ -1,65 +0,0 @@
/*
* self-edit-langs.js display half of the self-modification tools
* (edit_source / edit_playbook / edit_settings), the counterpart to
* run-langs.js. Nothing executes or applies here: by the time this parses a
* fence, the write (if any) already happened on the backend under the user's
* per-call approval, in synapse/self_edit.py. This side only labels and lays
* out what was returned.
*
* One fence tag, three payload shapes (source / playbook / settings), because
* all three go through the same approval round-trip and the same "carry what
* happened in one block" idea as run_snippet's nexus-run fence.
*/
export const EDIT_FENCE_LANG = "nexus-edit";
/**
* Parse a nexus-edit fence body. Returns null for anything malformed or of an
* unrecognized kind the caller falls back to showing the block as plain
* code, the same honest-fallback behavior as parseRunResult.
*/
export function parseEditResult(text) {
let data;
try {
data = JSON.parse(text);
} catch {
return null;
}
if (!data || typeof data !== "object") return null;
if (data.kind === "source") {
if (typeof data.path !== "string" || typeof data.diff !== "string") return null;
return {
kind: "source",
path: data.path,
diff: data.diff,
commit: typeof data.commit === "string" ? data.commit : null,
instruction: typeof data.instruction === "string" ? data.instruction : "",
};
}
if (data.kind === "playbook") {
if (typeof data.id !== "string") return null;
return {
kind: "playbook",
id: data.id,
title: typeof data.title === "string" ? data.title : "",
goal: typeof data.goal === "string" ? data.goal : "",
instructions: typeof data.instructions === "string" ? data.instructions : "",
isMainPlaybook: !!data.is_main_playbook,
};
}
if (data.kind === "settings") {
if (!data.applied || typeof data.applied !== "object") return null;
return {
kind: "settings",
applied: data.applied,
ignoredUnknown: Array.isArray(data.ignored_unknown) ? data.ignored_unknown : [],
policyChange: !!data.policy_change,
systemPromptChange: !!data.system_prompt_change,
};
}
return null;
}
@@ -1,80 +0,0 @@
/*
* The self-edit result envelope: what parseEditResult will and won't accept.
*
* Same reasoning as run-langs.test.js: this parses text a model chose to
* paste, so the malformed cases matter as much as the well-formed ones. A bad
* envelope must return null so the caller falls back to plain code, not a
* half-built object that renders a change that never happened.
*/
import { test } from "node:test";
import assert from "node:assert/strict";
import { EDIT_FENCE_LANG, parseEditResult } from "./self-edit-langs.js";
test("the fence tag is the one the backend emits", () => {
assert.equal(EDIT_FENCE_LANG, "nexus-edit");
});
test("a well-formed source envelope parses into display fields", () => {
const edit = parseEditResult(JSON.stringify({
kind: "source", ok: true, path: "synapse/tools.py", diff: "--- a\n+++ b\n",
commit: "abc1234", instruction: "restart needed",
}));
assert.equal(edit.kind, "source");
assert.equal(edit.path, "synapse/tools.py");
assert.equal(edit.commit, "abc1234");
assert.equal(edit.instruction, "restart needed");
});
test("a source envelope with no commit reads as null, not undefined", () => {
const edit = parseEditResult(JSON.stringify({
kind: "source", path: "x.py", diff: "", commit: null,
}));
assert.equal(edit.commit, null);
});
test("a source envelope missing path or diff is rejected", () => {
assert.equal(parseEditResult(JSON.stringify({ kind: "source", diff: "x" })), null);
assert.equal(parseEditResult(JSON.stringify({ kind: "source", path: "x.py" })), null);
});
test("a well-formed playbook envelope parses into display fields", () => {
const edit = parseEditResult(JSON.stringify({
kind: "playbook", id: "p1", title: "T", goal: "G", instructions: "I",
is_main_playbook: true,
}));
assert.equal(edit.kind, "playbook");
assert.equal(edit.id, "p1");
assert.equal(edit.isMainPlaybook, true);
});
test("a playbook envelope missing id is rejected", () => {
assert.equal(parseEditResult(JSON.stringify({ kind: "playbook", title: "T" })), null);
});
test("a well-formed settings envelope parses into display fields", () => {
const edit = parseEditResult(JSON.stringify({
kind: "settings",
applied: { model: { before: "a", after: "b" } },
ignored_unknown: ["nope"],
policy_change: true,
system_prompt_change: false,
}));
assert.equal(edit.kind, "settings");
assert.deepEqual(edit.applied, { model: { before: "a", after: "b" } });
assert.deepEqual(edit.ignoredUnknown, ["nope"]);
assert.equal(edit.policyChange, true);
assert.equal(edit.systemPromptChange, false);
});
test("a settings envelope missing applied is rejected", () => {
assert.equal(parseEditResult(JSON.stringify({ kind: "settings" })), null);
});
test("malformed or foreign envelopes are rejected", () => {
assert.equal(parseEditResult("not json"), null);
assert.equal(parseEditResult("null"), null);
assert.equal(parseEditResult("[1,2,3]"), null);
assert.equal(parseEditResult(JSON.stringify({ kind: "unknown_kind" })), null);
assert.equal(parseEditResult(JSON.stringify({ path: "x.py", diff: "" })), null);
});
+21 -55
View File
@@ -24,10 +24,8 @@ from . import ncp as services
CONFIG_SCHEMA = { CONFIG_SCHEMA = {
"home": "path", "home": "path",
"api_url": "url", "api_url": "url",
"memory_url": "url",
"bind_host": "text", "bind_host": "text",
"backend_port": "port", "backend_port": "port",
"memory_port": "port",
"provider": "provider", "provider": "provider",
"provider_url": "url", "provider_url": "url",
"provider_timeout": "positive_int", "provider_timeout": "positive_int",
@@ -39,8 +37,6 @@ CONFIG_SCHEMA = {
} }
LEGACY_TARGETS = { LEGACY_TARGETS = {
"-m": "memory",
"--memory": "memory",
"-b": "backend", "-b": "backend",
"--backend": "backend", "--backend": "backend",
"-f": "frontend", "-f": "frontend",
@@ -303,14 +299,13 @@ def diagnostics() -> dict:
) )
add( add(
"service ports", "service ports",
all(1 <= port <= 65535 for port in (settings.backend_port, settings.memory_port)), 1 <= settings.backend_port <= 65535,
f"backend={settings.backend_port}, memory={settings.memory_port}", f"backend={settings.backend_port}",
) )
for module in ("fastapi", "uvicorn", "httpx", "pydantic", "yaml"): for module in ("fastapi", "uvicorn", "httpx", "pydantic", "yaml"):
add(f"import:{module}", _check_import(module), module) add(f"import:{module}", _check_import(module), module)
add("backend", _http_ok(settings.api_url + "/status"), settings.api_url, required=False) add("backend", _http_ok(settings.api_url + "/status"), settings.api_url, required=False)
add("memory service", _http_ok(settings.memory_url + "/"), settings.memory_url, required=False)
provider = _provider_payload() provider = _provider_payload()
add("provider", provider["reachable"], provider["url"], required=False) add("provider", provider["reachable"], provider["url"], required=False)
if settings.manage_ollama: if settings.manage_ollama:
@@ -362,7 +357,7 @@ def cmd_doctor(args) -> int:
def service_status() -> dict: def service_status() -> dict:
payload = {} payload = {}
for key in ("backend", "memory", "frontend"): for key in ("backend", "frontend"):
svc = services.SERVICES[key] svc = services.SERVICES[key]
pid = services.read_pid(svc) pid = services.read_pid(svc)
payload[key] = { payload[key] = {
@@ -380,7 +375,7 @@ def cmd_status(args) -> int:
_emit(payload, True) _emit(payload, True)
return 0 return 0
print("Nexus Service Status:\n") print("Nexus Service Status:\n")
for key in ("backend", "memory", "frontend"): for key in ("backend", "frontend"):
info = payload[key] info = payload[key]
suffix = f" (PID {info['pid']})" if info["pid"] else "" suffix = f" (PID {info['pid']})" if info["pid"] else ""
print(f" {key:<10} {'RUNNING' if info['running'] else 'STOPPED'}{suffix} {info['url']}") print(f" {key:<10} {'RUNNING' if info['running'] else 'STOPPED'}{suffix} {info['url']}")
@@ -418,7 +413,6 @@ def cmd_tui(args) -> int:
def _target_flag(target: str | None): def _target_flag(target: str | None):
return { return {
"memory": "--memory",
"backend": "--backend", "backend": "--backend",
"frontend": "--frontend", "frontend": "--frontend",
"ai": "--ai", "ai": "--ai",
@@ -503,18 +497,14 @@ def cmd_serve(args) -> int:
return 2 return 2
settings.backend_port = args.port settings.backend_port = args.port
settings.memory_port = args.memory_port
settings.bind_host = host settings.bind_host = host
settings.api_url = f"http://127.0.0.1:{args.port}" settings.api_url = f"http://127.0.0.1:{args.port}"
settings.memory_url = f"http://127.0.0.1:{args.memory_port}"
os.environ["NEXUS_BACKEND_PORT"] = str(args.port) os.environ["NEXUS_BACKEND_PORT"] = str(args.port)
os.environ["NEXUS_MEMORY_PORT"] = str(args.memory_port)
os.environ["NEXUS_BIND_HOST"] = host os.environ["NEXUS_BIND_HOST"] = host
for origin_host in ("localhost", "127.0.0.1"): for origin_host in ("localhost", "127.0.0.1"):
for port in (args.port, args.memory_port): origin = f"http://{origin_host}:{args.port}"
origin = f"http://{origin_host}:{port}" if origin not in config.ALLOWED_ORIGINS:
if origin not in config.ALLOWED_ORIGINS: config.ALLOWED_ORIGINS.append(origin)
config.ALLOWED_ORIGINS.append(origin)
if args.allow_lan: if args.allow_lan:
# Widen to the addresses this bind actually answers on - NOT "*". # Widen to the addresses this bind actually answers on - NOT "*".
# ALLOWED_HOSTS drives TrustedHostMiddleware, which is the DNS-rebinding # ALLOWED_HOSTS drives TrustedHostMiddleware, which is the DNS-rebinding
@@ -526,10 +516,9 @@ def cmd_serve(args) -> int:
for name in names: for name in names:
if name not in config.ALLOWED_HOSTS: if name not in config.ALLOWED_HOSTS:
config.ALLOWED_HOSTS.append(name) config.ALLOWED_HOSTS.append(name)
for port in (args.port, args.memory_port): origin = f"http://{_origin_host(name)}:{args.port}"
origin = f"http://{_origin_host(name)}:{port}" if origin not in config.ALLOWED_ORIGINS:
if origin not in config.ALLOWED_ORIGINS: config.ALLOWED_ORIGINS.append(origin)
config.ALLOWED_ORIGINS.append(origin)
os.environ.setdefault("NEXUS_ALLOWED_HOSTS", ",".join(config.ALLOWED_HOSTS)) os.environ.setdefault("NEXUS_ALLOWED_HOSTS", ",".join(config.ALLOWED_HOSTS))
os.environ.setdefault("NEXUS_ALLOWED_ORIGINS", ",".join(config.ALLOWED_ORIGINS)) os.environ.setdefault("NEXUS_ALLOWED_ORIGINS", ",".join(config.ALLOWED_ORIGINS))
print( print(
@@ -538,35 +527,13 @@ def cmd_serve(args) -> int:
" has full admin and data access." " has full admin and data access."
) )
memory_proc = None print(f"NexusOS serving on http://{host}:{args.port}")
memory_log = None import uvicorn
try: uvicorn.run(
if not args.no_memory and not _http_ok(settings.memory_url + "/"): "synapse.main:sio_app", host=host, port=args.port,
log_path = settings.runtime_dir / "memory.log" reload=bool(args.reload and settings.source_checkout),
log_path.parent.mkdir(parents=True, exist_ok=True) log_level=args.log_level,
memory_log = open(log_path, "ab") )
memory_proc = subprocess.Popen(
[sys.executable, "-m", "uvicorn", "synapse.memory.service:app",
"--host", host, "--port", str(args.memory_port)],
stdout=memory_log, stderr=subprocess.STDOUT, stdin=subprocess.DEVNULL,
)
print(f"Memory service starting on {host}:{args.memory_port} (log: {log_path})")
print(f"NexusOS serving on http://{host}:{args.port}")
import uvicorn
uvicorn.run(
"synapse.main:sio_app", host=host, port=args.port,
reload=bool(args.reload and settings.source_checkout),
log_level=args.log_level,
)
finally:
if memory_proc is not None and memory_proc.poll() is None:
memory_proc.terminate()
try:
memory_proc.wait(timeout=5)
except subprocess.TimeoutExpired:
memory_proc.kill()
if memory_log is not None:
memory_log.close()
return 0 return 0
@@ -626,7 +593,7 @@ def cmd_nvidia_reqs(_args) -> int:
def cmd_logs(args) -> int: def cmd_logs(args) -> int:
keys = ("backend", "memory", "frontend") if args.target == "all" else (args.target,) keys = ("backend", "frontend") if args.target == "all" else (args.target,)
paths = [services.SERVICES[key].log_file for key in keys] paths = [services.SERVICES[key].log_file for key in keys]
for path in paths: for path in paths:
print(f"=== {path.name} ===") print(f"=== {path.name} ===")
@@ -779,8 +746,7 @@ def build_parser() -> argparse.ArgumentParser:
p = sub.add_parser("serve", help="run NexusOS in the foreground") p = sub.add_parser("serve", help="run NexusOS in the foreground")
p.add_argument("--host"); p.add_argument("--port", type=_port, default=settings.backend_port) p.add_argument("--host"); p.add_argument("--port", type=_port, default=settings.backend_port)
p.add_argument("--memory-port", type=_port, default=settings.memory_port) p.add_argument("--allow-lan", action="store_true")
p.add_argument("--no-memory", action="store_true"); p.add_argument("--allow-lan", action="store_true")
p.add_argument("--reload", action="store_true"); p.add_argument("--log-level", default="info") p.add_argument("--reload", action="store_true"); p.add_argument("--log-level", default="info")
p.set_defaults(fn=cmd_serve) p.set_defaults(fn=cmd_serve)
@@ -789,7 +755,7 @@ def build_parser() -> argparse.ArgumentParser:
("stop", cmd_stop, "stop background services"), ("stop", cmd_stop, "stop background services"),
): ):
p = sub.add_parser(name, help=help_text) p = sub.add_parser(name, help=help_text)
p.add_argument("target", nargs="?", choices=["all", "backend", "memory", "frontend", "ai"], default="all") p.add_argument("target", nargs="?", choices=["all", "backend", "frontend", "ai"], default="all")
p.set_defaults(fn=fn) p.set_defaults(fn=fn)
sub.add_parser("restart", aliases=["refresh"], help="restart all services").set_defaults(fn=cmd_refresh) sub.add_parser("restart", aliases=["refresh"], help="restart all services").set_defaults(fn=cmd_refresh)
sub.add_parser("kill", help="force-stop NexusOS-owned processes").set_defaults(fn=lambda _a: services.cmd_kill() or 0) sub.add_parser("kill", help="force-stop NexusOS-owned processes").set_defaults(fn=lambda _a: services.cmd_kill() or 0)
@@ -799,7 +765,7 @@ def build_parser() -> argparse.ArgumentParser:
sub.add_parser("web", help="legacy desktop alias for open").set_defaults(fn=cmd_web) sub.add_parser("web", help="legacy desktop alias for open").set_defaults(fn=cmd_web)
sub.add_parser("panel", help="launch the legacy desktop control panel").set_defaults(fn=cmd_panel) sub.add_parser("panel", help="launch the legacy desktop control panel").set_defaults(fn=cmd_panel)
p = sub.add_parser("logs", help="read or follow service logs") p = sub.add_parser("logs", help="read or follow service logs")
p.add_argument("target", nargs="?", choices=["all", "backend", "memory", "frontend"], default="all") p.add_argument("target", nargs="?", choices=["all", "backend", "frontend"], default="all")
p.add_argument("--lines", type=int, choices=range(1, 10001), default=50, metavar="1..10000") p.add_argument("--lines", type=int, choices=range(1, 10001), default=50, metavar="1..10000")
p.add_argument("--follow", "-f", action="store_true"); p.set_defaults(fn=cmd_logs) p.add_argument("--follow", "-f", action="store_true"); p.set_defaults(fn=cmd_logs)
sub.add_parser("clean", help="remove runtime logs and stale PID files").set_defaults(fn=cmd_clean) sub.add_parser("clean", help="remove runtime logs and stale PID files").set_defaults(fn=cmd_clean)
+2 -4
View File
@@ -62,7 +62,7 @@ def _provider_payload() -> dict:
def _service_status() -> dict: def _service_status() -> dict:
payload = {} payload = {}
for key in ("backend", "memory", "frontend"): for key in ("backend", "frontend"):
svc = services.SERVICES[key] svc = services.SERVICES[key]
pid = services.read_pid(svc) pid = services.read_pid(svc)
payload[key] = { payload[key] = {
@@ -253,7 +253,6 @@ def collect_snapshot() -> dict:
services_payload = _service_status() services_payload = _service_status()
pids = [ pids = [
services_payload.get("backend", {}).get("pid"), services_payload.get("backend", {}).get("pid"),
services_payload.get("memory", {}).get("pid"),
services_payload.get("frontend", {}).get("pid"), services_payload.get("frontend", {}).get("pid"),
] ]
api = _api_counts(settings.api_url) api = _api_counts(settings.api_url)
@@ -268,7 +267,6 @@ def collect_snapshot() -> dict:
"recent_tools": _recent_tools(settings.logs_dir / "chat.log"), "recent_tools": _recent_tools(settings.logs_dir / "chat.log"),
"paths": { "paths": {
"api_url": settings.api_url, "api_url": settings.api_url,
"memory_url": settings.memory_url,
"runtime_dir": str(settings.runtime_dir), "runtime_dir": str(settings.runtime_dir),
}, },
} }
@@ -328,7 +326,7 @@ def render_frame(snapshot: dict, *, width: int | None = None, unicode: bool | No
lines.append(_row(box, "SERVICES", width)) lines.append(_row(box, "SERVICES", width))
svcs = snapshot.get("services") or {} svcs = snapshot.get("services") or {}
for key, label in (("backend", "backend"), ("memory", "memory"), ("frontend", "frontend")): for key, label in (("backend", "backend"), ("frontend", "frontend")):
info = svcs.get(key) or {} info = svcs.get(key) or {}
running = bool(info.get("running")) running = bool(info.get("running"))
pid = info.get("pid") pid = info.get("pid")
+1 -1
View File
@@ -146,7 +146,7 @@ def _uvicorn(app: str, port: int):
SERVICES = { SERVICES = {
"backend": Service("backend", "NEXUS BACKEND SERVICE", settings.backend_port, ROOT, "backend": Service("backend", "NEXUS BACKEND SERVICE", settings.backend_port, settings.state_dir,
["uvicorn synapse.main"], ["uvicorn synapse.main"],
lambda: _uvicorn("synapse.main:sio_app", settings.backend_port)), lambda: _uvicorn("synapse.main:sio_app", settings.backend_port)),
"frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR, "frontend": Service("frontend", "NEXUS FRONTEND SERVICE", 5173, FRONTEND_DIR,
+10 -19
View File
@@ -15,7 +15,6 @@ from typing import Any
import httpx import httpx
from synapse.nexus_config import settings from synapse.nexus_config import settings
from synapse.slash_commands import parse_slash_command
from .monitor import collect_snapshot from .monitor import collect_snapshot
@@ -345,19 +344,6 @@ class NexusTUI:
log.write( log.write(
f"[dim]model:[/] {_escape(self._model or '(auto)')}" f"[dim]model:[/] {_escape(self._model or '(auto)')}"
) )
elif parse_slash_command(text) is not None:
# Shaped like /tool_name(arg=val, ...) rather than one of
# the local meta-commands above — not handled here, sent
# to the backend as-is. chat_stream_endpoint recognizes
# and dispatches it directly (see synapse/slash_commands.py);
# a malformed one still goes through so the user sees the
# backend's own error, with full context, in one place.
if self._busy:
log.write(
"[yellow]Still streaming — wait or Ctrl+C to interrupt[/]"
)
else:
self._start_chat(text)
else: else:
log.write( log.write(
f"[red]unknown command[/] /{_escape(cmd)} — try /help" f"[red]unknown command[/] /{_escape(cmd)} — try /help"
@@ -373,14 +359,19 @@ class NexusTUI:
if not self.conversation_id: if not self.conversation_id:
self.conversation_id = str(uuid.uuid4()) self.conversation_id = str(uuid.uuid4())
conversation_id = self.conversation_id conversation_id = self.conversation_id
# The list object itself, not self.history - /new reassigns
# self.history to a fresh list, and a stream that outlives that
# must keep appending its reply to the conversation it actually
# belongs to, not whatever self.history now points at.
history_ref = self.history
body: dict[str, Any] = { body: dict[str, Any] = {
"message": message, "message": message,
"conversation_id": conversation_id, "conversation_id": conversation_id,
"history": list(self.history), "history": list(history_ref),
} }
if self._model: if self._model:
body["model"] = self._model body["model"] = self._model
self.history.append({"role": "user", "content": message}) history_ref.append({"role": "user", "content": message})
async def stream_worker(): async def stream_worker():
reply_parts: list[str] = [] reply_parts: list[str] = []
@@ -488,19 +479,19 @@ class NexusTUI:
if self._stream_cancel == (loop, task): if self._stream_cancel == (loop, task):
self._stream_cancel = None self._stream_cancel = None
text = "".join(reply_parts).strip() text = "".join(reply_parts).strip()
self._call_ui(self._finish_stream, text) self._call_ui(self._finish_stream, text, history_ref)
threading.Thread( threading.Thread(
target=lambda: asyncio.run(stream_worker()), daemon=True target=lambda: asyncio.run(stream_worker()), daemon=True
).start() ).start()
def _finish_stream(self, text: str) -> None: def _finish_stream(self, text: str, history_ref: list) -> None:
log = self.query_one("#log", RichLog) log = self.query_one("#log", RichLog)
live = self.query_one("#live", Static) live = self.query_one("#live", Static)
try: try:
if text: if text:
log.write(format_assistant_line(text)) log.write(format_assistant_line(text))
self.history.append( history_ref.append(
{"role": "assistant", "content": text} {"role": "assistant", "content": text}
) )
finally: finally:
+14 -44
View File
@@ -10,7 +10,6 @@ from typing import AsyncGenerator, Dict, List, Optional, Any
from .nexus_config import settings, DEFAULT_CHAT_MODEL from .nexus_config import settings, DEFAULT_CHAT_MODEL
from .ollama_manager import get_ollama_manager from .ollama_manager import get_ollama_manager
from . import tools as _tools from . import tools as _tools
from . import self_edit
# Cap on tool-call round-trips before the final answer — stops a confused small # Cap on tool-call round-trips before the final answer — stops a confused small
# model from looping forever. # model from looping forever.
@@ -140,12 +139,6 @@ async def _normalize_to_async_generator(maybe_iterable) -> AsyncGenerator[str, N
pending_approvals: Dict[str, Dict[str, Any]] = {} pending_approvals: Dict[str, Dict[str, Any]] = {}
_APPROVAL_TIMEOUT = 300 # seconds; a timeout is treated as "deny all" _APPROVAL_TIMEOUT = 300 # seconds; a timeout is treated as "deny all"
# Host code screening has a concrete safety contract, so run_snippet retains its
# bounded retry and second-attempt scaffold. Browser previews are packaged as-is.
_RETRY_TOOLS = frozenset({"run_snippet"})
_FENCE_TOOLS = frozenset({"render_preview", "run_snippet"})
def _as_tool_calls(obj) -> list: def _as_tool_calls(obj) -> list:
"""Normalize a parsed JSON value into Ollama-style tool_calls entries.""" """Normalize a parsed JSON value into Ollama-style tool_calls entries."""
if isinstance(obj, list): if isinstance(obj, list):
@@ -271,7 +264,6 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
# Let the UI show activity immediately — the first tool-turn is a full # Let the UI show activity immediately — the first tool-turn is a full
# non-stream generation and can sit silent for a long time otherwise. # non-stream generation and can sit silent for a long time otherwise.
yield "__status__tools" yield "__status__tools"
run_rejects = 0
allowed_names = { allowed_names = {
(schema.get("function") or {}).get("name") (schema.get("function") or {}).get("name")
for schema in (tool_schemas or []) for schema in (tool_schemas or [])
@@ -284,29 +276,23 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
) )
if not isinstance(msg, dict): if not isinstance(msg, dict):
break # None/error or no tool support -> fall back to plain stream break # None/error or no tool support -> fall back to plain stream
native = bool(msg.get("tool_calls"))
calls = _coerce_tool_calls(msg, allowed_names) calls = _coerce_tool_calls(msg, allowed_names)
if not calls: if not calls:
break break
# Normalize content-JSON tool calls into the shape later turns expect. # Normalize content-JSON tool calls into the shape later turns expect.
if not msg.get("tool_calls"): if not native:
msg = {"role": "assistant", "content": "", "tool_calls": calls} msg = {"role": "assistant", "content": "", "tool_calls": calls}
messages.append(msg) messages.append(msg)
# If any action tool needs per-call approval, pause and wait for the user. # If any action tool needs per-call approval, pause and wait for the user.
# edit_playbook/edit_settings/edit_source and the write/execute curry_* # A call recovered by guessing at `content` (no native tool_calls field)
# tools always require it, regardless of `policy` — a global "allow" set # is a weaker signal than the API's own structured field — a model can
# for convenience on an unrelated tool (web_search, say) must never # land on JSON shaped like a call while only meaning to describe one, so
# silently also unlock unattended self-modification or ledger writes. # it always goes through approval regardless of policy, even "allow".
# See _tools.ALWAYS_ASK_ACTION_TOOLS. (This floor governs MODEL-issued
# calls only — a human-typed /tool(...) slash-command skips it entirely,
# by design: see slash_commands.py.)
decisions = None decisions = None
action_calls = [c for c in calls if _tools.is_action(c.get("function", {}).get("name", ""))] action_calls = [c for c in calls if _tools.is_action(c.get("function", {}).get("name", ""))]
needs_approval = policy == "ask" or any( if (policy == "ask" or not native) and action_calls:
c.get("function", {}).get("name", "") in _tools.ALWAYS_ASK_ACTION_TOOLS
for c in action_calls
)
if needs_approval and action_calls:
event = asyncio.Event() event = asyncio.Event()
# Single-use capability token, delivered only to the client that owns # Single-use capability token, delivered only to the client that owns
# this stream. /chat/approve requires it, so knowing the (guessable, # this stream. /chat/approve requires it, so knowing the (guessable,
@@ -314,21 +300,13 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
# else's pending action. # else's pending action.
token = secrets.token_urlsafe(32) token = secrets.token_urlsafe(32)
pending_approvals[conversation_id] = {"event": event, "decisions": {}, "token": token} pending_approvals[conversation_id] = {"event": event, "decisions": {}, "token": token}
def _action_entry(c):
name = c.get("function", {}).get("name", "")
args = c.get("function", {}).get("arguments")
entry = {"name": name, "arguments": args}
if name in self_edit.PREVIEWABLE:
try:
entry["preview"] = self_edit.preview_for(name, args or {})
except Exception as e:
entry["preview"] = {"ok": False, "error": f"preview failed: {e}"}
return entry
yield "__approve__" + _json.dumps({ yield "__approve__" + _json.dumps({
"token": token, "token": token,
"actions": [_action_entry(c) for c in action_calls], "actions": [
{"name": c.get("function", {}).get("name", ""),
"arguments": c.get("function", {}).get("arguments")}
for c in action_calls
],
}) })
try: try:
await asyncio.wait_for(event.wait(), timeout=_APPROVAL_TIMEOUT) await asyncio.wait_for(event.wait(), timeout=_APPROVAL_TIMEOUT)
@@ -347,22 +325,14 @@ async def _run_tool_loop(manager, messages, model, tool_schemas, temperature, nu
continue continue
yield f"__status__{name}" yield f"__status__{name}"
call_args = fn.get("arguments") call_args = fn.get("arguments")
if name in _RETRY_TOOLS and isinstance(call_args, dict):
call_args = {**call_args, "_attempt": run_rejects}
result = await _tools.dispatch(name, call_args) result = await _tools.dispatch(name, call_args)
messages.append({"role": "tool", "content": result}) messages.append({"role": "tool", "content": result})
# Cap host-code reject loops — each retry is another full non-stream if name == "render_preview":
# generation and looks like the UI is "stuck thinking".
if name in _FENCE_TOOLS:
try: try:
body = _json.loads(result) body = _json.loads(result)
except Exception: except Exception:
body = {} body = {}
if name in _RETRY_TOOLS and isinstance(body, dict) and body.get("ok") is False: if isinstance(body, dict) and body.get("ok") is True:
run_rejects += 1
if run_rejects >= 2:
stop_after = True
elif isinstance(body, dict) and body.get("ok") is True:
# Good fence in hand — let the model write the reply next. # Good fence in hand — let the model write the reply next.
stop_after = True stop_after = True
if stop_after: if stop_after:
-604
View File
@@ -1,604 +0,0 @@
"""Run a short code snippet in an ephemeral working directory.
This is the *second* track of the code-preview feature and deliberately not the
first. `render_preview` (synapse/tools.py) executes nothing server-side: it
validates markup and the chat UI renders it inside an opaque-origin iframe.
That model fits HTML/SVG/JSX and cannot fit C, Rust or Erlang, which need a real
toolchain. So those go through here instead, and the result is shown as terminal
output rather than a rendered document.
WHAT THIS IS NOT
----------------
Not a security sandbox. Snippets run as the current user on the host. What this
module actually provides is *containment by limits*, layered:
1. Consent run_snippet is an ACTION tool, so it is withheld entirely
unless `action_tool_policy` is "ask"/"allow" and on "ask"
every call waits for the user's Approve/Deny in chat.
2. Screening `critique` rejects the obvious-abuse shapes (sockets, process
spawning, absolute paths) before anything is written to disk.
3. Isolation cwd is a fresh temp dir that is deleted afterwards; home and
temp environment variables point at it; the environment is
scrubbed to a small allowlist.
4. Limits wall-clock timeout, RLIMIT_CPU/AS/FSIZE/NPROC on POSIX,
truncated output.
5. Network on Linux, `unshare -rn` when unprivileged user namespaces are
available (probed once, see `_net_isolation`). Nowhere else
macOS and Windows get no network isolation at all.
Layer 2 is a tripwire, not a boundary: arbitrary Python can evade any regex
trivially. It exists to catch a model that reaches for `requests` by habit, not
an adversary. The load-bearing layers are 1 and 35. Anyone wanting a real
boundary should run this under a container or a VM; that is a deployment
decision this module does not make for them.
"""
from __future__ import annotations
import os
import re
import shutil
import subprocess
import sys
import tempfile
from functools import lru_cache
from pathlib import Path
# Wall clock. Compilation gets its own, larger budget: rustc on a cold cache
# routinely spends longer than any snippet is allowed to *run*, and killing a
# compile at the run timeout would look like the code hung when it never started.
RUN_TIMEOUT = 5.0
COMPILE_TIMEOUT = 25.0
MAX_SOURCE = 100_000 # chars of source accepted
MAX_OUTPUT = 20_000 # chars of stdout/stderr returned, per stream
MAX_STDIN = 10_000
# Applied to the run step only. A compiler legitimately needs more address space
# than a snippet does and forks a linker, so imposing these on the compile step
# breaks the toolchain rather than containing the snippet.
_MEM_BYTES = 512 * 1024 * 1024
_MAX_PROCS = 64
_MAX_FILE_BYTES = 8 * 1024 * 1024
# RLIMIT_NPROC is counted per real UID, not per run. An absolute "512" is
# therefore 512 minus whatever the user already has — enough on a quiet host,
# zero on a busy macOS desktop. Erlang needs relative headroom for BEAM's
# boot process tree; other languages keep the absolute _MAX_PROCS (blocking
# fork is the intent there).
_ERLANG_PROC_HEADROOM = 256
# ---------------------------------------------------------------------------
# Static screening
# ---------------------------------------------------------------------------
def _shared_issues(source: str) -> list[str]:
stripped = source.strip()
if len(stripped) < 8:
return ["source is empty or too short to run."]
issues: list[str] = []
# Only flag URLs inside string literals. A citation in a comment
# (`/* see https://… */`) is not a network reach and bouncing it costs a
# useless retry round — this layer is a tripwire, not a parser.
if re.search(r"""['"][^'"]*https?://[^'"]*['"]""", source):
issues.append(
"source contains an http(s) URL string — the runner has no network "
"access. Inline the data you need."
)
if re.search(r"\b(TODO|FIXME|your code here|implement this)\b", source, re.I):
issues.append("source still contains a placeholder — send the finished code.")
# An absolute path is either reaching outside the ephemeral dir or is a
# machine-specific guess that will not exist. Relative paths are fine: cwd
# is the temp dir and goes away with it.
# Windows: match `C:\…` and `C:\\…` (raw / escaped). Unix: common roots.
if re.search(
r"""['"](?:/(?:etc|home|root|usr|var|proc|sys)/|[A-Za-z]:[/\\])""",
source,
):
issues.append(
"source references an absolute filesystem path. The snippet runs in a "
"throwaway directory — use relative paths, or inline the data."
)
return issues
def _deny(source: str, rules: list[tuple[str, str]]) -> list[str]:
return [msg for pattern, msg in rules if re.search(pattern, source)]
_NO_NET = "the runner has no network access"
_NO_SPAWN = "the runner does not allow spawning other processes"
_PY_RULES = [
(r"\b(?:import|from)\s+(?:socket|ssl|ftplib|smtplib|telnetlib|urllib|http)\b",
f"networking module imported — {_NO_NET}."),
(r"\bimport\s+(?:requests|httpx|aiohttp|urllib3)\b",
f"HTTP client imported — {_NO_NET}."),
(r"\b(?:import|from)\s+(?:subprocess|multiprocessing)\b",
f"subprocess/multiprocessing imported — {_NO_SPAWN}."),
(r"\bos\.(?:system|popen|exec[lv]|fork|spawn|kill)\b",
f"os process call — {_NO_SPAWN}."),
(r"\b(?:import|from)\s+ctypes\b",
"ctypes imported — the runner does not allow native calls."),
]
_C_RULES = [
(r"#\s*include\s*<(?:sys/socket|netinet/|arpa/|netdb)",
f"socket header included — {_NO_NET}."),
(r"\b(?:system|popen|fork|execv?[lpe]*)\s*\(",
f"process call — {_NO_SPAWN}."),
]
_RUST_RULES = [
(r"\bstd::net\b", f"std::net used — {_NO_NET}."),
(r"\bstd::process::(?:Command|abort)\b", f"std::process::Command used — {_NO_SPAWN}."),
]
_ERL_RULES = [
(r"\b(?:gen_tcp|gen_udp|httpc|inets|ssl)\b", f"networking module used — {_NO_NET}."),
(r"\bos:cmd\b", f"os:cmd/1 used — {_NO_SPAWN}."),
]
def _entry_point(pattern: str, message: str):
"""Most of these languages fail with a linker/loader error rather than a
useful one when the entry point is missing, so name it up front."""
def check(source: str) -> list[str]:
return [] if re.search(pattern, source) else [message]
return check
def _critique(rules: list[tuple[str, str]], entry=None):
def check(source: str) -> list[str]:
issues = _shared_issues(source)
if issues and issues[0].startswith("source is empty"):
return issues # nothing else is worth saying about an empty body
issues += _deny(source, rules)
if entry:
issues += entry(source)
return issues
return check
# ---------------------------------------------------------------------------
# Drivers
# ---------------------------------------------------------------------------
def _source_name(default: str):
return lambda _source: default
def _erlang_source_name(source: str) -> str:
"""escript reads the same file two different ways, chosen by extension: a
`.erl` file is compiled as a module (needs -module/-export), anything else
is a plain script (needs only main/1). Models write both, so let the source
pick its own filename instead of forcing one dialect."""
return "main.erl" if re.search(r"^\s*-module\s*\(", source, re.M) else "main.escript"
def _erlang_write_source(source: str) -> str:
"""OTP 28+ escript rejects a shebang-less `.escript` with 'Premature end of
file'. Module-form `.erl` files do not need one. Inject only when missing so
a model that already wrote `#!/usr/bin/env escript` is left alone."""
if _erlang_source_name(source).endswith(".escript"):
stripped = source.lstrip()
if not stripped.startswith("#!"):
return "#!/usr/bin/env escript\n" + source
return source
@lru_cache(maxsize=None)
def _compiled_tool(lang: str, candidates: tuple[str, ...]) -> str | None:
"""Resolve a compiler, verifying the complete Windows toolchain once.
A compiler executable alone is not a usable toolchain on Windows: rustc's
MSVC target also needs Microsoft's linker, and an MSYS2 driver can remain on
PATH after one of its runtime DLLs has broken. Both cases otherwise make the
capability monitor say "ready" and turn every snippet into a compile error.
POSIX keeps the cheap historical which(1) check; the release hosts there
install compiler packages atomically.
"""
found = [tool for name in candidates if (tool := shutil.which(name))]
if sys.platform != "win32":
return found[0] if found else None
for tool in found:
if _probe_compiled_tool(lang, tool):
return tool
return None
def _probe_compiled_tool(lang: str, tool: str) -> bool:
"""Compile a minimal known-good program with the runner's real child env."""
source = {
"c": "int main(void){return 0;}",
"cpp": "int main(){return 0;}",
"rust": "fn main() {}",
}[lang]
try:
with tempfile.TemporaryDirectory(prefix="nexus-toolchain-") as tmp:
workdir = Path(tmp)
entry = RUN_LANGS[lang]
src = workdir / entry["source_name"](source)
src.write_text(source, encoding="utf-8")
exe = str(workdir / "probe.exe")
built = _spawn(
entry["compile"](tool, str(src), exe),
workdir,
_child_env(lang, workdir),
COMPILE_TIMEOUT,
constrain_memory=False,
)
return built.returncode == 0 and Path(exe).is_file()
except (OSError, subprocess.SubprocessError):
return False
# The one place that says which languages can be executed. Each entry owns that
# language's screening, toolchain probe and argv. The tool schema's `lang` enum,
# the capability line in the system prompt and the dispatch below are all derived
# from these keys rather than repeating them.
#
# The frontend keeps a matching registry (RUN_LANGS in
# interface/web/src/preview/run-langs.js) because the two sides need different
# things per language — this side executes, that side labels and displays — and
# neither should depend on the other at runtime. tests/test_tools.py asserts the
# key sets stay equal, so drift fails the check gate instead of silently showing
# a run result with the wrong language on it.
RUN_LANGS: dict[str, dict] = {
"python": {
"summary": "Python script (stdlib only)",
"tool": lambda: sys.executable,
"install": "Python is bundled with NexusOS; this should not happen.",
"source_name": _source_name("main.py"),
# -I is isolated mode: ignores PYTHON* env vars, the user site-packages
# dir and the script's own directory on sys.path.
"compile": None,
"run": lambda tool, src, _exe: [tool, "-I", src],
"critique": _critique(_PY_RULES),
},
"c": {
"summary": "single-file C program (C11, libm linked)",
"tool": lambda: _compiled_tool("c", ("cc", "gcc", "clang")),
"install": "install a C compiler (clang or gcc)",
"source_name": _source_name("main.c"),
"compile": lambda cc, src, exe: [cc, "-std=c11", "-O0", "-Wall", "-o", exe, src, "-lm"],
"run": lambda _tool, _src, exe: [exe],
"critique": _critique(_C_RULES, _entry_point(
r"\bmain\s*\(", "no main() — a C program needs `int main(void)`.")),
},
"cpp": {
"summary": "single-file C++ program (C++17)",
"tool": lambda: _compiled_tool("cpp", ("c++", "g++", "clang++")),
"install": "install a C++ compiler (clang++ or g++)",
"source_name": _source_name("main.cpp"),
"compile": lambda cc, src, exe: [cc, "-std=c++17", "-O0", "-Wall", "-o", exe, src],
"run": lambda _tool, _src, exe: [exe],
"critique": _critique(_C_RULES, _entry_point(
r"\bmain\s*\(", "no main() — a C++ program needs `int main()`.")),
},
"rust": {
"summary": "single-file Rust program (2021 edition, std only)",
"tool": lambda: _compiled_tool("rust", ("rustc",)),
"install": "install Rust (https://rustup.rs)",
"source_name": _source_name("main.rs"),
# Debug build: -O roughly triples compile time for snippets that run for
# milliseconds either way.
"compile": lambda cc, src, exe: [cc, "--edition", "2021", "-o", exe, src],
"run": lambda _tool, _src, exe: [exe],
"critique": _critique(_RUST_RULES, _entry_point(
r"\bfn\s+main\s*\(", "no main() — a Rust program needs `fn main()`.")),
},
"erlang": {
"summary": "escript program with a main/1 entry point",
"tool": lambda: shutil.which("escript"),
"install": "install Erlang/OTP (provides escript)",
"source_name": _erlang_source_name,
"compile": None,
"run": lambda tool, src, _exe: [tool, src],
"critique": _critique(_ERL_RULES, _entry_point(
r"\bmain\s*\(", "no main/1 — escript calls `main(Args)`.")),
},
}
# Spellings a model reaches for that aren't the registry key. Resolved before
# lookup so `c++` and `py` work without doubling the tool schema's enum.
ALIASES = {
"c++": "cpp", "cc": "c", "py": "python", "python3": "python",
"rs": "rust", "erl": "erlang", "escript": "erlang",
}
def resolve_lang(lang: str) -> str:
key = (lang or "").strip().lower()
return ALIASES.get(key, key)
def lang_prose() -> str:
"""'python, c or rust' — the runnable languages as a phrase for prompts."""
names = list(RUN_LANGS)
if len(names) < 2:
return names[0] if names else ""
return f"{', '.join(names[:-1])} or {names[-1]}"
# ---------------------------------------------------------------------------
# Execution
# ---------------------------------------------------------------------------
# Environment variables the child keeps. Everything else is dropped: the snippet
# has no business seeing API keys, proxy settings or the user's shell config, and
# PYTHON*/LD_* in particular would let ambient config change how it runs.
_ENV_KEEP = ("PATH", "LANG", "LC_ALL", "TERM", "SYSTEMROOT", "COMSPEC")
# rustc is usually a rustup shim, and a shim with HOME rewritten cannot find its
# own toolchain. Passing these through is what makes Rust work at all here; they
# point at read-only toolchain data, not at anything the snippet should write.
_ENV_KEEP_BY_LANG = {"rust": ("RUSTUP_HOME", "CARGO_HOME", "RUSTUP_TOOLCHAIN")}
def _child_env(lang: str, workdir: Path) -> dict:
env = {k: os.environ[k] for k in _ENV_KEEP if k in os.environ}
for k in _ENV_KEEP_BY_LANG.get(lang, ()):
if k in os.environ:
env[k] = os.environ[k]
if lang == "rust" and "RUSTUP_HOME" not in env:
# HOME is about to be rewritten, so resolve rustup's default location
# against the real home while we still know it.
default = Path.home() / ".rustup"
if default.is_dir():
env["RUSTUP_HOME"] = str(default)
env.setdefault("CARGO_HOME", str(Path.home() / ".cargo"))
scratch = str(workdir)
env["HOME"] = scratch
env["TMPDIR"] = scratch
# Windows ignores HOME/TMPDIR in its standard path helpers. Without these,
# expanduser() reaches the real profile and GetTempPath() falls back to the
# Windows directory; GCC and rustc then either escape the scratch directory
# or fail because a normal user cannot write there.
env["TEMP"] = scratch
env["TMP"] = scratch
if os.name == "nt":
env["USERPROFILE"] = scratch
env.setdefault("LC_ALL", "C.UTF-8")
return env
def _user_process_count() -> int | None:
"""How many processes this UID already owns. None when we cannot count
(Windows, or psutil missing) callers must not invent an absolute cap."""
if os.name == "nt" or not hasattr(os, "getuid"):
return None
try:
import psutil # optional; process extra
except ImportError:
return None
uid = os.getuid()
n = 0
for proc in psutil.process_iter(["uids"]):
try:
uids = proc.info.get("uids")
if uids is not None and getattr(uids, "real", None) == uid:
n += 1
except (psutil.Error, TypeError, AttributeError):
continue
return n
def _max_procs_for(lang: str) -> int | None:
"""Soft RLIMIT_NPROC for this run, or None to leave the limit unset.
Erlang: current per-UID count + headroom (RLIMIT_NPROC is UID-scoped).
Everything else: the absolute _MAX_PROCS tripwire against fork bombs.
"""
if lang == "erlang":
current = _user_process_count()
if current is None:
return None
return current + _ERLANG_PROC_HEADROOM
return _MAX_PROCS
def _limits(constrain_memory: bool, max_procs: int | None = None):
"""preexec_fn applying POSIX rlimits, or None where they don't exist.
RLIMIT_CPU is a backstop for the wall-clock timeout: a snippet that ignores
SIGTERM still loses the CPU. The memory and process caps are skipped for
compilation see _MEM_BYTES.
`max_procs=None` with constrain_memory means "do not set RLIMIT_NPROC"
(used when we cannot compute a relative Erlang ceiling). Passing an int
always sets it.
"""
if sys.platform == "win32":
return None
try:
import resource
except ImportError: # pragma: no cover - POSIX only
return None
cpu = int(COMPILE_TIMEOUT if not constrain_memory else RUN_TIMEOUT) + 1
wanted = [("RLIMIT_CPU", cpu), ("RLIMIT_FSIZE", _MAX_FILE_BYTES), ("RLIMIT_CORE", 0)]
if constrain_memory:
wanted.append(("RLIMIT_AS", _MEM_BYTES))
# Distinguish "caller omitted" (use default) from "explicitly skip"
# by requiring the kw to be passed — see _spawn.
if max_procs is not None:
wanted.append(("RLIMIT_NPROC", max_procs))
def apply(): # runs in the forked child, between fork and exec
# Every limit is set independently and failure is swallowed. Which of
# these exist, and which can be lowered, varies by platform (macOS has
# no usable RLIMIT_AS, RLIMIT_NPROC is absent on some POSIX systems) —
# and an exception raised here does not "skip a limit", it aborts the
# spawn entirely. Partial limits are the right failure mode; no run at
# all is not.
for name, soft in wanted:
which = getattr(resource, name, None)
if which is None:
continue
try:
_, hard = resource.getrlimit(which)
if hard != resource.RLIM_INFINITY:
soft = min(soft, hard)
resource.setrlimit(which, (soft, hard))
except (ValueError, OSError):
continue
return apply
_net_isolation_cache: list | None = None
def _net_isolation() -> list[str]:
"""argv prefix that drops the child into an empty network namespace, or [].
Linux only, and only where unprivileged user namespaces are enabled which
is a kernel/distro setting we can't change and shouldn't fail over. Probed
once and cached; an empty list means the run simply has host networking, and
callers must not treat this as a guarantee either way.
"""
global _net_isolation_cache
if _net_isolation_cache is not None:
return _net_isolation_cache
_net_isolation_cache = []
if sys.platform.startswith("linux") and shutil.which("unshare"):
try:
probe = subprocess.run(
["unshare", "-rn", "true"],
capture_output=True, timeout=5,
)
if probe.returncode == 0:
_net_isolation_cache = ["unshare", "-rn"]
except (OSError, subprocess.SubprocessError):
pass
return _net_isolation_cache
def _clip(raw: bytes) -> str:
text = raw.decode("utf-8", errors="replace")
if len(text) <= MAX_OUTPUT:
return text
return text[:MAX_OUTPUT] + f"\n... [truncated at {MAX_OUTPUT} characters]"
def _spawn(argv: list[str], workdir: Path, env: dict, timeout: float,
stdin: str = "", constrain_memory: bool = True,
max_procs: int | None = _MAX_PROCS):
"""Spawn a child. `max_procs` defaults to `_MAX_PROCS`; pass `None` to skip
RLIMIT_NPROC entirely (Erlang, when a relative ceiling cannot be computed)."""
return subprocess.run(
argv,
cwd=str(workdir),
env=env,
input=stdin.encode("utf-8"),
capture_output=True,
timeout=timeout,
preexec_fn=_limits(constrain_memory, max_procs=max_procs),
)
def run(lang: str, source: str, stdin: str = "") -> dict:
"""Compile (if needed) and run `source`. Blocking — call from a thread.
Returns {ok, lang, stage, exit_code, stdout, stderr, error}. `ok` is False
only when the snippet could not be run at all (missing toolchain, compile
error, timeout); a program that runs and exits non-zero is a successful run
with a non-zero exit_code, because its stderr is the answer the user wants.
"""
key = resolve_lang(lang)
entry = RUN_LANGS.get(key)
if entry is None:
return {"ok": False, "lang": lang, "stage": "lang",
"error": f"cannot run {lang!r} — use {lang_prose()}."}
tool = entry["tool"]()
if not tool:
return {"ok": False, "lang": key, "stage": "toolchain",
"error": f"no toolchain for {key} on this machine — {entry['install']}. "
"Show the code instead of running it."}
with tempfile.TemporaryDirectory(prefix="nexus-run-") as tmp:
workdir = Path(tmp)
body = _erlang_write_source(source) if key == "erlang" else source
src = workdir / entry["source_name"](source)
src.write_text(body, encoding="utf-8")
exe = str(workdir / ("program.exe" if sys.platform == "win32" else "program"))
env = _child_env(key, workdir)
max_procs = _max_procs_for(key)
if entry["compile"]:
try:
built = _spawn(entry["compile"](tool, str(src), exe), workdir, env,
COMPILE_TIMEOUT, constrain_memory=False)
except subprocess.TimeoutExpired:
return {"ok": False, "lang": key, "stage": "compile",
"error": f"compilation timed out after {COMPILE_TIMEOUT:g}s."}
except OSError as e:
return {"ok": False, "lang": key, "stage": "compile",
"error": f"could not start the compiler: {e}"}
if built.returncode != 0:
return {"ok": False, "lang": key, "stage": "compile",
"exit_code": built.returncode,
"stdout": _clip(built.stdout), "stderr": _clip(built.stderr),
"error": "compilation failed — read stderr, fix the source, "
"and call run_snippet again."}
argv = _net_isolation() + entry["run"](tool, str(src), exe)
try:
done = _spawn(argv, workdir, env, RUN_TIMEOUT, stdin=stdin[:MAX_STDIN],
max_procs=max_procs)
except subprocess.TimeoutExpired as e:
return {"ok": False, "lang": key, "stage": "run",
"stdout": _clip(e.stdout or b""), "stderr": _clip(e.stderr or b""),
"error": f"the program did not finish within {RUN_TIMEOUT:g}s — "
"it is probably looping. Bound the work and try again."}
except OSError as e:
return {"ok": False, "lang": key, "stage": "run",
"error": f"could not start the program: {e}"}
stdout = _clip(done.stdout)
stderr = _clip(done.stderr)
# BEAM failing to fork under RLIMIT_NPROC looks like a snippet bug if we
# report ok=True. Call it out as a runner limit so the model does not keep
# rewriting a correct program.
if (
key == "erlang"
and done.returncode != 0
and "Resource temporarily unavailable" in stderr
):
return {
"ok": False,
"lang": key,
"stage": "run",
"exit_code": done.returncode,
"stdout": stdout,
"stderr": stderr,
"error": (
"Erlang could not start under the process limit (host already has "
"many processes for this user). Free some processes and retry, or "
"show the code instead of running it."
),
}
return {
"ok": True,
"lang": key,
"stage": "run",
"exit_code": done.returncode,
"stdout": stdout,
"stderr": stderr,
}
def critique(lang: str, source: str) -> list[str]:
"""Static screening for one snippet. See the module docstring on what this
is worth: a tripwire against habitual network/process reaches, not a
boundary."""
key = resolve_lang(lang)
entry = RUN_LANGS.get(key)
if entry is None:
return [f"cannot run {lang!r} — use {lang_prose()}."]
if len(source) > MAX_SOURCE:
return [f"source is over {MAX_SOURCE} characters — send a single focused snippet."]
return entry["critique"](source)
File diff suppressed because it is too large Load Diff
-22
View File
@@ -1,22 +0,0 @@
"""NexusOS's own Curry instance: preloaded at import time, ready to be called.
Curry (curry_core.py, vendored alongside this file) is an immutable, versioned
fact store - constants, functions, model registrations, and inference
provenance, backed by SQLite. Nothing in NexusOS wires chat/model-authored
content into it yet; this module only makes it available - `from
synapse.curry_store import curry_db` and call `declare_constant`,
`get_constant_latest`, `declare_function`, `call_function`, etc. directly, the
same way `synapse.memory.store.store` and `synapse.playbooks.store.playbook_store`
are used elsewhere in this codebase.
Kept as a separate database file (CURRY_DB) from the memory/conversation store
on purpose: Curry's schema and lifecycle are independent of the memory store's.
"""
from __future__ import annotations
from .curry_core import Curry
from .nexus_config import CURRY_DB
curry_db = Curry(str(CURRY_DB))
__all__ = ["curry_db"]
+48 -124
View File
@@ -84,19 +84,6 @@ _RENDER_PREAMBLE = (
"CSS/JS, no network.\n" "CSS/JS, no network.\n"
) )
# The execution track's hint, on the same terms as the render one: offered only
# when the tool behind it is, for the same contamination reason. The distinction
# it has to carry is which track a request belongs to — a model that reaches for
# run_snippet to "preview" an HTML page gets a compile error, and one that
# reaches for render_preview to run a C program gets a plain code block.
_RUN_PREAMBLE = (
"\n\n---\nCode runner: when the answer depends on what code actually does, call "
f"the `run_snippet` tool with a complete {_tools.code_run.lang_prose()} program, "
"then paste the returned `fence` into your reply. It really runs, in a throwaway "
"directory with no network and a few seconds of CPU. Describe only the output it "
"returned.\n"
)
_CODING_KEYWORDS = frozenset({ _CODING_KEYWORDS = frozenset({
"code", "coding", "function", "class", "method", "variable", "bug", "error", "code", "coding", "function", "class", "method", "variable", "bug", "error",
@@ -151,8 +138,7 @@ async def _auto_select_model(message: str = "") -> str:
if remap: if remap:
return remap return remap
return await get_ollama_manager().select_best_model(intent) return await get_ollama_manager().select_best_model(intent)
except Exception as e: except Exception:
_synapse_trace(f"⚠ auto model selection failed, falling back to default: {e}\n")
return DEFAULT_CHAT_MODEL return DEFAULT_CHAT_MODEL
@@ -204,16 +190,13 @@ async def _generate_conversation_title(first_message: str, model: str) -> Option
if not title: if not title:
return None return None
return title[:120] return title[:120]
except Exception as e: except Exception:
_synapse_trace(f"⚠ title generation failed: {e}\n")
return None return None
from .memory.store import store, MemoryItem from .memory.store import store, MemoryItem
from .playbooks.store import playbook_store from .playbooks.store import playbook_store, PlaybookItem
from .curry_store import curry_db # noqa: F401 - import triggers Curry's own preload at startup
from .search import needs_web_search, web_search from .search import needs_web_search, web_search
from . import slash_commands as _slash_commands
app = FastAPI(title="Synapse Backend", version=VERSION) app = FastAPI(title="Synapse Backend", version=VERSION)
@@ -488,47 +471,6 @@ async def _resume_dropped_extractions() -> None:
# ------------------------- # -------------------------
# Chat (streaming) # Chat (streaming)
async def _slash_command_stream(
slash: "_slash_commands.SlashCommand | _slash_commands.SlashCommandError",
conversation_id: str,
) -> AsyncGenerator[str, None]:
"""Dispatch a parsed slash-command and stream its result the same shape a
normal reply streams in a single content chunk, `event: done`, nothing
else. No model call, no tool-loop, no approval round-trip: see
slash_commands.py for why that's the deliberate design here."""
if isinstance(slash, _slash_commands.SlashCommandError):
yield f"event: error\ndata: {_json.dumps({'detail': slash.text})}\n\n"
return
if slash.tool not in _tools.REGISTRY:
yield (
"event: error\ndata: "
f"{_json.dumps({'detail': f'unknown tool: {slash.tool}'})}\n\n"
)
return
yield f"event: status\ndata: {_json.dumps({'tool': slash.tool})}\n\n"
raw_result = await _tools.dispatch(slash.tool, slash.args)
# Tools that emit a fence (curry_*, edit_*, run_snippet) carry it as
# `"fence"` in their JSON result — reuse it verbatim so the existing
# nexus-run/nexus-edit renderers pick it up with no new frontend code.
# Anything else is shown as pretty-printed JSON.
content = raw_result
try:
parsed = _json.loads(raw_result)
if isinstance(parsed, dict) and isinstance(parsed.get("fence"), str):
content = parsed["fence"]
else:
content = _json.dumps(parsed, indent=2, ensure_ascii=False)
except (TypeError, ValueError):
pass
store.add_message(conversation_id, "assistant", content)
yield f"data: {_json.dumps(content)}\n\n"
yield "event: done\ndata: {}\n\n"
# ------------------------- # -------------------------
@app.post("/chat/stream") @app.post("/chat/stream")
async def chat_stream_endpoint(payload: Dict[str, Any]): async def chat_stream_endpoint(payload: Dict[str, Any]):
@@ -539,37 +481,13 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
_chat_slot_held = True _chat_slot_held = True
try: try:
message = payload.get("message", "") message = payload.get("message", "")
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
if not message:
raise HTTPException(status_code=400, detail="Missing 'message'")
# Direct tool invocation: /tool_name(arg=val, ...). A human typing this
# IS the approval, so it skips model selection, RAG/playbook context
# assembly, and the ask-policy round-trip entirely — see
# slash_commands.py for what it does and does not bypass.
_slash = _slash_commands.parse_slash_command(message)
if _slash is not None:
store.create_conversation(conversation_id, "")
store.add_message(conversation_id, "user", message)
_slash_inner = _slash_command_stream(_slash, conversation_id)
async def _slash_guarded() -> AsyncGenerator[str, None]:
try:
async for _chunk in _slash_inner:
yield _chunk
finally:
_CHAT_INFLIGHT.release()
_chat_slot_held = False
return StreamingResponse(_slash_guarded(), media_type="text/event-stream")
app_settings = store.get_settings() app_settings = store.get_settings()
# Model precedence: explicit request > active playbook's pinned model > auto-select. # Model precedence: explicit request > active playbook's pinned model > auto-select.
_active_pb = playbook_manager.get_main_playbook() _active_pb = playbook_manager.get_main_playbook()
_pb_model = _active_pb.model if (_active_pb and _active_pb.model) else "" _pb_model = _active_pb.model if (_active_pb and _active_pb.model) else ""
model = payload.get("model") or _pb_model or await _auto_select_model(message) model = payload.get("model") or _pb_model or await _auto_select_model(message)
context = payload.get("context", {}) context = payload.get("context", {})
conversation_id = payload.get("conversation_id") or str(_uuid.uuid4())
history = payload.get("history", []) history = payload.get("history", [])
temperature = payload.get("temperature", app_settings.get("temperature")) temperature = payload.get("temperature", app_settings.get("temperature"))
num_ctx = payload.get("num_ctx", app_settings.get("num_ctx", 0)) num_ctx = payload.get("num_ctx", app_settings.get("num_ctx", 0))
@@ -577,6 +495,9 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1)) gpu_offload = payload.get("gpu_offload", app_settings.get("gpu_offload", -1))
num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model) num_gpu = await get_ollama_manager().resolve_num_gpu(gpu_offload, model)
if not message:
raise HTTPException(status_code=400, detail="Missing 'message'")
# Resolve the project scope: an existing conversation keeps its bound project; # Resolve the project scope: an existing conversation keeps its bound project;
# a brand-new one inherits the current workspace (active_project setting). # a brand-new one inherits the current workspace (active_project setting).
# Everything project-scoped below (instructions, memory facts, RAG) uses it. # Everything project-scoped below (instructions, memory facts, RAG) uses it.
@@ -657,16 +578,8 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
# is implemented using a tool called render_preview... renders it live in # is implemented using a tool called render_preview... renders it live in
# a sandbox" — this text, recited as fact. A hint for a tool that isn't # a sandbox" — this text, recited as fact. A hint for a tool that isn't
# being offered is pure contamination. # being offered is pure contamination.
# Read once here rather than at the tools block below: the run-track
# hint and the run-track schema have to agree about whether the tool is
# on offer, and a second lookup is a second thing to keep in step.
_policy = app_settings.get("action_tool_policy", "off")
allow_actions = _policy != "off"
if _tools.wants_render_preview(message): if _tools.wants_render_preview(message):
system_prompt = (system_prompt + _RENDER_PREAMBLE) if system_prompt else _RENDER_PREAMBLE.lstrip() system_prompt = (system_prompt + _RENDER_PREAMBLE) if system_prompt else _RENDER_PREAMBLE.lstrip()
if allow_actions and _tools.wants_code_run(message):
system_prompt = (system_prompt + _RUN_PREAMBLE) if system_prompt else _RUN_PREAMBLE.lstrip()
# ── MindTrace pre-flight ────────────────────────────────────────── # ── MindTrace pre-flight ──────────────────────────────────────────
_trace_intent = _detect_intent(message) if message else "chat" _trace_intent = _detect_intent(message) if message else "chat"
@@ -727,14 +640,12 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
if images: if images:
metadata["images"] = images metadata["images"] = images
# Tools: playbook allowlist (main playbook AND the reference playbooks # Tools: playbook allowlist (including routed reference playbooks), plus
# _route_playbooks picked for this message — a routed playbook's # render_preview only when this turn looks like a visual ask. Always
# instructions are already in the prompt, so its abilities have to come # advertising it forced a non-stream tool round on every chat and felt
# with them or the model narrates tools it was never given), plus # like "stuck thinking".
# render_preview/run_snippet on a cue even when no playbook grants them _policy = app_settings.get("action_tool_policy", "off")
# (always advertising render_preview forced a non-stream tool round on allow_actions = _policy != "off"
# every chat and felt like "stuck thinking"). _policy/allow_actions were
# already computed above, in step with the capability-hint injection.
_pb_tools = list(dict.fromkeys( _pb_tools = list(dict.fromkeys(
(getattr(_main_pb, "tools", None) or [] if _main_pb else []) (getattr(_main_pb, "tools", None) or [] if _main_pb else [])
+ [t for pb in context_pbs for t in (getattr(pb, "tools", None) or [])] + [t for pb in context_pbs for t in (getattr(pb, "tools", None) or [])]
@@ -743,12 +654,6 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
if _tools.wants_render_preview(message) or "render_preview" in _pb_tools: if _tools.wants_render_preview(message) or "render_preview" in _pb_tools:
for s in _tools.standing_schemas(): for s in _tools.standing_schemas():
schemas_by_name[s["function"]["name"]] = s schemas_by_name[s["function"]["name"]] = s
# run_snippet rides the same cue mechanism but stays behind the action
# gate: it executes code on this machine. With the policy "off", "run
# this" gets an explanation and a code block, never a subprocess.
if allow_actions and _tools.wants_code_run(message):
for s in _tools.run_schemas():
schemas_by_name[s["function"]["name"]] = s
for s in _tools.schemas_for(_pb_tools, allow_actions): for s in _tools.schemas_for(_pb_tools, allow_actions):
schemas_by_name[s["function"]["name"]] = s schemas_by_name[s["function"]["name"]] = s
schemas = list(schemas_by_name.values()) schemas = list(schemas_by_name.values())
@@ -838,8 +743,8 @@ async def chat_stream_endpoint(payload: Dict[str, Any]):
if title: if title:
store.set_conversation_title(conversation_id, title) store.set_conversation_title(conversation_id, title)
yield f"event: title\ndata: {_json.dumps({'title': title})}\n\n" yield f"event: title\ndata: {_json.dumps({'title': title})}\n\n"
except Exception as e: except Exception:
_synapse_trace(f"⚠ conversation titling step failed: {e}\n") pass
# Hand the conversation to the curator once it goes quiet. Not now: # Hand the conversation to the curator once it goes quiet. Not now:
# the curator is the chat model, and Ollama runs one request at a # the curator is the chat model, and Ollama runs one request at a
@@ -1261,14 +1166,37 @@ def _find_playbook_by_id(playbook_id: str) -> Tuple[Optional[Any], Optional[str]
def _persist_playbook(playbook_dict: Dict[str, Any]) -> Dict[str, Any]: def _persist_playbook(playbook_dict: Dict[str, Any]) -> Dict[str, Any]:
"""Persist a playbook dict to the store. Thin wrapper over the shared, """
mergeable implementation in playbook_manager this call site always does a Persist a playbook dict to the store by converting to PlaybookItem.
full replace (merge=False), matching the frontend form's behavior of always """
submitting a complete object."""
try: try:
return playbook_manager.persist_playbook(dict(playbook_dict), merge=False) # Preserve existing order on update; use provided order (or tail) on create
except (ValueError, RuntimeError) as e: existing = playbook_store.get_playbook(str(playbook_dict["id"]))
raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(e)}") order = existing.order if existing else playbook_dict.get("order", len(playbook_store.all_playbooks()))
playbook_item = PlaybookItem(
id=str(playbook_dict["id"]),
title=playbook_dict.get("title", ""),
goal=playbook_dict.get("goal", ""),
instructions=playbook_dict.get("instructions", ""),
tags=playbook_dict.get("tags", []),
tools=playbook_dict.get("tools", []),
model=playbook_dict.get("model", ""),
order=order
)
playbook_store.add_playbook(playbook_item)
# Return as dict
return {
"id": playbook_item.id,
"title": playbook_item.title,
"goal": playbook_item.goal,
"instructions": playbook_item.instructions,
"tags": playbook_item.tags,
"tools": playbook_item.tools,
"model": playbook_item.model,
}
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(e)}") raise HTTPException(status_code=500, detail=f"Failed to persist playbook: {str(e)}")
@@ -1718,14 +1646,10 @@ async def list_icon_apps():
@app.get("/icons/image") @app.get("/icons/image")
async def get_icon_image(path: str): async def get_icon_image(path: str):
"""Serve an icon file after verifying it's in an allowed root.""" """Serve an icon file after verifying it's in an allowed root."""
real = Path(_os.path.realpath(path)) real = _os.path.realpath(path)
allowed = any( if not any(real.startswith(r) for r in _ALLOWED_ICON_ROOTS):
real == root or root in real.parents
for root in (Path(r).resolve() for r in _ALLOWED_ICON_ROOTS)
)
if not allowed:
raise HTTPException(status_code=403, detail="Path not allowed") raise HTTPException(status_code=403, detail="Path not allowed")
if not real.is_file(): if not _os.path.isfile(real):
raise HTTPException(status_code=404, detail="Icon not found") raise HTTPException(status_code=404, detail="Icon not found")
return FileResponse(real) return FileResponse(real)
+31 -30
View File
@@ -583,12 +583,25 @@ class PersistentMemoryStore:
conn = self._connect() conn = self._connect()
try: try:
cur = conn.cursor() cur = conn.cursor()
# Drop the embeddings first, while the message ids still resolve - # Drop the embeddings first, while the message ids still resolve.
# the delete-side counterpart of _vec_upsert_msg (see its docstring). # Stale vectors are inert (the search joins messages) but they still
ids = [r["id"] for r in cur.execute( # occupy slots in the ANN over-fetch, so leaving them behind quietly
"SELECT id FROM messages WHERE conversation_id = ?", (conversation_id,) # thins recall of the conversations that are still here.
).fetchall()] cur.execute(
self._delete_message_vectors(conn, ids) "DELETE FROM message_vectors WHERE message_id IN "
"(SELECT id FROM messages WHERE conversation_id = ?)",
(conversation_id,),
)
# vec_enabled only says the extension loaded; the virtual table is
# created lazily on the first semantic search, so check for it.
if self.vec_enabled and cur.execute(
"SELECT 1 FROM sqlite_master WHERE name = 'vec_messages'"
).fetchone():
cur.execute(
"DELETE FROM vec_messages WHERE rowid IN "
"(SELECT id FROM messages WHERE conversation_id = ?)",
(conversation_id,),
)
cur.execute("DELETE FROM messages WHERE conversation_id = ?", (conversation_id,)) cur.execute("DELETE FROM messages WHERE conversation_id = ?", (conversation_id,))
cur.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,)) cur.execute("DELETE FROM conversations WHERE id = ?", (conversation_id,))
conn.commit() conn.commit()
@@ -777,28 +790,6 @@ class PersistentMemoryStore:
except Exception: except Exception:
pass pass
def _delete_message_vectors(self, conn, message_ids) -> None:
"""Drop the cached embeddings of messages that are about to be deleted,
mirroring the removal into the ANN index the delete-side counterpart of
`_vec_upsert_msg`. Retrieval already ignores orphans (it inner-joins
messages), but `messages.id` is AUTOINCREMENT so a stale vector is never
overwritten either: without this the table and index only ever grow."""
ids = list(message_ids)
if not ids:
return
for i in range(0, len(ids), 500): # stay under SQLite's variable limit
batch = ids[i:i + 500]
conn.execute(
f"DELETE FROM message_vectors WHERE message_id IN ({','.join('?' * len(batch))})",
batch,
)
if self.vec_enabled:
try:
for mid in ids:
conn.execute("DELETE FROM vec_messages WHERE rowid = ?", (mid,))
except Exception:
pass # index absent / extension unavailable — it's only a mirror
def _sweep_orphan_msg_vectors(self, conn) -> None: def _sweep_orphan_msg_vectors(self, conn) -> None:
"""One-time repair for databases written before delete_conversation """One-time repair for databases written before delete_conversation
cleaned up after itself: drop vectors whose message is already gone.""" cleaned up after itself: drop vectors whose message is already gone."""
@@ -808,7 +799,17 @@ class PersistentMemoryStore:
"LEFT JOIN messages m ON m.id = v.message_id WHERE m.id IS NULL" "LEFT JOIN messages m ON m.id = v.message_id WHERE m.id IS NULL"
).fetchall()] ).fetchall()]
if ids: if ids:
self._delete_message_vectors(conn, ids) conn.execute(
"DELETE FROM message_vectors WHERE message_id NOT IN "
"(SELECT id FROM messages)"
)
if self.vec_enabled and conn.execute(
"SELECT 1 FROM sqlite_master WHERE name = 'vec_messages'"
).fetchone():
for message_id in ids:
conn.execute(
"DELETE FROM vec_messages WHERE rowid = ?", (message_id,)
)
except Exception: except Exception:
pass pass
@@ -1239,4 +1240,4 @@ class PersistentMemoryStore:
from ..nexus_config import MEMORY_DB from ..nexus_config import MEMORY_DB
DB_PATH = MEMORY_DB DB_PATH = MEMORY_DB
store = PersistentMemoryStore(DB_PATH) store = PersistentMemoryStore(DB_PATH)
+5 -14
View File
@@ -160,11 +160,6 @@ SEED_PLAYBOOK_DIR = (
# --- DATABASE / STORAGE FILES (match your repo) --- # --- DATABASE / STORAGE FILES (match your repo) ---
MEMORY_DB = _configured_path("memory_db", "NEXUS_MEMORY_DB", MEMORY_DIR / "memory.db") MEMORY_DB = _configured_path("memory_db", "NEXUS_MEMORY_DB", MEMORY_DIR / "memory.db")
# Vendored Curry (synapse/curry_core.py) database: immutable versioned
# constants/functions/models + inference provenance. Separate file from
# MEMORY_DB on purpose - Curry's schema and lifecycle are independent of the
# memory/conversation store.
CURRY_DB = _configured_path("curry_db", "NEXUS_CURRY_DB", DATA_DIR / "curry.db")
# --- LOG FILES --- # --- LOG FILES ---
BACKEND_LOG = RUNTIME_DIR / "backend.log" BACKEND_LOG = RUNTIME_DIR / "backend.log"
@@ -185,7 +180,6 @@ _REQUIRED_DIRS = (
UPLOADS_DIR, UPLOADS_DIR,
EXPORTS_DIR, EXPORTS_DIR,
MEMORY_DB.parent, MEMORY_DB.parent,
CURRY_DB.parent,
) )
@@ -323,13 +317,9 @@ class Settings:
"bind_host", "NEXUS_BIND_HOST", "127.0.0.1" "bind_host", "NEXUS_BIND_HOST", "127.0.0.1"
)) ))
self.backend_port: int = _int_value("backend_port", "NEXUS_BACKEND_PORT", 8000) self.backend_port: int = _int_value("backend_port", "NEXUS_BACKEND_PORT", 8000)
self.memory_port: int = _int_value("memory_port", "NEXUS_MEMORY_PORT", 8001)
self.api_url: str = str(_value( self.api_url: str = str(_value(
"api_url", "NEXUS_API", f"http://127.0.0.1:{self.backend_port}" "api_url", "NEXUS_API", f"http://127.0.0.1:{self.backend_port}"
)).rstrip("/") )).rstrip("/")
self.memory_url: str = str(_value(
"memory_url", "NEXUS_MEMORY_URL", f"http://127.0.0.1:{self.memory_port}"
)).rstrip("/")
def as_dict(self) -> Dict[str, Any]: def as_dict(self) -> Dict[str, Any]:
return { return {
@@ -351,8 +341,6 @@ class Settings:
"api_url": self.api_url, "api_url": self.api_url,
"bind_host": self.bind_host, "bind_host": self.bind_host,
"backend_port": self.backend_port, "backend_port": self.backend_port,
"memory_port": self.memory_port,
"memory_url": self.memory_url,
} }
# --- local-access allowlists (shared by the backend + memory FastAPI apps) --- # --- local-access allowlists (shared by the backend + memory FastAPI apps) ---
@@ -373,7 +361,10 @@ _LOCAL_HOSTS = ["localhost", "127.0.0.1", "[::1]", "::1", "testserver"]
_LOCAL_ORIGINS = [ _LOCAL_ORIGINS = [
f"http://{h}:{p}" f"http://{h}:{p}"
for h in ("localhost", "127.0.0.1") for h in ("localhost", "127.0.0.1")
for p in (_int_value("backend_port", "NEXUS_BACKEND_PORT", 8000), 5173) for p in (
_int_value("backend_port", "NEXUS_BACKEND_PORT", 8000),
5173,
)
] ]
_LOCAL_ORIGINS.extend(["capacitor://localhost", "https://localhost"]) _LOCAL_ORIGINS.extend(["capacitor://localhost", "https://localhost"])
ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS) ALLOWED_HOSTS = _csv_env("NEXUS_ALLOWED_HOSTS", _LOCAL_HOSTS)
@@ -426,7 +417,7 @@ __all__ = ["Settings", "settings", "path", "VERSION",
"read_user_config", "write_user_config", "init_state", "INITIALIZED_FILES", "read_user_config", "write_user_config", "init_state", "INITIALIZED_FILES",
"DATA_DIR", "MODELS_DIR", "RUNTIME_DIR", "DATA_DIR", "MODELS_DIR", "RUNTIME_DIR",
"MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR", "MEMORY_DIR", "LOGS_DIR", "PLAYBOOK_DIR", "UPLOADS_DIR",
"EXPORTS_DIR", "MEMORY_DB", "CURRY_DB", "WEB_DIST_DIR", "FRONTEND_SOURCE_DIR", "EXPORTS_DIR", "MEMORY_DB", "WEB_DIST_DIR", "FRONTEND_SOURCE_DIR",
"ASSETS_DIR", "SEED_PLAYBOOK_DIR", "ASSETS_DIR", "SEED_PLAYBOOK_DIR",
"BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG", "BACKEND_LOG", "OLLAMA_LOG", "CHAT_LOG",
"ALLOWED_HOSTS", "ALLOWED_ORIGINS", "ALLOWED_HOSTS", "ALLOWED_ORIGINS",
-53
View File
@@ -1,59 +1,6 @@
from typing import List from typing import List
from uuid import uuid4
from .playbooks.store import playbook_store, PlaybookItem from .playbooks.store import playbook_store, PlaybookItem
# Fields a caller can supply; anything else in a persist dict is ignored.
# `order` is deliberately excluded — see persist_playbook.
_EDITABLE_FIELDS = ("title", "goal", "instructions", "tags", "tools", "model")
_FIELD_DEFAULTS = {"title": "", "goal": "", "instructions": "", "tags": [], "tools": [], "model": ""}
def persist_playbook(data: dict, *, merge: bool) -> dict:
"""Write a playbook dict to the store, returning it as a plain dict.
`merge=False` is today's behavior (main.py's HTTP form handlers): a full
replace, with pydantic defaults for anything omitted. `merge=True` (used by
the edit_playbook tool) instead keeps each field's *existing* value when the
caller's dict doesn't supply it a model calling with a partial argument
set must not silently blank out the fields it didn't mention.
`order` is never taken from `data` in merge mode: it is preserved from the
existing playbook on update, or appended at the tail on create. Position 0
is unconditionally the active system prompt (see get_main_playbook) moving
a playbook there is `make_main`'s job, never an accidental side effect of an
ordinary field edit.
"""
existing_id = str(data.get("id") or "")
existing = playbook_store.get_playbook(existing_id) if existing_id else None
if merge:
fields = {}
for key in _EDITABLE_FIELDS:
if key in data and data[key] is not None:
fields[key] = data[key]
elif existing is not None:
fields[key] = getattr(existing, key)
else:
fields[key] = _FIELD_DEFAULTS[key]
if existing is None and not (fields["title"] and fields["goal"] and fields["instructions"]):
raise ValueError("title, goal, and instructions are required to create a new playbook")
else:
fields = {key: data.get(key, _FIELD_DEFAULTS[key]) for key in _EDITABLE_FIELDS}
order = existing.order if existing else data.get("order", len(playbook_store.all_playbooks()))
item = PlaybookItem(id=existing_id or str(uuid4()), order=order, **fields)
playbook_store.add_playbook(item)
return item.model_dump()
def make_main(playbook_id: str) -> None:
"""Reorder so `playbook_id` is position 0 (the active system prompt)."""
all_ids = [p.id for p in playbook_store.all_playbooks()]
if playbook_id not in all_ids:
raise ValueError(f"no playbook with id {playbook_id!r}")
ordered = [playbook_id] + [pid for pid in all_ids if pid != playbook_id]
playbook_store.reorder_playbooks(ordered)
def _all() -> List[PlaybookItem]: def _all() -> List[PlaybookItem]:
"""Return all playbooks sorted by order (position 0 is always main).""" """Return all playbooks sorted by order (position 0 is always main)."""
-290
View File
@@ -1,290 +0,0 @@
"""The assistant's ability to change what it is: its own playbooks, its own
runtime settings, and (in a source checkout only) its own source files.
WHAT THIS IS NOT
----------------
Not a way to skip human review. Every function here is either read-only
(the `preview_*`/`diff_text` functions, safe to call before anything is
approved) or is only ever reached after the per-call approval round-trip in
chat.py and `edit_playbook`/`edit_settings`/`edit_source` are *always*
gated that way, regardless of the global `action_tool_policy` setting (see
`ALWAYS_ASK_TOOLS`). This module is the mechanism the approval actually acts
on, layered:
1. Consent edit_* tools are ACTION tools with a hardcoded approval
floor chat.py pauses for Approve/Deny even when the
global policy is "allow", so flipping that setting for an
unrelated tool (web_search, say) can never silently unlock
unattended self-modification too.
2. Real diff the human reviews a diff computed here, server-side, from
what is actually on disk versus the model's proposed
`new_content` never a diff or description the model wrote
itself. A model can misdescribe a change; it cannot make
difflib misreport one.
3. Boundary edit_source is confined to one root (settings.project_root)
via the same realpath + `Path.parents` check that closed a
sibling-directory bypass in main.py's /icons/image, plus a
denylist of dangerous subtrees inside that root (.git, the
venv, node_modules, build output, runtime state).
4. Audit trail every applied source edit is committed to git (best-effort;
a missing git binary or a non-repo root degrades the result
to `commit: None`, it never blocks the write). This is a
reversibility net, not a substitute for layer 1 the
approval already happened before anything is written.
5. Size caps MAX_FILE_CHARS/MAX_DIFF_CHARS bound what a single call can
submit or what the approval UI has to render, mirroring
code_run.py's MAX_SOURCE/MAX_OUTPUT.
A file edit does not hot-reload the running process. Python does not re-import
a changed module on its own, and the frontend's production build is the static
`dist/` the backend serves editing interface/web/src/*.jsx only affects a
developer's own `npm run dev` session, if one happens to be running. Every
successful edit_source result says so explicitly, because both the model and
the human reviewing it will otherwise reasonably expect an instant effect that
does not happen.
"""
from __future__ import annotations
import difflib
import os
import subprocess
from pathlib import Path
from typing import Any
from .memory.store import store
from .nexus_config import settings
from .playbooks.store import playbook_store
from . import playbook_manager
MAX_FILE_CHARS = 200_000 # chars of proposed file content accepted
MAX_DIFF_CHARS = 20_000 # chars of diff text shown/returned, clipped like code_run.MAX_OUTPUT
# Subdirectories of settings.project_root that edit_source must never touch,
# even though they're inside the one allowed root. `data`/`runtime` land here
# in a source checkout (see nexus_config.py DATA_DIR/RUNTIME_DIR) and are owned
# by their own tools (edit_settings, the memory store), not raw file writes.
_DENYLIST_SUBDIRS = frozenset({
".git", "Promethean", "node_modules", "dist", "__pycache__",
"runtime", "data", ".venv", "venv",
})
# Tools that must always pause for human approval, regardless of the global
# action_tool_policy setting. Shared by tools.py (ACTION_TOOLS membership) and
# chat.py (the approval-gating condition) so there is one definition of the
# floor, not two that could drift apart.
ALWAYS_ASK_TOOLS = frozenset({"edit_playbook", "edit_settings", "edit_source"})
# Tool names whose approval payload gets a computed, human-readable preview
# attached before the human ever sees the Approve/Deny prompt.
PREVIEWABLE = frozenset({"edit_source", "edit_playbook", "edit_settings"})
WINDOWS = os.name == "nt"
_NO_WINDOW = subprocess.CREATE_NO_WINDOW if WINDOWS else 0
class PathError(ValueError):
pass
def _resolve_source_path(rel_path: str) -> Path:
"""A path the model gave us, resolved and boundary-checked against
settings.project_root. Raises PathError with a human-readable reason on
any rejection the same message is shown in the pre-approval preview and
returned as the tool's error, so both audiences see exactly why."""
raw = (rel_path or "").strip()
if not raw:
raise PathError("path is required")
# Cheap rejection before ever touching the filesystem: an absolute path or
# a Windows drive prefix is never a legitimate "file in this project" path.
if raw.startswith(("/", "\\")) or (len(raw) > 1 and raw[1] == ":"):
raise PathError(f"path must be relative to the project root, not absolute: {raw!r}")
root = Path(os.path.realpath(str(settings.project_root)))
candidate = root / raw
real = Path(os.path.realpath(str(candidate)))
# Same real == root or root in real.parents pattern as
# icons/compositor.py::_is_allowed_path — a bare str.startswith() here
# would let a sibling directory that merely shares a prefix pass, exactly
# the bug just fixed in main.py's /icons/image.
if not (real == root or root in real.parents):
raise PathError(f"path escapes the project root: {raw!r}")
try:
top = real.relative_to(root).parts[0]
except (ValueError, IndexError):
top = ""
if top in _DENYLIST_SUBDIRS:
raise PathError(f"{top}/ is off-limits to edit_source — use its own tool if there is one")
return real
def source_checkout_required() -> None:
"""Raise if this install has no live, editable source tree to write into.
In a wheel install, synapse/ lives inside site-packages with no sibling
repo settings.project_root would just be the installed package dir, and
there is nothing to commit into. Same precedent as nexusos_cli/ncp.py
refusing to start the Vite dev server in a wheel install."""
if not settings.source_checkout:
raise RuntimeError(
"edit_source is unavailable — this is not a source checkout, so "
"there is no live project tree to edit or commit into."
)
def diff_text(path_display: str, before: str, after: str) -> str:
lines = difflib.unified_diff(
before.splitlines(keepends=True),
after.splitlines(keepends=True),
fromfile=path_display,
tofile=path_display,
)
text = "".join(lines)
if len(text) <= MAX_DIFF_CHARS:
return text
return text[:MAX_DIFF_CHARS] + f"\n... [truncated at {MAX_DIFF_CHARS} characters, {len(text)} total]"
def preview_source_edit(path: str, new_content: str) -> dict:
"""Read-only: never raises. Computes the real diff between what's on disk
and the proposed new_content, so the approval UI shows ground truth before
anything is written. Degrades to {"ok": False, "error": ...} on any
rejection (bad path, wheel install, oversized content) rather than
crashing the approval payload the human still sees why it would fail."""
try:
source_checkout_required()
real = _resolve_source_path(path)
new_content = new_content or ""
if len(new_content) > MAX_FILE_CHARS:
return {"ok": False, "error": f"new_content is over {MAX_FILE_CHARS} characters"}
before = real.read_text(encoding="utf-8") if real.is_file() else ""
display = str(real.relative_to(Path(os.path.realpath(str(settings.project_root)))))
return {
"ok": True,
"path": display,
"is_new_file": not real.is_file(),
"diff": diff_text(display, before, new_content),
}
except (PathError, RuntimeError) as e:
return {"ok": False, "error": str(e)}
except Exception as e:
return {"ok": False, "error": f"could not compute preview: {e}"}
def _git_commit(real_path: Path, summary: str) -> str | None:
"""Best-effort audit-trail commit. Never raises, never undoes the write
that already happened a missing git binary or a project root that isn't
a repo just means commit stays None."""
root = str(settings.project_root)
try:
rel = str(real_path.relative_to(Path(os.path.realpath(root))))
message = f"self-edit: {(summary or rel)[:180]}"
subprocess.run(
["git", "add", "--", rel], cwd=root, check=True,
capture_output=True, creationflags=_NO_WINDOW,
)
subprocess.run(
["git", "commit", "-m", message, "--", rel], cwd=root, check=True,
capture_output=True, creationflags=_NO_WINDOW,
)
sha = subprocess.run(
["git", "rev-parse", "--short", "HEAD"], cwd=root, check=True,
capture_output=True, text=True, creationflags=_NO_WINDOW,
)
return sha.stdout.strip() or None
except Exception:
return None
def apply_source_edit(path: str, new_content: str, summary: str = "") -> dict:
"""Write an approved edit_source call. Re-validates everything preview did
never trust that nothing changed between preview and approval then
writes, diffs against the pre-write content, and commits."""
try:
source_checkout_required()
real = _resolve_source_path(path)
new_content = new_content or ""
if len(new_content) > MAX_FILE_CHARS:
return {"ok": False, "error": f"new_content is over {MAX_FILE_CHARS} characters"}
except (PathError, RuntimeError) as e:
return {"ok": False, "error": str(e)}
before = real.read_text(encoding="utf-8") if real.is_file() else ""
display = str(real.relative_to(Path(os.path.realpath(str(settings.project_root)))))
real.parent.mkdir(parents=True, exist_ok=True)
real.write_text(new_content, encoding="utf-8")
return {
"ok": True,
"path": display,
"diff": diff_text(display, before, new_content),
"commit": _git_commit(real, summary),
}
def preview_playbook_edit(args: dict) -> dict:
"""Read-only before/after preview for edit_playbook. Mirrors the merge
semantics of playbook_manager.persist_playbook(merge=True) without writing
anything, so the approval UI shows exactly what will actually change."""
try:
pb_id = str(args.get("id") or "")
existing = playbook_store.get_playbook(pb_id) if pb_id else None
make_active = bool(args.get("make_active"))
fields = {}
for key in ("title", "goal", "instructions", "tags", "tools", "model"):
if key in args and args[key] is not None:
fields[key] = args[key]
elif existing is not None:
fields[key] = getattr(existing, key)
else:
fields[key] = [] if key in ("tags", "tools") else ""
if existing is None and not (fields["title"] and fields["goal"] and fields["instructions"]):
return {"ok": False, "error": "title, goal, and instructions are required to create a new playbook"}
is_new = existing is None
becomes_main = make_active or (is_new and not playbook_store.all_playbooks())
return {
"ok": True,
"is_new": is_new,
"before": existing.model_dump() if existing else None,
"after": fields,
"becomes_main_playbook": becomes_main,
}
except Exception as e:
return {"ok": False, "error": f"could not compute preview: {e}"}
def preview_settings_edit(args: dict) -> dict:
"""Read-only before/after preview for edit_settings, split into keys that
will actually apply versus ones update_settings would silently ignore
the approval UI must never imply an unknown key will take effect."""
try:
changes = args.get("changes") or {}
current = store.get_settings()
applied: dict[str, dict[str, Any]] = {}
ignored_unknown: list[str] = []
for key, value in changes.items():
if key in store._SETTINGS_DEFAULTS:
applied[key] = {"before": current.get(key), "after": value}
else:
ignored_unknown.append(key)
return {
"ok": True,
"applied": applied,
"ignored_unknown": ignored_unknown,
"policy_change": "action_tool_policy" in applied,
"system_prompt_change": "system_prompt" in applied,
}
except Exception as e:
return {"ok": False, "error": f"could not compute preview: {e}"}
def preview_for(name: str, args: dict) -> dict:
"""Single dispatch entry point chat.py calls to enrich an approval payload."""
if name == "edit_source":
return preview_source_edit(args.get("path", ""), args.get("new_content", ""))
if name == "edit_playbook":
return preview_playbook_edit(args)
if name == "edit_settings":
return preview_settings_edit(args)
return {"ok": False, "error": f"no previewer for {name}"}
-94
View File
@@ -1,94 +0,0 @@
"""Direct tool invocation from chat input: `/tool_name(arg=val, arg=val)`.
A human typing this IS the approval there's no one else to ask — so a
recognized slash-command skips the ask-policy round-trip entirely and
dispatches straight through `tools.dispatch()`, the same entry point a
model-issued tool call already goes through. It does not bypass anything a
tool validates internally (path boundaries, size caps, Curry's own sandbox
checks, etc.) only the human-approval step, which this message already is.
Argument values are parsed with `ast.literal_eval`, not `eval()`: strings,
numbers, booleans, None, and literal lists/dicts/tuples only. There is no way
to reference a name, call a function, or access an attribute in this syntax
a malformed or hostile-looking argument fails to parse rather than executing
anything, which is the "lint, not run" property that makes this different
from just typing Python.
The whole message must be nothing but the command this is a deliberate
command line, not a directive embedded in prose. Anything else (including a
message that merely starts with `/` but isn't shaped like this) falls through
to the normal chat/model path unchanged.
"""
from __future__ import annotations
import ast
import re
from dataclasses import dataclass
from typing import Any, Optional
# name(args) where name is a plain identifier — the same shape as a Python
# function call, so it reads the way the tool's own schema already documents
# it. re.DOTALL: argument values (e.g. a multi-line body= string) may
# legitimately contain newlines.
_COMMAND_RE = re.compile(r"^/([A-Za-z_][A-Za-z0-9_]*)\((.*)\)\s*$", re.DOTALL)
@dataclass
class SlashCommand:
tool: str
args: dict[str, Any]
@dataclass
class SlashCommandError:
text: str
def parse_slash_command(message: str) -> Optional[SlashCommand | SlashCommandError]:
"""Parse `/tool_name(arg=val, ...)`.
Returns None when `message` isn't shaped like a slash-command at all (the
caller should treat it as an ordinary chat message). Returns
SlashCommandError when it looks like one but is malformed that's worth
telling the user about rather than silently sending "/curry_call_fnction(...)"
to the model as if it were prose.
"""
stripped = (message or "").strip()
match = _COMMAND_RE.match(stripped)
if not match:
return None
tool_name, raw_args = match.group(1), match.group(2).strip()
if not raw_args:
return SlashCommand(tool=tool_name, args={})
# Parse "k1=v1, k2=v2" as keyword arguments to a call with no positional
# arguments and no function to actually call — ast.parse(mode='eval') on a
# synthetic call expression reuses Python's own keyword-argument grammar
# (quoting, nesting, trailing commas) instead of hand-rolling a parser for
# it, while call() as a bare name is never resolved or invoked.
try:
tree = ast.parse(f"call({raw_args})", mode="eval")
except SyntaxError as e:
return SlashCommandError(f"could not parse arguments for /{tool_name}(...): {e}")
call_node = tree.body
if not isinstance(call_node, ast.Call) or call_node.args:
return SlashCommandError(
f"/{tool_name}(...) arguments must be keyword form: arg=value, arg=value"
)
args: dict[str, Any] = {}
for kw in call_node.keywords:
if kw.arg is None: # **mapping unpacking — no source for that here
return SlashCommandError(f"/{tool_name}(...) does not support **-unpacking")
try:
args[kw.arg] = ast.literal_eval(kw.value)
except (ValueError, SyntaxError):
return SlashCommandError(
f"/{tool_name}(...): argument '{kw.arg}' must be a literal "
"(string, number, bool, None, list, dict, or tuple) — not an "
"expression, name, or call"
)
return SlashCommand(tool=tool_name, args=args)
+15 -822
View File
@@ -3,30 +3,16 @@
Ollama drives the calling: `/api/chat` with a `tools` param returns Ollama drives the calling: `/api/chat` with a `tools` param returns
`message.tool_calls`, and this module is just the registry + dispatch. `message.tool_calls`, and this module is just the registry + dispatch.
Most tools READ local state (memory, history, documents, models). Some act: Most tools READ local state (memory, history, documents, models). A few act:
`web_search`/`fetch_url` make outbound HTTP requests, `remember` WRITES a `web_search`/`fetch_url` make outbound HTTP requests, and `remember` WRITES a
memory fact, `edit_playbook`/`edit_settings`/`edit_source` change the memory fact. The per-playbook allowlist (`PlaybookItem.tools`) is the security
assistant's own playbooks, settings, and (source checkout only) source code boundary an action tool only fires when a playbook explicitly lists it.
(see synapse/self_edit.py), and `curry_*` reads and writes NexusOS's vendored
Curry ledger (see synapse/curry_store.py) immutable versioned constants and
functions, with `curry_call_function` executing a previously declared one.
The per-playbook allowlist (`PlaybookItem.tools`) is the first gate an
action tool only fires when a playbook explicitly lists it and the
highest-risk tools in both families additionally always pause for per-call
approval regardless of the global action_tool_policy
(ALWAYS_ASK_ACTION_TOOLS, below). A message that's nothing but
`/tool_name(arg=val, ...)` skips that approval round-trip entirely and
dispatches directly see synapse/slash_commands.py for why that's safe.
""" """
from __future__ import annotations from __future__ import annotations
import json import json
from typing import Awaitable, Callable from typing import Awaitable, Callable
from . import code_run
from . import playbook_manager
from .curry_store import curry_db
from . import self_edit
from .memory.store import store, MemoryItem from .memory.store import store, MemoryItem
from .ollama_manager import get_ollama_manager from .ollama_manager import get_ollama_manager
@@ -172,9 +158,7 @@ async def _remember(text: str = "", section: str = "General", **_) -> str:
# --- Repo file access (read-only, scoped to PROJECT_ROOT) ------------------- # --- Repo file access (read-only, scoped to PROJECT_ROOT) -------------------
# Paths never leave the repo: every request is resolve()d and checked against # Paths never leave the repo: every request is resolve()d and checked against
# PROJECT_ROOT, which also kills symlink escapes. _DENIED covers the parts of # PROJECT_ROOT, which also kills symlink escapes. _DENIED covers the parts of
# the tree that are either secrets, private data, or multi-GB noise. Read-only # the tree that are either secrets, private data, or multi-GB noise.
# counterpart to edit_source's write path (self_edit.py) — a model reading
# real source before proposing an edit is the point.
_DENIED = { _DENIED = {
".git", ".env", "Promethean", "node_modules", "models", "ollama", ".git", ".env", "Promethean", "node_modules", "models", "ollama",
"runtime", "dist", "__pycache__", ".git-credentials", "runtime", "dist", "__pycache__", ".git-credentials",
@@ -222,15 +206,10 @@ async def _list_files(pattern: str = "", **_) -> str:
for f in PROJECT_ROOT.glob(pattern): for f in PROJECT_ROOT.glob(pattern):
if not f.is_file(): if not f.is_file():
continue continue
rel_posix = f.relative_to(PROJECT_ROOT).as_posix() target, err = _repo_path(str(f.relative_to(PROJECT_ROOT)))
target, err = _repo_path(rel_posix)
if err: if err:
continue continue
# .as_posix(), not str(): a bare str() gives backslash-separated paths hits.append(str(f.relative_to(PROJECT_ROOT)))
# on Windows, which don't match the forward-slash patterns this tool's
# own schema documents (e.g. "synapse/**/*.py") and that read_file
# expects back.
hits.append(rel_posix)
if len(hits) >= 200: if len(hits) >= 200:
break break
return json.dumps(sorted(hits)) return json.dumps(sorted(hits))
@@ -241,9 +220,9 @@ async def _list_files(pattern: str = "", **_) -> str:
# from these keys rather than repeated. # from these keys rather than repeated.
# #
# The frontend keeps its own matching registry (PREVIEW_LANGS in # The frontend keeps its own matching registry (PREVIEW_LANGS in
# interface/web/src/Markdown.jsx) because the two sides need different things per # interface/web/src/preview/languages.js) because the two sides need different
# language - this side describes them, that side renders them - and neither # things per language - this side describes them, that side renders them - and
# should depend on the other at runtime. tests/test_tools.py asserts the key sets # neither should depend on the other at runtime. tests/test_tools.py asserts the key sets
# stay equal, so drift fails the check gate instead of silently degrading to a # stay equal, so drift fails the check gate instead of silently degrading to a
# plain code block in the chat. # plain code block in the chat.
PREVIEW_LANGS: dict[str, dict] = { PREVIEW_LANGS: dict[str, dict] = {
@@ -297,348 +276,6 @@ async def _render_preview(
}) })
# ---------------------------------------------------------------------------
# run_snippet — the execution track
# ---------------------------------------------------------------------------
#
# render_preview and run_snippet are deliberately separate tools over separate
# registries. A preview is packaged here and parsed/rendered by the browser
# inside a sandboxed frame; a snippet is *executed* on the host by
# synapse/code_run.py and
# comes back as terminal output. Stretching one tool across both would have meant
# a `lang` enum where half the values run server-side and half don't, and a
# single description that could only be vague about which. The split is the
# feature: the model picks a track by picking a tool.
#
# Result fence: one ```nexus-run block whose body is JSON (source + streams +
# exit code), so the code and the output it produced cannot be separated by a
# model pasting only half of it. Backticks in the source are re-encoded as \u0060
# — still valid JSON, and it cannot terminate the fence early.
_RUN_FENCE_LANG = "nexus-run"
def _run_fence(payload: dict) -> str:
body = json.dumps(payload, ensure_ascii=False).replace("`", "\\u0060")
return f"```{_RUN_FENCE_LANG}\n{body}\n```"
def _run_lang_prose() -> str:
return code_run.lang_prose()
def _run_scaffold_for(lang: str) -> str:
"""Minimal entry-point patterns for a struggling model. Shown from the
second host-code rejection on."""
return {
"python": "print(sum(range(10)))\n",
"c": (
"#include <stdio.h>\n"
"int main(void) {\n"
' printf("%d\\n", 42);\n'
" return 0;\n"
"}\n"
),
"cpp": (
"#include <iostream>\n"
"int main() {\n"
' std::cout << 42 << "\\n";\n'
" return 0;\n"
"}\n"
),
"rust": 'fn main() { println!("{}", 42); }\n',
"erlang": 'main(_) -> io:format("~p~n", [42]).\n',
}.get(lang, f"(a short self-contained {lang} program that prints to stdout)\n")
def _with_run_scaffold(payload: dict, lang: str, attempt: int) -> dict:
"""Attach a starting pattern from the second rejection on. First rejection
stays issues-only so a pasteable scaffold does not become the answer."""
if attempt < 1:
return payload
return {
**payload,
"scaffold": _run_scaffold_for(lang),
"scaffold_note": (
"A pattern to adapt, not an answer to paste. Keep the entry point, "
"print results to stdout, and drop anything you do not use."
),
}
async def _run_snippet(
lang: str = "",
source: str = "",
stdin: str = "",
title: str = "",
_attempt: int = 0,
**_,
) -> str:
"""Compile and run a snippet, returning a fence that carries both the source
and what it printed.
ACTION tool: it executes code on this machine. `action_tool_policy` gates it
(withheld on "off", per-call Approve/Deny on "ask"), and synapse/code_run.py
documents exactly how much containment the run itself gets which is less
than the word "sandbox" would imply.
`_attempt` is supplied by the tool loop, not by the model it is how many
times this call has already been rejected in the current turn."""
import asyncio as _a
key = code_run.resolve_lang(lang)
source = (source or "").strip()
title = (title or "").strip()
if key not in code_run.RUN_LANGS:
return json.dumps({
"ok": False,
"error": f"lang must be {_run_lang_prose()} (got {lang!r}). "
"For HTML, SVG or JSX use render_preview instead — those are "
"rendered in the browser, not executed here.",
})
if not source:
return json.dumps(_with_run_scaffold({
"ok": False,
"error": f"source is required — send the complete {key} program, "
"entry point included.",
}, key, _attempt))
issues = code_run.critique(key, source)
if issues:
return json.dumps(_with_run_scaffold({
"ok": False,
"issues": issues,
"hint": (
"Fix these and call run_snippet again. The program runs in a throwaway "
"directory with no network and a few seconds of CPU: no downloads, no "
"absolute paths, no unbounded loops. Print your results to stdout."
),
}, key, _attempt))
result = await _a.to_thread(code_run.run, key, source, stdin or "")
if not result.get("ok"):
# A failed compile hands back the compiler's own diagnostics: they name
# the line and the fix, and paraphrasing them here would only lose that.
return json.dumps({k: v for k, v in result.items() if v not in ("", None)})
return json.dumps({
"ok": True,
"lang": key,
"exit_code": result["exit_code"],
"stdout": result["stdout"],
"stderr": result["stderr"],
"title": title or f"{key} output",
"instruction": (
"This ran for real — the output below is what it printed. Write a short "
"intro, then paste the fenced block exactly as it is. Do not wrap it in a "
"second fence, retype the output, or claim results it does not show."
),
"fence": _run_fence({
"lang": key,
"source": source,
"stdout": result["stdout"],
"stderr": result["stderr"],
"exit_code": result["exit_code"],
}),
})
# Result fence for the three self-edit tools: same "carry the change and what
# happened to it in one block" idea as _run_fence, so the model can't paste a
# claimed diff that doesn't match what was actually applied.
_EDIT_FENCE_LANG = "nexus-edit"
def _edit_fence(payload: dict) -> str:
body = json.dumps(payload, ensure_ascii=False).replace("`", "\\u0060")
return f"```{_EDIT_FENCE_LANG}\n{body}\n```"
async def _edit_source(path: str = "", new_content: str = "", summary: str = "", **_) -> str:
"""ACTION tool: writes a file in the live project tree and commits it. See
synapse/self_edit.py for the boundary check, the size cap, and exactly what
the git commit does and doesn't guarantee."""
import asyncio as _a
result = await _a.to_thread(self_edit.apply_source_edit, path, new_content or "", summary or "")
if result.get("ok"):
result["instruction"] = (
"This was written and committed for real. Paste the fence unchanged, then "
"tell the user plainly that a restart is needed for it to take effect — "
"this file is not reloaded into the running process."
)
result["fence"] = _edit_fence({"kind": "source", **result})
return json.dumps(result)
async def _edit_playbook(
id: str = "", title: str = "", goal: str = "", instructions: str = "",
tags: list | None = None, tools: list | None = None, model: str = "",
make_active: bool = False, **_,
) -> str:
"""ACTION tool: create or update a playbook. Fields left unset keep their
current value this merges, it does not replace. make_active is a
separate, explicit flag: without it, an edit can never accidentally become
the active system prompt."""
try:
result = playbook_manager.persist_playbook(
{
"id": id, "title": title, "goal": goal, "instructions": instructions,
"tags": tags, "tools": tools, "model": model,
},
merge=True,
)
except ValueError as e:
return json.dumps({"ok": False, "error": str(e)})
if make_active:
playbook_manager.make_main(result["id"])
result["is_main_playbook"] = True
result["ok"] = True
result["fence"] = _edit_fence({"kind": "playbook", **result})
return json.dumps(result)
async def _edit_settings(changes: dict | None = None, **_) -> str:
"""ACTION tool: change one or more runtime settings. Unknown keys are
silently ignored, exactly like PUT /settings already does."""
changes = changes if isinstance(changes, dict) else {}
if not changes:
return json.dumps({"ok": False, "error": "changes must be a non-empty object"})
preview = self_edit.preview_settings_edit({"changes": changes})
if not preview.get("ok"):
return json.dumps(preview)
if not preview.get("applied"):
return json.dumps({
"ok": False,
"error": "no recognized settings keys in changes",
"ignored_unknown": preview.get("ignored_unknown", []),
})
store.update_settings({k: v["after"] for k, v in preview["applied"].items()})
preview["ok"] = True
preview["fence"] = _edit_fence({"kind": "settings", **preview})
return json.dumps(preview)
# Curry (synapse/curry_core.py, vendored) — NexusOS's immutable, versioned
# fact store. Its own methods raise (KeyError/ValueError/TypeError/RuntimeError)
# on the failures a caller should see as a normal, expected result rather than
# a crash (unknown id, version conflict, retired reference, etc.) — dispatch()
# would already catch anything unhandled, but that produces a generic
# "toolname failed: ..." string instead of the {"ok": False, "error": ...}
# shape every other tool in this file returns, so it's caught locally here too.
_CURRY_FENCE_LANG = "nexus-curry"
def _curry_fence(payload: dict) -> str:
body = json.dumps(payload, ensure_ascii=False, default=str).replace("`", "\\u0060")
return f"```{_CURRY_FENCE_LANG}\n{body}\n```"
async def _curry_call(fn, *args, **kwargs) -> dict:
# Not run.to_thread()'d like run_snippet/self_edit's blocking work: curry_db
# holds one sqlite3 connection for its whole lifetime (unlike
# PersistentMemoryStore, which opens/closes a fresh one per call), and
# sqlite3 forbids using a connection from any thread but the one that
# created it. curry_db is created at import time on the same thread this
# runs on (the asyncio event loop thread), so calling it directly here is
# both correct and, for local-file SQLite, fast enough not to need
# offloading anyway.
try:
result = fn(*args, **kwargs)
return {"ok": True, "result": result}
except (KeyError, ValueError, TypeError, RuntimeError) as e:
return {"ok": False, "error": str(e)}
async def _curry_declare_constant(
id: str = "", version: int = 0, value=None, type_signature: str = "",
description: str = "", **_,
) -> str:
"""ACTION tool: declare a new, immutable version of a named constant."""
out = await _curry_call(
curry_db.declare_constant, id, version, value, type_signature, description or None
)
if out["ok"]:
out = {"ok": True, "id": id, "version": version}
out["fence"] = _curry_fence({"kind": "declare_constant", **out})
return json.dumps(out)
async def _curry_get_constant(id: str = "", version: int = 0, **_) -> str:
"""Retrieve a constant by exact id and version."""
return json.dumps(await _curry_call(curry_db.get_constant, id, version))
async def _curry_get_constant_latest(id: str = "", **_) -> str:
"""Retrieve the most recent active (non-retired) version of a constant."""
return json.dumps(await _curry_call(curry_db.get_constant_latest, id))
async def _curry_list_constants(active_only: bool = True, **_) -> str:
"""List all declared constants and their latest versions."""
return json.dumps(await _curry_call(curry_db.list_constants, active_only))
async def _curry_retire_constant(id: str = "", version: int = 0, reason: str = "", **_) -> str:
"""ACTION tool: retire (tombstone) a constant version. Does not delete it —
the version stays readable by exact id+version, just excluded from
"latest" lookups and blocked from new declarations that depend on it."""
out = await _curry_call(
curry_db.retire_constant_with_reason, id, version, reason or "retired via tool call"
)
return json.dumps(out)
async def _curry_declare_function(
name: str = "", version: int = 0, body: str = "",
constant_bindings: dict | None = None, function_bindings: dict | None = None,
is_pure: bool = False, expected_args: list | None = None,
description: str = "", arg_descriptions: dict | None = None, **_,
) -> str:
"""ACTION tool: declare a new, immutable version of a named function. Body
is a single Python expression (no statements) over stdlib-only builtins,
checked by curry_core.py's own static validator before this ever runs —
but that validator is a tripwire against habitual mistakes, not a
security boundary; treat it the same as run_snippet's containment."""
out = await _curry_call(
curry_db.declare_function, name, version, body,
constant_bindings or {}, function_bindings or {}, is_pure,
expected_args, description or None, arg_descriptions,
)
if out["ok"]:
out = {"ok": True, "name": name, "version": version}
out["fence"] = _curry_fence({"kind": "declare_function", **out})
return json.dumps(out)
async def _curry_get_function(name: str = "", version: int = 0, **_) -> str:
"""Retrieve a function definition by exact name and version."""
return json.dumps(await _curry_call(curry_db.get_function, name, version))
async def _curry_list_functions(active_only: bool = True, **_) -> str:
"""List all declared functions and their latest versions."""
return json.dumps(await _curry_call(curry_db.list_functions, active_only))
async def _curry_call_function(name: str = "", version: int = 0, args: dict | None = None, **_) -> str:
"""ACTION tool: execute a previously declared function version with the
given runtime arguments. Locked constant/function dependencies resolve
automatically; pure functions are memoized."""
out = await _curry_call(curry_db.call_function, name, version, args or {})
if out["ok"]:
out["fence"] = _curry_fence({"kind": "call_function", "name": name, "version": version, **out})
return json.dumps(out)
async def _curry_retire_function(name: str = "", version: int = 0, reason: str = "", **_) -> str:
"""ACTION tool: retire (tombstone) a function version. Does not delete it."""
out = await _curry_call(
curry_db.retire_function_with_reason, name, version, reason or "retired via tool call"
)
return json.dumps(out)
# name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format. # name -> (schema, callable). Schema is the OpenAI/Ollama function-tool format.
REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = { REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
"search_memory": ( "search_memory": (
@@ -792,62 +429,6 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
}, },
_render_preview, _render_preview,
), ),
"run_snippet": (
{
"type": "function",
"function": {
"name": "run_snippet",
# Same house style as render_preview: imperative, second person,
# nothing the model can recite back in place of acting.
"description": (
f"Compile and run a short {code_run.lang_prose()} program on this "
"machine and get its real output back. Use this when the answer "
"depends on what the code actually does — output, a computed "
"result, whether it compiles. Write one self-contained file with "
"its entry point; there is no package manager, no network, a "
f"throwaway working directory and {code_run.RUN_TIMEOUT:g}s of "
"runtime, so bound your loops and print results to stdout. "
"For HTML, SVG or JSX use render_preview instead. "
"Rejected: fix what `issues` or `stderr` says and send it again. "
"Accepted: paste the returned `fence` into your reply unchanged, "
"and describe only the output it actually contains."
),
"parameters": {
"type": "object",
"properties": {
"lang": {
"type": "string",
"enum": list(code_run.RUN_LANGS),
"description": (
"Language to run: "
+ "; ".join(
f"{name} ({spec['summary']})"
for name, spec in code_run.RUN_LANGS.items()
)
),
},
"title": {
"type": "string",
"description": "Short label for what this program does.",
},
"source": {
"type": "string",
"description": (
"The complete single-file program, entry point included. "
"Standard library only."
),
},
"stdin": {
"type": "string",
"description": "Optional text piped to the program's stdin.",
},
},
"required": ["lang", "source"],
},
},
},
_run_snippet,
),
"web_search": ( "web_search": (
{ {
"type": "function", "type": "function",
@@ -896,357 +477,13 @@ REGISTRY: dict[str, tuple[dict, Callable[..., Awaitable[str]]]] = {
}, },
_remember, _remember,
), ),
"edit_source": (
{
"type": "function",
"function": {
"name": "edit_source",
"description": (
"Rewrite a file in this project's own source tree and commit the "
"change. Requires human approval every time — the person reviews a "
"real diff before anything is written. Send the COMPLETE new file "
"content, not a patch; the server computes the diff itself. `path` "
"is relative to the project root (e.g. \"synapse/tools.py\"), never "
"absolute. Only available in a source checkout, not a packaged "
"install. Writing the file does not restart the running process — "
"say so plainly once it's applied."
),
"parameters": {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "Project-relative path to the file, e.g. synapse/tools.py",
},
"new_content": {
"type": "string",
"description": "The complete replacement content of the file.",
},
"summary": {
"type": "string",
"description": "One line describing the change, used as the commit message.",
},
},
"required": ["path", "new_content"],
},
},
},
_edit_source,
),
"edit_playbook": (
{
"type": "function",
"function": {
"name": "edit_playbook",
"description": (
"Create or update a playbook — the instructions that shape how the "
"assistant behaves. Requires human approval every time. Fields you "
"omit keep their current value; this merges into the existing "
"playbook, it does not replace it. Set make_active=true only when "
"this playbook should become the active system prompt — never as a "
"side effect of an ordinary edit. Omit `id` to create a new playbook."
),
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Existing playbook id to update; omit to create new."},
"title": {"type": "string", "description": "Short name for the playbook."},
"goal": {"type": "string", "description": "One-line statement of what this playbook is for."},
"instructions": {"type": "string", "description": "The actual instructions/system prompt text."},
"tags": {"type": "array", "items": {"type": "string"}, "description": "Routing tags."},
"tools": {"type": "array", "items": {"type": "string"}, "description": "Tool names this playbook grants."},
"model": {"type": "string", "description": "Preferred Ollama model for this playbook, or blank for auto."},
"make_active": {
"type": "boolean",
"description": "Set true to make this the active system prompt. Default false.",
},
},
"required": [],
},
},
},
_edit_playbook,
),
"edit_settings": (
{
"type": "function",
"function": {
"name": "edit_settings",
"description": (
"Change one or more runtime settings (e.g. model, temperature, "
"action_tool_policy, memory_model). Requires human approval every "
"time. Send only the keys you actually want to change — unrecognized "
"keys are silently ignored, and existing values are left untouched."
),
"parameters": {
"type": "object",
"properties": {
"changes": {
"type": "object",
"description": "Partial map of setting name to new value.",
},
},
"required": ["changes"],
},
},
},
_edit_settings,
),
"curry_declare_constant": (
{
"type": "function",
"function": {
"name": "curry_declare_constant",
"description": (
"Declare a new, immutable version of a named constant in the Curry "
"ledger. Requires human approval every time. `version` must exceed "
"the constant's current max version — versions are append-only, "
"never overwritten. type_signature is one of Float64, Int32, "
"String, Blob, Json, Tokens, Currency, Bool."
),
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Constant identifier."},
"version": {"type": "integer", "description": "Must exceed the current max version for this id."},
"value": {"description": "The value to store, matching type_signature."},
"type_signature": {"type": "string", "description": "Float64 | Int32 | String | Blob | Json | Tokens | Currency | Bool"},
"description": {"type": "string", "description": "What this constant means and why this value."},
},
"required": ["id", "version", "value", "type_signature"],
},
},
},
_curry_declare_constant,
),
"curry_get_constant": (
{
"type": "function",
"function": {
"name": "curry_get_constant",
"description": "Retrieve a Curry constant by its exact id and version.",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Constant identifier."},
"version": {"type": "integer", "description": "Exact version to retrieve."},
},
"required": ["id", "version"],
},
},
},
_curry_get_constant,
),
"curry_get_constant_latest": (
{
"type": "function",
"function": {
"name": "curry_get_constant_latest",
"description": "Retrieve the most recent active (non-retired) version of a Curry constant.",
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Constant identifier."},
},
"required": ["id"],
},
},
},
_curry_get_constant_latest,
),
"curry_list_constants": (
{
"type": "function",
"function": {
"name": "curry_list_constants",
"description": "List every constant declared in the Curry ledger.",
"parameters": {
"type": "object",
"properties": {
"active_only": {"type": "boolean", "description": "If true (default), exclude retired constants."},
},
"required": [],
},
},
},
_curry_list_constants,
),
"curry_retire_constant": (
{
"type": "function",
"function": {
"name": "curry_retire_constant",
"description": (
"Retire (tombstone) a Curry constant version. Requires human approval "
"every time. This does not delete anything — the version stays "
"readable by exact id+version, it's just excluded from 'latest' "
"lookups going forward."
),
"parameters": {
"type": "object",
"properties": {
"id": {"type": "string", "description": "Constant identifier."},
"version": {"type": "integer", "description": "Version to retire."},
"reason": {"type": "string", "description": "Why this version is being retired."},
},
"required": ["id", "version"],
},
},
},
_curry_retire_constant,
),
"curry_declare_function": (
{
"type": "function",
"function": {
"name": "curry_declare_function",
"description": (
"Declare a new, immutable version of a named function in the Curry "
"ledger. Requires human approval every time. body is a SINGLE Python "
"expression (no statements, no imports) over stdlib-only builtins — "
"reference bound constants/functions by name via constant_bindings / "
"function_bindings, and any additional runtime arguments via "
"expected_args. `version` must exceed the function's current max "
"version."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Function name."},
"version": {"type": "integer", "description": "Must exceed the current max version for this name."},
"body": {"type": "string", "description": "Single Python expression, e.g. \"amount * (1 + rate)\"."},
"constant_bindings": {"type": "object", "description": "Dict mapping constant id to the exact version to bind, e.g. {\"rate\": 1}."},
"function_bindings": {"type": "object", "description": "Dict mapping nested function name to the exact version to bind."},
"is_pure": {"type": "boolean", "description": "If true, results are memoized in the execution cache."},
"expected_args": {"type": "array", "items": {"type": "string"}, "description": "Runtime argument names the caller must supply to curry_call_function."},
"description": {"type": "string", "description": "What this function computes and which constants it binds."},
"arg_descriptions": {"type": "object", "description": "Per-argument hint strings, e.g. {\"amount\": \"USD, e.g. 100.00\"}."},
},
"required": ["name", "version", "body"],
},
},
},
_curry_declare_function,
),
"curry_get_function": (
{
"type": "function",
"function": {
"name": "curry_get_function",
"description": "Retrieve a Curry function definition by its exact name and version.",
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Function name."},
"version": {"type": "integer", "description": "Exact version to retrieve."},
},
"required": ["name", "version"],
},
},
},
_curry_get_function,
),
"curry_list_functions": (
{
"type": "function",
"function": {
"name": "curry_list_functions",
"description": "List every function declared in the Curry ledger, including expected_args for building curry_call_function calls.",
"parameters": {
"type": "object",
"properties": {
"active_only": {"type": "boolean", "description": "If true (default), exclude retired functions."},
},
"required": [],
},
},
},
_curry_list_functions,
),
"curry_call_function": (
{
"type": "function",
"function": {
"name": "curry_call_function",
"description": (
"Execute a previously declared Curry function version with runtime "
"arguments. Requires human approval every time. Use "
"curry_list_functions or curry_get_function first to discover "
"expected_args."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Function name."},
"version": {"type": "integer", "description": "Exact version to execute."},
"args": {"type": "object", "description": "Runtime arguments as a flat dict, e.g. {\"amount\": 100}."},
},
"required": ["name", "version"],
},
},
},
_curry_call_function,
),
"curry_retire_function": (
{
"type": "function",
"function": {
"name": "curry_retire_function",
"description": (
"Retire (tombstone) a Curry function version. Requires human approval "
"every time. Does not delete anything."
),
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Function name."},
"version": {"type": "integer", "description": "Version to retire."},
"reason": {"type": "string", "description": "Why this version is being retired."},
},
"required": ["name", "version"],
},
},
},
_curry_retire_function,
),
} }
# Tools that act (write local state or reach the network). These require an # Tools that act (write local state or reach the network). These require an
# explicit consent gate (settings.allow_action_tools) on top of the per-playbook # explicit consent gate (settings.allow_action_tools) on top of the per-playbook
# allowlist — a playbook granting one isn't enough on its own. The highest-risk # allowlist — a playbook granting one isn't enough on its own.
# tools in the self-edit and curry families additionally always pause for ACTION_TOOLS = frozenset({"web_search", "fetch_url", "remember"})
# per-call approval regardless of that global policy — see
# ALWAYS_ASK_ACTION_TOOLS and chat.py. curry_call_function is an action
# because it executes code (a declared function body), the same reasoning
# that makes run_snippet an action tool despite not writing to any ledger.
ACTION_TOOLS = frozenset({
"web_search", "fetch_url", "remember", "run_snippet",
"edit_playbook", "edit_settings", "edit_source",
"curry_declare_constant", "curry_retire_constant",
"curry_declare_function", "curry_retire_function", "curry_call_function",
})
# Curry write/execute tools that always pause for per-call approval regardless
# of the global action_tool_policy, on the same reasoning as
# self_edit.ALWAYS_ASK_TOOLS: a policy of "allow" set for convenience on an
# unrelated tool must never silently also unlock unattended ledger writes or
# code execution. Read-only curry_get_*/curry_list_* tools are not action
# tools at all and are unaffected.
CURRY_ALWAYS_ASK_TOOLS = frozenset({
"curry_declare_constant", "curry_retire_constant",
"curry_declare_function", "curry_retire_function", "curry_call_function",
})
# The union chat.py actually checks — one place, so a future tool family
# doesn't have to remember there are two sets to update.
ALWAYS_ASK_ACTION_TOOLS = self_edit.ALWAYS_ASK_TOOLS | CURRY_ALWAYS_ASK_TOOLS
# Action tools offered on a *cue* rather than only via a playbook allowlist —
# the run track is a standing UI capability like the render window, but unlike
# render_preview it executes code, so it stays behind the action gate. Listed
# here so main.py can advertise it on a "run this" without a playbook edit.
CUED_ACTION_TOOLS = frozenset({"run_snippet"})
# Always advertised when the user asks for a visual (see wants_render_preview). # Always advertised when the user asks for a visual (see wants_render_preview).
# Not playbook-gated — the render window is a standing UI capability. # Not playbook-gated — the render window is a standing UI capability.
@@ -1271,51 +508,16 @@ _RENDER_HINTS = (
) + tuple(PREVIEW_LANGS) ) + tuple(PREVIEW_LANGS)
# Cues for run_snippet. Unlike _RENDER_HINTS these are verb phrases, not topic def wants_render_preview(message: str) -> bool:
# words, and deliberately do not include the language names: "write me a python """True when this turn should advertise render_preview / enter the tool loop."""
# function" is not a request to execute anything, and putting `python` in here
# would drag every mention of the language into a slow non-stream tool round.
# Asking for the *output* is the signal, so that is what these match.
_RUN_HINTS = (
"run this", "run it", "run that", "run the code", "run this code",
"run my code", "run the program", "run and show", "actually run",
"run snippet", "run_snippet", "execute this", "execute it", "execute the code",
"compile", "compiles", "does it compile", "does this compile",
"show the output", "show me the output", "what does it print",
"what does this print", "what's the output", "whats the output",
"what is the output", "actual output", "real output",
)
def _mentions(message: str, hints: tuple[str, ...]) -> bool:
"""True if any hint appears in `message` as a whole word/phrase.
The lookarounds rather than \\b: several hints end in a non-word character
("what's the output"), where \\b would anchor to the apostrophe instead of
the phrase and match inside longer words.
"""
import re import re
lower = (message or "").lower() lower = (message or "").lower()
return any( return any(
re.search(rf"(?<![A-Za-z0-9_]){re.escape(hint)}(?![A-Za-z0-9_])", lower) re.search(rf"(?<![A-Za-z0-9_]){re.escape(hint)}(?![A-Za-z0-9_])", lower)
for hint in hints for hint in _RENDER_HINTS
) )
def wants_render_preview(message: str) -> bool:
"""True when this turn should advertise render_preview / enter the tool loop."""
return _mentions(message, _RENDER_HINTS)
def wants_code_run(message: str) -> bool:
"""True when this turn is asking for code to actually be executed.
Only a hint: run_snippet is an action tool, so this can never be what makes
it available `action_tool_policy` still has to be off "off" first.
"""
return _mentions(message, _RUN_HINTS)
def is_action(name: str) -> bool: def is_action(name: str) -> bool:
return name in ACTION_TOOLS return name in ACTION_TOOLS
@@ -1335,15 +537,6 @@ def standing_schemas() -> list[dict]:
return schemas_for(sorted(STANDING_TOOLS), allow_actions=True) return schemas_for(sorted(STANDING_TOOLS), allow_actions=True)
def run_schemas() -> list[dict]:
"""Schemas that ship with "run this" turns (currently just run_snippet).
Callers must have already established that actions are permitted these are
action tools and schemas_for would happily hand them over regardless.
"""
return schemas_for(sorted(CUED_ACTION_TOOLS), allow_actions=True)
async def dispatch(name: str, args: dict | None) -> str: async def dispatch(name: str, args: dict | None) -> str:
"""Run a tool by name. Never raises — returns an error string on failure.""" """Run a tool by name. Never raises — returns an error string on failure."""
entry = REGISTRY.get(name) entry = REGISTRY.get(name)
-23
View File
@@ -1,23 +0,0 @@
"""Test-process isolation for persistent runtime state."""
from __future__ import annotations
import os
import shutil
import sys
import tempfile
from pathlib import Path
# curry_store constructs its SQLite singleton during test collection. Point it
# at a per-run directory before any test module imports synapse, so the release
# gate is repeatable and never writes test constants into the checkout's live
# data/curry.db.
_TEST_STATE = Path(tempfile.mkdtemp(prefix="nexus-pytest-"))
os.environ["NEXUS_CURRY_DB"] = str(_TEST_STATE / "curry.db")
def pytest_sessionfinish(session, exitstatus):
module = sys.modules.get("synapse.curry_store")
if module is not None:
module.curry_db.close()
shutil.rmtree(_TEST_STATE, ignore_errors=True)
-1
View File
@@ -1 +0,0 @@
"""Data-driven snippet probes exercised by tests/test_snippet_probes.py."""
-274
View File
@@ -1,274 +0,0 @@
"""Catalog of code-snippet probes for the execution track.
Each probe is a small, self-contained program (or deliberate reject) that the
suite in tests/test_snippet_probes.py runs automatically. Keeping the corpus
here not inlined in the test file means adding a language or a case is a
data change, and the meta-tests can assert every RUN_LANGS key is covered.
"""
from __future__ import annotations
from dataclasses import dataclass, field
@dataclass(frozen=True)
class Probe:
"""One automatic snippet probe.
kind:
run critique must be clean, then code_run.run (skip if no toolchain)
screen critique must report at least one issue matching needle;
never executed
tool same as run, but also through tools.dispatch("run_snippet")
"""
id: str
lang: str
source: str
kind: str = "run"
expect_stdout: str | None = None # exact strip() match when set
expect_stdout_contains: tuple[str, ...] = ()
expect_stderr_contains: tuple[str, ...] = ()
expect_exit: int | None = 0 # None = don't care; run-ok can be nonzero
expect_ok: bool = True # False => stage failure (compile/timeout/…)
expect_stage: str | None = None
screen_needle: str | None = None # required substring in critique issues
stdin: str = ""
tags: tuple[str, ...] = field(default_factory=tuple)
# ---------------------------------------------------------------------------
# Corpus — keep each source short; the suite runs every probe on every check.
# ---------------------------------------------------------------------------
PROBES: tuple[Probe, ...] = (
# --- python (always available) -----------------------------------------
Probe(
id="py-hello",
lang="python",
source="print('probe-ok', 6 * 7)",
expect_stdout="probe-ok 42",
tags=("smoke", "python"),
),
Probe(
id="py-stdin",
lang="python",
source="import sys\nprint(sys.stdin.read().strip().upper())",
stdin="nexus\n",
expect_stdout="NEXUS",
tags=("stdin", "python"),
),
Probe(
id="py-nonzero-exit",
lang="python",
source="import sys\nprint('before')\nsys.exit(3)",
expect_stdout="before",
expect_exit=3,
tags=("exit", "python"),
),
Probe(
id="py-traceback",
lang="python",
source="print(1 / 0)",
expect_exit=None, # nonzero, exact code is interpreter-dependent enough
expect_stderr_contains=("ZeroDivisionError",),
tags=("stderr", "python"),
),
Probe(
id="py-alias-py",
lang="py",
source="print('alias')",
expect_stdout="alias",
tags=("alias", "python"),
),
Probe(
id="py-tool-envelope",
lang="python",
source="print('via-tool', 2 + 2)",
kind="tool",
expect_stdout="via-tool 4",
tags=("tool", "python"),
),
Probe(
id="py-screen-network",
lang="python",
source="import socket\nprint(socket.gethostname())",
kind="screen",
screen_needle="network",
tags=("screen", "python"),
),
Probe(
id="py-screen-subprocess",
lang="python",
source="import subprocess\nsubprocess.run(['true'])",
kind="screen",
screen_needle="processes",
tags=("screen", "python"),
),
# --- c -----------------------------------------------------------------
Probe(
id="c-hello",
lang="c",
source=(
"#include <stdio.h>\n"
"int main(void) {\n"
' printf("c-probe %d\\n", 6 * 7);\n'
" return 0;\n"
"}\n"
),
expect_stdout="c-probe 42",
tags=("smoke", "c"),
),
Probe(
id="c-math",
lang="c",
source=(
"#include <stdio.h>\n"
"#include <math.h>\n"
"int main(void) {\n"
' printf("%.0f\\n", sqrt(144.0));\n'
" return 0;\n"
"}\n"
),
expect_stdout="12",
tags=("libm", "c"),
),
Probe(
id="c-compile-error",
lang="c",
source="int main(void) { return nope; }\n",
expect_ok=False,
expect_stage="compile",
expect_exit=None,
expect_stderr_contains=("nope",),
tags=("compile", "c"),
),
Probe(
id="c-screen-system",
lang="c",
source='#include <stdlib.h>\nint main(void){ system("true"); return 0; }\n',
kind="screen",
screen_needle="processes",
tags=("screen", "c"),
),
Probe(
id="c-screen-no-main",
lang="c",
source="int add(int a){ return a + 1; }\n",
kind="screen",
screen_needle="main()",
tags=("screen", "c"),
),
# --- cpp ---------------------------------------------------------------
Probe(
id="cpp-hello",
lang="cpp",
source=(
"#include <iostream>\n"
"int main() {\n"
' std::cout << "cpp-probe " << (6 * 7) << "\\n";\n'
" return 0;\n"
"}\n"
),
expect_stdout="cpp-probe 42",
tags=("smoke", "cpp"),
),
Probe(
id="cpp-alias",
lang="c++",
source=(
"#include <iostream>\n"
"int main(){ std::cout << 9 << \"\\n\"; }\n"
),
expect_stdout="9",
tags=("alias", "cpp"),
),
Probe(
id="cpp-screen-socket",
lang="cpp",
source="#include <sys/socket.h>\nint main(){ return 0; }\n",
kind="screen",
screen_needle="network",
tags=("screen", "cpp"),
),
# --- rust --------------------------------------------------------------
Probe(
id="rust-hello",
lang="rust",
source='fn main() { println!("rust-probe {}", (1..=10).sum::<i32>()); }\n',
expect_stdout="rust-probe 55",
tags=("smoke", "rust"),
),
Probe(
id="rust-alias-rs",
lang="rs",
source='fn main() { println!("rs"); }\n',
expect_stdout="rs",
tags=("alias", "rust"),
),
Probe(
id="rust-compile-error",
lang="rust",
source="fn main() { let x: i32 = \"nope\"; }\n",
expect_ok=False,
expect_stage="compile",
expect_exit=None,
tags=("compile", "rust"),
),
Probe(
id="rust-screen-command",
lang="rust",
source='fn main() { let _ = std::process::Command::new("true"); }\n',
kind="screen",
screen_needle="processes",
tags=("screen", "rust"),
),
# --- erlang ------------------------------------------------------------
Probe(
id="erl-escript",
lang="erlang",
source='main(_) -> io:format("erl-probe ~p~n", [lists:sum(lists:seq(1, 10))]).\n',
expect_stdout="erl-probe 55",
tags=("smoke", "erlang"),
),
Probe(
id="erl-module",
lang="erlang",
source=(
"-module(main).\n"
"-export([main/1]).\n"
'main(_) -> io:format("~p~n", [7 * 6]).\n'
),
expect_stdout="42",
tags=("module", "erlang"),
),
Probe(
id="erl-alias",
lang="erl",
source='main(_) -> io:format("alias~n").\n',
expect_stdout="alias",
tags=("alias", "erlang"),
),
Probe(
id="erl-screen-os-cmd",
lang="erlang",
source='main(_) -> os:cmd("true").\n',
kind="screen",
screen_needle="processes",
tags=("screen", "erlang"),
),
)
def probes_by_tag(*tags: str) -> tuple[Probe, ...]:
wanted = set(tags)
return tuple(p for p in PROBES if wanted.intersection(p.tags))
def covered_langs() -> set[str]:
"""Canonical RUN_LANGS keys touched by at least one probe (aliases resolved)."""
from synapse import code_run
return {code_run.resolve_lang(p.lang) for p in PROBES}
-2
View File
@@ -55,12 +55,10 @@ def test_legacy_cli_spellings_remain_compatible():
assert _normalize_legacy_argv(["start", "-b"]) == ["start", "backend"] assert _normalize_legacy_argv(["start", "-b"]) == ["start", "backend"]
assert _normalize_legacy_argv(["stop", "--ai"]) == ["stop", "ai"] assert _normalize_legacy_argv(["stop", "--ai"]) == ["stop", "ai"]
assert _normalize_legacy_argv(["logs", "-m", "--follow"]) == ["logs", "memory", "--follow"]
assert _normalize_legacy_argv(["backup", "full"]) == ["backup", "--full"] assert _normalize_legacy_argv(["backup", "full"]) == ["backup", "--full"]
# -f is --follow for `logs`, but --frontend for start/stop. Translating it # -f is --follow for `logs`, but --frontend for start/stop. Translating it
# for logs turned `logs -f` into a one-shot tail of the frontend log. # for logs turned `logs -f` into a one-shot tail of the frontend log.
assert _normalize_legacy_argv(["logs", "-f"]) == ["logs", "-f"] assert _normalize_legacy_argv(["logs", "-f"]) == ["logs", "-f"]
assert _normalize_legacy_argv(["logs", "-m", "-f"]) == ["logs", "memory", "-f"]
assert _normalize_legacy_argv(["start", "-f"]) == ["start", "frontend"] assert _normalize_legacy_argv(["start", "-f"]) == ["start", "frontend"]
assert _normalize_legacy_argv(["restore", "-f"]) == ["restore"] assert _normalize_legacy_argv(["restore", "-f"]) == ["restore"]
assert _normalize_legacy_argv(["help"]) == ["--help"] assert _normalize_legacy_argv(["help"]) == ["--help"]
-338
View File
@@ -1,338 +0,0 @@
"""The execution track: synapse/code_run.py.
Python is the only toolchain guaranteed present (it is the interpreter running
these tests), so it carries the behavioural coverage limits, streams, exit
codes, isolation. The compiled languages are covered for the parts that hold
without their toolchain installed (screening, argv shape, the missing-toolchain
message) and skipped where they need it, so this file passes on a machine with
no clang and no rustc as well as on one with both.
"""
import shutil
import sys
import pytest
from synapse import code_run
def _requires(lang):
entry = code_run.RUN_LANGS[lang]
return pytest.mark.skipif(
not entry["tool"](), reason=f"no {lang} toolchain on this machine"
)
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
def test_every_lang_has_a_complete_driver():
for name, entry in code_run.RUN_LANGS.items():
assert entry["summary"], name
assert callable(entry["tool"]), name
assert callable(entry["source_name"]), name
assert callable(entry["run"]), name
assert callable(entry["critique"]), name
assert entry["install"], name
def test_aliases_resolve_to_real_langs():
for alias, target in code_run.ALIASES.items():
assert target in code_run.RUN_LANGS, alias
assert code_run.resolve_lang("C++") == "cpp"
assert code_run.resolve_lang(" Py ") == "python"
assert code_run.resolve_lang("javascript") == "javascript" # unknown passes through
def test_erlang_source_name_follows_the_dialect():
"""escript reads the same bytes two ways depending on the extension. Writing
a bare main/1 script to main.erl makes it a module with no exports, which
fails at load with an error that says nothing about the real problem."""
assert code_run._erlang_source_name("main(_) -> ok.") == "main.escript"
assert code_run._erlang_source_name("-module(main).\nmain(_) -> ok.") == "main.erl"
def test_erlang_escript_gets_a_shebang_when_missing():
"""OTP 28+ rejects shebang-less .escript with 'Premature end of file'."""
bare = "main(_) -> ok.\n"
out = code_run._erlang_write_source(bare)
assert out.startswith("#!/usr/bin/env escript\n")
assert out.endswith(bare)
already = "#!/usr/bin/env escript\nmain(_) -> ok.\n"
assert code_run._erlang_write_source(already) == already
module = "-module(main).\n-export([main/1]).\nmain(_) -> ok.\n"
assert code_run._erlang_write_source(module) == module
# ---------------------------------------------------------------------------
# Screening
# ---------------------------------------------------------------------------
def test_clean_snippets_pass_screening():
assert code_run.critique("python", "print(sum(range(100)))") == []
assert code_run.critique("c", '#include <stdio.h>\nint main(void){puts("x");}') == []
assert code_run.critique("rust", 'fn main(){ println!("x"); }') == []
assert code_run.critique("erlang", 'main(_) -> io:format("x~n").') == []
@pytest.mark.parametrize("lang,source,needle", [
("python", "import socket\nprint(socket)", "network"),
("python", "import requests\nprint(requests)", "network"),
("python", "import subprocess\nsubprocess.run(['ls'])", "processes"),
("c", "#include <sys/socket.h>\nint main(void){return 0;}", "network"),
("c", '#include <stdlib.h>\nint main(void){system("ls");}', "processes"),
("rust", "fn main(){ std::process::Command::new(\"ls\"); }", "processes"),
("erlang", 'main(_) -> os:cmd("ls").', "processes"),
])
def test_screening_catches_the_obvious_reaches(lang, source, needle):
issues = code_run.critique(lang, source)
assert issues, f"{lang} snippet passed screening"
assert any(needle in i for i in issues), issues
def test_screening_catches_absolute_paths_and_urls():
assert any("absolute" in i for i in code_run.critique(
"python", 'data = open("/etc/passwd").read()\nprint(data)'))
# Escaped and raw Windows paths both count.
assert any("absolute" in i for i in code_run.critique(
"python", 'data = open("C:\\\\Users\\\\me").read()'))
assert any("absolute" in i for i in code_run.critique(
"python", r'data = open(r"C:\Users\me").read()'))
assert any("http" in i for i in code_run.critique(
"python", 'print("see https://example.com/data.csv")'))
def test_url_in_a_comment_is_not_a_network_reach():
"""A citation in a comment is not fetch(); bouncing it costs a useless retry."""
c = (
"/* spec: https://en.cppreference.com/w/c/string */\n"
"#include <stdio.h>\n"
"int main(void){ puts(\"ok\"); return 0; }\n"
)
assert code_run.critique("c", c) == []
def test_erlang_nproc_ceiling_is_relative_to_the_user(monkeypatch):
"""RLIMIT_NPROC is UID-scoped; an absolute 512 is not 512 of headroom."""
monkeypatch.setattr(code_run, "_user_process_count", lambda: 400)
assert code_run._max_procs_for("erlang") == 400 + code_run._ERLANG_PROC_HEADROOM
assert code_run._max_procs_for("python") == code_run._MAX_PROCS
monkeypatch.setattr(code_run, "_user_process_count", lambda: None)
assert code_run._max_procs_for("erlang") is None
def test_missing_entry_point_is_named_up_front():
"""Without this the user sees a linker error about _main, or an escript load
failure neither of which says 'you forgot the entry point'."""
assert any("main()" in i for i in code_run.critique("c", "int add(int a){return a+1;}"))
assert any("main()" in i for i in code_run.critique("rust", "fn add(a: i32) -> i32 { a }"))
assert any("main/1" in i for i in code_run.critique("erlang", "add(A) -> A + 1."))
def test_empty_source_says_so_and_stops():
issues = code_run.critique("python", " ")
assert issues == ["source is empty or too short to run."]
def test_unknown_lang_is_refused_by_both_entry_points():
assert code_run.critique("brainfuck", "+++.")[0].startswith("cannot run")
assert code_run.run("brainfuck", "+++.")["ok"] is False
def test_oversized_source_is_refused_without_running():
issues = code_run.critique("python", "x = 1\n" * 40_000)
assert issues and "characters" in issues[0]
# ---------------------------------------------------------------------------
# Execution (python: always available)
# ---------------------------------------------------------------------------
def test_stdout_and_exit_code_come_back():
out = code_run.run("python", "print('hello', 6 * 7)")
assert out["ok"] is True
assert out["stdout"].strip() == "hello 42"
assert out["exit_code"] == 0
def test_streams_are_kept_separate():
out = code_run.run("python", "import sys\nprint('o')\nprint('e', file=sys.stderr)")
assert out["stdout"].strip() == "o"
assert out["stderr"].strip() == "e"
def test_nonzero_exit_is_still_a_successful_run():
out = code_run.run("python", "raise SystemExit(3)")
assert out["ok"] is True
assert out["exit_code"] == 3
def test_a_traceback_is_returned_not_raised():
out = code_run.run("python", "print(1/0)")
assert out["ok"] is True
assert out["exit_code"] != 0
assert "ZeroDivisionError" in out["stderr"]
def test_stdin_is_piped_in():
out = code_run.run("python", "import sys\nprint(sys.stdin.read().upper())", stdin="abc")
assert out["stdout"].strip() == "ABC"
def test_a_program_reading_stdin_with_none_given_does_not_hang():
"""stdin is always closed rather than inherited: a snippet calling input()
with nothing piped in would otherwise block on the server's own stdin until
the wall clock killed it, five seconds of 'thinking' for nothing."""
out = code_run.run("python", "print(input('prompt: '))")
assert out["ok"] is True
assert "EOFError" in out["stderr"]
def test_an_infinite_loop_is_killed_and_explained():
out = code_run.run("python", "while True:\n pass")
assert out["ok"] is False
assert out["stage"] == "run"
assert "did not finish" in out["error"]
def test_output_is_truncated_not_streamed_whole():
out = code_run.run("python", f"print('x' * {code_run.MAX_OUTPUT * 3})")
assert len(out["stdout"]) < code_run.MAX_OUTPUT + 200
assert "truncated" in out["stdout"]
def test_the_working_directory_is_ephemeral():
"""Two runs must not see each other's files. The temp dir is the only thing
standing between a snippet and the user's cwd, so its lifetime is worth a
test rather than an assumption."""
first = code_run.run("python", "open('note.txt', 'w').write('hi')\nprint('wrote')")
assert first["ok"] is True, first
second = code_run.run("python", "import os\nprint(os.path.exists('note.txt'))")
assert second["stdout"].strip() == "False"
def test_the_child_does_not_inherit_the_servers_environment():
"""A snippet has no business reading the process environment it happens to
be spawned from that is where an API key would be."""
import os
os.environ["NEXUS_RUN_LEAK_PROBE"] = "secret"
try:
out = code_run.run(
"python", "import os\nprint(os.environ.get('NEXUS_RUN_LEAK_PROBE'))"
)
finally:
os.environ.pop("NEXUS_RUN_LEAK_PROBE", None)
assert out["stdout"].strip() == "None"
def test_home_points_at_the_scratch_dir():
"""HOME is rewritten so a snippet writing a dotfile writes it somewhere that
gets deleted, rather than into the user's real home."""
out = code_run.run("python", "import os\nprint(os.path.expanduser('~'))")
assert "nexus-run-" in out["stdout"]
def test_temp_points_at_the_scratch_dir():
"""Compilers and snippets must not fall back to a host temp directory."""
out = code_run.run("python", "import tempfile\nprint(tempfile.gettempdir())")
assert "nexus-run-" in out["stdout"]
# ---------------------------------------------------------------------------
# Toolchains
# ---------------------------------------------------------------------------
def test_windows_compiler_resolver_skips_broken_candidates(monkeypatch):
"""An executable on PATH is not enough when its linker/runtime is broken."""
code_run._compiled_tool.cache_clear()
monkeypatch.setattr(code_run.sys, "platform", "win32")
monkeypatch.setattr(
code_run.shutil, "which", lambda name: f"C:\\tools\\{name}.exe"
)
probes = []
def probe(lang, tool):
probes.append((lang, tool))
return tool.endswith("clang.exe")
monkeypatch.setattr(code_run, "_probe_compiled_tool", probe)
try:
assert code_run._compiled_tool("c", ("gcc", "clang")) == "C:\\tools\\clang.exe"
assert probes == [
("c", "C:\\tools\\gcc.exe"),
("c", "C:\\tools\\clang.exe"),
]
finally:
code_run._compiled_tool.cache_clear()
def test_a_missing_toolchain_explains_itself(monkeypatch):
"""The model has to be able to tell 'you cannot run this here' from 'your
code is wrong' — otherwise it rewrites a correct program repeatedly."""
monkeypatch.setitem(
code_run.RUN_LANGS["rust"], "tool", lambda: None
)
out = code_run.run("rust", 'fn main(){ println!("x"); }')
assert out["ok"] is False
assert out["stage"] == "toolchain"
assert "rustup.rs" in out["error"]
assert "Show the code instead" in out["error"]
@_requires("c")
def test_c_compiles_and_runs():
out = code_run.run("c", '#include <stdio.h>\nint main(void){printf("%d\\n", 6*7);return 0;}')
assert out["ok"] is True, out
assert out["stdout"].strip() == "42"
@_requires("c")
def test_a_compile_error_comes_back_as_a_compile_error():
"""Stage matters: the compiler's diagnostics are the useful payload, and
labelling this a run failure would hide that nothing ever executed."""
out = code_run.run("c", "int main(void){ return oops; }")
assert out["ok"] is False
assert out["stage"] == "compile"
assert "oops" in out["stderr"]
@_requires("cpp")
def test_cpp_compiles_and_runs():
out = code_run.run("cpp", '#include <iostream>\nint main(){std::cout << 6*7 << "\\n";}')
assert out["ok"] is True, out
assert out["stdout"].strip() == "42"
@_requires("rust")
def test_rust_compiles_and_runs():
out = code_run.run("rust", 'fn main(){ println!("{}", (1..=10).sum::<i32>()); }')
assert out["ok"] is True, out
assert out["stdout"].strip() == "55"
@_requires("erlang")
def test_erlang_runs_a_bare_escript():
out = code_run.run("erlang", 'main(_) -> io:format("~p~n", [lists:sum(lists:seq(1,10))]).')
assert out["ok"] is True, out
assert out["stdout"].strip() == "55"
@_requires("erlang")
def test_erlang_runs_a_module_form_script():
out = code_run.run(
"erlang",
"-module(main).\n-export([main/1]).\nmain(_) -> io:format(\"~p~n\", [7*6]).",
)
assert out["ok"] is True, out
assert out["stdout"].strip() == "42"
@pytest.mark.skipif(sys.platform != "linux", reason="unshare is Linux-only")
def test_network_isolation_probe_is_honest():
"""Either we got a namespace or we did not — but the probe must never claim
one it did not verify, because the tool description promises 'no network' on
the strength of it."""
prefix = code_run._net_isolation()
assert prefix in ([], ["unshare", "-rn"])
if prefix:
assert shutil.which("unshare")
-55
View File
@@ -1,55 +0,0 @@
"""synapse/curry_core.py (vendored) + synapse/curry_store.py (NexusOS's preload).
Two concerns: the vendor sync didn't silently drop the sandbox fix from
https://github.com/Athena-Pro/Curry/pull/4, and curry_store actually gives
NexusOS a live, callable instance without wiring it into any chat-facing tool.
"""
import pytest
from synapse.curry_core import Curry, TypeSignature
from synapse import curry_store
def test_curry_store_is_preloaded_and_callable():
# curry_store.curry_db is a module-level singleton constructed at import
# time (mirrors synapse.memory.store.store / synapse.playbooks.store.playbook_store)
# - by the time this test runs, it has already opened its database file.
assert isinstance(curry_store.curry_db, Curry)
curry_store.curry_db.declare_constant("t_preload_check", 1, 1, TypeSignature.INT32.value)
assert curry_store.curry_db.get_constant_latest("t_preload_check")["value"] == 1
curry_store.curry_db.retire_constant("t_preload_check", 1)
def test_curry_db_path_matches_nexus_config(tmp_path, monkeypatch):
from synapse import nexus_config
assert str(curry_store.curry_db.db_path) == str(nexus_config.CURRY_DB)
def test_vendored_sandbox_fix_rejects_format_dunder_escape(tmp_path):
# Regression test for the vendored fix: a body that hides dunder-attribute
# traversal inside a str.format() field spec must still be rejected at
# declare time, not just the literal '.__class__' form. If a future
# re-vendor from upstream drops the fix, this is what catches it.
db = Curry(str(tmp_path / "sandbox_check.db"))
db.declare_function("helper", 1, "1")
exploit = "'{0.__globals__}'.format(helper)"
with pytest.raises(ValueError, match="format"):
db.declare_function("evil", 1, exploit, function_bindings={"helper": 1})
# the original, always-caught dunder-attribute form stays blocked too
with pytest.raises(ValueError):
db.declare_function("evil2", 1, "x.__class__", expected_args=["x"])
db.close()
def test_vendored_curry_basic_versioning_roundtrip(tmp_path):
db = Curry(str(tmp_path / "roundtrip.db"))
db.declare_constant("rate", 1, 0.1, TypeSignature.FLOAT64.value)
db.declare_function(
"apply_rate", 1, "amount * (1 + rate)",
constant_bindings={"rate": 1}, expected_args=["amount"],
)
assert db.call_function("apply_rate", 1, {"amount": 100}) == 110.00000000000001
db.close()
-34
View File
@@ -136,40 +136,6 @@ def test_conversation_recall_uses_vec_and_matches_brute_force():
asyncio.run(run()) asyncio.run(run())
def test_delete_conversation_removes_message_vectors():
"""Deleting a conversation must take its embeddings with it — orphaned
message_vectors rows are invisible to recall but grow the DB forever."""
s = _store()
async def run():
s.create_conversation("c1")
s.add_message("c1", "user", "tell me about lego star wars")
s.add_message("c1", "assistant", "lego star wars is a fun game")
s.create_conversation("c2")
s.add_message("c2", "user", "gpu vega vram notes")
# the first search lazily backfills a vector for every message
await s.semantic_search_conversations("lego star wars", _fake_embed, limit=2, min_score=0.1)
conn = s._connect()
assert conn.execute("SELECT COUNT(*) FROM message_vectors").fetchone()[0] == 3
conn.close()
s.delete_conversation("c1")
conn = s._connect()
orphans = conn.execute(
"SELECT COUNT(*) FROM message_vectors v "
"LEFT JOIN messages m ON m.id = v.message_id WHERE m.id IS NULL"
).fetchone()[0]
assert orphans == 0
assert conn.execute("SELECT COUNT(*) FROM message_vectors").fetchone()[0] == 1 # c2 untouched
if s.vec_enabled: # the ANN mirror is pruned too, not just the JSON table
assert conn.execute("SELECT COUNT(*) FROM vec_messages").fetchone()[0] == 1
conn.close()
asyncio.run(run())
def test_startup_sweeps_pre_existing_orphan_vectors(): def test_startup_sweeps_pre_existing_orphan_vectors():
"""Databases written before delete_conversation cleaned up after itself are """Databases written before delete_conversation cleaned up after itself are
repaired the next time the store opens them.""" repaired the next time the store opens them."""
+15 -15
View File
@@ -19,7 +19,7 @@ THEME_INSTALLER = REPO / "assets" / "themes" / "install-theme.sh"
def test_look_and_feel_is_copied_never_symlinked(): def test_look_and_feel_is_copied_never_symlinked():
"""KPackage skips symlinked package directories without a word, so a """KPackage skips symlinked package directories without a word, so a
symlinked Global Theme simply never appears in System Settings.""" symlinked Global Theme simply never appears in System Settings."""
text = INSTALLER.read_text(encoding="utf-8") text = INSTALLER.read_text()
assert "cp -rL" in text, "look-and-feel/wallpaper must be copied into place" assert "cp -rL" in text, "look-and-feel/wallpaper must be copied into place"
for line in text.splitlines(): for line in text.splitlines():
if line.strip().startswith("ln -s"): if line.strip().startswith("ln -s"):
@@ -31,7 +31,7 @@ def test_plasmashell_restart_is_detached_from_the_callers_stdout():
"""The restarted shell outlives the script. Inheriting stdout keeps the """The restarted shell outlives the script. Inheriting stdout keeps the
caller's pipe open forever, which hangs `ncp restore` after a successful caller's pipe open forever, which hangs `ncp restore` after a successful
apply.""" apply."""
text = INSTALLER.read_text(encoding="utf-8") text = INSTALLER.read_text()
restart = [l for l in text.splitlines() restart = [l for l in text.splitlines()
if "kstart5 plasmashell" in l and not l.strip().startswith("#")] if "kstart5 plasmashell" in l and not l.strip().startswith("#")]
assert restart, "no plasmashell restart found" assert restart, "no plasmashell restart found"
@@ -44,7 +44,7 @@ def test_plasmashell_restart_is_detached_from_the_callers_stdout():
def test_splash_renders_without_the_stage_signal(): def test_splash_renders_without_the_stage_signal():
"""A splash gated on `stage == 2` shows a blank coloured screen if that """A splash gated on `stage == 2` shows a blank coloured screen if that
signal never arrives -- what `ksplashqml --test` does.""" signal never arrives -- what `ksplashqml --test` does."""
qml = (LNF / "contents" / "splash" / "Splash.qml").read_text(encoding="utf-8") qml = (LNF / "contents" / "splash" / "Splash.qml").read_text()
content = qml[qml.index("id: content"):] content = qml[qml.index("id: content"):]
body = content[:content.index("OpacityAnimator")] body = content[:content.index("OpacityAnimator")]
assert "opacity: 0" not in body, "splash content starts invisible" assert "opacity: 0" not in body, "splash content starts invisible"
@@ -55,7 +55,7 @@ def test_sddm_theme_is_configured_in_exactly_one_place():
"""boot-branding.sh and install-plasma.sh both deploy the SDDM theme; two """boot-branding.sh and install-plasma.sh both deploy the SDDM theme; two
different config files meant the setting could disagree with itself.""" different config files meant the setting could disagree with itself."""
for script in (INSTALLER, REPO / "bin" / "boot-branding.sh"): for script in (INSTALLER, REPO / "bin" / "boot-branding.sh"):
text = script.read_text(encoding="utf-8") text = script.read_text()
stray = re.findall(r"/etc/sddm\.conf(?!\.d)", text) stray = re.findall(r"/etc/sddm\.conf(?!\.d)", text)
assert not stray, f"{script.name} writes bare /etc/sddm.conf; use conf.d" assert not stray, f"{script.name} writes bare /etc/sddm.conf; use conf.d"
@@ -63,7 +63,7 @@ def test_sddm_theme_is_configured_in_exactly_one_place():
def test_restore_desktop_stage_covers_plasma_as_well_as_xfce(): def test_restore_desktop_stage_covers_plasma_as_well_as_xfce():
"""The desktop stage used to bail out entirely without xfconf-query, so a """The desktop stage used to bail out entirely without xfconf-query, so a
Plasma box got no theme back from `ncp restore` at all.""" Plasma box got no theme back from `ncp restore` at all."""
text = (REPO / "bin" / "restore-linux.sh").read_text(encoding="utf-8") text = (REPO / "bin" / "restore-linux.sh").read_text()
assert "install-plasma.sh" in text, "restore never invokes the Plasma installer" assert "install-plasma.sh" in text, "restore never invokes the Plasma installer"
assert "--no-sddm" in text, "restore should leave SDDM to boot-branding.sh" assert "--no-sddm" in text, "restore should leave SDDM to boot-branding.sh"
# The XFCE check must not be able to skip the Plasma branch or the branding. # The XFCE check must not be able to skip the Plasma branch or the branding.
@@ -72,11 +72,11 @@ def test_restore_desktop_stage_covers_plasma_as_well_as_xfce():
def test_global_theme_package_is_well_formed(): def test_global_theme_package_is_well_formed():
meta = json.loads((LNF / "metadata.json").read_text(encoding="utf-8")) meta = json.loads((LNF / "metadata.json").read_text())
assert meta["KPlugin"]["Id"] == LNF.name, "package Id must match its directory" assert meta["KPlugin"]["Id"] == LNF.name, "package Id must match its directory"
assert "Plasma/LookAndFeel" in meta["KPlugin"]["ServiceTypes"] assert "Plasma/LookAndFeel" in meta["KPlugin"]["ServiceTypes"]
defaults = (LNF / "contents" / "defaults").read_text(encoding="utf-8") defaults = (LNF / "contents" / "defaults").read_text()
# Every component the Global Theme selects has to exist in the repo. # Every component the Global Theme selects has to exist in the repo.
assert "ColorScheme=NexusOS" in defaults assert "ColorScheme=NexusOS" in defaults
assert (KDE / "plasma" / "NexusOS").is_dir() assert (KDE / "plasma" / "NexusOS").is_dir()
@@ -96,7 +96,7 @@ def test_patterned_backgrounds_are_referenced_as_raster_not_svg():
for f in qml_files: for f in qml_files:
# Only the source: lines -- the comments deliberately mention the SVG, # Only the source: lines -- the comments deliberately mention the SVG,
# since that is the file you edit and re-rasterize. # since that is the file you edit and re-rasterize.
sources = [l for l in f.read_text(encoding="utf-8").splitlines() sources = [l for l in f.read_text().splitlines()
if "source:" in l and not l.strip().startswith("//")] if "source:" in l and not l.strip().startswith("//")]
bg = [l for l in sources if "background" in l] bg = [l for l in sources if "background" in l]
assert bg, f"{f.name} loads no background" assert bg, f"{f.name} loads no background"
@@ -108,7 +108,7 @@ def test_patterned_backgrounds_are_referenced_as_raster_not_svg():
def _defaults_sections(): def _defaults_sections():
"""Parse the look-and-feel defaults into {section: {key: value}}.""" """Parse the look-and-feel defaults into {section: {key: value}}."""
out, section = {}, None out, section = {}, None
for line in (LNF / "contents" / "defaults").read_text(encoding="utf-8").splitlines(): for line in (LNF / "contents" / "defaults").read_text().splitlines():
line = line.strip() line = line.strip()
if line.startswith("["): if line.startswith("["):
section = line section = line
@@ -127,7 +127,7 @@ def test_lock_screen_theme_names_a_look_and_feel_package():
assert greeter["Theme"].endswith(".desktop"), \ assert greeter["Theme"].endswith(".desktop"), \
f"lock theme must be a look-and-feel package id, got {greeter['Theme']!r}" f"lock theme must be a look-and-feel package id, got {greeter['Theme']!r}"
installer = INSTALLER.read_text(encoding="utf-8") installer = INSTALLER.read_text()
lock_lines = [l for l in installer.splitlines() lock_lines = [l for l in installer.splitlines()
if "kscreenlockerrc" in l and "--key Theme" in l] if "kscreenlockerrc" in l and "--key Theme" in l]
assert lock_lines, "installer never sets the lock screen theme" assert lock_lines, "installer never sets the lock screen theme"
@@ -141,7 +141,7 @@ def test_lock_screen_theme_names_a_look_and_feel_package():
def _index_theme(): def _index_theme():
"""Parse index.theme into (header dict, list of declared directories).""" """Parse index.theme into (header dict, list of declared directories)."""
header, section, dirs = {}, None, [] header, section, dirs = {}, None, []
for line in (ICONS / "index.theme").read_text(encoding="utf-8").splitlines(): for line in (ICONS / "index.theme").read_text().splitlines():
line = line.strip() line = line.strip()
if line.startswith("[") and line != "[Icon Theme]": if line.startswith("[") and line != "[Icon Theme]":
section = line.strip("[]") section = line.strip("[]")
@@ -180,7 +180,7 @@ def test_icon_theme_is_installed_where_qt_looks():
"""~/.icons is the GTK/XFCE legacy path. Qt/KF5 searches XDG data dirs only, """~/.icons is the GTK/XFCE legacy path. Qt/KF5 searches XDG data dirs only,
so installing there alone meant Plasma never found the theme and every icon so installing there alone meant Plasma never found the theme and every icon
fell back to Breeze without a word.""" fell back to Breeze without a word."""
text = THEME_INSTALLER.read_text(encoding="utf-8") text = THEME_INSTALLER.read_text()
links = [l for l in text.splitlines() links = [l for l in text.splitlines()
if l.strip().startswith("link ") and "NexusOS-icons" in l] if l.strip().startswith("link ") and "NexusOS-icons" in l]
assert any(".local/share/icons" in l for l in links), \ assert any(".local/share/icons" in l for l in links), \
@@ -193,7 +193,7 @@ def test_inherits_check_reads_only_the_primary_parent():
"""The installer compared the whole comma-separated Inherits value against a """The installer compared the whole comma-separated Inherits value against a
directory name, so a valid multi-parent list warned that an installed directory name, so a valid multi-parent list warned that an installed
fallback was missing.""" fallback was missing."""
text = THEME_INSTALLER.read_text(encoding="utf-8") text = THEME_INSTALLER.read_text()
inh = [l for l in text.splitlines() if "INH=" in l and "Inherits" in l] inh = [l for l in text.splitlines() if "INH=" in l and "Inherits" in l]
assert inh, "inheritance check not found" assert inh, "inheritance check not found"
assert any("-f1" in l for l in inh), \ assert any("-f1" in l for l in inh), \
@@ -204,11 +204,11 @@ def test_panel_layout_is_portable():
"""The panel script sets the launcher icon by absolute path. Hardcoding """The panel script sets the launcher icon by absolute path. Hardcoding
this box's home would give any other clone or user a missing icon, so the this box's home would give any other clone or user a missing icon, so the
path is a placeholder the installer substitutes.""" path is a placeholder the installer substitutes."""
js = (KDE / "panel-layout.js").read_text(encoding="utf-8") js = (KDE / "panel-layout.js").read_text()
assert "/home/" not in js, "panel-layout.js hardcodes a home directory" assert "/home/" not in js, "panel-layout.js hardcodes a home directory"
assert "__NEXUS_ROOT__" in js, "no placeholder for the repo path" assert "__NEXUS_ROOT__" in js, "no placeholder for the repo path"
installer = INSTALLER.read_text(encoding="utf-8") installer = INSTALLER.read_text()
assert "__NEXUS_ROOT__" in installer, "installer never substitutes the repo path" assert "__NEXUS_ROOT__" in installer, "installer never substitutes the repo path"
# Rewriting the panel wholesale on every restore would wipe later additions. # Rewriting the panel wholesale on every restore would wipe later additions.
assert "PANEL_MARKER" in installer, "panel layout is not guarded by a marker" assert "PANEL_MARKER" in installer, "panel layout is not guarded by a marker"
+2 -23
View File
@@ -3,41 +3,21 @@
Both checks guard fixes for real defects: the account file used to be written at Both checks guard fixes for real defects: the account file used to be written at
the umask and chmodded afterwards, and the IMAP/SMTP connections used to take the umask and chmodded afterwards, and the IMAP/SMTP connections used to take
Python's stdlib SSL context, which verifies nothing. Python's stdlib SSL context, which verifies nothing.
The 0600-mode assertions are POSIX-only: NTFS has no rwx-owner/group/other bit
model, so os.open(..., 0o600) on Windows creates a normal read-write file and
stat.S_IMODE reports 0o666 regardless of the mode argument -- Python's mode
param there only round-trips the read-only *attribute*, not real ACL-based
per-user access control (that needs pywin32/icacls, out of scope for a local
single-user app whose own user-profile directory is already the actual access
boundary on Windows). Skip rather than assert something the OS can't provide.
""" """
import json import json
import os import os
import ssl import ssl
import stat import stat
import sys
import pytest
from modules.mail import backend as mail from modules.mail import backend as mail
_WINDOWS_NO_POSIX_MODE = pytest.mark.skipif(
sys.platform == "win32",
reason="0600 is a POSIX permission model; NTFS has no equivalent bits to assert on",
)
def test_account_file_is_never_group_or_world_readable(tmp_path, monkeypatch): def test_account_file_is_never_group_or_world_readable(tmp_path, monkeypatch):
monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "mail_accounts.json") monkeypatch.setattr(mail, "_ACCOUNT_FILE", tmp_path / "mail_accounts.json")
mail._write_accounts([{**mail._DEFAULTS, "id": "abc", "username": "u", "password": "secret"}]) mail._write_accounts([{**mail._DEFAULTS, "id": "abc", "username": "u", "password": "secret"}])
# The mode assertion only means something on POSIX; the rest of this test mode = stat.S_IMODE((tmp_path / "mail_accounts.json").stat().st_mode)
# (no temp file left behind, password round-trip) is platform-independent assert mode == 0o600, f"account file is {oct(mode)}, expected 0o600"
# and must keep running on Windows.
if sys.platform != "win32":
mode = stat.S_IMODE((tmp_path / "mail_accounts.json").stat().st_mode)
assert mode == 0o600, f"account file is {oct(mode)}, expected 0o600"
assert not list(tmp_path.glob("*.tmp")), "temp file left behind" assert not list(tmp_path.glob("*.tmp")), "temp file left behind"
# The password round-trips to disk but never to the API. # The password round-trips to disk but never to the API.
@@ -47,7 +27,6 @@ def test_account_file_is_never_group_or_world_readable(tmp_path, monkeypatch):
assert json.loads((tmp_path / "mail_accounts.json").read_text())["accounts"] assert json.loads((tmp_path / "mail_accounts.json").read_text())["accounts"]
@_WINDOWS_NO_POSIX_MODE
def test_account_file_is_0600_while_it_is_being_written(tmp_path, monkeypatch): def test_account_file_is_0600_while_it_is_being_written(tmp_path, monkeypatch):
"""The old code wrote at the umask and chmodded afterwards, so the file sat """The old code wrote at the umask and chmodded afterwards, so the file sat
world-readable for the length of the write. Assert the handle it is written world-readable for the length of the write. Assert the handle it is written
+1 -2
View File
@@ -22,7 +22,6 @@ def test_render_frame_contains_sections():
"version": "0.0.0", "version": "0.0.0",
"services": { "services": {
"backend": {"running": True, "pid": 11, "url": "http://127.0.0.1:8000"}, "backend": {"running": True, "pid": 11, "url": "http://127.0.0.1:8000"},
"memory": {"running": False, "pid": None, "url": "http://127.0.0.1:8001"},
"frontend": {"running": False, "pid": None, "url": "http://127.0.0.1:5173"}, "frontend": {"running": False, "pid": None, "url": "http://127.0.0.1:5173"},
"provider": { "provider": {
"provider": "ollama", "provider": "ollama",
@@ -55,7 +54,7 @@ def test_render_frame_contains_sections():
assert "DATA / TOOLS" in frame assert "DATA / TOOLS" in frame
assert "RUN TOOLCHAINS" in frame assert "RUN TOOLCHAINS" in frame
assert "backend" in frame and "UP" in frame assert "backend" in frame and "UP" in frame
assert "memory" in frame and "DOWN" in frame assert "frontend" in frame and "DOWN" in frame
assert "run_snippet" in frame assert "run_snippet" in frame
assert "ready python" in frame assert "ready python" in frame
assert "missing rust" in frame assert "missing rust" in frame
+8 -2
View File
@@ -33,7 +33,7 @@ DISTRIBUTION_OF = {
TRANSITIVE = {"starlette", "socketio", "engineio", "rich"} TRANSITIVE = {"starlette", "socketio", "engineio", "rich"}
# Modules that ship inside this repo. # Modules that ship inside this repo.
FIRST_PARTY = {"synapse", "nexusos_cli", "management", "bin", "tests", "modules"} FIRST_PARTY = {"synapse", "nexusos_cli", "modules", "management", "bin", "tests"}
def _pyproject() -> dict: def _pyproject() -> dict:
@@ -56,6 +56,12 @@ def _declared() -> set[str]:
return {_requirement_name(s) for s in specs} return {_requirement_name(s) for s in specs}
def test_wheel_includes_every_shipped_package():
wheel = _pyproject()["tool"]["hatch"]["build"]["targets"]["wheel"]
configured = set(wheel["packages"])
assert configured == set(SHIPPED_PACKAGES)
def _imported_modules() -> set[str]: def _imported_modules() -> set[str]:
"""Top-level module names imported anywhere in the shipped packages.""" """Top-level module names imported anywhere in the shipped packages."""
found: set[str] = set() found: set[str] = set()
@@ -89,7 +95,7 @@ def test_every_third_party_import_is_a_declared_dependency():
if DISTRIBUTION_OF.get(module, module).lower().replace("_", "-") not in declared if DISTRIBUTION_OF.get(module, module).lower().replace("_", "-") not in declared
) )
assert not missing, ( assert not missing, (
"synapse/nexusos_cli import these, but pyproject.toml declares no " "a shipped package imports these, but pyproject.toml declares no "
f"distribution for them: {missing}. Add them to [project] dependencies " f"distribution for them: {missing}. Add them to [project] dependencies "
"or an extra (and to DISTRIBUTION_OF here if the names differ)." "or an extra (and to DISTRIBUTION_OF here if the names differ)."
) )
-308
View File
@@ -1,308 +0,0 @@
"""Self-modification: synapse/self_edit.py, plus the ACTION_TOOLS/approval-floor
wiring in tools.py and chat.py that gates it.
Path-boundary tests mirror the sibling-directory-bypass idiom already used for
/icons/image (tests/test_smoke.py) and icons/compositor.py the same bug class,
fixed the same way, tested the same way.
"""
import asyncio
import json
import shutil
import subprocess
from pathlib import Path
import pytest
from synapse import self_edit
from synapse import tools
from synapse import playbook_manager
from synapse.nexus_config import settings
from synapse.playbooks.store import PlaybookFileStore, PlaybookItem
def _requires_git():
return pytest.mark.skipif(not shutil.which("git"), reason="git not installed")
@pytest.fixture
def fake_project(tmp_path, monkeypatch):
"""A throwaway project root with the subdirs edit_source must know about."""
root = tmp_path / "proj"
(root / "synapse").mkdir(parents=True)
(root / ".git").mkdir()
(root / "runtime").mkdir()
(root / "data").mkdir()
monkeypatch.setattr(settings, "project_root", root)
monkeypatch.setattr(settings, "source_checkout", True)
return root
# ---------------------------------------------------------------------------
# Path boundary
# ---------------------------------------------------------------------------
def test_resolve_source_path_allows_a_file_under_root(fake_project):
f = fake_project / "synapse" / "foo.py"
f.write_text("x", encoding="utf-8")
resolved = self_edit._resolve_source_path("synapse/foo.py")
assert resolved == f.resolve()
def test_resolve_source_path_denies_sibling_directory_bypass(fake_project, tmp_path):
# A sibling directory that merely shares a string prefix with the allowed
# root ("proj-evil" vs "proj") must not pass — the exact bug class fixed
# today in main.py's /icons/image.
sibling = tmp_path / "proj-evil"
sibling.mkdir()
(sibling / "x.py").write_text("evil", encoding="utf-8")
with pytest.raises(self_edit.PathError):
self_edit._resolve_source_path("../proj-evil/x.py")
@pytest.mark.parametrize("subdir", sorted(self_edit._DENYLIST_SUBDIRS))
def test_resolve_source_path_denies_each_denylisted_subdir(fake_project, subdir):
with pytest.raises(self_edit.PathError):
self_edit._resolve_source_path(f"{subdir}/whatever.txt")
def test_resolve_source_path_denies_absolute_paths(fake_project):
with pytest.raises(self_edit.PathError):
self_edit._resolve_source_path("C:\\Windows\\System32\\evil.py")
with pytest.raises(self_edit.PathError):
self_edit._resolve_source_path("/etc/passwd")
# ---------------------------------------------------------------------------
# source_checkout gating
# ---------------------------------------------------------------------------
def test_source_checkout_gating(fake_project, monkeypatch):
monkeypatch.setattr(settings, "source_checkout", False)
with pytest.raises(RuntimeError):
self_edit.source_checkout_required()
preview = self_edit.preview_source_edit("synapse/foo.py", "x")
assert preview["ok"] is False
assert "not a source checkout" in preview["error"]
# ---------------------------------------------------------------------------
# Diff computed from ground truth
# ---------------------------------------------------------------------------
def test_preview_computes_a_real_diff_not_the_models_claim(fake_project):
f = fake_project / "synapse" / "foo.py"
f.write_text("print('old')\n", encoding="utf-8")
preview = self_edit.preview_source_edit("synapse/foo.py", "print('new')\n")
assert preview["ok"] is True
assert "-print('old')" in preview["diff"]
assert "+print('new')" in preview["diff"]
def test_preview_rejects_oversized_content(fake_project):
huge = "x" * (self_edit.MAX_FILE_CHARS + 1)
preview = self_edit.preview_source_edit("synapse/foo.py", huge)
assert preview["ok"] is False
# ---------------------------------------------------------------------------
# Apply + git commit
# ---------------------------------------------------------------------------
@_requires_git()
def test_apply_source_edit_writes_and_commits(fake_project):
subprocess.run(["git", "init"], cwd=fake_project, check=True, capture_output=True)
subprocess.run(["git", "config", "user.email", "test@test"], cwd=fake_project, check=True, capture_output=True)
subprocess.run(["git", "config", "user.name", "test"], cwd=fake_project, check=True, capture_output=True)
result = self_edit.apply_source_edit("synapse/foo.py", "print('applied')\n", "add foo")
assert result["ok"] is True
assert (fake_project / "synapse" / "foo.py").read_text(encoding="utf-8") == "print('applied')\n"
assert result["commit"] is not None
log = subprocess.run(["git", "log", "--oneline"], cwd=fake_project, check=True,
capture_output=True, text=True)
assert "self-edit: add foo" in log.stdout
def test_apply_source_edit_degrades_to_no_commit_when_git_fails(fake_project, monkeypatch):
# No `git init` here — fake_project/.git exists as a plain directory, not a
# real repo, so git commands fail. The write must still succeed.
result = self_edit.apply_source_edit("synapse/foo.py", "print('ok')\n", "x")
assert result["ok"] is True
assert result["commit"] is None
assert (fake_project / "synapse" / "foo.py").read_text(encoding="utf-8") == "print('ok')\n"
# ---------------------------------------------------------------------------
# Playbook merge semantics
# ---------------------------------------------------------------------------
@pytest.fixture
def fake_playbooks(tmp_path, monkeypatch):
store = PlaybookFileStore(tmp_path / "playbooks")
monkeypatch.setattr(playbook_manager, "playbook_store", store)
monkeypatch.setattr(self_edit, "playbook_store", store)
return store
def test_edit_playbook_merge_preserves_omitted_fields(fake_playbooks):
fake_playbooks.add_playbook(PlaybookItem(
id="p1", title="Title", goal="Goal", instructions="Do X",
tags=["a"], tools=["remember"], model="m1", order=0,
))
result = playbook_manager.persist_playbook({"id": "p1", "instructions": "Do Y"}, merge=True)
assert result["instructions"] == "Do Y"
assert result["title"] == "Title"
assert result["goal"] == "Goal"
assert result["tags"] == ["a"]
assert result["tools"] == ["remember"]
assert result["model"] == "m1"
def test_edit_playbook_requires_full_fields_for_new(fake_playbooks):
with pytest.raises(ValueError):
playbook_manager.persist_playbook({"instructions": "only this"}, merge=True)
def test_edit_playbook_create_appends_at_tail_not_main(fake_playbooks):
fake_playbooks.add_playbook(PlaybookItem(id="p1", title="A", goal="g", instructions="i", order=0))
result = playbook_manager.persist_playbook(
{"title": "B", "goal": "g2", "instructions": "i2"}, merge=True,
)
assert result["order"] != 0
main = fake_playbooks.all_playbooks()[0]
assert main.id == "p1"
def test_make_main_reassigns_order_zero_without_corrupting_the_rest(fake_playbooks):
fake_playbooks.add_playbook(PlaybookItem(id="p1", title="A", goal="g", instructions="i", order=0))
fake_playbooks.add_playbook(PlaybookItem(id="p2", title="B", goal="g", instructions="i", order=1))
playbook_manager.make_main("p2")
all_pb = fake_playbooks.all_playbooks()
assert all_pb[0].id == "p2"
assert {p.id for p in all_pb} == {"p1", "p2"}
def test_preview_playbook_edit_flags_becomes_main(fake_playbooks):
fake_playbooks.add_playbook(PlaybookItem(id="p1", title="A", goal="g", instructions="i", order=0))
fake_playbooks.add_playbook(PlaybookItem(id="p2", title="B", goal="g", instructions="i", order=1))
preview = self_edit.preview_playbook_edit({"id": "p2", "make_active": True})
assert preview["ok"] is True
assert preview["becomes_main_playbook"] is True
# ---------------------------------------------------------------------------
# Settings edit
# ---------------------------------------------------------------------------
def test_edit_settings_ignores_unknown_keys():
preview = self_edit.preview_settings_edit({"changes": {"model": "llama3.1:8b", "not_a_real_key": 1}})
assert preview["ok"] is True
assert "model" in preview["applied"]
assert "not_a_real_key" in preview["ignored_unknown"]
def test_edit_settings_flags_policy_and_system_prompt_changes():
preview = self_edit.preview_settings_edit({"changes": {"action_tool_policy": "allow"}})
assert preview["policy_change"] is True
preview2 = self_edit.preview_settings_edit({"changes": {"system_prompt": "be nice"}})
assert preview2["system_prompt_change"] is True
# ---------------------------------------------------------------------------
# Approval floor + payload enrichment (chat.py wiring)
# ---------------------------------------------------------------------------
class _EditManager:
"""Returns one edit_settings tool_call, then plain content."""
def __init__(self, args):
self.n = 0
self.args = args
async def chat(self, **_):
self.n += 1
if self.n == 1:
return {"role": "assistant",
"tool_calls": [{"function": {"name": "edit_settings", "arguments": self.args}}]}
return {"role": "assistant", "content": "done"}
def _drive(policy, decision, args, monkeypatch, preview_stub=None):
from synapse import chat as chatmod
async def fake_dispatch(name, call_args):
return json.dumps({"ok": True})
monkeypatch.setattr(tools, "dispatch", fake_dispatch)
if preview_stub is not None:
monkeypatch.setattr(self_edit, "preview_for", lambda name, a: preview_stub)
async def run():
messages = [{"role": "user", "content": "change a setting"}]
schemas = tools.schemas_for(["edit_settings"])
gen = chatmod._run_tool_loop(_EditManager(args), messages, "m", schemas, None, None,
conversation_id="conv2", policy=policy)
statuses = []
approve_payload = None
async for s in gen:
statuses.append(s)
if s.startswith("__approve__"):
approve_payload = json.loads(s[len("__approve__"):])
w = chatmod.pending_approvals["conv2"]
w["decisions"] = {"edit_settings": decision}
w["event"].set()
return statuses, approve_payload
return asyncio.run(run())
def test_edit_settings_requires_approval_even_when_policy_is_allow(monkeypatch):
statuses, payload = _drive("allow", True, {"changes": {"model": "x"}}, monkeypatch)
assert any(s.startswith("__approve__") for s in statuses)
assert payload is not None
def test_approve_payload_carries_the_computed_preview(monkeypatch):
stub = {"ok": True, "applied": {"model": {"before": "a", "after": "x"}}}
statuses, payload = _drive("ask", True, {"changes": {"model": "x"}}, monkeypatch, preview_stub=stub)
assert payload["actions"][0]["preview"] == stub
def test_approve_payload_degrades_gracefully_when_preview_raises(monkeypatch):
def boom(name, args):
raise RuntimeError("preview exploded")
monkeypatch.setattr(self_edit, "preview_for", boom)
statuses, payload = _drive("ask", True, {"changes": {"model": "x"}}, monkeypatch)
assert payload is not None
assert payload["actions"][0]["preview"]["ok"] is False
def test_action_tools_include_self_edit_and_are_gated_by_consent():
allow = ["edit_playbook", "edit_settings", "edit_source"]
on = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=True)]
off = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=False)]
assert set(on) == set(allow)
assert off == []
for name in allow:
assert tools.is_action(name)
assert name in self_edit.ALWAYS_ASK_TOOLS
# ---------------------------------------------------------------------------
# End-to-end: dispatch("edit_source", ...) through the real preview/apply path
# ---------------------------------------------------------------------------
@_requires_git()
def test_dispatch_edit_source_end_to_end(fake_project):
subprocess.run(["git", "init"], cwd=fake_project, check=True, capture_output=True)
subprocess.run(["git", "config", "user.email", "test@test"], cwd=fake_project, check=True, capture_output=True)
subprocess.run(["git", "config", "user.name", "test"], cwd=fake_project, check=True, capture_output=True)
out = asyncio.run(tools.dispatch("edit_source", {
"path": "synapse/foo.py", "new_content": "print('e2e')\n", "summary": "e2e test",
}))
payload = json.loads(out)
assert payload["ok"] is True
assert payload["commit"] is not None
assert "nexus-edit" in payload["fence"]
assert (fake_project / "synapse" / "foo.py").read_text(encoding="utf-8") == "print('e2e')\n"
-204
View File
@@ -1,204 +0,0 @@
"""synapse/slash_commands.py (the /tool_name(arg=val) parser) and its wiring
into chat_stream_endpoint (direct dispatch, no model call, no approval
round-trip) plus the ten curry_* tools it can now reach.
"""
import json
import pytest
from fastapi.testclient import TestClient
from synapse.slash_commands import SlashCommand, SlashCommandError, parse_slash_command
from synapse.main import app
from synapse import tools
# ---------------------------------------------------------------------------
# Parser
# ---------------------------------------------------------------------------
def test_parses_keyword_arguments_as_python_literals():
result = parse_slash_command('/curry_call_function(name="x", version=1, args={"a": 1})')
assert result == SlashCommand(
tool="curry_call_function",
args={"name": "x", "version": 1, "args": {"a": 1}},
)
def test_parses_no_arguments():
assert parse_slash_command("/curry_list_functions()") == SlashCommand(tool="curry_list_functions", args={})
def test_non_slash_message_returns_none():
assert parse_slash_command("just chatting, not a command") is None
def test_slash_without_parens_returns_none():
# The TUI's own local commands (/model foo, /new) use this shape — must
# never be mistaken for a tool call.
assert parse_slash_command("/model gpt") is None
def test_slash_embedded_in_prose_returns_none():
assert parse_slash_command('hey /curry_call_function(name="x", version=1) run this') is None
def test_name_or_call_as_argument_value_is_rejected():
# ast.literal_eval only accepts literals — a bare name or a call is a
# parse failure, not a value, so nothing here is ever evaluated.
result = parse_slash_command("/curry_call_function(x=some_name)")
assert isinstance(result, SlashCommandError)
result2 = parse_slash_command('/curry_call_function(x=__import__("os"))')
assert isinstance(result2, SlashCommandError)
def test_positional_arguments_are_rejected():
result = parse_slash_command("/curry_call_function(1, 2)")
assert isinstance(result, SlashCommandError)
def test_double_star_unpacking_is_rejected():
result = parse_slash_command('/curry_call_function(**{"a": 1})')
assert isinstance(result, SlashCommandError)
def test_malformed_syntax_is_rejected():
result = parse_slash_command("/curry_call_function(name=)")
assert isinstance(result, SlashCommandError)
# ---------------------------------------------------------------------------
# Curry tool registration
# ---------------------------------------------------------------------------
_CURRY_ACTION_TOOLS = {
"curry_declare_constant", "curry_retire_constant",
"curry_declare_function", "curry_retire_function", "curry_call_function",
}
_CURRY_READ_TOOLS = {
"curry_get_constant", "curry_get_constant_latest", "curry_list_constants",
"curry_get_function", "curry_list_functions",
}
def test_all_curry_tools_registered():
for name in _CURRY_ACTION_TOOLS | _CURRY_READ_TOOLS:
assert name in tools.REGISTRY
def test_curry_write_and_execute_tools_are_gated_actions():
for name in _CURRY_ACTION_TOOLS:
assert tools.is_action(name), name
assert name in tools.ALWAYS_ASK_ACTION_TOOLS, name
def test_curry_read_tools_are_not_actions():
for name in _CURRY_READ_TOOLS:
assert not tools.is_action(name), name
# ---------------------------------------------------------------------------
# End-to-end HTTP: direct dispatch, no model call, no approval round-trip
# ---------------------------------------------------------------------------
@pytest.fixture
def client():
return TestClient(app)
def _sse_events(body: str) -> list[tuple[str, str]]:
events = []
event_type = "message"
for block in body.split("\n\n"):
for line in block.splitlines():
if line.startswith("event: "):
event_type = line[len("event: "):].strip()
elif line.startswith("data: "):
events.append((event_type, line[len("data: "):]))
event_type = "message"
return events
def test_slash_command_dispatches_without_model_call(client, monkeypatch):
from synapse import chat as chatmod
async def _boom(*a, **k):
raise AssertionError("the model must not be called for a slash-command")
monkeypatch.setattr(chatmod, "stream_chat_response", _boom)
resp = client.post("/chat/stream", json={
"message": '/curry_list_functions()',
"conversation_id": "test-slash-http-1",
})
events = _sse_events(resp.text)
assert ("status", json.dumps({"tool": "curry_list_functions"})) in events
assert any(t == "done" for t, _ in events)
def test_slash_command_skips_approval_round_trip(client, monkeypatch):
async def _fake_dispatch(name, args):
return json.dumps({"ok": True, "result": "did it"})
monkeypatch.setattr(tools, "dispatch", _fake_dispatch)
resp = client.post("/chat/stream", json={
"message": '/curry_call_function(name="x", version=1, args={})',
"conversation_id": "test-slash-http-2",
})
events = _sse_events(resp.text)
assert not any(t == "tool_request" for t, _ in events)
assert any(t == "done" for t, _ in events)
def test_slash_command_uses_fence_from_result_when_present(client, monkeypatch):
async def _fake_dispatch(name, args):
return json.dumps({"ok": True, "fence": "```nexus-curry\n{\"kind\": \"x\"}\n```"})
monkeypatch.setattr(tools, "dispatch", _fake_dispatch)
resp = client.post("/chat/stream", json={
"message": '/curry_call_function(name="x", version=1, args={})',
"conversation_id": "test-slash-http-3",
})
events = _sse_events(resp.text)
content = [d for t, d in events if t == "message"]
assert content and "nexus-curry" in content[0]
def test_slash_command_unknown_tool_yields_error_not_a_chat_reply(client):
resp = client.post("/chat/stream", json={
"message": "/not_a_real_tool(a=1)",
"conversation_id": "test-slash-http-4",
})
events = _sse_events(resp.text)
assert any(t == "error" for t, _ in events)
assert not any(t == "status" for t, _ in events)
def test_slash_command_malformed_yields_error(client):
resp = client.post("/chat/stream", json={
"message": "/curry_call_function(x=some_name)",
"conversation_id": "test-slash-http-5",
})
events = _sse_events(resp.text)
assert any(t == "error" for t, _ in events)
def test_message_with_leading_slash_but_not_command_shaped_goes_to_chat(client, monkeypatch):
# e.g. "/model gpt" or plain prose starting with "/" - must still reach
# the normal model path, not be swallowed as a broken slash-command.
called = {}
async def _fake_stream(*a, **k):
called["hit"] = True
return
yield # pragma: no cover - make this an async generator
# main.py did `from .chat import stream_chat_response`, a separate name
# binding from chat.stream_chat_response - patch the one main.py actually
# calls.
from synapse import main as mainmod
monkeypatch.setattr(mainmod, "stream_chat_response", _fake_stream)
client.post("/chat/stream", json={
"message": "/model gpt",
"conversation_id": "test-slash-http-6",
})
assert called.get("hit") is True
+56 -94
View File
@@ -216,36 +216,6 @@ def test_icon_source_requires_real_allowed_file_boundary(tmp_path):
module._ALLOWED_ROOTS[:] = old_roots module._ALLOWED_ROOTS[:] = old_roots
def test_icons_image_endpoint_requires_real_allowed_root_boundary(tmp_path):
# Sibling directories that merely share a string prefix with an allowed
# root (e.g. "icons-other" vs "icons") must not pass the check.
from synapse import main
allowed = tmp_path / "icons"
allowed.mkdir()
source = allowed / "app.svg"
source.write_text("<svg />")
sibling = tmp_path / "icons-other"
sibling.mkdir()
evil = sibling / "app.svg"
evil.write_text("<svg />")
old_roots = list(main._ALLOWED_ICON_ROOTS)
main._ALLOWED_ICON_ROOTS[:] = [str(allowed)]
try:
client = TestClient(app)
ok = client.get("/icons/image", params={"path": str(source)})
assert ok.status_code == 200
blocked = client.get("/icons/image", params={"path": str(evil)})
assert blocked.status_code == 403
missing = client.get("/icons/image", params={"path": str(allowed / "missing.svg")})
assert missing.status_code in (403, 404)
finally:
main._ALLOWED_ICON_ROOTS[:] = old_roots
def test_ollama_stream_propagates_transport_errors(monkeypatch): def test_ollama_stream_propagates_transport_errors(monkeypatch):
"""A failing stream must surface, not be swallowed into an empty reply — """A failing stream must surface, not be swallowed into an empty reply —
and it must carry Ollama's own explanation, since that is the only part the and it must carry Ollama's own explanation, since that is the only part the
@@ -534,6 +504,62 @@ def test_curator_drops_fabricated_facts():
"i really prefer short answers over long explanations") is None "i really prefer short answers over long explanations") is None
def test_update_check_reports_behind_and_survives_git_failure(monkeypatch):
from synapse import main
# Fake git so the test never touches the network. Behind → the remote
# VERSION file, not this checkout's, is what the UI advertises.
calls = {
("rev-list", "--count", "HEAD..origin/main"): "3",
("show", "origin/main:VERSION"): "9.9.9\n",
("log", "-1", "--format=%h %s", "origin/main"): "abc1234 feat: thing",
}
monkeypatch.setattr(main, "_git", lambda *a, **kw: calls.get(a, ""))
body = TestClient(app).get("/update/check").json()
assert body["behind"] == 3 and body["remote_version"] == "9.9.9"
# An unreachable remote must not 500 the sidebar.
def boom(*a, **kw):
raise RuntimeError("could not resolve host")
monkeypatch.setattr(main, "_git", boom)
body = TestClient(app).get("/update/check").json()
assert body["behind"] == 0 and "could not resolve host" in body["error"]
def test_update_apply_spawns_detached_and_refuses_a_second_run(monkeypatch):
import subprocess
from synapse import main
seen = {}
def fake_popen(argv, **kw):
seen["argv"], seen["kw"] = argv, kw
return object()
monkeypatch.setattr(main, "_update_running", False)
monkeypatch.setattr(subprocess, "Popen", fake_popen)
client = TestClient(app)
assert client.post("/update/apply").json()["started"] is True
assert seen["argv"][-2:] == [str(REPO_ROOT / "management" / "ncp.py"), "upgrade"]
# Detached, or `ncp upgrade` dies with the backend it is about to stop.
assert seen["kw"].get("start_new_session") or seen["kw"].get("creationflags")
# Double-click must not launch a second pull/rebuild over the first.
assert client.post("/update/apply").json()["started"] is False
def test_preview_iframe_cannot_navigate_to_a_network_url():
"""The child CSP blocks resource loads; the parent CSP must separately
block a sandboxed frame from navigating its own browsing context."""
index = (REPO_ROOT / "interface" / "web" / "index.html").read_text(encoding="utf-8")
markdown = (REPO_ROOT / "interface" / "web" / "src" / "Markdown.jsx").read_text(
encoding="utf-8"
)
assert "frame-src data:" in index
assert 'sandbox="allow-scripts"' in markdown
assert "encodeURIComponent(doc)" in markdown
assert "src={frameUrl}" in markdown
assert "srcDoc={doc}" not in markdown
def test_ollama_failures_surface_the_reason_not_just_the_status(): def test_ollama_failures_surface_the_reason_not_just_the_status():
"""Ollama answers every failure with {"error": "..."} and httpx's default """Ollama answers every failure with {"error": "..."} and httpx's default
message throws it away. A user hitting a retired cloud model saw message throws it away. A user hitting a retired cloud model saw
@@ -623,67 +649,3 @@ def test_think_blocks_never_reach_the_reply():
assert strip_think("rambling\n</think>\nThe answer") == "The answer" assert strip_think("rambling\n</think>\nThe answer") == "The answer"
assert strip_think("a<think>b</think>c") == "ac" assert strip_think("a<think>b</think>c") == "ac"
assert strip_think("no tags here") == "no tags here" assert strip_think("no tags here") == "no tags here"
def test_render_hint_is_only_added_when_the_tool_is_offered():
"""The hint and the tool must share one condition. Unconditional, it turned
up recited as fact inside an answer about LRU caches."""
from synapse import tools as t
assert t.wants_render_preview("draw me a chart")
assert not t.wants_render_preview("Summarize what a thread-safe LRU cache needs")
def test_preview_iframe_cannot_navigate_to_a_network_url():
"""The child CSP blocks resource loads; the parent CSP must separately
block a sandboxed frame from navigating its own browsing context."""
index = (REPO_ROOT / "interface" / "web" / "index.html").read_text(encoding="utf-8")
markdown = (REPO_ROOT / "interface" / "web" / "src" / "Markdown.jsx").read_text(
encoding="utf-8"
)
assert "frame-src data:" in index
assert 'sandbox="allow-scripts"' in markdown
assert "encodeURIComponent(doc)" in markdown
assert "src={frameUrl}" in markdown
assert "srcDoc={doc}" not in markdown
def test_update_check_reports_behind_and_survives_git_failure(monkeypatch):
from synapse import main
# Fake git so the test never touches the network. Behind → the remote
# VERSION file, not this checkout's, is what the UI advertises.
calls = {
("rev-list", "--count", "HEAD..origin/main"): "3",
("show", "origin/main:VERSION"): "9.9.9\n",
("log", "-1", "--format=%h %s", "origin/main"): "abc1234 feat: thing",
}
monkeypatch.setattr(main, "_git", lambda *a, **kw: calls.get(a, ""))
body = TestClient(app).get("/update/check").json()
assert body["behind"] == 3 and body["remote_version"] == "9.9.9"
# An unreachable remote must not 500 the sidebar.
def boom(*a, **kw):
raise RuntimeError("could not resolve host")
monkeypatch.setattr(main, "_git", boom)
body = TestClient(app).get("/update/check").json()
assert body["behind"] == 0 and "could not resolve host" in body["error"]
def test_update_apply_spawns_detached_and_refuses_a_second_run(monkeypatch):
import subprocess
from synapse import main
seen = {}
def fake_popen(argv, **kw):
seen["argv"], seen["kw"] = argv, kw
return object()
monkeypatch.setattr(main, "_update_running", False)
monkeypatch.setattr(subprocess, "Popen", fake_popen)
client = TestClient(app)
assert client.post("/update/apply").json()["started"] is True
assert seen["argv"][-2:] == [str(REPO_ROOT / "management" / "ncp.py"), "upgrade"]
# Detached, or `ncp upgrade` dies with the backend it is about to stop.
assert seen["kw"].get("start_new_session") or seen["kw"].get("creationflags")
# Double-click must not launch a second pull/rebuild over the first.
assert client.post("/update/apply").json()["started"] is False
-176
View File
@@ -1,176 +0,0 @@
"""Automatic code-snippet probe suite.
Walks the catalog in tests/snippet_probes/catalog.py and, for every probe:
* screen asserts critique rejects it (never executed)
* run asserts critique is clean, then runs via synapse.code_run
(skipped cleanly when the host has no toolchain)
* tool same as run, plus tools.dispatch("run_snippet") envelope checks
Adding a language to RUN_LANGS without a smoke probe fails
test_every_run_lang_has_a_smoke_probe. That is the point of the catalog:
coverage is automatic and visible.
"""
from __future__ import annotations
import asyncio
import json
import pytest
from synapse import code_run, tools
from tests.snippet_probes.catalog import PROBES, Probe
def _has_toolchain(lang: str) -> bool:
key = code_run.resolve_lang(lang)
entry = code_run.RUN_LANGS.get(key)
return bool(entry and entry["tool"]())
def _ids(probes=PROBES):
return [p.id for p in probes]
# ---------------------------------------------------------------------------
# Catalog integrity (runs even when every compiled toolchain is missing)
# ---------------------------------------------------------------------------
def test_probe_ids_are_unique():
ids = [p.id for p in PROBES]
assert len(ids) == len(set(ids)), "duplicate probe ids in catalog"
def test_every_run_lang_has_a_smoke_probe():
"""New RUN_LANGS entry without a smoke probe = silent blind spot."""
smoke = {
code_run.resolve_lang(p.lang)
for p in PROBES
if "smoke" in p.tags and p.kind in ("run", "tool")
}
missing = set(code_run.RUN_LANGS) - smoke
assert not missing, (
f"RUN_LANGS without a smoke probe: {sorted(missing)}. "
"Add a Probe(..., tags=('smoke', ...)) to tests/snippet_probes/catalog.py."
)
def test_every_run_lang_has_a_screen_probe():
screened = {
code_run.resolve_lang(p.lang)
for p in PROBES
if p.kind == "screen"
}
missing = set(code_run.RUN_LANGS) - screened
assert not missing, (
f"RUN_LANGS without a screening probe: {sorted(missing)}."
)
def test_catalog_langs_resolve_into_run_langs_or_aliases():
for probe in PROBES:
key = code_run.resolve_lang(probe.lang)
assert key in code_run.RUN_LANGS, (
f"probe {probe.id!r} lang={probe.lang!r} resolves to unknown {key!r}"
)
# ---------------------------------------------------------------------------
# Per-probe execution
# ---------------------------------------------------------------------------
@pytest.mark.parametrize("probe", PROBES, ids=_ids())
def test_snippet_probe(probe: Probe):
if probe.kind == "screen":
_assert_screen(probe)
return
issues = code_run.critique(probe.lang, probe.source)
assert issues == [], f"{probe.id}: unexpected critique issues: {issues}"
if not _has_toolchain(probe.lang):
pytest.skip(f"no {code_run.resolve_lang(probe.lang)} toolchain on this machine")
result = code_run.run(probe.lang, probe.source, stdin=probe.stdin)
_assert_run_result(probe, result)
if probe.kind == "tool":
_assert_tool_envelope(probe, result)
def _assert_screen(probe: Probe) -> None:
assert probe.screen_needle, f"{probe.id}: screen probe needs screen_needle"
issues = code_run.critique(probe.lang, probe.source)
assert issues, f"{probe.id}: expected screening to reject the snippet"
assert any(probe.screen_needle in i for i in issues), (
f"{probe.id}: needle {probe.screen_needle!r} not in {issues}"
)
# Screening is the whole point — do not execute a rejected snippet.
# (A future change that runs despite issues would be a security regression.)
def _assert_run_result(probe: Probe, result: dict) -> None:
assert result.get("ok") is probe.expect_ok, (
f"{probe.id}: ok={result.get('ok')} expected {probe.expect_ok}; full={result}"
)
if probe.expect_stage is not None:
assert result.get("stage") == probe.expect_stage, result
if not probe.expect_ok:
# Failure path: still check optional stderr/stdout breadcrumbs.
for needle in probe.expect_stderr_contains:
assert needle in (result.get("stderr") or ""), result
for needle in probe.expect_stdout_contains:
assert needle in (result.get("stdout") or ""), result
return
stdout = (result.get("stdout") or "").strip()
stderr = (result.get("stderr") or "")
if probe.expect_stdout is not None:
assert stdout == probe.expect_stdout, (
f"{probe.id}: stdout {stdout!r} != {probe.expect_stdout!r}"
)
for needle in probe.expect_stdout_contains:
assert needle in stdout, result
for needle in probe.expect_stderr_contains:
assert needle in stderr, result
if probe.expect_exit is not None:
assert result.get("exit_code") == probe.expect_exit, result
else:
# Explicit "don't care" still requires that a run happened.
assert "exit_code" in result, result
def _assert_tool_envelope(probe: Probe, direct: dict) -> None:
out = json.loads(asyncio.run(tools.dispatch("run_snippet", {
"lang": probe.lang,
"source": probe.source,
"stdin": probe.stdin,
})))
assert out.get("ok") is probe.expect_ok, out
assert "fence" in out and out["fence"].startswith("```nexus-run\n"), out
body = out["fence"].split("\n", 1)[1].rsplit("\n", 1)[0]
envelope = json.loads(body)
assert envelope.get("lang") == code_run.resolve_lang(probe.lang)
if probe.expect_stdout is not None:
assert (envelope.get("stdout") or "").strip() == probe.expect_stdout
# Direct driver and tool path must agree on exit for successful runs.
if probe.expect_ok and probe.expect_exit is not None:
assert out.get("exit_code") == direct.get("exit_code") == probe.expect_exit
# ---------------------------------------------------------------------------
# One-shot inventory (useful when running the file directly)
# ---------------------------------------------------------------------------
def test_probe_inventory_lists_toolchain_readiness():
"""Not an assertion about readiness — just fails if the inventory shape
breaks, so `pytest -k inventory -s` is a quick host capability dump."""
rows = []
for name, entry in code_run.RUN_LANGS.items():
tool = entry["tool"]()
n = sum(1 for p in PROBES if code_run.resolve_lang(p.lang) == name)
rows.append({"lang": name, "ready": bool(tool), "probes": n, "tool": tool})
assert rows and all(r["probes"] >= 1 for r in rows)
# Printed only under -s; kept as a structured object for debuggability.
print("snippet-probe inventory:", json.dumps(rows, indent=2))
+103 -170
View File
@@ -10,7 +10,6 @@ import asyncio
import json import json
from synapse import tools from synapse import tools
from synapse import code_run
from synapse.chat import _run_tool_loop from synapse.chat import _run_tool_loop
@@ -94,6 +93,52 @@ def test_ask_policy_skips_on_deny(monkeypatch):
assert any(m["role"] == "tool" and "declined" in m["content"] for m in messages) assert any(m["role"] == "tool" and "declined" in m["content"] for m in messages)
class _ContentJsonActionManager:
"""Small-model shape: dumps the action call into `content`, no native
`tool_calls` field the lower-confidence path the "allow" bypass must
not trust."""
def __init__(self):
self.n = 0
async def chat(self, **_):
self.n += 1
if self.n == 1:
return {"role": "assistant",
"content": json.dumps({"name": "remember", "arguments": {"text": "x"}})}
return {"role": "assistant", "content": "done"}
def test_content_json_action_call_asks_even_under_allow_policy(monkeypatch):
"""A call recovered by guessing at `content` is weaker evidence than the
API's own structured tool_calls field — a model can land on JSON shaped
like a call while only meaning to describe one. It must still go through
approval even when action_tool_policy is "allow", the default that lets a
*native* tool_calls field run unattended."""
from synapse import chat as chatmod
async def fake_dispatch(name, args):
return "saved-ok"
monkeypatch.setattr(tools, "dispatch", fake_dispatch)
async def run():
messages = [{"role": "user", "content": "remember x"}]
schemas = tools.schemas_for(["remember"])
gen = chatmod._run_tool_loop(_ContentJsonActionManager(), messages, "m", schemas, None, None,
conversation_id="conv", policy="allow")
statuses = []
async for s in gen:
statuses.append(s)
if s.startswith("__approve__"):
w = chatmod.pending_approvals["conv"]
w["decisions"] = {"remember": True}
w["event"].set()
return statuses
statuses = asyncio.run(run())
assert any(s.startswith("__approve__") for s in statuses)
assert "__status__remember" in statuses
def test_action_tools_gated_by_consent(): def test_action_tools_gated_by_consent():
allow = ["search_memory", "web_search", "remember", "fetch_url"] allow = ["search_memory", "web_search", "remember", "fetch_url"]
on = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=True)] on = [s["function"]["name"] for s in tools.schemas_for(allow, allow_actions=True)]
@@ -154,6 +199,63 @@ def test_tool_loop_degrades_when_model_returns_no_dict():
assert messages == before # untouched -> falls back to a plain stream assert messages == before # untouched -> falls back to a plain stream
def test_read_file_stays_inside_the_repo():
"""The repo-file tools are the fix for the model inventing paths like
`nexus/nlp.py`; the deny-list is what keeps them from reading secrets."""
import json
def read(p):
return asyncio.run(tools._read_file(p))
assert "escapes" in read("../../etc/passwd")
# a leading slash is treated as repo-relative, so it lands nowhere real
assert "root:" not in read("/etc/passwd")
assert "required" in read("")
# private data and heavy trees are refused even though they're in-repo
for denied in ("synapse/memory/memory.db", ".git/config", "Promethean/pyvenv.cfg"):
assert "not readable" in read(denied), denied
assert "does not exist" in read("nexus/nlp.py")
assert "PROJECT_ROOT" in json.loads(read("synapse/nexus_config.py"))["content"]
def test_list_files_globs_the_repo_without_leaking_denied_paths():
import json
hits = json.loads(asyncio.run(tools._list_files("synapse/**/*")))
assert "synapse/main.py" in hits
assert not [h for h in hits if h.endswith(".db") or "__pycache__" in h], hits
def test_routed_reference_playbook_contributes_its_tools(tmp_path, monkeypatch):
"""A reference playbook routed into the prompt must bring its tools with it.
Without this the model reads instructions like "you can read the codebase"
while being advertised zero tools and narrates tool calls it never made."""
from synapse.main import _route_playbooks
from synapse.playbooks.store import PlaybookFileStore, PlaybookItem
# Own store, not data/playbooks: the live set is the operator's, and a
# published clone ships different playbooks - this asserted on data that
# travels with one machine.
store = PlaybookFileStore(tmp_path)
store.add_playbook(PlaybookItem(id="main", title="Main", goal="g",
instructions="i", order=0))
store.add_playbook(PlaybookItem(id="dev", title="NexusOS Developer", goal="g",
instructions="You can read the codebase.", order=1,
tags=["synapse", "backend"],
tools=["read_file", "list_files"]))
monkeypatch.setattr("synapse.playbook_manager.playbook_store", store)
import synapse.playbook_manager as pm
routed = _route_playbooks("why is the memory endpoint in synapse returning 500", pm.get_context_playbooks())
names = {pb.title for pb in routed}
assert "NexusOS Developer" in names, names
granted = {t for pb in routed for t in (pb.tools or [])}
assert {"read_file", "list_files"} <= granted, granted
# none of them are action tools, so they survive the default policy (off)
assert tools.schemas_for(sorted(granted), allow_actions=False)
def test_standing_schemas_include_render_preview(): def test_standing_schemas_include_render_preview():
names = [s["function"]["name"] for s in tools.standing_schemas()] names = [s["function"]["name"] for s in tools.standing_schemas()]
assert names == ["render_preview"] assert names == ["render_preview"]
@@ -288,118 +390,6 @@ def test_preview_langs_match_the_frontend_registry():
) )
_FRONTEND_RUN_REGISTRY = ("interface", "web", "src", "preview", "run-langs.js")
def _frontend_registry_keys(parts: tuple, name: str) -> list:
"""Top-level keys of a `export const <name> = {...}` object literal."""
import re
from pathlib import Path
src = Path(__file__).resolve().parents[1].joinpath(*parts)
text = src.read_text(encoding="utf-8")
body = re.search(rf"^export const {name} = \{{\n(.*?)^\}};", text, re.S | re.M)
assert body, f"could not find a {name} object literal in {src}"
return re.findall(r"^ (\w+):", body.group(1), re.M)
def test_run_langs_match_the_frontend_registry():
"""Same failure mode as the preview registries, one track over: a language
the backend can run but the frontend does not know about renders as raw JSON
in the chat, and one the frontend labels but the backend refuses produces a
tool error the user never asked for. Nothing at runtime couples them."""
assert _frontend_registry_keys(_FRONTEND_RUN_REGISTRY, "RUN_LANGS") == list(
code_run.RUN_LANGS
), (
"RUN_LANGS differs between synapse/code_run.py and "
"interface/web/src/preview/run-langs.js - add the language to both."
)
def test_run_fence_tag_matches_the_frontend():
"""The tag is the handshake: run_snippet emits it, Markdown.jsx dispatches on
it. A mismatch shows the JSON envelope to the user as a code block."""
import re
from pathlib import Path
src = Path(__file__).resolve().parents[1].joinpath(*_FRONTEND_RUN_REGISTRY)
m = re.search(r'export const RUN_FENCE_LANG = "([^"]+)"', src.read_text(encoding="utf-8"))
assert m and m.group(1) == tools._RUN_FENCE_LANG
def test_run_lang_enum_is_derived_not_repeated():
schema, _ = tools.REGISTRY["run_snippet"]
enum = schema["function"]["parameters"]["properties"]["lang"]["enum"]
assert enum == list(code_run.RUN_LANGS)
def test_run_snippet_is_an_action_tool():
"""It executes code on the host, so action_tool_policy has to gate it.
Slipping into STANDING_TOOLS (where render_preview lives, ungated) would make
every 'run this' a subprocess with no consent step anywhere."""
assert tools.is_action("run_snippet")
assert "run_snippet" not in tools.STANDING_TOOLS
assert "run_snippet" in tools.CUED_ACTION_TOOLS
# ...and withholding actions has to actually withhold it.
assert tools.schemas_for(["run_snippet"], allow_actions=False) == []
def test_wants_code_run_needs_a_verb_not_a_language():
"""A language name must not arm the run track. `python` in _RUN_HINTS would
drag every mention of the language into a non-stream tool round - the exact
'stuck thinking' problem that kept render_preview off by default."""
assert tools.wants_code_run("run this and show me the output")
assert tools.wants_code_run("does this compile?")
assert not tools.wants_code_run("write me a python function that sorts a list")
assert not tools.wants_code_run("explain how rust ownership works")
def test_run_snippet_rejects_a_preview_language():
out = json.loads(asyncio.run(tools.dispatch("run_snippet", {
"lang": "html", "source": "<p>hello there</p>",
})))
assert out["ok"] is False
assert "render_preview" in out["error"]
def test_run_snippet_fence_survives_backticks_in_the_source():
"""A backtick in the source would close the ```nexus-run fence early, and the
rest of the envelope would spill into the chat as prose."""
out = json.loads(asyncio.run(tools.dispatch("run_snippet", {
"lang": "python", "source": "s = '``` still inside'\nprint(len(s))",
})))
assert out["ok"] is True, out
body = out["fence"].split("\n", 1)[1].rsplit("\n", 1)[0]
assert "```" not in body
assert json.loads(body)["source"].startswith("s = '```")
def test_run_snippet_reports_a_program_that_fails():
"""A non-zero exit is a successful run, not a tool failure: its stderr is the
answer. Reporting ok=False here would send the model into a retry loop over
a program that did exactly what it was asked to demonstrate."""
out = json.loads(asyncio.run(tools.dispatch("run_snippet", {
"lang": "python",
"source": "import sys\nprint('before')\nsys.exit(2)",
})))
assert out["ok"] is True
assert out["exit_code"] == 2
assert "before" in out["stdout"]
def test_run_snippet_escalates_a_scaffold_on_retry():
"""First host-code reject is issues-only; second gets an entry-point pattern.
_attempt is supplied by the tool loop."""
first = json.loads(asyncio.run(tools.dispatch("run_snippet", {
"lang": "python", "source": "import socket\nprint(1)", "_attempt": 0,
})))
assert first["ok"] is False
assert "scaffold" not in first
second = json.loads(asyncio.run(tools.dispatch("run_snippet", {
"lang": "python", "source": "import socket\nprint(1)", "_attempt": 1,
})))
assert second["ok"] is False
assert "scaffold" in second and "print" in second["scaffold"]
def test_preview_lang_enum_is_derived_not_repeated(): def test_preview_lang_enum_is_derived_not_repeated():
schema, _ = tools.REGISTRY["render_preview"] schema, _ = tools.REGISTRY["render_preview"]
enum = schema["function"]["parameters"]["properties"]["lang"]["enum"] enum = schema["function"]["parameters"]["properties"]["lang"]["enum"]
@@ -644,60 +634,3 @@ async def _drain_with_messages(manager, model, schemas, user="draw a circle"):
_run_tool_loop(manager, messages, model, schemas, None, None) _run_tool_loop(manager, messages, model, schemas, None, None)
) )
return statuses, messages return statuses, messages
def test_read_file_stays_inside_the_repo():
"""The repo-file tools are the fix for the model inventing paths like
`nexus/nlp.py`; the deny-list is what keeps them from reading secrets."""
import json
def read(p):
return asyncio.run(tools._read_file(p))
assert "escapes" in read("../../etc/passwd")
# a leading slash is treated as repo-relative, so it lands nowhere real
assert "root:" not in read("/etc/passwd")
assert "required" in read("")
# private data and heavy trees are refused even though they're in-repo
for denied in ("synapse/memory/memory.db", ".git/config", "Promethean/pyvenv.cfg"):
assert "not readable" in read(denied), denied
assert "does not exist" in read("nexus/nlp.py")
assert "PROJECT_ROOT" in json.loads(read("synapse/nexus_config.py"))["content"]
def test_list_files_globs_the_repo_without_leaking_denied_paths():
import json
hits = json.loads(asyncio.run(tools._list_files("synapse/**/*")))
assert "synapse/main.py" in hits
assert not [h for h in hits if h.endswith(".db") or "__pycache__" in h], hits
def test_routed_reference_playbook_contributes_its_tools(tmp_path, monkeypatch):
"""A reference playbook routed into the prompt must bring its tools with it.
Without this the model reads instructions like "you can read the codebase"
while being advertised zero tools and narrates tool calls it never made."""
from synapse.main import _route_playbooks
from synapse.playbooks.store import PlaybookFileStore, PlaybookItem
# Own store, not data/playbooks: the live set is the operator's, and a
# published clone ships different playbooks - this asserted on data that
# travels with one machine.
store = PlaybookFileStore(tmp_path)
store.add_playbook(PlaybookItem(id="main", title="Main", goal="g",
instructions="i", order=0))
store.add_playbook(PlaybookItem(id="dev", title="NexusOS Developer", goal="g",
instructions="You can read the codebase.", order=1,
tags=["synapse", "backend"],
tools=["read_file", "list_files"]))
monkeypatch.setattr("synapse.playbook_manager.playbook_store", store)
import synapse.playbook_manager as pm
routed = _route_playbooks("why is the memory endpoint in synapse returning 500", pm.get_context_playbooks())
names = {pb.title for pb in routed}
assert "NexusOS Developer" in names, names
granted = {t for pb in routed for t in (pb.tools or [])}
assert {"read_file", "list_files"} <= granted, granted
# none of them are action tools, so they survive the default policy (off)
assert tools.schemas_for(sorted(granted), allow_actions=False)
+67 -85
View File
@@ -98,7 +98,7 @@ def test_finish_stream_markup_does_not_wedge_busy():
async def _run(): async def _run():
async with app.run_test(): async with app.run_test():
app._busy = True app._busy = True
app._finish_stream("see [/] and arr[i]") app._finish_stream("see [/] and arr[i]", app.history)
assert app._busy is False assert app._busy is False
assert app.history[-1]["content"] == "see [/] and arr[i]" assert app.history[-1]["content"] == "see [/] and arr[i]"
@@ -115,7 +115,7 @@ def test_stream_error_remains_visible_after_finish():
async with app.run_test(): async with app.run_test():
app._busy = True app._busy = True
app._show_error("[red]Backend not reachable[/]") app._show_error("[red]Backend not reachable[/]")
app._finish_stream("") app._finish_stream("", app.history)
log = app.query_one("#log") log = app.query_one("#log")
assert any("Backend not reachable" in line.text for line in log.lines) assert any("Backend not reachable" in line.text for line in log.lines)
assert app._busy is False assert app._busy is False
@@ -225,6 +225,71 @@ def test_inflight_tool_denial_uses_original_conversation_id(monkeypatch):
asyncio.run(_run()) asyncio.run(_run())
def test_new_mid_stream_does_not_leak_reply_into_next_conversation(monkeypatch):
"""A stream still in flight when /new resets self.history must keep
appending its reply to the conversation it was actually answering, not
whatever self.history now points at - otherwise the old reply's text
silently rides along in the next request's history payload."""
pytest.importorskip("textual")
import nexusos_cli.tui_app as tui_app
stream_started = threading.Event()
release_stream = threading.Event()
class _StreamResponse:
status_code = 200
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
async def aiter_lines(self):
stream_started.set()
await asyncio.to_thread(release_stream.wait, 2)
yield 'data: "the old reply"'
yield ""
yield "event: done"
yield "data: {}"
class _StreamClient:
def __init__(self, **kwargs):
pass
async def __aenter__(self):
return self
async def __aexit__(self, *args):
return None
def stream(self, *args, **kwargs):
return _StreamResponse()
monkeypatch.setattr(tui_app.httpx, "AsyncClient", _StreamClient)
app = tui_app.NexusTUI.build_app(api_url="http://127.0.0.1:9")
async def _run():
async with app.run_test():
app._start_chat("first question")
assert await asyncio.to_thread(stream_started.wait, 2)
old_history = app.history
app._handle_slash("/new")
assert app.history is not old_history
release_stream.set()
for _ in range(200):
if not app._busy:
break
await asyncio.sleep(0.01)
assert app._busy is False
# The reply landed on the abandoned conversation's own list...
assert any(m["content"] == "the old reply" for m in old_history)
# ...never on the fresh one /new started.
assert app.history == []
asyncio.run(_run())
def test_interrupt_cancels_silent_stream_and_accepts_next_message(monkeypatch): def test_interrupt_cancels_silent_stream_and_accepts_next_message(monkeypatch):
pytest.importorskip("textual") pytest.importorskip("textual")
import nexusos_cli.tui_app as tui_app import nexusos_cli.tui_app as tui_app
@@ -301,86 +366,3 @@ def test_interrupt_cancels_silent_stream_and_accepts_next_message(monkeypatch):
def test_escape_round_trip_helper(): def test_escape_round_trip_helper():
assert "[" in _escape("x[y]") or "\\[" in _escape("x[y]") assert "[" in _escape("x[y]") or "\\[" in _escape("x[y]")
def test_slash_tool_call_shape_forwards_to_start_chat(monkeypatch):
"""/tool_name(arg=val) isn't a local meta-command — it must reach the
backend (synapse/slash_commands.py + chat_stream_endpoint dispatch it),
not fall into the generic 'unknown command' branch."""
pytest.importorskip("textual")
from nexusos_cli.tui_app import NexusTUI
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
calls: list[str] = []
monkeypatch.setattr(app, "_start_chat", lambda text: calls.append(text))
async def _run():
async with app.run_test():
text = '/curry_call_function(name="double", version=1, args={"x": 21})'
app._handle_slash(text)
assert calls == [text]
log = app.query_one("#log")
assert not any("unknown command" in line.text for line in log.lines)
asyncio.run(_run())
def test_slash_malformed_tool_call_still_forwards_for_the_backend_error(monkeypatch):
"""Even a malformed /tool(...) is forwarded rather than swallowed locally
the backend's parser gives a clearer, more specific error than the
TUI's generic 'unknown command' would."""
pytest.importorskip("textual")
from nexusos_cli.tui_app import NexusTUI
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
calls: list[str] = []
monkeypatch.setattr(app, "_start_chat", lambda text: calls.append(text))
async def _run():
async with app.run_test():
text = "/curry_call_function(x=__import__('os'))"
app._handle_slash(text)
assert calls == [text]
asyncio.run(_run())
def test_slash_local_meta_commands_still_handled_locally(monkeypatch):
"""A known local command must still be handled in-TUI, never forwarded —
the new tool-call passthrough is strictly the fallback branch."""
pytest.importorskip("textual")
from nexusos_cli.tui_app import NexusTUI
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
calls: list[str] = []
monkeypatch.setattr(app, "_start_chat", lambda text: calls.append(text))
async def _run():
async with app.run_test():
app._handle_slash("/help")
assert calls == []
log = app.query_one("#log")
assert any("this list" in line.text for line in log.lines)
asyncio.run(_run())
def test_slash_unknown_bare_command_still_rejected(monkeypatch):
"""A genuinely unknown command (no parens, not a local command) keeps the
existing 'unknown command' behavior rather than silently forwarding
anything that starts with /."""
pytest.importorskip("textual")
from nexusos_cli.tui_app import NexusTUI
app = NexusTUI.build_app(api_url="http://127.0.0.1:9")
calls: list[str] = []
monkeypatch.setattr(app, "_start_chat", lambda text: calls.append(text))
async def _run():
async with app.run_test():
app._handle_slash("/frobnicate")
assert calls == []
log = app.query_one("#log")
assert any("unknown command" in line.text for line in log.lines)
asyncio.run(_run())