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 +1 -1
- package/src/agent/prompts.ts +16 -3
- package/src/channels/webchat.ts +8 -5
- package/src/tools/builtin/google-oauth.ts +9 -5
- package/src/tools/builtin/secrets.ts +32 -1
- package/src/tools/permissions.ts +16 -1
- package/tests/executor.test.ts +18 -4
- package/tests/permissions.test.ts +3 -3
- package/tests/tools/executor.test.ts +33 -13
package/package.json
CHANGED
package/src/agent/prompts.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
package/src/channels/webchat.ts
CHANGED
|
@@ -1109,29 +1109,32 @@ function handleDashboardApi(url: URL, req: Request): Response | null {
|
|
|
1109
1109
|
}
|
|
1110
1110
|
}
|
|
1111
1111
|
|
|
1112
|
-
// GET /api/dashboard/secrets/:name —
|
|
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
|
-
|
|
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
|
|
1133
|
-
|
|
1134
|
-
return Response.json({
|
|
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
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
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
|
|
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
|
|
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",
|
package/src/tools/permissions.ts
CHANGED
|
@@ -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",
|
package/tests/executor.test.ts
CHANGED
|
@@ -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
|
-
|
|
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("
|
|
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
|
-
|
|
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 '
|
|
18
|
-
expect(getToolPermission("my_custom_skill")).toBe("
|
|
19
|
-
expect(getToolPermission("weather")).toBe("
|
|
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
|
-
|
|
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
|
-
|
|
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-
|
|
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
|
-
|
|
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");
|