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.
Files changed (3) hide show
  1. package/README.md +13 -7
  2. package/cli.js +265 -172
  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,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 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.',
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 proveedor de IA. Aparecera una URL — abrisla en el navegador para completar el login.',
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
- 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));
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
- // 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
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 { lines, remaining };
888
+ return `${OPENAI_OAUTH.authorizeUrl}?${params}`;
848
889
  }
849
890
 
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);
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
- 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}`);
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
- 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));
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(authArgs);
937
- exitCode = authResult.status;
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
- if (statusResult.status !== 0) {
948
- process.stdout.write(statusResult.stdout || '');
949
- process.stderr.write(statusResult.stderr || '');
950
- die(t(cfg.language, 'authStatusFailed'));
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
- 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
- }
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "limbo-ai",
3
- "version": "1.9.5",
3
+ "version": "1.10.0",
4
4
  "description": "Your personal AI memory agent — install and manage Limbo via npx",
5
5
  "type": "commonjs",
6
6
  "bin": {