From b7363f7c18e5a5572d720114e9dd0ed0c4ccdcc2 Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Sat, 20 Dec 2025 21:32:06 +0100 Subject: [PATCH 1/6] feat: Nix mode config, UX, onboarding, SwiftPM plist, docs --- apps/macos/Package.swift | 3 + .../Sources/Clawdis/ClawdisConfigFile.swift | 1 + .../Sources/Clawdis/ConfigSettings.swift | 10 +- .../Sources/Clawdis/GeneralSettings.swift | 6 +- apps/macos/Sources/Clawdis/Onboarding.swift | 7 + .../Sources/Clawdis/PermissionsSettings.swift | 18 ++ .../Sources/Clawdis/ProcessInfo+Clawdis.swift | 5 + .../Sources/Clawdis/Resources/Info.plist | 73 +++++++ .../Sources/Clawdis/SettingsRootView.swift | 130 ++++++++----- docs/index.md | 1 + docs/nix.md | 49 +++++ scripts/package-mac-app.sh | 96 ++-------- src/config/config.test.ts | 180 ++++++++++++++++++ src/config/config.ts | 35 +++- src/gateway/server.test.ts | 1 + src/gateway/server.ts | 31 ++- 16 files changed, 513 insertions(+), 133 deletions(-) create mode 100644 apps/macos/Sources/Clawdis/Resources/Info.plist create mode 100644 docs/nix.md diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 02aaf2ecd..941d684c9 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -49,6 +49,9 @@ let package = Package( .product(name: "PeekabooBridge", package: "PeekabooCore"), .product(name: "PeekabooAutomationKit", package: "PeekabooAutomationKit"), ], + exclude: [ + "Resources/Info.plist", + ], resources: [ .copy("Resources/Clawdis.icns"), .copy("Resources/DeviceModels"), diff --git a/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift b/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift index 4675badfa..415c01571 100644 --- a/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift +++ b/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift @@ -20,6 +20,7 @@ enum ClawdisConfigFile { } static func saveDict(_ dict: [String: Any]) { + if ProcessInfo.processInfo.isNixMode { return } do { let data = try JSONSerialization.data(withJSONObject: dict, options: [.prettyPrinted, .sortedKeys]) let url = self.url() diff --git a/apps/macos/Sources/Clawdis/ConfigSettings.swift b/apps/macos/Sources/Clawdis/ConfigSettings.swift index 043139351..75b6a7de3 100644 --- a/apps/macos/Sources/Clawdis/ConfigSettings.swift +++ b/apps/macos/Sources/Clawdis/ConfigSettings.swift @@ -3,6 +3,7 @@ import SwiftUI @MainActor struct ConfigSettings: View { private let isPreview = ProcessInfo.processInfo.isPreview + private let isNixMode = ProcessInfo.processInfo.isNixMode private let state = AppStateStore.shared private let labelColumnWidth: CGFloat = 120 private static let browserAttachOnlyHelp = @@ -52,8 +53,11 @@ struct ConfigSettings: View { VStack(alignment: .leading, spacing: 14) { self.header self.agentSection + .disabled(self.isNixMode) self.heartbeatSection + .disabled(self.isNixMode) self.browserSection + .disabled(self.isNixMode) Spacer(minLength: 0) } .frame(maxWidth: .infinity, alignment: .leading) @@ -66,7 +70,9 @@ struct ConfigSettings: View { private var header: some View { Text("Clawdis CLI config") .font(.title3.weight(.semibold)) - Text("Edit ~/.clawdis/clawdis.json (agent / session / routing / messages).") + Text(self.isNixMode + ? "This tab is read-only in Nix mode. Edit config via Nix and rebuild." + : "Edit ~/.clawdis/clawdis.json (agent / session / routing / messages).") .font(.callout) .foregroundStyle(.secondary) } @@ -300,7 +306,7 @@ struct ConfigSettings: View { } private func autosaveConfig() { - guard self.allowAutosave else { return } + guard self.allowAutosave, !self.isNixMode else { return } Task { await self.saveConfig() } } diff --git a/apps/macos/Sources/Clawdis/GeneralSettings.swift b/apps/macos/Sources/Clawdis/GeneralSettings.swift index 754e4a013..c705941d2 100644 --- a/apps/macos/Sources/Clawdis/GeneralSettings.swift +++ b/apps/macos/Sources/Clawdis/GeneralSettings.swift @@ -17,6 +17,7 @@ struct GeneralSettings: View { @State private var remoteStatus: RemoteStatus = .idle @State private var showRemoteAdvanced = false private let isPreview = ProcessInfo.processInfo.isPreview + private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode } var body: some View { ScrollView(.vertical) { @@ -125,7 +126,10 @@ struct GeneralSettings: View { } if self.state.connectionMode == .local { - self.gatewayInstallerCard + // In Nix mode, gateway is managed declaratively - no install buttons. + if !self.isNixMode { + self.gatewayInstallerCard + } TailscaleIntegrationSection( connectionMode: self.state.connectionMode, isPaused: self.state.isPaused) diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index 06b89b678..07dfb9776 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -15,6 +15,13 @@ final class OnboardingController { private var window: NSWindow? func show() { + if ProcessInfo.processInfo.isNixMode { + // Nix mode is fully declarative; onboarding would suggest interactive setup that doesn't apply. + UserDefaults.standard.set(true, forKey: "clawdis.onboardingSeen") + UserDefaults.standard.set(currentOnboardingVersion, forKey: onboardingVersionKey) + AppStateStore.shared.onboardingSeen = true + return + } if let window { DockIconManager.shared.temporarilyShowDock() window.makeKeyAndOrderFront(nil) diff --git a/apps/macos/Sources/Clawdis/PermissionsSettings.swift b/apps/macos/Sources/Clawdis/PermissionsSettings.swift index 99f29566d..fd2040f22 100644 --- a/apps/macos/Sources/Clawdis/PermissionsSettings.swift +++ b/apps/macos/Sources/Clawdis/PermissionsSettings.swift @@ -45,6 +45,24 @@ struct PermissionStatusList: View { .font(.footnote) .padding(.top, 2) .help("Refresh status") + + if (self.status[.accessibility] ?? false) == false || (self.status[.screenRecording] ?? false) == false { + VStack(alignment: .leading, spacing: 8) { + Text("Note: macOS may require restarting Clawdis after enabling Accessibility or Screen Recording.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Button { + LaunchdManager.startClawdis() + } label: { + Label("Restart Clawdis", systemImage: "arrow.counterclockwise") + } + .buttonStyle(.bordered) + .controlSize(.small) + } + .padding(.top, 4) + } } } diff --git a/apps/macos/Sources/Clawdis/ProcessInfo+Clawdis.swift b/apps/macos/Sources/Clawdis/ProcessInfo+Clawdis.swift index aacf7e83c..4df2b1815 100644 --- a/apps/macos/Sources/Clawdis/ProcessInfo+Clawdis.swift +++ b/apps/macos/Sources/Clawdis/ProcessInfo+Clawdis.swift @@ -5,6 +5,11 @@ extension ProcessInfo { self.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1" } + var isNixMode: Bool { + if self.environment["CLAWDIS_NIX_MODE"] == "1" { return true } + return UserDefaults.standard.bool(forKey: "clawdis.nixMode") + } + var isRunningTests: Bool { // SwiftPM tests load one or more `.xctest` bundles. With Swift Testing, `Bundle.main` is not // guaranteed to be the `.xctest` bundle, so check all loaded bundles. diff --git a/apps/macos/Sources/Clawdis/Resources/Info.plist b/apps/macos/Sources/Clawdis/Resources/Info.plist new file mode 100644 index 000000000..fa8b65716 --- /dev/null +++ b/apps/macos/Sources/Clawdis/Resources/Info.plist @@ -0,0 +1,73 @@ + + + + + CFBundleDevelopmentRegion + en + CFBundleExecutable + Clawdis + CFBundleIdentifier + com.steipete.clawdis + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + Clawdis + CFBundlePackageType + APPL + CFBundleShortVersionString + 0.0.0 + CFBundleVersion + 0.0.0 + CFBundleIconFile + Clawdis + CFBundleURLTypes + + + CFBundleURLName + com.steipete.clawdis.deeplink + CFBundleURLSchemes + + clawdis + + + + LSMinimumSystemVersion + 15.0 + LSUIElement + + + ClawdisBuildTimestamp + + ClawdisGitCommit + + + NSUserNotificationUsageDescription + Clawdis needs notification permission to show alerts for agent actions. + NSScreenCaptureDescription + Clawdis captures the screen when the agent needs screenshots for context. + NSCameraUsageDescription + Clawdis can capture photos or short video clips when requested by the agent. + NSMicrophoneUsageDescription + Clawdis needs the mic for Voice Wake tests and agent audio capture. + NSSpeechRecognitionUsageDescription + Clawdis uses speech recognition to detect your Voice Wake trigger phrase. + NSAppleEventsUsageDescription + Clawdis needs Automation (AppleScript) permission to drive Terminal and other apps for agent actions. + + NSAppTransportSecurity + + NSAllowsArbitraryLoadsInWebContent + + NSExceptionDomains + + 100.100.100.100 + + NSExceptionAllowsInsecureHTTPLoads + + NSIncludesSubdomains + + + + + + diff --git a/apps/macos/Sources/Clawdis/SettingsRootView.swift b/apps/macos/Sources/Clawdis/SettingsRootView.swift index 9ede06efb..3f6a4b8d5 100644 --- a/apps/macos/Sources/Clawdis/SettingsRootView.swift +++ b/apps/macos/Sources/Clawdis/SettingsRootView.swift @@ -8,6 +8,7 @@ struct SettingsRootView: View { @State private var selectedTab: SettingsTab = .general let updater: UpdaterProviding? private let isPreview = ProcessInfo.processInfo.isPreview + private let isNixMode = ProcessInfo.processInfo.isNixMode init(state: AppState, updater: UpdaterProviding?, initialTab: SettingsTab? = nil) { self.state = state @@ -16,55 +17,61 @@ struct SettingsRootView: View { } var body: some View { - TabView(selection: self.$selectedTab) { - GeneralSettings(state: self.state) - .tabItem { Label("General", systemImage: "gearshape") } - .tag(SettingsTab.general) - - ConnectionsSettings() - .tabItem { Label("Connections", systemImage: "link") } - .tag(SettingsTab.connections) - - VoiceWakeSettings(state: self.state) - .tabItem { Label("Voice Wake", systemImage: "waveform.circle") } - .tag(SettingsTab.voiceWake) - - ConfigSettings() - .tabItem { Label("Config", systemImage: "slider.horizontal.3") } - .tag(SettingsTab.config) - - InstancesSettings() - .tabItem { Label("Instances", systemImage: "network") } - .tag(SettingsTab.instances) - - SessionsSettings() - .tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") } - .tag(SettingsTab.sessions) - - CronSettings() - .tabItem { Label("Cron", systemImage: "calendar") } - .tag(SettingsTab.cron) - - SkillsSettings(state: self.state) - .tabItem { Label("Skills", systemImage: "sparkles") } - .tag(SettingsTab.skills) - - PermissionsSettings( - status: self.permissionMonitor.status, - refresh: self.refreshPerms, - showOnboarding: { OnboardingController.shared.show() }) - .tabItem { Label("Permissions", systemImage: "lock.shield") } - .tag(SettingsTab.permissions) - - if self.state.debugPaneEnabled { - DebugSettings() - .tabItem { Label("Debug", systemImage: "ant") } - .tag(SettingsTab.debug) + VStack(alignment: .leading, spacing: 12) { + if self.isNixMode { + self.nixManagedBanner } - AboutSettings(updater: self.updater) - .tabItem { Label("About", systemImage: "info.circle") } - .tag(SettingsTab.about) + TabView(selection: self.$selectedTab) { + GeneralSettings(state: self.state) + .tabItem { Label("General", systemImage: "gearshape") } + .tag(SettingsTab.general) + + ConnectionsSettings() + .tabItem { Label("Connections", systemImage: "link") } + .tag(SettingsTab.connections) + + VoiceWakeSettings(state: self.state) + .tabItem { Label("Voice Wake", systemImage: "waveform.circle") } + .tag(SettingsTab.voiceWake) + + ConfigSettings() + .tabItem { Label("Config", systemImage: "slider.horizontal.3") } + .tag(SettingsTab.config) + + InstancesSettings() + .tabItem { Label("Instances", systemImage: "network") } + .tag(SettingsTab.instances) + + SessionsSettings() + .tabItem { Label("Sessions", systemImage: "clock.arrow.circlepath") } + .tag(SettingsTab.sessions) + + CronSettings() + .tabItem { Label("Cron", systemImage: "calendar") } + .tag(SettingsTab.cron) + + SkillsSettings(state: self.state) + .tabItem { Label("Skills", systemImage: "sparkles") } + .tag(SettingsTab.skills) + + PermissionsSettings( + status: self.permissionMonitor.status, + refresh: self.refreshPerms, + showOnboarding: { OnboardingController.shared.show() }) + .tabItem { Label("Permissions", systemImage: "lock.shield") } + .tag(SettingsTab.permissions) + + if self.state.debugPaneEnabled { + DebugSettings() + .tabItem { Label("Debug", systemImage: "ant") } + .tag(SettingsTab.debug) + } + + AboutSettings(updater: self.updater) + .tabItem { Label("About", systemImage: "info.circle") } + .tag(SettingsTab.about) + } } .padding(.horizontal, 28) .padding(.vertical, 22) @@ -98,6 +105,35 @@ struct SettingsRootView: View { } } + private var nixManagedBanner: some View { + let configPath = ProcessInfo.processInfo.environment["CLAWDIS_CONFIG_PATH"] ?? "~/.clawdis/clawdis.json" + let stateDir = ProcessInfo.processInfo.environment["CLAWDIS_STATE_DIR"] ?? "~/.clawdis" + + return VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 8) { + Image(systemName: "gearshape.2.fill") + .foregroundStyle(.secondary) + Text("Managed by Nix") + .font(.callout.weight(.semibold)) + .foregroundStyle(.secondary) + } + + VStack(alignment: .leading, spacing: 2) { + Text("Config: \(configPath)") + Text("State: \(stateDir)") + } + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + .textSelection(.enabled) + .lineLimit(1) + .truncationMode(.middle) + } + .padding(.vertical, 8) + .padding(.horizontal, 10) + .background(Color.gray.opacity(0.12)) + .cornerRadius(10) + } + private func validTab(for requested: SettingsTab) -> SettingsTab { if requested == .debug, !self.state.debugPaneEnabled { return .general } return requested diff --git a/docs/index.md b/docs/index.md index 18f0009c5..ff4852755 100644 --- a/docs/index.md +++ b/docs/index.md @@ -116,6 +116,7 @@ Example: - Start here: - [Configuration](./configuration.md) + - [Nix mode](./nix.md) - [Clawd personal assistant setup](./clawd.md) - [Skills](./skills.md) - [Workspace templates](./templates/AGENTS.md) diff --git a/docs/nix.md b/docs/nix.md new file mode 100644 index 000000000..6e8a8cb42 --- /dev/null +++ b/docs/nix.md @@ -0,0 +1,49 @@ +--- +summary: "Running Clawdis under Nix (config, state, and packaging expectations)" +read_when: + - Building Clawdis with Nix + - Debugging Nix-mode behavior +--- +# Nix mode + +Clawdis supports a **Nix mode** that makes configuration deterministic and disables auto-install flows. +Enable it by exporting: + +``` +CLAWDIS_NIX_MODE=1 +``` + +On macOS, the GUI app does not automatically inherit shell env vars. You can +also enable Nix mode via defaults: + +``` +defaults write com.steipete.clawdis clawdis.nixMode -bool true +``` + +## Config + state paths + +Clawdis reads JSON5 config from `CLAWDIS_CONFIG_PATH` and stores mutable data in `CLAWDIS_STATE_DIR`. + +- `CLAWDIS_STATE_DIR` (default: `~/.clawdis`) +- `CLAWDIS_CONFIG_PATH` (default: `$CLAWDIS_STATE_DIR/clawdis.json`) + +When running under Nix, set these explicitly to Nix-managed locations so runtime state and config +stay out of the immutable store. + +## Runtime behavior in Nix mode + +- Auto-install and self-mutation flows should be disabled. +- Missing dependencies should surface Nix-specific remediation messages. +- UI surfaces a read-only Nix mode banner when present. + +## Packaging note (macOS) + +The macOS packaging flow expects a stable Info.plist template at: + +``` +apps/macos/Sources/Clawdis/Resources/Info.plist +``` + +`scripts/package-mac-app.sh` copies this template into the app bundle and patches dynamic fields +(bundle ID, version/build, Git SHA, Sparkle keys). This keeps the plist deterministic for SwiftPM +packaging and Nix builds (which do not rely on a full Xcode toolchain). diff --git a/scripts/package-mac-app.sh b/scripts/package-mac-app.sh index a7df9c293..63ccd6090 100755 --- a/scripts/package-mac-app.sh +++ b/scripts/package-mac-app.sh @@ -47,81 +47,27 @@ mkdir -p "$APP_ROOT/Contents/Resources" mkdir -p "$APP_ROOT/Contents/Resources/Relay" mkdir -p "$APP_ROOT/Contents/Frameworks" -echo "📄 Writing Info.plist" -cat > "$APP_ROOT/Contents/Info.plist" < - - - - CFBundleIdentifier - ${BUNDLE_ID} - CFBundleShortVersionString - ${APP_VERSION} - CFBundleVersion - ${APP_BUILD} - CFBundleName - Clawdis - CFBundleExecutable - Clawdis - CFBundleIconFile - Clawdis - CFBundlePackageType - APPL - LSMinimumSystemVersion - 15.0 - LSUIElement - - CFBundleURLTypes - - - CFBundleURLName - com.steipete.clawdis.deeplink - CFBundleURLSchemes - - clawdis - - - - ClawdisBuildTimestamp - ${BUILD_TS} - ClawdisGitCommit - ${GIT_COMMIT} - SUFeedURL - ${SPARKLE_FEED_URL} - SUPublicEDKey - ${SPARKLE_PUBLIC_ED_KEY} - SUEnableAutomaticChecks - <${AUTO_CHECKS}/> - NSUserNotificationUsageDescription - Clawdis needs notification permission to show alerts for agent actions. - NSScreenCaptureDescription - Clawdis captures the screen when the agent needs screenshots for context. - NSCameraUsageDescription - Clawdis can capture photos or short video clips when requested by the agent. - NSMicrophoneUsageDescription - Clawdis needs the mic for Voice Wake tests and agent audio capture. - NSSpeechRecognitionUsageDescription - Clawdis uses speech recognition to detect your Voice Wake trigger phrase. - NSAppleEventsUsageDescription - Clawdis needs Automation (AppleScript) permission to drive Terminal and other apps for agent actions. - NSAppTransportSecurity - - NSAllowsArbitraryLoadsInWebContent - - NSExceptionDomains - - 100.100.100.100 - - NSExceptionAllowsInsecureHTTPLoads - - NSIncludesSubdomains - - - - - - -PLIST +echo "📄 Copying Info.plist template" +INFO_PLIST_SRC="$ROOT_DIR/apps/macos/Sources/Clawdis/Resources/Info.plist" +if [ ! -f "$INFO_PLIST_SRC" ]; then + echo "ERROR: Info.plist template missing at $INFO_PLIST_SRC" >&2 + exit 1 +fi +cp "$INFO_PLIST_SRC" "$APP_ROOT/Contents/Info.plist" +/usr/libexec/PlistBuddy -c "Set :CFBundleIdentifier ${BUNDLE_ID}" "$APP_ROOT/Contents/Info.plist" || true +/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${APP_VERSION}" "$APP_ROOT/Contents/Info.plist" || true +/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${APP_BUILD}" "$APP_ROOT/Contents/Info.plist" || true +/usr/libexec/PlistBuddy -c "Set :ClawdisBuildTimestamp ${BUILD_TS}" "$APP_ROOT/Contents/Info.plist" || true +/usr/libexec/PlistBuddy -c "Set :ClawdisGitCommit ${GIT_COMMIT}" "$APP_ROOT/Contents/Info.plist" || true +/usr/libexec/PlistBuddy -c "Set :SUFeedURL ${SPARKLE_FEED_URL}" "$APP_ROOT/Contents/Info.plist" \ + || /usr/libexec/PlistBuddy -c "Add :SUFeedURL string ${SPARKLE_FEED_URL}" "$APP_ROOT/Contents/Info.plist" || true +/usr/libexec/PlistBuddy -c "Set :SUPublicEDKey ${SPARKLE_PUBLIC_ED_KEY}" "$APP_ROOT/Contents/Info.plist" \ + || /usr/libexec/PlistBuddy -c "Add :SUPublicEDKey string ${SPARKLE_PUBLIC_ED_KEY}" "$APP_ROOT/Contents/Info.plist" || true +if /usr/libexec/PlistBuddy -c "Set :SUEnableAutomaticChecks ${AUTO_CHECKS}" "$APP_ROOT/Contents/Info.plist"; then + true +else + /usr/libexec/PlistBuddy -c "Add :SUEnableAutomaticChecks bool ${AUTO_CHECKS}" "$APP_ROOT/Contents/Info.plist" || true +fi echo "🚚 Copying binary" cp "$BIN" "$APP_ROOT/Contents/MacOS/Clawdis" diff --git a/src/config/config.test.ts b/src/config/config.test.ts index c6669848a..21558a5f1 100644 --- a/src/config/config.test.ts +++ b/src/config/config.test.ts @@ -16,6 +16,37 @@ async function withTempHome(fn: (home: string) => Promise): Promise { } } +/** + * Helper to test env var overrides. Saves/restores env vars and resets modules. + */ +async function withEnvOverride( + overrides: Record, + fn: () => Promise, +): Promise { + const saved: Record = {}; + for (const key of Object.keys(overrides)) { + saved[key] = process.env[key]; + if (overrides[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = overrides[key]; + } + } + vi.resetModules(); + try { + return await fn(); + } finally { + for (const key of Object.keys(saved)) { + if (saved[key] === undefined) { + delete process.env[key]; + } else { + process.env[key] = saved[key]; + } + } + vi.resetModules(); + } +} + describe("config identity defaults", () => { let previousHome: string | undefined; @@ -174,3 +205,152 @@ describe("config identity defaults", () => { }); }); }); + +describe("Nix integration (U3, U5, U9)", () => { + describe("U3: isNixMode env var detection", () => { + it("isNixMode is false when CLAWDIS_NIX_MODE is not set", async () => { + await withEnvOverride({ CLAWDIS_NIX_MODE: undefined }, async () => { + const { isNixMode } = await import("./config.js"); + expect(isNixMode).toBe(false); + }); + }); + + it("isNixMode is false when CLAWDIS_NIX_MODE is empty", async () => { + await withEnvOverride({ CLAWDIS_NIX_MODE: "" }, async () => { + const { isNixMode } = await import("./config.js"); + expect(isNixMode).toBe(false); + }); + }); + + it("isNixMode is false when CLAWDIS_NIX_MODE is not '1'", async () => { + await withEnvOverride({ CLAWDIS_NIX_MODE: "true" }, async () => { + const { isNixMode } = await import("./config.js"); + expect(isNixMode).toBe(false); + }); + }); + + it("isNixMode is true when CLAWDIS_NIX_MODE=1", async () => { + await withEnvOverride({ CLAWDIS_NIX_MODE: "1" }, async () => { + const { isNixMode } = await import("./config.js"); + expect(isNixMode).toBe(true); + }); + }); + }); + + describe("U5: CONFIG_PATH and STATE_DIR env var overrides", () => { + it("STATE_DIR_CLAWDIS defaults to ~/.clawdis when env not set", async () => { + await withEnvOverride({ CLAWDIS_STATE_DIR: undefined }, async () => { + const { STATE_DIR_CLAWDIS } = await import("./config.js"); + expect(STATE_DIR_CLAWDIS).toMatch(/\.clawdis$/); + }); + }); + + it("STATE_DIR_CLAWDIS respects CLAWDIS_STATE_DIR override", async () => { + await withEnvOverride( + { CLAWDIS_STATE_DIR: "/custom/state/dir" }, + async () => { + const { STATE_DIR_CLAWDIS } = await import("./config.js"); + expect(STATE_DIR_CLAWDIS).toBe("/custom/state/dir"); + }, + ); + }); + + it("CONFIG_PATH_CLAWDIS defaults to ~/.clawdis/clawdis.json when env not set", async () => { + await withEnvOverride( + { CLAWDIS_CONFIG_PATH: undefined, CLAWDIS_STATE_DIR: undefined }, + async () => { + const { CONFIG_PATH_CLAWDIS } = await import("./config.js"); + expect(CONFIG_PATH_CLAWDIS).toMatch(/\.clawdis\/clawdis\.json$/); + }, + ); + }); + + it("CONFIG_PATH_CLAWDIS respects CLAWDIS_CONFIG_PATH override", async () => { + await withEnvOverride( + { CLAWDIS_CONFIG_PATH: "/nix/store/abc/clawdis.json" }, + async () => { + const { CONFIG_PATH_CLAWDIS } = await import("./config.js"); + expect(CONFIG_PATH_CLAWDIS).toBe("/nix/store/abc/clawdis.json"); + }, + ); + }); + + it("CONFIG_PATH_CLAWDIS uses STATE_DIR_CLAWDIS when only state dir is overridden", async () => { + await withEnvOverride( + { + CLAWDIS_CONFIG_PATH: undefined, + CLAWDIS_STATE_DIR: "/custom/state", + }, + async () => { + const { CONFIG_PATH_CLAWDIS } = await import("./config.js"); + expect(CONFIG_PATH_CLAWDIS).toBe("/custom/state/clawdis.json"); + }, + ); + }); + }); + + describe("U9: telegram.tokenFile schema validation", () => { + it("accepts config with only botToken", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdis"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdis.json"), + JSON.stringify({ + telegram: { botToken: "123:ABC" }, + }), + "utf-8", + ); + + vi.resetModules(); + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + expect(cfg.telegram?.botToken).toBe("123:ABC"); + expect(cfg.telegram?.tokenFile).toBeUndefined(); + }); + }); + + it("accepts config with only tokenFile", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdis"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdis.json"), + JSON.stringify({ + telegram: { tokenFile: "/run/agenix/telegram-token" }, + }), + "utf-8", + ); + + vi.resetModules(); + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + expect(cfg.telegram?.tokenFile).toBe("/run/agenix/telegram-token"); + expect(cfg.telegram?.botToken).toBeUndefined(); + }); + }); + + it("accepts config with both botToken and tokenFile", async () => { + await withTempHome(async (home) => { + const configDir = path.join(home, ".clawdis"); + await fs.mkdir(configDir, { recursive: true }); + await fs.writeFile( + path.join(configDir, "clawdis.json"), + JSON.stringify({ + telegram: { + botToken: "fallback:token", + tokenFile: "/run/agenix/telegram-token", + }, + }), + "utf-8", + ); + + vi.resetModules(); + const { loadConfig } = await import("./config.js"); + const cfg = loadConfig(); + expect(cfg.telegram?.botToken).toBe("fallback:token"); + expect(cfg.telegram?.tokenFile).toBe("/run/agenix/telegram-token"); + }); + }); + }); +}); diff --git a/src/config/config.ts b/src/config/config.ts index a9cbb2243..552e40755 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -7,6 +7,16 @@ import { z } from "zod"; import { parseDurationMs } from "../cli/parse-duration.js"; +/** + * Nix mode detection: When CLAWDIS_NIX_MODE=1, the gateway is running under Nix. + * In this mode: + * - No auto-install flows should be attempted + * - Missing dependencies should produce actionable Nix-specific error messages + * - Config is managed externally (read-only from Nix perspective) + */ +export const isNixMode = process.env.CLAWDIS_NIX_MODE === "1"; + +export type ReplyMode = "text" | "command"; export type SessionScope = "per-sender" | "global"; export type SessionConfig = { @@ -131,6 +141,8 @@ export type TelegramConfig = { /** If false, do not start the Telegram provider. Default: true. */ enabled?: boolean; botToken?: string; + /** Path to file containing bot token (for secret managers like agenix) */ + tokenFile?: string; requireMention?: boolean; allowFrom?: Array; mediaMaxMb?: number; @@ -395,12 +407,22 @@ export type ClawdisConfig = { skills?: Record; }; -// New branding path (preferred) -export const CONFIG_PATH_CLAWDIS = path.join( - os.homedir(), - ".clawdis", - "clawdis.json", -); +/** + * State directory for mutable data (sessions, logs, caches). + * Can be overridden via CLAWDIS_STATE_DIR environment variable. + * Default: ~/.clawdis + */ +export const STATE_DIR_CLAWDIS = + process.env.CLAWDIS_STATE_DIR ?? path.join(os.homedir(), ".clawdis"); + +/** + * Config file path (JSON5). + * Can be overridden via CLAWDIS_CONFIG_PATH environment variable. + * Default: ~/.clawdis/clawdis.json (or $CLAWDIS_STATE_DIR/clawdis.json) + */ +export const CONFIG_PATH_CLAWDIS = + process.env.CLAWDIS_CONFIG_PATH ?? + path.join(STATE_DIR_CLAWDIS, "clawdis.json"); const ModelApiSchema = z.union([ z.literal("openai-completions"), @@ -731,6 +753,7 @@ const ClawdisSchema = z.object({ .object({ enabled: z.boolean().optional(), botToken: z.string().optional(), + tokenFile: z.string().optional(), requireMention: z.boolean().optional(), allowFrom: z.array(z.union([z.string(), z.number()])).optional(), mediaMaxMb: z.number().positive().optional(), diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index dec83329d..f598b498c 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -189,6 +189,7 @@ vi.mock("../config/config.js", () => { return { CONFIG_PATH_CLAWDIS: resolveConfigPath(), + isNixMode: false, loadConfig: () => ({ agent: { model: "anthropic/claude-opus-4-5", diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 819e8fefb..9ec348416 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -46,6 +46,7 @@ import { getStatusSummary } from "../commands/status.js"; import { type ClawdisConfig, CONFIG_PATH_CLAWDIS, + isNixMode, loadConfig, parseConfigJson5, readConfigFileSnapshot, @@ -1876,6 +1877,30 @@ export async function startGatewayServer( }; }; + /** + * Load telegram token with priority: env var > tokenFile > botToken. + * tokenFile supports secret managers (e.g., agenix). + */ + const loadTelegramToken = (cfg: ClawdisConfig): string => { + if (process.env.TELEGRAM_BOT_TOKEN) { + return process.env.TELEGRAM_BOT_TOKEN.trim(); + } + if (cfg.telegram?.tokenFile) { + const filePath = cfg.telegram.tokenFile; + if (!fs.existsSync(filePath)) { + logTelegram.info(`telegram tokenFile not found: ${filePath}`); + return ""; + } + try { + return fs.readFileSync(filePath, "utf-8").trim(); + } catch (err) { + logTelegram.info(`failed to read telegram tokenFile: ${String(err)}`); + return ""; + } + } + return cfg.telegram?.botToken?.trim() ?? ""; + }; + const startTelegramProvider = async () => { if (telegramTask) return; const cfg = loadConfig(); @@ -1888,8 +1913,7 @@ export async function startGatewayServer( logTelegram.info("skipping provider start (telegram.enabled=false)"); return; } - const telegramToken = - process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken ?? ""; + const telegramToken = loadTelegramToken(cfg); if (!telegramToken.trim()) { telegramRuntime = { ...telegramRuntime, @@ -6090,6 +6114,9 @@ export async function startGatewayServer( }); log.info(`listening on ws://${bindHost}:${port} (PID ${process.pid})`); log.info(`log file: ${getResolvedLoggerSettings().file}`); + if (isNixMode) { + log.info("gateway: running in Nix mode (config managed externally)"); + } let tailscaleCleanup: (() => Promise) | null = null; if (tailscaleMode !== "off") { try { From 5d19afd4223980d9d925ed6721dd0421a055facb Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Sat, 20 Dec 2025 21:32:17 +0100 Subject: [PATCH 2/6] feat: improve health checks (telegram tokenFile + hints) --- apps/macos/Sources/Clawdis/HealthStore.swift | 39 +++++++++++++- src/commands/health.snapshot.test.ts | 55 ++++++++++++++++++++ src/commands/health.ts | 24 ++++++++- src/infra/provider-summary.ts | 7 ++- 4 files changed, 120 insertions(+), 5 deletions(-) diff --git a/apps/macos/Sources/Clawdis/HealthStore.swift b/apps/macos/Sources/Clawdis/HealthStore.swift index 262291824..cfd2ee014 100644 --- a/apps/macos/Sources/Clawdis/HealthStore.swift +++ b/apps/macos/Sources/Clawdis/HealthStore.swift @@ -5,6 +5,24 @@ import OSLog import SwiftUI struct HealthSnapshot: Codable, Sendable { + struct Telegram: Codable, Sendable { + struct Probe: Codable, Sendable { + struct Bot: Codable, Sendable { + let id: Int? + let username: String? + } + + let ok: Bool + let status: Int? + let error: String? + let elapsedMs: Double? + let bot: Bot? + } + + let configured: Bool + let probe: Probe? + } + struct Web: Codable, Sendable { struct Connect: Codable, Sendable { let ok: Bool @@ -30,9 +48,11 @@ struct HealthSnapshot: Codable, Sendable { let recent: [SessionInfo] } + let ok: Bool? let ts: Double let durationMs: Double let web: Web + let telegram: Telegram? let heartbeatSeconds: Int? let sessions: Sessions } @@ -112,12 +132,21 @@ final class HealthStore { } } + private static func isTelegramHealthy(_ snap: HealthSnapshot) -> Bool { + guard let tg = snap.telegram, tg.configured else { return false } + // If probe is missing, treat it as "configured but unknown health" (not a hard fail). + return tg.probe?.ok ?? true + } + var state: HealthState { if let error = self.lastError, !error.isEmpty { return .degraded(error) } guard let snap = self.snapshot else { return .unknown } - if !snap.web.linked { return .linkingNeeded } + if !snap.web.linked { + // WhatsApp Web linking is optional if Telegram is healthy; don't paint the whole app red. + return Self.isTelegramHealthy(snap) ? .degraded("Not linked") : .linkingNeeded + } if let connect = snap.web.connect, !connect.ok { let reason = connect.error ?? "connect failed" return .degraded(reason) @@ -129,7 +158,13 @@ final class HealthStore { if self.isRefreshing { return "Health check running…" } if let error = self.lastError { return "Health check failed: \(error)" } guard let snap = self.snapshot else { return "Health check pending" } - if !snap.web.linked { return "Not linked — run clawdis login" } + if !snap.web.linked { + if let tg = snap.telegram, tg.configured { + let tgLabel = (tg.probe?.ok ?? true) ? "Telegram ok" : "Telegram degraded" + return "\(tgLabel) · Not linked — run clawdis login" + } + return "Not linked — run clawdis login" + } let auth = snap.web.authAgeMs.map { msToAge($0) } ?? "unknown" if let connect = snap.web.connect, !connect.ok { let code = connect.status.map(String.init) ?? "?" diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index 661ea382b..cdeb5a505 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -1,3 +1,7 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + import { afterEach, describe, expect, it, vi } from "vitest"; import type { HealthSummary } from "./health.js"; @@ -99,6 +103,57 @@ describe("getHealthSnapshot", () => { expect(calls.some((c) => c.includes("/getWebhookInfo"))).toBe(true); }); + it("treats telegram.tokenFile as configured", async () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "clawdis-health-")); + const tokenFile = path.join(tmpDir, "telegram-token"); + fs.writeFileSync(tokenFile, "t-file\n", "utf-8"); + testConfig = { telegram: { tokenFile } }; + testStore = {}; + + const calls: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + calls.push(url); + if (url.includes("/getMe")) { + return { + ok: true, + status: 200, + json: async () => ({ + ok: true, + result: { id: 1, username: "bot" }, + }), + } as unknown as Response; + } + if (url.includes("/getWebhookInfo")) { + return { + ok: true, + status: 200, + json: async () => ({ + ok: true, + result: { + url: "https://example.com/h", + has_custom_certificate: false, + }, + }), + } as unknown as Response; + } + return { + ok: false, + status: 404, + json: async () => ({ ok: false, description: "nope" }), + } as unknown as Response; + }), + ); + + const snap = await getHealthSnapshot(25); + expect(snap.telegram.configured).toBe(true); + expect(snap.telegram.probe?.ok).toBe(true); + expect(calls.some((c) => c.includes("bott-file/getMe"))).toBe(true); + + fs.rmSync(tmpDir, { recursive: true, force: true }); + }); + it("returns a structured telegram probe error when getMe fails", async () => { testConfig = { telegram: { botToken: "bad-token" } }; testStore = {}; diff --git a/src/commands/health.ts b/src/commands/health.ts index e294653f8..a01401512 100644 --- a/src/commands/health.ts +++ b/src/commands/health.ts @@ -1,3 +1,5 @@ +import fs from "node:fs"; + import { loadConfig } from "../config/config.js"; import { loadSessionStore, resolveStorePath } from "../config/sessions.js"; import { type DiscordProbe, probeDiscord } from "../discord/probe.js"; @@ -53,6 +55,25 @@ export type HealthSummary = { const DEFAULT_TIMEOUT_MS = 10_000; +function loadTelegramToken(cfg: ReturnType): string { + const env = process.env.TELEGRAM_BOT_TOKEN?.trim(); + if (env) return env; + + const tokenFile = cfg.telegram?.tokenFile?.trim(); + if (tokenFile) { + try { + if (fs.existsSync(tokenFile)) { + const token = fs.readFileSync(tokenFile, "utf-8").trim(); + if (token) return token; + } + } catch { + // Ignore errors; health should be non-fatal. + } + } + + return cfg.telegram?.botToken?.trim() ?? ""; +} + export async function getHealthSnapshot( timeoutMs?: number, ): Promise { @@ -74,8 +95,7 @@ export async function getHealthSnapshot( const start = Date.now(); const cappedTimeout = Math.max(1000, timeoutMs ?? DEFAULT_TIMEOUT_MS); - const telegramToken = - process.env.TELEGRAM_BOT_TOKEN ?? cfg.telegram?.botToken ?? ""; + const telegramToken = loadTelegramToken(cfg); const telegramConfigured = telegramToken.trim().length > 0; const telegramProxy = cfg.telegram?.proxy; const telegramProbe = telegramConfigured diff --git a/src/infra/provider-summary.ts b/src/infra/provider-summary.ts index 6fa163e7a..6f84fbe1f 100644 --- a/src/infra/provider-summary.ts +++ b/src/infra/provider-summary.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import chalk from "chalk"; import { type ClawdisConfig, loadConfig } from "../config/config.js"; import { normalizeE164 } from "../utils.js"; @@ -36,8 +37,12 @@ export async function buildProviderSummary( } else { const telegramToken = process.env.TELEGRAM_BOT_TOKEN ?? effective.telegram?.botToken; + const telegramTokenFile = effective.telegram?.tokenFile?.trim(); + const telegramConfigured = + Boolean(telegramToken) || + Boolean(telegramTokenFile ? fs.existsSync(telegramTokenFile) : false); lines.push( - telegramToken + telegramConfigured ? chalk.green("Telegram: configured") : chalk.cyan("Telegram: not configured"), ); From ced271bec15502c4bd8bfaaab159b5ae9cdf8729 Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Sat, 20 Dec 2025 21:32:25 +0100 Subject: [PATCH 3/6] chore(macos): harden mktemp templates in codesign --- scripts/codesign-mac-app.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/scripts/codesign-mac-app.sh b/scripts/codesign-mac-app.sh index 1b2174558..8bb1d7eed 100755 --- a/scripts/codesign-mac-app.sh +++ b/scripts/codesign-mac-app.sh @@ -4,10 +4,10 @@ set -euo pipefail APP_BUNDLE="${1:-dist/Clawdis.app}" IDENTITY="${SIGN_IDENTITY:-}" TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}" -ENT_TMP_BASE=$(mktemp -t clawdis-entitlements-base) -ENT_TMP_APP=$(mktemp -t clawdis-entitlements-app) -ENT_TMP_APP_BASE=$(mktemp -t clawdis-entitlements-app-base) -ENT_TMP_BUN=$(mktemp -t clawdis-entitlements-bun) +ENT_TMP_BASE=$(mktemp -t clawdis-entitlements-base.XXXXXX) +ENT_TMP_APP=$(mktemp -t clawdis-entitlements-app.XXXXXX) +ENT_TMP_APP_BASE=$(mktemp -t clawdis-entitlements-app-base.XXXXXX) +ENT_TMP_BUN=$(mktemp -t clawdis-entitlements-bun.XXXXXX) if [ ! -d "$APP_BUNDLE" ]; then echo "App bundle not found: $APP_BUNDLE" >&2 From 1d8b47785c1546a40d52194cc1796bab178bb6d1 Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Sat, 20 Dec 2025 21:59:00 +0100 Subject: [PATCH 4/6] feat(macos): add current TeamID to Peekaboo allowlist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: The bridge only accepts the upstream TeamID, so packaged builds signed locally (Nix/CI) can’t use the bridge even though they are the same app. Fix: Include the running app’s TeamID (from its code signature) in the allowlist. Safety: TeamID gating remains; this just adds the app’s own TeamID to preserve permissions/automation in reproducible installs. --- .../PeekabooBridgeHostCoordinator.swift | 31 ++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/apps/macos/Sources/Clawdis/PeekabooBridgeHostCoordinator.swift b/apps/macos/Sources/Clawdis/PeekabooBridgeHostCoordinator.swift index 88c7e89bf..13edfaf20 100644 --- a/apps/macos/Sources/Clawdis/PeekabooBridgeHostCoordinator.swift +++ b/apps/macos/Sources/Clawdis/PeekabooBridgeHostCoordinator.swift @@ -1,4 +1,5 @@ import Foundation +import Security import os import PeekabooAutomationKit import PeekabooBridge @@ -32,7 +33,10 @@ final class PeekabooBridgeHostCoordinator { private func startIfNeeded() async { guard self.host == nil else { return } - let allowlistedTeamIDs: Set = ["Y5PE65HELJ"] + var allowlistedTeamIDs: Set = ["Y5PE65HELJ"] + if let teamID = Self.currentTeamID() { + allowlistedTeamIDs.insert(teamID) + } let allowlistedBundles: Set = [] let services = ClawdisPeekabooBridgeServices() @@ -55,6 +59,31 @@ final class PeekabooBridgeHostCoordinator { self.logger .info("PeekabooBridge host started at \(PeekabooBridgeConstants.clawdisSocketPath, privacy: .public)") } + + private static func currentTeamID() -> String? { + var code: SecCode? + guard SecCodeCopySelf(SecCSFlags(), &code) == errSecSuccess, + let code + else { + return nil + } + + var staticCode: SecStaticCode? + guard SecCodeCopyStaticCode(code, SecCSFlags(), &staticCode) == errSecSuccess, + let staticCode + else { + return nil + } + + var infoCF: CFDictionary? + guard SecCodeCopySigningInformation(staticCode, SecCSFlags(rawValue: kSecCSSigningInformation), &infoCF) == errSecSuccess, + let info = infoCF as? [String: Any] + else { + return nil + } + + return info[kSecCodeInfoTeamIdentifier as String] as? String + } } @MainActor From 10eced9971e3a42f8431dd98be2d4e46f69da26a Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Wed, 24 Dec 2025 18:18:48 +0100 Subject: [PATCH 5/6] fix: use telegram token file for sends and guard console EPIPE --- src/gateway/server.test.ts | 12 +++++++- src/gateway/server.ts | 56 +++++++++++++++++++++----------------- src/logging.ts | 18 ++++++++++-- src/web/auto-reply.test.ts | 36 ++++++++++++------------ 4 files changed, 77 insertions(+), 45 deletions(-) diff --git a/src/gateway/server.test.ts b/src/gateway/server.test.ts index f598b498c..a1597810f 100644 --- a/src/gateway/server.test.ts +++ b/src/gateway/server.test.ts @@ -120,6 +120,7 @@ let testCronEnabled: boolean | undefined = false; let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined; let testGatewayAuth: Record | undefined; let testHooksConfig: Record | undefined; +let testCanvasHostPort: number | undefined; const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 })); vi.mock("../config/sessions.js", async () => { const actual = await vi.importActual( @@ -205,6 +206,12 @@ vi.mock("../config/config.js", () => { if (testGatewayAuth) gateway.auth = testGatewayAuth; return Object.keys(gateway).length > 0 ? gateway : undefined; })(), + canvasHost: (() => { + const canvasHost: Record = {}; + if (typeof testCanvasHostPort === "number") + canvasHost.port = testCanvasHostPort; + return Object.keys(canvasHost).length > 0 ? canvasHost : undefined; + })(), hooks: testHooksConfig, cron: (() => { const cron: Record = {}; @@ -261,6 +268,7 @@ beforeEach(async () => { testGatewayBind = undefined; testGatewayAuth = undefined; testHooksConfig = undefined; + testCanvasHostPort = undefined; cronIsolatedRun.mockClear(); drainSystemEvents(); __resetModelCatalogCacheForTest(); @@ -1907,6 +1915,8 @@ describe("gateway server", () => { process.env.CLAWDIS_GATEWAY_TOKEN = "secret"; testTailnetIPv4.value = "100.64.0.1"; testGatewayBind = "lan"; + const canvasPort = await getFreePort(); + testCanvasHostPort = canvasPort; const port = await getFreePort(); const server = await startGatewayServer(port, { @@ -1919,7 +1929,7 @@ describe("gateway server", () => { await new Promise((resolve) => ws.once("open", resolve)); const hello = await connectOk(ws, { token: "secret" }); - expect(hello.canvasHostUrl).toBe(`http://100.64.0.1:18793`); + expect(hello.canvasHostUrl).toBe(`http://100.64.0.1:${canvasPort}`); ws.close(); await server.close(); diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 9ec348416..78f519aa5 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -287,6 +287,33 @@ const whatsappRuntimeEnv = runtimeForLogger(logWhatsApp); const telegramRuntimeEnv = runtimeForLogger(logTelegram); const discordRuntimeEnv = runtimeForLogger(logDiscord); +function loadTelegramToken( + config: ClawdisConfig, + opts: { logMissing?: boolean } = {}, +): string { + if (process.env.TELEGRAM_BOT_TOKEN) { + return process.env.TELEGRAM_BOT_TOKEN.trim(); + } + if (config.telegram?.tokenFile) { + const filePath = config.telegram.tokenFile; + if (!fs.existsSync(filePath)) { + if (opts.logMissing) { + logTelegram.warn(`telegram.tokenFile not found: ${filePath}`); + } + return ""; + } + try { + return fs.readFileSync(filePath, "utf-8").trim(); + } catch (err) { + if (opts.logMissing) { + logTelegram.warn(`telegram.tokenFile read failed: ${String(err)}`); + } + return ""; + } + } + return config.telegram?.botToken?.trim() ?? ""; +} + function resolveBonjourCliPath(): string | undefined { const envPath = process.env.CLAWDIS_CLI_PATH?.trim(); if (envPath) return envPath; @@ -1877,30 +1904,6 @@ export async function startGatewayServer( }; }; - /** - * Load telegram token with priority: env var > tokenFile > botToken. - * tokenFile supports secret managers (e.g., agenix). - */ - const loadTelegramToken = (cfg: ClawdisConfig): string => { - if (process.env.TELEGRAM_BOT_TOKEN) { - return process.env.TELEGRAM_BOT_TOKEN.trim(); - } - if (cfg.telegram?.tokenFile) { - const filePath = cfg.telegram.tokenFile; - if (!fs.existsSync(filePath)) { - logTelegram.info(`telegram tokenFile not found: ${filePath}`); - return ""; - } - try { - return fs.readFileSync(filePath, "utf-8").trim(); - } catch (err) { - logTelegram.info(`failed to read telegram tokenFile: ${String(err)}`); - return ""; - } - } - return cfg.telegram?.botToken?.trim() ?? ""; - }; - const startTelegramProvider = async () => { if (telegramTask) return; const cfg = loadConfig(); @@ -1913,7 +1916,7 @@ export async function startGatewayServer( logTelegram.info("skipping provider start (telegram.enabled=false)"); return; } - const telegramToken = loadTelegramToken(cfg); + const telegramToken = loadTelegramToken(cfg, { logMissing: true }); if (!telegramToken.trim()) { telegramRuntime = { ...telegramRuntime, @@ -5786,9 +5789,12 @@ export async function startGatewayServer( const provider = (params.provider ?? "whatsapp").toLowerCase(); try { if (provider === "telegram") { + const cfg = loadConfig(); + const token = loadTelegramToken(cfg); const result = await sendMessageTelegram(to, message, { mediaUrl: params.mediaUrl, verbose: isVerbose(), + token: token || undefined, }); const payload = { runId: idem, diff --git a/src/logging.ts b/src/logging.ts index e984c840b..a4b4362dc 100644 --- a/src/logging.ts +++ b/src/logging.ts @@ -268,6 +268,10 @@ function shouldSuppressConsoleMessage(message: string): boolean { ); } +function isEpipeError(err: unknown): boolean { + return Boolean((err as { code?: string })?.code === "EPIPE"); +} + /** * Route console.* calls through pino while still emitting to stdout/stderr. * This keeps user-facing output unchanged but guarantees every console call is captured in log files. @@ -321,9 +325,19 @@ export function enableConsoleCapture(): void { level === "error" || level === "fatal" || level === "warn" ? process.stderr : process.stderr; // in RPC/JSON mode, keep stdout clean - target.write(`${formatted}\n`); + try { + target.write(`${formatted}\n`); + } catch (err) { + if (isEpipeError(err)) return; + throw err; + } } else { - orig.apply(console, args as []); + try { + orig.apply(console, args as []); + } catch (err) { + if (isEpipeError(err)) return; + throw err; + } } }; diff --git a/src/web/auto-reply.test.ts b/src/web/auto-reply.test.ts index 8edf2dcf7..271ccceec 100644 --- a/src/web/auto-reply.test.ts +++ b/src/web/auto-reply.test.ts @@ -318,23 +318,25 @@ describe("web auto-reply", () => { let capturedOnMessage: | ((msg: import("./inbound.js").WebInboundMessage) => Promise) | undefined; - const listenerFactory = vi.fn(async (opts: { - onMessage: ( - msg: import("./inbound.js").WebInboundMessage, - ) => Promise; - }) => { - capturedOnMessage = opts.onMessage; - let resolveClose: (reason: unknown) => void = () => {}; - const onClose = new Promise((res) => { - resolveClose = res; - closeResolvers.push(res); - }); - return { - close: vi.fn(), - onClose, - signalClose: (reason?: unknown) => resolveClose(reason), - }; - }); + const listenerFactory = vi.fn( + async (opts: { + onMessage: ( + msg: import("./inbound.js").WebInboundMessage, + ) => Promise; + }) => { + capturedOnMessage = opts.onMessage; + let resolveClose: (reason: unknown) => void = () => {}; + const onClose = new Promise((res) => { + resolveClose = res; + closeResolvers.push(res); + }); + return { + close: vi.fn(), + onClose, + signalClose: (reason?: unknown) => resolveClose(reason), + }; + }, + ); const runtime = { log: vi.fn(), error: vi.fn(), From 0f7029583c6899676111bd4953bf08d06933dcae Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Sun, 28 Dec 2025 16:39:40 +0100 Subject: [PATCH 6/6] macOS: load device models from bundle resources --- .../Sources/Clawdis/DeviceModelCatalog.swift | 35 +++++++++++++++++-- 1 file changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/macos/Sources/Clawdis/DeviceModelCatalog.swift b/apps/macos/Sources/Clawdis/DeviceModelCatalog.swift index 2e37088d3..3bda2e487 100644 --- a/apps/macos/Sources/Clawdis/DeviceModelCatalog.swift +++ b/apps/macos/Sources/Clawdis/DeviceModelCatalog.swift @@ -104,8 +104,8 @@ enum DeviceModelCatalog { } private static func loadMapping(resourceName: String) -> [String: String] { - guard let url = Bundle.module.url( - forResource: resourceName, + guard let url = self.resourceURL( + resourceName: resourceName, withExtension: "json", subdirectory: "DeviceModels") else { @@ -121,6 +121,37 @@ enum DeviceModelCatalog { } } + private static func resourceURL( + resourceName: String, + withExtension ext: String, + subdirectory: String + ) -> URL? { + let bundledSubdir = "Clawdis_Clawdis.bundle/\(subdirectory)" + let mainBundle = Bundle.main + + if let url = mainBundle.url(forResource: resourceName, withExtension: ext, subdirectory: bundledSubdir) + ?? mainBundle.url(forResource: resourceName, withExtension: ext, subdirectory: subdirectory) + { + return url + } + + let fallbackBases = [ + mainBundle.resourceURL, + mainBundle.bundleURL.appendingPathComponent("Contents/Resources"), + mainBundle.bundleURL.deletingLastPathComponent(), + ].compactMap { $0 } + + let fileName = "\(resourceName).\(ext)" + for base in fallbackBases { + let bundled = base.appendingPathComponent(bundledSubdir).appendingPathComponent(fileName) + if FileManager.default.fileExists(atPath: bundled.path) { return bundled } + let loose = base.appendingPathComponent(subdirectory).appendingPathComponent(fileName) + if FileManager.default.fileExists(atPath: loose.path) { return loose } + } + + return nil + } + private enum NameValue: Decodable { case string(String) case stringArray([String])