@abstractframework/observer 0.1.14 → 0.2.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 CHANGED
@@ -18,7 +18,7 @@ The **entity app** (memory graph + visits UI) is its own package,
18
18
  *watches* entities — the Board's entities strip reads
19
19
  `GET /api/gateway/entities` + `/entities/{name}/card` — and its
20
20
  "Entities ↗" links point at the entity app deployment
21
- (`ABSTRACTOBSERVER_ENTITY_APP_URL`, default `http://127.0.0.1:3007`).
21
+ (`--entity-app-url`, default `http://127.0.0.1:3007`).
22
22
 
23
23
  ## Where it fits (AbstractFramework ecosystem)
24
24
  AbstractObserver is one of the browser UIs in the **AbstractFramework** ecosystem:
@@ -72,17 +72,31 @@ abstractobserver
72
72
 
73
73
  ### Pin a version (recommended for deployments)
74
74
  ```bash
75
- npx --yes --package @abstractframework/observer@0.1.14 -- abstractobserver
75
+ npx --yes --package @abstractframework/observer@0.2.0 -- abstractobserver
76
76
  ```
77
77
 
78
78
  ### CLI configuration
79
79
  The CLI (`bin/cli.js`) serves the built UI and mounts the app-origin Gateway
80
80
  session proxy from `@abstractframework/app-server` (a runtime dependency of
81
81
  this package, installed automatically).
82
- - `PORT` (default `3001`)
83
- - `HOST` (default `0.0.0.0`)
84
- - `ABSTRACTOBSERVER_GATEWAY_URL` (or `ABSTRACTGATEWAY_URL`) — the Gateway this server signs in to and proxies `/api` to (default `http://127.0.0.1:8080`)
85
- - `--monitor-gpu` or `ABSTRACTOBSERVER_MONITOR_GPU=on` (enables the optional GPU widget)
82
+ - `--gateway-url <url>` (aliases `--gateway`, `--url`) — the Gateway this server
83
+ signs in to and proxies `/api` to. Default: the gateway installed on this
84
+ computer (`~/.abstractframework/gateway.json`, followed when it moves port),
85
+ else `http://127.0.0.1:8080`.
86
+ - `--port <n>` (default `3001`) and `--host <addr>` (default `127.0.0.1`)
87
+ - `--monitor-gpu` — the optional GPU widget
88
+ - `--entity-app-url <url>` — where the entity app lives (the "Entities ↗" links)
89
+ - `--gateway-dir <dir>` — base for relative run workspace paths (local folder reveal)
90
+
91
+ Environment variables are legacy aliases below the flags: `PORT`, `HOST`,
92
+ `ABSTRACTOBSERVER_GATEWAY_URL`, `ABSTRACTGATEWAY_URL`,
93
+ `ABSTRACTOBSERVER_MONITOR_GPU`, `ABSTRACTOBSERVER_ENTITY_APP_URL`,
94
+ `ABSTRACTOBSERVER_GATEWAY_DIR`.
95
+
96
+ The gateway can also serve the Observer through itself at `/apps/observer/`
97
+ (one port and one address for the console, the API and the apps, e.g. on a
98
+ remote server): the gateway starts this server on `127.0.0.1` and relays to
99
+ it. `abstractobserver --help` lists the flags.
86
100
 
87
101
  See `docs/configuration.md` for the full list.
88
102
 
@@ -90,10 +104,11 @@ See `docs/configuration.md` for the full list.
90
104
  All pages share the same gateway connection settings.
91
105
 
92
106
  - **Board** (Mission Control, the landing page): kanban columns Pending / Working / Review / Done across every run — cards move themselves by state; the Review column carries inline Approve/Deny/Answer; an entities strip shows each entity's phase and links into the entity app; run lists poll live (5s visible / 30s hidden)
93
- - **Observe**: workflow/subworkflow navigator, overview, human timeline, raw ledger, provider calls, graph, digest, attachments, chat (optional voice: PTT + TTS)
107
+ - **Observe**: workflow/subworkflow navigator, overview, human timeline, raw ledger, provider calls, graph, digest, attachments, **Ask** about the run (the whole conversation is sent; a note says when the gateway's 50,000-token window left older messages out; optional voice: PTT + TTS)
108
+ - **Assistant** (top bar): questions about the Observer itself, from its docs; one gateway session per conversation (**New conversation** starts fresh), no tools
94
109
  - **System** (the Runtime view): Activity, Artifacts, Memory, and Logs modes for platform-level monitoring. The Artifact Explorer uses Gateway artifact envelopes and exact stats, separates Voice/Music/Sound/unclassified audio from render kinds such as Markdown/HTML/JSON, previews media inline, and links artifacts back to producing runs, ledgers, and trace/audit actions when metadata is available
95
110
  - **Launch**: Run once, or Automate (what, when in UTC intervals, independent or growing context, tool approval); bundle upload/reload
96
- - **Automations**: every automation of the signed-in user, with pause/resume/run now (also while paused)/stop current/revise/archive, its runs read as a conversation, answers to waiting runs by kind (question, tool approval, event), and Discuss (a fork at an occurrence with its full history, its own workspace and the automation's files read-only); legacy schedules keep their controls and can be recreated as automations (`docs/automations.md`)
111
+ - **Automations**: every automation of the signed-in user, with pause/resume/run now (also while paused)/stop current/edit (task, interval, context, tools — the row's Edit opens the form at once)/archive, its runs read as a conversation, answers to waiting runs by kind (question, tool approval, event), its folder browsable from the browser (open or download through the gateway), and Discuss (a chat, on the same page, with a fork at an occurrence: its full history, its own workspace and the automation's files read-only); legacy schedules keep their controls and can be recreated as automations (`docs/automations.md`)
97
112
  - **System → Memory**: knowledge-graph (active memory) query UI (requires `POST /api/gateway/kg/query`)
98
113
  - **Settings**: connection status, theme and display preferences, optional remote tool worker
99
114
  - **About** (the info button in the top bar): the AbstractObserver version, links to its website, source, documentation and issue tracker, and the versions the connected gateway reports
package/bin/cli.js CHANGED
@@ -1,275 +1,65 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  /**
4
- * CLI entry point for AbstractObserver
5
- * Serves the built web application on a configurable port
4
+ * AbstractObserver: serves the built app. Standalone at http://<host>:<port>/,
5
+ * or through the gateway at /apps/observer/ (the gateway relays to this
6
+ * server on 127.0.0.1; see bin/server.js).
7
+ *
8
+ * abstractobserver [--gateway-url <url>] [--port <n>] [--host <addr>]
9
+ * [--monitor-gpu] [--entity-app-url <url>] [--gateway-dir <dir>]
10
+ *
11
+ * Environment variables are legacy aliases below the flags (PORT, HOST,
12
+ * ABSTRACTOBSERVER_GATEWAY_URL, ABSTRACTGATEWAY_URL, ABSTRACTOBSERVER_MONITOR_GPU,
13
+ * ABSTRACTOBSERVER_ENTITY_APP_URL, ABSTRACTOBSERVER_GATEWAY_DIR).
6
14
  */
7
15
 
8
- import * as http from 'http';
9
- import { readFileSync, existsSync, statSync } from 'fs';
10
- import { join, extname, dirname, resolve, isAbsolute } from 'path';
11
- import { fileURLToPath } from 'url';
12
- import { spawn } from 'child_process';
13
- import { createGatewaySessionProxy } from '@abstractframework/app-server';
14
-
15
- const __dirname = dirname(fileURLToPath(import.meta.url));
16
- const DIST_DIR = join(__dirname, '..', 'dist');
17
- const PORT = process.env.PORT || 3001;
18
- const HOST = process.env.HOST || '0.0.0.0';
19
- // ONE app since the 2026-07-12 split: the entity view moved to its own
20
- // package (abstractentity, :3007). The ABSTRACTOBSERVER_LANDING knob died
21
- // with it — this server always lands on index.html.
22
- const DEFAULT_GATEWAY_URL = String(process.env.ABSTRACTOBSERVER_GATEWAY_URL || process.env.ABSTRACTGATEWAY_URL || 'http://127.0.0.1:8080').trim().replace(/\/+$/, '') || 'http://127.0.0.1:8080';
23
- // Where the ENTITY app lives — drives the "Entities ↗" links in the UI.
24
- // Normalized to origin+path (query/hash dropped): both consumers append
25
- // their own query (`/entity.html` redirect here, deep links in the UI),
26
- // so a configured value carrying `?` would produce malformed URLs.
27
- const ENTITY_APP_URL = (() => {
28
- const raw = String(process.env.ABSTRACTOBSERVER_ENTITY_APP_URL || '').trim();
29
- if (!raw) return '';
30
- try {
31
- const u = new URL(raw);
32
- return `${u.origin}${u.pathname}`.replace(/\/+$/, '');
33
- } catch {
34
- return raw.split(/[?#]/)[0].replace(/\/+$/, '');
35
- }
36
- })();
37
- const ARGV = process.argv.slice(2);
38
- const MONITOR_GPU =
39
- ARGV.includes("--monitor-gpu") ||
40
- ["1", "true", "yes", "on"].includes(String(process.env.ABSTRACTOBSERVER_MONITOR_GPU || "").trim().toLowerCase());
41
-
42
- // MIME types for common file extensions
43
- const MIME_TYPES = {
44
- '.html': 'text/html',
45
- '.js': 'application/javascript',
46
- '.css': 'text/css',
47
- '.json': 'application/json',
48
- '.png': 'image/png',
49
- '.jpg': 'image/jpeg',
50
- '.jpeg': 'image/jpeg',
51
- '.gif': 'image/gif',
52
- '.svg': 'image/svg+xml',
53
- '.ico': 'image/x-icon',
54
- '.webmanifest': 'application/manifest+json',
55
- };
56
-
57
- function getMimeType(filePath) {
58
- const ext = extname(filePath).toLowerCase();
59
- return MIME_TYPES[ext] || 'application/octet-stream';
60
- }
61
-
62
- function inject_config_html(html) {
63
- const ui_config = {};
64
- if (MONITOR_GPU) ui_config.monitor_gpu = true;
65
- // THIS deployment's gateway (maintainer incident 2026-07-09: the sign-in
66
- // card showed a retired gateway's URL) — the UI's connect/sign-in surfaces
67
- // default to it instead of any hardcoded historical port.
68
- if (DEFAULT_GATEWAY_URL) ui_config.gateway_url = DEFAULT_GATEWAY_URL;
69
- if (ENTITY_APP_URL) ui_config.entity_app_url = ENTITY_APP_URL;
70
- if (!Object.keys(ui_config).length) return html;
71
- const marker = "window.__ABSTRACT_UI_CONFIG__";
72
- if (html.includes(marker)) return html;
73
- const snippet = `<script>${marker}=Object.assign(${marker}||{}, ${JSON.stringify(ui_config)});</script>`;
74
- if (html.includes("</head>")) return html.replace("</head>", `${snippet}\n</head>`);
75
- if (html.includes("</body>")) return html.replace("</body>", `${snippet}\n</body>`);
76
- return `${html}\n${snippet}\n`;
77
- }
78
-
79
- function serveFile(res, filePath) {
80
- try {
81
- if (!existsSync(filePath) || !statSync(filePath).isFile()) {
82
- return false;
83
- }
84
- const mimeType = getMimeType(filePath);
85
-
86
- if (mimeType === "text/html") {
87
- const html = readFileSync(filePath, "utf8");
88
- const content = inject_config_html(html);
89
- res.writeHead(200, {
90
- "Content-Type": `${mimeType}; charset=utf-8`,
91
- "Cache-Control": "no-cache",
92
- });
93
- res.end(content);
94
- return true;
95
- }
96
-
97
- const content = readFileSync(filePath);
98
- res.writeHead(200, {
99
- 'Content-Type': mimeType,
100
- 'Cache-Control': 'no-cache',
101
- });
102
- res.end(content);
103
- return true;
104
- } catch (err) {
105
- return false;
106
- }
107
- }
108
-
109
- // APP-ORIGIN GATEWAY SESSION PROXY - the shared @abstractframework/app-server
110
- // module (extracted from this file's hardened copy; one source across
111
- // observer/flow/abstractcode per the 2026-07-12 consolidation). Cookie names
112
- // (abstractobserver_gateway_*), the x-abstractobserver-csrf header, and the
113
- // ABSTRACTOBSERVER_* / ABSTRACTGATEWAY_* env gates are all derived from appId,
114
- // so existing sessions and deployment security posture survive the swap.
115
- const gatewaySessionProxy = createGatewaySessionProxy({
116
- appId: 'abstractobserver',
117
- defaultGatewayUrl: DEFAULT_GATEWAY_URL,
118
- });
119
-
120
- // ── LOCAL FOLDER REVEAL (operator 2026-07-15: a folder button on the run
121
- // opens its workspace). Only meaningful when this server runs on the SAME
122
- // machine as the gateway (the local-first posture). Gateway workspace roots
123
- // are often RELATIVE to the gateway process cwd — resolve against
124
- // ABSTRACTOBSERVER_GATEWAY_DIR (default: the workspace parent this repo
125
- // lives in, matching the launcher layout).
126
- const GATEWAY_DIR = String(process.env.ABSTRACTOBSERVER_GATEWAY_DIR || join(__dirname, '..', '..')).trim();
127
-
128
- function resolveWorkspacePath(raw) {
129
- const p = String(raw || '').trim();
130
- if (!p || p.includes('\0')) return null;
131
- const abs = isAbsolute(p) ? p : resolve(GATEWAY_DIR, p);
132
- try {
133
- if (existsSync(abs) && statSync(abs).isDirectory()) return abs;
134
- } catch {}
135
- return null;
136
- }
137
-
138
- function handleRevealRequest(req, res, pathname) {
139
- if (pathname !== '/api/local/reveal' || req.method !== 'POST') return false;
140
- // Loopback only: revealing Finder windows on a remotely-served observer
141
- // is someone else's desktop. The socket peer is the truth (Host spoofable).
142
- const peer = String(req.socket?.remoteAddress || '');
143
- const loopback = peer === '127.0.0.1' || peer === '::1' || peer === '::ffff:127.0.0.1';
144
- if (!loopback) {
145
- res.writeHead(403, { 'Content-Type': 'application/json' });
146
- res.end(JSON.stringify({ ok: false, error: 'Folder reveal is only available on the machine running the observer.' }));
147
- return true;
148
- }
149
- let body = '';
150
- req.on('data', (c) => { body += c; if (body.length > 8192) req.destroy(); });
151
- req.on('end', () => {
152
- let path = '';
153
- try { path = String(JSON.parse(body || '{}').path || ''); } catch {}
154
- const abs = resolveWorkspacePath(path);
155
- if (!abs) {
156
- res.writeHead(404, { 'Content-Type': 'application/json' });
157
- res.end(JSON.stringify({ ok: false, error: `Workspace folder not found on this machine: ${path || '(empty)'}` }));
158
- return;
159
- }
160
- const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
161
- try {
162
- // `open <dir>` reveals a folder; never a shell, never arbitrary exec.
163
- spawn(cmd, [abs], { stdio: 'ignore', detached: true }).unref();
164
- res.writeHead(200, { 'Content-Type': 'application/json' });
165
- res.end(JSON.stringify({ ok: true, path: abs }));
166
- } catch (e) {
167
- res.writeHead(500, { 'Content-Type': 'application/json' });
168
- res.end(JSON.stringify({ ok: false, error: String(e && e.message || e) }));
169
- }
170
- });
171
- return true;
172
- }
173
-
174
- const server = http.createServer((req, res) => {
175
- // Remove query strings and normalize path
176
- const url = new URL(req.url, `http://${req.headers.host}`);
177
- let pathname = url.pathname;
178
-
179
- // Local-machine folder reveal (before the proxy: /api/local/* is OURS,
180
- // never forwarded to the gateway).
181
- if (handleRevealRequest(req, res, pathname)) {
182
- return;
183
- }
184
-
185
- // Connection endpoint + everything under /api/ (proxied to the gateway
186
- // with session cookies swapped for gateway headers).
187
- if (gatewaySessionProxy.handle(req, res, pathname)) {
188
- return;
189
- }
190
-
191
- // Security: prevent directory traversal
192
- if (pathname.includes('..')) {
193
- res.writeHead(400);
194
- res.end('Bad Request');
195
- return;
196
- }
197
-
198
- if (pathname === '/' || pathname === '') {
199
- pathname = '/index.html';
200
- }
201
-
202
- // Try to serve the requested file
203
- let filePath = join(DIST_DIR, pathname);
204
-
205
- if (serveFile(res, filePath)) {
206
- return;
207
- }
208
-
209
- // An EXPLICIT .html request that misses must fail loudly, never fall
210
- // through to the SPA fallback (maintainer incident 2026-07-09: the wrong
211
- // app wearing the right URL is worse than a 404). /entity.html was real
212
- // here before the 2026-07-12 split — send old bookmarks to the entity
213
- // app's own deployment when we know it, otherwise say where it went.
214
- if (pathname.endsWith('.html')) {
215
- if (pathname === '/entity.html' && ENTITY_APP_URL) {
216
- res.writeHead(302, { Location: `${ENTITY_APP_URL}/${url.search || ''}` });
217
- res.end();
218
- return;
219
- }
220
- res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
221
- res.end(
222
- pathname === '/entity.html'
223
- ? 'The entity app moved to its own package (abstractentity, default http://127.0.0.1:3007). '
224
- + 'Set ABSTRACTOBSERVER_ENTITY_APP_URL to make this a redirect.'
225
- : `${pathname} is not in this build (dist/). This server hosts the AbstractObserver app only.`
226
- );
227
- return;
228
- }
229
-
230
- // Try with .html extension
231
- if (serveFile(res, filePath + '.html')) {
232
- return;
233
- }
234
-
235
- // Try index.html in directory
236
- if (serveFile(res, join(filePath, 'index.html'))) {
237
- return;
238
- }
239
-
240
- // SPA fallback
241
- const indexPath = join(DIST_DIR, 'index.html');
242
- if (serveFile(res, indexPath)) {
243
- return;
244
- }
245
-
246
- // If nothing works, return 404
247
- res.writeHead(404);
248
- res.end('Not Found');
16
+ import * as http from "node:http";
17
+ import { dirname, join } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ import { createGatewayUrlResolver, parseAppFlagsOrExit } from "@abstractframework/app-server";
21
+
22
+ import { createObserverHandler } from "./server.js";
23
+
24
+ const HERE = dirname(fileURLToPath(import.meta.url));
25
+ const TRUE = ["1", "true", "yes", "on"];
26
+
27
+ const flags = parseAppFlagsOrExit(process.argv.slice(2), {
28
+ appName: "AbstractObserver",
29
+ command: "abstractobserver",
30
+ envPrefix: "ABSTRACTOBSERVER",
31
+ defaultPort: 3001,
32
+ extra: {
33
+ "monitor-gpu": { type: "boolean", help: "Show the gateway host's GPU monitor in the header." },
34
+ "entity-app-url": { type: "string", metavar: "url", help: "Where the entity app lives (the Entities ↗ links)." },
35
+ "gateway-dir": {
36
+ type: "string",
37
+ metavar: "dir",
38
+ help: "Folder that relative workspace roots are resolved against for the local folder reveal (default: this package's parent folder).",
39
+ },
40
+ },
249
41
  });
250
42
 
251
- server.listen(PORT, HOST, () => {
252
- console.log(`
253
- ╔════════════════════════════════════════════════════╗
254
- ║ AbstractObserver is running! ║
255
- ╚════════════════════════════════════════════════════╝
256
-
257
- 🌐 Local: http://localhost:${PORT}
258
- 🌐 Network: http://${HOST}:${PORT}
259
-
260
- 📡 Connect to your AbstractGateway instance
261
- 🚀 Start observing your workflows
262
-
263
- Press Ctrl+C to stop
264
- `);
43
+ // The gateway URL follows the local gateway pointer (a gateway that moves
44
+ // port is followed) unless a flag or a legacy variable chose it.
45
+ const gatewayUrl =
46
+ flags.gatewayUrlSource === "flag" || flags.gatewayUrlSource.startsWith("env:")
47
+ ? flags.gatewayUrl
48
+ : createGatewayUrlResolver({});
49
+
50
+ const handler = createObserverHandler({
51
+ distDir: join(HERE, "..", "dist"),
52
+ gatewayUrl,
53
+ monitorGpu: Boolean(flags.extra["monitor-gpu"]) || TRUE.includes(String(process.env.ABSTRACTOBSERVER_MONITOR_GPU || "").trim().toLowerCase()),
54
+ entityAppUrl: String(flags.extra["entity-app-url"] || process.env.ABSTRACTOBSERVER_ENTITY_APP_URL || ""),
55
+ gatewayDir: String(flags.extra["gateway-dir"] || process.env.ABSTRACTOBSERVER_GATEWAY_DIR || join(HERE, "..", "..")),
265
56
  });
266
57
 
267
- process.on('SIGINT', () => {
268
- console.log('\n\n👋 Shutting down AbstractObserver...\n');
269
- process.exit(0);
58
+ const server = http.createServer(handler);
59
+ server.listen(flags.port, flags.host, () => {
60
+ const gw = typeof gatewayUrl === "string" ? gatewayUrl : gatewayUrl.current();
61
+ console.log(`AbstractObserver on http://${flags.host}:${flags.port}/ (gateway ${gw}, ${flags.gatewayUrlSource})`);
62
+ console.log("Through the gateway: <gateway>/apps/observer/");
270
63
  });
271
64
 
272
- process.on('SIGTERM', () => {
273
- console.log('\n\n👋 Shutting down AbstractObserver...\n');
274
- process.exit(0);
275
- });
65
+ for (const signal of ["SIGINT", "SIGTERM"]) process.on(signal, () => process.exit(0));
package/bin/server.js ADDED
@@ -0,0 +1,222 @@
1
+ /**
2
+ * The AbstractObserver app server: serves the built app (dist/), the local
3
+ * folder reveal and the app-origin gateway session proxy, standalone at `/`
4
+ * or through the gateway at `/apps/observer/` (the app-server mount
5
+ * contract: @abstractframework/app-server README, "Serving under the gateway").
6
+ *
7
+ * - Every response announces `X-AbstractFramework-App: observer; mount=1`
8
+ * (`createMountedHandler`), so the gateway may serve it.
9
+ * - "Who is asking" is `ctx.clientAddress` / `ctx.clientIsLoopback`, never the
10
+ * socket peer (behind the gateway the socket peer is always loopback).
11
+ * - The page gets `<base href="<basePath>/">` and `base_path` in
12
+ * `window.__ABSTRACT_UI_CONFIG__` (`injectShell`); the app's URLs are
13
+ * relative, so they resolve under the base.
14
+ * - The session proxy sets its cookies at `Path=<basePath>/`.
15
+ */
16
+
17
+ import { timingSafeEqual } from "node:crypto";
18
+ import { existsSync, readFileSync, statSync } from "node:fs";
19
+ import { extname, isAbsolute, normalize, resolve, sep } from "node:path";
20
+ import { spawn as nodeSpawn } from "node:child_process";
21
+
22
+ import { createGatewaySessionProxy, createMountedHandler, injectShell } from "@abstractframework/app-server";
23
+
24
+ /** The gateway catalog id this app announces. */
25
+ export const APP_ID = "observer";
26
+ /** Cookie / CSRF prefix of the session proxy (kept: existing sessions survive). */
27
+ export const COOKIE_APP_ID = "abstractobserver";
28
+
29
+ const MIME_TYPES = {
30
+ ".html": "text/html",
31
+ ".js": "application/javascript",
32
+ ".css": "text/css",
33
+ ".json": "application/json",
34
+ ".png": "image/png",
35
+ ".jpg": "image/jpeg",
36
+ ".jpeg": "image/jpeg",
37
+ ".gif": "image/gif",
38
+ ".svg": "image/svg+xml",
39
+ ".ico": "image/x-icon",
40
+ ".webmanifest": "application/manifest+json",
41
+ };
42
+
43
+ function mimeType(filePath) {
44
+ return MIME_TYPES[extname(filePath).toLowerCase()] || "application/octet-stream";
45
+ }
46
+
47
+ function json(res, status, body) {
48
+ res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
49
+ res.end(JSON.stringify(body));
50
+ }
51
+
52
+ /** Origin + path of a configured app URL (query and hash dropped). */
53
+ export function normalizeAppUrl(raw) {
54
+ const value = String(raw || "").trim();
55
+ if (!value) return "";
56
+ try {
57
+ const u = new URL(value);
58
+ return `${u.origin}${u.pathname}`.replace(/\/+$/, "");
59
+ } catch {
60
+ return value.split(/[?#]/)[0].replace(/\/+$/, "");
61
+ }
62
+ }
63
+
64
+ /**
65
+ * @param {object} options
66
+ * @param {string} options.distDir the built app
67
+ * @param {string | {current(): string, refresh(): boolean}} options.gatewayUrl the server-pinned gateway
68
+ * @param {boolean} [options.monitorGpu]
69
+ * @param {string} [options.entityAppUrl] where the entity app lives (Entities ↗ links)
70
+ * @param {string} options.gatewayDir base for RELATIVE workspace roots (folder reveal)
71
+ * @param {Function} [options.spawn] child_process.spawn (tests inject a recorder)
72
+ * @returns {(req, res) => void} the request handler
73
+ */
74
+ export function createObserverHandler(options) {
75
+ const distDir = resolve(options.distDir);
76
+ const entityAppUrl = normalizeAppUrl(options.entityAppUrl);
77
+ const spawn = options.spawn || nodeSpawn;
78
+ const proxy = createGatewaySessionProxy({ appId: COOKIE_APP_ID, defaultGatewayUrl: options.gatewayUrl });
79
+
80
+ function uiConfig() {
81
+ const config = { gateway_url: proxy.defaultGatewayUrl };
82
+ if (options.monitorGpu) config.monitor_gpu = true;
83
+ if (entityAppUrl) config.entity_app_url = entityAppUrl;
84
+ return config;
85
+ }
86
+
87
+ /** A file under dist/, or null (never outside it). */
88
+ function distFile(pathname) {
89
+ const rel = normalize(decodeURIComponent(pathname)).replace(/^([/\\])+/, "");
90
+ const abs = resolve(distDir, rel);
91
+ if (abs !== distDir && !abs.startsWith(distDir + sep)) return null;
92
+ try {
93
+ return existsSync(abs) && statSync(abs).isFile() ? abs : null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ function serve(res, filePath, ctx) {
100
+ const type = mimeType(filePath);
101
+ if (type === "text/html") {
102
+ const page = injectShell(readFileSync(filePath, "utf8"), { basePath: ctx.basePath, config: uiConfig() });
103
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
104
+ res.end(page);
105
+ return;
106
+ }
107
+ res.writeHead(200, { "Content-Type": type, "Cache-Control": "no-cache" });
108
+ res.end(readFileSync(filePath));
109
+ }
110
+
111
+ function servePage(res, ctx) {
112
+ const index = distFile("index.html");
113
+ if (!index) return json(res, 500, { detail: "dist/index.html is missing: build the app (npm run build)." });
114
+ return serve(res, index, ctx);
115
+ }
116
+
117
+ function resolveWorkspacePath(raw) {
118
+ const p = String(raw || "").trim();
119
+ if (!p || p.includes("\0")) return null;
120
+ const abs = isAbsolute(p) ? p : resolve(options.gatewayDir, p);
121
+ try {
122
+ return existsSync(abs) && statSync(abs).isDirectory() ? abs : null;
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /**
129
+ * The rule of every mutating route of this origin (the session proxy's):
130
+ * the request presents the browser session's CSRF token in the app CSRF
131
+ * header (`X-AbstractObserver-CSRF`, or the canonical `X-Abstract-CSRF`),
132
+ * which a cross-site page can neither read nor send; and a request that
133
+ * names its `Origin` names this one. Returns the refusal, or null.
134
+ */
135
+ function crossSiteRefusal(req, ctx) {
136
+ const origin = String(req.headers.origin || "").trim();
137
+ if (origin && origin !== `${ctx.proto}://${ctx.host}`) return "Cross-site request refused.";
138
+ const expected = proxy.browserSession(req).csrfToken;
139
+ const presented = String(proxy.csrfHeaderNames.map((h) => req.headers[h]).find((v) => v) || "").trim();
140
+ const a = Buffer.from(presented, "utf8");
141
+ const b = Buffer.from(String(expected || ""), "utf8");
142
+ if (!expected || a.length !== b.length || !timingSafeEqual(a, b)) return "Browser session CSRF token missing or invalid.";
143
+ return null;
144
+ }
145
+
146
+ /** Open a run's folder on THIS machine: only for a browser on this machine, from this app's own page. */
147
+ function reveal(req, res, ctx) {
148
+ if (!ctx.clientIsLoopback) {
149
+ json(res, 403, { ok: false, error: "Folder reveal is only available on the machine running the observer." });
150
+ return;
151
+ }
152
+ const refused = crossSiteRefusal(req, ctx);
153
+ if (refused) {
154
+ json(res, 403, { ok: false, error: refused, reason_code: "csrf_required" });
155
+ return;
156
+ }
157
+ let body = "";
158
+ req.on("data", (c) => {
159
+ body += c;
160
+ if (body.length > 8192) req.destroy();
161
+ });
162
+ req.on("end", () => {
163
+ let path = "";
164
+ try {
165
+ path = String(JSON.parse(body || "{}").path || "");
166
+ } catch {
167
+ path = "";
168
+ }
169
+ const abs = resolveWorkspacePath(path);
170
+ if (!abs) {
171
+ json(res, 404, { ok: false, error: `Workspace folder not found on this machine: ${path || "(empty)"}` });
172
+ return;
173
+ }
174
+ try {
175
+ // `open <dir>` / `xdg-open <dir>`: never a shell, never arbitrary exec.
176
+ spawn(process.platform === "darwin" ? "open" : "xdg-open", [abs], { stdio: "ignore", detached: true }).unref();
177
+ json(res, 200, { ok: true, path: abs });
178
+ } catch (e) {
179
+ json(res, 500, { ok: false, error: String((e && e.message) || e) });
180
+ }
181
+ });
182
+ }
183
+
184
+ return createMountedHandler({ appId: APP_ID }, (req, res, ctx) => {
185
+ const url = new URL(req.url || "/", "http://observer.invalid");
186
+ const pathname = url.pathname;
187
+
188
+ // /api/local/* is OURS (never forwarded to the gateway).
189
+ if (pathname === "/api/local/reveal" && req.method === "POST") return reveal(req, res, ctx);
190
+ // Connection endpoint + everything under /api/ (session swapped for gateway credentials).
191
+ if (proxy.handle(req, res, pathname)) return;
192
+
193
+ if (pathname === "/" || pathname === "/index.html") return servePage(res, ctx);
194
+ const file = distFile(pathname);
195
+ if (file) return serve(res, file, ctx);
196
+
197
+ // An explicit .html that misses fails loudly, never the SPA fallback.
198
+ if (pathname.endsWith(".html")) {
199
+ if (pathname === "/entity.html" && entityAppUrl) {
200
+ res.writeHead(302, { Location: `${entityAppUrl}/${url.search || ""}` });
201
+ res.end();
202
+ return;
203
+ }
204
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
205
+ res.end(
206
+ pathname === "/entity.html"
207
+ ? "The entity app moved to its own package (abstractentity, default http://127.0.0.1:3007). Start the observer with --entity-app-url to make this a redirect."
208
+ : `${pathname} is not in this build (dist/). This server hosts the AbstractObserver app only.`
209
+ );
210
+ return;
211
+ }
212
+ // An asset that is not in the build is a 404, never the page.
213
+ if (extname(pathname)) {
214
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
215
+ res.end("Not Found");
216
+ return;
217
+ }
218
+ // SPA fallback (deep links such as /<run id>): the page, under the same base.
219
+ return servePage(res, ctx);
220
+ });
221
+ }
222
+