feat: RFC 0060 reliable binary installation
- install.sh: Add xattr/codesign fix after cp on macOS - install.sh: Add portable timeout verification using perl - INSTALL.md: Recommend cargo install --path as primary method - INSTALL.md: Document macOS signature issue and fix - blue doctor: Detect com.apple.provenance xattr with fix hint - blue doctor: Verify code signature with codesign --verify - blue doctor: 3-second liveness timeout for hanging binaries Fixes dyld hang at _dyld_start when copied binaries have stale signatures after Homebrew library updates. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
b0e533849c
commit
c745d11870
4 changed files with 355 additions and 100 deletions
135
.blue/docs/rfcs/0060-reliable-binary-installation.accepted.md
Normal file
135
.blue/docs/rfcs/0060-reliable-binary-installation.accepted.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
# RFC 0060: Reliable Binary Installation
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Status** | Accepted |
|
||||
| **Date** | 2026-02-06 |
|
||||
| **Relates To** | RFC 0052 (CLI Hook Management), RFC 0049 (Synchronous Guard) |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
Fix the binary installation flow so it doesn't produce binaries that hang on macOS. Add `cargo install --path` support as the primary install method, and post-copy re-signing as a fallback safety net.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### The Bug
|
||||
|
||||
When the Blue binary is copied to `~/.cargo/bin/` (or `/usr/local/bin/`), macOS preserves stale extended attributes (`com.apple.provenance`) and the adhoc code signature from the original build. If Homebrew updates a dynamically linked library (e.g., `openssl@3`) between when the binary was built and when it's run, `dyld` hangs indefinitely at `_dyld_start` during signature verification. The process never reaches `main()`.
|
||||
|
||||
### Observed Behavior
|
||||
|
||||
```
|
||||
$ blue init
|
||||
[hangs forever — no output, 0 bytes written to stdout/stderr]
|
||||
|
||||
$ sample $PID
|
||||
890 _dyld_start (in dyld) + 0 # 100% of samples stuck here
|
||||
```
|
||||
|
||||
- `kill -9` cannot terminate the process (state: `UE` — uninterruptible + exiting)
|
||||
- The same binary works fine when run from `target/release/blue` directly
|
||||
- The same binary works fine when copied to `/tmp/` without extended attributes
|
||||
|
||||
### Root Cause
|
||||
|
||||
`cp` on macOS preserves extended attributes by default. The combination of:
|
||||
1. `com.apple.provenance` xattr (marks binary as "downloaded/copied")
|
||||
2. Stale adhoc linker signature from original `cargo build`
|
||||
3. Updated dylib versions on disk (Homebrew `openssl@3`)
|
||||
|
||||
...causes `dyld` to enter a signature verification path that deadlocks.
|
||||
|
||||
### Evidence
|
||||
|
||||
| Test | Result |
|
||||
|------|--------|
|
||||
| `target/release/blue init` | Works (0.1s) |
|
||||
| `cp` to `~/.cargo/bin/blue` then run | Hangs at `_dyld_start` |
|
||||
| `cp` to `/tmp/blue-copy` then run | Works (no provenance xattr) |
|
||||
| Symlink to `target/release/blue` | Works |
|
||||
| `xattr -cr` + `codesign --force --sign -` | Works |
|
||||
|
||||
## Proposal
|
||||
|
||||
### 1. Support `cargo install` as primary method
|
||||
|
||||
Add workspace metadata so `cargo install --path apps/blue-cli` works correctly. This lets Cargo handle the build-and-install atomically, producing a freshly signed binary.
|
||||
|
||||
**INSTALL.md** becomes:
|
||||
```bash
|
||||
# Build and install (recommended)
|
||||
cargo install --path apps/blue-cli
|
||||
|
||||
# Then configure for Claude Code
|
||||
blue install
|
||||
```
|
||||
|
||||
### 2. Post-copy re-signing in `install.sh`
|
||||
|
||||
For users who prefer `install.sh` or `cp`:
|
||||
|
||||
```bash
|
||||
cp "$BINARY" "$INSTALL_DIR/blue"
|
||||
# Fix macOS code signature after copy
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
xattr -cr "$INSTALL_DIR/blue"
|
||||
codesign --force --sign - "$INSTALL_DIR/blue"
|
||||
fi
|
||||
```
|
||||
|
||||
### 3. Post-copy re-signing in `blue install` (Rust)
|
||||
|
||||
The `handle_install_command` currently doesn't copy the binary anywhere — it only sets up hooks, skills, and MCP config. But the `SessionStart` hook adds `target/release/` to PATH, which is fragile. Instead:
|
||||
|
||||
- Add an optional `--binary` flag to `blue install` that copies and re-signs the binary to `~/.cargo/bin/`
|
||||
- Or detect if running from `target/release/` and warn the user
|
||||
|
||||
### 4. `blue doctor` validation
|
||||
|
||||
Add a check to `blue doctor` that detects:
|
||||
- Stale code signatures on the installed binary
|
||||
- Mismatched binary vs source versions
|
||||
- Presence of `com.apple.provenance` xattr on the binary
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: Fix `install.sh` (immediate)
|
||||
|
||||
1. Add `xattr -cr` + `codesign --force --sign -` after every `cp` of the binary
|
||||
2. Add a verification step that actually runs `blue --version` with a timeout
|
||||
|
||||
### Phase 2: Support `cargo install --path`
|
||||
|
||||
1. Verify `cargo install --path apps/blue-cli` works with the current workspace layout
|
||||
2. Update `INSTALL.md` to recommend `cargo install` as the primary method
|
||||
3. Update `install.sh` to use `cargo install` instead of `cp` when possible
|
||||
|
||||
### Phase 3: Harden `blue doctor`
|
||||
|
||||
1. Add macOS signature check: `codesign --verify` on the installed binary
|
||||
2. Add xattr check: warn if `com.apple.provenance` is present
|
||||
3. Add timeout-based liveness check: run `blue --version` with a 3s timeout
|
||||
|
||||
## Files Changed
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `install.sh` | Add post-copy re-signing for macOS |
|
||||
| `apps/blue-cli/src/main.rs` | Add doctor checks for signature issues |
|
||||
| `INSTALL.md` | Recommend `cargo install --path` as primary method |
|
||||
|
||||
## Test Plan
|
||||
|
||||
- [x] `install.sh` produces a working binary on macOS after Homebrew openssl update
|
||||
- [x] `cargo install --path apps/blue-cli` produces a working binary
|
||||
- [x] `blue doctor` detects provenance xattr on binary (warns with fix hint)
|
||||
- [x] `blue doctor` passes on a freshly installed binary
|
||||
- [x] Install flow works on Linux (no-op for codesign steps via `#[cfg(target_os = "macos")]`)
|
||||
|
||||
---
|
||||
|
||||
*"Right then. Let's get to it."*
|
||||
|
||||
-- Blue
|
||||
193
INSTALL.md
193
INSTALL.md
|
|
@ -1,144 +1,151 @@
|
|||
# Installing Blue
|
||||
|
||||
## Quick Install
|
||||
## Quick Install (Recommended)
|
||||
|
||||
```bash
|
||||
./install.sh
|
||||
```
|
||||
# Build and install Blue (handles code signing correctly)
|
||||
cargo install --path apps/blue-cli
|
||||
|
||||
This builds and installs both:
|
||||
- **Blue CLI** to `/usr/local/bin/blue`
|
||||
- **Blue MCP** configured for Claude Code
|
||||
# Configure for Claude Code
|
||||
blue install
|
||||
```
|
||||
|
||||
Restart Claude Code after installation.
|
||||
|
||||
## Alternative: Build Then Install
|
||||
|
||||
If you prefer to build separately:
|
||||
|
||||
```bash
|
||||
# Build Blue
|
||||
cargo build --release
|
||||
|
||||
# Install for Claude Code (from build directory)
|
||||
./target/release/blue install
|
||||
```
|
||||
|
||||
Note: Running from `target/release/` works but the binary path may change after `cargo clean`. For a persistent installation, use `cargo install` above.
|
||||
|
||||
## What Gets Installed
|
||||
|
||||
### CLI
|
||||
`blue install` configures everything for Claude Code:
|
||||
|
||||
The `blue` command becomes available system-wide:
|
||||
| Component | Location | Purpose |
|
||||
|-----------|----------|---------|
|
||||
| **Hooks** | `.claude/hooks/` | Session lifecycle, write guards |
|
||||
| **Settings** | `.claude/settings.json` | Project configuration |
|
||||
| **Skills** | `~/.claude/skills/` | Alignment dialogues |
|
||||
| **MCP Server** | `~/.claude.json` | Blue tools for Claude |
|
||||
|
||||
### Hooks
|
||||
|
||||
- `session-start.sh` — Injects Blue context at session start
|
||||
- `guard-write.sh` — Validates file writes (RFC 0038)
|
||||
|
||||
### Skills
|
||||
|
||||
- `alignment-play` — Run multi-expert alignment dialogues
|
||||
- `alignment-expert` — Marker syntax for expert agents
|
||||
|
||||
### MCP Tools
|
||||
|
||||
After restart, Claude has access to Blue tools:
|
||||
- `blue_status`, `blue_next` — Project state
|
||||
- `blue_rfc_*` — RFC management
|
||||
- `blue_worktree_*` — Git worktree coordination
|
||||
- `blue_pr_create` — Pull request creation
|
||||
- `blue_dialogue_*` — Alignment dialogues
|
||||
|
||||
## System-Wide Install
|
||||
|
||||
To make `blue` available everywhere:
|
||||
|
||||
```bash
|
||||
blue --version # Check installation
|
||||
blue realm status # Realm commands
|
||||
blue session start # Session management
|
||||
blue daemon start # Background service
|
||||
# Recommended: cargo install handles signing correctly
|
||||
cargo install --path apps/blue-cli
|
||||
|
||||
# The binary is installed to ~/.cargo/bin/blue
|
||||
# Ensure ~/.cargo/bin is in your PATH
|
||||
```
|
||||
|
||||
### MCP Server
|
||||
### Manual Copy (Not Recommended on macOS)
|
||||
|
||||
Claude Code configuration is created/updated at `~/.config/claude-code/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"blue": {
|
||||
"command": "blue",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
After restart, Claude has access to 8 realm tools:
|
||||
- `realm_status`, `realm_check`, `contract_get`
|
||||
- `session_start`, `session_stop`
|
||||
- `realm_worktree_create`, `realm_pr_status`
|
||||
- `notifications_list`
|
||||
|
||||
## Manual Install
|
||||
|
||||
### Build
|
||||
If you must copy the binary manually on macOS, you need to fix the code signature:
|
||||
|
||||
```bash
|
||||
# Build
|
||||
cargo build --release
|
||||
```
|
||||
|
||||
### Install CLI
|
||||
|
||||
```bash
|
||||
# Standard location
|
||||
# Copy and fix signature
|
||||
sudo cp target/release/blue /usr/local/bin/
|
||||
|
||||
# Or custom location
|
||||
cp target/release/blue ~/bin/
|
||||
sudo xattr -cr /usr/local/bin/blue
|
||||
sudo codesign --force --sign - /usr/local/bin/blue
|
||||
```
|
||||
|
||||
### Configure MCP
|
||||
|
||||
Create `~/.config/claude-code/mcp.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"blue": {
|
||||
"command": "blue",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If blue isn't in PATH, use the full path:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"blue": {
|
||||
"command": "/path/to/blue",
|
||||
"args": ["mcp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Install Location
|
||||
|
||||
```bash
|
||||
INSTALL_DIR=~/bin ./install.sh
|
||||
```
|
||||
Without the signature fix, the binary may hang at startup (see RFC 0060).
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
# Remove CLI
|
||||
sudo rm /usr/local/bin/blue
|
||||
blue uninstall
|
||||
```
|
||||
|
||||
# Remove MCP config (or edit to remove blue entry)
|
||||
rm ~/.config/claude-code/mcp.json
|
||||
Or manually:
|
||||
|
||||
```bash
|
||||
# Remove hooks
|
||||
rm -rf .claude/hooks/
|
||||
|
||||
# Remove MCP config
|
||||
# Edit ~/.claude.json and remove "blue" entry
|
||||
|
||||
# Remove skills
|
||||
rm ~/.claude/skills/alignment-play
|
||||
rm ~/.claude/skills/alignment-expert
|
||||
|
||||
# Remove binary (if using cargo install)
|
||||
cargo uninstall blue
|
||||
|
||||
# Remove Blue data (optional)
|
||||
rm -rf ~/.blue
|
||||
rm -rf .blue/
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Rust toolchain (cargo)
|
||||
- macOS, Linux, or Windows with WSL
|
||||
- Claude Code (for MCP features)
|
||||
- Claude Code
|
||||
|
||||
## Verify Installation
|
||||
|
||||
```bash
|
||||
# CLI
|
||||
blue --version
|
||||
# Check installation health
|
||||
blue doctor
|
||||
```
|
||||
|
||||
# MCP (in Claude Code)
|
||||
Human: What realm tools do you have?
|
||||
Claude: I have realm_status, realm_check, contract_get...
|
||||
Or in Claude Code:
|
||||
```
|
||||
Human: blue status
|
||||
Claude: [calls blue_status] Project: blue, Branch: develop...
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"command not found: blue"**
|
||||
- Ensure `/usr/local/bin` is in your PATH
|
||||
- Or use `INSTALL_DIR=~/bin ./install.sh` and add `~/bin` to PATH
|
||||
- Use `cargo install --path apps/blue-cli` (adds to ~/.cargo/bin)
|
||||
- Or run from the project directory: `./target/release/blue install`
|
||||
- Ensure `~/.cargo/bin` is in your PATH
|
||||
|
||||
**Binary hangs on macOS (no output)**
|
||||
- This is a code signature issue (RFC 0060)
|
||||
- Fix: `xattr -cr $(which blue) && codesign --force --sign - $(which blue)`
|
||||
- Or reinstall with: `cargo install --path apps/blue-cli --force`
|
||||
|
||||
**MCP tools not appearing in Claude**
|
||||
- Restart Claude Code after installation
|
||||
- Check `~/.config/claude-code/mcp.json` syntax
|
||||
- Run `blue doctor` to check configuration
|
||||
- Verify `blue mcp` runs without errors
|
||||
|
||||
**Permission denied**
|
||||
- The installer uses sudo for `/usr/local/bin`
|
||||
- Or install to a user directory: `INSTALL_DIR=~/bin ./install.sh`
|
||||
**Hooks not firing**
|
||||
- Check `.claude/hooks/` exists with executable scripts
|
||||
- Run `blue install --force` to regenerate hooks
|
||||
|
|
|
|||
|
|
@ -3270,8 +3270,85 @@ async fn handle_doctor_command() -> Result<()> {
|
|||
|
||||
// Check binary
|
||||
println!("Binary:");
|
||||
if let Ok(path) = which::which("blue") {
|
||||
let binary_path = which::which("blue").ok();
|
||||
if let Some(ref path) = binary_path {
|
||||
println!(" ✓ blue found at {}", path.display());
|
||||
|
||||
// RFC 0060: macOS-specific signature and liveness checks
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Check for stale provenance xattr
|
||||
let xattr_output = std::process::Command::new("xattr")
|
||||
.arg("-l")
|
||||
.arg(path)
|
||||
.output();
|
||||
|
||||
if let Ok(output) = xattr_output {
|
||||
let attrs = String::from_utf8_lossy(&output.stdout);
|
||||
if attrs.contains("com.apple.provenance") {
|
||||
println!(" ⚠ com.apple.provenance xattr present (may cause hangs)");
|
||||
println!(" hint: xattr -cr {} && codesign --force --sign - {}",
|
||||
path.display(), path.display());
|
||||
issues += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Check code signature validity
|
||||
let codesign_output = std::process::Command::new("codesign")
|
||||
.args(["--verify", "--verbose"])
|
||||
.arg(path)
|
||||
.output();
|
||||
|
||||
if let Ok(output) = codesign_output {
|
||||
if output.status.success() {
|
||||
println!(" ✓ Code signature valid");
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("invalid signature") || stderr.contains("modified") {
|
||||
println!(" ✗ Code signature invalid or stale");
|
||||
println!(" hint: codesign --force --sign - {}", path.display());
|
||||
issues += 1;
|
||||
} else if stderr.contains("not signed") {
|
||||
println!(" - Binary not signed (may be fine)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Liveness check with timeout
|
||||
use std::time::Duration;
|
||||
let liveness = std::process::Command::new(path)
|
||||
.arg("--version")
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.and_then(|mut child| {
|
||||
// Wait up to 3 seconds
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => return Ok(status.success()),
|
||||
Ok(None) => {
|
||||
if start.elapsed() > Duration::from_secs(3) {
|
||||
let _ = child.kill();
|
||||
return Ok(false);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
match liveness {
|
||||
Ok(true) => println!(" ✓ Binary responds within timeout"),
|
||||
Ok(false) => {
|
||||
println!(" ✗ Binary hangs (dyld signature issue)");
|
||||
println!(" hint: cargo install --path apps/blue-cli --force");
|
||||
issues += 1;
|
||||
}
|
||||
Err(e) => println!(" ⚠ Could not run liveness check: {}", e),
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!(" ✗ blue not found in PATH");
|
||||
issues += 1;
|
||||
|
|
|
|||
48
install.sh
48
install.sh
|
|
@ -30,13 +30,49 @@ else
|
|||
sudo cp "$BINARY" "$INSTALL_DIR/blue"
|
||||
fi
|
||||
|
||||
# Verify installation
|
||||
if command -v blue &> /dev/null; then
|
||||
echo -e "${GREEN}Installed successfully${NC}"
|
||||
echo ""
|
||||
blue --version 2>/dev/null || blue help 2>/dev/null | head -1 || echo "blue installed to $INSTALL_DIR/blue"
|
||||
# RFC 0060: Fix macOS code signature after copy
|
||||
# cp preserves stale xattrs and adhoc signatures, causing dyld hangs
|
||||
if [[ "$OSTYPE" == "darwin"* ]]; then
|
||||
echo "Fixing macOS code signature..."
|
||||
if [ -w "$INSTALL_DIR/blue" ]; then
|
||||
xattr -cr "$INSTALL_DIR/blue" 2>/dev/null || true
|
||||
codesign --force --sign - "$INSTALL_DIR/blue" 2>/dev/null || true
|
||||
else
|
||||
echo -e "${GREEN}Installed to $INSTALL_DIR/blue${NC}"
|
||||
sudo xattr -cr "$INSTALL_DIR/blue" 2>/dev/null || true
|
||||
sudo codesign --force --sign - "$INSTALL_DIR/blue" 2>/dev/null || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# Verify installation with timeout (RFC 0060)
|
||||
# A hanging binary won't respond in 3 seconds
|
||||
# Note: Using perl for portable timeout (works on macOS and Linux)
|
||||
run_with_timeout() {
|
||||
local timeout_sec=$1
|
||||
shift
|
||||
perl -e 'alarm shift; exec @ARGV' "$timeout_sec" "$@" 2>/dev/null
|
||||
}
|
||||
|
||||
echo "Verifying installation..."
|
||||
BLUE_PATH="$INSTALL_DIR/blue"
|
||||
if [ -x "$BLUE_PATH" ]; then
|
||||
if run_with_timeout 3 "$BLUE_PATH" --version >/dev/null 2>&1; then
|
||||
echo -e "${GREEN}Installed successfully${NC}"
|
||||
"$BLUE_PATH" --version 2>/dev/null || echo "blue installed to $BLUE_PATH"
|
||||
else
|
||||
echo -e "${RED}Binary installed but failed verification (possible signing issue)${NC}"
|
||||
echo "Try: xattr -cr $BLUE_PATH && codesign --force --sign - $BLUE_PATH"
|
||||
exit 1
|
||||
fi
|
||||
elif command -v blue &> /dev/null; then
|
||||
if run_with_timeout 3 blue --version >/dev/null 2>&1; then
|
||||
echo -e "${GREEN}Installed successfully${NC}"
|
||||
blue --version 2>/dev/null || echo "blue available in PATH"
|
||||
else
|
||||
echo -e "${RED}Binary in PATH but failed verification${NC}"
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo -e "${GREEN}Installed to $BLUE_PATH${NC}"
|
||||
echo "Add $INSTALL_DIR to PATH if not already present"
|
||||
fi
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue