limbo-ai 1.19.0 → 1.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.js CHANGED
@@ -1566,6 +1566,11 @@ async function cmdStart() {
1566
1566
  fs.writeFileSync(ENV_FILE, minimalContent, { mode: 0o600 });
1567
1567
  // Keep gateway token secret intact
1568
1568
  ensureGatewayToken(existingEnv);
1569
+ // Clean config inside the Docker volume so entrypoint re-enters setup mode
1570
+ try {
1571
+ runDockerCompose(['run', '--rm', '--no-deps', '--entrypoint', 'sh', 'limbo',
1572
+ '-c', 'rm -f /data/config/.env /home/limbo/.zeroclaw/config.toml'], { stdio: 'pipe' });
1573
+ } catch {}
1569
1574
  }
1570
1575
 
1571
1576
  // ── Route: Wizard (default for fresh install or wizard reconfigure) ───────
@@ -1578,7 +1583,8 @@ async function cmdStart() {
1578
1583
  ensureVolumePermissions();
1579
1584
 
1580
1585
  header('Starting Limbo...');
1581
- const upResult = runDockerCompose(['up', '-d', '--remove-orphans'], { stdio: 'pipe' });
1586
+ // Force recreate so the container picks up the clean .env (enters setup mode)
1587
+ const upResult = runDockerCompose(['up', '-d', '--remove-orphans', '--force-recreate'], { stdio: 'pipe' });
1582
1588
  if (upResult.status !== 0) {
1583
1589
  process.stderr.write(upResult.stderr || '');
1584
1590
  die('Container failed to start. Run `limbo logs` to investigate.');
@@ -1,52 +1,23 @@
1
- provider = "${MODEL_PROVIDER}"
2
- default_model = "${MODEL_NAME}"
1
+ # ZeroClaw config template for Limbo.
2
+ # Rendered by entrypoint.sh via envsubst. Telegram section is appended
3
+ # conditionally — see entrypoint.sh for details.
4
+
5
+ default_provider = "${MODEL_PROVIDER}"
6
+ default_model = "${MODEL_PROVIDER}/${MODEL_NAME}"
3
7
 
4
8
  [gateway]
5
9
  host = "127.0.0.1"
6
10
  port = ${LIMBO_PORT}
11
+ require_pairing = false
7
12
  allow_public_bind = false
8
13
 
9
- [gateway.auth]
10
- mode = "token"
11
- token_file = "/run/secrets/gateway_token"
12
-
13
- [memory]
14
- backend = "none"
15
-
16
- [security]
17
- sandbox = true
18
- command_allowlist = []
19
-
20
- [tools]
21
- profile = "messaging"
22
- allow = ["web_search", "web_fetch"]
23
- deny = ["exec", "browser", "canvas", "nodes", "cron", "gateway", "sessions_spawn", "sessions_send", "process", "image", "group:automation", "group:runtime", "group:fs"]
24
-
25
- [tools.fs]
26
- workspace_only = true
27
-
28
- [tools.exec]
29
- security = "deny"
30
- ask = "always"
31
-
32
- [tools.elevated]
33
- enabled = false
34
-
35
- [session]
36
- dm_scope = "per-channel-peer"
37
-
38
- [agents.defaults]
39
- workspace = "/data/workspace"
40
-
41
- [agents.defaults.model]
42
- primary = "${MODEL_PROVIDER}/${MODEL_NAME}"
14
+ [autonomy]
15
+ level = "full"
43
16
 
44
- [channels.telegram]
45
- enabled = ${TELEGRAM_ENABLED}
46
- bot_token = "${TELEGRAM_BOT_TOKEN}"
47
- dm_policy = "pairing"
17
+ [mcp]
18
+ enabled = true
48
19
 
49
- [mcp.limbo-vault]
20
+ [[mcp.servers]]
21
+ name = "limbo-vault"
50
22
  command = "node"
51
23
  args = ["/app/mcp-server/index.js"]
52
- tool_timeout_secs = 30
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "limbo-ai",
3
- "version": "1.19.0",
3
+ "version": "1.20.0",
4
4
  "description": "Your personal AI memory agent — install and manage Limbo via npx",
5
5
  "type": "commonjs",
6
6
  "bin": {
@@ -1113,26 +1113,42 @@
1113
1113
  <div class="validation-msg" id="tgTokenValidation"></div>
1114
1114
  </div>
1115
1115
 
1116
- <div class="toggle-row" style="margin-top: 4px;">
1117
- <div>
1118
- <div class="toggle-label">
1119
- <span data-lang="en">Auto-pair with your account</span>
1120
- <span data-lang="es">Auto-vincular con tu cuenta</span>
1116
+ </div>
1117
+
1118
+ <!-- Pairing panel (shown after token validation) -->
1119
+ <div id="tgPairing" style="display: none;">
1120
+ <div class="guide-steps">
1121
+ <div class="guide-step">
1122
+ <div class="guide-num">5</div>
1123
+ <div class="guide-text">
1124
+ <span data-lang="en">Open your bot in Telegram: <strong id="tgBotHandle"></strong></span>
1125
+ <span data-lang="es">Abrí tu bot en Telegram: <strong id="tgBotHandleEs"></strong></span>
1121
1126
  </div>
1122
- <div class="toggle-sub">
1123
- <span data-lang="en">Only the first Telegram user to message the bot will be linked</span>
1124
- <span data-lang="es">Solo el primer usuario que le escriba al bot será vinculado</span>
1127
+ </div>
1128
+ <div class="guide-step">
1129
+ <div class="guide-num">6</div>
1130
+ <div class="guide-text">
1131
+ <span data-lang="en">Send any message (e.g. "hello")</span>
1132
+ <span data-lang="es">Mandá cualquier mensaje (ej: "hola")</span>
1125
1133
  </div>
1126
1134
  </div>
1127
- <label class="toggle">
1128
- <input type="checkbox" id="tgAutoPair" checked>
1129
- <span class="toggle-track"></span>
1130
- <span class="toggle-thumb"></span>
1131
- </label>
1135
+ </div>
1136
+
1137
+ <div id="tgPairingStatus" style="display: flex; align-items: center; gap: 12px; padding: 16px; background: var(--bg-3); border-radius: var(--r-md); margin-top: 16px;">
1138
+ <div class="spinner" style="border-color: var(--accent); border-top-color: transparent; width: 20px; height: 20px; border-width: 2px;"></div>
1139
+ <span style="color: var(--text-sub);">
1140
+ <span data-lang="en">Waiting for your message...</span>
1141
+ <span data-lang="es">Esperando tu mensaje...</span>
1142
+ </span>
1143
+ </div>
1144
+
1145
+ <div id="tgPairingSuccess" style="display: none; align-items: center; gap: 12px; padding: 16px; background: var(--bg-3); border-radius: var(--r-md); margin-top: 16px;">
1146
+ <span style="color: var(--success); font-size: 20px; font-weight: 700;">&#10003;</span>
1147
+ <span id="tgPairingSuccessMsg" style="color: var(--success);"></span>
1132
1148
  </div>
1133
1149
  </div>
1134
1150
 
1135
- <div class="btn-row">
1151
+ <div class="btn-row" id="tgBtnRow">
1136
1152
  <button class="btn-secondary" onclick="skipTelegram()">
1137
1153
  <span data-lang="en">Skip</span>
1138
1154
  <span data-lang="es">Omitir</span>
@@ -1216,7 +1232,7 @@
1216
1232
  authMode: null,
1217
1233
  apiKey: '',
1218
1234
  model: null,
1219
- telegram: { enabled: true, botToken: '', autoPair: true },
1235
+ telegram: { enabled: true, botToken: '' },
1220
1236
  subscriptionDone: false,
1221
1237
  subscriptionEmail: '',
1222
1238
  };
@@ -1275,6 +1291,7 @@
1275
1291
  // Step-specific setup
1276
1292
  if (n === 4) setupApiKeyStep();
1277
1293
  if (n === 5) fetchModels();
1294
+ if (n === 6) resetTelegramUI();
1278
1295
  if (n === 7) buildSummary();
1279
1296
  }
1280
1297
 
@@ -1714,25 +1731,116 @@
1714
1731
  }
1715
1732
  });
1716
1733
 
1734
+ let pairingActive = false;
1735
+
1717
1736
  function skipTelegram() {
1737
+ pairingActive = false;
1718
1738
  state.telegram.enabled = false;
1719
1739
  state.telegram.botToken = '';
1720
1740
  goToStep(7);
1721
1741
  }
1722
1742
 
1723
- function submitTelegram() {
1724
- if (state.telegram.enabled) {
1725
- const token = document.getElementById('tgTokenInput').value.trim();
1726
- if (!token || !/^\d+:.{20,}$/.test(token)) {
1743
+ function resetTelegramUI() {
1744
+ document.getElementById('tgFields').classList.toggle('visible', state.telegram.enabled);
1745
+ document.getElementById('tgPairing').style.display = 'none';
1746
+ document.getElementById('tgPairingStatus').style.display = 'flex';
1747
+ document.getElementById('tgPairingSuccess').style.display = 'none';
1748
+ const btn = document.getElementById('btnTgContinue');
1749
+ btn.disabled = false;
1750
+ btn.style.display = '';
1751
+ btn.innerHTML = `<span data-lang="en">Continue</span><span data-lang="es">Continuar</span>`;
1752
+ document.body.className = `lang-${state.language}`;
1753
+ }
1754
+
1755
+ async function submitTelegram() {
1756
+ if (!state.telegram.enabled) {
1757
+ goToStep(7);
1758
+ return;
1759
+ }
1760
+
1761
+ const token = document.getElementById('tgTokenInput').value.trim();
1762
+ if (!token || !/^\d+:.{20,}$/.test(token)) {
1763
+ const msgEl = document.getElementById('tgTokenValidation');
1764
+ const msg = state.language === 'es' ? 'Ingresá un token válido o salteá este paso' : 'Enter a valid token or skip this step';
1765
+ msgEl.innerHTML = `<span class="validation-msg error">${msg}</span>`;
1766
+ return;
1767
+ }
1768
+
1769
+ state.telegram.botToken = token;
1770
+ const btn = document.getElementById('btnTgContinue');
1771
+ btn.disabled = true;
1772
+ btn.innerHTML = `<div class="spinner" style="width:16px;height:16px;border-width:2px;display:inline-block;vertical-align:middle;"></div>`;
1773
+
1774
+ try {
1775
+ // Validate token with Telegram getMe
1776
+ const validateRes = await fetch('/api/telegram/validate-token', {
1777
+ method: 'POST',
1778
+ headers: authHeaders(),
1779
+ body: JSON.stringify({ botToken: token }),
1780
+ });
1781
+ const validateData = await validateRes.json();
1782
+
1783
+ if (!validateData.valid) {
1727
1784
  const msgEl = document.getElementById('tgTokenValidation');
1728
- const msg = state.language === 'es' ? 'Ingresá un token válido o salteá este paso' : 'Enter a valid token or skip this step';
1785
+ const msg = state.language === 'es' ? 'Token inválido. Revisá que sea correcto.' : 'Invalid token. Please check and try again.';
1729
1786
  msgEl.innerHTML = `<span class="validation-msg error">${msg}</span>`;
1787
+ btn.disabled = false;
1788
+ btn.innerHTML = `<span data-lang="en">Continue</span><span data-lang="es">Continuar</span>`;
1789
+ document.body.className = `lang-${state.language}`;
1730
1790
  return;
1731
1791
  }
1732
- state.telegram.botToken = token;
1733
- state.telegram.autoPair = document.getElementById('tgAutoPair').checked;
1792
+
1793
+ // Show pairing panel
1794
+ const handle = `@${validateData.botUsername}`;
1795
+ document.getElementById('tgBotHandle').textContent = handle;
1796
+ document.getElementById('tgBotHandleEs').textContent = handle;
1797
+ document.getElementById('tgFields').classList.remove('visible');
1798
+ document.getElementById('tgPairing').style.display = 'block';
1799
+ btn.style.display = 'none';
1800
+
1801
+ // Start long-poll pairing loop
1802
+ pairingActive = true;
1803
+ startTelegramPairing(token);
1804
+ } catch (err) {
1805
+ const msgEl = document.getElementById('tgTokenValidation');
1806
+ msgEl.innerHTML = `<span class="validation-msg error">${err.message}</span>`;
1807
+ btn.disabled = false;
1808
+ btn.innerHTML = `<span data-lang="en">Continue</span><span data-lang="es">Continuar</span>`;
1809
+ document.body.className = `lang-${state.language}`;
1810
+ }
1811
+ }
1812
+
1813
+ async function startTelegramPairing(token) {
1814
+ while (pairingActive) {
1815
+ try {
1816
+ const res = await fetch('/api/telegram/pair', {
1817
+ method: 'POST',
1818
+ headers: authHeaders(),
1819
+ body: JSON.stringify({ botToken: token, language: state.language }),
1820
+ });
1821
+ const data = await res.json();
1822
+
1823
+ if (!pairingActive) return;
1824
+
1825
+ if (data.paired) {
1826
+ pairingActive = false;
1827
+ document.getElementById('tgPairingStatus').style.display = 'none';
1828
+ const successEl = document.getElementById('tgPairingSuccess');
1829
+ successEl.style.display = 'flex';
1830
+ const msg = state.language === 'es'
1831
+ ? `Conectado${data.firstName ? ' con ' + data.firstName : ''}. Saludo enviado.`
1832
+ : `Connected${data.firstName ? ' with ' + data.firstName : ''}. Greeting sent.`;
1833
+ document.getElementById('tgPairingSuccessMsg').textContent = msg;
1834
+
1835
+ setTimeout(() => goToStep(7), 2000);
1836
+ return;
1837
+ }
1838
+ // Not paired yet — the server already waited ~25s, loop immediately
1839
+ } catch {
1840
+ if (!pairingActive) return;
1841
+ await new Promise(r => setTimeout(r, 2000));
1842
+ }
1734
1843
  }
1735
- goToStep(7);
1736
1844
  }
1737
1845
 
1738
1846
  // --- Step 7: Summary ---
@@ -1808,9 +1916,10 @@
1808
1916
  state.model = null;
1809
1917
  state.subscriptionDone = false;
1810
1918
  state.subscriptionEmail = '';
1811
- state.telegram = { enabled: true, botToken: '', autoPair: true };
1919
+ state.telegram = { enabled: true, botToken: '' };
1920
+ pairingActive = false;
1812
1921
  document.getElementById('tgToggle').checked = true;
1813
- document.getElementById('tgFields').classList.add('visible');
1922
+ resetTelegramUI();
1814
1923
  goToStep(1);
1815
1924
  }
1816
1925
 
@@ -1833,7 +1942,6 @@
1833
1942
  telegram: {
1834
1943
  enabled: state.telegram.enabled,
1835
1944
  botToken: state.telegram.enabled ? state.telegram.botToken : '',
1836
- autoPair: state.telegram.autoPair,
1837
1945
  },
1838
1946
  };
1839
1947
  if (state.authMode === 'api-key') {
@@ -13,8 +13,9 @@ const crypto = require('crypto');
13
13
  const PORT = parseInt(process.env.LIMBO_PORT, 10) || 18789;
14
14
  const PUBLIC_DIR = path.join(__dirname, 'public');
15
15
  const DATA_DIR = process.env.LIMBO_DATA_DIR || '/data';
16
+ const ZEROCLAW_STATE = process.env.ZEROCLAW_STATE_DIR || '/home/limbo/.zeroclaw';
16
17
  const CONFIG_DIR = path.join(DATA_DIR, 'config');
17
- const SECRETS_DIR = path.join(DATA_DIR, 'secrets');
18
+ const SECRETS_DIR = path.join(ZEROCLAW_STATE, 'secrets');
18
19
  const ENV_FILE = path.join(CONFIG_DIR, '.env');
19
20
  const SETUP_TOKEN_FILE = path.join(CONFIG_DIR, 'setup_token');
20
21
 
@@ -225,52 +226,216 @@ function decodeJwtPayload(token) {
225
226
  return JSON.parse(Buffer.from(parts[1], 'base64url').toString());
226
227
  }
227
228
 
229
+ // ZeroClaw auth-profiles.json format:
230
+ // path: ~/.zeroclaw/auth-profiles.json
231
+ // fields: profile_name, kind, provider, access_token, refresh_token, expires_at (RFC3339)
232
+ // After writing, `zeroclaw auth use --provider X --profile Y` activates it.
233
+
228
234
  function buildCodexAuthProfile(profile) {
229
- const profileId = profile.email ? `openai-codex:${profile.email}` : 'openai-codex:default';
235
+ const profileName = 'default';
236
+ const profileId = `openai-codex:${profileName}`;
230
237
  return {
231
238
  version: 1,
232
239
  profiles: {
233
240
  [profileId]: {
234
- type: 'oauth',
241
+ profile_name: profileName,
242
+ kind: 'oauth',
235
243
  provider: 'openai-codex',
236
- access: profile.access,
237
- refresh: profile.refresh,
238
- expires: profile.expires,
239
- accountId: profile.accountId,
244
+ access_token: profile.access,
245
+ refresh_token: profile.refresh,
246
+ expires_at: new Date(profile.expires).toISOString(),
247
+ account_id: profile.accountId || '',
240
248
  },
241
249
  },
242
- order: {},
243
- lastGood: {},
250
+ order: { 'openai-codex': [profileId] },
251
+ lastGood: { 'openai-codex': profileId },
244
252
  usageStats: {},
245
253
  };
246
254
  }
247
255
 
248
256
  function buildAnthropicAuthProfile(token) {
257
+ const profileName = 'default';
258
+ const profileId = `anthropic:${profileName}`;
249
259
  return {
250
260
  version: 1,
251
261
  profiles: {
252
- 'anthropic:token': {
253
- type: 'token',
262
+ [profileId]: {
263
+ profile_name: profileName,
264
+ kind: 'token',
254
265
  provider: 'anthropic',
255
- token,
266
+ access_token: token,
256
267
  },
257
268
  },
258
- order: { anthropic: ['anthropic:token'] },
259
- lastGood: {},
269
+ order: { anthropic: [profileId] },
270
+ lastGood: { anthropic: profileId },
260
271
  usageStats: {},
261
272
  };
262
273
  }
263
274
 
264
- const ZEROCLAW_STATE = process.env.ZEROCLAW_STATE_DIR || '/home/limbo/.zeroclaw';
265
- const AUTH_PROFILES_DIR = path.join(ZEROCLAW_STATE, 'agents/main/agent');
266
- const AUTH_PROFILES_FILE = path.join(AUTH_PROFILES_DIR, 'auth-profiles.json');
275
+ const AUTH_PROFILES_FILE = path.join(ZEROCLAW_STATE, 'auth-profiles.json');
267
276
 
268
277
  function writeAuthProfiles(store) {
269
- fs.mkdirSync(AUTH_PROFILES_DIR, { recursive: true, mode: 0o700 });
278
+ fs.mkdirSync(ZEROCLAW_STATE, { recursive: true, mode: 0o700 });
270
279
  fs.writeFileSync(AUTH_PROFILES_FILE, JSON.stringify(store, null, 2), { mode: 0o600 });
271
280
  log('Auth profile written to ' + AUTH_PROFILES_FILE);
272
281
  }
273
282
 
283
+ // ─── Telegram API Helpers ────────────────────────────────────────────────────
284
+
285
+ const https = require('https');
286
+
287
+ function telegramApiGet(token, method, params = {}) {
288
+ const qs = new URLSearchParams(params).toString();
289
+ const urlPath = `/bot${token}/${method}${qs ? '?' + qs : ''}`;
290
+ return new Promise((resolve, reject) => {
291
+ const req = https.get({
292
+ hostname: 'api.telegram.org',
293
+ path: urlPath,
294
+ timeout: 35000,
295
+ }, (r) => {
296
+ let data = '';
297
+ r.on('data', c => data += c);
298
+ r.on('end', () => {
299
+ try { resolve(JSON.parse(data)); }
300
+ catch { reject(new Error('Invalid JSON from Telegram API')); }
301
+ });
302
+ });
303
+ req.on('error', reject);
304
+ req.on('timeout', () => { req.destroy(); reject(new Error('Telegram API timeout')); });
305
+ });
306
+ }
307
+
308
+ function telegramApiPost(token, method, body) {
309
+ const jsonBody = JSON.stringify(body);
310
+ return new Promise((resolve, reject) => {
311
+ const req = https.request({
312
+ hostname: 'api.telegram.org',
313
+ path: `/bot${token}/${method}`,
314
+ method: 'POST',
315
+ headers: {
316
+ 'Content-Type': 'application/json',
317
+ 'Content-Length': Buffer.byteLength(jsonBody),
318
+ },
319
+ timeout: 10000,
320
+ }, (r) => {
321
+ let data = '';
322
+ r.on('data', c => data += c);
323
+ r.on('end', () => {
324
+ try { resolve(JSON.parse(data)); }
325
+ catch { reject(new Error('Invalid JSON from Telegram API')); }
326
+ });
327
+ });
328
+ req.on('error', reject);
329
+ req.on('timeout', () => { req.destroy(); reject(new Error('Telegram API timeout')); });
330
+ req.write(jsonBody);
331
+ req.end();
332
+ });
333
+ }
334
+
335
+ // ─── Telegram Pairing Handlers ──────────────────────────────────────────────
336
+
337
+ async function handleTelegramValidate(req, res) {
338
+ const body = await readBody(req);
339
+ const data = parseJSON(body);
340
+
341
+ if (!data || !data.botToken) {
342
+ return sendError(res, 400, 'Missing botToken');
343
+ }
344
+
345
+ try {
346
+ const result = await telegramApiGet(data.botToken, 'getMe');
347
+ if (result.ok) {
348
+ sendJSON(res, 200, {
349
+ valid: true,
350
+ botUsername: result.result.username,
351
+ botName: result.result.first_name,
352
+ });
353
+ } else {
354
+ sendJSON(res, 200, { valid: false, error: result.description });
355
+ }
356
+ } catch (err) {
357
+ sendError(res, 500, `Telegram API error: ${err.message}`);
358
+ }
359
+ }
360
+
361
+ async function handleTelegramPair(req, res) {
362
+ const body = await readBody(req);
363
+ const data = parseJSON(body);
364
+
365
+ if (!data || !data.botToken) {
366
+ return sendError(res, 400, 'Missing botToken');
367
+ }
368
+
369
+ const token = data.botToken;
370
+
371
+ try {
372
+ // Check for existing messages first (non-blocking)
373
+ let result = await telegramApiGet(token, 'getUpdates', { timeout: 0 });
374
+ if (!result.ok) {
375
+ return sendError(res, 502, `Telegram: ${result.description}`);
376
+ }
377
+
378
+ let updates = result.result || [];
379
+ let targetUpdate = null;
380
+
381
+ // Find the last message update
382
+ for (const u of updates) {
383
+ if (u.message && u.message.chat) targetUpdate = u;
384
+ }
385
+
386
+ if (!targetUpdate) {
387
+ // Long poll for a new message (25s — Telegram holds the connection)
388
+ const offset = updates.length > 0
389
+ ? updates[updates.length - 1].update_id + 1
390
+ : undefined;
391
+ const pollParams = { timeout: 25, limit: 1 };
392
+ if (offset !== undefined) pollParams.offset = offset;
393
+
394
+ result = await telegramApiGet(token, 'getUpdates', pollParams);
395
+ if (!result.ok) {
396
+ return sendError(res, 502, `Telegram: ${result.description}`);
397
+ }
398
+
399
+ for (const u of (result.result || [])) {
400
+ if (u.message && u.message.chat) targetUpdate = u;
401
+ }
402
+ }
403
+
404
+ if (!targetUpdate) {
405
+ return sendJSON(res, 200, { paired: false });
406
+ }
407
+
408
+ const chat = targetUpdate.message.chat;
409
+ const chatId = String(chat.id);
410
+ const firstName = chat.first_name || '';
411
+
412
+ // Acknowledge all updates up to this one
413
+ await telegramApiGet(token, 'getUpdates', {
414
+ offset: targetUpdate.update_id + 1,
415
+ timeout: 0,
416
+ limit: 1,
417
+ });
418
+
419
+ // Send greeting
420
+ const lang = data.language || 'en';
421
+ const greeting = lang === 'es'
422
+ ? `👋 ¡Hola${firstName ? ' ' + firstName : ''}! Limbo está configurado y listo. Podés hablarme por acá.`
423
+ : `👋 Hey${firstName ? ' ' + firstName : ''}! Limbo is set up and ready. You can talk to me here.`;
424
+
425
+ await telegramApiPost(token, 'sendMessage', { chat_id: chatId, text: greeting });
426
+
427
+ // Persist chat_id
428
+ writeSecretFile('telegram_chat_id', chatId);
429
+ log(`Telegram paired: chat_id=${chatId} name=${firstName}`);
430
+
431
+ sendJSON(res, 200, { paired: true, chatId, firstName });
432
+
433
+ } catch (err) {
434
+ log(`Telegram pair error: ${err.message}`);
435
+ sendError(res, 500, `Telegram pairing error: ${err.message}`);
436
+ }
437
+ }
438
+
274
439
  // ─── Static File Serving ─────────────────────────────────────────────────────
275
440
 
276
441
  function serveStatic(req, res) {
@@ -352,7 +517,6 @@ function handleOAuthStart(req, res) {
352
517
 
353
518
  // Shared token exchange logic
354
519
  async function exchangeOAuthCode(code, verifier, redirectUri) {
355
- const https = require('https');
356
520
  const tokenBody = new URLSearchParams({
357
521
  grant_type: 'authorization_code',
358
522
  client_id: OPENAI_OAUTH.clientId,
@@ -567,6 +731,7 @@ async function handleConfigure(req, res) {
567
731
  const telegram = data.telegram || {};
568
732
  if (telegram.botToken) {
569
733
  writeSecretFile('telegram_bot_token', telegram.botToken);
734
+ // chat_id is already captured by /api/telegram/pair during wizard Step 6
570
735
  }
571
736
 
572
737
  const gatewayToken = ensureGatewayToken();
@@ -580,8 +745,6 @@ async function handleConfigure(req, res) {
580
745
  MODEL_NAME: modelName,
581
746
  LIMBO_PORT: String(PORT),
582
747
  TELEGRAM_ENABLED: telegram.enabled ? 'true' : 'false',
583
- TELEGRAM_AUTO_PAIR_FIRST_DM: telegram.autoPair ? 'true' : 'false',
584
- GATEWAY_TOKEN: gatewayToken,
585
748
  };
586
749
 
587
750
  // Write .env file (quote values to handle special chars)
@@ -658,6 +821,10 @@ async function handleRequest(req, res) {
658
821
  await handleOAuthExchange(req, res);
659
822
  } else if (method === 'POST' && url === '/api/auth/anthropic/token') {
660
823
  await handleAnthropicToken(req, res);
824
+ } else if (method === 'POST' && url === '/api/telegram/validate-token') {
825
+ await handleTelegramValidate(req, res);
826
+ } else if (method === 'POST' && url === '/api/telegram/pair') {
827
+ await handleTelegramPair(req, res);
661
828
  } else if (method === 'POST' && url === '/api/configure') {
662
829
  await handleConfigure(req, res);
663
830
  } else if (method === 'GET') {
@@ -37,58 +37,74 @@ test('mcporter.json is deleted', () => {
37
37
  assert.ok(!exists('mcporter.json'), 'mcporter.json should not exist');
38
38
  });
39
39
 
40
- // ─── 2. New ZeroClaw config template exists and is valid TOML-ish ───────────
40
+ // ─── 2. New ZeroClaw config template exists and matches ZeroClaw schema ─────
41
41
 
42
42
  test('config.toml.template exists', () => {
43
43
  assert.ok(exists('config.toml.template'));
44
44
  });
45
45
 
46
- test('config.toml.template contains required sections', () => {
46
+ test('config.toml.template contains required ZeroClaw sections', () => {
47
47
  const toml = read('config.toml.template');
48
48
  const required = [
49
49
  '[gateway]',
50
+ '[mcp]',
51
+ '[[mcp.servers]]',
52
+ ];
53
+ for (const section of required) {
54
+ assert.ok(toml.includes(section), `Missing section: ${section}`);
55
+ }
56
+ });
57
+
58
+ test('config.toml.template does NOT contain unsupported sections', () => {
59
+ const toml = read('config.toml.template');
60
+ const forbidden = [
50
61
  '[gateway.auth]',
51
62
  '[memory]',
52
- '[security]',
53
- '[tools]',
54
63
  '[session]',
55
64
  '[agents.defaults]',
56
65
  '[channels.telegram]',
57
66
  '[mcp.limbo-vault]',
67
+ '[security]',
68
+ '[tools]',
58
69
  ];
59
- for (const section of required) {
60
- assert.ok(toml.includes(section), `Missing section: ${section}`);
70
+ for (const section of forbidden) {
71
+ assert.ok(!toml.includes(section), `Should not contain unsupported section: ${section}`);
61
72
  }
62
73
  });
63
74
 
64
75
  test('config.toml.template uses envsubst variables', () => {
65
76
  const toml = read('config.toml.template');
66
- const vars = ['${MODEL_PROVIDER}', '${MODEL_NAME}', '${LIMBO_PORT}', '${TELEGRAM_BOT_TOKEN}', '${TELEGRAM_ENABLED}'];
77
+ const vars = ['${MODEL_PROVIDER}', '${MODEL_NAME}', '${LIMBO_PORT}'];
67
78
  for (const v of vars) {
68
79
  assert.ok(toml.includes(v), `Missing envsubst variable: ${v}`);
69
80
  }
70
81
  });
71
82
 
72
- test('config.toml.template has gateway auth with token_file', () => {
73
- const toml = read('config.toml.template');
74
- assert.ok(toml.includes('token_file = "/run/secrets/gateway_token"'),
75
- 'Gateway auth must reference /run/secrets/gateway_token');
76
- });
77
-
78
- test('config.toml.template has memory backend = none', () => {
83
+ test('config.toml.template uses require_pairing for gateway auth', () => {
79
84
  const toml = read('config.toml.template');
80
- assert.ok(toml.includes('backend = "none"'),
81
- 'Memory backend must be "none" (Limbo uses its own vault via MCP)');
85
+ assert.ok(toml.includes('require_pairing = false'),
86
+ 'Gateway must use require_pairing (ZeroClaw schema)');
82
87
  });
83
88
 
84
- test('config.toml.template registers MCP server natively', () => {
89
+ test('config.toml.template registers MCP server via [[mcp.servers]]', () => {
85
90
  const toml = read('config.toml.template');
86
- assert.ok(toml.includes('[mcp.limbo-vault]'));
91
+ assert.ok(toml.includes('[[mcp.servers]]'));
92
+ assert.ok(toml.includes('name = "limbo-vault"'));
87
93
  assert.ok(toml.includes('command = "node"'));
88
94
  assert.ok(toml.includes('"/app/mcp-server/index.js"'));
89
95
  });
90
96
 
91
- // ─── 3. Dockerfile references ZeroClaw, not OpenClaw ────────────────────────
97
+ // ─── 3. Entrypoint conditionally appends Telegram config ────────────────────
98
+
99
+ test('entrypoint.sh appends channels_config.telegram conditionally', () => {
100
+ const ep = read('scripts/entrypoint.sh');
101
+ assert.ok(ep.includes('[channels_config.telegram]'),
102
+ 'Entrypoint must append [channels_config.telegram] section');
103
+ assert.ok(ep.includes('allowed_users'),
104
+ 'Telegram config must include allowed_users');
105
+ });
106
+
107
+ // ─── 4. Dockerfile references ZeroClaw, not OpenClaw ────────────────────────
92
108
 
93
109
  test('Dockerfile pulls ZeroClaw binary from official image', () => {
94
110
  const df = read('Dockerfile');
@@ -112,7 +128,7 @@ test('Dockerfile copies config.toml.template', () => {
112
128
  assert.ok(df.includes('config.toml.template'));
113
129
  });
114
130
 
115
- // ─── 4. docker-compose.yml uses ZeroClaw volumes and healthcheck ────────────
131
+ // ─── 5. docker-compose.yml uses ZeroClaw volumes and healthcheck ────────────
116
132
 
117
133
  test('docker-compose.yml uses limbo-zeroclaw-state volume', () => {
118
134
  const dc = read('docker-compose.yml');
@@ -138,7 +154,7 @@ test('docker-compose.yml mounts .zeroclaw state dir', () => {
138
154
  assert.ok(dc.includes('/home/limbo/.zeroclaw'));
139
155
  });
140
156
 
141
- // ─── 5. Entrypoint uses ZeroClaw ────────────────────────────────────────────
157
+ // ─── 6. Entrypoint uses ZeroClaw ────────────────────────────────────────────
142
158
 
143
159
  test('entrypoint.sh starts zeroclaw daemon', () => {
144
160
  const ep = read('scripts/entrypoint.sh');
@@ -165,7 +181,7 @@ test('entrypoint.sh renders config.toml from template via envsubst', () => {
165
181
  assert.ok(ep.includes('envsubst'));
166
182
  });
167
183
 
168
- // ─── 6. Migration version bumped correctly ──────────────────────────────────
184
+ // ─── 7. Migration version bumped correctly ──────────────────────────────────
169
185
 
170
186
  test('migration index has CURRENT_DATA_VERSION = 3', () => {
171
187
  const idx = read('migrations/index.js');
@@ -185,14 +201,14 @@ test('migration 003 exports version 3 and up function', () => {
185
201
  'Migration 003 must export an up function');
186
202
  });
187
203
 
188
- // ─── 7. CLI filter suppresses both openclaw and zeroclaw branding ───────────
204
+ // ─── 8. CLI filter suppresses both openclaw and zeroclaw branding ───────────
189
205
 
190
206
  test('cli-filter.test.js classify regex handles both brands', () => {
191
207
  const t = read('test/cli-filter.test.js');
192
208
  assert.ok(t.includes('openclaw|zeroclaw'), 'Filter regex must suppress both openclaw and zeroclaw branding');
193
209
  });
194
210
 
195
- // ─── 8. No stale OPENCLAW env vars in core config files ─────────────────────
211
+ // ─── 9. No stale OPENCLAW env vars in core config files ─────────────────────
196
212
 
197
213
  test('.env.example does not use OPENCLAW_ prefix', () => {
198
214
  if (!exists('.env.example')) return; // optional file
@@ -205,7 +221,7 @@ test('docker-compose.yml does not use OPENCLAW_ env vars', () => {
205
221
  assert.ok(!dc.includes('OPENCLAW_'));
206
222
  });
207
223
 
208
- // ─── 9. Workspace docs updated ──────────────────────────────────────────────
224
+ // ─── 10. Workspace docs updated ─────────────────────────────────────────────
209
225
 
210
226
  test('IDENTITY.md does not reference OpenClaw gateway', () => {
211
227
  const id = read('workspace/templates/IDENTITY.md');
@@ -217,14 +233,14 @@ test('TOOLS.md does not reference mcporter', () => {
217
233
  assert.ok(!tools.toLowerCase().includes('mcporter'), 'TOOLS.md should not reference mcporter');
218
234
  });
219
235
 
220
- // ─── 10. Package.json includes zeroclaw keyword ─────────────────────────────
236
+ // ─── 11. Package.json includes zeroclaw keyword ─────────────────────────────
221
237
 
222
238
  test('package.json keywords include zeroclaw', () => {
223
239
  const pkg = JSON.parse(read('package.json'));
224
240
  assert.ok(pkg.keywords.includes('zeroclaw'), 'package.json keywords should include "zeroclaw"');
225
241
  });
226
242
 
227
- // ─── 11. Setup server uses ZeroClaw paths ───────────────────────────────────
243
+ // ─── 12. Setup server uses ZeroClaw paths ───────────────────────────────────
228
244
 
229
245
  test('setup-server uses ZEROCLAW_STATE not OPENCLAW_STATE', () => {
230
246
  const srv = read('setup-server/server.js');
@@ -237,7 +253,7 @@ test('setup-server uses GATEWAY_TOKEN not OPENCLAW_GATEWAY_TOKEN', () => {
237
253
  assert.ok(!srv.includes('OPENCLAW_GATEWAY_TOKEN'), 'setup-server should not use OPENCLAW_GATEWAY_TOKEN');
238
254
  });
239
255
 
240
- // ─── 12. Security: sensitive dirs ───────────────────────────────────────────
256
+ // ─── 13. Security: sensitive dirs ───────────────────────────────────────────
241
257
 
242
258
  test('Dockerfile does not install git in final image', () => {
243
259
  const df = read('Dockerfile');