@aion0/forge 0.10.25 → 0.10.27

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.
@@ -0,0 +1,243 @@
1
+ /**
2
+ * Connector test runner — extracted from the /api/connectors/:id/test
3
+ * route so both that HTTP endpoint AND server-side callers (e.g. the
4
+ * Login Status panel) can run probes without a self-fetch dance.
5
+ *
6
+ * Self-fetching the route from inside the server hits the auth
7
+ * middleware (no session cookie → 401), making every probe look
8
+ * "unauthorized" regardless of the real connector state. Direct
9
+ * function calls bypass that.
10
+ */
11
+
12
+ import {
13
+ getConnector,
14
+ getConnectorEntries,
15
+ getInstalledConnector,
16
+ } from './registry';
17
+ import { expandSettingsTokens, expandAllTokens } from '../plugins/templates';
18
+ import { bridgeRpc } from '../chat/bridge-client';
19
+ import { applyAuth } from '../chat/protocols/http';
20
+ import type { ConnectorDefinition, ConnectorTest, HttpRequestSpec } from './types';
21
+
22
+ const DEFAULT_TIMEOUT_MS = 15_000;
23
+ const MAX_BODY_PREVIEW = 1024;
24
+
25
+ export interface TestResult {
26
+ ok: boolean;
27
+ status?: number;
28
+ message?: string;
29
+ error?: string;
30
+ duration_ms?: number;
31
+ body_preview?: string;
32
+ /** Final landed URL for browser probes — helps diagnose redirects. */
33
+ url?: string;
34
+ }
35
+
36
+ function expandString(s: string, settings: Record<string, unknown>): string {
37
+ return expandAllTokens(s, settings as Record<string, any>, {});
38
+ }
39
+
40
+ function buildUrl(spec: HttpRequestSpec, settings: Record<string, unknown>): string {
41
+ let url = expandString(spec.url, settings);
42
+ if (spec.query) {
43
+ const u = new URL(url);
44
+ for (const [k, raw] of Object.entries(spec.query)) {
45
+ u.searchParams.set(k, expandString(String(raw), settings));
46
+ }
47
+ url = u.toString();
48
+ }
49
+ return url;
50
+ }
51
+
52
+ function buildHeaders(spec: HttpRequestSpec, settings: Record<string, unknown>): Headers {
53
+ const h = new Headers();
54
+ if (spec.headers) {
55
+ for (const [k, raw] of Object.entries(spec.headers)) {
56
+ h.set(k, expandString(String(raw), settings));
57
+ }
58
+ }
59
+ return h;
60
+ }
61
+
62
+ function buildBody(
63
+ spec: HttpRequestSpec,
64
+ settings: Record<string, unknown>,
65
+ ): { body?: string; contentType?: string } {
66
+ if (spec.body == null) return {};
67
+ if (typeof spec.body === 'string') {
68
+ return { body: expandString(spec.body, settings) };
69
+ }
70
+ const out: Record<string, unknown> = {};
71
+ for (const [k, v] of Object.entries(spec.body)) {
72
+ out[k] = typeof v === 'string' ? expandString(v, settings) : v;
73
+ }
74
+ return { body: JSON.stringify(out), contentType: 'application/json' };
75
+ }
76
+
77
+ function renderTemplate(template: string, body: unknown): string {
78
+ return template.replace(/\{\{([^{}]+)\}\}/g, (_match, expr) => {
79
+ const path = String(expr).trim().split('.').filter(Boolean);
80
+ let cur: any = body;
81
+ for (const p of path) {
82
+ if (cur && typeof cur === 'object' && p in cur) cur = cur[p];
83
+ else return '?';
84
+ }
85
+ if (cur == null) return '?';
86
+ return typeof cur === 'string' ? cur : JSON.stringify(cur);
87
+ });
88
+ }
89
+
90
+ async function runHttpProbe(
91
+ test: ConnectorTest,
92
+ settings: Record<string, unknown>,
93
+ def: ConnectorDefinition,
94
+ ): Promise<TestResult> {
95
+ const spec = test.request;
96
+ if (!spec?.url) return { ok: false, error: 'test.request.url is required for http probe' };
97
+
98
+ // Multi-instance overlay: first instance only (matches tool-dispatcher).
99
+ let effectiveSettings = settings as Record<string, any>;
100
+ let instances = effectiveSettings?.instances;
101
+ if (typeof instances === 'string') {
102
+ try { instances = JSON.parse(instances); } catch { instances = null; }
103
+ }
104
+ if (
105
+ Array.isArray(instances) &&
106
+ instances.length > 0 &&
107
+ instances.every((i: any) => i && typeof i === 'object' && typeof i.name === 'string')
108
+ ) {
109
+ effectiveSettings = { ...effectiveSettings, ...instances[0] };
110
+ }
111
+
112
+ const method = (spec.method || 'GET').toUpperCase();
113
+ let url = buildUrl(spec, effectiveSettings);
114
+ const headers = buildHeaders(spec, effectiveSettings);
115
+ const { body, contentType } = buildBody(spec, effectiveSettings);
116
+ if (body != null && contentType && !headers.has('content-type')) {
117
+ headers.set('content-type', contentType);
118
+ }
119
+ try {
120
+ url = await applyAuth(url, headers, def.auth, effectiveSettings);
121
+ } catch (e) {
122
+ return { ok: false, error: `auth setup failed: ${(e as Error).message}` };
123
+ }
124
+
125
+ const timeoutMs = test.timeout_ms || DEFAULT_TIMEOUT_MS;
126
+ const okStatus = test.ok_status?.length ? test.ok_status : [200];
127
+
128
+ const ctrl = new AbortController();
129
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
130
+ const t0 = Date.now();
131
+ let res: Response;
132
+ try {
133
+ res = await fetch(url, { method, headers, body, signal: ctrl.signal });
134
+ } catch (e) {
135
+ clearTimeout(timer);
136
+ const err = e as Error & { cause?: unknown };
137
+ const cause = err.cause instanceof Error ? `: ${err.cause.message}` : '';
138
+ return {
139
+ ok: false,
140
+ error: `request failed: ${err.message}${cause}`,
141
+ duration_ms: Date.now() - t0,
142
+ };
143
+ }
144
+ clearTimeout(timer);
145
+
146
+ const duration = Date.now() - t0;
147
+ const text = await res.text().catch(() => '');
148
+ const preview = text.length > MAX_BODY_PREVIEW ? text.slice(0, MAX_BODY_PREVIEW) + '…' : text;
149
+
150
+ if (!okStatus.includes(res.status)) {
151
+ let errMsg = `HTTP ${res.status} ${res.statusText}`;
152
+ try {
153
+ const j = JSON.parse(text);
154
+ if (typeof j?.error === 'string') errMsg += `: ${j.error}`;
155
+ else if (typeof j?.message === 'string') errMsg += `: ${j.message}`;
156
+ else if (typeof j?.error_description === 'string') errMsg += `: ${j.error_description}`;
157
+ } catch {}
158
+ return {
159
+ ok: false,
160
+ status: res.status,
161
+ error: errMsg,
162
+ duration_ms: duration,
163
+ body_preview: preview,
164
+ };
165
+ }
166
+
167
+ let parsedBody: unknown = null;
168
+ try { parsedBody = JSON.parse(text); } catch {}
169
+ const message = test.ok_template
170
+ ? renderTemplate(test.ok_template, parsedBody)
171
+ : `OK (HTTP ${res.status})`;
172
+ return { ok: true, status: res.status, message, duration_ms: duration };
173
+ }
174
+
175
+ async function runBrowserProbe(
176
+ def: ConnectorDefinition,
177
+ settings: Record<string, unknown>,
178
+ ): Promise<TestResult> {
179
+ const entries = getConnectorEntries(def);
180
+ const entry = entries[0];
181
+ const hostMatch = def.host_match || entry?.host_match;
182
+ const loginRedirect = def.login_redirect || entry?.login_redirect;
183
+ if (!hostMatch) {
184
+ return { ok: false, error: 'browser probe requires host_match on the manifest' };
185
+ }
186
+ const expandedHost = expandSettingsTokens(hostMatch, settings as any);
187
+ const expandedLoginRedirect = loginRedirect
188
+ ? expandSettingsTokens(loginRedirect, settings as any)
189
+ : undefined;
190
+
191
+ const t0 = Date.now();
192
+ let value: unknown;
193
+ try {
194
+ value = await bridgeRpc('connector.probe', {
195
+ pluginId: def.id,
196
+ host_match: expandedHost,
197
+ login_redirect: expandedLoginRedirect,
198
+ runner: def.runner || entry?.runner || 'main',
199
+ timeout_ms: def.test?.timeout_ms || 30_000,
200
+ });
201
+ } catch (e) {
202
+ const raw = (e as Error).message || String(e);
203
+ let friendly = raw;
204
+ if (raw.includes('unknown method: connector.probe')) {
205
+ friendly =
206
+ 'Your Forge browser extension is out of date — it doesn\'t know how to run browser probes yet. ' +
207
+ 'Rebuild the extension (pnpm ext in forge-browser-extension), then chrome://extensions → Reload, and try Test again.';
208
+ } else if (raw.includes('bridge') && raw.includes('unreachable')) {
209
+ friendly =
210
+ 'Forge browser bridge is unreachable on port 8407. Restart Forge (forge server restart) or check that the browser-bridge standalone is running.';
211
+ } else if (raw.includes('no paired extensions') || raw.includes('no connected')) {
212
+ friendly =
213
+ 'Forge browser extension not connected. Install the extension from forge-browser-extension/dist, pin it, and sign in with your Forge URL + admin password.';
214
+ }
215
+ return { ok: false, error: friendly, duration_ms: Date.now() - t0 };
216
+ }
217
+ const r = (value || {}) as { ok?: boolean; url?: string; error?: string };
218
+ return {
219
+ ok: !!r.ok,
220
+ message: r.ok ? `Session active${r.url ? ` · ${r.url}` : ''}` : undefined,
221
+ error: r.ok ? undefined : (r.error || 'login required'),
222
+ url: r.url,
223
+ duration_ms: Date.now() - t0,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Run a connector's `test:` probe. Returns a structured result. The HTTP
229
+ * route + Login Status panel both call this — they share the same
230
+ * semantics for free.
231
+ */
232
+ export async function runConnectorTest(id: string): Promise<TestResult & { code?: number }> {
233
+ const def = getConnector(id);
234
+ if (!def) return { ok: false, error: 'connector not found', code: 404 };
235
+ if (!def.test) return { ok: false, error: 'connector has no test block', code: 400 };
236
+ const inst = getInstalledConnector(id);
237
+ if (!inst) return { ok: false, error: 'connector not installed', code: 400 };
238
+
239
+ const probe = def.test.probe || 'http';
240
+ return probe === 'browser'
241
+ ? await runBrowserProbe(def, inst.config)
242
+ : await runHttpProbe(def.test, inst.config, def);
243
+ }
@@ -159,7 +159,36 @@ export type ConnectorAuth =
159
159
  | { type: 'basic'; username: string; password: string }
160
160
  | { type: 'bearer'; token: string }
161
161
  | { type: 'header'; name: string; value: string }
162
- | { type: 'query'; name: string; value: string };
162
+ | { type: 'query'; name: string; value: string }
163
+ /**
164
+ * Two-step bearer-token exchange, e.g. Black Duck:
165
+ * POST {exchange_url} Authorization: {exchange_auth_header}
166
+ * → { bearerToken, expiresInMilliseconds }
167
+ * ...then every subsequent request gets `Authorization: Bearer <jwt>`.
168
+ * Forge caches the bearer in-memory keyed by (api_token, exchange_url)
169
+ * and refreshes when within 60s of expiry. Tool calls stay fast — only
170
+ * the first call per ~2h pays the exchange round-trip.
171
+ */
172
+ | {
173
+ type: 'bearer-token-exchange';
174
+ /** Long-lived API token sent to the exchange endpoint. Templated. */
175
+ api_token: string;
176
+ /** Exchange URL. Templated — usually `{base_url}/api/tokens/authenticate`. */
177
+ exchange_url: string;
178
+ /** HTTP method for the exchange. Default POST. */
179
+ exchange_method?: 'POST' | 'GET';
180
+ /**
181
+ * Authorization header VALUE sent to the exchange endpoint.
182
+ * Default `token {api_token}` (Black Duck style). Templated.
183
+ */
184
+ exchange_auth_header?: string;
185
+ /** Extra headers (Accept etc.) for the exchange. Values templated. */
186
+ exchange_headers?: Record<string, string>;
187
+ /** JSON path to the bearer in the exchange response. Default `bearerToken`. */
188
+ bearer_path?: string;
189
+ /** JSON path to expiry ms in the exchange response. Default `expiresInMilliseconds`. */
190
+ expires_path?: string;
191
+ };
163
192
 
164
193
  /**
165
194
  * Server-side HTTP request shape, used by `protocol: http` tools.
@@ -205,6 +234,21 @@ export interface HttpRequestSpec {
205
234
  * rather than baking them into the manifest.
206
235
  */
207
236
  body_form_inject_from?: string;
237
+ /**
238
+ * Names (case-insensitive) of response headers to surface in the
239
+ * truncated preamble. Default empty. Use when the connector needs
240
+ * a value the upstream returns via headers — typically `set-cookie`
241
+ * for session bootstrap, `location` for create-redirect APIs, or
242
+ * `x-ratelimit-remaining` for backoff logic.
243
+ *
244
+ * Headers appear between the status line and the blank line that
245
+ * separates preamble from body, one per line as `name: value`.
246
+ * Pipeline scripts can grep them with sed/awk before the body parse:
247
+ * JS=$(echo "$RESP" | jq -r '.content' | sed -n 's/^set-cookie: //p' | …)
248
+ *
249
+ * Ignored when `noTruncation: true` (raw-body mode for Jobs scheduler).
250
+ */
251
+ capture_response_headers?: string[];
208
252
  }
209
253
 
210
254
  /**
@@ -432,6 +476,19 @@ export interface ConnectorDefinition {
432
476
  */
433
477
  auth?: ConnectorAuth;
434
478
 
479
+ /**
480
+ * Connector-level HTTP runtime knobs. Apply to every `protocol: http`
481
+ * tool in this connector unless overridden per-tool.
482
+ *
483
+ * `verify_tls: false` disables TLS cert verification — necessary for
484
+ * appliances that ship self-signed certs (FortiNAC, ESXi, internal
485
+ * services). Set this ONLY for hosts you trust by IP; turning it off
486
+ * means an attacker on-path could impersonate the server.
487
+ */
488
+ http?: {
489
+ verify_tls?: boolean;
490
+ };
491
+
435
492
  // ─── 1:N suite — list of sibling entries sharing auth ──
436
493
  connectors?: ConnectorEntry[];
437
494
 
@@ -25,29 +25,44 @@ chrome-devtools-mcp ──CDP──▶ Chrome (user's real instance)
25
25
 
26
26
  ### 1. Start Chrome with remote debugging enabled
27
27
 
28
- Quit all Chrome windows first (so the new instance can take the debug
29
- port).
28
+ **Easiest path use the bundled script:**
29
+ ```bash
30
+ ./scripts/chrome-mcp.sh restart # stop + start with debug port 9222
31
+ ./scripts/chrome-mcp.sh status # check whether port is up
32
+ ```
33
+ Subcommands: `start`, `stop`, `restart`, `status`. Use `-p PORT` to override.
34
+
35
+ **Manual path** — quit all Chrome windows first (so the new instance
36
+ can take the debug port), then:
30
37
 
31
38
  **macOS:**
32
39
  ```bash
33
40
  /Applications/Google\ Chrome.app/Contents/MacOS/Google\ Chrome \
34
41
  --remote-debugging-port=9222 \
35
- --user-data-dir="$HOME/Library/Application Support/Google/Chrome"
42
+ --user-data-dir="$HOME/.chrome-mcp-profile"
36
43
  ```
37
44
 
38
- The `--user-data-dir` argument keeps your normal profile (and all your
39
- logins) drop it only if you want a fresh profile.
45
+ **IMPORTANT — non-default profile required.** Chrome refuses to open
46
+ the remote debug port on the default user-data-dir (security guard, so
47
+ localhost code can't hijack your real Chrome with all its logins). The
48
+ error message reads: `DevTools remote debugging requires a non-default
49
+ data directory`.
50
+
51
+ Use a separate path like `$HOME/.chrome-mcp-profile`. **One-time cost:**
52
+ this profile starts empty, so you log in to NAC / Mantis / GitLab once
53
+ inside it. Cookies persist across restarts — wipe with
54
+ `rm -rf "$HOME/.chrome-mcp-profile"` if you want a fresh start.
40
55
 
41
56
  **Linux:**
42
57
  ```bash
43
- google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.config/google-chrome"
58
+ google-chrome --remote-debugging-port=9222 --user-data-dir="$HOME/.chrome-mcp-profile"
44
59
  ```
45
60
 
46
61
  **Windows:**
47
62
  ```powershell
48
63
  "C:\Program Files\Google\Chrome\Application\chrome.exe" `
49
64
  --remote-debugging-port=9222 `
50
- --user-data-dir="$env:LOCALAPPDATA\Google\Chrome\User Data"
65
+ --user-data-dir="$env:USERPROFILE\.chrome-mcp-profile"
51
66
  ```
52
67
 
53
68
  Verify it worked: open `http://localhost:9222/json/version` in the new
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.25",
3
+ "version": "0.10.27",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {
@@ -55,6 +55,7 @@
55
55
  "react-dom": "^19.2.4",
56
56
  "react-markdown": "^10.1.0",
57
57
  "remark-gfm": "^4.0.1",
58
+ "undici": "^8.3.0",
58
59
  "ws": "^8.19.0",
59
60
  "yaml": "^2.8.2",
60
61
  "zod": "^4.3.6"