Compare commits
26 Commits
feature/v0
...
b2749826b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2749826b1 | ||
|
|
a332a9e80d | ||
|
|
d45dd10917 | ||
|
|
4d02a50cc8 | ||
|
|
5d9a7ee8d3 | ||
|
|
006e67c361 | ||
|
|
95d1ff833c | ||
|
|
4fc1357368 | ||
|
|
df58b0dda1 | ||
|
|
ed9fcbe6ba | ||
|
|
0172a06698 | ||
|
|
6d5a2570d4 | ||
|
|
6a1c6d5875 | ||
|
|
6d8f699fcb | ||
|
|
c0921b134d | ||
|
|
0443f6a3b4 | ||
|
|
5e8e617a4d | ||
|
|
efac53d527 | ||
|
|
af8626fb5f | ||
|
|
9c97f9f939 | ||
|
|
76d092d4f6 | ||
|
|
1342228a51 | ||
|
|
d539050aec | ||
|
|
8fd9a05875 | ||
|
|
8a72b5e192 | ||
|
|
ca059e7507 |
@@ -1,4 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"relay": {
|
||||||
|
"type": "sse",
|
||||||
|
"url": "http://localhost:7331/sse"
|
||||||
|
}
|
||||||
|
},
|
||||||
"enabledPlugins": {
|
"enabledPlugins": {
|
||||||
"superpowers@claude-plugins-official": true
|
"superpowers@claude-plugins-official": true
|
||||||
}
|
}
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ extension/dist-firefox/
|
|||||||
extension/wasm/
|
extension/wasm/
|
||||||
reference.jpg
|
reference.jpg
|
||||||
ref.jpg
|
ref.jpg
|
||||||
|
tools/relay/node_modules/
|
||||||
|
|||||||
104
CHANGELOG.md
104
CHANGELOG.md
@@ -1,9 +1,72 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## Unreleased
|
## v0.5.0 — 2026-05-02
|
||||||
|
|
||||||
|
Three release trains roll into one tag — backup/restore + LastPass
|
||||||
|
import (originally v0.3.0), device authentication (originally v0.4.0),
|
||||||
|
and the v0.5.0 polish + harden bundle (security fixes + UX fixes +
|
||||||
|
two confirmed bugs).
|
||||||
|
|
||||||
|
### Security
|
||||||
|
|
||||||
|
- **Pre-receive hook now actually verifies signatures (audit S1, HIGH).**
|
||||||
|
Earlier `relicario-server` builds accepted any commit with a
|
||||||
|
`Good signature` line on stderr regardless of which key signed it —
|
||||||
|
device-auth was a no-op. The hook now builds an `allowed_signers`
|
||||||
|
file from `devices.json` at the commit (via `GIT_CONFIG_*` env, no
|
||||||
|
global git-config mutation), parses the SSH SHA-256 fingerprint out
|
||||||
|
of `git verify-commit --raw` stderr, and rejects unregistered keys or
|
||||||
|
revoked keys whose committer-date is at or after the revocation
|
||||||
|
timestamp. Bootstrap mode is preserved only when **both**
|
||||||
|
`devices.json` AND `revoked.json` are empty (closes an
|
||||||
|
empty-devices.json privilege-escalation route).
|
||||||
|
- **Backup-restore tar unpacking hardened (audit S2).** `relicario
|
||||||
|
backup restore` no longer trusts `tar::Archive::unpack`'s defaults.
|
||||||
|
A new `relicario_core::safe_unpack_git_archive` validates each
|
||||||
|
entry's path components (rejects `..`, absolute paths, Windows
|
||||||
|
drive prefixes), rejects symlinks/hardlinks, and caps total
|
||||||
|
uncompressed size at the lower of 100×compressed-bytes or 1 GiB.
|
||||||
|
The CLI restore path adds a paranoid `dest.starts_with(.git/)`
|
||||||
|
check after path-joining as defense-in-depth.
|
||||||
|
- **`RELICARIO_*` env-var surface audited (audit S3).** `docs/SECURITY.md`
|
||||||
|
gains a per-variable trust table. `RELICARIO_NO_GROUPS_CACHE` (a
|
||||||
|
developer escape hatch, not a user knob) is now
|
||||||
|
`cfg(debug_assertions)`-gated and is a no-op in `--release` builds;
|
||||||
|
the env-var lookup is removed from the binary by the optimiser.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- **Strength meter no longer goes stale after the regenerate button (B1).**
|
||||||
|
Programmatic `input.value = newPassword` doesn't fire `input`
|
||||||
|
events; the regenerate handler now dispatches a synthetic
|
||||||
|
`InputEvent('input', { bubbles: true })` so the meter listener
|
||||||
|
re-rates the new value.
|
||||||
|
- **Snake_case error codes no longer leak into the UI (B2 / P4).**
|
||||||
|
Errors like `vault_locked`, `origin_mismatch`, `unauthorized_sender`
|
||||||
|
used to render verbatim in the fullscreen vault tab and (in some
|
||||||
|
cases) the popup. New `extension/src/shared/error-copy.ts` central
|
||||||
|
registry maps every service-worker error code to friendly
|
||||||
|
title/body/CTA copy; the popup and fullscreen tab consume the
|
||||||
|
same map. The fullscreen lock screen's `vault_locked` block now
|
||||||
|
reads `Vault locked / Unlock your vault to continue. / [Unlock
|
||||||
|
vault]`. A generated test enumerates the live error codes via
|
||||||
|
grep so the registry can't drift.
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
|
- **Password coloring (P1).** Revealed passwords in the popup
|
||||||
|
item-detail, fullscreen item view, field-history viewer, and
|
||||||
|
generator preview render digits and symbols in distinct colors.
|
||||||
|
Defaults: blue digits, red symbols. Users can override via the
|
||||||
|
new Display section in settings (color pickers + live preview
|
||||||
|
swatch + reset). Defaults round-trip via
|
||||||
|
`chrome.storage.sync.password_display_scheme`; cross-device when
|
||||||
|
Chrome sync is enabled.
|
||||||
|
- **Setup wizard hands off to the fullscreen vault tab on completion
|
||||||
|
(P2).** Both create-new and attach-existing flows now open
|
||||||
|
`vault.html` in a new tab and best-effort close the setup tab
|
||||||
|
after device registration succeeds — replaces the prior
|
||||||
|
setup-tab-stays-open terminal screen.
|
||||||
- **Sync now button** in the extension settings view — surfaces the
|
- **Sync now button** in the extension settings view — surfaces the
|
||||||
previously hidden `{ type: 'sync' }` SW message to users with success /
|
previously hidden `{ type: 'sync' }` SW message to users with success /
|
||||||
error feedback.
|
error feedback.
|
||||||
@@ -59,6 +122,30 @@
|
|||||||
file `cmd_backup_export` writes on success). Reads "never" for
|
file `cmd_backup_export` writes on success). Reads "never" for
|
||||||
fresh vaults, "4 days ago" otherwise.
|
fresh vaults, "4 days ago" otherwise.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Form layout in the fullscreen vault tab is now visually consistent
|
||||||
|
(P3).** Notes, custom-fields disclosure, attachments disclosure, and
|
||||||
|
form-actions in fullscreen logins now sit inside a `.form-lower`
|
||||||
|
wrapper with the same `max-width: 960px; margin: 0 auto` envelope as
|
||||||
|
the `.form-grid` cards above. Removes the visual rhythm break at the
|
||||||
|
2-col → full-width transition. The popup surface is unchanged.
|
||||||
|
- **Documentation refreshed for v0.5.0 (doc audit, 14 findings).**
|
||||||
|
`docs/architecture/overview.md` now describes four codebases (the
|
||||||
|
`relicario-server` pre-receive hook crate is no longer invisible);
|
||||||
|
`CLAUDE.md` project tree and roadmap reflect current state;
|
||||||
|
`docs/SECURITY.md` names the server crate and its `verify-commit` /
|
||||||
|
`generate-hook` subcommands and notes the without-the-hook-it's-
|
||||||
|
advisory caveat; `docs/ARCHITECTURE.md` shows `settings.enc` as a
|
||||||
|
parallel artifact in the vault-creation flow; the foundational
|
||||||
|
design spec gains a "historical" status banner pointing readers at
|
||||||
|
the current docs.
|
||||||
|
- `relicario generate` now consults `VaultSettings.generator_defaults` when
|
||||||
|
invoked inside an initialized vault. Explicit flags (`--length`,
|
||||||
|
`--bip39`, `--words`, `--symbols`, `--separator`) override the vault
|
||||||
|
default. Outside a vault, behavior is unchanged (length 20, safe symbol
|
||||||
|
set, 5 BIP39 words, space separator).
|
||||||
|
|
||||||
### Known limitations
|
### Known limitations
|
||||||
|
|
||||||
- **Mid-restore failure leaves the target remote in a half-written
|
- **Mid-restore failure leaves the target remote in a half-written
|
||||||
@@ -74,6 +161,13 @@
|
|||||||
|
|
||||||
### Internal
|
### Internal
|
||||||
|
|
||||||
|
- 5 stale local feature branches and 3 worktrees pruned (audit C1).
|
||||||
|
- Pre-existing clippy warnings cleaned up across `relicario-{core,cli}`
|
||||||
|
(deref operators, `Option::is_none_or` over `map_or(true, ...)`,
|
||||||
|
`iter_mut().enumerate()` patterns, `div_ceil()`) so the workspace
|
||||||
|
builds clean under `-D warnings`.
|
||||||
|
- `Cargo.lock` regenerated and committed; was stale since the
|
||||||
|
`--totp-qr` commit.
|
||||||
- Refactored `cmd_add` and `cmd_edit` in the CLI: each `ItemCore` variant
|
- Refactored `cmd_add` and `cmd_edit` in the CLI: each `ItemCore` variant
|
||||||
now has its own `build_*_item` / `edit_*` helper. Pure mechanical
|
now has its own `build_*_item` / `edit_*` helper. Pure mechanical
|
||||||
extraction; behavior unchanged. The dispatcher matches and delegates.
|
extraction; behavior unchanged. The dispatcher matches and delegates.
|
||||||
@@ -83,14 +177,6 @@
|
|||||||
`setup.ts` since it walks live wizard state. Setup.ts went from
|
`setup.ts` since it walks live wizard state. Setup.ts went from
|
||||||
1205 → 1137 lines.
|
1205 → 1137 lines.
|
||||||
|
|
||||||
### Changed
|
|
||||||
|
|
||||||
- `relicario generate` now consults `VaultSettings.generator_defaults` when
|
|
||||||
invoked inside an initialized vault. Explicit flags (`--length`,
|
|
||||||
`--bip39`, `--words`, `--symbols`, `--separator`) override the vault
|
|
||||||
default. Outside a vault, behavior is unchanged (length 20, safe symbol
|
|
||||||
set, 5 BIP39 words, space separator).
|
|
||||||
|
|
||||||
## v0.2.0 — 2026-04-27
|
## v0.2.0 — 2026-04-27
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
12
CLAUDE.md
12
CLAUDE.md
@@ -48,9 +48,11 @@ crates/
|
|||||||
│ ├── src/helpers.rs # vault_dir, git_command, iso8601
|
│ ├── src/helpers.rs # vault_dir, git_command, iso8601
|
||||||
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
|
│ ├── src/session.rs # UnlockedVault (master key in Zeroizing)
|
||||||
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
|
│ └── tests/ # basic_flows, edit_and_history, attachments, settings, vault_detection
|
||||||
└── relicario-wasm/ # WASM bindings for the extension
|
├── relicario-wasm/ # WASM bindings for the extension
|
||||||
├── src/lib.rs # #[wasm_bindgen] surface
|
│ ├── src/lib.rs # #[wasm_bindgen] surface
|
||||||
└── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
|
│ └── src/session.rs # opaque SessionHandle → Zeroizing<[u8;32]>
|
||||||
|
└── relicario-server/ # `relicario-server` binary (pre-receive Git hook)
|
||||||
|
└── src/main.rs # verify-commit + generate-hook subcommands
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key design decisions
|
## Key design decisions
|
||||||
@@ -76,7 +78,7 @@ passphrase (UTF-8 bytes) || image_secret (32 bytes from reference JPEG)
|
|||||||
|
|
||||||
- Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever.
|
- Tests use fast Argon2id params (m=256, t=1, p=1) so they don't take forever.
|
||||||
- Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures.
|
- Test JPEGs are generated synthetically via `make_test_jpeg()` — no binary test fixtures.
|
||||||
- Item IDs are random 8-char hex strings.
|
- Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256 over the plaintext (128 bits).
|
||||||
- Git history is preserved as an audit log — no squashing.
|
- Git history is preserved as an audit log — no squashing.
|
||||||
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.
|
- The CLI shells out to `git` for sync — no libgit2/gitoxide dependency.
|
||||||
|
|
||||||
@@ -90,4 +92,4 @@ Full threat model, entropy analysis, and architecture: `docs/superpowers/specs/2
|
|||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM).
|
Next: v0.5.0 polish + harden (in progress). After that, Phases 3/4 of the fullscreen UX redesign (vault-tab shell + command palette), Plan 1C-γ (attachments + Document + trash/history/device UI), and the LastPass importer. Mobile (Rust core compiles to ARM) and recovery QR remain on the roadmap.
|
||||||
|
|||||||
940
Cargo.lock
generated
940
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -91,6 +91,7 @@ pub fn store_device_keys(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load the signing private key for a device.
|
/// Load the signing private key for a device.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
||||||
let path = device_dir(name)?.join("signing.key");
|
let path = device_dir(name)?.join("signing.key");
|
||||||
let key = fs::read_to_string(&path)
|
let key = fs::read_to_string(&path)
|
||||||
@@ -99,6 +100,7 @@ pub fn load_signing_key(name: &str) -> Result<Zeroizing<String>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Load the deploy private key for a device.
|
/// Load the deploy private key for a device.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
|
pub fn load_deploy_key(name: &str) -> Result<Zeroizing<String>> {
|
||||||
let path = device_dir(name)?.join("deploy.key");
|
let path = device_dir(name)?.join("deploy.key");
|
||||||
let key = fs::read_to_string(&path)
|
let key = fs::read_to_string(&path)
|
||||||
@@ -115,6 +117,7 @@ pub fn load_gitea_key_id(name: &str) -> Result<u64> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Delete the local key directory for a device.
|
/// Delete the local key directory for a device.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn delete_device_keys(name: &str) -> Result<()> {
|
pub fn delete_device_keys(name: &str) -> Result<()> {
|
||||||
let dir = device_dir(name)?;
|
let dir = device_dir(name)?;
|
||||||
if dir.exists() {
|
if dir.exists() {
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ struct CreateKeyRequest<'a> {
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct DeployKey {
|
pub struct DeployKey {
|
||||||
pub id: u64,
|
pub id: u64,
|
||||||
|
#[allow(dead_code)]
|
||||||
pub title: String,
|
pub title: String,
|
||||||
|
#[allow(dead_code)]
|
||||||
pub key: String,
|
pub key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ impl GiteaClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// List all deploy keys.
|
/// List all deploy keys.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn list_deploy_keys(&self) -> Result<Vec<DeployKey>> {
|
pub fn list_deploy_keys(&self) -> Result<Vec<DeployKey>> {
|
||||||
let url = format!(
|
let url = format!(
|
||||||
"{}/repos/{}/{}/keys",
|
"{}/repos/{}/{}/keys",
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub fn vault_dir() -> Result<PathBuf> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Path to the `.relicario/` configuration directory within the vault.
|
/// Path to the `.relicario/` configuration directory within the vault.
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn relicario_dir() -> Result<PathBuf> {
|
pub fn relicario_dir() -> Result<PathBuf> {
|
||||||
Ok(vault_dir()?.join(".relicario"))
|
Ok(vault_dir()?.join(".relicario"))
|
||||||
}
|
}
|
||||||
@@ -88,19 +89,21 @@ fn plural(n: i64) -> &'static str { if n == 1 { "" } else { "s" } }
|
|||||||
///
|
///
|
||||||
/// **Plaintext leak:** group names land on disk in cleartext alongside the
|
/// **Plaintext leak:** group names land on disk in cleartext alongside the
|
||||||
/// vault directory. This is intentional — the file feeds shell completion,
|
/// vault directory. This is intentional — the file feeds shell completion,
|
||||||
/// which cannot prompt for a passphrase. Set `RELICARIO_NO_GROUPS_CACHE=1`
|
/// which cannot prompt for a passphrase. In debug builds, set
|
||||||
/// to suppress the write.
|
/// `RELICARIO_NO_GROUPS_CACHE=1` to suppress the write.
|
||||||
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
pub fn groups_cache_path(vault_dir: &Path) -> PathBuf {
|
||||||
vault_dir.join(".relicario").join("groups.cache")
|
vault_dir.join(".relicario").join("groups.cache")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
/// Write the sorted set of group names to `<vault_dir>/.relicario/groups.cache`,
|
||||||
/// one name per line. A no-op if `RELICARIO_NO_GROUPS_CACHE` is set.
|
/// one name per line. In debug builds, setting `RELICARIO_NO_GROUPS_CACHE`
|
||||||
|
/// suppresses the write (developer debugging tool). In release builds the env
|
||||||
|
/// var is ignored.
|
||||||
pub fn write_groups_cache(
|
pub fn write_groups_cache(
|
||||||
vault_dir: &Path,
|
vault_dir: &Path,
|
||||||
groups: &std::collections::BTreeSet<String>,
|
groups: &std::collections::BTreeSet<String>,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
if std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
|
if cfg!(debug_assertions) && std::env::var_os("RELICARIO_NO_GROUPS_CACHE").is_some() {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
let path = groups_cache_path(vault_dir);
|
let path = groups_cache_path(vault_dir);
|
||||||
|
|||||||
@@ -170,7 +170,7 @@ enum Commands {
|
|||||||
///
|
///
|
||||||
/// For `--group <TAB>` autocomplete, the bash/zsh/fish scripts read
|
/// For `--group <TAB>` autocomplete, the bash/zsh/fish scripts read
|
||||||
/// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
|
/// the plaintext `${RELICARIO_VAULT}/.relicario/groups.cache` file,
|
||||||
/// which the CLI refreshes on every manifest read. Set
|
/// which the CLI refreshes on every manifest read. In debug builds, set
|
||||||
/// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
|
/// `RELICARIO_NO_GROUPS_CACHE=1` to opt out of the cache (completion
|
||||||
/// will fall back to no value enumeration).
|
/// will fall back to no value enumeration).
|
||||||
///
|
///
|
||||||
@@ -540,7 +540,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
};
|
};
|
||||||
let carrier = fs::read(&image)
|
let carrier = fs::read(&image)
|
||||||
.with_context(|| format!("failed to read carrier image {}", image.display()))?;
|
.with_context(|| format!("failed to read carrier image {}", image.display()))?;
|
||||||
let stego = imgsecret::embed(&carrier, &*image_secret)?;
|
let stego = imgsecret::embed(&carrier, &image_secret)?;
|
||||||
fs::write(&output, &stego)
|
fs::write(&output, &stego)
|
||||||
.with_context(|| format!("failed to write reference image {}", output.display()))?;
|
.with_context(|| format!("failed to write reference image {}", output.display()))?;
|
||||||
|
|
||||||
@@ -550,7 +550,7 @@ fn cmd_init(image: PathBuf, output: PathBuf) -> Result<()> {
|
|||||||
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
let params = KdfParams { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
|
||||||
|
|
||||||
// Derive master key, then persist an empty Manifest + default VaultSettings.
|
// Derive master key, then persist an empty Manifest + default VaultSettings.
|
||||||
let master_key = derive_master_key(passphrase.as_bytes(), &*image_secret, &salt, ¶ms)?;
|
let master_key = derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms)?;
|
||||||
|
|
||||||
fs::create_dir_all(&relicario_dir)?;
|
fs::create_dir_all(&relicario_dir)?;
|
||||||
fs::create_dir_all(root.join("items"))?;
|
fs::create_dir_all(root.join("items"))?;
|
||||||
@@ -645,6 +645,7 @@ fn cmd_add(kind: AddKind) -> Result<()> {
|
|||||||
// (for attachment-cap settings + writing the encrypted blob alongside
|
// (for attachment-cap settings + writing the encrypted blob alongside
|
||||||
// the item).
|
// the item).
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn build_login_item(
|
fn build_login_item(
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
@@ -860,6 +861,7 @@ fn build_document_item(
|
|||||||
Ok(item)
|
Ok(item)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
fn build_totp_item(
|
fn build_totp_item(
|
||||||
title: Option<String>,
|
title: Option<String>,
|
||||||
issuer: Option<String>,
|
issuer: Option<String>,
|
||||||
@@ -924,7 +926,7 @@ fn prompt_optional(label: &str) -> Result<Option<String>> {
|
|||||||
|
|
||||||
fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
|
fn parse_month_year(s: &str) -> Result<relicario_core::MonthYear> {
|
||||||
// Accepts MM/YYYY or MM-YYYY or MM/YY.
|
// Accepts MM/YYYY or MM-YYYY or MM/YY.
|
||||||
let (m_str, y_str) = s.split_once(|c: char| c == '/' || c == '-')
|
let (m_str, y_str) = s.split_once(['/', '-'])
|
||||||
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
|
.ok_or_else(|| anyhow::anyhow!("expected MM/YYYY"))?;
|
||||||
let month: u8 = m_str.parse().context("invalid month")?;
|
let month: u8 = m_str.parse().context("invalid month")?;
|
||||||
let year: u16 = if y_str.len() == 2 {
|
let year: u16 = if y_str.len() == 2 {
|
||||||
@@ -998,12 +1000,12 @@ fn cmd_get(query: String, show: bool, copy: bool) -> Result<()> {
|
|||||||
if let Some(u) = &l.url { println!("URL: {u}"); }
|
if let Some(u) = &l.url { println!("URL: {u}"); }
|
||||||
if let Some(t) = &l.totp {
|
if let Some(t) = &l.totp {
|
||||||
if show {
|
if show {
|
||||||
println!("TOTP: {}", data_encoding::BASE32.encode(&*t.secret));
|
println!("TOTP: {}", data_encoding::BASE32.encode(&t.secret));
|
||||||
} else {
|
} else {
|
||||||
println!("TOTP: **** (use --show to reveal)");
|
println!("TOTP: **** (use --show to reveal)");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(p) = &l.password { Some(p.clone()) } else { None }
|
l.password.clone()
|
||||||
}
|
}
|
||||||
ItemCore::SecureNote(n) => {
|
ItemCore::SecureNote(n) => {
|
||||||
if show { println!("Body:\n{}", n.body.as_str()); }
|
if show { println!("Body:\n{}", n.body.as_str()); }
|
||||||
@@ -1125,8 +1127,8 @@ fn cmd_list(
|
|||||||
Some(t) => e.r#type == t,
|
Some(t) => e.r#type == t,
|
||||||
None => true,
|
None => true,
|
||||||
})
|
})
|
||||||
.filter(|e| group_filter.as_ref().map_or(true, |g| e.group.as_deref() == Some(g.as_str())))
|
.filter(|e| group_filter.as_ref().is_none_or(|g| e.group.as_deref() == Some(g.as_str())))
|
||||||
.filter(|e| tag_filter.as_ref().map_or(true, |t| e.tags.iter().any(|x| x == t)))
|
.filter(|e| tag_filter.as_ref().is_none_or(|t| e.tags.iter().any(|x| x == t)))
|
||||||
.collect();
|
.collect();
|
||||||
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
entries.sort_by(|a, b| a.title.to_lowercase().cmp(&b.title.to_lowercase()));
|
||||||
|
|
||||||
@@ -1135,7 +1137,7 @@ fn cmd_list(
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{:<16} {:<14} {:<6} {}", "ID", "TYPE", "FAV", "TITLE");
|
println!("{:<16} {:<14} {:<6} TITLE", "ID", "TYPE", "FAV");
|
||||||
for e in entries {
|
for e in entries {
|
||||||
let fav = if e.favorite { " *" } else { "" };
|
let fav = if e.favorite { " *" } else { "" };
|
||||||
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
|
println!("{:<16} {:<14} {:<6} {}", e.id.as_str(), format!("{:?}", e.r#type), fav, e.title);
|
||||||
@@ -1718,9 +1720,32 @@ fn cmd_backup_restore(input: PathBuf, target: PathBuf) -> Result<()> {
|
|||||||
|
|
||||||
// .git/ history.
|
// .git/ history.
|
||||||
if let Some(tar_bytes) = &unpacked.git_archive {
|
if let Some(tar_bytes) = &unpacked.git_archive {
|
||||||
let mut archive = tar::Archive::new(tar_bytes.as_slice());
|
// Cap: 100× the compressed bundle size, or 1 GiB, whichever is lower.
|
||||||
archive.unpack(target.join(".git"))
|
let cap = std::cmp::min(
|
||||||
.with_context(|| "failed to untar .git/")?;
|
(tar_bytes.len() as u64).saturating_mul(100),
|
||||||
|
relicario_core::DEFAULT_MAX_UNCOMPRESSED,
|
||||||
|
);
|
||||||
|
let entries = relicario_core::safe_unpack_git_archive(tar_bytes, cap)
|
||||||
|
.with_context(|| "failed to safely unpack .git/ archive")?;
|
||||||
|
let git_dir = target.join(".git");
|
||||||
|
for (rel_path, body) in entries {
|
||||||
|
let dest = git_dir.join(&rel_path);
|
||||||
|
// Paranoid OS-level check even after textual validation in core.
|
||||||
|
if !dest.starts_with(&git_dir) {
|
||||||
|
anyhow::bail!(
|
||||||
|
"tar entry {} resolved outside .git/ (path traversal blocked)",
|
||||||
|
rel_path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if let Some(parent) = dest.parent() {
|
||||||
|
fs::create_dir_all(parent).with_context(|| {
|
||||||
|
format!("create parent {}", parent.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
fs::write(&dest, &body).with_context(|| {
|
||||||
|
format!("write {}", dest.display())
|
||||||
|
})?;
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// No history bundled — start a fresh git repo.
|
// No history bundled — start a fresh git repo.
|
||||||
let status = crate::helpers::git_command(&target, &["init"]).status()?;
|
let status = crate::helpers::git_command(&target, &["init"]).status()?;
|
||||||
@@ -1950,7 +1975,7 @@ fn cmd_attachments(query: String) -> Result<()> {
|
|||||||
let entry = resolve_query(&manifest, &query)?;
|
let entry = resolve_query(&manifest, &query)?;
|
||||||
let item = vault.load_item(&entry.id)?;
|
let item = vault.load_item(&entry.id)?;
|
||||||
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
|
if item.attachments.is_empty() { eprintln!("(no attachments)"); return Ok(()); }
|
||||||
println!("{:<17} {:>12} {:<22} {}", "AID", "SIZE", "MIME", "FILENAME");
|
println!("{:<17} {:>12} {:<22} FILENAME", "AID", "SIZE", "MIME");
|
||||||
for a in &item.attachments {
|
for a in &item.attachments {
|
||||||
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
|
println!("{:<17} {:>12} {:<22} {}", a.id.as_str(), a.size, a.mime_type, a.filename);
|
||||||
}
|
}
|
||||||
@@ -2518,7 +2543,7 @@ fn cmd_device(action: DeviceAction) -> Result<()> {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
println!("{:<20} {:<20} {}", "NAME", "ADDED", "SIGNING KEY (prefix)");
|
println!("{:<20} {:<20} SIGNING KEY (prefix)", "NAME", "ADDED");
|
||||||
println!("{}", "-".repeat(72));
|
println!("{}", "-".repeat(72));
|
||||||
for d in &devices {
|
for d in &devices {
|
||||||
let marker = if d.name == current { " *" } else { "" };
|
let marker = if d.name == current { " *" } else { "" };
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ impl UnlockedVault {
|
|||||||
|
|
||||||
let master_key = derive_master_key(
|
let master_key = derive_master_key(
|
||||||
passphrase.as_bytes(),
|
passphrase.as_bytes(),
|
||||||
&*image_secret,
|
&image_secret,
|
||||||
&salt,
|
&salt,
|
||||||
¶ms,
|
¶ms,
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ fn detach_removes_attachment_and_blob() {
|
|||||||
// Encrypted blob file is gone.
|
// Encrypted blob file is gone.
|
||||||
let blob_path = v.path()
|
let blob_path = v.path()
|
||||||
.join("attachments")
|
.join("attachments")
|
||||||
.join(stdout.lines().nth(1).is_some().then_some("").unwrap_or(""));
|
.join("");
|
||||||
let item_attach_dir = std::fs::read_dir(v.path().join("attachments"))
|
let item_attach_dir = std::fs::read_dir(v.path().join("attachments"))
|
||||||
.unwrap().next().unwrap().unwrap().path();
|
.unwrap().next().unwrap().unwrap().path();
|
||||||
let blob = item_attach_dir.join(format!("{aid}.enc"));
|
let blob = item_attach_dir.join(format!("{aid}.enc"));
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ impl TestVault {
|
|||||||
cmd.output().unwrap()
|
cmd.output().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output {
|
pub fn run_with_backup_pass(&self, args: &[&str], backup_pass: &str) -> std::process::Output {
|
||||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
cmd.current_dir(self.dir.path())
|
cmd.current_dir(self.dir.path())
|
||||||
@@ -91,6 +92,7 @@ impl TestVault {
|
|||||||
cmd.output().unwrap()
|
cmd.output().unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
|
pub fn run_with_input(&self, args: &[&str], extra: &[&str]) -> std::process::Output {
|
||||||
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
let mut cmd = Command::cargo_bin("relicario").unwrap();
|
||||||
cmd.current_dir(self.dir.path())
|
cmd.current_dir(self.dir.path())
|
||||||
|
|||||||
@@ -408,7 +408,7 @@ mod tests {
|
|||||||
blob.extend_from_slice(&[0u8; 16]);
|
blob.extend_from_slice(&[0u8; 16]);
|
||||||
|
|
||||||
let key = Zeroizing::new([0u8; 32]);
|
let key = Zeroizing::new([0u8; 32]);
|
||||||
let err = decrypt(&*key, &blob).expect_err("v1 blob should fail decrypt");
|
let err = decrypt(&key, &blob).expect_err("v1 blob should fail decrypt");
|
||||||
match err {
|
match err {
|
||||||
RelicarioError::UnsupportedFormatVersion { found, expected } => {
|
RelicarioError::UnsupportedFormatVersion { found, expected } => {
|
||||||
assert_eq!(found, 0x01);
|
assert_eq!(found, 0x01);
|
||||||
|
|||||||
@@ -106,6 +106,16 @@ pub fn verify(public_key_openssh: &str, data: &[u8], signature_b64: &str) -> Res
|
|||||||
Ok(verifying_key.verify(data, &signature).is_ok())
|
Ok(verifying_key.verify(data, &signature).is_ok())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compute the OpenSSH SHA-256 fingerprint of a public key.
|
||||||
|
/// Output format matches `ssh-keygen -lf` and `git verify-commit --raw`:
|
||||||
|
/// `SHA256:<43-char base64 without padding>`.
|
||||||
|
pub fn fingerprint(public_key_openssh: &str) -> Result<String> {
|
||||||
|
use ssh_key::HashAlg;
|
||||||
|
let public = PublicKey::from_openssh(public_key_openssh)
|
||||||
|
.map_err(|e| RelicarioError::DeviceKey(format!("parse public key: {e}")))?;
|
||||||
|
Ok(public.fingerprint(HashAlg::Sha256).to_string())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -132,4 +142,27 @@ mod tests {
|
|||||||
let sig = sign(&private, b"hello").unwrap();
|
let sig = sign(&private, b"hello").unwrap();
|
||||||
assert!(!verify(&other_public, b"hello", &sig).unwrap());
|
assert!(!verify(&other_public, b"hello", &sig).unwrap());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprint_matches_ssh_keygen_format() {
|
||||||
|
let (_, public) = generate_keypair().unwrap();
|
||||||
|
let fp = fingerprint(&public).unwrap();
|
||||||
|
assert!(fp.starts_with("SHA256:"), "fingerprint should start with SHA256: prefix, got {fp}");
|
||||||
|
let body = fp.strip_prefix("SHA256:").unwrap();
|
||||||
|
assert_eq!(body.len(), 43, "SHA-256 fingerprint body is 43 base64 chars (no padding)");
|
||||||
|
assert!(body.chars().all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '/'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprint_is_deterministic() {
|
||||||
|
let (_, public) = generate_keypair().unwrap();
|
||||||
|
assert_eq!(fingerprint(&public).unwrap(), fingerprint(&public).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fingerprint_differs_per_key() {
|
||||||
|
let (_, p1) = generate_keypair().unwrap();
|
||||||
|
let (_, p2) = generate_keypair().unwrap();
|
||||||
|
assert_ne!(fingerprint(&p1).unwrap(), fingerprint(&p2).unwrap());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,6 +51,10 @@ pub enum RelicarioError {
|
|||||||
#[error("backup envelope schema v{found}; this Relicario reads v{expected}")]
|
#[error("backup envelope schema v{found}; this Relicario reads v{expected}")]
|
||||||
BackupSchemaMismatch { found: u32, expected: u32 },
|
BackupSchemaMismatch { found: u32, expected: u32 },
|
||||||
|
|
||||||
|
/// An error during backup restore (e.g., tar safety validation failure).
|
||||||
|
#[error("backup restore: {0}")]
|
||||||
|
BackupRestore(String),
|
||||||
|
|
||||||
/// CSV header doesn't match the LastPass column layout.
|
/// CSV header doesn't match the LastPass column layout.
|
||||||
#[error("unrecognized CSV header — expected LastPass export format ({0})")]
|
#[error("unrecognized CSV header — expected LastPass export format ({0})")]
|
||||||
ImportCsvHeader(String),
|
ImportCsvHeader(String),
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ const BITS_PER_BLOCK: usize = 12; // EMBED_POSITIONS.len()
|
|||||||
|
|
||||||
/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret.
|
/// Number of 8x8 blocks needed to hold one complete copy of the 256-bit secret.
|
||||||
/// ceil(256 / 12) = 22 blocks per copy.
|
/// ceil(256 / 12) = 22 blocks per copy.
|
||||||
const BLOCKS_PER_COPY: usize = (SECRET_BITS + BITS_PER_BLOCK - 1) / BITS_PER_BLOCK; // 22
|
const BLOCKS_PER_COPY: usize = SECRET_BITS.div_ceil(BITS_PER_BLOCK); // 22
|
||||||
|
|
||||||
/// Mid-frequency DCT coefficient positions for embedding, specified as
|
/// Mid-frequency DCT coefficient positions for embedding, specified as
|
||||||
/// (row, col) indices into the 8x8 DCT coefficient matrix.
|
/// (row, col) indices into the 8x8 DCT coefficient matrix.
|
||||||
@@ -302,9 +302,9 @@ fn read_block_abs(y: &YChannel, px: usize, py: usize) -> Option<[[f64; 8]; 8]> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
let mut block = [[0.0f64; 8]; 8];
|
let mut block = [[0.0f64; 8]; 8];
|
||||||
for row in 0..8 {
|
for (row, block_row) in block.iter_mut().enumerate() {
|
||||||
for col in 0..8 {
|
for (col, cell) in block_row.iter_mut().enumerate() {
|
||||||
block[row][col] = y.get(px + col, py + row);
|
*cell = y.get(px + col, py + row);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(block)
|
Some(block)
|
||||||
@@ -323,9 +323,9 @@ fn read_block(y: &YChannel, bx: usize, by: usize, region: &EmbedRegion) -> [[f64
|
|||||||
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
|
fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, block: &[[f64; 8]; 8]) {
|
||||||
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
let start_x = region.x_offset + bx * BLOCK_SIZE;
|
||||||
let start_y = region.y_offset + by * BLOCK_SIZE;
|
let start_y = region.y_offset + by * BLOCK_SIZE;
|
||||||
for row in 0..8 {
|
for (row, block_row) in block.iter().enumerate() {
|
||||||
for col in 0..8 {
|
for (col, &cell) in block_row.iter().enumerate() {
|
||||||
y.set(start_x + col, start_y + row, block[row][col]);
|
y.set(start_x + col, start_y + row, cell);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,17 +349,17 @@ fn write_block(y: &mut YChannel, bx: usize, by: usize, region: &EmbedRegion, blo
|
|||||||
/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0.
|
/// where c(0) = sqrt(1/8) and c(k) = sqrt(2/8) for k > 0.
|
||||||
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||||
let mut output = [0.0f64; 8];
|
let mut output = [0.0f64; 8];
|
||||||
for k in 0..8 {
|
for (k, out_k) in output.iter_mut().enumerate() {
|
||||||
let ck = if k == 0 {
|
let ck = if k == 0 {
|
||||||
(1.0 / 8.0_f64).sqrt()
|
(1.0 / 8.0_f64).sqrt()
|
||||||
} else {
|
} else {
|
||||||
(2.0 / 8.0_f64).sqrt()
|
(2.0 / 8.0_f64).sqrt()
|
||||||
};
|
};
|
||||||
let mut sum = 0.0;
|
let mut sum = 0.0;
|
||||||
for i in 0..8 {
|
for (i, &x) in input.iter().enumerate() {
|
||||||
sum += input[i] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
sum += x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||||
}
|
}
|
||||||
output[k] = ck * sum;
|
*out_k = ck * sum;
|
||||||
}
|
}
|
||||||
output
|
output
|
||||||
}
|
}
|
||||||
@@ -370,17 +370,17 @@ fn dct1d(input: &[f64; 8]) -> [f64; 8] {
|
|||||||
/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16)
|
/// x[i] = sum_{k=0}^{7} c(k) * X[k] * cos((2i+1)*k*pi/16)
|
||||||
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
fn idct1d(input: &[f64; 8]) -> [f64; 8] {
|
||||||
let mut output = [0.0f64; 8];
|
let mut output = [0.0f64; 8];
|
||||||
for i in 0..8 {
|
for (i, out_i) in output.iter_mut().enumerate() {
|
||||||
let mut sum = 0.0;
|
let mut sum = 0.0;
|
||||||
for k in 0..8 {
|
for (k, &x) in input.iter().enumerate() {
|
||||||
let ck = if k == 0 {
|
let ck = if k == 0 {
|
||||||
(1.0 / 8.0_f64).sqrt()
|
(1.0 / 8.0_f64).sqrt()
|
||||||
} else {
|
} else {
|
||||||
(2.0 / 8.0_f64).sqrt()
|
(2.0 / 8.0_f64).sqrt()
|
||||||
};
|
};
|
||||||
sum += ck * input[k] * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
sum += ck * x * ((2 * i + 1) as f64 * k as f64 * PI / 16.0).cos();
|
||||||
}
|
}
|
||||||
output[i] = sum;
|
*out_i = sum;
|
||||||
}
|
}
|
||||||
output
|
output
|
||||||
}
|
}
|
||||||
@@ -501,7 +501,7 @@ fn bytes_to_bits(bytes: &[u8]) -> Vec<u8> {
|
|||||||
///
|
///
|
||||||
/// Pads the last byte with zeros if the bit count is not a multiple of 8.
|
/// Pads the last byte with zeros if the bit count is not a multiple of 8.
|
||||||
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
fn bits_to_bytes(bits: &[u8]) -> Vec<u8> {
|
||||||
let mut bytes = Vec::with_capacity((bits.len() + 7) / 8);
|
let mut bytes = Vec::with_capacity(bits.len().div_ceil(8));
|
||||||
for chunk in bits.chunks(8) {
|
for chunk in bits.chunks(8) {
|
||||||
let mut byte = 0u8;
|
let mut byte = 0u8;
|
||||||
for (i, &bit) in chunk.iter().enumerate() {
|
for (i, &bit) in chunk.iter().enumerate() {
|
||||||
|
|||||||
@@ -52,18 +52,15 @@ pub enum TotpAlgorithm {
|
|||||||
Sha512,
|
Sha512,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum TotpKind {
|
pub enum TotpKind {
|
||||||
|
#[default]
|
||||||
Totp,
|
Totp,
|
||||||
Hotp { counter: u64 },
|
Hotp { counter: u64 },
|
||||||
Steam,
|
Steam,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for TotpKind {
|
|
||||||
fn default() -> Self { TotpKind::Totp }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Compute a TOTP/Steam code for `config` at the given Unix timestamp.
|
/// Compute a TOTP/Steam code for `config` at the given Unix timestamp.
|
||||||
///
|
///
|
||||||
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
|
/// For TOTP and Steam: counter = `now_unix_seconds / period_seconds`.
|
||||||
|
|||||||
@@ -85,4 +85,7 @@ pub mod import_lastpass;
|
|||||||
pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
pub use import_lastpass::{parse_lastpass_csv, ImportWarning};
|
||||||
|
|
||||||
pub mod device;
|
pub mod device;
|
||||||
pub use device::{DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
pub use device::{fingerprint, DeviceEntry, RevokedEntry, generate_keypair, sign, verify};
|
||||||
|
|
||||||
|
pub mod tar_safe;
|
||||||
|
pub use tar_safe::{safe_unpack_git_archive, DEFAULT_MAX_UNCOMPRESSED};
|
||||||
|
|||||||
138
crates/relicario-core/src/tar_safe.rs
Normal file
138
crates/relicario-core/src/tar_safe.rs
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
//! Safe tar unpacking for backup restore.
|
||||||
|
//!
|
||||||
|
//! The standard `tar::Archive::unpack` has no guards against path traversal,
|
||||||
|
//! absolute paths, symlinks, hardlinks, or tar bombs. This module replaces it
|
||||||
|
//! with `safe_unpack_git_archive`, which validates every entry before returning
|
||||||
|
//! `(relative_path, bytes)` pairs to the caller.
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
use std::path::{Component, PathBuf};
|
||||||
|
|
||||||
|
use tar::EntryType;
|
||||||
|
|
||||||
|
use crate::error::{RelicarioError, Result};
|
||||||
|
|
||||||
|
/// Default cap on total uncompressed bytes extracted in one restore (1 GiB).
|
||||||
|
pub const DEFAULT_MAX_UNCOMPRESSED: u64 = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
/// Decode `tar_bytes` and return `(relative_path, file_bytes)` pairs for
|
||||||
|
/// regular files only.
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
///
|
||||||
|
/// Returns `Err(RelicarioError::BackupRestore(...))` if:
|
||||||
|
///
|
||||||
|
/// - Any path component is `..` (`Component::ParentDir`) — "path traversal blocked".
|
||||||
|
/// - Any path starts with `/` (`Component::RootDir`) — "path traversal blocked".
|
||||||
|
/// - Any path has a Windows drive prefix (`Component::Prefix`) — "path traversal blocked".
|
||||||
|
/// - An entry is a symlink or hardlink — "symlink/link rejected".
|
||||||
|
/// - An entry's declared size exceeds `max_uncompressed_bytes` — "size cap exceeded".
|
||||||
|
/// - The running total of all entry sizes exceeds `max_uncompressed_bytes` — "size cap exceeded".
|
||||||
|
/// - An entry has an unexpected type (not regular file, not directory) — "unexpected entry type".
|
||||||
|
pub fn safe_unpack_git_archive(
|
||||||
|
tar_bytes: &[u8],
|
||||||
|
max_uncompressed_bytes: u64,
|
||||||
|
) -> Result<Vec<(PathBuf, Vec<u8>)>> {
|
||||||
|
let mut archive = tar::Archive::new(tar_bytes);
|
||||||
|
let entries = archive
|
||||||
|
.entries()
|
||||||
|
.map_err(|e| RelicarioError::BackupRestore(format!("failed to read tar entries: {e}")))?;
|
||||||
|
|
||||||
|
let mut result: Vec<(PathBuf, Vec<u8>)> = Vec::new();
|
||||||
|
let mut cumulative: u64 = 0;
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let mut entry = entry.map_err(|e| {
|
||||||
|
RelicarioError::BackupRestore(format!("failed to read tar entry: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let header = entry.header();
|
||||||
|
let entry_type = header.entry_type();
|
||||||
|
|
||||||
|
// Reject symlinks and hardlinks.
|
||||||
|
match entry_type {
|
||||||
|
EntryType::Symlink => {
|
||||||
|
return Err(RelicarioError::BackupRestore(
|
||||||
|
"symlink entry rejected".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
EntryType::Link => {
|
||||||
|
return Err(RelicarioError::BackupRestore(
|
||||||
|
"hardlink entry rejected".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
EntryType::Directory => {
|
||||||
|
// Directories are implicit — skip without reading body.
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
EntryType::Regular | EntryType::Continuous | EntryType::GNUSparse => {
|
||||||
|
// These are normal file types; fall through to path checks.
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(RelicarioError::BackupRestore(format!(
|
||||||
|
"unexpected entry type: {:?}",
|
||||||
|
entry_type
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the path.
|
||||||
|
let path = entry.path().map_err(|e| {
|
||||||
|
RelicarioError::BackupRestore(format!("invalid path in tar entry: {e}"))
|
||||||
|
})?;
|
||||||
|
let path = path.into_owned();
|
||||||
|
|
||||||
|
for component in path.components() {
|
||||||
|
match component {
|
||||||
|
Component::ParentDir => {
|
||||||
|
return Err(RelicarioError::BackupRestore(
|
||||||
|
"path traversal blocked: entry contains '..' component".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Component::RootDir => {
|
||||||
|
return Err(RelicarioError::BackupRestore(
|
||||||
|
"path traversal blocked: entry has absolute path".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Component::Prefix(_) => {
|
||||||
|
return Err(RelicarioError::BackupRestore(
|
||||||
|
"path traversal blocked: entry has Windows drive prefix".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
Component::Normal(_) | Component::CurDir => {
|
||||||
|
// Acceptable components.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check declared size before reading body.
|
||||||
|
let claimed = header.size().map_err(|e| {
|
||||||
|
RelicarioError::BackupRestore(format!("could not read entry size: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if claimed > max_uncompressed_bytes {
|
||||||
|
return Err(RelicarioError::BackupRestore(format!(
|
||||||
|
"size cap exceeded: entry claims {claimed} bytes (cap {max_uncompressed_bytes})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let new_total = cumulative.saturating_add(claimed);
|
||||||
|
if new_total > max_uncompressed_bytes {
|
||||||
|
return Err(RelicarioError::BackupRestore(format!(
|
||||||
|
"size cap exceeded: cumulative size would reach {new_total} bytes (cap {max_uncompressed_bytes})"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the file body.
|
||||||
|
let mut body = Vec::with_capacity(claimed as usize);
|
||||||
|
entry.read_to_end(&mut body).map_err(|e| {
|
||||||
|
RelicarioError::BackupRestore(format!("failed to read entry body: {e}"))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
cumulative += body.len() as u64;
|
||||||
|
|
||||||
|
result.push((path, body));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(result)
|
||||||
|
}
|
||||||
@@ -19,7 +19,7 @@ impl MonthYear {
|
|||||||
if !(1..=12).contains(&month) {
|
if !(1..=12).contains(&month) {
|
||||||
return Err("month must be 1..=12");
|
return Err("month must be 1..=12");
|
||||||
}
|
}
|
||||||
if year < 2000 || year > 2099 {
|
if !(2000..=2099).contains(&year) {
|
||||||
return Err("year must be 2000..=2099");
|
return Err("year must be 2000..=2099");
|
||||||
}
|
}
|
||||||
Ok(Self { month, year })
|
Ok(Self { month, year })
|
||||||
|
|||||||
187
crates/relicario-core/tests/safe_unpack.rs
Normal file
187
crates/relicario-core/tests/safe_unpack.rs
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
use tar::{Builder, Header, EntryType};
|
||||||
|
use relicario_core::safe_unpack_git_archive;
|
||||||
|
|
||||||
|
/// Craft a raw POSIX ustar tar with a single entry using the given raw path bytes.
|
||||||
|
/// The tar crate's `Builder` sanitises paths, so we write the 512-byte header
|
||||||
|
/// manually to produce truly malicious archives.
|
||||||
|
fn raw_tar_with_path(raw_path: &[u8], content: &[u8]) -> Vec<u8> {
|
||||||
|
let mut buf = vec![0u8; 512]; // one header block
|
||||||
|
|
||||||
|
// Bytes 0-99: name field (null-padded)
|
||||||
|
let name_len = raw_path.len().min(100);
|
||||||
|
buf[..name_len].copy_from_slice(&raw_path[..name_len]);
|
||||||
|
|
||||||
|
// Bytes 100-107: mode = "0000644\0"
|
||||||
|
buf[100..108].copy_from_slice(b"0000644\0");
|
||||||
|
|
||||||
|
// Bytes 108-115: uid
|
||||||
|
buf[108..116].copy_from_slice(b"0000000\0");
|
||||||
|
|
||||||
|
// Bytes 116-123: gid
|
||||||
|
buf[116..124].copy_from_slice(b"0000000\0");
|
||||||
|
|
||||||
|
// Bytes 124-135: size (octal, 11 digits + null)
|
||||||
|
let size_str = format!("{:011o}\0", content.len());
|
||||||
|
buf[124..136].copy_from_slice(size_str.as_bytes());
|
||||||
|
|
||||||
|
// Bytes 136-147: mtime
|
||||||
|
buf[136..148].copy_from_slice(b"00000000000\0");
|
||||||
|
|
||||||
|
// Bytes 148-155: checksum placeholder (spaces during compute)
|
||||||
|
buf[148..156].copy_from_slice(b" ");
|
||||||
|
|
||||||
|
// Byte 156: typeflag = '0' (regular file)
|
||||||
|
buf[156] = b'0';
|
||||||
|
|
||||||
|
// Bytes 257-262: magic "ustar\0"
|
||||||
|
buf[257..263].copy_from_slice(b"ustar\0");
|
||||||
|
// Bytes 263-264: version "00"
|
||||||
|
buf[263..265].copy_from_slice(b"00");
|
||||||
|
|
||||||
|
// Compute checksum (sum of all bytes, checksum field treated as spaces).
|
||||||
|
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
|
||||||
|
let cksum_str = format!("{:06o}\0 ", checksum);
|
||||||
|
buf[148..156].copy_from_slice(cksum_str.as_bytes());
|
||||||
|
|
||||||
|
// Append padded content blocks.
|
||||||
|
let mut out = buf;
|
||||||
|
if !content.is_empty() {
|
||||||
|
out.extend_from_slice(content);
|
||||||
|
// Pad to 512-byte boundary.
|
||||||
|
let remainder = content.len() % 512;
|
||||||
|
if remainder != 0 {
|
||||||
|
out.extend(vec![0u8; 512 - remainder]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two zero blocks = end-of-archive.
|
||||||
|
out.extend(vec![0u8; 1024]);
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a tar with a raw symlink entry (typeflag = '2').
|
||||||
|
fn raw_symlink_tar() -> Vec<u8> {
|
||||||
|
let mut buf = vec![0u8; 512];
|
||||||
|
|
||||||
|
// name
|
||||||
|
buf[..9].copy_from_slice(b"evil_link");
|
||||||
|
// mode
|
||||||
|
buf[100..108].copy_from_slice(b"0000755\0");
|
||||||
|
// uid/gid
|
||||||
|
buf[108..116].copy_from_slice(b"0000000\0");
|
||||||
|
buf[116..124].copy_from_slice(b"0000000\0");
|
||||||
|
// size = 0
|
||||||
|
buf[124..136].copy_from_slice(b"00000000000\0");
|
||||||
|
// mtime
|
||||||
|
buf[136..148].copy_from_slice(b"00000000000\0");
|
||||||
|
// checksum placeholder
|
||||||
|
buf[148..156].copy_from_slice(b" ");
|
||||||
|
// typeflag = '2' (symlink)
|
||||||
|
buf[156] = b'2';
|
||||||
|
// linkname
|
||||||
|
let target = b"/etc/passwd";
|
||||||
|
buf[157..157 + target.len()].copy_from_slice(target);
|
||||||
|
// magic
|
||||||
|
buf[257..263].copy_from_slice(b"ustar\0");
|
||||||
|
buf[263..265].copy_from_slice(b"00");
|
||||||
|
|
||||||
|
// Compute checksum.
|
||||||
|
let checksum: u32 = buf.iter().map(|&b| b as u32).sum();
|
||||||
|
let cksum_str = format!("{:06o}\0 ", checksum);
|
||||||
|
buf[148..156].copy_from_slice(cksum_str.as_bytes());
|
||||||
|
|
||||||
|
let mut out = buf;
|
||||||
|
out.extend(vec![0u8; 1024]); // end-of-archive
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_normal_tar() -> Vec<u8> {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut builder = Builder::new(&mut buf);
|
||||||
|
let content = b"hello";
|
||||||
|
let mut header = Header::new_gnu();
|
||||||
|
header.set_entry_type(EntryType::Regular);
|
||||||
|
header.set_size(content.len() as u64);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, "subdir/hello.txt", content.as_ref())
|
||||||
|
.unwrap();
|
||||||
|
builder.finish().unwrap();
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_oversize_tar() -> Vec<u8> {
|
||||||
|
// Actual 2048-byte body; test will use cap=1024
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
{
|
||||||
|
let mut builder = Builder::new(&mut buf);
|
||||||
|
let content = vec![0u8; 2048];
|
||||||
|
let mut header = Header::new_gnu();
|
||||||
|
header.set_entry_type(EntryType::Regular);
|
||||||
|
header.set_size(content.len() as u64);
|
||||||
|
header.set_cksum();
|
||||||
|
builder
|
||||||
|
.append_data(&mut header, "bigfile.bin", content.as_slice())
|
||||||
|
.unwrap();
|
||||||
|
builder.finish().unwrap();
|
||||||
|
}
|
||||||
|
buf
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_rejects_path_traversal() {
|
||||||
|
// Craft a tar with "../../escaped.txt" using raw bytes (Builder sanitises paths).
|
||||||
|
let bytes = raw_tar_with_path(b"../../escaped.txt", b"evil content");
|
||||||
|
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("path traversal") || msg.contains(".."),
|
||||||
|
"got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_rejects_absolute_path() {
|
||||||
|
// Craft a tar with "/etc/escaped.txt" using raw bytes.
|
||||||
|
let bytes = raw_tar_with_path(b"/etc/escaped.txt", b"evil content");
|
||||||
|
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("path traversal") || msg.contains("absolute"),
|
||||||
|
"got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_rejects_symlink() {
|
||||||
|
let bytes = raw_symlink_tar();
|
||||||
|
let err = safe_unpack_git_archive(&bytes, 1024 * 1024).unwrap_err();
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("symlink") || msg.contains("link"),
|
||||||
|
"got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_rejects_size_bomb() {
|
||||||
|
let bytes = build_oversize_tar(); // actual 2048-byte entry
|
||||||
|
let err = safe_unpack_git_archive(&bytes, 1024).unwrap_err(); // cap = 1024 bytes
|
||||||
|
let msg = format!("{err:#}");
|
||||||
|
assert!(
|
||||||
|
msg.contains("size") || msg.contains("cap") || msg.contains("too large"),
|
||||||
|
"got: {msg}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn restore_accepts_normal_files() {
|
||||||
|
let buf = build_normal_tar();
|
||||||
|
let entries = safe_unpack_git_archive(&buf, 1024 * 1024).expect("happy path");
|
||||||
|
assert_eq!(entries.len(), 1);
|
||||||
|
assert_eq!(entries[0].0, PathBuf::from("subdir/hello.txt"));
|
||||||
|
assert_eq!(entries[0].1, b"hello");
|
||||||
|
}
|
||||||
@@ -9,3 +9,10 @@ anyhow = "1"
|
|||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
|
tempfile = "3"
|
||||||
|
regex = "1"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
assert_cmd = "2"
|
||||||
|
predicates = "3"
|
||||||
|
tempfile = "3"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
//! relicario-server -- pre-receive hook for signature verification.
|
//! relicario-server -- pre-receive hook for signature verification.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
use anyhow::{Context, Result};
|
||||||
@@ -34,49 +35,120 @@ fn main() -> Result<()> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn verify_commit(commit: &str) -> Result<()> {
|
fn verify_commit(commit: &str) -> Result<()> {
|
||||||
// Get devices.json at this commit
|
|
||||||
let devices_json = match git_show(commit, ".relicario/devices.json") {
|
let devices_json = match git_show(commit, ".relicario/devices.json") {
|
||||||
Ok(json) => json,
|
Ok(json) => json,
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
// No devices.json yet -- bootstrap mode, allow unsigned
|
eprintln!("OK: commit {commit} (bootstrap - no devices.json)");
|
||||||
eprintln!("OK: commit {} (bootstrap - no devices.json)", commit);
|
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
|
let devices: Vec<DeviceEntry> = serde_json::from_str(&devices_json)
|
||||||
.context("parse devices.json")?;
|
.context("parse devices.json")?;
|
||||||
|
|
||||||
// Bootstrap: if devices.json is empty, allow unsigned
|
|
||||||
if devices.is_empty() {
|
|
||||||
eprintln!("OK: commit {} (bootstrap - empty devices.json)", commit);
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get revoked.json (may not exist)
|
|
||||||
let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
|
let revoked: Vec<RevokedEntry> = git_show(commit, ".relicario/revoked.json")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| serde_json::from_str(&s).ok())
|
.and_then(|s| serde_json::from_str(&s).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
// Get commit signature
|
// True bootstrap: no devices ever registered and none revoked.
|
||||||
|
if devices.is_empty() && revoked.is_empty() {
|
||||||
|
eprintln!("OK: commit {commit} (bootstrap - no devices registered)");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build temp allowed-signers file from registered devices.
|
||||||
|
let tmp = tempfile::tempdir().context("create tempdir")?;
|
||||||
|
let allowed_path = tmp.path().join("allowed_signers");
|
||||||
|
let mut allowed_body = String::new();
|
||||||
|
for d in &devices {
|
||||||
|
allowed_body.push_str("relicario ");
|
||||||
|
allowed_body.push_str(d.public_key.trim());
|
||||||
|
allowed_body.push('\n');
|
||||||
|
}
|
||||||
|
fs::write(&allowed_path, &allowed_body).context("write allowed_signers")?;
|
||||||
|
|
||||||
|
// Run git verify-commit --raw. Capture both exit code and stderr.
|
||||||
|
// NOTE: we do NOT short-circuit on non-zero exit here because even for
|
||||||
|
// unregistered keys git still outputs "Good ... key SHA256:..." on stderr.
|
||||||
let output = Command::new("git")
|
let output = Command::new("git")
|
||||||
.args(["verify-commit", "--raw", commit])
|
.args(["verify-commit", "--raw", commit])
|
||||||
|
.env("GIT_CONFIG_COUNT", "1")
|
||||||
|
.env("GIT_CONFIG_KEY_0", "gpg.ssh.allowedSignersFile")
|
||||||
|
.env("GIT_CONFIG_VALUE_0", allowed_path.as_os_str())
|
||||||
.output()
|
.output()
|
||||||
.context("git verify-commit")?;
|
.context("git verify-commit")?;
|
||||||
|
|
||||||
// Check if signed
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
if !stderr.contains("GOODSIG") && !stderr.contains("Good signature") {
|
|
||||||
eprintln!("REJECT: commit {} is not signed by a registered device", commit);
|
// Parse the SHA-256 fingerprint from stderr.
|
||||||
|
// SSH signature output: "Good "git" signature ... with ED25519 key SHA256:<base64>"
|
||||||
|
let re = regex::Regex::new(r"key (SHA256:[A-Za-z0-9+/]+)").expect("static regex");
|
||||||
|
let signing_fp = match re.captures(&stderr).and_then(|c| c.get(1)) {
|
||||||
|
Some(m) => m.as_str().to_string(),
|
||||||
|
None => {
|
||||||
|
// No fingerprint in stderr = unsigned or completely malformed signature.
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: commit {commit} — no valid signature found (stderr: {})",
|
||||||
|
stderr.trim()
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build fingerprint → entry maps.
|
||||||
|
let mut device_by_fp: std::collections::HashMap<String, &DeviceEntry> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for d in &devices {
|
||||||
|
if let Ok(fp) = relicario_core::device::fingerprint(&d.public_key) {
|
||||||
|
device_by_fp.insert(fp, d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut revoked_by_fp: std::collections::HashMap<String, &RevokedEntry> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for r in &revoked {
|
||||||
|
if let Ok(fp) = relicario_core::device::fingerprint(&r.public_key) {
|
||||||
|
revoked_by_fp.insert(fp, r);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get committer date (NOT author date).
|
||||||
|
let ct_out = Command::new("git")
|
||||||
|
.args(["show", "-s", "--format=%ct", commit])
|
||||||
|
.output()
|
||||||
|
.context("git show committer date")?;
|
||||||
|
let committer_ts: i64 = String::from_utf8_lossy(&ct_out.stdout)
|
||||||
|
.trim()
|
||||||
|
.parse()
|
||||||
|
.context("parse committer timestamp")?;
|
||||||
|
|
||||||
|
// Check revocation FIRST (revoked entries may not be in devices anymore).
|
||||||
|
if let Some(r) = revoked_by_fp.get(&signing_fp) {
|
||||||
|
if committer_ts >= r.revoked_at {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: commit {commit} — signed by revoked device '{}' \
|
||||||
|
(committer ts {committer_ts} >= revoked_at {})",
|
||||||
|
r.name, r.revoked_at
|
||||||
|
);
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
// Historical commit: committer_ts < revoked_at → was valid when signed.
|
||||||
|
eprintln!(
|
||||||
|
"OK: commit {commit} — historical commit signed by '{}' before revocation",
|
||||||
|
r.name
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not revoked — must be in active devices.
|
||||||
|
if !device_by_fp.contains_key(&signing_fp) {
|
||||||
|
eprintln!(
|
||||||
|
"REJECT: commit {commit} — signed by unregistered device (fingerprint {signing_fp})"
|
||||||
|
);
|
||||||
std::process::exit(1);
|
std::process::exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure the signing key is not revoked.
|
eprintln!("OK: commit {commit} verified (signed by '{}')", device_by_fp[&signing_fp].name);
|
||||||
// The allowed-signers file approach means git verify-commit already checks
|
|
||||||
// against the list; we additionally guard against revoked.json entries.
|
|
||||||
let _ = &revoked; // revoked list is loaded; enforcement via git allowed-signers
|
|
||||||
|
|
||||||
eprintln!("OK: commit {} verified", commit);
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
230
crates/relicario-server/tests/verify_commit.rs
Normal file
230
crates/relicario-server/tests/verify_commit.rs
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
//! Acceptance tests for `relicario-server verify-commit`.
|
||||||
|
//!
|
||||||
|
//! Four scenarios from audit S1:
|
||||||
|
//! 1. Registered non-revoked key → exit 0
|
||||||
|
//! 2. Unregistered key → exit 1 (stderr contains "unregistered")
|
||||||
|
//! 3. Revoked key, commit AFTER revoked_at → exit 1 (stderr contains "revoked")
|
||||||
|
//! 4. Revoked key, commit BEFORE revoked_at (historical) → exit 0
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
use assert_cmd::Command as AssertCommand;
|
||||||
|
use predicates::prelude::*;
|
||||||
|
use relicario_core::device::{generate_keypair, DeviceEntry, RevokedEntry};
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn write_keypair(dir: &Path, name: &str) -> (PathBuf, PathBuf, String) {
|
||||||
|
let (priv_pem, pub_line) = generate_keypair().expect("generate keypair");
|
||||||
|
let priv_path = dir.join(format!("{name}.key"));
|
||||||
|
let pub_path = dir.join(format!("{name}.pub"));
|
||||||
|
fs::write(&priv_path, priv_pem.as_str()).unwrap();
|
||||||
|
fs::write(&pub_path, &pub_line).unwrap();
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
fs::set_permissions(&priv_path, fs::Permissions::from_mode(0o600)).unwrap();
|
||||||
|
}
|
||||||
|
(priv_path, pub_path, pub_line)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn git(repo: &Path, args: &[&str], extra_env: &[(&str, &str)]) {
|
||||||
|
let mut cmd = Command::new("git");
|
||||||
|
cmd.current_dir(repo).args(args);
|
||||||
|
for (k, v) in extra_env {
|
||||||
|
cmd.env(k, v);
|
||||||
|
}
|
||||||
|
let status = cmd.status().expect("spawn git");
|
||||||
|
assert!(status.success(), "git {args:?} failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn init_repo(repo: &Path) {
|
||||||
|
git(repo, &["init", "-q", "-b", "main"], &[]);
|
||||||
|
git(repo, &["config", "user.email", "test@test"], &[]);
|
||||||
|
git(repo, &["config", "user.name", "test"], &[]);
|
||||||
|
git(repo, &["commit", "--allow-empty", "-q", "-m", "init"], &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sign_commit(
|
||||||
|
repo: &Path,
|
||||||
|
signing_key: &Path,
|
||||||
|
allowed_signers: &Path,
|
||||||
|
committer_unix: i64,
|
||||||
|
msg: &str,
|
||||||
|
file_path: &str,
|
||||||
|
file_content: &str,
|
||||||
|
) -> String {
|
||||||
|
fs::write(repo.join(file_path), file_content).unwrap();
|
||||||
|
git(repo, &["add", file_path], &[]);
|
||||||
|
let date = format!("@{committer_unix} +0000");
|
||||||
|
git(
|
||||||
|
repo,
|
||||||
|
&[
|
||||||
|
"-c", "gpg.format=ssh",
|
||||||
|
"-c", &format!("user.signingkey={}", signing_key.display()),
|
||||||
|
"-c", &format!("gpg.ssh.allowedSignersFile={}", allowed_signers.display()),
|
||||||
|
"commit", "-S", "-q", "-m", msg,
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
("GIT_AUTHOR_DATE", &date),
|
||||||
|
("GIT_COMMITTER_DATE", &date),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
let out = Command::new("git")
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["rev-parse", "HEAD"])
|
||||||
|
.output()
|
||||||
|
.unwrap();
|
||||||
|
String::from_utf8(out.stdout).unwrap().trim().to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_device_files(repo: &Path, devices: &[DeviceEntry], revoked: &[RevokedEntry]) {
|
||||||
|
let dir = repo.join(".relicario");
|
||||||
|
fs::create_dir_all(&dir).unwrap();
|
||||||
|
fs::write(dir.join("devices.json"), serde_json::to_string_pretty(devices).unwrap()).unwrap();
|
||||||
|
fs::write(dir.join("revoked.json"), serde_json::to_string_pretty(revoked).unwrap()).unwrap();
|
||||||
|
git(repo, &["add", ".relicario"], &[]);
|
||||||
|
git(repo, &["commit", "-q", "-m", "device files"], &[]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn registered_non_revoked_key_accepted() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_repo(repo);
|
||||||
|
|
||||||
|
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
||||||
|
write_device_files(
|
||||||
|
repo,
|
||||||
|
&[DeviceEntry {
|
||||||
|
name: "alice".into(),
|
||||||
|
public_key: pub_a.clone(),
|
||||||
|
added_at: 1_700_000_000,
|
||||||
|
added_by: "bootstrap".into(),
|
||||||
|
}],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
|
||||||
|
let allowed = repo.join("test_allowed_signers");
|
||||||
|
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
||||||
|
|
||||||
|
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "x", "a.txt", "hi");
|
||||||
|
|
||||||
|
AssertCommand::cargo_bin("relicario-server")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["verify-commit", &sha])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unregistered_key_rejected() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_repo(repo);
|
||||||
|
|
||||||
|
let (_, _, pub_a) = write_keypair(repo, "alice");
|
||||||
|
let (priv_evil, _, pub_evil) = write_keypair(repo, "evil");
|
||||||
|
|
||||||
|
// Only Alice is registered.
|
||||||
|
write_device_files(
|
||||||
|
repo,
|
||||||
|
&[DeviceEntry {
|
||||||
|
name: "alice".into(),
|
||||||
|
public_key: pub_a.clone(),
|
||||||
|
added_at: 1_700_000_000,
|
||||||
|
added_by: "bootstrap".into(),
|
||||||
|
}],
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Evil signs against a file containing both keys so git commit signing works,
|
||||||
|
// but the binary's allowed-signers (from devices.json) only has Alice.
|
||||||
|
let allowed = repo.join("test_allowed_signers");
|
||||||
|
fs::write(
|
||||||
|
&allowed,
|
||||||
|
format!("relicario {}\nrelicario {}\n", pub_a.trim(), pub_evil.trim()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let sha = sign_commit(repo, &priv_evil, &allowed, 1_710_000_000, "evil", "a.txt", "hi");
|
||||||
|
|
||||||
|
AssertCommand::cargo_bin("relicario-server")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["verify-commit", &sha])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("unregistered"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revoked_key_after_revoked_at_rejected() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_repo(repo);
|
||||||
|
|
||||||
|
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
||||||
|
|
||||||
|
// Alice's entry is only in revoked.json (was removed from devices.json after revocation).
|
||||||
|
write_device_files(
|
||||||
|
repo,
|
||||||
|
&[],
|
||||||
|
&[RevokedEntry {
|
||||||
|
name: "alice".into(),
|
||||||
|
public_key: pub_a.clone(),
|
||||||
|
revoked_at: 1_705_000_000,
|
||||||
|
revoked_by: "admin".into(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
|
||||||
|
let allowed = repo.join("test_allowed_signers");
|
||||||
|
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
||||||
|
|
||||||
|
// Commit dated AFTER revocation.
|
||||||
|
let sha = sign_commit(repo, &priv_a, &allowed, 1_710_000_000, "post", "a.txt", "hi");
|
||||||
|
|
||||||
|
AssertCommand::cargo_bin("relicario-server")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["verify-commit", &sha])
|
||||||
|
.assert()
|
||||||
|
.failure()
|
||||||
|
.stderr(predicate::str::contains("revoked"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn revoked_key_before_revoked_at_accepted_historical() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let repo = tmp.path();
|
||||||
|
init_repo(repo);
|
||||||
|
|
||||||
|
let (priv_a, _, pub_a) = write_keypair(repo, "alice");
|
||||||
|
|
||||||
|
// Same as above: Alice only in revoked.json.
|
||||||
|
write_device_files(
|
||||||
|
repo,
|
||||||
|
&[],
|
||||||
|
&[RevokedEntry {
|
||||||
|
name: "alice".into(),
|
||||||
|
public_key: pub_a.clone(),
|
||||||
|
revoked_at: 1_705_000_000,
|
||||||
|
revoked_by: "admin".into(),
|
||||||
|
}],
|
||||||
|
);
|
||||||
|
|
||||||
|
let allowed = repo.join("test_allowed_signers");
|
||||||
|
fs::write(&allowed, format!("relicario {}\n", pub_a.trim())).unwrap();
|
||||||
|
|
||||||
|
// Commit dated BEFORE revocation -- historical case must pass.
|
||||||
|
let sha = sign_commit(repo, &priv_a, &allowed, 1_700_000_000, "historical", "a.txt", "hi");
|
||||||
|
|
||||||
|
AssertCommand::cargo_bin("relicario-server")
|
||||||
|
.unwrap()
|
||||||
|
.current_dir(repo)
|
||||||
|
.args(["verify-commit", &sha])
|
||||||
|
.assert()
|
||||||
|
.success();
|
||||||
|
}
|
||||||
@@ -83,8 +83,9 @@ vault_salt ────────►│ │
|
|||||||
|
|
||||||
┌──────────────────┐
|
┌──────────────────┐
|
||||||
master_key ────────►│ XChaCha20- │──────► manifest.enc
|
master_key ────────►│ XChaCha20- │──────► manifest.enc
|
||||||
empty manifest ────►│ Poly1305 │
|
empty manifest ────►│ Poly1305 │ settings.enc
|
||||||
└──────────────────┘
|
default settings ──►│ encrypt (×2) │ (parallel artifacts;
|
||||||
|
└──────────────────┘ independent nonces)
|
||||||
|
|
||||||
┌──────────────────┐
|
┌──────────────────┐
|
||||||
│ git init │──────► vault repo
|
│ git init │──────► vault repo
|
||||||
@@ -92,6 +93,14 @@ empty manifest ────►│ Poly1305 │
|
|||||||
└──────────────────┘
|
└──────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Item creation, the typed-item envelope (`Item` + per-type `ItemCore`),
|
||||||
|
attachment encryption, and field-history tracking are not shown above —
|
||||||
|
they are described in [`crates/relicario-core/ARCHITECTURE.md`](../crates/relicario-core/ARCHITECTURE.md).
|
||||||
|
The flow above covers only the crypto-pipeline shape that vault init
|
||||||
|
establishes; the per-item lifecycle reuses the same `master_key` +
|
||||||
|
XChaCha20-Poly1305 primitives against `items/<id>.enc` and
|
||||||
|
`attachments/<item-id>/<aid>.enc`.
|
||||||
|
|
||||||
## Unlock Flow (every vault operation)
|
## Unlock Flow (every vault operation)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -48,6 +48,19 @@ When enabled, device authentication provides:
|
|||||||
- **Push access control**: Deploy keys managed via Gitea API
|
- **Push access control**: Deploy keys managed via Gitea API
|
||||||
- **Instant revocation**: One command cuts off both signing and push
|
- **Instant revocation**: One command cuts off both signing and push
|
||||||
|
|
||||||
|
Enforcement requires deploying the `relicario-server` pre-receive hook
|
||||||
|
on the vault remote. The crate provides two subcommands:
|
||||||
|
|
||||||
|
- `relicario-server generate-hook` — emits the hook script to install at
|
||||||
|
`<repo>/hooks/pre-receive`
|
||||||
|
- `relicario-server verify-commit <sha>` — checks one commit's signature
|
||||||
|
against `.relicario/devices.json` and `.relicario/revoked.json` as of
|
||||||
|
that commit; the hook calls this for every pushed ref
|
||||||
|
|
||||||
|
Without the server hook, signed commits provide authorship metadata only
|
||||||
|
— any process with push access can land an unsigned commit, since
|
||||||
|
verification is otherwise advisory.
|
||||||
|
|
||||||
See `docs/superpowers/specs/2026-05-02-device-authentication-design.md`.
|
See `docs/superpowers/specs/2026-05-02-device-authentication-design.md`.
|
||||||
|
|
||||||
## Access Control
|
## Access Control
|
||||||
@@ -57,5 +70,35 @@ Without device authentication, access control is transport-layer only:
|
|||||||
- **CLI**: SSH key authentication to git remote
|
- **CLI**: SSH key authentication to git remote
|
||||||
- **Extension**: Git credentials in browser storage
|
- **Extension**: Git credentials in browser storage
|
||||||
|
|
||||||
Device registration was optional before v0.4.0. With device auth enabled,
|
Device registration is optional but recommended for shared vaults.
|
||||||
all commits must be signed by a registered device.
|
|
||||||
|
## Configuration env vars
|
||||||
|
|
||||||
|
Relicario reads the following environment variables. Each is a trust
|
||||||
|
boundary: an attacker who can set them in the user's environment can
|
||||||
|
influence Relicario's behavior. They are listed here for security
|
||||||
|
reviewers to audit the surface in one place.
|
||||||
|
|
||||||
|
### User-facing (active in all builds)
|
||||||
|
|
||||||
|
| Variable | Purpose | Trust |
|
||||||
|
|---|---|---|
|
||||||
|
| `RELICARIO_IMAGE` | Override the reference-image JPEG path used during vault unlock. | Trusted: filesystem path under the user's control. Read-only; its bytes feed `imgsecret::extract_secret`. |
|
||||||
|
| `RELICARIO_GITEA_URL` | Gitea API base URL for `relicario device add`. Equivalent to `--gitea-url`. | Trusted: HTTPS URL. Used only in the device-add code path. |
|
||||||
|
| `RELICARIO_GITEA_TOKEN` | Gitea personal-access token. Equivalent to `--gitea-token`. | **Secret**: anyone who can read this env var can manage the user's deploy keys via the Gitea API. The CLI never logs it. |
|
||||||
|
| `RELICARIO_GITEA_OWNER` | Gitea repository owner (e.g. `alee`). Equivalent to `--owner`. | Trusted: opaque string. |
|
||||||
|
| `RELICARIO_GITEA_REPO` | Gitea repository name (e.g. `vault`). Equivalent to `--repo`. | Trusted: opaque string. |
|
||||||
|
|
||||||
|
### Debug-only (compiled out of `cargo build --release`)
|
||||||
|
|
||||||
|
The following variables are gated behind `cfg(debug_assertions)` and
|
||||||
|
are **no-ops** in release builds. The env-var lookup is removed by the
|
||||||
|
optimiser from any binary built without debug assertions (i.e. the
|
||||||
|
standard `--release` profile).
|
||||||
|
|
||||||
|
| Variable | Purpose |
|
||||||
|
|---|---|
|
||||||
|
| `RELICARIO_NO_GROUPS_CACHE` | Suppress the plaintext `groups.cache` write. Developer debugging tool for the cache logic. |
|
||||||
|
| `RELICARIO_TEST_PASSPHRASE` | Bypass the `rpassword` prompt during integration tests. |
|
||||||
|
| `RELICARIO_TEST_ITEM_SECRET` | Bypass the `rpassword` prompt for item-secret fields during integration tests. |
|
||||||
|
| `RELICARIO_TEST_BACKUP_PASSPHRASE` | Bypass the `rpassword` prompt for backup export/restore passphrases during integration tests. |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Architecture overview — Relicario
|
# Architecture overview — Relicario
|
||||||
|
|
||||||
This is the cross-codebase entry point. It describes how the three Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs.
|
This is the cross-codebase entry point. It describes how the four Relicario codebases fit together, the contracts that flow between them, and the conventions they share. It is **deliberately thin**; the deep content lives in per-codebase docs.
|
||||||
|
|
||||||
> If you are about to make a change in a single codebase, read its `ARCHITECTURE.md` first:
|
> If you are about to make a change in a single codebase, read its `ARCHITECTURE.md` first:
|
||||||
>
|
>
|
||||||
@@ -10,7 +10,7 @@ This is the cross-codebase entry point. It describes how the three Relicario cod
|
|||||||
>
|
>
|
||||||
> If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*.
|
> If you want historical *why*, see `docs/superpowers/specs/` — those are time-stamped decision artifacts. This overview describes what *is*.
|
||||||
|
|
||||||
## The three codebases
|
## The four codebases
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────┐
|
┌─────────────────────┐
|
||||||
@@ -18,36 +18,40 @@ This is the cross-codebase entry point. It describes how the three Relicario cod
|
|||||||
│ (Rust, no I/O) │
|
│ (Rust, no I/O) │
|
||||||
│ crypto · items │
|
│ crypto · items │
|
||||||
│ manifest · stego │
|
│ manifest · stego │
|
||||||
└──────────┬──────────┘
|
│ device keys + fp │
|
||||||
|
└──┬───────────┬──────┘
|
||||||
|
│ │
|
||||||
|
┌────────────────┼───────────┴──────┬────────────────────┐
|
||||||
|
│ │ │ │
|
||||||
|
▼ ▼ ▼ ▼
|
||||||
|
┌────────────────┐ ┌──────────────────┐ ┌────────────────────┐
|
||||||
|
│ relicario-cli │ │ relicario-server │ │ relicario-wasm │
|
||||||
|
│ (Rust binary) │ │ (Rust binary) │ │ (#[wasm_bindgen] │
|
||||||
|
│ │ │ │ │ bindings) │
|
||||||
|
│ filesystem + │ │ pre-receive hook │ │ │
|
||||||
|
│ git + │ │ verify-commit + │ │ compiled to WASM │
|
||||||
|
│ clap UX │ │ generate-hook │ │ for the extension │
|
||||||
|
└────────────────┘ └──────────────────┘ └──────────┬─────────┘
|
||||||
│
|
│
|
||||||
┌─────────────────────┼─────────────────────┐
|
▼
|
||||||
│ │ │
|
┌─────────────────────┐
|
||||||
▼ ▼ ▼
|
│ extension │
|
||||||
┌────────────────┐ ┌────────────────────┐ (compiled to WASM
|
│ (TypeScript) │
|
||||||
│ relicario-cli │ │ relicario-wasm │ inside the )
|
│ popup · vault │
|
||||||
│ (Rust binary) │ │ (#[wasm_bindgen] │ extension │
|
│ setup · content │
|
||||||
│ │ │ bindings) │ │
|
│ service worker │
|
||||||
│ filesystem + │ │ │ │
|
|
||||||
│ git + │ └────────┬───────────┘ │
|
|
||||||
│ clap UX │ │ │
|
|
||||||
└────────────────┘ ▼ │
|
|
||||||
┌─────────────────────┐ │
|
|
||||||
│ extension │ │
|
|
||||||
│ (TypeScript) │ │
|
|
||||||
│ popup · vault │ │
|
|
||||||
│ setup · content │ │
|
|
||||||
│ service worker │ │
|
|
||||||
└─────────────────────┘
|
└─────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
| Codebase | Language | Role | Key boundary |
|
| Codebase | Language | Role | Key boundary |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `relicario-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators. Pure, no I/O. | Only `bytes-in / bytes-out`. No filesystem, no git, no network. |
|
| `relicario-core` | Rust | Crypto, item types, manifest, attachments, imgsecret, generators, device keys / fingerprints. Pure, no I/O. | Only `bytes-in / bytes-out`. No filesystem, no git, no network. |
|
||||||
| `relicario-cli` | Rust binary | Wraps core with filesystem ops, git plumbing, clap UX. | Only entry point that runs without a browser; sole working interface during disaster recovery. |
|
| `relicario-cli` | Rust binary | Wraps core with filesystem ops, git plumbing, clap UX. | Only entry point that runs without a browser; sole working interface during disaster recovery. |
|
||||||
| `relicario-wasm` | Rust → WASM | Thin `#[wasm_bindgen]` exports from core for the extension. | Compiles `relicario-core` to WASM; no extra logic. |
|
| `relicario-wasm` | Rust → WASM | Thin `#[wasm_bindgen]` exports from core for the extension. | Compiles `relicario-core` to WASM; no extra logic. |
|
||||||
|
| `relicario-server` | Rust binary | Pre-receive Git hook (`verify-commit`) plus hook installer (`generate-hook`) running on the vault remote. Verifies SSH-signed commits against `.relicario/devices.json` and `.relicario/revoked.json`. | Lives on the git server, not on a client device. The only Relicario component the user does not run themselves. Sees only public key material. |
|
||||||
| `extension` | TypeScript | Browser-resident UI. Five entry-point bundles (popup, vault tab, setup, content script, service worker). | The service worker is the only crypto holder; popup/vault/content/setup never touch the master key. |
|
| `extension` | TypeScript | Browser-resident UI. Five entry-point bundles (popup, vault tab, setup, content script, service worker). | The service worker is the only crypto holder; popup/vault/content/setup never touch the master key. |
|
||||||
|
|
||||||
The CLI and the extension are **at parity**: every user-facing capability lands in both surfaces together. Diverging is allowed only with a documented reason. See the per-codebase docs for which surface owns which user flow.
|
The CLI and the extension are **at parity**: every user-facing capability lands in both surfaces together. Diverging is allowed only with a documented reason. See the per-codebase docs for which surface owns which user flow. The server has no user-facing surface — it is a server-side enforcer of the device-auth invariant the clients already agreed to.
|
||||||
|
|
||||||
## Inter-codebase contracts
|
## Inter-codebase contracts
|
||||||
|
|
||||||
@@ -151,6 +155,7 @@ The CLI keeps its master key in process memory; if the process exits or crashes,
|
|||||||
| Target | Tool | Output | When to run |
|
| Target | Tool | Output | When to run |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Native CLI | `cargo build` (debug or `--release`) | `target/{debug,release}/relicario` | After CLI changes; for distribution |
|
| Native CLI | `cargo build` (debug or `--release`) | `target/{debug,release}/relicario` | After CLI changes; for distribution |
|
||||||
|
| Server hook | `cargo build -p relicario-server --release` | `target/release/relicario-server` | After server changes; deploy onto the git remote |
|
||||||
| Native test suites | `cargo test` (workspace) | — | After any Rust change |
|
| Native test suites | `cargo test` (workspace) | — | After any Rust change |
|
||||||
| WASM module | `wasm-pack build --target web` (via `npm run build:wasm`) | `extension/wasm/relicario_wasm{,_bg.wasm,.js}` | After core or wasm crate changes |
|
| WASM module | `wasm-pack build --target web` (via `npm run build:wasm`) | `extension/wasm/relicario_wasm{,_bg.wasm,.js}` | After core or wasm crate changes |
|
||||||
| Chrome extension | `webpack` (`npm run build`) | `extension/dist/` | After TS or WASM changes; for Chrome distribution |
|
| Chrome extension | `webpack` (`npm run build`) | `extension/dist/` | After TS or WASM changes; for Chrome distribution |
|
||||||
@@ -196,6 +201,7 @@ Core tests use **fast Argon2id params** (m=256, t=1, p=1) so they don't take for
|
|||||||
| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](../../extension/ARCHITECTURE.md) |
|
| A new popup view, vault tab feature, or autofill change | [`extension/ARCHITECTURE.md`](../../extension/ARCHITECTURE.md) |
|
||||||
| A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](../../extension/ARCHITECTURE.md) |
|
| A new SW message type | `extension/src/shared/messages.ts` (capability sets), then [`extension/ARCHITECTURE.md § Invariants`](../../extension/ARCHITECTURE.md) |
|
||||||
| A new GitHost (e.g. GitLab support) | `extension/src/service-worker/git-host.ts` (interface) and existing implementations |
|
| A new GitHost (e.g. GitLab support) | `extension/src/service-worker/git-host.ts` (interface) and existing implementations |
|
||||||
|
| The pre-receive hook / device-auth enforcement | `crates/relicario-server/src/main.rs`, then `docs/superpowers/specs/2026-05-02-device-authentication-design.md` for rationale |
|
||||||
| Adding a new item type | core's `item_types/` mod, then CLI's `build_*_item`/`edit_*` helpers, then extension's `popup/components/types/<type>.ts` |
|
| Adding a new item type | core's `item_types/` mod, then CLI's `build_*_item`/`edit_*` helpers, then extension's `popup/components/types/<type>.ts` |
|
||||||
| Threat model / why a primitive was chosen | `docs/superpowers/specs/2026-04-11-relicario-design.md` (historical, but authoritative for rationale) |
|
| Threat model / why a primitive was chosen | `docs/superpowers/specs/2026-04-11-relicario-design.md` (historical, but authoritative for rationale) |
|
||||||
| Format of the import/export feature | `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` (designed but not yet implemented) |
|
| Format of the import/export feature | `docs/superpowers/specs/2026-04-27-relicario-import-export-design.md` (designed but not yet implemented) |
|
||||||
|
|||||||
165
docs/superpowers/MULTI-AGENT.md
Normal file
165
docs/superpowers/MULTI-AGENT.md
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# Multi-Agent Development Paradigm
|
||||||
|
|
||||||
|
This repo uses a three-terminal workflow for large development lifts: one Claude Code session acts as **PM** and two act as **senior developers** (Dev-A, Dev-B), each working in their own git worktree on a parallel feature branch.
|
||||||
|
|
||||||
|
A local relay MCP server eliminates manual message copying between terminals — agents call `post_message`/`read_messages` instead of asking the user to copy-paste.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Role | Terminal | Branch | Responsibilities |
|
||||||
|
|------|----------|--------|-----------------|
|
||||||
|
| PM | 1 | `main` (read-only) | Drive doc-audit follow-ups, review PRs, write CHANGELOG, authorize merges and tagging |
|
||||||
|
| Dev-A | 2 | `feature/<release>-plan-a-*` | Implement Plan A tasks in their own worktree |
|
||||||
|
| Dev-B | 3 | `feature/<release>-plan-b-*` | Implement Plan B tasks in their own worktree |
|
||||||
|
| Relay server | 4 | — | Message bus; Ctrl-C to stop at end of lift |
|
||||||
|
|
||||||
|
**User's job:** authorize merges (the PM asks), resolve escalations the PM can't handle, and watch the streams. You are no longer the message bus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Starting a lift
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [ ] Kickoff prompts exist in `docs/superpowers/coordination/` (generate with the `multi-agent-kickoff` skill if not)
|
||||||
|
- [ ] No uncommitted changes in main that would confuse the devs
|
||||||
|
- [ ] `tools/relay/` is present (run `ls tools/relay/` to confirm)
|
||||||
|
|
||||||
|
### Launch sequence
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start the relay server (this terminal becomes the relay log)
|
||||||
|
tools/relay/start.sh # prints copy-paste instructions, then starts server
|
||||||
|
|
||||||
|
# Optional: use a multiplexer to auto-open all four terminals
|
||||||
|
tools/relay/start.sh --tmux # creates tmux session "relay-lift" with 4 windows
|
||||||
|
tools/relay/start.sh --kitty # creates kitty tab "relay" + 3 windows
|
||||||
|
```
|
||||||
|
|
||||||
|
`start.sh` prints the paths to the three kickoff prompt files. In each Claude Code terminal, run `cat <path>` and paste everything below the `---` line as the first message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
Agents communicate by posting structured blocks to each other's inboxes. Four message kinds:
|
||||||
|
|
||||||
|
| Kind | Block header | When used |
|
||||||
|
|------|-------------|-----------|
|
||||||
|
| `status` | `## STATUS UPDATE — DEV-*` | After completing a task, getting blocked, or reaching a review-ready state |
|
||||||
|
| `question` | `## QUESTION TO PM — DEV-*` | When a dev needs PM input mid-task |
|
||||||
|
| `directive` | `## DIRECTIVE TO DEV-*` | When PM instructs a dev to proceed, hold, rescope, or approve a PR |
|
||||||
|
| `free` | (none) | Ad-hoc messages not covered by the above |
|
||||||
|
|
||||||
|
A well-formed `status` block:
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-B
|
||||||
|
Time: 2026-05-02T14:30:00-07:00
|
||||||
|
Branch: feature/v0.5.0-plan-b-extension-ux
|
||||||
|
Task: P4 / error-copy map
|
||||||
|
Status: DONE
|
||||||
|
Last commit: abc1234 feat(extension): centralize ERROR_COPY map
|
||||||
|
Tests: green
|
||||||
|
Notes: No issues. Ready for PM review of P4 before starting B1.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using the relay tools
|
||||||
|
|
||||||
|
All three Claude Code sessions have these tools available when the relay server is running:
|
||||||
|
|
||||||
|
```
|
||||||
|
post_message(from, to, kind, body) → { id }
|
||||||
|
read_messages(for) → RelayMessage[] (drains inbox)
|
||||||
|
list_pending(for) → { count, kinds } (non-destructive)
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical dev flow per task:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. read_messages(for="dev-b") # check for directives before starting
|
||||||
|
2. ... do the work ...
|
||||||
|
3. post_message(from="dev-b", to="pm", kind="status", body="## STATUS UPDATE...")
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical PM flow:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. read_messages(for="pm") # see what devs posted
|
||||||
|
2. ... review ...
|
||||||
|
3. post_message(from="pm", to="dev-b", kind="directive", body="## DIRECTIVE TO DEV-B...")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## If the relay server isn't running
|
||||||
|
|
||||||
|
Claude Code will show a yellow MCP connection warning for the `relay` server. The tools will be unavailable.
|
||||||
|
|
||||||
|
Agents fall back to the manual protocol: they emit the structured blocks as text and ask the user to copy-paste them to the relevant terminal. This is slower but fully functional — the coordination protocol works either way.
|
||||||
|
|
||||||
|
To restart a crashed server mid-lift:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/relay/start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
In-flight messages are lost on restart. Any agent with unread messages should re-post them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generating kickoff prompts
|
||||||
|
|
||||||
|
### Full workflow (spec → plans → kickoff)
|
||||||
|
|
||||||
|
**Step 1 — Write a spec**
|
||||||
|
|
||||||
|
Run the `superpowers:brainstorming` skill. At the end it invokes `superpowers:writing-plans` for each dev stream. Each stream gets its own plan file in `docs/superpowers/plans/`. The spec lives in `docs/superpowers/specs/`.
|
||||||
|
|
||||||
|
**Step 2 — Invoke the kickoff skill**
|
||||||
|
|
||||||
|
Say anything like:
|
||||||
|
- "kick off the multi-agent thing for v0.6.0"
|
||||||
|
- "spin up PM and devs for this release"
|
||||||
|
- "set up the three-terminal paradigm"
|
||||||
|
|
||||||
|
The `multi-agent-kickoff` skill auto-triggers on those phrases. It will:
|
||||||
|
|
||||||
|
1. Auto-discover the spec and plans by date/release label (asks to confirm if ambiguous)
|
||||||
|
2. Generate `docs/superpowers/coordination/<release>-pm-prompt.md` and one `-dev-<letter>-prompt.md` per plan
|
||||||
|
3. Inject the relay paragraph, branch names, worktree paths, test commands, and scope partitioning automatically from the plans and `CLAUDE.md`
|
||||||
|
4. Commit the prompts and print launch instructions
|
||||||
|
|
||||||
|
N>2 devs works automatically — 3 plans produces PM + Dev-A/B/C prompts.
|
||||||
|
|
||||||
|
**Step 3 — Launch**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/relay/start.sh # prints prompt file paths, starts relay server
|
||||||
|
# open N+1 terminals, paste each prompt below its '---' line
|
||||||
|
```
|
||||||
|
|
||||||
|
The skill reminder: run `tools/relay/start.sh` **before** opening the Claude Code sessions — the MCP tools need the server up when each session initializes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ending a lift
|
||||||
|
|
||||||
|
1. PM emits `REVIEW-COMPLETE` and `MERGE-APPROVED` for each dev's PR
|
||||||
|
2. User merges each PR (the PM session does `gh pr merge` with user authorization)
|
||||||
|
3. PM tags the release (only after explicit user `yes`)
|
||||||
|
4. Ctrl-C the relay terminal — all in-memory messages are discarded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roles and boundaries (quick reference)
|
||||||
|
|
||||||
|
**PM must not:** write feature code, merge without user authorization, tag without user approval, run `git push --force` / `git reset --hard` without asking.
|
||||||
|
|
||||||
|
**Devs must not:** merge their branch to main, push `--force`, run `git reset --hard` without asking.
|
||||||
|
|
||||||
|
**User must:** authorize all merges and the release tag. Everything else is delegated.
|
||||||
@@ -5,8 +5,9 @@ Pre-v0.5.0 audit of Relicario's documentation against the current codebase.
|
|||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
- **Total findings:** 14
|
- **Total findings:** 14
|
||||||
- **Fixed inline:** 6
|
- **Fixed inline (initial pass):** 6
|
||||||
- **Need user input (proposed only):** 8
|
- **Fixed during v0.5.0 PM run (this audit, follow-up commits):** 8
|
||||||
|
- **No action needed:** 0
|
||||||
- **Top 3 recommendations:**
|
- **Top 3 recommendations:**
|
||||||
1. **Add `relicario-server` to architecture docs.** It exists in `crates/`, is referenced by SECURITY.md, and underpins device-auth, but `docs/architecture/overview.md`'s "three codebases" framing and `CLAUDE.md`'s project-structure tree still pretend it doesn't exist (Findings 1, 2, 9). This is the single biggest gap before tagging v0.5.0.
|
1. **Add `relicario-server` to architecture docs.** It exists in `crates/`, is referenced by SECURITY.md, and underpins device-auth, but `docs/architecture/overview.md`'s "three codebases" framing and `CLAUDE.md`'s project-structure tree still pretend it doesn't exist (Findings 1, 2, 9). This is the single biggest gap before tagging v0.5.0.
|
||||||
2. **Replace `CLAUDE.md`'s Roadmap.** It still says "Next: WASM build + Chrome MV3 browser extension (Plan 2)" — a milestone that shipped weeks ago. Multiple subsequent train rounds (typed items, attachments, backup, LastPass, device auth, fullscreen UX phases) have shipped, none of which are reflected (Finding 3).
|
2. **Replace `CLAUDE.md`'s Roadmap.** It still says "Next: WASM build + Chrome MV3 browser extension (Plan 2)" — a milestone that shipped weeks ago. Multiple subsequent train rounds (typed items, attachments, backup, LastPass, device auth, fullscreen UX phases) have shipped, none of which are reflected (Finding 3).
|
||||||
@@ -24,7 +25,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
|||||||
**Issue:** The repo now has **four** Rust crates (`relicario-core`, `relicario-cli`, `relicario-wasm`, `relicario-server`) plus the extension. The framing "The three codebases" + accompanying ASCII diagram + four-row table all predate the May 2026 server crate. `relicario-server` is the pre-receive hook binary that enforces device-signature verification — load-bearing for the device-auth model that SECURITY.md already advertises.
|
**Issue:** The repo now has **four** Rust crates (`relicario-core`, `relicario-cli`, `relicario-wasm`, `relicario-server`) plus the extension. The framing "The three codebases" + accompanying ASCII diagram + four-row table all predate the May 2026 server crate. `relicario-server` is the pre-receive hook binary that enforces device-signature verification — load-bearing for the device-auth model that SECURITY.md already advertises.
|
||||||
**Fix:** Re-title the section ("The four codebases" or "The relicario codebases"), add a server box to the diagram, add a row to the table. The role is "Pre-receive Git hook that verifies commit signatures against `.relicario/devices.json` and `.relicario/revoked.json`".
|
**Fix:** Re-title the section ("The four codebases" or "The relicario codebases"), add a server box to the diagram, add a row to the table. The role is "Pre-receive Git hook that verifies commit signatures against `.relicario/devices.json` and `.relicario/revoked.json`".
|
||||||
**Severity:** must-fix-before-v0.5.0
|
**Severity:** must-fix-before-v0.5.0
|
||||||
**Status:** Proposed; needs user decision (>50 words of new prose; touches the framing of the whole overview doc)
|
**Status:** Fixed in `ca059e7` (PM follow-up, 2026-05-02): "four codebases" framing, ASCII diagram fans core out to cli + server + wasm, table row added, build matrix gains `cargo build -p relicario-server`, "Where to look next" points at server src + design spec.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -34,7 +35,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
|||||||
**Issue:** The `crates/` tree only lists `relicario-core/`, `relicario-cli/`, `relicario-wasm/`. `relicario-server/` is missing. Since CLAUDE.md is the project-level summary every Claude session reads, this is the highest-leverage staleness.
|
**Issue:** The `crates/` tree only lists `relicario-core/`, `relicario-cli/`, `relicario-wasm/`. `relicario-server/` is missing. Since CLAUDE.md is the project-level summary every Claude session reads, this is the highest-leverage staleness.
|
||||||
**Fix:** Add a fourth crate entry for `relicario-server/` with `src/main.rs # pre-receive hook: verify_commit + generate_hook`.
|
**Fix:** Add a fourth crate entry for `relicario-server/` with `src/main.rs # pre-receive hook: verify_commit + generate_hook`.
|
||||||
**Severity:** must-fix-before-v0.5.0
|
**Severity:** must-fix-before-v0.5.0
|
||||||
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled per audit constraints)
|
**Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): added `relicario-server/` entry to project tree.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
|||||||
**Issue:** Says `Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM).` Plan 2 (extension) shipped, then Plans 1A-1C, 3A (backup), 3B (LastPass), Plan 4 (security fixes + device auth), and Phases 1-2B of the fullscreen UX redesign all shipped. The current "next thing" per project memory is v0.5.0 polish + harden plus Phase 3/4 of fullscreen UX.
|
**Issue:** Says `Next: WASM build + Chrome MV3 browser extension (Plan 2). Then mobile (Rust core compiles to ARM).` Plan 2 (extension) shipped, then Plans 1A-1C, 3A (backup), 3B (LastPass), Plan 4 (security fixes + device auth), and Phases 1-2B of the fullscreen UX redesign all shipped. The current "next thing" per project memory is v0.5.0 polish + harden plus Phase 3/4 of fullscreen UX.
|
||||||
**Fix:** Replace with a current-state Roadmap line (e.g. `Next: v0.5.0 polish + harden, then Phase 3 (vault tab shell). Mobile (ARM) and recovery QR remain on the roadmap.`).
|
**Fix:** Replace with a current-state Roadmap line (e.g. `Next: v0.5.0 polish + harden, then Phase 3 (vault tab shell). Mobile (ARM) and recovery QR remain on the roadmap.`).
|
||||||
**Severity:** must-fix-before-v0.5.0
|
**Severity:** must-fix-before-v0.5.0
|
||||||
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled; phrasing is a judgment call)
|
**Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): replaced with the v0.5.0 / Phases 3-4 / 1C-γ / LastPass / mobile / recovery-QR picture.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -54,7 +55,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
|||||||
**Issue:** Audit M8 bumped `ItemId`/`FieldId` to 16-char hex (64 bits). Verified against `crates/relicario-core/src/ids.rs:3-4, 35-37` and `tests/integration` — they're 16 hex chars. The same line also doesn't mention that `AttachmentId` was bumped to 32 hex chars / 128 bits (audit I2/B4).
|
**Issue:** Audit M8 bumped `ItemId`/`FieldId` to 16-char hex (64 bits). Verified against `crates/relicario-core/src/ids.rs:3-4, 35-37` and `tests/integration` — they're 16 hex chars. The same line also doesn't mention that `AttachmentId` was bumped to 32 hex chars / 128 bits (audit I2/B4).
|
||||||
**Fix:** Change to: `Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256(plaintext) (128 bits, audit I2/B4).`
|
**Fix:** Change to: `Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256(plaintext) (128 bits, audit I2/B4).`
|
||||||
**Severity:** must-fix-before-v0.5.0
|
**Severity:** must-fix-before-v0.5.0
|
||||||
**Status:** Proposed; needs user decision (CLAUDE.md is user-controlled)
|
**Status:** Fixed in `8fd9a05` (PM follow-up, 2026-05-02 with user approval): now reads "Item IDs and Field IDs are random 16-char hex strings (64 bits of OsRng entropy). AttachmentIds are content-addressed: first 32 hex chars of SHA-256 over the plaintext (128 bits)."
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -114,7 +115,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
|||||||
**Issue:** The vault-creation pipeline in this doc shows `master_key → XChaCha20-Poly1305 → manifest.enc` only. In reality `cmd_init` also encrypts and writes `settings.enc` (default `VaultSettings`). Field-history-tracked items, attachments, the `Item` envelope shape — none of these are in the flow doc. Without context on typed items, a new contributor reading this doc would have a v0.1-era model of the system.
|
**Issue:** The vault-creation pipeline in this doc shows `master_key → XChaCha20-Poly1305 → manifest.enc` only. In reality `cmd_init` also encrypts and writes `settings.enc` (default `VaultSettings`). Field-history-tracked items, attachments, the `Item` envelope shape — none of these are in the flow doc. Without context on typed items, a new contributor reading this doc would have a v0.1-era model of the system.
|
||||||
**Fix:** Add a settings.enc step to the flow; either expand the items section or note that the full item lifecycle is in `crates/relicario-core/ARCHITECTURE.md`.
|
**Fix:** Add a settings.enc step to the flow; either expand the items section or note that the full item lifecycle is in `crates/relicario-core/ARCHITECTURE.md`.
|
||||||
**Severity:** nice-to-have (the per-codebase ARCHITECTURE.md files are the source of truth; this top-level doc could just point at them)
|
**Severity:** nice-to-have (the per-codebase ARCHITECTURE.md files are the source of truth; this top-level doc could just point at them)
|
||||||
**Status:** Proposed; needs user decision (>50 words of new prose, design choice between rewriting vs trimming)
|
**Status:** Fixed in `76d092d` (PM follow-up, 2026-05-02): trim path. Added settings.enc as a parallel artifact in the encrypt step, then a short paragraph pointing at `crates/relicario-core/ARCHITECTURE.md` for the per-item lifecycle.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -124,7 +125,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
|||||||
**Issue:** Says `Device registration was optional before v0.4.0. With device auth enabled, all commits must be signed by a registered device.` But (a) v0.4.0 hasn't been tagged yet — the changelog goes v0.1.0 → v0.2.0 → "Unreleased", and the next tag-in-flight per project memory is v0.5.0; (b) per the v0.5.0 polish + harden spec, device-auth enforcement is **currently a no-op** because the pre-receive hook fix (S1) hasn't landed. Saying "all commits MUST be signed" is aspirational, not current.
|
**Issue:** Says `Device registration was optional before v0.4.0. With device auth enabled, all commits must be signed by a registered device.` But (a) v0.4.0 hasn't been tagged yet — the changelog goes v0.1.0 → v0.2.0 → "Unreleased", and the next tag-in-flight per project memory is v0.5.0; (b) per the v0.5.0 polish + harden spec, device-auth enforcement is **currently a no-op** because the pre-receive hook fix (S1) hasn't landed. Saying "all commits MUST be signed" is aspirational, not current.
|
||||||
**Fix:** Reword to clarify (a) the actual version line (e.g. "Pre-v0.5.0 vaults can opt out by leaving `devices.json` empty"), AND (b) acknowledge that signature *enforcement* depends on the pre-receive hook being deployed and the S1 fix landing. Could just be a one-line caveat.
|
**Fix:** Reword to clarify (a) the actual version line (e.g. "Pre-v0.5.0 vaults can opt out by leaving `devices.json` empty"), AND (b) acknowledge that signature *enforcement* depends on the pre-receive hook being deployed and the S1 fix landing. Could just be a one-line caveat.
|
||||||
**Severity:** must-fix-before-v0.5.0 (security-doc accuracy is part of the legibility pitch)
|
**Severity:** must-fix-before-v0.5.0 (security-doc accuracy is part of the legibility pitch)
|
||||||
**Status:** Proposed; needs user decision (security wording — exact phrasing matters)
|
**Status:** Fixed in `1342228` (PM follow-up, 2026-05-02 with user approval): dropped the "before v0.4.0" version line entirely (v0.4.0 was never tagged); replaced with a single line saying registration is optional but recommended for shared vaults. Enforcement story now lives in the Device Authentication section (see F12).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -134,7 +135,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
|||||||
**Issue:** The "Device Authentication" section refers to a "pre-receive hook" but never says it lives in `crates/relicario-server`, what binary the hook calls (`relicario-server verify-commit <sha>`), or how to install it (`relicario-server generate-hook`). For a self-hosted user reading this to decide whether to enable it, those are the two essential operational facts.
|
**Issue:** The "Device Authentication" section refers to a "pre-receive hook" but never says it lives in `crates/relicario-server`, what binary the hook calls (`relicario-server verify-commit <sha>`), or how to install it (`relicario-server generate-hook`). For a self-hosted user reading this to decide whether to enable it, those are the two essential operational facts.
|
||||||
**Fix:** Add a short paragraph naming the crate and the two subcommands, pointing to the design spec.
|
**Fix:** Add a short paragraph naming the crate and the two subcommands, pointing to the design spec.
|
||||||
**Severity:** nice-to-have
|
**Severity:** nice-to-have
|
||||||
**Status:** Proposed; needs user decision (>50 words of new prose)
|
**Status:** Fixed in `1342228` (PM follow-up, 2026-05-02): added paragraph naming the `relicario-server` crate, both subcommands (`generate-hook`, `verify-commit`), and the caveat that signed commits without the server hook provide authorship metadata only.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -144,7 +145,7 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
|||||||
**Issue:** This doc is explicitly historical (per `docs/architecture/overview.md` "Stale spec docs" disclaimer), so editing it as architecture would violate convention. Still worth flagging that "Post-V1 Ideas" lists secure notes, secure documents, mobile, LastPass import, Firefox extension, TOTP — most of which have shipped. Per project policy this is *informational only*; the spec is a time-stamped decision artifact.
|
**Issue:** This doc is explicitly historical (per `docs/architecture/overview.md` "Stale spec docs" disclaimer), so editing it as architecture would violate convention. Still worth flagging that "Post-V1 Ideas" lists secure notes, secure documents, mobile, LastPass import, Firefox extension, TOTP — most of which have shipped. Per project policy this is *informational only*; the spec is a time-stamped decision artifact.
|
||||||
**Fix:** None — leave alone. If desired, prepend a one-line "Status: V1 shipped 2026-04-22; many Post-V1 ideas have since landed — see CHANGELOG.md" at the top of the file.
|
**Fix:** None — leave alone. If desired, prepend a one-line "Status: V1 shipped 2026-04-22; many Post-V1 ideas have since landed — see CHANGELOG.md" at the top of the file.
|
||||||
**Severity:** informational
|
**Severity:** informational
|
||||||
**Status:** Proposed; needs user decision (touches a historical spec the user may want to leave frozen)
|
**Status:** Fixed in `9c97f9f` (PM follow-up, 2026-05-02): added the optional one-line status banner at the top of the spec pointing at CHANGELOG.md and overview.md for current state. Body of the spec untouched per the "specs are frozen decision artifacts" convention.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -160,10 +161,18 @@ The codebase itself is well-documented — `crates/{relicario-core,relicario-cli
|
|||||||
|
|
||||||
## Inline-fix verification
|
## Inline-fix verification
|
||||||
|
|
||||||
Files modified during this audit:
|
### Initial pass (commit `900ccf1`):
|
||||||
|
|
||||||
- `README.md` — vault layout (`items/`, `settings.enc`, `attachments/`), crate tree (added `relicario-wasm`, `relicario-server`, typed-items modules), ID width, Roadmap.
|
- `README.md` — vault layout (`items/`, `settings.enc`, `attachments/`), crate tree (added `relicario-wasm`, `relicario-server`, typed-items modules), ID width, Roadmap.
|
||||||
- `docs/ARCHITECTURE.md` — git-server box (`items/`, `settings.enc`, `attachments/`, `revoked.json`), crate-architecture inner box (current core modules), removed "Future: relicario-wasm" line.
|
- `docs/ARCHITECTURE.md` — git-server box (`items/`, `settings.enc`, `attachments/`, `revoked.json`), crate-architecture inner box (current core modules), removed "Future: relicario-wasm" line.
|
||||||
- `docs/architecture/overview.md` — conventions table (16-char hex IDs, 128-bit AttachmentIds).
|
- `docs/architecture/overview.md` — conventions table (16-char hex IDs, 128-bit AttachmentIds).
|
||||||
|
|
||||||
No source files, `Cargo.lock`, or extension code were modified. CLAUDE.md, SECURITY.md, and the foundational design spec were not modified — those changes need user review per the audit constraints.
|
### v0.5.0 PM follow-up pass (commits `ca059e7`, `8fd9a05`, `1342228`, `76d092d`, `9c97f9f`):
|
||||||
|
|
||||||
|
- `docs/architecture/overview.md` — F1: four-codebases framing, ASCII diagram fans out to server, table row, build matrix, "Where to look next".
|
||||||
|
- `CLAUDE.md` — F2: project tree gains `relicario-server`. F3: Roadmap line replaced. F4: Item/Field/Attachment ID widths and entropy noted.
|
||||||
|
- `docs/SECURITY.md` — F11: dropped `before v0.4.0` line. F12: Device Authentication section now names the `relicario-server` crate and its subcommands, with the "without the hook, commits are advisory" caveat.
|
||||||
|
- `docs/ARCHITECTURE.md` — F10: settings.enc shown alongside manifest.enc in the Vault Creation Flow; pointer added to per-crate ARCHITECTURE.md for typed-items detail.
|
||||||
|
- `docs/superpowers/specs/2026-04-11-relicario-design.md` — F13: optional one-line "historical spec" status banner at top.
|
||||||
|
|
||||||
|
No source files, `Cargo.lock`, or extension code were modified at any point.
|
||||||
|
|||||||
956
docs/superpowers/plans/2026-05-02-relay-server.md
Normal file
956
docs/superpowers/plans/2026-05-02-relay-server.md
Normal file
@@ -0,0 +1,956 @@
|
|||||||
|
# Relay Server Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Build a local MCP SSE server that gives PM, Dev-A, and Dev-B Claude Code sessions native `post_message`/`read_messages`/`list_pending` tools, eliminating manual copy-paste during multi-agent development lifts.
|
||||||
|
|
||||||
|
**Architecture:** A single Node.js process hosts an HTTP server with SSE transport for the MCP protocol. Three named in-memory FIFO queues (one per role) hold consume-once messages. A `start.sh` launcher prints copy-paste instructions (default) or spawns a tmux/kitty layout (flags). The multi-agent-kickoff skill templates get a `<<RELAY_PARAGRAPH>>` placeholder injected so every future lift prompt auto-includes relay instructions.
|
||||||
|
|
||||||
|
**Tech Stack:** Node.js v25, `@modelcontextprotocol/sdk` (MCP + SSE transport), `tsx` (dev dep, runs TypeScript directly), Node built-in `node:test` runner. No Express, no Hono, no Zod as a direct dep.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File map
|
||||||
|
|
||||||
|
| Action | Path | Responsibility |
|
||||||
|
|--------|------|----------------|
|
||||||
|
| Create | `tools/relay/package.json` | npm metadata, scripts, single runtime dep + tsx devDep |
|
||||||
|
| Create | `tools/relay/tsconfig.json` | TypeScript config for ESM Node target |
|
||||||
|
| Create | `tools/relay/queue.ts` | `RelayQueue` class — in-memory FIFO, `post`/`read`/`pending`, `isRole` guard |
|
||||||
|
| Create | `tools/relay/queue.test.ts` | Node `node:test` unit tests for queue (5 cases) |
|
||||||
|
| Create | `tools/relay/server.ts` | MCP `Server` + `SSEServerTransport` HTTP server on port 7331 |
|
||||||
|
| Create | `tools/relay/start.sh` | Launcher: `--manual` (default), `--tmux`, `--kitty` |
|
||||||
|
| Modify | `.gitignore` | Add `tools/relay/node_modules/` |
|
||||||
|
| Modify | `.claude/settings.json` | Add `mcpServers.relay` SSE entry |
|
||||||
|
| Create | `docs/superpowers/MULTI-AGENT.md` | Paradigm reference README |
|
||||||
|
| Modify | `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md` | Add `<<RELAY_PARAGRAPH>>` section |
|
||||||
|
| Modify | `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md` | Add `<<RELAY_PARAGRAPH>>` section |
|
||||||
|
| Modify | `~/.claude/skills/multi-agent-kickoff/SKILL.md` | Placeholder ref + step 8 update + `<<DEV_ROLE>>` placeholder |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: Scaffold `tools/relay/`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tools/relay/package.json`
|
||||||
|
- Create: `tools/relay/tsconfig.json`
|
||||||
|
- Modify: `.gitignore`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `tools/relay/package.json`**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "@relicario/relay",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "npx tsx server.ts",
|
||||||
|
"test": "node --import=tsx/esm --test queue.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.10.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"@types/node": "^22.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Create `tools/relay/tsconfig.json`**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["*.ts"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add to root `.gitignore`**
|
||||||
|
|
||||||
|
Open `/home/alee/Sources/relicario/.gitignore` and append:
|
||||||
|
|
||||||
|
```
|
||||||
|
tools/relay/node_modules/
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Install dependencies and verify**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/relay && npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `node_modules/` created, no errors. Verify with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls node_modules/@modelcontextprotocol/sdk && ls node_modules/tsx
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: both directories exist.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit scaffold**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tools/relay/package.json tools/relay/tsconfig.json tools/relay/package-lock.json .gitignore
|
||||||
|
git commit -m "chore(relay): scaffold tools/relay with MCP SDK dep"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `queue.ts` — TDD
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tools/relay/queue.ts`
|
||||||
|
- Create: `tools/relay/queue.test.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `tools/relay/queue.test.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { describe, it, beforeEach } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { RelayQueue, isRole } from "./queue.ts";
|
||||||
|
|
||||||
|
describe("RelayQueue", () => {
|
||||||
|
let q: RelayQueue;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
q = new RelayQueue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("post + read roundtrip returns the message with correct fields", () => {
|
||||||
|
q.post("dev-b", "pm", "status", "Task P4 DONE");
|
||||||
|
const msgs = q.read("pm");
|
||||||
|
assert.equal(msgs.length, 1);
|
||||||
|
assert.equal(msgs[0].from, "dev-b");
|
||||||
|
assert.equal(msgs[0].to, "pm");
|
||||||
|
assert.equal(msgs[0].kind, "status");
|
||||||
|
assert.equal(msgs[0].body, "Task P4 DONE");
|
||||||
|
assert.ok(typeof msgs[0].id === "string" && msgs[0].id.length > 0);
|
||||||
|
assert.ok(typeof msgs[0].ts === "string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consume-once: second read returns empty", () => {
|
||||||
|
q.post("dev-a", "pm", "question", "Should I use approach A?");
|
||||||
|
q.read("pm");
|
||||||
|
const second = q.read("pm");
|
||||||
|
assert.deepEqual(second, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("list_pending does not drain inbox", () => {
|
||||||
|
q.post("dev-b", "pm", "directive", "PROCEED");
|
||||||
|
const before = q.pending("pm");
|
||||||
|
assert.equal(before.count, 1);
|
||||||
|
const after = q.read("pm");
|
||||||
|
assert.equal(after.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FIFO ordering across multiple senders", () => {
|
||||||
|
q.post("dev-a", "pm", "status", "first");
|
||||||
|
q.post("dev-b", "pm", "status", "second");
|
||||||
|
q.post("dev-a", "pm", "question", "third");
|
||||||
|
const msgs = q.read("pm");
|
||||||
|
assert.equal(msgs.length, 3);
|
||||||
|
assert.equal(msgs[0].body, "first");
|
||||||
|
assert.equal(msgs[1].body, "second");
|
||||||
|
assert.equal(msgs[2].body, "third");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isRole rejects unknown strings", () => {
|
||||||
|
assert.ok(isRole("pm"));
|
||||||
|
assert.ok(isRole("dev-a"));
|
||||||
|
assert.ok(isRole("dev-b"));
|
||||||
|
assert.ok(!isRole("dev-c"));
|
||||||
|
assert.ok(!isRole(""));
|
||||||
|
assert.ok(!isRole("PM"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run tests to confirm they fail**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/relay && node --import=tsx/esm --test queue.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: fails with `Cannot find module './queue.ts'` or similar. If it fails with a different error, investigate before continuing.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Write `queue.ts`**
|
||||||
|
|
||||||
|
Create `tools/relay/queue.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
export type Role = "pm" | "dev-a" | "dev-b";
|
||||||
|
export type MessageKind = "status" | "question" | "directive" | "free";
|
||||||
|
|
||||||
|
export interface RelayMessage {
|
||||||
|
id: string;
|
||||||
|
from: Role;
|
||||||
|
to: Role;
|
||||||
|
kind: MessageKind;
|
||||||
|
body: string;
|
||||||
|
ts: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_ROLES = new Set<string>(["pm", "dev-a", "dev-b"]);
|
||||||
|
|
||||||
|
export function isRole(s: string): s is Role {
|
||||||
|
return KNOWN_ROLES.has(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RelayQueue {
|
||||||
|
private readonly queues = new Map<Role, RelayMessage[]>([
|
||||||
|
["pm", []],
|
||||||
|
["dev-a", []],
|
||||||
|
["dev-b", []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage {
|
||||||
|
const msg: RelayMessage = {
|
||||||
|
id: randomUUID(),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
kind,
|
||||||
|
body,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.queues.get(to)!.push(msg);
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
read(forRole: Role): RelayMessage[] {
|
||||||
|
const inbox = this.queues.get(forRole)!;
|
||||||
|
const messages = [...inbox];
|
||||||
|
inbox.length = 0;
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending(forRole: Role): { count: number; kinds: MessageKind[] } {
|
||||||
|
const inbox = this.queues.get(forRole)!;
|
||||||
|
return {
|
||||||
|
count: inbox.length,
|
||||||
|
kinds: inbox.map((m) => m.kind),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run tests to confirm they pass**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/relay && node --import=tsx/esm --test queue.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output (all 5 passing):
|
||||||
|
```
|
||||||
|
▶ RelayQueue
|
||||||
|
✔ post + read roundtrip returns the message with correct fields
|
||||||
|
✔ consume-once: second read returns empty
|
||||||
|
✔ list_pending does not drain inbox
|
||||||
|
✔ FIFO ordering across multiple senders
|
||||||
|
✔ isRole rejects unknown strings
|
||||||
|
▶ RelayQueue (Xms)
|
||||||
|
ℹ tests 5
|
||||||
|
ℹ pass 5
|
||||||
|
ℹ fail 0
|
||||||
|
```
|
||||||
|
|
||||||
|
If any test fails, fix `queue.ts` before proceeding.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tools/relay/queue.ts tools/relay/queue.test.ts
|
||||||
|
git commit -m "feat(relay): in-memory queue with consume-once semantics"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: `server.ts`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tools/relay/server.ts`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `server.ts`**
|
||||||
|
|
||||||
|
Create `tools/relay/server.ts`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||||
|
import {
|
||||||
|
CallToolRequestSchema,
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import http from "node:http";
|
||||||
|
import { RelayQueue, isRole } from "./queue.ts";
|
||||||
|
|
||||||
|
const PORT = 7331;
|
||||||
|
const queue = new RelayQueue();
|
||||||
|
|
||||||
|
const mcpServer = new Server(
|
||||||
|
{ name: "relay", version: "0.1.0" },
|
||||||
|
{ capabilities: { tools: {} } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
{
|
||||||
|
name: "post_message",
|
||||||
|
description:
|
||||||
|
"Push a message to a recipient's inbox. Returns the assigned message id.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
from: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["pm", "dev-a", "dev-b"],
|
||||||
|
description: "Your role name",
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["pm", "dev-a", "dev-b"],
|
||||||
|
description: "Recipient role name",
|
||||||
|
},
|
||||||
|
kind: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["status", "question", "directive", "free"],
|
||||||
|
description: "Message type matching the coordination protocol",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "string",
|
||||||
|
description: "Message body — freeform markdown, typically the full formatted block",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["from", "to", "kind", "body"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read_messages",
|
||||||
|
description:
|
||||||
|
"Pop and return all pending messages for this recipient. Inbox is empty after this call (consume-once).",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
for: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["pm", "dev-a", "dev-b"],
|
||||||
|
description: "Your role name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["for"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_pending",
|
||||||
|
description:
|
||||||
|
"Return count and kinds of pending messages without consuming them.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
for: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["pm", "dev-a", "dev-b"],
|
||||||
|
description: "Your role name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["for"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
||||||
|
|
||||||
|
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
const { name, arguments: args } = request.params;
|
||||||
|
const a = args as Record<string, string>;
|
||||||
|
|
||||||
|
if (name === "post_message") {
|
||||||
|
if (!isRole(a.from)) {
|
||||||
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.from}"` }], isError: true };
|
||||||
|
}
|
||||||
|
if (!isRole(a.to)) {
|
||||||
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.to}"` }], isError: true };
|
||||||
|
}
|
||||||
|
const kind = a.kind as "status" | "question" | "directive" | "free";
|
||||||
|
const msg = queue.post(a.from, a.to, kind, a.body);
|
||||||
|
const ts = new Date(msg.ts).toTimeString().slice(0, 8);
|
||||||
|
const preview = a.body.slice(0, 60).replace(/\n/g, " ");
|
||||||
|
const ellipsis = a.body.length > 60 ? "..." : "";
|
||||||
|
process.stdout.write(`[${ts}] ${a.from} → ${a.to} [${kind}] "${preview}${ellipsis}"\n`);
|
||||||
|
return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "read_messages") {
|
||||||
|
if (!isRole(a.for)) {
|
||||||
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
|
||||||
|
}
|
||||||
|
const messages = queue.read(a.for);
|
||||||
|
return { content: [{ type: "text" as const, text: JSON.stringify(messages) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "list_pending") {
|
||||||
|
if (!isRole(a.for)) {
|
||||||
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
|
||||||
|
}
|
||||||
|
const result = queue.pending(a.for);
|
||||||
|
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: `Error: unknown tool "${name}"` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const transports = new Map<string, SSEServerTransport>();
|
||||||
|
|
||||||
|
const httpServer = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.method === "GET" && req.url === "/sse") {
|
||||||
|
const transport = new SSEServerTransport("/message", res);
|
||||||
|
transports.set(transport.sessionId, transport);
|
||||||
|
transport.onclose = () => transports.delete(transport.sessionId);
|
||||||
|
await mcpServer.connect(transport);
|
||||||
|
} else if (req.method === "POST" && req.url?.startsWith("/message")) {
|
||||||
|
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||||
|
const sessionId = url.searchParams.get("sessionId") ?? "";
|
||||||
|
const transport = transports.get(sessionId);
|
||||||
|
if (transport) {
|
||||||
|
await transport.handlePostMessage(req, res);
|
||||||
|
} else {
|
||||||
|
res.writeHead(404, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "session not found" }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.writeHead(404).end("not found");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[relay] error:", err);
|
||||||
|
if (!res.headersSent) res.writeHead(500).end(String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
httpServer.listen(PORT, "127.0.0.1", () => {
|
||||||
|
console.log(`[relay] server ready on :${PORT}`);
|
||||||
|
console.log(`[relay] tools: post_message, read_messages, list_pending`);
|
||||||
|
console.log(`[relay] waiting for connections — Ctrl-C to stop`);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Smoke-test server startup**
|
||||||
|
|
||||||
|
In one terminal:
|
||||||
|
```bash
|
||||||
|
cd tools/relay && npx tsx server.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected output:
|
||||||
|
```
|
||||||
|
[relay] server ready on :7331
|
||||||
|
[relay] tools: post_message, read_messages, list_pending
|
||||||
|
[relay] waiting for connections — Ctrl-C to stop
|
||||||
|
```
|
||||||
|
|
||||||
|
In a second terminal, verify the port is listening:
|
||||||
|
```bash
|
||||||
|
curl -s --max-time 2 http://127.0.0.1:7331/sse | head -3
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: SSE `data:` stream begins (it won't complete — the connection stays open). Ctrl-C both.
|
||||||
|
|
||||||
|
If the server errors on startup, check that `@modelcontextprotocol/sdk` is installed and review any TypeScript errors by running `npx tsc --noEmit` in `tools/relay/`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tools/relay/server.ts
|
||||||
|
git commit -m "feat(relay): MCP SSE server with post_message/read_messages/list_pending"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: `start.sh`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `tools/relay/start.sh`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write `start.sh`**
|
||||||
|
|
||||||
|
Create `tools/relay/start.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
|
||||||
|
PORT=7331
|
||||||
|
MODE="manual"
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--tmux) MODE="tmux" ;;
|
||||||
|
--kitty) MODE="kitty" ;;
|
||||||
|
--manual) MODE="manual" ;;
|
||||||
|
*) echo "Unknown option: $arg" >&2; echo "Usage: $0 [--manual|--tmux|--kitty]" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Port check
|
||||||
|
if lsof -ti:"$PORT" &>/dev/null; then
|
||||||
|
echo "Error: port $PORT is already in use."
|
||||||
|
echo "Relay already running? Kill it with: kill \$(lsof -ti:$PORT)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install deps (no-op if node_modules current)
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
npm install --silent
|
||||||
|
|
||||||
|
# Discover latest coordination prompts for instructions
|
||||||
|
COORD_DIR="$REPO_ROOT/docs/superpowers/coordination"
|
||||||
|
PM_PROMPT="$(ls -t "$COORD_DIR"/*-pm-prompt.md 2>/dev/null | head -1 || echo "(none found — run multi-agent-kickoff skill first)")"
|
||||||
|
DEV_A_PROMPT="$(ls -t "$COORD_DIR"/*-dev-a-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
||||||
|
DEV_B_PROMPT="$(ls -t "$COORD_DIR"/*-dev-b-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
||||||
|
|
||||||
|
print_manual_instructions() {
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ RELAY SERVER — MULTI-AGENT LIFT LAUNCHER ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo "Open 3 new terminals. In each, start Claude Code and paste"
|
||||||
|
echo "the content BELOW the '---' line from the corresponding file."
|
||||||
|
echo ""
|
||||||
|
echo " Terminal 1 (PM): cat '$PM_PROMPT'"
|
||||||
|
echo " Terminal 2 (Dev A): cat '$DEV_A_PROMPT'"
|
||||||
|
echo " Terminal 3 (Dev B): cat '$DEV_B_PROMPT'"
|
||||||
|
echo ""
|
||||||
|
echo "This terminal becomes the relay log. Keep it open."
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════════════════════════"
|
||||||
|
}
|
||||||
|
|
||||||
|
launch_tmux() {
|
||||||
|
SESSION="relay-lift"
|
||||||
|
tmux new-session -d -s "$SESSION" -n "relay" \
|
||||||
|
"cd '$SCRIPT_DIR' && npx tsx server.ts"
|
||||||
|
tmux new-window -t "$SESSION:" -n "pm" "cd '$REPO_ROOT' && claude"
|
||||||
|
tmux new-window -t "$SESSION:" -n "dev-a" "cd '$REPO_ROOT' && claude"
|
||||||
|
tmux new-window -t "$SESSION:" -n "dev-b" "cd '$REPO_ROOT' && claude"
|
||||||
|
echo ""
|
||||||
|
echo "[relay] Opened tmux session '$SESSION' with 4 windows: relay, pm, dev-a, dev-b."
|
||||||
|
echo "[relay] Paste the kickoff prompt into each Claude window."
|
||||||
|
echo " Prompts:"
|
||||||
|
echo " PM: $PM_PROMPT"
|
||||||
|
echo " Dev A: $DEV_A_PROMPT"
|
||||||
|
echo " Dev B: $DEV_B_PROMPT"
|
||||||
|
echo ""
|
||||||
|
tmux attach-session -t "$SESSION"
|
||||||
|
}
|
||||||
|
|
||||||
|
launch_kitty() {
|
||||||
|
kitty @ launch --new-tab --tab-title "relay" -- \
|
||||||
|
bash -c "cd '$SCRIPT_DIR' && npx tsx server.ts"
|
||||||
|
kitty @ launch --new-window --window-title "PM" -- \
|
||||||
|
bash -c "cd '$REPO_ROOT' && claude"
|
||||||
|
kitty @ launch --new-window --window-title "Dev-A" -- \
|
||||||
|
bash -c "cd '$REPO_ROOT' && claude"
|
||||||
|
kitty @ launch --new-window --window-title "Dev-B" -- \
|
||||||
|
bash -c "cd '$REPO_ROOT' && claude"
|
||||||
|
echo ""
|
||||||
|
echo "[relay] Opened kitty tab 'relay' + 3 windows (PM, Dev-A, Dev-B)."
|
||||||
|
echo " Paste the kickoff prompts into each Claude window."
|
||||||
|
echo " PM: $PM_PROMPT"
|
||||||
|
echo " Dev A: $DEV_A_PROMPT"
|
||||||
|
echo " Dev B: $DEV_B_PROMPT"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
manual)
|
||||||
|
print_manual_instructions
|
||||||
|
exec npx tsx "$SCRIPT_DIR/server.ts"
|
||||||
|
;;
|
||||||
|
tmux)
|
||||||
|
launch_tmux
|
||||||
|
;;
|
||||||
|
kitty)
|
||||||
|
launch_kitty
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Make executable**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x tools/relay/start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Smoke-test `--manual` mode**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/alee/Sources/relicario && tools/relay/start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: prints the launch box with prompt paths, then server starts and shows `[relay] server ready on :7331`. Ctrl-C to stop.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tools/relay/start.sh
|
||||||
|
git commit -m "feat(relay): start.sh launcher with --manual/--tmux/--kitty modes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Claude Code MCP configuration
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.claude/settings.json`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read current `.claude/settings.json`**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat .claude/settings.json
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the relay MCP server entry**
|
||||||
|
|
||||||
|
The file currently has `{ "enabledPlugins": { ... } }`. Add `"mcpServers"` at the top level:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"relay": {
|
||||||
|
"type": "sse",
|
||||||
|
"url": "http://localhost:7331/sse"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"enabledPlugins": {
|
||||||
|
"superpowers@claude-plugins-official": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Preserve whatever is already in `enabledPlugins` — only add the `mcpServers` key.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .claude/settings.json
|
||||||
|
git commit -m "chore(relay): add relay MCP server to project Claude config"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: `docs/superpowers/MULTI-AGENT.md`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `docs/superpowers/MULTI-AGENT.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the paradigm README**
|
||||||
|
|
||||||
|
Create `docs/superpowers/MULTI-AGENT.md`:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# Multi-Agent Development Paradigm
|
||||||
|
|
||||||
|
This repo uses a three-terminal workflow for large development lifts: one Claude Code session acts as **PM** and two act as **senior developers** (Dev-A, Dev-B), each working in their own git worktree on a parallel feature branch.
|
||||||
|
|
||||||
|
A local relay MCP server eliminates manual message copying between terminals — agents call `post_message`/`read_messages` instead of asking the user to copy-paste.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Role | Terminal | Branch | Responsibilities |
|
||||||
|
|------|----------|--------|-----------------|
|
||||||
|
| PM | 1 | `main` (read-only) | Drive doc-audit follow-ups, review PRs, write CHANGELOG, authorize merges and tagging |
|
||||||
|
| Dev-A | 2 | `feature/<release>-plan-a-*` | Implement Plan A tasks in their own worktree |
|
||||||
|
| Dev-B | 3 | `feature/<release>-plan-b-*` | Implement Plan B tasks in their own worktree |
|
||||||
|
| Relay server | 4 | — | Message bus; Ctrl-C to stop at end of lift |
|
||||||
|
|
||||||
|
**User's job:** authorize merges (the PM asks), resolve escalations the PM can't handle, and watch the streams. You are no longer the message bus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Starting a lift
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [ ] Kickoff prompts exist in `docs/superpowers/coordination/` (generate with the `multi-agent-kickoff` skill if not)
|
||||||
|
- [ ] No uncommitted changes in main that would confuse the devs
|
||||||
|
- [ ] `tools/relay/` is present (run `ls tools/relay/` to confirm)
|
||||||
|
|
||||||
|
### Launch sequence
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Start the relay server (this terminal becomes the relay log)
|
||||||
|
tools/relay/start.sh # prints copy-paste instructions, then starts server
|
||||||
|
|
||||||
|
# Optional: use a multiplexer to auto-open all four terminals
|
||||||
|
tools/relay/start.sh --tmux # creates tmux session "relay-lift" with 4 windows
|
||||||
|
tools/relay/start.sh --kitty # creates kitty tab "relay" + 3 windows
|
||||||
|
```
|
||||||
|
|
||||||
|
`start.sh` prints the paths to the three kickoff prompt files. In each Claude Code terminal, run `cat <path>` and paste everything below the `---` line as the first message.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Coordination protocol
|
||||||
|
|
||||||
|
Agents communicate by posting structured blocks to each other's inboxes. Four message kinds:
|
||||||
|
|
||||||
|
| Kind | Block header | When used |
|
||||||
|
|------|-------------|-----------|
|
||||||
|
| `status` | `## STATUS UPDATE — DEV-*` | After completing a task, getting blocked, or reaching a review-ready state |
|
||||||
|
| `question` | `## QUESTION TO PM — DEV-*` | When a dev needs PM input mid-task |
|
||||||
|
| `directive` | `## DIRECTIVE TO DEV-*` | When PM instructs a dev to proceed, hold, rescope, or approve a PR |
|
||||||
|
| `free` | (none) | Ad-hoc messages not covered by the above |
|
||||||
|
|
||||||
|
A well-formed `status` block:
|
||||||
|
|
||||||
|
```
|
||||||
|
## STATUS UPDATE — DEV-B
|
||||||
|
Time: 2026-05-02T14:30:00-07:00
|
||||||
|
Branch: feature/v0.5.0-plan-b-extension-ux
|
||||||
|
Task: P4 / error-copy map
|
||||||
|
Status: DONE
|
||||||
|
Last commit: abc1234 feat(extension): centralize ERROR_COPY map
|
||||||
|
Tests: green
|
||||||
|
Notes: No issues. Ready for PM review of P4 before starting B1.
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Using the relay tools
|
||||||
|
|
||||||
|
All three Claude Code sessions have these tools available when the relay server is running:
|
||||||
|
|
||||||
|
```
|
||||||
|
post_message(from, to, kind, body) → { id }
|
||||||
|
read_messages(for) → RelayMessage[] (drains inbox)
|
||||||
|
list_pending(for) → { count, kinds } (non-destructive)
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical dev flow per task:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. read_messages(for="dev-b") # check for directives before starting
|
||||||
|
2. ... do the work ...
|
||||||
|
3. post_message(from="dev-b", to="pm", kind="status", body="## STATUS UPDATE...")
|
||||||
|
```
|
||||||
|
|
||||||
|
Typical PM flow:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. read_messages(for="pm") # see what devs posted
|
||||||
|
2. ... review ...
|
||||||
|
3. post_message(from="pm", to="dev-b", kind="directive", body="## DIRECTIVE TO DEV-B...")
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## If the relay server isn't running
|
||||||
|
|
||||||
|
Claude Code will show a yellow MCP connection warning for the `relay` server. The tools will be unavailable.
|
||||||
|
|
||||||
|
Agents fall back to the manual protocol: they emit the structured blocks as text and ask the user to copy-paste them to the relevant terminal. This is slower but fully functional — the coordination protocol works either way.
|
||||||
|
|
||||||
|
To restart a crashed server mid-lift:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/relay/start.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
In-flight messages are lost on restart. Any agent with unread messages should re-post them.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generating kickoff prompts
|
||||||
|
|
||||||
|
Use the `multi-agent-kickoff` skill (in the `superpowers` plugin). It auto-discovers the spec and plans for the release, substitutes all placeholders including the relay paragraph, and writes files to `docs/superpowers/coordination/`.
|
||||||
|
|
||||||
|
The skill reminder: run `tools/relay/start.sh` **before** opening the three Claude Code sessions — the MCP tools need the server to be up when each session initializes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ending a lift
|
||||||
|
|
||||||
|
1. PM emits `REVIEW-COMPLETE` and `MERGE-APPROVED` for each dev's PR
|
||||||
|
2. User merges each PR (the PM session does `gh pr merge` with user authorization)
|
||||||
|
3. PM tags the release (only after explicit user `yes`)
|
||||||
|
4. Ctrl-C the relay terminal — all in-memory messages are discarded
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roles and boundaries (quick reference)
|
||||||
|
|
||||||
|
**PM must not:** write feature code, merge without user authorization, tag without user approval, run `git push --force` / `git reset --hard` without asking.
|
||||||
|
|
||||||
|
**Devs must not:** merge their branch to main, push `--force`, run `git reset --hard` without asking.
|
||||||
|
|
||||||
|
**User must:** authorize all merges and the release tag. Everything else is delegated.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/superpowers/MULTI-AGENT.md
|
||||||
|
git commit -m "docs: add multi-agent development paradigm README"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 7: Update `multi-agent-kickoff` skill
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md`
|
||||||
|
- Modify: `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md`
|
||||||
|
- Modify: `~/.claude/skills/multi-agent-kickoff/SKILL.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Read current templates**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cat ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md
|
||||||
|
cat ~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Note where the "Setup" section ends in each template. The relay paragraph goes right after it (before "Required reading").
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add `<<RELAY_PARAGRAPH>>` to `pm-prompt.md`**
|
||||||
|
|
||||||
|
In `~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md`, find the "## Setup" section and add the placeholder block immediately after it (before the "## Required reading" heading):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<<RELAY_PARAGRAPH>>
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated output for this placeholder (substituted by the skill at generation time) is:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — push a message; `from` is always `"pm"` for you
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="pm"` before each action
|
||||||
|
- `list_pending(for)` — check inbox count without consuming
|
||||||
|
|
||||||
|
Recipients: `pm`, `dev-a`, `dev-b`. Use these instead of asking the user to copy-paste. After sending any directive, call `post_message(from="pm", to="dev-a", kind="directive", body="...")`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add `<<RELAY_PARAGRAPH>>` to `dev-prompt.md`**
|
||||||
|
|
||||||
|
In `~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md`, find the "## Setup" section and add immediately after it (before "## Required reading"):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
<<RELAY_PARAGRAPH>>
|
||||||
|
```
|
||||||
|
|
||||||
|
The generated output for this placeholder is role-specific (uses `<<DEV_ROLE>>`):
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## Relay server
|
||||||
|
|
||||||
|
A message-bus MCP server is running on `localhost:7331`. You have three native tools:
|
||||||
|
|
||||||
|
- `post_message(from, to, kind, body)` — push a message; your `from` is always `"<<DEV_ROLE>>"`
|
||||||
|
- `read_messages(for)` — drain your inbox; call with `for="<<DEV_ROLE>>"` before each task
|
||||||
|
- `list_pending(for)` — check inbox count without consuming
|
||||||
|
|
||||||
|
Recipients: `pm`, `dev-a`, `dev-b`. Use these instead of asking the user to copy-paste. Before starting each task: `read_messages(for="<<DEV_ROLE>>")`. After emitting any status/question block: `post_message(from="<<DEV_ROLE>>", to="pm", kind="status"|"question", body="...")`.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Update `SKILL.md` — add two entries to the placeholder reference table**
|
||||||
|
|
||||||
|
In `~/.claude/skills/multi-agent-kickoff/SKILL.md`, find the "### Common to all prompts" section of the Placeholder reference and add:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- `<<RELAY_PARAGRAPH>>` — the relay server instruction block (substituted from the template above). For the PM prompt, `from` is hardcoded to `"pm"`. For dev prompts, uses `<<DEV_ROLE>>`.
|
||||||
|
```
|
||||||
|
|
||||||
|
In the "### Per-dev" section, add:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
- `<<DEV_ROLE>>` — lowercase relay role name, e.g. `dev-a`, `dev-b`. Derived from `<<DEV_LETTER>>` by lowercasing and prepending `dev-`. Set when `<<DEV_LETTER>>` is set.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Update `SKILL.md` — step 8 (kickoff instructions)**
|
||||||
|
|
||||||
|
Find step 8 in the Process section ("Print kickoff instructions") and prepend a bullet:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
8. **Print kickoff instructions.** Tell the user exactly what to do:
|
||||||
|
- **Start the relay server first:** `tools/relay/start.sh` (or `--tmux`/`--kitty` for auto-layout). The server must be running before the sessions open so the MCP tools initialize correctly.
|
||||||
|
- Open three terminal windows (or panes — their choice of multiplexer)
|
||||||
|
...rest of existing bullets unchanged...
|
||||||
|
```
|
||||||
|
|
||||||
|
Also update the "After generation" section — change bullet 4 ("From that point on, they're the message bus...") to:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
4. The relay server handles message routing — agents call `post_message`/`read_messages` directly. The user only needs to step in for escalations the PM can't resolve, or if the relay server is down (manual fallback: copy-paste the block to the relevant terminal as before)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit skill changes**
|
||||||
|
|
||||||
|
The skill files live outside the git repo, so no git commit needed. Verify the changes look right:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -n "RELAY_PARAGRAPH\|DEV_ROLE\|relay server" ~/.claude/skills/multi-agent-kickoff/SKILL.md | head -10
|
||||||
|
grep -n "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md
|
||||||
|
grep -n "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: each grep returns at least one match.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Final verification
|
||||||
|
|
||||||
|
- [ ] **Run queue tests one more time from repo root**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd tools/relay && node --import=tsx/esm --test queue.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: 5 passing, 0 failing.
|
||||||
|
|
||||||
|
- [ ] **Start server and verify it binds**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tools/relay/start.sh &
|
||||||
|
sleep 1
|
||||||
|
curl -s --max-time 1 http://127.0.0.1:7331/sse | head -1 || true
|
||||||
|
kill %1
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `data:` line appears (SSE stream started), then server killed cleanly.
|
||||||
|
|
||||||
|
- [ ] **Verify MCP config is present**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 -c "import json; d=json.load(open('.claude/settings.json')); print(d['mcpServers']['relay'])"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: `{'type': 'sse', 'url': 'http://localhost:7331/sse'}`
|
||||||
|
|
||||||
|
- [ ] **Verify skill placeholders were added**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep "RELAY_PARAGRAPH" ~/.claude/skills/multi-agent-kickoff/templates/pm-prompt.md \
|
||||||
|
~/.claude/skills/multi-agent-kickoff/templates/dev-prompt.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: one match per file.
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
# Relicario — Design Specification
|
# Relicario — Design Specification
|
||||||
|
|
||||||
|
> **Status:** historical. V1 shipped 2026-04-22; several "Post-V1 Ideas" listed below (typed items, attachments, secure documents, TOTP, Firefox extension, LastPass import, device authentication) have since shipped. See `CHANGELOG.md` and `docs/architecture/overview.md` for current state. Do not edit this spec as if it were architecture documentation — it is a time-stamped decision artifact.
|
||||||
|
|
||||||
A git-backed, self-hostable password manager with a Rust core, CLI, and Chrome browser extension. The reference image as a DCT-embedded secret carrier is the core differentiator.
|
A git-backed, self-hostable password manager with a Rust core, CLI, and Chrome browser extension. The reference image as a DCT-embedded secret carrier is the core differentiator.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|||||||
218
docs/superpowers/specs/2026-05-02-relay-server-design.md
Normal file
218
docs/superpowers/specs/2026-05-02-relay-server-design.md
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
# Relay Server Design
|
||||||
|
|
||||||
|
**Date:** 2026-05-02
|
||||||
|
**Status:** Approved
|
||||||
|
**Scope:** Dev tooling — not shipped in any product artifact
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Multi-agent development lifts (PM + Dev-A + Dev-B in parallel Claude Code sessions) require passing status updates, questions, and directives between terminals. Today the user manually copies and pastes every message block. This is error-prone, breaks flow, and scales poorly as lift complexity grows.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
A lightweight MCP server running on localhost that gives all three Claude Code sessions native tools to post and read messages. The user stops being the message bus.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository layout
|
||||||
|
|
||||||
|
```
|
||||||
|
tools/relay/
|
||||||
|
├── package.json # private, not published; single dep: @modelcontextprotocol/sdk
|
||||||
|
├── tsconfig.json
|
||||||
|
├── server.ts # MCP SSE server entry point (~150 lines)
|
||||||
|
├── queue.ts # in-memory queue logic (~50 lines)
|
||||||
|
├── queue.test.ts # Node built-in test runner
|
||||||
|
└── start.sh # launcher script
|
||||||
|
```
|
||||||
|
|
||||||
|
Added to root `.gitignore`: `tools/relay/node_modules/`, `tools/relay/dist/`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech stack
|
||||||
|
|
||||||
|
- **Runtime:** Node.js (v25, already installed at `/usr/bin/node`)
|
||||||
|
- **Package manager:** npm (bun has known compat gaps with the MCP SDK's SSE transport)
|
||||||
|
- **Dependencies:** `@modelcontextprotocol/sdk`, `tsx` (devDependency, runs TypeScript directly — no compile step)
|
||||||
|
- **Transport:** SSE (`SSEServerTransport` from the SDK handles the HTTP layer — no Express or Hono needed)
|
||||||
|
- **Port:** `7331` (hardcoded; easy to remember, unlikely to collide)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Queue model
|
||||||
|
|
||||||
|
Three named inboxes: `pm`, `dev-a`, `dev-b`. Each is a FIFO array in memory.
|
||||||
|
|
||||||
|
Message shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
interface RelayMessage {
|
||||||
|
id: string; // uuid v4
|
||||||
|
from: string; // sender role name
|
||||||
|
to: string; // recipient role name
|
||||||
|
kind: "status" | "question" | "directive" | "free";
|
||||||
|
body: string; // freeform, typically the existing markdown block format
|
||||||
|
ts: string; // ISO 8601
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`kind` maps to the existing coordination protocol:
|
||||||
|
|
||||||
|
| kind | existing block |
|
||||||
|
|-------------|-----------------------------|
|
||||||
|
| `status` | `## STATUS UPDATE — DEV-*` |
|
||||||
|
| `question` | `## QUESTION TO PM — DEV-*` |
|
||||||
|
| `directive` | `## DIRECTIVE TO DEV-*` |
|
||||||
|
| `free` | ad-hoc / unstructured |
|
||||||
|
|
||||||
|
Messages are **consume-once**: `read_messages` drains the inbox. There is no persistence — if the server restarts mid-lift, in-flight messages are lost. Acceptable for a dev tool; agents re-send on reconnect.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MCP tool surface
|
||||||
|
|
||||||
|
All three tools are exposed to every connected session.
|
||||||
|
|
||||||
|
### `post_message`
|
||||||
|
|
||||||
|
```
|
||||||
|
post_message(from: "pm"|"dev-a"|"dev-b", to: "pm"|"dev-a"|"dev-b", kind: "status"|"question"|"directive"|"free", body: string) → { id: string }
|
||||||
|
```
|
||||||
|
|
||||||
|
Pushes one message onto the target's inbox. Returns the assigned message id. Errors if `to` or `from` is not a known role. Agents declare their own identity via `from` — the kickoff prompt tells each agent its role name.
|
||||||
|
|
||||||
|
### `read_messages`
|
||||||
|
|
||||||
|
```
|
||||||
|
read_messages(for: "pm"|"dev-a"|"dev-b") → RelayMessage[]
|
||||||
|
```
|
||||||
|
|
||||||
|
Pops and returns all pending messages for that recipient, in FIFO order. After this call the inbox is empty.
|
||||||
|
|
||||||
|
### `list_pending`
|
||||||
|
|
||||||
|
```
|
||||||
|
list_pending(for: "pm"|"dev-a"|"dev-b") → { count: number, kinds: string[] }
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns count and kind breakdown of pending messages without consuming them. Lets an agent cheaply check "do I have anything to act on?" before committing to a `read_messages` call.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server terminal output
|
||||||
|
|
||||||
|
Every `post_message` call prints a one-liner to stdout in the dedicated relay terminal:
|
||||||
|
|
||||||
|
```
|
||||||
|
[14:32:01] dev-b → pm [status] "Task P4 DONE, last commit abc1234..."
|
||||||
|
[14:33:15] pm → dev-b [directive] "PROCEED to task B1"
|
||||||
|
```
|
||||||
|
|
||||||
|
This log is the operational value of keeping the server in a dedicated terminal rather than backgrounding it.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Launcher script (`start.sh`)
|
||||||
|
|
||||||
|
`start.sh` accepts one optional flag:
|
||||||
|
|
||||||
|
| Flag | Behavior |
|
||||||
|
|------------|----------|
|
||||||
|
| *(default)*| `--manual` mode: prints three labeled prompt blocks (one per role) for copy-paste into fresh Claude Code sessions, then starts the server in the foreground |
|
||||||
|
| `--tmux` | Creates a new tmux window with four panes: relay server + PM + Dev-A + Dev-B, each pre-loaded with its kickoff command |
|
||||||
|
| `--kitty` | Same layout using kitty's `launch --new-tab` / `--new-window` |
|
||||||
|
|
||||||
|
Execution order (all modes):
|
||||||
|
|
||||||
|
1. `cd tools/relay && npm install --silent` (no-op if `node_modules` is current)
|
||||||
|
2. Print the session snippet (copy-paste blocks or multiplexer launch)
|
||||||
|
3. Foreground `npx tsx server.ts` — the terminal that ran `start.sh` becomes the relay terminal; no compile step needed
|
||||||
|
|
||||||
|
Port-already-in-use check before step 3: if `:7331` is bound, print `relay already running? kill it with: kill $(lsof -ti:7331)` and exit 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claude Code configuration
|
||||||
|
|
||||||
|
Add to project `.claude/settings.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
"mcpServers": {
|
||||||
|
"relay": {
|
||||||
|
"type": "sse",
|
||||||
|
"url": "http://localhost:7331/sse"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is project-scoped — the relay tools only appear in Relicario Claude Code sessions. When the server is not running, Claude Code shows a yellow MCP connection warning but does not break. Agents gracefully fall back to asking the user to relay manually (existing behavior).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kickoff prompt changes
|
||||||
|
|
||||||
|
One paragraph added near the top of each coordination prompt (`v0.5.0-pm-prompt.md`, `v0.5.0-dev-a-prompt.md`, `v0.5.0-dev-b-prompt.md` as template):
|
||||||
|
|
||||||
|
> **Relay server:** A message-bus MCP server is running. You have three tools: `post_message(to, kind, body)`, `read_messages(for)`, `list_pending(for)`. Recipients: `pm`, `dev-a`, `dev-b`. Use these instead of asking the user to copy-paste. Before starting each task call `read_messages(for="<your-role>")`. After emitting any status, question, or directive block, call `post_message` with `kind` set to the block type and `body` set to the formatted block.
|
||||||
|
|
||||||
|
The `multi-agent-kickoff` skill is updated to:
|
||||||
|
- Remind the user to run `tools/relay/start.sh` before opening the three sessions
|
||||||
|
- Inject the relay paragraph automatically into every generated kickoff prompt
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error handling
|
||||||
|
|
||||||
|
| Scenario | Behavior |
|
||||||
|
|----------|----------|
|
||||||
|
| Unknown `to` in `post_message` | MCP error returned; message not queued |
|
||||||
|
| Server crash / restart | In-flight messages lost; agent re-sends |
|
||||||
|
| Port 7331 in use at startup | Startup exits 1 with a kill hint |
|
||||||
|
| Session connects before server starts | Claude Code shows MCP warning; agent falls back to manual relay |
|
||||||
|
|
||||||
|
No authentication. This is localhost-only, single-machine, dev-tool use.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
`queue.test.ts` using Node's built-in `node:test` runner. No extra test dep.
|
||||||
|
|
||||||
|
Coverage:
|
||||||
|
- `post_message` + `read_messages` roundtrip (single and multiple messages)
|
||||||
|
- Consume-once: second `read_messages` on same inbox returns empty
|
||||||
|
- `list_pending` does not drain inbox
|
||||||
|
- FIFO ordering across multiple senders to the same inbox
|
||||||
|
- Unknown recipient returns an error
|
||||||
|
|
||||||
|
No integration test against the MCP SSE transport — that is the SDK's responsibility.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Top-level README (`docs/superpowers/MULTI-AGENT.md`)
|
||||||
|
|
||||||
|
A durable reference document covering the whole development paradigm — not the relay server specifically, but the entire three-terminal workflow that the relay server enables. Lives in `docs/superpowers/` alongside the specs and plans it describes.
|
||||||
|
|
||||||
|
**Contents:**
|
||||||
|
|
||||||
|
1. **Overview** — the PM/Dev-A/Dev-B pattern: why three terminals, what each role owns, what the user's job is (authorize merges, resolve escalations)
|
||||||
|
2. **Starting a lift** — prerequisites checklist, then: `tools/relay/start.sh` → three sessions → paste kickoff prompts
|
||||||
|
3. **Coordination protocol reference** — the four message kinds (`status`, `question`, `directive`, `free`), when each is used, what a well-formed body looks like
|
||||||
|
4. **Using the relay tools** — `post_message`, `read_messages`, `list_pending` with one-liner examples
|
||||||
|
5. **If the relay server isn't running** — fallback to manual copy-paste; the coordination protocol still works, just with the user as bus
|
||||||
|
6. **Generating kickoff prompts** — point to the `multi-agent-kickoff` skill; note that the skill injects the relay paragraph automatically
|
||||||
|
7. **Ending a lift** — PM emits MERGE-APPROVED, devs push branches, user authorizes merges, Ctrl-C the relay terminal
|
||||||
|
|
||||||
|
This README is written for future-you opening the repo six months from now, not for the current lift.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What this is not
|
||||||
|
|
||||||
|
- Not a product feature — never bundled with the extension or CLI
|
||||||
|
- Not persistent — no SQLite, no file queue, in-memory only
|
||||||
|
- Not authenticated — localhost dev tool, no threat model
|
||||||
|
- Not a general-purpose message bus — three hardcoded roles, no dynamic registration
|
||||||
1701
tools/relay/package-lock.json
generated
Normal file
1701
tools/relay/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
17
tools/relay/package.json
Normal file
17
tools/relay/package.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "@relicario/relay",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"start": "npx tsx server.ts",
|
||||||
|
"test": "node --import=tsx/esm --test queue.test.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@modelcontextprotocol/sdk": "^1.10.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"tsx": "^4.19.0",
|
||||||
|
"@types/node": "^22.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
58
tools/relay/queue.test.ts
Normal file
58
tools/relay/queue.test.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, it, beforeEach } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { RelayQueue, isRole } from "./queue.ts";
|
||||||
|
|
||||||
|
describe("RelayQueue", () => {
|
||||||
|
let q: RelayQueue;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
q = new RelayQueue();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("post + read roundtrip returns the message with correct fields", () => {
|
||||||
|
q.post("dev-b", "pm", "status", "Task P4 DONE");
|
||||||
|
const msgs = q.read("pm");
|
||||||
|
assert.equal(msgs.length, 1);
|
||||||
|
assert.equal(msgs[0].from, "dev-b");
|
||||||
|
assert.equal(msgs[0].to, "pm");
|
||||||
|
assert.equal(msgs[0].kind, "status");
|
||||||
|
assert.equal(msgs[0].body, "Task P4 DONE");
|
||||||
|
assert.ok(typeof msgs[0].id === "string" && msgs[0].id.length > 0);
|
||||||
|
assert.ok(typeof msgs[0].ts === "string");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consume-once: second read returns empty", () => {
|
||||||
|
q.post("dev-a", "pm", "question", "Should I use approach A?");
|
||||||
|
q.read("pm");
|
||||||
|
const second = q.read("pm");
|
||||||
|
assert.deepEqual(second, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("list_pending does not drain inbox", () => {
|
||||||
|
q.post("dev-b", "pm", "directive", "PROCEED");
|
||||||
|
const before = q.pending("pm");
|
||||||
|
assert.equal(before.count, 1);
|
||||||
|
const after = q.read("pm");
|
||||||
|
assert.equal(after.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("FIFO ordering across multiple senders", () => {
|
||||||
|
q.post("dev-a", "pm", "status", "first");
|
||||||
|
q.post("dev-b", "pm", "status", "second");
|
||||||
|
q.post("dev-a", "pm", "question", "third");
|
||||||
|
const msgs = q.read("pm");
|
||||||
|
assert.equal(msgs.length, 3);
|
||||||
|
assert.equal(msgs[0].body, "first");
|
||||||
|
assert.equal(msgs[1].body, "second");
|
||||||
|
assert.equal(msgs[2].body, "third");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isRole rejects unknown strings", () => {
|
||||||
|
assert.ok(isRole("pm"));
|
||||||
|
assert.ok(isRole("dev-a"));
|
||||||
|
assert.ok(isRole("dev-b"));
|
||||||
|
assert.ok(!isRole("dev-c"));
|
||||||
|
assert.ok(!isRole(""));
|
||||||
|
assert.ok(!isRole("PM"));
|
||||||
|
});
|
||||||
|
});
|
||||||
55
tools/relay/queue.ts
Normal file
55
tools/relay/queue.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
|
||||||
|
export type Role = "pm" | "dev-a" | "dev-b";
|
||||||
|
export type MessageKind = "status" | "question" | "directive" | "free";
|
||||||
|
|
||||||
|
export interface RelayMessage {
|
||||||
|
id: string;
|
||||||
|
from: Role;
|
||||||
|
to: Role;
|
||||||
|
kind: MessageKind;
|
||||||
|
body: string;
|
||||||
|
ts: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_ROLES = new Set<string>(["pm", "dev-a", "dev-b"]);
|
||||||
|
|
||||||
|
export function isRole(s: string): s is Role {
|
||||||
|
return KNOWN_ROLES.has(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RelayQueue {
|
||||||
|
private readonly queues = new Map<Role, RelayMessage[]>([
|
||||||
|
["pm", []],
|
||||||
|
["dev-a", []],
|
||||||
|
["dev-b", []],
|
||||||
|
]);
|
||||||
|
|
||||||
|
post(from: Role, to: Role, kind: MessageKind, body: string): RelayMessage {
|
||||||
|
const msg: RelayMessage = {
|
||||||
|
id: randomUUID(),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
kind,
|
||||||
|
body,
|
||||||
|
ts: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
this.queues.get(to)!.push(msg);
|
||||||
|
return msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
read(forRole: Role): RelayMessage[] {
|
||||||
|
const inbox = this.queues.get(forRole)!;
|
||||||
|
const messages = [...inbox];
|
||||||
|
inbox.length = 0;
|
||||||
|
return messages;
|
||||||
|
}
|
||||||
|
|
||||||
|
pending(forRole: Role): { count: number; kinds: MessageKind[] } {
|
||||||
|
const inbox = this.queues.get(forRole)!;
|
||||||
|
return {
|
||||||
|
count: inbox.length,
|
||||||
|
kinds: inbox.map((m) => m.kind),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
159
tools/relay/server.ts
Normal file
159
tools/relay/server.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||||
|
import { SSEServerTransport } from "@modelcontextprotocol/sdk/server/sse.js";
|
||||||
|
import {
|
||||||
|
CallToolRequestSchema,
|
||||||
|
ListToolsRequestSchema,
|
||||||
|
} from "@modelcontextprotocol/sdk/types.js";
|
||||||
|
import http from "node:http";
|
||||||
|
import { RelayQueue, isRole } from "./queue.ts";
|
||||||
|
|
||||||
|
const PORT = 7331;
|
||||||
|
const queue = new RelayQueue();
|
||||||
|
|
||||||
|
const mcpServer = new Server(
|
||||||
|
{ name: "relay", version: "0.1.0" },
|
||||||
|
{ capabilities: { tools: {} } }
|
||||||
|
);
|
||||||
|
|
||||||
|
const TOOLS = [
|
||||||
|
{
|
||||||
|
name: "post_message",
|
||||||
|
description:
|
||||||
|
"Push a message to a recipient's inbox. Returns the assigned message id.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
from: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["pm", "dev-a", "dev-b"],
|
||||||
|
description: "Your role name",
|
||||||
|
},
|
||||||
|
to: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["pm", "dev-a", "dev-b"],
|
||||||
|
description: "Recipient role name",
|
||||||
|
},
|
||||||
|
kind: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["status", "question", "directive", "free"],
|
||||||
|
description: "Message type matching the coordination protocol",
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
type: "string",
|
||||||
|
description: "Message body — freeform markdown, typically the full formatted block",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["from", "to", "kind", "body"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "read_messages",
|
||||||
|
description:
|
||||||
|
"Pop and return all pending messages for this recipient. Inbox is empty after this call (consume-once).",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
for: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["pm", "dev-a", "dev-b"],
|
||||||
|
description: "Your role name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["for"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "list_pending",
|
||||||
|
description:
|
||||||
|
"Return count and kinds of pending messages without consuming them.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object" as const,
|
||||||
|
properties: {
|
||||||
|
for: {
|
||||||
|
type: "string",
|
||||||
|
enum: ["pm", "dev-a", "dev-b"],
|
||||||
|
description: "Your role name",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ["for"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
mcpServer.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
|
||||||
|
|
||||||
|
mcpServer.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||||
|
const { name, arguments: args } = request.params;
|
||||||
|
const a = args as Record<string, string>;
|
||||||
|
|
||||||
|
if (name === "post_message") {
|
||||||
|
if (!isRole(a.from)) {
|
||||||
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.from}"` }], isError: true };
|
||||||
|
}
|
||||||
|
if (!isRole(a.to)) {
|
||||||
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.to}"` }], isError: true };
|
||||||
|
}
|
||||||
|
const kind = a.kind as "status" | "question" | "directive" | "free";
|
||||||
|
const msg = queue.post(a.from, a.to, kind, a.body);
|
||||||
|
const ts = new Date(msg.ts).toTimeString().slice(0, 8);
|
||||||
|
const preview = a.body.slice(0, 60).replace(/\n/g, " ");
|
||||||
|
const ellipsis = a.body.length > 60 ? "..." : "";
|
||||||
|
process.stdout.write(`[${ts}] ${a.from} → ${a.to} [${kind}] "${preview}${ellipsis}"\n`);
|
||||||
|
return { content: [{ type: "text" as const, text: JSON.stringify({ id: msg.id }) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "read_messages") {
|
||||||
|
if (!isRole(a.for)) {
|
||||||
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
|
||||||
|
}
|
||||||
|
const messages = queue.read(a.for);
|
||||||
|
return { content: [{ type: "text" as const, text: JSON.stringify(messages) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "list_pending") {
|
||||||
|
if (!isRole(a.for)) {
|
||||||
|
return { content: [{ type: "text" as const, text: `Error: unknown role "${a.for}"` }], isError: true };
|
||||||
|
}
|
||||||
|
const result = queue.pending(a.for);
|
||||||
|
return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: [{ type: "text" as const, text: `Error: unknown tool "${name}"` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const transports = new Map<string, SSEServerTransport>();
|
||||||
|
|
||||||
|
const httpServer = http.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (req.method === "GET" && req.url === "/sse") {
|
||||||
|
const transport = new SSEServerTransport("/message", res);
|
||||||
|
transports.set(transport.sessionId, transport);
|
||||||
|
transport.onclose = () => transports.delete(transport.sessionId);
|
||||||
|
await mcpServer.connect(transport);
|
||||||
|
} else if (req.method === "POST" && req.url?.startsWith("/message")) {
|
||||||
|
const url = new URL(req.url, `http://127.0.0.1:${PORT}`);
|
||||||
|
const sessionId = url.searchParams.get("sessionId") ?? "";
|
||||||
|
const transport = transports.get(sessionId);
|
||||||
|
if (transport) {
|
||||||
|
await transport.handlePostMessage(req, res);
|
||||||
|
} else {
|
||||||
|
res.writeHead(404, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "session not found" }));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.writeHead(404).end("not found");
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[relay] error:", err);
|
||||||
|
if (!res.headersSent) res.writeHead(500).end(String(err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
httpServer.listen(PORT, "127.0.0.1", () => {
|
||||||
|
console.log(`[relay] server ready on :${PORT}`);
|
||||||
|
console.log(`[relay] tools: post_message, read_messages, list_pending`);
|
||||||
|
console.log(`[relay] waiting for connections — Ctrl-C to stop`);
|
||||||
|
});
|
||||||
99
tools/relay/start.sh
Executable file
99
tools/relay/start.sh
Executable file
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
REPO_ROOT="$(git -C "$SCRIPT_DIR" rev-parse --show-toplevel)"
|
||||||
|
PORT=7331
|
||||||
|
MODE="manual"
|
||||||
|
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--tmux) MODE="tmux" ;;
|
||||||
|
--kitty) MODE="kitty" ;;
|
||||||
|
--manual) MODE="manual" ;;
|
||||||
|
*) echo "Unknown option: $arg" >&2; echo "Usage: $0 [--manual|--tmux|--kitty]" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Port check
|
||||||
|
if lsof -ti:"$PORT" &>/dev/null; then
|
||||||
|
echo "Error: port $PORT is already in use."
|
||||||
|
echo "Relay already running? Kill it with: kill \$(lsof -ti:$PORT)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Install deps (no-op if node_modules current)
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
npm install --silent
|
||||||
|
|
||||||
|
# Discover latest coordination prompts for instructions
|
||||||
|
COORD_DIR="$REPO_ROOT/docs/superpowers/coordination"
|
||||||
|
PM_PROMPT="$(ls -t "$COORD_DIR"/*-pm-prompt.md 2>/dev/null | head -1 || echo "(none found — run multi-agent-kickoff skill first)")"
|
||||||
|
DEV_A_PROMPT="$(ls -t "$COORD_DIR"/*-dev-a-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
||||||
|
DEV_B_PROMPT="$(ls -t "$COORD_DIR"/*-dev-b-prompt.md 2>/dev/null | head -1 || echo "(none found)")"
|
||||||
|
|
||||||
|
print_manual_instructions() {
|
||||||
|
echo ""
|
||||||
|
echo "╔══════════════════════════════════════════════════════════════╗"
|
||||||
|
echo "║ RELAY SERVER — MULTI-AGENT LIFT LAUNCHER ║"
|
||||||
|
echo "╚══════════════════════════════════════════════════════════════╝"
|
||||||
|
echo ""
|
||||||
|
echo "Open 3 new terminals. In each, start Claude Code and paste"
|
||||||
|
echo "the content BELOW the '---' line from the corresponding file."
|
||||||
|
echo ""
|
||||||
|
echo " Terminal 1 (PM): cat '$PM_PROMPT'"
|
||||||
|
echo " Terminal 2 (Dev A): cat '$DEV_A_PROMPT'"
|
||||||
|
echo " Terminal 3 (Dev B): cat '$DEV_B_PROMPT'"
|
||||||
|
echo ""
|
||||||
|
echo "This terminal becomes the relay log. Keep it open."
|
||||||
|
echo ""
|
||||||
|
echo "══════════════════════════════════════════════════════════════"
|
||||||
|
}
|
||||||
|
|
||||||
|
launch_tmux() {
|
||||||
|
SESSION="relay-lift"
|
||||||
|
tmux new-session -d -s "$SESSION" -n "relay" \
|
||||||
|
"cd '$SCRIPT_DIR' && npx tsx server.ts"
|
||||||
|
tmux new-window -t "$SESSION:" -n "pm" "cd '$REPO_ROOT' && claude"
|
||||||
|
tmux new-window -t "$SESSION:" -n "dev-a" "cd '$REPO_ROOT' && claude"
|
||||||
|
tmux new-window -t "$SESSION:" -n "dev-b" "cd '$REPO_ROOT' && claude"
|
||||||
|
echo ""
|
||||||
|
echo "[relay] Opened tmux session '$SESSION' with 4 windows: relay, pm, dev-a, dev-b."
|
||||||
|
echo "[relay] Paste the kickoff prompt into each Claude window."
|
||||||
|
echo " Prompts:"
|
||||||
|
echo " PM: $PM_PROMPT"
|
||||||
|
echo " Dev A: $DEV_A_PROMPT"
|
||||||
|
echo " Dev B: $DEV_B_PROMPT"
|
||||||
|
echo ""
|
||||||
|
tmux attach-session -t "$SESSION"
|
||||||
|
}
|
||||||
|
|
||||||
|
launch_kitty() {
|
||||||
|
kitty @ launch --new-tab --tab-title "relay" -- \
|
||||||
|
bash -c "cd '$SCRIPT_DIR' && npx tsx server.ts"
|
||||||
|
kitty @ launch --new-window --window-title "PM" -- \
|
||||||
|
bash -c "cd '$REPO_ROOT' && claude"
|
||||||
|
kitty @ launch --new-window --window-title "Dev-A" -- \
|
||||||
|
bash -c "cd '$REPO_ROOT' && claude"
|
||||||
|
kitty @ launch --new-window --window-title "Dev-B" -- \
|
||||||
|
bash -c "cd '$REPO_ROOT' && claude"
|
||||||
|
echo ""
|
||||||
|
echo "[relay] Opened kitty tab 'relay' + 3 windows (PM, Dev-A, Dev-B)."
|
||||||
|
echo " Paste the kickoff prompts into each Claude window."
|
||||||
|
echo " PM: $PM_PROMPT"
|
||||||
|
echo " Dev A: $DEV_A_PROMPT"
|
||||||
|
echo " Dev B: $DEV_B_PROMPT"
|
||||||
|
}
|
||||||
|
|
||||||
|
case "$MODE" in
|
||||||
|
manual)
|
||||||
|
print_manual_instructions
|
||||||
|
exec npx tsx "$SCRIPT_DIR/server.ts"
|
||||||
|
;;
|
||||||
|
tmux)
|
||||||
|
launch_tmux
|
||||||
|
;;
|
||||||
|
kitty)
|
||||||
|
launch_kitty
|
||||||
|
;;
|
||||||
|
esac
|
||||||
10
tools/relay/tsconfig.json
Normal file
10
tools/relay/tsconfig.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"module": "NodeNext",
|
||||||
|
"moduleResolution": "NodeNext",
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["*.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user