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 +13 -7
- package/cli.js +247 -126
- package/package.json +3 -2
- package/test/cli-auth.test.js +250 -0
- package/test/cli-filter.test.js +197 -0
package/README.md
CHANGED
|
@@ -50,11 +50,11 @@ Pulls the latest Limbo image and restarts the container. Your vault data is pers
|
|
|
50
50
|
|
|
51
51
|
## Connecting
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
There are two ways to connect: **talk to Limbo** (conversational, with its personality and memory logic) or **use the vault directly** (raw tool access from another agent).
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
### Talk to Limbo
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
#### Telegram (recommended)
|
|
58
58
|
|
|
59
59
|
Set `TELEGRAM_ENABLED=true` and `TELEGRAM_BOT_TOKEN` in `~/.limbo/.env`, then restart:
|
|
60
60
|
|
|
@@ -62,17 +62,21 @@ Set `TELEGRAM_ENABLED=true` and `TELEGRAM_BOT_TOKEN` in `~/.limbo/.env`, then re
|
|
|
62
62
|
npx limbo-ai start --reconfigure
|
|
63
63
|
```
|
|
64
64
|
|
|
65
|
-
Message your bot and Limbo will respond.
|
|
65
|
+
Message your bot and Limbo will respond — full agent with personality, memory logic, and vault tools.
|
|
66
66
|
|
|
67
|
-
|
|
67
|
+
#### OpenClaw client
|
|
68
68
|
|
|
69
|
-
|
|
69
|
+
Any [OpenClaw](https://openclaw.dev)-compatible chat client can connect to:
|
|
70
70
|
|
|
71
71
|
```
|
|
72
72
|
ws://localhost:18789
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
This
|
|
75
|
+
This gives you a conversational session with Limbo, same as Telegram but over WebSocket.
|
|
76
|
+
|
|
77
|
+
### Use the vault from another agent
|
|
78
|
+
|
|
79
|
+
If you want another AI agent (like Claude Code) to read and write to Limbo's vault directly — without going through Limbo's personality or reasoning — add it as an MCP server:
|
|
76
80
|
|
|
77
81
|
```json
|
|
78
82
|
{
|
|
@@ -84,6 +88,8 @@ This includes Claude Code — add Limbo to your MCP config:
|
|
|
84
88
|
}
|
|
85
89
|
```
|
|
86
90
|
|
|
91
|
+
This exposes the 4 vault tools (`vault_search`, `vault_read`, `vault_write_note`, `vault_update_map`) as MCP tools in the connecting agent. The agent operates on the vault directly — Limbo's LLM is not involved.
|
|
92
|
+
|
|
87
93
|
---
|
|
88
94
|
|
|
89
95
|
## Environment Variables
|
package/cli.js
CHANGED
|
@@ -20,8 +20,6 @@ const COMPOSE_FILE = path.join(LIMBO_DIR, 'docker-compose.yml');
|
|
|
20
20
|
const GHCR_IMAGE = 'ghcr.io/tomasward1/limbo';
|
|
21
21
|
const DEFAULT_TAG = require('./package.json').version;
|
|
22
22
|
const PORT = 18789;
|
|
23
|
-
// OpenClaw's OAuth callback server port — must be exposed when running auth inside Docker
|
|
24
|
-
const OPENCLAW_AUTH_PORT = 1453;
|
|
25
23
|
|
|
26
24
|
// OpenClaw compatibility snapshots from official docs:
|
|
27
25
|
// - https://docs.openclaw.ai/providers/openai
|
|
@@ -72,6 +70,9 @@ const COMPOSE_CONTENT = `services:
|
|
|
72
70
|
- no-new-privileges:true
|
|
73
71
|
cap_drop:
|
|
74
72
|
- ALL
|
|
73
|
+
cap_add:
|
|
74
|
+
- CHOWN
|
|
75
|
+
- FOWNER
|
|
75
76
|
pids_limit: 200
|
|
76
77
|
tmpfs:
|
|
77
78
|
- /tmp:size=100M,noexec,nosuid,nodev
|
|
@@ -80,14 +81,14 @@ const COMPOSE_CONTENT = `services:
|
|
|
80
81
|
- "127.0.0.1:${PORT}:${PORT}"
|
|
81
82
|
volumes:
|
|
82
83
|
- limbo-data:/data
|
|
83
|
-
-
|
|
84
|
+
- ${VAULT_DIR}:/data/vault
|
|
84
85
|
- limbo-openclaw-state:/home/limbo/.openclaw
|
|
85
86
|
secrets:
|
|
86
87
|
- llm_api_key
|
|
87
88
|
- telegram_bot_token
|
|
88
89
|
- gateway_token
|
|
89
90
|
env_file:
|
|
90
|
-
-
|
|
91
|
+
- ${LIMBO_DIR}/.env
|
|
91
92
|
environment:
|
|
92
93
|
OPENCLAW_CONFIG_PATH: /home/limbo/.openclaw/openclaw.json
|
|
93
94
|
OPENCLAW_STATE_DIR: /home/limbo/.openclaw
|
|
@@ -104,11 +105,11 @@ const COMPOSE_CONTENT = `services:
|
|
|
104
105
|
|
|
105
106
|
secrets:
|
|
106
107
|
llm_api_key:
|
|
107
|
-
file:
|
|
108
|
+
file: ${SECRETS_DIR}/llm_api_key
|
|
108
109
|
telegram_bot_token:
|
|
109
|
-
file:
|
|
110
|
+
file: ${SECRETS_DIR}/telegram_bot_token
|
|
110
111
|
gateway_token:
|
|
111
|
-
file:
|
|
112
|
+
file: ${SECRETS_DIR}/gateway_token
|
|
112
113
|
|
|
113
114
|
volumes:
|
|
114
115
|
limbo-data:
|
|
@@ -125,6 +126,9 @@ const COMPOSE_CONTENT_HARDENED = `services:
|
|
|
125
126
|
- no-new-privileges:true
|
|
126
127
|
cap_drop:
|
|
127
128
|
- ALL
|
|
129
|
+
cap_add:
|
|
130
|
+
- CHOWN
|
|
131
|
+
- FOWNER
|
|
128
132
|
pids_limit: 200
|
|
129
133
|
tmpfs:
|
|
130
134
|
- /tmp:size=100M,noexec,nosuid,nodev
|
|
@@ -133,14 +137,14 @@ const COMPOSE_CONTENT_HARDENED = `services:
|
|
|
133
137
|
- "127.0.0.1:${PORT}:${PORT}"
|
|
134
138
|
volumes:
|
|
135
139
|
- limbo-data:/data
|
|
136
|
-
-
|
|
140
|
+
- ${VAULT_DIR}:/data/vault
|
|
137
141
|
- limbo-openclaw-state:/home/limbo/.openclaw
|
|
138
142
|
secrets:
|
|
139
143
|
- llm_api_key
|
|
140
144
|
- telegram_bot_token
|
|
141
145
|
- gateway_token
|
|
142
146
|
env_file:
|
|
143
|
-
-
|
|
147
|
+
- ${LIMBO_DIR}/.env
|
|
144
148
|
environment:
|
|
145
149
|
OPENCLAW_CONFIG_PATH: /home/limbo/.openclaw/openclaw.json
|
|
146
150
|
OPENCLAW_STATE_DIR: /home/limbo/.openclaw
|
|
@@ -178,8 +182,8 @@ const COMPOSE_CONTENT_HARDENED = `services:
|
|
|
178
182
|
- internal
|
|
179
183
|
- external
|
|
180
184
|
volumes:
|
|
181
|
-
-
|
|
182
|
-
-
|
|
185
|
+
- ${LIMBO_DIR}/squid/squid.conf:/etc/squid/squid.conf:ro
|
|
186
|
+
- ${LIMBO_DIR}/squid/allowed-domains.txt:/etc/squid/allowed-domains.txt:ro
|
|
183
187
|
|
|
184
188
|
networks:
|
|
185
189
|
internal:
|
|
@@ -188,11 +192,11 @@ networks:
|
|
|
188
192
|
|
|
189
193
|
secrets:
|
|
190
194
|
llm_api_key:
|
|
191
|
-
file:
|
|
195
|
+
file: ${SECRETS_DIR}/llm_api_key
|
|
192
196
|
telegram_bot_token:
|
|
193
|
-
file:
|
|
197
|
+
file: ${SECRETS_DIR}/telegram_bot_token
|
|
194
198
|
gateway_token:
|
|
195
|
-
file:
|
|
199
|
+
file: ${SECRETS_DIR}/gateway_token
|
|
196
200
|
|
|
197
201
|
volumes:
|
|
198
202
|
limbo-data:
|
|
@@ -254,14 +258,19 @@ const TEXT = {
|
|
|
254
258
|
logsHint: 'Check logs with: limbo logs',
|
|
255
259
|
healthy: 'Container is healthy.',
|
|
256
260
|
subscriptionSetup: 'Provider authentication',
|
|
257
|
-
openaiSubscriptionIntro: 'Limbo will authenticate with your
|
|
261
|
+
openaiSubscriptionIntro: 'Limbo will authenticate with your OpenAI account. A URL will open in your browser — log in and authorize access.',
|
|
258
262
|
anthropicSubscriptionIntro: 'Generate a Claude setup-token on any machine with `claude setup-token`, then paste it into the next step.',
|
|
259
263
|
authFlowStart: 'Starting authentication...',
|
|
260
264
|
authFlowDone: 'Authentication complete.',
|
|
261
|
-
modelConnected: (model) => `Model connected: ${model}`,
|
|
262
265
|
authFlowFailed: 'Authentication did not complete successfully.',
|
|
263
266
|
authStatusFailed: 'Provider auth is still missing or invalid. Try running with --reconfigure.',
|
|
267
|
+
oauthPasteHint: 'After you log in, the browser will redirect to a localhost URL (it may show an error page — that\'s normal). Copy the full URL from the address bar and paste it below.',
|
|
268
|
+
oauthCallbackPrompt: ' Paste the callback URL: ',
|
|
269
|
+
oauthInvalidCallback: 'Could not extract an authorization code from that input. Paste the full URL from the browser address bar.',
|
|
270
|
+
oauthExchanging: 'Exchanging authorization code for tokens...',
|
|
271
|
+
oauthStateMismatch: 'OAuth state mismatch — proceeding anyway, but this may indicate a problem.',
|
|
264
272
|
configFlowStart: 'Applying configuration...',
|
|
273
|
+
configFlowSlow: 'This may take a couple of minutes.',
|
|
265
274
|
configFlowDone: 'Configuration applied.',
|
|
266
275
|
configFlowFailed: 'Could not apply configuration. Check your settings and try again.',
|
|
267
276
|
composing: 'Initializing...',
|
|
@@ -347,14 +356,19 @@ const TEXT = {
|
|
|
347
356
|
logsHint: 'Mira los logs con: limbo logs',
|
|
348
357
|
healthy: 'El container esta healthy.',
|
|
349
358
|
subscriptionSetup: 'Autenticacion del provider',
|
|
350
|
-
openaiSubscriptionIntro: 'Limbo va a autenticarse con tu
|
|
359
|
+
openaiSubscriptionIntro: 'Limbo va a autenticarse con tu cuenta de OpenAI. Se va a abrir una URL en tu navegador — inicia sesion y autoriza el acceso.',
|
|
351
360
|
anthropicSubscriptionIntro: 'Genera un Claude setup-token en cualquier maquina con `claude setup-token` y pegalo en el siguiente paso.',
|
|
352
361
|
authFlowStart: 'Iniciando autenticacion...',
|
|
353
362
|
authFlowDone: 'Autenticacion completada.',
|
|
354
|
-
modelConnected: (model) => `Modelo conectado: ${model}`,
|
|
355
363
|
authFlowFailed: 'La autenticacion no termino correctamente.',
|
|
356
364
|
authStatusFailed: 'La autenticacion del provider sigue siendo invalida o no esta configurada. Proba con --reconfigure.',
|
|
365
|
+
oauthPasteHint: 'Despues de loguearte, el browser va a redirigir a una URL de localhost (puede mostrar una pagina de error — es normal). Copa la URL completa de la barra de direcciones y pegala abajo.',
|
|
366
|
+
oauthCallbackPrompt: ' Pega la URL de callback: ',
|
|
367
|
+
oauthInvalidCallback: 'No se pudo extraer un codigo de autorizacion. Pega la URL completa de la barra del navegador.',
|
|
368
|
+
oauthExchanging: 'Intercambiando codigo de autorizacion por tokens...',
|
|
369
|
+
oauthStateMismatch: 'OAuth state mismatch — se continua igual, pero esto puede indicar un problema.',
|
|
357
370
|
configFlowStart: 'Aplicando configuracion...',
|
|
371
|
+
configFlowSlow: 'Esto puede tardar un par de minutos.',
|
|
358
372
|
configFlowDone: 'Configuracion aplicada.',
|
|
359
373
|
configFlowFailed: 'No se pudo aplicar la configuracion. Revisa los ajustes e intenta de nuevo.',
|
|
360
374
|
composing: 'Inicializando...',
|
|
@@ -751,19 +765,21 @@ function ensureGatewayToken(existingEnv) {
|
|
|
751
765
|
}
|
|
752
766
|
|
|
753
767
|
function pullOrBuildImage(lang) {
|
|
768
|
+
// When running from the repo (npx .), prefer local build over registry pull.
|
|
769
|
+
const repoDockerfile = path.join(__dirname, 'Dockerfile');
|
|
770
|
+
if (fs.existsSync(repoDockerfile)) {
|
|
771
|
+
header(t(lang, 'buildingFallback'));
|
|
772
|
+
execSync(`docker build -t ${GHCR_IMAGE}:${DEFAULT_TAG} .`, { stdio: 'inherit', cwd: __dirname });
|
|
773
|
+
ok(t(lang, 'buildOk', DEFAULT_TAG));
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
|
|
754
777
|
header(t(lang, 'pullingImage'));
|
|
755
778
|
try {
|
|
756
779
|
run('docker compose pull -q');
|
|
757
780
|
ok(t(lang, 'imagePulled'));
|
|
758
781
|
} catch {
|
|
759
|
-
|
|
760
|
-
const repoDockerfile = path.join(__dirname, 'Dockerfile');
|
|
761
|
-
if (!fs.existsSync(repoDockerfile)) {
|
|
762
|
-
die('Could not pull image and no local Dockerfile found. Check your network or GHCR access.');
|
|
763
|
-
}
|
|
764
|
-
log(t(lang, 'buildingFallback'));
|
|
765
|
-
execSync(`docker build -t ${GHCR_IMAGE}:${DEFAULT_TAG} .`, { stdio: 'inherit', cwd: __dirname });
|
|
766
|
-
ok(t(lang, 'buildOk', DEFAULT_TAG));
|
|
782
|
+
die('Could not pull image and no local Dockerfile found. Check your network or GHCR access.');
|
|
767
783
|
}
|
|
768
784
|
}
|
|
769
785
|
|
|
@@ -771,8 +787,24 @@ function runOpenClaw(args, opts = {}) {
|
|
|
771
787
|
return runDockerCompose(['run', '--rm', '--entrypoint', 'openclaw', 'limbo', ...args], opts);
|
|
772
788
|
}
|
|
773
789
|
|
|
790
|
+
// Fix volume ownership before any docker compose run commands.
|
|
791
|
+
// cap_drop:ALL strips CAP_DAC_OVERRIDE from root, so a root-user container
|
|
792
|
+
// cannot write to limbo-owned volumes. This one-shot container runs as root
|
|
793
|
+
// with the minimum caps needed to chown the volume dirs back to limbo.
|
|
794
|
+
function ensureVolumePermissions() {
|
|
795
|
+
runDockerCompose([
|
|
796
|
+
'run', '--rm', '--no-deps',
|
|
797
|
+
'--user', 'root',
|
|
798
|
+
'--cap-add', 'DAC_OVERRIDE',
|
|
799
|
+
'--entrypoint', 'sh',
|
|
800
|
+
'limbo',
|
|
801
|
+
'-c', 'chown -R limbo:limbo /data /home/limbo/.openclaw 2>/dev/null; true',
|
|
802
|
+
], { stdio: 'pipe' });
|
|
803
|
+
}
|
|
804
|
+
|
|
774
805
|
function applyOpenClawConfig(cfg) {
|
|
775
806
|
header(t(cfg.language, 'configFlowStart'));
|
|
807
|
+
log(t(cfg.language, 'configFlowSlow'));
|
|
776
808
|
|
|
777
809
|
const setCommands = [
|
|
778
810
|
['config', 'set', 'gateway.mode', 'local'],
|
|
@@ -790,9 +822,15 @@ function applyOpenClawConfig(cfg) {
|
|
|
790
822
|
);
|
|
791
823
|
}
|
|
792
824
|
|
|
825
|
+
const total = setCommands.length + 1; // +1 for validate
|
|
826
|
+
let step = 0;
|
|
827
|
+
|
|
793
828
|
for (const command of setCommands) {
|
|
829
|
+
step++;
|
|
830
|
+
process.stdout.write(`\r${c.dim} [${step}/${total}] ${command.slice(1, 4).join(' ')}${c.reset}`.padEnd(60));
|
|
794
831
|
const result = runOpenClaw(command, { stdio: 'pipe' });
|
|
795
832
|
if (result.status !== 0) {
|
|
833
|
+
console.log('');
|
|
796
834
|
process.stdout.write(result.stdout || '');
|
|
797
835
|
process.stderr.write(result.stderr || '');
|
|
798
836
|
die(t(cfg.language, 'configFlowFailed'));
|
|
@@ -803,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
|
-
//
|
|
817
|
-
//
|
|
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
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
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
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
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(
|
|
914
|
-
|
|
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
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
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.
|
|
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
|
+
});
|