Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
38841c71e0 | ||
|
|
ec1b430a2c | ||
|
|
3431d3d115 | ||
|
|
fe9e027d58 | ||
|
|
33d17957e5 | ||
|
|
25ae5f897e | ||
|
|
624ff09314 | ||
|
|
c8003ae472 | ||
|
|
6bf627bce8 | ||
|
|
08525435e0 | ||
|
|
544ca062a3 | ||
|
|
be9aa5494a |
No files matched your search
@@ -107,6 +107,7 @@
|
||||
- Bug investigations: read source code of relevant npm dependencies and all related local code before concluding; aim for high-confidence root cause.
|
||||
- Code style: add brief comments for tricky logic; keep files under ~500 LOC when feasible (split/refactor as needed).
|
||||
- Tool schema guardrails (google-antigravity): avoid `Type.Union` in tool input schemas; no `anyOf`/`oneOf`/`allOf`. Use `stringEnum`/`optionalStringEnum` (Type.Unsafe enum) for string lists, and `Type.Optional(...)` instead of `... | null`. Keep top-level tool schema as `type: "object"` with `properties`.
|
||||
- Tool schema guardrails: avoid raw `format` property names in tool schemas; some validators treat `format` as a reserved keyword and reject the schema.
|
||||
- When asked to open a “session” file, open the Pi session logs under `~/.clawdbot/agents/main/sessions/*.jsonl` (newest unless a specific ID is given), not the default `sessions.json`. If logs are needed from another machine, SSH via Tailscale and read the same path there.
|
||||
- Menubar dimming + restart flow mirrors Trimmy: use `scripts/restart-mac.sh` (kills all Clawdbot variants, runs `swift build`, packages, relaunches). Icon dimming depends on MenuBarExtraAccess wiring in AppMain; keep `appearsDisabled` updates intact when touching the status item.
|
||||
- Do not rebuild the macOS app over SSH; rebuilds must be run directly on the Mac.
|
||||
|
||||
+5
-1
@@ -29,6 +29,7 @@
|
||||
- Daemon: share profile/state-dir resolution across service helpers and honor `CLAWDBOT_STATE_DIR` for Windows task scripts.
|
||||
- Docs: clarify multi-gateway rescue bot guidance. (#969) — thanks @bjesuiter.
|
||||
- Agents: add Current Date & Time system prompt section with configurable time format (auto/12/24).
|
||||
- Agents: default to no narration for routine tool calls. (#1008) — thanks @cpojer.
|
||||
- Tools: normalize Slack/Discord message timestamps with `timestampMs`/`timestampUtc` while keeping raw provider fields.
|
||||
- macOS: add `system.which` for prompt-free remote skill discovery (with gateway fallback to `system.run`).
|
||||
- Docs: add Date & Time guide and update prompt/timezone configuration docs.
|
||||
@@ -49,6 +50,8 @@
|
||||
|
||||
### Fixes
|
||||
- WhatsApp: default response prefix only for self-chat, using identity name when set.
|
||||
- Auth: merge main auth profiles into per-agent stores for sub-agents and document inheritance. (#1013) — thanks @marcmarg.
|
||||
- Agents: avoid JSON Schema `format` collisions in tool params by renaming snapshot format fields. (#1013) — thanks @marcmarg.
|
||||
- Fix: make `clawdbot update` auto-update global installs when installed via a package manager.
|
||||
- Fix: list model picker entries as provider/model pairs for explicit selection. (#970) — thanks @mcinteerj.
|
||||
- Fix: align OpenAI image-gen defaults with DALL-E 3 standard quality and document output formats. (#880) — thanks @mkbehr.
|
||||
@@ -61,7 +64,6 @@
|
||||
- Fix: refactor session store updates, add chat.inject, and harden subagent cleanup flow. (#944) — thanks @tyler6204.
|
||||
- Fix: clean up suspended CLI processes across backends. (#978) — thanks @Nachx639.
|
||||
- Fix: support MiniMax coding plan usage responses with `model_remains`/`current_interval_*` payloads.
|
||||
- Fix: honor message tool channel for duplicate suppression (prefer `NO_REPLY` after `message` tool sends). (#1053) — thanks @sashcatanzarite.
|
||||
- Fix: suppress WhatsApp pairing replies for historical catch-up DMs on initial link. (#904)
|
||||
- Browser: extension mode recovers when only one tab is attached (stale targetId fallback).
|
||||
- Browser: fix `tab not found` for extension relay snapshots/actions when Playwright blocks `newCDPSession` (use the single available Page).
|
||||
@@ -72,7 +74,9 @@
|
||||
- Fix: allow local Tailscale Serve hostnames without treating tailnet clients as direct. (#885) — thanks @oswalpalash.
|
||||
- Fix: reset sessions after role-ordering conflicts to recover from consecutive user turns. (#998)
|
||||
- Fix: keep background exec aborts from killing backgrounded sessions while honoring timeouts.
|
||||
- Fix: use local auth for gateway security probe unless remote mode has a URL. (#1011) — thanks @ivanrvpereira.
|
||||
- Discord: truncate skill command descriptions for slash command limits. (#1018) — thanks @evalexpr.
|
||||
- macOS: resolve gateway token/password using config mode/remote URL, and warn when `launchctl setenv` overrides config. (#1022, #1021) — thanks @kkarimi.
|
||||
|
||||
## 2026.1.14-1
|
||||
|
||||
|
||||
@@ -257,30 +257,8 @@ final class AppState {
|
||||
|
||||
let configRoot = ClawdbotConfigFile.loadDict()
|
||||
let configGateway = configRoot["gateway"] as? [String: Any]
|
||||
let configModeRaw = (configGateway?["mode"] as? String)?.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let configMode: ConnectionMode? = switch configModeRaw {
|
||||
case "local":
|
||||
.local
|
||||
case "remote":
|
||||
.remote
|
||||
default:
|
||||
nil
|
||||
}
|
||||
let configRemoteUrl = (configGateway?["remote"] as? [String: Any])?["url"] as? String
|
||||
let configHasRemoteUrl = !(configRemoteUrl?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.isEmpty ?? true)
|
||||
|
||||
let storedMode = UserDefaults.standard.string(forKey: connectionModeKey)
|
||||
let resolvedConnectionMode: ConnectionMode = if let configMode {
|
||||
configMode
|
||||
} else if configHasRemoteUrl {
|
||||
.remote
|
||||
} else if let storedMode {
|
||||
ConnectionMode(rawValue: storedMode) ?? .local
|
||||
} else {
|
||||
onboardingSeen ? .local : .unconfigured
|
||||
}
|
||||
let resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
||||
self.connectionMode = resolvedConnectionMode
|
||||
|
||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
|
||||
@@ -385,14 +385,8 @@ enum CommandResolver {
|
||||
}
|
||||
|
||||
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
||||
let modeRaw = defaults.string(forKey: connectionModeKey)
|
||||
let mode: AppState.ConnectionMode
|
||||
if let modeRaw {
|
||||
mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
|
||||
} else {
|
||||
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
|
||||
mode = seen ? .local : .unconfigured
|
||||
}
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
||||
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
enum EffectiveConnectionModeSource: Sendable, Equatable {
|
||||
case configMode
|
||||
case configRemoteURL
|
||||
case userDefaults
|
||||
case onboarding
|
||||
}
|
||||
|
||||
struct EffectiveConnectionMode: Sendable, Equatable {
|
||||
let mode: AppState.ConnectionMode
|
||||
let source: EffectiveConnectionModeSource
|
||||
}
|
||||
|
||||
enum ConnectionModeResolver {
|
||||
static func resolve(
|
||||
root: [String: Any],
|
||||
defaults: UserDefaults = .standard) -> EffectiveConnectionMode
|
||||
{
|
||||
let gateway = root["gateway"] as? [String: Any]
|
||||
let configModeRaw = (gateway?["mode"] as? String) ?? ""
|
||||
let configMode = configModeRaw
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
|
||||
switch configMode {
|
||||
case "local":
|
||||
return EffectiveConnectionMode(mode: .local, source: .configMode)
|
||||
case "remote":
|
||||
return EffectiveConnectionMode(mode: .remote, source: .configMode)
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
let remoteURLRaw = ((gateway?["remote"] as? [String: Any])?["url"] as? String) ?? ""
|
||||
let remoteURL = remoteURLRaw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !remoteURL.isEmpty {
|
||||
return EffectiveConnectionMode(mode: .remote, source: .configRemoteURL)
|
||||
}
|
||||
|
||||
if let storedModeRaw = defaults.string(forKey: connectionModeKey) {
|
||||
let storedMode = AppState.ConnectionMode(rawValue: storedModeRaw) ?? .local
|
||||
return EffectiveConnectionMode(mode: storedMode, source: .userDefaults)
|
||||
}
|
||||
|
||||
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
|
||||
return EffectiveConnectionMode(mode: seen ? .local : .unconfigured, source: .onboarding)
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import ConcurrencyExtras
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@@ -16,6 +17,13 @@ actor GatewayEndpointStore {
|
||||
static let shared = GatewayEndpointStore()
|
||||
private static let supportedBindModes: Set<String> = ["loopback", "tailnet", "lan", "auto"]
|
||||
private static let remoteConnectingDetail = "Connecting to remote gateway…"
|
||||
private static let staticLogger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
||||
private enum EnvOverrideWarningKind: Sendable {
|
||||
case token
|
||||
case password
|
||||
}
|
||||
|
||||
private static let envOverrideWarnings = LockIsolated((token: false, password: false))
|
||||
|
||||
struct Deps: Sendable {
|
||||
let mode: @Sendable () async -> AppState.ConnectionMode
|
||||
@@ -30,16 +38,18 @@ actor GatewayEndpointStore {
|
||||
mode: { await MainActor.run { AppStateStore.shared.connectionMode } },
|
||||
token: {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
|
||||
return GatewayEndpointStore.resolveGatewayToken(
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
isRemote: isRemote,
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment,
|
||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||
},
|
||||
password: {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let isRemote = ConnectionModeResolver.resolve(root: root).mode == .remote
|
||||
return GatewayEndpointStore.resolveGatewayPassword(
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
isRemote: isRemote,
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment,
|
||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||
@@ -68,6 +78,14 @@ actor GatewayEndpointStore {
|
||||
let raw = env["CLAWDBOT_GATEWAY_PASSWORD"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
if let configPassword = self.resolveConfigPassword(isRemote: isRemote, root: root),
|
||||
!configPassword.isEmpty
|
||||
{
|
||||
self.warnEnvOverrideOnce(
|
||||
kind: .password,
|
||||
envVar: "CLAWDBOT_GATEWAY_PASSWORD",
|
||||
configKey: isRemote ? "gateway.remote.password" : "gateway.auth.password")
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
if isRemote {
|
||||
@@ -99,6 +117,26 @@ actor GatewayEndpointStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveConfigPassword(isRemote: Bool, root: [String: Any]) -> String? {
|
||||
if isRemote {
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let password = remote["password"] as? String
|
||||
{
|
||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let password = auth["password"] as? String
|
||||
{
|
||||
return password.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveGatewayToken(
|
||||
isRemote: Bool,
|
||||
root: [String: Any],
|
||||
@@ -108,6 +146,14 @@ actor GatewayEndpointStore {
|
||||
let raw = env["CLAWDBOT_GATEWAY_TOKEN"] ?? ""
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !trimmed.isEmpty {
|
||||
if let configToken = self.resolveConfigToken(isRemote: isRemote, root: root),
|
||||
!configToken.isEmpty
|
||||
{
|
||||
self.warnEnvOverrideOnce(
|
||||
kind: .token,
|
||||
envVar: "CLAWDBOT_GATEWAY_TOKEN",
|
||||
configKey: isRemote ? "gateway.remote.token" : "gateway.auth.token")
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
if isRemote {
|
||||
@@ -139,6 +185,49 @@ actor GatewayEndpointStore {
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func resolveConfigToken(isRemote: Bool, root: [String: Any]) -> String? {
|
||||
if isRemote {
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let remote = gateway["remote"] as? [String: Any],
|
||||
let token = remote["token"] as? String
|
||||
{
|
||||
return token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if let gateway = root["gateway"] as? [String: Any],
|
||||
let auth = gateway["auth"] as? [String: Any],
|
||||
let token = auth["token"] as? String
|
||||
{
|
||||
return token.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func warnEnvOverrideOnce(
|
||||
kind: EnvOverrideWarningKind,
|
||||
envVar: String,
|
||||
configKey: String)
|
||||
{
|
||||
let shouldWarn = Self.envOverrideWarnings.withValue { state in
|
||||
switch kind {
|
||||
case .token:
|
||||
guard !state.token else { return false }
|
||||
state.token = true
|
||||
return true
|
||||
case .password:
|
||||
guard !state.password else { return false }
|
||||
state.password = true
|
||||
return true
|
||||
}
|
||||
}
|
||||
guard shouldWarn else { return }
|
||||
Self.staticLogger.warning(
|
||||
"\(envVar, privacy: .public) is set and overrides \(configKey, privacy: .public). " +
|
||||
"If this is unintentional, clear it with: launchctl unsetenv \(envVar, privacy: .public)")
|
||||
}
|
||||
|
||||
private let deps: Deps
|
||||
private let logger = Logger(subsystem: "com.clawdbot", category: "gateway-endpoint")
|
||||
|
||||
|
||||
@@ -3,6 +3,13 @@ import Testing
|
||||
@testable import Clawdbot
|
||||
|
||||
@Suite struct GatewayEndpointStoreTests {
|
||||
private func makeDefaults() -> UserDefaults {
|
||||
let suiteName = "GatewayEndpointStoreTests.\(UUID().uuidString)"
|
||||
let defaults = UserDefaults(suiteName: suiteName)!
|
||||
defaults.removePersistentDomain(forName: suiteName)
|
||||
return defaults
|
||||
}
|
||||
|
||||
@Test func resolveGatewayTokenPrefersEnvAndFallsBackToLaunchd() {
|
||||
let snapshot = LaunchAgentPlistSnapshot(
|
||||
programArguments: [],
|
||||
@@ -66,4 +73,70 @@ import Testing
|
||||
launchdSnapshot: snapshot)
|
||||
#expect(password == "launchd-pass")
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverPrefersConfigModeOverDefaults() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("remote", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": " local ",
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .local)
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverTrimsConfigMode() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("local", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": " remote ",
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverFallsBackToDefaultsWhenMissingConfig() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("remote", forKey: connectionModeKey)
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: [:], defaults: defaults)
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverFallsBackToDefaultsOnUnknownConfig() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("local", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"mode": "staging",
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .local)
|
||||
}
|
||||
|
||||
@Test func connectionModeResolverPrefersRemoteURLWhenModeMissing() {
|
||||
let defaults = self.makeDefaults()
|
||||
defaults.set("local", forKey: connectionModeKey)
|
||||
|
||||
let root: [String: Any] = [
|
||||
"gateway": [
|
||||
"remote": [
|
||||
"url": " ws://umbrel:18789 ",
|
||||
],
|
||||
],
|
||||
]
|
||||
|
||||
let resolved = ConnectionModeResolver.resolve(root: root, defaults: defaults)
|
||||
#expect(resolved.mode == .remote)
|
||||
}
|
||||
}
|
||||
@@ -21,3 +21,14 @@ clawdbot doctor --repair
|
||||
clawdbot doctor --deep
|
||||
```
|
||||
|
||||
## macOS: `launchctl` env overrides
|
||||
|
||||
If you previously ran `launchctl setenv CLAWDBOT_GATEWAY_TOKEN ...` (or `...PASSWORD`), that value overrides your config file and can cause persistent “unauthorized” errors.
|
||||
|
||||
```bash
|
||||
launchctl getenv CLAWDBOT_GATEWAY_TOKEN
|
||||
launchctl getenv CLAWDBOT_GATEWAY_PASSWORD
|
||||
|
||||
launchctl unsetenv CLAWDBOT_GATEWAY_TOKEN
|
||||
launchctl unsetenv CLAWDBOT_GATEWAY_PASSWORD
|
||||
```
|
||||
@@ -146,6 +146,19 @@ Full setup walkthrough (28m) by VelvetShark.
|
||||
|
||||
14+ agents under one gateway with Opus 4.5 orchestrator delegating to Codex workers. Comprehensive [technical write-up](https://github.com/adam91holt/orchestrated-ai-articles) covering the Dream Team roster, model selection, sandboxing, webhooks, heartbeats, and delegation flows. [Clawdspace](https://github.com/adam91holt/clawdspace) for agent sandboxing. [Blog post](https://adams-ai-journey.ghost.io/2026-the-year-of-the-orchestrator/).
|
||||
</Card>
|
||||
|
||||
<Card title="Linear CLI" icon="terminal" href="https://github.com/Finesssee/linear-cli">
|
||||
**@NessZerra** • `devtools` `linear` `cli` `issues`
|
||||
|
||||
CLI for Linear that integrates with agentic workflows (Claude Code, Clawdbot). Manage issues, projects, and workflows from the terminal. First external PR merged!
|
||||
</Card>
|
||||
|
||||
<Card title="Beeper CLI" icon="message" href="https://github.com/blqke/beepcli">
|
||||
**@jules** • `messaging` `beeper` `cli` `automation`
|
||||
|
||||
Read, send, and archive messages via Beeper Desktop. Uses Beeper local MCP API so agents can manage all your chats (iMessage, WhatsApp, etc.) in one place.
|
||||
</Card>
|
||||
|
||||
</CardGroup>
|
||||
|
||||
## 🤖 Automation & Workflows
|
||||
|
||||
@@ -43,6 +43,15 @@ Auto-archive:
|
||||
- Auto-archive is best-effort; pending timers are lost if the gateway restarts.
|
||||
- `runTimeoutSeconds` does **not** auto-archive; it only stops the run. The session remains until auto-archive.
|
||||
|
||||
## Authentication
|
||||
|
||||
Sub-agent auth is resolved by **agent id**, not by session type:
|
||||
- The sub-agent session key is `agent:<agentId>:subagent:<uuid>`.
|
||||
- The auth store is loaded from that agent’s `agentDir`.
|
||||
- The main agent’s auth profiles are merged in as a **fallback**; agent profiles override main profiles on conflicts.
|
||||
|
||||
Note: the merge is additive, so main profiles are always available as fallbacks. Fully isolated auth per agent is not supported yet.
|
||||
|
||||
## Announce
|
||||
|
||||
Sub-agents report back via an announce step:
|
||||
|
||||
@@ -3,6 +3,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ensureAuthProfileStore } from "./auth-profiles.js";
|
||||
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
|
||||
|
||||
describe("ensureAuthProfileStore", () => {
|
||||
it("migrates legacy auth.json and deletes it (PR #368)", () => {
|
||||
@@ -45,4 +46,80 @@ describe("ensureAuthProfileStore", () => {
|
||||
fs.rmSync(agentDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("merges main auth profiles into agent store and keeps agent overrides", () => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "clawdbot-auth-merge-"));
|
||||
const previousAgentDir = process.env.CLAWDBOT_AGENT_DIR;
|
||||
const previousPiAgentDir = process.env.PI_CODING_AGENT_DIR;
|
||||
try {
|
||||
const mainDir = path.join(root, "main-agent");
|
||||
const agentDir = path.join(root, "agent-x");
|
||||
fs.mkdirSync(mainDir, { recursive: true });
|
||||
fs.mkdirSync(agentDir, { recursive: true });
|
||||
|
||||
process.env.CLAWDBOT_AGENT_DIR = mainDir;
|
||||
process.env.PI_CODING_AGENT_DIR = mainDir;
|
||||
|
||||
const mainStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "main-key",
|
||||
},
|
||||
"anthropic:default": {
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "main-anthropic-key",
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(mainDir, "auth-profiles.json"),
|
||||
`${JSON.stringify(mainStore, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const agentStore = {
|
||||
version: AUTH_STORE_VERSION,
|
||||
profiles: {
|
||||
"openai:default": {
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "agent-key",
|
||||
},
|
||||
},
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(agentDir, "auth-profiles.json"),
|
||||
`${JSON.stringify(agentStore, null, 2)}\n`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const store = ensureAuthProfileStore(agentDir);
|
||||
expect(store.profiles["anthropic:default"]).toMatchObject({
|
||||
type: "api_key",
|
||||
provider: "anthropic",
|
||||
key: "main-anthropic-key",
|
||||
});
|
||||
expect(store.profiles["openai:default"]).toMatchObject({
|
||||
type: "api_key",
|
||||
provider: "openai",
|
||||
key: "agent-key",
|
||||
});
|
||||
} finally {
|
||||
if (previousAgentDir === undefined) {
|
||||
delete process.env.CLAWDBOT_AGENT_DIR;
|
||||
} else {
|
||||
process.env.CLAWDBOT_AGENT_DIR = previousAgentDir;
|
||||
}
|
||||
if (previousPiAgentDir === undefined) {
|
||||
delete process.env.PI_CODING_AGENT_DIR;
|
||||
} else {
|
||||
process.env.PI_CODING_AGENT_DIR = previousPiAgentDir;
|
||||
}
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -111,6 +111,34 @@ function coerceAuthStore(raw: unknown): AuthProfileStore | null {
|
||||
};
|
||||
}
|
||||
|
||||
function mergeRecord<T>(
|
||||
base?: Record<string, T>,
|
||||
override?: Record<string, T>,
|
||||
): Record<string, T> | undefined {
|
||||
if (!base && !override) return undefined;
|
||||
if (!base) return { ...override };
|
||||
if (!override) return { ...base };
|
||||
return { ...base, ...override };
|
||||
}
|
||||
|
||||
function mergeAuthProfileStores(base: AuthProfileStore, override: AuthProfileStore): AuthProfileStore {
|
||||
if (
|
||||
Object.keys(override.profiles).length === 0 &&
|
||||
!override.order &&
|
||||
!override.lastGood &&
|
||||
!override.usageStats
|
||||
) {
|
||||
return base;
|
||||
}
|
||||
return {
|
||||
version: Math.max(base.version, override.version ?? base.version),
|
||||
profiles: { ...base.profiles, ...override.profiles },
|
||||
order: mergeRecord(base.order, override.order),
|
||||
lastGood: mergeRecord(base.lastGood, override.lastGood),
|
||||
usageStats: mergeRecord(base.usageStats, override.usageStats),
|
||||
};
|
||||
}
|
||||
|
||||
function mergeOAuthFileIntoStore(store: AuthProfileStore): boolean {
|
||||
const oauthPath = resolveOAuthPath();
|
||||
const oauthRaw = loadJsonFile(oauthPath);
|
||||
@@ -191,7 +219,7 @@ export function loadAuthProfileStore(): AuthProfileStore {
|
||||
return store;
|
||||
}
|
||||
|
||||
export function ensureAuthProfileStore(
|
||||
function loadAuthProfileStoreForAgent(
|
||||
agentDir?: string,
|
||||
options?: { allowKeychainPrompt?: boolean },
|
||||
): AuthProfileStore {
|
||||
@@ -207,6 +235,19 @@ export function ensureAuthProfileStore(
|
||||
return asStore;
|
||||
}
|
||||
|
||||
// Fallback: inherit auth-profiles from main agent if subagent has none
|
||||
if (agentDir) {
|
||||
const mainAuthPath = resolveAuthStorePath(); // without agentDir = main
|
||||
const mainRaw = loadJsonFile(mainAuthPath);
|
||||
const mainStore = coerceAuthStore(mainRaw);
|
||||
if (mainStore && Object.keys(mainStore.profiles).length > 0) {
|
||||
// Clone main store to subagent directory for auth inheritance
|
||||
saveJsonFile(authPath, mainStore);
|
||||
log.info("inherited auth-profiles from main agent", { agentDir });
|
||||
return mainStore;
|
||||
}
|
||||
}
|
||||
|
||||
const legacyRaw = loadJsonFile(resolveLegacyAuthStorePath(agentDir));
|
||||
const legacy = coerceLegacyStore(legacyRaw);
|
||||
const store: AuthProfileStore = {
|
||||
@@ -274,6 +315,21 @@ export function ensureAuthProfileStore(
|
||||
return store;
|
||||
}
|
||||
|
||||
export function ensureAuthProfileStore(
|
||||
agentDir?: string,
|
||||
options?: { allowKeychainPrompt?: boolean },
|
||||
): AuthProfileStore {
|
||||
const store = loadAuthProfileStoreForAgent(agentDir, options);
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const mainAuthPath = resolveAuthStorePath();
|
||||
if (!agentDir || authPath === mainAuthPath) {
|
||||
return store;
|
||||
}
|
||||
|
||||
const mainStore = loadAuthProfileStoreForAgent(undefined, options);
|
||||
return mergeAuthProfileStores(mainStore, store);
|
||||
}
|
||||
|
||||
export function saveAuthProfileStore(store: AuthProfileStore, agentDir?: string): void {
|
||||
const authPath = resolveAuthStorePath(agentDir);
|
||||
const payload = {
|
||||
|
||||
@@ -63,7 +63,7 @@ test("background exec still times out after tool signal abort", async () => {
|
||||
abortController.abort();
|
||||
|
||||
let finished = getFinishedSession(sessionId);
|
||||
const deadline = Date.now() + 2000;
|
||||
const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000);
|
||||
while (!finished && Date.now() < deadline) {
|
||||
await sleep(20);
|
||||
finished = getFinishedSession(sessionId);
|
||||
@@ -72,6 +72,7 @@ test("background exec still times out after tool signal abort", async () => {
|
||||
const running = getSession(sessionId);
|
||||
|
||||
try {
|
||||
expect(finished).toBeTruthy();
|
||||
expect(finished?.status).toBe("failed");
|
||||
} finally {
|
||||
const pid = running?.pid;
|
||||
@@ -121,7 +122,7 @@ test("yielded background exec still times out", async () => {
|
||||
const sessionId = (result.details as { sessionId: string }).sessionId;
|
||||
|
||||
let finished = getFinishedSession(sessionId);
|
||||
const deadline = Date.now() + 2000;
|
||||
const deadline = Date.now() + (process.platform === "win32" ? 10_000 : 2_000);
|
||||
while (!finished && Date.now() < deadline) {
|
||||
await sleep(20);
|
||||
finished = getFinishedSession(sessionId);
|
||||
@@ -130,6 +131,7 @@ test("yielded background exec still times out", async () => {
|
||||
const running = getSession(sessionId);
|
||||
|
||||
try {
|
||||
expect(finished).toBeTruthy();
|
||||
expect(finished?.status).toBe("failed");
|
||||
} finally {
|
||||
const pid = running?.pid;
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { extractMessagingToolSend } from "./pi-embedded-subscribe.tools.js";
|
||||
|
||||
describe("extractMessagingToolSend", () => {
|
||||
it("uses channel as provider for message tool", () => {
|
||||
const result = extractMessagingToolSend("message", {
|
||||
action: "send",
|
||||
channel: "telegram",
|
||||
to: "123",
|
||||
});
|
||||
|
||||
expect(result?.tool).toBe("message");
|
||||
expect(result?.provider).toBe("telegram");
|
||||
expect(result?.to).toBe("telegram:123");
|
||||
});
|
||||
|
||||
it("prefers provider when both provider and channel are set", () => {
|
||||
const result = extractMessagingToolSend("message", {
|
||||
action: "send",
|
||||
provider: "slack",
|
||||
channel: "telegram",
|
||||
to: "channel:C1",
|
||||
});
|
||||
|
||||
expect(result?.tool).toBe("message");
|
||||
expect(result?.provider).toBe("slack");
|
||||
expect(result?.to).toBe("channel:c1");
|
||||
});
|
||||
});
|
||||
@@ -57,10 +57,8 @@ export function extractMessagingToolSend(
|
||||
const toRaw = typeof args.to === "string" ? args.to : undefined;
|
||||
if (!toRaw) return undefined;
|
||||
const providerRaw = typeof args.provider === "string" ? args.provider.trim() : "";
|
||||
const channelRaw = typeof args.channel === "string" ? args.channel.trim() : "";
|
||||
const providerHint = providerRaw || channelRaw;
|
||||
const providerId = providerHint ? normalizeChannelId(providerHint) : null;
|
||||
const provider = providerId ?? (providerHint ? providerHint.toLowerCase() : "message");
|
||||
const providerId = providerRaw ? normalizeChannelId(providerRaw) : null;
|
||||
const provider = providerId ?? (providerRaw ? providerRaw.toLowerCase() : "message");
|
||||
const to = normalizeTargetForProvider(provider, toRaw);
|
||||
return to ? { tool: toolName, provider, accountId, to } : undefined;
|
||||
}
|
||||
|
||||
+4
-4
@@ -129,16 +129,16 @@ describe("createClawdbotCodingTools", () => {
|
||||
expect(Array.isArray(action?.enum)).toBe(true);
|
||||
expect(action?.enum).toContain("act");
|
||||
|
||||
const format = parameters.properties?.format as
|
||||
const snapshotFormat = parameters.properties?.snapshotFormat as
|
||||
| {
|
||||
type?: unknown;
|
||||
enum?: unknown[];
|
||||
anyOf?: unknown[];
|
||||
}
|
||||
| undefined;
|
||||
expect(format?.type).toBe("string");
|
||||
expect(format?.anyOf).toBeUndefined();
|
||||
expect(format?.enum).toEqual(["aria", "ai"]);
|
||||
expect(snapshotFormat?.type).toBe("string");
|
||||
expect(snapshotFormat?.anyOf).toBeUndefined();
|
||||
expect(snapshotFormat?.enum).toEqual(["aria", "ai"]);
|
||||
});
|
||||
it("inlines local $ref before removing unsupported keywords", () => {
|
||||
const cleaned = __testing.cleanToolSchemaForGemini({
|
||||
|
||||
@@ -216,7 +216,6 @@ describe("buildAgentSystemPrompt", () => {
|
||||
|
||||
expect(prompt).toContain("message: Send messages and channel actions");
|
||||
expect(prompt).toContain("### message tool");
|
||||
expect(prompt).toContain("respond with ONLY: NO_REPLY");
|
||||
});
|
||||
|
||||
it("includes runtime provider capabilities when present", () => {
|
||||
|
||||
@@ -95,7 +95,6 @@ function buildMessagingSection(params: {
|
||||
"- Use `message` for proactive sends + channel actions (polls, reactions, etc.).",
|
||||
"- For `action=send`, include `to` and `message`.",
|
||||
`- If multiple channels are configured, pass \`channel\` (${params.messageChannelOptions}).`,
|
||||
`- If you use \`message\` (\`action=send\`) to deliver your user-visible reply, respond with ONLY: ${SILENT_REPLY_TOKEN} (avoid duplicate replies).`,
|
||||
params.inlineButtonsEnabled
|
||||
? "- Inline buttons supported. Use `action=send` with `buttons=[[{text,callback_data}]]` (callback_data routes back as a user message)."
|
||||
: params.runtimeChannel
|
||||
@@ -328,6 +327,11 @@ export function buildAgentSystemPrompt(params: {
|
||||
"TOOLS.md does not control tool availability; it is user guidance for how to use external tools.",
|
||||
"If a task is more complex or takes longer, spawn a sub-agent. It will do the work for you and ping you when it's done. You can always check up on it.",
|
||||
"",
|
||||
"## Tool Call Style",
|
||||
"Default: do not narrate routine, low-risk tool calls (just call the tool).",
|
||||
"Narrate only when it helps: multi-step work, complex/challenging problems, sensitive actions (e.g., deletions), or when the user explicitly asks.",
|
||||
"Keep narration brief and value-dense; avoid repeating obvious steps.",
|
||||
"",
|
||||
"## Clawdbot CLI Quick Reference",
|
||||
"Clawdbot is controlled via subcommands. Do not invent commands.",
|
||||
"To manage the Gateway daemon service (start/stop/restart):",
|
||||
@@ -410,9 +414,7 @@ export function buildAgentSystemPrompt(params: {
|
||||
? "You may also send /elevated on|off when needed."
|
||||
: "",
|
||||
params.sandboxInfo.elevated?.allowed
|
||||
? `Current elevated level: ${
|
||||
params.sandboxInfo.elevated.defaultLevel
|
||||
} (on runs exec on host; off runs in sandbox).`
|
||||
? `Current elevated level: ${params.sandboxInfo.elevated.defaultLevel} (on runs exec on host; off runs in sandbox).`
|
||||
: "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
|
||||
@@ -91,7 +91,7 @@ export const BrowserToolSchema = Type.Object({
|
||||
limit: Type.Optional(Type.Number()),
|
||||
maxChars: Type.Optional(Type.Number()),
|
||||
mode: optionalStringEnum(BROWSER_SNAPSHOT_MODES),
|
||||
format: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS),
|
||||
snapshotFormat: optionalStringEnum(BROWSER_SNAPSHOT_FORMATS),
|
||||
refs: optionalStringEnum(BROWSER_SNAPSHOT_REFS),
|
||||
interactive: Type.Optional(Type.Boolean()),
|
||||
compact: Type.Optional(Type.Boolean()),
|
||||
|
||||
@@ -191,8 +191,8 @@ export function createBrowserTool(opts?: {
|
||||
}
|
||||
case "snapshot": {
|
||||
const format =
|
||||
params.format === "ai" || params.format === "aria"
|
||||
? (params.format as "ai" | "aria")
|
||||
params.snapshotFormat === "ai" || params.snapshotFormat === "aria"
|
||||
? (params.snapshotFormat as "ai" | "aria")
|
||||
: "ai";
|
||||
const mode = params.mode === "efficient" ? "efficient" : undefined;
|
||||
const labels = typeof params.labels === "boolean" ? params.labels : undefined;
|
||||
|
||||
@@ -40,7 +40,7 @@ const CanvasToolSchema = Type.Object({
|
||||
// eval
|
||||
javaScript: Type.Optional(Type.String()),
|
||||
// snapshot
|
||||
format: optionalStringEnum(CANVAS_SNAPSHOT_FORMATS),
|
||||
outputFormat: optionalStringEnum(CANVAS_SNAPSHOT_FORMATS),
|
||||
maxWidth: Type.Optional(Type.Number()),
|
||||
quality: Type.Optional(Type.Number()),
|
||||
delayMs: Type.Optional(Type.Number()),
|
||||
@@ -127,7 +127,7 @@ export function createCanvasTool(): AnyAgentTool {
|
||||
return jsonResult({ ok: true });
|
||||
}
|
||||
case "snapshot": {
|
||||
const formatRaw = typeof params.format === "string" ? params.format.toLowerCase() : "png";
|
||||
const formatRaw = typeof params.outputFormat === "string" ? params.outputFormat.toLowerCase() : "png";
|
||||
const format = formatRaw === "jpg" || formatRaw === "jpeg" ? "jpeg" : "png";
|
||||
const maxWidth =
|
||||
typeof params.maxWidth === "number" && Number.isFinite(params.maxWidth)
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { noteMacLaunchctlGatewayEnvOverrides } from "./doctor-platform-notes.js";
|
||||
|
||||
describe("noteMacLaunchctlGatewayEnvOverrides", () => {
|
||||
it("prints clear unsetenv instructions for token override", async () => {
|
||||
const noteFn = vi.fn();
|
||||
const getenv = vi.fn(async (name: string) =>
|
||||
name === "CLAWDBOT_GATEWAY_TOKEN" ? "launchctl-token" : undefined,
|
||||
);
|
||||
const cfg = {
|
||||
gateway: {
|
||||
auth: {
|
||||
token: "config-token",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
await noteMacLaunchctlGatewayEnvOverrides(cfg, { platform: "darwin", getenv, noteFn });
|
||||
|
||||
expect(noteFn).toHaveBeenCalledTimes(1);
|
||||
expect(getenv).toHaveBeenCalledTimes(2);
|
||||
|
||||
const [message, title] = noteFn.mock.calls[0] ?? [];
|
||||
expect(title).toBe("Gateway (macOS)");
|
||||
expect(message).toContain("launchctl environment overrides detected");
|
||||
expect(message).toContain("CLAWDBOT_GATEWAY_TOKEN");
|
||||
expect(message).toContain("launchctl unsetenv CLAWDBOT_GATEWAY_TOKEN");
|
||||
expect(message).not.toContain("CLAWDBOT_GATEWAY_PASSWORD");
|
||||
});
|
||||
|
||||
it("does nothing when config has no gateway credentials", async () => {
|
||||
const noteFn = vi.fn();
|
||||
const getenv = vi.fn(async () => "launchctl-token");
|
||||
const cfg = {} as ClawdbotConfig;
|
||||
|
||||
await noteMacLaunchctlGatewayEnvOverrides(cfg, { platform: "darwin", getenv, noteFn });
|
||||
|
||||
expect(getenv).not.toHaveBeenCalled();
|
||||
expect(noteFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does nothing on non-darwin platforms", async () => {
|
||||
const noteFn = vi.fn();
|
||||
const getenv = vi.fn(async () => "launchctl-token");
|
||||
const cfg = {
|
||||
gateway: {
|
||||
auth: {
|
||||
token: "config-token",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig;
|
||||
|
||||
await noteMacLaunchctlGatewayEnvOverrides(cfg, { platform: "linux", getenv, noteFn });
|
||||
|
||||
expect(getenv).not.toHaveBeenCalled();
|
||||
expect(noteFn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,14 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import { note } from "../terminal/note.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
function resolveHomeDir(): string {
|
||||
return process.env.HOME ?? os.homedir();
|
||||
}
|
||||
@@ -21,3 +26,56 @@ export async function noteMacLaunchAgentOverrides() {
|
||||
].filter((line): line is string => Boolean(line));
|
||||
note(lines.join("\n"), "Gateway (macOS)");
|
||||
}
|
||||
|
||||
async function launchctlGetenv(name: string): Promise<string | undefined> {
|
||||
try {
|
||||
const result = await execFileAsync("/bin/launchctl", ["getenv", name], { encoding: "utf8" });
|
||||
const value = String(result.stdout ?? "").trim();
|
||||
return value.length > 0 ? value : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function hasConfigGatewayCreds(cfg: ClawdbotConfig): boolean {
|
||||
const localToken =
|
||||
typeof cfg.gateway?.auth?.token === "string" ? cfg.gateway?.auth?.token.trim() : "";
|
||||
const localPassword =
|
||||
typeof cfg.gateway?.auth?.password === "string" ? cfg.gateway?.auth?.password.trim() : "";
|
||||
const remoteToken =
|
||||
typeof cfg.gateway?.remote?.token === "string" ? cfg.gateway?.remote?.token.trim() : "";
|
||||
const remotePassword =
|
||||
typeof cfg.gateway?.remote?.password === "string" ? cfg.gateway?.remote?.password.trim() : "";
|
||||
return Boolean(localToken || localPassword || remoteToken || remotePassword);
|
||||
}
|
||||
|
||||
export async function noteMacLaunchctlGatewayEnvOverrides(
|
||||
cfg: ClawdbotConfig,
|
||||
deps?: {
|
||||
platform?: NodeJS.Platform;
|
||||
getenv?: (name: string) => Promise<string | undefined>;
|
||||
noteFn?: typeof note;
|
||||
},
|
||||
) {
|
||||
const platform = deps?.platform ?? process.platform;
|
||||
if (platform !== "darwin") return;
|
||||
if (!hasConfigGatewayCreds(cfg)) return;
|
||||
|
||||
const getenv = deps?.getenv ?? launchctlGetenv;
|
||||
const envToken = await getenv("CLAWDBOT_GATEWAY_TOKEN");
|
||||
const envPassword = await getenv("CLAWDBOT_GATEWAY_PASSWORD");
|
||||
if (!envToken && !envPassword) return;
|
||||
|
||||
const lines = [
|
||||
"- launchctl environment overrides detected (can cause confusing unauthorized errors).",
|
||||
envToken ? "- `CLAWDBOT_GATEWAY_TOKEN` is set; it overrides config tokens." : undefined,
|
||||
envPassword
|
||||
? "- `CLAWDBOT_GATEWAY_PASSWORD` is set; it overrides config passwords."
|
||||
: undefined,
|
||||
"- Clear overrides and restart the app/gateway:",
|
||||
envToken ? " launchctl unsetenv CLAWDBOT_GATEWAY_TOKEN" : undefined,
|
||||
envPassword ? " launchctl unsetenv CLAWDBOT_GATEWAY_PASSWORD" : undefined,
|
||||
].filter((line): line is string => Boolean(line));
|
||||
|
||||
(deps?.noteFn ?? note)(lines.join("\n"), "Gateway (macOS)");
|
||||
}
|
||||
@@ -26,7 +26,10 @@ import {
|
||||
maybeScanExtraGatewayServices,
|
||||
} from "./doctor-gateway-services.js";
|
||||
import { noteSourceInstallIssues } from "./doctor-install.js";
|
||||
import { noteMacLaunchAgentOverrides } from "./doctor-platform-notes.js";
|
||||
import {
|
||||
noteMacLaunchAgentOverrides,
|
||||
noteMacLaunchctlGatewayEnvOverrides,
|
||||
} from "./doctor-platform-notes.js";
|
||||
import { createDoctorPrompter, type DoctorOptions } from "./doctor-prompter.js";
|
||||
import { maybeRepairSandboxImages, noteSandboxScopeWarnings } from "./doctor-sandbox.js";
|
||||
import { noteSecurityWarnings } from "./doctor-security.js";
|
||||
@@ -160,6 +163,7 @@ export async function doctorCommand(
|
||||
await maybeScanExtraGatewayServices(options);
|
||||
await maybeRepairGatewayServiceConfig(cfg, resolveMode(cfg), runtime, prompter);
|
||||
await noteMacLaunchAgentOverrides();
|
||||
await noteMacLaunchctlGatewayEnvOverrides(cfg);
|
||||
|
||||
await noteSecurityWarnings(cfg);
|
||||
|
||||
|
||||
@@ -91,6 +91,18 @@ describe("callGateway url resolution", () => {
|
||||
|
||||
expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18800");
|
||||
});
|
||||
|
||||
it("uses url override in remote mode even when remote url is missing", async () => {
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: { mode: "remote", bind: "loopback", remote: {} },
|
||||
});
|
||||
resolveGatewayPort.mockReturnValue(18789);
|
||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
||||
|
||||
await callGateway({ method: "health", url: "wss://override.example/ws" });
|
||||
|
||||
expect(lastClientOptions?.url).toBe("wss://override.example/ws");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildGatewayConnectionDetails", () => {
|
||||
@@ -313,3 +325,43 @@ describe("callGateway password resolution", () => {
|
||||
expect(lastClientOptions?.password).toBe("from-env");
|
||||
});
|
||||
});
|
||||
|
||||
describe("callGateway token resolution", () => {
|
||||
const originalEnvToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
|
||||
beforeEach(() => {
|
||||
loadConfig.mockReset();
|
||||
resolveGatewayPort.mockReset();
|
||||
pickPrimaryTailnetIPv4.mockReset();
|
||||
lastClientOptions = null;
|
||||
startMode = "hello";
|
||||
closeCode = 1006;
|
||||
closeReason = "";
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
resolveGatewayPort.mockReturnValue(18789);
|
||||
pickPrimaryTailnetIPv4.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnvToken == null) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = originalEnvToken;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses remote token when remote mode uses url override", async () => {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = "env-token";
|
||||
loadConfig.mockReturnValue({
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: { token: "remote-token" },
|
||||
auth: { token: "local-token" },
|
||||
},
|
||||
});
|
||||
|
||||
await callGateway({ method: "health", url: "wss://override.example/ws" });
|
||||
|
||||
expect(lastClientOptions?.token).toBe("remote-token");
|
||||
});
|
||||
});
|
||||
+338
-1
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
@@ -251,6 +251,29 @@ describe("security audit", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("adds a warning when deep probe throws", async () => {
|
||||
const cfg: ClawdbotConfig = { gateway: { mode: "local" } };
|
||||
|
||||
const res = await runSecurityAudit({
|
||||
config: cfg,
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
probeGatewayFn: async () => {
|
||||
throw new Error("probe boom");
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.deep?.gateway.ok).toBe(false);
|
||||
expect(res.deep?.gateway.error).toContain("probe boom");
|
||||
expect(res.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ checkId: "gateway.probe_failed", severity: "warn" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns on legacy model configuration", async () => {
|
||||
const cfg: ClawdbotConfig = {
|
||||
agents: { defaults: { model: { primary: "openai/gpt-3.5-turbo" } } },
|
||||
@@ -401,4 +424,318 @@ describe("security audit", () => {
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
describe("maybeProbeGateway auth selection", () => {
|
||||
const originalEnvToken = process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
const originalEnvPassword = process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||
|
||||
beforeEach(() => {
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalEnvToken == null) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_TOKEN;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = originalEnvToken;
|
||||
}
|
||||
if (originalEnvPassword == null) {
|
||||
delete process.env.CLAWDBOT_GATEWAY_PASSWORD;
|
||||
} else {
|
||||
process.env.CLAWDBOT_GATEWAY_PASSWORD = originalEnvPassword;
|
||||
}
|
||||
});
|
||||
|
||||
it("uses local auth when gateway.mode is local", async () => {
|
||||
let capturedAuth: { token?: string; password?: string } | undefined;
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: {
|
||||
mode: "local",
|
||||
auth: { token: "local-token-abc123" },
|
||||
},
|
||||
};
|
||||
|
||||
await runSecurityAudit({
|
||||
config: cfg,
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
probeGatewayFn: async (opts) => {
|
||||
capturedAuth = opts.auth;
|
||||
return {
|
||||
ok: true,
|
||||
url: opts.url,
|
||||
connectLatencyMs: 10,
|
||||
error: null,
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedAuth?.token).toBe("local-token-abc123");
|
||||
});
|
||||
|
||||
it("prefers env token over local config token", async () => {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = "env-token";
|
||||
let capturedAuth: { token?: string; password?: string } | undefined;
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: {
|
||||
mode: "local",
|
||||
auth: { token: "local-token" },
|
||||
},
|
||||
};
|
||||
|
||||
await runSecurityAudit({
|
||||
config: cfg,
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
probeGatewayFn: async (opts) => {
|
||||
capturedAuth = opts.auth;
|
||||
return {
|
||||
ok: true,
|
||||
url: opts.url,
|
||||
connectLatencyMs: 10,
|
||||
error: null,
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedAuth?.token).toBe("env-token");
|
||||
});
|
||||
|
||||
it("uses local auth when gateway.mode is undefined (default)", async () => {
|
||||
let capturedAuth: { token?: string; password?: string } | undefined;
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: {
|
||||
auth: { token: "default-local-token" },
|
||||
},
|
||||
};
|
||||
|
||||
await runSecurityAudit({
|
||||
config: cfg,
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
probeGatewayFn: async (opts) => {
|
||||
capturedAuth = opts.auth;
|
||||
return {
|
||||
ok: true,
|
||||
url: opts.url,
|
||||
connectLatencyMs: 10,
|
||||
error: null,
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedAuth?.token).toBe("default-local-token");
|
||||
});
|
||||
|
||||
it("uses remote auth when gateway.mode is remote with URL", async () => {
|
||||
let capturedAuth: { token?: string; password?: string } | undefined;
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
auth: { token: "local-token-should-not-use" },
|
||||
remote: {
|
||||
url: "ws://remote.example.com:18789",
|
||||
token: "remote-token-xyz789",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await runSecurityAudit({
|
||||
config: cfg,
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
probeGatewayFn: async (opts) => {
|
||||
capturedAuth = opts.auth;
|
||||
return {
|
||||
ok: true,
|
||||
url: opts.url,
|
||||
connectLatencyMs: 10,
|
||||
error: null,
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedAuth?.token).toBe("remote-token-xyz789");
|
||||
});
|
||||
|
||||
it("ignores env token when gateway.mode is remote", async () => {
|
||||
process.env.CLAWDBOT_GATEWAY_TOKEN = "env-token";
|
||||
let capturedAuth: { token?: string; password?: string } | undefined;
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
auth: { token: "local-token-should-not-use" },
|
||||
remote: {
|
||||
url: "ws://remote.example.com:18789",
|
||||
token: "remote-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await runSecurityAudit({
|
||||
config: cfg,
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
probeGatewayFn: async (opts) => {
|
||||
capturedAuth = opts.auth;
|
||||
return {
|
||||
ok: true,
|
||||
url: opts.url,
|
||||
connectLatencyMs: 10,
|
||||
error: null,
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedAuth?.token).toBe("remote-token");
|
||||
});
|
||||
|
||||
it("uses remote password when env is unset", async () => {
|
||||
let capturedAuth: { token?: string; password?: string } | undefined;
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: {
|
||||
url: "ws://remote.example.com:18789",
|
||||
password: "remote-pass",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await runSecurityAudit({
|
||||
config: cfg,
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
probeGatewayFn: async (opts) => {
|
||||
capturedAuth = opts.auth;
|
||||
return {
|
||||
ok: true,
|
||||
url: opts.url,
|
||||
connectLatencyMs: 10,
|
||||
error: null,
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedAuth?.password).toBe("remote-pass");
|
||||
});
|
||||
|
||||
it("prefers env password over remote password", async () => {
|
||||
process.env.CLAWDBOT_GATEWAY_PASSWORD = "env-pass";
|
||||
let capturedAuth: { token?: string; password?: string } | undefined;
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
remote: {
|
||||
url: "ws://remote.example.com:18789",
|
||||
password: "remote-pass",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await runSecurityAudit({
|
||||
config: cfg,
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
probeGatewayFn: async (opts) => {
|
||||
capturedAuth = opts.auth;
|
||||
return {
|
||||
ok: true,
|
||||
url: opts.url,
|
||||
connectLatencyMs: 10,
|
||||
error: null,
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedAuth?.password).toBe("env-pass");
|
||||
});
|
||||
|
||||
it("falls back to local auth when gateway.mode is remote but URL is missing", async () => {
|
||||
let capturedAuth: { token?: string; password?: string } | undefined;
|
||||
const cfg: ClawdbotConfig = {
|
||||
gateway: {
|
||||
mode: "remote",
|
||||
auth: { token: "fallback-local-token" },
|
||||
remote: {
|
||||
token: "remote-token-should-not-use",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await runSecurityAudit({
|
||||
config: cfg,
|
||||
deep: true,
|
||||
deepTimeoutMs: 50,
|
||||
includeFilesystem: false,
|
||||
includeChannelSecurity: false,
|
||||
probeGatewayFn: async (opts) => {
|
||||
capturedAuth = opts.auth;
|
||||
return {
|
||||
ok: true,
|
||||
url: opts.url,
|
||||
connectLatencyMs: 10,
|
||||
error: null,
|
||||
close: null,
|
||||
health: null,
|
||||
status: null,
|
||||
presence: null,
|
||||
configSnapshot: null,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(capturedAuth?.token).toBe("fallback-local-token");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -538,7 +538,7 @@ async function maybeProbeGateway(params: {
|
||||
return { token, password };
|
||||
};
|
||||
|
||||
const auth = remoteUrlMissing ? resolveAuth("local") : resolveAuth("remote");
|
||||
const auth = !isRemoteMode || remoteUrlMissing ? resolveAuth("local") : resolveAuth("remote");
|
||||
const res = await params.probe({ url, auth, timeoutMs: params.timeoutMs }).catch((err) => ({
|
||||
ok: false,
|
||||
url,
|
||||
|
||||
Reference in new issue
Block a user