Skip to content

Commit 9bd07fb

Browse files
author
itsdodobitch
committed
Add direct local Hermes support
1 parent 985de07 commit 9bd07fb

33 files changed

Lines changed: 1320 additions & 282 deletions

README.md

Lines changed: 93 additions & 82 deletions
Large diffs are not rendered by default.

SECURITY.md

Lines changed: 39 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
# Security Model
22

3-
Hermes Desktop is a native macOS client for Hermes that talks directly to the
4-
selected host over SSH.
3+
Hermes Desktop is a native macOS client for Hermes that operates directly on
4+
this Mac or connects to another machine over SSH.
55

6-
The host stays the source of truth. The app does not introduce a gateway API,
7-
background daemon, local mirror, or separate sync layer.
6+
The active Hermes machine stays the source of truth. The app does not introduce
7+
a gateway API, background daemon, synchronized mirror, or separate sync layer.
88

99
This document describes the current implementation in this repository. It is
1010
not a promise about future packaging, signing, or infrastructure changes.
@@ -16,17 +16,24 @@ Hermes Desktop runs as a normal macOS app on your Mac.
1616
Local execution includes:
1717

1818
- the app UI and local state handling
19-
- `/usr/bin/ssh` for all host communication
20-
- a local embedded terminal session that opens an SSH shell to the selected
21-
host
19+
- direct `/bin/sh` service commands when the connection type is `This Mac`
20+
- a local embedded terminal when the connection type is `This Mac`
21+
- `/usr/bin/ssh` and an embedded SSH shell when the connection type is
22+
`SSH Host`
2223
- the built-in update check, which requests the latest Hermes Desktop release
2324
metadata from the GitHub Releases API
2425

2526
The app does not install a helper service on the host or on your Mac.
2627

27-
## What Executes Remotely Over SSH
28+
Direct-local commands run with the current macOS user's permissions against
29+
that account's real Hermes files. The app reuses the same `HERMES_HOME`, named
30+
profile, custom-home, PATH, Hermes CLI, service, and terminal bootstrap rules
31+
used for SSH connections.
2832

29-
Hermes Desktop uses SSH to execute commands on the selected host.
33+
## What Can Execute Remotely Over SSH
34+
35+
For an `SSH Host` connection, Hermes Desktop uses SSH to execute commands on
36+
the selected host.
3037

3138
Current remote execution includes:
3239

@@ -35,7 +42,7 @@ Current remote execution includes:
3542
- remote shell startup for the embedded terminal
3643
- remote `hermes` CLI invocations for in-app chat and session resume flows
3744

38-
The app therefore depends on the remote SSH environment you already trust:
45+
SSH mode therefore depends on the remote SSH environment you already trust:
3946

4047
- SSH access must already work from Terminal on your Mac
4148
- `python3` must exist on the host for service-style RPC requests
@@ -51,12 +58,12 @@ Hermes Desktop stores a small amount of local state under:
5158
Current files written there include:
5259

5360
- `connections.json`
54-
Saved connection definitions such as label, SSH alias or host, user, port,
55-
and selected Hermes profile
61+
Saved connection definitions including connection type, label, SSH routing
62+
fields when applicable, and the selected Hermes profile or custom home
5663
- `preferences.json`
5764
App preferences and lightweight workspace state such as last-used
5865
connection, terminal theme and font preferences, app appearance preference,
59-
background image metadata, update-check preference, bookmarked remote files,
66+
background image metadata, update-check preference, bookmarked Hermes files,
6067
pinned sessions, sidebar order, and workflow presets
6168

6269
If you choose a custom appearance background image, Hermes Desktop copies that
@@ -69,40 +76,41 @@ support directory is created with private directory permissions (`0700`).
6976
They are ordinary JSON files under your macOS user account, not Keychain
7077
entries.
7178

72-
The app also creates SSH control sockets under:
79+
For SSH connections, the app also creates control sockets under:
7380

7481
`/tmp/hd-<uid>`
7582

7683
That directory is also created with private directory permissions (`0700`).
7784

7885
## What Is Not Stored Locally
7986

80-
Hermes Desktop does not maintain a local mirror of Hermes host state.
87+
Hermes Desktop does not maintain a synchronized mirror of Hermes state.
8188

8289
In the current implementation, it does not store these Hermes artifacts as a
83-
local source of truth:
90+
second source of truth:
8491

85-
- remote session databases and transcripts
86-
- remote Kanban databases
87-
- remote cron job definitions
88-
- remote Hermes skill directories
89-
- remote workspace files as a synchronized mirror
92+
- session databases and transcripts
93+
- Kanban databases
94+
- cron job definitions
95+
- Hermes skill directories
96+
- workspace files as a synchronized mirror
9097

9198
Unsaved edits can still exist transiently in app memory while you are working,
92-
but the app's design is to read and write the canonical state on the host.
99+
but the app's design is to read and write the canonical state on the active
100+
Hermes machine.
93101

94102
Workflow presets are the notable local exception: they are intentionally stored
95-
as lightweight launch helpers on your Mac, scoped to the selected host/profile,
96-
and used only to seed a fresh remote Terminal session. They are not a local
97-
mirror of remote Hermes state.
103+
as lightweight launch helpers on your Mac, scoped to the active
104+
connection/profile, and used only to seed a fresh local or SSH Terminal
105+
session. They are not a mirror of Hermes state.
98106

99107
## Secrets And Credentials
100108

101109
Hermes Desktop does not ask you to enter an SSH password into the app.
102110

103-
Current connection profiles store routing details such as alias, host, user,
104-
port, and Hermes profile name. They do not contain SSH private keys, API keys,
105-
or a stored SSH password.
111+
Current connection profiles store the connection type, Hermes profile details,
112+
and—when using SSH—routing details such as alias, host, user, and port. They do
113+
not contain SSH private keys, API keys, or a stored SSH password.
106114

107115
The app assumes SSH authentication is already handled by your existing macOS
108116
and SSH setup.
@@ -113,7 +121,7 @@ Hermes Desktop keeps its network surface intentionally small.
113121

114122
In the current implementation, network calls are:
115123

116-
- SSH connections to the host you explicitly configure
124+
- SSH connections only for hosts you explicitly configure as `SSH Host`
117125
- an optional GitHub API request to
118126
`https://api.github.com/repos/dodo-reach/hermes-desktop/releases/latest`
119127
when checking whether a newer Hermes Desktop version exists
@@ -133,8 +141,8 @@ If you want to validate the app before trusting it:
133141
- verify the installed bundle with
134142
`codesign --verify --deep --strict /Applications/HermesDesktop.app`
135143
- observe live connections with Little Snitch, LuLu, `lsof`, or `nettop`
136-
- compare this document with the current code, especially the SSH transport,
137-
local storage, update check, and packaging scripts
144+
- compare this document with the current code, especially the local/SSH
145+
transport switch, local storage, update check, and packaging scripts
138146

139147
For release-specific details and the limits of those checks, see
140148
[docs/distribution.md](docs/distribution.md).

Sources/HermesDesktop/App/AppState.swift

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,11 @@ final class AppState: ObservableObject {
566566
guard !profile.isDefault && profile.name != "default" else {
567567
activeAlert = AppAlert(
568568
title: L10n.string("Default profile cannot be deleted"),
569-
message: L10n.string("Hermes Desktop will not delete ~/.hermes from the host. Use Stop Tracking if you only want to hide the profile locally.")
569+
message: L10n.string(
570+
activeConnection.kind == .local
571+
? "Hermes Desktop will not delete ~/.hermes from this Mac. Use Stop Tracking if you only want to hide the profile in the app."
572+
: "Hermes Desktop will not delete ~/.hermes from the host. Use Stop Tracking if you only want to hide the profile locally."
573+
)
570574
)
571575
return
572576
}
@@ -636,14 +640,23 @@ final class AppState: ObservableObject {
636640
guard connectionTestRequestID == requestID else { return }
637641
isBusy = false
638642
let home = response.remoteHome.trimmingCharacters(in: .whitespacesAndNewlines)
639-
setStatusMessage(L10n.string("SSH and python3 OK for %@", profile.label))
640-
let messageLines = [
641-
L10n.string("SSH and python3 are available for this Hermes host."),
642-
home.isEmpty ? nil : L10n.string("Remote HOME: %@", home)
643-
].compactMap { $0 }
643+
setStatusMessage(
644+
profile.kind == .local
645+
? L10n.string("Local Hermes and python3 OK for %@", profile.label)
646+
: L10n.string("SSH and python3 OK for %@", profile.label)
647+
)
648+
let messageLines: [String?] = profile.kind == .local
649+
? [
650+
L10n.string("Hermes Desktop can run local commands with python3 using your current macOS account."),
651+
home.isEmpty ? nil : L10n.string("Local HOME: %@", home)
652+
]
653+
: [
654+
L10n.string("SSH and python3 are available for this Hermes host."),
655+
home.isEmpty ? nil : L10n.string("Remote HOME: %@", home)
656+
]
644657
activeAlert = AppAlert(
645658
title: L10n.string("Connection OK"),
646-
message: messageLines.joined(separator: "\n")
659+
message: messageLines.compactMap { $0 }.joined(separator: "\n")
647660
)
648661
} catch {
649662
guard connectionTestRequestID == requestID else { return }
@@ -687,7 +700,11 @@ final class AppState: ObservableObject {
687700
guard isActiveWorkspace(profile) else { return }
688701
overview = nil
689702
overviewError = error.localizedDescription
690-
setStatusMessage(L10n.string("Unable to refresh remote discovery"))
703+
setStatusMessage(L10n.string(
704+
profile.kind == .local
705+
? "Unable to refresh local discovery"
706+
: "Unable to refresh remote discovery"
707+
))
691708
}
692709
}
693710

@@ -935,7 +952,7 @@ final class AppState: ObservableObject {
935952
guard isActiveWorkspace(profile) else { return }
936953
isLoadingWorkspaceFileBrowser = false
937954
workspaceFileBrowserError = error.localizedDescription
938-
setStatusMessage(L10n.string("Unable to browse remote files"))
955+
setStatusMessage(L10n.string("Unable to browse files"))
939956
}
940957
}
941958

@@ -1232,7 +1249,11 @@ final class AppState: ObservableObject {
12321249
await loadSessions(reset: true)
12331250
await loadUsage(forceRefresh: true)
12341251
isDeletingSession = false
1235-
setStatusMessage(L10n.string("Session deleted locally and on the remote Hermes host"))
1252+
setStatusMessage(L10n.string(
1253+
profile.kind == .local
1254+
? "Session deleted from this Mac’s real Hermes data"
1255+
: "Session deleted locally and on the remote Hermes host"
1256+
))
12361257
} catch {
12371258
guard isActiveWorkspace(profile) else { return }
12381259
isDeletingSession = false

Sources/HermesDesktop/Models/ConnectionProfile.swift

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
import Foundation
22

3+
enum ConnectionKind: String, Codable, CaseIterable, Identifiable {
4+
case ssh
5+
case local
6+
7+
var id: String { rawValue }
8+
}
9+
310
struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable {
411
var id: UUID
12+
var kind: ConnectionKind
513
var label: String
614
var sshAlias: String
715
var sshHost: String
@@ -15,6 +23,7 @@ struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable {
1523

1624
init(
1725
id: UUID = UUID(),
26+
kind: ConnectionKind = .ssh,
1827
label: String = "",
1928
sshAlias: String = "",
2029
sshHost: String = "",
@@ -27,6 +36,7 @@ struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable {
2736
lastConnectedAt: Date? = nil
2837
) {
2938
self.id = id
39+
self.kind = kind
3040
self.label = label
3141
self.sshAlias = sshAlias
3242
self.sshHost = sshHost
@@ -39,6 +49,37 @@ struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable {
3949
self.lastConnectedAt = lastConnectedAt
4050
}
4151

52+
private enum CodingKeys: String, CodingKey {
53+
case id
54+
case kind
55+
case label
56+
case sshAlias
57+
case sshHost
58+
case sshPort
59+
case sshUser
60+
case hermesProfile
61+
case customHermesHomePath
62+
case createdAt
63+
case updatedAt
64+
case lastConnectedAt
65+
}
66+
67+
init(from decoder: Decoder) throws {
68+
let container = try decoder.container(keyedBy: CodingKeys.self)
69+
id = try container.decode(UUID.self, forKey: .id)
70+
kind = try container.decodeIfPresent(ConnectionKind.self, forKey: .kind) ?? .ssh
71+
label = try container.decode(String.self, forKey: .label)
72+
sshAlias = try container.decodeIfPresent(String.self, forKey: .sshAlias) ?? ""
73+
sshHost = try container.decodeIfPresent(String.self, forKey: .sshHost) ?? ""
74+
sshPort = try container.decodeIfPresent(Int.self, forKey: .sshPort)
75+
sshUser = try container.decodeIfPresent(String.self, forKey: .sshUser) ?? ""
76+
hermesProfile = try container.decodeIfPresent(String.self, forKey: .hermesProfile)
77+
customHermesHomePath = try container.decodeIfPresent(String.self, forKey: .customHermesHomePath)
78+
createdAt = try container.decode(Date.self, forKey: .createdAt)
79+
updatedAt = try container.decode(Date.self, forKey: .updatedAt)
80+
lastConnectedAt = try container.decodeIfPresent(Date.self, forKey: .lastConnectedAt)
81+
}
82+
4283
var trimmedAlias: String? {
4384
let value = sshAlias.trimmingCharacters(in: .whitespacesAndNewlines)
4485
return value.isEmpty ? nil : value
@@ -202,7 +243,10 @@ struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable {
202243
}
203244

204245
var workspaceScopeFingerprint: String {
205-
[
246+
if kind == .local {
247+
return "\(localConnectionIdentity)|\(remoteHermesHomePath)"
248+
}
249+
return [
206250
effectiveTarget,
207251
trimmedUser ?? "",
208252
resolvedPort.map(String.init) ?? "",
@@ -211,15 +255,25 @@ struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable {
211255
}
212256

213257
var hostConnectionFingerprint: String {
214-
[
258+
if kind == .local {
259+
return localConnectionIdentity
260+
}
261+
return [
215262
effectiveTarget,
216263
trimmedUser ?? "",
217264
resolvedPort.map(String.init) ?? ""
218265
].joined(separator: "|")
219266
}
220267

221268
var effectiveTarget: String {
222-
trimmedAlias ?? trimmedHost ?? ""
269+
if kind == .local {
270+
return "This Mac"
271+
}
272+
return trimmedAlias ?? trimmedHost ?? ""
273+
}
274+
275+
var localConnectionIdentity: String {
276+
"local:v1"
223277
}
224278

225279
var usesAliasSourceOfTruth: Bool {
@@ -235,12 +289,19 @@ struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable {
235289
}
236290

237291
var displayDestination: String {
292+
if kind == .local {
293+
return "This Mac"
294+
}
238295
guard let user = trimmedUser else {
239296
return effectiveTarget
240297
}
241298
return "\(user)@\(effectiveTarget)"
242299
}
243300

301+
var localizedDisplayDestination: String {
302+
kind == .local ? L10n.string("This Mac") : displayDestination
303+
}
304+
244305
var isValid: Bool {
245306
validationError == nil
246307
}
@@ -250,7 +311,11 @@ struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable {
250311
return "Name is required."
251312
}
252313

253-
return sshValidationError
314+
if kind == .ssh, let sshValidationError {
315+
return sshValidationError
316+
}
317+
318+
return hermesWorkspaceValidationError
254319
}
255320

256321
var sshValidationError: String? {
@@ -270,6 +335,10 @@ struct ConnectionProfile: Codable, Identifiable, Equatable, Hashable {
270335
return error
271336
}
272337

338+
return hermesWorkspaceValidationError
339+
}
340+
341+
private var hermesWorkspaceValidationError: String? {
273342
if trimmedHermesProfile != nil && trimmedCustomHermesHomePath != nil {
274343
return "Choose either a Hermes profile or a custom Hermes home path."
275344
}

0 commit comments

Comments
 (0)