OXYGENHUB

Radar: Live, channels, threads and the log for the owner, signed in by a CLI-signed link; ledger writes survive a cancelled caller (#10) 39f5c913f617

Author
Andy McCall @andymccall <[email protected]>
via
agent agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key ed4de7d42b19
When
· 2026-11-02 23:10 UTC
Signature
◈ sealed by agent:claude@andymccall
Hash
39f5c913f617e503c9660cf9bb11095ee27ca3866ee694a372481d9cfe7973c8
Depends on
037c45d8093c 09752034b5a8 258bb3824813 2fc88a7b3a5c 36537112fcac 571572f5fb2c 5eb57578f4ef 8dc6df8c4d4e eb675dfa5e18
Depended on by
862f0db224aa fe6b29c66cd4 58e1ce30ae58 b044d90930d9 98a0fb9f0948 01320eefd505 b576d05cf862 d17cbc7069ce e071e91a14ef 8cfb9269bb14
In bookmarks
main
In tags
v0.1.0
Edits
79 atoms, 14 tree operations, 26 files

Browse the tree at this change

added crates/radar/assets/radar.js +17 −0
1 +
// Radar's one script (spec §17.3): when the event stream says something
2 +
// changed, fetch this page again and replace <main>. Pages work without it.
3 +
(function () {
4 +
  var main = document.querySelector("main[data-live]");
5 +
  if (!main || !window.EventSource) return;
6 +
  var timer = null;
7 +
  function refresh() {
8 +
    fetch(location.href, { credentials: "same-origin" }).then(function (r) { return r.ok ? r.text() : null; }).then(function (html) {
9 +
      if (!html) return;
10 +
      var next = new DOMParser().parseFromString(html, "text/html").querySelector("main");
11 +
      var active = document.activeElement;
12 +
      if (next && !(active && (active.tagName === "TEXTAREA" || active.tagName === "INPUT") && active.value)) main.innerHTML = next.innerHTML;
13 +
    });
14 +
  }
15 +
  var es = new EventSource("/radar/events");
16 +
  es.onmessage = function () { clearTimeout(timer); timer = setTimeout(refresh, 300); };
17 +
})();
added crates/radar/assets/spectra.svg +13 −0
1 +
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" width="48" height="32" aria-hidden="true">
2 +
  <!-- Spectra's mark: one line of light into a prism, out as a band of colour. -->
3 +
  <line x1="1" y1="17" x2="17" y2="15" stroke="#f0f6fc" stroke-width="2" stroke-linecap="round" stroke-opacity="0.9"/>
4 +
  <path d="M15 27 L24 4 L33 27 Z" fill="none" stroke="#8b949e" stroke-width="2" stroke-linejoin="round"/>
5 +
  <g stroke-width="2.2" stroke-linecap="round">
6 +
    <line x1="27" y1="13" x2="47" y2="5" stroke="#f85149"/>
7 +
    <line x1="27.5" y1="15" x2="47" y2="10" stroke="#ff9e2c"/>
8 +
    <line x1="28" y1="17" x2="47" y2="15" stroke="#e3b341"/>
9 +
    <line x1="28.5" y1="19" x2="47" y2="20" stroke="#3fb950"/>
10 +
    <line x1="29" y1="21" x2="47" y2="25" stroke="#4493f8"/>
11 +
    <line x1="29.5" y1="23" x2="47" y2="30" stroke="#a371f7"/>
12 +
  </g>
13 +
</svg>
added crates/radar/assets/style.css +70 −0
1 +
/* Radar's look: OxygenHub's (Primer colours, system fonts, rounded boxes,
2 +
   underline navigation, sentence case), light or dark with the system. */
3 +
:root {
4 +
  --bg:#ffffff; --soft:#f6f8fa; --ink:#1f2328; --mute:#59636e; --line:#d1d9e0; --dim:#818b98;
5 +
  --acc:#0969da; --ok:#1a7f37; --bad:#d1242f; --warn:#9a6700; --busy:#8250df;
6 +
  --header:#f6f8fa; --btn:#f6f8fa; --btn-line:#d1d9e0; --primary:#1f883d; --primary-ink:#ffffff; --banner:#fff8c5;
7 +
  --font:-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif;
8 +
  --mono:ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
9 +
  color-scheme: light;
10 +
}
11 +
@media (prefers-color-scheme: dark) {
12 +
  :root {
13 +
    --bg:#0d1117; --soft:#151b23; --ink:#f0f6fc; --mute:#9198a1; --line:#3d444d; --dim:#656c76;
14 +
    --acc:#4493f8; --ok:#3fb950; --bad:#f85149; --warn:#d29922; --busy:#a371f7;
15 +
    --header:#010409; --btn:#212830; --btn-line:#3d444d; --primary:#238636; --banner:#3b2e00;
16 +
    color-scheme: dark;
17 +
  }
18 +
}
19 +
* { box-sizing: border-box; }
20 +
body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.5 var(--font); }
21 +
a { color: var(--acc); text-decoration: none; }
22 +
a:hover { text-decoration: underline; }
23 +
code, pre, .mono { font-family: var(--mono); font-size: 12px; }
24 +
header { display: flex; align-items: center; gap: 12px; padding: 12px 24px; background: var(--header); border-bottom: 1px solid var(--line); }
25 +
.brand { display: flex; align-items: center; gap: 8px; color: var(--ink); font-weight: 600; font-size: 16px; }
26 +
.brand svg { height: 28px; width: auto; }
27 +
header .grow { flex: 1; }
28 +
header .who { color: var(--mute); }
29 +
nav.band { display: flex; gap: 4px; padding: 0 24px; border-bottom: 1px solid var(--line); background: var(--header); }
30 +
nav.band a { padding: 8px 12px; color: var(--ink); border-bottom: 2px solid transparent; }
31 +
nav.band a.now { border-bottom-color: #fd8c73; font-weight: 600; }
32 +
nav.band a:hover { text-decoration: none; background: var(--soft); border-radius: 6px 6px 0 0; }
33 +
.banner { background: var(--banner); border-bottom: 1px solid var(--line); padding: 6px 24px; color: var(--ink); font-size: 13px; }
34 +
main { max-width: 1180px; margin: 0 auto; padding: 24px; }
35 +
h1 { font-size: 20px; font-weight: 600; margin: 0 0 16px; }
36 +
h2 { font-size: 16px; font-weight: 600; margin: 24px 0 8px; }
37 +
.box { border: 1px solid var(--line); border-radius: 6px; background: var(--bg); }
38 +
.box > .row { padding: 8px 16px; border-top: 1px solid var(--line); }
39 +
.box > .row:first-child { border-top: 0; }
40 +
.box > .head { padding: 8px 16px; background: var(--soft); border-bottom: 1px solid var(--line); border-radius: 6px 6px 0 0; font-weight: 600; }
41 +
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 12px; }
42 +
.card { border: 1px solid var(--line); border-radius: 6px; padding: 12px 16px; }
43 +
.card .name { font-weight: 600; font-size: 15px; }
44 +
.mute { color: var(--mute); }
45 +
.dim { color: var(--dim); }
46 +
.pill { display: inline-block; padding: 0 8px; border-radius: 2em; border: 1px solid var(--line); font-size: 12px; line-height: 20px; color: var(--mute); }
47 +
.p-online { color: var(--ok); border-color: var(--ok); }
48 +
.p-busy { color: var(--busy); border-color: var(--busy); }
49 +
.p-blocked, .p-paused { color: var(--warn); border-color: var(--warn); }
50 +
.ok { color: var(--ok); }
51 +
.bad { color: var(--bad); }
52 +
.msg { display: flex; flex-direction: column; gap: 2px; }
53 +
.msg .meta { color: var(--mute); font-size: 12px; }
54 +
.msg .body p { margin: 4px 0; }
55 +
.msg .body pre { background: var(--soft); padding: 8px; border-radius: 6px; overflow-x: auto; }
56 +
.redacted { color: var(--dim); font-style: italic; }
57 +
form.compose { display: flex; flex-direction: column; gap: 8px; margin-top: 16px; }
58 +
textarea { width: 100%; min-height: 80px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); font: inherit; }
59 +
button { padding: 5px 16px; border: 1px solid var(--btn-line); border-radius: 6px; background: var(--btn); color: var(--ink); font: inherit; font-weight: 500; cursor: pointer; }
60 +
button.primary { background: var(--primary); border-color: rgba(31,35,40,.15); color: var(--primary-ink); align-self: flex-start; }
61 +
details > summary { cursor: pointer; list-style: none; }
62 +
details[open] > summary { margin-bottom: 6px; }
63 +
pre.payload { background: var(--soft); padding: 8px 12px; border-radius: 6px; overflow-x: auto; white-space: pre-wrap; word-break: break-all; }
64 +
table.facts td { padding: 2px 12px 2px 0; vertical-align: top; }
65 +
table.facts td:first-child { color: var(--mute); white-space: nowrap; }
66 +
form.inline { display: inline; }
67 +
.filters { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
68 +
.filters a { padding: 2px 10px; border: 1px solid var(--line); border-radius: 2em; color: var(--ink); font-size: 12px; }
69 +
.filters a.now { background: var(--acc); border-color: var(--acc); color: #fff; }
70 +
footer { max-width: 1180px; margin: 24px auto; padding: 0 24px; color: var(--dim); font-size: 12px; }
added crates/radar/src/session.rs +86 −0
1 +
//! Radar's sign-in (spec §8.3): the owner's CLI signs a request for a
2 +
//! one-time link (`POST /radar/link`, valid 60 s); opening it starts a
3 +
//! browser session whose events carry that signed request as their proof
4 +
//! (level 2). The passkey (level 3) is 0.2. Sessions live in memory: a
5 +
//! restart signs the browser out.
6 +
7 +
use rand::RngCore;
8 +
use std::collections::HashMap;
9 +
use std::sync::Mutex;
10 +
11 +
/// How long a sign-in link is good for, and a session.
12 +
pub const LINK_MS: u64 = 60_000;
13 +
pub const SESSION_MS: u64 = 12 * 3_600_000;
14 +
15 +
/// What a session holds: the owner's key and the proof that opened it.
16 +
#[derive(Clone, Debug)]
17 +
pub struct Session {
18 +
    pub key: [u8; 32],
19 +
    pub proof: Vec<u8>,
20 +
    pub csrf: String,
21 +
    pub expires_ms: u64,
22 +
}
23 +
24 +
#[derive(Debug, Default)]
25 +
pub struct Sessions {
26 +
    codes: Mutex<HashMap<String, (Session, u64)>>,
27 +
    live: Mutex<HashMap<String, Session>>,
28 +
}
29 +
30 +
fn random_hex() -> String {
31 +
    let mut b = [0u8; 32];
32 +
    rand::rngs::OsRng.fill_bytes(&mut b);
33 +
    hex::encode(b)
34 +
}
35 +
36 +
impl Sessions {
37 +
    /// A one-time code for the owner, from their signed request.
38 +
    pub fn link(&self, key: [u8; 32], proof: Vec<u8>, now: u64) -> String {
39 +
        let code = random_hex();
40 +
        let s = Session { key, proof, csrf: random_hex(), expires_ms: now + SESSION_MS };
41 +
        let mut codes = self.codes.lock().unwrap();
42 +
        codes.retain(|_, (_, until)| *until > now);
43 +
        codes.insert(code.clone(), (s, now + LINK_MS));
44 +
        code
45 +
    }
46 +
47 +
    /// Spend a code: the session id, once, within a minute of the link.
48 +
    pub fn open(&self, code: &str, now: u64) -> Option<String> {
49 +
        let (s, until) = self.codes.lock().unwrap().remove(code)?;
50 +
        if until < now {
51 +
            return None;
52 +
        }
53 +
        let id = random_hex();
54 +
        let mut live = self.live.lock().unwrap();
55 +
        live.retain(|_, s| s.expires_ms > now);
56 +
        live.insert(id.clone(), s);
57 +
        Some(id)
58 +
    }
59 +
60 +
    pub fn get(&self, id: &str, now: u64) -> Option<Session> {
61 +
        self.live.lock().unwrap().get(id).filter(|s| s.expires_ms > now).cloned()
62 +
    }
63 +
64 +
    pub fn close(&self, id: &str) {
65 +
        self.live.lock().unwrap().remove(id);
66 +
    }
67 +
}
68 +
69 +
#[cfg(test)]
70 +
mod tests {
71 +
    use super::*;
72 +
73 +
    #[test]
74 +
    fn a_link_opens_one_session_once_and_only_while_fresh() {
75 +
        let s = Sessions::default();
76 +
        let code = s.link([1; 32], vec![9], 1_000);
77 +
        let id = s.open(&code, 2_000).expect("fresh");
78 +
        assert!(s.open(&code, 2_000).is_none(), "spent");
79 +
        assert_eq!(s.get(&id, 3_000).unwrap().key, [1; 32]);
80 +
        assert!(s.get(&id, 1_000 + SESSION_MS + 1).is_none(), "expired");
81 +
        let stale = s.link([1; 32], vec![9], 1_000);
82 +
        assert!(s.open(&stale, 1_000 + LINK_MS + 1).is_none());
83 +
        s.close(&id);
84 +
        assert!(s.get(&id, 3_000).is_none());
85 +
    }
86 +
}
added crates/radar/templates/base.html +29 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
<head>
4 +
<meta charset="utf-8">
5 +
<meta name="viewport" content="width=device-width, initial-scale=1">
6 +
<title>{% block title %}{% endblock %} · Spectra</title>
7 +
<link rel="stylesheet" href="/radar/style.css">
8 +
<link rel="icon" href="/radar/spectra.svg" type="image/svg+xml">
9 +
<script src="/radar/radar.js" defer></script>
10 +
{% block head %}{% endblock %}
11 +
</head>
12 +
<body>
13 +
<header>
14 +
  <a class="brand" href="/">{{ shell.mark|safe }}Spectra</a>
15 +
  <span class="grow"></span>
16 +
  <span class="who">{{ shell.owner }}</span>
17 +
</header>
18 +
<nav class="band" aria-label="Radar">
19 +
  <a href="/"{% if shell.page == "live" %} class="now"{% endif %}>Live</a>
20 +
  <a href="/channels"{% if shell.page == "channels" %} class="now"{% endif %}>Channels</a>
21 +
  <a href="/log"{% if shell.page == "log" %} class="now"{% endif %}>Log</a>
22 +
</nav>
23 +
<div class="banner">Signed in from the command line (level 2). Answering agents' requests and changing rules will need your passkey, which arrives in 0.2.</div>
24 +
<main id="main" data-live>
25 +
{% block content %}{% endblock %}
26 +
</main>
27 +
<footer>Spectra {{ shell.version }} · ledger head #{{ shell.head }}</footer>
28 +
</body>
29 +
</html>
added crates/radar/templates/channel.html +23 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}{{ display }}{% endblock %}
3 +
{% block content %}
4 +
<h1>{{ display }} {% if let Some(t) = thread %}<span class="mute">· thread</span>{% endif %}</h1>
5 +
{% if let Some(p) = purpose %}<p class="mute">{{ p }}</p>{% endif %}
6 +
<div class="box">
7 +
{% if messages.is_empty() %}<div class="row mute">No messages yet.</div>{% endif %}
8 +
{% for m in messages %}
9 +
  <div class="row msg" id="m{{ m.id }}">
10 +
    <div class="meta"><strong>{{ m.author }}</strong> · {{ m.at }} · <code>{{ m.kind }}</code>{% if m.replies > 0 %} · <a href="/c/{{ name }}/t/{{ m.id }}">{{ m.replies }} repl{% if m.replies == 1 %}y{% else %}ies{% endif %}</a>{% else %}{% if thread.is_none() %} · <a href="/c/{{ name }}/t/{{ m.id }}">reply</a>{% endif %}{% endif %} <span class="dim mono">{{ m.id }}</span></div>
11 +
    {% if m.redacted %}<div class="redacted">{{ m.body_html|safe }}</div>{% else %}<div class="body">{{ m.body_html|safe }}</div>{% endif %}
12 +
  </div>
13 +
{% endfor %}
14 +
</div>
15 +
{% if can_post %}
16 +
<form class="compose" method="post" action="{{ action }}">
17 +
  <input type="hidden" name="_csrf" value="{{ csrf }}">
18 +
  {% if let Some(t) = thread %}<input type="hidden" name="reply_to" value="{{ t }}">{% endif %}
19 +
  <textarea name="body" placeholder="Write as {{ shell.owner }}. Markdown; @name mentions." required></textarea>
20 +
  <button class="primary" type="submit">{% if thread.is_some() %}Reply{% else %}Post{% endif %}</button>
21 +
</form>
22 +
{% endif %}
23 +
{% endblock %}
added crates/radar/templates/channels.html +10 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Channels{% endblock %}
3 +
{% block content %}
4 +
<h1>Channels</h1>
5 +
<div class="box">
6 +
{% for c in channels %}
7 +
  <div class="row"><a href="/c/{{ c.name }}"><strong>{{ c.display }}</strong></a> <span class="pill">{{ c.kind }}</span> <span class="mute">{{ c.purpose }}</span> <span class="dim">· {{ c.count }} message(s){% if !c.last.is_empty() %}, last {{ c.last }}{% endif %}</span></div>
8 +
{% endfor %}
9 +
</div>
10 +
{% endblock %}
added crates/radar/templates/live.html +28 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Live{% endblock %}
3 +
{% block head %}<noscript><meta http-equiv="refresh" content="30"></noscript>{% endblock %}
4 +
{% block content %}
5 +
<h1>Live</h1>
6 +
{% if agents.is_empty() %}
7 +
<p class="mute">No agents yet. Add one with <code>spectra agent add NAME --kind KIND --project PROJECT --home DIR</code>.</p>
8 +
{% else %}
9 +
<div class="cards">
10 +
{% for a in agents %}
11 +
  <div class="card">
12 +
    <div class="name">{{ a.name }} <span class="pill p-{{ a.presence }}">{{ a.presence }}</span></div>
13 +
    <div class="mute">{{ a.owner }}'s agent · {{ a.kind }} · #{{ a.project }}</div>
14 +
    {% if let Some(s) = a.status %}<div>“{{ s }}”</div>{% endif %}
15 +
    {% if let Some(t) = a.last_tool %}<div class="dim">last call: <code>{{ t }}</code> {{ a.last_at }}</div>{% endif %}
16 +
  </div>
17 +
{% endfor %}
18 +
</div>
19 +
{% endif %}
20 +
<h2>Your inbox</h2>
21 +
<p class="mute">{{ unread }} unread mention(s) and direct message(s). <a href="/channels">Channels</a></p>
22 +
<h2>Recent events</h2>
23 +
<div class="box">
24 +
{% for e in events %}
25 +
  <div class="row"><a class="mono" href="/log?around={{ e.seq }}">#{{ e.seq }}</a> <span class="dim">{{ e.at }}</span> <code>{{ e.kind }}</code> {{ e.actor }}{% if let Some(s) = e.subject %} <span class="dim">{{ s }}</span>{% endif %}</div>
26 +
{% endfor %}
27 +
</div>
28 +
{% endblock %}
added crates/radar/templates/log.html +31 −0
1 +
{% extends "base.html" %}
2 +
{% block title %}Log{% endblock %}
3 +
{% block content %}
4 +
<h1>Log</h1>
5 +
<table class="facts">
6 +
  <tr><td>head</td><td class="mono">#{{ shell.head }} {{ head_hash }}</td></tr>
7 +
  <tr><td>verified</td><td>{{ verified }}
8 +
    <form class="inline" method="post" action="/log/verify"><input type="hidden" name="_csrf" value="{{ csrf }}"> <button type="submit">Verify now</button></form></td></tr>
9 +
</table>
10 +
<div class="filters">
11 +
  <a href="/log"{% if kind.is_empty() %} class="now"{% endif %}>All</a>
12 +
{% for k in kinds %}
13 +
  <a href="/log?kind={{ k }}"{% if kind.as_str() == k.as_str() %} class="now"{% endif %}>{{ k }}</a>
14 +
{% endfor %}
15 +
</div>
16 +
<div class="box">
17 +
{% for e in events %}
18 +
  <div class="row"><details><summary><span class="mono">#{{ e.seq }}</span> <span class="dim">{{ e.at }}</span> <code>{{ e.kind }}</code> {{ e.actor }}{% if let Some(s) = e.subject %} <span class="dim">{{ s }}</span>{% endif %} <span class="pill">{{ e.method }} · level {{ e.assurance }}</span></summary>
19 +
    <table class="facts">
20 +
      <tr><td>hash</td><td class="mono">{{ e.hash }}</td></tr>
21 +
      <tr><td>prev</td><td class="mono">{{ e.prev }}</td></tr>
22 +
      <tr><td>key</td><td class="mono">{{ e.key }}</td></tr>
23 +
      <tr><td>proof</td><td>{{ e.proof }}</td></tr>
24 +
      <tr><td>id</td><td class="mono">{{ e.id }}</td></tr>
25 +
    </table>
26 +
    <pre class="payload">{{ e.payload }}</pre>
27 +
  </details></div>
28 +
{% endfor %}
29 +
</div>
30 +
{% if let Some(b) = older %}<p><a href="/log?before={{ b }}{% if !kind.is_empty() %}&amp;kind={{ kind }}{% endif %}">Older events</a></p>{% endif %}
31 +
{% endblock %}
added crates/radar/templates/signin.html +12 −0
1 +
<!doctype html>
2 +
<html lang="en">
3 +
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>Sign in · Spectra</title><link rel="stylesheet" href="/radar/style.css"></head>
4 +
<body>
5 +
<header><a class="brand" href="/">{{ mark|safe }}Spectra</a></header>
6 +
<main>
7 +
<h1>Sign in to Radar</h1>
8 +
<p>{{ message }}</p>
9 +
<p>Run <code>spectra open</code> on this machine: it signs a one-time link with your key and opens it here.</p>
10 +
</main>
11 +
</body>
12 +
</html>
added src/cli/open.rs +38 −0
1 +
//! `spectra open`: sign in to Radar (spec §8.3). The owner's CLI signs a
2 +
//! request for a one-time link with the owner's key; the link, valid for a
3 +
//! minute, opens a level-2 browser session.
4 +
5 +
use super::identity::{owner_home, recorded_owner};
6 +
use super::{ui, Ctx};
7 +
use anyhow::{bail, Context, Result};
8 +
use spectra_core::ledger::Ledger;
9 +
use spectra_mcp::client::Client;
10 +
use std::path::PathBuf;
11 +
12 +
pub async fn open(ctx: &Ctx, owner_home_flag: Option<PathBuf>, launch: bool) -> Result<i32> {
13 +
    let socket = ctx.socket();
14 +
    if !Client::alive(&socket).await {
15 +
        bail!("Spectra is not serving {} (spectra serve)", ctx.data.display());
16 +
    }
17 +
    let home = owner_home(ctx, owner_home_flag);
18 +
    let ledger = Ledger::open_read_only(&ctx.db()?).await?;
19 +
    let owner = recorded_owner(&ledger, &home).await?;
20 +
    let r = Client::new(&socket, Some(owner)).send("POST", "/radar/link", &[], Vec::new()).await?;
21 +
    if r.status != 200 {
22 +
        bail!("the daemon refused the sign-in: {}", String::from_utf8_lossy(&r.body));
23 +
    }
24 +
    let url = r.json()?.get("url").and_then(|u| u.as_str()).map(String::from).context("no link in the answer")?;
25 +
    println!("{} {}", ui::done("Signed"), ui::subject("a one-time link to Radar, good for a minute"));
26 +
    println!("  {}", ui::id(&url));
27 +
    if launch
28 +
        && std::process::Command::new("xdg-open")
29 +
            .arg(&url)
30 +
            .stdout(std::process::Stdio::null())
31 +
            .stderr(std::process::Stdio::null())
32 +
            .spawn()
33 +
            .is_ok()
34 +
    {
35 +
        println!("  {} {}", ui::note(), ui::dim("opened in your browser"));
36 +
    }
37 +
    Ok(0)
38 +
}
added tests/radar.rs +89 −0
1 +
//! Radar end to end (spec §17, §8.3, §21.2): the owner signs in with a
2 +
//! one-time link their CLI signed, every page renders and none shows
3 +
//! without the session, a post needs the session's CSRF token and our own
4 +
//! origin and is recorded as a verified session event.
5 +
6 +
mod common;
7 +
8 +
use common::{add_agent, hermetic, run, setup, text, Serving};
9 +
use serde_json::json;
10 +
use spectra_mcp::client::Client;
11 +
12 +
fn link(d: &std::path::Path) -> String {
13 +
    let o = hermetic(d).args(["open", "--no-browser"]).output().unwrap();
14 +
    let t = text(&o);
15 +
    let url = t.split_whitespace().find(|w| w.starts_with("http://")).unwrap_or_else(|| panic!("no link in {t}")).to_string();
16 +
    url.split_once("7350")
17 +
        .or_else(|| url.split_once(":0"))
18 +
        .map(|(_, p)| p.to_string())
19 +
        .unwrap_or_else(|| format!("/{}", url.splitn(4, '/').nth(3).unwrap()))
20 +
}
21 +
22 +
#[tokio::test]
23 +
async fn the_owner_signs_in_reads_every_page_and_posts() {
24 +
    let dir = setup();
25 +
    let d = dir.path();
26 +
    let agent = add_agent(d, "claude-oxygen", &[]);
27 +
    let srv = Serving::start(d);
28 +
    let a = Client::new(&srv.socket, Some(agent));
29 +
    let say = json!({ "jsonrpc": "2.0", "id": 1, "method": "tools/call", "params": { "name": "say", "arguments": { "body": "@andy **ready** for review", "channel": "humans" } } });
30 +
    assert_eq!(a.mcp("2025-11-25", &say).await.unwrap().status, 200);
31 +
32 +
    let web = Client::new(&srv.socket, None);
33 +
    for path in ["/", "/channels", "/c/general", "/log"] {
34 +
        let r = web.send("GET", path, &[], vec![]).await.unwrap();
35 +
        assert_eq!(r.status, 401, "{path} without a session");
36 +
        assert!(String::from_utf8_lossy(&r.body).contains("spectra open"));
37 +
    }
38 +
39 +
    let path = link(d);
40 +
    let r = web.send("GET", &path, &[], vec![]).await.unwrap();
41 +
    assert_eq!(r.status, 303, "{}", String::from_utf8_lossy(&r.body));
42 +
    let cookie = r.header("set-cookie").unwrap().split(';').next().unwrap().to_string();
43 +
    assert!(r.header("set-cookie").unwrap().contains("HttpOnly"));
44 +
    assert_eq!(web.send("GET", &path, &[], vec![]).await.unwrap().status, 401, "a link works once");
45 +
46 +
    let get = |p: &'static str| {
47 +
        let (web, cookie) = (&web, cookie.clone());
48 +
        async move {
49 +
            let r = web.send("GET", p, &[("cookie", cookie)], vec![]).await.unwrap();
50 +
            assert_eq!(r.status, 200, "{p}: {}", String::from_utf8_lossy(&r.body));
51 +
            assert!(r.header("content-security-policy").unwrap().contains("default-src 'self'"));
52 +
            String::from_utf8_lossy(&r.body).to_string()
53 +
        }
54 +
    };
55 +
    let live = get("/").await;
56 +
    assert!(live.contains("claude-oxygen") && live.contains("Andy McCall&#39;s agent"), "escaped by askama: {live}");
57 +
    assert!(live.contains("1 unread"));
58 +
    assert!(get("/channels").await.contains("#humans"));
59 +
    let humans = get("/c/humans").await;
60 +
    assert!(humans.contains("<strong>ready</strong>"), "Markdown rendered: {humans}");
61 +
    let log = get("/log").await;
62 +
    assert!(log.contains("message.posted") && log.contains("signature · level 2"));
63 +
64 +
    let csrf = humans.split("name=\"_csrf\" value=\"").nth(1).unwrap().split('"').next().unwrap().to_string();
65 +
    let form = |csrf: &str| format!("_csrf={csrf}&body=Looks+good%2C+%40claude-oxygen").into_bytes();
66 +
    let h = |origin: &str| {
67 +
        vec![("cookie", cookie.clone()), ("content-type", "application/x-www-form-urlencoded".to_string()), ("origin", origin.to_string())]
68 +
    };
69 +
    assert_eq!(web.send("POST", "/c/humans", &h("http://evil.example"), form(&csrf)).await.unwrap().status, 403);
70 +
    // Without an Origin header (not a browser) the CSRF token alone guards
71 +
    // the form; a browser's Origin must also be the daemon's own address.
72 +
    let no_origin = vec![("cookie", cookie.clone()), ("content-type", "application/x-www-form-urlencoded".to_string())];
73 +
    assert_eq!(web.send("POST", "/c/humans", &no_origin, form("stale")).await.unwrap().status, 403);
74 +
    assert_eq!(web.send("POST", "/c/humans", &no_origin, form(&csrf)).await.unwrap().status, 303);
75 +
    assert!(get("/c/humans").await.contains("Looks good"));
76 +
77 +
    assert_eq!(web.send("POST", "/log/verify", &no_origin, format!("_csrf={csrf}").into_bytes()).await.unwrap().status, 303);
78 +
    assert!(get("/log").await.contains("clean at"));
79 +
    srv.stop();
80 +
81 +
    let v: serde_json::Value = serde_json::from_slice(&run(d, &["admin", "verify", "--json"]).stdout).unwrap();
82 +
    assert!(v["findings"].as_array().unwrap().is_empty(), "the session post verifies: {v}");
83 +
    let pool = sqlx::SqlitePool::connect(&format!("sqlite://{}", d.join("data/spectra.db").display())).await.unwrap();
84 +
    let m: (String, i64) = sqlx::query_as("SELECT actor, auth_method FROM events WHERE kind = 'message.posted' ORDER BY seq DESC LIMIT 1")
85 +
        .fetch_one(&pool)
86 +
        .await
87 +
        .unwrap();
88 +
    assert_eq!(m, ("human".to_string(), 4), "recorded as the owner, through a session");
89 +
}
modified CHANGELOG.md +2 −0
…
1 unchanged lines
2 2
3 3
## Unreleased
4 4
5 +
- **Fixed: a client hanging up mid-request could stop the ledger saving anything.** A request cancelled while its events were being written left a pooled database connection inside an open transaction, and every later write on that connection was lost when the daemon stopped. Every ledger write now runs to completion in a task of its own; a regression test abandons 300 appends mid-flight and finds them all.
6 +
- **Added: Radar, the owner's window (#10).** `spectra open` (or `spectra serve --open`) signs a one-time link with the owner's key; the browser session it opens shows Live (each agent with its owner, presence, status and last call; the inbox; recent events), Channels, a channel or thread with a compose box, and the Log, with each event's hash, proof and level and a Verify now button. Pages refresh from an event stream that carries ids, never data, and work without JavaScript; posts need the session's CSRF token and Spectra's own origin and are recorded as the owner's, with the signed link as proof.
5 7
- **Added: the stdio shim, `spectra mcp --agent NAME` (#9).** Any MCP client that starts stdio servers connects with one line and nothing secret in it: the shim signs every request with the agent's key from its own home and forwards it to the daemon. It speaks 2025-11-25, 2025-06-18 and 2025-03-26 (and passes 2026-07-28 through), answers requests concurrently so a `wait` holds nothing up, writes only protocol to stdout, and says plainly when the daemon is not running.
6 8
- **Added: the 0.1 tools (#8).** `brief`, `rules`, `agents`, `status`, `say`, `read`, `inbox`, `wait`, `ack`, `search` and `redact`, in a `tools/list` under its 1,500-token budget. Every call is recorded as `tool.called` with its arguments and result kept in full, content addressed. While an agent works on stale house rules, every result begins by saying so until it calls `rules`. Messages come back delimited with their author. `wait` wakes as soon as something arrives, and after three empty waits in a row it refuses for ten minutes: an idle agent ends its turn instead of burning tokens. `spectra serve` now stops within five seconds of SIGTERM whatever streams are open.
7 9
- **Added: `spectra serve` and Tuner, MCP over the Unix socket and loopback (#7).** The daemon holds the ledger and serves `POST /mcp` on `spectra.sock` (reading the caller's uid) and on `127.0.0.1:7350`. Every request is signed with the caller's key and checked before anything else; an agent bound to another Unix user is refused and recorded as `guard.impersonation`, and `--learn-uid` binds an agent on its first connection. MCP 2026-07-28 (stateless, with its mirrored headers checked, `server/discover`, `subscriptions/listen`) and 2025-11-25, 2025-06-18 and 2025-03-26 (`initialize`); a foreign `Origin` is refused. While it serves, `spectra agent add`, `project add` and `identity refresh` hand their signed events to it. It writes a checkpoint when it stops.
…
7 unchanged lines
modified Cargo.lock +265 −0
…
23 unchanged lines
24 24
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
25 25
26 26
[[package]]
27 +
name = "ammonia"
28 +
version = "4.2.4"
29 +
source = "registry+https://github.com/rust-lang/crates.io-index"
30 +
checksum = "1f45dfa685e1ec33b1bf1a3b93b8047cbdbef41b6040deebbe6bea7720a45574"
31 +
dependencies = [
32 +
 "cssparser",
33 +
 "html5ever",
34 +
 "maplit",
35 +
 "url",
36 +
]
37 +
38 +
[[package]]
27 39
name = "anstream"
28 40
version = "1.0.0"
29 41
source = "registry+https://github.com/rust-lang/crates.io-index"
…
54 unchanged lines
84 96
version = "0.7.8"
85 97
source = "registry+https://github.com/rust-lang/crates.io-index"
86 98
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
99 +
100 +
[[package]]
101 +
name = "askama"
102 +
version = "0.14.0"
103 +
source = "registry+https://github.com/rust-lang/crates.io-index"
104 +
checksum = "f75363874b771be265f4ffe307ca705ef6f3baa19011c149da8674a87f1b75c4"
105 +
dependencies = [
106 +
 "askama_derive",
107 +
 "itoa",
108 +
 "percent-encoding",
109 +
 "serde",
110 +
 "serde_json",
111 +
]
87 112
88 113
[[package]]
114 +
name = "askama_derive"
115 +
version = "0.14.0"
116 +
source = "registry+https://github.com/rust-lang/crates.io-index"
117 +
checksum = "129397200fe83088e8a68407a8e2b1f826cf0086b21ccdb866a722c8bcd3a94f"
118 +
dependencies = [
119 +
 "askama_parser",
120 +
 "basic-toml",
121 +
 "memchr",
122 +
 "proc-macro2",
123 +
 "quote",
124 +
 "rustc-hash",
125 +
 "serde",
126 +
 "serde_derive",
127 +
 "syn 2.0.119",
128 +
]
129 +
130 +
[[package]]
131 +
name = "askama_parser"
132 +
version = "0.14.0"
133 +
source = "registry+https://github.com/rust-lang/crates.io-index"
134 +
checksum = "d6ab5630b3d5eaf232620167977f95eb51f3432fc76852328774afbd242d4358"
135 +
dependencies = [
136 +
 "memchr",
137 +
 "serde",
138 +
 "serde_derive",
139 +
 "winnow",
140 +
]
141 +
142 +
[[package]]
89 143
name = "atoi"
90 144
version = "2.0.0"
91 145
source = "registry+https://github.com/rust-lang/crates.io-index"
…
91 unchanged lines
183 237
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
184 238
185 239
[[package]]
240 +
name = "basic-toml"
241 +
version = "0.1.10"
242 +
source = "registry+https://github.com/rust-lang/crates.io-index"
243 +
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
244 +
dependencies = [
245 +
 "serde",
246 +
]
247 +
248 +
[[package]]
186 249
name = "bit-set"
187 250
version = "0.8.0"
188 251
source = "registry+https://github.com/rust-lang/crates.io-index"
…
276 unchanged lines
465 528
]
466 529
467 530
[[package]]
531 +
name = "cssparser"
532 +
version = "0.38.0"
533 +
source = "registry+https://github.com/rust-lang/crates.io-index"
534 +
checksum = "11119743ad110e8c1bdccd930d7f5c30c99e5fc76a7b63ec9807e84eef0c5f59"
535 +
dependencies = [
536 +
 "dtoa-short",
537 +
 "itoa",
538 +
 "smallvec",
539 +
]
540 +
541 +
[[package]]
468 542
name = "ctutils"
469 543
version = "0.4.2"
470 544
source = "registry+https://github.com/rust-lang/crates.io-index"
…
89 unchanged lines
560 634
version = "0.15.7"
561 635
source = "registry+https://github.com/rust-lang/crates.io-index"
562 636
checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"
637 +
638 +
[[package]]
639 +
name = "dtoa"
640 +
version = "1.0.11"
641 +
source = "registry+https://github.com/rust-lang/crates.io-index"
642 +
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
563 643
564 644
[[package]]
645 +
name = "dtoa-short"
646 +
version = "0.3.5"
647 +
source = "registry+https://github.com/rust-lang/crates.io-index"
648 +
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
649 +
dependencies = [
650 +
 "dtoa",
651 +
]
652 +
653 +
[[package]]
565 654
name = "ed25519"
566 655
version = "3.0.0"
567 656
source = "registry+https://github.com/rust-lang/crates.io-index"
…
348 unchanged lines
916 1005
checksum = "cc627f471c528ff0c4a49e1d5e60450c8f6461dd6d10ba9dcd3a61d3dff7728d"
917 1006
dependencies = [
918 1007
 "windows-sys 0.61.2",
1008 +
]
1009 +
1010 +
[[package]]
1011 +
name = "html5ever"
1012 +
version = "0.40.1"
1013 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1014 +
checksum = "456a1a377e608e555d22ddab27ac0114bc7a7b4199078108e34c2aeae6c9b130"
1015 +
dependencies = [
1016 +
 "log",
1017 +
 "markup5ever",
1018 +
 "memchr",
919 1019
]
920 1020
921 1021
[[package]]
…
352 unchanged lines
1274 1374
version = "0.4.34"
1275 1375
source = "registry+https://github.com/rust-lang/crates.io-index"
1276 1376
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
1377 +
1378 +
[[package]]
1379 +
name = "maplit"
1380 +
version = "1.0.2"
1381 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1382 +
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
1277 1383
1278 1384
[[package]]
1385 +
name = "markup5ever"
1386 +
version = "0.40.0"
1387 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1388 +
checksum = "0ab3dc68ac4a0f5719e560136778c1ee716e296030d75dbd4484e37e39e3a842"
1389 +
dependencies = [
1390 +
 "log",
1391 +
 "tendril",
1392 +
 "web_atoms",
1393 +
]
1394 +
1395 +
[[package]]
1279 1396
name = "matchers"
1280 1397
version = "0.2.0"
1281 1398
source = "registry+https://github.com/rust-lang/crates.io-index"
…
86 unchanged lines
1368 1485
 "hybrid-array",
1369 1486
 "num-traits",
1370 1487
]
1488 +
1489 +
[[package]]
1490 +
name = "new_debug_unreachable"
1491 +
version = "1.0.6"
1492 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1493 +
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
1371 1494
1372 1495
[[package]]
1373 1496
name = "nu-ansi-term"
…
137 unchanged lines
1511 1634
version = "2.3.2"
1512 1635
source = "registry+https://github.com/rust-lang/crates.io-index"
1513 1636
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
1637 +
1638 +
[[package]]
1639 +
name = "phf"
1640 +
version = "0.14.0"
1641 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1642 +
checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6"
1643 +
dependencies = [
1644 +
 "phf_shared",
1645 +
 "serde",
1646 +
]
1647 +
1648 +
[[package]]
1649 +
name = "phf_codegen"
1650 +
version = "0.14.0"
1651 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1652 +
checksum = "41b585a510fb76fdebead6897982ef2a03a21d8e6cbcca904999742a4afc6ffe"
1653 +
dependencies = [
1654 +
 "phf_generator",
1655 +
 "phf_shared",
1656 +
]
1657 +
1658 +
[[package]]
1659 +
name = "phf_generator"
1660 +
version = "0.14.0"
1661 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1662 +
checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100"
1663 +
dependencies = [
1664 +
 "fastrand",
1665 +
 "phf_shared",
1666 +
]
1514 1667
1515 1668
[[package]]
1669 +
name = "phf_shared"
1670 +
version = "0.14.0"
1671 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1672 +
checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89"
1673 +
dependencies = [
1674 +
 "siphasher",
1675 +
]
1676 +
1677 +
[[package]]
1516 1678
name = "pin-project-lite"
1517 1679
version = "0.2.17"
1518 1680
source = "registry+https://github.com/rust-lang/crates.io-index"
…
67 unchanged lines
1586 1748
]
1587 1749
1588 1750
[[package]]
1751 +
name = "precomputed-hash"
1752 +
version = "0.1.1"
1753 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1754 +
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
1755 +
1756 +
[[package]]
1589 1757
name = "proc-macro2"
1590 1758
version = "1.0.107"
1591 1759
source = "registry+https://github.com/rust-lang/crates.io-index"
…
22 unchanged lines
1614 1782
]
1615 1783
1616 1784
[[package]]
1785 +
name = "pulldown-cmark"
1786 +
version = "0.13.6"
1787 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1788 +
checksum = "3db2be46835f013dd3f4fa7f5f7402c537787b54ebb6fe2b94bbdd7e2669bb7b"
1789 +
dependencies = [
1790 +
 "bitflags",
1791 +
 "memchr",
1792 +
 "pulldown-cmark-escape",
1793 +
 "unicase",
1794 +
]
1795 +
1796 +
[[package]]
1797 +
name = "pulldown-cmark-escape"
1798 +
version = "0.11.0"
1799 +
source = "registry+https://github.com/rust-lang/crates.io-index"
1800 +
checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
1801 +
1802 +
[[package]]
1617 1803
name = "quick-error"
1618 1804
version = "1.2.3"
1619 1805
source = "registry+https://github.com/rust-lang/crates.io-index"
…
24 unchanged lines
1644 1830
name = "radar"
1645 1831
version = "0.1.0"
1646 1832
dependencies = [
1833 +
 "ammonia",
1834 +
 "anyhow",
1835 +
 "askama",
1836 +
 "axum",
1837 +
 "futures",
1838 +
 "hex",
1839 +
 "oxy-core",
1840 +
 "pulldown-cmark",
1841 +
 "rand 0.8.8",
1842 +
 "serde",
1843 +
 "serde_json",
1647 1844
 "spectra-core",
1845 +
 "spectra-mcp",
1846 +
 "sqlx",
1847 +
 "tokio",
1848 +
 "tokio-stream",
1849 +
 "tracing",
1648 1850
]
1649 1851
1650 1852
[[package]]
…
160 unchanged lines
1811 2013
]
1812 2014
1813 2015
[[package]]
2016 +
name = "rustc-hash"
2017 +
version = "2.1.3"
2018 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2019 +
checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d"
2020 +
2021 +
[[package]]
1814 2022
name = "rustc_version"
1815 2023
version = "0.4.1"
1816 2024
source = "registry+https://github.com/rust-lang/crates.io-index"
…
281 unchanged lines
2098 2306
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
2099 2307
2100 2308
[[package]]
2309 +
name = "siphasher"
2310 +
version = "1.0.4"
2311 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2312 +
checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256"
2313 +
2314 +
[[package]]
2101 2315
name = "slab"
2102 2316
version = "0.4.12"
2103 2317
source = "registry+https://github.com/rust-lang/crates.io-index"
…
319 unchanged lines
2423 2637
version = "1.2.1"
2424 2638
source = "registry+https://github.com/rust-lang/crates.io-index"
2425 2639
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
2640 +
2641 +
[[package]]
2642 +
name = "string_cache"
2643 +
version = "0.11.0"
2644 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2645 +
checksum = "ffa8a5dbe8b3f0bbe29d4c3225daafaeead63afdc1b65fc4c01a1384166038e6"
2646 +
dependencies = [
2647 +
 "new_debug_unreachable",
2648 +
 "parking_lot",
2649 +
 "phf_shared",
2650 +
 "precomputed-hash",
2651 +
]
2652 +
2653 +
[[package]]
2654 +
name = "string_cache_codegen"
2655 +
version = "0.11.2"
2656 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2657 +
checksum = "928dcdf75e47626b3617a976ec205d9f057584c371c1f23b782129268d0e6edc"
2658 +
dependencies = [
2659 +
 "phf_generator",
2660 +
 "phf_shared",
2661 +
 "proc-macro2",
2662 +
 "quote",
2663 +
]
2426 2664
2427 2665
[[package]]
2428 2666
name = "stringprep"
…
71 unchanged lines
2500 2738
]
2501 2739
2502 2740
[[package]]
2741 +
name = "tendril"
2742 +
version = "0.5.1"
2743 +
source = "registry+https://github.com/rust-lang/crates.io-index"
2744 +
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
2745 +
dependencies = [
2746 +
 "new_debug_unreachable",
2747 +
]
2748 +
2749 +
[[package]]
2503 2750
name = "thiserror"
2504 2751
version = "1.0.69"
2505 2752
source = "registry+https://github.com/rust-lang/crates.io-index"
…
260 unchanged lines
2766 3013
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
2767 3014
2768 3015
[[package]]
3016 +
name = "unicase"
3017 +
version = "2.10.0"
3018 +
source = "registry+https://github.com/rust-lang/crates.io-index"
3019 +
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
3020 +
3021 +
[[package]]
2769 3022
name = "unicode-bidi"
2770 3023
version = "0.3.18"
2771 3024
source = "registry+https://github.com/rust-lang/crates.io-index"
…
192 unchanged lines
2964 3217
dependencies = [
2965 3218
 "js-sys",
2966 3219
 "wasm-bindgen",
3220 +
]
3221 +
3222 +
[[package]]
3223 +
name = "web_atoms"
3224 +
version = "0.3.0"
3225 +
source = "registry+https://github.com/rust-lang/crates.io-index"
3226 +
checksum = "7572660c8890448ba236b7376f27e389c6a7e1c70195622faced601f855c0ada"
3227 +
dependencies = [
3228 +
 "phf",
3229 +
 "phf_codegen",
3230 +
 "string_cache",
3231 +
 "string_cache_codegen",
2967 3232
]
2968 3233
2969 3234
[[package]]
…
362 unchanged lines
modified crates/radar/Cargo.toml +16 −0
…
6 unchanged lines
7 7
8 8
[dependencies]
9 9
spectra-core.workspace = true
10 +
spectra-mcp.workspace = true
11 +
oxy-core.workspace = true
12 +
anyhow.workspace = true
13 +
axum.workspace = true
14 +
askama.workspace = true
15 +
serde.workspace = true
16 +
serde_json.workspace = true
17 +
tokio.workspace = true
18 +
futures.workspace = true
19 +
tokio-stream.workspace = true
20 +
sqlx.workspace = true
21 +
rand.workspace = true
22 +
hex.workspace = true
23 +
tracing.workspace = true
24 +
pulldown-cmark.workspace = true
25 +
ammonia.workspace = true
modified crates/radar/src/lib.rs +637 −3
1 -
//! Radar (spec §17): the human's pages, rendered with askama, live over the
2 -
//! event stream, working without JavaScript. Linked by `spectra` and, later,
3 -
//! mounted by SpectraHub.
1 +
//! Radar (spec §17): the human's pages, rendered with askama, refreshed
2 +
//! from the event stream, working without JavaScript. Linked by `spectra`
3 +
//! and, later, mounted by SpectraHub.
4 +
//!
5 +
//! The owner signs in with a one-time link their CLI signed (`spectra
6 +
//! open`); everything they do here is recorded with that signed request as
7 +
//! its proof (spec §8.3). Every unsafe request carries the session's CSRF
8 +
//! token and a same-origin `Origin`.
9 +
10 +
pub mod session;
11 +
12 +
use askama::Template;
13 +
use axum::body::Bytes;
14 +
use axum::extract::connect_info::ConnectInfo;
15 +
use axum::extract::{Form, Path, Query, State};
16 +
use axum::http::{header, HeaderMap, HeaderValue, StatusCode};
17 +
use axum::response::sse::{Event as SseEvent, KeepAlive, Sse};
18 +
use axum::response::{Html, IntoResponse, Redirect, Response};
19 +
use axum::routing::{get, post};
20 +
use axum::Router;
21 +
use futures::StreamExt;
22 +
use serde::Deserialize;
23 +
use session::{Session, Sessions};
24 +
use spectra_core::conversation::{self as conv, Author, Post, Signer};
25 +
use spectra_core::event::{Auth, Method};
26 +
use spectra_core::ids::{format_time, now_ms};
27 +
use spectra_core::principals::{Principal, Principals};
28 +
use spectra_mcp::http::{AppState, PeerInfo};
29 +
use std::sync::Arc;
30 +
use std::time::Duration;
31 +
32 +
const MARK: &str = include_str!("../assets/spectra.svg");
33 +
const STYLE: &str = include_str!("../assets/style.css");
34 +
const SCRIPT: &str = include_str!("../assets/radar.js");
35 +
const COOKIE: &str = "spectra_session";
36 +
37 +
#[derive(Clone)]
38 +
pub struct RadarState {
39 +
    pub app: AppState,
40 +
    pub sessions: Arc<Sessions>,
41 +
}
42 +
43 +
/// Radar's routes.
44 +
pub fn router(app: AppState) -> Router {
45 +
    let state = RadarState { app, sessions: Arc::new(Sessions::default()) };
46 +
    Router::new()
47 +
        .route("/", get(live))
48 +
        .route("/channels", get(channels))
49 +
        .route("/c/{name}", get(channel).post(post_channel))
50 +
        .route("/c/{name}/t/{id}", get(thread).post(post_thread))
51 +
        .route("/log", get(log))
52 +
        .route("/log/verify", post(verify))
53 +
        .route("/radar/link", post(link))
54 +
        .route("/signin", get(signin))
55 +
        .route("/signout", post(signout))
56 +
        .route("/radar/events", get(events))
57 +
        .route("/radar/style.css", get(|| async { asset("text/css; charset=utf-8", STYLE) }))
58 +
        .route("/radar/radar.js", get(|| async { asset("text/javascript; charset=utf-8", SCRIPT) }))
59 +
        .route("/radar/spectra.svg", get(|| async { asset("image/svg+xml", MARK) }))
60 +
        .layer(axum::middleware::map_response(security_headers))
61 +
        .with_state(state)
62 +
}
63 +
64 +
fn asset(kind: &'static str, body: &'static str) -> Response {
65 +
    ([(header::CONTENT_TYPE, kind), (header::CACHE_CONTROL, "max-age=300")], body).into_response()
66 +
}
67 +
68 +
/// CSP and friends on every response (spec §21.2).
69 +
async fn security_headers(mut r: Response) -> Response {
70 +
    let h = r.headers_mut();
71 +
    h.insert("content-security-policy", HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; frame-ancestors 'none'; form-action 'self'; base-uri 'none'"));
72 +
    h.insert("x-content-type-options", HeaderValue::from_static("nosniff"));
73 +
    // `same-origin`: a form post still carries our Origin (no-referrer makes it `null`).
74 +
    h.insert("referrer-policy", HeaderValue::from_static("same-origin"));
75 +
    h.insert("x-frame-options", HeaderValue::from_static("DENY"));
76 +
    r
77 +
}
78 +
79 +
/// What every page's frame shows.
80 +
pub struct Shell {
81 +
    pub page: &'static str,
82 +
    pub owner: String,
83 +
    pub head: u64,
84 +
    pub version: &'static str,
85 +
    pub mark: &'static str,
86 +
}
87 +
88 +
/// The signed-in owner, or the page that says how to sign in.
89 +
struct Owner {
90 +
    session: Session,
91 +
    principal: Principal,
92 +
    shell: Shell,
93 +
}
94 +
95 +
impl Owner {
96 +
    fn author(&self) -> Author<'static> {
97 +
        Author {
98 +
            principal: self.principal.clone(),
99 +
            signer: Signer::Request(Auth {
100 +
                key: Some(self.session.key),
101 +
                method: Method::Session,
102 +
                assurance: 2,
103 +
                proof: Some(self.session.proof.clone()),
104 +
            }),
105 +
        }
106 +
    }
107 +
}
108 +
109 +
fn cookie(headers: &HeaderMap) -> Option<String> {
110 +
    let c = headers.get(header::COOKIE)?.to_str().ok()?;
111 +
    c.split(';').map(str::trim).find_map(|kv| kv.strip_prefix(&format!("{COOKIE}=")).map(String::from))
112 +
}
113 +
114 +
async fn owner(st: &RadarState, headers: &HeaderMap, page: &'static str) -> Result<Owner, Response> {
115 +
    let now = now_ms();
116 +
    let session = cookie(headers)
117 +
        .and_then(|id| st.sessions.get(&id, now))
118 +
        .ok_or_else(|| signin_page("Radar shows Spectra to its owner only.", StatusCode::UNAUTHORIZED))?;
119 +
    let d = &st.app.daemon;
120 +
    let principal = d
121 +
        .principals
122 +
        .read()
123 +
        .await
124 +
        .principal(&oxy_core_key(session.key), now)
125 +
        .map_err(|why| signin_page(&format!("This session no longer belongs to the owner: {why}."), StatusCode::UNAUTHORIZED))?;
126 +
    if !matches!(principal, Principal::Owner { .. }) {
127 +
        return Err(signin_page("Radar shows Spectra to its owner only.", StatusCode::FORBIDDEN));
128 +
    }
129 +
    let shell = Shell { page, owner: principal.display(), head: d.ledger.head().await.0, version: env!("CARGO_PKG_VERSION"), mark: MARK };
130 +
    Ok(Owner { session, principal, shell })
131 +
}
132 +
133 +
fn oxy_core_key(k: [u8; 32]) -> oxy_core::hash::KeyId {
134 +
    oxy_core::hash::ObjectRef(k)
135 +
}
136 +
137 +
#[derive(Template)]
138 +
#[template(path = "signin.html")]
139 +
struct SigninPage<'a> {
140 +
    mark: &'static str,
141 +
    message: &'a str,
142 +
}
143 +
144 +
fn signin_page(message: &str, status: StatusCode) -> Response {
145 +
    (status, render(&SigninPage { mark: MARK, message })).into_response()
146 +
}
147 +
148 +
fn render<T: Template>(t: &T) -> Response {
149 +
    match t.render() {
150 +
        Ok(html) => Html(html).into_response(),
151 +
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("could not render the page: {e}")).into_response(),
152 +
    }
153 +
}
154 +
155 +
/// Unsafe requests: the session's CSRF token and our own origin.
156 +
/// The refusal, if the form may not go through.
157 +
fn check_form(st: &RadarState, o: &Owner, headers: &HeaderMap, csrf: &str) -> Option<Response> {
158 +
    if !spectra_mcp::http::origin_ok(headers, &st.app.origins) {
159 +
        return Some((StatusCode::FORBIDDEN, "this origin may not change Spectra").into_response());
160 +
    }
161 +
    if csrf != o.session.csrf {
162 +
        return Some((StatusCode::FORBIDDEN, "the form is stale: reload the page").into_response());
163 +
    }
164 +
    None
165 +
}
166 +
167 +
/// Markdown, sanitised: what a message's body becomes on a page.
168 +
fn markdown(body: &str) -> String {
169 +
    let mut html = String::new();
170 +
    pulldown_cmark::html::push_html(
171 +
        &mut html,
172 +
        pulldown_cmark::Parser::new_ext(body, pulldown_cmark::Options::ENABLE_STRIKETHROUGH | pulldown_cmark::Options::ENABLE_TABLES),
173 +
    );
174 +
    ammonia::clean(&html)
175 +
}
176 +
177 +
// ---- Live ----
178 +
179 +
struct AgentCard {
180 +
    name: String,
181 +
    owner: String,
182 +
    kind: String,
183 +
    project: String,
184 +
    presence: String,
185 +
    status: Option<String>,
186 +
    last_tool: Option<String>,
187 +
    last_at: String,
188 +
}
189 +
190 +
struct EventRow {
191 +
    seq: i64,
192 +
    at: String,
193 +
    kind: String,
194 +
    actor: String,
195 +
    subject: Option<String>,
196 +
}
197 +
198 +
#[derive(Template)]
199 +
#[template(path = "live.html")]
200 +
struct LivePage {
201 +
    shell: Shell,
202 +
    agents: Vec<AgentCard>,
203 +
    unread: i64,
204 +
    events: Vec<EventRow>,
205 +
}
206 +
207 +
async fn live(State(st): State<RadarState>, headers: HeaderMap) -> Response {
208 +
    let o = match owner(&st, &headers, "live").await {
209 +
        Ok(o) => o,
210 +
        Err(r) => return r,
211 +
    };
212 +
    let d = &st.app.daemon;
213 +
    let pool = d.ledger.pool();
214 +
    let rows: Vec<(String, String, String, String, Option<String>)> =
215 +
        sqlx::query_as("SELECT a.name, a.owner_name, a.kind, a.project, p.status_text FROM agents a LEFT JOIN presence p ON p.agent = a.name WHERE a.disabled_ms IS NULL ORDER BY a.name")
216 +
            .fetch_all(pool)
217 +
            .await
218 +
            .unwrap_or_default();
219 +
    let mut agents = Vec::new();
220 +
    {
221 +
        let now = now_ms();
222 +
        for (name, owner, kind, project, status) in rows {
223 +
            let last: Option<(String, i64)> =
224 +
                sqlx::query_as("SELECT tool, at_ms FROM tool_calls WHERE agent = ? ORDER BY seq DESC LIMIT 1")
225 +
                    .bind(&name)
226 +
                    .fetch_optional(pool)
227 +
                    .await
228 +
                    .ok()
229 +
                    .flatten();
230 +
            let presence = d.presence.lock().await.state(&name, now).to_string();
231 +
            agents.push(AgentCard {
232 +
                name,
233 +
                owner,
234 +
                kind,
235 +
                project,
236 +
                presence,
237 +
                status,
238 +
                last_tool: last.as_ref().map(|(t, _)| t.clone()),
239 +
                last_at: last.map(|(_, at)| format_time(at as u64)).unwrap_or_default(),
240 +
            });
241 +
        }
242 +
    }
243 +
    let unread = conv::unread(&d.ledger, "human").await.unwrap_or(0);
244 +
    let events = recent(&st, None, None, 50)
245 +
        .await
246 +
        .into_iter()
247 +
        .map(|e| EventRow { seq: e.seq, at: e.at, kind: e.kind, actor: e.actor, subject: e.subject })
248 +
        .collect();
249 +
    render(&LivePage { shell: o.shell, agents, unread, events })
250 +
}
251 +
252 +
// ---- Channels ----
253 +
254 +
struct ChannelRow {
255 +
    name: String,
256 +
    display: String,
257 +
    kind: String,
258 +
    purpose: String,
259 +
    count: i64,
260 +
    last: String,
261 +
}
262 +
263 +
/// A channel with its message count and last message time.
264 +
type ChannelCount = (String, String, String, Option<String>, i64, Option<i64>);
265 +
266 +
#[derive(Template)]
267 +
#[template(path = "channels.html")]
268 +
struct ChannelsPage {
269 +
    shell: Shell,
270 +
    channels: Vec<ChannelRow>,
271 +
}
272 +
273 +
async fn channels(State(st): State<RadarState>, headers: HeaderMap) -> Response {
274 +
    let o = match owner(&st, &headers, "channels").await {
275 +
        Ok(o) => o,
276 +
        Err(r) => return r,
277 +
    };
278 +
    let rows: Vec<ChannelCount> = sqlx::query_as(
279 +
        "SELECT c.id, c.name, c.kind, c.purpose, (SELECT COUNT(*) FROM messages m WHERE m.channel = c.id), (SELECT MAX(at_ms) FROM messages m WHERE m.channel = c.id) \
280 +
         FROM channels c ORDER BY c.kind = 'direct', c.kind = 'system', c.name",
281 +
    )
282 +
    .fetch_all(st.app.daemon.ledger.pool())
283 +
    .await
284 +
    .unwrap_or_default();
285 +
    let channels = rows
286 +
        .into_iter()
287 +
        .map(|(_, name, kind, purpose, count, last)| ChannelRow {
288 +
            display: match name.strip_prefix("dm:") {
289 +
                Some(p) => format!("@{p}"),
290 +
                None => format!("#{name}"),
291 +
            },
292 +
            name,
293 +
            kind,
294 +
            purpose: purpose.unwrap_or_default(),
295 +
            count,
296 +
            last: last.map(|t| format_time(t as u64)).unwrap_or_default(),
297 +
        })
298 +
        .collect();
299 +
    render(&ChannelsPage { shell: o.shell, channels })
300 +
}
301 +
302 +
// ---- A channel and a thread ----
303 +
304 +
struct MessageRow {
305 +
    id: String,
306 +
    author: String,
307 +
    at: String,
308 +
    kind: String,
309 +
    replies: i64,
310 +
    redacted: bool,
311 +
    body_html: String,
312 +
}
313 +
314 +
#[derive(Template)]
315 +
#[template(path = "channel.html")]
316 +
struct ChannelPage {
317 +
    shell: Shell,
318 +
    name: String,
319 +
    display: String,
320 +
    purpose: Option<String>,
321 +
    thread: Option<String>,
322 +
    messages: Vec<MessageRow>,
323 +
    can_post: bool,
324 +
    action: String,
325 +
    csrf: String,
326 +
}
327 +
328 +
async fn show(st: &RadarState, o: Owner, name: &str, thread: Option<&str>) -> Response {
329 +
    let d = &st.app.daemon;
330 +
    let (ch, msgs) = match conv::read(&d.ledger, &o.principal, if thread.is_some() { None } else { Some(name) }, thread, None, 200).await {
331 +
        Ok(r) => r,
332 +
        Err(e) => return (StatusCode::NOT_FOUND, format!("{e:#}")).into_response(),
333 +
    };
334 +
    let mut messages = Vec::new();
335 +
    for m in msgs {
336 +
        messages.push(MessageRow {
337 +
            author: spectra_core::conversation::display_name(&d.ledger, &m.author).await,
338 +
            at: format_time(m.at_ms as u64),
339 +
            redacted: m.redacted.is_some(),
340 +
            body_html: if m.redacted.is_some() { ammonia::clean_text(&m.body) } else { markdown(&m.body) },
341 +
            id: m.id,
342 +
            kind: m.kind,
343 +
            replies: m.replies,
344 +
        });
345 +
    }
346 +
    let can_post = conv::can_post(&o.principal, &ch).is_ok();
347 +
    let action = match thread {
348 +
        Some(t) => format!("/c/{}/t/{t}", ch.name),
349 +
        None => format!("/c/{}", ch.name),
350 +
    };
351 +
    let csrf = o.session.csrf.clone();
352 +
    render(&ChannelPage {
353 +
        shell: o.shell,
354 +
        name: ch.name.clone(),
355 +
        display: ch.display(),
356 +
        purpose: ch.purpose.clone(),
357 +
        thread: thread.map(String::from),
358 +
        messages,
359 +
        can_post,
360 +
        action,
361 +
        csrf,
362 +
    })
363 +
}
364 +
365 +
async fn channel(State(st): State<RadarState>, headers: HeaderMap, Path(name): Path<String>) -> Response {
366 +
    match owner(&st, &headers, "channels").await {
367 +
        Ok(o) => show(&st, o, &name, None).await,
368 +
        Err(r) => r,
369 +
    }
370 +
}
371 +
372 +
async fn thread(State(st): State<RadarState>, headers: HeaderMap, Path((name, id)): Path<(String, String)>) -> Response {
373 +
    match owner(&st, &headers, "channels").await {
374 +
        Ok(o) => show(&st, o, &name, Some(&id)).await,
375 +
        Err(r) => r,
376 +
    }
377 +
}
378 +
379 +
#[derive(Deserialize)]
380 +
struct PostForm {
381 +
    _csrf: String,
382 +
    body: String,
383 +
    reply_to: Option<String>,
384 +
}
385 +
386 +
async fn post_to(st: &RadarState, headers: &HeaderMap, name: &str, form: PostForm, back: String) -> Response {
387 +
    let o = match owner(st, headers, "channels").await {
388 +
        Ok(o) => o,
389 +
        Err(r) => return r,
390 +
    };
391 +
    if let Some(r) = check_form(st, &o, headers, &form._csrf) {
392 +
        return r;
393 +
    }
394 +
    let post = Post {
395 +
        channel: if form.reply_to.is_some() { None } else { Some(name.to_string()) },
396 +
        body: form.body,
397 +
        reply_to: form.reply_to,
398 +
        ..Default::default()
399 +
    };
400 +
    match conv::post(&st.app.daemon.ledger, &o.author(), post, now_ms()).await {
401 +
        Ok(_) => Redirect::to(&back).into_response(),
402 +
        Err(e) => (StatusCode::BAD_REQUEST, format!("{e:#}")).into_response(),
403 +
    }
404 +
}
405 +
406 +
async fn post_channel(State(st): State<RadarState>, headers: HeaderMap, Path(name): Path<String>, Form(form): Form<PostForm>) -> Response {
407 +
    let back = format!("/c/{name}");
408 +
    post_to(&st, &headers, &name, form, back).await
409 +
}
410 +
411 +
async fn post_thread(
412 +
    State(st): State<RadarState>,
413 +
    headers: HeaderMap,
414 +
    Path((name, id)): Path<(String, String)>,
415 +
    Form(mut form): Form<PostForm>,
416 +
) -> Response {
417 +
    form.reply_to = Some(id.clone());
418 +
    let back = format!("/c/{name}/t/{id}");
419 +
    post_to(&st, &headers, &name, form, back).await
420 +
}
421 +
422 +
// ---- The log ----
423 +
424 +
struct LogRow {
425 +
    seq: i64,
426 +
    id: String,
427 +
    at: String,
428 +
    kind: String,
429 +
    actor: String,
430 +
    subject: Option<String>,
431 +
    method: String,
432 +
    assurance: i64,
433 +
    hash: String,
434 +
    prev: String,
435 +
    key: String,
436 +
    proof: String,
437 +
    payload: String,
438 +
}
439 +
440 +
async fn recent(st: &RadarState, kind: Option<&str>, before: Option<i64>, limit: i64) -> Vec<LogRow> {
441 +
    #[allow(clippy::type_complexity)]
442 +
    let rows: Vec<(
443 +
        i64,
444 +
        String,
445 +
        i64,
446 +
        String,
447 +
        String,
448 +
        Option<String>,
449 +
        i64,
450 +
        i64,
451 +
        Vec<u8>,
452 +
        Vec<u8>,
453 +
        Option<Vec<u8>>,
454 +
        Option<Vec<u8>>,
455 +
        Option<Vec<u8>>,
456 +
    )> = sqlx::query_as(
457 +
        "SELECT seq, id, at_ms, kind, actor, subject, auth_method, auth_assurance, hash, prev, auth_key, auth_proof, payload FROM events \
458 +
         WHERE (? IS NULL OR kind = ?) AND (? IS NULL OR seq < ?) ORDER BY seq DESC LIMIT ?",
459 +
    )
460 +
    .bind(kind)
461 +
    .bind(kind)
462 +
    .bind(before)
463 +
    .bind(before)
464 +
    .bind(limit)
465 +
    .fetch_all(st.app.daemon.ledger.pool())
466 +
    .await
467 +
    .unwrap_or_default();
468 +
    let mut out = Vec::new();
469 +
    for (seq, id, at, kind, actor, subject, method, assurance, hash, prev, key, proof, payload) in rows {
470 +
        let payload = payload
471 +
            .and_then(|p| serde_json::from_slice::<serde_json::Value>(&p).ok())
472 +
            .map(|v| serde_json::to_string_pretty(&v).unwrap_or_default())
473 +
            .unwrap_or_else(|| "(pruned to its stub)".into());
474 +
        out.push(LogRow {
475 +
            seq,
476 +
            id,
477 +
            at: format_time(at as u64),
478 +
            kind,
479 +
            actor: spectra_core::conversation::display_name(&st.app.daemon.ledger, &actor).await,
480 +
            subject,
481 +
            method: Method::from_u8(method as u8).map(|m| m.name()).unwrap_or("?").to_string(),
482 +
            assurance,
483 +
            hash: hex::encode(hash),
484 +
            prev: hex::encode(prev),
485 +
            key: key.map(hex::encode).unwrap_or_else(|| "none (Spectra itself; covered by the next checkpoint)".into()),
486 +
            proof: proof.map(|p| format!("{} bytes", p.len())).unwrap_or_else(|| "none".into()),
487 +
            payload,
488 +
        });
489 +
    }
490 +
    out
491 +
}
492 +
493 +
#[derive(Template)]
494 +
#[template(path = "log.html")]
495 +
struct LogPage {
496 +
    shell: Shell,
497 +
    head_hash: String,
498 +
    verified: String,
499 +
    csrf: String,
500 +
    kinds: Vec<String>,
501 +
    kind: String,
502 +
    events: Vec<LogRow>,
503 +
    older: Option<i64>,
504 +
}
505 +
506 +
#[derive(Deserialize)]
507 +
struct LogQuery {
508 +
    kind: Option<String>,
509 +
    before: Option<i64>,
510 +
    around: Option<i64>,
511 +
}
512 +
513 +
async fn log(State(st): State<RadarState>, headers: HeaderMap, Query(q): Query<LogQuery>) -> Response {
514 +
    let o = match owner(&st, &headers, "log").await {
515 +
        Ok(o) => o,
516 +
        Err(r) => return r,
517 +
    };
518 +
    let d = &st.app.daemon;
519 +
    let (_, head) = d.ledger.head().await;
520 +
    let last: Option<(Vec<u8>, i64)> =
521 +
        sqlx::query_as("SELECT payload, at_ms FROM events WHERE kind = 'ledger.verified' ORDER BY seq DESC LIMIT 1")
522 +
            .fetch_optional(d.ledger.pool())
523 +
            .await
524 +
            .ok()
525 +
            .flatten();
526 +
    let verified = match last {
527 +
        Some((p, at)) => {
528 +
            let v: serde_json::Value = serde_json::from_slice(&p).unwrap_or_default();
529 +
            let n = v["findings"].as_u64().unwrap_or(0);
530 +
            format!(
531 +
                "{} at {} over {} events",
532 +
                if n == 0 { "clean".to_string() } else { format!("{n} finding(s)") },
533 +
                format_time(at as u64),
534 +
                v["events"].as_u64().unwrap_or(0)
535 +
            )
536 +
        }
537 +
        None => "not yet".into(),
538 +
    };
539 +
    let kinds: Vec<String> =
540 +
        sqlx::query_scalar("SELECT kind FROM event_counts ORDER BY n DESC LIMIT 12").fetch_all(d.ledger.pool()).await.unwrap_or_default();
541 +
    let kind = q.kind.unwrap_or_default();
542 +
    let before = q.before.or(q.around.map(|a| a + 25));
543 +
    let events = recent(&st, (!kind.is_empty()).then_some(kind.as_str()), before, 100).await;
544 +
    let older = (events.len() == 100).then(|| events.last().map(|e| e.seq)).flatten();
545 +
    let csrf = o.session.csrf.clone();
546 +
    render(&LogPage { shell: o.shell, head_hash: hex::encode(head), verified, csrf, kinds, kind, events, older })
547 +
}
548 +
549 +
#[derive(Deserialize)]
550 +
struct CsrfForm {
551 +
    _csrf: String,
552 +
}
553 +
554 +
/// Verify the ledger now and record the result (`ledger.verified`).
555 +
async fn verify(State(st): State<RadarState>, headers: HeaderMap, Form(f): Form<CsrfForm>) -> Response {
556 +
    let o = match owner(&st, &headers, "log").await {
557 +
        Ok(o) => o,
558 +
        Err(r) => return r,
559 +
    };
560 +
    if let Some(r) = check_form(&st, &o, &headers, &f._csrf) {
561 +
        return r;
562 +
    }
563 +
    let ledger = &st.app.daemon.ledger;
564 +
    match ledger.verify(&mut Principals::default()).await {
565 +
        Ok(v) => {
566 +
            let e = spectra_core::event::NewEvent::system(
567 +
                "ledger.verified",
568 +
                serde_json::json!({ "events": v.events, "checkpoints": v.checkpoints, "findings": v.findings.len(), "head": hex::encode(v.head), "first": v.findings.first().map(|f| format!("#{}: {}", f.seq, f.detail)) }),
569 +
            );
570 +
            let _ = ledger.append_one(e).await;
571 +
            Redirect::to("/log").into_response()
572 +
        }
573 +
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("{e:#}")).into_response(),
574 +
    }
575 +
}
576 +
577 +
// ---- Signing in ----
578 +
579 +
/// `POST /radar/link`, signed by the owner's CLI: a one-time sign-in link.
580 +
async fn link(State(st): State<RadarState>, ConnectInfo(peer): ConnectInfo<PeerInfo>, headers: HeaderMap, body: Bytes) -> Response {
581 +
    let caller = match spectra_mcp::http::authenticate(&st.app, peer, &headers, "POST /radar/link", &body).await {
582 +
        Ok(c) => c,
583 +
        Err(r) => return r,
584 +
    };
585 +
    if !matches!(caller.principal, Principal::Owner { .. }) {
586 +
        return (StatusCode::FORBIDDEN, axum::Json(serde_json::json!({ "title": "only the owner signs in to Radar" }))).into_response();
587 +
    }
588 +
    let (Some(key), Some(proof)) = (caller.auth.key, caller.auth.proof) else {
589 +
        return StatusCode::UNAUTHORIZED.into_response();
590 +
    };
591 +
    let code = st.sessions.link(key, proof, now_ms());
592 +
    let base = st.app.origins.first().cloned().unwrap_or_default();
593 +
    axum::Json(serde_json::json!({ "url": format!("{base}/signin?code={code}"), "valid_ms": session::LINK_MS })).into_response()
594 +
}
595 +
596 +
#[derive(Deserialize)]
597 +
struct SigninQuery {
598 +
    code: Option<String>,
599 +
}
600 +
601 +
async fn signin(State(st): State<RadarState>, Query(q): Query<SigninQuery>) -> Response {
602 +
    let Some(id) = q.code.and_then(|c| st.sessions.open(&c, now_ms())) else {
603 +
        return signin_page("That sign-in link has been used or has expired.", StatusCode::UNAUTHORIZED);
604 +
    };
605 +
    let cookie = format!("{COOKIE}={id}; HttpOnly; SameSite=Strict; Path=/; Max-Age={}", session::SESSION_MS / 1000);
606 +
    let mut r = Redirect::to("/").into_response();
607 +
    r.headers_mut().insert(header::SET_COOKIE, HeaderValue::from_str(&cookie).unwrap());
608 +
    r
609 +
}
610 +
611 +
async fn signout(State(st): State<RadarState>, headers: HeaderMap, Form(f): Form<CsrfForm>) -> Response {
612 +
    if let Ok(o) = owner(&st, &headers, "live").await {
613 +
        if check_form(&st, &o, &headers, &f._csrf).is_none() {
614 +
            if let Some(id) = cookie(&headers) {
615 +
                st.sessions.close(&id);
616 +
            }
617 +
        }
618 +
    }
619 +
    signin_page("Signed out.", StatusCode::OK)
620 +
}
621 +
622 +
// ---- The event stream ----
623 +
624 +
/// `GET /radar/events`: one SSE message per appended event, carrying its
625 +
/// sequence number, kind, actor and subject, never its data (spec §17.3).
626 +
async fn events(State(st): State<RadarState>, headers: HeaderMap) -> Response {
627 +
    if let Err(r) = owner(&st, &headers, "live").await {
628 +
        return r;
629 +
    }
630 +
    let rx = st.app.daemon.ledger.subscribe();
631 +
    let stream = tokio_stream::wrappers::BroadcastStream::new(rx).filter_map(|e| async move {
632 +
        let e = e.ok()?;
633 +
        let data = serde_json::json!({ "seq": e.seq, "kind": e.kind, "actor": e.actor, "subject": e.subject });
634 +
        Some(Ok::<_, std::convert::Infallible>(SseEvent::default().data(data.to_string())))
635 +
    });
636 +
    Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response()
637 +
}
modified crates/spectra-core/src/conversation.rs +22 −0
…
583 unchanged lines
584 584
    Ok((first.as_deref() == Some(n.as_str())).then(|| "human".into()))
585 585
}
586 586
587 +
/// How a principal is shown to people: `claude-oxygen · Andy McCall's agent`,
588 +
/// the owner's name, or `Spectra`.
589 +
pub async fn display_name(ledger: &Ledger, actor: &str) -> String {
590 +
    if actor == "human" {
591 +
        let name: Option<String> = sqlx::query_scalar("SELECT name FROM identities WHERE role = 'owner' AND current = 1")
592 +
            .fetch_optional(ledger.pool())
593 +
            .await
594 +
            .ok()
595 +
            .flatten();
596 +
        return name.unwrap_or_else(|| "the owner".into());
597 +
    }
598 +
    if let Some(a) = actor.strip_prefix("agent:") {
599 +
        let owner: Option<String> =
600 +
            sqlx::query_scalar("SELECT owner_name FROM agents WHERE name = ?").bind(a).fetch_optional(ledger.pool()).await.ok().flatten();
601 +
        return match owner {
602 +
            Some(o) => format!("{a} · {o}'s agent"),
603 +
            None => a.to_string(),
604 +
        };
605 +
    }
606 +
    "Spectra".into()
607 +
}
608 +
587 609
/// `task:ID`, `decision:ID`, `oxy:change:HASH` and URLs in a body.
588 610
pub fn parse_refs(body: &str) -> Vec<String> {
589 611
    body.split(|c: char| c.is_whitespace() || "()[]<>,;\"'".contains(c))
…
158 unchanged lines
modified crates/spectra-core/src/event.rs +5 −0
…
30 unchanged lines
31 31
    Signature = 2,
32 32
    /// A passkey assertion with user presence.
33 33
    Passkey = 3,
34 +
    /// A Radar session opened by a link the owner signed with their CLI key:
35 +
    /// the proof is that signed request (spec §8.3).
36 +
    Session = 4,
34 37
}
35 38
36 39
impl Method {
…
3 unchanged lines
40 43
            1 => Method::Token,
41 44
            2 => Method::Signature,
42 45
            3 => Method::Passkey,
46 +
            4 => Method::Session,
43 47
            _ => return None,
44 48
        })
45 49
    }
…
3 unchanged lines
49 53
            Method::Token => "token",
50 54
            Method::Signature => "signature",
51 55
            Method::Passkey => "passkey",
56 +
            Method::Session => "session",
52 57
        }
53 58
    }
54 59
}
…
233 unchanged lines
modified crates/spectra-core/src/ledger.rs +134 −91
…
31 unchanged lines
32 32
    /// Held for as long as this ledger may append: one writer per data
33 33
    /// directory, so the chain never forks (`None` when opened read-only).
34 34
    _lock: Option<std::fs::File>,
35 +
    w: Arc<Writer>,
36 +
}
37 +
38 +
/// What writing needs, shared with the task that does it. Every write runs
39 +
/// in a task of its own, so a caller that goes away (a client that hangs up
40 +
/// mid-request) can never leave a transaction half done: a cancelled
41 +
/// transaction on a pooled connection would swallow every later commit
42 +
/// made on that connection.
43 +
struct Writer {
44 +
    pool: SqlitePool,
35 45
    head: Mutex<Head>,
36 46
    clock: Clock,
37 47
    bus: broadcast::Sender<Arc<Event>>,
…
77 unchanged lines
115 125
        let since = seq.saturating_sub(checkpoint_seq as u64);
116 126
        let (bus, _) = broadcast::channel(4096);
117 127
        Ok(Ledger {
118 -
            pool,
128 +
            pool: pool.clone(),
119 129
            _lock: None,
120 -
            head: Mutex::new(Head { seq, hash, since_checkpoint: since, oldest_unchecked_ms: (since > 0).then(ids::now_ms) }),
121 -
            clock: Clock::starting_at(hlc),
122 -
            bus,
123 -
            instance,
130 +
            w: Arc::new(Writer {
131 +
                pool,
132 +
                head: Mutex::new(Head { seq, hash, since_checkpoint: since, oldest_unchecked_ms: (since > 0).then(ids::now_ms) }),
133 +
                clock: Clock::starting_at(hlc),
134 +
                bus,
135 +
                instance,
136 +
            }),
124 137
        })
125 138
    }
126 139
…
4 unchanged lines
131 144
132 145
    /// Every appended event, as it commits.
133 146
    pub fn subscribe(&self) -> broadcast::Receiver<Arc<Event>> {
134 -
        self.bus.subscribe()
147 +
        self.w.bus.subscribe()
135 148
    }
136 149
137 150
    /// The instance key, when this ledger signs checkpoints.
138 151
    pub fn instance(&self) -> Option<&KeyPair> {
139 -
        self.instance.as_ref()
152 +
        self.w.instance.as_ref()
140 153
    }
141 154
142 155
    /// The newest event's sequence number and hash.
143 156
    pub async fn head(&self) -> (u64, [u8; 32]) {
144 -
        let h = self.head.lock().await;
157 +
        let h = self.w.head.lock().await;
145 158
        (h.seq, h.hash)
159 +
    }
160 +
161 +
    /// Append `events` in one transaction, chained and projected, then
162 +
    /// publish them; a checkpoint follows when one is due. The work runs to
163 +
    /// completion even if the caller is dropped.
164 +
    pub async fn append(&self, events: Vec<NewEvent>) -> Result<Vec<Arc<Event>>> {
165 +
        let w = self.w.clone();
166 +
        tokio::spawn(async move { w.append(events).await }).await.context("the ledger's writer stopped")?
167 +
    }
168 +
169 +
    /// Append one event.
170 +
    pub async fn append_one(&self, e: NewEvent) -> Result<Arc<Event>> {
171 +
        Ok(self.append(vec![e]).await?.remove(0))
172 +
    }
173 +
174 +
    /// Write a checkpoint now if any event is not covered by one (at
175 +
    /// shutdown, and from the ten-minute timer).
176 +
    pub async fn checkpoint(&self) -> Result<Option<Arc<Event>>> {
177 +
        let w = self.w.clone();
178 +
        tokio::spawn(async move { w.checkpoint().await }).await.context("the ledger's writer stopped")?
179 +
    }
180 +
181 +
    /// Events from `from` (inclusive), oldest first, at most `limit`.
182 +
    pub async fn events(&self, from: u64, limit: u32) -> Result<Vec<Event>> {
183 +
        let rows = sqlx::query(&format!("{SELECT_EVENT} WHERE seq >= ? ORDER BY seq LIMIT ?"))
184 +
            .bind(from as i64)
185 +
            .bind(limit as i64)
186 +
            .fetch_all(&self.pool)
187 +
            .await?;
188 +
        rows.iter().map(row_to_event).collect()
189 +
    }
190 +
191 +
    /// Events of the given kinds from `from` (inclusive), oldest first.
192 +
    pub async fn events_of_kinds(&self, from: u64, kinds: &[&str], limit: u32) -> Result<Vec<Event>> {
193 +
        let marks = vec!["?"; kinds.len()].join(",");
194 +
        let sql = format!("{SELECT_EVENT} WHERE seq >= ? AND kind IN ({marks}) ORDER BY seq LIMIT ?");
195 +
        let mut q = sqlx::query(&sql).bind(from as i64);
196 +
        for k in kinds {
197 +
            q = q.bind(*k);
198 +
        }
199 +
        let rows = q.bind(limit as i64).fetch_all(&self.pool).await?;
200 +
        rows.iter().map(row_to_event).collect()
201 +
    }
202 +
203 +
    /// Recompute the chain and check every checkpoint and every proof it
204 +
    /// can, from the ledger alone (spec §9.5, `spectra admin verify`).
205 +
    pub async fn verify(&self, keys: &mut dyn KeyResolver) -> Result<Verification> {
206 +
        let mut v = Verification::default();
207 +
        let mut prev = [0u8; 32];
208 +
        let mut expect = 1u64;
209 +
        let mut instance: Option<Identity> = None;
210 +
        let mut from = 1;
211 +
        loop {
212 +
            let batch = self.events(from, 2_000).await?;
213 +
            if batch.is_empty() {
214 +
                break;
215 +
            }
216 +
            for e in &batch {
217 +
                v.events += 1;
218 +
                if e.seq != expect {
219 +
                    v.finding(e.seq, Problem::Gap, format!("expected event {expect}"));
220 +
                    expect = e.seq;
221 +
                }
222 +
                if e.prev != prev {
223 +
                    v.finding(e.seq, Problem::ChainBreak, "prev is not the hash of the event before".into());
224 +
                }
225 +
                if e.payload.is_some() && e.computed_hash() != e.hash {
226 +
                    v.finding(e.seq, Problem::HashMismatch, "the event's fields do not give its hash: it was edited".into());
227 +
                }
228 +
                if e.kind == "identity.instance" {
229 +
                    instance = e
230 +
                        .json()
231 +
                        .get("identity")
232 +
                        .and_then(|h| h.as_str())
233 +
                        .and_then(|h| hex::decode(h).ok())
234 +
                        .and_then(|b| Identity::decode(&b).ok());
235 +
                }
236 +
                if e.kind == "ledger.checkpoint" {
237 +
                    v.checkpoints += 1;
238 +
                    match check_checkpoint(e, prev, instance.as_ref()) {
239 +
                        Ok(()) => v.last_checkpoint = Some(e.seq),
240 +
                        Err(why) => v.finding(e.seq, Problem::BadCheckpoint, why),
241 +
                    }
242 +
                } else if let Err(why) = keys.check(e) {
243 +
                    v.finding(e.seq, Problem::BadProof, why);
244 +
                }
245 +
                prev = e.hash;
246 +
                expect += 1;
247 +
            }
248 +
            from = batch.last().map(|e| e.seq + 1).unwrap_or(from);
249 +
        }
250 +
        v.head = prev;
251 +
        v.uncovered = v.events.saturating_sub(v.last_checkpoint.unwrap_or(0));
252 +
        Ok(v)
253 +
    }
254 +
255 +
    /// Clear every projection and replay the ledger into them.
256 +
    pub async fn rebuild(&self) -> Result<()> {
257 +
        let w = self.w.clone();
258 +
        tokio::spawn(async move { w.rebuild().await }).await.context("the ledger's writer stopped")?
259 +
    }
260 +
261 +
    /// Whether the projections equal a rebuild from the ledger: rebuild in
262 +
    /// a transaction, compare, and roll back. Returns the tables that differ.
263 +
    pub async fn check(&self) -> Result<Vec<String>> {
264 +
        let w = self.w.clone();
265 +
        tokio::spawn(async move { w.check().await }).await.context("the ledger's writer stopped")?
146 266
    }
267 +
}
147 268
269 +
impl Writer {
148 270
    /// Append `events` in one transaction, chained and projected, then
149 271
    /// publish them; a checkpoint follows when one is due.
150 -
    pub async fn append(&self, events: Vec<NewEvent>) -> Result<Vec<Arc<Event>>> {
272 +
    async fn append(&self, events: Vec<NewEvent>) -> Result<Vec<Arc<Event>>> {
151 273
        let mut head = self.head.lock().await;
152 274
        let mut tx = self.pool.begin().await?;
153 275
        let mut out = Vec::with_capacity(events.len() + 1);
…
30 unchanged lines
184 306
        Ok(out)
185 307
    }
186 308
187 -
    /// Append one event.
188 -
    pub async fn append_one(&self, e: NewEvent) -> Result<Arc<Event>> {
189 -
        Ok(self.append(vec![e]).await?.remove(0))
190 -
    }
191 -
192 309
    /// Write a checkpoint now if any event is not covered by one (at
193 310
    /// shutdown, and from the ten-minute timer).
194 -
    pub async fn checkpoint(&self) -> Result<Option<Arc<Event>>> {
311 +
    async fn checkpoint(&self) -> Result<Option<Arc<Event>>> {
195 312
        let Some(_) = &self.instance else { return Ok(None) };
196 313
        let mut head = self.head.lock().await;
197 314
        if head.since_checkpoint == 0 {
…
55 unchanged lines
253 370
        h.since_checkpoint = 0;
254 371
        h.oldest_unchecked_ms = None;
255 372
        e
256 -
    }
257 -
258 -
    /// Events from `from` (inclusive), oldest first, at most `limit`.
259 -
    pub async fn events(&self, from: u64, limit: u32) -> Result<Vec<Event>> {
260 -
        let rows = sqlx::query(&format!("{SELECT_EVENT} WHERE seq >= ? ORDER BY seq LIMIT ?"))
261 -
            .bind(from as i64)
262 -
            .bind(limit as i64)
263 -
            .fetch_all(&self.pool)
264 -
            .await?;
265 -
        rows.iter().map(row_to_event).collect()
266 -
    }
267 -
268 -
    /// Events of the given kinds from `from` (inclusive), oldest first.
269 -
    pub async fn events_of_kinds(&self, from: u64, kinds: &[&str], limit: u32) -> Result<Vec<Event>> {
270 -
        let marks = vec!["?"; kinds.len()].join(",");
271 -
        let sql = format!("{SELECT_EVENT} WHERE seq >= ? AND kind IN ({marks}) ORDER BY seq LIMIT ?");
272 -
        let mut q = sqlx::query(&sql).bind(from as i64);
273 -
        for k in kinds {
274 -
            q = q.bind(*k);
275 -
        }
276 -
        let rows = q.bind(limit as i64).fetch_all(&self.pool).await?;
277 -
        rows.iter().map(row_to_event).collect()
278 -
    }
279 -
280 -
    /// Recompute the chain and check every checkpoint and every proof it
281 -
    /// can, from the ledger alone (spec §9.5, `spectra admin verify`).
282 -
    pub async fn verify(&self, keys: &mut dyn KeyResolver) -> Result<Verification> {
283 -
        let mut v = Verification::default();
284 -
        let mut prev = [0u8; 32];
285 -
        let mut expect = 1u64;
286 -
        let mut instance: Option<Identity> = None;
287 -
        let mut from = 1;
288 -
        loop {
289 -
            let batch = self.events(from, 2_000).await?;
290 -
            if batch.is_empty() {
291 -
                break;
292 -
            }
293 -
            for e in &batch {
294 -
                v.events += 1;
295 -
                if e.seq != expect {
296 -
                    v.finding(e.seq, Problem::Gap, format!("expected event {expect}"));
297 -
                    expect = e.seq;
298 -
                }
299 -
                if e.prev != prev {
300 -
                    v.finding(e.seq, Problem::ChainBreak, "prev is not the hash of the event before".into());
301 -
                }
302 -
                if e.payload.is_some() && e.computed_hash() != e.hash {
303 -
                    v.finding(e.seq, Problem::HashMismatch, "the event's fields do not give its hash: it was edited".into());
304 -
                }
305 -
                if e.kind == "identity.instance" {
306 -
                    instance = e
307 -
                        .json()
308 -
                        .get("identity")
309 -
                        .and_then(|h| h.as_str())
310 -
                        .and_then(|h| hex::decode(h).ok())
311 -
                        .and_then(|b| Identity::decode(&b).ok());
312 -
                }
313 -
                if e.kind == "ledger.checkpoint" {
314 -
                    v.checkpoints += 1;
315 -
                    match check_checkpoint(e, prev, instance.as_ref()) {
316 -
                        Ok(()) => v.last_checkpoint = Some(e.seq),
317 -
                        Err(why) => v.finding(e.seq, Problem::BadCheckpoint, why),
318 -
                    }
319 -
                } else if let Err(why) = keys.check(e) {
320 -
                    v.finding(e.seq, Problem::BadProof, why);
321 -
                }
322 -
                prev = e.hash;
323 -
                expect += 1;
324 -
            }
325 -
            from = batch.last().map(|e| e.seq + 1).unwrap_or(from);
326 -
        }
327 -
        v.head = prev;
328 -
        v.uncovered = v.events.saturating_sub(v.last_checkpoint.unwrap_or(0));
329 -
        Ok(v)
330 373
    }
331 374
332 375
    /// Clear every projection and replay the ledger into them.
333 -
    pub async fn rebuild(&self) -> Result<()> {
376 +
    async fn rebuild(&self) -> Result<()> {
334 377
        let _head = self.head.lock().await;
335 378
        let mut tx = self.pool.begin().await?;
336 379
        replay(&mut tx).await?;
…
3 unchanged lines
340 383
341 384
    /// Whether the projections equal a rebuild from the ledger: rebuild in
342 385
    /// a transaction, compare, and roll back. Returns the tables that differ.
343 -
    pub async fn check(&self) -> Result<Vec<String>> {
386 +
    async fn check(&self) -> Result<Vec<String>> {
344 387
        let _head = self.head.lock().await;
345 388
        let mut tx = self.pool.begin().await?;
346 389
        let mut before = Vec::new();
…
205 unchanged lines
modified crates/spectra-core/src/principals.rs +17 −0
…
244 unchanged lines
245 245
        Ok(owner)
246 246
    }
247 247
248 +
    /// An event from a Radar session: its proof is the owner's signed
249 +
    /// request for the sign-in link, so it must verify against the owner,
250 +
    /// and only the owner acts this way.
251 +
    pub fn check_session(&self, e: &Event) -> Result<(), String> {
252 +
        let key = oxy_core::hash::ObjectRef(e.auth.key.ok_or("a session event names no key")?);
253 +
        if !matches!(self.principal(&key, e.hlc.wall_ms)?, Principal::Owner { .. }) || e.actor != "human" {
254 +
            return Err("only the owner acts through a Radar session".into());
255 +
        }
256 +
        let identity = self.identity(&key).ok_or("an unknown key")?;
257 +
        let proof = Proof::decode(e.auth.proof.as_deref().ok_or("no proof")?)?;
258 +
        if proof.key() != key.0 || proof.method != "POST /radar/link" {
259 +
            return Err("the session's proof is not the owner's sign-in request".into());
260 +
        }
261 +
        proof.verify(identity, None)
262 +
    }
263 +
248 264
    /// Check a signed event's proof against the principal its key was at
249 265
    /// the event's time.
250 266
    pub fn check_signed(&self, e: &Event) -> Result<(), String> {
…
20 unchanged lines
271 287
            }
272 288
            "agent.uid_bound" => self.observe(e),
273 289
            _ if matches!(e.auth.method, Method::Signature | Method::Passkey) => self.check_signed(e),
290 +
            _ if e.auth.method == Method::Session => self.check_session(e),
274 291
            _ => Ok(()),
275 292
        }
276 293
    }
…
149 unchanged lines
modified crates/spectra-core/tests/ledger.rs +28 −0
…
206 unchanged lines
207 207
    // The target is 10 ms with fsync (spec §24); debug builds get headroom.
208 208
    assert!(each_us < 50_000, "{each_us} µs");
209 209
}
210 +
211 +
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
212 +
async fn an_append_abandoned_mid_flight_still_commits_and_never_blocks_later_ones() {
213 +
    // A client that hangs up mid-request drops the future doing its append.
214 +
    // Before the writer ran in a task of its own, that could leave a pooled
215 +
    // connection inside an open transaction, and every later "commit" on it
216 +
    // went nowhere (found by hand in the Radar demo, 2026-11-02).
217 +
    let dir = tempfile::tempdir().unwrap();
218 +
    let ledger = open(dir.path()).await;
219 +
    for i in 0..300 {
220 +
        let f = ledger.append_one(msg(i));
221 +
        tokio::select! {
222 +
            biased;
223 +
            _ = f => {}
224 +
            _ = tokio::task::yield_now() => {}
225 +
        }
226 +
    }
227 +
    tokio::time::sleep(std::time::Duration::from_millis(300)).await;
228 +
    let last = ledger.append_one(msg(10_000)).await.unwrap();
229 +
    let (head, _) = ledger.head().await;
230 +
    assert_eq!(last.seq, head);
231 +
    let other = sqlx::SqlitePool::connect(&format!("sqlite://{}", dir.path().join("spectra.db").display())).await.unwrap();
232 +
    let seen: i64 = sqlx::query_scalar("SELECT MAX(seq) FROM events").fetch_one(&other).await.unwrap();
233 +
    assert_eq!(seen as u64, head, "another connection sees every committed event");
234 +
    let n: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM events WHERE kind = 'test.message'").fetch_one(&other).await.unwrap();
235 +
    assert_eq!(n, 301, "every abandoned append still committed");
236 +
    assert!(ledger.verify(&mut NoKeys).await.unwrap().clean());
237 +
}
modified crates/spectra-mcp/src/client.rs +5 −6
…
49 unchanged lines
50 50
        tokio::spawn(async move {
51 51
            let _ = conn.await;
52 52
        });
53 -
        let mut req = hyper::Request::builder()
54 -
            .method(method)
55 -
            .uri(path)
56 -
            .header("host", "spectra")
57 -
            .header("content-type", "application/json")
58 -
            .header("connection", "close");
59 53
        // One request a connection: the server closes it after answering, so
60 54
        // nothing lingers to hold up its shutdown.
55 +
        let mut req = hyper::Request::builder().method(method).uri(path).header("host", "spectra").header("connection", "close");
56 +
        // JSON unless the caller says otherwise (a form, say).
57 +
        if !headers.iter().any(|(k, _)| k.eq_ignore_ascii_case("content-type")) {
58 +
            req = req.header("content-type", "application/json");
59 +
        }
61 60
        for (k, v) in headers {
62 61
            req = req.header(*k, v);
63 62
        }
…
55 unchanged lines
modified docs/plan.md +2 −2
…
5 unchanged lines
6 6
7 7
- Never run `git init` in this directory or push this project anywhere but its `oxygenhub` remote. Commit with `OXY_HOME=~/.oxy-agents/claude oxy commit -m "..." PATHS..`, never with Andy's own key.
8 8
- Tests are hermetic: a temporary data directory, a temporary socket, port 0; never `~/.local/share/spectra`, `~/.config` or the network.
9 -
- Gate every commit on `make check` (changelog, fmt, clippy with warnings as errors, tests).
9 +
- Gate every commit on `make check` (changelog, fmt, clippy with warnings as errors, tests), chained so a failing gate stops the commit: `make check && oxy commit …`.
10 10
- Every code change adds a CHANGELOG.md line under `## Unreleased`, or sets `NO_CHANGELOG=1`.
11 11
- Never weaken a security goal of spec §5 to make a test pass.
12 12
- Never commit or push anything under `docs/prompts/` (Andy's prompts; ignored in `.oxyignore`). Name the paths in every commit.
…
10 unchanged lines
23 23
- [x] #7 Tuner: MCP over Streamable HTTP — done 2026-11-02: spectra serve, socket + loopback, signed requests, both revisions, refusals, owner events through the daemon; end-to-end tests drive the real binary.
24 24
- [x] #8 The 0.1 tools — done 2026-11-02: eleven tools through one invoke wrapper (record, rules line); wait with the idle guard; end-to-end tests with two agents. Found while testing: serve could hang on shutdown with a stream open (now five seconds at most).
25 25
- [x] #9 The stdio shim — done 2026-11-02: spectra mcp --agent NAME, driven over pipes in tests/cli_mcp.rs. Real Claude Code, Antigravity and Kimi connections are checked in #13 with Andy, since they need his MCP configuration.
26 -
- [ ] #10 Radar
26 +
- [x] #10 Radar — done 2026-11-02: Live, Channels, channel/thread with compose, Log with Verify now; spectra open; checked by hand in Chrome on a demo Spectra. That demo found the ledger cancellation bug (fixed, regression test fails on the old code) and a referrer policy that blanked the Origin of form posts.
27 27
- [ ] #11 JSON API and spectra-api types
28 28
- [ ] #12 Human CLI
29 29
- [ ] #13 Acceptance (with Andy)
…
7 unchanged lines
modified docs/spec.md +3 −2
…
1 unchanged lines
2 2
3 3
| | |
4 4
|---|---|
5 -
| **Status** | Draft 0.3b |
5 +
| **Status** | Draft 0.3c |
6 6
| **Date** | 2026-11-02 |
7 7
| **Scope** | One Rust binary, `spectra`, that AI coding agents on one machine connect to over the Model Context Protocol (MCP) to find each other, talk, hand work over, keep to the house rules, remember what was said and decided, and let the human see all of it live. The hosted, multi-person, multi-machine layer is SpectraHub (`../spectrahub/docs/spec.md`). |
8 8
| **Companion documents** | `../spectrahub/docs/spec.md`, `../spectra-vscode/docs/spec.md`, `../spectrahub-vscode/docs/spec.md` |
…
311 unchanged lines
320 320
321 321
**Signed requests.** A shim signs every request it forwards with the agent's key; the CLI, with the owner's. The headers are `Spectra-Key` (the KeyId), `Spectra-Time` (milliseconds), `Spectra-Nonce` (16 random bytes, hex) and `Spectra-Signature` (an Oxygen composite signature, oxygen spec §14.3, over `"spectra1 request " || method || 0x00 || BLAKE3(body) || time || nonce`, made through `oxy-core`'s challenge signing so the signed target is `BLAKE3("oxy1 challenge " || message)` and can never be taken for a change, an attestation or a policy). The daemon refuses a time more than five minutes from its clock, a nonce it has seen in that window, a key it does not know, and a key whose owner's current identity does not delegate to it now (§8.1). The signature is kept as the resulting events' proof (§9.1), so an event can be checked later against the identity in the ledger without trusting the daemon that wrote it.
322 322
323 -
**Passkey assertions** are the proof of the human's Radar actions (§8.3). **Tokens**, when allowed, are proof of nothing beyond possession and are recorded as such.
323 +
**Passkey assertions** are the proof of the human's Radar actions (§8.3). Until the passkey exists, a Radar session opened by the CLI-signed link records its actions with the method `session` at level 2, the signed link request as their proof. **Tokens**, when allowed, are proof of nothing beyond possession and are recorded as such.
324 324
325 325
**Assurance levels**, recorded on every event in `auth.assurance`:
326 326
…
665 unchanged lines
992 992
993 993
## 27. Changelog
994 994
995 +
- Draft 0.3c (2026-11-02), while building #10: a Radar session's events carry the method `session` (assurance 2) and, as proof, the owner's signed request for the sign-in link, which `verify` checks against the owner (§8.3); every ledger write runs to completion in a task of its own, so a caller that goes away cannot leave a transaction open (§9.5); Radar's templates and assets live in the `radar` crate, so SpectraHub mounts it whole (§7.3).
995 996
- Draft 0.3b (2026-11-02), while building #7: a refused request names its reason (§15.1); requests to the daemon are signed with the owner identity the ledger already records, and a rotation travels in the same batch as the event that needs it (§8.5).
996 997
- Draft 0.3a (2026-11-02), while building #3 and #4: request and checkpoint signatures go through `oxy-core`'s challenge signing with Spectra's prefix inside the message (§8.5, §9.5); CLI-written events are signed over their payload, and a signed event's actor must be its signer (§9.5); one writer per data directory under a lock (§8.5); `identity refresh` proves possession at every successor and refuses a fresh key pair in 0.1 (§8.1); `agent add` waits for the delegation and resumes (§8.1).
997 998
- Draft 0.3 (2026-11-02), security: agents are their owner's Oxygen delegates with ownership verified and shown (§8.1, G7); no owner token: the owner signs from the CLI and uses a passkey in Radar (§8.3); a Unix socket with peer credentials, signed requests, assurance levels and the policy that keeps the human's authority out of agents' reach (§8.5, G8); the ledger: hash chain, proofs on every event, checkpoints signed by an instance key and witnessed by OxygenHub's transparency log, `spectra admin verify`, pruning to stubs (§9.5, G6); full tool-call payloads content addressed (§9.2); committed rules with their `oxy` change (§12.4); claimed changes verified (§23.5); one uid per agent as the recommended deployment and the limit stated (§5.3, §21.6).
…
2 unchanged lines
modified src/cli/mod.rs +15 −2
…
8 unchanged lines
9 9
pub mod json;
10 10
pub mod marks;
11 11
pub mod mcp;
12 +
pub mod open;
12 13
pub mod project;
13 14
pub mod serve;
14 15
pub mod ui;
…
46 unchanged lines
61 62
        /// Allow a non-loopback address in plain HTTP
62 63
        #[arg(long)]
63 64
        insecure: bool,
65 +
        /// Open Radar in the browser once serving (signs a link as the owner)
66 +
        #[arg(long)]
67 +
        open: bool,
68 +
    },
69 +
    /// Sign in to Radar: a one-time link signed with the owner's key
70 +
    Open {
71 +
        #[arg(long)]
72 +
        owner_home: Option<PathBuf>,
73 +
        /// Print the link without opening a browser
74 +
        #[arg(long)]
75 +
        no_browser: bool,
64 76
    },
65 77
    /// The stdio MCP server for one agent: signs with its key and forwards to the daemon
66 78
    Mcp {
…
227 unchanged lines
294 306
                IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await,
295 307
            }
296 308
        }),
297 -
        Command::Serve { bind, insecure } => {
309 +
        Command::Serve { bind, insecure, open } => {
298 310
            init_logging();
299 -
            runtime()?.block_on(serve::serve(&ctx, &bind, insecure))
311 +
            runtime()?.block_on(serve::serve(&ctx, &bind, insecure, open))
300 312
        }
313 +
        Command::Open { owner_home, no_browser } => runtime()?.block_on(open::open(&ctx, owner_home, !no_browser)),
301 314
        Command::Mcp { agent, home } => runtime()?.block_on(mcp::run(&ctx, &agent, home)),
302 315
        Command::Project { command } => runtime()?.block_on(async {
303 316
            match command {
…
39 unchanged lines
modified src/cli/serve.rs +13 −2
…
9 unchanged lines
10 10
use std::net::SocketAddr;
11 11
use std::sync::Arc;
12 12
13 -
pub async fn serve(ctx: &Ctx, bind: &str, insecure: bool) -> Result<i32> {
13 +
pub async fn serve(ctx: &Ctx, bind: &str, insecure: bool, open: bool) -> Result<i32> {
14 14
    let addr: SocketAddr = bind.parse().with_context(|| format!("--bind {bind} is not an address and port"))?;
15 15
    if !addr.ip().is_loopback() && !insecure {
16 16
        bail!("Spectra serves loopback only unless it has TLS (spec §21.1); pass --insecure to bind {addr} in plain HTTP anyway");
…
16 unchanged lines
33 33
    let port = tcp.local_addr()?.port();
34 34
    let origins = vec![format!("http://127.0.0.1:{port}"), format!("http://localhost:{port}"), format!("http://{addr}")];
35 35
    let state = AppState { daemon: daemon.clone(), origins: Arc::new(origins) };
36 -
    let app = spectra_mcp::http::router(state);
36 +
    let app = spectra_mcp::http::router(state.clone()).merge(radar::router(state));
37 37
38 38
    let (head, _) = daemon.ledger.head().await;
39 39
    let agents = sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM agents WHERE disabled_ms IS NULL")
…
5 unchanged lines
45 45
    println!("{}", ui::kv("http", format!("http://127.0.0.1:{port}")));
46 46
    println!("{}", ui::kv("ledger", format!("{head} events{}{agents} agent(s)", ui::sep())));
47 47
    println!("  {} {}", ui::note(), ui::dim("agents connect with: spectra mcp --agent NAME"));
48 +
    println!("  {} {}", ui::note(), ui::dim("open Radar with: spectra open"));
49 +
    if open {
50 +
        let ctx2 = Ctx { data: ctx.data.clone() };
51 +
        tokio::spawn(async move {
52 +
            // Once the socket answers, sign the link as the owner and open it.
53 +
            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
54 +
            if let Err(e) = super::open::open(&ctx2, None, true).await {
55 +
                eprintln!("{} {e:#}", ui::caution());
56 +
            }
57 +
        });
58 +
    }
48 59
49 60
    let (stop_tx, stop_rx) = tokio::sync::watch::channel(false);
50 61
    let stop = |mut rx: tokio::sync::watch::Receiver<bool>| async move {
…
47 unchanged lines