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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/ISSUE_TEMPLATE/bug_report.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,9 +92,9 @@ body:
attributes:
label: Relevant log lines
description: |
Halen writes diagnostic output to `/tmp/halen-trace.log`. The last 200 lines around the bug are usually plenty.
Halen writes diagnostic output to `~/Library/Application Support/Halen/halen-trace.log`. The last 200 lines around the bug are usually plenty.
```bash
tail -n 200 /tmp/halen-trace.log
tail -n 200 "$HOME/Library/Application Support/Halen/halen-trace.log"
```
Redact anything sensitive before pasting — Halen tries to scrub PII but trust your eyes over ours.
render: text
Expand Down
15 changes: 13 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,15 +36,20 @@ jobs:
swift --version
xcodebuild -version

- name: Test release verifiers
run: ./scripts/test-release-verifiers.sh

- name: Cache llama.xcframework
# Keyed on the pinned llama.cpp tag — bumping Vendor/LLAMA_CPP_VERSION
# invalidates the cache and forces a rebuild. Build is ~10 min cold,
# ~5 s warm.
id: cache-llama
uses: actions/cache@v4
with:
path: Vendor/llama.xcframework
key: llama-xcframework-${{ runner.os }}-${{ hashFiles('Vendor/LLAMA_CPP_VERSION') }}
path: |
Vendor/llama.xcframework
Vendor/llama.xcframework.provenance
key: llama-xcframework-${{ runner.os }}-${{ hashFiles('Vendor/LLAMA_CPP_VERSION', 'Vendor/LLAMA_CPP_COMMIT', 'scripts/verify-llama-framework.sh') }}

- name: Cache SwiftPM build
uses: actions/cache@v4
Expand All @@ -66,10 +71,16 @@ jobs:
- name: Build (debug)
run: swift build -c debug

- name: Verify vendored provenance
run: ./scripts/verify-llama-framework.sh

- name: Test
run: swift test -c debug --enable-code-coverage

- name: Verify release build compiles
# Release config catches a different class of warning/error than debug.
# Skip running tests at -O so CI stays under 15 min on the macos-14 runner.
run: swift build -c release

- name: Verify release Mach-O paths
run: ./scripts/verify-macho-paths.sh "$(swift build -c release --show-bin-path)/halen"
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ turned on, organised by feature folder. A few specifics:
- **No force-unwraps in production paths.** Guard, return, log. The
`HalenSupportDirectory.root` resolver exists specifically because every
`.first!` we used to have was a crash waiting on someone's edge case.
- **`Log` for diagnostics, not `print`.** Lines under `/tmp/halen-trace.log`
- **`Log` for diagnostics, not `print`.** Lines under
`~/Library/Application Support/Halen/halen-trace.log`
are how we debug; `print` is invisible in a release build.
- **Tests for non-trivial logic.** If you fix a bug, a regression test is
the price of admission. If you add a feature with branches, cover the
Expand Down
4 changes: 2 additions & 2 deletions Sources/Halen/App/AppCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ final class AppCoordinator {
pluginHost = host
for (dir, manifest) in host.discoverManifests() {
let adapter = ExternalPluginAdapter(manifest: manifest, pluginDir: dir, host: host)
registry.register(adapter)
registry.register(adapter, defaultEnabled: false)
}
host.startEventDispatcher()

Expand Down Expand Up @@ -286,7 +286,7 @@ final class AppCoordinator {
}
guard !registry.contains(manifest.id) else { return }
let adapter = ExternalPluginAdapter(manifest: manifest, pluginDir: directory, host: pluginHost)
registry.register(adapter)
registry.register(adapter, defaultEnabled: false)
}

private func startEventLogger() {
Expand Down
2 changes: 1 addition & 1 deletion Sources/Halen/App/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ struct SettingsView: View {
@AppStorage(OverlayController.dotStyleKey) private var overlayDotStyle: String = "solid"
/// Two-way binding to the WS bridge's enabled preference. Toggling
/// here also calls into the bridge to actually start/stop it live.
@AppStorage(WebSocketBridge.enabledKey) private var webSocketEnabled: Bool = true
@AppStorage(WebSocketBridge.enabledKey) private var webSocketEnabled: Bool = false
/// Persisted Ollama endpoint. The TextField edits `ollamaURLDraft` and
/// only writes through to this key on commit — typing "http://localh"
/// mid-edit shouldn't put a half-URL into UserDefaults.
Expand Down
4 changes: 2 additions & 2 deletions Sources/Halen/Inference/LlamaCpp/ModelSpec.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ extension ModelSpec {
bundleResourceName: "gemma-4-E4B-it-IQ4_XS",
displayName: "Gemma 4 E4B (IQ4_XS)",
sourceURL: URL(string:
"https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/main/gemma-4-E4B-it-IQ4_XS.gguf"
"https://huggingface.co/unsloth/gemma-4-E4B-it-GGUF/resolve/653803f092503c04a65164346f3208a36e707693/gemma-4-E4B-it-IQ4_XS.gguf"
)!,
expectedSize: 4_715_414_688, // ~4.72 GB
expectedSHA256: "eb29c8519c4c07b880fb9cae7ff13ee2e30c5f38516268920ab85c04df6d52a2",
Expand All @@ -97,7 +97,7 @@ extension ModelSpec {
bundleResourceName: "qwen2.5-0.5b-instruct-q4_k_m",
displayName: "Qwen 2.5 0.5B (Q4_K_M)",
sourceURL: URL(string:
"https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF/resolve/main/qwen2.5-0.5b-instruct-q4_k_m.gguf"
"https://huggingface.co/Qwen/Qwen2.5-0.5B-Instruct-GGUF/resolve/9217f5db79a29953eb74d5343926648285ec7e67/qwen2.5-0.5b-instruct-q4_k_m.gguf"
)!,
expectedSize: 491_400_032, // ~491 MB
expectedSHA256: "74a4da8c9fdbcd15bd1f6d01d621410d31c6fc00986f5eb687824e7b93d7a9db",
Expand Down
28 changes: 22 additions & 6 deletions Sources/Halen/Plugins/External/ExternalPluginAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,8 @@ final class ExternalPluginAdapter: HalenPlugin {
/// Marketplace detail view for an external plugin. Shows the manifest fields
/// the user might want to verify before trusting the plugin — id, version,
/// the actual executable that runs, declared permissions, where it lives on
/// disk. Permissions are surfaced even though the host doesn't enforce them
/// yet (informational v1), because they're the user's only signal of what
/// surface area the plugin is asking for.
/// disk. Permissions are surfaced because enabling is the user's approval
/// action and the host enforces this exact closed set.
@MainActor
private struct ExternalPluginDetailView: View {
let manifest: PluginManifest
Expand Down Expand Up @@ -147,7 +146,7 @@ private struct ExternalPluginDetailView: View {
GlassCard {
VStack(alignment: .leading, spacing: 8) {
cardLabel("Declared permissions")
let perms = manifest.permissions ?? []
let perms = manifest.permissions
if perms.isEmpty {
Text("This plugin declared no permissions.")
.font(.system(size: 11))
Expand All @@ -158,12 +157,12 @@ private struct ExternalPluginDetailView: View {
Image(systemName: "checkmark.shield")
.font(.system(size: 10))
.foregroundStyle(.secondary)
Text(perm)
Text(perm.rawValue)
.font(.system(size: 12, design: .monospaced))
}
}
}
Text("Permission enforcement is informational in v1 — the host trusts any installed plugin. A sandboxed exec ladder is on the roadmap.")
Text("These permissions gate Halen's plugin API. A plugin is still local executable code and is not an OS sandbox; enable only code you trust.")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
Expand All @@ -180,6 +179,23 @@ private struct ExternalPluginDetailView: View {
.font(.system(size: 12))
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)

Divider().padding(.vertical, 2)
cardLabel("Data subscriptions")
if manifest.events.isEmpty {
Text("This plugin receives no Halen event data.")
.font(.system(size: 11))
.foregroundStyle(.secondary)
} else {
ForEach(manifest.events.map(\.rawValue).sorted(), id: \.self) { topic in
Text(topic)
.font(.system(size: 12, design: .monospaced))
}
}
Text("Subscriptions can include typed text, focused-app identity, caret location, or findings. Review them as data-access grants.")
.font(.system(size: 10))
.foregroundStyle(.tertiary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
Expand Down
68 changes: 36 additions & 32 deletions Sources/Halen/Plugins/External/HostBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,9 @@ import AppKit
import ApplicationServices
import UserNotifications

/// Single source of truth for every plugin/extension → host JSON-RPC method
/// the host exposes. Both transports (stdio via `PluginHost` and WebSocket
/// via `WebSocketBridge`) delegate every incoming request to
/// `HostBridge.dispatch(...)` so the API surface is identical regardless of
/// how a client arrived.
///
/// This used to live duplicated in `PluginHost.handleIncoming` and
/// `WebSocketBridge.dispatch`, and the two had already drifted: the WS path
/// hardcoded `temperature: 0.4`, didn't accept `stop`/`taskKind`/`maxTokens`,
/// and was missing `ax/replaceRange` + `ui/toast` entirely. Consolidating
/// closes that class of bug.
/// Single source of truth for every external stdio plugin → host JSON-RPC
/// method. The browser WebSocket is deliberately notification-only and never
/// reaches this dispatcher.
@MainActor
final class HostBridge {
private let services: HalenServices
Expand All @@ -28,15 +20,14 @@ final class HostBridge {
/// The one dispatch site. Returns the `result` payload or throws an
/// `RPCErrorObject` the transport then encodes back to the caller.
///
/// `grantedPermissions` is the calling client's permission set — for a
/// stdio plugin, its manifest's `permissions`; for the WebSocket bridge,
/// empty (the browser extension has no privileged grants). Sensitive
/// methods (currently `calendar/*`) are gated on it. The text/AX/inference
/// methods stay ungated for now — tightening those is a separate security
/// pass that would need every existing plugin to declare permissions.
/// `grantedPermissions` is the stdio plugin manifest's permission set.
/// Every exposed capability is mapped to one closed permission here.
func dispatch(method: String,
params: RPCValue?,
grantedPermissions: Set<String>) async throws -> RPCValue {
if let permission = Self.requiredPermission(for: method) {
try require(permission, in: grantedPermissions, for: method)
}
switch method {
case "inference/complete":
return try await inferenceComplete(params: params)
Expand All @@ -49,10 +40,8 @@ final class HostBridge {
case "ui/prompt":
return await uiPrompt(params: params)
case "calendar/upcomingEvents":
try require("calendar", in: grantedPermissions, for: method)
return try await calendarUpcomingEvents(params: params)
case "calendar/createEvent":
try require("calendar", in: grantedPermissions, for: method)
return try await calendarCreateEvent(params: params)
case "profile/getToneProfile":
return profileGet(params: params)
Expand All @@ -66,16 +55,35 @@ final class HostBridge {
}
}

nonisolated static func requiredPermission(for method: String) -> PluginPermission? {
switch method {
case "inference/complete": return .inference
case "ax/readSelection": return .axRead
case "ax/replaceRange": return .axWrite
case "ui/toast": return .notifications
case "ui/prompt": return .uiPrompt
case "calendar/upcomingEvents", "calendar/createEvent": return .calendar
case "profile/getToneProfile", "profile/listToneProfiles": return .profilesRead
case "profile/setToneProfile": return .profilesWrite
default: return nil
}
}

nonisolated static func isAuthorized(method: String, grantedPermissions: Set<String>) -> Bool {
guard let required = requiredPermission(for: method) else { return true }
return grantedPermissions.contains(required.rawValue)
}

/// Throw `permissionDenied` unless `permission` is in the caller's grant
/// set. The plugin declared (or didn't) the permission in its manifest;
/// the marketplace install sheet is where the user actually consents.
private func require(_ permission: String,
private func require(_ permission: PluginPermission,
in granted: Set<String>,
for method: String) throws {
guard granted.contains(permission) else {
guard granted.contains(permission.rawValue) else {
throw RPCErrorObject(
code: PluginRPC.ErrorCode.permissionDenied.rawValue,
message: "\(method) requires the `\(permission)` permission — declare it in halen-plugin.json",
message: "\(method) requires the `\(permission.rawValue)` permission — declare it in halen-plugin.json",
data: nil)
}
}
Expand Down Expand Up @@ -164,10 +172,9 @@ final class HostBridge {
let title = params?.objectValue?["title"]?.stringValue ?? "Halen"
let body = params?.objectValue?["body"]?.stringValue ?? ""
// `ui/toast` posts a real system notification (it used to only log).
// No permission gate: a notification is low-risk and the user can
// silence Halen's notifications in System Settings. Authorisation is
// requested lazily — the first toast triggers the one-time prompt.
Log.info("toast: \(title): \(body)")
// The dispatch table gates this on `notifications`. System
// authorisation is requested lazily on first use.
Log.info(Log.redactedToastDescription(title: title, body: body))
Task { await Self.postNotification(title: title, body: body) }
return .object(["ok": true] as [String: Any?])
}
Expand All @@ -191,8 +198,7 @@ final class HostBridge {

/// Interactive popup. Unlike `ui/toast` this *blocks* the plugin's RPC
/// call until the user picks an action (or dismisses / it times out).
/// Ungated — like `ui/toast`, a popup is an annoyance at worst, not a
/// privilege; marketplace curation is the real gate on hostile plugins.
/// The dispatch table gates this on the separate `ui.prompt` permission.
private func uiPrompt(params: RPCValue?) async -> RPCValue {
let obj = params?.objectValue
let title = obj?["title"]?.stringValue ?? "Halen"
Expand All @@ -217,10 +223,8 @@ final class HostBridge {
// on every classification. Exposing the store over RPC lets an
// external plugin edit the *same* data the in-process readers see.
//
// Ungated for now. A future security pass might gate writes on a
// `profiles` permission, but for v0.2.0 the data is per-user
// preference (formal vs casual register, not a privacy-sensitive
// signal) and the marketplace is the trust boundary.
// Reads and writes are separately gated by `profiles.read` and
// `profiles.write` in the central dispatch table.

private func profileGet(params: RPCValue?) -> RPCValue {
let bundleId = params?.objectValue?["bundleId"]?.stringValue ?? ""
Expand Down
14 changes: 9 additions & 5 deletions Sources/Halen/Plugins/External/PluginHost.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,8 @@ final class PluginHost {
func spawn(at dir: URL, manifest: PluginManifest) async {
guard !instances.contains(where: { $0.manifest.id == manifest.id }) else { return }
// The plugin's granted permission set — what it declared in its
// manifest. `HostBridge` gates sensitive methods (calendar/*) on it.
let granted = Set(manifest.permissions ?? [])
// manifest. `HostBridge` gates every exposed host method on it.
let granted = Set(manifest.permissions.map(\.rawValue))
let pluginId = manifest.id // captured by the per-instance handler
// Captured separately because the conflict registry surfaces a
// human label (manifest name), not the dotted reverse-DNS id.
Expand All @@ -76,11 +76,15 @@ final class PluginHost {
handler: { [bridge, weak self] method, params in
// Per-plugin methods (hotkey/*) need plugin identity to route
// fired events back; intercept them here before falling
// through to the shared bridge. Every other RPC goes through
// the single `HostBridge` shared with the WebSocket transport,
// so the surface is identical and can't drift.
// through to the centralized `HostBridge`. The WebSocket bridge
// is notification-only and has no route to these RPC methods.
switch method {
case "hotkey/register", "hotkey/unregister":
guard granted.contains(PluginPermission.hotkeys.rawValue) else {
throw RPCErrorObject(code: PluginRPC.ErrorCode.permissionDenied.rawValue,
message: "\(method) requires the `hotkeys` permission",
data: nil)
}
guard let self else {
throw RPCErrorObject(code: PluginRPC.ErrorCode.internalError.rawValue,
message: "Plugin host shutting down", data: nil)
Expand Down
Loading