@geminixiang/mama 0.2.0-beta.1 → 0.2.0-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +85 -58
- package/dist/adapter.d.ts +8 -6
- package/dist/adapter.d.ts.map +1 -1
- package/dist/adapter.js.map +1 -1
- package/dist/adapters/discord/bot.d.ts +2 -2
- package/dist/adapters/discord/bot.d.ts.map +1 -1
- package/dist/adapters/discord/bot.js +20 -29
- package/dist/adapters/discord/bot.js.map +1 -1
- package/dist/adapters/discord/context.d.ts.map +1 -1
- package/dist/adapters/discord/context.js +16 -20
- package/dist/adapters/discord/context.js.map +1 -1
- package/dist/adapters/slack/bot.d.ts +11 -4
- package/dist/adapters/slack/bot.d.ts.map +1 -1
- package/dist/adapters/slack/bot.js +199 -73
- package/dist/adapters/slack/bot.js.map +1 -1
- package/dist/adapters/slack/context.d.ts.map +1 -1
- package/dist/adapters/slack/context.js +27 -30
- package/dist/adapters/slack/context.js.map +1 -1
- package/dist/adapters/telegram/bot.d.ts +4 -2
- package/dist/adapters/telegram/bot.d.ts.map +1 -1
- package/dist/adapters/telegram/bot.js +130 -71
- package/dist/adapters/telegram/bot.js.map +1 -1
- package/dist/adapters/telegram/context.d.ts.map +1 -1
- package/dist/adapters/telegram/context.js +9 -95
- package/dist/adapters/telegram/context.js.map +1 -1
- package/dist/adapters/telegram/html.d.ts +3 -0
- package/dist/adapters/telegram/html.d.ts.map +1 -0
- package/dist/adapters/telegram/html.js +98 -0
- package/dist/adapters/telegram/html.js.map +1 -0
- package/dist/agent.d.ts +3 -11
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +63 -70
- package/dist/agent.js.map +1 -1
- package/dist/bindings.d.ts +1 -20
- package/dist/bindings.d.ts.map +1 -1
- package/dist/bindings.js +1 -21
- package/dist/bindings.js.map +1 -1
- package/dist/config.d.ts +7 -27
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +77 -63
- package/dist/config.js.map +1 -1
- package/dist/context.d.ts +2 -2
- package/dist/context.d.ts.map +1 -1
- package/dist/context.js +2 -2
- package/dist/context.js.map +1 -1
- package/dist/events.d.ts +11 -6
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js +33 -13
- package/dist/events.js.map +1 -1
- package/dist/execution-resolver.d.ts.map +1 -1
- package/dist/execution-resolver.js +1 -3
- package/dist/execution-resolver.js.map +1 -1
- package/dist/instrument.d.ts.map +1 -1
- package/dist/instrument.js +5 -11
- package/dist/instrument.js.map +1 -1
- package/dist/link-server.d.ts +2 -1
- package/dist/link-server.d.ts.map +1 -1
- package/dist/link-server.js +62 -2
- package/dist/link-server.js.map +1 -1
- package/dist/login.d.ts +1 -1
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +1 -1
- package/dist/login.js.map +1 -1
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +96 -112
- package/dist/main.js.map +1 -1
- package/dist/provisioner.d.ts +0 -41
- package/dist/provisioner.d.ts.map +1 -1
- package/dist/provisioner.js +0 -45
- package/dist/provisioner.js.map +1 -1
- package/dist/sandbox/host.d.ts +0 -2
- package/dist/sandbox/host.d.ts.map +1 -1
- package/dist/sandbox/host.js +1 -5
- package/dist/sandbox/host.js.map +1 -1
- package/dist/sentry.d.ts.map +1 -1
- package/dist/sentry.js +2 -0
- package/dist/sentry.js.map +1 -1
- package/dist/session-store.d.ts +1 -1
- package/dist/session-store.d.ts.map +1 -1
- package/dist/session-store.js +5 -9
- package/dist/session-store.js.map +1 -1
- package/dist/tools/event.d.ts +1 -0
- package/dist/tools/event.d.ts.map +1 -1
- package/dist/tools/event.js +6 -5
- package/dist/tools/event.js.map +1 -1
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +2 -2
- package/dist/tools/index.js.map +1 -1
- package/dist/ui-copy.d.ts +1 -0
- package/dist/ui-copy.d.ts.map +1 -1
- package/dist/ui-copy.js +3 -0
- package/dist/ui-copy.js.map +1 -1
- package/dist/vault-routing.d.ts +1 -2
- package/dist/vault-routing.d.ts.map +1 -1
- package/dist/vault-routing.js +1 -7
- package/dist/vault-routing.js.map +1 -1
- package/package.json +1 -1
- package/dist/vault.test.d.ts +0 -2
- package/dist/vault.test.d.ts.map +0 -1
- package/dist/vault.test.js +0 -67
- package/dist/vault.test.js.map +0 -1
package/dist/main.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";AAEA,OAAO,iBAAiB,CAAC","sourcesContent":["#!/usr/bin/env node\n\nimport \"./instrument.js\";\n\nimport { join, resolve } from \"path\";\nimport { homedir } from \"os\";\nimport { mkdirSync, readFileSync, statSync } from \"fs\";\nimport { fileURLToPath } from \"url\";\nimport { dirname, join as pathJoin } from \"path\";\nimport type { Bot, BotAdapters, BotEvent, BotHandler } from \"./adapter.js\";\nimport { DiscordBot } from \"./adapters/discord/index.js\";\nimport { TelegramBot } from \"./adapters/telegram/index.js\";\nimport { SlackBot as SlackBotClass } from \"./adapters/slack/index.js\";\nimport { type AgentRunner, createRunner } from \"./agent.js\";\nimport {\n createManagedSessionFile,\n createManagedSessionFileAtPath,\n getChannelSessionDir,\n getThreadSessionFile,\n} from \"./session-store.js\";\nimport { downloadChannel } from \"./download.js\";\nimport { createEventsWatcher } from \"./events.js\";\nimport * as log from \"./log.js\";\nimport { FileUserBindingStore } from \"./bindings.js\";\nimport { startLinkServer } from \"./link-server.js\";\nimport { parseLoginCommand } from \"./login.js\";\nimport { InMemoryLinkTokenStore } from \"./link-token.js\";\nimport { DockerContainerManager } from \"./provisioner.js\";\nimport { SandboxError, parseSandboxArg, type SandboxConfig, validateSandbox } from \"./sandbox.js\";\nimport { formatNothingRunning, formatStopping } from \"./ui-copy.js\";\nimport { FileVaultManager } from \"./vault.js\";\nimport { ensureSettingsFile } from \"./config.js\";\nimport {\n createManagedVaultEntry,\n ensureSandboxVaultEntry,\n resolveActorVaultKey,\n} from \"./vault-routing.js\";\nimport { addLifecycleBreadcrumb, applyRunScope } from \"./sentry.js\";\nimport { ChannelStore } from \"./store.js\";\nimport * as Sentry from \"@sentry/node\";\n\n// ============================================================================\n// Config\n// ============================================================================\n\n// Get version from package.json\nfunction getVersion(): string {\n // Try to find package.json in the dist directory or parent\n const possiblePaths = [\n pathJoin(dirname(fileURLToPath(import.meta.url)), \"package.json\"),\n pathJoin(dirname(fileURLToPath(import.meta.url)), \"..\", \"package.json\"),\n pathJoin(process.cwd(), \"package.json\"),\n ];\n\n for (const pkgPath of possiblePaths) {\n try {\n const pkg = JSON.parse(readFileSync(pkgPath, \"utf-8\"));\n if (pkg.version) return pkg.version;\n } catch {\n // Continue to next path\n }\n }\n return \"unknown\";\n}\n\nconst MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;\nconst MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;\nconst MOM_TELEGRAM_BOT_TOKEN = process.env.MOM_TELEGRAM_BOT_TOKEN;\nconst MOM_DISCORD_BOT_TOKEN = process.env.MOM_DISCORD_BOT_TOKEN;\n/** Base URL of the web login portal, e.g. https://platform.trygemini.xyz */\nconst MOM_LINK_URL = process.env.MOM_LINK_URL;\n/**\n * Port for the link callback HTTP server.\n * Defaults to 8181 when MOM_LINK_URL is set (behind a reverse proxy).\n * If neither is set, the server is not started.\n */\nconst MOM_LINK_PORT = process.env.MOM_LINK_PORT\n ? parseInt(process.env.MOM_LINK_PORT, 10)\n : MOM_LINK_URL\n ? 8181\n : undefined;\n\ninterface ParsedArgs {\n workingDir?: string;\n stateDir?: string;\n sandbox: SandboxConfig;\n downloadChannel?: string;\n showVersion?: boolean;\n}\n\nfunction parseArgs(): ParsedArgs {\n const args = process.argv.slice(2);\n let sandbox: SandboxConfig = { type: \"host\" };\n let workingDir: string | undefined;\n let stateDirArg: string | undefined;\n let downloadChannelId: string | undefined;\n let showVersion = false;\n\n for (let i = 0; i < args.length; i++) {\n const arg = args[i];\n if (arg === \"--version\" || arg === \"-v\" || arg === \"-V\") {\n showVersion = true;\n } else if (arg.startsWith(\"--sandbox=\")) {\n sandbox = parseSandboxArg(arg.slice(\"--sandbox=\".length));\n } else if (arg === \"--sandbox\") {\n sandbox = parseSandboxArg(args[++i] || \"\");\n } else if (arg.startsWith(\"--state-dir=\")) {\n stateDirArg = arg.slice(\"--state-dir=\".length);\n } else if (arg === \"--state-dir\") {\n stateDirArg = args[++i];\n } else if (arg.startsWith(\"--download=\")) {\n downloadChannelId = arg.slice(\"--download=\".length);\n } else if (arg === \"--download\") {\n downloadChannelId = args[++i];\n } else if (!arg.startsWith(\"-\")) {\n workingDir = arg;\n }\n }\n\n return {\n workingDir: workingDir ? resolve(workingDir) : undefined,\n stateDir: stateDirArg ? resolve(stateDirArg) : undefined,\n sandbox,\n downloadChannel: downloadChannelId,\n showVersion,\n };\n}\n\nconst WORLD_WRITABLE_MODE = 0o002;\n\n/**\n * Create stateDir if missing and refuse to use it if another local user could\n * tamper with its contents. stateDir holds vaults, bindings, and settings —\n * a world-writable or foreign-owned directory there would let a local attacker\n * swap in credentials or change routing.\n */\nfunction ensureSecureStateDir(path: string): void {\n let stat;\n try {\n stat = statSync(path);\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code === \"ENOENT\") {\n mkdirSync(path, { recursive: true, mode: 0o700 });\n return;\n }\n console.error(`Error: cannot access --state-dir ${path}: ${(err as Error).message}`);\n process.exit(1);\n }\n\n if (!stat.isDirectory()) {\n console.error(`Error: --state-dir ${path} exists but is not a directory`);\n process.exit(1);\n }\n\n if (stat.mode & WORLD_WRITABLE_MODE) {\n console.error(\n `Error: --state-dir ${path} is world-writable (mode ${(stat.mode & 0o777).toString(8)}). ` +\n `Credentials stored there would be exposed to other local users. ` +\n `Fix with: chmod 0700 ${path}`,\n );\n process.exit(1);\n }\n\n const euid = typeof process.geteuid === \"function\" ? process.geteuid() : undefined;\n if (euid !== undefined && stat.uid !== euid) {\n console.error(\n `Error: --state-dir ${path} is owned by uid ${stat.uid} but mama is running as uid ${euid}. ` +\n `Run mama as the directory owner or point --state-dir at a directory you own.`,\n );\n process.exit(1);\n }\n}\n\nfunction handleStartupError(error: unknown): never {\n if (error instanceof SandboxError) {\n for (const line of error.formatForCli()) {\n console.error(line);\n }\n process.exit(1);\n }\n throw error;\n}\n\nlet parsedArgs: ParsedArgs;\ntry {\n parsedArgs = parseArgs();\n} catch (error) {\n handleStartupError(error);\n}\n\n// Handle --version\nif (parsedArgs.showVersion) {\n console.log(getVersion());\n process.exit(0);\n}\n\n// Handle --download mode (Slack only)\nif (parsedArgs.downloadChannel) {\n if (!MOM_SLACK_BOT_TOKEN) {\n console.error(\"Missing env: MOM_SLACK_BOT_TOKEN\");\n process.exit(1);\n }\n await downloadChannel(parsedArgs.downloadChannel, MOM_SLACK_BOT_TOKEN);\n process.exit(0);\n}\n\n// Normal bot mode - require working dir\nif (!parsedArgs.workingDir) {\n console.error(\n \"Usage: mama [--sandbox=host|container:<name>|image:<image>|firecracker:<vm-id>:<host-path>] [--state-dir=<path>] <working-directory>\",\n );\n console.error(\" mama --download <channel-id>\");\n process.exit(1);\n}\n\nconst { workingDir, sandbox } = { workingDir: parsedArgs.workingDir, sandbox: parsedArgs.sandbox };\n// stateDir holds operator-managed files (vaults, settings, bindings).\n// Defaults to ~/.mama to keep secrets outside the project workspace mounted into sandboxes.\nconst stateDir = parsedArgs.stateDir ?? join(homedir(), \".mama\");\nensureSecureStateDir(stateDir);\n// Share stateDir with instrument.ts (for Sentry config loading)\nprocess.env.MAMA_STATE_DIR = stateDir;\n\n// Ensure settings.json exists; create a template if first run.\nconst { created: settingsCreated, config: agentSettings } = ensureSettingsFile(stateDir);\nif (settingsCreated) {\n console.log(`Created default settings: ${join(stateDir, \"settings.json\")}`);\n console.log(\"Review and update provider/model before starting.\");\n}\n\nif (!agentSettings.provider || !agentSettings.model) {\n console.error(`Error: 'provider' and 'model' must be set in ${join(stateDir, \"settings.json\")}`);\n process.exit(1);\n}\n\n// Validate platform tokens\nconst hasSlack = !!(MOM_SLACK_APP_TOKEN && MOM_SLACK_BOT_TOKEN);\nconst hasTelegram = !!MOM_TELEGRAM_BOT_TOKEN;\nconst hasDiscord = !!MOM_DISCORD_BOT_TOKEN;\n\nif (!hasSlack && !hasTelegram && !hasDiscord) {\n console.error(\n \"No platform tokens found. Set one of:\\n\" +\n \" Slack: MOM_SLACK_APP_TOKEN + MOM_SLACK_BOT_TOKEN\\n\" +\n \" Telegram: MOM_TELEGRAM_BOT_TOKEN\\n\" +\n \" Discord: MOM_DISCORD_BOT_TOKEN\",\n );\n process.exit(1);\n}\n\ntry {\n await validateSandbox(sandbox);\n} catch (error) {\n handleStartupError(error);\n}\n\nconst vaultManager = new FileVaultManager(stateDir);\nif (vaultManager.isEnabled()) {\n console.log(\n sandbox.type === \"container\"\n ? \" Vault system enabled. Shared container vault active.\"\n : \" Vault system enabled. Per-user credential routing active.\",\n );\n}\n\nconst bindingStore = new FileUserBindingStore(stateDir);\nif (bindingStore.isEnabled()) {\n console.log(\n sandbox.type === \"container\"\n ? \" Binding store enabled. Shared container mode ignores per-user vault bindings.\"\n : \" Binding store enabled. Platform user → vault routing active.\",\n );\n}\n\nconst provisioner =\n sandbox.type === \"image\" ? new DockerContainerManager(sandbox.image, workingDir) : undefined;\n\nconst linkTokenStore = new InMemoryLinkTokenStore();\n\n// Purge expired link tokens every 5 minutes\nsetInterval(() => linkTokenStore.purge(), 5 * 60 * 1000).unref();\n\n// ============================================================================\n// State (per conversation)\n// ============================================================================\n\ninterface ConversationState {\n running: boolean;\n runner: AgentRunner;\n stopRequested: boolean;\n stopMessageTs?: string;\n lastAccessedAt: number;\n startedAt?: number;\n lastActivityAt?: number;\n}\n\nconst conversationStates = new Map<string, ConversationState>();\n\n/** Track in-flight runs for graceful shutdown */\nconst inFlightRuns = new Set<Promise<void>>();\n\n/** Flag to stop accepting new events during shutdown */\nlet isShuttingDown = false;\n\n/** Maximum number of cached sessions */\nconst MAX_SESSIONS = 500;\n/** Idle timeout before a non-running session can be evicted (10 minutes) */\nconst IDLE_TIMEOUT_MS = 600000;\n\nif (provisioner) {\n await provisioner.reconcile();\n await provisioner.stopIdle(IDLE_TIMEOUT_MS);\n}\n\n// Stop idle containers every hour (same cadence as session eviction)\nif (provisioner) {\n setInterval(() => provisioner.stopIdle(IDLE_TIMEOUT_MS), IDLE_TIMEOUT_MS).unref();\n}\n\nfunction normalizeLoginBaseUrl(): string | undefined {\n if (MOM_LINK_URL) {\n return MOM_LINK_URL.replace(/\\/+$/, \"\");\n }\n if (MOM_LINK_PORT) {\n return `http://localhost:${MOM_LINK_PORT}`;\n }\n return undefined;\n}\n\nfunction ensureLoginVault(platform: string, platformUserId: string): string {\n const vaultId = resolveActorVaultKey(\n sandbox,\n vaultManager,\n bindingStore,\n platform,\n platformUserId,\n );\n\n ensureSandboxVaultEntry(sandbox, vaultManager, platform, platformUserId, vaultId);\n if (sandbox.type !== \"image\" && sandbox.type !== \"container\") {\n vaultManager.addEntry(\n vaultId,\n createManagedVaultEntry(platform, platformUserId, vaultId, false),\n );\n }\n\n return vaultId;\n}\n\nasync function getState(conversationId: string, sessionKey?: string): Promise<ConversationState> {\n const key = sessionKey ?? conversationId;\n let state = conversationStates.get(key);\n if (!state) {\n const conversationDir = join(workingDir, conversationId);\n state = {\n running: false,\n runner: await createRunner(\n sandbox,\n key,\n conversationId,\n conversationDir,\n workingDir,\n vaultManager,\n bindingStore,\n provisioner,\n stateDir,\n ),\n stopRequested: false,\n lastAccessedAt: Date.now(),\n };\n conversationStates.set(key, state);\n } else {\n state.lastAccessedAt = Date.now();\n }\n return state;\n}\n\n/**\n * Evict idle sessions from conversationStates to bound memory usage.\n * Called after each handleEvent completes.\n */\nfunction evictIdleSessions(): void {\n const now = Date.now();\n\n for (const [key, state] of conversationStates) {\n if (!state.running && now - state.lastAccessedAt > IDLE_TIMEOUT_MS) {\n conversationStates.delete(key);\n }\n }\n\n if (conversationStates.size > MAX_SESSIONS) {\n const idleSessions: Array<{ key: string; lastAccessedAt: number }> = [];\n for (const [key, state] of conversationStates) {\n if (!state.running) {\n idleSessions.push({ key, lastAccessedAt: state.lastAccessedAt });\n }\n }\n\n idleSessions.sort((a, b) => a.lastAccessedAt - b.lastAccessedAt);\n\n const toEvict = conversationStates.size - MAX_SESSIONS;\n for (let i = 0; i < toEvict && i < idleSessions.length; i++) {\n conversationStates.delete(idleSessions[i].key);\n }\n }\n}\n\n// ============================================================================\n// Handler\n// ============================================================================\n\nconst handler: BotHandler = {\n isRunning(sessionKey: string): boolean {\n const state = conversationStates.get(sessionKey);\n return !!state?.running;\n },\n\n getRunningSessions() {\n const sessions: import(\"./adapter.js\").RunningSession[] = [];\n for (const [sessionKey, state] of conversationStates) {\n if (state.running && state.startedAt) {\n // Get current step from runner\n const currentStep = state.runner.getCurrentStep();\n sessions.push({\n sessionKey,\n startedAt: state.startedAt,\n lastActivityAt: state.lastActivityAt,\n currentTool: currentStep?.label || currentStep?.toolName,\n });\n }\n }\n return sessions;\n },\n\n async handleStop(sessionKey: string, conversationId: string, bot: Bot): Promise<void> {\n const state = conversationStates.get(sessionKey);\n if (state?.running) {\n state.stopRequested = true;\n state.runner.abort();\n const ts = await bot.postMessage(conversationId, formatStopping(bot));\n state.stopMessageTs = ts;\n } else {\n await bot.postMessage(conversationId, formatNothingRunning(bot));\n }\n },\n\n forceStop(sessionKey: string): void {\n const state = conversationStates.get(sessionKey);\n if (state?.running) {\n log.logInfo(`[Force Stop] Force stopping session: ${sessionKey}`);\n state.stopRequested = true;\n state.runner.abort();\n state.running = false;\n }\n },\n\n async handleNew(sessionKey: string, conversationId: string, bot: Bot): Promise<void> {\n const state = conversationStates.get(sessionKey);\n if (state?.running) {\n state.stopRequested = true;\n state.runner.abort();\n }\n\n // Channel sessions rotate via current pointer. Thread sessions reset in place.\n const conversationDir = join(workingDir, conversationId);\n if (sessionKey.includes(\":\")) {\n createManagedSessionFileAtPath(\n getThreadSessionFile(conversationDir, sessionKey),\n conversationDir,\n );\n } else {\n createManagedSessionFile(getChannelSessionDir(conversationDir), conversationDir);\n }\n\n // Remove from in-memory cache\n conversationStates.delete(sessionKey);\n\n log.logInfo(`[${conversationId}] Session reset: ${sessionKey}`);\n await bot.postMessage(conversationId, \"Conversation reset. Send a new message to start fresh.\");\n },\n\n async handleLogin(\n platform: string,\n platformUserId: string,\n conversationId: string,\n bot: Bot,\n commandText: string,\n isPrivateConversation: boolean,\n ): Promise<void> {\n const parsed = parseLoginCommand(commandText);\n if (!parsed) {\n return;\n }\n\n if (!isPrivateConversation) {\n await bot.postMessage(\n conversationId,\n \"为了保护你的凭证,`/login` 只能在与机器人的私聊中使用。请先私信机器人,再重新执行 `/login`。\",\n );\n return;\n }\n\n const baseUrl = normalizeLoginBaseUrl();\n if (!baseUrl) {\n await bot.postMessage(\n conversationId,\n \"Login is not configured. Set `MOM_LINK_URL` or `MOM_LINK_PORT` on the server.\",\n );\n return;\n }\n\n let vaultId: string;\n try {\n vaultId = ensureLoginVault(platform, platformUserId);\n } catch (error) {\n log.logWarning(\n `[${conversationId}] Failed to prepare login vault for ${platform}/${platformUserId}`,\n error instanceof Error ? error.message : String(error),\n );\n await bot.postMessage(\n conversationId,\n \"Login setup failed on the server. 请稍后重试,或联系管理员检查 vault 存储权限。\",\n );\n return;\n }\n\n const loginLabel = \"credential\";\n const vaultLabel = sandbox.type === \"container\" ? \"the shared container vault\" : \"your vault\";\n await bot.postMessage(\n conversationId,\n `Open this link to store ${loginLabel} in ${vaultLabel} ` +\n `(expires in 15 minutes):\\n${baseUrl}/link?token=${\n linkTokenStore.create(\n platform as \"slack\" | \"discord\" | \"telegram\",\n platformUserId,\n conversationId,\n vaultId,\n \"\",\n ).token\n }`,\n );\n },\n\n async handleEvent(\n event: BotEvent,\n bot: Bot,\n adapters: BotAdapters,\n _isEvent?: boolean,\n ): Promise<void> {\n // Don't accept new events during shutdown\n if (isShuttingDown) {\n log.logInfo(\n `[${event.conversationId}] Rejected event during shutdown: ${event.text.substring(0, 50)}`,\n );\n return;\n }\n\n const sessionKey = event.sessionKey ?? `${event.conversationId}:${event.thread_ts ?? event.ts}`;\n const state = await getState(event.conversationId, sessionKey);\n\n // Start run\n state.running = true;\n state.stopRequested = false;\n state.startedAt = Date.now();\n state.lastActivityAt = Date.now();\n\n log.logInfo(`[${event.conversationId}] Starting run: ${event.text.substring(0, 50)}`);\n\n // Wrap in-flight run tracking\n Sentry.metrics.count(\"agent.run.started\", 1, {\n attributes: { channel: event.conversationId },\n });\n Sentry.metrics.gauge(\"agent.sessions.active\", inFlightRuns.size + 1);\n\n const runPromise = Sentry.startSpan(\n {\n name: \"agent.run\",\n op: \"agent\",\n attributes: { channelId: event.conversationId, sessionKey },\n },\n async () => {\n return Sentry.withScope(async (scope) => {\n const { message, responseCtx, platform } = adapters;\n applyRunScope(scope, {\n conversationId: event.conversationId,\n sessionKey,\n messageId: message.id,\n platform: platform.name,\n userId: message.userId,\n userName: message.userName,\n threadTs: message.threadTs,\n isEvent: _isEvent,\n });\n addLifecycleBreadcrumb(\"agent.run.started\", {\n channel_id: event.conversationId,\n platform: platform.name,\n has_attachments: (message.attachments?.length ?? 0) > 0,\n });\n\n try {\n await responseCtx.setTyping(true);\n await responseCtx.setWorking(true);\n const result = await state.runner.run(message, responseCtx, platform);\n await responseCtx.setWorking(false);\n\n const durationMs = Date.now() - state.startedAt!;\n Sentry.metrics.distribution(\"agent.run.duration\", durationMs, {\n unit: \"millisecond\",\n attributes: {\n channel: event.conversationId,\n platform: platform.name,\n stop_reason: result.stopReason,\n },\n });\n Sentry.metrics.count(\"agent.run.completed\", 1, {\n attributes: {\n channel: event.conversationId,\n platform: platform.name,\n stop_reason: result.stopReason,\n },\n });\n addLifecycleBreadcrumb(\"agent.run.completed\", {\n channel_id: event.conversationId,\n platform: platform.name,\n stop_reason: result.stopReason,\n duration_ms: durationMs,\n });\n\n if (result.stopReason === \"aborted\" && state.stopRequested) {\n if (state.stopMessageTs) {\n await bot.updateMessage(event.conversationId, state.stopMessageTs, \"_Stopped_\");\n state.stopMessageTs = undefined;\n } else {\n await bot.postMessage(event.conversationId, \"_Stopped_\");\n }\n }\n } catch (err) {\n scope.setContext(\"agent_run_error\", {\n conversationId: event.conversationId,\n sessionKey,\n platform: adapters.platform.name,\n messageId: adapters.message.id,\n threadTs: adapters.message.threadTs,\n });\n Sentry.captureException(err);\n Sentry.metrics.count(\"agent.run.errors\", 1, {\n attributes: { channel: event.conversationId, platform: adapters.platform.name },\n });\n log.logWarning(\n `[${event.conversationId}] Run error`,\n err instanceof Error ? err.message : String(err),\n );\n } finally {\n state.running = false;\n state.lastAccessedAt = Date.now();\n Sentry.metrics.gauge(\"agent.sessions.active\", inFlightRuns.size - 1);\n evictIdleSessions();\n }\n });\n },\n );\n\n inFlightRuns.add(runPromise);\n try {\n await runPromise;\n } finally {\n inFlightRuns.delete(runPromise);\n }\n },\n};\n\n// ============================================================================\n// Start\n// ============================================================================\n\nconst sandboxDesc =\n sandbox.type === \"host\"\n ? \"host\"\n : sandbox.type === \"container\"\n ? `container:${sandbox.container}`\n : sandbox.type === \"image\"\n ? `image:${sandbox.image}`\n : `firecracker:${sandbox.vmId}`;\nlog.logStartup(workingDir, sandboxDesc);\n\n// Start link callback server if port is configured\nif (MOM_LINK_PORT) {\n startLinkServer(\n MOM_LINK_PORT,\n linkTokenStore,\n vaultManager,\n async (platform, conversationId, msg) => {\n const bot = botsByPlatform[platform];\n if (bot) await bot.postMessage(conversationId, msg);\n },\n );\n}\n\n// Create platform bots\nconst bots: Bot[] = [];\nconst botsByPlatform: Record<string, Bot> = {};\n\nif (hasSlack) {\n const sharedStore = new ChannelStore({ workingDir, botToken: MOM_SLACK_BOT_TOKEN! });\n const slackBot = new SlackBotClass(handler, {\n appToken: MOM_SLACK_APP_TOKEN!,\n botToken: MOM_SLACK_BOT_TOKEN!,\n workingDir,\n store: sharedStore,\n });\n bots.push(slackBot);\n botsByPlatform.slack = slackBot;\n log.logInfo(\"Platform: Slack\");\n}\nif (hasTelegram) {\n const telegramBot = new TelegramBot(handler, {\n token: MOM_TELEGRAM_BOT_TOKEN!,\n workingDir,\n });\n bots.push(telegramBot);\n botsByPlatform.telegram = telegramBot;\n log.logInfo(\"Platform: Telegram\");\n}\nif (hasDiscord) {\n const discordBot = new DiscordBot(handler, {\n token: MOM_DISCORD_BOT_TOKEN!,\n workingDir,\n });\n bots.push(discordBot);\n botsByPlatform.discord = discordBot;\n log.logInfo(\"Platform: Discord\");\n}\n\n// Start events watcher with explicit platform routing\nconst eventsWatcher = createEventsWatcher(workingDir, botsByPlatform);\nconst slackBot = botsByPlatform.slack as SlackBotClass | undefined;\nif (slackBot) {\n slackBot.setEventsWatcher(eventsWatcher);\n}\neventsWatcher.start();\n\n// Handle shutdown\nasync function shutdown(): Promise<void> {\n if (isShuttingDown) return;\n isShuttingDown = true;\n log.logInfo(\"Shutting down gracefully...\");\n\n const timeout = Date.now() + 30000;\n while (inFlightRuns.size > 0 && Date.now() < timeout) {\n await new Promise((resolve) => setTimeout(resolve, 500));\n }\n\n if (inFlightRuns.size > 0) {\n log.logWarning(`Forcing exit with ${inFlightRuns.size} runs still in progress`);\n }\n\n eventsWatcher.stop();\n await Sentry.close(5000);\n process.exit(0);\n}\n\nprocess.on(\"SIGINT\", shutdown);\nprocess.on(\"SIGTERM\", shutdown);\n\n// Start all bots\nawait Promise.all(\n bots.map((bot) =>\n bot.start().catch((err) => {\n log.logWarning(\"Failed to start bot\", err instanceof Error ? err.message : String(err));\n process.exit(1);\n }),\n ),\n);\n"]}
|
|
1
|
+
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";AAEA,OAAO,iBAAiB,CAAC","sourcesContent":["#!/usr/bin/env node\n\nimport \"./instrument.js\";\n\nimport { join, resolve } from \"path\";\nimport { mkdirSync, readFileSync, statSync } from \"fs\";\nimport { homedir } from \"os\";\nimport { fileURLToPath } from \"url\";\nimport { dirname, join as pathJoin } from \"path\";\nimport type { Bot, BotAdapters, BotEvent, BotHandler } from \"./adapter.js\";\nimport { DiscordBot } from \"./adapters/discord/index.js\";\nimport { TelegramBot } from \"./adapters/telegram/index.js\";\nimport { SlackBot as SlackBotClass } from \"./adapters/slack/index.js\";\nimport { type AgentRunner, createRunner } from \"./agent.js\";\nimport {\n createManagedSessionFile,\n createManagedSessionFileAtPath,\n getChannelSessionDir,\n getThreadSessionFile,\n} from \"./session-store.js\";\nimport { downloadChannel } from \"./download.js\";\nimport { createEventsWatcher } from \"./events.js\";\nimport * as log from \"./log.js\";\nimport { FileUserBindingStore } from \"./bindings.js\";\nimport { startLinkServer } from \"./link-server.js\";\nimport { parseLoginCommand } from \"./login.js\";\nimport { InMemoryLinkTokenStore } from \"./link-token.js\";\nimport { DockerContainerManager } from \"./provisioner.js\";\nimport { SandboxError, parseSandboxArg, type SandboxConfig, validateSandbox } from \"./sandbox.js\";\nimport { FileVaultManager } from \"./vault.js\";\nimport {\n createManagedVaultEntry,\n ensureSandboxVaultEntry,\n resolveActorVaultKey,\n} from \"./vault-routing.js\";\nimport { addLifecycleBreadcrumb, applyRunScope } from \"./sentry.js\";\nimport { ChannelStore } from \"./store.js\";\nimport { formatNothingRunning, formatStopped, formatStopping } from \"./ui-copy.js\";\nimport * as Sentry from \"@sentry/node\";\n\n// ============================================================================\n// Config\n// ============================================================================\n\n// Get version from package.json\nfunction getVersion(): string {\n // Try to find package.json in the dist directory or parent\n const possiblePaths = [\n pathJoin(dirname(fileURLToPath(import.meta.url)), \"package.json\"),\n pathJoin(dirname(fileURLToPath(import.meta.url)), \"..\", \"package.json\"),\n pathJoin(process.cwd(), \"package.json\"),\n ];\n\n for (const pkgPath of possiblePaths) {\n try {\n const pkg = JSON.parse(readFileSync(pkgPath, \"utf-8\"));\n if (pkg.version) return pkg.version;\n } catch {\n // Continue to next path\n }\n }\n return \"unknown\";\n}\n\nconst MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;\nconst MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;\nconst MOM_TELEGRAM_BOT_TOKEN = process.env.MOM_TELEGRAM_BOT_TOKEN;\nconst MOM_DISCORD_BOT_TOKEN = process.env.MOM_DISCORD_BOT_TOKEN;\nconst MOM_LINK_URL = process.env.MOM_LINK_URL;\nconst MOM_LINK_PORT = process.env.MOM_LINK_PORT\n ? parseInt(process.env.MOM_LINK_PORT, 10)\n : MOM_LINK_URL\n ? 8181\n : undefined;\n\ninterface ParsedArgs {\n workingDir?: string;\n stateDir?: string;\n sandbox: SandboxConfig;\n downloadChannel?: string;\n showVersion?: boolean;\n}\n\nfunction parseArgs(): ParsedArgs {\n const args = process.argv.slice(2);\n let sandbox: SandboxConfig = { type: \"host\" };\n let workingDir: string | undefined;\n let stateDirArg: string | undefined;\n let downloadChannelId: string | undefined;\n let showVersion = false;\n\n for (let i = 0; i < args.length; i++) {\n const arg = args[i];\n if (arg === \"--version\" || arg === \"-v\" || arg === \"-V\") {\n showVersion = true;\n } else if (arg.startsWith(\"--sandbox=\")) {\n sandbox = parseSandboxArg(arg.slice(\"--sandbox=\".length));\n } else if (arg === \"--sandbox\") {\n sandbox = parseSandboxArg(args[++i] || \"\");\n } else if (arg.startsWith(\"--state-dir=\")) {\n stateDirArg = arg.slice(\"--state-dir=\".length);\n } else if (arg === \"--state-dir\") {\n stateDirArg = args[++i];\n } else if (arg.startsWith(\"--download=\")) {\n downloadChannelId = arg.slice(\"--download=\".length);\n } else if (arg === \"--download\") {\n downloadChannelId = args[++i];\n } else if (!arg.startsWith(\"-\")) {\n workingDir = arg;\n }\n }\n\n return {\n workingDir: workingDir ? resolve(workingDir) : undefined,\n stateDir: stateDirArg ? resolve(stateDirArg) : undefined,\n sandbox,\n downloadChannel: downloadChannelId,\n showVersion,\n };\n}\n\nconst WORLD_WRITABLE_MODE = 0o002;\n\nfunction ensureSecureStateDir(path: string): void {\n let stat;\n try {\n stat = statSync(path);\n } catch (err) {\n const code = (err as NodeJS.ErrnoException).code;\n if (code === \"ENOENT\") {\n mkdirSync(path, { recursive: true, mode: 0o700 });\n return;\n }\n console.error(`Error: cannot access --state-dir ${path}: ${(err as Error).message}`);\n process.exit(1);\n }\n\n if (!stat.isDirectory()) {\n console.error(`Error: --state-dir ${path} exists but is not a directory`);\n process.exit(1);\n }\n\n if (stat.mode & WORLD_WRITABLE_MODE) {\n console.error(\n `Error: --state-dir ${path} is world-writable (mode ${(stat.mode & 0o777).toString(8)}). ` +\n `Credentials stored there would be exposed to other local users. ` +\n `Fix with: chmod 0700 ${path}`,\n );\n process.exit(1);\n }\n\n const euid = typeof process.geteuid === \"function\" ? process.geteuid() : undefined;\n if (euid !== undefined && stat.uid !== euid) {\n console.error(\n `Error: --state-dir ${path} is owned by uid ${stat.uid} but mama is running as uid ${euid}. ` +\n `Run mama as the directory owner or point --state-dir at a directory you own.`,\n );\n process.exit(1);\n }\n}\n\nfunction handleStartupError(error: unknown): never {\n if (error instanceof SandboxError) {\n for (const line of error.formatForCli()) {\n console.error(line);\n }\n process.exit(1);\n }\n throw error;\n}\n\nlet parsedArgs: ParsedArgs;\ntry {\n parsedArgs = parseArgs();\n} catch (error) {\n handleStartupError(error);\n}\n\n// Handle --version\nif (parsedArgs.showVersion) {\n console.log(getVersion());\n process.exit(0);\n}\n\n// Handle --download mode (Slack only)\nif (parsedArgs.downloadChannel) {\n if (!MOM_SLACK_BOT_TOKEN) {\n console.error(\"Missing env: MOM_SLACK_BOT_TOKEN\");\n process.exit(1);\n }\n await downloadChannel(parsedArgs.downloadChannel, MOM_SLACK_BOT_TOKEN);\n process.exit(0);\n}\n\n// Normal bot mode - require working dir\nif (!parsedArgs.workingDir) {\n console.error(\n \"Usage: mama [--state-dir=<dir>] [--sandbox=host|container:<name>|image:<image>|firecracker:<vm-id>:<host-path>] <working-directory>\",\n );\n console.error(\" mama --download <channel-id>\");\n process.exit(1);\n}\n\nconst { workingDir, sandbox } = { workingDir: parsedArgs.workingDir, sandbox: parsedArgs.sandbox };\nconst stateDir = parsedArgs.stateDir ?? join(homedir(), \".mama\");\nprocess.env.MAMA_STATE_DIR = stateDir;\nensureSecureStateDir(stateDir);\n\n// Validate platform tokens\nconst hasSlack = !!(MOM_SLACK_APP_TOKEN && MOM_SLACK_BOT_TOKEN);\nconst hasTelegram = !!MOM_TELEGRAM_BOT_TOKEN;\nconst hasDiscord = !!MOM_DISCORD_BOT_TOKEN;\n\nif (!hasSlack && !hasTelegram && !hasDiscord) {\n console.error(\n \"No platform tokens found. Set one of:\\n\" +\n \" Slack: MOM_SLACK_APP_TOKEN + MOM_SLACK_BOT_TOKEN\\n\" +\n \" Telegram: MOM_TELEGRAM_BOT_TOKEN\\n\" +\n \" Discord: MOM_DISCORD_BOT_TOKEN\",\n );\n process.exit(1);\n}\n\ntry {\n await validateSandbox(sandbox);\n} catch (error) {\n handleStartupError(error);\n}\n\nconst vaultManager = new FileVaultManager(stateDir);\nif (vaultManager.isEnabled()) {\n console.log(\n sandbox.type === \"container\"\n ? \" Vault system enabled. Container vault active.\"\n : sandbox.type === \"image\" || sandbox.type === \"firecracker\"\n ? \" Vault system enabled. Per-user credential routing active.\"\n : \" Vault system enabled. Host mode will not inject vault env.\",\n );\n}\n\nconst bindingStore = new FileUserBindingStore(stateDir);\nif (bindingStore.isEnabled()) {\n console.log(\n sandbox.type === \"container\"\n ? \" Binding store enabled. Container mode uses the container vault.\"\n : sandbox.type === \"image\" || sandbox.type === \"firecracker\"\n ? \" Binding store enabled. Platform user → vault routing active.\"\n : \" Binding store enabled. Host mode will not inject vault env.\",\n );\n}\n\nconst provisioner =\n sandbox.type === \"image\" ? new DockerContainerManager(sandbox.image, workingDir) : undefined;\n\nconst linkTokenStore = new InMemoryLinkTokenStore();\nsetInterval(() => linkTokenStore.purge(), 5 * 60 * 1000).unref();\n\nfunction normalizeLoginBaseUrl(): string | undefined {\n if (MOM_LINK_URL) {\n return MOM_LINK_URL.replace(/\\/+$/, \"\");\n }\n if (MOM_LINK_PORT) {\n return `http://localhost:${MOM_LINK_PORT}`;\n }\n return undefined;\n}\n\nfunction isPrivateConversation(event: BotEvent): boolean {\n return (\n event.conversationKind === \"direct\" ||\n event.type === \"dm\" ||\n event.sessionKey === event.conversationId\n );\n}\n\nfunction ensureLoginVault(platform: string, platformUserId: string): string {\n const vaultId = resolveActorVaultKey(\n sandbox,\n vaultManager,\n bindingStore,\n platform,\n platformUserId,\n );\n\n ensureSandboxVaultEntry(sandbox, vaultManager, platform, platformUserId, vaultId);\n if (sandbox.type !== \"container\" && sandbox.type !== \"image\") {\n vaultManager.addEntry(vaultId, createManagedVaultEntry(platform, platformUserId, vaultId));\n }\n\n return vaultId;\n}\n\nasync function replyWithContext(\n responseCtx: BotAdapters[\"responseCtx\"],\n text: string,\n): Promise<void> {\n await responseCtx.setTyping(false);\n await responseCtx.setWorking(false);\n await responseCtx.respond(text);\n}\n\nasync function handleLoginCommand(\n platform: string,\n platformUserId: string,\n conversationId: string,\n responseCtx: BotAdapters[\"responseCtx\"],\n commandText: string,\n privateConversation: boolean,\n): Promise<boolean> {\n const parsed = parseLoginCommand(commandText);\n if (!parsed) return false;\n\n if (!privateConversation) {\n await replyWithContext(\n responseCtx,\n \"為了保護你的憑證,`/login` 只能在與機器人的私訊中使用。請先私訊機器人,再重新執行 `/login`。\",\n );\n return true;\n }\n\n const baseUrl = normalizeLoginBaseUrl();\n if (!baseUrl) {\n await replyWithContext(\n responseCtx,\n \"Login is not configured. Set `MOM_LINK_URL` or `MOM_LINK_PORT` on the server.\",\n );\n return true;\n }\n\n let vaultId: string;\n try {\n vaultId = ensureLoginVault(platform, platformUserId);\n } catch (error) {\n log.logWarning(\n `[${conversationId}] Failed to prepare login vault for ${platform}/${platformUserId}`,\n error instanceof Error ? error.message : String(error),\n );\n await replyWithContext(\n responseCtx,\n \"Login setup failed on the server. 請稍後重試,或聯絡管理員檢查 vault 儲存權限。\",\n );\n return true;\n }\n\n const token = linkTokenStore.create(\n platform as \"slack\" | \"discord\" | \"telegram\",\n platformUserId,\n conversationId,\n vaultId,\n \"\",\n );\n const vaultLabel = sandbox.type === \"container\" ? `container vault (${vaultId})` : \"your vault\";\n await replyWithContext(\n responseCtx,\n `Open this link to store credentials in ${vaultLabel} (expires in 15 minutes):\\n${baseUrl}/link?token=${token.token}`,\n );\n return true;\n}\n\n// ============================================================================\n// State (per conversation)\n// ============================================================================\n\ninterface ConversationState {\n running: boolean;\n runner: AgentRunner;\n stopRequested: boolean;\n stopMessageTs?: string;\n lastAccessedAt: number;\n startedAt?: number;\n lastActivityAt?: number;\n}\n\nconst conversationStates = new Map<string, ConversationState>();\n\n/** Track in-flight runs for graceful shutdown */\nconst inFlightRuns = new Set<Promise<void>>();\n\n/** Flag to stop accepting new events during shutdown */\nlet isShuttingDown = false;\n\n/** Maximum number of cached sessions */\nconst MAX_SESSIONS = 500;\n/** Idle timeout before a non-running session can be evicted (1 hour) */\nconst IDLE_TIMEOUT_MS = 3600000;\n/** Idle timeout for managed image containers (10 minutes) */\nconst IMAGE_IDLE_TIMEOUT_MS = 10 * 60 * 1000;\n\nif (provisioner) {\n await provisioner.reconcile();\n await provisioner.stopIdle(IMAGE_IDLE_TIMEOUT_MS);\n setInterval(() => provisioner.stopIdle(IMAGE_IDLE_TIMEOUT_MS), IMAGE_IDLE_TIMEOUT_MS).unref();\n}\n\nasync function getState(conversationId: string, sessionKey?: string): Promise<ConversationState> {\n const key = sessionKey ?? conversationId;\n let state = conversationStates.get(key);\n if (!state) {\n const conversationDir = join(workingDir, conversationId);\n state = {\n running: false,\n runner: await createRunner(\n sandbox,\n key,\n conversationId,\n conversationDir,\n workingDir,\n vaultManager,\n bindingStore,\n provisioner,\n ),\n stopRequested: false,\n lastAccessedAt: Date.now(),\n };\n conversationStates.set(key, state);\n } else {\n state.lastAccessedAt = Date.now();\n }\n return state;\n}\n\n/**\n * Evict idle sessions from conversationStates to bound memory usage.\n * Called after each handleEvent completes.\n */\nfunction evictIdleSessions(): void {\n const now = Date.now();\n\n for (const [key, state] of conversationStates) {\n if (!state.running && now - state.lastAccessedAt > IDLE_TIMEOUT_MS) {\n conversationStates.delete(key);\n }\n }\n\n if (conversationStates.size > MAX_SESSIONS) {\n const idleSessions: Array<{ key: string; lastAccessedAt: number }> = [];\n for (const [key, state] of conversationStates) {\n if (!state.running) {\n idleSessions.push({ key, lastAccessedAt: state.lastAccessedAt });\n }\n }\n\n idleSessions.sort((a, b) => a.lastAccessedAt - b.lastAccessedAt);\n\n const toEvict = conversationStates.size - MAX_SESSIONS;\n for (let i = 0; i < toEvict && i < idleSessions.length; i++) {\n conversationStates.delete(idleSessions[i].key);\n }\n }\n}\n\n// ============================================================================\n// Handler\n// ============================================================================\n\nconst handler: BotHandler = {\n isRunning(sessionKey: string): boolean {\n const state = conversationStates.get(sessionKey);\n return !!state?.running;\n },\n\n getRunningSessions() {\n const sessions: import(\"./adapter.js\").RunningSession[] = [];\n for (const [sessionKey, state] of conversationStates) {\n if (state.running && state.startedAt) {\n // Get current step from runner\n const currentStep = state.runner.getCurrentStep();\n sessions.push({\n sessionKey,\n startedAt: state.startedAt,\n lastActivityAt: state.lastActivityAt,\n currentTool: currentStep?.label || currentStep?.toolName,\n });\n }\n }\n return sessions;\n },\n\n async handleStop(sessionKey: string, conversationId: string, bot: Bot): Promise<void> {\n const state = conversationStates.get(sessionKey);\n if (state?.running) {\n state.stopRequested = true;\n state.runner.abort();\n const ts = await bot.postMessage(conversationId, formatStopping(bot));\n state.stopMessageTs = ts;\n } else {\n await bot.postMessage(conversationId, formatNothingRunning(bot));\n }\n },\n\n forceStop(sessionKey: string): void {\n const state = conversationStates.get(sessionKey);\n if (state?.running) {\n log.logInfo(`[Force Stop] Force stopping session: ${sessionKey}`);\n state.stopRequested = true;\n state.runner.abort();\n state.running = false;\n }\n },\n\n async handleNew(sessionKey: string, conversationId: string, bot: Bot): Promise<void> {\n const state = conversationStates.get(sessionKey);\n if (state?.running) {\n state.stopRequested = true;\n state.runner.abort();\n }\n\n // Conversation sessions rotate via current pointer. Thread sessions reset in place.\n const conversationDir = join(workingDir, conversationId);\n if (sessionKey.includes(\":\")) {\n createManagedSessionFileAtPath(\n getThreadSessionFile(conversationDir, sessionKey),\n conversationDir,\n );\n } else {\n createManagedSessionFile(getChannelSessionDir(conversationDir), conversationDir);\n }\n\n // Remove from in-memory cache\n conversationStates.delete(sessionKey);\n\n log.logInfo(`[${conversationId}] Session reset: ${sessionKey}`);\n await bot.postMessage(conversationId, \"Conversation reset. Send a new message to start fresh.\");\n },\n\n async handleEvent(\n event: BotEvent,\n bot: Bot,\n adapters: BotAdapters,\n _isEvent?: boolean,\n ): Promise<void> {\n const conversationId = event.conversationId;\n\n // Don't accept new events during shutdown\n if (isShuttingDown) {\n log.logInfo(\n `[${conversationId}] Rejected event during shutdown: ${event.text.substring(0, 50)}`,\n );\n return;\n }\n\n const sessionKey = event.sessionKey ?? `${conversationId}:${event.thread_ts ?? event.ts}`;\n const handledLogin = await handleLoginCommand(\n adapters.platform.name,\n event.user,\n conversationId,\n adapters.responseCtx,\n event.text,\n isPrivateConversation(event),\n );\n if (handledLogin) return;\n\n const state = await getState(conversationId, sessionKey);\n\n // Start run\n state.running = true;\n state.stopRequested = false;\n state.startedAt = Date.now();\n state.lastActivityAt = Date.now();\n\n log.logInfo(`[${conversationId}] Starting run: ${event.text.substring(0, 50)}`);\n\n // Wrap in-flight run tracking\n Sentry.metrics.count(\"agent.run.started\", 1, {\n attributes: { channel: conversationId },\n });\n Sentry.metrics.gauge(\"agent.sessions.active\", inFlightRuns.size + 1);\n\n const runPromise = Sentry.startSpan(\n { name: \"agent.run\", op: \"agent\", attributes: { conversationId, sessionKey } },\n async () => {\n return Sentry.withScope(async (scope) => {\n const { message, responseCtx, platform } = adapters;\n applyRunScope(scope, {\n conversationId,\n sessionKey,\n messageId: message.id,\n platform: platform.name,\n userId: message.userId,\n userName: message.userName,\n threadTs: message.threadTs,\n isEvent: _isEvent,\n });\n addLifecycleBreadcrumb(\"agent.run.started\", {\n channel_id: conversationId,\n platform: platform.name,\n has_attachments: (message.attachments?.length ?? 0) > 0,\n });\n\n try {\n await responseCtx.setTyping(true);\n await responseCtx.setWorking(true);\n const result = await state.runner.run(message, responseCtx, platform);\n await responseCtx.setWorking(false);\n\n const durationMs = Date.now() - state.startedAt!;\n Sentry.metrics.distribution(\"agent.run.duration\", durationMs, {\n unit: \"millisecond\",\n attributes: {\n channel: conversationId,\n platform: platform.name,\n stop_reason: result.stopReason,\n },\n });\n Sentry.metrics.count(\"agent.run.completed\", 1, {\n attributes: {\n channel: conversationId,\n platform: platform.name,\n stop_reason: result.stopReason,\n },\n });\n addLifecycleBreadcrumb(\"agent.run.completed\", {\n channel_id: conversationId,\n platform: platform.name,\n stop_reason: result.stopReason,\n duration_ms: durationMs,\n });\n\n if (result.stopReason === \"aborted\" && state.stopRequested) {\n if (state.stopMessageTs) {\n await bot.updateMessage(conversationId, state.stopMessageTs, formatStopped(bot));\n state.stopMessageTs = undefined;\n } else {\n await bot.postMessage(conversationId, formatStopped(bot));\n }\n }\n } catch (err) {\n scope.setContext(\"agent_run_error\", {\n conversationId,\n sessionKey,\n platform: adapters.platform.name,\n messageId: adapters.message.id,\n threadTs: adapters.message.threadTs,\n });\n Sentry.captureException(err);\n Sentry.metrics.count(\"agent.run.errors\", 1, {\n attributes: { channel: conversationId, platform: adapters.platform.name },\n });\n log.logWarning(\n `[${conversationId}] Run error`,\n err instanceof Error ? err.message : String(err),\n );\n } finally {\n state.running = false;\n state.lastAccessedAt = Date.now();\n Sentry.metrics.gauge(\"agent.sessions.active\", inFlightRuns.size - 1);\n evictIdleSessions();\n }\n });\n },\n );\n\n inFlightRuns.add(runPromise);\n try {\n await runPromise;\n } finally {\n inFlightRuns.delete(runPromise);\n }\n },\n};\n\n// ============================================================================\n// Start\n// ============================================================================\n\nconst sandboxDesc =\n sandbox.type === \"host\"\n ? \"host\"\n : sandbox.type === \"container\"\n ? `container:${sandbox.container}`\n : sandbox.type === \"image\"\n ? `image:${sandbox.image}`\n : `firecracker:${sandbox.vmId}`;\nlog.logStartup(workingDir, sandboxDesc);\n\n// Create platform bots\nconst bots: Bot[] = [];\nconst botsByPlatform: Record<string, Bot> = {};\n\nif (hasSlack) {\n const sharedStore = new ChannelStore({ workingDir, botToken: MOM_SLACK_BOT_TOKEN! });\n const slackBot = new SlackBotClass(handler, {\n appToken: MOM_SLACK_APP_TOKEN!,\n botToken: MOM_SLACK_BOT_TOKEN!,\n workingDir,\n store: sharedStore,\n });\n bots.push(slackBot);\n botsByPlatform.slack = slackBot;\n log.logInfo(\"Platform: Slack\");\n}\nif (hasTelegram) {\n const telegramBot = new TelegramBot(handler, {\n token: MOM_TELEGRAM_BOT_TOKEN!,\n workingDir,\n });\n bots.push(telegramBot);\n botsByPlatform.telegram = telegramBot;\n log.logInfo(\"Platform: Telegram\");\n}\nif (hasDiscord) {\n const discordBot = new DiscordBot(handler, {\n token: MOM_DISCORD_BOT_TOKEN!,\n workingDir,\n });\n bots.push(discordBot);\n botsByPlatform.discord = discordBot;\n log.logInfo(\"Platform: Discord\");\n}\n\nif (MOM_LINK_PORT) {\n startLinkServer(\n MOM_LINK_PORT,\n linkTokenStore,\n vaultManager,\n async (platform, conversationId, message) => {\n const bot = botsByPlatform[platform];\n if (bot) await bot.postMessage(conversationId, message);\n },\n );\n}\n\n// Start events watcher with explicit platform routing\nconst eventsWatcher = createEventsWatcher(workingDir, botsByPlatform);\nconst slackBot = botsByPlatform.slack as SlackBotClass | undefined;\nif (slackBot) {\n slackBot.setEventsWatcher(eventsWatcher);\n}\neventsWatcher.start();\n\n// Handle shutdown\nasync function shutdown(): Promise<void> {\n if (isShuttingDown) return;\n isShuttingDown = true;\n log.logInfo(\"Shutting down gracefully...\");\n\n const timeout = Date.now() + 30000;\n while (inFlightRuns.size > 0 && Date.now() < timeout) {\n await new Promise((resolve) => setTimeout(resolve, 500));\n }\n\n if (inFlightRuns.size > 0) {\n log.logWarning(`Forcing exit with ${inFlightRuns.size} runs still in progress`);\n }\n\n eventsWatcher.stop();\n await Sentry.close(5000);\n process.exit(0);\n}\n\nprocess.on(\"SIGINT\", shutdown);\nprocess.on(\"SIGTERM\", shutdown);\n\n// Start all bots\nawait Promise.all(\n bots.map((bot) =>\n bot.start().catch((err) => {\n log.logWarning(\"Failed to start bot\", err instanceof Error ? err.message : String(err));\n process.exit(1);\n }),\n ),\n);\n"]}
|
package/dist/main.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import "./instrument.js";
|
|
3
3
|
import { join, resolve } from "path";
|
|
4
|
-
import { homedir } from "os";
|
|
5
4
|
import { mkdirSync, readFileSync, statSync } from "fs";
|
|
5
|
+
import { homedir } from "os";
|
|
6
6
|
import { fileURLToPath } from "url";
|
|
7
7
|
import { dirname, join as pathJoin } from "path";
|
|
8
8
|
import { DiscordBot } from "./adapters/discord/index.js";
|
|
@@ -19,12 +19,11 @@ import { parseLoginCommand } from "./login.js";
|
|
|
19
19
|
import { InMemoryLinkTokenStore } from "./link-token.js";
|
|
20
20
|
import { DockerContainerManager } from "./provisioner.js";
|
|
21
21
|
import { SandboxError, parseSandboxArg, validateSandbox } from "./sandbox.js";
|
|
22
|
-
import { formatNothingRunning, formatStopping } from "./ui-copy.js";
|
|
23
22
|
import { FileVaultManager } from "./vault.js";
|
|
24
|
-
import { ensureSettingsFile } from "./config.js";
|
|
25
23
|
import { createManagedVaultEntry, ensureSandboxVaultEntry, resolveActorVaultKey, } from "./vault-routing.js";
|
|
26
24
|
import { addLifecycleBreadcrumb, applyRunScope } from "./sentry.js";
|
|
27
25
|
import { ChannelStore } from "./store.js";
|
|
26
|
+
import { formatNothingRunning, formatStopped, formatStopping } from "./ui-copy.js";
|
|
28
27
|
import * as Sentry from "@sentry/node";
|
|
29
28
|
// ============================================================================
|
|
30
29
|
// Config
|
|
@@ -53,13 +52,7 @@ const MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;
|
|
|
53
52
|
const MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;
|
|
54
53
|
const MOM_TELEGRAM_BOT_TOKEN = process.env.MOM_TELEGRAM_BOT_TOKEN;
|
|
55
54
|
const MOM_DISCORD_BOT_TOKEN = process.env.MOM_DISCORD_BOT_TOKEN;
|
|
56
|
-
/** Base URL of the web login portal, e.g. https://platform.trygemini.xyz */
|
|
57
55
|
const MOM_LINK_URL = process.env.MOM_LINK_URL;
|
|
58
|
-
/**
|
|
59
|
-
* Port for the link callback HTTP server.
|
|
60
|
-
* Defaults to 8181 when MOM_LINK_URL is set (behind a reverse proxy).
|
|
61
|
-
* If neither is set, the server is not started.
|
|
62
|
-
*/
|
|
63
56
|
const MOM_LINK_PORT = process.env.MOM_LINK_PORT
|
|
64
57
|
? parseInt(process.env.MOM_LINK_PORT, 10)
|
|
65
58
|
: MOM_LINK_URL
|
|
@@ -108,12 +101,6 @@ function parseArgs() {
|
|
|
108
101
|
};
|
|
109
102
|
}
|
|
110
103
|
const WORLD_WRITABLE_MODE = 0o002;
|
|
111
|
-
/**
|
|
112
|
-
* Create stateDir if missing and refuse to use it if another local user could
|
|
113
|
-
* tamper with its contents. stateDir holds vaults, bindings, and settings —
|
|
114
|
-
* a world-writable or foreign-owned directory there would let a local attacker
|
|
115
|
-
* swap in credentials or change routing.
|
|
116
|
-
*/
|
|
117
104
|
function ensureSecureStateDir(path) {
|
|
118
105
|
let stat;
|
|
119
106
|
try {
|
|
@@ -177,27 +164,14 @@ if (parsedArgs.downloadChannel) {
|
|
|
177
164
|
}
|
|
178
165
|
// Normal bot mode - require working dir
|
|
179
166
|
if (!parsedArgs.workingDir) {
|
|
180
|
-
console.error("Usage: mama [--sandbox=host|container:<name>|image:<image>|firecracker:<vm-id>:<host-path>]
|
|
167
|
+
console.error("Usage: mama [--state-dir=<dir>] [--sandbox=host|container:<name>|image:<image>|firecracker:<vm-id>:<host-path>] <working-directory>");
|
|
181
168
|
console.error(" mama --download <channel-id>");
|
|
182
169
|
process.exit(1);
|
|
183
170
|
}
|
|
184
171
|
const { workingDir, sandbox } = { workingDir: parsedArgs.workingDir, sandbox: parsedArgs.sandbox };
|
|
185
|
-
// stateDir holds operator-managed files (vaults, settings, bindings).
|
|
186
|
-
// Defaults to ~/.mama to keep secrets outside the project workspace mounted into sandboxes.
|
|
187
172
|
const stateDir = parsedArgs.stateDir ?? join(homedir(), ".mama");
|
|
188
|
-
ensureSecureStateDir(stateDir);
|
|
189
|
-
// Share stateDir with instrument.ts (for Sentry config loading)
|
|
190
173
|
process.env.MAMA_STATE_DIR = stateDir;
|
|
191
|
-
|
|
192
|
-
const { created: settingsCreated, config: agentSettings } = ensureSettingsFile(stateDir);
|
|
193
|
-
if (settingsCreated) {
|
|
194
|
-
console.log(`Created default settings: ${join(stateDir, "settings.json")}`);
|
|
195
|
-
console.log("Review and update provider/model before starting.");
|
|
196
|
-
}
|
|
197
|
-
if (!agentSettings.provider || !agentSettings.model) {
|
|
198
|
-
console.error(`Error: 'provider' and 'model' must be set in ${join(stateDir, "settings.json")}`);
|
|
199
|
-
process.exit(1);
|
|
200
|
-
}
|
|
174
|
+
ensureSecureStateDir(stateDir);
|
|
201
175
|
// Validate platform tokens
|
|
202
176
|
const hasSlack = !!(MOM_SLACK_APP_TOKEN && MOM_SLACK_BOT_TOKEN);
|
|
203
177
|
const hasTelegram = !!MOM_TELEGRAM_BOT_TOKEN;
|
|
@@ -218,36 +192,22 @@ catch (error) {
|
|
|
218
192
|
const vaultManager = new FileVaultManager(stateDir);
|
|
219
193
|
if (vaultManager.isEnabled()) {
|
|
220
194
|
console.log(sandbox.type === "container"
|
|
221
|
-
? " Vault system enabled.
|
|
222
|
-
: "
|
|
195
|
+
? " Vault system enabled. Container vault active."
|
|
196
|
+
: sandbox.type === "image" || sandbox.type === "firecracker"
|
|
197
|
+
? " Vault system enabled. Per-user credential routing active."
|
|
198
|
+
: " Vault system enabled. Host mode will not inject vault env.");
|
|
223
199
|
}
|
|
224
200
|
const bindingStore = new FileUserBindingStore(stateDir);
|
|
225
201
|
if (bindingStore.isEnabled()) {
|
|
226
202
|
console.log(sandbox.type === "container"
|
|
227
|
-
? " Binding store enabled.
|
|
228
|
-
:
|
|
203
|
+
? " Binding store enabled. Container mode uses the container vault."
|
|
204
|
+
: sandbox.type === "image" || sandbox.type === "firecracker"
|
|
205
|
+
? " Binding store enabled. Platform user → vault routing active."
|
|
206
|
+
: " Binding store enabled. Host mode will not inject vault env.");
|
|
229
207
|
}
|
|
230
208
|
const provisioner = sandbox.type === "image" ? new DockerContainerManager(sandbox.image, workingDir) : undefined;
|
|
231
209
|
const linkTokenStore = new InMemoryLinkTokenStore();
|
|
232
|
-
// Purge expired link tokens every 5 minutes
|
|
233
210
|
setInterval(() => linkTokenStore.purge(), 5 * 60 * 1000).unref();
|
|
234
|
-
const conversationStates = new Map();
|
|
235
|
-
/** Track in-flight runs for graceful shutdown */
|
|
236
|
-
const inFlightRuns = new Set();
|
|
237
|
-
/** Flag to stop accepting new events during shutdown */
|
|
238
|
-
let isShuttingDown = false;
|
|
239
|
-
/** Maximum number of cached sessions */
|
|
240
|
-
const MAX_SESSIONS = 500;
|
|
241
|
-
/** Idle timeout before a non-running session can be evicted (10 minutes) */
|
|
242
|
-
const IDLE_TIMEOUT_MS = 600000;
|
|
243
|
-
if (provisioner) {
|
|
244
|
-
await provisioner.reconcile();
|
|
245
|
-
await provisioner.stopIdle(IDLE_TIMEOUT_MS);
|
|
246
|
-
}
|
|
247
|
-
// Stop idle containers every hour (same cadence as session eviction)
|
|
248
|
-
if (provisioner) {
|
|
249
|
-
setInterval(() => provisioner.stopIdle(IDLE_TIMEOUT_MS), IDLE_TIMEOUT_MS).unref();
|
|
250
|
-
}
|
|
251
211
|
function normalizeLoginBaseUrl() {
|
|
252
212
|
if (MOM_LINK_URL) {
|
|
253
213
|
return MOM_LINK_URL.replace(/\/+$/, "");
|
|
@@ -257,14 +217,67 @@ function normalizeLoginBaseUrl() {
|
|
|
257
217
|
}
|
|
258
218
|
return undefined;
|
|
259
219
|
}
|
|
220
|
+
function isPrivateConversation(event) {
|
|
221
|
+
return (event.conversationKind === "direct" ||
|
|
222
|
+
event.type === "dm" ||
|
|
223
|
+
event.sessionKey === event.conversationId);
|
|
224
|
+
}
|
|
260
225
|
function ensureLoginVault(platform, platformUserId) {
|
|
261
226
|
const vaultId = resolveActorVaultKey(sandbox, vaultManager, bindingStore, platform, platformUserId);
|
|
262
227
|
ensureSandboxVaultEntry(sandbox, vaultManager, platform, platformUserId, vaultId);
|
|
263
|
-
if (sandbox.type !== "
|
|
264
|
-
vaultManager.addEntry(vaultId, createManagedVaultEntry(platform, platformUserId, vaultId
|
|
228
|
+
if (sandbox.type !== "container" && sandbox.type !== "image") {
|
|
229
|
+
vaultManager.addEntry(vaultId, createManagedVaultEntry(platform, platformUserId, vaultId));
|
|
265
230
|
}
|
|
266
231
|
return vaultId;
|
|
267
232
|
}
|
|
233
|
+
async function replyWithContext(responseCtx, text) {
|
|
234
|
+
await responseCtx.setTyping(false);
|
|
235
|
+
await responseCtx.setWorking(false);
|
|
236
|
+
await responseCtx.respond(text);
|
|
237
|
+
}
|
|
238
|
+
async function handleLoginCommand(platform, platformUserId, conversationId, responseCtx, commandText, privateConversation) {
|
|
239
|
+
const parsed = parseLoginCommand(commandText);
|
|
240
|
+
if (!parsed)
|
|
241
|
+
return false;
|
|
242
|
+
if (!privateConversation) {
|
|
243
|
+
await replyWithContext(responseCtx, "為了保護你的憑證,`/login` 只能在與機器人的私訊中使用。請先私訊機器人,再重新執行 `/login`。");
|
|
244
|
+
return true;
|
|
245
|
+
}
|
|
246
|
+
const baseUrl = normalizeLoginBaseUrl();
|
|
247
|
+
if (!baseUrl) {
|
|
248
|
+
await replyWithContext(responseCtx, "Login is not configured. Set `MOM_LINK_URL` or `MOM_LINK_PORT` on the server.");
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
let vaultId;
|
|
252
|
+
try {
|
|
253
|
+
vaultId = ensureLoginVault(platform, platformUserId);
|
|
254
|
+
}
|
|
255
|
+
catch (error) {
|
|
256
|
+
log.logWarning(`[${conversationId}] Failed to prepare login vault for ${platform}/${platformUserId}`, error instanceof Error ? error.message : String(error));
|
|
257
|
+
await replyWithContext(responseCtx, "Login setup failed on the server. 請稍後重試,或聯絡管理員檢查 vault 儲存權限。");
|
|
258
|
+
return true;
|
|
259
|
+
}
|
|
260
|
+
const token = linkTokenStore.create(platform, platformUserId, conversationId, vaultId, "");
|
|
261
|
+
const vaultLabel = sandbox.type === "container" ? `container vault (${vaultId})` : "your vault";
|
|
262
|
+
await replyWithContext(responseCtx, `Open this link to store credentials in ${vaultLabel} (expires in 15 minutes):\n${baseUrl}/link?token=${token.token}`);
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
const conversationStates = new Map();
|
|
266
|
+
/** Track in-flight runs for graceful shutdown */
|
|
267
|
+
const inFlightRuns = new Set();
|
|
268
|
+
/** Flag to stop accepting new events during shutdown */
|
|
269
|
+
let isShuttingDown = false;
|
|
270
|
+
/** Maximum number of cached sessions */
|
|
271
|
+
const MAX_SESSIONS = 500;
|
|
272
|
+
/** Idle timeout before a non-running session can be evicted (1 hour) */
|
|
273
|
+
const IDLE_TIMEOUT_MS = 3600000;
|
|
274
|
+
/** Idle timeout for managed image containers (10 minutes) */
|
|
275
|
+
const IMAGE_IDLE_TIMEOUT_MS = 10 * 60 * 1000;
|
|
276
|
+
if (provisioner) {
|
|
277
|
+
await provisioner.reconcile();
|
|
278
|
+
await provisioner.stopIdle(IMAGE_IDLE_TIMEOUT_MS);
|
|
279
|
+
setInterval(() => provisioner.stopIdle(IMAGE_IDLE_TIMEOUT_MS), IMAGE_IDLE_TIMEOUT_MS).unref();
|
|
280
|
+
}
|
|
268
281
|
async function getState(conversationId, sessionKey) {
|
|
269
282
|
const key = sessionKey ?? conversationId;
|
|
270
283
|
let state = conversationStates.get(key);
|
|
@@ -272,7 +285,7 @@ async function getState(conversationId, sessionKey) {
|
|
|
272
285
|
const conversationDir = join(workingDir, conversationId);
|
|
273
286
|
state = {
|
|
274
287
|
running: false,
|
|
275
|
-
runner: await createRunner(sandbox, key, conversationId, conversationDir, workingDir, vaultManager, bindingStore, provisioner
|
|
288
|
+
runner: await createRunner(sandbox, key, conversationId, conversationDir, workingDir, vaultManager, bindingStore, provisioner),
|
|
276
289
|
stopRequested: false,
|
|
277
290
|
lastAccessedAt: Date.now(),
|
|
278
291
|
};
|
|
@@ -359,7 +372,7 @@ const handler = {
|
|
|
359
372
|
state.stopRequested = true;
|
|
360
373
|
state.runner.abort();
|
|
361
374
|
}
|
|
362
|
-
//
|
|
375
|
+
// Conversation sessions rotate via current pointer. Thread sessions reset in place.
|
|
363
376
|
const conversationDir = join(workingDir, conversationId);
|
|
364
377
|
if (sessionKey.includes(":")) {
|
|
365
378
|
createManagedSessionFileAtPath(getThreadSessionFile(conversationDir, sessionKey), conversationDir);
|
|
@@ -372,62 +385,34 @@ const handler = {
|
|
|
372
385
|
log.logInfo(`[${conversationId}] Session reset: ${sessionKey}`);
|
|
373
386
|
await bot.postMessage(conversationId, "Conversation reset. Send a new message to start fresh.");
|
|
374
387
|
},
|
|
375
|
-
async handleLogin(platform, platformUserId, conversationId, bot, commandText, isPrivateConversation) {
|
|
376
|
-
const parsed = parseLoginCommand(commandText);
|
|
377
|
-
if (!parsed) {
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
if (!isPrivateConversation) {
|
|
381
|
-
await bot.postMessage(conversationId, "为了保护你的凭证,`/login` 只能在与机器人的私聊中使用。请先私信机器人,再重新执行 `/login`。");
|
|
382
|
-
return;
|
|
383
|
-
}
|
|
384
|
-
const baseUrl = normalizeLoginBaseUrl();
|
|
385
|
-
if (!baseUrl) {
|
|
386
|
-
await bot.postMessage(conversationId, "Login is not configured. Set `MOM_LINK_URL` or `MOM_LINK_PORT` on the server.");
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
let vaultId;
|
|
390
|
-
try {
|
|
391
|
-
vaultId = ensureLoginVault(platform, platformUserId);
|
|
392
|
-
}
|
|
393
|
-
catch (error) {
|
|
394
|
-
log.logWarning(`[${conversationId}] Failed to prepare login vault for ${platform}/${platformUserId}`, error instanceof Error ? error.message : String(error));
|
|
395
|
-
await bot.postMessage(conversationId, "Login setup failed on the server. 请稍后重试,或联系管理员检查 vault 存储权限。");
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
const loginLabel = "credential";
|
|
399
|
-
const vaultLabel = sandbox.type === "container" ? "the shared container vault" : "your vault";
|
|
400
|
-
await bot.postMessage(conversationId, `Open this link to store ${loginLabel} in ${vaultLabel} ` +
|
|
401
|
-
`(expires in 15 minutes):\n${baseUrl}/link?token=${linkTokenStore.create(platform, platformUserId, conversationId, vaultId, "").token}`);
|
|
402
|
-
},
|
|
403
388
|
async handleEvent(event, bot, adapters, _isEvent) {
|
|
389
|
+
const conversationId = event.conversationId;
|
|
404
390
|
// Don't accept new events during shutdown
|
|
405
391
|
if (isShuttingDown) {
|
|
406
|
-
log.logInfo(`[${
|
|
392
|
+
log.logInfo(`[${conversationId}] Rejected event during shutdown: ${event.text.substring(0, 50)}`);
|
|
407
393
|
return;
|
|
408
394
|
}
|
|
409
|
-
const sessionKey = event.sessionKey ?? `${
|
|
410
|
-
const
|
|
395
|
+
const sessionKey = event.sessionKey ?? `${conversationId}:${event.thread_ts ?? event.ts}`;
|
|
396
|
+
const handledLogin = await handleLoginCommand(adapters.platform.name, event.user, conversationId, adapters.responseCtx, event.text, isPrivateConversation(event));
|
|
397
|
+
if (handledLogin)
|
|
398
|
+
return;
|
|
399
|
+
const state = await getState(conversationId, sessionKey);
|
|
411
400
|
// Start run
|
|
412
401
|
state.running = true;
|
|
413
402
|
state.stopRequested = false;
|
|
414
403
|
state.startedAt = Date.now();
|
|
415
404
|
state.lastActivityAt = Date.now();
|
|
416
|
-
log.logInfo(`[${
|
|
405
|
+
log.logInfo(`[${conversationId}] Starting run: ${event.text.substring(0, 50)}`);
|
|
417
406
|
// Wrap in-flight run tracking
|
|
418
407
|
Sentry.metrics.count("agent.run.started", 1, {
|
|
419
|
-
attributes: { channel:
|
|
408
|
+
attributes: { channel: conversationId },
|
|
420
409
|
});
|
|
421
410
|
Sentry.metrics.gauge("agent.sessions.active", inFlightRuns.size + 1);
|
|
422
|
-
const runPromise = Sentry.startSpan({
|
|
423
|
-
name: "agent.run",
|
|
424
|
-
op: "agent",
|
|
425
|
-
attributes: { channelId: event.conversationId, sessionKey },
|
|
426
|
-
}, async () => {
|
|
411
|
+
const runPromise = Sentry.startSpan({ name: "agent.run", op: "agent", attributes: { conversationId, sessionKey } }, async () => {
|
|
427
412
|
return Sentry.withScope(async (scope) => {
|
|
428
413
|
const { message, responseCtx, platform } = adapters;
|
|
429
414
|
applyRunScope(scope, {
|
|
430
|
-
conversationId
|
|
415
|
+
conversationId,
|
|
431
416
|
sessionKey,
|
|
432
417
|
messageId: message.id,
|
|
433
418
|
platform: platform.name,
|
|
@@ -437,7 +422,7 @@ const handler = {
|
|
|
437
422
|
isEvent: _isEvent,
|
|
438
423
|
});
|
|
439
424
|
addLifecycleBreadcrumb("agent.run.started", {
|
|
440
|
-
channel_id:
|
|
425
|
+
channel_id: conversationId,
|
|
441
426
|
platform: platform.name,
|
|
442
427
|
has_attachments: (message.attachments?.length ?? 0) > 0,
|
|
443
428
|
});
|
|
@@ -450,37 +435,37 @@ const handler = {
|
|
|
450
435
|
Sentry.metrics.distribution("agent.run.duration", durationMs, {
|
|
451
436
|
unit: "millisecond",
|
|
452
437
|
attributes: {
|
|
453
|
-
channel:
|
|
438
|
+
channel: conversationId,
|
|
454
439
|
platform: platform.name,
|
|
455
440
|
stop_reason: result.stopReason,
|
|
456
441
|
},
|
|
457
442
|
});
|
|
458
443
|
Sentry.metrics.count("agent.run.completed", 1, {
|
|
459
444
|
attributes: {
|
|
460
|
-
channel:
|
|
445
|
+
channel: conversationId,
|
|
461
446
|
platform: platform.name,
|
|
462
447
|
stop_reason: result.stopReason,
|
|
463
448
|
},
|
|
464
449
|
});
|
|
465
450
|
addLifecycleBreadcrumb("agent.run.completed", {
|
|
466
|
-
channel_id:
|
|
451
|
+
channel_id: conversationId,
|
|
467
452
|
platform: platform.name,
|
|
468
453
|
stop_reason: result.stopReason,
|
|
469
454
|
duration_ms: durationMs,
|
|
470
455
|
});
|
|
471
456
|
if (result.stopReason === "aborted" && state.stopRequested) {
|
|
472
457
|
if (state.stopMessageTs) {
|
|
473
|
-
await bot.updateMessage(
|
|
458
|
+
await bot.updateMessage(conversationId, state.stopMessageTs, formatStopped(bot));
|
|
474
459
|
state.stopMessageTs = undefined;
|
|
475
460
|
}
|
|
476
461
|
else {
|
|
477
|
-
await bot.postMessage(
|
|
462
|
+
await bot.postMessage(conversationId, formatStopped(bot));
|
|
478
463
|
}
|
|
479
464
|
}
|
|
480
465
|
}
|
|
481
466
|
catch (err) {
|
|
482
467
|
scope.setContext("agent_run_error", {
|
|
483
|
-
conversationId
|
|
468
|
+
conversationId,
|
|
484
469
|
sessionKey,
|
|
485
470
|
platform: adapters.platform.name,
|
|
486
471
|
messageId: adapters.message.id,
|
|
@@ -488,9 +473,9 @@ const handler = {
|
|
|
488
473
|
});
|
|
489
474
|
Sentry.captureException(err);
|
|
490
475
|
Sentry.metrics.count("agent.run.errors", 1, {
|
|
491
|
-
attributes: { channel:
|
|
476
|
+
attributes: { channel: conversationId, platform: adapters.platform.name },
|
|
492
477
|
});
|
|
493
|
-
log.logWarning(`[${
|
|
478
|
+
log.logWarning(`[${conversationId}] Run error`, err instanceof Error ? err.message : String(err));
|
|
494
479
|
}
|
|
495
480
|
finally {
|
|
496
481
|
state.running = false;
|
|
@@ -520,14 +505,6 @@ const sandboxDesc = sandbox.type === "host"
|
|
|
520
505
|
? `image:${sandbox.image}`
|
|
521
506
|
: `firecracker:${sandbox.vmId}`;
|
|
522
507
|
log.logStartup(workingDir, sandboxDesc);
|
|
523
|
-
// Start link callback server if port is configured
|
|
524
|
-
if (MOM_LINK_PORT) {
|
|
525
|
-
startLinkServer(MOM_LINK_PORT, linkTokenStore, vaultManager, async (platform, conversationId, msg) => {
|
|
526
|
-
const bot = botsByPlatform[platform];
|
|
527
|
-
if (bot)
|
|
528
|
-
await bot.postMessage(conversationId, msg);
|
|
529
|
-
});
|
|
530
|
-
}
|
|
531
508
|
// Create platform bots
|
|
532
509
|
const bots = [];
|
|
533
510
|
const botsByPlatform = {};
|
|
@@ -561,6 +538,13 @@ if (hasDiscord) {
|
|
|
561
538
|
botsByPlatform.discord = discordBot;
|
|
562
539
|
log.logInfo("Platform: Discord");
|
|
563
540
|
}
|
|
541
|
+
if (MOM_LINK_PORT) {
|
|
542
|
+
startLinkServer(MOM_LINK_PORT, linkTokenStore, vaultManager, async (platform, conversationId, message) => {
|
|
543
|
+
const bot = botsByPlatform[platform];
|
|
544
|
+
if (bot)
|
|
545
|
+
await bot.postMessage(conversationId, message);
|
|
546
|
+
});
|
|
547
|
+
}
|
|
564
548
|
// Start events watcher with explicit platform routing
|
|
565
549
|
const eventsWatcher = createEventsWatcher(workingDir, botsByPlatform);
|
|
566
550
|
const slackBot = botsByPlatform.slack;
|