Merge pull request #40 from joshp123/upstream-preview-nix-2025-12-20
Nix mode support + macOS Info.plist template
This commit is contained in:
@@ -51,6 +51,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"),
|
||||
|
||||
@@ -32,6 +32,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()
|
||||
|
||||
@@ -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 =
|
||||
@@ -59,9 +60,13 @@ struct ConfigSettings: View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
self.header
|
||||
self.agentSection
|
||||
.disabled(self.isNixMode)
|
||||
self.heartbeatSection
|
||||
.disabled(self.isNixMode)
|
||||
self.talkSection
|
||||
.disabled(self.isNixMode)
|
||||
self.browserSection
|
||||
.disabled(self.isNixMode)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
@@ -74,7 +79,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)
|
||||
}
|
||||
@@ -413,7 +420,7 @@ struct ConfigSettings: View {
|
||||
}
|
||||
|
||||
private func autosaveConfig() {
|
||||
guard self.allowAutosave else { return }
|
||||
guard self.allowAutosave, !self.isNixMode else { return }
|
||||
Task { await self.saveConfig() }
|
||||
}
|
||||
|
||||
|
||||
@@ -163,7 +163,6 @@ enum DeviceModelCatalog {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private enum NameValue: Decodable {
|
||||
case string(String)
|
||||
case stringArray([String])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,6 +4,24 @@ import Observation
|
||||
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
|
||||
@@ -29,9 +47,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
|
||||
}
|
||||
@@ -111,12 +131,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)
|
||||
@@ -128,7 +157,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) ?? "?"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<String> = ["Y5PE65HELJ"]
|
||||
var allowlistedTeamIDs: Set<String> = ["Y5PE65HELJ"]
|
||||
if let teamID = Self.currentTeamID() {
|
||||
allowlistedTeamIDs.insert(teamID)
|
||||
}
|
||||
let allowlistedBundles: Set<String> = []
|
||||
|
||||
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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
73
apps/macos/Sources/Clawdis/Resources/Info.plist
Normal file
73
apps/macos/Sources/Clawdis/Resources/Info.plist
Normal file
@@ -0,0 +1,73 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>en</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Clawdis</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.steipete.clawdis</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Clawdis</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>0.0.0</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Clawdis</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.steipete.clawdis.deeplink</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>clawdis</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>15.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
|
||||
<key>ClawdisBuildTimestamp</key>
|
||||
<string></string>
|
||||
<key>ClawdisGitCommit</key>
|
||||
<string></string>
|
||||
|
||||
<key>NSUserNotificationUsageDescription</key>
|
||||
<string>Clawdis needs notification permission to show alerts for agent actions.</string>
|
||||
<key>NSScreenCaptureDescription</key>
|
||||
<string>Clawdis captures the screen when the agent needs screenshots for context.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Clawdis can capture photos or short video clips when requested by the agent.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Clawdis needs the mic for Voice Wake tests and agent audio capture.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Clawdis uses speech recognition to detect your Voice Wake trigger phrase.</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Clawdis needs Automation (AppleScript) permission to drive Terminal and other apps for agent actions.</string>
|
||||
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>100.100.100.100</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -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,60 @@ 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(state: self.state)
|
||||
.tabItem { Label("Debug", systemImage: "ant") }
|
||||
.tag(SettingsTab.debug)
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
if self.isNixMode {
|
||||
self.nixManagedBanner
|
||||
}
|
||||
TabView(selection: self.$selectedTab) {
|
||||
GeneralSettings(state: self.state)
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
.tag(SettingsTab.general)
|
||||
|
||||
AboutSettings(updater: self.updater)
|
||||
.tabItem { Label("About", systemImage: "info.circle") }
|
||||
.tag(SettingsTab.about)
|
||||
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(state: self.state)
|
||||
.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 +104,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
|
||||
|
||||
@@ -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)
|
||||
|
||||
49
docs/nix.md
Normal file
49
docs/nix.md
Normal file
@@ -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).
|
||||
@@ -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
|
||||
|
||||
@@ -54,81 +54,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" <<PLIST
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>${BUNDLE_ID}</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${APP_VERSION}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${APP_BUILD}</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Clawdis</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>Clawdis</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>Clawdis</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>15.0</string>
|
||||
<key>LSUIElement</key>
|
||||
<true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.steipete.clawdis.deeplink</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>clawdis</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>ClawdisBuildTimestamp</key>
|
||||
<string>${BUILD_TS}</string>
|
||||
<key>ClawdisGitCommit</key>
|
||||
<string>${GIT_COMMIT}</string>
|
||||
<key>SUFeedURL</key>
|
||||
<string>${SPARKLE_FEED_URL}</string>
|
||||
<key>SUPublicEDKey</key>
|
||||
<string>${SPARKLE_PUBLIC_ED_KEY}</string>
|
||||
<key>SUEnableAutomaticChecks</key>
|
||||
<${AUTO_CHECKS}/>
|
||||
<key>NSUserNotificationUsageDescription</key>
|
||||
<string>Clawdis needs notification permission to show alerts for agent actions.</string>
|
||||
<key>NSScreenCaptureDescription</key>
|
||||
<string>Clawdis captures the screen when the agent needs screenshots for context.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Clawdis can capture photos or short video clips when requested by the agent.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Clawdis needs the mic for Voice Wake tests and agent audio capture.</string>
|
||||
<key>NSSpeechRecognitionUsageDescription</key>
|
||||
<string>Clawdis uses speech recognition to detect your Voice Wake trigger phrase.</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>Clawdis needs Automation (AppleScript) permission to drive Terminal and other apps for agent actions.</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoadsInWebContent</key>
|
||||
<true/>
|
||||
<key>NSExceptionDomains</key>
|
||||
<dict>
|
||||
<key>100.100.100.100</key>
|
||||
<dict>
|
||||
<key>NSExceptionAllowsInsecureHTTPLoads</key>
|
||||
<true/>
|
||||
<key>NSIncludesSubdomains</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
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"
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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<typeof loadConfig>): 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<HealthSummary> {
|
||||
@@ -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
|
||||
|
||||
@@ -16,6 +16,37 @@ async function withTempHome<T>(fn: (home: string) => Promise<T>): Promise<T> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to test env var overrides. Saves/restores env vars and resets modules.
|
||||
*/
|
||||
async function withEnvOverride<T>(
|
||||
overrides: Record<string, string | undefined>,
|
||||
fn: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const saved: Record<string, string | undefined> = {};
|
||||
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;
|
||||
|
||||
@@ -175,6 +206,155 @@ 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("talk api key fallback", () => {
|
||||
let previousEnv: string | undefined;
|
||||
|
||||
|
||||
@@ -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<string | number>;
|
||||
mediaMaxMb?: number;
|
||||
@@ -415,12 +427,22 @@ export type ClawdisConfig = {
|
||||
skills?: Record<string, SkillConfig>;
|
||||
};
|
||||
|
||||
// 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"),
|
||||
@@ -760,6 +782,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(),
|
||||
|
||||
@@ -120,6 +120,7 @@ let testCronEnabled: boolean | undefined = false;
|
||||
let testGatewayBind: "auto" | "lan" | "tailnet" | "loopback" | undefined;
|
||||
let testGatewayAuth: Record<string, unknown> | undefined;
|
||||
let testHooksConfig: Record<string, unknown> | undefined;
|
||||
let testCanvasHostPort: number | undefined;
|
||||
const sessionStoreSaveDelayMs = vi.hoisted(() => ({ value: 0 }));
|
||||
vi.mock("../config/sessions.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("../config/sessions.js")>(
|
||||
@@ -189,6 +190,7 @@ vi.mock("../config/config.js", () => {
|
||||
|
||||
return {
|
||||
CONFIG_PATH_CLAWDIS: resolveConfigPath(),
|
||||
isNixMode: false,
|
||||
loadConfig: () => ({
|
||||
agent: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
@@ -204,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<string, unknown> = {};
|
||||
if (typeof testCanvasHostPort === "number")
|
||||
canvasHost.port = testCanvasHostPort;
|
||||
return Object.keys(canvasHost).length > 0 ? canvasHost : undefined;
|
||||
})(),
|
||||
hooks: testHooksConfig,
|
||||
cron: (() => {
|
||||
const cron: Record<string, unknown> = {};
|
||||
@@ -260,6 +268,7 @@ beforeEach(async () => {
|
||||
testGatewayBind = undefined;
|
||||
testGatewayAuth = undefined;
|
||||
testHooksConfig = undefined;
|
||||
testCanvasHostPort = undefined;
|
||||
cronIsolatedRun.mockClear();
|
||||
drainSystemEvents();
|
||||
__resetModelCatalogCacheForTest();
|
||||
@@ -1914,6 +1923,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, {
|
||||
@@ -1926,7 +1937,7 @@ describe("gateway server", () => {
|
||||
await new Promise<void>((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();
|
||||
|
||||
@@ -46,6 +46,7 @@ import { getStatusSummary } from "../commands/status.js";
|
||||
import {
|
||||
type ClawdisConfig,
|
||||
CONFIG_PATH_CLAWDIS,
|
||||
isNixMode,
|
||||
loadConfig,
|
||||
parseConfigJson5,
|
||||
readConfigFileSnapshot,
|
||||
@@ -286,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;
|
||||
@@ -1904,8 +1932,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, { logMissing: true });
|
||||
if (!telegramToken.trim()) {
|
||||
telegramRuntime = {
|
||||
...telegramRuntime,
|
||||
@@ -5856,9 +5883,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,
|
||||
@@ -6184,6 +6214,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<void>) | null = null;
|
||||
if (tailscaleMode !== "off") {
|
||||
try {
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user