@aion0/forge 0.9.15 → 0.9.18

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,22 @@
1
- # Forge v0.9.15
1
+ # Forge v0.9.18
2
2
 
3
- Released: 2026-05-27
3
+ Released: 2026-05-28
4
4
 
5
- ## Changes since v0.9.14
5
+ ## Changes since v0.9.16
6
6
 
7
7
  ### Other
8
- - fix(connectors): derive mode label from tool protocols
8
+ - feat(chat): trigger_pipeline accepts skills array
9
+ - feat(connectors): body_form_inject_from + nested instances UI
10
+ - fix(http-protocol): JSON.parse args[X] when LLM stringified it + template inject keys
11
+ - feat(connectors): http body_form_inject for server-side credential injection
12
+ - Revert "feat(connectors): {secret:...} refs for cross-connector + global secrets"
13
+ - Revert "fix(chat): make secret-refs system prompt push the tool-call path"
14
+ - fix(chat): make secret-refs system prompt push the tool-call path
15
+ - revert: drop migrateConnectorInstanceSecrets startup hook
16
+ - fix(connectors): encrypt nested secrets inside type:instances rows
17
+ - feat(connectors): {secret:...} refs for cross-connector + global secrets
18
+ - feat(connectors): generic 'type: instances' field renderer + v0.9.17
19
+ - feat(connectors): generic auth + url_encoding + body_form + multi-instance
9
20
 
10
21
 
11
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.14...v0.9.15
22
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.9.16...v0.9.18
@@ -41,17 +41,76 @@ function defaultsFor(id: string): Record<string, any> {
41
41
 
42
42
  const SECRET_MASK = '••••••••';
43
43
 
44
- function isSecretField(schema: ConnectorFieldSchema | undefined): boolean {
45
- if (!schema) return false;
46
- return schema.type === 'secret' || (schema.type as string) === 'password';
44
+ /**
45
+ * Walk `value` against `schema`, applying `mask` (plaintext → ••••) or
46
+ * `restore` (•••• stored plaintext). Recurses into `type: 'instances'`
47
+ * arrays so per-row sub-secrets get the same mask treatment as flat
48
+ * top-level secrets. Mirrors the encrypt/decrypt walker in
49
+ * lib/connectors/registry.ts so the on-disk and over-the-wire
50
+ * representations stay symmetric.
51
+ */
52
+ function transformFieldSecrets(
53
+ value: any,
54
+ schema: ConnectorFieldSchema | undefined,
55
+ existingValue: any,
56
+ op: 'mask' | 'restore',
57
+ ): any {
58
+ if (!schema) return value;
59
+ const t = String((schema as any)?.type || '');
60
+
61
+ if (t === 'secret' || t === 'password') {
62
+ if (op === 'mask') {
63
+ return typeof value === 'string' && value ? SECRET_MASK : value;
64
+ }
65
+ // restore
66
+ if (value === SECRET_MASK) {
67
+ return typeof existingValue === 'string' ? existingValue : undefined;
68
+ }
69
+ return value;
70
+ }
71
+
72
+ if (t === 'instances' && (schema as any).fields) {
73
+ const wasString = typeof value === 'string';
74
+ let rows: any;
75
+ if (wasString) {
76
+ try { rows = JSON.parse(value); } catch { return value; }
77
+ } else {
78
+ rows = value;
79
+ }
80
+ if (!Array.isArray(rows)) return value;
81
+
82
+ // Build name→row map of the existing stored value so per-instance
83
+ // mask restoration can find the corresponding row by name.
84
+ let existingRows: any[] = [];
85
+ if (typeof existingValue === 'string') {
86
+ try { existingRows = JSON.parse(existingValue); } catch { existingRows = []; }
87
+ } else if (Array.isArray(existingValue)) {
88
+ existingRows = existingValue;
89
+ }
90
+ const existingByName = new Map<string, any>();
91
+ for (const r of existingRows) {
92
+ if (r && typeof r === 'object' && typeof r.name === 'string') existingByName.set(r.name, r);
93
+ }
94
+
95
+ const transformed = rows.map((row: any) => {
96
+ if (!row || typeof row !== 'object') return row;
97
+ const existingRow = (typeof row.name === 'string' && existingByName.get(row.name)) || {};
98
+ const out: any = { ...row };
99
+ for (const [k, sub] of Object.entries((schema as any).fields)) {
100
+ out[k] = transformFieldSecrets(out[k], sub as ConnectorFieldSchema, existingRow[k], op);
101
+ }
102
+ return out;
103
+ });
104
+ return wasString ? JSON.stringify(transformed) : transformed;
105
+ }
106
+
107
+ return value;
47
108
  }
48
109
 
49
110
  function maskSecrets(settings: Record<string, any>, schema: Record<string, ConnectorFieldSchema>): Record<string, any> {
50
111
  const out: Record<string, any> = { ...settings };
51
112
  for (const [k, v] of Object.entries(schema)) {
52
- if (isSecretField(v) && typeof out[k] === 'string' && out[k]) {
53
- out[k] = SECRET_MASK;
54
- }
113
+ out[k] = transformFieldSecrets(out[k], v, undefined, 'mask');
55
114
  }
56
115
  return out;
57
116
  }
@@ -63,10 +122,9 @@ function restoreSecrets(
63
122
  ): Record<string, any> {
64
123
  const out: Record<string, any> = { ...incoming };
65
124
  for (const [k, v] of Object.entries(schema)) {
66
- if (isSecretField(v) && out[k] === SECRET_MASK) {
67
- if (typeof existing[k] === 'string') out[k] = existing[k];
68
- else delete out[k];
69
- }
125
+ const restored = transformFieldSecrets(out[k], v, existing[k], 'restore');
126
+ if (restored === undefined) delete out[k];
127
+ else out[k] = restored;
70
128
  }
71
129
  return out;
72
130
  }
@@ -34,6 +34,7 @@ import {
34
34
  } from '@/lib/connectors/registry';
35
35
  import { expandSettingsTokens, expandAllTokens } from '@/lib/plugins/templates';
36
36
  import { bridgeRpc } from '@/lib/chat/bridge-client';
37
+ import { applyAuth } from '@/lib/chat/protocols/http';
37
38
  import type { ConnectorDefinition, ConnectorTest, HttpRequestSpec } from '@/lib/connectors/types';
38
39
 
39
40
  const DEFAULT_TIMEOUT_MS = 15_000;
@@ -106,17 +107,39 @@ interface TestResult {
106
107
  body_preview?: string;
107
108
  }
108
109
 
109
- async function runHttpProbe(test: ConnectorTest, settings: Record<string, unknown>): Promise<TestResult> {
110
+ async function runHttpProbe(test: ConnectorTest, settings: Record<string, unknown>, def: ConnectorDefinition): Promise<TestResult> {
110
111
  const spec = test.request;
111
112
  if (!spec?.url) return { ok: false, error: 'test.request.url is required for http probe' };
112
113
 
114
+ // Multi-instance overlay: test probe always uses the first instance
115
+ // (same guard as tool-dispatcher — only kicks in when instances is a
116
+ // well-formed array, so single-instance connectors are unaffected).
117
+ let effectiveSettings = settings as Record<string, any>;
118
+ let instances = effectiveSettings?.instances;
119
+ // type: json fields persist as strings — parse before checking.
120
+ if (typeof instances === 'string') {
121
+ try { instances = JSON.parse(instances); } catch { instances = null; }
122
+ }
123
+ if (
124
+ Array.isArray(instances) &&
125
+ instances.length > 0 &&
126
+ instances.every((i: any) => i && typeof i === 'object' && typeof i.name === 'string')
127
+ ) {
128
+ effectiveSettings = { ...effectiveSettings, ...instances[0] };
129
+ }
130
+
113
131
  const method = (spec.method || 'GET').toUpperCase();
114
- const url = buildUrl(spec, settings);
115
- const headers = buildHeaders(spec, settings);
116
- const { body, contentType } = buildBody(spec, settings);
132
+ let url = buildUrl(spec, effectiveSettings);
133
+ const headers = buildHeaders(spec, effectiveSettings);
134
+ const { body, contentType } = buildBody(spec, effectiveSettings);
117
135
  if (body != null && contentType && !headers.has('content-type')) {
118
136
  headers.set('content-type', contentType);
119
137
  }
138
+ // Apply connector-level auth so the test probe uses the same scheme
139
+ // as live tool calls (e.g. Basic auth for Jenkins). Manifests that
140
+ // hand-craft Authorization in test.request.headers still work — the
141
+ // auth scheme would just overwrite the header.
142
+ url = applyAuth(url, headers, def.auth, effectiveSettings);
120
143
 
121
144
  const timeoutMs = test.timeout_ms || DEFAULT_TIMEOUT_MS;
122
145
  const okStatus = test.ok_status?.length ? test.ok_status : [200];
@@ -255,6 +278,6 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
255
278
  const probe = def.test.probe || 'http';
256
279
  const r = probe === 'browser'
257
280
  ? await runBrowserProbe(def, inst.config)
258
- : await runHttpProbe(def.test, inst.config);
281
+ : await runHttpProbe(def.test, inst.config, def);
259
282
  return NextResponse.json(r);
260
283
  }
@@ -44,6 +44,8 @@ interface FieldSchema {
44
44
  required?: boolean;
45
45
  default?: any;
46
46
  options?: string[];
47
+ /** For type: 'instances' — schema of each row's inner fields. */
48
+ fields?: Record<string, FieldSchema>;
47
49
  }
48
50
 
49
51
  interface ConnectorTool {
@@ -532,7 +534,13 @@ export default function ConnectorsPanel() {
532
534
  <label className="text-[10px] text-[var(--text-secondary)] block mb-0.5">
533
535
  {sc.label || key} {sc.required && <span className="text-red-400">*</span>}
534
536
  </label>
535
- {sc.type === 'boolean' ? (
537
+ {sc.type === 'instances' ? (
538
+ <InstancesField
539
+ schema={sc}
540
+ rawValue={values[key]}
541
+ onChange={(v) => setValues({ ...values, [key]: v })}
542
+ />
543
+ ) : sc.type === 'boolean' ? (
536
544
  <input
537
545
  type="checkbox"
538
546
  checked={values[key] === true || values[key] === 'true'}
@@ -634,3 +642,135 @@ export default function ConnectorsPanel() {
634
642
  </div>
635
643
  );
636
644
  }
645
+
646
+ /**
647
+ * Generic renderer for `type: instances` — a list of named records
648
+ * configured per-connector (Jenkins instances, GitLab tenants, etc.).
649
+ * Each row collapses into the connector's declared sub-fields. Value
650
+ * is round-tripped as a JSON-stringified array so the existing
651
+ * connector-configs.json shape (the textarea-saved string) keeps
652
+ * working unchanged.
653
+ */
654
+ function InstancesField({
655
+ schema,
656
+ rawValue,
657
+ onChange,
658
+ }: {
659
+ schema: FieldSchema;
660
+ rawValue: any;
661
+ onChange: (next: string) => void;
662
+ }) {
663
+ const subFields = schema.fields || {};
664
+ const subKeys = Object.keys(subFields);
665
+
666
+ // Parse incoming value: accept stored JSON string, in-memory array,
667
+ // null, or anything malformed (treat as empty).
668
+ const rows: Record<string, any>[] = (() => {
669
+ let v: any = rawValue;
670
+ if (v == null || v === '') return [];
671
+ if (typeof v === 'string') {
672
+ try { v = JSON.parse(v); } catch { return []; }
673
+ }
674
+ return Array.isArray(v) ? v : [];
675
+ })();
676
+
677
+ const commit = (next: Record<string, any>[]) => onChange(JSON.stringify(next));
678
+
679
+ const addRow = () => {
680
+ const blank: Record<string, any> = {};
681
+ for (const k of subKeys) blank[k] = subFields[k]?.default ?? '';
682
+ commit([...rows, blank]);
683
+ };
684
+ const removeRow = (i: number) => commit(rows.filter((_, idx) => idx !== i));
685
+ const updateRow = (i: number, key: string, val: any) => {
686
+ const next = rows.map((r, idx) => (idx === i ? { ...r, [key]: val } : r));
687
+ commit(next);
688
+ };
689
+
690
+ return (
691
+ <div className="space-y-1.5">
692
+ {rows.length === 0 && (
693
+ <div className="text-[10px] text-[var(--text-secondary)] italic py-1">
694
+ No entries yet — click “+ Add” to create one.
695
+ </div>
696
+ )}
697
+ {rows.map((row, i) => {
698
+ const rowLabel =
699
+ (typeof row.name === 'string' && row.name.trim()) || `(unnamed #${i + 1})`;
700
+ return (
701
+ <div
702
+ key={i}
703
+ className="border border-[var(--border)] rounded p-2 bg-[var(--bg-secondary)]"
704
+ >
705
+ <div className="flex items-center justify-between mb-1.5">
706
+ <span className="text-[10px] font-semibold text-[var(--text-primary)]">
707
+ {rowLabel}
708
+ </span>
709
+ <button
710
+ type="button"
711
+ onClick={() => removeRow(i)}
712
+ title="Remove this entry"
713
+ className="text-[10px] text-red-400 hover:text-red-300"
714
+ >
715
+
716
+ </button>
717
+ </div>
718
+ <div className="space-y-1.5">
719
+ {subKeys.map((k) => {
720
+ const f = subFields[k];
721
+ // Nested instances — render the same component recursively.
722
+ // Lets a connector model "rows of rows" (e.g. Jenkins
723
+ // instances each with a list of inject_params key/value
724
+ // pairs).
725
+ if (f.type === 'instances') {
726
+ return (
727
+ <div key={k}>
728
+ <label className="text-[9px] text-[var(--text-secondary)] block mb-0.5">
729
+ {f.label || k} {f.required && <span className="text-red-400">*</span>}
730
+ </label>
731
+ <InstancesField
732
+ schema={f}
733
+ rawValue={row[k]}
734
+ onChange={(next) => updateRow(i, k, next)}
735
+ />
736
+ {f.description && (
737
+ <p className="text-[9px] text-[var(--text-secondary)] mt-0.5">{f.description}</p>
738
+ )}
739
+ </div>
740
+ );
741
+ }
742
+ const inputType =
743
+ f.type === 'secret' || f.type === 'password'
744
+ ? 'password'
745
+ : f.type === 'number'
746
+ ? 'number'
747
+ : 'text';
748
+ return (
749
+ <div key={k}>
750
+ <label className="text-[9px] text-[var(--text-secondary)] block mb-0.5">
751
+ {f.label || k} {f.required && <span className="text-red-400">*</span>}
752
+ </label>
753
+ <input
754
+ type={inputType}
755
+ value={row[k] ?? ''}
756
+ onChange={(e) => updateRow(i, k, e.target.value)}
757
+ placeholder={f.description || ''}
758
+ className="w-full bg-[var(--bg-tertiary)] border border-[var(--border)] rounded px-2 py-1 text-[10px] text-[var(--text-primary)] font-mono"
759
+ />
760
+ </div>
761
+ );
762
+ })}
763
+ </div>
764
+ </div>
765
+ );
766
+ })}
767
+ <button
768
+ type="button"
769
+ onClick={addRow}
770
+ className="text-[10px] px-2 py-1 rounded border border-dashed border-[var(--border)] text-[var(--text-secondary)] hover:border-[var(--accent)] hover:text-[var(--accent)] transition-colors w-full"
771
+ >
772
+ + Add {schema.label || 'instance'}
773
+ </button>
774
+ </div>
775
+ );
776
+ }
package/install.sh CHANGED
@@ -76,11 +76,25 @@ if [ ${#missing_opts[@]} -gt 0 ]; then
76
76
  fi
77
77
 
78
78
  if [ "$1" = "local" ] || [ "$1" = "--local" ]; then
79
- echo "[forge] Installing from local source..."
80
- npm uninstall -g @aion0/forge 2>/dev/null || true
81
- npm link
82
- echo "[forge] Building..."
79
+ # Build a real npm tarball from the current working tree and install it
80
+ # globally exactly the way `npm install -g @aion0/forge` would after a
81
+ # publish. This runs the `prepack` hook (bundles cli/mw.mjs), respects
82
+ # .npmignore, and uses npm (not pnpm) for the global install — so the
83
+ # behaviour matches what a real user gets from `forge upgrade`.
84
+ echo "[forge] Building from local source (npm pack flow)..."
85
+ SRC_DIR="$(pwd)"
86
+ echo "[forge] Running pnpm build..."
83
87
  pnpm build || echo "[forge] Build completed with warnings (non-critical)"
88
+ echo "[forge] Packing tarball..."
89
+ PACK_DIR="$(mktemp -d -t forge-pack-XXXXXX)"
90
+ TARBALL="$(cd "$PACK_DIR" && npm pack "$SRC_DIR" --silent)"
91
+ TARBALL_PATH="$PACK_DIR/$TARBALL"
92
+ echo "[forge] Built $TARBALL_PATH"
93
+ echo "[forge] Uninstalling previous global install..."
94
+ npm uninstall -g @aion0/forge 2>/dev/null || true
95
+ echo "[forge] Installing tarball globally..."
96
+ (cd /tmp && npm install -g "$TARBALL_PATH")
97
+ rm -rf "$PACK_DIR"
84
98
  else
85
99
  echo "[forge] Installing from npm..."
86
100
  rm -rf "$(npm root -g)/@aion0/forge" 2>/dev/null || true
@@ -15,13 +15,20 @@
15
15
  * is_error so the LLM can react.
16
16
  */
17
17
 
18
- import type { HttpRequestSpec, ConnectorTool } from '../../connectors/types';
18
+ import type { HttpRequestSpec, ConnectorTool, ConnectorAuth, ConnectorFieldSchema } from '../../connectors/types';
19
19
  import { expandAllTokens } from '../../plugins/templates';
20
20
 
21
21
  export interface HttpProtocolArgs {
22
22
  tool: ConnectorTool;
23
23
  settings: Record<string, any>;
24
24
  args: Record<string, any>;
25
+ /**
26
+ * Connector-level auth. Tool-level `tool.auth` takes precedence.
27
+ * Forge resolves the scheme into the right header/query at dispatch
28
+ * time so manifests don't have to hand-craft Authorization headers
29
+ * or base64-encode credentials.
30
+ */
31
+ connectorAuth?: ConnectorAuth;
25
32
  /**
26
33
  * When true, return the full response body without the 8KB cap. Used by
27
34
  * the Jobs scheduler — it parses JSON, not feeds the response into an
@@ -53,22 +60,42 @@ function expandObjectLeaves(obj: any, settings: Record<string, any>, args: Recor
53
60
  }
54
61
 
55
62
  /**
56
- * Expand `{args.X}` placeholders in a URL path with the value URL-
57
- * encoded. `{settings.X}` is NOT encoded — `{settings.base_url}` is the
58
- * scheme + host (with its own `://` and `/`), which must stay literal.
59
- *
60
- * Why: GitLab and many REST APIs accept either a numeric id or a
61
- * URL-encoded namespace path (`fortinac%2FFortiNAC`) as the project
62
- * identifier in the path. Without encoding, `args.project_id =
63
- * "fortinac/FortiNAC"` interpolates as a raw `/` and turns
64
- * `/projects/{args.project_id}/...` into `/projects/fortinac/FortiNAC/...`
65
- * (extra path segment), which the API can't parse. Numeric ids encode
66
- * to themselves — no regression.
63
+ * Encode a string value per its parameter's `url_encoding` declaration.
64
+ * Default `uri_component` matches encodeURIComponent (slashes encoded).
65
+ * `none` is raw, for pre-formatted paths (e.g. Jenkins folder paths
66
+ * `job/team/job/build`). `path_segments` encodes each `/`-separated
67
+ * piece but preserves the slashes good for human-readable paths
68
+ * that contain spaces or unicode.
69
+ */
70
+ function encodePathValue(raw: string, mode: ConnectorFieldSchema['url_encoding'] | undefined): string {
71
+ switch (mode) {
72
+ case 'none':
73
+ return raw;
74
+ case 'path_segments':
75
+ return raw.split('/').map(encodeURIComponent).join('/');
76
+ case 'uri_component':
77
+ case undefined:
78
+ default:
79
+ return encodeURIComponent(raw);
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Expand `{args.X}` placeholders in a URL path. Each arg's encoding is
85
+ * decided by its parameter schema's `url_encoding` field (default
86
+ * `uri_component` — see `encodePathValue` for the modes). `{settings.X}`
87
+ * is NOT encoded — `{settings.base_url}` is the scheme + host (with its
88
+ * own `://` and `/`), which must stay literal.
67
89
  */
68
- function expandUrlPath(template: string, settings: Record<string, any>, args: Record<string, any>): string {
90
+ function expandUrlPath(
91
+ template: string,
92
+ settings: Record<string, any>,
93
+ args: Record<string, any>,
94
+ paramSchemas?: Record<string, ConnectorFieldSchema>,
95
+ ): string {
69
96
  // First handle settings.* with raw substitution (keeps base_url intact).
70
97
  let out = expandAllTokens(template, settings, {});
71
- // Then handle args.* with URL-encoding.
98
+ // Then handle args.* with per-parameter URL encoding.
72
99
  out = out.replace(/\{args\.([^{}]+)\}/g, (full, rawKey) => {
73
100
  const path = String(rawKey).trim().split('.');
74
101
  let v: any = args;
@@ -78,13 +105,23 @@ function expandUrlPath(template: string, settings: Record<string, any>, args: Re
78
105
  }
79
106
  if (v == null) return full;
80
107
  const s = typeof v === 'string' ? v : (typeof v === 'number' || typeof v === 'boolean' ? String(v) : JSON.stringify(v));
81
- return encodeURIComponent(s);
108
+ // Encoding mode comes from the top-level parameter's schema. Nested
109
+ // arg paths inherit their root parameter's encoding — common case is
110
+ // a flat scalar parameter so this matters rarely.
111
+ const rootParam = path[0];
112
+ const mode = paramSchemas?.[rootParam]?.url_encoding;
113
+ return encodePathValue(s, mode);
82
114
  });
83
115
  return out;
84
116
  }
85
117
 
86
- function buildUrl(spec: HttpRequestSpec, settings: Record<string, any>, args: Record<string, any>): string {
87
- const base = expandUrlPath(spec.url, settings, args);
118
+ function buildUrl(
119
+ spec: HttpRequestSpec,
120
+ settings: Record<string, any>,
121
+ args: Record<string, any>,
122
+ paramSchemas?: Record<string, ConnectorFieldSchema>,
123
+ ): string {
124
+ const base = expandUrlPath(spec.url, settings, args, paramSchemas);
88
125
  if (!spec.query) return base;
89
126
  const url = new URL(base);
90
127
  for (const [k, raw] of Object.entries(spec.query)) {
@@ -98,6 +135,48 @@ function buildUrl(spec: HttpRequestSpec, settings: Record<string, any>, args: Re
98
135
  return url.toString();
99
136
  }
100
137
 
138
+ /**
139
+ * Apply a connector/tool auth scheme onto an outbound request. Resolves
140
+ * templated `{settings.*}` inside auth values, base64-encodes basic
141
+ * credentials, and chooses between header / query placement. The URL is
142
+ * passed by reference (returned as a new string if the auth scheme
143
+ * appends a query param). Centralised so the chat dispatcher and the
144
+ * connector-test probe stay consistent.
145
+ */
146
+ export function applyAuth(
147
+ url: string,
148
+ headers: Headers,
149
+ auth: ConnectorAuth | undefined,
150
+ settings: Record<string, any>,
151
+ args: Record<string, any> = {},
152
+ ): string {
153
+ if (!auth || auth.type === 'none') return url;
154
+ const exp = (s: string) => expandAllTokens(String(s ?? ''), settings, args);
155
+ switch (auth.type) {
156
+ case 'basic': {
157
+ const u = exp(auth.username);
158
+ const p = exp(auth.password);
159
+ const token = Buffer.from(`${u}:${p}`, 'utf-8').toString('base64');
160
+ headers.set('Authorization', `Basic ${token}`);
161
+ return url;
162
+ }
163
+ case 'bearer': {
164
+ headers.set('Authorization', `Bearer ${exp(auth.token)}`);
165
+ return url;
166
+ }
167
+ case 'header': {
168
+ headers.set(auth.name, exp(auth.value));
169
+ return url;
170
+ }
171
+ case 'query': {
172
+ const u = new URL(url);
173
+ u.searchParams.set(auth.name, exp(auth.value));
174
+ return u.toString();
175
+ }
176
+ }
177
+ return url;
178
+ }
179
+
101
180
  function buildHeaders(spec: HttpRequestSpec, settings: Record<string, any>, args: Record<string, any>): Headers {
102
181
  const h = new Headers();
103
182
  if (spec.headers) {
@@ -109,12 +188,102 @@ function buildHeaders(spec: HttpRequestSpec, settings: Record<string, any>, args
109
188
  }
110
189
 
111
190
  function buildBody(spec: HttpRequestSpec, settings: Record<string, any>, args: Record<string, any>): { body?: string; contentType?: string } {
112
- if (spec.body == null) return {};
113
- if (typeof spec.body === 'string') {
114
- return { body: expandAllTokens(spec.body, settings, args) };
191
+ if (spec.body != null) {
192
+ if (typeof spec.body === 'string') {
193
+ return { body: expandAllTokens(spec.body, settings, args) };
194
+ }
195
+ const obj = expandObjectLeaves(spec.body, settings, args);
196
+ return { body: JSON.stringify(obj), contentType: 'application/json' };
197
+ }
198
+ if (spec.body_form != null || spec.body_form_inject != null || spec.body_form_inject_from != null) {
199
+ return buildFormBody(spec.body_form, spec.body_form_inject, spec.body_form_inject_from, settings, args);
115
200
  }
116
- const obj = expandObjectLeaves(spec.body, settings, args);
117
- return { body: JSON.stringify(obj), contentType: 'application/json' };
201
+ return {};
202
+ }
203
+
204
+ /**
205
+ * Serialise an object into application/x-www-form-urlencoded body.
206
+ * The spec value can be:
207
+ * - a literal placeholder `{args.NAME}` — resolved to the named arg
208
+ * (must be a plain object); used by Jenkins trigger_build to take a
209
+ * dynamic `params` map of build parameters.
210
+ * - an inline object whose leaves get template-expanded — used when
211
+ * the form keys are static.
212
+ * - any other string — treated as a JSON-string template, parsed, then
213
+ * serialised (less common, but lets manifests build the body inline).
214
+ *
215
+ * null/undefined values are dropped (no empty `KEY=`). Non-string values
216
+ * are stringified.
217
+ */
218
+ function buildFormBody(spec: string | Record<string, unknown> | undefined, inject: Record<string, string> | undefined, injectFrom: string | undefined, settings: Record<string, any>, args: Record<string, any>): { body?: string; contentType?: string } {
219
+ let obj: any = null;
220
+ if (spec != null) {
221
+ if (typeof spec === 'string') {
222
+ const m = spec.match(/^\{args\.([^{}]+)\}$/);
223
+ if (m) {
224
+ obj = args[m[1]];
225
+ // LLMs frequently JSON-stringify an object arg even when the
226
+ // tool schema declares it as `type: json`. Parse it back so the
227
+ // form serialisation works either way.
228
+ if (typeof obj === 'string') {
229
+ try { obj = JSON.parse(obj); } catch { /* leave as null below */ }
230
+ }
231
+ } else {
232
+ const expanded = expandAllTokens(spec, settings, args);
233
+ try { obj = JSON.parse(expanded); } catch { obj = null; }
234
+ }
235
+ } else {
236
+ obj = expandObjectLeaves(spec, settings, args);
237
+ }
238
+ }
239
+ if (obj == null) obj = {};
240
+ if (typeof obj !== 'object' || Array.isArray(obj)) obj = {};
241
+
242
+ // Server-side inject — typically secrets pulled from settings.
243
+ // BOTH key and value are templated (against settings only, NOT args,
244
+ // so the LLM can't shadow injected keys). Templated keys let one
245
+ // manifest target different Jenkins jobs whose param names vary —
246
+ // each instance config sets the key name (e.g. TOKEN_PASSWORD)
247
+ // alongside the value source (e.g. {settings.gitlab_pat}). Entries
248
+ // where the key OR value comes back empty / unresolved get dropped.
249
+ if (inject) {
250
+ for (const [rawKey, rawVal] of Object.entries(inject)) {
251
+ const k = expandAllTokens(String(rawKey), settings, {});
252
+ if (!k || /\{(settings|args)\./.test(k)) continue;
253
+ const v = expandAllTokens(String(rawVal), settings, {});
254
+ if (!v || /\{(settings|args)\./.test(v)) continue;
255
+ obj[k] = v;
256
+ }
257
+ }
258
+
259
+ // body_form_inject_from — settings[X] is expected to be an
260
+ // `instances`-shaped array (each row { name, value, ... }). Inject
261
+ // every row as one form key/value pair. Lets the connector defer
262
+ // the actual key+value choices to per-user instance config without
263
+ // hardcoding them in the manifest. Rows with empty name or value
264
+ // are dropped.
265
+ if (injectFrom) {
266
+ let rows: any = (settings as any)[injectFrom];
267
+ if (typeof rows === 'string') {
268
+ try { rows = JSON.parse(rows); } catch { rows = null; }
269
+ }
270
+ if (Array.isArray(rows)) {
271
+ for (const row of rows) {
272
+ if (!row || typeof row !== 'object') continue;
273
+ const k = typeof row.name === 'string' ? row.name.trim() : '';
274
+ const v = typeof row.value === 'string' ? row.value : (row.value == null ? '' : String(row.value));
275
+ if (!k || !v) continue;
276
+ obj[k] = v;
277
+ }
278
+ }
279
+ }
280
+
281
+ const usp = new URLSearchParams();
282
+ for (const [k, v] of Object.entries(obj)) {
283
+ if (v == null) continue;
284
+ usp.append(k, typeof v === 'string' ? v : String(v));
285
+ }
286
+ return { body: usp.toString(), contentType: 'application/x-www-form-urlencoded' };
118
287
  }
119
288
 
120
289
  function truncate(s: string): { text: string; truncated: boolean; totalBytes: number } {
@@ -124,7 +293,7 @@ function truncate(s: string): { text: string; truncated: boolean; totalBytes: nu
124
293
  return { text: slice, truncated: true, totalBytes: buf.byteLength };
125
294
  }
126
295
 
127
- export async function runHttp({ tool, settings, args, noTruncation }: HttpProtocolArgs): Promise<HttpProtocolResult> {
296
+ export async function runHttp({ tool, settings, args, connectorAuth, noTruncation }: HttpProtocolArgs): Promise<HttpProtocolResult> {
128
297
  const spec = tool.request;
129
298
  if (!spec || !spec.url) {
130
299
  return { content: 'http tool missing `request.url`', is_error: true };
@@ -145,11 +314,16 @@ export async function runHttp({ tool, settings, args, noTruncation }: HttpProtoc
145
314
  }
146
315
  }
147
316
 
148
- const url = buildUrl(spec, settings, argsWithDefaults);
317
+ let url = buildUrl(spec, settings, argsWithDefaults, tool.parameters);
149
318
  const headers = buildHeaders(spec, settings, argsWithDefaults);
150
319
  const { body, contentType } = buildBody(spec, settings, argsWithDefaults);
151
320
  if (body != null && contentType && !headers.has('content-type')) headers.set('content-type', contentType);
152
321
 
322
+ // Tool-level auth overrides connector-level. `{ type: 'none' }` is a
323
+ // valid override that disables auth entirely (public endpoint).
324
+ const effectiveAuth = tool.auth ?? connectorAuth;
325
+ url = applyAuth(url, headers, effectiveAuth, settings, argsWithDefaults);
326
+
153
327
  const controller = new AbortController();
154
328
  const timer = setTimeout(() => controller.abort(), timeoutMs);
155
329