From d9561fdde396c8f8951b8afc9c7bd9330b786fc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Sat, 4 Apr 2026 02:34:01 +0100 Subject: [PATCH 1/7] Fix embedded Owletto CLI execution --- bun.lock | 7 ++ docker/Dockerfile.worker | 2 + .../__tests__/config-memory-plugins.test.ts | 92 +++++++++++++++++++ .../src/__tests__/embedded-deployment.test.ts | 22 ++++- packages/gateway/src/config/index.ts | 85 +++++++++++++++-- .../orchestration/impl/embedded-deployment.ts | 41 ++++++++- packages/worker/package.json | 1 + .../embedded-just-bash-bootstrap.test.ts | 39 ++++++++ .../src/embedded/just-bash-bootstrap.ts | 23 ++++- 9 files changed, 296 insertions(+), 16 deletions(-) create mode 100644 packages/gateway/src/__tests__/config-memory-plugins.test.ts create mode 100644 packages/worker/src/__tests__/embedded-just-bash-bootstrap.test.ts diff --git a/bun.lock b/bun.lock index 3901cec76..aa59d3e23 100644 --- a/bun.lock +++ b/bun.lock @@ -139,6 +139,7 @@ "form-data": "^4.0.4", "hono": "^4.11.7", "just-bash": "^2.12.8", + "owletto": "^1.4.0", "zod": "^3.24.1", }, "devDependencies": { @@ -1314,6 +1315,8 @@ "ci-info": ["ci-info@4.4.0", "", {}, "sha512-77PSwercCZU2Fc4sX94eF8k8Pxte6JAwL4/ICZLFjJLqegs7kCuAsqqj/70NQF6TvDpgFjkubQB2FW2ZZddvQg=="], + "citty": ["citty@0.1.6", "", { "dependencies": { "consola": "^3.2.3" } }, "sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ=="], + "cjs-module-lexer": ["cjs-module-lexer@1.4.3", "", {}, "sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q=="], "cli-boxes": ["cli-boxes@3.0.0", "", {}, "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g=="], @@ -1360,6 +1363,8 @@ "compressjs": ["compressjs@1.0.3", "", { "dependencies": { "amdefine": "~1.0.0", "commander": "~2.8.1" }, "bin": { "compressjs": "./bin/compressjs" } }, "sha512-jpKJjBTretQACTGLNuvnozP1JdP2ZLrjdGdBgk/tz1VfXlUcBhhSZW6vEsuThmeot/yjvSrPQKEgfF3X2Lpi8Q=="], + "consola": ["consola@3.4.2", "", {}, "sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA=="], + "constantinople": ["constantinople@4.0.1", "", { "dependencies": { "@babel/parser": "^7.6.0", "@babel/types": "^7.6.1" } }, "sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw=="], "content-disposition": ["content-disposition@1.0.0", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-Au9nRL8VNUut/XSzbQA38+M78dzP4D+eqg3gfJHMIHHYa3bg067xj1KxMUWj+VULbiZMowKngFFbKczUrNJ1mg=="], @@ -2214,6 +2219,8 @@ "ora": ["ora@8.2.0", "", { "dependencies": { "chalk": "^5.3.0", "cli-cursor": "^5.0.0", "cli-spinners": "^2.9.2", "is-interactive": "^2.0.0", "is-unicode-supported": "^2.0.0", "log-symbols": "^6.0.0", "stdin-discarder": "^0.2.2", "string-width": "^7.2.0", "strip-ansi": "^7.1.0" } }, "sha512-weP+BZ8MVNnlCm8c0Qdc1WSWq4Qn7I+9CJGm7Qali6g44e/PUzbjNqJX5NJ9ljlNMosfJvg1fKEGILklK9cwnw=="], + "owletto": ["owletto@1.4.0", "", { "dependencies": { "citty": "^0.1.6" }, "peerDependencies": { "@owletto/sdk": "^1.4.0", "@owletto/worker": "^1.4.0" }, "optionalPeers": ["@owletto/sdk", "@owletto/worker"], "bin": { "owletto": "dist/bin.js" } }, "sha512-49TKRly3qnssf+GPbTiZVndGKpoB0gwTxjCNPII0AUl1Yj3rPtOX+5XEBYdzHTr23rBYSPLHpHa1O3NdpAMj/g=="], + "oxc-resolver": ["oxc-resolver@11.11.1", "", { "optionalDependencies": { "@oxc-resolver/binding-android-arm-eabi": "11.11.1", "@oxc-resolver/binding-android-arm64": "11.11.1", "@oxc-resolver/binding-darwin-arm64": "11.11.1", "@oxc-resolver/binding-darwin-x64": "11.11.1", "@oxc-resolver/binding-freebsd-x64": "11.11.1", "@oxc-resolver/binding-linux-arm-gnueabihf": "11.11.1", "@oxc-resolver/binding-linux-arm-musleabihf": "11.11.1", "@oxc-resolver/binding-linux-arm64-gnu": "11.11.1", "@oxc-resolver/binding-linux-arm64-musl": "11.11.1", "@oxc-resolver/binding-linux-ppc64-gnu": "11.11.1", "@oxc-resolver/binding-linux-riscv64-gnu": "11.11.1", "@oxc-resolver/binding-linux-riscv64-musl": "11.11.1", "@oxc-resolver/binding-linux-s390x-gnu": "11.11.1", "@oxc-resolver/binding-linux-x64-gnu": "11.11.1", "@oxc-resolver/binding-linux-x64-musl": "11.11.1", "@oxc-resolver/binding-wasm32-wasi": "11.11.1", "@oxc-resolver/binding-win32-arm64-msvc": "11.11.1", "@oxc-resolver/binding-win32-ia32-msvc": "11.11.1", "@oxc-resolver/binding-win32-x64-msvc": "11.11.1" } }, "sha512-4Z86u4xQAxl2IC1OAAdHjk/S9GNbE2ewALQVOpBk9F8NkfqXlglY6R2ts+HEgY/Q3T9m/H8W0G4id71muw/Nng=="], "p-finally": ["p-finally@1.0.0", "", {}, "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="], diff --git a/docker/Dockerfile.worker b/docker/Dockerfile.worker index 39d3b62a7..b3fa062f5 100644 --- a/docker/Dockerfile.worker +++ b/docker/Dockerfile.worker @@ -3,6 +3,8 @@ FROM oven/bun:1.2.9 # Build argument to control dev/prod behavior ARG NODE_ENV=production +ENV PATH="/app/node_modules/.bin:/app/packages/worker/node_modules/.bin:/home/bun/.bun/bin:/root/.cargo/bin:${PATH}" + WORKDIR /app # Install runtime dependencies including Node.js, Python, uv, Docker, and GitHub CLI diff --git a/packages/gateway/src/__tests__/config-memory-plugins.test.ts b/packages/gateway/src/__tests__/config-memory-plugins.test.ts new file mode 100644 index 000000000..81c328b3a --- /dev/null +++ b/packages/gateway/src/__tests__/config-memory-plugins.test.ts @@ -0,0 +1,92 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { buildMemoryPlugins } from "../config"; + +const originalMemoryUrl = process.env.MEMORY_URL; +const originalDispatcherServiceName = process.env.DISPATCHER_SERVICE_NAME; +const originalKubernetesNamespace = process.env.KUBERNETES_NAMESPACE; + +afterEach(() => { + if (originalMemoryUrl === undefined) { + delete process.env.MEMORY_URL; + } else { + process.env.MEMORY_URL = originalMemoryUrl; + } + + if (originalDispatcherServiceName === undefined) { + delete process.env.DISPATCHER_SERVICE_NAME; + } else { + process.env.DISPATCHER_SERVICE_NAME = originalDispatcherServiceName; + } + + if (originalKubernetesNamespace === undefined) { + delete process.env.KUBERNETES_NAMESPACE; + } else { + process.env.KUBERNETES_NAMESPACE = originalKubernetesNamespace; + } +}); + +describe("buildMemoryPlugins", () => { + test("returns native memory when MEMORY_URL is unset and plugin exists", () => { + delete process.env.MEMORY_URL; + + expect(buildMemoryPlugins({ hasNativeMemoryPlugin: true })).toEqual([ + { + source: "@openclaw/native-memory", + slot: "memory", + enabled: true, + }, + ]); + }); + + test("returns no plugin when MEMORY_URL is unset and native memory is unavailable", () => { + delete process.env.MEMORY_URL; + + expect(buildMemoryPlugins({ hasNativeMemoryPlugin: false })).toEqual([]); + }); + + test("falls back to native memory when Owletto plugin is unavailable", () => { + process.env.MEMORY_URL = "https://memory.example.com"; + + expect( + buildMemoryPlugins({ + hasOwlettoPlugin: false, + hasNativeMemoryPlugin: true, + }) + ).toEqual([ + { + source: "@openclaw/native-memory", + slot: "memory", + enabled: true, + }, + ]); + }); + + test("returns no plugin when neither Owletto nor native memory plugin exists", () => { + process.env.MEMORY_URL = "https://memory.example.com"; + + expect( + buildMemoryPlugins({ + hasOwlettoPlugin: false, + hasNativeMemoryPlugin: false, + }) + ).toEqual([]); + }); + + test("uses Owletto plugin when installed and MEMORY_URL is set", () => { + process.env.MEMORY_URL = "https://memory.example.com"; + process.env.DISPATCHER_SERVICE_NAME = "lobu-gateway"; + process.env.KUBERNETES_NAMESPACE = "lobu"; + + expect(buildMemoryPlugins({ hasOwlettoPlugin: true })).toEqual([ + { + source: "@lobu/owletto-openclaw", + slot: "memory", + enabled: true, + config: { + mcpUrl: "http://lobu-gateway.lobu.svc.cluster.local:8080/mcp/owletto", + gatewayAuthUrl: "http://lobu-gateway.lobu.svc.cluster.local:8080", + }, + }, + ]); + }); +}); diff --git a/packages/gateway/src/__tests__/embedded-deployment.test.ts b/packages/gateway/src/__tests__/embedded-deployment.test.ts index bb22c71cc..7ecceebb9 100644 --- a/packages/gateway/src/__tests__/embedded-deployment.test.ts +++ b/packages/gateway/src/__tests__/embedded-deployment.test.ts @@ -9,6 +9,7 @@ import { } from "bun:test"; import { EventEmitter } from "node:events"; import fs from "node:fs"; +import path from "node:path"; import { ErrorCode, OrchestratorError } from "@lobu/core"; import type { MessagePayload, @@ -19,6 +20,7 @@ import type { // Mock child_process.spawn to return a fake ChildProcess // --------------------------------------------------------------------------- const mockChildProcesses: EventEmitter[] = []; +const mockSpawn = mock(() => createMockChildProcess()); function createMockChildProcess() { const cp = new EventEmitter() as EventEmitter & { @@ -45,7 +47,7 @@ function createMockChildProcess() { } mock.module("node:child_process", () => ({ - spawn: mock(() => createMockChildProcess()), + spawn: mockSpawn, })); // --------------------------------------------------------------------------- @@ -117,6 +119,7 @@ describe("EmbeddedDeploymentManager", () => { process.env.ENCRYPTION_KEY = TEST_ENCRYPTION_KEY; manager = new EmbeddedDeploymentManager(TEST_CONFIG); mockChildProcesses.length = 0; + mockSpawn.mockClear(); mkdirSyncSpy = spyOn(fs, "mkdirSync").mockReturnValue(undefined); }); @@ -170,6 +173,7 @@ describe("EmbeddedDeploymentManager", () => { await manager.createDeployment("worker-1", "user-1", "user-1", msg); expect(mockChildProcesses).toHaveLength(1); expect(mockChildProcesses[0]).toBeDefined(); + expect(mockSpawn.mock.calls.at(-1)?.[0]).toBe(process.execPath); }); test("createDeployment with different names returns multiple entries", async () => { @@ -279,6 +283,22 @@ describe("EmbeddedDeploymentManager", () => { expect((globalThis as any).__lobuEmbeddedBashOps).toBeUndefined(); }); + test("prepends the worker bin directory to subprocess PATH", async () => { + const msg = createTestMessagePayload(); + await manager.createDeployment("worker-1", "user-1", "user-1", msg); + + const spawnCall = mockSpawn.mock.calls.at(-1); + expect(spawnCall).toBeDefined(); + + const spawnOptions = spawnCall?.[2] as + | { env?: Record } + | undefined; + const pathEntries = (spawnOptions?.env?.PATH || "").split(":"); + expect(pathEntries).toContain( + path.resolve("packages/worker/node_modules/.bin") + ); + }); + test("child process exit removes worker from map", async () => { const msg = createTestMessagePayload(); await manager.createDeployment("worker-1", "user-1", "user-1", msg); diff --git a/packages/gateway/src/config/index.ts b/packages/gateway/src/config/index.ts index 7618eb5bd..b3a43e511 100644 --- a/packages/gateway/src/config/index.ts +++ b/packages/gateway/src/config/index.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; import path from "node:path"; import type { AgentOptions, LogLevel, PluginConfig } from "@lobu/core"; import { @@ -16,6 +17,12 @@ import { config as dotenvConfig } from "dotenv"; import type { OrchestratorConfig } from "../orchestration/base-deployment-manager"; const logger = createLogger("cli-config"); +const OWLETTO_PLUGIN_SOURCE = "@lobu/owletto-openclaw"; +const NATIVE_MEMORY_PLUGIN_SOURCE = "@openclaw/native-memory"; +const WORKER_PACKAGE_JSON_CANDIDATES = [ + path.resolve(process.cwd(), "packages/worker/package.json"), + "/app/packages/worker/package.json", +] as const; // ============================================================================ // CONSTANTS @@ -182,23 +189,81 @@ function getInternalGatewayUrl(): string { } /** - * Build the default memory plugin list based on MEMORY_PLUGIN env var. - * "owletto" (default) → Owletto MCP plugin - * "native" → @openclaw/native-memory (filesystem-based) + * Build the default memory plugin list based on MEMORY_URL env var. + * MEMORY_URL set → Owletto MCP plugin (connect to that URL) when installed + * MEMORY_URL empty → @openclaw/native-memory (filesystem-based) */ -export function buildMemoryPlugins(): PluginConfig[] { - const memoryPlugin = getOptionalEnv("MEMORY_PLUGIN", "owletto"); +function isPluginInstalled(source: string): boolean { + const resolverPaths = new Set([__filename]); + const packagePathParts = source.split("/"); - if (memoryPlugin === "native") { - return [ - { source: "@openclaw/native-memory", slot: "memory", enabled: true }, - ]; + for (const candidate of WORKER_PACKAGE_JSON_CANDIDATES) { + if (existsSync(candidate)) { + resolverPaths.add(candidate); + } + } + + for (const resolverPath of resolverPaths) { + try { + createRequire(resolverPath).resolve(source); + return true; + } catch { + const packageDir = path.join( + path.dirname(resolverPath), + "node_modules", + ...packagePathParts + ); + if (existsSync(packageDir)) { + return true; + } + } + } + + return false; +} + +export function buildMemoryPlugins(options?: { + hasOwlettoPlugin?: boolean; + hasNativeMemoryPlugin?: boolean; +}): PluginConfig[] { + const nativeMemoryPlugin: PluginConfig = { + source: NATIVE_MEMORY_PLUGIN_SOURCE, + slot: "memory", + enabled: true, + }; + const hasNativeMemoryPlugin = + options?.hasNativeMemoryPlugin ?? + isPluginInstalled(NATIVE_MEMORY_PLUGIN_SOURCE); + + if (!process.env.MEMORY_URL) { + if (hasNativeMemoryPlugin) { + return [nativeMemoryPlugin]; + } + logger.warn( + `${NATIVE_MEMORY_PLUGIN_SOURCE} is not installed; continuing without a memory plugin` + ); + return []; + } + + const hasOwlettoPlugin = + options?.hasOwlettoPlugin ?? isPluginInstalled(OWLETTO_PLUGIN_SOURCE); + if (!hasOwlettoPlugin) { + if (hasNativeMemoryPlugin) { + logger.warn( + `${OWLETTO_PLUGIN_SOURCE} is not installed; falling back to ${NATIVE_MEMORY_PLUGIN_SOURCE}` + ); + return [nativeMemoryPlugin]; + } + logger.warn( + `${OWLETTO_PLUGIN_SOURCE} is not installed and ${NATIVE_MEMORY_PLUGIN_SOURCE} is unavailable; continuing without a memory plugin` + ); + return []; } const gatewayUrl = getInternalGatewayUrl(); return [ { - source: "@lobu/owletto-openclaw", + source: OWLETTO_PLUGIN_SOURCE, slot: "memory", enabled: true, config: { diff --git a/packages/gateway/src/orchestration/impl/embedded-deployment.ts b/packages/gateway/src/orchestration/impl/embedded-deployment.ts index 9bda84eee..dab6dbd43 100644 --- a/packages/gateway/src/orchestration/impl/embedded-deployment.ts +++ b/packages/gateway/src/orchestration/impl/embedded-deployment.ts @@ -19,6 +19,10 @@ const logger = createLogger("orchestrator"); /** Timeout (ms) to wait for graceful shutdown before SIGKILL. */ const KILL_TIMEOUT_MS = 5_000; +const WORKER_BIN_DIR_CANDIDATES = [ + path.resolve("packages/worker/node_modules/.bin"), + "/app/packages/worker/node_modules/.bin", +] as const; interface EmbeddedWorkerEntry { process: ChildProcess; @@ -27,6 +31,24 @@ interface EmbeddedWorkerEntry { workspaceDir: string; } +function buildEmbeddedWorkerPath(existingPath?: string): string | undefined { + const segments = (existingPath || "").split(":").filter(Boolean); + + for (const candidate of [...WORKER_BIN_DIR_CANDIDATES].reverse()) { + if (!fs.existsSync(candidate)) continue; + if (segments.includes(candidate)) continue; + segments.unshift(candidate); + } + + return segments.length > 0 ? segments.join(":") : existingPath; +} + +function getBunExecutable(): string { + return path.basename(process.execPath).startsWith("bun") + ? process.execPath + : "bun"; +} + export class EmbeddedDeploymentManager extends BaseDeploymentManager { private workers: Map = new Map(); @@ -59,7 +81,13 @@ export class EmbeddedDeploymentManager extends BaseDeploymentManager { const [deploymentName, username, userId, messageDataRaw] = args; const messageData = messageDataRaw as MessagePayload | undefined; - const agentId = messageData?.agentId!; + const agentId = messageData?.agentId; + if (!agentId) { + throw new OrchestratorError( + ErrorCode.DEPLOYMENT_CREATE_FAILED, + "Missing agentId in message payload" + ); + } const workspaceDir = path.resolve(`workspaces/${agentId}`); fs.mkdirSync(workspaceDir, { recursive: true }); @@ -73,6 +101,12 @@ export class EmbeddedDeploymentManager extends BaseDeploymentManager { commonEnvVars.WORKSPACE_DIR = workspaceDir; commonEnvVars.DEPLOYMENT_MODE = "embedded"; + const embeddedPath = buildEmbeddedWorkerPath( + commonEnvVars.PATH || process.env.PATH + ); + if (embeddedPath) { + commonEnvVars.PATH = embeddedPath; + } // Serialize allowed domains for worker-side just-bash bootstrap const allowedDomains = messageData?.networkConfig?.allowedDomains ?? []; @@ -83,6 +117,7 @@ export class EmbeddedDeploymentManager extends BaseDeploymentManager { // Determine spawn command based on nix packages const nixPackages = messageData?.nixConfig?.packages ?? []; const workerEntryPoint = path.resolve("packages/worker/src/index.ts"); + const bunExecutable = getBunExecutable(); let command: string; let spawnArgs: string[]; @@ -94,13 +129,13 @@ export class EmbeddedDeploymentManager extends BaseDeploymentManager { "-p", ...nixPackages, "--run", - `bun run ${workerEntryPoint}`, + `${bunExecutable} run ${workerEntryPoint}`, ]; logger.info( `Spawning embedded worker ${deploymentName} with nix packages: ${nixPackages.join(", ")}` ); } else { - command = "bun"; + command = bunExecutable; spawnArgs = ["run", workerEntryPoint]; } diff --git a/packages/worker/package.json b/packages/worker/package.json index a64144a0b..2c7bad99b 100644 --- a/packages/worker/package.json +++ b/packages/worker/package.json @@ -46,6 +46,7 @@ "form-data": "^4.0.4", "hono": "^4.11.7", "just-bash": "^2.12.8", + "owletto": "^1.4.0", "zod": "^3.24.1" }, "devDependencies": { diff --git a/packages/worker/src/__tests__/embedded-just-bash-bootstrap.test.ts b/packages/worker/src/__tests__/embedded-just-bash-bootstrap.test.ts new file mode 100644 index 000000000..568afc2b9 --- /dev/null +++ b/packages/worker/src/__tests__/embedded-just-bash-bootstrap.test.ts @@ -0,0 +1,39 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { buildBinaryInvocation } from "../embedded/just-bash-bootstrap"; + +const tempDirs: string[] = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + fs.rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("buildBinaryInvocation", () => { + test("runs node shebang scripts through node", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lobu-owletto-")); + tempDirs.push(dir); + const scriptPath = path.join(dir, "owletto"); + fs.writeFileSync( + scriptPath, + "#!/usr/bin/env node\nconsole.log('ok');\n", + "utf8" + ); + fs.chmodSync(scriptPath, 0o755); + + expect(buildBinaryInvocation(scriptPath, ["version"])).toEqual({ + command: "node", + args: [scriptPath, "version"], + }); + }); + + test("executes normal binaries directly", () => { + expect(buildBinaryInvocation("/bin/echo", ["hello"])).toEqual({ + command: "/bin/echo", + args: ["hello"], + }); + }); +}); diff --git a/packages/worker/src/embedded/just-bash-bootstrap.ts b/packages/worker/src/embedded/just-bash-bootstrap.ts index 2a4b7b398..9329f904b 100644 --- a/packages/worker/src/embedded/just-bash-bootstrap.ts +++ b/packages/worker/src/embedded/just-bash-bootstrap.ts @@ -20,6 +20,23 @@ const EMBEDDED_BASH_LIMITS = { maxCallDepth: 50, } as const; +export function buildBinaryInvocation( + binaryPath: string, + args: string[] +): { command: string; args: string[] } { + try { + const firstLine = + fs.readFileSync(binaryPath, "utf8").split("\n", 1)[0] || ""; + if (firstLine === "#!/usr/bin/env node" || firstLine.endsWith("/node")) { + return { command: "node", args: [binaryPath, ...args] }; + } + } catch { + // Fall back to executing the binary directly. + } + + return { command: binaryPath, args }; +} + /** * Discover binaries to register as custom commands: * 1. All executables from /nix/store/ PATH directories @@ -77,6 +94,8 @@ async function buildCustomCommands( for (const [name, binaryPath] of binaries) { commands.push( defineCommand(name, async (args: string[], ctx) => { + const invocation = buildBinaryInvocation(binaryPath, args); + // Convert ctx.env (Map-like) to a plain Record for child_process const envRecord: Record = { ...process.env } as Record< string, @@ -96,8 +115,8 @@ async function buildCustomCommands( exitCode: number; }>((resolve) => { execFile( - binaryPath, - args, + invocation.command, + invocation.args, { cwd: ctx.cwd, env: envRecord, From 6ea0efd8b71d2fedd15bdca4473d9aadd89ba20e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Sat, 4 Apr 2026 02:39:08 +0100 Subject: [PATCH 2/7] Add root bin path for embedded workers --- packages/gateway/src/__tests__/embedded-deployment.test.ts | 4 +--- .../gateway/src/orchestration/impl/embedded-deployment.ts | 2 ++ 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/gateway/src/__tests__/embedded-deployment.test.ts b/packages/gateway/src/__tests__/embedded-deployment.test.ts index 7ecceebb9..cbf704ec6 100644 --- a/packages/gateway/src/__tests__/embedded-deployment.test.ts +++ b/packages/gateway/src/__tests__/embedded-deployment.test.ts @@ -294,9 +294,7 @@ describe("EmbeddedDeploymentManager", () => { | { env?: Record } | undefined; const pathEntries = (spawnOptions?.env?.PATH || "").split(":"); - expect(pathEntries).toContain( - path.resolve("packages/worker/node_modules/.bin") - ); + expect(pathEntries).toContain(path.resolve("node_modules/.bin")); }); test("child process exit removes worker from map", async () => { diff --git a/packages/gateway/src/orchestration/impl/embedded-deployment.ts b/packages/gateway/src/orchestration/impl/embedded-deployment.ts index dab6dbd43..c6a869b99 100644 --- a/packages/gateway/src/orchestration/impl/embedded-deployment.ts +++ b/packages/gateway/src/orchestration/impl/embedded-deployment.ts @@ -20,7 +20,9 @@ const logger = createLogger("orchestrator"); /** Timeout (ms) to wait for graceful shutdown before SIGKILL. */ const KILL_TIMEOUT_MS = 5_000; const WORKER_BIN_DIR_CANDIDATES = [ + path.resolve("node_modules/.bin"), path.resolve("packages/worker/node_modules/.bin"), + "/app/node_modules/.bin", "/app/packages/worker/node_modules/.bin", ] as const; From d2a2278578f45fe672687acfe231308dab42028c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Burak=20Emre=20Kabakc=C4=B1?= Date: Sat, 4 Apr 2026 02:42:03 +0100 Subject: [PATCH 3/7] Apply pending repository changes --- .env.example | 4 +- README.md | 6 +- bun.lock | 238 +- charts/lobu/templates/gateway-deployment.yaml | 8 +- charts/lobu/values-community.yaml | 2 +- charts/lobu/values.yaml | 2 +- config/dependency-cruiser.config.cjs | 63 - config/knip.ts | 21 - config/skill-registries.json | 14 - config/system-skills.json | 2 +- docker/Dockerfile.gateway | 12 +- docker/docker-compose.yml | 6 +- examples/hr-assistant/README.md | 2 +- examples/hr-assistant/TESTING.md | 18 +- examples/hr-assistant/docker-compose.yml | 3 +- landing/.gitignore | 2 - landing/bun.lock | 270 --- landing/index.html | 18 - landing/package.json | 23 - landing/src/App.tsx | 199 -- landing/src/components/AnimatedPacket.tsx | 119 - landing/src/components/ConnectionLine.tsx | 32 - landing/src/components/Diagram.tsx | 104 - landing/src/components/DiagramNode.tsx | 154 -- landing/src/components/NodeIcon.tsx | 196 -- landing/src/components/PromptSwitcher.tsx | 47 - landing/src/components/SlackPanel.tsx | 363 ---- landing/src/components/StepIndicator.tsx | 177 -- landing/src/main.tsx | 9 - landing/src/nodes.ts | 54 - landing/src/steps.ts | 344 --- landing/src/styles.ts | 73 - landing/src/types.ts | 44 - landing/tsconfig.json | 21 - landing/vite.config.ts | 7 - packages/cli/package.json | 1 - packages/cli/src/__tests__/login.test.ts | 229 -- packages/cli/src/api/client.ts | 43 - packages/cli/src/api/context.ts | 6 +- packages/cli/src/commands/chat.ts | 180 +- packages/cli/src/commands/dev.ts | 227 +- packages/cli/src/commands/init.ts | 188 +- packages/cli/src/commands/launch.ts | 39 - packages/cli/src/commands/status.ts | 2 +- packages/cli/src/config/agents-manifest.ts | 87 - packages/cli/src/config/loader.ts | 72 +- packages/cli/src/index.ts | 38 +- packages/cli/src/mcp-servers.ts | 44 - packages/cli/src/templates/README.md.tmpl | 4 +- packages/cli/src/templates/TESTING.md.tmpl | 38 +- packages/cli/src/utils/config.ts | 12 - packages/core/src/agent-policy.ts | 66 +- packages/core/src/agent-store.ts | 27 +- packages/core/src/index.ts | 10 +- packages/core/src/types.ts | 6 +- packages/gateway/package.json | 18 +- packages/gateway/scripts/build-agent.ts | 48 - packages/gateway/scripts/build-agents.ts | 36 - packages/gateway/scripts/build-history.ts | 50 - packages/gateway/scripts/generate-css.ts | 29 - .../src/__tests__/agent-config-routes.test.ts | 131 +- .../__tests__/agent-history-routes.test.ts | 64 + .../src/__tests__/agent-routes.test.ts | 63 + .../__tests__/agent-schedules-routes.test.ts | 54 + .../__tests__/chat-response-bridge.test.ts | 4 +- .../__tests__/config-request-store.test.ts | 137 ++ .../src/__tests__/connection-routes.test.ts | 128 ++ .../src/__tests__/instruction-service.test.ts | 2 +- .../src/__tests__/link-buttons.test.ts | 31 +- .../__tests__/message-handler-bridge.test.ts | 17 + .../src/__tests__/routes/cli-auth.test.ts | 79 +- .../__tests__/settings-oauth-client.test.ts | 358 --- .../__tests__/skill-and-mcp-registry.test.ts | 141 -- packages/gateway/src/api/index.ts | 1 - packages/gateway/src/api/platform.ts | 10 + .../gateway/src/auth/admin-status-cache.ts | 57 - .../gateway/src/auth/api-auth-middleware.ts | 2 +- packages/gateway/src/auth/chatgpt/index.ts | 1 - packages/gateway/src/auth/external/client.ts | 6 +- packages/gateway/src/auth/mcp/proxy.ts | 31 +- packages/gateway/src/auth/oauth-templates.ts | 15 +- .../src/auth/provider-model-options.ts | 16 - .../src/auth/settings/agent-settings-store.ts | 3 - .../src/auth/settings/claim-service.ts | 168 -- packages/gateway/src/auth/settings/index.ts | 1 - .../src/auth/settings/model-selection.ts | 2 +- .../gateway/src/auth/settings/oauth-client.ts | 4 - .../auth/settings/resolved-settings-view.ts | 6 +- .../src/auth/settings/template-utils.ts | 39 - .../src/auth/settings/token-service.ts | 34 +- .../gateway/src/auth/telegram-webapp-auth.ts | 147 -- packages/gateway/src/cli/gateway.ts | 321 +-- .../gateway/src/commands/built-in-commands.ts | 87 +- packages/gateway/src/config/file-loader.ts | 618 ++++++ packages/gateway/src/config/index.ts | 19 + .../src/connections/chat-instance-manager.ts | 17 +- .../src/connections/chat-response-bridge.ts | 92 +- .../src/connections/interaction-bridge.ts | 161 +- .../src/connections/message-handler-bridge.ts | 20 +- .../src/connections/platform-auth-methods.ts | 8 +- packages/gateway/src/connections/types.ts | 12 +- packages/gateway/src/gateway-main.ts | 10 +- packages/gateway/src/gateway/index.ts | 4 +- packages/gateway/src/index.ts | 52 +- .../infrastructure/model-provider/index.ts | 4 - packages/gateway/src/interactions.ts | 59 +- .../src/interactions/config-request-store.ts | 198 ++ packages/gateway/src/lobu.ts | 310 +++ packages/gateway/src/metrics/prometheus.ts | 58 +- packages/gateway/src/modules/module-system.ts | 8 - .../orchestration/base-deployment-manager.ts | 2 +- .../orchestration/impl/docker-deployment.ts | 8 +- .../src/orchestration/impl/k8s/deployment.ts | 10 +- .../src/orchestration/impl/k8s/helpers.ts | 4 +- packages/gateway/src/platform.ts | 4 - .../gateway/src/platform/interaction-utils.ts | 13 - packages/gateway/src/platform/link-buttons.ts | 4 +- .../gateway/src/platform/renderer-utils.ts | 27 - packages/gateway/src/proxy/http-proxy.ts | 11 +- packages/gateway/src/proxy/proxy-manager.ts | 2 +- packages/gateway/src/proxy/secret-proxy.ts | 4 +- .../routes/internal/integrations-discovery.ts | 450 ---- .../src/routes/internal/settings-link.ts | 263 --- packages/gateway/src/routes/openapi-auto.ts | 35 +- .../gateway/src/routes/public/agent-access.ts | 43 +- .../gateway/src/routes/public/agent-config.ts | 1918 +++-------------- .../src/routes/public/agent-history.ts | 40 +- .../src/routes/public/agent-page/api.ts | 592 ----- .../src/routes/public/agent-page/app.tsx | 1186 ---------- .../components/ConnectionsSection.tsx | 1207 ----------- .../public/agent-page/components/Header.tsx | 239 -- .../components/InstructionsSection.tsx | 82 - .../agent-page/components/MessageBanners.tsx | 501 ----- .../components/NixPackagesSection.tsx | 203 -- .../components/PermissionsSection.tsx | 196 -- .../agent-page/components/ProviderSection.tsx | 957 -------- .../components/RemindersSection.tsx | 159 -- .../public/agent-page/components/Section.tsx | 90 - .../agent-page/components/SkillsSection.tsx | 362 ---- .../src/routes/public/agent-page/index.ts | 443 ---- .../src/routes/public/agent-page/types.ts | 255 --- .../src/routes/public/agent-page/utils.ts | 41 - .../src/routes/public/agent-schedules.ts | 48 +- .../src/routes/public/agent-settings.ts | 1253 ----------- packages/gateway/src/routes/public/agent.ts | 406 +++- .../gateway/src/routes/public/agents-page.ts | 529 ----- .../routes/public/agents-page/EnvVarRow.tsx | 115 - .../src/routes/public/agents-page/app.tsx | 500 ----- packages/gateway/src/routes/public/agents.ts | 82 +- .../gateway/src/routes/public/cli-auth.ts | 187 +- .../gateway/src/routes/public/connections.ts | 588 +---- .../src/routes/public/history-page/app.tsx | 256 --- .../history-page/components/MessageRow.tsx | 241 --- .../history-page/components/StatusBar.tsx | 64 - .../history-page/components/WakePrompt.tsx | 83 - .../src/routes/public/history-page/index.ts | 35 - .../src/routes/public/history-page/types.ts | 33 - .../gateway/src/routes/public/integrations.ts | 206 -- packages/gateway/src/routes/public/landing.ts | 11 +- .../gateway/src/routes/public/messaging.ts | 370 ---- packages/gateway/src/routes/public/oauth.ts | 8 +- .../src/routes/public/settings-auth.ts | 46 +- .../src/routes/public/settings-input.css | 69 - .../src/routes/shared/agent-ownership.ts | 101 + .../src/routes/shared/token-verifier.ts | 36 +- packages/gateway/src/services/agent-seeder.ts | 382 ---- .../gateway/src/services/core-services.ts | 403 +++- .../src/services/instruction-service.ts | 14 +- .../gateway/src/services/mcp-discovery.ts | 350 --- .../gateway/src/services/skill-registry.ts | 226 -- .../gateway/src/services/skills-fetcher.ts | 303 --- .../src/services/system-skills-registry.ts | 72 - .../src/services/system-skills-service.ts | 2 +- packages/gateway/src/session.ts | 36 +- .../src/stores/in-memory-agent-store.ts | 403 ++++ .../gateway/src/stores/redis-agent-store.ts | 435 ---- packages/gateway/src/utils/public-url.ts | 2 +- packages/gateway/tailwind.config.js | 11 - packages/landing/astro.config.mjs | 3 + packages/landing/package.json | 1 - packages/landing/src/components/CTA.tsx | 14 +- .../landing/src/components/DemoSection.tsx | 551 +++-- .../landing/src/components/HeroSection.tsx | 40 +- .../landing/src/components/InstallSection.tsx | 85 +- .../landing/src/components/MemorySection.tsx | 782 +++++++ packages/landing/src/components/Nav.tsx | 110 +- .../landing/src/components/SkillsSection.tsx | 642 +++--- .../landing/src/components/TerminalLog.tsx | 8 +- .../src/content/docs/deployment/cloud.md | 30 - .../src/content/docs/deployment/embedding.md | 245 +++ .../docs/getting-started/capabilities.mdx | 31 - .../content/docs/getting-started/index.mdx | 5 +- .../content/docs/getting-started/skills.mdx | 13 - .../src/content/docs/guides/architecture.md | 2 +- .../src/content/docs/guides/mcp-proxy.md | 8 - .../src/content/docs/platforms/discord.md | 62 + .../src/content/docs/platforms/google-chat.md | 71 + .../src/content/docs/platforms/teams.md | 67 + .../landing/src/content/docs/reference/cli.md | 38 +- packages/landing/src/globals.css | 7 +- .../landing/src/pages/blog/[...slug].astro | 2 +- packages/landing/src/pages/blog/index.astro | 2 +- packages/landing/src/pages/index.astro | 5 +- packages/landing/src/pages/memory.astro | 23 + .../src/pages/reference/api-reference.astro | 41 +- .../src/pages/serverless-openclaw.astro | 2 +- packages/landing/src/pages/skills.astro | 2 +- packages/worker/package.json | 1 - .../audio-provider-suggestions.test.ts | 101 +- .../worker/src/__tests__/tool-policy.test.ts | 6 +- .../worker/src/common/mcp-discovery-client.ts | 121 -- packages/worker/src/gateway/sse-client.ts | 13 +- packages/worker/src/openclaw/custom-tools.ts | 68 - .../worker/src/openclaw/model-resolver.ts | 2 +- .../worker/src/openclaw/session-context.ts | 4 +- packages/worker/src/openclaw/tool-policy.ts | 12 - packages/worker/src/openclaw/tools.ts | 33 +- packages/worker/src/openclaw/worker.ts | 76 +- packages/worker/src/shared/processor-utils.ts | 2 +- .../worker/src/shared/tool-implementations.ts | 470 +--- scripts/test-bot.sh | 15 +- 221 files changed, 6978 insertions(+), 22062 deletions(-) delete mode 100644 config/dependency-cruiser.config.cjs delete mode 100644 config/knip.ts delete mode 100644 config/skill-registries.json delete mode 100644 landing/.gitignore delete mode 100644 landing/bun.lock delete mode 100644 landing/index.html delete mode 100644 landing/package.json delete mode 100644 landing/src/App.tsx delete mode 100644 landing/src/components/AnimatedPacket.tsx delete mode 100644 landing/src/components/ConnectionLine.tsx delete mode 100644 landing/src/components/Diagram.tsx delete mode 100644 landing/src/components/DiagramNode.tsx delete mode 100644 landing/src/components/NodeIcon.tsx delete mode 100644 landing/src/components/PromptSwitcher.tsx delete mode 100644 landing/src/components/SlackPanel.tsx delete mode 100644 landing/src/components/StepIndicator.tsx delete mode 100644 landing/src/main.tsx delete mode 100644 landing/src/nodes.ts delete mode 100644 landing/src/steps.ts delete mode 100644 landing/src/styles.ts delete mode 100644 landing/src/types.ts delete mode 100644 landing/tsconfig.json delete mode 100644 landing/vite.config.ts delete mode 100644 packages/cli/src/__tests__/login.test.ts delete mode 100644 packages/cli/src/api/client.ts delete mode 100644 packages/cli/src/commands/launch.ts delete mode 100644 packages/cli/src/config/agents-manifest.ts delete mode 100644 packages/cli/src/mcp-servers.ts delete mode 100644 packages/cli/src/utils/config.ts delete mode 100644 packages/gateway/scripts/build-agent.ts delete mode 100644 packages/gateway/scripts/build-agents.ts delete mode 100644 packages/gateway/scripts/build-history.ts delete mode 100644 packages/gateway/scripts/generate-css.ts create mode 100644 packages/gateway/src/__tests__/agent-history-routes.test.ts create mode 100644 packages/gateway/src/__tests__/agent-routes.test.ts create mode 100644 packages/gateway/src/__tests__/agent-schedules-routes.test.ts create mode 100644 packages/gateway/src/__tests__/config-request-store.test.ts create mode 100644 packages/gateway/src/__tests__/connection-routes.test.ts create mode 100644 packages/gateway/src/__tests__/message-handler-bridge.test.ts delete mode 100644 packages/gateway/src/__tests__/settings-oauth-client.test.ts delete mode 100644 packages/gateway/src/auth/admin-status-cache.ts delete mode 100644 packages/gateway/src/auth/settings/claim-service.ts delete mode 100644 packages/gateway/src/auth/settings/oauth-client.ts delete mode 100644 packages/gateway/src/auth/telegram-webapp-auth.ts create mode 100644 packages/gateway/src/config/file-loader.ts delete mode 100644 packages/gateway/src/infrastructure/model-provider/index.ts create mode 100644 packages/gateway/src/interactions/config-request-store.ts create mode 100644 packages/gateway/src/lobu.ts delete mode 100644 packages/gateway/src/platform/interaction-utils.ts delete mode 100644 packages/gateway/src/routes/internal/integrations-discovery.ts delete mode 100644 packages/gateway/src/routes/internal/settings-link.ts delete mode 100644 packages/gateway/src/routes/public/agent-page/api.ts delete mode 100644 packages/gateway/src/routes/public/agent-page/app.tsx delete mode 100644 packages/gateway/src/routes/public/agent-page/components/ConnectionsSection.tsx delete mode 100644 packages/gateway/src/routes/public/agent-page/components/Header.tsx delete mode 100644 packages/gateway/src/routes/public/agent-page/components/InstructionsSection.tsx delete mode 100644 packages/gateway/src/routes/public/agent-page/components/MessageBanners.tsx delete mode 100644 packages/gateway/src/routes/public/agent-page/components/NixPackagesSection.tsx delete mode 100644 packages/gateway/src/routes/public/agent-page/components/PermissionsSection.tsx delete mode 100644 packages/gateway/src/routes/public/agent-page/components/ProviderSection.tsx delete mode 100644 packages/gateway/src/routes/public/agent-page/components/RemindersSection.tsx delete mode 100644 packages/gateway/src/routes/public/agent-page/components/Section.tsx delete mode 100644 packages/gateway/src/routes/public/agent-page/components/SkillsSection.tsx delete mode 100644 packages/gateway/src/routes/public/agent-page/index.ts delete mode 100644 packages/gateway/src/routes/public/agent-page/types.ts delete mode 100644 packages/gateway/src/routes/public/agent-page/utils.ts delete mode 100644 packages/gateway/src/routes/public/agent-settings.ts delete mode 100644 packages/gateway/src/routes/public/agents-page.ts delete mode 100644 packages/gateway/src/routes/public/agents-page/EnvVarRow.tsx delete mode 100644 packages/gateway/src/routes/public/agents-page/app.tsx delete mode 100644 packages/gateway/src/routes/public/history-page/app.tsx delete mode 100644 packages/gateway/src/routes/public/history-page/components/MessageRow.tsx delete mode 100644 packages/gateway/src/routes/public/history-page/components/StatusBar.tsx delete mode 100644 packages/gateway/src/routes/public/history-page/components/WakePrompt.tsx delete mode 100644 packages/gateway/src/routes/public/history-page/index.ts delete mode 100644 packages/gateway/src/routes/public/history-page/types.ts delete mode 100644 packages/gateway/src/routes/public/integrations.ts delete mode 100644 packages/gateway/src/routes/public/messaging.ts delete mode 100644 packages/gateway/src/routes/public/settings-input.css create mode 100644 packages/gateway/src/routes/shared/agent-ownership.ts delete mode 100644 packages/gateway/src/services/agent-seeder.ts delete mode 100644 packages/gateway/src/services/mcp-discovery.ts delete mode 100644 packages/gateway/src/services/skill-registry.ts delete mode 100644 packages/gateway/src/services/skills-fetcher.ts delete mode 100644 packages/gateway/src/services/system-skills-registry.ts create mode 100644 packages/gateway/src/stores/in-memory-agent-store.ts delete mode 100644 packages/gateway/src/stores/redis-agent-store.ts delete mode 100644 packages/gateway/tailwind.config.js create mode 100644 packages/landing/src/components/MemorySection.tsx delete mode 100644 packages/landing/src/content/docs/deployment/cloud.md create mode 100644 packages/landing/src/content/docs/deployment/embedding.md create mode 100644 packages/landing/src/content/docs/platforms/discord.md create mode 100644 packages/landing/src/content/docs/platforms/google-chat.md create mode 100644 packages/landing/src/content/docs/platforms/teams.md create mode 100644 packages/landing/src/pages/memory.astro delete mode 100644 packages/worker/src/common/mcp-discovery-client.ts diff --git a/.env.example b/.env.example index f7514a4fd..24a70ac61 100644 --- a/.env.example +++ b/.env.example @@ -67,8 +67,8 @@ WORKER_DISALLOWED_DOMAINS= # Public gateway URL (required for OAuth callbacks) PUBLIC_GATEWAY_URL=https://community.lobu.ai -# Owletto MCP URL (enables OAuth login, memory, and integrations) -AUTH_MCP_URL=https://owletto.com/mcp +# Owletto MCP URL (enables memory and integrations) +MEMORY_URL=https://owletto.com/mcp # System skills registry URL (points to config/system-skills.json) # LOBU_SYSTEM_SKILLS_URL=file:///app/config/system-skills.json diff --git a/README.md b/README.md index c4e6cc843..7d89387cb 100644 --- a/README.md +++ b/README.md @@ -82,14 +82,14 @@ Every Lobu agent comes equipped with a suite of tools for autonomous execution a | Feature | Description | Built-in Tools | | :--- | :--- | :--- | | **Autonomous Scheduling** | Schedule one-time or recurring execution via cron. | `ScheduleReminder`, `ListReminders`, `CancelReminder` | -| **Human-in-the-Loop** | Pause for user input via buttons and resume when answered. | `AskUserQuestion`, `Configure` | +| **Human-in-the-Loop** | Pause for user input via buttons and resume when answered. | `AskUserQuestion` | | **Full Linux Toolbox** | Sandboxed shell access, file editing, and advanced search. | `bash`, `read`, `write`, `edit`, `grep`, `find`, `ls` | | **Conversation Context** | Pull earlier thread messages when the user references prior work. | `GetChannelHistory` | | **File & Media Delivery** | Share reports, charts, or generated voice messages. | `UploadUserFile`, `GenerateAudio` | -| **Self-Expansion** | Search and dynamically install new skills or MCP servers. | `SearchSkills`, `InstallSkill` | +| **Skills** | Extend agent capabilities via skills configured in lobu.toml or the admin settings page. | `lobu.toml`, Settings UI | | **Connected APIs** | Access third-party APIs (GitHub, Google, etc.) through Owletto MCP tools with managed OAuth. | MCP tools via Owletto | | **Managed MCP Proxy** | Securely connect to any MCP server with secret injection. | [MCP Proxy](docs/SECURITY.md#credentials) | -| **Advanced Capabilities** | Extend agent abilities with web browsing, headless UI interaction, and specialized utilities via Nix packages or external MCP servers. | `bash` (Nix), `SearchSkills`, `InstallSkill` (MCP) | +| **Advanced Capabilities** | Extend agent abilities with web browsing, headless UI interaction, and specialized utilities via Nix packages or external MCP servers. | `bash` (Nix), MCP servers | ### Popular MCP Integrations Workers access third-party APIs through MCP servers. OAuth and credential management is handled by Owletto: diff --git a/bun.lock b/bun.lock index aa59d3e23..defa70164 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,6 @@ "open": "^10.1.0", "ora": "^8.0.1", "smol-toml": "^1.3.1", - "yaml": "^2.3.4", "zod": "^3.24.0", }, "devDependencies": { @@ -62,6 +61,7 @@ "version": "1.0.0", "dependencies": { "@chat-adapter/discord": "^4", + "@chat-adapter/gchat": "^4", "@chat-adapter/slack": "^4", "@chat-adapter/state-ioredis": "^4", "@chat-adapter/teams": "^4", @@ -81,23 +81,13 @@ "dotenv": "^17.2.1", "hono": "^4.11.7", "ioredis": "^5.4.1", - "jose": "^6.0.11", - "jsonwebtoken": "^9.0.2", - "marked": "^12.0.0", - "pino": "^9.1.0", + "smol-toml": "^1.3.1", "yaml": "^2.7.1", "zod": "^4.1.12", }, "devDependencies": { - "@preact/signals": "^2.0.0", - "@tailwindcss/typography": "^0.5.19", - "@tanstack/react-virtual": "^3.11.2", "@types/dockerode": "^3.3.29", - "@types/express": "^4.17.21", - "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.0.0", - "esbuild": "^0.24.0", - "preact": "^10.25.0", "typescript": "^5.8.3", }, }, @@ -109,7 +99,6 @@ "@astrojs/sitemap": "^3.7.0", "@astrojs/starlight": "^0.37.6", "@preact/signals": "^1.3.1", - "@scalar/astro": "^0.2.0", "astro": "^5.18.0", "preact": "^10.25.4", "zod": "^3.25.76", @@ -133,7 +122,6 @@ "@mariozechner/pi-agent-core": "^0.51.6", "@mariozechner/pi-ai": "^0.51.6", "@mariozechner/pi-coding-agent": "^0.51.6", - "@modelcontextprotocol/sdk": "^1.17.4", "@sentry/node": "^10.6.0", "@sinclair/typebox": "^0.34.33", "form-data": "^4.0.4", @@ -343,6 +331,8 @@ "@chat-adapter/discord": ["@chat-adapter/discord@4.20.0", "", { "dependencies": { "@chat-adapter/shared": "4.20.0", "chat": "4.20.0", "discord-api-types": "^0.37.119", "discord-interactions": "^4.4.0", "discord.js": "^14.25.1" } }, "sha512-iCHoxOWmHJx3Z3haxZQJwd9oq2fN/mK+oieiJ0ttL1vuJ/BDi2tnvC2hXP3X+kS1yb2Iy5sK476HeAlMu9vwzw=="], + "@chat-adapter/gchat": ["@chat-adapter/gchat@4.23.0", "", { "dependencies": { "@chat-adapter/shared": "4.23.0", "@googleapis/chat": "^44.6.0", "@googleapis/workspaceevents": "^9.1.0", "chat": "4.23.0" } }, "sha512-4IQxu3bHDkCLLaBldB8jN68AGgJhMN9pNIovUWWzL3HRQ4yHADna8B3PsCQEzy2KjPXV85aG7/+5C+nm7P4rCA=="], + "@chat-adapter/shared": ["@chat-adapter/shared@4.20.0", "", { "dependencies": { "chat": "4.20.0" } }, "sha512-PaN3TADZUswD5VNV5qMS5M316PP9hp14lGHNa5SB8s7LN5PGXuXUroO0e7rjyo18i7jNKsalJUjpD/uoEg0u3Q=="], "@chat-adapter/slack": ["@chat-adapter/slack@4.20.0", "", { "dependencies": { "@chat-adapter/shared": "4.20.0", "@slack/web-api": "^7.14.0", "chat": "4.20.0" } }, "sha512-pZruK/ndyXJcgwTQ7S9MjB31TE80REuMtcGGlZCX/iaPSZ2QYDhz2493MvtBiy2DY4dyNOk2eihsf1r9JzzhkQ=="], @@ -379,57 +369,57 @@ "@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.24.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-thpVCb/rhxE/BnMLQ7GReQLLN8q9qbHmI55F4489/ByVg2aQaQ6kbcLb6FHkocZzQhxc4gx0sCk0tJkKBFzDhA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], - "@esbuild/android-arm": ["@esbuild/android-arm@0.24.2", "", { "os": "android", "cpu": "arm" }, "sha512-tmwl4hJkCfNHwFB3nBa8z1Uy3ypZpxqxfTQOcHX+xRByyYgunVbZ9MzUUfb0RxaHIMnbHagwAxuTL+tnNM+1/Q=="], + "@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.24.2", "", { "os": "android", "cpu": "arm64" }, "sha512-cNLgeqCqV8WxfcTIOeL4OAtSmL8JjcN6m09XIgro1Wi7cF4t/THaWEa7eL5CMoMBdjoHOTh/vwTO/o2TRXIyzg=="], + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], - "@esbuild/android-x64": ["@esbuild/android-x64@0.24.2", "", { "os": "android", "cpu": "x64" }, "sha512-B6Q0YQDqMx9D7rvIcsXfmJfvUYLoP722bgfBlO5cGvNVb5V/+Y7nhBE3mHV9OpxBf4eAS2S68KZztiPaWq4XYw=="], + "@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.24.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-kj3AnYWc+CekmZnS5IPu9D+HWtUI49hbnyqk0FLEJDbzCIQt7hg7ucF1SQAilhtYpIujfaHr6O0UHlzzSPdOeA=="], + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.24.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-WeSrmwwHaPkNR5H3yYfowhZcbriGqooyu3zI/3GGpF8AyUdsrrP0X6KumITGA9WOyiJavnGZUwPGvxvwfWPHIA=="], + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.24.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-UN8HXjtJ0k/Mj6a9+5u6+2eZ2ERD7Edt1Q9IZiB5UZAIdPnVKDoG7mdTVGhHJIeEml60JteamR3qhsr1r8gXvg=="], + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.24.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TvW7wE/89PYW+IevEJXZ5sF6gJRDY/14hyIGFXdIucxCsbRmLUcjseQu1SyTko+2idmCw94TgyaEZi9HUSOe3Q=="], + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.24.2", "", { "os": "linux", "cpu": "arm" }, "sha512-n0WRM/gWIdU29J57hJyUdIsk0WarGd6To0s+Y+LwvlC55wt+GT/OgkwoXCXvIue1i1sSNWblHEig00GBWiJgfA=="], + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.24.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-7HnAD6074BW43YvvUmE/35Id9/NB7BeX5EoNkK9obndmZBUk8xmJJeU7DwmUeN7tkysslb2eSl6CTrYz6oEMQg=="], + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.24.2", "", { "os": "linux", "cpu": "ia32" }, "sha512-sfv0tGPQhcZOgTKO3oBE9xpHuUqguHvSo4jl+wjnKwFpapx+vUDcawbwPNuBIAYdRAvIDBfZVvXprIj3HA+Ugw=="], + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-CN9AZr8kEndGooS35ntToZLTQLHEjtVB5n7dl8ZcTZMonJ7CCfStrYhrzF97eAecqVbVJ7APOEe18RPI4KLhwQ=="], + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-iMkk7qr/wl3exJATwkISxI7kTcmHKE+BlymIAbHO8xanq/TjHaaVThFF6ipWzPHryoFsesNQJPE/3wFJw4+huw=="], + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.24.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-shsVrgCZ57Vr2L8mm39kO5PPIb+843FStGt7sGGoqiiWYconSxwTiuswC1VJZLCjNiMLAMh34jg4VSEQb+iEbw=="], + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.24.2", "", { "os": "linux", "cpu": "none" }, "sha512-4eSFWnU9Hhd68fW16GD0TINewo1L6dRrB+oLNNbYyMUAeOD2yCK5KXGK1GH4qD/kT+bTEXjsyTCiJGHPZ3eM9Q=="], + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.24.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-S0Bh0A53b0YHL2XEXC20bHLuGMOhFDO6GN4b3YjRLK//Ep3ql3erpNcPlEFed93hsQAjAQDNsvcK+hV90FubSw=="], + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.24.2", "", { "os": "linux", "cpu": "x64" }, "sha512-8Qi4nQcCTbLnK9WoMjdC9NiTG6/E38RNICU6sUNqK0QFxCYgoARqVqxdFmWkdonVsvGqWhmm7MO0jyTqLqwj0Q=="], + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.24.2", "", { "os": "none", "cpu": "arm64" }, "sha512-wuLK/VztRRpMt9zyHSazyCVdCXlpHkKm34WUyinD2lzK07FAHTq0KQvZZlXikNWkDGoT6x3TD51jKQ7gMVpopw=="], + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.24.2", "", { "os": "none", "cpu": "x64" }, "sha512-VefFaQUc4FMmJuAxmIHgUmfNiLXY438XrL4GDNV1Y1H/RW3qow68xTwjZKfj/+Plp9NANmzbH5R40Meudu8mmw=="], + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.24.2", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-YQbi46SBct6iKnszhSvdluqDmxCJA+Pu280Av9WICNwQmMxV7nLRHZfjQzwbPs3jeWnuAhE9Jy0NrnJ12Oz+0A=="], + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.24.2", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+iDS6zpNM6EnJyWv0bMGLWSWeXGN/HTaF/LXHXHwejGsVi+ooqDfMCCTerNFxEkM3wYVcExkeGXNqshc9iMaOA=="], + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g=="], - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.24.2", "", { "os": "sunos", "cpu": "x64" }, "sha512-hTdsW27jcktEvpwNHJU4ZwWFGkz2zRJUz8pvddmXPtXDzVKTTINmlmga3ZzwcuMpUvLw7JkLy9QLKyGpD2Yxig=="], + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.24.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-LihEQ2BBKVFLOC9ZItT9iFprsE9tqjDjnbulhHoFxYQtQfai7qfluVODIYxt1PgdoyQkz23+01rzwNwYfutxUQ=="], + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.24.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-q+iGUwfs8tncmFC9pcnD5IvRHAzmbwQ3GPS5/ceCyHdjXubwQWI12MKWSNSMYLJMq23/IUCvJMS76PDqXe1fxA=="], + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.24.2", "", { "os": "win32", "cpu": "x64" }, "sha512-7VTgWzgMGvup6aSqDPLiW5zHaxYJGTO4OokMjIlrCtf+VpEL+cXKtCvg723iguPYI5oaUNdS+/V7OU2gvXVWEg=="], + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], "@expressive-code/core": ["@expressive-code/core@0.41.7", "", { "dependencies": { "@ctrl/tinycolor": "^4.0.4", "hast-util-select": "^6.0.2", "hast-util-to-html": "^9.0.1", "hast-util-to-text": "^4.0.1", "hastscript": "^9.0.0", "postcss": "^8.4.38", "postcss-nested": "^6.0.1", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.1" } }, "sha512-ck92uZYZ9Wba2zxkiZLsZGi9N54pMSAVdrI9uW3Oo9AtLglD5RmrdTwbYPCT2S/jC36JGB2i+pnQtBm/Ib2+dg=="], @@ -441,6 +431,10 @@ "@google/genai": ["@google/genai@1.34.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.24.0" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-vu53UMPvjmb7PGzlYu6Tzxso8Dfhn+a7eQFaS2uNemVtDZKwzSpJ5+ikqBbXplF7RGB1STcVDqCkPvquiwb2sw=="], + "@googleapis/chat": ["@googleapis/chat@44.6.0", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-Bnqzev/bSTXSbE0/N2WS4Stnleo8j9bJJ1LkCBk1fXQnehcArVMv7q543rzPYU6MJql4D34On6diNGAuYtI9xQ=="], + + "@googleapis/workspaceevents": ["@googleapis/workspaceevents@9.1.0", "", { "dependencies": { "googleapis-common": "^8.0.0" } }, "sha512-aJiMrTi/YyUUaaTO0tnhTHDYU+N9CTD3l3FSfe0yzEHQl7DEc+1LISgdK1o2nurvCtguBEumify5kTkr6Cg5eA=="], + "@grpc/grpc-js": ["@grpc/grpc-js@1.13.4", "", { "dependencies": { "@grpc/proto-loader": "^0.7.13", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-GsFaMXCkMqkKIvwCQjCrwH+GHbPKBjhwo/8ZuUkWHqbI73Kky9I+pQltrlT0+MWpedCoosda53lgjYfyEPgxBg=="], "@grpc/proto-loader": ["@grpc/proto-loader@0.7.15", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.2.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ=="], @@ -767,13 +761,11 @@ "@pagefind/windows-x64": ["@pagefind/windows-x64@1.4.0", "", { "os": "win32", "cpu": "x64" }, "sha512-NkT+YAdgS2FPCn8mIA9bQhiBs+xmniMGq1LFPDhcFn0+2yIUEiIG06t7bsZlhdjknEQRTSdT7YitP6fC5qwP0g=="], - "@pinojs/redact": ["@pinojs/redact@0.4.0", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="], - "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], "@preact/preset-vite": ["@preact/preset-vite@2.10.3", "", { "dependencies": { "@babel/plugin-transform-react-jsx": "^7.27.1", "@babel/plugin-transform-react-jsx-development": "^7.27.1", "@prefresh/vite": "^2.4.11", "@rollup/pluginutils": "^5.0.0", "babel-plugin-transform-hook-names": "^1.0.2", "debug": "^4.4.3", "picocolors": "^1.1.1", "vite-prerender-plugin": "^0.5.8" }, "peerDependencies": { "@babel/core": "7.x", "vite": "2.x || 3.x || 4.x || 5.x || 6.x || 7.x" } }, "sha512-1SiS+vFItpkNdBs7q585PSAIln0wBeBdcpJYbzPs1qipsb/FssnkUioNXuRsb8ZnU8YEQHr+3v8+/mzWSnTQmg=="], - "@preact/signals": ["@preact/signals@2.8.1", "", { "dependencies": { "@preact/signals-core": "^1.13.0" }, "peerDependencies": { "preact": ">= 10.25.0 || >=11.0.0-0" } }, "sha512-wX6U0SpcCukZTJBs5ChljvBZb3XmYzA5gd4vKHgX8wZZKaQCo2WHtmThdLx+mcVvlBa5u3XShC7ffbETJD4BiQ=="], + "@preact/signals": ["@preact/signals@1.3.4", "", { "dependencies": { "@preact/signals-core": "^1.7.0" }, "peerDependencies": { "preact": "10.x" } }, "sha512-TPMkStdT0QpSc8FpB63aOwXoSiZyIrPsP9Uj347KopdS6olZdAYeeird/5FZv/M1Yc1ge5qstub2o8VDbvkT4g=="], "@preact/signals-core": ["@preact/signals-core@1.13.0", "", {}, "sha512-slT6XeTCAbdql61GVLlGU4x7XHI7kCZV5Um5uhE4zLX4ApgiiXc0UYFvVOKq06xcovzp7p+61l68oPi563ARKg=="], @@ -865,8 +857,6 @@ "@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="], - "@scalar/astro": ["@scalar/astro@0.2.0", "", { "dependencies": { "@scalar/core": "0.4.0" }, "peerDependencies": { "astro": "^4.0.0 || ^5.0.0" } }, "sha512-OO93Ou3EkXqz8jUvAXR9CXZDH/teZGaUJDR627VBIMJtMIMDPNC7sT+8pIL0Mu8WZ6aXAYlP+KkXlvOFltRI8g=="], - "@scalar/core": ["@scalar/core@0.3.36", "", { "dependencies": { "@scalar/types": "0.6.1" } }, "sha512-gdgoF/XP2RkvhqGlI0l2MWTR/2522GPdaiQkWwS348Po8oCkJy2npxFuZbC2jtp6DIrWDrOD6qYgHssyzMmcrA=="], "@scalar/helpers": ["@scalar/helpers@0.2.10", "", {}, "sha512-VS32setBEAGY9JifuDZKHIq8SUCUWLEfL1V+h3s5V4wcmE8OZVkzaJemsMq/YAM9e7gb9ZbkvJLL4zzEvPSrVg=="], @@ -1027,14 +1017,8 @@ "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.1", "", { "os": "win32", "cpu": "x64" }, "sha512-rbO34G5sMWWyrN/idLeVxAZgAKWrn5LiR3/I90Q9MkA67s6T1oB0xtTe+0heoBvHSpbU9Mk7i6uwJnpo4u21XQ=="], - "@tailwindcss/typography": ["@tailwindcss/typography@0.5.19", "", { "dependencies": { "postcss-selector-parser": "6.0.10" }, "peerDependencies": { "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg=="], - "@tailwindcss/vite": ["@tailwindcss/vite@4.2.1", "", { "dependencies": { "@tailwindcss/node": "4.2.1", "@tailwindcss/oxide": "4.2.1", "tailwindcss": "4.2.1" }, "peerDependencies": { "vite": "^5.2.0 || ^6 || ^7" } }, "sha512-TBf2sJjYeb28jD2U/OhwdW0bbOsxkWPwQ7SrqGf9sVcoYwZj7rkXljroBO9wKBut9XnmQLXanuDUeqQK0lGg/w=="], - "@tanstack/react-virtual": ["@tanstack/react-virtual@3.13.19", "", { "dependencies": { "@tanstack/virtual-core": "3.13.19" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-KzwmU1IbE0IvCZSm6OXkS+kRdrgW2c2P3Ho3NC+zZXWK6oObv/L+lcV/2VuJ+snVESRlMJ+w/fg4WXI/JzoNGQ=="], - - "@tanstack/virtual-core": ["@tanstack/virtual-core@3.13.19", "", {}, "sha512-/BMP7kNhzKOd7wnDeB8NrIRNLwkf5AhCYCvtfZV2GXWbBieFm/el0n6LOAXlTi6ZwHICSNnQcIxRCWHrLzDY+g=="], - "@tokenizer/inflate": ["@tokenizer/inflate@0.4.1", "", { "dependencies": { "debug": "^4.4.3", "token-types": "^6.1.1" } }, "sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], @@ -1043,8 +1027,6 @@ "@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], - "@types/body-parser": ["@types/body-parser@1.19.6", "", { "dependencies": { "@types/connect": "*", "@types/node": "*" } }, "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g=="], - "@types/bun": ["@types/bun@1.2.11", "", { "dependencies": { "bun-types": "1.2.11" } }, "sha512-ZLbbI91EmmGwlWTRWuV6J19IUiUC5YQ3TCEuSHI3usIP75kuoA8/0PVF+LTrbEnVc8JIhpElWOxv1ocI1fJBbw=="], "@types/caseless": ["@types/caseless@0.12.5", "", {}, "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg=="], @@ -1061,26 +1043,18 @@ "@types/estree-jsx": ["@types/estree-jsx@1.0.5", "", { "dependencies": { "@types/estree": "*" } }, "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg=="], - "@types/express": ["@types/express@4.17.23", "", { "dependencies": { "@types/body-parser": "*", "@types/express-serve-static-core": "^4.17.33", "@types/qs": "*", "@types/serve-static": "*" } }, "sha512-Crp6WY9aTYP3qPi2wGDo9iUe/rceX01UMhnF1jmwDcKCFM6cx7YhGP/Mpr3y9AASpfHixIG0E6azCcL5OcDHsQ=="], - - "@types/express-serve-static-core": ["@types/express-serve-static-core@4.19.6", "", { "dependencies": { "@types/node": "*", "@types/qs": "*", "@types/range-parser": "*", "@types/send": "*" } }, "sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A=="], - "@types/hast": ["@types/hast@3.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ=="], - "@types/http-errors": ["@types/http-errors@2.0.5", "", {}, "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg=="], - "@types/inquirer": ["@types/inquirer@9.0.9", "", { "dependencies": { "@types/through": "*", "rxjs": "^7.2.0" } }, "sha512-/mWx5136gts2Z2e5izdoRCo46lPp5TMs9R15GTSsgg/XnZyxDWVqoVU3R9lWnccKpqwsJLvRoxbCjoJtZB7DSw=="], "@types/js-yaml": ["@types/js-yaml@4.0.9", "", {}, "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg=="], - "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.10", "", { "dependencies": { "@types/ms": "*", "@types/node": "*" } }, "sha512-asx5hIG9Qmf/1oStypjanR7iKTv0gXQ1Ov/jfrX6kS/EO0OFni8orbmGCn0672NHR3kXHwpAwR+B368ZGN/2rA=="], + "@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw=="], "@types/mdast": ["@types/mdast@4.0.4", "", { "dependencies": { "@types/unist": "*" } }, "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA=="], "@types/mdx": ["@types/mdx@2.0.13", "", {}, "sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw=="], - "@types/mime": ["@types/mime@1.3.5", "", {}, "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w=="], - "@types/mime-types": ["@types/mime-types@2.1.4", "", {}, "sha512-lfU4b34HOri+kAY5UheuFMWPDOI+OPceBSHZKp69gEyTL/mmJ4cnU6Y/rlme3UL3GyOn6Y42hyIEw0/q8sWx5w=="], "@types/ms": ["@types/ms@2.1.0", "", {}, "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="], @@ -1095,10 +1069,6 @@ "@types/pg-pool": ["@types/pg-pool@2.0.6", "", { "dependencies": { "@types/pg": "*" } }, "sha512-TaAUE5rq2VQYxab5Ts7WZhKNmuN78Q6PiFonTDdpbx8a1H0M1vhy3rhiMjl+e2iHmogyMw7jZF4FrE6eJUy5HQ=="], - "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], - - "@types/range-parser": ["@types/range-parser@1.2.7", "", {}, "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ=="], - "@types/request": ["@types/request@2.48.12", "", { "dependencies": { "@types/caseless": "*", "@types/node": "*", "@types/tough-cookie": "*", "form-data": "^2.5.0" } }, "sha512-G3sY+NpsA9jnwm0ixhAFQSJ3Q9JkpLZpJbI3GMv0mIAT0y3mRabYeINzal5WOChIiaTEGQYlHOKgkaM9EisWHw=="], "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="], @@ -1107,10 +1077,6 @@ "@types/sax": ["@types/sax@1.2.7", "", { "dependencies": { "@types/node": "*" } }, "sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A=="], - "@types/send": ["@types/send@0.17.5", "", { "dependencies": { "@types/mime": "^1", "@types/node": "*" } }, "sha512-z6F2D3cOStZvuk2SaP6YrwkNO65iTZcwA2ZkSABegdkAh/lf+Aa/YQndZVfmEXT5vgAp6zv06VQ3ejSVjAny4w=="], - - "@types/serve-static": ["@types/serve-static@1.15.8", "", { "dependencies": { "@types/http-errors": "*", "@types/node": "*", "@types/send": "*" } }, "sha512-roei0UY3LhpOJvjbIP6ZZFngyLKl5dskOtDhxY5THRSpO+ZI+nzJ+m5yUMzGrp89YRa7lvknKkMYjqQFGwA7Sg=="], - "@types/shimmer": ["@types/shimmer@1.2.0", "", {}, "sha512-UE7oxhQLLd9gub6JKIAhDq06T0F6FnztwMNRvYgjeQSBeMc1ZG/tA47EwfduvkuQS8apbkM/lpLpWsaCeYsXVg=="], "@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="], @@ -1193,8 +1159,6 @@ "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], - "atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="], - "aws-sign2": ["aws-sign2@0.7.0", "", {}, "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA=="], "aws4": ["aws4@1.13.2", "", {}, "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="], @@ -1523,7 +1487,7 @@ "esast-util-from-js": ["esast-util-from-js@2.0.1", "", { "dependencies": { "@types/estree-jsx": "^1.0.0", "acorn": "^8.0.0", "esast-util-from-estree": "^2.0.0", "vfile-message": "^4.0.0" } }, "sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw=="], - "esbuild": ["esbuild@0.24.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.24.2", "@esbuild/android-arm": "0.24.2", "@esbuild/android-arm64": "0.24.2", "@esbuild/android-x64": "0.24.2", "@esbuild/darwin-arm64": "0.24.2", "@esbuild/darwin-x64": "0.24.2", "@esbuild/freebsd-arm64": "0.24.2", "@esbuild/freebsd-x64": "0.24.2", "@esbuild/linux-arm": "0.24.2", "@esbuild/linux-arm64": "0.24.2", "@esbuild/linux-ia32": "0.24.2", "@esbuild/linux-loong64": "0.24.2", "@esbuild/linux-mips64el": "0.24.2", "@esbuild/linux-ppc64": "0.24.2", "@esbuild/linux-riscv64": "0.24.2", "@esbuild/linux-s390x": "0.24.2", "@esbuild/linux-x64": "0.24.2", "@esbuild/netbsd-arm64": "0.24.2", "@esbuild/netbsd-x64": "0.24.2", "@esbuild/openbsd-arm64": "0.24.2", "@esbuild/openbsd-x64": "0.24.2", "@esbuild/sunos-x64": "0.24.2", "@esbuild/win32-arm64": "0.24.2", "@esbuild/win32-ia32": "0.24.2", "@esbuild/win32-x64": "0.24.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-+9egpBW8I3CD5XPe0n6BfT5fxLzxrlDzqydF3aviG+9ni1lDC/OvMHcxqEFV0+LANZG5R1bFMWfUrjVsdwxJvA=="], + "esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], @@ -1673,6 +1637,8 @@ "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="], + "googleapis-common": ["googleapis-common@8.0.1", "", { "dependencies": { "extend": "^3.0.2", "gaxios": "^7.0.0-rc.4", "google-auth-library": "^10.1.0", "qs": "^6.7.0", "url-template": "^2.0.8" } }, "sha512-eCzNACUXPb1PW5l0ULTzMHaL/ltPRADoPgjBlT8jWsTbxkCp6siv+qKJ/1ldaybCthGwsYFYallF7u9AkU4L+A=="], + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], @@ -1823,7 +1789,7 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], @@ -1845,7 +1811,7 @@ "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], - "jose": ["jose@6.1.3", "", {}, "sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ=="], + "jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], "js-stringify": ["js-stringify@1.0.2", "", {}, "sha512-rtS5ATOo2Q5k1G+DADISilDA6lv79zIiwFd6CcjuIxGKLFm5C+RLImRscVap9k55i+MOZwgliw+NejvkLuGD5g=="], @@ -2193,8 +2159,6 @@ "oidc-token-hash": ["oidc-token-hash@5.1.1", "", {}, "sha512-D7EmwxJV6DsEB6vOFLrBM2OzsVgQzgPWyHlV2OOAVj772n+WTXpudC9e9u5BVKQnYwaD30Ivhi9b+4UeBcGu9g=="], - "on-exit-leak-free": ["on-exit-leak-free@2.1.2", "", {}, "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA=="], - "on-finished": ["on-finished@2.4.1", "", { "dependencies": { "ee-first": "1.1.1" } }, "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg=="], "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], @@ -2279,19 +2243,13 @@ "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "pino": ["pino@9.14.0", "", { "dependencies": { "@pinojs/redact": "^0.4.0", "atomic-sleep": "^1.0.0", "on-exit-leak-free": "^2.1.0", "pino-abstract-transport": "^2.0.0", "pino-std-serializers": "^7.0.0", "process-warning": "^5.0.0", "quick-format-unescaped": "^4.0.3", "real-require": "^0.2.0", "safe-stable-stringify": "^2.3.1", "sonic-boom": "^4.0.1", "thread-stream": "^3.0.0" }, "bin": { "pino": "bin.js" } }, "sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w=="], - - "pino-abstract-transport": ["pino-abstract-transport@2.0.0", "", { "dependencies": { "split2": "^4.0.0" } }, "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw=="], - - "pino-std-serializers": ["pino-std-serializers@7.0.0", "", {}, "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="], - "pkce-challenge": ["pkce-challenge@5.0.0", "", {}, "sha512-ueGLflrrnvwB3xuo/uGob5pd5FN7l0MsLf0Z87o/UQmRtwjvfylfc9MurIxRAWywCYTgrvpXBcqjV4OfCYGCIQ=="], "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], - "postcss-selector-parser": ["postcss-selector-parser@6.0.10", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-IQ7TZdoaqbT+LCpShg46jnZVlhWD2w6iQYAcYXfHARZ7X1t/UGhhceQDs5X0cGqKvYlHNOuv7Oa1xmb0oQuA3w=="], + "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], @@ -2309,8 +2267,6 @@ "prismjs": ["prismjs@1.30.0", "", {}, "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw=="], - "process-warning": ["process-warning@5.0.0", "", {}, "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA=="], - "promise": ["promise@7.3.1", "", { "dependencies": { "asap": "~2.0.3" } }, "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg=="], "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], @@ -2361,8 +2317,6 @@ "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], - "quick-format-unescaped": ["quick-format-unescaped@4.0.4", "", {}, "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg=="], - "radix3": ["radix3@1.1.2", "", {}, "sha512-b484I/7b8rDEdSDKckSSBA8knMpcdsXudlE/LNL639wFoHKwLbEkQFZHWEYwDC0wa0FKUcCY+GAF73Z7wxNVFA=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -2373,16 +2327,10 @@ "re2js": ["re2js@1.2.2", "", {}, "sha512-xvy4uuynAZWg9SuHbg0lgQncOuK6wssLmbHs8L8+YRbWLKY8Pe1avaHjNaFLOjErq8Oh0HvwQRWqIOCRL7uDDw=="], - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], - - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], - "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "readdirp": ["readdirp@5.0.0", "", {}, "sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ=="], - "real-require": ["real-require@0.2.0", "", {}, "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg=="], - "recma-build-jsx": ["recma-build-jsx@1.0.0", "", { "dependencies": { "@types/estree": "^1.0.0", "estree-util-build-jsx": "^3.0.0", "vfile": "^6.0.0" } }, "sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew=="], "recma-jsx": ["recma-jsx@1.0.1", "", { "dependencies": { "acorn-jsx": "^5.0.0", "estree-util-to-js": "^2.0.0", "recma-parse": "^1.0.0", "recma-stringify": "^1.0.0", "unified": "^11.0.0" }, "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-huSIy7VU2Z5OLv6oFLosQGGDqPqdO1iq6bWNAdhzMxSJP7RAso4fCZ1cKu8j9YHCZf3TPrq4dw3okhrylgcd7w=="], @@ -2485,8 +2433,6 @@ "sax": ["sax@1.5.0", "", {}, "sha512-21IYA3Q5cQf089Z6tgaUTr7lDAyzoTPx5HRtbhsME8Udispad8dC/+sziTNugOEx54ilvatQ9YCzl4KQLPcRHA=="], - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], "send": ["send@1.2.0", "", { "dependencies": { "debug": "^4.3.5", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.0", "mime-types": "^3.0.1", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.1" } }, "sha512-uaW0WwXKpL9blXE2o0bRhoL2EGXIrZxQ2ZQ4mgcfoBxdFmQold+qWsD2jLrfZ0trjKL6vOw0j//eAwcALFjKSw=="], @@ -2537,8 +2483,6 @@ "socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="], - "sonic-boom": ["sonic-boom@4.2.0", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww=="], - "source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], @@ -2549,8 +2493,6 @@ "split-ca": ["split-ca@1.0.1", "", {}, "sha512-Q5thBSxp5t8WPTTJQS59LrGqOZqOsrhDGDVm8azCqIBjSBd7nd9o2PM+mDulQQkh8h//4U6hFZnc/mul8t5pWQ=="], - "split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], - "sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], "sql.js": ["sql.js@1.14.1", "", {}, "sha512-gcj8zBWU5cFsi9WUP+4bFNXAyF1iRpA3LLyS/DP5xlrNzGmPIizUeBggKa8DbDwdqaKwUcTEnChtd2grWo/x/A=="], @@ -2623,8 +2565,6 @@ "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], - "thread-stream": ["thread-stream@3.1.0", "", { "dependencies": { "real-require": "^0.2.0" } }, "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A=="], - "tiny-inflate": ["tiny-inflate@1.0.3", "", {}, "sha512-pkY1fj1cKHb2seWDy0B16HeWyczlJA9/WW3u3c4z/NiWDsO3DOU5D7nhTLE9CF0yXv/QZFY7sEJmj24dK+Rrqw=="], "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], @@ -2717,6 +2657,8 @@ "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + "url-template": ["url-template@2.0.8", "", {}, "sha512-XdVKMF4SJ0nP/O7XIPB0JwAEuT9lDIYnNsK8yGVe43y0AWoKeJNdv3ZNWh7ksJ6KqQFjOO6ox/VEitLnaVNufw=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="], @@ -2807,6 +2749,8 @@ "@astrojs/markdown-remark/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], + "@astrojs/preact/@preact/signals": ["@preact/signals@2.8.1", "", { "dependencies": { "@preact/signals-core": "^1.13.0" }, "peerDependencies": { "preact": ">= 10.25.0 || >=11.0.0-0" } }, "sha512-wX6U0SpcCukZTJBs5ChljvBZb3XmYzA5gd4vKHgX8wZZKaQCo2WHtmThdLx+mcVvlBa5u3XShC7ffbETJD4BiQ=="], + "@astrojs/starlight/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], @@ -2821,6 +2765,10 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@chat-adapter/gchat/@chat-adapter/shared": ["@chat-adapter/shared@4.23.0", "", { "dependencies": { "chat": "4.23.0" } }, "sha512-IDHgrAi3Y8Qptl1kd8zmM6jxWX+lu0cq/uZiURv8jONufWHrVwVr19pN0YK52ASnRlLZkcaNYXoa+mLFGQ2tlw=="], + + "@chat-adapter/gchat/chat": ["chat@4.23.0", "", { "dependencies": { "@workflow/serde": "4.1.0-beta.2", "mdast-util-to-string": "^4.0.0", "remark-gfm": "^4.0.0", "remark-parse": "^11.0.0", "remark-stringify": "^11.0.0", "remend": "^1.2.1", "unified": "^11.0.5" } }, "sha512-Gmw8yyDrrH9Vxs+TfxF7FoTENrCzJV4T0FiAh7APGcQPhMMYAhrpq66PKj8azhkUSEyaen8ujbneogoJWiY8vg=="], + "@discordjs/builders/discord-api-types": ["discord-api-types@0.38.42", "", {}, "sha512-qs1kya7S84r5RR8m9kgttywGrmmoHaRifU1askAoi+wkoSefLpZP6aGXusjNw5b0jD3zOg3LTwUa3Tf2iHIceQ=="], "@discordjs/formatters/discord-api-types": ["discord-api-types@0.38.42", "", {}, "sha512-qs1kya7S84r5RR8m9kgttywGrmmoHaRifU1askAoi+wkoSefLpZP6aGXusjNw5b0jD3zOg3LTwUa3Tf2iHIceQ=="], @@ -2849,12 +2797,8 @@ "@lobu/gateway/commander": ["commander@14.0.1", "", {}, "sha512-2JkV3gUZUVrbNA+1sjBOYLsMZ5cEEl8GTFP2a4AVz5hvasAMCQ1D2l2le/cX+pV4N6ZU17zjUahLpIXRrnWL8A=="], - "@lobu/gateway/marked": ["marked@12.0.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-qXUm7e/YKFoqFPYPa3Ukg9xlI5cyAtGmyEIzMfW//m6kXwCy2Ps9DYf5ioijFKQ8qyuscrHoY04iJGctu2Kg0Q=="], - "@lobu/gateway/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "@lobu/landing/@preact/signals": ["@preact/signals@1.3.4", "", { "dependencies": { "@preact/signals-core": "^1.7.0" }, "peerDependencies": { "preact": "10.x" } }, "sha512-TPMkStdT0QpSc8FpB63aOwXoSiZyIrPsP9Uj347KopdS6olZdAYeeird/5FZv/M1Yc1ge5qstub2o8VDbvkT4g=="], - "@mariozechner/pi-ai/zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="], "@mariozechner/pi-coding-agent/marked": ["marked@15.0.12", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA=="], @@ -2909,8 +2853,6 @@ "@rollup/pluginutils/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], - "@scalar/astro/@scalar/core": ["@scalar/core@0.4.0", "", { "dependencies": { "@scalar/types": "0.7.0" } }, "sha512-Zcl+V8oBxb0S7vR+Nro8J53GD/w/kSjuyX0UoT3r1sn0bUM3Buf4Ob44n3CSfluQzxmiMuZxfOROHqa9F+ckbg=="], - "@scalar/types/type-fest": ["type-fest@5.4.3", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-AXSAQJu79WGc79/3e9/CR77I/KQgeY1AhNvcShIH4PTcGYyC4xv6H4R4AUOwkPS5799KlVDAu8zExeCrkGquiA=="], "@scalar/types/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], @@ -2941,16 +2883,12 @@ "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - "@types/body-parser/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], - "@types/connect/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], "@types/docker-modem/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], "@types/dockerode/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], - "@types/express-serve-static-core/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], - "@types/jsonwebtoken/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], "@types/mysql/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], @@ -2963,10 +2901,6 @@ "@types/sax/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], - "@types/send/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], - - "@types/serve-static/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], - "@types/ssh2/@types/node": ["@types/node@18.19.123", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-K7DIaHnh0mzVxreCR9qwgNxp3MH9dltPNIEddW9MYUlcKAzm+3grKNSTe2vCJHI1FaLpvpL5JGJrz1UZDKYvDg=="], "@types/tedious/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], @@ -2979,8 +2913,6 @@ "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], - "astro/esbuild": ["esbuild@0.27.3", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.3", "@esbuild/android-arm": "0.27.3", "@esbuild/android-arm64": "0.27.3", "@esbuild/android-x64": "0.27.3", "@esbuild/darwin-arm64": "0.27.3", "@esbuild/darwin-x64": "0.27.3", "@esbuild/freebsd-arm64": "0.27.3", "@esbuild/freebsd-x64": "0.27.3", "@esbuild/linux-arm": "0.27.3", "@esbuild/linux-arm64": "0.27.3", "@esbuild/linux-ia32": "0.27.3", "@esbuild/linux-loong64": "0.27.3", "@esbuild/linux-mips64el": "0.27.3", "@esbuild/linux-ppc64": "0.27.3", "@esbuild/linux-riscv64": "0.27.3", "@esbuild/linux-s390x": "0.27.3", "@esbuild/linux-x64": "0.27.3", "@esbuild/netbsd-arm64": "0.27.3", "@esbuild/netbsd-x64": "0.27.3", "@esbuild/openbsd-arm64": "0.27.3", "@esbuild/openbsd-x64": "0.27.3", "@esbuild/openharmony-arm64": "0.27.3", "@esbuild/sunos-x64": "0.27.3", "@esbuild/win32-arm64": "0.27.3", "@esbuild/win32-ia32": "0.27.3", "@esbuild/win32-x64": "0.27.3" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg=="], - "astro/js-yaml": ["js-yaml@4.1.1", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA=="], "babel-walk/@babel/types": ["@babel/types@7.28.5", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA=="], @@ -3003,8 +2935,6 @@ "botframework-connector/@azure/msal-node": ["@azure/msal-node@2.16.3", "", { "dependencies": { "@azure/msal-common": "14.16.1", "jsonwebtoken": "^9.0.0", "uuid": "^8.3.0" } }, "sha512-CO+SE4weOsfJf+C5LM8argzvotrXw252/ZU6SM2Tz63fEblhH1uuVaaO4ISYFuN4Q6BhTo7I3qIdi8ydUQCqhw=="], - "botframework-connector/@types/jsonwebtoken": ["@types/jsonwebtoken@9.0.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-/5hndP5dCjloafCXns6SZyESp3Ldq7YjH3zwzwczYnjxIT0Fqzk5ROSYVGfFyczIue7IUEj8hkvLbPoLQ18vQw=="], - "botframework-connector/axios": ["axios@1.12.2", "", { "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw=="], "botframework-schema/uuid": ["uuid@10.0.0", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ=="], @@ -3073,6 +3003,8 @@ "google-auth-library/jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], + "googleapis-common/qs": ["qs@6.14.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w=="], + "gtoken/jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="], "har-validator/ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], @@ -3089,8 +3021,6 @@ "jsonwebtoken/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - "jstransformer/is-promise": ["is-promise@2.2.2", "", {}, "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ=="], - "just-bash/minimatch": ["minimatch@10.1.2", "", { "dependencies": { "@isaacs/brace-expansion": "^5.0.1" } }, "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw=="], "knip/smol-toml": ["smol-toml@1.4.2", "", {}, "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g=="], @@ -3113,8 +3043,6 @@ "openapi3-ts/yaml": ["yaml@2.8.1", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-lcYcMxX2PO9XMGvAJkJ3OsNMw+/7FKes7/hgerGUYWIoWu5j/+YQqcZr5JnPZWzOsEBgMbSbiSTn/dv/69Mkpw=="], - "openid-client/jose": ["jose@4.15.9", "", {}, "sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA=="], - "ora/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], "ora/strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], @@ -3127,8 +3055,6 @@ "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - "postcss-nested/postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], - "proper-lockfile/retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="], "protobufjs/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], @@ -3153,6 +3079,8 @@ "router/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + "router/is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "send/debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "send/mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], @@ -3207,8 +3135,6 @@ "@prisma/instrumentation/@opentelemetry/instrumentation/semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], - "@scalar/astro/@scalar/core/@scalar/types": ["@scalar/types@0.7.0", "", { "dependencies": { "@scalar/helpers": "0.3.0", "nanoid": "^5.1.6", "type-fest": "^5.3.1", "zod": "^4.3.5" } }, "sha512-IkG62M4ztmqkYNVhLpcswBojlQctbXLdkDa3UFsY8FfT7yfZ2LppjptycW9tWjD09ZQb4QAZ070FAUHmFRIS7w=="], - "@slack/web-api/p-queue/eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], "@slack/web-api/p-queue/p-timeout": ["p-timeout@3.2.0", "", { "dependencies": { "p-finally": "^1.0.0" } }, "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg=="], @@ -3217,56 +3143,6 @@ "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "astro/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.3", "", { "os": "aix", "cpu": "ppc64" }, "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg=="], - - "astro/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.27.3", "", { "os": "android", "cpu": "arm" }, "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA=="], - - "astro/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.3", "", { "os": "android", "cpu": "arm64" }, "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg=="], - - "astro/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.27.3", "", { "os": "android", "cpu": "x64" }, "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ=="], - - "astro/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg=="], - - "astro/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg=="], - - "astro/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.3", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w=="], - - "astro/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.3", "", { "os": "freebsd", "cpu": "x64" }, "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA=="], - - "astro/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.3", "", { "os": "linux", "cpu": "arm" }, "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw=="], - - "astro/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg=="], - - "astro/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.3", "", { "os": "linux", "cpu": "ia32" }, "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg=="], - - "astro/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA=="], - - "astro/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw=="], - - "astro/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA=="], - - "astro/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.3", "", { "os": "linux", "cpu": "none" }, "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ=="], - - "astro/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw=="], - - "astro/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.3", "", { "os": "linux", "cpu": "x64" }, "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA=="], - - "astro/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.3", "", { "os": "none", "cpu": "arm64" }, "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA=="], - - "astro/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.3", "", { "os": "none", "cpu": "x64" }, "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA=="], - - "astro/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.3", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw=="], - - "astro/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.3", "", { "os": "openbsd", "cpu": "x64" }, "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ=="], - - "astro/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.3", "", { "os": "sunos", "cpu": "x64" }, "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA=="], - - "astro/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA=="], - - "astro/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.3", "", { "os": "win32", "cpu": "ia32" }, "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q=="], - - "astro/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.3", "", { "os": "win32", "cpu": "x64" }, "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA=="], - "botbuilder/@azure/msal-node/@azure/msal-common": ["@azure/msal-common@14.16.1", "", {}, "sha512-nyxsA6NA4SVKh5YyRpbSXiMr7oQbwark7JU9LMeg6tJYTSPyAGkdx61wPT4gyxZfxlSxMMEyAsWaubBlNyIa1w=="], "botbuilder/@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], @@ -3279,8 +3155,6 @@ "botframework-connector/@azure/msal-node/uuid": ["uuid@8.3.2", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg=="], - "botframework-connector/@types/jsonwebtoken/@types/node": ["@types/node@22.16.5", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-bJFoMATwIGaxxx8VJPeM8TonI8t579oRvgAuT8zFugJsJZgzqv0Fu8Mhp68iecjzG7cnN3mO2dJQ5uUM2EFrgQ=="], - "botframework-connector/axios/follow-redirects": ["follow-redirects@1.15.9", "", {}, "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ=="], "botframework-connector/axios/form-data": ["form-data@4.0.4", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.12" } }, "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow=="], @@ -3393,12 +3267,6 @@ "@grpc/proto-loader/yargs/cliui/wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], - "@scalar/astro/@scalar/core/@scalar/types/@scalar/helpers": ["@scalar/helpers@0.3.0", "", {}, "sha512-lhQdehgighJC+PiSTJbbggM/SM3UydcRQil6Cfp/M4l539qklIh35pt4eh1+H+5Esa03gHnJwhTHF3TwglSOJw=="], - - "@scalar/astro/@scalar/core/@scalar/types/type-fest": ["type-fest@5.4.3", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-AXSAQJu79WGc79/3e9/CR77I/KQgeY1AhNvcShIH4PTcGYyC4xv6H4R4AUOwkPS5799KlVDAu8zExeCrkGquiA=="], - - "@scalar/astro/@scalar/core/@scalar/types/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "inquirer/ora/cli-cursor/restore-cursor": ["restore-cursor@3.1.0", "", { "dependencies": { "onetime": "^5.1.0", "signal-exit": "^3.0.2" } }, "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA=="], "rimraf/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], diff --git a/charts/lobu/templates/gateway-deployment.yaml b/charts/lobu/templates/gateway-deployment.yaml index 39653661a..05efcca79 100644 --- a/charts/lobu/templates/gateway-deployment.yaml +++ b/charts/lobu/templates/gateway-deployment.yaml @@ -219,10 +219,10 @@ spec: value: "{{ .Values.gateway.anthropicProxy.baseUrl }}" {{- end }} - # External auth (Owletto OIDC) - {{- if .Values.gateway.config.authMcpUrl }} - - name: AUTH_MCP_URL - value: "{{ .Values.gateway.config.authMcpUrl }}" + # Owletto memory URL + {{- if .Values.gateway.config.memoryUrl }} + - name: MEMORY_URL + value: "{{ .Values.gateway.config.memoryUrl }}" {{- end }} # Admin password (direct value override, not in sealed secrets) diff --git a/charts/lobu/values-community.yaml b/charts/lobu/values-community.yaml index d39c3f1cf..28bed2982 100644 --- a/charts/lobu/values-community.yaml +++ b/charts/lobu/values-community.yaml @@ -130,7 +130,7 @@ gateway: config: nodeEnv: "production" logLevel: "INFO" - authMcpUrl: "https://owletto.com/mcp" + memoryUrl: "https://owletto.com/mcp" anthropicProxy: enabled: true baseUrl: "" diff --git a/charts/lobu/values.yaml b/charts/lobu/values.yaml index d74609e14..a7054f773 100644 --- a/charts/lobu/values.yaml +++ b/charts/lobu/values.yaml @@ -176,7 +176,7 @@ gateway: config: nodeEnv: "development" logLevel: "DEBUG" - authMcpUrl: "" # Optional: Owletto MCP URL for OAuth login, memory, and integrations (e.g., https://owletto.com/mcp) + memoryUrl: "" # Optional: Owletto MCP URL for memory and integrations (e.g., https://owletto.com/mcp) # Anthropic API proxy configuration anthropicProxy: enabled: true # Set to true to enable the proxy diff --git a/config/dependency-cruiser.config.cjs b/config/dependency-cruiser.config.cjs deleted file mode 100644 index 736c1219b..000000000 --- a/config/dependency-cruiser.config.cjs +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Dependency-cruiser configuration to keep our workspace boundaries honest. - * See https://github.com/sverweij/dependency-cruiser for details. - * @type {import('dependency-cruiser').IConfiguration} - */ -module.exports = { - extends: ["dependency-cruiser/configs/recommended-warn-only"], - forbidden: [ - { - name: "core-must-stay-isolated", - comment: - "Core is the shared foundation; it must not depend on package-specific code.", - severity: "error", - from: { - path: "^packages/core/src", - }, - to: { - path: "^packages/(gateway|worker|github)/src", - }, - }, - { - name: "worker-must-not-know-platforms", - comment: - "Worker stays platform-agnostic and should only rely on @lobu/core.", - severity: "error", - from: { - path: "^packages/worker/src", - }, - to: { - path: "^packages/(gateway|github)/src", - }, - }, - { - name: "gateway-must-not-import-worker", - comment: - "Gateway keeps platform adapters separate; shared logic lives in @lobu/core.", - severity: "error", - from: { - path: "^packages/gateway/src", - }, - to: { - path: "^packages/worker/src", - }, - }, - ], - options: { - tsConfig: { - fileName: "./tsconfig.json", - }, - tsPreCompilationDeps: true, - doNotFollow: { - path: "node_modules", - }, - exclude: { - path: "node_modules|/dist/|__tests__|__mocks__|\\.(spec|test)\\.(ts|tsx)$|/docs/|integration-tests|examples|workspaces|charts|bin", - }, - enhancedResolveOptions: { - extensions: [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json"], - conditionNames: ["import", "default"], - exportsFields: ["exports"], - }, - }, -}; diff --git a/config/knip.ts b/config/knip.ts deleted file mode 100644 index c5df728c9..000000000 --- a/config/knip.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { KnipConfig } from "knip"; - -const config: KnipConfig = { - entry: [ - "packages/*/src/**/*.{ts,tsx,js,jsx,cjs,mjs}", - "packages/*/bin/**/*.{js,cjs,mjs}", - ], - ignore: [ - "**/dist/**", - "charts/**", - "integration-tests/**", - "config/dependency-cruiser.config.cjs", - "workspaces/**", - "my-app/**", - "docker/docker-compose*.yml", - "scripts/**", - ], - ignoreBinaries: ["helm"], -}; - -export default config; diff --git a/config/skill-registries.json b/config/skill-registries.json deleted file mode 100644 index fba1095d9..000000000 --- a/config/skill-registries.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "registries": [ - { - "id": "lobu", - "type": "lobu", - "apiUrl": "config/system-skills.json" - }, - { - "id": "clawhub", - "type": "clawhub", - "apiUrl": "https://wry-manatee-359.convex.site/api/v1" - } - ] -} diff --git a/config/system-skills.json b/config/system-skills.json index 9ef4fd9fd..03132be1b 100644 --- a/config/system-skills.json +++ b/config/system-skills.json @@ -10,7 +10,7 @@ { "id": "owletto", "name": "Owletto", - "url": "${env:AUTH_MCP_URL}", + "url": "${env:MEMORY_URL}", "type": "sse" } ] diff --git a/docker/Dockerfile.gateway b/docker/Dockerfile.gateway index 205b24ecb..a9f1a19c4 100644 --- a/docker/Dockerfile.gateway +++ b/docker/Dockerfile.gateway @@ -5,7 +5,7 @@ FROM node:20-alpine RUN apk add --no-cache curl bash docker-cli xz shadow gcompat coreutils && \ curl -fsSL https://bun.sh/install | BUN_INSTALL=/usr/local bash && \ chmod +x /usr/local/bin/bun -ENV PATH="/usr/local/bin:${PATH}" +ENV PATH="/app/node_modules/.bin:/app/packages/worker/node_modules/.bin:/usr/local/bin:${PATH}" # Install Nix (single-user mode) for embedded deployment mode nix-shell support RUN adduser -D -u 1001 nixuser && mkdir -p /nix && chown nixuser:nixuser /nix @@ -43,20 +43,14 @@ COPY packages/gateway/ ./packages/gateway/ COPY packages/worker/ ./packages/worker/ # Copy CLI's mcp-servers.json (referenced by gateway's mcp-registry) COPY packages/cli/src/mcp-servers.json ./packages/cli/src/mcp-servers.json -# Copy system skills registry (provider catalog, integrations, MCP configs) +# Copy system skills config (provider catalog, integrations, MCP configs) COPY config/system-skills.json ./config/system-skills.json -COPY config/skill-registries.json ./config/skill-registries.json # Build core package dist (used by Node.js in production, Bun uses src/ via conditional exports) WORKDIR /app/packages/core RUN bunx tsc -# Generate Tailwind CSS and page bundles WORKDIR /app/packages/gateway -RUN bun run generate:css && \ - bun run scripts/build-history.ts && \ - bun run scripts/build-agent.ts && \ - bun run scripts/build-agents.ts WORKDIR /app @@ -69,4 +63,4 @@ RUN chmod -R a+rX /app # Run with bun for TypeScript support and hot reload in dev ENTRYPOINT [] -CMD ["sh", "-lc", "cd /app/packages/gateway && bun run generate:css >/dev/null 2>&1 && bun run scripts/build-history.ts >/dev/null 2>&1 && bun run scripts/build-agent.ts >/dev/null 2>&1 && bun run scripts/build-agents.ts >/dev/null 2>&1 && cd /app && exec bun --watch packages/gateway/src/index.ts"] +CMD ["sh", "-lc", "exec bun --watch /app/packages/gateway/src/index.ts"] diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 9bd4d9f9e..7fc138bc7 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -41,6 +41,8 @@ services: QUEUE_URL: redis://redis:6379 # Host project path for worker bind mounts (Docker-in-Docker) LOBU_DEV_PROJECT_PATH: ${PWD} + # Read-only workspace mount used for file-first agent config loading + LOBU_WORKSPACE_ROOT: /workspace/project env_file: - ../.env volumes: @@ -48,8 +50,10 @@ services: - /var/run/docker.sock:/var/run/docker.sock # Mount .env so gateway can load it via dotenv (avoids ARG_MAX with large vars) - ../.env:/app/.env:ro - # Mount .lobu/ for agents manifest and config + # Mount .lobu/ for local CLI session state - ../.lobu:/app/.lobu:ro + # Mount workspace root so the gateway can read lobu.toml directly + - ..:/workspace/project:ro # Mount integrations config for live editing - ../config:/app/config:ro # Mount source for hot reload (bun --watch detects changes) diff --git a/examples/hr-assistant/README.md b/examples/hr-assistant/README.md index 35125b72c..4f7c1f3e3 100644 --- a/examples/hr-assistant/README.md +++ b/examples/hr-assistant/README.md @@ -8,7 +8,7 @@ Make sure you have Docker CLI installed. ```bash # Start the services -lobu dev -d +lobu run -d # View logs docker compose logs -f diff --git a/examples/hr-assistant/TESTING.md b/examples/hr-assistant/TESTING.md index 383ebaa0c..4d9ea2886 100644 --- a/examples/hr-assistant/TESTING.md +++ b/examples/hr-assistant/TESTING.md @@ -8,7 +8,7 @@ Send messages to your bot with optional file uploads. ### Endpoint ``` -POST http://localhost:8081/api/messaging/send +POST http://localhost:8081/api/v1/agents/{agentId}/messages ``` ### Authentication @@ -24,7 +24,7 @@ The bot token must be provided in the `Authorization` header, not in the request #### JSON Request (Simple Message) ```bash -curl -X POST http://localhost:8081/api/messaging/send \ +curl -X POST http://localhost:8081/api/v1/agents/{agentId}/messages \ -H "Authorization: Bearer xoxb-your-bot-token" \ -H "Content-Type: application/json" \ -d '{ @@ -36,7 +36,7 @@ curl -X POST http://localhost:8081/api/messaging/send \ #### Multipart Request (With File Upload) ```bash -curl -X POST http://localhost:8081/api/messaging/send \ +curl -X POST http://localhost:8081/api/v1/agents/{agentId}/messages \ -H "Authorization: Bearer xoxb-your-bot-token" \ -F "platform=slack" \ -F "channel=C12345678" \ @@ -90,7 +90,7 @@ If you don't want to mention the bot, simply omit `@me` from your message. ### Example: Simple Text Message (with @me) ```bash -curl -X POST http://localhost:8081/api/messaging/send \ +curl -X POST http://localhost:8081/api/v1/agents/{agentId}/messages \ -H "Authorization: Bearer xoxb-your-bot-token" \ -H "Content-Type: application/json" \ -d '{ @@ -103,7 +103,7 @@ curl -X POST http://localhost:8081/api/messaging/send \ ### Example: Without Bot Mention ```bash -curl -X POST http://localhost:8081/api/messaging/send \ +curl -X POST http://localhost:8081/api/v1/agents/{agentId}/messages \ -H "Authorization: Bearer xoxb-your-bot-token" \ -H "Content-Type: application/json" \ -d '{ @@ -116,7 +116,7 @@ curl -X POST http://localhost:8081/api/messaging/send \ ### Example: Thread Reply ```bash -curl -X POST http://localhost:8081/api/messaging/send \ +curl -X POST http://localhost:8081/api/v1/agents/{agentId}/messages \ -H "Authorization: Bearer xoxb-your-bot-token" \ -H "Content-Type: application/json" \ -d '{ @@ -130,7 +130,7 @@ curl -X POST http://localhost:8081/api/messaging/send \ ### Example: Single File Upload ```bash -curl -X POST http://localhost:8081/api/messaging/send \ +curl -X POST http://localhost:8081/api/v1/agents/{agentId}/messages \ -H "Authorization: Bearer xoxb-your-bot-token" \ -F "platform=slack" \ -F "channel=dev-channel" \ @@ -141,7 +141,7 @@ curl -X POST http://localhost:8081/api/messaging/send \ ### Example: Multiple File Upload ```bash -curl -X POST http://localhost:8081/api/messaging/send \ +curl -X POST http://localhost:8081/api/v1/agents/{agentId}/messages \ -H "Authorization: Bearer xoxb-your-bot-token" \ -F "platform=slack" \ -F "channel=dev-channel" \ @@ -187,7 +187,7 @@ Testing a full conversation: ```bash # Step 1: Send initial message -RESPONSE=$(curl -s -X POST http://localhost:8081/api/messaging/send \ +RESPONSE=$(curl -s -X POST http://localhost:8081/api/v1/agents/{agentId}/messages \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ diff --git a/examples/hr-assistant/docker-compose.yml b/examples/hr-assistant/docker-compose.yml index 811b9ec92..ce22224e8 100644 --- a/examples/hr-assistant/docker-compose.yml +++ b/examples/hr-assistant/docker-compose.yml @@ -35,8 +35,7 @@ services: ENCRYPTION_KEY: ${ENCRYPTION_KEY} WORKER_ALLOWED_DOMAINS: ${WORKER_ALLOWED_DOMAINS:-} WORKER_DISALLOWED_DOMAINS: ${WORKER_DISALLOWED_DOMAINS:-} - AUTH_MCP_URL: ${AUTH_MCP_URL:-} - MEMORY_PLUGIN: ${MEMORY_PLUGIN:-owletto} + MEMORY_URL: ${MEMORY_URL:-} volumes: - ./.lobu:/app/.lobu networks: diff --git a/landing/.gitignore b/landing/.gitignore deleted file mode 100644 index f06235c46..000000000 --- a/landing/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -dist diff --git a/landing/bun.lock b/landing/bun.lock deleted file mode 100644 index 8f82a9af5..000000000 --- a/landing/bun.lock +++ /dev/null @@ -1,270 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "lobu-demo", - "dependencies": { - "framer-motion": "^11.15.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", - }, - "devDependencies": { - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.4", - "typescript": "^5.6.3", - "vite": "^6.0.0", - }, - }, - }, - "packages": { - "@babel/code-frame": ["@babel/code-frame@7.29.0", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.28.5", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw=="], - - "@babel/compat-data": ["@babel/compat-data@7.29.0", "", {}, "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg=="], - - "@babel/core": ["@babel/core@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-compilation-targets": "^7.28.6", "@babel/helper-module-transforms": "^7.28.6", "@babel/helpers": "^7.28.6", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/traverse": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA=="], - - "@babel/generator": ["@babel/generator@7.29.1", "", { "dependencies": { "@babel/parser": "^7.29.0", "@babel/types": "^7.29.0", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw=="], - - "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.28.6", "", { "dependencies": { "@babel/compat-data": "^7.28.6", "@babel/helper-validator-option": "^7.27.1", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA=="], - - "@babel/helper-globals": ["@babel/helper-globals@7.28.0", "", {}, "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw=="], - - "@babel/helper-module-imports": ["@babel/helper-module-imports@7.28.6", "", { "dependencies": { "@babel/traverse": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw=="], - - "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.28.6", "", { "dependencies": { "@babel/helper-module-imports": "^7.28.6", "@babel/helper-validator-identifier": "^7.28.5", "@babel/traverse": "^7.28.6" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA=="], - - "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.28.6", "", {}, "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug=="], - - "@babel/helper-string-parser": ["@babel/helper-string-parser@7.27.1", "", {}, "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA=="], - - "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], - - "@babel/helper-validator-option": ["@babel/helper-validator-option@7.27.1", "", {}, "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg=="], - - "@babel/helpers": ["@babel/helpers@7.28.6", "", { "dependencies": { "@babel/template": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw=="], - - "@babel/parser": ["@babel/parser@7.29.0", "", { "dependencies": { "@babel/types": "^7.29.0" }, "bin": "./bin/babel-parser.js" }, "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww=="], - - "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw=="], - - "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw=="], - - "@babel/template": ["@babel/template@7.28.6", "", { "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/parser": "^7.28.6", "@babel/types": "^7.28.6" } }, "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ=="], - - "@babel/traverse": ["@babel/traverse@7.29.0", "", { "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", "@babel/helper-globals": "^7.28.0", "@babel/parser": "^7.29.0", "@babel/template": "^7.28.6", "@babel/types": "^7.29.0", "debug": "^4.3.1" } }, "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA=="], - - "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], - - "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="], - - "@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="], - - "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="], - - "@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="], - - "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="], - - "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="], - - "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="], - - "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="], - - "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="], - - "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="], - - "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="], - - "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="], - - "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="], - - "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="], - - "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="], - - "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="], - - "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="], - - "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="], - - "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="], - - "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="], - - "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="], - - "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="], - - "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="], - - "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="], - - "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="], - - "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="], - - "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], - - "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], - - "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], - - "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], - - "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], - - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.57.1", "", { "os": "android", "cpu": "arm" }, "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg=="], - - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.57.1", "", { "os": "android", "cpu": "arm64" }, "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w=="], - - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.57.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg=="], - - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.57.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w=="], - - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.57.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug=="], - - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.57.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q=="], - - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw=="], - - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.57.1", "", { "os": "linux", "cpu": "arm" }, "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw=="], - - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g=="], - - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.57.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q=="], - - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA=="], - - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw=="], - - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w=="], - - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.57.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw=="], - - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A=="], - - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.57.1", "", { "os": "linux", "cpu": "none" }, "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw=="], - - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.57.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg=="], - - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg=="], - - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.57.1", "", { "os": "linux", "cpu": "x64" }, "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw=="], - - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.57.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw=="], - - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.57.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ=="], - - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.57.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ=="], - - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.57.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew=="], - - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ=="], - - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.57.1", "", { "os": "win32", "cpu": "x64" }, "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA=="], - - "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], - - "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], - - "@types/babel__template": ["@types/babel__template@7.4.4", "", { "dependencies": { "@babel/parser": "^7.1.0", "@babel/types": "^7.0.0" } }, "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A=="], - - "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], - - "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], - - "@types/prop-types": ["@types/prop-types@15.7.15", "", {}, "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw=="], - - "@types/react": ["@types/react@18.3.28", "", { "dependencies": { "@types/prop-types": "*", "csstype": "^3.2.2" } }, "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw=="], - - "@types/react-dom": ["@types/react-dom@18.3.7", "", { "peerDependencies": { "@types/react": "^18.0.0" } }, "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ=="], - - "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], - - "baseline-browser-mapping": ["baseline-browser-mapping@2.9.19", "", { "bin": { "baseline-browser-mapping": "dist/cli.js" } }, "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg=="], - - "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], - - "caniuse-lite": ["caniuse-lite@1.0.30001770", "", {}, "sha512-x/2CLQ1jHENRbHg5PSId2sXq1CIO1CISvwWAj027ltMVG2UNgW+w9oH2+HzgEIRFembL8bUlXtfbBHR1fCg2xw=="], - - "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], - - "electron-to-chromium": ["electron-to-chromium@1.5.286", "", {}, "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A=="], - - "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="], - - "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], - - "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], - - "framer-motion": ["framer-motion@11.18.2", "", { "dependencies": { "motion-dom": "^11.18.1", "motion-utils": "^11.18.1", "tslib": "^2.4.0" }, "peerDependencies": { "@emotion/is-prop-valid": "*", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@emotion/is-prop-valid", "react", "react-dom"] }, "sha512-5F5Och7wrvtLVElIpclDT0CBzMVg3dL22B64aZwHtsIY8RB4mXICLrkajK4G9R+ieSAGcgrLeae2SeUTg2pr6w=="], - - "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], - - "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], - - "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], - - "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], - - "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - - "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], - - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - - "motion-dom": ["motion-dom@11.18.1", "", { "dependencies": { "motion-utils": "^11.18.1" } }, "sha512-g76KvA001z+atjfxczdRtw/RXOM3OMSdd1f4DL77qCTF/+avrRJiawSG4yDibEQ215sr9kpinSlX2pCTJ9zbhw=="], - - "motion-utils": ["motion-utils@11.18.1", "", {}, "sha512-49Kt+HKjtbJKLtgO/LKj9Ld+6vw9BjH5d9sc40R/kVyH8GLAXgT42M2NnuPcJNuA3s9ZfZBUcwIgpmZWGEE+hA=="], - - "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - - "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], - - "node-releases": ["node-releases@2.0.27", "", {}, "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA=="], - - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - - "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], - - "react": ["react@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ=="], - - "react-dom": ["react-dom@18.3.1", "", { "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" }, "peerDependencies": { "react": "^18.3.1" } }, "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw=="], - - "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], - - "rollup": ["rollup@4.57.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.57.1", "@rollup/rollup-android-arm64": "4.57.1", "@rollup/rollup-darwin-arm64": "4.57.1", "@rollup/rollup-darwin-x64": "4.57.1", "@rollup/rollup-freebsd-arm64": "4.57.1", "@rollup/rollup-freebsd-x64": "4.57.1", "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", "@rollup/rollup-linux-arm-musleabihf": "4.57.1", "@rollup/rollup-linux-arm64-gnu": "4.57.1", "@rollup/rollup-linux-arm64-musl": "4.57.1", "@rollup/rollup-linux-loong64-gnu": "4.57.1", "@rollup/rollup-linux-loong64-musl": "4.57.1", "@rollup/rollup-linux-ppc64-gnu": "4.57.1", "@rollup/rollup-linux-ppc64-musl": "4.57.1", "@rollup/rollup-linux-riscv64-gnu": "4.57.1", "@rollup/rollup-linux-riscv64-musl": "4.57.1", "@rollup/rollup-linux-s390x-gnu": "4.57.1", "@rollup/rollup-linux-x64-gnu": "4.57.1", "@rollup/rollup-linux-x64-musl": "4.57.1", "@rollup/rollup-openbsd-x64": "4.57.1", "@rollup/rollup-openharmony-arm64": "4.57.1", "@rollup/rollup-win32-arm64-msvc": "4.57.1", "@rollup/rollup-win32-ia32-msvc": "4.57.1", "@rollup/rollup-win32-x64-gnu": "4.57.1", "@rollup/rollup-win32-x64-msvc": "4.57.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A=="], - - "scheduler": ["scheduler@0.23.2", "", { "dependencies": { "loose-envify": "^1.1.0" } }, "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ=="], - - "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - - "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], - - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], - - "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="], - - "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], - - "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - } -} diff --git a/landing/index.html b/landing/index.html deleted file mode 100644 index 30a6a2ed3..000000000 --- a/landing/index.html +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - Lobu - How It Works - - - - - -
- - - diff --git a/landing/package.json b/landing/package.json deleted file mode 100644 index 3f06cdda1..000000000 --- a/landing/package.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "lobu-landing", - "private": true, - "version": "0.0.1", - "type": "module", - "scripts": { - "dev": "vite", - "build": "tsc && vite build", - "preview": "vite preview" - }, - "dependencies": { - "framer-motion": "^11.15.0", - "react": "^18.3.1", - "react-dom": "^18.3.1" - }, - "devDependencies": { - "@types/react": "^18.3.12", - "@types/react-dom": "^18.3.1", - "@vitejs/plugin-react": "^4.3.4", - "typescript": "^5.6.3", - "vite": "^6.0.0" - } -} diff --git a/landing/src/App.tsx b/landing/src/App.tsx deleted file mode 100644 index c74dc4064..000000000 --- a/landing/src/App.tsx +++ /dev/null @@ -1,199 +0,0 @@ -import type React from "react"; -import { useCallback, useEffect, useRef, useState } from "react"; -import { Diagram } from "./components/Diagram"; -import { PromptSwitcher } from "./components/PromptSwitcher"; -import { SlackPanel } from "./components/SlackPanel"; -import { StepIndicator } from "./components/StepIndicator"; -import { buildSteps, PROMPT_OPTIONS } from "./steps"; -import { colors, layout } from "./styles"; - -const globalStyles = ` - * { margin: 0; padding: 0; box-sizing: border-box; } - html, body, #root { height: 100%; } - body { background: ${colors.bg}; color: ${colors.text}; } - ::-webkit-scrollbar { width: 6px; } - ::-webkit-scrollbar-track { background: transparent; } - ::-webkit-scrollbar-thumb { background: ${colors.border}; border-radius: 3px; } - ::-webkit-scrollbar-thumb:hover { background: ${colors.borderLight}; } -`; - -const App: React.FC = () => { - const [selectedPrompt, setSelectedPrompt] = useState(PROMPT_OPTIONS[0]); - const [currentStepIndex, setCurrentStepIndex] = useState(0); - const [isPlaying, setIsPlaying] = useState(true); - const steps = buildSteps(selectedPrompt); - const timerRef = useRef | null>(null); - - const clearTimer = useCallback(() => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - }, []); - - // Auto-play timer - useEffect(() => { - clearTimer(); - - if (!isPlaying) return; - - const currentStep = steps[currentStepIndex]; - timerRef.current = setTimeout(() => { - setCurrentStepIndex((prev) => { - if (prev >= steps.length - 1) { - // Loop back to start - return 0; - } - return prev + 1; - }); - }, currentStep.duration); - - return clearTimer; - }, [isPlaying, currentStepIndex, steps, clearTimer]); - - const handleStepClick = useCallback( - (index: number) => { - clearTimer(); - setCurrentStepIndex(index); - setIsPlaying(false); - }, - [clearTimer] - ); - - const handleTogglePlay = useCallback(() => { - setIsPlaying((prev) => !prev); - }, []); - - const handleReset = useCallback(() => { - clearTimer(); - setCurrentStepIndex(0); - setIsPlaying(true); - }, [clearTimer]); - - const handlePromptChange = useCallback( - (prompt: typeof selectedPrompt) => { - clearTimer(); - setSelectedPrompt(prompt); - setCurrentStepIndex(0); - setIsPlaying(true); - }, - [clearTimer] - ); - - const currentStep = steps[currentStepIndex]; - - return ( - <> - -
- {/* Header */} -
-
-
- L -
-
-
- How Lobu Works -
-
- Message processing flow -
-
-
- - -
- - {/* Main content */} -
- {/* Diagram area */} -
- {/* Step description overlay */} -
-
-
- Step {currentStepIndex + 1}: {currentStep.title} -
-
- {currentStep.description} -
-
-
- - {/* SVG Diagram */} -
- -
-
- - {/* Slack chat panel */} -
- -
-
- - {/* Bottom control bar */} -
- -
-
- - ); -}; - -export default App; diff --git a/landing/src/components/AnimatedPacket.tsx b/landing/src/components/AnimatedPacket.tsx deleted file mode 100644 index 2d19a4d30..000000000 --- a/landing/src/components/AnimatedPacket.tsx +++ /dev/null @@ -1,119 +0,0 @@ -import { motion } from "framer-motion"; -import type React from "react"; -import { NODES } from "../nodes"; - -interface AnimatedPacketProps { - from: string; - to: string; - color: string; - label?: string; -} - -export const AnimatedPacket: React.FC = ({ - from, - to, - color, - label, -}) => { - const fromNode = NODES.find((n) => n.id === from); - const toNode = NODES.find((n) => n.id === to); - if (!fromNode || !toNode) return null; - - // If from and to are not directly connected, route through gateway - const isDirectConnection = from === "gateway" || to === "gateway"; - - // For indirect routes (e.g., mcp → sandbox), we go through gateway - let waypoints: Array<{ x: number; y: number }>; - - if (isDirectConnection) { - waypoints = [ - { x: fromNode.x, y: fromNode.y }, - { x: toNode.x, y: toNode.y }, - ]; - } else { - const gateway = NODES.find((n) => n.id === "gateway")!; - waypoints = [ - { x: fromNode.x, y: fromNode.y }, - { x: gateway.x, y: gateway.y }, - { x: toNode.x, y: toNode.y }, - ]; - } - - const numWaypoints = waypoints.length; - const duration = 0.8 * (numWaypoints - 1); - - // Compute midpoint for label positioning - const midIdx = Math.floor(numWaypoints / 2); - const midX = waypoints[midIdx].x; - const midY = waypoints[midIdx].y; - - return ( - - {/* Animated dot traveling the path */} - w.x), - cy: waypoints.map((w) => w.y), - }} - transition={{ duration, ease: "easeInOut" }} - /> - - {/* Fade in/out overlay */} - w.x), - cy: waypoints.map((w) => w.y), - opacity: [0, 1, 0], - }} - transition={{ - duration, - ease: "easeInOut", - opacity: { duration, times: [0, 0.15, 1] }, - }} - /> - - {/* Trail effect */} - w.x), - cy: waypoints.map((w) => w.y), - opacity: [0, 0.4, 0], - }} - transition={{ - duration, - ease: "easeInOut", - delay: 0.08, - opacity: { duration, times: [0, 0.2, 1] }, - }} - /> - - {/* Label at midpoint */} - {label && ( - - {label} - - )} - - ); -}; diff --git a/landing/src/components/ConnectionLine.tsx b/landing/src/components/ConnectionLine.tsx deleted file mode 100644 index 1cc5775a1..000000000 --- a/landing/src/components/ConnectionLine.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import type React from "react"; -import { NODES } from "../nodes"; -import { colors } from "../styles"; - -interface ConnectionLineProps { - from: string; - to: string; - isActive: boolean; -} - -export const ConnectionLine: React.FC = ({ - from, - to, - isActive, -}) => { - const fromNode = NODES.find((n) => n.id === from); - const toNode = NODES.find((n) => n.id === to); - if (!fromNode || !toNode) return null; - - return ( - - ); -}; diff --git a/landing/src/components/Diagram.tsx b/landing/src/components/Diagram.tsx deleted file mode 100644 index d91a24acd..000000000 --- a/landing/src/components/Diagram.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { AnimatePresence } from "framer-motion"; -import type React from "react"; -import { CONNECTIONS, NODES } from "../nodes"; -import type { FlowStep, PromptOption } from "../types"; -import { AnimatedPacket } from "./AnimatedPacket"; -import { ConnectionLine } from "./ConnectionLine"; -import { DiagramNode } from "./DiagramNode"; - -interface DiagramProps { - currentStep: FlowStep; - prompt: PromptOption; -} - -export const Diagram: React.FC = ({ currentStep, prompt }) => { - const activeNodes = new Set(currentStep.activeNodes); - - // Determine which connections are active - const activeConnections = new Set(); - if (currentStep.packet) { - const { from, to } = currentStep.packet; - // Mark direct connection or connections through gateway - for (const conn of CONNECTIONS) { - if ( - (conn.from === from && conn.to === to) || - (conn.from === to && conn.to === from) || - // Indirect: from → gateway → to - (conn.from === from && conn.to === "gateway") || - (conn.from === "gateway" && conn.to === to) || - (conn.to === from && conn.from === "gateway") || - (conn.to === "gateway" && conn.from === to) - ) { - activeConnections.add(`${conn.from}-${conn.to}`); - } - } - } - - // Update MCP label dynamically based on selected prompt - const nodesWithDynamicLabels = NODES.map((n) => { - if (n.id === "mcp") { - return { ...n, label: prompt.mcpTarget, sublabel: prompt.mcpDomain }; - } - return n; - }); - - return ( - - ); -}; diff --git a/landing/src/components/DiagramNode.tsx b/landing/src/components/DiagramNode.tsx deleted file mode 100644 index f9e802efa..000000000 --- a/landing/src/components/DiagramNode.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { motion } from "framer-motion"; -import type React from "react"; -import { colors } from "../styles"; -import type { NodePosition } from "../types"; -import { NodeIcon } from "./NodeIcon"; - -interface DiagramNodeProps { - node: NodePosition; - isActive: boolean; - callout?: { - text: string; - type: "info" | "security" | "warning"; - }; -} - -const NODE_WIDTH = 140; -const NODE_HEIGHT = 80; - -const calloutColors = { - info: { bg: colors.accentDim, border: colors.accent, text: colors.accent }, - security: { - bg: colors.greenDim, - border: colors.green, - text: colors.green, - }, - warning: { - bg: colors.yellowDim, - border: colors.yellow, - text: colors.yellow, - }, -}; - -export const DiagramNode: React.FC = ({ - node, - isActive, - callout, -}) => { - const nodeX = node.x - NODE_WIDTH / 2; - const nodeY = node.y - NODE_HEIGHT / 2; - - return ( - - {/* Node box */} - - - - {/* Icon */} - -
- -
-
- - {/* Label */} - - {node.label} - - - {/* Sublabel */} - {node.sublabel && ( - - {node.sublabel} - - )} -
- - {/* Callout bubble */} - {callout && ( - - - - {callout.type === "security" && "🔒 "} - {callout.type === "warning" && "⚠️ "} - {callout.text} - - - - )} -
- ); -}; diff --git a/landing/src/components/NodeIcon.tsx b/landing/src/components/NodeIcon.tsx deleted file mode 100644 index 0305ba077..000000000 --- a/landing/src/components/NodeIcon.tsx +++ /dev/null @@ -1,196 +0,0 @@ -import type React from "react"; - -interface NodeIconProps { - type: string; - size?: number; -} - -export const NodeIcon: React.FC = ({ type, size = 28 }) => { - const s = size; - const color = "currentColor"; - - switch (type) { - case "slack": - return ( - - ); - - case "gateway": - return ( - - ); - - case "sandbox": - return ( - - ); - - case "mcp": - return ( - - ); - - case "llm": - return ( - - ); - - default: - return ( - - ); - } -}; diff --git a/landing/src/components/PromptSwitcher.tsx b/landing/src/components/PromptSwitcher.tsx deleted file mode 100644 index cc6c7fd70..000000000 --- a/landing/src/components/PromptSwitcher.tsx +++ /dev/null @@ -1,47 +0,0 @@ -import { motion } from "framer-motion"; -import type React from "react"; -import { colors } from "../styles"; -import type { PromptOption } from "../types"; - -interface PromptSwitcherProps { - options: PromptOption[]; - selected: PromptOption; - onSelect: (option: PromptOption) => void; -} - -export const PromptSwitcher: React.FC = ({ - options, - selected, - onSelect, -}) => { - return ( -
- {options.map((option) => { - const isSelected = option.label === selected.label; - return ( - onSelect(option)} - style={{ - padding: "6px 14px", - borderRadius: 20, - border: `1px solid ${isSelected ? colors.accent : colors.border}`, - background: isSelected ? colors.accentDim : "transparent", - color: isSelected ? colors.accent : colors.textSecondary, - fontSize: 12, - fontWeight: 500, - cursor: "pointer", - whiteSpace: "nowrap", - fontFamily: "Inter, sans-serif", - transition: "all 0.2s", - }} - > - {option.label} - - ); - })} -
- ); -}; diff --git a/landing/src/components/SlackPanel.tsx b/landing/src/components/SlackPanel.tsx deleted file mode 100644 index 949a48455..000000000 --- a/landing/src/components/SlackPanel.tsx +++ /dev/null @@ -1,363 +0,0 @@ -import { AnimatePresence, motion } from "framer-motion"; -import type React from "react"; -import { useEffect, useRef, useState } from "react"; -import { colors } from "../styles"; -import type { FlowStep } from "../types"; - -interface SlackMessage { - id: string; - type: "user" | "bot" | "system" | "permission"; - text: string; - streaming?: boolean; -} - -interface SlackPanelProps { - steps: FlowStep[]; - currentStepIndex: number; -} - -export const SlackPanel: React.FC = ({ - steps, - currentStepIndex, -}) => { - const [messages, setMessages] = useState([]); - const [streamedText, setStreamedText] = useState(""); - const [isStreaming, setIsStreaming] = useState(false); - const scrollRef = useRef(null); - const prevStepRef = useRef(-1); - - useEffect(() => { - // Reset on prompt change (step 0 reached again) - if (currentStepIndex === 0 && prevStepRef.current !== 0) { - setMessages([]); - setStreamedText(""); - setIsStreaming(false); - } - prevStepRef.current = currentStepIndex; - - // Collect all slack events up to current step - const slackMessages: SlackMessage[] = []; - for (let i = 0; i <= currentStepIndex; i++) { - const step = steps[i]; - if (step.slackEvent && step.slackEvent.type !== "typing") { - slackMessages.push({ - id: step.id, - type: step.slackEvent.type, - text: step.slackEvent.text, - }); - } - } - setMessages(slackMessages); - - // Handle streaming for typing events - const currentStep = steps[currentStepIndex]; - if (currentStep?.slackEvent?.streaming) { - setIsStreaming(true); - const fullText = currentStep.slackEvent.text; - let charIndex = 0; - setStreamedText(""); - - const interval = setInterval(() => { - charIndex += 2; - if (charIndex >= fullText.length) { - setStreamedText(fullText); - setIsStreaming(false); - clearInterval(interval); - } else { - setStreamedText(fullText.slice(0, charIndex)); - } - }, 40); - - return () => clearInterval(interval); - } else { - setIsStreaming(false); - setStreamedText(""); - } - }, [currentStepIndex, steps]); - - useEffect(() => { - if (scrollRef.current) { - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - } - }, []); - - return ( -
- {/* Slack header */} -
-
- # general -
- - {/* Messages area */} -
- - {messages.map((msg) => ( - - {msg.type === "user" && } - {msg.type === "bot" && } - {msg.type === "system" && } - {msg.type === "permission" && ( - - )} - - ))} - - {/* Streaming message */} - {isStreaming && ( - - - - )} - -
- - {/* Input bar */} -
-
- Message #general -
-
-
- ); -}; - -const UserMessage: React.FC<{ text: string }> = ({ text }) => ( -
-
- U -
-
-
- You - now -
-
- {text} -
-
-
-); - -const BotMessage: React.FC<{ text: string; isStreaming?: boolean }> = ({ - text, - isStreaming, -}) => ( -
-
- L -
-
-
- Lobu - - APP - - now -
-
- {renderMarkdownLight(text)} - {isStreaming && ( - - | - - )} -
-
-
-); - -const SystemMessage: React.FC<{ text: string }> = ({ text }) => ( -
- {text} -
-); - -const PermissionMessage: React.FC<{ text: string }> = ({ text }) => ( -
-
- Permission Required -
-
- {text} -
-
- - Allow for 1 hour - - -
-
-); - -function renderMarkdownLight(text: string): React.ReactNode { - // Very simple bold markdown rendering - const parts = text.split(/(\*\*[^*]+\*\*)/g); - return parts.map((part, i) => { - if (part.startsWith("**") && part.endsWith("**")) { - return ( - // biome-ignore lint/suspicious/noArrayIndexKey: markdown parts are derived from static text and never reordered - - {part.slice(2, -2)} - - ); - } - return part; - }); -} diff --git a/landing/src/components/StepIndicator.tsx b/landing/src/components/StepIndicator.tsx deleted file mode 100644 index 894bf4175..000000000 --- a/landing/src/components/StepIndicator.tsx +++ /dev/null @@ -1,177 +0,0 @@ -import { motion } from "framer-motion"; -import type React from "react"; -import { colors } from "../styles"; -import type { FlowStep } from "../types"; - -interface StepIndicatorProps { - steps: FlowStep[]; - currentIndex: number; - onStepClick: (index: number) => void; - isPlaying: boolean; - onTogglePlay: () => void; - onReset: () => void; -} - -export const StepIndicator: React.FC = ({ - steps, - currentIndex, - onStepClick, - isPlaying, - onTogglePlay, - onReset, -}) => { - const currentStep = steps[currentIndex]; - - return ( -
- {/* Play/Pause button */} - - {isPlaying ? ( - - ) : ( - - )} - - - {/* Reset button */} - - - - - {/* Step dots */} -
- {steps.map((_, index) => ( - onStepClick(index)} - style={{ - width: index === currentIndex ? 16 : 6, - height: 6, - borderRadius: 3, - background: - index < currentIndex - ? colors.green - : index === currentIndex - ? colors.accent - : colors.border, - border: "none", - cursor: "pointer", - padding: 0, - transition: "all 0.3s", - }} - whileHover={{ scale: 1.4 }} - title={steps[index].title} - /> - ))} -
- - {/* Current step info */} -
-
- {currentStep.title} -
-
- {currentIndex + 1}/{steps.length} — {currentStep.description} -
-
-
- ); -}; diff --git a/landing/src/main.tsx b/landing/src/main.tsx deleted file mode 100644 index f46c379cc..000000000 --- a/landing/src/main.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom/client"; -import App from "./App"; - -ReactDOM.createRoot(document.getElementById("root")!).render( - - - -); diff --git a/landing/src/nodes.ts b/landing/src/nodes.ts deleted file mode 100644 index bb0fd0067..000000000 --- a/landing/src/nodes.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { NodePosition } from "./types"; - -// Layout: hub-spoke with Gateway in center -// Slack on the left, Sandbox on the right, MCP top-right, LLM bottom-right -export const NODES: NodePosition[] = [ - { - id: "slack", - label: "Slack", - sublabel: "User Interface", - x: 120, - y: 260, - icon: "slack", - }, - { - id: "gateway", - label: "Gateway", - sublabel: "Lobu Gateway", - x: 400, - y: 260, - icon: "gateway", - }, - { - id: "sandbox", - label: "Sandbox", - sublabel: "Isolated Container", - x: 680, - y: 260, - icon: "sandbox", - }, - { - id: "mcp", - label: "MCP Server", - sublabel: "External Service", - x: 400, - y: 80, - icon: "mcp", - }, - { - id: "llm", - label: "LLM API", - sublabel: "Claude / OpenAI", - x: 400, - y: 440, - icon: "llm", - }, -]; - -// Connection lines between nodes -export const CONNECTIONS: Array<{ from: string; to: string }> = [ - { from: "slack", to: "gateway" }, - { from: "gateway", to: "sandbox" }, - { from: "gateway", to: "mcp" }, - { from: "gateway", to: "llm" }, -]; diff --git a/landing/src/steps.ts b/landing/src/steps.ts deleted file mode 100644 index d71449dea..000000000 --- a/landing/src/steps.ts +++ /dev/null @@ -1,344 +0,0 @@ -import type { FlowStep, PromptOption } from "./types"; - -export const PROMPT_OPTIONS: PromptOption[] = [ - { - label: "Summarize my emails", - prompt: "Summarize my emails from today", - mcpTarget: "Gmail", - mcpDomain: "gmail.com", - }, - { - label: "Update Jira ticket", - prompt: "Update PROJ-123 status to done", - mcpTarget: "Jira", - mcpDomain: "jira.atlassian.com", - }, - { - label: "Check PR reviews", - prompt: "Show my open PR reviews on GitHub", - mcpTarget: "GitHub", - mcpDomain: "github.com", - }, -]; - -export function buildSteps(prompt: PromptOption): FlowStep[] { - return [ - { - id: "user-types", - title: "User sends message", - description: `User types "${prompt.prompt}" in Slack`, - activeNodes: ["slack"], - slackEvent: { - type: "user", - text: prompt.prompt, - }, - duration: 2000, - }, - { - id: "message-to-gateway", - title: "Message reaches Gateway", - description: "Slack delivers the event to the Lobu gateway", - activeNodes: ["slack", "gateway"], - packet: { - from: "slack", - to: "gateway", - label: "event", - color: "#4A9EFF", - }, - duration: 1500, - }, - { - id: "check-sandbox", - title: "Check sandbox status", - description: - "Gateway checks if a sandbox is already running for this user", - activeNodes: ["gateway"], - callout: { - node: "gateway", - text: "Is sandbox running? → No", - type: "info", - }, - duration: 1800, - }, - { - id: "check-snapshot", - title: "Look up snapshot", - description: - "Gateway checks if a pre-built snapshot exists for the agent", - activeNodes: ["gateway"], - callout: { - node: "gateway", - text: "Snapshot exists? → No", - type: "info", - }, - duration: 1800, - }, - { - id: "create-snapshot", - title: "Create snapshot", - description: - "Gateway creates a snapshot with the agent's tools, MCP config, and dependencies", - activeNodes: ["gateway", "sandbox"], - packet: { - from: "gateway", - to: "sandbox", - label: "snapshot", - color: "#F59E0B", - }, - callout: { - node: "sandbox", - text: "Building snapshot...", - type: "info", - }, - duration: 2500, - }, - { - id: "start-container", - title: "Start container", - description: - "Container launches from snapshot — isolated environment with no direct internet access", - activeNodes: ["sandbox"], - callout: { - node: "sandbox", - text: "Container running (network isolated)", - type: "security", - }, - slackEvent: { - type: "bot", - text: "Working on it...", - }, - duration: 2000, - }, - { - id: "deliver-message", - title: "Deliver prompt to sandbox", - description: "Gateway sends the user's message to the sandbox runtime", - activeNodes: ["gateway", "sandbox"], - packet: { - from: "gateway", - to: "sandbox", - label: "prompt", - color: "#4A9EFF", - }, - duration: 1500, - }, - { - id: "sandbox-runs", - title: "Sandbox processes message", - description: - "OpenClaw runtime analyzes the prompt and determines it needs to fetch emails via MCP", - activeNodes: ["sandbox"], - callout: { - node: "sandbox", - text: `Need to call ${prompt.mcpTarget} via MCP`, - type: "info", - }, - duration: 2000, - }, - { - id: "mcp-call", - title: "MCP proxy request", - description: - "Sandbox calls the gateway's MCP proxy endpoint — it does NOT have OAuth tokens", - activeNodes: ["sandbox", "gateway"], - packet: { - from: "sandbox", - to: "gateway", - label: "MCP call", - color: "#A855F7", - }, - callout: { - node: "sandbox", - text: "No OAuth tokens in sandbox", - type: "security", - }, - duration: 2000, - }, - { - id: "enrich-token", - title: "Gateway enriches with credentials", - description: - "Gateway attaches the user's OAuth token to the MCP request — sandbox never sees it", - activeNodes: ["gateway"], - callout: { - node: "gateway", - text: "Injecting OAuth token (hidden from sandbox)", - type: "security", - }, - duration: 2000, - }, - { - id: "domain-check", - title: "Domain access check", - description: `MCP needs to reach ${prompt.mcpDomain} — but it's not in the allowlist`, - activeNodes: ["gateway", "mcp"], - packet: { - from: "gateway", - to: "mcp", - label: "blocked", - color: "#EF4444", - }, - callout: { - node: "gateway", - text: `${prompt.mcpDomain} not in allowlist!`, - type: "warning", - }, - duration: 2000, - }, - { - id: "permission-prompt", - title: "User permission prompt", - description: `User is asked in Slack to approve access to ${prompt.mcpDomain}`, - activeNodes: ["gateway", "slack"], - packet: { - from: "gateway", - to: "slack", - label: "permission", - color: "#F59E0B", - }, - slackEvent: { - type: "permission", - text: `Allow access to ${prompt.mcpDomain} for 1 hour?`, - }, - duration: 3000, - }, - { - id: "user-approves", - title: "User approves access", - description: `User clicks "Allow for 1 hour" — domain is temporarily whitelisted`, - activeNodes: ["slack", "gateway"], - packet: { - from: "slack", - to: "gateway", - label: "approved", - color: "#10B981", - }, - slackEvent: { - type: "system", - text: `✓ ${prompt.mcpDomain} allowed for 1 hour`, - }, - duration: 1800, - }, - { - id: "fetch-data", - title: `Fetch from ${prompt.mcpTarget}`, - description: `Gateway fetches data from ${prompt.mcpDomain} using the user's OAuth credentials`, - activeNodes: ["gateway", "mcp"], - packet: { - from: "gateway", - to: "mcp", - label: "fetch", - color: "#10B981", - }, - duration: 2000, - }, - { - id: "data-to-sandbox", - title: "Data returned to sandbox", - description: `${prompt.mcpTarget} data flows back through gateway to the isolated sandbox`, - activeNodes: ["mcp", "gateway", "sandbox"], - packet: { - from: "mcp", - to: "sandbox", - label: "data", - color: "#10B981", - }, - duration: 1800, - }, - { - id: "llm-request", - title: "LLM API call", - description: - "Sandbox calls the gateway's LLM endpoint — it does NOT have API keys", - activeNodes: ["sandbox", "gateway"], - packet: { - from: "sandbox", - to: "gateway", - label: "LLM request", - color: "#8B5CF6", - }, - callout: { - node: "sandbox", - text: "No LLM API keys in sandbox", - type: "security", - }, - duration: 2000, - }, - { - id: "llm-process", - title: "LLM processes request", - description: - "Gateway forwards to LLM API with proper credentials, generates the response", - activeNodes: ["gateway", "llm"], - packet: { - from: "gateway", - to: "llm", - label: "generate", - color: "#8B5CF6", - }, - duration: 2000, - }, - { - id: "llm-response", - title: "LLM response returns", - description: "Generated response flows back through gateway to sandbox", - activeNodes: ["llm", "gateway", "sandbox"], - packet: { - from: "llm", - to: "sandbox", - label: "response", - color: "#8B5CF6", - }, - duration: 1500, - }, - { - id: "stream-result", - title: "Stream result to user", - description: - "Sandbox sends the final output through gateway back to Slack, streamed in real-time", - activeNodes: ["sandbox", "gateway", "slack"], - packet: { - from: "sandbox", - to: "slack", - label: "stream", - color: "#10B981", - }, - slackEvent: { - type: "typing", - text: getResponseText(prompt), - streaming: true, - }, - duration: 4000, - }, - { - id: "done", - title: "Complete", - description: - "User sees the response in Slack — all processing happened in an isolated sandbox", - activeNodes: ["slack"], - slackEvent: { - type: "bot", - text: getResponseText(prompt), - }, - callout: { - node: "sandbox", - text: "Sandbox stays warm for follow-ups", - type: "info", - }, - duration: 3000, - }, - ]; -} - -function getResponseText(prompt: PromptOption): string { - switch (prompt.mcpTarget) { - case "Gmail": - return "Here's your email summary for today:\n\n• **Design review** from Sarah — needs feedback by EOD\n• **Sprint planning** reminder — tomorrow 10am\n• **AWS billing alert** — usage up 12% this month\n\n3 emails require your attention."; - case "Jira": - return "Done! PROJ-123 has been updated:\n\n• Status: **In Progress** → **Done**\n• Resolution: Completed\n• Time logged: 2h 30m\n\nThe ticket is now closed."; - case "GitHub": - return "You have 3 open PR reviews:\n\n• **#142** feat: add OAuth flow — 2 comments pending\n• **#138** fix: rate limiting bug — approved, ready to merge\n• **#135** refactor: MCP proxy — 5 files changed\n\nPR #138 can be merged now."; - default: - return "Done!"; - } -} diff --git a/landing/src/styles.ts b/landing/src/styles.ts deleted file mode 100644 index 3d1620f75..000000000 --- a/landing/src/styles.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { CSSProperties } from "react"; - -export const colors = { - bg: "#0F1117", - bgSecondary: "#1A1D27", - bgTertiary: "#232633", - border: "#2D3148", - borderLight: "#3D4260", - text: "#E2E8F0", - textSecondary: "#94A3B8", - textMuted: "#64748B", - accent: "#4A9EFF", - accentDim: "rgba(74, 158, 255, 0.15)", - green: "#10B981", - greenDim: "rgba(16, 185, 129, 0.15)", - red: "#EF4444", - redDim: "rgba(239, 68, 68, 0.15)", - yellow: "#F59E0B", - yellowDim: "rgba(245, 158, 11, 0.15)", - purple: "#A855F7", - purpleDim: "rgba(168, 85, 247, 0.15)", - slackBg: "#1A1D21", - slackSidebar: "#19171D", - slackHover: "#222529", -}; - -export const layout: Record = { - container: { - fontFamily: "'Inter', -apple-system, BlinkMacSystemFont, sans-serif", - background: colors.bg, - color: colors.text, - minHeight: "100vh", - display: "flex", - flexDirection: "column", - overflow: "hidden", - }, - header: { - padding: "20px 32px", - borderBottom: `1px solid ${colors.border}`, - display: "flex", - alignItems: "center", - justifyContent: "space-between", - flexShrink: 0, - }, - main: { - display: "flex", - flex: 1, - overflow: "hidden", - }, - diagramPanel: { - flex: 1, - position: "relative", - display: "flex", - flexDirection: "column", - }, - slackPanel: { - width: 380, - borderLeft: `1px solid ${colors.border}`, - display: "flex", - flexDirection: "column", - background: colors.slackBg, - flexShrink: 0, - }, - bottomBar: { - borderTop: `1px solid ${colors.border}`, - padding: "16px 32px", - display: "flex", - alignItems: "center", - gap: 16, - background: colors.bgSecondary, - flexShrink: 0, - }, -}; diff --git a/landing/src/types.ts b/landing/src/types.ts deleted file mode 100644 index 48cb2143a..000000000 --- a/landing/src/types.ts +++ /dev/null @@ -1,44 +0,0 @@ -export interface FlowStep { - id: string; - title: string; - description: string; - /** Which nodes are active/highlighted during this step */ - activeNodes: string[]; - /** Animated packet from → to */ - packet?: { - from: string; - to: string; - label?: string; - color?: string; - }; - /** What appears in the Slack chat panel */ - slackEvent?: { - type: "user" | "bot" | "system" | "typing" | "permission"; - text: string; - streaming?: boolean; - }; - /** Callout/annotation to show */ - callout?: { - node: string; - text: string; - type: "info" | "security" | "warning"; - }; - /** Duration in ms for auto-play */ - duration: number; -} - -export interface NodePosition { - id: string; - label: string; - x: number; - y: number; - icon: string; - sublabel?: string; -} - -export interface PromptOption { - label: string; - prompt: string; - mcpTarget: string; - mcpDomain: string; -} diff --git a/landing/tsconfig.json b/landing/tsconfig.json deleted file mode 100644 index 39a405b95..000000000 --- a/landing/tsconfig.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2020", - "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], - "module": "ESNext", - "skipLibCheck": true, - "moduleResolution": "bundler", - "allowImportingTsExtensions": true, - "isolatedModules": true, - "moduleDetection": "force", - "noEmit": true, - "jsx": "react-jsx", - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true, - "noUncheckedSideEffectImports": true - }, - "include": ["src"] -} diff --git a/landing/vite.config.ts b/landing/vite.config.ts deleted file mode 100644 index 75120e622..000000000 --- a/landing/vite.config.ts +++ /dev/null @@ -1,7 +0,0 @@ -import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; - -export default defineConfig({ - plugins: [react()], - base: "./", -}); diff --git a/packages/cli/package.json b/packages/cli/package.json index b99d19dc1..2afbdde33 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -30,7 +30,6 @@ "open": "^10.1.0", "ora": "^8.0.1", "smol-toml": "^1.3.1", - "yaml": "^2.3.4", "zod": "^3.24.0" }, "devDependencies": { diff --git a/packages/cli/src/__tests__/login.test.ts b/packages/cli/src/__tests__/login.test.ts deleted file mode 100644 index 1b35e016a..000000000 --- a/packages/cli/src/__tests__/login.test.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { - afterEach, - beforeEach, - describe, - expect, - mock, - spyOn, - test, -} from "bun:test"; - -describe("loginCommand", () => { - const originalFetch = globalThis.fetch; - let consoleLog: ReturnType; - - beforeEach(() => { - mock.restore(); - consoleLog = spyOn(console, "log").mockImplementation(() => undefined); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - consoleLog.mockRestore(); - }); - - test("uses device-first login when the gateway returns device mode", async () => { - const saveCredentials = mock(async () => undefined); - const loadCredentials = mock(async () => null); - const openMock = mock(async () => undefined); - const spinner = { - start: mock(() => spinner), - fail: mock(() => spinner), - succeed: mock(() => spinner), - }; - - mock.module("open", () => ({ - default: openMock, - })); - mock.module("ora", () => ({ - default: mock(() => spinner), - })); - mock.module("../api/context.js", () => ({ - resolveContext: mock(async () => ({ - name: "dev", - apiUrl: "https://lobu.example.com/api/v1", - })), - })); - mock.module("../api/credentials.js", () => ({ - loadCredentials, - saveCredentials, - })); - - let calls = 0; - globalThis.fetch = mock(async (input: string | URL | Request) => { - const url = String(input); - calls += 1; - - if (url.endsWith("/auth/cli/start")) { - return new Response( - JSON.stringify({ - mode: "device", - deviceAuthId: "device-123", - userCode: "ABCD-EFGH", - verificationUri: "https://issuer.example.com/device", - verificationUriComplete: - "https://issuer.example.com/device?user_code=ABCD-EFGH", - interval: 1, - expiresAt: Date.now() + 10_000, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - if (url.endsWith("/auth/cli/poll")) { - return new Response( - JSON.stringify({ - status: "complete", - accessToken: "lobu-access-token", - refreshToken: "lobu-refresh-token", - expiresAt: Date.now() + 3600_000, - user: { - userId: "user-123", - email: "user@example.com", - name: "Example User", - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - throw new Error(`Unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const { loginCommand } = await import( - `../commands/login.ts?device=${Date.now()}` - ); - await loginCommand({}); - - expect(calls).toBe(2); - expect(openMock).toHaveBeenCalledWith( - "https://issuer.example.com/device?user_code=ABCD-EFGH" - ); - expect(saveCredentials).toHaveBeenCalledTimes(1); - expect(spinner.succeed).toHaveBeenCalledTimes(1); - }); - - test("falls back to browser login when the gateway returns browser mode", async () => { - const saveCredentials = mock(async () => undefined); - const loadCredentials = mock(async () => null); - const openMock = mock(async () => undefined); - const spinner = { - start: mock(() => spinner), - fail: mock(() => spinner), - succeed: mock(() => spinner), - }; - - mock.module("open", () => ({ - default: openMock, - })); - mock.module("ora", () => ({ - default: mock(() => spinner), - })); - mock.module("../api/context.js", () => ({ - resolveContext: mock(async () => ({ - name: "prod", - apiUrl: "https://lobu.example.com/api/v1", - })), - })); - mock.module("../api/credentials.js", () => ({ - loadCredentials, - saveCredentials, - })); - - globalThis.fetch = mock(async (input: string | URL | Request) => { - const url = String(input); - - if (url.endsWith("/auth/cli/start")) { - return new Response( - JSON.stringify({ - mode: "browser", - requestId: "request-123", - loginUrl: "https://lobu.example.com/login", - pollIntervalMs: 1, - expiresAt: Date.now() + 10_000, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - if (url.endsWith("/auth/cli/poll")) { - return new Response( - JSON.stringify({ - status: "complete", - accessToken: "lobu-access-token", - refreshToken: "lobu-refresh-token", - expiresAt: Date.now() + 3600_000, - user: { - userId: "user-456", - email: "prod@example.com", - name: "Prod User", - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - throw new Error(`Unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const { loginCommand } = await import( - `../commands/login.ts?browser=${Date.now()}` - ); - await loginCommand({}); - - expect(openMock).toHaveBeenCalledWith("https://lobu.example.com/login"); - expect(saveCredentials).toHaveBeenCalledTimes(1); - expect(spinner.succeed).toHaveBeenCalledTimes(1); - }); - - test("uses the explicit admin-password fallback when requested", async () => { - const saveCredentials = mock(async () => undefined); - const loadCredentials = mock(async () => null); - const promptMock = mock(async () => ({ password: "dev-secret" })); - - mock.module("inquirer", () => ({ - default: { - prompt: promptMock, - }, - })); - mock.module("../api/context.js", () => ({ - resolveContext: mock(async () => ({ - name: "dev", - apiUrl: "https://lobu.example.com/api/v1", - })), - })); - mock.module("../api/credentials.js", () => ({ - loadCredentials, - saveCredentials, - })); - - globalThis.fetch = mock(async (input: string | URL | Request) => { - const url = String(input); - if (url.endsWith("/auth/cli/admin-login")) { - return new Response( - JSON.stringify({ - status: "complete", - accessToken: "lobu-access-token", - refreshToken: "lobu-refresh-token", - expiresAt: Date.now() + 3600_000, - user: { - userId: "admin", - name: "Admin (dev)", - }, - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - throw new Error(`Unexpected fetch: ${url}`); - }) as unknown as typeof fetch; - - const { loginCommand } = await import( - `../commands/login.ts?admin=${Date.now()}` - ); - await loginCommand({ adminPassword: true }); - - expect(promptMock).toHaveBeenCalledTimes(1); - expect(saveCredentials).toHaveBeenCalledTimes(1); - }); -}); diff --git a/packages/cli/src/api/client.ts b/packages/cli/src/api/client.ts deleted file mode 100644 index ab5ce93a3..000000000 --- a/packages/cli/src/api/client.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { resolveContext } from "./context.js"; -import { getToken } from "./credentials.js"; - -export interface ApiResponse { - ok: boolean; - data?: T; - error?: string; -} - -/** - * HTTP client for community.lobu.ai API. - * Stub for Phase 1 — most endpoints don't exist yet. - */ -export async function apiRequest( - path: string, - options: RequestInit = {} -): Promise> { - const token = await getToken(); - const context = await resolveContext(); - const url = `${context.apiUrl}${path}`; - - const headers: Record = { - "Content-Type": "application/json", - "X-Lobu-Org": "default", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(options.headers as Record | undefined), - }; - - try { - const response = await fetch(url, { ...options, headers }); - if (!response.ok) { - const body = await response.text(); - return { ok: false, error: `${response.status}: ${body}` }; - } - const data = (await response.json()) as T; - return { ok: true, data }; - } catch (err) { - return { - ok: false, - error: err instanceof Error ? err.message : String(err), - }; - } -} diff --git a/packages/cli/src/api/context.ts b/packages/cli/src/api/context.ts index 6da0ef54c..70ceb5007 100644 --- a/packages/cli/src/api/context.ts +++ b/packages/cli/src/api/context.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; export const LOBU_CONFIG_DIR = join(homedir(), ".config", "lobu"); export const DEFAULT_CONTEXT_NAME = "community"; -export const DEFAULT_API_URL = "https://community.lobu.ai/api/v1"; +const DEFAULT_API_URL = "https://community.lobu.ai/api/v1"; const CONTEXTS_FILE = join(LOBU_CONFIG_DIR, "config.json"); @@ -38,9 +38,7 @@ export async function loadContextConfig(): Promise { } } -export async function saveContextConfig( - config: LobuContextConfig -): Promise { +async function saveContextConfig(config: LobuContextConfig): Promise { await mkdir(LOBU_CONFIG_DIR, { recursive: true }); await writeFile(CONTEXTS_FILE, JSON.stringify(config, null, 2), { mode: 0o600, diff --git a/packages/cli/src/commands/chat.ts b/packages/cli/src/commands/chat.ts index 96a58fd44..ff7d03276 100644 --- a/packages/cli/src/commands/chat.ts +++ b/packages/cli/src/commands/chat.ts @@ -8,19 +8,26 @@ import { renderMarkdown } from "../utils/markdown.js"; /** * `lobu chat "prompt"` — send a prompt to an agent and stream the response. * - * Requires `lobu dev` running. Connects to the local gateway, - * creates a session, sends the message, streams output, then exits. + * Without --user: API mode — creates a session, sends message, streams to terminal. + * With --user platform:id: Platform mode — sends through Telegram/Slack, response + * appears on the platform. Terminal shows the streamed response too. */ export async function chatCommand( cwd: string, prompt: string, - options: { agent?: string; gateway?: string } + options: { + agent?: string; + gateway?: string; + user?: string; + thread?: string; + dryRun?: boolean; + new?: boolean; + } ): Promise { const gatewayUrl = ( options.gateway ?? (await resolveGatewayUrl(cwd)) ).replace(/\/$/, ""); - // Resolve auth token: CLI JWT (from `lobu login`) → ADMIN_PASSWORD env var const authToken = (await getToken()) ?? process.env.ADMIN_PASSWORD; if (!authToken) { console.error( @@ -31,12 +38,142 @@ export async function chatCommand( process.exit(1); } - // Resolve agent ID from flag or first agent in lobu.toml (undefined = ephemeral) const agentId = options.agent ?? (await resolveAgentId(cwd)); - // 1. Create agent session - const createBody: Record = {}; - if (agentId) createBody.agentId = agentId; + // Parse --user flag: "telegram:12345" → { platform: "telegram", userId: "12345" } + const platformUser = options.user ? parsePlatformUser(options.user) : null; + + if (platformUser) { + // Platform mode: route through Telegram/Slack + await sendViaPlatform(gatewayUrl, authToken, { + agentId, + platform: platformUser.platform, + userId: platformUser.userId, + message: prompt, + thread: options.thread, + }); + } else { + // API mode: create session, send message, stream response + await sendViaApi(gatewayUrl, authToken, { + agentId, + message: prompt, + thread: options.thread, + dryRun: options.dryRun, + forceNew: options.new, + }); + } +} + +function parsePlatformUser( + user: string +): { platform: string; userId: string } | null { + const colonIndex = user.indexOf(":"); + if (colonIndex === -1) { + // No platform prefix — use as plain userId in API mode + return null; + } + return { + platform: user.slice(0, colonIndex), + userId: user.slice(colonIndex + 1), + }; +} + +/** + * Platform mode: send message through Telegram/Slack via /api/v1/agents/{agentId}/messages. + * The response appears on the platform AND streams to terminal via eventsUrl. + */ +async function sendViaPlatform( + gatewayUrl: string, + authToken: string, + opts: { + agentId?: string; + platform: string; + userId: string; + message: string; + thread?: string; + } +): Promise { + const agentId = opts.agentId || `test-${opts.platform}`; + const body: Record = { + platform: opts.platform, + content: opts.message, + }; + + // Platform-specific routing + if (opts.platform === "telegram") { + body.telegram = { chatId: opts.userId }; + } else if (opts.platform === "slack") { + body.slack = { + channel: opts.userId, + thread: opts.thread, + }; + } else if (opts.platform === "discord") { + body.discord = { channelId: opts.userId }; + } + + const res = await fetch( + `${gatewayUrl}/api/v1/agents/${encodeURIComponent(agentId)}/messages`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${authToken}`, + }, + body: JSON.stringify(body), + } + ); + + if (!res.ok) { + const resBody = await res.text().catch(() => ""); + console.error( + chalk.red(`\n Failed to send message (${res.status}): ${resBody}\n`) + ); + process.exit(1); + } + + const result = (await res.json()) as { + success: boolean; + agentId?: string; + eventsUrl?: string; + queued?: boolean; + }; + + if (result.eventsUrl) { + // Stream the response from the agent + const sseUrl = result.eventsUrl.startsWith("http") + ? result.eventsUrl + : `${gatewayUrl}${result.eventsUrl}`; + + const sseController = new AbortController(); + await streamResponse(sseUrl, authToken, sseController); + } else { + console.log( + chalk.dim( + ` Message sent via ${opts.platform}. Response will appear on the platform.\n` + ) + ); + } +} + +/** + * API mode: create session, send message, stream response to terminal. + */ +async function sendViaApi( + gatewayUrl: string, + authToken: string, + opts: { + agentId?: string; + message: string; + thread?: string; + dryRun?: boolean; + forceNew?: boolean; + } +): Promise { + const createBody: Record = {}; + if (opts.agentId) createBody.agentId = opts.agentId; + if (opts.thread) createBody.thread = opts.thread; + if (opts.dryRun) createBody.dryRun = true; + if (opts.forceNew) createBody.forceNew = true; const createRes = await fetch(`${gatewayUrl}/api/v1/agents`, { method: "POST", @@ -68,23 +205,20 @@ export async function chatCommand( token: string; }; - // Build URLs from gateway flag (returned URLs may point to a public domain) const base = `${gatewayUrl}/api/v1/agents/${session.agentId}`; const sseUrl = `${base}/events`; const messagesUrl = `${base}/messages`; - // 2. Open SSE connection before sending message so we don't miss events const sseController = new AbortController(); const streaming = streamResponse(sseUrl, session.token, sseController); - // 3. Send the prompt const msgRes = await fetch(messagesUrl, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${session.token}`, }, - body: JSON.stringify({ content: prompt }), + body: JSON.stringify({ content: opts.message }), }); if (!msgRes.ok) { @@ -96,28 +230,16 @@ export async function chatCommand( process.exit(1); } - // 4. Wait for streaming to complete await streaming; } async function resolveAgentId(cwd: string): Promise { const result = await loadConfig(cwd); - if (isLoadError(result)) { - // No lobu.toml — use ephemeral agent - return undefined; - } - + if (isLoadError(result)) return undefined; const ids = Object.keys(result.config.agents); - if (ids.length === 0) { - return undefined; - } - - return ids[0]!; + return ids[0]; } -/** - * Connect to SSE, print deltas to stdout, resolve on complete/error. - */ async function streamResponse( sseUrl: string, token: string, @@ -176,8 +298,7 @@ async function streamResponse( console.error(`\n${renderMarkdown(data.content)}\n`); } controller.abort(); - process.exit(1); - break; + return; case "link-button": case "question": case "grant-request": @@ -195,7 +316,7 @@ async function streamResponse( chalk.red(`\n Agent error: ${String(data.error)}\n`) ); controller.abort(); - process.exit(1); + return; } currentEvent = ""; @@ -205,7 +326,6 @@ async function streamResponse( } } } catch (err: unknown) { - // AbortError is expected when we call controller.abort() on complete if (err instanceof Error && err.name === "AbortError") return; throw err; } finally { diff --git a/packages/cli/src/commands/dev.ts b/packages/cli/src/commands/dev.ts index 7ada5382c..0632e0a72 100644 --- a/packages/cli/src/commands/dev.ts +++ b/packages/cli/src/commands/dev.ts @@ -1,24 +1,14 @@ import { spawn } from "node:child_process"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; -import { basename, join, resolve } from "node:path"; +import { readFile, writeFile } from "node:fs/promises"; +import { basename, join } from "node:path"; import chalk from "chalk"; import ora from "ora"; -import { loadSkillsRegistry } from "../commands/skills/registry.js"; -import type { - AgentManifestEntry, - AgentsManifest, -} from "../config/agents-manifest.js"; -import { - isLoadError, - loadAgentMarkdown, - loadConfig, - loadSkillFiles, -} from "../config/loader.js"; -import type { AgentEntry } from "../config/schema.js"; +import { isLoadError, loadConfig } from "../config/loader.js"; /** - * `lobu dev` — smart wrapper around `docker compose up`. - * Reads lobu.toml, seeds .env + agents manifest, then passes all args through. + * `lobu run` — smart wrapper around `docker compose up`. + * Validates lobu.toml, seeds .env, then starts docker compose. + * The gateway reads lobu.toml directly from the mounted workspace. */ export async function devCommand( cwd: string, @@ -41,10 +31,7 @@ export async function devCommand( const spinner = ora("Preparing local dev environment...").start(); try { - const lobuDir = join(cwd, ".lobu"); - await mkdir(lobuDir, { recursive: true }); - - // Parse .env first so we can resolve $VAR references in lobu.toml + // Parse .env to merge derived vars const envPath = join(cwd, ".env"); let existingEnv = ""; try { @@ -54,16 +41,15 @@ export async function devCommand( } const dotenvVars = parseEnvFile(existingEnv); - const { manifest, envVars } = await buildManifest( - cwd, - config.agents, - dotenvVars - ); + const agentCount = Object.keys(config.agents).length; - await writeFile( - join(lobuDir, "agents.json"), - JSON.stringify(manifest, null, 2) - ); + // Derive env vars (compose project name from first agent or directory name) + const firstAgent = Object.values(config.agents)[0]; + const envVars: Record = { + COMPOSE_PROJECT_NAME: firstAgent?.name + ? firstAgent.name.toLowerCase().replace(/\s+/g, "-") + : basename(cwd), + }; // Merge derived vars into existing .env (preserves comments and formatting) await mergeEnvFile(envPath, existingEnv, envVars); @@ -84,9 +70,7 @@ export async function devCommand( const fallbackPort = dotenvVars.GATEWAY_PORT || "8080"; - console.log( - chalk.cyan(`\n Starting ${manifest.agents.length} agent(s)...\n`) - ); + console.log(chalk.cyan(`\n Starting ${agentCount} agent(s)...\n`)); const explicitDetach = passthroughArgs.includes("-d") || passthroughArgs.includes("--detach"); @@ -129,7 +113,7 @@ export async function devCommand( const gatewayUrl = `http://localhost:${port}`; console.log(chalk.green("\n Lobu is running!\n")); - console.log(chalk.cyan(` Admin page: ${gatewayUrl}/agents`)); + console.log(chalk.cyan(` API docs: ${gatewayUrl}/api/docs`)); console.log(chalk.dim(`\n Stop: docker compose down`)); if (explicitDetach) { @@ -163,183 +147,6 @@ export async function devCommand( } } -/** - * Build agents manifest and merged env vars from [agents.*] config. - */ -async function buildManifest( - cwd: string, - agents: Record, - dotenvVars: Record -): Promise<{ manifest: AgentsManifest; envVars: Record }> { - const entries: AgentManifestEntry[] = []; - const rootSkillsDir = join(cwd, "skills"); - const registrySkills = new Map( - loadSkillsRegistry().map((skill) => [skill.id, skill]) - ); - - for (const [agentId, agentConfig] of Object.entries(agents)) { - const agentDir = resolve(cwd, agentConfig.dir); - const markdown = await loadAgentMarkdown(agentDir); - const skillFiles = await loadSkillFiles([ - rootSkillsDir, - join(agentDir, "skills"), - ]); - const systemSkills = agentConfig.skills.enabled - .map((skillId) => registrySkills.get(skillId)) - .filter((skill): skill is NonNullable => !!skill) - .map((skill) => ({ - repo: `system/${skill.id}`, - name: skill.name, - description: skill.description, - instructions: skill.instructions, - enabled: true, - system: true, - content: "", - mcpServers: skill.mcpServers?.map((mcp) => ({ - id: mcp.id, - name: mcp.name, - url: mcp.url, - type: mcp.type, - command: mcp.command, - args: mcp.args, - })), - nixPackages: skill.nixPackages, - permissions: skill.permissions, - providers: skill.providers?.length ? [skill.id] : undefined, - })); - const localSkills = skillFiles.map((skillFile) => ({ - repo: `local/${skillFile.name}`, - name: skillFile.name, - content: skillFile.content, - enabled: true, - })); - - const entry: AgentManifestEntry = { - agentId, - name: agentConfig.name, - description: agentConfig.description, - settings: { ...markdown }, - }; - - if (agentConfig.providers.length > 0) { - entry.settings.installedProviders = agentConfig.providers.map((p) => ({ - providerId: p.id, - })); - entry.settings.modelSelection = { mode: "auto" }; - const providerModelPreferences = Object.fromEntries( - agentConfig.providers - .filter((provider) => !!provider.model?.trim()) - .map((provider) => [provider.id, provider.model!.trim()]) - ); - if (Object.keys(providerModelPreferences).length > 0) { - entry.settings.providerModelPreferences = providerModelPreferences; - } - } - - if (systemSkills.length > 0 || localSkills.length > 0) { - entry.settings.skillsConfig = { - skills: [...systemSkills, ...localSkills], - }; - } - - if (agentConfig.network) { - entry.settings.networkConfig = { - allowedDomains: agentConfig.network.allowed, - deniedDomains: agentConfig.network.denied, - }; - } - - if (agentConfig.worker?.nix_packages?.length) { - entry.settings.nixConfig = { - packages: agentConfig.worker.nix_packages, - }; - } - - if (agentConfig.skills.mcp) { - const mcpServers: Record = {}; - for (const [id, mcp] of Object.entries(agentConfig.skills.mcp)) { - const mapped: Record = { ...mcp }; - if (mcp.oauth) { - mapped.oauth = { - authUrl: mcp.oauth.auth_url, - tokenUrl: mcp.oauth.token_url, - clientId: resolveEnvVar(mcp.oauth.client_id || "", dotenvVars), - clientSecret: resolveEnvVar( - mcp.oauth.client_secret || "", - dotenvVars - ), - scopes: mcp.oauth.scopes, - tokenEndpointAuthMethod: mcp.oauth.token_endpoint_auth_method, - }; - } - // Resolve env vars in MCP env block - if (mcp.env) { - mapped.env = Object.fromEntries( - Object.entries(mcp.env).map(([k, v]) => [ - k, - resolveEnvVar(v, dotenvVars), - ]) - ); - } - mcpServers[id] = mapped; - } - entry.settings.mcpServers = mcpServers; - } - - // Resolve provider credentials from $ENV_VAR references - const credentials = agentConfig.providers - .filter((p) => p.key) - .map((p) => ({ - providerId: p.id, - key: resolveEnvVar(p.key!, dotenvVars), - })) - .filter((c) => c.key); // skip if env var not found - - if (credentials.length > 0) { - entry.credentials = credentials; - } - - // Resolve connection configs from $ENV_VAR references - const connections = agentConfig.connections - .map((conn) => ({ - type: conn.type, - config: Object.fromEntries( - Object.entries(conn.config).map(([k, v]) => [ - k, - resolveEnvVar(v, dotenvVars), - ]) - ), - })) - .filter((conn) => Object.values(conn.config).every((v) => v !== "")); // skip if any env var missing - - if (connections.length > 0) { - entry.connections = connections; - } - - entries.push(entry); - } - - const envVars: Record = { - COMPOSE_PROJECT_NAME: entries[0]?.name - ? entries[0].name.toLowerCase().replace(/\s+/g, "-") - : basename(cwd), - }; - - return { manifest: { version: 1, agents: entries }, envVars }; -} - -/** - * Resolve a value that may be a $ENV_VAR reference. - * Returns the resolved value, or empty string if the env var is not set. - */ -function resolveEnvVar(value: string, envVars: Record): string { - if (value.startsWith("$")) { - const varName = value.slice(1); - return envVars[varName] || process.env[varName] || ""; - } - return value; -} - function parseEnvFile(content: string): Record { const vars: Record = {}; for (const line of content.split("\n")) { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 6d8f5a054..a8379e69b 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -297,63 +297,57 @@ export async function initCommand( } } - // Auth provider (OAuth login for settings page + memory/integrations) - const { authProvider } = await inquirer.prompt([ + // Memory + const { memoryChoice } = await inquirer.prompt([ { type: "list", - name: "authProvider", - message: "Auth provider (OAuth login, memory & integrations):", + name: "memoryChoice", + message: "Memory:", choices: [ - { name: "Owletto (owletto.com)", value: "owletto" }, - { name: "Custom URL", value: "custom" }, - { name: "None (admin password only)", value: "none" }, + { name: "None (filesystem memory)", value: "none" }, + { name: "Owletto Cloud (owletto.com)", value: "owletto-cloud" }, + { + name: "Owletto Local (runs alongside gateway)", + value: "owletto-local", + }, + { name: "Custom Owletto URL", value: "owletto-custom" }, ], - default: "owletto", + default: "none", }, ]); - const oauthSecrets: Array<{ envVar: string; value: string }> = []; - if (authProvider === "owletto") { - oauthSecrets.push({ - envVar: "AUTH_MCP_URL", - value: "https://owletto.com/mcp", + const envSecrets: Array<{ envVar: string; value: string }> = []; + let includeOwlettoLocal = false; + let owlettoUrl = ""; + + if (memoryChoice === "owletto-cloud") { + owlettoUrl = "https://owletto.com/mcp"; + envSecrets.push({ envVar: "MEMORY_URL", value: owlettoUrl }); + } else if (memoryChoice === "owletto-local") { + includeOwlettoLocal = true; + owlettoUrl = "http://owletto:8787/mcp"; + envSecrets.push({ envVar: "MEMORY_URL", value: owlettoUrl }); + envSecrets.push({ + envVar: "OWLETTO_AUTH_SECRET", + value: randomBytes(32).toString("hex"), + }); + envSecrets.push({ + envVar: "OWLETTO_DB_PASSWORD", + value: randomBytes(16).toString("hex"), }); - } else if (authProvider === "custom") { - const { customAuthMcpUrl } = await inquirer.prompt([ + } else if (memoryChoice === "owletto-custom") { + const { customOwlettoUrl } = await inquirer.prompt([ { type: "input", - name: "customAuthMcpUrl", - message: "Auth MCP URL:", + name: "customOwlettoUrl", + message: "Owletto MCP URL:", + validate: (v: string) => (v ? true : "URL is required"), }, ]); - if (customAuthMcpUrl) { - oauthSecrets.push({ - envVar: "AUTH_MCP_URL", - value: customAuthMcpUrl, - }); - } - } - - // Memory plugin - const { memoryPlugin } = await inquirer.prompt([ - { - type: "list", - name: "memoryPlugin", - message: "Memory plugin:", - choices: [ - { name: "Owletto (persistent, cross-conversation)", value: "owletto" }, - { name: "Native (filesystem-based)", value: "native" }, - ], - default: authProvider === "owletto" ? "owletto" : "native", - }, - ]); - - if (memoryPlugin !== "owletto") { - oauthSecrets.push({ - envVar: "MEMORY_PLUGIN", - value: memoryPlugin, - }); + owlettoUrl = customOwlettoUrl; + envSecrets.push({ envVar: "MEMORY_URL", value: owlettoUrl }); } + // "none" — no env var needed, gateway defaults to filesystem memory // Compute network domains from selected policy let allowedDomains: string; @@ -450,7 +444,7 @@ export async function initCommand( } // Save OAuth secrets to .env - for (const secret of oauthSecrets) { + for (const secret of envSecrets) { await secretsSetCommand(projectDir, secret.envVar, secret.value); } @@ -517,6 +511,7 @@ export async function initCommand( gatewayPort, dockerfilePath: "./Dockerfile.worker", deploymentMode: answers.deploymentMode, + includeOwlettoLocal, }); await writeFile(join(projectDir, composeFilename), composeContent); @@ -551,13 +546,30 @@ export async function initCommand( console.log(); const gatewayUrl = `http://localhost:${gatewayPort}`; + if (owlettoUrl) { + const displayUrl = includeOwlettoLocal + ? "http://localhost:8787" + : owlettoUrl; + console.log(chalk.cyan(" Owletto:")); + console.log(chalk.dim(` ${displayUrl}\n`)); + } console.log(chalk.cyan(" 3. Start the services:")); - console.log(chalk.dim(" lobu dev -d\n")); - console.log(chalk.cyan(" 4. Open the admin page:")); - console.log(chalk.dim(` ${gatewayUrl}/agents\n`)); - console.log(chalk.cyan(" 5. View logs:")); + console.log(chalk.dim(" lobu run -d\n")); + if (includeOwlettoLocal) { + console.log(chalk.cyan(" 4. Set up Owletto (first run):")); + console.log( + chalk.dim(" Visit http://localhost:8787 to create your account\n") + ); + } + console.log( + chalk.cyan(` ${includeOwlettoLocal ? "5" : "4"}. Open the API docs:`) + ); + console.log(chalk.dim(` ${gatewayUrl}/api/docs\n`)); + console.log(chalk.cyan(` ${includeOwlettoLocal ? "6" : "5"}. View logs:`)); console.log(chalk.dim(" docker compose logs -f\n")); - console.log(chalk.cyan(" 6. Stop the services:")); + console.log( + chalk.cyan(` ${includeOwlettoLocal ? "7" : "6"}. Stop the services:`) + ); console.log(chalk.dim(" docker compose down\n")); } catch (error) { spinner.fail("Failed to create project"); @@ -604,7 +616,7 @@ async function generateLobuToml( ); } else { lines.push( - "# Add providers via the admin page or uncomment below:", + "# Add providers via the gateway configuration APIs or uncomment below:", `# [[agents.${id}.providers]]`, '# id = "anthropic"', '# key = "$ANTHROPIC_API_KEY"' @@ -624,7 +636,7 @@ async function generateLobuToml( } } else { lines.push( - "# Messaging platform (add via admin page or uncomment below):", + "# Messaging platform (add via the gateway configuration APIs or uncomment below):", `# [[agents.${id}.connections]]`, '# type = "telegram"', `# [agents.${id}.connections.config]`, @@ -675,8 +687,10 @@ function generateDockerCompose(options: { gatewayPort: string; dockerfilePath: string; deploymentMode: "embedded" | "docker"; + includeOwlettoLocal?: boolean; }): string { - const { projectName, gatewayPort, deploymentMode } = options; + const { projectName, gatewayPort, deploymentMode, includeOwlettoLocal } = + options; const gatewayImage = `ghcr.io/lobu-ai/lobu-gateway:latest`; const workerImage = `ghcr.io/lobu-ai/lobu-worker-base:latest`; @@ -698,6 +712,63 @@ function generateDockerCompose(options: { - "127.0.0.1:8118:8118" # HTTP proxy for workers` : ""; + const owlettoServices = includeOwlettoLocal + ? ` + owletto-postgres: + image: pgvector/pgvector:pg17 + environment: + POSTGRES_USER: owletto + POSTGRES_PASSWORD: \${OWLETTO_DB_PASSWORD} + POSTGRES_DB: owletto + volumes: + - owletto-pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U owletto -d owletto"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - lobu-internal + restart: unless-stopped + + owletto: + image: ghcr.io/lobu-ai/owletto-app:latest + pull_policy: always + ports: + - "127.0.0.1:8787:8787" + environment: + DATABASE_URL: postgresql://owletto:\${OWLETTO_DB_PASSWORD}@owletto-postgres:5432/owletto + BETTER_AUTH_SECRET: \${OWLETTO_AUTH_SECRET} + PORT: "8787" + HOST: 0.0.0.0 + networks: + - lobu-internal + depends_on: + owletto-postgres: + condition: service_healthy + healthcheck: + test: ["CMD-SHELL", "curl -f http://127.0.0.1:8787/health || exit 1"] + interval: 10s + timeout: 10s + retries: 10 + start_period: 20s + restart: unless-stopped +` + : ""; + + const owlettoDependsOn = includeOwlettoLocal + ? ` + owletto: + condition: service_healthy` + : ""; + + const owlettoVolumes = includeOwlettoLocal + ? ` +volumes: + owletto-pgdata: +` + : ""; + return `# Generated by @lobu/cli # Deployment mode: ${deploymentMode} @@ -718,7 +789,7 @@ services: networks: - lobu-internal restart: unless-stopped - +${owlettoServices} gateway: image: ${gatewayImage} pull_policy: always @@ -735,16 +806,17 @@ services: ENCRYPTION_KEY: \${ENCRYPTION_KEY} WORKER_ALLOWED_DOMAINS: \${WORKER_ALLOWED_DOMAINS:-} WORKER_DISALLOWED_DOMAINS: \${WORKER_DISALLOWED_DOMAINS:-} - AUTH_MCP_URL: \${AUTH_MCP_URL:-} - MEMORY_PLUGIN: \${MEMORY_PLUGIN:-owletto} + MEMORY_URL: \${MEMORY_URL:-} + LOBU_WORKSPACE_ROOT: /workspace/project volumes:${dockerSocketMount} - ./.lobu:/app/.lobu + - .:/workspace/project:ro networks: - lobu-public - lobu-internal depends_on: redis: - condition: service_healthy + condition: service_healthy${owlettoDependsOn} restart: unless-stopped networks: @@ -753,6 +825,6 @@ networks: lobu-internal: internal: true driver: bridge - +${owlettoVolumes} `; } diff --git a/packages/cli/src/commands/launch.ts b/packages/cli/src/commands/launch.ts deleted file mode 100644 index 850733713..000000000 --- a/packages/cli/src/commands/launch.ts +++ /dev/null @@ -1,39 +0,0 @@ -import chalk from "chalk"; -import { isLoadError, loadConfig } from "../config/loader.js"; -import { validateCommand } from "./validate.js"; - -export async function launchCommand( - cwd: string, - options: { dryRun?: boolean; env?: string; message?: string } -): Promise { - const valid = await validateCommand(cwd); - if (!valid) { - process.exit(1); - } - - const result = await loadConfig(cwd); - if (isLoadError(result)) { - process.exit(1); - } - - const agentCount = Object.keys(result.config.agents).length; - console.log(chalk.dim(` ${agentCount} agent(s) configured`)); - - if (options.dryRun) { - console.log(chalk.dim("\n Dry run — no changes applied.\n")); - return; - } - - console.log(chalk.bold.cyan("\n Lobu Cloud is in early access.\n")); - console.log(chalk.bold(" Get started:")); - console.log( - ` Schedule a call ${chalk.cyan("https://cal.com/burakemre/lobu")}` - ); - console.log( - ` Self-host now ${chalk.cyan("https://lobu.ai/docs/deployment")}` - ); - console.log( - ` REST API docs ${chalk.cyan("https://community.lobu.ai/api/docs")}` - ); - console.log(chalk.dim(`\n Run locally with: ${chalk.white("lobu dev")}\n`)); -} diff --git a/packages/cli/src/commands/status.ts b/packages/cli/src/commands/status.ts index 3914844fe..76631c77b 100644 --- a/packages/cli/src/commands/status.ts +++ b/packages/cli/src/commands/status.ts @@ -44,7 +44,7 @@ export async function statusCommand(cwd: string): Promise { status = (await res.json()) as StatusResponse; } catch { console.log(chalk.yellow("\n Gateway not reachable.")); - console.log(chalk.dim(" Start with `lobu dev` to run your agents.\n")); + console.log(chalk.dim(" Start with `lobu run` to run your agents.\n")); return; } diff --git a/packages/cli/src/config/agents-manifest.ts b/packages/cli/src/config/agents-manifest.ts deleted file mode 100644 index 24ba2453b..000000000 --- a/packages/cli/src/config/agents-manifest.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Agents manifest — written to .lobu/agents.json by the CLI, - * read by the gateway on startup to seed agent settings into Redis. - */ -// NOTE: Keep in sync with packages/gateway/src/services/agent-seeder.ts - -export interface AgentsManifest { - version: 1; - agents: AgentManifestEntry[]; -} - -export interface AgentManifestEntry { - agentId: string; - name: string; - description?: string; - settings: { - identityMd?: string; - soulMd?: string; - userMd?: string; - installedProviders?: Array<{ - providerId: string; - }>; - modelSelection?: { - mode: "auto" | "pinned"; - pinnedModel?: string; - }; - providerModelPreferences?: Record; - nixConfig?: { - packages?: string[]; - flakeUrl?: string; - }; - skillsConfig?: { - skills: Array<{ - repo: string; - name: string; - description?: string; - instructions?: string; - content: string; - enabled: boolean; - system?: boolean; - mcpServers?: Array<{ - id: string; - name?: string; - url?: string; - type?: string; - command?: string; - args?: string[]; - }>; - nixPackages?: string[]; - permissions?: string[]; - providers?: string[]; - modelPreference?: string; - thinkingLevel?: "off" | "low" | "medium" | "high"; - }>; - }; - networkConfig?: { - allowedDomains?: string[]; - deniedDomains?: string[]; - }; - mcpServers?: Record< - string, - { - url?: string; - command?: string; - args?: string[]; - env?: Record; - headers?: Record; - oauth?: { - authUrl: string; - tokenUrl: string; - clientId?: string; - clientSecret?: string; - scopes?: string[]; - tokenEndpointAuthMethod?: string; - }; - } - >; - }; - credentials?: Array<{ - providerId: string; - key: string; - }>; - connections?: Array<{ - type: string; - config: Record; - }>; -} diff --git a/packages/cli/src/config/loader.ts b/packages/cli/src/config/loader.ts index 6d5988739..36601b6fd 100644 --- a/packages/cli/src/config/loader.ts +++ b/packages/cli/src/config/loader.ts @@ -1,5 +1,5 @@ -import { readdir, readFile } from "node:fs/promises"; -import { join, resolve } from "node:path"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; import { parse as parseToml } from "smol-toml"; import { type LobuTomlConfig, lobuConfigSchema } from "./schema.js"; @@ -57,71 +57,3 @@ export function isLoadError( ): result is LoadError { return "error" in result; } - -/** - * Read IDENTITY.md, SOUL.md, USER.md from a directory. - * Returns content or undefined if file doesn't exist. - */ -export async function loadAgentMarkdown( - dir: string -): Promise<{ identityMd?: string; soulMd?: string; userMd?: string }> { - const result: { identityMd?: string; soulMd?: string; userMd?: string } = {}; - - const files = [ - { path: "IDENTITY.md", key: "identityMd" as const }, - { path: "SOUL.md", key: "soulMd" as const }, - { path: "USER.md", key: "userMd" as const }, - ]; - - for (const { path, key } of files) { - try { - const content = await readFile(join(dir, path), "utf-8"); - if (content.trim()) { - result[key] = content.trim(); - } - } catch { - // File doesn't exist, skip - } - } - - return result; -} - -/** - * Scan directories for *.md skill files. - * Later dirs override earlier dirs (agent-specific overrides shared). - * Returns array of { name, content } where name is filename without extension. - */ -export async function loadSkillFiles( - dirs: string[] -): Promise> { - const skillMap = new Map(); - - for (const dir of dirs) { - const resolvedDir = resolve(dir); - let entries: string[]; - try { - entries = await readdir(resolvedDir); - } catch { - continue; // Directory doesn't exist, skip - } - - for (const entry of entries) { - if (!entry.endsWith(".md")) continue; - const name = entry.slice(0, -3); // strip .md - try { - const content = await readFile(join(resolvedDir, entry), "utf-8"); - if (content.trim()) { - skillMap.set(name, content.trim()); - } - } catch { - // Skip unreadable files - } - } - } - - return Array.from(skillMap.entries()).map(([name, content]) => ({ - name, - content, - })); -} diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 8393642bf..b6a0b39e5 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -48,12 +48,26 @@ export async function runCli( .command("chat ") .description("Send a prompt to an agent and stream the response") .option("-a, --agent ", "Agent ID (defaults to first in lobu.toml)") + .option("-u, --user ", "User ID to impersonate (e.g. telegram:12345)") + .option("-t, --thread ", "Thread/conversation ID for multi-turn") .option( "-g, --gateway ", "Gateway URL (default: http://localhost:8080)" ) + .option("--dry-run", "Process without persisting history") + .option("--new", "Force new session (ignore existing)") .action( - async (prompt: string, options: { agent?: string; gateway?: string }) => { + async ( + prompt: string, + options: { + agent?: string; + gateway?: string; + user?: string; + thread?: string; + dryRun?: boolean; + new?: boolean; + } + ) => { const { chatCommand } = await import("./commands/chat.js"); await chatCommand(process.cwd(), prompt, options); } @@ -69,12 +83,12 @@ export async function runCli( if (!valid) process.exit(1); }); - // ─── dev ──────────────────────────────────────────────────────────── + // ─── run ──────────────────────────────────────────────────────────── // Passthrough to docker compose up — all extra args forwarded directly. - // lobu dev -d --build → docker compose up -d --build + // lobu run -d --build → docker compose up -d --build program - .command("dev") - .description("Run agent locally (reads lobu.toml, then docker compose up)") + .command("run") + .description("Run agent stack (reads lobu.toml, then docker compose up)") .allowUnknownOption(true) .helpOption(false) .action(async (_opts: unknown, cmd: Command) => { @@ -82,20 +96,6 @@ export async function runCli( await devCommand(process.cwd(), cmd.args); }); - // ─── launch ───────────────────────────────────────────────────────── - program - .command("launch") - .description("Launch agent to Lobu Cloud") - .option("-e, --env ", "Target environment") - .option("--dry-run", "Show what would change") - .option("-m, --message ", "Deployment note") - .action( - async (options: { env?: string; dryRun?: boolean; message?: string }) => { - const { launchCommand } = await import("./commands/launch.js"); - await launchCommand(process.cwd(), options); - } - ); - // ─── login ────────────────────────────────────────────────────────── program .command("login") diff --git a/packages/cli/src/mcp-servers.ts b/packages/cli/src/mcp-servers.ts deleted file mode 100644 index 50fdaed76..000000000 --- a/packages/cli/src/mcp-servers.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -export interface McpServerDefinition { - id: string; - name: string; - description: string; - type: "oauth" | "api-key" | "command" | "none"; - config: any; - setupInstructions?: string; -} - -// Load MCP servers from JSON file -const mcpServersJson = readFileSync( - join(__dirname, "mcp-servers.json"), - "utf-8" -); -const mcpServersData = JSON.parse(mcpServersJson); - -export const MCP_SERVERS: McpServerDefinition[] = mcpServersData.servers; - -// Helper function to get OAuth servers -// Helper function to generate env variable names -export function getRequiredEnvVars(servers: McpServerDefinition[]): string[] { - const envVars = new Set(); - - servers.forEach((server) => { - const configStr = JSON.stringify(server.config); - // Extract all ${VARIABLE} and ${env:VARIABLE} patterns - const matches = configStr.match(/\$\{(?:env:)?([A-Z_]+)\}/g) || []; - matches.forEach((match) => { - const varName = match.replace(/\$\{(?:env:)?([A-Z_]+)\}/, "$1"); - if (varName !== "PUBLIC_URL") { - // PUBLIC_URL is handled separately - envVars.add(varName); - } - }); - }); - - return Array.from(envVars); -} diff --git a/packages/cli/src/templates/README.md.tmpl b/packages/cli/src/templates/README.md.tmpl index 6aba598fb..1c818eb4b 100644 --- a/packages/cli/src/templates/README.md.tmpl +++ b/packages/cli/src/templates/README.md.tmpl @@ -8,7 +8,7 @@ Make sure you have Docker CLI installed. ```bash # Start the services -lobu dev -d +lobu run -d # View logs docker compose logs -f @@ -51,7 +51,7 @@ Edit `.env` to configure: ### Platform Connections -Platforms (Slack, Telegram, Discord, WhatsApp, Teams) are configured via the admin page at `{PUBLIC_GATEWAY_URL}/agents`. No platform-specific env vars needed — just paste your bot token in the UI. +Platforms (Slack, Telegram, Discord, WhatsApp, Teams) are configured through the gateway APIs. No platform-specific env vars are required in `docker-compose.yml`; manage connections against `{PUBLIC_GATEWAY_URL}/api/v1/connections` and related auth/config endpoints. ### Worker Customization diff --git a/packages/cli/src/templates/TESTING.md.tmpl b/packages/cli/src/templates/TESTING.md.tmpl index d197022fb..3dcd5583b 100644 --- a/packages/cli/src/templates/TESTING.md.tmpl +++ b/packages/cli/src/templates/TESTING.md.tmpl @@ -8,7 +8,7 @@ Send messages to your bot with optional file uploads. ### Endpoint ``` -POST http://localhost:{{GATEWAY_PORT}}/api/messaging/send +POST http://localhost:{{GATEWAY_PORT}}/api/v1/agents/{agentId}/messages ``` ### Authentication @@ -24,23 +24,23 @@ The bot token must be provided in the `Authorization` header, not in the request #### JSON Request (Simple Message) ```bash -curl -X POST http://localhost:{{GATEWAY_PORT}}/api/messaging/send \ +curl -X POST http://localhost:{{GATEWAY_PORT}}/api/v1/agents/{agentId}/messages \ -H "Authorization: Bearer xoxb-your-bot-token" \ -H "Content-Type: application/json" \ -d '{ "platform": "slack", "channel": "general", - "message": "what is 2+2?" + "content": "what is 2+2?" }' ``` #### Multipart Request (With File Upload) ```bash -curl -X POST http://localhost:{{GATEWAY_PORT}}/api/messaging/send \ +curl -X POST http://localhost:{{GATEWAY_PORT}}/api/v1/agents/{agentId}/messages \ -H "Authorization: Bearer xoxb-your-bot-token" \ -F "platform=slack" \ -F "channel=C12345678" \ - -F "message=please review this file" \ + -F "content=please review this file" \ -F "file=@/path/to/document.pdf" ``` @@ -50,7 +50,7 @@ curl -X POST http://localhost:{{GATEWAY_PORT}}/api/messaging/send \ |-------|----------|-------------| | `platform` | Yes | Platform name (currently: "slack") | | `channel` | Yes | Channel ID (e.g., `C12345678`) or name (e.g., `general`, `#general`) | -| `message` | Yes | Message text to send (use `@me` to mention the bot) | +| `content` | Yes | Message text to send (use `@me` to mention the bot) | | `threadId` | No | Thread ID to reply to (for thread continuity) | | `files` | No | File attachments (multipart/form-data, up to 10 files) | @@ -76,7 +76,7 @@ Use the `@me` placeholder to mention the bot in a platform-agnostic way: ```json { - "message": "@me what is 2+2?" + "content": "@me what is 2+2?" } ``` @@ -90,39 +90,39 @@ If you don't want to mention the bot, simply omit `@me` from your message. ### Example: Simple Text Message (with @me) ```bash -curl -X POST http://localhost:{{GATEWAY_PORT}}/api/messaging/send \ +curl -X POST http://localhost:{{GATEWAY_PORT}}/api/v1/agents/{agentId}/messages \ -H "Authorization: Bearer xoxb-your-bot-token" \ -H "Content-Type: application/json" \ -d '{ "platform": "slack", "channel": "general", - "message": "@me what is 2+2?" + "content": "@me what is 2+2?" }' ``` ### Example: Without Bot Mention ```bash -curl -X POST http://localhost:{{GATEWAY_PORT}}/api/messaging/send \ +curl -X POST http://localhost:{{GATEWAY_PORT}}/api/v1/agents/{agentId}/messages \ -H "Authorization: Bearer xoxb-your-bot-token" \ -H "Content-Type: application/json" \ -d '{ "platform": "slack", "channel": "general", - "message": "just a regular message" + "content": "just a regular message" }' ``` ### Example: Thread Reply ```bash -curl -X POST http://localhost:{{GATEWAY_PORT}}/api/messaging/send \ +curl -X POST http://localhost:{{GATEWAY_PORT}}/api/v1/agents/{agentId}/messages \ -H "Authorization: Bearer xoxb-your-bot-token" \ -H "Content-Type: application/json" \ -d '{ "platform": "slack", "channel": "C12345678", - "message": "tell me more about that", + "content": "tell me more about that", "threadId": "1234567890.123456" }' ``` @@ -130,22 +130,22 @@ curl -X POST http://localhost:{{GATEWAY_PORT}}/api/messaging/send \ ### Example: Single File Upload ```bash -curl -X POST http://localhost:{{GATEWAY_PORT}}/api/messaging/send \ +curl -X POST http://localhost:{{GATEWAY_PORT}}/api/v1/agents/{agentId}/messages \ -H "Authorization: Bearer xoxb-your-bot-token" \ -F "platform=slack" \ -F "channel=dev-channel" \ - -F "message=@me analyze this CSV" \ + -F "content=@me analyze this CSV" \ -F "files=@data.csv" ``` ### Example: Multiple File Upload ```bash -curl -X POST http://localhost:{{GATEWAY_PORT}}/api/messaging/send \ +curl -X POST http://localhost:{{GATEWAY_PORT}}/api/v1/agents/{agentId}/messages \ -H "Authorization: Bearer xoxb-your-bot-token" \ -F "platform=slack" \ -F "channel=dev-channel" \ - -F "message=@me review these documents" \ + -F "content=@me review these documents" \ -F "files=@document1.pdf" \ -F "files=@document2.pdf" \ -F "files=@spreadsheet.xlsx" @@ -196,13 +196,13 @@ Testing a full conversation: ```bash # Step 1: Send initial message -RESPONSE=$(curl -s -X POST http://localhost:{{GATEWAY_PORT}}/api/messaging/send \ +RESPONSE=$(curl -s -X POST http://localhost:{{GATEWAY_PORT}}/api/v1/agents/{agentId}/messages \ -H "Authorization: Bearer $SLACK_BOT_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "platform": "slack", "channel": "test-channel", - "message": "@me give me three options" + "content": "@me give me three options" }') THREAD_ID=$(echo $RESPONSE | jq -r '.threadId') diff --git a/packages/cli/src/utils/config.ts b/packages/cli/src/utils/config.ts deleted file mode 100644 index eb7a5b758..000000000 --- a/packages/cli/src/utils/config.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { constants } from "node:fs"; -import { access } from "node:fs/promises"; -import { join } from "node:path"; - -export async function checkConfigExists(cwd: string): Promise { - try { - await access(join(cwd, ".lobu"), constants.F_OK); - return true; - } catch { - return false; - } -} diff --git a/packages/core/src/agent-policy.ts b/packages/core/src/agent-policy.ts index f9118a1d9..925f57d55 100644 --- a/packages/core/src/agent-policy.ts +++ b/packages/core/src/agent-policy.ts @@ -29,22 +29,6 @@ export const CUSTOM_TOOL_METADATA: Record = { description: "List all pending reminders you have scheduled. Shows upcoming reminders with their schedule IDs and remaining time.", }, - SearchSkills: { - description: - "Search for installable skills and MCP servers, or list installed capabilities. Pass a query to search available capabilities. Pass an empty query to list all installed skills and MCP servers.", - }, - InstallSkill: { - description: - "Install or upgrade a skill or MCP server. Pass the id from SearchSkills results.", - }, - InstallPackage: { - description: - "Request installation of system packages (nix). Sends approval buttons to the user. Stop and wait for approval after calling.", - }, - RequestNetworkAccess: { - description: - "Request access to blocked domains. Sends inline approval buttons to the user. Stop and wait for approval after calling. Do NOT retry blocked requests — the domain is blocked at the network level.", - }, GenerateImage: { description: "Generate an image from a text prompt and send it to the user. Use when the user asks for image generation, visual concepts, posters, illustrations, or edits that can be done from prompt instructions.", @@ -136,39 +120,6 @@ export const TOOL_INTENT_RULES: ToolIntentRule[] = [ /\bevery\s+\d+\s*(minute|minutes|hour|hours|day|days|week|weeks)\b/i, ], }, - { - id: "package-installation", - title: "System Package Installation", - tools: ["InstallPackage"], - instructionLines: [ - "If the user asks to install or update a system package, your first action must be InstallPackage.", - "Do not run apt, brew, nix, or similar package installation commands directly.", - "After calling InstallPackage, stop and wait for approval.", - ], - priority: 50, - patterns: [ - /\binstall\b.*\b(package|dependency|dependencies|ffmpeg|imagemagick|curl|git)\b/i, - /\b(upgrade|update|add)\b.*\b(package|dependency|dependencies)\b/i, - /\b(ffmpeg|imagemagick)\b/i, - /\b(apt|brew|nix(?:-shell)?|apk|yum)\b/i, - ], - }, - { - id: "network-access", - title: "Blocked Network Access", - tools: ["RequestNetworkAccess"], - instructionLines: [ - "If access to a domain is blocked or the user explicitly asks for network/domain access, use RequestNetworkAccess.", - "Do not keep retrying blocked requests after a proxy denial.", - "After calling RequestNetworkAccess, stop and wait for approval.", - ], - priority: 60, - patterns: [ - /\b(request|need|grant|allow|whitelist|allowlist|unblock)\b.*\b(domain|network|access)\b/i, - /\bblocked domain\b/i, - /\bnetwork access\b/i, - ], - }, { id: "image-generation", title: "Image Generation", @@ -183,19 +134,6 @@ export const TOOL_INTENT_RULES: ToolIntentRule[] = [ /\b(image|illustration|poster|logo|picture|photo|icon)\b.*\b(generate|create|make|draw|edit|design)\b/i, ], }, - { - id: "skills-discovery", - title: "Skill and MCP Discovery", - tools: ["SearchSkills", "InstallSkill"], - instructionLines: [ - "If the user asks about adding capabilities, finding skills, or installing an MCP server, search with SearchSkills first.", - "Use InstallSkill only after you have an id from SearchSkills.", - ], - priority: 80, - patterns: [ - /\b(search|find|install|add)\b.*\b(skill|skills|mcp|mcp server|capability|capabilities)\b/i, - ], - }, ]; export function getCustomToolDescription(name: string): string { @@ -257,13 +195,13 @@ export function renderDetectedToolIntentRules(prompt: string): string { export function buildUnconfiguredAgentNotice(settingsUrl?: string): string { const settingsHint = settingsUrl - ? ` Your settings page is: ${settingsUrl}` + ? ` Admin configuration URL: ${settingsUrl}` : ""; return `## Agent Configuration Notice Your identity, instructions, and user context (IDENTITY.md, SOUL.md, USER.md) are not configured yet. -To configure your soul, ask your admin to open the settings page and fill in the Instructions section.${settingsHint} +To configure your soul, ask your admin to update the agent instructions in the admin control plane.${settingsHint} Until configured, behave as a helpful, concise AI assistant.`; } diff --git a/packages/core/src/agent-store.ts b/packages/core/src/agent-store.ts index ac55ac4dc..af13ce7d8 100644 --- a/packages/core/src/agent-store.ts +++ b/packages/core/src/agent-store.ts @@ -1,9 +1,8 @@ /** * AgentStore — unified interface for agent configuration storage. * - * Replaces 6 separate Redis-backed stores with a single abstraction. * Implementations: - * - RedisAgentStore (CLI mode, seeded from lobu.toml) + * - InMemoryAgentStore (default, populated from files or API) * - Host-provided store (embedded mode, e.g. PostgresAgentStore in Owletto) */ @@ -14,7 +13,6 @@ import type { McpServerConfig, NetworkConfig, NixConfig, - RegistryEntry, SkillsConfig, ToolsConfig, } from "./types"; @@ -37,7 +35,6 @@ export interface AgentSettings { pluginsConfig?: PluginsConfig; authProfiles?: AuthProfile[]; installedProviders?: InstalledProvider[]; - skillRegistries?: RegistryEntry[]; verboseLogging?: boolean; templateAgentId?: string; updatedAt: number; @@ -125,6 +122,26 @@ export interface AgentConfigStore { listSandboxes(connectionId: string): Promise; } +/** + * Find the first non-sandbox agent with installed providers configured. + * Used to pick a default template agent when creating ephemeral/API agents. + */ +export async function findTemplateAgentId( + store: Pick +): Promise { + const agents = await store.listAgents(); + + for (const agent of agents) { + if (agent.parentConnectionId) continue; + const settings = await store.getSettings(agent.agentId); + if (settings?.installedProviders?.length) { + return agent.agentId; + } + } + + return null; +} + /** * Platform wiring storage. * Connections (Telegram, Slack, etc.) + channel bindings. @@ -195,7 +212,7 @@ export interface AgentAccessStore { /** * Full storage interface — intersection of all sub-stores. - * Implementations (RedisAgentStore, PostgresAgentStore) satisfy all 3. + * Implementations (InMemoryAgentStore, etc.) satisfy all 3. * Hosts can provide individual sub-stores via GatewayOptions instead. */ export type AgentStore = AgentConfigStore & diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5a0d017fa..50750fd08 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -16,6 +16,7 @@ export type { Grant, StoredConnection, } from "./agent-store"; +export { findTemplateAgentId } from "./agent-store"; export type { CommandContext, CommandDefinition } from "./command-registry"; // Command registry export { CommandRegistry } from "./command-registry"; @@ -94,15 +95,6 @@ export type { UserSuggestion, } from "./types"; -// Platform constants -export const SUPPORTED_PLATFORMS = [ - "telegram", - "slack", - "discord", - "whatsapp", - "teams", -] as const; -export type SupportedPlatform = (typeof SUPPORTED_PLATFORMS)[number]; // Agent Settings API response types (for UI consumers) export type { diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d4ac070c3..266a6533d 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -137,8 +137,12 @@ export interface SkillConfig { mcpServers?: SkillMcpServer[]; /** System packages declared by the skill (nix) */ nixPackages?: string[]; - /** Network domains the skill needs access to */ + /** Network domains the skill needs access to (legacy flat list) */ permissions?: string[]; + /** Network access policy declared by the skill */ + networkConfig?: { allowedDomains?: string[]; deniedDomains?: string[] }; + /** Tool permission policy declared by the skill */ + toolPermissions?: { allow?: string[]; deny?: string[] }; /** AI providers the skill requires */ providers?: string[]; /** Preferred model for this skill (e.g., "anthropic/claude-opus-4") */ diff --git a/packages/gateway/package.json b/packages/gateway/package.json index e87e63da5..263cd204b 100644 --- a/packages/gateway/package.json +++ b/packages/gateway/package.json @@ -13,15 +13,15 @@ } }, "scripts": { - "generate:css": "bun run scripts/generate-css.ts", - "build": "bun run generate:css && bun run scripts/build-history.ts && bun run scripts/build-agent.ts && bun run scripts/build-agents.ts && tsc", - "dev": "bun run generate:css && bun run scripts/build-history.ts && bun run scripts/build-agent.ts && bun run scripts/build-agents.ts && DEPLOYMENT_MODE=docker bun --watch src/index.ts -- --env ../../.env", + "build": "tsc", + "dev": "DEPLOYMENT_MODE=docker bun --watch src/index.ts -- --env ../../.env", "start": "cd ../core && bun run build && cd ../gateway && bun src/index.ts", "test": "bun test", "typecheck": "tsc --noEmit" }, "dependencies": { "@chat-adapter/discord": "^4", + "@chat-adapter/gchat": "^4", "@chat-adapter/slack": "^4", "@chat-adapter/state-ioredis": "^4", "@chat-adapter/teams": "^4", @@ -41,23 +41,13 @@ "dotenv": "^17.2.1", "hono": "^4.11.7", "ioredis": "^5.4.1", - "jose": "^6.0.11", - "jsonwebtoken": "^9.0.2", - "marked": "^12.0.0", - "pino": "^9.1.0", + "smol-toml": "^1.3.1", "yaml": "^2.7.1", "zod": "^4.1.12" }, "devDependencies": { - "@preact/signals": "^2.0.0", - "@tailwindcss/typography": "^0.5.19", - "@tanstack/react-virtual": "^3.11.2", "@types/dockerode": "^3.3.29", - "@types/express": "^4.17.21", - "@types/jsonwebtoken": "^9.0.10", "@types/node": "^20.0.0", - "esbuild": "^0.24.0", - "preact": "^10.25.0", "typescript": "^5.8.3" } } diff --git a/packages/gateway/scripts/build-agent.ts b/packages/gateway/scripts/build-agent.ts deleted file mode 100644 index 407f8fa9a..000000000 --- a/packages/gateway/scripts/build-agent.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { build } from "esbuild"; - -const gatewayDir = resolve(import.meta.dir, ".."); - -async function main() { - const result = await build({ - entryPoints: [resolve(gatewayDir, "src/routes/public/agent-page/app.tsx")], - bundle: true, - minify: true, - format: "esm", - target: ["es2020"], - write: false, - jsx: "automatic", - jsxImportSource: "preact", - alias: { - react: "preact/compat", - "react-dom": "preact/compat", - }, - }); - - const js = result.outputFiles?.[0]?.text || ""; - - // Escape for embedding in a JS template literal - const escaped = js - .replace(/\\/g, "\\\\") - .replace(/`/g, "\\`") - .replace(/\$\{/g, "\\${"); - - const output = `/** - * Auto-generated Preact bundle for the agent page. - * DO NOT EDIT — regenerated on every build. - */ -export const agentPageJS = \`${escaped}\`; -`; - - writeFileSync( - resolve(gatewayDir, "src/routes/public/agent-page-bundle.ts"), - output - ); - console.log("Agent page JS bundle generated"); -} - -main().catch((err) => { - console.error("Failed to build agent page:", err); - process.exit(1); -}); diff --git a/packages/gateway/scripts/build-agents.ts b/packages/gateway/scripts/build-agents.ts deleted file mode 100644 index 56e95460e..000000000 --- a/packages/gateway/scripts/build-agents.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { build } from "esbuild"; - -const gatewayDir = resolve(import.meta.dir, ".."); - -async function main() { - const result = await build({ - entryPoints: [resolve(gatewayDir, "src/routes/public/agents-page/app.tsx")], - bundle: true, - minify: true, - format: "esm", - target: ["es2020"], - write: false, - jsx: "automatic", - jsxImportSource: "preact", - alias: { - react: "preact/compat", - "react-dom": "preact/compat", - }, - }); - - const js = result.outputFiles?.[0]?.text || ""; - - // Also write raw JS for fs.readFileSync usage (avoids bun require cache) - writeFileSync( - resolve(gatewayDir, "src/routes/public/agents-page-bundle.raw.js"), - js - ); - console.log("Agents page JS bundle generated"); -} - -main().catch((err) => { - console.error("Failed to build agents page:", err); - process.exit(1); -}); diff --git a/packages/gateway/scripts/build-history.ts b/packages/gateway/scripts/build-history.ts deleted file mode 100644 index dbdc1ba62..000000000 --- a/packages/gateway/scripts/build-history.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { writeFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { build } from "esbuild"; - -const gatewayDir = resolve(import.meta.dir, ".."); - -async function main() { - const result = await build({ - entryPoints: [ - resolve(gatewayDir, "src/routes/public/history-page/app.tsx"), - ], - bundle: true, - minify: true, - format: "esm", - target: ["es2020"], - write: false, - jsx: "automatic", - jsxImportSource: "preact", - alias: { - react: "preact/compat", - "react-dom": "preact/compat", - }, - }); - - const js = result.outputFiles?.[0]?.text || ""; - - // Escape for embedding in a JS template literal - const escaped = js - .replace(/\\/g, "\\\\") - .replace(/`/g, "\\`") - .replace(/\$\{/g, "\\${"); - - const output = `/** - * Auto-generated Preact bundle for the history page. - * DO NOT EDIT — regenerated on every build. - */ -export const historyPageJS = \`${escaped}\`; -`; - - writeFileSync( - resolve(gatewayDir, "src/routes/public/history-page-bundle.ts"), - output - ); - console.log("History page JS bundle generated"); -} - -main().catch((err) => { - console.error("Failed to build history page:", err); - process.exit(1); -}); diff --git a/packages/gateway/scripts/generate-css.ts b/packages/gateway/scripts/generate-css.ts deleted file mode 100644 index 1636baa21..000000000 --- a/packages/gateway/scripts/generate-css.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { execSync } from "node:child_process"; -import { writeFileSync } from "node:fs"; -import { resolve } from "node:path"; - -const gatewayDir = resolve(import.meta.dir, ".."); -const css = execSync( - "bunx tailwindcss@3 -c tailwind.config.js -i src/routes/public/settings-input.css --minify", - { cwd: gatewayDir, encoding: "utf-8" } -).trim(); - -// Escape for embedding in a JS template literal: -// - backslashes (e.g. Tailwind's `.w-3\.5` selector) must be doubled -// - backticks and ${ must be escaped -const escaped = css - .replace(/\\/g, "\\\\") - .replace(/`/g, "\\`") - .replace(/\$\{/g, "\\${"); - -const output = `/** - * Auto-generated Tailwind CSS for all pages. - * DO NOT EDIT — regenerated on every build/dev start. - */ -export const pageCSS = \` -${escaped} -[x-cloak] { display: none !important; }\`; -`; - -writeFileSync(resolve(gatewayDir, "src/routes/public/page-styles.ts"), output); -console.log("Page CSS generated"); diff --git a/packages/gateway/src/__tests__/agent-config-routes.test.ts b/packages/gateway/src/__tests__/agent-config-routes.test.ts index f8c88d6e1..960836836 100644 --- a/packages/gateway/src/__tests__/agent-config-routes.test.ts +++ b/packages/gateway/src/__tests__/agent-config-routes.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { OpenAPIHono } from "@hono/zod-openapi"; +import { encrypt } from "@lobu/core"; import { MockRedisClient } from "@lobu/core/testing"; import { AgentMetadataStore } from "../auth/agent-metadata-store"; import { AgentSettingsStore } from "../auth/settings/agent-settings-store"; @@ -8,12 +9,16 @@ import { createAgentConfigRoutes } from "../routes/public/agent-config"; import { setAuthProvider } from "../routes/public/settings-auth"; describe("agent config routes", () => { + let originalEncryptionKey: string | undefined; let redis: MockRedisClient; let agentSettingsStore: AgentSettingsStore; let agentMetadataStore: AgentMetadataStore; let grantStore: GrantStore; beforeEach(async () => { + originalEncryptionKey = process.env.ENCRYPTION_KEY; + process.env.ENCRYPTION_KEY = + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"; redis = new MockRedisClient(); agentSettingsStore = new AgentSettingsStore(redis as any); agentMetadataStore = new AgentMetadataStore(redis as any); @@ -51,6 +56,11 @@ describe("agent config routes", () => { }); afterEach(() => { + if (originalEncryptionKey !== undefined) { + process.env.ENCRYPTION_KEY = originalEncryptionKey; + } else { + delete process.env.ENCRYPTION_KEY; + } setAuthProvider(null); }); @@ -80,7 +90,12 @@ describe("agent config routes", () => { "/api/v1/agents/:agentId/config", createAgentConfigRoutes({ agentSettingsStore, - agentMetadataStore, + agentConfigStore: { + getSettings: (agentId: string) => + agentSettingsStore.getSettings(agentId), + getMetadata: (agentId: string) => + agentMetadataStore.getMetadata(agentId), + }, grantStore, scheduledWakeupService: scheduledWakeupService as any, }) @@ -125,33 +140,115 @@ describe("agent config routes", () => { expect(data.tools.schedules[0]?.scheduleId).toBe("schedule-1"); }); - test("POST /reset-section clears sandbox overrides and restores inheritance", async () => { + test("GET /config accepts direct query token auth", async () => { + const app = buildApp(); + const token = encrypt( + JSON.stringify({ + agentId: "telegram-1", + userId: "u1", + platform: "telegram", + exp: Date.now() + 60_000, + settingsMode: "user", + allowedScopes: ["view-model", "system-prompt", "permissions"], + }) + ); + + const response = await app.request( + `/api/v1/agents/telegram-1/config?token=${encodeURIComponent(token)}` + ); + + expect(response.status).toBe(200); + const data = (await response.json()) as any; + expect(data.agentId).toBe("telegram-1"); + expect(data.scope).toBe("sandbox"); + }); + + test("GET /config keeps exact agent tokens read-only when settingsMode is missing", async () => { + const app = buildApp(); + const token = encrypt( + JSON.stringify({ + agentId: "telegram-1", + userId: "u1", + platform: "telegram", + exp: Date.now() + 60_000, + }) + ); + + const response = await app.request( + `/api/v1/agents/telegram-1/config?token=${encodeURIComponent(token)}` + ); + + expect(response.status).toBe(200); + const data = (await response.json()) as any; + expect(data.sections.model.editable).toBe(false); + expect(data.sections["system-prompt"].editable).toBe(false); + }); + + test("GET /config rejects direct query token for the wrong agent", async () => { + const app = buildApp(); + const token = encrypt( + JSON.stringify({ + agentId: "template-agent", + userId: "u1", + platform: "telegram", + exp: Date.now() + 60_000, + settingsMode: "user", + }) + ); + + const response = await app.request( + `/api/v1/agents/telegram-1/config?token=${encodeURIComponent(token)}` + ); + + expect(response.status).toBe(401); + }); + + test("GET /config reads effective settings from the settings store", async () => { setAuthProvider(() => ({ agentId: "telegram-1", userId: "u1", platform: "telegram", exp: Date.now() + 60_000, settingsMode: "user", - allowedScopes: ["system-prompt"], + allowedScopes: ["view-model", "system-prompt"], })); - const app = buildApp(); - const response = await app.request( - "/api/v1/agents/telegram-1/config/reset-section", - { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ section: "system-prompt" }), - } + const app = new OpenAPIHono(); + app.route( + "/api/v1/agents/:agentId/config", + createAgentConfigRoutes({ + agentSettingsStore, + agentConfigStore: { + getSettings: async () => null, + getMetadata: (agentId: string) => + agentMetadataStore.getMetadata(agentId), + }, + }) ); + + const response = await app.request("/api/v1/agents/telegram-1/config"); + expect(response.status).toBe(200); + const data = (await response.json()) as any; + expect(data.instructions.identity).toBe("Local identity"); + expect(data.instructions.soul).toBe("Template soul"); + expect(data.providers.order).toEqual(["chatgpt"]); + expect(data.templateAgentId).toBe("template-agent"); + }); - const localSettings = await agentSettingsStore.getSettings("telegram-1"); - const effectiveSettings = - await agentSettingsStore.getEffectiveSettings("telegram-1"); + test("GET /config grants owners full access even when browser session has no settingsMode", async () => { + setAuthProvider(() => ({ + userId: "u1", + platform: "telegram", + exp: Date.now() + 60_000, + })); - expect(localSettings?.identityMd).toBeUndefined(); - expect(effectiveSettings?.identityMd).toBe("Template identity"); - expect(effectiveSettings?.soulMd).toBe("Template soul"); + const app = buildApp(); + const response = await app.request("/api/v1/agents/telegram-1/config"); + + expect(response.status).toBe(200); + const data = (await response.json()) as any; + expect(data.sections.model.editable).toBe(true); + expect(data.sections["system-prompt"].editable).toBe(true); }); }); diff --git a/packages/gateway/src/__tests__/agent-history-routes.test.ts b/packages/gateway/src/__tests__/agent-history-routes.test.ts new file mode 100644 index 000000000..1d235e551 --- /dev/null +++ b/packages/gateway/src/__tests__/agent-history-routes.test.ts @@ -0,0 +1,64 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { Hono } from "hono"; +import { MockRedisClient } from "@lobu/core/testing"; +import { AgentMetadataStore } from "../auth/agent-metadata-store"; +import { UserAgentsStore } from "../auth/user-agents-store"; +import { createAgentHistoryRoutes } from "../routes/public/agent-history"; +import { setAuthProvider } from "../routes/public/settings-auth"; + +describe("agent history routes", () => { + let redis: MockRedisClient; + let agentMetadataStore: AgentMetadataStore; + let userAgentsStore: UserAgentsStore; + + beforeEach(async () => { + redis = new MockRedisClient(); + agentMetadataStore = new AgentMetadataStore(redis as any); + userAgentsStore = new UserAgentsStore(redis as any); + + await agentMetadataStore.createAgent("agent-1", "Agent 1", "external", "u1"); + await userAgentsStore.addAgent("external", "u1", "agent-1"); + }); + + afterEach(() => { + setAuthProvider(null); + }); + + test("rejects sessions that do not own the requested agent", async () => { + setAuthProvider(() => ({ + userId: "u2", + platform: "external", + exp: Date.now() + 60_000, + })); + + const app = new Hono(); + app.route( + "/api/v1/agents/:agentId/history", + createAgentHistoryRoutes({ + connectionManager: { + getDeploymentsForAgent() { + return []; + }, + getHttpUrl() { + return null; + }, + } as any, + agentConfigStore: { + getMetadata: (agentId: string) => + agentMetadataStore.getMetadata(agentId), + listSandboxes: async () => [], + }, + userAgentsStore, + }) + ); + + const response = await app.request("/api/v1/agents/agent-1/history/status", { + headers: { + host: "localhost", + }, + method: "GET", + }); + + expect(response.status).toBe(401); + }); +}); diff --git a/packages/gateway/src/__tests__/agent-routes.test.ts b/packages/gateway/src/__tests__/agent-routes.test.ts new file mode 100644 index 000000000..c3c2258a7 --- /dev/null +++ b/packages/gateway/src/__tests__/agent-routes.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { MockRedisClient } from "@lobu/core/testing"; +import { AgentMetadataStore } from "../auth/agent-metadata-store"; +import { AgentSettingsStore } from "../auth/settings/agent-settings-store"; +import { UserAgentsStore } from "../auth/user-agents-store"; +import { createAgentRoutes } from "../routes/public/agents"; +import { setAuthProvider } from "../routes/public/settings-auth"; + +describe("agent routes", () => { + let redis: MockRedisClient; + let agentMetadataStore: AgentMetadataStore; + let agentSettingsStore: AgentSettingsStore; + let userAgentsStore: UserAgentsStore; + + beforeEach(async () => { + redis = new MockRedisClient(); + agentMetadataStore = new AgentMetadataStore(redis as any); + agentSettingsStore = new AgentSettingsStore(redis as any); + userAgentsStore = new UserAgentsStore(redis as any); + + await agentMetadataStore.createAgent("agent-1", "Agent 1", "telegram", "u1"); + await userAgentsStore.addAgent("telegram", "u1", "agent-1"); + }); + + afterEach(() => { + setAuthProvider(null); + }); + + test("lists agents for external browser sessions by owner userId", async () => { + setAuthProvider(() => ({ + userId: "u1", + oauthUserId: "u1", + platform: "external", + exp: Date.now() + 60_000, + })); + + const app = createAgentRoutes({ + userAgentsStore, + agentMetadataStore, + agentSettingsStore, + channelBindingService: { + async getBinding() { + return null; + }, + async createBinding() { + return true; + }, + async listBindings() { + return []; + }, + async deleteAllBindings() { + return 0; + }, + } as any, + }); + + const response = await app.request("/"); + expect(response.status).toBe(200); + const data = (await response.json()) as any; + expect(data.agents).toHaveLength(1); + expect(data.agents[0]?.agentId).toBe("agent-1"); + }); +}); diff --git a/packages/gateway/src/__tests__/agent-schedules-routes.test.ts b/packages/gateway/src/__tests__/agent-schedules-routes.test.ts new file mode 100644 index 000000000..ac2c4a1d5 --- /dev/null +++ b/packages/gateway/src/__tests__/agent-schedules-routes.test.ts @@ -0,0 +1,54 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { OpenAPIHono } from "@hono/zod-openapi"; +import { MockRedisClient } from "@lobu/core/testing"; +import { AgentMetadataStore } from "../auth/agent-metadata-store"; +import { UserAgentsStore } from "../auth/user-agents-store"; +import { createAgentSchedulesRoutes } from "../routes/public/agent-schedules"; +import { setAuthProvider } from "../routes/public/settings-auth"; + +describe("agent schedules routes", () => { + let redis: MockRedisClient; + let agentMetadataStore: AgentMetadataStore; + let userAgentsStore: UserAgentsStore; + + beforeEach(async () => { + redis = new MockRedisClient(); + agentMetadataStore = new AgentMetadataStore(redis as any); + userAgentsStore = new UserAgentsStore(redis as any); + + await agentMetadataStore.createAgent("agent-1", "Agent 1", "external", "u1"); + await userAgentsStore.addAgent("external", "u1", "agent-1"); + }); + + afterEach(() => { + setAuthProvider(null); + }); + + test("rejects neutral sessions that do not own the agent", async () => { + setAuthProvider(() => ({ + userId: "u2", + platform: "external", + exp: Date.now() + 60_000, + })); + + const app = new OpenAPIHono(); + app.route( + "/api/v1/agents/:agentId/schedules", + createAgentSchedulesRoutes({ + scheduledWakeupService: { + async listPendingForAgent() { + return []; + }, + } as any, + userAgentsStore, + agentMetadataStore: { + getMetadata: (agentId: string) => + agentMetadataStore.getMetadata(agentId), + }, + }) + ); + + const response = await app.request("/api/v1/agents/agent-1/schedules"); + expect(response.status).toBe(401); + }); +}); diff --git a/packages/gateway/src/__tests__/chat-response-bridge.test.ts b/packages/gateway/src/__tests__/chat-response-bridge.test.ts index 85501e09f..600d7ab3b 100644 --- a/packages/gateway/src/__tests__/chat-response-bridge.test.ts +++ b/packages/gateway/src/__tests__/chat-response-bridge.test.ts @@ -57,7 +57,7 @@ describe("ChatResponseBridge.handleEphemeral", () => { chatId: "123", }, content: - "Setup required: add OpenAI in settings before this bot can respond.\n\n[Open Agent Settings](https://example.com/agent?claim=abc123)", + "Setup required: add OpenAI in settings before this bot can respond.\n\n[Open Agent Settings](https://example.com/connect/claim?claim=abc123)", }); expect(posts).toHaveLength(1); @@ -65,7 +65,7 @@ describe("ChatResponseBridge.handleEphemeral", () => { expect(posts[0]).toHaveProperty("card"); expect(posts[0]).toHaveProperty("fallbackText"); expect((posts[0] as { fallbackText: string }).fallbackText).toContain( - "Open Agent Settings: https://example.com/agent?claim=abc123" + "Open Agent Settings: https://example.com/connect/claim?claim=abc123" ); }); diff --git a/packages/gateway/src/__tests__/config-request-store.test.ts b/packages/gateway/src/__tests__/config-request-store.test.ts new file mode 100644 index 000000000..0ffd63876 --- /dev/null +++ b/packages/gateway/src/__tests__/config-request-store.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, mock, test } from "bun:test"; +import { + applyPendingConfigRequest, + buildConfigRequestText, +} from "../interactions/config-request-store"; + +describe("config-request-store", () => { + test("applies skills, mcps, nix packages, and grants", async () => { + const updateSettings = mock(() => Promise.resolve()); + const grant = mock(() => Promise.resolve()); + const agentSettingsStore = { + getSettings: mock(() => + Promise.resolve({ + nixConfig: { packages: ["git"] }, + skillsConfig: { + skills: [ + { + repo: "existing-skill", + name: "Existing Skill", + description: "Existing", + enabled: false, + }, + ], + }, + mcpServers: { + existing: { enabled: true, url: "https://existing.example.com" }, + }, + }) + ), + updateSettings, + }; + const grantStore = { grant }; + + await applyPendingConfigRequest( + agentSettingsStore as any, + grantStore as any, + { + agentId: "agent-1", + reason: "Install requested skill", + skills: [ + { + repo: "existing-skill", + name: "Existing Skill", + description: "Updated", + }, + { + repo: "new-skill", + name: "New Skill", + description: "New", + }, + ], + mcpServers: [ + { + id: "new-mcp", + name: "New MCP", + url: "https://mcp.example.com", + type: "sse", + }, + ], + nixPackages: ["git", "ffmpeg"], + grants: ["api.example.com"], + } + ); + + expect(updateSettings).toHaveBeenCalledTimes(1); + const [, updates] = updateSettings.mock.calls[0]!; + expect(updates.skillsConfig.skills).toHaveLength(2); + expect( + updates.skillsConfig.skills.find( + (skill: any) => skill.repo === "existing-skill" + ).enabled + ).toBe(true); + expect( + updates.skillsConfig.skills.find( + (skill: any) => skill.repo === "new-skill" + ).name + ).toBe("New Skill"); + expect(updates.mcpServers["new-mcp"]).toMatchObject({ + enabled: true, + url: "https://mcp.example.com", + type: "sse", + }); + expect(updates.nixConfig.packages).toEqual(["git", "ffmpeg"]); + expect(grant).toHaveBeenCalledWith( + "agent-1", + "api.example.com", + null, + undefined + ); + }); + + test("skips settings writes when a request only grants permissions", async () => { + const updateSettings = mock(() => Promise.resolve()); + const grant = mock(() => Promise.resolve()); + const agentSettingsStore = { + getSettings: mock(() => Promise.resolve({})), + updateSettings, + }; + const grantStore = { grant }; + + await applyPendingConfigRequest( + agentSettingsStore as any, + grantStore as any, + { + agentId: "agent-1", + reason: "Allow API access", + grants: ["api.example.com"], + } + ); + + expect(updateSettings).not.toHaveBeenCalled(); + expect(grant).toHaveBeenCalledWith( + "agent-1", + "api.example.com", + null, + undefined + ); + }); + + test("builds readable config request text", () => { + const text = buildConfigRequestText({ + agentId: "agent-1", + reason: "Install skill", + message: "Needed for triage", + skills: [{ repo: "ops-triage", name: "Ops Triage" }], + nixPackages: ["ffmpeg"], + grants: ["api.example.com"], + providers: ["openai"], + }); + + expect(text).toContain("Configuration Change Request"); + expect(text).toContain("Ops Triage"); + expect(text).toContain("ffmpeg"); + expect(text).toContain("api.example.com"); + expect(text).toContain("openai"); + }); +}); diff --git a/packages/gateway/src/__tests__/connection-routes.test.ts b/packages/gateway/src/__tests__/connection-routes.test.ts new file mode 100644 index 000000000..677f84451 --- /dev/null +++ b/packages/gateway/src/__tests__/connection-routes.test.ts @@ -0,0 +1,128 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { MockRedisClient } from "@lobu/core/testing"; +import { AgentMetadataStore } from "../auth/agent-metadata-store"; +import { UserAgentsStore } from "../auth/user-agents-store"; +import { createConnectionCrudRoutes } from "../routes/public/connections"; +import { setAuthProvider } from "../routes/public/settings-auth"; + +describe("connection routes", () => { + let redis: MockRedisClient; + let agentMetadataStore: AgentMetadataStore; + let userAgentsStore: UserAgentsStore; + + beforeEach(async () => { + redis = new MockRedisClient(); + agentMetadataStore = new AgentMetadataStore(redis as any); + userAgentsStore = new UserAgentsStore(redis as any); + + await agentMetadataStore.createAgent("agent-1", "Agent 1", "telegram", "u1"); + await agentMetadataStore.createAgent("sandbox-1", "Sandbox 1", "telegram", "u1", { + parentConnectionId: "conn-1", + }); + await userAgentsStore.addAgent("telegram", "u1", "agent-1"); + }); + + afterEach(() => { + setAuthProvider(null); + }); + + function buildApp() { + return createConnectionCrudRoutes( + { + async listConnections(filters?: any) { + const connection = { + id: "conn-1", + platform: "telegram", + templateAgentId: "agent-1", + config: { platform: "telegram" }, + settings: {}, + metadata: {}, + status: "active", + createdAt: 1, + updatedAt: 1, + }; + if (filters?.templateAgentId && filters.templateAgentId !== "agent-1") { + return []; + } + return [connection]; + }, + async getConnection(id: string) { + if (id !== "conn-1") return null; + return { + id: "conn-1", + platform: "telegram", + templateAgentId: "agent-1", + config: { platform: "telegram" }, + settings: {}, + metadata: {}, + status: "active", + createdAt: 1, + updatedAt: 1, + }; + }, + has() { + return true; + }, + getServices() { + return { + getQueue() { + return { + getRedisClient() { + return redis; + }, + }; + }, + }; + }, + } as any, + { + userAgentsStore, + agentMetadataStore: { + getMetadata: (agentId: string) => + agentMetadataStore.getMetadata(agentId), + listSandboxes: (connectionId: string) => + agentMetadataStore.listSandboxes(connectionId), + }, + } + ); + } + + test("forbids non-admin sessions from listing all connections", async () => { + setAuthProvider(() => ({ + userId: "u1", + platform: "telegram", + exp: Date.now() + 60_000, + })); + + const response = await buildApp().request("/api/v1/connections"); + expect(response.status).toBe(403); + }); + + test("allows external owner sessions to list connections for their agent", async () => { + setAuthProvider(() => ({ + userId: "u1", + oauthUserId: "u1", + platform: "external", + exp: Date.now() + 60_000, + })); + + const response = await buildApp().request( + "/api/v1/connections?templateAgentId=agent-1" + ); + expect(response.status).toBe(200); + const data = (await response.json()) as any; + expect(data.connections).toHaveLength(1); + expect(data.connections[0]?.id).toBe("conn-1"); + }); + + test("forbids sandbox listing when session cannot access the connection template agent", async () => { + setAuthProvider(() => ({ + userId: "u2", + platform: "telegram", + exp: Date.now() + 60_000, + })); + + const response = await buildApp().request("/api/v1/connections/conn-1/sandboxes"); + expect(response.status).toBe(403); + }); +}); diff --git a/packages/gateway/src/__tests__/instruction-service.test.ts b/packages/gateway/src/__tests__/instruction-service.test.ts index b66487d23..551a2987a 100644 --- a/packages/gateway/src/__tests__/instruction-service.test.ts +++ b/packages/gateway/src/__tests__/instruction-service.test.ts @@ -20,7 +20,7 @@ describe("InstructionService", () => { userId: "user-1", workingDirectory: "/workspace/thread-1", } as any, - { settingsUrl: "http://localhost:8080/agents/agent-1" } + { settingsUrl: "http://localhost:8080/api/v1/agents/agent-1/config" } ); expect(sessionContext.agentInstructions).toContain( diff --git a/packages/gateway/src/__tests__/link-buttons.test.ts b/packages/gateway/src/__tests__/link-buttons.test.ts index 7a5f692c0..6fafacae6 100644 --- a/packages/gateway/src/__tests__/link-buttons.test.ts +++ b/packages/gateway/src/__tests__/link-buttons.test.ts @@ -4,31 +4,33 @@ import { extractSettingsLinkButtons } from "../platform/link-buttons"; describe("extractSettingsLinkButtons", () => { test("extracts settings link and replaces with label", () => { const content = - "Click [Open Settings](https://example.com/agent?claim=abc123) to continue"; + "Click [Open Settings](https://example.com/connect/claim?claim=abc123) to continue"; const { processedContent, linkButtons } = extractSettingsLinkButtons(content); expect(linkButtons).toHaveLength(1); expect(linkButtons[0]!.text).toBe("Open Settings"); - expect(linkButtons[0]!.url).toBe("https://example.com/agent?claim=abc123"); + expect(linkButtons[0]!.url).toBe( + "https://example.com/connect/claim?claim=abc123" + ); expect(processedContent).toBe("Click Open Settings to continue"); expect(processedContent).not.toContain("https://"); }); test("extracts settings link with agent param", () => { const content = - "[Settings](https://example.com/agent?claim=abc&agent=agent-1)"; + "[Settings](https://example.com/connect/claim?claim=abc&agent=agent-1)"; const { linkButtons } = extractSettingsLinkButtons(content); expect(linkButtons).toHaveLength(1); expect(linkButtons[0]!.url).toBe( - "https://example.com/agent?claim=abc&agent=agent-1" + "https://example.com/connect/claim?claim=abc&agent=agent-1" ); }); test("extracts multiple settings links", () => { const content = - "[First](https://a.com/agent?claim=1) and [Second](https://b.com/agent?claim=2)"; + "[First](https://a.com/connect/claim?claim=1) and [Second](https://b.com/connect/claim?claim=2)"; const { processedContent, linkButtons } = extractSettingsLinkButtons(content); @@ -37,7 +39,8 @@ describe("extractSettingsLinkButtons", () => { }); test("filters out localhost URLs", () => { - const content = "[Settings](http://localhost:3000/agent?claim=token)"; + const content = + "[Settings](http://localhost:3000/connect/claim?claim=token)"; const { processedContent, linkButtons } = extractSettingsLinkButtons(content); @@ -47,7 +50,7 @@ describe("extractSettingsLinkButtons", () => { }); test("filters out 127.0.0.1 URLs", () => { - const content = "[Settings](http://127.0.0.1/agent?claim=token)"; + const content = "[Settings](http://127.0.0.1/connect/claim?claim=token)"; const { linkButtons } = extractSettingsLinkButtons(content); expect(linkButtons).toHaveLength(0); }); @@ -80,8 +83,8 @@ describe("extractSettingsLinkButtons", () => { }); test("handles HTTP and HTTPS", () => { - const httpContent = "[A](http://example.com/agent?claim=x)"; - const httpsContent = "[B](https://example.com/agent?claim=y)"; + const httpContent = "[A](http://example.com/connect/claim?claim=x)"; + const httpsContent = "[B](https://example.com/connect/claim?claim=y)"; const httpResult = extractSettingsLinkButtons(httpContent); const httpsResult = extractSettingsLinkButtons(httpsContent); @@ -92,10 +95,18 @@ describe("extractSettingsLinkButtons", () => { test("mixed localhost and remote links only keeps remote", () => { const content = - "[Local](http://localhost/agent?claim=a) and [Remote](https://app.com/agent?claim=b)"; + "[Local](http://localhost/connect/claim?claim=a) and [Remote](https://app.com/connect/claim?claim=b)"; const { linkButtons } = extractSettingsLinkButtons(content); expect(linkButtons).toHaveLength(1); expect(linkButtons[0]!.url).toContain("app.com"); }); + + test("keeps backward compatibility with legacy /agent claim links", () => { + const content = "[Legacy](https://example.com/agent?claim=legacy)"; + const { linkButtons } = extractSettingsLinkButtons(content); + + expect(linkButtons).toHaveLength(1); + expect(linkButtons[0]!.url).toBe("https://example.com/agent?claim=legacy"); + }); }); diff --git a/packages/gateway/src/__tests__/message-handler-bridge.test.ts b/packages/gateway/src/__tests__/message-handler-bridge.test.ts new file mode 100644 index 000000000..f0566209b --- /dev/null +++ b/packages/gateway/src/__tests__/message-handler-bridge.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, test } from "bun:test"; +import { isSenderAllowed } from "../connections/message-handler-bridge"; + +describe("isSenderAllowed", () => { + test("allows everyone when allowFrom is not configured", () => { + expect(isSenderAllowed(undefined, "user-1")).toBe(true); + }); + + test("blocks everyone when allowFrom is an empty array", () => { + expect(isSenderAllowed([], "user-1")).toBe(false); + }); + + test("only allows listed users when allowFrom is configured", () => { + expect(isSenderAllowed(["user-1"], "user-1")).toBe(true); + expect(isSenderAllowed(["user-1"], "user-2")).toBe(false); + }); +}); diff --git a/packages/gateway/src/__tests__/routes/cli-auth.test.ts b/packages/gateway/src/__tests__/routes/cli-auth.test.ts index 4273a6554..64012e098 100644 --- a/packages/gateway/src/__tests__/routes/cli-auth.test.ts +++ b/packages/gateway/src/__tests__/routes/cli-auth.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import { decrypt } from "@lobu/core"; import { MockRedisClient } from "../../../../core/src/__tests__/fixtures/mock-redis"; -import { createCliAuthRoutes } from "../../routes/public/cli-auth"; +import { + createCliAuthRoutes, + createConnectAuthRoutes, +} from "../../routes/public/cli-auth"; describe("cli auth routes", () => { let originalKey: string | undefined; @@ -75,6 +79,79 @@ describe("cli auth routes", () => { expect(body.loginUrl).toContain("/api/v1/auth/cli/session/login?request="); }); + test("GET /connect/oauth/login redirects into external browser auth", async () => { + const router = createConnectAuthRoutes({ + queue: queue as any, + externalAuthClient: { + generateCodeVerifier: () => "code-verifier", + buildAuthUrl: mock(async (state: string, codeVerifier: string) => { + expect(state).toBeTruthy(); + expect(codeVerifier).toBe("code-verifier"); + return "https://issuer.example.com/oauth/authorize"; + }), + } as any, + }); + + const res = await router.request( + "https://gateway.example.com/connect/oauth/login?returnUrl=%2Fdone" + ); + + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe( + "https://issuer.example.com/oauth/authorize" + ); + }); + + test("GET /connect/oauth/callback sets a settings session and redirects back", async () => { + await redis.setex( + "cli:auth:connect:state-123", + 600, + JSON.stringify({ + returnUrl: "/done", + codeVerifier: "code-verifier", + }) + ); + + const router = createConnectAuthRoutes({ + queue: queue as any, + externalAuthClient: { + exchangeCodeForToken: mock(async () => ({ + accessToken: "provider-access-token", + refreshToken: "provider-refresh-token", + tokenType: "Bearer", + expiresAt: Date.now() + 3600_000, + scopes: ["profile:read"], + })), + fetchUserInfo: mock(async () => ({ + sub: "user-123", + email: "user@example.com", + name: "Example User", + })), + } as any, + }); + + const res = await router.request( + "https://gateway.example.com/connect/oauth/callback?code=auth-code&state=state-123" + ); + + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("/done"); + expect(res.headers.get("set-cookie")).toContain("lobu_settings_session="); + + const setCookie = res.headers.get("set-cookie"); + const token = setCookie + ?.match(/lobu_settings_session=([^;]+)/)?.[1]; + expect(token).toBeTruthy(); + + const payload = JSON.parse( + decrypt(decodeURIComponent(token!)) + ) as Record; + expect(payload.userId).toBe("user-123"); + expect(payload.platform).toBe("external"); + expect(payload.isAdmin).toBeUndefined(); + expect(payload.settingsMode).toBeUndefined(); + }); + test("POST /cli/poll mints Lobu tokens after device auth completes", async () => { await redis.setex( "cli:auth:device:device-123", diff --git a/packages/gateway/src/__tests__/settings-oauth-client.test.ts b/packages/gateway/src/__tests__/settings-oauth-client.test.ts deleted file mode 100644 index 04ab66741..000000000 --- a/packages/gateway/src/__tests__/settings-oauth-client.test.ts +++ /dev/null @@ -1,358 +0,0 @@ -import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; -import { SettingsOAuthClient } from "../auth/settings/oauth-client"; - -describe("SettingsOAuthClient", () => { - const originalFetch = globalThis.fetch; - - beforeEach(() => { - mock.restore(); - }); - - afterEach(() => { - globalThis.fetch = originalFetch; - }); - - test("dynamically registers a client and requests device grant support when available", async () => { - const fetchMock = mock( - async (input: string | URL | Request, init?: RequestInit) => { - const url = String(input); - - if (url.endsWith("/.well-known/openid-configuration")) { - return new Response( - JSON.stringify({ - issuer: "https://issuer.example.com", - authorization_endpoint: - "https://issuer.example.com/oauth/authorize", - token_endpoint: "https://issuer.example.com/oauth/token", - registration_endpoint: - "https://issuer.example.com/oauth/register", - userinfo_endpoint: "https://issuer.example.com/oauth/userinfo", - device_authorization_endpoint: - "https://issuer.example.com/oauth/device_authorization", - grant_types_supported: [ - "authorization_code", - "refresh_token", - "urn:ietf:params:oauth:grant-type:device_code", - ], - token_endpoint_auth_methods_supported: ["none"], - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - if (url.endsWith("/oauth/register")) { - expect(init?.method).toBe("POST"); - const body = JSON.parse(String(init?.body)) as { - grant_types?: string[]; - token_endpoint_auth_method?: string; - }; - expect(body.grant_types).toContain( - "urn:ietf:params:oauth:grant-type:device_code" - ); - expect(body.token_endpoint_auth_method).toBe("none"); - - return new Response( - JSON.stringify({ - client_id: "dynamic-client-id", - token_endpoint_auth_method: "none", - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - throw new Error(`Unexpected fetch: ${url}`); - } - ); - - globalThis.fetch = fetchMock as typeof fetch; - - const cache = new Map(); - const client = new SettingsOAuthClient({ - issuerUrl: "https://issuer.example.com", - redirectUri: "https://gateway.example.com/agent/oauth/callback", - cacheStore: { - get: async (key) => cache.get(key) ?? null, - set: async (key, value) => { - cache.set(key, value); - }, - }, - }); - - const capabilities = await client.getCapabilities(); - expect(capabilities).toEqual({ browser: true, device: true }); - - const authUrl = await client.buildAuthUrl("state-123", "verifier-123"); - const parsed = new URL(authUrl); - - expect(parsed.origin + parsed.pathname).toBe( - "https://issuer.example.com/oauth/authorize" - ); - expect(parsed.searchParams.get("client_id")).toBe("dynamic-client-id"); - expect(parsed.searchParams.get("redirect_uri")).toBe( - "https://gateway.example.com/agent/oauth/callback" - ); - expect(cache.size).toBe(1); - // Discovery is cached in-memory after first call, so only 2 fetches: discovery + registration - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - test("reuses cached client credentials and falls back to browser-only when device flow is unavailable", async () => { - const fetchMock = mock(async (input: string | URL | Request) => { - const url = String(input); - if (url.endsWith("/.well-known/openid-configuration")) { - return new Response( - JSON.stringify({ - issuer: "https://issuer.example.com", - authorization_endpoint: - "https://issuer.example.com/oauth/authorize", - token_endpoint: "https://issuer.example.com/oauth/token", - registration_endpoint: "https://issuer.example.com/oauth/register", - userinfo_endpoint: "https://issuer.example.com/oauth/userinfo", - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - throw new Error(`Unexpected fetch: ${url}`); - }); - - globalThis.fetch = fetchMock as typeof fetch; - - const cache = new Map([ - [ - "external:auth:client:v3", - JSON.stringify({ - client_id: "cached-client-id", - token_endpoint_auth_method: "none", - }), - ], - ]); - - const client = new SettingsOAuthClient({ - issuerUrl: "https://issuer.example.com", - redirectUri: "https://gateway.example.com/agent/oauth/callback", - cacheStore: { - get: async (key) => cache.get(key) ?? null, - set: async () => { - throw new Error("should not write cache when already populated"); - }, - }, - }); - - const capabilities = await client.getCapabilities(); - expect(capabilities).toEqual({ browser: true, device: false }); - - const authUrl = await client.buildAuthUrl("state-123", "verifier-123"); - expect(new URL(authUrl).searchParams.get("client_id")).toBe( - "cached-client-id" - ); - // Discovery is cached in-memory after getCapabilities(), so buildAuthUrl() reuses it - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - test("prefers path-relative discovery for AUTH_MCP_URL-style issuers", async () => { - const fetchMock = mock(async (input: string | URL | Request) => { - const url = String(input); - expect(url).toBe( - "https://issuer.example.com/mcp/.well-known/openid-configuration" - ); - - return new Response( - JSON.stringify({ - authorization_endpoint: "https://issuer.example.com/oauth/authorize", - token_endpoint: "https://issuer.example.com/oauth/token", - userinfo_endpoint: "https://issuer.example.com/oauth/userinfo", - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - }); - - globalThis.fetch = fetchMock as typeof fetch; - - const client = new SettingsOAuthClient({ - issuerUrl: "https://issuer.example.com/mcp", - clientId: "static-client-id", - redirectUri: "https://gateway.example.com/agent/oauth/callback", - }); - - const capabilities = await client.getCapabilities(); - expect(capabilities).toEqual({ browser: true, device: false }); - expect(fetchMock).toHaveBeenCalledTimes(1); - }); - - test("falls back to root discovery when path-relative discovery fails", async () => { - const fetchMock = mock(async (input: string | URL | Request) => { - const url = String(input); - - if ( - url === - "https://issuer.example.com/mcp/.well-known/openid-configuration" - ) { - return new Response("not json", { - status: 200, - headers: { "content-type": "text/html" }, - }); - } - - if ( - url === "https://issuer.example.com/.well-known/openid-configuration" - ) { - return new Response( - JSON.stringify({ - authorization_endpoint: - "https://issuer.example.com/oauth/authorize", - token_endpoint: "https://issuer.example.com/oauth/token", - userinfo_endpoint: "https://issuer.example.com/oauth/userinfo", - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - throw new Error(`Unexpected fetch: ${url}`); - }); - - globalThis.fetch = fetchMock as typeof fetch; - - const client = new SettingsOAuthClient({ - issuerUrl: "https://issuer.example.com/mcp", - clientId: "static-client-id", - redirectUri: "https://gateway.example.com/agent/oauth/callback", - }); - - const capabilities = await client.getCapabilities(); - expect(capabilities).toEqual({ browser: true, device: false }); - expect(fetchMock).toHaveBeenCalledTimes(2); - }); - - test("treats authorization_pending and slow_down as pending during device polling", async () => { - let tokenPolls = 0; - const fetchMock = mock(async (input: string | URL | Request) => { - const url = String(input); - - if (url.endsWith("/.well-known/openid-configuration")) { - return new Response( - JSON.stringify({ - authorization_endpoint: - "https://issuer.example.com/oauth/authorize", - token_endpoint: "https://issuer.example.com/oauth/token", - device_authorization_endpoint: - "https://issuer.example.com/oauth/device_authorization", - userinfo_endpoint: "https://issuer.example.com/oauth/userinfo", - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - if (url.endsWith("/oauth/token")) { - tokenPolls += 1; - return new Response( - JSON.stringify({ - error: tokenPolls === 1 ? "authorization_pending" : "slow_down", - }), - { status: 400, headers: { "content-type": "application/json" } } - ); - } - - throw new Error(`Unexpected fetch: ${url}`); - }); - - globalThis.fetch = fetchMock as typeof fetch; - - const client = new SettingsOAuthClient({ - issuerUrl: "https://issuer.example.com", - clientId: "static-client-id", - redirectUri: "https://gateway.example.com/agent/oauth/callback", - }); - - const pending = await client.pollDeviceAuthorization("device-1", 5); - expect(pending).toEqual({ status: "pending", interval: 5 }); - - const slowed = await client.pollDeviceAuthorization("device-1", 5); - expect(slowed).toEqual({ status: "pending", interval: 10 }); - }); - - test("returns device auth errors and fetches userinfo after successful device login", async () => { - let tokenPolls = 0; - const fetchMock = mock(async (input: string | URL | Request) => { - const url = String(input); - - if (url.endsWith("/.well-known/openid-configuration")) { - return new Response( - JSON.stringify({ - authorization_endpoint: - "https://issuer.example.com/oauth/authorize", - token_endpoint: "https://issuer.example.com/oauth/token", - device_authorization_endpoint: - "https://issuer.example.com/oauth/device_authorization", - userinfo_endpoint: "https://issuer.example.com/oauth/userinfo", - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - if (url.endsWith("/oauth/token")) { - tokenPolls += 1; - if (tokenPolls === 1) { - return new Response( - JSON.stringify({ - error: "expired_token", - error_description: "This device code expired.", - }), - { status: 400, headers: { "content-type": "application/json" } } - ); - } - - return new Response( - JSON.stringify({ - access_token: "provider-access-token", - token_type: "Bearer", - expires_in: 3600, - refresh_token: "provider-refresh-token", - scope: "profile:read", - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - if (url.endsWith("/oauth/userinfo")) { - return new Response( - JSON.stringify({ - sub: "user-123", - email: "user@example.com", - name: "Example User", - }), - { status: 200, headers: { "content-type": "application/json" } } - ); - } - - throw new Error(`Unexpected fetch: ${url}`); - }); - - globalThis.fetch = fetchMock as typeof fetch; - - const client = new SettingsOAuthClient({ - issuerUrl: "https://issuer.example.com", - clientId: "static-client-id", - redirectUri: "https://gateway.example.com/agent/oauth/callback", - }); - - const expired = await client.pollDeviceAuthorization("device-1", 5); - expect(expired).toEqual({ - status: "error", - error: "This device code expired.", - errorCode: "expired_token", - }); - - const complete = await client.pollDeviceAuthorization("device-1", 5); - expect(complete.status).toBe("complete"); - if (complete.status !== "complete") { - throw new Error("Expected complete device auth result"); - } - expect(complete.credentials.accessToken).toBe("provider-access-token"); - expect(complete.user).toEqual({ - sub: "user-123", - email: "user@example.com", - name: "Example User", - }); - }); -}); diff --git a/packages/gateway/src/__tests__/skill-and-mcp-registry.test.ts b/packages/gateway/src/__tests__/skill-and-mcp-registry.test.ts index 6ad983cd6..5a927895e 100644 --- a/packages/gateway/src/__tests__/skill-and-mcp-registry.test.ts +++ b/packages/gateway/src/__tests__/skill-and-mcp-registry.test.ts @@ -1,148 +1,7 @@ import { describe, expect, test } from "bun:test"; import { McpRegistryService } from "../services/mcp-registry"; -import { - type SkillContent, - type SkillRegistry, - SkillRegistryCoordinator, - type SkillRegistryResult, -} from "../services/skill-registry"; import type { SystemConfigResolver } from "../services/system-config-resolver"; -function createMockRegistry( - id: string, - results: SkillRegistryResult[], - skills: Record = {} -): SkillRegistry { - return { - id, - search: async (_query: string, limit: number) => results.slice(0, limit), - fetch: async (skillId: string) => { - const skill = skills[skillId]; - if (!skill) throw new Error(`Not found: ${skillId}`); - return skill; - }, - }; -} - -function createFailingRegistry(id: string): SkillRegistry { - return { - id, - search: async () => { - throw new Error("Registry unavailable"); - }, - fetch: async () => { - throw new Error("Registry unavailable"); - }, - }; -} - -const skillA: SkillContent = { - name: "Skill A", - description: "First skill", - content: "content-a", -}; - -const skillB: SkillContent = { - name: "Skill B", - description: "Second skill", - content: "content-b", -}; - -describe("SkillRegistryCoordinator", () => { - test("search aggregates results from multiple registries", async () => { - const reg1 = createMockRegistry("r1", [ - { id: "s1", name: "Skill 1", source: "r1", score: 5 }, - ]); - const reg2 = createMockRegistry("r2", [ - { id: "s2", name: "Skill 2", source: "r2", score: 3 }, - ]); - const coordinator = new SkillRegistryCoordinator([reg1, reg2]); - - const results = await coordinator.search("test", 10); - expect(results).toHaveLength(2); - expect(results.map((r) => r.id)).toEqual(["s1", "s2"]); - }); - - test("search deduplicates by id, first registry wins", async () => { - const reg1 = createMockRegistry("r1", [ - { id: "dup", name: "From R1", source: "r1", score: 1 }, - ]); - const reg2 = createMockRegistry("r2", [ - { id: "dup", name: "From R2", source: "r2", score: 10 }, - ]); - const coordinator = new SkillRegistryCoordinator([reg1, reg2]); - - const results = await coordinator.search("test", 10); - expect(results).toHaveLength(1); - expect(results[0].name).toBe("From R1"); - expect(results[0].source).toBe("r1"); - }); - - test("search sorts by score descending", async () => { - const reg1 = createMockRegistry("r1", [ - { id: "low", name: "Low", source: "r1", score: 1 }, - { id: "high", name: "High", source: "r1", score: 10 }, - { id: "mid", name: "Mid", source: "r1", score: 5 }, - ]); - const coordinator = new SkillRegistryCoordinator([reg1]); - - const results = await coordinator.search("test", 10); - expect(results.map((r) => r.id)).toEqual(["high", "mid", "low"]); - }); - - test("search respects limit", async () => { - const reg1 = createMockRegistry("r1", [ - { id: "s1", name: "A", source: "r1", score: 3 }, - { id: "s2", name: "B", source: "r1", score: 2 }, - { id: "s3", name: "C", source: "r1", score: 1 }, - ]); - const coordinator = new SkillRegistryCoordinator([reg1]); - - const results = await coordinator.search("test", 2); - expect(results).toHaveLength(2); - }); - - test("search tolerates registry failures", async () => { - const failing = createFailingRegistry("bad"); - const working = createMockRegistry("good", [ - { id: "s1", name: "Survivor", source: "good", score: 1 }, - ]); - const coordinator = new SkillRegistryCoordinator([failing, working]); - - const results = await coordinator.search("test", 10); - expect(results).toHaveLength(1); - expect(results[0].id).toBe("s1"); - }); - - test("fetch returns from first registry that has the skill", async () => { - const reg1 = createMockRegistry("r1", [], { "skill-a": skillA }); - const reg2 = createMockRegistry("r2", [], { "skill-b": skillB }); - const coordinator = new SkillRegistryCoordinator([reg1, reg2]); - - const result = await coordinator.fetch("skill-a"); - expect(result.name).toBe("Skill A"); - }); - - test("fetch falls through to next registry on failure", async () => { - const failing = createFailingRegistry("bad"); - const working = createMockRegistry("good", [], { "skill-b": skillB }); - const coordinator = new SkillRegistryCoordinator([failing, working]); - - const result = await coordinator.fetch("skill-b"); - expect(result.name).toBe("Skill B"); - }); - - test("fetch throws when skill not found in any registry", async () => { - const reg1 = createMockRegistry("r1", [], {}); - const reg2 = createMockRegistry("r2", [], {}); - const coordinator = new SkillRegistryCoordinator([reg1, reg2]); - - expect(coordinator.fetch("nonexistent")).rejects.toThrow( - 'Skill "nonexistent" not found in any registry' - ); - }); -}); - // --- McpRegistryService --- function createMockResolver( diff --git a/packages/gateway/src/api/index.ts b/packages/gateway/src/api/index.ts index 24f89f9a5..b45b7536c 100644 --- a/packages/gateway/src/api/index.ts +++ b/packages/gateway/src/api/index.ts @@ -1,2 +1 @@ export { ApiPlatform, type ApiPlatformConfig } from "./platform"; -export { ApiResponseRenderer } from "./response-renderer"; diff --git a/packages/gateway/src/api/platform.ts b/packages/gateway/src/api/platform.ts index 2662b2b90..fdce40293 100644 --- a/packages/gateway/src/api/platform.ts +++ b/packages/gateway/src/api/platform.ts @@ -97,6 +97,16 @@ export class ApiPlatform implements PlatformAdapter { }); }); + interactionService.on("config:requested", (event: any) => { + if (event.teamId !== "api") return; + broadcastToAgent(event.conversationId, "config-request", { + type: "config-request", + requestId: event.id, + text: event.text, + timestamp: Date.now(), + }); + }); + interactionService.on("suggestion:created", (event: any) => { if (event.teamId !== "api") return; broadcastToAgent(event.conversationId, "suggestion", { diff --git a/packages/gateway/src/auth/admin-status-cache.ts b/packages/gateway/src/auth/admin-status-cache.ts deleted file mode 100644 index e8b80f622..000000000 --- a/packages/gateway/src/auth/admin-status-cache.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { BaseRedisStore } from "@lobu/core"; -import type Redis from "ioredis"; - -interface AdminStatusEntry { - isAdmin: boolean; - cachedAt: number; -} - -/** - * Cache admin status checks to avoid platform API rate limiting. - * TTL: 5 minutes by default. - * - * Storage: admin_status:{platform}:{chatId}:{userId} - */ -export class AdminStatusCache extends BaseRedisStore { - private readonly CACHE_TTL_SECONDS = 300; // 5 minutes - - constructor(redis: Redis) { - super({ - redis, - keyPrefix: "admin_status", - loggerName: "admin-status-cache", - }); - } - - /** - * Get cached admin status. - * Returns null if not cached or expired. - */ - async getStatus( - platform: string, - chatId: string, - userId: string - ): Promise { - const key = this.buildKey(platform, chatId, userId); - const cached = await this.get(key); - if (!cached) return null; - return cached.isAdmin; - } - - /** - * Cache admin status with TTL. - */ - async setStatus( - platform: string, - chatId: string, - userId: string, - isAdmin: boolean - ): Promise { - const key = this.buildKey(platform, chatId, userId); - await this.set( - key, - { isAdmin, cachedAt: Date.now() }, - this.CACHE_TTL_SECONDS - ); - } -} diff --git a/packages/gateway/src/auth/api-auth-middleware.ts b/packages/gateway/src/auth/api-auth-middleware.ts index 1664c299a..f074000a9 100644 --- a/packages/gateway/src/auth/api-auth-middleware.ts +++ b/packages/gateway/src/auth/api-auth-middleware.ts @@ -34,7 +34,7 @@ export function createApiAuthMiddleware(opts: { if (identity) return next(); } - // 3. Try external OAuth token (validated against AUTH_MCP_URL userinfo) + // 3. Try external OAuth token (validated against MEMORY_URL userinfo) if (opts.externalAuthClient) { try { const userInfo = await opts.externalAuthClient.fetchUserInfo(token); diff --git a/packages/gateway/src/auth/chatgpt/index.ts b/packages/gateway/src/auth/chatgpt/index.ts index 4c0275e94..6e192434d 100644 --- a/packages/gateway/src/auth/chatgpt/index.ts +++ b/packages/gateway/src/auth/chatgpt/index.ts @@ -1,2 +1 @@ export { ChatGPTOAuthModule } from "./chatgpt-oauth-module"; -export { ChatGPTDeviceCodeClient } from "./device-code-client"; diff --git a/packages/gateway/src/auth/external/client.ts b/packages/gateway/src/auth/external/client.ts index ef638bc52..2e90e3328 100644 --- a/packages/gateway/src/auth/external/client.ts +++ b/packages/gateway/src/auth/external/client.ts @@ -238,18 +238,18 @@ export class ExternalAuthClient { } static isConfigured(): boolean { - return !!process.env.AUTH_MCP_URL; + return !!process.env.MEMORY_URL; } static fromEnv( publicGatewayUrl: string, cacheStore?: ExternalAuthConfig["cacheStore"] ): ExternalAuthClient | null { - const authMcpUrl = process.env.AUTH_MCP_URL; + const authMcpUrl = process.env.MEMORY_URL; if (!authMcpUrl) return null; const issuerUrl = authMcpUrl.replace(/\/+$/, ""); - const callbackPath = "/agent/oauth/callback"; + const callbackPath = "/connect/oauth/callback"; // Register redirect URIs for both the configured public URL and localhost // so OAuth works regardless of how the user accesses the gateway diff --git a/packages/gateway/src/auth/mcp/proxy.ts b/packages/gateway/src/auth/mcp/proxy.ts index 89f291e28..7f69e8a0c 100644 --- a/packages/gateway/src/auth/mcp/proxy.ts +++ b/packages/gateway/src/auth/mcp/proxy.ts @@ -339,10 +339,15 @@ export class McpProxy { private async handleListTools(c: Context): Promise { const mcpId = c.req.param("mcpId"); + if (!mcpId) return c.json({ error: "Missing MCP server id" }, 400); const auth = authenticateRequest(c); if (!auth) return c.json({ error: "Invalid authentication token" }, 401); const agentId = auth.tokenData.agentId || auth.tokenData.userId; + const requesterUserId = auth.tokenData.userId; + if (!agentId || !requesterUserId) { + return c.json({ error: "Invalid authentication token" }, 401); + } const httpServer = await this.configService.getHttpServer(mcpId, agentId); if (!httpServer) { return c.json({ error: `MCP server '${mcpId}' not found` }, 404); @@ -368,7 +373,7 @@ export class McpProxy { mcpId, "POST", jsonRpcBody, - auth.tokenData.userId + requesterUserId ); const data = (await response.json()) as JsonRpcResponse; @@ -402,10 +407,17 @@ export class McpProxy { private async handleCallTool(c: Context): Promise { const mcpId = c.req.param("mcpId"); const toolName = c.req.param("toolName"); + if (!mcpId || !toolName) { + return c.json({ error: "Missing MCP server id or tool name" }, 400); + } const auth = authenticateRequest(c); if (!auth) return c.json({ error: "Invalid authentication token" }, 401); const agentId = auth.tokenData.agentId || auth.tokenData.userId; + const requesterUserId = auth.tokenData.userId; + if (!agentId || !requesterUserId) { + return c.json({ error: "Invalid authentication token" }, 401); + } const httpServer = await this.configService.getHttpServer(mcpId, agentId); if (!httpServer) { return c.json({ error: `MCP server '${mcpId}' not found` }, 404); @@ -434,7 +446,7 @@ export class McpProxy { content: [ { type: "text", - text: `Tool call requires approval. Grant access via settings page for: ${mcpId} → ${toolName}`, + text: `Tool call requires approval. Request access approval in chat for: ${mcpId} → ${toolName}`, }, ], isError: true, @@ -466,15 +478,13 @@ export class McpProxy { id: 1, }); - const userId = auth.tokenData.userId; - let response = await this.sendUpstreamRequest( httpServer, agentId, mcpId, "POST", jsonRpcBody, - userId + requesterUserId ); let data = (await response.json()) as JsonRpcResponse; @@ -485,7 +495,12 @@ export class McpProxy { mcpId, toolName, }); - await this.reinitializeSession(httpServer, agentId, mcpId, userId); + await this.reinitializeSession( + httpServer, + agentId, + mcpId, + requesterUserId + ); response = await this.sendUpstreamRequest( httpServer, @@ -493,7 +508,7 @@ export class McpProxy { mcpId, "POST", jsonRpcBody, - userId + requesterUserId ); data = (await response.json()) as JsonRpcResponse; } @@ -513,7 +528,7 @@ export class McpProxy { const autoAuthResult = await this.tryAutoDeviceAuth( mcpId, agentId, - auth.tokenData.userId + requesterUserId ); if (autoAuthResult) { return c.json( diff --git a/packages/gateway/src/auth/oauth-templates.ts b/packages/gateway/src/auth/oauth-templates.ts index 3e395c133..18f3c682d 100644 --- a/packages/gateway/src/auth/oauth-templates.ts +++ b/packages/gateway/src/auth/oauth-templates.ts @@ -1,14 +1,3 @@ -/** - * Format MCP ID into human-readable name - * Example: "github-mcp" -> "Github Mcp" - */ -export function formatMcpName(mcpId: string): string { - return mcpId - .split("-") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) - .join(" "); -} - /** * HTML templates for OAuth flow */ @@ -36,7 +25,7 @@ function escapeHtml(value: string): string { /** * Render a success page that auto-closes the tab (for in-app browsers) - * and provides a link to the settings page as fallback. + * and provides a fallback link to agent configuration when available. */ export function renderOAuthSuccessPage( name: string, @@ -105,7 +94,7 @@ export function renderOAuthSuccessPage(

${safeTitle}

${safeDescription.includes(safeName) ? safeDescription : `${safeDescription} ${safeName}`}

${safeDetails ? `

${safeDetails}

` : ""} - ${safeSettingsUrl ? `Open Settings` : ""} + ${safeSettingsUrl ? `Open Configuration` : ""}

${safeCloseNote}

` : ""; - })()} - - -
-
-
- - - -`; -} - -// ─── Picker Page ──────────────────────────────────────────────────────────── - -export function renderPickerPage( - payload: SettingsTokenPayload, - agents: (AgentMetadata & { channelCount: number })[] -): string { - return ` - - - - - - Configure Agent - Lobu - - ${(() => { - const s = getAuthMethod(payload.platform).scriptUrl; - return s ? `` : ""; - })()} - - -
-
-
🦞
-

Configure Agent

-

${getPlatformDisplay(payload.platform).icon} ${escapeHtml(formatUserId(payload.userId))}

- ${payload.channelId ? `

Channel: ${escapeHtml(payload.channelId)}

` : ""} -
- - - - - ${ - agents.length > 0 - ? ` -
-

Your Agents

-
-${agents - .map( - (agent) => ` -
-
-

${escapeHtml(agent.name)}${agent.isWorkspaceAgent ? ' (workspace)' : ""}

-

${escapeHtml(agent.agentId)} · ${agent.channelCount} channel${agent.channelCount !== 1 ? "s" : ""}

- ${agent.description ? `

${escapeHtml(agent.description)}

` : ""} -
- -
` - ) - .join("")} -
-
` - : `
-

No agents yet. Create one below to get started.

-
` - } - - -
-

Create New Agent

-
-
- - -
-
- - -

Auto-generated from name. Lowercase letters, numbers, hyphens.

-
- -
-
-
- - - -`; -} - -// ─── Error Page ───────────────────────────────────────────────────────────── - -export function renderErrorPage(message: string): string { - return ` - - - - - Settings Error - Lobu - - - -
-
-

Settings Error

-

Unable to load settings page.

-
- ${escapeHtml(message)} -
-
- -`; -} diff --git a/packages/gateway/src/routes/public/agent-page/types.ts b/packages/gateway/src/routes/public/agent-page/types.ts deleted file mode 100644 index 1b719e67d..000000000 --- a/packages/gateway/src/routes/public/agent-page/types.ts +++ /dev/null @@ -1,255 +0,0 @@ -export interface ProviderInfo { - name: string; - authType: "oauth" | "device-code" | "api-key"; - supportedAuthTypes: ("oauth" | "device-code" | "api-key")[]; - apiKeyInstructions: string; - apiKeyPlaceholder: string; - capabilities: ( - | "text" - | "image-generation" - | "speech-to-text" - | "text-to-speech" - )[]; -} - -export interface CatalogProvider { - id: string; - name: string; - iconUrl: string; - authType: "oauth" | "device-code" | "api-key"; - supportedAuthTypes: ("oauth" | "device-code" | "api-key")[]; - apiKeyInstructions: string; - apiKeyPlaceholder: string; - capabilities: ( - | "text" - | "image-generation" - | "speech-to-text" - | "text-to-speech" - )[]; -} - -export interface ModelOption { - label: string; - value: string; -} - -export type SettingsScope = "agent" | "sandbox"; -export type SettingsSource = "local" | "inherited" | "mixed"; -export type SettingsSectionKey = - | "model" - | "system-prompt" - | "skills" - | "packages" - | "permissions" - | "schedules" - | "logging"; - -export interface SectionView { - source: SettingsSource; - editable: boolean; - canReset: boolean; - hasLocalOverride: boolean; -} - -export interface ProviderView { - id: string; - source: SettingsSource; - canEdit: boolean; - canReset: boolean; - hasLocalOverride: boolean; -} - -export interface ModelSelectionState { - mode: "auto" | "pinned"; - pinnedModel?: string; -} - -export interface SkillMcpServerInfo { - id: string; - name?: string; - url?: string; - type?: "sse" | "stdio"; - command?: string; - args?: string[]; -} - -export interface Skill { - repo: string; - name: string; - description: string; - enabled: boolean; - system?: boolean; - content?: string; - contentFetchedAt?: number; - mcpServers?: SkillMcpServerInfo[]; - nixPackages?: string[]; - permissions?: string[]; - providers?: string[]; - modelPreference?: string; - thinkingLevel?: string; -} - -export interface McpConfig { - enabled?: boolean; - url?: string; - command?: string; - args?: string[]; - type?: string; - description?: string; -} - -export interface Schedule { - scheduleId: string; - task: string; - scheduledFor: string; - status: "pending" | "triggered" | "cancelled"; - isRecurring?: boolean; - cron?: string; - iteration?: number; - maxIterations?: number; -} - -export interface PrefillSkill { - repo: string; - name?: string; - description?: string; -} - -export interface PrefillMcp { - id: string; - name?: string; - url?: string; - type?: string; - command?: string; - args?: string[]; - envVars?: string[]; -} - -export interface ProviderState { - status: string; - connected: boolean; - userConnected: boolean; - systemConnected: boolean; - showAuthFlow: boolean; - showCodeInput: boolean; - showDeviceCode: boolean; - showApiKeyInput: boolean; - activeAuthTab: string; - activeAuthType?: string | null; - authMethods?: string[]; - code: string; - apiKey: string; - userCode: string; - verificationUrl: string; - pollStatus: string; - deviceAuthId: string; - selectedModel: string; - modelQuery: string; - showModelDropdown: boolean; -} - -export interface PermissionGrant { - pattern: string; - expiresAt: number | null; - denied?: boolean; - grantedAt?: number; -} - -export interface AgentInfo { - agentId: string; - name: string; - isWorkspaceAgent?: boolean; - channelCount: number; - description?: string; -} - -export interface SettingsState { - agentId: string; - scope: SettingsScope; - PROVIDERS: Record; - providerOrder: string[]; - providerModels: Record; - modelSelection: ModelSelectionState; - providerModelPreferences: Record; - catalogProviders: CatalogProvider[]; - memoryEnabled: boolean; - initialSkills: Skill[]; - initialMcpServers: Record; - prefillSkills: PrefillSkill[]; - prefillMcpServers: PrefillMcp[]; - prefillGrants: string[]; - prefillNixPackages: string[]; - prefillProviders: string[]; - initialNixPackages: string[]; - agentName: string; - agentDescription: string; - hasChannelId: boolean; - verboseLogging: boolean; - identityMd: string; - soulMd: string; - userMd: string; - // Injected by the server into the HTML shell - platform: string; - userId: string; - channelId?: string; - teamId?: string; - message?: string; - showSwitcher: boolean; - agents: AgentInfo[]; - hasNoProviders: boolean; - configManagedProviders: string[]; - // Provider icon URLs for rendering - providerIconUrls: Record; - // Settings mode and scoped access - settingsMode: "admin" | "user"; - allowedScopes?: string[]; - // Whether the user has admin access (authenticated via OAuth or admin password) - isAdmin?: boolean; - // Whether this agent is a sandbox (auto-created under a connection) - isSandbox?: boolean; - // Platform of the sandbox agent's owner (for displaying platform icon) - ownerPlatform?: string; - // Template/base agent this sandbox promotes into - templateAgentId?: string; - templateAgentName?: string; - sectionViews: Record; - providerViews: Record; - // Skill registries - globalRegistries: { id: string; type: string; apiUrl: string }[]; - initialRegistries: { id: string; type: string; apiUrl: string }[]; - // Source conversation context for post-install callbacks - conversationId?: string; - connectionId?: string; -} - -export interface Connection { - id: string; - platform: string; - templateAgentId?: string; - config: Record; - settings: { - allowFrom?: string[]; - allowGroups?: boolean; - userConfigScopes?: string[]; - }; - metadata: Record; - status: "active" | "stopped" | "error"; - errorMessage?: string; - createdAt: number; - updatedAt: number; -} - -export interface SettingsSnapshot { - identityMd: string; - soulMd: string; - userMd: string; - verboseLogging: boolean; - primaryProvider: string; - providerOrder: string; - nixPackages: string; - skills: string; - mcpServers: string; - permissions: string; - providerModelPreferences: string; - registries: string; -} diff --git a/packages/gateway/src/routes/public/agent-page/utils.ts b/packages/gateway/src/routes/public/agent-page/utils.ts deleted file mode 100644 index f804d16f1..000000000 --- a/packages/gateway/src/routes/public/agent-page/utils.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { platformRegistry } from "../../../platform"; - -export function escapeHtml(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -export function formatUserId(userId: string): string { - if (userId.startsWith("+")) return userId; - if (userId.includes("@")) { - const parts = userId.split("@"); - const id = parts[0] || ""; - const domain = parts[1] || ""; - if (domain === "lid") return `ID: ${id.slice(0, 8)}...`; - if (domain === "s.whatsapp.net") return `+${id}`; - return userId; - } - return userId; -} - -export function getPlatformDisplay(platform: string): { - icon: string; - name: string; -} { - const adapter = platformRegistry.get(platform); - if (adapter?.getDisplayInfo) { - const info = adapter.getDisplayInfo(); - const icon = info.icon.includes('class="') - ? info.icon.replace('class="', 'class="w-4 h-4 inline-block ') - : info.icon.replace("', - name: platform || "API", - }; -} diff --git a/packages/gateway/src/routes/public/agent-schedules.ts b/packages/gateway/src/routes/public/agent-schedules.ts index c9617ecc1..051c2e5eb 100644 --- a/packages/gateway/src/routes/public/agent-schedules.ts +++ b/packages/gateway/src/routes/public/agent-schedules.ts @@ -5,10 +5,14 @@ */ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; +import type { AgentConfigStore } from "@lobu/core"; import { createLogger } from "@lobu/core"; import type { ExternalAuthClient } from "../../auth/external/client"; +import type { SettingsTokenPayload } from "../../auth/settings/token-service"; +import type { UserAgentsStore } from "../../auth/user-agents-store"; import type { ScheduledWakeupService } from "../../orchestration/scheduled-wakeup"; -import { verifySettingsSession } from "./settings-auth"; +import { createTokenVerifier } from "../shared/token-verifier"; +import { verifySettingsSessionOrToken } from "./settings-auth"; const logger = createLogger("agent-schedules"); @@ -130,37 +134,65 @@ const createScheduleRoute = createRoute({ export interface AgentSchedulesRoutesConfig { scheduledWakeupService?: ScheduledWakeupService; externalAuthClient?: ExternalAuthClient; + userAgentsStore?: UserAgentsStore; + agentMetadataStore?: Pick; } export function createAgentSchedulesRoutes( config: AgentSchedulesRoutesConfig ): OpenAPIHono { const app = new OpenAPIHono(); + const verifySessionToken = createTokenVerifier({ + userAgentsStore: config.userAgentsStore, + agentMetadataStore: config.agentMetadataStore, + }); /** * Auth: settings session (cookie/authProvider) OR external OAuth Bearer token - * (validated via AUTH_MCP_URL userinfo endpoint). + * (validated via MEMORY_URL userinfo endpoint). */ - async function requireAuth(c: any, _agentId: string): Promise { + async function requireAuth( + c: any, + agentId: string + ): Promise { // 1. Try settings session (cookie or injected authProvider) - const session = verifySettingsSession(c); - if (session) return true; + const session = verifySettingsSessionOrToken(c); + if (session) { + if (session.isAdmin || session.settingsMode === "admin") { + return { + ...session, + isAdmin: true, + settingsMode: "admin", + }; + } + return verifySessionToken(session, agentId); + } - // 2. Try external OAuth Bearer token (validated via AUTH_MCP_URL) + // 2. Try external OAuth Bearer token (validated via MEMORY_URL) if (config.externalAuthClient) { const authHeader = c.req.header("Authorization"); if (authHeader?.startsWith("Bearer ")) { const token = authHeader.substring(7); try { const userInfo = await config.externalAuthClient.fetchUserInfo(token); - if (userInfo?.sub) return true; + if (userInfo?.sub) { + return verifySessionToken( + { + userId: userInfo.sub, + oauthUserId: userInfo.sub, + platform: "external", + exp: Date.now() + 60_000, + }, + agentId + ); + } } catch (err) { logger.debug({ err }, "Bearer token validation failed"); } } } - return false; + return null; } // POST / — create schedule diff --git a/packages/gateway/src/routes/public/agent-settings.ts b/packages/gateway/src/routes/public/agent-settings.ts deleted file mode 100644 index 21e65f216..000000000 --- a/packages/gateway/src/routes/public/agent-settings.ts +++ /dev/null @@ -1,1253 +0,0 @@ -/** - * Agent Page Routes - * - * Serves the unified settings/agent-selector page. - * OAuth + claims is the only auth path. No fallback to encrypted tokens. - * - * Platforms that support webapp-initdata auth (e.g. Telegram) use their - * platform-specific signed payloads for session creation. - */ - -import { OpenAPIHono } from "@hono/zod-openapi"; -import { type AuthProfile, createLogger } from "@lobu/core"; -import type { - AgentMetadata, - AgentMetadataStore, -} from "../../auth/agent-metadata-store"; -import type { OAuthStateStore } from "../../auth/oauth/state-store"; -import { collectProviderModelOptions } from "../../auth/provider-model-options"; -import type { AgentSettingsStore } from "../../auth/settings"; -import type { ClaimService } from "../../auth/settings/claim-service"; -import type { SettingsOAuthClient } from "../../auth/settings/oauth-client"; -import { resolveSettingsView } from "../../auth/settings/resolved-settings-view"; -import type { - PrefillMcpServer, - SettingsTokenPayload, -} from "../../auth/settings/token-service"; -import { verifyTelegramWebAppData } from "../../auth/telegram-webapp-auth"; -import type { UserAgentsStore } from "../../auth/user-agents-store"; -import type { ChannelBindingService } from "../../channels"; -import { getAuthMethod } from "../../connections/platform-auth-methods"; -import { getModelProviderModules } from "../../modules/module-system"; -import { - buildMessagePayload, - resolveAgentOptions, -} from "../../services/platform-helpers"; -import { platformAgentId } from "../../spaces"; -import { resolvePublicBaseUrl } from "../../utils/public-url"; -import type { ProviderMeta } from "./agent-page"; -import { - renderErrorPage, - renderPickerPage, - renderSettingsPage, -} from "./agent-page"; -import { - clearSettingsSessionCookie, - setSettingsSessionCookie, - verifySettingsSession, -} from "./settings-auth"; - -const logger = createLogger("settings-routes"); - -/** - * Validate returnUrl to prevent open redirects. - * Only allows relative paths under /agent or /api/v1/. - */ -function isSafeReturnUrl(url: string): boolean { - if (!url.startsWith("/")) return false; - // Block protocol-relative URLs (//evil.com) - if (url.startsWith("//")) return false; - return ( - url.startsWith("/agent") || url.startsWith("/api/v1/") || url === "/agents" - ); -} - -function parsePrefillMcpServersParam( - encoded?: string -): PrefillMcpServer[] | undefined { - if (!encoded) return undefined; - - try { - const decoded = Buffer.from(encoded, "base64url").toString("utf-8"); - const parsed = JSON.parse(decoded); - if (!Array.isArray(parsed)) return undefined; - - const servers: PrefillMcpServer[] = parsed - .map((entry) => { - if (!entry || typeof entry !== "object") return null; - - const id = typeof entry.id === "string" ? entry.id.trim() : ""; - if (!id) return null; - - const server: PrefillMcpServer = { id }; - - if (typeof entry.name === "string" && entry.name.trim()) { - server.name = entry.name.trim(); - } - if (typeof entry.url === "string" && entry.url.trim()) { - server.url = entry.url.trim(); - } - if (entry.type === "sse" || entry.type === "stdio") { - server.type = entry.type; - } - if (typeof entry.command === "string" && entry.command.trim()) { - server.command = entry.command.trim(); - } - if (Array.isArray(entry.args)) { - server.args = entry.args.filter( - (arg: unknown): arg is string => - typeof arg === "string" && arg.trim().length > 0 - ); - } - if (Array.isArray(entry.envVars)) { - server.envVars = entry.envVars.filter( - (envVar: unknown): envVar is string => - typeof envVar === "string" && envVar.trim().length > 0 - ); - } - - return server; - }) - .filter((server): server is PrefillMcpServer => server !== null); - - return servers.length > 0 ? servers : undefined; - } catch (error) { - logger.warn("Invalid prefill MCP payload in query param", { error }); - return undefined; - } -} - -/** State data for settings OAuth flow */ -interface SettingsOAuthStateData { - userId: string; - codeVerifier: string; - returnUrl: string; - redirectUri?: string; -} - -export interface SettingsPageConfig { - agentSettingsStore: AgentSettingsStore; - userAgentsStore: UserAgentsStore; - agentMetadataStore: AgentMetadataStore; - channelBindingService: ChannelBindingService; - connectionManager?: import("../../gateway/connection-manager").WorkerConnectionManager; - /** Chat instance manager for looking up connections (scopes, template agents) */ - chatInstanceManager?: import("../../connections/chat-instance-manager").ChatInstanceManager; - /** Settings OAuth client (optional — webapp-initdata auth works without it) */ - settingsOAuthClient?: SettingsOAuthClient; - /** Settings OAuth state store (optional — required only when OAuth client is set) */ - settingsOAuthStateStore?: OAuthStateStore; - /** Claim service for channel ownership verification (required) */ - claimService: ClaimService; - /** Platform registry for dispatching notifications */ - platformRegistry?: { get(platform: string): any }; - /** Interaction service for posting messages back to conversations */ - interactionService?: import("../../interactions").InteractionService; - /** Queue producer for dispatching messages to workers */ - queueProducer?: import("../../infrastructure/queue/queue-producer").QueueProducer; -} - -type ProviderCapability = - | "text" - | "image-generation" - | "speech-to-text" - | "text-to-speech"; - -const PROVIDER_CAPABILITY_ORDER: ProviderCapability[] = [ - "text", - "image-generation", - "speech-to-text", - "text-to-speech", -]; - -function orderedCapabilities( - capabilities: Set -): ProviderCapability[] { - return PROVIDER_CAPABILITY_ORDER.filter((capability) => - capabilities.has(capability) - ); -} - -function parseJwtScopes(token: string): Set | null { - const parts = token.split("."); - if (parts.length < 2) return null; - try { - const payload = JSON.parse( - Buffer.from(parts[1] || "", "base64url").toString("utf-8") - ) as { - scope?: unknown; - scp?: unknown; - }; - const scopes: string[] = []; - - if (typeof payload.scope === "string") { - scopes.push(...payload.scope.split(/\s+/)); - } - if (typeof payload.scp === "string") { - scopes.push(...payload.scp.split(/\s+/)); - } - if (Array.isArray(payload.scp)) { - scopes.push( - ...payload.scp.filter( - (value): value is string => typeof value === "string" - ) - ); - } - - const cleaned = scopes.map((scope) => scope.trim()).filter(Boolean); - return cleaned.length > 0 ? new Set(cleaned) : null; - } catch { - return null; - } -} - -function chatGptHasAudioScope(profile: AuthProfile): boolean { - if (profile.authType === "api-key") return true; - const scopes = parseJwtScopes(profile.credential); - if (!scopes) return true; - return ( - scopes.has("api.model.audio.request") || scopes.has("model.audio.request") - ); -} - -function chatGptHasImageGenerationScope(profile: AuthProfile): boolean { - if (profile.authType === "api-key") return true; - const scopes = parseJwtScopes(profile.credential); - if (!scopes) return true; - return ( - scopes.has("api.model.image.request") || - scopes.has("api.model.request") || - scopes.has("model.image.request") - ); -} - -function getPrimaryValidProfile( - profiles: AuthProfile[] | undefined, - providerId: string -): AuthProfile | undefined { - if (!Array.isArray(profiles) || profiles.length === 0) return undefined; - const now = Date.now(); - return profiles.find((profile) => { - if (profile.provider !== providerId) return false; - const expiresAt = profile.metadata?.expiresAt; - return !expiresAt || expiresAt > now; - }); -} - -function applyCapabilityOverrides( - provider: ProviderMeta, - authProfiles: AuthProfile[] | undefined -): ProviderMeta { - if (provider.id !== "chatgpt") return provider; - - const primaryProfile = getPrimaryValidProfile(authProfiles, provider.id); - if (!primaryProfile) { - return provider; - } - - const hasAudio = chatGptHasAudioScope(primaryProfile); - const hasImageGeneration = chatGptHasImageGenerationScope(primaryProfile); - if (hasAudio && hasImageGeneration) { - return provider; - } - - return { - ...provider, - capabilities: (provider.capabilities || []).filter( - (capability) => - capability === "text" || - (capability === "image-generation" && hasImageGeneration) || - ((capability === "speech-to-text" || capability === "text-to-speech") && - hasAudio) - ), - }; -} - -function buildProviderMeta( - m: ReturnType[number] -): ProviderMeta { - const providerId = m.providerId.toLowerCase(); - const capabilities = new Set(); - - if (providerId !== "elevenlabs") { - capabilities.add("text"); - } - if (providerId === "chatgpt" || providerId === "openai") { - capabilities.add("image-generation"); - } - if ( - providerId === "chatgpt" || - providerId === "openai" || - providerId === "gemini" || - providerId === "elevenlabs" || - providerId === "groq" - ) { - capabilities.add("speech-to-text"); - } - if ( - providerId === "chatgpt" || - providerId === "openai" || - providerId === "gemini" || - providerId === "elevenlabs" - ) { - capabilities.add("text-to-speech"); - } - - return { - id: m.providerId, - name: m.providerDisplayName, - iconUrl: m.providerIconUrl || "", - authType: (m.authType || "oauth") as ProviderMeta["authType"], - supportedAuthTypes: - (m.supportedAuthTypes as ProviderMeta["supportedAuthTypes"]) || [ - m.authType || "oauth", - ], - apiKeyInstructions: m.apiKeyInstructions || "", - apiKeyPlaceholder: m.apiKeyPlaceholder || "", - catalogDescription: m.catalogDescription || "", - capabilities: orderedCapabilities(capabilities), - }; -} - -/** - * Render the settings page for a resolved payload + agentId. - */ -async function renderSettingsForPayload( - c: any, - config: SettingsPageConfig, - payload: SettingsTokenPayload, - agentId: string -) { - const [settingsView, agentMetadata] = await Promise.all([ - resolveSettingsView({ - agentId, - agentSettingsStore: config.agentSettingsStore, - agentMetadataStore: config.agentMetadataStore, - viewer: { - settingsMode: payload.settingsMode, - allowedScopes: payload.allowedScopes, - isAdmin: payload.isAdmin, - }, - }), - config.agentMetadataStore.getMetadata(agentId), - ]); - const settings = settingsView.effectiveSettings; - - // Build provider metadata from registry - const allModules = getModelProviderModules(); - const allProviderMeta = allModules - .filter((m) => m.catalogVisible !== false) - .map(buildProviderMeta); - - // Resolve installed providers in order - const installedIds = (settings?.installedProviders || []).map( - (ip) => ip.providerId - ); - const installedSet = new Set(installedIds); - const installedProviders = installedIds - .map((id) => allProviderMeta.find((p) => p.id === id)) - .filter((p): p is ProviderMeta => p !== undefined) - .map((provider) => - applyCapabilityOverrides(provider, settings?.authProfiles) - ); - - // Catalog providers = all that are not installed - const catalogProviders = allProviderMeta.filter( - (p) => !installedSet.has(p.id) - ); - - const providerModelOptions = await collectProviderModelOptions( - agentId, - payload.userId - ); - - // Non-OAuth platforms don't need agent switching - const isDeterministicPlatform = - getAuthMethod(payload.platform).type !== "oauth"; - const showSwitcher = !isDeterministicPlatform && !!payload.channelId; - - // Get agents list for switcher (only if switcher is enabled) - const agents: (AgentMetadata & { channelCount: number })[] = []; - if (showSwitcher) { - const agentIds = await config.userAgentsStore.listAgents( - payload.platform || "unknown", - payload.userId - ); - for (const id of agentIds) { - const metadata = await config.agentMetadataStore.getMetadata(id); - if (metadata) { - const bindings = await config.channelBindingService.listBindings(id); - agents.push({ ...metadata, channelCount: bindings.length }); - } - } - - // Ensure the currently active agent appears in switcher even when it is - // not part of the user's direct agent list (e.g. workspace-bound agent). - if ( - agentMetadata && - !agents.some((agent) => agent.agentId === agentMetadata.agentId) - ) { - const bindings = await config.channelBindingService.listBindings( - agentMetadata.agentId - ); - agents.unshift({ ...agentMetadata, channelCount: bindings.length }); - } - } - - // Determine config-managed providers from manifest credentials - let configManagedProviders: string[] = []; - try { - const { readFileSync } = await import("node:fs"); - const { resolve } = await import("node:path"); - const manifestRaw = readFileSync( - resolve(process.cwd(), ".lobu/agents.json"), - "utf-8" - ); - const manifest = JSON.parse(manifestRaw); - const manifestAgentId = settingsView.templateAgentId || agentId; - const entry = manifest.agents?.find( - (a: any) => a.agentId === manifestAgentId - ); - if (entry?.credentials?.length) { - configManagedProviders = entry.credentials.map((c: any) => c.providerId); - } - } catch { - // Not a CLI-managed project or manifest not found - } - - // Ensure the payload has agentId for the template (may have been resolved from binding) - const effectivePayload = { ...payload, agentId }; - - return c.html( - renderSettingsPage(effectivePayload, settings, { - memoryEnabled: !!process.env.AUTH_MCP_URL, - scope: settingsView.scope, - providers: installedProviders, - catalogProviders, - providerModelOptions, - showSwitcher, - agents, - agentName: agentMetadata?.name, - agentDescription: agentMetadata?.description, - hasChannelId: !!payload.channelId, - isSandbox: !!agentMetadata?.parentConnectionId, - ownerPlatform: agentMetadata?.owner?.platform || "", - templateAgentId: settingsView.templateAgentId, - templateAgentName: settingsView.templateAgentName, - sectionViews: settingsView.sections, - providerViews: settingsView.providerSources, - configManagedProviders, - }) - ); -} - -export function createAgentPageRoutes(config: SettingsPageConfig): OpenAPIHono { - const app = new OpenAPIHono(); - - const oauthClient = config.settingsOAuthClient ?? null; - const stateStore = config.settingsOAuthStateStore ?? null; - const claimService = config.claimService; - - // ==================================================================== - // POST /agent/session — webapp-initdata authentication - // ==================================================================== - app.post("/agent/session", async (c) => { - const body = await c.req - .json<{ - initData?: string; - chatId?: string; - platform?: string; - connectionId?: string; - }>() - .catch( - (): { - initData?: string; - chatId?: string; - platform?: string; - connectionId?: string; - } => ({}) - ); - - if (!body.initData) { - return c.json({ error: "Missing initData" }, 400); - } - - const platform = (body.platform ?? "").trim(); - if (!platform) { - return c.json({ error: "Missing platform" }, 400); - } - - const authMethod = getAuthMethod(platform); - if (authMethod.type !== "webapp-initdata") { - return c.json({ error: "Platform does not support initData auth" }, 400); - } - - // Look up bot token from connection config (Chat SDK) or fall back to env - let botToken: string | undefined; - if (body.connectionId && config.chatInstanceManager) { - botToken = config.chatInstanceManager.getConnectionConfigSecret( - body.connectionId, - "botToken" - ); - } - botToken ??= process.env.TELEGRAM_BOT_TOKEN; - if (!botToken) { - return c.json({ error: "Platform not configured" }, 500); - } - - const chatId = (body.chatId ?? "").trim(); - if (!chatId) { - return c.json({ error: "Missing chatId" }, 400); - } - - const webAppData = verifyTelegramWebAppData(body.initData, botToken); - if (!webAppData) { - clearSettingsSessionCookie(c); - return c.json({ error: "Invalid or expired initData" }, 401); - } - - const userId = String(webAppData.user.id); - - // DM validation: chatId must equal userId - const chatIdNum = Number(chatId); - if (chatIdNum > 0 && chatId !== userId) { - return c.json({ error: "Chat ID mismatch" }, 403); - } - - const linkedOAuthUserId = await claimService.getLinkedOAuthUserId( - platform, - userId - ); - - // Linked users get a 24h session with oauthUserId (same as OAuth sessions) - // Unlinked users get a 1h session without oauthUserId - const sessionTtlMs = linkedOAuthUserId - ? 24 * 60 * 60 * 1000 - : 60 * 60 * 1000; - const session: SettingsTokenPayload = { - userId, - platform, - channelId: chatId, - exp: Date.now() + sessionTtlMs, - ...(linkedOAuthUserId && { oauthUserId: linkedOAuthUserId }), - }; - - setSettingsSessionCookie(c, session); - return c.json({ success: true }); - }); - - // ==================================================================== - // OAuth Login Flow (only registered when OAuth client is configured) - // ==================================================================== - - if (oauthClient && stateStore) { - /** - * GET /agent/oauth/login — Start OAuth flow - * Redirects to the OAuth provider's authorization page. - * Preserves returnUrl through the OAuth round-trip. - */ - app.get("/agent/oauth/login", async (c) => { - const rawReturnUrl = c.req.query("returnUrl") || "/agent"; - const returnUrl = isSafeReturnUrl(rawReturnUrl) ? rawReturnUrl : "/agent"; - const codeVerifier = oauthClient.generateCodeVerifier(); - const redirectUri = `${resolvePublicBaseUrl({ - requestUrl: c.req.url, - forwardedProto: c.req.header("x-forwarded-proto"), - })}/agent/oauth/callback`; - - const state = await stateStore.create({ - userId: "pending", // will be resolved after OAuth - codeVerifier, - returnUrl, - redirectUri, - }); - - const authUrl = await oauthClient.buildAuthUrl( - state, - codeVerifier, - redirectUri - ); - return c.redirect(authUrl); - }); - - /** - * GET /agent/oauth/callback — OAuth callback - * Exchanges code for token, fetches user info, creates session. - */ - app.get("/agent/oauth/callback", async (c) => { - const code = c.req.query("code"); - const stateParam = c.req.query("state"); - const error = c.req.query("error"); - - if (error) { - logger.warn("OAuth callback error", { error }); - return c.html(renderErrorPage(`OAuth login failed: ${error}`), 400); - } - - if (!code || !stateParam) { - return c.html( - renderErrorPage("Missing code or state in OAuth callback"), - 400 - ); - } - - // Consume state - const stateData = await stateStore.consume(stateParam); - if (!stateData) { - return c.html( - renderErrorPage("Invalid or expired OAuth state. Please try again."), - 400 - ); - } - - try { - // Exchange code for token (use the same redirectUri from the authorize step) - const credentials = await oauthClient.exchangeCodeForToken( - code, - stateData.codeVerifier, - stateData.redirectUri - ); - - // Fetch user info - const userInfo = await oauthClient.fetchUserInfo( - credentials.accessToken - ); - - // Validate OAuth user ID (used as Redis key) - if ( - !userInfo.sub || - typeof userInfo.sub !== "string" || - userInfo.sub.length > 255 || - /[:\s]/.test(userInfo.sub) - ) { - logger.error("Invalid OAuth user ID", { sub: userInfo.sub }); - return c.html( - renderErrorPage("Invalid user identity from OAuth provider."), - 500 - ); - } - - // Create unified session (24h TTL) - const sessionTtlMs = 24 * 60 * 60 * 1000; - const session: SettingsTokenPayload = { - userId: userInfo.sub, - platform: "unknown", - oauthUserId: userInfo.sub, - email: userInfo.email, - name: userInfo.name, - exp: Date.now() + sessionTtlMs, - isAdmin: true, - }; - - setSettingsSessionCookie(c, session); - logger.info("OAuth login successful", { - oauthUserId: userInfo.sub, - email: userInfo.email, - }); - - // Redirect to the original settings URL (with claim/agent params preserved) - const safeReturnUrl = isSafeReturnUrl(stateData.returnUrl) - ? stateData.returnUrl - : "/agent"; - return c.redirect(safeReturnUrl); - } catch (err) { - logger.error("OAuth callback failed", { error: err }); - return c.html( - renderErrorPage("OAuth login failed. Please try again."), - 500 - ); - } - }); - } else { - // OAuth not configured — return helpful error for OAuth-only paths - app.get("/agent/oauth/login", (c) => - c.html( - renderErrorPage( - "OAuth login is not configured. Use /configure in your chat to access settings." - ), - 501 - ) - ); - app.get("/agent/oauth/callback", (c) => - c.html(renderErrorPage("OAuth login is not configured."), 501) - ); - } - - // ==================================================================== - // GET /agent/:agentId? — Main agent page (agentId optional for picker) - // ==================================================================== - const agentPageHandler = async (c: any) => { - c.header("Referrer-Policy", "no-referrer"); - c.header("Cache-Control", "no-store, max-age=0"); - c.header("Pragma", "no-cache"); - - // 1. Verify session from cookie - let session = verifySettingsSession(c); - - // 1b. Claim-based flow: if the URL has ?claim= and the existing session - // lacks an oauthUserId (e.g. webapp-initdata session), clear the stale - // session so the claim+OAuth flow can proceed properly. - if (session && c.req.query("claim") && !session.oauthUserId) { - clearSettingsSessionCookie(c); - session = null; - } - - // 2. No session + webapp-initdata platform URL → render bootstrap page - // Must happen before claim handling, otherwise WebApp links that - // include claim=... will be redirected to OAuth login unnecessarily. - if (!session) { - const qp = c.req.query("platform"); - const chatId = c.req.query("chat"); - if (qp && chatId && getAuthMethod(qp).type === "webapp-initdata") { - return c.html(renderWebAppBootstrapPage()); - } - - // 3. No session + ?claim= → redirect to OAuth login (preserving returnUrl) - if (c.req.query("claim")) { - if (!oauthClient) { - return c.html( - renderErrorPage( - "OAuth login is not configured. Use /configure in your chat to access settings." - ), - 501 - ); - } - const currentUrl = new URL(c.req.url); - const returnUrl = `${currentUrl.pathname}${currentUrl.search}`; - return c.redirect( - `/agent/oauth/login?returnUrl=${encodeURIComponent(returnUrl)}` - ); - } - - // No session — redirect to OAuth login if available, otherwise show error - if (oauthClient) { - return c.redirect("/agent/oauth/login"); - } - return c.html( - renderErrorPage( - "No active session. Use /configure in your chat to get a settings link." - ), - 401 - ); - } - - // 4. Session + ?claim= → process claim, grant access, redirect - const claimCode = c.req.query("claim"); - if (claimCode) { - const oauthUserId = session.oauthUserId; - if (oauthUserId) { - const claimData = await claimService.consumeClaim(claimCode); - if (claimData) { - await claimService.grantAccess( - oauthUserId, - claimData.platform, - claimData.channelId - ); - - // Link platform identity → OAuth user for future initData sessions - const wasAlreadyLinked = await claimService.getLinkedOAuthUserId( - claimData.platform, - claimData.platformUserId - ); - await claimService.linkPlatformIdentity( - claimData.platform, - claimData.platformUserId, - oauthUserId - ); - - logger.info("Claim processed, access granted", { - oauthUserId, - platform: claimData.platform, - channelId: claimData.channelId, - wasAlreadyLinked: !!wasAlreadyLinked, - }); - - if (!wasAlreadyLinked) { - config.platformRegistry - ?.get(claimData.platform) - ?.notifyIdentityLinked?.(claimData.channelId); - } - - // Redirect to clean URL (strip claim param, keep agent/channel params) - const cleanUrl = new URL(c.req.url); - cleanUrl.searchParams.delete("claim"); - - const binding = await config.channelBindingService.getBinding( - claimData.platform, - claimData.channelId - ); - - // Reconcile user-agents index for the claimed platform identity. - if (binding) { - config.userAgentsStore - .addAgent( - claimData.platform, - claimData.platformUserId, - binding.agentId - ) - .catch(() => { - /* best-effort reconciliation */ - }); - } - - // Bind OAuth session to the claimed platform identity for subsequent - // authorization checks on config/auth APIs. - const resolvedAgentId = c.req.query("agent") || binding?.agentId; - const claimedSession: SettingsTokenPayload = { - ...session, - platform: claimData.platform, - channelId: claimData.channelId, - userId: claimData.platformUserId, - ...(resolvedAgentId && { agentId: resolvedAgentId }), - }; - setSettingsSessionCookie(c, claimedSession); - - // Resolve agentId for the redirect path - const claimAgentId = - cleanUrl.searchParams.get("agent") || binding?.agentId; - cleanUrl.searchParams.delete("agent"); - if (!cleanUrl.searchParams.has("platform")) { - cleanUrl.searchParams.set("platform", claimData.platform); - } - if (!cleanUrl.searchParams.has("channel")) { - cleanUrl.searchParams.set("channel", claimData.channelId); - } - - const redirectPath = claimAgentId - ? `/agent/${encodeURIComponent(claimAgentId)}` - : "/agent"; - return c.redirect(`${redirectPath}${cleanUrl.search}`, 303); - } else { - logger.warn("Invalid or expired claim code", { claimCode }); - } - } - } - - // 5. Session + agent → render settings (path param or query fallback) - const agentParam = c.req.param?.("agentId") || c.req.query("agent"); - const channelParam = c.req.query("channel"); - const platformParam = c.req.query("platform"); - - let agentId: string | undefined = agentParam; - - // If channel+platform provided, resolve agent via binding - if (!agentId && channelParam && platformParam) { - // Check access for OAuth sessions - if (session.oauthUserId) { - const hasAccess = await claimService.hasAccess( - session.oauthUserId, - platformParam, - channelParam - ); - if (!hasAccess) { - return c.html( - renderErrorPage( - "You don't have access to this channel's settings. Use /configure in the chat to get access." - ), - 403 - ); - } - } - - const binding = await config.channelBindingService.getBinding( - platformParam, - channelParam - ); - if (binding) { - agentId = binding.agentId; - } - } - - // For sessions with channelId, resolve agent via binding or deterministic ID - if (!agentId && session.channelId && session.platform) { - const binding = await config.channelBindingService.getBinding( - session.platform, - session.channelId - ); - if (binding) { - agentId = binding.agentId; - } else if (getAuthMethod(session.platform).type !== "oauth") { - // Deterministic agent ID for non-OAuth platforms — no binding needed - const isGroup = session.channelId.startsWith("-"); - agentId = platformAgentId( - session.platform, - session.userId, - session.channelId, - isGroup - ); - } - } - - // Verify OAuth sessions have access to the resolved agent - if (agentId && session.oauthUserId) { - // Non-OAuth platform agents are owned by the session user — no binding check needed - const isDeterministicAgent = - getAuthMethod(session.platform).type !== "oauth"; - - if (!isDeterministicAgent && session.agentId !== agentId) { - const channels = await claimService.getAccessibleChannels( - session.oauthUserId - ); - if (channels.length === 0) { - return c.html( - renderErrorPage( - "No channels configured. Use /configure in a chat first." - ), - 403 - ); - } - - // Check the agent is reachable from an accessible channel - const accessibleAgentIds = new Set(); - for (const ch of channels) { - const binding = await config.channelBindingService.getBinding( - ch.platform, - ch.channelId - ); - if (binding) accessibleAgentIds.add(binding.agentId); - } - if (!accessibleAgentIds.has(agentId)) { - return c.html( - renderErrorPage( - "You don't have access to this agent. Use /configure in the chat to get access." - ), - 403 - ); - } - } - } - - // 6. No agentId resolved: show dashboard of accessible channels - if (!agentId && session.oauthUserId) { - // OAuth session: show accessible channels - const channels = await claimService.getAccessibleChannels( - session.oauthUserId - ); - - if (channels.length === 0) { - return c.html( - renderErrorPage( - "No channels configured yet. Use /configure in a chat to link your account." - ), - 200 - ); - } - - // Try to find agents for all accessible channels - const agents: (AgentMetadata & { channelCount: number })[] = []; - const seenAgents = new Set(); - for (const ch of channels) { - // 1. Try channel binding first - const binding = await config.channelBindingService.getBinding( - ch.platform, - ch.channelId - ); - if (binding && !seenAgents.has(binding.agentId)) { - seenAgents.add(binding.agentId); - const metadata = await config.agentMetadataStore.getMetadata( - binding.agentId - ); - if (metadata) { - const bindings = await config.channelBindingService.listBindings( - binding.agentId - ); - agents.push({ ...metadata, channelCount: bindings.length }); - } - } - - // 2. Fallback for DM channels without bindings: resolve via user_agents. - // For DMs, channelId == userId on most platforms (Telegram, WhatsApp). - // Groups should always have channel bindings so this path won't fire for them. - if (!binding && !ch.channelId.startsWith("-")) { - const userAgentIds = await config.userAgentsStore.listAgents( - ch.platform, - ch.channelId - ); - for (const aid of userAgentIds) { - if (seenAgents.has(aid)) continue; - seenAgents.add(aid); - const metadata = await config.agentMetadataStore.getMetadata(aid); - if (metadata) { - const bindings = - await config.channelBindingService.listBindings(aid); - agents.push({ ...metadata, channelCount: bindings.length }); - } - } - } - } - - if (agents.length === 1 && agents[0]) { - agentId = agents[0].agentId; - } else { - // Multiple or zero agents: show picker - const displayUserId = - session.email || - session.name || - (session.userId !== session.oauthUserId ? session.userId : null) || - session.oauthUserId || - session.userId; - const syntheticPayload: SettingsTokenPayload = { - userId: displayUserId, - platform: channels[0]?.platform || session.platform || "unknown", - channelId: session.channelId || channels[0]?.channelId, - exp: session.exp, - }; - return c.html(renderPickerPage(syntheticPayload, agents)); - } - } - - if (!agentId) { - // Non-OAuth session with channelId: show agent picker - if (session.channelId && session.platform) { - const agentIds = await config.userAgentsStore.listAgents( - session.platform, - session.userId - ); - - const agents: (AgentMetadata & { channelCount: number })[] = []; - for (const id of agentIds) { - const metadata = await config.agentMetadataStore.getMetadata(id); - if (metadata) { - const bindings = - await config.channelBindingService.listBindings(id); - agents.push({ ...metadata, channelCount: bindings.length }); - } - } - - return c.html( - renderPickerPage(session as SettingsTokenPayload, agents) - ); - } - - return c.html( - renderErrorPage( - "No agent specified. Use /configure in a chat to get a settings link." - ), - 400 - ); - } - - // Update session cookie to include agentId for provider OAuth flows - if (agentId && session.agentId !== agentId) { - setSettingsSessionCookie(c, { ...session, agentId }); - } - - // Build payload for rendering - // For non-OAuth platforms, use the platform userId directly, not the opaque OAuth ID - const effectivePlatform = platformParam || session.platform || "unknown"; - const isDeterministic = getAuthMethod(effectivePlatform).type !== "oauth"; - const payload: SettingsTokenPayload = { - userId: isDeterministic - ? session.userId - : session.oauthUserId || session.userId, - platform: effectivePlatform, - channelId: channelParam || session.channelId, - agentId, - exp: session.exp, - isAdmin: session.isAdmin, - // Parse prefill query params (set by settings-link.ts for claim-based flows) - message: c.req.query("message") || undefined, - prefillSkills: c.req.query("skills") - ? c.req - .query("skills")! - .split(",") - .filter(Boolean) - .map((repo: string) => ({ repo })) - : undefined, - prefillGrants: c.req.query("grants") - ? c.req.query("grants")!.split(",").filter(Boolean) - : undefined, - prefillNixPackages: c.req.query("nix") - ? c.req.query("nix")!.split(",").filter(Boolean) - : undefined, - prefillMcpServers: parsePrefillMcpServersParam(c.req.query("mcps")), - prefillProviders: c.req.query("providers") - ? c.req.query("providers")!.split(",").filter(Boolean) - : undefined, - // Thread source conversation context for post-install callbacks - sourceContext: c.req.query("conversationId") - ? { - conversationId: c.req.query("conversationId")!, - channelId: channelParam || session.channelId || "", - teamId: session.teamId, - platform: effectivePlatform, - } - : undefined, - connectionId: c.req.query("connectionId") || undefined, - }; - - // Resolve scoped settings mode from connectionId or sandbox's parent connection - // For sandbox agents, always apply scopes (even for admins — admin manages from parent) - const agentMeta = await config.agentMetadataStore.getMetadata(agentId); - const isSandboxAgent = !!agentMeta?.parentConnectionId; - if (!payload.isAdmin || isSandboxAgent) { - const connectionIdParam = c.req.query("connectionId"); - const resolvedConnectionId = - connectionIdParam || agentMeta?.parentConnectionId; - if (resolvedConnectionId && config.chatInstanceManager) { - const connection = - await config.chatInstanceManager.getConnection(resolvedConnectionId); - if (connection?.settings?.userConfigScopes) { - payload.settingsMode = "user"; - payload.allowedScopes = connection.settings.userConfigScopes; - payload.connectionId = resolvedConnectionId; - } - } - } - - return await renderSettingsForPayload(c, config, payload, agentId); - }; - // GET /agent/logout — must be before the :agentId catch-all - app.get("/agent/logout", (c) => { - clearSettingsSessionCookie(c); - return c.redirect("/agents/login"); - }); - - app.get("/agent", agentPageHandler); - app.get("/agent/:agentId", agentPageHandler); - - // POST /api/v1/agents/:agentId/install-callback — Notify conversation after skill install - app.post("/api/v1/agents/:agentId/install-callback", async (c) => { - const agentId = c.req.param("agentId"); - - const body = await c.req - .json<{ - platform: string; - channelId: string; - conversationId: string; - connectionId?: string; - skills: string[]; - }>() - .catch(() => null); - - if (!body?.conversationId || !body?.channelId || !body?.skills?.length) { - return c.json({ error: "Missing required fields" }, 400); - } - - if (!config.interactionService) { - return c.json({ error: "Interaction service not configured" }, 500); - } - - try { - const skillList = body.skills.join(", "); - if (config.queueProducer) { - // All deps met — trigger the agent to continue - const agentOptions = await resolveAgentOptions( - agentId, - {}, - config.agentSettingsStore - ); - const messageId = `install-cb-${Date.now()}`; - const payload = buildMessagePayload({ - platform: body.platform, - userId: "system", - botId: body.platform, - conversationId: body.conversationId, - teamId: body.platform, - agentId, - messageId, - messageText: `[System: ${skillList} has been installed and is ready to use. Continue with the task.]`, - channelId: body.channelId, - platformMetadata: { - agentId, - source: "install-callback", - connectionId: body.connectionId, - }, - agentOptions, - }); - await config.queueProducer.enqueueMessage(payload); - } - - return c.json({ success: true }); - } catch (error) { - logger.error("Install callback failed", { error, agentId }); - return c.json({ error: "Failed to send notification" }, 500); - } - }); - - return app; -} - -/** - * Minimal bootstrap page for webapp-initdata authentication. - * Extracts initData from the URL hash fragment and POSTs to /agent/session. - */ -function renderWebAppBootstrapPage(): string { - return ` - - - - - - Loading Settings - Lobu - - - - -
-
-

Securing your settings session...

-

-
- - -`; -} diff --git a/packages/gateway/src/routes/public/agent.ts b/packages/gateway/src/routes/public/agent.ts index ea55d750e..bb954b014 100644 --- a/packages/gateway/src/routes/public/agent.ts +++ b/packages/gateway/src/routes/public/agent.ts @@ -1,12 +1,15 @@ import { randomUUID } from "node:crypto"; import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; import { + type AgentConfigStore, createLogger, createRootSpan, + findTemplateAgentId, generateWorkerToken, type InstalledProvider, type McpServerConfig, type NetworkConfig, + verifyWorkerToken, } from "@lobu/core"; import { streamSSE } from "hono/streaming"; import { z } from "zod"; @@ -19,6 +22,7 @@ import type { ExternalAuthClient } from "../../auth/external/client"; import type { AgentSettingsStore } from "../../auth/settings/agent-settings-store"; import type { QueueProducer } from "../../infrastructure/queue/queue-producer"; import { getModelProviderModules } from "../../modules/module-system"; +import type { PlatformRegistry } from "../../platform"; import { resolveAgentOptions } from "../../services/platform-helpers"; import type { ISessionManager, ThreadSession } from "../../session"; @@ -62,6 +66,10 @@ const CreateAgentRequestSchema = z.object({ provider: z.string().default("claude").optional(), model: z.string().optional(), agentId: z.string().min(1).optional(), + userId: z.string().min(1).optional(), + thread: z.string().optional(), + forceNew: z.boolean().optional(), + dryRun: z.boolean().optional(), networkConfig: NetworkConfigSchema.optional(), mcpServers: z.record(z.string(), McpServerConfigSchema).optional(), nix: NixConfigSchema.optional(), @@ -76,15 +84,36 @@ const CreateAgentResponseSchema = z.object({ messagesUrl: z.string(), }); -const SendMessageRequestSchema = z.object({ - content: z.string(), - messageId: z.string().optional(), +const SlackRoutingInfoSchema = z.object({ + channel: z.string().describe("Slack channel ID"), + thread: z.string().optional().describe("Thread timestamp for replies"), + team: z.string().optional().describe("Slack team ID"), }); +const SendMessageRequestSchema = z + .object({ + content: z.string().optional().describe("Message content"), + message: z + .string() + .optional() + .describe("Message content (alias for content)"), + messageId: z.string().optional(), + platform: z + .string() + .optional() + .describe("Target platform (api, slack, telegram)"), + slack: SlackRoutingInfoSchema.optional().describe( + "Slack-specific routing info (required when platform=slack)" + ), + }) + .passthrough(); + const SendMessageResponseSchema = z.object({ success: z.boolean(), messageId: z.string(), - jobId: z.string(), + agentId: z.string().optional(), + jobId: z.string().optional(), + eventsUrl: z.string().optional(), queued: z.boolean(), traceparent: z.string().optional(), }); @@ -353,11 +382,16 @@ const sendMessageRoute = createRoute({ path: "/api/v1/agents/{agentId}/messages", tags: ["Messages"], summary: "Send a message to the agent", + description: + "Send a message to an agent. Supports JSON body or multipart form data for file uploads. " + + "When platform is specified, the message is routed through the platform adapter.", security: [{ bearerAuth: [] }], request: { params: AgentIdParamSchema, body: { - content: { "application/json": { schema: SendMessageRequestSchema } }, + content: { + "application/json": { schema: SendMessageRequestSchema }, + }, }, }, responses: { @@ -373,6 +407,10 @@ const sendMessageRoute = createRoute({ description: "Unauthorized", content: { "application/json": { schema: ErrorResponseSchema } }, }, + 403: { + description: "Forbidden - worker tokens cannot route to platforms", + content: { "application/json": { schema: ErrorResponseSchema } }, + }, 404: { description: "Agent not found", content: { "application/json": { schema: ErrorResponseSchema } }, @@ -392,6 +430,8 @@ export interface AgentApiConfig { cliTokenService?: CliTokenService; externalAuthClient?: ExternalAuthClient; agentSettingsStore?: AgentSettingsStore; + agentConfigStore?: Pick; + platformRegistry?: PlatformRegistry; } export function createAgentApi(config: AgentApiConfig): OpenAPIHono; @@ -414,8 +454,14 @@ export function createAgentApi( publicGatewayUrl: publicGatewayUrl!, }; - const { queueProducer, adminPassword, cliTokenService, agentSettingsStore } = - config; + const { + queueProducer, + adminPassword, + cliTokenService, + agentSettingsStore, + agentConfigStore, + platformRegistry, + } = config; const sessMgr = config.sessionManager; const pubUrl = config.publicGatewayUrl; const app = new OpenAPIHono(); @@ -442,6 +488,10 @@ export function createAgentApi( provider = "claude", model, agentId: requestedAgentId, + userId: requestedUserId, + thread, + forceNew, + dryRun, networkConfig, mcpServers, nix: nixConfig, @@ -485,9 +535,12 @@ export function createAgentApi( if (systemProviders.length > 0) { // Also inherit pluginsConfig from template agent if available - const templateId = await agentSettingsStore.findTemplateAgentId(); + const templateId = agentConfigStore + ? await findTemplateAgentId(agentConfigStore) + : await agentSettingsStore.findTemplateAgentId(); const templateSettings = templateId - ? await agentSettingsStore.getSettings(templateId) + ? await (agentConfigStore?.getSettings(templateId) ?? + agentSettingsStore.getSettings(templateId)) : null; await agentSettingsStore.saveSettings(agentId, { installedProviders: systemProviders, @@ -498,10 +551,13 @@ export function createAgentApi( ); } else { // Fall back to using an existing agent as template (inherits its providers) - const templateId = await agentSettingsStore.findTemplateAgentId(); + const templateId = agentConfigStore + ? await findTemplateAgentId(agentConfigStore) + : await agentSettingsStore.findTemplateAgentId(); if (templateId) { - const templateSettings = - await agentSettingsStore.getSettings(templateId); + const templateSettings = await (agentConfigStore?.getSettings( + templateId + ) ?? agentSettingsStore.getSettings(templateId)); await agentSettingsStore.saveSettings(agentId, { templateAgentId: templateId, pluginsConfig: templateSettings?.pluginsConfig, @@ -513,15 +569,61 @@ export function createAgentApi( } } - const conversationId = agentId; - const channelId = `api-${agentId.slice(0, 8)}`; + const userId = requestedUserId || agentId; + + // Build composite conversationId for user-specific sessions + // Uses _ separator (colons not allowed in BullMQ custom IDs) + const conversationId = thread + ? `${agentId}_${userId}_${thread}` + : `${agentId}_${userId}`; + const channelId = `api_${userId}`; const deploymentName = `api-${agentId.slice(0, 8)}`; + // Try to resume existing session (unless forceNew is requested) + if (!forceNew) { + const existing = await sessMgr.getSession(conversationId); + if (existing) { + // Reuse existing session — touch lastActivity and return existing token + await sessMgr.touchSession(conversationId); + + const token = generateWorkerToken( + agentId, + conversationId, + deploymentName, + { + channelId, + agentId, + platform: "api", + sessionKey: userId, + } + ); + + const expiresAt = Date.now() + TOKEN_EXPIRATION_MS; + const baseUrl = pubUrl || "http://localhost:8080"; + + logger.info( + `Resumed API session: ${conversationId} (agent=${agentId})` + ); + + return c.json( + { + success: true, + agentId: conversationId, + token, + expiresAt, + sseUrl: `${baseUrl}/api/v1/agents/${conversationId}/events`, + messagesUrl: `${baseUrl}/api/v1/agents/${conversationId}/messages`, + }, + 201 + ); + } + } + const token = generateWorkerToken(agentId, conversationId, deploymentName, { channelId, agentId, platform: "api", - sessionKey: agentId, + sessionKey: userId, }); const expiresAt = Date.now() + TOKEN_EXPIRATION_MS; @@ -529,8 +631,8 @@ export function createAgentApi( const session: ThreadSession = { conversationId, channelId, - userId: agentId, - threadCreator: agentId, + userId, + threadCreator: userId, lastActivity: Date.now(), createdAt: Date.now(), status: "created", @@ -541,20 +643,22 @@ export function createAgentApi( ? { mcpServers: mcpServers as Record } : undefined, nixConfig, + agentId, + dryRun: dryRun || false, }; await sessMgr.setSession(session); - logger.info(`Created API agent: ${agentId}`); + logger.info(`Created API agent: ${conversationId} (agent=${agentId})`); const baseUrl = pubUrl || "http://localhost:8080"; return c.json( { success: true, - agentId, + agentId: conversationId, token, expiresAt, - sseUrl: `${baseUrl}/api/v1/agents/${agentId}/events`, - messagesUrl: `${baseUrl}/api/v1/agents/${agentId}/messages`, + sseUrl: `${baseUrl}/api/v1/agents/${conversationId}/events`, + messagesUrl: `${baseUrl}/api/v1/agents/${conversationId}/messages`, }, 201 ); @@ -562,16 +666,16 @@ export function createAgentApi( // GET /api/v1/agents/:agentId - Get status app.openapi(getAgentRoute, async (c): Promise => { - const { agentId } = c.req.valid("param"); + const { agentId: sessionKey } = c.req.valid("param"); - const session = await sessMgr.getSession(agentId); + const session = await sessMgr.getSession(sessionKey); if (!session) { return c.json({ success: false, error: "Agent not found" }, 404); } const hasActiveConnection = - sseConnections.has(agentId) && - (sseConnections.get(agentId)?.size ?? 0) > 0; + sseConnections.has(sessionKey) && + (sseConnections.get(sessionKey)?.size ?? 0) > 0; return c.json({ success: true, @@ -588,9 +692,9 @@ export function createAgentApi( // DELETE /api/v1/agents/:agentId app.openapi(deleteAgentRoute, async (c): Promise => { - const { agentId } = c.req.valid("param"); + const { agentId: sessionKey } = c.req.valid("param"); - const connections = sseConnections.get(agentId); + const connections = sseConnections.get(sessionKey); if (connections) { for (const connection of connections) { try { @@ -610,26 +714,34 @@ export function createAgentApi( // Ignore } } - sseConnections.delete(agentId); + sseConnections.delete(sessionKey); } - await sessMgr.deleteSession(agentId); + // Get real agentId from session before deleting + const session = await sessMgr.getSession(sessionKey); + const realAgentId = session?.agentId || sessionKey; + + await sessMgr.deleteSession(sessionKey); // Clean up ephemeral agent settings if (agentSettingsStore) { - await agentSettingsStore.deleteSettings(agentId).catch(() => { + await agentSettingsStore.deleteSettings(realAgentId).catch(() => { /* best-effort cleanup */ }); } - logger.info(`Deleted agent ${agentId}`); + logger.info(`Deleted agent ${sessionKey}`); - return c.json({ success: true, message: "Agent deleted", agentId }); + return c.json({ + success: true, + message: "Agent deleted", + agentId: sessionKey, + }); }); // GET /api/v1/agents/:agentId/events - SSE stream app.openapi(getAgentEventsRoute, async (c): Promise => { - const { agentId } = c.req.valid("param"); + const { agentId: sessionKey } = c.req.valid("param"); - const session = await sessMgr.getSession(agentId); + const session = await sessMgr.getSession(sessionKey); if (!session) { return c.json({ success: false, error: "Agent not found" }, 404); } @@ -646,10 +758,12 @@ export function createAgentApi( ); } - if (!sseConnections.has(agentId)) { - sseConnections.set(agentId, new Set()); + // Use conversationId as the SSE connection key (matches broadcastToAgent calls) + const sseKey = session.conversationId; + if (!sseConnections.has(sseKey)) { + sseConnections.set(sseKey, new Set()); } - const agentConnections = sseConnections.get(agentId)!; + const agentConnections = sseConnections.get(sseKey)!; if (agentConnections.size >= MAX_CONNECTIONS_PER_AGENT) { return c.json( { @@ -666,7 +780,10 @@ export function createAgentApi( await stream.writeSSE({ event: "connected", - data: JSON.stringify({ agentId, timestamp: Date.now() }), + data: JSON.stringify({ + agentId: session.agentId || sessionKey, + timestamp: Date.now(), + }), }); const heartbeatInterval = setInterval(async () => { @@ -684,9 +801,9 @@ export function createAgentApi( clearInterval(heartbeatInterval); agentConnections.delete(stream); if (agentConnections.size === 0) { - sseConnections.delete(agentId); + sseConnections.delete(sseKey); } - logger.info(`SSE connection closed for agent ${agentId}`); + logger.info(`SSE connection closed for session ${sseKey}`); }); while (true) { @@ -696,16 +813,202 @@ export function createAgentApi( }); // POST /api/v1/agents/:agentId/messages - Send message + // Supports two paths: + // 1. Direct API (no platform field): requires pre-created session, enqueues directly + // 2. Platform-routed (platform field present): delegates to platform adapter app.openapi(sendMessageRoute, async (c): Promise => { const { agentId } = c.req.valid("param"); - const body = c.req.valid("json"); - const { content, messageId = randomUUID() } = body; + // Parse body — multipart for file uploads, JSON otherwise + const contentType = c.req.header("content-type") || ""; + let body: Record; + let files: Array<{ buffer: Buffer; filename: string }> | undefined; + + if (contentType.includes("multipart/form-data")) { + const formData = await c.req.formData(); + body = { + content: formData.get("content") as string | null, + message: formData.get("message") as string | null, + messageId: formData.get("messageId") as string | null, + platform: formData.get("platform") as string | null, + }; + + // Extract nested platform routing from form fields + const slackChannel = formData.get("slack.channel") as string; + if (slackChannel) { + body.slack = { + channel: slackChannel, + thread: formData.get("slack.thread") as string | undefined, + team: formData.get("slack.team") as string | undefined, + }; + } + const whatsappChat = formData.get("whatsapp.chat") as string; + if (whatsappChat) { + body.whatsapp = { chat: whatsappChat }; + } + const telegramChatId = formData.get("telegram.chatId") as string; + if (telegramChatId) { + body.telegram = { chatId: telegramChatId }; + } + + // Extract files with size validation + const MAX_FILE_SIZE = 50 * 1024 * 1024; + const MAX_TOTAL_SIZE = 100 * 1024 * 1024; + const MAX_FILE_COUNT = 10; + const fileEntries = formData.getAll("files"); + if (fileEntries.length > MAX_FILE_COUNT) { + return c.json( + { + success: false, + error: `Too many files: ${fileEntries.length} (max ${MAX_FILE_COUNT})`, + }, + 400 + ); + } + if (fileEntries.length > 0) { + const fileResults: Array<{ buffer: Buffer; filename: string }> = []; + let totalSize = 0; + for (const entry of fileEntries) { + if (entry instanceof File) { + if (entry.size > MAX_FILE_SIZE) { + return c.json( + { + success: false, + error: `File "${entry.name}" exceeds maximum size of ${MAX_FILE_SIZE / 1024 / 1024}MB`, + }, + 400 + ); + } + totalSize += entry.size; + if (totalSize > MAX_TOTAL_SIZE) { + return c.json( + { + success: false, + error: `Total upload size exceeds maximum of ${MAX_TOTAL_SIZE / 1024 / 1024}MB`, + }, + 400 + ); + } + const arrayBuffer = await entry.arrayBuffer(); + fileResults.push({ + buffer: Buffer.from(arrayBuffer), + filename: entry.name, + }); + } + } + if (fileResults.length > 0) files = fileResults; + } + } else { + body = c.req.valid("json"); + } + + const messageContent = body.content || body.message; + const messageId = body.messageId || randomUUID(); - if (!content || typeof content !== "string") { + if (!messageContent || typeof messageContent !== "string") { return c.json({ success: false, error: "content is required" }, 400); } + const platform = body.platform as string | undefined; + + // ── Platform-routed path ────────────────────────────────────────────────── + // When platform is specified, delegate to the platform adapter which handles + // session creation, routing, and file delivery. + if (platform) { + // Worker tokens cannot route to user-facing platform connections + const authHeader = c.req.header("Authorization"); + const rawToken = authHeader?.startsWith("Bearer ") + ? authHeader.substring(7) + : ""; + if (verifyWorkerToken(rawToken)) { + return c.json( + { success: false, error: "Worker tokens cannot route to platforms" }, + 403 + ); + } + + if (!platformRegistry) { + return c.json( + { success: false, error: "Platform routing not available" }, + 501 + ); + } + + const adapter = platformRegistry.get(platform); + if (!adapter) { + return c.json( + { + success: false, + error: `Platform "${platform}" not found`, + details: `Available: ${platformRegistry.getAvailablePlatforms().join(", ")}`, + }, + 404 + ); + } + + if (!adapter.sendMessage) { + return c.json( + { + success: false, + error: `Platform "${platform}" does not support sendMessage`, + }, + 501 + ); + } + + // Extract platform-specific routing info + let channelId = agentId; + let conversationId: string | undefined = + platform === "api" ? agentId : undefined; + let teamId = "api"; + + if (adapter.extractRoutingInfo) { + const routingInfo = adapter.extractRoutingInfo( + body as Record + ); + if (routingInfo) { + channelId = routingInfo.channelId; + conversationId = routingInfo.conversationId || conversationId; + teamId = routingInfo.teamId || "api"; + } else if (platform !== "api") { + return c.json( + { + success: false, + error: `Platform-specific routing info required for ${platform}`, + }, + 400 + ); + } + } + + logger.info( + `Sending message via ${platform}: agentId=${agentId}, channelId=${channelId}${files?.length ? `, files=${files.length}` : ""}` + ); + + try { + const result = await adapter.sendMessage(rawToken, messageContent, { + agentId, + channelId, + conversationId, + teamId, + files, + }); + + return c.json({ + success: true, + agentId, + messageId: result.messageId, + eventsUrl: result.eventsUrl, + queued: result.queued || false, + }); + } catch (error) { + logger.error("Failed to send platform message", { error }); + return c.json({ success: false, error: "Internal server error" }, 500); + } + } + + // ── Direct API path ─────────────────────────────────────────────────────── + // No platform field: use existing session-based direct enqueue const session = await sessMgr.getSession(agentId); if (!session) { return c.json({ success: false, error: "Agent not found" }, 404); @@ -713,26 +1016,26 @@ export function createAgentApi( await sessMgr.touchSession(agentId); + const realAgentId = session.agentId || agentId; + const { span: rootSpan, traceparent } = createRootSpan("message_received", { - "lobu.agent_id": agentId, + "lobu.agent_id": realAgentId, "lobu.message_id": messageId, }); try { - const channelId = session.channelId || `api-${agentId.slice(0, 8)}`; + const channelId = session.channelId || `api_${session.userId}`; - // Merge agent settings (pluginsConfig, toolsConfig, etc.) like platform handlers do const baseOptions: Record = { provider: session.provider || "claude", model: session.model, }; const agentOptions = await resolveAgentOptions( - agentId, + realAgentId, baseOptions, agentSettingsStore ); - // Extract settings-level overrides that resolveAgentOptions may have added const { networkConfig: settingsNetwork, mcpServers: settingsMcpServers, @@ -745,14 +1048,15 @@ export function createAgentApi( messageId, channelId, teamId: "api", - agentId: agentId, + agentId: realAgentId, botId: "lobu-api", platform: "api", - messageText: content, + messageText: messageContent, platformMetadata: { - agentId, + agentId: realAgentId, source: "direct-api", traceparent: traceparent || undefined, + dryRun: session.dryRun || false, }, agentOptions: remainingOptions, networkConfig: session.networkConfig || settingsNetwork, diff --git a/packages/gateway/src/routes/public/agents-page.ts b/packages/gateway/src/routes/public/agents-page.ts deleted file mode 100644 index fccf5ee2f..000000000 --- a/packages/gateway/src/routes/public/agents-page.ts +++ /dev/null @@ -1,529 +0,0 @@ -/** - * Agents Page — system skills registry + connections management. - * Preact SPA with server-injected state. - */ - -import * as crypto from "node:crypto"; -import * as fs from "node:fs"; -import * as path from "node:path"; -import { OpenAPIHono } from "@hono/zod-openapi"; -import { createLogger } from "@lobu/core"; -import type { Context } from "hono"; -import type { AgentMetadataStore } from "../../auth/agent-metadata-store"; -import type { SettingsTokenPayload } from "../../auth/settings/token-service"; -import type { SystemEnvStore } from "../../auth/system-env-store"; -import type { UserAgentsStore } from "../../auth/user-agents-store"; -import type { ChatInstanceManager } from "../../connections/chat-instance-manager"; -import { getModelProviderModules } from "../../modules/module-system"; -import type { SystemSkillsService } from "../../services/system-skills-service"; -import { pageCSS } from "./page-styles"; -import { - setSettingsSessionCookie, - verifySettingsSession, -} from "./settings-auth"; - -const logger = createLogger("agents-page-routes"); - -const _agentsPageBundlePath = path.resolve( - __dirname, - "agents-page-bundle.raw.js" -); -function getAgentsPageJS(): string { - try { - const content = fs.readFileSync(_agentsPageBundlePath, "utf-8"); - return `/* AGENTS_BUNDLE_LOADED_AT_${Date.now()} */ ${content}`; - } catch (e) { - return `document.getElementById("app").textContent = "Bundle error: ${String(e).replace(/"/g, "'")}";`; - } -} - -function esc(text: string): string { - return text - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """); -} - -// ─── Env Var Helpers ────────────────────────────────────────────────────────── - -const ENV_REF_REGEX = /\$\{(?:env:)?([A-Z_][A-Z0-9_]*)\}/g; - -/** Extract all ${env:KEY} references from an object tree */ -function extractEnvRefs(obj: unknown): string[] { - const refs = new Set(); - const str = JSON.stringify(obj); - for (const match of str.matchAll(ENV_REF_REGEX)) { - if (match[1]) refs.add(match[1]); - } - return [...refs]; -} - -interface EnvVarEntry { - key: string; - /** Section this var belongs to: provider:, integration:, mcp:, gateway */ - section: string; - label: string; - isSet: boolean; - maskedValue: string | null; -} - -function maskValue(value: string): string { - if (value.length <= 4) return "****"; - return `${"*".repeat(4)}${value.slice(-4)}`; -} - -/** - * Build the env var catalog dynamically from registered providers, - * system-skill MCP servers, and static gateway/connection vars. - */ -function buildEnvCatalog( - skills: any[], - redisOverrides: Record -): { vars: EnvVarEntry[]; allowedKeys: Set } { - const vars: EnvVarEntry[] = []; - const seen = new Set(); - - function addVar(key: string, section: string, label: string) { - if (seen.has(key)) return; - seen.add(key); - const redisVal = redisOverrides[key]; - const processVal = process.env[key]; - const value = redisVal ?? processVal; - vars.push({ - key, - section, - label, - isSet: !!value, - maskedValue: value ? maskValue(value) : null, - }); - } - - // 1. LLM Providers — from module registry - for (const mod of getModelProviderModules()) { - if (mod.catalogVisible === false) continue; - const envVars = mod.getSecretEnvVarNames?.() || []; - for (const key of envVars) { - addVar(key, `provider:${mod.providerId}`, mod.providerDisplayName); - } - } - - // 2. MCP Servers — extract ${env:*} from server configs - for (const skill of skills) { - const raw = skill as any; - if (!raw.mcpServers) continue; - for (const srv of raw.mcpServers) { - const refs = extractEnvRefs(srv); - for (const key of refs) { - addVar(key, `mcp:${srv.name || srv.id}`, srv.name || srv.id); - } - } - } - - return { vars, allowedKeys: seen }; -} - -interface AgentsPageConfig { - systemSkillsService: SystemSkillsService; - userAgentsStore: UserAgentsStore; - agentMetadataStore: AgentMetadataStore; - chatInstanceManager?: ChatInstanceManager; - systemEnvStore?: SystemEnvStore; - adminPassword: string; - oauthEnabled?: boolean; - version?: string; - githubUrl?: string; -} - -function requireAdmin(c: Context): SettingsTokenPayload | null { - const session = verifySettingsSession(c); - if (!session || !session.isAdmin) return null; - return session; -} - -function verifyPassword(input: string, expected: string): boolean { - const a = Buffer.from(input); - const b = Buffer.from(expected); - if (a.length !== b.length) return false; - return crypto.timingSafeEqual(a, b); -} - -export function createAgentsPageRoutes(config: AgentsPageConfig) { - const app = new OpenAPIHono(); - - // ─── Agents Login ────────────────────────────────────────────────────────── - - app.get("/agents/login", (c) => { - const session = verifySettingsSession(c); - if (session?.isAdmin) return c.redirect("/agents"); - // Redirect to OAuth if configured, otherwise show password form - if (config.oauthEnabled) { - return c.redirect("/agent/oauth/login?returnUrl=/agents"); - } - const error = c.req.query("error"); - return c.html(renderAgentsLoginPage(error || undefined)); - }); - - app.post("/agents/login", async (c) => { - const body = await c.req.parseBody(); - const password = typeof body.password === "string" ? body.password : ""; - - if (!verifyPassword(password, config.adminPassword)) { - return c.redirect("/agents/login?error=Invalid+password"); - } - - // Create or upgrade session with isAdmin - let session = verifySettingsSession(c); - if (session) { - session = { ...session, isAdmin: true }; - } else { - session = { - userId: "admin", - platform: "admin", - exp: Date.now() + 24 * 60 * 60 * 1000, - isAdmin: true, - }; - } - setSettingsSessionCookie(c, session); - return c.redirect("/agents"); - }); - - // ─── Agents State Builder ──────────────────────────────────────────────── - - async function buildAgentsState() { - const rawSkills = - (await config.systemSkillsService.getSystemSkills()) || []; - const providerConfigs = - await config.systemSkillsService.getProviderConfigs(); - - const systemSkillProviderIds = new Set(); - const adminSkills: { - id: string; - name: string; - description?: string; - source: string; - mcpServers: { name: string; type: string; url: string }[]; - providers: { - providerId: string; - displayName: string; - defaultModel: string; - sdkCompat: string; - }[]; - }[] = []; - - for (const skill of rawSkills) { - const raw = skill as any; - const skillId = skill.repo.replace("system/", ""); - - const skillMcpServers: (typeof adminSkills)[0]["mcpServers"] = []; - const servers = raw.mcpServers || []; - for (const srv of servers) { - skillMcpServers.push({ - name: srv.name || srv.id, - type: srv.type || "sse", - url: srv.url || srv.command || "-", - }); - } - - const skillProviders: (typeof adminSkills)[0]["providers"] = []; - const providerEntry = providerConfigs[skillId]; - if (providerEntry) { - systemSkillProviderIds.add(skillId); - skillProviders.push({ - providerId: skillId, - displayName: providerEntry.displayName, - defaultModel: providerEntry.defaultModel || "-", - sdkCompat: providerEntry.sdkCompat || "-", - }); - } - - adminSkills.push({ - id: skillId, - name: skill.name, - description: raw.description, - source: "lobu", - mcpServers: skillMcpServers, - providers: skillProviders, - }); - } - - const builtInProviders: (typeof adminSkills)[0]["providers"] = []; - for (const mod of getModelProviderModules()) { - if (mod.catalogVisible === false) continue; - if (systemSkillProviderIds.has(mod.providerId)) continue; - builtInProviders.push({ - providerId: mod.providerId, - displayName: mod.providerDisplayName, - defaultModel: "-", - sdkCompat: mod.authType || "-", - }); - } - if (builtInProviders.length > 0) { - adminSkills.push({ - id: "__built-in__", - name: "Built-in", - source: "built-in", - mcpServers: [], - providers: builtInProviders, - }); - } - - const allAgents = await config.agentMetadataStore.listAllAgents(); - - // Enrich agents with connection counts and platforms - const allConnections = config.chatInstanceManager - ? await config.chatInstanceManager.listConnections() - : []; - const connectionsByAgent = new Map< - string, - { count: number; platforms: string[] } - >(); - for (const conn of allConnections) { - const agentId = (conn as any).templateAgentId; - if (!agentId) continue; - const entry = connectionsByAgent.get(agentId) || { - count: 0, - platforms: [], - }; - entry.count++; - if (!entry.platforms.includes(conn.platform)) { - entry.platforms.push(conn.platform); - } - connectionsByAgent.set(agentId, entry); - } - - const agents = allAgents.map((a) => { - const connInfo = connectionsByAgent.get(a.agentId); - return { - agentId: a.agentId, - name: a.name, - description: a.description || "", - owner: a.owner, - parentConnectionId: a.parentConnectionId || null, - createdAt: a.createdAt, - lastUsedAt: a.lastUsedAt ?? null, - connectionCount: connInfo?.count ?? 0, - platforms: connInfo?.platforms ?? [], - }; - }); - - const plugins = [ - { - source: "@lobu/owletto-openclaw", - name: "Owletto Memory", - slot: "memory", - enabled: true, - configured: !!process.env.AUTH_MCP_URL, - settingsUrl: "/agent#skills", - }, - ]; - - return { - version: config.version || process.env.npm_package_version || "unknown", - githubUrl: config.githubUrl || "", - deploymentMode: process.env.DEPLOYMENT_MODE || "docker", - uptime: Math.floor(process.uptime()), - skills: adminSkills, - agents, - plugins, - }; - } - - // ─── Agents Pages ───────────────────────────────────────────────────────── - - async function handleAgentsPage(c: Context) { - const session = requireAdmin(c); - if (!session) return c.redirect("/agents/login"); - try { - const agentsState = await buildAgentsState(); - return c.html(renderAgentsPage(agentsState)); - } catch (error) { - logger.error("Failed to render agents page", { error }); - return c.html( - renderAgentsErrorPage("Failed to load system skills."), - 500 - ); - } - } - - app.get("/agents", handleAgentsPage); - - // ─── Agents API ────────────────────────────────────────────────────────── - - app.get("/api/v1/admin/agents", async (c) => { - if (!requireAdmin(c)) return c.json({ error: "Unauthorized" }, 401); - try { - const agents = await config.agentMetadataStore.listAllAgents(); - return c.json({ - agents: agents.map((a) => ({ - agentId: a.agentId, - name: a.name, - description: a.description || "", - owner: a.owner, - parentConnectionId: a.parentConnectionId || null, - createdAt: a.createdAt, - lastUsedAt: a.lastUsedAt ?? null, - })), - }); - } catch (error) { - logger.error("Failed to list agents", { error }); - return c.json({ error: "Failed to list agents" }, 500); - } - }); - - // ─── System Env API ──────────────────────────────────────────────────────── - - if (config.systemEnvStore) { - const envStore = config.systemEnvStore; - - /** Build catalog and allowlist from current state */ - async function getCatalog() { - const skills = - (await config.systemSkillsService.getRawSystemSkills()) || []; - const redisOverrides = await envStore.listAll(); - return buildEnvCatalog(skills, redisOverrides); - } - - app.get("/api/v1/admin/env", async (c) => { - if (!requireAdmin(c)) return c.json({ error: "Unauthorized" }, 401); - - try { - const { vars } = await getCatalog(); - return c.json({ vars }); - } catch (error) { - logger.error("Failed to list env vars", { error }); - return c.json({ error: "Failed to list env vars" }, 500); - } - }); - - app.put("/api/v1/admin/env/:key", async (c) => { - if (!requireAdmin(c)) return c.json({ error: "Unauthorized" }, 401); - - const key = c.req.param("key"); - const { allowedKeys } = await getCatalog(); - if (!allowedKeys.has(key)) { - return c.json({ error: "Key not in allowed catalog" }, 400); - } - - try { - const body = await c.req.json(); - const value = body?.value; - if (typeof value !== "string" || value.length === 0) { - return c.json({ error: "Missing or empty value" }, 400); - } - - await envStore.set(key, value); - return c.json({ success: true, maskedValue: maskValue(value) }); - } catch (error) { - logger.error("Failed to set env var", { key, error }); - return c.json({ error: "Failed to set env var" }, 500); - } - }); - - app.delete("/api/v1/admin/env/:key", async (c) => { - if (!requireAdmin(c)) return c.json({ error: "Unauthorized" }, 401); - - const key = c.req.param("key"); - const { allowedKeys } = await getCatalog(); - if (!allowedKeys.has(key)) { - return c.json({ error: "Key not in allowed catalog" }, 400); - } - - try { - await envStore.delete(key); - const processVal = process.env[key]; - return c.json({ - success: true, - isSet: !!processVal, - maskedValue: processVal ? maskValue(processVal) : null, - }); - } catch (error) { - logger.error("Failed to delete env var", { key, error }); - return c.json({ error: "Failed to delete env var" }, 500); - } - }); - } - - return app; -} - -// ─── HTML Renderers ────────────────────────────────────────────────────────── - -function renderAgentsPage(agentsState: Record): string { - return ` - - - - - - Agents - - - -
-
-
- - - -`; -} - -function renderAgentsLoginPage(error?: string): string { - return ` - - - - - - Agents Login - - - -
-

Agents Login

- ${error ? `
${esc(error)}
` : ""} -
- - - -
-
- -`; -} - -function renderAgentsErrorPage(message: string): string { - return ` - - - - - Agents Error - - - -
-

Agents Error

-

Unable to load agents page.

-
${esc(message)}
-
- -`; -} diff --git a/packages/gateway/src/routes/public/agents-page/EnvVarRow.tsx b/packages/gateway/src/routes/public/agents-page/EnvVarRow.tsx deleted file mode 100644 index 075cafaea..000000000 --- a/packages/gateway/src/routes/public/agents-page/EnvVarRow.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { useSignal } from "@preact/signals"; -import type { EnvVarEntry } from "../agent-page/api"; -import * as api from "../agent-page/api"; - -export function EnvVarRow({ - entry, - onRefresh, -}: { - entry: EnvVarEntry; - onRefresh: () => void; -}) { - const expanded = useSignal(false); - const inputValue = useSignal(""); - const saving = useSignal(false); - const error = useSignal(""); - - const handleSave = async () => { - if (!inputValue.value.trim()) return; - saving.value = true; - error.value = ""; - try { - await api.setEnvVar(entry.key, inputValue.value.trim()); - inputValue.value = ""; - expanded.value = false; - onRefresh(); - } catch (e: unknown) { - error.value = e instanceof Error ? e.message : "Failed to save"; - } finally { - saving.value = false; - } - }; - - const handleClear = async () => { - saving.value = true; - error.value = ""; - try { - await api.deleteEnvVar(entry.key); - expanded.value = false; - onRefresh(); - } catch (e: unknown) { - error.value = e instanceof Error ? e.message : "Failed to clear"; - } finally { - saving.value = false; - } - }; - - return ( -
- - {expanded.value && ( -
-
- { - inputValue.value = (e.target as HTMLInputElement).value; - }} - onKeyDown={(e) => { - if (e.key === "Enter") handleSave(); - }} - /> - - {entry.isSet && ( - - )} -
- {error.value && ( -

{error.value}

- )} -
- )} -
- ); -} diff --git a/packages/gateway/src/routes/public/agents-page/app.tsx b/packages/gateway/src/routes/public/agents-page/app.tsx deleted file mode 100644 index 1466ce849..000000000 --- a/packages/gateway/src/routes/public/agents-page/app.tsx +++ /dev/null @@ -1,500 +0,0 @@ -import { useSignal } from "@preact/signals"; -import { render } from "preact"; -import type { EnvVarEntry } from "../agent-page/api"; -import * as api from "../agent-page/api"; -import { EnvVarRow } from "./EnvVarRow"; - -declare global { - interface Window { - __AGENTS_STATE__: AdminState; - } -} - -interface AdminAgent { - agentId: string; - name: string; - description: string; - owner: { platform: string; userId: string }; - parentConnectionId: string | null; - createdAt: number; - lastUsedAt: number | null; - connectionCount: number; - platforms: string[]; -} - -interface AdminPlugin { - source: string; - name: string; - slot: string; - enabled: boolean; - configured: boolean; - settingsUrl?: string; -} - -interface AdminState { - version: string; - githubUrl: string; - deploymentMode: string; - uptime: number; - agents: AdminAgent[]; - plugins: AdminPlugin[]; -} - -const PLATFORM_LABELS: Record = { - telegram: "Telegram", - slack: "Slack", - discord: "Discord", - whatsapp: "WhatsApp", - teams: "Teams", -}; - -const PLATFORM_DOMAINS: Record = { - telegram: "telegram.org", - slack: "slack.com", - discord: "discord.com", - whatsapp: "whatsapp.com", - teams: "teams.microsoft.com", -}; - -// ─── Top Bar ──────────────────────────────────────────────────────────────── - -function TopBar({ githubUrl }: { githubUrl: string }) { - return ( -
-
- - Agents -
-
- - API Docs - - {githubUrl && ( - - GitHub - - )} - - Logout - -
-
- ); -} - -// ─── Agent List ───────────────────────────────────────────────────────────── - -function AgentRow({ agent }: { agent: AdminAgent }) { - const settingsUrl = `/agent/${encodeURIComponent(agent.agentId)}`; - - return ( - - {/* Platform favicons */} -
- {agent.platforms.length > 0 ? ( - agent.platforms.slice(0, 2).map((p) => { - const domain = PLATFORM_DOMAINS[p]; - return domain ? ( - {PLATFORM_LABELS[p] - ) : null; - }) - ) : ( - - )} -
- - {/* Name + description */} -
-

- {agent.name} -

-

- {agent.description || "No description"} -

-
- - {/* Connection count badge */} - {agent.connectionCount > 0 && ( - - {agent.connectionCount} - - )} - - {/* Arrow */} - -
- ); -} - -function AgentList({ initialAgents }: { initialAgents: AdminAgent[] }) { - const agents = useSignal(initialAgents); - const showNewForm = useSignal(false); - const successMsg = useSignal(""); - const errorMsg = useSignal(""); - - // Filter: top-level agents exclude sandboxes - const topLevelAgents = agents.value.filter((a) => !a.parentConnectionId); - - function flashSuccess(msg: string) { - successMsg.value = msg; - setTimeout(() => { - successMsg.value = ""; - }, 3000); - } - - function handleAgentCreated(agent: AdminAgent) { - agents.value = [...agents.value, agent]; - showNewForm.value = false; - flashSuccess("Agent created!"); - } - - return ( -
- {successMsg.value && ( -
- {successMsg.value} -
- )} - {errorMsg.value && ( -
- {errorMsg.value} -
- )} - - {topLevelAgents.length === 0 && ( -

- No agents yet. Create one to get started. -

- )} - - {topLevelAgents.map((agent) => ( - - ))} - - {/* Create agent */} -
- {showNewForm.value ? ( - { - showNewForm.value = false; - }} - /> - ) : ( - - )} -
-
- ); -} - -// ─── New Agent Form ───────────────────────────────────────────────────────── - -function NewAgentForm({ - onCreated, - onCancel, -}: { - onCreated: (agent: AdminAgent) => void; - onCancel: () => void; -}) { - const agentId = useSignal(""); - const name = useSignal(""); - const description = useSignal(""); - const formError = useSignal(""); - const formLoading = useSignal(false); - - async function handleSubmit() { - formError.value = ""; - if (!name.value.trim()) { - formError.value = "Name is required"; - return; - } - if (!agentId.value.trim()) { - formError.value = "Agent ID is required"; - return; - } - formLoading.value = true; - try { - await api.createAgent(agentId.value.trim(), name.value.trim()); - onCreated({ - agentId: agentId.value.trim(), - name: name.value.trim(), - description: description.value.trim(), - owner: { platform: "admin", userId: "admin" }, - parentConnectionId: null, - createdAt: Date.now(), - lastUsedAt: null, - connectionCount: 0, - platforms: [], - }); - } catch (e: unknown) { - formError.value = - e instanceof Error ? e.message : "Failed to create agent"; - } finally { - formLoading.value = false; - } - } - - return ( -
-
- -

- Lowercase, 3-60 chars, starts with a letter -

-
-
- -
-
- -
- - {formError.value && ( -
- {formError.value} -
- )} - -
- - -
-
- ); -} - -// ─── Plugins Section ───────────────────────────────────────────────────────── - -const SLOT_COLORS: Record = { - memory: "bg-purple-100 text-purple-700", - tool: "bg-blue-100 text-blue-700", - provider: "bg-amber-100 text-amber-700", -}; - -function PluginsSection({ plugins }: { plugins: AdminPlugin[] }) { - if (plugins.length === 0) return null; - return ( -
-
- Plugins - - {plugins.length} - -
-
- {plugins.map((p) => ( -
-
-

{p.name}

-

{p.source}

-
- - {p.slot} - - {p.configured && p.enabled ? ( - - - Active - - ) : ( - - - Not Configured - - )} -
- ))} -
-
- ); -} - -// ─── Gateway Section ──────────────────────────────────────────────────────── - -function GatewaySection({ - envVars, - onRefreshEnv, -}: { - envVars: EnvVarEntry[]; - onRefreshEnv: () => void; -}) { - const isOpen = useSignal(false); - if (envVars.length === 0) return null; - return ( -
- - {isOpen.value && ( -
-
- {envVars.map((entry) => ( - - ))} -
-
- )} -
- ); -} - -// ─── Env Var Data Hook ────────────────────────────────────────────────────── - -function useEnvVars() { - const vars = useSignal([]); - const loading = useSignal(true); - const initialized = useSignal(false); - - const loadVars = () => { - loading.value = true; - api - .listEnvVars() - .then((data) => { - vars.value = data; - }) - .catch(() => { - // Silently ignore env var load failures - }) - .finally(() => { - loading.value = false; - }); - }; - - if (!initialized.value) { - initialized.value = true; - loadVars(); - } - - const gateway = vars.value.filter((v) => v.section === "gateway"); - - return { gateway, loadVars, loading }; -} - -// ─── App ──────────────────────────────────────────────────────────────────── - -function App() { - const state = window.__AGENTS_STATE__; - const env = useEnvVars(); - - return ( -
- -
- - - -
-
- ); -} - -render(, document.getElementById("app")!); diff --git a/packages/gateway/src/routes/public/agents.ts b/packages/gateway/src/routes/public/agents.ts index 700713fcc..346b96c66 100644 --- a/packages/gateway/src/routes/public/agents.ts +++ b/packages/gateway/src/routes/public/agents.ts @@ -13,8 +13,13 @@ import { Hono } from "hono"; import type { AgentMetadataStore } from "../../auth/agent-metadata-store"; import type { AgentSettings, AgentSettingsStore } from "../../auth/settings"; import { buildDefaultSettingsFromSource } from "../../auth/settings/template-utils"; +import type { SettingsTokenPayload } from "../../auth/settings/token-service"; import type { UserAgentsStore } from "../../auth/user-agents-store"; import type { ChannelBindingService } from "../../channels"; +import { + resolveSettingsLookupUserId, + verifyOwnedAgentAccess, +} from "../shared/agent-ownership"; import { verifySettingsSession } from "./settings-auth"; const logger = createLogger("agent-routes"); @@ -32,6 +37,27 @@ export interface AgentRoutesConfig { channelBindingService: ChannelBindingService; } +async function listOwnedAgentIds( + payload: SettingsTokenPayload, + config: Pick +): Promise { + const lookupUserId = resolveSettingsLookupUserId(payload); + const agentIds = new Set( + await config.userAgentsStore.listAgents(payload.platform, lookupUserId) + ); + + if (payload.platform === "external") { + const allAgents = await config.agentMetadataStore.listAllAgents(); + for (const agent of allAgents) { + if (agent.owner.userId === lookupUserId) { + agentIds.add(agent.agentId); + } + } + } + + return [...agentIds]; +} + /** * Sanitize user-provided agentId. * Lowercase alphanumeric with hyphens, 3-60 chars, must start with a letter. @@ -54,6 +80,7 @@ export function createAgentRoutes(config: AgentRoutesConfig): Hono { } try { + const lookupUserId = resolveSettingsLookupUserId(payload); const body = await c.req.json<{ agentId: string; name: string; @@ -84,10 +111,7 @@ export function createAgentRoutes(config: AgentRoutesConfig): Hono { // Check per-user limit (admins bypass) if (!payload.isAdmin && MAX_AGENTS_PER_USER > 0) { - const userAgents = await config.userAgentsStore.listAgents( - payload.platform, - payload.userId - ); + const userAgents = await listOwnedAgentIds(payload, config); if (userAgents.length >= MAX_AGENTS_PER_USER) { return c.json( { @@ -103,7 +127,7 @@ export function createAgentRoutes(config: AgentRoutesConfig): Hono { agentId, body.name, payload.platform, - payload.userId, + lookupUserId, { description: body.description } ); @@ -135,7 +159,7 @@ export function createAgentRoutes(config: AgentRoutesConfig): Hono { // Associate with user await config.userAgentsStore.addAgent( payload.platform, - payload.userId, + lookupUserId, agentId ); @@ -157,7 +181,7 @@ export function createAgentRoutes(config: AgentRoutesConfig): Hono { return c.json({ agentId, name: body.name, - settingsUrl: `/agent/${encodeURIComponent(agentId)}`, + settingsUrl: `/api/v1/agents/${encodeURIComponent(agentId)}/config`, }); } catch (error) { logger.error("Failed to create agent", { error }); @@ -178,10 +202,7 @@ export function createAgentRoutes(config: AgentRoutesConfig): Hono { } try { - const agentIds = await config.userAgentsStore.listAgents( - payload.platform, - payload.userId - ); + const agentIds = await listOwnedAgentIds(payload, config); const agents = []; for (const agentId of agentIds) { @@ -226,12 +247,11 @@ export function createAgentRoutes(config: AgentRoutesConfig): Hono { try { // Verify ownership (admins bypass) if (!payload.isAdmin) { - const owns = await config.userAgentsStore.ownsAgent( - payload.platform, - payload.userId, - agentId - ); - if (!owns) { + const access = await verifyOwnedAgentAccess(payload, agentId, { + userAgentsStore: config.userAgentsStore, + agentMetadataStore: config.agentMetadataStore, + }); + if (!access.authorized) { return c.json({ error: "Agent not found or not owned by you" }, 404); } } @@ -290,15 +310,18 @@ export function createAgentRoutes(config: AgentRoutesConfig): Hono { try { // Verify ownership (admins bypass) + let ownerPlatform: string | undefined; + let ownerUserId: string | undefined; if (!payload.isAdmin) { - const owns = await config.userAgentsStore.ownsAgent( - payload.platform, - payload.userId, - agentId - ); - if (!owns) { + const access = await verifyOwnedAgentAccess(payload, agentId, { + userAgentsStore: config.userAgentsStore, + agentMetadataStore: config.agentMetadataStore, + }); + if (!access.authorized) { return c.json({ error: "Agent not found or not owned by you" }, 404); } + ownerPlatform = access.ownerPlatform; + ownerUserId = access.ownerUserId; } // Auto-unbind all channels @@ -314,9 +337,20 @@ export function createAgentRoutes(config: AgentRoutesConfig): Hono { // Remove from user's list await config.userAgentsStore.removeAgent( payload.platform, - payload.userId, + resolveSettingsLookupUserId(payload), agentId ); + if ( + ownerPlatform && + ownerUserId && + (ownerPlatform !== payload.platform || ownerUserId !== payload.userId) + ) { + await config.userAgentsStore.removeAgent( + ownerPlatform, + ownerUserId, + agentId + ); + } logger.info( `Deleted agent ${agentId} (unbound ${unboundCount} channels)` diff --git a/packages/gateway/src/routes/public/cli-auth.ts b/packages/gateway/src/routes/public/cli-auth.ts index 3fe857106..a88d4fe53 100644 --- a/packages/gateway/src/routes/public/cli-auth.ts +++ b/packages/gateway/src/routes/public/cli-auth.ts @@ -2,18 +2,22 @@ import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; import { createLogger } from "@lobu/core"; import { type Context, Hono } from "hono"; import { CliTokenService } from "../../auth/cli/token-service"; -import type { SettingsOAuthClient } from "../../auth/settings/oauth-client"; +import type { ExternalAuthClient } from "../../auth/external/client"; import type { IMessageQueue } from "../../infrastructure/queue"; import { resolvePublicUrl } from "../../utils/public-url"; import { getClientIp, RedisFixedWindowRateLimiter, } from "../../utils/rate-limiter"; -import { verifySettingsSession } from "./settings-auth"; +import { + setSettingsSessionCookie, + verifySettingsSession, +} from "./settings-auth"; const logger = createLogger("cli-auth-routes"); const AUTH_REQUEST_TTL_SECONDS = 10 * 60; const POLL_INTERVAL_MS = 2000; +const CONNECT_OAUTH_TTL_SECONDS = 10 * 60; const ADMIN_LOGIN_RATE_LIMIT = { limit: 5, windowSeconds: 5 * 60, @@ -49,13 +53,28 @@ interface CliDeviceAuthState { result?: CliAuthResult; } +interface ConnectOauthState { + returnUrl: string; + codeVerifier: string; +} + export interface CliAuthRoutesConfig { queue: IMessageQueue; - externalAuthClient?: SettingsOAuthClient; + externalAuthClient?: ExternalAuthClient; allowAdminPasswordLogin?: boolean; adminPassword?: string; } +function normalizeReturnUrl( + returnUrl: string | null | undefined +): string | null { + const value = returnUrl?.trim(); + if (!value || !value.startsWith("/") || value.startsWith("//")) { + return null; + } + return value; +} + function escapeHtml(str: string): string { return str .replace(/&/g, "&") @@ -526,7 +545,7 @@ export function createCliAuthRoutes(config: CliAuthRoutesConfig): Hono { const returnUrl = `/api/v1/auth/cli/session/complete?request=${encodeURIComponent(requestId)}`; return c.redirect( - `/agent/oauth/login?returnUrl=${encodeURIComponent(returnUrl)}` + `/connect/oauth/login?returnUrl=${encodeURIComponent(returnUrl)}` ); }); @@ -659,6 +678,162 @@ export function createCliAuthRoutes(config: CliAuthRoutesConfig): Hono { return router; } +export function createConnectAuthRoutes(config: CliAuthRoutesConfig): Hono { + const router = new Hono(); + const redis = config.queue.getRedisClient(); + + async function loadConnectState( + state: string + ): Promise { + const raw = await redis.get(getConnectStateKey(state)); + if (!raw) return null; + + try { + return JSON.parse(raw) as ConnectOauthState; + } catch { + await redis.del(getConnectStateKey(state)); + return null; + } + } + + router.get("/connect/oauth/login", async (c) => { + if (!config.externalAuthClient) { + return c.html( + renderPage( + "OAuth Unavailable", + "Browser OAuth login is not configured on this gateway.", + "error" + ), + 501 + ); + } + + const returnUrl = normalizeReturnUrl(c.req.query("returnUrl")); + if (!returnUrl) { + return c.html( + renderPage( + "OAuth Login Failed", + "Missing or invalid returnUrl.", + "error" + ), + 400 + ); + } + + const existingSession = verifySettingsSession(c); + if (existingSession) { + return c.redirect(returnUrl); + } + + try { + const state = randomBytes(24).toString("base64url"); + const codeVerifier = config.externalAuthClient.generateCodeVerifier(); + await redis.setex( + getConnectStateKey(state), + CONNECT_OAUTH_TTL_SECONDS, + JSON.stringify({ returnUrl, codeVerifier } satisfies ConnectOauthState) + ); + + const redirectUri = resolvePublicUrl("/connect/oauth/callback", { + requestUrl: c.req.url, + }); + const authUrl = await config.externalAuthClient.buildAuthUrl( + state, + codeVerifier, + redirectUri + ); + + return c.redirect(authUrl); + } catch (error) { + logger.error("Failed to start browser OAuth handoff", { error }); + return c.html( + renderPage( + "OAuth Login Failed", + "The gateway could not start the browser OAuth flow.", + "error" + ), + 500 + ); + } + }); + + router.get("/connect/oauth/callback", async (c) => { + if (!config.externalAuthClient) { + return c.html( + renderPage( + "OAuth Unavailable", + "Browser OAuth login is not configured on this gateway.", + "error" + ), + 501 + ); + } + + const code = c.req.query("code")?.trim(); + const state = c.req.query("state")?.trim(); + if (!code || !state) { + return c.html( + renderPage( + "OAuth Login Failed", + "Missing OAuth code or state.", + "error" + ), + 400 + ); + } + + const connectState = await loadConnectState(state); + await redis.del(getConnectStateKey(state)); + if (!connectState) { + return c.html( + renderPage( + "OAuth Login Expired", + "This OAuth login request has expired. Start the flow again.", + "error" + ), + 410 + ); + } + + try { + const redirectUri = resolvePublicUrl("/connect/oauth/callback", { + requestUrl: c.req.url, + }); + const credentials = await config.externalAuthClient.exchangeCodeForToken( + code, + connectState.codeVerifier, + redirectUri + ); + const user = await config.externalAuthClient.fetchUserInfo( + credentials.accessToken + ); + + setSettingsSessionCookie(c, { + userId: user.sub, + platform: "external", + oauthUserId: user.sub, + email: user.email, + name: user.name, + exp: Date.now() + AUTH_REQUEST_TTL_SECONDS * 1000, + }); + + return c.redirect(connectState.returnUrl); + } catch (error) { + logger.error("Failed to complete browser OAuth handoff", { error }); + return c.html( + renderPage( + "OAuth Login Failed", + "The gateway could not complete the browser OAuth flow.", + "error" + ), + 500 + ); + } + }); + + return router; +} + function getRequestKey(requestId: string): string { return `cli:auth:request:${requestId}`; } @@ -666,3 +841,7 @@ function getRequestKey(requestId: string): string { function getDeviceRequestKey(deviceAuthId: string): string { return `cli:auth:device:${deviceAuthId}`; } + +function getConnectStateKey(state: string): string { + return `cli:auth:connect:${state}`; +} diff --git a/packages/gateway/src/routes/public/connections.ts b/packages/gateway/src/routes/public/connections.ts index 1ce525d37..555f27aa4 100644 --- a/packages/gateway/src/routes/public/connections.ts +++ b/packages/gateway/src/routes/public/connections.ts @@ -1,21 +1,17 @@ /** - * Connection CRUD routes + webhook endpoint. + * Connection routes + webhook endpoint. * * Webhook: POST /api/v1/webhooks/:connectionId - * CRUD (auth: settings session cookie): - * POST /api/v1/connections + * Read-only (auth: settings session cookie): * GET /api/v1/connections * GET /api/v1/connections/:id - * PATCH /api/v1/connections/:id - * DELETE /api/v1/connections/:id - * POST /api/v1/connections/:id/restart - * POST /api/v1/connections/:id/stop + * GET /api/v1/connections/:id/sandboxes */ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; -import { createLogger, SUPPORTED_PLATFORMS } from "@lobu/core"; +import type { AgentConfigStore } from "@lobu/core"; +import { createLogger } from "@lobu/core"; import { Hono } from "hono"; -import type { AgentMetadataStore } from "../../auth/agent-metadata-store"; import type { UserAgentsStore } from "../../auth/user-agents-store"; import type { ChatInstanceManager } from "../../connections/chat-instance-manager"; import { verifyAgentAccess } from "./agent-access"; @@ -23,8 +19,6 @@ import { verifySettingsSession } from "./settings-auth"; const logger = createLogger("connection-routes"); const TAG = "Connections"; - -const SupportedPlatformSchema = z.enum(SUPPORTED_PLATFORMS); const ErrorResponseSchema = z.object({ error: z.string() }); const FlexibleObjectSchema = z.record(z.string(), z.unknown()); @@ -41,7 +35,7 @@ const UserConfigScopeSchema = z.enum([ const ConnectionSettingsSchema = z.object({ allowFrom: z.array(z.string()).optional().openapi({ description: - "User IDs allowed to interact with this connection. Empty = allow all.", + "User IDs allowed to interact with this connection. Omit to allow all; empty array blocks all.", }), allowGroups: z.boolean().optional().openapi({ description: "Whether group messages are allowed (default true).", @@ -209,14 +203,71 @@ const TeamsConfigSchema = z.object({ .openapi({ description: "Override bot username." }), }); +const GoogleChatConfigSchema = z.object({ + platform: z.literal("gchat"), + credentials: z + .string() + .optional() + .openapi({ + description: + "Service account credentials JSON string. Defaults to GOOGLE_CHAT_CREDENTIALS env var.", + }), + useApplicationDefaultCredentials: z + .boolean() + .optional() + .openapi({ + description: + "Use Application Default Credentials (ADC) instead of service account JSON.", + }), + endpointUrl: z + .string() + .optional() + .openapi({ + description: + "HTTP endpoint URL for button click actions. Required for HTTP endpoint apps.", + }), + googleChatProjectNumber: z + .string() + .optional() + .openapi({ + description: + "Google Cloud project number for verifying webhook JWTs. Defaults to GOOGLE_CHAT_PROJECT_NUMBER env var.", + }), + impersonateUser: z + .string() + .optional() + .openapi({ + description: + "User email for domain-wide delegation. Defaults to GOOGLE_CHAT_IMPERSONATE_USER env var.", + }), + pubsubAudience: z + .string() + .optional() + .openapi({ + description: + "Expected audience for Pub/Sub push JWT verification. Defaults to GOOGLE_CHAT_PUBSUB_AUDIENCE env var.", + }), + userName: z + .string() + .optional() + .openapi({ description: "Override bot username." }), +}); + const PlatformAdapterConfigSchema = z.discriminatedUnion("platform", [ TelegramConfigSchema, SlackConfigSchema, DiscordConfigSchema, WhatsAppConfigSchema, TeamsConfigSchema, + GoogleChatConfigSchema, ]); +/** Derived from the discriminated union — no separate list to maintain. */ +const SUPPORTED_PLATFORMS = PlatformAdapterConfigSchema.options.map( + (s) => s.shape.platform.value +) as [string, ...string[]]; +const SupportedPlatformSchema = z.enum(SUPPORTED_PLATFORMS); + const PlatformConnectionSchema = z.object({ id: z.string(), platform: SupportedPlatformSchema, @@ -230,19 +281,6 @@ const PlatformConnectionSchema = z.object({ updatedAt: z.number(), }); -const CreateConnectionRequestSchema = z.object({ - platform: SupportedPlatformSchema, - templateAgentId: z.string().optional(), - config: PlatformAdapterConfigSchema, - settings: ConnectionSettingsSchema.optional(), -}); - -const UpdateConnectionRequestSchema = z.object({ - templateAgentId: z.string().nullable().optional(), - config: PlatformAdapterConfigSchema.optional(), - settings: ConnectionSettingsSchema.optional(), -}); - const ConnectionIdParamsSchema = z.object({ id: z.string(), }); @@ -252,57 +290,6 @@ const ListConnectionsQuerySchema = z.object({ templateAgentId: z.string().optional(), }); -const CreateConnectionRoute = createRoute({ - method: "post", - path: "/api/v1/connections", - tags: [TAG], - summary: "Create a platform connection", - description: "Creates and starts a Chat SDK-backed connection for an agent.", - request: { - body: { - content: { - "application/json": { - schema: CreateConnectionRequestSchema, - }, - }, - }, - }, - responses: { - 201: { - description: "Connection created", - content: { - "application/json": { - schema: PlatformConnectionSchema, - }, - }, - }, - 400: { - description: "Invalid request", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 401: { - description: "Unauthorized", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 403: { - description: "Forbidden", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - }, -}); - const ListConnectionsRoute = createRoute({ method: "get", path: "/api/v1/connections", @@ -387,223 +374,6 @@ const GetConnectionRoute = createRoute({ }, }); -const UpdateConnectionRoute = createRoute({ - method: "patch", - path: "/api/v1/connections/{id}", - tags: [TAG], - summary: "Update a platform connection", - request: { - params: ConnectionIdParamsSchema, - body: { - content: { - "application/json": { - schema: UpdateConnectionRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Updated connection", - content: { - "application/json": { - schema: PlatformConnectionSchema, - }, - }, - }, - 400: { - description: "Invalid request", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 401: { - description: "Unauthorized", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 403: { - description: "Forbidden", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 404: { - description: "Connection not found", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - }, -}); - -const DeleteConnectionRoute = createRoute({ - method: "delete", - path: "/api/v1/connections/{id}", - tags: [TAG], - summary: "Delete a platform connection", - request: { - params: ConnectionIdParamsSchema, - }, - responses: { - 200: { - description: "Connection removed", - content: { - "application/json": { - schema: z.object({ - success: z.literal(true), - }), - }, - }, - }, - 400: { - description: "Invalid request", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 401: { - description: "Unauthorized", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 403: { - description: "Forbidden", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 404: { - description: "Connection not found", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - }, -}); - -const RestartConnectionRoute = createRoute({ - method: "post", - path: "/api/v1/connections/{id}/restart", - tags: [TAG], - summary: "Restart a platform connection", - request: { - params: ConnectionIdParamsSchema, - }, - responses: { - 200: { - description: "Restarted connection", - content: { - "application/json": { - schema: PlatformConnectionSchema.nullable(), - }, - }, - }, - 400: { - description: "Invalid request", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 401: { - description: "Unauthorized", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 403: { - description: "Forbidden", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 404: { - description: "Connection not found", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - }, -}); - -const StopConnectionRoute = createRoute({ - method: "post", - path: "/api/v1/connections/{id}/stop", - tags: [TAG], - summary: "Stop a platform connection", - request: { - params: ConnectionIdParamsSchema, - }, - responses: { - 200: { - description: "Stopped connection", - content: { - "application/json": { - schema: PlatformConnectionSchema.nullable(), - }, - }, - }, - 400: { - description: "Invalid request", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 401: { - description: "Unauthorized", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 403: { - description: "Forbidden", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 404: { - description: "Connection not found", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - }, -}); - export function createConnectionWebhookRoutes( manager: ChatInstanceManager ): Hono { @@ -643,7 +413,7 @@ export function createConnectionCrudRoutes( manager: ChatInstanceManager, accessConfig: { userAgentsStore: UserAgentsStore; - agentMetadataStore: AgentMetadataStore; + agentMetadataStore: Pick; } ): OpenAPIHono { const app = new OpenAPIHono(); @@ -707,9 +477,7 @@ export function createConnectionCrudRoutes( }; app.get("/internal/connections/platforms", listLocalTestPlatforms); - app.get("/api/internal/connections/platforms", listLocalTestPlatforms); app.get("/internal/connections/test-targets", listLocalTestTargets); - app.get("/api/internal/connections/test-targets", listLocalTestTargets); // Internal endpoint for server-to-server connection listing (no auth required) const listAllConnections = async (c: any) => { @@ -721,51 +489,6 @@ export function createConnectionCrudRoutes( return c.json({ connections }); }; app.get("/internal/connections", listAllConnections); - app.get("/api/internal/connections", listAllConnections); - - app.openapi(CreateConnectionRoute, async (c): Promise => { - const session = verifySettingsSession(c); - if (!session) { - return c.json({ error: "Unauthorized" }, 401); - } - - try { - const body = c.req.valid("json"); - - if ( - body.templateAgentId && - !(await verifyAgentAccess(session, body.templateAgentId, accessConfig)) - ) { - return c.json({ error: "Forbidden" }, 403); - } - - const connection = await manager.addConnection( - body.platform, - body.templateAgentId, - body.config, - body.settings - ); - - logger.info( - { - id: connection.id, - platform: body.platform, - templateAgentId: body.templateAgentId, - }, - "Connection created via API" - ); - - return c.json(connection, 201); - } catch (error) { - logger.error({ error: String(error) }, "Failed to create connection"); - return c.json( - { - error: "Failed to create connection", - }, - 400 - ); - } - }); app.openapi(ListConnectionsRoute, async (c): Promise => { const session = verifySettingsSession(c); @@ -786,7 +509,9 @@ export function createConnectionCrudRoutes( templateAgentId, }); } else { - // List all connections (admin view) + if (!session.isAdmin && session.settingsMode !== "admin") { + return c.json({ error: "Forbidden" }, 403); + } connections = await manager.listConnections({ platform: platform || undefined, }); @@ -820,130 +545,6 @@ export function createConnectionCrudRoutes( return c.json(connection); }); - app.openapi(UpdateConnectionRoute, async (c): Promise => { - const session = verifySettingsSession(c); - if (!session) { - return c.json({ error: "Unauthorized" }, 401); - } - - const { id } = c.req.valid("param"); - - try { - const existing = await manager.getConnection(id); - if (!existing) { - return c.json({ error: "Connection not found" }, 404); - } - if ( - existing.templateAgentId && - !(await verifyAgentAccess( - session, - existing.templateAgentId, - accessConfig - )) - ) { - return c.json({ error: "Forbidden" }, 403); - } - - const body = c.req.valid("json"); - - if ( - body.templateAgentId && - !(await verifyAgentAccess(session, body.templateAgentId, accessConfig)) - ) { - return c.json({ error: "Forbidden" }, 403); - } - - const updated = await manager.updateConnection(id, body); - return c.json(updated); - } catch (error) { - logger.error({ id, error: String(error) }, "Failed to update connection"); - return c.json( - { - error: "Failed to update connection", - }, - 400 - ); - } - }); - - app.openapi(DeleteConnectionRoute, async (c): Promise => { - const session = verifySettingsSession(c); - if (!session) { - return c.json({ error: "Unauthorized" }, 401); - } - - const { id } = c.req.valid("param"); - - try { - const existing = await manager.getConnection(id); - if (!existing) { - return c.json({ error: "Connection not found" }, 404); - } - if ( - existing.templateAgentId && - !(await verifyAgentAccess( - session, - existing.templateAgentId, - accessConfig - )) - ) { - return c.json({ error: "Forbidden" }, 403); - } - - await manager.removeConnection(id); - return c.json({ success: true }); - } catch (error) { - logger.error({ id, error: String(error) }, "Failed to remove connection"); - return c.json( - { - error: "Failed to remove connection", - }, - 400 - ); - } - }); - - app.openapi(RestartConnectionRoute, async (c): Promise => { - const session = verifySettingsSession(c); - if (!session) { - return c.json({ error: "Unauthorized" }, 401); - } - - const { id } = c.req.valid("param"); - - try { - const existing = await manager.getConnection(id); - if (!existing) { - return c.json({ error: "Connection not found" }, 404); - } - if ( - existing.templateAgentId && - !(await verifyAgentAccess( - session, - existing.templateAgentId, - accessConfig - )) - ) { - return c.json({ error: "Forbidden" }, 403); - } - - await manager.restartConnection(id); - const connection = await manager.getConnection(id); - return c.json(connection); - } catch (error) { - logger.error( - { id, error: String(error) }, - "Failed to restart connection" - ); - return c.json( - { - error: "Failed to restart connection", - }, - 400 - ); - } - }); - // GET /api/v1/connections/:id/sandboxes — list sandbox agents for a connection app.get("/api/v1/connections/:id/sandboxes", async (c): Promise => { const session = verifySettingsSession(c); @@ -956,6 +557,23 @@ export function createConnectionCrudRoutes( if (!connection) { return c.json({ error: "Connection not found" }, 404); } + if ( + connection.templateAgentId && + !(await verifyAgentAccess( + session, + connection.templateAgentId, + accessConfig + )) + ) { + return c.json({ error: "Forbidden" }, 403); + } + if ( + !connection.templateAgentId && + !session.isAdmin && + session.settingsMode !== "admin" + ) { + return c.json({ error: "Forbidden" }, 403); + } const sandboxes = await accessConfig.agentMetadataStore.listSandboxes(id); return c.json({ @@ -970,43 +588,5 @@ export function createConnectionCrudRoutes( }); }); - app.openapi(StopConnectionRoute, async (c): Promise => { - const session = verifySettingsSession(c); - if (!session) { - return c.json({ error: "Unauthorized" }, 401); - } - - const { id } = c.req.valid("param"); - - try { - const existing = await manager.getConnection(id); - if (!existing) { - return c.json({ error: "Connection not found" }, 404); - } - if ( - existing.templateAgentId && - !(await verifyAgentAccess( - session, - existing.templateAgentId, - accessConfig - )) - ) { - return c.json({ error: "Forbidden" }, 403); - } - - await manager.stopConnection(id); - const connection = await manager.getConnection(id); - return c.json(connection); - } catch (error) { - logger.error({ id, error: String(error) }, "Failed to stop connection"); - return c.json( - { - error: "Failed to stop connection", - }, - 400 - ); - } - }); - return app; } diff --git a/packages/gateway/src/routes/public/history-page/app.tsx b/packages/gateway/src/routes/public/history-page/app.tsx deleted file mode 100644 index c823fbb72..000000000 --- a/packages/gateway/src/routes/public/history-page/app.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import { useSignal } from "@preact/signals"; -import { useVirtualizer } from "@tanstack/react-virtual"; -import { render } from "preact"; -import { useEffect, useRef } from "preact/hooks"; -import { MessageRow } from "./components/MessageRow"; -import { StatusBar } from "./components/StatusBar"; -import { WakePrompt } from "./components/WakePrompt"; -import type { - HistoryMessage, - MessagesResponse, - StatsResponse, - StatusResponse, -} from "./types"; - -declare global { - interface Window { - __AGENT_ID__: string; - __sessionReady__?: Promise; - } -} - -function App() { - const agentId = window.__AGENT_ID__; - const messages = useSignal([]); - const loading = useSignal(false); - const hasMore = useSignal(true); - const cursor = useSignal(null); - const status = useSignal(null); - const stats = useSignal(null); - const focusedId = useSignal(null); - const initialLoad = useSignal(true); - const error = useSignal(null); - - const parentRef = useRef(null); - - // Parse URL state - useEffect(() => { - const params = new URLSearchParams(window.location.search); - if (params.get("msg")) focusedId.value = params.get("msg"); - }, []); - - // Fetch status - async function fetchStatus() { - try { - const resp = await fetch(`/api/v1/agents/${agentId}/history/status`); - if (resp.ok) { - status.value = await resp.json(); - } - } catch { - status.value = { - connected: false, - hasHttpServer: false, - deploymentCount: 0, - }; - } - } - - // Fetch stats - async function fetchStats() { - try { - const resp = await fetch( - `/api/v1/agents/${agentId}/history/session/stats` - ); - if (resp.ok) { - stats.value = await resp.json(); - } - } catch { - // ignore - } - } - - // Fetch messages - async function fetchMessages(cursorParam?: string | null) { - if (loading.value) return; - loading.value = true; - error.value = null; - - try { - const params = new URLSearchParams(); - if (cursorParam) params.set("cursor", cursorParam); - params.set("limit", "100"); - - const resp = await fetch( - `/api/v1/agents/${agentId}/history/session/messages?${params}` - ); - - if (!resp.ok) { - if (resp.status === 503) { - status.value = { - connected: false, - hasHttpServer: false, - deploymentCount: 0, - }; - return; - } - throw new Error(`HTTP ${resp.status}`); - } - - const data: MessagesResponse = await resp.json(); - - if (cursorParam) { - messages.value = [...messages.value, ...data.messages]; - } else { - messages.value = data.messages; - } - - cursor.value = data.nextCursor; - hasMore.value = data.hasMore; - } catch (e) { - error.value = e instanceof Error ? e.message : "Failed to load messages"; - } finally { - loading.value = false; - initialLoad.value = false; - } - } - - // Initial load — wait for session bootstrap (token→cookie exchange) first - useEffect(() => { - const init = async () => { - if (window.__sessionReady__) await window.__sessionReady__; - await fetchStatus(); - if (status.value?.connected) { - fetchMessages(); - fetchStats(); - } else { - initialLoad.value = false; - } - }; - init(); - }, []); - - // Virtual scroll - const virtualizer = useVirtualizer({ - count: messages.value.length, - getScrollElement: () => parentRef.current, - estimateSize: () => 80, - overscan: 10, - }); - - // Load more when reaching the end - useEffect(() => { - const items = virtualizer.getVirtualItems(); - const lastItem = items[items.length - 1]; - if (!lastItem) return; - - if ( - lastItem.index >= messages.value.length - 5 && - hasMore.value && - !loading.value && - cursor.value - ) { - fetchMessages(cursor.value); - } - }, [virtualizer.getVirtualItems(), hasMore.value, loading.value]); - - // Scroll to focused message - useEffect(() => { - if (focusedId.value && messages.value.length > 0) { - const idx = messages.value.findIndex((m) => m.id === focusedId.value); - if (idx >= 0) { - virtualizer.scrollToIndex(idx, { align: "center" }); - } - } - }, [focusedId.value, messages.value.length]); - - // Handle wake callback - function onWake() { - fetchStatus().then(() => { - if (status.value?.connected) { - fetchMessages(); - fetchStats(); - } - }); - } - - if (initialLoad.value) { - return ( -
-
-
- ); - } - - if (!status.value?.connected) { - return ( - <> - - - - ); - } - - return ( - <> - - - {error.value && ( -
- {error.value} -
- )} - -
-
- {virtualizer.getVirtualItems().map((virtualRow) => { - const msg = messages.value[virtualRow.index]; - if (!msg) return null; - return ( -
- -
- ); - })} -
- - {loading.value && ( -
-
-
- )} - - {!hasMore.value && messages.value.length > 0 && ( -
- End of conversation -
- )} - - {!loading.value && messages.value.length === 0 && ( -
No messages yet
- )} -
- - ); -} - -render(, document.getElementById("app")!); diff --git a/packages/gateway/src/routes/public/history-page/components/MessageRow.tsx b/packages/gateway/src/routes/public/history-page/components/MessageRow.tsx deleted file mode 100644 index f5dfe006b..000000000 --- a/packages/gateway/src/routes/public/history-page/components/MessageRow.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import { useSignal } from "@preact/signals"; -import { marked } from "marked"; -import type { HistoryMessage } from "../types"; - -// Configure marked for inline rendering (no wrapping

tags for single lines) -marked.setOptions({ breaks: true, gfm: true }); - -function formatTimestamp(ts: string): string { - try { - const d = new Date(ts); - return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); - } catch { - return ts; - } -} - -function renderContent(content: unknown): string { - if (typeof content === "string") return content; - if (Array.isArray(content)) { - return content - .map((block: any) => { - if (block.type === "text") return block.text; - if (block.type === "toolCall") return `[Tool: ${block.name}]`; - if (block.type === "thinking") return "[Thinking...]"; - if (block.type === "image") return "[Image]"; - return ""; - }) - .filter(Boolean) - .join("\n"); - } - if (content && typeof content === "object") { - return JSON.stringify(content, null, 2); - } - return String(content || ""); -} - -function renderMarkdown(text: string): string { - return marked.parse(text, { async: false }) as string; -} - -function truncateText( - text: string, - maxLen: number -): { text: string; truncated: boolean } { - if (text.length <= maxLen) return { text, truncated: false }; - return { text: text.slice(0, maxLen), truncated: true }; -} - -export function MessageRow({ - message, - isFocused, -}: { - message: HistoryMessage; - isFocused: boolean; -}) { - const expanded = useSignal(false); - const contentText = renderContent(message.content); - const { text: displayText, truncated } = truncateText(contentText, 1000); - - // System pills for model_change and compaction - if (message.type === "model_change") { - return ( -

- - - Model: {String(message.content)} - -
- ); - } - - if (message.type === "compaction") { - return ( -
- - - Context compacted - -
- ); - } - - const isUser = message.role === "user"; - const isAssistant = message.role === "assistant"; - const isToolResult = message.role === "toolResult"; - - // Tool results — collapsible muted block - if (isToolResult) { - return ( -
- - {expanded.value && ( -
-            {contentText}
-          
- )} -
- ); - } - - // User message — right-aligned bubble - if (isUser) { - return ( -
-
-
-
- {truncated && !expanded.value && ( - - )} -
-
- {formatTimestamp(message.timestamp)} -
-
-
- ); - } - - // Assistant message — left-aligned block - if (isAssistant) { - return ( -
-
-
-
- {truncated && !expanded.value && ( - - )} -
-
- {formatTimestamp(message.timestamp)} - {message.usage && ( - - {message.usage.inputTokens?.toLocaleString()}↓{" "} - {message.usage.outputTokens?.toLocaleString()}↑ - - )} -
-
-
- ); - } - - // Fallback for custom_message or unknown types - return ( -
-
- {contentText} -
-
- {message.type} — {formatTimestamp(message.timestamp)} -
-
- ); -} diff --git a/packages/gateway/src/routes/public/history-page/components/StatusBar.tsx b/packages/gateway/src/routes/public/history-page/components/StatusBar.tsx deleted file mode 100644 index be481a754..000000000 --- a/packages/gateway/src/routes/public/history-page/components/StatusBar.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import type { StatsResponse } from "../types"; - -export function StatusBar({ - connected, - stats, - agentId, -}: { - connected: boolean; - stats: StatsResponse | null; - agentId: string; -}) { - const backUrl = `/agent/${encodeURIComponent(agentId)}`; - - return ( -
-
- - - Back to settings - - -
- - Agent History -
- - {stats && ( - - )} -
-
- ); -} diff --git a/packages/gateway/src/routes/public/history-page/components/WakePrompt.tsx b/packages/gateway/src/routes/public/history-page/components/WakePrompt.tsx deleted file mode 100644 index f809f6eb5..000000000 --- a/packages/gateway/src/routes/public/history-page/components/WakePrompt.tsx +++ /dev/null @@ -1,83 +0,0 @@ -import { useSignal } from "@preact/signals"; - -export function WakePrompt({ - agentId, - onWake, -}: { - agentId: string; - onWake: () => void; -}) { - const polling = useSignal(false); - const error = useSignal(null); - - async function handleWake() { - polling.value = true; - error.value = null; - - // Poll status until connected - const maxAttempts = 30; - for (let i = 0; i < maxAttempts; i++) { - try { - const resp = await fetch(`/api/v1/agents/${agentId}/history/status`); - if (resp.ok) { - const data = await resp.json(); - if (data.connected && data.hasHttpServer) { - polling.value = false; - onWake(); - return; - } - } - } catch { - // retry - } - await new Promise((r) => setTimeout(r, 2000)); - } - - polling.value = false; - error.value = - "Agent did not come online. Try sending a message to wake it."; - } - - return ( -
-
- -
-

Agent is offline

-

- The agent worker is currently scaled down. Send a message to wake it up, - then come back to view the conversation history. -

- - {polling.value ? ( -
-
- Waiting for agent to come online... -
- ) : ( - - )} - - {error.value &&

{error.value}

} -
- ); -} diff --git a/packages/gateway/src/routes/public/history-page/index.ts b/packages/gateway/src/routes/public/history-page/index.ts deleted file mode 100644 index de339ff30..000000000 --- a/packages/gateway/src/routes/public/history-page/index.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * History page HTML shell — serves the Preact app for viewing agent conversation history. - */ - -import { pageCSS } from "../page-styles"; - -let historyPageJS = ""; -try { - // Dynamic import to handle case where bundle hasn't been generated yet - const bundle = require("../history-page-bundle"); - historyPageJS = bundle.historyPageJS; -} catch { - historyPageJS = - 'document.getElementById("app").textContent = "Bundle not built. Run: bun run scripts/build-history.ts";'; -} - -export function renderHistoryPage(agentId: string): string { - return ` - - - - - - Agent History - - - -
-
-
- - - -`; -} diff --git a/packages/gateway/src/routes/public/history-page/types.ts b/packages/gateway/src/routes/public/history-page/types.ts deleted file mode 100644 index d32ff9376..000000000 --- a/packages/gateway/src/routes/public/history-page/types.ts +++ /dev/null @@ -1,33 +0,0 @@ -export interface HistoryMessage { - id: string; - type: "message" | "compaction" | "model_change" | "custom_message"; - role?: string; - content: unknown; - model?: string; - timestamp: string; - isVerbose?: boolean; - usage?: { inputTokens?: number; outputTokens?: number }; -} - -export interface MessagesResponse { - messages: HistoryMessage[]; - nextCursor: string | null; - hasMore: boolean; - sessionId: string; -} - -export interface StatsResponse { - sessionId: string; - messageCount: number; - userMessages: number; - assistantMessages: number; - totalInputTokens: number; - totalOutputTokens: number; - currentModel?: string; -} - -export interface StatusResponse { - connected: boolean; - hasHttpServer: boolean; - deploymentCount: number; -} diff --git a/packages/gateway/src/routes/public/integrations.ts b/packages/gateway/src/routes/public/integrations.ts deleted file mode 100644 index 04b4f19d0..000000000 --- a/packages/gateway/src/routes/public/integrations.ts +++ /dev/null @@ -1,206 +0,0 @@ -/** - * Integrations Routes (MCP registry + skill fetch) - * - * Registry endpoint returns MCPs from the MCP registry. - * Skill fetch endpoint is kept for the settings page prefill flow. - */ - -import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; -import type { AgentSettingsStore } from "../../auth/settings/agent-settings-store"; -import { McpRegistryService } from "../../services/mcp-registry"; -import { SkillRegistryCoordinator } from "../../services/skill-registry"; -import type { SystemConfigResolver } from "../../services/system-config-resolver"; -import { verifySettingsSession } from "./settings-auth"; - -const TAG = "Integrations"; -const ErrorResponse = z.object({ error: z.string() }); - -const registryRoute = createRoute({ - method: "get", - path: "/registry", - tags: [TAG], - summary: "Browse/search integrations registry (MCPs only)", - description: - "Returns curated MCPs if no query, or searches MCP registry if q provided", - request: { - query: z.object({ - token: z.string().optional(), - q: z - .string() - .optional() - .openapi({ description: "Search query (omit for curated)" }), - limit: z.string().optional(), - }), - }, - responses: { - 200: { - description: "Integrations", - content: { - "application/json": { - schema: z.object({ - mcps: z.array( - z.object({ - id: z.string(), - name: z.string(), - description: z.string(), - type: z.string().optional(), - }) - ), - source: z.enum(["curated", "search"]), - }), - }, - }, - }, - 401: { - description: "Unauthorized", - content: { "application/json": { schema: ErrorResponse } }, - }, - }, -}); - -const skillFetchRoute = createRoute({ - method: "post", - path: "/skills/fetch", - tags: [TAG], - summary: "Fetch skill metadata from registry", - description: "Fetches skill name, description, and content by slug", - request: { - query: z.object({ token: z.string().optional() }), - body: { - content: { - "application/json": { - schema: z.object({ - repo: z - .string() - .openapi({ description: "Skill slug (e.g., 'pdf')" }), - refresh: z - .boolean() - .optional() - .openapi({ description: "Force refresh" }), - }), - }, - }, - }, - }, - responses: { - 200: { - description: "Skill metadata", - content: { - "application/json": { - schema: z.object({ - repo: z.string(), - name: z.string(), - description: z.string(), - content: z.string(), - fetchedAt: z.number(), - mcpServers: z - .array( - z.object({ - id: z.string(), - name: z.string().optional(), - url: z.string().optional(), - type: z.enum(["sse", "stdio"]).optional(), - command: z.string().optional(), - args: z.array(z.string()).optional(), - }) - ) - .optional(), - nixPackages: z.array(z.string()).optional(), - permissions: z.array(z.string()).optional(), - providers: z.array(z.string()).optional(), - }), - }, - }, - }, - 400: { - description: "Invalid", - content: { "application/json": { schema: ErrorResponse } }, - }, - 401: { - description: "Unauthorized", - content: { "application/json": { schema: ErrorResponse } }, - }, - }, -}); - -export interface IntegrationsRoutesConfig { - configResolver?: SystemConfigResolver; - agentSettingsStore?: AgentSettingsStore; -} - -export function createIntegrationsRoutes( - config: IntegrationsRoutesConfig = {} -): OpenAPIHono { - const app = new OpenAPIHono(); - const coordinator = new SkillRegistryCoordinator(); - const mcpRegistry = new McpRegistryService(config.configResolver); - - app.openapi(registryRoute, async (c): Promise => { - const { q, limit } = c.req.valid("query"); - if (!verifySettingsSession(c)) - return c.json({ error: "Unauthorized" }, 401); - - const maxLimit = Math.min(parseInt(limit || "20", 10), 50); - - if (q) { - const mcpResults = await mcpRegistry.search(q, maxLimit); - return c.json({ - mcps: mcpResults.map((m) => ({ - id: m.id, - name: m.name, - description: m.description, - type: m.type, - })), - source: "search", - }); - } - - const mcps = await mcpRegistry.getCurated(); - return c.json({ - mcps: mcps.map((m) => ({ - id: m.id, - name: m.name, - description: m.description, - type: m.type, - })), - source: "curated", - }); - }); - - app.openapi(skillFetchRoute, async (c): Promise => { - if (!verifySettingsSession(c)) - return c.json({ error: "Unauthorized" }, 401); - - const { repo } = c.req.valid("json"); - if (!repo?.trim()) return c.json({ error: "Missing skill slug" }, 400); - - // Get per-agent registries if agentId is available in session - const session = verifySettingsSession(c); - let extraRegistries; - if (session?.agentId && config.agentSettingsStore) { - const settings = await config.agentSettingsStore.getSettings( - session.agentId - ); - extraRegistries = settings?.skillRegistries; - } - - try { - const skillContent = await coordinator.fetch(repo, extraRegistries); - return c.json({ - repo, - name: skillContent.name, - description: skillContent.description, - content: skillContent.content, - fetchedAt: Date.now(), - mcpServers: skillContent.mcpServers, - nixPackages: skillContent.nixPackages, - permissions: skillContent.permissions, - providers: skillContent.providers, - }); - } catch (e) { - return c.json({ error: e instanceof Error ? e.message : "Failed" }, 400); - } - }); - - return app; -} diff --git a/packages/gateway/src/routes/public/landing.ts b/packages/gateway/src/routes/public/landing.ts index 11b5a15af..85029f0d6 100644 --- a/packages/gateway/src/routes/public/landing.ts +++ b/packages/gateway/src/routes/public/landing.ts @@ -1,14 +1,15 @@ import { Hono } from "hono"; -import { verifySettingsSession } from "./settings-auth"; export function createLandingRoutes() { const app = new Hono(); app.get("/", (c) => { - const session = verifySettingsSession(c); - if (session?.isAdmin) return c.redirect("/agents"); - if (session) return c.redirect("/agent"); - return c.redirect("/agents/login"); + return c.json({ + name: "Lobu Gateway", + mode: "api-only", + docs: "/api/docs", + health: "/health", + }); }); return app; diff --git a/packages/gateway/src/routes/public/messaging.ts b/packages/gateway/src/routes/public/messaging.ts deleted file mode 100644 index 1ae87a290..000000000 --- a/packages/gateway/src/routes/public/messaging.ts +++ /dev/null @@ -1,370 +0,0 @@ -#!/usr/bin/env bun - -import { createRoute, OpenAPIHono } from "@hono/zod-openapi"; -import { createLogger } from "@lobu/core"; -import { z } from "zod"; -import { createApiAuthMiddleware } from "../../auth/api-auth-middleware"; -import type { CliTokenService } from "../../auth/cli/token-service"; -import type { ExternalAuthClient } from "../../auth/external/client"; -import type { PlatformRegistry } from "../../platform"; - -const logger = createLogger("messaging-routes"); - -// ============================================================================ -// Request/Response Schemas -// ============================================================================ - -const SlackRoutingInfoSchema = z.object({ - channel: z.string().describe("Slack channel ID"), - thread: z.string().optional().describe("Thread timestamp for replies"), - team: z.string().optional().describe("Slack team ID"), -}); - -const SendMessageRequestSchema = z - .object({ - agentId: z.string().describe("Agent ID to send message to"), - message: z.string().describe("Message content"), - platform: z - .string() - .optional() - .default("api") - .describe("Target platform (api, slack, telegram)"), - slack: SlackRoutingInfoSchema.optional().describe( - "Slack-specific routing info (required when platform=slack)" - ), - // Undocumented fields may be passed for internal/hidden platform support. - }) - .passthrough(); - -const SendMessageResponseSchema = z.object({ - success: z.boolean(), - agentId: z.string(), - messageId: z.string(), - eventsUrl: z.string().optional(), - queued: z.boolean(), -}); - -const ErrorResponseSchema = z.object({ - success: z.literal(false), - error: z.string(), - details: z.string().optional(), - availablePlatforms: z.array(z.string()).optional(), -}); - -// ============================================================================ -// Route Definitions -// ============================================================================ - -const sendMessageRoute = createRoute({ - method: "post", - path: "/api/v1/messaging/send", - tags: ["Messages"], - summary: "Send a message via platform API", - description: - "Send a message to an agent. Supports JSON body or multipart form data for file uploads.", - request: { - body: { - content: { - "application/json": { - schema: SendMessageRequestSchema, - }, - }, - }, - }, - responses: { - 200: { - description: "Message sent successfully", - content: { - "application/json": { - schema: SendMessageResponseSchema, - }, - }, - }, - 400: { - description: "Bad request - missing required fields", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 401: { - description: "Unauthorized - missing or invalid token", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 404: { - description: "Platform not found", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 500: { - description: "Internal server error", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - 501: { - description: "Platform does not support sendMessage", - content: { - "application/json": { - schema: ErrorResponseSchema, - }, - }, - }, - }, - security: [{ bearerAuth: [] }], -}); - -// ============================================================================ -// Route Handlers -// ============================================================================ - -interface SendMessageRequest { - agentId: string; - message: string; - platform?: string; - slack?: { - channel: string; - thread?: string; - team?: string; - }; - // Intentionally undocumented (hidden feature). - whatsapp?: { - chat: string; - }; -} - -/** - * Create messaging routes (OpenAPI) - */ -export function createMessagingRoutes( - platformRegistry: PlatformRegistry, - auth?: { - adminPassword?: string; - cliTokenService?: CliTokenService; - externalAuthClient?: ExternalAuthClient; - } -): OpenAPIHono { - const app = new OpenAPIHono(); - - // Public messaging must not accept worker tokens, because this route can - // post messages into user-facing platform connections. - app.use( - "/api/v1/messaging/*", - createApiAuthMiddleware({ - ...(auth ?? {}), - allowWorkerToken: false, - allowSettingsSession: false, - }) - ); - - app.openapi(sendMessageRoute, async (c): Promise => { - try { - // Platform token still comes from the Authorization header for the adapter call - const authHeader = c.req.header("authorization"); - const token = authHeader ? authHeader.substring(7) : ""; - - // Handle multipart form data for file uploads - const contentType = c.req.header("content-type") || ""; - let body: SendMessageRequest; - let files: Array<{ buffer: Buffer; filename: string }> | undefined; - - if (contentType.includes("multipart/form-data")) { - const formData = await c.req.formData(); - body = { - agentId: formData.get("agentId") as string, - message: formData.get("message") as string, - platform: (formData.get("platform") as string) || "api", - }; - - // Handle nested objects from form data - const slackChannel = formData.get("slack.channel") as string; - if (slackChannel) { - body.slack = { - channel: slackChannel, - thread: formData.get("slack.thread") as string | undefined, - team: formData.get("slack.team") as string | undefined, - }; - } - - // Intentionally undocumented (hidden feature): WhatsApp routing info - const whatsappChat = formData.get("whatsapp.chat") as string; - if (whatsappChat) { - body.whatsapp = { chat: whatsappChat }; - } - - // Extract files with size validation - const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB per file - const MAX_TOTAL_SIZE = 100 * 1024 * 1024; // 100MB total - const MAX_FILE_COUNT = 10; - - const fileEntries = formData.getAll("files"); - if (fileEntries.length > MAX_FILE_COUNT) { - return c.json( - { - success: false, - error: `Too many files: ${fileEntries.length} (max ${MAX_FILE_COUNT})`, - }, - 400 - ); - } - - if (fileEntries.length > 0) { - const fileResults: Array<{ buffer: Buffer; filename: string }> = []; - let totalSize = 0; - for (const entry of fileEntries) { - if (entry instanceof File) { - if (entry.size > MAX_FILE_SIZE) { - return c.json( - { - success: false, - error: `File "${entry.name}" exceeds maximum size of ${MAX_FILE_SIZE / 1024 / 1024}MB`, - }, - 400 - ); - } - totalSize += entry.size; - if (totalSize > MAX_TOTAL_SIZE) { - return c.json( - { - success: false, - error: `Total upload size exceeds maximum of ${MAX_TOTAL_SIZE / 1024 / 1024}MB`, - }, - 400 - ); - } - const arrayBuffer = await entry.arrayBuffer(); - fileResults.push({ - buffer: Buffer.from(arrayBuffer), - filename: entry.name, - }); - } - } - if (fileResults.length > 0) { - files = fileResults; - } - } - } else { - body = c.req.valid("json"); - } - - const { agentId, message, platform = "api" } = body; - - if (!agentId) { - return c.json({ success: false, error: "agentId is required" }, 400); - } - - if (!message) { - return c.json({ success: false, error: "message is required" }, 400); - } - - // Get platform adapter first to use its routing info extractor - const adapter = platformRegistry.get(platform); - - // Extract platform-specific routing info using adapter's method if available - let channelId = agentId; - let conversationId: string | undefined = - platform === "api" ? agentId : undefined; - let teamId = "api"; - - if (adapter?.extractRoutingInfo) { - const routingInfo = adapter.extractRoutingInfo( - body as unknown as Record - ); - if (routingInfo) { - channelId = routingInfo.channelId; - conversationId = - routingInfo.conversationId || - (platform === "api" ? agentId : undefined); - teamId = routingInfo.teamId || "api"; - } else if (platform !== "api") { - // Platform-specific fields required but not provided - return c.json( - { - success: false, - error: `Platform-specific routing info required for ${platform}`, - }, - 400 - ); - } - } - - logger.info( - `Sending message via ${platform}: agentId=${agentId}, channelId=${channelId}${files && files.length > 0 ? `, files=${files.length}` : ""}` - ); - if (!adapter) { - const availablePlatforms = platformRegistry.getAvailablePlatforms(); - return c.json( - { - success: false, - error: `Platform "${platform}" not found`, - availablePlatforms, - }, - 404 - ); - } - - if (!adapter.sendMessage) { - return c.json( - { - success: false, - error: `Platform "${platform}" does not support sendMessage`, - }, - 501 - ); - } - - const options: { - agentId: string; - channelId: string; - conversationId?: string; - teamId: string; - files?: Array<{ buffer: Buffer; filename: string }>; - } = { - agentId, - channelId, - conversationId, - teamId, - }; - - if (files && files.length > 0) { - options.files = files; - } - - const result = await adapter.sendMessage(token, message, options); - - logger.info( - `Message sent: agentId=${agentId}, messageId=${result.messageId}` - ); - - return c.json({ - success: true, - agentId, - messageId: result.messageId, - eventsUrl: result.eventsUrl, - queued: result.queued || false, - }); - } catch (error) { - logger.error("Failed to send message:", error); - return c.json( - { - success: false, - error: "Internal server error", - }, - 500 - ); - } - }); - - logger.debug("Messaging routes registered"); - return app; -} diff --git a/packages/gateway/src/routes/public/oauth.ts b/packages/gateway/src/routes/public/oauth.ts index 75931cde4..cf2dd9ba3 100644 --- a/packages/gateway/src/routes/public/oauth.ts +++ b/packages/gateway/src/routes/public/oauth.ts @@ -6,7 +6,7 @@ import { createRoute, OpenAPIHono, z } from "@hono/zod-openapi"; import type { ProviderOAuthStateStore } from "../../auth/oauth/state-store"; -import { verifySettingsSession } from "./settings-auth"; +import { verifySettingsSessionOrToken } from "./settings-auth"; const TAG = "Auth"; const SuccessResponse = z.object({ success: z.boolean() }); @@ -72,7 +72,7 @@ export function createOAuthRoutes(config: OAuthRoutesConfig): OpenAPIHono { // --- Provider login redirect (excluded from docs) --- app.get("/:provider/login", async (c) => { - const session = verifySettingsSession(c); + const session = verifySettingsSessionOrToken(c); const agentId = session?.agentId || c.req.query("agentId"); if (!agentId) return c.json({ error: "Missing agentId" }, 400); @@ -81,7 +81,7 @@ export function createOAuthRoutes(config: OAuthRoutesConfig): OpenAPIHono { // No session — redirect through settings OAuth to establish one, then return here const returnUrl = `${c.req.path}?agentId=${encodeURIComponent(agentId)}`; return c.redirect( - `/agent/oauth/login?returnUrl=${encodeURIComponent(returnUrl)}` + `/connect/oauth/login?returnUrl=${encodeURIComponent(returnUrl)}` ); } @@ -103,7 +103,7 @@ export function createOAuthRoutes(config: OAuthRoutesConfig): OpenAPIHono { // --- Provider code exchange --- app.openapi(codeExchangeRoute, async (c): Promise => { - const session = verifySettingsSession(c); + const session = verifySettingsSessionOrToken(c); const agentId = session?.agentId; if (!session || !agentId) return c.json({ error: "Unauthorized" }, 401); diff --git a/packages/gateway/src/routes/public/settings-auth.ts b/packages/gateway/src/routes/public/settings-auth.ts index 57027b62a..155b90673 100644 --- a/packages/gateway/src/routes/public/settings-auth.ts +++ b/packages/gateway/src/routes/public/settings-auth.ts @@ -18,6 +18,24 @@ export function setAuthProvider(provider: AuthProvider | null): void { _authProvider = provider; } +function decodeSettingsPayload( + token: string | null | undefined +): SettingsTokenPayload | null { + if (!token || token.trim().length === 0) return null; + + try { + const decrypted = decrypt(token); + const payload = JSON.parse(decrypted) as SettingsTokenPayload; + + if (!payload.userId || !payload.exp) return null; + if (Date.now() > payload.exp) return null; + + return payload; + } catch { + return null; + } +} + function isSecureRequest(c: Context): boolean { const forwardedProto = c.req.header("x-forwarded-proto"); if (forwardedProto) { @@ -38,19 +56,25 @@ export function verifySettingsSession(c: Context): SettingsTokenPayload | null { } const token = getCookie(c, SETTINGS_SESSION_COOKIE_NAME); - if (!token || token.trim().length === 0) return null; - - try { - const decrypted = decrypt(token); - const payload = JSON.parse(decrypted) as SettingsTokenPayload; + return decodeSettingsPayload(token); +} - if (!payload.userId || !payload.exp) return null; - if (Date.now() > payload.exp) return null; +export function verifySettingsToken( + token: string | null | undefined +): SettingsTokenPayload | null { + if (!token) return null; + return decodeSettingsPayload(token); +} - return payload; - } catch { - return null; - } +/** + * Resolve settings auth from an injected auth provider, cookie session, + * or a direct encrypted query token. + */ +export function verifySettingsSessionOrToken( + c: Context, + queryKey = "token" +): SettingsTokenPayload | null { + return verifySettingsSession(c) ?? verifySettingsToken(c.req.query(queryKey)); } /** diff --git a/packages/gateway/src/routes/public/settings-input.css b/packages/gateway/src/routes/public/settings-input.css deleted file mode 100644 index 40dce99bc..000000000 --- a/packages/gateway/src/routes/public/settings-input.css +++ /dev/null @@ -1,69 +0,0 @@ -@tailwind base; -@tailwind components; -@tailwind utilities; - -/* Scoped prose styles for rendered markdown inside skill content previews */ -.skill-content h1 { - font-size: 1.1em; - font-weight: 700; - margin: 0.75em 0 0.25em; -} -.skill-content h2 { - font-size: 1em; - font-weight: 700; - margin: 0.6em 0 0.2em; -} -.skill-content h3 { - font-size: 0.95em; - font-weight: 600; - margin: 0.5em 0 0.15em; -} -.skill-content p { - margin: 0.3em 0; -} -.skill-content ul, -.skill-content ol { - margin: 0.3em 0; - padding-left: 1.4em; -} -.skill-content ul { - list-style-type: disc; -} -.skill-content ol { - list-style-type: decimal; -} -.skill-content li { - margin: 0.15em 0; -} -.skill-content pre { - background: #1e293b; - color: #e2e8f0; - border-radius: 0.375rem; - padding: 0.5em 0.75em; - overflow-x: auto; - margin: 0.4em 0; - font-size: 0.92em; -} -.skill-content code { - font-family: ui-monospace, monospace; - font-size: 0.92em; -} -.skill-content :not(pre) > code { - background: #e2e8f0; - padding: 0.1em 0.3em; - border-radius: 0.25rem; -} -.skill-content a { - color: #7c3aed; - text-decoration: underline; -} -.skill-content blockquote { - border-left: 3px solid #d1d5db; - padding-left: 0.6em; - color: #6b7280; - margin: 0.4em 0; -} -.skill-content hr { - border-color: #e5e7eb; - margin: 0.5em 0; -} diff --git a/packages/gateway/src/routes/shared/agent-ownership.ts b/packages/gateway/src/routes/shared/agent-ownership.ts new file mode 100644 index 000000000..6490caa9f --- /dev/null +++ b/packages/gateway/src/routes/shared/agent-ownership.ts @@ -0,0 +1,101 @@ +import type { AgentConfigStore } from "@lobu/core"; +import type { SettingsTokenPayload } from "../../auth/settings/token-service"; +import type { UserAgentsStore } from "../../auth/user-agents-store"; +import { getAuthMethod } from "../../connections/platform-auth-methods"; + +export interface AgentOwnershipConfig { + userAgentsStore?: UserAgentsStore; + agentMetadataStore?: Pick; +} + +export interface AgentOwnershipResult { + authorized: boolean; + ownerPlatform?: string; + ownerUserId?: string; +} + +export function resolveSettingsLookupUserId( + session: SettingsTokenPayload +): string { + if (session.platform === "external") { + return session.oauthUserId || session.userId; + } + + const isDeterministic = getAuthMethod(session.platform).type !== "oauth"; + return isDeterministic + ? session.userId + : session.oauthUserId || session.userId; +} + +function sessionMatchesMetadataOwner( + session: SettingsTokenPayload, + ownerPlatform: string, + ownerUserId: string +): boolean { + const lookupUserId = resolveSettingsLookupUserId(session); + if (!lookupUserId || ownerUserId !== lookupUserId) { + return false; + } + + return ownerPlatform === session.platform || session.platform === "external"; +} + +export async function verifyOwnedAgentAccess( + session: SettingsTokenPayload, + agentId: string, + config: AgentOwnershipConfig +): Promise { + if (session.isAdmin) { + return { authorized: true }; + } + + if (session.agentId) { + return { authorized: session.agentId === agentId }; + } + + const lookupUserId = resolveSettingsLookupUserId(session); + if (config.userAgentsStore) { + const owns = await config.userAgentsStore.ownsAgent( + session.platform, + lookupUserId, + agentId + ); + if (owns) { + return { + authorized: true, + ownerPlatform: session.platform, + ownerUserId: lookupUserId, + }; + } + } + + if (!config.agentMetadataStore) { + return { authorized: false }; + } + + const metadata = await config.agentMetadataStore.getMetadata(agentId); + if ( + !metadata?.owner || + !sessionMatchesMetadataOwner( + session, + metadata.owner.platform, + metadata.owner.userId + ) + ) { + return { authorized: false }; + } + + if (config.userAgentsStore) { + config.userAgentsStore + .addAgent(session.platform, lookupUserId, agentId) + .catch(() => { + /* best-effort reconciliation */ + }); + } + + return { + authorized: true, + ownerPlatform: metadata.owner.platform, + ownerUserId: metadata.owner.userId, + }; +} diff --git a/packages/gateway/src/routes/shared/token-verifier.ts b/packages/gateway/src/routes/shared/token-verifier.ts index 03b5525d3..b27d9580b 100644 --- a/packages/gateway/src/routes/shared/token-verifier.ts +++ b/packages/gateway/src/routes/shared/token-verifier.ts @@ -5,13 +5,14 @@ * user ownership via UserAgentsStore, or canonical metadata owner fallback. */ -import type { AgentMetadataStore } from "../../auth/agent-metadata-store"; +import type { AgentConfigStore } from "@lobu/core"; import type { SettingsTokenPayload } from "../../auth/settings/token-service"; import type { UserAgentsStore } from "../../auth/user-agents-store"; +import { verifyOwnedAgentAccess } from "./agent-ownership"; export interface TokenVerifierConfig { userAgentsStore?: UserAgentsStore; - agentMetadataStore?: AgentMetadataStore; + agentMetadataStore?: Pick; } /** @@ -27,34 +28,7 @@ export function createTokenVerifier(config: TokenVerifierConfig) { ): Promise => { if (!payload) return null; - if (payload.agentId) { - if (payload.agentId !== agentId) return null; - } else { - const owns = config.userAgentsStore - ? await config.userAgentsStore.ownsAgent( - payload.platform, - payload.userId, - agentId - ) - : false; - - if (!owns) { - if (!config.agentMetadataStore) return null; - const metadata = await config.agentMetadataStore.getMetadata(agentId); - const isOwner = - metadata?.owner?.platform === payload.platform && - metadata?.owner?.userId === payload.userId; - if (!isOwner) return null; - - if (isOwner && config.userAgentsStore) { - config.userAgentsStore - .addAgent(payload.platform, payload.userId, agentId) - .catch(() => { - /* best-effort reconciliation */ - }); - } - } - } - return payload; + const result = await verifyOwnedAgentAccess(payload, agentId, config); + return result.authorized ? payload : null; }; } diff --git a/packages/gateway/src/services/agent-seeder.ts b/packages/gateway/src/services/agent-seeder.ts deleted file mode 100644 index 3b77fc9ee..000000000 --- a/packages/gateway/src/services/agent-seeder.ts +++ /dev/null @@ -1,382 +0,0 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; -import { - type AgentConfigStore, - type AgentSettings, - createLogger, - type InstalledProvider, - type SkillConfig, -} from "@lobu/core"; -import type { AuthProfilesManager } from "../auth/settings/auth-profiles-manager"; - -const logger = createLogger("agent-seeder"); - -// NOTE: Keep in sync with packages/cli/src/config/agents-manifest.ts -interface ManifestSkill { - repo: string; - name: string; - description?: string; - instructions?: string; - content: string; - enabled: boolean; - system?: boolean; - mcpServers?: SkillConfig["mcpServers"]; - nixPackages?: SkillConfig["nixPackages"]; - permissions?: SkillConfig["permissions"]; - providers?: SkillConfig["providers"]; - modelPreference?: SkillConfig["modelPreference"]; - thinkingLevel?: SkillConfig["thinkingLevel"]; -} - -interface AgentManifestEntry { - agentId: string; - name: string; - description?: string; - settings: { - identityMd?: string; - soulMd?: string; - userMd?: string; - installedProviders?: Array<{ - providerId: string; - }>; - modelSelection?: AgentSettings["modelSelection"]; - providerModelPreferences?: AgentSettings["providerModelPreferences"]; - nixConfig?: AgentSettings["nixConfig"]; - skillsConfig?: { - skills: ManifestSkill[]; - }; - networkConfig?: { - allowedDomains?: string[]; - deniedDomains?: string[]; - }; - mcpServers?: Record< - string, - { - url?: string; - command?: string; - args?: string[]; - env?: Record; - headers?: Record; - oauth?: { - authUrl: string; - tokenUrl: string; - clientId?: string; - clientSecret?: string; - scopes?: string[]; - tokenEndpointAuthMethod?: string; - }; - } - >; - }; - credentials?: Array<{ - providerId: string; - key: string; - }>; - connections?: Array<{ - type: string; - config: Record; - }>; -} - -interface AgentsManifest { - version: number; - agents: AgentManifestEntry[]; -} - -/** - * Seed agents from .lobu/agents.json using the unified AgentStore. - */ -export async function seedAgentsFromManifest( - agentStore: AgentConfigStore, - authProfilesManager?: AuthProfilesManager -): Promise { - const manifest = loadManifest(); - if (!manifest) return; - - logger.debug(`Seeding ${manifest.agents.length} agent(s) from manifest`); - - for (const entry of manifest.agents) { - try { - const existingMetadata = await agentStore.getMetadata(entry.agentId); - if (!existingMetadata) { - await agentStore.saveMetadata(entry.agentId, { - agentId: entry.agentId, - name: entry.name, - description: entry.description, - owner: { platform: "system", userId: "manifest" }, - createdAt: Date.now(), - }); - } else if ( - existingMetadata.name !== entry.name || - existingMetadata.description !== entry.description - ) { - await agentStore.updateMetadata(entry.agentId, { - name: entry.name, - description: entry.description, - }); - } - - const existingSettings = await agentStore.getSettings(entry.agentId); - const nextSettings = buildReconciledSettings(entry, existingSettings); - const settingsChanged = settingsDiffer(existingSettings, nextSettings); - - if (settingsChanged) { - await agentStore.saveSettings(entry.agentId, { - ...nextSettings, - updatedAt: Date.now(), - }); - logger.debug(`Reconciled settings for agent "${entry.agentId}"`); - } - - // Seed provider credentials - if (authProfilesManager && entry.credentials?.length) { - for (const cred of entry.credentials) { - await authProfilesManager.upsertProfile({ - agentId: entry.agentId, - provider: cred.providerId, - credential: cred.key, - authType: "api-key", - label: `${cred.providerId} (from lobu.toml)`, - makePrimary: true, - }); - } - } - - // NOTE: Connections are NOT seeded here. They go through - // seedConnectionsFromManifest() → ChatInstanceManager.addConnection() - // which handles both persistence AND starting the live adapter. - // That call happens later in startGateway() after ChatInstanceManager is ready. - } catch (err) { - logger.error(`Failed to seed agent "${entry.agentId}"`, { - error: err instanceof Error ? err.message : String(err), - }); - } - } -} - -function loadManifest(): AgentsManifest | null { - const manifestPath = resolve(process.cwd(), ".lobu/agents.json"); - let raw: string; - try { - raw = readFileSync(manifestPath, "utf-8"); - } catch { - return null; - } - try { - const manifest = JSON.parse(raw) as AgentsManifest; - if (!manifest.agents || manifest.agents.length === 0) return null; - return manifest; - } catch (err) { - logger.warn("Failed to parse agents.json", { - error: err instanceof Error ? err.message : String(err), - }); - return null; - } -} - -/** - * Seed connections from the manifest after ChatInstanceManager is ready. - * Skips connections that already exist for the agent on the same platform. - */ -export async function seedConnectionsFromManifest(chatInstanceManager: { - listConnections(filter?: { - platform?: string; - templateAgentId?: string; - }): Promise>; - addConnection( - platform: string, - templateAgentId: string | undefined, - config: any, - settings?: { allowGroups?: boolean } - ): Promise; -}): Promise { - const manifestPath = resolve(process.cwd(), ".lobu/agents.json"); - - let raw: string; - try { - raw = readFileSync(manifestPath, "utf-8"); - } catch { - return; - } - - let manifest: AgentsManifest; - try { - manifest = JSON.parse(raw); - } catch { - return; - } - - if (!manifest.agents) return; - - for (const entry of manifest.agents) { - if (!entry.connections?.length) continue; - - for (const conn of entry.connections) { - // Skip if a connection already exists for this agent + platform - const existing = await chatInstanceManager.listConnections({ - platform: conn.type, - templateAgentId: entry.agentId, - }); - if (existing.length > 0) continue; - - try { - await chatInstanceManager.addConnection( - conn.type, - entry.agentId, - { platform: conn.type, ...conn.config }, - { allowGroups: true } - ); - logger.debug( - `Created ${conn.type} connection for agent "${entry.agentId}"` - ); - } catch (err) { - logger.error( - `Failed to create ${conn.type} connection for agent "${entry.agentId}"`, - { error: err instanceof Error ? err.message : String(err) } - ); - } - } - } -} - -function buildReconciledSettings( - entry: AgentManifestEntry, - existing: AgentSettings | null -): Omit { - const { updatedAt, ...base } = existing || {}; - const installedProviders = buildInstalledProviders( - entry.settings.installedProviders, - existing?.installedProviders - ); - const skillsConfig = buildSkillsConfig( - entry.settings.skillsConfig?.skills, - existing?.skillsConfig?.skills - ); - const modelSelection = entry.settings.modelSelection; - - return { - ...base, - model: - modelSelection?.mode === "pinned" - ? modelSelection.pinnedModel - : undefined, - modelSelection, - providerModelPreferences: entry.settings.providerModelPreferences, - identityMd: entry.settings.identityMd, - soulMd: entry.settings.soulMd, - userMd: entry.settings.userMd, - installedProviders, - skillsConfig, - networkConfig: entry.settings.networkConfig, - nixConfig: entry.settings.nixConfig, - mcpServers: entry.settings.mcpServers, - }; -} - -function buildInstalledProviders( - manifestProviders: AgentManifestEntry["settings"]["installedProviders"], - existingProviders: AgentSettings["installedProviders"] -): InstalledProvider[] | undefined { - if (!manifestProviders) { - return undefined; - } - - const manifestIds = new Set(manifestProviders.map((p) => p.providerId)); - - const merged: InstalledProvider[] = manifestProviders.map( - (provider, index) => { - const existing = existingProviders?.find( - (candidate) => candidate.providerId === provider.providerId - ); - return { - providerId: provider.providerId, - installedAt: existing?.installedAt ?? Date.now() + index, - ...(existing?.config ? { config: existing.config } : {}), - }; - } - ); - - // Preserve providers added via the API that aren't in the manifest - if (existingProviders) { - for (const existing of existingProviders) { - if (!manifestIds.has(existing.providerId)) { - merged.push(existing); - } - } - } - - return merged; -} - -function buildSkillsConfig( - manifestSkills: ManifestSkill[] | undefined, - existingSkills: SkillConfig[] | undefined -): AgentSettings["skillsConfig"] { - if (!manifestSkills) { - return undefined; - } - - return { - skills: manifestSkills.map((skill): SkillConfig => { - const existing = existingSkills?.find( - (candidate) => - candidate.repo === skill.repo || candidate.name === skill.name - ); - const contentFetchedAt = - existing?.content === skill.content && existing.contentFetchedAt - ? existing.contentFetchedAt - : skill.content - ? Date.now() - : existing?.contentFetchedAt; - - return { - repo: skill.repo, - name: skill.name, - description: skill.description, - instructions: skill.instructions, - enabled: skill.enabled, - system: skill.system, - content: skill.content || undefined, - contentFetchedAt, - mcpServers: skill.mcpServers, - nixPackages: skill.nixPackages, - permissions: skill.permissions, - providers: skill.providers, - modelPreference: skill.modelPreference, - thinkingLevel: skill.thinkingLevel, - }; - }), - }; -} - -function settingsDiffer( - existing: AgentSettings | null, - next: Omit -): boolean { - if (!existing) { - return true; - } - - const { updatedAt, ...current } = existing; - return stableStringify(current) !== stableStringify(next); -} - -function stableStringify(value: unknown): string { - return JSON.stringify(sortValue(value)); -} - -function sortValue(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map((entry) => sortValue(entry)); - } - - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value as Record) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, entry]) => [key, sortValue(entry)]) - ); - } - - return value; -} diff --git a/packages/gateway/src/services/core-services.ts b/packages/gateway/src/services/core-services.ts index 69829c98b..ef3b8433a 100644 --- a/packages/gateway/src/services/core-services.ts +++ b/packages/gateway/src/services/core-services.ts @@ -7,14 +7,13 @@ import { CommandRegistry, createLogger, moduleRegistry, - type RegistryEntry, type SystemSkillEntry, } from "@lobu/core"; -import { AdminStatusCache } from "../auth/admin-status-cache"; import { AgentMetadataStore } from "../auth/agent-metadata-store"; import { ApiKeyProviderModule } from "../auth/api-key-provider-module"; import { ChatGPTOAuthModule } from "../auth/chatgpt"; import { ClaudeOAuthModule } from "../auth/claude/oauth-module"; +import { ExternalAuthClient } from "../auth/external/client"; import { McpConfigService } from "../auth/mcp/config-service"; import { McpProxy } from "../auth/mcp/proxy"; import { McpToolCache } from "../auth/mcp/tool-cache"; @@ -22,18 +21,19 @@ import { OAuthClient } from "../auth/oauth/client"; import { CLAUDE_PROVIDER } from "../auth/oauth/providers"; import { createOAuthStateStore, - OAuthStateStore, type ProviderOAuthStateStore, } from "../auth/oauth/state-store"; import { ProviderCatalogService } from "../auth/provider-catalog"; import { AgentSettingsStore, AuthProfilesManager } from "../auth/settings"; -import { ClaimService } from "../auth/settings/claim-service"; import { ModelPreferenceStore } from "../auth/settings/model-preference-store"; -import { SettingsOAuthClient } from "../auth/settings/oauth-client"; import { UserAgentsStore } from "../auth/user-agents-store"; import { ChannelBindingService } from "../channels"; import { registerBuiltInCommands } from "../commands/built-in-commands"; -import type { GatewayConfig } from "../config"; +import type { AgentConfig, GatewayConfig } from "../config"; +import { + type FileLoadedAgent, + loadAgentConfigFromFiles, +} from "../config/file-loader"; import { WorkerGateway } from "../gateway"; import type { IMessageQueue } from "../infrastructure/queue"; import { @@ -50,7 +50,7 @@ import { import { GrantStore } from "../permissions/grant-store"; import { SecretProxy } from "../proxy/secret-proxy"; import { TokenRefreshJob } from "../proxy/token-refresh-job"; -import { seedAgentsFromManifest } from "./agent-seeder"; +import { InMemoryAgentStore } from "../stores/in-memory-agent-store"; import { ImageGenerationService } from "./image-generation-service"; import { InstructionService } from "./instruction-service"; import { RedisSessionStore, SessionManager } from "./session-manager"; @@ -118,18 +118,11 @@ export class CoreServices { private imageGenerationService?: ImageGenerationService; private userAgentsStore?: UserAgentsStore; private agentMetadataStore?: AgentMetadataStore; - private adminStatusCache?: AdminStatusCache; // ============================================================================ - // Settings OAuth + // External OAuth // ============================================================================ - private claimService?: ClaimService; - private settingsOAuthClient?: SettingsOAuthClient; - private settingsOAuthStateStore?: OAuthStateStore<{ - userId: string; - codeVerifier: string; - returnUrl: string; - }>; + private externalAuthClient?: ExternalAuthClient; // ============================================================================ // Provider Catalog @@ -154,13 +147,17 @@ export class CoreServices { private accessStore?: AgentAccessStore; private settingsResolver?: SettingsResolver; + // File-first architecture state + private fileLoadedAgents: FileLoadedAgent[] = []; + private projectPath: string | null = null; + private configAgents: AgentConfig[] = []; + // Options stored for deferred initialization private options?: { configStore?: AgentConfigStore; connectionStore?: AgentConnectionStore; accessStore?: AgentAccessStore; systemSkills?: SystemSkillEntry[]; - skillRegistries?: RegistryEntry[]; }; constructor( @@ -170,7 +167,6 @@ export class CoreServices { connectionStore?: AgentConnectionStore; accessStore?: AgentAccessStore; systemSkills?: SystemSkillEntry[]; - skillRegistries?: RegistryEntry[]; } ) { this.options = options; @@ -196,10 +192,6 @@ export class CoreServices { return this.settingsResolver; } - getSkillRegistryConfigs(): RegistryEntry[] | undefined { - return this.options?.skillRegistries; - } - /** * Initialize all core services in dependency order */ @@ -312,14 +304,54 @@ export class CoreServices { this.interactionService = new InteractionService(); logger.debug("Interaction service initialized"); - // Initialize agent sub-stores — default missing ones to Redis + // Initialize agent sub-stores if (!this.configStore || !this.connectionStore || !this.accessStore) { - const { RedisAgentStore } = await import("../stores/redis-agent-store"); - const redisStore = new RedisAgentStore(redisClient); - if (!this.configStore) this.configStore = redisStore; - if (!this.connectionStore) this.connectionStore = redisStore; - if (!this.accessStore) this.accessStore = redisStore; - logger.debug("Agent sub-stores initialized (Redis defaults for missing)"); + const inMemoryStore = new InMemoryAgentStore(); + if (!this.configStore) this.configStore = inMemoryStore; + if (!this.connectionStore) this.connectionStore = inMemoryStore; + if (!this.accessStore) this.accessStore = inMemoryStore; + + if (this.config.agents?.length) { + // Embedded mode: populate from config.agents + await this.populateStoreFromAgentConfigs( + inMemoryStore, + this.config.agents + ); + logger.debug( + `Agent sub-stores initialized (in-memory, ${this.config.agents.length} agent(s) from config)` + ); + } else { + // Check if lobu.toml exists (file-first dev mode) + const { existsSync } = await import("node:fs"); + const { resolve } = await import("node:path"); + const workspaceRoot = process.env.LOBU_WORKSPACE_ROOT?.trim(); + const candidatePaths = [ + ...(workspaceRoot ? [resolve(workspaceRoot, "lobu.toml")] : []), + resolve(process.cwd(), "lobu.toml"), + resolve("/app/lobu.toml"), + resolve("/app/.lobu/lobu.toml"), + ]; + const tomlPath = candidatePaths.find((p) => existsSync(p)); + + if (tomlPath) { + // File-first dev mode: use InMemoryAgentStore populated from files + this.projectPath = resolve(tomlPath, ".."); + + // Load agents from files and populate store + this.fileLoadedAgents = await loadAgentConfigFromFiles( + this.projectPath + ); + await this.populateStoreFromFiles( + inMemoryStore, + this.fileLoadedAgents + ); + logger.debug( + `Agent sub-stores initialized (in-memory, ${this.fileLoadedAgents.length} agent(s) from files)` + ); + } else { + logger.debug("Agent sub-stores initialized (in-memory, empty)"); + } + } } else { logger.debug("Using host-provided agent sub-stores (embedded mode)"); } @@ -339,29 +371,19 @@ export class CoreServices { this.channelBindingService = new ChannelBindingService(redisClient); this.userAgentsStore = new UserAgentsStore(redisClient); this.agentMetadataStore = new AgentMetadataStore(redisClient); - this.adminStatusCache = new AdminStatusCache(redisClient); logger.debug( "Agent settings, channel binding, user agents & metadata stores initialized" ); - // Initialize claim service (always available, used by OAuth settings flow) - this.claimService = new ClaimService(redisClient); - logger.debug("Claim service initialized"); - - // Initialize settings OAuth client if configured - this.settingsOAuthClient = - SettingsOAuthClient.fromEnv(this.config.mcp.publicGatewayUrl, { + // Initialize external OAuth client if configured + this.externalAuthClient = + ExternalAuthClient.fromEnv(this.config.mcp.publicGatewayUrl, { get: (key) => redisClient.get(key), set: (key, value, ttlSeconds) => redisClient.setex(key, ttlSeconds, value), }) ?? undefined; - if (this.settingsOAuthClient) { - this.settingsOAuthStateStore = new OAuthStateStore( - redisClient, - "settings:oauth:state", - "settings-oauth-state" - ); - logger.debug("Settings OAuth client initialized"); + if (this.externalAuthClient) { + logger.debug("External OAuth client initialized"); } } @@ -382,6 +404,27 @@ export class CoreServices { ); } + if (this.fileLoadedAgents.length > 0) { + for (const agent of this.fileLoadedAgents) { + await this.syncAgentSettingsToRuntimeStore(agent.agentId, agent.settings); + } + logger.debug( + `Synced settings for ${this.fileLoadedAgents.length} file-loaded agent(s)` + ); + } + + if (this.configAgents.length > 0) { + for (const agent of this.configAgents) { + await this.syncAgentSettingsToRuntimeStore( + agent.id, + this.buildSettingsFromAgentConfig(agent) + ); + } + logger.debug( + `Synced settings for ${this.configAgents.length} config agent(s)` + ); + } + // Initialize auth profile and preference stores this.authProfilesManager = new AuthProfilesManager(this.agentSettingsStore); this.transcriptionService = new TranscriptionService( @@ -392,8 +435,44 @@ export class CoreServices { ); this.modelPreferenceStore = new ModelPreferenceStore(redisClient, "claude"); - // Seed agents from .lobu/agents.json manifest (CLI-managed projects) - await seedAgentsFromManifest(this.configStore!, this.authProfilesManager); + // Seed provider credentials from file-loaded agents + if (this.authProfilesManager && this.fileLoadedAgents.length > 0) { + for (const agent of this.fileLoadedAgents) { + for (const cred of agent.credentials) { + await this.authProfilesManager.upsertProfile({ + agentId: agent.agentId, + provider: cred.provider, + credential: cred.key, + authType: "api-key", + label: `${cred.provider} (from lobu.toml)`, + makePrimary: true, + }); + } + } + logger.debug( + `Seeded credentials for ${this.fileLoadedAgents.length} file-loaded agent(s)` + ); + } + + // Seed provider credentials from config agents (embedded mode) + if (this.authProfilesManager && this.configAgents.length > 0) { + for (const agent of this.configAgents) { + for (const provider of agent.providers || []) { + if (!provider.key) continue; + await this.authProfilesManager.upsertProfile({ + agentId: agent.id, + provider: provider.id, + credential: provider.key, + authType: "api-key", + label: `${provider.id} (from config)`, + makePrimary: true, + }); + } + } + logger.debug( + `Seeded credentials for ${this.configAgents.length} config agent(s)` + ); + } logger.debug( "Auth profile, model preference, transcription, and image generation services initialized" @@ -449,27 +528,9 @@ export class CoreServices { this.options.systemSkills ); } else { - let systemSkillsUrl = "config/system-skills.json"; - try { - const { readFileSync, existsSync } = await import("node:fs"); - const { resolve } = await import("node:path"); - const configPath = resolve( - process.cwd(), - "config/skill-registries.json" - ); - if (existsSync(configPath)) { - const raw = JSON.parse(readFileSync(configPath, "utf-8")); - const lobuEntry = (raw.registries || []).find( - (r: any) => r.type === "lobu" - ); - if (lobuEntry?.apiUrl) { - systemSkillsUrl = lobuEntry.apiUrl; - } - } - } catch (error) { - logger.warn("Failed to read skill registries config", { error }); - } - this.systemSkillsService = new SystemSkillsService(systemSkillsUrl); + this.systemSkillsService = new SystemSkillsService( + "config/system-skills.json" + ); } this.systemConfigResolver = new SystemConfigResolver( this.systemSkillsService @@ -622,20 +683,190 @@ export class CoreServices { "Agent settings store must be initialized before command registry" ); } - if (!this.claimService) { - throw new Error( - "Claim service must be initialized before command registry" - ); - } - this.commandRegistry = new CommandRegistry(); registerBuiltInCommands(this.commandRegistry, { agentSettingsStore: this.agentSettingsStore, - claimService: this.claimService, }); logger.debug("Command registry initialized with built-in commands"); } + // ============================================================================ + // File-First Helpers + // ============================================================================ + + private async populateStoreFromFiles( + store: InMemoryAgentStore, + agents: FileLoadedAgent[] + ): Promise { + for (const agent of agents) { + await store.saveMetadata(agent.agentId, { + agentId: agent.agentId, + name: agent.name, + description: agent.description, + owner: { platform: "system", userId: "manifest" }, + createdAt: Date.now(), + }); + await store.saveSettings(agent.agentId, { + ...agent.settings, + updatedAt: Date.now(), + } as any); + } + } + + private buildSettingsFromAgentConfig(agent: AgentConfig): Record { + const settings: Record = {}; + if (agent.identityMd) settings.identityMd = agent.identityMd; + if (agent.soulMd) settings.soulMd = agent.soulMd; + if (agent.userMd) settings.userMd = agent.userMd; + + if (agent.providers?.length) { + settings.installedProviders = agent.providers.map((p) => ({ + providerId: p.id, + installedAt: Date.now(), + })); + settings.modelSelection = { mode: "auto" }; + const providerModelPreferences = Object.fromEntries( + agent.providers + .filter((p) => !!p.model?.trim()) + .map((p) => [p.id, p.model!.trim()]) + ); + if (Object.keys(providerModelPreferences).length > 0) { + settings.providerModelPreferences = providerModelPreferences; + } + } + + if (agent.skills?.mcp) { + settings.mcpServers = agent.skills.mcp; + } + + if (agent.network) { + settings.networkConfig = { + allowedDomains: agent.network.allowed, + deniedDomains: agent.network.denied, + }; + } + + if (agent.nixPackages?.length) { + settings.nixConfig = { packages: agent.nixPackages }; + } + + return settings; + } + + private async syncAgentSettingsToRuntimeStore( + agentId: string, + settings: Record + ): Promise { + if (!this.agentSettingsStore) { + throw new Error("Agent settings store must be initialized"); + } + + const existing = await this.agentSettingsStore.getSettings(agentId); + await this.agentSettingsStore.saveSettings(agentId, { + ...settings, + authProfiles: existing?.authProfiles, + mcpInstallNotified: existing?.mcpInstallNotified, + }); + } + + private async populateStoreFromAgentConfigs( + store: InMemoryAgentStore, + agents: AgentConfig[] + ): Promise { + for (const agent of agents) { + await store.saveMetadata(agent.id, { + agentId: agent.id, + name: agent.name, + description: agent.description, + owner: { platform: "system", userId: "config" }, + createdAt: Date.now(), + }); + await store.saveSettings( + agent.id, + { + ...this.buildSettingsFromAgentConfig(agent), + updatedAt: Date.now(), + } as any + ); + } + + // Store agent configs for credential seeding and connection seeding later + this.configAgents = agents; + } + + /** + * Reload agent config from files (dev mode only). + * Re-reads lobu.toml + markdown, clears and re-populates the in-memory store. + */ + async reloadFromFiles(): Promise<{ reloaded: boolean; agents: string[] }> { + if (!this.projectPath) { + return { reloaded: false, agents: [] }; + } + + // Re-load from disk + this.fileLoadedAgents = await loadAgentConfigFromFiles(this.projectPath); + + // Re-populate the in-memory store (clear existing data first) + if (this.configStore instanceof InMemoryAgentStore) { + const store = this.configStore as InMemoryAgentStore; + // Clear existing agents by loading fresh + const existing = await store.listAgents(); + for (const meta of existing) { + // Only clear file-managed agents (owner: system/manifest) + if ( + meta.owner?.platform === "system" && + meta.owner?.userId === "manifest" + ) { + await store.deleteSettings(meta.agentId); + await store.deleteMetadata(meta.agentId); + } + } + await this.populateStoreFromFiles(store, this.fileLoadedAgents); + } + + if (this.agentSettingsStore) { + for (const agent of this.fileLoadedAgents) { + await this.syncAgentSettingsToRuntimeStore(agent.agentId, agent.settings); + } + } + + // Re-seed credentials + if (this.authProfilesManager) { + for (const agent of this.fileLoadedAgents) { + for (const cred of agent.credentials) { + await this.authProfilesManager.upsertProfile({ + agentId: agent.agentId, + provider: cred.provider, + credential: cred.key, + authType: "api-key", + label: `${cred.provider} (from lobu.toml)`, + makePrimary: true, + }); + } + } + } + + const agentIds = this.fileLoadedAgents.map((a) => a.agentId); + logger.info(`Reloaded ${agentIds.length} agent(s) from files`); + return { reloaded: true, agents: agentIds }; + } + + getFileLoadedAgents(): FileLoadedAgent[] { + return this.fileLoadedAgents; + } + + getConfigAgents(): AgentConfig[] { + return this.configAgents; + } + + getProjectPath(): string | null { + return this.projectPath; + } + + isFileFirstMode(): boolean { + return this.projectPath !== null; + } + // ============================================================================ // Shutdown // ============================================================================ @@ -757,12 +988,6 @@ export class CoreServices { return this.agentMetadataStore; } - getAdminStatusCache(): AdminStatusCache { - if (!this.adminStatusCache) - throw new Error("Admin status cache not initialized"); - return this.adminStatusCache; - } - getCommandRegistry(): CommandRegistry { if (!this.commandRegistry) throw new Error("Command registry not initialized"); @@ -791,21 +1016,7 @@ export class CoreServices { return this.systemConfigResolver; } - getClaimService(): ClaimService | undefined { - return this.claimService; - } - - getSettingsOAuthClient(): SettingsOAuthClient | undefined { - return this.settingsOAuthClient; - } - - getSettingsOAuthStateStore(): - | OAuthStateStore<{ - userId: string; - codeVerifier: string; - returnUrl: string; - }> - | undefined { - return this.settingsOAuthStateStore; + getExternalAuthClient(): ExternalAuthClient | undefined { + return this.externalAuthClient; } } diff --git a/packages/gateway/src/services/instruction-service.ts b/packages/gateway/src/services/instruction-service.ts index dea05c7ee..ae68c96e7 100644 --- a/packages/gateway/src/services/instruction-service.ts +++ b/packages/gateway/src/services/instruction-service.ts @@ -95,13 +95,7 @@ ${this.getGenericSkillsInstructions()}`; private getGenericSkillsInstructions(): string { return `## Skills -You can extend your capabilities by searching for installable skills and MCP servers with the built-in tools. - -**Available tools:** -- \`SearchSkills\` - Search for skills and MCP servers by keyword -- \`InstallSkill\` - Install or upgrade a skill or MCP server from search results - -When the user asks about adding capabilities, finding tools, or extending functionality, search for relevant skills first using \`SearchSkills\`.`; +Your available skills are listed above. To read full instructions for a skill, use: \`cat .skills/{skillName}/SKILL.md\``; } } @@ -147,7 +141,7 @@ You can access any external service without restrictions.`; **Internet Access:** Complete isolation (no external access) -You do NOT have access to the internet. All external requests (curl, wget, npm, pip, etc.) will fail. If you need network access, use RequestNetworkAccess to request it — this presents inline approval buttons to the user. Only local operations and MCP tools are available.`; +You do NOT have access to the internet. All external requests (curl, wget, npm, pip, etc.) will fail. Network access is configured via lobu.toml or the gateway configuration APIs. Only local operations and MCP tools are available.`; } // Allowlist mode @@ -178,7 +172,7 @@ ${blockedList}`; instructions += ` -You can only access the allowed domains listed above. All other external requests will be blocked by the proxy. If a domain is blocked, use RequestNetworkAccess to request access — this presents inline approval buttons to the user (default grant: 1 hour). Plan your work accordingly and use available MCP tools when possible.`; +You can only access the allowed domains listed above. All other external requests will be blocked by the proxy. Network access is configured via lobu.toml or the gateway configuration APIs. Plan your work accordingly and use available MCP tools when possible.`; return instructions; } @@ -275,7 +269,7 @@ export class InstructionService { agentInstructions = sections.join("\n\n"); } - // When soul is unconfigured, tell the agent about its settings page + // When soul is unconfigured, tell the agent to defer to admin config. if (!agentInstructions.trim()) { agentInstructions = buildUnconfiguredAgentNotice( options?.settingsUrl diff --git a/packages/gateway/src/services/mcp-discovery.ts b/packages/gateway/src/services/mcp-discovery.ts deleted file mode 100644 index 84681f7b2..000000000 --- a/packages/gateway/src/services/mcp-discovery.ts +++ /dev/null @@ -1,350 +0,0 @@ -import { createLogger } from "@lobu/core"; -import type { PrefillMcpServer } from "../auth/settings/token-service"; -import type { SystemConfigResolver } from "./system-config-resolver"; - -const logger = createLogger("mcp-discovery-service"); - -const DEFAULT_OFFICIAL_REGISTRY_URL = - "https://registry.modelcontextprotocol.io/v0.1/servers"; -const DEFAULT_TIMEOUT_MS = 8000; -const MAX_RECENT_CANDIDATE_KEYS = 2000; - -type DiscoverySource = "official" | "local"; - -interface RegistryRemote { - type?: string; - url?: string; - headers?: Array<{ - name?: string; - isRequired?: boolean; - isSecret?: boolean; - value?: string; - }>; -} - -interface RegistryServerRecord { - server?: { - name?: string; - description?: string; - repository?: { url?: string; source?: string }; - remotes?: RegistryRemote[]; - }; -} - -interface LocalRegistryServer { - id: string; - name: string; - description: string; - type: string; - config: Record; -} - -export interface DiscoveredMcpCandidate { - id: string; - canonicalId: string; - name: string; - description: string; - source: DiscoverySource; - url: string; - requiresAuth: boolean; - prefillMcpServer: PrefillMcpServer; -} - -export class McpDiscoveryService { - private readonly officialRegistryUrl: string; - private readonly timeoutMs: number; - private readonly configResolver?: SystemConfigResolver; - private localRegistry: LocalRegistryServer[] = []; - private localRegistryLoaded = false; - private readonly recentCandidates = new Map(); - - constructor(options: { configResolver?: SystemConfigResolver } = {}) { - this.configResolver = options.configResolver; - this.officialRegistryUrl = - process.env.MCP_DISCOVERY_OFFICIAL_REGISTRY_URL || - DEFAULT_OFFICIAL_REGISTRY_URL; - const timeoutFromEnv = Number(process.env.MCP_DISCOVERY_TIMEOUT_MS || ""); - this.timeoutMs = - Number.isFinite(timeoutFromEnv) && timeoutFromEnv > 0 - ? timeoutFromEnv - : DEFAULT_TIMEOUT_MS; - } - - async search(query: string, limit = 5): Promise { - const trimmed = query.trim(); - if (!trimmed) return []; - await this.ensureLocalRegistryLoaded(); - - const [official, local] = await Promise.all([ - this.searchOfficialRegistry(trimmed, 25), - this.searchLocalRegistry(trimmed), - ]); - - const merged = dedupeById([...official, ...local]); - for (const candidate of merged) { - this.cacheCandidate(candidate); - } - return merged.slice(0, limit); - } - - async getById(id: string): Promise { - const clean = id.trim(); - if (!clean) return null; - await this.ensureLocalRegistryLoaded(); - - const cached = - this.recentCandidates.get(clean) || - this.recentCandidates.get(clean.toLowerCase()); - if (cached) return cached; - - const localMatch = this.searchLocalRegistry(clean).find( - (candidate) => - candidate.id === clean || - candidate.canonicalId === clean || - candidate.id.toLowerCase() === clean.toLowerCase() - ); - if (localMatch) { - this.cacheCandidate(localMatch); - return localMatch; - } - - const officialCandidates = await this.searchOfficialRegistry(clean, 40); - for (const candidate of officialCandidates) { - this.cacheCandidate(candidate); - } - const exact = officialCandidates.find( - (candidate) => - candidate.id === clean || - candidate.canonicalId === clean || - candidate.id.toLowerCase() === clean.toLowerCase() || - candidate.canonicalId.toLowerCase() === clean.toLowerCase() - ); - return exact || null; - } - - private async ensureLocalRegistryLoaded(): Promise { - if (this.localRegistryLoaded) return; - this.localRegistryLoaded = true; - - if (!this.configResolver) { - logger.info("MCP discovery local resolver not configured"); - return; - } - - try { - const resolved = await this.configResolver.getMcpRegistryServers(); - this.localRegistry = resolved.map((entry) => ({ - id: entry.id, - name: entry.name, - description: entry.description, - type: entry.type, - config: entry.config, - })); - logger.info("Loaded local MCP registry", { - source: "resolver", - serverCount: this.localRegistry.length, - }); - } catch (error) { - logger.warn("Failed to load local MCP registry from resolver", { - error, - }); - } - } - - private cacheCandidate(candidate: DiscoveredMcpCandidate): void { - this.setCacheKey(candidate.id, candidate); - this.setCacheKey(candidate.id.toLowerCase(), candidate); - this.setCacheKey(candidate.canonicalId, candidate); - this.setCacheKey(candidate.canonicalId.toLowerCase(), candidate); - } - - private setCacheKey(key: string, candidate: DiscoveredMcpCandidate): void { - if (this.recentCandidates.has(key)) { - this.recentCandidates.delete(key); - } - this.recentCandidates.set(key, candidate); - - while (this.recentCandidates.size > MAX_RECENT_CANDIDATE_KEYS) { - const oldestKey = this.recentCandidates.keys().next().value; - if (!oldestKey) break; - this.recentCandidates.delete(oldestKey); - } - } - - private async searchOfficialRegistry( - query: string, - limit: number - ): Promise { - try { - const url = new URL(this.officialRegistryUrl); - url.searchParams.set("search", query); - url.searchParams.set("limit", String(clamp(limit, 1, 100))); - - const response = await fetchWithTimeout(url.toString(), this.timeoutMs); - if (!response.ok) { - logger.warn("Official MCP registry request failed", { - status: response.status, - statusText: response.statusText, - }); - return []; - } - - const data = (await response.json()) as { - servers?: RegistryServerRecord[]; - }; - - const candidates: DiscoveredMcpCandidate[] = []; - for (const entry of data.servers || []) { - const normalized = normalizeOfficialEntry(entry); - if (normalized) { - candidates.push(normalized); - } - } - - return candidates; - } catch (error) { - logger.warn("Official MCP registry search failed", { query, error }); - return []; - } - } - - private searchLocalRegistry(query: string): DiscoveredMcpCandidate[] { - const q = query.toLowerCase(); - return this.localRegistry - .filter((server) => { - const url = - typeof server.config.url === "string" ? server.config.url.trim() : ""; - if (!isHttpUrl(url)) return false; - - const id = server.id.toLowerCase(); - const name = server.name.toLowerCase(); - const description = (server.description || "").toLowerCase(); - - return id.includes(q) || name.includes(q) || description.includes(q); - }) - .map((server) => { - const url = String(server.config.url); - const requiresAuth = - server.type === "oauth" || - !!server.config.oauth || - !!server.config.loginUrl; - return { - id: server.id, - canonicalId: server.id, - name: server.name, - description: server.description || "", - source: "local" as const, - url, - requiresAuth, - prefillMcpServer: { - id: server.id, - name: server.name, - url, - type: "sse", - }, - }; - }); - } -} - -function normalizeOfficialEntry( - record: RegistryServerRecord -): DiscoveredMcpCandidate | null { - const server = record.server; - if (!server?.name) return null; - - const streamableRemote = (server.remotes || []).find( - (remote) => remote?.type === "streamable-http" && isHttpUrl(remote.url) - ); - if (!streamableRemote || !streamableRemote.url) { - return null; - } - - // We only support turn-key remote MCP installs from chat. - // Skip entries requiring extra secret headers not represented in settings prefill. - if (requiresSecretHeaders(streamableRemote)) { - return null; - } - - const canonicalId = server.name; - const generatedId = sanitizeMcpId(canonicalId); - return { - id: generatedId, - canonicalId, - name: server.name, - description: server.description || "", - source: "official", - url: streamableRemote.url, - requiresAuth: false, - prefillMcpServer: { - id: generatedId, - name: server.name, - url: streamableRemote.url, - type: "sse", - }, - }; -} - -function requiresSecretHeaders(remote: RegistryRemote): boolean { - const headers = remote.headers || []; - return headers.some((header) => !!header.isRequired || !!header.isSecret); -} - -function sanitizeMcpId(raw: string): string { - const lowered = raw.toLowerCase().replace(/[^a-z0-9_-]+/g, "-"); - const compact = lowered.replace(/-+/g, "-").replace(/^-+|-+$/g, ""); - const normalized = compact || "mcp-server"; - const withPrefix = /^[a-z]/.test(normalized) - ? normalized - : `mcp-${normalized}`; - const hash = shortHash(raw); - const base = withPrefix.slice(0, 40); - return `${base}-${hash}`; -} - -function shortHash(value: string): string { - let hash = 2166136261; - for (let i = 0; i < value.length; i++) { - hash ^= value.charCodeAt(i); - hash += - (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24); - } - return (hash >>> 0).toString(36).slice(0, 6); -} - -function isHttpUrl(value: unknown): value is string { - return ( - typeof value === "string" && - (value.startsWith("http://") || value.startsWith("https://")) - ); -} - -function clamp(value: number, min: number, max: number): number { - return Math.max(min, Math.min(max, value)); -} - -function dedupeById( - candidates: DiscoveredMcpCandidate[] -): DiscoveredMcpCandidate[] { - const seen = new Set(); - const output: DiscoveredMcpCandidate[] = []; - for (const candidate of candidates) { - if (seen.has(candidate.id)) continue; - seen.add(candidate.id); - output.push(candidate); - } - return output; -} - -async function fetchWithTimeout( - input: string, - timeoutMs: number -): Promise { - const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), timeoutMs); - try { - return await fetch(input, { signal: controller.signal }); - } finally { - clearTimeout(timeout); - } -} diff --git a/packages/gateway/src/services/skill-registry.ts b/packages/gateway/src/services/skill-registry.ts deleted file mode 100644 index 62b789d03..000000000 --- a/packages/gateway/src/services/skill-registry.ts +++ /dev/null @@ -1,226 +0,0 @@ -import * as fs from "node:fs"; -import * as path from "node:path"; -import { createLogger, type SkillMcpServer } from "@lobu/core"; - -const logger = createLogger("skill-registry"); - -const DEFAULT_CLAWHUB_API_URL = "https://wry-manatee-359.convex.site/api/v1"; - -/** - * Search result returned by a skill registry - */ -export interface SkillRegistryResult { - id: string; - name: string; - description?: string; - installs?: number; - score?: number; - uri?: string; - source: string; -} - -/** - * Full skill content fetched from a registry - */ -export interface SkillContent { - name: string; - description: string; - content: string; - mcpServers?: SkillMcpServer[]; - nixPackages?: string[]; - permissions?: string[]; - providers?: string[]; -} - -/** - * Registry adapter interface. Each registry type (clawhub, etc.) implements this. - */ -export interface SkillRegistry { - id: string; - search(query: string, limit: number): Promise; - fetch(id: string): Promise; -} - -/** - * Config entry for a skill registry - */ -export interface RegistryConfig { - id: string; - type: string; - apiUrl: string; -} - -interface RegistriesConfig { - registries: RegistryConfig[]; -} - -/** - * Factory for creating registry instances from config - */ -type RegistryFactory = (config: RegistryConfig) => SkillRegistry; - -/** - * Coordinator that aggregates multiple skill registries. - * - * - Loads config from `config/skill-registries.json` - * - Creates registry instances via factory - * - Searches all registries in parallel - * - Falls back to default ClawHub if no config - */ -export class SkillRegistryCoordinator { - private registries: SkillRegistry[]; - - constructor( - registries?: SkillRegistry[], - registryConfigs?: RegistryConfig[] - ) { - if (registries) { - this.registries = registries; - } else if (registryConfigs?.length) { - this.registries = registryConfigs - .map((entry) => this.createRegistry(entry)) - .filter(Boolean) as SkillRegistry[]; - } else { - this.registries = this.loadFromConfig(); - } - - logger.debug( - `Initialized with ${this.registries.length} registry(ies): ${this.registries.map((r) => r.id).join(", ")}` - ); - } - - private loadFromConfig(): SkillRegistry[] { - const configPath = path.resolve( - process.cwd(), - "config/skill-registries.json" - ); - - try { - if (fs.existsSync(configPath)) { - const raw = fs.readFileSync(configPath, "utf-8"); - const config = JSON.parse(raw) as RegistriesConfig; - - if (config.registries?.length) { - return config.registries - .map((entry) => this.createRegistry(entry)) - .filter(Boolean) as SkillRegistry[]; - } - } - } catch (error) { - logger.warn("Failed to load skill-registries.json, using defaults", { - error, - }); - } - - // Fallback: default ClawHub - logger.debug("No config found, using default ClawHub registry"); - return [this.createDefaultClawHub()]; - } - - private createRegistry(config: RegistryConfig): SkillRegistry | null { - const factory = registryFactories[config.type]; - if (!factory) { - logger.warn(`Unknown registry type: ${config.type}, skipping`); - return null; - } - return factory(config); - } - - private createDefaultClawHub(): SkillRegistry { - // Lazy import to avoid circular dependency - const { ClawHubRegistry } = require("./skills-fetcher"); - return new ClawHubRegistry(DEFAULT_CLAWHUB_API_URL); - } - - private buildExtraRegistries(extras?: RegistryConfig[]): SkillRegistry[] { - if (!extras?.length) return []; - return extras - .map((entry) => this.createRegistry(entry)) - .filter(Boolean) as SkillRegistry[]; - } - - /** - * Search all registries in parallel, dedupe by id. - * Optionally includes extra per-agent registries for this call. - */ - async search( - query: string, - limit: number, - extraRegistries?: RegistryConfig[] - ): Promise { - const allRegistries = [ - ...this.registries, - ...this.buildExtraRegistries(extraRegistries), - ]; - const results = await Promise.all( - allRegistries.map((r) => - r.search(query, limit).catch((error) => { - logger.error(`Search failed for registry ${r.id}`, { error }); - return [] as SkillRegistryResult[]; - }) - ) - ); - - // Flatten and dedupe by id - const seen = new Set(); - const merged: SkillRegistryResult[] = []; - for (const batch of results) { - for (const result of batch) { - if (!seen.has(result.id)) { - seen.add(result.id); - merged.push(result); - } - } - } - - merged.sort((a, b) => (b.score ?? 0) - (a.score ?? 0)); - return merged.slice(0, limit); - } - - /** - * Fetch skill content, trying each registry until one succeeds. - * Optionally includes extra per-agent registries for this call. - */ - async fetch( - id: string, - extraRegistries?: RegistryConfig[] - ): Promise { - const allRegistries = [ - ...this.registries, - ...this.buildExtraRegistries(extraRegistries), - ]; - for (const registry of allRegistries) { - try { - return await registry.fetch(id); - } catch { - logger.debug(`Registry ${registry.id} could not fetch skill ${id}`); - } - } - throw new Error(`Skill "${id}" not found in any registry`); - } -} - -/** - * Registry of factory functions keyed by type - */ -const registryFactories: Record = { - clawhub: (config) => { - const { ClawHubRegistry } = require("./skills-fetcher"); - return new ClawHubRegistry(config.apiUrl); - }, - lobu: (config) => { - const { SystemSkillsRegistry } = require("./system-skills-registry"); - return new SystemSkillsRegistry(config.apiUrl); - }, -}; - -/** - * Register a custom registry factory for a given type. - * Call this before creating a coordinator to add support for new registry types. - */ -export function registerRegistryFactory( - type: string, - factory: RegistryFactory -): void { - registryFactories[type] = factory; -} diff --git a/packages/gateway/src/services/skills-fetcher.ts b/packages/gateway/src/services/skills-fetcher.ts deleted file mode 100644 index 22a44d822..000000000 --- a/packages/gateway/src/services/skills-fetcher.ts +++ /dev/null @@ -1,303 +0,0 @@ -import type { SkillMcpServer } from "@lobu/core"; -import { createLogger } from "@lobu/core"; -import yaml from "yaml"; -import type { - SkillContent, - SkillRegistry, - SkillRegistryResult, -} from "./skill-registry"; - -const logger = createLogger("skills-fetcher"); - -const CLAWHUB_WEB_URL = "https://clawhub.ai/skills"; - -/** - * ClawHub list response - */ -interface ClawHubListItem { - slug: string; - displayName: string; - summary?: string | null; - tags?: Record; - stats?: { - downloads?: number; - installsCurrent?: number; - installsAllTime?: number; - stars?: number; - }; - latestVersion?: { version: string } | null; -} - -interface ClawHubListResponse { - items: ClawHubListItem[]; - nextCursor: string | null; -} - -/** - * ClawHub search response - */ -interface ClawHubSearchResult { - score: number; - slug: string; - displayName: string; - summary?: string | null; - version?: string | null; -} - -interface ClawHubSearchResponse { - results: ClawHubSearchResult[]; -} - -/** - * ClawHub skill registry adapter. - * - * Implements the SkillRegistry interface for the ClawHub (OpenClaw) registry. - * Handles search, fetch, and caching of SKILL.md content. - */ -export class ClawHubRegistry implements SkillRegistry { - id: string; - private apiUrl: string; - private contentCache: Map; - private readonly CACHE_TTL_MS = 24 * 60 * 60 * 1000; // 24 hours - - // Cache for list results - private listCache: { - skills: SkillRegistryResult[]; - fetchedAt: number; - } | null = null; - private readonly LIST_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour - - constructor(apiUrl: string, registryId = "clawhub") { - this.apiUrl = apiUrl; - this.id = registryId; - this.contentCache = new Map(); - } - - /** - * Search skills from ClawHub registry. - */ - async search(query: string, limit = 20): Promise { - if (!query.trim()) { - const allSkills = await this.fetchList(); - return allSkills ? allSkills.slice(0, limit) : []; - } - - logger.info(`Searching ClawHub for: ${query}`); - - try { - const url = `${this.apiUrl}/search?q=${encodeURIComponent(query)}&limit=${limit}`; - const response = await fetch(url); - - if (!response.ok) { - throw new Error(`ClawHub search API returned ${response.status}`); - } - - const data = (await response.json()) as ClawHubSearchResponse; - logger.info(`Found ${data.results.length} skills for query: ${query}`); - - return data.results - .filter((r) => r.score >= 3) - .slice(0, limit) - .map((result) => ({ - id: result.slug, - name: result.displayName, - description: result.summary || undefined, - installs: 0, - score: result.score, - uri: `${CLAWHUB_WEB_URL}/${result.slug}`, - source: this.id, - })); - } catch (error) { - logger.error("Failed to search ClawHub", { error, query }); - // Fall back to client-side filtering - const allSkills = await this.fetchList(); - if (!allSkills) return []; - const lowerQuery = query.toLowerCase().trim(); - return allSkills - .filter( - (skill) => - skill.name.toLowerCase().includes(lowerQuery) || - skill.id.toLowerCase().includes(lowerQuery) - ) - .slice(0, limit); - } - } - - /** - * Fetch SKILL.md content from ClawHub and parse frontmatter. - */ - async fetch(slug: string): Promise { - // Check cache - const cached = this.contentCache.get(slug); - if (cached && Date.now() - cached.fetchedAt < this.CACHE_TTL_MS) { - logger.debug(`Returning cached skill: ${slug}`); - return cached.data; - } - - logger.info(`Fetching skill from ClawHub: ${slug}`); - - try { - const url = `${this.apiUrl}/skills/${encodeURIComponent(slug)}/file?path=SKILL.md`; - const response = await fetch(url, { - headers: { Accept: "text/plain" }, - }); - - if (!response.ok) { - throw new Error( - `ClawHub returned ${response.status} for skill ${slug}` - ); - } - - const content = await response.text(); - const skillContent = this.parseSkillContent(content, slug); - - // Cache result - this.contentCache.set(slug, { - data: skillContent, - fetchedAt: Date.now(), - }); - logger.info(`Cached skill: ${slug} (${skillContent.name})`); - - return skillContent; - } catch (error) { - logger.error(`Failed to fetch skill ${slug} from ClawHub`, { error }); - throw new Error( - `Failed to fetch skill ${slug}: ${error instanceof Error ? error.message : "unknown error"}` - ); - } - } - - /** - * Clear cached skill content. - */ - clearCache(slug?: string): void { - if (slug) { - this.contentCache.delete(slug); - logger.debug(`Cleared cache for: ${slug}`); - } else { - this.contentCache.clear(); - logger.debug("Cleared all skill cache"); - } - } - - /** - * Fetch popular skills list from ClawHub API (with caching). - */ - private async fetchList(): Promise { - if ( - this.listCache && - Date.now() - this.listCache.fetchedAt < this.LIST_CACHE_TTL_MS - ) { - logger.debug( - `Returning cached ClawHub data (${this.listCache.skills.length} skills)` - ); - return this.listCache.skills; - } - - logger.info("Fetching skills from ClawHub API..."); - - try { - const response = await fetch( - `${this.apiUrl}/skills?sort=downloads&limit=50` - ); - if (!response.ok) { - throw new Error(`ClawHub API returned ${response.status}`); - } - - const data = (await response.json()) as ClawHubListResponse; - const skills: SkillRegistryResult[] = data.items.map((item) => ({ - id: item.slug, - name: item.displayName, - description: item.summary || undefined, - installs: item.stats?.downloads || 0, - uri: `${CLAWHUB_WEB_URL}/${item.slug}`, - source: this.id, - })); - - logger.info(`Fetched ${skills.length} skills from ClawHub`); - - this.listCache = { skills, fetchedAt: Date.now() }; - return skills; - } catch (error) { - logger.error("Failed to fetch skills from ClawHub", { error }); - return null; - } - } - - /** - * Parse SKILL.md content and extract YAML frontmatter using yaml parser. - */ - private parseSkillContent(content: string, slug: string): SkillContent { - const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/); - - let name = slug; - let description = ""; - let mcpServers: SkillMcpServer[] | undefined; - let nixPackages: string[] | undefined; - let permissions: string[] | undefined; - let providers: string[] | undefined; - - if (frontmatterMatch?.[1]) { - try { - const fm = yaml.parse(frontmatterMatch[1]) as Record; - - if (typeof fm.name === "string") name = fm.name; - if (typeof fm.description === "string") description = fm.description; - - mcpServers = this.parseMcpServers(fm.mcpServers); - nixPackages = this.parseStringList(fm.nixPackages); - permissions = this.parseStringList(fm.permissions); - providers = this.parseStringList(fm.providers); - } catch (error) { - logger.warn(`Failed to parse YAML frontmatter for ${slug}`, { error }); - } - } - - return { - name, - description, - content, - mcpServers, - nixPackages, - permissions, - providers, - }; - } - - /** - * Parse mcpServers field from frontmatter. - */ - private parseMcpServers(value: unknown): SkillMcpServer[] | undefined { - if (!Array.isArray(value) || value.length === 0) return undefined; - return value - .map((entry) => { - if ( - typeof entry !== "object" || - entry === null || - typeof entry.id !== "string" - ) - return null; - const server: SkillMcpServer = { id: entry.id }; - if (typeof entry.name === "string") server.name = entry.name; - if (typeof entry.url === "string") server.url = entry.url; - if (entry.type === "sse" || entry.type === "stdio") - server.type = entry.type; - if (typeof entry.command === "string") server.command = entry.command; - if (Array.isArray(entry.args)) - server.args = entry.args.filter( - (a: unknown) => typeof a === "string" - ); - return server; - }) - .filter((v): v is SkillMcpServer => v !== null); - } - - /** - * Parse a YAML list of strings. - */ - private parseStringList(value: unknown): string[] | undefined { - if (!Array.isArray(value) || value.length === 0) return undefined; - const items = value.filter((v): v is string => typeof v === "string"); - return items.length > 0 ? items : undefined; - } -} diff --git a/packages/gateway/src/services/system-skills-registry.ts b/packages/gateway/src/services/system-skills-registry.ts deleted file mode 100644 index e868543fd..000000000 --- a/packages/gateway/src/services/system-skills-registry.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { createLogger } from "@lobu/core"; -import type { - SkillContent, - SkillRegistry, - SkillRegistryResult, -} from "./skill-registry"; -import { SystemSkillsService } from "./system-skills-service"; - -const logger = createLogger("system-skills-registry"); - -/** System skills rank above remote results (ClawHub scores are typically 0–5) */ -const SYSTEM_SKILL_SCORE = 10; - -/** - * Registry adapter that exposes local system skills (config/system-skills.json) - * through the SkillRegistry interface, making them discoverable via SearchSkills. - */ -export class SystemSkillsRegistry implements SkillRegistry { - id = "lobu"; - private service: SystemSkillsService; - - constructor(configUrl: string) { - this.service = new SystemSkillsService(configUrl); - } - - async search(query: string, limit: number): Promise { - const skills = await this.service.getSearchableSkills(); - const q = query.toLowerCase().trim(); - - const filtered = q - ? skills.filter( - (s) => - s.name.toLowerCase().includes(q) || - s.repo.toLowerCase().includes(q) || - (s.description?.toLowerCase().includes(q) ?? false) - ) - : skills; - - return filtered.slice(0, limit).map((s) => ({ - id: s.repo, - name: s.name, - description: s.description, - score: SYSTEM_SKILL_SCORE, - source: "system", - })); - } - - async fetch(id: string): Promise { - const skills = await this.service.getSystemSkills(); - const skill = skills.find( - (s) => s.repo === id || s.repo === `system/${id}` - ); - if (!skill) { - throw new Error(`System skill "${id}" not found`); - } - - // Get runtime content (formatted markdown) - const runtimeSkills = await this.service.getRuntimeSystemSkills(); - const runtime = runtimeSkills.find((s) => s.repo === skill.repo); - - logger.debug(`Fetched system skill: ${skill.repo}`); - - return { - name: skill.name, - description: skill.description || "", - content: runtime?.content || "", - mcpServers: skill.mcpServers, - nixPackages: skill.nixPackages, - permissions: skill.permissions, - }; - } -} diff --git a/packages/gateway/src/services/system-skills-service.ts b/packages/gateway/src/services/system-skills-service.ts index 6e7ab2584..3666ddb88 100644 --- a/packages/gateway/src/services/system-skills-service.ts +++ b/packages/gateway/src/services/system-skills-service.ts @@ -59,7 +59,7 @@ export class SystemSkillsService { } /** - * Returns only skills that are discoverable via SearchSkills. + * Returns only skills that are discoverable via search. * Filters out entries with `hidden: true` (e.g. Owletto which is embedded). */ async getSearchableSkills(): Promise { diff --git a/packages/gateway/src/session.ts b/packages/gateway/src/session.ts index 265c6675d..fd3a2e67c 100644 --- a/packages/gateway/src/session.ts +++ b/packages/gateway/src/session.ts @@ -1,9 +1,4 @@ -import type { - AgentMcpConfig, - NetworkConfig, - NixConfig, - SessionContext, -} from "@lobu/core"; +import type { AgentMcpConfig, NetworkConfig, NixConfig } from "@lobu/core"; /** * Platform-agnostic session types and utilities @@ -40,6 +35,10 @@ export interface ThreadSession { mcpConfig?: AgentMcpConfig; /** Nix environment configuration for agent workspace */ nixConfig?: NixConfig; + /** Original agent ID (before composite session key generation) */ + agentId?: string; + /** Process without persisting history */ + dryRun?: boolean; } /** @@ -51,9 +50,10 @@ export function computeSessionKey(session: { channelId: string; conversationId: string; }): string { - // For API platform, channelId starts with "api-" and we just use conversationId + // For API platform, channelId starts with "api-" or "api_" and we just use conversationId if ( session.channelId.startsWith("api-") || + session.channelId.startsWith("api_") || session.channelId === session.conversationId ) { return session.conversationId; @@ -108,25 +108,3 @@ export interface ISessionManager { touchSession(sessionKey: string): Promise; cleanupExpired(ttl: number): Promise; } - -// ============================================================================ -// Utilities -// ============================================================================ - -/** - * Generate session key from context - */ -export function generateSessionKey(context: SessionContext): string { - // Use conversation ID as the session key (if in a conversation) - // Otherwise use message ID - const id = context.conversationId || context.messageId || ""; - - // If we have a conversation ID, use it directly as the session key - // This ensures consistency across all worker executions in the same conversation - if (context.conversationId) { - return context.conversationId; - } - - // For direct messages (no conversation), use the channel and message ID - return `${context.channelId}-${id}`; -} diff --git a/packages/gateway/src/stores/in-memory-agent-store.ts b/packages/gateway/src/stores/in-memory-agent-store.ts new file mode 100644 index 000000000..653a04091 --- /dev/null +++ b/packages/gateway/src/stores/in-memory-agent-store.ts @@ -0,0 +1,403 @@ +/** + * InMemoryAgentStore — default AgentStore backed by in-memory Maps. + * + * Populated from files (dev mode) or via API (embedded mode). + */ + +import type { + AgentMetadata, + AgentSettings, + AgentStore, + ChannelBinding, + Grant, + StoredConnection, +} from "@lobu/core"; + +export class InMemoryAgentStore implements AgentStore { + private settings = new Map(); + private metadata = new Map(); + private connections = new Map(); + private connectionsAll = new Set(); + private connectionsByAgent = new Map>(); + private channelBindings = new Map(); + private channelBindingIndex = new Map>(); + private grants = new Map< + string, + { expiresAt: number | null; grantedAt: number; denied?: boolean } + >(); + private userAgents = new Map>(); + private sandboxes = new Map>(); + + // ── Agent Settings ────────────────────────────────────────────────── + + async getSettings(agentId: string): Promise { + return this.settings.get(agentId) ?? null; + } + + async saveSettings(agentId: string, settings: AgentSettings): Promise { + this.settings.set(agentId, { ...settings, updatedAt: Date.now() }); + } + + async updateSettings( + agentId: string, + updates: Partial + ): Promise { + const existing = this.settings.get(agentId); + this.settings.set(agentId, { + ...(existing || {}), + ...updates, + updatedAt: Date.now(), + } as AgentSettings); + } + + async deleteSettings(agentId: string): Promise { + this.settings.delete(agentId); + } + + async hasSettings(agentId: string): Promise { + return this.settings.has(agentId); + } + + // ── Agent Metadata ──────────────────────────────────────────────── + + async getMetadata(agentId: string): Promise { + return this.metadata.get(agentId) ?? null; + } + + async saveMetadata(agentId: string, metadata: AgentMetadata): Promise { + this.metadata.set(agentId, metadata); + if (metadata.parentConnectionId) { + let set = this.sandboxes.get(metadata.parentConnectionId); + if (!set) { + set = new Set(); + this.sandboxes.set(metadata.parentConnectionId, set); + } + set.add(agentId); + } + } + + async updateMetadata( + agentId: string, + updates: Partial + ): Promise { + const existing = this.metadata.get(agentId); + if (!existing) return; + await this.saveMetadata(agentId, { ...existing, ...updates }); + } + + async deleteMetadata(agentId: string): Promise { + const existing = this.metadata.get(agentId); + this.metadata.delete(agentId); + if (existing?.parentConnectionId) { + const set = this.sandboxes.get(existing.parentConnectionId); + if (set) { + set.delete(agentId); + if (set.size === 0) this.sandboxes.delete(existing.parentConnectionId); + } + } + } + + async hasAgent(agentId: string): Promise { + return this.metadata.has(agentId); + } + + async listAgents(): Promise { + return Array.from(this.metadata.values()); + } + + async listSandboxes(connectionId: string): Promise { + const ids = this.sandboxes.get(connectionId); + if (!ids) return []; + const results: AgentMetadata[] = []; + for (const id of ids) { + const m = this.metadata.get(id); + if (m) results.push(m); + } + return results; + } + + // ── Connections ────────────────────────────────────────────────── + + async getConnection(connectionId: string): Promise { + return this.connections.get(connectionId) ?? null; + } + + async listConnections(filter?: { + templateAgentId?: string; + platform?: string; + }): Promise { + let ids: Iterable; + if (filter?.templateAgentId) { + ids = this.connectionsByAgent.get(filter.templateAgentId) ?? []; + } else { + ids = this.connectionsAll; + } + + let connections: StoredConnection[] = []; + for (const id of ids) { + const conn = this.connections.get(id); + if (conn) connections.push(conn); + } + + if (filter?.platform) { + connections = connections.filter((c) => c.platform === filter.platform); + } + return connections; + } + + async saveConnection(connection: StoredConnection): Promise { + this.connections.set(connection.id, connection); + this.connectionsAll.add(connection.id); + if (connection.templateAgentId) { + let set = this.connectionsByAgent.get(connection.templateAgentId); + if (!set) { + set = new Set(); + this.connectionsByAgent.set(connection.templateAgentId, set); + } + set.add(connection.id); + } + } + + async updateConnection( + connectionId: string, + updates: Partial + ): Promise { + const existing = this.connections.get(connectionId); + if (!existing) return; + await this.saveConnection({ + ...existing, + ...updates, + id: connectionId, + updatedAt: Date.now(), + }); + } + + async deleteConnection(connectionId: string): Promise { + const conn = this.connections.get(connectionId); + this.connections.delete(connectionId); + this.connectionsAll.delete(connectionId); + if (conn?.templateAgentId) { + const set = this.connectionsByAgent.get(conn.templateAgentId); + if (set) { + set.delete(connectionId); + if (set.size === 0) + this.connectionsByAgent.delete(conn.templateAgentId); + } + } + } + + // ── Grants ────────────────────────────────────────────────────── + + private grantKey(agentId: string, pattern: string): string { + return `${agentId}:${pattern}`; + } + + private getValidGrant( + agentId: string, + pattern: string + ): { expiresAt: number | null; grantedAt: number; denied?: boolean } | null { + const key = this.grantKey(agentId, pattern); + const entry = this.grants.get(key); + if (!entry) return null; + if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) { + this.grants.delete(key); + return null; + } + return entry; + } + + async grant( + agentId: string, + pattern: string, + expiresAt: number | null, + denied?: boolean + ): Promise { + this.grants.set(this.grantKey(agentId, pattern), { + expiresAt, + grantedAt: Date.now(), + ...(denied && { denied: true }), + }); + } + + async hasGrant(agentId: string, pattern: string): Promise { + // Exact match + const exact = this.getValidGrant(agentId, pattern); + if (exact) return !exact.denied; + + // MCP wildcard: /mcp/gmail/tools/send_email -> /mcp/gmail/tools/* + if (pattern.startsWith("/mcp/")) { + const lastSlash = pattern.lastIndexOf("/"); + if (lastSlash > 0) { + const wildcard = `${pattern.substring(0, lastSlash)}/*`; + const entry = this.getValidGrant(agentId, wildcard); + if (entry) return !entry.denied; + } + } + + // Domain wildcard: sub.example.com -> *.example.com + if (!pattern.startsWith("/")) { + const parts = pattern.split("."); + if (parts.length > 2) { + const wildcard = `*.${parts.slice(1).join(".")}`; + const entry = this.getValidGrant(agentId, wildcard); + if (entry) return !entry.denied; + } + } + + return false; + } + + async isDenied(agentId: string, pattern: string): Promise { + const entry = this.getValidGrant(agentId, pattern); + if (!entry) return false; + return entry.denied === true; + } + + async listGrants(agentId: string): Promise { + const prefix = `${agentId}:`; + const grants: Grant[] = []; + for (const [key, entry] of this.grants) { + if (!key.startsWith(prefix)) continue; + if (entry.expiresAt !== null && entry.expiresAt <= Date.now()) { + this.grants.delete(key); + continue; + } + grants.push({ + pattern: key.substring(prefix.length), + expiresAt: entry.expiresAt, + grantedAt: entry.grantedAt, + ...(entry.denied && { denied: true }), + }); + } + return grants; + } + + async revokeGrant(agentId: string, pattern: string): Promise { + this.grants.delete(this.grantKey(agentId, pattern)); + } + + // ── User-Agent Associations ───────────────────────────────────── + + private userKey(platform: string, userId: string): string { + return `${platform}:${userId}`; + } + + async addUserAgent( + platform: string, + userId: string, + agentId: string + ): Promise { + const key = this.userKey(platform, userId); + let set = this.userAgents.get(key); + if (!set) { + set = new Set(); + this.userAgents.set(key, set); + } + set.add(agentId); + } + + async removeUserAgent( + platform: string, + userId: string, + agentId: string + ): Promise { + const key = this.userKey(platform, userId); + const set = this.userAgents.get(key); + if (set) { + set.delete(agentId); + if (set.size === 0) this.userAgents.delete(key); + } + } + + async listUserAgents(platform: string, userId: string): Promise { + const set = this.userAgents.get(this.userKey(platform, userId)); + return set ? Array.from(set) : []; + } + + async ownsAgent( + platform: string, + userId: string, + agentId: string + ): Promise { + const set = this.userAgents.get(this.userKey(platform, userId)); + return set ? set.has(agentId) : false; + } + + // ── Channel Bindings ──────────────────────────────────────────── + + private channelBindingKey( + platform: string, + channelId: string, + teamId?: string + ): string { + return teamId + ? `${platform}:${channelId}:${teamId}` + : `${platform}:${channelId}`; + } + + async getChannelBinding( + platform: string, + channelId: string, + teamId?: string + ): Promise { + return ( + this.channelBindings.get( + this.channelBindingKey(platform, channelId, teamId) + ) ?? null + ); + } + + async createChannelBinding(binding: ChannelBinding): Promise { + const key = this.channelBindingKey( + binding.platform, + binding.channelId, + binding.teamId + ); + this.channelBindings.set(key, binding); + let set = this.channelBindingIndex.get(binding.agentId); + if (!set) { + set = new Set(); + this.channelBindingIndex.set(binding.agentId, set); + } + set.add(key); + } + + async deleteChannelBinding( + platform: string, + channelId: string, + teamId?: string + ): Promise { + const key = this.channelBindingKey(platform, channelId, teamId); + const binding = this.channelBindings.get(key); + if (binding) { + const set = this.channelBindingIndex.get(binding.agentId); + if (set) { + set.delete(key); + if (set.size === 0) this.channelBindingIndex.delete(binding.agentId); + } + } + this.channelBindings.delete(key); + } + + async listChannelBindings(agentId: string): Promise { + const keys = this.channelBindingIndex.get(agentId); + if (!keys) return []; + const bindings: ChannelBinding[] = []; + for (const key of keys) { + const binding = this.channelBindings.get(key); + if (binding) bindings.push(binding); + } + return bindings; + } + + async deleteAllChannelBindings(agentId: string): Promise { + const keys = this.channelBindingIndex.get(agentId); + if (!keys || keys.size === 0) return 0; + const count = keys.size; + for (const key of keys) { + this.channelBindings.delete(key); + } + this.channelBindingIndex.delete(agentId); + return count; + } +} diff --git a/packages/gateway/src/stores/redis-agent-store.ts b/packages/gateway/src/stores/redis-agent-store.ts deleted file mode 100644 index 9f898aaaf..000000000 --- a/packages/gateway/src/stores/redis-agent-store.ts +++ /dev/null @@ -1,435 +0,0 @@ -/** - * RedisAgentStore — implements AgentStore backed by Redis. - * - * Composes the existing Redis-backed stores behind the unified AgentStore interface. - * Used in CLI/standalone mode. In embedded mode, the host provides its own implementation. - */ - -import type { - AgentMetadata, - AgentSettings, - AgentStore, - ChannelBinding, - Grant, - StoredConnection, -} from "@lobu/core"; -import { createLogger } from "@lobu/core"; -import type Redis from "ioredis"; - -const logger = createLogger("redis-agent-store"); - -// ── Redis key helpers ────────────────────────────────────────────────────── - -const KEYS = { - settings: (id: string) => `agent:settings:${id}`, - metadata: (id: string) => `agent_metadata:${id}`, - connection: (id: string) => `connection:${id}`, - connectionsAll: "connections:all", - connectionsByAgent: (agentId: string) => `connections:agent:${agentId}`, - grant: (agentId: string, pattern: string) => `grant:${agentId}:${pattern}`, - userAgents: (platform: string, userId: string) => - `user_agents:${platform}:${userId}`, - channelBinding: (platform: string, channelId: string, teamId?: string) => - teamId - ? `channel_binding:${platform}:${channelId}:${teamId}` - : `channel_binding:${platform}:${channelId}`, - channelBindingIndex: (agentId: string) => `channel_binding_index:${agentId}`, - sandboxes: (connectionId: string) => `sandboxes:connection:${connectionId}`, -}; - -export class RedisAgentStore implements AgentStore { - constructor(private readonly redis: Redis) {} - - // ── Agent Settings ────────────────────────────────────────────────── - - async getSettings(agentId: string): Promise { - const raw = await this.redis.get(KEYS.settings(agentId)); - return raw ? JSON.parse(raw) : null; - } - - async saveSettings(agentId: string, settings: AgentSettings): Promise { - await this.redis.set( - KEYS.settings(agentId), - JSON.stringify({ ...settings, updatedAt: Date.now() }) - ); - } - - async updateSettings( - agentId: string, - updates: Partial - ): Promise { - const existing = await this.getSettings(agentId); - const merged = { ...(existing || {}), ...updates, updatedAt: Date.now() }; - await this.redis.set(KEYS.settings(agentId), JSON.stringify(merged)); - } - - async deleteSettings(agentId: string): Promise { - await this.redis.del(KEYS.settings(agentId)); - } - - async hasSettings(agentId: string): Promise { - return (await this.redis.exists(KEYS.settings(agentId))) === 1; - } - - // ── Agent Metadata ──────────────────────────────────────────────── - - async getMetadata(agentId: string): Promise { - const raw = await this.redis.get(KEYS.metadata(agentId)); - return raw ? JSON.parse(raw) : null; - } - - async saveMetadata(agentId: string, metadata: AgentMetadata): Promise { - await this.redis.set(KEYS.metadata(agentId), JSON.stringify(metadata)); - if (metadata.parentConnectionId) { - await this.redis.sadd( - KEYS.sandboxes(metadata.parentConnectionId), - agentId - ); - } - } - - async updateMetadata( - agentId: string, - updates: Partial - ): Promise { - const existing = await this.getMetadata(agentId); - if (!existing) return; - await this.saveMetadata(agentId, { ...existing, ...updates }); - } - - async deleteMetadata(agentId: string): Promise { - const metadata = await this.getMetadata(agentId); - await this.redis.del(KEYS.metadata(agentId)); - if (metadata?.parentConnectionId) { - await this.redis.srem( - KEYS.sandboxes(metadata.parentConnectionId), - agentId - ); - } - } - - async hasAgent(agentId: string): Promise { - return (await this.redis.exists(KEYS.metadata(agentId))) === 1; - } - - async listAgents(): Promise { - const keys = await this.scanKeys("agent_metadata:*"); - if (keys.length === 0) return []; - const values = await this.redis.mget(...keys); - return values - .filter((v): v is string => v !== null) - .map((v) => { - try { - return JSON.parse(v) as AgentMetadata; - } catch (error) { - logger.error("Failed to parse agent metadata", { error, value: v }); - return null; - } - }) - .filter((v): v is AgentMetadata => v !== null); - } - - async listSandboxes(connectionId: string): Promise { - const ids = await this.redis.smembers(KEYS.sandboxes(connectionId)); - const results: AgentMetadata[] = []; - for (const id of ids) { - const m = await this.getMetadata(id); - if (m) results.push(m); - } - return results; - } - - // ── Connections ────────────────────────────────────────────────── - - async getConnection(connectionId: string): Promise { - const raw = await this.redis.get(KEYS.connection(connectionId)); - return raw ? JSON.parse(raw) : null; - } - - async listConnections(filter?: { - templateAgentId?: string; - platform?: string; - }): Promise { - let ids: string[]; - if (filter?.templateAgentId) { - ids = await this.redis.smembers( - KEYS.connectionsByAgent(filter.templateAgentId) - ); - } else { - ids = await this.redis.smembers(KEYS.connectionsAll); - } - - if (ids.length === 0) return []; - - const keys = ids.map(KEYS.connection); - const values = await this.redis.mget(...keys); - let connections = values - .filter((v): v is string => v !== null) - .map((v) => { - try { - return JSON.parse(v) as StoredConnection; - } catch (error) { - logger.error("Failed to parse connection", { error, value: v }); - return null; - } - }) - .filter((v): v is StoredConnection => v !== null); - - if (filter?.platform) { - connections = connections.filter((c) => c.platform === filter.platform); - } - return connections; - } - - async saveConnection(connection: StoredConnection): Promise { - await this.redis.set( - KEYS.connection(connection.id), - JSON.stringify(connection) - ); - await this.redis.sadd(KEYS.connectionsAll, connection.id); - if (connection.templateAgentId) { - await this.redis.sadd( - KEYS.connectionsByAgent(connection.templateAgentId), - connection.id - ); - } - } - - async updateConnection( - connectionId: string, - updates: Partial - ): Promise { - const existing = await this.getConnection(connectionId); - if (!existing) return; - await this.saveConnection({ - ...existing, - ...updates, - id: connectionId, - updatedAt: Date.now(), - }); - } - - async deleteConnection(connectionId: string): Promise { - const conn = await this.getConnection(connectionId); - await this.redis.del(KEYS.connection(connectionId)); - await this.redis.srem(KEYS.connectionsAll, connectionId); - if (conn?.templateAgentId) { - await this.redis.srem( - KEYS.connectionsByAgent(conn.templateAgentId), - connectionId - ); - } - } - - // ── Grants ────────────────────────────────────────────────────── - - async grant( - agentId: string, - pattern: string, - expiresAt: number | null, - denied?: boolean - ): Promise { - const key = KEYS.grant(agentId, pattern); - const value = JSON.stringify({ - expiresAt, - grantedAt: Date.now(), - ...(denied && { denied: true }), - }); - if (expiresAt === null) { - await this.redis.set(key, value); - } else { - const ttl = Math.max(1, Math.ceil((expiresAt - Date.now()) / 1000)); - await this.redis.set(key, value, "EX", ttl); - } - } - - async hasGrant(agentId: string, pattern: string): Promise { - // Exact match - const exact = await this.redis.get(KEYS.grant(agentId, pattern)); - if (exact) { - const parsed = JSON.parse(exact); - return !parsed.denied; - } - - // MCP wildcard: /mcp/gmail/tools/send_email → /mcp/gmail/tools/* - if (pattern.startsWith("/mcp/")) { - const lastSlash = pattern.lastIndexOf("/"); - if (lastSlash > 0) { - const wildcard = `${pattern.substring(0, lastSlash)}/*`; - const raw = await this.redis.get(KEYS.grant(agentId, wildcard)); - if (raw) { - const parsed = JSON.parse(raw); - return !parsed.denied; - } - } - } - - // Domain wildcard: sub.example.com → *.example.com - if (!pattern.startsWith("/")) { - const parts = pattern.split("."); - if (parts.length > 2) { - const wildcard = `*.${parts.slice(1).join(".")}`; - const raw = await this.redis.get(KEYS.grant(agentId, wildcard)); - if (raw) { - const parsed = JSON.parse(raw); - return !parsed.denied; - } - } - } - - return false; - } - - async isDenied(agentId: string, pattern: string): Promise { - const raw = await this.redis.get(KEYS.grant(agentId, pattern)); - if (!raw) return false; - return JSON.parse(raw).denied === true; - } - - async listGrants(agentId: string): Promise { - const prefix = `grant:${agentId}:`; - const keys = await this.scanKeys(`${prefix}*`); - if (keys.length === 0) return []; - const values = await this.redis.mget(...keys); - const grants: Grant[] = []; - for (let i = 0; i < keys.length; i++) { - const val = values[i]; - if (!val) continue; - try { - const parsed = JSON.parse(val); - grants.push({ - pattern: (keys[i] as string).substring(prefix.length), - expiresAt: parsed.expiresAt ?? null, - grantedAt: parsed.grantedAt, - ...(parsed.denied && { denied: true }), - }); - } catch { - // skip malformed - } - } - return grants; - } - - async revokeGrant(agentId: string, pattern: string): Promise { - await this.redis.del(KEYS.grant(agentId, pattern)); - } - - // ── User-Agent Associations ───────────────────────────────────── - - async addUserAgent( - platform: string, - userId: string, - agentId: string - ): Promise { - await this.redis.sadd(KEYS.userAgents(platform, userId), agentId); - } - - async removeUserAgent( - platform: string, - userId: string, - agentId: string - ): Promise { - await this.redis.srem(KEYS.userAgents(platform, userId), agentId); - } - - async listUserAgents(platform: string, userId: string): Promise { - return this.redis.smembers(KEYS.userAgents(platform, userId)); - } - - async ownsAgent( - platform: string, - userId: string, - agentId: string - ): Promise { - return ( - (await this.redis.sismember( - KEYS.userAgents(platform, userId), - agentId - )) === 1 - ); - } - - // ── Channel Bindings ──────────────────────────────────────────── - - async getChannelBinding( - platform: string, - channelId: string, - teamId?: string - ): Promise { - const raw = await this.redis.get( - KEYS.channelBinding(platform, channelId, teamId) - ); - return raw ? JSON.parse(raw) : null; - } - - async createChannelBinding(binding: ChannelBinding): Promise { - const key = KEYS.channelBinding( - binding.platform, - binding.channelId, - binding.teamId - ); - await this.redis.set(key, JSON.stringify(binding)); - await this.redis.sadd(KEYS.channelBindingIndex(binding.agentId), key); - } - - async deleteChannelBinding( - platform: string, - channelId: string, - teamId?: string - ): Promise { - const key = KEYS.channelBinding(platform, channelId, teamId); - const raw = await this.redis.get(key); - if (raw) { - const binding = JSON.parse(raw) as ChannelBinding; - await this.redis.srem(KEYS.channelBindingIndex(binding.agentId), key); - } - await this.redis.del(key); - } - - async listChannelBindings(agentId: string): Promise { - const keys = await this.redis.smembers(KEYS.channelBindingIndex(agentId)); - if (keys.length === 0) return []; - const values = await this.redis.mget(...keys); - return values - .filter((v): v is string => v !== null) - .map((v) => { - try { - return JSON.parse(v) as ChannelBinding; - } catch (error) { - logger.error("Failed to parse channel binding", { - error, - value: v, - }); - return null; - } - }) - .filter((v): v is ChannelBinding => v !== null); - } - - async deleteAllChannelBindings(agentId: string): Promise { - const keys = await this.redis.smembers(KEYS.channelBindingIndex(agentId)); - if (keys.length === 0) return 0; - await this.redis.del(...keys); - await this.redis.del(KEYS.channelBindingIndex(agentId)); - return keys.length; - } - - // ── Helpers ────────────────────────────────────────────────────── - - private async scanKeys(pattern: string): Promise { - const keys: string[] = []; - let cursor = "0"; - do { - const [next, batch] = await this.redis.scan( - cursor, - "MATCH", - pattern, - "COUNT", - 100 - ); - cursor = next; - keys.push(...batch); - } while (cursor !== "0"); - return keys; - } -} diff --git a/packages/gateway/src/utils/public-url.ts b/packages/gateway/src/utils/public-url.ts index edaec51ee..b5d21119a 100644 --- a/packages/gateway/src/utils/public-url.ts +++ b/packages/gateway/src/utils/public-url.ts @@ -2,7 +2,7 @@ function normalizeBaseUrl(url: string): string { return url.replace(/\/+$/, ""); } -export function resolvePublicBaseUrl(options?: { +function resolvePublicBaseUrl(options?: { configuredUrl?: string; requestUrl?: string; forwardedProto?: string; diff --git a/packages/gateway/tailwind.config.js b/packages/gateway/tailwind.config.js deleted file mode 100644 index 585a05082..000000000 --- a/packages/gateway/tailwind.config.js +++ /dev/null @@ -1,11 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -module.exports = { - content: [ - "./src/routes/public/agent-page/**/*.{ts,tsx}", - "./src/routes/public/history-page/**/*.{ts,tsx}", - ], - theme: { - extend: {}, - }, - plugins: [require("@tailwindcss/typography")], -}; diff --git a/packages/landing/astro.config.mjs b/packages/landing/astro.config.mjs index 2bf42b904..69ba9e187 100644 --- a/packages/landing/astro.config.mjs +++ b/packages/landing/astro.config.mjs @@ -68,6 +68,9 @@ export default defineConfig({ label: "Platforms", items: [ { label: "Slack", link: "/platforms/slack/" }, + { label: "Discord", link: "/platforms/discord/" }, + { label: "Microsoft Teams", link: "/platforms/teams/" }, + { label: "Google Chat", link: "/platforms/google-chat/" }, { label: "WhatsApp", link: "/platforms/whatsapp/" }, { label: "Telegram", link: "/platforms/telegram/" }, { label: "REST API", link: "/platforms/rest-api/" }, diff --git a/packages/landing/package.json b/packages/landing/package.json index 721b61e2c..1eb61b75c 100644 --- a/packages/landing/package.json +++ b/packages/landing/package.json @@ -13,7 +13,6 @@ "@astrojs/sitemap": "^3.7.0", "@astrojs/starlight": "^0.37.6", "@preact/signals": "^1.3.1", - "@scalar/astro": "^0.2.0", "astro": "^5.18.0", "preact": "^10.25.4", "zod": "^3.25.76" diff --git a/packages/landing/src/components/CTA.tsx b/packages/landing/src/components/CTA.tsx index e26182257..14c80748f 100644 --- a/packages/landing/src/components/CTA.tsx +++ b/packages/landing/src/components/CTA.tsx @@ -14,7 +14,7 @@ export function CTA() { class="text-sm mb-8" style={{ color: "var(--color-page-text-muted)" }} > - Get started locally in seconds. + Get started locally, then self-host or embed with TypeScript.

+ ); } -const PANEL_MAP: Record = { - connections: ConnectionsPanel, - setup: ModelsPanel, - packages: PackagesPanel, - skills: IntegrationsPanel, - schedules: RemindersPanel, - network: PermissionsPanel, - memory: MemoryPanel, -}; +function TermLinkPill({ link }: { link: TermLink }) { + return ( + + {link.label} + + ); +} -function TimelineDot() { +function TerminalWindow({ + label, + lines, +}: { + label: string; + lines: TermLine[]; +}) { return (
+ > + +
+ {lines.map((line, index) => + line.text === "" ? ( +
+ ) : ( +
+ {line.text} + {line.links?.map((link) => ( + + ))} +
+ ) + )} +
+
); } -function FeatureRow({ - uc, - isFirst, - isLast, -}: { - uc: (typeof useCases)[0]; - isFirst: boolean; - isLast: boolean; -}) { - const Panel = PANEL_MAP[uc.id]; - const [panelHighlight, setPanelHighlight] = useState(false); - +function PromptWindow({ label, prompt }: { label: string; prompt: string }) { return ( - <> - {/* Left cell */} -
-
- {uc.tabLabel} -
-

- {uc.title} -

-

- {renderDescriptionWithIcons(uc.description)} -

- {uc.learnMoreUrl && ( - - Learn more → - - )} - {!uc.learnMoreUrl &&
} -

+

+ - {uc.settingsLabel} -

-
+
+ Copy +
+
+        {prompt}
+      
+
+ ); +} - {/* Center cell — timeline */} -