@aion0/forge 0.10.26 → 0.10.28
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 +4 -12
- package/app/api/connectors/[id]/test/route.ts +12 -3
- package/lib/auth/login-status.ts +5 -85
- package/lib/chat/agent-loop.ts +4 -1
- package/lib/chat/protocols/http.ts +24 -2
- package/lib/chat/session-store.ts +32 -9
- package/lib/connectors/test-runner.ts +5 -1
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,19 +1,11 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.28
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-02
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.27
|
|
6
6
|
|
|
7
7
|
### Other
|
|
8
|
-
-
|
|
9
|
-
- fix(login-status): 2FA probe = passive SSH greet, not interactive 2fa_verify
|
|
10
|
-
- fix(login-status): derive 2FA host from gitlab connector + skip uninstalled sources
|
|
11
|
-
- fix(login-status): expand host_match settings tokens for open-login URL
|
|
12
|
-
- fix(login-status): probe via lib, not self-fetch — fixes all-401 results
|
|
13
|
-
- feat(login-status): central panel for all expirable auth credentials
|
|
14
|
-
- feat(http): capture_response_headers + chrome-mcp.sh helper
|
|
15
|
-
- fix(http): use undici.fetch for verify_tls path (v8 Agent incompatible with Node 22 bundled fetch)
|
|
16
|
-
- feat(http): connector-level http.verify_tls knob for self-signed appliances
|
|
8
|
+
- fix(login-status): remove GitLab 2FA row — 2fa_verify is interactive, can't probe
|
|
17
9
|
|
|
18
10
|
|
|
19
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.
|
|
11
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.27...v0.10.28
|
|
@@ -11,7 +11,16 @@ import { runConnectorTest } from '@/lib/connectors/test-runner';
|
|
|
11
11
|
|
|
12
12
|
export async function POST(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
|
13
13
|
const { id } = await params;
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
try {
|
|
15
|
+
const r = await runConnectorTest(id);
|
|
16
|
+
const { code, ...body } = r;
|
|
17
|
+
return NextResponse.json(body, code ? { status: code } : undefined);
|
|
18
|
+
} catch (e) {
|
|
19
|
+
// Belt-and-suspenders: never let an uncaught exception bubble to
|
|
20
|
+
// Next's HTML 500 page — the browser would then choke on JSON parse.
|
|
21
|
+
return NextResponse.json(
|
|
22
|
+
{ ok: false, error: `internal error: ${(e as Error).message}` },
|
|
23
|
+
{ status: 500 },
|
|
24
|
+
);
|
|
25
|
+
}
|
|
17
26
|
}
|
package/lib/auth/login-status.ts
CHANGED
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
|
|
20
20
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
|
-
import { spawn } from 'node:child_process';
|
|
23
22
|
import { getDataDir } from '../dirs';
|
|
24
23
|
import { listConnectorIds, getConnector, getInstalledConnector } from '../connectors/registry';
|
|
25
24
|
import { runConnectorTest } from '../connectors/test-runner';
|
|
@@ -151,40 +150,15 @@ export function listSources(): LoginSource[] {
|
|
|
151
150
|
});
|
|
152
151
|
}
|
|
153
152
|
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
}
|
|
153
|
+
// GitLab 2FA used to be an external row here, but `2fa_verify` is
|
|
154
|
+
// interactive auth (not a probe) and bare SSH greet doesn't catch
|
|
155
|
+
// expiry. Removed in favour of a static hint on the GitLab
|
|
156
|
+
// connector itself — user copies the command when they actually
|
|
157
|
+
// hit a 2FA expiry instead of being nagged proactively.
|
|
172
158
|
|
|
173
159
|
return out;
|
|
174
160
|
}
|
|
175
161
|
|
|
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
162
|
// ─── Checks ─────────────────────────────────────────────────────────
|
|
189
163
|
|
|
190
164
|
/**
|
|
@@ -197,8 +171,6 @@ export async function checkSource(source: LoginSource): Promise<LoginCheckResult
|
|
|
197
171
|
|
|
198
172
|
if (source.id.startsWith('connector:')) {
|
|
199
173
|
r = await checkConnector(source.id.slice('connector:'.length));
|
|
200
|
-
} else if (source.id === 'ext:gitlab-2fa') {
|
|
201
|
-
r = await checkGitLab2fa();
|
|
202
174
|
} else {
|
|
203
175
|
r = { ok: false, message: `unknown source ${source.id}` };
|
|
204
176
|
}
|
|
@@ -229,58 +201,6 @@ async function checkConnector(
|
|
|
229
201
|
}
|
|
230
202
|
}
|
|
231
203
|
|
|
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
204
|
// ─── Bulk view ──────────────────────────────────────────────────────
|
|
285
205
|
|
|
286
206
|
export function listStatus(): LoginStatusRow[] {
|
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
|
|
@@ -122,13 +122,33 @@ function expandUrlPath(
|
|
|
122
122
|
return out;
|
|
123
123
|
}
|
|
124
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
|
+
|
|
125
143
|
function buildUrl(
|
|
126
144
|
spec: HttpRequestSpec,
|
|
127
145
|
settings: Record<string, any>,
|
|
128
146
|
args: Record<string, any>,
|
|
129
147
|
paramSchemas?: Record<string, ConnectorFieldSchema>,
|
|
130
148
|
): string {
|
|
131
|
-
const base =
|
|
149
|
+
const base = collapsePathSlashes(
|
|
150
|
+
expandUrlPath(spec.url, settings, args, paramSchemas),
|
|
151
|
+
);
|
|
132
152
|
if (!spec.query) return base;
|
|
133
153
|
const url = new URL(base);
|
|
134
154
|
for (const [k, raw] of Object.entries(spec.query)) {
|
|
@@ -175,7 +195,9 @@ async function exchangeBearerToken(
|
|
|
175
195
|
const exp = (s: string | undefined) =>
|
|
176
196
|
s == null ? '' : expandAllTokens(String(s), settings, args);
|
|
177
197
|
const apiToken = exp(auth.api_token);
|
|
178
|
-
|
|
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));
|
|
179
201
|
if (!apiToken) throw new Error('bearer-token-exchange: api_token is empty');
|
|
180
202
|
if (!exchangeUrl) throw new Error('bearer-token-exchange: exchange_url is empty');
|
|
181
203
|
const key = `${apiToken}|${exchangeUrl}`;
|
|
@@ -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 {
|
|
@@ -116,7 +116,11 @@ async function runHttpProbe(
|
|
|
116
116
|
if (body != null && contentType && !headers.has('content-type')) {
|
|
117
117
|
headers.set('content-type', contentType);
|
|
118
118
|
}
|
|
119
|
-
|
|
119
|
+
try {
|
|
120
|
+
url = await applyAuth(url, headers, def.auth, effectiveSettings);
|
|
121
|
+
} catch (e) {
|
|
122
|
+
return { ok: false, error: `auth setup failed: ${(e as Error).message}` };
|
|
123
|
+
}
|
|
120
124
|
|
|
121
125
|
const timeoutMs = test.timeout_ms || DEFAULT_TIMEOUT_MS;
|
|
122
126
|
const okStatus = test.ok_status?.length ? test.ok_status : [200];
|
package/package.json
CHANGED