From b2dbaa1957d593a1ea486c44664119bbab5b7808 Mon Sep 17 00:00:00 2001 From: shpark Date: Tue, 9 Jun 2026 17:22:57 +0000 Subject: [PATCH 01/20] fix(claude): use proper namespace for tool (mcp__plugin____) --- commands/claude/activate.md | 18 +++++++++--------- commands/claude/capture.md | 2 +- commands/claude/configure.md | 26 +++++++++++++------------- commands/claude/deactivate.md | 4 ++-- commands/claude/history.md | 2 +- commands/claude/recall.md | 2 +- commands/claude/status.md | 2 +- 7 files changed, 28 insertions(+), 28 deletions(-) diff --git a/commands/claude/activate.md b/commands/claude/activate.md index b9ec654..1e63822 100644 --- a/commands/claude/activate.md +++ b/commands/claude/activate.md @@ -1,6 +1,6 @@ --- description: Activate Rune (resume from dormant) and verify pipelines come up healthy -allowed-tools: Bash(~/.rune/bin/rune install:*), Bash(${CLAUDE_PLUGIN_ROOT}/bin/rune install:*), mcp__rune__activate, mcp__rune__diagnostics, mcp__rune__vault_status +allowed-tools: Bash(~/.rune/bin/rune install:*), Bash(${CLAUDE_PLUGIN_ROOT}/bin/rune install:*), mcp__plugin_rune_rune__activate, mcp__plugin_rune_rune__diagnostics, mcp__plugin_rune_rune__vault_status --- # /rune:activate — Activate Plugin @@ -10,18 +10,18 @@ Resume Rune from a dormant state and verify the boot loop reaches Active. The MCP server is a Go binary at `~/.rune/bin/rune-mcp`, spawned by Claude Code via the plugin manifest's committed wrapper `${CLAUDE_PLUGIN_ROOT}/bin/rune mcp-server` (always present at session start; self-installs rune-mcp on first -run). `mcp__rune__activate` runs the prereq checks server-side (config presence +run). `mcp__plugin_rune_rune__activate` runs the prereq checks server-side (config presence + runed socket reachability, plus a runed Health probe) and only triggers the boot loop if everything's ready. ## Preflight: the first MCP call self-installs -On a fresh `claude plugin install rune`, the first `mcp__rune__*` call spawns +On a fresh `claude plugin install rune`, the first `mcp__plugin_rune_rune__*` call spawns `${CLAUDE_PLUGIN_ROOT}/bin/rune mcp-server`, which self-installs rune-mcp and then serves — so the call is EXPECTED to succeed in-session (may be slow on a cold download, bounded by the manifest's spawn timeout; that is normal). -You normally do NOT need to run anything here. ONLY if a `mcp__rune__*` call +You normally do NOT need to run anything here. ONLY if a `mcp__plugin_rune_rune__*` call actually fails with a transport / connection / spawn error (e.g. the server shows failed in `/mcp`) — a genuinely broken bootstrap — recover by running ONE of these via the Bash tool, then retry the failed MCP call once: @@ -36,7 +36,7 @@ error and stop — do NOT loop. The user never types `rune install` themselves. ## Steps -### 1. Call `mcp__rune__activate` +### 1. Call `mcp__plugin_rune_rune__activate` That's it - no Read, no Edit, no manual state inspection. The MCP tool performs: @@ -75,7 +75,7 @@ The response shape: **`configure_required`** - Vault credentials missing. - Render: `"Rune is not yet configured. Run /rune:configure to set Vault credentials."` - Use the `hint` verbatim - it already names the exact next step. -- Stop. Do NOT call `mcp__rune__diagnostics`; the agent already has the answer. +- Stop. Do NOT call `mcp__plugin_rune_rune__diagnostics`; the agent already has the answer. **`install_pending`** - the activate handler tried to auto-spawn the runed daemon (via `${HOME}/.rune/bin/rune runed --detach` or the @@ -103,7 +103,7 @@ it can serve embeddings. already started a background watcher that polls runed's health and will complete activation. Suggest: - - To check progress (optional): invoke `/rune:diagnostics` + - To check progress (optional): invoke `/rune:status` and check `embedding.phase` / `embedding.bytes_done` / `embedding.bytes_total`. The bootstrap fields are populated there while runed is `LOADING`; once they go to zero and `embedding.status` @@ -114,7 +114,7 @@ it can serve embeddings. - Stop. DO NOT poll automatically; DO NOT re-run `/rune:activate`. **`active`** — happy path. Pipelines initialized, ready to capture/recall. -- Optionally call `mcp__rune__diagnostics` ONCE to render the +- Optionally call `mcp__plugin_rune_rune__diagnostics` ONCE to render the per-subsystem summary below. Skip if you only need to confirm activation; `response.reload.state == "active"` is authoritative. @@ -130,7 +130,7 @@ it can serve embeddings. ### 3. Render success snapshot (active path only) -When `status == "active"`, call `mcp__rune__diagnostics` once and +When `status == "active"`, call `mcp__plugin_rune_rune__diagnostics` once and render the per-subsystem report (use ✓ for healthy, ✗ for failures with the specific error on the same line): diff --git a/commands/claude/capture.md b/commands/claude/capture.md index 07309b9..9d79e7e 100644 --- a/commands/claude/capture.md +++ b/commands/claude/capture.md @@ -1,6 +1,6 @@ --- description: Capture organizational context to encrypted memory -allowed-tools: Bash(cat ~/.rune/*), Read, mcp__rune__* +allowed-tools: Bash(cat ~/.rune/*), Read, mcp__plugin_rune_rune__* --- # /rune:capture — Store Context diff --git a/commands/claude/configure.md b/commands/claude/configure.md index 5b55cdf..b811356 100644 --- a/commands/claude/configure.md +++ b/commands/claude/configure.md @@ -1,13 +1,13 @@ --- description: Configure Rune — collect Vault credentials and write ~/.rune/config.json -allowed-tools: Bash(cp:*), Bash(~/.rune/bin/rune install:*), Bash(${CLAUDE_PLUGIN_ROOT}/bin/rune install:*), Read, AskUserQuestion, mcp__rune__configure, mcp__rune__activate, mcp__rune__diagnostics +allowed-tools: Bash(cp:*), Bash(~/.rune/bin/rune install:*), Bash(${CLAUDE_PLUGIN_ROOT}/bin/rune install:*), Read, AskUserQuestion, mcp__plugin_rune_rune__configure, mcp__plugin_rune_rune__activate, mcp__plugin_rune_rune__diagnostics --- # /rune:configure — Setup & Configuration Single entry after `claude plugin install rune`. Collects Vault credentials, -calls `mcp__rune__configure` (atomic 0600 write + soft Vault probe), and -hands off to `mcp__rune__activate` to bring pipelines online. +calls `mcp__plugin_rune_rune__configure` (atomic 0600 write + soft Vault probe), and +hands off to `mcp__plugin_rune_rune__activate` to bring pipelines online. The MCP server is a Go binary at `~/.rune/bin/rune-mcp`. The plugin manifest spawns it via the committed bash wrapper `${CLAUDE_PLUGIN_ROOT}/bin/rune @@ -17,13 +17,13 @@ server comes online in the SAME session, with no restart. ## Preflight: the first MCP call self-installs -On a fresh `claude plugin install rune`, the first `mcp__rune__*` call spawns +On a fresh `claude plugin install rune`, the first `mcp__plugin_rune_rune__*` call spawns `${CLAUDE_PLUGIN_ROOT}/bin/rune mcp-server`, which self-installs rune-mcp (downloading the CLI + rune-mcp if needed) and then serves — so the call is EXPECTED to succeed in-session. On a cold download it may be slow (bounded by the manifest's spawn timeout); that is normal, not an error. -You normally do NOT need to run anything here. ONLY if a `mcp__rune__*` call +You normally do NOT need to run anything here. ONLY if a `mcp__plugin_rune_rune__*` call actually fails with a transport / connection / spawn error (e.g. the server shows failed in `/mcp`) — a genuinely broken bootstrap — recover by running ONE of these via the Bash tool, then retry the failed MCP call once: @@ -47,10 +47,10 @@ If $ARGUMENTS contains any of: `--vault-token`, `--vault-endpoint`: 2. Merge the partial update into the existing values: - `--vault-token `: use as the new `token`, keep existing `endpoint`/`ca_cert`/`tls_disable`. - `--vault-endpoint `: auto-prepend `tcp://` if no scheme, keep existing `token`/`ca_cert`/`tls_disable`. -3. Call `mcp__rune__configure` with the merged values. Server-side +3. Call `mcp__plugin_rune_rune__configure` with the merged values. Server-side handles atomic write + 0600 perms + `metadata.lastUpdated` refresh + the soft Vault probe. -4. Call `mcp__rune__activate` to apply. +4. Call `mcp__plugin_rune_rune__activate` to apply. 5. Render: `"Updated [field]. Use /rune:status to verify."` Skip all steps below. @@ -71,7 +71,7 @@ to do so. - File present: mask the token (first 8 chars + "***") and show the current `endpoint`, `ca_cert`, `tls_disable`, `state`, masked token. Then issue a single `AskUserQuestion("Reconfigure these values?")`: - - User declines: call `mcp__rune__activate` and stop (just bring + - User declines: call `mcp__plugin_rune_rune__activate` and stop (just bring the existing config online). - User confirms: continue to Step 2 with the existing values as defaults the user can override. @@ -108,7 +108,7 @@ Otherwise skip the follow-up. **On any "I don't have it yet" answer**: stop the flow immediately. Tell the user exactly what to request from their Vault admin (endpoint / `evt_...` token / `ca.pem`), -point them at `setup/check-prerequisites.md`, and exit **without writing any files** - do not call `mcp__rune__configure`. +point them at `setup/check-prerequisites.md`, and exit **without writing any files** - do not call `mcp__plugin_rune_rune__configure`. Resulting argument mapping for the configure call: @@ -132,7 +132,7 @@ If `cp` fails (file not found / permission denied), surface the error and ask the user for a readable path (one more `AskUserQuestion`). Common recovery: `mkdir -p ~/.rune/certs && sudo cp /opt/runevault/certs/ca.pem ~/.rune/certs/ca.pem && sudo chown $USER ~/.rune/certs/ca.pem`. -### 4. Call `mcp__rune__configure` +### 4. Call `mcp__plugin_rune_rune__configure` ```jsonc { @@ -166,13 +166,13 @@ Response: ### 5. Decide what to do next based on the probe **`vault_reachable: true`** - credentials look good. Call -`mcp__rune__activate` to bring pipelines up. Proceed to Step 6. +`mcp__plugin_rune_rune__activate` to bring pipelines up. Proceed to Step 6. **`vault_reachable: false`** - early warning. The file IS written and `state` IS active, but the probe couldn't dial Vault. Two ways to proceed: - **Common case (transient / first-time):** still call - `mcp__rune__activate`. The boot loop has retries with backoff, + `mcp__plugin_rune_rune__activate`. The boot loop has retries with backoff, and the classified `last_boot_error` it produces will be richer than the probe error. - **Obvious typo case** (`probe_error` contains "no such host" / @@ -215,7 +215,7 @@ classifier has already done that work server-side. ### 7. Completion Summary (success path) When `activate.status == "active"`, optionally call -`mcp__rune__diagnostics` once for the rich per-subsystem snapshot and +`mcp__plugin_rune_rune__diagnostics` once for the rich per-subsystem snapshot and render: ``` diff --git a/commands/claude/deactivate.md b/commands/claude/deactivate.md index 6ae1cb4..75c1787 100644 --- a/commands/claude/deactivate.md +++ b/commands/claude/deactivate.md @@ -1,6 +1,6 @@ --- description: Deactivate Rune to pause organizational memory without clearing configuration -allowed-tools: Read, Edit, mcp__rune__reload_pipelines +allowed-tools: Read, Edit, mcp__plugin_rune_rune__reload_pipelines --- # /rune:deactivate — Deactivate Plugin @@ -20,7 +20,7 @@ Switch from active to dormant state. Configuration is preserved — use `/rune:a - Set `dormant_reason` to `"user_deactivated"` - Set `dormant_since` to current timestamp (e.g., `"2026-03-26T10:00:00Z"`) -4. Call `reload_pipelines` as a **native MCP tool** (`mcp__rune__reload_pipelines`) — invoke it directly like any other tool, do NOT use `claude mcp call` via Bash (that subcommand doesn't exist). +4. Call `reload_pipelines` as a **native MCP tool** (`mcp__plugin_rune_rune__reload_pipelines`) — invoke it directly like any other tool, do NOT use `claude mcp call` via Bash (that subcommand doesn't exist). - This ensures MCP tools (`capture`/`recall`) immediately return errors instead of processing. 5. Respond: "Rune deactivated. Organizational memory is paused. Config preserved — `/rune:activate` to resume." diff --git a/commands/claude/history.md b/commands/claude/history.md index e6c8746..cd69e11 100644 --- a/commands/claude/history.md +++ b/commands/claude/history.md @@ -1,6 +1,6 @@ --- description: View recent capture history -allowed-tools: mcp__rune__capture_history +allowed-tools: mcp__plugin_rune_rune__capture_history --- # /rune:history — View Capture History diff --git a/commands/claude/recall.md b/commands/claude/recall.md index f46b64a..20d9f9d 100644 --- a/commands/claude/recall.md +++ b/commands/claude/recall.md @@ -1,6 +1,6 @@ --- description: Search organizational memory for past decisions and context -allowed-tools: Bash(cat ~/.rune/*), Read, mcp__rune__* +allowed-tools: Bash(cat ~/.rune/*), Read, mcp__plugin_rune_rune__* --- # /rune:recall — Search Memory diff --git a/commands/claude/status.md b/commands/claude/status.md index 1f76319..a536b06 100644 --- a/commands/claude/status.md +++ b/commands/claude/status.md @@ -1,6 +1,6 @@ --- description: Check Rune plugin activation status and infrastructure health -allowed-tools: Bash(cat ~/.rune/*), Bash(ls:*), Read, mcp__rune__diagnostics, mcp__rune__vault_status +allowed-tools: Bash(cat ~/.rune/*), Bash(ls:*), Read, mcp__plugin_rune_rune__diagnostics, mcp__plugin_rune_rune__vault_status --- # /rune:status — Plugin Status From 48947d9e6d2f7e3ad8b6f2320df5cd86a7925616 Mon Sep 17 00:00:00 2001 From: shpark Date: Tue, 9 Jun 2026 17:30:11 +0000 Subject: [PATCH 02/20] docs(skill): update activate/configure MCP workflow Co-Authored-By: Claude Sonnet 4.6 --- SKILL.md | 54 +++++++++++++++++++++++++++++++++--------------------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/SKILL.md b/SKILL.md index 25176aa..7e79986 100644 --- a/SKILL.md +++ b/SKILL.md @@ -109,12 +109,15 @@ If in Active state but operations fail: Note: enVector credentials are delivered automatically via the Vault bundle — no user input needed. -4. Create `~/.rune/config.json` with `state: "active"` and the values - above (`mkdir -p ~/.rune && chmod 700 ~/.rune`, then `chmod 600` the - file). -5. Call the `reload_pipelines` MCP tool. The MCP server's boot loop - dials Vault, fetches the agent manifest (EncKey + envector - creds), connects to enVector, and transitions to Active. +4. Call the `configure` MCP tool with the collected values + (`endpoint`, `token`, `ca_cert_path`, `tls_disable`). The server does + the atomic 0600 write to `~/.rune/config.json`, sets `state: "active"`, + refreshes `metadata.lastUpdated`, and runs a best-effort Vault probe. + The agent never writes the config file itself. +5. Call the `activate` MCP tool to bring pipelines online. It runs the + prereq checks server-side and drives the boot loop: dials Vault, + fetches the agent manifest (EncKey + enVector creds), connects to + enVector, and transitions to Active. 6. Confirm health by calling `diagnostics` and applying the **Boot Failure — Fast-Fail Rule** (see section below). If `vault.last_boot_error` is present, surface its `hint` verbatim @@ -195,7 +198,7 @@ Recommendations: **Note**: In most cases, simply asking naturally ("Why did we choose PostgreSQL?") triggers Retriever automatically — no command needed. -### `/rune:activate` (or `/rune:wakeup`) +### `/rune:activate` (or `$rune activate` for Codex CLI) **Purpose**: Attempt to activate plugin after infrastructure is ready @@ -203,20 +206,29 @@ Recommendations: **Use Case**: Infrastructure was not ready during configure, but now it's deployed and running. **Steps**: -1. Check if config exists - - NO → Redirect to `/rune:configure` (or `$rune configure` for Codex CLI) - - YES → Continue -2. If `state` is already `"active"`, skip to step 4 (just verify health). -3. If `state` is `"dormant"`, set it to `"active"` and clear any - `dormant_reason` / `dormant_since` fields. -4. Call the `reload_pipelines` MCP tool. From a terminal Dormant the - boot loop is re-spawned; from Active it is a no-op. -5. Call `diagnostics` and apply the **Boot Failure — Fast-Fail Rule** - (section below). -6. If `vault.last_boot_error` is present: surface its `hint` verbatim, - suggest the matching recovery action, and stop. Do NOT loop on - `reload_pipelines` or probe with shell tools — the classifier has - already done that work. Otherwise render the per-subsystem snapshot. +1. Call the `activate` MCP tool — no Read, no Edit, no manual state + inspection. It runs the prereq checks server-side (config present, + runed socket reachable + Health probe) and only triggers the boot + loop when everything is ready. It returns a `status`: + `configure_required` | `install_pending` | `waiting_for_bootstrap` | + `active` | `waiting_for_vault` | `dormant`. +2. Branch on `status`: + - `configure_required` → redirect to `/rune:configure`; use the `hint` + verbatim and stop. + - `install_pending` → invoke the recovery in `hint` (the agent runs + `rune install`, never the user), then retry `/rune:activate` once. + - `waiting_for_bootstrap` → runed is still downloading llama-server / + the embedding model; summarize `.bootstrap` progress, tell the user + no further action is needed, and stop (do NOT poll). + - `active` → optionally call `diagnostics` once and render the + per-subsystem snapshot. + - `waiting_for_vault` / `dormant` → apply the **Boot Failure — + Fast-Fail Rule** (below): surface `reload.last_boot_error.hint` + verbatim, suggest one recovery, and stop. + +(Older rune-mcp binaries without the `activate` tool fall back to the +legacy flow: set `state: "active"`, call `reload_pipelines` directly, and +branch on `diagnostics.vault.last_boot_error`.) ### `/rune:reset` (or `$rune reset` for Codex CLI) From 51bb7019905b5f852eeabf0f09a45fcb108be8ca Mon Sep 17 00:00:00 2001 From: shpark Date: Tue, 9 Jun 2026 17:38:26 +0000 Subject: [PATCH 03/20] docs: fix mismatched reference Co-Authored-By: Claude Sonnet 4.6 --- AGENT_INTEGRATION.md | 32 +++++++++++++++++++------------- 1 file changed, 19 insertions(+), 13 deletions(-) diff --git a/AGENT_INTEGRATION.md b/AGENT_INTEGRATION.md index 789395f..e267a6e 100644 --- a/AGENT_INTEGRATION.md +++ b/AGENT_INTEGRATION.md @@ -1,19 +1,23 @@ # Agent Integration Guide Rune works with all major AI agents via native MCP (Model Context Protocol) -support. In v0.4 the MCP server is a single Go binary -(`bin/rune-mcp`) that the host CLI auto-spawns over stdio — no Python -runtime, no `pip install`, no manual `mcp add` for the supported CLIs. +support. In v0.4 the MCP server is a single Go binary (`rune-mcp`) that the +host CLI auto-spawns over stdio through the committed bash wrapper +`bin/rune mcp-server` — no Python runtime, no `pip install`, no manual +`mcp add` for the supported CLIs. ## Integration Principles ### Cross-agent common (single source of truth) -- The Go binary at `cmd/rune-mcp/` is the only MCP server entry point. - Plugin / extension manifests point each CLI at the same binary. -- Runtime preparation happens at install time (the binary is already - built and shipped with the plugin tarball — see Task #30 for the - release pipeline). Nothing needs to be (re)bootstrapped at session - start. +- The CLI entry point is `cmd/rune/` (the `rune` binary). Plugin / + extension manifests point each CLI at the committed bash wrapper + `bin/rune` invoked as `rune mcp-server`, which execs the downloaded + `rune-mcp` MCP server. +- Runtime preparation happens on the first MCP spawn, not at plugin + install: the wrapper self-installs the `rune` CLI and downloads the + pinned `rune-mcp` binary (per `.release-pins.yaml`) into `~/.rune/bin/`, + then execs it — so the server comes online in the same session with no + manual `/mcp` reconnect or restart. ### Agent-specific adapters (thin layer only) - Codex-only tasks: `codex mcp add/remove/list` registration flows @@ -48,10 +52,12 @@ $ claude plugin install rune > /plugin install rune ``` -The plugin manifest (`.claude-plugin/plugin.json`) declares the binary -path; Claude Code spawns `${CLAUDE_PLUGIN_ROOT}/bin/rune-mcp` via stdio -on session start. enVector Cloud credentials are delivered automatically -via the Vault bundle — you never set `ENVECTOR_*` env vars directly. +The plugin manifest (`.claude-plugin/plugin.json`) declares the wrapper +path; Claude Code spawns `${CLAUDE_PLUGIN_ROOT}/bin/rune mcp-server` via +stdio on session start (on a fresh install the wrapper self-installs +rune-mcp first, then execs it). enVector Cloud credentials are delivered +automatically via the Vault bundle — you never set `ENVECTOR_*` env vars +directly. ### Configure credentials From c0c635c5008a70a439c7a77a164fc804fba4fea1 Mon Sep 17 00:00:00 2001 From: shpark Date: Tue, 9 Jun 2026 18:26:46 +0000 Subject: [PATCH 04/20] fix(bootstrap): add fail-fast install to prevent consuming budget on real error --- cmd/rune/mcpserver.go | 12 ++++++++++-- internal/bootstrap/install.go | 3 +++ internal/bootstrap/lock_unix.go | 5 +++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/cmd/rune/mcpserver.go b/cmd/rune/mcpserver.go index 3f78df8..27e0d81 100644 --- a/cmd/rune/mcpserver.go +++ b/cmd/rune/mcpserver.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "io" "os" @@ -41,9 +42,16 @@ func runMCPServer(ctx context.Context, args []string, stderr io.Writer) int { }, }) if instErr != nil { + // If error is not ErrInstalINProgress, it is unavoidable error + if !errors.Is(instErr, bootstrap.ErrInstallInProgress) { + fmt.Fprintf(stderr, "rune: cannot install rune-mcp: %v\n", instErr) + return 1 + } + + // Another session hold lock if !waitForFile(healCtx, paths.RuneMCPBinary, mcpSelfhealBudget) { - fmt.Fprintf(stderr, "rune: failed to install rune-mcp within %s: %v\n", mcpSelfhealBudget, instErr) - fmt.Fprintln(stderr, " another session may still be installing it; reconnect via /mcp once it completes") + fmt.Fprintf(stderr, "rune: rune-mcp install still in progress after %s: %v\n", mcpSelfhealBudget, instErr) + fmt.Fprintln(stderr, " another session is installing it; reconnect via /mcp once it completes") return 1 } diff --git a/internal/bootstrap/install.go b/internal/bootstrap/install.go index 74c6b0a..986d1e6 100644 --- a/internal/bootstrap/install.go +++ b/internal/bootstrap/install.go @@ -2,6 +2,7 @@ package bootstrap import ( "context" + "errors" "fmt" "net" "os" @@ -10,6 +11,8 @@ import ( "time" ) +var ErrInstallInProgress = errors.New("install: another install in progress") + type InstallOptions struct { ManifestURL string Force bool // `rune install --force` to force re-download diff --git a/internal/bootstrap/lock_unix.go b/internal/bootstrap/lock_unix.go index 532a382..abfe73f 100644 --- a/internal/bootstrap/lock_unix.go +++ b/internal/bootstrap/lock_unix.go @@ -34,15 +34,16 @@ func acquireInstallLock(ctx context.Context, lockPath string, timeout time.Durat _ = f.Close() return nil, fmt.Errorf("flock %s: %w", lockPath, err) } + if !time.Now().Before(deadline) { _ = f.Close() - return nil, fmt.Errorf("install lock %s: another install in progress (waited %s)", lockPath, timeout) + return nil, fmt.Errorf("%w: lock %s held (waited %s)", ErrInstallInProgress, lockPath, timeout) } select { case <-ctx.Done(): _ = f.Close() - return nil, ctx.Err() + return nil, fmt.Errorf("%w: %w", ErrInstallInProgress, ctx.Err()) case <-time.After(installLockPollInterval): } } From db90dfd2e895eb4c8dbdcec00b4cf406795357e2 Mon Sep 17 00:00:00 2001 From: Sanghoon Park Date: Wed, 10 Jun 2026 11:10:31 +0900 Subject: [PATCH 05/20] chore(cli): fix typo --- cmd/rune/mcpserver.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/rune/mcpserver.go b/cmd/rune/mcpserver.go index 27e0d81..16fcdfc 100644 --- a/cmd/rune/mcpserver.go +++ b/cmd/rune/mcpserver.go @@ -42,7 +42,7 @@ func runMCPServer(ctx context.Context, args []string, stderr io.Writer) int { }, }) if instErr != nil { - // If error is not ErrInstalINProgress, it is unavoidable error + // If error is not ErrInstallInProgress, it is unavoidable error if !errors.Is(instErr, bootstrap.ErrInstallInProgress) { fmt.Fprintf(stderr, "rune: cannot install rune-mcp: %v\n", instErr) return 1 From 04dff6edd6f707afb0ebe13ba67ea32be77b78fd Mon Sep 17 00:00:00 2001 From: shpark Date: Tue, 9 Jun 2026 07:32:14 +0000 Subject: [PATCH 06/20] fix(cli): harden initial bootstrap considering network failures and stale lock --- bin/rune | 152 +++++++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 132 insertions(+), 20 deletions(-) diff --git a/bin/rune b/bin/rune index 14e3d18..3428a19 100755 --- a/bin/rune +++ b/bin/rune @@ -44,30 +44,135 @@ mkdir -p "$RUNE_HOME" LOCK_DIR="$RUNE_HOME/bootstrap.lock.d" TMP="" SUMS="" +OWNER_TOKEN="" cleanup() { [ -n "$TMP" ] && rm -f "$TMP" [ -n "$SUMS" ] && rm -f "$SUMS" - rmdir "$LOCK_DIR" 2>/dev/null || true + # Release lock after checking token is valid or not + if [ -n "$OWNER_TOKEN" ] && [ "$(cat "$LOCK_DIR/owner" 2>/dev/null || true)" = "$OWNER_TOKEN" ]; then + rm -f "$LOCK_DIR/owner" 2>/dev/null || true + rmdir "$LOCK_DIR" 2>/dev/null || true + fi +} + +# Network time budget +# - `mcp-server`: MCP entryponit run by Claude Code session with ~30s timeout. +# SIGKILL after timeout skip cleanup which leave unreleased bootstrap lock; +# so overall time should be less than 30s. +# Worst case: API resolve up to 7s + binary download up to 13s + checksum up to 7s +# - other: matched with each downloaded binaries' deadline +if [ "${1:-}" = mcp-server ]; then + NET_RETRY=3; NET_RETRY_DELAY=1; NET_RETRY_MAXTIME=3 + NET_API_MAXTIME=4; NET_BIN_MAXTIME=10; NET_CHECKSUM_MAXTIME=4 +else + NET_RETRY=3; NET_RETRY_DELAY=2; NET_RETRY_MAXTIME=60 + NET_API_MAXTIME=20; NET_BIN_MAXTIME=120; NET_CHECKSUM_MAXTIME=30 +fi + +# --retry rides out transient GitHub CDN failures (504, timeouts) instead +# of aborting the whole bootstrap on the first blip. +# Caller add NET_{API|BIN|CHECKSUM}_MAXTIME properly on each step +fetch() { + curl --fail --silent --show-error --location --connect-timeout 5 \ + --retry "$NET_RETRY" --retry-delay "$NET_RETRY_DELAY" \ + --retry-max-time "$NET_RETRY_MAXTIME" "$@" +} + +# Lock waiting budget to exit before Claude code MCP spawn timeout (~30s) +LOCK_WAIT_BUDGET="${RUNE_LOCK_WAIT_BUDGET:-20}" +# Lock's wall-clock age to prevent alive but stucked holder +# Worst case: NET_RETRY_MAXTIME + NET_{API|BIN|CHECKSUM}_MAXTIME (about 350s) when !mcp-server +LOCK_STALE_AFTER="${RUNE_LOCK_STALE_AFTER:-360}" + +# Atomically take stale lock and remove it +clear_stale_lock() { + if mv "$LOCK_DIR" "$LOCK_DIR.reclaim.$$" 2>/dev/null; then + rm -rf "$LOCK_DIR.reclaim.$$" 2>/dev/null || true + fi + return 0 } waited=0 -while ! mkdir "$LOCK_DIR" 2>/dev/null; do # another session hold lock +wait_count=0 +while true; do + # Claim lock atomically + if mkdir "$LOCK_DIR" 2>/dev/null; then + OWNER_TOKEN="$$ $(date +%s)" # " " + if ( set -C; printf '%s\n' "$OWNER_TOKEN" > "$LOCK_DIR/owner" ) 2>/dev/null; then + trap cleanup EXIT INT TERM + # Double-check if mkdir -> write gap affect lock + if [ "$(cat "$LOCK_DIR/owner" 2>/dev/null || true)" = "$OWNER_TOKEN" ]; then + break + fi + trap - EXIT INT TERM + OWNER_TOKEN="" + continue + fi + + OWNER_TOKEN="" + if [ ! -d "$LOCK_DIR" ]; then + continue # lock is cleared, retry claim + fi + + # Real write error (disk full, permission, or others) + if [ ! -e "$LOCK_DIR/owner" ]; then + echo "rune: cannot record install bootstrap lock owner (file write failed)" >&2 + exit 1 + fi + fi + + # Wait for another process as we failed to claim lokc if [ -x "$TARGET" ]; then exec "$TARGET" "$@" # bootstrap finished fi + # Validate owner + owner="$(cat "$LOCK_DIR/owner" 2>/dev/null || true)" + pid="${owner%% *}" + case "$owner" in + *" "*) ts="${owner##* }" ;; + *) ts="" ;; + esac + + if [ -z "$owner" ]; then + # Dir is created but no owner yet; holder in the middle of claim or died + wait_count=$((wait_count + 1)) + if [ "$wait_count" -ge 5 ]; then + echo "rune: bootstrap lock not claimed for ${wait_count}s; reclaiming" >&2 + clear_stale_lock; wait_count=0; continue + fi + else + wait_count=0 + if [ -n "$pid" ] && ! kill -0 "$pid" 2>/dev/null; then + # Holder process not found; lock is leaked + echo "rune: bootstrap lock holder (pid $pid) is not found; reclaiming" >&2 + clear_stale_lock; continue + fi + + # Check wall-clock age + case "$ts" in + ''|*[!0-9]*) age=0 ;; + *) age=$(( $(date +%s) - ts )) ;; + esac + + if [ "$age" -ge "$LOCK_STALE_AFTER" ]; then + echo "rune: bootstrap lock stale (${age}s); reclaiming" >&2 + clear_stale_lock; continue + fi + + if [ "$waited" -ge "$LOCK_WAIT_BUDGET" ]; then + echo "rune: another rune bootstrap is in progress over MCP spawn budget." >&2 + echo " Retry in a moment, or run it out-of-band:" >&2 + echo " bash -c \"\${CLAUDE_PLUGIN_ROOT:-.}/bin/rune install\"" >&2 + exit 1 + fi + fi + sleep 1 waited=$((waited + 1)) - if [ "$waited" -ge 120 ]; then - echo "rune: bootstrap lock held >120s, reclaiming" >&2 - rmdir "$LOCK_DIR" 2>/dev/null || true - waited=0 - fi done -# Check error -trap cleanup EXIT INT TERM - +# Double-check: install is completed right before we won the lock if [ -x "$TARGET" ]; then cleanup trap - EXIT INT TERM @@ -85,12 +190,9 @@ if [ -z "$RUNE_VERSION" ]; then # Use token if exist token="${GITHUB_TOKEN:-${GH_TOKEN:-}}" if [ -n "$token" ]; then - body="$(curl --fail --silent --show-error --location --connect-timeout 10 --max-time 20 \ - --retry 3 --retry-delay 2 \ - --header "Authorization: Bearer $token" "$api" || true)" + body="$(fetch --max-time "$NET_API_MAXTIME" --header "Authorization: Bearer $token" "$api" || true)" else - body="$(curl --fail --silent --show-error --location --connect-timeout 10 --max-time 20 \ - --retry 3 --retry-delay 2 "$api" || true)" + body="$(fetch --max-time "$NET_API_MAXTIME" "$api" || true)" fi RUNE_VERSION="$(printf '%s' "$body" \ @@ -128,12 +230,22 @@ mkdir -p "$(dirname "$TARGET")" TMP="$(mktemp "$(dirname "$TARGET")/.rune-bootstrap-XXXXXX")" SUMS="$(mktemp -t rune-bootstrap-sums-XXXXXX)" -# --retry rides out transient GitHub CDN failures (504, timeouts) instead -# of aborting the whole bootstrap on the first blip. -curl --fail --silent --show-error --location --connect-timeout 10 --max-time 120 --retry 3 --retry-delay 2 "$RELEASE_BASE/$ASSET" -o "$TMP" -curl --fail --silent --show-error --location --connect-timeout 10 --max-time 30 --retry 3 --retry-delay 2 "$RELEASE_BASE/checksums.txt" -o "$SUMS" +if ! fetch --max-time "$NET_BIN_MAXTIME" "$RELEASE_BASE/$ASSET" -o "$TMP"; then + echo "rune: could not download $ASSET ($RUNE_VERSION) after retries." >&2 + echo " The release endpoint may be slow or temporarily unavailable (e.g. HTTP 504)." >&2 + echo " Recover out-of-band, then reconnect /mcp:" >&2 + echo " bash -c \"\${CLAUDE_PLUGIN_ROOT:-.}/bin/rune install\"" >&2 + exit 1 +fi +if ! fetch --max-time "$NET_CHECKSUM_MAXTIME" "$RELEASE_BASE/checksums.txt" -o "$SUMS"; then + echo "rune: could not download checksums.txt ($RUNE_VERSION) after retries." >&2 + echo " The release endpoint may be slow or temporarily unavailable (e.g. HTTP 504)." >&2 + echo " Recover out-of-band, then reconnect /mcp:" >&2 + echo " bash -c \"\${CLAUDE_PLUGIN_ROOT:-.}/bin/rune install\"" >&2 + exit 1 +fi -EXPECTED="$(grep " $ASSET\$" "$SUMS" | cut -d' ' -f1)" +EXPECTED="$(grep " $ASSET\$" "$SUMS" | cut -d' ' -f1 || true)" if [ -z "$EXPECTED" ]; then echo "rune: $ASSET not listed in checksums.txt for $RUNE_VERSION" >&2 exit 1 From 91e3182910baa3a877414cce74a9ad5f9d3fe44b Mon Sep 17 00:00:00 2001 From: shpark Date: Wed, 10 Jun 2026 08:15:56 +0000 Subject: [PATCH 07/20] chore: fix typo and misleading comment --- bin/rune | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/bin/rune b/bin/rune index 3428a19..3ac36ad 100755 --- a/bin/rune +++ b/bin/rune @@ -56,7 +56,7 @@ cleanup() { } # Network time budget -# - `mcp-server`: MCP entryponit run by Claude Code session with ~30s timeout. +# - `mcp-server`: MCP entrypoint run by Claude Code session with ~30s timeout. # SIGKILL after timeout skip cleanup which leave unreleased bootstrap lock; # so overall time should be less than 30s. # Worst case: API resolve up to 7s + binary download up to 13s + checksum up to 7s @@ -69,8 +69,8 @@ else NET_API_MAXTIME=20; NET_BIN_MAXTIME=120; NET_CHECKSUM_MAXTIME=30 fi -# --retry rides out transient GitHub CDN failures (504, timeouts) instead -# of aborting the whole bootstrap on the first blip. +# retries fast transient errors such as Github CDN failures (504, timeouts) only; +# slow/hung requests are intentionally not retried to stay within the spawn budget. # Caller add NET_{API|BIN|CHECKSUM}_MAXTIME properly on each step fetch() { curl --fail --silent --show-error --location --connect-timeout 5 \ @@ -80,7 +80,7 @@ fetch() { # Lock waiting budget to exit before Claude code MCP spawn timeout (~30s) LOCK_WAIT_BUDGET="${RUNE_LOCK_WAIT_BUDGET:-20}" -# Lock's wall-clock age to prevent alive but stucked holder +# Lock's wall-clock age to prevent alive but stuck holder # Worst case: NET_RETRY_MAXTIME + NET_{API|BIN|CHECKSUM}_MAXTIME (about 350s) when !mcp-server LOCK_STALE_AFTER="${RUNE_LOCK_STALE_AFTER:-360}" @@ -121,7 +121,7 @@ while true; do fi fi - # Wait for another process as we failed to claim lokc + # Wait for another process as we failed to claim lock if [ -x "$TARGET" ]; then exec "$TARGET" "$@" # bootstrap finished fi From 620444441cb7982635cd29f1df6ba3b3167ad3b4 Mon Sep 17 00:00:00 2001 From: shpark Date: Thu, 11 Jun 2026 12:46:08 +0000 Subject: [PATCH 08/20] fix(bootstrap): make test hardening for lock test --- internal/bootstrap/lock_unix_test.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/bootstrap/lock_unix_test.go b/internal/bootstrap/lock_unix_test.go index d42068a..14fc97f 100644 --- a/internal/bootstrap/lock_unix_test.go +++ b/internal/bootstrap/lock_unix_test.go @@ -39,6 +39,9 @@ func TestAcquireInstallLock_TimeOut(t *testing.T) { if !strings.Contains(err.Error(), "another install in progress") { t.Errorf("error should mention 'another install in progress'; got %v", err) } + if !errors.Is(err, ErrInstallInProgress) { + t.Errorf("timeout error should wrap ErrInstallInProgress; got %v", err) + } if elapsed < 80*time.Millisecond { t.Errorf("returned too quickly: %s", elapsed) @@ -80,6 +83,9 @@ func TestAcquireInstallLock_CtxCancel(t *testing.T) { if !errors.Is(gotErr, context.Canceled) { t.Errorf("want context.Canceled, got %v", gotErr) } + if !errors.Is(gotErr, ErrInstallInProgress) { + t.Errorf("context cancelled during waiting lock should be wrapped with ErrInstallInProgress; got %v", gotErr) + } if elapsed > 1*time.Second { t.Errorf("ctx cancel should be honored quickly; took %s", elapsed) } From 2313204d282d0a7681eac8910d4351e289e93d80 Mon Sep 17 00:00:00 2001 From: shpark Date: Thu, 11 Jun 2026 13:51:01 +0000 Subject: [PATCH 09/20] test(cli): add tests for waitForFile --- cmd/rune/mcpserver_test.go | 59 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 cmd/rune/mcpserver_test.go diff --git a/cmd/rune/mcpserver_test.go b/cmd/rune/mcpserver_test.go new file mode 100644 index 0000000..4afb9ea --- /dev/null +++ b/cmd/rune/mcpserver_test.go @@ -0,0 +1,59 @@ +package main + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" +) + +func TestWaitForFile_AlreadyPresent(t *testing.T) { + p := filepath.Join(t.TempDir(), "rune-mcp") + if err := os.WriteFile(p, []byte("x"), 0o755); err != nil { + t.Fatal(err) + } + + if !waitForFile(context.Background(), p, time.Second) { + t.Error("want true when the file already exists") + } +} + +func TestWaitForFile_CreatedAfterCheck(t *testing.T) { + p := filepath.Join(t.TempDir(), "rune-mcp") + go func() { + time.Sleep(100 * time.Millisecond) // file created after initial check + _ = os.WriteFile(p, []byte("x"), 0o755) + }() + + if !waitForFile(context.Background(), p, 3*time.Second) { + t.Error("want true once the file appears after initial check-up") + } +} + +func TestWaitForFile_CtxCancel(t *testing.T) { + p := filepath.Join(t.TempDir(), "never") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + start := time.Now() + if waitForFile(ctx, p, 5*time.Second) { + t.Error("want false when ctx is already cancelled") + } + + if elapsed := time.Since(start); elapsed > time.Second { + t.Errorf("ctx cancel should return promptly, not wait out the timeout; took %s", elapsed) + } +} + +func TestWaitForFile_Timeout(t *testing.T) { + p := filepath.Join(t.TempDir(), "never") + start := time.Now() + if waitForFile(context.Background(), p, 200*time.Millisecond) { + t.Error("want false on timeout when the file never appears") + } + + if elapsed := time.Since(start); elapsed < 150*time.Millisecond { + t.Errorf("returned %s before the 200ms timeout", elapsed) + } +} From ca2be0fd2dd33f00fc65e3f0b1762a8f640fe9d2 Mon Sep 17 00:00:00 2001 From: shpark Date: Thu, 11 Jun 2026 13:54:49 +0000 Subject: [PATCH 10/20] test(cli): add test for 'install --json' --- cmd/rune/install_test.go | 96 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 cmd/rune/install_test.go diff --git a/cmd/rune/install_test.go b/cmd/rune/install_test.go new file mode 100644 index 0000000..6b47f1c --- /dev/null +++ b/cmd/rune/install_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/CryptoLabInc/rune-cli/internal/bootstrap" +) + +func TestRunInstall_JSONHappyPath(t *testing.T) { + saved := manifestURL + manifestURL = "" + defer func() { manifestURL = saved }() + + dir := t.TempDir() + t.Setenv("RUNE_HOME", filepath.Join(dir, "rune")) + t.Setenv("RUNED_HOME", filepath.Join(dir, "runed")) + t.Setenv("RUNE_MANIFEST", "") + + runed := []byte("runed-bytes") + mcp := []byte("rune-mcp-bytes") + sha := func(b []byte) string { s := sha256.Sum256(b); return hex.EncodeToString(s[:]) } + + mux := http.NewServeMux() + var srv *httptest.Server + mux.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) { + m := map[string]any{ + "version": 1, + "rune_mcp_version": "v0.1.0-test", + "runed_version": "v0.1.0-test", + "platforms": map[string]any{ + bootstrap.PlatformTuple(): map[string]any{ + "runed": map[string]any{"url": srv.URL + "/runed", "sha256": sha(runed), "size": len(runed)}, + "rune_mcp": map[string]any{"url": srv.URL + "/rune-mcp", "sha256": sha(mcp), "size": len(mcp)}, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(m) + }) + + serve := func(b []byte) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(b))) + _, _ = w.Write(b) + } + } + + mux.HandleFunc("/runed", serve(runed)) + mux.HandleFunc("/rune-mcp", serve(mcp)) + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + var stdout, stderr bytes.Buffer + code := runInstall(context.Background(), []string{"--json", "--manifest-url", srv.URL + "/manifest.json"}, &stdout, &stderr) + if code != 0 { + t.Fatalf("exit = %d, want 0; stderr=%q stdout=%q", code, stderr.String(), stdout.String()) + } + + dec := json.NewDecoder(&stdout) + var sawLog, sawSummary bool + for dec.More() { + var ev jsonEvent + if err := dec.Decode(&ev); err != nil { + t.Fatalf("stdout is not a clean JSON: %v", err) + } + + switch ev.Event { + case "log": + sawLog = true + case "summary": + sawSummary = true + if ev.Error != "" { + t.Errorf("success summary should carry no error; got %q", ev.Error) + } + if ev.Result == nil || !ev.Result.OK { + t.Errorf("summary Result should be OK; got %+v", ev.Result) + } + } + } + + if !sawLog { + t.Error("expected at least one log event in --json output") + } + if !sawSummary { + t.Error("expected a terminal summary event in --json output") + } +} From b9d21cb173e5449cdb91febced7966ec5660cb50 Mon Sep 17 00:00:00 2001 From: shpark Date: Thu, 11 Jun 2026 14:02:00 +0000 Subject: [PATCH 11/20] test(supervisor): test SIGKILL escalation --- internal/supervisor/supervisor_test.go | 35 ++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/internal/supervisor/supervisor_test.go b/internal/supervisor/supervisor_test.go index a64f19f..4cfc71d 100644 --- a/internal/supervisor/supervisor_test.go +++ b/internal/supervisor/supervisor_test.go @@ -56,6 +56,10 @@ func fakeRuned(behavior string) { os.Exit(1) } os.Exit(0) + case "ignore_sigterm": // deligate SIGTERM to SIGKILL + signal.Ignore(syscall.SIGTERM, syscall.SIGINT) + time.Sleep(30 * time.Second) + os.Exit(0) default: os.Exit(99) } @@ -155,3 +159,34 @@ func TestWatcher_ContextCancelTriggersShutdown(t *testing.T) { t.Errorf("runWatcher returned %v; want nil on graceful shutdown", v) } } + +func TestWatcher_EscalateSIGKILL(t *testing.T) { + t.Setenv(fakeRunedEnv, "ignore_sigterm") + cfg := testWatcherConfig(t) + cfg.ShutdownGrace = 300 * time.Millisecond + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { done <- runWatcher(ctx, cfg) }() + + time.Sleep(400 * time.Millisecond) + start := time.Now() + cancel() + + select { + case err := <-done: + elapsed := time.Since(start) + if err != nil { + t.Errorf("runWatcher = %v; want nil after SIGKILL escalation", err) + } + + if elapsed < cfg.ShutdownGrace { + t.Errorf("returned in %s (< grace %s); SIGTERM should have been ignored", elapsed, cfg.ShutdownGrace) + } + if elapsed > cfg.ShutdownGrace+3*time.Second { + t.Errorf("escalation too slow: %s", elapsed) + } + case <-time.After(8 * time.Second): + t.Fatal("runWatcher hung - SIGKILL escalation did not fire") + } +} From c0327a730f205ae53c97154671b5ecdb4a4bff97 Mon Sep 17 00:00:00 2001 From: shpark Date: Thu, 11 Jun 2026 14:11:17 +0000 Subject: [PATCH 12/20] test(bootstrap): add more install/extract tests --- internal/bootstrap/extract_test.go | 40 ++++++++++++++++ internal/bootstrap/install_test.go | 73 ++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/internal/bootstrap/extract_test.go b/internal/bootstrap/extract_test.go index d6d6976..9d0f062 100644 --- a/internal/bootstrap/extract_test.go +++ b/internal/bootstrap/extract_test.go @@ -108,3 +108,43 @@ func TestExtractTarball_RejectsAbsolutePath(t *testing.T) { t.Fatal("expected error for absolute entry path, got nil") } } + +func TestExtractTarball_SkipSymlink(t *testing.T) { + dir := t.TempDir() + tarPath := filepath.Join(dir, "symlink.tar.gz") + + var buf bytes.Buffer + gz := gzip.NewWriter(&buf) + tw := tar.NewWriter(gz) + reg := []byte("REAL") + + if err := tw.WriteHeader(&tar.Header{Name: "runed", Mode: 0o755, Size: int64(len(reg)), Typeflag: tar.TypeReg}); err != nil { + t.Fatalf("tar header (reg): %v", err) + } + if _, err := tw.Write(reg); err != nil { + t.Fatalf("tar write: %v", err) + } + if err := tw.WriteHeader(&tar.Header{Name: "malicious", Linkname: "/etc/passwd", Typeflag: tar.TypeSymlink}); err != nil { + t.Fatalf("tar header (symlink): %v", err) + } + if err := tw.Close(); err != nil { + t.Fatalf("tar close: %v", err) + } + if err := gz.Close(); err != nil { + t.Fatalf("gz close: %v", err) + } + if err := os.WriteFile(tarPath, buf.Bytes(), 0o600); err != nil { + t.Fatalf("write tarball: %v", err) + } + + dest := filepath.Join(dir, "out") + if err := ExtractTarball(tarPath, dest); err != nil { + t.Fatalf("ExtractTarball should skip symlinks without error: %v", err) + } + if _, err := os.Stat(filepath.Join(dest, "runed")); err != nil { + t.Errorf("regular file should still extract alongside a skipped symlink: %v", err) + } + if _, err := os.Lstat(filepath.Join(dest, "malicious")); !os.IsNotExist(err) { + t.Errorf("symlink entry must be skipped, not created; lstat err=%v", err) + } +} diff --git a/internal/bootstrap/install_test.go b/internal/bootstrap/install_test.go index ee86ec2..c6233ec 100644 --- a/internal/bootstrap/install_test.go +++ b/internal/bootstrap/install_test.go @@ -204,3 +204,76 @@ func TestInstall_ChecksumMismatch_PartialFailure(t *testing.T) { t.Errorf("rune-mcp should not exist after checksum failure (err=%v)", err) } } + +func tarGz(t *testing.T, name string, body []byte) []byte { + t.Helper() + p := filepath.Join(t.TempDir(), "artifact.tar.gz") + + makeTarball(t, p, map[string]struct { + body []byte + mode int64 + }{ + name: {body: body, mode: 0o755}, + }) + + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read tarball: %v", err) + } + + return b +} + +func TestInstall_TarballExtract(t *testing.T) { + setRealms(t) + paths, err := Resolve() + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + runedTar := tarGz(t, filepath.Base(paths.RunedBinary), []byte("RUNED")) + mcpTar := tarGz(t, filepath.Base(paths.RuneMCPBinary), []byte("RUNE-MCP")) + + var srv *httptest.Server + mux := http.NewServeMux() + + mux.HandleFunc("/manifest.json", func(w http.ResponseWriter, r *http.Request) { + m := map[string]any{ + "version": 1, + "rune_mcp_version": "v0.1.0-test", + "runed_version": "v0.1.0-test", + "platforms": map[string]any{ + PlatformTuple(): map[string]any{ + "runed": map[string]any{"url": srv.URL + "/runed.tar.gz", "sha256": sha256Hex(runedTar), "size": len(runedTar), "extract": "tar.gz"}, + "rune_mcp": map[string]any{"url": srv.URL + "/rune-mcp.tar.gz", "sha256": sha256Hex(mcpTar), "size": len(mcpTar), "extract": "tar.gz"}, + }, + }, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(m) + }) + mux.HandleFunc("/runed.tar.gz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(runedTar) }) + mux.HandleFunc("/rune-mcp.tar.gz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write(mcpTar) }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + r, err := Install(context.Background(), InstallOptions{ManifestURL: srv.URL + "/manifest.json"}) + if err != nil { + t.Fatalf("Install (tar.gz): %v", err) + } + if !r.OK { + t.Errorf("Result.OK = false, want true (r=%+v)", r) + } + + for _, p := range []string{paths.RunedBinary, paths.RuneMCPBinary} { + info, statErr := os.Stat(p) + if statErr != nil { + t.Errorf("extracted binary missing at %s: %v", p, statErr) + continue + } + if info.Mode().Perm()&0o100 == 0 { + t.Errorf("%s is not executable: mode=%v", p, info.Mode()) + } + } +} From 6d142f3184cce9254abf31a609fb13926af95b7e Mon Sep 17 00:00:00 2001 From: shpark Date: Thu, 11 Jun 2026 03:00:48 +0000 Subject: [PATCH 13/20] fix(cli): fall back to RUNE_MANIFEST env if empty argument --- cmd/rune/install.go | 16 +++++++++++++++- cmd/rune/version.go | 10 ++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/cmd/rune/install.go b/cmd/rune/install.go index c1a73cf..668c84a 100644 --- a/cmd/rune/install.go +++ b/cmd/rune/install.go @@ -6,6 +6,7 @@ import ( "flag" "fmt" "io" + "os" "github.com/CryptoLabInc/rune-cli/internal/bootstrap" ) @@ -20,8 +21,21 @@ func runInstall(ctx context.Context, args []string, stdout, stderr io.Writer) in return 2 } + // Check RUNE_MANIFEST before fail if *manifest == "" { - fmt.Fprintln(stderr, "rune install: no manifest URL configured (set --manifest-url or RUNE_MANIFEST)") + if env := os.Getenv("RUNE_MANIFEST"); env != "" { + *manifest = env + } + } + + if *manifest == "" { + const msg = "no manifest URL configured (set --manifest-url or RUNE_MANIFEST)" + if *jsonOut { + _ = json.NewEncoder(stdout).Encode(jsonEvent{Event: "summary", Error: msg}) + } else { + fmt.Fprintln(stderr, "rune install: "+msg) + } + return 2 } diff --git a/cmd/rune/version.go b/cmd/rune/version.go index be36d71..503770e 100644 --- a/cmd/rune/version.go +++ b/cmd/rune/version.go @@ -3,12 +3,18 @@ package main import ( "fmt" "io" + "os" ) func runVersion(w io.Writer) int { fmt.Fprintf(w, "rune %s\n", runeVersion) - if manifestURL != "" { - fmt.Fprintf(w, "manifest: %s\n", manifestURL) + manifest := manifestURL + if manifest == "" { + manifest = os.Getenv("RUNE_MANIFEST") + } + + if manifest != "" { + fmt.Fprintf(w, "manifest: %s\n", manifest) } else { fmt.Fprintln(w, "manifest missing: supply --manifest-url or RUNE_MANIFEST") } From 47cc161b1f1ed49050fc116fba4cb9efe6c16ad9 Mon Sep 17 00:00:00 2001 From: shpark Date: Thu, 11 Jun 2026 11:53:46 +0000 Subject: [PATCH 14/20] fix(cli): send flag parse and usage error to stderr rather than stdout --- cmd/rune/main.go | 2 +- cmd/rune/main_test.go | 95 +++++++++++++++++++++++++++++++++++++++++-- cmd/rune/verify.go | 4 +- 3 files changed, 94 insertions(+), 7 deletions(-) diff --git a/cmd/rune/main.go b/cmd/rune/main.go index a83dde8..a2b4377 100644 --- a/cmd/rune/main.go +++ b/cmd/rune/main.go @@ -59,7 +59,7 @@ func main() { case "install": os.Exit(runInstall(ctx, args, os.Stdout, os.Stderr)) case "verify": - os.Exit(runVerify(ctx, args, os.Stdout)) + os.Exit(runVerify(ctx, args, os.Stdout, os.Stderr)) case "version": os.Exit(runVersion(os.Stdout)) case "mcp-server": diff --git a/cmd/rune/main_test.go b/cmd/rune/main_test.go index 72646ec..306b5bd 100644 --- a/cmd/rune/main_test.go +++ b/cmd/rune/main_test.go @@ -33,6 +33,7 @@ func TestRunVersion_EmptyManifest(t *testing.T) { saved := manifestURL manifestURL = "" defer func() { manifestURL = saved }() + t.Setenv("RUNE_MANIFEST", "") var buf bytes.Buffer _ = runVersion(&buf) @@ -90,8 +91,8 @@ func TestRunVerify_ExitCodeOnFail(t *testing.T) { t.Setenv("RUNED_HOME", filepath.Join(dir, "runed")) // No config file - var buf bytes.Buffer - code := runVerify(context.Background(), nil, &buf) + var buf, errBuf bytes.Buffer + code := runVerify(context.Background(), nil, &buf, &errBuf) if code != 1 { t.Errorf("exit = %d, want 1 (fail)", code) } @@ -105,8 +106,8 @@ func TestRunVerify_JSONValidity(t *testing.T) { t.Setenv("RUNE_HOME", filepath.Join(dir, "rune")) t.Setenv("RUNED_HOME", filepath.Join(dir, "runed")) - var buf bytes.Buffer - _ = runVerify(context.Background(), []string{"--json"}, &buf) + var buf, errBuf bytes.Buffer + _ = runVerify(context.Background(), []string{"--json"}, &buf, &errBuf) var got bootstrap.InstallChecks if err := json.Unmarshal(buf.Bytes(), &got); err != nil { t.Fatalf("output not valid JSON: %v\n%s", err, buf.String()) @@ -120,6 +121,7 @@ func TestRunInstall_ErrorsWithoutManifest(t *testing.T) { saved := manifestURL manifestURL = "" defer func() { manifestURL = saved }() + t.Setenv("RUNE_MANIFEST", "") var stdout, stderr bytes.Buffer code := runInstall(context.Background(), nil, &stdout, &stderr) @@ -138,3 +140,88 @@ func TestRunInstall_UnknownFlag(t *testing.T) { t.Errorf("exit = %d, want 2 (flag parse error)", code) } } + +func TestRunInstall_CheckManifestEnv(t *testing.T) { + saved := manifestURL + manifestURL = "" + defer func() { manifestURL = saved }() + + dir := t.TempDir() + t.Setenv("RUNE_HOME", filepath.Join(dir, "rune")) + t.Setenv("RUNED_HOME", filepath.Join(dir, "runed")) + t.Setenv("RUNE_MANIFEST", "https://example.invalid/manifest.json") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + var stdout, stderr bytes.Buffer + code := runInstall(ctx, nil, &stdout, &stderr) + if code == 2 { + t.Errorf("exit = 2 (guard tripped); RUNE_MANIFEST exist but ignored. stderr=%q", stderr.String()) + } + if strings.Contains(stderr.String(), "no manifest URL configured") { + t.Errorf("guard message present despite RUNE_MANIFEST set: %q", stderr.String()) + } +} + +func TestRunInstall_JSONMissingManifest(t *testing.T) { + saved := manifestURL + manifestURL = "" + defer func() { manifestURL = saved }() + t.Setenv("RUNE_MANIFEST", "") + + var stdout, stderr bytes.Buffer + code := runInstall(context.Background(), []string{"--json"}, &stdout, &stderr) + if code != 2 { + t.Errorf("exit = %d, want 2", code) + } + + // Emit stdout as JSON + var ev jsonEvent + if err := json.Unmarshal(stdout.Bytes(), &ev); err != nil { + t.Fatalf("stdout should be a JSON event; got %q (err %v)", stdout.String(), err) + } + + if ev.Event != "summary" { + t.Errorf("event = %q, want \"summary\"", ev.Event) + } + if ev.Error == "" { + t.Errorf("missing-manifest summary must carry an error; got %+v", ev) + } +} + +func TestRunVersion_CheckManifestEnv(t *testing.T) { + saved := manifestURL + manifestURL = "" + defer func() { manifestURL = saved }() + + t.Setenv("RUNE_MANIFEST", "https://example.invalid/m.json") + + var buf bytes.Buffer + if code := runVersion(&buf); code != 0 { + t.Errorf("exit = %d, want 0", code) + } + + out := buf.String() + if !strings.Contains(out, "https://example.invalid/m.json") { + t.Errorf("RUNE_MANIFEST exist but ignored: %q", out) + } + if strings.Contains(out, "manifest missing") { + t.Errorf("should not report missing when RUNE_MANIFEST is set: %q", out) + } +} + +func TestRunVerify_BadFlagToStderr(t *testing.T) { + var stdout, stderr bytes.Buffer + code := runVerify(context.Background(), []string{"--no-such-flag"}, &stdout, &stderr) + if code != 2 { + t.Errorf("exit = %d, want 2 (flag parse error)", code) + } + + if stdout.Len() != 0 { + t.Errorf("stdout must clean for --json consumers; got %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "not defined") { + t.Errorf("flag error should stderr; got %q", stderr.String()) + } +} diff --git a/cmd/rune/verify.go b/cmd/rune/verify.go index 23eab87..e84f382 100644 --- a/cmd/rune/verify.go +++ b/cmd/rune/verify.go @@ -10,9 +10,9 @@ import ( "github.com/CryptoLabInc/rune-cli/internal/bootstrap" ) -func runVerify(ctx context.Context, args []string, stdout io.Writer) int { +func runVerify(ctx context.Context, args []string, stdout, stderr io.Writer) int { fs := flag.NewFlagSet("verify", flag.ContinueOnError) - fs.SetOutput(stdout) + fs.SetOutput(stderr) jsonOut := fs.Bool("json", false, "emit JSON instead of human-readable text") if err := fs.Parse(args); err != nil { return 2 From 7d51bf1d90282d7de894c5ac3edbcb51788c9f20 Mon Sep 17 00:00:00 2001 From: shpark Date: Fri, 12 Jun 2026 08:36:51 +0000 Subject: [PATCH 15/20] test(cli): add more specified tests --- cmd/rune/mcpserver_test.go | 94 ++++++++++++++++++++++++++ internal/bootstrap/install_test.go | 83 ++++++++++++++++++++--- internal/supervisor/supervisor_test.go | 29 ++++++++ 3 files changed, 196 insertions(+), 10 deletions(-) diff --git a/cmd/rune/mcpserver_test.go b/cmd/rune/mcpserver_test.go index 4afb9ea..9ad3d9e 100644 --- a/cmd/rune/mcpserver_test.go +++ b/cmd/rune/mcpserver_test.go @@ -1,13 +1,107 @@ package main import ( + "bytes" "context" + "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" + "syscall" "testing" "time" + + "github.com/CryptoLabInc/rune-cli/internal/bootstrap" ) +func TestRunMCPServer_InstallErrorFailFast(t *testing.T) { + saved := manifestURL + manifestURL = "" + defer func() { manifestURL = saved }() + + dir := t.TempDir() + t.Setenv("RUNE_HOME", filepath.Join(dir, "rune")) + t.Setenv("RUNED_HOME", filepath.Join(dir, "runed")) + + // Fail-fast error: unsupported manifest version + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"version": 999}`)) + })) + t.Cleanup(srv.Close) + t.Setenv("RUNE_MANIFEST", srv.URL) + + var stderr bytes.Buffer + start := time.Now() + code := runMCPServer(context.Background(), nil, &stderr) + elapsed := time.Since(start) + + if code != 1 { + t.Errorf("exit = %d, want 1", code) + } + if elapsed >= 5*time.Second { + t.Errorf("took %s; fail fast install errors must not poll the %s self-heal budget", elapsed, mcpSelfhealBudget) + } + if !strings.Contains(stderr.String(), "cannot install rune-mcp") { + t.Errorf("stderr missing fail-fast message: %q", stderr.String()) + } + if strings.Contains(stderr.String(), "another session") { + t.Errorf("fail-fast errors must not be diagnosed as a concurrent install: %q", stderr.String()) + } +} + +//--- Lock tests ---// + +func TestRunMCPServer_TryLockDuringInstall(t *testing.T) { + saved := manifestURL + manifestURL = "" + defer func() { manifestURL = saved }() + t.Setenv("RUNE_MANIFEST", "") + + dir := t.TempDir() + t.Setenv("RUNE_HOME", filepath.Join(dir, "rune")) + t.Setenv("RUNED_HOME", filepath.Join(dir, "runed")) + + paths, err := bootstrap.Resolve() + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if err := paths.EnsureDirs(); err != nil { + t.Fatalf("EnsureDirs: %v", err) + } + + f, err := os.OpenFile(paths.InstallLock, os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + t.Fatalf("open lock: %v", err) + } + defer f.Close() + + // Hold lock + if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + t.Fatalf("flock: %v", err) + } + defer func() { _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN) }() + + ctx, cancel := context.WithTimeout(context.Background(), 600*time.Millisecond) // short timeout for test + defer cancel() + + var stderr bytes.Buffer + code := runMCPServer(ctx, nil, &stderr) + + if code != 1 { + t.Errorf("exit = %d, want 1", code) + } + if !strings.Contains(stderr.String(), "another session") { + t.Errorf("lock failure should route to the concurrent-install wait path: %q", stderr.String()) + } + if strings.Contains(stderr.String(), "cannot install rune-mcp") { + t.Errorf("lock failure must be a retriable error: %q", stderr.String()) + } +} + +//--- waitForFile tests ---// + func TestWaitForFile_AlreadyPresent(t *testing.T) { p := filepath.Join(t.TempDir(), "rune-mcp") if err := os.WriteFile(p, []byte("x"), 0o755); err != nil { diff --git a/internal/bootstrap/install_test.go b/internal/bootstrap/install_test.go index c6233ec..fa45132 100644 --- a/internal/bootstrap/install_test.go +++ b/internal/bootstrap/install_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "strings" "testing" ) @@ -224,15 +225,8 @@ func tarGz(t *testing.T, name string, body []byte) []byte { return b } -func TestInstall_TarballExtract(t *testing.T) { - setRealms(t) - paths, err := Resolve() - if err != nil { - t.Fatalf("Resolve: %v", err) - } - - runedTar := tarGz(t, filepath.Base(paths.RunedBinary), []byte("RUNED")) - mcpTar := tarGz(t, filepath.Base(paths.RuneMCPBinary), []byte("RUNE-MCP")) +func tarballManifestServer(t *testing.T, runedTar, mcpTar []byte) string { + t.Helper() var srv *httptest.Server mux := http.NewServeMux() @@ -258,7 +252,20 @@ func TestInstall_TarballExtract(t *testing.T) { srv = httptest.NewServer(mux) t.Cleanup(srv.Close) - r, err := Install(context.Background(), InstallOptions{ManifestURL: srv.URL + "/manifest.json"}) + return srv.URL + "/manifest.json" +} + +func TestInstall_TarballExtract(t *testing.T) { + setRealms(t) + paths, err := Resolve() + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + runedTar := tarGz(t, filepath.Base(paths.RunedBinary), []byte("RUNED")) + mcpTar := tarGz(t, filepath.Base(paths.RuneMCPBinary), []byte("RUNE-MCP")) + + r, err := Install(context.Background(), InstallOptions{ManifestURL: tarballManifestServer(t, runedTar, mcpTar)}) if err != nil { t.Fatalf("Install (tar.gz): %v", err) } @@ -277,3 +284,59 @@ func TestInstall_TarballExtract(t *testing.T) { } } } + +func TestInstall_TarballMissingExpectedFile(t *testing.T) { + setRealms(t) + paths, err := Resolve() + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + runedTar := tarGz(t, filepath.Base(paths.RunedBinary), []byte("RUNED")) + mcpTar := tarGz(t, "not-rune-mcp", []byte("WRONG-NAME")) // valid archive but wrong entry + + r, err := Install(context.Background(), InstallOptions{ManifestURL: tarballManifestServer(t, runedTar, mcpTar)}) + if err == nil { + t.Fatal("expected error when the tarball lacks the expected file") + } + if !strings.Contains(err.Error(), "did not have expected file") { + t.Errorf("err = %v, want 'did not have expected file'", err) + } + if r.Status != "partial" { + t.Errorf("Status=%q, want partial", r.Status) + } + if r.Failed[StepRuneMCP] == "" { + t.Errorf("Failed missing %s: %+v", StepRuneMCP, r.Failed) + } + if fileExists(paths.RuneMCPBinary) { + t.Errorf("%s should not exist when the tarball lacks it", paths.RuneMCPBinary) + } +} + +func TestInstall_CorruptTarball(t *testing.T) { + setRealms(t) + paths, err := Resolve() + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + runedTar := tarGz(t, filepath.Base(paths.RunedBinary), []byte("RUNED")) + corrupt := []byte("this is not a gzip stream") // SHA and size are matched but broken tarball + + r, err := Install(context.Background(), InstallOptions{ManifestURL: tarballManifestServer(t, runedTar, corrupt)}) + if err == nil { + t.Fatal("expected extract error for a corrupt gzip body") + } + if !strings.Contains(err.Error(), "extract") { + t.Errorf("err = %v, want an extract failure", err) + } + if r.Status != "partial" { + t.Errorf("Status=%q, want partial", r.Status) + } + if r.Failed[StepRuneMCP] == "" { + t.Errorf("Failed missing %s: %+v", StepRuneMCP, r.Failed) + } + if fileExists(paths.RuneMCPBinary) { + t.Errorf("%s should not exist after a failed extract", paths.RuneMCPBinary) + } +} diff --git a/internal/supervisor/supervisor_test.go b/internal/supervisor/supervisor_test.go index 4cfc71d..1a4a9f9 100644 --- a/internal/supervisor/supervisor_test.go +++ b/internal/supervisor/supervisor_test.go @@ -190,3 +190,32 @@ func TestWatcher_EscalateSIGKILL(t *testing.T) { t.Fatal("runWatcher hung - SIGKILL escalation did not fire") } } + +func TestWatcher_ForwardSignalToChild(t *testing.T) { + t.Setenv(fakeRunedEnv, "sleep") + cfg := testWatcherConfig(t) + cfg.ShutdownGrace = 2 * time.Second + + done := make(chan error, 1) + go func() { done <- runWatcher(context.Background(), cfg) }() + + time.Sleep(400 * time.Millisecond) + + start := time.Now() + if err := syscall.Kill(os.Getpid(), syscall.SIGTERM); err != nil { + t.Fatalf("send SIGTERM: %v", err) + } + + select { + case err := <-done: + if err != nil { + t.Errorf("runWatcher = %v; want nil when SIGTERM is forwarded and the child exit 0", err) + } + + if elapsed := time.Since(start); elapsed >= cfg.ShutdownGrace { + t.Errorf("took %s (>= grace %s); child should exit by forwarded SIGTERM", elapsed, cfg.ShutdownGrace) + } + case <-time.After(5 * time.Second): + t.Fatal("runWatcher did not return after SIGTERM - signal not forwarded") + } +} From 0f3b3e6543341370ecf62cd8d706f57fc10dd0c7 Mon Sep 17 00:00:00 2001 From: shpark Date: Fri, 12 Jun 2026 09:12:36 +0000 Subject: [PATCH 16/20] feat(ci): run tests on release branches also --- .github/workflows/pr-tests.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-tests.yml b/.github/workflows/pr-tests.yml index 94146b9..a55eda0 100644 --- a/.github/workflows/pr-tests.yml +++ b/.github/workflows/pr-tests.yml @@ -4,9 +4,9 @@ name: PR Tests on: pull_request: - branches: [main] + branches: [main, "release/**"] push: - branches: [main] + branches: [main, "release/**"] permissions: contents: read From 31b5a290468762416e3dbe4b637da9db992f6d8d Mon Sep 17 00:00:00 2001 From: shpark Date: Fri, 12 Jun 2026 09:12:36 +0000 Subject: [PATCH 17/20] feat(ci): run tests on release branches also --- cmd/rune/main_test.go | 2 +- internal/supervisor/supervisor.go | 79 +++++++++++++++++++------- internal/supervisor/supervisor_test.go | 52 +++++++++++++++++ 3 files changed, 110 insertions(+), 23 deletions(-) diff --git a/cmd/rune/main_test.go b/cmd/rune/main_test.go index 306b5bd..e0f23d1 100644 --- a/cmd/rune/main_test.go +++ b/cmd/rune/main_test.go @@ -176,7 +176,7 @@ func TestRunInstall_JSONMissingManifest(t *testing.T) { t.Errorf("exit = %d, want 2", code) } - // Emit stdout as JSON + // Emit stdout as JSON var ev jsonEvent if err := json.Unmarshal(stdout.Bytes(), &ev); err != nil { t.Fatalf("stdout should be a JSON event; got %q (err %v)", stdout.String(), err) diff --git a/internal/supervisor/supervisor.go b/internal/supervisor/supervisor.go index 315a619..9231d08 100644 --- a/internal/supervisor/supervisor.go +++ b/internal/supervisor/supervisor.go @@ -107,6 +107,37 @@ func runWatcher(ctx context.Context, cfg Config) error { var crashes []time.Time backoffIdx := 0 + recordCrash := func(now time.Time) (int, bool) { + crashes = append(crashes, now) + cutoff := now.Add(-cfg.MaxCrashWindow) // crash older than 'now - cfg.MaxCrashWindow' is considered expired + i := 0 + + for i < len(crashes) && crashes[i].Before(cutoff) { + i++ + } + crashes = crashes[i:] // sliding-window + + return len(crashes), len(crashes) >= cfg.MaxCrashes + } + + // sleepBackoff waits the next backoff step; false means shutdown was + // requested while waiting. + sleepBackoff := func(crashCount int) bool { + wait := cfg.BackoffSchedule[min(backoffIdx, len(cfg.BackoffSchedule)-1)] + backoffIdx++ + + fmt.Fprintf(os.Stderr, "supervisor: backing off %s before restart (crash %d of max %d in %s window)\n", wait, crashCount, cfg.MaxCrashes, cfg.MaxCrashWindow) + + select { + case <-time.After(wait): // wait next backoff + return true + case <-ctx.Done(): // shutdown requested + return false + case <-sigCh: // shutdown requested + return false + } + } + for { cmd := exec.Command(cfg.RunedBinary, cfg.RunedArgs...) cmd.Stdin = nil @@ -115,8 +146,20 @@ func runWatcher(ctx context.Context, cfg Config) error { fmt.Fprintf(os.Stderr, "supervisor: starting %s %v\n", cfg.RunedBinary, cfg.RunedArgs) started := time.Now() + + // Share crash budget rather than end up supervision for retriable error if err := cmd.Start(); err != nil { - return fmt.Errorf("supervisor: start %s: %w", cfg.RunedBinary, err) + fmt.Fprintf(os.Stderr, "supervisor: start %s: %v\n", cfg.RunedBinary, err) + + count, giveUp := recordCrash(time.Now()) + if giveUp { + return fmt.Errorf("supervisor: start %s: %w (%d failures within %s - giving up)", cfg.RunedBinary, err, count, cfg.MaxCrashWindow) + } + if !sleepBackoff(count) { + return nil + } + + continue } done := make(chan error, 1) @@ -146,27 +189,11 @@ func runWatcher(ctx context.Context, cfg Config) error { backoffIdx = 0 } - crashes = append(crashes, now) - cutoff := now.Add(-cfg.MaxCrashWindow) // crashes older than now - cfg.MaxCrashWindow are considered expired - i := 0 - for i < len(crashes) && crashes[i].Before(cutoff) { - i++ + count, giveUp := recordCrash(now) + if giveUp { + return fmt.Errorf("supervisor: %d crashes within %s - giving up", count, cfg.MaxCrashWindow) } - crashes = crashes[i:] // sliding-window - - if len(crashes) >= cfg.MaxCrashes { - return fmt.Errorf("supervisor: %d crashes within %s - giving up", len(crashes), cfg.MaxCrashWindow) - } - - wait := cfg.BackoffSchedule[min(backoffIdx, len(cfg.BackoffSchedule)-1)] - backoffIdx++ - fmt.Fprintf(os.Stderr, "supervisor: backing off %s before restart (crash %d of max %d in %s window)\n", wait, len(crashes), cfg.MaxCrashes, cfg.MaxCrashWindow) - select { - case <-time.After(wait): - continue - case <-ctx.Done(): - return nil - case <-sigCh: + if !sleepBackoff(count) { return nil } } @@ -184,8 +211,16 @@ func shutdownChild(cmd *exec.Cmd, grace time.Duration, done <-chan error) error return nil case <-time.After(grace): fmt.Fprintf(os.Stderr, "supervisor: child didn't exit within %s, sending SIGKILL\n", grace) + _ = cmd.Process.Kill() - <-done + + // Also check if child still hasn't died for certain period + select { + case <-done: + case <-time.After(grace): + fmt.Fprintf(os.Stderr, "supervisor: child unresponsive to SIGKILL after %s, abandoning\n", grace) + } + return nil } } diff --git a/internal/supervisor/supervisor_test.go b/internal/supervisor/supervisor_test.go index 1a4a9f9..356c09c 100644 --- a/internal/supervisor/supervisor_test.go +++ b/internal/supervisor/supervisor_test.go @@ -4,9 +4,12 @@ package supervisor import ( "context" + "errors" "fmt" "os" + "os/exec" "os/signal" + "path/filepath" "strings" "sync/atomic" "syscall" @@ -219,3 +222,52 @@ func TestWatcher_ForwardSignalToChild(t *testing.T) { t.Fatal("runWatcher did not return after SIGTERM - signal not forwarded") } } + +func TestWatcher_RetriesStartFailure(t *testing.T) { + cfg := testWatcherConfig(t) + cfg.RunedBinary = filepath.Join(t.TempDir(), "no-such-runed") + cfg.MaxCrashes = 3 + + err := runWatcher(context.Background(), cfg) + if err == nil { + t.Fatal("runWatcher should give up after MaxCrashes start failures") + } + if !strings.Contains(err.Error(), "giving up") { + t.Errorf("error: got %q, want substring 'giving up'", err.Error()) + } + if !strings.Contains(err.Error(), fmt.Sprintf("%d failures", cfg.MaxCrashes)) { + t.Errorf("error: got %q, want %d retried attempts before giving up", err.Error(), cfg.MaxCrashes) + } + if !errors.Is(err, os.ErrNotExist) { + t.Errorf("error: got %v, want wrapped os.ErrNotExist from the failed Start", err) + } +} + +func TestShutdownChild_BoundedAfterSIGKILL(t *testing.T) { + t.Setenv(fakeRunedEnv, "ignore_sigterm") + cmd := exec.Command(os.Args[0]) + + if err := cmd.Start(); err != nil { + t.Fatalf("start fake: %v", err) + } + defer func() { + _ = cmd.Process.Kill() + _, _ = cmd.Process.Wait() + }() + + done := make(chan error) // no signal + + grace := 200 * time.Millisecond + start := time.Now() + if err := shutdownChild(cmd, grace, done); err != nil { + t.Errorf("shutdownChild = %v, want nil", err) + } + + elapsed := time.Since(start) + if elapsed < 2*grace { + t.Errorf("returned in %s; want >= %s (SIGTERM grace + bounded kill-wait)", elapsed, 2*grace) + } + if elapsed > 2*grace+2*time.Second { + t.Errorf("took %s; should wait after SIGKILL sended", elapsed) + } +} From 1a65558c3fd7043b2bec33e133be89b66727b71a Mon Sep 17 00:00:00 2001 From: shpark Date: Mon, 15 Jun 2026 07:56:17 +0000 Subject: [PATCH 18/20] fix(bootstrap): repair corrupted/staled binaries on install --- internal/bootstrap/audit.go | 9 +- internal/bootstrap/install.go | 49 +++++++- internal/bootstrap/install_test.go | 180 +++++++++++++++++++++++++++++ 3 files changed, 231 insertions(+), 7 deletions(-) diff --git a/internal/bootstrap/audit.go b/internal/bootstrap/audit.go index b0ddecf..3f78b17 100644 --- a/internal/bootstrap/audit.go +++ b/internal/bootstrap/audit.go @@ -21,10 +21,11 @@ type InstalledManifest struct { } type InstalledArtifact struct { - URL string `json:"url"` - SHA256 string `json:"sha256"` - Path string `json:"path"` - Size int64 `json:"size,omitempty"` + URL string `json:"url"` + SHA256 string `json:"sha256"` // manifest spec hash (the archive hash for a tar.gz artifact) + DestSHA256 string `json:"dest_sha256,omitempty"` // installed raw binary sha256 (extracted file's hash for tarball) + Path string `json:"path"` + Size int64 `json:"size,omitempty"` } func WriteInstalledManifest(paths *Paths, manifestURL string, manifest *Manifest, artifacts map[string]InstalledArtifact) error { diff --git a/internal/bootstrap/install.go b/internal/bootstrap/install.go index 986d1e6..0086ffd 100644 --- a/internal/bootstrap/install.go +++ b/internal/bootstrap/install.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "slices" + "strings" "time" ) @@ -113,16 +114,24 @@ func Install(ctx context.Context, opts InstallOptions) (*Result, error) { }) } + // Get previous installed info + recordedDestSHA := map[string]string{} + if prior, perr := ReadInstalledManifest(paths); perr == nil && prior != nil { + for step, a := range prior.Artifacts { + recordedDestSHA[step] = a.DestSHA256 + } + } + for i, in := range installs { stepNum := i + 2 // step 1: manifest - if !opts.Force { - if fileExists(in.dest) { - logf("[%d/%d] %s: skipped (already at %s)", stepNum, total, in.step, in.dest) + if !opts.Force && fileExists(in.dest) { + if skipExistingArtifact(in.step, in.spec, in.dest, recordedDestSHA[in.step], stepNum, total, logf) { r.Completed = append(r.Completed, in.step) r.Skipped = append(r.Skipped, in.step) continue } + // Corrupted or staled binary (SHA mismatch / unverifiable): re-install } logf("[%d/%d] %s (%d bytes): downloading...", stepNum, total, in.step, in.spec.Size) @@ -154,6 +163,16 @@ func Install(ctx context.Context, opts InstallOptions) (*Result, error) { entry.Size = info.Size() } + entry.DestSHA256 = in.spec.SHA256 // record on-disk binary hash for later verification + if in.spec.Extract != "" { + if h, hErr := FileSHA256(in.dest); hErr == nil { + entry.DestSHA256 = h + } else { + entry.DestSHA256 = "" // leave as unverified + logf("warning: cannot hash %s for audit: %v", in.dest, hErr) + } + } + auditArtifacts[in.step] = entry } @@ -242,6 +261,30 @@ func installArtifact(ctx context.Context, paths *Paths, spec ArtifactSpec, dest } } +func skipExistingArtifact(step string, spec ArtifactSpec, dest, recordedDestSHA string, stepNum, total int, logf func(string, ...any)) bool { + expected, ref := spec.SHA256, "manifest" // installed from raw binary + if spec.Extract != "" { + expected, ref = recordedDestSHA, "installed.json" // installed from tarball artifact + } + if expected == "" { + logf("[%d/%d] %s: skipped (already at %s; no recorded sha256 to verify)", stepNum, total, step, dest) + return true + } + + got, err := FileSHA256(dest) + switch { + case err != nil: + logf("[%d/%d] %s: cannot verify existing %s (%v); re-downloading", stepNum, total, step, dest, err) + return false + case !strings.EqualFold(got, expected): + logf("[%d/%d] %s: existing %s failed sha256 check vs %s (got %s, want %s); re-downloading", stepNum, total, step, dest, ref, got, expected) + return false + default: + logf("[%d/%d] %s: skipped (already at %s, sha256 verified vs %s)", stepNum, total, step, dest, ref) + return true + } +} + func probeSocket(path string) bool { conn, err := net.DialTimeout("unix", path, 200*time.Millisecond) if err != nil { diff --git a/internal/bootstrap/install_test.go b/internal/bootstrap/install_test.go index fa45132..d347420 100644 --- a/internal/bootstrap/install_test.go +++ b/internal/bootstrap/install_test.go @@ -164,6 +164,186 @@ func TestInstall_Idempotent_SkipsExistingBinaries(t *testing.T) { } } +func TestInstall_RepairCorruptBinary(t *testing.T) { + rune, _ := setRealms(t) + fx := newFixture(t) + + if _, err := Install(context.Background(), InstallOptions{ManifestURL: fx.manifestURL()}); err != nil { + t.Fatalf("first install: %v", err) + } + + // Simulate partial/stale rune-mcp + mcpPath := filepath.Join(rune, "bin", "rune-mcp") + if err := os.WriteFile(mcpPath, []byte("corrupt-truncated"), 0o755); err != nil { + t.Fatalf("corrupt rune-mcp: %v", err) + } + beforeMCP, beforeRuned := fx.hits["/rune-mcp"], fx.hits["/runed"] + + // Non-force install detect sha mismatch and repair it + r, err := Install(context.Background(), InstallOptions{ManifestURL: fx.manifestURL()}) + if err != nil { + t.Fatalf("repair install: %v", err) + } + if !r.OK { + t.Errorf("Result.OK = false, want true (r=%+v)", r) + } + + if fx.hits["/rune-mcp"] != beforeMCP+1 { + t.Errorf("rune-mcp not re-downloaded: hits=%d, want %d", fx.hits["/rune-mcp"], beforeMCP+1) + } + if got, _ := os.ReadFile(mcpPath); string(got) != string(fx.runeMCP) { + t.Errorf("rune-mcp not repaired: on-disk=%q, want %q", got, fx.runeMCP) + } + + // Skip healthy runed binary + if fx.hits["/runed"] != beforeRuned { + t.Errorf("healthy runed re-downloaded: hits=%d, want %d", fx.hits["/runed"], beforeRuned) + } + + skipped := map[string]bool{} + for _, s := range r.Skipped { + skipped[s] = true + } + if !skipped[StepRuned] { + t.Errorf("Skipped missing %s: %v", StepRuned, r.Skipped) + } + if skipped[StepRuneMCP] { + t.Errorf("corrupt %s should have been repaired, but not skipped: %v", StepRuneMCP, r.Skipped) + } +} + +func TestInstall_SkipVerifiedTarball(t *testing.T) { + setRealms(t) + paths, err := Resolve() + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + runedTar := tarGz(t, filepath.Base(paths.RunedBinary), []byte("RUNED")) + mcpTar := tarGz(t, filepath.Base(paths.RuneMCPBinary), []byte("RUNE-MCP")) + url := tarballManifestServer(t, runedTar, mcpTar) + + if _, err := Install(context.Background(), InstallOptions{ManifestURL: url}); err != nil { + t.Fatalf("first install: %v", err) + } + + r, err := Install(context.Background(), InstallOptions{ManifestURL: url}) + if err != nil { + t.Fatalf("second install: %v", err) + } + if r.Status != "no_op" { + t.Errorf("Status=%q, want no_op (both tar.gz artifacts verified and skipped)", r.Status) + } + if len(r.Installed) != 0 { + t.Errorf("Installed=%v, want empty (nothing re-extracted)", r.Installed) + } +} + +func TestInstall_RepairCorruptTarball(t *testing.T) { + setRealms(t) + paths, err := Resolve() + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + runedTar := tarGz(t, filepath.Base(paths.RunedBinary), []byte("RUNED")) + mcpTar := tarGz(t, filepath.Base(paths.RuneMCPBinary), []byte("RUNE-MCP")) + url := tarballManifestServer(t, runedTar, mcpTar) + + if _, err := Install(context.Background(), InstallOptions{ManifestURL: url}); err != nil { + t.Fatalf("first install: %v", err) + } + + // Make extracted runed binary as corrupted + if err := os.WriteFile(paths.RunedBinary, []byte("corrupt"), 0o755); err != nil { + t.Fatalf("corrupt runed: %v", err) + } + + r, err := Install(context.Background(), InstallOptions{ManifestURL: url}) + if err != nil { + t.Fatalf("repair install: %v", err) + } + if !r.OK { + t.Errorf("Result.OK = false, want true (r=%+v)", r) + } + + // Corrupted runed should be re-installed + if got, _ := os.ReadFile(paths.RunedBinary); string(got) != "RUNED" { + t.Errorf("runed not repaired: on-disk=%q, want %q", got, "RUNED") + } + + skipped := map[string]bool{} + for _, s := range r.Skipped { + skipped[s] = true + } + if skipped[StepRuned] { + t.Errorf("corrupt %s should have been repaired, but skipped: %v", StepRuned, r.Skipped) + } + if !skipped[StepRuneMCP] { + t.Errorf("healthy %s should be skipped: %v", StepRuneMCP, r.Skipped) + } +} + +func TestInstall_TarballNoRecordedHash(t *testing.T) { + // tar.gz repair is conditional on a recorded dest hash. When installed.json + // has no hash for the step (a prior audit that couldn't hash the extracted + // file, or a missing/partial record), the skip path degrades to + // existence-only: a corrupt binary is reused, not repaired. This pins that + // documented fallback so it can't silently change. + setRealms(t) + paths, err := Resolve() + if err != nil { + t.Fatalf("Resolve: %v", err) + } + + runedTar := tarGz(t, filepath.Base(paths.RunedBinary), []byte("RUNED")) + mcpTar := tarGz(t, filepath.Base(paths.RuneMCPBinary), []byte("RUNE-MCP")) + url := tarballManifestServer(t, runedTar, mcpTar) + + if _, err := Install(context.Background(), InstallOptions{ManifestURL: url}); err != nil { + t.Fatalf("first install: %v", err) + } + + rec, err := ReadInstalledManifest(paths) + if err != nil { + t.Fatalf("read installed.json: %v", err) + } + + a := rec.Artifacts[StepRuned] + a.DestSHA256 = "" // extracted runed binary is not hashed + rec.Artifacts[StepRuned] = a + + data, err := json.MarshalIndent(rec, "", " ") + if err != nil { + t.Fatalf("marshal installed.json: %v", err) + } + if err := os.WriteFile(paths.InstalledManifest, data, 0o600); err != nil { + t.Fatalf("rewrite installed.json: %v", err) + } + + // Make runed as corrupted + if err := os.WriteFile(paths.RunedBinary, []byte("corrupt"), 0o755); err != nil { + t.Fatalf("corrupt runed: %v", err) + } + + r, err := Install(context.Background(), InstallOptions{ManifestURL: url}) + if err != nil { + t.Fatalf("install: %v", err) + } + + // 'runed' is not repaired if hash is not recorded + skipped := map[string]bool{} + for _, s := range r.Skipped { + skipped[s] = true + } + if !skipped[StepRuned] { + t.Errorf("with no recorded hash, runed re-install should be skipped; Skipped=%v", r.Skipped) + } + if got, _ := os.ReadFile(paths.RunedBinary); string(got) != "corrupt" { + t.Errorf("repair should be skipped; runed on-disk=%q, want %q", got, "corrupt") + } +} + func TestInstall_Force_ReDownloadsAllBinaries(t *testing.T) { setRealms(t) fx := newFixture(t) From 85ee009f75ad99d449438a3fddef50cc24cae808 Mon Sep 17 00:00:00 2001 From: Jeonghwan Lee Date: Tue, 16 Jun 2026 15:58:18 +0900 Subject: [PATCH 19/20] docs: remove internal v0.4 Go migration design docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove docs/v04/README.md and docs/v04/onboarding.md, which are internal design/onboarding notes for the Python→Go MCP port and not intended to ship in the release. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/v04/README.md | 143 ------------------------- docs/v04/onboarding.md | 234 ----------------------------------------- 2 files changed, 377 deletions(-) delete mode 100644 docs/v04/README.md delete mode 100644 docs/v04/onboarding.md diff --git a/docs/v04/README.md b/docs/v04/README.md deleted file mode 100644 index e06efe7..0000000 --- a/docs/v04/README.md +++ /dev/null @@ -1,143 +0,0 @@ -# Rune v0.4.0 — Go 전환 설계 문서 - -2026-04 기준 Python MCP → Go MCP 포팅 설계 문서. Python 코드베이스(`mcp/`, `agents/`)를 기반으로 Go 구현을 위한 계약·결정·흐름을 정리한다. - -**이 디렉토리가 단일 진실 소스**. 이전 `docs/migration/` · `docs/runed/` 문서는 히스토리·배경 자료로만 참조. - -## 핵심 아키텍처 - -Python의 "세션당 MCP 프로세스에 embedding model까지 포함" 구조가 갖는 **모델 메모리 중복 문제**만 제거하고 나머지는 Python 구조에 가깝게 유지: - -- **`rune-mcp`** (이 프로젝트): 세션당 1개. stdio JSON-RPC. Python MCP를 Go로 포팅 (임베딩 제외) -- **`embedder`** (별도 프로세스, 가칭): 머신당 1개 상주. 임베딩 모델 전담. gRPC over unix socket -- **Vault · envector**: 각 MCP가 독립적으로 gRPC 연결 - -`embedder`는 외부 컴포넌트. rune-mcp는 **gRPC 클라이언트로만** 사용. - -> **네이밍 히스토리**: 초기 설계에서 "runed" (Python MCP 대체 + 임베딩 내장 통합 데몬)을 구상했으나 폐기. 현재는 **rune-mcp (Go 포팅) + embedder (임베딩 별도)** 구조. - -## 디렉토리 구조 - -``` -docs/v04/ -├── README.md # 이 파일 (index · role별 reading order) -│ -├── overview/ # 사람용 — Why & What -│ ├── architecture.md # 3-프로세스 구조 · 메모리 모델 · 원칙 · 상태머신 -│ ├── decisions.md # D1-D30 결정 트래커 (배경·선택지·근거·재평가) -│ └── open-questions.md # Q1-Q9 미결 사항 -│ -├── spec/ # 개발자용 — How (구현 계약) -│ ├── types.md # 🔑 DecisionRecord v2.1 · 8 enum · I/O schemas (단일 진실 소스) -│ ├── flows/ # Phase 단위 end-to-end 로직 + Go pseudocode -│ │ ├── capture.md # 7-phase capture -│ │ ├── recall.md # 7-phase recall -│ │ └── lifecycle.md # 6 tool (vault_status · diagnostics · batch_capture · …) -│ ├── components/ # 컴포넌트 계약 · 패키지 구조 · gRPC client 구현 -│ │ ├── rune-mcp.md # 세션별 MCP 바이너리 (메인) -│ │ ├── embedder.md # 외부 embedder gRPC 클라이언트 -│ │ ├── vault.md # Vault gRPC 클라이언트 -│ │ └── envector.md # envector-go SDK -│ └── python-mapping.md # Python 파일/LoC → Go 구조 매핑 -│ -├── notes/ # 내부 작업 노트 (참고용) -│ ├── verification-matrix.md # Python↔Go bit-identical 대조 검증 로그 -│ ├── implementability-report.md # Go 개발자 진입 가능성 검증 리포트 -│ └── flow-matrix.md # 10 flow × 파일 매트릭스 + Tier S/A/B 공통 모듈 -│ -└── progress/ # 실제 개발 진행 추적 (vertical slice 단위) - ├── README.md # 인덱스 + 마일스톤별 상태표 - └── phase-a-mcp-boot.md # MCP handshake + tools/list (`19b7bf6`) -``` - -## 읽는 순서 - -### 🧑‍💼 **처음 보는 사람 / 리뷰어** (overview만 읽어도 충분) - -1. 이 README → 전체 요약 -2. `overview/architecture.md` → 왜·무엇을·어떻게 (narrative) -3. `overview/decisions.md` → 결정 히스토리·대안 근거 -4. `overview/open-questions.md` → 아직 결정 안 된 것들 - -### 👨‍💻 **Go로 구현할 개발자** - -**새 개발자라면 먼저 [`onboarding.md`](onboarding.md)** — 10분 안에 첫 PR 위치까지 안내 (환경 셋업 · 읽기 순서 · Phase별 starter task · 컨벤션 · FAQ). - -그 다음 (필요 시점에): - -1. `overview/architecture.md` → 맥락 + **§Scope** (agent-delegated only 전제가 프로젝트 전반을 좌우) -2. [`../../internal/README.md`](../../internal/README.md) → 패키지 지도 (Go 파일 ↔ spec ↔ Python 원본 매핑표) -3. `spec/types.md` → 모든 도메인 타입·enum·I/O schema (항상 옆에 두기) -4. `spec/python-mapping.md` → Python 파일 → Go 패키지 LoC 매핑 -5. `spec/flows/{capture,recall,lifecycle}.md` → 담당 Phase에 해당하는 7-phase 흐름 -6. `spec/components/*.md` → 각 컴포넌트 gRPC 계약·패키지 구조 -7. 헷갈리면 `overview/decisions.md` D_N 참조 (spec 문서들이 D_N으로 link) - -### 🔍 **검증 상태 확인하고 싶은 사람** - -- `notes/verification-matrix.md` — 28 상수 · Phase 순서 · 함수 라인 전수 대조 결과 -- `notes/implementability-report.md` — "docs만으로 Go 구현 가능한가" 검증 - -## overview vs spec vs notes - -| 유형 | 누가 읽나 | 성격 | -|---|---|---| -| **overview/** | PM, 아키텍트, 신규 참여자 | narrative prose, "왜"에 집중, 결정 히스토리 | -| **spec/** | Go 개발자 | 구체 인터페이스·pseudocode·상수·에러 매핑, "어떻게"에 집중 | -| **notes/** | 작성자·검증자 | 작업 중 발견·검증 로그, 참고용 | - -**`overview/decisions.md` vs `overview/open-questions.md` 구분**: -- `decisions.md` — 모든 결정 트래커 (가벼운 구현 선택부터 중대 결정까지). 상태 마커 (Blocking/Pending/Deferred/Decided/Archived)로 무게 구분 -- `open-questions.md` — 아직 "결정 후보"로 정리 안 된 조사 중 항목 - -## 상태 (2026-04-22) - -| 영역 | 상태 | -|---|---| -| 아키텍처 방향 | ✅ 결정됨 (세션별 rune-mcp + 외부 embedder) | -| rune-mcp 설계 (7-phase) | 🟢 완료 (`spec/flows/*.md`) | -| embedder gRPC 통합 | ✅ 확정 (D30) | -| Vault 연동 | ✅ 기존 Python 구조 유지 | -| envector 연동 | 🟡 SDK 조건 완화 PR 대기 (Q4) | -| AES-MAC envelope | 🔵 Deferred Post-MVP (Q1) | -| Python↔Go 대조 검증 | ✅ 완료 (`notes/verification-matrix.md`) | -| Go 구현 진입 가능성 | 🟢 Ready (`notes/implementability-report.md`, P0 blocker 0건) | -| Go skeleton | ✅ 완료 (`2eb167d`, 37 파일, stdlib-only compile) | -| 개발자 onboarding 가이드 | ✅ 완료 (`onboarding.md` · `internal/README.md`) | - -## 구현 로드맵 - -Skeleton(`2eb167d`) 이후 단계. 각 Phase는 **별도 PR** 단위이며, 이전 Phase가 완료되기 전에 다음 Phase로 진입하지 않는다 (하위 → 상위 build-order 존중). - -| Phase | 범위 | 비고 | -|---|---|---| -| 1 | 외부 의존성 추가 | `modelcontextprotocol/go-sdk` v1.5+, `google.golang.org/grpc`, `google.golang.org/protobuf`, envector-go SDK(Q4 PR 머지 후 연결), embedder proto stub import | -| 2 | `internal/domain/` + `internal/policy/` 순수 로직 | `ParseDomain` 19-enum map + `customer_escalation` alias · 상수·regex 이식 (81 stopwords · 31 intent · 16 time · 4 tech) · `GenerateRecordID` unicode slug · `ClassifyNovelty` · `ApplyRecencyWeighting` · `FilterByTime` · golden fixture 테스트 harness | -| 3 | `record_builder` (703 LoC) + `payload_text` (364 LoC) 라인 단위 포팅 | D13 Option A · D15 canonical. 5 SENSITIVE + 4 QUOTE + 5 RATIONALE regex, `_parse_domain` 19-map + alias, `ensure_evidence_certainty_consistency` → `render_payload_text` 순서. `testdata/` 50+ 샘플로 byte-identical 검증 (Python 원본이 canonical source) | -| 4 | `internal/adapters/` 실제 클라이언트 | Vault gRPC (3 RPC + keepalive + 256MB msg + TLS + health 2-tier + endpoint 4-form 정규화) · envector SDK + AES envelope Seal/Open (AES-256-CTR, 16B IV) · embedder gRPC + Info `sync.Once` 캐시 + D7 retry · capture_log `flock` append + 역순 `Tail` | -| 5 | `internal/service/` orchestration | 7-phase capture (novelty non-fatal, D17 atomicity probe) · 7-phase recall (D25 순차, D26 Vault 위임 decrypt, D27 phase_chain expansion) · 6 lifecycle tools · Vault/envector 실패 시 `state=dormant` 전환 side-effect | -| 6 | `internal/mcp/` SDK 연결 | 공식 Go SDK로 8 tool 등록 · stdio transport · `Deps` 주입 · adapter error → `domain.RuneError` → MCP response wrap · state-specific recovery hints | -| 7 | 검증 | policy unit (golden fixture byte-identical) · bufconn Vault integration · libevi/mock backend envector contract tests · `synctest` (Go 1.25) boot retry 결정적 테스트 · Python ↔ Go shadow run (cutover 리스크 완화) | - -**우선순위 결정 팁**: -- Phase 2·3 완료 전에는 외부 의존성(Phase 1) 없이도 순수 로직 테스트 가능 → 빠른 confidence 구축 -- ~~Phase 4 envector SDK 조건 완화 PR(Q4) 블로킹~~: ✅ Resolved — SDK가 `WithKeyParts(KeyPartEnc, KeyPartEval)`로 정식 지원 -- Phase 5·6는 adapter interface 확정 후 진입. 미리 시작하면 signature 변경 비용 큼 -- Phase 7 shadow run은 Post-MVP에도 유지 — 장기 품질 보증 수단 - -## 이전 문서와의 관계 - -- 기존 `docs/migration/python-go-comparison.html` — 이전 방향(단일 데몬) 기준. 일부 충돌 -- 기존 `docs/runed/` — 폐기된 "runed" **통합 데몬** 설계. 히스토리 보존 (현재 `runed`는 임베딩 전담 데몬의 정식 이름으로 의미 좁혀져 부활 — `github.com/CryptoLabInc/runed`) -- 이 `docs/v04/`가 권위 있는 설계 문서 - -## 용어 - -- **rune-mcp**: Python MCP를 Go로 포팅한 세션별 바이너리 (임베딩 제외). **본 프로젝트 산출물** -- **embedder**: 임베딩 모델 호스팅 외부 gRPC 프로세스의 **역할명**. rune-mcp는 gRPC 클라이언트로만 사용. 구체 구현은 `runed` (아래) -- **runed**: 임베딩 전담 데몬의 **정식 프로젝트 이름** (`github.com/CryptoLabInc/runed`). gRPC over UDS (`~/.runed/embedding.sock`). ⚠️ 초기 설계의 "통합 데몬" 의미는 폐기, 임베딩 전담 의미로 부활 -- **Vault**: `rune-Vault` gRPC 서비스. FHE 키 관리 + 복호화 (`GetPublicKey`/`DecryptScores`/`DecryptMetadata`) -- **envector**: enVector Cloud. FHE 벡터 저장·검색 (`Insert`/`Score`/`GetMetadata`) -- **agent_dek**: 에이전트별 AES-256 DEK. metadata envelope 암호화용. Vault가 배포, rune-mcp 메모리에만 -- **Vault-delegated 보안 모델**: SecKey는 Vault만 보유. rune-mcp는 EncKey + EvalKey만 로컬. 복호화는 Vault RPC 경유 -- **agent-delegated**: 에이전트(Claude Code 등)가 extraction·판정을 수행하고 rune-mcp는 저장·검색 파이프라인만 담당 (D14/D21/D28) diff --git a/docs/v04/onboarding.md b/docs/v04/onboarding.md deleted file mode 100644 index 47cd0b3..0000000 --- a/docs/v04/onboarding.md +++ /dev/null @@ -1,234 +0,0 @@ -# Onboarding — rune-mcp Go 개발자 진입 가이드 - -**목표**: 이 문서를 10분 읽으면, 어디를 보고 어디를 고쳐야 할지 판단하고 첫 PR 위치까지 도달. - -이 문서는 `docs/v04/`의 모든 세부 문서를 대체하지 않는다. **네비게이션 맵**이다. - ---- - -## 사전 조건 - -- Go 1.24+ (`go version`로 확인) -- Git + 이 repo 접근 권한 -- Python rune 코드베이스에 대한 최소한의 친숙함 (or 의지) — **Python이 canonical source**라 계속 참조 - ---- - -## 10분 루트 - -### 0~3분: 컨텍스트 - -1. **[docs/v04/README.md](README.md)** — 핵심 아키텍처(3-프로세스 모델 rune-mcp / embedder / Vault+envector) + 현재 상태 (1분) -2. **[docs/v04/overview/architecture.md §Scope](overview/architecture.md#scope-sot--agent-delegated-only)** — 가장 중요. **agent-delegated path only**가 프로젝트 전제다. legacy LLM 경로(detector/tier2_filter/llm_extractor/synthesizer/auto-provider) 전부 **scope 밖** (2분) - -### 3~7분: 네비게이션 - -3. **[internal/README.md](../../internal/README.md)** — 패키지 지도. 어느 Go 파일이 어느 spec / Python 원본에 매핑되는지 (2분) -4. **`go build ./...`** — 로컬에서 컴파일 확인 (stdlib-only 상태, 외부 deps 없이 통과) (1분) -5. **[docs/v04/README.md §구현 로드맵](README.md#구현-로드맵)** — 7 Phase 중 본인이 시작할 Phase 선택 (1분) - -### 7~10분: 첫 target 선택 - -6. 아래 [§첫 PR 추천](#첫-pr-추천-phase별-starter-task)에서 작은 task 하나 집기 -7. 해당 파일 열어 TODO 주석 확인 → Python 원본 파일 라인 확인 → 브랜치 생성 - -이 시점에 **어디를 고치면 되는지 명확**해야 한다. 그렇지 않으면 본 가이드의 § 중 놓친 링크가 있는 것. 뒤로 돌아가서 재확인. - ---- - -## 환경 셋업 - -### IDE - -- **VS Code + Go extension** (권장): `gopls` 자동 설치 -- **GoLand**: 상용, 풀 기능 -- 공통: `golangci-lint` 설치 권장 (`brew install golangci-lint` 또는 `go install`) - -### Python 참조용 - -Python rune 코드는 같은 repo 안에 있다 (`./mcp/`, `./agents/`). 별도 checkout 불필요. -- IDE에서 Python 파일을 옆 탭에 열어두고 같이 보는 습관 -- Go 파일 주석에 `Python: file.py:L` 라인 참조가 있음 → 바로 점프 - -### 빌드·검사 명령 - -```bash -go build ./... # 모든 패키지 빌드. Phase 0 baseline -go vet ./... # 표준 정적 분석 -gofmt -l . # 포맷 체크 (출력 없으면 OK) -golangci-lint run # 린터 (설치됐다면) -go test ./... # 테스트 (Phase 2 이후부터 실효성) -``` - ---- - -## 문서 읽기 순서 - -### 반드시 먼저 (진입 순서) - -1. [docs/v04/README.md](README.md) — 아키텍처 · 상태 · 구현 로드맵 -2. [docs/v04/overview/architecture.md §Scope](overview/architecture.md#scope-sot--agent-delegated-only) — **agent-delegated only** 전제 -3. [internal/README.md](../../internal/README.md) — 패키지 지도 - -### Phase 시작 전 (해당 Phase만 읽어도 됨) - -| Phase | 필수 읽기 | -|---|---| -| **2** (domain/policy 구현) | [spec/types.md](spec/types.md) · [decisions.md](overview/decisions.md) D11 / D21 / D22 / D23 | -| **3** (record_builder + payload_text 포팅) | [spec/flows/capture.md](spec/flows/capture.md) Phase 3 canonical section · [decisions.md](overview/decisions.md) D13 / D14 / D15 · `agents/common/schemas/templates.py` (**canonical**) · `agents/scribe/record_builder.py` | -| **4** (adapter 실 클라이언트) | [spec/components/vault.md](spec/components/vault.md) · [envector.md](spec/components/envector.md) · [embedder.md](spec/components/embedder.md) · [decisions.md](overview/decisions.md) D26 / D30 | -| **5** (service orchestration) | [spec/flows/capture.md](spec/flows/capture.md) · [recall.md](spec/flows/recall.md) · [lifecycle.md](spec/flows/lifecycle.md) | -| **6** (MCP SDK 연결) | [spec/components/rune-mcp.md §MCP 서버 구현](spec/components/rune-mcp.md) · [decisions.md](overview/decisions.md) D2 | - -### 필요할 때 참조 (lookup 용도) - -- 결정 근거: [overview/decisions.md](overview/decisions.md) — D1-D32. 왜 이렇게 했는지 의심될 때 -- 미결 사항: [overview/open-questions.md](overview/open-questions.md) — Q1 (AES-MAC) · Q4 (envector-go SDK PR) -- 검증 로그: [notes/verification-matrix.md](notes/verification-matrix.md) · [python-parity-final.md](notes/python-parity-final.md) -- Python 파일 매핑: [spec/python-mapping.md](spec/python-mapping.md) - ---- - -## 첫 PR 추천 (Phase별 starter task) - -**원칙**: 파일 1-2개, 50-150줄 변경, 테스트 포함. 첫 PR로 patterns 익히기. - -### Phase 2 — 가장 쉬움 - -**🟢 추천 A**: `internal/domain/schema.go:ParseDomain` 19-enum map 구현 -- 현재 상태: stub (`return DomainGeneral`) -- Python 참조: `agents/scribe/record_builder.py:L621-655 _parse_domain` (+ L646 `customer_escalation → CUSTOMER_SUCCESS` alias) -- 작업량: ~30줄 Go + 20줄 테이블 테스트 -- 학습 포인트: Python bit-identical 포팅 패턴 첫 체험 - -**🟢 추천 B**: `internal/policy/novelty.go:ClassifyNovelty` 구현 -- 현재 상태: stub (`return NoveltyClassNovel, 1.0`) -- Python 참조: `agents/common/schemas/embedding.py:L33-56 classify_novelty` -- 작업량: ~20줄 + 테이블 테스트 (4 class boundary) -- 학습 포인트: **score inverted** (1.0 - max_sim) + round(4) 주의 - -**🟢 추천 C**: `internal/domain/schema.go:GenerateRecordID` 테스트 추가 -- 현재 상태: **구현 완료** (테스트 없음) -- 테스트 케이스: unicode (한글) · alphanumeric only · empty title · punctuation mixed -- 작업량: ~40줄 테스트 (Python bit-identical 결과와 비교) -- 학습 포인트: `isPyIsalnum` unicode 동작 확인 — Python 결과 golden으로 비교 - -**🟢 추천 D**: `internal/domain/errors.go:MakeError` 구현 -- 현재 상태: stub (`return nil`) -- Python 참조: `mcp/server/errors.py:L93-118 make_error` -- 작업량: ~30줄 + 테스트 -- 학습 포인트: `errors.As` type assertion 패턴 - -### Phase 3 — regex 포팅 입문 - -**🟡 추천**: `internal/policy/pii.go:RedactSensitive` 구현 -- 현재 상태: stub (truncate만 함) -- Python 참조: `agents/scribe/record_builder.py:L89-95` (5 regex) + `L406-418 _redact_sensitive` + `L227 MAX_INPUT_CHARS=12_000` -- 작업량: ~50줄 Go (5 regex 컴파일 + 치환) + golden fixture 테스트 (redact 전후 텍스트 쌍) -- 학습 포인트: `regexp.MustCompile` + `ReplaceAllString` + unicode 고려 - -### Phase 4 — adapter 부분 완성 - -**🟡 추천**: `internal/adapters/vault/endpoint.go:NormalizeEndpoint` 테스트 + 엣지 케이스 -- 현재 상태: **부분 구현** (4 형식 basic 처리) -- Python 참조: `mcp/adapter/vault_client.py:L116-140 _derive_grpc_target` -- 작업량: ~40줄 테스트 (env var override · trailing slash · IPv6 brackets 등) -- 학습 포인트: URL parsing edge case + Python 동작 정확 매칭 - -### 큰 task (Phase 3 본격 진입) - -**🔴 heavy**: `internal/policy/payload_text.go:RenderPayloadText` 전체 포팅 -- Python 참조: `agents/common/schemas/templates.py` (364 LoC, **canonical**) -- 작업량: ~250줄 Go + 50+ golden fixture -- **주의 사항**: phase_line post-insertion + blank line collapse + `_format_alternatives` bug 유지 (bit-identical) — [decisions.md D15 포팅 주의사항](overview/decisions.md) 참조 -- 학습 포인트: 대형 canonical port. 본격 들어가기 전에 팀과 먼저 논의 - ---- - -## 코드 컨벤션 - -### Python bit-identical 원칙 - -- 포팅 대상 함수 위에 `// Python: .py:L-` 형식 주석 (skeleton에 이미 들어가 있음) -- 상수값은 Python 동일 (novelty `0.3/0.7/0.95` · half-life `90일` · title `60자` · `MAX_INPUT_CHARS=12_000` 등) -- 검증은 `testdata/golden/` 의 JSON/MD 파일로 byte-identical 비교 - -### TODO 관리 - -- 스켈레톤의 기존 TODO는 구현 시 **제거** -- 새 TODO 포맷: `// TODO(): <무엇> — <참조>` - - 예: `// TODO(Phase 3): implement _extract_evidence — record_builder.py:L498-531` -- 장기 유보 항목은 TODO 대신 decisions.md에 D_N 항목으로 - -### 테스트 - -- 순수 함수(domain/, policy/)는 Go 표준 `_test.go` + 테이블 테스트 -- Python ↔ Go bit-identical이 필요한 경우 `testdata/golden/` 고정 후 byte-compare -- Python 측 골든 생성 스크립트는 향후 `scripts/gen_golden.py` 추가 예정 (없으면 해당 PR에서 같이 추가) - -### 디렉토리 간 경계 (internal/README.md §패키지 의존 방향 참조) - -- `internal/domain`은 leaf. 다른 `internal/*` 패키지 import 금지 (stdlib만) -- `internal/policy`는 I/O 없음. `adapters/` 호출 금지 -- Adapter error는 service 레이어에서만 `domain.RuneError` 로 wrap - -### 커밋 메시지 - -Conventional Commits 스타일: -- `feat(go): ...` — 새 기능 -- `fix(go): ...` — 버그 수정 -- `test(go): ...` — 테스트만 추가 -- `docs(v04): ...` — 문서 변경 -- `refactor(go): ...` — 동작 변경 없이 구조만 - -본문에 **Python 원본 파일:라인 참조** + **영향 받는 spec 문서** 언급 권장. - -### PR 단위 - -- Phase 단위로 크게 묶지 말고, 하나의 파일 / 하나의 함수 단위로 작게 -- 골든 픽스처 추가/변경은 별도 PR -- `go build ./...` · `go test ./...` · `golangci-lint run` 통과 확인 후 PR - ---- - -## FAQ - -**Q. Python 코드를 꼭 읽어야 하나?** -예. 모든 포팅은 Python이 canonical. Go 주석에 라인 번호 있으니 점프하기 쉬움. 읽지 않고 구현하면 bit-identical 실패 가능성 큼. - -**Q. 결정을 바꾸고 싶다 (예: novelty threshold 조정)** -PR 전에 [decisions.md](overview/decisions.md)에 새 `D` 항목 추가 → 근거 기록 → 리뷰 승인 후 코드 변경. 갑자기 코드에서 상수 바꾸기 금지. - -**Q. 외부 의존성을 직접 추가해도 되나?** -Phase 1이 이 역할. 임의 추가 금지. `go.mod require` 변경은 팀 리뷰 필요. - -**Q. 테스트가 Python과 float 오차로 실패한다** -`math.Floor` 적용 확인 (예: `searcher.py:L291` `(now-ts).days`는 integer truncation. Go에서는 float 연산 후 Floor 필수). 자세한 bit-identical 주의사항은 [recall.md Phase 6](spec/flows/recall.md) 참조. - -**Q. agent-delegated가 아닌 경로를 구현해야 하나?** -아니오. Python에 남아있는 legacy LLM 경로(detector/tier2_filter/llm_extractor/synthesizer/_legacy_standard_capture)는 **Go scope 밖**. 자세한 drop 목록은 [architecture.md §Scope 표](overview/architecture.md#scope-sot--agent-delegated-only). - -**Q. 에이전트가 `extracted.tier2`를 안 보내면?** -D14에 따라 `EXTRACTION_MISSING` 에러. agent-delegated 전제이므로 `pre_extraction` 필수. legacy regex fallback은 포팅 안 함. - -**Q. envector SDK가 아직 없으면 Phase 4를 어떻게 진행?** -Mock backend + stub 구현으로 Phase 4 starting 가능. Q4 envector-go SDK `OpenKeysFromFile` 조건 완화 PR 머지 전까지는 libevi 통합 불가, mock으로만 테스트. [open-questions.md Q4](overview/open-questions.md) 참조. - -**Q. Python과 완전히 같은 동작이 맞는지 어떻게 검증?** -Golden fixture (Python에서 생성한 JSON/MD를 `testdata/golden/`에 고정) + Go 출력과 byte-compare. 대형 포팅(record_builder/payload_text)은 이게 유일한 신뢰 보증 수단. - -**Q. 어디서 막히면?** -1. Python 원본 파일 + 해당 spec 문서를 옆에 놓고 다시 읽기 -2. [open-questions.md](overview/open-questions.md) 확인 — 이미 논의된 미결 사항일 수도 -3. [decisions.md D1-D32](overview/decisions.md) 관련 결정 확인 -4. 그래도 안 풀리면 team channel 질문 - ---- - -## 다음 단계 - -- Phase 선택 후 이 가이드의 §첫 PR 추천 에서 starter 골라서 브랜치 생성 -- 첫 PR 리뷰 후 배운 점 팀 Slack에 공유 (다음 사람 도움) -- 전체 로드맵은 [docs/v04/README.md §구현 로드맵](README.md#구현-로드맵) 에서 추적 - -환영합니다. 🚀 From dc8bc28be63ba65040bb3b52888fb5581f1faa1f Mon Sep 17 00:00:00 2001 From: shpark Date: Tue, 16 Jun 2026 09:28:48 +0000 Subject: [PATCH 20/20] feat(cli): 'version' subcommand shows installed rune-mcp/runed --- cmd/rune/main_test.go | 67 +++++++++++++++++++++++++++++++++++++++++++ cmd/rune/version.go | 17 +++++++++++ 2 files changed, 84 insertions(+) diff --git a/cmd/rune/main_test.go b/cmd/rune/main_test.go index e0f23d1..ed00c7d 100644 --- a/cmd/rune/main_test.go +++ b/cmd/rune/main_test.go @@ -16,6 +16,10 @@ func TestRunVersion_PrintConstants(t *testing.T) { manifestURL = "https://example/manifest.json" defer func() { manifestURL = saved }() + dir := t.TempDir() + t.Setenv("RUNE_HOME", filepath.Join(dir, "rune")) + t.Setenv("RUNED_HOME", filepath.Join(dir, "runed")) + var buf bytes.Buffer if code := runVersion(&buf); code != 0 { t.Errorf("exit = %d, want 0", code) @@ -35,6 +39,10 @@ func TestRunVersion_EmptyManifest(t *testing.T) { defer func() { manifestURL = saved }() t.Setenv("RUNE_MANIFEST", "") + dir := t.TempDir() + t.Setenv("RUNE_HOME", filepath.Join(dir, "rune")) + t.Setenv("RUNED_HOME", filepath.Join(dir, "runed")) + var buf bytes.Buffer _ = runVersion(&buf) // Match the actual "manifest missing" copy in version.go's empty @@ -45,6 +53,62 @@ func TestRunVersion_EmptyManifest(t *testing.T) { } } +func TestRunVersion_ShowsInstalledVersion(t *testing.T) { + dir := t.TempDir() + t.Setenv("RUNE_HOME", filepath.Join(dir, "rune")) + t.Setenv("RUNED_HOME", filepath.Join(dir, "runed")) + + paths, err := bootstrap.Resolve() + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if err := paths.EnsureDirs(); err != nil { + t.Fatalf("EnsureDirs: %v", err) + } + + m := &bootstrap.Manifest{Version: 1, RuneMCPVersion: "v0.1.1", RunedVersion: "v0.1.3"} + arts := map[string]bootstrap.InstalledArtifact{ + bootstrap.StepRuneMCP: {Path: paths.RuneMCPBinary, SHA256: "aaa"}, + bootstrap.StepRuned: {Path: paths.RunedBinary, SHA256: "bbb"}, + } + + if err := bootstrap.WriteInstalledManifest(paths, "https://example/manifest.json", m, arts); err != nil { + t.Fatalf("WriteInstalledManifest: %v", err) + } + + var buf bytes.Buffer + if code := runVersion(&buf); code != 0 { + t.Errorf("exit = %d, want 0", code) + } + + out := buf.String() + if !strings.Contains(out, "v0.1.1") { + t.Errorf("missing installed rune-mcp version: %q", out) + } + if !strings.Contains(out, "v0.1.3") { + t.Errorf("missing installed runed version: %q", out) + } +} + +func TestRunVersion_NoInstalledManifest(t *testing.T) { + dir := t.TempDir() + t.Setenv("RUNE_HOME", filepath.Join(dir, "rune")) + t.Setenv("RUNED_HOME", filepath.Join(dir, "runed")) + + var buf bytes.Buffer + if code := runVersion(&buf); code != 0 { + t.Errorf("exit = %d, want 0", code) + } + + out := buf.String() + if !strings.Contains(out, "rune ") { + t.Errorf("CLI version line must be printed: %q", out) + } + if strings.Contains(out, "rune-mcp:") || strings.Contains(out, "runed:") { + t.Errorf("should not print installed versions when installed.json is absent: %q", out) + } +} + func TestRenderVerifyText_HappyPath(t *testing.T) { r := &bootstrap.InstallChecks{ OK: true, @@ -196,6 +260,9 @@ func TestRunVersion_CheckManifestEnv(t *testing.T) { defer func() { manifestURL = saved }() t.Setenv("RUNE_MANIFEST", "https://example.invalid/m.json") + dir := t.TempDir() + t.Setenv("RUNE_HOME", filepath.Join(dir, "rune")) + t.Setenv("RUNED_HOME", filepath.Join(dir, "runed")) var buf bytes.Buffer if code := runVersion(&buf); code != 0 { diff --git a/cmd/rune/version.go b/cmd/rune/version.go index 503770e..bbffaf3 100644 --- a/cmd/rune/version.go +++ b/cmd/rune/version.go @@ -4,6 +4,8 @@ import ( "fmt" "io" "os" + + "github.com/CryptoLabInc/rune-cli/internal/bootstrap" ) func runVersion(w io.Writer) int { @@ -19,5 +21,20 @@ func runVersion(w io.Writer) int { fmt.Fprintln(w, "manifest missing: supply --manifest-url or RUNE_MANIFEST") } + // Show latest installed rune-mcp/runed (skip if not installed yet) + if paths, err := bootstrap.Resolve(); err == nil { + if rec, rerr := bootstrap.ReadInstalledManifest(paths); rerr == nil && rec != nil { + if rec.RuneMCPVersion != "" { + fmt.Fprintf(w, "rune-mcp: %s\n", rec.RuneMCPVersion) + } + if rec.RunedVersion != "" { + fmt.Fprintf(w, "runed: %s\n", rec.RunedVersion) + } + if rec.InstalledAt != "" { + fmt.Fprintf(w, "installed: %s\n", rec.InstalledAt) + } + } + } + return 0 }