limbo-ai 1.9.5 → 1.11.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/README.md +13 -7
- package/cli.js +306 -173
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -50,11 +50,11 @@ Pulls the latest Limbo image and restarts the container. Your vault data is pers
|
|
|
50
50
|
|
|
51
51
|
## Connecting
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
There are two ways to connect: **talk to Limbo** (conversational, with its personality and memory logic) or **use the vault directly** (raw tool access from another agent).
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
### Talk to Limbo
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
#### Telegram (recommended)
|
|
58
58
|
|
|
59
59
|
Set `TELEGRAM_ENABLED=true` and `TELEGRAM_BOT_TOKEN` in `~/.limbo/.env`, then restart:
|
|
60
60
|
|
|
@@ -62,17 +62,21 @@ Set `TELEGRAM_ENABLED=true` and `TELEGRAM_BOT_TOKEN` in `~/.limbo/.env`, then re
|
|
|
62
62
|
npx limbo-ai start --reconfigure
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
Message your bot and Limbo will respond.
|
|
65
|
+
Message your bot and Limbo will respond — full agent with personality, memory logic, and vault tools.
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
#### OpenClaw client
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
Any [OpenClaw](https://openclaw.dev)-compatible chat client can connect to:
|
|
70
70
|
|
|
71
71
|
```
|
|
72
72
|
ws://localhost:18789
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
This
|
|
75
|
+
This gives you a conversational session with Limbo, same as Telegram but over WebSocket.
|
|
76
|
+
|
|
77
|
+
### Use the vault from another agent
|
|
78
|
+
|
|
79
|
+
If you want another AI agent (like Claude Code) to read and write to Limbo's vault directly — without going through Limbo's personality or reasoning — add it as an MCP server:
|
|
76
80
|
|
|
77
81
|
```json
|
|
78
82
|
{
|
|
@@ -84,6 +88,8 @@ This includes Claude Code — add Limbo to your MCP config:
|
|
|
84
88
|
}
|
|
85
89
|
```
|
|
86
90
|
|
|
91
|
+
This exposes the 4 vault tools (`vault_search`, `vault_read`, `vault_write_note`, `vault_update_map`) as MCP tools in the connecting agent. The agent operates on the vault directly — Limbo's LLM is not involved.
|
|
92
|
+
|
|
87
93
|
---
|
|
88
94
|
|
|
89
95
|
## Environment Variables
|
package/cli.js
CHANGED
|
@@ -20,8 +20,6 @@ const COMPOSE_FILE = path.join(LIMBO_DIR, 'docker-compose.yml');
|
|
|
20
20
|
const GHCR_IMAGE = 'ghcr.io/tomasward1/limbo';
|
|
21
21
|
const DEFAULT_TAG = require('./package.json').version;
|
|
22
22
|
const PORT = 18789;
|
|
23
|
-
// OpenClaw's OAuth callback server port — must be exposed when running auth inside Docker
|
|
24
|
-
const OPENCLAW_AUTH_PORT = 1453;
|
|
25
23
|
|
|
26
24
|
// OpenClaw compatibility snapshots from official docs:
|
|
27
25
|
// - https://docs.openclaw.ai/providers/openai
|
|
@@ -72,6 +70,9 @@ const COMPOSE_CONTENT = `services:
|
|
|
72
70
|
- no-new-privileges:true
|
|
73
71
|
cap_drop:
|
|
74
72
|
- ALL
|
|
73
|
+
cap_add:
|
|
74
|
+
- CHOWN
|
|
75
|
+
- FOWNER
|
|
75
76
|
pids_limit: 200
|
|
76
77
|
tmpfs:
|
|
77
78
|
- /tmp:size=100M,noexec,nosuid,nodev
|
|
@@ -80,14 +81,14 @@ const COMPOSE_CONTENT = `services:
|
|
|
80
81
|
- "127.0.0.1:${PORT}:${PORT}"
|
|
81
82
|
volumes:
|
|
82
83
|
- limbo-data:/data
|
|
83
|
-
-
|
|
84
|
+
- ${VAULT_DIR}:/data/vault
|
|
84
85
|
- limbo-openclaw-state:/home/limbo/.openclaw
|
|
85
86
|
secrets:
|
|
86
87
|
- llm_api_key
|
|
87
88
|
- telegram_bot_token
|
|
88
89
|
- gateway_token
|
|
89
90
|
env_file:
|
|
90
|
-
-
|
|
91
|
+
- ${LIMBO_DIR}/.env
|
|
91
92
|
environment:
|
|
92
93
|
OPENCLAW_CONFIG_PATH: /home/limbo/.openclaw/openclaw.json
|
|
93
94
|
OPENCLAW_STATE_DIR: /home/limbo/.openclaw
|
|
@@ -104,11 +105,11 @@ const COMPOSE_CONTENT = `services:
|
|
|
104
105
|
|
|
105
106
|
secrets:
|
|
106
107
|
llm_api_key:
|
|
107
|
-
file:
|
|
108
|
+
file: ${SECRETS_DIR}/llm_api_key
|
|
108
109
|
telegram_bot_token:
|
|
109
|
-
file:
|
|
110
|
+
file: ${SECRETS_DIR}/telegram_bot_token
|
|
110
111
|
gateway_token:
|
|
111
|
-
file:
|
|
112
|
+
file: ${SECRETS_DIR}/gateway_token
|
|
112
113
|
|
|
113
114
|
volumes:
|
|
114
115
|
limbo-data:
|
|
@@ -125,6 +126,9 @@ const COMPOSE_CONTENT_HARDENED = `services:
|
|
|
125
126
|
- no-new-privileges:true
|
|
126
127
|
cap_drop:
|
|
127
128
|
- ALL
|
|
129
|
+
cap_add:
|
|
130
|
+
- CHOWN
|
|
131
|
+
- FOWNER
|
|
128
132
|
pids_limit: 200
|
|
129
133
|
tmpfs:
|
|
130
134
|
- /tmp:size=100M,noexec,nosuid,nodev
|
|
@@ -133,14 +137,14 @@ const COMPOSE_CONTENT_HARDENED = `services:
|
|
|
133
137
|
- "127.0.0.1:${PORT}:${PORT}"
|
|
134
138
|
volumes:
|
|
135
139
|
- limbo-data:/data
|
|
136
|
-
-
|
|
140
|
+
- ${VAULT_DIR}:/data/vault
|
|
137
141
|
- limbo-openclaw-state:/home/limbo/.openclaw
|
|
138
142
|
secrets:
|
|
139
143
|
- llm_api_key
|
|
140
144
|
- telegram_bot_token
|
|
141
145
|
- gateway_token
|
|
142
146
|
env_file:
|
|
143
|
-
-
|
|
147
|
+
- ${LIMBO_DIR}/.env
|
|
144
148
|
environment:
|
|
145
149
|
OPENCLAW_CONFIG_PATH: /home/limbo/.openclaw/openclaw.json
|
|
146
150
|
OPENCLAW_STATE_DIR: /home/limbo/.openclaw
|
|
@@ -178,8 +182,8 @@ const COMPOSE_CONTENT_HARDENED = `services:
|
|
|
178
182
|
- internal
|
|
179
183
|
- external
|
|
180
184
|
volumes:
|
|
181
|
-
-
|
|
182
|
-
-
|
|
185
|
+
- ${LIMBO_DIR}/squid/squid.conf:/etc/squid/squid.conf:ro
|
|
186
|
+
- ${LIMBO_DIR}/squid/allowed-domains.txt:/etc/squid/allowed-domains.txt:ro
|
|
183
187
|
|
|
184
188
|
networks:
|
|
185
189
|
internal:
|
|
@@ -188,11 +192,11 @@ networks:
|
|
|
188
192
|
|
|
189
193
|
secrets:
|
|
190
194
|
llm_api_key:
|
|
191
|
-
file:
|
|
195
|
+
file: ${SECRETS_DIR}/llm_api_key
|
|
192
196
|
telegram_bot_token:
|
|
193
|
-
file:
|
|
197
|
+
file: ${SECRETS_DIR}/telegram_bot_token
|
|
194
198
|
gateway_token:
|
|
195
|
-
file:
|
|
199
|
+
file: ${SECRETS_DIR}/gateway_token
|
|
196
200
|
|
|
197
201
|
volumes:
|
|
198
202
|
limbo-data:
|
|
@@ -254,14 +258,22 @@ const TEXT = {
|
|
|
254
258
|
logsHint: 'Check logs with: limbo logs',
|
|
255
259
|
healthy: 'Container is healthy.',
|
|
256
260
|
subscriptionSetup: 'Provider authentication',
|
|
257
|
-
openaiSubscriptionIntro: 'Limbo will authenticate with your
|
|
261
|
+
openaiSubscriptionIntro: 'Limbo will authenticate with your OpenAI account. A URL will open in your browser — log in and authorize access.',
|
|
258
262
|
anthropicSubscriptionIntro: 'Generate a Claude setup-token on any machine with `claude setup-token`, then paste it into the next step.',
|
|
263
|
+
claudeTokenPrompt: ' Setup token: ',
|
|
264
|
+
claudeTokenInvalid: 'Invalid token. It should start with "sk-ant-".',
|
|
265
|
+
claudeTokenWritten: 'Auth profile written.',
|
|
259
266
|
authFlowStart: 'Starting authentication...',
|
|
260
267
|
authFlowDone: 'Authentication complete.',
|
|
261
|
-
modelConnected: (model) => `Model connected: ${model}`,
|
|
262
268
|
authFlowFailed: 'Authentication did not complete successfully.',
|
|
263
269
|
authStatusFailed: 'Provider auth is still missing or invalid. Try running with --reconfigure.',
|
|
270
|
+
oauthPasteHint: 'After you log in, the browser will redirect to a localhost URL (it may show an error page — that\'s normal). Copy the full URL from the address bar and paste it below.',
|
|
271
|
+
oauthCallbackPrompt: ' Paste the callback URL: ',
|
|
272
|
+
oauthInvalidCallback: 'Could not extract an authorization code from that input. Paste the full URL from the browser address bar.',
|
|
273
|
+
oauthExchanging: 'Exchanging authorization code for tokens...',
|
|
274
|
+
oauthStateMismatch: 'OAuth state mismatch — proceeding anyway, but this may indicate a problem.',
|
|
264
275
|
configFlowStart: 'Applying configuration...',
|
|
276
|
+
configFlowSlow: 'This may take a couple of minutes.',
|
|
265
277
|
configFlowDone: 'Configuration applied.',
|
|
266
278
|
configFlowFailed: 'Could not apply configuration. Check your settings and try again.',
|
|
267
279
|
composing: 'Initializing...',
|
|
@@ -347,14 +359,22 @@ const TEXT = {
|
|
|
347
359
|
logsHint: 'Mira los logs con: limbo logs',
|
|
348
360
|
healthy: 'El container esta healthy.',
|
|
349
361
|
subscriptionSetup: 'Autenticacion del provider',
|
|
350
|
-
openaiSubscriptionIntro: 'Limbo va a autenticarse con tu
|
|
362
|
+
openaiSubscriptionIntro: 'Limbo va a autenticarse con tu cuenta de OpenAI. Se va a abrir una URL en tu navegador — inicia sesion y autoriza el acceso.',
|
|
351
363
|
anthropicSubscriptionIntro: 'Genera un Claude setup-token en cualquier maquina con `claude setup-token` y pegalo en el siguiente paso.',
|
|
364
|
+
claudeTokenPrompt: ' Setup token: ',
|
|
365
|
+
claudeTokenInvalid: 'Token invalido. Deberia empezar con "sk-ant-".',
|
|
366
|
+
claudeTokenWritten: 'Perfil de auth guardado.',
|
|
352
367
|
authFlowStart: 'Iniciando autenticacion...',
|
|
353
368
|
authFlowDone: 'Autenticacion completada.',
|
|
354
|
-
modelConnected: (model) => `Modelo conectado: ${model}`,
|
|
355
369
|
authFlowFailed: 'La autenticacion no termino correctamente.',
|
|
356
370
|
authStatusFailed: 'La autenticacion del provider sigue siendo invalida o no esta configurada. Proba con --reconfigure.',
|
|
371
|
+
oauthPasteHint: 'Despues de loguearte, el browser va a redirigir a una URL de localhost (puede mostrar una pagina de error — es normal). Copa la URL completa de la barra de direcciones y pegala abajo.',
|
|
372
|
+
oauthCallbackPrompt: ' Pega la URL de callback: ',
|
|
373
|
+
oauthInvalidCallback: 'No se pudo extraer un codigo de autorizacion. Pega la URL completa de la barra del navegador.',
|
|
374
|
+
oauthExchanging: 'Intercambiando codigo de autorizacion por tokens...',
|
|
375
|
+
oauthStateMismatch: 'OAuth state mismatch — se continua igual, pero esto puede indicar un problema.',
|
|
357
376
|
configFlowStart: 'Aplicando configuracion...',
|
|
377
|
+
configFlowSlow: 'Esto puede tardar un par de minutos.',
|
|
358
378
|
configFlowDone: 'Configuracion aplicada.',
|
|
359
379
|
configFlowFailed: 'No se pudo aplicar la configuracion. Revisa los ajustes e intenta de nuevo.',
|
|
360
380
|
composing: 'Inicializando...',
|
|
@@ -751,19 +771,21 @@ function ensureGatewayToken(existingEnv) {
|
|
|
751
771
|
}
|
|
752
772
|
|
|
753
773
|
function pullOrBuildImage(lang) {
|
|
774
|
+
// When running from the repo (npx .), prefer local build over registry pull.
|
|
775
|
+
const repoDockerfile = path.join(__dirname, 'Dockerfile');
|
|
776
|
+
if (fs.existsSync(repoDockerfile)) {
|
|
777
|
+
header(t(lang, 'buildingFallback'));
|
|
778
|
+
execSync(`docker build -t ${GHCR_IMAGE}:${DEFAULT_TAG} .`, { stdio: 'inherit', cwd: __dirname });
|
|
779
|
+
ok(t(lang, 'buildOk', DEFAULT_TAG));
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
|
|
754
783
|
header(t(lang, 'pullingImage'));
|
|
755
784
|
try {
|
|
756
785
|
run('docker compose pull -q');
|
|
757
786
|
ok(t(lang, 'imagePulled'));
|
|
758
787
|
} catch {
|
|
759
|
-
|
|
760
|
-
const repoDockerfile = path.join(__dirname, 'Dockerfile');
|
|
761
|
-
if (!fs.existsSync(repoDockerfile)) {
|
|
762
|
-
die('Could not pull image and no local Dockerfile found. Check your network or GHCR access.');
|
|
763
|
-
}
|
|
764
|
-
log(t(lang, 'buildingFallback'));
|
|
765
|
-
execSync(`docker build -t ${GHCR_IMAGE}:${DEFAULT_TAG} .`, { stdio: 'inherit', cwd: __dirname });
|
|
766
|
-
ok(t(lang, 'buildOk', DEFAULT_TAG));
|
|
788
|
+
die('Could not pull image and no local Dockerfile found. Check your network or GHCR access.');
|
|
767
789
|
}
|
|
768
790
|
}
|
|
769
791
|
|
|
@@ -771,8 +793,24 @@ function runOpenClaw(args, opts = {}) {
|
|
|
771
793
|
return runDockerCompose(['run', '--rm', '--entrypoint', 'openclaw', 'limbo', ...args], opts);
|
|
772
794
|
}
|
|
773
795
|
|
|
796
|
+
// Fix volume ownership before any docker compose run commands.
|
|
797
|
+
// cap_drop:ALL strips CAP_DAC_OVERRIDE from root, so a root-user container
|
|
798
|
+
// cannot write to limbo-owned volumes. This one-shot container runs as root
|
|
799
|
+
// with the minimum caps needed to chown the volume dirs back to limbo.
|
|
800
|
+
function ensureVolumePermissions() {
|
|
801
|
+
runDockerCompose([
|
|
802
|
+
'run', '--rm', '--no-deps',
|
|
803
|
+
'--user', 'root',
|
|
804
|
+
'--cap-add', 'DAC_OVERRIDE',
|
|
805
|
+
'--entrypoint', 'sh',
|
|
806
|
+
'limbo',
|
|
807
|
+
'-c', 'chown -R limbo:limbo /data /home/limbo/.openclaw 2>/dev/null; true',
|
|
808
|
+
], { stdio: 'pipe' });
|
|
809
|
+
}
|
|
810
|
+
|
|
774
811
|
function applyOpenClawConfig(cfg) {
|
|
775
812
|
header(t(cfg.language, 'configFlowStart'));
|
|
813
|
+
log(t(cfg.language, 'configFlowSlow'));
|
|
776
814
|
|
|
777
815
|
const setCommands = [
|
|
778
816
|
['config', 'set', 'gateway.mode', 'local'],
|
|
@@ -790,9 +828,15 @@ function applyOpenClawConfig(cfg) {
|
|
|
790
828
|
);
|
|
791
829
|
}
|
|
792
830
|
|
|
831
|
+
const total = setCommands.length + 1; // +1 for validate
|
|
832
|
+
let step = 0;
|
|
833
|
+
|
|
793
834
|
for (const command of setCommands) {
|
|
835
|
+
step++;
|
|
836
|
+
process.stdout.write(`\r${c.dim} [${step}/${total}] ${command.slice(1, 4).join(' ')}${c.reset}`.padEnd(60));
|
|
794
837
|
const result = runOpenClaw(command, { stdio: 'pipe' });
|
|
795
838
|
if (result.status !== 0) {
|
|
839
|
+
console.log('');
|
|
796
840
|
process.stdout.write(result.stdout || '');
|
|
797
841
|
process.stderr.write(result.stderr || '');
|
|
798
842
|
die(t(cfg.language, 'configFlowFailed'));
|
|
@@ -803,154 +847,247 @@ function applyOpenClawConfig(cfg) {
|
|
|
803
847
|
runOpenClaw(['config', 'unset', 'channels.telegram'], { stdio: 'pipe' });
|
|
804
848
|
}
|
|
805
849
|
|
|
850
|
+
step++;
|
|
851
|
+
process.stdout.write(`\r${c.dim} [${step}/${total}] config validate${c.reset}`.padEnd(60));
|
|
806
852
|
const validateResult = runOpenClaw(['config', 'validate'], { stdio: 'pipe' });
|
|
807
853
|
if (validateResult.status !== 0) {
|
|
854
|
+
console.log('');
|
|
808
855
|
process.stdout.write(validateResult.stdout || '');
|
|
809
856
|
process.stderr.write(validateResult.stderr || '');
|
|
810
857
|
die(t(cfg.language, 'configFlowFailed'));
|
|
811
858
|
}
|
|
812
859
|
|
|
860
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
813
861
|
ok(t(cfg.language, 'configFlowDone'));
|
|
814
862
|
}
|
|
815
863
|
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
// Matches lines consisting entirely of TUI chrome: spinner glyphs, box-drawing chars, and
|
|
832
|
-
// clack/prompt decorations. Used to suppress animation frame scatter from OpenClaw TUI output.
|
|
833
|
-
const TUI_CHROME_RE = /^[\s\u2500-\u257f\u2580-\u259f\u25a0-\u25ff\u2600-\u26ff\u2190-\u21ff\u2700-\u27bf\u2800-\u28ff]*$/u;
|
|
834
|
-
|
|
835
|
-
// Pure helper: flush complete lines from a raw buffer.
|
|
836
|
-
// Normalises \r\n → \n, splits on \n, and within each segment keeps only the LAST
|
|
837
|
-
// \r-separated piece — that's the final rendered state after TUI in-place overwrites.
|
|
838
|
-
// Returns the lines to emit and the leftover incomplete segment.
|
|
839
|
-
function flushStreamLines(buf) {
|
|
840
|
-
const normalized = buf.replace(/\r\n/g, '\n');
|
|
841
|
-
const segments = normalized.split('\n');
|
|
842
|
-
const remaining = segments.pop(); // no trailing \n yet
|
|
843
|
-
const lines = segments.map((seg) => {
|
|
844
|
-
const frames = seg.split('\r');
|
|
845
|
-
return frames[frames.length - 1]; // last frame = final rendered content
|
|
846
|
-
});
|
|
847
|
-
return { lines, remaining };
|
|
864
|
+
// ─── Native OAuth (PKCE) for OpenAI Codex ───────────────────────────────────
|
|
865
|
+
// Implements the full OAuth flow locally so we never need OpenClaw's interactive TUI.
|
|
866
|
+
|
|
867
|
+
const OPENAI_OAUTH = {
|
|
868
|
+
clientId: 'app_EMoamEEZ73f0CkXaXp7hrann',
|
|
869
|
+
authorizeUrl: 'https://auth.openai.com/oauth/authorize',
|
|
870
|
+
tokenUrl: 'https://auth.openai.com/oauth/token',
|
|
871
|
+
redirectUri: 'http://localhost:1455/auth/callback',
|
|
872
|
+
scopes: 'openid profile email offline_access',
|
|
873
|
+
};
|
|
874
|
+
|
|
875
|
+
function generatePKCE() {
|
|
876
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
877
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
878
|
+
return { verifier, challenge };
|
|
848
879
|
}
|
|
849
880
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
buf += data.toString();
|
|
866
|
-
// flushStreamLines keeps only the final \r frame per \n-terminated line,
|
|
867
|
-
// discarding all intermediate TUI animation states (character-by-character
|
|
868
|
-
// reveals, spinner frames) that would otherwise scatter across the terminal.
|
|
869
|
-
const { lines, remaining } = flushStreamLines(buf);
|
|
870
|
-
buf = remaining;
|
|
871
|
-
for (const line of lines) emitLine(line);
|
|
872
|
-
};
|
|
881
|
+
function buildOAuthUrl(pkce, state) {
|
|
882
|
+
const params = new URLSearchParams({
|
|
883
|
+
response_type: 'code',
|
|
884
|
+
client_id: OPENAI_OAUTH.clientId,
|
|
885
|
+
redirect_uri: OPENAI_OAUTH.redirectUri,
|
|
886
|
+
scope: OPENAI_OAUTH.scopes,
|
|
887
|
+
code_challenge: pkce.challenge,
|
|
888
|
+
code_challenge_method: 'S256',
|
|
889
|
+
state,
|
|
890
|
+
id_token_add_organizations: 'true',
|
|
891
|
+
codex_cli_simplified_flow: 'true',
|
|
892
|
+
originator: 'pi',
|
|
893
|
+
});
|
|
894
|
+
return `${OPENAI_OAUTH.authorizeUrl}?${params}`;
|
|
895
|
+
}
|
|
873
896
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
if (onUrl) onUrl(url);
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
return; // don't double-print the line containing the URL
|
|
886
|
-
}
|
|
887
|
-
// Suppress OpenClaw branding
|
|
888
|
-
if (/openclaw/i.test(line)) return;
|
|
889
|
-
// Suppress TUI chrome: lines consisting only of spinner/decoration/box-drawing chars.
|
|
890
|
-
if (TUI_CHROME_RE.test(line)) return;
|
|
891
|
-
if (line.trim()) console.log(` ${line}`);
|
|
897
|
+
function parseCallbackInput(input) {
|
|
898
|
+
const trimmed = input.trim();
|
|
899
|
+
// Accept full URL, just the code, or code#state / code=XXX&state=YYY
|
|
900
|
+
try {
|
|
901
|
+
const url = new URL(trimmed);
|
|
902
|
+
return {
|
|
903
|
+
code: url.searchParams.get('code'),
|
|
904
|
+
state: url.searchParams.get('state'),
|
|
892
905
|
};
|
|
906
|
+
} catch {}
|
|
907
|
+
// Try as query string
|
|
908
|
+
if (trimmed.includes('code=')) {
|
|
909
|
+
const params = new URLSearchParams(trimmed.replace(/^\?/, ''));
|
|
910
|
+
return { code: params.get('code'), state: params.get('state') };
|
|
911
|
+
}
|
|
912
|
+
// Bare code
|
|
913
|
+
return { code: trimmed, state: null };
|
|
914
|
+
}
|
|
893
915
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
}
|
|
902
|
-
resolve(code ?? 1);
|
|
903
|
-
});
|
|
904
|
-
proc.on('error', () => resolve(1));
|
|
916
|
+
async function exchangeCodeForTokens(code, pkceVerifier) {
|
|
917
|
+
const body = new URLSearchParams({
|
|
918
|
+
grant_type: 'authorization_code',
|
|
919
|
+
client_id: OPENAI_OAUTH.clientId,
|
|
920
|
+
code,
|
|
921
|
+
code_verifier: pkceVerifier,
|
|
922
|
+
redirect_uri: OPENAI_OAUTH.redirectUri,
|
|
905
923
|
});
|
|
924
|
+
const res = await fetch(OPENAI_OAUTH.tokenUrl, {
|
|
925
|
+
method: 'POST',
|
|
926
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
927
|
+
body: body.toString(),
|
|
928
|
+
});
|
|
929
|
+
if (!res.ok) {
|
|
930
|
+
const text = await res.text();
|
|
931
|
+
throw new Error(`Token exchange failed (${res.status}): ${text}`);
|
|
932
|
+
}
|
|
933
|
+
return res.json();
|
|
906
934
|
}
|
|
907
935
|
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
if (
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
log(t(cfg.language, 'anthropicSubscriptionIntro'));
|
|
914
|
-
}
|
|
915
|
-
log(t(cfg.language, 'authFlowStart'));
|
|
936
|
+
function decodeJwtPayload(token) {
|
|
937
|
+
const parts = token.split('.');
|
|
938
|
+
if (parts.length < 2) return {};
|
|
939
|
+
return JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
940
|
+
}
|
|
916
941
|
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
942
|
+
function writeAuthProfilesToDocker(store) {
|
|
943
|
+
const json = JSON.stringify(store, null, 2);
|
|
944
|
+
const destDir = '/home/limbo/.openclaw/agents/main/agent';
|
|
945
|
+
const destFile = `${destDir}/auth-profiles.json`;
|
|
946
|
+
spawnSync('docker', [
|
|
947
|
+
'compose', 'run', '--rm', '--no-deps', '--entrypoint', 'sh', 'limbo',
|
|
948
|
+
'-c', `mkdir -p "${destDir}" && cat > "${destFile}"`,
|
|
949
|
+
], {
|
|
950
|
+
cwd: LIMBO_DIR,
|
|
951
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
952
|
+
input: json,
|
|
953
|
+
encoding: 'utf8',
|
|
954
|
+
});
|
|
955
|
+
}
|
|
920
956
|
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
957
|
+
function buildCodexAuthProfile(profile) {
|
|
958
|
+
const profileId = profile.email ? `openai-codex:${profile.email}` : 'openai-codex:default';
|
|
959
|
+
return {
|
|
960
|
+
version: 1,
|
|
961
|
+
profiles: {
|
|
962
|
+
[profileId]: {
|
|
963
|
+
type: 'oauth',
|
|
964
|
+
provider: 'openai-codex',
|
|
965
|
+
access: profile.access,
|
|
966
|
+
refresh: profile.refresh,
|
|
967
|
+
expires: profile.expires,
|
|
968
|
+
accountId: profile.accountId,
|
|
932
969
|
},
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
970
|
+
},
|
|
971
|
+
order: {},
|
|
972
|
+
lastGood: {},
|
|
973
|
+
usageStats: {},
|
|
974
|
+
};
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function buildAnthropicAuthProfile(token) {
|
|
978
|
+
return {
|
|
979
|
+
version: 1,
|
|
980
|
+
profiles: {
|
|
981
|
+
'anthropic:token': {
|
|
982
|
+
type: 'token',
|
|
983
|
+
provider: 'anthropic',
|
|
984
|
+
token,
|
|
985
|
+
},
|
|
986
|
+
},
|
|
987
|
+
order: { anthropic: ['anthropic:token'] },
|
|
988
|
+
lastGood: {},
|
|
989
|
+
usageStats: {},
|
|
990
|
+
};
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
function parseClaudeSetupToken(raw) {
|
|
994
|
+
const trimmed = raw.trim();
|
|
995
|
+
if (/^sk-ant-[a-zA-Z0-9_-]+$/.test(trimmed)) return trimmed;
|
|
996
|
+
return null;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
async function runClaudeSetupTokenAuth(language) {
|
|
1000
|
+
const tokenRaw = await promptValidated(
|
|
1001
|
+
t(language, 'claudeTokenPrompt'),
|
|
1002
|
+
(value) => {
|
|
1003
|
+
if (!value) return { ok: false, message: t(language, 'requiredField') };
|
|
1004
|
+
const parsed = parseClaudeSetupToken(value);
|
|
1005
|
+
if (!parsed) return { ok: false, message: t(language, 'claudeTokenInvalid') };
|
|
1006
|
+
return { ok: true, value };
|
|
1007
|
+
},
|
|
1008
|
+
);
|
|
939
1009
|
|
|
940
|
-
|
|
1010
|
+
const token = parseClaudeSetupToken(tokenRaw);
|
|
1011
|
+
const store = buildAnthropicAuthProfile(token);
|
|
1012
|
+
writeAuthProfilesToDocker(store);
|
|
1013
|
+
ok(t(language, 'claudeTokenWritten'));
|
|
1014
|
+
return 0;
|
|
1015
|
+
}
|
|
941
1016
|
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
1017
|
+
async function runCodexOAuth(language) {
|
|
1018
|
+
const pkce = generatePKCE();
|
|
1019
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
1020
|
+
const authUrl = buildOAuthUrl(pkce, state);
|
|
1021
|
+
|
|
1022
|
+
// Open browser automatically
|
|
1023
|
+
const openCmd = process.platform === 'darwin' ? 'open'
|
|
1024
|
+
: process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
1025
|
+
try { execSync(`${openCmd} "${authUrl}"`, { stdio: 'ignore' }); } catch {}
|
|
1026
|
+
|
|
1027
|
+
console.log(`\n ${c.cyan}${c.bold}→ ${authUrl}${c.reset}\n`);
|
|
1028
|
+
log(t(language, 'oauthPasteHint'));
|
|
1029
|
+
|
|
1030
|
+
const callbackRaw = await promptValidated(
|
|
1031
|
+
t(language, 'oauthCallbackPrompt'),
|
|
1032
|
+
(value) => {
|
|
1033
|
+
if (!value) return { ok: false, message: t(language, 'requiredField') };
|
|
1034
|
+
const parsed = parseCallbackInput(value);
|
|
1035
|
+
if (!parsed.code) return { ok: false, message: t(language, 'oauthInvalidCallback') };
|
|
1036
|
+
return { ok: true, value };
|
|
1037
|
+
},
|
|
945
1038
|
);
|
|
946
1039
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
die(t(cfg.language, 'authStatusFailed'));
|
|
1040
|
+
const { code, state: returnedState } = parseCallbackInput(callbackRaw);
|
|
1041
|
+
if (returnedState && returnedState !== state) {
|
|
1042
|
+
warn(t(language, 'oauthStateMismatch'));
|
|
951
1043
|
}
|
|
952
1044
|
|
|
953
|
-
|
|
1045
|
+
log(t(language, 'oauthExchanging'));
|
|
1046
|
+
const tokens = await exchangeCodeForTokens(code, pkce.verifier);
|
|
1047
|
+
|
|
1048
|
+
// Extract account info from JWT
|
|
1049
|
+
const jwt = decodeJwtPayload(tokens.access_token);
|
|
1050
|
+
const authClaim = jwt['https://api.openai.com/auth'] || {};
|
|
1051
|
+
const accountId = authClaim.chatgpt_account_id || '';
|
|
1052
|
+
const email = jwt.email || '';
|
|
1053
|
+
|
|
1054
|
+
const store = buildCodexAuthProfile({
|
|
1055
|
+
access: tokens.access_token,
|
|
1056
|
+
refresh: tokens.refresh_token,
|
|
1057
|
+
expires: Date.now() + (tokens.expires_in * 1000),
|
|
1058
|
+
accountId,
|
|
1059
|
+
email,
|
|
1060
|
+
});
|
|
1061
|
+
writeAuthProfilesToDocker(store);
|
|
1062
|
+
|
|
1063
|
+
return 0;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
// ─── Subscription auth flow ──────────────────────────────────────────────────
|
|
1067
|
+
|
|
1068
|
+
async function runSubscriptionAuthFlow(cfg) {
|
|
1069
|
+
header(t(cfg.language, 'subscriptionSetup'));
|
|
1070
|
+
|
|
1071
|
+
if (cfg.providerFamily === 'openai') {
|
|
1072
|
+
log(t(cfg.language, 'openaiSubscriptionIntro'));
|
|
1073
|
+
log(t(cfg.language, 'authFlowStart'));
|
|
1074
|
+
try {
|
|
1075
|
+
await runCodexOAuth(cfg.language);
|
|
1076
|
+
} catch (err) {
|
|
1077
|
+
die(`${t(cfg.language, 'authFlowFailed')}: ${err.message}`);
|
|
1078
|
+
}
|
|
1079
|
+
// Native OAuth — tokens verified by successful exchange, no status check needed
|
|
1080
|
+
ok(t(cfg.language, 'authFlowDone'));
|
|
1081
|
+
} else {
|
|
1082
|
+
log(t(cfg.language, 'anthropicSubscriptionIntro'));
|
|
1083
|
+
log(t(cfg.language, 'authFlowStart'));
|
|
1084
|
+
try {
|
|
1085
|
+
await runClaudeSetupTokenAuth(cfg.language);
|
|
1086
|
+
} catch (err) {
|
|
1087
|
+
die(`${t(cfg.language, 'authFlowFailed')}: ${err.message}`);
|
|
1088
|
+
}
|
|
1089
|
+
ok(t(cfg.language, 'authFlowDone'));
|
|
1090
|
+
}
|
|
954
1091
|
}
|
|
955
1092
|
|
|
956
1093
|
function printSuccess(cfg, gatewayToken) {
|
|
@@ -1026,6 +1163,7 @@ async function cmdStart() {
|
|
|
1026
1163
|
}
|
|
1027
1164
|
|
|
1028
1165
|
pullOrBuildImage(cfg.language);
|
|
1166
|
+
ensureVolumePermissions();
|
|
1029
1167
|
|
|
1030
1168
|
if (cfg.authMode === 'subscription' && (process.argv.includes('--reconfigure') || !alreadyHasEnv)) {
|
|
1031
1169
|
await runSubscriptionAuthFlow(cfg);
|
|
@@ -1116,29 +1254,24 @@ ${c.bold}Data directory:${c.reset} ${LIMBO_DIR}
|
|
|
1116
1254
|
|
|
1117
1255
|
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
1118
1256
|
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
});
|
|
1141
|
-
} else {
|
|
1142
|
-
// Exported for unit testing — not part of the public CLI API.
|
|
1143
|
-
module.exports = { stripAnsi, AUTH_URL_RE, TUI_CHROME_RE, flushStreamLines };
|
|
1144
|
-
}
|
|
1257
|
+
const [,, cmd = 'start'] = process.argv;
|
|
1258
|
+
|
|
1259
|
+
(async () => {
|
|
1260
|
+
switch (cmd) {
|
|
1261
|
+
case 'start':
|
|
1262
|
+
case 'install': await cmdStart(); break;
|
|
1263
|
+
case 'stop': cmdStop(); break;
|
|
1264
|
+
case 'logs': cmdLogs(); break;
|
|
1265
|
+
case 'update': cmdUpdate(); break;
|
|
1266
|
+
case 'status': cmdStatus(); break;
|
|
1267
|
+
case 'help':
|
|
1268
|
+
case '--help':
|
|
1269
|
+
case '-h': cmdHelp(); break;
|
|
1270
|
+
default:
|
|
1271
|
+
warn(t('en', 'unknownCommand', cmd));
|
|
1272
|
+
cmdHelp();
|
|
1273
|
+
process.exit(1);
|
|
1274
|
+
}
|
|
1275
|
+
})().catch((err) => {
|
|
1276
|
+
die(err.message || String(err));
|
|
1277
|
+
});
|