@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 +13 -4
- package/lib/chat/protocols/http.ts +66 -17
- package/lib/chat/protocols/ssh.ts +4 -1
- package/lib/connectors/test-runner.ts +29 -1
- package/lib/connectors/types.ts +48 -4
- package/lib/help-content.ts +9 -15
- package/lib/help-docs/21-build-connector.md +60 -0
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.29
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-02
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.28
|
|
6
6
|
|
|
7
7
|
### Other
|
|
8
|
-
-
|
|
8
|
+
- feat(ssh): template-expand spec.timeout_sec — lets 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.
|
|
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
|
-
|
|
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
|
|
208
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
218
|
-
throw new Error(`bearer-token-exchange: ${res.status} ${res.statusText}: ${
|
|
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
|
|
227
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/lib/connectors/types.ts
CHANGED
|
@@ -171,23 +171,52 @@ export type ConnectorAuth =
|
|
|
171
171
|
*/
|
|
172
172
|
| {
|
|
173
173
|
type: 'bearer-token-exchange';
|
|
174
|
-
/**
|
|
175
|
-
|
|
176
|
-
|
|
174
|
+
/**
|
|
175
|
+
* Long-lived API token sent to the exchange endpoint (BD-style
|
|
176
|
+
* auth via Authorization header). Optional — omit 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).
|
|
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
|
package/lib/help-content.ts
CHANGED
|
@@ -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
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
//
|
|
17
|
-
//
|
|
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
|
-
|
|
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