forked from enderofwings/NexusOS
feat(chat): add run_snippet, an execution track beside the render track
render_preview validates markup and hands it to the browser, which renders it in an opaque-origin sandboxed iframe. Nothing executes server-side. That model fits HTML/SVG/JSX and cannot fit C, Rust or Erlang, which need a real toolchain - so those get a second tool instead of a widened first one. The split is the feature: the model picks a track by picking a tool, rather than picking a `lang` value from an enum where half the entries run server-side and half do not. synapse/code_run.py compiles and runs one file in a throwaway directory and returns a ```nexus-run fence carrying the source and its captured output together, so a model cannot paste output without the code that produced it. Backticks in the source are re-encoded as ` - still valid JSON, and it cannot close the fence early. It is not a sandbox, and the module docstring says so up front. What it gives is containment by layers: consent (an action tool, gated by action_tool_policy, per-call Approve/Deny on "ask"), static screening, a scrubbed environment in a temp dir, wall-clock and POSIX rlimits, and a network namespace on Linux where unprivileged userns are available. Screening is a tripwire against a model reaching for `requests` out of habit, not a boundary against an adversary; layers 1 and 3-5 are the load-bearing ones. Backend RUN_LANGS and frontend run-langs.js are separate registries because the two sides need different things - one executes, one labels - and neither should depend on the other at runtime. tests/test_tools.py asserts the key sets and the fence tag stay equal, so drift fails the gate instead of rendering a run result under the wrong language. tests/snippet_probes/ is a data catalog rather than inlined cases, so adding a language is a data change and the meta-tests can assert every RUN_LANGS key has both a smoke probe and a screening probe. Probes skip cleanly on hosts without the toolchain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
425184a30b
commit
affba1805c
@@ -10,6 +10,7 @@ import asyncio
|
||||
import json
|
||||
|
||||
from synapse import tools
|
||||
from synapse import code_run
|
||||
from synapse.chat import _run_tool_loop
|
||||
|
||||
|
||||
@@ -347,6 +348,118 @@ 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():
|
||||
"""Same discipline as render_preview: first reject is issues-only; second
|
||||
gets a 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():
|
||||
schema, _ = tools.REGISTRY["render_preview"]
|
||||
enum = schema["function"]["parameters"]["properties"]["lang"]["enum"]
|
||||
|
||||
Reference in New Issue
Block a user