limbo-ai 1.9.4 → 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 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,131 +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[@-Z\\-_]/g, '') // two-char ESC sequences
825
- .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences
826
- .replace(/\r/g, '');
827
-
828
- // Spawn OpenClaw auth with filtered output: extract OAuth URLs, suppress branding.
829
- // --tty is required so openclaw sees a TTY inside the container and runs the auth wizard.
830
- // We pipe stdout/stderr to filter content while the container gets a proper PTY allocation.
831
- // onUrl: optional callback invoked with each unique URL as it appears (e.g. to auto-open browser).
832
- function streamFilteredAuth(dockerArgs, onUrl = null) {
833
- return new Promise((resolve) => {
834
- const proc = spawn('docker', dockerArgs, {
835
- cwd: LIMBO_DIR,
836
- stdio: ['inherit', 'pipe', 'pipe'],
837
- });
838
-
839
- const urlRe = /https?:\/\/[^\s"'<>\]]+/g;
840
- const seenUrls = new Set();
841
- let buf = '';
842
-
843
- const handleData = (data) => {
844
- buf += data.toString();
845
- // Split on \r\n, \n, or bare \r — TUIs use carriage returns for in-place redraws
846
- const lines = buf.split(/\r?\n|\r/);
847
- buf = lines.pop(); // hold incomplete last line
848
- for (const line of lines) emitLine(line);
849
- };
858
+ // ─── Native OAuth (PKCE) for OpenAI Codex ───────────────────────────────────
859
+ // Implements the full OAuth flow locally so we never need OpenClaw's interactive TUI.
850
860
 
851
- const emitLine = (rawLine) => {
852
- const line = stripAnsi(rawLine);
853
- const urls = line.match(urlRe) || [];
854
- if (urls.length > 0) {
855
- for (const url of urls) {
856
- if (!seenUrls.has(url)) {
857
- seenUrls.add(url);
858
- console.log(`\n ${c.cyan}${c.bold}→ ${url}${c.reset}\n`);
859
- if (onUrl) onUrl(url);
860
- }
861
- }
862
- return; // don't double-print the line containing the URL
863
- }
864
- // Suppress OpenClaw branding
865
- if (/openclaw/i.test(line)) return;
866
- // Suppress TUI chrome: lines that are only spinner/decoration/box-drawing chars.
867
- // OpenClaw's TUI writes animation frames separated by \r — after our \r-split, each
868
- // frame becomes a short line (often a single char). We filter these out so they don't
869
- // scatter across the terminal as individual console.log lines.
870
- const tuiChrome = /^[\s\u2500-\u257f\u2580-\u259f\u25a0-\u25ff\u2600-\u26ff◇◆●○◈→←↑↓⠀-\u28ff]*$/u;
871
- if (tuiChrome.test(line)) return;
872
- if (line.trim()) console.log(` ${line}`);
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',
887
+ });
888
+ return `${OPENAI_OAUTH.authorizeUrl}?${params}`;
889
+ }
890
+
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'),
873
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
+ }
909
+
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
+ }
929
+
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',
967
+ });
968
+ }
874
969
 
875
- proc.stdout.on('data', handleData);
876
- proc.stderr.on('data', handleData);
877
- proc.on('close', (code) => {
878
- if (buf.trim()) emitLine(buf);
879
- resolve(code ?? 1);
880
- });
881
- proc.on('error', () => resolve(1));
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,
882
1013
  });
1014
+
1015
+ return 0;
883
1016
  }
884
1017
 
1018
+ // ─── Subscription auth flow ──────────────────────────────────────────────────
1019
+
885
1020
  async function runSubscriptionAuthFlow(cfg) {
886
1021
  header(t(cfg.language, 'subscriptionSetup'));
1022
+
887
1023
  if (cfg.providerFamily === 'openai') {
888
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'));
889
1033
  } else {
890
1034
  log(t(cfg.language, 'anthropicSubscriptionIntro'));
891
- }
892
- log(t(cfg.language, 'authFlowStart'));
893
-
894
- const authArgs = cfg.providerFamily === 'openai'
895
- ? ['models', 'auth', 'login', '--provider', 'openai-codex']
896
- : ['models', 'auth', 'paste-token', '--provider', 'anthropic'];
897
-
898
- let exitCode;
899
- if (cfg.providerFamily === 'openai') {
900
- // --tty allocates a PTY inside the container so openclaw's auth wizard runs correctly.
901
- // -p exposes the OAuth callback port so the browser redirect reaches the in-container server.
902
- // We still pipe stdout/stderr to filter out branding and highlight the OAuth URL.
903
- const opener = process.platform === 'darwin' ? 'open' : 'xdg-open';
904
- exitCode = await streamFilteredAuth(
905
- ['compose', 'run', '--tty', '--rm', '-p', `${OPENCLAW_AUTH_PORT}:${OPENCLAW_AUTH_PORT}`, '--entrypoint', 'openclaw', 'limbo', ...authArgs],
906
- (url) => {
907
- // Auto-open the browser so the user doesn't need to copy/paste the URL
908
- try { spawnSync(opener, [url], { stdio: 'ignore', timeout: 3000 }); } catch {}
909
- },
910
- );
911
- } else {
1035
+ log(t(cfg.language, 'authFlowStart'));
912
1036
  // Anthropic paste-token is interactive (user pastes a token); keep stdio inherited
913
- const authResult = runOpenClaw(authArgs);
914
- exitCode = authResult.status;
915
- }
916
-
917
- if (exitCode !== 0) die(t(cfg.language, 'authFlowFailed'));
918
-
919
- const statusResult = runOpenClaw(
920
- ['models', 'status', '--check', '--probe-provider', cfg.provider],
921
- { stdio: 'pipe' },
922
- );
1037
+ const authResult = runOpenClaw(['models', 'auth', 'paste-token', '--provider', 'anthropic']);
1038
+ if (authResult.status !== 0) die(t(cfg.language, 'authFlowFailed'));
923
1039
 
924
- if (statusResult.status !== 0) {
925
- process.stdout.write(statusResult.stdout || '');
926
- process.stderr.write(statusResult.stderr || '');
927
- 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'));
928
1050
  }
929
-
930
- ok(t(cfg.language, 'modelConnected', `${cfg.provider}/${cfg.modelName}`));
931
1051
  }
932
1052
 
933
1053
  function printSuccess(cfg, gatewayToken) {
@@ -1003,6 +1123,7 @@ async function cmdStart() {
1003
1123
  }
1004
1124
 
1005
1125
  pullOrBuildImage(cfg.language);
1126
+ ensureVolumePermissions();
1006
1127
 
1007
1128
  if (cfg.authMode === 'subscription' && (process.argv.includes('--reconfigure') || !alreadyHasEnv)) {
1008
1129
  await runSubscriptionAuthFlow(cfg);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "limbo-ai",
3
- "version": "1.9.4",
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": {
@@ -13,7 +13,8 @@
13
13
  "@clack/prompts": "^1.1.0"
14
14
  },
15
15
  "scripts": {
16
- "start": "node cli.js start"
16
+ "start": "node cli.js start",
17
+ "test": "node --test test/**/*.test.js"
17
18
  },
18
19
  "keywords": [
19
20
  "limbo",
@@ -0,0 +1,250 @@
1
+ // test/cli-auth.test.js
2
+ // Unit tests for the streamFilteredAuth pure-logic functions exported from cli.js.
3
+ // Run with: node --test test/cli-auth.test.js
4
+ 'use strict';
5
+
6
+ const { test } = require('node:test');
7
+ const assert = require('node:assert/strict');
8
+
9
+ const { stripAnsi, AUTH_URL_RE, TUI_CHROME_RE, flushStreamLines } = require('../cli.js');
10
+
11
+ // ─── stripAnsi ────────────────────────────────────────────────────────────────
12
+
13
+ test('stripAnsi: strips standard CSI sequences', () => {
14
+ assert.equal(stripAnsi('\x1b[32mgreen\x1b[0m'), 'green');
15
+ assert.equal(stripAnsi('\x1b[1;31mbold red\x1b[0m'), 'bold red');
16
+ assert.equal(stripAnsi('\x1b[2Kclear line'), 'clear line');
17
+ });
18
+
19
+ test('stripAnsi: strips ?-prefixed private-mode CSI sequences', () => {
20
+ // \x1b[?25l hide cursor, \x1b[?25h show cursor
21
+ assert.equal(stripAnsi('\x1b[?25lhello\x1b[?25h'), 'hello');
22
+ // \x1b[?2004h / \x1b[?2004l bracketed paste mode
23
+ assert.equal(stripAnsi('\x1b[?2004htext\x1b[?2004l'), 'text');
24
+ });
25
+
26
+ test('stripAnsi: strips two-char ESC sequences (0x40-0x5F range)', () => {
27
+ // ESC M (0x4D) — reverse index (cursor up with scroll)
28
+ assert.equal(stripAnsi('\x1bMline'), 'line');
29
+ // ESC E (0x45) — next line
30
+ assert.equal(stripAnsi('text\x1bEafter'), 'textafter');
31
+ // ESC ^ (0x5E) — privacy message (PM)
32
+ assert.equal(stripAnsi('before\x1b^after'), 'beforeafter');
33
+ });
34
+
35
+ test('stripAnsi: strips OSC sequences (BEL-terminated)', () => {
36
+ // OSC 0 ; title BEL — window title sequence
37
+ assert.equal(stripAnsi('\x1b]0;My Terminal Title\x07visible'), 'visible');
38
+ });
39
+
40
+ test('stripAnsi: strips OSC sequences (ST-terminated)', () => {
41
+ assert.equal(stripAnsi('\x1b]0;title\x1b\\visible'), 'visible');
42
+ });
43
+
44
+ test('stripAnsi: strips bare carriage returns', () => {
45
+ assert.equal(stripAnsi('line1\rline2'), 'line1line2');
46
+ assert.equal(stripAnsi('\r'), '');
47
+ });
48
+
49
+ test('stripAnsi: leaves plain text untouched', () => {
50
+ const plain = 'Hello, world! 123 !@#';
51
+ assert.equal(stripAnsi(plain), plain);
52
+ });
53
+
54
+ test('stripAnsi: handles empty string', () => {
55
+ assert.equal(stripAnsi(''), '');
56
+ });
57
+
58
+ test('stripAnsi: strips mixed sequences in one pass', () => {
59
+ // CSI (?25l hide cursor) + CSI (32m color) + OSC (window title) + bare CR
60
+ const input = '\x1b[?25l\x1b[32mProcessing\x1b[0m...\x1b]0;term\x07done\r';
61
+ assert.equal(stripAnsi(input), 'Processing...done');
62
+ // CSI + two-char ESC (M) mixed with real text
63
+ const input2 = '\x1b[1mbold\x1b[0m\x1bMnext';
64
+ assert.equal(stripAnsi(input2), 'boldnext');
65
+ });
66
+
67
+ // ─── TUI_CHROME_RE ────────────────────────────────────────────────────────────
68
+
69
+ test('TUI_CHROME_RE: suppresses Braille spinner chars', () => {
70
+ // Common clack/openclaw spinner frames
71
+ for (const ch of ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']) {
72
+ assert.equal(TUI_CHROME_RE.test(ch), true, `expected ${ch} to match TUI_CHROME_RE`);
73
+ }
74
+ });
75
+
76
+ test('TUI_CHROME_RE: suppresses box-drawing chars', () => {
77
+ assert.equal(TUI_CHROME_RE.test('─'), true);
78
+ assert.equal(TUI_CHROME_RE.test('│'), true);
79
+ assert.equal(TUI_CHROME_RE.test('┌┐└┘'), true);
80
+ assert.equal(TUI_CHROME_RE.test('═══'), true);
81
+ });
82
+
83
+ test('TUI_CHROME_RE: suppresses clack decoration chars', () => {
84
+ assert.equal(TUI_CHROME_RE.test('◇'), true);
85
+ assert.equal(TUI_CHROME_RE.test('●'), true);
86
+ assert.equal(TUI_CHROME_RE.test('◆'), true);
87
+ assert.equal(TUI_CHROME_RE.test('○'), true);
88
+ });
89
+
90
+ test('TUI_CHROME_RE: suppresses whitespace-only lines', () => {
91
+ assert.equal(TUI_CHROME_RE.test(' '), true);
92
+ assert.equal(TUI_CHROME_RE.test(''), true);
93
+ assert.equal(TUI_CHROME_RE.test('\t'), true);
94
+ });
95
+
96
+ test('TUI_CHROME_RE: suppresses mixed chrome lines (spinner + whitespace)', () => {
97
+ assert.equal(TUI_CHROME_RE.test(' ⠋ '), true);
98
+ assert.equal(TUI_CHROME_RE.test('◇ ─ ◇'), true);
99
+ });
100
+
101
+ test('TUI_CHROME_RE: passes lines with real text content', () => {
102
+ assert.equal(TUI_CHROME_RE.test('Please open this URL'), false);
103
+ assert.equal(TUI_CHROME_RE.test('Authenticating...'), false);
104
+ assert.equal(TUI_CHROME_RE.test('Press Enter to continue'), false);
105
+ assert.equal(TUI_CHROME_RE.test('Error: invalid token'), false);
106
+ });
107
+
108
+ test('TUI_CHROME_RE: passes lines starting with decoration but containing text', () => {
109
+ // clack prompts often have a leading decoration glyph followed by text
110
+ assert.equal(TUI_CHROME_RE.test('◇ Enter your API key'), false);
111
+ assert.equal(TUI_CHROME_RE.test('● Model selected: claude-opus-4-6'), false);
112
+ });
113
+
114
+ // ─── AUTH_URL_RE (URL extraction) ─────────────────────────────────────────────
115
+
116
+ test('AUTH_URL_RE: detects http OAuth URLs', () => {
117
+ const line = 'Open this URL to authenticate: http://localhost:3000/oauth/callback?code=abc123';
118
+ const matches = line.match(AUTH_URL_RE);
119
+ assert.ok(matches, 'expected URL match');
120
+ assert.equal(matches[0], 'http://localhost:3000/oauth/callback?code=abc123');
121
+ });
122
+
123
+ test('AUTH_URL_RE: detects https OAuth URLs', () => {
124
+ const line = 'Visit https://auth.anthropic.com/oauth2/authorize?client_id=limbo&state=xyz to login';
125
+ const matches = line.match(AUTH_URL_RE);
126
+ assert.ok(matches, 'expected URL match');
127
+ assert.equal(matches[0], 'https://auth.anthropic.com/oauth2/authorize?client_id=limbo&state=xyz');
128
+ });
129
+
130
+ test('AUTH_URL_RE: does not match plain text without URL', () => {
131
+ const line = 'Waiting for authentication...';
132
+ const matches = line.match(AUTH_URL_RE);
133
+ assert.equal(matches, null);
134
+ });
135
+
136
+ test('AUTH_URL_RE: stops URL at whitespace', () => {
137
+ const line = 'URL: https://example.com/auth and then some text';
138
+ const matches = line.match(AUTH_URL_RE);
139
+ assert.ok(matches);
140
+ assert.equal(matches[0], 'https://example.com/auth');
141
+ });
142
+
143
+ test('AUTH_URL_RE: stops URL at angle bracket', () => {
144
+ const line = 'Go to <https://example.com/auth>';
145
+ const matches = line.match(AUTH_URL_RE);
146
+ assert.ok(matches);
147
+ assert.equal(matches[0], 'https://example.com/auth');
148
+ });
149
+
150
+ test('AUTH_URL_RE: extracts multiple URLs from a single line', () => {
151
+ const line = 'Primary: https://example.com/a Fallback: https://example.com/b';
152
+ const matches = line.match(AUTH_URL_RE);
153
+ assert.ok(matches);
154
+ assert.equal(matches.length, 2);
155
+ assert.equal(matches[0], 'https://example.com/a');
156
+ assert.equal(matches[1], 'https://example.com/b');
157
+ });
158
+
159
+ test('AUTH_URL_RE: URL deduplication — same URL seen twice is emitted once', () => {
160
+ // This tests the seenUrls Set logic conceptually — we verify that running AUTH_URL_RE
161
+ // against the same URL twice and filtering via a Set yields a single emission.
162
+ const url = 'https://auth.openai.com/oauth/callback?code=abc';
163
+ const lines = [
164
+ `Open: ${url}`,
165
+ `Retry: ${url}`,
166
+ 'Different: https://example.com/other',
167
+ ];
168
+
169
+ const emitted = [];
170
+ const seenUrls = new Set();
171
+
172
+ for (const line of lines) {
173
+ const urls = line.match(AUTH_URL_RE) || [];
174
+ for (const u of urls) {
175
+ if (!seenUrls.has(u)) {
176
+ seenUrls.add(u);
177
+ emitted.push(u);
178
+ }
179
+ }
180
+ }
181
+
182
+ assert.equal(emitted.length, 2, 'duplicate URL should only be emitted once');
183
+ assert.equal(emitted[0], url);
184
+ assert.equal(emitted[1], 'https://example.com/other');
185
+ });
186
+
187
+ // ─── flushStreamLines (animation scatter regression) ──────────────────────────
188
+
189
+ test('flushStreamLines: emits only final \\r frame — suppresses scatter', () => {
190
+ // This is the exact pattern that caused the diagonal scatter seen in the screenshot:
191
+ // clack's character-by-character reveal writes each progressive state separated by \r.
192
+ // Before the fix, every frame was emitted as a separate line causing staircase output.
193
+ const buf = '│ Y\r│ Yo\r│ You\r│ Your URL: https://auth.example.com\n';
194
+ const { lines, remaining } = flushStreamLines(buf);
195
+ assert.equal(remaining, '');
196
+ assert.equal(lines.length, 1, 'only the final frame should be emitted');
197
+ assert.equal(lines[0], '│ Your URL: https://auth.example.com');
198
+ });
199
+
200
+ test('flushStreamLines: handles spinner animation (pure chrome final frame)', () => {
201
+ // Spinner that completes and clears the line — final state is empty/chrome
202
+ const buf = '⠋\r⠙\r⠹\r⠸\r \n';
203
+ const { lines } = flushStreamLines(buf);
204
+ assert.equal(lines.length, 1);
205
+ assert.equal(lines[0], ' '); // final frame (space = cleared); TUI_CHROME_RE will suppress it
206
+ });
207
+
208
+ test('flushStreamLines: normalises \\r\\n to single newline', () => {
209
+ const buf = 'line one\r\nline two\r\n';
210
+ const { lines, remaining } = flushStreamLines(buf);
211
+ assert.equal(remaining, '');
212
+ assert.deepEqual(lines, ['line one', 'line two']);
213
+ });
214
+
215
+ test('flushStreamLines: holds incomplete segment in remaining', () => {
216
+ const buf = 'complete line\nstill coming';
217
+ const { lines, remaining } = flushStreamLines(buf);
218
+ assert.deepEqual(lines, ['complete line']);
219
+ assert.equal(remaining, 'still coming');
220
+ });
221
+
222
+ test('flushStreamLines: accumulating chunks yields same result as one chunk', () => {
223
+ // Simulate data arriving in two chunks mid-animation-frame
224
+ const chunk1 = '│ Y\r│ Yo\r';
225
+ const chunk2 = '│ You\r│ Your URL: https://auth.example.com\n';
226
+
227
+ // Chunk 1 alone: no complete \n-terminated line yet
228
+ const r1 = flushStreamLines(chunk1);
229
+ assert.deepEqual(r1.lines, []);
230
+ assert.equal(r1.remaining, '│ Y\r│ Yo\r');
231
+
232
+ // Chunk 2 appended to remaining: yields only the final frame
233
+ const r2 = flushStreamLines(r1.remaining + chunk2);
234
+ assert.equal(r2.lines.length, 1);
235
+ assert.equal(r2.lines[0], '│ Your URL: https://auth.example.com');
236
+ assert.equal(r2.remaining, '');
237
+ });
238
+
239
+ test('flushStreamLines: multiple \\n-terminated lines processed independently', () => {
240
+ // Two different lines, each with animation frames
241
+ const buf = '⠋ Loading...\r⠙ Loading...\r Done!\nEnter code: \r\n';
242
+ const { lines } = flushStreamLines(buf);
243
+ assert.deepEqual(lines, [' Done!', 'Enter code: ']);
244
+ });
245
+
246
+ test('flushStreamLines: empty buffer returns no lines', () => {
247
+ const { lines, remaining } = flushStreamLines('');
248
+ assert.deepEqual(lines, []);
249
+ assert.equal(remaining, '');
250
+ });
@@ -0,0 +1,197 @@
1
+ /**
2
+ * Unit tests for the CLI auth output filter logic.
3
+ *
4
+ * Tests stripAnsi, processLine (carriage-return collapse), and the emitLine
5
+ * filtering decisions in streamFilteredAuth. Zero external dependencies —
6
+ * uses Node.js built-in test runner (node:test, node >= 18).
7
+ *
8
+ * Run: node --test test/cli-filter.test.js
9
+ */
10
+
11
+ 'use strict';
12
+
13
+ const { test } = require('node:test');
14
+ const assert = require('node:assert/strict');
15
+
16
+ // ─── Replicated filter logic (must stay in sync with cli.js) ─────────────────
17
+ // If these start diverging, extract to a shared module.
18
+
19
+ const stripAnsi = (str) => str
20
+ .replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '') // CSI sequences (all parameter byte combos)
21
+ .replace(/\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/g, '') // OSC sequences (before two-char — shares \x1b] prefix)
22
+ .replace(/\x1b[^[\]]/g, '') // two-char ESC sequences (e.g. ESC 7/8 save/restore)
23
+ .replace(/\r/g, '');
24
+
25
+ const urlRe = /https?:\/\/[^\s"'<>\]]+/g;
26
+ const tuiChrome = /^[\s\u2500-\u257f\u2580-\u259f\u25a0-\u25ff\u2600-\u26ff\u2190-\u21ff\u2700-\u27bf\u2800-\u28ff]*$/u;
27
+
28
+ /** Returns last \r-frame — the final visual state of any carriage-return animation. */
29
+ function processLine(raw) {
30
+ return raw.split('\r').pop() || '';
31
+ }
32
+
33
+ /**
34
+ * Models the emitLine decision: 'url' | 'show' | 'suppress'.
35
+ * Reset urlRe.lastIndex before each call since it's a global regex.
36
+ */
37
+ function classify(rawLine) {
38
+ urlRe.lastIndex = 0;
39
+ const line = stripAnsi(rawLine);
40
+ if (urlRe.test(line)) return 'url';
41
+ if (/openclaw/i.test(line)) return 'suppress';
42
+ if (tuiChrome.test(line)) return 'suppress';
43
+ if (!line.trim()) return 'suppress';
44
+ return 'show';
45
+ }
46
+
47
+ // ─── stripAnsi ────────────────────────────────────────────────────────────────
48
+
49
+ test('stripAnsi: removes standard SGR color codes', () => {
50
+ assert.equal(stripAnsi('\x1b[31mRed text\x1b[0m'), 'Red text');
51
+ assert.equal(stripAnsi('\x1b[1;32mBold green\x1b[0m'), 'Bold green');
52
+ });
53
+
54
+ test('stripAnsi: removes private-mode sequences (the original bug — ?25l hide cursor)', () => {
55
+ assert.equal(stripAnsi('\x1b[?25l'), '');
56
+ assert.equal(stripAnsi('\x1b[?25lhello\x1b[?25h'), 'hello');
57
+ assert.equal(stripAnsi('\x1b[?2004h bracketed paste mode \x1b[?2004l'), ' bracketed paste mode ');
58
+ });
59
+
60
+ test('stripAnsi: removes cursor movement and erase sequences', () => {
61
+ assert.equal(stripAnsi('\x1b[5;10HY'), 'Y'); // cursor to row 5, col 10, then char
62
+ assert.equal(stripAnsi('\x1b[2K'), ''); // erase entire line
63
+ assert.equal(stripAnsi('\x1b[1A'), ''); // cursor up 1
64
+ assert.equal(stripAnsi('\x1b[G'), ''); // cursor to column 1
65
+ });
66
+
67
+ test('stripAnsi: removes two-char ESC sequences (save/restore cursor)', () => {
68
+ assert.equal(stripAnsi('\x1b7saved\x1b8'), 'saved');
69
+ assert.equal(stripAnsi('\x1bcReset'), 'Reset');
70
+ });
71
+
72
+ test('stripAnsi: removes OSC sequences (window title)', () => {
73
+ assert.equal(stripAnsi('\x1b]0;My Terminal\x07normal'), 'normal');
74
+ assert.equal(stripAnsi('\x1b]2;Title\x1b\\text'), 'text');
75
+ });
76
+
77
+ test('stripAnsi: strips \\r (carriage return)', () => {
78
+ assert.equal(stripAnsi('hello\rworld'), 'helloworld');
79
+ });
80
+
81
+ test('stripAnsi: passes through plain text unchanged', () => {
82
+ assert.equal(stripAnsi('Auth complete.'), 'Auth complete.');
83
+ assert.equal(stripAnsi(''), '');
84
+ });
85
+
86
+ // ─── processLine (carriage-return collapse) ───────────────────────────────────
87
+
88
+ test('processLine: returns last \\r-frame (final typewriter state)', () => {
89
+ // This is the core fix. Typewriter animation builds text with \r between frames.
90
+ const input = '│ Y\r│ Yo\r│ You\r│ You \r│ You are running in a remote environment';
91
+ assert.equal(processLine(input), '│ You are running in a remote environment');
92
+ });
93
+
94
+ test('processLine: collapses spinner animation to final frame', () => {
95
+ assert.equal(processLine('⠋ Waiting\r⠙ Waiting\r⠹ Waiting\r⠸ Done'), '⠸ Done');
96
+ });
97
+
98
+ test('processLine: returns line unchanged if no \\r present', () => {
99
+ assert.equal(processLine('Auth complete.'), 'Auth complete.');
100
+ assert.equal(processLine(''), '');
101
+ });
102
+
103
+ test('processLine: handles trailing \\r (line ending with empty final frame)', () => {
104
+ // \r at end → final frame is empty string; processLine returns ''
105
+ assert.equal(processLine('clear this\r'), '');
106
+ });
107
+
108
+ // ─── emitLine classification ──────────────────────────────────────────────────
109
+
110
+ test('classify: detects OAuth URLs', () => {
111
+ assert.equal(classify('https://auth.openai.com/oauth/authorize?foo=bar'), 'url');
112
+ assert.equal(classify(' → https://auth.openai.com/oauth/authorize?foo=bar '), 'url');
113
+ assert.equal(classify('\x1b[36mhttps://example.com/auth\x1b[0m'), 'url');
114
+ });
115
+
116
+ test('classify: suppresses OpenClaw branding', () => {
117
+ assert.equal(classify('Starting OpenClaw gateway...'), 'suppress');
118
+ assert.equal(classify('openclaw v1.2.3'), 'suppress');
119
+ assert.equal(classify(' OpenClaw ready '), 'suppress');
120
+ });
121
+
122
+ test('classify: suppresses pure TUI chrome — spinner chars', () => {
123
+ assert.equal(classify('⠋'), 'suppress');
124
+ assert.equal(classify('⠙'), 'suppress');
125
+ assert.equal(classify('⠹ '), 'suppress'); // spinner + whitespace
126
+ });
127
+
128
+ test('classify: suppresses pure TUI chrome — box-drawing and clack decorations', () => {
129
+ assert.equal(classify('│'), 'suppress'); // box drawing
130
+ assert.equal(classify('◇'), 'suppress'); // clack diamond
131
+ assert.equal(classify('●'), 'suppress'); // clack bullet
132
+ assert.equal(classify('─────'), 'suppress'); // horizontal rule
133
+ assert.equal(classify(' '), 'suppress'); // whitespace only
134
+ assert.equal(classify(''), 'suppress'); // empty
135
+ });
136
+
137
+ test('classify: suppresses lines with ANSI that reduce to chrome', () => {
138
+ // \x1b[?25l is "hide cursor" — stripping it leaves empty string
139
+ assert.equal(classify('\x1b[?25l'), 'suppress');
140
+ assert.equal(classify('\x1b[?25l◇\x1b[?25h'), 'suppress');
141
+ });
142
+
143
+ test('classify: shows actual text prompts (the lines we want to preserve)', () => {
144
+ assert.equal(classify('│ You are running in a remote environment'), 'show');
145
+ assert.equal(classify('Auth complete. Model connected.'), 'show');
146
+ assert.equal(classify('If the browser did not open, paste the callback URL:'), 'show');
147
+ assert.equal(classify('✓ Authentication successful'), 'show');
148
+ });
149
+
150
+ // ─── Full pipeline: typewriter animation ─────────────────────────────────────
151
+
152
+ test('full pipeline: typewriter animation collapses to single clean line', () => {
153
+ // Simulate the exact failure pattern from the bug report.
154
+ // OpenClaw writes text character-by-character with \r between frames,
155
+ // terminated by \n when the message is complete.
156
+ const buffer = '│ Y\r│ Yo\r│ You\r│ You \r│ You are running in a remote environment\n';
157
+
158
+ // handleData splits on \n only
159
+ const lines = buffer.split(/\r?\n/);
160
+ lines.pop(); // drop trailing empty after final \n
161
+
162
+ const results = lines.map((raw) => {
163
+ const frame = processLine(raw);
164
+ return { frame, decision: classify(frame) };
165
+ });
166
+
167
+ // Should produce exactly one output, fully formed
168
+ assert.equal(results.length, 1);
169
+ assert.equal(results[0].frame, '│ You are running in a remote environment');
170
+ assert.equal(results[0].decision, 'show');
171
+ });
172
+
173
+ test('full pipeline: spinner-only animation is suppressed after collapse', () => {
174
+ // Spinner that ends without a meaningful final state (pure decoration)
175
+ const buffer = '⠋\r⠙\r⠹\r⠸\r⠼\r\n';
176
+
177
+ const lines = buffer.split(/\r?\n/);
178
+ lines.pop();
179
+
180
+ const results = lines.map((raw) => {
181
+ const frame = processLine(raw);
182
+ return classify(frame);
183
+ });
184
+
185
+ assert.deepEqual(results, ['suppress']);
186
+ });
187
+
188
+ test('full pipeline: URL line is extracted and not double-printed', () => {
189
+ const buffer = 'Open: https://auth.openai.com/oauth/authorize?response_type=code&client_id=app\n';
190
+
191
+ const lines = buffer.split(/\r?\n/);
192
+ lines.pop();
193
+
194
+ const [raw] = lines;
195
+ const frame = processLine(raw);
196
+ assert.equal(classify(frame), 'url');
197
+ });