@aion0/forge 0.10.44 → 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 +43 -5
- package/app/api/auth/gitlab-2fa/route.ts +98 -0
- package/app/api/auth/idp-credentials/route.ts +41 -0
- package/app/api/auth/idp-login/route.ts +22 -0
- package/app/api/enterprise-keys/route.ts +11 -1
- package/app/api/scratch/[...path]/route.ts +78 -0
- package/bin/forge-server.mjs +30 -3
- package/components/Dashboard.tsx +43 -50
- package/components/EnterpriseBadge.tsx +390 -1
- package/install.sh +6 -0
- package/lib/auth/gitlab-2fa.ts +443 -0
- package/lib/auth/idp-login.ts +221 -0
- package/lib/auth/terminal-keystroke.ts +245 -0
- package/lib/chat/link-patterns.ts +11 -0
- package/lib/chat/session-store.ts +5 -2
- package/lib/chat/tool-dispatcher.ts +90 -18
- package/lib/connectors/migration.ts +55 -0
- package/lib/connectors/registry.ts +20 -2
- package/lib/connectors/sync.ts +21 -1
- package/lib/crypto.ts +1 -1
- package/lib/enterprise.ts +5 -1
- package/lib/init.ts +9 -1
- package/lib/scratch-cleanup.ts +64 -0
- package/lib/settings.ts +20 -0
- package/next-env.d.ts +1 -1
- package/package.json +1 -1
package/RELEASE_NOTES.md
CHANGED
|
@@ -1,11 +1,49 @@
|
|
|
1
|
-
# Forge v0.10.
|
|
1
|
+
# Forge v0.10.46
|
|
2
2
|
|
|
3
3
|
Released: 2026-06-08
|
|
4
4
|
|
|
5
|
-
## Changes since v0.10.
|
|
5
|
+
## Changes since v0.10.45
|
|
6
6
|
|
|
7
|
-
###
|
|
8
|
-
-
|
|
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
|
|
|
11
|
+
### Other
|
|
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
|
|
10
47
|
|
|
11
|
-
|
|
48
|
+
|
|
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
|
+
}
|
|
@@ -28,7 +28,7 @@ import {
|
|
|
28
28
|
removeEnterpriseKey,
|
|
29
29
|
updateEnterpriseKey,
|
|
30
30
|
} from '@/lib/enterprise';
|
|
31
|
-
import { syncRegistry, getLastSync } from '@/lib/connectors/sync';
|
|
31
|
+
import { syncRegistry, getLastSync, probeEnterpriseKey } from '@/lib/connectors/sync';
|
|
32
32
|
import { syncMarketplace as syncWorkflows } from '@/lib/workflow-marketplace';
|
|
33
33
|
import { syncSkills } from '@/lib/skills';
|
|
34
34
|
|
|
@@ -121,6 +121,13 @@ export async function POST(req: Request) {
|
|
|
121
121
|
const key = String(body?.key || '').trim();
|
|
122
122
|
if (!key) return NextResponse.json({ ok: false, error: 'key is required' }, { status: 400 });
|
|
123
123
|
|
|
124
|
+
// Probe BEFORE persisting — a key whose PAT is wrong / expired or whose
|
|
125
|
+
// repo we can't reach should never land on disk. Surfaces the real
|
|
126
|
+
// 401/403/404/network error to the user instead of silently failing
|
|
127
|
+
// later inside the best-effort sync.
|
|
128
|
+
const probe = await probeEnterpriseKey(key);
|
|
129
|
+
if (!probe.ok) return NextResponse.json({ ok: false, error: probe.error }, { status: 400 });
|
|
130
|
+
|
|
124
131
|
const r = addEnterpriseKey(key);
|
|
125
132
|
if (!r.ok) return NextResponse.json(r, { status: 400 });
|
|
126
133
|
|
|
@@ -176,6 +183,9 @@ export async function PATCH(req: Request) {
|
|
|
176
183
|
const key = String(body?.key || '').trim();
|
|
177
184
|
if (!key) return NextResponse.json({ ok: false, error: 'key is required' }, { status: 400 });
|
|
178
185
|
|
|
186
|
+
const probe = await probeEnterpriseKey(key);
|
|
187
|
+
if (!probe.ok) return NextResponse.json({ ok: false, error: probe.error }, { status: 400 });
|
|
188
|
+
|
|
179
189
|
const r = updateEnterpriseKey(tenant_id, key);
|
|
180
190
|
if (!r.ok) return NextResponse.json(r, { status: 400 });
|
|
181
191
|
|
|
@@ -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
|
+
}
|
package/bin/forge-server.mjs
CHANGED
|
@@ -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: {
|
|
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: {
|
|
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: {
|
|
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
|
}
|
package/components/Dashboard.tsx
CHANGED
|
@@ -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"
|