@aion0/forge 0.10.49 → 0.10.50
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 +3 -6
- package/app/api/web-sessions/route.ts +38 -0
- package/components/EnterpriseBadge.tsx +2 -2
- package/lib/agents/index.ts +15 -5
- package/lib/auth/idp-login.ts +11 -9
- package/lib/auth/login-status.ts +59 -28
- package/lib/connectors/test-runner.ts +48 -0
- package/lib/workspace-standalone.ts +5 -1
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.50
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-09
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.49
|
|
6
6
|
|
|
7
|
-
### Other
|
|
8
|
-
- fix(idp-login): pick a failing SP as the SAML trigger, not the first one
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.48...v0.10.49
|
|
8
|
+
**Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.49...v0.10.50
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GET /api/web-sessions → IdP-consolidated rows + cached statuses
|
|
3
|
+
* POST /api/web-sessions → re-probe every IdP in parallel
|
|
4
|
+
*
|
|
5
|
+
* Distinct from /api/login-status (per-connector): this endpoint is used
|
|
6
|
+
* by the Enterprise badge "Web sessions" section, which wants one row
|
|
7
|
+
* per IdP block rather than one row per SAML SP.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { NextResponse } from 'next/server';
|
|
11
|
+
import { listIdpSources, listIdpStatus, checkSource } from '@/lib/auth/login-status';
|
|
12
|
+
|
|
13
|
+
export async function GET() {
|
|
14
|
+
return NextResponse.json({ rows: listIdpStatus() });
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function POST() {
|
|
18
|
+
const sources = listIdpSources();
|
|
19
|
+
const results = await Promise.all(
|
|
20
|
+
sources.map(async (s) => {
|
|
21
|
+
try {
|
|
22
|
+
const r = await checkSource(s);
|
|
23
|
+
return { source: s, result: r };
|
|
24
|
+
} catch (e) {
|
|
25
|
+
return {
|
|
26
|
+
source: s,
|
|
27
|
+
result: {
|
|
28
|
+
ok: false,
|
|
29
|
+
message: `check error: ${(e as Error).message}`,
|
|
30
|
+
checked_at: Date.now(),
|
|
31
|
+
duration_ms: 0,
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}),
|
|
36
|
+
);
|
|
37
|
+
return NextResponse.json({ rows: results });
|
|
38
|
+
}
|
|
@@ -143,7 +143,7 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
|
|
|
143
143
|
|
|
144
144
|
const loadWebSessions = async () => {
|
|
145
145
|
try {
|
|
146
|
-
const r = await fetch('/api/
|
|
146
|
+
const r = await fetch('/api/web-sessions');
|
|
147
147
|
if (!r.ok) return;
|
|
148
148
|
const data = await r.json();
|
|
149
149
|
ingestLoginRows((data?.rows || []) as LoginRow[]);
|
|
@@ -154,7 +154,7 @@ export default function EnterpriseBadge({ onOpenSettings: _onOpenSettings }: { o
|
|
|
154
154
|
if (webBusy) return;
|
|
155
155
|
setWebBusy(true); setWebErr('');
|
|
156
156
|
try {
|
|
157
|
-
const r = await fetch('/api/
|
|
157
|
+
const r = await fetch('/api/web-sessions', { method: 'POST' });
|
|
158
158
|
const data = await r.json();
|
|
159
159
|
if (!r.ok || data?.error) {
|
|
160
160
|
setWebErr(data?.error || `HTTP ${r.status}`);
|
package/lib/agents/index.ts
CHANGED
|
@@ -15,11 +15,17 @@ function resolveAbsoluteBin(cmd: string): string {
|
|
|
15
15
|
if (cached !== undefined) return cached;
|
|
16
16
|
try {
|
|
17
17
|
const out = execSync(`which ${cmd}`, { encoding: 'utf-8', timeout: 3000, stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
if (out) {
|
|
19
|
+
_whichCache.set(cmd, out);
|
|
20
|
+
return out;
|
|
21
|
+
}
|
|
22
|
+
// `which` exited 0 but printed nothing — don't poison cache, retry next call.
|
|
23
|
+
return cmd;
|
|
24
|
+
} catch (e) {
|
|
25
|
+
// Restart-window contention can blow past the 3s timeout. Caching the
|
|
26
|
+
// failure here would pin bare `cmd` for the rest of the process — let
|
|
27
|
+
// the next call retry instead.
|
|
28
|
+
console.warn(`[agents] which ${cmd} failed:`, (e as Error).message);
|
|
23
29
|
return cmd;
|
|
24
30
|
}
|
|
25
31
|
}
|
|
@@ -225,6 +231,10 @@ export function getAgent(id?: AgentId): AgentAdapter {
|
|
|
225
231
|
/** Clear adapter cache (call after settings change) */
|
|
226
232
|
export function clearAgentCache(): void {
|
|
227
233
|
adapterCache.clear();
|
|
234
|
+
// Also drop the `which <bin>` resolution cache. Without this, a failed
|
|
235
|
+
// resolution during the restart window stayed in cache and every later
|
|
236
|
+
// terminal-launch returned bare `claude`.
|
|
237
|
+
_whichCache.clear();
|
|
228
238
|
}
|
|
229
239
|
|
|
230
240
|
/** Auto-detect all available agents (called on startup) */
|
package/lib/auth/idp-login.ts
CHANGED
|
@@ -47,8 +47,15 @@ export interface IdpLoginResult {
|
|
|
47
47
|
error?: string;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
interface IdpTemplateBlock {
|
|
50
|
+
export interface IdpTemplateBlock {
|
|
51
51
|
host?: string;
|
|
52
|
+
/** Optional display name override for the Login Status panel.
|
|
53
|
+
* Falls back to `host` when missing. */
|
|
54
|
+
display_name?: string;
|
|
55
|
+
/** Optional explicit URL the Login Status probe opens. Defaults to
|
|
56
|
+
* `https://<host>/`. Override when the IdP's logged-in landing page
|
|
57
|
+
* is at a specific path (e.g. `/saml-idp/portal/` for FAC). */
|
|
58
|
+
probe_url?: string;
|
|
52
59
|
/** Optional alt hostnames the IdP also uses (e.g. regional SAML servers). */
|
|
53
60
|
alt_hosts?: string[];
|
|
54
61
|
saml_sps?: string[];
|
|
@@ -83,7 +90,7 @@ interface IdpTemplateBlock {
|
|
|
83
90
|
* (legacy) or an array of objects (multi-IdP). Returns normalized
|
|
84
91
|
* array of valid blocks.
|
|
85
92
|
*/
|
|
86
|
-
function readIdpBlocks(): IdpTemplateBlock[] {
|
|
93
|
+
export function readIdpBlocks(): IdpTemplateBlock[] {
|
|
87
94
|
const tryRead = (sourceId?: string): IdpTemplateBlock[] | null => {
|
|
88
95
|
const t = resolveWizardTemplate(sourceId);
|
|
89
96
|
const raw = (t?.template as { _idp?: IdpTemplateBlock | IdpTemplateBlock[] } | undefined)?._idp;
|
|
@@ -109,13 +116,8 @@ function readIdpBlocks(): IdpTemplateBlock[] {
|
|
|
109
116
|
* 1. A SP whose login-status cache currently shows ✗ — opening it forces
|
|
110
117
|
* a SAML redirect to the IdP, which is exactly what we want when the
|
|
111
118
|
* IdP cookie has expired even though some sibling SPs still have a
|
|
112
|
-
* valid session cookie
|
|
113
|
-
*
|
|
114
|
-
* 2. Otherwise first installed SP with a base_url (legacy fallback).
|
|
115
|
-
*
|
|
116
|
-
* Without (1) the IdP login flow would open Mantis (first in saml_sps),
|
|
117
|
-
* see the cookie still good, declare "no form needed", and exit — leaving
|
|
118
|
-
* the broken siblings unrefreshed.
|
|
119
|
+
* valid session cookie.
|
|
120
|
+
* 2. Otherwise first installed SP with a base_url.
|
|
119
121
|
*/
|
|
120
122
|
function pickTriggerUrl(saml_sps: string[]): string | null {
|
|
121
123
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
package/lib/auth/login-status.ts
CHANGED
|
@@ -20,10 +20,10 @@
|
|
|
20
20
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
21
21
|
import { join } from 'node:path';
|
|
22
22
|
import { getDataDir } from '../dirs';
|
|
23
|
+
import { runConnectorTest, runBrowserUrlProbe } from '../connectors/test-runner';
|
|
23
24
|
import { listConnectorIds, getConnector, getInstalledConnector } from '../connectors/registry';
|
|
24
|
-
import { runConnectorTest } from '../connectors/test-runner';
|
|
25
25
|
import { expandSettingsTokens } from '../plugins/templates';
|
|
26
|
-
import type
|
|
26
|
+
import { readIdpBlocks, type IdpTemplateBlock } from './idp-login';
|
|
27
27
|
|
|
28
28
|
export type LoginCategory = 'browser' | 'token' | 'external';
|
|
29
29
|
|
|
@@ -112,20 +112,14 @@ export function invalidateCachedResult(sourceId: string): void {
|
|
|
112
112
|
// ─── Source enumeration ─────────────────────────────────────────────
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
|
-
* Build the canonical list of LoginSources
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* connectors without a test block (e.g. `nac` which uses per-call SSH
|
|
120
|
-
* password) are intentionally omitted.
|
|
115
|
+
* Build the canonical list of LoginSources from connector manifests.
|
|
116
|
+
* Connector-derived rows are filtered to those with `test:` declared and
|
|
117
|
+
* actually installed (so an uninstalled connector with empty creds
|
|
118
|
+
* doesn't pollute the panel with permanent 401s).
|
|
121
119
|
*/
|
|
122
120
|
export function listSources(): LoginSource[] {
|
|
123
121
|
const out: LoginSource[] = [];
|
|
124
122
|
|
|
125
|
-
// 1. Connector-derived — skip any connector the user hasn't installed
|
|
126
|
-
// (no entry in connector-configs.json). An un-installed connector
|
|
127
|
-
// would just probe with default/empty settings and always fail,
|
|
128
|
-
// polluting the panel.
|
|
129
123
|
for (const id of listConnectorIds()) {
|
|
130
124
|
const def = getConnector(id);
|
|
131
125
|
if (!def || !def.test) continue;
|
|
@@ -133,25 +127,17 @@ export function listSources(): LoginSource[] {
|
|
|
133
127
|
const probe = def.test.probe || 'http';
|
|
134
128
|
const category: LoginCategory = probe === 'browser' ? 'browser' : 'token';
|
|
135
129
|
|
|
136
|
-
// Refresh hint per category
|
|
137
130
|
let refresh: RefreshHint;
|
|
138
131
|
if (probe === 'browser') {
|
|
139
|
-
// For browser sources, host_match (e.g. "{base_url}/*") needs
|
|
140
|
-
// settings tokens expanded — the user-saved {base_url} lives in
|
|
141
|
-
// the connector's installed config, not in plain settings.
|
|
142
132
|
const rawHm = def.host_match || def.connectors?.[0]?.host_match || '';
|
|
143
133
|
const inst = getInstalledConnector(id);
|
|
144
134
|
const expanded = inst ? expandSettingsTokens(rawHm, inst.config as any) : rawHm;
|
|
145
|
-
// Strip Chrome match-pattern suffix like `/*`
|
|
146
135
|
const url = expanded.replace(/\/\*$/, '/');
|
|
147
|
-
// Heuristic: if expansion left literal `{...}` tokens behind,
|
|
148
|
-
// settings are incomplete — fall back to opening Settings.
|
|
149
136
|
const stillUnresolved = /\{[^}]+\}/.test(url) || !/^https?:\/\//.test(url);
|
|
150
137
|
refresh = stillUnresolved
|
|
151
138
|
? { kind: 'open-settings', section: 'connectors' }
|
|
152
139
|
: { kind: 'open-url', url, description: 'Open in a new tab to log in' };
|
|
153
140
|
} else {
|
|
154
|
-
// HTTP/token — fix by editing connector settings (PAT, API token)
|
|
155
141
|
refresh = { kind: 'open-settings', section: 'connectors' };
|
|
156
142
|
}
|
|
157
143
|
|
|
@@ -164,12 +150,6 @@ export function listSources(): LoginSource[] {
|
|
|
164
150
|
});
|
|
165
151
|
}
|
|
166
152
|
|
|
167
|
-
// GitLab 2FA used to be an external row here, but `2fa_verify` is
|
|
168
|
-
// interactive auth (not a probe) and bare SSH greet doesn't catch
|
|
169
|
-
// expiry. Removed in favour of a static hint on the GitLab
|
|
170
|
-
// connector itself — user copies the command when they actually
|
|
171
|
-
// hit a 2FA expiry instead of being nagged proactively.
|
|
172
|
-
|
|
173
153
|
return out;
|
|
174
154
|
}
|
|
175
155
|
|
|
@@ -185,6 +165,8 @@ export async function checkSource(source: LoginSource): Promise<LoginCheckResult
|
|
|
185
165
|
|
|
186
166
|
if (source.id.startsWith('connector:')) {
|
|
187
167
|
r = await checkConnector(source.id.slice('connector:'.length));
|
|
168
|
+
} else if (source.id.startsWith('idp:')) {
|
|
169
|
+
r = await checkIdp(source.id.slice('idp:'.length));
|
|
188
170
|
} else {
|
|
189
171
|
r = { ok: false, message: `unknown source ${source.id}` };
|
|
190
172
|
}
|
|
@@ -201,8 +183,6 @@ export async function checkSource(source: LoginSource): Promise<LoginCheckResult
|
|
|
201
183
|
async function checkConnector(
|
|
202
184
|
connectorId: string,
|
|
203
185
|
): Promise<Omit<LoginCheckResult, 'checked_at' | 'duration_ms'>> {
|
|
204
|
-
// Direct lib call — self-fetching the route hits the auth middleware
|
|
205
|
-
// (no session cookie) and would return "unauthorized" for every probe.
|
|
206
186
|
try {
|
|
207
187
|
const r = await runConnectorTest(connectorId);
|
|
208
188
|
return {
|
|
@@ -215,6 +195,57 @@ async function checkConnector(
|
|
|
215
195
|
}
|
|
216
196
|
}
|
|
217
197
|
|
|
198
|
+
// ─── IdP-consolidated sources (Enterprise badge "Web sessions") ────
|
|
199
|
+
// The right-side panel iterates per-connector via listSources(); the
|
|
200
|
+
// Enterprise badge wants a coarser view that asks "is the IdP cookie
|
|
201
|
+
// still alive?" — one row per _idp block, not one per SP.
|
|
202
|
+
|
|
203
|
+
function idpProbeUrl(block: IdpTemplateBlock): string {
|
|
204
|
+
return (block.probe_url && block.probe_url.trim()) || `https://${block.host}/`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export function listIdpSources(): LoginSource[] {
|
|
208
|
+
const out: LoginSource[] = [];
|
|
209
|
+
for (const block of readIdpBlocks()) {
|
|
210
|
+
if (!block.host) continue;
|
|
211
|
+
out.push({
|
|
212
|
+
id: `idp:${block.host}`,
|
|
213
|
+
label: block.display_name || block.host,
|
|
214
|
+
category: 'browser',
|
|
215
|
+
detail: block.saml_sps?.length
|
|
216
|
+
? `IdP for: ${block.saml_sps.join(', ')}`
|
|
217
|
+
: 'Identity provider',
|
|
218
|
+
refresh: {
|
|
219
|
+
kind: 'open-url',
|
|
220
|
+
url: idpProbeUrl(block),
|
|
221
|
+
description: 'Open IdP portal to re-authenticate',
|
|
222
|
+
},
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function listIdpStatus(): LoginStatusRow[] {
|
|
229
|
+
return listIdpSources().map((source) => ({ source, result: getCachedResult(source.id) }));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
async function checkIdp(
|
|
233
|
+
host: string,
|
|
234
|
+
): Promise<Omit<LoginCheckResult, 'checked_at' | 'duration_ms'>> {
|
|
235
|
+
const block = readIdpBlocks().find((b) => b.host === host);
|
|
236
|
+
const probe_url = block ? idpProbeUrl(block) : `https://${host}/`;
|
|
237
|
+
try {
|
|
238
|
+
const r = await runBrowserUrlProbe({ id: `idp:${host}`, host, probe_url });
|
|
239
|
+
return {
|
|
240
|
+
ok: !!r.ok,
|
|
241
|
+
message: r.ok ? (r.message || 'ok') : (r.error || `HTTP ${r.status || '?'}`),
|
|
242
|
+
landed_url: r.url,
|
|
243
|
+
};
|
|
244
|
+
} catch (e) {
|
|
245
|
+
return { ok: false, message: `probe error: ${(e as Error).message}` };
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
218
249
|
// ─── Bulk view ──────────────────────────────────────────────────────
|
|
219
250
|
|
|
220
251
|
export function listStatus(): LoginStatusRow[] {
|
|
@@ -270,6 +270,54 @@ async function runBrowserProbe(
|
|
|
270
270
|
};
|
|
271
271
|
}
|
|
272
272
|
|
|
273
|
+
/**
|
|
274
|
+
* Run a browser probe against an ad-hoc URL — used by the Login Status
|
|
275
|
+
* panel's IdP rows (no connector backing them). Reuses the same
|
|
276
|
+
* `connector.probe` bridge RPC as runBrowserProbe — the extension
|
|
277
|
+
* opens host_match in a tab, waits for navigation, returns landed URL.
|
|
278
|
+
* If the landed URL host matches `expected_host`, the user is still
|
|
279
|
+
* signed in. If it redirected to a login page (different host or path),
|
|
280
|
+
* not signed in.
|
|
281
|
+
*/
|
|
282
|
+
export async function runBrowserUrlProbe(opts: {
|
|
283
|
+
id: string; // identifier for telemetry, e.g. 'idp:fac.corp.fortinet.com'
|
|
284
|
+
host: string; // expected host (e.g. 'fac.corp.fortinet.com')
|
|
285
|
+
probe_url: string; // URL to navigate to (e.g. 'https://fac.corp.fortinet.com/saml-idp/portal/')
|
|
286
|
+
timeout_ms?: number;
|
|
287
|
+
}): Promise<TestResult> {
|
|
288
|
+
const t0 = Date.now();
|
|
289
|
+
let value: unknown;
|
|
290
|
+
try {
|
|
291
|
+
value = await bridgeRpc('connector.probe', {
|
|
292
|
+
pluginId: opts.id,
|
|
293
|
+
host_match: opts.probe_url, // extension navigates to this URL directly
|
|
294
|
+
runner: 'main',
|
|
295
|
+
timeout_ms: opts.timeout_ms || 15_000,
|
|
296
|
+
});
|
|
297
|
+
} catch (e) {
|
|
298
|
+
return { ok: false, error: (e as Error).message || 'probe error', duration_ms: Date.now() - t0 };
|
|
299
|
+
}
|
|
300
|
+
const r = (value || {}) as { ok?: boolean; url?: string; error?: string };
|
|
301
|
+
const landed = r.url || '';
|
|
302
|
+
const failedNetwork = !landed || landed.startsWith('chrome-error://') || landed.startsWith('about:');
|
|
303
|
+
// Pass if landed host matches expected. A redirect to a different host
|
|
304
|
+
// (e.g. fac.corp.fortinet.com → ms-login.fortinet.com when SSO expired)
|
|
305
|
+
// means the user needs to re-authenticate.
|
|
306
|
+
let onExpected = false;
|
|
307
|
+
try { onExpected = !failedNetwork && new URL(landed).hostname.toLowerCase() === opts.host.toLowerCase(); } catch { /* bad url */ }
|
|
308
|
+
return {
|
|
309
|
+
ok: onExpected,
|
|
310
|
+
message: onExpected ? `Session active · ${landed}` : undefined,
|
|
311
|
+
error: onExpected
|
|
312
|
+
? undefined
|
|
313
|
+
: (failedNetwork
|
|
314
|
+
? `Network unreachable — ${landed || '(no url)'}. VPN / hostname / firewall?`
|
|
315
|
+
: `Not signed in — redirected to ${landed}`),
|
|
316
|
+
url: landed,
|
|
317
|
+
duration_ms: Date.now() - t0,
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
273
321
|
/**
|
|
274
322
|
* Run a connector's `test:` probe. Returns a structured result. The HTTP
|
|
275
323
|
* route + Login Status panel both call this — they share the same
|
|
@@ -306,7 +306,11 @@ async function handleAgentsPost(id: string, body: any, res: ServerResponse): Pro
|
|
|
306
306
|
const { resolveTerminalLaunch, clearAgentCache } = await import('./agents/index.js');
|
|
307
307
|
clearAgentCache(); // ensure fresh settings are read
|
|
308
308
|
launchInfo = resolveTerminalLaunch(agentConfig.agentId);
|
|
309
|
-
} catch {
|
|
309
|
+
} catch (e) {
|
|
310
|
+
// Silent fallback used to leak bare `claude` to the UI on restart.
|
|
311
|
+
// Log loudly so future regressions surface in forge.log.
|
|
312
|
+
console.warn(`[workspace] open_terminal: resolveTerminalLaunch failed, falling back to bare 'claude':`, (e as Error).message);
|
|
313
|
+
}
|
|
310
314
|
|
|
311
315
|
// resolveOnly: return launch info + current session ID (no side effects)
|
|
312
316
|
if (body.resolveOnly) {
|
package/package.json
CHANGED