Spectra stands alone: its own identities, signatures and CBOR; projects of any kind of work (#26, #27) 98a0fb9f0948
Spectra no longer uses Oxygen in any way. Composite Ed25519 + ML-DSA-65 identities with delegation and expiry, deterministic CBOR and atomic writes are Spectra's own code, designed after Oxygen's and sharing none. init, agent add and connect sign delegations themselves once the owner confirms. A project is any work with an optional folder that is never read; the owner links projects. deps/oxygen and oxy-core are gone. inbox --follow no longer misses a message posted as it starts. Spec Draft 0.5. Co-Authored-By: Claude Opus 5.5 <[email protected]>
- Author
- Andy McCall @andymccall <[email protected]>
- via
- agent
agent:claude@andymccall <[email protected]> · keyless, under your grant for changes,refs,seal · key fdf1b15e9f7d
- When
- · 2026-11-03 15:22 UTC
- Signature
- ◈ sealed by agent:claude@andymccall
- Hash
98a0fb9f094806b4deb462510587e3a11cd5bd742de72d6155d2637bc1203e87- Depends on
- 037c45d8093c 09752034b5a8 200875410937 258bb3824813 2fc88a7b3a5c 36537112fcac 39f5c913f617 571572f5fb2c 5d695d869fbf 5eb57578f4ef 67bfa06a894f 77df591d5a2f 813b6330f3ad 8dc6df8c4d4e 98a16830a3d3 aa72eb904932 b576d05cf862 d17cbc7069ce da84e98f104e e071e91a14ef eca5af92279b
- Depended on by
- 1876f2ce71e4 862f0db224aa 87f601ccce6d c7161ec1a607 b044d90930d9 f7986cb07b0e 8f87df88babf
- In bookmarks
- main
- In tags
- v0.1.0
- Edits
- 339 atoms, 7 tree operations, 53 files
Browse the tree at this change
added crates/spectra-core/src/cbor.rs +267 −0
| 1 | + | //! Deterministic CBOR (RFC 8949 §4.2), as much of it as Spectra encodes: |
|
| 2 | + | //! unsigned integers, byte and text strings, arrays, maps with integer |
|
| 3 | + | //! keys, null and booleans. Shortest forms, definite lengths, map keys in |
|
| 4 | + | //! ascending order; the decoder refuses anything else, so one value has |
|
| 5 | + | //! exactly one encoding and hashing it is meaningful (spec §9.5). |
|
| 6 | + | ||
| 7 | + | use std::collections::BTreeMap; |
|
| 8 | + | ||
| 9 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 10 | + | pub enum Value { |
|
| 11 | + | Uint(u64), |
|
| 12 | + | Bytes(Vec<u8>), |
|
| 13 | + | Text(String), |
|
| 14 | + | Array(Vec<Value>), |
|
| 15 | + | Map(BTreeMap<u64, Value>), |
|
| 16 | + | Bool(bool), |
|
| 17 | + | Null, |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | impl Value { |
|
| 21 | + | pub fn map<const N: usize>(entries: [(u64, Value); N]) -> Value { |
|
| 22 | + | Value::Map(entries.into_iter().collect()) |
|
| 23 | + | } |
|
| 24 | + | pub fn as_uint(&self) -> Option<u64> { |
|
| 25 | + | match self { |
|
| 26 | + | Value::Uint(u) => Some(*u), |
|
| 27 | + | _ => None, |
|
| 28 | + | } |
|
| 29 | + | } |
|
| 30 | + | pub fn as_bytes(&self) -> Option<&[u8]> { |
|
| 31 | + | match self { |
|
| 32 | + | Value::Bytes(b) => Some(b), |
|
| 33 | + | _ => None, |
|
| 34 | + | } |
|
| 35 | + | } |
|
| 36 | + | pub fn as_text(&self) -> Option<&str> { |
|
| 37 | + | match self { |
|
| 38 | + | Value::Text(t) => Some(t), |
|
| 39 | + | _ => None, |
|
| 40 | + | } |
|
| 41 | + | } |
|
| 42 | + | pub fn as_array(&self) -> Option<&[Value]> { |
|
| 43 | + | match self { |
|
| 44 | + | Value::Array(a) => Some(a), |
|
| 45 | + | _ => None, |
|
| 46 | + | } |
|
| 47 | + | } |
|
| 48 | + | pub fn as_map(&self) -> Option<&BTreeMap<u64, Value>> { |
|
| 49 | + | match self { |
|
| 50 | + | Value::Map(m) => Some(m), |
|
| 51 | + | _ => None, |
|
| 52 | + | } |
|
| 53 | + | } |
|
| 54 | + | pub fn is_null(&self) -> bool { |
|
| 55 | + | matches!(self, Value::Null) |
|
| 56 | + | } |
|
| 57 | + | } |
|
| 58 | + | ||
| 59 | + | fn head(out: &mut Vec<u8>, major: u8, n: u64) { |
|
| 60 | + | let m = major << 5; |
|
| 61 | + | match n { |
|
| 62 | + | 0..=23 => out.push(m | n as u8), |
|
| 63 | + | 24..=0xff => out.extend_from_slice(&[m | 24, n as u8]), |
|
| 64 | + | 0x100..=0xffff => { |
|
| 65 | + | out.push(m | 25); |
|
| 66 | + | out.extend_from_slice(&(n as u16).to_be_bytes()); |
|
| 67 | + | } |
|
| 68 | + | 0x1_0000..=0xffff_ffff => { |
|
| 69 | + | out.push(m | 26); |
|
| 70 | + | out.extend_from_slice(&(n as u32).to_be_bytes()); |
|
| 71 | + | } |
|
| 72 | + | _ => { |
|
| 73 | + | out.push(m | 27); |
|
| 74 | + | out.extend_from_slice(&n.to_be_bytes()); |
|
| 75 | + | } |
|
| 76 | + | } |
|
| 77 | + | } |
|
| 78 | + | ||
| 79 | + | fn put(out: &mut Vec<u8>, v: &Value) { |
|
| 80 | + | match v { |
|
| 81 | + | Value::Uint(n) => head(out, 0, *n), |
|
| 82 | + | Value::Bytes(b) => { |
|
| 83 | + | head(out, 2, b.len() as u64); |
|
| 84 | + | out.extend_from_slice(b); |
|
| 85 | + | } |
|
| 86 | + | Value::Text(t) => { |
|
| 87 | + | head(out, 3, t.len() as u64); |
|
| 88 | + | out.extend_from_slice(t.as_bytes()); |
|
| 89 | + | } |
|
| 90 | + | Value::Array(a) => { |
|
| 91 | + | head(out, 4, a.len() as u64); |
|
| 92 | + | for x in a { |
|
| 93 | + | put(out, x); |
|
| 94 | + | } |
|
| 95 | + | } |
|
| 96 | + | // BTreeMap iterates keys ascending, which for unsigned integers is |
|
| 97 | + | // the order of their shortest encodings. |
|
| 98 | + | Value::Map(m) => { |
|
| 99 | + | head(out, 5, m.len() as u64); |
|
| 100 | + | for (k, x) in m { |
|
| 101 | + | head(out, 0, *k); |
|
| 102 | + | put(out, x); |
|
| 103 | + | } |
|
| 104 | + | } |
|
| 105 | + | Value::Bool(false) => out.push(0xf4), |
|
| 106 | + | Value::Bool(true) => out.push(0xf5), |
|
| 107 | + | Value::Null => out.push(0xf6), |
|
| 108 | + | } |
|
| 109 | + | } |
|
| 110 | + | ||
| 111 | + | pub fn encode(v: &Value) -> Vec<u8> { |
|
| 112 | + | let mut out = Vec::new(); |
|
| 113 | + | put(&mut out, v); |
|
| 114 | + | out |
|
| 115 | + | } |
|
| 116 | + | ||
| 117 | + | /// Why bytes are not a value Spectra encodes. |
|
| 118 | + | #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] |
|
| 119 | + | #[error("malformed CBOR: {0}")] |
|
| 120 | + | pub struct Error(pub String); |
|
| 121 | + | ||
| 122 | + | struct Reader<'a> { |
|
| 123 | + | b: &'a [u8], |
|
| 124 | + | at: usize, |
|
| 125 | + | depth: usize, |
|
| 126 | + | } |
|
| 127 | + | ||
| 128 | + | const MAX_DEPTH: usize = 32; |
|
| 129 | + | ||
| 130 | + | impl<'a> Reader<'a> { |
|
| 131 | + | fn take(&mut self, n: usize) -> Result<&'a [u8], Error> { |
|
| 132 | + | let end = self.at.checked_add(n).filter(|e| *e <= self.b.len()).ok_or_else(|| Error("truncated".into()))?; |
|
| 133 | + | let s = &self.b[self.at..end]; |
|
| 134 | + | self.at = end; |
|
| 135 | + | Ok(s) |
|
| 136 | + | } |
|
| 137 | + | ||
| 138 | + | fn head(&mut self) -> Result<(u8, u64), Error> { |
|
| 139 | + | let first = self.take(1)?[0]; |
|
| 140 | + | let (major, info) = (first >> 5, first & 0x1f); |
|
| 141 | + | let n = match info { |
|
| 142 | + | 0..=23 => info as u64, |
|
| 143 | + | 24 => self.take(1)?[0] as u64, |
|
| 144 | + | 25 => u16::from_be_bytes(self.take(2)?.try_into().unwrap()) as u64, |
|
| 145 | + | 26 => u32::from_be_bytes(self.take(4)?.try_into().unwrap()) as u64, |
|
| 146 | + | 27 => u64::from_be_bytes(self.take(8)?.try_into().unwrap()), |
|
| 147 | + | _ if major == 7 => return Ok((7, info as u64)), |
|
| 148 | + | _ => return Err(Error("an indefinite length or a reserved form".into())), |
|
| 149 | + | }; |
|
| 150 | + | let minimal = match info { |
|
| 151 | + | 24 => n >= 24, |
|
| 152 | + | 25 => n > 0xff, |
|
| 153 | + | 26 => n > 0xffff, |
|
| 154 | + | 27 => n > 0xffff_ffff, |
|
| 155 | + | _ => true, |
|
| 156 | + | }; |
|
| 157 | + | if !minimal && major != 7 { |
|
| 158 | + | return Err(Error("a length or number not in its shortest form".into())); |
|
| 159 | + | } |
|
| 160 | + | Ok((major, n)) |
|
| 161 | + | } |
|
| 162 | + | ||
| 163 | + | fn len(&mut self, n: u64) -> Result<usize, Error> { |
|
| 164 | + | let n = usize::try_from(n).map_err(|_| Error("too long".into()))?; |
|
| 165 | + | if n > self.b.len() - self.at { |
|
| 166 | + | return Err(Error("truncated".into())); |
|
| 167 | + | } |
|
| 168 | + | Ok(n) |
|
| 169 | + | } |
|
| 170 | + | ||
| 171 | + | fn value(&mut self) -> Result<Value, Error> { |
|
| 172 | + | self.depth += 1; |
|
| 173 | + | if self.depth > MAX_DEPTH { |
|
| 174 | + | return Err(Error("nested too deeply".into())); |
|
| 175 | + | } |
|
| 176 | + | let (major, n) = self.head()?; |
|
| 177 | + | let v = match major { |
|
| 178 | + | 0 => Value::Uint(n), |
|
| 179 | + | 2 => { |
|
| 180 | + | let l = self.len(n)?; |
|
| 181 | + | Value::Bytes(self.take(l)?.to_vec()) |
|
| 182 | + | } |
|
| 183 | + | 3 => { |
|
| 184 | + | let l = self.len(n)?; |
|
| 185 | + | Value::Text(String::from_utf8(self.take(l)?.to_vec()).map_err(|_| Error("text that is not UTF-8".into()))?) |
|
| 186 | + | } |
|
| 187 | + | 4 => { |
|
| 188 | + | let l = self.len(n)?; |
|
| 189 | + | let mut a = Vec::with_capacity(l); |
|
| 190 | + | for _ in 0..l { |
|
| 191 | + | a.push(self.value()?); |
|
| 192 | + | } |
|
| 193 | + | Value::Array(a) |
|
| 194 | + | } |
|
| 195 | + | 5 => { |
|
| 196 | + | let l = self.len(n)?; |
|
| 197 | + | let mut m = BTreeMap::new(); |
|
| 198 | + | let mut last: Option<u64> = None; |
|
| 199 | + | for _ in 0..l { |
|
| 200 | + | let (km, k) = self.head()?; |
|
| 201 | + | if km != 0 { |
|
| 202 | + | return Err(Error("a map key that is not an unsigned integer".into())); |
|
| 203 | + | } |
|
| 204 | + | if last.is_some_and(|p| k <= p) { |
|
| 205 | + | return Err(Error("map keys out of order or repeated".into())); |
|
| 206 | + | } |
|
| 207 | + | last = Some(k); |
|
| 208 | + | m.insert(k, self.value()?); |
|
| 209 | + | } |
|
| 210 | + | Value::Map(m) |
|
| 211 | + | } |
|
| 212 | + | 7 => match n { |
|
| 213 | + | 20 => Value::Bool(false), |
|
| 214 | + | 21 => Value::Bool(true), |
|
| 215 | + | 22 => Value::Null, |
|
| 216 | + | _ => return Err(Error("an unsupported simple value or float".into())), |
|
| 217 | + | }, |
|
| 218 | + | _ => return Err(Error("a negative integer or a tag".into())), |
|
| 219 | + | }; |
|
| 220 | + | self.depth -= 1; |
|
| 221 | + | Ok(v) |
|
| 222 | + | } |
|
| 223 | + | } |
|
| 224 | + | ||
| 225 | + | /// Decode exactly one value, refusing any non-canonical encoding. |
|
| 226 | + | pub fn decode(b: &[u8]) -> Result<Value, Error> { |
|
| 227 | + | let mut r = Reader { b, at: 0, depth: 0 }; |
|
| 228 | + | let v = r.value()?; |
|
| 229 | + | if r.at != b.len() { |
|
| 230 | + | return Err(Error("bytes after the value".into())); |
|
| 231 | + | } |
|
| 232 | + | Ok(v) |
|
| 233 | + | } |
|
| 234 | + | ||
| 235 | + | #[cfg(test)] |
|
| 236 | + | mod tests { |
|
| 237 | + | use super::*; |
|
| 238 | + | ||
| 239 | + | #[test] |
|
| 240 | + | fn round_trips_and_known_encodings() { |
|
| 241 | + | let v = Value::map([ |
|
| 242 | + | (0, Value::Uint(1)), |
|
| 243 | + | (1, Value::Text("é".into())), |
|
| 244 | + | (2, Value::Bytes(vec![1, 2])), |
|
| 245 | + | (3, Value::Array(vec![Value::Null, Value::Bool(true), Value::Uint(500)])), |
|
| 246 | + | (24, Value::Uint(u64::MAX)), |
|
| 247 | + | ]); |
|
| 248 | + | let b = encode(&v); |
|
| 249 | + | assert_eq!(decode(&b).unwrap(), v); |
|
| 250 | + | assert_eq!(encode(&Value::Uint(23)), [0x17]); |
|
| 251 | + | assert_eq!(encode(&Value::Uint(24)), [0x18, 24]); |
|
| 252 | + | assert_eq!(encode(&Value::Uint(1000)), [0x19, 0x03, 0xe8]); |
|
| 253 | + | assert_eq!(encode(&Value::Text("a".into())), [0x61, b'a']); |
|
| 254 | + | } |
|
| 255 | + | ||
| 256 | + | #[test] |
|
| 257 | + | fn refuses_what_is_not_canonical() { |
|
| 258 | + | assert!(decode(&[0x18, 5]).is_err(), "5 in two bytes"); |
|
| 259 | + | assert!(decode(&[0xa2, 0x01, 0x00, 0x00, 0x00]).is_err(), "keys out of order"); |
|
| 260 | + | assert!(decode(&[0x5f]).is_err(), "indefinite length"); |
|
| 261 | + | assert!(decode(&[0x01, 0x02]).is_err(), "trailing bytes"); |
|
| 262 | + | assert!(decode(&[0x5a, 0xff, 0xff, 0xff, 0xff]).is_err(), "truncated"); |
|
| 263 | + | assert!(decode(&[0x20]).is_err(), "negative"); |
|
| 264 | + | let deep = [0x81u8; 64]; |
|
| 265 | + | assert!(decode(&deep).is_err(), "too deep"); |
|
| 266 | + | } |
|
| 267 | + | } |
added crates/spectra-core/src/durable.rs +39 −0
| 1 | + | //! Writing files so a crash leaves the old content or the new, never half: |
|
| 2 | + | //! a temporary file in the same directory, flushed, then renamed over. |
|
| 3 | + | ||
| 4 | + | use std::io::Write; |
|
| 5 | + | use std::path::Path; |
|
| 6 | + | ||
| 7 | + | fn write(path: &Path, bytes: &[u8], private: bool) -> std::io::Result<()> { |
|
| 8 | + | let dir = path.parent().filter(|d| !d.as_os_str().is_empty()).unwrap_or(Path::new(".")); |
|
| 9 | + | std::fs::create_dir_all(dir)?; |
|
| 10 | + | let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("file"); |
|
| 11 | + | let tmp = dir.join(format!(".{name}.{}.tmp", std::process::id())); |
|
| 12 | + | let mut opts = std::fs::OpenOptions::new(); |
|
| 13 | + | opts.write(true).create(true).truncate(true); |
|
| 14 | + | #[cfg(unix)] |
|
| 15 | + | { |
|
| 16 | + | use std::os::unix::fs::OpenOptionsExt; |
|
| 17 | + | opts.mode(if private { 0o600 } else { 0o644 }); |
|
| 18 | + | } |
|
| 19 | + | #[cfg(not(unix))] |
|
| 20 | + | let _ = private; |
|
| 21 | + | let mut f = opts.open(&tmp)?; |
|
| 22 | + | f.write_all(bytes)?; |
|
| 23 | + | f.sync_all()?; |
|
| 24 | + | drop(f); |
|
| 25 | + | std::fs::rename(&tmp, path)?; |
|
| 26 | + | if let Ok(d) = std::fs::File::open(dir) { |
|
| 27 | + | let _ = d.sync_all(); |
|
| 28 | + | } |
|
| 29 | + | Ok(()) |
|
| 30 | + | } |
|
| 31 | + | ||
| 32 | + | pub fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> { |
|
| 33 | + | write(path, bytes, false) |
|
| 34 | + | } |
|
| 35 | + | ||
| 36 | + | /// As [`write_atomic`], readable by its owner only. |
|
| 37 | + | pub fn write_atomic_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> { |
|
| 38 | + | write(path, bytes, true) |
|
| 39 | + | } |
added crates/spectra-core/src/identity.rs +391 −0
| 1 | + | //! Spectra's identities and composite signatures (spec §8.0). |
|
| 2 | + | //! |
|
| 3 | + | //! An identity is a composite key pair, Ed25519 and ML-DSA-65, with a name, |
|
| 4 | + | //! the identity it succeeds and the keys it delegates to. Both halves sign |
|
| 5 | + | //! the same target and both must verify, so a signature stays sound while |
|
| 6 | + | //! either algorithm does. The design follows the lessons of Oxygen's |
|
| 7 | + | //! identities; the code, encoding and files are Spectra's own. |
|
| 8 | + | ||
| 9 | + | use crate::cbor::{self, Value}; |
|
| 10 | + | use ed25519_dalek::ed25519::signature::Signer as _; |
|
| 11 | + | use ed25519_dalek::{Signature as EdSignature, SigningKey as EdSigningKey, VerifyingKey as EdVerifyingKey}; |
|
| 12 | + | use ml_dsa::{ |
|
| 13 | + | EncodedSignature, EncodedVerifyingKey, MlDsa65, Seed, Signature as MlSignature, SigningKey as MlSigningKey, |
|
| 14 | + | VerifyingKey as MlVerifyingKey, |
|
| 15 | + | }; |
|
| 16 | + | use rand::RngCore; |
|
| 17 | + | ||
| 18 | + | const VERSION: u64 = 1; |
|
| 19 | + | ||
| 20 | + | /// An identity's name: `BLAKE3("spectra1 identity " || encoding)`. |
|
| 21 | + | #[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] |
|
| 22 | + | pub struct KeyId(pub [u8; 32]); |
|
| 23 | + | ||
| 24 | + | impl KeyId { |
|
| 25 | + | pub fn to_hex(&self) -> String { |
|
| 26 | + | hex::encode(self.0) |
|
| 27 | + | } |
|
| 28 | + | pub fn from_hex(s: &str) -> Option<KeyId> { |
|
| 29 | + | hex::decode(s.trim()).ok()?.try_into().ok().map(KeyId) |
|
| 30 | + | } |
|
| 31 | + | /// The first `n` hex digits, for display. |
|
| 32 | + | pub fn short(&self, n: usize) -> String { |
|
| 33 | + | let h = self.to_hex(); |
|
| 34 | + | h[..n.min(h.len())].to_string() |
|
| 35 | + | } |
|
| 36 | + | } |
|
| 37 | + | ||
| 38 | + | impl std::fmt::Debug for KeyId { |
|
| 39 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
|
| 40 | + | write!(f, "KeyId({})", self.short(12)) |
|
| 41 | + | } |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | impl std::fmt::Display for KeyId { |
|
| 45 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
|
| 46 | + | f.write_str(&self.to_hex()) |
|
| 47 | + | } |
|
| 48 | + | } |
|
| 49 | + | ||
| 50 | + | /// A key an identity lets act for it, until `expires` if set (ms). |
|
| 51 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 52 | + | pub struct Delegate { |
|
| 53 | + | pub key: KeyId, |
|
| 54 | + | pub expires: Option<u64>, |
|
| 55 | + | } |
|
| 56 | + | ||
| 57 | + | impl Delegate { |
|
| 58 | + | pub fn allows(&self, at_ms: u64) -> bool { |
|
| 59 | + | self.expires.is_none_or(|e| at_ms < e) |
|
| 60 | + | } |
|
| 61 | + | } |
|
| 62 | + | ||
| 63 | + | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 64 | + | pub struct Identity { |
|
| 65 | + | pub ed25519_pk: [u8; 32], |
|
| 66 | + | pub mldsa65_pk: Vec<u8>, |
|
| 67 | + | pub name: String, |
|
| 68 | + | pub contact: String, |
|
| 69 | + | pub created: u64, |
|
| 70 | + | pub expires: Option<u64>, |
|
| 71 | + | pub previous: Option<KeyId>, |
|
| 72 | + | pub delegates: Vec<Delegate>, |
|
| 73 | + | } |
|
| 74 | + | ||
| 75 | + | /// Why an identity, a secret or a signature could not be read or checked. |
|
| 76 | + | #[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)] |
|
| 77 | + | pub enum Error { |
|
| 78 | + | #[error(transparent)] |
|
| 79 | + | Cbor(#[from] cbor::Error), |
|
| 80 | + | #[error("{0}")] |
|
| 81 | + | Shape(String), |
|
| 82 | + | #[error("the signature is by another key")] |
|
| 83 | + | KeyMismatch, |
|
| 84 | + | #[error("a public key that does not decode")] |
|
| 85 | + | BadKey, |
|
| 86 | + | #[error("the Ed25519 half does not verify")] |
|
| 87 | + | Ed25519, |
|
| 88 | + | #[error("the ML-DSA-65 half does not verify")] |
|
| 89 | + | MlDsa, |
|
| 90 | + | } |
|
| 91 | + | ||
| 92 | + | fn shape(s: &str) -> Error { |
|
| 93 | + | Error::Shape(s.into()) |
|
| 94 | + | } |
|
| 95 | + | ||
| 96 | + | fn bytes_of(v: Option<&Value>, what: &str) -> Result<Vec<u8>, Error> { |
|
| 97 | + | v.and_then(Value::as_bytes).map(|b| b.to_vec()).ok_or_else(|| shape(what)) |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | fn key_of(v: Option<&Value>, what: &str) -> Result<KeyId, Error> { |
|
| 101 | + | bytes_of(v, what)?.try_into().map(KeyId).map_err(|_| shape(what)) |
|
| 102 | + | } |
|
| 103 | + | ||
| 104 | + | fn opt_uint(v: Option<&Value>, what: &str) -> Result<Option<u64>, Error> { |
|
| 105 | + | match v { |
|
| 106 | + | None | Some(Value::Null) => Ok(None), |
|
| 107 | + | Some(Value::Uint(u)) => Ok(Some(*u)), |
|
| 108 | + | _ => Err(shape(what)), |
|
| 109 | + | } |
|
| 110 | + | } |
|
| 111 | + | ||
| 112 | + | fn opt_val<T>(o: Option<T>, f: impl Fn(T) -> Value) -> Value { |
|
| 113 | + | o.map(f).unwrap_or(Value::Null) |
|
| 114 | + | } |
|
| 115 | + | ||
| 116 | + | impl Identity { |
|
| 117 | + | pub fn to_value(&self) -> Value { |
|
| 118 | + | Value::map([ |
|
| 119 | + | (0, Value::Uint(VERSION)), |
|
| 120 | + | (1, Value::Bytes(self.ed25519_pk.to_vec())), |
|
| 121 | + | (2, Value::Bytes(self.mldsa65_pk.clone())), |
|
| 122 | + | (3, Value::Text(self.name.clone())), |
|
| 123 | + | (4, Value::Text(self.contact.clone())), |
|
| 124 | + | (5, Value::Uint(self.created)), |
|
| 125 | + | (6, opt_val(self.expires, Value::Uint)), |
|
| 126 | + | (7, opt_val(self.previous, |k| Value::Bytes(k.0.to_vec()))), |
|
| 127 | + | ( |
|
| 128 | + | 8, |
|
| 129 | + | Value::Array( |
|
| 130 | + | self.delegates |
|
| 131 | + | .iter() |
|
| 132 | + | .map(|d| Value::map([(0, Value::Bytes(d.key.0.to_vec())), (1, opt_val(d.expires, Value::Uint))])) |
|
| 133 | + | .collect(), |
|
| 134 | + | ), |
|
| 135 | + | ), |
|
| 136 | + | ]) |
|
| 137 | + | } |
|
| 138 | + | ||
| 139 | + | pub fn encode(&self) -> Vec<u8> { |
|
| 140 | + | cbor::encode(&self.to_value()) |
|
| 141 | + | } |
|
| 142 | + | ||
| 143 | + | pub fn hash(&self) -> KeyId { |
|
| 144 | + | let mut h = blake3::Hasher::new(); |
|
| 145 | + | h.update(b"spectra1 identity "); |
|
| 146 | + | h.update(&self.encode()); |
|
| 147 | + | KeyId(*h.finalize().as_bytes()) |
|
| 148 | + | } |
|
| 149 | + | ||
| 150 | + | pub fn decode(b: &[u8]) -> Result<Identity, Error> { |
|
| 151 | + | let v = cbor::decode(b)?; |
|
| 152 | + | let m = v.as_map().ok_or_else(|| shape("an identity is a map"))?; |
|
| 153 | + | if m.get(&0).and_then(Value::as_uint) != Some(VERSION) { |
|
| 154 | + | return Err(shape("an identity of an unknown version")); |
|
| 155 | + | } |
|
| 156 | + | let text = |k: u64, what: &str| m.get(&k).and_then(Value::as_text).map(String::from).ok_or_else(|| shape(what)); |
|
| 157 | + | let delegates = m |
|
| 158 | + | .get(&8) |
|
| 159 | + | .and_then(Value::as_array) |
|
| 160 | + | .ok_or_else(|| shape("delegates"))? |
|
| 161 | + | .iter() |
|
| 162 | + | .map(|d| { |
|
| 163 | + | let dm = d.as_map().ok_or_else(|| shape("a delegate is a map"))?; |
|
| 164 | + | Ok(Delegate { key: key_of(dm.get(&0), "a delegate's key")?, expires: opt_uint(dm.get(&1), "a delegate's expiry")? }) |
|
| 165 | + | }) |
|
| 166 | + | .collect::<Result<Vec<_>, Error>>()?; |
|
| 167 | + | let id = Identity { |
|
| 168 | + | ed25519_pk: bytes_of(m.get(&1), "the Ed25519 key")?.try_into().map_err(|_| shape("the Ed25519 key"))?, |
|
| 169 | + | mldsa65_pk: bytes_of(m.get(&2), "the ML-DSA-65 key")?, |
|
| 170 | + | name: text(3, "the name")?, |
|
| 171 | + | contact: text(4, "the contact")?, |
|
| 172 | + | created: m.get(&5).and_then(Value::as_uint).ok_or_else(|| shape("the creation time"))?, |
|
| 173 | + | expires: opt_uint(m.get(&6), "the expiry")?, |
|
| 174 | + | previous: match m.get(&7) { |
|
| 175 | + | None | Some(Value::Null) => None, |
|
| 176 | + | v => Some(key_of(v, "the previous identity")?), |
|
| 177 | + | }, |
|
| 178 | + | delegates, |
|
| 179 | + | }; |
|
| 180 | + | // One identity, one encoding: what was read must be what we write. |
|
| 181 | + | if id.encode() != b { |
|
| 182 | + | return Err(shape("an identity not in Spectra's own encoding")); |
|
| 183 | + | } |
|
| 184 | + | Ok(id) |
|
| 185 | + | } |
|
| 186 | + | ||
| 187 | + | pub fn delegate(&self, key: &KeyId) -> Option<&Delegate> { |
|
| 188 | + | self.delegates.iter().find(|d| &d.key == key) |
|
| 189 | + | } |
|
| 190 | + | ||
| 191 | + | /// Whether this identity lets `key` act for it at `at_ms`. |
|
| 192 | + | pub fn delegates_to(&self, key: &KeyId, at_ms: u64) -> bool { |
|
| 193 | + | !self.is_expired_at(at_ms) && self.delegate(key).is_some_and(|d| d.allows(at_ms)) |
|
| 194 | + | } |
|
| 195 | + | ||
| 196 | + | pub fn is_expired_at(&self, at_ms: u64) -> bool { |
|
| 197 | + | self.expires.is_some_and(|e| at_ms >= e) |
|
| 198 | + | } |
|
| 199 | + | ||
| 200 | + | /// The next identity: the same key pair with these delegates. |
|
| 201 | + | pub fn successor(&self, delegates: Vec<Delegate>, expires: Option<u64>, created: u64) -> Identity { |
|
| 202 | + | Identity { delegates, expires, created, previous: Some(self.hash()), ..self.clone() } |
|
| 203 | + | } |
|
| 204 | + | ||
| 205 | + | fn ed_key(&self) -> Result<EdVerifyingKey, Error> { |
|
| 206 | + | EdVerifyingKey::from_bytes(&self.ed25519_pk).map_err(|_| Error::BadKey) |
|
| 207 | + | } |
|
| 208 | + | ||
| 209 | + | fn ml_key(&self) -> Result<MlVerifyingKey<MlDsa65>, Error> { |
|
| 210 | + | let enc = EncodedVerifyingKey::<MlDsa65>::try_from(self.mldsa65_pk.as_slice()).map_err(|_| Error::BadKey)?; |
|
| 211 | + | Ok(MlVerifyingKey::decode(&enc)) |
|
| 212 | + | } |
|
| 213 | + | } |
|
| 214 | + | ||
| 215 | + | /// What both halves sign: the signer's KeyId and the message, under |
|
| 216 | + | /// Spectra's prefix, so a signature means one thing by one key. |
|
| 217 | + | fn target(key: &KeyId, message: &[u8]) -> [u8; 32] { |
|
| 218 | + | let mut h = blake3::Hasher::new(); |
|
| 219 | + | h.update(b"spectra1 sign "); |
|
| 220 | + | h.update(&key.0); |
|
| 221 | + | h.update(message); |
|
| 222 | + | *h.finalize().as_bytes() |
|
| 223 | + | } |
|
| 224 | + | ||
| 225 | + | #[derive(Clone, PartialEq, Eq)] |
|
| 226 | + | pub struct Signature { |
|
| 227 | + | pub key: KeyId, |
|
| 228 | + | pub ed: [u8; 64], |
|
| 229 | + | pub ml: Vec<u8>, |
|
| 230 | + | } |
|
| 231 | + | ||
| 232 | + | impl std::fmt::Debug for Signature { |
|
| 233 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
|
| 234 | + | write!(f, "Signature({:?})", self.key) |
|
| 235 | + | } |
|
| 236 | + | } |
|
| 237 | + | ||
| 238 | + | impl Signature { |
|
| 239 | + | pub fn to_value(&self) -> Value { |
|
| 240 | + | Value::map([(0, Value::Bytes(self.key.0.to_vec())), (1, Value::Bytes(self.ed.to_vec())), (2, Value::Bytes(self.ml.clone()))]) |
|
| 241 | + | } |
|
| 242 | + | ||
| 243 | + | pub fn from_value(v: &Value) -> Result<Signature, Error> { |
|
| 244 | + | let m = v.as_map().ok_or_else(|| shape("a signature is a map"))?; |
|
| 245 | + | Ok(Signature { |
|
| 246 | + | key: key_of(m.get(&0), "the signature's key")?, |
|
| 247 | + | ed: bytes_of(m.get(&1), "the Ed25519 signature")?.try_into().map_err(|_| shape("the Ed25519 signature"))?, |
|
| 248 | + | ml: bytes_of(m.get(&2), "the ML-DSA-65 signature")?, |
|
| 249 | + | }) |
|
| 250 | + | } |
|
| 251 | + | ||
| 252 | + | pub fn encode(&self) -> Vec<u8> { |
|
| 253 | + | cbor::encode(&self.to_value()) |
|
| 254 | + | } |
|
| 255 | + | ||
| 256 | + | pub fn decode(b: &[u8]) -> Result<Signature, Error> { |
|
| 257 | + | Signature::from_value(&cbor::decode(b)?) |
|
| 258 | + | } |
|
| 259 | + | } |
|
| 260 | + | ||
| 261 | + | /// Check a signature by `identity` over `message`. |
|
| 262 | + | pub fn verify(identity: &Identity, sig: &Signature, message: &[u8]) -> Result<(), Error> { |
|
| 263 | + | let key = identity.hash(); |
|
| 264 | + | if sig.key != key { |
|
| 265 | + | return Err(Error::KeyMismatch); |
|
| 266 | + | } |
|
| 267 | + | let t = target(&key, message); |
|
| 268 | + | identity.ed_key()?.verify_strict(&t, &EdSignature::from_bytes(&sig.ed)).map_err(|_| Error::Ed25519)?; |
|
| 269 | + | let enc = EncodedSignature::<MlDsa65>::try_from(sig.ml.as_slice()).map_err(|_| Error::MlDsa)?; |
|
| 270 | + | let ml = MlSignature::<MlDsa65>::decode(&enc).ok_or(Error::MlDsa)?; |
|
| 271 | + | if !identity.ml_key()?.verify_with_context(&t, b"", &ml) { |
|
| 272 | + | return Err(Error::MlDsa); |
|
| 273 | + | } |
|
| 274 | + | Ok(()) |
|
| 275 | + | } |
|
| 276 | + | ||
| 277 | + | /// The seeds of a key pair; stored only in its identity home, 0600. |
|
| 278 | + | #[derive(Clone)] |
|
| 279 | + | pub struct SecretKey { |
|
| 280 | + | ed_seed: [u8; 32], |
|
| 281 | + | ml_seed: [u8; 32], |
|
| 282 | + | } |
|
| 283 | + | ||
| 284 | + | impl std::fmt::Debug for SecretKey { |
|
| 285 | + | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
|
| 286 | + | f.write_str("SecretKey(..)") |
|
| 287 | + | } |
|
| 288 | + | } |
|
| 289 | + | ||
| 290 | + | impl SecretKey { |
|
| 291 | + | pub fn generate() -> SecretKey { |
|
| 292 | + | let (mut ed_seed, mut ml_seed) = ([0u8; 32], [0u8; 32]); |
|
| 293 | + | rand::rngs::OsRng.fill_bytes(&mut ed_seed); |
|
| 294 | + | rand::rngs::OsRng.fill_bytes(&mut ml_seed); |
|
| 295 | + | SecretKey { ed_seed, ml_seed } |
|
| 296 | + | } |
|
| 297 | + | ||
| 298 | + | fn ed(&self) -> EdSigningKey { |
|
| 299 | + | EdSigningKey::from_bytes(&self.ed_seed) |
|
| 300 | + | } |
|
| 301 | + | ||
| 302 | + | fn ml(&self) -> MlSigningKey<MlDsa65> { |
|
| 303 | + | MlSigningKey::from_seed(&Seed::from(self.ml_seed)) |
|
| 304 | + | } |
|
| 305 | + | ||
| 306 | + | /// The identity of this key pair, delegating to nobody. |
|
| 307 | + | pub fn identity(&self, name: &str, contact: &str, created: u64) -> Identity { |
|
| 308 | + | Identity { |
|
| 309 | + | ed25519_pk: self.ed().verifying_key().to_bytes(), |
|
| 310 | + | mldsa65_pk: self.ml().expanded_key().verifying_key().encode().as_slice().to_vec(), |
|
| 311 | + | name: name.into(), |
|
| 312 | + | contact: contact.into(), |
|
| 313 | + | created, |
|
| 314 | + | expires: None, |
|
| 315 | + | previous: None, |
|
| 316 | + | delegates: vec![], |
|
| 317 | + | } |
|
| 318 | + | } |
|
| 319 | + | ||
| 320 | + | /// Sign `message` as the identity named `key` (this key pair's). |
|
| 321 | + | pub fn sign(&self, key: &KeyId, message: &[u8]) -> Signature { |
|
| 322 | + | let t = target(key, message); |
|
| 323 | + | let ed = self.ed().sign(&t).to_bytes(); |
|
| 324 | + | let ml = self.ml().expanded_key().sign_deterministic(&t, b"").expect("an empty context is valid").encode().as_slice().to_vec(); |
|
| 325 | + | Signature { key: *key, ed, ml } |
|
| 326 | + | } |
|
| 327 | + | ||
| 328 | + | /// Whether this is the key pair of `identity`. |
|
| 329 | + | pub fn matches(&self, identity: &Identity) -> bool { |
|
| 330 | + | let mine = self.identity("", "", 0); |
|
| 331 | + | mine.ed25519_pk == identity.ed25519_pk && mine.mldsa65_pk == identity.mldsa65_pk |
|
| 332 | + | } |
|
| 333 | + | ||
| 334 | + | pub fn encode(&self, key: &KeyId) -> Vec<u8> { |
|
| 335 | + | cbor::encode(&Value::map([ |
|
| 336 | + | (0, Value::Uint(VERSION)), |
|
| 337 | + | (1, Value::Bytes(key.0.to_vec())), |
|
| 338 | + | (2, Value::Bytes(self.ed_seed.to_vec())), |
|
| 339 | + | (3, Value::Bytes(self.ml_seed.to_vec())), |
|
| 340 | + | ])) |
|
| 341 | + | } |
|
| 342 | + | ||
| 343 | + | pub fn decode(b: &[u8]) -> Result<(KeyId, SecretKey), Error> { |
|
| 344 | + | let v = cbor::decode(b)?; |
|
| 345 | + | let m = v.as_map().ok_or_else(|| shape("a secret key is a map"))?; |
|
| 346 | + | if m.get(&0).and_then(Value::as_uint) != Some(VERSION) { |
|
| 347 | + | return Err(shape("a secret key of an unknown version")); |
|
| 348 | + | } |
|
| 349 | + | let seed = |k: u64| -> Result<[u8; 32], Error> { bytes_of(m.get(&k), "a seed")?.try_into().map_err(|_| shape("a seed")) }; |
|
| 350 | + | Ok((key_of(m.get(&1), "the secret's key")?, SecretKey { ed_seed: seed(2)?, ml_seed: seed(3)? })) |
|
| 351 | + | } |
|
| 352 | + | } |
|
| 353 | + | ||
| 354 | + | #[cfg(test)] |
|
| 355 | + | mod tests { |
|
| 356 | + | use super::*; |
|
| 357 | + | ||
| 358 | + | #[test] |
|
| 359 | + | fn a_signature_binds_the_key_and_the_message_and_needs_both_halves() { |
|
| 360 | + | let sk = SecretKey::generate(); |
|
| 361 | + | let id = sk.identity("Andy", "a@example", 1); |
|
| 362 | + | let key = id.hash(); |
|
| 363 | + | let sig = sk.sign(&key, b"spectra1 test hello"); |
|
| 364 | + | verify(&id, &sig, b"spectra1 test hello").unwrap(); |
|
| 365 | + | assert_eq!(verify(&id, &sig, b"spectra1 test other"), Err(Error::Ed25519)); |
|
| 366 | + | let other = SecretKey::generate().identity("B", "", 1); |
|
| 367 | + | assert_eq!(verify(&other, &sig, b"spectra1 test hello"), Err(Error::KeyMismatch)); |
|
| 368 | + | let mut half = sig.clone(); |
|
| 369 | + | half.ml[0] ^= 1; |
|
| 370 | + | assert!(verify(&id, &half, b"spectra1 test hello").is_err(), "a broken ML-DSA half fails"); |
|
| 371 | + | assert_eq!(Signature::decode(&sig.encode()).unwrap(), sig); |
|
| 372 | + | } |
|
| 373 | + | ||
| 374 | + | #[test] |
|
| 375 | + | fn identities_round_trip_and_successors_chain() { |
|
| 376 | + | let sk = SecretKey::generate(); |
|
| 377 | + | let id = sk.identity("Andy", "", 5); |
|
| 378 | + | assert_eq!(Identity::decode(&id.encode()).unwrap(), id); |
|
| 379 | + | let agent = SecretKey::generate().identity("claude", "", 5).hash(); |
|
| 380 | + | let next = id.successor(vec![Delegate { key: agent, expires: Some(100) }], None, 6); |
|
| 381 | + | assert_eq!(next.previous, Some(id.hash())); |
|
| 382 | + | assert_ne!(next.hash(), id.hash()); |
|
| 383 | + | assert!(next.delegates_to(&agent, 99)); |
|
| 384 | + | assert!(!next.delegates_to(&agent, 100), "expired"); |
|
| 385 | + | assert!(!id.delegates_to(&agent, 1)); |
|
| 386 | + | assert!(sk.matches(&next)); |
|
| 387 | + | let (k, back) = SecretKey::decode(&sk.encode(&next.hash())).unwrap(); |
|
| 388 | + | assert_eq!(k, next.hash()); |
|
| 389 | + | assert!(back.matches(&id)); |
|
| 390 | + | } |
|
| 391 | + | } |
removed .oxymodules +0 −0
| … | 3 unchanged lines |
removed deps +0 −0
No line changes.
removed deps/oxygen +0 −0
No line changes.
removed src/cli/json.rs +0 −0
| … | 25 unchanged lines |
modified CHANGELOG.md +3 −0
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | ## Unreleased |
|
| 4 | 4 | ||
| 5 | + | - **Changed: Spectra stands alone.** It no longer uses Oxygen in any way: no `oxy` command, no Oxygen library, no Oxygen files. Identities, composite Ed25519 + ML-DSA-65 signatures and deterministic CBOR are Spectra's own. `spectra identity create` makes your identity in `~/.config/spectra` (`SPECTRA_HOME`); `spectra init` delegates to the machine's instance key itself; `spectra agent add` shows the agent and asks before delegating to it (`--yes` for scripts, `--expires 8h` for an overnight agent), `spectra agent renew` gives it more time and `agent disable` takes the delegation back; `spectra connect` shows the server's instance key and asks you to confirm it (`--expect` checks it instead). Ledgers and identity homes made before this change are not readable: run `spectra identity create` and `spectra init` again. |
|
| 6 | + | - **Changed: a project is any kind of work.** `spectra project add NAME [--path DIR] [-d TEXT] [--url URL]`, or `spectra project add FOLDER`; Spectra records the folder and reads nothing in it. `spectra project link P --depends-on Q` draws the links `to: downstream` follows, where they used to be read from `.oxymodules`. |
|
| 7 | + | - **Fixed: `spectra inbox --follow` could miss a message posted just as it started.** It now subscribes first and reads the inbox once the server says the subscription holds. |
|
| 5 | 8 | - **Fixed: `agent add` printed a shim line without `--home` when Spectra is on another machine,** where the shim needs it. |
|
| 6 | 9 | - **Added: `make install-server HOST=user@host` (#25).** Builds a release, copies a stripped binary and the systemd user unit over SSH, and enables the service. The unit now waits until a Spectra exists instead of failing in a loop. `make install` copies the binary rather than linking into the build tree, so cleaning a build directory no longer breaks an installed service. The README describes setting up the daemon on another machine. |
|
| 7 | 10 | - **Added: Radar from another machine (#24).** With a server configured, `spectra open` serves Radar on `http://127.0.0.1:7350` (`--port` to change it) through a proxy to the pinned server, and signs you in through it; pages, forms and the live stream all pass through. The proxy answers only its own loopback host and origin, so no other site can reach the server through it. Ctrl-C stops it. |
|
| … | 19 unchanged lines |
modified Cargo.lock +18 −415
| … | 2 unchanged lines |
||
| 3 | 3 | version = 4 |
|
| 4 | 4 | ||
| 5 | 5 | [[package]] |
|
| 6 | - | name = "adler2" |
|
| 7 | - | version = "2.0.1" |
|
| 8 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 9 | - | checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" |
|
| 10 | - | ||
| 11 | - | [[package]] |
|
| 12 | 6 | name = "aho-corasick" |
|
| 13 | 7 | version = "1.1.5" |
|
| 14 | 8 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 136 unchanged lines |
||
| 151 | 145 | "nom", |
|
| 152 | 146 | "num-traits", |
|
| 153 | 147 | "rusticata-macros", |
|
| 154 | - | "thiserror 2.0.21", |
|
| 148 | + | "thiserror", |
|
| 155 | 149 | "time", |
|
| 156 | 150 | ] |
|
| 157 | 151 | ||
| … | 130 unchanged lines |
||
| 288 | 282 | checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a" |
|
| 289 | 283 | dependencies = [ |
|
| 290 | 284 | "serde", |
|
| 291 | - | ] |
|
| 292 | - | ||
| 293 | - | [[package]] |
|
| 294 | - | name = "bit-set" |
|
| 295 | - | version = "0.8.0" |
|
| 296 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 297 | - | checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" |
|
| 298 | - | dependencies = [ |
|
| 299 | - | "bit-vec 0.8.0", |
|
| 300 | 285 | ] |
|
| 301 | - | ||
| 302 | - | [[package]] |
|
| 303 | - | name = "bit-vec" |
|
| 304 | - | version = "0.8.0" |
|
| 305 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 306 | - | checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" |
|
| 307 | 286 | ||
| 308 | 287 | [[package]] |
|
| 309 | 288 | name = "bit-vec" |
|
| … | 24 unchanged lines |
||
| 334 | 313 | "cfg-if", |
|
| 335 | 314 | "constant_time_eq", |
|
| 336 | 315 | "cpufeatures 0.3.1", |
|
| 337 | - | "rayon-core", |
|
| 338 | 316 | ] |
|
| 339 | 317 | ||
| 340 | 318 | [[package]] |
|
| … | 12 unchanged lines |
||
| 353 | 331 | checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" |
|
| 354 | 332 | dependencies = [ |
|
| 355 | 333 | "hybrid-array", |
|
| 356 | - | ] |
|
| 357 | - | ||
| 358 | - | [[package]] |
|
| 359 | - | name = "bstr" |
|
| 360 | - | version = "1.13.1" |
|
| 361 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 362 | - | checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" |
|
| 363 | - | dependencies = [ |
|
| 364 | - | "memchr", |
|
| 365 | - | "serde_core", |
|
| 366 | 334 | ] |
|
| 367 | 335 | ||
| 368 | 336 | [[package]] |
|
| … | 21 unchanged lines |
||
| 390 | 358 | checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" |
|
| 391 | 359 | dependencies = [ |
|
| 392 | 360 | "find-msvc-tools", |
|
| 393 | - | "jobserver", |
|
| 394 | - | "libc", |
|
| 395 | 361 | "shlex", |
|
| 396 | 362 | ] |
|
| 397 | 363 | ||
| … | 118 unchanged lines |
||
| 516 | 482 | version = "2.5.0" |
|
| 517 | 483 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 518 | 484 | checksum = "217698eaf96b4a3f0bc4f3662aaa55bdf913cd54d7204591faa790070c6d0853" |
|
| 519 | - | ||
| 520 | - | [[package]] |
|
| 521 | - | name = "crc32fast" |
|
| 522 | - | version = "1.5.2" |
|
| 523 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 524 | - | checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" |
|
| 525 | - | dependencies = [ |
|
| 526 | - | "cfg-if", |
|
| 527 | - | ] |
|
| 528 | - | ||
| 529 | - | [[package]] |
|
| 530 | - | name = "crossbeam-deque" |
|
| 531 | - | version = "0.8.8" |
|
| 532 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 533 | - | checksum = "622f3fc73690be383c7214310406f28a90e6edeadc3cea882f9d71e495b9711a" |
|
| 534 | - | dependencies = [ |
|
| 535 | - | "crossbeam-epoch", |
|
| 536 | - | "crossbeam-utils", |
|
| 537 | - | ] |
|
| 538 | - | ||
| 539 | - | [[package]] |
|
| 540 | - | name = "crossbeam-epoch" |
|
| 541 | - | version = "0.9.21" |
|
| 542 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 543 | - | checksum = "dc74980687109a3b14c72fd458107bf0baa1da1a1a805e178d15501ba9b86d9d" |
|
| 544 | - | dependencies = [ |
|
| 545 | - | "crossbeam-utils", |
|
| 546 | - | ] |
|
| 547 | 485 | ||
| 548 | 486 | [[package]] |
|
| 549 | 487 | name = "crossbeam-queue" |
|
| … | 144 unchanged lines |
||
| 694 | 632 | checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" |
|
| 695 | 633 | dependencies = [ |
|
| 696 | 634 | "block-buffer 0.12.1", |
|
| 697 | - | "const-oid 0.10.2", |
|
| 698 | 635 | "crypto-common 0.2.2", |
|
| 699 | 636 | ] |
|
| 700 | 637 | ||
| … | 126 unchanged lines |
||
| 827 | 764 | version = "0.1.14" |
|
| 828 | 765 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 829 | 766 | checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" |
|
| 830 | - | ||
| 831 | - | [[package]] |
|
| 832 | - | name = "flate2" |
|
| 833 | - | version = "1.1.10" |
|
| 834 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 835 | - | checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" |
|
| 836 | - | dependencies = [ |
|
| 837 | - | "crc32fast", |
|
| 838 | - | "miniz_oxide", |
|
| 839 | - | "zlib-rs", |
|
| 840 | - | ] |
|
| 841 | 767 | ||
| 842 | 768 | [[package]] |
|
| 843 | 769 | name = "flume" |
|
| … | 5 unchanged lines |
||
| 849 | 775 | "futures-sink", |
|
| 850 | 776 | "spin", |
|
| 851 | 777 | ] |
|
| 852 | - | ||
| 853 | - | [[package]] |
|
| 854 | - | name = "fnv" |
|
| 855 | - | version = "1.0.7" |
|
| 856 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 857 | - | checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" |
|
| 858 | 778 | ||
| 859 | 779 | [[package]] |
|
| 860 | 780 | name = "foldhash" |
|
| … | 152 unchanged lines |
||
| 1013 | 933 | "libc", |
|
| 1014 | 934 | "r-efi 6.0.0", |
|
| 1015 | 935 | "rand_core 0.10.1", |
|
| 1016 | - | ] |
|
| 1017 | - | ||
| 1018 | - | [[package]] |
|
| 1019 | - | name = "globset" |
|
| 1020 | - | version = "0.4.20" |
|
| 1021 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1022 | - | checksum = "07c34a9410465b45bd9787443bc7370f37735bad04b0f0cd57ff1a3186c98988" |
|
| 1023 | - | dependencies = [ |
|
| 1024 | - | "aho-corasick", |
|
| 1025 | - | "bstr", |
|
| 1026 | - | "log", |
|
| 1027 | - | "regex-automata", |
|
| 1028 | - | "regex-syntax", |
|
| 1029 | 936 | ] |
|
| 1030 | 937 | ||
| 1031 | 938 | [[package]] |
|
| … | 268 unchanged lines |
||
| 1300 | 1207 | ] |
|
| 1301 | 1208 | ||
| 1302 | 1209 | [[package]] |
|
| 1303 | - | name = "ignore" |
|
| 1304 | - | version = "0.4.33" |
|
| 1305 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1306 | - | checksum = "00b69833ed729dc5aa7d19541d96d6cf8e9137194207a04916d658e43168402f" |
|
| 1307 | - | dependencies = [ |
|
| 1308 | - | "crossbeam-deque", |
|
| 1309 | - | "globset", |
|
| 1310 | - | "log", |
|
| 1311 | - | "memchr", |
|
| 1312 | - | "regex-automata", |
|
| 1313 | - | "same-file", |
|
| 1314 | - | "walkdir", |
|
| 1315 | - | "winapi-util", |
|
| 1316 | - | ] |
|
| 1317 | - | ||
| 1318 | - | [[package]] |
|
| 1319 | 1210 | name = "indexmap" |
|
| 1320 | 1211 | version = "2.14.2" |
|
| 1321 | 1212 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 27 unchanged lines |
||
| 1349 | 1240 | version = "1.0.18" |
|
| 1350 | 1241 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1351 | 1242 | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" |
|
| 1352 | - | ||
| 1353 | - | [[package]] |
|
| 1354 | - | name = "jobserver" |
|
| 1355 | - | version = "0.1.35" |
|
| 1356 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1357 | - | checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" |
|
| 1358 | - | dependencies = [ |
|
| 1359 | - | "getrandom 0.4.3", |
|
| 1360 | - | "libc", |
|
| 1361 | - | ] |
|
| 1362 | 1243 | ||
| 1363 | 1244 | [[package]] |
|
| 1364 | 1245 | name = "js-sys" |
|
| … | 164 unchanged lines |
||
| 1529 | 1410 | version = "0.2.1" |
|
| 1530 | 1411 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1531 | 1412 | checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" |
|
| 1532 | - | ||
| 1533 | - | [[package]] |
|
| 1534 | - | name = "miniz_oxide" |
|
| 1535 | - | version = "0.9.1" |
|
| 1536 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1537 | - | checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" |
|
| 1538 | - | dependencies = [ |
|
| 1539 | - | "adler2", |
|
| 1540 | - | "simd-adler32", |
|
| 1541 | - | ] |
|
| 1542 | 1413 | ||
| 1543 | 1414 | [[package]] |
|
| 1544 | 1415 | name = "mio" |
|
| … | 79 unchanged lines |
||
| 1624 | 1495 | "num-integer", |
|
| 1625 | 1496 | "num-iter", |
|
| 1626 | 1497 | "num-traits", |
|
| 1627 | - | "rand 0.8.8", |
|
| 1498 | + | "rand", |
|
| 1628 | 1499 | "smallvec", |
|
| 1629 | 1500 | "zeroize", |
|
| 1630 | 1501 | ] |
|
| … | 61 unchanged lines |
||
| 1692 | 1563 | checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" |
|
| 1693 | 1564 | ||
| 1694 | 1565 | [[package]] |
|
| 1695 | - | name = "oxy-core" |
|
| 1696 | - | version = "0.1.0" |
|
| 1697 | - | dependencies = [ |
|
| 1698 | - | "blake3", |
|
| 1699 | - | "ed25519-dalek", |
|
| 1700 | - | "globset", |
|
| 1701 | - | "hex", |
|
| 1702 | - | "ignore", |
|
| 1703 | - | "libc", |
|
| 1704 | - | "memchr", |
|
| 1705 | - | "ml-dsa", |
|
| 1706 | - | "proptest", |
|
| 1707 | - | "rand 0.8.8", |
|
| 1708 | - | "rayon", |
|
| 1709 | - | "rustls", |
|
| 1710 | - | "rustls-pemfile", |
|
| 1711 | - | "serde_json", |
|
| 1712 | - | "sha2 0.11.0", |
|
| 1713 | - | "similar", |
|
| 1714 | - | "tempfile", |
|
| 1715 | - | "thiserror 1.0.69", |
|
| 1716 | - | "ureq", |
|
| 1717 | - | "webpki-roots 1.0.9", |
|
| 1718 | - | "zstd", |
|
| 1719 | - | ] |
|
| 1720 | - | ||
| 1721 | - | [[package]] |
|
| 1722 | 1566 | name = "parking" |
|
| 1723 | 1567 | version = "2.2.1" |
|
| 1724 | 1568 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 178 unchanged lines |
||
| 1903 | 1747 | checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" |
|
| 1904 | 1748 | dependencies = [ |
|
| 1905 | 1749 | "unicode-ident", |
|
| 1906 | - | ] |
|
| 1907 | - | ||
| 1908 | - | [[package]] |
|
| 1909 | - | name = "proptest" |
|
| 1910 | - | version = "1.11.0" |
|
| 1911 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1912 | - | checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" |
|
| 1913 | - | dependencies = [ |
|
| 1914 | - | "bit-set", |
|
| 1915 | - | "bit-vec 0.8.0", |
|
| 1916 | - | "bitflags", |
|
| 1917 | - | "num-traits", |
|
| 1918 | - | "rand 0.9.5", |
|
| 1919 | - | "rand_chacha 0.9.0", |
|
| 1920 | - | "rand_xorshift", |
|
| 1921 | - | "regex-syntax", |
|
| 1922 | - | "rusty-fork", |
|
| 1923 | - | "tempfile", |
|
| 1924 | - | "unarray", |
|
| 1925 | 1750 | ] |
|
| 1926 | 1751 | ||
| 1927 | 1752 | [[package]] |
|
| … | 13 unchanged lines |
||
| 1941 | 1766 | version = "0.11.0" |
|
| 1942 | 1767 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1943 | 1768 | checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" |
|
| 1944 | - | ||
| 1945 | - | [[package]] |
|
| 1946 | - | name = "quick-error" |
|
| 1947 | - | version = "1.2.3" |
|
| 1948 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 1949 | - | checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" |
|
| 1950 | 1769 | ||
| 1951 | 1770 | [[package]] |
|
| 1952 | 1771 | name = "quote" |
|
| … | 26 unchanged lines |
||
| 1979 | 1798 | "axum", |
|
| 1980 | 1799 | "futures", |
|
| 1981 | 1800 | "hex", |
|
| 1982 | - | "oxy-core", |
|
| 1983 | 1801 | "pulldown-cmark", |
|
| 1984 | - | "rand 0.8.8", |
|
| 1802 | + | "rand", |
|
| 1985 | 1803 | "serde", |
|
| 1986 | 1804 | "serde_json", |
|
| 1987 | 1805 | "spectra-core", |
|
| … | 11 unchanged lines |
||
| 1999 | 1817 | checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" |
|
| 2000 | 1818 | dependencies = [ |
|
| 2001 | 1819 | "libc", |
|
| 2002 | - | "rand_chacha 0.3.1", |
|
| 1820 | + | "rand_chacha", |
|
| 2003 | 1821 | "rand_core 0.6.4", |
|
| 2004 | 1822 | ] |
|
| 2005 | 1823 | ||
| 2006 | 1824 | [[package]] |
|
| 2007 | - | name = "rand" |
|
| 2008 | - | version = "0.9.5" |
|
| 2009 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2010 | - | checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" |
|
| 2011 | - | dependencies = [ |
|
| 2012 | - | "rand_chacha 0.9.0", |
|
| 2013 | - | "rand_core 0.9.5", |
|
| 2014 | - | ] |
|
| 2015 | - | ||
| 2016 | - | [[package]] |
|
| 2017 | 1825 | name = "rand_chacha" |
|
| 2018 | 1826 | version = "0.3.1" |
|
| 2019 | 1827 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 4 unchanged lines |
||
| 2024 | 1832 | ] |
|
| 2025 | 1833 | ||
| 2026 | 1834 | [[package]] |
|
| 2027 | - | name = "rand_chacha" |
|
| 2028 | - | version = "0.9.0" |
|
| 2029 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2030 | - | checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" |
|
| 2031 | - | dependencies = [ |
|
| 2032 | - | "ppv-lite86", |
|
| 2033 | - | "rand_core 0.9.5", |
|
| 2034 | - | ] |
|
| 2035 | - | ||
| 2036 | - | [[package]] |
|
| 2037 | 1835 | name = "rand_core" |
|
| 2038 | 1836 | version = "0.6.4" |
|
| 2039 | 1837 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2040 | 1838 | checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" |
|
| 2041 | 1839 | dependencies = [ |
|
| 2042 | 1840 | "getrandom 0.2.17", |
|
| 2043 | - | ] |
|
| 2044 | - | ||
| 2045 | - | [[package]] |
|
| 2046 | - | name = "rand_core" |
|
| 2047 | - | version = "0.9.5" |
|
| 2048 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2049 | - | checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" |
|
| 2050 | - | dependencies = [ |
|
| 2051 | - | "getrandom 0.3.4", |
|
| 2052 | 1841 | ] |
|
| 2053 | 1842 | ||
| 2054 | 1843 | [[package]] |
|
| … | 1 unchanged lines |
||
| 2056 | 1845 | version = "0.10.1" |
|
| 2057 | 1846 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2058 | 1847 | checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" |
|
| 2059 | - | ||
| 2060 | - | [[package]] |
|
| 2061 | - | name = "rand_xorshift" |
|
| 2062 | - | version = "0.4.0" |
|
| 2063 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2064 | - | checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" |
|
| 2065 | - | dependencies = [ |
|
| 2066 | - | "rand_core 0.9.5", |
|
| 2067 | - | ] |
|
| 2068 | - | ||
| 2069 | - | [[package]] |
|
| 2070 | - | name = "rayon" |
|
| 2071 | - | version = "1.12.0" |
|
| 2072 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2073 | - | checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" |
|
| 2074 | - | dependencies = [ |
|
| 2075 | - | "either", |
|
| 2076 | - | "rayon-core", |
|
| 2077 | - | ] |
|
| 2078 | - | ||
| 2079 | - | [[package]] |
|
| 2080 | - | name = "rayon-core" |
|
| 2081 | - | version = "1.13.0" |
|
| 2082 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2083 | - | checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" |
|
| 2084 | - | dependencies = [ |
|
| 2085 | - | "crossbeam-deque", |
|
| 2086 | - | "crossbeam-utils", |
|
| 2087 | - | ] |
|
| 2088 | 1848 | ||
| 2089 | 1849 | [[package]] |
|
| 2090 | 1850 | name = "rcgen" |
|
| … | 141 unchanged lines |
||
| 2232 | 1992 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2233 | 1993 | checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" |
|
| 2234 | 1994 | dependencies = [ |
|
| 2235 | - | "log", |
|
| 2236 | 1995 | "once_cell", |
|
| 2237 | 1996 | "ring", |
|
| 2238 | 1997 | "rustls-pki-types", |
|
| … | 36 unchanged lines |
||
| 2275 | 2034 | version = "1.0.23" |
|
| 2276 | 2035 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2277 | 2036 | checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" |
|
| 2278 | - | ||
| 2279 | - | [[package]] |
|
| 2280 | - | name = "rusty-fork" |
|
| 2281 | - | version = "0.3.1" |
|
| 2282 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2283 | - | checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" |
|
| 2284 | - | dependencies = [ |
|
| 2285 | - | "fnv", |
|
| 2286 | - | "quick-error", |
|
| 2287 | - | "tempfile", |
|
| 2288 | - | "wait-timeout", |
|
| 2289 | - | ] |
|
| 2290 | 2037 | ||
| 2291 | 2038 | [[package]] |
|
| 2292 | 2039 | name = "ryu" |
|
| 2293 | 2040 | version = "1.0.23" |
|
| 2294 | 2041 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2295 | 2042 | checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" |
|
| 2296 | - | ||
| 2297 | - | [[package]] |
|
| 2298 | - | name = "same-file" |
|
| 2299 | - | version = "1.0.6" |
|
| 2300 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2301 | - | checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" |
|
| 2302 | - | dependencies = [ |
|
| 2303 | - | "winapi-util", |
|
| 2304 | - | ] |
|
| 2305 | 2043 | ||
| 2306 | 2044 | [[package]] |
|
| 2307 | 2045 | name = "schemars" |
|
| … | 206 unchanged lines |
||
| 2514 | 2252 | "digest 0.11.3", |
|
| 2515 | 2253 | "rand_core 0.10.1", |
|
| 2516 | 2254 | ] |
|
| 2517 | - | ||
| 2518 | - | [[package]] |
|
| 2519 | - | name = "simd-adler32" |
|
| 2520 | - | version = "0.3.10" |
|
| 2521 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2522 | - | checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" |
|
| 2523 | - | ||
| 2524 | - | [[package]] |
|
| 2525 | - | name = "similar" |
|
| 2526 | - | version = "2.7.0" |
|
| 2527 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2528 | - | checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" |
|
| 2529 | 2255 | ||
| 2530 | 2256 | [[package]] |
|
| 2531 | 2257 | name = "siphasher" |
|
| … | 38 unchanged lines |
||
| 2570 | 2296 | "indicatif", |
|
| 2571 | 2297 | "libc", |
|
| 2572 | 2298 | "mimalloc", |
|
| 2573 | - | "oxy-core", |
|
| 2574 | 2299 | "radar", |
|
| 2575 | 2300 | "serde_json", |
|
| 2576 | 2301 | "spectra-api", |
|
| … | 22 unchanged lines |
||
| 2599 | 2324 | dependencies = [ |
|
| 2600 | 2325 | "anyhow", |
|
| 2601 | 2326 | "blake3", |
|
| 2327 | + | "ed25519-dalek", |
|
| 2602 | 2328 | "hex", |
|
| 2603 | - | "oxy-core", |
|
| 2604 | - | "rand 0.8.8", |
|
| 2329 | + | "ml-dsa", |
|
| 2330 | + | "rand", |
|
| 2605 | 2331 | "serde", |
|
| 2606 | 2332 | "serde_json", |
|
| 2607 | 2333 | "sqlx", |
|
| 2608 | 2334 | "tempfile", |
|
| 2609 | - | "thiserror 2.0.21", |
|
| 2335 | + | "thiserror", |
|
| 2610 | 2336 | "tokio", |
|
| 2611 | 2337 | "toml", |
|
| 2612 | 2338 | "tracing", |
|
| … | 10 unchanged lines |
||
| 2623 | 2349 | "http-body-util", |
|
| 2624 | 2350 | "hyper", |
|
| 2625 | 2351 | "hyper-util", |
|
| 2626 | - | "oxy-core", |
|
| 2627 | - | "rand 0.8.8", |
|
| 2352 | + | "rand", |
|
| 2628 | 2353 | "rcgen", |
|
| 2629 | 2354 | "ring", |
|
| 2630 | 2355 | "rustls", |
|
| … | 86 unchanged lines |
||
| 2717 | 2442 | "serde_json", |
|
| 2718 | 2443 | "sha2 0.10.9", |
|
| 2719 | 2444 | "smallvec", |
|
| 2720 | - | "thiserror 2.0.21", |
|
| 2445 | + | "thiserror", |
|
| 2721 | 2446 | "tokio", |
|
| 2722 | 2447 | "tokio-stream", |
|
| 2723 | 2448 | "tracing", |
|
| … | 65 unchanged lines |
||
| 2789 | 2514 | "memchr", |
|
| 2790 | 2515 | "once_cell", |
|
| 2791 | 2516 | "percent-encoding", |
|
| 2792 | - | "rand 0.8.8", |
|
| 2517 | + | "rand", |
|
| 2793 | 2518 | "rsa", |
|
| 2794 | 2519 | "sha1", |
|
| 2795 | 2520 | "sha2 0.10.9", |
|
| 2796 | 2521 | "smallvec", |
|
| 2797 | 2522 | "sqlx-core", |
|
| 2798 | 2523 | "stringprep", |
|
| 2799 | - | "thiserror 2.0.21", |
|
| 2524 | + | "thiserror", |
|
| 2800 | 2525 | "tracing", |
|
| 2801 | 2526 | "whoami", |
|
| 2802 | 2527 | ] |
|
| … | 23 unchanged lines |
||
| 2826 | 2551 | "md-5", |
|
| 2827 | 2552 | "memchr", |
|
| 2828 | 2553 | "once_cell", |
|
| 2829 | - | "rand 0.8.8", |
|
| 2554 | + | "rand", |
|
| 2830 | 2555 | "serde", |
|
| 2831 | 2556 | "serde_json", |
|
| 2832 | 2557 | "sha2 0.10.9", |
|
| 2833 | 2558 | "smallvec", |
|
| 2834 | 2559 | "sqlx-core", |
|
| 2835 | 2560 | "stringprep", |
|
| 2836 | - | "thiserror 2.0.21", |
|
| 2561 | + | "thiserror", |
|
| 2837 | 2562 | "tracing", |
|
| 2838 | 2563 | "whoami", |
|
| 2839 | 2564 | ] |
|
| … | 17 unchanged lines |
||
| 2857 | 2582 | "serde", |
|
| 2858 | 2583 | "serde_urlencoded", |
|
| 2859 | 2584 | "sqlx-core", |
|
| 2860 | - | "thiserror 2.0.21", |
|
| 2585 | + | "thiserror", |
|
| 2861 | 2586 | "tracing", |
|
| 2862 | 2587 | "url", |
|
| 2863 | 2588 | ] |
|
| … | 121 unchanged lines |
||
| 2985 | 2710 | checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" |
|
| 2986 | 2711 | dependencies = [ |
|
| 2987 | 2712 | "new_debug_unreachable", |
|
| 2988 | - | ] |
|
| 2989 | - | ||
| 2990 | - | [[package]] |
|
| 2991 | - | name = "thiserror" |
|
| 2992 | - | version = "1.0.69" |
|
| 2993 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 2994 | - | checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" |
|
| 2995 | - | dependencies = [ |
|
| 2996 | - | "thiserror-impl 1.0.69", |
|
| 2997 | 2713 | ] |
|
| 2998 | 2714 | ||
| 2999 | 2715 | [[package]] |
|
| … | 1 unchanged lines |
||
| 3001 | 2717 | version = "2.0.21" |
|
| 3002 | 2718 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3003 | 2719 | checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" |
|
| 3004 | - | dependencies = [ |
|
| 3005 | - | "thiserror-impl 2.0.21", |
|
| 3006 | - | ] |
|
| 3007 | - | ||
| 3008 | - | [[package]] |
|
| 3009 | - | name = "thiserror-impl" |
|
| 3010 | - | version = "1.0.69" |
|
| 3011 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3012 | - | checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" |
|
| 3013 | 2720 | dependencies = [ |
|
| 3014 | - | "proc-macro2", |
|
| 3015 | - | "quote", |
|
| 3016 | - | "syn 2.0.119", |
|
| 2721 | + | "thiserror-impl", |
|
| 3017 | 2722 | ] |
|
| 3018 | 2723 | ||
| 3019 | 2724 | [[package]] |
|
| … | 268 unchanged lines |
||
| 3288 | 2993 | checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" |
|
| 3289 | 2994 | ||
| 3290 | 2995 | [[package]] |
|
| 3291 | - | name = "unarray" |
|
| 3292 | - | version = "0.1.4" |
|
| 3293 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3294 | - | checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" |
|
| 3295 | - | ||
| 3296 | - | [[package]] |
|
| 3297 | 2996 | name = "unicase" |
|
| 3298 | 2997 | version = "2.10.0" |
|
| 3299 | 2998 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 37 unchanged lines |
||
| 3337 | 3036 | version = "0.9.0" |
|
| 3338 | 3037 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3339 | 3038 | checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" |
|
| 3340 | - | ||
| 3341 | - | [[package]] |
|
| 3342 | - | name = "ureq" |
|
| 3343 | - | version = "2.12.1" |
|
| 3344 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3345 | - | checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" |
|
| 3346 | - | dependencies = [ |
|
| 3347 | - | "base64 0.22.1", |
|
| 3348 | - | "flate2", |
|
| 3349 | - | "log", |
|
| 3350 | - | "once_cell", |
|
| 3351 | - | "rustls", |
|
| 3352 | - | "rustls-pki-types", |
|
| 3353 | - | "url", |
|
| 3354 | - | "webpki-roots 0.26.11", |
|
| 3355 | - | ] |
|
| 3356 | 3039 | ||
| 3357 | 3040 | [[package]] |
|
| 3358 | 3041 | name = "url" |
|
| … | 36 unchanged lines |
||
| 3395 | 3078 | version = "0.9.5" |
|
| 3396 | 3079 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3397 | 3080 | checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" |
|
| 3398 | - | ||
| 3399 | - | [[package]] |
|
| 3400 | - | name = "wait-timeout" |
|
| 3401 | - | version = "0.2.1" |
|
| 3402 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3403 | - | checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" |
|
| 3404 | - | dependencies = [ |
|
| 3405 | - | "libc", |
|
| 3406 | - | ] |
|
| 3407 | - | ||
| 3408 | - | [[package]] |
|
| 3409 | - | name = "walkdir" |
|
| 3410 | - | version = "2.5.0" |
|
| 3411 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3412 | - | checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" |
|
| 3413 | - | dependencies = [ |
|
| 3414 | - | "same-file", |
|
| 3415 | - | "winapi-util", |
|
| 3416 | - | ] |
|
| 3417 | 3081 | ||
| 3418 | 3082 | [[package]] |
|
| 3419 | 3083 | name = "want" |
|
| … | 90 unchanged lines |
||
| 3510 | 3174 | "phf_codegen", |
|
| 3511 | 3175 | "string_cache", |
|
| 3512 | 3176 | "string_cache_codegen", |
|
| 3513 | - | ] |
|
| 3514 | - | ||
| 3515 | - | [[package]] |
|
| 3516 | - | name = "webpki-roots" |
|
| 3517 | - | version = "0.26.11" |
|
| 3518 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3519 | - | checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" |
|
| 3520 | - | dependencies = [ |
|
| 3521 | - | "webpki-roots 1.0.9", |
|
| 3522 | 3177 | ] |
|
| 3523 | 3178 | ||
| 3524 | 3179 | [[package]] |
|
| 3525 | - | name = "webpki-roots" |
|
| 3526 | - | version = "1.0.9" |
|
| 3527 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3528 | - | checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" |
|
| 3529 | - | dependencies = [ |
|
| 3530 | - | "rustls-pki-types", |
|
| 3531 | - | ] |
|
| 3532 | - | ||
| 3533 | - | [[package]] |
|
| 3534 | 3180 | name = "whoami" |
|
| 3535 | 3181 | version = "1.6.1" |
|
| 3536 | 3182 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| … | 1 unchanged lines |
||
| 3538 | 3184 | dependencies = [ |
|
| 3539 | 3185 | "libredox", |
|
| 3540 | 3186 | "wasite", |
|
| 3541 | - | ] |
|
| 3542 | - | ||
| 3543 | - | [[package]] |
|
| 3544 | - | name = "winapi-util" |
|
| 3545 | - | version = "0.1.11" |
|
| 3546 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3547 | - | checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" |
|
| 3548 | - | dependencies = [ |
|
| 3549 | - | "windows-sys 0.48.0", |
|
| 3550 | 3187 | ] |
|
| 3551 | 3188 | ||
| 3552 | 3189 | [[package]] |
|
| … | 194 unchanged lines |
||
| 3747 | 3384 | "oid-registry", |
|
| 3748 | 3385 | "ring", |
|
| 3749 | 3386 | "rusticata-macros", |
|
| 3750 | - | "thiserror 2.0.21", |
|
| 3387 | + | "thiserror", |
|
| 3751 | 3388 | "time", |
|
| 3752 | 3389 | ] |
|
| 3753 | 3390 | ||
| … | 3 unchanged lines |
||
| 3757 | 3394 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3758 | 3395 | checksum = "b5f6765e852b9b4dc8e2a76843e4d64d1cea8e79bcde0b6901aea8e7c7f08282" |
|
| 3759 | 3396 | dependencies = [ |
|
| 3760 | - | "bit-vec 0.9.1", |
|
| 3397 | + | "bit-vec", |
|
| 3761 | 3398 | "time", |
|
| 3762 | 3399 | ] |
|
| 3763 | 3400 | ||
| … | 99 unchanged lines |
||
| 3863 | 3500 | "quote", |
|
| 3864 | 3501 | "syn 3.0.6", |
|
| 3865 | 3502 | ] |
|
| 3866 | - | ||
| 3867 | - | [[package]] |
|
| 3868 | - | name = "zlib-rs" |
|
| 3869 | - | version = "0.6.8" |
|
| 3870 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3871 | - | checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" |
|
| 3872 | 3503 | ||
| 3873 | 3504 | [[package]] |
|
| 3874 | 3505 | name = "zmij" |
|
| 3875 | 3506 | version = "1.0.23" |
|
| 3876 | 3507 | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3877 | 3508 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" |
|
| 3878 | - | ||
| 3879 | - | [[package]] |
|
| 3880 | - | name = "zstd" |
|
| 3881 | - | version = "0.13.3" |
|
| 3882 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3883 | - | checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" |
|
| 3884 | - | dependencies = [ |
|
| 3885 | - | "zstd-safe", |
|
| 3886 | - | ] |
|
| 3887 | - | ||
| 3888 | - | [[package]] |
|
| 3889 | - | name = "zstd-safe" |
|
| 3890 | - | version = "7.3.0" |
|
| 3891 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3892 | - | checksum = "64d80649ab6db9d9f6f9c80a40becd948eda4714a0a5ac8c4d157a32231c7882" |
|
| 3893 | - | dependencies = [ |
|
| 3894 | - | "zstd-sys", |
|
| 3895 | - | ] |
|
| 3896 | - | ||
| 3897 | - | [[package]] |
|
| 3898 | - | name = "zstd-sys" |
|
| 3899 | - | version = "2.1.0+zstd.1.5.7" |
|
| 3900 | - | source = "registry+https://github.com/rust-lang/crates.io-index" |
|
| 3901 | - | checksum = "0ef0a8027ec3ee71300ab3bcbcd0393f434aa72b91ca6d635a39941deae8eea0" |
|
| 3902 | - | dependencies = [ |
|
| 3903 | - | "cc", |
|
| 3904 | - | "pkg-config", |
|
| 3905 | - | ] |
modified Cargo.toml +0 −8
| 1 | 1 | [workspace] |
|
| 2 | 2 | members = ["crates/spectra-core", "crates/spectra-mcp", "crates/spectra-api", "crates/radar", "."] |
|
| 3 | - | # The pinned Oxygen (deps/oxygen) is a workspace of its own. |
|
| 4 | - | exclude = ["deps"] |
|
| 5 | 3 | resolver = "2" |
|
| 6 | 4 | ||
| 7 | 5 | [workspace.package] |
|
| … | 2 unchanged lines |
||
| 10 | 8 | license = "MIT OR Apache-2.0" |
|
| 11 | 9 | ||
| 12 | 10 | [workspace.dependencies] |
|
| 13 | - | # Identities, delegation, composite signatures, deterministic CBOR and |
|
| 14 | - | # hashing are Oxygen's: Spectra links them from the pinned subrepo and never |
|
| 15 | - | # reimplements them (spec §7.3, §9.5). |
|
| 16 | - | oxy-core = { path = "deps/oxygen/crates/oxy-core" } |
|
| 17 | 11 | spectra-core = { path = "crates/spectra-core" } |
|
| 18 | 12 | spectra-mcp = { path = "crates/spectra-mcp" } |
|
| 19 | 13 | spectra-api = { path = "crates/spectra-api" } |
|
| … | 42 unchanged lines |
||
| 62 | 56 | spectra-mcp.workspace = true |
|
| 63 | 57 | spectra-api.workspace = true |
|
| 64 | 58 | radar.workspace = true |
|
| 65 | - | oxy-core.workspace = true |
|
| 66 | 59 | anyhow.workspace = true |
|
| 67 | 60 | clap.workspace = true |
|
| 68 | 61 | serde_json.workspace = true |
|
| … | 11 unchanged lines |
||
| 80 | 73 | ||
| 81 | 74 | [dev-dependencies] |
|
| 82 | 75 | tempfile.workspace = true |
|
| 83 | - | oxy-core.workspace = true |
|
| 84 | 76 | spectra-mcp.workspace = true |
|
| 85 | 77 | sqlx.workspace = true |
|
| 86 | 78 | serde_json.workspace = true |
|
| … | 3 unchanged lines |
modified README.md +24 −31
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | [](https://oxygenhub.io/andymccall/spectra/flow) |
|
| 4 | 4 | ||
| 5 | - | Spectra is a control plane for AI coding agents, written in Rust. Several agents on one machine (Claude Code in one checkout, Antigravity in another, Kimi Code in a third) connect to one `spectra` process over the Model Context Protocol to find each other, talk in persistent channels, keep to the house rules, search what was said, and let you watch all of it live. See [docs/spec.md](docs/spec.md) for the specification. |
|
| 5 | + | Spectra is a control plane for AI agents, written in Rust. Several agents (Claude Code on one piece of work, Antigravity on another, Kimi Code on a third) connect to one `spectra` daemon over the Model Context Protocol to find each other, talk in persistent channels, keep to the house rules, search what was said, and let you watch all of it live. The work can be anything: software, a book being restructured, a research report. Spectra needs no other tool and reads nothing inside your work. See [docs/spec.md](docs/spec.md) for the specification. |
|
| 6 | 6 | ||
| 7 | - | Every agent is its owner's Oxygen delegate: its key is listed in your identity with a scope and an expiry, so "claude-oxygen · Andy McCall's agent" is a fact Spectra checks, not a label. Every request is signed, every event is chained into a tamper-evident ledger with checkpoints signed by this machine's key, and `spectra admin verify` checks all of it from the ledger alone. |
|
| 7 | + | Every agent is somebody's: its key is a delegate of its owner's Spectra identity, with an expiry if you set one, so "claude-oxygen · Andy McCall's agent" is a fact Spectra checks, not a label. Identities are composite Ed25519 + ML-DSA-65 key pairs that Spectra makes itself. Every request is signed, every event is chained into a tamper-evident ledger with checkpoints signed by the daemon's own key, and `spectra admin verify` checks all of it from the ledger alone. |
|
| 8 | 8 | ||
| 9 | - | The hosted, multi-machine layer is [SpectraHub](../spectrahub); the editor integrations are [spectra-vscode](../spectra-vscode) and [spectrahub-vscode](../spectrahub-vscode). The split mirrors Oxygen and OxygenHub. |
|
| 9 | + | The hosted, multi-person layer is [SpectraHub](../spectrahub); the editor integrations are [spectra-vscode](../spectra-vscode) and [spectrahub-vscode](../spectrahub-vscode). |
|
| 10 | 10 | ||
| 11 | 11 | ## Status |
|
| 12 | 12 | ||
| 13 | - | 0.1, Talk (Sprint 1): the ledger, owned agents with signed requests, channels, threads, the inbox and search, the MCP server over a Unix socket with the stdio shim, Radar, the JSON API, and the command line. Tasks, handoffs and the passkey are 0.2; see the [board](https://oxygenhub.io/projects/andymccall/7). |
|
| 13 | + | 0.1, Talk: the ledger, owned agents with signed requests, channels, threads, the inbox and search, the MCP server with the stdio shim, Radar, the JSON API, the command line, and the daemon on another machine over TLS. Tasks, handoffs and the passkey are 0.2; see the [board](https://oxygenhub.io/projects/andymccall/7). |
|
| 14 | 14 | ||
| 15 | 15 | ## Building |
|
| 16 | 16 | ||
| … | 10 unchanged lines |
||
| 27 | 27 | ||
| 28 | 28 | ## Quick start |
|
| 29 | 29 | ||
| 30 | - | You need an Oxygen identity of your own (`oxy identity create`). Then: |
|
| 30 | + | On one machine: |
|
| 31 | 31 | ||
| 32 | 32 | ``` |
|
| 33 | - | spectra init # records your identity, makes this machine's key |
|
| 34 | - | oxy identity delegate add ~/.local/share/spectra/keys/instance.identity --scope changes |
|
| 35 | - | spectra serve # or: cp packaging/spectra.service ~/.config/systemd/user/ |
|
| 36 | - | spectra project add ~/development/oxygen |
|
| 37 | - | spectra agent add claude-oxygen --kind claude-code --project oxygen --home ~/.oxy-agents/claude-oxygen |
|
| 33 | + | spectra identity create --name "Your Name" # your own key pair, in ~/.config/spectra |
|
| 34 | + | spectra init # this machine's Spectra, owned by you |
|
| 35 | + | spectra serve # or: make install, then the systemd user unit |
|
| 36 | + | spectra project add ~/Documents/book -d "the manuscript" |
|
| 37 | + | spectra agent add claude-book --kind claude-code --project book --home ~/.spectra-agents/claude-book --expires 8h |
|
| 38 | 38 | ``` |
|
| 39 | 39 | ||
| 40 | - | `agent add` prints the one command that makes the agent yours and waits for it: |
|
| 40 | + | `agent add` shows you the agent and its key and asks before letting it act for you; `--expires` makes an overnight agent stop by itself, and `spectra agent renew` gives it more time. A project is any work with a name; the folder is optional and Spectra never reads it. `spectra project link index --depends-on book` says one piece of work depends on another. |
|
| 41 | 41 | ||
| 42 | - | ``` |
|
| 43 | - | oxy identity delegate add ~/.oxy-agents/claude-oxygen/keys/<KEY>.identity --scope changes --expires 8h |
|
| 44 | - | ``` |
|
| 45 | - | ||
| 46 | 42 | Connect the agent's client with one line and nothing secret in it: |
|
| 47 | 43 | ||
| 48 | 44 | ``` |
|
| 49 | - | claude mcp add spectra -- spectra mcp --agent claude-oxygen # Claude Code |
|
| 50 | - | kimi mcp add spectra -- spectra mcp --agent kimi-oxygenflow # Kimi Code |
|
| 45 | + | claude mcp add spectra -- spectra mcp --agent claude-book # Claude Code |
|
| 46 | + | kimi mcp add spectra -- spectra mcp --agent kimi-index # Kimi Code |
|
| 51 | 47 | ``` |
|
| 52 | 48 | ||
| 53 | 49 | Antigravity, in `~/.gemini/config/mcp_config.json`: |
|
| 54 | 50 | ||
| 55 | 51 | ```json |
|
| 56 | - | { "mcpServers": { "spectra": { "command": "spectra", "args": ["mcp", "--agent", "antigravity-oxygenhub"] } } } |
|
| 52 | + | { "mcpServers": { "spectra": { "command": "spectra", "args": ["mcp", "--agent", "antigravity-index"] } } } |
|
| 57 | 53 | ``` |
|
| 58 | 54 | ||
| 59 | - | Open Radar with `spectra open`. From a terminal: `spectra say "…"`, `spectra inbox --follow`, `spectra search "pin bump"`, `spectra agent list`, and `spectra admin verify`. |
|
| 55 | + | Open Radar with `spectra open`. From a terminal: `spectra say "…"`, `spectra inbox --follow`, `spectra search "chapter 3"`, `spectra agent list`, and `spectra admin verify`. |
|
| 60 | 56 | ||
| 61 | 57 | ## The daemon on another machine |
|
| 62 | 58 | ||
| 63 | - | Spectra can run on a server while you, your keys and your agents stay on your workstation (spec §8.6). Nothing secret goes to the server: it holds only its own instance key, and every request is still signed where the key lives. Traffic is TLS with a certificate the server makes itself; your workstation trusts it because your own identity delegates to the server's instance key, which vouches for the certificate. No certificate authority is involved. |
|
| 59 | + | Spectra can run on a server while you, your keys and your agents stay on your workstation (spec §8.6). Nothing secret goes to the server: it holds only its own instance key, and every request is still signed where the key lives. Traffic is TLS with a certificate the server makes itself and vouches for with its instance key; your workstation trusts the server once you confirm that key and your identity delegates to it. No certificate authority is involved. |
|
| 64 | 60 | ||
| 65 | - | On the workstation, install the server and note your KeyId: |
|
| 61 | + | On the workstation: |
|
| 66 | 62 | ||
| 67 | 63 | ``` |
|
| 64 | + | spectra identity create --name "Your Name" # if you have not already; note the key it prints |
|
| 68 | 65 | make install-server HOST=claude@earth |
|
| 69 | - | oxy identity show # the 64-hex key |
|
| 70 | 66 | ``` |
|
| 71 | 67 | ||
| 72 | 68 | On the server: |
|
| 73 | 69 | ||
| 74 | 70 | ``` |
|
| 75 | - | spectra init --owner KEYID --name earth # no key of yours is needed there |
|
| 71 | + | spectra init --owner KEYID --name earth # your key's id only: no secret of yours goes there |
|
| 76 | 72 | # in ~/.local/share/spectra/spectra.toml, under [server]: |
|
| 77 | 73 | # bind = "[::]:7350" |
|
| 78 | 74 | # names = ["earth", "earth.example"] |
|
| 79 | 75 | systemctl --user start spectra |
|
| 80 | 76 | ``` |
|
| 81 | 77 | ||
| 82 | - | On the workstation: |
|
| 78 | + | `init` prints the server's instance key. On the workstation: |
|
| 83 | 79 | ||
| 84 | 80 | ``` |
|
| 85 | - | spectra connect https://earth.example:7350 # prints the delegation to run, the first time |
|
| 86 | - | oxy identity delegate add ~/.config/spectra/earth-instance.identity --scope changes |
|
| 87 | - | spectra connect https://earth.example:7350 # trusted, pinned, and claimed as yours |
|
| 81 | + | spectra connect https://earth.example:7350 # shows the instance key; confirm it matches |
|
| 88 | 82 | ``` |
|
| 89 | 83 | ||
| 90 | - | From then on every command on the workstation works against the server, with no ledger file locally. Agents name their home, since it is on the workstation: `spectra mcp --agent claude-oxygen --home ~/.oxy-agents/claude-oxygen`. `spectra open` serves Radar on `http://127.0.0.1:7350` through a proxy to the server. |
|
| 84 | + | From then on every command on the workstation works against the server, with no ledger file locally. Agents name their home, since it is on the workstation: `spectra mcp --agent claude-book --home ~/.spectra-agents/claude-book`. `spectra open` serves Radar on `http://127.0.0.1:7350` through a proxy to the server. |
|
| 91 | 85 | ||
| 92 | - | Output uses colour and glyphs on a UTF-8 terminal, like `oxy`'s, and falls back to plain ASCII when piped, with `--plain`, or with `NO_COLOR`. |
|
| 86 | + | Output uses colour and glyphs on a UTF-8 terminal and falls back to plain ASCII when piped, with `--plain`, or with `NO_COLOR`. |
|
| 93 | 87 | ||
| 94 | 88 | ## Layout |
|
| 95 | 89 | ||
| 96 | 90 | | Path | Contents | |
|
| 97 | 91 | |---|---| |
|
| 98 | - | | `crates/spectra-core` | The model: the ledger, identities and principals, conversation, presence, rules, the daemon's state | |
|
| 92 | + | | `crates/spectra-core` | The model: the ledger, Spectra's own identities, signatures and CBOR, principals, conversation, presence, rules, the daemon's state | |
|
| 99 | 93 | | `crates/spectra-mcp` | Tuner (MCP over the Unix socket and loopback), the JSON API, the signing client and the browser sessions | |
|
| 100 | 94 | | `crates/spectra-api` | The JSON API's types; `make ts` writes `ts/index.d.ts` for the extensions | |
|
| 101 | 95 | | `crates/radar` | Radar, the owner's web pages, with its templates and assets | |
|
| 102 | 96 | | `src/cli` | The `spectra` command line | |
|
| 103 | - | | `deps/oxygen` | Oxygen, pinned: identities, signatures, CBOR and hashing come from `oxy-core` | |
|
| 104 | 97 | | `docs/spec.md` | The specification | |
|
| 105 | 98 | | `docs/plan.md` | The sprint plan and its log | |
|
| 106 | 99 | | `packaging/spectra.service` | The systemd user unit | |
|
| … | 4 unchanged lines |
modified crates/radar/Cargo.toml +0 −1
| … | 7 unchanged lines |
||
| 8 | 8 | [dependencies] |
|
| 9 | 9 | spectra-core.workspace = true |
|
| 10 | 10 | spectra-mcp.workspace = true |
|
| 11 | - | oxy-core.workspace = true |
|
| 12 | 11 | anyhow.workspace = true |
|
| 13 | 12 | axum.workspace = true |
|
| 14 | 13 | askama.workspace = true |
|
| … | 11 unchanged lines |
modified crates/radar/src/lib.rs +3 −3
| … | 122 unchanged lines |
||
| 123 | 123 | .principals |
|
| 124 | 124 | .read() |
|
| 125 | 125 | .await |
|
| 126 | - | .principal(&oxy_core_key(session.key), now) |
|
| 126 | + | .principal(&key_id(session.key), now) |
|
| 127 | 127 | .map_err(|why| signin_page(&format!("This session no longer belongs to the owner: {why}."), StatusCode::UNAUTHORIZED))?; |
|
| 128 | 128 | if !matches!(principal, Principal::Owner { .. }) { |
|
| 129 | 129 | return Err(signin_page("Radar shows Spectra to its owner only.", StatusCode::FORBIDDEN)); |
|
| … | 2 unchanged lines |
||
| 132 | 132 | Ok(Owner { session, principal, shell }) |
|
| 133 | 133 | } |
|
| 134 | 134 | ||
| 135 | - | fn oxy_core_key(k: [u8; 32]) -> oxy_core::hash::KeyId { |
|
| 136 | - | oxy_core::hash::ObjectRef(k) |
|
| 135 | + | fn key_id(k: [u8; 32]) -> spectra_core::identity::KeyId { |
|
| 136 | + | spectra_core::identity::KeyId(k) |
|
| 137 | 137 | } |
|
| 138 | 138 | ||
| 139 | 139 | #[derive(Template)] |
|
| … | 500 unchanged lines |
modified crates/spectra-api/src/lib.rs +6 −2
| … | 50 unchanged lines |
||
| 51 | 51 | #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] |
|
| 52 | 52 | pub struct Project { |
|
| 53 | 53 | pub name: String, |
|
| 54 | - | pub path: String, |
|
| 55 | - | pub remote: Option<String>, |
|
| 54 | + | /// A folder, if the work has one; Spectra never reads it. |
|
| 55 | + | pub path: Option<String>, |
|
| 56 | + | #[serde(default)] |
|
| 57 | + | pub description: String, |
|
| 58 | + | /// A link to the work, if any. |
|
| 59 | + | pub url: Option<String>, |
|
| 56 | 60 | pub depends_on: Vec<String>, |
|
| 57 | 61 | pub depended_on_by: Vec<String>, |
|
| 58 | 62 | /// Its agents that are not disabled. |
|
| … | 151 unchanged lines |
modified crates/spectra-api/ts/index.d.ts +3 −2
| … | 31 unchanged lines |
||
| 32 | 32 | agents?: string[]; |
|
| 33 | 33 | depended_on_by: string[]; |
|
| 34 | 34 | depends_on: string[]; |
|
| 35 | + | description?: string; |
|
| 35 | 36 | name: string; |
|
| 36 | - | path: string; |
|
| 37 | - | remote?: string | null; |
|
| 37 | + | path?: string | null; |
|
| 38 | + | url?: string | null; |
|
| 38 | 39 | }; |
|
| 39 | 40 | ||
| 40 | 41 | export type Channel = { |
|
| … | 94 unchanged lines |
modified crates/spectra-core/Cargo.toml +3 −1
| … | 5 unchanged lines |
||
| 6 | 6 | description = "Spectra's model: the ledger, projections, principals and the bus" |
|
| 7 | 7 | ||
| 8 | 8 | [dependencies] |
|
| 9 | - | oxy-core.workspace = true |
|
| 10 | 9 | anyhow.workspace = true |
|
| 11 | 10 | thiserror.workspace = true |
|
| 12 | 11 | serde.workspace = true |
|
| … | 5 unchanged lines |
||
| 18 | 17 | hex.workspace = true |
|
| 19 | 18 | tracing.workspace = true |
|
| 20 | 19 | toml.workspace = true |
|
| 20 | + | # Spectra's own identities (spec §8.0): composite Ed25519 + ML-DSA-65. |
|
| 21 | + | ed25519-dalek = "3" |
|
| 22 | + | ml-dsa = "0.1" |
|
| 21 | 23 | ||
| 22 | 24 | [dev-dependencies] |
|
| 23 | 25 | tempfile.workspace = true |
|
| … | 1 unchanged lines |
modified crates/spectra-core/migrations/0003_projects_presence.sql +6 −7
| 1 | - | -- Projects and how they depend on one another (spec §23.1), and agents' |
|
| 2 | - | -- presence and sessions (spec §8.1, §8.2). |
|
| 1 | + | -- Projects, any kind of work, and the links the owner draws between them |
|
| 2 | + | -- (spec §23.1); agents' presence and sessions (spec §8.1, §8.2). |
|
| 3 | 3 | CREATE TABLE projects ( |
|
| 4 | 4 | name TEXT PRIMARY KEY, |
|
| 5 | - | path TEXT NOT NULL, |
|
| 6 | - | repo_id TEXT, |
|
| 7 | - | remote TEXT, |
|
| 8 | - | modules TEXT NOT NULL, -- JSON array of the URLs in .oxymodules |
|
| 9 | - | seq INTEGER NOT NULL |
|
| 5 | + | path TEXT, -- a folder, if the work has one; never read |
|
| 6 | + | description TEXT NOT NULL, |
|
| 7 | + | url TEXT, -- a link to it, if any |
|
| 8 | + | seq INTEGER NOT NULL |
|
| 10 | 9 | ); |
|
| 11 | 10 | ||
| 12 | 11 | CREATE TABLE project_relations ( |
|
| … | 22 unchanged lines |
modified crates/spectra-core/src/attachments.rs +1 −1
| … | 11 unchanged lines |
||
| 12 | 12 | let path = dir.join(&hash); |
|
| 13 | 13 | if !path.exists() { |
|
| 14 | 14 | std::fs::create_dir_all(&dir)?; |
|
| 15 | - | oxy_core::durable::write_atomic(&path, bytes)?; |
|
| 15 | + | crate::durable::write_atomic(&path, bytes)?; |
|
| 16 | 16 | } |
|
| 17 | 17 | Ok(hash) |
|
| 18 | 18 | } |
|
| … | 8 unchanged lines |
modified crates/spectra-core/src/authn.rs +1 −1
| … | 67 unchanged lines |
||
| 68 | 68 | if skew.unsigned_abs() > WINDOW_MS { |
|
| 69 | 69 | return Err(Refusal::Stale(skew / 1000)); |
|
| 70 | 70 | } |
|
| 71 | - | let key = oxy_core::hash::ObjectRef(proof.key()); |
|
| 71 | + | let key = crate::identity::KeyId(proof.key()); |
|
| 72 | 72 | let principal = principals.principal(&key, now_ms).map_err(Refusal::NotAPrincipal)?; |
|
| 73 | 73 | let identity = principals.identity(&key).ok_or_else(|| Refusal::NotAPrincipal("an unknown key".into()))?; |
|
| 74 | 74 | proof.verify(identity, Some(body)).map_err(Refusal::BadSignature)?; |
|
| … | 23 unchanged lines |
modified crates/spectra-core/src/daemon.rs +3 −2
| … | 17 unchanged lines |
||
| 18 | 18 | ||
| 19 | 19 | /// Kinds the owner's CLI may hand the daemon, already signed over their |
|
| 20 | 20 | /// payload (spec §8.5, "One writer"). |
|
| 21 | - | pub const OWNER_KINDS: &[&str] = &["identity.rotated", "agent.added", "agent.disabled", "project.added"]; |
|
| 21 | + | pub const OWNER_KINDS: &[&str] = |
|
| 22 | + | &["identity.rotated", "agent.added", "agent.disabled", "project.added", "project.linked", "project.unlinked"]; |
|
| 22 | 23 | ||
| 23 | 24 | pub struct Daemon { |
|
| 24 | 25 | pub data: PathBuf, |
|
| … | 155 unchanged lines |
||
| 180 | 181 | let probe = probe_event(&n); |
|
| 181 | 182 | principals.check(&probe).map_err(|why| anyhow::anyhow!("refused {}: {why}", n.kind))?; |
|
| 182 | 183 | if n.kind != "identity.rotated" { |
|
| 183 | - | let key = oxy_core::hash::ObjectRef(n.auth.key.context("unsigned")?); |
|
| 184 | + | let key = crate::identity::KeyId(n.auth.key.context("unsigned")?); |
|
| 184 | 185 | if !matches!(principals.principal(&key, crate::ids::now_ms()), Ok(Principal::Owner { .. })) { |
|
| 185 | 186 | bail!("refused {}: not signed by the owner", n.kind); |
|
| 186 | 187 | } |
|
| … | 129 unchanged lines |
modified crates/spectra-core/src/event.rs +1 −1
| … | 8 unchanged lines |
||
| 9 | 9 | //! The envelope is frozen from the first release: fields may be added at new |
|
| 10 | 10 | //! keys, never changed or removed, so a hub merges logs from any version. |
|
| 11 | 11 | ||
| 12 | + | use crate::cbor::{self, Value}; |
|
| 12 | 13 | use crate::hlc::Hlc; |
|
| 13 | - | use oxy_core::cbor::{self, Value}; |
|
| 14 | 14 | ||
| 15 | 15 | /// The envelope's version, key 0 of the body. |
|
| 16 | 16 | pub const ENVELOPE_VERSION: u64 = 1; |
|
| … | 276 unchanged lines |
modified crates/spectra-core/src/ids.rs +1 −1
| … | 28 unchanged lines |
||
| 29 | 29 | let secs = (ms / 1000) as i64; |
|
| 30 | 30 | let days = secs.div_euclid(86_400); |
|
| 31 | 31 | let rem = secs.rem_euclid(86_400); |
|
| 32 | - | // Civil from days (Howard Hinnant), as oxy's `format::when`. |
|
| 32 | + | // Civil from days (Howard Hinnant). |
|
| 33 | 33 | let z = days + 719_468; |
|
| 34 | 34 | let era = z.div_euclid(146_097); |
|
| 35 | 35 | let doe = z - era * 146_097; |
|
| … | 24 unchanged lines |
modified crates/spectra-core/src/keys.rs +119 −51
| 1 | - | //! Keys and identities on disk, laid out exactly as `oxy` lays them out |
|
| 2 | - | //! (spec §8.1, §21.3): an `OXY_HOME` holds `keys/<KEYID>.secret` (private), |
|
| 3 | - | //! `keys/<KEYID>.identity` and `config.toml` naming the key as `author.key`. |
|
| 4 | - | //! Spectra writes them with `oxy-core`'s own functions, so `oxy` reads what |
|
| 5 | - | //! Spectra made and the other way round. |
|
| 1 | + | //! Identity homes on disk (spec §8.0, §21.3): `keys/KEYID.secret` (0600), |
|
| 2 | + | //! `keys/KEYID.identity`, and `identity.toml` naming the current KeyId. The |
|
| 3 | + | //! owner's home is `$SPECTRA_HOME`, else `$XDG_CONFIG_HOME/spectra`, else |
|
| 4 | + | //! `~/.config/spectra`; each agent's is the `--home` it was added with. |
|
| 6 | 5 | //! |
|
| 7 | 6 | //! The instance key (spec §9.5) is kept in the data directory instead, at |
|
| 8 | 7 | //! `keys/instance.secret` and `keys/instance.identity`. |
|
| 9 | 8 | ||
| 9 | + | use crate::durable::{write_atomic, write_atomic_private}; |
|
| 10 | + | use crate::identity::{Delegate, Identity, KeyId, SecretKey, Signature}; |
|
| 10 | 11 | use anyhow::{bail, Context, Result}; |
|
| 11 | - | use oxy_core::durable::{write_atomic, write_atomic_private}; |
|
| 12 | - | use oxy_core::hash::KeyId; |
|
| 13 | - | use oxy_core::identity::{Identity, SecretKey, Signature}; |
|
| 14 | 12 | use std::fs; |
|
| 15 | 13 | use std::path::{Path, PathBuf}; |
|
| 16 | 14 | ||
| 15 | + | /// The owner's identity home. |
|
| 16 | + | pub fn default_home() -> PathBuf { |
|
| 17 | + | if let Some(h) = std::env::var_os("SPECTRA_HOME").filter(|h| !h.is_empty()) { |
|
| 18 | + | return PathBuf::from(h); |
|
| 19 | + | } |
|
| 20 | + | std::env::var_os("XDG_CONFIG_HOME") |
|
| 21 | + | .filter(|p| !p.is_empty()) |
|
| 22 | + | .map(PathBuf::from) |
|
| 23 | + | .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) |
|
| 24 | + | .unwrap_or_else(|| PathBuf::from(".")) |
|
| 25 | + | .join("spectra") |
|
| 26 | + | } |
|
| 27 | + | ||
| 17 | 28 | /// A key pair with its identity: the instance, an agent, or the owner. |
|
| 18 | 29 | #[derive(Clone, Debug)] |
|
| 19 | 30 | pub struct KeyPair { |
|
| … | 4 unchanged lines |
||
| 24 | 35 | ||
| 25 | 36 | impl KeyPair { |
|
| 26 | 37 | /// A fresh key pair named `name`, made now. |
|
| 27 | - | pub fn generate(name: &str, email: &str) -> KeyPair { |
|
| 38 | + | pub fn generate(name: &str, contact: &str) -> KeyPair { |
|
| 28 | 39 | let secret = SecretKey::generate(); |
|
| 29 | - | let identity = secret.identity(name, email, crate::ids::now_ms()); |
|
| 40 | + | let identity = secret.identity(name, contact, crate::ids::now_ms()); |
|
| 30 | 41 | KeyPair { key: identity.hash(), identity, secret } |
|
| 31 | 42 | } |
|
| 32 | 43 | ||
| 33 | - | /// Sign `message` (a Spectra domain-prefixed byte string, at least 16 |
|
| 34 | - | /// bytes) through Oxygen's challenge signing: the target is |
|
| 35 | - | /// `BLAKE3("oxy1 challenge " || message)`, so a Spectra signature can |
|
| 36 | - | /// never be taken for a change, an attestation or a policy. |
|
| 44 | + | /// Sign `message`, which carries its own `spectra1 …` prefix. |
|
| 37 | 45 | pub fn sign(&self, message: &[u8]) -> Signature { |
|
| 38 | - | self.secret.sign_challenge(&self.key, message).expect("Spectra messages are longer than 16 bytes") |
|
| 46 | + | self.secret.sign(&self.key, message) |
|
| 39 | 47 | } |
|
| 40 | 48 | ||
| 41 | - | /// Make a key pair in an `OXY_HOME` laid out as `oxy identity create` |
|
| 42 | - | /// lays one out, and name it the home's author. |
|
| 43 | - | pub fn create_home(home: &Path, name: &str, email: &str) -> Result<KeyPair> { |
|
| 44 | - | let pair = KeyPair::generate(name, email); |
|
| 45 | - | let keys = home.join("keys"); |
|
| 46 | - | fs::create_dir_all(&keys).with_context(|| format!("creating {}", keys.display()))?; |
|
| 49 | + | /// Make a key pair in a new identity home and name it current. |
|
| 50 | + | pub fn create_home(home: &Path, name: &str, contact: &str) -> Result<KeyPair> { |
|
| 51 | + | if current_key(home)?.is_some() { |
|
| 52 | + | bail!("{} already holds an identity", home.display()); |
|
| 53 | + | } |
|
| 54 | + | let pair = KeyPair::generate(name, contact); |
|
| 55 | + | fs::create_dir_all(home).with_context(|| format!("creating {}", home.display()))?; |
|
| 47 | 56 | #[cfg(unix)] |
|
| 48 | 57 | { |
|
| 49 | 58 | use std::os::unix::fs::PermissionsExt; |
|
| 50 | 59 | fs::set_permissions(home, fs::Permissions::from_mode(0o700))?; |
|
| 51 | 60 | } |
|
| 52 | - | write_atomic_private(&keys.join(format!("{}.secret", pair.key.to_hex())), &pair.secret.encode(&pair.key))?; |
|
| 53 | - | write_atomic(&keys.join(format!("{}.identity", pair.key.to_hex())), &pair.identity.encode())?; |
|
| 54 | - | let config = home.join("config.toml"); |
|
| 55 | - | oxy_core::repo::Config::set_user(&config, "author.name", name)?; |
|
| 56 | - | oxy_core::repo::Config::set_user(&config, "author.email", email)?; |
|
| 57 | - | oxy_core::repo::Config::set_user(&config, "author.key", &pair.key.to_hex())?; |
|
| 61 | + | pair.write(home)?; |
|
| 58 | 62 | Ok(pair) |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | /// Write this key pair into `home` and name it current. |
|
| 66 | + | fn write(&self, home: &Path) -> Result<()> { |
|
| 67 | + | let keys = home.join("keys"); |
|
| 68 | + | write_atomic_private(&keys.join(format!("{}.secret", self.key.to_hex())), &self.secret.encode(&self.key))?; |
|
| 69 | + | write_atomic(&keys.join(format!("{}.identity", self.key.to_hex())), &self.identity.encode())?; |
|
| 70 | + | write_atomic( |
|
| 71 | + | &home.join("identity.toml"), |
|
| 72 | + | format!( |
|
| 73 | + | "# The identity this home signs with (spec §8.0).\ncurrent = \"{}\"\nname = {}\n", |
|
| 74 | + | self.key.to_hex(), |
|
| 75 | + | toml_str(&self.identity.name) |
|
| 76 | + | ) |
|
| 77 | + | .as_bytes(), |
|
| 78 | + | )?; |
|
| 79 | + | Ok(()) |
|
| 59 | 80 | } |
|
| 60 | 81 | ||
| 61 | - | /// The key pair an `OXY_HOME` signs with: `author.key` from its |
|
| 62 | - | /// `config.toml`, else its only secret key. |
|
| 82 | + | /// The key pair a home signs with: `current` in `identity.toml`, else |
|
| 83 | + | /// its only secret key. |
|
| 63 | 84 | pub fn load_home(home: &Path) -> Result<KeyPair> { |
|
| 64 | 85 | let keys = home.join("keys"); |
|
| 65 | - | let key = match author_key(home)? { |
|
| 86 | + | let key = match current_key(home)? { |
|
| 66 | 87 | Some(k) => k, |
|
| 67 | 88 | None => { |
|
| 68 | 89 | let secrets: Vec<PathBuf> = fs::read_dir(&keys) |
|
| 69 | - | .with_context(|| format!("reading {}", keys.display()))? |
|
| 90 | + | .with_context(|| format!("{} holds no identity (spectra identity create)", home.display()))? |
|
| 70 | 91 | .filter_map(|e| e.ok().map(|e| e.path())) |
|
| 71 | 92 | .filter(|p| p.extension().is_some_and(|x| x == "secret")) |
|
| 72 | 93 | .collect(); |
|
| 73 | 94 | match secrets.as_slice() { |
|
| 74 | 95 | [one] => KeyId::from_hex(one.file_stem().and_then(|s| s.to_str()).unwrap_or("")) |
|
| 75 | 96 | .context("a secret key file is not named by its key")?, |
|
| 76 | - | [] => bail!("{} holds no secret key: an agent home is made by `spectra agent add`", home.display()), |
|
| 77 | - | _ => bail!("{} holds several keys and its config.toml names none (author.key)", home.display()), |
|
| 97 | + | [] => bail!("{} holds no identity (spectra identity create)", home.display()), |
|
| 98 | + | _ => bail!("{} holds several keys and its identity.toml names none", home.display()), |
|
| 78 | 99 | } |
|
| 79 | 100 | } |
|
| 80 | 101 | }; |
|
| 102 | + | KeyPair::load_key(home, &key) |
|
| 103 | + | } |
|
| 104 | + | ||
| 105 | + | /// The key pair of the identity `key` in `home`. |
|
| 106 | + | pub fn load_key(home: &Path, key: &KeyId) -> Result<KeyPair> { |
|
| 107 | + | let keys = home.join("keys"); |
|
| 81 | 108 | KeyPair::load(&keys.join(format!("{}.secret", key.to_hex())), &keys.join(format!("{}.identity", key.to_hex()))) |
|
| 82 | 109 | } |
|
| 83 | 110 | ||
| … | 6 unchanged lines |
||
| 90 | 117 | if identity.hash() != key { |
|
| 91 | 118 | bail!("{} is not the identity of {}", identity.name, secret.display()); |
|
| 92 | 119 | } |
|
| 93 | - | if secret_key.identity(&identity.name, &identity.email, identity.created).ed25519_pk != identity.ed25519_pk { |
|
| 120 | + | if !secret_key.matches(&identity) { |
|
| 94 | 121 | bail!("the secret key in {} does not match its identity", secret.display()); |
|
| 95 | 122 | } |
|
| 96 | 123 | Ok(KeyPair { key, identity, secret: secret_key }) |
|
| … | 11 unchanged lines |
||
| 108 | 135 | write_atomic_private(&secret, &pair.secret.encode(&pair.key))?; |
|
| 109 | 136 | write_atomic(&identity, &pair.identity.encode())?; |
|
| 110 | 137 | Ok(pair) |
|
| 138 | + | } |
|
| 139 | + | ||
| 140 | + | /// The successor of this identity with `delegates`, written into `home` |
|
| 141 | + | /// and made current there. |
|
| 142 | + | pub fn succeed(&self, home: &Path, delegates: Vec<Delegate>) -> Result<KeyPair> { |
|
| 143 | + | let identity = self.identity.successor(delegates, self.identity.expires, crate::ids::now_ms()); |
|
| 144 | + | let next = KeyPair { key: identity.hash(), identity, secret: self.secret.clone() }; |
|
| 145 | + | next.write(home)?; |
|
| 146 | + | Ok(next) |
|
| 111 | 147 | } |
|
| 148 | + | } |
|
| 149 | + | ||
| 150 | + | /// Delegate to `key` from the current identity in `home` (replacing any |
|
| 151 | + | /// delegation to it): the owner's successor, made current. |
|
| 152 | + | pub fn delegate(home: &Path, key: KeyId, expires: Option<u64>) -> Result<KeyPair> { |
|
| 153 | + | let cur = KeyPair::load_home(home)?; |
|
| 154 | + | let mut d: Vec<Delegate> = cur.identity.delegates.iter().filter(|x| x.key != key).cloned().collect(); |
|
| 155 | + | d.push(Delegate { key, expires }); |
|
| 156 | + | cur.succeed(home, d) |
|
| 112 | 157 | } |
|
| 113 | 158 | ||
| 114 | - | /// The identity an `OXY_HOME` names as its author (`oxy identity export` |
|
| 115 | - | /// without the CLI): for reading the owner's identity at `spectra init`. |
|
| 159 | + | /// Stop delegating to `key`: the owner's successor without it. |
|
| 160 | + | pub fn undelegate(home: &Path, key: KeyId) -> Result<KeyPair> { |
|
| 161 | + | let cur = KeyPair::load_home(home)?; |
|
| 162 | + | let d = cur.identity.delegates.iter().filter(|x| x.key != key).cloned().collect(); |
|
| 163 | + | cur.succeed(home, d) |
|
| 164 | + | } |
|
| 165 | + | ||
| 166 | + | /// The identity a home names as current. |
|
| 116 | 167 | pub fn home_identity(home: &Path) -> Result<Identity> { |
|
| 117 | - | let key = author_key(home)?.with_context(|| format!("{} names no identity (author.key in config.toml)", home.display()))?; |
|
| 168 | + | let key = current_key(home)?.with_context(|| format!("{} holds no identity (spectra identity create)", home.display()))?; |
|
| 118 | 169 | let path = home.join("keys").join(format!("{}.identity", key.to_hex())); |
|
| 119 | 170 | Identity::decode(&fs::read(&path).with_context(|| format!("reading {}", path.display()))?) |
|
| 120 | 171 | .map_err(|e| anyhow::anyhow!("{}: {e}", path.display())) |
|
| 172 | + | } |
|
| 173 | + | ||
| 174 | + | /// An identity file of any home, by its KeyId. |
|
| 175 | + | pub fn identity_in(home: &Path, key: &KeyId) -> Option<Identity> { |
|
| 176 | + | let path = home.join("keys").join(format!("{}.identity", key.to_hex())); |
|
| 177 | + | Identity::decode(&fs::read(path).ok()?).ok() |
|
| 178 | + | } |
|
| 179 | + | ||
| 180 | + | fn current_key(home: &Path) -> Result<Option<KeyId>> { |
|
| 181 | + | let path = home.join("identity.toml"); |
|
| 182 | + | let Ok(text) = fs::read_to_string(&path) else { return Ok(None) }; |
|
| 183 | + | let table: toml::Table = text.parse().with_context(|| format!("reading {}", path.display()))?; |
|
| 184 | + | match table.get("current").and_then(|v| v.as_str()) { |
|
| 185 | + | Some(hex) => Ok(Some(KeyId::from_hex(hex).with_context(|| format!("{}: current is not a KeyId", path.display()))?)), |
|
| 186 | + | None => Ok(None), |
|
| 187 | + | } |
|
| 121 | 188 | } |
|
| 122 | 189 | ||
| 123 | - | fn author_key(home: &Path) -> Result<Option<KeyId>> { |
|
| 124 | - | let config = home.join("config.toml"); |
|
| 125 | - | let Ok(text) = fs::read_to_string(&config) else { return Ok(None) }; |
|
| 126 | - | let table: toml::Table = text.parse().with_context(|| format!("reading {}", config.display()))?; |
|
| 127 | - | let found = table.get("author").and_then(|a| a.get("key")).or_else(|| table.get("author.key")).and_then(|v| v.as_str()); |
|
| 128 | - | Ok(match found { |
|
| 129 | - | Some(hex) => Some(KeyId::from_hex(hex).with_context(|| format!("{}: author.key is not a key", config.display()))?), |
|
| 130 | - | None => None, |
|
| 131 | - | }) |
|
| 190 | + | fn toml_str(s: &str) -> String { |
|
| 191 | + | toml::Value::String(s.to_string()).to_string() |
|
| 132 | 192 | } |
|
| 133 | 193 | ||
| 134 | 194 | #[cfg(test)] |
|
| … | 1 unchanged lines |
||
| 136 | 196 | use super::*; |
|
| 137 | 197 | ||
| 138 | 198 | #[test] |
|
| 139 | - | fn a_home_made_here_reads_back_and_signs_verifiably() { |
|
| 199 | + | fn a_home_made_here_reads_back_signs_and_delegates() { |
|
| 140 | 200 | let dir = tempfile::tempdir().unwrap(); |
|
| 141 | - | let made = KeyPair::create_home(dir.path(), "claude-oxygen", "").unwrap(); |
|
| 201 | + | let made = KeyPair::create_home(dir.path(), "Andy McCall", "").unwrap(); |
|
| 142 | 202 | let read = KeyPair::load_home(dir.path()).unwrap(); |
|
| 143 | 203 | assert_eq!(made.key, read.key); |
|
| 144 | 204 | assert_eq!(home_identity(dir.path()).unwrap().hash(), made.key); |
|
| 145 | 205 | let msg = b"spectra1 test message of some length"; |
|
| 146 | - | let sig = read.sign(msg); |
|
| 147 | - | oxy_core::identity::verify_challenge(&made.identity, &sig, msg).unwrap(); |
|
| 148 | - | assert!(oxy_core::identity::verify_challenge(&made.identity, &sig, b"spectra1 another message entirely").is_err()); |
|
| 206 | + | crate::identity::verify(&made.identity, &read.sign(msg), msg).unwrap(); |
|
| 207 | + | assert!(KeyPair::create_home(dir.path(), "again", "").is_err()); |
|
| 208 | + | ||
| 209 | + | let agent = KeyPair::generate("claude", ""); |
|
| 210 | + | let next = delegate(dir.path(), agent.key, Some(u64::MAX)).unwrap(); |
|
| 211 | + | assert_eq!(next.identity.previous, Some(made.key)); |
|
| 212 | + | assert_eq!(KeyPair::load_home(dir.path()).unwrap().key, next.key, "the successor is current"); |
|
| 213 | + | assert!(next.identity.delegates_to(&agent.key, 1)); |
|
| 214 | + | assert!(KeyPair::load_key(dir.path(), &made.key).is_ok(), "the old identity is kept"); |
|
| 215 | + | let after = undelegate(dir.path(), agent.key).unwrap(); |
|
| 216 | + | assert!(!after.identity.delegates_to(&agent.key, 1)); |
|
| 149 | 217 | } |
|
| 150 | 218 | ||
| 151 | 219 | #[test] |
|
| … | 7 unchanged lines |
modified crates/spectra-core/src/ledger.rs +5 −11
| … | 7 unchanged lines |
||
| 8 | 8 | ||
| 9 | 9 | use crate::event::{canonical_json, Auth, Event, Method, NewEvent}; |
|
| 10 | 10 | use crate::hlc::{Clock, Hlc}; |
|
| 11 | + | use crate::identity::KeyId; |
|
| 12 | + | use crate::identity::{Identity, Signature}; |
|
| 11 | 13 | use crate::keys::KeyPair; |
|
| 12 | 14 | use crate::{ids, projections}; |
|
| 13 | 15 | use anyhow::{bail, Context, Result}; |
|
| 14 | - | use oxy_core::hash::KeyId; |
|
| 15 | - | use oxy_core::identity::{Identity, Signature}; |
|
| 16 | 16 | use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteRow, SqliteSynchronous}; |
|
| 17 | 17 | use sqlx::{Row, SqlitePool}; |
|
| 18 | 18 | use std::path::Path; |
|
| … | 340 unchanged lines |
||
| 359 | 359 | let sig = instance.sign(&checkpoint_message(seq, &hash)); |
|
| 360 | 360 | let n = NewEvent::system("ledger.checkpoint", serde_json::json!({ "seq": seq, "hash": hex::encode(hash), "count": count })).by( |
|
| 361 | 361 | "system", |
|
| 362 | - | Auth { |
|
| 363 | - | key: Some(instance.key.0), |
|
| 364 | - | method: Method::Signature, |
|
| 365 | - | assurance: 2, |
|
| 366 | - | proof: Some(oxy_core::cbor::encode(&sig.to_value())), |
|
| 367 | - | }, |
|
| 362 | + | Auth { key: Some(instance.key.0), method: Method::Signature, assurance: 2, proof: Some(crate::cbor::encode(&sig.to_value())) }, |
|
| 368 | 363 | ); |
|
| 369 | 364 | let e = self.chain(h, n); |
|
| 370 | 365 | h.since_checkpoint = 0; |
|
| … | 52 unchanged lines |
||
| 423 | 418 | return Err("signed by a key other than the instance's".into()); |
|
| 424 | 419 | } |
|
| 425 | 420 | let proof = e.auth.proof.as_deref().ok_or("unsigned")?; |
|
| 426 | - | let sig = oxy_core::cbor::decode(proof).ok().and_then(|v| Signature::from_value(&v).ok()).ok_or("malformed signature")?; |
|
| 427 | - | oxy_core::identity::verify_challenge(instance, &sig, &checkpoint_message(seq, &hash)) |
|
| 428 | - | .map_err(|e| format!("signature does not verify: {e}")) |
|
| 421 | + | let sig = crate::cbor::decode(proof).ok().and_then(|v| Signature::from_value(&v).ok()).ok_or("malformed signature")?; |
|
| 422 | + | crate::identity::verify(instance, &sig, &checkpoint_message(seq, &hash)).map_err(|e| format!("signature does not verify: {e}")) |
|
| 429 | 423 | } |
|
| 430 | 424 | ||
| 431 | 425 | /// Checks the proof of a signed event: the identities and delegations that |
|
| … | 163 unchanged lines |
modified crates/spectra-core/src/lib.rs +3 −0
| … | 6 unchanged lines |
||
| 7 | 7 | ||
| 8 | 8 | pub mod attachments; |
|
| 9 | 9 | pub mod authn; |
|
| 10 | + | pub mod cbor; |
|
| 10 | 11 | pub mod conversation; |
|
| 11 | 12 | pub mod daemon; |
|
| 13 | + | pub mod durable; |
|
| 12 | 14 | pub mod event; |
|
| 13 | 15 | pub mod hlc; |
|
| 16 | + | pub mod identity; |
|
| 14 | 17 | pub mod ids; |
|
| 15 | 18 | pub mod keys; |
|
| 16 | 19 | pub mod ledger; |
|
| … | 10 unchanged lines |
modified crates/spectra-core/src/principals.rs +6 −9
| … | 15 unchanged lines |
||
| 16 | 16 | //! checks every signed event against who was who at that moment. |
|
| 17 | 17 | ||
| 18 | 18 | use crate::event::{Event, Method}; |
|
| 19 | + | use crate::identity::Identity; |
|
| 20 | + | use crate::identity::KeyId; |
|
| 19 | 21 | use crate::ledger::KeyResolver; |
|
| 20 | 22 | use crate::proof::Proof; |
|
| 21 | - | use oxy_core::hash::KeyId; |
|
| 22 | - | use oxy_core::identity::{Identity, Scope}; |
|
| 23 | 23 | use serde_json::Value as Json; |
|
| 24 | 24 | use sqlx::SqliteConnection; |
|
| 25 | 25 | use std::collections::HashMap; |
|
| … | 1 unchanged lines |
||
| 27 | 27 | /// The scopes a new agent gets (spec §8.4). |
|
| 28 | 28 | pub const DEFAULT_SCOPES: &[&str] = &["read", "say", "tasks", "claims", "decisions", "status", "search", "schedule:self"]; |
|
| 29 | 29 | ||
| 30 | - | /// The Oxygen delegation scope an agent's key needs from its owner. |
|
| 31 | - | pub const DELEGATION_SCOPE: Scope = Scope::CHANGES; |
|
| 32 | - | ||
| 33 | 30 | /// Who caused something. |
|
| 34 | 31 | #[derive(Clone, Debug, PartialEq, Eq)] |
|
| 35 | 32 | pub enum Principal { |
|
| … | 93 unchanged lines |
||
| 129 | 126 | return Err(format!("agent {} is disabled", a.name)); |
|
| 130 | 127 | } |
|
| 131 | 128 | let owner = self.owner().ok_or("no owner is recorded")?; |
|
| 132 | - | if !owner.delegates_to(key, DELEGATION_SCOPE, at_ms) { |
|
| 129 | + | if !owner.delegates_to(key, at_ms) { |
|
| 133 | 130 | return Err(match owner.delegate(key) { |
|
| 134 | 131 | Some(d) if d.expires.is_some_and(|x| at_ms > x) => format!("agent {}'s delegation from {} has expired", a.name, owner.name), |
|
| 135 | 132 | Some(_) => format!("{}'s delegation to agent {} does not include `changes`", owner.name, a.name), |
|
| … | 48 unchanged lines |
||
| 184 | 181 | let id = identity_of(&p)?; |
|
| 185 | 182 | let key = id.hash(); |
|
| 186 | 183 | let owner = self.owner_signed(e)?; |
|
| 187 | - | if !owner.delegates_to(&key, DELEGATION_SCOPE, e.hlc.wall_ms) { |
|
| 184 | + | if !owner.delegates_to(&key, e.hlc.wall_ms) { |
|
| 188 | 185 | return Err("added without the owner's delegation to its key".into()); |
|
| 189 | 186 | } |
|
| 190 | 187 | let name = text(&p, "name")?; |
|
| … | 58 unchanged lines |
||
| 249 | 246 | /// request for the sign-in link, so it must verify against the owner, |
|
| 250 | 247 | /// and only the owner acts this way. |
|
| 251 | 248 | 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")?); |
|
| 249 | + | let key = crate::identity::KeyId(e.auth.key.ok_or("a session event names no key")?); |
|
| 253 | 250 | if !matches!(self.principal(&key, e.hlc.wall_ms)?, Principal::Owner { .. }) || e.actor != "human" { |
|
| 254 | 251 | return Err("only the owner acts through a Radar session".into()); |
|
| 255 | 252 | } |
|
| … | 8 unchanged lines |
||
| 264 | 261 | /// Check a signed event's proof against the principal its key was at |
|
| 265 | 262 | /// the event's time. |
|
| 266 | 263 | pub fn check_signed(&self, e: &Event) -> Result<(), String> { |
|
| 267 | - | let key = oxy_core::hash::ObjectRef(e.auth.key.ok_or("signed, but names no key")?); |
|
| 264 | + | let key = crate::identity::KeyId(e.auth.key.ok_or("signed, but names no key")?); |
|
| 268 | 265 | let who = self.principal(&key, e.hlc.wall_ms)?; |
|
| 269 | 266 | // The actor an event names must be the principal that signed it: |
|
| 270 | 267 | // an agent's key never makes an event in another's name. |
|
| … | 172 unchanged lines |
modified crates/spectra-core/src/projects.rs +26 −103
| 1 | - | //! Projects (spec §23.1): checkouts the agents work in, with the |
|
| 2 | - | //! repository id and remote read from `.oxy/`, and dependencies read from |
|
| 3 | - | //! `.oxymodules`, matched to other registered projects by remote URL. |
|
| 1 | + | //! Projects (spec §23.1): any kind of work a group of agents shares. A |
|
| 2 | + | //! project has a name, a description, and optionally a folder and a link; |
|
| 3 | + | //! Spectra records where the folder is and reads nothing inside it. The |
|
| 4 | + | //! owner links projects (`project.linked`: one depends on another), which |
|
| 5 | + | //! is what `to: "downstream"` follows. |
|
| 4 | 6 | ||
| 5 | 7 | use crate::event::Event; |
|
| 6 | 8 | use anyhow::{Context, Result}; |
|
| … | 1 unchanged lines |
||
| 8 | 10 | use sqlx::SqliteConnection; |
|
| 9 | 11 | use std::path::Path; |
|
| 10 | 12 | ||
| 11 | - | /// What a checkout says about itself. |
|
| 12 | - | #[derive(Clone, Debug, Default, PartialEq, Eq)] |
|
| 13 | - | pub struct Checkout { |
|
| 14 | - | pub repo_id: Option<String>, |
|
| 15 | - | pub remote: Option<String>, |
|
| 16 | - | /// The URLs of the subrepos it pins (`.oxymodules`). |
|
| 17 | - | pub modules: Vec<String>, |
|
| 18 | - | } |
|
| 19 | - | ||
| 20 | - | /// Read `.oxy/id`, the remote (`origin`, else the first) and `.oxymodules`. |
|
| 21 | - | pub fn inspect(path: &Path) -> Result<Checkout> { |
|
| 22 | - | if !path.is_dir() { |
|
| 23 | - | anyhow::bail!("{} is not a directory", path.display()); |
|
| 24 | - | } |
|
| 25 | - | let oxy = path.join(".oxy"); |
|
| 26 | - | let repo_id = std::fs::read_to_string(oxy.join("id")).ok().map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); |
|
| 27 | - | let mut remotes: Vec<(String, String)> = Vec::new(); |
|
| 28 | - | if let Ok(text) = std::fs::read_to_string(oxy.join("config")) { |
|
| 29 | - | for line in text.lines() { |
|
| 30 | - | if let Some((k, v)) = line.split_once('=') { |
|
| 31 | - | let k = k.trim(); |
|
| 32 | - | if let Some(name) = k.strip_prefix("remote.").and_then(|r| r.strip_suffix(".url")) { |
|
| 33 | - | remotes.push((name.to_string(), v.trim().trim_matches('"').to_string())); |
|
| 34 | - | } |
|
| 35 | - | } |
|
| 36 | - | } |
|
| 37 | - | } |
|
| 38 | - | let remote = remotes.iter().find(|(n, _)| n == "origin").or(remotes.first()).map(|(_, u)| u.clone()); |
|
| 39 | - | let modules = std::fs::read_to_string(path.join(".oxymodules")) |
|
| 40 | - | .map(|t| { |
|
| 41 | - | t.lines() |
|
| 42 | - | .filter_map(|l| l.trim().strip_prefix("url").map(|r| r.trim_start().trim_start_matches('=').trim().to_string())) |
|
| 43 | - | .collect() |
|
| 44 | - | }) |
|
| 45 | - | .unwrap_or_default(); |
|
| 46 | - | Ok(Checkout { repo_id, remote, modules }) |
|
| 47 | - | } |
|
| 13 | + | /// Kinds of event the owner signs about projects. |
|
| 14 | + | pub const KINDS: &[&str] = &["project.added", "project.linked", "project.unlinked"]; |
|
| 48 | 15 | ||
| 49 | - | /// A project's name: the directory's, lower-cased, made valid. |
|
| 16 | + | /// A project's name from a folder: its name, lower-cased, made valid. |
|
| 50 | 17 | pub fn default_name(path: &Path) -> Result<String> { |
|
| 51 | 18 | let base = path.canonicalize()?.file_name().and_then(|n| n.to_str()).map(str::to_lowercase).context("the path has no name")?; |
|
| 52 | 19 | Ok(base.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect()) |
|
| 53 | - | } |
|
| 54 | - | ||
| 55 | - | fn same_repo(a: &str, b: &str) -> bool { |
|
| 56 | - | let n = |s: &str| s.trim_end_matches('/').trim_end_matches(".git").to_lowercase(); |
|
| 57 | - | n(a) == n(b) |
|
| 58 | 20 | } |
|
| 59 | 21 | ||
| 60 | - | /// Apply `project.added` to the tables, deriving relations both ways. |
|
| 22 | + | /// Apply a project event to the tables. |
|
| 61 | 23 | pub async fn project(conn: &mut SqliteConnection, e: &Event) -> sqlx::Result<()> { |
|
| 62 | - | if e.kind != "project.added" { |
|
| 63 | - | return Ok(()); |
|
| 64 | - | } |
|
| 65 | 24 | let p = e.json(); |
|
| 66 | 25 | let s = |k: &str| p.get(k).and_then(Json::as_str).map(String::from); |
|
| 67 | - | let Some(name) = s("name") else { return Ok(()) }; |
|
| 68 | - | let modules: Vec<String> = p |
|
| 69 | - | .get("modules") |
|
| 70 | - | .and_then(Json::as_array) |
|
| 71 | - | .map(|a| a.iter().filter_map(|m| m.as_str().map(String::from)).collect()) |
|
| 72 | - | .unwrap_or_default(); |
|
| 73 | - | let remote = s("remote"); |
|
| 74 | - | sqlx::query("INSERT OR REPLACE INTO projects (name, path, repo_id, remote, modules, seq) VALUES (?, ?, ?, ?, ?, ?)") |
|
| 75 | - | .bind(&name) |
|
| 76 | - | .bind(s("path").unwrap_or_default()) |
|
| 77 | - | .bind(s("repo_id")) |
|
| 78 | - | .bind(&remote) |
|
| 79 | - | .bind(serde_json::to_string(&modules).unwrap_or_default()) |
|
| 80 | - | .bind(e.seq as i64) |
|
| 81 | - | .execute(&mut *conn) |
|
| 82 | - | .await?; |
|
| 83 | - | let others: Vec<(String, Option<String>, String)> = |
|
| 84 | - | sqlx::query_as("SELECT name, remote, modules FROM projects WHERE name != ?").bind(&name).fetch_all(&mut *conn).await?; |
|
| 85 | - | for (other, other_remote, other_modules) in others { |
|
| 86 | - | if other_remote.as_deref().is_some_and(|r| modules.iter().any(|m| same_repo(m, r))) { |
|
| 87 | - | sqlx::query("INSERT OR IGNORE INTO project_relations (project, depends_on) VALUES (?, ?)") |
|
| 26 | + | match e.kind.as_str() { |
|
| 27 | + | "project.added" => { |
|
| 28 | + | let Some(name) = s("name") else { return Ok(()) }; |
|
| 29 | + | sqlx::query("INSERT OR REPLACE INTO projects (name, path, description, url, seq) VALUES (?, ?, ?, ?, ?)") |
|
| 88 | 30 | .bind(&name) |
|
| 89 | - | .bind(&other) |
|
| 31 | + | .bind(s("path").filter(|p| !p.is_empty())) |
|
| 32 | + | .bind(s("description").unwrap_or_default()) |
|
| 33 | + | .bind(s("url")) |
|
| 34 | + | .bind(e.seq as i64) |
|
| 90 | 35 | .execute(&mut *conn) |
|
| 91 | 36 | .await?; |
|
| 92 | 37 | } |
|
| 93 | - | let theirs: Vec<String> = serde_json::from_str(&other_modules).unwrap_or_default(); |
|
| 94 | - | if remote.as_deref().is_some_and(|r| theirs.iter().any(|m| same_repo(m, r))) { |
|
| 95 | - | sqlx::query("INSERT OR IGNORE INTO project_relations (project, depends_on) VALUES (?, ?)") |
|
| 96 | - | .bind(&other) |
|
| 97 | - | .bind(&name) |
|
| 98 | - | .execute(&mut *conn) |
|
| 99 | - | .await?; |
|
| 38 | + | "project.linked" | "project.unlinked" => { |
|
| 39 | + | let (Some(project), Some(on)) = (s("project"), s("depends_on")) else { return Ok(()) }; |
|
| 40 | + | let sql = if e.kind == "project.linked" { |
|
| 41 | + | "INSERT OR IGNORE INTO project_relations (project, depends_on) VALUES (?, ?)" |
|
| 42 | + | } else { |
|
| 43 | + | "DELETE FROM project_relations WHERE project = ? AND depends_on = ?" |
|
| 44 | + | }; |
|
| 45 | + | sqlx::query(sql).bind(project).bind(on).execute(&mut *conn).await?; |
|
| 100 | 46 | } |
|
| 47 | + | _ => {} |
|
| 101 | 48 | } |
|
| 102 | 49 | Ok(()) |
|
| 103 | - | } |
|
| 104 | - | ||
| 105 | - | #[cfg(test)] |
|
| 106 | - | mod tests { |
|
| 107 | - | use super::*; |
|
| 108 | - | ||
| 109 | - | #[test] |
|
| 110 | - | fn a_checkout_reports_its_id_remote_and_subrepos() { |
|
| 111 | - | let dir = tempfile::tempdir().unwrap(); |
|
| 112 | - | std::fs::create_dir_all(dir.path().join(".oxy")).unwrap(); |
|
| 113 | - | std::fs::write(dir.path().join(".oxy/id"), "97e6").unwrap(); |
|
| 114 | - | std::fs::write( |
|
| 115 | - | dir.path().join(".oxy/config"), |
|
| 116 | - | "remote.earth.url = \"https://hub/a/hub\"\nremote.origin.url = \"https://hub/a/x\"\n", |
|
| 117 | - | ) |
|
| 118 | - | .unwrap(); |
|
| 119 | - | std::fs::write(dir.path().join(".oxymodules"), "[deps/flow]\nurl = https://hub/a/flow\nbookmark = main\n").unwrap(); |
|
| 120 | - | let c = inspect(dir.path()).unwrap(); |
|
| 121 | - | assert_eq!( |
|
| 122 | - | c, |
|
| 123 | - | Checkout { repo_id: Some("97e6".into()), remote: Some("https://hub/a/x".into()), modules: vec!["https://hub/a/flow".into()] } |
|
| 124 | - | ); |
|
| 125 | - | assert!(same_repo("https://hub/a/flow/", "https://HUB/a/flow.git")); |
|
| 126 | - | } |
|
| 127 | 50 | } |
modified crates/spectra-core/src/proof.rs +3 −3
| … | 9 unchanged lines |
||
| 10 | 10 | //! `body` = the event's canonical payload, so its proof also binds the |
|
| 11 | 11 | //! event's content. |
|
| 12 | 12 | ||
| 13 | + | use crate::cbor::{self, Value}; |
|
| 14 | + | use crate::identity::{Identity, Signature}; |
|
| 13 | 15 | use crate::keys::KeyPair; |
|
| 14 | - | use oxy_core::cbor::{self, Value}; |
|
| 15 | - | use oxy_core::identity::{Identity, Signature}; |
|
| 16 | 16 | use rand::RngCore; |
|
| 17 | 17 | ||
| 18 | 18 | /// How far a request's time may be from the server's clock (spec §8.5). |
|
| … | 36 unchanged lines |
||
| 55 | 55 | } |
|
| 56 | 56 | } |
|
| 57 | 57 | let msg = message(&self.method, &self.body_hash, self.time_ms, &self.nonce); |
|
| 58 | - | oxy_core::identity::verify_challenge(identity, &self.sig, &msg).map_err(|e| format!("signature does not verify: {e}")) |
|
| 58 | + | crate::identity::verify(identity, &self.sig, &msg).map_err(|e| format!("signature does not verify: {e}")) |
|
| 59 | 59 | } |
|
| 60 | 60 | ||
| 61 | 61 | pub fn encode(&self) -> Vec<u8> { |
|
| … | 56 unchanged lines |
modified crates/spectra-core/tests/common/mod.rs +2 −12
| … | 2 unchanged lines |
||
| 3 | 3 | //! recorded as `spectra init`, `project add` and `agent add` record them. |
|
| 4 | 4 | #![allow(dead_code)] |
|
| 5 | 5 | ||
| 6 | - | use oxy_core::durable::{write_atomic, write_atomic_private}; |
|
| 7 | - | use oxy_core::identity::{Delegate, SecretKey}; |
|
| 8 | 6 | use spectra_core::conversation::{default_channel_events, project_channel_event, Author, Signer}; |
|
| 9 | 7 | use spectra_core::ids::now_ms; |
|
| 10 | 8 | use spectra_core::keys::KeyPair; |
|
| 11 | 9 | use spectra_core::ledger::Ledger; |
|
| 12 | - | use spectra_core::principals::{identity_payload, signed_event, Principal, Principals, DELEGATION_SCOPE}; |
|
| 10 | + | use spectra_core::principals::{identity_payload, signed_event, Principal, Principals}; |
|
| 13 | 11 | use std::path::PathBuf; |
|
| 14 | 12 | ||
| 15 | 13 | pub struct World { |
|
| … | 28 unchanged lines |
||
| 44 | 42 | /// Delegate to a new agent and add it, as `spectra agent add` does. |
|
| 45 | 43 | pub async fn agent(&mut self, name: &str, project: &str) -> KeyPair { |
|
| 46 | 44 | let agent = KeyPair::generate(name, ""); |
|
| 47 | - | let keys = self.owner_home.join("keys"); |
|
| 48 | - | let (_, secret) = SecretKey::decode(&std::fs::read(keys.join(format!("{}.secret", self.owner.key.to_hex()))).unwrap()).unwrap(); |
|
| 49 | - | let mut d = self.owner.identity.delegates.clone(); |
|
| 50 | - | d.push(Delegate { key: agent.key, scope: DELEGATION_SCOPE, expires: None }); |
|
| 51 | - | let next = self.owner.identity.successor(d, None, now_ms()); |
|
| 52 | - | let k = next.hash(); |
|
| 53 | - | write_atomic_private(&keys.join(format!("{}.secret", k.to_hex())), &secret.encode(&k)).unwrap(); |
|
| 54 | - | write_atomic(&keys.join(format!("{}.identity", k.to_hex())), &next.encode()).unwrap(); |
|
| 55 | - | oxy_core::repo::Config::set_user(&self.owner_home.join("config.toml"), "author.key", &k.to_hex()).unwrap(); |
|
| 45 | + | spectra_core::keys::delegate(&self.owner_home, agent.key, None).unwrap(); |
|
| 56 | 46 | self.owner = KeyPair::load_home(&self.owner_home).unwrap(); |
|
| 57 | 47 | let mut rotated = identity_payload(&self.owner.identity); |
|
| 58 | 48 | rotated["chain"] = serde_json::json!([]); |
|
| … | 32 unchanged lines |
modified crates/spectra-core/tests/ledger.rs +1 −1
| … | 110 unchanged lines |
||
| 111 | 111 | drop(ledger); |
|
| 112 | 112 | let mallory = KeyPair::generate("mallory", ""); |
|
| 113 | 113 | let sig = mallory.sign(&spectra_core::ledger::checkpoint_message(cp.seq - 1, &cp.prev)); |
|
| 114 | - | let proof = hex::encode(oxy_core::cbor::encode(&sig.to_value())); |
|
| 114 | + | let proof = hex::encode(spectra_core::cbor::encode(&sig.to_value())); |
|
| 115 | 115 | raw(dir.path(), &format!("UPDATE events SET auth_proof = X'{proof}' WHERE seq = {}", cp.seq)).await; |
|
| 116 | 116 | let v = open(dir.path()).await.verify(&mut NoKeys).await.unwrap(); |
|
| 117 | 117 | assert_eq!(v.findings.len(), 1, "{:?}", v.findings); |
|
| … | 120 unchanged lines |
modified crates/spectra-core/tests/principals.rs +6 −21
| … | 2 unchanged lines |
||
| 3 | 3 | //! delegation expires or is disabled, and a request is refused unless |
|
| 4 | 4 | //! signed, fresh, unreplayed and from the bound Unix user. |
|
| 5 | 5 | ||
| 6 | - | use oxy_core::durable::{write_atomic, write_atomic_private}; |
|
| 7 | - | use oxy_core::identity::{Delegate, Identity, SecretKey}; |
|
| 8 | 6 | use spectra_core::authn::{Authenticator, Refusal}; |
|
| 9 | 7 | use spectra_core::event::NewEvent; |
|
| 8 | + | use spectra_core::identity::{Delegate, Identity}; |
|
| 10 | 9 | use spectra_core::ids::now_ms; |
|
| 11 | 10 | use spectra_core::keys::KeyPair; |
|
| 12 | 11 | use spectra_core::ledger::{Ledger, Problem}; |
|
| 13 | - | use spectra_core::principals::{identity_payload, signed_event, Principal, Principals, DELEGATION_SCOPE}; |
|
| 12 | + | use spectra_core::principals::{identity_payload, signed_event, Principal, Principals}; |
|
| 14 | 13 | use spectra_core::proof::Proof; |
|
| 15 | 14 | use std::path::Path; |
|
| 16 | 15 | ||
| 17 | - | /// What `oxy identity delegate add KEY --scope changes [--expires]` does to |
|
| 18 | - | /// the owner's home: a successor with the same key pair, its own secret |
|
| 19 | - | /// file, and `author.key` naming it. Only the owner's tools do this. |
|
| 16 | + | /// The owner delegates to `agent`: a successor with the same key pair, |
|
| 17 | + | /// made current in the owner's home. Only the owner's own tools do this. |
|
| 20 | 18 | fn delegate(owner_home: &Path, agent: &Identity, expires: Option<u64>) -> KeyPair { |
|
| 21 | - | let cur = KeyPair::load_home(owner_home).unwrap(); |
|
| 22 | - | let keys = owner_home.join("keys"); |
|
| 23 | - | let secret_file = std::fs::read(keys.join(format!("{}.secret", cur.key.to_hex()))).unwrap(); |
|
| 24 | - | let (_, secret) = SecretKey::decode(&secret_file).unwrap(); |
|
| 25 | - | let mut delegates = cur.identity.delegates.clone(); |
|
| 26 | - | delegates.retain(|d| d.key != agent.hash()); |
|
| 27 | - | delegates.push(Delegate { key: agent.hash(), scope: DELEGATION_SCOPE, expires }); |
|
| 28 | - | let next = cur.identity.successor(delegates, None, now_ms()); |
|
| 29 | - | let key = next.hash(); |
|
| 30 | - | write_atomic_private(&keys.join(format!("{}.secret", key.to_hex())), &secret.encode(&key)).unwrap(); |
|
| 31 | - | write_atomic(&keys.join(format!("{}.identity", key.to_hex())), &next.encode()).unwrap(); |
|
| 32 | - | oxy_core::repo::Config::set_user(&owner_home.join("config.toml"), "author.key", &key.to_hex()).unwrap(); |
|
| 33 | - | KeyPair::load_home(owner_home).unwrap() |
|
| 19 | + | spectra_core::keys::delegate(owner_home, agent.hash(), expires).unwrap() |
|
| 34 | 20 | } |
|
| 35 | 21 | ||
| 36 | 22 | struct World { |
|
| … | 88 unchanged lines |
||
| 125 | 111 | let mut w = world().await; |
|
| 126 | 112 | let a = add_agent(&mut w, "claude-oxygen", None).await; |
|
| 127 | 113 | // A successor of the owner that the agent made: it has no owner key to sign it with. |
|
| 128 | - | let forged = |
|
| 129 | - | w.owner.identity.successor(vec![Delegate { key: a.key, scope: oxy_core::identity::Scope::ALL, expires: None }], None, now_ms()); |
|
| 114 | + | let forged = w.owner.identity.successor(vec![Delegate { key: a.key, expires: None }], None, now_ms()); |
|
| 130 | 115 | let mut p = identity_payload(&forged); |
|
| 131 | 116 | p["chain"] = serde_json::json!([]); |
|
| 132 | 117 | w.ledger.append_one(signed_event(&a, "human", "identity.rotated", p)).await.unwrap(); |
|
| … | 110 unchanged lines |
modified crates/spectra-mcp/Cargo.toml +0 −1
| … | 7 unchanged lines |
||
| 8 | 8 | [dependencies] |
|
| 9 | 9 | spectra-core.workspace = true |
|
| 10 | 10 | spectra-api.workspace = true |
|
| 11 | - | oxy-core.workspace = true |
|
| 12 | 11 | anyhow.workspace = true |
|
| 13 | 12 | serde.workspace = true |
|
| 14 | 13 | serde_json.workspace = true |
|
| … | 21 unchanged lines |
modified crates/spectra-mcp/src/api.rs +10 −7
| … | 81 unchanged lines |
||
| 82 | 82 | if !crate::http::origin_ok(headers, &st.origins) { |
|
| 83 | 83 | return Err(problem(StatusCode::FORBIDDEN, "this origin may not read Spectra", None)); |
|
| 84 | 84 | } |
|
| 85 | - | let key = oxy_core::hash::ObjectRef(s.key); |
|
| 85 | + | let key = spectra_core::identity::KeyId(s.key); |
|
| 86 | 86 | let principal = st |
|
| 87 | 87 | .daemon |
|
| 88 | 88 | .principals |
|
| … | 86 unchanged lines |
||
| 175 | 175 | let now = now_ms(); |
|
| 176 | 176 | rows.into_iter() |
|
| 177 | 177 | .map(|(name, owner, key, kind, project, status, disabled)| { |
|
| 178 | - | let k = oxy_core::hash::ObjectRef::from_hex(&key); |
|
| 178 | + | let k = spectra_core::identity::KeyId::from_hex(&key); |
|
| 179 | 179 | let state = match k.map(|k| principals.principal(&k, now)) { |
|
| 180 | 180 | Some(Ok(_)) => "ok".to_string(), |
|
| 181 | 181 | Some(Err(why)) => why, |
|
| … | 35 unchanged lines |
||
| 217 | 217 | ) -> Response { |
|
| 218 | 218 | let _ = who!(st, peer, headers, uri, "GET", b"", true); |
|
| 219 | 219 | let pool = st.daemon.ledger.pool(); |
|
| 220 | - | let rows: Vec<(String, String, Option<String>)> = |
|
| 221 | - | sqlx::query_as("SELECT name, path, remote FROM projects ORDER BY name").fetch_all(pool).await.unwrap_or_default(); |
|
| 220 | + | let rows: Vec<(String, Option<String>, String, Option<String>)> = |
|
| 221 | + | sqlx::query_as("SELECT name, path, description, url FROM projects ORDER BY name").fetch_all(pool).await.unwrap_or_default(); |
|
| 222 | 222 | let mut out = Vec::new(); |
|
| 223 | - | for (name, path, remote) in rows { |
|
| 223 | + | for (name, path, description, url) in rows { |
|
| 224 | 224 | let depends_on = sqlx::query_scalar("SELECT depends_on FROM project_relations WHERE project = ? ORDER BY 1") |
|
| 225 | 225 | .bind(&name) |
|
| 226 | 226 | .fetch_all(pool) |
|
| … | 9 unchanged lines |
||
| 236 | 236 | .fetch_all(pool) |
|
| 237 | 237 | .await |
|
| 238 | 238 | .unwrap_or_default(); |
|
| 239 | - | out.push(api::Project { name, path, remote, depends_on, depended_on_by, agents }); |
|
| 239 | + | out.push(api::Project { name, path, description, url, depends_on, depended_on_by, agents }); |
|
| 240 | 240 | } |
|
| 241 | 241 | json_etag(&headers, &out) |
|
| 242 | 242 | } |
|
| … | 277 unchanged lines |
||
| 520 | 520 | } |
|
| 521 | 521 | }) |
|
| 522 | 522 | .take(q.max.unwrap_or(usize::MAX)); |
|
| 523 | - | return Sse::new(stream).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response(); |
|
| 523 | + | // A comment first: the follower knows it is subscribed, so it can |
|
| 524 | + | // read the state and miss nothing that happens after. |
|
| 525 | + | let hello = futures::stream::once(async { Ok::<_, std::convert::Infallible>(SseEvent::default().comment("subscribed")) }); |
|
| 526 | + | return Sse::new(hello.chain(stream)).keep_alive(KeepAlive::new().interval(Duration::from_secs(15))).into_response(); |
|
| 524 | 527 | } |
|
| 525 | 528 | let limit = q.limit.unwrap_or(100).clamp(1, 1000); |
|
| 526 | 529 | let from = q.after.map(|a| a + 1).unwrap_or(1); |
|
| … | 12 unchanged lines |
modified crates/spectra-mcp/src/client.rs +1 −1
| … | 197 unchanged lines |
||
| 198 | 198 | ("spectra-key", p.sig.key.to_hex()), |
|
| 199 | 199 | ("spectra-time", p.time_ms.to_string()), |
|
| 200 | 200 | ("spectra-nonce", hex::encode(p.nonce)), |
|
| 201 | - | ("spectra-signature", hex::encode(oxy_core::cbor::encode(&p.sig.to_value()))), |
|
| 201 | + | ("spectra-signature", hex::encode(spectra_core::cbor::encode(&p.sig.to_value()))), |
|
| 202 | 202 | ] |
|
| 203 | 203 | } |
|
| 204 | 204 | ||
| … | 17 unchanged lines |
modified crates/spectra-mcp/src/http.rs +1 −1
| … | 116 unchanged lines |
||
| 117 | 117 | let time_ms = get("spectra-time")?.parse().ok()?; |
|
| 118 | 118 | let nonce: [u8; 16] = hex::decode(get("spectra-nonce")?).ok()?.try_into().ok()?; |
|
| 119 | 119 | let sig_bytes = hex::decode(get("spectra-signature")?).ok()?; |
|
| 120 | - | let sig = oxy_core::cbor::decode(&sig_bytes).ok().and_then(|v| oxy_core::identity::Signature::from_value(&v).ok())?; |
|
| 120 | + | let sig = spectra_core::cbor::decode(&sig_bytes).ok().and_then(|v| spectra_core::identity::Signature::from_value(&v).ok())?; |
|
| 121 | 121 | if sig.key.to_hex() != key { |
|
| 122 | 122 | return None; |
|
| 123 | 123 | } |
|
| … | 142 unchanged lines |
modified crates/spectra-mcp/src/instance.rs +1 −1
| … | 2 unchanged lines |
||
| 3 | 3 | //! check a client makes of it. |
|
| 4 | 4 | ||
| 5 | 5 | use anyhow::{bail, Context, Result}; |
|
| 6 | - | use oxy_core::identity::Identity; |
|
| 7 | 6 | use serde_json::{json, Value as Json}; |
|
| 8 | 7 | use spectra_core::event::Event; |
|
| 8 | + | use spectra_core::identity::Identity; |
|
| 9 | 9 | use spectra_core::keys::KeyPair; |
|
| 10 | 10 | use spectra_core::ledger::Ledger; |
|
| 11 | 11 | use spectra_core::proof::Proof; |
|
| … | 68 unchanged lines |
modified docs/plan.md +20 −10
| 1 | 1 | # Sprint plan: Spectra 0.1, Talk |
|
| 2 | 2 | ||
| 3 | - | Each loop iteration: pick the first unchecked item, implement it with tests, run the gated checks, commit with `oxy`, tick the box here with a one-line note, and commit again. Update `docs/spec.md` (and its changelog) whenever behaviour the spec describes changes. If an item cannot be finished in two iterations, note why under it and move on. The issues are on https://oxygenhub.io/projects/andymccall/7. Sprint 1 built 0.1 on one machine; Sprint 2 makes it client and server (spec §8.6), since Andy runs the daemon on Earth and everything else on his workstation. |
|
| 3 | + | Each loop iteration: pick the first unchecked item, implement it with tests, run the gated checks, commit with `oxy`, tick the box here with a one-line note, and commit again. Update `docs/spec.md` (and its changelog) whenever behaviour the spec describes changes. If an item cannot be finished in two iterations, note why under it and move on. The issues are on https://oxygenhub.io/projects/andymccall/7. Sprint 1 built 0.1 on one machine; Sprint 2 made it client and server (spec §8.6), since Andy runs the daemon on Earth and everything else on his workstation; Sprint 3 makes Spectra its own program, with nothing of Oxygen in it (spec Draft 0.5). |
|
| 4 | 4 | ||
| 5 | 5 | ## Rules |
|
| 6 | 6 | ||
| … | 3 unchanged lines |
||
| 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. |
|
| 13 | - | - Never act as Andy: owner-only steps (delegating to an agent key, the acceptance run) wait for him. |
|
| 13 | + | - Never act as Andy: owner-only steps (creating his identity, confirming a delegation, the acceptance run) wait for him. |
|
| 14 | + | - Spectra uses Oxygen in no way: no `oxy` command, library or file in the product. This repository is still kept with `oxy` and built on OxygenFlow; that is how it is developed, not what it is. |
|
| 14 | 15 | ||
| 15 | 16 | ## Sprint 1 (done) |
|
| 16 | 17 | ||
| … | 19 unchanged lines |
||
| 36 | 37 | - [x] #23 The CLI without a ledger file — done 2026-11-03: every command through the API when a daemon serves, local or remote; admin verify via the server; tests/client.rs runs them all from a workstation with no data directory. |
|
| 37 | 38 | - [x] #24 Radar from a workstation — done 2026-11-03: spectra open serves a loopback proxy to the pinned server and signs in through it; tests/client.rs; checked by hand in Chrome on a demo server with a real oxy identity (sign-in, live update from the CLI, a post from the compose box, verify). |
|
| 38 | 39 | - [x] #25 Run the daemon on Earth — done 2026-11-03: make install-server, a unit that waits for a Spectra, make install copies; Earth runs `spectra serve` on [::]:7350 as the claude account from boot, set up with `spectra init --owner d89664ced1fc…` (Andy's public KeyId) and waiting for his claim; /api/v1/instance answers from the workstation. |
|
| 40 | + | - #13 Acceptance moved to Sprint 3, after Spectra stood alone. |
|
| 41 | + | ||
| 42 | + | ## Sprint 3: Spectra stands alone |
|
| 43 | + | ||
| 44 | + | - [x] #26 Own identities, signatures and CBOR — done 2026-11-03: spectra-core's identity, cbor and durable modules (designed after Oxygen's, no shared code); spectra identity create; init, agent add (confirm, --expires, renew, disable) and connect (confirm or --expect) sign the delegation themselves; deps/oxygen and oxy-core removed. |
|
| 45 | + | - [x] #27 A project is any kind of work — done 2026-11-03: name, optional folder never read, description, link; spectra project link replaces .oxymodules; tests/cli_project.rs adds a book. Found: inbox --follow could miss a message posted as it started; the stream now announces its subscription. |
|
| 46 | + | - [ ] #28 The companion specs without Oxygen: SpectraHub, spectra-vscode, spectrahub-vscode. |
|
| 39 | 47 | - [ ] #13 Acceptance (with Andy), across two machines. Ready when the rest is done: |
|
| 40 | - | 1. Done for Andy: Earth runs the daemon, set up for his KeyId and waiting for his claim. Before step 2, `spectra --version` here should be the release (`make install`). |
|
| 41 | - | 2. On the workstation: `spectra connect https://earth.andymccall.xyz:7350`; run the printed `oxy identity delegate add ~/.config/spectra/earth-instance.identity --scope changes`; `spectra connect` again, which claims Earth as Andy's. |
|
| 42 | - | 3. `spectra project add ~/development/oxygen` and `~/development/oxygenhub`. |
|
| 43 | - | 4. `spectra agent add claude-oxygen --kind claude-code --project oxygen --home ~/.oxy-agents/claude-oxygen`, then the printed delegation; the same for `antigravity-oxygenhub`. |
|
| 44 | - | 5. Claude Code in oxygen: `claude mcp add spectra -- spectra mcp --agent claude-oxygen --home ~/.oxy-agents/claude-oxygen`; Antigravity in oxygenhub: the README's `mcp_config.json` line with `--home` added. |
|
| 45 | - | 6. Both call `brief`, post in #general, and see each other's messages; watch with `spectra open`. |
|
| 46 | - | 7. `spectra admin verify` (Earth verifies its own ledger); on Earth, on a copy of the database, edit a row and see `spectra --data COPY admin verify` catch it. |
|
| 47 | - | 8. Cut 0.1.0 in CHANGELOG.md and tag it. |
|
| 48 | + | 1. On the workstation: `make install`; `spectra identity create --name "Andy McCall" --contact [email protected]`. |
|
| 49 | + | 2. On Earth (claude account; the KeyId only): `spectra init --owner KEYID --name earth`, `[server] bind` and `names` set, the service started. It prints Earth's instance key. |
|
| 50 | + | 3. On the workstation: `spectra connect https://earth.andymccall.xyz:7350`, confirming the instance key matches. |
|
| 51 | + | 4. `spectra project add ~/development/oxygen` and `~/development/oxygenhub`. |
|
| 52 | + | 5. `spectra agent add claude-oxygen --kind claude-code --project oxygen --home ~/.spectra-agents/claude-oxygen --expires 8h`; the same for `antigravity-oxygenhub`. |
|
| 53 | + | 6. Claude Code in oxygen: `claude mcp add spectra -- spectra mcp --agent claude-oxygen --home ~/.spectra-agents/claude-oxygen`; Antigravity in oxygenhub: the README's `mcp_config.json` line with `--home` added. |
|
| 54 | + | 7. Both call `brief`, post in #general, and see each other's messages; watch with `spectra open`. |
|
| 55 | + | 8. `spectra admin verify`; on Earth, on a copy of the database, edit a row and see `spectra --data COPY admin verify` catch it. |
|
| 56 | + | 9. Cut 0.1.0 in CHANGELOG.md and tag it. |
|
| 48 | 57 | ||
| 49 | 58 | ## Log |
|
| 50 | 59 | ||
| … | 3 unchanged lines |
||
| 54 | 63 | - 2026-11-02 Andy asked for an oxy-style CLI: every command now reports through oxy's ui module. #5 done. |
|
| 55 | 64 | - 2026-11-02 #11 and #12 done. Measured in release with 1,000 messages, round trip over the socket including signing (tests/bench.rs): brief 2.7 ms (target 5), say 3.1 ms (target 10), wait wakes 4.2 ms after a mention (target 100). A single ledger append 0.15 ms. 58 tests. #13 is ready for Andy. |
|
| 56 | 65 | - 2026-11-03 Sprint 2 set up: Andy wants the daemon on Earth and the rest on his workstation. Spec Draft 0.4 adds client and server (§8.6); #20–#25 opened, #13 moved and rewritten for two machines. The binary and an enabled, waiting user unit are already on Earth (claude account, lingering on). |
|
| 66 | + | - 2026-11-03 Andy: Spectra must be its own program, not tied to Oxygen or to programming work. Spec Draft 0.5; Sprint 3 opened with #26–#28; #13 moved again. Earth's first ledger (Oxygen identities) is set aside, not deleted. |
modified docs/spec.md +101 −84
| … | 1 unchanged lines |
||
| 2 | 2 | ||
| 3 | 3 | | | | |
|
| 4 | 4 | |---|---| |
|
| 5 | - | | **Status** | Draft 0.4 | |
|
| 5 | + | | **Status** | Draft 0.5 | |
|
| 6 | 6 | | **Date** | 2026-11-03 | |
|
| 7 | - | | **Scope** | One Rust binary, `spectra`, that AI coding agents on one machine connect to over the Model Context Protocol (MCP), from that machine or from others over TLS (§8.6), 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`). | |
|
| 7 | + | | **Scope** | One Rust binary, `spectra`, that AI agents connect to over the Model Context Protocol (MCP), from that machine or from others over TLS (§8.6), 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` | |
|
| 9 | 9 | | **Audience** | Implementers, security reviewers, and the person who runs it | |
|
| 10 | 10 | ||
| … | 25 unchanged lines |
||
| 36 | 36 | 20. [Guard rails: loops, budgets and injection](#20-guard-rails-loops-budgets-and-injection) |
|
| 37 | 37 | 21. [Security](#21-security) |
|
| 38 | 38 | 22. [Operations: configuration, storage, backup](#22-operations-configuration-storage-backup) |
|
| 39 | - | 23. [Integration with Oxygen and OxygenHub](#23-integration-with-oxygen-and-oxygenhub) |
|
| 39 | + | 23. [Integrations and SpectraHub](#23-integrations-and-spectrahub) |
|
| 40 | 40 | 24. [Performance targets](#24-performance-targets) |
|
| 41 | 41 | 25. [Milestones](#25-milestones) |
|
| 42 | 42 | 26. [Open questions](#26-open-questions) |
|
| … | 18 unchanged lines |
||
| 61 | 61 | - **AI first, through MCP, and honest about MCP.** Agents reach Spectra as an ordinary MCP server with a small set of well-named tools. MCP is stateless and gives a server no way to speak into an agent's turn uninvited, so Spectra specifies three delivery tiers (§14) and says exactly what each can and cannot do, instead of pretending to a capability the protocol lacks. |
|
| 62 | 62 | - **Rules are text, not summaries.** The house rules are Markdown files the human edits. Spectra never paraphrases them; it compiles the layers that apply to an agent, hashes the result, and hands that exact text over. Every tool result carries the hash, so an agent working on stale rules finds out on its next call. |
|
| 63 | 63 | - **Handoffs are contracts.** A task names its requester, its assignee, what is wanted, what done looks like, and optional JSON Schemas for its input and result. It moves through a fixed lifecycle. Nothing is passed by reference to someone else's context window. |
|
| 64 | - | - **Every event is signed and chained; every agent is somebody's.** The log is a hash chain whose entries carry the signature of the key that caused them, and an agent's key is a delegate of its owner's Oxygen identity (oxygen spec §14.2), so "claude-oxygen is Andy McCall's agent" is a verifiable fact in the ledger and a forged event needs the secret key of the principal it names. Actions that carry the human's authority need a passkey that no agent can use (§8.5). |
|
| 64 | + | - **Every event is signed and chained; every agent is somebody's.** The log is a hash chain whose entries carry the signature of the key that caused them, and an agent's key is a delegate of its owner's identity (§8), so "claude-oxygen is Andy McCall's agent" is a verifiable fact in the ledger and a forged event needs the secret key of the principal it names. Actions that carry the human's authority need a passkey that no agent can use (§8.5). |
|
| 65 | 65 | - **The human is a principal, not a spectator.** The human posts in channels, is assigned tasks, approves, pauses and overrides, from the same model the agents use. Radar is a window onto that model, not a separate system. |
|
| 66 | 66 | - **One binary, one directory.** `spectra serve` is the MCP server, the JSON API, the live web interface and the scheduler in one process over one data directory. A backup is a copy of that directory. |
|
| 67 | 67 | ||
| … | 1 unchanged lines |
||
| 69 | 69 | ||
| 70 | 70 | ### 2.1 Goals |
|
| 71 | 71 | ||
| 72 | - | 1. **Coordination across projects on one machine, today.** An agent in `oxygenhub` learns within a second that an agent in `oxygen` changed the wire protocol, and can ask it what to do about the pin, without the human carrying the message. |
|
| 72 | + | 1. **Coordination across projects, today.** An agent working on one part of a job learns within a second that another agent changed something it depends on, and can ask it what to do, without the human carrying the message. The job may be software, a book being restructured, a research report or anything else agents can work on: Spectra knows nothing of the work's format. |
|
| 73 | 73 | 2. **Zero-trust observability.** No agent's activity is a black box: its presence, its tool calls to Spectra with their payloads, its messages and its tasks are all visible, live and after the fact. |
|
| 74 | 74 | 3. **Immutable, exact rule delivery.** What the human wrote is what every agent reads, byte for byte, with a version it can quote. |
|
| 75 | 75 | 4. **Searchable history for agents.** "What did we decide about the pin bump last Tuesday?" is one tool call with a useful answer. |
|
| … | 5 unchanged lines |
||
| 81 | 81 | ||
| 82 | 82 | - **Running agents.** Spectra does not start, host, prompt or bill LLMs. Agents are started by their own tools (a terminal, an IDE) and connect to Spectra. Spectra is the switchboard, not the workforce. (SpectraHub may add a hosted runner later; that is a Hub question.) |
|
| 83 | 83 | - **Being the agent's memory of its own code.** Agents keep their own working context. Echo stores what was said and decided between agents, not file contents. |
|
| 84 | - | - **Replacing version control, review or CI.** Those are Oxygen, OxygenHub and OxygenFlow. Spectra links to them (§23) and never duplicates them. |
|
| 85 | - | - **A workflow graph language.** Spectra has tasks with dependencies, not a DAG DSL. If a pipeline is wanted, OxygenFlow is the pipeline. |
|
| 84 | + | - **Replacing version control, review, CI or a document store.** Spectra coordinates the agents doing the work; it does not hold the work. It depends on no other tool, and integrations with any are optional (§23). |
|
| 85 | + | - **A workflow graph language.** Spectra has tasks with dependencies, not a DAG DSL. If a pipeline is wanted, a pipeline tool is the pipeline. |
|
| 86 | 86 | - **Multi-human access control in version 1.** `spectra` serves one human, its owner. Accounts, teams and roles are SpectraHub. |
|
| 87 | 87 | ||
| 88 | 88 | ### 2.3 Lineage |
|
| 89 | 89 | ||
| 90 | - | The task lifecycle (submitted, working, input-required, completed, failed, cancelled, rejected) and the idea of an agent card come from Google's Agent2Agent protocol (A2A). The contract-shaped handoff and the per-run budget are the lessons of LangGraph (checkpointed state, human-in-the-loop interrupts) and the OpenAI Agents SDK (handoffs and guardrails as first-class objects). Group chat with a speaker-selection rule and a termination condition is AutoGen's model; Spectra keeps the room and replaces implicit termination with explicit tasks and budgets. The role-and-task vocabulary is CrewAI's, kept where it reads naturally and dropped where it hides state. The event-sourced core follows the hub's event bus and Oxygen's operation log: events say what happened and name ids; projections are rebuilt from them. The MCP surface follows `oxy mcp`: typed tools, JSON back, nothing destructive unless allowed, a bounded result. |
|
| 90 | + | The task lifecycle (submitted, working, input-required, completed, failed, cancelled, rejected) and the idea of an agent card come from Google's Agent2Agent protocol (A2A). The contract-shaped handoff and the per-run budget are the lessons of LangGraph (checkpointed state, human-in-the-loop interrupts) and the OpenAI Agents SDK (handoffs and guardrails as first-class objects). Group chat with a speaker-selection rule and a termination condition is AutoGen's model; Spectra keeps the room and replaces implicit termination with explicit tasks and budgets. The role-and-task vocabulary is CrewAI's, kept where it reads naturally and dropped where it hides state. The event-sourced core takes the lessons of operation logs in version control (Oxygen's among them): events say what happened and name ids; projections are rebuilt from them. Identities, delegation with expiry, composite post-quantum signatures and deterministic encoding are lessons from Oxygen too, implemented afresh: Spectra shares no code, file or command with it. The MCP surface keeps to typed tools, JSON back, nothing destructive unless allowed, and a bounded result. |
|
| 91 | 91 | ||
| 92 | 92 | ## 3. The problem, concretely |
|
| 93 | 93 | ||
| 94 | - | One person runs several projects that depend on each other (`oxygen` → `oxygenflow` → `oxygenhub` → the extensions and the Android app), with a different AI agent open in each, for hours at a time and overnight. Today the agents do not know of each other. The person is the message bus: they notice that `oxy` grew a flag, open the `oxygenhub` session and type it in. Rules that apply to every project (changelog lines, no `git init`, hermetic tests, how to commit with `oxy`) live in several `CLAUDE.md` files that drift. What an agent decided at 03:00 is in a scroll-back buffer nobody will read. |
|
| 94 | + | One person runs several pieces of work that depend on each other, with a different AI agent open on each, for hours at a time and overnight. It might be a set of software projects (a library, the service that uses it, the apps on top), or a book being restructured (one agent reorganising chapters, one keeping the index and cross-references straight, one checking the style guide). Today the agents do not know of each other. The person is the message bus: they notice that chapter 3 became chapter 5, open the indexing agent's session and type it in. Rules that apply to everything (the style guide, how to name files, what never to touch) live in several instruction files that drift. What an agent decided at 03:00 is in a scroll-back buffer nobody will read. |
|
| 95 | 95 | ||
| 96 | 96 | Spectra's shape follows from that: |
|
| 97 | 97 | ||
| 98 | - | - a **project** is a checkout, and agents belong to one; |
|
| 98 | + | - a **project** is a piece of work, often a folder, and agents belong to one; |
|
| 99 | 99 | - a **channel** per project and a few shared ones, with the history kept; |
|
| 100 | 100 | - a **task** is how one agent gets another to do something, and how the human gets either to; |
|
| 101 | 101 | - **rules** are layered: site, project, agent kind; |
|
| … | 37 unchanged lines |
||
| 139 | 139 | - **G4 Bounded resource use.** No sequence of tool calls can make Spectra use unbounded memory, disk, or wall-clock in a request. |
|
| 140 | 140 | - **G5 Local by default.** Out of the box Spectra listens on a Unix socket and loopback only, validates `Origin`, and authenticates every request. |
|
| 141 | 141 | - **G6 Tamper evidence.** The ledger is a hash chain with signed checkpoints: an edited, removed or reordered event is detectable by `spectra admin verify` on the machine, and by a hub holding the checkpoints, without trusting the database. |
|
| 142 | - | - **G7 Ownership.** Every agent is a delegate of a human's Oxygen identity, with a scope and an expiry the human set; Spectra shows the owner beside the agent everywhere and refuses an agent whose delegation has expired or been revoked. |
|
| 142 | + | - **G7 Ownership.** Every agent is a delegate of a human's identity, with an expiry the human set; Spectra shows the owner beside the agent everywhere and refuses an agent whose delegation has expired or been revoked. |
|
| 143 | 143 | - **G8 The human's authority is out of agents' reach.** Answering a request to the human, a permission verdict, a rule change, an identity change and pruning need an assurance level (§8.5) that a key on disk cannot provide, so an agent with a shell on the human's machine still cannot do them. |
|
| 144 | 144 | ||
| 145 | 145 | ### 5.3 Explicitly out of scope, and what is said plainly instead |
|
| … | 2 unchanged lines |
||
| 148 | 148 | - Confidentiality of the data directory against a local user with read access to it. |
|
| 149 | 149 | - Preventing an agent from doing harm with tools it has outside Spectra. Spectra can record, flag and escalate; it does not sandbox the agent's shell. |
|
| 150 | 150 | - Verifying that an agent's self-reported status is true. |
|
| 151 | - | - Recovery from a compromised key beyond revocation (`oxy identity revoke`, `oxy identity delegate remove`) and the marking of what it signed. |
|
| 151 | + | - Recovery from a compromised key beyond revocation (`spectra agent disable`, a successor identity without the delegation) and the marking of what it signed. |
|
| 152 | 152 | ||
| 153 | 153 | ## 6. Terminology and the component lexicon |
|
| 154 | 154 | ||
| 155 | 155 | | Term | Meaning | |
|
| 156 | 156 | |---|---| |
|
| 157 | 157 | | **Principal** | Something that can cause events: an **agent** or the **human** (the owner). | |
|
| 158 | - | | **Agent** | A connected AI session with its own Oxygen identity (a delegate of its owner), name and kind, belonging to a project. | |
|
| 158 | + | | **Agent** | A connected AI session with its own identity (a delegate of its owner), name and kind, belonging to a project. | |
|
| 159 | 159 | | **Kind** | The agent's software: `claude-code`, `antigravity`, `kimi-code`, `codex`, `other`. Rules can be layered by kind. | |
|
| 160 | 160 | | **Session** | A span of an agent's activity between a `brief` call (or first contact) and an idle timeout. Server-minted id; groups events. | |
|
| 161 | - | | **Project** | A directory the agents work in, usually an Oxygen checkout; has a channel, rules and relations to other projects. | |
|
| 161 | + | | **Project** | A piece of work agents share: a name, a description and optionally a folder (code, a manuscript, documents; Spectra reads nothing in it); has a channel, rules and links to other projects. | |
|
| 162 | 162 | | **Channel** | A named, persistent conversation: one per project, shared ones, direct messages. | |
|
| 163 | 163 | | **Thread** | Messages replying to a root message within a channel. | |
|
| 164 | 164 | | **Message** | An immutable post by a principal in a channel, with a kind and optional structured payload. | |
|
| 165 | 165 | | **Task** | A unit of work with a requester, an assignee, a contract and a lifecycle. | |
|
| 166 | 166 | | **Handoff** | A task whose contract carries input and result schemas; the strict form. | |
|
| 167 | - | | **Claim** | An advisory lease on a named resource ("oxygen wire protocol") with a holder and a TTL. | |
|
| 167 | + | | **Claim** | An advisory lease on a named resource ("chapter 3", "the wire protocol") with a holder and a TTL. | |
|
| 168 | 168 | | **Decision** | A recorded conclusion with a rationale, searchable, linkable, supersedable. | |
|
| 169 | 169 | | **Rules** | The house rules: layered Markdown; the **rules version** is the BLAKE3 of the compiled text an agent receives. | |
|
| 170 | 170 | | **Event** | One immutable record in the log: a sequence number, a time, a kind, an actor and a JSON payload. | |
|
| 171 | 171 | | **Brief** | The one tool an agent calls first: who it is, its rules, its unread inbox, its tasks, who else is here. | |
|
| 172 | - | | **Identity** | An Oxygen identity object (oxygen spec §14.2): a composite Ed25519 + ML-DSA-65 key pair with a name, named by its **KeyId**. The human, every agent and the Spectra instance each have one. | |
|
| 173 | - | | **Owner** | The human whose identity lists an agent's key as a **delegate** (oxygen spec §14.2), with a scope and an expiry. | |
|
| 172 | + | | **Identity** | A Spectra identity (§8.0): a composite Ed25519 + ML-DSA-65 key pair with a name and the keys it delegates to, named by its **KeyId**. The human, every agent and the Spectra instance each have one. | |
|
| 173 | + | | **Owner** | The human whose identity lists an agent's key as a **delegate**, with an optional expiry. | |
|
| 174 | 174 | | **Ledger** | The event log as a hash chain: every event carries the hash of the previous one and the proof that authenticated it, and checkpoints of the chain head are signed by the instance key (§9.5). | |
|
| 175 | 175 | | **Assurance** | How an event was authenticated: level 1 a bearer token, level 2 a signature by a key on disk, level 3 a signature with user presence (a passkey). Policy says which actions need which (§8.5). | |
|
| 176 | 176 | | **Instance key** | The identity of one `spectra` installation, made at `spectra init`, a delegate of the owner; it signs checkpoints and system events. | |
|
| … | 48 unchanged lines |
||
| 225 | 225 | Cargo.toml workspace |
|
| 226 | 226 | crates/spectra-core the model: store (sqlx, migrations), events, the ledger (chain, signatures, |
|
| 227 | 227 | checkpoints, verify), projections, bus, rules, tasks, claims, search, |
|
| 228 | - | scheduler, principals; depends on oxy-core for identities, signatures and encoding |
|
| 228 | + | scheduler, principals, and Spectra's own identities, signatures and encoding |
|
| 229 | 229 | crates/spectra-mcp the MCP server (Tuner): protocol (stateless HTTP and stdio), tools, |
|
| 230 | 230 | resources, prompts; depends on spectra-core |
|
| 231 | 231 | crates/spectra-api JSON API types with serde and JSON Schema derives, shared with |
|
| … | 15 unchanged lines |
||
| 247 | 247 | ||
| 248 | 248 | ## 8. Principals: agents and the human |
|
| 249 | 249 | ||
| 250 | + | ### 8.0 Identities |
|
| 251 | + | ||
| 252 | + | Spectra has identities of its own and needs no other program to make or use them. Every principal, the human, each agent and each Spectra instance, has one: |
|
| 253 | + | ||
| 254 | + | ``` |
|
| 255 | + | Identity { |
|
| 256 | + | ed25519_pk 32 bytes |
|
| 257 | + | mldsa65_pk 1952 bytes // ML-DSA-65 (FIPS 204) |
|
| 258 | + | name text // "Andy McCall", "claude-oxygen", "earth" |
|
| 259 | + | contact text // optional: an email, a URL |
|
| 260 | + | created_ms u64 |
|
| 261 | + | expires_ms u64? |
|
| 262 | + | previous KeyId? // the identity this one succeeds |
|
| 263 | + | delegates [{ key: KeyId, expires_ms: u64? }] |
|
| 264 | + | } |
|
| 265 | + | ``` |
|
| 266 | + | ||
| 267 | + | It is encoded as deterministic CBOR with integer keys (§9.5) and named by its **KeyId**, `BLAKE3("spectra1 identity " || encoding)`. A signature is composite: both halves sign `BLAKE3("spectra1 sign " || message)`, every message carries its own domain prefix (`spectra1 request `, `spectra1 checkpoint `, …), and a signature verifies only if both halves do, so it stays sound if either algorithm falls. Adding or removing a delegate, or changing an expiry, makes a **successor**: the same key pair, a new KeyId, `previous` naming the old one; a successor is recorded in the ledger signed by its own key, so possession is proven at each step. |
|
| 268 | + | ||
| 269 | + | An identity lives in an **identity home**: `keys/KEYID.secret` (0600), `keys/KEYID.identity`, and `identity.toml` naming the current KeyId. The owner's is `$SPECTRA_HOME`, else `$XDG_CONFIG_HOME/spectra`, else `~/.config/spectra`; each agent's is the directory `spectra agent add --home` names; the instance's is `<data>/keys`. `spectra identity create --name NAME [--contact TEXT]` makes the owner's. The design owes much to Oxygen's identities; Spectra shares no code, file or command with Oxygen and works on machines that have never heard of it. |
|
| 270 | + | ||
| 250 | 271 | ### 8.1 Agents |
|
| 251 | 272 | ||
| 252 | - | An agent is its owner's **delegate** in Oxygen's sense (oxygen spec §14.2; `oxygen/docs/ai_native_agent_workflows.md` §2): it has an identity of its own, a composite key pair in an agent home of its own, and the owner's identity object lists that key under `delegates` with a scope and an expiry. Spectra adds nothing to that model; it verifies it, and it uses the same key for everything the agent does, so the key that signs an agent's `oxy` commits is the key that signs its requests to Spectra. One agent, one key, one owner. |
|
| 273 | + | An agent is its owner's **delegate**: it has an identity of its own, a composite key pair in an agent home of its own, and the owner's identity lists that key under `delegates`, with an optional expiry. One agent, one key, one owner. |
|
| 253 | 274 | ||
| 254 | 275 | ``` |
|
| 255 | - | spectra agent add claude-oxygen --kind claude-code --project oxygen --home ~/.oxy-agents/claude-oxygen |
|
| 276 | + | spectra agent add claude-oxygen --kind claude-code --project oxygen --home ~/.spectra-agents/claude-oxygen [--expires 8h] |
|
| 256 | 277 | ``` |
|
| 257 | 278 | ||
| 258 | - | 1. Refuses a name already taken. Creates the agent's key pair and identity in `--home` (an `OXY_HOME`, laid out as `oxy identity create` lays one out, through `oxy-core`), unless `--identity FILE` names an identity the agent already has. The secret key never leaves that directory and is never read by `spectra serve`. |
|
| 259 | - | 2. Prints the one command the owner runs to make it theirs, and waits for it (`--wait`, ten minutes by default; running `add` again resumes with the same key): `oxy identity delegate add ~/.oxy-agents/claude-oxygen/identity --scope changes --expires 8h`. The owner's current identity (§8.3) must list the key before the agent is recorded; `spectra agent add` refuses to finish otherwise, so there is no such thing as an agent without an owner. |
|
| 260 | - | 3. Records `agent.added` in the ledger with the agent's identity object, its owner's KeyId, and the delegation as found. |
|
| 279 | + | 1. Refuses a name already taken. Creates the agent's key pair and identity in `--home` unless one is there already. The secret key never leaves that directory and is never read by `spectra serve`. |
|
| 280 | + | 2. Shows the agent, its key and the expiry, and asks the owner to confirm (`--yes` for scripts). Then signs a successor of the owner's identity that delegates to the agent's key, with the expiry if one was given. There is no such thing as an agent without an owner, and no second program to run. |
|
| 281 | + | 3. Records the owner's successor (`identity.rotated`) and `agent.added`, with the agent's identity and the delegation, in one batch. |
|
| 261 | 282 | ||
| 262 | 283 | ``` |
|
| 263 | 284 | Agent { |
|
| … | 5 unchanged lines |
||
| 269 | 290 | owner KeyId // the human whose identity delegates to `key` |
|
| 270 | 291 | project Project id |
|
| 271 | 292 | scopes [scope] // §8.4 |
|
| 272 | - | home path // the agent's OXY_HOME, for the shims |
|
| 293 | + | home path // the agent's identity home, for the shims |
|
| 273 | 294 | uid u32? // the Unix user the key may be used from (§8.5) |
|
| 274 | 295 | created_ms, disabled_ms? |
|
| 275 | 296 | } |
|
| 276 | 297 | ``` |
|
| 277 | 298 | ||
| 278 | - | **Ownership is shown everywhere.** Radar, the CLI and every tool result name an agent as `claude-oxygen · Andy McCall's agent`, from the owner identity's `name`; on a hub, with the account the identity is linked to. An event made by an agent carries the agent's key and, in projections, its owner, as OxygenHub shows "Andy McCall · via Claude Code". |
|
| 299 | + | **Ownership is shown everywhere.** Radar, the CLI and every tool result name an agent as `claude-oxygen · Andy McCall's agent`, from the owner identity's `name`; on a hub, with the account the identity is linked to. An event made by an agent carries the agent's key and, in projections, its owner. |
|
| 279 | 300 | ||
| 280 | - | **Expiry and revocation are Oxygen's.** Authentication (§8.5) checks that the owner's current identity still delegates to the agent's key at the event's time (`delegates_to`, with `expires`). An expired delegation is refused with a message naming the `oxy identity delegate add` to run; an overnight agent given `--expires 8h` stops being able to act in the morning without anyone remembering to switch it off. A delegation removed in a successor identity, or a `revoke` attestation, disables the agent, and `spectra admin verify` (§9.5) marks what it signed as *valid but revoked*, as `oxy verify` does. The owner's successor identities reach Spectra through `spectra identity refresh`, which `agent add` and `agent disable` also run: it reads the current identity from the owner's home, follows `previous` back to the recorded owner, and records it signed with that identity's own key, so possession is proven at every step. A successor must keep the key pair; a fresh one (`oxy identity rotate`) needs its rotation attestation, which 0.1 does not accept yet. |
|
| 301 | + | **Expiry and revocation.** Authentication (§8.5) checks that the owner's current identity still delegates to the agent's key at the event's time. An expired delegation is refused with a message naming `spectra agent renew NAME --expires …`; an overnight agent given `--expires 8h` stops being able to act in the morning without anyone remembering to switch it off. `spectra agent disable NAME` records a successor without the delegation and `agent.disabled`; `spectra admin verify` (§9.5) marks what the agent signed before as *valid but revoked*. The owner's successors are recorded as they are made; `spectra identity refresh` records any the ledger has missed, following `previous` back to the recorded owner. A successor must keep the key pair; a fresh key pair needs a rotation statement signed by both, which 0.1 does not accept yet. |
|
| 281 | 302 | ||
| 282 | 303 | An agent MAY belong to one project. An agent that works across several (a "release" agent) is given the `projects:all` scope rather than several projects, so attribution stays one name. |
|
| 283 | 304 | ||
| … | 17 unchanged lines |
||
| 301 | 322 | ||
| 302 | 323 | ### 8.3 The human |
|
| 303 | 324 | ||
| 304 | - | `spectra` has one human, the **owner**: an Oxygen identity. `spectra init` reads `oxy identity export` from the owner's own `OXY_HOME`, records it in the ledger (`identity.owner`), and names it in `spectra.toml` (`[owner] key`); the display name comes from the identity. The owner is a principal: messages and tasks the owner creates carry `actor = human`, and agents see them as from `@andy`. |
|
| 325 | + | `spectra` has one human, the **owner**: a Spectra identity (§8.0), made with `spectra identity create`. `spectra init` reads it from the owner's identity home, records it in the ledger (`identity.owner`) signed by itself, and names it in `spectra.toml` (`[owner] key`); the display name comes from the identity. The owner is a principal: messages and tasks the owner creates carry `actor = human`, and agents see them as from `@andy`. |
|
| 305 | 326 | ||
| 306 | 327 | The owner acts in two ways, with different assurance (§8.5): |
|
| 307 | 328 | ||
| 308 | - | - **From the CLI**, signing each request with the owner's own key from `OXY_HOME`, as `oxy` signs a change: level 2. Enough for chat and tasks. |
|
| 309 | - | - **From Radar**, signed in with a **passkey** (WebAuthn, user verification required): registered at `spectra init` through a one-time setup code printed to the terminal, as OxygenHub's first account is made; its credential id and public key are in the ledger (`identity.passkey`). Signing in asserts it once; each action that carries the human's authority (§8.5) asserts it again, and the assertion is the event's proof: level 3. A passkey lives in the platform authenticator or a hardware key, needs the person's presence, and cannot be read from disk, which is what makes G8 true on a machine where agents share the human's user. |
|
| 329 | + | - **From the CLI**, signing each request with the owner's own key from their identity home: level 2. Enough for chat and tasks. |
|
| 330 | + | - **From Radar**, signed in with a **passkey** (WebAuthn, user verification required): registered at `spectra init` through a one-time setup code printed to the terminal; its credential id and public key are in the ledger (`identity.passkey`). Signing in asserts it once; each action that carries the human's authority (§8.5) asserts it again, and the assertion is the event's proof: level 3. A passkey lives in the platform authenticator or a hardware key, needs the person's presence, and cannot be read from disk, which is what makes G8 true on a machine where agents share the human's user. |
|
| 310 | 331 | ||
| 311 | 332 | There is no owner token. A client that can neither sign nor assert a passkey is not the owner. Until the passkey exists (0.1 ships without it, §25), `spectra serve --open` signs the owner in to Radar with a one-time link that the CLI signs with the owner's key: a level-2 session, with a standing banner, in which nothing the policy puts at level 3 can be done. |
|
| 312 | 333 | ||
| … | 5 unchanged lines |
||
| 318 | 339 | ||
| 319 | 340 | **Transports.** Local principals reach Spectra over the Unix socket `<data>/spectra.sock`: the shims (`spectra mcp`, `spectra channel`, `spectra hook`, `spectra trigger`) and the CLI connect there, and the daemon reads the peer's uid and pid (`SO_PEERCRED`) on every connection. The loopback TCP port serves Radar (browsers cannot open sockets) and, when tokens are allowed, clients that connect by HTTP alone. Both carry the same HTTP and the same MCP endpoint. |
|
| 320 | 341 | ||
| 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. |
|
| 342 | + | **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` (a composite signature, §8.0, over `"spectra1 request " || method || 0x00 || BLAKE3(body) || time || nonce`). 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 | 343 | ||
| 323 | 344 | **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 | 345 | ||
| … | 15 unchanged lines |
||
| 340 | 361 | ||
| 341 | 362 | **The server.** `[server] bind` names a non-loopback address and `[server] tls = "auto"` (the default once the bind is not loopback) makes the daemon serve HTTPS with a certificate of its own: a P-256 key and a self-signed certificate in `<data>/keys/tls.key` and `tls.crt`, made on first start, naming `[server] names` (default: the host name and its fully qualified name). The instance key vouches for the certificate with an `instance.tls` event, signed over its payload as CLI events are (§9.5), naming the SHA-256 of the certificate's SubjectPublicKeyInfo and its names; the daemon appends one whenever the certificate differs from the last recorded, and `spectra admin verify` checks it like any signed event. `tls = { cert = "…", key = "…" }` uses a certificate from elsewhere; the binding is signed the same way. A certificate authority is never needed: clients trust the instance key, not a CA. `GET /api/v1/instance` answers without authentication with the instance's name and identity, the owner's key, and the latest `instance.tls` payload with its proof. |
|
| 342 | 363 | ||
| 343 | - | **Connecting.** `spectra connect https://HOST:PORT` reads the presented certificate and `/api/v1/instance`, and accepts the server only when the binding is signed by the instance key and the owner's own identity (their `OXY_HOME`) delegates to that instance key now. Until the owner has delegated, it saves the instance's public identity and prints the `oxy identity delegate add` that would; it never asks the owner to trust a fingerprint by eye. On success it writes the client configuration, `$XDG_CONFIG_HOME/spectra/client.toml`: |
|
| 364 | + | **Connecting.** `spectra connect https://HOST:PORT` reads the presented certificate and `/api/v1/instance`, and accepts the server only when the binding is signed by the instance key and the owner's own identity delegates to that instance key. The first time, it shows the instance's name and KeyId, which `spectra init` printed on the server, and asks the owner to confirm they match (`--expect KEYID-PREFIX` checks it without asking); then it signs a successor of the owner's identity delegating to the instance key. On success it writes the client configuration, `client.toml` in the owner's identity home: |
|
| 344 | 365 | ||
| 345 | 366 | ```toml |
|
| 346 | 367 | server = "https://earth.example:7350" |
|
| … | 39 unchanged lines |
||
| 386 | 407 | } |
|
| 387 | 408 | ``` |
|
| 388 | 409 | ||
| 389 | - | `hlc` is a hybrid logical clock as Oxygen's changes carry (oxygen spec §6.4): advanced on every event the Spectra commits or receives, never more than five minutes ahead of the local clock. Wherever events from more than one Spectra meet (mirrored threads on a hub), the order is `hlc` then `id`, so an answer cannot sort before its question because one machine's clock is behind. On one machine `seq` is the order. The envelope (`seq`, `id`, `at_ms`, `hlc`, `prev`, `hash`, `auth`, `kind`, `actor`, `session`, `project`, `subject`, `payload`, `trace`) is **frozen at 0.1**: fields may be added later, never changed or removed, so a hub built later merges logs written from the first release. |
|
| 410 | + | `hlc` is a hybrid logical clock: advanced on every event the Spectra commits or receives, never more than five minutes ahead of the local clock. Wherever events from more than one Spectra meet (mirrored threads on a hub), the order is `hlc` then `id`, so an answer cannot sort before its question because one machine's clock is behind. On one machine `seq` is the order. The envelope (`seq`, `id`, `at_ms`, `hlc`, `prev`, `hash`, `auth`, `kind`, `actor`, `session`, `project`, `subject`, `payload`, `trace`) is **frozen at 0.1**: fields may be added later, never changed or removed, so a hub built later merges logs written from the first release. |
|
| 390 | 411 | ||
| 391 | 412 | Events are never updated or deleted. Corrections are new events (`message.redacted`, `task.updated`). The owner MAY run `spectra admin prune --before DATE` (a level-3 action), which removes the payloads and proofs of events older than the date, keeps their **stubs** (`seq`, `id`, `hlc`, `prev`, `hash`, `kind`, `actor`, `auth.key`) so the chain still verifies through them (§9.5), **and** rebuilds every projection from what remains, so the invariant "projections are a function of the log" holds after pruning too. Pruning is itself an event, `admin.pruned`, signed by the owner. |
|
| 392 | 413 | ||
| … | 4 unchanged lines |
||
| 397 | 418 | | `identity.owner`, `identity.rotated`, `identity.passkey`, `identity.instance`, `identity.revoked` | an identity object, its predecessor, a passkey credential, a revoke attestation | `spectra init`, `spectra identity refresh`, Radar | |
|
| 398 | 419 | | `agent.added`, `agent.disabled`, `agent.presence`, `agent.status`, `agent.session_started`, `agent.session_ended` | agent id, identity, owner, delegation, state, text, task | owner, Tuner, presence timer | |
|
| 399 | 420 | | `ledger.checkpoint`, `ledger.verified` | seq, hash, hlc, count, the instance signature; a verification's findings | the ledger (§9.5) | |
|
| 400 | - | | `rules.committed` | layer, version, text hash, `oxy_change?` | owner (§12.4) | |
|
| 421 | + | | `rules.committed` | layer, version, text hash | owner (§12.4) | |
|
| 401 | 422 | | `ref.verified` | a referenced change's hash, signer, author, status | §23.5 | |
|
| 402 | 423 | | `policy.weakened`, `policy.restored` | class, level | owner | |
|
| 403 | 424 | | `project.added`, `project.updated`, `project.relation` | name, path, remote, depends_on | owner, CLI | |
|
| … | 23 unchanged lines |
||
| 427 | 448 | ||
| 428 | 449 | ### 9.5 The ledger: chain, proofs, checkpoints, verification |
|
| 429 | 450 | ||
| 430 | - | The log is tamper-evident the way Oxygen's history is: by hashing, not by trusting the database. |
|
| 451 | + | The log is tamper-evident by hashing, not by trusting the database. |
|
| 431 | 452 | ||
| 432 | - | - **Canonical encoding.** An event is encoded as deterministic CBOR with integer keys, exactly as Oxygen encodes its objects (oxygen spec §5.1, through `oxy-core`), and hashed as `BLAKE3("spectra1 event " || decimal(len) || 0x00 || body)`, the domain-separated form of oxygen spec §5.2, with `hash` left out of the body. JSON, in the API and the export, is a projection of that encoding, never the thing hashed. |
|
| 453 | + | - **Canonical encoding.** An event is encoded as deterministic CBOR (RFC 8949 §4.2: shortest integer forms, definite lengths, map keys sorted by their encoding) with integer keys, by Spectra's own encoder, and hashed as `BLAKE3("spectra1 event " || decimal(len) || 0x00 || body)`, with `hash` left out of the body. JSON, in the API and the export, is a projection of that encoding, never the thing hashed. |
|
| 433 | 454 | - **The chain.** `prev` is the previous event's `hash`; the first event's is zero. An edit to any field of any event changes its hash and breaks every later `prev`. Removing or reordering events does the same. A pruned event's stub keeps `prev` and `hash`, so the chain verifies across it. |
|
| 434 | 455 | - **Proofs.** `auth.proof` is the request signature (§8.5) or the passkey assertion that caused the event, with the hash of what was signed. An event the CLI writes itself is signed with `method = "cli"` over the event's canonical payload, so its proof binds its content too. A signed event's `actor` must be the principal its key is (`agent:NAME`, `human` for the owner, `system` for the instance): an agent's key never makes an event in another's name. Verifying it needs the signer's identity and, for an agent, the owner's identity that delegated to it at the event's `hlc`; both are themselves events in the ledger (`identity.*`, `agent.added`), so verification is closed over the ledger and needs nothing else. |
|
| 435 | - | - **Checkpoints.** Every 256 events, every ten minutes while events are written, and at a clean shutdown, the daemon appends `ledger.checkpoint { seq, hash, hlc, count }` signed by the **instance key**: an Oxygen identity made at `spectra init` (`keys/instance.secret`, 0600; its identity object is the `identity.instance` event), which the owner delegates to with `--scope changes` so a checkpoint is verifiably from the owner's machine. Checkpoints go up the uplink (§23.3) and, when `[oxygenhub] url` is set, into OxygenHub's transparency log (hub spec §13.5.3) as `spectra-checkpoint` entries, so the machine cannot later show a different history than the one it checkpointed. |
|
| 436 | - | - **Verification.** `spectra admin verify [--from SEQ] [--json]` recomputes every hash, checks every `prev`, verifies every proof against the identity and delegation valid at its `hlc`, verifies every checkpoint's signature, and reports: chain breaks, events without a valid proof, events signed by a since-revoked or expired delegate (*valid but revoked*, as `oxy verify` says), and events whose assurance was below the policy of their day. Exit 0 when clean, 1 otherwise. It runs nightly, records `ledger.verified`, and Radar's Log page shows the last result and the head's hash. `spectra admin export` writes the canonical encoding, so an export verifies anywhere `spectra` runs. |
|
| 437 | - | - **Signatures are outside the hash** (oxygen spec §5.4): a checkpoint's signature, and a proof, are carried beside the hashed body, so re-signing never changes a hash. |
|
| 456 | + | - **Checkpoints.** Every 256 events, every ten minutes while events are written, and at a clean shutdown, the daemon appends `ledger.checkpoint { seq, hash, hlc, count }` signed by the **instance key**: an identity made at `spectra init` (`keys/instance.secret`, 0600; its identity object is the `identity.instance` event), which the owner delegates to so a checkpoint is verifiably from the owner's machine. Checkpoints go up the uplink (§23.3) and, when `[witness] url` is set, to an append-only log of the owner's choosing, so the machine cannot later show a different history than the one it checkpointed. |
|
| 457 | + | - **Verification.** `spectra admin verify [--from SEQ] [--json]` recomputes every hash, checks every `prev`, verifies every proof against the identity and delegation valid at its `hlc`, verifies every checkpoint's signature, and reports: chain breaks, events without a valid proof, events signed by a since-revoked or expired delegate (*valid but revoked*), and events whose assurance was below the policy of their day. Exit 0 when clean, 1 otherwise. It runs nightly, records `ledger.verified`, and Radar's Log page shows the last result and the head's hash. `spectra admin export` writes the canonical encoding, so an export verifies anywhere `spectra` runs. |
|
| 458 | + | - **Signatures are outside the hash**: a checkpoint's signature, and a proof, are carried beside the hashed body, so re-signing never changes a hash. |
|
| 438 | 459 | ||
| 439 | - | What this gives: a message, task or decision in Spectra is attributable to a key, that key to an owner, and the whole record to a point in time witnessed outside the machine. What it does not give: protection against the holder of a secret key, which is Oxygen's limit too (§5.3). |
|
| 460 | + | What this gives: a message, task or decision in Spectra is attributable to a key, that key to an owner, and the whole record to a point in time witnessed outside the machine. What it does not give: protection against the holder of a secret key, which no signature scheme can give (§5.3). |
|
| 440 | 461 | ||
| 441 | 462 | ## 10. Conversation: channels, threads and messages |
|
| 442 | 463 | ||
| … | 30 unchanged lines |
||
| 473 | 494 | body text (Markdown), at most 64 KiB |
|
| 474 | 495 | payload JSON?, at most 64 KiB // structured content for request, answer, handoff |
|
| 475 | 496 | mentions [principal] // parsed from @name, plus explicit |
|
| 476 | - | refs [ref] // task:ID, decision:ID, oxy:change:HASH, hub URL, parsed and explicit |
|
| 497 | + | refs [ref] // task:ID, decision:ID, any URL, parsed and explicit |
|
| 477 | 498 | attachments [{ name, blake3, bytes, media_type }] |
|
| 478 | 499 | at_ms |
|
| 479 | 500 | } |
|
| … | 101 unchanged lines |
||
| 581 | 602 | ||
| 582 | 603 | ### 12.4 Provenance of rules |
|
| 583 | 604 | ||
| 584 | - | A rule file on disk can be edited by anything running as the owner's user, an agent included, and the file watcher cannot say who. So a layer takes effect only when the owner **commits** it: `spectra rules commit [LAYER]` from the CLI (level 2) or Save in Radar's editor (level 3) records `rules.committed { layer, version, text }` in the ledger, and the compiled rules an agent receives are built from the ledger's committed text, never from the file directly. A file that differs from its committed text is shown as *uncommitted* in Radar and in `spectra rules show`, with a diff, and changes nothing until committed. When the file is in an Oxygen checkout (`<project>/.spectra/rules.md`, the recommended place for project rules, and a `rules` repository for the site layer), the commit records the `oxy` change hash that holds the text, so a rule's provenance is a signed change as well as a ledger entry. The policy class for committing is `rules` (§8.5), level 3 by default. |
|
| 605 | + | A rule file on disk can be edited by anything running as the owner's user, an agent included, and the file watcher cannot say who. So a layer takes effect only when the owner **commits** it: `spectra rules commit [LAYER]` from the CLI (level 2) or Save in Radar's editor (level 3) records `rules.committed { layer, version, text }` in the ledger, and the compiled rules an agent receives are built from the ledger's committed text, never from the file directly. A file that differs from its committed text is shown as *uncommitted* in Radar and in `spectra rules show`, with a diff, and changes nothing until committed. The ledger entry, signed by the owner, is the rule's provenance; where the file also lives in version control, that history is a second record Spectra neither needs nor reads. The policy class for committing is `rules` (§8.5), level 3 by default. |
|
| 585 | 606 | ||
| 586 | 607 | ### 12.5 What rules are for |
|
| 587 | 608 | ||
| … | 97 unchanged lines |
||
| 685 | 706 | ### 15.1 Transports and revisions |
|
| 686 | 707 | ||
| 687 | 708 | - **Streamable HTTP** at `POST /mcp`, protocol revisions `2026-07-28` (preferred), `2025-11-25` and `2025-06-18`. The 2026-07-28 rules apply: `MCP-Protocol-Version`, `Mcp-Method` and `Mcp-Name` headers are required and validated against the body (400 and `-32020` on mismatch); `server/discover` answers with versions, capabilities, identity and instructions; `subscriptions/listen` carries `tools/list_changed` and resource updates; there are no sessions and no `Mcp-Session-Id`; list results carry `ttlMs` and `cacheScope: "private"`; tools are listed in a fixed order so the client's prompt cache holds. For the older revisions the endpoint accepts `initialize` and ignores a session header it did not mint. |
|
| 688 | - | - **stdio**: `spectra mcp --agent NAME` is a shim that speaks stdio to the client (revisions `2025-11-25`, `2025-06-18`, `2025-03-26`, as `oxy mcp` does) and forwards each request to the daemon over the Unix socket, signed with the agent's key from its home (§8.5). It is the standard way in: every client starts stdio servers, the configuration is one line with nothing secret in it, and every event the agent causes carries its signature. |
|
| 709 | + | - **stdio**: `spectra mcp --agent NAME` is a shim that speaks stdio to the client (revisions `2025-11-25`, `2025-06-18`, `2025-03-26`) and forwards each request to the daemon over the Unix socket, signed with the agent's key from its home (§8.5). It is the standard way in: every client starts stdio servers, the configuration is one line with nothing secret in it, and every event the agent causes carries its signature. |
|
| 689 | 710 | - **Identity** is the request signature (§8.5), or a bearer token where `[security] allow_tokens` permits one. A request that authenticates as nobody is 401 with a JSON-RPC error naming the reason (unsigned, stale, replayed, an unknown key, an expired or missing delegation, a key bound to another Unix user): every reason concerns the caller's own key, and an agent whose delegation expired must be able to tell its owner so. |
|
| 690 | 711 | ||
| 691 | 712 | Adding Spectra to Claude Code, for one agent: |
|
| 692 | 713 | ||
| 693 | 714 | ```sh |
|
| 694 | - | spectra agent add claude-oxygen --kind claude-code --project oxygen --home ~/.oxy-agents/claude-oxygen |
|
| 695 | - | oxy identity delegate add ~/.oxy-agents/claude-oxygen/identity --scope changes --expires 8h # as the owner |
|
| 715 | + | spectra agent add claude-oxygen --kind claude-code --project oxygen --home ~/.spectra-agents/claude-oxygen --expires 8h |
|
| 696 | 716 | claude mcp add spectra -- spectra mcp --agent claude-oxygen |
|
| 697 | 717 | ``` |
|
| 698 | 718 | ||
| … | 58 unchanged lines |
||
| 757 | 777 | ||
| 758 | 778 | ```toml |
|
| 759 | 779 | [[hooks]] |
|
| 760 | - | name = "oxygenhub" |
|
| 761 | - | secret_env = "SPECTRA_HOOK_OXYGENHUB" # keyed-BLAKE3 signature header, as OxygenHub sends it |
|
| 780 | + | name = "forge" |
|
| 781 | + | secret_env = "SPECTRA_HOOK_FORGE" # HMAC-SHA256 of the body in X-Spectra-Signature: sha256=HEX |
|
| 762 | 782 | match = { event = "push", repo = "andymccall/oxygen" } |
|
| 763 | - | action = { say = { channel = "#oxygen", body = "Push to {{bookmark}} by {{pusher}}: {{summary}}" } } |
|
| 783 | + | action = { say = { channel = "#oxygen", body = "Push to {{branch}} by {{pusher}}: {{summary}}" } } |
|
| 764 | 784 | ||
| 765 | 785 | [[hooks]] |
|
| 766 | - | name = "oxygenhub-release" |
|
| 767 | - | secret_env = "SPECTRA_HOOK_OXYGENHUB" |
|
| 768 | - | match = { event = "release.published", repo = "andymccall/oxygen" } |
|
| 769 | - | action = { delegate = { to = "claude-oxygenhub", title = "Bump the oxygen pin to {{tag}}", kind = "task" } } |
|
| 786 | + | name = "editor-notes" |
|
| 787 | + | secret_env = "SPECTRA_HOOK_EDITOR" |
|
| 788 | + | match = { event = "comment" } |
|
| 789 | + | action = { delegate = { to = "claude-manuscript", title = "Address the editor's note on {{chapter}}", kind = "task" } } |
|
| 770 | 790 | ``` |
|
| 771 | 791 | ||
| 772 | - | Bodies are templated with the event's fields; unknown fields render empty. A route with no `secret_env` accepts only loopback. Every receipt is `hook.received` with a 4 KiB summary, so Radar shows what came in even when no route matched. The **relay** is a special route the other way: `[relay] url` and `secret_env` name an outside endpoint (a Telegram or Discord bridge, a phone app through SpectraHub) that human requests (§11.5) and permission prompts (§14.2) are POSTed to, and `POST /hooks/relay` takes the answers back. |
|
| 792 | + | Any system that can POST JSON and sign it with a shared secret can drive a route: a code forge, a document editor, a form, a calendar. Bodies are templated with the event's fields; unknown fields render empty. A route with no `secret_env` accepts only loopback. Every receipt is `hook.received` with a 4 KiB summary, so Radar shows what came in even when no route matched. The **relay** is a special route the other way: `[relay] url` and `secret_env` name an outside endpoint (a Telegram or Discord bridge, a phone app through SpectraHub) that human requests (§11.5) and permission prompts (§14.2) are POSTed to, and `POST /hooks/relay` takes the answers back. |
|
| 773 | 793 | ||
| 774 | 794 | ## 17. Radar: the human interface |
|
| 775 | 795 | ||
| 776 | 796 | ### 17.1 Theme and shell |
|
| 777 | 797 | ||
| 778 | - | Radar uses the same look as OxygenHub's default (Primer colours, light by default with `dark` and `auto` in a `theme` cookie, the system font stack, rounded boxes, underline navigation, sentence case), with Spectra's own mark: a prism splitting one line into a band of colours, as `assets/icons/spectra.svg`, shared with the VS Code extensions. The stylesheet is hand-written and compiled into the binary; fonts are system fonts, so nothing loads from the network. `header` (wordmark, search box, the human inbox count, theme switch) · `.band` (Live · Channels · Tasks · Agents · Decisions · Rules · Schedules · Log · Settings) · `main` at 1180px · `footer`. |
|
| 798 | + | Radar uses Primer colours (light by default with `dark` and `auto` in a `theme` cookie, the system font stack, rounded boxes, underline navigation, sentence case) and Spectra's own mark: a prism splitting one line into a band of colours, as `assets/icons/spectra.svg`, shared with the VS Code extensions. The stylesheet is hand-written and compiled into the binary; fonts are system fonts, so nothing loads from the network. `header` (wordmark, search box, the human inbox count, theme switch) · `.band` (Live · Channels · Tasks · Agents · Decisions · Rules · Schedules · Log · Settings) · `main` at 1180px · `footer`. |
|
| 779 | 799 | ||
| 780 | 800 | Every page has a `<title>` of the form `<thing> · Spectra`. |
|
| 781 | 801 | ||
| … | 12 unchanged lines |
||
| 794 | 814 | | `/schedules` | Schedules with next and last firing; create, disable, delete | |
|
| 795 | 815 | | `/log` | The ledger, filterable by kind, actor, project, time; every event expandable to its payload, hash and proof; the head's hash and the last verification at the top; `?follow=1` tails it | |
|
| 796 | 816 | | `/search` | Echo's search as a page, with the same filters | |
|
| 797 | - | | `/settings` | The owner's identity and passkey, the instance key, projects (add from a path; relations read from `.oxymodules` and editable), hooks and the relay, retention, the policy (§8.5) | |
|
| 817 | + | | `/settings` | The owner's identity and passkey, the instance key, projects (name, description, optional folder, and links between them), hooks and the relay, retention, the policy (§8.5) | |
|
| 798 | 818 | ||
| 799 | 819 | ### 17.3 Live updates |
|
| 800 | 820 | ||
| … | 12 unchanged lines |
||
| 813 | 833 | ## 19. Command-line interface |
|
| 814 | 834 | ||
| 815 | 835 | ``` |
|
| 816 | - | spectra serve [--data DIR] [--bind 127.0.0.1:7350] [--open] the daemon; --open opens Radar |
|
| 817 | - | spectra mcp --agent NAME stdio shim for one agent (§15.1) |
|
| 836 | + | spectra serve [--data DIR] [--bind ADDR] [--tls auto|on|off] [--open] the daemon; --open opens Radar |
|
| 837 | + | spectra mcp --agent NAME [--home DIR] stdio shim for one agent (§15.1) |
|
| 818 | 838 | spectra channel --agent NAME [--relay-permissions] Claude Code channel shim (§14.2) |
|
| 819 | 839 | spectra hook brief --agent NAME | stop --agent NAME --wait S | install claude-code [--agent NAME] |
|
| 820 | 840 | spectra trigger --agent NAME -- CMD… run CMD when work arrives and no session is live (§14.4) |
|
| 821 | - | spectra init [--owner-home DIR] the owner's identity, the instance key, the passkey setup code |
|
| 822 | - | spectra identity show | refresh the owner's identity as the ledger knows it; read a successor |
|
| 823 | - | spectra agent add NAME --kind KIND --project NAME --home DIR [--identity FILE] [--uid N] [--scope S..] | list | pause | unpause | disable |
|
| 824 | - | spectra project add PATH [--name N] [--remote URL] | list | relate A --depends-on B |
|
| 841 | + | spectra identity create --name NAME [--contact TEXT] the owner's own identity (§8.0) |
|
| 842 | + | spectra init [--owner-home DIR] | --owner KEYID this machine's Spectra, or a server's for its owner to claim (§8.6) |
|
| 843 | + | spectra connect https://HOST:PORT [--expect KEYID-PREFIX] [--yes] use a Spectra on another machine (§8.6) |
|
| 844 | + | spectra identity show | refresh the owner's identity as the ledger knows it; record a missed successor |
|
| 845 | + | spectra agent add NAME --kind KIND --project NAME --home DIR [--expires 8h] [--yes] [--uid N] [--scope S..] | renew NAME --expires D | list | pause | unpause | disable |
|
| 846 | + | spectra project add NAME-OR-FOLDER [--path DIR] [-d TEXT] [--url URL] | link P --depends-on Q [--remove] | list |
|
| 825 | 847 | spectra say [-c CHANNEL] [--reply-to ID] BODY as the human |
|
| 826 | 848 | spectra inbox [--follow] the human's inbox; --follow tails it |
|
| 827 | 849 | spectra task new|list|show|accept|complete|cancel|answer … as the human |
|
| … | 5 unchanged lines |
||
| 833 | 855 | spectra version [--json] |
|
| 834 | 856 | ``` |
|
| 835 | 857 | ||
| 836 | - | Every listing command takes `--json` and prints one JSON document on stdout; errors go to stderr with a non-zero status. Output uses colour and glyphs on a terminal and plain text when piped, with `--plain` and `NO_COLOR`, as `oxy` does. `spectra say` and `spectra inbox --follow` together are a usable terminal chat with the agents when Radar is not open. |
|
| 858 | + | Every listing command takes `--json` and prints one JSON document on stdout; errors go to stderr with a non-zero status. Output uses colour and glyphs on a terminal and plain text when piped, with `--plain` and `NO_COLOR`. `spectra say` and `spectra inbox --follow` together are a usable terminal chat with the agents when Radar is not open. |
|
| 837 | 859 | ||
| 838 | 860 | ## 20. Guard rails: loops, budgets and injection |
|
| 839 | 861 | ||
| … | 29 unchanged lines |
||
| 869 | 891 | ||
| 870 | 892 | ### 21.3 Keys, passkeys and tokens |
|
| 871 | 893 | ||
| 872 | - | Every principal's secret key stays in its own home: the owner's in `OXY_HOME`, each agent's in the home `spectra agent add --home` named, the instance's in `keys/instance.secret`; `spectra serve` holds only the instance key, and signs only checkpoints and system events with it. Delegations, expiries and revocations are Oxygen's and are verified from the ledger's copies of the identities. The passkey's credential is in the ledger and its private half in the authenticator. Tokens, where allowed, are stored as BLAKE3, shown once, and recorded as level-1 proof; a token in a URL is refused. Webhook secrets are read from the environment variables the routes name, never from the configuration file. |
|
| 894 | + | Every principal's secret key stays in its own home: the owner's in their identity home (§8.0), each agent's in the home `spectra agent add --home` named, the instance's in `keys/instance.secret`; `spectra serve` holds only the instance key, and signs only checkpoints and system events with it. Delegations, expiries and revocations are Oxygen's and are verified from the ledger's copies of the identities. The passkey's credential is in the ledger and its private half in the authenticator. Tokens, where allowed, are stored as BLAKE3, shown once, and recorded as level-1 proof; a token in a URL is refused. Webhook secrets are read from the environment variables the routes name, never from the configuration file. |
|
| 873 | 895 | ||
| 874 | 896 | ### 21.4 Rate limits |
|
| 875 | 897 | ||
| … | 15 unchanged lines |
||
| 891 | 913 | ||
| 892 | 914 | ```toml |
|
| 893 | 915 | [owner] |
|
| 894 | - | key = "KEYID" # the owner's Oxygen identity, recorded by `spectra init`; the name comes from it |
|
| 895 | - | home = "~/.config/oxy" # the OXY_HOME the CLI signs with |
|
| 916 | + | key = "KEYID" # the owner's identity, recorded by `spectra init`; the name comes from it |
|
| 917 | + | home = "~/.config/spectra" # the identity home the CLI signs with |
|
| 896 | 918 | ||
| 897 | 919 | [security] |
|
| 898 | 920 | allow_tokens = false # level-1 bearer tokens for clients that cannot run a shim (§8.5) |
|
| … | 8 unchanged lines |
||
| 907 | 929 | identities = 3 |
|
| 908 | 930 | admin = 3 |
|
| 909 | 931 | ||
| 910 | - | [oxygenhub] |
|
| 911 | - | # url = "https://oxygenhub.io" # where checkpoints are witnessed (§9.5) |
|
| 932 | + | [witness] |
|
| 933 | + | # url = "https://…" # an append-only log checkpoints are posted to (§9.5) |
|
| 912 | 934 | ||
| 913 | 935 | [server] |
|
| 914 | 936 | bind = "127.0.0.1:7350" # "[::]:7350" to serve other machines (§8.6) |
|
| … | 39 unchanged lines |
||
| 954 | 976 | ### 22.4 Backup and restore |
|
| 955 | 977 | ||
| 956 | 978 | Stop the daemon or use `spectra admin export` (a consistent JSONL of the event log, from a read transaction, while running), copy the data directory, and restore by copying it back; `spectra admin import` rebuilds a database from an export, and `spectra admin check` verifies that projections match the log. A SpectraHub uplink (§23.3) is also a backup of the log. |
|
| 957 | - | ||
| 958 | - | ## 23. Integration with Oxygen and OxygenHub |
|
| 959 | 979 | ||
| 960 | - | ### 23.1 Projects are checkouts |
|
| 980 | + | ## 23. Integrations and SpectraHub |
|
| 961 | 981 | ||
| 962 | - | `spectra project add PATH` records the path, reads `.oxy/` for the repository id and the `oxygenhub` remote URL when present, and reads `.oxymodules` to record `depends_on` relations to other registered projects. The project channel's purpose line links the hub page. Relations are what make "the agent in the project that depends on yours" a question Spectra can answer: `delegate` with `to: "downstream"` fans a task out to one agent per dependent project. |
|
| 982 | + | Spectra depends on no other program. Integrations are optional and none is built into the core. |
|
| 963 | 983 | ||
| 964 | - | ### 23.2 Agents sign as themselves |
|
| 984 | + | ### 23.1 Projects are any kind of work |
|
| 965 | 985 | ||
| 966 | - | An agent commits with `oxy` under the same delegated key it speaks to Spectra with (§8.1), from the same home (`OXY_HOME=<home> oxy …`, or `oxy mcp` started from it). So a push webhook from OxygenHub carrying `via` (the delegate that signed) maps straight to the agent, and a push shows in `#oxygen` as "claude-oxygen pushed 3 changes", with the same key on both sides of the story. Identities and delegations are Oxygen's; Spectra reads them and never makes its own. A project that is not in Oxygen still works: the agent's key is then known only to Spectra. |
|
| 986 | + | `spectra project add NAME [--path DIR] [-d TEXT] [--url URL]` (or `spectra project add FOLDER`, named after it) records a project: what a group of agents works on. The folder, if given, may hold code, a manuscript, a set of documents or nothing in particular; Spectra records where it is and reads nothing inside it. Projects are linked by the owner (`spectra project link P --depends-on Q`), and links are what make "the agent on the work that depends on yours" a question Spectra can answer: `delegate` with `to: "downstream"` fans a task out to one agent per dependent project. |
|
| 967 | 987 | ||
| 968 | - | ### 23.5 Verifying what an agent claims |
|
| 988 | + | ### 23.2 Integrations |
|
| 969 | 989 | ||
| 970 | - | When a message, a task result or a decision references a change (`oxy:change:HASH`), Spectra checks the claim rather than displaying it: `oxy verify --json HASH` in the project's checkout (or the hub's change API) says whether the change exists, who signed it and whether the signature verifies, and `ref.verified` records the answer. Radar shows a tick beside a reference whose change is signed by the agent that cited it, a mark beside one signed by someone else, and a warning beside one that does not verify or exist. "Done, see change X" is thereby a checkable statement, which is the point of giving agents Oxygen keys. |
|
| 990 | + | Outside systems reach Spectra through webhook routes (§16.2) and the JSON API (§18), and Spectra reaches them through the relay. That is enough for a code forge, a document editor, a chat bridge or a calendar to post into channels and create tasks. Deeper integrations, such as checking that a commit an agent cites exists and was signed by that agent, belong in separate, optional tools that use the API; the core never shells out to them or reads their files. |
|
| 971 | 991 | ||
| 972 | 992 | ### 23.3 SpectraHub uplink |
|
| 973 | 993 | ||
| 974 | 994 | `[uplink] url`: when set, `spectra serve` authenticates to the hub with the instance key and streams every committed event, with its proof, and every checkpoint to the hub (`POST /api/v1/uplink/events`, batched, resumable from the hub's last acknowledged `seq`), and takes routed events back (messages and tasks addressed to this Spectra's agents from other machines or from the hub's web and Android clients). The hub is a superset: it stores many Spectras' logs, merges on event id, and renders the same Radar over all of them. See `../spectrahub/docs/spec.md`. Nothing in this document depends on the hub. |
|
| 975 | - | ||
| 976 | - | ### 23.4 OxygenHub's assistants |
|
| 977 | - | ||
| 978 | - | OxygenHub can already ask a chat-completion endpoint to review a proposal (hub spec §13.4). That is a different thing from a Spectra agent: an assistant is a request-response call the hub makes; a Spectra agent is a live session that connects. The two meet when a review request is routed to Spectra as a task for the project's agent (a webhook route, §16.2) and the agent's answer is posted back through the hub's API. Not in 1.0; recorded here so the words stay distinct. |
|
| 979 | 995 | ||
| 980 | 996 | ## 24. Performance targets |
|
| 981 | 997 | ||
| … | 16 unchanged lines |
||
| 998 | 1014 | | **0.1 Talk** | Client and server over TLS (§8.6), `spectra connect`, remote setup with `spectra init --owner`; workspace, config, SQLite with migrations, the ledger (the frozen envelope with its hybrid logical clock, chain and proofs; `spectra admin verify`; checkpoints signed by the instance key), `spectra init` with the owner's identity, agents as delegates with ownership shown, signed requests over the Unix socket, Radar's level-2 sign-in from the CLI (§8.3), projects, channels, messages, threads, `brief`, `rules` (site layer), `say`, `read`, `inbox`, `wait`, `ack`, `agents`, `search`; Tuner over Streamable HTTP (2026-07-28 and 2025-11-25) and the stdio shim; Radar Live, Channels and Log pages with the SSE stream; `spectra say` and `spectra inbox --follow` | With the daemon on one machine and the owner and both agents on another, Claude Code in `oxygen` and Antigravity in `oxygenhub` each call `brief`, post in `#general`, and see each other's messages via `wait` within a second; the owner watches it on Live; `spectra admin verify` passes | |
|
| 999 | 1015 | | **0.2 Work** | The passkey and the assurance policy (§8.3, §8.5), since this is where the human's authority is first exercised; tasks with the full lifecycle, queues, `delegate`, `ask` and `task_update`, contracts with schema validation, claims, `human` and the human inbox, budgets, ping-pong detection, TTL expiry, the Tasks board and graph, agent pages with tool-call payloads, pause | A handoff with a `result_schema` goes from one agent to another and back with a validated result; a runaway thread is stopped by the guard and shown to the owner | |
|
| 1000 | 1016 | | **0.3 Rules and memory** | Rule layers (kind, project, agent), file watching, versions and diffs, the rule-change line on every result, `spectra rules export`, `spectra hook` with `install claude-code` and `stop --wait`, `spectra trigger`, `spectra rules commit` with the `oxy` change hash (§12.4), `ref.verified` (§23.5), decisions, the Rules and Decisions pages, resources and prompts | Editing `site.md` is noticed by every connected agent on its next call; `search` answers a question from last week's thread | |
|
| 1001 | - | | **0.4 Push and automations** | The Claude Code channel shim with permission relay, schedules, webhook routes with the OxygenHub signature, the relay, Settings page, `.oxymodules` relations, `to: "downstream"` | A push to `oxygen` on OxygenHub appears in `#oxygen` and creates a pin-bump task for the `oxygenhub` agent, who is told by channel event without polling | |
|
| 1002 | - | | **0.5 Uplink and API** | `spectra-api` complete for every page, `make ts`, the uplink to SpectraHub with proofs and checkpoints, checkpoints into OxygenHub's transparency log, export and import, prune and rebuild, retention | spectra-vscode lists agents, channels and tasks through the API; a Hub shows this machine's log | |
|
| 1003 | - | | **1.0** | Hardening (fuzzed JSON-RPC and hook bodies, projection equality tests, load test at §24), security review of §21, packaging (binary, `.deb` via the OxygenHub apt repository, systemd user unit), documentation | — | |
|
| 1017 | + | | **0.4 Push and automations** | The Claude Code channel shim with permission relay, schedules, webhook routes with HMAC signatures, the relay, Settings page, project links, `to: "downstream"` | A signed webhook from an outside system (a push on a code forge, a comment in a document editor) appears in the project's channel and creates a task for the agent on the dependent project, who is told by channel event without polling | |
|
| 1018 | + | | **0.5 Uplink and API** | `spectra-api` complete for every page, `make ts`, the uplink to SpectraHub with proofs and checkpoints, checkpoints to a witness log, export and import, prune and rebuild, retention | spectra-vscode lists agents, channels and tasks through the API; a Hub shows this machine's log | |
|
| 1019 | + | | **1.0** | Hardening (fuzzed JSON-RPC and hook bodies, projection equality tests, load test at §24), security review of §21, packaging (binary, `.deb`, systemd user unit), documentation | — | |
|
| 1004 | 1020 | ||
| 1005 | 1021 | ## 26. Open questions |
|
| 1006 | 1022 | ||
| … | 2 unchanged lines |
||
| 1009 | 1025 | 3. **Channel shim lifetime.** The Claude Code channel feature is a research preview with an allowlist. If it ships generally, `spectra channel` becomes a plugin in an official marketplace; if it is withdrawn, Tier 3 hooks are the push path. Keep both until one wins. |
|
| 1010 | 1026 | 4. **Tool count.** Twenty after Draft 0.2 folded the transitions. Measure which are called; `claim` and `schedule` could fold into `task_update`'s shape further, or `ask` into `delegate`, if the list still costs too much for small models. |
|
| 1011 | 1027 | 5. **Encryption at rest.** Messages may carry secrets an agent pasted. A passphrase-sealed database (SQLCipher or application-level sealing of bodies) is possible; the cost is every search and page decrypting. Decide before a Hub holds other people's logs. |
|
| 1012 | - | 6. **Agent-to-agent calls.** Should an agent be able to run a tool *on* another agent (ask Claude in `oxygen` to run `oxy log` and return it)? That is sampling-shaped and MCP has deprecated sampling. Tasks with a `result_schema` cover it with a human-visible step; keep it that way unless it proves too slow. |
|
| 1028 | + | 6. **Agent-to-agent calls.** Should an agent be able to run a tool *on* another agent (ask the agent on one project to run a command in its folder and return the output)? That is sampling-shaped and MCP has deprecated sampling. Tasks with a `result_schema` cover it with a human-visible step; keep it that way unless it proves too slow. |
|
| 1013 | 1029 | 7. **Kinds.** The agent `kind` list will date. Treat it as open text with known values for the rules layer. |
|
| 1014 | - | 8. **A `spectra` delegation scope in Oxygen.** Today an agent is Spectra's because its owner delegated to it with any Oxygen scope (`changes`, usually). A scope that says "may act for me in Spectra" would let an owner give an agent Spectra without `oxy`, or `oxy` without Spectra, and would be the natural place for an expiry that Spectra alone honours. It is a one-line change to oxygen spec §14.2 and `oxy identity delegate add`; propose it to Oxygen once the shape here has settled. |
|
| 1030 | + | 8. **Bringing an identity from elsewhere.** Spectra's identities are its own. Someone who already has keys (an SSH key, a signing key from another tool) might want to use them; a statement signed by both keys could link them. Not planned; recorded so the answer is not "impossible" by accident. |
|
| 1015 | 1031 | 9. **The owner's CLI key.** Level 2 from the CLI means an agent under the owner's user could sign as the owner for chat and tasks. A hardware-backed key for the owner (a YubiKey through PKCS#11, or the platform keystore) would raise the CLI to level 3; so would running agents under their own users (§21.6). Decide after living with the passkey in 0.2. |
|
| 1016 | - | 10. **Witnessing without OxygenHub.** Checkpoints are witnessed by OxygenHub's transparency log. A machine with no hub could publish them to a plain append-only file on a second machine, or to a public log. Not before 0.5. |
|
| 1032 | + | 10. **Where checkpoints are witnessed.** `[witness] url` posts checkpoints to an append-only log, but which logs to support (a SpectraHub, a plain file on a second machine over SSH, a public transparency log) is open. Not before 0.5. |
|
| 1017 | 1033 | 11. **How spectra-vscode signs.** The API takes signed requests (§8.5), and an extension in TypeScript has no ML-DSA-65 of its own. Recommended: the extension runs `spectra` as a signing proxy over the socket (a `spectra api METHOD PATH` command that signs with the owner's key and prints the JSON), so no key ever leaves Rust; the alternative, a WebAssembly build of `oxy-core`'s signing, is heavier. Decide before spectra-vscode 0.1. |
|
| 1018 | 1034 | ||
| 1019 | 1035 | ## 27. Changelog |
|
| 1020 | 1036 | ||
| 1037 | + | - Draft 0.5 (2026-11-03), Spectra stands alone: identities, composite signatures and deterministic CBOR are Spectra's own (§8.0), designed after Oxygen's but sharing no code, file or command; `spectra identity create` makes the owner's identity, `spectra agent add` and `spectra connect` sign the delegation themselves after the owner confirms; a project is any kind of work with an optional folder that Spectra never reads, linked by the owner (§23.1); webhooks are signed with HMAC-SHA256 and checkpoints are witnessed by a log of the owner's choosing; nothing requires Oxygen, OxygenHub or version control (§2.2, §23). |
|
| 1021 | 1038 | - Draft 0.4 (2026-11-03), Sprint 2: client and server (§8.6): the daemon serves other machines over TLS with a certificate signed by its instance key, so no CA is needed; `spectra connect` accepts a server only when the owner's own identity delegates to its instance key, and pins its certificate; `spectra init --owner KEYID` sets a server up without the owner's secret key, which claims it from the client; a client has no ledger file and works through the JSON API; an agent bound to a uid is refused over TCP; Radar from a client goes through a loopback proxy; 0.1's acceptance runs across two machines (§25). |
|
| 1022 | 1039 | - 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). |
|
| 1023 | 1040 | - 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). |
|
| … | 4 unchanged lines |
modified src/cli/agent.rs +88 −36
| 1 | 1 | //! `spectra agent`: agents as their owner's delegates (spec §8.1). |
|
| 2 | 2 | //! |
|
| 3 | - | //! `add` makes the agent's key pair in its own home, prints the one `oxy` |
|
| 4 | - | //! command that makes it the owner's, and records the agent only once the |
|
| 5 | - | //! owner's identity delegates to it. There is no agent without an owner. |
|
| 3 | + | //! `add` makes the agent's key pair in its own home, asks the owner to |
|
| 4 | + | //! confirm, signs the owner's successor that delegates to it, and records |
|
| 5 | + | //! both. There is no agent without an owner. |
|
| 6 | 6 | ||
| 7 | 7 | use super::identity::owner_home; |
|
| 8 | 8 | use super::{call, marks, ui, Ctx}; |
|
| … | 1 unchanged lines |
||
| 10 | 10 | use spectra_core::ids::now_ms; |
|
| 11 | 11 | use spectra_core::keys::{home_identity, KeyPair}; |
|
| 12 | 12 | use spectra_core::ledger::Ledger; |
|
| 13 | - | use spectra_core::principals::{identity_payload, signed_event, Principal, Principals, DEFAULT_SCOPES, DELEGATION_SCOPE}; |
|
| 13 | + | use spectra_core::principals::{identity_payload, signed_event, Principal, Principals, DEFAULT_SCOPES}; |
|
| 14 | 14 | use std::path::PathBuf; |
|
| 15 | - | use std::time::Duration; |
|
| 16 | 15 | ||
| 17 | 16 | pub const KINDS: &[&str] = &["claude-code", "antigravity", "kimi-code", "codex", "other"]; |
|
| 18 | 17 | ||
| … | 4 unchanged lines |
||
| 23 | 22 | pub home: PathBuf, |
|
| 24 | 23 | pub uid: Option<u32>, |
|
| 25 | 24 | pub learn_uid: bool, |
|
| 26 | - | pub wait_secs: u64, |
|
| 25 | + | pub expires: Option<String>, |
|
| 26 | + | pub yes: bool, |
|
| 27 | 27 | pub owner_home: Option<PathBuf>, |
|
| 28 | 28 | } |
|
| 29 | 29 | ||
| 30 | + | /// `30m`, `8h`, `7d` from now, in ms; `never` is none. |
|
| 31 | + | pub fn expiry(s: &str) -> Result<Option<u64>> { |
|
| 32 | + | let s = s.trim(); |
|
| 33 | + | if s == "never" { |
|
| 34 | + | return Ok(None); |
|
| 35 | + | } |
|
| 36 | + | let (n, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len())); |
|
| 37 | + | let n: u64 = n.parse().with_context(|| format!("{s} is not a length of time (30m, 8h, 7d)"))?; |
|
| 38 | + | let ms = match unit { |
|
| 39 | + | "s" => n * 1000, |
|
| 40 | + | "m" => n * 60_000, |
|
| 41 | + | "h" => n * 3_600_000, |
|
| 42 | + | "d" | "" => n * 86_400_000, |
|
| 43 | + | _ => bail!("{s} is not a length of time (30m, 8h, 7d)"), |
|
| 44 | + | }; |
|
| 45 | + | Ok(Some(now_ms() + ms)) |
|
| 46 | + | } |
|
| 47 | + | ||
| 48 | + | /// Ask the owner at the terminal; `yes` answers for scripts. |
|
| 49 | + | pub fn confirm(question: &str, yes: bool) -> Result<()> { |
|
| 50 | + | use std::io::IsTerminal; |
|
| 51 | + | if yes { |
|
| 52 | + | return Ok(()); |
|
| 53 | + | } |
|
| 54 | + | if !std::io::stdin().is_terminal() { |
|
| 55 | + | bail!("{question}: not asked, since this is not a terminal (--yes answers for a script)"); |
|
| 56 | + | } |
|
| 57 | + | print!("{} {} {} ", ui::glyph("?", "?"), question, ui::dim("[y/N]")); |
|
| 58 | + | use std::io::Write; |
|
| 59 | + | std::io::stdout().flush().ok(); |
|
| 60 | + | let mut line = String::new(); |
|
| 61 | + | std::io::stdin().read_line(&mut line)?; |
|
| 62 | + | if !matches!(line.trim().to_ascii_lowercase().as_str(), "y" | "yes") { |
|
| 63 | + | bail!("not delegated: nothing was changed"); |
|
| 64 | + | } |
|
| 65 | + | Ok(()) |
|
| 66 | + | } |
|
| 67 | + | ||
| 30 | 68 | pub async fn add(ctx: &Ctx, a: Add) -> Result<i32> { |
|
| 31 | 69 | if !valid_name(&a.name) { |
|
| 32 | 70 | bail!("an agent's name is lower-case letters, digits and dashes, starting with a letter or digit, at most 64"); |
|
| … | 31 unchanged lines |
||
| 64 | 102 | println!("{} {} {}", ui::done("Created"), ui::subject(format!("{}'s key", a.name)), ui::path(a.home.display())); |
|
| 65 | 103 | pair |
|
| 66 | 104 | }; |
|
| 67 | - | let delegated = |id: &oxy_core::identity::Identity| id.delegates_to(&agent.key, DELEGATION_SCOPE, now_ms()); |
|
| 68 | - | if !delegated(&home_identity(&home)?) { |
|
| 69 | - | println!("{} {}", ui::caution(), ui::warn(format!("{} is not yours yet: delegate to its key, as yourself:", a.name))); |
|
| 70 | - | println!( |
|
| 71 | - | " {}", |
|
| 72 | - | ui::id(format!( |
|
| 73 | - | "oxy identity delegate add {} --scope changes --expires 8h", |
|
| 74 | - | a.home.join("keys").join(format!("{}.identity", agent.key.to_hex())).display() |
|
| 75 | - | )) |
|
| 76 | - | ); |
|
| 77 | - | let spin = ui::spinner(&format!( |
|
| 78 | - | "waiting for your delegation to {} (up to {} s; Ctrl-C stops, and running this again resumes)", |
|
| 79 | - | a.name, a.wait_secs |
|
| 80 | - | )); |
|
| 81 | - | let deadline = std::time::Instant::now() + Duration::from_secs(a.wait_secs); |
|
| 82 | - | loop { |
|
| 83 | - | if home_identity(&home).is_ok_and(|id| delegated(&id)) { |
|
| 84 | - | spin.clear(); |
|
| 85 | - | break; |
|
| 86 | - | } |
|
| 87 | - | if std::time::Instant::now() >= deadline { |
|
| 88 | - | spin.clear(); |
|
| 89 | - | bail!("{}'s key is not delegated yet: run the command above, then this again", a.name); |
|
| 90 | - | } |
|
| 91 | - | tokio::time::sleep(Duration::from_secs(2)).await; |
|
| 92 | - | } |
|
| 105 | + | let expires = a.expires.as_deref().map(expiry).transpose()?.flatten(); |
|
| 106 | + | let current = home_identity(&home)?; |
|
| 107 | + | if current.delegate(&agent.key).map(|d| d.expires) != Some(expires) { |
|
| 108 | + | let until = expires.map(|e| format!("until {}", super::format::when(e))).unwrap_or_else(|| "until you disable it".into()); |
|
| 109 | + | println!("{}", ui::kv("agent", format!("{}{}{}", ui::subject(&a.name), ui::sep(), ui::hash(agent.key.short(12))))); |
|
| 110 | + | println!("{}", ui::kv("acts for", format!("{}{}{until}", ui::author(¤t.name), ui::sep()))); |
|
| 111 | + | confirm(&format!("Let {} act for you", a.name), a.yes)?; |
|
| 112 | + | spectra_core::keys::delegate(&home, agent.key, expires)?; |
|
| 93 | 113 | } |
|
| 94 | 114 | let w = ctx.owner_writer(&home).await?; |
|
| 95 | 115 | let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
|
| … | 11 unchanged lines |
||
| 107 | 127 | } |
|
| 108 | 128 | p["learn_uid"] = a.learn_uid.into(); |
|
| 109 | 129 | let d = owner.identity.delegate(&agent.key).context("the delegation vanished")?; |
|
| 110 | - | p["delegation"] = serde_json::json!({ "scope": d.scope.names(), "expires": d.expires }); |
|
| 130 | + | p["delegation"] = serde_json::json!({ "expires": d.expires }); |
|
| 111 | 131 | events.push(signed_event(&owner, "human", "agent.added", p)); |
|
| 112 | 132 | w.append(events).await?; |
|
| 113 | 133 | println!("{} {}{}{}", ui::done("Added"), ui::subject(&a.name), ui::sep(), ui::author(format!("{}'s agent", owner.identity.name))); |
|
| 114 | 134 | println!("{}", ui::kv("key", ui::hash(agent.key.to_hex()))); |
|
| 115 | - | let until = d.expires.map(|e| format!(" until {}", super::format::when(e))).unwrap_or_default(); |
|
| 116 | - | println!("{}", ui::kv("delegated", format!("{}{until}", d.scope.names().join(",")))); |
|
| 135 | + | let until = d.expires.map(|e| format!("until {}", super::format::when(e))).unwrap_or_else(|| "until you disable it".into()); |
|
| 136 | + | println!("{}", ui::kv("delegated", until)); |
|
| 117 | 137 | // With Spectra on another machine the shim cannot look the home up. |
|
| 118 | 138 | let shim = if ctx.remote()?.is_some() { |
|
| 119 | 139 | format!("spectra mcp --agent {} --home {}", a.name, a.home.display()) |
|
| … | 36 unchanged lines |
||
| 156 | 176 | let now = now_ms(); |
|
| 157 | 177 | let mut out = Vec::new(); |
|
| 158 | 178 | for AgentRow { name, key, kind, project, disabled_ms: disabled, presence, status } in rows { |
|
| 159 | - | let k = oxy_core::hash::ObjectRef::from_hex(&key).context("a stored key")?; |
|
| 179 | + | let k = spectra_core::identity::KeyId::from_hex(&key).context("a stored key")?; |
|
| 160 | 180 | let state = match p.principal(&k, now) { |
|
| 161 | 181 | Ok(_) => "ok".to_string(), |
|
| 162 | 182 | Err(why) => why, |
|
| … | 61 unchanged lines |
||
| 224 | 244 | }; |
|
| 225 | 245 | if !known { |
|
| 226 | 246 | bail!("no agent named {name}"); |
|
| 247 | + | } |
|
| 248 | + | // Stop delegating to its key too, where the owner's identity names it. |
|
| 249 | + | if let Some(key) = agent_key(&w, name).await? { |
|
| 250 | + | if home_identity(&home)?.delegate(&key).is_some() { |
|
| 251 | + | spectra_core::keys::undelegate(&home, key)?; |
|
| 252 | + | } |
|
| 227 | 253 | } |
|
| 228 | 254 | let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
|
| 229 | 255 | let owner = KeyPair::load_home(&home)?; |
|
| … | 1 unchanged lines |
||
| 231 | 257 | w.append(events).await?; |
|
| 232 | 258 | println!("{} {}", ui::done("Disabled"), ui::subject(name)); |
|
| 233 | 259 | println!(" {} {}", ui::note(), ui::dim("its key is refused from now on; earlier events keep their signatures")); |
|
| 260 | + | Ok(0) |
|
| 261 | + | } |
|
| 262 | + | ||
| 263 | + | /// An agent's key by name, from the daemon or the ledger here. |
|
| 264 | + | async fn agent_key(w: &super::Writer, name: &str) -> Result<Option<spectra_core::identity::KeyId>> { |
|
| 265 | + | Ok(match (w.client(), w.ledger()) { |
|
| 266 | + | (Some(c), _) => call(c, "GET", "/api/v1/agents", None).await?.as_array().and_then(|l| { |
|
| 267 | + | l.iter().find(|x| x["name"] == name).and_then(|x| x["key"].as_str().and_then(spectra_core::identity::KeyId::from_hex)) |
|
| 268 | + | }), |
|
| 269 | + | (None, Some(l)) => Principals::load(l).await?.agent_key(name), |
|
| 270 | + | (None, None) => None, |
|
| 271 | + | }) |
|
| 272 | + | } |
|
| 273 | + | ||
| 274 | + | /// `spectra agent renew NAME --expires 8h`: the owner's successor with a |
|
| 275 | + | /// new expiry for the agent's key, recorded. |
|
| 276 | + | pub async fn renew(ctx: &Ctx, name: &str, expires: &str, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
|
| 277 | + | let home = owner_home(ctx, owner_home_flag); |
|
| 278 | + | let w = ctx.owner_writer(&home).await?; |
|
| 279 | + | let key = agent_key(&w, name).await?.with_context(|| format!("no agent named {name}"))?; |
|
| 280 | + | let expires = expiry(expires)?; |
|
| 281 | + | spectra_core::keys::delegate(&home, key, expires)?; |
|
| 282 | + | let events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
|
| 283 | + | w.append(events).await?; |
|
| 284 | + | let until = expires.map(|e| format!("until {}", super::format::when(e))).unwrap_or_else(|| "until you disable it".into()); |
|
| 285 | + | println!("{} {}{}{}", ui::done("Renewed"), ui::subject(name), ui::sep(), until); |
|
| 234 | 286 | Ok(0) |
|
| 235 | 287 | } |
|
| 236 | 288 | ||
| … | 6 unchanged lines |
modified src/cli/connect.rs +23 −34
| … | 4 unchanged lines |
||
| 5 | 5 | ||
| 6 | 6 | use super::{identity, ui, Ctx}; |
|
| 7 | 7 | use anyhow::{bail, Context, Result}; |
|
| 8 | - | use oxy_core::identity::Identity; |
|
| 9 | 8 | use serde_json::Value as Json; |
|
| 10 | 9 | use spectra_core::daemon::WireEvent; |
|
| 10 | + | use spectra_core::identity::Identity; |
|
| 11 | 11 | use spectra_core::keys::KeyPair; |
|
| 12 | - | use spectra_core::principals::{identity_payload, signed_event, DELEGATION_SCOPE}; |
|
| 12 | + | use spectra_core::principals::{identity_payload, signed_event}; |
|
| 13 | 13 | use spectra_mcp::client::{host_port, Client, Endpoint}; |
|
| 14 | 14 | use spectra_mcp::tls::Pinned; |
|
| 15 | 15 | use std::path::{Path, PathBuf}; |
|
| … | 12 unchanged lines |
||
| 28 | 28 | } |
|
| 29 | 29 | } |
|
| 30 | 30 | ||
| 31 | - | /// `SPECTRA_SERVER`, else `$XDG_CONFIG_HOME/spectra/client.toml`, else |
|
| 32 | - | /// `~/.config/spectra/client.toml`. |
|
| 31 | + | /// `SPECTRA_SERVER`, else `client.toml` in the owner's identity home. |
|
| 33 | 32 | pub fn path() -> PathBuf { |
|
| 34 | 33 | if let Some(p) = std::env::var_os("SPECTRA_SERVER").filter(|p| !p.is_empty()) { |
|
| 35 | 34 | return PathBuf::from(p); |
|
| … | 1 unchanged lines |
||
| 37 | 36 | config_dir().join("client.toml") |
|
| 38 | 37 | } |
|
| 39 | 38 | ||
| 39 | + | /// The owner's identity home, where the client configuration lives too. |
|
| 40 | 40 | fn config_dir() -> PathBuf { |
|
| 41 | - | let base = std::env::var_os("XDG_CONFIG_HOME") |
|
| 42 | - | .filter(|p| !p.is_empty()) |
|
| 43 | - | .map(PathBuf::from) |
|
| 44 | - | .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) |
|
| 45 | - | .unwrap_or_else(|| PathBuf::from(".")); |
|
| 46 | - | base.join("spectra") |
|
| 41 | + | spectra_core::keys::default_home() |
|
| 47 | 42 | } |
|
| 48 | 43 | ||
| 49 | 44 | /// The client configuration, if this machine uses a Spectra elsewhere. |
|
| … | 33 unchanged lines |
||
| 83 | 78 | KeyPair::load(&keys.join(format!("{key}.secret")), &keys.join(format!("{key}.identity"))).ok() |
|
| 84 | 79 | } |
|
| 85 | 80 | ||
| 86 | - | pub async fn connect(_ctx: &Ctx, url: &str, owner_home: Option<PathBuf>) -> Result<i32> { |
|
| 81 | + | pub async fn connect(_ctx: &Ctx, url: &str, owner_home: Option<PathBuf>, expect: Option<String>, yes: bool) -> Result<i32> { |
|
| 87 | 82 | let server = format!("https://{}", host_port(url)?); |
|
| 88 | - | let home = owner_home.unwrap_or_else(oxy_core::repo::default_home); |
|
| 89 | - | let owner = KeyPair::load_home(&home) |
|
| 83 | + | let home = owner_home.unwrap_or_else(spectra_core::keys::default_home); |
|
| 84 | + | let mut owner = KeyPair::load_home(&home) |
|
| 90 | 85 | .with_context(|| format!("the owner connects with their own identity: none usable in {} (or --owner-home)", home.display()))?; |
|
| 91 | 86 | ||
| 92 | 87 | // Who is there, before trusting anything. |
|
| … | 16 unchanged lines |
||
| 109 | 104 | bail!("{server} belongs to someone else (owner {})", &k[..12.min(k.len())]); |
|
| 110 | 105 | } |
|
| 111 | 106 | (None, Some(k)) if own(&home, k).is_none() => { |
|
| 112 | - | bail!("{server} was set up for {}, which is not an identity in {}", &k[..12.min(k.len())], home.display()); |
|
| 107 | + | bail!( |
|
| 108 | + | "{server} was set up for {}, which is not an identity in {} (spectra init --owner takes `spectra identity show`'s key)", |
|
| 109 | + | &k[..12.min(k.len())], |
|
| 110 | + | home.display() |
|
| 111 | + | ); |
|
| 113 | 112 | } |
|
| 114 | 113 | (None, None) => bail!("{server} has no owner and names none"), |
|
| 115 | 114 | _ => {} |
|
| 116 | 115 | } |
|
| 117 | 116 | ||
| 118 | 117 | // Trust: the owner's own identity delegates to that instance key now. |
|
| 119 | - | if !owner.identity.delegates_to(&instance_key, DELEGATION_SCOPE, spectra_core::ids::now_ms()) { |
|
| 120 | - | let dir = config_dir(); |
|
| 121 | - | std::fs::create_dir_all(&dir)?; |
|
| 122 | - | let file = dir.join(format!("{}-instance.identity", safe(&checked.name))); |
|
| 123 | - | std::fs::write(&file, checked.instance.encode())?; |
|
| 124 | - | println!("{} {} {}", ui::caution(), ui::subject(&server), ui::dim("is not yet yours to trust")); |
|
| 125 | - | println!("{}", ui::kv("instance", format!("{}{}{}", checked.name, ui::sep(), ui::hash(instance_key.short(12))))); |
|
| 126 | - | println!(" {} {}", ui::note(), ui::dim("your identity does not delegate to its instance key; if this is your server, run:")); |
|
| 127 | - | println!(" {}", ui::id(format!("oxy identity delegate add {} --scope changes", file.display()))); |
|
| 128 | - | println!(" {} {}", ui::note(), ui::dim("then run spectra connect again")); |
|
| 129 | - | return Ok(1); |
|
| 118 | + | if !owner.identity.delegates_to(&instance_key, spectra_core::ids::now_ms()) { |
|
| 119 | + | println!("{}", ui::kv("server", ui::path(&server))); |
|
| 120 | + | println!("{}", ui::kv("instance", format!("{}{}{}", checked.name, ui::sep(), ui::hash(instance_key.to_hex())))); |
|
| 121 | + | println!(" {} {}", ui::note(), ui::dim("`spectra init` printed this instance's key on the server: check it is the same")); |
|
| 122 | + | match &expect { |
|
| 123 | + | Some(prefix) if instance_key.to_hex().starts_with(&prefix.trim().to_ascii_lowercase()) => {} |
|
| 124 | + | Some(prefix) => bail!("the server's instance key does not start with {prefix}: not trusted"), |
|
| 125 | + | None => super::agent::confirm(&format!("Trust {} as your Spectra", checked.name), yes)?, |
|
| 126 | + | } |
|
| 127 | + | owner = spectra_core::keys::delegate(&home, instance_key, None)?; |
|
| 130 | 128 | } |
|
| 131 | 129 | ||
| 132 | 130 | let cfg = ClientCfg { server: server.clone(), instance: instance_key.to_hex(), tls_spki: seen }; |
|
| … | 39 unchanged lines |
||
| 172 | 170 | ); |
|
| 173 | 171 | println!("{}", ui::kv("config", ui::path(saved.display()))); |
|
| 174 | 172 | Ok(0) |
|
| 175 | - | } |
|
| 176 | - | ||
| 177 | - | fn safe(name: &str) -> String { |
|
| 178 | - | let s: String = name.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '_' }).collect(); |
|
| 179 | - | if s.is_empty() { |
|
| 180 | - | "spectra".into() |
|
| 181 | - | } else { |
|
| 182 | - | s |
|
| 183 | - | } |
|
| 184 | 173 | } |
|
| 185 | 174 | ||
| 186 | 175 | fn problem(body: &[u8]) -> String { |
|
| … | 5 unchanged lines |
modified src/cli/format.rs +1 −1
| 1 | - | //! Times as `oxy` prints them: UTC, `YYYY-MM-DD HH:MM:SSZ`. |
|
| 1 | + | //! Times as Spectra prints them: UTC, `YYYY-MM-DD HH:MM:SSZ`. |
|
| 2 | 2 | ||
| 3 | 3 | pub fn when(ms: u64) -> String { |
|
| 4 | 4 | let secs = (ms / 1000) as i64; |
|
| … | 15 unchanged lines |
modified src/cli/human.rs +30 −16
| … | 115 unchanged lines |
||
| 116 | 116 | pub async fn inbox(ctx: &Ctx, follow: bool, owner_home_flag: Option<PathBuf>, as_json: bool) -> Result<i32> { |
|
| 117 | 117 | let home = owner_home(ctx, owner_home_flag); |
|
| 118 | 118 | let client = owner_client(ctx, &home).await?; |
|
| 119 | + | // Following: subscribe first, and read the inbox once the server says |
|
| 120 | + | // the subscription holds, so nothing posted in between is missed. |
|
| 121 | + | let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<bool>(); |
|
| 122 | + | let watcher = if follow { |
|
| 123 | + | let stream_client = owner_client(ctx, &home).await?.context("--follow needs `spectra serve` running")?; |
|
| 124 | + | let w = tokio::spawn(async move { |
|
| 125 | + | stream_client |
|
| 126 | + | .stream("/api/v1/events?follow=1&kind=message.posted", |l| { |
|
| 127 | + | if l.starts_with(':') { |
|
| 128 | + | let _ = tx.send(false); |
|
| 129 | + | } else if l.starts_with("data:") { |
|
| 130 | + | let _ = tx.send(true); |
|
| 131 | + | } |
|
| 132 | + | true |
|
| 133 | + | }) |
|
| 134 | + | .await |
|
| 135 | + | }); |
|
| 136 | + | match tokio::time::timeout(std::time::Duration::from_secs(10), rx.recv()).await { |
|
| 137 | + | Ok(Some(_)) => {} |
|
| 138 | + | _ => bail!("the server did not start the stream of new messages"), |
|
| 139 | + | } |
|
| 140 | + | Some(w) |
|
| 141 | + | } else { |
|
| 142 | + | None |
|
| 143 | + | }; |
|
| 119 | 144 | let items = inbox_items(ctx, client.as_ref()).await?; |
|
| 120 | 145 | if as_json && !follow { |
|
| 121 | 146 | println!("{}", Value::Array(items)); |
|
| … | 11 unchanged lines |
||
| 133 | 158 | print_item(i); |
|
| 134 | 159 | } |
|
| 135 | 160 | } |
|
| 136 | - | if !follow { |
|
| 137 | - | return Ok(0); |
|
| 138 | - | } |
|
| 161 | + | let Some(watcher) = watcher else { return Ok(0) }; |
|
| 139 | 162 | let client = client.context("--follow needs `spectra serve` running")?; |
|
| 140 | 163 | if !as_json { |
|
| 141 | 164 | println!("{} {}", ui::note(), ui::dim("following; Ctrl-C stops")); |
|
| 142 | 165 | } |
|
| 143 | 166 | // Every new message is a cue to look at the inbox again. |
|
| 144 | - | let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<()>(); |
|
| 145 | - | let stream_client = owner_client(ctx, &home).await?.context("--follow needs `spectra serve` running")?; |
|
| 146 | - | let watcher = tokio::spawn(async move { |
|
| 147 | - | stream_client |
|
| 148 | - | .stream("/api/v1/events?follow=1&kind=message.posted", |l| { |
|
| 149 | - | if l.starts_with("data:") { |
|
| 150 | - | let _ = tx.send(()); |
|
| 151 | - | } |
|
| 152 | - | true |
|
| 153 | - | }) |
|
| 154 | - | .await |
|
| 155 | - | }); |
|
| 156 | - | while rx.recv().await.is_some() { |
|
| 167 | + | while let Some(changed) = rx.recv().await { |
|
| 168 | + | if !changed { |
|
| 169 | + | continue; |
|
| 170 | + | } |
|
| 157 | 171 | for v in inbox_items(ctx, Some(&client)).await? { |
|
| 158 | 172 | if seen.insert(v["id"].as_str().unwrap_or("").to_string()) { |
|
| 159 | 173 | if as_json { |
|
| … | 77 unchanged lines |
modified src/cli/identity.rs +32 −24
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | use super::{ui, Ctx}; |
|
| 6 | 6 | use anyhow::{bail, Context, Result}; |
|
| 7 | - | use oxy_core::identity::Identity; |
|
| 7 | + | use spectra_core::identity::Identity; |
|
| 8 | 8 | use spectra_core::keys::KeyPair; |
|
| 9 | 9 | use spectra_core::ledger::Ledger; |
|
| 10 | 10 | use spectra_core::principals::{identity_payload, signed_event, Principals}; |
|
| 11 | 11 | use std::path::{Path, PathBuf}; |
|
| 12 | 12 | ||
| 13 | 13 | /// The owner's `OXY_HOME`: `--owner-home`, else `[owner] home` in |
|
| 14 | - | /// spectra.toml, else oxy's own default. |
|
| 14 | + | /// spectra.toml, else the default identity home. |
|
| 15 | 15 | pub fn owner_home(ctx: &Ctx, flag: Option<PathBuf>) -> PathBuf { |
|
| 16 | - | flag.or_else(|| ctx.config_owner_home()).unwrap_or_else(oxy_core::repo::default_home) |
|
| 16 | + | flag.or_else(|| ctx.config_owner_home()).unwrap_or_else(spectra_core::keys::default_home) |
|
| 17 | 17 | } |
|
| 18 | 18 | ||
| 19 | 19 | pub async fn init(ctx: &Ctx, owner_home_flag: Option<PathBuf>, name: Option<String>) -> Result<i32> { |
|
| … | 3 unchanged lines |
||
| 23 | 23 | bail!("{} already holds a Spectra (spectra identity show)", data.display()); |
|
| 24 | 24 | } |
|
| 25 | 25 | let home = owner_home(ctx, owner_home_flag); |
|
| 26 | - | let owner = KeyPair::load_home(&home).with_context(|| { |
|
| 27 | - | format!("the owner signs with their own identity: none usable in {} (oxy identity create, or --owner-home)", home.display()) |
|
| 26 | + | KeyPair::load_home(&home).with_context(|| { |
|
| 27 | + | format!("the owner signs with their own identity: none usable in {} (spectra identity create, or --owner-home)", home.display()) |
|
| 28 | 28 | })?; |
|
| 29 | 29 | std::fs::create_dir_all(data)?; |
|
| 30 | 30 | #[cfg(unix)] |
|
| … | 3 unchanged lines |
||
| 34 | 34 | } |
|
| 35 | 35 | let name = name.unwrap_or_else(hostname); |
|
| 36 | 36 | let instance = KeyPair::instance(data, &name)?; |
|
| 37 | + | // You are setting up your own machine: your identity delegates to its |
|
| 38 | + | // instance key, so its checkpoints are verifiably yours (spec §9.5). |
|
| 39 | + | let owner = spectra_core::keys::delegate(&home, instance.key, None)?; |
|
| 37 | 40 | let ledger = Ledger::open(&db, Some(instance.clone())).await?; |
|
| 38 | 41 | let mut first = vec![ |
|
| 39 | 42 | signed_event(&owner, "human", "identity.owner", identity_payload(&owner.identity)), |
|
| … | 12 unchanged lines |
||
| 52 | 55 | println!("{}", ui::kv("instance", format!("{}{}{}", name, ui::sep(), ui::hash(instance.key.short(12))))); |
|
| 53 | 56 | println!("{}", ui::kv("ledger", format!("{}{}", ui::path(db.display()), ui::dim(" (verified with spectra admin verify)")))); |
|
| 54 | 57 | println!(); |
|
| 55 | - | println!(" {} {}", ui::note(), ui::dim("so that checkpoints are verifiably from your machine, delegate to the instance key:")); |
|
| 56 | 58 | println!( |
|
| 57 | - | " {}", |
|
| 58 | - | ui::id(format!("oxy identity delegate add {} --scope changes", data.join("keys").join("instance.identity").display())) |
|
| 59 | - | ); |
|
| 60 | - | println!( |
|
| 61 | 59 | " {} {} {}", |
|
| 62 | 60 | ui::note(), |
|
| 63 | 61 | ui::dim("then add agents with"), |
|
| … | 10 unchanged lines |
||
| 74 | 72 | let p = Principals::load(&ledger).await?; |
|
| 75 | 73 | let owner = p.owner().context("no owner is recorded")?; |
|
| 76 | 74 | let instance = p.instance(); |
|
| 77 | - | let delegated = |
|
| 78 | - | instance.is_some_and(|i| owner.delegates_to(&i.hash(), spectra_core::principals::DELEGATION_SCOPE, spectra_core::ids::now_ms())); |
|
| 75 | + | let delegated = instance.is_some_and(|i| owner.delegates_to(&i.hash(), spectra_core::ids::now_ms())); |
|
| 79 | 76 | if json { |
|
| 80 | 77 | println!( |
|
| 81 | 78 | "{}", |
|
| … | 5 unchanged lines |
||
| 87 | 84 | return Ok(0); |
|
| 88 | 85 | } |
|
| 89 | 86 | println!("{}", ui::section("Owner")); |
|
| 90 | - | println!("{}", ui::kv("name", ui::author(format!("{} <{}>", owner.name, owner.email)))); |
|
| 87 | + | println!("{}", ui::kv("name", ui::author(format!("{} <{}>", owner.name, owner.contact)))); |
|
| 91 | 88 | println!("{}", ui::kv("key", ui::hash(owner.hash().to_hex()))); |
|
| 92 | 89 | println!("{}", ui::kv("delegates", owner.delegates.len())); |
|
| 93 | 90 | if let Some(i) = instance { |
|
| … | 3 unchanged lines |
||
| 97 | 94 | if delegated { |
|
| 98 | 95 | println!("{}", ui::kv("status", ui::ok("delegated by the owner: checkpoints are verifiably yours"))); |
|
| 99 | 96 | } else { |
|
| 100 | - | println!("{}", ui::kv("status", ui::warn("not delegated by the owner yet"))); |
|
| 101 | - | println!( |
|
| 102 | - | " {} {}", |
|
| 103 | - | ui::note(), |
|
| 104 | - | ui::id(format!("oxy identity delegate add {} --scope changes", ctx.data.join("keys").join("instance.identity").display())) |
|
| 105 | - | ); |
|
| 97 | + | println!("{}", ui::kv("status", ui::warn("not delegated by the owner's current identity"))); |
|
| 106 | 98 | } |
|
| 107 | 99 | } |
|
| 108 | 100 | Ok(0) |
|
| … | 79 unchanged lines |
||
| 188 | 180 | let info = super::call(&spectra_mcp::client::Client::at(cfg.endpoint()?, None), "GET", "/api/v1/instance", None).await?; |
|
| 189 | 181 | let owner_key = info["owner"].as_str().unwrap_or("").to_string(); |
|
| 190 | 182 | let instance_key = info["instance"]["key"].as_str().unwrap_or("").to_string(); |
|
| 191 | - | let home = oxy_core::repo::default_home(); |
|
| 183 | + | let home = spectra_core::keys::default_home(); |
|
| 192 | 184 | let me = KeyPair::load_home(&home).ok(); |
|
| 193 | 185 | let delegated = me.as_ref().is_some_and(|m| { |
|
| 194 | - | oxy_core::hash::ObjectRef::from_hex(&instance_key) |
|
| 195 | - | .is_some_and(|k| m.identity.delegates_to(&k, spectra_core::principals::DELEGATION_SCOPE, spectra_core::ids::now_ms())) |
|
| 186 | + | spectra_core::identity::KeyId::from_hex(&instance_key).is_some_and(|k| m.identity.delegates_to(&k, spectra_core::ids::now_ms())) |
|
| 196 | 187 | }); |
|
| 197 | 188 | if json { |
|
| 198 | 189 | println!( |
|
| … | 10 unchanged lines |
||
| 209 | 200 | println!("{}", ui::kv("url", ui::path(&cfg.server))); |
|
| 210 | 201 | println!("{}", ui::section("Owner")); |
|
| 211 | 202 | if let Some(m) = &me { |
|
| 212 | - | println!("{}", ui::kv("name", ui::author(format!("{} <{}>", m.identity.name, m.identity.email)))); |
|
| 203 | + | println!("{}", ui::kv("name", ui::author(format!("{} <{}>", m.identity.name, m.identity.contact)))); |
|
| 213 | 204 | } |
|
| 214 | 205 | println!("{}", ui::kv("key", ui::hash(&owner_key))); |
|
| 215 | 206 | println!("{}", ui::section("Instance")); |
|
| … | 4 unchanged lines |
||
| 220 | 211 | } else { |
|
| 221 | 212 | println!("{}", ui::kv("status", ui::warn("not delegated by your current identity"))); |
|
| 222 | 213 | } |
|
| 214 | + | Ok(0) |
|
| 215 | + | } |
|
| 216 | + | ||
| 217 | + | /// `spectra identity create`: the owner's own key pair (spec §8.0). |
|
| 218 | + | pub fn create(name: &str, contact: &str, home: Option<PathBuf>) -> Result<i32> { |
|
| 219 | + | let home = home.unwrap_or_else(spectra_core::keys::default_home); |
|
| 220 | + | let pair = KeyPair::create_home(&home, name.trim(), contact.trim())?; |
|
| 221 | + | println!("{} {} {}", ui::done("Created"), ui::subject("your identity"), ui::path(home.display())); |
|
| 222 | + | println!("{}", ui::kv("name", ui::author(&pair.identity.name))); |
|
| 223 | + | println!("{}", ui::kv("key", ui::hash(pair.key.to_hex()))); |
|
| 224 | + | println!(" {} {}", ui::note(), ui::dim("keep keys/ private: anyone with it can act as you")); |
|
| 225 | + | println!( |
|
| 226 | + | " {} {} {}", |
|
| 227 | + | ui::note(), |
|
| 228 | + | ui::dim("next: spectra init here, or on a server"), |
|
| 229 | + | ui::id(format!("spectra init --owner {}", pair.key.to_hex())) |
|
| 230 | + | ); |
|
| 223 | 231 | Ok(0) |
|
| 224 | 232 | } |
|
| 225 | 233 | ||
| … | 3 unchanged lines |
||
| 229 | 237 | pub async fn init_for(ctx: &Ctx, owner_key: &str, name: Option<String>) -> Result<i32> { |
|
| 230 | 238 | let key = owner_key.trim().to_ascii_lowercase(); |
|
| 231 | 239 | if key.len() != 64 || hex::decode(&key).is_err() { |
|
| 232 | - | bail!("{owner_key} is not a KeyId (64 hex digits; the owner's `oxy identity show` prints it)"); |
|
| 240 | + | bail!("{owner_key} is not a KeyId (64 hex digits; the owner's `spectra identity show` prints it)"); |
|
| 233 | 241 | } |
|
| 234 | 242 | let data = &ctx.data; |
|
| 235 | 243 | let db = data.join("spectra.db"); |
|
| … | 43 unchanged lines |
modified src/cli/marks.rs +1 −2
| 1 | - | //! Spectra's own marks, in the style of `oxy`'s (`ui.rs` stays exactly |
|
| 2 | - | //! `oxy`'s, so the two tools look the same): presence and agent health. |
|
| 1 | + | //! Spectra's own marks beside `ui.rs`: presence and agent health. |
|
| 3 | 2 | ||
| 4 | 3 | use super::ui::{dim, glyph, ok, warn}; |
|
| 5 | 4 | use console::style; |
|
| … | 28 unchanged lines |
modified src/cli/mod.rs +76 −22
| 1 | - | //! The `spectra` command line (spec §19). Output follows `oxy`: colour and |
|
| 1 | + | //! The `spectra` command line (spec §19). Output is modern: colour and |
|
| 2 | 2 | //! glyphs on a UTF-8 terminal, plain when piped, with `--plain` or with |
|
| 3 | 3 | //! `NO_COLOR`; `--json` prints one document on stdout and nothing else. |
|
| 4 | 4 | ||
| … | 3 unchanged lines |
||
| 8 | 8 | pub mod format; |
|
| 9 | 9 | pub mod human; |
|
| 10 | 10 | pub mod identity; |
|
| 11 | - | pub mod json; |
|
| 12 | 11 | pub mod marks; |
|
| 13 | 12 | pub mod mcp; |
|
| 14 | 13 | pub mod open; |
|
| … | 29 unchanged lines |
||
| 44 | 43 | /// Make this machine's Spectra: record the owner's identity (read from |
|
| 45 | 44 | /// their OXY_HOME and proven by signing) and make the instance key |
|
| 46 | 45 | Init { |
|
| 47 | - | /// The owner's OXY_HOME (default: $OXY_HOME, else ~/.config/oxy) |
|
| 46 | + | /// The owner's identity home (default: $SPECTRA_HOME, else ~/.config/spectra) |
|
| 48 | 47 | #[arg(long)] |
|
| 49 | 48 | owner_home: Option<PathBuf>, |
|
| 50 | 49 | /// This instance's name (default: the host name) |
|
| … | 10 unchanged lines |
||
| 61 | 60 | Connect { |
|
| 62 | 61 | /// The server, https://HOST[:PORT] |
|
| 63 | 62 | url: String, |
|
| 64 | - | /// The owner's OXY_HOME (default: $OXY_HOME, else ~/.config/oxy) |
|
| 63 | + | /// Trust the server if its instance key starts with this, without asking |
|
| 64 | + | #[arg(long, value_name = "KEYID-PREFIX")] |
|
| 65 | + | expect: Option<String>, |
|
| 66 | + | /// Trust it without asking (for scripts) |
|
| 67 | + | #[arg(long, short = 'y')] |
|
| 68 | + | yes: bool, |
|
| 69 | + | /// The owner's identity home (default: $SPECTRA_HOME, else ~/.config/spectra) |
|
| 65 | 70 | #[arg(long)] |
|
| 66 | 71 | owner_home: Option<PathBuf>, |
|
| 67 | 72 | }, |
|
| … | 109 unchanged lines |
||
| 177 | 182 | ||
| 178 | 183 | #[derive(Subcommand)] |
|
| 179 | 184 | pub enum IdentityCmd { |
|
| 185 | + | /// Make your own identity: a key pair that signs as you (spec §8.0) |
|
| 186 | + | Create { |
|
| 187 | + | /// Your name, as agents and Radar show it |
|
| 188 | + | #[arg(long)] |
|
| 189 | + | name: String, |
|
| 190 | + | /// An email or URL, if you want one shown |
|
| 191 | + | #[arg(long, default_value = "")] |
|
| 192 | + | contact: String, |
|
| 193 | + | /// Where to keep it (default: $SPECTRA_HOME, else ~/.config/spectra) |
|
| 194 | + | #[arg(long)] |
|
| 195 | + | home: Option<PathBuf>, |
|
| 196 | + | }, |
|
| 180 | 197 | /// The owner and the instance as the ledger knows them |
|
| 181 | 198 | Show { |
|
| 182 | 199 | #[arg(long)] |
|
| 183 | 200 | json: bool, |
|
| 184 | 201 | }, |
|
| 185 | - | /// Record the owner's current identity after `oxy identity delegate add|remove` |
|
| 202 | + | /// Record the owner's current identity if the ledger has missed a successor |
|
| 186 | 203 | Refresh { |
|
| 187 | 204 | #[arg(long)] |
|
| 188 | 205 | owner_home: Option<PathBuf>, |
|
| … | 2 unchanged lines |
||
| 191 | 208 | ||
| 192 | 209 | #[derive(Subcommand)] |
|
| 193 | 210 | pub enum ProjectCmd { |
|
| 194 | - | /// Register a checkout: its repository id, remote and pinned subrepos |
|
| 211 | + | /// Add a project: any kind of work, with a folder if it has one |
|
| 195 | 212 | Add { |
|
| 196 | - | path: PathBuf, |
|
| 197 | - | /// Its name (default: the directory's) |
|
| 213 | + | /// A folder (its name becomes the project's), or just a name |
|
| 214 | + | target: String, |
|
| 215 | + | /// Its name, when TARGET is a folder |
|
| 198 | 216 | #[arg(long)] |
|
| 199 | 217 | name: Option<String>, |
|
| 200 | - | /// Its remote URL (default: the checkout's origin) |
|
| 218 | + | /// The work's folder, when TARGET is a name; Spectra never reads it |
|
| 219 | + | #[arg(long)] |
|
| 220 | + | path: Option<PathBuf>, |
|
| 221 | + | /// What the work is, for agents and Radar |
|
| 222 | + | #[arg(long, short = 'd', default_value = "")] |
|
| 223 | + | description: String, |
|
| 224 | + | /// A link to it, if any |
|
| 225 | + | #[arg(long)] |
|
| 226 | + | url: Option<String>, |
|
| 227 | + | #[arg(long)] |
|
| 228 | + | owner_home: Option<PathBuf>, |
|
| 229 | + | }, |
|
| 230 | + | /// Say that one project depends on another (`delegate to: downstream` follows links) |
|
| 231 | + | Link { |
|
| 232 | + | project: String, |
|
| 233 | + | #[arg(long)] |
|
| 234 | + | depends_on: String, |
|
| 235 | + | /// Remove the link instead |
|
| 201 | 236 | #[arg(long)] |
|
| 202 | - | remote: Option<String>, |
|
| 237 | + | remove: bool, |
|
| 203 | 238 | #[arg(long)] |
|
| 204 | 239 | owner_home: Option<PathBuf>, |
|
| 205 | 240 | }, |
|
| 206 | - | /// Every project with what it pins, what pins it, and its agents |
|
| 241 | + | /// Every project with its links and agents |
|
| 207 | 242 | List { |
|
| 208 | 243 | #[arg(long)] |
|
| 209 | 244 | json: bool, |
|
| … | 2 unchanged lines |
||
| 212 | 247 | ||
| 213 | 248 | #[derive(Subcommand)] |
|
| 214 | 249 | pub enum AgentCmd { |
|
| 215 | - | /// Make an agent's key in its own home and record it once you delegate to it |
|
| 250 | + | /// Make an agent's key in its own home, delegate to it, and record it |
|
| 216 | 251 | Add { |
|
| 217 | 252 | /// Lower-case letters, digits and dashes |
|
| 218 | 253 | name: String, |
|
| … | 3 unchanged lines |
||
| 222 | 257 | /// The project it works in |
|
| 223 | 258 | #[arg(long)] |
|
| 224 | 259 | project: String, |
|
| 225 | - | /// The agent's own OXY_HOME, where its key is made and kept |
|
| 260 | + | /// The agent's own identity home, where its key is made and kept |
|
| 226 | 261 | #[arg(long)] |
|
| 227 | 262 | home: PathBuf, |
|
| 228 | 263 | /// Bind the key to this Unix user: a request from another is refused |
|
| … | 2 unchanged lines |
||
| 231 | 266 | /// Bind the key to the Unix user of its first signed connection |
|
| 232 | 267 | #[arg(long, conflicts_with = "uid")] |
|
| 233 | 268 | learn_uid: bool, |
|
| 234 | - | /// How long to wait for your delegation, in seconds |
|
| 235 | - | #[arg(long, default_value_t = 600)] |
|
| 236 | - | wait: u64, |
|
| 237 | - | /// The owner's OXY_HOME |
|
| 269 | + | /// How long the agent may act for you: 30m, 8h, 7d (default: until disabled) |
|
| 270 | + | #[arg(long)] |
|
| 271 | + | expires: Option<String>, |
|
| 272 | + | /// Delegate without asking (for scripts) |
|
| 273 | + | #[arg(long, short = 'y')] |
|
| 274 | + | yes: bool, |
|
| 275 | + | /// The owner's identity home |
|
| 276 | + | #[arg(long)] |
|
| 277 | + | owner_home: Option<PathBuf>, |
|
| 278 | + | }, |
|
| 279 | + | /// Give an agent more time: delegate to its key again with a new expiry |
|
| 280 | + | Renew { |
|
| 281 | + | name: String, |
|
| 282 | + | /// 30m, 8h, 7d, or `never` |
|
| 283 | + | #[arg(long)] |
|
| 284 | + | expires: String, |
|
| 238 | 285 | #[arg(long)] |
|
| 239 | 286 | owner_home: Option<PathBuf>, |
|
| 240 | 287 | }, |
|
| … | 128 unchanged lines |
||
| 369 | 416 | /// A Spectra on another machine; `recorded` is the owner identity it knows. |
|
| 370 | 417 | Remote { |
|
| 371 | 418 | client: Box<spectra_mcp::client::Client>, |
|
| 372 | - | recorded: oxy_core::identity::Identity, |
|
| 419 | + | recorded: spectra_core::identity::Identity, |
|
| 373 | 420 | }, |
|
| 374 | 421 | } |
|
| 375 | 422 | ||
| … | 107 unchanged lines |
||
| 483 | 530 | let ctx = Ctx { data: cli.data.unwrap_or_else(default_data) }; |
|
| 484 | 531 | match cli.command { |
|
| 485 | 532 | Command::Version { json } => version(json), |
|
| 486 | - | Command::Connect { url, owner_home } => runtime()?.block_on(connect::connect(&ctx, &url, owner_home)), |
|
| 533 | + | Command::Connect { url, owner_home, expect, yes } => runtime()?.block_on(connect::connect(&ctx, &url, owner_home, expect, yes)), |
|
| 487 | 534 | Command::Init { owner_home, name, owner: Some(key) } => { |
|
| 488 | 535 | let _ = owner_home; |
|
| 489 | 536 | runtime()?.block_on(identity::init_for(&ctx, &key, name)) |
|
| … | 1 unchanged lines |
||
| 491 | 538 | Command::Init { owner_home, name, owner: None } => runtime()?.block_on(identity::init(&ctx, owner_home, name)), |
|
| 492 | 539 | Command::Identity { command } => runtime()?.block_on(async { |
|
| 493 | 540 | match command { |
|
| 541 | + | IdentityCmd::Create { name, contact, home } => identity::create(&name, &contact, home), |
|
| 494 | 542 | IdentityCmd::Show { json } => identity::show(&ctx, json).await, |
|
| 495 | 543 | IdentityCmd::Refresh { owner_home } => identity::refresh(&ctx, owner_home).await, |
|
| 496 | 544 | } |
|
| … | 20 unchanged lines |
||
| 517 | 565 | Command::Api { method, path, body, owner_home } => runtime()?.block_on(human::raw(&ctx, &method, &path, body, owner_home)), |
|
| 518 | 566 | Command::Project { command } => runtime()?.block_on(async { |
|
| 519 | 567 | match command { |
|
| 520 | - | ProjectCmd::Add { path, name, remote, owner_home } => project::add(&ctx, &path, name, remote, owner_home).await, |
|
| 568 | + | ProjectCmd::Add { target, name, path, description, url, owner_home } => { |
|
| 569 | + | project::add(&ctx, project::Add { target, name, path, description, url }, owner_home).await |
|
| 570 | + | } |
|
| 571 | + | ProjectCmd::Link { project, depends_on, remove, owner_home } => { |
|
| 572 | + | project::link(&ctx, &project, &depends_on, remove, owner_home).await |
|
| 573 | + | } |
|
| 521 | 574 | ProjectCmd::List { json } => project::list(&ctx, json).await, |
|
| 522 | 575 | } |
|
| 523 | 576 | }), |
|
| 524 | 577 | Command::Agent { command } => runtime()?.block_on(async { |
|
| 525 | 578 | match command { |
|
| 526 | - | AgentCmd::Add { name, kind, project, home, uid, learn_uid, wait, owner_home } => { |
|
| 527 | - | agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, wait_secs: wait, owner_home }).await |
|
| 579 | + | AgentCmd::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home } => { |
|
| 580 | + | agent::add(&ctx, agent::Add { name, kind, project, home, uid, learn_uid, expires, yes, owner_home }).await |
|
| 528 | 581 | } |
|
| 582 | + | AgentCmd::Renew { name, expires, owner_home } => agent::renew(&ctx, &name, &expires, owner_home).await, |
|
| 529 | 583 | AgentCmd::List { json } => agent::list(&ctx, json).await, |
|
| 530 | 584 | AgentCmd::Disable { name, owner_home } => agent::disable(&ctx, &name, owner_home).await, |
|
| 531 | 585 | } |
|
| … | 27 unchanged lines |
modified src/cli/project.rs +76 −37
| 1 | - | //! `spectra project`: the checkouts agents work in (spec §23.1). |
|
| 1 | + | //! `spectra project`: the work agents share (spec §23.1), any kind of it. |
|
| 2 | 2 | ||
| 3 | 3 | use super::identity::owner_home; |
|
| 4 | 4 | use super::{call, ui, Ctx}; |
|
| … | 4 unchanged lines |
||
| 9 | 9 | use spectra_core::projects; |
|
| 10 | 10 | use std::path::{Path, PathBuf}; |
|
| 11 | 11 | ||
| 12 | - | pub async fn add(ctx: &Ctx, path: &Path, name: Option<String>, remote: Option<String>, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
|
| 13 | - | let path = path.canonicalize().with_context(|| format!("{} does not exist", path.display()))?; |
|
| 14 | - | let name = match name { |
|
| 15 | - | Some(n) => n, |
|
| 16 | - | None => projects::default_name(&path)?, |
|
| 12 | + | pub struct Add { |
|
| 13 | + | pub target: String, |
|
| 14 | + | pub name: Option<String>, |
|
| 15 | + | pub path: Option<PathBuf>, |
|
| 16 | + | pub description: String, |
|
| 17 | + | pub url: Option<String>, |
|
| 18 | + | } |
|
| 19 | + | ||
| 20 | + | pub async fn add(ctx: &Ctx, a: Add, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
|
| 21 | + | // A folder that exists is the work's folder; anything else is a name. |
|
| 22 | + | let as_dir = Path::new(&shellexpand(&a.target)).to_path_buf(); |
|
| 23 | + | let (name, path) = match (&a.path, as_dir.is_dir()) { |
|
| 24 | + | (None, true) => { |
|
| 25 | + | let path = as_dir.canonicalize()?; |
|
| 26 | + | (a.name.clone().map(Ok).unwrap_or_else(|| projects::default_name(&path))?, Some(path)) |
|
| 27 | + | } |
|
| 28 | + | (Some(p), _) => { |
|
| 29 | + | let path = p.canonicalize().with_context(|| format!("{} does not exist", p.display()))?; |
|
| 30 | + | (a.name.clone().unwrap_or_else(|| a.target.clone()), Some(path)) |
|
| 31 | + | } |
|
| 32 | + | (None, false) => (a.name.clone().unwrap_or_else(|| a.target.clone()), None), |
|
| 17 | 33 | }; |
|
| 18 | 34 | if !super::agent::valid_name(&name) { |
|
| 19 | - | bail!("a project's name is lower-case letters, digits and dashes (--name)"); |
|
| 35 | + | bail!("a project's name is lower-case letters, digits and dashes, starting with a letter or digit (--name)"); |
|
| 20 | 36 | } |
|
| 21 | - | let checkout = projects::inspect(&path)?; |
|
| 22 | 37 | let home = owner_home(ctx, owner_home_flag); |
|
| 23 | 38 | let w = ctx.owner_writer(&home).await?; |
|
| 24 | - | let path_s = path.display().to_string(); |
|
| 39 | + | let path_s = path.as_ref().map(|p| p.display().to_string()); |
|
| 25 | 40 | let existing = all(&w).await?; |
|
| 26 | - | if let Some(e) = existing.iter().find(|p| p["name"] == name.as_str() || p["path"] == path_s.as_str()) { |
|
| 27 | - | bail!("{} is already a project ({})", path.display(), e["name"].as_str().unwrap_or("")); |
|
| 41 | + | if let Some(e) = existing.iter().find(|p| p["name"] == name.as_str() || (path_s.is_some() && p["path"].as_str() == path_s.as_deref())) { |
|
| 42 | + | bail!("{} is already a project", e["name"].as_str().unwrap_or("")); |
|
| 28 | 43 | } |
|
| 29 | 44 | let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
|
| 30 | 45 | let owner = KeyPair::load_home(&home)?; |
|
| 31 | - | let remote = remote.or(checkout.remote.clone()); |
|
| 32 | - | let payload = serde_json::json!({ |
|
| 33 | - | "name": name, "path": path.display().to_string(), "repo_id": checkout.repo_id, "remote": remote, "modules": checkout.modules, |
|
| 34 | - | }); |
|
| 46 | + | let payload = serde_json::json!({ "name": name, "path": path_s, "description": a.description, "url": a.url }); |
|
| 35 | 47 | events.push(signed_event(&owner, "human", "project.added", payload).project(name.clone()).subject(name.clone())); |
|
| 36 | 48 | events.push(spectra_core::conversation::project_channel_event(&name)); |
|
| 37 | 49 | w.append(events).await?; |
|
| 38 | - | println!("{} {} {}", ui::done("Added"), ui::subject(&name), ui::path(path.display())); |
|
| 39 | - | if let Some(r) = &remote { |
|
| 40 | - | println!("{}", ui::kv("remote", r)); |
|
| 50 | + | println!("{} {}{}", ui::done("Added"), ui::subject(&name), path_s.map(|p| format!(" {}", ui::path(p))).unwrap_or_default()); |
|
| 51 | + | if !a.description.is_empty() { |
|
| 52 | + | println!("{}", ui::kv("about", &a.description)); |
|
| 41 | 53 | } |
|
| 42 | - | if let Some(id) = &checkout.repo_id { |
|
| 43 | - | println!("{}", ui::kv("repo", ui::hash(id))); |
|
| 54 | + | if let Some(u) = &a.url { |
|
| 55 | + | println!("{}", ui::kv("link", u)); |
|
| 44 | 56 | } |
|
| 45 | - | let now = all(&w).await?; |
|
| 46 | - | let me = now.iter().find(|p| p["name"] == name.as_str()).cloned().unwrap_or_default(); |
|
| 47 | - | let names = |
|
| 48 | - | |k: &str| me[k].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(String::from)).collect::<Vec<_>>()).unwrap_or_default(); |
|
| 49 | - | let (deps, users) = (names("depends_on"), names("depended_on_by")); |
|
| 50 | - | if !deps.is_empty() { |
|
| 51 | - | println!("{}", ui::kv("pins", deps.join(", "))); |
|
| 57 | + | println!( |
|
| 58 | + | " {} {}", |
|
| 59 | + | ui::note(), |
|
| 60 | + | ui::dim(format!("agents talk about it in #{name}; link it with spectra project link {name} --depends-on OTHER")) |
|
| 61 | + | ); |
|
| 62 | + | Ok(0) |
|
| 63 | + | } |
|
| 64 | + | ||
| 65 | + | /// `~/x` as the shell would have it. |
|
| 66 | + | fn shellexpand(s: &str) -> String { |
|
| 67 | + | match (s.strip_prefix("~/"), std::env::var_os("HOME")) { |
|
| 68 | + | (Some(rest), Some(h)) => Path::new(&h).join(rest).display().to_string(), |
|
| 69 | + | _ => s.to_string(), |
|
| 52 | 70 | } |
|
| 53 | - | if !users.is_empty() { |
|
| 54 | - | println!("{}", ui::kv("pinned by", users.join(", "))); |
|
| 71 | + | } |
|
| 72 | + | ||
| 73 | + | /// `spectra project link P --depends-on Q [--remove]`. |
|
| 74 | + | pub async fn link(ctx: &Ctx, project: &str, on: &str, remove: bool, owner_home_flag: Option<PathBuf>) -> Result<i32> { |
|
| 75 | + | if project == on { |
|
| 76 | + | bail!("a project cannot depend on itself"); |
|
| 55 | 77 | } |
|
| 56 | - | if checkout.repo_id.is_none() { |
|
| 57 | - | println!(" {} {}", ui::note(), ui::dim("not an Oxygen checkout: relations come from .oxymodules, so it has none")); |
|
| 78 | + | let home = owner_home(ctx, owner_home_flag); |
|
| 79 | + | let w = ctx.owner_writer(&home).await?; |
|
| 80 | + | let existing = all(&w).await?; |
|
| 81 | + | for n in [project, on] { |
|
| 82 | + | if !existing.iter().any(|p| p["name"] == n) { |
|
| 83 | + | bail!("no project named {n} (spectra project list)"); |
|
| 84 | + | } |
|
| 58 | 85 | } |
|
| 86 | + | let mut events: Vec<_> = w.refresh_event(&home).await?.into_iter().collect(); |
|
| 87 | + | let owner = KeyPair::load_home(&home)?; |
|
| 88 | + | let kind = if remove { "project.unlinked" } else { "project.linked" }; |
|
| 89 | + | events.push( |
|
| 90 | + | signed_event(&owner, "human", kind, serde_json::json!({ "project": project, "depends_on": on })).project(project.to_string()), |
|
| 91 | + | ); |
|
| 92 | + | w.append(events).await?; |
|
| 93 | + | let verb = if remove { "Unlinked" } else { "Linked" }; |
|
| 94 | + | println!("{} {} {} {}", ui::done(verb), ui::subject(project), ui::dim("depends on"), ui::subject(on)); |
|
| 59 | 95 | Ok(0) |
|
| 60 | 96 | } |
|
| 61 | 97 | ||
| … | 20 unchanged lines |
||
| 82 | 118 | } |
|
| 83 | 119 | ||
| 84 | 120 | async fn local(ledger: &Ledger) -> Result<Vec<serde_json::Value>> { |
|
| 85 | - | let rows: Vec<(String, String, Option<String>)> = |
|
| 86 | - | sqlx::query_as("SELECT name, path, remote FROM projects ORDER BY name").fetch_all(ledger.pool()).await?; |
|
| 121 | + | let rows: Vec<(String, Option<String>, String, Option<String>)> = |
|
| 122 | + | sqlx::query_as("SELECT name, path, description, url FROM projects ORDER BY name").fetch_all(ledger.pool()).await?; |
|
| 87 | 123 | let mut out = Vec::new(); |
|
| 88 | - | for (name, path, remote) in rows { |
|
| 124 | + | for (name, path, description, url) in rows { |
|
| 89 | 125 | let (deps, users) = relations(ledger, &name).await?; |
|
| 90 | 126 | let agents: Vec<String> = sqlx::query_scalar("SELECT name FROM agents WHERE project = ? AND disabled_ms IS NULL ORDER BY name") |
|
| 91 | 127 | .bind(&name) |
|
| 92 | 128 | .fetch_all(ledger.pool()) |
|
| 93 | 129 | .await?; |
|
| 94 | - | out.push(serde_json::json!({ "name": name, "path": path, "remote": remote, "depends_on": deps, "depended_on_by": users, "agents": agents })); |
|
| 130 | + | out.push(serde_json::json!({ "name": name, "path": path, "description": description, "url": url, "depends_on": deps, "depended_on_by": users, "agents": agents })); |
|
| 95 | 131 | } |
|
| 96 | 132 | Ok(out) |
|
| 97 | 133 | } |
|
| … | 8 unchanged lines |
||
| 106 | 142 | return Ok(0); |
|
| 107 | 143 | } |
|
| 108 | 144 | if out.is_empty() { |
|
| 109 | - | println!("{} {}", ui::note(), ui::dim("no projects yet: spectra project add PATH")); |
|
| 145 | + | println!("{} {}", ui::note(), ui::dim("no projects yet: spectra project add NAME-OR-FOLDER")); |
|
| 110 | 146 | } |
|
| 111 | 147 | for p in out { |
|
| 112 | 148 | let list = |
|
| … | 4 unchanged lines |
||
| 117 | 153 | ui::bookmark(p["name"].as_str().unwrap_or("")), |
|
| 118 | 154 | ui::path(p["path"].as_str().unwrap_or("")) |
|
| 119 | 155 | ); |
|
| 120 | - | for (k, label) in [("depends_on", "pins"), ("depended_on_by", "pinned by"), ("agents", "agents")] { |
|
| 156 | + | if let Some(d) = p["description"].as_str().filter(|d| !d.is_empty()) { |
|
| 157 | + | println!("{}", ui::kv("about", d)); |
|
| 158 | + | } |
|
| 159 | + | for (k, label) in [("depends_on", "depends on"), ("depended_on_by", "needed by"), ("agents", "agents")] { |
|
| 121 | 160 | let v = list(k); |
|
| 122 | 161 | if !v.is_empty() { |
|
| 123 | 162 | println!("{}", ui::kv(label, v)); |
|
| … | 5 unchanged lines |
modified src/cli/ui.rs +1 −1
| 1 | - | #![allow(dead_code)] // Shared with oxy: not every helper is used yet. |
|
| 1 | + | #![allow(dead_code)] // A toolkit: not every helper is used yet. |
|
| 2 | 2 | //! Terminal presentation: colour, emphasis, and a live spinner while a |
|
| 3 | 3 | //! command works. Everything degrades to plain text when stdout is not a |
|
| 4 | 4 | //! terminal, when `NO_COLOR` is set, or with `--plain`. |
|
| … | 241 unchanged lines |
modified tests/cli_admin.rs +2 −2
| 1 | 1 | //! `spectra admin verify|check|rebuild` against a real ledger, run as the |
|
| 2 | 2 | //! built binary in a hermetic environment: a temporary data directory and |
|
| 3 | - | //! `OXY_HOME`, never the user's own. |
|
| 3 | + | //! `SPECTRA_HOME`, never the user's own. |
|
| 4 | 4 | ||
| 5 | 5 | use spectra_core::event::NewEvent; |
|
| 6 | 6 | use spectra_core::keys::KeyPair; |
|
| … | 5 unchanged lines |
||
| 12 | 12 | fn hermetic(dir: &Path) -> Command { |
|
| 13 | 13 | let mut c = Command::new(env!("CARGO_BIN_EXE_spectra")); |
|
| 14 | 14 | c.env("SPECTRA_DATA", dir.join("data")) |
|
| 15 | - | .env("OXY_HOME", dir.join("oxy")) |
|
| 15 | + | .env("SPECTRA_HOME", dir.join("owner-home")) |
|
| 16 | 16 | .env("HOME", dir) |
|
| 17 | 17 | .env("XDG_DATA_HOME", dir.join("xdg")) |
|
| 18 | 18 | .env("NO_COLOR", "1"); |
|
| … | 55 unchanged lines |
modified tests/cli_identity.rs +29 −33
| 1 | - | //! The owner and agents from the command line (spec §8.1, §8.3), as the |
|
| 2 | - | //! built binary in a hermetic environment: `init` proves the owner's |
|
| 3 | - | //! identity, `agent add` refuses until the owner delegates, ownership is |
|
| 4 | - | //! shown, and the ledger verifies throughout. |
|
| 1 | + | //! The owner and agents from the command line (spec §8.0, §8.1, §8.3), as |
|
| 2 | + | //! the built binary in a hermetic environment: `identity create` makes the |
|
| 3 | + | //! owner, `init` proves it, `agent add` delegates only when the owner |
|
| 4 | + | //! confirms, ownership is shown, and the ledger verifies throughout. |
|
| 5 | 5 | ||
| 6 | - | use oxy_core::durable::{write_atomic, write_atomic_private}; |
|
| 7 | - | use oxy_core::identity::{Delegate, Identity, SecretKey}; |
|
| 8 | 6 | use spectra_core::keys::KeyPair; |
|
| 9 | - | use spectra_core::principals::DELEGATION_SCOPE; |
|
| 10 | 7 | use std::path::Path; |
|
| 11 | 8 | use std::process::{Command, Output}; |
|
| 12 | 9 | ||
| 13 | 10 | fn hermetic(dir: &Path) -> Command { |
|
| 14 | 11 | let mut c = Command::new(env!("CARGO_BIN_EXE_spectra")); |
|
| 15 | 12 | c.env("SPECTRA_DATA", dir.join("data")) |
|
| 16 | - | .env("OXY_HOME", dir.join("owner")) |
|
| 13 | + | .env("SPECTRA_HOME", dir.join("owner")) |
|
| 17 | 14 | .env("HOME", dir) |
|
| 18 | 15 | .env("XDG_DATA_HOME", dir.join("xdg")) |
|
| 19 | 16 | .env("NO_COLOR", "1"); |
|
| … | 8 unchanged lines |
||
| 28 | 25 | format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) |
|
| 29 | 26 | } |
|
| 30 | 27 | ||
| 31 | - | /// `oxy identity delegate add IDENTITY --scope changes` in the owner's home. |
|
| 32 | - | fn delegate(owner_home: &Path, agent: &Identity) { |
|
| 33 | - | let cur = KeyPair::load_home(owner_home).unwrap(); |
|
| 34 | - | let keys = owner_home.join("keys"); |
|
| 35 | - | let (_, secret) = SecretKey::decode(&std::fs::read(keys.join(format!("{}.secret", cur.key.to_hex()))).unwrap()).unwrap(); |
|
| 36 | - | let mut d = cur.identity.delegates.clone(); |
|
| 37 | - | d.push(Delegate { key: agent.hash(), scope: DELEGATION_SCOPE, expires: None }); |
|
| 38 | - | let next = cur.identity.successor(d, None, spectra_core::ids::now_ms()); |
|
| 39 | - | let k = next.hash(); |
|
| 40 | - | write_atomic_private(&keys.join(format!("{}.secret", k.to_hex())), &secret.encode(&k)).unwrap(); |
|
| 41 | - | write_atomic(&keys.join(format!("{}.identity", k.to_hex())), &next.encode()).unwrap(); |
|
| 42 | - | oxy_core::repo::Config::set_user(&owner_home.join("config.toml"), "author.key", &k.to_hex()).unwrap(); |
|
| 43 | - | } |
|
| 44 | - | ||
| 45 | 28 | fn verify_clean(dir: &Path) { |
|
| 46 | 29 | let o = run(dir, &["admin", "verify", "--json"]); |
|
| 47 | 30 | let v: serde_json::Value = serde_json::from_slice(&o.stdout).unwrap_or_else(|_| panic!("{}", text(&o))); |
|
| … | 4 unchanged lines |
||
| 52 | 35 | fn init_then_an_owned_agent_end_to_end() { |
|
| 53 | 36 | let dir = tempfile::tempdir().unwrap(); |
|
| 54 | 37 | let d = dir.path(); |
|
| 55 | - | KeyPair::create_home(&d.join("owner"), "Andy McCall", "[email protected]").unwrap(); |
|
| 38 | + | let o = run(d, &["identity", "create", "--name", "Andy McCall", "--contact", "[email protected]"]); |
|
| 39 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 40 | + | assert!(!run(d, &["identity", "create", "--name", "again"]).status.success(), "one identity per home"); |
|
| 56 | 41 | ||
| 57 | 42 | let o = run(d, &["init", "--name", "laptop"]); |
|
| 58 | 43 | assert!(o.status.success(), "{}", text(&o)); |
|
| 59 | - | assert!(text(&o).contains("oxy identity delegate add"), "{}", text(&o)); |
|
| 60 | 44 | assert!(!run(d, &["init"]).status.success(), "a second init is refused"); |
|
| 45 | + | let owner = KeyPair::load_home(&d.join("owner")).unwrap(); |
|
| 46 | + | let instance = KeyPair::instance(&d.join("data"), "laptop").unwrap(); |
|
| 47 | + | assert!(owner.identity.delegates_to(&instance.key, 1), "init delegates to its own instance key"); |
|
| 61 | 48 | verify_clean(d); |
|
| 62 | 49 | ||
| 63 | - | let checkout = d.join("oxygen"); |
|
| 64 | - | std::fs::create_dir_all(&checkout).unwrap(); |
|
| 65 | - | let o = run(d, &["project", "add", checkout.to_str().unwrap()]); |
|
| 50 | + | // A project is any work; with no folder at all, too. |
|
| 51 | + | let o = run(d, &["project", "add", "oxygen", "-d", "the version control system"]); |
|
| 66 | 52 | assert!(o.status.success(), "{}", text(&o)); |
|
| 67 | 53 | let home = d.join("agents/claude-oxygen"); |
|
| 68 | 54 | let home_s = home.to_str().unwrap(); |
|
| 69 | - | let args = ["agent", "add", "claude-oxygen", "--kind", "claude-code", "--project", "oxygen", "--home", home_s, "--wait", "0"]; |
|
| 55 | + | let args = ["agent", "add", "claude-oxygen", "--kind", "claude-code", "--project", "oxygen", "--home", home_s, "--expires", "8h"]; |
|
| 70 | 56 | let o = run(d, &args); |
|
| 71 | - | assert!(!o.status.success(), "refused until delegated"); |
|
| 72 | - | assert!(text(&o).contains("oxy identity delegate add") && text(&o).contains("not delegated yet"), "{}", text(&o)); |
|
| 57 | + | assert!(!o.status.success(), "not a terminal and no --yes: nothing delegated"); |
|
| 58 | + | assert!(text(&o).contains("--yes"), "{}", text(&o)); |
|
| 73 | 59 | let agent = KeyPair::load_home(&home).unwrap(); |
|
| 60 | + | assert!(!KeyPair::load_home(&d.join("owner")).unwrap().identity.delegates_to(&agent.key, 1)); |
|
| 74 | 61 | ||
| 75 | - | delegate(&d.join("owner"), &agent.identity); |
|
| 76 | - | let o = run(d, &args); |
|
| 62 | + | let mut yes = args.to_vec(); |
|
| 63 | + | yes.push("--yes"); |
|
| 64 | + | let o = run(d, &yes); |
|
| 77 | 65 | assert!(o.status.success(), "{}", text(&o)); |
|
| 78 | 66 | assert!(text(&o).contains("Added claude-oxygen") && text(&o).contains("Andy McCall's agent"), "{}", text(&o)); |
|
| 79 | 67 | assert_eq!(KeyPair::load_home(&home).unwrap().key, agent.key, "the key made first is the key recorded"); |
|
| 68 | + | let d8 = KeyPair::load_home(&d.join("owner")).unwrap().identity.delegate(&agent.key).cloned().unwrap(); |
|
| 69 | + | assert!(d8.expires.is_some(), "the expiry was delegated"); |
|
| 80 | 70 | ||
| 81 | 71 | let list: serde_json::Value = serde_json::from_slice(&run(d, &["agent", "list", "--json"]).stdout).unwrap(); |
|
| 82 | 72 | assert_eq!(list[0]["display"], "claude-oxygen · Andy McCall's agent"); |
|
| 83 | 73 | assert_eq!(list[0]["state"], "ok"); |
|
| 74 | + | verify_clean(d); |
|
| 75 | + | ||
| 76 | + | let o = run(d, &["agent", "renew", "claude-oxygen", "--expires", "never"]); |
|
| 77 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 78 | + | assert_eq!(KeyPair::load_home(&d.join("owner")).unwrap().identity.delegate(&agent.key).unwrap().expires, None); |
|
| 84 | 79 | verify_clean(d); |
|
| 85 | 80 | ||
| 86 | 81 | assert!(run(d, &["agent", "disable", "claude-oxygen"]).status.success()); |
|
| 82 | + | assert!(KeyPair::load_home(&d.join("owner")).unwrap().identity.delegate(&agent.key).is_none(), "the delegation is removed"); |
|
| 87 | 83 | let list: serde_json::Value = serde_json::from_slice(&run(d, &["agent", "list", "--json"]).stdout).unwrap(); |
|
| 88 | 84 | assert!(list[0]["state"].as_str().unwrap().contains("disabled"), "{list}"); |
|
| 89 | 85 | verify_clean(d); |
|
| 90 | 86 | assert!(run(d, &["admin", "check"]).status.success()); |
|
| 91 | 87 | ||
| 92 | - | // Piped, as here, output is plain like oxy's: no colour codes, no glyphs. |
|
| 88 | + | // Piped, as here, output is plain: no colour codes, no glyphs. |
|
| 93 | 89 | let o = run(d, &["agent", "list"]); |
|
| 94 | 90 | let t = text(&o); |
|
| 95 | 91 | assert!(!t.contains('\x1b') && !t.contains('✔'), "{t}"); |
|
| … | 23 unchanged lines |
modified tests/cli_project.rs +34 −33
| 1 | - | //! Projects from the command line (spec §23.1): relations come from |
|
| 2 | - | //! `.oxymodules`, matched by remote, whichever is registered first; an agent |
|
| 3 | - | //! needs a registered project. |
|
| 1 | + | //! Projects from the command line (spec §23.1): any kind of work, with or |
|
| 2 | + | //! without a folder that Spectra never reads; links drawn by the owner; an |
|
| 3 | + | //! agent needs a registered project. |
|
| 4 | 4 | ||
| 5 | 5 | use spectra_core::keys::KeyPair; |
|
| 6 | 6 | use std::path::Path; |
|
| … | 2 unchanged lines |
||
| 9 | 9 | fn hermetic(dir: &Path) -> Command { |
|
| 10 | 10 | let mut c = Command::new(env!("CARGO_BIN_EXE_spectra")); |
|
| 11 | 11 | c.env("SPECTRA_DATA", dir.join("data")) |
|
| 12 | - | .env("OXY_HOME", dir.join("owner")) |
|
| 12 | + | .env("SPECTRA_HOME", dir.join("owner")) |
|
| 13 | 13 | .env("HOME", dir) |
|
| 14 | 14 | .env("XDG_DATA_HOME", dir.join("xdg")) |
|
| 15 | 15 | .env("NO_COLOR", "1"); |
|
| … | 8 unchanged lines |
||
| 24 | 24 | format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) |
|
| 25 | 25 | } |
|
| 26 | 26 | ||
| 27 | - | /// A checkout as `oxy` leaves one: an id, a remote, and the subrepos it pins. |
|
| 28 | - | fn checkout(at: &Path, remote: &str, pins: &[&str]) { |
|
| 29 | - | std::fs::create_dir_all(at.join(".oxy")).unwrap(); |
|
| 30 | - | std::fs::write(at.join(".oxy/id"), "0123456789abcdef0123456789abcdef").unwrap(); |
|
| 31 | - | std::fs::write(at.join(".oxy/config"), format!("remote.origin.url = \"{remote}\"\n")).unwrap(); |
|
| 32 | - | let modules: String = pins.iter().enumerate().map(|(i, u)| format!("[deps/{i}]\nurl = {u}\nbookmark = main\n")).collect(); |
|
| 33 | - | std::fs::write(at.join(".oxymodules"), modules).unwrap(); |
|
| 34 | - | } |
|
| 35 | - | ||
| 36 | 27 | fn setup() -> tempfile::TempDir { |
|
| 37 | 28 | let dir = tempfile::tempdir().unwrap(); |
|
| 38 | 29 | KeyPair::create_home(&dir.path().join("owner"), "Andy McCall", "").unwrap(); |
|
| … | 6 unchanged lines |
||
| 45 | 36 | } |
|
| 46 | 37 | ||
| 47 | 38 | #[test] |
|
| 48 | - | fn relations_are_found_whichever_project_comes_first() { |
|
| 49 | - | for hub_first in [false, true] { |
|
| 50 | - | let dir = setup(); |
|
| 51 | - | let d = dir.path(); |
|
| 52 | - | checkout(&d.join("oxygen"), "https://hub.example/andy/oxygen", &[]); |
|
| 53 | - | checkout(&d.join("oxygenhub"), "https://hub.example/andy/oxygenhub", &["https://hub.example/andy/oxygen"]); |
|
| 54 | - | let order = if hub_first { ["oxygenhub", "oxygen"] } else { ["oxygen", "oxygenhub"] }; |
|
| 55 | - | for p in order { |
|
| 56 | - | let o = run(d, &["project", "add", d.join(p).to_str().unwrap()]); |
|
| 39 | + | fn any_work_is_a_project_and_the_owner_links_them() { |
|
| 40 | + | let dir = setup(); |
|
| 41 | + | let d = dir.path(); |
|
| 42 | + | // A book being restructured: a folder of chapters, and an index with none. |
|
| 43 | + | std::fs::create_dir_all(d.join("Manuscript")).unwrap(); |
|
| 44 | + | std::fs::write(d.join("Manuscript/.oxymodules"), "this is not read").unwrap(); |
|
| 45 | + | let o = run(d, &["project", "add", d.join("Manuscript").to_str().unwrap(), "-d", "the book, chapter by chapter"]); |
|
| 46 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 47 | + | let o = run(d, &["project", "add", "index", "-d", "the index and cross-references", "--url", "https://example.org/index"]); |
|
| 57 | 48 | assert!(o.status.success(), "{}", text(&o)); |
|
| 58 | - | } |
|
| 59 | - | let list = projects(d); |
|
| 60 | - | let hub = list.as_array().unwrap().iter().find(|p| p["name"] == "oxygenhub").unwrap(); |
|
| 61 | - | let oxy = list.as_array().unwrap().iter().find(|p| p["name"] == "oxygen").unwrap(); |
|
| 62 | - | assert_eq!(hub["depends_on"], serde_json::json!(["oxygen"]), "hub first: {hub_first}"); |
|
| 63 | - | assert_eq!(oxy["depended_on_by"], serde_json::json!(["oxygenhub"])); |
|
| 64 | - | assert!(run(d, &["admin", "check"]).status.success()); |
|
| 65 | - | assert!(run(d, &["admin", "verify"]).status.success()); |
|
| 66 | - | } |
|
| 49 | + | let o = run(d, &["project", "link", "index", "--depends-on", "manuscript"]); |
|
| 50 | + | assert!(o.status.success(), "{}", text(&o)); |
|
| 51 | + | ||
| 52 | + | let list = projects(d); |
|
| 53 | + | let book = list.as_array().unwrap().iter().find(|p| p["name"] == "manuscript").unwrap(); |
|
| 54 | + | let index = list.as_array().unwrap().iter().find(|p| p["name"] == "index").unwrap(); |
|
| 55 | + | assert_eq!(book["description"], "the book, chapter by chapter"); |
|
| 56 | + | assert!(book["path"].as_str().unwrap().ends_with("Manuscript")); |
|
| 57 | + | assert!(index["path"].is_null(), "no folder"); |
|
| 58 | + | assert_eq!(index["url"], "https://example.org/index"); |
|
| 59 | + | assert_eq!(index["depends_on"], serde_json::json!(["manuscript"])); |
|
| 60 | + | assert_eq!(book["depended_on_by"], serde_json::json!(["index"])); |
|
| 61 | + | ||
| 62 | + | assert!(run(d, &["project", "link", "index", "--depends-on", "manuscript", "--remove"]).status.success()); |
|
| 63 | + | assert_eq!(projects(d)[0]["depends_on"], serde_json::json!([])); |
|
| 64 | + | assert!(!run(d, &["project", "link", "index", "--depends-on", "nowhere"]).status.success()); |
|
| 65 | + | assert!(run(d, &["admin", "check"]).status.success()); |
|
| 66 | + | assert!(run(d, &["admin", "verify"]).status.success()); |
|
| 67 | 67 | } |
|
| 68 | 68 | ||
| 69 | 69 | #[test] |
|
| 70 | 70 | fn a_project_is_registered_once_and_an_agent_needs_one() { |
|
| 71 | 71 | let dir = setup(); |
|
| 72 | 72 | let d = dir.path(); |
|
| 73 | - | checkout(&d.join("oxygen"), "https://hub.example/andy/oxygen", &[]); |
|
| 73 | + | std::fs::create_dir_all(d.join("oxygen")).unwrap(); |
|
| 74 | 74 | assert!(run(d, &["project", "add", d.join("oxygen").to_str().unwrap()]).status.success()); |
|
| 75 | 75 | let again = run(d, &["project", "add", d.join("oxygen").to_str().unwrap(), "--name", "other"]); |
|
| 76 | 76 | assert!(!again.status.success() && text(&again).contains("already a project"), "{}", text(&again)); |
|
| 77 | + | assert!(!run(d, &["project", "add", "Not A Name"]).status.success()); |
|
| 77 | 78 | let o = run( |
|
| 78 | 79 | d, |
|
| 79 | - | &["agent", "add", "kimi", "--kind", "kimi-code", "--project", "nowhere", "--home", d.join("kimi").to_str().unwrap(), "--wait", "0"], |
|
| 80 | + | &["agent", "add", "kimi", "--kind", "kimi-code", "--project", "nowhere", "--home", d.join("kimi").to_str().unwrap(), "--yes"], |
|
| 80 | 81 | ); |
|
| 81 | 82 | assert!(!o.status.success() && text(&o).contains("no project named nowhere"), "{}", text(&o)); |
|
| 82 | 83 | assert!(!d.join("kimi").exists(), "no key is made for an agent that cannot be added"); |
|
| … | 1 unchanged lines |
modified tests/client.rs +2 −4
| … | 3 unchanged lines |
||
| 4 | 4 | ||
| 5 | 5 | mod common; |
|
| 6 | 6 | ||
| 7 | - | use common::{add_agent, delegate, hermetic, run, text, Serving}; |
|
| 7 | + | use common::{add_agent, hermetic, run, text, Serving}; |
|
| 8 | 8 | use serde_json::{json, Value}; |
|
| 9 | 9 | use spectra_core::keys::KeyPair; |
|
| 10 | 10 | use std::path::Path; |
|
| … | 15 unchanged lines |
||
| 26 | 26 | assert!(run(s, &["init", "--owner", &owner.key.to_hex(), "--name", "earth"]).status.success()); |
|
| 27 | 27 | let (srv, port) = Serving::start_tls(s); |
|
| 28 | 28 | let url = format!("https://127.0.0.1:{port}"); |
|
| 29 | - | assert_eq!(run(c, &["connect", &url]).status.code(), Some(1)); |
|
| 30 | - | delegate(&c.join("owner"), &KeyPair::instance(&s.join("data"), "earth").unwrap()); |
|
| 31 | - | let o = run(c, &["connect", &url]); |
|
| 29 | + | let o = run(c, &["connect", &url, "--yes"]); |
|
| 32 | 30 | assert!(o.status.success(), "{}", text(&o)); |
|
| 33 | 31 | (srv, port) |
|
| 34 | 32 | } |
|
| … | 159 unchanged lines |
modified tests/common/mod.rs +8 −23
| 1 | 1 | //! Helpers for end-to-end tests: a hermetic environment, the owner's |
|
| 2 | - | //! delegation as `oxy identity delegate add` makes it, agents added through |
|
| 3 | - | //! the CLI, and the daemon started and stopped as a real process. |
|
| 2 | + | //! delegation as Spectra makes it, agents added through the CLI, and the |
|
| 3 | + | //! daemon started and stopped as a real process. |
|
| 4 | 4 | #![allow(dead_code)] |
|
| 5 | 5 | ||
| 6 | - | use oxy_core::durable::{write_atomic, write_atomic_private}; |
|
| 7 | - | use oxy_core::identity::{Delegate, SecretKey}; |
|
| 8 | 6 | use spectra_core::keys::KeyPair; |
|
| 9 | - | use spectra_core::principals::DELEGATION_SCOPE; |
|
| 10 | 7 | use std::path::{Path, PathBuf}; |
|
| 11 | 8 | use std::process::{Child, Command, Output}; |
|
| 12 | 9 | ||
| 13 | 10 | pub fn hermetic(dir: &Path) -> Command { |
|
| 14 | 11 | let mut c = Command::new(env!("CARGO_BIN_EXE_spectra")); |
|
| 15 | 12 | c.env("SPECTRA_DATA", dir.join("data")) |
|
| 16 | - | .env("OXY_HOME", dir.join("owner")) |
|
| 13 | + | .env("SPECTRA_HOME", dir.join("owner")) |
|
| 17 | 14 | .env("HOME", dir) |
|
| 18 | 15 | .env("XDG_DATA_HOME", dir.join("xdg")) |
|
| 19 | 16 | .env("NO_COLOR", "1"); |
|
| … | 8 unchanged lines |
||
| 28 | 25 | format!("{}{}", String::from_utf8_lossy(&o.stdout), String::from_utf8_lossy(&o.stderr)) |
|
| 29 | 26 | } |
|
| 30 | 27 | ||
| 31 | - | /// `oxy identity delegate add` in the owner's home. |
|
| 28 | + | /// The owner delegates to `key`, as `spectra agent add` and `connect` do. |
|
| 32 | 29 | pub fn delegate(owner_home: &Path, agent: &KeyPair) { |
|
| 33 | - | let cur = KeyPair::load_home(owner_home).unwrap(); |
|
| 34 | - | let keys = owner_home.join("keys"); |
|
| 35 | - | let (_, secret) = SecretKey::decode(&std::fs::read(keys.join(format!("{}.secret", cur.key.to_hex()))).unwrap()).unwrap(); |
|
| 36 | - | let mut d = cur.identity.delegates.clone(); |
|
| 37 | - | d.push(Delegate { key: agent.key, scope: DELEGATION_SCOPE, expires: None }); |
|
| 38 | - | let next = cur.identity.successor(d, None, spectra_core::ids::now_ms()); |
|
| 39 | - | let k = next.hash(); |
|
| 40 | - | write_atomic_private(&keys.join(format!("{}.secret", k.to_hex())), &secret.encode(&k)).unwrap(); |
|
| 41 | - | write_atomic(&keys.join(format!("{}.identity", k.to_hex())), &next.encode()).unwrap(); |
|
| 42 | - | oxy_core::repo::Config::set_user(&owner_home.join("config.toml"), "author.key", &k.to_hex()).unwrap(); |
|
| 30 | + | spectra_core::keys::delegate(owner_home, agent.key, None).unwrap(); |
|
| 43 | 31 | } |
|
| 44 | 32 | ||
| 45 | - | /// Make an agent's home, delegate to it, and add it. |
|
| 33 | + | /// Add an agent through the CLI, confirming the delegation. |
|
| 46 | 34 | pub fn add_agent(d: &Path, name: &str, extra: &[&str]) -> KeyPair { |
|
| 47 | 35 | let home = d.join("agents").join(name); |
|
| 48 | - | let pair = KeyPair::create_home(&home, name, "").unwrap(); |
|
| 49 | - | delegate(&d.join("owner"), &pair); |
|
| 50 | - | let mut args = |
|
| 51 | - | vec!["agent", "add", name, "--kind", "claude-code", "--project", "oxygen", "--home", home.to_str().unwrap(), "--wait", "0"]; |
|
| 36 | + | let mut args = vec!["agent", "add", name, "--kind", "claude-code", "--project", "oxygen", "--home", home.to_str().unwrap(), "--yes"]; |
|
| 52 | 37 | args.extend_from_slice(extra); |
|
| 53 | 38 | let o = run(d, &args); |
|
| 54 | 39 | assert!(o.status.success(), "{}", text(&o)); |
|
| 55 | - | pair |
|
| 40 | + | KeyPair::load_home(&home).unwrap() |
|
| 56 | 41 | } |
|
| 57 | 42 | ||
| 58 | 43 | pub struct Serving { |
|
| … | 65 unchanged lines |
modified tests/connect.rs +26 −22
| 1 | - | //! `spectra connect` (spec §8.6, spectra#21): a workstation trusts a server |
|
| 2 | - | //! because the owner delegates to its instance key and that key vouches for |
|
| 3 | - | //! its certificate; it claims a server set up with `init --owner`, even |
|
| 4 | - | //! though delegating changed the owner's KeyId; a new certificate is |
|
| 5 | - | //! refused until connect runs again; someone else's server is refused. |
|
| 1 | + | //! `spectra connect` (spec §8.6): a workstation trusts a server once the |
|
| 2 | + | //! owner confirms its instance key and delegates to it, and that key |
|
| 3 | + | //! vouches for its certificate; it claims a server set up with `init |
|
| 4 | + | //! --owner`, even though delegating changed the owner's KeyId; a new |
|
| 5 | + | //! certificate is refused until connect runs again; someone else's server |
|
| 6 | + | //! is refused. |
|
| 6 | 7 | ||
| 7 | 8 | mod common; |
|
| 8 | 9 | ||
| 9 | - | use common::{delegate, hermetic, run, text, Serving}; |
|
| 10 | + | use common::{hermetic, run, text, Serving}; |
|
| 10 | 11 | use spectra_core::keys::KeyPair; |
|
| 11 | 12 | use spectra_mcp::client::{Client, Endpoint}; |
|
| 12 | 13 | use spectra_mcp::tls::Pinned; |
|
| … | 21 unchanged lines |
||
| 34 | 35 | let (srv, port) = server_for(s, &c.join("owner")); |
|
| 35 | 36 | let url = format!("https://127.0.0.1:{port}"); |
|
| 36 | 37 | ||
| 37 | - | // First contact: not yet delegated, so not trusted; told what to run. |
|
| 38 | + | // First contact, not at a terminal and not confirmed: nothing trusted. |
|
| 38 | 39 | let o = run(c, &["connect", &url]); |
|
| 39 | - | assert_eq!(o.status.code(), Some(1), "{}", text(&o)); |
|
| 40 | - | assert!(text(&o).contains("oxy identity delegate add"), "{}", text(&o)); |
|
| 41 | - | let saved = c.join(".config/spectra/earth-instance.identity"); |
|
| 42 | - | assert!(saved.exists(), "the instance's public identity is saved to delegate to"); |
|
| 43 | - | assert!(!c.join(".config/spectra/client.toml").exists(), "nothing trusted yet"); |
|
| 40 | + | assert!(!o.status.success(), "{}", text(&o)); |
|
| 41 | + | assert!(text(&o).contains("--yes"), "{}", text(&o)); |
|
| 42 | + | assert!(!c.join("owner/client.toml").exists(), "nothing trusted yet"); |
|
| 43 | + | // A key that does not match what `init` printed is refused. |
|
| 44 | + | let o = run(c, &["connect", &url, "--expect", "00ff"]); |
|
| 45 | + | assert!(!o.status.success() && text(&o).contains("does not start with"), "{}", text(&o)); |
|
| 44 | 46 | ||
| 45 | - | // The owner delegates (which gives their identity a new KeyId) ... |
|
| 47 | + | // The owner checks the instance key against what `init` printed (here, |
|
| 48 | + | // by --expect) and connects: their identity now delegates to it, under |
|
| 49 | + | // a new KeyId ... |
|
| 46 | 50 | let instance = KeyPair::instance(&s.join("data"), "earth").unwrap(); |
|
| 47 | - | assert_eq!(oxy_core::identity::Identity::decode(&std::fs::read(&saved).unwrap()).unwrap().hash(), instance.key); |
|
| 48 | - | delegate(&c.join("owner"), &instance); |
|
| 49 | - | let current = KeyPair::load_home(&c.join("owner")).unwrap(); |
|
| 50 | - | ||
| 51 | - | // ... and connects again: trusted, pinned, and claimed as theirs. |
|
| 52 | - | let o = run(c, &["connect", &url]); |
|
| 51 | + | let before = KeyPair::load_home(&c.join("owner")).unwrap(); |
|
| 52 | + | // ... and the server is trusted, pinned, and claimed as theirs. |
|
| 53 | + | let o = run(c, &["connect", &url, "--expect", &instance.key.short(16)]); |
|
| 53 | 54 | assert!(o.status.success(), "{}", text(&o)); |
|
| 54 | 55 | assert!(text(&o).contains("Claimed"), "{}", text(&o)); |
|
| 55 | - | let cfg = std::fs::read_to_string(c.join(".config/spectra/client.toml")).unwrap(); |
|
| 56 | + | let current = KeyPair::load_home(&c.join("owner")).unwrap(); |
|
| 57 | + | assert_ne!(current.key, before.key); |
|
| 58 | + | assert!(current.identity.delegates_to(&instance.key, 1)); |
|
| 59 | + | let cfg = std::fs::read_to_string(c.join("owner/client.toml")).unwrap(); |
|
| 56 | 60 | assert!(cfg.contains(&instance.key.to_hex()) && cfg.contains("tls_spki"), "{cfg}"); |
|
| 57 | 61 | assert_eq!(instance_owner(port).await, current.key.to_hex(), "recorded at the owner's current identity"); |
|
| 58 | 62 | ||
| … | 8 unchanged lines |
||
| 67 | 71 | std::fs::remove_file(s.join("data/keys/tls.key")).unwrap(); |
|
| 68 | 72 | let (srv, port) = Serving::start_tls(s); |
|
| 69 | 73 | let url = format!("https://127.0.0.1:{port}"); |
|
| 70 | - | let cfg = std::fs::read_to_string(c.join(".config/spectra/client.toml")).unwrap(); |
|
| 74 | + | let cfg = std::fs::read_to_string(c.join("owner/client.toml")).unwrap(); |
|
| 71 | 75 | let old_pin: [u8; 32] = |
|
| 72 | 76 | hex::decode(cfg.lines().find(|l| l.starts_with("tls_spki")).unwrap().split('"').nth(1).unwrap()).unwrap().try_into().unwrap(); |
|
| 73 | 77 | let pinned = Client::at(Endpoint::tls(&url, old_pin).unwrap(), None); |
|
| … | 1 unchanged lines |
||
| 75 | 79 | // ... until connect checks that the same instance key vouches for it. |
|
| 76 | 80 | let o = run(c, &["connect", &url]); |
|
| 77 | 81 | assert!(o.status.success(), "{}", text(&o)); |
|
| 78 | - | let cfg2 = std::fs::read_to_string(c.join(".config/spectra/client.toml")).unwrap(); |
|
| 82 | + | let cfg2 = std::fs::read_to_string(c.join("owner/client.toml")).unwrap(); |
|
| 79 | 83 | assert_ne!(cfg, cfg2, "re-pinned"); |
|
| 80 | 84 | srv.stop(); |
|
| 81 | 85 | ||
| … | 16 unchanged lines |