@abstractframework/observer 0.1.13 → 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 +30 -12
- package/bin/cli.js +54 -264
- package/bin/server.js +222 -0
- package/dist/assets/index-BYhozpg0.css +1 -0
- package/dist/assets/index-DL1LcmFy.js +138 -0
- package/dist/assets/index-DL1LcmFy.js.map +1 -0
- package/dist/index.html +5 -5
- package/dist/manifest.webmanifest +3 -2
- package/dist/sw.js +9 -4
- package/package.json +2 -2
- package/dist/assets/index-BHHnK0PW.css +0 -1
- package/dist/assets/index-BNRMqyx3.js +0 -140
- package/dist/assets/index-BNRMqyx3.js.map +0 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ Gateway-only observability UI (Web/PWA) for AbstractFramework runs.
|
|
|
4
4
|
|
|
5
5
|
What it does (implemented in `src/ui/app.tsx` + `src/lib/gateway_client.ts`):
|
|
6
6
|
- **Discover** workflows/bundles exposed by an AbstractGateway
|
|
7
|
-
- **Launch** or **
|
|
7
|
+
- **Launch** runs, or create **automations** that the gateway runs on a schedule (durable)
|
|
8
8
|
- **Observe** runs and subruns by replaying + streaming the durable **ledger** (replay-first + SSE)
|
|
9
9
|
- **Inspect runtime state** across active runs, generated artifacts, provider calls, and gateway audit logs
|
|
10
10
|
- **Control** runs via durable commands (`pause`, `resume`, `cancel`)
|
|
@@ -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
|
-
(
|
|
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:
|
|
@@ -31,7 +31,7 @@ flowchart LR
|
|
|
31
31
|
U[User] --> O[AbstractObserver<br/>browser UI]
|
|
32
32
|
O -->|HTTP fetch + SSE| G[AbstractGateway<br/>/api/gateway/*]
|
|
33
33
|
G --> R[AbstractRuntime<br/>durable runs + append-only ledger]
|
|
34
|
-
R --> C[AbstractCore<br/>LLM + tools (optional)]
|
|
34
|
+
R --> C["AbstractCore<br/>LLM + tools (optional)"]
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
## Quickstart (npm)
|
|
@@ -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.
|
|
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
|
-
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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,12 @@ 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,
|
|
94
|
-
- **
|
|
95
|
-
- **
|
|
96
|
-
- **
|
|
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
|
|
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
|
|
110
|
+
- **Launch**: Run once, or Automate (what, when in UTC intervals, independent or growing context, tool approval); bundle upload/reload
|
|
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`)
|
|
112
|
+
- **System → Memory**: knowledge-graph (active memory) query UI (requires `POST /api/gateway/kg/query`)
|
|
97
113
|
- **Settings**: connection status, theme and display preferences, optional remote tool worker
|
|
98
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
|
|
99
115
|
|
|
@@ -104,6 +120,8 @@ All pages share the same gateway connection settings.
|
|
|
104
120
|
- Architecture (with diagrams): `docs/architecture.md`
|
|
105
121
|
- Configuration & deployment: `docs/configuration.md`
|
|
106
122
|
- API (gateway endpoints used): `docs/api.md`
|
|
123
|
+
- Automations (create, manage, answer, discuss): `docs/automations.md`
|
|
124
|
+
- Troubleshooting: `docs/troubleshooting.md`
|
|
107
125
|
- Development: `docs/development.md`
|
|
108
126
|
- Security & trust boundaries: `docs/security.md`
|
|
109
127
|
|
package/bin/cli.js
CHANGED
|
@@ -1,275 +1,65 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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(
|
|
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
|
+
|