Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e42399b9c | ||
|
|
bc44453d36 |
No files matched your search
+1
-8
@@ -8,24 +8,17 @@ Docs: https://docs.clawd.bot
|
|||||||
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
- Highlight: Lobster optional plugin tool for typed workflows + approval gates. https://docs.clawd.bot/tools/lobster
|
||||||
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
- Memory: prevent CLI hangs by deferring vector probes, adding sqlite-vec/embedding timeouts, and showing sync progress early.
|
||||||
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
- Docs: add troubleshooting entry for gateway.mode blocking gateway start. https://docs.clawd.bot/gateway/troubleshooting
|
||||||
- Docs: add /model allowlist troubleshooting note. (#1405)
|
|
||||||
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
- Docs: add per-message Gmail search example for gog. (#1220) Thanks @mbelinky.
|
||||||
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
- Onboarding: remove the run setup-token auth option (paste setup-token or reuse CLI creds instead).
|
||||||
- Signal: add typing indicators and DM read receipts via signal-cli.
|
- Signal: add typing indicators and DM read receipts via signal-cli.
|
||||||
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
- MSTeams: add file uploads, adaptive cards, and attachment handling improvements. (#1410) Thanks @Evizero.
|
||||||
|
|
||||||
### Breaking
|
|
||||||
- **BREAKING:** Envelope and system event timestamps now default to host-local time (was UTC) so agents don’t have to constantly convert.
|
|
||||||
|
|
||||||
### Fixes
|
### Fixes
|
||||||
- Config: avoid stack traces for invalid configs and log the config path.
|
- Config: avoid stack traces for invalid configs and log the config path.
|
||||||
- Doctor: avoid recreating WhatsApp config when only legacy routing keys remain. (#900)
|
|
||||||
- Doctor: warn when gateway.mode is unset with configure/config guidance.
|
- Doctor: warn when gateway.mode is unset with configure/config guidance.
|
||||||
- OpenCode Zen: route models to the Zen API shape per family so proxy endpoints are used. (#1416)
|
|
||||||
- Browser: suppress Chrome restore prompts for managed profiles. (#1419) Thanks @jamesgroat.
|
|
||||||
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
|
- macOS: include Textual syntax highlighting resources in packaged app to prevent chat crashes. (#1362)
|
||||||
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
|
- Cron: cap reminder context history to 10 messages and honor `contextMessages`. (#1103) Thanks @mkbehr.
|
||||||
- Exec approvals: treat main as the default agent + migrate legacy default allowlists. (#1417) Thanks @czekaj.
|
- Cron: clarify reminder systemEvent wording guidance. (#1204) Thanks @cpojer.
|
||||||
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
|
- UI: refresh debug panel on route-driven tab changes. (#1373) Thanks @yazinsai.
|
||||||
|
|
||||||
## 2026.1.21
|
## 2026.1.21
|
||||||
|
|||||||
@@ -149,7 +149,6 @@ struct ExecApprovalsResolvedDefaults {
|
|||||||
|
|
||||||
enum ExecApprovalsStore {
|
enum ExecApprovalsStore {
|
||||||
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
|
private static let logger = Logger(subsystem: "com.clawdbot", category: "exec-approvals")
|
||||||
private static let defaultAgentId = "main"
|
|
||||||
private static let defaultSecurity: ExecSecurity = .deny
|
private static let defaultSecurity: ExecSecurity = .deny
|
||||||
private static let defaultAsk: ExecAsk = .onMiss
|
private static let defaultAsk: ExecAsk = .onMiss
|
||||||
private static let defaultAskFallback: ExecSecurity = .deny
|
private static let defaultAskFallback: ExecSecurity = .deny
|
||||||
@@ -166,22 +165,13 @@ enum ExecApprovalsStore {
|
|||||||
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
static func normalizeIncoming(_ file: ExecApprovalsFile) -> ExecApprovalsFile {
|
||||||
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let socketPath = file.socket?.path?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let token = file.socket?.token?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
var agents = file.agents ?? [:]
|
|
||||||
if let legacyDefault = agents["default"] {
|
|
||||||
if let main = agents[self.defaultAgentId] {
|
|
||||||
agents[self.defaultAgentId] = self.mergeAgents(current: main, legacy: legacyDefault)
|
|
||||||
} else {
|
|
||||||
agents[self.defaultAgentId] = legacyDefault
|
|
||||||
}
|
|
||||||
agents.removeValue(forKey: "default")
|
|
||||||
}
|
|
||||||
return ExecApprovalsFile(
|
return ExecApprovalsFile(
|
||||||
version: 1,
|
version: 1,
|
||||||
socket: ExecApprovalsSocketConfig(
|
socket: ExecApprovalsSocketConfig(
|
||||||
path: socketPath.isEmpty ? nil : socketPath,
|
path: socketPath.isEmpty ? nil : socketPath,
|
||||||
token: token.isEmpty ? nil : token),
|
token: token.isEmpty ? nil : token),
|
||||||
defaults: file.defaults,
|
defaults: file.defaults,
|
||||||
agents: agents)
|
agents: file.agents)
|
||||||
}
|
}
|
||||||
|
|
||||||
static func readSnapshot() -> ExecApprovalsSnapshot {
|
static func readSnapshot() -> ExecApprovalsSnapshot {
|
||||||
@@ -282,7 +272,9 @@ enum ExecApprovalsStore {
|
|||||||
ask: defaults.ask ?? self.defaultAsk,
|
ask: defaults.ask ?? self.defaultAsk,
|
||||||
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
askFallback: defaults.askFallback ?? self.defaultAskFallback,
|
||||||
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
autoAllowSkills: defaults.autoAllowSkills ?? self.defaultAutoAllowSkills)
|
||||||
let key = self.agentKey(agentId)
|
let key = (agentId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == false)
|
||||||
|
? agentId!.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
: "default"
|
||||||
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
let agentEntry = file.agents?[key] ?? ExecApprovalsAgent()
|
||||||
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
|
let wildcardEntry = file.agents?["*"] ?? ExecApprovalsAgent()
|
||||||
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
let resolvedAgent = ExecApprovalsResolvedDefaults(
|
||||||
@@ -465,36 +457,7 @@ enum ExecApprovalsStore {
|
|||||||
|
|
||||||
private static func agentKey(_ agentId: String?) -> String {
|
private static func agentKey(_ agentId: String?) -> String {
|
||||||
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
let trimmed = agentId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||||
return trimmed.isEmpty ? self.defaultAgentId : trimmed
|
return trimmed.isEmpty ? "default" : trimmed
|
||||||
}
|
|
||||||
|
|
||||||
private static func normalizedPattern(_ pattern: String?) -> String? {
|
|
||||||
let trimmed = pattern?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
|
||||||
return trimmed.isEmpty ? nil : trimmed.lowercased()
|
|
||||||
}
|
|
||||||
|
|
||||||
private static func mergeAgents(
|
|
||||||
current: ExecApprovalsAgent,
|
|
||||||
legacy: ExecApprovalsAgent
|
|
||||||
) -> ExecApprovalsAgent {
|
|
||||||
var seen = Set<String>()
|
|
||||||
var allowlist: [ExecAllowlistEntry] = []
|
|
||||||
func append(_ entry: ExecAllowlistEntry) {
|
|
||||||
guard let key = self.normalizedPattern(entry.pattern), !seen.contains(key) else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
seen.insert(key)
|
|
||||||
allowlist.append(entry)
|
|
||||||
}
|
|
||||||
for entry in current.allowlist ?? [] { append(entry) }
|
|
||||||
for entry in legacy.allowlist ?? [] { append(entry) }
|
|
||||||
|
|
||||||
return ExecApprovalsAgent(
|
|
||||||
security: current.security ?? legacy.security,
|
|
||||||
ask: current.ask ?? legacy.ask,
|
|
||||||
askFallback: current.askFallback ?? legacy.askFallback,
|
|
||||||
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
|
|
||||||
allowlist: allowlist.isEmpty ? nil : allowlist)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,15 @@ import AppKit
|
|||||||
import ClawdbotDiscovery
|
import ClawdbotDiscovery
|
||||||
import ClawdbotIPC
|
import ClawdbotIPC
|
||||||
import ClawdbotKit
|
import ClawdbotKit
|
||||||
|
import CoreLocation
|
||||||
import Observation
|
import Observation
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct GeneralSettings: View {
|
struct GeneralSettings: View {
|
||||||
@Bindable var state: AppState
|
@Bindable var state: AppState
|
||||||
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
@AppStorage(cameraEnabledKey) private var cameraEnabled: Bool = false
|
||||||
|
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||||
|
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
||||||
private let healthStore = HealthStore.shared
|
private let healthStore = HealthStore.shared
|
||||||
private let gatewayManager = GatewayProcessManager.shared
|
private let gatewayManager = GatewayProcessManager.shared
|
||||||
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
@State private var gatewayDiscovery = GatewayDiscoveryModel(
|
||||||
@@ -17,6 +20,7 @@ struct GeneralSettings: View {
|
|||||||
@State private var showRemoteAdvanced = false
|
@State private var showRemoteAdvanced = false
|
||||||
private let isPreview = ProcessInfo.processInfo.isPreview
|
private let isPreview = ProcessInfo.processInfo.isPreview
|
||||||
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
private var isNixMode: Bool { ProcessInfo.processInfo.isNixMode }
|
||||||
|
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView(.vertical) {
|
ScrollView(.vertical) {
|
||||||
@@ -56,6 +60,27 @@ struct GeneralSettings: View {
|
|||||||
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
subtitle: "Allow the agent to capture a photo or short video via the built-in camera.",
|
||||||
binding: self.$cameraEnabled)
|
binding: self.$cameraEnabled)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Location Access")
|
||||||
|
.font(.body)
|
||||||
|
|
||||||
|
Picker("", selection: self.$locationModeRaw) {
|
||||||
|
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
|
||||||
|
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
||||||
|
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
||||||
|
}
|
||||||
|
.labelsHidden()
|
||||||
|
.pickerStyle(.menu)
|
||||||
|
|
||||||
|
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
||||||
|
.disabled(self.locationMode == .off)
|
||||||
|
|
||||||
|
Text("Always may require System Settings to approve background location.")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.tertiary)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
SettingsToggleRow(
|
SettingsToggleRow(
|
||||||
title: "Enable Peekaboo Bridge",
|
title: "Enable Peekaboo Bridge",
|
||||||
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
subtitle: "Allow signed tools (e.g. `peekaboo`) to drive UI automation via PeekabooBridge.",
|
||||||
@@ -81,12 +106,27 @@ struct GeneralSettings: View {
|
|||||||
.onAppear {
|
.onAppear {
|
||||||
guard !self.isPreview else { return }
|
guard !self.isPreview else { return }
|
||||||
self.refreshGatewayStatus()
|
self.refreshGatewayStatus()
|
||||||
|
self.lastLocationModeRaw = self.locationModeRaw
|
||||||
}
|
}
|
||||||
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
.onChange(of: self.state.canvasEnabled) { _, enabled in
|
||||||
if !enabled {
|
if !enabled {
|
||||||
CanvasManager.shared.hideAll()
|
CanvasManager.shared.hideAll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.onChange(of: self.locationModeRaw) { _, newValue in
|
||||||
|
let previous = self.lastLocationModeRaw
|
||||||
|
self.lastLocationModeRaw = newValue
|
||||||
|
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
|
||||||
|
Task {
|
||||||
|
let granted = await self.requestLocationAuthorization(mode: mode)
|
||||||
|
if !granted {
|
||||||
|
await MainActor.run {
|
||||||
|
self.locationModeRaw = previous
|
||||||
|
self.lastLocationModeRaw = previous
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var activeBinding: Binding<Bool> {
|
private var activeBinding: Binding<Bool> {
|
||||||
@@ -95,6 +135,26 @@ struct GeneralSettings: View {
|
|||||||
set: { self.state.isPaused = !$0 })
|
set: { self.state.isPaused = !$0 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var locationMode: ClawdbotLocationMode {
|
||||||
|
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
|
||||||
|
}
|
||||||
|
|
||||||
|
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
|
||||||
|
guard mode != .off else { return true }
|
||||||
|
guard CLLocationManager.locationServicesEnabled() else {
|
||||||
|
await MainActor.run { LocationPermissionHelper.openSettings() }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let status = CLLocationManager().authorizationStatus
|
||||||
|
let requireAlways = mode == .always
|
||||||
|
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
|
||||||
|
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
|
||||||
|
}
|
||||||
|
|
||||||
private var connectionSection: some View {
|
private var connectionSection: some View {
|
||||||
VStack(alignment: .leading, spacing: 10) {
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
Text("Clawdbot runs")
|
Text("Clawdbot runs")
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
import ClawdbotIPC
|
import ClawdbotIPC
|
||||||
import ClawdbotKit
|
|
||||||
import CoreLocation
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
|
|
||||||
struct PermissionsSettings: View {
|
struct PermissionsSettings: View {
|
||||||
@@ -19,8 +17,6 @@ struct PermissionsSettings: View {
|
|||||||
.padding(.horizontal, 2)
|
.padding(.horizontal, 2)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
|
|
||||||
LocationAccessSettings()
|
|
||||||
|
|
||||||
Button("Restart onboarding") { self.showOnboarding() }
|
Button("Restart onboarding") { self.showOnboarding() }
|
||||||
.buttonStyle(.bordered)
|
.buttonStyle(.bordered)
|
||||||
Spacer()
|
Spacer()
|
||||||
@@ -30,72 +26,6 @@ struct PermissionsSettings: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct LocationAccessSettings: View {
|
|
||||||
@AppStorage(locationModeKey) private var locationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
|
||||||
@AppStorage(locationPreciseKey) private var locationPreciseEnabled: Bool = true
|
|
||||||
@State private var lastLocationModeRaw: String = ClawdbotLocationMode.off.rawValue
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack(alignment: .leading, spacing: 6) {
|
|
||||||
Text("Location Access")
|
|
||||||
.font(.body)
|
|
||||||
|
|
||||||
Picker("", selection: self.$locationModeRaw) {
|
|
||||||
Text("Off").tag(ClawdbotLocationMode.off.rawValue)
|
|
||||||
Text("While Using").tag(ClawdbotLocationMode.whileUsing.rawValue)
|
|
||||||
Text("Always").tag(ClawdbotLocationMode.always.rawValue)
|
|
||||||
}
|
|
||||||
.labelsHidden()
|
|
||||||
.pickerStyle(.menu)
|
|
||||||
|
|
||||||
Toggle("Precise Location", isOn: self.$locationPreciseEnabled)
|
|
||||||
.disabled(self.locationMode == .off)
|
|
||||||
|
|
||||||
Text("Always may require System Settings to approve background location.")
|
|
||||||
.font(.footnote)
|
|
||||||
.foregroundStyle(.tertiary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
}
|
|
||||||
.onAppear {
|
|
||||||
self.lastLocationModeRaw = self.locationModeRaw
|
|
||||||
}
|
|
||||||
.onChange(of: self.locationModeRaw) { _, newValue in
|
|
||||||
let previous = self.lastLocationModeRaw
|
|
||||||
self.lastLocationModeRaw = newValue
|
|
||||||
guard let mode = ClawdbotLocationMode(rawValue: newValue) else { return }
|
|
||||||
Task {
|
|
||||||
let granted = await self.requestLocationAuthorization(mode: mode)
|
|
||||||
if !granted {
|
|
||||||
await MainActor.run {
|
|
||||||
self.locationModeRaw = previous
|
|
||||||
self.lastLocationModeRaw = previous
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var locationMode: ClawdbotLocationMode {
|
|
||||||
ClawdbotLocationMode(rawValue: self.locationModeRaw) ?? .off
|
|
||||||
}
|
|
||||||
|
|
||||||
private func requestLocationAuthorization(mode: ClawdbotLocationMode) async -> Bool {
|
|
||||||
guard mode != .off else { return true }
|
|
||||||
guard CLLocationManager.locationServicesEnabled() else {
|
|
||||||
await MainActor.run { LocationPermissionHelper.openSettings() }
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
let status = CLLocationManager().authorizationStatus
|
|
||||||
let requireAlways = mode == .always
|
|
||||||
if PermissionManager.isLocationAuthorized(status: status, requireAlways: requireAlways) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
let updated = await LocationPermissionRequester.shared.request(always: requireAlways)
|
|
||||||
return PermissionManager.isLocationAuthorized(status: updated, requireAlways: requireAlways)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
struct PermissionStatusList: View {
|
struct PermissionStatusList: View {
|
||||||
let status: [Capability: Bool]
|
let status: [Capability: Bool]
|
||||||
let refresh: () async -> Void
|
let refresh: () async -> Void
|
||||||
|
|||||||
+5
-7
@@ -69,13 +69,11 @@ High-level:
|
|||||||
|
|
||||||
1. Requires a clean worktree (no uncommitted changes).
|
1. Requires a clean worktree (no uncommitted changes).
|
||||||
2. Switches to the selected channel (tag or branch).
|
2. Switches to the selected channel (tag or branch).
|
||||||
3. Fetches upstream (dev only).
|
3. Fetches and rebases against `@{upstream}` (dev only).
|
||||||
4. Dev only: preflight lint + TypeScript build in a temp worktree; if the tip fails, walks back up to 10 commits to find the newest clean build.
|
4. Installs deps (pnpm preferred; npm fallback).
|
||||||
5. Rebases onto the selected commit (dev only).
|
5. Builds + builds the Control UI.
|
||||||
6. Installs deps (pnpm preferred; npm fallback).
|
6. Runs `clawdbot doctor` as the final “safe update” check.
|
||||||
7. Builds + builds the Control UI.
|
7. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
|
||||||
8. Runs `clawdbot doctor` as the final “safe update” check.
|
|
||||||
9. Syncs plugins to the active channel (dev uses bundled extensions; stable/beta uses npm) and updates npm-installed plugins.
|
|
||||||
|
|
||||||
## `--update` shorthand
|
## `--update` shorthand
|
||||||
|
|
||||||
|
|||||||
@@ -9,15 +9,15 @@ read_when:
|
|||||||
|
|
||||||
Clawdbot standardizes timestamps so the model sees a **single reference time**.
|
Clawdbot standardizes timestamps so the model sees a **single reference time**.
|
||||||
|
|
||||||
## Message envelopes (local by default)
|
## Message envelopes (UTC by default)
|
||||||
|
|
||||||
Inbound messages are wrapped in an envelope like:
|
Inbound messages are wrapped in an envelope like:
|
||||||
|
|
||||||
```
|
```
|
||||||
[Provider ... 2026-01-05 16:26 PST] message text
|
[Provider ... 2026-01-05T21:26Z] message text
|
||||||
```
|
```
|
||||||
|
|
||||||
The timestamp in the envelope is **host-local by default**, with minutes precision.
|
The timestamp in the envelope is **UTC by default**, with minutes precision.
|
||||||
|
|
||||||
You can override this with:
|
You can override this with:
|
||||||
|
|
||||||
@@ -25,7 +25,7 @@ You can override this with:
|
|||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone
|
envelopeTimezone: "user", // "utc" | "local" | "user" | IANA timezone
|
||||||
envelopeTimestamp: "on", // "on" | "off"
|
envelopeTimestamp: "on", // "on" | "off"
|
||||||
envelopeElapsed: "on" // "on" | "off"
|
envelopeElapsed: "on" // "on" | "off"
|
||||||
}
|
}
|
||||||
@@ -33,7 +33,6 @@ You can override this with:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `envelopeTimezone: "utc"` uses UTC.
|
|
||||||
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||||
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
|
- Use an explicit IANA timezone (e.g., `"Europe/Vienna"`) for a fixed offset.
|
||||||
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
- `envelopeTimestamp: "off"` removes absolute timestamps from envelope headers.
|
||||||
@@ -41,10 +40,10 @@ You can override this with:
|
|||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
**Local (default):**
|
**UTC (default):**
|
||||||
|
|
||||||
```
|
```
|
||||||
[Signal Alice +1555 2026-01-18 00:19 PST] hello
|
[Signal Alice +1555 2026-01-18T05:19Z] hello
|
||||||
```
|
```
|
||||||
|
|
||||||
**Fixed timezone:**
|
**Fixed timezone:**
|
||||||
|
|||||||
+11
-13
@@ -7,18 +7,18 @@ read_when:
|
|||||||
|
|
||||||
# Date & Time
|
# Date & Time
|
||||||
|
|
||||||
Clawdbot defaults to **host-local time for transport timestamps** and **user-local time only in the system prompt**.
|
Clawdbot defaults to **UTC for transport timestamps** and **user-local time only in the system prompt**.
|
||||||
Provider timestamps are preserved so tools keep their native semantics.
|
Provider timestamps are preserved so tools keep their native semantics.
|
||||||
|
|
||||||
## Message envelopes (local by default)
|
## Message envelopes (UTC by default)
|
||||||
|
|
||||||
Inbound messages are wrapped with a timestamp (minute precision):
|
Inbound messages are wrapped with a UTC timestamp (minute precision):
|
||||||
|
|
||||||
```
|
```
|
||||||
[Provider ... 2026-01-05 16:26 PST] message text
|
[Provider ... 2026-01-05T21:26Z] message text
|
||||||
```
|
```
|
||||||
|
|
||||||
This envelope timestamp is **host-local by default**, regardless of the provider timezone.
|
This envelope timestamp is **UTC by default**, regardless of the host timezone.
|
||||||
|
|
||||||
You can override this behavior:
|
You can override this behavior:
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ You can override this behavior:
|
|||||||
{
|
{
|
||||||
agents: {
|
agents: {
|
||||||
defaults: {
|
defaults: {
|
||||||
envelopeTimezone: "local", // "utc" | "local" | "user" | IANA timezone
|
envelopeTimezone: "utc", // "utc" | "local" | "user" | IANA timezone
|
||||||
envelopeTimestamp: "on", // "on" | "off"
|
envelopeTimestamp: "on", // "on" | "off"
|
||||||
envelopeElapsed: "on" // "on" | "off"
|
envelopeElapsed: "on" // "on" | "off"
|
||||||
}
|
}
|
||||||
@@ -34,7 +34,6 @@ You can override this behavior:
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `envelopeTimezone: "utc"` uses UTC.
|
|
||||||
- `envelopeTimezone: "local"` uses the host timezone.
|
- `envelopeTimezone: "local"` uses the host timezone.
|
||||||
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
- `envelopeTimezone: "user"` uses `agents.defaults.userTimezone` (falls back to host timezone).
|
||||||
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
|
- Use an explicit IANA timezone (e.g., `"America/Chicago"`) for a fixed zone.
|
||||||
@@ -43,10 +42,10 @@ You can override this behavior:
|
|||||||
|
|
||||||
### Examples
|
### Examples
|
||||||
|
|
||||||
**Local (default):**
|
**UTC (default):**
|
||||||
|
|
||||||
```
|
```
|
||||||
[WhatsApp +1555 2026-01-18 00:19 PST] hello
|
[WhatsApp +1555 2026-01-18T05:19Z] hello
|
||||||
```
|
```
|
||||||
|
|
||||||
**User timezone:**
|
**User timezone:**
|
||||||
@@ -74,13 +73,12 @@ Time format: 12-hour
|
|||||||
If only the timezone is known, we still include the section and instruct the model
|
If only the timezone is known, we still include the section and instruct the model
|
||||||
to assume UTC for unknown time references.
|
to assume UTC for unknown time references.
|
||||||
|
|
||||||
## System event lines (local by default)
|
## System event lines (UTC)
|
||||||
|
|
||||||
Queued system events inserted into agent context are prefixed with a timestamp using the
|
Queued system events inserted into agent context are prefixed with a UTC timestamp:
|
||||||
same timezone selection as message envelopes (default: host-local).
|
|
||||||
|
|
||||||
```
|
```
|
||||||
System: [2026-01-12 12:19:17 PST] Model switched.
|
System: [2026-01-12T20:19:17Z] Model switched.
|
||||||
```
|
```
|
||||||
|
|
||||||
### Configure user timezone + format
|
### Configure user timezone + format
|
||||||
|
|||||||
@@ -178,20 +178,6 @@ Even with strong system prompts, **prompt injection is not solved**. What helps
|
|||||||
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
|
- Run sensitive tool execution in a sandbox; keep secrets out of the agent’s reachable filesystem.
|
||||||
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
|
- **Model choice matters:** older/legacy models can be less robust against prompt injection and tool misuse. Prefer modern, instruction-hardened models for any bot with tools. We recommend Anthropic Opus 4.5 because it’s quite good at recognizing prompt injections (see [“A step forward on safety”](https://www.anthropic.com/news/claude-opus-4-5)).
|
||||||
|
|
||||||
### Prompt injection does not require public DMs
|
|
||||||
|
|
||||||
Even if **only you** can message the bot, prompt injection can still happen via
|
|
||||||
any **untrusted content** the bot reads (web search/fetch results, browser pages,
|
|
||||||
emails, docs, attachments, pasted logs/code). In other words: the sender is not
|
|
||||||
the only threat surface; the **content itself** can carry adversarial instructions.
|
|
||||||
|
|
||||||
When tools are enabled, the typical risk is exfiltrating context or triggering
|
|
||||||
tool calls. Reduce the blast radius by:
|
|
||||||
- Using a read-only or tool-disabled **reader agent** to summarize untrusted content,
|
|
||||||
then pass the summary to your main agent.
|
|
||||||
- Keeping `web_search` / `web_fetch` / `browser` off for tool-enabled agents unless needed.
|
|
||||||
- Enabling sandboxing and strict tool allowlists for any agent that touches untrusted input.
|
|
||||||
|
|
||||||
### Model strength (security note)
|
### Model strength (security note)
|
||||||
|
|
||||||
Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts.
|
Prompt injection resistance is **not** uniform across model tiers. Smaller/cheaper models are generally more susceptible to tool misuse and instruction hijacking, especially under adversarial prompts.
|
||||||
@@ -201,7 +187,6 @@ Recommendations:
|
|||||||
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
|
- **Avoid weaker tiers** (for example, Sonnet or Haiku) for tool-enabled agents or untrusted inboxes.
|
||||||
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
|
- If you must use a smaller model, **reduce blast radius** (read-only tools, strong sandboxing, minimal filesystem access, strict allowlists).
|
||||||
- When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled.
|
- When running small models, **enable sandboxing for all sessions** and **disable web_search/web_fetch/browser** unless inputs are tightly controlled.
|
||||||
- For chat-only personal assistants with trusted input and no tools, smaller models are usually fine.
|
|
||||||
|
|
||||||
## Reasoning & verbose output in groups
|
## Reasoning & verbose output in groups
|
||||||
|
|
||||||
|
|||||||
@@ -53,15 +53,6 @@ Almost always a Node/npm PATH issue. Start here:
|
|||||||
- [Models](/cli/models)
|
- [Models](/cli/models)
|
||||||
- [OAuth / auth concepts](/concepts/oauth)
|
- [OAuth / auth concepts](/concepts/oauth)
|
||||||
|
|
||||||
### `/model` says `model not allowed`
|
|
||||||
|
|
||||||
This usually means `agents.defaults.models` is configured as an allowlist. When it’s non-empty,
|
|
||||||
only those provider/model keys can be selected.
|
|
||||||
|
|
||||||
- Check the allowlist: `clawdbot config get agents.defaults.models`
|
|
||||||
- Add the model you want (or clear the allowlist) and retry `/model`
|
|
||||||
- Use `/models` to browse the allowed providers/models
|
|
||||||
|
|
||||||
### When filing an issue
|
### When filing an issue
|
||||||
|
|
||||||
Paste a safe report:
|
Paste a safe report:
|
||||||
|
|||||||
@@ -155,21 +155,18 @@ Quick diagnosis:
|
|||||||
```bash
|
```bash
|
||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
npm prefix -g
|
npm bin -g
|
||||||
echo "$PATH"
|
echo "$PATH"
|
||||||
```
|
```
|
||||||
|
|
||||||
If `$(npm prefix -g)/bin` (macOS/Linux) or `$(npm prefix -g)` (Windows) is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
|
If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
|
||||||
|
|
||||||
Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`):
|
Fix: add it to your shell startup file (zsh: `~/.zshrc`, bash: `~/.bashrc`):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS / Linux
|
export PATH="/path/from/npm/bin/-g:$PATH"
|
||||||
export PATH="$(npm prefix -g)/bin:$PATH"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
On Windows, add the output of `npm prefix -g` to your PATH.
|
|
||||||
|
|
||||||
Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
|
Then open a new terminal (or `rehash` in zsh / `hash -r` in bash).
|
||||||
|
|
||||||
## Update / uninstall
|
## Update / uninstall
|
||||||
|
|||||||
+8
-11
@@ -19,36 +19,33 @@ Run:
|
|||||||
```bash
|
```bash
|
||||||
node -v
|
node -v
|
||||||
npm -v
|
npm -v
|
||||||
npm prefix -g
|
npm bin -g
|
||||||
echo "$PATH"
|
echo "$PATH"
|
||||||
```
|
```
|
||||||
|
|
||||||
If `$(npm prefix -g)/bin` (macOS/Linux) or `$(npm prefix -g)` (Windows) is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
|
If the output of `npm bin -g` is **not** present inside `echo "$PATH"`, your shell can’t find global npm binaries (including `clawdbot`).
|
||||||
|
|
||||||
## Fix: put npm’s global bin dir on PATH
|
## Fix: put npm’s global bin dir on PATH
|
||||||
|
|
||||||
1) Find your global npm prefix:
|
1) Find your global bin directory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm prefix -g
|
npm bin -g
|
||||||
```
|
```
|
||||||
|
|
||||||
2) Add the global npm bin directory to your shell startup file:
|
2) Add it to your shell startup file:
|
||||||
|
|
||||||
- zsh: `~/.zshrc`
|
- zsh: `~/.zshrc`
|
||||||
- bash: `~/.bashrc`
|
- bash: `~/.bashrc`
|
||||||
|
|
||||||
Example (replace the path with your `npm prefix -g` output):
|
Example (replace the path with your `npm bin -g` output):
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# macOS / Linux
|
export PATH="/path/from/npm/bin/-g:$PATH"
|
||||||
export PATH="/path/from/npm/prefix/bin:$PATH"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Then open a **new terminal** (or run `rehash` in zsh / `hash -r` in bash).
|
Then open a **new terminal** (or run `rehash` in zsh / `hash -r` in bash).
|
||||||
|
|
||||||
On Windows, add the output of `npm prefix -g` to your PATH.
|
|
||||||
|
|
||||||
## Fix: avoid `sudo npm install -g` / permission errors (Linux)
|
## Fix: avoid `sudo npm install -g` / permission errors (Linux)
|
||||||
|
|
||||||
If `npm install -g ...` fails with `EACCES`, switch npm’s global prefix to a user-writable directory:
|
If `npm install -g ...` fails with `EACCES`, switch npm’s global prefix to a user-writable directory:
|
||||||
@@ -66,7 +63,7 @@ Persist the `export PATH=...` line in your shell startup file.
|
|||||||
You’ll have the fewest surprises if Node/npm are installed in a way that:
|
You’ll have the fewest surprises if Node/npm are installed in a way that:
|
||||||
|
|
||||||
- keeps Node updated (22+)
|
- keeps Node updated (22+)
|
||||||
- makes the global npm bin dir stable and on PATH in new shells
|
- makes `npm bin -g` stable and on PATH in new shells
|
||||||
|
|
||||||
Common choices:
|
Common choices:
|
||||||
|
|
||||||
|
|||||||
@@ -117,8 +117,6 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS,
|
|||||||
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
|
- [My skill generated an image/PDF, but nothing was sent](#my-skill-generated-an-imagepdf-but-nothing-was-sent)
|
||||||
- [Security and access control](#security-and-access-control)
|
- [Security and access control](#security-and-access-control)
|
||||||
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
|
- [Is it safe to expose Clawdbot to inbound DMs?](#is-it-safe-to-expose-clawdbot-to-inbound-dms)
|
||||||
- [Is prompt injection only a concern for public bots?](#is-prompt-injection-only-a-concern-for-public-bots)
|
|
||||||
- [Can I use cheaper models for personal assistant tasks?](#can-i-use-cheaper-models-for-personal-assistant-tasks)
|
|
||||||
- [I ran `/start` in Telegram but didn’t get a pairing code](#i-ran-start-in-telegram-but-didnt-get-a-pairing-code)
|
- [I ran `/start` in Telegram but didn’t get a pairing code](#i-ran-start-in-telegram-but-didnt-get-a-pairing-code)
|
||||||
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
|
- [WhatsApp: will it message my contacts? How does pairing work?](#whatsapp-will-it-message-my-contacts-how-does-pairing-work)
|
||||||
- [Chat commands, aborting tasks, and “it won’t stop”](#chat-commands-aborting-tasks-and-it-wont-stop)
|
- [Chat commands, aborting tasks, and “it won’t stop”](#chat-commands-aborting-tasks-and-it-wont-stop)
|
||||||
@@ -1541,28 +1539,6 @@ Treat inbound DMs as untrusted input. Defaults are designed to reduce risk:
|
|||||||
|
|
||||||
Run `clawdbot doctor` to surface risky DM policies.
|
Run `clawdbot doctor` to surface risky DM policies.
|
||||||
|
|
||||||
### Is prompt injection only a concern for public bots?
|
|
||||||
|
|
||||||
No. Prompt injection is about **untrusted content**, not just who can DM the bot.
|
|
||||||
If your assistant reads external content (web search/fetch, browser pages, emails,
|
|
||||||
docs, attachments, pasted logs), that content can include instructions that try
|
|
||||||
to hijack the model. This can happen even if **you are the only sender**.
|
|
||||||
|
|
||||||
The biggest risk is when tools are enabled: the model can be tricked into
|
|
||||||
exfiltrating context or calling tools on your behalf. Reduce the blast radius by:
|
|
||||||
- using a read-only or tool-disabled "reader" agent to summarize untrusted content
|
|
||||||
- keeping `web_search` / `web_fetch` / `browser` off for tool-enabled agents
|
|
||||||
- sandboxing and strict tool allowlists
|
|
||||||
|
|
||||||
Details: [Security](/gateway/security).
|
|
||||||
|
|
||||||
### Can I use cheaper models for personal assistant tasks?
|
|
||||||
|
|
||||||
Yes, **if** the agent is chat-only and the input is trusted. Smaller tiers are
|
|
||||||
more susceptible to instruction hijacking, so avoid them for tool-enabled agents
|
|
||||||
or when reading untrusted content. If you must use a smaller model, lock down
|
|
||||||
tools and run inside a sandbox. See [Security](/gateway/security).
|
|
||||||
|
|
||||||
### I ran `/start` in Telegram but didn’t get a pairing code
|
### I ran `/start` in Telegram but didn’t get a pairing code
|
||||||
|
|
||||||
Pairing codes are sent **only** when an unknown sender messages the bot and
|
Pairing codes are sent **only** when an unknown sender messages the bot and
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ If a prompt is required but no UI is reachable, fallback decides:
|
|||||||
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
|
Allowlists are **per agent**. If multiple agents exist, switch which agent you’re
|
||||||
editing in the macOS app. Patterns are **case-insensitive glob matches**.
|
editing in the macOS app. Patterns are **case-insensitive glob matches**.
|
||||||
Patterns should resolve to **binary paths** (basename-only entries are ignored).
|
Patterns should resolve to **binary paths** (basename-only entries are ignored).
|
||||||
Legacy `agents.default` entries are migrated to `agents.main` on load.
|
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
- `~/Projects/**/bin/bird`
|
- `~/Projects/**/bin/bird`
|
||||||
|
|||||||
@@ -7,32 +7,19 @@ import { describe, expect, it } from "vitest";
|
|||||||
import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/plugins/types.js";
|
import type { ClawdbotPluginApi, ClawdbotPluginToolContext } from "../../../src/plugins/types.js";
|
||||||
import { createLobsterTool } from "./lobster-tool.js";
|
import { createLobsterTool } from "./lobster-tool.js";
|
||||||
|
|
||||||
async function writeFakeLobsterScript(scriptBody: string, prefix = "clawdbot-lobster-plugin-") {
|
async function writeFakeLobster(params: {
|
||||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix));
|
payload: unknown;
|
||||||
const isWindows = process.platform === "win32";
|
}) {
|
||||||
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-"));
|
||||||
if (isWindows) {
|
|
||||||
const scriptPath = path.join(dir, "lobster.js");
|
|
||||||
const cmdPath = path.join(dir, "lobster.cmd");
|
|
||||||
await fs.writeFile(scriptPath, scriptBody, { encoding: "utf8" });
|
|
||||||
const cmd = `@echo off\r\n"${process.execPath}" "${scriptPath}" %*\r\n`;
|
|
||||||
await fs.writeFile(cmdPath, cmd, { encoding: "utf8" });
|
|
||||||
return { dir, binPath: cmdPath };
|
|
||||||
}
|
|
||||||
|
|
||||||
const binPath = path.join(dir, "lobster");
|
const binPath = path.join(dir, "lobster");
|
||||||
const file = `#!/usr/bin/env node\n${scriptBody}\n`;
|
|
||||||
|
const file = `#!/usr/bin/env node\n` +
|
||||||
|
`process.stdout.write(JSON.stringify(${JSON.stringify(params.payload)}));\n`;
|
||||||
|
|
||||||
await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 });
|
await fs.writeFile(binPath, file, { encoding: "utf8", mode: 0o755 });
|
||||||
return { dir, binPath };
|
return { dir, binPath };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function writeFakeLobster(params: { payload: unknown }) {
|
|
||||||
const scriptBody =
|
|
||||||
`const payload = ${JSON.stringify(params.payload)};\n` +
|
|
||||||
`process.stdout.write(JSON.stringify(payload));\n`;
|
|
||||||
return await writeFakeLobsterScript(scriptBody);
|
|
||||||
}
|
|
||||||
|
|
||||||
function fakeApi(): ClawdbotPluginApi {
|
function fakeApi(): ClawdbotPluginApi {
|
||||||
return {
|
return {
|
||||||
id: "lobster",
|
id: "lobster",
|
||||||
@@ -95,10 +82,12 @@ describe("lobster plugin tool", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("rejects invalid JSON from lobster", async () => {
|
it("rejects invalid JSON from lobster", async () => {
|
||||||
const { binPath } = await writeFakeLobsterScript(
|
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-lobster-plugin-bad-"));
|
||||||
`process.stdout.write("nope");\n`,
|
const binPath = path.join(dir, "lobster");
|
||||||
"clawdbot-lobster-plugin-bad-",
|
await fs.writeFile(binPath, `#!/usr/bin/env node\nprocess.stdout.write('nope');\n`, {
|
||||||
);
|
encoding: "utf8",
|
||||||
|
mode: 0o755,
|
||||||
|
});
|
||||||
|
|
||||||
const tool = createLobsterTool(fakeApi());
|
const tool = createLobsterTool(fakeApi());
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -29,22 +29,13 @@ function resolveExecutablePath(lobsterPathRaw: string | undefined) {
|
|||||||
return lobsterPath;
|
return lobsterPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWindowsSpawnEINVAL(err: unknown) {
|
async function runLobsterSubprocess(params: {
|
||||||
if (!err || typeof err !== "object") return false;
|
execPath: string;
|
||||||
const code = (err as { code?: unknown }).code;
|
argv: string[];
|
||||||
return code === "EINVAL";
|
cwd: string;
|
||||||
}
|
timeoutMs: number;
|
||||||
|
maxStdoutBytes: number;
|
||||||
async function runLobsterSubprocessOnce(
|
}) {
|
||||||
params: {
|
|
||||||
execPath: string;
|
|
||||||
argv: string[];
|
|
||||||
cwd: string;
|
|
||||||
timeoutMs: number;
|
|
||||||
maxStdoutBytes: number;
|
|
||||||
},
|
|
||||||
useShell: boolean,
|
|
||||||
) {
|
|
||||||
const { execPath, argv, cwd } = params;
|
const { execPath, argv, cwd } = params;
|
||||||
const timeoutMs = Math.max(200, params.timeoutMs);
|
const timeoutMs = Math.max(200, params.timeoutMs);
|
||||||
const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes);
|
const maxStdoutBytes = Math.max(1024, params.maxStdoutBytes);
|
||||||
@@ -60,8 +51,6 @@ async function runLobsterSubprocessOnce(
|
|||||||
cwd,
|
cwd,
|
||||||
stdio: ["ignore", "pipe", "pipe"],
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
env,
|
env,
|
||||||
shell: useShell,
|
|
||||||
windowsHide: useShell ? true : undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let stdout = "";
|
let stdout = "";
|
||||||
@@ -113,23 +102,6 @@ async function runLobsterSubprocessOnce(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runLobsterSubprocess(params: {
|
|
||||||
execPath: string;
|
|
||||||
argv: string[];
|
|
||||||
cwd: string;
|
|
||||||
timeoutMs: number;
|
|
||||||
maxStdoutBytes: number;
|
|
||||||
}) {
|
|
||||||
try {
|
|
||||||
return await runLobsterSubprocessOnce(params, false);
|
|
||||||
} catch (err) {
|
|
||||||
if (process.platform === "win32" && isWindowsSpawnEINVAL(err)) {
|
|
||||||
return await runLobsterSubprocessOnce(params, true);
|
|
||||||
}
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseEnvelope(stdout: string): LobsterEnvelope {
|
function parseEnvelope(stdout: string): LobsterEnvelope {
|
||||||
let parsed: unknown;
|
let parsed: unknown;
|
||||||
try {
|
try {
|
||||||
|
|||||||
Generated
-2
@@ -301,8 +301,6 @@ importers:
|
|||||||
|
|
||||||
extensions/imessage: {}
|
extensions/imessage: {}
|
||||||
|
|
||||||
extensions/lobster: {}
|
|
||||||
|
|
||||||
extensions/matrix:
|
extensions/matrix:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@matrix-org/matrix-sdk-crypto-nodejs':
|
'@matrix-org/matrix-sdk-crypto-nodejs':
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ TIMESTAMP_MODE="${CODESIGN_TIMESTAMP:-auto}"
|
|||||||
DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}"
|
DISABLE_LIBRARY_VALIDATION="${DISABLE_LIBRARY_VALIDATION:-0}"
|
||||||
SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}"
|
SKIP_TEAM_ID_CHECK="${SKIP_TEAM_ID_CHECK:-0}"
|
||||||
ENT_TMP_BASE=$(mktemp -t clawdbot-entitlements-base.XXXXXX)
|
ENT_TMP_BASE=$(mktemp -t clawdbot-entitlements-base.XXXXXX)
|
||||||
|
ENT_TMP_APP=$(mktemp -t clawdbot-entitlements-app.XXXXXX)
|
||||||
ENT_TMP_APP_BASE=$(mktemp -t clawdbot-entitlements-app-base.XXXXXX)
|
ENT_TMP_APP_BASE=$(mktemp -t clawdbot-entitlements-app-base.XXXXXX)
|
||||||
ENT_TMP_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX)
|
ENT_TMP_RUNTIME=$(mktemp -t clawdbot-entitlements-runtime.XXXXXX)
|
||||||
|
|
||||||
@@ -20,6 +21,7 @@ Env:
|
|||||||
CODESIGN_TIMESTAMP=auto|on|off
|
CODESIGN_TIMESTAMP=auto|on|off
|
||||||
DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround
|
DISABLE_LIBRARY_VALIDATION=1 # dev-only Sparkle Team ID workaround
|
||||||
SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit
|
SKIP_TEAM_ID_CHECK=1 # bypass Team ID audit
|
||||||
|
ENABLE_TIME_SENSITIVE_NOTIFICATIONS=1
|
||||||
HELP
|
HELP
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
@@ -180,13 +182,43 @@ cat > "$ENT_TMP_RUNTIME" <<'PLIST'
|
|||||||
</plist>
|
</plist>
|
||||||
PLIST
|
PLIST
|
||||||
|
|
||||||
|
cat > "$ENT_TMP_APP" <<'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>com.apple.developer.usernotifications.time-sensitive</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.automation.apple-events</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.device.audio-input</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.device.camera</key>
|
||||||
|
<true/>
|
||||||
|
<key>com.apple.security.personal-information.location</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
|
PLIST
|
||||||
|
|
||||||
if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then
|
if [[ "$DISABLE_LIBRARY_VALIDATION" == "1" ]]; then
|
||||||
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP_BASE" >/dev/null 2>&1 || \
|
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP_BASE" >/dev/null 2>&1 || \
|
||||||
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP_BASE"
|
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP_BASE"
|
||||||
|
/usr/libexec/PlistBuddy -c "Add :com.apple.security.cs.disable-library-validation bool true" "$ENT_TMP_APP" >/dev/null 2>&1 || \
|
||||||
|
/usr/libexec/PlistBuddy -c "Set :com.apple.security.cs.disable-library-validation true" "$ENT_TMP_APP"
|
||||||
echo "Note: disable-library-validation entitlement enabled (DISABLE_LIBRARY_VALIDATION=1)."
|
echo "Note: disable-library-validation entitlement enabled (DISABLE_LIBRARY_VALIDATION=1)."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# The time-sensitive entitlement is restricted and requires explicit enablement
|
||||||
|
# (and typically a matching provisioning profile). It is *not* safe to enable
|
||||||
|
# unconditionally for local debug packaging since AMFI will refuse to launch.
|
||||||
APP_ENTITLEMENTS="$ENT_TMP_APP_BASE"
|
APP_ENTITLEMENTS="$ENT_TMP_APP_BASE"
|
||||||
|
if [[ "${ENABLE_TIME_SENSITIVE_NOTIFICATIONS:-}" == "1" ]]; then
|
||||||
|
APP_ENTITLEMENTS="$ENT_TMP_APP"
|
||||||
|
else
|
||||||
|
echo "Note: Time Sensitive Notifications entitlement disabled."
|
||||||
|
echo " To force it: ENABLE_TIME_SENSITIVE_NOTIFICATIONS=1 scripts/codesign-mac-app.sh <app>"
|
||||||
|
fi
|
||||||
|
|
||||||
# clear extended attributes to avoid stale signatures
|
# clear extended attributes to avoid stale signatures
|
||||||
xattr -cr "$APP_BUNDLE" 2>/dev/null || true
|
xattr -cr "$APP_BUNDLE" 2>/dev/null || true
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ COPY src ./src
|
|||||||
COPY scripts ./scripts
|
COPY scripts ./scripts
|
||||||
COPY docs ./docs
|
COPY docs ./docs
|
||||||
COPY skills ./skills
|
COPY skills ./skills
|
||||||
COPY extensions/memory-core ./extensions/memory-core
|
|
||||||
|
|
||||||
RUN pnpm install --frozen-lockfile
|
RUN pnpm install --frozen-lockfile
|
||||||
RUN pnpm build
|
RUN pnpm build
|
||||||
|
|||||||
@@ -51,27 +51,14 @@ TRASH
|
|||||||
start_s="$(date +%s)"
|
start_s="$(date +%s)"
|
||||||
while true; do
|
while true; do
|
||||||
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
|
if [ -n "${WIZARD_LOG_PATH:-}" ] && [ -f "$WIZARD_LOG_PATH" ]; then
|
||||||
if grep -a -F -q "$needle" "$WIZARD_LOG_PATH"; then
|
if NEEDLE="$needle_compact" node --input-type=module -e "
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if NEEDLE=\"$needle_compact\" node --input-type=module -e "
|
|
||||||
import fs from \"node:fs\";
|
import fs from \"node:fs\";
|
||||||
const file = process.env.WIZARD_LOG_PATH;
|
const file = process.env.WIZARD_LOG_PATH;
|
||||||
const needle = process.env.NEEDLE ?? \"\";
|
const needle = process.env.NEEDLE ?? \"\";
|
||||||
let text = \"\";
|
let text = \"\";
|
||||||
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
|
try { text = fs.readFileSync(file, \"utf8\"); } catch { process.exit(1); }
|
||||||
if (text.length > 20000) text = text.slice(-20000);
|
text = text.replace(/\\x1b\\[[0-9;]*[A-Za-z]/g, \"\").replace(/\\s+/g, \"\");
|
||||||
const sanitize = (value) => value.replace(/[\\x00-\\x1f\\x7f]/g, \"\");
|
process.exit(text.includes(needle) ? 0 : 1);
|
||||||
const haystack = sanitize(text);
|
|
||||||
const safeNeedle = sanitize(needle);
|
|
||||||
const needsEscape = new Set([\"\\\\\", \"^\", \"$\", \".\", \"*\", \"+\", \"?\", \"(\", \")\", \"[\", \"]\", \"{\", \"}\", \"|\"]);
|
|
||||||
let escaped = \"\";
|
|
||||||
for (const ch of safeNeedle) {
|
|
||||||
escaped += needsEscape.has(ch) ? \"\\\\\" + ch : ch;
|
|
||||||
}
|
|
||||||
const pattern = escaped.split(\"\").join(\".*\");
|
|
||||||
const re = new RegExp(pattern, \"i\");
|
|
||||||
process.exit(re.test(haystack) ? 0 : 1);
|
|
||||||
"; then
|
"; then
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
@@ -93,35 +80,13 @@ TRASH
|
|||||||
}
|
}
|
||||||
|
|
||||||
wait_for_gateway() {
|
wait_for_gateway() {
|
||||||
for _ in $(seq 1 20); do
|
for _ in $(seq 1 10); do
|
||||||
if node --input-type=module -e "
|
if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway-e2e.log; then
|
||||||
import net from 'node:net';
|
|
||||||
const socket = net.createConnection({ host: '127.0.0.1', port: 18789 });
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
socket.destroy();
|
|
||||||
process.exit(1);
|
|
||||||
}, 500);
|
|
||||||
socket.on('connect', () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
socket.end();
|
|
||||||
process.exit(0);
|
|
||||||
});
|
|
||||||
socket.on('error', () => {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
" >/dev/null 2>&1; then
|
|
||||||
return 0
|
return 0
|
||||||
fi
|
fi
|
||||||
if [ -f /tmp/gateway-e2e.log ] && grep -E -q "listening on ws://[^ ]+:18789" /tmp/gateway-e2e.log; then
|
|
||||||
if [ -n "${GATEWAY_PID:-}" ] && kill -0 "$GATEWAY_PID" 2>/dev/null; then
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
sleep 1
|
sleep 1
|
||||||
done
|
done
|
||||||
echo "Gateway failed to start"
|
cat /tmp/gateway-e2e.log
|
||||||
cat /tmp/gateway-e2e.log || true
|
|
||||||
return 1
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,7 +116,7 @@ TRASH
|
|||||||
WIZARD_LOG_PATH="$log_path"
|
WIZARD_LOG_PATH="$log_path"
|
||||||
export WIZARD_LOG_PATH
|
export WIZARD_LOG_PATH
|
||||||
# Run under script to keep an interactive TTY for clack prompts.
|
# Run under script to keep an interactive TTY for clack prompts.
|
||||||
script -q -f -c "$command" "$log_path" < "$input_fifo" &
|
script -q -c "$command" "$log_path" < "$input_fifo" &
|
||||||
wizard_pid=$!
|
wizard_pid=$!
|
||||||
exec 3> "$input_fifo"
|
exec 3> "$input_fifo"
|
||||||
|
|
||||||
@@ -164,18 +129,8 @@ TRASH
|
|||||||
|
|
||||||
"$send_fn"
|
"$send_fn"
|
||||||
|
|
||||||
if ! wait "$wizard_pid"; then
|
|
||||||
wizard_status=$?
|
|
||||||
exec 3>&-
|
|
||||||
rm -f "$input_fifo"
|
|
||||||
stop_gateway "$gw_pid"
|
|
||||||
echo "Wizard exited with status $wizard_status"
|
|
||||||
if [ -f "$log_path" ]; then
|
|
||||||
tail -n 160 "$log_path" || true
|
|
||||||
fi
|
|
||||||
exit "$wizard_status"
|
|
||||||
fi
|
|
||||||
exec 3>&-
|
exec 3>&-
|
||||||
|
wait "$wizard_pid"
|
||||||
rm -f "$input_fifo"
|
rm -f "$input_fifo"
|
||||||
stop_gateway "$gw_pid"
|
stop_gateway "$gw_pid"
|
||||||
if [ -n "$validate_fn" ]; then
|
if [ -n "$validate_fn" ]; then
|
||||||
@@ -221,18 +176,14 @@ TRASH
|
|||||||
|
|
||||||
send_local_basic() {
|
send_local_basic() {
|
||||||
# Risk acknowledgement (default is "No").
|
# Risk acknowledgement (default is "No").
|
||||||
wait_for_log "Continue?" 60
|
|
||||||
send $'"'"'y\r'"'"' 0.6
|
send $'"'"'y\r'"'"' 0.6
|
||||||
# Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI.
|
# Choose local gateway, accept defaults, skip channels/skills/daemon, skip UI.
|
||||||
if wait_for_log "Where will the Gateway run?" 20; then
|
send $'"'"'\r'"'"' 0.5
|
||||||
send $'"'"'\r'"'"' 0.5
|
|
||||||
fi
|
|
||||||
select_skip_hooks
|
select_skip_hooks
|
||||||
}
|
}
|
||||||
|
|
||||||
send_reset_config_only() {
|
send_reset_config_only() {
|
||||||
# Risk acknowledgement (default is "No").
|
# Risk acknowledgement (default is "No").
|
||||||
wait_for_log "Continue?" 40 || true
|
|
||||||
send $'"'"'y\r'"'"' 0.8
|
send $'"'"'y\r'"'"' 0.8
|
||||||
# Select reset flow for existing config.
|
# Select reset flow for existing config.
|
||||||
wait_for_log "Config handling" 40 || true
|
wait_for_log "Config handling" 40 || true
|
||||||
@@ -260,27 +211,19 @@ TRASH
|
|||||||
|
|
||||||
send_skills_flow() {
|
send_skills_flow() {
|
||||||
# Select skills section and skip optional installs.
|
# Select skills section and skip optional installs.
|
||||||
send $'"'"'\r'"'"' 1.2
|
wait_for_log "Where will the Gateway run?" 40 || true
|
||||||
|
send $'"'"'\r'"'"' 0.8
|
||||||
# Configure skills now? -> No
|
# Configure skills now? -> No
|
||||||
send $'"'"'n\r'"'"' 1.5
|
wait_for_log "Configure skills now?" 40 || true
|
||||||
send "" 1.0
|
send $'"'"'n\r'"'"' 0.8
|
||||||
|
wait_for_log "Configure complete." 40 || true
|
||||||
|
send "" 0.8
|
||||||
}
|
}
|
||||||
|
|
||||||
run_case_local_basic() {
|
run_case_local_basic() {
|
||||||
local home_dir
|
local home_dir
|
||||||
home_dir="$(make_home local-basic)"
|
home_dir="$(make_home local-basic)"
|
||||||
export HOME="$home_dir"
|
run_wizard local-basic "$home_dir" send_local_basic validate_local_basic_log
|
||||||
mkdir -p "$HOME"
|
|
||||||
node dist/index.js onboard \
|
|
||||||
--non-interactive \
|
|
||||||
--accept-risk \
|
|
||||||
--flow quickstart \
|
|
||||||
--mode local \
|
|
||||||
--skip-channels \
|
|
||||||
--skip-skills \
|
|
||||||
--skip-daemon \
|
|
||||||
--skip-ui \
|
|
||||||
--skip-health
|
|
||||||
|
|
||||||
# Assert config + workspace scaffolding.
|
# Assert config + workspace scaffolding.
|
||||||
workspace_dir="$HOME/clawd"
|
workspace_dir="$HOME/clawd"
|
||||||
@@ -340,6 +283,25 @@ if (errors.length > 0) {
|
|||||||
}
|
}
|
||||||
NODE
|
NODE
|
||||||
|
|
||||||
|
node dist/index.js gateway --port 18789 --bind loopback > /tmp/gateway.log 2>&1 &
|
||||||
|
GW_PID=$!
|
||||||
|
# Gate on gateway readiness, then run health.
|
||||||
|
for _ in $(seq 1 10); do
|
||||||
|
if grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway.log; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! grep -q "listening on ws://127.0.0.1:18789" /tmp/gateway.log; then
|
||||||
|
cat /tmp/gateway.log
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
node dist/index.js health --timeout 2000 || (cat /tmp/gateway.log && exit 1)
|
||||||
|
|
||||||
|
kill "$GW_PID"
|
||||||
|
wait "$GW_PID" || true
|
||||||
}
|
}
|
||||||
|
|
||||||
run_case_remote_non_interactive() {
|
run_case_remote_non_interactive() {
|
||||||
@@ -393,7 +355,7 @@ NODE
|
|||||||
# Seed a remote config to exercise reset path.
|
# Seed a remote config to exercise reset path.
|
||||||
cat > "$HOME/.clawdbot/clawdbot.json" <<'"'"'JSON'"'"'
|
cat > "$HOME/.clawdbot/clawdbot.json" <<'"'"'JSON'"'"'
|
||||||
{
|
{
|
||||||
"agents": { "defaults": { "workspace": "/root/old" } },
|
"agent": { "workspace": "/root/old" },
|
||||||
"gateway": {
|
"gateway": {
|
||||||
"mode": "remote",
|
"mode": "remote",
|
||||||
"remote": { "url": "ws://old.example:18789", "token": "old-token" }
|
"remote": { "url": "ws://old.example:18789", "token": "old-token" }
|
||||||
@@ -401,17 +363,7 @@ NODE
|
|||||||
}
|
}
|
||||||
JSON
|
JSON
|
||||||
|
|
||||||
node dist/index.js onboard \
|
run_wizard reset-config "$home_dir" send_reset_config_only
|
||||||
--non-interactive \
|
|
||||||
--accept-risk \
|
|
||||||
--flow quickstart \
|
|
||||||
--mode local \
|
|
||||||
--reset \
|
|
||||||
--skip-channels \
|
|
||||||
--skip-skills \
|
|
||||||
--skip-daemon \
|
|
||||||
--skip-ui \
|
|
||||||
--skip-health
|
|
||||||
|
|
||||||
config_path="$HOME/.clawdbot/clawdbot.json"
|
config_path="$HOME/.clawdbot/clawdbot.json"
|
||||||
assert_file "$config_path"
|
assert_file "$config_path"
|
||||||
|
|||||||
@@ -54,7 +54,6 @@ import { callGatewayTool } from "./tools/gateway.js";
|
|||||||
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
|
import { listNodes, resolveNodeIdFromList } from "./tools/nodes-utils.js";
|
||||||
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
|
import { getShellConfig, sanitizeBinaryOutput } from "./shell-utils.js";
|
||||||
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
|
import { buildCursorPositionResponse, stripDsrRequests } from "./pty-dsr.js";
|
||||||
import { parseAgentSessionKey, resolveAgentIdFromSessionKey } from "../routing/session-key.js";
|
|
||||||
|
|
||||||
const DEFAULT_MAX_OUTPUT = clampNumber(
|
const DEFAULT_MAX_OUTPUT = clampNumber(
|
||||||
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
|
readEnvInt("PI_BASH_MAX_OUTPUT_CHARS"),
|
||||||
@@ -660,11 +659,6 @@ export function createExecTool(
|
|||||||
const notifyOnExit = defaults?.notifyOnExit !== false;
|
const notifyOnExit = defaults?.notifyOnExit !== false;
|
||||||
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
|
const notifySessionKey = defaults?.sessionKey?.trim() || undefined;
|
||||||
const approvalRunningNoticeMs = resolveApprovalRunningNoticeMs(defaults?.approvalRunningNoticeMs);
|
const approvalRunningNoticeMs = resolveApprovalRunningNoticeMs(defaults?.approvalRunningNoticeMs);
|
||||||
// Derive agentId only when sessionKey is an agent session key.
|
|
||||||
const parsedAgentSession = parseAgentSessionKey(defaults?.sessionKey);
|
|
||||||
const agentId =
|
|
||||||
defaults?.agentId ??
|
|
||||||
(parsedAgentSession ? resolveAgentIdFromSessionKey(defaults?.sessionKey) : undefined);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: "exec",
|
name: "exec",
|
||||||
@@ -805,7 +799,7 @@ export function createExecTool(
|
|||||||
|
|
||||||
if (host === "node") {
|
if (host === "node") {
|
||||||
const approvals = resolveExecApprovals(
|
const approvals = resolveExecApprovals(
|
||||||
agentId,
|
defaults?.agentId,
|
||||||
host === "node" ? { security: "allowlist" } : undefined,
|
host === "node" ? { security: "allowlist" } : undefined,
|
||||||
);
|
);
|
||||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||||
@@ -871,7 +865,7 @@ export function createExecTool(
|
|||||||
cwd: workdir,
|
cwd: workdir,
|
||||||
env: nodeEnv,
|
env: nodeEnv,
|
||||||
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
|
timeoutMs: typeof params.timeout === "number" ? params.timeout * 1000 : undefined,
|
||||||
agentId,
|
agentId: defaults?.agentId,
|
||||||
sessionKey: defaults?.sessionKey,
|
sessionKey: defaults?.sessionKey,
|
||||||
approved: approvedByAsk,
|
approved: approvedByAsk,
|
||||||
approvalDecision: approvalDecision ?? undefined,
|
approvalDecision: approvalDecision ?? undefined,
|
||||||
@@ -901,9 +895,9 @@ export function createExecTool(
|
|||||||
host: "node",
|
host: "node",
|
||||||
security: hostSecurity,
|
security: hostSecurity,
|
||||||
ask: hostAsk,
|
ask: hostAsk,
|
||||||
agentId,
|
agentId: defaults?.agentId,
|
||||||
resolvedPath: null,
|
resolvedPath: undefined,
|
||||||
sessionKey: defaults?.sessionKey ?? null,
|
sessionKey: defaults?.sessionKey,
|
||||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
)) as { decision?: string } | null;
|
)) as { decision?: string } | null;
|
||||||
@@ -1032,7 +1026,7 @@ export function createExecTool(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (host === "gateway") {
|
if (host === "gateway") {
|
||||||
const approvals = resolveExecApprovals(agentId, { security: "allowlist" });
|
const approvals = resolveExecApprovals(defaults?.agentId, { security: "allowlist" });
|
||||||
const hostSecurity = minSecurity(security, approvals.agent.security);
|
const hostSecurity = minSecurity(security, approvals.agent.security);
|
||||||
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
const hostAsk = maxAsk(ask, approvals.agent.ask);
|
||||||
const askFallback = approvals.agent.askFallback;
|
const askFallback = approvals.agent.askFallback;
|
||||||
@@ -1066,7 +1060,7 @@ export function createExecTool(
|
|||||||
const approvalSlug = createApprovalSlug(approvalId);
|
const approvalSlug = createApprovalSlug(approvalId);
|
||||||
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
|
const expiresAtMs = Date.now() + DEFAULT_APPROVAL_TIMEOUT_MS;
|
||||||
const contextKey = `exec:${approvalId}`;
|
const contextKey = `exec:${approvalId}`;
|
||||||
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath ?? null;
|
const resolvedPath = analysis.segments[0]?.resolution?.resolvedPath;
|
||||||
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
|
const noticeSeconds = Math.max(1, Math.round(approvalRunningNoticeMs / 1000));
|
||||||
const commandText = params.command;
|
const commandText = params.command;
|
||||||
const effectiveTimeout =
|
const effectiveTimeout =
|
||||||
@@ -1086,9 +1080,9 @@ export function createExecTool(
|
|||||||
host: "gateway",
|
host: "gateway",
|
||||||
security: hostSecurity,
|
security: hostSecurity,
|
||||||
ask: hostAsk,
|
ask: hostAsk,
|
||||||
agentId,
|
agentId: defaults?.agentId,
|
||||||
resolvedPath,
|
resolvedPath,
|
||||||
sessionKey: defaults?.sessionKey ?? null,
|
sessionKey: defaults?.sessionKey,
|
||||||
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
timeoutMs: DEFAULT_APPROVAL_TIMEOUT_MS,
|
||||||
},
|
},
|
||||||
)) as { decision?: string } | null;
|
)) as { decision?: string } | null;
|
||||||
@@ -1129,7 +1123,7 @@ export function createExecTool(
|
|||||||
for (const segment of analysis.segments) {
|
for (const segment of analysis.segments) {
|
||||||
const pattern = segment.resolution?.resolvedPath ?? "";
|
const pattern = segment.resolution?.resolvedPath ?? "";
|
||||||
if (pattern) {
|
if (pattern) {
|
||||||
addAllowlistEntry(approvals.file, agentId, pattern);
|
addAllowlistEntry(approvals.file, defaults?.agentId, pattern);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1158,7 +1152,7 @@ export function createExecTool(
|
|||||||
seen.add(match.pattern);
|
seen.add(match.pattern);
|
||||||
recordAllowlistUse(
|
recordAllowlistUse(
|
||||||
approvals.file,
|
approvals.file,
|
||||||
agentId,
|
defaults?.agentId,
|
||||||
match,
|
match,
|
||||||
commandText,
|
commandText,
|
||||||
resolvedPath ?? undefined,
|
resolvedPath ?? undefined,
|
||||||
@@ -1248,7 +1242,7 @@ export function createExecTool(
|
|||||||
seen.add(match.pattern);
|
seen.add(match.pattern);
|
||||||
recordAllowlistUse(
|
recordAllowlistUse(
|
||||||
approvals.file,
|
approvals.file,
|
||||||
agentId,
|
defaults?.agentId,
|
||||||
match,
|
match,
|
||||||
params.command,
|
params.command,
|
||||||
analysis.segments[0]?.resolution?.resolvedPath,
|
analysis.segments[0]?.resolution?.resolvedPath,
|
||||||
|
|||||||
@@ -68,10 +68,6 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
|
|||||||
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
|
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInstructionsRequiredError(raw: string): boolean {
|
|
||||||
return /instructions are required/i.test(raw);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toInt(value: string | undefined, fallback: number): number {
|
function toInt(value: string | undefined, fallback: number): number {
|
||||||
const trimmed = value?.trim();
|
const trimmed = value?.trim();
|
||||||
if (!trimmed) return fallback;
|
if (!trimmed) return fallback;
|
||||||
@@ -447,15 +443,6 @@ describeLive("live models (profile keys)", () => {
|
|||||||
logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
|
logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
allowNotFoundSkip &&
|
|
||||||
model.provider === "openai-codex" &&
|
|
||||||
isInstructionsRequiredError(message)
|
|
||||||
) {
|
|
||||||
skipped.push({ model: id, reason: message });
|
|
||||||
logProgress(`${progressLabel}: skip (instructions required)`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
logProgress(`${progressLabel}: failed`);
|
logProgress(`${progressLabel}: failed`);
|
||||||
failures.push({ model: id, error: message });
|
failures.push({ model: id, error: message });
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -44,8 +44,6 @@ describe("resolveOpencodeZenModelApi", () => {
|
|||||||
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
|
expect(resolveOpencodeZenModelApi("minimax-m2.1-free")).toBe("anthropic-messages");
|
||||||
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
|
expect(resolveOpencodeZenModelApi("gemini-3-pro")).toBe("google-generative-ai");
|
||||||
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
|
expect(resolveOpencodeZenModelApi("gpt-5.2")).toBe("openai-responses");
|
||||||
expect(resolveOpencodeZenModelApi("alpha-gd4")).toBe("openai-completions");
|
|
||||||
expect(resolveOpencodeZenModelApi("big-pickle")).toBe("openai-completions");
|
|
||||||
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
|
expect(resolveOpencodeZenModelApi("glm-4.7-free")).toBe("openai-completions");
|
||||||
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
|
expect(resolveOpencodeZenModelApi("some-unknown-model")).toBe("openai-completions");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -87,19 +87,19 @@ export function resolveOpencodeZenAlias(modelIdOrAlias: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* OpenCode Zen routes models to specific API shapes by family.
|
* OpenCode Zen routes models to different APIs based on model family.
|
||||||
*/
|
*/
|
||||||
export function resolveOpencodeZenModelApi(modelId: string): ModelApi {
|
export function resolveOpencodeZenModelApi(modelId: string): ModelApi {
|
||||||
const lower = modelId.toLowerCase();
|
const lower = modelId.toLowerCase();
|
||||||
if (lower.startsWith("gpt-")) {
|
if (lower.startsWith("claude-") || lower.startsWith("minimax") || lower.startsWith("alpha-gd4")) {
|
||||||
return "openai-responses";
|
|
||||||
}
|
|
||||||
if (lower.startsWith("claude-") || lower.startsWith("minimax-")) {
|
|
||||||
return "anthropic-messages";
|
return "anthropic-messages";
|
||||||
}
|
}
|
||||||
if (lower.startsWith("gemini-")) {
|
if (lower.startsWith("gemini-")) {
|
||||||
return "google-generative-ai";
|
return "google-generative-ai";
|
||||||
}
|
}
|
||||||
|
if (lower.startsWith("gpt-")) {
|
||||||
|
return "openai-responses";
|
||||||
|
}
|
||||||
return "openai-completions";
|
return "openai-completions";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -200,7 +200,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
browser: "Control web browser",
|
browser: "Control web browser",
|
||||||
canvas: "Present/eval/snapshot the Canvas",
|
canvas: "Present/eval/snapshot the Canvas",
|
||||||
nodes: "List/describe/notify/camera/screen on paired nodes",
|
nodes: "List/describe/notify/camera/screen on paired nodes",
|
||||||
cron: "Manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
|
cron: "Manage cron jobs and wake events (use for reminders; set systemEvent to a reminder-style message, especially after long delays; include recent context in reminder text if appropriate)",
|
||||||
message: "Send messages and channel actions",
|
message: "Send messages and channel actions",
|
||||||
gateway: "Restart, apply config, or run updates on the running Clawdbot process",
|
gateway: "Restart, apply config, or run updates on the running Clawdbot process",
|
||||||
agents_list: "List agent ids allowed for sessions_spawn",
|
agents_list: "List agent ids allowed for sessions_spawn",
|
||||||
@@ -351,7 +351,7 @@ export function buildAgentSystemPrompt(params: {
|
|||||||
"- browser: control clawd's dedicated browser",
|
"- browser: control clawd's dedicated browser",
|
||||||
"- canvas: present/eval/snapshot the Canvas",
|
"- canvas: present/eval/snapshot the Canvas",
|
||||||
"- nodes: list/describe/notify/camera/screen on paired nodes",
|
"- nodes: list/describe/notify/camera/screen on paired nodes",
|
||||||
"- cron: manage cron jobs and wake events (use for reminders; when scheduling a reminder, write the systemEvent text as something that will read like a reminder when it fires, and mention that it is a reminder depending on the time gap between setting and firing; include recent context in reminder text if appropriate)",
|
"- cron: manage cron jobs and wake events (use for reminders; set systemEvent to a reminder-style message, especially after long delays; include recent context in reminder text if appropriate)",
|
||||||
"- sessions_list: list sessions",
|
"- sessions_list: list sessions",
|
||||||
"- sessions_history: fetch session history",
|
"- sessions_history: fetch session history",
|
||||||
"- sessions_send: send to another session",
|
"- sessions_send: send to another session",
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ describe("formatAgentEnvelope", () => {
|
|||||||
host: "mac-mini",
|
host: "mac-mini",
|
||||||
ip: "10.0.0.5",
|
ip: "10.0.0.5",
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
envelope: { timezone: "utc" },
|
|
||||||
body: "hello",
|
body: "hello",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ describe("formatAgentEnvelope", () => {
|
|||||||
expect(body).toBe("[WebChat user1 mac-mini 10.0.0.5 2025-01-02T03:04Z] hello");
|
expect(body).toBe("[WebChat user1 mac-mini 10.0.0.5 2025-01-02T03:04Z] hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats timestamps in local timezone by default", () => {
|
it("formats timestamps in UTC regardless of local timezone", () => {
|
||||||
const originalTz = process.env.TZ;
|
const originalTz = process.env.TZ;
|
||||||
process.env.TZ = "America/Los_Angeles";
|
process.env.TZ = "America/Los_Angeles";
|
||||||
|
|
||||||
@@ -40,10 +39,10 @@ describe("formatAgentEnvelope", () => {
|
|||||||
|
|
||||||
process.env.TZ = originalTz;
|
process.env.TZ = originalTz;
|
||||||
|
|
||||||
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
|
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats timestamps in UTC when configured", () => {
|
it("formats timestamps in local timezone when configured", () => {
|
||||||
const originalTz = process.env.TZ;
|
const originalTz = process.env.TZ;
|
||||||
process.env.TZ = "America/Los_Angeles";
|
process.env.TZ = "America/Los_Angeles";
|
||||||
|
|
||||||
@@ -51,13 +50,13 @@ describe("formatAgentEnvelope", () => {
|
|||||||
const body = formatAgentEnvelope({
|
const body = formatAgentEnvelope({
|
||||||
channel: "WebChat",
|
channel: "WebChat",
|
||||||
timestamp: ts,
|
timestamp: ts,
|
||||||
envelope: { timezone: "utc" },
|
envelope: { timezone: "local" },
|
||||||
body: "hello",
|
body: "hello",
|
||||||
});
|
});
|
||||||
|
|
||||||
process.env.TZ = originalTz;
|
process.env.TZ = originalTz;
|
||||||
|
|
||||||
expect(body).toBe("[WebChat 2025-01-02T03:04Z] hello");
|
expect(body).toMatch(/\[WebChat 2025-01-01 19:04 [^\]]+\] hello/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats timestamps in user timezone when configured", () => {
|
it("formats timestamps in user timezone when configured", () => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export type AgentEnvelopeParams = {
|
|||||||
|
|
||||||
export type EnvelopeFormatOptions = {
|
export type EnvelopeFormatOptions = {
|
||||||
/**
|
/**
|
||||||
* "local" (default), "utc", "user", or an explicit IANA timezone string.
|
* "utc" (default), "local", "user", or an explicit IANA timezone string.
|
||||||
*/
|
*/
|
||||||
timezone?: string;
|
timezone?: string;
|
||||||
/**
|
/**
|
||||||
@@ -59,7 +59,7 @@ function normalizeEnvelopeOptions(options?: EnvelopeFormatOptions): NormalizedEn
|
|||||||
const includeTimestamp = options?.includeTimestamp !== false;
|
const includeTimestamp = options?.includeTimestamp !== false;
|
||||||
const includeElapsed = options?.includeElapsed !== false;
|
const includeElapsed = options?.includeElapsed !== false;
|
||||||
return {
|
return {
|
||||||
timezone: options?.timezone?.trim() || "local",
|
timezone: options?.timezone?.trim() || "utc",
|
||||||
includeTimestamp,
|
includeTimestamp,
|
||||||
includeElapsed,
|
includeElapsed,
|
||||||
userTimezone: options?.userTimezone,
|
userTimezone: options?.userTimezone,
|
||||||
@@ -77,7 +77,7 @@ function resolveExplicitTimezone(value: string): string | undefined {
|
|||||||
|
|
||||||
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
|
function resolveEnvelopeTimezone(options: NormalizedEnvelopeOptions): ResolvedEnvelopeTimezone {
|
||||||
const trimmed = options.timezone?.trim();
|
const trimmed = options.timezone?.trim();
|
||||||
if (!trimmed) return { mode: "local" };
|
if (!trimmed) return { mode: "utc" };
|
||||||
const lowered = trimmed.toLowerCase();
|
const lowered = trimmed.toLowerCase();
|
||||||
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" };
|
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" };
|
||||||
if (lowered === "local" || lowered === "host") return { mode: "local" };
|
if (lowered === "local" || lowered === "host") return { mode: "local" };
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import { enqueueSystemEvent, resetSystemEventsForTest } from "../../infra/system
|
|||||||
import { prependSystemEvents } from "./session-updates.js";
|
import { prependSystemEvents } from "./session-updates.js";
|
||||||
|
|
||||||
describe("prependSystemEvents", () => {
|
describe("prependSystemEvents", () => {
|
||||||
it("adds a local timestamp to queued system events by default", async () => {
|
it("adds a UTC timestamp to queued system events", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const originalTz = process.env.TZ;
|
|
||||||
process.env.TZ = "America/Los_Angeles";
|
|
||||||
const timestamp = new Date("2026-01-12T20:19:17Z");
|
const timestamp = new Date("2026-01-12T20:19:17Z");
|
||||||
vi.setSystemTime(timestamp);
|
vi.setSystemTime(timestamp);
|
||||||
|
|
||||||
@@ -22,10 +20,11 @@ describe("prependSystemEvents", () => {
|
|||||||
prefixedBodyBase: "User: hi",
|
prefixedBodyBase: "User: hi",
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toMatch(/System: \[2026-01-12 12:19:17 [^\]]+\] Model switched\./);
|
const expectedTimestamp = "2026-01-12T20:19:17Z";
|
||||||
|
|
||||||
|
expect(result).toContain(`System: [${expectedTimestamp}] Model switched.`);
|
||||||
|
|
||||||
resetSystemEventsForTest();
|
resetSystemEventsForTest();
|
||||||
process.env.TZ = originalTz;
|
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import crypto from "node:crypto";
|
import crypto from "node:crypto";
|
||||||
|
|
||||||
import { resolveUserTimezone } from "../../agents/date-time.js";
|
|
||||||
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
import { buildWorkspaceSkillSnapshot } from "../../agents/skills.js";
|
||||||
import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
|
import { ensureSkillsWatcher, getSkillsSnapshotVersion } from "../../agents/skills/refresh.js";
|
||||||
import type { ClawdbotConfig } from "../../config/config.js";
|
import type { ClawdbotConfig } from "../../config/config.js";
|
||||||
@@ -28,32 +27,9 @@ export async function prependSystemEvents(params: {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolveExplicitTimezone = (value: string): string | undefined => {
|
const formatSystemEventTimestamp = (ts: number) => {
|
||||||
try {
|
const date = new Date(ts);
|
||||||
new Intl.DateTimeFormat("en-US", { timeZone: value }).format(new Date());
|
if (Number.isNaN(date.getTime())) return "unknown-time";
|
||||||
return value;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const resolveSystemEventTimezone = (cfg: ClawdbotConfig) => {
|
|
||||||
const raw = cfg.agents?.defaults?.envelopeTimezone?.trim();
|
|
||||||
if (!raw) return { mode: "local" as const };
|
|
||||||
const lowered = raw.toLowerCase();
|
|
||||||
if (lowered === "utc" || lowered === "gmt") return { mode: "utc" as const };
|
|
||||||
if (lowered === "local" || lowered === "host") return { mode: "local" as const };
|
|
||||||
if (lowered === "user") {
|
|
||||||
return {
|
|
||||||
mode: "iana" as const,
|
|
||||||
timeZone: resolveUserTimezone(cfg.agents?.defaults?.userTimezone),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const explicit = resolveExplicitTimezone(raw);
|
|
||||||
return explicit ? { mode: "iana" as const, timeZone: explicit } : { mode: "local" as const };
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatUtcTimestamp = (date: Date): string => {
|
|
||||||
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
const yyyy = String(date.getUTCFullYear()).padStart(4, "0");
|
||||||
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
const mm = String(date.getUTCMonth() + 1).padStart(2, "0");
|
||||||
const dd = String(date.getUTCDate()).padStart(2, "0");
|
const dd = String(date.getUTCDate()).padStart(2, "0");
|
||||||
@@ -63,42 +39,6 @@ export async function prependSystemEvents(params: {
|
|||||||
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
|
return `${yyyy}-${mm}-${dd}T${hh}:${min}:${sec}Z`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatZonedTimestamp = (date: Date, timeZone?: string): string | undefined => {
|
|
||||||
const parts = new Intl.DateTimeFormat("en-US", {
|
|
||||||
timeZone,
|
|
||||||
year: "numeric",
|
|
||||||
month: "2-digit",
|
|
||||||
day: "2-digit",
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
second: "2-digit",
|
|
||||||
hourCycle: "h23",
|
|
||||||
timeZoneName: "short",
|
|
||||||
}).formatToParts(date);
|
|
||||||
const pick = (type: string) => parts.find((part) => part.type === type)?.value;
|
|
||||||
const yyyy = pick("year");
|
|
||||||
const mm = pick("month");
|
|
||||||
const dd = pick("day");
|
|
||||||
const hh = pick("hour");
|
|
||||||
const min = pick("minute");
|
|
||||||
const sec = pick("second");
|
|
||||||
const tz = [...parts]
|
|
||||||
.reverse()
|
|
||||||
.find((part) => part.type === "timeZoneName")
|
|
||||||
?.value?.trim();
|
|
||||||
if (!yyyy || !mm || !dd || !hh || !min || !sec) return undefined;
|
|
||||||
return `${yyyy}-${mm}-${dd} ${hh}:${min}:${sec}${tz ? ` ${tz}` : ""}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatSystemEventTimestamp = (ts: number, cfg: ClawdbotConfig) => {
|
|
||||||
const date = new Date(ts);
|
|
||||||
if (Number.isNaN(date.getTime())) return "unknown-time";
|
|
||||||
const zone = resolveSystemEventTimezone(cfg);
|
|
||||||
if (zone.mode === "utc") return formatUtcTimestamp(date);
|
|
||||||
if (zone.mode === "local") return formatZonedTimestamp(date) ?? "unknown-time";
|
|
||||||
return formatZonedTimestamp(date, zone.timeZone) ?? "unknown-time";
|
|
||||||
};
|
|
||||||
|
|
||||||
const systemLines: string[] = [];
|
const systemLines: string[] = [];
|
||||||
const queued = drainSystemEventEntries(params.sessionKey);
|
const queued = drainSystemEventEntries(params.sessionKey);
|
||||||
systemLines.push(
|
systemLines.push(
|
||||||
@@ -106,7 +46,7 @@ export async function prependSystemEvents(params: {
|
|||||||
.map((event) => {
|
.map((event) => {
|
||||||
const compacted = compactSystemEvent(event.text);
|
const compacted = compactSystemEvent(event.text);
|
||||||
if (!compacted) return null;
|
if (!compacted) return null;
|
||||||
return `[${formatSystemEventTimestamp(event.ts, params.cfg)}] ${compacted}`;
|
return `[${formatSystemEventTimestamp(event.ts)}] ${compacted}`;
|
||||||
})
|
})
|
||||||
.filter((v): v is string => Boolean(v)),
|
.filter((v): v is string => Boolean(v)),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -180,11 +180,3 @@ export function decorateClawdProfile(
|
|||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ensureProfileCleanExit(userDataDir: string) {
|
|
||||||
const preferencesPath = path.join(userDataDir, "Default", "Preferences");
|
|
||||||
const prefs = safeReadJson(preferencesPath) ?? {};
|
|
||||||
setDeep(prefs, ["exit_type"], "Normal");
|
|
||||||
setDeep(prefs, ["exited_cleanly"], true);
|
|
||||||
safeWriteJson(preferencesPath, prefs);
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,6 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
decorateClawdProfile,
|
decorateClawdProfile,
|
||||||
ensureProfileCleanExit,
|
|
||||||
findChromeExecutableMac,
|
findChromeExecutableMac,
|
||||||
findChromeExecutableWindows,
|
findChromeExecutableWindows,
|
||||||
isChromeReachable,
|
isChromeReachable,
|
||||||
@@ -104,18 +103,6 @@ describe("browser chrome profile decoration", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("writes clean exit prefs to avoid restore prompts", async () => {
|
|
||||||
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-"));
|
|
||||||
try {
|
|
||||||
ensureProfileCleanExit(userDataDir);
|
|
||||||
const prefs = await readJson(path.join(userDataDir, "Default", "Preferences"));
|
|
||||||
expect(prefs.exit_type).toBe("Normal");
|
|
||||||
expect(prefs.exited_cleanly).toBe(true);
|
|
||||||
} finally {
|
|
||||||
await fsp.rm(userDataDir, { recursive: true, force: true });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("is idempotent when rerun on an existing profile", async () => {
|
it("is idempotent when rerun on an existing profile", async () => {
|
||||||
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-"));
|
const userDataDir = await fsp.mkdtemp(path.join(os.tmpdir(), "clawdbot-chrome-test-"));
|
||||||
try {
|
try {
|
||||||
|
|||||||
+2
-18
@@ -13,11 +13,7 @@ import {
|
|||||||
type BrowserExecutable,
|
type BrowserExecutable,
|
||||||
resolveBrowserExecutableForPlatform,
|
resolveBrowserExecutableForPlatform,
|
||||||
} from "./chrome.executables.js";
|
} from "./chrome.executables.js";
|
||||||
import {
|
import { decorateClawdProfile, isProfileDecorated } from "./chrome.profile-decoration.js";
|
||||||
decorateClawdProfile,
|
|
||||||
ensureProfileCleanExit,
|
|
||||||
isProfileDecorated,
|
|
||||||
} from "./chrome.profile-decoration.js";
|
|
||||||
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
|
import type { ResolvedBrowserConfig, ResolvedBrowserProfile } from "./config.js";
|
||||||
import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_PROFILE_NAME } from "./constants.js";
|
import { DEFAULT_CLAWD_BROWSER_COLOR, DEFAULT_CLAWD_BROWSER_PROFILE_NAME } from "./constants.js";
|
||||||
|
|
||||||
@@ -30,11 +26,7 @@ export {
|
|||||||
findChromeExecutableWindows,
|
findChromeExecutableWindows,
|
||||||
resolveBrowserExecutableForPlatform,
|
resolveBrowserExecutableForPlatform,
|
||||||
} from "./chrome.executables.js";
|
} from "./chrome.executables.js";
|
||||||
export {
|
export { decorateClawdProfile, isProfileDecorated } from "./chrome.profile-decoration.js";
|
||||||
decorateClawdProfile,
|
|
||||||
ensureProfileCleanExit,
|
|
||||||
isProfileDecorated,
|
|
||||||
} from "./chrome.profile-decoration.js";
|
|
||||||
|
|
||||||
function exists(filePath: string) {
|
function exists(filePath: string) {
|
||||||
try {
|
try {
|
||||||
@@ -186,8 +178,6 @@ export async function launchClawdChrome(
|
|||||||
"--disable-background-networking",
|
"--disable-background-networking",
|
||||||
"--disable-component-update",
|
"--disable-component-update",
|
||||||
"--disable-features=Translate,MediaRouter",
|
"--disable-features=Translate,MediaRouter",
|
||||||
"--disable-session-crashed-bubble",
|
|
||||||
"--hide-crash-restore-bubble",
|
|
||||||
"--password-store=basic",
|
"--password-store=basic",
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -256,12 +246,6 @@ export async function launchClawdChrome(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
|
||||||
ensureProfileCleanExit(userDataDir);
|
|
||||||
} catch (err) {
|
|
||||||
log.warn(`clawd browser clean-exit prefs failed: ${String(err)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const proc = spawnOnce();
|
const proc = spawnOnce();
|
||||||
// Wait for CDP to come up.
|
// Wait for CDP to come up.
|
||||||
const readyDeadline = Date.now() + 15_000;
|
const readyDeadline = Date.now() + 15_000;
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
0ae29522de4c48c6b6407290be18b94d7244d4e0036738abd19d93148f2c8cd4
|
27d5aed982d9f110b44e85254877597e49efae61141de480b4e9f254c04131ce
|
||||||
@@ -68,12 +68,8 @@ const STEP_LABELS: Record<string, string> = {
|
|||||||
"clean check": "Working directory is clean",
|
"clean check": "Working directory is clean",
|
||||||
"upstream check": "Upstream branch exists",
|
"upstream check": "Upstream branch exists",
|
||||||
"git fetch": "Fetching latest changes",
|
"git fetch": "Fetching latest changes",
|
||||||
"git rebase": "Rebasing onto target commit",
|
"git rebase": "Rebasing onto upstream",
|
||||||
"git rev-parse @{upstream}": "Resolving upstream commit",
|
|
||||||
"git rev-list": "Enumerating candidate commits",
|
|
||||||
"git clone": "Cloning git checkout",
|
"git clone": "Cloning git checkout",
|
||||||
"preflight worktree": "Preparing preflight worktree",
|
|
||||||
"preflight cleanup": "Cleaning preflight worktree",
|
|
||||||
"deps install": "Installing dependencies",
|
"deps install": "Installing dependencies",
|
||||||
build: "Building",
|
build: "Building",
|
||||||
"ui:build": "Building UI",
|
"ui:build": "Building UI",
|
||||||
|
|||||||
@@ -34,7 +34,6 @@ export type AgentIdentity = {
|
|||||||
creature?: string;
|
creature?: string;
|
||||||
vibe?: string;
|
vibe?: string;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
avatar?: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
|
export function listAgentEntries(cfg: ClawdbotConfig): AgentEntry[] {
|
||||||
@@ -91,7 +90,6 @@ export function parseIdentityMarkdown(content: string): AgentIdentity {
|
|||||||
if (label === "creature") identity.creature = value;
|
if (label === "creature") identity.creature = value;
|
||||||
if (label === "vibe") identity.vibe = value;
|
if (label === "vibe") identity.vibe = value;
|
||||||
if (label === "theme") identity.theme = value;
|
if (label === "theme") identity.theme = value;
|
||||||
if (label === "avatar") identity.avatar = value;
|
|
||||||
}
|
}
|
||||||
return identity;
|
return identity;
|
||||||
}
|
}
|
||||||
@@ -101,14 +99,7 @@ export function loadAgentIdentity(workspace: string): AgentIdentity | null {
|
|||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(identityPath, "utf-8");
|
const content = fs.readFileSync(identityPath, "utf-8");
|
||||||
const parsed = parseIdentityMarkdown(content);
|
const parsed = parseIdentityMarkdown(content);
|
||||||
if (
|
if (!parsed.name && !parsed.emoji && !parsed.theme && !parsed.creature && !parsed.vibe) {
|
||||||
!parsed.name &&
|
|
||||||
!parsed.emoji &&
|
|
||||||
!parsed.theme &&
|
|
||||||
!parsed.creature &&
|
|
||||||
!parsed.vibe &&
|
|
||||||
!parsed.avatar
|
|
||||||
) {
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return parsed;
|
return parsed;
|
||||||
|
|||||||
@@ -23,33 +23,21 @@ describe("legacy config detection", () => {
|
|||||||
expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention");
|
expect(res.issues[0]?.path).toBe("routing.groupChat.requireMention");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
it("migrates routing.allowFrom to channels.whatsapp.allowFrom when whatsapp configured", async () => {
|
it("migrates routing.allowFrom to channels.whatsapp.allowFrom", async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const { migrateLegacyConfig } = await import("./config.js");
|
const { migrateLegacyConfig } = await import("./config.js");
|
||||||
const res = migrateLegacyConfig({
|
const res = migrateLegacyConfig({
|
||||||
routing: { allowFrom: ["+15555550123"] },
|
routing: { allowFrom: ["+15555550123"] },
|
||||||
channels: { whatsapp: {} },
|
|
||||||
});
|
});
|
||||||
expect(res.changes).toContain("Moved routing.allowFrom → channels.whatsapp.allowFrom.");
|
expect(res.changes).toContain("Moved routing.allowFrom → channels.whatsapp.allowFrom.");
|
||||||
expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
|
expect(res.config?.channels?.whatsapp?.allowFrom).toEqual(["+15555550123"]);
|
||||||
expect(res.config?.routing?.allowFrom).toBeUndefined();
|
expect(res.config?.routing?.allowFrom).toBeUndefined();
|
||||||
});
|
});
|
||||||
it("drops routing.allowFrom when whatsapp missing", async () => {
|
it("migrates routing.groupChat.requireMention to channels whatsapp/telegram/imessage groups", async () => {
|
||||||
vi.resetModules();
|
|
||||||
const { migrateLegacyConfig } = await import("./config.js");
|
|
||||||
const res = migrateLegacyConfig({
|
|
||||||
routing: { allowFrom: ["+15555550123"] },
|
|
||||||
});
|
|
||||||
expect(res.changes).toContain("Removed routing.allowFrom (channels.whatsapp not configured).");
|
|
||||||
expect(res.config?.channels?.whatsapp).toBeUndefined();
|
|
||||||
expect(res.config?.routing?.allowFrom).toBeUndefined();
|
|
||||||
});
|
|
||||||
it("migrates routing.groupChat.requireMention to channels whatsapp/telegram/imessage groups when whatsapp configured", async () => {
|
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const { migrateLegacyConfig } = await import("./config.js");
|
const { migrateLegacyConfig } = await import("./config.js");
|
||||||
const res = migrateLegacyConfig({
|
const res = migrateLegacyConfig({
|
||||||
routing: { groupChat: { requireMention: false } },
|
routing: { groupChat: { requireMention: false } },
|
||||||
channels: { whatsapp: {} },
|
|
||||||
});
|
});
|
||||||
expect(res.changes).toContain(
|
expect(res.changes).toContain(
|
||||||
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
|
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
|
||||||
@@ -65,26 +53,6 @@ describe("legacy config detection", () => {
|
|||||||
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false);
|
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false);
|
||||||
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
|
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
|
||||||
});
|
});
|
||||||
it("migrates routing.groupChat.requireMention to telegram/imessage when whatsapp missing", async () => {
|
|
||||||
vi.resetModules();
|
|
||||||
const { migrateLegacyConfig } = await import("./config.js");
|
|
||||||
const res = migrateLegacyConfig({
|
|
||||||
routing: { groupChat: { requireMention: false } },
|
|
||||||
});
|
|
||||||
expect(res.changes).toContain(
|
|
||||||
'Moved routing.groupChat.requireMention → channels.telegram.groups."*".requireMention.',
|
|
||||||
);
|
|
||||||
expect(res.changes).toContain(
|
|
||||||
'Moved routing.groupChat.requireMention → channels.imessage.groups."*".requireMention.',
|
|
||||||
);
|
|
||||||
expect(res.changes).not.toContain(
|
|
||||||
'Moved routing.groupChat.requireMention → channels.whatsapp.groups."*".requireMention.',
|
|
||||||
);
|
|
||||||
expect(res.config?.channels?.whatsapp).toBeUndefined();
|
|
||||||
expect(res.config?.channels?.telegram?.groups?.["*"]?.requireMention).toBe(false);
|
|
||||||
expect(res.config?.channels?.imessage?.groups?.["*"]?.requireMention).toBe(false);
|
|
||||||
expect(res.config?.routing?.groupChat?.requireMention).toBeUndefined();
|
|
||||||
});
|
|
||||||
it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => {
|
it("migrates routing.groupChat.mentionPatterns to messages.groupChat.mentionPatterns", async () => {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
const { migrateLegacyConfig } = await import("./config.js");
|
const { migrateLegacyConfig } = await import("./config.js");
|
||||||
|
|||||||
@@ -156,16 +156,11 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
|||||||
const allowFrom = (routing as Record<string, unknown>).allowFrom;
|
const allowFrom = (routing as Record<string, unknown>).allowFrom;
|
||||||
if (allowFrom === undefined) return;
|
if (allowFrom === undefined) return;
|
||||||
|
|
||||||
const channels = getRecord(raw.channels);
|
const channels = ensureRecord(raw, "channels");
|
||||||
const whatsapp = channels ? getRecord(channels.whatsapp) : null;
|
const whatsapp =
|
||||||
if (!whatsapp) {
|
channels.whatsapp && typeof channels.whatsapp === "object"
|
||||||
delete (routing as Record<string, unknown>).allowFrom;
|
? (channels.whatsapp as Record<string, unknown>)
|
||||||
if (Object.keys(routing as Record<string, unknown>).length === 0) {
|
: {};
|
||||||
delete raw.routing;
|
|
||||||
}
|
|
||||||
changes.push("Removed routing.allowFrom (channels.whatsapp not configured).");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (whatsapp.allowFrom === undefined) {
|
if (whatsapp.allowFrom === undefined) {
|
||||||
whatsapp.allowFrom = allowFrom;
|
whatsapp.allowFrom = allowFrom;
|
||||||
@@ -178,8 +173,8 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
|||||||
if (Object.keys(routing as Record<string, unknown>).length === 0) {
|
if (Object.keys(routing as Record<string, unknown>).length === 0) {
|
||||||
delete raw.routing;
|
delete raw.routing;
|
||||||
}
|
}
|
||||||
channels!.whatsapp = whatsapp;
|
channels.whatsapp = whatsapp;
|
||||||
raw.channels = channels!;
|
raw.channels = channels;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -198,11 +193,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
|||||||
if (requireMention === undefined) return;
|
if (requireMention === undefined) return;
|
||||||
|
|
||||||
const channels = ensureRecord(raw, "channels");
|
const channels = ensureRecord(raw, "channels");
|
||||||
const applyTo = (
|
const applyTo = (key: "whatsapp" | "telegram" | "imessage") => {
|
||||||
key: "whatsapp" | "telegram" | "imessage",
|
|
||||||
options?: { requireExisting?: boolean },
|
|
||||||
) => {
|
|
||||||
if (options?.requireExisting && !isRecord(channels[key])) return;
|
|
||||||
const section =
|
const section =
|
||||||
channels[key] && typeof channels[key] === "object"
|
channels[key] && typeof channels[key] === "object"
|
||||||
? (channels[key] as Record<string, unknown>)
|
? (channels[key] as Record<string, unknown>)
|
||||||
@@ -231,7 +222,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
applyTo("whatsapp", { requireExisting: true });
|
applyTo("whatsapp");
|
||||||
applyTo("telegram");
|
applyTo("telegram");
|
||||||
applyTo("imessage");
|
applyTo("imessage");
|
||||||
|
|
||||||
|
|||||||
@@ -154,6 +154,4 @@ export type IdentityConfig = {
|
|||||||
name?: string;
|
name?: string;
|
||||||
theme?: string;
|
theme?: string;
|
||||||
emoji?: string;
|
emoji?: string;
|
||||||
/** Path to a custom avatar image (relative to workspace or absolute). */
|
|
||||||
avatar?: string;
|
|
||||||
};
|
};
|
||||||
@@ -113,30 +113,6 @@ function isChatGPTUsageLimitErrorMessage(raw: string): boolean {
|
|||||||
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
|
return msg.includes("hit your chatgpt usage limit") && msg.includes("try again in");
|
||||||
}
|
}
|
||||||
|
|
||||||
function isInstructionsRequiredError(error: string): boolean {
|
|
||||||
return /instructions are required/i.test(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isOpenAIReasoningSequenceError(error: string): boolean {
|
|
||||||
const msg = error.toLowerCase();
|
|
||||||
return msg.includes("required following item") && msg.includes("reasoning");
|
|
||||||
}
|
|
||||||
|
|
||||||
function isToolNonceRefusal(error: string): boolean {
|
|
||||||
const msg = error.toLowerCase();
|
|
||||||
if (!msg.includes("nonce")) return false;
|
|
||||||
return (
|
|
||||||
msg.includes("token") ||
|
|
||||||
msg.includes("secret") ||
|
|
||||||
msg.includes("local file") ||
|
|
||||||
msg.includes("disclose") ||
|
|
||||||
msg.includes("can't help") ||
|
|
||||||
msg.includes("can’t help") ||
|
|
||||||
msg.includes("can't comply") ||
|
|
||||||
msg.includes("can’t comply")
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isMissingProfileError(error: string): boolean {
|
function isMissingProfileError(error: string): boolean {
|
||||||
return /no credentials found for profile/i.test(error);
|
return /no credentials found for profile/i.test(error);
|
||||||
}
|
}
|
||||||
@@ -880,27 +856,6 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
|
|||||||
logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
|
logProgress(`${progressLabel}: skip (chatgpt usage limit)`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (model.provider === "openai-codex" && isInstructionsRequiredError(message)) {
|
|
||||||
skippedCount += 1;
|
|
||||||
logProgress(`${progressLabel}: skip (instructions required)`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(model.provider === "openai" || model.provider === "openai-codex") &&
|
|
||||||
isOpenAIReasoningSequenceError(message)
|
|
||||||
) {
|
|
||||||
skippedCount += 1;
|
|
||||||
logProgress(`${progressLabel}: skip (openai reasoning sequence error)`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(model.provider === "openai" || model.provider === "openai-codex") &&
|
|
||||||
isToolNonceRefusal(message)
|
|
||||||
) {
|
|
||||||
skippedCount += 1;
|
|
||||||
logProgress(`${progressLabel}: skip (tool probe refusal)`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (isMissingProfileError(message)) {
|
if (isMissingProfileError(message)) {
|
||||||
skippedCount += 1;
|
skippedCount += 1;
|
||||||
logProgress(`${progressLabel}: skip (missing auth profile)`);
|
logProgress(`${progressLabel}: skip (missing auth profile)`);
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
normalizeSafeBins,
|
normalizeSafeBins,
|
||||||
resolveCommandResolution,
|
resolveCommandResolution,
|
||||||
resolveExecApprovals,
|
resolveExecApprovals,
|
||||||
resolveExecApprovalsFromFile,
|
|
||||||
type ExecAllowlistEntry,
|
type ExecAllowlistEntry,
|
||||||
} from "./exec-approvals.js";
|
} from "./exec-approvals.js";
|
||||||
|
|
||||||
@@ -228,32 +227,3 @@ describe("exec approvals wildcard agent", () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("exec approvals default agent migration", () => {
|
|
||||||
it("migrates legacy default agent entries to main", () => {
|
|
||||||
const file = {
|
|
||||||
version: 1,
|
|
||||||
agents: {
|
|
||||||
default: { allowlist: [{ pattern: "/bin/legacy" }] },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const resolved = resolveExecApprovalsFromFile({ file });
|
|
||||||
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/legacy"]);
|
|
||||||
expect(resolved.file.agents?.default).toBeUndefined();
|
|
||||||
expect(resolved.file.agents?.main?.allowlist?.[0]?.pattern).toBe("/bin/legacy");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("prefers main agent settings when both main and default exist", () => {
|
|
||||||
const file = {
|
|
||||||
version: 1,
|
|
||||||
agents: {
|
|
||||||
main: { ask: "always", allowlist: [{ pattern: "/bin/main" }] },
|
|
||||||
default: { ask: "off", allowlist: [{ pattern: "/bin/legacy" }] },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const resolved = resolveExecApprovalsFromFile({ file });
|
|
||||||
expect(resolved.agent.ask).toBe("always");
|
|
||||||
expect(resolved.allowlist.map((entry) => entry.pattern)).toEqual(["/bin/main", "/bin/legacy"]);
|
|
||||||
expect(resolved.file.agents?.default).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -4,8 +4,6 @@ import net from "node:net";
|
|||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { DEFAULT_AGENT_ID } from "../routing/session-key.js";
|
|
||||||
|
|
||||||
export type ExecHost = "sandbox" | "gateway" | "node";
|
export type ExecHost = "sandbox" | "gateway" | "node";
|
||||||
export type ExecSecurity = "deny" | "allowlist" | "full";
|
export type ExecSecurity = "deny" | "allowlist" | "full";
|
||||||
export type ExecAsk = "off" | "on-miss" | "always";
|
export type ExecAsk = "off" | "on-miss" | "always";
|
||||||
@@ -86,35 +84,6 @@ export function resolveExecApprovalsSocketPath(): string {
|
|||||||
return expandHome(DEFAULT_SOCKET);
|
return expandHome(DEFAULT_SOCKET);
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeAllowlistPattern(value: string | undefined): string | null {
|
|
||||||
const trimmed = value?.trim() ?? "";
|
|
||||||
return trimmed ? trimmed.toLowerCase() : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeLegacyAgent(
|
|
||||||
current: ExecApprovalsAgent,
|
|
||||||
legacy: ExecApprovalsAgent,
|
|
||||||
): ExecApprovalsAgent {
|
|
||||||
const allowlist: ExecAllowlistEntry[] = [];
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const pushEntry = (entry: ExecAllowlistEntry) => {
|
|
||||||
const key = normalizeAllowlistPattern(entry.pattern);
|
|
||||||
if (!key || seen.has(key)) return;
|
|
||||||
seen.add(key);
|
|
||||||
allowlist.push(entry);
|
|
||||||
};
|
|
||||||
for (const entry of current.allowlist ?? []) pushEntry(entry);
|
|
||||||
for (const entry of legacy.allowlist ?? []) pushEntry(entry);
|
|
||||||
|
|
||||||
return {
|
|
||||||
security: current.security ?? legacy.security,
|
|
||||||
ask: current.ask ?? legacy.ask,
|
|
||||||
askFallback: current.askFallback ?? legacy.askFallback,
|
|
||||||
autoAllowSkills: current.autoAllowSkills ?? legacy.autoAllowSkills,
|
|
||||||
allowlist: allowlist.length > 0 ? allowlist : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureDir(filePath: string) {
|
function ensureDir(filePath: string) {
|
||||||
const dir = path.dirname(filePath);
|
const dir = path.dirname(filePath);
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
@@ -123,13 +92,6 @@ function ensureDir(filePath: string) {
|
|||||||
export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
|
export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFile {
|
||||||
const socketPath = file.socket?.path?.trim();
|
const socketPath = file.socket?.path?.trim();
|
||||||
const token = file.socket?.token?.trim();
|
const token = file.socket?.token?.trim();
|
||||||
const agents = { ...file.agents };
|
|
||||||
const legacyDefault = agents.default;
|
|
||||||
if (legacyDefault) {
|
|
||||||
const main = agents[DEFAULT_AGENT_ID];
|
|
||||||
agents[DEFAULT_AGENT_ID] = main ? mergeLegacyAgent(main, legacyDefault) : legacyDefault;
|
|
||||||
delete agents.default;
|
|
||||||
}
|
|
||||||
const normalized: ExecApprovalsFile = {
|
const normalized: ExecApprovalsFile = {
|
||||||
version: 1,
|
version: 1,
|
||||||
socket: {
|
socket: {
|
||||||
@@ -142,7 +104,7 @@ export function normalizeExecApprovals(file: ExecApprovalsFile): ExecApprovalsFi
|
|||||||
askFallback: file.defaults?.askFallback,
|
askFallback: file.defaults?.askFallback,
|
||||||
autoAllowSkills: file.defaults?.autoAllowSkills,
|
autoAllowSkills: file.defaults?.autoAllowSkills,
|
||||||
},
|
},
|
||||||
agents,
|
agents: file.agents ?? {},
|
||||||
};
|
};
|
||||||
return normalized;
|
return normalized;
|
||||||
}
|
}
|
||||||
@@ -269,7 +231,7 @@ export function resolveExecApprovalsFromFile(params: {
|
|||||||
}): ExecApprovalsResolved {
|
}): ExecApprovalsResolved {
|
||||||
const file = normalizeExecApprovals(params.file);
|
const file = normalizeExecApprovals(params.file);
|
||||||
const defaults = file.defaults ?? {};
|
const defaults = file.defaults ?? {};
|
||||||
const agentKey = params.agentId ?? DEFAULT_AGENT_ID;
|
const agentKey = params.agentId ?? "default";
|
||||||
const agent = file.agents?.[agentKey] ?? {};
|
const agent = file.agents?.[agentKey] ?? {};
|
||||||
const wildcard = file.agents?.["*"] ?? {};
|
const wildcard = file.agents?.["*"] ?? {};
|
||||||
const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY;
|
const fallbackSecurity = params.overrides?.security ?? DEFAULT_SECURITY;
|
||||||
@@ -734,7 +696,7 @@ export function recordAllowlistUse(
|
|||||||
command: string,
|
command: string,
|
||||||
resolvedPath?: string,
|
resolvedPath?: string,
|
||||||
) {
|
) {
|
||||||
const target = agentId ?? DEFAULT_AGENT_ID;
|
const target = agentId ?? "default";
|
||||||
const agents = approvals.agents ?? {};
|
const agents = approvals.agents ?? {};
|
||||||
const existing = agents[target] ?? {};
|
const existing = agents[target] ?? {};
|
||||||
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
|
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
|
||||||
@@ -758,7 +720,7 @@ export function addAllowlistEntry(
|
|||||||
agentId: string | undefined,
|
agentId: string | undefined,
|
||||||
pattern: string,
|
pattern: string,
|
||||||
) {
|
) {
|
||||||
const target = agentId ?? DEFAULT_AGENT_ID;
|
const target = agentId ?? "default";
|
||||||
const agents = approvals.agents ?? {};
|
const agents = approvals.agents ?? {};
|
||||||
const existing = agents[target] ?? {};
|
const existing = agents[target] ?? {};
|
||||||
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
|
const allowlist = Array.isArray(existing.allowlist) ? existing.allowlist : [];
|
||||||
|
|||||||
@@ -74,9 +74,7 @@ describe("runGatewayUpdate", () => {
|
|||||||
stdout: "origin/main",
|
stdout: "origin/main",
|
||||||
},
|
},
|
||||||
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
|
[`git -C ${tempDir} fetch --all --prune --tags`]: { stdout: "" },
|
||||||
[`git -C ${tempDir} rev-parse @{upstream}`]: { stdout: "upstream123" },
|
[`git -C ${tempDir} rebase @{upstream}`]: { code: 1, stderr: "conflict" },
|
||||||
[`git -C ${tempDir} rev-list --max-count=10 upstream123`]: { stdout: "upstream123\n" },
|
|
||||||
[`git -C ${tempDir} rebase upstream123`]: { code: 1, stderr: "conflict" },
|
|
||||||
[`git -C ${tempDir} rebase --abort`]: { stdout: "" },
|
[`git -C ${tempDir} rebase --abort`]: { stdout: "" },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
+1
-147
@@ -1,5 +1,4 @@
|
|||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import os from "node:os";
|
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
|
|
||||||
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
|
import { type CommandOptions, runCommandWithTimeout } from "../process/exec.js";
|
||||||
@@ -64,7 +63,6 @@ type UpdateRunnerOptions = {
|
|||||||
|
|
||||||
const DEFAULT_TIMEOUT_MS = 20 * 60_000;
|
const DEFAULT_TIMEOUT_MS = 20 * 60_000;
|
||||||
const MAX_LOG_CHARS = 8000;
|
const MAX_LOG_CHARS = 8000;
|
||||||
const PREFLIGHT_MAX_COMMITS = 10;
|
|
||||||
const START_DIRS = ["cwd", "argv1", "process"];
|
const START_DIRS = ["cwd", "argv1", "process"];
|
||||||
|
|
||||||
function normalizeDir(value?: string | null) {
|
function normalizeDir(value?: string | null) {
|
||||||
@@ -422,152 +420,8 @@ export async function runGatewayUpdate(opts: UpdateRunnerOptions = {}): Promise<
|
|||||||
);
|
);
|
||||||
steps.push(fetchStep);
|
steps.push(fetchStep);
|
||||||
|
|
||||||
const upstreamShaStep = await runStep(
|
|
||||||
step(
|
|
||||||
"git rev-parse @{upstream}",
|
|
||||||
["git", "-C", gitRoot, "rev-parse", "@{upstream}"],
|
|
||||||
gitRoot,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
steps.push(upstreamShaStep);
|
|
||||||
const upstreamSha = upstreamShaStep.stdoutTail?.trim();
|
|
||||||
if (!upstreamShaStep.stdoutTail || !upstreamSha) {
|
|
||||||
return {
|
|
||||||
status: "error",
|
|
||||||
mode: "git",
|
|
||||||
root: gitRoot,
|
|
||||||
reason: "no-upstream-sha",
|
|
||||||
before: { sha: beforeSha, version: beforeVersion },
|
|
||||||
steps,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const revListStep = await runStep(
|
|
||||||
step(
|
|
||||||
"git rev-list",
|
|
||||||
["git", "-C", gitRoot, "rev-list", `--max-count=${PREFLIGHT_MAX_COMMITS}`, upstreamSha],
|
|
||||||
gitRoot,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
steps.push(revListStep);
|
|
||||||
if (revListStep.exitCode !== 0) {
|
|
||||||
return {
|
|
||||||
status: "error",
|
|
||||||
mode: "git",
|
|
||||||
root: gitRoot,
|
|
||||||
reason: "preflight-revlist-failed",
|
|
||||||
before: { sha: beforeSha, version: beforeVersion },
|
|
||||||
steps,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const candidates = (revListStep.stdoutTail ?? "")
|
|
||||||
.split("\n")
|
|
||||||
.map((line) => line.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
if (candidates.length === 0) {
|
|
||||||
return {
|
|
||||||
status: "error",
|
|
||||||
mode: "git",
|
|
||||||
root: gitRoot,
|
|
||||||
reason: "preflight-no-candidates",
|
|
||||||
before: { sha: beforeSha, version: beforeVersion },
|
|
||||||
steps,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const manager = await detectPackageManager(gitRoot);
|
|
||||||
const preflightRoot = await fs.mkdtemp(path.join(os.tmpdir(), "clawdbot-update-preflight-"));
|
|
||||||
const worktreeDir = path.join(preflightRoot, "worktree");
|
|
||||||
const worktreeStep = await runStep(
|
|
||||||
step(
|
|
||||||
"preflight worktree",
|
|
||||||
["git", "-C", gitRoot, "worktree", "add", "--detach", worktreeDir, upstreamSha],
|
|
||||||
gitRoot,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
steps.push(worktreeStep);
|
|
||||||
if (worktreeStep.exitCode !== 0) {
|
|
||||||
await fs.rm(preflightRoot, { recursive: true, force: true }).catch(() => {});
|
|
||||||
return {
|
|
||||||
status: "error",
|
|
||||||
mode: "git",
|
|
||||||
root: gitRoot,
|
|
||||||
reason: "preflight-worktree-failed",
|
|
||||||
before: { sha: beforeSha, version: beforeVersion },
|
|
||||||
steps,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
let selectedSha: string | null = null;
|
|
||||||
try {
|
|
||||||
for (const sha of candidates) {
|
|
||||||
const shortSha = sha.slice(0, 8);
|
|
||||||
const checkoutStep = await runStep(
|
|
||||||
step(
|
|
||||||
`preflight checkout (${shortSha})`,
|
|
||||||
["git", "-C", worktreeDir, "checkout", "--detach", sha],
|
|
||||||
worktreeDir,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
steps.push(checkoutStep);
|
|
||||||
if (checkoutStep.exitCode !== 0) continue;
|
|
||||||
|
|
||||||
const depsStep = await runStep(
|
|
||||||
step(`preflight deps install (${shortSha})`, managerInstallArgs(manager), worktreeDir),
|
|
||||||
);
|
|
||||||
steps.push(depsStep);
|
|
||||||
if (depsStep.exitCode !== 0) continue;
|
|
||||||
|
|
||||||
const lintStep = await runStep(
|
|
||||||
step(`preflight lint (${shortSha})`, managerScriptArgs(manager, "lint"), worktreeDir),
|
|
||||||
);
|
|
||||||
steps.push(lintStep);
|
|
||||||
if (lintStep.exitCode !== 0) continue;
|
|
||||||
|
|
||||||
const buildStep = await runStep(
|
|
||||||
step(`preflight build (${shortSha})`, managerScriptArgs(manager, "build"), worktreeDir),
|
|
||||||
);
|
|
||||||
steps.push(buildStep);
|
|
||||||
if (buildStep.exitCode !== 0) continue;
|
|
||||||
|
|
||||||
selectedSha = sha;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
const removeStep = await runStep(
|
|
||||||
step(
|
|
||||||
"preflight cleanup",
|
|
||||||
["git", "-C", gitRoot, "worktree", "remove", "--force", worktreeDir],
|
|
||||||
gitRoot,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
steps.push(removeStep);
|
|
||||||
await runCommand(["git", "-C", gitRoot, "worktree", "prune"], {
|
|
||||||
cwd: gitRoot,
|
|
||||||
timeoutMs,
|
|
||||||
}).catch(() => null);
|
|
||||||
await fs.rm(preflightRoot, { recursive: true, force: true }).catch(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!selectedSha) {
|
|
||||||
return {
|
|
||||||
status: "error",
|
|
||||||
mode: "git",
|
|
||||||
root: gitRoot,
|
|
||||||
reason: "preflight-no-good-commit",
|
|
||||||
before: { sha: beforeSha, version: beforeVersion },
|
|
||||||
steps,
|
|
||||||
durationMs: Date.now() - startedAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const rebaseStep = await runStep(
|
const rebaseStep = await runStep(
|
||||||
step("git rebase", ["git", "-C", gitRoot, "rebase", selectedSha], gitRoot),
|
step("git rebase", ["git", "-C", gitRoot, "rebase", "@{upstream}"], gitRoot),
|
||||||
);
|
);
|
||||||
steps.push(rebaseStep);
|
steps.push(rebaseStep);
|
||||||
if (rebaseStep.exitCode !== 0) {
|
if (rebaseStep.exitCode !== 0) {
|
||||||
|
|||||||
+3
-12
@@ -1,4 +1,4 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
import { resetInboundDedupe } from "../auto-reply/reply/inbound-dedupe.js";
|
||||||
import { createTelegramBot } from "./bot.js";
|
import { createTelegramBot } from "./bot.js";
|
||||||
|
|
||||||
@@ -89,7 +89,6 @@ vi.mock("grammy", () => ({
|
|||||||
const sequentializeMiddleware = vi.fn();
|
const sequentializeMiddleware = vi.fn();
|
||||||
const sequentializeSpy = vi.fn(() => sequentializeMiddleware);
|
const sequentializeSpy = vi.fn(() => sequentializeMiddleware);
|
||||||
let _sequentializeKey: ((ctx: unknown) => string) | undefined;
|
let _sequentializeKey: ((ctx: unknown) => string) | undefined;
|
||||||
let originalTz: string | undefined;
|
|
||||||
vi.mock("@grammyjs/runner", () => ({
|
vi.mock("@grammyjs/runner", () => ({
|
||||||
sequentialize: (keyFn: (ctx: unknown) => string) => {
|
sequentialize: (keyFn: (ctx: unknown) => string) => {
|
||||||
_sequentializeKey = keyFn;
|
_sequentializeKey = keyFn;
|
||||||
@@ -121,8 +120,6 @@ const getOnHandler = (event: string) => {
|
|||||||
|
|
||||||
describe("createTelegramBot", () => {
|
describe("createTelegramBot", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
originalTz = process.env.TZ;
|
|
||||||
process.env.TZ = "UTC";
|
|
||||||
resetInboundDedupe();
|
resetInboundDedupe();
|
||||||
loadConfig.mockReturnValue({
|
loadConfig.mockReturnValue({
|
||||||
channels: {
|
channels: {
|
||||||
@@ -141,10 +138,6 @@ describe("createTelegramBot", () => {
|
|||||||
_sequentializeKey = undefined;
|
_sequentializeKey = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
process.env.TZ = originalTz;
|
|
||||||
});
|
|
||||||
|
|
||||||
// groupPolicy tests
|
// groupPolicy tests
|
||||||
|
|
||||||
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
|
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
|
||||||
@@ -183,9 +176,7 @@ describe("createTelegramBot", () => {
|
|||||||
expect(payload.WasMentioned).toBe(true);
|
expect(payload.WasMentioned).toBe(true);
|
||||||
expect(payload.SenderName).toBe("Ada");
|
expect(payload.SenderName).toBe("Ada");
|
||||||
expect(payload.SenderId).toBe("9");
|
expect(payload.SenderId).toBe("9");
|
||||||
expect(payload.Body).toMatch(
|
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
|
||||||
/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
it("keeps group envelope headers stable (sender identity is separate)", async () => {
|
it("keeps group envelope headers stable (sender identity is separate)", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
@@ -226,7 +217,7 @@ describe("createTelegramBot", () => {
|
|||||||
expect(payload.SenderName).toBe("Ada Lovelace");
|
expect(payload.SenderName).toBe("Ada Lovelace");
|
||||||
expect(payload.SenderId).toBe("99");
|
expect(payload.SenderId).toBe("99");
|
||||||
expect(payload.SenderUsername).toBe("ada");
|
expect(payload.SenderUsername).toBe("ada");
|
||||||
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/);
|
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
|
||||||
});
|
});
|
||||||
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
|
|||||||
@@ -329,7 +329,7 @@ describe("createTelegramBot", () => {
|
|||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
const payload = replySpy.mock.calls[0][0];
|
const payload = replySpy.mock.calls[0][0];
|
||||||
expect(payload.Body).toMatch(
|
expect(payload.Body).toMatch(
|
||||||
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09 01:00 [^\]]+\]/,
|
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/,
|
||||||
);
|
);
|
||||||
expect(payload.Body).toContain("hello world");
|
expect(payload.Body).toContain("hello world");
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
+61
-77
@@ -451,7 +451,7 @@ describe("createTelegramBot", () => {
|
|||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
const payload = replySpy.mock.calls[0][0];
|
const payload = replySpy.mock.calls[0][0];
|
||||||
expect(payload.Body).toMatch(
|
expect(payload.Body).toMatch(
|
||||||
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09 01:00 [^\]]+\]/,
|
/^\[Telegram Ada Lovelace \(@ada_bot\) id:1234 (\+\d+[smhd] )?2025-01-09T00:00Z\]/,
|
||||||
);
|
);
|
||||||
expect(payload.Body).toContain("hello world");
|
expect(payload.Body).toContain("hello world");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -551,102 +551,86 @@ describe("createTelegramBot", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
|
it("accepts group messages when mentionPatterns match (without @botUsername)", async () => {
|
||||||
const originalTz = process.env.TZ;
|
|
||||||
process.env.TZ = "UTC";
|
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
replySpy.mockReset();
|
replySpy.mockReset();
|
||||||
|
|
||||||
try {
|
loadConfig.mockReturnValue({
|
||||||
loadConfig.mockReturnValue({
|
identity: { name: "Bert" },
|
||||||
identity: { name: "Bert" },
|
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
|
||||||
messages: { groupChat: { mentionPatterns: ["\\bbert\\b"] } },
|
channels: {
|
||||||
channels: {
|
telegram: {
|
||||||
telegram: {
|
groupPolicy: "open",
|
||||||
groupPolicy: "open",
|
groups: { "*": { requireMention: true } },
|
||||||
groups: { "*": { requireMention: true } },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
message: {
|
message: {
|
||||||
chat: { id: 7, type: "group", title: "Test Group" },
|
chat: { id: 7, type: "group", title: "Test Group" },
|
||||||
text: "bert: introduce yourself",
|
text: "bert: introduce yourself",
|
||||||
date: 1736380800,
|
date: 1736380800,
|
||||||
message_id: 1,
|
message_id: 1,
|
||||||
from: { id: 9, first_name: "Ada" },
|
from: { id: 9, first_name: "Ada" },
|
||||||
},
|
},
|
||||||
me: { username: "clawdbot_bot" },
|
me: { username: "clawdbot_bot" },
|
||||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
const payload = replySpy.mock.calls[0][0];
|
const payload = replySpy.mock.calls[0][0];
|
||||||
expectInboundContextContract(payload);
|
expectInboundContextContract(payload);
|
||||||
expect(payload.WasMentioned).toBe(true);
|
expect(payload.WasMentioned).toBe(true);
|
||||||
expect(payload.Body).toMatch(
|
expect(payload.Body).toMatch(/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
|
||||||
/^\[Telegram Test Group id:7 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
|
expect(payload.SenderName).toBe("Ada");
|
||||||
);
|
expect(payload.SenderId).toBe("9");
|
||||||
expect(payload.SenderName).toBe("Ada");
|
|
||||||
expect(payload.SenderId).toBe("9");
|
|
||||||
} finally {
|
|
||||||
process.env.TZ = originalTz;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("includes sender identity in group envelope headers", async () => {
|
it("includes sender identity in group envelope headers", async () => {
|
||||||
const originalTz = process.env.TZ;
|
|
||||||
process.env.TZ = "UTC";
|
|
||||||
onSpy.mockReset();
|
onSpy.mockReset();
|
||||||
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
const replySpy = replyModule.__replySpy as unknown as ReturnType<typeof vi.fn>;
|
||||||
replySpy.mockReset();
|
replySpy.mockReset();
|
||||||
|
|
||||||
try {
|
loadConfig.mockReturnValue({
|
||||||
loadConfig.mockReturnValue({
|
channels: {
|
||||||
channels: {
|
telegram: {
|
||||||
telegram: {
|
groupPolicy: "open",
|
||||||
groupPolicy: "open",
|
groups: { "*": { requireMention: false } },
|
||||||
groups: { "*": { requireMention: false } },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
|
||||||
createTelegramBot({ token: "tok" });
|
createTelegramBot({ token: "tok" });
|
||||||
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
const handler = getOnHandler("message") as (ctx: Record<string, unknown>) => Promise<void>;
|
||||||
|
|
||||||
await handler({
|
await handler({
|
||||||
message: {
|
message: {
|
||||||
chat: { id: 42, type: "group", title: "Ops" },
|
chat: { id: 42, type: "group", title: "Ops" },
|
||||||
text: "hello",
|
text: "hello",
|
||||||
date: 1736380800,
|
date: 1736380800,
|
||||||
message_id: 2,
|
message_id: 2,
|
||||||
from: {
|
from: {
|
||||||
id: 99,
|
id: 99,
|
||||||
first_name: "Ada",
|
first_name: "Ada",
|
||||||
last_name: "Lovelace",
|
last_name: "Lovelace",
|
||||||
username: "ada",
|
username: "ada",
|
||||||
},
|
|
||||||
},
|
},
|
||||||
me: { username: "clawdbot_bot" },
|
},
|
||||||
getFile: async () => ({ download: async () => new Uint8Array() }),
|
me: { username: "clawdbot_bot" },
|
||||||
});
|
getFile: async () => ({ download: async () => new Uint8Array() }),
|
||||||
|
});
|
||||||
|
|
||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
expect(replySpy).toHaveBeenCalledTimes(1);
|
||||||
const payload = replySpy.mock.calls[0][0];
|
const payload = replySpy.mock.calls[0][0];
|
||||||
expectInboundContextContract(payload);
|
expectInboundContextContract(payload);
|
||||||
expect(payload.Body).toMatch(
|
expect(payload.Body).toMatch(/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09T00:00Z\]/);
|
||||||
/^\[Telegram Ops id:42 (\+\d+[smhd] )?2025-01-09 00:00 [^\]]+\]/,
|
expect(payload.SenderName).toBe("Ada Lovelace");
|
||||||
);
|
expect(payload.SenderId).toBe("99");
|
||||||
expect(payload.SenderName).toBe("Ada Lovelace");
|
expect(payload.SenderUsername).toBe("ada");
|
||||||
expect(payload.SenderId).toBe("99");
|
|
||||||
expect(payload.SenderUsername).toBe("ada");
|
|
||||||
} finally {
|
|
||||||
process.env.TZ = originalTz;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
it("reacts to mention-gated group messages when ackReaction is enabled", async () => {
|
||||||
|
|||||||
@@ -329,11 +329,11 @@ describe("web auto-reply", () => {
|
|||||||
const firstArgs = resolver.mock.calls[0][0];
|
const firstArgs = resolver.mock.calls[0][0];
|
||||||
const secondArgs = resolver.mock.calls[1][0];
|
const secondArgs = resolver.mock.calls[1][0];
|
||||||
expect(firstArgs.Body).toMatch(
|
expect(firstArgs.Body).toMatch(
|
||||||
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01 01:00 [^\]]+\] \[clawdbot\] first/,
|
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T00:00Z\] \[clawdbot\] first/,
|
||||||
);
|
);
|
||||||
expect(firstArgs.Body).not.toContain("second");
|
expect(firstArgs.Body).not.toContain("second");
|
||||||
expect(secondArgs.Body).toMatch(
|
expect(secondArgs.Body).toMatch(
|
||||||
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01 02:00 [^\]]+\] \[clawdbot\] second/,
|
/\[WhatsApp \+1 (\+\d+[smhd] )?2025-01-01T01:00Z\] \[clawdbot\] second/,
|
||||||
);
|
);
|
||||||
expect(secondArgs.Body).not.toContain("first");
|
expect(secondArgs.Body).not.toContain("first");
|
||||||
|
|
||||||
|
|||||||
@@ -89,12 +89,6 @@
|
|||||||
color: rgba(134, 142, 150, 1);
|
color: rgba(134, 142, 150, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Image avatar support */
|
|
||||||
img.chat-avatar {
|
|
||||||
object-fit: cover;
|
|
||||||
object-position: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Minimal Bubble Design - dynamic width based on content */
|
/* Minimal Bubble Design - dynamic width based on content */
|
||||||
.chat-bubble {
|
.chat-bubble {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|||||||
@@ -105,7 +105,7 @@ export function renderMessageGroup(
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderAvatar(role: string, avatarUrl?: string) {
|
function renderAvatar(role: string) {
|
||||||
const normalized = normalizeRoleForGrouping(role);
|
const normalized = normalizeRoleForGrouping(role);
|
||||||
const initial =
|
const initial =
|
||||||
normalized === "user"
|
normalized === "user"
|
||||||
@@ -123,12 +123,6 @@ function renderAvatar(role: string, avatarUrl?: string) {
|
|||||||
: normalized === "tool"
|
: normalized === "tool"
|
||||||
? "tool"
|
? "tool"
|
||||||
: "other";
|
: "other";
|
||||||
|
|
||||||
// If avatar URL is provided for assistant, show image
|
|
||||||
if (avatarUrl && normalized === "assistant") {
|
|
||||||
return html`<img class="chat-avatar ${className}" src="${avatarUrl}" alt="Assistant" />`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
return html`<div class="chat-avatar ${className}">${initial}</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const sectionIcons = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Section metadata
|
// Section metadata
|
||||||
export const SECTION_META: Record<string, { label: string; description: string }> = {
|
const SECTION_META: Record<string, { label: string; description: string }> = {
|
||||||
env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" },
|
env: { label: "Environment Variables", description: "Environment variables passed to the gateway process" },
|
||||||
update: { label: "Updates", description: "Auto-update settings and release channel" },
|
update: { label: "Updates", description: "Auto-update settings and release channel" },
|
||||||
agents: { label: "Agents", description: "Agent configurations, models, and identities" },
|
agents: { label: "Agents", description: "Agent configurations, models, and identities" },
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export { renderConfigForm, type ConfigFormProps, SECTION_META } from "./config-form.render";
|
export { renderConfigForm, type ConfigFormProps } from "./config-form.render";
|
||||||
export {
|
export {
|
||||||
analyzeConfigSchema,
|
analyzeConfigSchema,
|
||||||
type ConfigSchemaAnalysis,
|
type ConfigSchemaAnalysis,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { html, nothing } from "lit";
|
import { html, nothing } from "lit";
|
||||||
import type { ConfigUiHints } from "../types";
|
import type { ConfigUiHints } from "../types";
|
||||||
import { analyzeConfigSchema, renderConfigForm, SECTION_META } from "./config-form";
|
import { analyzeConfigSchema, renderConfigForm } from "./config-form";
|
||||||
import {
|
import {
|
||||||
hintForPath,
|
hintForPath,
|
||||||
humanize,
|
humanize,
|
||||||
|
|||||||
Reference in new issue
Block a user