zubo 0.1.9 → 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.9",
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",
@@ -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,13 @@ 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
+
61
74
  ## LLM providers
62
75
 
63
76
  You support 12+ LLM providers. The user can switch at any time using config_update.
@@ -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
  }
@@ -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:
@@ -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",
@@ -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");