zubo 0.1.8 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "zubo",
3
- "version": "0.1.8",
3
+ "version": "0.1.11",
4
4
  "description": "Your AI agent that never forgets. Persistent memory, 25+ tools, 7 channels, 11+ LLM providers — runs entirely on your machine.",
5
5
  "license": "MIT",
6
6
  "author": "thomaskanze",
package/site/index.html CHANGED
@@ -4,7 +4,7 @@
4
4
  <meta charset="UTF-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
6
  <title>Zubo — Your AI Agent. Your Machine. Your Rules.</title>
7
- <meta name="description" content="Open-source AI agent that runs on your machine. One command to install, one file to configure. Persistent memory, 25+ tools, 7 messaging channels, 11+ LLM providers. MCP compatible. Zero complexity.">
7
+ <meta name="description" content="Open-source AI agent that runs on your machine. One command to install, one file to configure. Persistent memory, 25+ tools, 7 messaging channels, 12+ LLM providers. MCP compatible. Zero complexity.">
8
8
  <meta name="theme-color" content="#060608">
9
9
  <link rel="canonical" href="https://zubo.bot/">
10
10
  <meta property="og:title" content="Zubo — Your AI Agent. Your Machine. Your Rules.">
@@ -305,11 +305,11 @@
305
305
  <svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/><polyline points="7.5 4.21 12 6.81 16.5 4.21"/><polyline points="7.5 19.79 7.5 14.6 3 12"/><polyline points="21 12 16.5 14.6 16.5 19.79"/><polyline points="3.27 6.96 12 12.01 20.73 6.96"/><line x1="12" y1="22.08" x2="12" y2="12"/></svg>
306
306
  </div>
307
307
  <h3>Any LLM, Your Choice</h3>
308
- <p>11+ providers including Anthropic, OpenAI, Gemini, Ollama, Groq, DeepSeek, and more. Smart routing sends simple queries to fast, cheap models automatically &mdash; so you save money without thinking about it.</p>
308
+ <p>12+ providers including Anthropic, OpenAI, MiniMax, Ollama, Groq, DeepSeek, and more. Smart routing sends simple queries to fast, cheap models automatically &mdash; so you save money without thinking about it.</p>
309
309
  <div class="bento-models">
310
310
  <span class="model-tag">Claude</span>
311
311
  <span class="model-tag">GPT-4o</span>
312
- <span class="model-tag">Gemini</span>
312
+ <span class="model-tag">MiniMax</span>
313
313
  <span class="model-tag">DeepSeek</span>
314
314
  <span class="model-tag">Llama</span>
315
315
  <span class="model-tag">Mistral</span>
@@ -104,8 +104,8 @@ export async function delegateToAgent(
104
104
 
105
105
  // Filter out privileged tools from sub-agents to prevent escalation
106
106
  const FORBIDDEN_DELEGATION_TOOLS = new Set([
107
- "delegate", "manage_agents", "config_update",
108
- "secret_set", "secret_delete", "manage_skills",
107
+ "delegate", "delegate_task", "manage_agents",
108
+ "config_update", "secret_set", "secret_delete", "manage_skills",
109
109
  ]);
110
110
  const filteredTools = agent.tools.filter(
111
111
  (t) => !FORBIDDEN_DELEGATION_TOOLS.has(t)
@@ -5,7 +5,7 @@ const DEFAULT_PERSONALITY = `You are Zubo, a personal AI agent. You are friendly
5
5
 
6
6
  ## How you behave
7
7
 
8
- **Act first.** When the user asks you to do something, do it. Don't describe what you could do — use your tools and make it happen. If you need something from the user (an API key, a preference, a clarification), ask for it directly, and once you get it, act on it immediately.
8
+ **Act first.** When the user asks you to do something, do it immediately. Don't describe what you could do — use your tools and make it happen. Don't ask for permission to do what the user just asked you to do (e.g. if they say "check my mails", just call the gmail tool — don't ask "do you approve me reading your emails?"). If you need something from the user (an API key, a preference, a clarification), ask for it directly, and once you get it, act on it immediately.
9
9
 
10
10
  **Be concise.** Answer in the fewest words that fully address the question. No filler, no preamble. Long explanations only when explicitly asked.
11
11
 
@@ -47,9 +47,15 @@ const DEFAULT_PERSONALITY = `You are Zubo, a personal AI agent. You are friendly
47
47
  - Delegate tasks using the delegate tool. Sub-agents share your memory but have scoped tools.
48
48
  - Keep the main conversation lightweight. Offload complex, self-contained tasks.
49
49
 
50
- ## Connecting services
50
+ ## Connecting services (integrations)
51
51
 
52
- - **Google** (Gmail, Calendar, Drive): Requires OAuth 2.0. Need both client_id (ends with .apps.googleusercontent.com) and client_secret (starts with GOCSPX-) from Google Cloud Console. Use google_oauth to start the flow.
52
+ Service integrations and LLM providers are COMPLETELY SEPARATE concepts. Never confuse them:
53
+ - **Integrations** = external services like Gmail, GitHub, Notion (connected via OAuth or API tokens)
54
+ - **LLM providers** = the AI model you use for thinking (Anthropic, OpenAI, etc.)
55
+ - If Gmail access is broken, the fix is to re-authenticate Google OAuth — NOT to change the LLM provider. Never suggest changing LLM providers as a fix for integration issues.
56
+
57
+ How to connect services:
58
+ - **Google** (Gmail, Calendar, Drive): Requires OAuth 2.0. Need both client_id (ends with .apps.googleusercontent.com) and client_secret (starts with GOCSPX-) from Google Cloud Console. The OAuth app type should be "Desktop app" (NOT "Web application"). Use google_oauth tool to manage the full flow.
53
59
  - **GitHub**: Personal Access Token. Store as github_token via secret_set.
54
60
  - **Notion**: Internal Integration Token from notion.so/my-integrations. Store as notion_token.
55
61
  - **Linear**: Personal API Key from Linear > Settings > API. Store as linear_token.
@@ -58,6 +64,30 @@ const DEFAULT_PERSONALITY = `You are Zubo, a personal AI agent. You are friendly
58
64
  - **Twitter/X**: Bearer token for reading, full OAuth keys for posting. Store as twitter_bearer_token.
59
65
  - When the user says "connect my GitHub" or similar, ask for the credentials, store them with secret_set, then call connect_service.
60
66
 
67
+ When Google OAuth expires or a Google tool returns an auth error:
68
+ - Call google_oauth with action "start" (with NO parameters). It will automatically use the stored client_id and client_secret from secrets. Do NOT ask the user to re-provide credentials that are already saved.
69
+ - Only ask the user for credentials if google_oauth returns an error saying credentials are missing.
70
+ - For non-Google services, ask the user to provide a new token and store it with secret_set.
71
+ - NEVER suggest unrelated solutions like changing the LLM provider.
72
+ - NEVER try to guide the user through OAuth setup manually — always use the google_oauth tool.
73
+
74
+ ## LLM providers
75
+
76
+ You support 12+ LLM providers. The user can switch at any time using config_update.
77
+
78
+ **API-based providers** (need an API key):
79
+ - Anthropic (Claude), OpenAI (GPT), MiniMax (M2.5), Groq, Together, DeepSeek, xAI (Grok), Fireworks, Cerebras, Perplexity, OpenRouter
80
+ - To set up: config_update with path "providers.<name>" value {"apiKey":"...","model":"..."}, then set "activeProvider" to "<name>".
81
+
82
+ **Local providers** (no API key needed):
83
+ - Ollama, LM Studio — run models locally
84
+
85
+ **CLI-based providers** (NO API key needed — they use the user's own CLI authentication):
86
+ - **Claude Code** (provider name: "claude-code"): Spawns the Claude Code CLI. The user must have it installed and authenticated on their machine. To activate: config_update with path "providers.claude-code" value {"model":"claude-sonnet-4-5-20250929"}, then set "activeProvider" to "claude-code". NO apiKey field needed.
87
+ - **OpenAI Codex** (provider name: "codex"): Spawns the Codex CLI. The user must have it installed and authenticated on their machine. To activate: config_update with path "providers.codex" value {"model":"o4-mini"}, then set "activeProvider" to "codex". NO apiKey field needed.
88
+
89
+ IMPORTANT: When a user says "use codex" or "use claude code", do NOT ask for an API key. These are CLI tools that authenticate via the user's own terminal session. Just set the provider config and activate it.
90
+
61
91
  ## Tool confirmation
62
92
 
63
93
  Some tools (shell, file_write) require user confirmation. When a tool returns a confirmation request, explain what you want to do and why, then ask for permission. Never set _confirmed without explicit user approval.
@@ -154,8 +154,12 @@ class ImapClient {
154
154
  const bodyMatch = result.match(/\r\n\r\n([\s\S]*?)(?:\r\n\)|\r\nA\d+)/);
155
155
  if (bodyMatch) body = bodyMatch[1].trim();
156
156
 
157
- // Strip HTML tags if present
158
- body = body.replace(/<[^>]+>/g, "").trim();
157
+ // Strip script/style blocks and all HTML tags
158
+ body = body
159
+ .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, "")
160
+ .replace(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/gi, "")
161
+ .replace(/<[^>]+>/g, "")
162
+ .trim();
159
163
  // Decode basic quoted-printable
160
164
  body = body.replace(/=\r?\n/g, "").replace(/=([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
161
165
 
@@ -1109,29 +1109,32 @@ function handleDashboardApi(url: URL, req: Request): Response | null {
1109
1109
  }
1110
1110
  }
1111
1111
 
1112
- // GET /api/dashboard/secrets/:name — check if a secret exists (NEVER reveals values)
1112
+ // GET /api/dashboard/secrets/:name — reveal a secret value (dashboard is localhost-only + session-authenticated)
1113
1113
  if (path.startsWith("/secrets/") && req.method === "GET") {
1114
1114
  const secretName = decodeURIComponent(path.replace("/secrets/", ""));
1115
1115
  if (!secretName || !/^[a-z0-9_]+$/.test(secretName)) {
1116
1116
  return Response.json({ error: "Invalid secret name" }, { status: 400 });
1117
1117
  }
1118
1118
  try {
1119
+ const { maskToken } = require("../util/mask") as { maskToken: (s: string) => string };
1119
1120
  // Check config provider keys first
1120
1121
  if (secretName.endsWith("_api_key")) {
1121
1122
  const provider = secretName.replace(/_api_key$/, "");
1122
1123
  try {
1123
1124
  const cfg = JSON.parse(readFileSync(paths.config, "utf-8"));
1124
1125
  if (cfg.providers?.[provider]?.apiKey) {
1125
- return Response.json({ name: secretName, exists: true, source: "config" });
1126
+ const key = cfg.providers[provider].apiKey;
1127
+ return Response.json({ name: secretName, value: maskToken(key), source: "config" });
1126
1128
  }
1127
1129
  } catch (err: any) {
1128
1130
  logger.warn("Failed to read provider secret from config", { error: (err as Error).message });
1129
1131
  }
1130
1132
  }
1131
1133
  const db = getDb();
1132
- const row = db.query("SELECT name FROM secrets WHERE name = ?").get(secretName) as { name: string } | null;
1133
- if (!row) return Response.json({ error: "Not found" }, { status: 404 });
1134
- return Response.json({ name: secretName, exists: true, source: "secrets" });
1134
+ const { getSecret } = require("../secrets/store") as { getSecret: (name: string) => string | null };
1135
+ const value = getSecret(secretName);
1136
+ if (!value) return Response.json({ error: "Not found" }, { status: 404 });
1137
+ return Response.json({ name: secretName, value: maskToken(value), source: "secrets" });
1135
1138
  } catch (err: any) {
1136
1139
  return Response.json({ error: err.message }, { status: 500 });
1137
1140
  }
@@ -2013,8 +2016,15 @@ async function handleRequest(
2013
2016
  // CORS protection: reject cross-origin API requests
2014
2017
  if (url.pathname.startsWith("/api/")) {
2015
2018
  const origin = req.headers.get("origin");
2016
- if (origin && !origin.startsWith("http://localhost:") && !origin.startsWith("http://127.0.0.1:")) {
2017
- return Response.json({ error: "Cross-origin requests are not allowed" }, { status: 403 });
2019
+ if (origin) {
2020
+ const actualPort = server?.port ?? port;
2021
+ const allowed = [
2022
+ `http://localhost:${actualPort}`,
2023
+ `http://127.0.0.1:${actualPort}`,
2024
+ ];
2025
+ if (!allowed.includes(origin)) {
2026
+ return Response.json({ error: "Cross-origin requests are not allowed" }, { status: 403 });
2027
+ }
2018
2028
  }
2019
2029
  }
2020
2030
 
@@ -2038,15 +2048,20 @@ async function handleRequest(
2038
2048
  });
2039
2049
  }
2040
2050
 
2041
- // --- OAuth routes (no auth required — part of OAuth flow) ---
2051
+ // --- OAuth routes ---
2042
2052
 
2043
- // GET /oauth/:provider/authorize — redirect user to provider's auth page
2053
+ // GET /oauth/:provider/authorize — redirect user to provider's auth page (requires session key)
2044
2054
  const ALLOWED_OAUTH_PROVIDERS = new Set(["google", "github", "notion", "linear", "slack"]);
2045
2055
  if (url.pathname.match(/^\/oauth\/[a-z]+\/authorize$/) && req.method === "GET") {
2046
2056
  const provider = url.pathname.split("/")[2];
2047
2057
  if (!ALLOWED_OAUTH_PROVIDERS.has(provider)) {
2048
2058
  return Response.json({ error: "Unknown OAuth provider" }, { status: 400 });
2049
2059
  }
2060
+ // Require session key to prevent unauthorized OAuth initiation
2061
+ const sk = url.searchParams.get("sk");
2062
+ if (sk !== sessionKey) {
2063
+ return Response.json({ error: "Unauthorized" }, { status: 401 });
2064
+ }
2050
2065
  try {
2051
2066
  const { getAuthUrl } = await import("../tools/oauth");
2052
2067
  const actualPort = server?.port ?? port;
@@ -13,7 +13,7 @@ export function createWhatsAppAdapter(
13
13
  let sock: any = null;
14
14
  const sessionDir = authDir ?? join(paths.root, "whatsapp-auth");
15
15
 
16
- return {
16
+ const adapter: ChannelAdapter = {
17
17
  channelName: "whatsapp",
18
18
 
19
19
  start() {
@@ -40,8 +40,11 @@ export function createWhatsAppAdapter(
40
40
  const shouldReconnect =
41
41
  lastDisconnect?.error?.output?.statusCode !== DisconnectReason.loggedOut;
42
42
  if (shouldReconnect) {
43
- logger.info("WhatsApp reconnecting...");
44
- // Re-create connection
43
+ logger.info("WhatsApp reconnecting in 3s...");
44
+ sock = null;
45
+ setTimeout(() => adapter.start(), 3000);
46
+ } else {
47
+ logger.info("WhatsApp logged out — not reconnecting");
45
48
  }
46
49
  } else if (connection === "open") {
47
50
  logger.info("WhatsApp connected");
@@ -116,4 +119,6 @@ export function createWhatsAppAdapter(
116
119
  }
117
120
  },
118
121
  };
122
+
123
+ return adapter;
119
124
  }
@@ -67,11 +67,16 @@ export class ClaudeCodeProvider implements LlmProvider {
67
67
  try { proc.kill(); } catch {}
68
68
  }, 300000);
69
69
 
70
- const exitCode = await proc.exited;
71
- clearTimeout(timeout);
72
-
73
- const stdout = await new Response(proc.stdout as ReadableStream).text();
74
- const stderr = await new Response(proc.stderr as ReadableStream).text();
70
+ let exitCode: number;
71
+ let stdout: string;
72
+ let stderr: string;
73
+ try {
74
+ exitCode = await proc.exited;
75
+ stdout = await new Response(proc.stdout as ReadableStream).text();
76
+ stderr = await new Response(proc.stderr as ReadableStream).text();
77
+ } finally {
78
+ clearTimeout(timeout);
79
+ }
75
80
 
76
81
  if (exitCode !== 0) {
77
82
  throw new Error(`Claude Code CLI exited with code ${exitCode}: ${stderr.slice(0, 500)}`);
package/src/llm/codex.ts CHANGED
@@ -64,11 +64,16 @@ export class CodexProvider implements LlmProvider {
64
64
  try { proc.kill(); } catch {}
65
65
  }, 300000);
66
66
 
67
- const exitCode = await proc.exited;
68
- clearTimeout(timeout);
69
-
70
- const stdout = await new Response(proc.stdout as ReadableStream).text();
71
- const stderr = await new Response(proc.stderr as ReadableStream).text();
67
+ let exitCode: number;
68
+ let stdout: string;
69
+ let stderr: string;
70
+ try {
71
+ exitCode = await proc.exited;
72
+ stdout = await new Response(proc.stdout as ReadableStream).text();
73
+ stderr = await new Response(proc.stderr as ReadableStream).text();
74
+ } finally {
75
+ clearTimeout(timeout);
76
+ }
72
77
 
73
78
  if (exitCode !== 0) {
74
79
  throw new Error(`Codex CLI exited with code ${exitCode}: ${stderr.slice(0, 500)}`);
@@ -18,6 +18,7 @@ const KNOWN_BASE_URLS: Record<string, string> = {
18
18
  cerebras: "https://api.cerebras.ai/v1",
19
19
  perplexity: "https://api.perplexity.ai",
20
20
  xai: "https://api.x.ai/v1",
21
+ minimax: "https://api.minimax.io/v1",
21
22
  ollama: "http://localhost:11434/v1",
22
23
  lmstudio: "http://localhost:1234/v1",
23
24
  };
@@ -28,6 +28,7 @@ const DEFAULT_CONTEXT_WINDOWS: Record<string, number> = {
28
28
  cerebras: 128_000,
29
29
  perplexity: 128_000,
30
30
  xai: 131_072, // Grok 4.1 supports up to 2M; safe default
31
+ minimax: 204_800, // MiniMax M2.5
31
32
  ollama: 8_000,
32
33
  lmstudio: 8_000,
33
34
  };
@@ -375,7 +376,7 @@ export class OpenAICompatProvider implements LlmProvider {
375
376
  } finally {
376
377
  reader.releaseLock();
377
378
  // Ensure the response body is fully consumed/cancelled to free resources
378
- try { await res.body!.cancel(); } catch (err: any) { logger.warn("Failed to cancel response body stream", { error: (err as Error).message }); }
379
+ try { await res.body?.cancel(); } catch (err: any) { logger.warn("Failed to cancel response body stream", { error: (err as Error).message }); }
379
380
  }
380
381
 
381
382
  // Emit tool_use_end for all tool calls
@@ -147,8 +147,6 @@ export class SmartRouterProvider implements LlmProvider {
147
147
  model: this.fast.model,
148
148
  reason: "simple query",
149
149
  });
150
- this.providerName = this.fast.providerName;
151
- this.model = this.fast.model;
152
150
  return this.fast;
153
151
  }
154
152
 
@@ -157,8 +155,6 @@ export class SmartRouterProvider implements LlmProvider {
157
155
  model: this.primary.model,
158
156
  reason: "complex query",
159
157
  });
160
- this.providerName = this.primary.providerName;
161
- this.model = this.primary.model;
162
158
  return this.primary;
163
159
  }
164
160
 
@@ -172,8 +168,6 @@ export class SmartRouterProvider implements LlmProvider {
172
168
  logger.warn("Fast model failed, falling back to primary", {
173
169
  error: err.message,
174
170
  });
175
- this.providerName = this.primary.providerName;
176
- this.model = this.primary.model;
177
171
  return this.primary.chat(request);
178
172
  }
179
173
  }
@@ -212,13 +206,9 @@ export class SmartRouterProvider implements LlmProvider {
212
206
  }
213
207
 
214
208
  // Fallback to primary stream
215
- this.providerName = this.primary.providerName;
216
- this.model = this.primary.model;
217
209
  } else {
218
210
  // Fast model has no streaming, fall back to primary
219
211
  logger.info("Fast model has no streaming support, using primary");
220
- this.providerName = this.primary.providerName;
221
- this.model = this.primary.model;
222
212
  }
223
213
  }
224
214
 
package/src/setup.ts CHANGED
@@ -168,6 +168,21 @@ const PROVIDER_OPTIONS: ProviderOption[] = [
168
168
  },
169
169
  {
170
170
  key: "9",
171
+ label: "MiniMax (M2.5)",
172
+ setup: async () => {
173
+ const apiKey = await prompt(" MiniMax API key: ");
174
+ const model = await prompt(" Model [MiniMax-M2.5] (or MiniMax-M2.5-highspeed): ");
175
+ return {
176
+ name: "minimax",
177
+ config: {
178
+ apiKey,
179
+ model: model || "MiniMax-M2.5",
180
+ },
181
+ };
182
+ },
183
+ },
184
+ {
185
+ key: "10",
171
186
  label: "Fireworks AI",
172
187
  setup: async () => {
173
188
  const apiKey = await prompt(" Fireworks API key: ");
@@ -183,7 +198,7 @@ const PROVIDER_OPTIONS: ProviderOption[] = [
183
198
  },
184
199
  },
185
200
  {
186
- key: "10",
201
+ key: "11",
187
202
  label: "Cerebras",
188
203
  setup: async () => {
189
204
  const apiKey = await prompt(" Cerebras API key: ");
@@ -199,7 +214,7 @@ const PROVIDER_OPTIONS: ProviderOption[] = [
199
214
  },
200
215
  },
201
216
  {
202
- key: "11",
217
+ key: "12",
203
218
  label: "LM Studio (local)",
204
219
  setup: async () => {
205
220
  const baseUrl = await prompt(" LM Studio URL [http://localhost:1234/v1]: ");
@@ -215,7 +230,7 @@ const PROVIDER_OPTIONS: ProviderOption[] = [
215
230
  },
216
231
  },
217
232
  {
218
- key: "12",
233
+ key: "13",
219
234
  label: "Other (OpenAI-compatible)",
220
235
  setup: async () => {
221
236
  const name = await prompt(" Provider name: ");
@@ -35,7 +35,9 @@ export function registerDelegateTool(llm: LlmProvider) {
35
35
 
36
36
  // Dynamic import to avoid circular dependency
37
37
  const { delegateToAgent } = await import("../../agent/delegate");
38
- const result = await delegateToAgent(llm, agent, task);
38
+ const crypto = await import("crypto");
39
+ const contextId = "delegation-" + crypto.randomUUID().slice(0, 8);
40
+ const result = await delegateToAgent(llm, agent, task, contextId);
39
41
  return result;
40
42
  },
41
43
  });
@@ -80,6 +80,10 @@ async function handleStart(
80
80
  clientId?: string,
81
81
  clientSecret?: string
82
82
  ): Promise<string> {
83
+ // If credentials not provided as parameters, check stored secrets
84
+ if (!clientId) clientId = getSecret("google_client_id") ?? undefined;
85
+ if (!clientSecret) clientSecret = getSecret("google_client_secret") ?? undefined;
86
+
83
87
  if (!clientId || !clientSecret) {
84
88
  return JSON.stringify({
85
89
  error:
@@ -75,6 +75,7 @@ export function registerManageAgentsTool() {
75
75
  const FORBIDDEN_SUBAGENT_TOOLS = [
76
76
  "manage_agents", // Prevents recursive agent creation
77
77
  "delegate", // Prevents delegation loops
78
+ "delegate_task", // Prevents ad-hoc delegation loops
78
79
  "config_update", // Prevents config tampering
79
80
  "secret_set", // Prevents secret manipulation
80
81
  "secret_delete", // Prevents secret deletion
@@ -433,6 +433,11 @@ export async function getToken(provider: string): Promise<string | null> {
433
433
  return await refreshPromise;
434
434
  } catch (err: any) {
435
435
  logger.warn(`[OAuth] Token refresh failed for ${provider}`, { error: err.message });
436
+ // If refresh token is invalid/revoked, remove stale token to stop retry loops
437
+ if (err.message?.includes("400") || err.message?.includes("401") || err.message?.includes("invalid")) {
438
+ logger.warn(`[OAuth] Removing stale token for ${provider} — user must re-authenticate`);
439
+ try { revokeToken(provider); } catch {}
440
+ }
436
441
  return null;
437
442
  }
438
443
  }
@@ -39,6 +39,20 @@ const DEFAULT_PERMISSIONS: Record<string, ToolPermission> = {
39
39
  image_generate: "auto",
40
40
  google_oauth: "auto",
41
41
 
42
+ // Integration skills — auto because user explicitly requests these actions
43
+ gmail: "auto",
44
+ google_calendar: "auto",
45
+ google_sheets: "auto",
46
+ google_docs: "auto",
47
+ google_drive: "auto",
48
+ github_issues: "auto",
49
+ github_repos: "auto",
50
+ github_prs: "auto",
51
+ notion_pages: "auto",
52
+ linear_issues: "auto",
53
+ jira_issues: "auto",
54
+ slack_messages: "auto",
55
+
42
56
  // Built-in skills — require confirmation (network writes, code execution, system access)
43
57
  http_request: "confirm",
44
58
  code_interpreter: "confirm",
package/src/util/costs.ts CHANGED
@@ -24,6 +24,10 @@ const MODEL_PRICING: Record<string, { input: number; output: number }> = {
24
24
  "deepseek-chat": { input: 0.56, output: 1.68 },
25
25
  "deepseek-reasoner": { input: 0.55, output: 2.19 },
26
26
 
27
+ // MiniMax
28
+ "MiniMax-M2.5": { input: 0.3, output: 1.2 },
29
+ "MiniMax-M2.5-highspeed": { input: 0.3, output: 2.4 },
30
+
27
31
  // Groq
28
32
  "llama-3.3-70b-versatile": { input: 0.59, output: 0.79 },
29
33
  "llama-3.1-8b-instant": { input: 0.05, output: 0.08 },
@@ -22,11 +22,18 @@ describe("Tool Executor", () => {
22
22
  unregisterTool("error_tool");
23
23
  });
24
24
 
25
- it("should execute a registered tool", async () => {
26
- const result = await executeTool("test_tool", "t1", { msg: "hello" });
25
+ it("should execute a registered tool (with confirmation)", async () => {
26
+ // Unknown tools default to "confirm" first call returns confirmation token
27
+ const confirmResult = await executeTool("test_tool", "t1", { msg: "hello" });
28
+ expect(confirmResult.content).toContain("Confirmation Token:");
29
+ const tokenMatch = confirmResult.content.match(/Confirmation Token: ([a-f0-9-]+)/);
30
+ expect(tokenMatch).toBeTruthy();
31
+
32
+ // Second call with token executes the tool
33
+ const result = await executeTool("test_tool", "t1b", { msg: "hello", _confirmToken: tokenMatch![1] });
27
34
  expect(result.content).toBe("echo: hello");
28
35
  expect(result.is_error).toBe(false);
29
- expect(result.tool_use_id).toBe("t1");
36
+ expect(result.tool_use_id).toBe("t1b");
30
37
  });
31
38
 
32
39
  it("should return error for unknown tool", async () => {
@@ -53,7 +60,14 @@ describe("Tool Executor", () => {
53
60
  },
54
61
  });
55
62
 
56
- const result = await executeTool("error_tool", "t4", {});
63
+ // Unknown tools default to "confirm" — first call returns confirmation token
64
+ const confirmResult = await executeTool("error_tool", "t4", {});
65
+ expect(confirmResult.content).toContain("Confirmation Token:");
66
+ const tokenMatch = confirmResult.content.match(/Confirmation Token: ([a-f0-9-]+)/);
67
+ expect(tokenMatch).toBeTruthy();
68
+
69
+ // Second call with token — tool throws
70
+ const result = await executeTool("error_tool", "t4b", { _confirmToken: tokenMatch![1] });
57
71
  expect(result.is_error).toBe(true);
58
72
  expect(result.content).toContain("intentional failure");
59
73
  });
@@ -14,8 +14,8 @@ describe("getToolPermission", () => {
14
14
  expect(getToolPermission("file_write")).toBe("confirm");
15
15
  });
16
16
 
17
- it("returns 'auto' for unknown tools (user-installed skills)", () => {
18
- expect(getToolPermission("my_custom_skill")).toBe("auto");
19
- expect(getToolPermission("weather")).toBe("auto");
17
+ it("returns 'confirm' for unknown tools (user-installed skills, MCP)", () => {
18
+ expect(getToolPermission("my_custom_skill")).toBe("confirm");
19
+ expect(getToolPermission("weather")).toBe("confirm");
20
20
  });
21
21
  });
@@ -41,13 +41,24 @@ describe("executeTool", () => {
41
41
  });
42
42
 
43
43
  try {
44
- const result = await executeTool(
44
+ // Unknown tools default to "confirm" — first call returns confirmation token
45
+ const confirmResult = await executeTool(
45
46
  testToolName,
46
47
  "call-789",
47
48
  {},
48
49
  [testToolName]
49
50
  );
51
+ expect(confirmResult.content).toContain("Confirmation Token:");
52
+ const tokenMatch = confirmResult.content.match(/Confirmation Token: ([a-f0-9-]+)/);
53
+ expect(tokenMatch).toBeTruthy();
50
54
 
55
+ // Second call with token executes the tool
56
+ const result = await executeTool(
57
+ testToolName,
58
+ "call-789b",
59
+ { _confirmToken: tokenMatch![1] },
60
+ [testToolName]
61
+ );
51
62
  expect(result.is_error).toBe(false);
52
63
  expect(result.content).toBe("success");
53
64
  } finally {
@@ -56,15 +67,6 @@ describe("executeTool", () => {
56
67
  });
57
68
 
58
69
  test("returns a denied error for tools with deny permission", async () => {
59
- // Register a tool and give it the "deny" permission by temporarily
60
- // adding it to the permissions map. Since getToolPermission defaults
61
- // to "auto" for unknown tools, we test with the "shell" tool which
62
- // has "confirm" permission instead. Let's test the confirm flow.
63
- // We'll test denied tools by checking the mechanism works correctly.
64
-
65
- // The "shell" tool has "confirm" permission in the default map.
66
- // For a truly denied tool, we'd need to modify the permissions map.
67
- // Instead, let's verify that when allowedTools blocks a tool, it works.
68
70
  const result = await executeTool(
69
71
  "shell",
70
72
  "call-deny",
@@ -92,13 +94,23 @@ describe("executeTool", () => {
92
94
  });
93
95
 
94
96
  try {
95
- const result = await executeTool(testToolName, "call-exec", {
97
+ // First call: get confirmation token (unknown tools default to "confirm")
98
+ const confirmResult = await executeTool(testToolName, "call-exec", {
96
99
  message: "hello",
97
100
  });
101
+ expect(confirmResult.content).toContain("Confirmation Token:");
102
+ const tokenMatch = confirmResult.content.match(/Confirmation Token: ([a-f0-9-]+)/);
103
+ expect(tokenMatch).toBeTruthy();
104
+
105
+ // Second call with token: actually execute
106
+ const result = await executeTool(testToolName, "call-exec2", {
107
+ message: "hello",
108
+ _confirmToken: tokenMatch![1],
109
+ });
98
110
 
99
111
  expect(result.is_error).toBe(false);
100
112
  expect(result.content).toBe("echo: hello");
101
- expect(result.tool_use_id).toBe("call-exec");
113
+ expect(result.tool_use_id).toBe("call-exec2");
102
114
  } finally {
103
115
  unregisterTool(testToolName);
104
116
  }
@@ -119,7 +131,15 @@ describe("executeTool", () => {
119
131
  });
120
132
 
121
133
  try {
122
- const result = await executeTool(testToolName, "call-throw", {});
134
+ // First call: get confirmation token
135
+ const confirmResult = await executeTool(testToolName, "call-throw", {});
136
+ const tokenMatch = confirmResult.content.match(/Confirmation Token: ([a-f0-9-]+)/);
137
+ expect(tokenMatch).toBeTruthy();
138
+
139
+ // Second call with token: tool throws
140
+ const result = await executeTool(testToolName, "call-throw2", {
141
+ _confirmToken: tokenMatch![1],
142
+ });
123
143
 
124
144
  expect(result.is_error).toBe(true);
125
145
  expect(result.content).toContain("Something went wrong");