@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 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`, then go to **Settings** and configure:
40
- - **Gateway URL** (usually your gateway base URL, e.g. `http://localhost:8081`)
41
- - Leave it blank only for same-origin deployments (reverse proxy routes `/api`) or when using `npm run dev` (Vite `/api` proxy).
42
- - **Gateway user** and that user's **Gateway token** in hosted user-auth mode,
43
- then click **Connect**
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.6 -- abstractobserver
75
+ npx --yes --package @abstractframework/observer@0.1.13 -- abstractobserver
65
76
  ```
66
77
 
67
78
  ### CLI configuration
68
- The CLI is a static file server implemented in `bin/cli.js`.
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
- - **Mindmap**: knowledge-graph query UI (requires `POST /api/gateway/kg/query`)
80
- - **Backlog / Inbox (reports + email) / Processes**: maintainer tooling (high trust; requires additional gateway endpoints)
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
- const GATEWAY_SESSION_URL_COOKIE = 'abstractobserver_gateway_url';
20
- const GATEWAY_SESSION_ID_COOKIE = 'abstractobserver_gateway_session';
21
- const GATEWAY_SESSION_CSRF_COOKIE = 'abstractobserver_gateway_csrf';
22
- const TRUE_VALUES = new Set(['1', 'true', 'yes', 'y', 'on']);
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
- if (ENABLE_BACKLOG !== undefined) ui_config.enable_backlog = ENABLE_BACKLOG;
64
- if (ENABLE_INBOX_TRIAGE !== undefined) ui_config.enable_inbox_triage = ENABLE_INBOX_TRIAGE;
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
- function sendJson(res, status, payload) {
105
- res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
106
- res.end(JSON.stringify(payload));
107
- }
108
-
109
- function envBool(name) {
110
- const raw = process.env[name];
111
- if (typeof raw !== 'string' || !raw.trim()) return false;
112
- return TRUE_VALUES.has(raw.trim().toLowerCase());
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
- function proxyHeaders(headers) {
364
- const out = {};
365
- for (const [key, value] of Object.entries(headers || {})) {
366
- const k = String(key).toLowerCase();
367
- if (HOP_BY_HOP_HEADERS.has(k) || k === 'content-length') continue;
368
- out[key] = value;
369
- }
370
- return out;
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 proxyApiRequest(req, res) {
374
- const session = browserSession(req);
375
- if (!session.sessionId) {
376
- sendJson(res, 401, { detail: 'Gateway sign-in required' });
377
- return;
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
- if (mutatingMethod(req.method)) {
380
- const presented = String(req.headers['x-abstractobserver-csrf'] || '').trim();
381
- if (!session.csrfToken || presented !== session.csrfToken) {
382
- sendJson(res, 403, { detail: 'Gateway browser session CSRF token missing or invalid', reason_code: 'csrf_required' });
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
- let backend;
387
- try {
388
- backend = resolveBackend(session.gatewayUrl);
389
- } catch (err) {
390
- sendJson(res, 500, { detail: `Invalid gateway URL: ${String(err?.message || err)}` });
391
- return;
392
- }
393
- const headers = { ...req.headers, host: backend.url.host };
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
- proxyReq.on('error', (err) => sendJson(res, 502, { detail: `Backend not reachable at ${backend.origin} (${String(err?.message || err)})` }));
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
- if (pathname === '/api/connection/gateway') {
418
- void handleConnectionApi(req, res);
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
- if (pathname.startsWith('/api/')) {
423
- proxyApiRequest(req, res);
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: serve index.html for all other routes
240
+ // SPA fallback
452
241
  const indexPath = join(DIST_DIR, 'index.html');
453
242
  if (serveFile(res, indexPath)) {
454
243
  return;