Commit graph

12645 commits

Author SHA1 Message Date
yoniebans
2ead3210ea fix(desktop): carry remoteKind/remoteHost through the primary backend so the pill reads SSH
A global SSH connection showed the statusbar pill as a plain token remote
("Remote: 127.0.0.1") instead of "SSH: user@host". startHermes() builds the
renderer-facing connection by hand-copying fields from the resolved descriptor
and dropped remoteHost + remoteKind, so the pill never saw remoteKind === ssh
and fell back to the URL-remote label (and looked like a token connection).

The per-profile pool path already spreads the full descriptor (...remote), so
only the primary/global path was affected — which is exactly the global-SSH
setup. Pass remoteHost + remoteKind through.

(The saved connection.json was already correct: mode:ssh with the encrypted
served dashboard token — that token IS the intended artifact, not a regression.)
2026-06-23 13:22:14 +02:00
yoniebans
a510e1132c fix(desktop): keep SSH ControlPath under sun_path on macOS
First real-hardware connect failed with: unix_listener: path
"/var/folders/8r/.../T/hermes-desktop-ssh/<hash>.sock.VSNDLDxh7gXySb0w" too long
for Unix domain socket.

Root cause: the default control-socket base was os.tmpdir(), which on macOS is
the deeply-nested per-user /var/folders/xx/yyyy.../T/ (~49 bytes). The socket
path itself fit 104, but OpenSSH binds a TEMPORARY listener at
<ControlPath>.<16 random chars> (a 17-byte suffix) while establishing the
master — 89 + 17 = 106 > 104, so bind failed.

Fix: default the POSIX control dir to a short, per-user base
(~/.hermes/desktop-ssh) instead of os.tmpdir(). Worst case is now ~72 bytes incl.
the temp suffix. Per-user (not a shared /tmp) avoids foreign-owned-dir/symlink
surface; still created 0700 in open(). Windows keeps os.tmpdir() (AF_UNIX has no
sun_path limit there). Deliberate divergence from ssh.py, which uses
gettempdir() and would hit this on macOS.

Test: default socket path + 17-byte temp suffix asserted <= 104 and not under
/var/folders/.
2026-06-23 13:22:14 +02:00
yoniebans
e4cf51dbc0 fix(desktop): pill deep-links to Gateway tab + tooltip shows profile
- Connection pill now navigates to /settings?tab=gateway (the settings index
  reads ?tab=), landing the user in the connection panel instead of generic
  settings.
- Tooltip appends the per-profile scope when the connection is profile-scoped,
  so it discloses which profile the host backs.
2026-06-23 13:22:14 +02:00
yoniebans
9622497036 fix(desktop): parse user@host[:port] typed into the SSH host field
normalizeSshConfig only trimmed the host, so user@host worked only by accident
(passed through as the literal target) and user@host:port did not split into
host + port. Worse, typing user@host AND filling the User field could produce
user@user@host.

Now: split a leading user@ and a trailing :port off the host field; explicit
user/port fields win (no doubling); IPv6 literals (multiple colons) and bare
~/.ssh/config aliases are left untouched. Tests cover user@host, user@host:port,
explicit-fields-win, and the alias/IPv6 passthrough.
2026-06-23 13:22:14 +02:00
yoniebans
bb898b80f9 fix(desktop): harden remote lifecycle — skip-build spawn + adoption liveness
- Remote dashboard spawn now passes --skip-build so a headless SSH bootstrap
  never triggers an npm web-UI build; if no built dist exists the backend fails
  loudly (scraped from the readiness log) instead of hanging on a build.
- Served-token adoption no longer asserts childAlive: () => true. Fresh spawn
  confirms the spawned remote pid is still alive (remotePidAlive) at adoption
  time; the reuse path reuses the pid-alive gate it already computed. This
  restores the foreign-backend guard: a served token from a DIFFERENT backend
  that grabbed the same forwarded port after the dashboard exited is rejected.
- Tests: --skip-build asserted in buildSpawnCommand.

(Note: the awaited before-quit SSH teardown landed in cfc0082b2 with the scoping
fixes — preventDefault + bounded await + one-shot guard so local forwards do not
linger after quit.)
2026-06-23 13:22:14 +02:00
yoniebans
cc96ac617a fix(desktop): create control-socket dir before opening the SSH master
OpenSSH does not create intermediate directories for ControlPath, so on a fresh
box (no prior hermes-desktop-ssh dir under \$TMPDIR) the very first connect failed
when ssh tried to create the master socket. Unit tests mock spawn and never hit
real fs, so this was invisible.

open() now mkdir -p (mode 0700 — the socket grants command execution on the
master) the control-socket directory before spawning ssh. Mirrors ssh.py.
Added a test that open() creates a non-existent control dir.
2026-06-23 13:22:14 +02:00
yoniebans
28d7472fb4 fix(desktop): scope global SSH per profile + stop terminal leaking to non-SSH remotes
Two scoping bugs found in audit:

1. Global SSH lost per-profile request scoping. globalRemoteActive() returned
   true only for mode === remote (or the env URL), not mode === ssh, so a global
   SSH connection serving multiple desktop profiles routed every profile to the
   remote DEFAULT profile instead of carrying ?profile=. Treat mode === ssh as a
   global remote for request scoping (one loopback backend, ?profile= per request
   — same contract as a global URL remote).

2. Interim ssh -tt terminal could leak into a token/OAuth remote. activeSshTerminalTarget()
   returned any cached SSH state (primary scope, then GLOBAL scope) without
   checking what the active profile actually resolves to. With a global SSH
   connection AND a per-profile token/OAuth override active, the terminal opened
   ssh -tt on the global SSH host. Rewrite it to mirror resolveRemoteBackend
   precedence: a per-profile non-SSH override (or env URL) returns null — never
   falls through to global SSH.
2026-06-23 13:22:14 +02:00
yoniebans
86c685a862 fix(desktop): make DesktopConnectionConfig contract total (tsc)
The connection-config fields were declared optional to accommodate the SSH-only
shape, but GatewaySettings and boot-failure-overlay read them as required —
tsc -p . --noEmit failed on every setState(config) and on the boot overlay.

- global.d.ts: remoteAuthMode / remoteOauthConnected / remoteTokenPreview /
  remoteUrl AND the five ssh* fields are now required on DesktopConnectionConfig.
  The contract is total; mode just decides which half is meaningful.
- main.cjs sanitizeDesktopConnectionConfig: the ssh branch returns inert
  remote-auth defaults; the local/remote branch returns empty ssh* defaults.
  Both branches now satisfy the total contract.
- boot-failure-reauth.test.ts: fixture carries the ssh* fields.

Verified: npm run typecheck clean; desktop-fs vitest 6/6; .cjs suites 116/116
(node --test). Pre-existing stale-base vitest failures (use-gateway-boot FIX:
tests, Windows-path + model/toolset/pane-shell) are unrelated — none touch any
file in this diff.
2026-06-23 13:22:14 +02:00
yoniebans
85c8848fa9 fix(desktop): lockfile records hermesHome + protocolVersion
The remote reuse lockfile omitted hermesHome and protocolVersion. protocolVersion
is load-bearing: it gates reuse across incompatible dashboard reuse-contracts —
without it, a future desktop could reattach to a dashboard whose token/spawn/
adoption semantics it no longer understands.

- Add PROTOCOL_VERSION (=1); reuse now requires lock.protocolVersion === current
  in addition to pid-alive + fingerprint-match + authenticated probe. A missing
  or mismatched protocolVersion fails closed -> clean respawn.
- probeRemoteHermesHome() records the remote HERMES_HOME (explicit env, else
  ~/.hermes; best-effort) in the lockfile.
- Tests: protocol-mismatch forces respawn; fresh spawn writes both fields. 26 pass.
2026-06-23 13:22:14 +02:00
yoniebans
97b44e5401 fix(desktop): dispose SSH terminals on connection flip
teardownSshConnection cancelled the forward and closed the control master but
left any interim ssh -tt terminals riding that master alive — after the flip
they were pointed at a dead control socket. Quit disposed them globally, but a
live A->B connection switch did not.

- Tag each SSH terminal session with its backing SSH scope at spawn
  (activeSshTerminalTarget now returns { ssh, scope }).
- teardownSshConnection disposes terminalSessions whose sshScope matches the
  scope being torn down, BEFORE closing the master (so the PTY dies while its
  socket is still valid). Local and other-scope terminals are untouched.

Honors the connection-flip teardown invariant (dispose terminal sessions on the
connection being torn down).
2026-06-23 13:22:14 +02:00
yoniebans
f0693a3232 fix(desktop): connectionCacheKey identity includes remote host (fs cache collision)
connectionCacheKey() keyed the desktop-fs cache on mode:profile:baseUrl only.
Local forwarded ports are reusable across different remotes, so two remotes
that map to the same 127.0.0.1:<localPort> (e.g. two SSH hosts whose tunnels
land on the same local port across reconnects) collide — one host's cached
directory listings and file reads get served for the other.

Fold the remote host into the identity: remoteHost (user@host for SSH, the real
backend host for token/oauth), with a baseUrl fallback. This is a latent bug on
main for ANY two remotes sharing a forwarded port, not only SSH — but SSH mode
makes it reachable in normal use.

Adds vitest coverage: two SSH hosts on the same local port get distinct keys;
the no-remoteHost fallback and local key are preserved.

vitest deferred (no node_modules in the worktree on this host); the regression
guard ships with the fix.
2026-06-23 13:22:14 +02:00
yoniebans
7c21034330 feat(desktop): interim ssh -tt remote terminal (SSH mode only; tracked for /api/terminal)
Make the integrated terminal land on the remote host when the window is
SSH-connected, so the chat/files/terminal loop is complete in SSH mode before
the dashboard /api/terminal WebSocket exists.

- ssh-connection.cjs: buildInteractiveSshArgs() — `ssh -tt` over the EXISTING
  control master (no new auth handshake; attaches instantly), cd into the
  remote session cwd best-effort, then exec "$SHELL" -l. Pure + node --test
  covered (PTY flag, master reuse, cwd cd, quote-safety).
- main.cjs: hermes:terminal:start spawns node-pty wrapping that ssh command
  when activeSshTerminalTarget() returns the live SSH connection for the
  window's primary backend; otherwise the existing local-shell path is
  unchanged. Gated to SSH mode ONLY — token/oauth remotes return null and never
  get a remote shell (their trust boundary is a token, not shell access). The
  existing resize IPC already propagates: node-pty.resize() -> SIGWINCH -> ssh
  -> remote PTY, end to end. Remote cwd is NOT run through safeTerminalCwd
  (that stats the local fs).

Clearly marked TODO(remote-terminal): replace with /api/terminal over the
tunnel once specs/desktop-remote-terminal.md lands, so cwd-follows-session
becomes uniform and this interim path is deleted.

ssh-connection node --test: 26 pass. main.cjs node --check OK.
2026-06-23 13:22:14 +02:00
yoniebans
56cded1ae1 feat(desktop): statusbar connection pill (SSH:/Remote: host) + i18n locale parity
Add a persistent connection-identity pill to the right-hand statusbar so the
user always knows WHERE a command lands — VS Code's load-bearing "am I local
or remote?" safety cue. More important in SSH mode precisely because the
experience is meant to feel identical to local.

- use-statusbar-items.tsx: new connectionItem rendered next to the version
  pills when connection.mode === "remote". SSH remotes read "SSH: user@host";
  token/oauth remotes read "Remote: host" — a free win that closes the same
  "where am I?" gap for the existing remote modes. Hidden in local mode.
  Clicking navigates to Settings (SETTINGS_ROUTE), so the pill doubles as the
  switch/disconnect entry point. Network (server) icon, distinct from the
  version Hash icon.
- main.cjs: buildRemoteConnection gains a remoteKind ("ssh" | "url") on every
  descriptor; the SSH bootstrap passes "ssh" so the pill can label it. The host
  is the SSH user@host (or the real backend host for url remotes), never the
  127.0.0.1 tunnel.
- global.d.ts: HermesConnection.remoteKind.
- i18n: connectionSsh / connectionRemote / *Tooltip added to types.ts AND every
  shipped locale (en, ja, zh, zh-hant) — locale parity.

Renderer typecheck/vitest deferred (no node_modules in the worktree on this
host). Delimiter/marker + i18n key-parity checks pass; main.cjs node --check OK.
2026-06-23 13:22:14 +02:00
yoniebans
fce5aaae4b feat(desktop): SSH connection settings UI + onboarding entry (issue #36970)
Add a third "Connect via SSH" connection mode to Settings -> Gateway next to
Local and Remote, so an SSH-capable user reaches a remote Hermes backend by
entering user@host — no token to copy, no dashboard pre-config (issue #36970).

- gateway-settings.tsx: third ModeCard (Network icon); SSH form with host
  (datalist-backed ~/.ssh/config suggestions + ssh -G resolve-on-blur that
  fills blank user/port/identity from the alias), user, port, identity file,
  and an optional remote Hermes path override.
- Connection test (Test SSH) runs ssh open + uname gate + locate-hermes WITHOUT
  spawning a dashboard, surfacing distinct unreachable / auth-failed /
  host-key-changed / hermes-not-found / unsupported-platform / timeout errors
  inline and via toast. Save/Connect persist mode:ssh; Connect applies + rehomes.
- icons.ts: add Network (IconServer).
- i18n: full SSH copy block added to types.ts AND every shipped locale
  (en, ja, zh, zh-hant) — locale parity, not just en.

Renderer typecheck/vitest deferred: no node_modules in the worktree on this
host; to be run by the user in a populated tree. Delimiter/marker structural
check + i18n key-parity check pass.
2026-06-23 13:22:14 +02:00
yoniebans
7d1afaa769 feat(desktop): connection-config ssh mode plumbing
Wire mode:"ssh" through the desktop connection resolution chain so an SSH
remote resolves into the EXISTING token-remote machinery — SSH mode is
desktop-local mode with the loopback stretched over SSH.

connection-config.cjs (pure, node --test):
- normalizeSshConfig() validates a {mode:ssh, host, user?, port?, keyPath?,
  remoteHermesPath?} entry (drops the default port, requires a host).
- profileSshOverride() resolves a profile-scoped SSH entry.
- hostLabelFromBaseUrl() derives the pill host for token/oauth remotes.

ssh-config.cjs (pure, node --test): parse ~/.ssh/config host aliases (follows
Include, filters wildcard/negated patterns, read-only, cycle-safe) and parse
ssh -G output (hostname/user/port/identityfile) for the settings UI.

main.cjs:
- readDesktopConnectionConfig / sanitizeConnectionProfiles preserve mode:ssh
  and the SSH fields; coerceDesktopConnectionConfig + buildSshBlock build/save
  SSH blocks (no user token; the dashboard token rides separately, encrypted).
- resolveRemoteBackend gains per-profile and global SSH branches that
  bootstrap via SshConnection + remote-lifecycle.connect(), persist the served
  token (encrypted), and hand buildRemoteConnection a 127.0.0.1 tunnel baseUrl
  with the SSH host as the pill label.
- buildRemoteConnection gains a remoteHost param + remoteHost on every
  descriptor (token/oauth pill host derived from the real URL).
- SSH connection-state registry (master + tunnel ports + remote pid per scope);
  teardownSshConnection cancels the forward + closes the master but LEAVES the
  remote dashboard running (reconnect-instant VS Code semantics); wired into
  before-quit and connection-config:apply (flip = re-bootstrap).
- testDesktopConnectionConfig SSH branch: ssh open + uname gate + locate-hermes
  WITHOUT spawning, returning distinct unreachable/auth-failed/hermes-not-found/
  unsupported-platform errors. New IPC: ssh-hosts, ssh-resolve. preload +
  global.d.ts updated (HermesConnection.remoteHost, SSH config/test/resolve
  types).

node --test: ssh-connection 23, remote-lifecycle 24, ssh-config 9,
connection-config 55 — 111 pass. (tsc/vitest deferred: no node_modules in the
worktree; renderer typecheck to be run by the user in a populated tree.)
2026-06-23 13:22:14 +02:00
yoniebans
cc24de2caa feat(desktop): remote dashboard lifecycle over SSH
Electron-free module that brings up (or reuses) a desktop-dedicated Hermes
dashboard on the remote host and a tunnel to it. Composes an injected
SshConnection with injected HTTP probes + served-token adoption so it stays
node --test-able.

- locateHermes(): profile path -> login-shell `command -v hermes` -> conventional
  venv path. The login-shell probe is load-bearing (non-login ssh PATH misses
  user installs). Clear hermes-not-found error with an install one-liner.
- probeRemotePlatform(): uname -s/-m gate to Linux/macOS; anything else fails
  with an unsupported-platform error before spawning.
- Lockfile on the remote (~/.hermes/desktop-ssh/<client>.lock.json, schemaVersion
  guarded). Reuse requires ALL of: schema parses, pid alive, the stored token's
  fingerprint matches the lockfile, AND an authenticated /api/status probe
  through the tunnel succeeds. PID liveness alone is insufficient (recycled pid,
  wedged dashboard, rotated token) — the probe is the deciding test.
- Spawn fresh: detached setsid `hermes dashboard --isolated --no-open --host
  127.0.0.1 --port 0`, sentinel-marked log so we scrape only THIS spawn's
  HERMES_DASHBOARD_READY port=<n>. --isolated keeps it off the host's unified
  machine dashboard.
- Served-token adoption against the tunneled baseUrl; the SERVED token's
  fingerprint lands in the lockfile so reuse checks the credential that actually
  authenticates /api/ws.
- Stale cleanup kills a pid ONLY when provably ours (cmdline carries hermes +
  dashboard + --isolated); always drops the lockfile.

24 node --test cases cover locate ordering, platform gate, lockfile parse/
write, pid-aliveness, provably-ours cleanup, spawn-command shape, readiness
scrape (incl. timeout + dead-process), and connect() fresh-spawn / reuse /
killed-respawn / wedged-respawn / unsupported-platform paths. Wired into
test:desktop:platforms.
2026-06-23 13:21:40 +02:00
yoniebans
f65468624b feat(desktop): ssh-connection.cjs — OpenSSH ControlMaster manager + token redaction
Electron-free SSH connection manager for Desktop SSH remote mode, using the
system OpenSSH client so it inherits ~/.ssh/config, the agent, ProxyJump, and
hardware keys for free (same rationale as tools/environments/ssh.py).

- SshConnection: exec(), forward()/cancelForward(), isAlive(), open(), close()
  over a persistent ControlMaster (ControlMaster=auto + ControlPersist), with a
  SHA256-hashed control-socket path kept short for macOS's 104-byte sun_path
  limit.
- BatchMode=yes everywhere: a programmatic ssh never hangs on a passphrase/2FA
  prompt; auth-needing-interactivity fails fast with guidance to load the key
  into the agent.
- Host-key policy StrictHostKeyChecking=accept-new (TOFU, fingerprint logged);
  a host-key CHANGE fails closed with the verbatim OpenSSH error surfaced.
- Every op raced against a hard timeout; timeout => connection-dead (half-open
  TCP after sleep) so the caller reconnects rather than retrying in place.
- redactSecrets() scrubs HERMES_DASHBOARD_SESSION_TOKEN, X-Hermes-Session-Token,
  Authorization: Bearer, and ?token=/?ticket= before any line hits desktop.log.
  All lifecycle logging routes through it.
- classifySshError() => distinct unreachable / auth-failed / host-key-changed /
  timeout kinds for actionable UI errors.

23 node --test cases cover command construction, redaction, error
classification, and the lifecycle with an injected fake spawn. Wired into
test:desktop:platforms.
2026-06-23 13:21:11 +02:00
kshitijk4poor
5ecf3bf0e0 fix(slack): report ext-matched audio mimetype for rerouted voice clips
Some checks failed
Deploy Site / deploy-vercel (push) Waiting to run
Deploy Site / deploy-docs (push) Waiting to run
Docker Build and Publish / build-amd64 (push) Waiting to run
Docker Build and Publish / build-arm64 (push) Waiting to run
Docker Build and Publish / merge (push) Blocked by required conditions
Lint (ruff + ty) / ruff + ty diff (push) Has been cancelled
Lint (ruff + ty) / ruff enforcement (blocking) (push) Has been cancelled
Lint (ruff + ty) / Windows footguns (blocking) (push) Has been cancelled
Tests / test (1) (push) Has been cancelled
Tests / test (2) (push) Has been cancelled
Tests / test (3) (push) Has been cancelled
Tests / test (4) (push) Has been cancelled
Tests / test (5) (push) Has been cancelled
Tests / test (6) (push) Has been cancelled
Tests / e2e (push) Has been cancelled
Typecheck / typecheck (apps/bootstrap-installer) (push) Has been cancelled
Typecheck / typecheck (apps/desktop) (push) Has been cancelled
Typecheck / typecheck (apps/shared) (push) Has been cancelled
Typecheck / typecheck (ui-tui) (push) Has been cancelled
Typecheck / typecheck (web) (push) Has been cancelled
Typecheck / desktop-build (push) Has been cancelled
Tests / save-durations (push) Has been cancelled
Follow-up to the salvaged voice-clip fix: the rerouted video/mp4 branch
used {".m4a": "audio/mp4"}.get(ext, "audio/mp4"), whose sole key's value
equals the default, so it always returned "audio/mp4" regardless of the
cached extension (dead lookup + a throwaway dict per inbound voice clip).

Replace it with a module-level _SLACK_EXT_TO_AUDIO_MIME map so the reported
media_type matches the bytes we cached (e.g. a clip cached as .wav now
reports audio/wav instead of audio/mp4). STT routing already keys on the
audio/ prefix + cached filename extension, so behavior is unchanged; this
just removes the dead construct and keeps the reported mimetype coherent.
2026-06-23 14:44:12 +05:30
Ben
2196584161 fix(slack): transcribe in-app voice messages (audio/mp4) instead of failing
Slack in-app voice clips ("record a clip") arrive as MP4/AAC containers
(mimetype audio/mp4, filename audio_message*.mp4), and Slack sometimes
labels them video/mp4. The inbound audio handler derived the cache
extension from the mimetype and fell back to ".ogg" for anything not in
{.ogg,.mp3,.wav,.webm,.m4a} — so audio/mp4 voice messages were cached as
.ogg. OpenAI STT (whisper-1, gpt-4o-transcribe) sniffs the container from
the FILENAME extension, so it received MP4 bytes named .ogg and rejected
them. WhatsApp .ogg and uploaded .m4a worked only because their extension
happened to match the bytes.

Fix:
- _resolve_slack_audio_ext(): pick the cache extension from the real
  filename first, then a mimetype map (audio/mp4 -> .m4a), defaulting to
  .m4a — never the bogus .ogg fallback. Mirrors the video branch and the
  audio map already in gateway/platforms/bluebubbles.py.
- _is_slack_voice_clip(): detect audio-only clips mislabeled video/mp4
  via the slack_audio subtype / audio_message* filename, and route them
  through the audio path (cached as audio, reported as audio/*) so they
  reach STT instead of video understanding. Genuine videos (and
  slack_video screen recordings) are left on the video path.

Verified end-to-end against a real audio-only MP4: old path cached it as
.ogg (ffprobe shows MP4 bytes -> container mismatch -> OpenAI rejects);
new path caches it as .mp4 (extension matches bytes -> accepted).

Adds inbound-audio tests (previously none): helper unit tests plus
_handle_slack_message E2E coverage for audio/mp4, video/mp4-mislabeled
voice clips, and a real video staying on the video path. Confirmed the
two voice-message tests fail without the fix (mutation check).
2026-06-23 14:44:12 +05:30
Ben Barclay
45bc4fb37f
feat(relay): declare relevance policy to the connector + document the management plane (#51248)
The gateway half of Phase 6 Unit ζ: project the agent's existing relevance
knobs into the connector's platform-agnostic vocabulary and declare them at boot
over the /relay/policy route, so the SAME mention-gating / free-response /
allow-bots behavior the agent applies directly also governs relay delivery (and
excluded chatter never wakes a scaled-to-zero agent).

- gateway/relay/__init__.py:
  - relay_relevance_policy(): project require_mention -> requireAddress,
    free_response_channels -> freeResponseScopes, {PLATFORM}_ALLOW_BOTS in
    {mentions,all} -> allowOtherBots. Reads the fronted platform's config block
    + bridged top-level keys. Returns None when all-default (the connector's
    quiet default already matches) or no concrete platform is fronted.
  - send_relay_policy(): POST /relay/policy authenticated with the gateway's own
    per-gateway upgrade token (make_upgrade_token — same bearer as the WS
    upgrade), so the connector attaches it to the authenticated instance, never
    a body-asserted id. Re-declares every boot (self-healing, full replace).
    NEVER raises, NEVER blocks boot — relevance is an optimization layered on
    the δ/ε authorization gate. Reuses the per-gateway secret + the
    /relay/provision host; no new inbound surface, no new credential.
  - _policy_url(): ws(s)://…/relay -> http(s)://…/relay/policy.
- gateway/run.py: call send_relay_policy() after register_relay_adapter()
  succeeds (the secret is resolved by then).
- docs/relay-connector-contract.md: new §7 documenting per-instance delivery +
  the management plane (/manage/* + /relay/policy) + the relevance-declaration
  contract; versioning renumbered to §8. Contract conformance test stays green
  (§2/§3 tables untouched).

Tests: +12 (projection mapping incl. comma-string + top-level fallback; send
auth/skip/fail-soft/non-200). Full relay suite 118 pass. The connector route is
already E2E-proven (connector repo gateway_policy_driver.py); this adds the real
gateway send-path it pairs with.

This completes Phase 6 (Team Gateway per-user isolation) end to end.
2026-06-23 18:43:19 +10:00
brooklyn!
211ba9c7d3
feat(agent): one-shot LLM helper + llm.oneshot gateway RPC (#51261)
A "one-shot" is a single stateless model call that runs OUTSIDE any conversation:
it never touches session history, never breaks prompt caching, and returns plain
text. UI surfaces need this for small generative chores — a commit message from a
diff, a rename suggestion, a summary — where an agent turn would pollute the
thread and hand-rolling an LLM call at every call site would be worse.

- `agent/oneshot.py`: `run_oneshot(...)` over the existing auxiliary-client
  plumbing (same path as title generation). Two call shapes: explicit
  instructions/input, or a registered `template` + `variables` (templates own the
  prompt engineering so it stays consistent across CLI/TUI/desktop). Ships a
  `commit_message` template. Model selection inherits the live session via
  `main_runtime`, else the configured aux `task` backend.
- `tui_gateway/server.py`: `llm.oneshot` RPC (long-handler) inheriting the
  session's model when `session_id` resolves.

Stateless by construction — no session mutation, cache untouched.
2026-06-23 08:01:50 +00:00
brooklyn!
af7b7f6322
feat(agent): expose coding-context project facts as structured data + project.facts RPC (#51259)
Follow-up to the coding-context posture (#43316): that PR detects each repo's
verify loop (manifests, package manager, exact test/lint/build commands, context
files) and bakes it into the system-prompt snapshot — but only as a string, for
the model. Non-prompt consumers (the desktop verify UI) had no way to read it
without re-sniffing and drifting from the prompt.

Split detection from rendering, keeping one source of truth:

- `detect_project_facts(root) -> ProjectFacts` (frozen) holds the structured
  facts; `_project_facts()` now renders it into the same snapshot lines, so the
  prompt block stays byte-identical (cache-safe).
- `project_facts_for(cwd)` resolves the workspace root (git, else marker) and
  returns the structured facts, or None outside a workspace.
- `project.facts` gateway RPC surfaces it to any client (desktop/TUI/ACP).

Tests assert the structured output and that the UI-facing commands never drift
from what the prompt block renders (one detector feeds both).
2026-06-23 08:00:01 +00:00
Teknium
bb7ff7dc30
revert(cron): return cron job storage to per-profile (reverts #32117 + #50993) (#51116)
* Revert "fix(cron): scope job execution to its owning profile (#32091 follow-up) (#50993)"

This reverts commit 660e36f097.

* Revert "fix(cron): anchor cron storage at the default root home (not the active profile)"

This reverts commit a5c09fd176.
2026-06-22 17:53:50 -07:00
brooklyn!
2a10b8384a
Merge pull request #51103 from NousResearch/bb/desktop-tool-preview-cleanup
fix(desktop): manual tool previews via status stack
2026-06-22 19:29:29 -05:00
Brooklyn Nicholson
7daa6d83fc style(desktop): soften inline code and expanded tool chrome
Drop the inline-code border; halve the expanded tool block radius.
2026-06-22 19:23:07 -05:00
Brooklyn Nicholson
48a8f84169 fix(desktop): toggle preview rail and open in browser
Status row opens/closes the preview pane; external link uses a dedicated
file:// browser bridge (openExternal, not openPath).
2026-06-22 19:22:11 -05:00
Brooklyn Nicholson
d0af7fc954 feat(desktop): detect tool previews into composer status stack
Register previewable artifacts from the tool row, feed a session-scoped store,
and render compact rows above the composer. Remove the inline preview card.
2026-06-22 19:22:11 -05:00
Brooklyn Nicholson
cb17a9efb2 fix(desktop): stop auto-opening tool previews
Drop gateway-event preview registration so HTML artifacts from tool results
no longer pop the rail. De-dupe the inline preview card label.
2026-06-22 19:21:20 -05:00
Eri Barrett
ba9e3a491b
feat(memory): Honcho OAuth connect — desktop and CLI flows + token refresh (#44335)
* feat(memory): OAuth token storage and refresh for the Honcho provider

* feat(memory): refresh the Honcho OAuth token in the client and session

* feat(memory): zero-CLI loopback OAuth authorization flow

* feat(memory): generic memory-provider OAuth connect endpoints

* feat(desktop): memory-provider OAuth connect link

* feat(memory): CLI OAuth sign-in with source-tagged authorize links

* fix(memory): IP-literal loopback redirect and consent config_path on the authorize link

* fix(memory): profile-scope the memory-provider OAuth endpoints

* refactor(desktop): generic memory-provider OAuth client functions

* docs(memory): trim OAuth module docstrings to the invariants

* docs(memory): document OAuth connect as an optional auth method

* fix(memory): send home-relative display path to consent, not the absolute path

* perf(memory): cache OAuth token expiry in memory to skip the hot-path disk read

* fix(memory): log OAuth refresh failures at warning, not debug

* feat(memory): fall back to an OS-assigned loopback port when 8765 is taken

* test(memory): cover the desktop Connect launcher, status, and provider dispatch

* fix(desktop): keep the memory-provider dropdown one size regardless of connect state

* fix(desktop): move the memory connect link to the description line, leaving the dropdown untouched

* refactor(memory): move OAuth connect routes out of web_server into a memory-layer router

* refactor(desktop): import MemoryConnect directly, drop the single-export barrel

* fix(memory): launch CLI OAuth sign-in right after the auth choice, not after the wizard

* fix(desktop): auto-clear the OAuth error state instead of leaving it sticky

* test(honcho): isolate auth-method prompt from deployment-shape wizard tests

main's wizard suite scripts the cloud prompts without the OAuth auth-method step; auto-answer it in the shared helper so the answer lists stay shape-only.

* docs(honcho): document query-adaptive reasoning level (reasoningHeuristic)

README never mentioned reasoningHeuristic and listed reasoningLevelCap as an orphaned cap with the wrong default (— vs "high"). Add the query-adaptive scaling note + the reasoningHeuristic/reasoningLevelCap rows (grouped under Dialectic & Reasoning), matching the wording already on the hosted honcho.md page, and add a pointer from the memory-providers overview.

* fix(honcho): default the CLI peer prompt to the OAuth consent name

The CLI runs the grant with apply_config=False, so the peerName the user just entered at consent was dropped and the wizard's 'Your name' prompt fell back to $USER. Surface it as a transient OAuthCredential.consent_peer_name (set even when config isn't merged) and seed the prompt default from it.

* feat(honcho): split OAuth client_id by surface (cli=hermes-agent, desktop=hermes-desktop)

resolve_endpoints now picks the client_id from the initiating surface and
threads it through authorize -> token exchange -> persisted grant -> refresh,
so the CLI and desktop register as distinct OAuth clients. Surface-specific
env overrides (HONCHO_OAUTH_CLIENT_ID_CLI/_DESKTOP) win over the generic
HONCHO_OAUTH_CLIENT_ID, which still overrides every surface.

* feat(honcho): show OAuth vs API key in status; detect existing OAuth in setup

status now prints 'Auth: OAuth (clientId, token valid Xm/expired)' instead of
masking the OAuth access token as a generic API key; setup notes an existing
OAuth grant when re-run.

* docs(honcho): drop 'shared pool' wording from unified observation mode help

* fix(honcho): cross-process lock around OAuth refresh to prevent grant revocation

The in-process threading lock can't stop a sibling process (another profile or
the desktop app sharing honcho.json) from replaying the single-use refresh
token and tripping reuse-detection, which revokes the whole grant. Guard the
read-refresh-persist section with an OS file lock on <config>.lock so only one
process rotates at a time; the others re-read the freshly-persisted token.
Best-effort: platforms without flock degrade to in-process serialization.

* refactor(honcho): one OAuth client (hermes-agent) for all surfaces

Collapse the per-surface client_id split. CLI and desktop now use a single
client_id (hermes-agent); consent branding/UI still adapt via the source query
param. One grant identity means no clientId-vs-refresh-token desync that could
get the grant revoked. HONCHO_OAUTH_CLIENT_ID still overrides for self-hosting.

* fix(honcho): per-session resolves to session_id, never remapped by title

Reorder resolve_session_name so stable identifiers win over labels: gateway
per-chat key first, then the per-session session_id, then the cwd map / title.
A (possibly auto-generated) title can no longer remap a live per-session
conversation onto a second Honcho session mid-stream — fixes the desktop, which
is per-conversation via session_id. Consequence: a gateway's per-chat key now
also wins over a title (titles never remap a stable id).
2026-06-22 19:16:47 -05:00
brooklyn!
672ea1f894
Merge pull request #50994 from NousResearch/hermes/hermes-9fb04abd
fix(computer-use): working vision capture + whole-screen/desktop target on Windows
2026-06-22 19:02:04 -05:00
Brooklyn Nicholson
833710d33e Merge remote-tracking branch 'origin/main' into pr-50994
# Conflicts:
#	tools/computer_use/cua_backend.py
2026-06-22 18:48:07 -05:00
brooklyn!
116331dd3f
Merge pull request #51094 from NousResearch/bb/desktop-thread-timeline
feat(desktop): conversation timeline rail for long threads
2026-06-22 18:41:13 -05:00
brooklyn!
760fd9513e
Merge pull request #51078 from NousResearch/bb/fix-vision-capture
fix(computer-use): vision capture returns an image on cua-driver >=0.5.x
2026-06-22 18:37:18 -05:00
brooklyn!
6780cee679
Merge pull request #51072 from NousResearch/bb/desktop-computer-use
feat(computer-use): add a cross-platform readiness preflight to the desktop
2026-06-22 18:37:07 -05:00
Brooklyn Nicholson
3fffecbdaf feat(desktop): add timeline rail for long chat threads
Adds a compact right-edge prompt timeline for long desktop chat sessions, with hover previews, click-to-jump, active/hover row states, and pane hover-reveal suppression so the rail can live at the hard edge without opening side panels.
2026-06-22 18:34:07 -05:00
brooklyn!
9bacd7d4bb
Merge pull request #51096 from NousResearch/bb/desktop-oversized-image-replay
fix(agent): shrink anthropic-native image history
2026-06-22 18:30:18 -05:00
brooklyn!
b90f1e4ac0
Merge pull request #51093 from NousResearch/bb/desktop-string-stack-overflow
fix(desktop): avoid stack overflow on embedded image replay
2026-06-22 18:26:34 -05:00
Brooklyn Nicholson
88e136448d fix(agent): shrink anthropic-native image history
Retry image-size rejections by rewriting Anthropic base64 image source blocks, not just OpenAI-style image_url parts.
2026-06-22 18:23:21 -05:00
Brooklyn Nicholson
a6b670d4a2 fix(desktop): avoid stack overflow on embedded image replay
Replace the giant embedded-image regex with a bounded scanner so opening sessions with multi-megabyte data URLs does not crash the renderer.
2026-06-22 18:19:36 -05:00
Brooklyn Nicholson
3c1058e2e9 fix(computer-use): set stdin=DEVNULL on cua-driver subprocess calls
The subprocess-stdin guard (TUI gateway fd-inheritance protection) flagged
the `permissions grant` call. None of the cua-driver probes/grant read
stdin, so DEVNULL is correct; apply it to the shared `_run` helper and the
grant call.
2026-06-22 17:59:18 -05:00
Brooklyn Nicholson
2dfcead683 feat(computer-use): make the preflight cross-platform (win/linux)
The card was macOS-only. cua-driver also runs on Windows and Linux, so
fold `cua-driver doctor` (cross-platform binary/health probes) into a
single OS-aware `ready` signal:

- macOS: ready == both TCC grants; keeps the permission rows + grant flow.
- Windows/Linux: no TCC toggles, so ready == driver health, with a
  per-OS note (SmartScreen/UIAccess on Windows; X11/XWayland on Linux).

`computer_use_status()` replaces the macOS-only `permissions_status()` and
surfaces `platform`, `ready`, `can_grant`, and the doctor `checks` (non-ok
ones render as warnings). CLI `permissions status`, the REST endpoint, and
the desktop card all key off the one payload. Grant stays macOS-only (400
elsewhere — nothing to grant).
2026-06-22 17:48:43 -05:00
Brooklyn Nicholson
807b696295 fix(computer-use): vision capture returns an image on cua-driver >=0.5.x
Vision mode called a `screenshot` MCP tool that cua-driver dropped in
0.5.x (full-window PNG capture was folded into `get_window_state`). The
driver replied "Unknown tool: screenshot", so `images` came back empty,
`png_b64` stayed None, and capture returned a 0x0 result with no image
on every call. `som`/`ax` were unaffected because they already use
`get_window_state`, which masked the regression.

Route vision by capability:
- driver advertises `screenshot` (older builds) -> use it (no AX walk)
- otherwise -> call `get_window_state` but discard the AX tree/elements,
  returning only the PNG so vision stays free of element noise
- capabilities not yet discovered -> try `screenshot`, fall back to
  `get_window_state` on an empty image, so the path self-heals

Add `_image_from_tool_result` to pull the PNG from either an MCP image
content-part or `structuredContent.screenshot_png_b64`, and use it on
the som path too so the image won't silently drop on driver builds that
deliver it via structuredContent instead of a content part.

Verified live (vision: 1568x954, 0 elements; som: image + 527 elements)
and with unit coverage of all four routing cases.
2026-06-22 17:41:42 -05:00
Brooklyn Nicholson
0223ea5f59 feat(computer-use): surface macOS permission preflight in the desktop
Computer Use already worked through the desktop backend (the cua-driver
toolset enables + installs via Settings -> Skills & Tools), but there was
no in-app way to see or grant the two macOS permissions it needs, so "give
a model my Mac" was tribal knowledge.

The grants attach to cua-driver's OWN TCC identity (com.trycua.driver /
the installed CuaDriver.app), not Hermes -- so no app entitlement is
involved. cua-driver 0.5+ exposes `permissions status/grant`, which we wrap:

- tools/computer_use/permissions.py: thin client over the two subcommands
- hermes computer-use permissions {status,grant}: CLI parity
- GET /api/tools/computer-use/status, POST .../permissions/grant: desktop REST
- ComputerUsePanel: live Accessibility + Screen Recording state with a
  Grant button (dialog attributed to CuaDriver), shown in the expanded
  Computer Use toolset row. Binary install stays in the existing provider
  post-setup runner.

Follow-ups: i18n the card copy; a "Stop driver" control (cua-driver stop)
for the runaway-`serve` case.
2026-06-22 17:33:52 -05:00
Teknium
87c4a5ebb8
feat(background-review): aux-model selector for the self-improvement review (#49252)
Adds auxiliary.background_review.{provider,model} (default auto = main chat
model — unchanged). Set it to a different, cheaper model and the post-turn
self-improvement review runs there for ~3-5x lower cost.

Cache-aware by design: the main chat is warm in the prompt cache, so the
default full-history replay on the main model is cheap cache reads — left
exactly as-is. A different model can't reuse that cache (different key), so
when (and only when) routed to a different model the fork replays a compact
digest instead of the full transcript, minimising what it cold-writes on the
aux model. Same model -> full replay; different model -> digest.

Quality holds in benchmarks: memory capture identical, skill near-identical.
Nothing changes unless you opt in by naming a different model.

Co-authored-by: Hermes Agent <noreply@nousresearch.com>
2026-06-22 14:54:53 -07:00
Teknium
660e36f097
fix(cron): scope job execution to its owning profile (#32091 follow-up) (#50993)
The #32091 fix moved every profile's cron jobs into one shared root store,
but never wired the execution-scoping half it recommended: a job still ran
under whichever profile's ticker picked it up, not its owning profile. So a
job created under `hermes -p donna` could execute with the root profile's
.env / config.yaml / credentials.

- jobs.py: create_job auto-captures the active profile (explicit profile=
  override available) and stores it on the job; resolve_profile_home() maps a
  profile name to its HERMES_HOME; legacy jobs backfill to 'default'.
- scheduler.py: run_job applies the job's profile via a scoped HERMES_HOME
  override (env var + in-process ContextVar) before any .env/config/script
  load, restored in finally. tick() routes profile-mismatched jobs to the
  single-worker sequential pool so the env mutation can't race.
- cronjob tool threads profile through (NOT exposed in the model schema, to
  avoid cross-profile privilege escalation); hermes cron add gains --profile.

E2E verified against a temp HERMES_HOME with a real profile dir: a root-profile
ticker runs a profile='donna' job with HERMES_HOME=donna during execution and
restores the ticker env afterward.
2026-06-22 14:54:28 -07:00
Tranquil-Flow
15880da8bb fix(file_tools): resolve tilde using profile home for file operations (#48552)
File tools (read_file, write_file, patch, list_directory, etc.) used
os.path.expanduser() which reads the gateway process HOME env var.
In Docker/systemd/s6 deployments where the gateway HOME differs from
interactive sessions, tilde expanded to the wrong directory.

Add _expand_tilde() helper that delegates to get_subprocess_home() when
available, falling back to os.path.expanduser(). Replace all 9
expanduser() call sites in file_tools.py with _expand_tilde().
2026-06-23 03:17:47 +05:30
kshitijk4poor
c080b2dc3e fix(gateway): redact credentials from TUI approval prompts (#48456)
Follow-up to #50767, which redacted the chat-platform (_approval_notify_sync)
and SSE/API (_approval_notify) approval transports. The TUI JSON-RPC transport
is the third egress and was missed: three register_gateway_notify callbacks in
tui_gateway/server.py emitted the raw approval_data — including the unredacted
command Tirith flagged — straight to the TUI client via _emit.

Route all three registrations through a new module-level _emit_approval_request()
helper that redacts payload['command'] via the shared
gateway.run._redact_approval_command seam before emitting, matching the pattern
used for the other two transports. Completes the whole-bug-class fix for #48456.

Tests: assert the helper emits a redacted command (real credential pattern),
handles missing/None command, and a wiring guard that no registration emits the
raw payload directly (only the helper may). Both mutation-checked.

The #48456 fix series originated from @liuhao1024's #48462 — credit to them for
the original report and chat-platform fix; this completes the remaining transport.

Co-authored-by: liuhao1024 <sunsky.lau@gmail.com>
2026-06-23 03:14:18 +05:30
kshitijk4poor
0e69cd4b37 fix(memory): honor configured char limits in the no-agent on-disk store
Follow-up to the /memory approve fresh-store fix. Both the CLI fallback and
the messaging-gateway handler built a bare MemoryStore() with the hardcoded
default char limits (2200/1375), ignoring the user's configured
memory.memory_char_limit / user_char_limit. A live agent honors those
overrides (agent/agent_init.py), so an approval applied without a live agent
could accept a write the user's lower cap would reject, or vice versa.

Extract a shared tools.memory_tool.load_on_disk_store() factory that reads
the configured limits (falling back to defaults if config can't load) and
wire both the CLI and gateway handlers to it, closing the gap on both
surfaces and de-duplicating the construction block.
2026-06-23 03:10:53 +05:30
Max Hsu
3147cbb136 fix(memory): apply /memory approve against a fresh store when no live agent
The CLI /memory slash handler (cli_commands_mixin._handle_memory_command)
passed self.agent._memory_store straight through, which is None when the
command runs without a live agent — e.g. /memory approve from the Desktop
GUI. The shared write-approval handler then returns "memory store
unavailable" and applies nothing, even with built-in memory enabled and
pending writes present.

Fall back to a freshly loaded on-disk MemoryStore when no live store is
available, mirroring the gateway path (gateway/slash_commands.py). It
persists to the same MEMORY/USER.md and creates MEMORY.md on the first
approved write.

Fixes #46783

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 03:10:53 +05:30
kshitijk4poor
100e7be20e fix(security): deny root-level credential stores in media delivery
The media-delivery denylist in gateway/platforms/base.py enumerated only
.env/auth.json/credentials/config.yaml under HERMES_HOME, so other
credential stores that live at the root fell through and could be
auto-attached to chat replies. The reported case: the Google Workspace
skill's google_token.json refreshes every turn, bumping its mtime to
'now', which kept passing the strict-mode recency window and re-sent the
OAuth token on every reply.

Extend the explicit per-file denylist to mirror the canonical credential
set already enforced by the read/write guards in agent/file_safety.py:
google_token.json, google_oauth_pending.json, auth/google_oauth.json,
.anthropic_oauth.json, webhook_subscriptions.json, cache/bws_cache.json,
auth.lock, and the pairing/ token directory.

Targeted per-file additions (not a blanket ~/.hermes deny, which was
declined in #32090/#34425 because it would block skills/, logs/, and
ad-hoc agent-written deliverables). mcp-tokens/ (#37222) and
state.db/kanban.db (#41071) are left to their sibling targeted PRs.

Reported-by: xxxigm (#50912)
2026-06-23 02:56:48 +05:30