Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions crates/biorouter-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,9 @@ serial_test = { workspace = true }
# The workspace's process-wide environment lock, so env-mutating tests here
# exclude every other one rather than only the `#[serial]` ones.
env-lock = { workspace = true }
# A WebSocket client, so `commands::web`'s tests can drive the page's socket the
# way the page does. The version axum's `ws` feature already locks.
tokio-tungstenite = "0.28.0"
# Issue #56 DR-20 / Task 55. `biorouter session declassify <id>` raises the OS
# authentication prompt, so its TESTS would type a real password on every run
# without a stand-in.
Expand Down
24 changes: 23 additions & 1 deletion crates/biorouter-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,24 @@ fn parse_key_val(s: &str) -> Result<(String, String), String> {
}
}

/// ⚠ **An empty token is not a token, and `--auth-token ""` used to be accepted
/// as one.** Passing it made `validate_network_auth` see `Some(_)` and let
/// `--host 0.0.0.0` through, while `commands::web`'s middleware would then admit
/// anyone who sent `Authorization: Bearer ` with nothing after it — so the one
/// check whose entire job is to insist on protection was satisfied by its
/// absence. Refused here, at parse time, so the mistake cannot reach a bind; a
/// whitespace-only value is refused for the same reason.
pub(crate) fn parse_auth_token(s: &str) -> Result<String, String> {
if s.trim().is_empty() {
return Err(
"an empty --auth-token is not a token; omit the flag to run without one (loopback \
binds only), or pass a real secret"
.to_string(),
);
}
Ok(s.to_string())
}

#[derive(Subcommand)]
enum SessionCommand {
#[command(about = "List all available sessions")]
Expand Down Expand Up @@ -1672,7 +1690,11 @@ enum Command {
open: bool,

/// Authentication token for both Basic Auth (password) and Bearer token
#[arg(long, help = "Authentication token to secure the web interface")]
#[arg(
long,
value_parser = parse_auth_token,
help = "Authentication token to secure the web interface"
)]
auth_token: Option<String>,

/// Allow running without authentication when exposed on the network (unsafe)
Expand Down
1,184 changes: 1,098 additions & 86 deletions crates/biorouter-cli/src/commands/web.rs

Large diffs are not rendered by default.

17 changes: 12 additions & 5 deletions crates/biorouter-cli/static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,19 @@ <h1 id="session-title">biorouter chat</h1>
<h2>Welcome to biorouter!</h2>
<p>I'm your AI assistant. How can I help you today?</p>

<!--
No `onclick` here, deliberately. The page is served under
`script-src 'self'` (see CONTENT_SECURITY_POLICY in
commands/web.rs), which refuses inline event handlers —
the same rule that makes an injected `onerror=` inert.
script.js binds these by their `data-suggestion`.
-->
<div class="suggestion-pills">
<div class="suggestion-pill" onclick="sendSuggestion('What can you do?')">What can you do?</div>
<div class="suggestion-pill" onclick="sendSuggestion('Demo writing and reading files')">Demo writing and reading files</div>
<div class="suggestion-pill" onclick="sendSuggestion('Make a snake game in a new folder')">Make a snake game in a new folder</div>
<div class="suggestion-pill" onclick="sendSuggestion('List files in my current directory')">List files in my current directory</div>
<div class="suggestion-pill" onclick="sendSuggestion('Take a screenshot and summarize')">Take a screenshot and summarize</div>
<div class="suggestion-pill" data-suggestion="What can you do?">What can you do?</div>
<div class="suggestion-pill" data-suggestion="Demo writing and reading files">Demo writing and reading files</div>
<div class="suggestion-pill" data-suggestion="Make a snake game in a new folder">Make a snake game in a new folder</div>
<div class="suggestion-pill" data-suggestion="List files in my current directory">List files in my current directory</div>
<div class="suggestion-pill" data-suggestion="Take a screenshot and summarize">Take a screenshot and summarize</div>
</div>
</div>
</div>
Expand Down
95 changes: 46 additions & 49 deletions crates/biorouter-cli/static/script.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,28 @@ const connectionStatus = document.getElementById('connection-status');
// Track if we're currently processing
let isProcessing = false;

// The values the server writes into this page, read from HTML attributes rather
// than from globals an inline <script> assigned.
//
// The session name is whatever the URL path said, and script context is not a
// place to put a stranger's bytes: a `'` used to end the string literal and a
// `</script>` used to end the element. See `serve_session` in
// crates/biorouter-cli/src/commands/web.rs for the full account. The HTML parser
// decodes entities inside an attribute, so what `dataset` hands back here is the
// value exactly as it arrived, with no byte able to escape the attribute.
function bootValue(name) {
const boot = document.getElementById('biorouter-boot');
return (boot && boot.dataset[name]) || '';
}

// Get session ID - either from URL parameter, injected session name, or generate new one
function getSessionId() {
// Check if session name was injected by server (for /session/:name routes)
if (window.BIOROUTER_SESSION_NAME) {
return window.BIOROUTER_SESSION_NAME;
// Check if a session name was written into the page (for /session/:name routes)
const injected = bootValue('sessionName');
if (injected) {
return injected;
}

// Check URL parameters
const urlParams = new URLSearchParams(window.location.search);
const sessionParam = urlParams.get('session') || urlParams.get('name');
Expand Down Expand Up @@ -138,7 +153,7 @@ function removeThinkingIndicator() {
// Connect to WebSocket
function connectWebSocket() {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const token = window.BIOROUTER_WS_TOKEN || '';
const token = bootValue('wsToken');
const wsUrl = `${protocol}//${window.location.host}/ws?token=${encodeURIComponent(token)}`;

socket = new WebSocket(wsUrl);
Expand All @@ -149,9 +164,6 @@ function connectWebSocket() {
connectionStatus.textContent = 'Connected';
connectionStatus.className = 'status connected';
sendButton.disabled = false;

// Check if this session exists and load history if it does
loadSessionIfExists();
};

socket.onmessage = (event) => {
Expand Down Expand Up @@ -264,24 +276,32 @@ function handleToolRequest(data) {

const headerDiv = document.createElement('div');
headerDiv.className = 'tool-header';
headerDiv.innerHTML = `🔧 <strong>${data.tool_name}</strong>`;

// Every one of these interpolations is model-controlled: a tool name and a
// tool call's arguments are chosen by whatever the agent decided to run, and
// a prompt injection in a file or a web page reaches them. `escapeHtml` is
// adequate here and only here because every hole below sits in element
// content, never inside an attribute value — it does not escape `"`.
headerDiv.innerHTML = `🔧 <strong>${escapeHtml(data.tool_name)}</strong>`;

const contentDiv = document.createElement('div');
contentDiv.className = 'tool-content';

// Format the arguments
if (data.tool_name === 'developer__shell' && data.arguments.command) {
contentDiv.innerHTML = `<pre><code>${escapeHtml(data.arguments.command)}</code></pre>`;
} else if (data.tool_name === 'developer__text_editor') {
const action = data.arguments.command || 'unknown';
const path = data.arguments.path || 'unknown';
contentDiv.innerHTML = `<div class="tool-param"><strong>action:</strong> ${action}</div>`;
contentDiv.innerHTML = `<div class="tool-param"><strong>action:</strong> ${escapeHtml(action)}</div>`;
contentDiv.innerHTML += `<div class="tool-param"><strong>path:</strong> ${escapeHtml(path)}</div>`;
if (data.arguments.file_text) {
contentDiv.innerHTML += `<div class="tool-param"><strong>content:</strong> <pre><code>${escapeHtml(data.arguments.file_text.substring(0, 200))}${data.arguments.file_text.length > 200 ? '...' : ''}</code></pre></div>`;
}
} else {
contentDiv.innerHTML = `<pre><code>${JSON.stringify(data.arguments, null, 2)}</code></pre>`;
// `JSON.stringify` escapes for JSON, which says nothing about HTML: it
// leaves `<` and `/` alone, so an argument holding `<img src=x
// onerror=…>` arrived here as live markup.
contentDiv.innerHTML = `<pre><code>${escapeHtml(JSON.stringify(data.arguments, null, 2))}</code></pre>`;
}

toolDiv.appendChild(headerDiv);
Expand Down Expand Up @@ -346,8 +366,8 @@ function handleToolConfirmation(data) {
confirmDiv.innerHTML = `
<div class="tool-confirm-header">⚠️ Tool Confirmation Required</div>
<div class="tool-confirm-content">
<strong>${data.tool_name}</strong> wants to execute with:
<pre><code>${JSON.stringify(data.arguments, null, 2)}</code></pre>
<strong>${escapeHtml(data.tool_name)}</strong> wants to execute with:
<pre><code>${escapeHtml(JSON.stringify(data.arguments, null, 2))}</code></pre>
</div>
<div class="tool-confirm-note">Auto-approved in web mode (UI coming soon)</div>
`;
Expand Down Expand Up @@ -456,43 +476,20 @@ function sendSuggestion(text) {
sendMessage();
}

// Load session history if the session exists (like --resume in CLI)
async function loadSessionIfExists() {
try {
const response = await fetch(`/api/sessions/${sessionId}`);
if (response.ok) {
const sessionData = await response.json();
if (sessionData.messages && sessionData.messages.length > 0) {
// Remove welcome message since we're resuming
const welcomeMessage = messagesContainer.querySelector('.welcome-message');
if (welcomeMessage) {
welcomeMessage.remove();
}

// Display session resumed message
const resumeDiv = document.createElement('div');
resumeDiv.className = 'message system-message';
resumeDiv.innerHTML = `<em>Session resumed: ${sessionData.messages.length} messages loaded</em>`;
messagesContainer.appendChild(resumeDiv);

// Update page title with session description if available
if (sessionData.metadata && sessionData.metadata.description) {
document.title = `biorouter chat - ${sessionData.metadata.description}`;
}

messagesContainer.scrollTop = messagesContainer.scrollHeight;
}
}
} catch (error) {
console.log('No existing session found or error loading:', error);
// This is fine - just means it's a new session
}
}


// Event listeners
sendButton.addEventListener('click', sendMessage);

// The welcome pills, bound here rather than through an `onclick` attribute in
// index.html: the page is served under `script-src 'self'`, which refuses inline
// handlers. Delegated from the container because the welcome block is removed
// once the first message is sent.
messagesContainer.addEventListener('click', (e) => {
const pill = e.target.closest('.suggestion-pill[data-suggestion]');
if (pill) {
sendSuggestion(pill.dataset.suggestion);
}
});

messageInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
Expand Down
33 changes: 26 additions & 7 deletions crates/biorouter/tests/privacy_guard_wiring.rs
Original file line number Diff line number Diff line change
Expand Up @@ -164,8 +164,18 @@ const REGISTRY: &[Guard] = &[
ident: "may_read",
defined_in: VISIBILITY,
decides: "READ ⇔ VIS: whether a caller of tier C may read a session classified T",
status: Status::WiredThrough("refuse_unless_readable"),
// It was `WiredThrough("refuse_unless_readable")` until `biorouter web` became
// its first caller outside this file; the in-file row below still holds.
status: Status::Wired,
sites: &[
Site {
file: "crates/biorouter-cli/src/commands/web.rs",
counts: c(1, 0, 1),
kind: SiteKind::Guard,
what: "`refuse_turn_unless_reachable`, the gate on `biorouter web`'s WebSocket: \
a message there runs a turn in whichever chat it names, so the page must \
be able to read that chat. Plus its import",
},
Site {
file: "crates/biorouter-mcp/src/memory/mod.rs",
counts: c(2, 0, 0),
Expand Down Expand Up @@ -238,12 +248,21 @@ const REGISTRY: &[Guard] = &[
spawned, read everything else — is retired: an agent may inject into any \
conversation, and the tier is the only boundary",
status: Status::Wired,
sites: &[Site {
file: "crates/biorouter/src/agents/workspace_extension.rs",
counts: c(1, 0, 0),
kind: SiteKind::Guard,
what: "the shared writable adapter used by send_prompt, set_tools and close",
}],
sites: &[
Site {
file: "crates/biorouter-cli/src/commands/web.rs",
counts: c(1, 0, 1),
kind: SiteKind::Guard,
what: "`refuse_turn_unless_reachable`, the write half: a `biorouter web` message \
is written into the chat it names. Plus its import",
},
Site {
file: "crates/biorouter/src/agents/workspace_extension.rs",
counts: c(1, 0, 0),
kind: SiteKind::Guard,
what: "the shared writable adapter used by send_prompt, set_tools and close",
},
],
},
Guard {
ident: "requires_first_crossing_approval",
Expand Down
4 changes: 3 additions & 1 deletion docs/cli/command-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -798,7 +798,9 @@ The printed URL carries an access token as `?t=<token>`, minted per launch and s

### web

> **Deprecated.** Use [`serve`](#serve) instead. `web` serves a minimal standalone chat page rather than the Biorouter interface, and its default port collides with `biorouterd`'s. It is kept for now and unchanged; new deployments should not use it.
> **Deprecated.** Use [`serve`](#serve) instead. `web` serves a minimal standalone chat page rather than the Biorouter interface, and its default port collides with `biorouterd`'s. It is kept for now; new deployments should not use it.
>
> Since 2026-09-11 it lists no chats and returns no transcripts, and it opens a private chat only if it started that chat itself while running a private model. Continue any other private chat in the desktop app. [SD-13](../deployment/serve-decisions.md#sd-13--biorouter-web-serves-no-transcripts-and-opens-no-private-chat-it-did-not-start) records why.

Start a new session in biorouter Web, a lightweight web-based interface launched via the CLI that mirrors the desktop app's chat experience.

Expand Down
2 changes: 1 addition & 1 deletion docs/deployment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ any deployment live in [configuration](../configuration/environment-variables.md
| [Headless Linux deployment](headless-linux.md) | Running `biorouter serve` as a long-lived service on a Linux host with no graphical desktop: the CLI-only packages, the systemd unit, migrating secrets onto the host, and network exposure. |
| [Reaching a private chat from a script](programmatic-session-access.md) | The `X-Caller-Provider` header: how a monitoring dashboard, a CI job or a shell script reads and follows a **private** conversation over the HTTP API, what the header is not (it is not authentication), and which routes honour it. |
| [How browser-served Biorouter is built](serve-architecture.md) | Developer-facing architecture: what the daemon does with a web directory, how a browser is authenticated, and what the retired front door was replaced by. |
| [Decisions behind `biorouter serve`](serve-decisions.md) | The nine decision records governing the serving path — why a browser session cannot change its model, why the bind defaults to loopback, why the standalone binary was retired, and why the launch token is reusable until the daemon stops. |
| [Decisions behind `biorouter serve`](serve-decisions.md) | The ten decision records governing the serving path — why a browser session cannot change its model, why the bind defaults to loopback, why the standalone binary was retired, why the launch token is reusable until the daemon stops, and which chats the deprecated `biorouter web` may still open. |

## Related documentation

Expand Down
Loading
Loading