@abstractframework/observer 0.1.11 → 0.1.13
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 +29 -10
- package/bin/cli.js +113 -324
- package/dist/assets/index-BHHnK0PW.css +1 -0
- package/dist/assets/index-BNRMqyx3.js +140 -0
- package/dist/assets/index-BNRMqyx3.js.map +1 -0
- package/dist/index.html +2 -2
- package/package.json +7 -2
- package/dist/assets/index-B15Wt8-t.js +0 -166
- package/dist/assets/index-B15Wt8-t.js.map +0 -1
- package/dist/assets/index-DXWoQByS.css +0 -1
package/README.md
CHANGED
|
@@ -10,6 +10,16 @@ What it does (implemented in `src/ui/app.tsx` + `src/lib/gateway_client.ts`):
|
|
|
10
10
|
- **Control** runs via durable commands (`pause`, `resume`, `cancel`)
|
|
11
11
|
- (Optional) **Voice** in run chat: gateway-based TTS + push-to-talk transcription (`src/ui/use_gateway_voice.ts`)
|
|
12
12
|
|
|
13
|
+
## Watching entities (the entity app)
|
|
14
|
+
|
|
15
|
+
The **entity app** (memory graph + visits UI) is its own package,
|
|
16
|
+
[AbstractEntity](https://github.com/lpalbou/AbstractEntity)
|
|
17
|
+
(`npx @abstractframework/entity`, default port `3007`). The observer still
|
|
18
|
+
*watches* entities — the Board's entities strip reads
|
|
19
|
+
`GET /api/gateway/entities` + `/entities/{name}/card` — and its
|
|
20
|
+
"Entities ↗" links point at the entity app deployment
|
|
21
|
+
(`ABSTRACTOBSERVER_ENTITY_APP_URL`, default `http://127.0.0.1:3007`).
|
|
22
|
+
|
|
13
23
|
## Where it fits (AbstractFramework ecosystem)
|
|
14
24
|
AbstractObserver is one of the browser UIs in the **AbstractFramework** ecosystem:
|
|
15
25
|
- AbstractFramework (ecosystem entrypoint): https://github.com/lpalbou/AbstractFramework
|
|
@@ -36,11 +46,12 @@ npx --yes --package @abstractframework/observer -- abstractobserver
|
|
|
36
46
|
|
|
37
47
|
Note: the npm package is `@abstractframework/observer`, and the CLI binary is `abstractobserver`.
|
|
38
48
|
|
|
39
|
-
Open `http://localhost:3001
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
-
|
|
43
|
-
|
|
49
|
+
Open `http://localhost:3001`. Sign-in is the shared AbstractFramework
|
|
50
|
+
connection dialog (the same one AbstractFlow and the gateway console use):
|
|
51
|
+
it opens automatically when this browser has no gateway session — enter the
|
|
52
|
+
gateway URL (pre-filled from the server), a Gateway user id, and that
|
|
53
|
+
user's token. The dialog is always one click away on the header connection
|
|
54
|
+
badge.
|
|
44
55
|
|
|
45
56
|
In hosted mode, Observer exchanges the user token for an app-scoped Gateway
|
|
46
57
|
browser session and does not persist the token in browser settings. Direct
|
|
@@ -61,23 +72,30 @@ abstractobserver
|
|
|
61
72
|
|
|
62
73
|
### Pin a version (recommended for deployments)
|
|
63
74
|
```bash
|
|
64
|
-
npx --yes --package @abstractframework/observer@0.1.
|
|
75
|
+
npx --yes --package @abstractframework/observer@0.1.13 -- abstractobserver
|
|
65
76
|
```
|
|
66
77
|
|
|
67
78
|
### CLI configuration
|
|
68
|
-
The CLI
|
|
79
|
+
The CLI (`bin/cli.js`) serves the built UI and mounts the app-origin Gateway
|
|
80
|
+
session proxy from `@abstractframework/app-server` (a runtime dependency of
|
|
81
|
+
this package, installed automatically).
|
|
69
82
|
- `PORT` (default `3001`)
|
|
70
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`)
|
|
71
85
|
- `--monitor-gpu` or `ABSTRACTOBSERVER_MONITOR_GPU=on` (enables the optional GPU widget)
|
|
72
86
|
|
|
87
|
+
See `docs/configuration.md` for the full list.
|
|
88
|
+
|
|
73
89
|
## Features (UI pages)
|
|
74
90
|
All pages share the same gateway connection settings.
|
|
75
91
|
|
|
92
|
+
- **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)
|
|
76
93
|
- **Observe**: workflow/subworkflow navigator, overview, human timeline, raw ledger, provider calls, graph, digest, attachments, chat (optional voice: PTT + TTS)
|
|
77
|
-
- **Runtime**: Activity, Artifacts, 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
|
|
94
|
+
- **Runtime**: 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
|
|
78
95
|
- **Launch**: start runs, schedule runs, bundle upload/reload
|
|
79
|
-
- **
|
|
80
|
-
- **
|
|
96
|
+
- **Runtime → Memory**: knowledge-graph (active memory) query UI (requires `POST /api/gateway/kg/query`)
|
|
97
|
+
- **Settings**: connection status, theme and display preferences, optional remote tool worker
|
|
98
|
+
- **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
|
|
81
99
|
|
|
82
100
|
## Documentation
|
|
83
101
|
- Start here: `docs/getting-started.md`
|
|
@@ -94,6 +112,7 @@ All pages share the same gateway connection settings.
|
|
|
94
112
|
- Contributing: `CONTRIBUTING.md`
|
|
95
113
|
- Security policy (vulnerability reporting): `SECURITY.md`
|
|
96
114
|
- Acknowledgments: `ACKNOWLEDGMENTS.md`
|
|
115
|
+
- Code of conduct: `CODE_OF_CONDUCT.md`
|
|
97
116
|
|
|
98
117
|
## Development (from source)
|
|
99
118
|
```bash
|
package/bin/cli.js
CHANGED
|
@@ -6,36 +6,38 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
import * as http from 'http';
|
|
9
|
-
import * as https from 'https';
|
|
10
9
|
import { readFileSync, existsSync, statSync } from 'fs';
|
|
11
|
-
import { join, extname, dirname } from 'path';
|
|
10
|
+
import { join, extname, dirname, resolve, isAbsolute } from 'path';
|
|
12
11
|
import { fileURLToPath } from 'url';
|
|
12
|
+
import { spawn } from 'child_process';
|
|
13
|
+
import { createGatewaySessionProxy } from '@abstractframework/app-server';
|
|
13
14
|
|
|
14
15
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const DIST_DIR = join(__dirname, '..', 'dist');
|
|
16
17
|
const PORT = process.env.PORT || 3001;
|
|
17
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.
|
|
18
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';
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
+
})();
|
|
23
37
|
const ARGV = process.argv.slice(2);
|
|
24
|
-
function parse_bool_env(name) {
|
|
25
|
-
const raw = process.env[name];
|
|
26
|
-
if (raw === undefined || raw === null || String(raw).trim() === "") return undefined;
|
|
27
|
-
const normalized = String(raw).trim().toLowerCase();
|
|
28
|
-
if (["1", "true", "yes", "on"].includes(normalized)) return true;
|
|
29
|
-
if (["0", "false", "no", "off"].includes(normalized)) return false;
|
|
30
|
-
console.warn(`#FALLBACK: ${name}="${raw}" is not a boolean; defaulting to false.`);
|
|
31
|
-
return false;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
38
|
const MONITOR_GPU =
|
|
35
39
|
ARGV.includes("--monitor-gpu") ||
|
|
36
40
|
["1", "true", "yes", "on"].includes(String(process.env.ABSTRACTOBSERVER_MONITOR_GPU || "").trim().toLowerCase());
|
|
37
|
-
const ENABLE_BACKLOG = parse_bool_env("ABSTRACTOBSERVER_ENABLE_BACKLOG");
|
|
38
|
-
const ENABLE_INBOX_TRIAGE = parse_bool_env("ABSTRACTOBSERVER_ENABLE_INBOX_TRIAGE");
|
|
39
41
|
|
|
40
42
|
// MIME types for common file extensions
|
|
41
43
|
const MIME_TYPES = {
|
|
@@ -60,8 +62,11 @@ function getMimeType(filePath) {
|
|
|
60
62
|
function inject_config_html(html) {
|
|
61
63
|
const ui_config = {};
|
|
62
64
|
if (MONITOR_GPU) ui_config.monitor_gpu = true;
|
|
63
|
-
|
|
64
|
-
|
|
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;
|
|
65
70
|
if (!Object.keys(ui_config).length) return html;
|
|
66
71
|
const marker = "window.__ABSTRACT_UI_CONFIG__";
|
|
67
72
|
if (html.includes(marker)) return html;
|
|
@@ -101,312 +106,69 @@ function serveFile(res, filePath) {
|
|
|
101
106
|
}
|
|
102
107
|
}
|
|
103
108
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function requestHostname(req) {
|
|
116
|
-
const trustProxyHeaders = envBool('ABSTRACTOBSERVER_TRUST_PROXY_HEADERS') || envBool('ABSTRACTGATEWAY_TRUST_PROXY_HEADERS');
|
|
117
|
-
const headerValue = trustProxyHeaders ? (req?.headers?.['x-forwarded-host'] || req?.headers?.host) : req?.headers?.host;
|
|
118
|
-
const raw = String(headerValue || '').split(',', 1)[0].trim();
|
|
119
|
-
if (!raw) return '';
|
|
120
|
-
if (raw.startsWith('[')) return raw.slice(1).split(']', 1)[0].trim().toLowerCase();
|
|
121
|
-
if ((raw.match(/:/g) || []).length === 1) return raw.split(':')[0].trim().toLowerCase();
|
|
122
|
-
return raw.toLowerCase();
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function isLoopbackHostname(hostname) {
|
|
126
|
-
const h = String(hostname || '').trim().toLowerCase();
|
|
127
|
-
return h === 'localhost' || h === 'localhost.localdomain' || h === '::1' || h.startsWith('127.');
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function browserGatewayConnectionConfigAllowed(req) {
|
|
131
|
-
if (envBool('ABSTRACTOBSERVER_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG')) return true;
|
|
132
|
-
if (envBool('ABSTRACTGATEWAY_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG')) return true;
|
|
133
|
-
return isLoopbackHostname(requestHostname(req));
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function browserGatewayConnectionConfigDenial(req) {
|
|
137
|
-
const host = requestHostname(req) || 'unknown host';
|
|
138
|
-
return (
|
|
139
|
-
`Browser-supplied Gateway URL changes are disabled for this non-local Observer host (${host}). ` +
|
|
140
|
-
'Use the server-configured Gateway URL, or set ABSTRACTOBSERVER_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG=1 behind your own access control.'
|
|
141
|
-
);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
function parseCookies(req) {
|
|
145
|
-
const out = {};
|
|
146
|
-
for (const part of String(req?.headers?.cookie || '').split(';')) {
|
|
147
|
-
const idx = part.indexOf('=');
|
|
148
|
-
if (idx < 0) continue;
|
|
149
|
-
const key = part.slice(0, idx).trim();
|
|
150
|
-
const value = part.slice(idx + 1).trim();
|
|
151
|
-
if (!key) continue;
|
|
152
|
-
try {
|
|
153
|
-
out[key] = decodeURIComponent(value);
|
|
154
|
-
} catch {
|
|
155
|
-
out[key] = value;
|
|
156
|
-
}
|
|
157
|
-
}
|
|
158
|
-
return out;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
function cookieSecure(req) {
|
|
162
|
-
return String(req?.headers?.['x-forwarded-proto'] || '').trim().toLowerCase() === 'https' ? '; Secure' : '';
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
function setSessionCookies(res, req, gatewayUrl, sessionId, csrfToken, persist) {
|
|
166
|
-
const secure = cookieSecure(req);
|
|
167
|
-
const maxAge = persist ? '; Max-Age=2592000' : '';
|
|
168
|
-
const attrs = `; Path=/; HttpOnly; SameSite=Lax${secure}${maxAge}`;
|
|
169
|
-
const csrfAttrs = `; Path=/; SameSite=Lax${secure}${maxAge}`;
|
|
170
|
-
res.setHeader('Set-Cookie', [
|
|
171
|
-
`${GATEWAY_SESSION_URL_COOKIE}=${encodeURIComponent(gatewayUrl)}${attrs}`,
|
|
172
|
-
`${GATEWAY_SESSION_ID_COOKIE}=${encodeURIComponent(sessionId)}${attrs}`,
|
|
173
|
-
`${GATEWAY_SESSION_CSRF_COOKIE}=${encodeURIComponent(csrfToken)}${csrfAttrs}`,
|
|
174
|
-
]);
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
function clearSessionCookies(res, req) {
|
|
178
|
-
const secure = cookieSecure(req);
|
|
179
|
-
res.setHeader('Set-Cookie', [
|
|
180
|
-
`${GATEWAY_SESSION_URL_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
|
|
181
|
-
`${GATEWAY_SESSION_ID_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
|
|
182
|
-
`${GATEWAY_SESSION_CSRF_COOKIE}=; Path=/; SameSite=Lax; Max-Age=0${secure}`,
|
|
183
|
-
]);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function browserSession(req) {
|
|
187
|
-
const cookies = parseCookies(req);
|
|
188
|
-
const cookieUrl = String(cookies[GATEWAY_SESSION_URL_COOKIE] || '').trim().replace(/\/+$/, '');
|
|
189
|
-
const allowCookieUrl =
|
|
190
|
-
envBool('ABSTRACTOBSERVER_ALLOW_BROWSER_GATEWAY_URL_COOKIE') ||
|
|
191
|
-
envBool('ABSTRACTOBSERVER_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG') ||
|
|
192
|
-
envBool('ABSTRACTGATEWAY_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG') ||
|
|
193
|
-
browserGatewayConnectionConfigAllowed(req);
|
|
194
|
-
return {
|
|
195
|
-
gatewayUrl: cookieUrl && (allowCookieUrl || cookieUrl === DEFAULT_GATEWAY_URL) ? cookieUrl : DEFAULT_GATEWAY_URL,
|
|
196
|
-
sessionId: String(cookies[GATEWAY_SESSION_ID_COOKIE] || '').trim(),
|
|
197
|
-
csrfToken: String(cookies[GATEWAY_SESSION_CSRF_COOKIE] || '').trim(),
|
|
198
|
-
};
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function resolveBackend(gatewayUrl) {
|
|
202
|
-
const backend = new URL(String(gatewayUrl || DEFAULT_GATEWAY_URL).trim());
|
|
203
|
-
if (!backend.port) backend.port = backend.protocol === 'https:' ? '443' : '80';
|
|
204
|
-
return {
|
|
205
|
-
url: backend,
|
|
206
|
-
origin: `${backend.protocol}//${backend.host}`,
|
|
207
|
-
client: backend.protocol === 'https:' ? https : http,
|
|
208
|
-
};
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function mutatingMethod(method) {
|
|
212
|
-
return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(String(method || 'GET').toUpperCase());
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
function readRequestJson(req) {
|
|
216
|
-
return new Promise((resolve) => {
|
|
217
|
-
const chunks = [];
|
|
218
|
-
req.on('data', (chunk) => chunks.push(chunk));
|
|
219
|
-
req.on('end', () => {
|
|
220
|
-
try {
|
|
221
|
-
const raw = Buffer.concat(chunks).toString('utf8');
|
|
222
|
-
resolve(raw ? JSON.parse(raw) : {});
|
|
223
|
-
} catch {
|
|
224
|
-
resolve({});
|
|
225
|
-
}
|
|
226
|
-
});
|
|
227
|
-
req.on('error', () => resolve({}));
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
function gatewayRequest(gatewayUrl, options, body) {
|
|
232
|
-
return new Promise((resolve) => {
|
|
233
|
-
let backend;
|
|
234
|
-
try {
|
|
235
|
-
backend = resolveBackend(gatewayUrl);
|
|
236
|
-
} catch (err) {
|
|
237
|
-
resolve({ ok: false, status: 0, payload: { detail: `Invalid gateway URL: ${String(err?.message || err)}` } });
|
|
238
|
-
return;
|
|
239
|
-
}
|
|
240
|
-
const req = backend.client.request(
|
|
241
|
-
{
|
|
242
|
-
protocol: backend.url.protocol,
|
|
243
|
-
hostname: backend.url.hostname,
|
|
244
|
-
port: backend.url.port,
|
|
245
|
-
timeout: options.timeout || 4000,
|
|
246
|
-
...options,
|
|
247
|
-
},
|
|
248
|
-
(resp) => {
|
|
249
|
-
const chunks = [];
|
|
250
|
-
resp.on('data', (chunk) => chunks.push(chunk));
|
|
251
|
-
resp.on('end', () => {
|
|
252
|
-
const text = Buffer.concat(chunks).toString('utf8');
|
|
253
|
-
let payload = {};
|
|
254
|
-
try {
|
|
255
|
-
payload = text ? JSON.parse(text) : {};
|
|
256
|
-
} catch {
|
|
257
|
-
payload = { detail: text };
|
|
258
|
-
}
|
|
259
|
-
const status = resp.statusCode || 0;
|
|
260
|
-
resolve({ ok: status >= 200 && status < 300, status, payload, headers: resp.headers, origin: backend.origin });
|
|
261
|
-
});
|
|
262
|
-
}
|
|
263
|
-
);
|
|
264
|
-
req.on('timeout', () => {
|
|
265
|
-
req.destroy();
|
|
266
|
-
resolve({ ok: false, status: 0, payload: { detail: 'Gateway request timed out' }, origin: backend?.origin });
|
|
267
|
-
});
|
|
268
|
-
req.on('error', (err) => resolve({ ok: false, status: 0, payload: { detail: String(err?.message || err) }, origin: backend?.origin }));
|
|
269
|
-
if (body) req.write(body);
|
|
270
|
-
req.end();
|
|
271
|
-
});
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function cookieValueFromSetCookie(rawHeaders, name) {
|
|
275
|
-
const headers = Array.isArray(rawHeaders) ? rawHeaders : rawHeaders ? [rawHeaders] : [];
|
|
276
|
-
for (const header of headers) {
|
|
277
|
-
for (const candidate of String(header || '').split(/,(?=\s*[^;,=]+=)/)) {
|
|
278
|
-
const first = candidate.split(';', 1)[0];
|
|
279
|
-
const idx = first.indexOf('=');
|
|
280
|
-
if (idx < 0) continue;
|
|
281
|
-
if (first.slice(0, idx).trim() !== name) continue;
|
|
282
|
-
const raw = first.slice(idx + 1).trim();
|
|
283
|
-
try {
|
|
284
|
-
return decodeURIComponent(raw);
|
|
285
|
-
} catch {
|
|
286
|
-
return raw;
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
return '';
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
async function handleConnectionApi(req, res) {
|
|
294
|
-
if (req.method === 'GET') {
|
|
295
|
-
const session = browserSession(req);
|
|
296
|
-
if (!session.sessionId) {
|
|
297
|
-
sendJson(res, 200, { ok: false, gateway_url: session.gatewayUrl, has_session: false, gateway: { ok: false, error: 'Gateway sign-in required' } });
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
const checked = await gatewayRequest(session.gatewayUrl, {
|
|
301
|
-
method: 'GET',
|
|
302
|
-
path: '/api/gateway/me',
|
|
303
|
-
headers: { Accept: 'application/json', 'X-AbstractGateway-Session': session.sessionId },
|
|
304
|
-
});
|
|
305
|
-
sendJson(res, 200, { ok: checked.ok, gateway_url: session.gatewayUrl, has_session: Boolean(session.sessionId), gateway: checked.payload });
|
|
306
|
-
return;
|
|
307
|
-
}
|
|
308
|
-
if (req.method === 'POST') {
|
|
309
|
-
const payload = await readRequestJson(req);
|
|
310
|
-
const gatewayUrl = String(payload.gateway_url || DEFAULT_GATEWAY_URL).trim().replace(/\/+$/, '') || DEFAULT_GATEWAY_URL;
|
|
311
|
-
if (!browserGatewayConnectionConfigAllowed(req) && gatewayUrl !== DEFAULT_GATEWAY_URL) {
|
|
312
|
-
sendJson(res, 403, { detail: browserGatewayConnectionConfigDenial(req) });
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
const body = Buffer.from(JSON.stringify({
|
|
316
|
-
user_id: String(payload.gateway_user_id || '').trim(),
|
|
317
|
-
token: String(payload.gateway_token || '').trim(),
|
|
318
|
-
remember: payload.persist === true,
|
|
319
|
-
}));
|
|
320
|
-
const login = await gatewayRequest(gatewayUrl, {
|
|
321
|
-
method: 'POST',
|
|
322
|
-
path: '/api/gateway/session/login',
|
|
323
|
-
headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Content-Length': String(body.length) },
|
|
324
|
-
}, body);
|
|
325
|
-
const session = login.payload && typeof login.payload.session === 'object' ? login.payload.session : {};
|
|
326
|
-
const setCookie = login.headers?.['set-cookie'];
|
|
327
|
-
const sessionId = cookieValueFromSetCookie(setCookie, 'abstractgateway_session') || String(session.session_id || '').trim();
|
|
328
|
-
const csrfToken = cookieValueFromSetCookie(setCookie, 'abstractgateway_csrf') || String(session.csrf_token || '').trim();
|
|
329
|
-
if (!login.ok || !sessionId || !csrfToken) {
|
|
330
|
-
sendJson(res, login.status || 401, { ok: false, detail: login.payload?.detail || 'Gateway browser session failed', gateway: login.payload });
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
setSessionCookies(res, req, gatewayUrl, sessionId, csrfToken, payload.persist === true);
|
|
334
|
-
sendJson(res, 200, { ok: true, gateway_url: gatewayUrl, has_session: true, gateway: login.payload });
|
|
335
|
-
return;
|
|
336
|
-
}
|
|
337
|
-
if (req.method === 'DELETE') {
|
|
338
|
-
const session = browserSession(req);
|
|
339
|
-
if (session.sessionId) {
|
|
340
|
-
const body = Buffer.from('{}');
|
|
341
|
-
await gatewayRequest(session.gatewayUrl, {
|
|
342
|
-
method: 'POST',
|
|
343
|
-
path: '/api/gateway/session/logout',
|
|
344
|
-
headers: {
|
|
345
|
-
Accept: 'application/json',
|
|
346
|
-
'Content-Type': 'application/json',
|
|
347
|
-
'Content-Length': String(body.length),
|
|
348
|
-
'X-AbstractGateway-Session': session.sessionId,
|
|
349
|
-
'X-AbstractGateway-CSRF': session.csrfToken,
|
|
350
|
-
},
|
|
351
|
-
timeout: 2000,
|
|
352
|
-
}, body);
|
|
353
|
-
}
|
|
354
|
-
clearSessionCookies(res, req);
|
|
355
|
-
sendJson(res, 200, { ok: true });
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
sendJson(res, 405, { detail: 'Method not allowed' });
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
const HOP_BY_HOP_HEADERS = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'trailers', 'transfer-encoding', 'upgrade']);
|
|
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
|
+
});
|
|
362
119
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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;
|
|
371
136
|
}
|
|
372
137
|
|
|
373
|
-
function
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
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;
|
|
378
148
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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)'}` }));
|
|
383
158
|
return;
|
|
384
159
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
delete headers.cookie;
|
|
395
|
-
delete headers.authorization;
|
|
396
|
-
delete headers['x-forwarded-for'];
|
|
397
|
-
delete headers['x-forwarded-host'];
|
|
398
|
-
delete headers['x-forwarded-proto'];
|
|
399
|
-
headers['x-abstractgateway-session'] = session.sessionId;
|
|
400
|
-
if (mutatingMethod(req.method)) headers['x-abstractgateway-csrf'] = session.csrfToken;
|
|
401
|
-
const proxyReq = backend.client.request(
|
|
402
|
-
{ protocol: backend.url.protocol, hostname: backend.url.hostname, port: backend.url.port, method: req.method, path: req.url, headers },
|
|
403
|
-
(proxyRes) => {
|
|
404
|
-
res.writeHead(proxyRes.statusCode || 502, proxyHeaders(proxyRes.headers));
|
|
405
|
-
proxyRes.pipe(res);
|
|
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) }));
|
|
406
169
|
}
|
|
407
|
-
);
|
|
408
|
-
|
|
409
|
-
req.pipe(proxyReq);
|
|
170
|
+
});
|
|
171
|
+
return true;
|
|
410
172
|
}
|
|
411
173
|
|
|
412
174
|
const server = http.createServer((req, res) => {
|
|
@@ -414,13 +176,15 @@ const server = http.createServer((req, res) => {
|
|
|
414
176
|
const url = new URL(req.url, `http://${req.headers.host}`);
|
|
415
177
|
let pathname = url.pathname;
|
|
416
178
|
|
|
417
|
-
|
|
418
|
-
|
|
179
|
+
// Local-machine folder reveal (before the proxy: /api/local/* is OURS,
|
|
180
|
+
// never forwarded to the gateway).
|
|
181
|
+
if (handleRevealRequest(req, res, pathname)) {
|
|
419
182
|
return;
|
|
420
183
|
}
|
|
421
184
|
|
|
422
|
-
|
|
423
|
-
|
|
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)) {
|
|
424
188
|
return;
|
|
425
189
|
}
|
|
426
190
|
|
|
@@ -431,6 +195,10 @@ const server = http.createServer((req, res) => {
|
|
|
431
195
|
return;
|
|
432
196
|
}
|
|
433
197
|
|
|
198
|
+
if (pathname === '/' || pathname === '') {
|
|
199
|
+
pathname = '/index.html';
|
|
200
|
+
}
|
|
201
|
+
|
|
434
202
|
// Try to serve the requested file
|
|
435
203
|
let filePath = join(DIST_DIR, pathname);
|
|
436
204
|
|
|
@@ -438,6 +206,27 @@ const server = http.createServer((req, res) => {
|
|
|
438
206
|
return;
|
|
439
207
|
}
|
|
440
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
|
+
|
|
441
230
|
// Try with .html extension
|
|
442
231
|
if (serveFile(res, filePath + '.html')) {
|
|
443
232
|
return;
|
|
@@ -448,7 +237,7 @@ const server = http.createServer((req, res) => {
|
|
|
448
237
|
return;
|
|
449
238
|
}
|
|
450
239
|
|
|
451
|
-
// SPA fallback
|
|
240
|
+
// SPA fallback
|
|
452
241
|
const indexPath = join(DIST_DIR, 'index.html');
|
|
453
242
|
if (serveFile(res, indexPath)) {
|
|
454
243
|
return;
|