@aion0/forge 0.10.46 → 0.10.48
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 +5 -42
- package/app/api/auth/keys/[id]/route.ts +16 -0
- package/app/api/auth/keys/route.ts +36 -0
- package/app/api/mcp/route.ts +144 -0
- package/cli/key.ts +67 -0
- package/cli/mcp-install.ts +106 -0
- package/cli/mcp-proxy.ts +196 -0
- package/cli/mw.mjs +453 -35
- package/cli/mw.ts +26 -1
- package/components/Dashboard.tsx +25 -20
- package/components/SettingsModal.tsx +123 -0
- package/lib/api-auth.ts +50 -0
- package/lib/api-keys.ts +157 -0
- package/lib/jobs/store.ts +25 -0
- package/lib/projects.ts +79 -5
- package/lib/settings.ts +12 -2
- package/mcp/README.md +46 -0
- package/mcp/server.ts +30 -0
- package/mcp/tools/_shared.ts +103 -0
- package/mcp/tools/automation.ts +244 -0
- package/mcp/tools/connectors.ts +83 -0
- package/mcp/tools/help.ts +50 -0
- package/mcp/tools/index.ts +39 -0
- package/mcp/tools/integrations.ts +97 -0
- package/mcp/tools/logs.ts +57 -0
- package/mcp/tools/marketplace.ts +75 -0
- package/mcp/tools/observability.ts +96 -0
- package/mcp/tools/pipelines.ts +150 -0
- package/mcp/tools/projects.ts +54 -0
- package/mcp/tools/tasks.ts +93 -0
- package/mcp/tools/workspace.ts +94 -0
- package/package.json +1 -1
- package/proxy.ts +27 -16
- package/src/core/db/database.ts +50 -43
package/components/Dashboard.tsx
CHANGED
|
@@ -422,9 +422,9 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
422
422
|
<button
|
|
423
423
|
key={mode}
|
|
424
424
|
onClick={() => setViewMode(mode)}
|
|
425
|
-
className={`text-[
|
|
425
|
+
className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
|
|
426
426
|
viewMode === mode
|
|
427
|
-
? 'bg-[var(--
|
|
427
|
+
? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
|
|
428
428
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
429
429
|
}`}
|
|
430
430
|
>
|
|
@@ -435,9 +435,9 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
435
435
|
{/* Docs */}
|
|
436
436
|
<button
|
|
437
437
|
onClick={() => setViewMode('docs')}
|
|
438
|
-
className={`text-[
|
|
438
|
+
className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
|
|
439
439
|
viewMode === 'docs'
|
|
440
|
-
? 'bg-[var(--
|
|
440
|
+
? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
|
|
441
441
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
442
442
|
}`}
|
|
443
443
|
>
|
|
@@ -451,9 +451,9 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
451
451
|
onClick={() => {
|
|
452
452
|
if (!['tasks', 'pipelines', 'schedules'].includes(viewMode)) setViewMode('schedules');
|
|
453
453
|
}}
|
|
454
|
-
className={`text-[
|
|
454
|
+
className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
|
|
455
455
|
['tasks', 'pipelines', 'schedules'].includes(viewMode)
|
|
456
|
-
? 'bg-[var(--
|
|
456
|
+
? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
|
|
457
457
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
458
458
|
}`}
|
|
459
459
|
>
|
|
@@ -468,9 +468,9 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
468
468
|
{/* Marketplace */}
|
|
469
469
|
<button
|
|
470
470
|
onClick={() => setViewMode('skills')}
|
|
471
|
-
className={`text-[
|
|
471
|
+
className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
|
|
472
472
|
viewMode === 'skills'
|
|
473
|
-
? 'bg-[var(--
|
|
473
|
+
? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
|
|
474
474
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
475
475
|
}`}
|
|
476
476
|
>
|
|
@@ -490,13 +490,15 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
490
490
|
</span>
|
|
491
491
|
)}
|
|
492
492
|
<span className="w-[2px] h-4 bg-[var(--text-secondary)]/30" />
|
|
493
|
-
{/* Alerts */}
|
|
493
|
+
{/* Alerts — bell glyph keeps the same role; unread pill rides the bell. */}
|
|
494
494
|
<div className="relative">
|
|
495
495
|
<button
|
|
496
496
|
onClick={() => { setShowNotifications(v => !v); setShowUserMenu(false); }}
|
|
497
|
-
className="text-[
|
|
497
|
+
className="text-[14px] leading-none text-[var(--text-secondary)] hover:text-[var(--text-primary)] relative px-1"
|
|
498
|
+
title="Alerts"
|
|
499
|
+
aria-label="Alerts"
|
|
498
500
|
>
|
|
499
|
-
|
|
501
|
+
<span aria-hidden>🔔</span>
|
|
500
502
|
{unreadCount > 0 && (
|
|
501
503
|
<span className="absolute -top-1.5 -right-1.5 min-w-[14px] h-[14px] rounded-full bg-[var(--red)] text-[8px] text-white flex items-center justify-center px-1 font-bold">
|
|
502
504
|
{unreadCount > 99 ? '99+' : unreadCount}
|
|
@@ -620,14 +622,17 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
620
622
|
<div className="relative">
|
|
621
623
|
<button
|
|
622
624
|
onClick={() => { setShowUserMenu(v => !v); setShowNotifications(false); }}
|
|
623
|
-
className="text-[
|
|
625
|
+
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] flex items-center gap-1 px-1"
|
|
624
626
|
>
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
627
|
+
<span className="flex flex-col items-end leading-tight">
|
|
628
|
+
{profileDept && (
|
|
629
|
+
<span className="text-[8px] px-1 rounded bg-emerald-500/15 text-emerald-500 border border-emerald-500/30" title="Active department">
|
|
630
|
+
{profileDept}
|
|
631
|
+
</span>
|
|
632
|
+
)}
|
|
633
|
+
<span className="text-[9px]">{displayName}</span>
|
|
634
|
+
</span>
|
|
635
|
+
<span className="text-[8px]">▾</span>
|
|
631
636
|
</button>
|
|
632
637
|
{showUserMenu && (
|
|
633
638
|
<>
|
|
@@ -758,9 +763,9 @@ export default function Dashboard({ user }: { user: any }) {
|
|
|
758
763
|
<button
|
|
759
764
|
key={m}
|
|
760
765
|
onClick={() => setViewMode(m)}
|
|
761
|
-
className={`text-[
|
|
766
|
+
className={`text-[12px] px-2.5 py-0.5 rounded transition-colors ${
|
|
762
767
|
viewMode === m
|
|
763
|
-
? 'bg-[var(--
|
|
768
|
+
? 'bg-[var(--accent)]/15 text-[var(--accent)] shadow-sm'
|
|
764
769
|
: 'text-[var(--text-secondary)] hover:text-[var(--text-primary)]'
|
|
765
770
|
}`}
|
|
766
771
|
>
|
|
@@ -205,6 +205,126 @@ function SecretField({ label, description, isSet, onEdit }: {
|
|
|
205
205
|
);
|
|
206
206
|
}
|
|
207
207
|
|
|
208
|
+
// ─── API Key Section ───────────────────────────────────────────
|
|
209
|
+
|
|
210
|
+
interface ApiKeyPublic { id: string; name: string; prefix: string; createdAt: string; lastUsedAt: string | null }
|
|
211
|
+
|
|
212
|
+
function ApiKeySection() {
|
|
213
|
+
const [keys, setKeys] = useState<ApiKeyPublic[]>([]);
|
|
214
|
+
// Whether an admin password is configured — read from the keys endpoint's
|
|
215
|
+
// purpose-built `adminConfigured` flag, not inferred from an unrelated secret
|
|
216
|
+
// field, so this section isn't coupled to how the admin password is stored.
|
|
217
|
+
const [adminSet, setAdminSet] = useState(false);
|
|
218
|
+
const [dialog, setDialog] = useState<null | { mode: 'generate' } | { mode: 'revoke'; id: string; prefix: string }>(null);
|
|
219
|
+
const [adminPassword, setAdminPassword] = useState('');
|
|
220
|
+
const [name, setName] = useState('agent key');
|
|
221
|
+
const [error, setError] = useState('');
|
|
222
|
+
const [busy, setBusy] = useState(false);
|
|
223
|
+
const [revealed, setRevealed] = useState('');
|
|
224
|
+
const [copied, setCopied] = useState(false);
|
|
225
|
+
|
|
226
|
+
const refresh = async () => {
|
|
227
|
+
try { const r = await fetch('/api/auth/keys'); const d = await r.json(); setKeys(d.keys || []); setAdminSet(!!d.adminConfigured); } catch { /* ignore */ }
|
|
228
|
+
};
|
|
229
|
+
useEffect(() => { refresh(); }, []);
|
|
230
|
+
|
|
231
|
+
const closeDialog = () => { setDialog(null); setAdminPassword(''); setError(''); };
|
|
232
|
+
|
|
233
|
+
const generate = async () => {
|
|
234
|
+
setBusy(true); setError('');
|
|
235
|
+
try {
|
|
236
|
+
const r = await fetch('/api/auth/keys', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, adminPassword }) });
|
|
237
|
+
const d = await r.json();
|
|
238
|
+
if (!r.ok) { setError(d.error || 'Failed to generate key'); setBusy(false); return; }
|
|
239
|
+
setRevealed(d.key); setCopied(false); closeDialog(); refresh();
|
|
240
|
+
} catch (e) { setError((e as Error).message || 'Failed'); }
|
|
241
|
+
setBusy(false);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const revoke = async () => {
|
|
245
|
+
if (dialog?.mode !== 'revoke') return;
|
|
246
|
+
setBusy(true); setError('');
|
|
247
|
+
try {
|
|
248
|
+
const r = await fetch(`/api/auth/keys/${dialog.id}`, { method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ adminPassword }) });
|
|
249
|
+
const d = await r.json().catch(() => ({}));
|
|
250
|
+
if (!r.ok) { setError(d.error || 'Failed to revoke'); setBusy(false); return; }
|
|
251
|
+
closeDialog(); refresh();
|
|
252
|
+
} catch (e) { setError((e as Error).message || 'Failed'); }
|
|
253
|
+
setBusy(false);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const inputClass = "w-full px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded text-xs text-[var(--text-primary)] font-mono focus:outline-none focus:border-[var(--accent)]";
|
|
257
|
+
|
|
258
|
+
return (
|
|
259
|
+
<div className="space-y-2" data-testid="api-key-section">
|
|
260
|
+
<label className="text-xs text-[var(--text-secondary)] font-semibold uppercase">API Key</label>
|
|
261
|
+
<p className="text-[10px] text-[var(--text-secondary)]">
|
|
262
|
+
Lets an agent or service act on your behalf — MCP operations and automation — without sharing your admin password. Generated, revocable, shown once.
|
|
263
|
+
</p>
|
|
264
|
+
|
|
265
|
+
{!adminSet ? (
|
|
266
|
+
<p className="text-[10px] text-[var(--text-secondary)] italic">Set an admin password first to generate an API key.</p>
|
|
267
|
+
) : (
|
|
268
|
+
<>
|
|
269
|
+
{revealed && (
|
|
270
|
+
<div className="p-2 bg-[var(--bg-tertiary)] border border-[var(--accent)] rounded space-y-1" data-testid="api-key-revealed">
|
|
271
|
+
<p className="text-[9px] text-[var(--accent)]">⚠ Copy now — you won't be able to see this again.</p>
|
|
272
|
+
<div className="flex items-center gap-2">
|
|
273
|
+
<code className="flex-1 text-[10px] font-mono text-[var(--text-primary)] break-all">{revealed}</code>
|
|
274
|
+
<button onClick={() => { navigator.clipboard?.writeText(revealed); setCopied(true); }} className="text-[10px] px-2 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white shrink-0">{copied ? 'Copied' : 'Copy'}</button>
|
|
275
|
+
</div>
|
|
276
|
+
<button onClick={() => { setRevealed(''); setCopied(false); }} className="text-[9px] text-[var(--text-secondary)] hover:text-[var(--text-primary)]">Dismiss</button>
|
|
277
|
+
</div>
|
|
278
|
+
)}
|
|
279
|
+
|
|
280
|
+
{keys.length === 0 ? (
|
|
281
|
+
<button onClick={() => setDialog({ mode: 'generate' })} data-testid="api-key-generate" className="text-[10px] px-2 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white">Generate API Key</button>
|
|
282
|
+
) : (
|
|
283
|
+
<div className="space-y-1">
|
|
284
|
+
{keys.map(k => (
|
|
285
|
+
<div key={k.id} className="flex items-center justify-between gap-2 px-2 py-1.5 bg-[var(--bg-tertiary)] border border-[var(--border)] rounded">
|
|
286
|
+
<div className="min-w-0">
|
|
287
|
+
<div className="text-xs font-mono text-[var(--text-primary)] truncate">{k.prefix}</div>
|
|
288
|
+
<div className="text-[9px] text-[var(--text-secondary)]">{k.name} · created {k.createdAt?.slice(0, 10)} · last used {k.lastUsedAt ? new Date(k.lastUsedAt).toLocaleDateString() : 'never'}</div>
|
|
289
|
+
</div>
|
|
290
|
+
<button onClick={() => setDialog({ mode: 'revoke', id: k.id, prefix: k.prefix })} className="text-[10px] px-2 py-1 border border-[var(--red)] text-[var(--red)] rounded hover:bg-[var(--red)] hover:text-white shrink-0">Revoke</button>
|
|
291
|
+
</div>
|
|
292
|
+
))}
|
|
293
|
+
<button onClick={() => setDialog({ mode: 'generate' })} data-testid="api-key-generate" className="text-[10px] px-2 py-1 border border-[var(--accent)] text-[var(--accent)] rounded hover:bg-[var(--accent)] hover:text-white">Generate New Key</button>
|
|
294
|
+
</div>
|
|
295
|
+
)}
|
|
296
|
+
</>
|
|
297
|
+
)}
|
|
298
|
+
|
|
299
|
+
{dialog && (
|
|
300
|
+
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-[60]" onClick={closeDialog}>
|
|
301
|
+
<div className="bg-[var(--bg-secondary)] border border-[var(--border)] rounded-lg w-[380px] p-4 space-y-3" onClick={e => e.stopPropagation()}>
|
|
302
|
+
<h3 className="text-xs font-bold">{dialog.mode === 'generate' ? 'Generate API Key' : 'Revoke API Key'}</h3>
|
|
303
|
+
{dialog.mode === 'revoke' && (
|
|
304
|
+
<p className="text-[10px] text-[var(--text-secondary)]">Revoke <code className="text-[var(--text-primary)]">{dialog.prefix}</code>? Any agent using it will stop working.</p>
|
|
305
|
+
)}
|
|
306
|
+
{dialog.mode === 'generate' && (
|
|
307
|
+
<div className="space-y-1">
|
|
308
|
+
<label className="text-[10px] text-[var(--text-secondary)]">Key name</label>
|
|
309
|
+
<input value={name} onChange={e => setName(e.target.value)} placeholder="e.g. laptop, ci, agent" className={inputClass} data-testid="api-key-name" />
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
<div className="space-y-1">
|
|
313
|
+
<label className="text-[10px] text-[var(--text-secondary)]">Admin password</label>
|
|
314
|
+
<SecretInput value={adminPassword} onChange={v => { setAdminPassword(v); setError(''); }} placeholder="Enter admin password" className={inputClass} />
|
|
315
|
+
</div>
|
|
316
|
+
{error && <p className="text-[10px] text-[var(--red)]" data-testid="api-key-error">{error}</p>}
|
|
317
|
+
<div className="flex justify-end gap-2 pt-1">
|
|
318
|
+
<button onClick={closeDialog} className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)]">Cancel</button>
|
|
319
|
+
<button onClick={dialog.mode === 'generate' ? generate : revoke} disabled={!adminPassword || busy} data-testid="api-key-confirm" className={`px-3 py-1.5 text-xs text-white rounded hover:opacity-90 disabled:opacity-50 ${dialog.mode === 'revoke' ? 'bg-[var(--red)]' : 'bg-[var(--accent)]'}`}>{busy ? '...' : dialog.mode === 'generate' ? 'Generate' : 'Revoke'}</button>
|
|
320
|
+
</div>
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
)}
|
|
324
|
+
</div>
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
|
|
208
328
|
// ─── Settings Modal ────────────────────────────────────────────
|
|
209
329
|
|
|
210
330
|
interface Settings {
|
|
@@ -873,6 +993,9 @@ export default function SettingsModal({ onClose }: { onClose: () => void }) {
|
|
|
873
993
|
</p>
|
|
874
994
|
</div>
|
|
875
995
|
|
|
996
|
+
{/* API Key */}
|
|
997
|
+
<ApiKeySection />
|
|
998
|
+
|
|
876
999
|
{/* Actions */}
|
|
877
1000
|
<div className="flex items-center justify-between pt-2 border-t border-[var(--border)]">
|
|
878
1001
|
<span className="text-[10px] text-[var(--green)]">
|
package/lib/api-auth.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request authentication for Forge's API surface (API-key / session).
|
|
3
|
+
*
|
|
4
|
+
* A request is authenticated if it carries a valid persistent API key — a
|
|
5
|
+
* `forge_sk_`-prefixed key in Authorization: Bearer, the x-forge-token header,
|
|
6
|
+
* or the forge-api-token cookie — OR a VALIDATED NextAuth browser session. The
|
|
7
|
+
* session is checked with the framework decoder (auth()), NOT by cookie presence
|
|
8
|
+
* — a forged or unsigned session-token cookie does not pass. The legacy
|
|
9
|
+
* admin-minted in-memory token is NOT handled here; routes that accept it call
|
|
10
|
+
* isValidToken() in addition. Centralizes the key+session check so those routes
|
|
11
|
+
* share one implementation. (Named api-auth to avoid colliding with lib/auth.ts,
|
|
12
|
+
* the NextAuth config.)
|
|
13
|
+
*
|
|
14
|
+
* Route handlers call isAuthenticated() for the combined key-or-session check.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { verifyApiKey } from './api-keys';
|
|
18
|
+
import { auth } from './auth';
|
|
19
|
+
|
|
20
|
+
/** Extract a bearer/token credential from the request, if any. */
|
|
21
|
+
export function extractToken(req: Request): string | null {
|
|
22
|
+
const authz = req.headers.get('authorization');
|
|
23
|
+
if (authz && /^Bearer\s+/i.test(authz)) return authz.replace(/^Bearer\s+/i, '').trim();
|
|
24
|
+
const header = req.headers.get('x-forge-token');
|
|
25
|
+
if (header) return header;
|
|
26
|
+
const cookie = req.headers.get('cookie') || '';
|
|
27
|
+
const m = cookie.match(/forge-api-token=([^;]+)/);
|
|
28
|
+
return m ? m[1] : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True if the request carries a valid persistent API key — a `forge_sk_` key in
|
|
32
|
+
* Authorization: Bearer, x-forge-token, or the forge-api-token cookie. (The
|
|
33
|
+
* legacy admin-minted token is checked separately by isValidToken.) */
|
|
34
|
+
export function hasValidApiKey(req: Request): boolean {
|
|
35
|
+
return verifyApiKey(extractToken(req));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** True if there is a VALIDATED NextAuth browser session for the current request.
|
|
39
|
+
* Uses the framework decoder (auth()) — NOT cookie presence — so a forged or
|
|
40
|
+
* unsigned session-token cookie does not pass. Must be called within a request
|
|
41
|
+
* context (route handler / server component); returns false otherwise. */
|
|
42
|
+
export async function hasSession(): Promise<boolean> {
|
|
43
|
+
try { return !!(await auth()); } catch { return false; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** True if the request presents a valid API key or a validated browser session. */
|
|
47
|
+
export async function isAuthenticated(req: Request): Promise<boolean> {
|
|
48
|
+
if (hasValidApiKey(req)) return true;
|
|
49
|
+
return hasSession();
|
|
50
|
+
}
|
package/lib/api-keys.ts
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* API key store — credentials for services/agents acting on the user's behalf
|
|
3
|
+
* (MCP operations, automation), distinct from the human admin password.
|
|
4
|
+
*
|
|
5
|
+
* Keys are stored HASHED (sha256) in <dataDir>/api-keys.json — the plaintext is
|
|
6
|
+
* shown to the user exactly once at creation and is never recoverable from disk.
|
|
7
|
+
* Verification is constant-time. Minting/revoking is gated by the admin password
|
|
8
|
+
* at the route layer.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync, statSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
|
|
14
|
+
import { getDataDir } from './dirs';
|
|
15
|
+
|
|
16
|
+
const KEY_PREFIX = 'forge_sk_';
|
|
17
|
+
|
|
18
|
+
export interface ApiKeyRecord {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
prefix: string; // non-secret display prefix, e.g. "forge_sk_a1b2c3…"
|
|
22
|
+
hash: string; // sha256(full key)
|
|
23
|
+
createdAt: string;
|
|
24
|
+
lastUsedAt: string | null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Public view — never includes the hash. */
|
|
28
|
+
export type ApiKeyPublic = Omit<ApiKeyRecord, 'hash'>;
|
|
29
|
+
|
|
30
|
+
function file(): string {
|
|
31
|
+
return join(getDataDir(), 'api-keys.json');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Parsed-records cache keyed by file mtime. verifyApiKey() runs on every /api/mcp
|
|
35
|
+
// POST, so re-reading + JSON.parse per call is wasted I/O. Any write (this process
|
|
36
|
+
// via persist(), or another worker) changes mtime, so the cache self-invalidates.
|
|
37
|
+
let _cache: { mtimeMs: number; keys: ApiKeyRecord[] } | null = null;
|
|
38
|
+
|
|
39
|
+
function load(): ApiKeyRecord[] {
|
|
40
|
+
try {
|
|
41
|
+
const fp = file();
|
|
42
|
+
if (!existsSync(fp)) { _cache = null; return []; }
|
|
43
|
+
const mtimeMs = statSync(fp).mtimeMs;
|
|
44
|
+
if (_cache && _cache.mtimeMs === mtimeMs) return _cache.keys;
|
|
45
|
+
const parsed = JSON.parse(readFileSync(fp, 'utf-8'));
|
|
46
|
+
const keys = Array.isArray(parsed) ? parsed : [];
|
|
47
|
+
_cache = { mtimeMs, keys };
|
|
48
|
+
return keys;
|
|
49
|
+
} catch {
|
|
50
|
+
return [];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function persist(keys: ApiKeyRecord[]): void {
|
|
55
|
+
const dir = getDataDir();
|
|
56
|
+
mkdirSync(dir, { recursive: true });
|
|
57
|
+
// Atomic write: serialize to a fresh 0600 temp file in the same dir, then
|
|
58
|
+
// rename over the target. A crash or a concurrent reader never sees a
|
|
59
|
+
// half-written api-keys.json, and the rename is atomic on the same filesystem.
|
|
60
|
+
const tmp = join(dir, `.api-keys.${randomBytes(6).toString('hex')}.tmp`);
|
|
61
|
+
try {
|
|
62
|
+
writeFileSync(tmp, JSON.stringify(keys, null, 2), { mode: 0o600 });
|
|
63
|
+
renameSync(tmp, file());
|
|
64
|
+
_cache = null; // invalidate; next load() re-reads at the new mtime
|
|
65
|
+
} catch (e) {
|
|
66
|
+
try { rmSync(tmp, { force: true }); } catch { /* leave nothing behind */ }
|
|
67
|
+
throw e;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function sha256(s: string): string {
|
|
72
|
+
return createHash('sha256').update(s).digest('hex');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const toPublic = (r: ApiKeyRecord): ApiKeyPublic => ({
|
|
76
|
+
id: r.id, name: r.name, prefix: r.prefix, createdAt: r.createdAt, lastUsedAt: r.lastUsedAt,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
/** Create a new key. Returns the plaintext ONCE (caller must show + discard).
|
|
80
|
+
*
|
|
81
|
+
* createApiKey/revokeApiKey load()→persist() the whole array, loading immediately
|
|
82
|
+
* before the write (same minimal in-process window as touchLastUsed). They do NOT
|
|
83
|
+
* take a file lock, so two WORKERS mutating concurrently still last-writer-win and
|
|
84
|
+
* one change is dropped. Acceptable here because minting/revoking is admin-gated and
|
|
85
|
+
* rare; if that stops holding, wrap the read-modify-write below in a short lock. */
|
|
86
|
+
export function createApiKey(name: string): { key: string; record: ApiKeyPublic } {
|
|
87
|
+
const key = `${KEY_PREFIX}${randomBytes(24).toString('base64url')}`;
|
|
88
|
+
const record: ApiKeyRecord = {
|
|
89
|
+
id: randomBytes(6).toString('hex'),
|
|
90
|
+
name: name.trim() || 'agent key',
|
|
91
|
+
prefix: `${key.slice(0, KEY_PREFIX.length + 6)}…`,
|
|
92
|
+
hash: sha256(key),
|
|
93
|
+
createdAt: new Date().toISOString(),
|
|
94
|
+
lastUsedAt: null,
|
|
95
|
+
};
|
|
96
|
+
const keys = load();
|
|
97
|
+
keys.push(record);
|
|
98
|
+
persist(keys);
|
|
99
|
+
return { key, record: toPublic(record) };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function listApiKeys(): ApiKeyPublic[] {
|
|
103
|
+
return load().map(toPublic);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function revokeApiKey(id: string): boolean {
|
|
107
|
+
const keys = load();
|
|
108
|
+
const idx = keys.findIndex((k) => k.id === id);
|
|
109
|
+
if (idx < 0) return false;
|
|
110
|
+
keys.splice(idx, 1);
|
|
111
|
+
persist(keys);
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Bump one key's lastUsedAt by id, re-loading the freshest file immediately
|
|
116
|
+
* before the write. verifyApiKey must NOT persist the array it read before the
|
|
117
|
+
* hash compare: a concurrent createApiKey/revokeApiKey in another worker would
|
|
118
|
+
* be clobbered (last-writer-wins on the whole file). Re-loading here shrinks the
|
|
119
|
+
* window to the load→write span and, if the key was revoked meanwhile, leaves it
|
|
120
|
+
* deleted (find returns undefined → no write) instead of resurrecting it. */
|
|
121
|
+
function touchLastUsed(id: string, whenISO: string): void {
|
|
122
|
+
const keys = load();
|
|
123
|
+
const k = keys.find((r) => r.id === id);
|
|
124
|
+
if (!k) return;
|
|
125
|
+
k.lastUsedAt = whenISO;
|
|
126
|
+
persist(keys);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Constant-time verification of a presented key. Bumps lastUsedAt (throttled). */
|
|
130
|
+
export function verifyApiKey(presented: string | null | undefined): boolean {
|
|
131
|
+
if (!presented || !presented.startsWith(KEY_PREFIX)) return false;
|
|
132
|
+
const presentedHash = Buffer.from(sha256(presented));
|
|
133
|
+
const keys = load();
|
|
134
|
+
let matched: ApiKeyRecord | undefined;
|
|
135
|
+
for (const k of keys) {
|
|
136
|
+
const stored = Buffer.from(k.hash);
|
|
137
|
+
if (stored.length === presentedHash.length && timingSafeEqual(stored, presentedHash)) {
|
|
138
|
+
matched = k;
|
|
139
|
+
break;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!matched) return false;
|
|
143
|
+
// Throttle the lastUsedAt write to at most once a minute to avoid disk churn;
|
|
144
|
+
// write through a fresh re-load (touchLastUsed) so a concurrent mint/revoke
|
|
145
|
+
// isn't lost.
|
|
146
|
+
const now = Date.now();
|
|
147
|
+
// A corrupt (unparseable) lastUsedAt yields NaN; treat it as stale so the bad
|
|
148
|
+
// value self-heals on the next use instead of freezing forever.
|
|
149
|
+
const last = matched.lastUsedAt ? new Date(matched.lastUsedAt).getTime() : NaN;
|
|
150
|
+
if (!Number.isFinite(last) || now - last > 60_000) {
|
|
151
|
+
// Bookkeeping only — a failed lastUsedAt write (e.g. api-keys.json owned by a
|
|
152
|
+
// different user after a local `forge mcp install`) must never turn a valid
|
|
153
|
+
// key into an auth error.
|
|
154
|
+
try { touchLastUsed(matched.id, new Date(now).toISOString()); } catch { /* ignore */ }
|
|
155
|
+
}
|
|
156
|
+
return true;
|
|
157
|
+
}
|
package/lib/jobs/store.ts
CHANGED
|
@@ -313,6 +313,31 @@ export function setNextRunAt(id: string, nextRunAt: string | null): void {
|
|
|
313
313
|
db().prepare("UPDATE jobs SET next_run_at = ?, last_run_at = datetime('now') WHERE id = ?").run(nextRunAt, id);
|
|
314
314
|
}
|
|
315
315
|
|
|
316
|
+
/** Recompute next_run_at from the job's trigger without touching last_run_at.
|
|
317
|
+
* Use on resume: cancelJobDrain nulled next_run_at, and NULL = due now, so
|
|
318
|
+
* a bare re-enable would fire off-schedule. once-in-the-past fires next tick;
|
|
319
|
+
* period seeds now + interval to avoid an instant re-run. */
|
|
320
|
+
export function seedNextRunAt(id: string): void {
|
|
321
|
+
ensureSchema();
|
|
322
|
+
const j = getJob(id);
|
|
323
|
+
if (!j) return;
|
|
324
|
+
const fmt = (d: Date) => d.toISOString().replace('T', ' ').slice(0, 19);
|
|
325
|
+
let next: string | null = null;
|
|
326
|
+
if (j.schedule_kind === 'once' && j.schedule_at) {
|
|
327
|
+
const t = new Date(j.schedule_at);
|
|
328
|
+
if (!Number.isNaN(t.getTime())) next = fmt(t);
|
|
329
|
+
} else if (j.schedule_kind === 'cron' && j.schedule_cron) {
|
|
330
|
+
try {
|
|
331
|
+
const iter = CronExpressionParser.parse(j.schedule_cron, { currentDate: new Date() });
|
|
332
|
+
next = fmt(iter.next().toDate());
|
|
333
|
+
} catch { next = null; }
|
|
334
|
+
} else if (j.schedule_kind === 'period') {
|
|
335
|
+
const mins = j.schedule_interval_minutes > 0 ? j.schedule_interval_minutes : 30;
|
|
336
|
+
next = fmt(new Date(Date.now() + mins * 60_000));
|
|
337
|
+
}
|
|
338
|
+
db().prepare('UPDATE jobs SET next_run_at = ? WHERE id = ?').run(next, id);
|
|
339
|
+
}
|
|
340
|
+
|
|
316
341
|
/** Jobs due to run: enabled AND (next_run_at IS NULL OR next_run_at <= now). */
|
|
317
342
|
export function getDueJobs(): Job[] {
|
|
318
343
|
ensureSchema();
|
package/lib/projects.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readdirSync, existsSync, statSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
2
|
-
import { join } from 'node:path';
|
|
2
|
+
import { join, resolve, isAbsolute, parse } from 'node:path';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { loadSettings } from './settings';
|
|
4
|
+
import { loadSettings, saveSettings } from './settings';
|
|
5
5
|
import { getDataDir } from './dirs';
|
|
6
6
|
|
|
7
7
|
export interface LocalProject {
|
|
@@ -75,11 +75,22 @@ function scratchProject(): LocalProject {
|
|
|
75
75
|
};
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
+
let scanCache: { at: number; key: string; result: LocalProject[] } | null = null;
|
|
79
|
+
const SCAN_TTL_MS = 5_000;
|
|
80
|
+
|
|
81
|
+
/** Drop the scanProjects cache (call after registering/unregistering a root). */
|
|
82
|
+
export function invalidateProjectScan(): void { scanCache = null; }
|
|
83
|
+
|
|
78
84
|
export function scanProjects(): LocalProject[] {
|
|
79
85
|
const settings = loadSettings();
|
|
80
|
-
const roots = settings.projectRoots;
|
|
86
|
+
const roots = settings.projectRoots || [];
|
|
87
|
+
const cacheKey = roots.join('\n');
|
|
88
|
+
const now = Date.now();
|
|
89
|
+
if (scanCache && scanCache.key === cacheKey && now - scanCache.at < SCAN_TTL_MS) {
|
|
90
|
+
return scanCache.result;
|
|
91
|
+
}
|
|
81
92
|
|
|
82
|
-
if (roots.length === 0) return [];
|
|
93
|
+
if (roots.length === 0) { scanCache = { at: now, key: cacheKey, result: [] }; return []; }
|
|
83
94
|
|
|
84
95
|
const projects: LocalProject[] = [];
|
|
85
96
|
|
|
@@ -118,7 +129,9 @@ export function scanProjects(): LocalProject[] {
|
|
|
118
129
|
// Prepend the synthetic scratch project so the UI surfaces it as a
|
|
119
130
|
// first-class option. It's stamped with the current date so it tends
|
|
120
131
|
// to sort to the top — that's intentional: it's the default fallback.
|
|
121
|
-
|
|
132
|
+
const result = [scratchProject(), ...projects.sort((a, b) => b.lastModified.localeCompare(a.lastModified))];
|
|
133
|
+
scanCache = { at: now, key: cacheKey, result };
|
|
134
|
+
return result;
|
|
122
135
|
}
|
|
123
136
|
|
|
124
137
|
function detectLanguage(projectPath: string): string | null {
|
|
@@ -234,3 +247,64 @@ export function getProjectClaudeMd(projectPath: string): string | null {
|
|
|
234
247
|
if (!existsSync(claudeMdPath)) return null;
|
|
235
248
|
return readFileSync(claudeMdPath, 'utf-8');
|
|
236
249
|
}
|
|
250
|
+
|
|
251
|
+
/** Register a project root — a directory whose immediate subdirectories Forge
|
|
252
|
+
* lists as projects (see scanProjects). Idempotent; validates the path is an
|
|
253
|
+
* existing directory. Returns the normalized root and whether it was already
|
|
254
|
+
* registered. */
|
|
255
|
+
const MAX_ROOT_SUBDIRS = 200;
|
|
256
|
+
|
|
257
|
+
export function addProjectRoot(rootPath: string): {
|
|
258
|
+
ok: boolean; root: string; error?: string; alreadyRegistered?: boolean;
|
|
259
|
+
} {
|
|
260
|
+
const trimmed = (rootPath || '').trim();
|
|
261
|
+
if (!trimmed) return { ok: false, root: '', error: 'Path is required' };
|
|
262
|
+
// Require an absolute path: resolve('') is process.cwd() and a relative path
|
|
263
|
+
// resolves against the server's cwd — neither is a project root the caller meant.
|
|
264
|
+
if (!isAbsolute(trimmed)) return { ok: false, root: trimmed, error: 'Path must be absolute' };
|
|
265
|
+
const root = resolve(trimmed);
|
|
266
|
+
if (!existsSync(root)) return { ok: false, root, error: 'Path does not exist' };
|
|
267
|
+
try {
|
|
268
|
+
if (!statSync(root).isDirectory()) return { ok: false, root, error: 'Path is not a directory' };
|
|
269
|
+
} catch {
|
|
270
|
+
return { ok: false, root, error: 'Path is not accessible' };
|
|
271
|
+
}
|
|
272
|
+
// A root is meant to CONTAIN projects, not be a giant tree. Refuse the
|
|
273
|
+
// filesystem root and absurdly broad dirs so scanProjects stays cheap.
|
|
274
|
+
if (root === parse(root).root) {
|
|
275
|
+
return { ok: false, root, error: 'Refusing to register the filesystem root' };
|
|
276
|
+
}
|
|
277
|
+
let subdirs = 0;
|
|
278
|
+
try {
|
|
279
|
+
for (const e of readdirSync(root, { withFileTypes: true })) {
|
|
280
|
+
if (e.isDirectory() && !e.name.startsWith('.') && ++subdirs > MAX_ROOT_SUBDIRS) break;
|
|
281
|
+
}
|
|
282
|
+
} catch {
|
|
283
|
+
return { ok: false, root, error: 'Path is not accessible' };
|
|
284
|
+
}
|
|
285
|
+
if (subdirs > MAX_ROOT_SUBDIRS) {
|
|
286
|
+
return { ok: false, root, error: `Too many subdirectories (>${MAX_ROOT_SUBDIRS}); point at the folder that directly contains your repos` };
|
|
287
|
+
}
|
|
288
|
+
const settings = loadSettings();
|
|
289
|
+
const roots = settings.projectRoots || [];
|
|
290
|
+
if (roots.includes(root)) return { ok: true, root, alreadyRegistered: true };
|
|
291
|
+
settings.projectRoots = [...roots, root];
|
|
292
|
+
saveSettings(settings);
|
|
293
|
+
invalidateProjectScan();
|
|
294
|
+
return { ok: true, root };
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Unregister a project root. Does not touch files — only stops scanProjects
|
|
298
|
+
* from listing its subdirectories as projects. */
|
|
299
|
+
export function removeProjectRoot(rootPath: string): { ok: boolean; root: string; error?: string } {
|
|
300
|
+
const root = resolve((rootPath || '').trim());
|
|
301
|
+
const settings = loadSettings();
|
|
302
|
+
const roots = settings.projectRoots || [];
|
|
303
|
+
if (!roots.includes(root)) {
|
|
304
|
+
return { ok: false, root, error: `Not a registered root. Registered: ${roots.join(', ') || 'none'}` };
|
|
305
|
+
}
|
|
306
|
+
settings.projectRoots = roots.filter((r) => r !== root);
|
|
307
|
+
saveSettings(settings);
|
|
308
|
+
invalidateProjectScan();
|
|
309
|
+
return { ok: true, root };
|
|
310
|
+
}
|
package/lib/settings.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync, renameSync, rmSync } from 'node:fs';
|
|
2
2
|
import { join, dirname } from 'node:path';
|
|
3
|
+
import { randomBytes } from 'node:crypto';
|
|
3
4
|
import YAML from 'yaml';
|
|
4
5
|
import { encryptSecret, decryptSecret, isEncrypted, SECRET_FIELDS } from './crypto';
|
|
5
6
|
import { getDataDir } from './dirs';
|
|
@@ -423,7 +424,16 @@ export function saveSettings(settings: Settings) {
|
|
|
423
424
|
}
|
|
424
425
|
// Encrypt nested apiKeys
|
|
425
426
|
encryptNestedSecrets(toSave);
|
|
426
|
-
|
|
427
|
+
// Atomic write: serialize to a temp file in the same dir, then rename over the
|
|
428
|
+
// target so a crash or concurrent reader never sees a half-written settings.yaml.
|
|
429
|
+
const tmp = join(dir, `.settings.${randomBytes(6).toString('hex')}.tmp`);
|
|
430
|
+
try {
|
|
431
|
+
writeFileSync(tmp, YAML.stringify(toSave), 'utf-8');
|
|
432
|
+
renameSync(tmp, SETTINGS_FILE);
|
|
433
|
+
} catch (e) {
|
|
434
|
+
try { rmSync(tmp, { force: true }); } catch { /* leave nothing behind */ }
|
|
435
|
+
throw e;
|
|
436
|
+
}
|
|
427
437
|
}
|
|
428
438
|
|
|
429
439
|
/** Verify a secret field's current value */
|
package/mcp/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Forge Management MCP
|
|
2
|
+
|
|
3
|
+
Manage Forge — projects, tasks, pipelines, schedules, jobs, connectors, usage — from an
|
|
4
|
+
MCP client like Claude Code, instead of clicking through the UI. It manages Forge; it does
|
|
5
|
+
not read your project files or run coding tasks (that's the separate workspace agent MCP
|
|
6
|
+
on `:8406`).
|
|
7
|
+
|
|
8
|
+
## Add it to Claude Code
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
forge mcp install
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
This mints an API key if you don't have one and registers the server with Claude Code.
|
|
15
|
+
Run `/mcp` in a session to confirm. No admin password — running `forge` already proves
|
|
16
|
+
local access, so the key is minted directly on disk (into `FORGE_DATA_DIR`, else
|
|
17
|
+
`~/.forge/data`; set `FORGE_DATA_DIR` for a non-default instance); the running server
|
|
18
|
+
picks it up automatically (and need not even be up).
|
|
19
|
+
|
|
20
|
+
Options: `--http` (register the HTTP transport instead of the stdio proxy), `--scope user`
|
|
21
|
+
(use it in every project), `--remote https://forge.example.com` (target a remote Forge —
|
|
22
|
+
set `FORGE_API_KEY` first, since the remote's data dir isn't local), `--print` (print the
|
|
23
|
+
`claude mcp add` command instead of running it).
|
|
24
|
+
|
|
25
|
+
## What you can do
|
|
26
|
+
|
|
27
|
+
Tools cover projects, tasks, pipelines, schedules and jobs, connectors, the marketplace,
|
|
28
|
+
usage and status, log tailing, and workspace agents. Ask "what forge tools do you have?"
|
|
29
|
+
to list them.
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
Runs in-process at `/api/mcp` (Streamable HTTP), behind Forge's existing auth — same port
|
|
34
|
+
as the web UI. All logic stays in the server: tools call `lib/` in-process and the wire
|
|
35
|
+
carries named intents, never SQL. The `forge mcp` proxy (`cli/mcp-proxy.ts`) is
|
|
36
|
+
transport-only; the same `mcp/server.ts` backs both the HTTP route and the proxy.
|
|
37
|
+
|
|
38
|
+
Manual setup, if you're not using `forge mcp install`:
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
forge key create "claude-code" --save # mint + save a key (no password — local)
|
|
42
|
+
claude mcp add forge -- forge mcp # stdio proxy (reads ~/.forge/mcp-key)
|
|
43
|
+
# or, direct HTTP:
|
|
44
|
+
claude mcp add --transport http forge http://localhost:8403/api/mcp \
|
|
45
|
+
--header "Authorization: Bearer forge_sk_..."
|
|
46
|
+
```
|