fix: land mac node bridge ping loop (#572) (thanks @ngutman)

This commit is contained in:
Peter Steinberger
2026-01-09 14:01:20 +01:00
parent 975aa5bf82
commit cb86d0d6d4
12 changed files with 80 additions and 56 deletions

View File

@@ -2,6 +2,7 @@
## Unreleased ## Unreleased
- macOS: add node bridge heartbeat pings to detect half-open sockets and reconnect cleanly. (#572) — thanks @ngutman
- CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott - CLI: add `sandbox list` and `sandbox recreate` commands for managing Docker sandbox containers after image/config updates. (#563) — thanks @pasogott
- Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc - Providers: add Microsoft Teams provider with polling, attachments, and CLI send support. (#404) — thanks @onutc
- Commands: accept /models as an alias for /model. - Commands: accept /models as an alias for /model.

View File

@@ -252,12 +252,17 @@ actor MacNodeBridgeSession {
} }
private func send(_ obj: some Encodable) async throws { private func send(_ obj: some Encodable) async throws {
guard let connection = self.connection else {
throw NSError(domain: "Bridge", code: 15, userInfo: [
NSLocalizedDescriptionKey: "not connected",
])
}
let data = try self.encoder.encode(obj) let data = try self.encoder.encode(obj)
var line = Data() var line = Data()
line.append(data) line.append(data)
line.append(0x0A) line.append(0x0A)
try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation(isolation: self) { (cont: CheckedContinuation<Void, Error>) in
self.connection?.send(content: line, completion: .contentProcessed { err in connection.send(content: line, completion: .contentProcessed { err in
if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) } if let err { cont.resume(throwing: err) } else { cont.resume(returning: ()) }
}) })
} }
@@ -334,9 +339,8 @@ actor MacNodeBridgeSession {
} }
private func notePong(_ pong: BridgePong) { private func notePong(_ pong: BridgePong) {
if pong.id == self.lastPingId || self.lastPingId == nil { _ = pong
self.lastPongAt = Date() self.lastPongAt = Date()
}
} }
private static func makeStateStream( private static func makeStateStream(

View File

@@ -0,0 +1,19 @@
import Testing
@testable import Clawdbot
@Suite
struct MacNodeBridgeSessionTests {
@Test func sendEventThrowsWhenNotConnected() async {
let session = MacNodeBridgeSession()
do {
try await session.sendEvent(event: "test", payloadJSON: "{}")
Issue.record("Expected sendEvent to throw when disconnected")
} catch {
let ns = error as NSError
#expect(ns.domain == "Bridge")
#expect(ns.code == 15)
}
}
}

View File

@@ -179,14 +179,23 @@ async function ensureDevGatewayConfig(opts: { reset?: boolean }) {
mode: "local", mode: "local",
bind: "loopback", bind: "loopback",
}, },
agent: { agents: {
workspace, defaults: {
skipBootstrap: true, workspace,
}, skipBootstrap: true,
identity: { },
name: DEV_IDENTITY_NAME, list: [
theme: DEV_IDENTITY_THEME, {
emoji: DEV_IDENTITY_EMOJI, id: "dev",
default: true,
workspace,
identity: {
name: DEV_IDENTITY_NAME,
theme: DEV_IDENTITY_THEME,
emoji: DEV_IDENTITY_EMOJI,
},
},
],
}, },
}); });
await ensureDevWorkspace(workspace); await ensureDevWorkspace(workspace);

View File

@@ -278,7 +278,7 @@ describe("doctor", () => {
changes: ["Moved routing.allowFrom → whatsapp.allowFrom."], changes: ["Moved routing.allowFrom → whatsapp.allowFrom."],
}); });
await doctorCommand(runtime); await doctorCommand(runtime, { nonInteractive: true });
expect(writeConfigFile).toHaveBeenCalledTimes(1); expect(writeConfigFile).toHaveBeenCalledTimes(1);
const written = writeConfigFile.mock.calls[0]?.[0] as Record< const written = writeConfigFile.mock.calls[0]?.[0] as Record<

View File

@@ -114,10 +114,13 @@ export async function doctorCommand(
.join("\n"), .join("\n"),
"Legacy config keys detected", "Legacy config keys detected",
); );
const migrate = await prompter.confirm({ const migrate =
message: "Migrate legacy config entries now?", options.nonInteractive === true
initialValue: true, ? true
}); : await prompter.confirm({
message: "Migrate legacy config entries now?",
initialValue: true,
});
if (migrate) { if (migrate) {
// Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom. // Legacy migration (2026-01-02, commit: 16420e5b) — normalize per-provider allowlists; move WhatsApp gating into whatsapp.allowFrom.
const { config: migrated, changes } = migrateLegacyConfig( const { config: migrated, changes } = migrateLegacyConfig(

View File

@@ -10,12 +10,6 @@ const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000;
const DEFAULT_MINIMAX_MAX_TOKENS = 8192; const DEFAULT_MINIMAX_MAX_TOKENS = 8192;
export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`; export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`;
const DEFAULT_MINIMAX_BASE_URL = "https://api.minimax.io/v1";
export const MINIMAX_HOSTED_MODEL_ID = "MiniMax-M2.1";
const DEFAULT_MINIMAX_CONTEXT_WINDOW = 200000;
const DEFAULT_MINIMAX_MAX_TOKENS = 8192;
export const MINIMAX_HOSTED_MODEL_REF = `minimax/${MINIMAX_HOSTED_MODEL_ID}`;
export async function writeOAuthCredentials( export async function writeOAuthCredentials(
provider: OAuthProvider, provider: OAuthProvider,
creds: OAuthCredentials, creds: OAuthCredentials,
@@ -176,7 +170,7 @@ export function applyMinimaxHostedProviderConfig(
cfg: ClawdbotConfig, cfg: ClawdbotConfig,
params?: { baseUrl?: string }, params?: { baseUrl?: string },
): ClawdbotConfig { ): ClawdbotConfig {
const models = { ...cfg.agent?.models }; const models = { ...cfg.agents?.defaults?.models };
models[MINIMAX_HOSTED_MODEL_REF] = { models[MINIMAX_HOSTED_MODEL_REF] = {
...models[MINIMAX_HOSTED_MODEL_REF], ...models[MINIMAX_HOSTED_MODEL_REF],
alias: models[MINIMAX_HOSTED_MODEL_REF]?.alias ?? "Minimax", alias: models[MINIMAX_HOSTED_MODEL_REF]?.alias ?? "Minimax",
@@ -212,9 +206,12 @@ export function applyMinimaxHostedProviderConfig(
return { return {
...cfg, ...cfg,
agent: { agents: {
...cfg.agent, ...cfg.agents,
models, defaults: {
...cfg.agents?.defaults,
models,
},
}, },
models: { models: {
mode: cfg.models?.mode ?? "merge", mode: cfg.models?.mode ?? "merge",
@@ -254,17 +251,14 @@ export function applyMinimaxHostedConfig(
const next = applyMinimaxHostedProviderConfig(cfg, params); const next = applyMinimaxHostedProviderConfig(cfg, params);
return { return {
...next, ...next,
agent: { agents: {
...next.agent, ...next.agents,
model: { defaults: {
...(next.agent?.model && ...next.agents?.defaults,
"fallbacks" in (next.agent.model as Record<string, unknown>) model: {
? { ...(next.agents?.defaults?.model ?? {}),
fallbacks: (next.agent.model as { fallbacks?: string[] }) primary: MINIMAX_HOSTED_MODEL_REF,
.fallbacks, },
}
: undefined),
primary: MINIMAX_HOSTED_MODEL_REF,
}, },
}, },
}; };

View File

@@ -546,7 +546,8 @@ async function promptWhatsAppAllowFrom(
"WhatsApp number", "WhatsApp number",
); );
const entry = await prompter.text({ const entry = await prompter.text({
message: "Your personal WhatsApp number (the phone you will message from)", message:
"Your personal WhatsApp number (the phone you will message from)",
placeholder: "+15555550123", placeholder: "+15555550123",
initialValue: existingAllowFrom[0], initialValue: existingAllowFrom[0],
validate: (value) => { validate: (value) => {
@@ -613,7 +614,8 @@ async function promptWhatsAppAllowFrom(
"WhatsApp number", "WhatsApp number",
); );
const entry = await prompter.text({ const entry = await prompter.text({
message: "Your personal WhatsApp number (the phone you will message from)", message:
"Your personal WhatsApp number (the phone you will message from)",
placeholder: "+15555550123", placeholder: "+15555550123",
initialValue: existingAllowFrom[0], initialValue: existingAllowFrom[0],
validate: (value) => { validate: (value) => {

View File

@@ -1211,6 +1211,7 @@ export type AgentDefaultsConfig = {
| "slack" | "slack"
| "signal" | "signal"
| "imessage" | "imessage"
| "msteams"
| "none"; | "none";
/** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */ /** Optional delivery override (E.164 for WhatsApp, chat id for Telegram). */
to?: string; to?: string;

View File

@@ -56,8 +56,9 @@ export async function monitorMSTeamsProvider(
const textLimit = resolveTextChunkLimit(cfg, "msteams"); const textLimit = resolveTextChunkLimit(cfg, "msteams");
const MB = 1024 * 1024; const MB = 1024 * 1024;
const mediaMaxBytes = const mediaMaxBytes =
typeof cfg.agent?.mediaMaxMb === "number" && cfg.agent.mediaMaxMb > 0 typeof cfg.agents?.defaults?.mediaMaxMb === "number" &&
? Math.floor(cfg.agent.mediaMaxMb * MB) cfg.agents.defaults.mediaMaxMb > 0
? Math.floor(cfg.agents.defaults.mediaMaxMb * MB)
: 8 * MB; : 8 * MB;
const conversationStore = const conversationStore =
opts.conversationStore ?? createMSTeamsConversationStoreFs(); opts.conversationStore ?? createMSTeamsConversationStoreFs();

View File

@@ -122,9 +122,7 @@ export async function sendMessageTelegram(
const client: ApiClientOptions | undefined = fetchImpl const client: ApiClientOptions | undefined = fetchImpl
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
: undefined; : undefined;
const api = const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
opts.api ??
new Bot(token, client ? { client } : undefined).api;
const mediaUrl = opts.mediaUrl?.trim(); const mediaUrl = opts.mediaUrl?.trim();
// Build optional params for forum topics and reply threading. // Build optional params for forum topics and reply threading.
@@ -296,9 +294,7 @@ export async function reactMessageTelegram(
const client: ApiClientOptions | undefined = fetchImpl const client: ApiClientOptions | undefined = fetchImpl
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
: undefined; : undefined;
const api = const api = opts.api ?? new Bot(token, client ? { client } : undefined).api;
opts.api ??
new Bot(token, client ? { client } : undefined).api;
const request = createTelegramRetryRunner({ const request = createTelegramRetryRunner({
retry: opts.retry, retry: opts.retry,
configRetry: account.config.retry, configRetry: account.config.retry,

View File

@@ -11,10 +11,7 @@ export async function setTelegramWebhook(opts: {
const client: ApiClientOptions | undefined = fetchImpl const client: ApiClientOptions | undefined = fetchImpl
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
: undefined; : undefined;
const bot = new Bot( const bot = new Bot(opts.token, client ? { client } : undefined);
opts.token,
client ? { client } : undefined,
);
await bot.api.setWebhook(opts.url, { await bot.api.setWebhook(opts.url, {
secret_token: opts.secret, secret_token: opts.secret,
drop_pending_updates: opts.dropPendingUpdates ?? false, drop_pending_updates: opts.dropPendingUpdates ?? false,
@@ -26,9 +23,6 @@ export async function deleteTelegramWebhook(opts: { token: string }) {
const client: ApiClientOptions | undefined = fetchImpl const client: ApiClientOptions | undefined = fetchImpl
? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] } ? { fetch: fetchImpl as unknown as ApiClientOptions["fetch"] }
: undefined; : undefined;
const bot = new Bot( const bot = new Bot(opts.token, client ? { client } : undefined);
opts.token,
client ? { client } : undefined,
);
await bot.api.deleteWebhook(); await bot.api.deleteWebhook();
} }