Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0bf2aa4
ci: build and test Windows, so a Windows break cannot pass review
Amir-SSVLabs Aug 12, 2026
5d72be2
browser: the engine's window handle is macOS's, and the module compil…
Amir-SSVLabs Aug 12, 2026
89874b7
build: call detection's types are imported where it is compiled
Amir-SSVLabs Aug 12, 2026
9264ff0
brains: a Windows dev build finds its token, and never had a dialog t…
Amir-SSVLabs Aug 12, 2026
2b24db0
recall: tar is handed relative paths, which is the form both tars accept
Amir-SSVLabs Aug 12, 2026
72673e4
model: the PATH separator is `;` on Windows, and the CLI was unfindab…
Amir-SSVLabs Aug 12, 2026
d8b5f15
storage: the wipe guard's cwd encoding flattens a drive colon too
Amir-SSVLabs Aug 12, 2026
84439b9
storage: the `~/.exo` relic guard reads USERPROFILE as well as HOME
Amir-SSVLabs Aug 12, 2026
fe603ad
agents: the seatbelt suite is macOS's, and the test binary compiles e…
Amir-SSVLabs Aug 12, 2026
3d71424
recording: the sidecar binary assertion carries .exe on Windows
Amir-SSVLabs Aug 12, 2026
e87d525
session: the ledger's builder list is compared in one spelling
Amir-SSVLabs Aug 12, 2026
47e0010
build: ignore the staged Recall runtime, which was untracked but not …
Amir-SSVLabs Aug 12, 2026
dc8ba91
Merge dev into fix/windows-build
Amir-SSVLabs Aug 12, 2026
7b27533
Merge dev again — the recording PR (#55) landed mid-review
Amir-SSVLabs Aug 12, 2026
7264d9d
recording: a dying sidecar can be caught at any of three points, not two
Amir-SSVLabs Aug 12, 2026
27b38e0
Merge dev — #45 landed, so its Windows job gives way to this matrix
Amir-SSVLabs Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 34 additions & 57 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,24 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [macos-latest]
# WINDOWS IS A SHIPPING PLATFORM, so it is built here. It was not, and the cost
# was exact: `browser_host.rs` reached for `ns_window()` — macOS-only — in a
# module `lib.rs` declares unconditionally, and the default Windows build stopped
# linking. Every job was green through review and merge, because not one of them
# compiled for Windows. `fail-fast: false` so a break on one OS still reports the
# other rather than hiding it.
#
# THIS MATRIX REPLACED A SEPARATE `Windows (recording)` job (#45), which existed
# only because extending the matrix was impossible before this PR: `--workspace`
# was red on Windows (`agents/local/src/sandbox.rs` asserted `/private/tmp`
# ungated) and `brains-desktop` did not link there at all. Both are fixed here, so
# the matrix now covers strictly more than that job did — its clippy and tests are
# the two steps below — and two Windows mechanisms would just drift apart.
#
# NEVER `continue-on-error` on either leg. A job that cannot go red is worse than
# no job, because it reads as coverage: a green Windows job in the reference app
# once hid 14 failing tests.
os: [macos-latest, windows-latest]
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

Expand All @@ -79,14 +96,20 @@ jobs:
with:
workspaces: src-tauri

# `shell: bash` on every scripted step from here down. The Windows runner defaults
# to PowerShell, where `if ! cmd; then`, `mkdir -p` and `: >` are syntax errors —
# and GitHub ships Git Bash on that image, so one line keeps a single script for
# both operating systems instead of a PowerShell fork of each.
#
# NOT WIDENED TO `cargo fmt --all --check`, though it should be. The
# engine crates are unchecked today, and widening was scoped into this
# PR — but the base is ~160 fmt-dirty sites across the workspace, and
# engine crates are unchecked today, and widening was scoped into #45
# — but the base is ~160 fmt-dirty sites across the workspace, and
# this narrower check is ALREADY failing on dev (src-tauri/src/lib.rs
# alone has 11). Widening it here would mean either landing red or
# burying an audio review under a whole-workspace reformat. Do the
# burying a review under a whole-workspace reformat. Do the
# reformat as its own commit, then add --all.
- name: Format check
shell: bash
run: |
if ! cargo fmt --manifest-path src-tauri/Cargo.toml --check; then
echo ""
Expand All @@ -96,6 +119,7 @@ jobs:
fi

- name: Create frontend build stub
shell: bash
run: mkdir -p build

# A bare-cargo job runs no beforeBuildCommand, and tauri_build hard-fails
Expand Down Expand Up @@ -124,6 +148,12 @@ jobs:
run: cargo test --workspace

- name: Check the bundled Chromium engine compiles
# macOS ONLY, and not an oversight: the engine's own modules are AppKit — `app_mac`,
# `clip`, and the `objc2-app-kit` views `engine.rs` is written against. The feature
# does not claim to build off macOS (`platform_supported()` answers for Windows via
# the OS webview), so asking Windows to type-check it would fail on the honest
# absence of a macOS-only backend rather than on anything wrong.
if: runner.os == 'macOS'
# ~2,000 feature-gated lines are invisible to every step above, because the engine
# is off by default and CI has no reason to turn it on. Without this a change to
# `Layout` or `host::Host` breaks them silently and the break surfaces on a
Expand All @@ -133,59 +163,6 @@ jobs:
# it type-checks the code against the pinned bindings, which is the part that rots.
run: cargo check -p brains-browser --features chromium

# WHY THIS JOB EXISTS: call recording had never been run on Windows, and the
# reason it could go that long is that nothing here ever compiled for it.
# The bugs that cost a bring-up day were a verbatim resource path, a missing
# process-tree kill, and a wire type in snake_case — the first two are
# Windows-only by construction and the third was invisible everywhere.
#
# It is deliberately NARROW and deliberately BLOCKING. Never
# continue-on-error: a job that cannot go red is worse than no job, because
# it reads as coverage — a green Windows job here once hid 14 failing tests.
windows:
name: Windows (recording)
runs-on: windows-latest
timeout-minutes: 45
defaults:
run:
# Every step here is POSIX; the runner's default shell is pwsh.
shell: bash
steps:
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0

- uses: dtolnay/rust-toolchain@4cda84d5c5c54efe2404f9d843567869ab1699d4 # stable branch
with:
toolchain: stable
components: clippy

- uses: Swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
with:
workspaces: src-tauri

# SCOPED TO brains-recording ON PURPOSE, and the scope is the whole
# design of this job. Two things are knowingly excluded:
#
# * --workspace, because src/engines/agents/local/src/sandbox.rs
# asserts real_path("/tmp") == "/private/tmp" with no platform gate,
# so the workspace is red on Windows for reasons that have nothing
# to do with recording.
# * -p brains-desktop --lib, because it pulls brains-storage in and
# that crate has its own pre-existing clippy debt (backups.rs
# sort_by_key, previews.rs needless borrows).
#
# Both are worth fixing and both are their own PR. Widening this job
# before they are fixed would mean landing it red, and a red-on-arrival
# job gets `continue-on-error` bolted on within a week — which is the
# exact failure this job exists to avoid.
- name: Clippy (recording engine, tests included)
run: cargo clippy -p brains-recording --all-targets -- -D warnings

- name: Unit tests (recording engine)
run: cargo test -p brains-recording

- name: Build the recording engine
run: cargo build -p brains-recording

eval:
name: Eval (token-less)
runs-on: macos-latest
Expand Down
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,11 @@ src-tauri/resources/
# committed: hundreds of MB of vendor native code, and the bundled Node.
src-tauri/recall/
src-tauri/binaries/
# The verified SDK archive `package-runtime.mjs` builds, plus its manifest —
# ~37MB, generated, and the one staging directory the list above missed. It was
# merely untracked, which is not the same as ignored: it sat in every
# `git status` waiting for one `git add -A` to commit the vendored SDK.
src-tauri/recall-runtime/

# Environment
.env
Expand Down
8 changes: 6 additions & 2 deletions scripts/dev/recall/package-runtime.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,12 @@ if (!existsSync(join(sdkDir, "node_modules"))) {
]);
}
// Through host.mjs: GNU tar (first on PATH under Git for Windows) reads the
// `C:` in an absolute path as a hostname and aborts, so every path goes
// relative to an explicit cwd. A no-op on macOS and Linux.
// `C:` in an absolute path as a hostname and aborts ("Cannot connect to C:
// resolve failed"), so every path goes relative to an explicit cwd. A no-op on
// macOS and Linux, where bsdtar accepts either form.
//
// This runs inside `beforeBuildCommand`, so getting it wrong fails the whole
// build rather than just the recording assets.
tar(root, ["-czf", temporaryArchive, "-C", dirname(sdkDir), basename(sdkDir)]);
await rm(archivePath, { force: true });
await rename(temporaryArchive, archivePath);
Expand Down
17 changes: 16 additions & 1 deletion src-tauri/src/browser_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,23 @@ impl Host for TauriHost {
// `None` for a window that is not there is a NORMAL answer, not an error: a view
// can be asked for while the window is being torn down, and the engine treats
// absence as "nothing to parent into" rather than a failure.
//
// WHICH IS ALSO WHY THE NON-macOS ARM IS `None` RATHER THAN A STUB THAT PANICS.
// `ns_window()` exists only on macOS, and this file is compiled on every
// platform because `lib.rs` declares the module unconditionally — so without the
// gate the DEFAULT Windows build does not compile at all, which is what shipped.
// There is no NSWindow off macOS, "nothing to parent into" is the truth there,
// and the trait already says that is an ordinary answer.
let window = self.app.get_webview_window(window_label)?;
window.ns_window().ok().map(WindowPtr)
#[cfg(target_os = "macos")]
{
window.ns_window().ok().map(WindowPtr)
}
#[cfg(not(target_os = "macos"))]
{
let _ = window;
None
}
}

fn emit_report(&self, key: &str, payload: &str) {
Expand Down
6 changes: 6 additions & 0 deletions src/engines/agents/local/src/actor_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,8 @@ fn mark_terminal(storage: &Storage, run_id: &str, status: StoredStatus, detail:
#[cfg(test)]
pub(crate) mod tests {
use super::*;
// Only `runner` below takes one, and that is macOS-gated with it.
#[cfg(target_os = "macos")]
use std::path::Path;

pub(crate) fn spec() -> AgentRunSpec {
Expand Down Expand Up @@ -350,6 +352,9 @@ pub(crate) mod tests {
Arc::new(move |_app_id| Ok(workspace.clone()))
}

// Its only caller is the macOS-gated test below; `runner_settings` builds its
// own with a dynamic workspace resolver.
#[cfg(target_os = "macos")]
pub(crate) fn runner(tmp: &Path) -> (Arc<Storage>, ActorRunner) {
let storage = Arc::new(Storage::init_at(tmp.join("data-root")).unwrap());
let workspace = tmp.join("workspace");
Expand All @@ -368,6 +373,7 @@ pub(crate) mod tests {
(storage, runner)
}

#[cfg(target_os = "macos")] // elsewhere `policy` is `Unsupported`: refused before a run exists
#[tokio::test]
async fn a_run_that_cannot_spawn_reports_a_failure_and_still_persists_a_run() {
let tmp = tempfile::tempdir().unwrap();
Expand Down
37 changes: 26 additions & 11 deletions src/engines/agents/local/src/runner_settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,13 @@ impl ActorRunner {
#[cfg(test)]
mod tests {
use super::*;
use crate::actor_runner::tests::{no_credential, spec};
use crate::actor_runner::tests::no_credential;
// `spec` and `RunnerPort` serve the macOS-gated tail below and nothing
// else: `spawn_plan` is inherent, so only `spawn_headless` needs the trait.
#[cfg(target_os = "macos")]
use crate::actor_runner::tests::spec;
use crate::actor_runner::WorkspaceResolver;
#[cfg(target_os = "macos")]
use crate::runner::RunnerPort;

use std::sync::{Arc, Mutex};
Expand Down Expand Up @@ -145,7 +150,7 @@ mod tests {
#[tokio::test]
async fn a_settings_change_reaches_the_next_scheduled_spawn() {
let tmp = tempfile::tempdir().unwrap();
let (storage, runner) = runner_with_dynamic_workspace(tmp.path());
let (_storage, runner) = runner_with_dynamic_workspace(tmp.path());

let before = runner.spawn_plan(&ALLOWED, "sample").unwrap();
assert_eq!(before.cwd, tmp.path().join("workspace"));
Expand Down Expand Up @@ -189,14 +194,24 @@ mod tests {

// And the run the scheduler actually fires uses them: the run on disk
// is stamped with the cwd from the workspace resolver.
let handle = runner.spawn_headless(spec()).expect("the run was created");
let run_id = handle.run_id.clone();
let _ = handle.wait().await;
// The cwd comes from the workspace resolver, not directly from settings.
// In a full integration, the resolver reads from settings, so the effect
// is the same.
let meta = storage.read_run_meta(&run_id).unwrap();
// Just verify the run completed and has a cwd set
assert!(!meta.cwd.as_os_str().is_empty());
//
// THIS LAST STEP ONLY is macOS-gated, and the split is deliberate: the
// live-settings claim above is what this test is for, and it holds on
// every platform. `spawn_headless` is what does not — off macOS it
// refuses before a run exists, because there is no seatbelt to confine
// one to (`sandbox::policy` answers `Unsupported`), so there would be
// no run on disk to read a cwd off. Gating the whole test instead would
// have thrown away the portable half to buy nothing.
#[cfg(target_os = "macos")]
{
let handle = runner.spawn_headless(spec()).expect("the run was created");
let run_id = handle.run_id.clone();
let _ = handle.wait().await;
// The cwd comes from the workspace resolver, not directly from
// settings. In a full integration, the resolver reads from
// settings, so the effect is the same.
let meta = _storage.read_run_meta(&run_id).unwrap();
assert!(!meta.cwd.as_os_str().is_empty());
}
}
}
11 changes: 11 additions & 0 deletions src/engines/agents/local/src/sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -460,6 +460,11 @@ mod tests {
/// Seatbelt matches RESOLVED paths, so a root handed over unresolved
/// grants nothing at all — silently. `/tmp` is a symlink on macOS, which
/// makes it the cheapest way to prove the resolution really happens.
///
/// …and is also why this is macOS-only: the whole check is built on that
/// symlink. `/tmp` resolving to `/private/tmp` is a fact about macOS, not
/// about the resolver, and off macOS there is no `/tmp` to resolve.
#[cfg(target_os = "macos")]
#[test]
fn roots_reach_the_profile_as_real_paths() {
let resolved = real_path(Path::new("/tmp")).expect("/tmp exists");
Expand Down Expand Up @@ -495,6 +500,12 @@ mod tests {
/// A write root that cannot be resolved is an ERROR, not a silently
/// dropped rule — a `(subpath "/nope")` would have granted nothing and
/// said so to nobody, and the run would have failed somewhere far away.
///
/// macOS-only because `policy()` checks the PLATFORM before it checks the
/// roots: off macOS every call is `Err(Unsupported)`, so the test would
/// pass for the wrong reason — it would never reach the resolution it
/// exists to pin.
#[cfg(target_os = "macos")]
#[test]
fn an_unresolvable_root_refuses_to_produce_a_policy() {
let roots = SandboxRoots::for_run("/no-such-volume-xyz/run", "/tmp");
Expand Down
28 changes: 28 additions & 0 deletions src/engines/agents/local/tests/headless_sandbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,23 +33,45 @@
//! another shape of "the boundary holds", and these tests share fixtures that
//! cannot be split without duplicating the ENV_LOCK/Env machinery.

// EVERY TEST HERE IS macOS-ONLY BUT ONE, and so is everything they lean on —
// which is why the gates run down the imports and helpers as well as the tests.
// A `#[cfg]` on the test alone leaves its fixtures compiling on a platform that
// cannot host them: `probing_cli` writes a `#!/bin/sh` and chmods it through
// `std::os::unix`, which does not exist off Unix, so the whole test binary
// failed to build on Windows and took `cargo test --workspace` down with it.
//
// The one survivor reads `sandbox::PROFILE` as a string, which is why it is the
// survivor: everything else here reaches `sandbox::policy()`, and off macOS that
// is `Err(Unsupported)` by design — there is no seatbelt to ask. Keeping the
// profile check portable is deliberate rather than incidental; the absence it
// asserts (no `mach-lookup` grant) is the one a careless edit reopens, and it
// should fail on whichever machine the careless edit was made.
#[cfg(target_os = "macos")]
use std::path::{Path, PathBuf};
#[cfg(target_os = "macos")]
use std::sync::{Arc, Mutex, MutexGuard};

#[cfg(target_os = "macos")]
use brains_local_agents::actor_runner::ActorRunner;
#[cfg(target_os = "macos")]
use brains_local_agents::runner::{AgentRunSpec, RunStatus, RunnerPort};
use brains_local_agents::sandbox;
#[cfg(target_os = "macos")]
use brains_model::session_actor::{EventSink, SessionRegistry};
#[cfg(target_os = "macos")]
use brains_storage::{RunStatus as StoredStatus, Storage};

#[cfg(target_os = "macos")]
static ENV_LOCK: Mutex<()> = Mutex::new(());

/// Sets environment variables for the length of the test and restores them.
#[cfg(target_os = "macos")]
struct Env {
_lock: MutexGuard<'static, ()>,
restore: Vec<(String, Option<String>)>,
}

#[cfg(target_os = "macos")]
impl Env {
fn new() -> Self {
Self {
Expand All @@ -69,6 +91,7 @@ impl Env {
}
}

#[cfg(target_os = "macos")]
impl Drop for Env {
fn drop(&mut self) {
for (key, previous) in self.restore.iter().rev() {
Expand All @@ -82,6 +105,7 @@ impl Drop for Env {

/// The declared job a tick would build. Values do not matter here — this file
/// is about what the CHILD may do, not about what the prompt says.
#[cfg(target_os = "macos")]
fn spec() -> AgentRunSpec {
AgentRunSpec {
run_uuid: "morning:2026-08-05".into(),
Expand All @@ -103,6 +127,7 @@ fn spec() -> AgentRunSpec {
/// A stand-in for the CLI that PROBES ITS OWN CONFINEMENT before answering:
/// `probe` is shell run in the child, inside whatever boundary the engine put
/// it in, and its findings are the evidence these tests read.
#[cfg(target_os = "macos")]
fn probing_cli(dir: &Path, probe: &str) -> PathBuf {
use std::os::unix::fs::PermissionsExt;
let path = dir.join("fake-claude");
Expand All @@ -123,6 +148,7 @@ printf '%s\n' '{{"type":"result","subtype":"success","is_error":false,"duration_
path
}

#[cfg(target_os = "macos")]
fn runner(tmp: &Path, workspace: &Path, bin: PathBuf) -> (Arc<Storage>, ActorRunner) {
let storage = Arc::new(Storage::init_at(tmp.join(".brains")).unwrap());
let workspace_path = workspace.to_path_buf();
Expand Down Expand Up @@ -304,6 +330,7 @@ if /usr/bin/security list-keychains >/dev/null 2>&1; then echo KEYCHAIN-ALLOWED
// add ways for them to fail for other reasons.

/// A run's roots over a temp dir, with the two write roots created.
#[cfg(target_os = "macos")]
fn prepared() -> (tempfile::TempDir, sandbox::SandboxRoots) {
let tmp = tempfile::tempdir().unwrap();
std::fs::create_dir_all(tmp.path().join("run")).unwrap();
Expand Down Expand Up @@ -566,6 +593,7 @@ fn the_mach_allowlist_is_exactly_the_keychain_door() {

/// The wrapper argv, as the child will really be exec'd: parameters, then
/// the profile, then whatever is appended.
#[cfg(target_os = "macos")]
#[test]
fn the_wrapper_is_sandbox_exec_with_the_profile_inline() {
let (_tmp, roots) = prepared();
Expand Down
Loading
Loading