@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 +16 -5
- package/app/api/connectors/[id]/settings/route.ts +68 -10
- package/app/api/connectors/[id]/test/route.ts +28 -5
- package/components/ConnectorsPanel.tsx +141 -1
- package/install.sh +18 -4
- package/lib/chat/protocols/http.ts +198 -24
- package/lib/chat/tool-dispatcher.ts +84 -7
- package/lib/connectors/registry.ts +76 -18
- package/lib/connectors/types.ts +87 -1
- package/lib/craft-sdk/server.ts +2 -2
- package/lib/crafts/runtime.ts +1 -1
- package/lib/flows.ts +1 -1
- package/lib/help-docs/21-build-connector.md +139 -0
- package/lib/issue-scanner-gitlab.ts +2 -2
- package/lib/issue-scanner.ts +2 -2
- package/lib/jobs/scheduler.ts +4 -4
- package/lib/jobs/store.ts +3 -3
- package/lib/notifications.ts +2 -2
- package/lib/notify.ts +1 -1
- package/lib/pipeline-scheduler.ts +2 -2
- package/lib/pipeline.ts +1 -1
- package/lib/prompts/store.ts +1 -1
- package/lib/schedules/action-runner.ts +4 -4
- package/lib/schedules/scheduler.ts +7 -7
- package/lib/schedules/store.ts +4 -4
- package/lib/session-manager.ts +5 -5
- package/lib/session-watcher.ts +1 -1
- package/lib/skills.ts +2 -2
- package/lib/task-manager.ts +3 -3
- package/lib/telegram-bot.ts +1 -1
- package/lib/usage-scanner.ts +2 -2
- package/package.json +1 -1
- package/src/config/index.ts +2 -2
- package/src/core/memory/strategy.ts +1 -1
- package/src/core/providers/chat.ts +2 -2
- package/src/core/providers/registry.ts +2 -2
- package/src/core/session/manager.ts +5 -5
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
|
-
# Forge v0.9.
|
|
1
|
+
# Forge v0.9.18
|
|
2
2
|
|
|
3
|
-
Released: 2026-05-
|
|
3
|
+
Released: 2026-05-28
|
|
4
4
|
|
|
5
|
-
## Changes since v0.9.
|
|
5
|
+
## Changes since v0.9.16
|
|
6
6
|
|
|
7
7
|
### Other
|
|
8
|
-
-
|
|
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.
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
115
|
-
const headers = buildHeaders(spec,
|
|
116
|
-
const { body, contentType } = buildBody(spec,
|
|
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 === '
|
|
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
|
-
|
|
80
|
-
npm
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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(
|
|
87
|
-
|
|
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
|
|
113
|
-
|
|
114
|
-
|
|
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
|
-
|
|
117
|
-
|
|
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
|
-
|
|
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
|
|