@aion0/forge 0.10.23 → 0.10.26

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,291 @@
1
+ /**
2
+ * Login Status — central registry of expirable auth credentials.
3
+ *
4
+ * Auto-discovers sources from two places:
5
+ * 1. Connector manifests with a `test:` block → one source per
6
+ * connector. Browser-side or HTTP, decided by `test.probe`.
7
+ * 2. External hardcoded sources (currently just GitLab 2FA via
8
+ * `git fetch`).
9
+ *
10
+ * Each source can:
11
+ * - check() → runs the test, returns { ok, detail }
12
+ * - refresh() → returns either a URL for the user to open, or a
13
+ * shell command for them to run interactively
14
+ *
15
+ * Results are cached in-memory and persisted to
16
+ * `<dataDir>/login-status.json` so the panel doesn't re-probe every
17
+ * open (which would slow the UI and disturb the user's Chrome tabs).
18
+ */
19
+
20
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { spawn } from 'node:child_process';
23
+ import { getDataDir } from '../dirs';
24
+ import { listConnectorIds, getConnector, getInstalledConnector } from '../connectors/registry';
25
+ import { runConnectorTest } from '../connectors/test-runner';
26
+ import { expandSettingsTokens } from '../plugins/templates';
27
+ import type { ConnectorDefinition } from '../connectors/types';
28
+
29
+ export type LoginCategory = 'browser' | 'token' | 'external';
30
+
31
+ export type RefreshHint =
32
+ | { kind: 'open-url'; url: string; description?: string }
33
+ | { kind: 'show-command'; command: string; description: string }
34
+ | { kind: 'open-settings'; section: string };
35
+
36
+ export interface LoginSource {
37
+ id: string;
38
+ label: string;
39
+ category: LoginCategory;
40
+ /** Free-form one-line description shown under the label. */
41
+ detail?: string;
42
+ /** Returns true if check is safe to run without prompting the user. */
43
+ refresh: RefreshHint;
44
+ }
45
+
46
+ export interface LoginCheckResult {
47
+ ok: boolean;
48
+ /** Short status message (e.g. "session active", "401 unauthorized"). */
49
+ message?: string;
50
+ /** Optional URL the probe landed on — useful for diagnosing redirects. */
51
+ landed_url?: string;
52
+ /** Timestamp of this check (ms since epoch). */
53
+ checked_at: number;
54
+ /** Duration of the check itself, ms. */
55
+ duration_ms: number;
56
+ }
57
+
58
+ export interface LoginStatusRow {
59
+ source: LoginSource;
60
+ result: LoginCheckResult | null;
61
+ }
62
+
63
+ // ─── In-memory + disk cache ─────────────────────────────────────────
64
+
65
+ const CACHE_FILE = () => join(getDataDir(), 'login-status.json');
66
+ let cache: Record<string, LoginCheckResult> | null = null;
67
+
68
+ function loadCache(): Record<string, LoginCheckResult> {
69
+ if (cache) return cache;
70
+ try {
71
+ if (existsSync(CACHE_FILE())) {
72
+ cache = JSON.parse(readFileSync(CACHE_FILE(), 'utf-8'));
73
+ return cache!;
74
+ }
75
+ } catch (e) {
76
+ console.warn('[login-status] cache read failed:', (e as Error).message);
77
+ }
78
+ cache = {};
79
+ return cache;
80
+ }
81
+
82
+ function persistCache(): void {
83
+ try {
84
+ writeFileSync(CACHE_FILE(), JSON.stringify(cache || {}, null, 2));
85
+ } catch (e) {
86
+ console.warn('[login-status] cache write failed:', (e as Error).message);
87
+ }
88
+ }
89
+
90
+ export function getCachedResult(sourceId: string): LoginCheckResult | null {
91
+ return loadCache()[sourceId] ?? null;
92
+ }
93
+
94
+ export function setCachedResult(sourceId: string, r: LoginCheckResult): void {
95
+ loadCache()[sourceId] = r;
96
+ persistCache();
97
+ }
98
+
99
+ // ─── Source enumeration ─────────────────────────────────────────────
100
+
101
+ /**
102
+ * Build the canonical list of LoginSources. Browser/HTTP sources come
103
+ * from connector manifests; external sources are hardcoded here.
104
+ *
105
+ * Connector-derived rows are filtered to those with `test:` declared —
106
+ * connectors without a test block (e.g. `nac` which uses per-call SSH
107
+ * password) are intentionally omitted.
108
+ */
109
+ export function listSources(): LoginSource[] {
110
+ const out: LoginSource[] = [];
111
+
112
+ // 1. Connector-derived — skip any connector the user hasn't installed
113
+ // (no entry in connector-configs.json). An un-installed connector
114
+ // would just probe with default/empty settings and always fail,
115
+ // polluting the panel.
116
+ for (const id of listConnectorIds()) {
117
+ const def = getConnector(id);
118
+ if (!def || !def.test) continue;
119
+ if (!getInstalledConnector(id)) continue;
120
+ const probe = def.test.probe || 'http';
121
+ const category: LoginCategory = probe === 'browser' ? 'browser' : 'token';
122
+
123
+ // Refresh hint per category
124
+ let refresh: RefreshHint;
125
+ if (probe === 'browser') {
126
+ // For browser sources, host_match (e.g. "{base_url}/*") needs
127
+ // settings tokens expanded — the user-saved {base_url} lives in
128
+ // the connector's installed config, not in plain settings.
129
+ const rawHm = def.host_match || def.connectors?.[0]?.host_match || '';
130
+ const inst = getInstalledConnector(id);
131
+ const expanded = inst ? expandSettingsTokens(rawHm, inst.config as any) : rawHm;
132
+ // Strip Chrome match-pattern suffix like `/*`
133
+ const url = expanded.replace(/\/\*$/, '/');
134
+ // Heuristic: if expansion left literal `{...}` tokens behind,
135
+ // settings are incomplete — fall back to opening Settings.
136
+ const stillUnresolved = /\{[^}]+\}/.test(url) || !/^https?:\/\//.test(url);
137
+ refresh = stillUnresolved
138
+ ? { kind: 'open-settings', section: 'connectors' }
139
+ : { kind: 'open-url', url, description: 'Open in a new tab to log in' };
140
+ } else {
141
+ // HTTP/token — fix by editing connector settings (PAT, API token)
142
+ refresh = { kind: 'open-settings', section: 'connectors' };
143
+ }
144
+
145
+ out.push({
146
+ id: `connector:${id}`,
147
+ label: def.name || id,
148
+ category,
149
+ detail: def.test.description,
150
+ refresh,
151
+ });
152
+ }
153
+
154
+ // 2. External: GitLab 2FA — derive SSH host from the gitlab
155
+ // connector. Only surface this row if gitlab connector is
156
+ // installed; otherwise it's noise.
157
+ const gitlabHost = getGitLabHost();
158
+ if (gitlabHost) {
159
+ out.push({
160
+ id: 'ext:gitlab-2fa',
161
+ label: 'GitLab 2FA',
162
+ category: 'external',
163
+ detail: `ssh git@${gitlabHost} 2fa_verify (BatchMode)`,
164
+ refresh: {
165
+ kind: 'show-command',
166
+ command: `ssh git@${gitlabHost} 2fa_verify`,
167
+ description:
168
+ 'Run in a terminal — it will prompt for your 2FA token. Once accepted, future git fetch/push work for a while.',
169
+ },
170
+ });
171
+ }
172
+
173
+ return out;
174
+ }
175
+
176
+ /** Pull the gitlab connector's host from its installed base_url, if any. */
177
+ function getGitLabHost(): string | null {
178
+ try {
179
+ const inst = getInstalledConnector('gitlab');
180
+ const baseUrl = (inst?.config as any)?.base_url as string | undefined;
181
+ if (!baseUrl) return null;
182
+ return new URL(baseUrl).hostname || null;
183
+ } catch {
184
+ return null;
185
+ }
186
+ }
187
+
188
+ // ─── Checks ─────────────────────────────────────────────────────────
189
+
190
+ /**
191
+ * Run the check for one source. Returns the fresh result AND writes it
192
+ * to the cache.
193
+ */
194
+ export async function checkSource(source: LoginSource): Promise<LoginCheckResult> {
195
+ const t0 = Date.now();
196
+ let r: Omit<LoginCheckResult, 'checked_at' | 'duration_ms'>;
197
+
198
+ if (source.id.startsWith('connector:')) {
199
+ r = await checkConnector(source.id.slice('connector:'.length));
200
+ } else if (source.id === 'ext:gitlab-2fa') {
201
+ r = await checkGitLab2fa();
202
+ } else {
203
+ r = { ok: false, message: `unknown source ${source.id}` };
204
+ }
205
+
206
+ const result: LoginCheckResult = {
207
+ ...r,
208
+ checked_at: Date.now(),
209
+ duration_ms: Date.now() - t0,
210
+ };
211
+ setCachedResult(source.id, result);
212
+ return result;
213
+ }
214
+
215
+ async function checkConnector(
216
+ connectorId: string,
217
+ ): Promise<Omit<LoginCheckResult, 'checked_at' | 'duration_ms'>> {
218
+ // Direct lib call — self-fetching the route hits the auth middleware
219
+ // (no session cookie) and would return "unauthorized" for every probe.
220
+ try {
221
+ const r = await runConnectorTest(connectorId);
222
+ return {
223
+ ok: !!r.ok,
224
+ message: r.ok ? (r.message || 'ok') : (r.error || `HTTP ${r.status || '?'}`),
225
+ landed_url: r.url,
226
+ };
227
+ } catch (e) {
228
+ return { ok: false, message: `probe error: ${(e as Error).message}` };
229
+ }
230
+ }
231
+
232
+ async function checkGitLab2fa(): Promise<Omit<LoginCheckResult, 'checked_at' | 'duration_ms'>> {
233
+ const host = getGitLabHost();
234
+ if (!host) {
235
+ return {
236
+ ok: false,
237
+ message: 'gitlab connector not installed or base_url empty',
238
+ };
239
+ }
240
+ // Passive probe: SSH to the host with NO command. gitlab-shell prints
241
+ // "Welcome to GitLab, @user!" then closes — only triggers SSH key auth,
242
+ // never the interactive 2fa_verify push. Used here purely to confirm
243
+ // the SSH path + key + server are all reachable; if your setup gates
244
+ // git operations behind a separate 2FA window, this won't catch that
245
+ // (no known passive 2FA-status command).
246
+ return new Promise((resolve) => {
247
+ const proc = spawn(
248
+ 'ssh',
249
+ ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=30', `git@${host}`],
250
+ { stdio: ['ignore', 'pipe', 'pipe'] },
251
+ );
252
+ let stdout = '';
253
+ let stderr = '';
254
+ const timer = setTimeout(() => {
255
+ try { proc.kill('SIGTERM'); } catch {}
256
+ resolve({ ok: false, message: 'timeout after 35s — host unreachable' });
257
+ }, 35_000);
258
+ proc.stdout?.on('data', (b) => { stdout += b.toString('utf-8'); });
259
+ proc.stderr?.on('data', (b) => { stderr += b.toString('utf-8'); });
260
+ proc.on('close', (code) => {
261
+ clearTimeout(timer);
262
+ const combined = (stdout + '\n' + stderr).trim().replace(/\s+/g, ' ');
263
+ // gitlab-shell exits 0 on greeting; "Welcome to GitLab" is the
264
+ // success marker regardless of exit code (some setups exit 1 with
265
+ // PTY warnings on stderr but still authenticate).
266
+ const welcomed = /Welcome to GitLab/i.test(combined);
267
+ if (welcomed) {
268
+ const m = combined.match(/Welcome to GitLab,?\s*@?[\w.-]+!?/i);
269
+ resolve({ ok: true, message: m ? m[0] : 'gitlab-shell greeted ok' });
270
+ } else {
271
+ resolve({
272
+ ok: false,
273
+ message: `exit ${code}: ${combined.slice(0, 400) || '(no output)'}`,
274
+ });
275
+ }
276
+ });
277
+ proc.on('error', (e) => {
278
+ clearTimeout(timer);
279
+ resolve({ ok: false, message: e.message });
280
+ });
281
+ });
282
+ }
283
+
284
+ // ─── Bulk view ──────────────────────────────────────────────────────
285
+
286
+ export function listStatus(): LoginStatusRow[] {
287
+ return listSources().map((source) => ({
288
+ source,
289
+ result: getCachedResult(source.id),
290
+ }));
291
+ }
@@ -36,6 +36,13 @@ export interface HttpProtocolArgs {
36
36
  * larger than 8KB (Todos / search responses routinely exceed this).
37
37
  */
38
38
  noTruncation?: boolean;
39
+ /**
40
+ * Set to false to disable TLS cert verification (self-signed appliances:
41
+ * FortiNAC, ESXi, internal services). Default true. Manifests opt in
42
+ * via top-level `http: { verify_tls: false }`; tool-dispatcher forwards
43
+ * that value here.
44
+ */
45
+ verifyTls?: boolean;
39
46
  }
40
47
 
41
48
  export interface HttpProtocolResult {
@@ -143,13 +150,70 @@ function buildUrl(
143
150
  * appends a query param). Centralised so the chat dispatcher and the
144
151
  * connector-test probe stay consistent.
145
152
  */
146
- export function applyAuth(
153
+ // Bearer-token-exchange cache (BD, etc) — module-level so repeated tool
154
+ // calls within the bearer's TTL skip the exchange round-trip.
155
+ // key = `<api_token>|<exchange_url>` (api_token resolved after template
156
+ // expansion so different settings rotations get separate entries).
157
+ const _bearerCache = new Map<string, { bearer: string; expiresAt: number }>();
158
+
159
+ function pickByPath(obj: any, path: string | undefined, fallback: string): any {
160
+ const p = (path && path.trim()) || fallback;
161
+ const parts = p.split('.').filter(Boolean);
162
+ let cur = obj;
163
+ for (const k of parts) {
164
+ if (cur == null || typeof cur !== 'object') return undefined;
165
+ cur = cur[k];
166
+ }
167
+ return cur;
168
+ }
169
+
170
+ async function exchangeBearerToken(
171
+ auth: Extract<ConnectorAuth, { type: 'bearer-token-exchange' }>,
172
+ settings: Record<string, any>,
173
+ args: Record<string, any>,
174
+ ): Promise<string> {
175
+ const exp = (s: string | undefined) =>
176
+ s == null ? '' : expandAllTokens(String(s), settings, args);
177
+ const apiToken = exp(auth.api_token);
178
+ const exchangeUrl = exp(auth.exchange_url);
179
+ if (!apiToken) throw new Error('bearer-token-exchange: api_token is empty');
180
+ if (!exchangeUrl) throw new Error('bearer-token-exchange: exchange_url is empty');
181
+ const key = `${apiToken}|${exchangeUrl}`;
182
+ const cached = _bearerCache.get(key);
183
+ if (cached && cached.expiresAt > Date.now() + 60_000) return cached.bearer;
184
+
185
+ const authHeader = exp(auth.exchange_auth_header || `token ${auth.api_token}`);
186
+ const headers: Record<string, string> = { Authorization: authHeader };
187
+ if (auth.exchange_headers) {
188
+ for (const [k, v] of Object.entries(auth.exchange_headers)) headers[k] = exp(v);
189
+ }
190
+ const res = await fetch(exchangeUrl, {
191
+ method: auth.exchange_method || 'POST',
192
+ headers,
193
+ });
194
+ if (!res.ok) {
195
+ const body = await res.text().catch(() => '');
196
+ throw new Error(`bearer-token-exchange: ${res.status} ${res.statusText}: ${body.slice(0, 200)}`);
197
+ }
198
+ const j: any = await res.json().catch(() => ({}));
199
+ const bearer = pickByPath(j, auth.bearer_path, 'bearerToken');
200
+ const expiresMs = Number(pickByPath(j, auth.expires_path, 'expiresInMilliseconds')) || 0;
201
+ if (typeof bearer !== 'string' || !bearer) {
202
+ throw new Error(`bearer-token-exchange: missing bearer at "${auth.bearer_path || 'bearerToken'}" in response`);
203
+ }
204
+ // Cache for the reported TTL, or 5 minutes if not provided.
205
+ const ttl = expiresMs > 0 ? expiresMs : 5 * 60 * 1000;
206
+ _bearerCache.set(key, { bearer, expiresAt: Date.now() + ttl });
207
+ return bearer;
208
+ }
209
+
210
+ export async function applyAuth(
147
211
  url: string,
148
212
  headers: Headers,
149
213
  auth: ConnectorAuth | undefined,
150
214
  settings: Record<string, any>,
151
215
  args: Record<string, any> = {},
152
- ): string {
216
+ ): Promise<string> {
153
217
  if (!auth || auth.type === 'none') return url;
154
218
  const exp = (s: string) => expandAllTokens(String(s ?? ''), settings, args);
155
219
  switch (auth.type) {
@@ -173,6 +237,11 @@ export function applyAuth(
173
237
  u.searchParams.set(auth.name, exp(auth.value));
174
238
  return u.toString();
175
239
  }
240
+ case 'bearer-token-exchange': {
241
+ const bearer = await exchangeBearerToken(auth, settings, args);
242
+ headers.set('Authorization', `Bearer ${bearer}`);
243
+ return url;
244
+ }
176
245
  }
177
246
  return url;
178
247
  }
@@ -293,7 +362,7 @@ function truncate(s: string): { text: string; truncated: boolean; totalBytes: nu
293
362
  return { text: slice, truncated: true, totalBytes: buf.byteLength };
294
363
  }
295
364
 
296
- export async function runHttp({ tool, settings, args, connectorAuth, noTruncation }: HttpProtocolArgs): Promise<HttpProtocolResult> {
365
+ export async function runHttp({ tool, settings, args, connectorAuth, noTruncation, verifyTls }: HttpProtocolArgs): Promise<HttpProtocolResult> {
297
366
  const spec = tool.request;
298
367
  if (!spec || !spec.url) {
299
368
  return { content: 'http tool missing `request.url`', is_error: true };
@@ -322,17 +391,30 @@ export async function runHttp({ tool, settings, args, connectorAuth, noTruncatio
322
391
  // Tool-level auth overrides connector-level. `{ type: 'none' }` is a
323
392
  // valid override that disables auth entirely (public endpoint).
324
393
  const effectiveAuth = tool.auth ?? connectorAuth;
325
- url = applyAuth(url, headers, effectiveAuth, settings, argsWithDefaults);
394
+ url = await applyAuth(url, headers, effectiveAuth, settings, argsWithDefaults);
326
395
 
327
396
  const controller = new AbortController();
328
397
  const timer = setTimeout(() => controller.abort(), timeoutMs);
329
398
 
330
399
  let res: Response;
331
400
  try {
332
- res = await fetch(url, { method, headers, body, signal: controller.signal });
401
+ if (verifyTls === false) {
402
+ // Skip TLS cert verification — for self-signed appliances (NAC,
403
+ // ESXi, etc.). Use undici's own fetch + Agent so dispatcher and
404
+ // fetch are version-matched; passing an external undici Agent
405
+ // into Node's bundled global fetch fails with UND_ERR_INVALID_ARG
406
+ // because Node 22 ships an older undici than the installed one.
407
+ const { fetch: undiciFetch, Agent } = await import('undici');
408
+ const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
409
+ res = await undiciFetch(url, { method, headers, body, signal: controller.signal, dispatcher }) as unknown as Response;
410
+ } else {
411
+ res = await fetch(url, { method, headers, body, signal: controller.signal });
412
+ }
333
413
  } catch (e) {
334
414
  clearTimeout(timer);
335
- return { content: `http request failed: ${(e as Error).message}`, is_error: true };
415
+ const err = e as Error & { cause?: { message?: string; code?: string } };
416
+ const cause = err.cause ? ` (cause: ${err.cause.code || ''} ${err.cause.message || ''})` : '';
417
+ return { content: `http request failed: ${err.message}${cause}`, is_error: true };
336
418
  }
337
419
  clearTimeout(timer);
338
420
 
@@ -344,8 +426,20 @@ export async function runHttp({ tool, settings, args, connectorAuth, noTruncatio
344
426
  return { content: text, is_error: !res.ok };
345
427
  }
346
428
  const { text: shown, truncated, totalBytes } = truncate(text);
347
- const preamble = `HTTP ${res.status} ${res.statusText} · ${method} ${url}\n` +
348
- (truncated ? `(showing ${MAX_BODY_BYTES} of ${totalBytes} bytes truncated)\n\n` : '\n');
429
+ let preamble = `HTTP ${res.status} ${res.statusText} · ${method} ${url}\n`;
430
+ // Surface user-declared response headers in the preamble so connector
431
+ // bash callers can grep them out before the body parse. Set-Cookie is
432
+ // the canonical case — appliances like FortiNAC put the session token
433
+ // in JSESSIONID and there's no other way for an http: tool to fish it
434
+ // out. Headers are lower-cased on lookup; emitted name preserves the
435
+ // schema's casing for readability.
436
+ if (spec.capture_response_headers && spec.capture_response_headers.length) {
437
+ for (const name of spec.capture_response_headers) {
438
+ const v = res.headers.get(name);
439
+ if (v != null) preamble += `${name}: ${v}\n`;
440
+ }
441
+ }
442
+ preamble += truncated ? `(showing ${MAX_BODY_BYTES} of ${totalBytes} bytes — truncated)\n\n` : '\n';
349
443
  return {
350
444
  content: preamble + shown,
351
445
  is_error: !res.ok,
@@ -576,7 +576,7 @@ export async function dispatchTool(
576
576
  let result: ToolResult;
577
577
  switch (protocol) {
578
578
  case 'http':
579
- result = await runHttp({ tool: located.tool, settings: effectiveSettings, args: argInput, connectorAuth: def.auth, noTruncation: opts.noTruncation });
579
+ result = await runHttp({ tool: located.tool, settings: effectiveSettings, args: argInput, connectorAuth: def.auth, noTruncation: opts.noTruncation, verifyTls: def.http?.verify_tls });
580
580
  break;
581
581
  case 'shell':
582
582
  result = await runShell({ tool: located.tool, settings: effectiveSettings, args: argInput });