@arthovlabs/liber 0.5.6 → 0.6.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 +46 -0
- package/liber.yaml +1 -1
- package/package.json +1 -1
- package/src/daemon/core/auth.js +8 -1
- package/src/daemon/core/engine.js +10 -4
- package/src/daemon/core/http-guard.js +47 -0
- package/src/daemon/core/providers.js +37 -0
- package/src/daemon/core/shares.js +151 -0
- package/src/daemon/core/stops.js +76 -0
- package/src/daemon/core/tools/terminal-tool.js +45 -13
- package/src/daemon/liberd.js +101 -6
- package/src/daemon/llm/manager.js +5 -0
package/README.md
CHANGED
|
@@ -373,6 +373,52 @@ Every decision and every run is appended to `.liber/terminal-audit.log`, and
|
|
|
373
373
|
Docker Desktop must be running for the container backend; when it is not, LiBER
|
|
374
374
|
falls back to Constrained Language Mode and says so instead of pretending.
|
|
375
375
|
|
|
376
|
+
Each line records `decision` (`allow`, `run` or **`deny`**), the command, the exit
|
|
377
|
+
code and duration, the sandbox in force, and **who asked** — `agentId`, `client`
|
|
378
|
+
and `turnId`. A refusal is the event an operator most needs to see, so it is
|
|
379
|
+
logged with its reason instead of being swallowed. Files rotate at 5 MB
|
|
380
|
+
(current → `.1`) so the trail stays readable.
|
|
381
|
+
|
|
382
|
+
### Stopping a turn that is already running
|
|
383
|
+
|
|
384
|
+
`POST /stop {"agentId":"dev-bot"}` is the kill switch. It marks the agent so the
|
|
385
|
+
engine stops **before its next model call**, and it kills any command already
|
|
386
|
+
running for it — on Windows the whole process tree, not just the shell wrapper.
|
|
387
|
+
The turn ends with `stopped: true` and an honest "Stopped by you after N
|
|
388
|
+
iterations. Nothing is claimed as complete." The desktop shell's ■ button calls
|
|
389
|
+
this endpoint as well as aborting its own stream, so a turn started by a script is
|
|
390
|
+
just as stoppable as one started by hand.
|
|
391
|
+
|
|
392
|
+
## 🔗 Sharing a session (read-only links)
|
|
393
|
+
|
|
394
|
+
Right-click a session in the sidebar → **Share link**. LiBER mints a 128-bit
|
|
395
|
+
capability URL:
|
|
396
|
+
|
|
397
|
+
```
|
|
398
|
+
POST /sessions/share {"key":"dev-bot#<session>","ttl_hours":168}
|
|
399
|
+
GET /shared/<id> no bearer token; the id is the credential
|
|
400
|
+
GET /sessions/share list links, expiry and view count
|
|
401
|
+
DELETE /sessions/share?id=<id> revoke immediately
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
What a link can and cannot do:
|
|
405
|
+
|
|
406
|
+
- **read only** — there is no route from a share id to `/chat`, `/config` or any
|
|
407
|
+
tool, so a leaked link cannot cost you tokens or change anything;
|
|
408
|
+
- **sanitized** — the payload is built server-side: tool observations
|
|
409
|
+
(`OBSERVATION [E…]`), denials and internal retry prompts are dropped,
|
|
410
|
+
credentials are redacted, the account path becomes `~`, and long messages are
|
|
411
|
+
truncated. Rendering-side hiding is not relied on;
|
|
412
|
+
- **expiring** — seven days by default, checked at read time, and revoked links
|
|
413
|
+
answer 404 rather than a cached copy.
|
|
414
|
+
|
|
415
|
+
### Kill switch vs. share link
|
|
416
|
+
|
|
417
|
+
A share link is deliberately *not* a token: it cannot be replayed into the API.
|
|
418
|
+
The only thing it unlocks is `GET /shared/<id>`, once per request, while
|
|
419
|
+
`expires_at` is in the future and `revoked` is false.
|
|
420
|
+
|
|
421
|
+
|
|
376
422
|
---
|
|
377
423
|
|
|
378
424
|
## 🔑 Clients and tokens
|
package/liber.yaml
CHANGED
|
@@ -70,7 +70,7 @@ policy:
|
|
|
70
70
|
backend: "docker"
|
|
71
71
|
image: "alpine:latest"
|
|
72
72
|
allow_network: false
|
|
73
|
-
mode: "
|
|
73
|
+
mode: "prompt" # ask the human for state-changing commands; read-only runs freely
|
|
74
74
|
allowlist: ["npm test", "node --test", "git status", "git diff", "git add", "git commit", "git push"]
|
|
75
75
|
denylist: []
|
|
76
76
|
|
package/package.json
CHANGED
package/src/daemon/core/auth.js
CHANGED
|
@@ -21,7 +21,7 @@ const DEFAULT_LIMIT = 240; // requests per minute per client
|
|
|
21
21
|
function scopeFor(method, endpoint) {
|
|
22
22
|
if (endpoint === '/shutdown' || endpoint.startsWith('/clients')) return 'admin';
|
|
23
23
|
if (endpoint.startsWith('/config') || endpoint === '/vault/migrate') return method === 'GET' ? 'read' : 'admin';
|
|
24
|
-
if (endpoint === '/chat' || endpoint === '/chat/stream' || endpoint === '/model/switch' || endpoint.endsWith('/run')) return 'chat';
|
|
24
|
+
if (endpoint === '/chat' || endpoint === '/chat/stream' || endpoint === '/model/switch' || endpoint === '/stop' || endpoint === '/sessions/share' || endpoint.endsWith('/run')) return 'chat';
|
|
25
25
|
if (endpoint.startsWith('/hitl') || endpoint.startsWith('/memory') || endpoint.startsWith('/budget') || endpoint.startsWith('/sessions')) return 'chat';
|
|
26
26
|
if (method === 'GET' || method === 'HEAD') return 'read';
|
|
27
27
|
return 'tools';
|
|
@@ -123,6 +123,13 @@ function can(client, scope) {
|
|
|
123
123
|
return list.includes('*') || list.includes(scope);
|
|
124
124
|
}
|
|
125
125
|
|
|
126
|
+
function rotateAudit(file, maxBytes = 5 * 1024 * 1024) {
|
|
127
|
+
try {
|
|
128
|
+
if (require('fs').statSync(file).size < maxBytes) return;
|
|
129
|
+
require('fs').renameSync(file, file + '.1');
|
|
130
|
+
} catch { /* first write, or a rotation race */ }
|
|
131
|
+
}
|
|
132
|
+
|
|
126
133
|
function audit(entry) {
|
|
127
134
|
try {
|
|
128
135
|
fs.mkdirSync(DIR(), { recursive: true });
|
|
@@ -10,6 +10,7 @@ const budget = require('./budget');
|
|
|
10
10
|
const verifier = require('./verifier');
|
|
11
11
|
const journal = require('./journal');
|
|
12
12
|
const injection = require('./injection');
|
|
13
|
+
const stops = require('./stops');
|
|
13
14
|
|
|
14
15
|
const DEFAULT_GROUNDING = {
|
|
15
16
|
enabled: true,
|
|
@@ -398,14 +399,19 @@ class LiberEngine {
|
|
|
398
399
|
depth = 0,
|
|
399
400
|
allowedTools = null,
|
|
400
401
|
resumeState = null,
|
|
401
|
-
shouldStop = null
|
|
402
|
+
shouldStop = null,
|
|
403
|
+
client = null
|
|
402
404
|
}) {
|
|
403
405
|
const resumed = !!resumeState;
|
|
404
406
|
// The client can hang up mid-turn (stop button / closed window). Checked
|
|
405
407
|
// between iterations so we stop spending tokens as soon as it does.
|
|
406
408
|
const stopRequested = () => {
|
|
407
|
-
try {
|
|
409
|
+
try { if (typeof shouldStop === 'function' && shouldStop() === true) return true; } catch { /* socket may be gone */ }
|
|
410
|
+
// A /stop request is honoured even when no socket is watching: that is the
|
|
411
|
+
// whole point of a kill switch.
|
|
412
|
+
return stops.isRequested(agentId);
|
|
408
413
|
};
|
|
414
|
+
stops.clear(agentId);
|
|
409
415
|
let stopped = false;
|
|
410
416
|
// Session history lives under its own key so a new session starts empty;
|
|
411
417
|
// tracing, evidence and the breaker stay keyed to the real agent.
|
|
@@ -861,7 +867,7 @@ class LiberEngine {
|
|
|
861
867
|
seq: ++subSeq
|
|
862
868
|
});
|
|
863
869
|
} else {
|
|
864
|
-
result = await registry.execute(call.name, call.args);
|
|
870
|
+
result = await registry.execute(call.name, call.args, { agentId, client: client || null, turnId: ledger.turnId || null });
|
|
865
871
|
}
|
|
866
872
|
|
|
867
873
|
// A tool can refuse for policy reasons and report that a human may
|
|
@@ -875,7 +881,7 @@ class LiberEngine {
|
|
|
875
881
|
const approved = await waitForHitl(agentId, call.name, call.args);
|
|
876
882
|
if (approved) {
|
|
877
883
|
onEvent('hitl_approved', { tool: call.name, retried: true });
|
|
878
|
-
result = await registry.execute(call.name, call.args, { approved: true });
|
|
884
|
+
result = await registry.execute(call.name, call.args, { approved: true, agentId, client: client || null, turnId: ledger.turnId || null });
|
|
879
885
|
} else {
|
|
880
886
|
result = { error: `DENIED: approval was not granted. Tool '${call.name}' did not execute.`, policy_denied: true };
|
|
881
887
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// src/daemon/core/http-guard.js — two decisions every request makes before routing.
|
|
2
|
+
//
|
|
3
|
+
// Kept pure and small so both can be tested without a socket: which browser origin
|
|
4
|
+
// is allowed to read a response, and how many requests an unauthenticated address
|
|
5
|
+
// may make. Neither is a substitute for the bearer token; they shrink what a page
|
|
6
|
+
// the user happens to visit, or a local probe, can do before it gets that far.
|
|
7
|
+
|
|
8
|
+
const LOOPBACK = /^https?:\/\/(127\.0\.0\.1|localhost|\[::1\])(:\d+)?$/;
|
|
9
|
+
|
|
10
|
+
/** Returns the origin to reflect, or null when it must not be echoed back. */
|
|
11
|
+
function allowOrigin(origin) {
|
|
12
|
+
const value = String(origin || '');
|
|
13
|
+
return LOOPBACK.test(value) ? value : null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A per-address ceiling for routes that answer without a token.
|
|
18
|
+
* 240/min is far above what a shell does and far below what a probe wants.
|
|
19
|
+
*/
|
|
20
|
+
function createThrottle({ limit, windowMs = 60000 } = {}) {
|
|
21
|
+
const max = Number.isFinite(limit) ? limit : (Number(process.env.LIBER_UNAUTH_RPM) || 240);
|
|
22
|
+
const buckets = new Map();
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
limit: max,
|
|
26
|
+
windowMs,
|
|
27
|
+
check(ip, now = Date.now()) {
|
|
28
|
+
const key = String(ip || 'local');
|
|
29
|
+
let bucket = buckets.get(key);
|
|
30
|
+
if (!bucket || now >= bucket.reset) {
|
|
31
|
+
bucket = { count: 0, reset: now + windowMs };
|
|
32
|
+
buckets.set(key, bucket);
|
|
33
|
+
}
|
|
34
|
+
bucket.count += 1;
|
|
35
|
+
const allowed = bucket.count <= max;
|
|
36
|
+
return {
|
|
37
|
+
allowed,
|
|
38
|
+
remaining: Math.max(0, max - bucket.count),
|
|
39
|
+
retryAfterMs: allowed ? 0 : Math.max(0, bucket.reset - now)
|
|
40
|
+
};
|
|
41
|
+
},
|
|
42
|
+
reset() { buckets.clear(); },
|
|
43
|
+
get size() { return buckets.size; }
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
module.exports = { allowOrigin, createThrottle, LOOPBACK };
|
|
@@ -294,6 +294,42 @@ function status() {
|
|
|
294
294
|
return { providers: rows, custom: customs };
|
|
295
295
|
}
|
|
296
296
|
|
|
297
|
+
/**
|
|
298
|
+
* Fill provider/endpoint when liber.yaml leaves them null.
|
|
299
|
+
*
|
|
300
|
+
* A fresh install ships `provider: null` and `endpoint: null` on purpose: the
|
|
301
|
+
* only thing the user is expected to hand over is a key. Nothing used to turn
|
|
302
|
+
* that key back into a base URL, so the adapter built `null/chat/completions`
|
|
303
|
+
* and every turn died — either as a URL parse error or, when a placeholder key
|
|
304
|
+
* from .env.example was sent instead, as HTTP 401. The endpoint has to come from
|
|
305
|
+
* whichever provider is actually configured.
|
|
306
|
+
*/
|
|
307
|
+
function engineDefaults(engine = {}) {
|
|
308
|
+
const cfg = { ...engine };
|
|
309
|
+
const missingProvider = !cfg.provider || cfg.provider === 'null';
|
|
310
|
+
const missingEndpoint = !cfg.endpoint || cfg.endpoint === 'null';
|
|
311
|
+
if (!missingProvider && !missingEndpoint) return cfg;
|
|
312
|
+
|
|
313
|
+
const { providers, custom } = status();
|
|
314
|
+
// Rank what is configured: an explicit custom endpoint first, then real API
|
|
315
|
+
// keys, and only then a local server. A machine that happens to run Ollama
|
|
316
|
+
// exports OLLAMA_HOST, which would otherwise hijack the endpoint away from a
|
|
317
|
+
// perfectly good cloud key.
|
|
318
|
+
const rank = p => (p.kind === 'custom' ? 0 : p.kind === 'api' ? 1 : 2);
|
|
319
|
+
const pick = [...custom, ...providers]
|
|
320
|
+
.filter(p => p.configured && p.base && p.adapter)
|
|
321
|
+
.sort((a, b) => rank(a) - rank(b))[0];
|
|
322
|
+
if (!pick) return cfg;
|
|
323
|
+
|
|
324
|
+
if (missingProvider) cfg.provider = pick.adapter;
|
|
325
|
+
if (missingEndpoint) cfg.endpoint = String(pick.base).replace(/\/+$/, '');
|
|
326
|
+
if (!cfg.apiKey) {
|
|
327
|
+
const key = resolve(pick.id, pick.base);
|
|
328
|
+
if (key) cfg.apiKey = key;
|
|
329
|
+
}
|
|
330
|
+
return cfg;
|
|
331
|
+
}
|
|
332
|
+
|
|
297
333
|
function imports() {
|
|
298
334
|
return IMPORTS.map(i => {
|
|
299
335
|
let present = false, when = null;
|
|
@@ -499,5 +535,6 @@ module.exports = {
|
|
|
499
535
|
status, imports, importFrom,
|
|
500
536
|
setKey, setToken, clear, setCustom,
|
|
501
537
|
resolve, resolveStored, test, deviceStart, devicePoll,
|
|
538
|
+
engineDefaults,
|
|
502
539
|
storePath: () => STORE()
|
|
503
540
|
};
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// src/daemon/core/shares.js — read-only session links (capability URLs).
|
|
2
|
+
//
|
|
3
|
+
// A share is a 128-bit id that maps to one session key. Whoever holds the id can
|
|
4
|
+
// read that session; nobody can write through it. The daemon binds to 127.0.0.1,
|
|
5
|
+
// so the link is only reachable from this machine (or a tunnel the operator
|
|
6
|
+
// chooses). Expiry and revoke are first-class, not an afterthought.
|
|
7
|
+
const crypto = require('crypto');
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const secrets = require('./secrets');
|
|
11
|
+
|
|
12
|
+
const MAX_MESSAGES = 400;
|
|
13
|
+
const MAX_CHARS_PER_MESSAGE = 8000;
|
|
14
|
+
const DEFAULT_TTL_HOURS = 24 * 7;
|
|
15
|
+
|
|
16
|
+
function dir() {
|
|
17
|
+
return path.resolve(process.cwd(), '.liber');
|
|
18
|
+
}
|
|
19
|
+
function file() {
|
|
20
|
+
return process.env.LIBER_SHARES_FILE || path.join(dir(), 'shares.json');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function read() {
|
|
24
|
+
try {
|
|
25
|
+
const raw = JSON.parse(fs.readFileSync(file(), 'utf8'));
|
|
26
|
+
return { version: 1, shares: raw && typeof raw.shares === 'object' ? raw.shares : {} };
|
|
27
|
+
} catch {
|
|
28
|
+
return { version: 1, shares: {} };
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function write(state) {
|
|
33
|
+
const target = file();
|
|
34
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
35
|
+
const tmp = target + '.tmp';
|
|
36
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf8');
|
|
37
|
+
try { fs.chmodSync(tmp, 0o600); } catch { /* Windows has no POSIX mode; its ACL applies */ }
|
|
38
|
+
fs.renameSync(tmp, target);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function newId() {
|
|
42
|
+
return crypto.randomBytes(16).toString('hex'); // 32 hex chars = 128 bits
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function create({ key, title = null, ttlHours = DEFAULT_TTL_HOURS } = {}) {
|
|
46
|
+
const sessionKey = String(key || '').trim();
|
|
47
|
+
if (!sessionKey) throw new Error('A session key is required to share.');
|
|
48
|
+
|
|
49
|
+
const hours = Number(ttlHours);
|
|
50
|
+
const ttl = Number.isFinite(hours) && hours > 0 ? Math.min(hours, 24 * 365) : DEFAULT_TTL_HOURS;
|
|
51
|
+
|
|
52
|
+
const state = read();
|
|
53
|
+
const id = newId();
|
|
54
|
+
const now = Date.now();
|
|
55
|
+
const record = {
|
|
56
|
+
id,
|
|
57
|
+
key: sessionKey,
|
|
58
|
+
title: title ? String(title).slice(0, 120) : null,
|
|
59
|
+
created_at: new Date(now).toISOString(),
|
|
60
|
+
expires_at: new Date(now + ttl * 3600 * 1000).toISOString(),
|
|
61
|
+
revoked: false,
|
|
62
|
+
views: 0,
|
|
63
|
+
last_viewed_at: null
|
|
64
|
+
};
|
|
65
|
+
state.shares[id] = record;
|
|
66
|
+
write(state);
|
|
67
|
+
return record;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function list() {
|
|
71
|
+
const state = read();
|
|
72
|
+
return Object.values(state.shares)
|
|
73
|
+
.sort((a, b) => String(b.created_at).localeCompare(String(a.created_at)))
|
|
74
|
+
.map(r => ({ ...r, expired: isExpired(r) }));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isExpired(record) {
|
|
78
|
+
return !record || record.revoked === true || Date.parse(record.expires_at) <= Date.now();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Resolve a link for reading. Returns null for unknown, revoked or expired ids. */
|
|
82
|
+
function resolve(id) {
|
|
83
|
+
const state = read();
|
|
84
|
+
const record = state.shares[String(id || '')];
|
|
85
|
+
if (!record || isExpired(record)) return null;
|
|
86
|
+
|
|
87
|
+
record.views = (record.views || 0) + 1;
|
|
88
|
+
record.last_viewed_at = new Date().toISOString();
|
|
89
|
+
try { write(state); } catch { /* a read must not fail because the counter could not be saved */ }
|
|
90
|
+
return record;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function revoke(id) {
|
|
94
|
+
const state = read();
|
|
95
|
+
const key = String(id || '');
|
|
96
|
+
if (!state.shares[key]) return false;
|
|
97
|
+
state.shares[key].revoked = true;
|
|
98
|
+
state.shares[key].revoked_at = new Date().toISOString();
|
|
99
|
+
write(state);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Turns the model's own scaffolding into noise the reader never asked for, and
|
|
104
|
+
// keeps anything that could identify this machine out of a link.
|
|
105
|
+
const INTERNAL = /^(OBSERVATION \[E|DENIED:|GROUNDING AUDIT|STOP —|You have at most|You returned an empty response|Your reply contained no \[FINAL|Your previous answer)/;
|
|
106
|
+
const HOME_PATHS = [
|
|
107
|
+
[new RegExp(String(process.env.USERPROFILE || '').replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'), 'gi'), '~'],
|
|
108
|
+
// C:\Users\<name>\... and the POSIX equivalent, so a shared link never carries
|
|
109
|
+
// the account name of the machine it came from.
|
|
110
|
+
[/[A-Z]:\\Users\\[^\\\s"']+/gi, '~'],
|
|
111
|
+
[/\/home\/[^/\s"']+/g, '~'],
|
|
112
|
+
[/\/Users\/[^/\s"']+/g, '~']
|
|
113
|
+
];
|
|
114
|
+
|
|
115
|
+
function sanitize(messages = []) {
|
|
116
|
+
const out = [];
|
|
117
|
+
for (const m of messages) {
|
|
118
|
+
const role = m && m.role ? String(m.role) : 'user';
|
|
119
|
+
let content = String((m && m.content) || '');
|
|
120
|
+
if (INTERNAL.test(content)) continue; // tool output, retries, audits
|
|
121
|
+
content = secrets.redact(content); // keys, tokens, cards
|
|
122
|
+
for (const [rx, to] of HOME_PATHS) if (rx.source) content = content.replace(rx, to);
|
|
123
|
+
if (content.length > MAX_CHARS_PER_MESSAGE) {
|
|
124
|
+
content = content.slice(0, MAX_CHARS_PER_MESSAGE) + '\n… [truncated in the shared copy]';
|
|
125
|
+
}
|
|
126
|
+
out.push({ role, content, ts: (m && m.ts) || null });
|
|
127
|
+
if (out.length >= MAX_MESSAGES) break;
|
|
128
|
+
}
|
|
129
|
+
return out;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The only thing a public link ever returns. */
|
|
133
|
+
function publicPayload(record, messages) {
|
|
134
|
+
return {
|
|
135
|
+
success: true,
|
|
136
|
+
share: {
|
|
137
|
+
id: record.id,
|
|
138
|
+
title: record.title,
|
|
139
|
+
created_at: record.created_at,
|
|
140
|
+
expires_at: record.expires_at,
|
|
141
|
+
read_only: true
|
|
142
|
+
},
|
|
143
|
+
messages: sanitize(messages)
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
module.exports = {
|
|
148
|
+
create, list, resolve, revoke, sanitize, publicPayload,
|
|
149
|
+
isExpired,
|
|
150
|
+
storePath: () => file()
|
|
151
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// src/daemon/core/stops.js — per-turn stop requests and the processes they own.
|
|
2
|
+
//
|
|
3
|
+
// The old kill switch was "close the browser": the SSE handler noticed the client
|
|
4
|
+
// disconnecting and the engine stopped at the next iteration. That leaves two
|
|
5
|
+
// holes: a caller with no socket (a script) cannot stop anything, and a terminal
|
|
6
|
+
// command keeps running to completion. This module closes both: a request marks
|
|
7
|
+
// the agent, and every child process started on its behalf is killed at once.
|
|
8
|
+
const stops = new Map(); // agentId -> { requested: boolean, at: number|null, children: Set }
|
|
9
|
+
|
|
10
|
+
function entry(agentId) {
|
|
11
|
+
const id = String(agentId || 'default');
|
|
12
|
+
if (!stops.has(id)) stops.set(id, { requested: false, at: null, children: new Set() });
|
|
13
|
+
return stops.get(id);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function request(agentId) {
|
|
17
|
+
const e = entry(agentId);
|
|
18
|
+
e.requested = true;
|
|
19
|
+
e.at = Date.now();
|
|
20
|
+
const killed = killChildren(agentId);
|
|
21
|
+
return { agentId: String(agentId || 'default'), killed };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function isRequested(agentId) {
|
|
25
|
+
const e = stops.get(String(agentId || 'default'));
|
|
26
|
+
return !!(e && e.requested);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function clear(agentId) {
|
|
30
|
+
const e = stops.get(String(agentId || 'default'));
|
|
31
|
+
if (e) { e.requested = false; e.at = null; }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Register a child process so /stop can end it mid-run. Returns a disposer. */
|
|
35
|
+
function track(agentId, child) {
|
|
36
|
+
if (!child || typeof child.kill !== 'function') return () => {};
|
|
37
|
+
const e = entry(agentId);
|
|
38
|
+
e.children.add(child);
|
|
39
|
+
const done = () => e.children.delete(child);
|
|
40
|
+
child.once('exit', done);
|
|
41
|
+
child.once('error', done);
|
|
42
|
+
return done;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function killChildren(agentId) {
|
|
46
|
+
const e = entry(agentId);
|
|
47
|
+
let killed = 0;
|
|
48
|
+
for (const child of [...e.children]) {
|
|
49
|
+
try {
|
|
50
|
+
// Kill the direct child first — it is the one we have a handle on — then, on
|
|
51
|
+
// Windows, take the whole tree: powershell.exe dying does not stop the
|
|
52
|
+
// command it launched.
|
|
53
|
+
try { child.kill('SIGKILL'); } catch { /* already gone */ }
|
|
54
|
+
if (process.platform === 'win32' && child.pid) {
|
|
55
|
+
require('child_process').execFile('taskkill', ['/PID', String(child.pid), '/T', '/F'],
|
|
56
|
+
{ windowsHide: true }, () => {});
|
|
57
|
+
}
|
|
58
|
+
killed += 1;
|
|
59
|
+
} catch { /* already gone */ }
|
|
60
|
+
e.children.delete(child);
|
|
61
|
+
}
|
|
62
|
+
return killed;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Called by the engine before it spends another model call or runs a tool. */
|
|
66
|
+
function shouldStop(agentId) {
|
|
67
|
+
return isRequested(agentId);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function snapshot() {
|
|
71
|
+
return [...stops.entries()].map(([agentId, e]) => ({
|
|
72
|
+
agentId, requested: e.requested, at: e.at, children: e.children.size
|
|
73
|
+
}));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
module.exports = { request, isRequested, clear, track, killChildren, shouldStop, snapshot };
|
|
@@ -248,12 +248,30 @@ function sandboxEnv() {
|
|
|
248
248
|
return env;
|
|
249
249
|
}
|
|
250
250
|
|
|
251
|
+
const stops = require('../stops');
|
|
252
|
+
|
|
253
|
+
function auditFile(name) {
|
|
254
|
+
return path.join(path.resolve(process.cwd(), '.liber'), name);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Keep an audit trail readable over months: when the file passes 5 MB it is
|
|
259
|
+
* rotated once (current -> .1, older .1 discarded). An audit log that grows
|
|
260
|
+
* without bound is the same as no audit log — nobody reads 200 MB.
|
|
261
|
+
*/
|
|
262
|
+
function rotate(file, maxBytes = 5 * 1024 * 1024) {
|
|
263
|
+
try {
|
|
264
|
+
if (fs.statSync(file).size < maxBytes) return;
|
|
265
|
+
fs.renameSync(file, file + '.1');
|
|
266
|
+
} catch { /* no file yet, or a rotation race — the append below still works */ }
|
|
267
|
+
}
|
|
268
|
+
|
|
251
269
|
function auditTerminal(entry) {
|
|
252
270
|
try {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
271
|
+
fs.mkdirSync(path.resolve(process.cwd(), '.liber'), { recursive: true });
|
|
272
|
+
const file = auditFile('terminal-audit.log');
|
|
273
|
+
rotate(file);
|
|
274
|
+
fs.appendFileSync(file, new Date().toISOString() + ' ' + JSON.stringify(entry) + '\n', 'utf8');
|
|
257
275
|
} catch { /* auditing must never block a command */ }
|
|
258
276
|
}
|
|
259
277
|
|
|
@@ -323,7 +341,7 @@ function containerUsable(force = false, image = 'alpine:latest') {
|
|
|
323
341
|
return _runCache;
|
|
324
342
|
}
|
|
325
343
|
|
|
326
|
-
function runContainer(command, { cwd, timeout, profile }) {
|
|
344
|
+
function runContainer(command, { cwd, timeout, profile, meta = {} }) {
|
|
327
345
|
const { execFile } = require('child_process');
|
|
328
346
|
const mount = `${path.resolve(process.cwd())}:/work:rw`;
|
|
329
347
|
// On POSIX hosts run as the invoking user: files the container writes land
|
|
@@ -348,8 +366,9 @@ function runContainer(command, { cwd, timeout, profile }) {
|
|
|
348
366
|
'sh', '-lc', command
|
|
349
367
|
];
|
|
350
368
|
return new Promise((resolve) => {
|
|
351
|
-
execFile('docker', args, { timeout, maxBuffer: 10 * 1024 * 1024, windowsHide: true },
|
|
369
|
+
const child = execFile('docker', args, { timeout, maxBuffer: 10 * 1024 * 1024, windowsHide: true },
|
|
352
370
|
(error, stdout, stderr) => resolve({ error, stdout: String(stdout || ''), stderr: String(stderr || '') }));
|
|
371
|
+
stops.track(meta.agentId, child);
|
|
353
372
|
});
|
|
354
373
|
}
|
|
355
374
|
|
|
@@ -357,7 +376,7 @@ function runContainer(command, { cwd, timeout, profile }) {
|
|
|
357
376
|
* Run under Constrained Language Mode via a throwaway script file. A file avoids
|
|
358
377
|
* every quoting trap that inline -Command has with quotes and $ signs.
|
|
359
378
|
*/
|
|
360
|
-
function runSandboxed(command, { cwd, timeout }) {
|
|
379
|
+
function runSandboxed(command, { cwd, timeout, meta = {} }) {
|
|
361
380
|
const { execFile } = require('child_process');
|
|
362
381
|
const tmpDir = path.resolve(process.cwd(), '.liber', 'tmp');
|
|
363
382
|
fs.mkdirSync(tmpDir, { recursive: true });
|
|
@@ -368,13 +387,14 @@ function runSandboxed(command, { cwd, timeout }) {
|
|
|
368
387
|
command + '\n', 'utf8');
|
|
369
388
|
|
|
370
389
|
return new Promise((resolve) => {
|
|
371
|
-
execFile('powershell.exe',
|
|
390
|
+
const child = execFile('powershell.exe',
|
|
372
391
|
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', script],
|
|
373
392
|
{ cwd, timeout, maxBuffer: 10 * 1024 * 1024, windowsHide: true, env: sandboxEnv() },
|
|
374
393
|
(error, stdout, stderr) => {
|
|
375
394
|
try { fs.unlinkSync(script); } catch { }
|
|
376
395
|
resolve({ error, stdout: String(stdout || ''), stderr: String(stderr || '') });
|
|
377
396
|
});
|
|
397
|
+
stops.track(meta.agentId, child);
|
|
378
398
|
});
|
|
379
399
|
}
|
|
380
400
|
|
|
@@ -383,8 +403,17 @@ async function runTerminal({ command, timeout = 45000, cwd = process.cwd() }, me
|
|
|
383
403
|
return { error: "Missing or invalid command argument." };
|
|
384
404
|
}
|
|
385
405
|
|
|
406
|
+
const ctx = { agentId: meta.agentId || null, client: meta.client || null, turnId: meta.turnId || null };
|
|
386
407
|
const decision = evaluatePolicy(command, { approved: meta && meta.approved === true });
|
|
387
408
|
if (decision.denied) {
|
|
409
|
+
// A refusal is the event an operator most needs to see; it used to leave no trace.
|
|
410
|
+
auditTerminal({
|
|
411
|
+
decision: 'deny',
|
|
412
|
+
command,
|
|
413
|
+
reason: decision.reason,
|
|
414
|
+
needs_approval: decision.needs_approval === true,
|
|
415
|
+
...ctx
|
|
416
|
+
});
|
|
388
417
|
// Keep the escalation signal: a consumer (registry/engine) needs to know the
|
|
389
418
|
// difference between "policy says no" and "policy says ask a human".
|
|
390
419
|
return {
|
|
@@ -402,7 +431,7 @@ async function runTerminal({ command, timeout = 45000, cwd = process.cwd() }, me
|
|
|
402
431
|
const sandbox = sandboxPolicy();
|
|
403
432
|
const container = containerPolicy();
|
|
404
433
|
const backend = container.on ? 'docker' : (sandbox.on ? 'constrained-language' : 'none');
|
|
405
|
-
auditTerminal({ decision: 'allow', command, cwd, sandbox: backend });
|
|
434
|
+
auditTerminal({ decision: 'allow', command, cwd, sandbox: backend, ...ctx });
|
|
406
435
|
|
|
407
436
|
const finish = (error, stdout, stderr) => {
|
|
408
437
|
const durationMs = Date.now() - startTime;
|
|
@@ -416,7 +445,7 @@ async function runTerminal({ command, timeout = 45000, cwd = process.cwd() }, me
|
|
|
416
445
|
truncated = true;
|
|
417
446
|
}
|
|
418
447
|
const exitCode = error ? (typeof error.code === 'number' ? error.code : 1) : 0;
|
|
419
|
-
auditTerminal({ decision: 'run', command, exit_code: exitCode, duration_ms: durationMs, sandbox: backend });
|
|
448
|
+
auditTerminal({ decision: 'run', command, exit_code: exitCode, duration_ms: durationMs, sandbox: backend, ...ctx });
|
|
420
449
|
if (error) {
|
|
421
450
|
return {
|
|
422
451
|
command,
|
|
@@ -448,7 +477,8 @@ async function runTerminal({ command, timeout = 45000, cwd = process.cwd() }, me
|
|
|
448
477
|
const { error, stdout, stderr } = await runContainer(command, {
|
|
449
478
|
cwd,
|
|
450
479
|
timeout: parseInt(timeout, 10) || 45000,
|
|
451
|
-
profile: container
|
|
480
|
+
profile: container,
|
|
481
|
+
meta
|
|
452
482
|
});
|
|
453
483
|
return finish(error, stdout, stderr);
|
|
454
484
|
}
|
|
@@ -456,13 +486,14 @@ async function runTerminal({ command, timeout = 45000, cwd = process.cwd() }, me
|
|
|
456
486
|
if (sandbox.on) {
|
|
457
487
|
const { error, stdout, stderr } = await runSandboxed(command, {
|
|
458
488
|
cwd,
|
|
459
|
-
timeout: parseInt(timeout, 10) || 45000
|
|
489
|
+
timeout: parseInt(timeout, 10) || 45000,
|
|
490
|
+
meta
|
|
460
491
|
});
|
|
461
492
|
return finish(error, stdout, stderr);
|
|
462
493
|
}
|
|
463
494
|
|
|
464
495
|
return new Promise((resolve) => {
|
|
465
|
-
exec(
|
|
496
|
+
const child = exec(
|
|
466
497
|
command,
|
|
467
498
|
{
|
|
468
499
|
cwd,
|
|
@@ -473,6 +504,7 @@ async function runTerminal({ command, timeout = 45000, cwd = process.cwd() }, me
|
|
|
473
504
|
},
|
|
474
505
|
(error, stdout, stderr) => resolve(finish(error, stdout, stderr))
|
|
475
506
|
);
|
|
507
|
+
stops.track(meta.agentId, child);
|
|
476
508
|
});
|
|
477
509
|
}
|
|
478
510
|
|
package/src/daemon/liberd.js
CHANGED
|
@@ -10,6 +10,13 @@ const registry = require('./core/registry');
|
|
|
10
10
|
const circuitBreaker = require('./core/circuit-breaker');
|
|
11
11
|
const trace = require('./core/trace');
|
|
12
12
|
const auth = require('./core/auth');
|
|
13
|
+
const shares = require('./core/shares');
|
|
14
|
+
const stops = require('./core/stops');
|
|
15
|
+
|
|
16
|
+
const guard = require('./core/http-guard');
|
|
17
|
+
|
|
18
|
+
// ceiling for the routes that answer without a token
|
|
19
|
+
const unauth = guard.createThrottle();
|
|
13
20
|
const policy = require('./core/policy');
|
|
14
21
|
const journal = require('./core/journal');
|
|
15
22
|
const modelSwitch = require('./core/model-switch');
|
|
@@ -323,10 +330,16 @@ class LiberDaemon {
|
|
|
323
330
|
const endpoint = url.pathname;
|
|
324
331
|
res.setHeader('Content-Type', 'application/json');
|
|
325
332
|
|
|
326
|
-
// CORS
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
333
|
+
// CORS: reflect only loopback origins. The shell is served from
|
|
334
|
+
// http://127.0.0.1:<port> (or localhost), so that is the whole allowlist.
|
|
335
|
+
// A wildcard used to be harmless only because of the bearer token; removing
|
|
336
|
+
// it means a page the user happens to visit cannot even attempt the call.
|
|
337
|
+
const origin = String(req.headers.origin || '');
|
|
338
|
+
const allowed = guard.allowOrigin(req.headers.origin);
|
|
339
|
+
if (allowed) {
|
|
340
|
+
res.setHeader('Access-Control-Allow-Origin', allowed);
|
|
341
|
+
res.setHeader('Vary', 'Origin');
|
|
342
|
+
}
|
|
330
343
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
|
|
331
344
|
res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type');
|
|
332
345
|
if (req.method === 'OPTIONS') {
|
|
@@ -335,6 +348,19 @@ class LiberDaemon {
|
|
|
335
348
|
return;
|
|
336
349
|
}
|
|
337
350
|
|
|
351
|
+
// The unauthenticated routes need a ceiling of their own: without a token there
|
|
352
|
+
// is nothing else to throttle against. 240/min per address is far above what a
|
|
353
|
+
// shell does and far below what a probe wants.
|
|
354
|
+
if (endpoint === '/healthz' || endpoint === '/whoami' || endpoint.startsWith('/shared/')) {
|
|
355
|
+
const gate = unauth.check(req.socket.remoteAddress || 'local');
|
|
356
|
+
if (!gate.allowed) {
|
|
357
|
+
res.statusCode = 429;
|
|
358
|
+
res.setHeader('retry-after', Math.ceil(gate.retryAfterMs / 1000));
|
|
359
|
+
res.end(JSON.stringify({ success: false, error: 'Too many requests without a token.' }));
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
338
364
|
// Unauthenticated liveness probe only — it exposes no state.
|
|
339
365
|
if (endpoint === '/healthz') {
|
|
340
366
|
res.end(JSON.stringify({ ok: true }));
|
|
@@ -359,6 +385,25 @@ class LiberDaemon {
|
|
|
359
385
|
return;
|
|
360
386
|
}
|
|
361
387
|
|
|
388
|
+
// A share link is a capability URL: the 128-bit id is the credential, so this
|
|
389
|
+
// route answers without a bearer token — and only ever returns one session,
|
|
390
|
+
// sanitized, with no tool output and no path from this machine.
|
|
391
|
+
if (req.method === 'GET' && endpoint.startsWith('/shared/')) {
|
|
392
|
+
const id = endpoint.slice('/shared/'.length);
|
|
393
|
+
// A revoked link must stop working in the browser too, not only on the wire:
|
|
394
|
+
// without this a cached copy keeps answering after DELETE /sessions/share.
|
|
395
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
396
|
+
const record = shares.resolve(id);
|
|
397
|
+
if (!record) {
|
|
398
|
+
res.statusCode = 404;
|
|
399
|
+
res.end(JSON.stringify({ success: false, error: 'This link is unknown, expired or revoked.' }));
|
|
400
|
+
return;
|
|
401
|
+
}
|
|
402
|
+
const messages = memory.get(record.key) || [];
|
|
403
|
+
res.end(JSON.stringify(shares.publicPayload(record, messages)));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
|
|
362
407
|
// Every request is resolved to a client, then checked for scope and rate.
|
|
363
408
|
const requestStart = Date.now();
|
|
364
409
|
const client = auth.verify(auth.extract(req));
|
|
@@ -497,9 +542,57 @@ class LiberDaemon {
|
|
|
497
542
|
let body = '';
|
|
498
543
|
for await (const chunk of req) body += chunk;
|
|
499
544
|
const { verdict } = JSON.parse(body || '{}');
|
|
500
|
-
const
|
|
545
|
+
const approved = verdict === 'approve' || verdict === true;
|
|
546
|
+
auth.audit({ client: client.id, endpoint, decision: approved ? 'approve' : 'deny', reason: 'hitl verdict' });
|
|
547
|
+
const handled = this.resolveHitl(approved);
|
|
501
548
|
res.end(JSON.stringify({ success: true, handled }));
|
|
502
549
|
}
|
|
550
|
+
else if (endpoint === '/stop') {
|
|
551
|
+
// The kill switch. It marks the agent so the engine stops before its next
|
|
552
|
+
// model call, and kills any command already running for it.
|
|
553
|
+
let body = '';
|
|
554
|
+
for await (const chunk of req) body += chunk;
|
|
555
|
+
const { agentId } = JSON.parse(body || '{}');
|
|
556
|
+
if (!agentId) {
|
|
557
|
+
res.statusCode = 400;
|
|
558
|
+
res.end(JSON.stringify({ success: false, error: 'Missing agentId.' }));
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
const out = stops.request(agentId);
|
|
562
|
+
logger.warn(agentId, 'STOP_REQUESTED', { client: client.id, killed: out.killed });
|
|
563
|
+
auth.audit({ client: client.id, endpoint, decision: 'allow', reason: 'stop ' + agentId, killed: out.killed });
|
|
564
|
+
res.end(JSON.stringify({ success: true, agentId: out.agentId, killed: out.killed }));
|
|
565
|
+
}
|
|
566
|
+
else if (endpoint === '/sessions/share') {
|
|
567
|
+
if (req.method === 'GET') {
|
|
568
|
+
res.end(JSON.stringify({ success: true, shares: shares.list() }));
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
if (req.method === 'DELETE') {
|
|
572
|
+
const id = url.searchParams.get('id');
|
|
573
|
+
const ok = shares.revoke(id);
|
|
574
|
+
auth.audit({ client: client.id, endpoint, decision: ok ? 'allow' : 'deny', reason: 'revoke ' + (id || '(none)') });
|
|
575
|
+
res.statusCode = ok ? 200 : 404;
|
|
576
|
+
res.end(JSON.stringify({ success: ok, revoked: ok ? id : null }));
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
let body = '';
|
|
580
|
+
for await (const chunk of req) body += chunk;
|
|
581
|
+
const { key, title, ttl_hours } = JSON.parse(body || '{}');
|
|
582
|
+
if (!key) {
|
|
583
|
+
res.statusCode = 400;
|
|
584
|
+
res.end(JSON.stringify({ success: false, error: 'Missing session key.' }));
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (!(memory.get(key) || []).length) {
|
|
588
|
+
res.statusCode = 404;
|
|
589
|
+
res.end(JSON.stringify({ success: false, error: 'No such session: ' + key }));
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
const record = shares.create({ key, title, ttlHours: ttl_hours });
|
|
593
|
+
auth.audit({ client: client.id, endpoint, decision: 'allow', reason: 'share ' + record.id });
|
|
594
|
+
res.end(JSON.stringify({ success: true, share: record, path: '/shared/' + record.id }));
|
|
595
|
+
}
|
|
503
596
|
else if (endpoint === '/sessions/history') {
|
|
504
597
|
// The window keeps rendered HTML in memory only, so after a restart a
|
|
505
598
|
// session looked empty while its messages were still on disk. This
|
|
@@ -1283,7 +1376,8 @@ class LiberDaemon {
|
|
|
1283
1376
|
onEvent: sendEvent,
|
|
1284
1377
|
waitForHitl: (aid, toolName, args) => this.waitForHitl(aid, `${toolName}: ${JSON.stringify(args)}`),
|
|
1285
1378
|
maxLoops: this.maxLoops,
|
|
1286
|
-
shouldStop
|
|
1379
|
+
shouldStop,
|
|
1380
|
+
client: client.id
|
|
1287
1381
|
}));
|
|
1288
1382
|
if (this.traceEnabled) {
|
|
1289
1383
|
trace.appendTurn({ agentId, userMessage: message, result, model: agent.config.engine?.model });
|
|
@@ -1307,6 +1401,7 @@ class LiberDaemon {
|
|
|
1307
1401
|
memoryKey,
|
|
1308
1402
|
agentConfig: agent.config,
|
|
1309
1403
|
userMessage: message,
|
|
1404
|
+
client: client.id,
|
|
1310
1405
|
memory,
|
|
1311
1406
|
llmManager,
|
|
1312
1407
|
// This endpoint answers with one JSON body, so there is no channel to
|
|
@@ -22,6 +22,11 @@ class LLMManager {
|
|
|
22
22
|
* @param {Object} config - Bagian 'engine' dari liber.yaml
|
|
23
23
|
*/
|
|
24
24
|
initialize(config) {
|
|
25
|
+
// liber.yaml may leave provider/endpoint null and expect the installed key
|
|
26
|
+
// to supply them; resolve before anything reads config.endpoint, or the
|
|
27
|
+
// adapter ends up requesting null/chat/completions.
|
|
28
|
+
const { engineDefaults } = require('../core/providers');
|
|
29
|
+
config = engineDefaults(config);
|
|
25
30
|
this.engineConfig = { ...config };
|
|
26
31
|
const ProviderClass = this.adapters[config.provider];
|
|
27
32
|
if (!ProviderClass) {
|