@aion0/forge 0.10.45 → 0.10.46

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,14 +1,49 @@
1
- # Forge v0.10.45
1
+ # Forge v0.10.46
2
2
 
3
3
  Released: 2026-06-08
4
4
 
5
- ## Changes since v0.10.44
5
+ ## Changes since v0.10.45
6
6
 
7
- ### Bug Fixes
8
- - fix: validate enterprise key against GitHub before persisting
7
+ ### Features
8
+ - feat: unified-auth Phase 2 Web Sessions sweep in EnterpriseBadge
9
+ - feat: unified-auth Phase 1 — GitLab SSH 2FA verify from EnterpriseBadge
9
10
 
10
11
  ### Other
11
- - fix(enterprise): strip whitespace + zero-width chars from pasted keys
12
+ - feat(ui): move Browser + Help into user dropdown menu
13
+ - feat(scratch): auto-cleanup files older than scratchRetentionDays (default 7)
14
+ - feat(chat): save_scratch_file builtin tool — direct file write + download link
15
+ - fix(chat): listMessages returns the LAST N, not the first N
16
+ - fix(chat-standalone): bump default session GET limit 500 -> 2000
17
+ - fix(server): spawn next with --use-system-ca for corp SSL inspection
18
+ - feat(migration): one-shot disk rename for renamed connector setting keys
19
+ - feat(chat): autolink scratch/<file> paths to /api/scratch download endpoint
20
+ - diag(dispatch): drop temp default-fill logs
21
+ - diag(dispatch): log default keys + arg state to forge.log (TEMP)
22
+ - fix(connector alias): mantis default_project rename — drop legacy key
23
+ - fix(install.sh --local): wipe .next + Turbopack cache before pnpm build
24
+ - fix(dispatcher): two-pass default fill + per-connector legacy aliases
25
+ - feat(idp-login): opt-in encrypted credential save
26
+ - feat(idp-login): show/hide toggle on password input
27
+ - feat(idp-login): per-entry selectors / username override / timeout; UI prefills username from email
28
+ - feat(idp-login): surface trigger URL on per-IdP failure for manual login
29
+ - feat(idp-login): trigger_url override + transcript-based failure detection
30
+ - feat(idp-login): show manual command line when terminal-keystroke fails
31
+ - feat(idp-login): multi-IdP support — _idp accepts array
32
+ - ui: remove standalone GitLab 2FA section from EnterpriseBadge
33
+ - feat(unified-auth): template-driven post_login_terminal commands
34
+ - fix(gitlab-2fa keystroke): split OK/FAIL into separate atomic markers
35
+ - fix(gitlab-2fa keystroke): only match prompt in NEW Terminal output
36
+ - fix(gitlab-2fa keystroke): clipboard+paste instead of keystroke-char-by-char
37
+ - simplify(gitlab-2fa): bare ssh, drop noisy options
38
+ - fix(gitlab-2fa keystroke): wait for OTP prompt before typing OTP
39
+ - feat(gitlab-2fa): keystroke fallback via macOS Terminal + System Events
40
+ - feat(unified-auth): read IdP host + SAML SPs from wizard template _idp
41
+ - fix(unified-auth): only use SAML SPs as IdP trigger (drop gitlab/teams)
42
+ - feat(unified-auth): Phase 3 — one-shot SAML IdP login
43
+ - feat(gitlab-2fa): manual fallback for FortiClient per-process VPN block
44
+ - fix(gitlab-2fa): bump ConnectTimeout 8s→30s, outer 15s→60s
45
+ - fix(gitlab-2fa): catch 'Operation timed out' (macOS ssh) + strip echoed OTP from error
46
+ - fix(gitlab-2fa): aggressive OTP send + surface ssh transcript on failure
12
47
 
13
48
 
14
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.44...v0.10.45
49
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.10.45...v0.10.46
@@ -0,0 +1,98 @@
1
+ /**
2
+ * GitLab SSH 2FA verify API.
3
+ *
4
+ * GET /api/auth/gitlab-2fa
5
+ * → { host, verifiedAt: string | null, seconds_left: number }
6
+ *
7
+ * POST /api/auth/gitlab-2fa
8
+ * body: { otp: "123456" }
9
+ * → { ok: true, verifiedAt, seconds_left, host } |
10
+ * { ok: false, error, transcript? }
11
+ *
12
+ * verifiedAt is persisted in settings.gitlab2fa so dropdown survives a
13
+ * page refresh.
14
+ */
15
+
16
+ import { NextResponse } from 'next/server';
17
+ import { loadSettings, saveSettings } from '@/lib/settings';
18
+ import {
19
+ verifyGitlab2FA,
20
+ verifyGitlab2FAViaKeystroke,
21
+ getGitlabSshHost,
22
+ secondsLeft,
23
+ GITLAB_2FA_VALID_MS,
24
+ } from '@/lib/auth/gitlab-2fa';
25
+
26
+ export async function GET() {
27
+ const settings = loadSettings();
28
+ const host = getGitlabSshHost();
29
+ const recorded = settings.gitlab2fa;
30
+ // Drop the stamp if host changed (user pointed gitlab elsewhere)
31
+ const valid = recorded && (!host || recorded.host === host);
32
+ const verifiedAt = valid ? recorded?.verifiedAt ?? null : null;
33
+ return NextResponse.json({
34
+ host,
35
+ verifiedAt,
36
+ seconds_left: secondsLeft(verifiedAt ?? undefined),
37
+ validity_seconds: Math.floor(GITLAB_2FA_VALID_MS / 1000),
38
+ });
39
+ }
40
+
41
+ export async function POST(req: Request) {
42
+ let body: any = {};
43
+ try { body = await req.json(); } catch {}
44
+
45
+ // Manual mode: caller already ran `ssh git@host 2fa_verify` in their
46
+ // own terminal (necessary when corp VPN like FortiClient does per-
47
+ // process tunnel filtering — node-spawned ssh can't reach the host).
48
+ // Forge just stamps verifiedAt so the 55-minute clock starts.
49
+ if (body?.manual === true) {
50
+ const host = getGitlabSshHost();
51
+ if (!host) {
52
+ return NextResponse.json({ ok: false, error: 'GitLab connector not configured' }, { status: 400 });
53
+ }
54
+ const verifiedAt = new Date().toISOString();
55
+ const settings = loadSettings();
56
+ settings.gitlab2fa = { verifiedAt, host };
57
+ saveSettings(settings);
58
+ return NextResponse.json({
59
+ ok: true,
60
+ verifiedAt,
61
+ host,
62
+ seconds_left: Math.floor(GITLAB_2FA_VALID_MS / 1000),
63
+ mode: 'manual',
64
+ });
65
+ }
66
+
67
+ const otp = String(body?.otp || '').trim();
68
+ if (!otp) {
69
+ return NextResponse.json({ ok: false, error: 'otp is required' }, { status: 400 });
70
+ }
71
+
72
+ // Mode selector: 'keystroke' uses osascript+System Events to type
73
+ // into the user's frontmost Terminal — required in environments
74
+ // where the corp VPN blocks Forge-spawned ssh (FortiClient).
75
+ // Default is the direct node-pty path (works for non-filtered envs).
76
+ const mode = String(body?.mode || 'direct');
77
+ const r = mode === 'keystroke'
78
+ ? await verifyGitlab2FAViaKeystroke(otp)
79
+ : await verifyGitlab2FA(otp);
80
+ if (!r.ok) {
81
+ return NextResponse.json(
82
+ { ok: false, error: r.error || 'verify failed', transcript: r.transcript },
83
+ { status: 400 },
84
+ );
85
+ }
86
+
87
+ // Persist for the dropdown's "X min left" rendering.
88
+ const settings = loadSettings();
89
+ settings.gitlab2fa = { verifiedAt: r.verifiedAt!, host: r.host! };
90
+ saveSettings(settings);
91
+
92
+ return NextResponse.json({
93
+ ok: true,
94
+ verifiedAt: r.verifiedAt,
95
+ host: r.host,
96
+ seconds_left: Math.floor(GITLAB_2FA_VALID_MS / 1000),
97
+ });
98
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * GET /api/auth/idp-credentials → { username, password } (decrypted)
3
+ * POST /api/auth/idp-credentials body: { username, password }
4
+ * DELETE /api/auth/idp-credentials
5
+ *
6
+ * Read/write the EnterpriseBadge's saved unified-login credentials.
7
+ * Password lives in SECRET_FIELDS so it's AES-256-GCM encrypted at rest;
8
+ * this endpoint hands it back in plaintext for the local UI to prefill
9
+ * (Forge is single-user, runs on localhost — equivalent threat surface
10
+ * to the user's own keychain). OTP is never persisted — it's TOTP.
11
+ */
12
+
13
+ import { NextResponse } from 'next/server';
14
+ import { loadSettings, saveSettings } from '@/lib/settings';
15
+
16
+ export async function GET() {
17
+ const s = loadSettings() as unknown as Record<string, unknown>;
18
+ return NextResponse.json({
19
+ username: (s.idpSavedUsername as string) || '',
20
+ password: (s.idpSavedPassword as string) || '',
21
+ });
22
+ }
23
+
24
+ export async function POST(req: Request) {
25
+ let body: { username?: string; password?: string } = {};
26
+ try { body = await req.json(); } catch { /* empty body */ }
27
+ const username = String(body.username || '').trim();
28
+ const password = String(body.password || '');
29
+ if (!username || !password) {
30
+ return NextResponse.json({ ok: false, error: 'username and password are required' }, { status: 400 });
31
+ }
32
+ const current = loadSettings() as unknown as Record<string, unknown>;
33
+ saveSettings({ ...current, idpSavedUsername: username, idpSavedPassword: password } as never);
34
+ return NextResponse.json({ ok: true });
35
+ }
36
+
37
+ export async function DELETE() {
38
+ const current = loadSettings() as unknown as Record<string, unknown>;
39
+ saveSettings({ ...current, idpSavedUsername: '', idpSavedPassword: '' } as never);
40
+ return NextResponse.json({ ok: true });
41
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * POST /api/auth/idp-login
3
+ * body: { username, password, otp }
4
+ * → { ok, idp_logins: [{idp_host, ok, steps_completed?, final_url?, error?, post_login_terminal?}], error? }
5
+ *
6
+ * Drives one or more SAML IdP logins (template `_idp` may be array) via
7
+ * the extension. Secrets stay in this request — never persisted.
8
+ */
9
+
10
+ import { NextResponse } from 'next/server';
11
+ import { performIdpLogin } from '@/lib/auth/idp-login';
12
+
13
+ export async function POST(req: Request) {
14
+ let body: any = {};
15
+ try { body = await req.json(); } catch {}
16
+ const r = await performIdpLogin({
17
+ username: String(body?.username || '').trim(),
18
+ password: String(body?.password || ''),
19
+ otp: String(body?.otp || '').trim(),
20
+ });
21
+ return NextResponse.json(r, { status: r.ok ? 200 : 400 });
22
+ }
@@ -0,0 +1,78 @@
1
+ /**
2
+ * GET /api/scratch/<path...>
3
+ * ?download=1 → force Content-Disposition: attachment
4
+ *
5
+ * Serves files from `<dataDir>/scratch/` so chat-emitted paths like
6
+ * `scratch/report-2026-06-08.md` can be clicked open from the chat UI.
7
+ * Path-traversal protected: any segment containing `..` or any resolved
8
+ * path escaping the scratch root is rejected.
9
+ *
10
+ * Auth-gated by Forge's proxy middleware just like every other /api/*.
11
+ */
12
+
13
+ import { NextResponse } from 'next/server';
14
+ import { existsSync, readFileSync, statSync } from 'node:fs';
15
+ import { join, resolve, extname } from 'node:path';
16
+ import { getDataDir } from '@/lib/dirs';
17
+
18
+ const MIME: Record<string, string> = {
19
+ '.md': 'text/markdown; charset=utf-8',
20
+ '.txt': 'text/plain; charset=utf-8',
21
+ '.json': 'application/json; charset=utf-8',
22
+ '.yaml': 'application/yaml; charset=utf-8',
23
+ '.yml': 'application/yaml; charset=utf-8',
24
+ '.csv': 'text/csv; charset=utf-8',
25
+ '.log': 'text/plain; charset=utf-8',
26
+ '.html': 'text/html; charset=utf-8',
27
+ '.pdf': 'application/pdf',
28
+ '.png': 'image/png',
29
+ '.jpg': 'image/jpeg',
30
+ '.jpeg': 'image/jpeg',
31
+ '.gif': 'image/gif',
32
+ '.svg': 'image/svg+xml',
33
+ };
34
+
35
+ export async function GET(
36
+ req: Request,
37
+ ctx: { params: Promise<{ path: string[] }> },
38
+ ) {
39
+ const { path: parts } = await ctx.params;
40
+ if (!parts || parts.length === 0) {
41
+ return NextResponse.json({ ok: false, error: 'missing path' }, { status: 400 });
42
+ }
43
+ // Reject explicit traversal attempts before path construction.
44
+ for (const seg of parts) {
45
+ if (seg === '..' || seg.startsWith('..')) {
46
+ return NextResponse.json({ ok: false, error: 'path traversal rejected' }, { status: 400 });
47
+ }
48
+ }
49
+ const scratchRoot = resolve(join(getDataDir(), 'scratch'));
50
+ const target = resolve(join(scratchRoot, ...parts));
51
+ // Verify the resolved path stays under scratchRoot — handles symlink
52
+ // tricks the segment check above can't catch.
53
+ if (!target.startsWith(scratchRoot + '/') && target !== scratchRoot) {
54
+ return NextResponse.json({ ok: false, error: 'path traversal rejected' }, { status: 400 });
55
+ }
56
+ if (!existsSync(target)) {
57
+ return NextResponse.json({ ok: false, error: 'not found' }, { status: 404 });
58
+ }
59
+ const st = statSync(target);
60
+ if (!st.isFile()) {
61
+ return NextResponse.json({ ok: false, error: 'not a file' }, { status: 400 });
62
+ }
63
+ const url = new URL(req.url);
64
+ const download = url.searchParams.get('download') === '1';
65
+ const ext = extname(target).toLowerCase();
66
+ const mime = MIME[ext] || 'application/octet-stream';
67
+ const data = readFileSync(target);
68
+ const filename = parts[parts.length - 1];
69
+ return new NextResponse(new Uint8Array(data), {
70
+ status: 200,
71
+ headers: {
72
+ 'Content-Type': mime,
73
+ 'Content-Length': String(st.size),
74
+ 'Content-Disposition': `${download ? 'attachment' : 'inline'}; filename="${filename.replace(/"/g, '\\"')}"`,
75
+ 'Cache-Control': 'private, max-age=0, must-revalidate',
76
+ },
77
+ });
78
+ }
@@ -656,7 +656,16 @@ function startBackground() {
656
656
  const child = spawn(nextBin, ['start', '-p', String(webPort)], {
657
657
  cwd: ROOT,
658
658
  stdio: ['ignore', logFd, logFd],
659
- env: { ...process.env, FORGE_EXTERNAL_SERVICES: '1' },
659
+ env: {
660
+ ...process.env,
661
+ FORGE_EXTERNAL_SERVICES: '1',
662
+ // --use-system-ca lets Node 22+ trust the OS keychain in addition
663
+ // to its baked-in CA bundle. Corp networks (FortiClient SSL
664
+ // inspection, Zscaler, etc.) install a custom root via MDM; Node
665
+ // doesn't see it without this flag, so marketplace sync fails with
666
+ // 'self-signed certificate in certificate chain' on api.github.com.
667
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, '--use-system-ca'].filter(Boolean).join(' '),
668
+ },
660
669
  detached: true,
661
670
  });
662
671
 
@@ -738,7 +747,16 @@ if (isDev) {
738
747
  const child = spawn('npx', ['next', 'dev', '--turbopack', '-p', String(webPort)], {
739
748
  cwd: ROOT,
740
749
  stdio: 'inherit',
741
- env: { ...process.env, FORGE_EXTERNAL_SERVICES: '1' },
750
+ env: {
751
+ ...process.env,
752
+ FORGE_EXTERNAL_SERVICES: '1',
753
+ // --use-system-ca lets Node 22+ trust the OS keychain in addition
754
+ // to its baked-in CA bundle. Corp networks (FortiClient SSL
755
+ // inspection, Zscaler, etc.) install a custom root via MDM; Node
756
+ // doesn't see it without this flag, so marketplace sync fails with
757
+ // 'self-signed certificate in certificate chain' on api.github.com.
758
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, '--use-system-ca'].filter(Boolean).join(' '),
759
+ },
742
760
  });
743
761
  child.on('exit', (code) => { stopServices(); process.exit(code || 0); });
744
762
  } else {
@@ -748,7 +766,16 @@ if (isDev) {
748
766
  const child = spawn('npx', ['next', 'start', '-p', String(webPort)], {
749
767
  cwd: ROOT,
750
768
  stdio: 'inherit',
751
- env: { ...process.env, FORGE_EXTERNAL_SERVICES: '1' },
769
+ env: {
770
+ ...process.env,
771
+ FORGE_EXTERNAL_SERVICES: '1',
772
+ // --use-system-ca lets Node 22+ trust the OS keychain in addition
773
+ // to its baked-in CA bundle. Corp networks (FortiClient SSL
774
+ // inspection, Zscaler, etc.) install a custom root via MDM; Node
775
+ // doesn't see it without this flag, so marketplace sync fails with
776
+ // 'self-signed certificate in certificate chain' on api.github.com.
777
+ NODE_OPTIONS: [process.env.NODE_OPTIONS, '--use-system-ca'].filter(Boolean).join(' '),
778
+ },
752
779
  });
753
780
  child.on('exit', (code) => { stopServices(); process.exit(code || 0); });
754
781
  }
@@ -481,56 +481,7 @@ export default function Dashboard({ user }: { user: any }) {
481
481
  </div>
482
482
  <div className="flex items-center gap-2.5">
483
483
  {/* (Automation context actions moved to the secondary toolbar row below) */}
484
- {/* Help */}
485
- <button
486
- onClick={() => setShowHelp(v => !v)}
487
- className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
488
- showHelp
489
- ? 'border-[var(--accent)] text-[var(--accent)]'
490
- : 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
491
- }`}
492
- >?</button>
493
- <div className="relative">
494
- <button
495
- onClick={() => setShowBrowserMenu(v => !v)}
496
- className={`text-[10px] px-2 py-0.5 border rounded transition-colors ${
497
- browserMode !== 'none'
498
- ? 'border-blue-500 text-blue-400'
499
- : 'border-[var(--border)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)]'
500
- }`}
501
- >
502
- Browser
503
- </button>
504
- {showBrowserMenu && (
505
- <>
506
- <div className="fixed inset-0 z-40" onClick={() => setShowBrowserMenu(false)} />
507
- <div className="absolute top-full right-0 mt-1 z-50 bg-[var(--bg-secondary)] border border-[var(--border)] rounded shadow-lg py-1 min-w-[140px]">
508
- {browserMode !== 'none' && (
509
- <button onClick={() => { setBrowserMode('none'); setShowBrowserMenu(false); }} className="w-full text-left px-3 py-1.5 text-[10px] text-red-400 hover:bg-[var(--bg-tertiary)]">
510
- Close Browser
511
- </button>
512
- )}
513
- <button onClick={() => { setBrowserMode('float'); setShowBrowserMenu(false); }} className={`w-full text-left px-3 py-1.5 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'float' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
514
- Floating Window
515
- </button>
516
- <button onClick={() => { setBrowserMode('right'); setShowBrowserMenu(false); }} className={`w-full text-left px-3 py-1.5 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'right' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
517
- Right Side
518
- </button>
519
- <button onClick={() => { setBrowserMode('left'); setShowBrowserMenu(false); }} className={`w-full text-left px-3 py-1.5 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'left' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
520
- Left Side
521
- </button>
522
- <button onClick={() => {
523
- const url = localStorage.getItem('forge-browser-url');
524
- if (url) window.open(url, '_blank');
525
- else { const u = prompt('Enter URL to open:'); if (u) window.open(u.trim(), '_blank'); }
526
- setShowBrowserMenu(false);
527
- }} className="w-full text-left px-3 py-1.5 text-[10px] text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]">
528
- New Tab
529
- </button>
530
- </div>
531
- </>
532
- )}
533
- </div>
484
+ {/* Help / Browser moved into the user dropdown — see below. */}
534
485
  <Suspense fallback={null}><TunnelToggle /></Suspense>
535
486
  {onlineCount.total > 0 && (
536
487
  <span className="text-[10px] text-[var(--text-secondary)] flex items-center gap-1" title={`${onlineCount.total} online${onlineCount.remote > 0 ? `, ${onlineCount.remote} remote` : ''}`}>
@@ -742,7 +693,49 @@ export default function Dashboard({ user }: { user: any }) {
742
693
  <span className="flex-1">Mobile View</span>
743
694
  <span className="text-[9px] text-[var(--text-secondary)]">↗</span>
744
695
  </a>
696
+ {/* Browser — toggling reveals the layout options inline so the
697
+ sub-menu lives inside the user dropdown (no nested popup). */}
698
+ <button
699
+ onClick={() => setShowBrowserMenu(v => !v)}
700
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
701
+ >
702
+ <span className="w-3 text-center">🌐</span>
703
+ <span className="flex-1">Browser{browserMode !== 'none' ? ` · ${browserMode}` : ''}</span>
704
+ <span className="text-[9px] text-[var(--text-secondary)]">{showBrowserMenu ? '▾' : '▸'}</span>
705
+ </button>
706
+ {showBrowserMenu && (
707
+ <div className="pl-6 pr-1 py-0.5 border-l border-[var(--border)] ml-3 mb-1">
708
+ <button onClick={() => { setBrowserMode('float'); setShowBrowserMenu(false); setShowUserMenu(false); }} className={`w-full text-left px-2 py-1 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'float' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
709
+ Floating Window
710
+ </button>
711
+ <button onClick={() => { setBrowserMode('right'); setShowBrowserMenu(false); setShowUserMenu(false); }} className={`w-full text-left px-2 py-1 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'right' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
712
+ Right Side
713
+ </button>
714
+ <button onClick={() => { setBrowserMode('left'); setShowBrowserMenu(false); setShowUserMenu(false); }} className={`w-full text-left px-2 py-1 text-[10px] hover:bg-[var(--bg-tertiary)] ${browserMode === 'left' ? 'text-[var(--accent)]' : 'text-[var(--text-primary)]'}`}>
715
+ Left Side
716
+ </button>
717
+ <button onClick={() => {
718
+ const url = localStorage.getItem('forge-browser-url');
719
+ if (url) window.open(url, '_blank');
720
+ else { const u = prompt('Enter URL to open:'); if (u) window.open(u.trim(), '_blank'); }
721
+ setShowBrowserMenu(false); setShowUserMenu(false);
722
+ }} className="w-full text-left px-2 py-1 text-[10px] text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)]">
723
+ New Tab
724
+ </button>
725
+ {browserMode !== 'none' && (
726
+ <button onClick={() => { setBrowserMode('none'); setShowBrowserMenu(false); setShowUserMenu(false); }} className="w-full text-left px-2 py-1 text-[10px] text-red-400 hover:bg-[var(--bg-tertiary)]">
727
+ Close Browser
728
+ </button>
729
+ )}
730
+ </div>
731
+ )}
745
732
  <div className="border-t border-[var(--border)] my-1" />
733
+ <button
734
+ onClick={() => { setShowHelp(v => !v); setShowUserMenu(false); }}
735
+ className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"
736
+ >
737
+ <span className="w-3 text-center">?</span><span>Help</span>
738
+ </button>
746
739
  <button
747
740
  onClick={() => signOut({ callbackUrl: '/login' })}
748
741
  className="w-full text-left text-[11px] px-3 py-1.5 text-[var(--text-secondary)] hover:text-[var(--red)] hover:bg-[var(--bg-tertiary)] flex items-center gap-2"