zubo 0.1.9 → 0.1.12

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.12",
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
  }
@@ -16,9 +16,9 @@ export function registerGoogleOAuthTool(): void {
16
16
  name: "google_oauth",
17
17
  description:
18
18
  "Manage the Google OAuth 2.0 connection used by Gmail, Calendar, Sheets, Docs, and Drive. " +
19
- "IMPORTANT: You need TWO credentials from the user: client_id (looks like 123456-abc.apps.googleusercontent.com) " +
20
- "and client_secret (starts with GOCSPX-). Ask for BOTH before calling this tool. " +
21
- "Use action 'start' to begin the OAuth flow. " +
19
+ "Use action 'start' to begin or re-start the OAuth flow. If credentials are already stored in secrets, " +
20
+ "you can call 'start' with NO parameters it will automatically use the saved google_client_id and google_client_secret. " +
21
+ "Only ask the user for client_id and client_secret if this tool returns an error saying they are missing. " +
22
22
  "Use 'complete' to finish the flow by providing the authorization code the user copied from the browser. " +
23
23
  "Use 'status' to check connection state. Use 'disconnect' to remove all stored Google credentials.",
24
24
  input_schema: {
@@ -32,12 +32,12 @@ export function registerGoogleOAuthTool(): void {
32
32
  client_id: {
33
33
  type: "string",
34
34
  description:
35
- "Google OAuth client ID (looks like 123456789-xxxx.apps.googleusercontent.com). Required for 'start'.",
35
+ "Google OAuth client ID. Optional if omitted, uses the value already saved in secrets.",
36
36
  },
37
37
  client_secret: {
38
38
  type: "string",
39
39
  description:
40
- "Google OAuth client secret (starts with GOCSPX-). Required for 'start'.",
40
+ "Google OAuth client secret. Optional if omitted, uses the value already saved in secrets.",
41
41
  },
42
42
  code: {
43
43
  type: "string",
@@ -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:
@@ -1,5 +1,5 @@
1
1
  import { registerTool } from "../registry";
2
- import { setSecret, listSecrets, deleteSecret } from "../../secrets/store";
2
+ import { setSecret, getSecret, listSecrets, deleteSecret } from "../../secrets/store";
3
3
 
4
4
  export function registerSecretTools() {
5
5
  registerTool({
@@ -52,6 +52,37 @@ export function registerSecretTools() {
52
52
  },
53
53
  });
54
54
 
55
+ registerTool({
56
+ definition: {
57
+ name: "secret_get",
58
+ description:
59
+ "Retrieve the value of a stored secret. Use this to access API keys, tokens, and credentials needed for tool calls and integrations.",
60
+ input_schema: {
61
+ type: "object",
62
+ properties: {
63
+ name: {
64
+ type: "string",
65
+ description: "The name of the secret to retrieve (e.g., 'github_token', 'google_client_id')",
66
+ },
67
+ },
68
+ required: ["name"],
69
+ },
70
+ },
71
+ execute: async (input) => {
72
+ const { name } = input as { name: string };
73
+ if (!name) {
74
+ return JSON.stringify({ error: "Secret name is required." });
75
+ }
76
+
77
+ const value = getSecret(name);
78
+ if (value === null) {
79
+ return JSON.stringify({ error: `No secret found with name "${name}".` });
80
+ }
81
+
82
+ return JSON.stringify({ name, value });
83
+ },
84
+ });
85
+
55
86
  registerTool({
56
87
  definition: {
57
88
  name: "secret_list",
@@ -9,8 +9,9 @@ const DEFAULT_PERMISSIONS: Record<string, ToolPermission> = {
9
9
  reminder_set: "auto",
10
10
  diagnose: "auto",
11
11
 
12
- // Secrets — set/list are safe, delete requires confirmation
12
+ // Secrets — set/list/get are safe, delete requires confirmation
13
13
  secret_set: "auto",
14
+ secret_get: "auto",
14
15
  secret_list: "auto",
15
16
  secret_delete: "confirm",
16
17
 
@@ -39,6 +40,20 @@ const DEFAULT_PERMISSIONS: Record<string, ToolPermission> = {
39
40
  image_generate: "auto",
40
41
  google_oauth: "auto",
41
42
 
43
+ // Integration skills — auto because user explicitly requests these actions
44
+ gmail: "auto",
45
+ google_calendar: "auto",
46
+ google_sheets: "auto",
47
+ google_docs: "auto",
48
+ google_drive: "auto",
49
+ github_issues: "auto",
50
+ github_repos: "auto",
51
+ github_prs: "auto",
52
+ notion_pages: "auto",
53
+ linear_issues: "auto",
54
+ jira_issues: "auto",
55
+ slack_messages: "auto",
56
+
42
57
  // Built-in skills — require confirmation (network writes, code execution, system access)
43
58
  http_request: "confirm",
44
59
  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");