Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions app/config/detect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [];
Expand Down Expand Up @@ -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: []}});
}
Expand Down
21 changes: 20 additions & 1 deletion app/config/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
2 changes: 1 addition & 1 deletion sidecar/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion sidecar/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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"

Expand Down
133 changes: 83 additions & 50 deletions sidecar/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<String> {
Expand Down Expand Up @@ -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 <token>', 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((
Expand Down Expand Up @@ -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 <token>' (MCP client: \
headers.Authorization = \"Bearer ${{HYPERIA_AGENT_TOKEN}}\"). EXTERNAL agent (no env \
var): call the request_token tool (or POST /api/identity/agent {{\"name\":\"<you>\"}}) \
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((
Expand Down Expand Up @@ -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((
Expand Down Expand Up @@ -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()
Expand Down
Loading
Loading