limbo-ai 1.9.5 → 1.10.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 +265 -172
- 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,19 @@ 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.',
|
|
259
263
|
authFlowStart: 'Starting authentication...',
|
|
260
264
|
authFlowDone: 'Authentication complete.',
|
|
261
|
-
modelConnected: (model) => `Model connected: ${model}`,
|
|
262
265
|
authFlowFailed: 'Authentication did not complete successfully.',
|
|
263
266
|
authStatusFailed: 'Provider auth is still missing or invalid. Try running with --reconfigure.',
|
|
267
|
+
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.',
|
|
268
|
+
oauthCallbackPrompt: ' Paste the callback URL: ',
|
|
269
|
+
oauthInvalidCallback: 'Could not extract an authorization code from that input. Paste the full URL from the browser address bar.',
|
|
270
|
+
oauthExchanging: 'Exchanging authorization code for tokens...',
|
|
271
|
+
oauthStateMismatch: 'OAuth state mismatch — proceeding anyway, but this may indicate a problem.',
|
|
264
272
|
configFlowStart: 'Applying configuration...',
|
|
273
|
+
configFlowSlow: 'This may take a couple of minutes.',
|
|
265
274
|
configFlowDone: 'Configuration applied.',
|
|
266
275
|
configFlowFailed: 'Could not apply configuration. Check your settings and try again.',
|
|
267
276
|
composing: 'Initializing...',
|
|
@@ -347,14 +356,19 @@ const TEXT = {
|
|
|
347
356
|
logsHint: 'Mira los logs con: limbo logs',
|
|
348
357
|
healthy: 'El container esta healthy.',
|
|
349
358
|
subscriptionSetup: 'Autenticacion del provider',
|
|
350
|
-
openaiSubscriptionIntro: 'Limbo va a autenticarse con tu
|
|
359
|
+
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
360
|
anthropicSubscriptionIntro: 'Genera un Claude setup-token en cualquier maquina con `claude setup-token` y pegalo en el siguiente paso.',
|
|
352
361
|
authFlowStart: 'Iniciando autenticacion...',
|
|
353
362
|
authFlowDone: 'Autenticacion completada.',
|
|
354
|
-
modelConnected: (model) => `Modelo conectado: ${model}`,
|
|
355
363
|
authFlowFailed: 'La autenticacion no termino correctamente.',
|
|
356
364
|
authStatusFailed: 'La autenticacion del provider sigue siendo invalida o no esta configurada. Proba con --reconfigure.',
|
|
365
|
+
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.',
|
|
366
|
+
oauthCallbackPrompt: ' Pega la URL de callback: ',
|
|
367
|
+
oauthInvalidCallback: 'No se pudo extraer un codigo de autorizacion. Pega la URL completa de la barra del navegador.',
|
|
368
|
+
oauthExchanging: 'Intercambiando codigo de autorizacion por tokens...',
|
|
369
|
+
oauthStateMismatch: 'OAuth state mismatch — se continua igual, pero esto puede indicar un problema.',
|
|
357
370
|
configFlowStart: 'Aplicando configuracion...',
|
|
371
|
+
configFlowSlow: 'Esto puede tardar un par de minutos.',
|
|
358
372
|
configFlowDone: 'Configuracion aplicada.',
|
|
359
373
|
configFlowFailed: 'No se pudo aplicar la configuracion. Revisa los ajustes e intenta de nuevo.',
|
|
360
374
|
composing: 'Inicializando...',
|
|
@@ -751,19 +765,21 @@ function ensureGatewayToken(existingEnv) {
|
|
|
751
765
|
}
|
|
752
766
|
|
|
753
767
|
function pullOrBuildImage(lang) {
|
|
768
|
+
// When running from the repo (npx .), prefer local build over registry pull.
|
|
769
|
+
const repoDockerfile = path.join(__dirname, 'Dockerfile');
|
|
770
|
+
if (fs.existsSync(repoDockerfile)) {
|
|
771
|
+
header(t(lang, 'buildingFallback'));
|
|
772
|
+
execSync(`docker build -t ${GHCR_IMAGE}:${DEFAULT_TAG} .`, { stdio: 'inherit', cwd: __dirname });
|
|
773
|
+
ok(t(lang, 'buildOk', DEFAULT_TAG));
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
754
777
|
header(t(lang, 'pullingImage'));
|
|
755
778
|
try {
|
|
756
779
|
run('docker compose pull -q');
|
|
757
780
|
ok(t(lang, 'imagePulled'));
|
|
758
781
|
} 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));
|
|
782
|
+
die('Could not pull image and no local Dockerfile found. Check your network or GHCR access.');
|
|
767
783
|
}
|
|
768
784
|
}
|
|
769
785
|
|
|
@@ -771,8 +787,24 @@ function runOpenClaw(args, opts = {}) {
|
|
|
771
787
|
return runDockerCompose(['run', '--rm', '--entrypoint', 'openclaw', 'limbo', ...args], opts);
|
|
772
788
|
}
|
|
773
789
|
|
|
790
|
+
// Fix volume ownership before any docker compose run commands.
|
|
791
|
+
// cap_drop:ALL strips CAP_DAC_OVERRIDE from root, so a root-user container
|
|
792
|
+
// cannot write to limbo-owned volumes. This one-shot container runs as root
|
|
793
|
+
// with the minimum caps needed to chown the volume dirs back to limbo.
|
|
794
|
+
function ensureVolumePermissions() {
|
|
795
|
+
runDockerCompose([
|
|
796
|
+
'run', '--rm', '--no-deps',
|
|
797
|
+
'--user', 'root',
|
|
798
|
+
'--cap-add', 'DAC_OVERRIDE',
|
|
799
|
+
'--entrypoint', 'sh',
|
|
800
|
+
'limbo',
|
|
801
|
+
'-c', 'chown -R limbo:limbo /data /home/limbo/.openclaw 2>/dev/null; true',
|
|
802
|
+
], { stdio: 'pipe' });
|
|
803
|
+
}
|
|
804
|
+
|
|
774
805
|
function applyOpenClawConfig(cfg) {
|
|
775
806
|
header(t(cfg.language, 'configFlowStart'));
|
|
807
|
+
log(t(cfg.language, 'configFlowSlow'));
|
|
776
808
|
|
|
777
809
|
const setCommands = [
|
|
778
810
|
['config', 'set', 'gateway.mode', 'local'],
|
|
@@ -790,9 +822,15 @@ function applyOpenClawConfig(cfg) {
|
|
|
790
822
|
);
|
|
791
823
|
}
|
|
792
824
|
|
|
825
|
+
const total = setCommands.length + 1; // +1 for validate
|
|
826
|
+
let step = 0;
|
|
827
|
+
|
|
793
828
|
for (const command of setCommands) {
|
|
829
|
+
step++;
|
|
830
|
+
process.stdout.write(`\r${c.dim} [${step}/${total}] ${command.slice(1, 4).join(' ')}${c.reset}`.padEnd(60));
|
|
794
831
|
const result = runOpenClaw(command, { stdio: 'pipe' });
|
|
795
832
|
if (result.status !== 0) {
|
|
833
|
+
console.log('');
|
|
796
834
|
process.stdout.write(result.stdout || '');
|
|
797
835
|
process.stderr.write(result.stderr || '');
|
|
798
836
|
die(t(cfg.language, 'configFlowFailed'));
|
|
@@ -803,154 +841,213 @@ function applyOpenClawConfig(cfg) {
|
|
|
803
841
|
runOpenClaw(['config', 'unset', 'channels.telegram'], { stdio: 'pipe' });
|
|
804
842
|
}
|
|
805
843
|
|
|
844
|
+
step++;
|
|
845
|
+
process.stdout.write(`\r${c.dim} [${step}/${total}] config validate${c.reset}`.padEnd(60));
|
|
806
846
|
const validateResult = runOpenClaw(['config', 'validate'], { stdio: 'pipe' });
|
|
807
847
|
if (validateResult.status !== 0) {
|
|
848
|
+
console.log('');
|
|
808
849
|
process.stdout.write(validateResult.stdout || '');
|
|
809
850
|
process.stderr.write(validateResult.stderr || '');
|
|
810
851
|
die(t(cfg.language, 'configFlowFailed'));
|
|
811
852
|
}
|
|
812
853
|
|
|
854
|
+
process.stdout.write('\r' + ' '.repeat(60) + '\r');
|
|
813
855
|
ok(t(cfg.language, 'configFlowDone'));
|
|
814
856
|
}
|
|
815
857
|
|
|
816
|
-
//
|
|
817
|
-
//
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
const
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
return frames[frames.length - 1]; // last frame = final rendered content
|
|
858
|
+
// ─── Native OAuth (PKCE) for OpenAI Codex ───────────────────────────────────
|
|
859
|
+
// Implements the full OAuth flow locally so we never need OpenClaw's interactive TUI.
|
|
860
|
+
|
|
861
|
+
const OPENAI_OAUTH = {
|
|
862
|
+
clientId: 'app_EMoamEEZ73f0CkXaXp7hrann',
|
|
863
|
+
authorizeUrl: 'https://auth.openai.com/oauth/authorize',
|
|
864
|
+
tokenUrl: 'https://auth.openai.com/oauth/token',
|
|
865
|
+
redirectUri: 'http://localhost:1455/auth/callback',
|
|
866
|
+
scopes: 'openid profile email offline_access',
|
|
867
|
+
};
|
|
868
|
+
|
|
869
|
+
function generatePKCE() {
|
|
870
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
871
|
+
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
872
|
+
return { verifier, challenge };
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function buildOAuthUrl(pkce, state) {
|
|
876
|
+
const params = new URLSearchParams({
|
|
877
|
+
response_type: 'code',
|
|
878
|
+
client_id: OPENAI_OAUTH.clientId,
|
|
879
|
+
redirect_uri: OPENAI_OAUTH.redirectUri,
|
|
880
|
+
scope: OPENAI_OAUTH.scopes,
|
|
881
|
+
code_challenge: pkce.challenge,
|
|
882
|
+
code_challenge_method: 'S256',
|
|
883
|
+
state,
|
|
884
|
+
id_token_add_organizations: 'true',
|
|
885
|
+
codex_cli_simplified_flow: 'true',
|
|
886
|
+
originator: 'pi',
|
|
846
887
|
});
|
|
847
|
-
return {
|
|
888
|
+
return `${OPENAI_OAUTH.authorizeUrl}?${params}`;
|
|
848
889
|
}
|
|
849
890
|
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
//
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
stdio: ['inherit', 'pipe', 'pipe'],
|
|
859
|
-
});
|
|
860
|
-
|
|
861
|
-
const seenUrls = new Set();
|
|
862
|
-
let buf = '';
|
|
863
|
-
|
|
864
|
-
const handleData = (data) => {
|
|
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);
|
|
891
|
+
function parseCallbackInput(input) {
|
|
892
|
+
const trimmed = input.trim();
|
|
893
|
+
// Accept full URL, just the code, or code#state / code=XXX&state=YYY
|
|
894
|
+
try {
|
|
895
|
+
const url = new URL(trimmed);
|
|
896
|
+
return {
|
|
897
|
+
code: url.searchParams.get('code'),
|
|
898
|
+
state: url.searchParams.get('state'),
|
|
872
899
|
};
|
|
900
|
+
} catch {}
|
|
901
|
+
// Try as query string
|
|
902
|
+
if (trimmed.includes('code=')) {
|
|
903
|
+
const params = new URLSearchParams(trimmed.replace(/^\?/, ''));
|
|
904
|
+
return { code: params.get('code'), state: params.get('state') };
|
|
905
|
+
}
|
|
906
|
+
// Bare code
|
|
907
|
+
return { code: trimmed, state: null };
|
|
908
|
+
}
|
|
873
909
|
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
910
|
+
async function exchangeCodeForTokens(code, pkceVerifier) {
|
|
911
|
+
const body = new URLSearchParams({
|
|
912
|
+
grant_type: 'authorization_code',
|
|
913
|
+
client_id: OPENAI_OAUTH.clientId,
|
|
914
|
+
code,
|
|
915
|
+
code_verifier: pkceVerifier,
|
|
916
|
+
redirect_uri: OPENAI_OAUTH.redirectUri,
|
|
917
|
+
});
|
|
918
|
+
const res = await fetch(OPENAI_OAUTH.tokenUrl, {
|
|
919
|
+
method: 'POST',
|
|
920
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
921
|
+
body: body.toString(),
|
|
922
|
+
});
|
|
923
|
+
if (!res.ok) {
|
|
924
|
+
const text = await res.text();
|
|
925
|
+
throw new Error(`Token exchange failed (${res.status}): ${text}`);
|
|
926
|
+
}
|
|
927
|
+
return res.json();
|
|
928
|
+
}
|
|
893
929
|
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
930
|
+
function decodeJwtPayload(token) {
|
|
931
|
+
const parts = token.split('.');
|
|
932
|
+
if (parts.length < 2) return {};
|
|
933
|
+
return JSON.parse(Buffer.from(parts[1], 'base64url').toString());
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function writeAuthProfilesToDocker(profile) {
|
|
937
|
+
// Write auth-profiles.json into the OpenClaw Docker volume via a temp container
|
|
938
|
+
const profileId = profile.email ? `openai-codex:${profile.email}` : 'openai-codex:default';
|
|
939
|
+
const store = {
|
|
940
|
+
version: 1,
|
|
941
|
+
profiles: {
|
|
942
|
+
[profileId]: {
|
|
943
|
+
type: 'oauth',
|
|
944
|
+
provider: 'openai-codex',
|
|
945
|
+
access: profile.access,
|
|
946
|
+
refresh: profile.refresh,
|
|
947
|
+
expires: profile.expires,
|
|
948
|
+
accountId: profile.accountId,
|
|
949
|
+
},
|
|
950
|
+
},
|
|
951
|
+
order: {},
|
|
952
|
+
lastGood: {},
|
|
953
|
+
usageStats: {},
|
|
954
|
+
};
|
|
955
|
+
const json = JSON.stringify(store, null, 2);
|
|
956
|
+
const destDir = '/home/limbo/.openclaw/agents/main/agent';
|
|
957
|
+
const destFile = `${destDir}/auth-profiles.json`;
|
|
958
|
+
// Use a one-shot container to write into the named volume
|
|
959
|
+
spawnSync('docker', [
|
|
960
|
+
'compose', 'run', '--rm', '--no-deps', '--entrypoint', 'sh', 'limbo',
|
|
961
|
+
'-c', `mkdir -p "${destDir}" && cat > "${destFile}"`,
|
|
962
|
+
], {
|
|
963
|
+
cwd: LIMBO_DIR,
|
|
964
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
965
|
+
input: json,
|
|
966
|
+
encoding: 'utf8',
|
|
905
967
|
});
|
|
906
968
|
}
|
|
907
969
|
|
|
970
|
+
async function runCodexOAuth(language) {
|
|
971
|
+
const pkce = generatePKCE();
|
|
972
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
973
|
+
const authUrl = buildOAuthUrl(pkce, state);
|
|
974
|
+
|
|
975
|
+
// Open browser automatically
|
|
976
|
+
const openCmd = process.platform === 'darwin' ? 'open'
|
|
977
|
+
: process.platform === 'win32' ? 'start' : 'xdg-open';
|
|
978
|
+
try { execSync(`${openCmd} "${authUrl}"`, { stdio: 'ignore' }); } catch {}
|
|
979
|
+
|
|
980
|
+
console.log(`\n ${c.cyan}${c.bold}→ ${authUrl}${c.reset}\n`);
|
|
981
|
+
log(t(language, 'oauthPasteHint'));
|
|
982
|
+
|
|
983
|
+
const callbackRaw = await promptValidated(
|
|
984
|
+
t(language, 'oauthCallbackPrompt'),
|
|
985
|
+
(value) => {
|
|
986
|
+
if (!value) return { ok: false, message: t(language, 'requiredField') };
|
|
987
|
+
const parsed = parseCallbackInput(value);
|
|
988
|
+
if (!parsed.code) return { ok: false, message: t(language, 'oauthInvalidCallback') };
|
|
989
|
+
return { ok: true, value };
|
|
990
|
+
},
|
|
991
|
+
);
|
|
992
|
+
|
|
993
|
+
const { code, state: returnedState } = parseCallbackInput(callbackRaw);
|
|
994
|
+
if (returnedState && returnedState !== state) {
|
|
995
|
+
warn(t(language, 'oauthStateMismatch'));
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
log(t(language, 'oauthExchanging'));
|
|
999
|
+
const tokens = await exchangeCodeForTokens(code, pkce.verifier);
|
|
1000
|
+
|
|
1001
|
+
// Extract account info from JWT
|
|
1002
|
+
const jwt = decodeJwtPayload(tokens.access_token);
|
|
1003
|
+
const authClaim = jwt['https://api.openai.com/auth'] || {};
|
|
1004
|
+
const accountId = authClaim.chatgpt_account_id || '';
|
|
1005
|
+
const email = jwt.email || '';
|
|
1006
|
+
|
|
1007
|
+
writeAuthProfilesToDocker({
|
|
1008
|
+
access: tokens.access_token,
|
|
1009
|
+
refresh: tokens.refresh_token,
|
|
1010
|
+
expires: Date.now() + (tokens.expires_in * 1000),
|
|
1011
|
+
accountId,
|
|
1012
|
+
email,
|
|
1013
|
+
});
|
|
1014
|
+
|
|
1015
|
+
return 0;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
// ─── Subscription auth flow ──────────────────────────────────────────────────
|
|
1019
|
+
|
|
908
1020
|
async function runSubscriptionAuthFlow(cfg) {
|
|
909
1021
|
header(t(cfg.language, 'subscriptionSetup'));
|
|
1022
|
+
|
|
910
1023
|
if (cfg.providerFamily === 'openai') {
|
|
911
1024
|
log(t(cfg.language, 'openaiSubscriptionIntro'));
|
|
1025
|
+
log(t(cfg.language, 'authFlowStart'));
|
|
1026
|
+
try {
|
|
1027
|
+
await runCodexOAuth(cfg.language);
|
|
1028
|
+
} catch (err) {
|
|
1029
|
+
die(`${t(cfg.language, 'authFlowFailed')}: ${err.message}`);
|
|
1030
|
+
}
|
|
1031
|
+
// Native OAuth — tokens verified by successful exchange, no status check needed
|
|
1032
|
+
ok(t(cfg.language, 'authFlowDone'));
|
|
912
1033
|
} else {
|
|
913
1034
|
log(t(cfg.language, 'anthropicSubscriptionIntro'));
|
|
914
|
-
|
|
915
|
-
log(t(cfg.language, 'authFlowStart'));
|
|
916
|
-
|
|
917
|
-
const authArgs = cfg.providerFamily === 'openai'
|
|
918
|
-
? ['models', 'auth', 'login', '--provider', 'openai-codex']
|
|
919
|
-
: ['models', 'auth', 'paste-token', '--provider', 'anthropic'];
|
|
920
|
-
|
|
921
|
-
let exitCode;
|
|
922
|
-
if (cfg.providerFamily === 'openai') {
|
|
923
|
-
// --tty allocates a PTY inside the container so openclaw's auth wizard runs correctly.
|
|
924
|
-
// -p exposes the OAuth callback port so the browser redirect reaches the in-container server.
|
|
925
|
-
// We still pipe stdout/stderr to filter out branding and highlight the OAuth URL.
|
|
926
|
-
const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
|
|
927
|
-
exitCode = await streamFilteredAuth(
|
|
928
|
-
['compose', 'run', '--tty', '--rm', '-p', `${OPENCLAW_AUTH_PORT}:${OPENCLAW_AUTH_PORT}`, '--entrypoint', 'openclaw', 'limbo', ...authArgs],
|
|
929
|
-
(url) => {
|
|
930
|
-
// Auto-open the browser so the user doesn't need to copy/paste the URL
|
|
931
|
-
try { spawnSync(opener, [url], { stdio: 'ignore', timeout: 3000 }); } catch {}
|
|
932
|
-
},
|
|
933
|
-
);
|
|
934
|
-
} else {
|
|
1035
|
+
log(t(cfg.language, 'authFlowStart'));
|
|
935
1036
|
// Anthropic paste-token is interactive (user pastes a token); keep stdio inherited
|
|
936
|
-
const authResult = runOpenClaw(
|
|
937
|
-
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
if (exitCode !== 0) die(t(cfg.language, 'authFlowFailed'));
|
|
941
|
-
|
|
942
|
-
const statusResult = runOpenClaw(
|
|
943
|
-
['models', 'status', '--check', '--probe-provider', cfg.provider],
|
|
944
|
-
{ stdio: 'pipe' },
|
|
945
|
-
);
|
|
1037
|
+
const authResult = runOpenClaw(['models', 'auth', 'paste-token', '--provider', 'anthropic']);
|
|
1038
|
+
if (authResult.status !== 0) die(t(cfg.language, 'authFlowFailed'));
|
|
946
1039
|
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1040
|
+
const statusResult = runOpenClaw(
|
|
1041
|
+
['models', 'status', '--check', '--probe-provider', cfg.provider],
|
|
1042
|
+
{ stdio: 'pipe' },
|
|
1043
|
+
);
|
|
1044
|
+
if (statusResult.status !== 0) {
|
|
1045
|
+
process.stdout.write(statusResult.stdout || '');
|
|
1046
|
+
process.stderr.write(statusResult.stderr || '');
|
|
1047
|
+
die(t(cfg.language, 'authStatusFailed'));
|
|
1048
|
+
}
|
|
1049
|
+
ok(t(cfg.language, 'authFlowDone'));
|
|
951
1050
|
}
|
|
952
|
-
|
|
953
|
-
ok(t(cfg.language, 'modelConnected', `${cfg.provider}/${cfg.modelName}`));
|
|
954
1051
|
}
|
|
955
1052
|
|
|
956
1053
|
function printSuccess(cfg, gatewayToken) {
|
|
@@ -1026,6 +1123,7 @@ async function cmdStart() {
|
|
|
1026
1123
|
}
|
|
1027
1124
|
|
|
1028
1125
|
pullOrBuildImage(cfg.language);
|
|
1126
|
+
ensureVolumePermissions();
|
|
1029
1127
|
|
|
1030
1128
|
if (cfg.authMode === 'subscription' && (process.argv.includes('--reconfigure') || !alreadyHasEnv)) {
|
|
1031
1129
|
await runSubscriptionAuthFlow(cfg);
|
|
@@ -1116,29 +1214,24 @@ ${c.bold}Data directory:${c.reset} ${LIMBO_DIR}
|
|
|
1116
1214
|
|
|
1117
1215
|
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
1118
1216
|
|
|
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
|
-
}
|
|
1217
|
+
const [,, cmd = 'start'] = process.argv;
|
|
1218
|
+
|
|
1219
|
+
(async () => {
|
|
1220
|
+
switch (cmd) {
|
|
1221
|
+
case 'start':
|
|
1222
|
+
case 'install': await cmdStart(); break;
|
|
1223
|
+
case 'stop': cmdStop(); break;
|
|
1224
|
+
case 'logs': cmdLogs(); break;
|
|
1225
|
+
case 'update': cmdUpdate(); break;
|
|
1226
|
+
case 'status': cmdStatus(); break;
|
|
1227
|
+
case 'help':
|
|
1228
|
+
case '--help':
|
|
1229
|
+
case '-h': cmdHelp(); break;
|
|
1230
|
+
default:
|
|
1231
|
+
warn(t('en', 'unknownCommand', cmd));
|
|
1232
|
+
cmdHelp();
|
|
1233
|
+
process.exit(1);
|
|
1234
|
+
}
|
|
1235
|
+
})().catch((err) => {
|
|
1236
|
+
die(err.message || String(err));
|
|
1237
|
+
});
|