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.
Files changed (3) hide show
  1. package/README.md +13 -7
  2. package/cli.js +306 -173
  3. 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
- The easiest way to talk to Limbo is via **Telegram** set up once, works from any device.
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
- For everything else, Limbo speaks over WebSocket at `ws://localhost:18789` via the OpenClaw gateway. Any OpenClaw-compatible client can connect there directly.
55
+ ### Talk to Limbo
56
56
 
57
- ### Telegram (recommended)
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
- ### Without Telegram
67
+ #### OpenClaw client
68
68
 
69
- Connect any [OpenClaw](https://openclaw.dev)-compatible client to:
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 includes Claude Code add Limbo to your MCP config:
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
- - ./vault:/data/vault
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
- - .env
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: ./secrets/llm_api_key
108
+ file: ${SECRETS_DIR}/llm_api_key
108
109
  telegram_bot_token:
109
- file: ./secrets/telegram_bot_token
110
+ file: ${SECRETS_DIR}/telegram_bot_token
110
111
  gateway_token:
111
- file: ./secrets/gateway_token
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
- - ./vault:/data/vault
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
- - .env
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
- - ./squid/squid.conf:/etc/squid/squid.conf:ro
182
- - ./squid/allowed-domains.txt:/etc/squid/allowed-domains.txt:ro
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: ./secrets/llm_api_key
195
+ file: ${SECRETS_DIR}/llm_api_key
192
196
  telegram_bot_token:
193
- file: ./secrets/telegram_bot_token
197
+ file: ${SECRETS_DIR}/telegram_bot_token
194
198
  gateway_token:
195
- file: ./secrets/gateway_token
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 AI provider. A URL will appear — open it in your browser to complete login.',
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 proveedor de IA. Aparecera una URL — abrisla en el navegador para completar el login.',
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
- warn(t(lang, 'pullFailed'));
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
- // Strip all ANSI/VT100 escape sequences from a string.
817
- // Uses standards-based ECMA-48 byte ranges:
818
- // CSI: ESC [ <param bytes 0x30-0x3F>* <intermediate bytes 0x20-0x2F>* <final byte 0x40-0x7E>
819
- // Two-char ESC: ESC <0x40-0x5F> (e.g. ESC M, ESC =, ESC 7/8 save/restore cursor)
820
- // OSC: ESC ] <any> BEL|ST
821
- // Covers private-mode sequences like \x1b[?25l (hide cursor) that the old [0-9;]* missed.
822
- const stripAnsi = (str) => str
823
- .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') // CSI sequences (all parameter byte combos)
824
- .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences (before two-char — shares \x1b] prefix)
825
- .replace(/\x1b[^[\]]/g, '') // two-char ESC sequences (e.g. ESC 7/8 save/restore)
826
- .replace(/\r/g, '');
827
-
828
- // Matches OAuth/browser URLs emitted by OpenClaw during auth flows.
829
- const AUTH_URL_RE = /https?:\/\/[^\s"'<>\]]+/g;
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
- // Spawn OpenClaw auth with filtered output: extract OAuth URLs, suppress branding.
851
- // --tty is required so openclaw sees a TTY inside the container and runs the auth wizard.
852
- // We pipe stdout/stderr to filter content while the container gets a proper PTY allocation.
853
- // onUrl: optional callback invoked with each unique URL as it appears (e.g. to auto-open browser).
854
- function streamFilteredAuth(dockerArgs, onUrl = null) {
855
- return new Promise((resolve) => {
856
- const proc = spawn('docker', dockerArgs, {
857
- cwd: LIMBO_DIR,
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);
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
- const emitLine = (rawLine) => {
875
- const line = stripAnsi(rawLine);
876
- const urls = line.match(AUTH_URL_RE) || [];
877
- if (urls.length > 0) {
878
- for (const url of urls) {
879
- if (!seenUrls.has(url)) {
880
- seenUrls.add(url);
881
- console.log(`\n ${c.cyan}${c.bold}→ ${url}${c.reset}\n`);
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
- proc.stdout.on('data', handleData);
895
- proc.stderr.on('data', handleData);
896
- proc.on('close', (code) => {
897
- if (buf.trim()) {
898
- // Append a synthetic \n to flush the remaining buffer through flushStreamLines.
899
- const { lines } = flushStreamLines(buf + '\n');
900
- for (const line of lines) emitLine(line);
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
- async function runSubscriptionAuthFlow(cfg) {
909
- header(t(cfg.language, 'subscriptionSetup'));
910
- if (cfg.providerFamily === 'openai') {
911
- log(t(cfg.language, 'openaiSubscriptionIntro'));
912
- } else {
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
- const authArgs = cfg.providerFamily === 'openai'
918
- ? ['models', 'auth', 'login', '--provider', 'openai-codex']
919
- : ['models', 'auth', 'paste-token', '--provider', 'anthropic'];
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
- 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 {}
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
- } else {
935
- // Anthropic paste-token is interactive (user pastes a token); keep stdio inherited
936
- const authResult = runOpenClaw(authArgs);
937
- exitCode = authResult.status;
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
- if (exitCode !== 0) die(t(cfg.language, 'authFlowFailed'));
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
- const statusResult = runOpenClaw(
943
- ['models', 'status', '--check', '--probe-provider', cfg.provider],
944
- { stdio: 'pipe' },
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
- if (statusResult.status !== 0) {
948
- process.stdout.write(statusResult.stdout || '');
949
- process.stderr.write(statusResult.stderr || '');
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
- ok(t(cfg.language, 'modelConnected', `${cfg.provider}/${cfg.modelName}`));
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
- if (require.main === module) {
1120
- const [,, cmd = 'start'] = process.argv;
1121
-
1122
- (async () => {
1123
- switch (cmd) {
1124
- case 'start':
1125
- case 'install': await cmdStart(); break;
1126
- case 'stop': cmdStop(); break;
1127
- case 'logs': cmdLogs(); break;
1128
- case 'update': cmdUpdate(); break;
1129
- case 'status': cmdStatus(); break;
1130
- case 'help':
1131
- case '--help':
1132
- case '-h': cmdHelp(); break;
1133
- default:
1134
- warn(t('en', 'unknownCommand', cmd));
1135
- cmdHelp();
1136
- process.exit(1);
1137
- }
1138
- })().catch((err) => {
1139
- die(err.message || String(err));
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "limbo-ai",
3
- "version": "1.9.5",
3
+ "version": "1.11.0",
4
4
  "description": "Your personal AI memory agent — install and manage Limbo via npx",
5
5
  "type": "commonjs",
6
6
  "bin": {