diff --git a/app/config/detect.ts b/app/config/detect.ts index 5188492d5e5..a32023c339d 100644 --- a/app/config/detect.ts +++ b/app/config/detect.ts @@ -113,6 +113,23 @@ function psVersion(exe: string): string { return /^\d+\.\d+/.test(v) ? v : ''; } +// First path in the list that exists on disk, or '' if none. Lets detection try +// several known install locations for one tool instead of hardcoding one. +function firstExisting(paths: (string | null | undefined)[]): string { + for (const p of paths) { + if (p && existsSync(p)) return p; + } + return ''; +} + +// Resolve a bare executable name to its first absolute path via `where` (Windows). +// Returns '' if not found. Catches installs that live only on PATH — winget and +// Microsoft Store pwsh, portable unzips — which no hardcoded folder would find. +function resolveOnPath(bin: string): string { + const found = safeExec(`where ${bin}`).trim().split(/\r?\n/)[0].trim(); + return found && existsSync(found) ? found : ''; +} + function detectWslDistros(): string[] { const wslExe = 'C:\\Windows\\System32\\wsl.exe'; if (!existsSync(wslExe)) return []; @@ -154,8 +171,18 @@ function detectWindows(): DetectedProfile[] { const profiles: DetectedProfile[] = []; // PowerShell 7 (pwsh) — labeled with its real version, e.g. "PowerShell 7.5.5". - const ps7 = 'C:\\Program Files\\PowerShell\\7\\pwsh.exe'; - if (existsSync(ps7)) { + // The install location varies: the 64-bit MSI lands in "Program Files", the + // 32-bit MSI in "Program Files (x86)", and winget/Store installs are only on + // PATH. Hardcoding just the 64-bit path meant an x86 install was missed + // entirely — the "PowerShell" profile then pointed at a nonexistent exe and + // every pane opened with it silently fell back to Windows PowerShell 5.1. + // Probe the known folders, then PATH, and take the first that actually exists. + const ps7 = firstExisting([ + 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + 'C:\\Program Files (x86)\\PowerShell\\7\\pwsh.exe', + resolveOnPath('pwsh') + ]); + if (ps7) { const v = psVersion(ps7); profiles.push({name: v ? `PowerShell ${v}` : 'PowerShell', config: {shell: ps7, shellArgs: []}}); } diff --git a/app/config/init.ts b/app/config/init.ts index 18f1ce5ad73..821973080c7 100644 --- a/app/config/init.ts +++ b/app/config/init.ts @@ -103,7 +103,26 @@ const _init = (userCfg: rawConfig, defaultCfg: rawConfig): parsedConfig => { if (userCfg?.config) { const conf = userCfg.config; conf.defaultProfile = conf.defaultProfile || 'default'; - conf.profiles = conf.profiles || []; + // Coerce profiles to a real array before anything calls .map on it. A + // config write can land a malformed value here — e.g. settings_set given + // a JSON-encoded STRING stores "[{...}]" as a string, and `.map` then + // throws an UNCAUGHT exception that crashes the whole main process at + // launch (an unrecoverable state: the app won't start to let you fix it). + // Parse a stringified array, and fall back to [] for anything that still + // isn't an array, so a bad value degrades to defaults instead of a crash. + // `profiles` is typed as an array, so read through `unknown` to make the + // runtime string/array checks legal (a malformed config violates the type). + const rawProfiles: unknown = conf.profiles; + if (typeof rawProfiles === 'string') { + try { + conf.profiles = JSON.parse(rawProfiles); + } catch { + conf.profiles = []; + } + } + if (!Array.isArray(conf.profiles)) { + conf.profiles = []; + } conf.profiles = conf.profiles.length > 0 ? conf.profiles : [{name: 'default', config: {}}]; conf.profiles = conf.profiles.map((p, i) => ({ ...p, diff --git a/app/package.json b/app/package.json index f5a3ce691db..6c9312a572a 100644 --- a/app/package.json +++ b/app/package.json @@ -2,7 +2,7 @@ "name": "hyperia", "productName": "Hyperia", "description": "A modern agentic terminal.", - "version": "0.16.9", + "version": "0.16.10", "license": "BSD-2-Clause", "author": { "name": "Deep Blue Dynamics", diff --git a/package.json b/package.json index 579b4c7d948..50e97c23e1e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hyperia", - "version": "0.16.9", + "version": "0.16.10", "repository": "deepbluedynamics/hyperia", "scripts": { "start": "concurrently -n \"Webpack,TypeScript,App\" -c \"cyan.bold,blue.bold,green.bold\" \"webpack -w\" \"tsc --build -v --pretty --watch --preserveWatchOutput\" \"cross-env ELECTRONMON_LOGLEVEL=error electronmon target\" -k", diff --git a/sidecar/Cargo.lock b/sidecar/Cargo.lock index c6343d2fa92..7b5a5716588 100644 --- a/sidecar/Cargo.lock +++ b/sidecar/Cargo.lock @@ -1211,7 +1211,7 @@ dependencies = [ [[package]] name = "hyperia-sidecar" -version = "0.16.9" +version = "0.16.10" dependencies = [ "aegis-edit", "anyhow", diff --git a/sidecar/Cargo.toml b/sidecar/Cargo.toml index 7495091108e..50ff6ddafeb 100644 --- a/sidecar/Cargo.toml +++ b/sidecar/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hyperia-sidecar" -version = "0.16.9" +version = "0.16.10" edition = "2021" description = "Rust sidecar for Hyperia: agent engine, MCP, signaling" diff --git a/sidecar/src/main.rs b/sidecar/src/main.rs index b91e15894d6..732a4d1bcb9 100644 --- a/sidecar/src/main.rs +++ b/sidecar/src/main.rs @@ -11,6 +11,8 @@ mod fsnav; mod ghost; mod logs; mod mcp; +/// Agent-facing prose, keyed and per-locale — see `messages/mod.rs`. +mod messages; mod models; mod perms; mod process; @@ -1013,24 +1015,68 @@ async fn requester_display_name( /// when the actual fact was "the header never arrived" (#135). fn anon_reason(headers: &HeaderMap) -> String { match bearer_token(headers) { - None => "FACT: this request arrived with NO Authorization header — the server received no \ - credentials at all. If you believe your client is configured with a token, your \ - transport is not sending it on THIS call." - .to_string(), + None => messages::text(messages::Msg::AnonNoAuthHeader).to_string(), Some(t) => { let prefix: String = t.chars().take(10).collect(); - format!( - "FACT: an Authorization token WAS received ({prefix}…, {} chars) but it is not \ - recognized by this sidecar. Pane tokens (hyp_pane_…) are deleted when their pane \ - closes and wiped on sidecar restart; agent tokens (hyp_agent_…) persist in \ - ~/.hyperia/agents.json. Your token is most likely stale — re-read \ - HYPERIA_AGENT_TOKEN from a live pane or re-mint with request_token.", - t.chars().count() + let len = t.chars().count().to_string(); + messages::render( + messages::Msg::AnonUnknownToken, + &[("prefix", &prefix), ("len", &len)], ) } } } +/// Does this request look like it came from inside a container? Containers reach +/// the sidecar through their runtime's host gateway, so the Host header they send +/// is that gateway's name or IP — never loopback. Used only to steer recovery +/// advice, never to grant or deny: a containerized agent that hand-edits its own +/// MCP config loses the edit on the next container reset (#135). +fn caller_via_container(headers: &HeaderMap) -> bool { + let raw = headers + .get(axum::http::header::HOST) + .and_then(|h| h.to_str().ok()) + .unwrap_or(""); + // Strip the :port, and the brackets around a v6 literal. + let host = raw + .rsplit_once(':') + .map(|(h, _)| h) + .unwrap_or(raw) + .trim() + .trim_start_matches('[') + .trim_end_matches(']') + .to_ascii_lowercase(); + if host.is_empty() || host == "localhost" || host == "127.0.0.1" || host == "::1" { + return false; + } + host.contains("host.docker.internal") + || host.contains("host.containers.internal") + || host.starts_with("172.") + || host.starts_with("10.") + || host.starts_with("192.168.") +} + +/// Recovery instructions appended to every "you're anonymous" refusal. Branches +/// on WHERE the caller runs, because the correct fix is completely different and +/// the wrong one is actively harmful: +/// +/// - In a container, editing MCP config in-place is wasted work — the filesystem +/// is ephemeral, so the fix vanishes on the next reset and the following agent +/// re-debugs it from scratch. The durable fix lives in the orchestrator on the +/// host (for nemesis8: mcp-servers/hyperia.toml + the HYPERIA_URL it injects). +/// - On the host, the fix is real but the failure is almost always config +/// PRECEDENCE, not a missing token — a project-scoped entry silently shadows a +/// perfectly good global one. Saying "check your config" without saying that +/// sends agents to audit the wrong block (#135). +fn identity_recovery(headers: &HeaderMap) -> String { + let key = if caller_via_container(headers) { + messages::Msg::RecoveryContainer + } else { + messages::Msg::RecoveryHost + }; + messages::text(key).to_string() +} + /// Extract a bearer token from an `Authorization` header (case-insensitive /// scheme, tolerant of a bare token without the "Bearer " prefix). fn bearer_token(headers: &HeaderMap) -> Option { @@ -1200,34 +1246,16 @@ async fn enforce_drive_with_purpose( AuthDecision::Allow => Ok(()), AuthDecision::RefuseHome => Err(( StatusCode::FORBIDDEN, - "That's the pane you're running in — you can't drive your own terminal. \ - Split it or open a new pane for a worker shell." - .to_string(), + messages::text(messages::Msg::DriveRefuseHome).to_string(), )), AuthDecision::SoftWall => Err(( StatusCode::UNAUTHORIZED, - format!("{} \ - Reads (terminal_status, terminal_screen, \ - hyperia_version) work without identity; writes (terminal_run/keys/cd/split, etc.) do not. \ - IMPORTANT: do NOT call request_access to fix this — request_access ALSO requires identity \ - and will return this exact error. Identity comes first, access second. Recovery:\n\ - INSIDE a Hyperia pane (you have a HYPERIA_AGENT_TOKEN env var):\n\ - 1. Your MCP client's hyperia entry must send header Authorization = \"Bearer \ - ${{HYPERIA_AGENT_TOKEN}}\". Check the config block your session ACTUALLY loads: a project-local \ - .mcp.json, or the ~/.claude.json entry for THIS working directory. If neither defines hyperia \ - it falls back to the GLOBAL mcpServers entry — which may have a literal empty \"Bearer\" (the \ - usual culprit). Fix that header.\n\ - 2. You MUST FULLY RESTART this pane afterward (close the agent, relaunch e.g. with --continue). \ - MCP Authorization headers are read ONLY at process startup — editing config mid-session, or a \ - '/mcp' reconnect, does NOT reload them. This restart is the step that is almost always missed; \ - without it every write keeps failing no matter what you change.\n\ - 3. After restart, verify with hyperia_version (a read), then retry your write. THEN, if you \ - need to drive a pane you don't own, request_access will work (it can finally raise the user's \ - approval prompt).\n\ - EXTERNAL agent (no HYPERIA_AGENT_TOKEN env var): call request_token to mint a persistent \ - hyp_agent_… token, set your client's Authorization header to 'Bearer ', restart/reconnect \ - the client, then retry.", - anon_reason(&headers) + messages::render( + messages::Msg::DriveSoftWall, + &[ + ("facts", &anon_reason(&headers)), + ("recovery", &identity_recovery(&headers)), + ], ), )), AuthDecision::Denied => Err(( @@ -1327,14 +1355,12 @@ async fn enforce_create( AuthDecision::RefuseHome => Ok(()), // n/a to create AuthDecision::SoftWall => Err(( StatusCode::UNAUTHORIZED, - format!( - "{} Creating panes/tabs requires identity. INSIDE a pane: send the \ - HYPERIA_AGENT_TOKEN env var as 'Authorization: Bearer ' (MCP client: \ - headers.Authorization = \"Bearer ${{HYPERIA_AGENT_TOKEN}}\"). EXTERNAL agent (no env \ - var): call the request_token tool (or POST /api/identity/agent {{\"name\":\"\"}}) \ - to mint a persistent hyp_agent_… token, wire it as your MCP Authorization header, and \ - reconnect.", - anon_reason(headers) + messages::render( + messages::Msg::CreateSoftWall, + &[ + ("facts", &anon_reason(headers)), + ("recovery", &identity_recovery(headers)), + ], ), )), AuthDecision::Denied => Err(( @@ -1430,12 +1456,13 @@ async fn enforce_capability( AuthDecision::Allow | AuthDecision::RefuseHome => Ok(()), AuthDecision::SoftWall => Err(( StatusCode::UNAUTHORIZED, - format!( - "{} The '{cap}' capability therefore can't be authorized. In-pane agents: send \ - 'Authorization: Bearer ${{HYPERIA_AGENT_TOKEN}}' and fully restart the agent \ - process (headers load at startup only). External agents: request_token mints a \ - persistent hyp_agent_… token.", - anon_reason(headers) + messages::render( + messages::Msg::CapabilitySoftWall, + &[ + ("facts", &anon_reason(headers)), + ("cap", cap), + ("recovery", &identity_recovery(headers)), + ], ), )), AuthDecision::Denied => Err(( @@ -3145,6 +3172,12 @@ async fn main() -> anyhow::Result<()> { let args = Args::parse(); + // Pick the message locale before anything can emit agent-facing prose. + // Unset or unknown tags fall back to English (see messages/mod.rs). + if let Ok(tag) = std::env::var("HYPERIA_LOCALE") { + messages::set_locale(&tag); + } + // MCP mode: stdio proxy if args.mcp { tracing_subscriber::fmt() diff --git a/sidecar/src/mcp.rs b/sidecar/src/mcp.rs index 83e28f2f0b5..c80830e405b 100644 --- a/sidecar/src/mcp.rs +++ b/sidecar/src/mcp.rs @@ -1774,7 +1774,7 @@ impl HyperiaMcp { } } - #[tool(description = "Get a persistent Hyperia identity token for an EXTERNAL agent — one NOT running inside a Hyperia pane, so it has no HYPERIA_AGENT_TOKEN in its environment. Call this the moment a state-changing tool returns 'No identity': it mints (or returns) a persistent hyp_agent_… token. Then set your MCP client's Authorization header to 'Bearer ' and reconnect — after which terminal_run / terminal_keys / terminal_split / request_access etc. work. Read-only/monitoring tools never needed it. The token persists in ~/.hyperia/agents.json across restarts; minting the same name again returns the same token.")] + #[tool(description = "Get a persistent Hyperia identity token for an EXTERNAL agent — one NOT running inside a Hyperia pane, so it has no HYPERIA_AGENT_TOKEN in its environment. Call this the moment a state-changing tool returns 'No identity': it mints (or returns) a persistent hyp_agent_… token. Then set your MCP client's Authorization header to 'Bearer ' and reconnect — after which terminal_run / terminal_keys / terminal_split / request_access etc. work. Read-only/monitoring tools never needed it. The token persists in ~/.hyperia/agents.json across restarts; minting the same name again returns the same token. DOES NOT HELP in-pane agents (you cannot inject a token into your own running connection — fix your MCP config's Authorization header and restart) or containerized agents (the container is ephemeral — fix the host orchestrator's config instead); the 'No identity' refusal text spells out both.")] async fn request_token( &self, Parameters(req): Parameters, @@ -1787,15 +1787,9 @@ impl HyperiaMcp { .ok() .and_then(|v| v["token"].as_str().map(String::from)) .unwrap_or_default(); - let msg = format!( - "Minted a persistent Hyperia agent token for \"{name}\":\n\n {token}\n\n\ - Wire it into your MCP client and reconnect:\n \ - claude mcp add --transport http --scope user hyperia {base}/mcp --header \"Authorization: Bearer {token}\"\n\ - (or set headers.Authorization = \"Bearer {token}\" on the hyperia server in your MCP config — for Claude Code that's the `hyperia` block in ~/.claude.json — then restart the session).\n\n\ - After reconnect, state-changing tools work. This token persists in ~/.hyperia/agents.json; calling request_token again with the same name returns it.", - name = name, - token = token, - base = self.base_url + let msg = crate::messages::render( + crate::messages::Msg::RequestTokenMinted, + &[("name", &name), ("token", &token), ("base", &self.base_url)], ); Ok(CallToolResult::success(vec![Content::text(msg)])) } @@ -2029,14 +2023,20 @@ impl HyperiaMcp { } let mut cfg = self.read_config().await?; let old = walk_path(&cfg, &req.path); - match set_path(&mut cfg, &req.path, req.value.clone()) { + // Guard against an MCP client that serialized a structured value to a + // JSON string (a common footgun for untyped params): storing e.g. + // config.profiles as the string "[{...}]" instead of an array crashes + // the app's config loader. Unwrap a stringified array/object to the real + // structure before writing. + let value = coerce_json_string(req.value.clone()); + match set_path(&mut cfg, &req.path, value.clone()) { Ok(()) => { self.write_config(&cfg).await?; let summary = serde_json::json!({ "ok": true, "path": req.path, "old_value": old, - "new_value": req.value, + "new_value": value, }); Ok(CallToolResult::success(vec![Content::text( serde_json::to_string_pretty(&summary).unwrap_or_default(), @@ -2864,6 +2864,26 @@ fn walk_path(value: &serde_json::Value, path: &str) -> serde_json::Value { cur.clone() } +/// If `v` is a string whose contents parse as a JSON array or object, return the +/// parsed value; otherwise return `v` unchanged. Guards `settings_set` against an +/// MCP client that serialized a structured value to a string — which would store +/// e.g. `config.profiles` as a string and crash the app's config loader. Only +/// arrays/objects are unwrapped: a plain string that happens to be valid JSON +/// (a shell path, a number-like or boolean-like string) is left untouched. +fn coerce_json_string(v: serde_json::Value) -> serde_json::Value { + if let serde_json::Value::String(s) = &v { + let trimmed = s.trim_start(); + if trimmed.starts_with('[') || trimmed.starts_with('{') { + if let Ok(parsed) = serde_json::from_str::(s) { + if parsed.is_array() || parsed.is_object() { + return parsed; + } + } + } + } + v +} + /// Set a value at a dot-separated path, creating intermediate objects as /// needed. Passing Null as the new value removes the leaf key. fn set_path( @@ -3509,6 +3529,12 @@ impl ServerHandler for HyperiaMcp { ServerInfo { instructions: Some( "Hyperia MCP server — controls a running Hyperia terminal emulator. \ + \n\nIF HYPERIA ITSELF MISBEHAVES — a tool errors, a pane stops responding, output \ + looks wrong — STOP and tell the human what you saw and what you were attempting. \ + Do NOT keep calling the failing tool, retry it with variations, or theorise about \ + causes: two failed attempts is the ceiling. A wrong guess costs the human far more \ + than a pause does, and repeated calls can make the breakage worse. Use report_bug \ + to file it. \ \n\nCRITICAL — UI-FIRST PRINCIPLE: Hyperia gives you unlimited terminal panes and \ tabs. NEVER use shell-level backgrounding to run servers, watchers, REPLs, or any \ long-running task. That includes: PowerShell `Start-Process`, `nohup`, `tmux`, \ @@ -3536,8 +3562,14 @@ impl ServerHandler for HyperiaMcp { requires the human's consent, which they approve in the Hyperia UI; a denied or \ ungranted action returns 202 (awaiting approval) or 403 (denied), not a silent pass. \ You CANNOT drive the pane you're running in — you're already there; split or open a \ - new pane for a worker shell. If a call returns a 'No identity' message, wire the \ - Authorization header as above and retry. \ + new pane for a worker shell. \ + \n\nIF A CALL RETURNS 'No identity': reads still work, writes don't, and the refusal \ + text carries the full fix — follow it instead of guessing. Two things in it that \ + agents reliably get wrong: on the HOST the cause is usually config PRECEDENCE (a \ + project-scoped MCP entry silently shadows the global one), and INSIDE A CONTAINER \ + (docker or podman) you must NOT fix config locally — it is ephemeral, so route the \ + change through the host orchestrator (nemesis8: mcp-servers/hyperia.toml + \ + HYPERIA_URL) and restart the container. \ \n\nIDENTITY vs ACCESS — these are DIFFERENT, don't confuse them: your token says WHO \ you are (identity); ACCESS is the human's consent to act on a specific pane. If you're \ blocked on a pane (soft-walled / awaiting), DO NOT hunt for a 'tab token' in files, \ diff --git a/sidecar/src/messages/en.rs b/sidecar/src/messages/en.rs new file mode 100644 index 00000000000..5c65d104b0c --- /dev/null +++ b/sidecar/src/messages/en.rs @@ -0,0 +1,112 @@ +//! English catalog — the source of truth and the fallback for every other locale. +//! +//! This module must cover every [`Msg`] variant; `messages::tests` enforces it. +//! Keep the prose here and the logic elsewhere: if you find yourself wanting a +//! conditional inside a string, that's a sign it should be two keys. +//! +//! Length guidance, learned the hard way (#135): text on an ERROR path is cheap — +//! it is emitted once, to an agent that is already stuck, and thoroughness there +//! saves a human from re-explaining. Text on an ALWAYS-ON surface (an MCP tool +//! description, the server instructions block) is charged to every agent on every +//! connect, forever. Put the long explanation in the refusal; leave a pointer in +//! the always-on surface. + +use super::Msg; + +pub fn get(key: Msg) -> Option<&'static str> { + Some(match key { + // ── Received-facts preambles ───────────────────────────────────────── + // These report only what the server RECEIVED. They must never diagnose + // the caller: "you have no identity" once sent two agents theory-building + // about token tiers and config quests when the actual fact was "the + // header never arrived" (#135). + Msg::AnonNoAuthHeader => "FACT: this request arrived with NO Authorization header — the \ + server received no credentials at all. If you believe your client is configured with \ + a token, your transport is not sending it on THIS call.", + + Msg::AnonUnknownToken => "FACT: an Authorization token WAS received ({{prefix}}…, \ + {{len}} chars) but it is not recognized by this sidecar. Pane tokens (hyp_pane_…) are \ + deleted when their pane closes and wiped on sidecar restart; agent tokens \ + (hyp_agent_…) persist in ~/.hyperia/agents.json. Your token is most likely stale — \ + re-read HYPERIA_AGENT_TOKEN from a live pane or re-mint with request_token.", + + // ── Recovery guidance ──────────────────────────────────────────────── + Msg::RecoveryHost => "RECOVERY — you are calling from the host, so this is yours to fix. \ + 1) Find the config block your session ACTUALLY loaded and make it send \ + Authorization = \"Bearer ${HYPERIA_AGENT_TOKEN}\". PRECEDENCE IS THE USUAL CULPRIT: \ + for the same server name a project-scoped entry SILENTLY SHADOWS the global one — \ + they are NOT merged — so a correct global entry does you no good when a \ + project-scoped entry omits `headers` entirely. For Claude Code, check any \ + project-local .mcp.json and projects[].mcpServers.hyperia in ~/.claude.json \ + FIRST, before you look at the global mcpServers block, and confirm the header is \ + present in whichever one actually wins. 2) FULLY RESTART this agent process \ + afterward — MCP Authorization headers are read ONLY at startup; editing config \ + mid-session, or reconnecting with '/mcp', does NOT reload them. This is the step most \ + often missed. 3) Verify with hyperia_version (a read), then retry the write. NOTE: \ + request_token will NOT rescue an in-pane agent — it mints a token you have no way to \ + inject into an already-running connection. It is for external clients that can \ + rewrite their config and reconnect.", + + Msg::RecoveryContainer => "RECOVERY — this request's Host header is a container \ + host-gateway, not loopback, so you are almost certainly running INSIDE A CONTAINER. \ + DO NOT hand-edit MCP config in here and DO NOT go hunting for a token on the \ + container filesystem. The container is EPHEMERAL: anything you change is wiped when \ + it is next rebuilt or reset, the same failure comes back, and the next agent debugs \ + it from scratch. The fix belongs to the ORCHESTRATOR on the host — for nemesis8 that \ + is the checked-in server config (mcp-servers/hyperia.toml) and the HYPERIA_URL env it \ + injects, which is durable and applies to every future container. Report this to the \ + human or to the orchestrating host agent and let them make the change; the container \ + then has to be restarted to pick it up.\n\ + SEPARATELY, IF YOU CANNOT REACH THE SIDECAR AT ALL (connection refused, rather than \ + this message): that is BIND SCOPE, not auth, and the answer DEPENDS ON YOUR CONTAINER \ + RUNTIME — Hyperia supports both docker and podman, so identify which one you are on \ + before concluding anything. Under Docker Desktop (Windows/macOS) host.docker.internal \ + is forwarded by the VM to the host's loopback, so a loopback-bound sidecar is \ + reachable and a refused connection means something else. Under PODMAN, and under \ + native Linux docker, the host is reached through a REAL bridge gateway instead \ + (podman typically 10.88.0.1 and the name host.containers.internal, docker typically \ + 172.17.0.1) — nothing is listening there when the sidecar is bound to loopback, so \ + every container call is refused no matter how correct your MCP config is. That is \ + fixed host-side by binding wider (HYPERIA_BIND=0.0.0.0), never from inside the \ + container.", + + // ── Refusals ───────────────────────────────────────────────────────── + Msg::DriveRefuseHome => "That's the pane you're running in — you can't drive your own \ + terminal. Split it or open a new pane for a worker shell.", + + Msg::DriveSoftWall => "{{facts}} Reads (terminal_status, terminal_screen, hyperia_version) \ + work without identity; writes (terminal_run/keys/cd/split, etc.) do not. IMPORTANT: \ + do NOT call request_access to fix this — request_access ALSO requires identity and \ + will return this exact error. Identity comes first, access second.\n\ + {{recovery}}\n\ + Once identity works, if you still need to drive a pane you don't own, request_access \ + will then be able to raise the user's approval prompt.", + + Msg::CreateSoftWall => "{{facts}} Creating panes/tabs requires identity.\n{{recovery}}", + + Msg::CapabilitySoftWall => "{{facts}} The '{{cap}}' capability therefore can't be \ + authorized.\n{{recovery}}", + + // ── Tool responses ─────────────────────────────────────────────────── + Msg::RequestTokenMinted => "Minted a persistent Hyperia agent token for \"{{name}}\":\n\n \ + {{token}}\n\n\ + Wire it into your MCP client and reconnect:\n \ + claude mcp add --transport http --scope user hyperia {{base}}/mcp --header \ + \"Authorization: Bearer {{token}}\"\n\ + (or set headers.Authorization = \"Bearer {{token}}\" on the hyperia server in your MCP \ + config — for Claude Code that's the `hyperia` block in ~/.claude.json — then restart \ + the session).\n\n\ + After reconnect, state-changing tools work. This token persists in \ + ~/.hyperia/agents.json; calling request_token again with the same name returns it.\n\n\ + CAVEATS — if either applies to you, this token will NOT fix your problem:\n\ + - IN A HYPERIA PANE (you have HYPERIA_AGENT_TOKEN in your env): you cannot inject \ + this token into your own running connection — headers load at process startup only. \ + Fix the header in whichever config block your session actually loaded (a \ + project-scoped entry silently shadows the global one) and fully restart.\n\ + - IN A CONTAINER (docker or podman — you reach this sidecar via host.docker.internal, \ + host.containers.internal, or a gateway IP): do NOT wire this token into \ + container-local config; that filesystem is ephemeral and the edit is wiped on the \ + next reset. Fix the host orchestrator instead (nemesis8: mcp-servers/hyperia.toml + \ + HYPERIA_URL), then restart the container.", + }) +} diff --git a/sidecar/src/messages/mod.rs b/sidecar/src/messages/mod.rs new file mode 100644 index 00000000000..42162ff8ea2 --- /dev/null +++ b/sidecar/src/messages/mod.rs @@ -0,0 +1,170 @@ +//! Agent-facing message catalog. +//! +//! Every long piece of agent-facing prose lives here, keyed by [`Msg`], so the +//! logic files stay logic. Call sites read as one line — `messages::render( +//! Msg::DriveSoftWall, &[..])` — instead of burying a screenful of copy inside a +//! match arm where it can't be reviewed, reused, or translated. +//! +//! # Locales +//! +//! Catalogs are per-locale modules exposing `get(Msg) -> Option<&'static str>`. +//! [`en`] is the source of truth: it must cover every key, and it is the fallback +//! whenever a translation is missing one. That means a partially-translated +//! locale is always safe to ship — untranslated keys degrade to English rather +//! than to an empty string or a panic. +//! +//! Adding a language is: write `messages/.rs` with a `get()` covering the +//! keys you've translated, declare the `mod`, and add one arm to [`catalog`]. +//! Nothing at the call sites changes. +//! +//! # Placeholders +//! +//! Substitution uses `{{name}}`, NOT `{name}`. This is deliberate: several of +//! these messages contain literal shell/env expansions such as +//! `${HYPERIA_AGENT_TOKEN}`, and a single-brace syntax would try to substitute +//! the token name out of the very instruction telling the reader to type it. +//! Unknown placeholders are left in place rather than blanked, so a missed +//! variable shows up loudly in the output instead of silently vanishing. + +mod en; + +use std::sync::OnceLock; + +/// Stable identifier for a piece of agent-facing prose. +/// +/// Keys are behavioural, not lexical — `DriveSoftWall` names *the situation*, so +/// a translator (or a rewrite) can change every word without touching call sites. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub enum Msg { + // ── Received-facts preambles (what the server actually got) ────────────── + /// No Authorization header arrived at all. No placeholders. + AnonNoAuthHeader, + /// A token arrived but resolves to nobody. `{{prefix}}`, `{{len}}`. + AnonUnknownToken, + + // ── Recovery guidance, branched by where the caller runs ───────────────── + /// Caller is on the host: fix your own MCP config. No placeholders. + RecoveryHost, + /// Caller is containerized: do NOT fix it in here. No placeholders. + RecoveryContainer, + + // ── Refusals ───────────────────────────────────────────────────────────── + /// Tried to drive its own pane. No placeholders. + DriveRefuseHome, + /// Drive refused for want of identity. `{{facts}}`, `{{recovery}}`. + DriveSoftWall, + /// Pane/tab creation refused for want of identity. `{{facts}}`, `{{recovery}}`. + CreateSoftWall, + /// A capability couldn't be authorized. `{{facts}}`, `{{cap}}`, `{{recovery}}`. + CapabilitySoftWall, + + // ── Tool responses ─────────────────────────────────────────────────────── + /// request_token succeeded. `{{name}}`, `{{token}}`, `{{base}}`. + RequestTokenMinted, +} + +/// A locale catalog: resolves a key, or `None` if this locale hasn't translated it. +type Catalog = fn(Msg) -> Option<&'static str>; + +/// Active locale tag, lowercased (e.g. `en`, `es`, `pt-br`). Set once at startup. +static LOCALE: OnceLock = OnceLock::new(); + +/// Select the active locale. Called once during startup; later calls are ignored, +/// so a message's language can't change midway through a session. +pub fn set_locale(tag: &str) { + let tag = tag.trim().to_ascii_lowercase(); + if !tag.is_empty() { + let _ = LOCALE.set(tag); + } +} + +/// The active locale tag, defaulting to `en`. +pub fn locale() -> &'static str { + LOCALE.get().map(String::as_str).unwrap_or("en") +} + +/// Map a locale tag to its catalog, tolerating region suffixes (`pt-BR` → `pt`). +fn catalog(tag: &str) -> Option { + let base = tag.split(['-', '_']).next().unwrap_or(""); + match base { + "en" => Some(en::get as Catalog), + _ => None, + } +} + +/// Resolve a key in the active locale, falling back to English. +/// +/// English is exhaustive, so the `unwrap_or("")` is unreachable in practice; it +/// exists so a future key added to [`Msg`] but forgotten in the catalog degrades +/// to an empty message rather than panicking inside an error path. +pub fn text(key: Msg) -> &'static str { + catalog(locale()) + .and_then(|get| get(key)) + .or_else(|| en::get(key)) + .unwrap_or("") +} + +/// Resolve a key and substitute its `{{name}}` placeholders. +pub fn render(key: Msg, vars: &[(&str, &str)]) -> String { + let mut out = text(key).to_string(); + for (name, value) in vars { + let needle = ["{{", name, "}}"].concat(); + if out.contains(&needle) { + out = out.replace(&needle, value); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every key must resolve in English — this is what makes fallback total. + #[test] + fn english_covers_every_key() { + for key in [ + Msg::AnonNoAuthHeader, + Msg::AnonUnknownToken, + Msg::RecoveryHost, + Msg::RecoveryContainer, + Msg::DriveRefuseHome, + Msg::DriveSoftWall, + Msg::CreateSoftWall, + Msg::CapabilitySoftWall, + Msg::RequestTokenMinted, + ] { + assert!(en::get(key).is_some(), "en catalog missing {key:?}"); + assert!(!text(key).is_empty(), "empty text for {key:?}"); + } + } + + #[test] + fn render_substitutes_named_placeholders() { + let out = render(Msg::AnonUnknownToken, &[("prefix", "hyp_agent_"), ("len", "36")]); + assert!(out.contains("hyp_agent_"), "prefix not substituted: {out}"); + assert!(out.contains("36"), "len not substituted: {out}"); + assert!(!out.contains("{{"), "placeholder left unrendered: {out}"); + } + + /// The reason placeholders are doubled: `${HYPERIA_AGENT_TOKEN}` must survive + /// rendering intact, or the instruction tells the reader to type the wrong thing. + #[test] + fn shell_expansions_survive_rendering() { + let out = render(Msg::RecoveryHost, &[("HYPERIA_AGENT_TOKEN", "SHOULD-NOT-APPEAR")]); + assert!(out.contains("${HYPERIA_AGENT_TOKEN}"), "env expansion was mangled"); + assert!(!out.contains("SHOULD-NOT-APPEAR"), "single-brace substitution leaked in"); + } + + #[test] + fn unknown_locale_falls_back_to_english() { + assert!(catalog("zz").is_none()); + assert_eq!(text(Msg::DriveRefuseHome), en::get(Msg::DriveRefuseHome).unwrap()); + } + + #[test] + fn region_suffixes_resolve_to_base_language() { + assert!(catalog("en-US").is_some()); + assert!(catalog("en_GB").is_some()); + } +}