diff --git a/CHANGELOG.md b/CHANGELOG.md index bb9e793..5f99531 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.8] - 2026-07-24 + +### Fixed + +- Successfully scheduling a durable agent follow-up now ends the active turn + at the tool boundary. The runtime no longer makes a second model request or + briefly exposes a fabricated completion before removing it, eliminating a + timing race observed during the clean macOS release test. +- Gmail OAuth can now be completed from a remote browser by pasting its final + localhost callback URL into 1Helm. Automatic host-local callbacks still + work; the fallback validates the same one-time state and PKCE exchange. + +### Changed + +- The app's feedback surface and project privacy, support, and security + guidance now identify `build@1helm.com` as the company contact address. +- Notification audio is now a per-user preference: every member can mute all + pings globally, mute an individual channel, and choose that channel's sound + without changing anyone else's experience. + ## [0.0.7] - 2026-07-24 ### Fixed @@ -201,7 +221,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 notarization, stapled tickets, Gatekeeper verification, persistent Application Support, and isolated Apple container machines. -[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.7...HEAD +[Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.8...HEAD +[0.0.8]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.8 [0.0.7]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.7 [0.0.6]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.6 [0.0.5]: https://github.com/gitcommit90/1Helm/releases/tag/v0.0.5 diff --git a/SECURITY.md b/SECURITY.md index 36d7b74..b2e6014 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -12,6 +12,10 @@ Use GitHub's **Report a vulnerability** flow for Include the affected version, platform, impact, minimum reproduction, and any relevant logs after removing credentials and personal data. +For non-sensitive security questions or company contact, email +[`build@1helm.com`](mailto:build@1helm.com). Do not send unpatched vulnerability +details or secrets by ordinary email; use the private advisory flow above. + Do not open a public issue for an unpatched vulnerability. Do not include API keys, OAuth tokens, Photon project secrets, private messages, workspace data, Apple signing material, or other people's personal information in a report. diff --git a/desktop/main.cjs b/desktop/main.cjs index d7c8f45..908a670 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -164,7 +164,7 @@ function openAuthWindow(url) { window.webContents.on("will-navigate", returnToApp); window.webContents.on("will-redirect", returnToApp); window.webContents.setWindowOpenHandler(({ url: nextUrl }) => { - if (/^https?:/i.test(nextUrl)) void shell.openExternal(nextUrl); + if (/^https?:/i.test(nextUrl) || /^mailto:build@1helm\.com$/i.test(nextUrl)) void shell.openExternal(nextUrl); return { action: "deny" }; }); window.on("closed", () => { if (authWindow === window) authWindow = null; }); @@ -191,7 +191,7 @@ function createWindow(showWhenReady = true) { window.webContents.setWindowOpenHandler(({ url }) => { if (allowedAppUrl(url)) return { action: "allow" }; - if (/^https?:/i.test(url)) void shell.openExternal(url); + if (/^https?:/i.test(url) || /^mailto:build@1helm\.com$/i.test(url)) void shell.openExternal(url); return { action: "deny" }; }); window.webContents.on("will-navigate", (event, url) => { diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 5ec479c..c010d85 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -224,7 +224,9 @@ Settings → Connections → Gmail owns the connection on the 1Helm host. 1. Once per Google Cloud project, create a Desktop app OAuth client, enable the Gmail API, and choose the downloaded JSON in 1Helm. 2. Choose **Connect Gmail**. 1Helm opens a state-protected PKCE authorization and - receives the callback on loopback. + receives the callback on loopback. If the browser is on another device and + its `127.0.0.1` page cannot load, copy the complete callback URL from that + address bar and paste it into the Gmail connection card. 3. Return to 1Helm after Google confirms the account. ![Gmail and Photon are native host-brokered connections](assets/guide/connections.png) @@ -237,8 +239,10 @@ accounts to that resident. Gmail supports account inventory, search, read, and draft creation. Sending remains disabled. OAuth tokens live under 1Helm's host-owned data and never enter chat, Activity evidence, or a resident computer. -The loopback callback belongs to the installed/local 1Helm host. A remotely -viewed demo cannot authorize Gmail on a different machine. +The pasted callback is never fetched. 1Helm validates its one-time state and +PKCE verifier, then exchanges the authorization code from the host where the +tokens belong. Automatic loopback completion still works when the browser is +running on that host. ## Photon / iMessage @@ -283,6 +287,15 @@ Settings → Members and create an LLM-independent notice in the Captain's model policy, or Files world. Channel membership gates HTTP, files, terminals, messages, and WebSocket fan-out. +For privacy, support, or company questions, contact +[`build@1helm.com`](mailto:build@1helm.com). Sensitive vulnerability reports +belong in the repository's private GitHub security-advisory flow rather than +ordinary email. + +Notification sounds are personal. Settings → Notifications provides a global +mute for the signed-in account. Each channel's Settings view can mute only that +channel or select its ping sound; these choices do not affect other members. + ## Updates, removal, and recovery Signed Mac releases are unique patch versions. Profile → Check for updates diff --git a/package-lock.json b/package-lock.json index 4e55324..7971ff8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.7", + "version": "0.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.7", + "version": "0.0.8", "hasInstallScript": true, "dependencies": { "@gitcommit90/rerouted": "https://github.com/gitcommit90/rerouted/releases/download/v0.5.7/ReRouted-0.5.7-linux-node.tgz", diff --git a/package.json b/package.json index bfa053d..46aa18c 100644 --- a/package.json +++ b/package.json @@ -1,10 +1,14 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.7", + "version": "0.0.8", "private": true, "type": "module", "description": "1Helm is the self-hosted home for durable AI employees: one resident, one private computer, compounding memory and skills, and Skipper for every boundary.", + "author": { + "name": "Joseph Yaksich", + "email": "build@1helm.com" + }, "main": "desktop/main.cjs", "scripts": { "assets:brand": "node scripts/generate-brand-assets.cjs", diff --git a/public/index.html b/public/index.html index 82bb140..58a0e59 100644 --- a/public/index.html +++ b/public/index.html @@ -34,6 +34,6 @@
- + diff --git a/scripts/run-test-suite.mjs b/scripts/run-test-suite.mjs index 7389555..5f377aa 100644 --- a/scripts/run-test-suite.mjs +++ b/scripts/run-test-suite.mjs @@ -46,7 +46,7 @@ const suites = [ "test/channel-computers.mjs", "test/channel-computers-isolated-backends.mjs", "test/channel-computers-backend-migration.mjs", "test/cloudflare-worker.mjs", "test/connectors.mjs", "test/chatgpt-image.mjs", "test/autonomy-platform.mjs", "test/feedback.mjs", "test/feedback-browser.mjs", "test/gmail.mjs", "test/photon.mjs", "test/site.mjs", - "test/terminal-reconnect-contract.mjs", "test/terminal-reconnect-browser.mjs", "test/web-research.mjs", "test/workflows.mjs"], + "test/notifications.mjs", "test/terminal-reconnect-contract.mjs", "test/terminal-reconnect-browser.mjs", "test/web-research.mjs", "test/workflows.mjs"], ]; let status = 0; diff --git a/src/client/app.ts b/src/client/app.ts index f7844f6..5fbc595 100644 --- a/src/client/app.ts +++ b/src/client/app.ts @@ -1,6 +1,7 @@ import { api, downloadAuthenticatedFile, openAuthenticatedFile, uploadFile, connectEvents, getToken, setToken, clearToken, workspacePhotoSrc, type User, type Channel, type Message, type Bot, type Computer, type Provider, type Workspace, type ModelPolicy, type AgentProgress, type AgentQuestions, type ThreadUsage, type RoutingModel } from "./api.ts"; -import { h, clear, add, md, color, initials, timeLabel, dayLabel, sameDay, beep, icon, helmMark, type ChannelLink } from "./dom.ts"; +import { h, clear, add, md, color, initials, timeLabel, dayLabel, sameDay, icon, helmMark, type ChannelLink } from "./dom.ts"; import { openSettings, finishOpenRouterOAuth, refreshOpenSkillsSettings } from "./settings.ts"; +import { hydrateNotificationPreferences, playNotification } from "./notifications.ts"; import { pushRoutingActivity } from "./routing.ts"; import { openOnboarding } from "./onboarding.ts"; import { defaultTerminalComputer, openTerminals, refitChannelTerminals, getTerminalChrome } from "./term.ts"; @@ -103,6 +104,7 @@ function persistCurrentChannelView(): void { async function loadUiState(): Promise { try { const result = await api<{ state: Record }>("/api/me/ui-state"); + hydrateNotificationPreferences(result.state.notification_preferences); const next: Record = {}; for (const [key, value] of Object.entries(result.state || {})) { const match = /^channel_view:(\d+)$/.exec(key); @@ -385,7 +387,7 @@ function onEvent(e: any): void { markChannelRead(msg.channel_id); unreadBadgeCounted.add(msg.id); } - if (e.type === "message" && !mine) beep(mentionsMe ? "mention" : "msg"); + if (e.type === "message" && !mine) playNotification(msg.channel_id, mentionsMe ? "mention" : "msg"); // Stream ticks mutate one or two message rows. Rebuilding the whole thread // panel here used to destroy the focused composer every 75 ms while an // agent was working, which also reset selection and made scrolling jump. @@ -396,11 +398,11 @@ function onEvent(e: any): void { if (!unreadBadgeCounted.has(msg.id)) { unreadBadgeCounted.add(msg.id); bumpChannelUnread(msg.channel_id); - beep(mentionsMe ? "mention" : "msg"); + playNotification(msg.channel_id, mentionsMe ? "mention" : "msg"); } } else if (e.type === "message" && !mine && !messageIsSettled(msg)) { // Working… started in another channel — amber dots come from agent_status; no badge yet. - beep(mentionsMe ? "mention" : "msg"); + playNotification(msg.channel_id, mentionsMe ? "mention" : "msg"); } } else if (e.type === "message_deleted") { applyMessageDeleted(e); @@ -868,7 +870,13 @@ function openFeedback(): void { h("div", { class: "flex items-start justify-between gap-3" }, h("div", {}, h("h2", { class: "font-display text-xl text-fg" }, "Send feedback"), - h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "Tell us what feels broken or what would make 1Helm better.")), + h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "Tell us what feels broken or what would make 1Helm better."), + h("p", { class: "mt-1 text-xs leading-5 text-muted" }, "You can also email ", h("a", { + class: "text-accent hover:underline", + href: "mailto:build@1helm.com", + target: "_blank", + rel: "noopener", + }, "build@1helm.com"), ".")), h("button", { class: "grid h-8 w-8 place-items-center rounded text-muted hover:bg-hover", "aria-label": "Close feedback", onclick: close }, icon("x"))), comment, h("div", {}, diff --git a/src/client/channel.ts b/src/client/channel.ts index 2fec82c..dbb79b6 100644 --- a/src/client/channel.ts +++ b/src/client/channel.ts @@ -1,6 +1,7 @@ import { api, downloadAuthenticatedFile, openAuthenticatedFile, uploadFile, type ActivityItem, type AgentTemplate, type Channel, type ChannelFile, type GlobalThread, type MemoryItem, type Message, type ThreadState, type RoutingModel } from "./api.ts"; import { h, clear, icon, md, timeLabel } from "./dom.ts"; import { S, avatar, appAlert, appConfirm, appPrompt } from "./app.ts"; +import { NOTIFICATION_SOUNDS, channelNotificationPreference, previewNotification, setChannelNotificationPreference } from "./notifications.ts"; export type ChannelView = "chat" | "board" | "threads" | "files" | "terminal" | "memory" | "activity" | "settings"; @@ -614,6 +615,21 @@ export function renderChannelSettings(container: HTMLElement, channel: Channel, const provider = h("select", { class: "field" }, h("option", { value: "" }, "Loading providers…")) as HTMLSelectElement; const model = h("select", { class: "field" }, h("option", { value: channel.agent?.model || "" }, channel.agent?.model || "Choose a model")) as HTMLSelectElement; const status = h("p", { class: "min-h-5 text-sm text-muted" }); + const notificationPreference = channelNotificationPreference(channel.id); + const channelMuted = h("input", { type: "checkbox", checked: notificationPreference.muted, class: "accent-accent" }) as HTMLInputElement; + const channelSound = h("select", { class: "field" }, ...NOTIFICATION_SOUNDS.map((item) => h("option", { value: item.value, selected: item.value === notificationPreference.sound }, item.label))) as HTMLSelectElement; + const notificationStatus = h("p", { class: "min-h-5 text-sm text-muted" }); + const saveChannelNotifications = async (): Promise => { + channelMuted.disabled = true; channelSound.disabled = true; notificationStatus.textContent = "Saving…"; + try { + await setChannelNotificationPreference(channel.id, { muted: channelMuted.checked, sound: channelSound.value as typeof notificationPreference.sound }); + notificationStatus.textContent = channelMuted.checked ? `#${channel.name} is muted for your account.` : `#${channel.name} will use ${channelSound.selectedOptions[0]?.textContent || "this sound"}.`; + } catch (error) { notificationStatus.textContent = (error as Error).message; } + finally { channelMuted.disabled = false; channelSound.disabled = channelMuted.checked; } + }; + channelMuted.onchange = () => { channelSound.disabled = channelMuted.checked; void saveChannelNotifications(); }; + channelSound.onchange = () => { previewNotification(channelSound.value as typeof notificationPreference.sound); void saveChannelNotifications(); }; + channelSound.disabled = channelMuted.checked; let loadSequence = 0; let modelLoading = false; let changeModelButton: HTMLButtonElement | null = null; @@ -744,6 +760,11 @@ export function renderChannelSettings(container: HTMLElement, channel: Channel, ? null : h("button", { class: "btn-primary text-sm", onclick: () => { void saveName(); } }, "Rename"))), h("div", { class: "card space-y-3 p-4" }, h("h3", { class: "font-semibold text-fg" }, "Purpose"), purpose, h("div", { class: "flex justify-end" }, h("button", { class: "btn-primary text-sm", onclick: () => { void savePurpose(); } }, "Save purpose"))), + h("div", { class: "card space-y-3 p-4", dataset: { channelNotifications: "" } }, + h("div", {}, h("h3", { class: "font-semibold text-fg" }, "Notification sound"), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "Private to your account. Global mute in Settings → Notifications always takes priority.")), + h("label", { class: "flex items-center gap-3 rounded-lg border border-line bg-panel p-3 text-sm font-semibold text-fg" }, channelMuted, `Mute #${channel.name}`), + h("label", { class: "block space-y-1 text-xs font-semibold text-fg" }, "Ping sound", channelSound), + notificationStatus), h("div", { class: "card space-y-3 p-4" }, h("div", {}, h("h3", { class: "font-semibold text-fg" }, "Agent avatar"), h("p", { class: "mt-1 text-sm text-muted" }, "Pick a flat color or upload a custom image for this resident agent.")), agentAvatar, h("div", { class: "mt-2" }, h("span", { class: "mb-1 block text-xs font-semibold text-muted" }, "Default colors"), avatarColorRow)), diff --git a/src/client/dom.ts b/src/client/dom.ts index 58befa2..6117fa9 100644 --- a/src/client/dom.ts +++ b/src/client/dom.ts @@ -200,21 +200,28 @@ export function icon(name: keyof typeof ICONS | string, size = 16): SVGElement { } let audioCtx: AudioContext | null = null; +export type NotificationSound = "helm" | "bell" | "chime" | "pulse"; /** Synthesized notification chirp — no asset files needed. */ -export function beep(kind: "msg" | "mention" = "msg"): void { +export function beep(kind: "msg" | "mention" = "msg", sound: NotificationSound = "helm"): void { try { audioCtx = audioCtx || new (window.AudioContext || (window as any).webkitAudioContext)(); const ctx = audioCtx; - const notes = kind === "mention" ? [880, 1320] : [660, 990]; - notes.forEach((freq, i) => { + const voices: Record = { + helm: { notes: kind === "mention" ? [880, 1320] : [660, 990], wave: "sine", gap: 0.09, duration: 0.16, gain: 0.12 }, + bell: { notes: kind === "mention" ? [1046.5, 1568] : [1046.5], wave: "triangle", gap: 0.13, duration: 0.32, gain: 0.1 }, + chime: { notes: kind === "mention" ? [659.25, 783.99, 1046.5] : [523.25, 659.25, 783.99], wave: "sine", gap: 0.075, duration: 0.22, gain: 0.09 }, + pulse: { notes: kind === "mention" ? [587.33, 783.99] : [440, 587.33], wave: "square", gap: 0.065, duration: 0.1, gain: 0.045 }, + }; + const voice = voices[sound] || voices.helm; + voice.notes.forEach((freq, i) => { const osc = ctx.createOscillator(); const gain = ctx.createGain(); - osc.type = "sine"; osc.frequency.value = freq; - const t0 = ctx.currentTime + i * 0.09; + osc.type = voice.wave; osc.frequency.value = freq; + const t0 = ctx.currentTime + i * voice.gap; gain.gain.setValueAtTime(0, t0); - gain.gain.linearRampToValueAtTime(0.12, t0 + 0.02); - gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.16); + gain.gain.linearRampToValueAtTime(voice.gain, t0 + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, t0 + voice.duration); osc.connect(gain); gain.connect(ctx.destination); - osc.start(t0); osc.stop(t0 + 0.18); + osc.start(t0); osc.stop(t0 + voice.duration + 0.02); }); } catch { /* audio not available */ } } diff --git a/src/client/notifications.ts b/src/client/notifications.ts new file mode 100644 index 0000000..aa76500 --- /dev/null +++ b/src/client/notifications.ts @@ -0,0 +1,80 @@ +import { api } from "./api.ts"; +import { beep, type NotificationSound } from "./dom.ts"; + +export const NOTIFICATION_SOUNDS: ReadonlyArray<{ value: NotificationSound; label: string }> = [ + { value: "helm", label: "Helm chirp" }, + { value: "bell", label: "Ship bell" }, + { value: "chime", label: "Glass chime" }, + { value: "pulse", label: "Soft pulse" }, +]; + +type ChannelNotificationPreference = { muted: boolean; sound: NotificationSound }; +type NotificationPreferences = { + globalMuted: boolean; + channels: Record; +}; + +const sounds = new Set(NOTIFICATION_SOUNDS.map((item) => item.value)); +let preferences: NotificationPreferences = { globalMuted: false, channels: {} }; + +function soundValue(value: unknown): NotificationSound { + return sounds.has(value as NotificationSound) ? value as NotificationSound : "helm"; +} + +/** Hydrate the signed-in user's server-owned notification state. */ +export function hydrateNotificationPreferences(value: unknown): void { + const raw = value && typeof value === "object" ? value as Record : {}; + const rawChannels = raw.channels && typeof raw.channels === "object" ? raw.channels as Record : {}; + const channels: Record = {}; + for (const [key, item] of Object.entries(rawChannels)) { + const channelId = Number(key); + if (!Number.isSafeInteger(channelId) || channelId <= 0 || !item || typeof item !== "object") continue; + const row = item as Record; + channels[channelId] = { muted: row.muted === true, sound: soundValue(row.sound) }; + } + preferences = { globalMuted: raw.globalMuted === true, channels }; +} + +export function globalNotificationsMuted(): boolean { + return preferences.globalMuted; +} + +export function channelNotificationPreference(channelId: number): ChannelNotificationPreference { + return preferences.channels[channelId] || { muted: false, sound: "helm" }; +} + +async function persist(next: NotificationPreferences): Promise { + const result = await api<{ state: Record }>("/api/me/ui-state", { + method: "PATCH", + body: { key: "notification_preferences", value: next }, + }); + hydrateNotificationPreferences(result.state.notification_preferences); +} + +export async function setGlobalNotificationsMuted(muted: boolean): Promise { + await persist({ ...preferences, globalMuted: muted }); +} + +export async function setChannelNotificationPreference( + channelId: number, + patch: Partial, +): Promise { + const current = channelNotificationPreference(channelId); + await persist({ + ...preferences, + channels: { + ...preferences.channels, + [channelId]: { muted: patch.muted ?? current.muted, sound: soundValue(patch.sound ?? current.sound) }, + }, + }); +} + +export function playNotification(channelId: number, kind: "msg" | "mention" = "msg"): void { + const channel = channelNotificationPreference(channelId); + if (preferences.globalMuted || channel.muted) return; + beep(kind, channel.sound); +} + +export function previewNotification(sound: NotificationSound): void { + beep("msg", soundValue(sound)); +} diff --git a/src/client/settings.ts b/src/client/settings.ts index 79cb60c..aba9d91 100644 --- a/src/client/settings.ts +++ b/src/client/settings.ts @@ -2,6 +2,7 @@ import { api, getToken, workspacePhotoSrc, type AccessRequest, type ChannelRunti import { h, clear, add, icon } from "./dom.ts"; import { S, avatar, reloadProviders, renderApp, appAlert, appConfirm, appPrompt } from "./app.ts"; import { connectRoutingOauth, routingPanel } from "./routing.ts"; +import { globalNotificationsMuted, setGlobalNotificationsMuted } from "./notifications.ts"; // ============================================================ OpenRouter OAuth (PKCE) const b64url = (buf: ArrayBuffer): string => btoa(String.fromCharCode(...new Uint8Array(buf))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); @@ -68,15 +69,15 @@ export async function startChatGPTOAuth(): Promise { } // ============================================================ settings application page -type Tab = "admin" | "agents" | "skills" | "workflows" | "connections" | "feedback" | "audit" | "domains" | "providers" | "computers" | "members"; +type Tab = "admin" | "agents" | "skills" | "workflows" | "connections" | "notifications" | "feedback" | "audit" | "domains" | "providers" | "computers" | "members"; export function openSettings(tab: Tab = "agents"): void { document.querySelector("[data-settings-overlay]")?.remove(); const overlay = h("div", { class: "modal-overlay fixed inset-0 z-40 bg-surface", dataset: { settingsOverlay: "", settingsTab: tab } }); const bodyEl = h("main", { class: "min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto p-4 sm:p-6 lg:p-8" }); const page = h("div", { class: "flex h-full w-full flex-col overflow-hidden bg-surface" }); const tabs: [Tab, string][] = S.me.is_admin - ? [["admin", "Admin"], ["agents", "Agents"], ["skills", "Skills"], ["workflows", "Workflows"], ["connections", "Connections"], ["feedback", "Feedback"], ["audit", "Audit"], ["domains", "Domains"], ["providers", "Providers"], ["computers", "Skipper computers"], ["members", "Members"]] - : [["providers", "Providers"]]; + ? [["admin", "Admin"], ["agents", "Agents"], ["skills", "Skills"], ["workflows", "Workflows"], ["connections", "Connections"], ["notifications", "Notifications"], ["feedback", "Feedback"], ["audit", "Audit"], ["domains", "Domains"], ["providers", "Providers"], ["computers", "Skipper computers"], ["members", "Members"]] + : [["providers", "Providers"], ["notifications", "Notifications"]]; if (!tabs.length) return; const tabBar = h("nav", { class: "grid w-full shrink-0 grid-cols-2 gap-1 border-b border-line bg-raised/30 p-3 sm:grid-cols-3 lg:w-64 lg:grid-cols-1 lg:border-b-0 lg:border-r lg:p-4", "aria-label": "Settings sections" }); const draw = (t: Tab): void => { @@ -84,7 +85,7 @@ export function openSettings(tab: Tab = "agents"): void { clear(tabBar); tabs.forEach(([id, label]) => tabBar.append(h("button", { class: `rounded-lg px-3 py-2.5 text-left text-sm font-semibold transition ${t === id ? "bg-accent text-white shadow-sm" : "text-muted hover:bg-hover hover:text-fg"}`, type: "button", "aria-current": t === id ? "page" : undefined, onclick: () => draw(id) }, label))); clear(bodyEl); - const content = t === "admin" ? adminPanel() : t === "agents" ? agentsPanel() : t === "skills" ? skillsPanel() : t === "workflows" ? workflowsPanel() : t === "connections" ? connectionsPanel() : t === "feedback" ? feedbackPanel() : t === "audit" ? auditPanel() : t === "domains" ? domainsPanel() : t === "providers" ? providersPanel() : t === "computers" ? computersPanel() : membersPanel(); + const content = t === "admin" ? adminPanel() : t === "agents" ? agentsPanel() : t === "skills" ? skillsPanel() : t === "workflows" ? workflowsPanel() : t === "connections" ? connectionsPanel() : t === "notifications" ? notificationsPanel() : t === "feedback" ? feedbackPanel() : t === "audit" ? auditPanel() : t === "domains" ? domainsPanel() : t === "providers" ? providersPanel() : t === "computers" ? computersPanel() : membersPanel(); bodyEl.append(h("div", { class: `mx-auto w-full ${t === "providers" ? "max-w-7xl" : "max-w-5xl"}` }, h("div", { class: "mb-5" }, h("div", { class: "eyebrow text-accent" }, "Settings"), h("h1", { class: "font-display mt-1 text-3xl text-fg" }, tabs.find(([id]) => id === t)?.[1] || "Settings")), content)); }; page.append( @@ -107,6 +108,28 @@ export function refreshOpenSkillsSettings(): void { const adminNote = (): HTMLElement => h("p", { class: "rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-600 dark:text-amber-300" }, "Only admins can add or edit these."); +function notificationsPanel(): HTMLElement { + const muted = h("input", { type: "checkbox", checked: globalNotificationsMuted(), class: "accent-accent" }) as HTMLInputElement; + const status = h("p", { class: "min-h-5 text-sm text-muted" }); + muted.onchange = async () => { + muted.disabled = true; + status.textContent = "Saving…"; + try { + await setGlobalNotificationsMuted(muted.checked); + status.textContent = muted.checked ? "All notification sounds are muted for your account." : "Notification sounds are on for your account."; + } catch (error) { + muted.checked = !muted.checked; + status.textContent = (error as Error).message; + } finally { muted.disabled = false; } + }; + return h("div", { class: "space-y-4" }, + h("section", { class: "card space-y-3 p-4" }, + h("div", {}, h("h3", { class: "font-semibold text-fg" }, "Global sound"), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "This preference belongs only to your 1Helm account and follows you across signed-in devices.")), + h("label", { class: "flex items-start gap-3 rounded-lg border border-line bg-panel p-3" }, muted, h("span", {}, h("span", { class: "block text-sm font-semibold text-fg" }, "Mute all notification sounds"), h("span", { class: "mt-1 block text-xs leading-5 text-muted" }, "Visual unread badges and agent activity remain available."))), + status), + h("section", { class: "card p-4" }, h("h3", { class: "font-semibold text-fg" }, "Channel sounds"), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "Open any channel → Settings to mute that channel or choose its ping sound. Those choices are also private to your account."))); +} + function adminPanel(): HTMLElement { const name = h("input", { class: "field", value: S.workspace.name, autocomplete: "organization" }) as HTMLInputElement; const theme = h("select", { class: "field" }, ...[["graphite", "Signal"], ["ocean", "Ocean"], ["forest", "Forest"], ["ember", "Brass"], ["plum", "Plum"]].map(([value, label]) => h("option", { value, selected: S.workspace.theme === value }, label))) as HTMLSelectElement; @@ -278,11 +301,12 @@ function learnSkillDialog(): void { type PhotonMapping = { channel_id: number; channel_name: string; agent_name: string; allowed_users: string; updated: number }; type PhotonStatus = { configured: boolean; connected: boolean; project_id: string; operator_phone: string; assigned_phone: string; secret: "stored" | "missing"; mappings: PhotonMapping[] }; type PhotonSetup = { active: boolean; status?: string | PhotonStatus; operator_phone?: string; user_code?: string; verification_uri?: string; verification_uri_complete?: string; expires_at?: number; error?: string; connector?: PhotonStatus }; -type GmailStatus = { accounts: string[]; has_oauth_client: boolean; setup: { active: boolean; status: string; authorization_url?: string; error?: string } }; +type GmailStatus = { accounts: string[]; has_oauth_client: boolean; setup: { active: boolean; status: string; authorization_url?: string; manual_completion?: boolean; error?: string } }; function gmailConnectionPanel(): HTMLElement { const card = h("section", { class: "card space-y-4 p-4" }, h("p", { class: "text-sm text-muted" }, "Checking Gmail…")); let timer: ReturnType | null = null; + let callbackDraft = ""; const draw = async (): Promise => { try { const { gmail } = await api<{ gmail: GmailStatus }>("/api/connectors/gmail"); @@ -303,6 +327,23 @@ function gmailConnectionPanel(): HTMLElement { h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "1Helm owns the OAuth tokens on this host. Skipper can search, read, and draft; resident computers never receive credentials and sending stays disabled.")); card.append(h("div", { class: "flex flex-wrap items-start justify-between gap-3" }, copy, connect)); if (gmail.setup.authorization_url) card.append(h("a", { class: "btn-primary inline-flex w-fit text-sm", href: gmail.setup.authorization_url, target: "_blank", rel: "noopener noreferrer" }, "Authorize with Google")); + if (gmail.setup.active && gmail.setup.manual_completion) { + const callback = h("input", { class: "field font-mono text-xs", value: callbackDraft, placeholder: "http://127.0.0.1:.../gmail/callback?state=...&code=...", autocomplete: "off", spellcheck: "false" }) as HTMLInputElement; + callback.onfocus = () => { if (timer) { clearTimeout(timer); timer = null; } }; + callback.oninput = () => { callbackDraft = callback.value; }; + const callbackStatus = h("p", { class: "min-h-5 text-xs text-muted" }); + const complete = h("button", { class: "btn-primary shrink-0 text-sm", type: "button", onclick: async () => { + complete.disabled = true; callbackStatus.textContent = "Completing securely on the 1Helm host…"; + try { + await api("/api/connectors/gmail/callback", { body: { callback_url: callback.value } }); + callbackDraft = ""; await draw(); + } catch (error) { callbackStatus.textContent = (error as Error).message; complete.disabled = false; } + } }, "Complete connection") as HTMLButtonElement; + card.append(h("div", { class: "rounded-lg border border-line bg-panel p-4", dataset: { gmailManualCallback: "" } }, + h("h4", { class: "font-semibold text-fg" }, "If the localhost page cannot load"), + h("p", { class: "mt-1 text-sm leading-6 text-muted" }, "That is expected when Google opened on a different device. Copy the complete 127.0.0.1 URL from that browser's address bar and paste it here. 1Helm validates its one-time state and PKCE verifier, then exchanges the code on the host; it never fetches the pasted URL."), + h("div", { class: "mt-3 flex flex-col gap-2 sm:flex-row" }, callback, complete), callbackStatus)); + } if (gmail.accounts.length) card.append(h("div", { class: "grid gap-2 sm:grid-cols-2" }, ...gmail.accounts.map((account) => h("div", { class: "rounded-lg border border-line bg-panel px-3 py-2" }, h("div", { class: "font-semibold text-fg" }, account), h("div", { class: "mt-0.5 text-xs text-muted" }, "Read · search · draft · host-owned"))))); if (!gmail.has_oauth_client) { const picker = h("input", { type: "file", accept: "application/json,.json", class: "hidden" }) as HTMLInputElement; @@ -316,7 +357,7 @@ function gmailConnectionPanel(): HTMLElement { } if (gmail.setup.error) card.append(h("p", { class: "text-sm text-danger" }, gmail.setup.error)); if (timer) clearTimeout(timer); - if (gmail.setup.active) timer = setTimeout(() => { void draw(); }, 1500); + if (gmail.setup.active && !callbackDraft) timer = setTimeout(() => { void draw(); }, 1500); } catch (error) { clear(card); card.append(h("p", { class: "text-sm text-danger" }, (error as Error).message)); } }; void draw(); @@ -397,7 +438,10 @@ function feedbackPanel(): HTMLElement { void api<{ reports: FeedbackReport[]; central: FeedbackReport[] }>("/api/feedback").then(({ reports, central }) => { clear(wrap); const combined = [...reports, ...(central || []).filter((remote) => !reports.some((local) => local.public_id === remote.public_id))]; - wrap.append(h("div", { class: "rounded-lg border border-accent/25 bg-accent-soft px-4 py-3 text-sm leading-6 text-fg" }, "Feedback is saved on this host first and relayed to the 1Helm team with automatic retries. Diagnostics are opt-in and exclude conversations, prompts, account content, terminal output, credentials, and OAuth material.")); + wrap.append(h("div", { class: "rounded-lg border border-accent/25 bg-accent-soft px-4 py-3 text-sm leading-6 text-fg" }, + "Feedback is saved on this host first and relayed to the 1Helm team with automatic retries. Diagnostics are opt-in and exclude conversations, prompts, account content, terminal output, credentials, and OAuth material. Company contact: ", + h("a", { class: "text-accent hover:underline", href: "mailto:build@1helm.com", target: "_blank", rel: "noopener" }, "build@1helm.com"), + ".")); if (!combined.length) { wrap.append(h("div", { class: "card p-6 text-center text-sm text-muted" }, "No feedback reports yet.")); return; diff --git a/src/server/bots.ts b/src/server/bots.ts index 40329fd..ba75d54 100644 --- a/src/server/bots.ts +++ b/src/server/bots.ts @@ -1442,6 +1442,32 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread responseBody = text; paintBody(responseBody); }; + const discardSilentTurnMessage = (): void => { + run("DELETE FROM agent_progress WHERE message_id=?", msgId); + run("DELETE FROM messages WHERE id=?", msgId); + if (threadRootId) { + const remaining = q( + `SELECT created FROM messages WHERE parent_id=? + AND body NOT LIKE '[scheduled-followup%' + AND body NOT LIKE '⟦followup⟧%' + AND body <> '_Working…_' + ORDER BY id`, + threadRootId, + ); + const last = remaining.length ? Number(remaining[remaining.length - 1].created) : null; + run("UPDATE messages SET reply_count=?, last_reply=? WHERE id=?", remaining.length, last, threadRootId); + broadcastToChannel(channelId, { + type: "message_deleted", + channelId, + id: msgId, + deleted_ids: [msgId], + parent_id: threadRootId, + parent: { id: threadRootId, reply_count: remaining.length, last_reply: last }, + }); + } + refreshThreadSummary(threadRootId); + setStatus(agent, channelId, "ready"); + }; /** Body while tools/thinking are mid-flight: keep last real thought, never flash back to Working… */ const paintStickyWorkingBody = (): void => { if (responseBody.trim()) { @@ -1529,6 +1555,7 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread if (streamedBody.trim()) liveThought = streamedBody; paintStickyWorkingBody(); messages.push({ role: "assistant", content: content || "", tool_calls: toolCalls }); + let scheduledSilentFollowup = false; for (const toolCall of toolCalls) { requireActiveTurn(channelId, controller.signal); const args = safeParse(toolCall.function.arguments); @@ -1792,6 +1819,7 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread } if (actionStatus === "complete") { lastCompletedTool = { name, result }; + if (name === "schedule_followup") scheduledSilentFollowup = true; if (name === "inspect_web_source") { try { const inspected = JSON.parse(result) as { requested_url?: string; final_url?: string }; @@ -1802,6 +1830,13 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread } messages.push({ role: "tool", tool_call_id: toolCall.id, name, content: result }); } + // A successful durable wake is the continuation. End this turn at the + // tool boundary so there is no second model request and no transient + // fake completion for clients to observe before cleanup. + if (scheduledSilentFollowup) { + discardSilentTurnMessage(); + return; + } if (awaitingQuestions) { if (!meaningfulAnswer(responseBody)) setBody("I need a few details before I continue."); run("UPDATE agent_progress SET status='complete',updated=? WHERE message_id=? AND status='running'", now(), msgId); @@ -1838,32 +1873,7 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread ); // Wake turns that only re-schedule (or that echo the internal scaffold) must not pollute chat. if (silentReschedule || echoedScaffold) { - run("DELETE FROM agent_progress WHERE message_id=?", msgId); - run("DELETE FROM messages WHERE id=?", msgId); - if (threadRootId) { - const remaining = q( - `SELECT created FROM messages WHERE parent_id=? - AND body NOT LIKE '[scheduled-followup%' - AND body NOT LIKE '⟦followup⟧%' - AND body <> '_Working…_' - ORDER BY id`, - threadRootId, - ); - const last = remaining.length ? Number(remaining[remaining.length - 1].created) : null; - run("UPDATE messages SET reply_count=?, last_reply=? WHERE id=?", remaining.length, last, threadRootId); - broadcastToChannel(channelId, { - type: "message_deleted", - channelId, - id: msgId, - deleted_ids: [msgId], - parent_id: threadRootId, - parent: { id: threadRootId, reply_count: remaining.length, last_reply: last }, - }); - } - refreshThreadSummary(threadRootId); - setStatus(agent, channelId, "ready"); - turns.delete(activeTurn); - if (!turns.size) activeTurns.delete(channelId); + discardSilentTurnMessage(); return; } if (!meaningfulAnswer(responseBody) && lastCompletedTool) setBody(completedToolAnswer(lastCompletedTool.name, lastCompletedTool.result)); @@ -1873,32 +1883,7 @@ async function executeBot(bot: Row, channelId: number, triggerId: number, thread requireActiveTurn(channelId, controller.signal); if (!meaningfulAnswer(responseBody) && lastCompletedTool) { if (lastCompletedTool.name === "schedule_followup" && !lastCompletedTool.result.startsWith("Error:")) { - run("DELETE FROM agent_progress WHERE message_id=?", msgId); - run("DELETE FROM messages WHERE id=?", msgId); - if (threadRootId) { - const remaining = q( - `SELECT created FROM messages WHERE parent_id=? - AND body NOT LIKE '[scheduled-followup%' - AND body NOT LIKE '⟦followup⟧%' - AND body <> '_Working…_' - ORDER BY id`, - threadRootId, - ); - const last = remaining.length ? Number(remaining[remaining.length - 1].created) : null; - run("UPDATE messages SET reply_count=?, last_reply=? WHERE id=?", remaining.length, last, threadRootId); - broadcastToChannel(channelId, { - type: "message_deleted", - channelId, - id: msgId, - deleted_ids: [msgId], - parent_id: threadRootId, - parent: { id: threadRootId, reply_count: remaining.length, last_reply: last }, - }); - } - refreshThreadSummary(threadRootId); - setStatus(agent, channelId, "ready"); - turns.delete(activeTurn); - if (!turns.size) activeTurns.delete(channelId); + discardSilentTurnMessage(); return; } setBody(completedToolAnswer(lastCompletedTool.name, lastCompletedTool.result)); diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index 829a51d..263b65b 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -67,7 +67,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0"; export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`; export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`; export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"; -export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.7"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.8"; const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[]; const LXC_RUNTIME_VERSION = "1helm-lxc-runtime-v1"; const LXC_HELPER_CANDIDATES = [ diff --git a/src/server/db.ts b/src/server/db.ts index 04c376c..7e5d167 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -924,7 +924,7 @@ export function migrate(): void { const platformBackend = process.platform === "darwin" ? "apple" : process.platform === "win32" ? "wsl" : "lxc"; const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend); const backend = ["apple", "lxc", "wsl", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend; - const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.7"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.8"); // Earlier Linux/Windows releases persisted the compatibility `native` // seam into every channel row. A production host update must actually // move those rows onto the platform isolation backend; changing the unit's diff --git a/src/server/gmail.ts b/src/server/gmail.ts index db80874..3884c38 100644 --- a/src/server/gmail.ts +++ b/src/server/gmail.ts @@ -19,7 +19,8 @@ type TokenPayload = { type MailConfig = { accounts: string[]; can_read: boolean; can_draft: boolean; can_send: boolean }; type OAuthClient = { client_id: string; client_secret: string; auth_uri?: string; token_uri?: string; redirect_uris?: string[] }; -type GmailSetup = { active: boolean; status: "idle" | "needs_client" | "waiting" | "connected" | "failed"; authorization_url?: string; error?: string; started_at?: number; expires_at?: number }; +type GmailSetup = { active: boolean; status: "idle" | "needs_client" | "waiting" | "connected" | "failed"; authorization_url?: string; manual_completion?: boolean; error?: string; started_at?: number; expires_at?: number }; +type PendingOAuth = { client: OAuthClient; state: string; verifier: string; redirectUri: string; completing: boolean }; const CONNECTION_DIR = process.env.ONEHELM_GOOGLE_CONNECTION_DIR || join(DATA_DIR, "connections", "gmail"); const TOKENS_DIR = process.env.ONEHELM_GOOGLE_TOKENS_DIR || join(CONNECTION_DIR, "tokens"); @@ -31,6 +32,7 @@ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; const GMAIL_SCOPES = ["openid", "email", "https://www.googleapis.com/auth/gmail.readonly", "https://www.googleapis.com/auth/gmail.compose"]; let setup: GmailSetup = { active: false, status: "idle" }; let callbackServer: Server | null = null; +let pendingOAuth: PendingOAuth | null = null; let gmailFetch: typeof fetch = fetch; export function setGmailFetchForTests(implementation: typeof fetch | null): void { @@ -40,6 +42,7 @@ export function setGmailFetchForTests(implementation: typeof fetch | null): void export function stopGmailConnection(): void { callbackServer?.close(); callbackServer = null; + pendingOAuth = null; setup = { active: false, status: "idle" }; } @@ -107,6 +110,53 @@ export function saveGmailOAuthClient(input: unknown): Record { const setupPage = (message: string, ok: boolean): string => `1Helm Gmail

${ok ? "Gmail connected" : "Gmail connection failed"}

${message.replace(/[<>&]/g, (char) => ({ "<": "<", ">": ">", "&": "&" })[char]!)}

You can close this tab and return to 1Helm.

`; +function parsedCallback(value: unknown, pending: PendingOAuth): URL { + const raw = String(value || "").trim(); + if (!raw || raw.length > 8192) throw new Error("Paste the complete Google callback URL from the browser address bar."); + let callback: URL; + try { callback = new URL(raw); } + catch { throw new Error("Paste a valid complete Google callback URL."); } + const expected = new URL(pending.redirectUri); + if (callback.protocol !== expected.protocol || callback.hostname !== expected.hostname || callback.port !== expected.port || callback.pathname !== expected.pathname) { + throw new Error("That is not the localhost callback URL created by this Gmail connection attempt."); + } + if (callback.searchParams.get("state") !== pending.state) throw new Error("OAuth state did not match. Start the connection again."); + const code = callback.searchParams.get("code"); + if (!code) throw new Error(callback.searchParams.get("error_description") || callback.searchParams.get("error") || "Google returned no authorization code."); + return callback; +} + +/** Complete the same PKCE exchange from either the local listener or a pasted + * localhost callback URL. The pasted URL is parsed, never fetched. */ +export async function completeGmailConnection(callbackUrl: unknown): Promise> { + const pending = pendingOAuth; + if (!pending || !setup.active || setup.status !== "waiting") throw new Error("No Gmail authorization is waiting. Start the connection again."); + if (pending.completing) throw new Error("This Gmail authorization is already being completed."); + const callback = parsedCallback(callbackUrl, pending); + pending.completing = true; + try { + const code = callback.searchParams.get("code")!; + const tokenResponse = await gmailFetch(pending.client.token_uri || "https://oauth2.googleapis.com/token", { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body: new URLSearchParams({ code, client_id: pending.client.client_id, client_secret: pending.client.client_secret, redirect_uri: pending.redirectUri, grant_type: "authorization_code", code_verifier: pending.verifier }), signal: AbortSignal.timeout(30_000) }); + const token = await tokenResponse.json().catch(() => ({})) as { access_token?: string; refresh_token?: string; expires_in?: number; scope?: string; error_description?: string }; + if (!tokenResponse.ok || !token.access_token || !token.refresh_token) throw new Error(token.error_description || "Google did not return a durable refresh token. Reconnect and approve access."); + const profileResponse = await gmailFetch(`${GMAIL_API}/profile`, { headers: { authorization: `Bearer ${token.access_token}` }, signal: AbortSignal.timeout(30_000) }); + const profile = await profileResponse.json().catch(() => ({})) as { emailAddress?: string; error?: { message?: string } }; + const email = normalizeEmail(profile.emailAddress); + if (!profileResponse.ok || !EMAIL.test(email)) throw new Error(profile.error?.message || "Google did not identify the Gmail account."); + writePrivate(tokenPath(email), { token: token.access_token, refresh_token: token.refresh_token, token_uri: pending.client.token_uri || "https://oauth2.googleapis.com/token", client_id: pending.client.client_id, client_secret: pending.client.client_secret, account_email: email, scope: token.scope || GMAIL_SCOPES.join(" ") }); + setup = { active: false, status: "connected", started_at: setup.started_at, expires_at: setup.expires_at }; + return gmailConnectionStatus(); + } catch (error) { + setup = { active: false, status: "failed", error: (error as Error).message, started_at: setup.started_at }; + throw error; + } finally { + if (pendingOAuth === pending) pendingOAuth = null; + const server = callbackServer; + callbackServer = null; + server?.close(); + } +} + export async function startGmailConnection(clientInput?: unknown): Promise> { importLegacyConnections(); if (clientInput) saveGmailOAuthClient(clientInput); @@ -122,6 +172,7 @@ export async function startGmailConnection(clientInput?: unknown): Promise ({})) as { access_token?: string; refresh_token?: string; expires_in?: number; scope?: string; error_description?: string }; - if (!tokenResponse.ok || !token.access_token || !token.refresh_token) throw new Error(token.error_description || "Google did not return a durable refresh token. Reconnect and approve access."); - const profileResponse = await gmailFetch(`${GMAIL_API}/profile`, { headers: { authorization: `Bearer ${token.access_token}` }, signal: AbortSignal.timeout(30_000) }); - const profile = await profileResponse.json().catch(() => ({})) as { emailAddress?: string; error?: { message?: string } }; - const email = normalizeEmail(profile.emailAddress); - if (!profileResponse.ok || !EMAIL.test(email)) throw new Error(profile.error?.message || "Google did not identify the Gmail account."); - writePrivate(tokenPath(email), { token: token.access_token, refresh_token: token.refresh_token, token_uri: client.token_uri || "https://oauth2.googleapis.com/token", client_id: client.client_id, client_secret: client.client_secret, account_email: email, scope: token.scope || GMAIL_SCOPES.join(" ") }); - setup = { active: false, status: "connected", started_at: setup.started_at, expires_at: setup.expires_at }; + const completed = await completeGmailConnection(new URL(req.url || "/", redirectUri).toString()); + const email = String((completed.accounts as string[])?.at(-1) || "This Gmail account"); res.writeHead(200, { "content-type": "text/html; charset=utf-8" }); res.end(setupPage(`${email} is connected with read and draft access. 1Helm will never expose its OAuth token to a resident computer.`, true)); } catch (error) { - setup = { active: false, status: "failed", error: (error as Error).message, started_at: setup.started_at }; res.writeHead(400, { "content-type": "text/html; charset=utf-8" }); res.end(setupPage((error as Error).message, false)); - } finally { setTimeout(() => { server.close(); if (callbackServer === server) callbackServer = null; }, 250).unref(); } + } }); callbackServer = server; const port = await new Promise((resolve, reject) => { callbackServer!.once("error", reject); callbackServer!.listen(0, "127.0.0.1", () => resolve((callbackServer!.address() as { port: number }).port)); }); const redirectUri = `http://127.0.0.1:${port}/gmail/callback`; + const pending: PendingOAuth = { client, state, verifier, redirectUri, completing: false }; + pendingOAuth = pending; const authorization = new URL(client.auth_uri || "https://accounts.google.com/o/oauth2/v2/auth"); authorization.search = new URLSearchParams({ client_id: client.client_id, redirect_uri: redirectUri, response_type: "code", scope: GMAIL_SCOPES.join(" "), access_type: "offline", prompt: "consent select_account", state, code_challenge: challenge, code_challenge_method: "S256" }).toString(); - setup = { active: true, status: "waiting", authorization_url: authorization.toString(), started_at: now(), expires_at: now() + 10 * 60_000 }; - const timer = setTimeout(() => { if (setup.active && setup.status === "waiting") setup = { active: false, status: "failed", error: "Gmail authorization expired. Start it again." }; callbackServer?.close(); callbackServer = null; }, 10 * 60_000); timer.unref(); + setup = { active: true, status: "waiting", authorization_url: authorization.toString(), manual_completion: true, started_at: now(), expires_at: now() + 10 * 60_000 }; + const timer = setTimeout(() => { + if (pendingOAuth !== pending) return; + setup = { active: false, status: "failed", error: "Gmail authorization expired. Start it again." }; + pendingOAuth = null; + callbackServer?.close(); callbackServer = null; + }, 10 * 60_000); timer.unref(); return gmailConnectionStatus(); } diff --git a/src/server/index.ts b/src/server/index.ts index 3d8db02..e54386b 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -58,7 +58,7 @@ import { inspectCatalogSkill, installCatalogSkill, refreshSkillCatalog, searchSk import { auditEvents, verifyAuditChain } from "./audit.ts"; import { configurePhoton, mapPhotonChannel, photonStatus, registerPhotonDispatcher, startPhotonConnector, stopPhotonConnector } from "./photon.ts"; import { photonSetupStatus, startPhotonSetup } from "./photon-auth.ts"; -import { gmailConnectionStatus, saveGmailOAuthClient, startGmailConnection } from "./gmail.ts"; +import { completeGmailConnection, gmailConnectionStatus, saveGmailOAuthClient, startGmailConnection } from "./gmail.ts"; import { cancelMnemosyneRuntimePreparation, ensureAgentMemory, mnemosyneAvailable, prepareMnemosyneRuntime } from "./memory.ts"; import { runImprovementPass, scheduleAgentReview, startImprovementLoop } from "./improvements.ts"; import { runThreadAuditPass, startThreadAuditLoop } from "./thread-audit.ts"; @@ -967,6 +967,12 @@ const server = createServer(async (req, res) => { try { return json(res, 202, { gmail: await startGmailConnection(b.client) }); } catch (error) { return json(res, 400, { error: (error as Error).message }); } } + if (p === "/api/connectors/gmail/callback" && m === "POST") { + if (!user.is_admin) return json(res, 403, { error: "Captain/admin only" }); + const b = await jbody(req); + try { return json(res, 200, { gmail: await completeGmailConnection(b.callback_url) }); } + catch (error) { return json(res, 400, { error: (error as Error).message }); } + } if (p === "/api/connectors/photon" && m === "GET") { if (!user.is_admin) return json(res, 403, { error: "Captain/admin only" }); return json(res, 200, { photon: photonStatus() }); diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index eab5113..8cce71f 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -168,7 +168,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive test("runtime digest and packaged image recipe stay pinned", async () => { assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"); assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/); - assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.7"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.8"); const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets"); const image = await readFile(join(root, "container", "Containerfile"), "utf8"); diff --git a/test/desktop.mjs b/test/desktop.mjs index c72dc31..5beb184 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -35,6 +35,7 @@ async function waitFor(url, timeoutMs = 20_000) { test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", async () => { const source = await readFile(join(root, "desktop", "main.cjs"), "utf8"); + const appClient = await readFile(join(root, "src", "client", "app.ts"), "utf8"); assert.match(source, /CTRL_DATA_DIR = app\.getPath\("userData"\)/); assert.match(source, /HELM_HOST = LOOPBACK/); assert.match(source, /contextIsolation: true/); @@ -45,6 +46,7 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(source, /HELM_RESOURCES_PATH = process\.resourcesPath/, "the local runtime resolves the connector bundled in the signed app"); assert.match(source, /allowedTeamUrl/); assert.match(source, /url\.protocol === "https:"/); + assert.match(source, /\^mailto:build@1helm\\\.com\$/i, "the company email opens only through the exact external mail link allowlist"); assert.match(source, /\.1helm\\\.com/); assert.match(source, /!\["demo\.1helm\.com", "provision\.1helm\.com"\]\.includes/, "only exact customer workspace subdomains may load inside the sandboxed renderer"); assert.match(source, /remoteWorkspacePath/); @@ -69,7 +71,6 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn const server = await readFile(join(root, "src", "server", "index.ts"), "utf8"); assert.match(server, /hostUpdateState\(APP_ROOT, DATA_DIR\)/, "the local control plane owns host update state"); assert.match(server, /runHostUpdateAction\(APP_ROOT, DATA_DIR, action\)/, "the local control plane invokes a constrained host action"); - const appClient = await readFile(join(root, "src", "client", "app.ts"), "utf8"); assert.match(appClient, /Check for updates/); assert.match(appClient, /1Helm v\$\{update\.current_version\}/, "Profile displays the installed version beside the update control"); assert.match(appClient, /Download on host/, "Profile makes native download ownership explicit"); @@ -161,6 +162,7 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(testRunner, /MNEMOSYNE_PYTHON: runtime/, "the full test suite shares one explicit pinned memory runtime instead of racing app-start installers"); assert.match(testRunner, /if \(existsSync\(venv\)\) rmSync\(venv, \{ recursive: true, force: true \}\);[\s\S]*spawnSync\(installer/, "each test-runtime fallback starts clean after a preferred Python leaves a partial venv"); assert.match(feedbackBrowser, /skip: executablePath \? false :/, "the Feedback browser contract does not hang a Chrome-free release runner"); + assert.match(await readFile(join(root, "src", "client", "app.ts"), "utf8"), /mailto:build@1helm\.com/, "the in-app Feedback surface exposes the company contact address"); assert.match(terminalBrowser, /HELM_CHANNEL_COMPUTER_BACKEND: "native"/, "the terminal browser contract uses the explicit development backend on CI hosts without an installed LXC runtime"); const serverRuntime = await readFile(join(root, "src", "server", "index.ts"), "utf8"); assert.match(serverRuntime, /const memoryRuntime = prepareMnemosyneRuntime\(\);[\s\S]*server\.listen\([\s\S]*memoryRuntime\.then/, "the HTTP server becomes ready before optional memory installation and initializes agent databases afterward"); diff --git a/test/gmail.mjs b/test/gmail.mjs index b97970c..4dfd31d 100644 --- a/test/gmail.mjs +++ b/test/gmail.mjs @@ -81,6 +81,23 @@ test("Gmail loopback OAuth rejects state mismatch and uses PKCE for a valid call assert.equal(exchanges[0].redirect_uri, authorization.searchParams.get("redirect_uri")); }); +test("Gmail accepts a pasted remote-browser localhost callback without fetching it", async () => { + const before = exchanges.length; + const status = await gmail.startGmailConnection(); + const authorization = new URL(status.setup.authorization_url); + assert.equal(status.setup.manual_completion, true); + const callback = new URL(authorization.searchParams.get("redirect_uri")); + callback.searchParams.set("code", "pasted-valid-code"); + callback.searchParams.set("state", authorization.searchParams.get("state")); + const completed = await gmail.completeGmailConnection(callback.toString()); + assert.equal(completed.setup.status, "connected"); + assert.deepEqual(completed.accounts, ["captain@example.com"]); + assert.equal(exchanges.length, before + 1, "only Google's token endpoint is called; the pasted localhost URL is never fetched"); + assert.equal(exchanges.at(-1).code, "pasted-valid-code"); + assert.equal(exchanges.at(-1).redirect_uri, authorization.searchParams.get("redirect_uri")); + await assert.rejects(() => gmail.completeGmailConnection(callback.toString()), /No Gmail authorization is waiting/i); +}); + test("connected Gmail inventory and brokered reads never expose tokens", async () => { const status = gmail.gmailConnectionStatus(); assert.deepEqual(status.accounts, ["captain@example.com"]); diff --git a/test/mock-openai.mjs b/test/mock-openai.mjs index 9022a76..1b4e007 100644 --- a/test/mock-openai.mjs +++ b/test/mock-openai.mjs @@ -2,11 +2,16 @@ import { createServer } from "node:http"; // Minimal OpenAI-compatible mock: /models + streaming /chat/completions with tool calling. const PORT = Number(process.argv[2] || 9099); +let scheduleFollowupContinuationRequests = 0; const sse = (res, obj) => res.write(`data: ${JSON.stringify(obj)}\n\n`); createServer(async (req, res) => { const url = new URL(req.url, "http://x"); + if (url.pathname === "/request-stats") { + res.writeHead(200, { "content-type": "application/json" }); + return res.end(JSON.stringify({ schedule_followup_continuations: scheduleFollowupContinuationRequests })); + } if (url.pathname === "/update-manifest") { res.writeHead(200, { "content-type": "application/json" }); return res.end(JSON.stringify({ version: process.env.MOCK_UPDATE_VERSION || "9.9.9" })); @@ -26,6 +31,9 @@ createServer(async (req, res) => { if (url.pathname.endsWith("/chat/completions")) { let raw = ""; for await (const c of req) raw += c; const reqBody = JSON.parse(raw || "{}"); + if (reqBody.messages?.some((message) => message.role === "tool" && message.name === "schedule_followup")) { + scheduleFollowupContinuationRequests++; + } if (url.pathname.includes("/always-fail/")) { res.writeHead(503, { "content-type": "application/json" }); return res.end(JSON.stringify({ error: { message: "Mock upstream unavailable", type: "api_error" } })); diff --git a/test/native-world.mjs b/test/native-world.mjs index de87d97..ae8fb35 100644 --- a/test/native-world.mjs +++ b/test/native-world.mjs @@ -671,6 +671,12 @@ try { const collaboratorLogin = await api("/api/auth/login", { body: { username: "collaborator", password: "secret-pass" } }); const collaborator = collaboratorLogin.body.token; ok(collaboratorCreate.status === 201 && collaborator, "Captain can add a workspace member who can sign in"); + const captainNotifications = await api("/api/me/ui-state", { method: "PATCH", body: { key: "notification_preferences", value: { globalMuted: true, channels: { [launch.id]: { muted: true, sound: "chime" } } } } }, captain); + const collaboratorUiState = await api("/api/me/ui-state", {}, collaborator); + ok(captainNotifications.body.state.notification_preferences.globalMuted === true + && captainNotifications.body.state.notification_preferences.channels[launch.id].sound === "chime" + && collaboratorUiState.body.state.notification_preferences === undefined, + "global mute, channel mute, and channel sound choices persist per user without muting another account"); const collaboratorInvite = await api(`/api/channels/${launch.id}/messages`, { body: { body: "@collaborator join this agent channel" } }, captain); await api(`/api/channels/${launch.id}/members/${collaboratorCreate.body.user.id}`, { body: { messageId: collaboratorInvite.body.message.id } }, captain); const removalStatus = await api("/api/app/removal", {}, captain); @@ -809,7 +815,11 @@ try { }, "schedule_followup persists pending row", 15_000); const followThread = await api(`/api/messages/${followRoot.body.message.id}/thread`, {}, captain); const visibleFollowupReply = followThread.body.replies?.some((message) => message.author?.name === afterRestart.agent.name && message.body && message.body !== "_Working…_"); - ok(Boolean(followEvidence) && !visibleFollowupReply, "resident schedule_followup creates a durable pending re-entry without a fake user-facing completion"); + const followupRequestStats = await fetch(`http://127.0.0.1:${mockPort}/request-stats`).then((response) => response.json()); + ok( + Boolean(followEvidence) && !visibleFollowupReply && followupRequestStats.schedule_followup_continuations === 0, + "resident schedule_followup creates a durable pending re-entry without another model request or a fake user-facing completion", + ); await api(`/api/threads/${taskThread.id}`, { method: "PATCH", body: { status: "waiting" } }, captain); const archive = await api(`/api/channels/${launch.id}/archive`, { body: {} }, captain); diff --git a/test/notifications.mjs b/test/notifications.mjs new file mode 100644 index 0000000..ab11fd2 --- /dev/null +++ b/test/notifications.mjs @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +const storage = new Map(); +globalThis.localStorage = { + getItem: (key) => storage.get(key) || null, + setItem: (key, value) => storage.set(key, String(value)), + removeItem: (key) => storage.delete(key), +}; + +let oscillators = 0; +class FakeAudioContext { + currentTime = 0; + destination = {}; + createOscillator() { + oscillators++; + return { type: "sine", frequency: { value: 0 }, connect() {}, start() {}, stop() {} }; + } + createGain() { + return { gain: { setValueAtTime() {}, linearRampToValueAtTime() {}, exponentialRampToValueAtTime() {} }, connect() {} }; + } +} +globalThis.window = { AudioContext: FakeAudioContext }; + +let serverPreference = { globalMuted: false, channels: {} }; +const requests = []; +globalThis.fetch = async (path, init = {}) => { + requests.push({ path, init }); + if (init.body) serverPreference = JSON.parse(String(init.body)).value; + return Response.json({ state: { notification_preferences: serverPreference } }); +}; + +const notifications = await import("../src/client/notifications.ts"); + +test("notification sounds honor user-global and per-channel mute before audio", () => { + notifications.hydrateNotificationPreferences({ globalMuted: true, channels: { 7: { muted: false, sound: "chime" } } }); + notifications.playNotification(7); + assert.equal(oscillators, 0); + + notifications.hydrateNotificationPreferences({ globalMuted: false, channels: { 7: { muted: true, sound: "chime" } } }); + notifications.playNotification(7); + assert.equal(oscillators, 0); + + notifications.hydrateNotificationPreferences({ globalMuted: false, channels: { 7: { muted: false, sound: "chime" } } }); + notifications.playNotification(7); + assert.equal(oscillators, 3, "the selected three-note channel chime is used"); +}); + +test("global and channel notification choices use profile-bound server state", async () => { + notifications.hydrateNotificationPreferences({ globalMuted: false, channels: {} }); + await notifications.setGlobalNotificationsMuted(true); + assert.equal(notifications.globalNotificationsMuted(), true); + await notifications.setChannelNotificationPreference(42, { muted: true, sound: "bell" }); + assert.deepEqual(notifications.channelNotificationPreference(42), { muted: true, sound: "bell" }); + assert.equal(requests.every((request) => request.path === "/api/me/ui-state"), true); + assert.equal(requests.every((request) => JSON.parse(String(request.init.body)).key === "notification_preferences"), true); +});