@geminixiang/mama 0.2.0-beta.0 → 0.2.0-beta.1

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.
Files changed (148) hide show
  1. package/README.md +59 -19
  2. package/dist/adapter.d.ts +9 -7
  3. package/dist/adapter.d.ts.map +1 -1
  4. package/dist/adapter.js.map +1 -1
  5. package/dist/adapters/discord/bot.d.ts +2 -2
  6. package/dist/adapters/discord/bot.d.ts.map +1 -1
  7. package/dist/adapters/discord/bot.js +33 -21
  8. package/dist/adapters/discord/bot.js.map +1 -1
  9. package/dist/adapters/discord/context.d.ts.map +1 -1
  10. package/dist/adapters/discord/context.js +20 -13
  11. package/dist/adapters/discord/context.js.map +1 -1
  12. package/dist/adapters/slack/bot.d.ts +4 -3
  13. package/dist/adapters/slack/bot.d.ts.map +1 -1
  14. package/dist/adapters/slack/bot.js +68 -30
  15. package/dist/adapters/slack/bot.js.map +1 -1
  16. package/dist/adapters/slack/context.d.ts.map +1 -1
  17. package/dist/adapters/slack/context.js +22 -12
  18. package/dist/adapters/slack/context.js.map +1 -1
  19. package/dist/adapters/telegram/bot.d.ts +2 -2
  20. package/dist/adapters/telegram/bot.d.ts.map +1 -1
  21. package/dist/adapters/telegram/bot.js +54 -33
  22. package/dist/adapters/telegram/bot.js.map +1 -1
  23. package/dist/adapters/telegram/context.d.ts.map +1 -1
  24. package/dist/adapters/telegram/context.js +61 -10
  25. package/dist/adapters/telegram/context.js.map +1 -1
  26. package/dist/agent.d.ts +7 -4
  27. package/dist/agent.d.ts.map +1 -1
  28. package/dist/agent.js +152 -96
  29. package/dist/agent.js.map +1 -1
  30. package/dist/bindings.d.ts +63 -0
  31. package/dist/bindings.d.ts.map +1 -0
  32. package/dist/bindings.js +94 -0
  33. package/dist/bindings.js.map +1 -0
  34. package/dist/config.d.ts +32 -5
  35. package/dist/config.d.ts.map +1 -1
  36. package/dist/config.js +71 -44
  37. package/dist/config.js.map +1 -1
  38. package/dist/context.d.ts +6 -6
  39. package/dist/context.d.ts.map +1 -1
  40. package/dist/context.js +8 -8
  41. package/dist/context.js.map +1 -1
  42. package/dist/events.d.ts +4 -0
  43. package/dist/events.d.ts.map +1 -1
  44. package/dist/events.js +20 -5
  45. package/dist/events.js.map +1 -1
  46. package/dist/execution-resolver.d.ts +20 -0
  47. package/dist/execution-resolver.d.ts.map +1 -0
  48. package/dist/execution-resolver.js +51 -0
  49. package/dist/execution-resolver.js.map +1 -0
  50. package/dist/instrument.d.ts.map +1 -1
  51. package/dist/instrument.js +11 -4
  52. package/dist/instrument.js.map +1 -1
  53. package/dist/link-server.d.ts +16 -0
  54. package/dist/link-server.d.ts.map +1 -0
  55. package/dist/link-server.js +839 -0
  56. package/dist/link-server.js.map +1 -0
  57. package/dist/link-token.d.ts +32 -0
  58. package/dist/link-token.d.ts.map +1 -0
  59. package/dist/link-token.js +68 -0
  60. package/dist/link-token.js.map +1 -0
  61. package/dist/log.d.ts +2 -2
  62. package/dist/log.d.ts.map +1 -1
  63. package/dist/log.js +7 -7
  64. package/dist/log.js.map +1 -1
  65. package/dist/login.d.ts +29 -0
  66. package/dist/login.d.ts.map +1 -0
  67. package/dist/login.js +164 -0
  68. package/dist/login.js.map +1 -0
  69. package/dist/main.d.ts.map +1 -1
  70. package/dist/main.js +243 -56
  71. package/dist/main.js.map +1 -1
  72. package/dist/provisioner.d.ts +93 -0
  73. package/dist/provisioner.d.ts.map +1 -0
  74. package/dist/provisioner.js +336 -0
  75. package/dist/provisioner.js.map +1 -0
  76. package/dist/sandbox/container.d.ts +15 -0
  77. package/dist/sandbox/container.d.ts.map +1 -0
  78. package/dist/sandbox/container.js +122 -0
  79. package/dist/sandbox/container.js.map +1 -0
  80. package/dist/sandbox/errors.d.ts +6 -0
  81. package/dist/sandbox/errors.d.ts.map +1 -0
  82. package/dist/sandbox/errors.js +11 -0
  83. package/dist/sandbox/errors.js.map +1 -0
  84. package/dist/sandbox/firecracker.d.ts +16 -0
  85. package/dist/sandbox/firecracker.d.ts.map +1 -0
  86. package/dist/sandbox/firecracker.js +206 -0
  87. package/dist/sandbox/firecracker.js.map +1 -0
  88. package/dist/sandbox/host.d.ts +12 -0
  89. package/dist/sandbox/host.d.ts.map +1 -0
  90. package/dist/sandbox/host.js +89 -0
  91. package/dist/sandbox/host.js.map +1 -0
  92. package/dist/sandbox/image.d.ts +5 -0
  93. package/dist/sandbox/image.d.ts.map +1 -0
  94. package/dist/sandbox/image.js +30 -0
  95. package/dist/sandbox/image.js.map +1 -0
  96. package/dist/sandbox/index.d.ts +20 -0
  97. package/dist/sandbox/index.d.ts.map +1 -0
  98. package/dist/sandbox/index.js +51 -0
  99. package/dist/sandbox/index.js.map +1 -0
  100. package/dist/sandbox/types.d.ts +51 -0
  101. package/dist/sandbox/types.d.ts.map +1 -0
  102. package/dist/sandbox/types.js +2 -0
  103. package/dist/sandbox/types.js.map +1 -0
  104. package/dist/sandbox/utils.d.ts +4 -0
  105. package/dist/sandbox/utils.d.ts.map +1 -0
  106. package/dist/sandbox/utils.js +51 -0
  107. package/dist/sandbox/utils.js.map +1 -0
  108. package/dist/sandbox.d.ts +1 -39
  109. package/dist/sandbox.d.ts.map +1 -1
  110. package/dist/sandbox.js +1 -286
  111. package/dist/sandbox.js.map +1 -1
  112. package/dist/sentry.d.ts +1 -1
  113. package/dist/sentry.d.ts.map +1 -1
  114. package/dist/sentry.js +2 -2
  115. package/dist/sentry.js.map +1 -1
  116. package/dist/session-store.d.ts +1 -5
  117. package/dist/session-store.d.ts.map +1 -1
  118. package/dist/session-store.js +7 -10
  119. package/dist/session-store.js.map +1 -1
  120. package/dist/store.d.ts +1 -1
  121. package/dist/store.d.ts.map +1 -1
  122. package/dist/store.js +8 -8
  123. package/dist/store.js.map +1 -1
  124. package/dist/tools/event.d.ts +21 -0
  125. package/dist/tools/event.d.ts.map +1 -0
  126. package/dist/tools/event.js +103 -0
  127. package/dist/tools/event.js.map +1 -0
  128. package/dist/tools/index.d.ts +6 -1
  129. package/dist/tools/index.d.ts.map +1 -1
  130. package/dist/tools/index.js +5 -1
  131. package/dist/tools/index.js.map +1 -1
  132. package/dist/ui-copy.d.ts +11 -0
  133. package/dist/ui-copy.d.ts.map +1 -0
  134. package/dist/ui-copy.js +33 -0
  135. package/dist/ui-copy.js.map +1 -0
  136. package/dist/vault-routing.d.ts +10 -0
  137. package/dist/vault-routing.d.ts.map +1 -0
  138. package/dist/vault-routing.js +58 -0
  139. package/dist/vault-routing.js.map +1 -0
  140. package/dist/vault.d.ts +106 -0
  141. package/dist/vault.d.ts.map +1 -0
  142. package/dist/vault.js +389 -0
  143. package/dist/vault.js.map +1 -0
  144. package/dist/vault.test.d.ts +2 -0
  145. package/dist/vault.test.d.ts.map +1 -0
  146. package/dist/vault.test.js +67 -0
  147. package/dist/vault.test.js.map +1 -0
  148. package/package.json +12 -11
package/dist/main.js CHANGED
@@ -1,18 +1,28 @@
1
1
  #!/usr/bin/env node
2
2
  import "./instrument.js";
3
3
  import { join, resolve } from "path";
4
- import { readFileSync } from "fs";
4
+ import { homedir } from "os";
5
+ import { mkdirSync, readFileSync, statSync } from "fs";
5
6
  import { fileURLToPath } from "url";
6
7
  import { dirname, join as pathJoin } from "path";
7
8
  import { DiscordBot } from "./adapters/discord/index.js";
8
9
  import { TelegramBot } from "./adapters/telegram/index.js";
9
10
  import { SlackBot as SlackBotClass } from "./adapters/slack/index.js";
10
11
  import { createRunner } from "./agent.js";
11
- import { createManagedSessionFile, createManagedSessionFileAtPath, getSessionDir, getThreadSessionFile, } from "./session-store.js";
12
+ import { createManagedSessionFile, createManagedSessionFileAtPath, getChannelSessionDir, getThreadSessionFile, } from "./session-store.js";
12
13
  import { downloadChannel } from "./download.js";
13
14
  import { createEventsWatcher } from "./events.js";
14
15
  import * as log from "./log.js";
15
- import { parseSandboxArg, validateSandbox } from "./sandbox.js";
16
+ import { FileUserBindingStore } from "./bindings.js";
17
+ import { startLinkServer } from "./link-server.js";
18
+ import { parseLoginCommand } from "./login.js";
19
+ import { InMemoryLinkTokenStore } from "./link-token.js";
20
+ import { DockerContainerManager } from "./provisioner.js";
21
+ import { SandboxError, parseSandboxArg, validateSandbox } from "./sandbox.js";
22
+ import { formatNothingRunning, formatStopping } from "./ui-copy.js";
23
+ import { FileVaultManager } from "./vault.js";
24
+ import { ensureSettingsFile } from "./config.js";
25
+ import { createManagedVaultEntry, ensureSandboxVaultEntry, resolveActorVaultKey, } from "./vault-routing.js";
16
26
  import { addLifecycleBreadcrumb, applyRunScope } from "./sentry.js";
17
27
  import { ChannelStore } from "./store.js";
18
28
  import * as Sentry from "@sentry/node";
@@ -43,10 +53,23 @@ const MOM_SLACK_APP_TOKEN = process.env.MOM_SLACK_APP_TOKEN;
43
53
  const MOM_SLACK_BOT_TOKEN = process.env.MOM_SLACK_BOT_TOKEN;
44
54
  const MOM_TELEGRAM_BOT_TOKEN = process.env.MOM_TELEGRAM_BOT_TOKEN;
45
55
  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
+ 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
+ const MOM_LINK_PORT = process.env.MOM_LINK_PORT
64
+ ? parseInt(process.env.MOM_LINK_PORT, 10)
65
+ : MOM_LINK_URL
66
+ ? 8181
67
+ : undefined;
46
68
  function parseArgs() {
47
69
  const args = process.argv.slice(2);
48
70
  let sandbox = { type: "host" };
49
71
  let workingDir;
72
+ let stateDirArg;
50
73
  let downloadChannelId;
51
74
  let showVersion = false;
52
75
  for (let i = 0; i < args.length; i++) {
@@ -60,6 +83,12 @@ function parseArgs() {
60
83
  else if (arg === "--sandbox") {
61
84
  sandbox = parseSandboxArg(args[++i] || "");
62
85
  }
86
+ else if (arg.startsWith("--state-dir=")) {
87
+ stateDirArg = arg.slice("--state-dir=".length);
88
+ }
89
+ else if (arg === "--state-dir") {
90
+ stateDirArg = args[++i];
91
+ }
63
92
  else if (arg.startsWith("--download=")) {
64
93
  downloadChannelId = arg.slice("--download=".length);
65
94
  }
@@ -72,12 +101,66 @@ function parseArgs() {
72
101
  }
73
102
  return {
74
103
  workingDir: workingDir ? resolve(workingDir) : undefined,
104
+ stateDir: stateDirArg ? resolve(stateDirArg) : undefined,
75
105
  sandbox,
76
106
  downloadChannel: downloadChannelId,
77
107
  showVersion,
78
108
  };
79
109
  }
80
- const parsedArgs = parseArgs();
110
+ 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
+ function ensureSecureStateDir(path) {
118
+ let stat;
119
+ try {
120
+ stat = statSync(path);
121
+ }
122
+ catch (err) {
123
+ const code = err.code;
124
+ if (code === "ENOENT") {
125
+ mkdirSync(path, { recursive: true, mode: 0o700 });
126
+ return;
127
+ }
128
+ console.error(`Error: cannot access --state-dir ${path}: ${err.message}`);
129
+ process.exit(1);
130
+ }
131
+ if (!stat.isDirectory()) {
132
+ console.error(`Error: --state-dir ${path} exists but is not a directory`);
133
+ process.exit(1);
134
+ }
135
+ if (stat.mode & WORLD_WRITABLE_MODE) {
136
+ console.error(`Error: --state-dir ${path} is world-writable (mode ${(stat.mode & 0o777).toString(8)}). ` +
137
+ `Credentials stored there would be exposed to other local users. ` +
138
+ `Fix with: chmod 0700 ${path}`);
139
+ process.exit(1);
140
+ }
141
+ const euid = typeof process.geteuid === "function" ? process.geteuid() : undefined;
142
+ if (euid !== undefined && stat.uid !== euid) {
143
+ console.error(`Error: --state-dir ${path} is owned by uid ${stat.uid} but mama is running as uid ${euid}. ` +
144
+ `Run mama as the directory owner or point --state-dir at a directory you own.`);
145
+ process.exit(1);
146
+ }
147
+ }
148
+ function handleStartupError(error) {
149
+ if (error instanceof SandboxError) {
150
+ for (const line of error.formatForCli()) {
151
+ console.error(line);
152
+ }
153
+ process.exit(1);
154
+ }
155
+ throw error;
156
+ }
157
+ let parsedArgs;
158
+ try {
159
+ parsedArgs = parseArgs();
160
+ }
161
+ catch (error) {
162
+ handleStartupError(error);
163
+ }
81
164
  // Handle --version
82
165
  if (parsedArgs.showVersion) {
83
166
  console.log(getVersion());
@@ -94,11 +177,27 @@ if (parsedArgs.downloadChannel) {
94
177
  }
95
178
  // Normal bot mode - require working dir
96
179
  if (!parsedArgs.workingDir) {
97
- console.error("Usage: mama [--sandbox=host|docker:<name>|firecracker:<vm-id>:<host-path>] <working-directory>");
180
+ console.error("Usage: mama [--sandbox=host|container:<name>|image:<image>|firecracker:<vm-id>:<host-path>] [--state-dir=<path>] <working-directory>");
98
181
  console.error(" mama --download <channel-id>");
99
182
  process.exit(1);
100
183
  }
101
184
  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
+ const stateDir = parsedArgs.stateDir ?? join(homedir(), ".mama");
188
+ ensureSecureStateDir(stateDir);
189
+ // Share stateDir with instrument.ts (for Sentry config loading)
190
+ process.env.MAMA_STATE_DIR = stateDir;
191
+ // Ensure settings.json exists; create a template if first run.
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
+ }
102
201
  // Validate platform tokens
103
202
  const hasSlack = !!(MOM_SLACK_APP_TOKEN && MOM_SLACK_BOT_TOKEN);
104
203
  const hasTelegram = !!MOM_TELEGRAM_BOT_TOKEN;
@@ -110,28 +209,74 @@ if (!hasSlack && !hasTelegram && !hasDiscord) {
110
209
  " Discord: MOM_DISCORD_BOT_TOKEN");
111
210
  process.exit(1);
112
211
  }
113
- await validateSandbox(sandbox);
114
- const channelStates = new Map();
212
+ try {
213
+ await validateSandbox(sandbox);
214
+ }
215
+ catch (error) {
216
+ handleStartupError(error);
217
+ }
218
+ const vaultManager = new FileVaultManager(stateDir);
219
+ if (vaultManager.isEnabled()) {
220
+ console.log(sandbox.type === "container"
221
+ ? " Vault system enabled. Shared container vault active."
222
+ : " Vault system enabled. Per-user credential routing active.");
223
+ }
224
+ const bindingStore = new FileUserBindingStore(stateDir);
225
+ if (bindingStore.isEnabled()) {
226
+ console.log(sandbox.type === "container"
227
+ ? " Binding store enabled. Shared container mode ignores per-user vault bindings."
228
+ : " Binding store enabled. Platform user → vault routing active.");
229
+ }
230
+ const provisioner = sandbox.type === "image" ? new DockerContainerManager(sandbox.image, workingDir) : undefined;
231
+ const linkTokenStore = new InMemoryLinkTokenStore();
232
+ // Purge expired link tokens every 5 minutes
233
+ setInterval(() => linkTokenStore.purge(), 5 * 60 * 1000).unref();
234
+ const conversationStates = new Map();
115
235
  /** Track in-flight runs for graceful shutdown */
116
236
  const inFlightRuns = new Set();
117
237
  /** Flag to stop accepting new events during shutdown */
118
238
  let isShuttingDown = false;
119
239
  /** Maximum number of cached sessions */
120
240
  const MAX_SESSIONS = 500;
121
- /** Idle timeout before a non-running session can be evicted (1 hour) */
122
- const IDLE_TIMEOUT_MS = 3600000;
123
- async function getState(channelId, sessionKey) {
124
- const key = sessionKey ?? channelId;
125
- let state = channelStates.get(key);
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
+ function normalizeLoginBaseUrl() {
252
+ if (MOM_LINK_URL) {
253
+ return MOM_LINK_URL.replace(/\/+$/, "");
254
+ }
255
+ if (MOM_LINK_PORT) {
256
+ return `http://localhost:${MOM_LINK_PORT}`;
257
+ }
258
+ return undefined;
259
+ }
260
+ function ensureLoginVault(platform, platformUserId) {
261
+ const vaultId = resolveActorVaultKey(sandbox, vaultManager, bindingStore, platform, platformUserId);
262
+ ensureSandboxVaultEntry(sandbox, vaultManager, platform, platformUserId, vaultId);
263
+ if (sandbox.type !== "image" && sandbox.type !== "container") {
264
+ vaultManager.addEntry(vaultId, createManagedVaultEntry(platform, platformUserId, vaultId, false));
265
+ }
266
+ return vaultId;
267
+ }
268
+ async function getState(conversationId, sessionKey) {
269
+ const key = sessionKey ?? conversationId;
270
+ let state = conversationStates.get(key);
126
271
  if (!state) {
127
- const channelDir = join(workingDir, channelId);
272
+ const conversationDir = join(workingDir, conversationId);
128
273
  state = {
129
274
  running: false,
130
- runner: await createRunner(sandbox, key, channelId, channelDir, workingDir),
275
+ runner: await createRunner(sandbox, key, conversationId, conversationDir, workingDir, vaultManager, bindingStore, provisioner, stateDir),
131
276
  stopRequested: false,
132
277
  lastAccessedAt: Date.now(),
133
278
  };
134
- channelStates.set(key, state);
279
+ conversationStates.set(key, state);
135
280
  }
136
281
  else {
137
282
  state.lastAccessedAt = Date.now();
@@ -139,27 +284,27 @@ async function getState(channelId, sessionKey) {
139
284
  return state;
140
285
  }
141
286
  /**
142
- * Evict idle sessions from channelStates to bound memory usage.
287
+ * Evict idle sessions from conversationStates to bound memory usage.
143
288
  * Called after each handleEvent completes.
144
289
  */
145
290
  function evictIdleSessions() {
146
291
  const now = Date.now();
147
- for (const [key, state] of channelStates) {
292
+ for (const [key, state] of conversationStates) {
148
293
  if (!state.running && now - state.lastAccessedAt > IDLE_TIMEOUT_MS) {
149
- channelStates.delete(key);
294
+ conversationStates.delete(key);
150
295
  }
151
296
  }
152
- if (channelStates.size > MAX_SESSIONS) {
297
+ if (conversationStates.size > MAX_SESSIONS) {
153
298
  const idleSessions = [];
154
- for (const [key, state] of channelStates) {
299
+ for (const [key, state] of conversationStates) {
155
300
  if (!state.running) {
156
301
  idleSessions.push({ key, lastAccessedAt: state.lastAccessedAt });
157
302
  }
158
303
  }
159
304
  idleSessions.sort((a, b) => a.lastAccessedAt - b.lastAccessedAt);
160
- const toEvict = channelStates.size - MAX_SESSIONS;
305
+ const toEvict = conversationStates.size - MAX_SESSIONS;
161
306
  for (let i = 0; i < toEvict && i < idleSessions.length; i++) {
162
- channelStates.delete(idleSessions[i].key);
307
+ conversationStates.delete(idleSessions[i].key);
163
308
  }
164
309
  }
165
310
  }
@@ -168,12 +313,12 @@ function evictIdleSessions() {
168
313
  // ============================================================================
169
314
  const handler = {
170
315
  isRunning(sessionKey) {
171
- const state = channelStates.get(sessionKey);
316
+ const state = conversationStates.get(sessionKey);
172
317
  return !!state?.running;
173
318
  },
174
319
  getRunningSessions() {
175
320
  const sessions = [];
176
- for (const [sessionKey, state] of channelStates) {
321
+ for (const [sessionKey, state] of conversationStates) {
177
322
  if (state.running && state.startedAt) {
178
323
  // Get current step from runner
179
324
  const currentStep = state.runner.getCurrentStep();
@@ -187,20 +332,20 @@ const handler = {
187
332
  }
188
333
  return sessions;
189
334
  },
190
- async handleStop(sessionKey, channelId, bot) {
191
- const state = channelStates.get(sessionKey);
335
+ async handleStop(sessionKey, conversationId, bot) {
336
+ const state = conversationStates.get(sessionKey);
192
337
  if (state?.running) {
193
338
  state.stopRequested = true;
194
339
  state.runner.abort();
195
- const ts = await bot.postMessage(channelId, "_Stopping..._");
340
+ const ts = await bot.postMessage(conversationId, formatStopping(bot));
196
341
  state.stopMessageTs = ts;
197
342
  }
198
343
  else {
199
- await bot.postMessage(channelId, "_Nothing running_");
344
+ await bot.postMessage(conversationId, formatNothingRunning(bot));
200
345
  }
201
346
  },
202
347
  forceStop(sessionKey) {
203
- const state = channelStates.get(sessionKey);
348
+ const state = conversationStates.get(sessionKey);
204
349
  if (state?.running) {
205
350
  log.logInfo(`[Force Stop] Force stopping session: ${sessionKey}`);
206
351
  state.stopRequested = true;
@@ -208,49 +353,81 @@ const handler = {
208
353
  state.running = false;
209
354
  }
210
355
  },
211
- async handleNew(sessionKey, channelId, bot) {
212
- const state = channelStates.get(sessionKey);
356
+ async handleNew(sessionKey, conversationId, bot) {
357
+ const state = conversationStates.get(sessionKey);
213
358
  if (state?.running) {
214
359
  state.stopRequested = true;
215
360
  state.runner.abort();
216
361
  }
217
362
  // Channel sessions rotate via current pointer. Thread sessions reset in place.
218
- const channelDir = join(workingDir, channelId);
363
+ const conversationDir = join(workingDir, conversationId);
219
364
  if (sessionKey.includes(":")) {
220
- createManagedSessionFileAtPath(getThreadSessionFile(channelDir, sessionKey), channelDir);
365
+ createManagedSessionFileAtPath(getThreadSessionFile(conversationDir, sessionKey), conversationDir);
221
366
  }
222
367
  else {
223
- createManagedSessionFile(getSessionDir(channelDir, sessionKey), channelDir);
368
+ createManagedSessionFile(getChannelSessionDir(conversationDir), conversationDir);
224
369
  }
225
370
  // Remove from in-memory cache
226
- channelStates.delete(sessionKey);
227
- log.logInfo(`[${channelId}] Session reset: ${sessionKey}`);
228
- await bot.postMessage(channelId, "Conversation reset. Send a new message to start fresh.");
371
+ conversationStates.delete(sessionKey);
372
+ log.logInfo(`[${conversationId}] Session reset: ${sessionKey}`);
373
+ await bot.postMessage(conversationId, "Conversation reset. Send a new message to start fresh.");
374
+ },
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}`);
229
402
  },
230
403
  async handleEvent(event, bot, adapters, _isEvent) {
231
404
  // Don't accept new events during shutdown
232
405
  if (isShuttingDown) {
233
- log.logInfo(`[${event.channel}] Rejected event during shutdown: ${event.text.substring(0, 50)}`);
406
+ log.logInfo(`[${event.conversationId}] Rejected event during shutdown: ${event.text.substring(0, 50)}`);
234
407
  return;
235
408
  }
236
- const sessionKey = event.sessionKey ?? `${event.channel}:${event.thread_ts ?? event.ts}`;
237
- const state = await getState(event.channel, sessionKey);
409
+ const sessionKey = event.sessionKey ?? `${event.conversationId}:${event.thread_ts ?? event.ts}`;
410
+ const state = await getState(event.conversationId, sessionKey);
238
411
  // Start run
239
412
  state.running = true;
240
413
  state.stopRequested = false;
241
414
  state.startedAt = Date.now();
242
415
  state.lastActivityAt = Date.now();
243
- log.logInfo(`[${event.channel}] Starting run: ${event.text.substring(0, 50)}`);
416
+ log.logInfo(`[${event.conversationId}] Starting run: ${event.text.substring(0, 50)}`);
244
417
  // Wrap in-flight run tracking
245
418
  Sentry.metrics.count("agent.run.started", 1, {
246
- attributes: { channel: event.channel },
419
+ attributes: { channel: event.conversationId },
247
420
  });
248
421
  Sentry.metrics.gauge("agent.sessions.active", inFlightRuns.size + 1);
249
- const runPromise = Sentry.startSpan({ name: "agent.run", op: "agent", attributes: { channelId: event.channel, sessionKey } }, async () => {
422
+ const runPromise = Sentry.startSpan({
423
+ name: "agent.run",
424
+ op: "agent",
425
+ attributes: { channelId: event.conversationId, sessionKey },
426
+ }, async () => {
250
427
  return Sentry.withScope(async (scope) => {
251
428
  const { message, responseCtx, platform } = adapters;
252
429
  applyRunScope(scope, {
253
- channelId: event.channel,
430
+ conversationId: event.conversationId,
254
431
  sessionKey,
255
432
  messageId: message.id,
256
433
  platform: platform.name,
@@ -260,7 +437,7 @@ const handler = {
260
437
  isEvent: _isEvent,
261
438
  });
262
439
  addLifecycleBreadcrumb("agent.run.started", {
263
- channel_id: event.channel,
440
+ channel_id: event.conversationId,
264
441
  platform: platform.name,
265
442
  has_attachments: (message.attachments?.length ?? 0) > 0,
266
443
  });
@@ -273,37 +450,37 @@ const handler = {
273
450
  Sentry.metrics.distribution("agent.run.duration", durationMs, {
274
451
  unit: "millisecond",
275
452
  attributes: {
276
- channel: event.channel,
453
+ channel: event.conversationId,
277
454
  platform: platform.name,
278
455
  stop_reason: result.stopReason,
279
456
  },
280
457
  });
281
458
  Sentry.metrics.count("agent.run.completed", 1, {
282
459
  attributes: {
283
- channel: event.channel,
460
+ channel: event.conversationId,
284
461
  platform: platform.name,
285
462
  stop_reason: result.stopReason,
286
463
  },
287
464
  });
288
465
  addLifecycleBreadcrumb("agent.run.completed", {
289
- channel_id: event.channel,
466
+ channel_id: event.conversationId,
290
467
  platform: platform.name,
291
468
  stop_reason: result.stopReason,
292
469
  duration_ms: durationMs,
293
470
  });
294
471
  if (result.stopReason === "aborted" && state.stopRequested) {
295
472
  if (state.stopMessageTs) {
296
- await bot.updateMessage(event.channel, state.stopMessageTs, "_Stopped_");
473
+ await bot.updateMessage(event.conversationId, state.stopMessageTs, "_Stopped_");
297
474
  state.stopMessageTs = undefined;
298
475
  }
299
476
  else {
300
- await bot.postMessage(event.channel, "_Stopped_");
477
+ await bot.postMessage(event.conversationId, "_Stopped_");
301
478
  }
302
479
  }
303
480
  }
304
481
  catch (err) {
305
482
  scope.setContext("agent_run_error", {
306
- channelId: event.channel,
483
+ conversationId: event.conversationId,
307
484
  sessionKey,
308
485
  platform: adapters.platform.name,
309
486
  messageId: adapters.message.id,
@@ -311,9 +488,9 @@ const handler = {
311
488
  });
312
489
  Sentry.captureException(err);
313
490
  Sentry.metrics.count("agent.run.errors", 1, {
314
- attributes: { channel: event.channel, platform: adapters.platform.name },
491
+ attributes: { channel: event.conversationId, platform: adapters.platform.name },
315
492
  });
316
- log.logWarning(`[${event.channel}] Run error`, err instanceof Error ? err.message : String(err));
493
+ log.logWarning(`[${event.conversationId}] Run error`, err instanceof Error ? err.message : String(err));
317
494
  }
318
495
  finally {
319
496
  state.running = false;
@@ -337,10 +514,20 @@ const handler = {
337
514
  // ============================================================================
338
515
  const sandboxDesc = sandbox.type === "host"
339
516
  ? "host"
340
- : sandbox.type === "docker"
341
- ? `docker:${sandbox.container}`
342
- : `firecracker:${sandbox.vmId}`;
517
+ : sandbox.type === "container"
518
+ ? `container:${sandbox.container}`
519
+ : sandbox.type === "image"
520
+ ? `image:${sandbox.image}`
521
+ : `firecracker:${sandbox.vmId}`;
343
522
  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
+ }
344
531
  // Create platform bots
345
532
  const bots = [];
346
533
  const botsByPlatform = {};