elelem-server 0.1.0 → 0.2.1
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.
- checksums.yaml +4 -4
- data/.github/workflows/release.yml +61 -0
- data/.release-please-manifest.json +3 -0
- data/.rubocop.yml +62 -0
- data/CHANGELOG.md +20 -0
- data/exe/elelem-server +8 -3
- data/lib/elelem/server/index.html +176 -0
- data/lib/elelem/server/tab.rb +55 -0
- data/lib/elelem/server/version.rb +1 -1
- data/lib/elelem/server/web_terminal.rb +54 -12
- data/lib/elelem/server.rb +152 -173
- data/release-please-config.json +12 -0
- data/spec/elelem/server/guard_spec.rb +69 -0
- data/spec/elelem/server/index_spec.rb +15 -0
- data/spec/elelem/server/serve_spec.rb +9 -0
- data/spec/elelem/server/tab_spec.rb +45 -0
- data/spec/elelem/server/turn_spec.rb +79 -0
- data/spec/elelem/server/web_terminal_spec.rb +53 -3
- metadata +28 -9
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 8ae17fb98f2f26fa6e0503d295ece4362a3938e4a44a80561111b6dced5a1deb
|
|
4
|
+
data.tar.gz: 4e5e7f0f76f667581ad852dc498bb9f5efa2c56540bfa2af50a3c25fe4f6503a
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 67b211c138c11bc595ce0e6ca7ab30dc7e47e0cf4350eddc0a835c1250e0f9a58607b7e7b2f8b2d5c00469c3bccdd75b212e4562f7e3731d467d1316116247c5
|
|
7
|
+
data.tar.gz: c6aaf041c17653e18900731c2ff78876c72763973e5ab5b45c08901c9de43669f2f54ad53cf70a9268e4ca5cdb650edba3772053df867cce2423697d636d7839
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
name: Release
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
workflow_dispatch:
|
|
7
|
+
|
|
8
|
+
permissions: {}
|
|
9
|
+
|
|
10
|
+
concurrency:
|
|
11
|
+
group: release
|
|
12
|
+
cancel-in-progress: false
|
|
13
|
+
|
|
14
|
+
jobs:
|
|
15
|
+
test:
|
|
16
|
+
runs-on: ubuntu-latest
|
|
17
|
+
permissions:
|
|
18
|
+
contents: read
|
|
19
|
+
steps:
|
|
20
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
21
|
+
with:
|
|
22
|
+
persist-credentials: false
|
|
23
|
+
- uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0
|
|
24
|
+
with:
|
|
25
|
+
ruby-version: "4.0"
|
|
26
|
+
bundler-cache: true
|
|
27
|
+
- run: bin/test
|
|
28
|
+
- run: bin/lint
|
|
29
|
+
|
|
30
|
+
release-please:
|
|
31
|
+
needs: test
|
|
32
|
+
runs-on: ubuntu-latest
|
|
33
|
+
permissions:
|
|
34
|
+
contents: write
|
|
35
|
+
issues: write
|
|
36
|
+
pull-requests: write
|
|
37
|
+
outputs:
|
|
38
|
+
release_created: ${{ steps.release.outputs.release_created }}
|
|
39
|
+
steps:
|
|
40
|
+
- id: release
|
|
41
|
+
uses: googleapis/release-please-action@45996ed1f6d02564a971a2fa1b5860e934307cf7 # v5.0.0
|
|
42
|
+
with:
|
|
43
|
+
config-file: release-please-config.json
|
|
44
|
+
manifest-file: .release-please-manifest.json
|
|
45
|
+
|
|
46
|
+
publish:
|
|
47
|
+
needs: release-please
|
|
48
|
+
if: needs.release-please.outputs.release_created == 'true'
|
|
49
|
+
runs-on: ubuntu-latest
|
|
50
|
+
permissions:
|
|
51
|
+
contents: write
|
|
52
|
+
id-token: write
|
|
53
|
+
steps:
|
|
54
|
+
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
|
|
55
|
+
with:
|
|
56
|
+
persist-credentials: false
|
|
57
|
+
- uses: ruby/setup-ruby@3ff19f5e2baf30647122352b96108b1fbe250c64 # v1.299.0
|
|
58
|
+
with:
|
|
59
|
+
ruby-version: "4.0"
|
|
60
|
+
bundler-cache: true
|
|
61
|
+
- uses: rubygems/release-gem@7f9650160c1a4e7989fdc9855807bdbd421d8b6b # v1.4.1
|
data/.rubocop.yml
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
AllCops:
|
|
2
|
+
NewCops: enable
|
|
3
|
+
TargetRubyVersion: 4.0
|
|
4
|
+
SuggestExtensions: false
|
|
5
|
+
Exclude:
|
|
6
|
+
- "pkg/**/*"
|
|
7
|
+
- "bin/**/*"
|
|
8
|
+
- "vendor/**/*"
|
|
9
|
+
- "fixtures/**/*"
|
|
10
|
+
|
|
11
|
+
Metrics:
|
|
12
|
+
Enabled: false
|
|
13
|
+
|
|
14
|
+
Naming/PredicateMethod:
|
|
15
|
+
AllowedMethods:
|
|
16
|
+
- call
|
|
17
|
+
- run
|
|
18
|
+
- summarize
|
|
19
|
+
- prompt
|
|
20
|
+
|
|
21
|
+
Naming/MethodParameterName:
|
|
22
|
+
AllowedNames:
|
|
23
|
+
- me
|
|
24
|
+
- id
|
|
25
|
+
- as
|
|
26
|
+
- io
|
|
27
|
+
|
|
28
|
+
Style/GlobalVars:
|
|
29
|
+
Exclude:
|
|
30
|
+
- "spec/**/*"
|
|
31
|
+
|
|
32
|
+
Lint/EmptyBlock:
|
|
33
|
+
Exclude:
|
|
34
|
+
- "spec/**/*"
|
|
35
|
+
|
|
36
|
+
Lint/UselessDefaultValueArgument:
|
|
37
|
+
Enabled: false
|
|
38
|
+
|
|
39
|
+
Lint/RedundantRequireStatement:
|
|
40
|
+
Enabled: false
|
|
41
|
+
|
|
42
|
+
Lint/UnusedBlockArgument:
|
|
43
|
+
IgnoreEmptyBlocks: true
|
|
44
|
+
AllowUnusedKeywordArguments: true
|
|
45
|
+
|
|
46
|
+
Style/Documentation:
|
|
47
|
+
Enabled: false
|
|
48
|
+
|
|
49
|
+
Style/FormatStringToken:
|
|
50
|
+
Enabled: false
|
|
51
|
+
|
|
52
|
+
Style/FrozenStringLiteralComment:
|
|
53
|
+
Enabled: true
|
|
54
|
+
|
|
55
|
+
Style/StringLiterals:
|
|
56
|
+
EnforcedStyle: double_quotes
|
|
57
|
+
|
|
58
|
+
Style/StringLiteralsInInterpolation:
|
|
59
|
+
EnforcedStyle: double_quotes
|
|
60
|
+
|
|
61
|
+
Layout/LineLength:
|
|
62
|
+
Max: 120
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## [0.2.1](https://github.com/elelem-sh/server/compare/v0.2.0...v0.2.1) (2026-11-15)
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
### Bug Fixes
|
|
7
|
+
|
|
8
|
+
* build a Session for Config.apply and Agent#turn ([4ab731c](https://github.com/elelem-sh/server/commit/4ab731c02948dfd0c49d1750e680fea0e9b7de75))
|
|
9
|
+
* correlate prompt/answer by id, close /answer pre-approval bug ([ddae723](https://github.com/elelem-sh/server/commit/ddae723c67ef2a44de4a50869f7cbaeab13c9b66))
|
|
10
|
+
* each browser tab gets its own session, and slash commands run as commands ([elelem-sh/elelem.sh#31](https://github.com/elelem-sh/elelem.sh/issues/31)) ([31a6d96](https://github.com/elelem-sh/server/commit/31a6d965704d7faceb23d143f37dbc58dfae4609))
|
|
11
|
+
* require elelem ~> 0.12, the floor builtins/skills actually need ([af2fdac](https://github.com/elelem-sh/server/commit/af2fdac0462ed4774fbb136361502f60555e05bc))
|
|
12
|
+
* server prompt ids are unique across tabs, a claimed tab cannot be evicted, and a closed tab keeps its place for a minute ([aedcf3c](https://github.com/elelem-sh/server/commit/aedcf3c4565ef18e2c48914abc9dc7c5ece08a72))
|
|
13
|
+
* server tabs build without blocking others, free idle places, survive a reload mid-prompt, and keep the token out of the address bar ([163433d](https://github.com/elelem-sh/server/commit/163433d85a5acd3eba7c891bedabc034f455b144))
|
|
14
|
+
* the server answers a bound IPv6 address, and ends a turn before taking the next ([f453fd2](https://github.com/elelem-sh/server/commit/f453fd293985781446d167ea110eb3249ce56cda))
|
|
15
|
+
* the server defaults to the demo provider, since elelem 0.13.1 removed stub ([e7867bc](https://github.com/elelem-sh/server/commit/e7867bc63e9f13a18cadaae5f51e9c8fc945533e))
|
|
16
|
+
* the server listens on 127.0.0.1 and refuses other hosts, origins and non-JSON posts ([elelem-sh/elelem.sh#1](https://github.com/elelem-sh/elelem.sh/issues/1)) ([54197c4](https://github.com/elelem-sh/server/commit/54197c442ae08cc62c3e5e82d54e2ea4efce892b))
|
|
17
|
+
* the server requires the token it prints at start ([elelem-sh/elelem.sh#1](https://github.com/elelem-sh/elelem.sh/issues/1)) ([c76e69c](https://github.com/elelem-sh/server/commit/c76e69c3d8e96e671fe9c8c577863bd0fc41668a))
|
|
18
|
+
* the server runs one turn at a time and the page waits for it ([elelem-sh/elelem.sh#31](https://github.com/elelem-sh/elelem.sh/issues/31)) ([a7fc5d6](https://github.com/elelem-sh/server/commit/a7fc5d6a48113bb53759ef24bc292cd9ce9caafe))
|
|
19
|
+
* the startup line brackets an IPv6 address so the URL works ([641a803](https://github.com/elelem-sh/server/commit/641a8034fdc1d0763150fa01ed76d7ae52a0376d))
|
|
20
|
+
* the web page renders line breaks, code and bold ([elelem-sh/elelem.sh#30](https://github.com/elelem-sh/elelem.sh/issues/30)) ([d29df97](https://github.com/elelem-sh/server/commit/d29df971dcbc0eb8d8c5b32d7a9b8c2816deffbd))
|
data/exe/elelem-server
CHANGED
|
@@ -4,14 +4,19 @@
|
|
|
4
4
|
require "elelem/server"
|
|
5
5
|
require "optparse"
|
|
6
6
|
|
|
7
|
-
provider = ENV.fetch("ELELEM_PROVIDER", "
|
|
7
|
+
provider = ENV.fetch("ELELEM_PROVIDER", "demo")
|
|
8
8
|
port = 4567
|
|
9
|
+
address = "127.0.0.1"
|
|
9
10
|
|
|
10
11
|
OptionParser.new do |o|
|
|
11
12
|
o.banner = "Usage: elelem-server [options]"
|
|
12
13
|
o.on("-p", "--provider NAME", "Provider to use (default: #{provider})") { |v| provider = v }
|
|
13
14
|
o.on("--port PORT", Integer, "Port to listen on (default: #{port})") { |v| port = v }
|
|
14
|
-
o.on("
|
|
15
|
+
o.on("--bind ADDR", "Address to listen on (default: #{address})") { |v| address = v }
|
|
16
|
+
o.on("-h", "--help", "Show this help") do
|
|
17
|
+
puts o
|
|
18
|
+
exit
|
|
19
|
+
end
|
|
15
20
|
end.parse!(ARGV)
|
|
16
21
|
|
|
17
|
-
Elelem::Server.serve(provider: provider, port: port)
|
|
22
|
+
Elelem::Server.serve(provider: provider, port: port, address: address)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
|
6
|
+
<title>elelem</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: dark; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
body {
|
|
11
|
+
margin: 0; height: 100dvh; display: flex; flex-direction: column;
|
|
12
|
+
background: #16161a; color: #e6e6e6;
|
|
13
|
+
font: 16px/1.55 ui-sans-serif, system-ui, -apple-system, sans-serif;
|
|
14
|
+
}
|
|
15
|
+
#log { flex: 1; overflow-y: auto; padding: 1rem; -webkit-overflow-scrolling: touch; }
|
|
16
|
+
.msg { max-width: 46rem; margin: 0 auto 1rem; padding: .65rem .85rem; border-radius: .6rem; }
|
|
17
|
+
.user { background: #2d3a4f; }
|
|
18
|
+
.assistant { background: #22222a; }
|
|
19
|
+
.thinking { opacity: .55; font-style: italic; white-space: pre-wrap; }
|
|
20
|
+
.msg pre {
|
|
21
|
+
background: #0d0d10; padding: .7rem; border-radius: .4rem;
|
|
22
|
+
overflow-x: auto; font-size: .875rem;
|
|
23
|
+
}
|
|
24
|
+
.msg code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
25
|
+
.prompt { max-width: 46rem; margin: 0 auto 1rem; padding: .85rem; border-radius: .6rem; background: #4a3a1e; }
|
|
26
|
+
.prompt button {
|
|
27
|
+
font-size: 1rem; padding: .7rem 1.4rem; margin: .5rem .5rem 0 0;
|
|
28
|
+
border: 0; border-radius: .5rem; color: #fff; touch-action: manipulation;
|
|
29
|
+
}
|
|
30
|
+
.allow { background: #2f7d4f; }
|
|
31
|
+
.deny { background: #8c3535; }
|
|
32
|
+
form { display: flex; gap: .5rem; padding: .75rem; padding-bottom: max(.75rem, env(safe-area-inset-bottom));
|
|
33
|
+
border-top: 1px solid #2c2c33; background: #1b1b20; }
|
|
34
|
+
textarea {
|
|
35
|
+
flex: 1; resize: none; font: inherit; padding: .6rem; border-radius: .5rem;
|
|
36
|
+
border: 1px solid #35353d; background: #101014; color: inherit;
|
|
37
|
+
}
|
|
38
|
+
button.send { font-size: 1rem; padding: .6rem 1.2rem; border: 0; border-radius: .5rem; background: #3b6ea5; color: #fff; }
|
|
39
|
+
button.send:disabled { opacity: .5; }
|
|
40
|
+
</style>
|
|
41
|
+
</head>
|
|
42
|
+
<body>
|
|
43
|
+
<div id="log"></div>
|
|
44
|
+
<form id="composer">
|
|
45
|
+
<textarea id="input" rows="1" placeholder="Message elelem" autocapitalize="sentences"></textarea>
|
|
46
|
+
<button class="send" type="submit">Send</button>
|
|
47
|
+
</form>
|
|
48
|
+
<script>
|
|
49
|
+
const log = document.getElementById("log");
|
|
50
|
+
const input = document.getElementById("input");
|
|
51
|
+
let current = null;
|
|
52
|
+
|
|
53
|
+
const atBottom = () => log.scrollHeight - log.scrollTop - log.clientHeight < 80;
|
|
54
|
+
const scroll = (was) => { if (was) log.scrollTop = log.scrollHeight; };
|
|
55
|
+
|
|
56
|
+
// The token comes in the URL elelem-server prints. Keep it for reloads in this tab.
|
|
57
|
+
const token = new URLSearchParams(location.search).get("token") || sessionStorage.getItem("elelem-token") || "";
|
|
58
|
+
sessionStorage.setItem("elelem-token", token);
|
|
59
|
+
// Keep the token out of the address bar and history.
|
|
60
|
+
history.replaceState(null, "", "/");
|
|
61
|
+
// Each tab has its own conversation. A reload keeps it; a new tab starts fresh.
|
|
62
|
+
const random = () => (crypto.randomUUID ? crypto.randomUUID() : String(Math.random()).slice(2));
|
|
63
|
+
const tab = sessionStorage.getItem("elelem-tab") || random();
|
|
64
|
+
sessionStorage.setItem("elelem-tab", tab);
|
|
65
|
+
|
|
66
|
+
function post(path, body) {
|
|
67
|
+
const headers = { "Content-Type": "application/json", "X-Elelem-Token": token, "X-Elelem-Tab": tab };
|
|
68
|
+
return fetch(path, { method: "POST", headers: headers, body: JSON.stringify(body) });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function escape(text) {
|
|
72
|
+
return text.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function render(text) {
|
|
76
|
+
return escape(text)
|
|
77
|
+
.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => "<pre><code>" + code + "</code></pre>")
|
|
78
|
+
.replace(/`([^`\n]+)`/g, "<code>$1</code>")
|
|
79
|
+
.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>")
|
|
80
|
+
.replace(/\n/g, "<br>");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function bubble(cls) {
|
|
84
|
+
const was = atBottom();
|
|
85
|
+
const el = document.createElement("div");
|
|
86
|
+
el.className = "msg " + cls;
|
|
87
|
+
log.appendChild(el);
|
|
88
|
+
scroll(was);
|
|
89
|
+
return el;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function append(cls, text) {
|
|
93
|
+
if (!current || current.dataset.cls !== cls) {
|
|
94
|
+
current = bubble(cls);
|
|
95
|
+
current.dataset.cls = cls;
|
|
96
|
+
current.dataset.raw = "";
|
|
97
|
+
}
|
|
98
|
+
const was = atBottom();
|
|
99
|
+
current.dataset.raw += text;
|
|
100
|
+
current.innerHTML = render(current.dataset.raw);
|
|
101
|
+
scroll(was);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function askPermission(id, text) {
|
|
105
|
+
// A reconnect replays open prompts; show each one once.
|
|
106
|
+
if (document.querySelector('.prompt[data-id="' + id + '"]')) return;
|
|
107
|
+
current = null;
|
|
108
|
+
const was = atBottom();
|
|
109
|
+
const el = document.createElement("div");
|
|
110
|
+
el.className = "prompt";
|
|
111
|
+
el.dataset.id = id;
|
|
112
|
+
el.innerHTML = "<div>" + escape(text) + "</div>";
|
|
113
|
+
const respond = (answer) => {
|
|
114
|
+
el.querySelectorAll("button").forEach((b) => b.remove());
|
|
115
|
+
el.insertAdjacentHTML("beforeend", "<div><em>" + answer + "</em></div>");
|
|
116
|
+
post("/answer", { id: el.dataset.id, text: answer });
|
|
117
|
+
};
|
|
118
|
+
const allow = document.createElement("button");
|
|
119
|
+
allow.className = "allow";
|
|
120
|
+
allow.textContent = "Allow";
|
|
121
|
+
allow.onclick = () => respond("y");
|
|
122
|
+
const deny = document.createElement("button");
|
|
123
|
+
deny.className = "deny";
|
|
124
|
+
deny.textContent = "Deny";
|
|
125
|
+
deny.onclick = () => respond("n");
|
|
126
|
+
el.append(allow, deny);
|
|
127
|
+
log.appendChild(el);
|
|
128
|
+
scroll(was);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
new EventSource("/events?token=" + encodeURIComponent(token) + "&tab=" + encodeURIComponent(tab)).onmessage = (e) => {
|
|
132
|
+
const event = JSON.parse(e.data);
|
|
133
|
+
if (event.type === "content") append("assistant", event.text);
|
|
134
|
+
else if (event.type === "thinking") append("thinking", event.text);
|
|
135
|
+
else if (event.type === "prompt") askPermission(event.id, event.text);
|
|
136
|
+
else if (event.type === "done") { current = null; idle(); }
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const send = document.querySelector("#composer .send");
|
|
140
|
+
const idle = () => { send.disabled = false; };
|
|
141
|
+
|
|
142
|
+
document.getElementById("composer").onsubmit = async (e) => {
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
const text = input.value.trim();
|
|
145
|
+
if (!text || send.disabled) return;
|
|
146
|
+
send.disabled = true;
|
|
147
|
+
append("user", text);
|
|
148
|
+
current = null;
|
|
149
|
+
input.value = "";
|
|
150
|
+
try {
|
|
151
|
+
const res = await post("/message", { text: text });
|
|
152
|
+
if (res.ok) return;
|
|
153
|
+
append("thinking", res.status === 409 ? "Still working on the last message. Try again when it is done." : "Not sent: " + res.status);
|
|
154
|
+
current = null;
|
|
155
|
+
input.value = text;
|
|
156
|
+
idle();
|
|
157
|
+
} catch (_err) {
|
|
158
|
+
input.value = text;
|
|
159
|
+
idle();
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
input.addEventListener("keydown", (e) => {
|
|
164
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
165
|
+
e.preventDefault();
|
|
166
|
+
document.getElementById("composer").requestSubmit();
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
input.addEventListener("input", () => {
|
|
171
|
+
input.style.height = "auto";
|
|
172
|
+
input.style.height = Math.min(input.scrollHeight, 160) + "px";
|
|
173
|
+
});
|
|
174
|
+
</script>
|
|
175
|
+
</body>
|
|
176
|
+
</html>
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Elelem
|
|
4
|
+
class Server
|
|
5
|
+
# One browser tab: its session, and whether a turn is running in it.
|
|
6
|
+
class Tab
|
|
7
|
+
attr_reader :session
|
|
8
|
+
|
|
9
|
+
def initialize(session)
|
|
10
|
+
@session = session
|
|
11
|
+
@lock = Mutex.new
|
|
12
|
+
@busy = false
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def terminal
|
|
16
|
+
@session.output
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def claim
|
|
20
|
+
@lock.synchronize { @busy ? false : (@busy = true) }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Busy tabs, and tabs with a page open or seen in the last minute (an
|
|
24
|
+
# EventSource that is reconnecting), keep their place.
|
|
25
|
+
def idle?(now = Process.clock_gettime(Process::CLOCK_MONOTONIC), grace: 60)
|
|
26
|
+
!@lock.synchronize { @busy } && terminal.subscribers.zero? && now - terminal.last_seen > grace
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# Runs a message or a /command, then tells the page the turn is done.
|
|
30
|
+
def run(text)
|
|
31
|
+
text.start_with?("/") ? command(text) : @session.turn(text)
|
|
32
|
+
rescue StandardError, ScriptError => e
|
|
33
|
+
terminal.say("Error: #{e.message}")
|
|
34
|
+
ensure
|
|
35
|
+
# done first, so the page cannot start a turn before this one says it ended.
|
|
36
|
+
terminal.done
|
|
37
|
+
@lock.synchronize { @busy = false }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
private
|
|
41
|
+
|
|
42
|
+
def command(line)
|
|
43
|
+
name, args = line.delete_prefix("/").split(" ", 2)
|
|
44
|
+
# /exit would stop the server, and /reload would reload plugins under
|
|
45
|
+
# every other tab. Restart elelem-server instead.
|
|
46
|
+
if %w[exit reload].include?(name)
|
|
47
|
+
return terminal.say("/#{name} is not available in the browser. Restart elelem-server instead.")
|
|
48
|
+
end
|
|
49
|
+
return if @session.commands.run(name, args)
|
|
50
|
+
|
|
51
|
+
terminal.say(@session.commands.names.join(" "))
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -3,27 +3,60 @@
|
|
|
3
3
|
module Elelem
|
|
4
4
|
class Server
|
|
5
5
|
class WebTerminal
|
|
6
|
+
# Prompt ids are unique across every tab's terminal, so a page never
|
|
7
|
+
# mistakes a new prompt for one it already answered.
|
|
8
|
+
@next_id = 0
|
|
9
|
+
@ids = Mutex.new
|
|
10
|
+
|
|
11
|
+
def self.next_prompt_id
|
|
12
|
+
@ids.synchronize { @next_id += 1 }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :last_seen
|
|
16
|
+
|
|
6
17
|
def initialize
|
|
7
18
|
@subscribers = []
|
|
8
19
|
@mutex = Mutex.new
|
|
9
|
-
@
|
|
20
|
+
@prompts = {}
|
|
21
|
+
@open = {}
|
|
22
|
+
@last_seen = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
10
23
|
end
|
|
11
24
|
|
|
25
|
+
# A page that subscribes again, after a reload, gets the prompts still
|
|
26
|
+
# waiting for an answer, so the turn does not hang on one it cannot see.
|
|
12
27
|
def subscribe
|
|
13
|
-
Queue.new.tap
|
|
28
|
+
Queue.new.tap do |queue|
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
@subscribers << queue
|
|
31
|
+
@open.each_value { |event| queue << event }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def subscribers
|
|
37
|
+
@mutex.synchronize { @subscribers.size }
|
|
14
38
|
end
|
|
15
39
|
|
|
16
40
|
def unsubscribe(queue)
|
|
17
|
-
@mutex.synchronize
|
|
41
|
+
@mutex.synchronize do
|
|
42
|
+
@subscribers.delete(queue)
|
|
43
|
+
@last_seen = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
44
|
+
end
|
|
18
45
|
end
|
|
19
46
|
|
|
20
47
|
def interactive?
|
|
21
48
|
true
|
|
22
49
|
end
|
|
23
50
|
|
|
24
|
-
def ask(prompt)
|
|
25
|
-
|
|
26
|
-
@
|
|
51
|
+
def ask(prompt, **)
|
|
52
|
+
queue = Queue.new
|
|
53
|
+
event = @mutex.synchronize do
|
|
54
|
+
id = self.class.next_prompt_id
|
|
55
|
+
@prompts[id] = queue
|
|
56
|
+
@open[id] = { type: "prompt", text: plain(prompt), id: id }
|
|
57
|
+
end
|
|
58
|
+
broadcast(event)
|
|
59
|
+
queue.pop
|
|
27
60
|
end
|
|
28
61
|
|
|
29
62
|
def thinking(text)
|
|
@@ -39,8 +72,7 @@ module Elelem
|
|
|
39
72
|
say "#{state} #{name}(#{args})"
|
|
40
73
|
end
|
|
41
74
|
|
|
42
|
-
def waiting
|
|
43
|
-
end
|
|
75
|
+
def waiting; end
|
|
44
76
|
|
|
45
77
|
def print(text)
|
|
46
78
|
emit("content", text)
|
|
@@ -55,8 +87,14 @@ module Elelem
|
|
|
55
87
|
say(fallback || path)
|
|
56
88
|
end
|
|
57
89
|
|
|
58
|
-
def answer(text)
|
|
59
|
-
@
|
|
90
|
+
def answer(id, text)
|
|
91
|
+
@mutex.synchronize do
|
|
92
|
+
if @prompts.key?(id)
|
|
93
|
+
@prompts[id] << text
|
|
94
|
+
@prompts.delete(id)
|
|
95
|
+
@open.delete(id)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
60
98
|
end
|
|
61
99
|
|
|
62
100
|
def done
|
|
@@ -66,10 +104,14 @@ module Elelem
|
|
|
66
104
|
private
|
|
67
105
|
|
|
68
106
|
def emit(type, text)
|
|
69
|
-
plain = text
|
|
107
|
+
plain = plain(text)
|
|
70
108
|
return if plain.strip.empty?
|
|
71
109
|
|
|
72
|
-
broadcast(type: type, text: plain)
|
|
110
|
+
broadcast({ type: type, text: plain })
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def plain(text)
|
|
114
|
+
text.to_s.gsub(/\e\[[0-9;]*[a-zA-Z]/, "")
|
|
73
115
|
end
|
|
74
116
|
|
|
75
117
|
def broadcast(event)
|
data/lib/elelem/server.rb
CHANGED
|
@@ -2,26 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
require "elelem"
|
|
4
4
|
require "json"
|
|
5
|
+
require "openssl"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "uri"
|
|
5
8
|
require "webrick"
|
|
6
9
|
|
|
7
10
|
require_relative "server/version"
|
|
8
11
|
require_relative "server/web_terminal"
|
|
12
|
+
require_relative "server/tab"
|
|
9
13
|
|
|
10
14
|
module Elelem
|
|
11
15
|
class Server
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
INDEX = File.read(File.expand_path("server/index.html", __dir__))
|
|
17
|
+
LOCAL_HOSTS = %w[localhost 127.0.0.1 [::1]].freeze
|
|
18
|
+
|
|
19
|
+
MAX_TABS = 16
|
|
20
|
+
TAB_ID = /\A[\w-]{1,64}\z/
|
|
21
|
+
|
|
22
|
+
# Each browser tab gets its own session, built on its first request.
|
|
23
|
+
def self.serve(provider: "demo", port: 4567, address: "127.0.0.1")
|
|
24
|
+
Config.build_provider(provider) # fail at start, not on the first page load
|
|
25
|
+
new(-> { session_for(provider) }, port: port, address: address).start
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.session_for(provider)
|
|
29
|
+
terminal = WebTerminal.new
|
|
30
|
+
Config.build_session(provider, input: terminal, output: terminal)
|
|
18
31
|
end
|
|
19
32
|
|
|
20
|
-
|
|
21
|
-
|
|
33
|
+
attr_reader :token
|
|
34
|
+
|
|
35
|
+
def initialize(build_session, port: 4567, address: "127.0.0.1", token: SecureRandom.hex(16))
|
|
36
|
+
@build_session = build_session
|
|
22
37
|
@port = port
|
|
23
38
|
@address = address
|
|
24
|
-
@
|
|
39
|
+
@token = token
|
|
40
|
+
@tabs = {}
|
|
41
|
+
@tabs_lock = Mutex.new
|
|
42
|
+
@build_lock = Mutex.new
|
|
25
43
|
end
|
|
26
44
|
|
|
27
45
|
def start
|
|
@@ -31,202 +49,163 @@ module Elelem
|
|
|
31
49
|
Logger: WEBrick::Log.new(File::NULL),
|
|
32
50
|
AccessLog: []
|
|
33
51
|
)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
server.mount_proc("/answer", &method(:answer))
|
|
52
|
+
%i[index events message answer].each do |name|
|
|
53
|
+
server.mount_proc(name == :index ? "/" : "/#{name}", &guarded(name, token: name != :index))
|
|
54
|
+
end
|
|
38
55
|
|
|
39
56
|
trap("INT") { server.shutdown }
|
|
40
|
-
$stdout.puts "elelem v#{VERSION} listening on http://#{
|
|
57
|
+
$stdout.puts "elelem v#{VERSION} listening on http://#{url_host}:#{@port}/?token=#{@token}"
|
|
58
|
+
$stdout.flush # the URL carries the token; it must show even when stdout is a file
|
|
41
59
|
server.start
|
|
42
60
|
end
|
|
43
61
|
|
|
44
62
|
private
|
|
45
63
|
|
|
64
|
+
# The agent can run commands, so only this machine's own pages may drive it.
|
|
65
|
+
# The token, printed at start, keeps out other users and programs on the
|
|
66
|
+
# machine; the page itself holds no secret, so it is served without one.
|
|
67
|
+
# A Host check stops DNS rebinding; JSON plus a matching Origin stops a
|
|
68
|
+
# cross-site form or fetch.
|
|
69
|
+
def guarded(name, token: true)
|
|
70
|
+
handler = method(name)
|
|
71
|
+
lambda do |req, res|
|
|
72
|
+
next handler.call(req, res) if allowed?(req, token: token)
|
|
73
|
+
|
|
74
|
+
res.status = 403
|
|
75
|
+
res.body = "forbidden"
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def allowed?(req, token: true)
|
|
80
|
+
host = req["Host"].to_s
|
|
81
|
+
return false if token && !token?(req)
|
|
82
|
+
return false unless local_hosts.include?(host.sub(/:\d+\z/, ""))
|
|
83
|
+
return true unless req.request_method == "POST"
|
|
84
|
+
|
|
85
|
+
json?(req) && same_origin?(req["Origin"], host)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A header from the page's fetch calls; the query string for the first page
|
|
89
|
+
# load and for EventSource, which cannot set headers.
|
|
90
|
+
def token?(req)
|
|
91
|
+
given = req["X-Elelem-Token"] || req.query["token"]
|
|
92
|
+
given.is_a?(String) && OpenSSL.secure_compare(given, @token)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def local_hosts
|
|
96
|
+
LOCAL_HOSTS + [url_host]
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# URLs and Host headers put an IPv6 address in brackets.
|
|
100
|
+
def url_host
|
|
101
|
+
@address.include?(":") ? "[#{@address}]" : @address
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def json?(req)
|
|
105
|
+
req["Content-Type"].to_s.split(";").first.to_s.strip.casecmp?("application/json")
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def same_origin?(origin, host)
|
|
109
|
+
return true if origin.nil?
|
|
110
|
+
|
|
111
|
+
uri = URI(origin)
|
|
112
|
+
authority = uri.port == uri.default_port ? uri.host : "#{uri.host}:#{uri.port}"
|
|
113
|
+
authority == host
|
|
114
|
+
rescue URI::InvalidURIError
|
|
115
|
+
false
|
|
116
|
+
end
|
|
117
|
+
|
|
46
118
|
def index(_req, res)
|
|
119
|
+
res["Referrer-Policy"] = "no-referrer"
|
|
47
120
|
res.content_type = "text/html; charset=utf-8"
|
|
48
121
|
res.body = INDEX
|
|
49
122
|
end
|
|
50
123
|
|
|
51
|
-
def events(
|
|
124
|
+
def events(req, res)
|
|
125
|
+
tab = tab_for(req) or return no_tab(res)
|
|
126
|
+
|
|
52
127
|
res.content_type = "text/event-stream"
|
|
53
128
|
res["Cache-Control"] = "no-cache"
|
|
54
129
|
res.chunked = true
|
|
55
|
-
res.body = ->(out) { stream(out) }
|
|
130
|
+
res.body = ->(out) { stream(tab.terminal, out) }
|
|
56
131
|
end
|
|
57
132
|
|
|
58
|
-
def stream(out)
|
|
59
|
-
events =
|
|
133
|
+
def stream(terminal, out)
|
|
134
|
+
events = terminal.subscribe
|
|
60
135
|
loop do
|
|
61
|
-
|
|
136
|
+
# A ping every 15 seconds finds a closed tab, so its thread ends.
|
|
137
|
+
event = events.pop(timeout: 15)
|
|
138
|
+
out.write(event ? "data: #{JSON.dump(event)}\n\n" : ": ping\n\n")
|
|
62
139
|
end
|
|
63
140
|
rescue IOError, Errno::EPIPE, Errno::ECONNRESET
|
|
64
141
|
nil
|
|
65
142
|
ensure
|
|
66
|
-
|
|
143
|
+
terminal.unsubscribe(events)
|
|
67
144
|
end
|
|
68
145
|
|
|
146
|
+
# One turn at a time per tab: two at once would interleave one conversation.
|
|
69
147
|
def message(req, res)
|
|
148
|
+
tab = tab_for(req) or return no_tab(res)
|
|
70
149
|
text = JSON.parse(req.body)["text"].to_s
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
ensure
|
|
76
|
-
@terminal.done
|
|
77
|
-
end
|
|
150
|
+
# Claim under the tabs lock, so the tab cannot be evicted in between.
|
|
151
|
+
return busy(res) unless @tabs_lock.synchronize { @tabs.value?(tab) && tab.claim }
|
|
152
|
+
|
|
153
|
+
Thread.new { tab.run(text) }
|
|
78
154
|
res.status = 204
|
|
79
155
|
end
|
|
80
156
|
|
|
157
|
+
def busy(res)
|
|
158
|
+
res.status = 409
|
|
159
|
+
res.body = "busy"
|
|
160
|
+
end
|
|
161
|
+
|
|
81
162
|
def answer(req, res)
|
|
82
|
-
|
|
163
|
+
tab = tab_for(req) or return no_tab(res)
|
|
164
|
+
body = JSON.parse(req.body)
|
|
165
|
+
tab.terminal.answer(body["id"].to_i, body["text"].to_s)
|
|
83
166
|
res.status = 204
|
|
84
167
|
end
|
|
85
168
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
<body>
|
|
128
|
-
<div id="log"></div>
|
|
129
|
-
<form id="composer">
|
|
130
|
-
<textarea id="input" rows="1" placeholder="Message elelem…" autocapitalize="sentences"></textarea>
|
|
131
|
-
<button class="send" type="submit">Send</button>
|
|
132
|
-
</form>
|
|
133
|
-
<script>
|
|
134
|
-
const log = document.getElementById("log");
|
|
135
|
-
const input = document.getElementById("input");
|
|
136
|
-
let current = null;
|
|
137
|
-
|
|
138
|
-
const atBottom = () => log.scrollHeight - log.scrollTop - log.clientHeight < 80;
|
|
139
|
-
const scroll = (was) => { if (was) log.scrollTop = log.scrollHeight; };
|
|
140
|
-
|
|
141
|
-
function escape(text) {
|
|
142
|
-
return text.replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" })[c]);
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
function render(text) {
|
|
146
|
-
return escape(text)
|
|
147
|
-
.replace(/```(\\w*)\\n([\\s\\S]*?)```/g, (_m, _lang, code) => "<pre><code>" + code + "</code></pre>")
|
|
148
|
-
.replace(/`([^`\\n]+)`/g, "<code>$1</code>")
|
|
149
|
-
.replace(/\\*\\*([^*]+)\\*\\*/g, "<strong>$1</strong>")
|
|
150
|
-
.replace(/\\n/g, "<br>");
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function bubble(cls) {
|
|
154
|
-
const was = atBottom();
|
|
155
|
-
const el = document.createElement("div");
|
|
156
|
-
el.className = "msg " + cls;
|
|
157
|
-
log.appendChild(el);
|
|
158
|
-
scroll(was);
|
|
159
|
-
return el;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function append(cls, text) {
|
|
163
|
-
if (!current || current.dataset.cls !== cls) {
|
|
164
|
-
current = bubble(cls);
|
|
165
|
-
current.dataset.cls = cls;
|
|
166
|
-
current.dataset.raw = "";
|
|
167
|
-
}
|
|
168
|
-
const was = atBottom();
|
|
169
|
-
current.dataset.raw += text;
|
|
170
|
-
current.innerHTML = render(current.dataset.raw);
|
|
171
|
-
scroll(was);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
function askPermission(text) {
|
|
175
|
-
current = null;
|
|
176
|
-
const was = atBottom();
|
|
177
|
-
const el = document.createElement("div");
|
|
178
|
-
el.className = "prompt";
|
|
179
|
-
el.innerHTML = "<div>" + escape(text) + "</div>";
|
|
180
|
-
const respond = (answer) => {
|
|
181
|
-
el.querySelectorAll("button").forEach((b) => b.remove());
|
|
182
|
-
el.insertAdjacentHTML("beforeend", "<div><em>" + answer + "</em></div>");
|
|
183
|
-
fetch("/answer", { method: "POST", body: JSON.stringify({ text: answer }) });
|
|
184
|
-
};
|
|
185
|
-
const allow = document.createElement("button");
|
|
186
|
-
allow.className = "allow";
|
|
187
|
-
allow.textContent = "Allow";
|
|
188
|
-
allow.onclick = () => respond("y");
|
|
189
|
-
const deny = document.createElement("button");
|
|
190
|
-
deny.className = "deny";
|
|
191
|
-
deny.textContent = "Deny";
|
|
192
|
-
deny.onclick = () => respond("n");
|
|
193
|
-
el.append(allow, deny);
|
|
194
|
-
log.appendChild(el);
|
|
195
|
-
scroll(was);
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
new EventSource("/events").onmessage = (e) => {
|
|
199
|
-
const event = JSON.parse(e.data);
|
|
200
|
-
if (event.type === "content") append("assistant", event.text);
|
|
201
|
-
else if (event.type === "thinking") append("thinking", event.text);
|
|
202
|
-
else if (event.type === "prompt") askPermission(event.text);
|
|
203
|
-
else if (event.type === "done") current = null;
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
document.getElementById("composer").onsubmit = (e) => {
|
|
207
|
-
e.preventDefault();
|
|
208
|
-
const text = input.value.trim();
|
|
209
|
-
if (!text) return;
|
|
210
|
-
append("user", text);
|
|
211
|
-
current = null;
|
|
212
|
-
input.value = "";
|
|
213
|
-
fetch("/message", { method: "POST", body: JSON.stringify({ text: text }) });
|
|
214
|
-
};
|
|
215
|
-
|
|
216
|
-
input.addEventListener("keydown", (e) => {
|
|
217
|
-
if (e.key === "Enter" && !e.shiftKey) {
|
|
218
|
-
e.preventDefault();
|
|
219
|
-
document.getElementById("composer").requestSubmit();
|
|
220
|
-
}
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
input.addEventListener("input", () => {
|
|
224
|
-
input.style.height = "auto";
|
|
225
|
-
input.style.height = Math.min(input.scrollHeight, 160) + "px";
|
|
226
|
-
});
|
|
227
|
-
</script>
|
|
228
|
-
</body>
|
|
229
|
-
</html>
|
|
230
|
-
HTML
|
|
169
|
+
# The page sends its tab id as a header, or in the query for EventSource.
|
|
170
|
+
def tab_for(req)
|
|
171
|
+
id = req["X-Elelem-Tab"] || req.query["tab"]
|
|
172
|
+
return unless id.is_a?(String) && id.match?(TAB_ID)
|
|
173
|
+
|
|
174
|
+
@tabs_lock.synchronize { @tabs[id] } || new_tab(id)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
# Built outside @tabs_lock, so a slow provider does not hold up other
|
|
178
|
+
# tabs, and one at a time, because loading plugins is not thread-safe.
|
|
179
|
+
def new_tab(id)
|
|
180
|
+
@build_lock.synchronize do
|
|
181
|
+
existing = @tabs_lock.synchronize { @tabs[id] }
|
|
182
|
+
next existing if existing
|
|
183
|
+
next unless room? # no session is built only to be refused
|
|
184
|
+
|
|
185
|
+
add_tab(id, Tab.new(@build_session.call))
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def room?
|
|
190
|
+
@tabs_lock.synchronize { @tabs.size < MAX_TABS || @tabs.each_value.any?(&:idle?) }
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# At the limit, a tab that is idle with no page open gives up its place.
|
|
194
|
+
def add_tab(id, tab)
|
|
195
|
+
@tabs_lock.synchronize do
|
|
196
|
+
if @tabs.size >= MAX_TABS
|
|
197
|
+
idle = @tabs.find { |_, other| other.idle? }&.first
|
|
198
|
+
return unless idle
|
|
199
|
+
|
|
200
|
+
@tabs.delete(idle)
|
|
201
|
+
end
|
|
202
|
+
@tabs[id] = tab
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def no_tab(res)
|
|
207
|
+
res.status = 400
|
|
208
|
+
res.body = "missing tab, or too many tabs (#{MAX_TABS})"
|
|
209
|
+
end
|
|
231
210
|
end
|
|
232
211
|
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"bootstrap-sha": "2db5a1c81ce0983d7e4e64772169c0b6dc0b5277",
|
|
3
|
+
"packages": {
|
|
4
|
+
".": {
|
|
5
|
+
"release-type": "ruby",
|
|
6
|
+
"package-name": "elelem-server",
|
|
7
|
+
"version-file": "lib/elelem/server/version.rb",
|
|
8
|
+
"bump-minor-pre-major": true,
|
|
9
|
+
"include-component-in-tag": false
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Elelem::Server, "request guard" do
|
|
4
|
+
subject(:server) { described_class.new(-> { session }, port: 4567, token: "secret") }
|
|
5
|
+
|
|
6
|
+
let(:session) { double("session", output: double("terminal")) }
|
|
7
|
+
|
|
8
|
+
def request(method: "GET", host: "127.0.0.1:4567", query: {}, x_elelem_token: "secret", **headers)
|
|
9
|
+
headers = headers.merge(x_elelem_token: x_elelem_token).compact
|
|
10
|
+
fields = { "Host" => host }.merge(headers.transform_keys { |k| k.to_s.split("_").map(&:capitalize).join("-") })
|
|
11
|
+
double("request", request_method: method, query: query, "[]": nil).tap do |req|
|
|
12
|
+
fields.each { |name, value| allow(req).to receive(:[]).with(name).and_return(value) }
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def allowed?(req) = server.send(:allowed?, req)
|
|
17
|
+
|
|
18
|
+
it "refuses a request without the token" do
|
|
19
|
+
expect(allowed?(request(x_elelem_token: nil))).to be false
|
|
20
|
+
expect(allowed?(request(x_elelem_token: "guess"))).to be false
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
it "serves the page itself without the token, which holds no secret" do
|
|
24
|
+
expect(server.send(:allowed?, request(x_elelem_token: nil), token: false)).to be true
|
|
25
|
+
expect(server.send(:allowed?, request(x_elelem_token: nil, host: "evil.example"), token: false)).to be false
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
it "takes the token from the query string, for the first page load and EventSource" do
|
|
29
|
+
expect(allowed?(request(x_elelem_token: nil, query: { "token" => "secret" }))).to be true
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
it "makes a new token for each server" do
|
|
33
|
+
expect(described_class.new(-> { session }).token).not_to eq(described_class.new(-> { session }).token)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
it "binds to 127.0.0.1 by default" do
|
|
37
|
+
expect(server.instance_variable_get(:@address)).to eq("127.0.0.1")
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
it "serves the page to localhost, 127.0.0.1 and [::1]" do
|
|
41
|
+
%w[localhost:4567 127.0.0.1:4567 [::1]:4567].each { |host| expect(allowed?(request(host: host))).to be true }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
it "answers a bound IPv6 address, which a Host header puts in brackets" do
|
|
45
|
+
server = described_class.new(-> { session }, port: 4567, address: "2001:db8::5", token: "secret")
|
|
46
|
+
|
|
47
|
+
expect(server.send(:allowed?, request(host: "[2001:db8::5]:4567"))).to be true
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
it "refuses another Host, which is what DNS rebinding sends" do
|
|
51
|
+
expect(allowed?(request(host: "evil.example:4567"))).to be false
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "takes a JSON post from its own page" do
|
|
55
|
+
req = request(method: "POST", content_type: "application/json; charset=utf-8", origin: "http://127.0.0.1:4567")
|
|
56
|
+
|
|
57
|
+
expect(allowed?(req)).to be true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
it "refuses a post that is not JSON, which a cross-site form can send" do
|
|
61
|
+
expect(allowed?(request(method: "POST", content_type: "text/plain"))).to be false
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
it "refuses a post from another origin" do
|
|
65
|
+
req = request(method: "POST", content_type: "application/json", origin: "https://evil.example")
|
|
66
|
+
|
|
67
|
+
expect(allowed?(req)).to be false
|
|
68
|
+
end
|
|
69
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe "the web page" do
|
|
4
|
+
let(:html) { Elelem::Server::INDEX }
|
|
5
|
+
|
|
6
|
+
# The page once lived in a Ruby heredoc, which needed \\n. In a .html file
|
|
7
|
+
# that is a literal backslash, so no reply rendered its line breaks.
|
|
8
|
+
it "has no regex escaped for a Ruby string" do
|
|
9
|
+
expect(html.scan(%r{\.replace\(/[^\n]*\\\\[nwsS*]})).to be_empty
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
it "turns line breaks into <br>" do
|
|
13
|
+
expect(html).to include(".replace(/\\n/g, \"<br>\")")
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Elelem::Server::Tab do
|
|
4
|
+
subject(:tab) { described_class.new(session) }
|
|
5
|
+
|
|
6
|
+
let(:said) { [] }
|
|
7
|
+
let(:terminal) { double("terminal", done: nil).tap { |t| allow(t).to receive(:say) { |text| said << text } } }
|
|
8
|
+
let(:commands) { Elelem::Commands.new.tap { |c| c.register("ping", description: "Ping") { said << "pong" } } }
|
|
9
|
+
let(:session) { double("session", output: terminal, commands: commands, turn: nil) }
|
|
10
|
+
|
|
11
|
+
it "runs a slash command instead of sending it to the model" do
|
|
12
|
+
tab.run("/ping")
|
|
13
|
+
|
|
14
|
+
expect(said).to eq(["pong"])
|
|
15
|
+
expect(session).not_to have_received(:turn)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
it "lists the commands for an unknown one" do
|
|
19
|
+
tab.run("/nope")
|
|
20
|
+
|
|
21
|
+
expect(said).to eq(["/ping"])
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
it "does not let /exit or /reload reach past this tab" do
|
|
25
|
+
tab.run("/exit")
|
|
26
|
+
tab.run("/reload")
|
|
27
|
+
|
|
28
|
+
expect(said).to all(match(/not available in the browser/))
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
it "sends anything else to the model, and says when the turn is done" do
|
|
32
|
+
tab.run("hello")
|
|
33
|
+
|
|
34
|
+
expect(session).to have_received(:turn).with("hello")
|
|
35
|
+
expect(terminal).to have_received(:done)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
it "keeps its place for a minute after its page goes, so a reconnecting page finds it" do
|
|
39
|
+
now = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
40
|
+
allow(terminal).to receive_messages(subscribers: 0, last_seen: now)
|
|
41
|
+
|
|
42
|
+
expect(tab.idle?(now + 30)).to be false
|
|
43
|
+
expect(tab.idle?(now + 61)).to be true
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
RSpec.describe Elelem::Server, "tabs and turns" do
|
|
4
|
+
subject(:server) { described_class.new(-> { build.call }, token: "t") }
|
|
5
|
+
|
|
6
|
+
let(:gate) { Queue.new }
|
|
7
|
+
let(:sessions) { [] }
|
|
8
|
+
let(:build) do
|
|
9
|
+
lambda do
|
|
10
|
+
terminal = double("terminal", done: nil, say: nil, subscribers: 0, last_seen: -1_000_000)
|
|
11
|
+
double("session", output: terminal).tap do |s|
|
|
12
|
+
allow(s).to receive(:turn) { gate.pop }
|
|
13
|
+
sessions << s
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def request(tab, text = "hi")
|
|
19
|
+
double("request", body: JSON.generate(text: text), query: {}).tap do |req|
|
|
20
|
+
allow(req).to receive(:[]).with("X-Elelem-Tab").and_return(tab)
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def post(tab, text = "hi")
|
|
25
|
+
res = Struct.new(:status, :body).new
|
|
26
|
+
server.send(:message, request(tab, text), res)
|
|
27
|
+
res
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def wait_idle(tab)
|
|
31
|
+
sleep 0.01 until server.send(:tab_for, request(tab)).claim
|
|
32
|
+
server.send(:tab_for, request(tab)).instance_variable_set(:@busy, false)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
it "gives each tab its own session" do
|
|
36
|
+
post("a")
|
|
37
|
+
post("b")
|
|
38
|
+
|
|
39
|
+
expect(sessions.size).to eq(2)
|
|
40
|
+
2.times { gate << :finish }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
it "refuses a second message in the same tab while its turn runs, then takes the next" do
|
|
44
|
+
expect(post("a").status).to eq(204)
|
|
45
|
+
expect(post("a").status).to eq(409)
|
|
46
|
+
expect(post("b").status).to eq(204)
|
|
47
|
+
|
|
48
|
+
2.times { gate << :finish }
|
|
49
|
+
wait_idle("a")
|
|
50
|
+
expect(post("a").status).to eq(204)
|
|
51
|
+
gate << :finish
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
it "refuses a request without a tab id" do
|
|
55
|
+
expect(post(nil).status).to eq(400)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it "frees an idle tab's place at the limit, but not a busy one's" do
|
|
59
|
+
described_class::MAX_TABS.times { |i| post("t#{i}") }
|
|
60
|
+
expect(post("one-more").status).to eq(400)
|
|
61
|
+
|
|
62
|
+
gate << :finish
|
|
63
|
+
sleep 0.01 until server.instance_variable_get(:@tabs).values.any?(&:idle?)
|
|
64
|
+
expect(post("one-more").status).to eq(204)
|
|
65
|
+
described_class::MAX_TABS.times { gate << :finish }
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
it "builds a new tab without holding up the others" do
|
|
69
|
+
post("a")
|
|
70
|
+
gate << :finish
|
|
71
|
+
slow = Queue.new
|
|
72
|
+
server.instance_variable_set(:@build_session, -> { slow.pop and build.call })
|
|
73
|
+
Thread.new { server.send(:tab_for, request("slow")) }
|
|
74
|
+
sleep 0.05
|
|
75
|
+
|
|
76
|
+
expect(Timeout.timeout(1) { server.send(:tab_for, request("a")) }).not_to be_nil
|
|
77
|
+
slow << :go
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -101,11 +101,61 @@ RSpec.describe Elelem::Server::WebTerminal do
|
|
|
101
101
|
end
|
|
102
102
|
|
|
103
103
|
describe "#ask" do
|
|
104
|
-
it "emits a prompt and blocks until answered" do
|
|
104
|
+
it "emits a prompt with an id and blocks until answered with a matching id" do
|
|
105
105
|
thread = Thread.new { subject.ask("Allow?") }
|
|
106
|
-
|
|
107
|
-
|
|
106
|
+
event = events.pop
|
|
107
|
+
expect(event).to include(type: "prompt", text: "Allow?")
|
|
108
|
+
subject.answer(event[:id], "n")
|
|
108
109
|
expect(thread.value).to eq("n")
|
|
109
110
|
end
|
|
111
|
+
|
|
112
|
+
it "ignores an answer for an id that isn't pending" do
|
|
113
|
+
thread = Thread.new { subject.ask("Allow?") }
|
|
114
|
+
event = events.pop
|
|
115
|
+
subject.answer(event[:id] + 1, "n")
|
|
116
|
+
subject.answer(event[:id], "y")
|
|
117
|
+
expect(thread.value).to eq("y")
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
it "does not let an answer sent before any prompt pre-approve the next prompt" do
|
|
121
|
+
subject.answer(1, "y")
|
|
122
|
+
|
|
123
|
+
thread = Thread.new { subject.ask("Allow?") }
|
|
124
|
+
event = events.pop
|
|
125
|
+
subject.answer(event[:id], "n")
|
|
126
|
+
|
|
127
|
+
expect(thread.value).to eq("n")
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
it "accepts the options a session passes to ask" do
|
|
131
|
+
thread = Thread.new { subject.ask("Allow?", history: false) }
|
|
132
|
+
subject.answer(events.pop[:id], "y")
|
|
133
|
+
expect(thread.value).to eq("y")
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
it "replays a prompt still waiting for an answer to a page that subscribes again" do
|
|
138
|
+
terminal = described_class.new
|
|
139
|
+
asking = Thread.new { terminal.ask("Allow execute `ls`? [Y/n] > ") }
|
|
140
|
+
sleep 0.01 until terminal.instance_variable_get(:@open).any?
|
|
141
|
+
|
|
142
|
+
replay = terminal.subscribe.pop(timeout: 1)
|
|
143
|
+
expect(replay).to include(type: "prompt", text: "Allow execute `ls`? [Y/n] > ")
|
|
144
|
+
|
|
145
|
+
terminal.answer(replay[:id], "y")
|
|
146
|
+
expect(asking.value).to eq("y")
|
|
147
|
+
expect(terminal.subscribe.pop(timeout: 0.05)).to be_nil
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
it "numbers prompts across every terminal, so a rebuilt tab never reuses an id" do
|
|
151
|
+
first = described_class.new
|
|
152
|
+
second = described_class.new
|
|
153
|
+
ids = [first, second].map do |terminal|
|
|
154
|
+
Thread.new { terminal.ask("ok?") }
|
|
155
|
+
sleep 0.01 until terminal.instance_variable_get(:@open).any?
|
|
156
|
+
terminal.instance_variable_get(:@open).keys.first.tap { |id| terminal.answer(id, "y") }
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
expect(ids.uniq.size).to eq(2)
|
|
110
160
|
end
|
|
111
161
|
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: elelem-server
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.1
|
|
4
|
+
version: 0.2.1
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- mo khan
|
|
@@ -13,16 +13,22 @@ dependencies:
|
|
|
13
13
|
name: elelem
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "
|
|
16
|
+
- - ">="
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: 0.13.1
|
|
19
|
+
- - "<"
|
|
17
20
|
- !ruby/object:Gem::Version
|
|
18
|
-
version: '
|
|
21
|
+
version: '1'
|
|
19
22
|
type: :runtime
|
|
20
23
|
prerelease: false
|
|
21
24
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
25
|
requirements:
|
|
23
|
-
- - "
|
|
26
|
+
- - ">="
|
|
27
|
+
- !ruby/object:Gem::Version
|
|
28
|
+
version: 0.13.1
|
|
29
|
+
- - "<"
|
|
24
30
|
- !ruby/object:Gem::Version
|
|
25
|
-
version: '
|
|
31
|
+
version: '1'
|
|
26
32
|
- !ruby/object:Gem::Dependency
|
|
27
33
|
name: json
|
|
28
34
|
requirement: !ruby/object:Gem::Requirement
|
|
@@ -59,22 +65,35 @@ executables:
|
|
|
59
65
|
extensions: []
|
|
60
66
|
extra_rdoc_files: []
|
|
61
67
|
files:
|
|
68
|
+
- ".github/workflows/release.yml"
|
|
69
|
+
- ".release-please-manifest.json"
|
|
62
70
|
- ".rspec"
|
|
71
|
+
- ".rubocop.yml"
|
|
72
|
+
- CHANGELOG.md
|
|
63
73
|
- LICENSE.txt
|
|
64
74
|
- Rakefile
|
|
65
75
|
- exe/elelem-server
|
|
66
76
|
- lib/elelem/server.rb
|
|
77
|
+
- lib/elelem/server/index.html
|
|
78
|
+
- lib/elelem/server/tab.rb
|
|
67
79
|
- lib/elelem/server/version.rb
|
|
68
80
|
- lib/elelem/server/web_terminal.rb
|
|
81
|
+
- release-please-config.json
|
|
82
|
+
- spec/elelem/server/guard_spec.rb
|
|
83
|
+
- spec/elelem/server/index_spec.rb
|
|
84
|
+
- spec/elelem/server/serve_spec.rb
|
|
85
|
+
- spec/elelem/server/tab_spec.rb
|
|
86
|
+
- spec/elelem/server/turn_spec.rb
|
|
69
87
|
- spec/elelem/server/web_terminal_spec.rb
|
|
70
88
|
- spec/spec_helper.rb
|
|
71
|
-
homepage: https://
|
|
89
|
+
homepage: https://elelem.sh
|
|
72
90
|
licenses:
|
|
73
91
|
- MIT
|
|
74
92
|
metadata:
|
|
75
93
|
allowed_push_host: https://rubygems.org
|
|
76
|
-
homepage_uri: https://
|
|
77
|
-
source_code_uri: https://
|
|
94
|
+
homepage_uri: https://elelem.sh
|
|
95
|
+
source_code_uri: https://github.com/elelem-sh/server
|
|
96
|
+
rubygems_mfa_required: 'true'
|
|
78
97
|
rdoc_options: []
|
|
79
98
|
require_paths:
|
|
80
99
|
- lib
|
|
@@ -89,7 +108,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
89
108
|
- !ruby/object:Gem::Version
|
|
90
109
|
version: '0'
|
|
91
110
|
requirements: []
|
|
92
|
-
rubygems_version: 4.0.
|
|
111
|
+
rubygems_version: 4.0.6
|
|
93
112
|
specification_version: 4
|
|
94
113
|
summary: A web UI server plugin for elelem.
|
|
95
114
|
test_files: []
|