diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml
index e64dfac65..dc39c6771 100644
--- a/.github/workflows/frontend.yml
+++ b/.github/workflows/frontend.yml
@@ -121,7 +121,7 @@ jobs:
run: npm ci
- name: Packaged dependency isolation regressions
- run: node --test scripts/verify-packaged-dependencies.test.cjs scripts/npm-command.test.cjs scripts/prepare-native-dependencies.test.cjs scripts/vite-optional-ws.test.mjs
+ run: node --test scripts/verify-packaged-dependencies.test.cjs scripts/npm-command.test.cjs scripts/prepare-native-dependencies.test.cjs scripts/vite-optional-ws.test.mjs scripts/fetch-esbuild.test.cjs
# Windows allocates a NEW, VISIBLE console for every console-subsystem
# child of a process that owns none — which the Electron main process is.
diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml
index 2e457f3a6..9da2c42e5 100644
--- a/.github/workflows/rust.yml
+++ b/.github/workflows/rust.yml
@@ -257,6 +257,39 @@ jobs:
sudo apt-get update
sudo apt-get install --yes libdbus-1-dev libxcb1-dev pkg-config
+ - uses: actions/setup-node@v4
+ with:
+ node-version: 24
+
+ - name: Windows launcher runtime digest (actual PowerShell template)
+ if: matrix.os == 'windows-latest'
+ timeout-minutes: 2
+ run: node scripts/agent-drafter/check-windows-runtime-digest.mjs
+
+ - name: Full export payload (generated launchers and authenticated daemon fixture)
+ timeout-minutes: 2
+ run: node scripts/agent-drafter/check-export-payload.mjs
+
+ - name: Stage pinned native app compiler
+ timeout-minutes: 2
+ shell: bash
+ run: |
+ node <<'NODE'
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const { execFileSync } = require('node:child_process');
+ const { fetchEsbuild, ESBUILD_VERSION } = require('./ui/desktop/scripts/fetch-esbuild.js');
+ const compiler = path.resolve(fetchEsbuild(process.platform, process.arch, {
+ binDir: path.join(process.env.RUNNER_TEMP, 'biorouter-test-tools'),
+ cacheDir: path.join(process.env.RUNNER_TEMP, 'biorouter-esbuild-cache'),
+ }));
+ const version = execFileSync(compiler, ['--version'], {
+ encoding: 'utf8', timeout: 5000, maxBuffer: 64,
+ }).trim();
+ if (version !== ESBUILD_VERSION) throw new Error('Native esbuild does not match the pinned release');
+ fs.appendFileSync(process.env.GITHUB_ENV, `BIOROUTER_ESBUILD_BIN=${compiler}\n`);
+ NODE
+
# Start narrow (lib + bins on every OS) so the matrix is green on arrival;
# widen as it proves stable. This selector leaves out `tests/`; those
# binaries run in their own step below, ubuntu only, with no network.
diff --git a/CLAUDE.md b/CLAUDE.md
index 6c0782c61..8d4832831 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1060,8 +1060,16 @@ per-app agent over `GET /apps//agent`. Full design in
need no region — the SDK always provides a `.br-dock` drawer.
- **Apps vendor their own `src/sdk.ts`**, so `manifest.sdk_hash` fingerprints the
SDK a bundle was built from. `build_app` refreshes the vendored copy and stamps
- it; `serve_index` rebuilds on drift. Otherwise an app built before a protocol
- addition silently ignores the new frames forever.
+ it; `serve_index` rebuilds on drift. The bundle also records the SDK's source
+ version, so an older daemon preserves a newer SDK across rebuilds. The desktop
+ ships pinned, integrity-checked esbuild beside the daemon; CLI packages stage
+ the same binary. Builds publish JavaScript and imported CSS together.
+- **Browser launch uses the daemon origin.** The desktop's shared-daemon proxy
+ requires its secret, so an external browser cannot use that proxy's address.
+ `POST /apps/{id}/launch` returns the daemon's own loopback URL and a one-time
+ access link; desktop launch and managed preview use that origin. Browser-open
+ failures are shown to the person. Build-chat buttons check session metadata
+ before opening a tab, and show a disabled reason when the chat is gone.
- **Export is directly runnable.** `export_app` writes `manifest.json` (without
it the daemon 404s the app), leaves the endpoint unset so the SDK derives it
from the page origin (the desktop app starts `biorouterd` on an *ephemeral*
@@ -1071,6 +1079,29 @@ per-app agent over `GET /apps//agent`. Full design in
binds loopback only. `.vault/` is excluded. The daemon's port env var is
**`BIOROUTER_PORT`**, not `BIOROUTER_SERVER__PORT` (only the secret key uses
the `__` form — `Settings` is a flat struct).
+- **Exports have one implementation:** `agent_drafter/export.rs`. The desktop
+ downloads the `POST /apps/{id}/export` zip, preserves executable modes, and
+ safely extracts it into the person's chosen folder. Launcher, Full and bundled
+ daemon options reach the exporter; Full filters shipped skills and privacy
+ gates knowledge payloads. Exported HTML stays raw for daemon assembly, binary
+ assets are preserved, and working files, secrets and build-chat ids are omitted.
+ Launchers honor `BIOROUTER_PATH_ROOT` for an isolated recipient store and retain
+ backups before replacing installed contents. Full launchers start or reuse the
+ authenticated daemon before importing knowledge archives through its existing
+ multipart API. Import receipts prevent duplicate imports on retries; only
+ matching app declarations are rebound to allocated knowledge ids. Payload
+ consent and archive privacy/owner floors remain enforced.
+- **App pages require a document navigation** on browser credentials in a secure
+ browser context (loopback or HTTPS). Plain HTTP on a network address refuses
+ app surfaces and the secret-bearing serve shell, because Fetch Metadata and
+ opener protections do not apply there. Forwarded HTTPS on a network host also
+ requires browser-controlled Fetch Metadata, so a spoofed proxy header alone
+ cannot bypass that refusal. Authored inline scripts and handlers run under a response-specific CSP;
+ runtime-injected handlers remain blocked. Figure frames have a separate policy
+ and are admitted by the desktop preview only as the same app's subframe.
+ Deleting an app retires its sockets and tokens; a replacement has a new
+ `app_uid` and a separate durable conversation. Per-app origins and private-app
+ classification remain the separately documented platform followups.
- **Tests:** `cargo test -p biorouter-mcp --lib agent_drafter::`,
`cargo test -p biorouter-mcp --test ui_example_apps`,
`cargo test -p biorouter-server --lib routes::apps`. Browser-level:
diff --git a/crates/biorouter-cli/src/commands/apps.rs b/crates/biorouter-cli/src/commands/apps.rs
index 74a86a370..fd5a56815 100644
--- a/crates/biorouter-cli/src/commands/apps.rs
+++ b/crates/biorouter-cli/src/commands/apps.rs
@@ -34,13 +34,21 @@
//! `apps open` must keep passing [`Supervision::Detached`]: tying its daemon to
//! this process would kill the daemon as `open` returned.
//!
-//! **Daemon management is deliberately minimal.** The CLI has no pre-existing
-//! biorouterd-supervision helper, so `open`/`serve` first health-check the
-//! configured port (`BIOROUTER_PORT`, default 3000) via the auth-exempt
-//! `GET /status`, reuse a daemon if one answers, and otherwise best-effort spawn
-//! the sibling `biorouterd agent`. If no `biorouterd` binary can be located the
-//! command fails with the exact command to run — an honest v1 over a fragile
-//! spawn.
+//! **The profile's shared daemon comes first.** When the desktop app or
+//! `biorouter crew` runs this profile's shared daemon, `open` and `serve` mint the
+//! link there, over its private socket, and send the browser to that daemon's own
+//! listener ([`shared_daemon_launch`]); nothing is started. They used to start a
+//! second full daemon beside it that never exited. A port the person names in
+//! `BIOROUTER_PORT` keeps its meaning: the shared daemon is used then only when
+//! it listens on that port ([`named_port`]).
+//!
+//! **Otherwise daemon management is deliberately minimal.** From the configured
+//! port (`BIOROUTER_PORT`, default 3000) up, `open`/`serve` health-check each port
+//! via the auth-exempt `GET /status`, taking as a Biorouter daemon only one that
+//! answers `ok` ([`port_state`]) and passing over a port anything else holds,
+//! reuse a daemon whose key they know, and otherwise best-effort spawn the sibling
+//! `biorouterd agent`. If no `biorouterd` binary can be located the command fails
+//! with the exact command to run — an honest v1 over a fragile spawn.
//!
//! **Opening an app takes the daemon's secret (W2-HRD-1).** A daemon serves an
//! app's page only to a browser holding that app's access cookie, set by a
@@ -276,6 +284,60 @@ pub(crate) async fn daemon_ok(host: &str, port: u16) -> bool {
}
}
+/// What answers on a loopback port, as far as `apps open` and `apps serve` care.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum PortState {
+ /// Nothing accepts a connection: a daemon can be started here.
+ Free,
+ /// A Biorouter daemon: `GET /status` answers 200 with the body `ok`.
+ Biorouter,
+ /// Something else holds the port: a dev server's catch-all that answers 200
+ /// to any path, a listener that never answers, another service.
+ Foreign,
+}
+
+/// What answers on `port` (see [`PortState`]).
+///
+/// Stricter than [`daemon_ok`], which takes any 200 and which other commands
+/// share: a Vite or CRA dev server on :3000 answers `/status` with its page, and
+/// this command then said a Biorouter daemon whose key it did not know held the
+/// port. HTTP/1.0, like [`with_secret`], so the body comes unchunked.
+async fn port_state(port: u16) -> PortState {
+ let addr = format!("{DAEMON_HOST}:{port}");
+ let connect = biorouter::net::connect_non_inheritable(&addr);
+ let mut stream = match tokio::time::timeout(Duration::from_millis(600), connect).await {
+ Ok(Ok(stream)) => stream,
+ _ => return PortState::Free,
+ };
+ let request = format!("GET /status HTTP/1.0\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
+ if stream.write_all(request.as_bytes()).await.is_err() {
+ return PortState::Foreign;
+ }
+ let mut response = Vec::new();
+ let read = tokio::time::timeout(
+ Duration::from_secs(2),
+ (&mut stream).take(4096).read_to_end(&mut response),
+ )
+ .await;
+ if !matches!(read, Ok(Ok(_))) {
+ return PortState::Foreign;
+ }
+ let text = String::from_utf8_lossy(&response);
+ match text.split_once("\r\n\r\n") {
+ Some((head, body))
+ if head.split_whitespace().nth(1) == Some("200") && body.trim() == "ok" =>
+ {
+ PortState::Biorouter
+ }
+ _ => PortState::Foreign,
+ }
+}
+
+/// How many ports `apps open` and `apps serve` try, from the configured one up,
+/// before giving up on finding one that is free or holds a daemon they can use.
+/// The exported launchers try the same six (3000–3005 by default).
+const PORT_ATTEMPTS: u16 = 6;
+
/// Locate the `biorouterd` binary: prefer the sibling of the running `biorouter`
/// executable (dev tree and installed app both colocate them), then the sibling
/// of the installation this copy came from (a Windows install has nothing else
@@ -620,6 +682,11 @@ pub(crate) fn open_page_in_browser(page: &Path) -> std::io::Result<()> {
webbrowser::open(page_url.as_str())
}
+/// `CREATE_NEW_PROCESS_GROUP | CREATE_NO_WINDOW`: a detached daemon's own
+/// process group, and no console to receive this one's Ctrl-C or close.
+#[cfg(windows)]
+const DETACHED_DAEMON_FLAGS: u32 = 0x0000_0200 | 0x0800_0000;
+
/// Whether a daemon this command starts is tied to this process's lifetime.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Supervision {
@@ -632,27 +699,67 @@ pub(crate) enum Supervision {
TiedToThisProcess,
}
-/// Ensure a daemon whose secret this command knows is reachable on the configured
-/// port, spawning one if needed. Answers the daemon and its secret.
-async fn ensure_daemon(port: u16, supervision: Supervision) -> Result<(Daemon, String)> {
- if daemon_ok(DAEMON_HOST, port).await {
- let known = std::env::var("BIOROUTER_SERVER__SECRET_KEY")
- .ok()
- .filter(|secret| !secret.is_empty())
- .into_iter()
- .chain(remembered_secret(port));
- for secret in known {
- if secret_works(port, &secret).await {
- return Ok((Daemon::Reused, secret));
+/// The secret of the daemon on `port`, when this command knows one that works:
+/// `BIOROUTER_SERVER__SECRET_KEY`, or the record of a daemon `apps open` or
+/// `apps serve` started there.
+async fn known_secret(port: u16) -> Option {
+ let known = std::env::var("BIOROUTER_SERVER__SECRET_KEY")
+ .ok()
+ .filter(|secret| !secret.is_empty())
+ .into_iter()
+ .chain(remembered_secret(port));
+ for secret in known {
+ if secret_works(port, &secret).await {
+ return Some(secret);
+ }
+ }
+ None
+}
+
+/// Ensure a daemon whose secret this command knows is reachable, from the
+/// configured port up, spawning one if needed. Answers the daemon, its secret and
+/// the port it is on.
+///
+/// A port held by something that is not Biorouter is passed over for the next,
+/// as the exported launchers do; it used to stop the command with a claim that a
+/// Biorouter daemon whose key it lacked was running there. A Biorouter daemon on
+/// the configured port whose key this command does not know still stops it: that
+/// is a daemon the person pointed it at.
+async fn ensure_daemon(first: u16, supervision: Supervision) -> Result<(Daemon, String, u16)> {
+ let mut port = first;
+ loop {
+ match port_state(port).await {
+ PortState::Free => break,
+ PortState::Biorouter => {
+ if let Some(secret) = known_secret(port).await {
+ return Ok((Daemon::Reused, secret, port));
+ }
+ if port == first {
+ bail!(
+ "a Biorouter daemon is already running on port {port}, and opening an app on it takes \
+ that daemon's secret key, which this command does not know. It knows the key of a \
+ daemon `biorouter apps open` or `biorouter apps serve` started; for any other, set \
+ BIOROUTER_SERVER__SECRET_KEY to its key, or set BIOROUTER_PORT to a free port so \
+ this command starts a daemon of its own."
+ );
+ }
+ eprintln!(
+ " {} port {port} has a Biorouter daemon this command has no key for; trying the next",
+ style("·").dim()
+ );
}
+ PortState::Foreign => eprintln!(
+ " {} port {port} is in use by something that is not Biorouter; trying the next",
+ style("·").dim()
+ ),
}
- bail!(
- "a Biorouter daemon is already running on port {port}, and opening an app on it takes \
- that daemon's secret key, which this command does not know. It knows the key of a \
- daemon `biorouter apps open` or `biorouter apps serve` started; for any other, set \
- BIOROUTER_SERVER__SECRET_KEY to its key, or set BIOROUTER_PORT to a free port so \
- this command starts a daemon of its own."
- );
+ port = match port.checked_add(1) {
+ Some(next) if next - first < PORT_ATTEMPTS => next,
+ _ => bail!(
+ "no port from {first} to {port} is free for a Biorouter daemon. Set BIOROUTER_PORT \
+ to a free port."
+ ),
+ };
}
let secret = new_daemon_secret();
@@ -667,6 +774,15 @@ async fn ensure_daemon(port: u16, supervision: Supervision) -> Result<(Daemon, S
.arg("--exit-with-parent")
.arg(std::process::id().to_string());
}
+ // A daemon `apps open` leaves running must not share this console. Windows
+ // sends Ctrl-C, and the close of the console window, to every process
+ // attached to it, and the daemon stops on either: the next Ctrl-C at that
+ // prompt, or closing the window, took the open app down with it. Its own
+ // process group and no console, as `run.ps1` gives the daemon it starts.
+ #[cfg(windows)]
+ if supervision == Supervision::Detached {
+ command.creation_flags(DETACHED_DAEMON_FLAGS);
+ }
let mut child = command
.env("BIOROUTER_PORT", port.to_string())
// The secret rides the environment, which only this account can read,
@@ -714,7 +830,7 @@ async fn ensure_daemon(port: u16, supervision: Supervision) -> Result<(Daemon, S
// a later `apps open` or `apps serve`, which can mint it only with
// this secret. `serve` removes the record when it stops the daemon.
let remembered = remember_daemon(port, &secret);
- return Ok((Daemon::Started { child, remembered }, secret));
+ return Ok((Daemon::Started { child, remembered }, secret, port));
}
if tokio::time::Instant::now() >= deadline {
let _ = child.start_kill();
@@ -767,30 +883,204 @@ fn serve_link_advice(port: u16, id: &str, started: Option) -> String {
}
// ──────────────────────────────────────────────────────────────────────────────
-// open
+// the profile's shared daemon
// ──────────────────────────────────────────────────────────────────────────────
-pub async fn handle_apps_open(id: String) -> Result<()> {
- require_app(&id)?;
- let port = configured_port();
- let (daemon, secret) = ensure_daemon(port, Supervision::Detached).await?;
- let url = app_url(port, &id);
- let launch = launch_url(port, &id, &secret).await?;
+/// `base` as an origin a browser on this machine is sent to: plain http to a
+/// loopback host on an explicit port. A daemon listening on every interface names
+/// `0.0.0.0` or `[::]`, an address to listen on and not one to visit, so it
+/// becomes the loopback address of its family. `None` for anything else.
+// Reached only through the shared daemon, which is unix-only for now.
+#[cfg_attr(not(unix), allow(dead_code))]
+fn loopback_origin(base: &str) -> Option {
+ let url = url::Url::parse(base).ok()?;
+ if url.scheme() != "http"
+ || !url.username().is_empty()
+ || url.password().is_some()
+ || url.path() != "/"
+ || url.query().is_some()
+ || url.fragment().is_some()
+ {
+ return None;
+ }
+ let port = url.port()?;
+ let host = match url.host_str()? {
+ "127.0.0.1" | "0.0.0.0" => "127.0.0.1",
+ "localhost" => "localhost",
+ "[::1]" | "[::]" => "[::1]",
+ _ => return None,
+ };
+ Some(format!("http://{host}:{port}"))
+}
- match daemon {
- Daemon::Reused => {
- println!(
- " {} using the daemon already running on port {port}",
- style("·").dim()
- );
+/// The address a browser on this machine opens app `id` at, from the shared
+/// daemon's launch answer: the link on the daemon's own listener. That is the
+/// answer's `url` when it names one, else the link's path on `listener` (the
+/// daemon's `/headless/health` `api_base_url`, for a daemon from before the
+/// answer named it). `None` unless the answer is exactly `id`'s page with a
+/// token, on a loopback listener.
+#[cfg_attr(not(unix), allow(dead_code))]
+fn shared_launch_link(
+ answer: &serde_json::Value,
+ id: &str,
+ listener: Option<&str>,
+) -> Option {
+ let path = answer.get("path")?.as_str()?;
+ if !is_launch_path(path, id) {
+ return None;
+ }
+ match answer.get("url") {
+ Some(serde_json::Value::String(url)) => {
+ let origin = url.strip_suffix(path)?;
+ (loopback_origin(origin)? == origin).then(|| url.clone())
}
- Daemon::Started { .. } => {
- // Leave the child running (detached) so the app stays served after
- // this command returns — the tokio child is not killed on drop.
- println!(" {} started biorouterd on port {port}", style("✓").green());
+ None | Some(serde_json::Value::Null) => {
+ Some(format!("{}{path}", loopback_origin(listener?)?))
}
+ Some(_) => None,
}
+}
+
+/// Mint app `id`'s launch link on the shared daemon `client` reaches.
+#[cfg(unix)]
+async fn launch_on_shared_daemon(
+ client: &crate::daemon_client::CrewClient,
+ id: &str,
+) -> Result {
+ // The id goes into a request line, so it must be an app name and nothing else.
+ biorouter_mcp::agent_drafter::store::validate_artifact_id(id)
+ .map_err(|_| anyhow!("'{id}' is not an app name"))?;
+ // `request_text`, not `request`: the daemon refuses a missing app in plain
+ // text, which `request` reports only as "not JSON".
+ let answer = client
+ .request_text("POST", &format!("/apps/{id}/launch"), None)
+ .await
+ .map_err(
+ |error| match error.downcast_ref::() {
+ Some(refusal) if refusal.status == 404 => {
+ anyhow!(
+ "this profile's Biorouter daemon would not open '{id}': it has no such app"
+ )
+ }
+ _ => anyhow!("this profile's Biorouter daemon would not open '{id}': {error}"),
+ },
+ )?;
+ let answer: serde_json::Value = serde_json::from_str(&answer).unwrap_or_default();
+ let listener = if answer.get("url").is_some_and(|url| !url.is_null()) {
+ None
+ } else {
+ client
+ .request("GET", "/headless/health", None)
+ .await
+ .ok()
+ .and_then(|health| Some(health.get("api_base_url")?.as_str()?.to_string()))
+ };
+ shared_launch_link(&answer, id, listener.as_deref()).ok_or_else(|| {
+ anyhow!("this profile's Biorouter daemon answered with an unexpected address for '{id}'")
+ })
+}
+
+/// App `id`'s launch link on this profile's shared daemon, the one the desktop
+/// app and `biorouter crew` run, when one is running and this command can use
+/// it: `Ok(None)` sends the caller on to a daemon of its own.
+///
+/// These commands used to look only at `BIOROUTER_PORT` (default 3000), so with
+/// the desktop app open they started a second full daemon, with its own MCP
+/// servers and scheduler, that never exited. The shared daemon is reached over
+/// its private socket with the key its starter saved, as every `crew` command
+/// reaches it; nothing is started here (`connect(true)`), and a daemon this
+/// command cannot use (started by an older Biorouter, or its key refused) is left
+/// running and passed over, with a line saying why.
+///
+/// A port named in `BIOROUTER_PORT` is honoured: see [`named_port`].
+async fn shared_daemon_launch(id: &str) -> Result> {
+ let named = named_port(std::env::var("BIOROUTER_PORT").ok().as_deref());
+ shared_daemon_launch_on(id, named).await
+}
+
+/// The port the person named in `BIOROUTER_PORT` (`value`), if they named one.
+///
+/// Someone who runs `BIOROUTER_PORT=8080 biorouter apps serve ` to match an
+/// SSH tunnel or a firewall rule means that port, and the shared daemon listens
+/// on one of its own choosing. So a named port sends these commands past the
+/// shared daemon unless it is the port that daemon listens on
+/// ([`on_named_port`]). ⚠ Not "skip the shared daemon whenever the variable is
+/// set": a shell the agent runs inside the shared daemon inherits the daemon's
+/// own `BIOROUTER_PORT`, and that shell is where the agent itself runs
+/// `apps open`. The desktop starts the daemon on a port it names, which then
+/// matches; `biorouter crew` starts it with `0`, which asks for any free port and
+/// names none.
+fn named_port(value: Option<&str>) -> Option {
+ value?.trim().parse::().ok().filter(|port| *port != 0)
+}
+
+/// Whether `link`, minted on the shared daemon, is on the port the person named.
+#[cfg_attr(not(unix), allow(dead_code))]
+fn on_named_port(link: &str, named: Option) -> bool {
+ match named {
+ None => true,
+ Some(port) => url::Url::parse(link).ok().and_then(|url| url.port()) == Some(port),
+ }
+}
+
+/// [`shared_daemon_launch`], for the port the person named (`named`).
+async fn shared_daemon_launch_on(id: &str, named: Option) -> Result> {
+ #[cfg(not(unix))]
+ {
+ let _ = (id, named);
+ Ok(None)
+ }
+ #[cfg(unix)]
+ {
+ use crate::daemon_client::{CrewClient, Restated, DAEMON_NOT_RUNNING_CODE};
+ let client = match CrewClient::connect(true).await {
+ Ok(client) => client,
+ Err(error) => {
+ let absent = error
+ .downcast_ref::()
+ .is_some_and(|restated| restated.code == Some(DAEMON_NOT_RUNNING_CODE));
+ if !absent {
+ let reason = error.to_string();
+ eprintln!(
+ " {} not using this profile's Biorouter daemon: {}",
+ style("·").dim(),
+ reason.lines().next().unwrap_or_default()
+ );
+ }
+ return Ok(None);
+ }
+ };
+ // A named port is checked before anything is minted, so a shared daemon
+ // on another port is passed over without a link being made for nobody,
+ // and without its refusals deciding a command meant for another daemon.
+ if let Some(port) = named {
+ let listener = client
+ .request("GET", "/headless/health", None)
+ .await
+ .ok()
+ .and_then(|health| Some(health.get("api_base_url")?.as_str()?.to_string()));
+ if !listener
+ .as_deref()
+ .is_some_and(|listener| on_named_port(listener, named))
+ {
+ eprintln!(
+ " {} not using this profile's Biorouter daemon, which listens on {}: \
+ BIOROUTER_PORT names port {port}. Unset BIOROUTER_PORT to use it.",
+ style("·").dim(),
+ listener.as_deref().unwrap_or("another port"),
+ );
+ return Ok(None);
+ }
+ }
+ let link = launch_on_shared_daemon(&client, id).await?;
+ // And the link itself, which is what the browser is sent to.
+ Ok(on_named_port(&link, named).then_some(link))
+ }
+}
+/// Open `launch` in the default browser through a private launch page, and print
+/// it for when no browser opened or one could not read the page.
+fn open_or_print(launch: &str) {
// The link itself never reaches an opener's command line: see
// `write_launch_page`. The terminal is this account's alone, so the link is
// printed there when no browser could be opened.
@@ -799,7 +1089,7 @@ pub async fn handle_apps_open(id: String) -> Result<()> {
// page (a snap-packaged browser cannot read hidden folders such as the state
// dir) shows an error, and `open` still reports success.
let once = format!("it works once, within {LAUNCH_LINK_LIFETIME}");
- match open_launch_link(&launch, &launch_pages_dir(), open_page_in_browser) {
+ match open_launch_link(launch, &launch_pages_dir(), open_page_in_browser) {
Ok(_) => println!(
" {} if the app does not appear, open this address instead ({once}): {launch}",
style("·").dim()
@@ -812,6 +1102,41 @@ pub async fn handle_apps_open(id: String) -> Result<()> {
);
}
}
+}
+
+// ──────────────────────────────────────────────────────────────────────────────
+// open
+// ──────────────────────────────────────────────────────────────────────────────
+
+pub async fn handle_apps_open(id: String) -> Result<()> {
+ require_app(&id)?;
+ if let Some(launch) = shared_daemon_launch(&id).await? {
+ println!(
+ " {} using this profile's Biorouter daemon",
+ style("·").dim()
+ );
+ open_or_print(&launch);
+ return Ok(());
+ }
+ let (daemon, secret, port) = ensure_daemon(configured_port(), Supervision::Detached).await?;
+ let url = app_url(port, &id);
+ let launch = launch_url(port, &id, &secret).await?;
+
+ match daemon {
+ Daemon::Reused => {
+ println!(
+ " {} using the daemon already running on port {port}",
+ style("·").dim()
+ );
+ }
+ Daemon::Started { .. } => {
+ // Leave the child running (detached) so the app stays served after
+ // this command returns — the tokio child is not killed on drop.
+ println!(" {} started biorouterd on port {port}", style("✓").green());
+ }
+ }
+
+ open_or_print(&launch);
println!(" {} {}", style("→").fg(ACCENT), style(&url).bold());
Ok(())
}
@@ -822,14 +1147,30 @@ pub async fn handle_apps_open(id: String) -> Result<()> {
pub async fn handle_apps_serve(id: String) -> Result<()> {
require_app(&id)?;
- let port = configured_port();
+ // The profile's shared daemon is already served by whoever runs it, so there
+ // is nothing to hold in the foreground: print a link and return, as for any
+ // daemon this command did not start.
+ if let Some(launch) = shared_daemon_launch(&id).await? {
+ println!(
+ " {} the address below opens the app once, within {LAUNCH_LINK_LIFETIME}. \
+ Run this again for another.",
+ style("·").dim()
+ );
+ println!(" {} {}", style("→").fg(ACCENT), style(&launch).bold());
+ println!(
+ " {} served by this profile's Biorouter daemon; it keeps running after this command.",
+ style("·").dim()
+ );
+ return Ok(());
+ }
// ⚠ BEFORE the spawn, and before anything that can park. A handler replaces
// the default action — which for SIGTERM is to end this process on the spot
// and leave the daemon behind — so from here on a signal waits to be read,
// including one that lands during the readiness wait inside `ensure_daemon`.
let mut stop = StopSignals::install()?;
- let (daemon, secret) = ensure_daemon(port, Supervision::TiedToThisProcess).await?;
+ let (daemon, secret, port) =
+ ensure_daemon(configured_port(), Supervision::TiedToThisProcess).await?;
let url = match launch_url(port, &id, &secret).await {
Ok(url) => url,
Err(error) => {
@@ -1308,11 +1649,12 @@ mod tests {
assert!(unknown.contains("does not know"), "{unknown}");
assert!(remember_daemon(port, SECRET));
- let (daemon, secret) = ensure_daemon(port, Supervision::TiedToThisProcess)
+ let (daemon, secret, reused) = ensure_daemon(port, Supervision::TiedToThisProcess)
.await
.expect("a recorded daemon is reused");
assert!(matches!(daemon, Daemon::Reused));
assert_eq!(secret, SECRET);
+ assert_eq!(reused, port);
let first = launch_url(port, "beta", &secret).await.unwrap();
let second = launch_url(port, "beta", &secret).await.unwrap();
@@ -1357,6 +1699,372 @@ mod tests {
}
}
+ /// A server that answers every request with `status` and `body`, as a dev
+ /// server's catch-all does, on `listener`.
+ fn answer_everything(
+ listener: tokio::net::TcpListener,
+ status: &'static str,
+ body: &'static str,
+ ) {
+ tokio::spawn(async move {
+ while let Ok((mut stream, _)) = listener.accept().await {
+ tokio::spawn(async move {
+ let mut chunk = [0u8; 1024];
+ let _ = stream.read(&mut chunk).await;
+ let answer = format!(
+ "HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
+ body.len()
+ );
+ let _ = stream.write_all(answer.as_bytes()).await;
+ let _ = stream.shutdown().await;
+ });
+ }
+ });
+ }
+
+ /// Two listeners on adjacent loopback ports, the lower one first.
+ async fn adjacent_listeners() -> (tokio::net::TcpListener, tokio::net::TcpListener) {
+ for _ in 0..50 {
+ let lower = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let port = lower.local_addr().unwrap().port();
+ let Some(next) = port.checked_add(1) else {
+ continue;
+ };
+ if let Ok(upper) = tokio::net::TcpListener::bind(("127.0.0.1", next)).await {
+ return (lower, upper);
+ }
+ }
+ panic!("no two adjacent loopback ports were free");
+ }
+
+ /// Only a daemon that says `ok` on `/status` is a Biorouter daemon. Any 200
+ /// used to be, so a dev server's catch-all on :3000 stopped `apps open` with
+ /// a claim that a Biorouter daemon whose key it lacked was running there.
+ #[tokio::test]
+ async fn only_a_status_that_says_ok_is_a_biorouter_daemon() {
+ let daemon = a_daemon_with("k", "beta").await;
+ assert_eq!(port_state(daemon).await, PortState::Biorouter);
+
+ for (status, body) in [
+ ("200 OK", "
"),
+ ("200 OK", ""),
+ ("404 Not Found", "ok"),
+ ] {
+ let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let port = listener.local_addr().unwrap().port();
+ answer_everything(listener, status, body);
+ assert_eq!(
+ port_state(port).await,
+ PortState::Foreign,
+ "{status} {body:?}"
+ );
+ }
+
+ let closed = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
+ let free = closed.local_addr().unwrap().port();
+ drop(closed);
+ assert_eq!(port_state(free).await, PortState::Free);
+ }
+
+ /// A port held by something that is not Biorouter is passed over for the
+ /// next, as the exported launchers do, and a daemon there that this command
+ /// started is reused.
+ #[tokio::test]
+ async fn a_port_held_by_something_else_is_passed_over() {
+ const SECRET: &str = "a11a11a11a11a11a11a11a11a11a11a11a11a11a11a11a11a11a11a11a11a11a";
+ let (foreign, relay) = adjacent_listeners().await;
+ let first = foreign.local_addr().unwrap().port();
+ answer_everything(foreign, "200 OK", "");
+ let next = relay.local_addr().unwrap().port();
+ // `a_daemon_with` binds a port of its own, so stand the daemon up on `next`
+ // through the listener already holding it, which hands its connections to
+ // the same stand-in. ⚠ Not dropped and bound again: macOS hands out
+ // ephemeral ports in order, so the stand-in's own `:0` took `next` in the
+ // gap and the second bind failed with `AddrInUse`.
+ let daemon = a_daemon_with(SECRET, "beta").await;
+ tokio::spawn(async move {
+ while let Ok((mut inbound, _)) = relay.accept().await {
+ tokio::spawn(async move {
+ if let Ok(mut outbound) =
+ tokio::net::TcpStream::connect(("127.0.0.1", daemon)).await
+ {
+ let _ = tokio::io::copy_bidirectional(&mut inbound, &mut outbound).await;
+ }
+ });
+ }
+ });
+ assert!(remember_daemon(next, SECRET));
+
+ let (daemon_state, secret, port) = ensure_daemon(first, Supervision::Detached)
+ .await
+ .expect("the daemon on the next port is reused");
+ assert!(matches!(daemon_state, Daemon::Reused));
+ assert_eq!((secret.as_str(), port), (SECRET, next));
+ forget_daemon(next, SECRET);
+ }
+
+ #[test]
+ fn a_shared_daemons_listener_is_a_loopback_origin() {
+ for (base, origin) in [
+ ("http://127.0.0.1:51851", Some("http://127.0.0.1:51851")),
+ ("http://0.0.0.0:51851", Some("http://127.0.0.1:51851")),
+ ("http://[::]:51851", Some("http://[::1]:51851")),
+ ("http://localhost:51851/", Some("http://localhost:51851")),
+ ("https://127.0.0.1:51851", None),
+ ("http://10.0.0.5:51851", None),
+ ("http://127.0.0.1", None),
+ ("http://127.0.0.1:51851/below", None),
+ ("http://u@127.0.0.1:51851", None),
+ ("not a url", None),
+ ] {
+ assert_eq!(loopback_origin(base).as_deref(), origin, "{base}");
+ }
+ }
+
+ /// The link `apps open` hands the browser for a shared daemon is the one on
+ /// the daemon's own listener: the daemon's `url`, or else its path on the
+ /// listener the daemon names in `/headless/health`.
+ #[test]
+ fn a_shared_daemons_link_is_on_its_own_listener() {
+ let token = "ab".repeat(32);
+ let path = format!("/apps/beta/?t={token}");
+ let url = format!("http://127.0.0.1:51851{path}");
+ let answer = serde_json::json!({ "path": path, "url": url });
+ assert_eq!(shared_launch_link(&answer, "beta", None), Some(url.clone()));
+
+ let older = serde_json::json!({ "path": path });
+ assert_eq!(
+ shared_launch_link(&older, "beta", Some("http://0.0.0.0:51851")),
+ Some(url.clone())
+ );
+ assert_eq!(shared_launch_link(&older, "beta", None), None);
+ assert_eq!(
+ shared_launch_link(&older, "beta", Some("http://10.0.0.5:1")),
+ None
+ );
+
+ for wrong in [
+ serde_json::json!({ "path": path, "url": format!("http://127.0.0.1:51851/apps/other/?t={token}") }),
+ serde_json::json!({ "path": path, "url": format!("{url}&next=/sessions") }),
+ serde_json::json!({ "path": path, "url": format!("http://evil.test:1{path}") }),
+ serde_json::json!({ "path": path, "url": 42 }),
+ serde_json::json!({ "path": format!("/apps/other/?t={token}"), "url": url }),
+ ] {
+ assert_eq!(
+ shared_launch_link(&wrong, "beta", Some("http://127.0.0.1:1")),
+ None,
+ "{wrong}"
+ );
+ }
+ }
+
+ /// A stand-in for the profile's shared daemon on its private socket: its
+ /// identity, the route a client checks its key on, an app's launch route
+ /// (answering `url` only when `names_listener`) and `/headless/health`.
+ #[cfg(unix)]
+ async fn a_shared_daemon(
+ names_listener: bool,
+ ) -> (
+ biorouter::daemon_runtime::Descriptor,
+ std::sync::Arc>>,
+ ) {
+ use biorouter::daemon_runtime::{self, Descriptor, Endpoint};
+ use bytes::Bytes;
+ use http_body_util::Full;
+ use hyper::{server::conn::http1, service::service_fn, Request, Response};
+ use hyper_util::rt::TokioIo;
+ use std::os::unix::fs::PermissionsExt;
+
+ let directory = daemon_runtime::runtime_directory();
+ daemon_runtime::private_directory(&directory).unwrap();
+ let socket = directory.join("daemon.sock");
+ let _ = fs::remove_file(&socket);
+ let listener = tokio::net::UnixListener::bind(&socket).unwrap();
+ fs::set_permissions(&socket, fs::Permissions::from_mode(0o600)).unwrap();
+ let descriptor = Descriptor {
+ version: daemon_runtime::VERSION,
+ profile_id: daemon_runtime::profile_identity().unwrap(),
+ instance_id: "33333333-3333-4333-8333-333333333333".to_owned(),
+ pid: std::process::id(),
+ endpoint: Endpoint::Unix { path: socket },
+ api_secret: "shared-daemon-secret-for-apps-tests".to_owned(),
+ user_action_installed: true,
+ };
+ daemon_runtime::write_private(&daemon_runtime::descriptor_path(), &descriptor).unwrap();
+ daemon_runtime::write_user_action_key(&descriptor, &"cd".repeat(32)).unwrap();
+ let identity = descriptor.identity();
+ let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
+ let requests = std::sync::Arc::clone(&seen);
+ tokio::spawn(async move {
+ while let Ok((stream, _)) = listener.accept().await {
+ let identity = identity.clone();
+ let requests = std::sync::Arc::clone(&requests);
+ tokio::spawn(async move {
+ let service = service_fn(move |request: Request| {
+ let identity = identity.clone();
+ let requests = std::sync::Arc::clone(&requests);
+ async move {
+ let line = format!("{} {}", request.method(), request.uri().path());
+ requests.lock().unwrap().push(line.clone());
+ let path = format!("/apps/beta/?t={}", "ef".repeat(32));
+ let body = match line.as_str() {
+ "GET /daemon/identity" => serde_json::to_value(&identity).unwrap(),
+ "GET /crew/connections" => serde_json::json!([]),
+ "POST /apps/beta/launch" if names_listener => serde_json::json!({
+ "path": path,
+ "url": format!("http://127.0.0.1:51851{path}"),
+ }),
+ "POST /apps/beta/launch" => serde_json::json!({ "path": path }),
+ "GET /headless/health" => serde_json::json!({
+ "status": "ok",
+ "api_base_url": "http://127.0.0.1:51900",
+ }),
+ _ => {
+ return Ok::<_, std::convert::Infallible>(
+ Response::builder()
+ .status(404)
+ .header("content-type", "text/plain")
+ .body(Full::new(Bytes::from_static(b"no such app")))
+ .unwrap(),
+ )
+ }
+ };
+ Ok(Response::builder()
+ .header("content-type", "application/json")
+ .body(Full::new(Bytes::from(body.to_string())))
+ .unwrap())
+ }
+ });
+ let _ = http1::Builder::new()
+ .serve_connection(TokioIo::new(stream), service)
+ .await;
+ });
+ }
+ });
+ (descriptor, seen)
+ }
+
+ #[cfg(unix)]
+ fn forget_shared_daemon() {
+ let _ = fs::remove_file(biorouter::daemon_runtime::descriptor_path());
+ let _ = fs::remove_file(biorouter::daemon_runtime::user_action_key_path());
+ }
+
+ /// With the desktop app open, `apps open` and `apps serve` started a second
+ /// full daemon, keyless and never exiting, beside the profile's shared one.
+ /// They now mint the link on the shared daemon and send the browser to its
+ /// own listener.
+ #[cfg(unix)]
+ #[tokio::test]
+ #[serial_test::serial]
+ async fn apps_open_prefers_the_profiles_shared_daemon() {
+ // `_on(…, None)`, not `shared_daemon_launch`: a test run from a shell
+ // inside a daemon inherits that daemon's BIOROUTER_PORT.
+ forget_shared_daemon();
+ assert_eq!(
+ shared_daemon_launch_on("beta", None).await.unwrap(),
+ None,
+ "with no shared daemon the command starts one of its own"
+ );
+
+ let (_descriptor, seen) = a_shared_daemon(true).await;
+ let link = shared_daemon_launch_on("beta", None).await.unwrap();
+ assert_eq!(
+ link,
+ Some(format!(
+ "http://127.0.0.1:51851/apps/beta/?t={}",
+ "ef".repeat(32)
+ ))
+ );
+ assert!(seen
+ .lock()
+ .unwrap()
+ .iter()
+ .any(|line| line == "POST /apps/beta/launch"));
+ let missing = shared_daemon_launch_on("gone", None)
+ .await
+ .unwrap_err()
+ .to_string();
+ assert!(missing.contains("it has no such app"), "{missing}");
+ forget_shared_daemon();
+
+ // A daemon from before the answer named its listener: the listener its
+ // health route names.
+ let (_descriptor, _seen) = a_shared_daemon(false).await;
+ assert_eq!(
+ shared_daemon_launch_on("beta", None).await.unwrap(),
+ Some(format!(
+ "http://127.0.0.1:51900/apps/beta/?t={}",
+ "ef".repeat(32)
+ ))
+ );
+ forget_shared_daemon();
+ }
+
+ /// `BIOROUTER_PORT=8080 biorouter apps serve `, run to match an SSH
+ /// tunnel, printed a link on the shared daemon's own port and returned, with
+ /// nothing listening on 8080. A named port is honoured; `0`, which a shell
+ /// inside a `crew`-started daemon inherits, names none.
+ #[test]
+ fn a_port_the_person_names_is_one_they_mean() {
+ for (value, named) in [
+ (None, None),
+ (Some("0"), None),
+ (Some(""), None),
+ (Some("eighty"), None),
+ (Some("70000"), None),
+ (Some("8080"), Some(8080)),
+ (Some(" 8080\n"), Some(8080)),
+ ] {
+ assert_eq!(named_port(value), named, "{value:?}");
+ }
+ let link = format!("http://127.0.0.1:51851/apps/beta/?t={}", "ef".repeat(32));
+ assert!(on_named_port(&link, None));
+ assert!(on_named_port(&link, Some(51851)));
+ assert!(on_named_port("http://0.0.0.0:51851", Some(51851)));
+ assert!(!on_named_port(&link, Some(8080)));
+ assert!(!on_named_port("not a url", Some(8080)));
+ }
+
+ #[cfg(unix)]
+ #[tokio::test]
+ #[serial_test::serial]
+ async fn a_named_port_passes_over_a_shared_daemon_on_another() {
+ forget_shared_daemon();
+ // The stand-in's health route names 51900, and so does the link it
+ // mints when its answer names no listener.
+ let (_descriptor, seen) = a_shared_daemon(false).await;
+ assert_eq!(
+ shared_daemon_launch_on("beta", Some(8080)).await.unwrap(),
+ None
+ );
+ assert!(
+ !seen
+ .lock()
+ .unwrap()
+ .iter()
+ .any(|line| line.ends_with("/launch")),
+ "nothing is minted on a daemon the command will not use"
+ );
+ // The shell inside the daemon: its own port is the one named.
+ assert_eq!(
+ shared_daemon_launch_on("beta", Some(51900)).await.unwrap(),
+ Some(format!(
+ "http://127.0.0.1:51900/apps/beta/?t={}",
+ "ef".repeat(32)
+ ))
+ );
+ forget_shared_daemon();
+
+ // A link off the named port is never handed out, whatever health said.
+ let (_descriptor, _seen) = a_shared_daemon(true).await;
+ assert_eq!(
+ shared_daemon_launch_on("beta", Some(51900)).await.unwrap(),
+ None
+ );
+ forget_shared_daemon();
+ }
+
/// What the person is told about a link's lifetime is the daemon's figure,
/// which lives in a crate this one cannot import.
#[test]
diff --git a/crates/biorouter-mcp/src/agent_drafter/bundle.rs b/crates/biorouter-mcp/src/agent_drafter/bundle.rs
index e1022c1f1..0cb6bc100 100644
--- a/crates/biorouter-mcp/src/agent_drafter/bundle.rs
+++ b/crates/biorouter-mcp/src/agent_drafter/bundle.rs
@@ -2,19 +2,28 @@
//!
//! Apps are authored in TypeScript (`src/main.ts` importing `src/sdk.ts`) and
//! bundled into a single browser-runnable `dist/app.js` with **esbuild**. esbuild
-//! is located via (in order): `$BIOROUTER_ESBUILD_BIN`, the desktop app's bundled
-//! `node_modules/.bin/esbuild` (dev tree), `esbuild` on `PATH`, then `npx esbuild`.
+//! is located via (in order, see [`find_esbuild`]): `$BIOROUTER_ESBUILD_BIN`, the
+//! copy the desktop app ships beside `biorouterd`, the checkout's
+//! `ui/desktop/node_modules/.bin/esbuild` (dev tree), `esbuild` on the process
+//! or login-shell `PATH`, then `npx --yes esbuild@`.
//!
//! When no esbuild is available (e.g. a headless CLI install with no Node), a
-//! best-effort vendored type-stripper concatenates the sources into a runnable
-//! bundle so simple apps still work. The stripper is intentionally conservative;
-//! complex TypeScript should be built where esbuild is present.
+//! vendored type-stripper builds the simple shapes it can prove it handles and
+//! REFUSES the rest, saying so. ⚠ It is a last resort, not a second compiler: it
+//! used to accept everything, which shipped broken bundles that looked built.
+//! See [`fallback_bundle`] for what it refuses and why.
+//!
+//! Every bundle this module writes starts with a one-line banner
+//! ([`BundleBanner`]) naming the builder, the App SDK it carries and the
+//! Biorouter that built it. That is how a daemon tells a fallback bundle from a
+//! real one, and never rebuilds an app down to an older SDK.
+use std::ffi::{OsStr, OsString};
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
-use std::sync::atomic::{AtomicBool, Ordering as AtomicOrdering};
-use std::sync::{Mutex, MutexGuard};
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering as AtomicOrdering};
+use std::sync::{Mutex, MutexGuard, OnceLock};
use std::time::{Duration, Instant};
use crate::agent_drafter::store::{ArtifactKind, Manifest};
@@ -101,6 +110,17 @@ pub fn lint_app(project_dir: &Path) -> Vec {
break;
}
}
+ // Inline `"#,
+ "",
+ Some(manifest),
+ );
+ assert!(!wired.contains("won't do anything"), "{wired}");
+ assert!(!wired.contains("javascript:"), "{wired}");
+
+ let dir = TempDir::new().unwrap();
+ std::fs::write(
+ dir.path().join("index.html"),
+ r#"Go "#,
+ )
+ .unwrap();
+ std::fs::write(dir.path().join("manifest.json"), manifest).unwrap();
+ let findings = lint_app(dir.path());
+ let url = findings
+ .iter()
+ .find(|f| f.msg.contains("javascript:"))
+ .expect("a javascript: URL is reported");
+ assert_eq!(url.level, LintLevel::Warn);
+ }
+
/// H6: a hardcoded text color won't adapt to the theme and can go invisible.
#[test]
fn lint_warns_on_hardcoded_text_color_but_not_theme_tokens() {
@@ -2870,12 +4644,11 @@ document.getElementById("edit")!.addEventListener("click", () => {
#[test]
#[ignore]
fn leak_probe_prints_esbuild_child_env() {
- let (program, lead) = find_esbuild().expect("BIOROUTER_ESBUILD_BIN shim");
+ let cmd = find_esbuild().expect("BIOROUTER_ESBUILD_BIN shim");
let work = TempDir::new().unwrap();
let entry = work.path().join("main.ts");
std::fs::write(&entry, "const x: number = 1;\n").unwrap();
- let report =
- run_esbuild(&program, &lead, &entry, &work.path().join("app.js")).expect("spawn");
+ let report = run_esbuild(&cmd, &entry, &work.path().join("app.js")).expect("spawn");
let canary = std::env::var("BR_TEST_CANARY").unwrap_or_default();
// Report BioRouter's namespace, the variables the parent injected,
// and any line carrying the canary under *any* key. Everything else
diff --git a/crates/biorouter-mcp/src/agent_drafter/control.rs b/crates/biorouter-mcp/src/agent_drafter/control.rs
index 65163722e..b40a7f711 100644
--- a/crates/biorouter-mcp/src/agent_drafter/control.rs
+++ b/crates/biorouter-mcp/src/agent_drafter/control.rs
@@ -588,11 +588,10 @@ pub struct ConsultRequest {
struct BridgeInner {
/// Outbound half of the *current* connection. `None` between connections.
tx: Mutex>>,
- /// Monotonic id of the current connection. Bumped by every `attach`; a
- /// `detach` only tears down when it still owns this generation, so a stale
- /// connection closing *after* a reload can't kill the fresh connection's
- /// channel or cancel its parked `ui_ask`.
- generation: AtomicU64,
+ /// Serializes the complete attach/detach lifecycle. A generation check
+ /// outside this lock could pass before a reload and then clear its new
+ /// sender or cancel its freshly parked tools.
+ generation: Mutex,
/// `requestId` → responder for a parked `ui_ask`.
pending: Mutex>>,
/// `callId` → responder for a parked `app_call`. Separate from [`Self::pending`]
@@ -678,7 +677,7 @@ impl UiBridge {
Self {
inner: Arc::new(BridgeInner {
tx: Mutex::new(None),
- generation: AtomicU64::new(0),
+ generation: Mutex::new(0),
pending: Mutex::new(HashMap::new()),
pending_calls: Mutex::new(HashMap::new()),
consult_tx: Mutex::new(None),
@@ -707,9 +706,13 @@ impl UiBridge {
/// the reloaded page has none of it. The semantic `state` bag survives and is
/// re-emitted so the fresh page rehydrates.
pub fn attach(&self) -> (mpsc::UnboundedReceiver, ConnToken) {
- // Claim the next generation first: any concurrent stale detach now sees a
- // newer generation and becomes a no-op.
- let gen = self.inner.generation.fetch_add(1, Ordering::AcqRel) + 1;
+ let mut generation = self
+ .inner
+ .generation
+ .lock()
+ .unwrap_or_else(|p| p.into_inner());
+ *generation += 1;
+ let gen = *generation;
self.cancel_all();
let (tx, rx) = mpsc::unbounded_channel();
if let Ok(mut slot) = self.inner.tx.lock() {
@@ -744,7 +747,12 @@ impl UiBridge {
/// a stale connection closing *after* a reload cannot tear down the fresh
/// one. Parked `ui_ask` calls are unblocked so no tool outlives its socket.
pub fn detach(&self, token: ConnToken) {
- if self.inner.generation.load(Ordering::Acquire) != token.0 {
+ let generation = self
+ .inner
+ .generation
+ .lock()
+ .unwrap_or_else(|p| p.into_inner());
+ if *generation != token.0 {
return; // a newer connection owns the bridge now
}
if let Ok(mut slot) = self.inner.tx.lock() {
@@ -5194,6 +5202,47 @@ mod tests {
);
}
+ #[test]
+ fn a_detach_paused_at_its_sender_cannot_clear_a_concurrent_reattach() {
+ let bridge = UiBridge::new();
+ let (_old_frames, old_token) = bridge.attach();
+
+ // Force detach to pause after claiming the connection lifecycle, while
+ // a reload tries to install a new sender and park a new UI request.
+ let sender = bridge.inner.tx.lock().unwrap();
+ let closing = bridge.clone();
+ let detached = std::thread::spawn(move || closing.detach(old_token));
+ let deadline = std::time::Instant::now() + Duration::from_secs(2);
+ loop {
+ if matches!(
+ bridge.inner.generation.try_lock(),
+ Err(std::sync::TryLockError::WouldBlock)
+ ) {
+ break;
+ }
+ assert!(
+ std::time::Instant::now() < deadline,
+ "detach did not claim the lifecycle lock"
+ );
+ std::thread::yield_now();
+ }
+
+ let reloading = bridge.clone();
+ let attached = std::thread::spawn(move || {
+ let (frames, token) = reloading.attach();
+ let answer = reloading.register_ask("concurrent-reconnect-ask".to_string());
+ assert!(reloading.emit_frame(json!({"message":"fresh connection"})));
+ (frames, token, answer)
+ });
+ drop(sender);
+ detached.join().unwrap();
+ let (mut frames, token, mut answer) = attached.join().unwrap();
+ assert_eq!(frames.try_recv().unwrap()["message"], "fresh connection");
+ assert!(bridge.resolve("concurrent-reconnect-ask", json!({"answer":"kept"})));
+ assert_eq!(answer.try_recv().unwrap(), json!({"answer":"kept"}));
+ bridge.detach(token);
+ }
+
/// Panels are per-connection (the reloaded DOM has none), but the semantic
/// state bag survives and is replayed so the fresh page rehydrates.
#[tokio::test]
diff --git a/crates/biorouter-mcp/src/agent_drafter/export.rs b/crates/biorouter-mcp/src/agent_drafter/export.rs
new file mode 100644
index 000000000..b72090472
--- /dev/null
+++ b/crates/biorouter-mcp/src/agent_drafter/export.rs
@@ -0,0 +1,3259 @@
+//! Exporting a Biorouter app as a folder that runs on another machine.
+//!
+//! **One implementation behind every door.** The `export_app` tool writes the
+//! folder where it was told to ([`export_to_dir`]); the desktop's Export button
+//! and a `biorouter serve` tab receive it as a zip that `POST /apps/{id}/export`
+//! builds with [`export_to_staging`]; and the legacy `GET /apps/{id}/export`
+//! answers older desktops with [`legacy_export_map`]. There used to be two
+//! writers, and the desktop's silently ignored every option its dialog offered:
+//! a "Full" export came out as a launcher export, and nothing said so.
+//!
+//! What a folder holds, and the failure each rule exists for:
+//! - The **raw authored entry**, never an assembled page. Every way an export
+//! runs is a daemon serving the page, and the daemon assembles it with this
+//! run's socket token. A page assembled at export time carried a token-less
+//! config island that the SDK read first, so no exported agentic app could
+//! reach its agent, and running an export on the author's machine wrote that
+//! page over the live app.
+//! - Every other authored file **byte for byte** (`assets/logo.png`, fonts,
+//! wasm). Reading files as text dropped every binary one without a word.
+//! - Never `workspace/` (the app's runtime data), never a `.vault`, `.git` or
+//! `node_modules` component anywhere, never a file named like a credential,
+//! and never a symlink. ⚠ The vault exclusion used to match `/.vault/`
+//! only, while the vault lives at `workspace/.vault/`: sealed secrets stayed
+//! out solely because ciphertext is not valid UTF-8, so the binary-safe copy
+//! above could not land without this.
+//! - `manifest.json` without `session_id`. A chat id means nothing on another
+//! machine, and there it can name an unrelated chat.
+//! - The generated launchers, executable. An authored file with one of their
+//! names moves under `app-files/`, so it can never replace a launcher.
+//! - In full mode, `payload/` (knowledge bases and skills) and `export.json`.
+//! An absent selection key means "everything the app references", an empty
+//! list means "none", and each knowledge base is asked of the caller's reach
+//! through [`PayloadAccess`], so a private base never leaves for a caller that
+//! could not open it.
+
+use std::collections::BTreeSet;
+use std::io;
+use std::path::{Component, Path, PathBuf};
+
+use serde::Serialize;
+
+use super::store::{validate_artifact_id, ArtifactKind, ArtifactStore, Manifest};
+use super::{bundle, render};
+use crate::knowledge::caller::KbCaller;
+use crate::knowledge::service::KnowledgeService;
+
+/// Path components that never leave the machine, wherever they appear: the
+/// app's sealed secrets, a repository's history (remote URLs carry tokens), and
+/// installed packages. The same rule the app jail applies (`developer::jail`),
+/// by component rather than by prefix.
+const EXCLUDED_COMPONENTS: &[&str] = &[".vault", ".git", "node_modules"];
+
+/// How deep an app or skill tree is followed. A real one is a few levels deep;
+/// the bound is what makes a pathological tree end.
+const MAX_DEPTH: usize = 24;
+/// More files than any authored app has; an app past it is refused rather than
+/// half-exported.
+const MAX_APP_FILES: usize = 20_000;
+/// A skill is a few documents and scripts. These bounds keep a skill folder that
+/// happens to hold a dataset from turning an export into a copy of it.
+const MAX_SKILL_FILES: usize = 5_000;
+const MAX_SKILL_BYTES: u64 = 256 * 1024 * 1024;
+
+// ---------------------------------------------------------------------------
+// The request
+// ---------------------------------------------------------------------------
+
+/// What the exported folder carries besides the app.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
+pub enum ExportMode {
+ /// The app and its launchers. It runs against whatever knowledge bases,
+ /// skills and extensions the recipient already has.
+ #[default]
+ Launcher,
+ /// Also the app's server-side payload under `payload/`, and `export.json`.
+ Full,
+}
+
+impl ExportMode {
+ pub fn parse(value: &str) -> Option {
+ match value {
+ "launcher" => Some(Self::Launcher),
+ "full" => Some(Self::Full),
+ _ => None,
+ }
+ }
+
+ pub fn as_str(self) -> &'static str {
+ match self {
+ Self::Launcher => "launcher",
+ Self::Full => "full",
+ }
+ }
+}
+
+/// A full export's selection. `None` for a key means "everything the app
+/// references"; `Some(vec![])` means "none of these". ⚠ The two must stay
+/// distinct all the way to the wire: the desktop used to drop an empty list, so
+/// unchecking a private knowledge base sent no key at all and the base shipped
+/// anyway.
+#[derive(Debug, Clone, Default, PartialEq, Eq)]
+pub struct ExportSelection {
+ pub knowledge_bases: Option>,
+ pub skills: Option>,
+ pub extensions: Option>,
+}
+
+impl ExportSelection {
+ /// Read `{"knowledge_bases": [...], "skills": [...], "extensions": [...]}`.
+ /// A missing or `null` key is "everything referenced"; anything that is not
+ /// a list of strings is refused rather than read as "everything".
+ pub fn from_json(include: Option<&serde_json::Value>) -> Result {
+ let Some(include) = include.filter(|value| !value.is_null()) else {
+ return Ok(Self::default());
+ };
+ let Some(object) = include.as_object() else {
+ return Err(
+ "`include` must be an object with `knowledge_bases`, `skills` and `extensions` \
+ lists"
+ .to_string(),
+ );
+ };
+ let list = |key: &str| -> Result>, String> {
+ match object.get(key) {
+ None | Some(serde_json::Value::Null) => Ok(None),
+ Some(serde_json::Value::Array(items)) => items
+ .iter()
+ .map(|item| {
+ item.as_str()
+ .map(str::to_string)
+ .ok_or_else(|| format!("`include.{key}` must list names as strings"))
+ })
+ .collect::, _>>()
+ .map(Some),
+ Some(_) => Err(format!("`include.{key}` must be a list")),
+ }
+ };
+ Ok(Self {
+ knowledge_bases: list("knowledge_bases")?,
+ skills: list("skills")?,
+ extensions: list("extensions")?,
+ })
+ }
+}
+
+/// One export, as every door asks for it.
+#[derive(Debug, Clone, Default)]
+pub struct ExportRequest {
+ pub mode: ExportMode,
+ /// Read in full mode only.
+ pub include: ExportSelection,
+ /// Copy this platform's `biorouterd` into `payload/bin/` ("fat" export).
+ pub bundle_daemon: bool,
+ /// Export even when the harness lint finds ERRORs. Only a person decides
+ /// this: the desktop asks them, and the model-facing tool never sets it.
+ pub allow_lint_errors: bool,
+}
+
+// ---------------------------------------------------------------------------
+// What the caller may carry out
+// ---------------------------------------------------------------------------
+
+/// Where a skill lives: its folder, and the root that folder must stay inside.
+#[derive(Debug, Clone)]
+pub struct SkillLocation {
+ pub dir: PathBuf,
+ pub root: PathBuf,
+}
+
+/// The two things a full export reaches outside the app, answered by the door
+/// that asked. `biorouter-mcp` cannot depend on `biorouter`, so the HTTP route
+/// plugs in its own reach decision (`HttpCaller`) and the daemon's skill
+/// catalog, while the `export_app` tool uses [`DrafterPayload`].
+pub trait PayloadAccess {
+ /// `Ok` when this caller may carry knowledge base `kb_id` out of the
+ /// machine; `Err` is the sentence the export notes in its place.
+ fn reach_kb(&self, kb_id: &str) -> Result<(), String>;
+ /// The base as a `.brkb` archive. Asked only after [`Self::reach_kb`].
+ fn export_kb(&self, kb_id: &str) -> Result, String>;
+ /// The knowledge store's root, or `None` when it cannot be opened. An
+ /// export target inside it is refused.
+ fn knowledge_root(&self) -> Option;
+ /// Where skill `name` (already validated by [`is_valid_item_name`]) lives.
+ fn skill_dir(&self, name: &str) -> Option;
+ /// Whether `name` is a skill Biorouter ships, which no export carries
+ /// ([`render::SHIPPED_SKILL_NAMES`]). The HTTP door asks the seeder's own
+ /// list instead, which a test holds equal to this one.
+ fn is_shipped_skill(&self, name: &str) -> bool {
+ render::SHIPPED_SKILL_NAMES
+ .iter()
+ .any(|shipped| shipped.eq_ignore_ascii_case(name))
+ }
+}
+
+/// The `export_app` tool's [`PayloadAccess`]: the calling chat's capability
+/// (issue #56, CP4) against the knowledge store, and the installed-skills
+/// folder.
+pub struct DrafterPayload<'a> {
+ caller: &'a KbCaller,
+ knowledge: Option,
+}
+
+impl<'a> DrafterPayload<'a> {
+ pub fn new(caller: &'a KbCaller) -> Self {
+ Self {
+ caller,
+ knowledge: knowledge_service_for_export(),
+ }
+ }
+}
+
+impl PayloadAccess for DrafterPayload<'_> {
+ fn reach_kb(&self, kb_id: &str) -> Result<(), String> {
+ let Some(svc) = &self.knowledge else {
+ return Err("the knowledge store could not be opened".to_string());
+ };
+ // Issue #56 (CP4), Task 10C. `export_brkb` writes the WHOLE base into
+ // the payload, and the id can come from the model; skip-and-note rather
+ // than fail the export, matching `search_visible_bases`.
+ self.caller
+ .assert_reachable(svc.root(), kb_id)
+ .map_err(|e| e.to_string())
+ }
+
+ fn export_kb(&self, kb_id: &str) -> Result, String> {
+ let Some(svc) = &self.knowledge else {
+ return Err("the knowledge store could not be opened".to_string());
+ };
+ svc.export_brkb(kb_id).map_err(|e| e.to_string())
+ }
+
+ fn knowledge_root(&self) -> Option {
+ self.knowledge.as_ref().map(|svc| svc.root().to_path_buf())
+ }
+
+ fn skill_dir(&self, name: &str) -> Option {
+ skill_in_root(&skills_root_for_export(), name)
+ }
+}
+
+/// Resolve a `KnowledgeService` for the drafter's exports. Honours
+/// `BIOROUTER_KNOWLEDGE_DIR` (an override used by tests and power users);
+/// otherwise the canonical store the `/knowledge` routes use. `None` when the
+/// store cannot be opened, and the export then notes the bases it left out.
+pub(crate) fn knowledge_service_for_export() -> Option {
+ if let Ok(dir) = std::env::var("BIOROUTER_KNOWLEDGE_DIR") {
+ if !dir.trim().is_empty() {
+ return Some(KnowledgeService::new(PathBuf::from(dir)));
+ }
+ }
+ KnowledgeService::new_default().ok()
+}
+
+/// The installed-skills directory (`/skills`). Honours
+/// `BIOROUTER_SKILLS_DIR` as a test/override hook.
+pub fn skills_root_for_export() -> PathBuf {
+ if let Ok(dir) = std::env::var("BIOROUTER_SKILLS_DIR") {
+ if !dir.trim().is_empty() {
+ return PathBuf::from(dir);
+ }
+ }
+ crate::paths::in_config_dir("skills")
+}
+
+/// A skill or extension name as one plain path component:
+/// `^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`.
+///
+/// ⚠ This is what stops `include.skills: ["../../.ssh"]` — the join used to take
+/// any string, so a name climbed out of the skills root on both the source and
+/// the destination side, and an absolute name replaced the root outright (with
+/// source and destination then the same file, which `fs::copy` truncates).
+pub fn is_valid_item_name(name: &str) -> bool {
+ let bytes = name.as_bytes();
+ (1..=128).contains(&bytes.len())
+ && bytes[0].is_ascii_alphanumeric()
+ && bytes
+ .iter()
+ .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'))
+}
+
+/// Find skill `name` under `root`: `/`, else one bundle level down
+/// (`//`), which is how skill discovery scans a root. The
+/// direct path is returned whatever it is, so a link there is reported as a
+/// link rather than as "not installed".
+pub fn skill_in_root(root: &Path, name: &str) -> Option {
+ if !is_valid_item_name(name) {
+ return None;
+ }
+ let direct = root.join(name);
+ if std::fs::symlink_metadata(&direct).is_ok() {
+ return Some(SkillLocation {
+ dir: direct,
+ root: root.to_path_buf(),
+ });
+ }
+ let mut bundles: Vec = std::fs::read_dir(root)
+ .ok()?
+ .flatten()
+ .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
+ .map(|entry| entry.path())
+ .collect();
+ bundles.sort();
+ bundles
+ .into_iter()
+ .map(|bundle| bundle.join(name))
+ .find(|candidate| {
+ std::fs::symlink_metadata(candidate).is_ok_and(|meta| meta.is_dir())
+ && std::fs::symlink_metadata(candidate.join("SKILL.md"))
+ .is_ok_and(|meta| meta.is_file())
+ })
+ .map(|dir| SkillLocation {
+ dir,
+ root: root.to_path_buf(),
+ })
+}
+
+// ---------------------------------------------------------------------------
+// The result
+// ---------------------------------------------------------------------------
+
+/// What an export did, for the tool's summary, the HTTP report header and the
+/// desktop's toast.
+#[derive(Debug, Clone, Default, Serialize)]
+pub struct ExportReport {
+ pub id: String,
+ pub mode: &'static str,
+ /// Where the folder is, for a door that wrote one the caller chose.
+ pub target: String,
+ /// Files in the folder, and their bytes.
+ pub files: usize,
+ pub bytes: u64,
+ /// Knowledge bases, skills and external extension references the payload
+ /// carries.
+ pub kbs: Vec,
+ pub skills: Vec,
+ pub extensions: Vec,
+ /// The bundled daemon's `export.json` record, for a fat export.
+ pub daemon: Option,
+ /// Things the person should know: what was left out and why, a rename.
+ pub notes: Vec,
+ /// Harness findings that did not block the export.
+ pub lint_warnings: Vec,
+ /// App or skill files deliberately not exported, each with its reason.
+ pub skipped: Vec,
+ pub wrote_export_json: bool,
+ /// Every relative path written, sorted. For tests and the zip writer.
+ #[serde(skip)]
+ pub written: Vec,
+}
+
+impl ExportReport {
+ fn new(id: &str, mode: ExportMode) -> Self {
+ Self {
+ id: id.to_string(),
+ mode: mode.as_str(),
+ ..Default::default()
+ }
+ }
+
+ /// The report as the HTTP route sends it in a response header, without the
+ /// daemon's temporary path and capped at about 6 KB.
+ ///
+ /// ⚠ The cap is not cosmetic: Node's HTTP client refuses a response whose
+ /// headers exceed 16 KB, so a long list of skipped files would turn a good
+ /// export into a failed download. The lists are trimmed from the end, with
+ /// a line saying how many were left out.
+ pub fn header_json(&self) -> serde_json::Value {
+ const LIMIT: usize = 6_000;
+ let mut notes = self.notes.clone();
+ let mut lint = self.lint_warnings.clone();
+ let mut skipped = self.skipped.clone();
+ let render = |notes: &[String], lint: &[String], skipped: &[String]| {
+ serde_json::json!({
+ "id": self.id,
+ "mode": self.mode,
+ "files": self.files,
+ "bytes": self.bytes,
+ "kbs": self.kbs,
+ "skills": self.skills,
+ "extensions": self.extensions,
+ "daemon": self.daemon,
+ "notes": notes,
+ "lint_warnings": lint,
+ "skipped": skipped,
+ "wrote_export_json": self.wrote_export_json,
+ })
+ };
+ let mut dropped = 0usize;
+ loop {
+ let mut value = render(¬es, &lint, &skipped);
+ if value.to_string().len() <= LIMIT {
+ if dropped > 0 {
+ if let Some(list) = value["notes"].as_array_mut() {
+ list.push(serde_json::Value::String(format!(
+ "{dropped} more line(s) were left out of this summary"
+ )));
+ }
+ }
+ return value;
+ }
+ // Halve the longest list (a handful of passes, however long it
+ // is); once every list is down to one line, halve the longest line.
+ let longest = [&mut skipped, &mut lint, &mut notes]
+ .into_iter()
+ .filter(|list| !list.is_empty())
+ .max_by_key(|list| list.len());
+ match longest {
+ Some(list) if list.len() > 1 => {
+ let keep = list.len() / 2;
+ dropped += list.len() - keep;
+ list.truncate(keep);
+ }
+ Some(list) => {
+ let line = &mut list[0];
+ let mut cut = line.len() / 2;
+ while !line.is_char_boundary(cut) {
+ cut -= 1;
+ }
+ if cut == 0 {
+ list.clear();
+ dropped += 1;
+ } else {
+ line.truncate(cut);
+ line.push('…');
+ }
+ }
+ None => return render(&[], &[], &[]),
+ }
+ }
+ }
+
+ /// The `export_app` tool's answer to the model.
+ pub fn summary(&self) -> String {
+ let mut msg = format!(
+ "Exported '{}' ({} mode) to {} ({} files). To run it: double-click run.command \
+ (macOS), `bash run.sh` (Linux), or run.bat (Windows). That installs the app, \
+ starts a biorouterd if one isn't already up, and opens it in the browser, with no \
+ npm install and no build step.",
+ self.id, self.mode, self.target, self.files
+ );
+ if self.mode == ExportMode::Full.as_str() {
+ msg.push_str(&format!(
+ "\nPayload: {} knowledge base(s), {} skill(s), {} external extension \
+ reference(s).",
+ self.kbs.len(),
+ self.skills.len(),
+ self.extensions.len()
+ ));
+ }
+ if self.wrote_export_json {
+ msg.push_str(" Wrote export.json (audit manifest).");
+ }
+ for note in &self.notes {
+ msg.push_str(&format!("\n- {note}"));
+ }
+ if !self.skipped.is_empty() {
+ msg.push_str("\nNot exported:");
+ for line in self.skipped.iter().take(20) {
+ msg.push_str(&format!("\n- {line}"));
+ }
+ if self.skipped.len() > 20 {
+ msg.push_str(&format!("\n- and {} more", self.skipped.len() - 20));
+ }
+ }
+ msg
+ }
+}
+
+/// Why an export did not happen. Each carries the sentence the person reads.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum ExportError {
+ /// There is no such app (HTTP 404).
+ NotFound(String),
+ /// The request or the target is unusable (HTTP 400).
+ BadRequest(String),
+ /// The harness lint found ERRORs and nobody chose to export anyway
+ /// (HTTP 422, with the findings).
+ Lint {
+ message: String,
+ findings: Vec,
+ },
+ /// The app itself cannot be exported as it stands: its entry is missing or
+ /// unsafe, or its manifest does not load (HTTP 409).
+ Unexportable(String),
+ /// Reading the app or writing the folder failed (HTTP 500).
+ Io(String),
+}
+
+impl std::fmt::Display for ExportError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::NotFound(m) | Self::BadRequest(m) | Self::Unexportable(m) | Self::Io(m) => {
+ f.write_str(m)
+ }
+ Self::Lint { message, .. } => f.write_str(message),
+ }
+ }
+}
+
+impl std::error::Error for ExportError {}
+
+fn io_error(what: &str, error: impl std::fmt::Display) -> ExportError {
+ ExportError::Io(format!("{what}: {error}"))
+}
+
+// ---------------------------------------------------------------------------
+// Entry points
+// ---------------------------------------------------------------------------
+
+/// Build the export of app `id` into `out`, a folder that must not exist yet
+/// and whose parent must. For a door that owns `out` — the HTTP route builds into
+/// a temporary directory of its own and zips it.
+pub fn export_to_staging(
+ store_root: &Path,
+ id: &str,
+ request: &ExportRequest,
+ access: &dyn PayloadAccess,
+ out: &Path,
+) -> Result {
+ build_export(store_root, id, request, access, out)
+}
+
+/// Export app `id` into `target`, a folder a model named.
+///
+/// The target is refused unless it is absolute, outside the Biorouter config
+/// folder, the app store and the knowledge store, and a folder of the person's
+/// own ([`placement_refusal`]: below Desktop, Documents or Downloads, never
+/// hidden or a folder code is imported from); and unless it does not exist,
+/// or is empty. With `replace`, a
+/// non-empty target is accepted only when it is an earlier export of this same
+/// app (its `manifest.json` names `id` and it has `biorouter-launch.sh`) that
+/// holds nothing the export does not write ([`foreign_entries`]).
+///
+/// ⚠ The folder is built beside the target and renamed into place, so a failed
+/// export leaves nothing half-written, a replaced export is swapped whole (no
+/// stale `payload/` or deleted `src/` files survive), and nothing is ever
+/// written through a link that was already in the target. The replaced export
+/// is kept beside it, renamed `.replaced-`, and the report names it.
+pub fn export_to_dir(
+ store_root: &Path,
+ id: &str,
+ request: &ExportRequest,
+ access: &dyn PayloadAccess,
+ target: &Path,
+ replace: bool,
+) -> Result {
+ validate_artifact_id(id)
+ .map_err(|_| ExportError::BadRequest(format!("'{id}' is not an app id.")))?;
+ let state = validate_target(target, id, store_root, access, replace)?;
+ let (Some(parent), Some(name)) = (target.parent(), target.file_name()) else {
+ return Err(ExportError::BadRequest(
+ "target_dir must name a folder, not a filesystem root.".to_string(),
+ ));
+ };
+ std::fs::create_dir_all(parent)
+ .map_err(|e| io_error("could not create the target's parent", e))?;
+ let name = name.to_string_lossy();
+ let nonce = uuid::Uuid::new_v4().simple().to_string();
+ let staging = parent.join(format!(".{name}.partial-{nonce}"));
+ let mut report = match build_export(store_root, id, request, access, &staging) {
+ Ok(report) => report,
+ Err(error) => {
+ let _ = std::fs::remove_dir_all(&staging);
+ return Err(error);
+ }
+ };
+ let placed = match state {
+ TargetState::Absent => std::fs::rename(&staging, target),
+ TargetState::Empty => {
+ std::fs::remove_dir(target).and_then(|()| std::fs::rename(&staging, target))
+ }
+ // ⚠ The earlier export is KEPT, beside the new one under a name that
+ // says what it is, and never deleted: the README invites editing an
+ // export where it is, so it may hold changes made nowhere else. It was
+ // `remove_dir_all`ed, with no question asked and nothing to restore.
+ TargetState::EarlierExport => {
+ let stamp = chrono::Local::now().format("%Y%m%d-%H%M%S");
+ let mut aside = parent.join(format!("{name}.replaced-{stamp}"));
+ if std::fs::symlink_metadata(&aside).is_ok() {
+ let short: String = nonce.chars().take(8).collect();
+ aside = parent.join(format!("{name}.replaced-{stamp}-{short}"));
+ }
+ match std::fs::rename(target, &aside) {
+ Err(error) => Err(error),
+ Ok(()) => match std::fs::rename(&staging, target) {
+ Ok(()) => {
+ report.notes.push(format!(
+ "the earlier export is kept, unchanged, at {}; delete it once you no \
+ longer need it",
+ aside.display()
+ ));
+ Ok(())
+ }
+ Err(error) => {
+ let _ = std::fs::rename(&aside, target);
+ Err(error)
+ }
+ },
+ }
+ }
+ };
+ if let Err(error) = placed {
+ let _ = std::fs::remove_dir_all(&staging);
+ return Err(io_error(
+ &format!("could not move the export into {}", target.display()),
+ error,
+ ));
+ }
+ report.target = target.display().to_string();
+ Ok(report)
+}
+
+/// The launcher-only text file map the legacy `GET /apps/{id}/export` serves to
+/// a desktop that predates `POST`. It carries the same raw entry and stripped
+/// manifest as every other export; the files it cannot carry (binary ones, which
+/// a JSON string map cannot hold) are listed in `skipped_binary` instead of
+/// being dropped without a word.
+#[derive(Debug, Clone, Default)]
+pub struct LegacyExport {
+ pub files: Vec<(String, String)>,
+ pub skipped_binary: Vec,
+ pub skipped: Vec,
+}
+
+pub fn legacy_export_map(store_root: &Path, id: &str) -> Result {
+ let (store, manifest, app_dir, mut report) = prepare(store_root, id, ExportMode::Launcher)?;
+ let collected = collect_app_files(&app_dir, &manifest, &mut report)?;
+ let mut out = LegacyExport {
+ files: vec![(
+ "manifest.json".to_string(),
+ render::exported_manifest_json(&manifest),
+ )],
+ ..Default::default()
+ };
+ let digest = render::runtime_digest(&app_dir, &manifest.entry);
+ out.files
+ .extend(render::launcher_files(&manifest, digest.as_deref()));
+ let mut push = |rel: String, src: &Path| match std::fs::read(src) {
+ Ok(bytes) => match String::from_utf8(bytes) {
+ Ok(text) => out.files.push((rel, text)),
+ Err(_) => out.skipped_binary.push(rel),
+ },
+ Err(_) => out.skipped.push(format!("{rel}: could not be read")),
+ };
+ for file in &collected {
+ push(export_path_for(&file.rel), &file.src);
+ }
+ for (rel, src) in built_bundle(&store, id) {
+ push(rel, &src);
+ }
+ out.skipped.extend(report.skipped);
+ Ok(out)
+}
+
+/// [`legacy_export_map`]'s files, for callers that only want the map.
+pub fn export_scaffold(
+ root: &Path,
+ id: &str,
+ _endpoint: Option<&str>,
+) -> io::Result> {
+ legacy_export_map(root, id)
+ .map(|export| export.files)
+ .map_err(|error| match error {
+ ExportError::NotFound(m) => io::Error::new(io::ErrorKind::NotFound, m),
+ other => io::Error::other(other.to_string()),
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Building the folder
+// ---------------------------------------------------------------------------
+
+/// Validate the id, load the app, lint-free or not, and freshen its bundle.
+fn prepare(
+ store_root: &Path,
+ id: &str,
+ mode: ExportMode,
+) -> Result<(ArtifactStore, Manifest, PathBuf, ExportReport), ExportError> {
+ validate_artifact_id(id)
+ .map_err(|_| ExportError::BadRequest(format!("'{id}' is not an app id.")))?;
+ let store = ArtifactStore::new(store_root.to_path_buf());
+ if !store.exists(id) {
+ return Err(ExportError::NotFound(format!("There is no app '{id}'.")));
+ }
+ let mut manifest = store.load_manifest(id).map_err(|e| {
+ ExportError::Unexportable(format!("The manifest of '{id}' could not be read: {e}"))
+ })?;
+ let app_dir = open_app_dir(&store, id)?;
+ let mut report = ExportReport::new(id, mode);
+ // Ship a bundle built from the CURRENT sources and SDK, not a stale one the
+ // app happens to have on disk: an export is the copy that leaves the
+ // machine. The same locked check serving a page makes, so an export never
+ // races a page load's build, and a build that failed on these very inputs
+ // is not run again.
+ match super::ensure_bundle_fresh(&store, id) {
+ Ok(check) if check.build.as_ref().is_some_and(|build| !build.ok) => {
+ report.notes.push(if check.has_bundle {
+ "the app's bundle could not be rebuilt; the export carries the last one that \
+ built, and the daemon that opens it tries again"
+ .to_string()
+ } else {
+ "the app's bundle could not be built; the daemon that opens it tries again"
+ .to_string()
+ })
+ }
+ Ok(_) => {}
+ Err(error) => report
+ .notes
+ .push(format!("the app's bundle could not be checked ({error})")),
+ }
+ if let Ok(fresh) = store.load_manifest(id) {
+ manifest = fresh;
+ }
+ validate_entry(&manifest.entry).map_err(ExportError::Unexportable)?;
+ Ok((store, manifest, app_dir, report))
+}
+
+fn build_export(
+ store_root: &Path,
+ id: &str,
+ request: &ExportRequest,
+ access: &dyn PayloadAccess,
+ out: &Path,
+) -> Result {
+ validate_artifact_id(id)
+ .map_err(|_| ExportError::BadRequest(format!("'{id}' is not an app id.")))?;
+ let store = ArtifactStore::new(store_root.to_path_buf());
+ if !store.exists(id) {
+ return Err(ExportError::NotFound(format!("There is no app '{id}'.")));
+ }
+ // The lint runs before anything is built or written: an app the harness
+ // refuses is not exported unless a person chose to export it anyway.
+ let app_dir = open_app_dir(&store, id)?;
+ let lint = bundle::lint_app(&app_dir);
+ let errors: Vec = lint
+ .iter()
+ .filter(|f| f.level == bundle::LintLevel::Error)
+ .map(|f| f.msg.clone())
+ .collect();
+ if !errors.is_empty() && !request.allow_lint_errors {
+ return Err(ExportError::Lint {
+ message: format!(
+ "harness ERRORs block export for '{id}':\n{}",
+ bundle::format_lint(&lint)
+ ),
+ findings: errors,
+ });
+ }
+
+ let (store, manifest, app_dir, mut report) = prepare(store_root, id, request.mode)?;
+ for finding in &lint {
+ report.lint_warnings.push(match finding.level {
+ bundle::LintLevel::Error => format!("ERROR: {}", finding.msg),
+ bundle::LintLevel::Warn => finding.msg.clone(),
+ });
+ }
+ if !errors.is_empty() {
+ report.notes.push(format!(
+ "exported despite {} harness error(s); the app may not work until they are fixed",
+ errors.len()
+ ));
+ }
+
+ let collected = collect_app_files(&app_dir, &manifest, &mut report)?;
+ std::fs::create_dir(out).map_err(|e| io_error("could not create the export folder", e))?;
+
+ for file in &collected {
+ let rel = export_path_for(&file.rel);
+ if rel != file.rel {
+ report.notes.push(format!(
+ "the app's own '{}' is in {rel}: the export writes its own file by that name",
+ file.rel
+ ));
+ }
+ let dst = join_rel(out, &rel);
+ if let Some(parent) = dst.parent() {
+ std::fs::create_dir_all(parent)
+ .map_err(|e| io_error("could not write the export", e))?;
+ }
+ std::fs::copy(&file.src, &dst)
+ .map_err(|e| io_error(&format!("could not copy {}", file.rel), e))?;
+ }
+ let bundle = built_bundle(&store, id);
+ for (rel, src) in &bundle {
+ std::fs::create_dir_all(out.join("dist"))
+ .and_then(|()| std::fs::copy(src, join_rel(out, rel)).map(|_| ()))
+ .map_err(|e| io_error(&format!("could not copy {rel}"), e))?;
+ }
+ if bundle.is_empty() && manifest.kind == ArtifactKind::Agentic {
+ report.notes.push(
+ "the app has no built bundle yet; the daemon that opens it builds one".to_string(),
+ );
+ }
+
+ write_file(
+ out,
+ "manifest.json",
+ &render::exported_manifest_json(&manifest),
+ )?;
+ // What the store held as it was exported, so the folder's launchers can
+ // tell an untouched install (re-synced) from one changed where it is
+ // installed (left alone).
+ let digest = render::runtime_digest(&app_dir, &manifest.entry);
+ for (rel, content) in render::launcher_files(&manifest, digest.as_deref()) {
+ write_file(out, &rel, &content)?;
+ if render::EXECUTABLE_EXPORT_FILES.contains(&rel.as_str()) {
+ make_executable(&join_rel(out, &rel));
+ }
+ }
+
+ let staged = if request.mode == ExportMode::Full {
+ stage_payload(&manifest, &request.include, access, out, &mut report)
+ } else {
+ Staged::default()
+ };
+ if request.bundle_daemon {
+ let (record, note) = stage_current_daemon(out);
+ report.notes.push(note);
+ report.daemon = record;
+ }
+ // export.json travels whenever the folder carries a payload: full mode, or
+ // a bundled daemon in launcher mode (a "fat launcher").
+ if request.mode == ExportMode::Full || report.daemon.is_some() {
+ let json = build_export_json(id, request.mode, &staged, report.daemon.as_ref());
+ write_file(
+ out,
+ "export.json",
+ &serde_json::to_string_pretty(&json).unwrap_or_default(),
+ )?;
+ report.wrote_export_json = true;
+ }
+
+ tally(out, out, &mut report).map_err(|e| io_error("could not read back the export", e))?;
+ report.written.sort();
+ Ok(report)
+}
+
+/// The app's folder, as the store hands it out. The store refuses an app whose
+/// tree holds a link or a hard-linked file (it serves no such app either), and
+/// that refusal is the app's state, not an I/O failure, so it is answered with
+/// a sentence the person can act on rather than as a server error.
+fn open_app_dir(store: &ArtifactStore, id: &str) -> Result {
+ store.artifact_dir(id).map_err(|error| {
+ if error.kind() == io::ErrorKind::InvalidInput {
+ ExportError::Unexportable(format!(
+ "'{id}' cannot be exported: {error}. Remove the link from the app's folder \
+ and export again."
+ ))
+ } else {
+ io_error("could not open the app", error)
+ }
+ })
+}
+
+/// The authored entry must be a plain relative path that no generated file or
+/// reserved folder shadows.
+fn validate_entry(entry: &str) -> Result<(), String> {
+ let path = Path::new(entry);
+ let plain = !entry.is_empty()
+ && !entry.contains('\\')
+ && !path.is_absolute()
+ && path.components().all(|c| matches!(c, Component::Normal(_)));
+ let top = entry.split('/').next().unwrap_or_default();
+ if !plain
+ || render::GENERATED_EXPORT_FILES.contains(&entry)
+ || render::RESERVED_EXPORT_DIRS.contains(&top)
+ || matches!(top, "workspace" | "dist" | "src")
+ {
+ return Err(format!(
+ "The app's entry file '{entry}' cannot be exported: it must be an HTML file in the \
+ app's own folder, such as index.html."
+ ));
+ }
+ Ok(())
+}
+
+struct Collected {
+ rel: String,
+ src: PathBuf,
+}
+
+/// Every authored file of the app, except what never leaves the machine. Each
+/// deliberate omission is recorded in `report.skipped` with its reason.
+fn collect_app_files(
+ app_dir: &Path,
+ manifest: &Manifest,
+ report: &mut ExportReport,
+) -> Result, ExportError> {
+ let mut out = Vec::new();
+ walk_app(app_dir, app_dir, 0, &mut out, report)
+ .map_err(|e| io_error("could not read the app's files", e))?;
+ if out.len() > MAX_APP_FILES {
+ return Err(ExportError::Unexportable(format!(
+ "The app has more than {MAX_APP_FILES} files; export the ones it needs from its \
+ folder by hand."
+ )));
+ }
+ if !out.iter().any(|file| file.rel == manifest.entry) {
+ return Err(ExportError::Unexportable(format!(
+ "The app's entry file '{}' is missing or is a link.",
+ manifest.entry
+ )));
+ }
+ out.sort_by(|a, b| a.rel.cmp(&b.rel));
+ Ok(out)
+}
+
+fn walk_app(
+ base: &Path,
+ dir: &Path,
+ depth: usize,
+ out: &mut Vec,
+ report: &mut ExportReport,
+) -> io::Result<()> {
+ let mut entries: Vec = std::fs::read_dir(dir)?.flatten().collect();
+ entries.sort_by_key(|entry| entry.file_name());
+ for entry in entries {
+ let path = entry.path();
+ let Ok(rel) = path.strip_prefix(base) else {
+ continue;
+ };
+ let rel = rel.to_string_lossy().replace('\\', "/");
+ let name = entry.file_name().to_string_lossy().to_string();
+ // `DirEntry::file_type` does not follow links, so a link is seen as one.
+ let kind = entry.file_type()?;
+ if kind.is_symlink() {
+ report
+ .skipped
+ .push(format!("{rel}: a link, never followed"));
+ continue;
+ }
+ if kind.is_dir() {
+ if depth == 0 && name == "workspace" {
+ report
+ .skipped
+ .push("workspace/: the app's runtime data stays on this machine".to_string());
+ continue;
+ }
+ // The bundle travels on its own (`built_bundle`): only what the
+ // build writes, never a stale chunk an older build left beside it.
+ if depth == 0 && name == "dist" {
+ continue;
+ }
+ if EXCLUDED_COMPONENTS.contains(&name.as_str()) {
+ report.skipped.push(format!("{rel}/: never exported"));
+ continue;
+ }
+ if depth + 1 > MAX_DEPTH {
+ report.skipped.push(format!("{rel}/: nested too deeply"));
+ continue;
+ }
+ walk_app(base, &path, depth + 1, out, report)?;
+ } else if kind.is_file() {
+ // Re-emitted from the parsed manifest, without the author's chat id.
+ if depth == 0 && name == "manifest.json" {
+ continue;
+ }
+ if looks_like_credential(&name) {
+ report
+ .skipped
+ .push(format!("{rel}: looks like a credential file"));
+ continue;
+ }
+ out.push(Collected { rel, src: path });
+ if out.len() > MAX_APP_FILES {
+ return Ok(());
+ }
+ } else {
+ report.skipped.push(format!("{rel}: not a regular file"));
+ }
+ }
+ Ok(())
+}
+
+/// File names that hold credentials often enough that an export never carries
+/// them: dotenv files, private keys and certificates, and package-manager and
+/// cloud credential files.
+fn looks_like_credential(name: &str) -> bool {
+ let lower = name.to_ascii_lowercase();
+ lower == ".env"
+ || lower.starts_with(".env.")
+ || [".pem", ".key", ".p12", ".pfx"]
+ .iter()
+ .any(|ext| lower.ends_with(ext))
+ || ["id_rsa", "id_dsa", "id_ecdsa", "id_ed25519"]
+ .iter()
+ .any(|key| lower.starts_with(key))
+ || matches!(
+ lower.as_str(),
+ ".netrc" | ".npmrc" | ".pypirc" | "credentials"
+ )
+}
+
+/// Where an authored file goes in the export: its own path, unless that path is
+/// one the export writes itself, in which case under `app-files/`.
+fn export_path_for(rel: &str) -> String {
+ let top = rel.split('/').next().unwrap_or_default();
+ let reserved = (rel == top && render::GENERATED_EXPORT_FILES.contains(&rel))
+ || render::RESERVED_EXPORT_DIRS.contains(&top);
+ if reserved {
+ format!("app-files/{rel}")
+ } else {
+ rel.to_string()
+ }
+}
+
+/// What the build writes: `dist/app.js`, and `dist/app.css` when the app's
+/// source imports a stylesheet (esbuild writes it beside the script). Each is
+/// shipped, for any kind of app, when it is a regular file.
+const BUNDLE_OUTPUTS: &[&str] = &["app.js", "app.css"];
+
+fn built_bundle(store: &ArtifactStore, id: &str) -> Vec<(String, PathBuf)> {
+ let Ok(dist) = store.artifact_dir(id).map(|dir| dir.join("dist")) else {
+ return Vec::new();
+ };
+ BUNDLE_OUTPUTS
+ .iter()
+ .map(|name| (format!("dist/{name}"), dist.join(name)))
+ .filter(|(_, path)| std::fs::symlink_metadata(path).is_ok_and(|meta| meta.is_file()))
+ .collect()
+}
+
+fn join_rel(base: &Path, rel: &str) -> PathBuf {
+ rel.split('/')
+ .fold(base.to_path_buf(), |path, part| path.join(part))
+}
+
+fn write_file(out: &Path, rel: &str, content: &str) -> Result<(), ExportError> {
+ let path = join_rel(out, rel);
+ if let Some(parent) = path.parent() {
+ std::fs::create_dir_all(parent).map_err(|e| io_error("could not write the export", e))?;
+ }
+ std::fs::write(&path, content).map_err(|e| io_error(&format!("could not write {rel}"), e))
+}
+
+/// Give a written export file the exec bit (owner+group+other rx, owner w).
+/// No-op on Windows, where executability isn't a file mode.
+pub(crate) fn make_executable(path: &Path) {
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
+ }
+ #[cfg(not(unix))]
+ let _ = path;
+}
+
+/// Whether the export path `rel` must be executable: the launchers, and a
+/// bundled daemon and compiler. A writer that cannot read file modes (a zip on Windows, a
+/// legacy file map) asks this.
+pub fn is_executable_export_path(rel: &str) -> bool {
+ render::EXECUTABLE_EXPORT_FILES.contains(&rel)
+ || rel == "payload/bin/biorouterd"
+ || rel == "payload/bin/biorouterd.exe"
+ || rel == "payload/bin/esbuild"
+ || rel == "payload/bin/esbuild.exe"
+}
+
+/// Count what was written: files, bytes and relative paths.
+fn tally(base: &Path, dir: &Path, report: &mut ExportReport) -> io::Result<()> {
+ for entry in std::fs::read_dir(dir)? {
+ let entry = entry?;
+ let kind = entry.file_type()?;
+ if kind.is_dir() {
+ tally(base, &entry.path(), report)?;
+ } else if kind.is_file() {
+ report.files += 1;
+ report.bytes += entry.metadata()?.len();
+ if let Ok(rel) = entry.path().strip_prefix(base) {
+ report
+ .written
+ .push(rel.to_string_lossy().replace('\\', "/"));
+ }
+ }
+ }
+ Ok(())
+}
+
+// ---------------------------------------------------------------------------
+// The target a model named
+// ---------------------------------------------------------------------------
+
+enum TargetState {
+ Absent,
+ Empty,
+ EarlierExport,
+}
+
+fn validate_target(
+ target: &Path,
+ id: &str,
+ store_root: &Path,
+ access: &dyn PayloadAccess,
+ replace: bool,
+) -> Result {
+ let refuse = |why: String| Err(ExportError::BadRequest(why));
+ if !target.is_absolute() {
+ return refuse(format!(
+ "target_dir must be an absolute path, such as /Users/you/Desktop/{id}."
+ ));
+ }
+ if target
+ .components()
+ .any(|c| matches!(c, Component::ParentDir | Component::CurDir))
+ || target.file_name().is_none()
+ {
+ return refuse("target_dir must name a folder plainly, without '..'.".to_string());
+ }
+ // Decide on where the target REALLY is: a link anywhere above it is
+ // followed here, once, the way the writes would follow it.
+ let resolved = resolve_existing_prefix(target);
+ let mut protected = vec![
+ (
+ crate::paths::config_dir(),
+ "Biorouter's configuration folder",
+ ),
+ (store_root.to_path_buf(), "the app store"),
+ ];
+ if let Some(knowledge) = access.knowledge_root() {
+ protected.push((knowledge, "the knowledge store"));
+ }
+ for (root, what) in protected {
+ let root = resolve_existing_prefix(&root);
+ if resolved.starts_with(&root) || root.starts_with(&resolved) {
+ return refuse(format!(
+ "target_dir {} is inside or contains {what}; export somewhere else, such as \
+ your Desktop.",
+ target.display()
+ ));
+ }
+ }
+ let home = etcetera::home_dir()
+ .ok()
+ .map(|home| resolve_existing_prefix(&home));
+ // A unit test's folders live under the system temp folder; nothing else's
+ // may (a temp folder is shared, and nobody looks there for an export).
+ let test_roots: Vec = if cfg!(test) {
+ vec![resolve_existing_prefix(&std::env::temp_dir())]
+ } else {
+ Vec::new()
+ };
+ if let Some(why) = placement_refusal(&resolved, home.as_deref(), &test_roots) {
+ return refuse(format!("target_dir {} {why}", target.display()));
+ }
+ match std::fs::symlink_metadata(target) {
+ Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(TargetState::Absent),
+ Err(error) => Err(io_error("could not inspect target_dir", error)),
+ Ok(meta) if meta.file_type().is_symlink() => refuse(format!(
+ "target_dir {} is a link; name the real folder.",
+ target.display()
+ )),
+ Ok(meta) if !meta.is_dir() => refuse(format!(
+ "target_dir {} is a file, not a folder.",
+ target.display()
+ )),
+ Ok(_) => {
+ let empty = std::fs::read_dir(target)
+ .map_err(|e| io_error("could not read target_dir", e))?
+ .next()
+ .is_none();
+ if empty {
+ Ok(TargetState::Empty)
+ } else if !replace {
+ refuse(format!(
+ "target_dir {} is not empty. Pick a new folder, or pass replace: true to \
+ replace an earlier export of this same app.",
+ target.display()
+ ))
+ } else if is_export_of(target, id) {
+ // The README invites editing an export where it is, so one
+ // that holds anything the export does not write (a `.git`,
+ // `node_modules`, a file of the person's own) has become their
+ // project, and is never replaced.
+ let foreign = foreign_entries(target, &store_root.join(id));
+ if foreign.is_empty() {
+ Ok(TargetState::EarlierExport)
+ } else {
+ let shown: Vec<&str> = foreign.iter().take(5).map(String::as_str).collect();
+ let more = foreign.len().saturating_sub(shown.len());
+ refuse(format!(
+ "target_dir {} is an earlier export of '{id}' that now also holds {}{}, \
+ which this export does not write: it has been used or changed since, \
+ so it is not replaced. Pick a new folder.",
+ target.display(),
+ shown.join(", "),
+ if more > 0 {
+ format!(" and {more} more")
+ } else {
+ String::new()
+ }
+ ))
+ }
+ } else {
+ refuse(format!(
+ "target_dir {} is not an earlier export of '{id}', so it is never replaced. \
+ Pick a new folder.",
+ target.display()
+ ))
+ }
+ }
+ }
+}
+
+/// Content roots a model may choose for an export. The desktop folder picker
+/// is the person's choice and is not subject to this tool-only allow list.
+const EXPORT_CONTENT_DIRS: &[&str] = &["Desktop", "Documents", "Downloads"];
+
+/// Folders code is loaded from by name, wherever they sit: a new package in
+/// one is imported by whatever asks for that name.
+const CODE_LOAD_DIRS: &[&str] = &["node_modules", "site-packages", "dist-packages"];
+
+/// Why a model may not export to `resolved` (already resolved through any
+/// link), or `None` when it may: below Desktop, Documents or Downloads, never
+/// a hidden folder or a folder code is imported from. The rest
+/// of the sentence after "target_dir ".
+///
+/// ⚠ This is the `export_app` tool's rule only. A chat with Agent Drafter and
+/// no developer shell could otherwise write launcher scripts and the app's
+/// files into a new folder anywhere the account can write, such as a Python
+/// `site-packages` under Homebrew. The desktop's Export writes where the
+/// PERSON picked, and needs no such rule.
+fn placement_refusal(
+ resolved: &Path,
+ home: Option<&Path>,
+ test_roots: &[PathBuf],
+) -> Option {
+ let named = |component: &Component, list: &[&str]| match component {
+ Component::Normal(name) => {
+ let name = name.to_string_lossy();
+ list.iter().any(|entry| name.eq_ignore_ascii_case(entry))
+ }
+ _ => false,
+ };
+ let hidden = |component: &Component| matches!(component, Component::Normal(name) if name.to_string_lossy().starts_with('.'));
+ if resolved.components().any(|c| named(&c, CODE_LOAD_DIRS)) {
+ return Some("is inside a folder code is imported from.".to_string());
+ }
+ let content_roots: Vec = home
+ .into_iter()
+ .flat_map(|home| EXPORT_CONTENT_DIRS.iter().map(move |name| home.join(name)))
+ .map(|root| resolve_existing_prefix(&root))
+ .collect();
+ let rest: Vec = if let Some((root, rest)) = content_roots
+ .iter()
+ .find_map(|root| resolved.strip_prefix(root).ok().map(|rest| (root, rest)))
+ {
+ let root_relative = home
+ .and_then(|home| root.strip_prefix(home).ok())
+ .unwrap_or(root);
+ if root_relative.components().any(|c| hidden(&c)) {
+ return Some("is inside a hidden folder; pick another export location.".to_string());
+ }
+ if rest.as_os_str().is_empty() {
+ return Some("is a content folder itself; name a new folder inside it.".to_string());
+ }
+ rest.components().collect()
+ } else if let Some(rest) = test_roots
+ .iter()
+ .find_map(|root| resolved.strip_prefix(root).ok())
+ {
+ // Temp folders are hidden names; only the code rule applies here.
+ return rest
+ .components()
+ .any(|c| named(&c, CODE_LOAD_DIRS))
+ .then(|| "is inside a folder code is imported from.".to_string());
+ } else {
+ return Some(
+ "is outside the export locations allowed for the tool. Export into a new folder \
+ in Desktop, Documents or Downloads; to put an app anywhere else, use Export in Biorouter's \
+ Applications view, where you pick the folder."
+ .to_string(),
+ );
+ };
+ if rest.iter().any(hidden) {
+ return Some(
+ "is inside a hidden folder, where settings and credentials live; export somewhere \
+ else, such as your Desktop."
+ .to_string(),
+ );
+ }
+ if rest.iter().any(|c| named(c, CODE_LOAD_DIRS)) {
+ return Some(
+ "is inside a folder code is imported from (node_modules, site-packages); export \
+ somewhere else, such as your Desktop."
+ .to_string(),
+ );
+ }
+ None
+}
+
+/// Names an earlier export may hold at its top level without having become
+/// someone's project: what every export writes, the app's own top-level files
+/// and folders, and the files a file manager leaves behind.
+fn foreign_entries(earlier: &Path, app_dir: &Path) -> Vec {
+ let mut known: BTreeSet = render::GENERATED_EXPORT_FILES
+ .iter()
+ .chain(render::RESERVED_EXPORT_DIRS)
+ .chain(&["src", "dist", "assets"])
+ .map(|name| name.to_string())
+ .collect();
+ if let Ok(entries) = std::fs::read_dir(app_dir) {
+ known.extend(
+ entries
+ .flatten()
+ .map(|entry| entry.file_name().to_string_lossy().to_string())
+ .filter(|name| !matches!(name.as_str(), "workspace" | ".git" | "node_modules")),
+ );
+ }
+ let mut foreign: Vec = std::fs::read_dir(earlier)
+ .map(|entries| {
+ entries
+ .flatten()
+ .map(|entry| entry.file_name().to_string_lossy().to_string())
+ .filter(|name| {
+ !known.contains(name)
+ && !matches!(name.as_str(), ".DS_Store" | "Thumbs.db" | "desktop.ini")
+ && !name.starts_with("._")
+ })
+ .collect()
+ })
+ .unwrap_or_default();
+ foreign.sort();
+ foreign
+}
+
+/// `path` with its longest existing prefix canonicalized, so a link above the
+/// target is resolved once and the rest of the path is kept as written.
+fn resolve_existing_prefix(path: &Path) -> PathBuf {
+ let mut existing = path.to_path_buf();
+ let mut rest: Vec = Vec::new();
+ loop {
+ if let Ok(real) = existing.canonicalize() {
+ return rest.iter().rev().fold(real, |path, part| path.join(part));
+ }
+ match (existing.file_name(), existing.parent()) {
+ (Some(name), Some(parent)) => {
+ rest.push(name.to_os_string());
+ existing = parent.to_path_buf();
+ }
+ _ => return path.to_path_buf(),
+ }
+ }
+}
+
+/// Is `dir` an export of app `id`: a `manifest.json` naming it, beside the
+/// launcher library every export writes?
+fn is_export_of(dir: &Path, id: &str) -> bool {
+ let is_file = |p: PathBuf| std::fs::symlink_metadata(p).is_ok_and(|meta| meta.is_file());
+ if !is_file(dir.join("biorouter-launch.sh")) || !is_file(dir.join("manifest.json")) {
+ return false;
+ }
+ std::fs::read_to_string(dir.join("manifest.json"))
+ .ok()
+ .and_then(|text| serde_json::from_str::(&text).ok())
+ .is_some_and(|manifest| manifest["id"].as_str() == Some(id))
+}
+
+// ---------------------------------------------------------------------------
+// Full-export payload (design §3.9)
+// ---------------------------------------------------------------------------
+
+/// Every knowledge base the app reaches: its agent's `knowledge_base`, each
+/// `knowledge` data source's ids, and its memory and shared-memory bases.
+/// Sorted and de-duplicated. A full export with no `knowledge_bases` key
+/// carries exactly these, and the desktop's dialog lists exactly these.
+pub fn referenced_knowledge_bases(manifest: &Manifest) -> Vec {
+ let mut ids = BTreeSet::new();
+ if let Some(agent) = &manifest.agent {
+ ids.extend(agent.knowledge_base.iter().cloned());
+ if let Some(data) = &agent.capabilities.data {
+ for source in data.sources.iter().filter(|s| s.kind == "knowledge") {
+ ids.extend(source.ids.iter().cloned());
+ }
+ }
+ let memory = &agent.capabilities.memory;
+ ids.extend(memory.kb.iter().cloned());
+ ids.extend(memory.shared_kb.iter().cloned());
+ }
+ ids.into_iter().filter(|id| !id.trim().is_empty()).collect()
+}
+
+/// What `export.json` records about the payload.
+#[derive(Default)]
+struct Staged {
+ /// `{id, file, bytes}` per staged `.brkb`.
+ knowledge_bases: Vec,
+ /// `{name, path}` per staged skill directory.
+ skills: Vec,
+ /// `{name, source:"registry", note}` per external extension.
+ extensions: Vec,
+}
+
+fn sorted_unique(mut items: Vec) -> Vec {
+ items.sort();
+ items.dedup();
+ items
+}
+
+fn stage_payload(
+ manifest: &Manifest,
+ include: &ExportSelection,
+ access: &dyn PayloadAccess,
+ out: &Path,
+ report: &mut ExportReport,
+) -> Staged {
+ let agent = manifest.agent.clone().unwrap_or_default();
+ let kb_ids = sorted_unique(
+ include
+ .knowledge_bases
+ .clone()
+ .unwrap_or_else(|| referenced_knowledge_bases(manifest)),
+ );
+ let skill_names = sorted_unique(
+ include
+ .skills
+ .clone()
+ .unwrap_or_else(|| agent.skills.clone()),
+ );
+ let ext_names = sorted_unique(
+ include
+ .extensions
+ .clone()
+ .unwrap_or_else(|| agent.extensions.clone()),
+ );
+ let payload = out.join("payload");
+ let mut staged = Staged::default();
+
+ // ── Knowledge bases → payload/knowledge/.brkb ──────────────────────
+ if !kb_ids.is_empty() && access.knowledge_root().is_none() {
+ report.notes.push(format!(
+ "the knowledge store could not be opened; left out {} knowledge base(s)",
+ kb_ids.len()
+ ));
+ } else {
+ for kb in &kb_ids {
+ if crate::knowledge::paths::validate_kb_id(kb).is_err() {
+ report
+ .notes
+ .push(format!("'{kb}' is not a knowledge base id; skipped"));
+ continue;
+ }
+ // Issue #56: the caller's reach, asked per base, before any byte of
+ // it is read. One base out of reach costs the export only that base.
+ if let Err(why) = access.reach_kb(kb) {
+ report
+ .notes
+ .push(format!("skipped knowledge base '{kb}': {why}"));
+ continue;
+ }
+ match access.export_kb(kb) {
+ Ok(bytes) => {
+ let file = format!("{kb}.brkb");
+ let kdir = payload.join("knowledge");
+ let written = std::fs::create_dir_all(&kdir)
+ .and_then(|()| std::fs::write(kdir.join(&file), &bytes));
+ match written {
+ Ok(()) => {
+ staged.knowledge_bases.push(serde_json::json!({
+ "id": kb,
+ "file": format!("payload/knowledge/{file}"),
+ "bytes": bytes.len(),
+ }));
+ report.kbs.push(kb.clone());
+ }
+ Err(error) => report.notes.push(format!(
+ "could not write knowledge base '{kb}' into the payload ({error}); \
+ skipped"
+ )),
+ }
+ }
+ Err(why) => report
+ .notes
+ .push(format!("skipped knowledge base '{kb}': {why}")),
+ }
+ }
+ }
+
+ // ── Skills → payload/skills// (a plain directory copy) ───────────
+ // Never one Biorouter ships: the recipient's install seeds its own (maybe
+ // newer) copy, and the launcher installing the author's over it under the
+ // seeded name is the shadowing `skill_package::refuse_shipped` refuses.
+ for name in &skill_names {
+ if access.is_shipped_skill(name) {
+ report.notes.push(format!(
+ "skill '{name}' ships with Biorouter, so the recipient already has it; not \
+ carried"
+ ));
+ continue;
+ }
+ if let Some(record) = stage_skill(access, name, &payload.join("skills"), report) {
+ staged.skills.push(record);
+ report.skills.push(name.clone());
+ }
+ }
+
+ // ── External extensions → recorded as registry references only ─────────
+ // Built-ins travel with the daemon, whether the list came from the app or
+ // was named explicitly; recording one as a registry install is a false
+ // audit record.
+ for name in &ext_names {
+ if super::is_builtin_extension(name) {
+ continue;
+ }
+ if !is_valid_item_name(name) {
+ report
+ .notes
+ .push(format!("'{name}' is not an extension name; skipped"));
+ continue;
+ }
+ staged.extensions.push(serde_json::json!({
+ "name": name,
+ "source": "registry",
+ "note": "install from the BAAM registry on the target; .brxt bundle staging is out of scope",
+ }));
+ report.extensions.push(name.clone());
+ }
+ staged
+}
+
+/// Copy one skill into `payload/skills//`, or say why not.
+fn stage_skill(
+ access: &dyn PayloadAccess,
+ name: &str,
+ skills_out: &Path,
+ report: &mut ExportReport,
+) -> Option {
+ if !is_valid_item_name(name) {
+ report
+ .notes
+ .push(format!("'{name}' is not a skill name; skipped"));
+ return None;
+ }
+ let not_installed = |report: &mut ExportReport| -> Option {
+ report
+ .notes
+ .push(format!("skill '{name}' not installed; skipped"));
+ None
+ };
+ let Some(location) = access.skill_dir(name) else {
+ return not_installed(report);
+ };
+ match std::fs::symlink_metadata(&location.dir) {
+ Ok(meta) if meta.file_type().is_symlink() => {
+ report.notes.push(format!(
+ "skill '{name}' is a link to another folder, and links are never followed; \
+ skipped"
+ ));
+ return None;
+ }
+ Ok(meta) if meta.is_dir() => {}
+ _ => return not_installed(report),
+ }
+ // The skill folder must really sit inside its root, whatever links sit
+ // above either of them.
+ let (Ok(dir), Ok(root)) = (location.dir.canonicalize(), location.root.canonicalize()) else {
+ return not_installed(report);
+ };
+ if dir == root || !dir.starts_with(&root) {
+ report.notes.push(format!(
+ "skill '{name}' is not inside the skills folder it was found in; skipped"
+ ));
+ return None;
+ }
+ let dst = skills_out.join(name);
+ let mut budget = CopyBudget {
+ files: MAX_SKILL_FILES,
+ bytes: MAX_SKILL_BYTES,
+ };
+ let mut skipped = Vec::new();
+ let copied = std::fs::create_dir_all(skills_out).and_then(|()| {
+ copy_tree(
+ &dir,
+ &dst,
+ &format!("payload/skills/{name}"),
+ 0,
+ &mut budget,
+ &mut skipped,
+ )
+ });
+ report.skipped.extend(skipped);
+ match copied {
+ Ok(()) => Some(serde_json::json!({
+ "name": name,
+ "path": format!("payload/skills/{name}"),
+ })),
+ Err(error) => {
+ let _ = std::fs::remove_dir_all(&dst);
+ report.notes.push(format!(
+ "could not copy skill '{name}' into the payload ({error}); skipped"
+ ));
+ None
+ }
+ }
+}
+
+struct CopyBudget {
+ files: usize,
+ bytes: u64,
+}
+
+/// Copy a tree without ever following a link, into a folder that must not
+/// exist yet. Links, special files, excluded components and credential-looking
+/// files are left out and listed in `skipped`.
+fn copy_tree(
+ src: &Path,
+ dst: &Path,
+ rel: &str,
+ depth: usize,
+ budget: &mut CopyBudget,
+ skipped: &mut Vec,
+) -> io::Result<()> {
+ std::fs::create_dir(dst)?;
+ let mut entries: Vec = std::fs::read_dir(src)?.flatten().collect();
+ entries.sort_by_key(|entry| entry.file_name());
+ for entry in entries {
+ let name = entry.file_name();
+ let shown = name.to_string_lossy();
+ let child = format!("{rel}/{shown}");
+ let kind = entry.file_type()?;
+ if kind.is_symlink() {
+ skipped.push(format!("{child}: a link, never followed"));
+ } else if kind.is_dir() {
+ if EXCLUDED_COMPONENTS.contains(&shown.as_ref()) {
+ skipped.push(format!("{child}/: never exported"));
+ } else if depth + 1 > MAX_DEPTH {
+ skipped.push(format!("{child}/: nested too deeply"));
+ } else {
+ copy_tree(
+ &entry.path(),
+ &dst.join(&name),
+ &child,
+ depth + 1,
+ budget,
+ skipped,
+ )?;
+ }
+ } else if kind.is_file() {
+ if looks_like_credential(&shown) {
+ skipped.push(format!("{child}: looks like a credential file"));
+ continue;
+ }
+ let len = entry.metadata()?.len();
+ if budget.files == 0 || len > budget.bytes {
+ return Err(io::Error::other(format!(
+ "it is larger than {MAX_SKILL_FILES} files or {} MB",
+ MAX_SKILL_BYTES / (1024 * 1024)
+ )));
+ }
+ budget.files -= 1;
+ budget.bytes -= len;
+ std::fs::copy(entry.path(), dst.join(&name))?;
+ } else {
+ skipped.push(format!("{child}: not a regular file"));
+ }
+ }
+ Ok(())
+}
+
+/// Assemble the deterministic `export.json` payload manifest (design §3.9 —
+/// Task 3). `required_credentials` and `runtime_requirements` are empty: the app
+/// manifest does not carry the extensions' declared credential env keys or
+/// runtime prerequisites (those live in each extension's own bundle metadata /
+/// the BAAM registry), so they cannot be enumerated here without the registry.
+fn build_export_json(
+ id: &str,
+ mode: ExportMode,
+ staged: &Staged,
+ bundled_daemon: Option<&serde_json::Value>,
+) -> serde_json::Value {
+ serde_json::json!({
+ "version": 1,
+ "app": id,
+ "mode": mode.as_str(),
+ "knowledge_bases": staged.knowledge_bases,
+ "skills": staged.skills,
+ "extensions": staged.extensions,
+ "bundled_daemon": bundled_daemon,
+ "required_credentials": [],
+ "runtime_requirements": [],
+ })
+}
+
+/// Locate a `biorouterd` binary to bundle for a fat export. This code runs
+/// inside `biorouterd` (GUI) **or** `biorouter` (CLI), so `current_exe` is not
+/// necessarily the daemon — we look next to it (the GUI ships them side by
+/// side), then on `PATH`. `BIOROUTERD_BIN` overrides (and is the test hook).
+fn find_biorouterd_binary() -> Option {
+ let exe_name = if cfg!(windows) {
+ "biorouterd.exe"
+ } else {
+ "biorouterd"
+ };
+ if let Ok(p) = std::env::var("BIOROUTERD_BIN") {
+ let pb = PathBuf::from(p);
+ return pb.is_file().then_some(pb);
+ }
+ if let Ok(exe) = std::env::current_exe() {
+ if let Some(dir) = exe.parent() {
+ let cand = dir.join(exe_name);
+ if cand.is_file() {
+ return Some(cand);
+ }
+ }
+ }
+ if let Ok(path) = std::env::var("PATH") {
+ for dir in std::env::split_paths(&path) {
+ let cand = dir.join(exe_name);
+ if cand.is_file() {
+ return Some(cand);
+ }
+ }
+ }
+ None
+}
+
+fn is_native_esbuild(path: &Path) -> io::Result {
+ use std::io::Read;
+
+ let mut file = std::fs::File::open(path)?;
+ let mut header = [0u8; 64];
+ file.read_exact(&mut header)?;
+ #[cfg(target_os = "macos")]
+ {
+ let cpu = match std::env::consts::ARCH {
+ "aarch64" => 0x0100_000cu32,
+ "x86_64" => 0x0100_0007u32,
+ _ => return Ok(false),
+ };
+ Ok(header[..4] == [0xcf, 0xfa, 0xed, 0xfe] && header[4..8] == cpu.to_le_bytes())
+ }
+ #[cfg(target_os = "linux")]
+ {
+ let (class, machine) = match std::env::consts::ARCH {
+ "aarch64" => (2, 183u16),
+ "x86_64" => (2, 62u16),
+ "x86" => (1, 3u16),
+ _ => return Ok(false),
+ };
+ Ok(header[..4] == *b"\x7fELF"
+ && header[4] == class
+ && header[5] == 1
+ && header[18..20] == machine.to_le_bytes())
+ }
+ #[cfg(windows)]
+ {
+ use std::io::{Seek, SeekFrom};
+ let machine = match std::env::consts::ARCH {
+ "aarch64" => 0xaa64u16,
+ "x86_64" => 0x8664u16,
+ "x86" => 0x014cu16,
+ _ => return Ok(false),
+ };
+ if header[..2] != *b"MZ" {
+ return Ok(false);
+ }
+ let offset = u32::from_le_bytes(header[60..64].try_into().unwrap());
+ file.seek(SeekFrom::Start(offset.into()))?;
+ let mut pe = [0u8; 6];
+ file.read_exact(&mut pe)?;
+ Ok(pe[..4] == *b"PE\0\0" && pe[4..6] == machine.to_le_bytes())
+ }
+ #[cfg(not(any(target_os = "macos", target_os = "linux", windows)))]
+ Ok(false)
+}
+
+fn stage_daemon_bundle(target: &Path, daemon: &Path) -> io::Result {
+ stage_daemon_bundle_with_probe(target, daemon, |compiler| {
+ verify_esbuild_version(compiler, std::time::Duration::from_secs(5))
+ })
+}
+
+fn verify_esbuild_version(compiler: &Path, timeout: std::time::Duration) -> io::Result<()> {
+ use std::io::{Read, Seek};
+ use std::process::{Command, Stdio};
+ use std::time::{Duration, Instant};
+
+ // A descendant can inherit stdout after the helper exits. A file lets the
+ // completed probe inspect its output without waiting for that pipe's EOF.
+ let mut output = tempfile::tempfile()?;
+ let mut command = Command::new(compiler);
+ command
+ .arg("--version")
+ .stdin(Stdio::null())
+ .stdout(Stdio::from(output.try_clone()?))
+ .stderr(Stdio::null());
+ super::prepare_agent_drafter_child(&mut command);
+ let mut child = command.spawn()?;
+ let started = Instant::now();
+ let status = loop {
+ if let Some(status) = child.try_wait()? {
+ break status;
+ }
+ if started.elapsed() >= timeout {
+ let _ = child.kill();
+ let _ = child.wait();
+ return Err(io::Error::new(
+ io::ErrorKind::TimedOut,
+ "esbuild --version timed out",
+ ));
+ }
+ std::thread::sleep(Duration::from_millis(10));
+ };
+ output.rewind()?;
+ let mut reported = Vec::new();
+ output.take(65).read_to_end(&mut reported)?;
+ if !status.success()
+ || reported.len() > 64
+ || std::str::from_utf8(&reported).ok().map(str::trim) != Some(bundle::ESBUILD_VERSION)
+ {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!(
+ "esbuild --version did not report the pinned release {}",
+ bundle::ESBUILD_VERSION
+ ),
+ ));
+ }
+ Ok(())
+}
+
+fn stage_daemon_bundle_with_probe(
+ target: &Path,
+ daemon: &Path,
+ probe: impl FnOnce(&Path) -> io::Result<()>,
+) -> io::Result {
+ let esbuild = bundle::packaged_esbuild(daemon).ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::NotFound,
+ "the selected daemon has no packaged esbuild; it cannot rebuild apps on a fresh machine",
+ )
+ })?;
+ if !is_native_esbuild(&esbuild)? {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "the packaged esbuild is not a native executable for this platform and architecture",
+ ));
+ }
+ let bin_name = if cfg!(windows) {
+ "biorouterd.exe"
+ } else {
+ "biorouterd"
+ };
+ let compiler_name = if cfg!(windows) {
+ "esbuild.exe"
+ } else {
+ "esbuild"
+ };
+ let payload = target.join("payload");
+ std::fs::create_dir_all(&payload)?;
+ let staging = tempfile::Builder::new()
+ .prefix(".biorouter-bin-")
+ .tempdir_in(&payload)?;
+ let daemon_bytes = std::fs::copy(daemon, staging.path().join(bin_name))?;
+ let compiler_bytes = std::fs::copy(esbuild, staging.path().join(compiler_name))?;
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ for name in [bin_name, compiler_name] {
+ std::fs::set_permissions(
+ staging.path().join(name),
+ std::fs::Permissions::from_mode(0o755),
+ )?;
+ }
+ }
+ probe(&staging.path().join(compiler_name))?;
+ std::fs::rename(staging.path(), payload.join("bin"))?;
+ Ok(serde_json::json!({
+ "platform": std::env::consts::OS,
+ "arch": std::env::consts::ARCH,
+ "file": format!("payload/bin/{bin_name}"),
+ "bytes": daemon_bytes,
+ "esbuild": {
+ "file": format!("payload/bin/{compiler_name}"),
+ "bytes": compiler_bytes,
+ "version": bundle::ESBUILD_VERSION,
+ },
+ }))
+}
+
+/// Stage the selected daemon and its native compiler together. A missing
+/// compiler makes this a thin export: shipping only the daemon leaves edits
+/// and SDK refreshes dependent on the recipient's Node/npm installation.
+fn stage_current_daemon(target: &Path) -> (Option, String) {
+ match find_biorouterd_binary() {
+ Some(bin) => match stage_daemon_bundle(target, &bin) {
+ Ok(rec) => (
+ Some(rec),
+ format!(
+ "bundled the {}-{} daemon and native esbuild under payload/bin (fat export)",
+ std::env::consts::OS,
+ std::env::consts::ARCH
+ ),
+ ),
+ Err(e) => (
+ None,
+ format!("could not bundle the daemon and compiler ({e}); thin export instead"),
+ ),
+ },
+ None => (
+ None,
+ "biorouterd not found for a fat export; thin export instead (the launcher locates or \
+ installs a daemon at run time)"
+ .to_string(),
+ ),
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::agent_drafter::store::AgentConfig;
+ use tempfile::TempDir;
+
+ #[cfg(any(target_os = "macos", target_os = "linux", windows))]
+ fn native_compiler_header() -> Vec {
+ let mut header = vec![0u8; 128];
+ #[cfg(target_os = "macos")]
+ {
+ header[..4].copy_from_slice(&[0xcf, 0xfa, 0xed, 0xfe]);
+ let cpu = if cfg!(target_arch = "aarch64") {
+ 0x0100_000cu32
+ } else {
+ 0x0100_0007u32
+ };
+ header[4..8].copy_from_slice(&cpu.to_le_bytes());
+ }
+ #[cfg(target_os = "linux")]
+ {
+ header[..4].copy_from_slice(b"\x7fELF");
+ header[4] = if cfg!(target_arch = "x86") { 1 } else { 2 };
+ header[5] = 1;
+ let machine = if cfg!(target_arch = "aarch64") {
+ 183u16
+ } else if cfg!(target_arch = "x86") {
+ 3u16
+ } else {
+ 62u16
+ };
+ header[18..20].copy_from_slice(&machine.to_le_bytes());
+ }
+ #[cfg(windows)]
+ {
+ header[..2].copy_from_slice(b"MZ");
+ header[60..64].copy_from_slice(&64u32.to_le_bytes());
+ header[64..68].copy_from_slice(b"PE\0\0");
+ let machine = if cfg!(target_arch = "aarch64") {
+ 0xaa64u16
+ } else if cfg!(target_arch = "x86") {
+ 0x014cu16
+ } else {
+ 0x8664u16
+ };
+ header[68..70].copy_from_slice(&machine.to_le_bytes());
+ }
+ header
+ }
+
+ #[test]
+ #[cfg(any(target_os = "macos", target_os = "linux", windows))]
+ fn fat_export_stages_its_selected_daemon_and_native_compiler_together() {
+ let dir = TempDir::new().unwrap();
+ let bin = dir.path().join("selected/bin");
+ std::fs::create_dir_all(&bin).unwrap();
+ let daemon_name = if cfg!(windows) {
+ "biorouterd.exe"
+ } else {
+ "biorouterd"
+ };
+ let compiler_name = if cfg!(windows) {
+ "esbuild.exe"
+ } else {
+ "esbuild"
+ };
+ let daemon = bin.join(daemon_name);
+ let compiler = bin.join(compiler_name);
+ let compiler_bytes = native_compiler_header();
+ std::fs::write(&daemon, b"the selected daemon").unwrap();
+ std::fs::write(&compiler, &compiler_bytes).unwrap();
+ let target = dir.path().join("export");
+ let record = stage_daemon_bundle_with_probe(&target, &daemon, |staged| {
+ assert_eq!(std::fs::read(staged).unwrap(), compiler_bytes);
+ assert!(is_native_esbuild(staged).unwrap());
+ Ok(())
+ })
+ .unwrap();
+ assert_eq!(record["file"], format!("payload/bin/{daemon_name}"));
+ assert_eq!(record["bytes"], 19);
+ assert_eq!(
+ record["esbuild"]["file"],
+ format!("payload/bin/{compiler_name}")
+ );
+ assert_eq!(record["esbuild"]["bytes"], compiler_bytes.len());
+ assert_eq!(record["esbuild"]["version"], bundle::ESBUILD_VERSION);
+ assert_eq!(
+ std::fs::read(target.join("payload/bin").join(compiler_name)).unwrap(),
+ compiler_bytes
+ );
+ assert_eq!(
+ std::fs::read_dir(target.join("payload")).unwrap().count(),
+ 1
+ );
+ for name in [daemon_name, compiler_name] {
+ assert!(is_executable_export_path(&format!("payload/bin/{name}")));
+ #[cfg(unix)]
+ {
+ use std::os::unix::fs::PermissionsExt;
+ let mode = std::fs::metadata(target.join("payload/bin").join(name))
+ .unwrap()
+ .permissions()
+ .mode()
+ & 0o777;
+ assert_eq!(mode, 0o755);
+ }
+ }
+ let refused = dir.path().join("rejected-export");
+ let error = stage_daemon_bundle_with_probe(&refused, &daemon, |_| {
+ Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "compiler probe refused",
+ ))
+ })
+ .unwrap_err();
+ assert!(error.to_string().contains("compiler probe refused"));
+ assert_eq!(
+ std::fs::read_dir(refused.join("payload")).unwrap().count(),
+ 0
+ );
+ }
+
+ #[test]
+ #[cfg(unix)]
+ fn fat_export_compiler_probe_requires_the_pin_and_has_a_deadline() {
+ use std::os::unix::fs::PermissionsExt;
+ use std::time::{Duration, Instant};
+ let dir = TempDir::new().unwrap();
+ let compiler = dir.path().join("compiler-probe");
+ std::fs::write(
+ &compiler,
+ format!("#!/bin/sh\nprintf '%s\\n' '{}'\n", bundle::ESBUILD_VERSION),
+ )
+ .unwrap();
+ std::fs::set_permissions(&compiler, std::fs::Permissions::from_mode(0o755)).unwrap();
+ verify_esbuild_version(&compiler, Duration::from_secs(5)).unwrap();
+ std::fs::write(&compiler, "#!/bin/sh\nprintf '%s\\n' '0.0.0'\n").unwrap();
+ assert!(verify_esbuild_version(&compiler, Duration::from_secs(5))
+ .unwrap_err()
+ .to_string()
+ .contains("pinned release"));
+ std::fs::write(
+ &compiler,
+ format!(
+ "#!/bin/sh\nprintf '%s' '{}{}unexpected'\n",
+ bundle::ESBUILD_VERSION,
+ " ".repeat(64)
+ ),
+ )
+ .unwrap();
+ assert!(verify_esbuild_version(&compiler, Duration::from_secs(5))
+ .unwrap_err()
+ .to_string()
+ .contains("pinned release"));
+ std::fs::write(&compiler, "#!/bin/sh\nwhile :; do :; done\n").unwrap();
+ let started = Instant::now();
+ let error = verify_esbuild_version(&compiler, Duration::from_millis(100)).unwrap_err();
+ assert_eq!(error.kind(), io::ErrorKind::TimedOut);
+ assert!(started.elapsed() < Duration::from_secs(2));
+ }
+
+ #[test]
+ #[cfg(unix)]
+ fn fat_export_compiler_probe_does_not_wait_for_descendant_stdout() {
+ use std::os::unix::fs::PermissionsExt;
+ use std::sync::mpsc;
+ use std::time::Duration;
+
+ let dir = TempDir::new().unwrap();
+ let compiler = dir.path().join("compiler-probe");
+ let pipe = dir.path().join("blocked-child");
+ let pid_file = dir.path().join("child-pid");
+ let pipe_name = std::ffi::CString::new(pipe.to_str().unwrap()).unwrap();
+ assert_eq!(unsafe { libc::mkfifo(pipe_name.as_ptr(), 0o600) }, 0);
+ std::fs::write(
+ &compiler,
+ format!(
+ "#!/bin/sh\ncat < '{}' &\nprintf '%s' \"$!\" > '{}'\nprintf '%s\\n' '{}'\n",
+ pipe.display(),
+ pid_file.display(),
+ bundle::ESBUILD_VERSION
+ ),
+ )
+ .unwrap();
+ std::fs::set_permissions(&compiler, std::fs::Permissions::from_mode(0o755)).unwrap();
+
+ let (completed, completion) = mpsc::channel();
+ let probe = std::thread::spawn(move || {
+ let result = verify_esbuild_version(&compiler, Duration::from_secs(1));
+ let _ = completed.send(());
+ result
+ });
+ let returned = completion.recv_timeout(Duration::from_secs(2)).is_ok();
+ let child: libc::pid_t = std::fs::read_to_string(pid_file).unwrap().parse().unwrap();
+ assert_eq!(unsafe { libc::kill(child, libc::SIGTERM) }, 0);
+ probe.join().unwrap().unwrap();
+ assert!(
+ returned,
+ "a descendant's stdout cannot outlive the probe deadline"
+ );
+ }
+
+ #[test]
+ fn fat_export_refuses_a_missing_compiler_or_a_node_wrapper_without_partial_binaries() {
+ let dir = TempDir::new().unwrap();
+ let bin = dir.path().join("selected/bin");
+ std::fs::create_dir_all(&bin).unwrap();
+ let daemon = bin.join(if cfg!(windows) {
+ "biorouterd.exe"
+ } else {
+ "biorouterd"
+ });
+ let compiler = bin.join(if cfg!(windows) {
+ "esbuild.exe"
+ } else {
+ "esbuild"
+ });
+ std::fs::write(&daemon, b"the selected daemon").unwrap();
+ let target = dir.path().join("export");
+ let error = stage_daemon_bundle(&target, &daemon).unwrap_err();
+ assert!(error.to_string().contains("no packaged esbuild"), "{error}");
+ assert!(!target.join("payload/bin").exists());
+ std::fs::write(
+ &compiler,
+ format!("#!/usr/bin/env node\n{}", " ".repeat(128)),
+ )
+ .unwrap();
+ let error = stage_daemon_bundle(&target, &daemon).unwrap_err();
+ assert!(
+ error.to_string().contains("not a native executable"),
+ "{error}"
+ );
+ assert!(!target.join("payload/bin").exists());
+ }
+
+ #[test]
+ #[cfg(any(target_os = "macos", target_os = "linux", windows))]
+ fn fat_export_refuses_a_compiler_for_another_architecture() {
+ let dir = TempDir::new().unwrap();
+ let compiler = dir.path().join("esbuild");
+ let mut header = native_compiler_header();
+ #[cfg(target_os = "macos")]
+ header[4..8].fill(0);
+ #[cfg(target_os = "linux")]
+ header[18..20].fill(0);
+ #[cfg(windows)]
+ header[68..70].fill(0);
+ std::fs::write(&compiler, header).unwrap();
+ assert!(!is_native_esbuild(&compiler).unwrap());
+ }
+
+ /// A full-mode access double: every base reachable except the ones named,
+ /// bases served from a map, skills under one root.
+ struct Fake {
+ refused: Vec<&'static str>,
+ skills_root: PathBuf,
+ knowledge_root: PathBuf,
+ }
+
+ impl PayloadAccess for Fake {
+ fn reach_kb(&self, kb_id: &str) -> Result<(), String> {
+ if self.refused.contains(&kb_id) {
+ Err("it is private".to_string())
+ } else {
+ Ok(())
+ }
+ }
+ fn export_kb(&self, kb_id: &str) -> Result, String> {
+ Ok(format!("brkb:{kb_id}").into_bytes())
+ }
+ fn knowledge_root(&self) -> Option {
+ Some(self.knowledge_root.clone())
+ }
+ fn skill_dir(&self, name: &str) -> Option {
+ skill_in_root(&self.skills_root, name)
+ }
+ }
+
+ struct Fixture {
+ _dir: TempDir,
+ store_root: PathBuf,
+ work: PathBuf,
+ access: Fake,
+ }
+
+ const PNG: &[u8] = &[
+ 0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0xFF, 0xFE, 0x80,
+ ];
+
+ fn fixture() -> Fixture {
+ let dir = TempDir::new().unwrap();
+ let store_root = dir.path().join("store");
+ let work = dir.path().join("work");
+ let skills_root = dir.path().join("skills");
+ let knowledge_root = dir.path().join("knowledge");
+ for d in [&store_root, &work, &skills_root, &knowledge_root] {
+ std::fs::create_dir_all(d).unwrap();
+ }
+ Fixture {
+ _dir: dir,
+ store_root,
+ work,
+ access: Fake {
+ refused: vec![],
+ skills_root,
+ knowledge_root,
+ },
+ }
+ }
+
+ impl Fixture {
+ fn store(&self) -> ArtifactStore {
+ ArtifactStore::new(self.store_root.clone())
+ }
+
+ /// A static app with a binary asset, workspace data, a nested vault and
+ /// a few files that must never leave.
+ fn app(&self, id: &str, kind: ArtifactKind) -> PathBuf {
+ let store = self.store();
+ let mut manifest = store
+ .create_with_id(
+ id,
+ "Demo",
+ "",
+ kind,
+ "index.html",
+ &[(
+ "index.html".to_string(),
+ "hi ".to_string(),
+ )],
+ )
+ .unwrap();
+ manifest.session_id = Some("20261009_3".to_string());
+ if kind == ArtifactKind::Agentic {
+ manifest.agent = Some(AgentConfig {
+ system_prompt: "help".into(),
+ ..Default::default()
+ });
+ }
+ // A current bundle (stamped, and written after src/ below), so no
+ // build runs in a unit test and `dist/app.js` stays the fixture's.
+ manifest.built_at = Some(1);
+ manifest.sdk_hash = Some(bundle::sdk_fingerprint());
+ store.save_manifest(&manifest).unwrap();
+ let app = store.artifact_dir(id).unwrap();
+ let write = |rel: &str, bytes: &[u8]| {
+ let path = join_rel(&app, rel);
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(path, bytes).unwrap();
+ };
+ write("assets/logo.png", PNG);
+ write("assets/theme.css", b"body{}");
+ write("src/main.ts", b"export {};");
+ write("src/node_modules/x.js", b"x");
+ write("dist/app.js", b"console.log(1);");
+ write("dist/app.css", b"main{}");
+ write("dist/stale-chunk.js", b"old");
+ // UTF-8 on purpose: binary bytes stayed out only by accident before.
+ write("workspace/.vault/API_KEY.enc", b"sealed-as-text");
+ write("workspace/notes.csv", b"a,b\n1,2\n");
+ write("assets/.vault/X.enc", b"sealed");
+ write("assets/.git/config", b"[remote] url=https://token@host");
+ write(".env", b"SECRET=1");
+ write("assets/server.pem", b"-----BEGIN");
+ app
+ }
+
+ fn export(&self, id: &str, request: &ExportRequest) -> Result {
+ let out = self
+ .work
+ .join(format!("{id}-{}", uuid::Uuid::new_v4().simple()));
+ export_to_staging(&self.store_root, id, request, &self.access, &out).map(|mut r| {
+ r.target = out.display().to_string();
+ r
+ })
+ }
+ }
+
+ fn out(report: &ExportReport) -> PathBuf {
+ PathBuf::from(&report.target)
+ }
+
+ #[test]
+ fn binary_assets_round_trip_byte_identical_and_runtime_data_stays_home() {
+ let f = fixture();
+ f.app("demo", ArtifactKind::Static);
+ let report = f.export("demo", &ExportRequest::default()).unwrap();
+ let dir = out(&report);
+
+ assert_eq!(std::fs::read(dir.join("assets/logo.png")).unwrap(), PNG);
+ assert!(dir.join("assets/theme.css").is_file());
+ assert!(dir.join("src/main.ts").is_file());
+ assert_eq!(
+ std::fs::read(dir.join("dist/app.js")).unwrap(),
+ b"console.log(1);"
+ );
+ assert_eq!(std::fs::read(dir.join("dist/app.css")).unwrap(), b"main{}");
+ assert!(!dir.join("dist/stale-chunk.js").exists());
+ for gone in [
+ "workspace",
+ "assets/.vault",
+ "assets/.git",
+ "src/node_modules",
+ ".env",
+ "assets/server.pem",
+ ] {
+ assert!(!dir.join(gone).exists(), "{gone} must not be exported");
+ }
+ assert!(
+ report
+ .written
+ .iter()
+ .all(|rel| !rel.split('/').any(|c| c == ".vault")),
+ "{:?}",
+ report.written
+ );
+ assert!(report.skipped.iter().any(|s| s.starts_with("workspace/")));
+ assert!(report.skipped.iter().any(|s| s.starts_with(".env")));
+ }
+
+ #[test]
+ fn the_raw_entry_is_exported_and_a_served_install_has_exactly_one_config_island() {
+ let f = fixture();
+ f.app("agent-app", ArtifactKind::Agentic);
+ let report = f
+ .export(
+ "agent-app",
+ &ExportRequest {
+ allow_lint_errors: true,
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ let dir = out(&report);
+ let raw = std::fs::read_to_string(
+ f.store()
+ .artifact_dir("agent-app")
+ .unwrap()
+ .join("index.html"),
+ )
+ .unwrap();
+ let exported = std::fs::read_to_string(dir.join("index.html")).unwrap();
+ assert_eq!(
+ exported, raw,
+ "the export carries the authored page as written"
+ );
+ assert!(!exported.contains("biorouter-app-config"));
+
+ // Install the way the launcher does, into another machine's store, and
+ // serve it the way `serve_index` does.
+ let other = TempDir::new().unwrap();
+ let installed = other.path().join("agent-app");
+ std::fs::create_dir_all(&installed).unwrap();
+ for rel in ["manifest.json", "index.html"] {
+ std::fs::copy(dir.join(rel), installed.join(rel)).unwrap();
+ }
+ let store = ArtifactStore::new(other.path().to_path_buf());
+ let manifest = store.load_manifest("agent-app").unwrap();
+ let entry = store.read_file("agent-app", &manifest.entry).unwrap();
+ let page = render::assemble_app(
+ &manifest,
+ &entry,
+ Some("/apps/agent-app/"),
+ None,
+ Some("tok123"),
+ );
+ assert_eq!(
+ page.matches("id=\"biorouter-app-config\"").count(),
+ 1,
+ "{page}"
+ );
+ assert!(page.contains("\"wsToken\":\"tok123\""));
+ assert_eq!(page.matches("src=\"dist/app.js\"").count(), 1);
+ }
+
+ #[test]
+ fn the_exported_manifest_drops_the_authors_chat_and_keeps_the_rest() {
+ let f = fixture();
+ f.app("demo", ArtifactKind::Static);
+ let report = f.export("demo", &ExportRequest::default()).unwrap();
+ let text = std::fs::read_to_string(out(&report).join("manifest.json")).unwrap();
+ let manifest: Manifest = serde_json::from_str(&text).unwrap();
+ assert_eq!(manifest.id, "demo");
+ assert_eq!(manifest.session_id, None);
+ assert!(!text.contains("20261009_3"));
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn launchers_are_executable() {
+ use std::os::unix::fs::PermissionsExt;
+ let f = fixture();
+ f.app("demo", ArtifactKind::Static);
+ let report = f.export("demo", &ExportRequest::default()).unwrap();
+ for name in render::EXECUTABLE_EXPORT_FILES {
+ let mode = std::fs::metadata(out(&report).join(name))
+ .unwrap()
+ .permissions()
+ .mode();
+ assert_eq!(mode & 0o777, 0o755, "{name}");
+ }
+ }
+
+ #[test]
+ fn an_authored_file_named_like_a_launcher_moves_under_app_files() {
+ let f = fixture();
+ let app = f.app("demo", ArtifactKind::Static);
+ std::fs::write(app.join("README.md"), "the app's own readme").unwrap();
+ std::fs::write(app.join("run.sh"), "echo authored").unwrap();
+ let report = f.export("demo", &ExportRequest::default()).unwrap();
+ let dir = out(&report);
+ assert!(std::fs::read_to_string(dir.join("run.sh"))
+ .unwrap()
+ .contains("biorouter-launch.sh"));
+ assert_eq!(
+ std::fs::read_to_string(dir.join("app-files/run.sh")).unwrap(),
+ "echo authored"
+ );
+ assert_eq!(
+ std::fs::read_to_string(dir.join("app-files/README.md")).unwrap(),
+ "the app's own readme"
+ );
+ assert!(report.notes.iter().any(|n| n.contains("app-files/run.sh")));
+ }
+
+ /// The store refuses an app whose folder holds a link, so the export does
+ /// too, with a sentence and before anything is written. The walk below
+ /// skips links on its own as well (a link made after that check), which
+ /// `walk_app` is driven through directly to show.
+ #[cfg(unix)]
+ #[test]
+ fn a_symlink_in_the_app_is_never_followed() {
+ let f = fixture();
+ let app = f.app("demo", ArtifactKind::Static);
+ let secret = f.work.join("secret.txt");
+ std::fs::write(&secret, "do not ship").unwrap();
+ std::os::unix::fs::symlink(&secret, app.join("assets/linked.txt")).unwrap();
+ let before: Vec<_> = std::fs::read_dir(&f.work).unwrap().flatten().collect();
+ match f.export("demo", &ExportRequest::default()) {
+ Err(ExportError::Unexportable(message)) => {
+ assert!(message.contains("link"), "{message}")
+ }
+ other => panic!("expected a refusal, got {other:?}"),
+ }
+ let after: Vec<_> = std::fs::read_dir(&f.work).unwrap().flatten().collect();
+ assert_eq!(before.len(), after.len(), "nothing was written");
+
+ let mut report = ExportReport::default();
+ let mut files = Vec::new();
+ walk_app(&app, &app, 0, &mut files, &mut report).unwrap();
+ assert!(files.iter().all(|file| file.rel != "assets/linked.txt"));
+ assert!(report
+ .skipped
+ .iter()
+ .any(|s| s.contains("assets/linked.txt")));
+ }
+
+ #[test]
+ fn lint_errors_block_unless_a_person_allows_them() {
+ let f = fixture();
+ let app = f.app("demo", ArtifactKind::Static);
+ std::fs::write(
+ app.join("index.html"),
+ "",
+ )
+ .unwrap();
+ match f.export("demo", &ExportRequest::default()) {
+ Err(ExportError::Lint { findings, .. }) => assert!(!findings.is_empty()),
+ other => panic!("expected a lint refusal, got {other:?}"),
+ }
+ let report = f
+ .export(
+ "demo",
+ &ExportRequest {
+ allow_lint_errors: true,
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ assert!(report.lint_warnings.iter().any(|w| w.starts_with("ERROR:")));
+ assert!(report.notes.iter().any(|n| n.contains("despite")));
+ }
+
+ #[test]
+ fn missing_apps_and_bad_ids_are_named_errors() {
+ let f = fixture();
+ assert!(matches!(
+ f.export("ghost", &ExportRequest::default()),
+ Err(ExportError::NotFound(_))
+ ));
+ assert!(matches!(
+ f.export("../x", &ExportRequest::default()),
+ Err(ExportError::BadRequest(_))
+ ));
+ }
+
+ fn agent_with_payload(f: &Fixture, id: &str) {
+ let store = f.store();
+ let mut manifest = store.load_manifest(id).unwrap();
+ let mut agent = AgentConfig {
+ system_prompt: "help".into(),
+ knowledge_base: Some("lab-notes".into()),
+ skills: vec!["ggplot".into()],
+ extensions: vec!["developer".into(), "spokeagent".into()],
+ ..Default::default()
+ };
+ agent.capabilities.data = Some(crate::agent_drafter::manifest::DataCapability {
+ sources: vec![serde_json::from_value(serde_json::json!({
+ "name": "kb", "kind": "knowledge", "ids": ["cohort"]
+ }))
+ .unwrap()],
+ });
+ agent.capabilities.memory.kb = Some("app-memory".into());
+ manifest.agent = Some(agent);
+ store.save_manifest(&manifest).unwrap();
+ let skill = f.access.skills_root.join("ggplot");
+ std::fs::create_dir_all(&skill).unwrap();
+ std::fs::write(skill.join("SKILL.md"), "---\nname: ggplot\n---\n").unwrap();
+ }
+
+ #[test]
+ fn full_mode_carries_every_referenced_base_and_filters_built_ins() {
+ let f = fixture();
+ f.app("demo", ArtifactKind::Static);
+ agent_with_payload(&f, "demo");
+ let report = f
+ .export(
+ "demo",
+ &ExportRequest {
+ mode: ExportMode::Full,
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ assert_eq!(report.kbs, vec!["app-memory", "cohort", "lab-notes"]);
+ assert_eq!(report.skills, vec!["ggplot"]);
+ assert_eq!(report.extensions, vec!["spokeagent"]);
+ let dir = out(&report);
+ assert!(dir.join("payload/skills/ggplot/SKILL.md").is_file());
+ assert!(dir.join("payload/knowledge/cohort.brkb").is_file());
+ let ej: serde_json::Value =
+ serde_json::from_str(&std::fs::read_to_string(dir.join("export.json")).unwrap())
+ .unwrap();
+ assert_eq!(ej["mode"], "full");
+ assert_eq!(ej["extensions"].as_array().unwrap().len(), 1);
+
+ // Named explicitly, a built-in is still not a registry install.
+ let explicit = f
+ .export(
+ "demo",
+ &ExportRequest {
+ mode: ExportMode::Full,
+ include: ExportSelection {
+ knowledge_bases: Some(vec![]),
+ skills: Some(vec![]),
+ extensions: Some(vec!["developer".into(), "autovisualiser".into()]),
+ },
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ assert!(explicit.extensions.is_empty());
+ }
+
+ /// A full export carried `office-word` (a skill every install seeds), and
+ /// the recipient's launcher then installed the author's copy over theirs
+ /// under the seeded name, which `skill_package::refuse_shipped` exists to
+ /// stop. Named by the app or by the request, a shipped skill stays home.
+ #[test]
+ fn a_skill_biorouter_ships_never_travels() {
+ let f = fixture();
+ f.app("demo", ArtifactKind::Static);
+ agent_with_payload(&f, "demo");
+ let store = f.store();
+ let mut manifest = store.load_manifest("demo").unwrap();
+ manifest.agent.as_mut().unwrap().skills = vec![
+ "ggplot".into(),
+ "office-word".into(),
+ "knowledge-bases".into(),
+ ];
+ store.save_manifest(&manifest).unwrap();
+ let seeded = f.access.skills_root.join("office-word");
+ std::fs::create_dir_all(&seeded).unwrap();
+ std::fs::write(seeded.join("SKILL.md"), "---\nname: office-word\n---\n").unwrap();
+
+ for skills in [
+ None,
+ Some(vec!["office-word".to_string()]),
+ Some(vec!["Office-Word".to_string()]),
+ Some(vec!["knowledge-bases".to_string()]),
+ ] {
+ let report = f
+ .export(
+ "demo",
+ &ExportRequest {
+ mode: ExportMode::Full,
+ include: ExportSelection {
+ skills: skills.clone(),
+ ..Default::default()
+ },
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ assert!(
+ !report.skills.contains(&"office-word".to_string()),
+ "{skills:?}"
+ );
+ for name in ["office-word", "Office-Word", "knowledge-bases"] {
+ assert!(!out(&report).join(format!("payload/skills/{name}")).exists());
+ }
+ assert!(
+ report
+ .notes
+ .iter()
+ .any(|note| note.contains("ships with Biorouter")),
+ "{:?}",
+ report.notes
+ );
+ let ej: serde_json::Value = serde_json::from_str(
+ &std::fs::read_to_string(out(&report).join("export.json")).unwrap(),
+ )
+ .unwrap();
+ assert!(!ej.to_string().contains("payload/skills/office-word"));
+ }
+ }
+
+ /// [`render::SHIPPED_SKILL_NAMES`] is a copy: the seeder's list lives in the
+ /// `biorouter` crate (`skills_extension::is_builtin_skill_name`), which
+ /// this crate cannot import. Read that source and hold the two equal, so a
+ /// skill shipped later is never exported over the recipient's.
+ #[test]
+ fn the_shipped_skill_list_is_the_seeders() {
+ let biorouter = Path::new(env!("CARGO_MANIFEST_DIR")).join("../biorouter/src");
+ let (Ok(seeder), Ok(soul)) = (
+ std::fs::read_to_string(biorouter.join("agents/skills_extension.rs")),
+ std::fs::read_to_string(biorouter.join("knowledge/soul.rs")),
+ ) else {
+ eprintln!("the biorouter crate is not beside this one; nothing to compare");
+ return;
+ };
+ let names_in = |start: &str| -> Vec {
+ let (_, rest) = seeder.split_once(start).expect("the list is still there");
+ let (list, _) = rest.split_once("];").expect("...and still ends");
+ list.split('"')
+ .skip(1)
+ .step_by(2)
+ .filter(|quoted| !quoted.contains('/'))
+ .map(str::to_string)
+ .collect()
+ };
+ let mut seeded = names_in("pub static BUILTIN_SKILLS: &[(&str, &str)] = &[");
+ seeded.extend(names_in(
+ "pub static KNOWLEDGE_SKILLS: &[(&str, &str)] = &[",
+ ));
+ let (_, rest) = soul
+ .split_once("pub const SOUL_SKILL_DIR: &str = \"")
+ .expect("SOUL_SKILL_DIR is still there");
+ seeded.push(rest.split('"').next().unwrap().to_string());
+ let (_, rest) = seeder
+ .split_once("pub const KNOWLEDGE_BUNDLE: &str = \"")
+ .expect("KNOWLEDGE_BUNDLE is still there");
+ seeded.push(rest.split('"').next().unwrap().to_string());
+ assert!(seeded.len() > 10, "{seeded:?}");
+ assert_eq!(seeded, render::SHIPPED_SKILL_NAMES);
+ }
+
+ #[test]
+ fn an_explicit_empty_selection_stages_nothing() {
+ let f = fixture();
+ f.app("demo", ArtifactKind::Static);
+ agent_with_payload(&f, "demo");
+ let report = f
+ .export(
+ "demo",
+ &ExportRequest {
+ mode: ExportMode::Full,
+ include: ExportSelection {
+ knowledge_bases: Some(vec![]),
+ skills: Some(vec![]),
+ extensions: Some(vec![]),
+ },
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ assert!(report.kbs.is_empty() && report.skills.is_empty() && report.extensions.is_empty());
+ assert!(!out(&report).join("payload").exists());
+ assert!(
+ report.wrote_export_json,
+ "a full export still records what it carried"
+ );
+ }
+
+ #[test]
+ fn a_base_out_of_the_callers_reach_is_left_out_with_a_note() {
+ let mut f = fixture();
+ f.access.refused = vec!["cohort"];
+ f.app("demo", ArtifactKind::Static);
+ agent_with_payload(&f, "demo");
+ let report = f
+ .export(
+ "demo",
+ &ExportRequest {
+ mode: ExportMode::Full,
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ assert!(!report.kbs.contains(&"cohort".to_string()));
+ assert!(!out(&report).join("payload/knowledge/cohort.brkb").exists());
+ assert!(report
+ .notes
+ .iter()
+ .any(|n| n.contains("cohort") && n.contains("private")));
+ }
+
+ #[test]
+ fn skill_names_that_are_paths_are_refused_before_any_join() {
+ let f = fixture();
+ f.app("demo", ArtifactKind::Static);
+ // Something a traversal would find, beside the skills root.
+ let outside = f.access.skills_root.parent().unwrap().join("secrets");
+ std::fs::create_dir_all(&outside).unwrap();
+ std::fs::write(outside.join("SKILL.md"), "secret").unwrap();
+ for name in ["../secrets", "/etc", "a/b", "..", ".hidden", ""] {
+ let report = f
+ .export(
+ "demo",
+ &ExportRequest {
+ mode: ExportMode::Full,
+ include: ExportSelection {
+ skills: Some(vec![name.to_string()]),
+ knowledge_bases: Some(vec![]),
+ extensions: Some(vec![]),
+ },
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ assert!(report.skills.is_empty(), "{name}");
+ assert!(!out(&report).join("payload").exists(), "{name}");
+ assert!(
+ !f.work.join("secrets").exists(),
+ "{name} wrote outside the export"
+ );
+ }
+ assert_eq!(
+ std::fs::read_to_string(outside.join("SKILL.md")).unwrap(),
+ "secret"
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn a_linked_skill_and_a_link_loop_inside_a_skill_are_skipped_and_the_copy_ends() {
+ let f = fixture();
+ f.app("demo", ArtifactKind::Static);
+ let real = f.work.join("elsewhere");
+ std::fs::create_dir_all(&real).unwrap();
+ std::fs::write(real.join("SKILL.md"), "x").unwrap();
+ std::os::unix::fs::symlink(&real, f.access.skills_root.join("linked")).unwrap();
+ let looped = f.access.skills_root.join("looped");
+ std::fs::create_dir_all(&looped).unwrap();
+ std::fs::write(looped.join("SKILL.md"), "---\nname: looped\n---\n").unwrap();
+ std::os::unix::fs::symlink(&looped, looped.join("again")).unwrap();
+ std::os::unix::fs::symlink("/etc/hosts", looped.join("hosts")).unwrap();
+
+ let report = f
+ .export(
+ "demo",
+ &ExportRequest {
+ mode: ExportMode::Full,
+ include: ExportSelection {
+ skills: Some(vec!["linked".into(), "looped".into()]),
+ knowledge_bases: Some(vec![]),
+ extensions: Some(vec![]),
+ },
+ ..Default::default()
+ },
+ )
+ .unwrap();
+ assert_eq!(report.skills, vec!["looped"]);
+ let dir = out(&report);
+ assert!(dir.join("payload/skills/looped/SKILL.md").is_file());
+ assert!(!dir.join("payload/skills/looped/again").exists());
+ assert!(!dir.join("payload/skills/looped/hosts").exists());
+ assert!(!dir.join("payload/skills/linked").exists());
+ assert!(report
+ .notes
+ .iter()
+ .any(|n| n.contains("'linked'") && n.contains("link")));
+ }
+
+ #[test]
+ fn a_bundled_skill_is_found_one_level_down() {
+ let f = fixture();
+ let skill = f.access.skills_root.join("knowledge-bases").join("okf");
+ std::fs::create_dir_all(&skill).unwrap();
+ std::fs::write(skill.join("SKILL.md"), "x").unwrap();
+ let found = skill_in_root(&f.access.skills_root, "okf").unwrap();
+ assert_eq!(found.dir, skill);
+ assert!(skill_in_root(&f.access.skills_root, "../okf").is_none());
+ }
+
+ #[test]
+ fn a_model_named_target_must_be_new_or_empty_unless_it_replaces_its_own_export() {
+ let f = fixture();
+ f.app("demo", ArtifactKind::Static);
+ let access = &f.access;
+ let target = f.work.join("Desktop").join("demo");
+
+ // Relative and parent-escaping targets are refused.
+ let escaping = f.work.join("x/../demo");
+ for bad in [Path::new("demo"), escaping.as_path()] {
+ assert!(matches!(
+ export_to_dir(
+ &f.store_root,
+ "demo",
+ &ExportRequest::default(),
+ access,
+ bad,
+ false
+ ),
+ Err(ExportError::BadRequest(_))
+ ));
+ }
+ // Inside the store or the knowledge store.
+ for inside in [
+ f.store_root.join("demo-out"),
+ access.knowledge_root.join("x"),
+ ] {
+ assert!(matches!(
+ export_to_dir(
+ &f.store_root,
+ "demo",
+ &ExportRequest::default(),
+ access,
+ &inside,
+ false
+ ),
+ Err(ExportError::BadRequest(_))
+ ));
+ }
+
+ let first = export_to_dir(
+ &f.store_root,
+ "demo",
+ &ExportRequest::default(),
+ access,
+ &target,
+ false,
+ )
+ .unwrap();
+ assert_eq!(first.target, target.display().to_string());
+ assert!(target.join("run.sh").is_file());
+ // Nothing half-written is left beside it.
+ let siblings: Vec<_> = std::fs::read_dir(target.parent().unwrap())
+ .unwrap()
+ .flatten()
+ .map(|e| e.file_name().to_string_lossy().to_string())
+ .collect();
+ assert_eq!(siblings, vec!["demo"]);
+
+ // Non-empty: refused without replace.
+ assert!(matches!(
+ export_to_dir(
+ &f.store_root,
+ "demo",
+ &ExportRequest::default(),
+ access,
+ &target,
+ false
+ ),
+ Err(ExportError::BadRequest(_))
+ ));
+ // A stale file from the earlier export does not survive a replace,
+ // and the earlier export itself is kept beside the new one, as it was.
+ std::fs::write(target.join("src/stale.ts"), "old").unwrap();
+ std::fs::write(target.join("src/main.ts"), "// edited in the export").unwrap();
+ let replaced = export_to_dir(
+ &f.store_root,
+ "demo",
+ &ExportRequest::default(),
+ access,
+ &target,
+ true,
+ )
+ .unwrap();
+ assert!(!target.join("src/stale.ts").exists());
+ assert!(target.join("manifest.json").is_file());
+ let kept: Vec = std::fs::read_dir(target.parent().unwrap())
+ .unwrap()
+ .flatten()
+ .map(|e| e.path())
+ .filter(|p| {
+ p.file_name()
+ .unwrap()
+ .to_string_lossy()
+ .starts_with("demo.replaced-")
+ })
+ .collect();
+ assert_eq!(kept.len(), 1, "{kept:?}");
+ assert_eq!(
+ std::fs::read_to_string(kept[0].join("src/main.ts")).unwrap(),
+ "// edited in the export"
+ );
+ assert!(kept[0].join("src/stale.ts").is_file());
+ assert!(
+ replaced
+ .notes
+ .iter()
+ .any(|note| note.contains(&kept[0].display().to_string())),
+ "{:?}",
+ replaced.notes
+ );
+ std::fs::remove_dir_all(&kept[0]).unwrap();
+
+ // An export that has become someone's project is never replaced:
+ // a repository, installed packages, a file of their own.
+ std::fs::write(target.join(".DS_Store"), "finder").unwrap();
+ for (made, rel) in [
+ ("dir", ".git"),
+ ("dir", "node_modules"),
+ ("file", "package-lock.json"),
+ ("file", "notes.md"),
+ ] {
+ let path = target.join(rel);
+ if made == "dir" {
+ std::fs::create_dir_all(path.join("x")).unwrap();
+ } else {
+ std::fs::write(&path, "mine").unwrap();
+ }
+ let refused = export_to_dir(
+ &f.store_root,
+ "demo",
+ &ExportRequest::default(),
+ access,
+ &target,
+ true,
+ );
+ match refused {
+ Err(ExportError::BadRequest(message)) => {
+ assert!(message.contains(rel), "{message}");
+ assert!(!message.contains(".DS_Store"), "{message}");
+ }
+ other => panic!("{rel}: {other:?}"),
+ }
+ assert!(path.exists(), "{rel} is left where it is");
+ if made == "dir" {
+ std::fs::remove_dir_all(&path).unwrap();
+ } else {
+ std::fs::remove_file(&path).unwrap();
+ }
+ }
+ // Finder's own file does not make it a project.
+ export_to_dir(
+ &f.store_root,
+ "demo",
+ &ExportRequest::default(),
+ access,
+ &target,
+ true,
+ )
+ .unwrap();
+
+ // Replace never touches a folder that is not this app's export.
+ let other = f.work.join("Desktop").join("projects");
+ std::fs::create_dir_all(&other).unwrap();
+ std::fs::write(other.join("thesis.tex"), "precious").unwrap();
+ assert!(matches!(
+ export_to_dir(
+ &f.store_root,
+ "demo",
+ &ExportRequest::default(),
+ access,
+ &other,
+ true
+ ),
+ Err(ExportError::BadRequest(_))
+ ));
+ assert_eq!(
+ std::fs::read_to_string(other.join("thesis.tex")).unwrap(),
+ "precious"
+ );
+
+ // An empty folder is fine.
+ let empty = f.work.join("Desktop").join("empty");
+ std::fs::create_dir_all(&empty).unwrap();
+ export_to_dir(
+ &f.store_root,
+ "demo",
+ &ExportRequest::default(),
+ access,
+ &empty,
+ false,
+ )
+ .unwrap();
+ assert!(empty.join("index.html").is_file());
+ }
+
+ /// `export_app` took any absolute folder the account could write that was
+ /// not one of three Biorouter folders or a hidden one at the top of home,
+ /// so a chat with no shell could plant a package in a Python
+ /// `site-packages` or files in `~/Library`. It writes only into the
+ /// person's own folders now.
+ #[cfg(unix)]
+ #[test]
+ fn a_model_exports_only_into_the_persons_own_folders() {
+ let home = resolve_existing_prefix(Path::new("/home/u"));
+ let tests = [resolve_existing_prefix(Path::new("/tmp/T"))];
+ let verdict = |path: &str| {
+ placement_refusal(
+ &resolve_existing_prefix(Path::new(path)),
+ Some(&home),
+ &tests,
+ )
+ };
+ for allowed in [
+ "/home/u/Desktop/my-app",
+ "/home/u/Documents/apps/my-app",
+ "/home/u/Downloads/my-app",
+ "/tmp/T/.tmpAbC/work/my-app",
+ ] {
+ assert_eq!(verdict(allowed), None, "{allowed}");
+ }
+ for (refused, because) in [
+ ("/home/u", "outside the export locations"),
+ ("/home/u/Desktop", "content folder itself"),
+ (
+ "/home/u/code/project/exports/my-app",
+ "outside the export locations",
+ ),
+ ("/Volumes/USB Stick/my-app", "outside the export locations"),
+ ("/media/u/usb/my-app", "outside the export locations"),
+ (
+ "/home/u/.config/biorouter-ish",
+ "outside the export locations",
+ ),
+ ("/home/u/Desktop/.secret/my-app", "hidden"),
+ (
+ "/home/u/Library/LaunchAgents/my-app",
+ "outside the export locations",
+ ),
+ ("/home/u/library/my-app", "outside the export locations"),
+ (
+ "/home/u/AppData/Roaming/x/my-app",
+ "outside the export locations",
+ ),
+ (
+ "/home/u/snap/firefox/my-app",
+ "outside the export locations",
+ ),
+ (
+ "/home/u/Documents/project/node_modules/my-app",
+ "imported from",
+ ),
+ (
+ "/home/u/Documents/py/lib/python3.12/site-packages/my-app",
+ "imported from",
+ ),
+ (
+ "/opt/homebrew/lib/python3.12/site-packages/my-app",
+ "imported from",
+ ),
+ ("/usr/local/my-app", "outside the export locations"),
+ ("/etc/my-app", "outside the export locations"),
+ ("/Volumes/USB", "outside the export locations"),
+ ("/tmp/other/my-app", "outside the export locations"),
+ ("/tmp/T/.tmpAbC/node_modules/my-app", "imported from"),
+ ] {
+ let why = verdict(refused).unwrap_or_else(|| panic!("{refused} was allowed"));
+ assert!(why.contains(because), "{refused}: {why}");
+ }
+ // No home at all: only the drives and the test folders remain.
+ assert!(placement_refusal(Path::new("/home/u/Desktop/x"), None, &tests).is_some());
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn redirected_content_roots_cannot_bypass_forbidden_destination_components() {
+ use std::os::unix::fs::symlink;
+ let root = tempfile::Builder::new()
+ .prefix("biorouter-placement-")
+ .tempdir()
+ .unwrap();
+ let home = root.path().join("home");
+ std::fs::create_dir_all(&home).unwrap();
+ let desktop = home.join("Desktop");
+ for (destination, reason) in [
+ (
+ root.path().join("python/site-packages"),
+ Some("imported from"),
+ ),
+ (home.join(".private-export"), Some("hidden")),
+ (root.path().join("Cloud Storage/Desktop"), None),
+ ] {
+ std::fs::create_dir_all(&destination).unwrap();
+ symlink(&destination, &desktop).unwrap();
+ let target = resolve_existing_prefix(&desktop.join("my-app"));
+ let refusal = placement_refusal(&target, Some(&home), &[]);
+ match reason {
+ Some(reason) => assert!(refusal.is_some_and(|why| why.contains(reason))),
+ None => assert_eq!(refusal, None),
+ }
+ std::fs::remove_file(&desktop).unwrap();
+ }
+ }
+
+ #[test]
+ fn source_project_metadata_does_not_permit_replacing_an_edited_export() {
+ let root = tempfile::tempdir().unwrap();
+ let app = root.path().join("app");
+ let earlier = root.path().join("earlier");
+ for dir in [&app, &earlier] {
+ std::fs::create_dir_all(dir.join(".git")).unwrap();
+ std::fs::create_dir_all(dir.join("node_modules")).unwrap();
+ }
+ assert_eq!(
+ foreign_entries(&earlier, &app),
+ vec![".git", "node_modules"]
+ );
+ }
+
+ #[cfg(unix)]
+ #[test]
+ fn a_target_that_is_a_link_is_refused() {
+ let f = fixture();
+ f.app("demo", ArtifactKind::Static);
+ let real = f.work.join("real");
+ std::fs::create_dir_all(&real).unwrap();
+ let link = f.work.join("link");
+ std::os::unix::fs::symlink(&real, &link).unwrap();
+ assert!(matches!(
+ export_to_dir(
+ &f.store_root,
+ "demo",
+ &ExportRequest::default(),
+ &f.access,
+ &link,
+ false
+ ),
+ Err(ExportError::BadRequest(_))
+ ));
+ // ...including a link above the target into a protected place.
+ let into_store = f.work.join("into-store");
+ std::os::unix::fs::symlink(&f.store_root, &into_store).unwrap();
+ assert!(matches!(
+ export_to_dir(
+ &f.store_root,
+ "demo",
+ &ExportRequest::default(),
+ &f.access,
+ &into_store.join("new"),
+ false
+ ),
+ Err(ExportError::BadRequest(_))
+ ));
+ }
+
+ #[test]
+ fn the_legacy_map_carries_the_raw_entry_and_lists_what_it_cannot_carry() {
+ let f = fixture();
+ f.app("demo", ArtifactKind::Static);
+ let legacy = legacy_export_map(&f.store_root, "demo").unwrap();
+ let file = |rel: &str| {
+ legacy
+ .files
+ .iter()
+ .find(|(p, _)| p == rel)
+ .map(|(_, c)| c.clone())
+ };
+ assert!(!file("index.html").unwrap().contains("biorouter-app-config"));
+ assert!(file("run.sh").is_some());
+ assert!(!file("manifest.json").unwrap().contains("20261009_3"));
+ assert!(file("workspace/notes.csv").is_none());
+ assert_eq!(legacy.skipped_binary, vec!["assets/logo.png"]);
+ }
+
+ #[test]
+ fn selection_json_keeps_absent_and_empty_apart() {
+ let none = ExportSelection::from_json(None).unwrap();
+ assert_eq!(none, ExportSelection::default());
+ let explicit = ExportSelection::from_json(Some(&serde_json::json!({
+ "knowledge_bases": [], "skills": ["a"]
+ })))
+ .unwrap();
+ assert_eq!(explicit.knowledge_bases, Some(vec![]));
+ assert_eq!(explicit.skills, Some(vec!["a".to_string()]));
+ assert_eq!(explicit.extensions, None);
+ assert!(ExportSelection::from_json(Some(&serde_json::json!({"skills": [1]}))).is_err());
+ assert!(ExportSelection::from_json(Some(&serde_json::json!(["x"]))).is_err());
+ }
+
+ /// The desktop keeps two lists of its own that must agree with this crate:
+ /// the built-in extensions its export dialog leaves out, and the paths its
+ /// unpacker makes executable whatever the zip recorded. Read from the real
+ /// files; skipped when the crate is built outside the repository.
+ #[test]
+ fn the_desktop_mirrors_of_the_export_lists_agree() {
+ let desktop = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../ui/desktop/src");
+ let (Ok(management), Ok(unpacker)) = (
+ std::fs::read_to_string(desktop.join("components/applications/appManagement.ts")),
+ std::fs::read_to_string(desktop.join("utils/appExport.ts")),
+ ) else {
+ eprintln!("ui/desktop is not beside this crate; nothing to compare");
+ return;
+ };
+ let quoted = |text: &str, start: &str| -> Vec {
+ let (_, rest) = text.split_once(start).expect("the list is still there");
+ let (list, _) = rest.split_once(']').expect("...and still ends");
+ list.split('\'')
+ .skip(1)
+ .step_by(2)
+ .map(str::to_string)
+ .collect()
+ };
+ let builtins = quoted(
+ &management,
+ "export const BUILTIN_EXTENSIONS: readonly string[] = [",
+ );
+ assert_eq!(builtins, super::super::BUILTIN_EXTENSION_NAMES);
+ let shipped = quoted(
+ &management,
+ "export const SHIPPED_SKILLS: readonly string[] = [",
+ );
+ assert_eq!(shipped, render::SHIPPED_SKILL_NAMES);
+ let executable = quoted(&unpacker, "export const EXECUTABLE_EXPORT_PATHS");
+ let mut expected: Vec = render::EXECUTABLE_EXPORT_FILES
+ .iter()
+ .map(|name| name.to_string())
+ .collect();
+ expected.push("payload/bin/biorouterd".to_string());
+ expected.push("payload/bin/biorouterd.exe".to_string());
+ expected.push("payload/bin/esbuild".to_string());
+ expected.push("payload/bin/esbuild.exe".to_string());
+ assert_eq!(executable, expected);
+ assert!(expected.iter().all(|rel| is_executable_export_path(rel)));
+ }
+
+ #[test]
+ fn the_report_header_stays_small_however_much_was_skipped() {
+ let mut report = ExportReport::new("demo", ExportMode::Full);
+ report.skipped = (0..5_000)
+ .map(|i| format!("assets/file-{i}.bin: a link"))
+ .collect();
+ report.notes = vec!["x".repeat(20_000)];
+ let header = report.header_json().to_string();
+ assert!(header.len() <= 6_100, "{}", header.len());
+ assert!(header.contains("more line(s)"));
+ }
+}
diff --git a/crates/biorouter-mcp/src/agent_drafter/mod.rs b/crates/biorouter-mcp/src/agent_drafter/mod.rs
index 9be6b8f80..45df7ba6e 100644
--- a/crates/biorouter-mcp/src/agent_drafter/mod.rs
+++ b/crates/biorouter-mcp/src/agent_drafter/mod.rs
@@ -19,6 +19,7 @@ pub mod catalog;
pub mod control;
pub mod declare;
pub mod evidence;
+pub mod export;
pub mod manifest;
pub mod render;
pub mod resolved;
@@ -26,6 +27,9 @@ pub mod store;
pub mod validate;
pub mod vault;
+/// The legacy launcher-only file map, under the name `routes::apps` imports.
+pub use export::export_scaffold;
+
use crate::developer::shell::strip_daemon_private_env_std;
use crate::knowledge::caller::KbCaller;
use base64::{engine::general_purpose::STANDARD, Engine as _};
@@ -107,12 +111,19 @@ pub struct ModelParam {
pub struct CreateAppParams {
/// Human-readable title; also used to derive the app id when `id` is omitted.
pub title: String,
- /// Optional explicit app id (slugified). If given and an app already exists
- /// at that id, it is REPLACED — so re-creating the same app is idempotent
- /// and the app stays addressable. Omit to auto-derive a unique id from the
- /// title.
+ /// Optional explicit app id (slugified). Omit to auto-derive a unique id from
+ /// the title. If an app already exists at that id and THIS chat created it,
+ /// it is replaced, so re-creating your own app is idempotent. An app another
+ /// chat created is never replaced unless `replace` is true: use update_app
+ /// or configure_app to change it, or pick another id.
#[serde(default)]
pub id: Option,
+ /// Replace an existing app at `id` even though another chat created it.
+ /// Replacing DELETES that app first, including its workspace data, and the
+ /// new app starts with none of its conversations. Only set this when the
+ /// user asked for that app to be replaced.
+ #[serde(default)]
+ pub replace: bool,
/// Short description of what the app does.
#[serde(default)]
pub description: String,
@@ -844,9 +855,26 @@ pub struct AppIdParams {
pub struct ExportAppParams {
/// App id.
pub id: String,
- /// Destination directory (created if missing).
+ /// Absolute path of a NEW (or empty) folder to export into, such as
+ /// `/Users/you/Desktop/my-app`. Created if missing. A folder that already
+ /// holds files is refused unless `replace` is true and it is an earlier
+ /// export of this same app. Only below the user's Desktop, Documents or Downloads:
+ /// never inside Biorouter's own settings, the app store, the knowledge
+ /// store, a hidden folder or a folder code is imported from
+ /// (`node_modules`, `site-packages`).
pub target_dir: String,
- /// Override the agent WebSocket endpoint the exported app connects to.
+ /// Replace an earlier export of this same app at `target_dir`. The new
+ /// export is built beside it and swapped in whole, so nothing stale
+ /// survives, and the earlier one is kept beside it as
+ /// `.replaced-` (the result names it). An export that has
+ /// since gained anything the export does not write (a `.git`,
+ /// `node_modules`, the user's own files) is refused, as is any other
+ /// non-empty folder: pick a new folder then.
+ #[serde(default)]
+ pub replace: bool,
+ /// No longer used. The exported page is always served by a local daemon,
+ /// which gives it the endpoint and socket token of the run that serves it;
+ /// a value here is ignored, with a note.
#[serde(default)]
pub endpoint: Option,
/// Export mode: `"launcher"` (default) ships only the app + launch scripts,
@@ -857,10 +885,11 @@ pub struct ExportAppParams {
#[serde(default)]
pub mode: Option,
/// Full-mode payload selection: `{"knowledge_bases": [ids], "skills":
- /// [names], "extensions": [names]}`. Any omitted key falls back to what the
- /// app's agent config references (KB → `agent.knowledge_base`; skills →
- /// `agent.skills`; extensions → `agent.extensions` minus built-ins). Ignored
- /// in launcher mode.
+ /// [names], "extensions": [names]}`. An omitted key means everything the
+ /// app references (knowledge bases: the agent's, its `knowledge` data
+ /// sources' and its memory bases; skills: `agent.skills`; extensions:
+ /// `agent.extensions` minus built-ins); an empty list means none. Skill and
+ /// extension entries are plain names, never paths. Ignored in launcher mode.
#[serde(default)]
pub include: Option,
/// Bundle the daemon binary for a self-contained ("fat") export:
@@ -923,9 +952,22 @@ fn run_smoke(dir: &Path) -> Result {
return Ok("smoke check skipped (BIOROUTER_APP_SMOKE=off)".to_string());
}
- let script = smoke_script_path().ok_or_else(|| "app-smoke.mjs not found".to_string())?;
- let mut command = std::process::Command::new("node");
+ let script = smoke_script_path().ok_or_else(|| {
+ "the smoke harness (scripts/agent-drafter/app-smoke.mjs) is not part of this \
+ Biorouter install; it ships only with a source checkout"
+ .to_string()
+ })?;
+ // `node` is looked up on the login shell's PATH too: a daemon the desktop
+ // app starts has launchd's PATH, which holds no Homebrew or nvm node.
+ let node = bundle::resolve_tool("node").ok_or_else(|| {
+ "Node.js (`node`) was not found on this machine's PATH or the login shell's PATH"
+ .to_string()
+ })?;
+ let mut command = std::process::Command::new(&node.path);
command.arg(&script).arg(dir);
+ if let Some(path) = node.child_path.as_deref() {
+ command.env("PATH", path);
+ }
// Last, so nothing set above can leave a daemon credential in the child.
prepare_agent_drafter_child(&mut command);
let out = command
@@ -979,21 +1021,49 @@ pub(crate) fn printenv_bin() -> &'static str {
.unwrap_or("printenv")
}
+const SMOKE_SCRIPT: &str = "scripts/agent-drafter/app-smoke.mjs";
+
/// Locate the smoke script relative to the running binary or the source tree.
+///
+/// ⚠ The compile-time source path is consulted only in a debug build. A
+/// release binary embedded the path of the checkout it was built in — on a
+/// shipped build, the release maintainer's — so the tool worked on that one
+/// machine while that checkout existed, and nowhere else.
fn smoke_script_path() -> Option {
- let rel = "scripts/agent-drafter/app-smoke.mjs";
- // Dev tree: walk up from CARGO_MANIFEST_DIR.
- let src = Path::new(env!("CARGO_MANIFEST_DIR"))
- .parent()?
- .parent()?
- .join(rel);
- if src.exists() {
- return Some(src);
- }
- // Installed: next to the executable.
+ #[cfg(debug_assertions)]
+ {
+ let src = Path::new(env!("CARGO_MANIFEST_DIR"))
+ .parent()?
+ .parent()?
+ .join(SMOKE_SCRIPT);
+ if src.exists() {
+ return Some(src);
+ }
+ }
let exe = std::env::current_exe().ok()?;
- let near = exe.parent()?.join(rel);
- near.exists().then_some(near)
+ smoke_script_near(exe.parent()?)
+}
+
+/// The smoke script next to the binary in `exe_dir`, or in the checkout that
+/// binary was built in (`/target//biorouterd`). The ascent
+/// is three directories at most and stops at the checkout root (the first
+/// ancestor holding `.git`), so an installed binary never picks up a script
+/// from some unrelated directory above it.
+fn smoke_script_near(exe_dir: &Path) -> Option {
+ let beside = exe_dir.join(SMOKE_SCRIPT);
+ if beside.is_file() {
+ return Some(beside);
+ }
+ for d in exe_dir.ancestors().skip(1).take(3) {
+ let candidate = d.join(SMOKE_SCRIPT);
+ if candidate.is_file() {
+ return Some(candidate);
+ }
+ if d.join(".git").exists() {
+ return None;
+ }
+ }
+ None
}
fn err(code: ErrorCode, msg: impl Into) -> ErrorData {
@@ -1084,6 +1154,9 @@ fn create_app_files(
None if use_starter => archetype
.index_html(&p.title, &p.description)
.unwrap_or_else(|| render::starter(&p.title, &p.description)),
+ // A static app has no agent, so it does not get the chat card and the
+ // agent notes the agentic starter carries — it showed an empty chat box.
+ None if kind == ArtifactKind::Static => render::static_starter(&p.title, &p.description),
None => render::starter(&p.title, &p.description),
};
let mut files = vec![(entry.to_string(), entry_html)];
@@ -1110,6 +1183,20 @@ fn create_app_files(
(use_starter, files)
}
+/// Write the default agentic sources (`src/sdk.ts`, `src/main.ts`) an app does
+/// not have yet, never replacing one it has. Returns the paths written.
+fn seed_missing_sources(store: &ArtifactStore, id: &str) -> std::io::Result> {
+ let mut written = Vec::new();
+ for (path, content) in bundle::default_sources() {
+ let path = path.to_string_lossy().replace('\\', "/");
+ if !store.file_exists(id, &path) {
+ store.write_file(id, &path, &content)?;
+ written.push(path);
+ }
+ }
+ Ok(written)
+}
+
fn created_agent_config(p: &mut CreateAppParams) -> Result {
let model = p
.model
@@ -1203,137 +1290,375 @@ fn now_secs() -> u64 {
.unwrap_or(0)
}
-/// Directories inside an app that must never leave the author's machine. The
-/// vault holds AES-sealed secrets whose key lives in the author's OS keyring —
-/// worthless to a recipient, and not ours to copy around.
-const EXPORT_EXCLUDED_DIRS: &[&str] = &[".vault/", ".git/"];
+/// Whether `id`'s bundle needs rebuilding before it can be served. See
+/// [`bundle_staleness`] for the rules.
+pub fn bundle_is_stale(store: &ArtifactStore, id: &str, manifest: &Manifest) -> bool {
+ store
+ .artifact_dir(id)
+ .is_ok_and(|dir| bundle_staleness(&dir, manifest).is_some())
+}
-/// Recursively collect an app's files (relative path → contents).
+/// Why the app in `dir` needs rebuilding, `None` when it does not.
+///
+/// Any app with a `src/main.ts` is built, static or agentic: a static page may
+/// keep its code there too. It is stale when:
///
-/// `manifest.json` is skipped here and re-emitted by `scaffold_standalone` from
-/// the parsed [`Manifest`], so the export always carries a canonical one.
-fn collect_files(base: &std::path::Path, dir: &std::path::Path, out: &mut Vec<(String, String)>) {
- let Ok(entries) = std::fs::read_dir(dir) else {
- return;
+/// - it has no `dist/app.js` yet;
+/// - `built_at` is unset — `update_app` clears it when it edits `src/`;
+/// - a file under `src/` changed since the bundle was built: by content, against
+/// the [`bundle::sources_fingerprint`] its banner records, or, for a bundle
+/// with no such record, by being newer than `dist/app.js`. ⚠ An edit made
+/// with the developer shell or text editor never touches `built_at`, and the
+/// launched app kept serving the old bundle after the agent said "try it now";
+/// - the bundle came from the fallback bundler and esbuild can run here, so a
+/// real build replaces the guess (this heals bundles a packaged daemon with
+/// no esbuild made before one shipped);
+/// - it was built against a different App SDK — each app vendors its own
+/// `src/sdk.ts`, so without this an app authored before a protocol addition
+/// kept the old runtime forever and silently dropped frames the server now
+/// sends — UNLESS a newer Biorouter built it. ⚠ Two daemons of different
+/// versions serving one store (the desktop's and a leftover
+/// `biorouter apps open` one) used to rebuild each app back and forth, the
+/// older one downgrading its SDK each time; the bundle's banner records whose
+/// SDK it carries, and this binary never rebuilds down to its own older SDK.
+fn bundle_staleness(dir: &Path, manifest: &Manifest) -> Option<&'static str> {
+ let dist = dir.join("dist/app.js");
+ if !dir.join("src/main.ts").is_file() {
+ // Nothing to build from. An agentic app with no bundle is still stale,
+ // so the attempt is made and its "src/main.ts not found" reaches
+ // whoever is looking instead of a blank page.
+ return (manifest.kind == ArtifactKind::Agentic && !dist.is_file())
+ .then_some("it has no bundle and no src/main.ts");
+ }
+ if !dist.is_file() {
+ return Some("it has never been built");
+ }
+ if manifest.built_at.is_none() {
+ return Some("its sources were edited after the last build");
+ }
+ let banner = bundle::read_bundle_banner(&dist);
+ match banner.as_ref().and_then(|b| b.src.as_deref()) {
+ // The bundle names the sources it was built from: compare contents.
+ Some(built_from) => {
+ if bundle::sources_fingerprint(dir) != built_from {
+ return Some("a file under src/ changed since the bundle was built");
+ }
+ }
+ // A bundle from before that record, or from a custom build script:
+ // the modification times are all there is.
+ None => {
+ let bundled_at = std::fs::metadata(&dist).and_then(|m| m.modified()).ok();
+ if newest_mtime(&dir.join("src"), 0) > bundled_at {
+ return Some("a file under src/ is newer than the bundle");
+ }
+ }
+ }
+ let stamp = manifest.sdk_hash.as_deref().unwrap_or("");
+ let (stamped_fallback, stamped_sdk) = match stamp.strip_prefix("fallback:") {
+ Some(sdk) => (true, sdk),
+ None => (false, stamp),
};
- for entry in entries.flatten() {
- let path = entry.path();
- let Ok(file_type) = entry.file_type() else {
+ let built_by_fallback = stamped_fallback || banner.as_ref().is_some_and(|b| b.is_fallback());
+ if built_by_fallback && bundle::esbuild_available() {
+ return Some("the fallback bundler built it, and esbuild is available now");
+ }
+ let bundles_sdk = manifest.kind == ArtifactKind::Agentic || dir.join("src/sdk.ts").is_file();
+ if bundles_sdk && stamped_sdk != bundle::sdk_fingerprint() {
+ // Whose SDK it carries, not who built it: an older daemon that rebuilt
+ // the app and KEPT a newer SDK is the builder of a bundle whose SDK is
+ // still the newer one's (`BundleBanner::sdk_version`).
+ let newer_sdk = banner.as_ref().is_some_and(|b| {
+ bundle::version_is_newer(b.sdk_version(), bundle::biorouter_version())
+ });
+ if !newer_sdk {
+ return Some("it was built against a different App SDK");
+ }
+ }
+ None
+}
+
+/// The newest modification time under `dir` (symlinks skipped, bounded depth).
+fn newest_mtime(dir: &Path, depth: usize) -> Option {
+ if depth > 8 {
+ return None;
+ }
+ let mut newest = None;
+ for entry in std::fs::read_dir(dir).ok()?.flatten() {
+ let Ok(kind) = entry.file_type() else {
continue;
};
- if file_type.is_symlink() {
- continue;
- }
- if file_type.is_dir() {
- collect_files(base, &path, out);
- } else if let Ok(rel) = path.strip_prefix(base) {
- let rel = rel.to_string_lossy().replace('\\', "/");
- if rel == "manifest.json" || EXPORT_EXCLUDED_DIRS.iter().any(|d| rel.starts_with(d)) {
+ let found = if kind.is_dir() {
+ newest_mtime(&entry.path(), depth + 1)
+ } else if kind.is_file() {
+ entry.metadata().and_then(|m| m.modified()).ok()
+ } else {
+ None
+ };
+ newest = newest.max(found);
+ }
+ newest
+}
+
+/// One lock per app directory, held for a whole build-and-stamp.
+///
+/// ⚠ Two loads of one app — the in-app preview and a browser tab, right after
+/// an upgrade changed the SDK — used to run two builds at once, both rewriting
+/// `src/sdk.ts` and `dist/app.js` while the browser fetched the bundle. Every
+/// build path goes through here: [`ensure_bundle_fresh`] (serving, launch,
+/// export) and [`rebuild_and_stamp`] (an explicit build).
+fn with_app_build_lock(dir: &Path, f: impl FnOnce() -> T) -> T {
+ type Locks =
+ std::sync::Mutex>>>;
+ static LOCKS: std::sync::LazyLock = std::sync::LazyLock::new(Default::default);
+ let lock = LOCKS
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .entry(dir.to_path_buf())
+ .or_default()
+ .clone();
+ let _held = lock.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
+ f()
+}
+
+/// The last failed build of each app, keyed by a fingerprint of everything
+/// that decides its outcome ([`build_inputs_fingerprint`]).
+///
+/// ⚠ Serving rebuilds a stale app before answering, and a failed build leaves
+/// it stale, so an app whose `main.ts` no longer compiled re-ran the bundler —
+/// up to a minute through `npx` — on every page load. Unchanged inputs now get
+/// the remembered answer; any edit is a new fingerprint and a new attempt.
+type FailedBuilds =
+ std::sync::Mutex>;
+static FAILED_BUILDS: std::sync::LazyLock =
+ std::sync::LazyLock::new(Default::default);
+
+fn build_inputs_fingerprint(dir: &Path, manifest: &Manifest) -> u64 {
+ use std::hash::{Hash, Hasher};
+ fn walk(dir: &Path, base: &Path, depth: usize, h: &mut impl Hasher) {
+ let Ok(entries) = std::fs::read_dir(dir) else {
+ return;
+ };
+ let mut entries: Vec<_> = entries.flatten().collect();
+ entries.sort_by_key(|e| e.file_name());
+ for entry in entries {
+ let Ok(kind) = entry.file_type() else {
continue;
- }
- if let Ok(content) = std::fs::read_to_string(&path) {
- out.push((rel, content));
+ };
+ if kind.is_dir() && depth < 8 {
+ walk(&entry.path(), base, depth + 1, h);
+ } else if kind.is_file() {
+ entry.path().strip_prefix(base).ok().hash(h);
+ if let Ok(meta) = entry.metadata() {
+ meta.len().hash(h);
+ meta.modified().ok().hash(h);
+ }
}
}
}
+ let mut h = std::collections::hash_map::DefaultHasher::new();
+ walk(&dir.join("src"), dir, 0, &mut h);
+ std::fs::metadata(dir.join("dist/app.js"))
+ .and_then(|m| m.modified())
+ .ok()
+ .hash(&mut h);
+ manifest.built_at.hash(&mut h);
+ manifest.sdk_hash.hash(&mut h);
+ bundle::sdk_fingerprint().hash(&mut h);
+ bundle::esbuild_available().hash(&mut h);
+ h.finish()
}
-/// Whether `id`'s bundle needs rebuilding before it can be served: it has never
-/// been built, or it was built against an older App SDK than this binary ships.
-///
-/// The second case matters because each app vendors its own `src/sdk.ts`. Without
-/// this, an app authored before a protocol addition keeps running the old runtime
-/// forever and silently drops frames the server now sends (e.g. the `ui` commands
-/// that let the agent drive the page).
-pub fn bundle_is_stale(store: &ArtifactStore, id: &str, manifest: &Manifest) -> bool {
- if manifest.kind != ArtifactKind::Agentic {
- return false;
- }
- if !store.file_exists(id, "dist/app.js") {
- return true;
+/// Record how a build of the app in `dir` went. A failure is keyed by the
+/// inputs as they are AFTER the attempt: a build that failed may still have
+/// refreshed `src/sdk.ts`, and keying it by the state before would make the
+/// next request see "new" inputs and run the same failing build once more.
+fn remember_build(store: &ArtifactStore, id: &str, dir: &Path, report: &bundle::BuildReport) {
+ let mut failed = FAILED_BUILDS
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ if report.ok {
+ failed.remove(dir);
+ } else if let Ok(manifest) = store.load_manifest(id) {
+ let inputs = build_inputs_fingerprint(dir, &manifest);
+ failed.insert(dir.to_path_buf(), (inputs, report.clone()));
}
- manifest.sdk_hash.as_deref() != Some(&bundle::sdk_fingerprint())
}
-/// Rebuild `id` and stamp the manifest with the build time + SDK fingerprint.
-/// Blocking (runs esbuild); callers on an async runtime must `spawn_blocking`.
-///
-pub fn rebuild_and_stamp(store: &ArtifactStore, id: &str) -> std::io::Result {
- // `build_app` refreshes the vendored `src/sdk.ts` before bundling, so the
- // fingerprint we stamp below always describes what actually went into
- // `dist/app.js` — never a current hash over a stale runtime.
- let report = bundle::build_app(&store.artifact_dir(id)?)?;
+/// Build and stamp, with the app's build lock already held.
+fn build_and_stamp_locked(
+ store: &ArtifactStore,
+ id: &str,
+ dir: &Path,
+) -> std::io::Result {
+ // Keep a vendored SDK that a NEWER Biorouter put there: its bundle's banner
+ // names that version and the very SDK the app still vendors. Refreshing it
+ // would rebuild the app down to this binary's older runtime. The version
+ // asked is the SDK's (`sdkv=`), not the builder's, and the build records
+ // it again, so the protection outlives this one build: ⚠ it once lasted
+ // exactly one, because the banner then named this older binary as builder
+ // and the next load "found" SDK drift and downgraded after all.
+ let banner = bundle::read_bundle_banner(&dir.join("dist/app.js"));
+ let keep_sdk_from = banner
+ .as_ref()
+ .filter(|b| {
+ bundle::version_is_newer(b.sdk_version(), bundle::biorouter_version())
+ && bundle::vendored_sdk_fingerprint(dir).as_deref() == Some(b.sdk.as_str())
+ })
+ .map(|b| b.sdk_version().to_string());
+ let mut report = bundle::build_app_with(
+ dir,
+ bundle::BuildOptions {
+ keep_sdk_from: keep_sdk_from.clone(),
+ },
+ )?;
+ if let Some(kept) = keep_sdk_from {
+ report.log = format!(
+ "Kept src/sdk.ts: it is the App SDK of Biorouter {kept}, which is newer than this one ({}).\n{}",
+ bundle::biorouter_version(),
+ report.log
+ );
+ }
+ // The stamp describes what actually went into `dist/app.js`: the SDK the
+ // bundle carries, or `fallback:` for a fallback bundle — never the
+ // current fingerprint over a bundle that does not have it.
if report.ok {
if let Ok(mut m) = store.load_manifest(id) {
m.built_at = Some(now_secs());
- m.sdk_hash = Some(bundle::sdk_fingerprint());
+ m.sdk_hash = report.stamp.clone();
let _ = store.save_manifest(&m);
}
}
Ok(report)
}
-/// Give a written export file the exec bit (owner+group+other rx, owner w).
-/// No-op on Windows, where executability isn't a file mode.
-fn make_executable(path: &std::path::Path) {
- #[cfg(unix)]
- {
- use std::os::unix::fs::PermissionsExt;
- let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o755));
- }
- #[cfg(not(unix))]
- let _ = path;
+/// Rebuild `id` now and stamp the manifest with the build time and what was
+/// bundled. Blocking (runs esbuild); callers on an async runtime must
+/// `spawn_blocking`. For "build it if it needs it", use [`ensure_bundle_fresh`].
+pub fn rebuild_and_stamp(store: &ArtifactStore, id: &str) -> std::io::Result {
+ let dir = store.artifact_dir(id)?;
+ with_app_build_lock(&dir, || {
+ let report = build_and_stamp_locked(store, id, &dir)?;
+ remember_build(store, id, &dir, &report);
+ Ok(report)
+ })
}
-/// Gather an app's files and produce the standalone export scaffold (a file map
-/// of relative-path → contents). Shared by the `export_app` tool and the
-/// server's `GET /apps/{id}/export` route. `endpoint` overrides the agent
-/// WebSocket the exported app connects to (None → derived from the page origin,
-/// with a loopback fallback).
-pub fn export_scaffold(
- root: &std::path::Path,
- id: &str,
- endpoint: Option<&str>,
-) -> std::io::Result> {
- let store = ArtifactStore::new(root.to_path_buf());
- let manifest = store.load_manifest(id)?;
- let dir = store.artifact_dir(id)?;
- let mut all_files = Vec::new();
- collect_files(&dir, &dir, &mut all_files);
- let entry_html = all_files
- .iter()
- .find(|(p, _)| p == &manifest.entry)
- .map(|(_, c)| c.clone())
- .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "entry file missing"))?;
- let src_files: Vec<(String, String)> = all_files
- .iter()
- .filter(|(p, _)| p.starts_with("src/"))
- .cloned()
- .collect();
- let extra_files: Vec<(String, String)> = all_files
- .iter()
- .filter(|(p, _)| p != &manifest.entry && !p.starts_with("src/") && !p.starts_with("dist/"))
- .cloned()
- .collect();
- let mut scaffold =
- render::scaffold_standalone(&manifest, &entry_html, &src_files, &extra_files, endpoint);
- // Ship a prebuilt bundle so the export is directly runnable with no build
- // step (the launcher / a static server can serve it as-is). Build on demand.
- if manifest.kind == ArtifactKind::Agentic {
- // Ship a bundle built from the CURRENT SDK, not a stale one the app
- // happens to have on disk — an export is the copy that leaves the machine.
- if bundle_is_stale(&store, id, &manifest) {
- let _ = rebuild_and_stamp(&store, id);
- }
- if let Ok(js) = store.read_file(id, "dist/app.js") {
- scaffold.push(("dist/app.js".to_string(), js));
+/// What [`ensure_bundle_fresh`] found, and did.
+#[derive(Debug, Clone)]
+pub struct BundleCheck {
+ /// Why the bundle was stale, `None` when it was fresh.
+ pub stale_because: Option<&'static str>,
+ /// The build that ran — or the remembered result of an identical earlier
+ /// attempt — when the bundle was stale.
+ pub build: Option,
+ /// Whether `build` is that remembered result rather than a new attempt.
+ pub remembered: bool,
+ /// Whether `dist/app.js` exists now.
+ pub has_bundle: bool,
+ /// Whether the app cannot work without one: it is agentic, or it has a
+ /// `src/main.ts`.
+ pub needs_bundle: bool,
+}
+
+impl BundleCheck {
+ /// The failed build behind a missing bundle, when that is the situation:
+ /// the app needs a bundle, has none, and the build said why.
+ pub fn missing_bundle_log(&self) -> Option {
+ if !self.needs_bundle || self.has_bundle {
+ return None;
}
+ Some(
+ self.build
+ .as_ref()
+ .map(|b| b.log.clone())
+ .unwrap_or_else(|| "dist/app.js is missing and nothing could rebuild it.".into()),
+ )
}
- Ok(scaffold)
+}
+
+/// Build `id` if it is stale, under its build lock, re-checking staleness once
+/// the lock is held (a concurrent request may just have built it).
+pub fn ensure_bundle_fresh(store: &ArtifactStore, id: &str) -> std::io::Result {
+ let dir = store.artifact_dir(id)?;
+ with_app_build_lock(&dir, || {
+ let manifest = store.load_manifest(id)?;
+ let needs_bundle =
+ manifest.kind == ArtifactKind::Agentic || dir.join("src/main.ts").is_file();
+ let stale_because = bundle_staleness(&dir, &manifest);
+ let mut remembered = false;
+ let build = match stale_because {
+ None => None,
+ Some(_) => {
+ let inputs = build_inputs_fingerprint(&dir, &manifest);
+ let earlier = FAILED_BUILDS
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner())
+ .get(&dir)
+ .filter(|(seen, _)| *seen == inputs)
+ .map(|(_, report)| report.clone());
+ match earlier {
+ Some(report) => {
+ remembered = true;
+ Some(report)
+ }
+ None => {
+ let report = build_and_stamp_locked(store, id, &dir)?;
+ remember_build(store, id, &dir, &report);
+ Some(report)
+ }
+ }
+ }
+ };
+ Ok(BundleCheck {
+ stale_because,
+ build,
+ remembered,
+ has_bundle: dir.join("dist/app.js").is_file(),
+ needs_bundle,
+ })
+ })
+}
+
+/// The browser address `launch_app` reports for `path`: the daemon's own
+/// listener (`BIOROUTER_APP_BASE_URL`, `base`), else loopback on
+/// `BIOROUTER_PORT` (`port`), else `:3000`.
+///
+/// A daemon bound to every interface publishes `0.0.0.0` or `[::]`. That is an
+/// address to listen on, not one to visit — Chrome refuses `0.0.0.0` — so it
+/// becomes the loopback address of the same family, as the server's launch
+/// route does (`routes::apps::app_browser_origin`).
+fn launch_browser_url(base: Option<&str>, port: Option<&str>, path: &str) -> String {
+ base.and_then(|base| url::Url::parse(base.trim()).ok())
+ .filter(|base| {
+ matches!(base.scheme(), "http" | "https")
+ && base.host_str().is_some()
+ && base.username().is_empty()
+ && base.password().is_none()
+ && base.query().is_none()
+ && base.fragment().is_none()
+ })
+ .and_then(|mut base| {
+ match base.host() {
+ Some(url::Host::Ipv4(ip)) if ip.is_unspecified() => {
+ base.set_host(Some("127.0.0.1")).ok()?
+ }
+ Some(url::Host::Ipv6(ip)) if ip.is_unspecified() => {
+ base.set_host(Some("[::1]")).ok()?
+ }
+ _ => {}
+ }
+ Some(format!("{}{}", base.as_str().trim_end_matches('/'), path))
+ })
+ .unwrap_or_else(|| {
+ let port = port
+ .and_then(|value| value.trim().parse::().ok())
+ .unwrap_or(3000);
+ format!("http://127.0.0.1:{port}{path}")
+ })
}
// ---------------------------------------------------------------------------
-// Full-export payload staging (design §3.9 — Task 2/3)
+// Built-in extensions (the export's payload staging lives in `export.rs`)
// ---------------------------------------------------------------------------
/// The biorouter-mcp built-in MCP server names (see `BUILTIN_EXTENSIONS` in
@@ -1355,359 +1680,6 @@ fn is_builtin_extension(name: &str) -> bool {
BUILTIN_EXTENSION_NAMES.contains(&name)
}
-/// Resolve a `KnowledgeService` for reading the author's knowledge bases while
-/// staging a full export. Honours `BIOROUTER_KNOWLEDGE_DIR` (an override used by
-/// tests and power users); otherwise the canonical store the `/knowledge` routes
-/// use. `None` when the store can't be opened → the caller notes the KBs were
-/// skipped rather than failing the export.
-fn knowledge_service_for_export() -> Option {
- use crate::knowledge::service::KnowledgeService;
- if let Ok(dir) = std::env::var("BIOROUTER_KNOWLEDGE_DIR") {
- if !dir.trim().is_empty() {
- return Some(KnowledgeService::new(PathBuf::from(dir)));
- }
- }
- KnowledgeService::new_default().ok()
-}
-
-/// The installed-skills directory (`/skills`). Honours
-/// `BIOROUTER_SKILLS_DIR` as a test/override hook, mirroring [`default_root`]'s
-/// config-dir resolution.
-fn skills_root_for_export() -> PathBuf {
- if let Ok(dir) = std::env::var("BIOROUTER_SKILLS_DIR") {
- if !dir.trim().is_empty() {
- return PathBuf::from(dir);
- }
- }
- crate::paths::in_config_dir("skills")
-}
-
-/// Locate a `biorouterd` binary to bundle for a fat export. This code runs
-/// inside `biorouterd` (GUI) **or** `biorouter` (CLI), so `current_exe` is not
-/// necessarily the daemon — we look next to it (the GUI ships them side by
-/// side), then on `PATH`. `BIOROUTERD_BIN` overrides (and is the test hook).
-/// `None` → the caller falls back to a thin export.
-fn find_biorouterd_binary() -> Option {
- let exe_name = if cfg!(windows) {
- "biorouterd.exe"
- } else {
- "biorouterd"
- };
- if let Ok(p) = std::env::var("BIOROUTERD_BIN") {
- let pb = PathBuf::from(p);
- return pb.is_file().then_some(pb);
- }
- if let Ok(exe) = std::env::current_exe() {
- if let Some(dir) = exe.parent() {
- let cand = dir.join(exe_name);
- if cand.is_file() {
- return Some(cand);
- }
- }
- }
- if let Ok(path) = std::env::var("PATH") {
- for dir in std::env::split_paths(&path) {
- let cand = dir.join(exe_name);
- if cand.is_file() {
- return Some(cand);
- }
- }
- }
- None
-}
-
-/// Recursively copy `src` into `dst` (used to stage a skill directory tree).
-fn copy_dir_recursive(src: &std::path::Path, dst: &std::path::Path) -> std::io::Result<()> {
- std::fs::create_dir_all(dst)?;
- for entry in std::fs::read_dir(src)? {
- let entry = entry?;
- let from = entry.path();
- let to = dst.join(entry.file_name());
- if from.is_dir() {
- copy_dir_recursive(&from, &to)?;
- } else {
- std::fs::copy(&from, &to)?;
- }
- }
- Ok(())
-}
-
-/// Extract a `Vec` from `include[key]` when it is an array of strings.
-/// `None` (key absent) means the caller falls back to the agent-config default;
-/// `Some(vec![])` (key present but empty) means "select none".
-fn selected_list(include: Option<&serde_json::Value>, key: &str) -> Option> {
- let arr = include?.get(key)?.as_array()?;
- Some(
- arr.iter()
- .filter_map(|v| v.as_str().map(str::to_string))
- .collect(),
- )
-}
-
-/// The pieces staged for a full export, recorded in `export.json` and surfaced
-/// as notes in the tool result.
-struct StagedPayload {
- /// `{id, file, bytes}` per staged `.brkb`.
- knowledge_bases: Vec,
- /// `{name, path}` per staged skill directory.
- skills: Vec,
- /// `{name, source:"registry", note}` per external extension.
- extensions: Vec,
- /// Human-readable notes (skips, failures) — never fatal.
- notes: Vec,
-}
-
-impl StagedPayload {
- fn empty() -> Self {
- Self {
- knowledge_bases: Vec::new(),
- skills: Vec::new(),
- extensions: Vec::new(),
- notes: Vec::new(),
- }
- }
-}
-
-/// Stage the app's server-side payload under `/payload/` (full mode).
-///
-/// Selection is per-item: an explicit `include` list wins; an omitted key falls
-/// back to what the app's agent config references (KB → `agent.knowledge_base`,
-/// skills → `agent.skills`, extensions → `agent.extensions` minus built-ins).
-/// A missing KB / skill is skipped with a note — it never fails the export.
-fn stage_full_payload(
- manifest: &Manifest,
- target: &std::path::Path,
- include: Option<&serde_json::Value>,
- // Issue #56 (CP4). The capability of the session that asked for the export —
- // BOTH axes as one value (issue #56 DR-26 / Task 50, and finding 17's
- // lesson): a signature that took them separately let one call site pass one
- // caller's tier and another's institution, and the wrong one compiles.
- caller: &KbCaller,
-) -> StagedPayload {
- let agent = manifest.agent.clone().unwrap_or_default();
-
- let mut kb_ids = selected_list(include, "knowledge_bases")
- .unwrap_or_else(|| agent.knowledge_base.clone().into_iter().collect());
- let mut skill_names = selected_list(include, "skills").unwrap_or_else(|| agent.skills.clone());
- let mut ext_names = selected_list(include, "extensions").unwrap_or_else(|| {
- agent
- .extensions
- .iter()
- .filter(|e| !is_builtin_extension(e))
- .cloned()
- .collect()
- });
- // Deterministic, deduped ordering so the export is auditable + reproducible.
- for v in [&mut kb_ids, &mut skill_names, &mut ext_names] {
- v.sort();
- v.dedup();
- }
-
- let payload_dir = target.join("payload");
- let mut out = StagedPayload::empty();
-
- // ── Knowledge bases → payload/knowledge/.brkb ──────────────────────
- if !kb_ids.is_empty() {
- match knowledge_service_for_export() {
- Some(svc) => {
- let kdir = payload_dir.join("knowledge");
- for kb in &kb_ids {
- // Issue #56 (CP4), Task 10C. `export_brkb` writes the WHOLE
- // base into the payload, and `kb_ids` comes from the
- // model-supplied `include.knowledge_bases` — a strictly
- // wider `kb_export` that never touches `KnowledgeServer`, so
- // CP1 cannot see it. Skip-and-note rather than fail the
- // export, matching `search_visible_bases`: the rest of the
- // payload is still useful and the user is told what was left
- // out.
- if let Err(e) = caller.assert_reachable(svc.root(), kb) {
- out.notes
- .push(format!("skipped knowledge base '{kb}': {e}"));
- continue;
- }
- match svc.export_brkb(kb) {
- Ok(bytes) => {
- let fname = format!("{kb}.brkb");
- let ok = std::fs::create_dir_all(&kdir).is_ok()
- && std::fs::write(kdir.join(&fname), &bytes).is_ok();
- if ok {
- out.knowledge_bases.push(serde_json::json!({
- "id": kb,
- "file": format!("payload/knowledge/{fname}"),
- "bytes": bytes.len(),
- }));
- } else {
- out.notes.push(format!(
- "could not write knowledge base '{kb}' into the payload; skipped"
- ));
- }
- }
- Err(e) => out
- .notes
- .push(format!("skipped knowledge base '{kb}': {e}")),
- }
- }
- }
- None => out.notes.push(format!(
- "knowledge store unavailable; skipped {} knowledge base(s)",
- kb_ids.len()
- )),
- }
- }
-
- // ── Skills → payload/skills// (plain directory copy) ─────────────
- // A plain recursive copy is used rather than a marketplace-format zip: it is
- // simpler, needs no extra crate, and the launcher installs it with a plain
- // dir copy into the skills dir. (The design allows either.)
- if !skill_names.is_empty() {
- let skills_root = skills_root_for_export();
- let sdir = payload_dir.join("skills");
- for name in &skill_names {
- let src = skills_root.join(name);
- if src.is_dir() {
- let dst = sdir.join(name);
- if copy_dir_recursive(&src, &dst).is_ok() {
- out.skills.push(serde_json::json!({
- "name": name,
- "path": format!("payload/skills/{name}"),
- }));
- } else {
- out.notes.push(format!(
- "could not copy skill '{name}' into the payload; skipped"
- ));
- }
- } else {
- out.notes
- .push(format!("skill '{name}' not installed; skipped"));
- }
- }
- }
-
- // ── External extensions → recorded as registry references only ─────────
- // Staging installed `.brxt` bundles (the installed-bundle layout) is out of
- // scope; a full export records external extensions as pinned registry
- // references the first-run installer resolves from BAAM.
- for name in &ext_names {
- out.extensions.push(serde_json::json!({
- "name": name,
- "source": "registry",
- "note": "install from the BAAM registry on the target; .brxt bundle staging is out of scope",
- }));
- }
-
- out
-}
-
-/// Assemble the deterministic `export.json` payload manifest (design §3.9 —
-/// Task 3). `required_credentials` and `runtime_requirements` are empty: the app
-/// manifest does not carry the extensions' declared credential env keys or
-/// runtime prerequisites (those live in each extension's own bundle metadata /
-/// the BAAM registry, not in the app manifest), so they cannot be enumerated
-/// here without the registry. The first-run credential dialog (§3.9 item 3)
-/// resolves what a specific extension needs.
-fn build_export_json(
- id: &str,
- mode: &str,
- staged: &StagedPayload,
- bundled_daemon: Option<&serde_json::Value>,
-) -> serde_json::Value {
- serde_json::json!({
- "version": 1,
- "app": id,
- "mode": mode,
- "knowledge_bases": staged.knowledge_bases,
- "skills": staged.skills,
- "extensions": staged.extensions,
- "bundled_daemon": bundled_daemon,
- "required_credentials": [],
- "runtime_requirements": [],
- })
-}
-
-/// The assistant-facing summary `export_app` returns, split out of
-/// `export_app_inner` so that function stays under `clippy::too_many_lines`
-/// (issue #56, review round 5). No behaviour change.
-fn export_summary(
- id: &str,
- mode: &str,
- target: &Path,
- scaffold_files: usize,
- staged: &StagedPayload,
- wrote_manifest: bool,
- notes: &[String],
-) -> String {
- let mut msg = format!(
- "Exported '{}' ({mode} mode) to {} ({} scaffold files). \
- To run it: double-click run.command (macOS), `bash run.sh` (Linux), or run.bat (Windows). \
- That installs the app, starts a biorouterd if one isn't already up, and opens it in the browser, with \
- no npm install and no build step.",
- id,
- target.display(),
- scaffold_files
- );
- if mode == "full" {
- msg.push_str(&format!(
- "\nPayload: {} knowledge base(s), {} skill(s), {} external extension reference(s).",
- staged.knowledge_bases.len(),
- staged.skills.len(),
- staged.extensions.len()
- ));
- }
- if wrote_manifest {
- msg.push_str(" Wrote export.json (audit manifest).");
- }
- for n in notes {
- msg.push_str(&format!("\n- {n}"));
- }
- msg
-}
-
-/// Stage the current-platform `biorouterd` under `payload/bin/` for a fat
-/// export. Returns the `export.json` record on success (or `None` → thin), plus
-/// a note either way.
-fn stage_current_daemon(target: &std::path::Path) -> (Option, String) {
- let bin_name = if cfg!(windows) {
- "biorouterd.exe"
- } else {
- "biorouterd"
- };
- match find_biorouterd_binary() {
- Some(bin) => {
- let dst = target.join("payload").join("bin").join(bin_name);
- if let Some(parent) = dst.parent() {
- let _ = std::fs::create_dir_all(parent);
- }
- match std::fs::copy(&bin, &dst) {
- Ok(bytes) => {
- make_executable(&dst);
- let rec = serde_json::json!({
- "platform": std::env::consts::OS,
- "arch": std::env::consts::ARCH,
- "file": format!("payload/bin/{bin_name}"),
- "bytes": bytes,
- });
- (
- Some(rec),
- format!(
- "bundled the {}-{} daemon at payload/bin/{bin_name} (fat export)",
- std::env::consts::OS,
- std::env::consts::ARCH
- ),
- )
- }
- Err(e) => (
- None,
- format!("could not copy biorouterd into the payload ({e}); thin export instead"),
- ),
- }
- }
- None => (
- None,
- "biorouterd not found for a fat export; thin export instead (the launcher locates or installs a daemon at run time)".to_string(),
- ),
- }
-}
-
/// Issue #56 (CP4/CP5). `export_app`, `configure_app` and `update_app` gained a
/// `RequestContext` so they can read the caller's capability; the unit tests
/// below drive their bodies without fabricating one, and are all public-caller
@@ -1812,7 +1784,16 @@ impl AgentDrafterServer {
Two kinds:
- "agentic" (default): a UI plus a live Biorouter agent + chat. Use this
for assistants, dashboards that reason over results, search tools, etc.
- - "static": a plain interactive page with no agent.
+ Its code belongs in `src/main.ts`: the SDK (`createApp`, `br.*`)
+ exists only inside the bundle, so an inline script cannot reach the
+ agent. Run `build_app` after editing `src/`.
+ - "static": a plain interactive page with no agent. Write it in
+ `index.html`. Inline `";
+
+/// Remove every `open … close` block (and the newline the assembler writes
+/// after it) from `html`.
+fn remove_blocks(html: &str, open: &str, close: &str) -> String {
+ let mut out = String::with_capacity(html.len());
+ let mut rest = html;
+ while let Some((before, after_open)) = rest.split_once(open) {
+ let Some((_, after_close)) = after_open.split_once(close) else {
+ break;
+ };
+ out.push_str(before);
+ rest = after_close.strip_prefix('\n').unwrap_or(after_close);
+ }
+ out.push_str(rest);
+ out
+}
+
+/// Undo an earlier assembly, so assembling is idempotent.
+///
+/// ⚠ This is what heals exported apps. An export used to ship an already
+/// assembled `index.html`, every launcher installed it as the app's entry, and
+/// the daemon assembled it again: two config islands, and the SDK reads the
+/// FIRST — the export's, which has no socket token — so the agent socket was
+/// refused; the bundle ran twice; and each re-export nested one more layer. It
+/// removes exactly what the assembler emits and nothing an author writes: the
+/// theme and overrides `");
+ out = remove_blocks(&out, THEME_OVERRIDES_TAG, "");
+ out = remove_blocks(&out, CONFIG_ISLAND_TAG, "");
+ out = remove_blocks(&out, BUNDLE_TAG, "");
+ static BASE: std::sync::LazyLock = std::sync::LazyLock::new(|| {
+ regex::Regex::new(r#" \n?"#).expect("static regex")
+ });
+ out = BASE.replace_all(&out, "").into_owned();
+ if let Some(start) = find_ascii_ci(&out, "') {
+ static PACK: std::sync::LazyLock = std::sync::LazyLock::new(|| {
+ regex::Regex::new(r#" data-br-pack="[^"]*""#).expect("static regex")
+ });
+ out = format!("{before}{}>{after}", PACK.replace_all(tag, ""));
+ }
+ }
+ out
+}
fn theme_block() -> String {
format!("{THEME_TAG}\n{THEME_CSS}\n\n")
@@ -114,7 +196,7 @@ fn pack_attr_value(manifest: &Manifest) -> Option<&str> {
/// `` and ``; if the document has no `` tag (a bare
/// fragment) the string is returned unchanged — the base tokens still apply.
fn set_root_pack(html: &str, pack: &str) -> String {
- if let Some(pos) = html.to_lowercase().find(" String {
///
/// `ws_token` is the per-app socket token the SDK must present on the agent
/// WebSocket (`?token=…`) — `serve_index` mints one per daemon run and injects
-/// it here so any page this daemon serves can authenticate. Exports pass `None`
-/// (their proxy mints its own token in a later phase).
+/// it here so any page this daemon serves can authenticate. A page assembled
+/// with `None` cannot open the agent socket at all; it is only for previews and
+/// offline views, which is why the daemon always re-assembles the stored entry
+/// (and [`strip_injected`] drops any island assembled earlier).
pub fn app_config_script(
manifest: &Manifest,
endpoint: Option<&str>,
@@ -181,6 +265,9 @@ pub fn app_config_script(
/// `base_href` should be the serving prefix (e.g. `/apps//`) so that the
/// relative `dist/app.js` and any relative assets resolve correctly. `endpoint`
/// overrides the agent WebSocket URL (None → derived from page location).
+///
+/// The bundle tag is included for an agentic app, which always has one; a
+/// caller that knows whether `dist/app.js` exists uses [`assemble_app_page`].
pub fn assemble_app(
manifest: &Manifest,
index_html: &str,
@@ -201,12 +288,68 @@ pub fn assemble_app_with_endpoints(
endpoints: &[String],
ws_token: Option<&str>,
) -> String {
+ assemble(
+ manifest,
+ index_html,
+ base_href,
+ endpoint,
+ endpoints,
+ ws_token,
+ manifest.kind == ArtifactKind::Agentic,
+ None,
+ )
+}
+
+/// The page `serve_index` sends for `/apps//`.
+///
+/// `has_bundle` is whether `dist/app.js` exists. ⚠ The bundle tag used to be
+/// appended unconditionally, so a static page with no bundle asked the browser
+/// for a file that 404s, and one whose author already loads its bundle
+/// (`\n");
+ let mut tail = String::new();
+ if let Some(nonce) = handler_nonce {
+ let (bound, binder) = bind_inline_handlers(&html, nonce);
+ html = bound;
+ tail.extend(binder);
+ }
+ tail.push_str(&app_config_script(manifest, endpoint, endpoints, ws_token));
+ if include_bundle && !authored_inline_code(&authored).loads_bundle() {
+ tail.push_str(BUNDLE_TAG);
+ tail.push('\n');
+ }
inject_before(&html, "", &tail, true)
}
@@ -226,26 +378,901 @@ pub fn assemble_app_with_endpoints(
/// are launched in the browser. Shows the styled UI plus a launch hint banner.
pub fn assemble_card(manifest: &Manifest, index_html: &str) -> String {
let head = theme_head(manifest);
- let mut html = inject_after(index_html, "", &head);
+ let authored = strip_injected(index_html);
+ let mut html = inject_after(&authored, "", &head);
if !html.contains(THEME_TAG) {
html = format!("{head}{html}");
}
if let Some(pack) = pack_attr_value(manifest) {
html = set_root_pack(&html, pack);
}
+ let launch_hint = if manifest.kind == ArtifactKind::Agentic {
+ "Launch from the Applications panel to use the live agent."
+ } else {
+ "Launch from the Applications panel to open it in your browser."
+ };
let banner = format!(
"\
- Preview of {} . Launch from the Applications panel to use the live agent.
\n",
+ Preview of {} . {launch_hint}\n",
html_escape(&manifest.title)
);
inject_after(&html, "", &banner)
}
+// ---------------------------------------------------------------------------
+// Authored inline code, and the policy sources that let exactly it run
+// ---------------------------------------------------------------------------
+
+/// The executable code an author wrote straight into an HTML page.
+#[derive(Debug, Default, Clone, PartialEq, Eq)]
+pub struct InlineCode {
+ /// The text of every inline executable ` bool {
+ let lower = body.to_ascii_lowercase();
+ if lower.contains(" String {
+ let functions = bodies
+ .iter()
+ .map(|body| handler_function(body))
+ .collect::>()
+ .join(",\n");
+ format!(
+ r#"
+"#
+ )
+}
+
+/// Move the `on…=` handlers written in `html` into a script, returning the
+/// rewritten page and that script (`None` when there was nothing to move).
+///
+/// Each handler attribute becomes `data-br-on-="."`, and the
+/// script holds handler `n` as a function and attaches it, ONCE, to each
+/// element whose marker names this response's `nonce`. It runs before the
+/// app's bundle and before any agent frame can arrive, so markup added later —
+/// an injection — is never bound: it would need the nonce, which it never sees
+/// (the script removes each marker as it reads it), and the script refuses to
+/// run twice. This is what lets a static page's `onclick="…"` work under a
+/// policy without `'unsafe-hashes'` ([`inline_script_sources`] says why that is
+/// not sent).
+///
+/// What is left as written, and so stays blocked as every handler was before
+/// hashes: a handler inside a `` (a clone could not carry the
+/// listener, and binding clones later would bind injections too), and one the
+/// script could not hold ([`handler_is_bindable`]). A repeated attribute, which
+/// a browser drops, is removed.
+pub fn bind_inline_handlers(html: &str, nonce: &str) -> (String, Option) {
+ let mut edits: Vec<(std::ops::Range, String)> = Vec::new();
+ let mut bodies: Vec = Vec::new();
+ let mut template_depth = 0usize;
+ scan_tags(html, |tag| match tag {
+ Tag::End { name } => {
+ if name == "template" {
+ template_depth = template_depth.saturating_sub(1);
+ }
+ }
+ Tag::Start { name, attrs, .. } => {
+ if template_depth == 0 {
+ for attr in attrs.iter().filter(|a| is_handler_name(&a.key)) {
+ if attr.duplicate {
+ edits.push((attr.span.clone(), String::new()));
+ continue;
+ }
+ if !handler_is_bindable(&attr.value) {
+ continue;
+ }
+ let n = bodies
+ .iter()
+ .position(|body| *body == attr.value)
+ .unwrap_or_else(|| {
+ bodies.push(attr.value.clone());
+ bodies.len() - 1
+ });
+ let event = attr.key.strip_prefix("on").unwrap_or_default();
+ edits.push((
+ attr.span.clone(),
+ format!("{HANDLER_MARKER}{event}=\"{nonce}.{n}\""),
+ ));
+ }
+ }
+ if name == "template" {
+ template_depth += 1;
+ }
+ }
+ });
+ if bodies.is_empty() && edits.is_empty() {
+ return (html.to_string(), None);
+ }
+ let mut out = String::with_capacity(html.len());
+ let mut copied = 0;
+ for (span, replacement) in edits {
+ out.push_str(html.get(copied..span.start).unwrap_or_default());
+ out.push_str(&replacement);
+ copied = span.end;
+ }
+ out.push_str(html.get(copied..).unwrap_or_default());
+ let script = (!bodies.is_empty()).then(|| handler_binder_script(nonce, &bodies));
+ (out, script)
+}
+
+/// Elements whose content is text, not markup: a `\
+ ";
+ assert_eq!(strip_injected(authored), authored);
+ }
+
+ /// ⚠ The insertion offset used to come from a lowercased COPY of the page:
+ /// U+2126 OHM SIGN lowercases 3 → 2 bytes and U+0130 'İ' 2 → 3, so a page
+ /// with "10 kΩ" spliced the config island into the middle of a tag, and an
+ /// offset inside a character panicked the request.
+ #[test]
+ fn injection_offsets_survive_characters_whose_lowercase_changes_length() {
+ let m = manifest(ArtifactKind::Agentic);
+ for odd in [
+ "10 k\u{2126}",
+ "\u{0130}stanbul",
+ "\u{212A}elvin \u{212B}ngstr\u{f6}m",
+ ] {
+ let body = format!("{odd}
{odd}
");
+ let raw = format!("{body}");
+ let out = assemble_app(&m, &raw, None, None, Some("t"));
+ let (before_island, _) = out.split_once(ISLAND).expect("island");
+ assert!(before_island.ends_with(&body), "{odd}: {out}");
+ assert!(out.ends_with("\n"), "{odd}: {out}");
+ assert!(
+ out.contains("\n"),
+ );
+ for (url, source) in [
+ (CDN_D3, D3_MIN),
+ (CDN_D3_SANKEY, D3_SANKEY),
+ (CDN_CHART, CHART_MIN),
+ (CDN_LEAFLET_JS, LEAFLET_JS),
+ (CDN_MARKERCLUSTER_JS, MARKERCLUSTER_JS),
+ (CDN_MERMAID, MERMAID_MIN),
+ ] {
+ let tag = script_src(url);
+ if out.contains(&tag) {
+ out = out.replace(&tag, &script_inline(source));
+ }
+ }
+ out
+}
+
// ---------------------------------------------------------------------------
// Template assembly + result construction
// ---------------------------------------------------------------------------
@@ -664,3 +698,51 @@ pub fn parse_keyword(raw: &str, accepted: &[&str]) -> Resul
)))
}
}
+
+#[cfg(test)]
+mod inline_cdn_tests {
+ use super::*;
+
+ /// A figure rendered in CDN mode leaves this function with no remote
+ /// reference at all, and with the vendored bytes in its place: an app's
+ /// figure frame allows no remote host, so a tag left behind is a blank chart.
+ #[test]
+ fn every_cdn_tag_becomes_its_vendored_copy() {
+ let cdn_page = format!(
+ "{}{}{}{} \n{}{}{}\
+
",
+ script_src(CDN_D3),
+ script_src(CDN_D3_SANKEY),
+ script_src(CDN_CHART),
+ script_src(CDN_MERMAID),
+ script_src(CDN_LEAFLET_JS),
+ script_src(CDN_MARKERCLUSTER_JS),
+ "",
+ );
+ let inlined = inline_pinned_cdn_assets(&cdn_page);
+ assert!(
+ !inlined.contains("cdn.jsdelivr.net"),
+ "a remote reference survived"
+ );
+ assert!(
+ !inlined.contains(""));
+
+ // A document with nothing to inline comes back unchanged.
+ let plain = "";
+ assert_eq!(inline_pinned_cdn_assets(plain), plain);
+ }
+}
diff --git a/crates/biorouter-mcp/src/autovisualiser/mod.rs b/crates/biorouter-mcp/src/autovisualiser/mod.rs
index b88c1e989..51e1da65f 100644
--- a/crates/biorouter-mcp/src/autovisualiser/mod.rs
+++ b/crates/biorouter-mcp/src/autovisualiser/mod.rs
@@ -7,6 +7,8 @@
mod common;
+pub use common::inline_pinned_cdn_assets;
+
use base64::{engine::general_purpose::STANDARD, Engine as _};
use common::{
check_limit, html_escape, invalid, js_data, js_value, render, Asset, MAX_LABELS, MAX_LINKS,
diff --git a/crates/biorouter-server/src/auth.rs b/crates/biorouter-server/src/auth.rs
index b617d53ed..27f2a83f6 100644
--- a/crates/biorouter-server/src/auth.rs
+++ b/crates/biorouter-server/src/auth.rs
@@ -346,6 +346,26 @@ fn failed_attempt_verdict(attempts: &mut Vec, now: Instant) -> StatusCo
// answers it the same way: that token opens every app below on a serve daemon
// (`app_access_granted_by`), and it is not single use.
//
+// ⚠ Every app shares the daemon's one origin (and on `biorouter serve`, the
+// shell that embeds the daemon secret does too). A cookie's `Path` is no
+// boundary between same-origin pages, and a cookie is not scoped by port at
+// all (`redeem_app_launch`). What separates
+// one app's page from another's script today: an app's page is served on a
+// browser credential only as a document navigation (`is_document_navigation`)
+// and cannot be framed, its run-state and model routes want the token only that
+// page holds, and the serve shell refuses anything but a navigation too. A
+// popup is a navigation, so the opener policies are what sever it: an app page
+// is `noopener-allow-popups` (`routes::apps::APP_PAGE_COOP`) and the shell
+// `same-origin` (`routes::web_ui`), so whoever opens either gets a closed
+// handle (measured in Chrome 155). ⚠ All of that needs a secure context: at a
+// plain-HTTP network address a browser sends no Fetch Metadata and ignores
+// opener policies, so neither the shell nor app surfaces are served on a
+// browser credential there (`reached_over_a_secure_context`). ⚠ An engine that does not know
+// `noopener-allow-popups` treats it as `unsafe-none` and keeps the handle; and
+// origin storage (`localStorage`) is shared by every app. Serving each app on
+// its own origin is the follow-up that closes both; see
+// `docs/deployment/browser-access.md`.
+//
// ⚠ The stores live HERE, in the lib-only `auth`, and not in `routes::apps`:
// `src/routes/` is compiled twice (`lib.rs`), so a static there exists once for
// the daemon binary's routes and once for this module, and a link the launch
@@ -412,6 +432,12 @@ impl AppAccess {
self.cookies.get(app_id).cloned()
}
+ /// Drop `app_id`'s cookie value and every unredeemed link to it.
+ fn forget(&mut self, app_id: &str) {
+ self.cookies.remove(app_id);
+ self.launches.retain(|_, (app, _)| app != app_id);
+ }
+
/// Compares against a stand-in when `app_id` has no cookie, so the work done
/// does not say whether a link was ever minted for that app.
fn cookie_matches(&self, app_id: &str, presented: &str) -> bool {
@@ -463,9 +489,29 @@ pub fn mint_app_launch(app_id: &str) -> String {
/// for another app gets `None`, and a redeemed one is gone.
///
/// `Path=/apps/{id}` (no trailing slash) covers the redirect route, the page and
-/// everything below it, and no other app. `HttpOnly` keeps the page's own script
-/// from reading it, and `SameSite=Strict` keeps it off every request another site
-/// starts: the two flags `routes::web_ui` sets on the served document's cookie.
+/// everything below it. `HttpOnly` keeps the page's own script from reading it,
+/// and `SameSite=Strict` keeps it off every request another site starts: the
+/// two flags `routes::web_ui` sets on the served document's cookie.
+///
+/// ⚠ `Path` decides which requests the browser attaches the cookie to, and is
+/// NOT a boundary between pages: every app is served from the daemon's one
+/// origin, so app A's script that requests `/apps/B/…` gets B's cookie
+/// attached. What keeps A out of B is that B's page, the one document holding
+/// B's socket token, is served on the cookie only as a navigation
+/// (`check_token`), and that B's socket, run state and model routes want that
+/// token. A popup of B's page is a navigation and is served; B's opener policy
+/// (`routes::apps::APP_PAGE_COOP`) is what leaves A no handle on it, in an
+/// engine that knows the policy. Per-app origins (`http://.localhost:`,
+/// routed by `Host`) are the follow-up that makes all of this structural, and
+/// are not built yet.
+///
+/// ⚠ Nor is a cookie scoped by PORT, and `SameSite` counts every port of
+/// 127.0.0.1 as the same site. A page on another loopback port that requests
+/// `http://127.0.0.1:/apps/x/` sends this cookie to whoever listens
+/// there, if anyone does. The name ([`app_access_cookie_name`]) and value are
+/// per daemon run, so a captured cookie opens one app until this daemon stops.
+/// That is browser behaviour no flag here changes; it is recorded in
+/// `docs/deployment/browser-access.md`.
pub fn redeem_app_launch(app_id: &str, presented: &str) -> Option {
let value = app_access().redeem(app_id, presented, Instant::now())?;
Some(format!(
@@ -474,6 +520,14 @@ pub fn redeem_app_launch(app_id: &str, presented: &str) -> Option {
))
}
+/// Forget `app_id`'s access: its cookie value and its unredeemed launch links.
+/// Called when the app is deleted (`routes::apps::forget_app_runtime`), so a
+/// tab of the deleted app cannot reload into an app built under the same id
+/// later; a new launch mints a new cookie value.
+pub fn forget_app_access(app_id: &str) {
+ app_access().forget(app_id);
+}
+
/// The access cookie a request carries, if any.
fn app_access_cookie(headers: &axum::http::HeaderMap) -> Option<&str> {
let name = app_access_cookie_name();
@@ -505,8 +559,8 @@ struct AppBrowserGet<'a> {
}
/// The routes a browser loads for a live app: its page, bundle, assets, model
-/// catalog, run state and agent socket. Recognised by shape, before any handler
-/// runs, so the answer never depends on whether the app exists.
+/// catalog, run state, figure frame and agent socket. Recognised by shape,
+/// before any handler runs, so the answer never depends on whether the app exists.
fn is_app_browser_get<'a>(method: &axum::http::Method, path: &'a str) -> Option> {
if method != axum::http::Method::GET {
return None;
@@ -527,7 +581,7 @@ fn is_app_browser_get<'a>(method: &axum::http::Method, path: &'a str) -> Option<
let tail = segments.collect::>();
let browser = matches!(
tail.as_slice(),
- [] | [""] | ["agent"] | ["models"] | ["runstate"]
+ [] | [""] | ["agent"] | ["models"] | ["runstate"] | ["figure-frame"]
) || matches!(tail.as_slice(), ["dist" | "assets", _, ..]);
browser.then_some(AppBrowserGet {
id,
@@ -568,6 +622,102 @@ fn app_access_granted_by(
&& launch_token(query).is_some_and(|token| access.pending(app.id, token, Instant::now()))
}
+/// Whether the address a request was sent to is one a browser treats as a
+/// secure context: a loopback address, `localhost` or a name under
+/// `.localhost`, or HTTPS forwarded by a TLS proxy with Fetch Metadata from
+/// the browser (`routes::request_scheme`). A request with no `Host` (nor authority) was not
+/// sent by a browser, and counts as one.
+///
+/// It is what decides whether [`is_document_navigation`] and the opener
+/// policies mean anything. A browser sends Fetch Metadata only to such an
+/// address and ignores `Cross-Origin-Opener-Policy` everywhere else, so at a
+/// plain-HTTP network address (`biorouter serve --host 0.0.0.0`, opened as
+/// `http://192.168.x.y:port`) an app's `fetch` of another app's page, or of the
+/// serve shell, arrives looking exactly like the person opening it, and a popup
+/// stays scriptable. Measured in Chrome: every `fetch`, frame and popup there
+/// arrived with no `Sec-Fetch-*` at all, where on 127.0.0.1 the same script was
+/// told apart and severed.
+///
+/// `X-Forwarded-Proto` alone proves nothing: a page left open from an older
+/// daemon can set it on an HTTP LAN fetch. Such a browser sends no Fetch
+/// Metadata and cannot forge those headers, so forwarded HTTPS also requires
+/// both metadata headers. Secret-header callers do not depend on this gate.
+pub fn reached_over_a_secure_context(
+ headers: &axum::http::HeaderMap,
+ uri: &axum::http::Uri,
+) -> bool {
+ let host = match headers.get(axum::http::header::HOST) {
+ Some(value) => match value.to_str() {
+ Ok(host) => host,
+ // Present and unreadable is not absent.
+ Err(_) => return false,
+ },
+ None => match uri.authority() {
+ Some(authority) => authority.as_str(),
+ None => return true,
+ },
+ };
+ host_is_loopback(host)
+ || (crate::routes::request_scheme(headers) == "https"
+ && ["sec-fetch-mode", "sec-fetch-dest"].iter().all(|name| {
+ headers
+ .get(*name)
+ .and_then(|value| value.to_str().ok())
+ .is_some_and(|value| !value.trim().is_empty())
+ }))
+}
+
+/// Whether a `Host` value names this machine the way a browser counts as
+/// secure: an address in `127.0.0.0/8` or `::1`, `localhost`, or a name under
+/// `.localhost` (a trailing dot allowed). A port is ignored; anything that does
+/// not parse as a host is not loopback.
+fn host_is_loopback(host: &str) -> bool {
+ let host = host.trim();
+ let name = match host.strip_prefix('[') {
+ Some(bracketed) => match bracketed.split_once(']') {
+ Some((address, _port)) => address,
+ None => return false,
+ },
+ None => host.split_once(':').map_or(host, |(name, _port)| name),
+ };
+ if let Ok(address) = name.parse::() {
+ return address.is_loopback();
+ }
+ let name = name.strip_suffix('.').unwrap_or(name).to_ascii_lowercase();
+ name == "localhost" || name.ends_with(".localhost")
+}
+
+/// Whether a request is a top-level document navigation, as far as its Fetch
+/// Metadata says: `Sec-Fetch-Mode: navigate` and `Sec-Fetch-Dest: document`.
+///
+/// A request that carries neither header is treated as one. Every client that
+/// is not a browser sends none (the CLI, the desktop's main process, `curl`,
+/// a test), and neither does a browser too old to send them; refusing those
+/// would break them and protect nothing, since only a browser runs a page's
+/// script. A header that is present and cannot be read counts against it.
+///
+/// Used for the documents that hold something a page of the same origin must
+/// not read: an app's page, which embeds its socket token (`check_token`), and
+/// a `biorouter serve` shell, which embeds the daemon secret (`routes::web_ui`).
+/// Script cannot set these headers, so `fetch`, an `
-
-', `\n`, true);
- if (agentic) {
- let block = agentConfigScript(agent || {});
- if (!html.toLowerCase().includes('data-br-chat')) {
- block += '
\n';
- }
- block += `\n`;
- html = injectBefore(html, '', block, true);
- }
- return html;
+const daemon = spawn(daemonBin, ["agent"], {
+ env: childEnv,
+ cwd: sandbox,
+ stdio: "ignore",
+});
+const exited = once(daemon, "exit").catch(() => undefined);
+let spawnError;
+daemon.on("error", (error) => {
+ spawnError = error;
+});
+let browser;
+let assertions = 0;
+function check(label, actual, expected = true) {
+ assert.deepEqual(actual, expected, label);
+ assertions++;
+ console.log(`[app-policy] PASS ${label}`);
}
+const pause = (ms) => new Promise((done) => setTimeout(done, ms));
-// --- the host page: emulates @mcp-ui renderer + Biorouter action routing -----
-const HOST_PAGE = `