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 {