@abstractframework/code 0.4.1 → 0.4.2

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
@@ -13,9 +13,10 @@ Run it without installing anything:
13
13
  npx @abstractframework/code # serves on http://127.0.0.1:3002
14
14
  ```
15
15
 
16
- Shared UI components come from the published `@abstractframework/*` packages
17
- (see `package.json`), so this app builds from its own directory with no other
18
- checkout present.
16
+ The interface uses AbstractUIC themes, authentication, settings, and reusable
17
+ workflow chat. It builds without a sibling checkout: the shared
18
+ `@abstractframework/*` components install from npm like any other dependency,
19
+ at the ranges declared in `package.json`.
19
20
 
20
21
  Docs:
21
22
  - Web overview: [`../docs/web.md`](../docs/web.md)
@@ -30,13 +31,19 @@ npm run dev
30
31
  ```
31
32
 
32
33
  In the UI:
33
- - set `Gateway URL` (blank = same origin / dev proxy; e.g. `http://127.0.0.1:8081`)
34
- - in hosted user-auth mode, set `Gateway user` and that user's `Gateway token`
34
+ - set `Gateway URL` (e.g. `http://127.0.0.1:8080`), or configure it on the
35
+ server with `ABSTRACTCODE_GATEWAY_URL`
36
+ - set `Gateway user` and that user's `Gateway token`
35
37
 
36
38
  When a Gateway user is provided, the web server exchanges the token for a
37
39
  Gateway browser session and stores only app-scoped session cookies. The raw
38
- token is not persisted in browser settings. Direct bearer-token mode is kept for
39
- local development only.
40
+ token is not persisted in browser settings. Vite development and the packaged
41
+ server use the same session proxy; the browser keeps API requests same-origin.
42
+
43
+ Choose an agent or registered workflow in the toolbar. Agent tasks use the
44
+ composer; structured workflows use **Inputs**. Questions, tool approvals, and
45
+ event waits appear in the conversation. Runs and history remain on the gateway
46
+ when you close the browser. Unsent drafts and queued turns do not survive reload.
40
47
 
41
48
  Whether a browser may change the Gateway URL is decided by the **connection
42
49
  peer**, not by any request header: only a request arriving from loopback may
package/bin/cli.js CHANGED
@@ -1,451 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- /**
4
- * CLI entry point for @abstractframework/code
5
- * Serves the browser-based coding assistant on a configurable port
6
- */
3
+ import { createCodeServer } from "./server.js";
7
4
 
8
- import * as http from 'http';
9
- import * as https from 'https';
10
- import { readFileSync, existsSync, statSync } from 'fs';
11
- import { join, extname, dirname } from 'path';
12
- import { fileURLToPath } from 'url';
5
+ const port = process.env.PORT || 3002;
6
+ const host = process.env.HOST || "0.0.0.0";
7
+ const server = createCodeServer();
13
8
 
14
- const __dirname = dirname(fileURLToPath(import.meta.url));
15
- const DIST_DIR = join(__dirname, '..', 'dist');
16
- const PORT = process.env.PORT || 3002;
17
- const HOST = process.env.HOST || '0.0.0.0';
18
- const DEFAULT_GATEWAY_URL = String(process.env.ABSTRACTCODE_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 = 'abstractcode_gateway_url';
20
- const GATEWAY_SESSION_ID_COOKIE = 'abstractcode_gateway_session';
21
- const GATEWAY_SESSION_CSRF_COOKIE = 'abstractcode_gateway_csrf';
22
- const TRUE_VALUES = new Set(['1', 'true', 'yes', 'y', 'on']);
23
-
24
- // MIME types for common file extensions
25
- const MIME_TYPES = {
26
- '.html': 'text/html',
27
- '.js': 'application/javascript',
28
- '.css': 'text/css',
29
- '.json': 'application/json',
30
- '.png': 'image/png',
31
- '.jpg': 'image/jpeg',
32
- '.jpeg': 'image/jpeg',
33
- '.gif': 'image/gif',
34
- '.svg': 'image/svg+xml',
35
- '.ico': 'image/x-icon',
36
- '.webmanifest': 'application/manifest+json',
37
- };
38
-
39
- function getMimeType(filePath) {
40
- const ext = extname(filePath).toLowerCase();
41
- return MIME_TYPES[ext] || 'application/octet-stream';
42
- }
43
-
44
- function serveFile(res, filePath) {
45
- try {
46
- if (!existsSync(filePath) || !statSync(filePath).isFile()) {
47
- return false;
48
- }
49
- const content = readFileSync(filePath);
50
- const mimeType = getMimeType(filePath);
51
- res.writeHead(200, {
52
- 'Content-Type': mimeType,
53
- 'Cache-Control': 'no-cache',
54
- });
55
- res.end(content);
56
- return true;
57
- } catch (err) {
58
- return false;
59
- }
60
- }
61
-
62
- function sendJson(res, status, payload) {
63
- res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8' });
64
- res.end(JSON.stringify(payload));
65
- }
66
-
67
- function envBool(name) {
68
- const raw = process.env[name];
69
- if (typeof raw !== 'string' || !raw.trim()) return false;
70
- return TRUE_VALUES.has(raw.trim().toLowerCase());
71
- }
72
-
73
- function requestHostname(req) {
74
- const trustProxyHeaders = envBool('ABSTRACTCODE_TRUST_PROXY_HEADERS') || envBool('ABSTRACTGATEWAY_TRUST_PROXY_HEADERS');
75
- const headerValue = trustProxyHeaders ? (req?.headers?.['x-forwarded-host'] || req?.headers?.host) : req?.headers?.host;
76
- const raw = String(headerValue || '').split(',', 1)[0].trim();
77
- if (!raw) return '';
78
- if (raw.startsWith('[')) return raw.slice(1).split(']', 1)[0].trim().toLowerCase();
79
- if ((raw.match(/:/g) || []).length === 1) return raw.split(':')[0].trim().toLowerCase();
80
- return raw.toLowerCase();
81
- }
82
-
83
- function isLoopbackHostname(hostname) {
84
- const h = String(hostname || '').trim().toLowerCase();
85
- return h === 'localhost' || h === 'localhost.localdomain' || h === '::1' || h.startsWith('127.');
86
- }
87
-
88
- // The SECURITY gate must derive local-vs-remote from the unspoofable
89
- // connection PEER, never from the client-controlled `Host` header (entity's
90
- // HIGH SSRF finding, 2026-07-14: a LAN client sending `Host: localhost`
91
- // against a 0.0.0.0 bind flipped the old Host-based check and unlocked the
92
- // browser-supplied gateway-URL cookie → SSRF relay with session cookies).
93
- // req.socket.remoteAddress is the real TCP peer; IPv4-mapped IPv6 loopback
94
- // (::ffff:127.x) is normalized.
95
- function isLoopbackPeer(req) {
96
- let addr = String(req?.socket?.remoteAddress || '').trim().toLowerCase();
97
- if (!addr) return false;
98
- if (addr.startsWith('::ffff:')) addr = addr.slice('::ffff:'.length);
99
- return addr === '::1' || addr === '127.0.0.1' || addr.startsWith('127.');
100
- }
101
-
102
- function browserGatewayConnectionConfigAllowed(req) {
103
- if (envBool('ABSTRACTCODE_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG')) return true;
104
- if (envBool('ABSTRACTGATEWAY_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG')) return true;
105
- // Behind an explicit trusted proxy the socket peer is always the proxy, so
106
- // loopback-by-socket is meaningless — such deployments MUST opt in via the
107
- // ALLOW_REMOTE env above. Without that opt-in, only a genuinely loopback
108
- // TCP peer may change the gateway config.
109
- const trustProxyHeaders =
110
- envBool('ABSTRACTCODE_TRUST_PROXY_HEADERS') || envBool('ABSTRACTGATEWAY_TRUST_PROXY_HEADERS');
111
- if (trustProxyHeaders) return false;
112
- return isLoopbackPeer(req);
113
- }
114
-
115
- function browserGatewayConnectionConfigDenial(req) {
116
- const host = requestHostname(req) || 'unknown host';
117
- return (
118
- `Browser-supplied Gateway URL changes are disabled for this non-local Code host (${host}). ` +
119
- 'Use the server-configured Gateway URL, or set ABSTRACTCODE_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG=1 behind your own access control.'
120
- );
121
- }
122
-
123
- function parseCookies(req) {
124
- const out = {};
125
- for (const part of String(req?.headers?.cookie || '').split(';')) {
126
- const idx = part.indexOf('=');
127
- if (idx < 0) continue;
128
- const key = part.slice(0, idx).trim();
129
- const value = part.slice(idx + 1).trim();
130
- if (!key) continue;
131
- try {
132
- out[key] = decodeURIComponent(value);
133
- } catch {
134
- out[key] = value;
135
- }
136
- }
137
- return out;
138
- }
139
-
140
- function cookieSecure(req) {
141
- return String(req?.headers?.['x-forwarded-proto'] || '').trim().toLowerCase() === 'https' ? '; Secure' : '';
142
- }
143
-
144
- function setSessionCookies(res, req, gatewayUrl, sessionId, csrfToken, persist) {
145
- const secure = cookieSecure(req);
146
- const maxAge = persist ? '; Max-Age=2592000' : '';
147
- const attrs = `; Path=/; HttpOnly; SameSite=Lax${secure}${maxAge}`;
148
- const csrfAttrs = `; Path=/; SameSite=Lax${secure}${maxAge}`;
149
- res.setHeader('Set-Cookie', [
150
- `${GATEWAY_SESSION_URL_COOKIE}=${encodeURIComponent(gatewayUrl)}${attrs}`,
151
- `${GATEWAY_SESSION_ID_COOKIE}=${encodeURIComponent(sessionId)}${attrs}`,
152
- `${GATEWAY_SESSION_CSRF_COOKIE}=${encodeURIComponent(csrfToken)}${csrfAttrs}`,
153
- ]);
154
- }
155
-
156
- function clearSessionCookies(res, req) {
157
- const secure = cookieSecure(req);
158
- res.setHeader('Set-Cookie', [
159
- `${GATEWAY_SESSION_URL_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
160
- `${GATEWAY_SESSION_ID_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0${secure}`,
161
- `${GATEWAY_SESSION_CSRF_COOKIE}=; Path=/; SameSite=Lax; Max-Age=0${secure}`,
162
- ]);
163
- }
164
-
165
- function browserSession(req) {
166
- const cookies = parseCookies(req);
167
- const cookieUrl = String(cookies[GATEWAY_SESSION_URL_COOKIE] || '').trim().replace(/\/+$/, '');
168
- const allowCookieUrl =
169
- envBool('ABSTRACTCODE_ALLOW_BROWSER_GATEWAY_URL_COOKIE') ||
170
- envBool('ABSTRACTCODE_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG') ||
171
- envBool('ABSTRACTGATEWAY_ALLOW_REMOTE_BROWSER_GATEWAY_CONFIG') ||
172
- browserGatewayConnectionConfigAllowed(req);
173
- return {
174
- gatewayUrl: cookieUrl && (allowCookieUrl || cookieUrl === DEFAULT_GATEWAY_URL) ? cookieUrl : DEFAULT_GATEWAY_URL,
175
- sessionId: String(cookies[GATEWAY_SESSION_ID_COOKIE] || '').trim(),
176
- csrfToken: String(cookies[GATEWAY_SESSION_CSRF_COOKIE] || '').trim(),
177
- };
178
- }
179
-
180
- function resolveBackend(gatewayUrl) {
181
- const backend = new URL(String(gatewayUrl || DEFAULT_GATEWAY_URL).trim());
182
- if (!backend.port) backend.port = backend.protocol === 'https:' ? '443' : '80';
183
- return {
184
- url: backend,
185
- origin: `${backend.protocol}//${backend.host}`,
186
- client: backend.protocol === 'https:' ? https : http,
187
- };
188
- }
189
-
190
- function mutatingMethod(method) {
191
- return ['POST', 'PUT', 'PATCH', 'DELETE'].includes(String(method || 'GET').toUpperCase());
192
- }
193
-
194
- function readRequestJson(req) {
195
- return new Promise((resolve) => {
196
- const chunks = [];
197
- req.on('data', (chunk) => chunks.push(chunk));
198
- req.on('end', () => {
199
- try {
200
- const raw = Buffer.concat(chunks).toString('utf8');
201
- resolve(raw ? JSON.parse(raw) : {});
202
- } catch {
203
- resolve({});
204
- }
205
- });
206
- req.on('error', () => resolve({}));
207
- });
208
- }
209
-
210
- function gatewayRequest(gatewayUrl, options, body) {
211
- return new Promise((resolve) => {
212
- let backend;
213
- try {
214
- backend = resolveBackend(gatewayUrl);
215
- } catch (err) {
216
- resolve({ ok: false, status: 0, payload: { detail: `Invalid gateway URL: ${String(err?.message || err)}` } });
217
- return;
218
- }
219
- const req = backend.client.request(
220
- {
221
- protocol: backend.url.protocol,
222
- hostname: backend.url.hostname,
223
- port: backend.url.port,
224
- timeout: options.timeout || 4000,
225
- ...options,
226
- },
227
- (resp) => {
228
- const chunks = [];
229
- resp.on('data', (chunk) => chunks.push(chunk));
230
- resp.on('end', () => {
231
- const text = Buffer.concat(chunks).toString('utf8');
232
- let payload = {};
233
- try {
234
- payload = text ? JSON.parse(text) : {};
235
- } catch {
236
- payload = { detail: text };
237
- }
238
- const status = resp.statusCode || 0;
239
- resolve({ ok: status >= 200 && status < 300, status, payload, headers: resp.headers, origin: backend.origin });
240
- });
241
- }
242
- );
243
- req.on('timeout', () => {
244
- req.destroy();
245
- resolve({ ok: false, status: 0, payload: { detail: 'Gateway request timed out' }, origin: backend?.origin });
246
- });
247
- req.on('error', (err) => resolve({ ok: false, status: 0, payload: { detail: String(err?.message || err) }, origin: backend?.origin }));
248
- if (body) req.write(body);
249
- req.end();
250
- });
251
- }
252
-
253
- function cookieValueFromSetCookie(rawHeaders, name) {
254
- const headers = Array.isArray(rawHeaders) ? rawHeaders : rawHeaders ? [rawHeaders] : [];
255
- for (const header of headers) {
256
- for (const candidate of String(header || '').split(/,(?=\s*[^;,=]+=)/)) {
257
- const first = candidate.split(';', 1)[0];
258
- const idx = first.indexOf('=');
259
- if (idx < 0) continue;
260
- if (first.slice(0, idx).trim() !== name) continue;
261
- const raw = first.slice(idx + 1).trim();
262
- try {
263
- return decodeURIComponent(raw);
264
- } catch {
265
- return raw;
266
- }
267
- }
268
- }
269
- return '';
270
- }
271
-
272
- async function handleConnectionApi(req, res) {
273
- if (req.method === 'GET') {
274
- const session = browserSession(req);
275
- if (!session.sessionId) {
276
- sendJson(res, 200, { ok: false, gateway_url: session.gatewayUrl, has_session: false, gateway: { ok: false, error: 'Gateway sign-in required' } });
277
- return;
278
- }
279
- const checked = await gatewayRequest(session.gatewayUrl, {
280
- method: 'GET',
281
- path: '/api/gateway/me',
282
- headers: { Accept: 'application/json', 'X-AbstractGateway-Session': session.sessionId },
283
- });
284
- sendJson(res, 200, { ok: checked.ok, gateway_url: session.gatewayUrl, has_session: Boolean(session.sessionId), gateway: checked.payload });
285
- return;
286
- }
287
- if (req.method === 'POST') {
288
- const payload = await readRequestJson(req);
289
- const gatewayUrl = String(payload.gateway_url || DEFAULT_GATEWAY_URL).trim().replace(/\/+$/, '') || DEFAULT_GATEWAY_URL;
290
- if (!browserGatewayConnectionConfigAllowed(req) && gatewayUrl !== DEFAULT_GATEWAY_URL) {
291
- sendJson(res, 403, { detail: browserGatewayConnectionConfigDenial(req) });
292
- return;
293
- }
294
- const body = Buffer.from(JSON.stringify({
295
- user_id: String(payload.gateway_user_id || '').trim(),
296
- token: String(payload.gateway_token || '').trim(),
297
- remember: payload.persist === true,
298
- }));
299
- const login = await gatewayRequest(gatewayUrl, {
300
- method: 'POST',
301
- path: '/api/gateway/session/login',
302
- headers: { Accept: 'application/json', 'Content-Type': 'application/json', 'Content-Length': String(body.length) },
303
- }, body);
304
- const session = login.payload && typeof login.payload.session === 'object' ? login.payload.session : {};
305
- const setCookie = login.headers?.['set-cookie'];
306
- const sessionId = cookieValueFromSetCookie(setCookie, 'abstractgateway_session') || String(session.session_id || '').trim();
307
- const csrfToken = cookieValueFromSetCookie(setCookie, 'abstractgateway_csrf') || String(session.csrf_token || '').trim();
308
- if (!login.ok || !sessionId || !csrfToken) {
309
- sendJson(res, login.status || 401, { ok: false, detail: login.payload?.detail || 'Gateway browser session failed', gateway: login.payload });
310
- return;
311
- }
312
- setSessionCookies(res, req, gatewayUrl, sessionId, csrfToken, payload.persist === true);
313
- sendJson(res, 200, { ok: true, gateway_url: gatewayUrl, has_session: true, gateway: login.payload });
314
- return;
315
- }
316
- if (req.method === 'DELETE') {
317
- const session = browserSession(req);
318
- if (session.sessionId) {
319
- const body = Buffer.from('{}');
320
- await gatewayRequest(session.gatewayUrl, {
321
- method: 'POST',
322
- path: '/api/gateway/session/logout',
323
- headers: {
324
- Accept: 'application/json',
325
- 'Content-Type': 'application/json',
326
- 'Content-Length': String(body.length),
327
- 'X-AbstractGateway-Session': session.sessionId,
328
- 'X-AbstractGateway-CSRF': session.csrfToken,
329
- },
330
- timeout: 2000,
331
- }, body);
332
- }
333
- clearSessionCookies(res, req);
334
- sendJson(res, 200, { ok: true });
335
- return;
336
- }
337
- sendJson(res, 405, { detail: 'Method not allowed' });
338
- }
339
-
340
- const HOP_BY_HOP_HEADERS = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'trailers', 'transfer-encoding', 'upgrade']);
341
-
342
- function proxyHeaders(headers) {
343
- const out = {};
344
- for (const [key, value] of Object.entries(headers || {})) {
345
- const k = String(key).toLowerCase();
346
- if (HOP_BY_HOP_HEADERS.has(k) || k === 'content-length') continue;
347
- out[key] = value;
348
- }
349
- return out;
350
- }
351
-
352
- function proxyApiRequest(req, res) {
353
- const session = browserSession(req);
354
- if (!session.sessionId) {
355
- sendJson(res, 401, { detail: 'Gateway sign-in required' });
356
- return;
357
- }
358
- if (mutatingMethod(req.method)) {
359
- const presented = String(req.headers['x-abstractcode-csrf'] || '').trim();
360
- if (!session.csrfToken || presented !== session.csrfToken) {
361
- sendJson(res, 403, { detail: 'Gateway browser session CSRF token missing or invalid', reason_code: 'csrf_required' });
362
- return;
363
- }
364
- }
365
- let backend;
366
- try {
367
- backend = resolveBackend(session.gatewayUrl);
368
- } catch (err) {
369
- sendJson(res, 500, { detail: `Invalid gateway URL: ${String(err?.message || err)}` });
370
- return;
371
- }
372
- const headers = { ...req.headers, host: backend.url.host };
373
- delete headers.cookie;
374
- delete headers.authorization;
375
- delete headers['x-forwarded-for'];
376
- delete headers['x-forwarded-host'];
377
- delete headers['x-forwarded-proto'];
378
- headers['x-abstractgateway-session'] = session.sessionId;
379
- if (mutatingMethod(req.method)) headers['x-abstractgateway-csrf'] = session.csrfToken;
380
- const proxyReq = backend.client.request(
381
- { protocol: backend.url.protocol, hostname: backend.url.hostname, port: backend.url.port, method: req.method, path: req.url, headers },
382
- (proxyRes) => {
383
- res.writeHead(proxyRes.statusCode || 502, proxyHeaders(proxyRes.headers));
384
- proxyRes.pipe(res);
385
- }
386
- );
387
- proxyReq.on('error', (err) => sendJson(res, 502, { detail: `Backend not reachable at ${backend.origin} (${String(err?.message || err)})` }));
388
- req.pipe(proxyReq);
389
- }
390
-
391
- const server = http.createServer((req, res) => {
392
- // Remove query strings and normalize path
393
- const url = new URL(req.url, `http://${req.headers.host}`);
394
- let pathname = url.pathname;
395
-
396
- if (pathname === '/api/connection/gateway') {
397
- void handleConnectionApi(req, res);
398
- return;
399
- }
400
-
401
- if (pathname.startsWith('/api/')) {
402
- proxyApiRequest(req, res);
403
- return;
404
- }
405
-
406
- // Security: prevent directory traversal
407
- if (pathname.includes('..')) {
408
- res.writeHead(400);
409
- res.end('Bad Request');
410
- return;
411
- }
412
-
413
- // Try to serve the requested file
414
- let filePath = join(DIST_DIR, pathname);
415
-
416
- if (serveFile(res, filePath)) {
417
- return;
418
- }
419
-
420
- // Try with .html extension
421
- if (serveFile(res, filePath + '.html')) {
422
- return;
423
- }
424
-
425
- // Try index.html in directory
426
- if (serveFile(res, join(filePath, 'index.html'))) {
427
- return;
428
- }
429
-
430
- // SPA fallback: serve index.html for all other routes
431
- const indexPath = join(DIST_DIR, 'index.html');
432
- if (serveFile(res, indexPath)) {
433
- return;
434
- }
435
-
436
- // If nothing works, return 404
437
- res.writeHead(404);
438
- res.end('Not Found');
439
- });
440
-
441
- server.listen(PORT, HOST, () => {
9
+ server.listen(port, host, () => {
442
10
  console.log(`
443
11
  ╔════════════════════════════════════════════════════╗
444
12
  ║ AbstractCode Web UI is running! ║
445
13
  ╚════════════════════════════════════════════════════╝
446
14
 
447
- 🌐 Local: http://localhost:${PORT}
448
- 🌐 Network: http://${HOST}:${PORT}
15
+ 🌐 Local: http://localhost:${port}
16
+ 🌐 Network: http://${host}:${port}
449
17
 
450
18
  💻 Browser-based coding assistant
451
19
  🔗 Connect to AbstractGateway in settings
@@ -455,12 +23,10 @@ server.listen(PORT, HOST, () => {
455
23
  `);
456
24
  });
457
25
 
458
- process.on('SIGINT', () => {
459
- console.log('\n\n👋 Shutting down AbstractCode Web...\n');
460
- process.exit(0);
461
- });
26
+ function shutdown() {
27
+ console.log("\n\n👋 Shutting down AbstractCode Web...\n");
28
+ server.close(() => process.exit(0));
29
+ }
462
30
 
463
- process.on('SIGTERM', () => {
464
- console.log('\n\n👋 Shutting down AbstractCode Web...\n');
465
- process.exit(0);
466
- });
31
+ process.on("SIGINT", shutdown);
32
+ process.on("SIGTERM", shutdown);