@aion0/forge 0.10.28 → 0.10.29

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 CHANGED
@@ -1,11 +1,20 @@
1
- # Forge v0.10.28
1
+ # Forge v0.10.29
2
2
 
3
3
  Released: 2026-06-02
4
4
 
5
- ## Changes since v0.10.27
5
+ ## Changes since v0.10.28
6
6
 
7
7
  ### Other
8
- - fix(login-status): remove GitLab 2FA row 2fa_verify is interactive, can't probe
8
+ - feat(ssh): template-expand spec.timeout_seclets tools surface as arg
9
+ - fix(help-content): drop import.meta.url branch — Turbopack rejects webpackIgnore
10
+ - fix(help-content): suppress Turbopack 'cannot resolve ./help-docs' warning
11
+ - fix(http): empty-string body resolves to no-body
12
+ - fix(http-auth): bearer-token-exchange honours connector http.verify_tls
13
+ - feat(connector-test): add body_match regex for response-body validation
14
+ - fix(http): reorder method-template expand to AFTER argsWithDefaults
15
+ - fix(http): expand templates in spec.method before uppercase
16
+ - docs(connector-authoring): add bearer-token-exchange section to 21-build-connector
17
+ - feat(http-auth): bearer-token-exchange supports body-mode + bare format
9
18
 
10
19
 
11
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.27...v0.10.28
20
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.28...v0.10.29
@@ -191,6 +191,7 @@ async function exchangeBearerToken(
191
191
  auth: Extract<ConnectorAuth, { type: 'bearer-token-exchange' }>,
192
192
  settings: Record<string, any>,
193
193
  args: Record<string, any>,
194
+ verifyTls?: boolean,
194
195
  ): Promise<string> {
195
196
  const exp = (s: string | undefined) =>
196
197
  s == null ? '' : expandAllTokens(String(s), settings, args);
@@ -198,24 +199,57 @@ async function exchangeBearerToken(
198
199
  // Same pathname-only collapse as buildUrl — guards against trailing
199
200
  // slash on settings.base_url producing `host//api/...`.
200
201
  const exchangeUrl = collapsePathSlashes(exp(auth.exchange_url));
201
- if (!apiToken) throw new Error('bearer-token-exchange: api_token is empty');
202
202
  if (!exchangeUrl) throw new Error('bearer-token-exchange: exchange_url is empty');
203
- const key = `${apiToken}|${exchangeUrl}`;
203
+
204
+ // Two credential modes:
205
+ // header-mode → exchange_auth_header (BD: "token <api_token>")
206
+ // body-mode → exchange_body (NCM: {username, password})
207
+ // Cache key derives from whichever credential identity is in play, so
208
+ // password rotation invalidates cache, and multi-host installs cache
209
+ // per (host, credentials) pair.
210
+ const usingBody = !!auth.exchange_body;
211
+ const credIdentity = usingBody
212
+ ? JSON.stringify(
213
+ Object.fromEntries(
214
+ Object.entries(auth.exchange_body!).map(([k, v]) => [k, exp(String(v ?? ''))]),
215
+ ),
216
+ )
217
+ : apiToken;
218
+ if (!credIdentity) throw new Error('bearer-token-exchange: no credentials (api_token or exchange_body)');
219
+ const key = `${credIdentity}|${exchangeUrl}`;
204
220
  const cached = _bearerCache.get(key);
205
221
  if (cached && cached.expiresAt > Date.now() + 60_000) return cached.bearer;
206
222
 
207
- const authHeader = exp(auth.exchange_auth_header || `token ${auth.api_token}`);
208
- const headers: Record<string, string> = { Authorization: authHeader };
223
+ const headers: Record<string, string> = {};
224
+ let body: string | undefined;
225
+ if (usingBody) {
226
+ headers['content-type'] = 'application/json';
227
+ const expanded: Record<string, unknown> = {};
228
+ for (const [k, v] of Object.entries(auth.exchange_body!)) {
229
+ expanded[k] = typeof v === 'string' ? exp(v) : v;
230
+ }
231
+ body = JSON.stringify(expanded);
232
+ } else {
233
+ headers['Authorization'] = exp(auth.exchange_auth_header || `token ${auth.api_token}`);
234
+ }
209
235
  if (auth.exchange_headers) {
210
236
  for (const [k, v] of Object.entries(auth.exchange_headers)) headers[k] = exp(v);
211
237
  }
212
- const res = await fetch(exchangeUrl, {
213
- method: auth.exchange_method || 'POST',
214
- headers,
215
- });
238
+ // Honour connector-level http.verify_tls self-signed appliances
239
+ // (NCM, NAC, ESXi …) need undici with rejectUnauthorized:false. Default
240
+ // fetch barfs with "fetch failed" otherwise.
241
+ const exchangeFetchInit = { method: auth.exchange_method || 'POST', headers, body };
242
+ let res: Response;
243
+ if (verifyTls === false) {
244
+ const { fetch: undiciFetch, Agent } = await import('undici');
245
+ const dispatcher = new Agent({ connect: { rejectUnauthorized: false } });
246
+ res = await undiciFetch(exchangeUrl, { ...exchangeFetchInit, dispatcher }) as unknown as Response;
247
+ } else {
248
+ res = await fetch(exchangeUrl, exchangeFetchInit);
249
+ }
216
250
  if (!res.ok) {
217
- const body = await res.text().catch(() => '');
218
- throw new Error(`bearer-token-exchange: ${res.status} ${res.statusText}: ${body.slice(0, 200)}`);
251
+ const errBody = await res.text().catch(() => '');
252
+ throw new Error(`bearer-token-exchange: ${res.status} ${res.statusText}: ${errBody.slice(0, 200)}`);
219
253
  }
220
254
  const j: any = await res.json().catch(() => ({}));
221
255
  const bearer = pickByPath(j, auth.bearer_path, 'bearerToken');
@@ -223,8 +257,9 @@ async function exchangeBearerToken(
223
257
  if (typeof bearer !== 'string' || !bearer) {
224
258
  throw new Error(`bearer-token-exchange: missing bearer at "${auth.bearer_path || 'bearerToken'}" in response`);
225
259
  }
226
- // Cache for the reported TTL, or 5 minutes if not provided.
227
- const ttl = expiresMs > 0 ? expiresMs : 5 * 60 * 1000;
260
+ // Cache for the reported TTL, the fallback ttl, or 5 min default.
261
+ const fallbackMs = (auth.expires_ttl_sec || 300) * 1000;
262
+ const ttl = expiresMs > 0 ? expiresMs : fallbackMs;
228
263
  _bearerCache.set(key, { bearer, expiresAt: Date.now() + ttl });
229
264
  return bearer;
230
265
  }
@@ -235,6 +270,7 @@ export async function applyAuth(
235
270
  auth: ConnectorAuth | undefined,
236
271
  settings: Record<string, any>,
237
272
  args: Record<string, any> = {},
273
+ verifyTls?: boolean,
238
274
  ): Promise<string> {
239
275
  if (!auth || auth.type === 'none') return url;
240
276
  const exp = (s: string) => expandAllTokens(String(s ?? ''), settings, args);
@@ -260,8 +296,9 @@ export async function applyAuth(
260
296
  return u.toString();
261
297
  }
262
298
  case 'bearer-token-exchange': {
263
- const bearer = await exchangeBearerToken(auth, settings, args);
264
- headers.set('Authorization', `Bearer ${bearer}`);
299
+ const bearer = await exchangeBearerToken(auth, settings, args, verifyTls);
300
+ const format = auth.bearer_format || 'bearer';
301
+ headers.set('Authorization', format === 'bare' ? bearer : `Bearer ${bearer}`);
265
302
  return url;
266
303
  }
267
304
  }
@@ -281,7 +318,13 @@ function buildHeaders(spec: HttpRequestSpec, settings: Record<string, any>, args
281
318
  function buildBody(spec: HttpRequestSpec, settings: Record<string, any>, args: Record<string, any>): { body?: string; contentType?: string } {
282
319
  if (spec.body != null) {
283
320
  if (typeof spec.body === 'string') {
284
- return { body: expandAllTokens(spec.body, settings, args) };
321
+ const expanded = expandAllTokens(spec.body, settings, args);
322
+ // Empty string after expansion = no body. Lets manifests use
323
+ // `body: "{args.body_json}"` with an empty default — Forge sends
324
+ // no body for GET/DELETE-style calls without the LLM needing to
325
+ // know about the body field at all.
326
+ if (expanded === '') return {};
327
+ return { body: expanded };
285
328
  }
286
329
  const obj = expandObjectLeaves(spec.body, settings, args);
287
330
  return { body: JSON.stringify(obj), contentType: 'application/json' };
@@ -389,7 +432,6 @@ export async function runHttp({ tool, settings, args, connectorAuth, noTruncatio
389
432
  if (!spec || !spec.url) {
390
433
  return { content: 'http tool missing `request.url`', is_error: true };
391
434
  }
392
- const method = (spec.method || 'GET').toUpperCase();
393
435
  const timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(1, Number(tool.timeout_ms || DEFAULT_TIMEOUT_MS)));
394
436
 
395
437
  // Apply parameter defaults from the manifest so templates like
@@ -405,6 +447,13 @@ export async function runHttp({ tool, settings, args, connectorAuth, noTruncatio
405
447
  }
406
448
  }
407
449
 
450
+ // Expand `{args.method}` etc. before uppercasing — call_api-style tools
451
+ // template the method from a tool param. Without expansion the literal
452
+ // `{args.method}` reached fetch as `{ARGS.METHOD}` and bombed with
453
+ // "is not a valid HTTP method".
454
+ const rawMethod = expandAllTokens(spec.method || 'GET', settings, argsWithDefaults);
455
+ const method = (rawMethod || 'GET').toUpperCase();
456
+
408
457
  let url = buildUrl(spec, settings, argsWithDefaults, tool.parameters);
409
458
  const headers = buildHeaders(spec, settings, argsWithDefaults);
410
459
  const { body, contentType } = buildBody(spec, settings, argsWithDefaults);
@@ -413,7 +462,7 @@ export async function runHttp({ tool, settings, args, connectorAuth, noTruncatio
413
462
  // Tool-level auth overrides connector-level. `{ type: 'none' }` is a
414
463
  // valid override that disables auth entirely (public endpoint).
415
464
  const effectiveAuth = tool.auth ?? connectorAuth;
416
- url = await applyAuth(url, headers, effectiveAuth, settings, argsWithDefaults);
465
+ url = await applyAuth(url, headers, effectiveAuth, settings, argsWithDefaults, verifyTls);
417
466
 
418
467
  const controller = new AbortController();
419
468
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -104,7 +104,10 @@ export async function runSsh({ tool, settings, args }: SshProtocolArgs): Promise
104
104
  const doneRe = rx(spec.done_when);
105
105
  const failRe = rx(spec.fail_when);
106
106
  const passwordRe = /password:\s*$/i;
107
- const timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(2_000, Number(spec.timeout_sec || 0) * 1000 || DEFAULT_TIMEOUT_MS));
107
+ // timeout_sec is templated so tools can expose it as an arg
108
+ // (e.g. nac.run_command for long-running `diagnose host list all`).
109
+ const rawTimeout = spec.timeout_sec != null ? exp(String(spec.timeout_sec)) : '';
110
+ const timeoutMs = Math.min(MAX_TIMEOUT_MS, Math.max(2_000, Number(rawTimeout || 0) * 1000 || DEFAULT_TIMEOUT_MS));
108
111
 
109
112
  const sshArgs = [
110
113
  '-tt', // force PTY for interactive prompts
@@ -117,7 +117,7 @@ async function runHttpProbe(
117
117
  headers.set('content-type', contentType);
118
118
  }
119
119
  try {
120
- url = await applyAuth(url, headers, def.auth, effectiveSettings);
120
+ url = await applyAuth(url, headers, def.auth, effectiveSettings, {}, def.http?.verify_tls);
121
121
  } catch (e) {
122
122
  return { ok: false, error: `auth setup failed: ${(e as Error).message}` };
123
123
  }
@@ -164,6 +164,34 @@ async function runHttpProbe(
164
164
  };
165
165
  }
166
166
 
167
+ // body_match — gate the test on response body content (e.g. version
168
+ // check). Applies BEFORE ok_template rendering.
169
+ if (test.body_match) {
170
+ let pattern: RegExp;
171
+ try {
172
+ pattern = new RegExp(test.body_match);
173
+ } catch (e) {
174
+ return {
175
+ ok: false,
176
+ status: res.status,
177
+ error: `invalid body_match regex "${test.body_match}": ${(e as Error).message}`,
178
+ duration_ms: duration,
179
+ body_preview: preview,
180
+ };
181
+ }
182
+ if (!pattern.test(text)) {
183
+ const errMsg = test.body_match_error
184
+ || `body did not match expected pattern /${test.body_match}/ — got: ${preview.slice(0, 100)}`;
185
+ return {
186
+ ok: false,
187
+ status: res.status,
188
+ error: errMsg,
189
+ duration_ms: duration,
190
+ body_preview: preview,
191
+ };
192
+ }
193
+ }
194
+
167
195
  let parsedBody: unknown = null;
168
196
  try { parsedBody = JSON.parse(text); } catch {}
169
197
  const message = test.ok_template
@@ -171,23 +171,52 @@ export type ConnectorAuth =
171
171
  */
172
172
  | {
173
173
  type: 'bearer-token-exchange';
174
- /** Long-lived API token sent to the exchange endpoint. Templated. */
175
- api_token: string;
176
- /** Exchange URL. Templatedusually `{base_url}/api/tokens/authenticate`. */
174
+ /**
175
+ * Long-lived API token sent to the exchange endpoint (BD-style
176
+ * auth via Authorization header). Optionalomit when using
177
+ * username/password via `exchange_body` (NCM-style).
178
+ */
179
+ api_token?: string;
180
+ /**
181
+ * Exchange URL. Templated against settings + args, so `{args.host}`
182
+ * works for connectors that take host per call. Cache key includes
183
+ * the resolved URL so multi-host installs cache per host.
184
+ */
177
185
  exchange_url: string;
178
186
  /** HTTP method for the exchange. Default POST. */
179
187
  exchange_method?: 'POST' | 'GET';
180
188
  /**
181
189
  * Authorization header VALUE sent to the exchange endpoint.
182
- * Default `token {api_token}` (Black Duck style). Templated.
190
+ * Default `token {api_token}` (Black Duck style). Ignored when
191
+ * `exchange_body` is set. Templated.
183
192
  */
184
193
  exchange_auth_header?: string;
185
194
  /** Extra headers (Accept etc.) for the exchange. Values templated. */
186
195
  exchange_headers?: Record<string, string>;
196
+ /**
197
+ * JSON body for the exchange POST (NCM-style username/password
198
+ * login). String values templated against settings + args. When
199
+ * present, content-type is application/json and
200
+ * `exchange_auth_header` is skipped.
201
+ */
202
+ exchange_body?: Record<string, unknown>;
187
203
  /** JSON path to the bearer in the exchange response. Default `bearerToken`. */
188
204
  bearer_path?: string;
189
205
  /** JSON path to expiry ms in the exchange response. Default `expiresInMilliseconds`. */
190
206
  expires_path?: string;
207
+ /**
208
+ * Fallback TTL in seconds when `expires_path` is missing from the
209
+ * response. Useful for APIs whose JWT carries `exp` inside the
210
+ * payload but isn't echoed at the response top level. Default 300.
211
+ */
212
+ expires_ttl_sec?: number;
213
+ /**
214
+ * How to attach the bearer to subsequent requests:
215
+ * `bearer` (default) → `Authorization: Bearer <token>`
216
+ * `bare` → `Authorization: <token>` (NCM, NAC,
217
+ * some legacy APIs)
218
+ */
219
+ bearer_format?: 'bearer' | 'bare';
191
220
  };
192
221
 
193
222
  /**
@@ -410,6 +439,21 @@ export interface ConnectorTest {
410
439
  * Example: "Authenticated as {{username}} ({{name}})".
411
440
  */
412
441
  ok_template?: string;
442
+ /**
443
+ * Optional regex (JS syntax) that the response body must match for
444
+ * the test to pass. Use for version gates or feature flags — e.g.
445
+ * `^(8|9|[1-9]\\d)\\.` to require version 8.x or newer. On mismatch
446
+ * the test fails with a descriptive error including the actual body
447
+ * snippet, so users see immediately why the connector won't work
448
+ * against their server build.
449
+ */
450
+ body_match?: string;
451
+ /**
452
+ * Custom error message shown when `body_match` fails. Defaults to
453
+ * "body did not match expected pattern <regex>". Use when you want
454
+ * to point users at a specific upgrade / config requirement.
455
+ */
456
+ body_match_error?: string;
413
457
 
414
458
  // ── probe: 'browser' ─────────────────────────────────────
415
459
  // No fields required — the extension reuses the connector's
@@ -1,28 +1,22 @@
1
1
  /**
2
2
  * Help-doc reader for the chat agent's read_help_doc / list_help_docs tools.
3
3
  *
4
- * Reads the markdown docs that ship under lib/help-docs/ (same set the Help AI
5
- * terminal uses). Resolved relative to this module so it works regardless of
6
- * whether the on-disk sync (ensureHelpDocs <config>/help) has run yet, then
7
- * falls back to the synced copy if the source tree isn't present (installed
8
- * tarball layouts).
4
+ * Reads the markdown docs synced to <configDir>/help by lib/init.ts on first
5
+ * API request (which runs before any chat call). The source tree at
6
+ * lib/help-docs/ is the authoring location; init.ts copies it out at startup.
9
7
  */
10
8
 
11
9
  import { readFileSync, readdirSync, existsSync } from 'node:fs';
12
10
  import { join } from 'node:path';
13
- import { fileURLToPath } from 'node:url';
14
11
  import { getConfigDir } from './dirs';
15
12
 
16
- // Candidate dirs, in priority order: source tree next to this module, then the
17
- // synced runtime copy under ~/.forge/help.
13
+ // Authoritative runtime location populated by ensureHelpDocs() on init.
14
+ // We used to also probe `new URL('./help-docs', import.meta.url)` as a
15
+ // dev-mode shortcut, but bundlers (Turbopack/webpack) can't ignore the
16
+ // static analysis cleanly and the synced copy is always present in
17
+ // practice, so it's gone.
18
18
  function helpDirs(): string[] {
19
- const dirs: string[] = [];
20
- try {
21
- const here = fileURLToPath(new URL('./help-docs', import.meta.url));
22
- dirs.push(here);
23
- } catch { /* import.meta.url unavailable (CJS bundle) — skip */ }
24
- dirs.push(join(getConfigDir(), 'help'));
25
- return dirs;
19
+ return [join(getConfigDir(), 'help')];
26
20
  }
27
21
 
28
22
  /** List available help-doc filenames (e.g. ["00-overview.md", ...]), sorted. */
@@ -176,6 +176,66 @@ auth:
176
176
 
177
177
  A tool can override (or disable) the inherited scheme with its own `auth:` block. `{ type: none }` skips auth entirely (public endpoint).
178
178
 
179
+ ##### `bearer-token-exchange` — auto-refreshing JWTs
180
+
181
+ For APIs that don't accept static tokens — you POST credentials to an exchange endpoint, get a short-lived JWT/bearer, then use that on every subsequent call. Two real-world flavours, both handled by the same auth type:
182
+
183
+ **Flavour A — header-mode (Black Duck style)**. You have a long-lived API token; you send it in the Authorization header to the exchange URL.
184
+
185
+ ```yaml
186
+ auth:
187
+ type: bearer-token-exchange
188
+ api_token: '{settings.api_token}'
189
+ exchange_url: '{settings.base_url}/api/tokens/authenticate'
190
+ exchange_method: POST # default
191
+ exchange_auth_header: 'token {settings.api_token}' # default
192
+ exchange_headers: # optional vendored Accept
193
+ Accept: 'application/vnd.blackducksoftware.user-4+json'
194
+ bearer_path: bearerToken # default — JSON path in response
195
+ expires_path: expiresInMilliseconds # default — JSON path in response
196
+ # bearer_format: bearer # default — sends 'Authorization: Bearer <jwt>'
197
+ ```
198
+
199
+ **Flavour B — body-mode (FortiNCM / NAC / most appliances)**. You have username + password; you POST them as a JSON body to a login endpoint and the JWT comes back in some nested field. The server expects the bare token (no `Bearer ` prefix).
200
+
201
+ ```yaml
202
+ auth:
203
+ type: bearer-token-exchange
204
+ exchange_url: 'https://{args.host}/api/v3/auth/login' # {args.*} works — host can be per-call
205
+ exchange_method: POST
206
+ exchange_body: # JSON body; values templated
207
+ username: '{settings.username}'
208
+ password: '{settings.password}'
209
+ bearer_path: result.jwt # dotted path into response JSON
210
+ bearer_format: bare # → 'Authorization: <jwt>', no Bearer prefix
211
+ expires_ttl_sec: 540 # fallback TTL when response doesn't include expiry
212
+ ```
213
+
214
+ Forge handles the round-trip transparently: first tool call pays the exchange round-trip, the bearer is cached in-memory keyed by `<credential identity>|<resolved exchange_url>`, and refreshed when within 60s of expiry. Tools see only their own `args.*` — they never touch the token.
215
+
216
+ Cache key derivation:
217
+ - **Credential identity**: `api_token` (header-mode) OR the serialised expanded body (body-mode). Password rotation invalidates the cache.
218
+ - **Resolved exchange_url**: settings + args fully resolved, so multi-host installs (`{args.host}` in URL) cache per-host independently.
219
+
220
+ All fields are templated:
221
+
222
+ | Field | Required | Default | Notes |
223
+ |---|---|---|---|
224
+ | `exchange_url` | yes | — | `{settings.*}` and `{args.*}` both work |
225
+ | `exchange_method` | no | `POST` | `POST` or `GET` |
226
+ | `api_token` | no¹ | — | Header-mode credential |
227
+ | `exchange_auth_header` | no | `token {api_token}` | Ignored when `exchange_body` is set |
228
+ | `exchange_body` | no¹ | — | JSON body for body-mode login |
229
+ | `exchange_headers` | no | — | Extra request headers for the exchange |
230
+ | `bearer_path` | no | `bearerToken` | Dotted path into response JSON |
231
+ | `expires_path` | no | `expiresInMilliseconds` | If missing in response → use `expires_ttl_sec` |
232
+ | `expires_ttl_sec` | no | `300` | Fallback TTL (seconds) |
233
+ | `bearer_format` | no | `bearer` | `bearer` → `Authorization: Bearer <jwt>`; `bare` → `Authorization: <jwt>` |
234
+
235
+ ¹ Provide ONE of `api_token` or `exchange_body`.
236
+
237
+ When picking between the two: if the API has its own "Generate API token" UI for users, prefer header-mode (token rotates rarely, user manages it). If the API only accepts username/password login, use body-mode (Forge stores the password encrypted in settings).
238
+
179
239
  #### Per-parameter URL encoding
180
240
 
181
241
  Default behaviour for an `{args.X}` in a URL path is `encodeURIComponent` — slashes become `%2F`. This is right for GitLab-style project paths but wrong for systems that expect literal slashes (Jenkins folder paths). Override per parameter:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.28",
3
+ "version": "0.10.29",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {