Skip to content

Commit 6754bba

Browse files
authored
Ship host-owned updates and member-scoped routing (#3)
* Ship host-owned updates and member-scoped routing * Stabilize scoped routing and reconnect verification --------- Co-authored-by: gitcommit90 <gitcommit90@users.noreply.github.com>
1 parent 0da2b43 commit 6754bba

42 files changed

Lines changed: 2069 additions & 255 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.0.4] - 2026-07-24
11+
12+
### Added
13+
14+
- Every workspace member can connect private OAuth or API-key providers,
15+
explicitly share owned providers and routes with the workspace, select a
16+
personal model, create isolated revocable endpoint keys, and use a dedicated
17+
loopback endpoint port on the 1Helm host. Personal keys also scope the shared
18+
`/v1` URL to that member's own-plus-shared provider pool and usage history.
19+
- Residents and Skipper can search current web/news sources, inspect selected
20+
pages, and attach real sourced images with captions and article links.
21+
- Native Mac releases now include a post-notarization updater ZIP, and the
22+
standard Linux installer provisions a root-owned atomic systemd updater with
23+
digest verification, health checks, and rollback.
24+
25+
### Fixed
26+
27+
- Update controls now operate on the machine hosting the 1Helm instance. The
28+
browser never receives a DMG or Linux artifact as the update action; native
29+
macOS downloads and verifies in place, while Linux accepts only a fixed
30+
host-side update request.
31+
- Recent-event questions must research first and answer once with dated source
32+
links. Ordinary uncertainty no longer opens an immediate interview, and a
33+
real-photo request cannot be satisfied with generated artwork.
34+
- Provider, route, OAuth, key, model, usage, and endpoint operations enforce
35+
signed-in member ownership server-side. Teammates—including the Captain—cannot
36+
mutate another member's shared credential, forge a private provider into a
37+
route, or observe another member's OAuth session.
38+
- Terminal panes send heartbeats and silently reconnect after brief
39+
backgrounding or transport loss while retaining the same server session,
40+
shell state, working directory, and scrollback. Disconnect text is no longer
41+
written into the terminal.
42+
1043
## [0.0.3] - 2026-07-24
1144

1245
### Fixed
@@ -71,7 +104,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
71104
notarization, stapled tickets, Gatekeeper verification, persistent
72105
Application Support, and isolated Apple container machines.
73106

74-
[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.3...HEAD
107+
[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.4...HEAD
108+
[0.0.4]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.4
75109
[0.0.3]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.3
76110
[0.0.2]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.2
77111
[0.0.1]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.1

README.md

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -133,10 +133,18 @@ Connect multiple ChatGPT, Claude, Gemini/Antigravity, and xAI OAuth accounts;
133133
OpenRouter, NVIDIA NIM, Cloudflare, GLM, and custom API keys; then enable exact
134134
models and assemble fallback or round-robin routes.
135135

136+
Each signed-in workspace member connects their own OAuth accounts and API keys.
137+
New accounts and routes are private to that member unless their owner explicitly
138+
shares them with the workspace; shared accounts are usable but remain editable
139+
only by their owner. Each member may inherit the workspace model or choose a
140+
personal model from their own-plus-shared pool.
141+
136142
Changing a route never replaces the resident or discards its computer, memory,
137143
skills, files, obligations, or thread history. The same fabric also exposes an
138144
authenticated OpenAI- and Anthropic-compatible `/v1` endpoint for external
139-
tools.
145+
tools. Every member receives separate revocable keys whose identity selects that
146+
same personal pool, plus a dedicated loopback port on the 1Helm host. The port
147+
does not run on the laptop or phone viewing the web UI.
140148

141149
## What ships now
142150

@@ -155,6 +163,9 @@ tools.
155163
SHA-256 chain for new operational events.
156164
- Local-first collaboration through an optional workspace domain routed to the
157165
Captain's Mac; workspace state and provider credentials remain on that Mac.
166+
- Host-owned updates: a signed native Mac updater plus an atomic, digest-verified Linux system service with health-check rollback.
167+
- Automatic terminal heartbeat and silent same-session reconnection after
168+
backgrounding, focus changes, or brief network interruptions.
158169
- Signed, Apple-notarized, stapled Apple Silicon DMG releases.
159170

160171
### Platform truth
@@ -184,8 +195,17 @@ Application state lives under:
184195
~/Library/Application Support/1Helm
185196
```
186197

187-
Replacing the app with a newer signed DMG preserves that directory, including
188-
credentials, databases, resident state, files, and workspaces.
198+
After the first install, Profile → Check for updates asks the Mac running
199+
1Helm—not the device displaying the web UI—to download and verify the signed
200+
update. **Restart & install** replaces the app while preserving that directory,
201+
including credentials, databases, resident state, files, and workspaces.
202+
203+
The standard Linux installer similarly provisions a root-owned systemd updater.
204+
The unprivileged web service may write only a fixed request file; systemd then
205+
downloads the exact release artifact on the Linux host, verifies GitHub's
206+
SHA-256 asset digest, stages a versioned release, switches atomically, restarts,
207+
health-checks, and restores the previous release on failure. Arbitrary source
208+
checkouts remain operator-managed and never send a Mac installer to the browser.
189209

190210
## Run the source workspace
191211

@@ -208,7 +228,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to
208228
| `PORT` | `8123` | HTTP/WebSocket control-plane port. |
209229
| `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and narrow workspace mirrors. |
210230
| `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `native` elsewhere | Explicit development/test backend override. |
211-
| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.3` | Versioned Apple channel-machine image. |
231+
| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.4` | Versioned Apple channel-machine image. |
212232

213233
### Agent-first JSON CLI
214234

desktop/main.cjs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
"use strict";
22

3-
const { app, BrowserWindow, dialog, shell, session } = require("electron");
3+
const { app, autoUpdater, BrowserWindow, dialog, shell, session } = require("electron");
44
const { createServer } = require("node:net");
55
const { pathToFileURL } = require("node:url");
66
const path = require("node:path");
77
const crypto = require("node:crypto");
88
const fs = require("node:fs");
99
const { spawnSync } = require("node:child_process");
10+
const { createNativeUpdateService } = require("./updater.cjs");
1011

1112
const LOOPBACK = "127.0.0.1";
1213
let mainWindow = null;
1314
let authWindow = null;
1415
let localOrigin = "";
1516
let quitting = false;
17+
let hostUpdateService = null;
1618
const remoteWorkspacePath = () => path.join(app.getPath("userData"), "remote-workspace");
1719

1820
function preferredWorkspaceOrigin() {
@@ -209,7 +211,16 @@ if (!app.requestSingleInstanceLock()) {
209211
try {
210212
removeLegacyWakeLaunchAgent();
211213
keepSkipperAvailable();
214+
hostUpdateService = createNativeUpdateService({ app, autoUpdater });
215+
hostUpdateService.initialize();
216+
globalThis[Symbol.for("1helm.nativeUpdater")] = {
217+
state: hostUpdateService.state,
218+
check: hostUpdateService.check,
219+
install: hostUpdateService.install,
220+
};
221+
process.on("1helm-native-update-ready", () => { hostUpdateService?.commitInstall(); });
212222
await startLocalRuntime();
223+
hostUpdateService.schedule();
213224
const login = app.getLoginItemSettings({ type: "mainAppService" });
214225
createWindow(!login.wasOpenedAtLogin && !process.argv.includes("--1helm-background"));
215226
} catch (error) {
@@ -229,6 +240,7 @@ if (!app.requestSingleInstanceLock()) {
229240
if (process.platform !== "darwin") app.quit();
230241
});
231242
app.on("before-quit", () => {
243+
hostUpdateService?.stop();
232244
if (quitting) return;
233245
quitting = true;
234246
// Explicit Quit is respected; the signed main-app login service starts the

desktop/updater.cjs

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"use strict";
2+
3+
const CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000;
4+
5+
function publicError(error) {
6+
const message = String(error?.message || error || "Update failed")
7+
.replace(/https?:\/\/\S+/g, "the update service")
8+
.replace(/\s+/g, " ")
9+
.trim();
10+
return message.slice(0, 220) || "Update failed";
11+
}
12+
13+
function releaseVersion(name) {
14+
const match = String(name || "").match(/v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)/);
15+
return match ? match[1] : null;
16+
}
17+
18+
function createNativeUpdateService({ app, autoUpdater, platform = process.platform, arch = process.arch } = {}) {
19+
let initialized = false;
20+
let active = false;
21+
let busy = false;
22+
let initialTimer = null;
23+
let intervalTimer = null;
24+
let state = {
25+
mode: "native-macos",
26+
status: "idle",
27+
current_version: app.getVersion(),
28+
version: null,
29+
checked_at: null,
30+
error: null,
31+
message: "Check for a signed 1Helm update on this Mac.",
32+
};
33+
34+
let inApplications = true;
35+
if (platform === "darwin" && typeof app.isInApplicationsFolder === "function") {
36+
try { inApplications = app.isInApplicationsFolder(); } catch { inApplications = false; }
37+
}
38+
const supported = platform === "darwin" && arch === "arm64" && app.isPackaged === true && inApplications;
39+
const feedUrl = `https://update.electronjs.org/gitcommit90/1Helm/darwin-arm64/${encodeURIComponent(app.getVersion())}`;
40+
41+
const snapshot = () => ({ ...state });
42+
const setState = (patch) => { state = { ...state, ...patch }; };
43+
44+
function initialize() {
45+
if (initialized) return active;
46+
initialized = true;
47+
if (!supported) {
48+
setState({
49+
status: "unsupported",
50+
error: app.isPackaged && platform === "darwin" && arch === "arm64" && !inApplications
51+
? "Move 1Helm to Applications to enable host updates."
52+
: null,
53+
message: app.isPackaged
54+
? "Signed automatic updates are available for Apple Silicon macOS hosts."
55+
: "Development builds are updated from their source checkout.",
56+
});
57+
return false;
58+
}
59+
autoUpdater.on("checking-for-update", () => {
60+
busy = true;
61+
setState({ status: "checking", error: null, message: "The 1Helm host is checking for a signed update…" });
62+
});
63+
autoUpdater.on("update-available", () => {
64+
busy = true;
65+
setState({ status: "downloading", error: null, message: "The 1Helm host is downloading and verifying the update…" });
66+
});
67+
autoUpdater.on("update-not-available", () => {
68+
busy = false;
69+
setState({ status: "current", checked_at: Date.now(), error: null, message: "This 1Helm host is up to date." });
70+
});
71+
autoUpdater.on("update-downloaded", (_event, notes, name) => {
72+
busy = false;
73+
const version = releaseVersion(name) || releaseVersion(notes);
74+
setState({
75+
status: "ready",
76+
version,
77+
checked_at: Date.now(),
78+
error: null,
79+
message: `1Helm${version ? ` v${version}` : ""} is verified and ready. Restart the host app to install it.`,
80+
});
81+
});
82+
autoUpdater.on("error", (error) => {
83+
busy = false;
84+
const message = publicError(error);
85+
console.error(`1Helm host update failed: ${message}`);
86+
setState({ status: "error", checked_at: Date.now(), error: message, message });
87+
});
88+
try {
89+
autoUpdater.setFeedURL({ url: feedUrl });
90+
active = true;
91+
return true;
92+
} catch (error) {
93+
const message = publicError(error);
94+
setState({ status: "error", error: message, message });
95+
return false;
96+
}
97+
}
98+
99+
function check() {
100+
if (!initialize()) return snapshot();
101+
if (busy || state.status === "ready") return snapshot();
102+
busy = true;
103+
setState({ status: "checking", error: null, message: "The 1Helm host is checking for a signed update…" });
104+
try {
105+
const pending = autoUpdater.checkForUpdates();
106+
pending?.catch?.((error) => {
107+
busy = false;
108+
const message = publicError(error);
109+
setState({ status: "error", checked_at: Date.now(), error: message, message });
110+
});
111+
} catch (error) {
112+
busy = false;
113+
const message = publicError(error);
114+
setState({ status: "error", checked_at: Date.now(), error: message, message });
115+
}
116+
return snapshot();
117+
}
118+
119+
function install() {
120+
if (state.status !== "ready") {
121+
return { ...snapshot(), error: "No downloaded host update is ready." };
122+
}
123+
setState({ status: "installing", error: null, message: "1Helm is restarting this Mac host to install the verified update…" });
124+
process.env.HELM_UPDATE_INSTALLING = "1";
125+
return snapshot();
126+
}
127+
128+
function commitInstall() {
129+
if (state.status !== "installing") return false;
130+
autoUpdater.quitAndInstall(false, true);
131+
return true;
132+
}
133+
134+
function schedule({ initialDelayMs = 20_000, intervalMs = CHECK_INTERVAL_MS } = {}) {
135+
if (!initialize()) return;
136+
initialTimer ||= setTimeout(() => check(), initialDelayMs);
137+
initialTimer.unref?.();
138+
intervalTimer ||= setInterval(() => check(), intervalMs);
139+
intervalTimer.unref?.();
140+
}
141+
142+
function stop() {
143+
if (initialTimer) clearTimeout(initialTimer);
144+
if (intervalTimer) clearInterval(intervalTimer);
145+
initialTimer = null;
146+
intervalTimer = null;
147+
}
148+
149+
return { initialize, check, install, commitInstall, schedule, stop, state: snapshot, feedUrl };
150+
}
151+
152+
module.exports = { createNativeUpdateService, publicError, releaseVersion };

docs/USER_GUIDE.md

Lines changed: 49 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,12 @@ Files created through either path appear in Files after reconciliation.
9999
![The channel Files surface with explicit Open and Download](assets/guide/files.png)
100100

101101
The terminal prompt displays the live current path and changes after `cd`.
102+
1Helm sends a terminal heartbeat while the pane is open and automatically
103+
reconnects a dropped browser transport to the same live shell. Briefly
104+
backgrounding the app or changing networks does not print disconnect noise or
105+
discard the shell's working directory, exported variables, running process, or
106+
scrollback. If the host confirms that the underlying terminal session itself no
107+
longer exists, 1Helm opens a fresh one.
102108
Ordinary residents cannot select or enter the Captain's native Mac. On supported
103109
Apple Silicon Macs, each resident runs inside its own Apple `container machine`
104110
with `home-mount=none`; source/CI compatibility backends do not claim equivalent
@@ -150,6 +156,14 @@ Cloudflare, GLM, or custom OpenAI-compatible endpoints. Enable accounts and
150156
models independently, then use a direct model or named fallback/round-robin
151157
route.
152158

159+
Providers are member-owned. Any signed-in member may connect their own OAuth
160+
accounts or API keys. A new provider or route starts private: only its owner can
161+
see and use it. The owner may explicitly choose **Share with workspace**, which
162+
makes it available to teammates without letting them reconnect, disable,
163+
reconfigure, or delete the owner's credential. A shared route can reference
164+
only providers that are also shared. Each member can choose **My model** from
165+
their own-plus-shared pool or return to the Captain's workspace default.
166+
153167
![The live Requests in flight → 1Helm → Providers visualization](assets/guide/providers.png)
154168

155169
The visualization reflects live requests and their selected destination.
@@ -158,9 +172,24 @@ account does not prevent a healthy account in the same family from serving its
158172
shared model. Quotas and Logs provide account-aware evidence, and disabled
159173
accounts must not appear in a request's attempts or log record.
160174

161-
The Endpoint section manages separate external gateway keys. 1Helm agents use a
162-
private internal credential that is not exposed, disabled, or revoked with
163-
external keys.
175+
The Endpoint section manages separate external gateway keys for the signed-in
176+
member. A personal key sent to the workspace `/v1` URL is also the routing
177+
identity: requests see only that member's providers plus accounts explicitly
178+
shared with the workspace. Each member additionally receives a distinct
179+
loopback port on the 1Helm host for host-local tools. That port is a convenience,
180+
not an unauthenticated trust boundary; clients still use a personal revocable
181+
key. 1Helm agents use a separate private internal credential that is not
182+
exposed, disabled, or revoked with external keys.
183+
184+
## Current events and real images
185+
186+
Recent-event questions trigger public web or news research immediately. The
187+
agent should search before asking for ordinary identifying details, inspect the
188+
useful source, and answer once with publication dates and clickable source
189+
links. When the user asks to see a real event, the agent attaches an image
190+
returned by that research with its article source and caption. Generated art is
191+
reserved for requests to create or illustrate something and cannot silently
192+
stand in for a news photograph.
164193

165194
## Skills and Learn a new skill
166195

@@ -248,15 +277,28 @@ messages, and WebSocket fan-out.
248277

249278
## Updates, removal, and recovery
250279

251-
Signed desktop releases are unique patch versions. Installing a newer 1Helm app
252-
preserves:
280+
Signed desktop releases are unique patch versions. Profile → Check for updates
281+
always operates on the machine hosting the active 1Helm instance. In the native
282+
Mac app, Electron downloads and verifies a notarized update ZIP on that Mac and
283+
offers **Restart & install** only when it is ready. It does not navigate the
284+
browsing device to a DMG.
285+
286+
The standard Linux systemd install uses a root-owned updater. 1Helm can request
287+
that one fixed operation, but cannot choose an arbitrary URL, command, or target
288+
path. The host updater requires a stable GitHub release and its SHA-256 asset
289+
digest, installs into a versioned directory, switches the current symlink
290+
atomically, restarts, health-checks, and restores the prior release if needed.
291+
Source/developer deployments report that their host operator owns updates.
292+
293+
Every host update preserves:
253294

254295
```text
255296
~/Library/Application Support/1Helm
256297
```
257298

258-
That directory contains databases, credentials, workspaces, resident state,
259-
and narrow mirrors. Do not delete it during an app replacement.
299+
On macOS that directory contains databases, credentials, workspaces, resident
300+
state, and narrow mirrors. Linux preserves the equivalent host state under
301+
`/var/lib/1helm`. Do not delete either data root during replacement.
260302

261303
Before removing 1Helm, use its removal preparation flow. It is Captain-only,
262304
requires typed confirmation, reports backend-owned resident machines, and

0 commit comments

Comments
 (0)