robot_lab-web 0.2.6
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 +7 -0
- data/.github/workflows/deploy-github-pages.yml +52 -0
- data/.loki +15 -0
- data/.quality/reek_baseline.txt +8 -0
- data/.rubocop.yml +8 -0
- data/CHANGELOG.md +35 -0
- data/LICENSE.txt +21 -0
- data/README.md +96 -0
- data/Rakefile +132 -0
- data/config.ru +19 -0
- data/docs/api_reference.md +173 -0
- data/docs/getting_started.md +95 -0
- data/docs/how_it_works.md +138 -0
- data/docs/index.md +59 -0
- data/docs/security.md +75 -0
- data/examples/boot.rb +24 -0
- data/exe/robot_lab-web +49 -0
- data/lib/robot_lab/web/activity_log.rb +61 -0
- data/lib/robot_lab/web/app.rb +170 -0
- data/lib/robot_lab/web/components/chat.rb +140 -0
- data/lib/robot_lab/web/components/dashboard.rb +47 -0
- data/lib/robot_lab/web/components/error_page.rb +25 -0
- data/lib/robot_lab/web/components/layout.rb +73 -0
- data/lib/robot_lab/web/components/message.rb +42 -0
- data/lib/robot_lab/web/event.rb +106 -0
- data/lib/robot_lab/web/event_sink.rb +46 -0
- data/lib/robot_lab/web/registry.rb +38 -0
- data/lib/robot_lab/web/stream_hook.rb +77 -0
- data/lib/robot_lab/web/version.rb +7 -0
- data/lib/robot_lab/web.rb +77 -0
- data/mkdocs.yml +118 -0
- data/sig/robot_lab/web.rbs +6 -0
- metadata +176 -0
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
# How It Works
|
|
2
|
+
|
|
3
|
+
## Core vs. Opt-In Web Stack
|
|
4
|
+
|
|
5
|
+
`robot_lab-web` is split into two load paths:
|
|
6
|
+
|
|
7
|
+
- **`require "robot_lab/web"`** — the core, Sinatra-free surface: `Event`, `ActivityLog`, `EventSink`, `Registry`, and (since `robot_lab` is a hard dependency, required directly by this file so load order never matters) `StreamHook`. No Sinatra, Falcon, or Phlex touched at all — this is everything `RobotLab::Web.run`/`.register` need, and everything a test or a custom front end needs too.
|
|
8
|
+
- **`require "robot_lab/web/app"`** (or simply call `RobotLab::Web.app`, which requires it lazily on first use) — pulls in Sinatra, `phlex-sinatra`, `phlex-icons-hero`, and the bundled `App` class plus its Phlex view components.
|
|
9
|
+
|
|
10
|
+
This split exists so that consuming the event stream from plain Ruby (a test, a script, an alternate front end) never has to load Sinatra/Falcon/Phlex at all — those are only paid for when you actually want the bundled console.
|
|
11
|
+
|
|
12
|
+
## The Event Model
|
|
13
|
+
|
|
14
|
+
`RobotLab::Web::Event` is a frontend-neutral, **immutable** value object for a single step in a robot run — the one model backing both the persisted (in-memory) activity log and the live SSE stream. It's a `Struct` (keyword-init) that deep-freezes its `content` on construction (recursively, through nested Hashes/Arrays/Strings) and freezes itself, so an `Event` handed to a sink can never be mutated by that sink afterward.
|
|
15
|
+
|
|
16
|
+
| Role | `content` shape | Meaning |
|
|
17
|
+
|---|---|---|
|
|
18
|
+
| `:user` | `String` | Text the human sent |
|
|
19
|
+
| `:delta` | `String` | One streamed token/content fragment |
|
|
20
|
+
| `:robot` | `String` | The robot's final reply (the whole text, not a fragment) |
|
|
21
|
+
| `:tool_call` | `{name:, args:}` | A tool invocation |
|
|
22
|
+
| `:tool_result` | `{name:, result:}` or `{name:, error:}` | A tool's return (or its failure) |
|
|
23
|
+
| `:error` | `{class:, message:}` | The run itself raised |
|
|
24
|
+
|
|
25
|
+
Role validation is strict — `Event.new(role: :bogus, ...)` raises `ArgumentError` immediately rather than silently rendering a blank or malformed bubble later. Construction also stamps a UTC `timestamp` and a random `event_id` (`SecureRandom.uuid`) when not given explicitly.
|
|
26
|
+
|
|
27
|
+
Convenience readers mean callers never have to reach into the `content` hash directly: `#tool_name` (works for both `:tool_call` and `:tool_result`), `#error?`, `#error_message`, and `#text` — a role-agnostic "what should I display" reader that unwraps `content[:result]`/`content[:message]`/`content[:args]` for hash-shaped content, or returns `content` itself otherwise.
|
|
28
|
+
|
|
29
|
+
`#to_h`/`.from_h` round-trip an `Event` through JSON (used both for SSE frames and any future persistence) — `timestamp` serializes as ISO 8601 with millisecond precision; `from_h` accepts either symbol or string keys (`hash.key?(key) ? hash[key] : hash[key.to_s]`) so it can rebuild an `Event` from a hash decoded from JSON (string keys) just as easily as one built in Ruby (symbol keys), and returns `nil` rather than raising if the role can't be validated.
|
|
30
|
+
|
|
31
|
+
## The Hook-to-Sink Bridge
|
|
32
|
+
|
|
33
|
+
Two pieces work together to turn a synchronous `robot.run` call into a live event stream, without adding any new API surface to `robot_lab` core itself:
|
|
34
|
+
|
|
35
|
+
### `EventSink` — a thread-local mailbox
|
|
36
|
+
|
|
37
|
+
`robot_lab` hooks are **class-level singletons** — there's no hook instance to hang a per-connection callback on. `EventSink` works around this by stashing the current consumer (any object responding to `#call`) in a `Thread.current` slot for the duration of one run:
|
|
38
|
+
|
|
39
|
+
```ruby
|
|
40
|
+
EventSink.capture(sink) { robot.run(message, hooks: [StreamHook]) { |chunk| ... } }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Because `Robot#run` dispatches its hooks **synchronously on the calling thread**, `StreamHook` (running inside that same call) sees exactly the right sink via `EventSink.current`. `EventSink.emit(event)` delivers to it and swallows any `StandardError` the sink itself raises — a broken consumer (a dead SSE connection, a bug in a custom sink) must never break the robot run it's observing.
|
|
44
|
+
|
|
45
|
+
**Caveat, stated directly in the source:** a robot that executes tools on other threads or via Ractors would not propagate this thread-local — fine for `robot_lab`'s core synchronous tool-call path, but worth knowing if you're pairing this with `robot_lab-ractor` or similar.
|
|
46
|
+
|
|
47
|
+
### `StreamHook` — the producer
|
|
48
|
+
|
|
49
|
+
A `RobotLab::Hook` subclass (`namespace = :web_stream`) that taps every lifecycle moment `robot_lab`'s hook system exposes and turns it into an `Event`, delivered to the current `EventSink` and also recorded in `ActivityLog` (see below):
|
|
50
|
+
|
|
51
|
+
| Hook callback | Emits |
|
|
52
|
+
|---|---|
|
|
53
|
+
| `before_run` | `:user` — the incoming request text |
|
|
54
|
+
| `after_run` (success) | `:robot` — the final reply text (`ctx.response.reply`, falling back to `#last_text_content`, then plain `#to_s`) |
|
|
55
|
+
| `after_run` (error) | `:error` |
|
|
56
|
+
| `before_tool_call` | `:tool_call` |
|
|
57
|
+
| `after_tool_call` | `:tool_result` (success or error shape, depending on `ctx.tool_error`) |
|
|
58
|
+
| `on_error` | `:error` |
|
|
59
|
+
|
|
60
|
+
Registered **per-run**, not globally (`robot.run(message, hooks: [RobotLab::Web::StreamHook])`), so it only fires for web-driven runs — a robot used elsewhere in your app, outside the console, is completely unaffected.
|
|
61
|
+
|
|
62
|
+
### `RobotLab::Web.run` — the one call that ties it together
|
|
63
|
+
|
|
64
|
+
```ruby
|
|
65
|
+
def run(robot, message, sink = nil, &block)
|
|
66
|
+
sink ||= block
|
|
67
|
+
EventSink.capture(sink) do
|
|
68
|
+
robot.run(message, hooks: [StreamHook]) do |chunk|
|
|
69
|
+
delta = chunk.respond_to?(:content) ? chunk.content : chunk.to_s
|
|
70
|
+
next if delta.nil? || delta.empty?
|
|
71
|
+
EventSink.emit(Event.new(role: :delta, content: delta, robot_name: name))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
This is the single call both Sinatra routes wrap, and the cleanest way to consume the stream from plain Ruby or a test. **Two independent layers of events reach the sink:**
|
|
78
|
+
|
|
79
|
+
1. `StreamHook`, translating `robot_lab` hook moments into `:user`/`:tool_call`/`:tool_result`/`:robot`/`:error` events, and
|
|
80
|
+
2. the streaming block passed to `robot.run` itself, translating each RubyLLM content chunk into a `:delta` event.
|
|
81
|
+
|
|
82
|
+
A model or run that doesn't stream token-by-token simply produces no `:delta` events at all — the final `:robot` event still carries the complete reply either way, so nothing is lost, only the incremental rendering.
|
|
83
|
+
|
|
84
|
+
**Deltas deliberately bypass `ActivityLog`** — `StreamHook`'s `emit` helper logs to the activity feed on every call, but the delta block above calls `EventSink.emit` directly, skipping that logging so token-by-token spam never floods the dashboard's recent-activity feed with hundreds of entries per run.
|
|
85
|
+
|
|
86
|
+
## `ActivityLog`
|
|
87
|
+
|
|
88
|
+
A thread-safe, bounded (`MAX_EVENTS` = 50) in-memory ring buffer backing the dashboard's "recent activity" feed — a process-wide singleton (`ActivityLog.instance`), not per-connection. `.safe_log` is the cardinal rule enforced everywhere this is called from instrumentation paths: swallow any error, because **recording that something happened must never be able to break the thing that happened**.
|
|
89
|
+
|
|
90
|
+
This is intentionally separate from the SSE event stream itself — the activity log is a lightweight, best-effort summary (`role`, `robot`, `tool` — see `StreamHook#emit`'s `ActivityLog.safe_log(role, robot: ..., tool: ...)` call) for the dashboard's overview, not the full transcript; the full transcript only ever exists as the live SSE stream (or whatever a custom sink chooses to persist).
|
|
91
|
+
|
|
92
|
+
## `Registry`
|
|
93
|
+
|
|
94
|
+
A plain in-memory `Hash`, keyed by robot name (string) — the host app registers robots at boot (`RobotLab::Web.register`), and the Sinatra app reads from it (`Registry.fetch`/`.names`/`.all`) to know what it can list and drive. No persistence beyond the process's own memory — restarting the console clears the registry, and it's rebuilt entirely by whatever boot file ran at start.
|
|
95
|
+
|
|
96
|
+
## The SSE Wire Protocol
|
|
97
|
+
|
|
98
|
+
`POST /robots/:name/stream` sets `content_type 'text/event-stream'` and streams frames as they happen, using Sinatra's `stream do |out| ... end`:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
write_sse = ->(type, data) { out << "event: #{type}\ndata: #{JSON.generate(data)}\n\n" }
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Three frame types:
|
|
105
|
+
|
|
106
|
+
| `event:` | `data:` payload | When |
|
|
107
|
+
|---|---|---|
|
|
108
|
+
| `message` | `event.to_h` (a full `Event`, JSON-encoded) | Once per `Event` the run produces — every `:user`/`:delta`/`:tool_call`/`:tool_result`/`:robot`/`:error` |
|
|
109
|
+
| `done` | `{reply: <final text>}` | Once, after the run completes successfully |
|
|
110
|
+
| `error` | `{message: <text>}` | Once, if the request had no `message` param, or if the run itself raised |
|
|
111
|
+
|
|
112
|
+
**Why this isn't a plain browser `EventSource`:** the stream route is CSRF-protected (see [Security](security.md#csrf-protection)), and `EventSource` is GET-only and cannot send custom headers or a request body — there's no way to attach a CSRF token to it. The bundled `Chat` component's client-side JS instead uses `fetch()` with a `ReadableStream` reader, manually parsing the same `event:`/`data:` frame format `EventSource` would have handled natively:
|
|
113
|
+
|
|
114
|
+
```js
|
|
115
|
+
const res = await fetch(streamUrl, {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-CSRF-Token': csrf },
|
|
118
|
+
body: 'message=' + encodeURIComponent(message)
|
|
119
|
+
});
|
|
120
|
+
const reader = res.body.getReader();
|
|
121
|
+
// ...decode chunks, split on "\n\n" into frames, parse "event:"/"data:" lines...
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
The parsing loop buffers incoming bytes, splits on blank lines (`\n\n`, the SSE frame delimiter) keeping the last, possibly-incomplete fragment in the buffer for the next read, and for each complete frame extracts the `event:` and `data:` lines exactly the way a native `EventSource` would internally.
|
|
125
|
+
|
|
126
|
+
## Token-Delta Rendering, Client-Side
|
|
127
|
+
|
|
128
|
+
The chat page's JS keeps a single "currently streaming" bubble reference (`streaming`) while `:delta` events arrive, appending each fragment's text (`streaming.textContent += ev.content`) so the reply grows token-by-token in place. When the terminal `:robot` event arrives, it replaces the accumulated text with the final authoritative string (`streaming.textContent = ev.content`) and clears the `streaming` reference — the delta-built text and the final `:robot` text should be identical in the normal case, but the final event is treated as the source of truth. `:user` events are skipped entirely on render — the client already rendered the user's own message optimistically, immediately on form submit, before the request even went out.
|
|
129
|
+
|
|
130
|
+
## Two Ways to Render the Same Transcript
|
|
131
|
+
|
|
132
|
+
The same `Event` → HTML mapping exists in two places, by necessity: `Components::Message` (Phlex, server-side — used by the non-streaming `/chat` fallback endpoint) and the inline JS in `Components::Chat::CLIENT_JS` (client-side — used by the streaming `/stream` endpoint's `fetch()` client). They're kept in sync intentionally (same CSS classes — `.msg`, `.msg.user`, `.msg.tool_call`, etc. — defined once in `Layout::STYLES` and shared by both renderers) but are genuinely two separate implementations, since the streaming path never touches the server-side Phlex renderer for the events it's actively streaming — only the non-streaming fallback does.
|
|
133
|
+
|
|
134
|
+
The non-streaming `/robots/:name/chat` endpoint explicitly filters out `:delta` events before rendering (`events.reject { |event| event.role == :delta }`) — since the final `:robot` event already carries the complete reply, showing the deltas too would duplicate the same text piecemeal-then-whole in a static (non-incremental) render.
|
|
135
|
+
|
|
136
|
+
## Falcon as the Application Server
|
|
137
|
+
|
|
138
|
+
`exe/robot_lab-web` builds a single `Falcon::Server` (not Puma) and calls `#run` at the top level, which blocks on the accept loop until interrupted. Falcon's async/fiber reactor gives cheap fiber-per-connection concurrency in one process — the right fit for a console serving multiple long-lived SSE streams at once, since each connection can sit open (mid-stream) for as long as a robot run takes without tying up an OS thread the way a thread-per-connection server would. `config.ru` itself stays server-agnostic — nothing in the Sinatra `App` class requires Falcon specifically; any Rack server can run it, just without that same cheap concurrency model for long-lived connections.
|
data/docs/index.md
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# robot_lab-web
|
|
2
|
+
|
|
3
|
+
A Rails-free **web console for [robot_lab](https://github.com/MadBomber/robot_lab)**: register your robots, chat with them from the browser, and watch each lifecycle event — tool calls, tool results, the final reply, errors — **stream in real time** over Server-Sent Events.
|
|
4
|
+
|
|
5
|
+
It's the standalone counterpart to [`robot_lab-rails`](https://github.com/MadBomber/robot_lab-rails): one event stream, a Sinatra + HTMX front end, no Turbo and no Rails required.
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
# my_robots.rb
|
|
9
|
+
require "robot_lab"
|
|
10
|
+
require "robot_lab/web"
|
|
11
|
+
|
|
12
|
+
assistant = RobotLab.build(name: "assistant", system_prompt: "You are concise.")
|
|
13
|
+
RobotLab::Web.register(assistant)
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
robot_lab-web my_robots.rb # http://127.0.0.1:9292 — pick a robot, start chatting
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
!!! warning "Developer tool — unauthenticated by default"
|
|
21
|
+
The console has no login. Run it on localhost or a trusted private network. It ships sensible hardening (CSRF with a constant-time compare, a hashed and persisted session secret, `httponly`/`SameSite` cookies, a production boot guard) but is **not** meant to face untrusted users. See [Security](security.md) for the full posture and its limits.
|
|
22
|
+
|
|
23
|
+
## How It Fits Together
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
browser ──POST /robots/:name/stream──▶ Sinatra App
|
|
27
|
+
└─ RobotLab::Web.run(robot, msg) { |event| write_sse }
|
|
28
|
+
└─ robot.run(msg, hooks: [StreamHook])
|
|
29
|
+
└─ StreamHook ─emits─▶ EventSink ─▶ SSE frames
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`robot_lab-web` registers a per-run hook (`RobotLab::Web::StreamHook`) on `robot_lab`'s own hook system. As a robot runs, the hook turns each moment into an immutable `RobotLab::Web::Event` and pushes it to whatever sink is currently listening — an SSE stream for the browser, or a plain array in a test. This is `robot_lab`'s native equivalent of an `on_event:` callback, built entirely out of existing hook infrastructure rather than a new API on `Robot` itself.
|
|
33
|
+
|
|
34
|
+
## Navigation
|
|
35
|
+
|
|
36
|
+
- [Getting Started](getting_started.md) — installation, writing a boot file, launching the console, the four HTTP endpoints
|
|
37
|
+
- [How It Works](how_it_works.md) — the event model, the hook-to-sink bridge, the SSE wire protocol and its browser-side client, token-delta streaming, the opt-in core/Sinatra split
|
|
38
|
+
- [API Reference](api_reference.md) — every public class and method
|
|
39
|
+
- [Security](security.md) — the full hardening posture (CSRF, session secret, cookies, host authorization, production guard) and exactly what it does and doesn't protect against
|
|
40
|
+
|
|
41
|
+
## At a Glance
|
|
42
|
+
|
|
43
|
+
| | |
|
|
44
|
+
|---|---|
|
|
45
|
+
| **Server** | [Falcon](https://github.com/socketry/falcon) (async/fiber reactor — the right fit for long-lived SSE streams); `config.ru` itself is server-agnostic |
|
|
46
|
+
| **Views** | [Phlex](https://www.phlex.fun/) components, rendered via `phlex-sinatra`, styled with Tailwind (Play CDN), icons from `phlex-icons-hero` |
|
|
47
|
+
| **Front end** | Vanilla JS `fetch()` streaming client (not `EventSource` — the stream route is CSRF-protected, and `EventSource` can't send custom headers) plus an HTMX-compatible non-streaming fallback |
|
|
48
|
+
| **Core dependency** | `require "robot_lab/web"` alone gets you the event model, activity log, sink, and registry — no Sinatra/Falcon/Phlex loaded |
|
|
49
|
+
| **Opt-in web stack** | `require "robot_lab/web/app"` (or call `RobotLab::Web.app`) pulls in Sinatra, Falcon, and Phlex |
|
|
50
|
+
| **Endpoints** | `GET /`, `GET /robots/:name`, `POST /robots/:name/stream` (SSE), `POST /robots/:name/chat` (non-streaming HTMX fallback) |
|
|
51
|
+
| **Hook used** | `RobotLab::Web::StreamHook`, registered per-run (`hooks: [StreamHook]`), not globally |
|
|
52
|
+
|
|
53
|
+
## Links
|
|
54
|
+
|
|
55
|
+
- [RobotLab Core](https://github.com/MadBomber/robot_lab)
|
|
56
|
+
- [robot_lab-rails](https://github.com/MadBomber/robot_lab-rails) — the Rails/Turbo counterpart to this gem
|
|
57
|
+
- [RubyGems](https://rubygems.org/gems/robot_lab-web)
|
|
58
|
+
- [GitHub](https://github.com/MadBomber/robot_lab-web)
|
|
59
|
+
- [Changelog](https://github.com/MadBomber/robot_lab-web/blob/main/CHANGELOG.md)
|
data/docs/security.md
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Security
|
|
2
|
+
|
|
3
|
+
## The Posture, Stated Plainly
|
|
4
|
+
|
|
5
|
+
`robot_lab-web` is a **developer tool**, unauthenticated by default. There is no login, no user model, no access control of any kind on any route. Everything below hardens specific, real attack surfaces (CSRF, session-cookie theft, Host-header tricks, an accidentally-insecure production boot), but **none of it adds authentication**. Run this on `localhost` or a network you already trust; do not expose it to the public internet or to untrusted users on a shared network, regardless of how hardened the pieces below are individually.
|
|
6
|
+
|
|
7
|
+
## CSRF Protection
|
|
8
|
+
|
|
9
|
+
Every non-safe request (anything but `GET`/`HEAD`/`OPTIONS`) must carry a valid CSRF token, checked with a **constant-time comparison** (`Rack::Utils.secure_compare`) specifically to avoid a timing side-channel that could otherwise leak the correct token one byte at a time:
|
|
10
|
+
|
|
11
|
+
```ruby
|
|
12
|
+
before do
|
|
13
|
+
session[:csrf] ||= SecureRandom.hex(32)
|
|
14
|
+
next if CSRF_SAFE_METHODS.include?(request.request_method)
|
|
15
|
+
|
|
16
|
+
provided = params['authenticity_token'] || request.env['HTTP_X_CSRF_TOKEN']
|
|
17
|
+
halt 403, 'Forbidden: invalid CSRF token' unless provided && Rack::Utils.secure_compare(provided, session[:csrf])
|
|
18
|
+
end
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
The token is accepted from either a form field (`authenticity_token`) or a request header (`X-CSRF-Token`) — the latter is what the streaming chat client actually uses, since its request body is a URL-encoded `message=...` string, not a form submission carrying a hidden field. The page embeds the token in a `<meta name="csrf-token">` tag; the client JS reads it from there.
|
|
22
|
+
|
|
23
|
+
**This is also why the streaming route can't be a plain browser `EventSource`** — `EventSource` is GET-only and can't attach custom headers, so there'd be no way to present a CSRF token on that connection at all. See [How It Works — The SSE Wire Protocol](how_it_works.md#the-sse-wire-protocol) for the `fetch()`-based client this constraint leads to instead.
|
|
24
|
+
|
|
25
|
+
## Session Secret Persistence
|
|
26
|
+
|
|
27
|
+
Sinatra's encrypted cookie session store needs a stable secret — if the secret changes between requests, every existing session cookie becomes invalid (Sinatra's own "HMAC is invalid" error) and users get silently logged out (or, here, lose their CSRF token) on every restart. `App.session_secret_source` resolves the secret in priority order:
|
|
28
|
+
|
|
29
|
+
1. `ENV['SESSION_SECRET']`, if set
|
|
30
|
+
2. A **persisted** per-installation random secret, generated once and written to `~/.config/robot_lab/web_session_secret` (mode `0600`) — read back on every subsequent boot instead of regenerating
|
|
31
|
+
|
|
32
|
+
```ruby
|
|
33
|
+
def self.persisted_dev_secret
|
|
34
|
+
path = File.join(Dir.home, '.config', 'robot_lab', 'web_session_secret')
|
|
35
|
+
existing = File.read(path).strip if File.exist?(path)
|
|
36
|
+
return existing if existing && !existing.empty?
|
|
37
|
+
|
|
38
|
+
SecureRandom.hex(64).tap { |secret| File.write(path, secret); File.chmod(0o600, path) }
|
|
39
|
+
end
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
The resolved secret is then **hashed** (`OpenSSL::Digest::SHA256.hexdigest(...)`) before being handed to Sinatra as `session_secret` — Sinatra's encrypted-cookie store errors on a secret that's too short or an odd length, and hashing to a fixed 64-hex-char digest makes *any* input value (a short `SESSION_SECRET`, an oddly-sized one) work deterministically, without validating the input's shape yourself.
|
|
43
|
+
|
|
44
|
+
## Cookie Flags
|
|
45
|
+
|
|
46
|
+
```ruby
|
|
47
|
+
set :sessions, httponly: true, same_site: :lax, secure: production?
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
`httponly: true` — the session cookie is inaccessible to JavaScript (mitigates cookie theft via an XSS bug). `same_site: :lax` — the cookie isn't sent on most cross-site requests (mitigates CSRF further, on top of the explicit token check above). `secure: production?` — the cookie is marked HTTPS-only when running in a Sinatra `production` environment; left off in development, where a local Falcon instance typically isn't behind TLS at all.
|
|
51
|
+
|
|
52
|
+
## Production Boot Guard
|
|
53
|
+
|
|
54
|
+
```ruby
|
|
55
|
+
configure :production do
|
|
56
|
+
unless ENV['SESSION_SECRET']
|
|
57
|
+
raise 'SESSION_SECRET must be set in production; a per-process random ' \
|
|
58
|
+
'fallback breaks sessions and CSRF across restarts and workers.'
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
In a Sinatra `production` environment specifically, the persisted-random-secret fallback is refused outright — the app raises at boot rather than starting with a secret that (in a real production deployment, likely running multiple worker processes) would differ across those workers and break sessions/CSRF unpredictably between them. This forces an explicit `SESSION_SECRET` in that one environment; development and test both still get the persisted-file fallback.
|
|
64
|
+
|
|
65
|
+
## Host Authorization
|
|
66
|
+
|
|
67
|
+
```ruby
|
|
68
|
+
set :host_authorization, { permitted_hosts: [] }
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
Sinatra 4 (via `rack-protection`) enables Host-header authorization by default, 403-ing any request whose `Host` header isn't on an explicit allowlist — a real protection against DNS-rebinding-style attacks in a typical web app. This console explicitly disables that check (`permitted_hosts: []` means "permit all") because, as a developer tool, it's reached via whatever host the operator chooses to bind (`localhost`, a LAN IP, a hostname on a private network) and there's no fixed "correct" host to allowlist ahead of time. This is a deliberate trade-off specific to this tool's use case, not an oversight — but it does mean Host-header-based attacks that this default would otherwise have blocked are back in play, which is one more reason this console belongs on a trusted network only.
|
|
72
|
+
|
|
73
|
+
## What This Doesn't Protect Against
|
|
74
|
+
|
|
75
|
+
To be explicit about the boundary: none of the above prevents **any** visitor who can reach the console's port from listing robots, reading recent activity, or driving a full chat run against any registered robot (including whatever tools that robot has access to). If a robot registered here has a tool that can read files, run shell commands, or call external APIs with real credentials, anyone who can reach this console can exercise all of that. Treat the console's network reachability itself as the actual access-control boundary, not anything documented above.
|
data/examples/boot.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Example boot file for robot_lab-web.
|
|
4
|
+
#
|
|
5
|
+
# robot_lab-web examples/boot.rb
|
|
6
|
+
# # or
|
|
7
|
+
# ROBOT_LAB_WEB_BOOT=examples/boot.rb rackup
|
|
8
|
+
#
|
|
9
|
+
# Build whatever robots you want and register each one. The web console reads
|
|
10
|
+
# the registry to list and drive them. Actually running a robot requires the
|
|
11
|
+
# provider API key for its model (e.g. ANTHROPIC_API_KEY).
|
|
12
|
+
|
|
13
|
+
require "robot_lab"
|
|
14
|
+
require "robot_lab/web"
|
|
15
|
+
|
|
16
|
+
assistant = RobotLab.build(
|
|
17
|
+
name: "assistant",
|
|
18
|
+
system_prompt: "You are a concise, friendly assistant."
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
RobotLab::Web.register(assistant)
|
|
22
|
+
|
|
23
|
+
# Register as many as you like:
|
|
24
|
+
# RobotLab::Web.register(RobotLab.build(name: "support", template: :support))
|
data/exe/robot_lab-web
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
# Launch the robot_lab-web console with Falcon (async/fiber reactor — the right
|
|
5
|
+
# fit for the long-lived SSE streams this console serves).
|
|
6
|
+
#
|
|
7
|
+
# robot_lab-web [--port 9292] [--host 127.0.0.1] [boot_file.rb]
|
|
8
|
+
#
|
|
9
|
+
# boot_file.rb (or $ROBOT_LAB_WEB_BOOT) should require robot_lab, build your
|
|
10
|
+
# robots, and RobotLab::Web.register each one.
|
|
11
|
+
|
|
12
|
+
# Set up the bundle when run from a source checkout (a Gemfile sits beside us),
|
|
13
|
+
# so the gem's own lib and its dependencies — including the local robot_lab path
|
|
14
|
+
# dependency in development — resolve. Run this from the gem directory (or via
|
|
15
|
+
# `bundle exec`); the loki bundler plugin resolves Gemfile.local relative to the
|
|
16
|
+
# working directory. An installed gem has no Gemfile here and uses RubyGems.
|
|
17
|
+
require 'bundler/setup' if File.exist?(File.expand_path('../Gemfile', __dir__))
|
|
18
|
+
|
|
19
|
+
require 'optparse'
|
|
20
|
+
require 'robot_lab/web'
|
|
21
|
+
|
|
22
|
+
options = { port: 9292, host: '127.0.0.1' }
|
|
23
|
+
OptionParser.new do |o|
|
|
24
|
+
o.banner = 'Usage: robot_lab-web [options] [boot_file.rb]'
|
|
25
|
+
o.on('-p', '--port PORT', Integer, 'Port (default 9292)') { |v| options[:port] = v }
|
|
26
|
+
o.on('-h', '--host HOST', 'Bind host (default 127.0.0.1)') { |v| options[:host] = v }
|
|
27
|
+
end.parse!
|
|
28
|
+
|
|
29
|
+
boot = ARGV.shift || ENV.fetch('ROBOT_LAB_WEB_BOOT', nil)
|
|
30
|
+
require File.expand_path(boot) if boot && File.exist?(boot)
|
|
31
|
+
|
|
32
|
+
require 'async'
|
|
33
|
+
require 'async/http/endpoint'
|
|
34
|
+
require 'falcon/server'
|
|
35
|
+
|
|
36
|
+
rack_app = RobotLab::Web.app
|
|
37
|
+
endpoint = Async::HTTP::Endpoint.parse("http://#{options[:host]}:#{options[:port]}")
|
|
38
|
+
|
|
39
|
+
warn "robot_lab-web on http://#{options[:host]}:#{options[:port]} (Falcon) " \
|
|
40
|
+
"(#{RobotLab::Web::Registry.names.size} robot(s) registered)"
|
|
41
|
+
|
|
42
|
+
# A single fiber reactor: one process with cheap fiber-per-connection
|
|
43
|
+
# concurrency, so the dashboard's in-memory state (Registry, ActivityLog) stays
|
|
44
|
+
# consistent while many SSE streams run at once. Ctrl-C to stop.
|
|
45
|
+
#
|
|
46
|
+
# Server#run wraps itself in `Async { ... }` and blocks on the accept loop, so
|
|
47
|
+
# calling it at the top level starts the reactor and serves until interrupted.
|
|
48
|
+
server = Falcon::Server.new(Falcon::Server.middleware(rack_app), endpoint)
|
|
49
|
+
server.run
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'time'
|
|
4
|
+
|
|
5
|
+
module RobotLab
|
|
6
|
+
module Web
|
|
7
|
+
# Thread-safe, bounded, in-memory ring buffer for a dashboard "recent
|
|
8
|
+
# activity" feed.
|
|
9
|
+
#
|
|
10
|
+
# The cardinal rule, enforced by .safe_log: instrumentation must never
|
|
11
|
+
# break the request that triggered it.
|
|
12
|
+
class ActivityLog
|
|
13
|
+
MAX_EVENTS = 50
|
|
14
|
+
|
|
15
|
+
class << self
|
|
16
|
+
def instance
|
|
17
|
+
@instance ||= new
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def log(type, details = {})
|
|
21
|
+
instance.log(type, details)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# Record an event, swallowing any error.
|
|
25
|
+
def safe_log(type, details = {})
|
|
26
|
+
log(type, details)
|
|
27
|
+
rescue StandardError
|
|
28
|
+
nil
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def recent(limit = 10)
|
|
32
|
+
instance.recent(limit)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def clear
|
|
36
|
+
instance.clear
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def initialize
|
|
41
|
+
@events = []
|
|
42
|
+
@mutex = Mutex.new
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def log(type, details = {})
|
|
46
|
+
@mutex.synchronize do
|
|
47
|
+
@events.unshift(type: type.to_sym, details: details, timestamp: Time.now.utc)
|
|
48
|
+
@events = @events.first(MAX_EVENTS)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def recent(limit = 10)
|
|
53
|
+
@mutex.synchronize { @events.first(limit) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def clear
|
|
57
|
+
@mutex.synchronize { @events = [] }
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'openssl'
|
|
4
|
+
require 'securerandom'
|
|
5
|
+
require 'json'
|
|
6
|
+
require 'sinatra/base'
|
|
7
|
+
require 'phlex-sinatra'
|
|
8
|
+
require 'phlex-icons-hero'
|
|
9
|
+
require 'rack/utils'
|
|
10
|
+
|
|
11
|
+
require_relative '../web'
|
|
12
|
+
require_relative 'components/layout'
|
|
13
|
+
require_relative 'components/dashboard'
|
|
14
|
+
require_relative 'components/chat'
|
|
15
|
+
require_relative 'components/message'
|
|
16
|
+
require_relative 'components/error_page'
|
|
17
|
+
|
|
18
|
+
module RobotLab
|
|
19
|
+
module Web
|
|
20
|
+
# Sinatra console for driving robot_lab robots from the browser and
|
|
21
|
+
# streaming each run over Server-Sent Events.
|
|
22
|
+
#
|
|
23
|
+
# This is a DEVELOPER TOOL, unauthenticated by default — run it on
|
|
24
|
+
# localhost or a trusted network. The security touches below (CSRF with
|
|
25
|
+
# constant-time compare, hashed session secret, cookie flags, production
|
|
26
|
+
# boot guard) harden the surface but do not make it safe to expose publicly.
|
|
27
|
+
class App < Sinatra::Base
|
|
28
|
+
# phlex-sinatra registers its `phlex` helper on the classic
|
|
29
|
+
# Sinatra::Application; a modular Sinatra::Base app must include it.
|
|
30
|
+
helpers Phlex::Sinatra
|
|
31
|
+
|
|
32
|
+
# Stable session secret. Uses SESSION_SECRET when set; otherwise persists a
|
|
33
|
+
# per-user random secret so it survives restarts. A fresh random secret on
|
|
34
|
+
# every boot would invalidate existing session cookies ("HMAC is invalid").
|
|
35
|
+
def self.session_secret_source
|
|
36
|
+
ENV['SESSION_SECRET'] || persisted_dev_secret
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def self.persisted_dev_secret
|
|
40
|
+
path = File.join(Dir.home, '.config', 'robot_lab', 'web_session_secret')
|
|
41
|
+
existing = File.read(path).strip if File.exist?(path)
|
|
42
|
+
return existing if existing && !existing.empty?
|
|
43
|
+
|
|
44
|
+
require 'fileutils'
|
|
45
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
46
|
+
SecureRandom.hex(64).tap do |secret|
|
|
47
|
+
File.write(path, secret)
|
|
48
|
+
File.chmod(0o600, path)
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
configure do
|
|
53
|
+
# Sinatra 4 / rack-protection enable Host authorization by default,
|
|
54
|
+
# which 403s any Host header not explicitly permitted. This dev tool is
|
|
55
|
+
# reached via localhost or whatever host the operator binds — permit all.
|
|
56
|
+
set :host_authorization, { permitted_hosts: [] }
|
|
57
|
+
set :sessions, httponly: true, same_site: :lax, secure: production?
|
|
58
|
+
|
|
59
|
+
# Hash to a 64-hex key — the encrypted-cookie store errors on short/odd
|
|
60
|
+
# secrets; hashing makes any value work deterministically.
|
|
61
|
+
set :session_secret, OpenSSL::Digest::SHA256.hexdigest(session_secret_source)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
configure :production do
|
|
65
|
+
unless ENV['SESSION_SECRET']
|
|
66
|
+
raise 'SESSION_SECRET must be set in production; a per-process random ' \
|
|
67
|
+
'fallback breaks sessions and CSRF across restarts and workers.'
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
CSRF_SAFE_METHODS = %w[GET HEAD OPTIONS].freeze
|
|
72
|
+
|
|
73
|
+
before do
|
|
74
|
+
session[:csrf] ||= SecureRandom.hex(32)
|
|
75
|
+
next if CSRF_SAFE_METHODS.include?(request.request_method)
|
|
76
|
+
|
|
77
|
+
provided = params['authenticity_token'] || request.env['HTTP_X_CSRF_TOKEN']
|
|
78
|
+
halt 403, 'Forbidden: invalid CSRF token' unless provided &&
|
|
79
|
+
Rack::Utils.secure_compare(provided, session[:csrf])
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
helpers do
|
|
83
|
+
def csrf_token
|
|
84
|
+
session[:csrf]
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Render a page component wrapped in the Layout (HTML doc shell).
|
|
88
|
+
def page(content)
|
|
89
|
+
phlex Components::Layout.new(csrf: csrf_token, content: content)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def robot_or_not_found(name)
|
|
93
|
+
Registry.fetch(name) ||
|
|
94
|
+
halt(404, page(Components::ErrorPage.new(message: "No robot named #{name.inspect}")))
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Render one transcript message (Event) to an HTML fragment.
|
|
98
|
+
def message_html(event)
|
|
99
|
+
Components::Message.new(event: event).call
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# --- Dashboard -------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
get '/' do
|
|
106
|
+
page Components::Dashboard.new(robots: Registry.names, activity: ActivityLog.recent(15))
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
get '/robots/:name' do
|
|
110
|
+
robot_or_not_found(params[:name])
|
|
111
|
+
page Components::Chat.new(name: params[:name])
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# --- Streaming run (SSE) ---------------------------------------------
|
|
115
|
+
#
|
|
116
|
+
# Each lifecycle Event is pushed the instant it happens; the run ends with
|
|
117
|
+
# a `done` frame (or `error`). Consume with fetch() streaming — the route
|
|
118
|
+
# is CSRF-protected, so EventSource (GET-only, no headers) can't be used.
|
|
119
|
+
post '/robots/:name/stream' do
|
|
120
|
+
robot = robot_or_not_found(params[:name])
|
|
121
|
+
message = params['message'].to_s
|
|
122
|
+
|
|
123
|
+
content_type 'text/event-stream'
|
|
124
|
+
headers 'Cache-Control' => 'no-cache', 'X-Accel-Buffering' => 'no'
|
|
125
|
+
|
|
126
|
+
stream do |out|
|
|
127
|
+
write_sse = ->(type, data) { out << "event: #{type}\ndata: #{JSON.generate(data)}\n\n" }
|
|
128
|
+
|
|
129
|
+
if message.empty?
|
|
130
|
+
write_sse.call('error', { message: "Missing 'message'." })
|
|
131
|
+
else
|
|
132
|
+
begin
|
|
133
|
+
result = RobotLab::Web.run(robot, message) { |event| write_sse.call('message', event.to_h) }
|
|
134
|
+
write_sse.call('done', { reply: final_text(result) })
|
|
135
|
+
rescue StandardError => e
|
|
136
|
+
write_sse.call('error', { message: e.message })
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# --- Non-streaming fallback (HTMX append) — resilient default -------
|
|
143
|
+
#
|
|
144
|
+
# Works without JS streaming: run to completion, collect events, return
|
|
145
|
+
# the rendered transcript fragments for `hx-swap="beforeend"`.
|
|
146
|
+
post '/robots/:name/chat' do
|
|
147
|
+
robot = robot_or_not_found(params[:name])
|
|
148
|
+
message = params['message'].to_s
|
|
149
|
+
halt 400, 'Missing message' if message.empty?
|
|
150
|
+
|
|
151
|
+
events = []
|
|
152
|
+
RobotLab::Web.run(robot, message) { |event| events << event }
|
|
153
|
+
content_type :html
|
|
154
|
+
# Drop :delta events — the final :robot event already carries the full
|
|
155
|
+
# reply, so the non-streaming transcript shows it once.
|
|
156
|
+
events.reject { |event| event.role == :delta }
|
|
157
|
+
.map { |event| message_html(event) }.join("\n")
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
helpers do
|
|
161
|
+
def final_text(result)
|
|
162
|
+
return result.reply if result.respond_to?(:reply)
|
|
163
|
+
return result.last_text_content if result.respond_to?(:last_text_content)
|
|
164
|
+
|
|
165
|
+
result.to_s
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
end
|