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 +7 -1
- package/config.toml.template +13 -42
- package/package.json +1 -1
- package/setup-server/public/index.html +134 -26
- package/setup-server/server.js +188 -21
- package/test/zeroclaw-migration.test.js +44 -28
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
|
-
|
|
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.');
|
package/config.toml.template
CHANGED
|
@@ -1,52 +1,23 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
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
|
-
[
|
|
10
|
-
|
|
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
|
-
[
|
|
45
|
-
enabled =
|
|
46
|
-
bot_token = "${TELEGRAM_BOT_TOKEN}"
|
|
47
|
-
dm_policy = "pairing"
|
|
17
|
+
[mcp]
|
|
18
|
+
enabled = true
|
|
48
19
|
|
|
49
|
-
[mcp.
|
|
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
|
@@ -1113,26 +1113,42 @@
|
|
|
1113
1113
|
<div class="validation-msg" id="tgTokenValidation"></div>
|
|
1114
1114
|
</div>
|
|
1115
1115
|
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
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
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
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;">✓</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: ''
|
|
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
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
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' ? '
|
|
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
|
-
|
|
1733
|
-
|
|
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: ''
|
|
1919
|
+
state.telegram = { enabled: true, botToken: '' };
|
|
1920
|
+
pairingActive = false;
|
|
1812
1921
|
document.getElementById('tgToggle').checked = true;
|
|
1813
|
-
|
|
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') {
|
package/setup-server/server.js
CHANGED
|
@@ -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(
|
|
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
|
|
235
|
+
const profileName = 'default';
|
|
236
|
+
const profileId = `openai-codex:${profileName}`;
|
|
230
237
|
return {
|
|
231
238
|
version: 1,
|
|
232
239
|
profiles: {
|
|
233
240
|
[profileId]: {
|
|
234
|
-
|
|
241
|
+
profile_name: profileName,
|
|
242
|
+
kind: 'oauth',
|
|
235
243
|
provider: 'openai-codex',
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
253
|
-
|
|
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: [
|
|
259
|
-
lastGood: {},
|
|
269
|
+
order: { anthropic: [profileId] },
|
|
270
|
+
lastGood: { anthropic: profileId },
|
|
260
271
|
usageStats: {},
|
|
261
272
|
};
|
|
262
273
|
}
|
|
263
274
|
|
|
264
|
-
const
|
|
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(
|
|
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
|
|
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
|
|
60
|
-
assert.ok(toml.includes(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}'
|
|
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
|
|
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('
|
|
81
|
-
'
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
// ───
|
|
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
|
-
// ───
|
|
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
|
-
// ───
|
|
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
|
-
// ───
|
|
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
|
-
// ───
|
|
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
|
-
// ───
|
|
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
|
-
// ───
|
|
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
|
-
// ───
|
|
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
|
-
// ───
|
|
256
|
+
// ─── 13. Security: sensitive dirs ───────────────────────────────────────────
|
|
241
257
|
|
|
242
258
|
test('Dockerfile does not install git in final image', () => {
|
|
243
259
|
const df = read('Dockerfile');
|