From b7363f7c18e5a5572d720114e9dd0ed0c4ccdcc2 Mon Sep 17 00:00:00 2001 From: Josh Palmer Date: Sat, 20 Dec 2025 21:32:06 +0100 Subject: [PATCH] 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 {