@arthovlabs/liber 0.7.2 → 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arthovlabs/liber",
3
- "version": "0.7.2",
3
+ "version": "0.7.4",
4
4
  "description": "LiBER — autonomous agent orchestrator with a machine-enforced grounding gate and a skill library",
5
5
  "bin": {
6
6
  "liber": "bin/liber",
@@ -301,43 +301,88 @@ function containerPolicy() {
301
301
  };
302
302
  }
303
303
 
304
- let _dockerCache = null;
305
- /** Is a Docker daemon reachable? Cached for a minute; the check costs ~50ms. */
306
- function containerAvailable(force = false) {
307
- if (!force && _dockerCache && Date.now() - _dockerCache.at < 60_000) return _dockerCache.ok;
308
- let ok = false;
309
- try {
310
- const out = require('child_process').execFileSync('docker', ['version', '--format', '{{.Server.Version}}'],
311
- { timeout: 8000, windowsHide: true, stdio: ['ignore', 'pipe', 'ignore'] });
312
- ok = String(out).trim().length > 0;
313
- } catch { ok = false; }
314
- _dockerCache = { ok, at: Date.now() };
315
- return ok;
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
- * A reachable daemon is not the same thing: a CI runner can have the CLI and a
322
- * server version and still refuse to run (no image, no permissions). Checking by
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 (!force && _runCache && _runCache.image === image && Date.now() - _runCache.at < 300_000) return _runCache;
327
- if (!containerAvailable(force)) {
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
- let ok = false;
332
- let error = null;
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
- require('child_process').execFileSync('docker', ['run', '--rm', '--network', 'none', image, 'true'],
335
- { timeout: 60000, windowsHide: true, stdio: ['ignore', 'ignore', 'pipe'] });
336
- ok = true;
337
- } catch (e) {
338
- error = String(e.stderr || e.message || 'container run failed').slice(0, 200);
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
- const status = sh('git', ['status', '--porcelain=v1', '--untracked-files=all']);
46
- if (!status) return [];
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
- return {
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) {
@@ -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
- logger.warn('SYSTEM', 'AUTH_FAILED', { endpoint, ip: req.socket.remoteAddress });
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
- const dockerOk = terminalTool.containerAvailable(true);
937
- let dockerVersion = null;
938
- if (dockerOk) {
939
- try {
940
- dockerVersion = String(require('child_process').execFileSync('docker', ['version', '--format', '{{.Server.Version}}'], { timeout: 8000, windowsHide: true })).trim();
941
- } catch { dockerVersion = null; }
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,