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