@aion0/forge 0.10.48 → 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 CHANGED
@@ -1,12 +1,8 @@
1
- # Forge v0.10.48
1
+ # Forge v0.10.50
2
2
 
3
- Released: 2026-06-08
3
+ Released: 2026-06-09
4
4
 
5
- ## Changes since v0.10.47
5
+ ## Changes since v0.10.49
6
6
 
7
- ### Other
8
- - fix(proxy): drop runtime:'nodejs' — Next.js 16 errors on it
9
- - Implement an MCP layer for forge management (#34)
10
7
 
11
-
12
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.47...v0.10.48
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/login-status');
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/login-status', { method: 'POST' });
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}`);
@@ -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
- const resolved = out || cmd;
19
- _whichCache.set(cmd, resolved);
20
- return resolved;
21
- } catch {
22
- _whichCache.set(cmd, cmd);
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) */
@@ -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;
@@ -103,14 +110,31 @@ function readIdpBlocks(): IdpTemplateBlock[] {
103
110
  return tryRead() || [];
104
111
  }
105
112
 
106
- /** First installed SP from saml_sps wins as trigger. */
113
+ /** Pick the SP whose tab we'll open to trigger the SAML flow.
114
+ *
115
+ * Preference order:
116
+ * 1. A SP whose login-status cache currently shows ✗ — opening it forces
117
+ * a SAML redirect to the IdP, which is exactly what we want when the
118
+ * IdP cookie has expired even though some sibling SPs still have a
119
+ * valid session cookie.
120
+ * 2. Otherwise first installed SP with a base_url.
121
+ */
107
122
  function pickTriggerUrl(saml_sps: string[]): string | null {
123
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
124
+ const { getCachedResult } = require('./login-status') as typeof import('./login-status');
125
+ let fallback: string | null = null;
108
126
  for (const id of saml_sps) {
109
127
  const c = getInstalledConnector(id);
110
128
  const url = (c?.config as { base_url?: string } | undefined)?.base_url;
111
- if (url) return url.replace(/\/+$/, '/');
129
+ if (!url) continue;
130
+ const normalized = url.replace(/\/+$/, '/');
131
+ fallback ||= normalized;
132
+ const cached = getCachedResult(`connector:${id}`);
133
+ if (cached && cached.ok === false) {
134
+ return normalized;
135
+ }
112
136
  }
113
- return null;
137
+ return fallback;
114
138
  }
115
139
 
116
140
  async function runOneIdp(block: IdpTemplateBlock, req: IdpLoginRequest): Promise<IdpLoginEntry> {
@@ -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 { ConnectorDefinition } from '../connectors/types';
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. Browser/HTTP sources come
116
- * from connector manifests; external sources are hardcoded here.
117
- *
118
- * Connector-derived rows are filtered to those with `test:` declared —
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@aion0/forge",
3
- "version": "0.10.48",
3
+ "version": "0.10.50",
4
4
  "description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
5
5
  "type": "module",
6
6
  "scripts": {