Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7146326c56 | ||
|
|
1a99be7450 | ||
|
|
05c2256324 |
No files matched your search
@@ -73,7 +73,6 @@
|
||||
- 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,8 +257,30 @@ 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 resolvedConnectionMode = ConnectionModeResolver.resolve(root: configRoot).mode
|
||||
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
|
||||
}
|
||||
self.connectionMode = resolvedConnectionMode
|
||||
|
||||
let storedRemoteTarget = UserDefaults.standard.string(forKey: remoteTargetKey) ?? ""
|
||||
|
||||
@@ -385,8 +385,14 @@ enum CommandResolver {
|
||||
}
|
||||
|
||||
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
||||
let root = ClawdbotConfigFile.loadDict()
|
||||
let mode = ConnectionModeResolver.resolve(root: root, defaults: defaults).mode
|
||||
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 target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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,4 +1,3 @@
|
||||
import ConcurrencyExtras
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
@@ -17,13 +16,6 @@ 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
|
||||
@@ -38,18 +30,16 @@ 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: isRemote,
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
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: isRemote,
|
||||
isRemote: CommandResolver.connectionModeIsRemote(),
|
||||
root: root,
|
||||
env: ProcessInfo.processInfo.environment,
|
||||
launchdSnapshot: GatewayLaunchAgentManager.launchdConfigSnapshot())
|
||||
@@ -78,14 +68,6 @@ 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 {
|
||||
@@ -117,26 +99,6 @@ 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],
|
||||
@@ -146,14 +108,6 @@ 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 {
|
||||
@@ -185,49 +139,6 @@ 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,13 +3,6 @@ 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: [],
|
||||
@@ -73,70 +66,4 @@ 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,14 +21,3 @@ 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,19 +146,6 @@ 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
|
||||
|
||||
@@ -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() + (process.platform === "win32" ? 10_000 : 2_000);
|
||||
const deadline = Date.now() + 2000;
|
||||
while (!finished && Date.now() < deadline) {
|
||||
await sleep(20);
|
||||
finished = getFinishedSession(sessionId);
|
||||
@@ -72,7 +72,6 @@ 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;
|
||||
@@ -122,7 +121,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() + (process.platform === "win32" ? 10_000 : 2_000);
|
||||
const deadline = Date.now() + 2000;
|
||||
while (!finished && Date.now() < deadline) {
|
||||
await sleep(20);
|
||||
finished = getFinishedSession(sessionId);
|
||||
@@ -131,7 +130,6 @@ 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,61 +0,0 @@
|
||||
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,14 +1,9 @@
|
||||
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();
|
||||
}
|
||||
@@ -26,56 +21,3 @@ 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,10 +26,7 @@ import {
|
||||
maybeScanExtraGatewayServices,
|
||||
} from "./doctor-gateway-services.js";
|
||||
import { noteSourceInstallIssues } from "./doctor-install.js";
|
||||
import {
|
||||
noteMacLaunchAgentOverrides,
|
||||
noteMacLaunchctlGatewayEnvOverrides,
|
||||
} from "./doctor-platform-notes.js";
|
||||
import { noteMacLaunchAgentOverrides } 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";
|
||||
@@ -163,7 +160,6 @@ export async function doctorCommand(
|
||||
await maybeScanExtraGatewayServices(options);
|
||||
await maybeRepairGatewayServiceConfig(cfg, resolveMode(cfg), runtime, prompter);
|
||||
await noteMacLaunchAgentOverrides();
|
||||
await noteMacLaunchctlGatewayEnvOverrides(cfg);
|
||||
|
||||
await noteSecurityWarnings(cfg);
|
||||
|
||||
|
||||
@@ -91,18 +91,6 @@ 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", () => {
|
||||
@@ -325,43 +313,3 @@ 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");
|
||||
});
|
||||
});
|
||||
+1
-194
@@ -1,4 +1,4 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { ClawdbotConfig } from "../config/config.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.js";
|
||||
@@ -251,29 +251,6 @@ 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" } } },
|
||||
@@ -426,27 +403,6 @@ 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 = {
|
||||
@@ -481,41 +437,6 @@ describe("security audit", () => {
|
||||
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 = {
|
||||
@@ -587,120 +508,6 @@ describe("security audit", () => {
|
||||
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 = {
|
||||
|
||||
Reference in new issue
Block a user