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,7 +10,7 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "node --test src/preview/jsx-transform.test.js",
|
||||
"test": "node --test \"src/preview/*.test.js\"",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -6,6 +6,9 @@ import { useEffect, useRef, useState } from "react";
|
||||
// auto-executing bare script isn't this feature's job (see RenderBlock's doc
|
||||
// comment for the sandboxing model).
|
||||
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";
|
||||
|
||||
// Parse content into an array of {type, value, lang, streaming} blocks.
|
||||
// Handles:
|
||||
@@ -29,9 +32,11 @@ function parseBlocks(content) {
|
||||
let j = fenceStart + 3;
|
||||
let lang = "";
|
||||
|
||||
// Language specifier is valid only when word-chars are followed by a newline.
|
||||
// Language specifier is valid only when tag-chars are followed by a newline.
|
||||
// If there's no newline (e.g. ```pythonprint(...)) treat everything as code.
|
||||
const langMatch = content.slice(j).match(/^(\w+)(\r?\n)/);
|
||||
// Hyphens count: `nexus-run` is a tag this file dispatches on, and real
|
||||
// languages spell themselves that way too (objective-c, c-sharp).
|
||||
const langMatch = content.slice(j).match(/^([\w-]+)(\r?\n)/);
|
||||
if (langMatch) {
|
||||
lang = langMatch[1];
|
||||
j += langMatch[0].length;
|
||||
@@ -61,6 +66,12 @@ export function Markdown({ content }) {
|
||||
{blocks.map((block, i) => {
|
||||
if (block.type !== "code") return <TextBlock key={i} text={block.value} />;
|
||||
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} />;
|
||||
}
|
||||
return RENDERABLE_LANGS.has(lang)
|
||||
? <RenderBlock key={i} lang={lang} value={block.value} streaming={block.streaming} />
|
||||
: <CodeBlock key={i} lang={block.lang} value={block.value} streaming={block.streaming} />;
|
||||
@@ -126,6 +137,121 @@ 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>
|
||||
);
|
||||
}
|
||||
|
||||
// Content-Security-Policy for the rendered preview. Together with the iframe's
|
||||
// `sandbox` attribute below, this is the entire trust boundary for model-
|
||||
// authored HTML/SVG, so it stays conservative rather than convenient:
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* 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);
|
||||
});
|
||||
Reference in New Issue
Block a user