zubo 0.1.0
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/.github/workflows/ci.yml +35 -0
- package/README.md +149 -0
- package/bun.lock +216 -0
- package/desktop/README.md +57 -0
- package/desktop/package.json +12 -0
- package/desktop/src-tauri/Cargo.toml +25 -0
- package/desktop/src-tauri/build.rs +3 -0
- package/desktop/src-tauri/icons/README.md +17 -0
- package/desktop/src-tauri/icons/icon.png +0 -0
- package/desktop/src-tauri/src/main.rs +189 -0
- package/desktop/src-tauri/tauri.conf.json +68 -0
- package/docs/ROADMAP.md +490 -0
- package/migrations/001_init.sql +9 -0
- package/migrations/002_memory.sql +33 -0
- package/migrations/003_cron.sql +24 -0
- package/migrations/004_usage.sql +12 -0
- package/migrations/005_secrets.sql +8 -0
- package/migrations/006_agents.sql +1 -0
- package/migrations/007_workflows.sql +22 -0
- package/migrations/008_proactive.sql +24 -0
- package/migrations/009_uploads.sql +9 -0
- package/migrations/010_observability.sql +22 -0
- package/migrations/011_api_keys.sql +7 -0
- package/migrations/012_indexes.sql +5 -0
- package/migrations/013_budget.sql +11 -0
- package/migrations/014_usage_session_idx.sql +2 -0
- package/package.json +39 -0
- package/site/404.html +156 -0
- package/site/CNAME +1 -0
- package/site/docs/agents.html +294 -0
- package/site/docs/api.html +446 -0
- package/site/docs/channels.html +345 -0
- package/site/docs/cli.html +238 -0
- package/site/docs/config.html +1034 -0
- package/site/docs/index.html +433 -0
- package/site/docs/integrations.html +381 -0
- package/site/docs/memory.html +254 -0
- package/site/docs/security.html +375 -0
- package/site/docs/skills.html +322 -0
- package/site/docs.css +412 -0
- package/site/index.html +638 -0
- package/site/install.sh +98 -0
- package/site/logo.svg +1 -0
- package/site/og-image.png +0 -0
- package/site/robots.txt +4 -0
- package/site/script.js +361 -0
- package/site/sitemap.xml +63 -0
- package/site/skills.html +532 -0
- package/site/style.css +1686 -0
- package/src/agent/agents.ts +159 -0
- package/src/agent/compaction.ts +53 -0
- package/src/agent/context.ts +18 -0
- package/src/agent/delegate.ts +118 -0
- package/src/agent/loop.ts +318 -0
- package/src/agent/prompts.ts +111 -0
- package/src/agent/session.ts +87 -0
- package/src/agent/teams.ts +116 -0
- package/src/agent/workflow-executor.ts +192 -0
- package/src/agent/workflow.ts +175 -0
- package/src/channels/adapter.ts +21 -0
- package/src/channels/dashboard.html.ts +2969 -0
- package/src/channels/discord.ts +137 -0
- package/src/channels/optional-deps.d.ts +17 -0
- package/src/channels/router.ts +199 -0
- package/src/channels/signal.ts +133 -0
- package/src/channels/slack.ts +101 -0
- package/src/channels/telegram.ts +102 -0
- package/src/channels/utils.ts +18 -0
- package/src/channels/webchat.ts +1797 -0
- package/src/channels/whatsapp.ts +119 -0
- package/src/config/loader.ts +22 -0
- package/src/config/paths.ts +43 -0
- package/src/config/schema.ts +121 -0
- package/src/db/connection.ts +20 -0
- package/src/db/export.ts +148 -0
- package/src/db/migrations.ts +42 -0
- package/src/index.ts +261 -0
- package/src/llm/claude.ts +193 -0
- package/src/llm/factory.ts +115 -0
- package/src/llm/failover.ts +101 -0
- package/src/llm/openai-compat.ts +409 -0
- package/src/llm/provider.ts +83 -0
- package/src/llm/smart-router.ts +241 -0
- package/src/logs.ts +53 -0
- package/src/memory/chunker.ts +58 -0
- package/src/memory/document-parser.ts +115 -0
- package/src/memory/embedder.ts +235 -0
- package/src/memory/engine.ts +170 -0
- package/src/memory/fts-index.ts +55 -0
- package/src/memory/hybrid-search.ts +72 -0
- package/src/memory/store.ts +56 -0
- package/src/memory/vector-index.ts +72 -0
- package/src/model.ts +118 -0
- package/src/registry/cli.ts +43 -0
- package/src/registry/client.ts +54 -0
- package/src/registry/installer.ts +67 -0
- package/src/scheduler/briefing.ts +71 -0
- package/src/scheduler/cron.ts +258 -0
- package/src/scheduler/heartbeat.ts +58 -0
- package/src/scheduler/memory-triggers.ts +100 -0
- package/src/scheduler/natural-cron.ts +163 -0
- package/src/scheduler/proactive.ts +25 -0
- package/src/scheduler/recipes.ts +110 -0
- package/src/secrets/store.ts +64 -0
- package/src/setup.ts +413 -0
- package/src/skills.ts +293 -0
- package/src/start.ts +373 -0
- package/src/status.ts +165 -0
- package/src/tools/builtin/connect-service.ts +205 -0
- package/src/tools/builtin/cron.ts +126 -0
- package/src/tools/builtin/datetime.ts +36 -0
- package/src/tools/builtin/delegate-task.ts +81 -0
- package/src/tools/builtin/delegate.ts +42 -0
- package/src/tools/builtin/diagnose.ts +41 -0
- package/src/tools/builtin/google-oauth.ts +379 -0
- package/src/tools/builtin/manage-agents.ts +149 -0
- package/src/tools/builtin/manage-skills.ts +294 -0
- package/src/tools/builtin/manage-teams.ts +89 -0
- package/src/tools/builtin/manage-triggers.ts +94 -0
- package/src/tools/builtin/manage-workflows.ts +119 -0
- package/src/tools/builtin/memory-search.ts +38 -0
- package/src/tools/builtin/memory-write.ts +30 -0
- package/src/tools/builtin/run-workflow.ts +36 -0
- package/src/tools/builtin/secrets.ts +122 -0
- package/src/tools/builtin/skill-registry.ts +75 -0
- package/src/tools/builtin-integrations/api-helpers.ts +26 -0
- package/src/tools/builtin-integrations/github/github_issues/SKILL.md +56 -0
- package/src/tools/builtin-integrations/github/github_issues/handler.ts +108 -0
- package/src/tools/builtin-integrations/github/github_prs/SKILL.md +57 -0
- package/src/tools/builtin-integrations/github/github_prs/handler.ts +113 -0
- package/src/tools/builtin-integrations/github/github_repos/SKILL.md +37 -0
- package/src/tools/builtin-integrations/github/github_repos/handler.ts +88 -0
- package/src/tools/builtin-integrations/google/gmail/SKILL.md +51 -0
- package/src/tools/builtin-integrations/google/gmail/handler.ts +125 -0
- package/src/tools/builtin-integrations/google/google_calendar/SKILL.md +35 -0
- package/src/tools/builtin-integrations/google/google_calendar/handler.ts +105 -0
- package/src/tools/builtin-integrations/google/google_docs/SKILL.md +35 -0
- package/src/tools/builtin-integrations/google/google_docs/handler.ts +108 -0
- package/src/tools/builtin-integrations/google/google_drive/SKILL.md +39 -0
- package/src/tools/builtin-integrations/google/google_drive/handler.ts +106 -0
- package/src/tools/builtin-integrations/google/google_sheets/SKILL.md +36 -0
- package/src/tools/builtin-integrations/google/google_sheets/handler.ts +116 -0
- package/src/tools/builtin-integrations/jira/jira_boards/SKILL.md +21 -0
- package/src/tools/builtin-integrations/jira/jira_boards/handler.ts +74 -0
- package/src/tools/builtin-integrations/jira/jira_issues/SKILL.md +28 -0
- package/src/tools/builtin-integrations/jira/jira_issues/handler.ts +140 -0
- package/src/tools/builtin-integrations/linear/linear_issues/SKILL.md +30 -0
- package/src/tools/builtin-integrations/linear/linear_issues/handler.ts +75 -0
- package/src/tools/builtin-integrations/linear/linear_projects/SKILL.md +21 -0
- package/src/tools/builtin-integrations/linear/linear_projects/handler.ts +43 -0
- package/src/tools/builtin-integrations/notion/notion_databases/SKILL.md +39 -0
- package/src/tools/builtin-integrations/notion/notion_databases/handler.ts +83 -0
- package/src/tools/builtin-integrations/notion/notion_pages/SKILL.md +43 -0
- package/src/tools/builtin-integrations/notion/notion_pages/handler.ts +130 -0
- package/src/tools/builtin-integrations/notion/notion_search/SKILL.md +27 -0
- package/src/tools/builtin-integrations/notion/notion_search/handler.ts +69 -0
- package/src/tools/builtin-integrations/slack/slack_messages/SKILL.md +42 -0
- package/src/tools/builtin-integrations/slack/slack_messages/handler.ts +72 -0
- package/src/tools/builtin-integrations/twitter/twitter_posts/SKILL.md +24 -0
- package/src/tools/builtin-integrations/twitter/twitter_posts/handler.ts +133 -0
- package/src/tools/builtin-skills/file-read/SKILL.md +26 -0
- package/src/tools/builtin-skills/file-read/handler.ts +66 -0
- package/src/tools/builtin-skills/file-write/SKILL.md +30 -0
- package/src/tools/builtin-skills/file-write/handler.ts +64 -0
- package/src/tools/builtin-skills/http-request/SKILL.md +34 -0
- package/src/tools/builtin-skills/http-request/handler.ts +87 -0
- package/src/tools/builtin-skills/shell/SKILL.md +26 -0
- package/src/tools/builtin-skills/shell/handler.ts +96 -0
- package/src/tools/builtin-skills/url-fetch/SKILL.md +26 -0
- package/src/tools/builtin-skills/url-fetch/handler.ts +37 -0
- package/src/tools/builtin-skills/web-search/SKILL.md +26 -0
- package/src/tools/builtin-skills/web-search/handler.ts +50 -0
- package/src/tools/executor.ts +205 -0
- package/src/tools/integration-installer.ts +106 -0
- package/src/tools/permissions.ts +45 -0
- package/src/tools/registry.ts +39 -0
- package/src/tools/sandbox-runner.ts +56 -0
- package/src/tools/sandbox.ts +82 -0
- package/src/tools/skill-installer.ts +52 -0
- package/src/tools/skill-loader.ts +259 -0
- package/src/types/optional-deps.d.ts +23 -0
- package/src/util/auth.ts +121 -0
- package/src/util/costs.ts +59 -0
- package/src/util/error-buffer.ts +32 -0
- package/src/util/google-tokens.ts +180 -0
- package/src/util/logger.ts +73 -0
- package/src/util/perf-collector.ts +35 -0
- package/src/util/rate-limiter.ts +70 -0
- package/src/util/tokens.ts +17 -0
- package/src/voice/stt.ts +57 -0
- package/src/voice/tts.ts +103 -0
- package/tests/agent/session.test.ts +109 -0
- package/tests/agent-loop.test.ts +54 -0
- package/tests/auth.test.ts +89 -0
- package/tests/channels.test.ts +67 -0
- package/tests/compaction.test.ts +44 -0
- package/tests/config.test.ts +51 -0
- package/tests/costs.test.ts +19 -0
- package/tests/cron.test.ts +55 -0
- package/tests/db/export.test.ts +219 -0
- package/tests/executor.test.ts +144 -0
- package/tests/export.test.ts +137 -0
- package/tests/helpers/mock-llm.ts +34 -0
- package/tests/helpers/test-db.ts +74 -0
- package/tests/integration/chat-flow.test.ts +48 -0
- package/tests/integrations.test.ts +97 -0
- package/tests/memory/engine.test.ts +114 -0
- package/tests/memory-engine.test.ts +57 -0
- package/tests/permissions.test.ts +21 -0
- package/tests/rate-limiter.test.ts +70 -0
- package/tests/registry.test.ts +67 -0
- package/tests/router.test.ts +36 -0
- package/tests/session.test.ts +58 -0
- package/tests/skill-loader.test.ts +44 -0
- package/tests/tokens.test.ts +30 -0
- package/tests/tools/executor.test.ts +130 -0
- package/tests/util/auth.test.ts +75 -0
- package/tests/util/rate-limiter.test.ts +73 -0
- package/tests/voice.test.ts +60 -0
- package/tests/webchat.test.ts +88 -0
- package/tests/workflow.test.ts +38 -0
- package/tsconfig.json +16 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { LlmProvider, LlmRequest, LlmResponse, LlmContentBlock } from "../../src/llm/provider";
|
|
2
|
+
|
|
3
|
+
export function textResponse(text: string): LlmResponse {
|
|
4
|
+
return {
|
|
5
|
+
content: [{ type: "text", text }],
|
|
6
|
+
stopReason: "end_turn",
|
|
7
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function toolCallResponse(toolName: string, toolId: string, input: Record<string, unknown>): LlmResponse {
|
|
12
|
+
return {
|
|
13
|
+
content: [
|
|
14
|
+
{ type: "tool_use", id: toolId, name: toolName, input },
|
|
15
|
+
],
|
|
16
|
+
stopReason: "tool_use",
|
|
17
|
+
usage: { inputTokens: 100, outputTokens: 50 },
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createMockLlm(responses: LlmResponse[]): LlmProvider {
|
|
22
|
+
let callIndex = 0;
|
|
23
|
+
return {
|
|
24
|
+
providerName: "mock",
|
|
25
|
+
model: "mock-model",
|
|
26
|
+
contextWindow: 100_000,
|
|
27
|
+
async chat(request: LlmRequest): Promise<LlmResponse> {
|
|
28
|
+
if (callIndex >= responses.length) {
|
|
29
|
+
return textResponse("(no more mock responses)");
|
|
30
|
+
}
|
|
31
|
+
return responses[callIndex++];
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { readFileSync, readdirSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Split SQL text into individual statements, respecting BEGIN...END blocks
|
|
7
|
+
* (e.g. CREATE TRIGGER statements that contain semicolons inside them).
|
|
8
|
+
*/
|
|
9
|
+
function splitSql(sql: string): string[] {
|
|
10
|
+
const statements: string[] = [];
|
|
11
|
+
let current = "";
|
|
12
|
+
let inBlock = false;
|
|
13
|
+
|
|
14
|
+
for (const line of sql.split("\n")) {
|
|
15
|
+
const trimmed = line.trim().toUpperCase();
|
|
16
|
+
|
|
17
|
+
// Detect BEGIN (start of trigger/block body)
|
|
18
|
+
if (trimmed === "BEGIN" || trimmed.endsWith(" BEGIN")) {
|
|
19
|
+
inBlock = true;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
current += line + "\n";
|
|
23
|
+
|
|
24
|
+
// Detect END that closes the block — only the standalone "END" or "END;"
|
|
25
|
+
if (inBlock && (trimmed === "END" || trimmed === "END;")) {
|
|
26
|
+
inBlock = false;
|
|
27
|
+
statements.push(current.trim().replace(/;$/, ""));
|
|
28
|
+
current = "";
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Outside a block, split on semicolons at end of line
|
|
33
|
+
if (!inBlock && trimmed.endsWith(";")) {
|
|
34
|
+
const stmt = current.trim().replace(/;$/, "");
|
|
35
|
+
if (stmt) statements.push(stmt);
|
|
36
|
+
current = "";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Leftover
|
|
41
|
+
const leftover = current.trim().replace(/;$/, "");
|
|
42
|
+
if (leftover) statements.push(leftover);
|
|
43
|
+
|
|
44
|
+
return statements;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function createTestDb(): Database {
|
|
48
|
+
const db = new Database(":memory:");
|
|
49
|
+
|
|
50
|
+
// Run all migrations
|
|
51
|
+
const migrationsDir = join(import.meta.dir, "../../migrations");
|
|
52
|
+
try {
|
|
53
|
+
const files = readdirSync(migrationsDir)
|
|
54
|
+
.filter(f => f.endsWith(".sql"))
|
|
55
|
+
.sort();
|
|
56
|
+
|
|
57
|
+
for (const file of files) {
|
|
58
|
+
const sql = readFileSync(join(migrationsDir, file), "utf-8");
|
|
59
|
+
const statements = splitSql(sql);
|
|
60
|
+
for (const stmt of statements) {
|
|
61
|
+
try {
|
|
62
|
+
db.run(stmt);
|
|
63
|
+
} catch (e: any) {
|
|
64
|
+
// Ignore "duplicate column" errors from ALTER TABLE
|
|
65
|
+
if (!e.message?.includes("duplicate column")) {
|
|
66
|
+
// Still try to continue with other statements
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch {}
|
|
72
|
+
|
|
73
|
+
return db;
|
|
74
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { createMockLlm, textResponse, toolCallResponse } from "../helpers/mock-llm";
|
|
3
|
+
|
|
4
|
+
describe("Integration: Chat Flow", () => {
|
|
5
|
+
it("should handle a full conversation with tool use", async () => {
|
|
6
|
+
const llm = createMockLlm([
|
|
7
|
+
toolCallResponse("datetime", "t1", {}),
|
|
8
|
+
textResponse("The current time is 2025-01-01T12:00:00Z"),
|
|
9
|
+
]);
|
|
10
|
+
|
|
11
|
+
// Simulate the conversation
|
|
12
|
+
const response1 = await llm.chat({ system: "You are Zubo", messages: [{ role: "user", content: "What time is it?" }] });
|
|
13
|
+
expect(response1.stopReason).toBe("tool_use");
|
|
14
|
+
expect(response1.content[0].name).toBe("datetime");
|
|
15
|
+
|
|
16
|
+
// After tool execution
|
|
17
|
+
const response2 = await llm.chat({
|
|
18
|
+
system: "You are Zubo",
|
|
19
|
+
messages: [
|
|
20
|
+
{ role: "user", content: "What time is it?" },
|
|
21
|
+
{ role: "assistant", content: response1.content },
|
|
22
|
+
{ role: "user", content: [{ type: "tool_result", tool_use_id: "t1", content: "2025-01-01T12:00:00Z" }] },
|
|
23
|
+
],
|
|
24
|
+
});
|
|
25
|
+
expect(response2.stopReason).toBe("end_turn");
|
|
26
|
+
expect(response2.content[0].text).toContain("current time");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("should handle multi-turn conversation", async () => {
|
|
30
|
+
const llm = createMockLlm([
|
|
31
|
+
textResponse("Hello! I'm Zubo."),
|
|
32
|
+
textResponse("My name is Zubo, and I'm your AI agent."),
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
const r1 = await llm.chat({ system: "You are Zubo", messages: [{ role: "user", content: "Hi" }] });
|
|
36
|
+
expect(r1.content[0].text).toContain("Zubo");
|
|
37
|
+
|
|
38
|
+
const r2 = await llm.chat({
|
|
39
|
+
system: "You are Zubo",
|
|
40
|
+
messages: [
|
|
41
|
+
{ role: "user", content: "Hi" },
|
|
42
|
+
{ role: "assistant", content: r1.content },
|
|
43
|
+
{ role: "user", content: "What's your name?" },
|
|
44
|
+
],
|
|
45
|
+
});
|
|
46
|
+
expect(r2.content[0].text).toContain("Zubo");
|
|
47
|
+
});
|
|
48
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { safeApiError, safeExceptionError } from "../src/tools/builtin-integrations/api-helpers";
|
|
3
|
+
|
|
4
|
+
describe("Integration Helpers", () => {
|
|
5
|
+
describe("safeApiError", () => {
|
|
6
|
+
it("should return status code without leaking response body", async () => {
|
|
7
|
+
const mockResponse = new Response("secret-token-leaked-here", {
|
|
8
|
+
status: 401,
|
|
9
|
+
statusText: "Unauthorized",
|
|
10
|
+
});
|
|
11
|
+
const result = await safeApiError(mockResponse, "TestService");
|
|
12
|
+
const parsed = JSON.parse(result);
|
|
13
|
+
expect(parsed.error).toContain("TestService");
|
|
14
|
+
expect(parsed.error).toContain("401");
|
|
15
|
+
expect(parsed.error).not.toContain("secret-token");
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should handle non-text responses gracefully", async () => {
|
|
19
|
+
const mockResponse = new Response(null, { status: 500, statusText: "Internal Server Error" });
|
|
20
|
+
const result = await safeApiError(mockResponse, "API");
|
|
21
|
+
const parsed = JSON.parse(result);
|
|
22
|
+
expect(parsed.error).toContain("500");
|
|
23
|
+
});
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("safeExceptionError", () => {
|
|
27
|
+
it("should not expose raw error messages", () => {
|
|
28
|
+
const result = safeExceptionError(
|
|
29
|
+
new Error("Connection refused to https://api.example.com?token=abc123"),
|
|
30
|
+
"GitHub"
|
|
31
|
+
);
|
|
32
|
+
const parsed = JSON.parse(result);
|
|
33
|
+
expect(parsed.error).toContain("GitHub");
|
|
34
|
+
expect(parsed.error).not.toContain("abc123");
|
|
35
|
+
expect(parsed.error).toContain("Check logs");
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe("Integration Installer", () => {
|
|
41
|
+
it("should have SERVICE_SECRETS for all builtin integrations", async () => {
|
|
42
|
+
const { listAvailableIntegrations } = await import(
|
|
43
|
+
"../src/tools/integration-installer"
|
|
44
|
+
);
|
|
45
|
+
const integrations = listAvailableIntegrations();
|
|
46
|
+
|
|
47
|
+
// Each integration should have a secret_name
|
|
48
|
+
for (const integration of integrations) {
|
|
49
|
+
expect(integration.secret_name).toBeTruthy();
|
|
50
|
+
expect(typeof integration.secret_name).toBe("string");
|
|
51
|
+
expect(integration.skills.length).toBeGreaterThan(0);
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
describe("Jira SSRF Validation", () => {
|
|
57
|
+
it("should block localhost URLs", async () => {
|
|
58
|
+
// Mock the Zubo global to simulate Jira config
|
|
59
|
+
(globalThis as any).Zubo = {
|
|
60
|
+
getSecret: (name: string) => {
|
|
61
|
+
if (name === "jira_token") return "fake-token";
|
|
62
|
+
if (name === "jira_email") return "test@example.com";
|
|
63
|
+
if (name === "jira_url") return "http://localhost:8080";
|
|
64
|
+
return undefined;
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const handler = (await import("../src/tools/builtin-integrations/jira/jira_issues/handler")).default;
|
|
69
|
+
const result = await handler({ action: "list" });
|
|
70
|
+
const parsed = JSON.parse(result);
|
|
71
|
+
expect(parsed.error).toBeTruthy();
|
|
72
|
+
// Should reject non-HTTPS or localhost
|
|
73
|
+
expect(
|
|
74
|
+
parsed.error.includes("HTTPS") || parsed.error.includes("internal")
|
|
75
|
+
).toBe(true);
|
|
76
|
+
|
|
77
|
+
delete (globalThis as any).Zubo;
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("should block private IP ranges", async () => {
|
|
81
|
+
(globalThis as any).Zubo = {
|
|
82
|
+
getSecret: (name: string) => {
|
|
83
|
+
if (name === "jira_token") return "fake-token";
|
|
84
|
+
if (name === "jira_email") return "test@example.com";
|
|
85
|
+
if (name === "jira_url") return "https://192.168.1.1";
|
|
86
|
+
return undefined;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const handler = (await import("../src/tools/builtin-integrations/jira/jira_issues/handler")).default;
|
|
91
|
+
const result = await handler({ action: "list" });
|
|
92
|
+
const parsed = JSON.parse(result);
|
|
93
|
+
expect(parsed.error).toContain("internal");
|
|
94
|
+
|
|
95
|
+
delete (globalThis as any).Zubo;
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { pruneOldChunks, getMemoryStats } from "../../src/memory/engine";
|
|
4
|
+
|
|
5
|
+
function createTestDb(): Database {
|
|
6
|
+
const db = new Database(":memory:");
|
|
7
|
+
|
|
8
|
+
// Run the memory migration schema
|
|
9
|
+
db.run(`
|
|
10
|
+
CREATE TABLE IF NOT EXISTS memory_chunks (
|
|
11
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
12
|
+
source_file TEXT NOT NULL,
|
|
13
|
+
chunk_index INTEGER NOT NULL,
|
|
14
|
+
content TEXT NOT NULL,
|
|
15
|
+
embedding BLOB,
|
|
16
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
17
|
+
UNIQUE(source_file, chunk_index)
|
|
18
|
+
)
|
|
19
|
+
`);
|
|
20
|
+
|
|
21
|
+
return db;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function insertChunks(db: Database, count: number): void {
|
|
25
|
+
const stmt = db.prepare(
|
|
26
|
+
"INSERT INTO memory_chunks (source_file, chunk_index, content) VALUES (?, ?, ?)"
|
|
27
|
+
);
|
|
28
|
+
for (let i = 0; i < count; i++) {
|
|
29
|
+
stmt.run(`file-${i}.md`, 0, `Chunk content number ${i}`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("pruneOldChunks", () => {
|
|
34
|
+
let db: Database;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
db = createTestDb();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("does nothing when chunk count is under the limit", () => {
|
|
41
|
+
insertChunks(db, 5);
|
|
42
|
+
const pruned = pruneOldChunks(db, 10);
|
|
43
|
+
expect(pruned).toBe(0);
|
|
44
|
+
|
|
45
|
+
const row = db.query("SELECT COUNT(*) as c FROM memory_chunks").get() as { c: number };
|
|
46
|
+
expect(row.c).toBe(5);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test("removes excess chunks when over the limit", () => {
|
|
50
|
+
insertChunks(db, 15);
|
|
51
|
+
const pruned = pruneOldChunks(db, 10);
|
|
52
|
+
expect(pruned).toBe(5);
|
|
53
|
+
|
|
54
|
+
const row = db.query("SELECT COUNT(*) as c FROM memory_chunks").get() as { c: number };
|
|
55
|
+
expect(row.c).toBe(10);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test("removes the oldest chunks (lowest IDs)", () => {
|
|
59
|
+
insertChunks(db, 5);
|
|
60
|
+
const pruned = pruneOldChunks(db, 3);
|
|
61
|
+
expect(pruned).toBe(2);
|
|
62
|
+
|
|
63
|
+
// The first 2 chunks (lowest IDs) should be gone
|
|
64
|
+
const remaining = db.query("SELECT id FROM memory_chunks ORDER BY id").all() as { id: number }[];
|
|
65
|
+
expect(remaining).toHaveLength(3);
|
|
66
|
+
// IDs 1 and 2 should have been pruned, leaving 3, 4, 5
|
|
67
|
+
expect(remaining[0].id).toBe(3);
|
|
68
|
+
expect(remaining[1].id).toBe(4);
|
|
69
|
+
expect(remaining[2].id).toBe(5);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("does nothing when count equals the limit", () => {
|
|
73
|
+
insertChunks(db, 10);
|
|
74
|
+
const pruned = pruneOldChunks(db, 10);
|
|
75
|
+
expect(pruned).toBe(0);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("handles empty table gracefully", () => {
|
|
79
|
+
const pruned = pruneOldChunks(db, 10);
|
|
80
|
+
expect(pruned).toBe(0);
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("getMemoryStats", () => {
|
|
85
|
+
let db: Database;
|
|
86
|
+
|
|
87
|
+
beforeEach(() => {
|
|
88
|
+
db = createTestDb();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("returns zero for an empty table", () => {
|
|
92
|
+
const stats = getMemoryStats(db);
|
|
93
|
+
expect(stats.totalChunks).toBe(0);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("returns correct count after inserting chunks", () => {
|
|
97
|
+
insertChunks(db, 7);
|
|
98
|
+
const stats = getMemoryStats(db);
|
|
99
|
+
expect(stats.totalChunks).toBe(7);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("returns correct count after pruning", () => {
|
|
103
|
+
insertChunks(db, 20);
|
|
104
|
+
pruneOldChunks(db, 12);
|
|
105
|
+
const stats = getMemoryStats(db);
|
|
106
|
+
expect(stats.totalChunks).toBe(12);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("returns zero when the table does not exist", () => {
|
|
110
|
+
const emptyDb = new Database(":memory:");
|
|
111
|
+
const stats = getMemoryStats(emptyDb);
|
|
112
|
+
expect(stats.totalChunks).toBe(0);
|
|
113
|
+
});
|
|
114
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { createTestDb } from "./helpers/test-db";
|
|
3
|
+
|
|
4
|
+
describe("Memory Engine", () => {
|
|
5
|
+
it("should create memory_chunks table in test DB", () => {
|
|
6
|
+
const db = createTestDb();
|
|
7
|
+
const result = db.query("SELECT name FROM sqlite_master WHERE type='table' AND name='memory_chunks'").get() as any;
|
|
8
|
+
expect(result).toBeTruthy();
|
|
9
|
+
expect(result.name).toBe("memory_chunks");
|
|
10
|
+
db.close();
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("should insert and retrieve memory chunks", () => {
|
|
14
|
+
const db = createTestDb();
|
|
15
|
+
db.prepare("INSERT INTO memory_chunks (source_file, chunk_index, content) VALUES (?, ?, ?)").run("test.md", 0, "Hello world");
|
|
16
|
+
|
|
17
|
+
const chunks = db.query("SELECT * FROM memory_chunks WHERE source_file = 'test.md'").all() as any[];
|
|
18
|
+
expect(chunks.length).toBe(1);
|
|
19
|
+
expect(chunks[0].content).toBe("Hello world");
|
|
20
|
+
expect(chunks[0].chunk_index).toBe(0);
|
|
21
|
+
db.close();
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("should support FTS search via triggers", () => {
|
|
25
|
+
const db = createTestDb();
|
|
26
|
+
// Inserting into memory_chunks should auto-populate memory_fts via triggers
|
|
27
|
+
db.prepare("INSERT INTO memory_chunks (source_file, chunk_index, content) VALUES (?, ?, ?)").run("a.md", 0, "TypeScript is great");
|
|
28
|
+
db.prepare("INSERT INTO memory_chunks (source_file, chunk_index, content) VALUES (?, ?, ?)").run("b.md", 0, "Python is cool");
|
|
29
|
+
|
|
30
|
+
const results = db.query("SELECT * FROM memory_fts WHERE memory_fts MATCH 'TypeScript'").all() as any[];
|
|
31
|
+
expect(results.length).toBe(1);
|
|
32
|
+
db.close();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("should handle dedup on re-index", () => {
|
|
36
|
+
const db = createTestDb();
|
|
37
|
+
db.prepare("INSERT INTO memory_chunks (source_file, chunk_index, content) VALUES (?, ?, ?)").run("test.md", 0, "original");
|
|
38
|
+
|
|
39
|
+
// Update same chunk (UNIQUE constraint on source_file, chunk_index)
|
|
40
|
+
db.prepare("UPDATE memory_chunks SET content = ? WHERE source_file = ? AND chunk_index = ?").run("updated", "test.md", 0);
|
|
41
|
+
|
|
42
|
+
const chunks = db.query("SELECT * FROM memory_chunks WHERE source_file = 'test.md'").all() as any[];
|
|
43
|
+
expect(chunks.length).toBe(1);
|
|
44
|
+
expect(chunks[0].content).toBe("updated");
|
|
45
|
+
db.close();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should enforce unique constraint on source_file + chunk_index", () => {
|
|
49
|
+
const db = createTestDb();
|
|
50
|
+
db.prepare("INSERT INTO memory_chunks (source_file, chunk_index, content) VALUES (?, ?, ?)").run("test.md", 0, "first");
|
|
51
|
+
|
|
52
|
+
expect(() => {
|
|
53
|
+
db.prepare("INSERT INTO memory_chunks (source_file, chunk_index, content) VALUES (?, ?, ?)").run("test.md", 0, "duplicate");
|
|
54
|
+
}).toThrow();
|
|
55
|
+
db.close();
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { getToolPermission } from "../src/tools/permissions";
|
|
3
|
+
|
|
4
|
+
describe("getToolPermission", () => {
|
|
5
|
+
it("returns 'auto' for safe built-in tools", () => {
|
|
6
|
+
expect(getToolPermission("datetime")).toBe("auto");
|
|
7
|
+
expect(getToolPermission("memory_write")).toBe("auto");
|
|
8
|
+
expect(getToolPermission("memory_search")).toBe("auto");
|
|
9
|
+
expect(getToolPermission("cron_list")).toBe("auto");
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns 'confirm' for dangerous tools", () => {
|
|
13
|
+
expect(getToolPermission("shell")).toBe("confirm");
|
|
14
|
+
expect(getToolPermission("file_write")).toBe("confirm");
|
|
15
|
+
});
|
|
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");
|
|
20
|
+
});
|
|
21
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from "bun:test";
|
|
2
|
+
import { RateLimiter } from "../src/util/rate-limiter";
|
|
3
|
+
|
|
4
|
+
describe("RateLimiter", () => {
|
|
5
|
+
const limiters: RateLimiter[] = [];
|
|
6
|
+
|
|
7
|
+
function create(max: number, windowMs: number) {
|
|
8
|
+
const rl = new RateLimiter(max, windowMs);
|
|
9
|
+
limiters.push(rl);
|
|
10
|
+
return rl;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
afterEach(() => {
|
|
14
|
+
for (const rl of limiters) rl.destroy();
|
|
15
|
+
limiters.length = 0;
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should allow requests under the limit", () => {
|
|
19
|
+
const rl = create(5, 60_000);
|
|
20
|
+
for (let i = 0; i < 5; i++) {
|
|
21
|
+
const result = rl.check("user1");
|
|
22
|
+
expect(result.allowed).toBe(true);
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("should block requests over the limit", () => {
|
|
27
|
+
const rl = create(3, 60_000);
|
|
28
|
+
rl.check("user1");
|
|
29
|
+
rl.check("user1");
|
|
30
|
+
rl.check("user1");
|
|
31
|
+
const blocked = rl.check("user1");
|
|
32
|
+
expect(blocked.allowed).toBe(false);
|
|
33
|
+
expect(blocked.retryAfterMs).toBeGreaterThan(0);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("should isolate keys independently", () => {
|
|
37
|
+
const rl = create(2, 60_000);
|
|
38
|
+
rl.check("user1");
|
|
39
|
+
rl.check("user1");
|
|
40
|
+
const blocked = rl.check("user1");
|
|
41
|
+
expect(blocked.allowed).toBe(false);
|
|
42
|
+
|
|
43
|
+
// Different key should still be allowed
|
|
44
|
+
const other = rl.check("user2");
|
|
45
|
+
expect(other.allowed).toBe(true);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("should allow requests after the window expires", async () => {
|
|
49
|
+
// Use a very short window
|
|
50
|
+
const rl = create(1, 50);
|
|
51
|
+
rl.check("user1");
|
|
52
|
+
const blocked = rl.check("user1");
|
|
53
|
+
expect(blocked.allowed).toBe(false);
|
|
54
|
+
|
|
55
|
+
// Wait for window to expire
|
|
56
|
+
await new Promise((r) => setTimeout(r, 60));
|
|
57
|
+
const allowed = rl.check("user1");
|
|
58
|
+
expect(allowed.allowed).toBe(true);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should report accurate Retry-After values", () => {
|
|
62
|
+
const rl = create(1, 60_000);
|
|
63
|
+
rl.check("user1");
|
|
64
|
+
const blocked = rl.check("user1");
|
|
65
|
+
expect(blocked.allowed).toBe(false);
|
|
66
|
+
// retryAfterMs should be roughly 60s (within a reasonable margin)
|
|
67
|
+
expect(blocked.retryAfterMs).toBeGreaterThan(50_000);
|
|
68
|
+
expect(blocked.retryAfterMs).toBeLessThanOrEqual(60_000);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
registerTool,
|
|
4
|
+
getTool,
|
|
5
|
+
getAllToolDefs,
|
|
6
|
+
unregisterTool,
|
|
7
|
+
} from "../src/tools/registry";
|
|
8
|
+
|
|
9
|
+
// Register a test tool before each test
|
|
10
|
+
const testTool = {
|
|
11
|
+
definition: {
|
|
12
|
+
name: "test_tool",
|
|
13
|
+
description: "A tool for testing",
|
|
14
|
+
input_schema: {
|
|
15
|
+
type: "object",
|
|
16
|
+
properties: {
|
|
17
|
+
input: { type: "string" },
|
|
18
|
+
},
|
|
19
|
+
required: ["input"],
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
execute: async (input: Record<string, unknown>) => {
|
|
23
|
+
return `echo: ${input.input}`;
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("tool registry", () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
unregisterTool("test_tool");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("registers and retrieves a tool", () => {
|
|
33
|
+
registerTool(testTool);
|
|
34
|
+
const tool = getTool("test_tool");
|
|
35
|
+
expect(tool).toBeDefined();
|
|
36
|
+
expect(tool!.definition.name).toBe("test_tool");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("returns undefined for unknown tool", () => {
|
|
40
|
+
expect(getTool("nonexistent")).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("unregisters a tool", () => {
|
|
44
|
+
registerTool(testTool);
|
|
45
|
+
expect(unregisterTool("test_tool")).toBe(true);
|
|
46
|
+
expect(getTool("test_tool")).toBeUndefined();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns false when unregistering unknown tool", () => {
|
|
50
|
+
expect(unregisterTool("nonexistent")).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("includes tool in getAllToolDefs", () => {
|
|
54
|
+
registerTool(testTool);
|
|
55
|
+
const defs = getAllToolDefs();
|
|
56
|
+
const found = defs.find((d) => d.name === "test_tool");
|
|
57
|
+
expect(found).toBeDefined();
|
|
58
|
+
expect(found!.description).toBe("A tool for testing");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("executes a tool handler", async () => {
|
|
62
|
+
registerTool(testTool);
|
|
63
|
+
const tool = getTool("test_tool")!;
|
|
64
|
+
const result = await tool.execute({ input: "hello" });
|
|
65
|
+
expect(result).toBe("echo: hello");
|
|
66
|
+
});
|
|
67
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { createMockLlm, textResponse } from "./helpers/mock-llm";
|
|
3
|
+
import { createTestDb } from "./helpers/test-db";
|
|
4
|
+
|
|
5
|
+
describe("Router", () => {
|
|
6
|
+
it("should create a router without errors", async () => {
|
|
7
|
+
const { createRouter } = await import("../src/channels/router");
|
|
8
|
+
const llm = createMockLlm([textResponse("hello")]);
|
|
9
|
+
const db = createTestDb();
|
|
10
|
+
const router = createRouter(llm, db);
|
|
11
|
+
expect(router).toBeDefined();
|
|
12
|
+
expect(router.handleMessage).toBeDefined();
|
|
13
|
+
expect(router.sendProactive).toBeDefined();
|
|
14
|
+
expect(router.addAdapter).toBeDefined();
|
|
15
|
+
db.close();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should support adding adapters", async () => {
|
|
19
|
+
const { createRouter } = await import("../src/channels/router");
|
|
20
|
+
const llm = createMockLlm([textResponse("test")]);
|
|
21
|
+
const db = createTestDb();
|
|
22
|
+
const router = createRouter(llm, db);
|
|
23
|
+
|
|
24
|
+
const mockAdapter = {
|
|
25
|
+
channelName: "test",
|
|
26
|
+
start() {},
|
|
27
|
+
stop() {},
|
|
28
|
+
async sendMessage() {},
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
router.addAdapter(mockAdapter);
|
|
32
|
+
// Should not throw
|
|
33
|
+
expect(true).toBe(true);
|
|
34
|
+
db.close();
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
+
import { mkdirSync, rmSync, existsSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
|
|
5
|
+
const TEST_SESSION_DIR = join(import.meta.dir, ".test-sessions");
|
|
6
|
+
|
|
7
|
+
describe("Session", () => {
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
mkdirSync(TEST_SESSION_DIR, { recursive: true });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
if (existsSync(TEST_SESSION_DIR)) {
|
|
14
|
+
rmSync(TEST_SESSION_DIR, { recursive: true });
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("should create session file on first append", async () => {
|
|
19
|
+
const { writeFileSync, readFileSync } = await import("fs");
|
|
20
|
+
const sessionPath = join(TEST_SESSION_DIR, "test-session.jsonl");
|
|
21
|
+
|
|
22
|
+
const msg = { role: "user", content: [{ type: "text", text: "hello" }], timestamp: new Date().toISOString() };
|
|
23
|
+
writeFileSync(sessionPath, JSON.stringify(msg) + "\n");
|
|
24
|
+
|
|
25
|
+
expect(existsSync(sessionPath)).toBe(true);
|
|
26
|
+
const content = readFileSync(sessionPath, "utf-8").trim();
|
|
27
|
+
const parsed = JSON.parse(content);
|
|
28
|
+
expect(parsed.role).toBe("user");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("should append multiple messages", async () => {
|
|
32
|
+
const { writeFileSync, readFileSync } = await import("fs");
|
|
33
|
+
const sessionPath = join(TEST_SESSION_DIR, "multi.jsonl");
|
|
34
|
+
|
|
35
|
+
const msgs = [
|
|
36
|
+
{ role: "user", content: [{ type: "text", text: "hi" }] },
|
|
37
|
+
{ role: "assistant", content: [{ type: "text", text: "hello" }] },
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
let content = "";
|
|
41
|
+
for (const msg of msgs) {
|
|
42
|
+
content += JSON.stringify(msg) + "\n";
|
|
43
|
+
}
|
|
44
|
+
writeFileSync(sessionPath, content);
|
|
45
|
+
|
|
46
|
+
const lines = readFileSync(sessionPath, "utf-8").trim().split("\n");
|
|
47
|
+
expect(lines.length).toBe(2);
|
|
48
|
+
expect(JSON.parse(lines[0]).role).toBe("user");
|
|
49
|
+
expect(JSON.parse(lines[1]).role).toBe("assistant");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("should reject path traversal in session IDs", () => {
|
|
53
|
+
const dangerous = "../../../etc/passwd";
|
|
54
|
+
const safe = dangerous.replace(/[^a-zA-Z0-9_:-]/g, "_");
|
|
55
|
+
expect(safe).not.toContain("..");
|
|
56
|
+
expect(safe).not.toContain("/");
|
|
57
|
+
});
|
|
58
|
+
});
|