@arthovlabs/liber 0.7.3 → 0.7.4
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/package.json
CHANGED
|
@@ -301,43 +301,88 @@ function containerPolicy() {
|
|
|
301
301
|
};
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
-
let _dockerCache = null;
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
304
|
+
let _dockerCache = { ok: false, at: 0, checked: false, version: null };
|
|
305
|
+
let _wslCache = { ok: false, at: 0, checked: false };
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Cached answer only — never probes. The probe is a synchronous child process and
|
|
309
|
+
* blocks the whole daemon: this used to cost 8 s per call, so a single /sandbox poll
|
|
310
|
+
* made /workspace look like it took 12 s and the UI piled requests on top of it.
|
|
311
|
+
*/
|
|
312
|
+
function containerAvailable() {
|
|
313
|
+
return _dockerCache.checked && _dockerCache.ok;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Last probe result, without touching the runtime. */
|
|
317
|
+
function containerSnapshot() {
|
|
318
|
+
return { ..._dockerCache };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Probe docker (and wsl) asynchronously and cache the result. Safe to call on every
|
|
323
|
+
* /sandbox: within the TTL it returns immediately, and it never blocks the event loop.
|
|
324
|
+
*/
|
|
325
|
+
async function probeContainers({ force = false } = {}) {
|
|
326
|
+
const TTL = 60_000;
|
|
327
|
+
if (!force && _dockerCache.checked && Date.now() - _dockerCache.at < TTL) return containerSnapshot();
|
|
328
|
+
|
|
329
|
+
const { execFile } = require('child_process');
|
|
330
|
+
const run = (file, args, timeout) => new Promise((resolve) => {
|
|
331
|
+
execFile(file, args, { timeout, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] },
|
|
332
|
+
(err, stdout) => resolve(err ? null : String(stdout || '').trim()));
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
const version = await run('docker', ['version', '--format', '{{.Server.Version}}'], 1500);
|
|
336
|
+
_dockerCache = { ok: !!version, at: Date.now(), checked: true, version: version || null };
|
|
337
|
+
|
|
338
|
+
if (!_dockerCache.ok) {
|
|
339
|
+
_runCache = { ok: false, image: (_runCache && _runCache.image) || 'alpine:latest', at: Date.now(), error: 'docker daemon not reachable' };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const wsl = process.platform === 'win32' ? await run('wsl.exe', ['-l', '-q'], 2000) : '';
|
|
343
|
+
_wslCache = { ok: !!wsl && wsl.replace(/\0/g, '').trim().length > 0, at: Date.now(), checked: true };
|
|
344
|
+
|
|
345
|
+
return containerSnapshot();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function wslAvailable() {
|
|
349
|
+
return _wslCache.checked && _wslCache.ok;
|
|
316
350
|
}
|
|
317
351
|
|
|
318
352
|
let _runCache = null;
|
|
319
353
|
/**
|
|
320
354
|
* Can this machine actually START a container with the configured image?
|
|
321
|
-
*
|
|
322
|
-
*
|
|
323
|
-
* running is the only honest answer, and it is cached because it costs ~1s.
|
|
355
|
+
* Reads the cache only; `probeContainers()` is what fills it, including the far more
|
|
356
|
+
* expensive "run something" check (a reachable daemon can still refuse to run).
|
|
324
357
|
*/
|
|
325
358
|
function containerUsable(force = false, image = 'alpine:latest') {
|
|
326
|
-
if (
|
|
327
|
-
if (!containerAvailable(
|
|
359
|
+
if (_runCache && _runCache.image === image && Date.now() - _runCache.at < 300_000) return _runCache;
|
|
360
|
+
if (!containerAvailable()) {
|
|
328
361
|
_runCache = { ok: false, image, at: Date.now(), error: 'docker daemon not reachable' };
|
|
329
362
|
return _runCache;
|
|
330
363
|
}
|
|
331
|
-
|
|
332
|
-
|
|
364
|
+
// Daemon is up but nothing has proven it can run this image yet: probe in the
|
|
365
|
+
// background and fail closed for now rather than blocking the caller.
|
|
366
|
+
probeRun(image).catch(() => {});
|
|
367
|
+
_runCache = { ok: false, image, at: Date.now(), error: 'container run not verified yet' };
|
|
368
|
+
return _runCache;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
let _probingRun = false;
|
|
372
|
+
async function probeRun(image = 'alpine:latest') {
|
|
373
|
+
if (_probingRun) return _runCache;
|
|
374
|
+
_probingRun = true;
|
|
333
375
|
try {
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
376
|
+
const { execFile } = require('child_process');
|
|
377
|
+
const ok = await new Promise((resolve) => {
|
|
378
|
+
execFile('docker', ['run', '--rm', '--network', 'none', image, 'true'],
|
|
379
|
+
{ timeout: 20000, windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] },
|
|
380
|
+
(err) => resolve(!err));
|
|
381
|
+
});
|
|
382
|
+
_runCache = { ok, image, at: Date.now(), error: ok ? null : 'container run failed' };
|
|
383
|
+
} finally {
|
|
384
|
+
_probingRun = false;
|
|
339
385
|
}
|
|
340
|
-
_runCache = { ok, image, at: Date.now(), error };
|
|
341
386
|
return _runCache;
|
|
342
387
|
}
|
|
343
388
|
|
|
@@ -516,5 +561,8 @@ module.exports = {
|
|
|
516
561
|
sandboxEnv,
|
|
517
562
|
containerPolicy,
|
|
518
563
|
containerAvailable,
|
|
564
|
+
containerSnapshot,
|
|
565
|
+
probeContainers,
|
|
566
|
+
wslAvailable,
|
|
519
567
|
containerUsable
|
|
520
568
|
};
|
|
@@ -42,10 +42,19 @@ function branch() {
|
|
|
42
42
|
*/
|
|
43
43
|
function changedFiles(limit = 40) {
|
|
44
44
|
if (!isGitRepo()) return [];
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// --untracked-files=all walked the entire parent repository on every poll (the
|
|
46
|
+
// data directory lives inside the user's home repo). Cap it in time and breadth:
|
|
47
|
+
// a workspace view needs the shape of the change, not an exhaustive census.
|
|
48
|
+
const status = sh('git', ['status', '--porcelain=v1', '--untracked-files=normal'], { timeout: 1500 });
|
|
49
|
+
if (!status) {
|
|
50
|
+
// Timed out: this directory sits in a repository too large to walk per request.
|
|
51
|
+
// Remember that, so the next polls hit the cache instead of stalling again.
|
|
52
|
+
_slowRepo = true;
|
|
53
|
+
return [];
|
|
54
|
+
}
|
|
55
|
+
_slowRepo = false;
|
|
47
56
|
|
|
48
|
-
const numstat = sh('git', ['diff', '--numstat', 'HEAD']) || '';
|
|
57
|
+
const numstat = sh('git', ['diff', '--numstat', 'HEAD'], { timeout: 1500 }) || '';
|
|
49
58
|
const stats = new Map();
|
|
50
59
|
for (const line of numstat.split('\n')) {
|
|
51
60
|
const m = line.match(/^(\d+|-)\t(\d+|-)\t(.+)$/);
|
|
@@ -78,12 +87,24 @@ function changedFiles(limit = 40) {
|
|
|
78
87
|
}
|
|
79
88
|
|
|
80
89
|
function summary(limit = 40) {
|
|
90
|
+
// Polled every 20 s by the shell. On a machine whose data directory sits inside a
|
|
91
|
+
// large parent repository (a home directory that happens to be a git repo), the
|
|
92
|
+
// status walk cost seconds per poll and starved every other request. Cache it.
|
|
93
|
+
const now = Date.now();
|
|
94
|
+
// A repository too large to walk gets a much longer TTL: the view is cached for a
|
|
95
|
+
// minute instead of four seconds, and says it was truncated rather than pretending.
|
|
96
|
+
const ttl = _slowRepo ? 60000 : 4000;
|
|
97
|
+
// Keyed by directory: a workspace switch (or a test) must not read the previous
|
|
98
|
+
// repository's answer from the cache.
|
|
99
|
+
if (_summaryCache.value && _summaryCache.cwd === process.cwd() && now - _summaryCache.at < ttl) return _summaryCache.value;
|
|
100
|
+
|
|
81
101
|
const files = changedFiles(limit);
|
|
82
102
|
const git = isGitRepo();
|
|
83
|
-
|
|
103
|
+
const value = {
|
|
84
104
|
cwd: process.cwd(),
|
|
85
105
|
is_git: git,
|
|
86
106
|
branch: git ? branch() : null,
|
|
107
|
+
truncated: _slowRepo,
|
|
87
108
|
changed_files: files,
|
|
88
109
|
totals: {
|
|
89
110
|
files: files.length,
|
|
@@ -91,7 +112,11 @@ function summary(limit = 40) {
|
|
|
91
112
|
deletions: files.reduce((a, f) => a + (f.deletions || 0), 0)
|
|
92
113
|
}
|
|
93
114
|
};
|
|
115
|
+
_summaryCache = { at: now, cwd: process.cwd(), value };
|
|
116
|
+
return value;
|
|
94
117
|
}
|
|
118
|
+
let _summaryCache = { at: 0, value: null };
|
|
119
|
+
let _slowRepo = false;
|
|
95
120
|
|
|
96
121
|
/** Record one tool step the engine actually ran (fed from turn events). */
|
|
97
122
|
function record(event) {
|
package/src/daemon/liberd.js
CHANGED
|
@@ -17,6 +17,22 @@ const guard = require('./core/http-guard');
|
|
|
17
17
|
|
|
18
18
|
// ceiling for the routes that answer without a token
|
|
19
19
|
const unauth = guard.createThrottle();
|
|
20
|
+
|
|
21
|
+
// A shell that polls without a token used to write one log line per request: 12,239
|
|
22
|
+
// of them in a day. Keep the signal, drop the flood.
|
|
23
|
+
const authFailLog = new Map();
|
|
24
|
+
function logAuthFailure(endpoint, ip) {
|
|
25
|
+
const key = endpoint + '|' + ip;
|
|
26
|
+
const now = Date.now();
|
|
27
|
+
const prev = authFailLog.get(key) || { at: 0, count: 0 };
|
|
28
|
+
if (now - prev.at > 60000) {
|
|
29
|
+
logger.warn('SYSTEM', 'AUTH_FAILED', { endpoint, ip, suppressed_since_last: prev.count });
|
|
30
|
+
authFailLog.set(key, { at: now, count: 0 });
|
|
31
|
+
} else {
|
|
32
|
+
prev.count += 1;
|
|
33
|
+
authFailLog.set(key, prev);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
20
36
|
const policy = require('./core/policy');
|
|
21
37
|
const journal = require('./core/journal');
|
|
22
38
|
const modelSwitch = require('./core/model-switch');
|
|
@@ -249,6 +265,9 @@ class LiberDaemon {
|
|
|
249
265
|
const bind = () => {
|
|
250
266
|
this.server.listen(this.port, host, () => {
|
|
251
267
|
logger.info('SYSTEM', 'DAEMON_START', { port: this.port, host, cwd: process.cwd() });
|
|
268
|
+
// Warm the sandbox probe so the first /sandbox (the shell asks on boot) does
|
|
269
|
+
// not have to wait for docker to answer — or for it to time out.
|
|
270
|
+
try { require('./core/tools/terminal-tool').probeContainers().catch(() => {}); } catch { /* tool not loaded */ }
|
|
252
271
|
});
|
|
253
272
|
};
|
|
254
273
|
this.server.on('error', (err) => {
|
|
@@ -415,7 +434,7 @@ class LiberDaemon {
|
|
|
415
434
|
success: false,
|
|
416
435
|
error: 'Unauthorized. Send Authorization: Bearer <token>. The desktop token lives in .liber/daemon.token; other clients get their own from /clients (scope admin).'
|
|
417
436
|
}));
|
|
418
|
-
|
|
437
|
+
logAuthFailure(endpoint, req.socket.remoteAddress);
|
|
419
438
|
auth.audit({ client: null, ip: req.socket.remoteAddress, method: req.method, endpoint, decision: 'deny', reason: 'bad token' });
|
|
420
439
|
return;
|
|
421
440
|
}
|
|
@@ -933,18 +952,13 @@ class LiberDaemon {
|
|
|
933
952
|
if (req.method === 'GET') {
|
|
934
953
|
const term = policy.terminalConfig();
|
|
935
954
|
const terminalTool = require('./core/tools/terminal-tool');
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
let wsl = false;
|
|
944
|
-
try {
|
|
945
|
-
const out = require('child_process').execFileSync('wsl.exe', ['-l', '-q'], { timeout: 8000, windowsHide: true });
|
|
946
|
-
wsl = String(out).replace(/\0/g, '').trim().length > 0;
|
|
947
|
-
} catch { wsl = false; }
|
|
955
|
+
// Probing docker synchronously here used to block the whole daemon for 8 s
|
|
956
|
+
// (plus 8 s for wsl), which made every other poll look stalled. The probe is
|
|
957
|
+
// now async and cached; on a warm cache this returns in microseconds.
|
|
958
|
+
const snap = await terminalTool.probeContainers();
|
|
959
|
+
const dockerOk = snap.ok;
|
|
960
|
+
const dockerVersion = snap.version;
|
|
961
|
+
const wsl = terminalTool.wslAvailable();
|
|
948
962
|
res.end(JSON.stringify({
|
|
949
963
|
success: true,
|
|
950
964
|
backend: term.backend,
|