Compare commits

..
7 changed files with 60 additions and 210 deletions

No files matched your search

-1
View File
@@ -67,7 +67,6 @@ Status: unreleased.
- Agents: release session locks on process termination and cover more signals. (#2483) Thanks @janeexai.
- Telegram: harden polling + retry behavior for transient network errors and Node 22 transport issues. (#2420) Thanks @techboss.
- Telegram: wrap reasoning italics per line to avoid raw underscores. (#2181) Thanks @YuriNachos.
- Telegram: log fetch/API errors in delivery to avoid unhandled rejections. (#2492) Thanks @altryne.
- Voice Call: enforce Twilio webhook signature verification for ngrok URLs; disable ngrok free tier bypass by default.
- Security: harden Tailscale Serve auth by validating identity via local tailscaled before trusting headers.
- Build: align memory-core peer dependency with lockfile.
+33 -33
View File
@@ -42,7 +42,7 @@ export function createMSTeamsReplyDispatcher(params: {
}) {
const core = getMSTeamsRuntime();
const sendTypingIndicator = async () => {
await params.context.sendActivity({ type: "typing" });
await params.context.sendActivities([{ type: "typing" }]);
};
const typingCallbacks = createTypingCallbacks({
start: sendTypingIndicator,
@@ -70,38 +70,38 @@ export function createMSTeamsReplyDispatcher(params: {
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg: params.cfg,
channel: "msteams",
});
const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit,
chunkText: true,
mediaMode: "split",
tableMode,
chunkMode,
});
const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
});
const ids = await sendMSTeamsMessages({
replyStyle: params.replyStyle,
adapter: params.adapter,
appId: params.appId,
conversationRef: params.conversationRef,
context: params.context,
messages,
// Enable default retry/backoff for throttling/transient failures.
retry: {},
onRetry: (event) => {
params.log.debug("retrying send", {
replyStyle: params.replyStyle,
...event,
});
},
tokenProvider: params.tokenProvider,
sharePointSiteId: params.sharePointSiteId,
mediaMaxBytes,
});
if (ids.length > 0) params.onSentMessageIds?.(ids);
});
const messages = renderReplyPayloadsToMessages([payload], {
textChunkLimit: params.textLimit,
chunkText: true,
mediaMode: "split",
tableMode,
chunkMode,
});
const mediaMaxBytes = resolveChannelMediaMaxBytes({
cfg: params.cfg,
resolveChannelLimitMb: ({ cfg }) => cfg.channels?.msteams?.mediaMaxMb,
});
const ids = await sendMSTeamsMessages({
replyStyle: params.replyStyle,
adapter: params.adapter,
appId: params.appId,
conversationRef: params.conversationRef,
context: params.context,
messages,
// Enable default retry/backoff for throttling/transient failures.
retry: {},
onRetry: (event) => {
params.log.debug("retrying send", {
replyStyle: params.replyStyle,
...event,
});
},
tokenProvider: params.tokenProvider,
sharePointSiteId: params.sharePointSiteId,
mediaMaxBytes,
});
if (ids.length > 0) params.onSentMessageIds?.(ids);
},
onError: (err, info) => {
const errMsg = formatUnknownError(err);
@@ -1,93 +0,0 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { resolveApiKeyForProfile } from "./oauth.js";
import type { AuthProfileStore } from "./types.js";
describe("resolveApiKeyForProfile", () => {
let tmpDir: string;
let mainAgentDir: string;
let secondaryAgentDir: string;
beforeEach(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), "oauth-test-"));
mainAgentDir = path.join(tmpDir, "agents", "main", "agent");
secondaryAgentDir = path.join(tmpDir, "agents", "kids", "agent");
await fs.promises.mkdir(mainAgentDir, { recursive: true });
await fs.promises.mkdir(secondaryAgentDir, { recursive: true });
// Set env to use our temp dir
process.env.CLAWDBOT_STATE_DIR = tmpDir;
});
afterEach(async () => {
delete process.env.CLAWDBOT_STATE_DIR;
await fs.promises.rm(tmpDir, { recursive: true, force: true });
vi.restoreAllMocks();
});
it("falls back to main agent credentials when secondary agent token is expired and refresh fails", async () => {
const profileId = "anthropic:claude-cli";
const now = Date.now();
const expiredTime = now - 60 * 60 * 1000; // 1 hour ago
const freshTime = now + 60 * 60 * 1000; // 1 hour from now
// Write expired credentials for secondary agent
const secondaryStore: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "expired-access-token",
refresh: "expired-refresh-token",
expires: expiredTime,
},
},
};
await fs.promises.writeFile(
path.join(secondaryAgentDir, "auth-profiles.json"),
JSON.stringify(secondaryStore),
);
// Write fresh credentials for main agent
const mainStore: AuthProfileStore = {
version: 1,
profiles: {
[profileId]: {
type: "oauth",
provider: "anthropic",
access: "fresh-access-token",
refresh: "fresh-refresh-token",
expires: freshTime,
},
},
};
await fs.promises.writeFile(
path.join(mainAgentDir, "auth-profiles.json"),
JSON.stringify(mainStore),
);
// The secondary agent should fall back to main agent's credentials
// when its own token refresh fails
const result = await resolveApiKeyForProfile({
store: secondaryStore,
profileId,
agentDir: secondaryAgentDir,
});
expect(result).not.toBeNull();
expect(result?.apiKey).toBe("fresh-access-token");
expect(result?.provider).toBe("anthropic");
// Verify the credentials were copied to the secondary agent
const updatedSecondaryStore = JSON.parse(
await fs.promises.readFile(path.join(secondaryAgentDir, "auth-profiles.json"), "utf8"),
) as AuthProfileStore;
expect(updatedSecondaryStore.profiles[profileId]).toMatchObject({
access: "fresh-access-token",
expires: freshTime,
});
});
});
+1 -27
View File
@@ -4,7 +4,7 @@ import lockfile from "proper-lockfile";
import type { ClawdbotConfig } from "../../config/config.js";
import { refreshChutesTokens } from "../chutes-oauth.js";
import { refreshQwenPortalCredentials } from "../../providers/qwen-portal-oauth.js";
import { AUTH_STORE_LOCK_OPTIONS, log } from "./constants.js";
import { AUTH_STORE_LOCK_OPTIONS } from "./constants.js";
import { formatAuthDoctorHint } from "./doctor.js";
import { ensureAuthStoreFile, resolveAuthStorePath } from "./paths.js";
import { suggestOAuthProfileIdForLegacyDefault } from "./repair.js";
@@ -196,32 +196,6 @@ export async function resolveApiKeyForProfile(params: {
// keep original error
}
}
// Fallback: if this is a secondary agent, try using the main agent's credentials
if (params.agentDir) {
try {
const mainStore = ensureAuthProfileStore(undefined); // main agent (no agentDir)
const mainCred = mainStore.profiles[profileId];
if (mainCred?.type === "oauth" && Date.now() < mainCred.expires) {
// Main agent has fresh credentials - copy them to this agent and use them
refreshedStore.profiles[profileId] = { ...mainCred };
saveAuthProfileStore(refreshedStore, params.agentDir);
log.info("inherited fresh OAuth credentials from main agent", {
profileId,
agentDir: params.agentDir,
expires: new Date(mainCred.expires).toISOString(),
});
return {
apiKey: buildOAuthApiKey(mainCred.provider, mainCred),
provider: mainCred.provider,
email: mainCred.email,
};
}
} catch {
// keep original error if main agent fallback also fails
}
}
const message = error instanceof Error ? error.message : String(error);
const hint = formatAuthDoctorHint({
cfg,
+8 -14
View File
@@ -80,20 +80,14 @@ async function promptTelegramAllowFrom(params: {
if (!token) return null;
const username = stripped.startsWith("@") ? stripped : `@${stripped}`;
const url = `https://api.telegram.org/bot${token}/getChat?chat_id=${encodeURIComponent(username)}`;
try {
const res = await fetch(url);
if (!res.ok) return null;
const data = (await res.json().catch(() => null)) as {
ok?: boolean;
result?: { id?: number | string };
} | null;
const id = data?.ok ? data?.result?.id : undefined;
if (typeof id === "number" || typeof id === "string") return String(id);
return null;
} catch {
// Network error during username lookup - return null to prompt user for numeric ID
return null;
}
const res = await fetch(url);
const data = (await res.json().catch(() => null)) as {
ok?: boolean;
result?: { id?: number | string };
} | null;
const id = data?.ok ? data?.result?.id : undefined;
if (typeof id === "number" || typeof id === "string") return String(id);
return null;
};
const parseInput = (value: string) =>
+18 -36
View File
@@ -25,24 +25,6 @@ import type { TelegramContext } from "./types.js";
const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
const VOICE_FORBIDDEN_RE = /VOICE_MESSAGES_FORBIDDEN/;
/**
* Wraps a Telegram API call with error logging. Ensures network failures are
* logged with context before propagating, preventing silent unhandled rejections.
*/
async function withMediaErrorHandler<T>(
operation: string,
runtime: RuntimeEnv,
fn: () => Promise<T>,
): Promise<T> {
try {
return await fn();
} catch (err) {
const errText = formatErrorMessage(err);
runtime.error?.(danger(`telegram ${operation} failed: ${errText}`));
throw err;
}
}
export async function deliverReplies(params: {
replies: ReplyPayload[];
chatId: string;
@@ -164,17 +146,17 @@ export async function deliverReplies(params: {
mediaParams.message_thread_id = threadParams.message_thread_id;
}
if (isGif) {
await withMediaErrorHandler("sendAnimation", runtime, () =>
bot.api.sendAnimation(chatId, file, { ...mediaParams }),
);
await bot.api.sendAnimation(chatId, file, {
...mediaParams,
});
} else if (kind === "image") {
await withMediaErrorHandler("sendPhoto", runtime, () =>
bot.api.sendPhoto(chatId, file, { ...mediaParams }),
);
await bot.api.sendPhoto(chatId, file, {
...mediaParams,
});
} else if (kind === "video") {
await withMediaErrorHandler("sendVideo", runtime, () =>
bot.api.sendVideo(chatId, file, { ...mediaParams }),
);
await bot.api.sendVideo(chatId, file, {
...mediaParams,
});
} else if (kind === "audio") {
const { useVoice } = resolveTelegramVoiceSend({
wantsVoice: reply.audioAsVoice === true, // default false (backward compatible)
@@ -187,9 +169,9 @@ export async function deliverReplies(params: {
// Switch typing indicator to record_voice before sending.
await params.onVoiceRecording?.();
try {
await withMediaErrorHandler("sendVoice", runtime, () =>
bot.api.sendVoice(chatId, file, { ...mediaParams }),
);
await bot.api.sendVoice(chatId, file, {
...mediaParams,
});
} catch (voiceErr) {
// Fall back to text if voice messages are forbidden in this chat.
// This happens when the recipient has Telegram Premium privacy settings
@@ -222,14 +204,14 @@ export async function deliverReplies(params: {
}
} else {
// Audio file - displays with metadata (title, duration) - DEFAULT
await withMediaErrorHandler("sendAudio", runtime, () =>
bot.api.sendAudio(chatId, file, { ...mediaParams }),
);
await bot.api.sendAudio(chatId, file, {
...mediaParams,
});
}
} else {
await withMediaErrorHandler("sendDocument", runtime, () =>
bot.api.sendDocument(chatId, file, { ...mediaParams }),
);
await bot.api.sendDocument(chatId, file, {
...mediaParams,
});
}
if (replyToId && !hasReplied) {
hasReplied = true;
-6
View File
@@ -127,12 +127,6 @@ export function connectGateway(host: GatewayHost) {
host.lastError = null;
host.hello = hello;
applySnapshot(host, hello);
// Reset orphaned chat run state from before disconnect.
// Any in-flight run's final event was lost during the disconnect window.
host.chatRunId = null;
(host as unknown as { chatStream: string | null }).chatStream = null;
(host as unknown as { chatStreamStartedAt: number | null }).chatStreamStartedAt = null;
resetToolStream(host as unknown as Parameters<typeof resetToolStream>[0]);
void loadAssistantIdentity(host as unknown as ClawdbotApp);
void loadAgents(host as unknown as ClawdbotApp);
void loadNodes(host as unknown as ClawdbotApp, { quiet: true });