@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.
- package/RELEASE_NOTES.md +8 -5
- package/app/api/connectors/[id]/test/route.ts +15 -272
- package/app/api/login-status/[id]/check/route.ts +22 -0
- package/app/api/login-status/route.ts +36 -0
- package/components/Dashboard.tsx +26 -0
- package/components/LoginStatusPanel.tsx +223 -0
- package/docs/blackduck-openapi.yaml +9626 -0
- package/lib/auth/login-status.ts +291 -0
- package/lib/chat/agent-loop.ts +4 -1
- package/lib/chat/protocols/http.ts +125 -9
- package/lib/chat/session-store.ts +32 -9
- package/lib/chat/tool-dispatcher.ts +1 -1
- package/lib/connectors/test-runner.ts +243 -0
- package/lib/connectors/types.ts +58 -1
- package/lib/help-docs/18-chrome-mcp.md +22 -7
- package/package.json +2 -1
|
@@ -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
|
+
}
|
package/lib/connectors/types.ts
CHANGED
|
@@ -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
|
-
|
|
29
|
-
|
|
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
|
|
42
|
+
--user-data-dir="$HOME/.chrome-mcp-profile"
|
|
36
43
|
```
|
|
37
44
|
|
|
38
|
-
|
|
39
|
-
|
|
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/.
|
|
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:
|
|
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.
|
|
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"
|