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:
Athena Kaminsky
2026-08-20 14:30:03 -05:00
co-authored by Claude Opus 5
parent 425184a30b
commit affba1805c
14 changed files with 2016 additions and 14 deletions
+113
View File
@@ -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"]