@aion0/forge 0.10.47 → 0.10.49
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 -6
- 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/SettingsModal.tsx +123 -0
- package/lib/api-auth.ts +50 -0
- package/lib/api-keys.ts +157 -0
- package/lib/auth/idp-login.ts +25 -3
- 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
|
@@ -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/auth/idp-login.ts
CHANGED
|
@@ -103,14 +103,36 @@ function readIdpBlocks(): IdpTemplateBlock[] {
|
|
|
103
103
|
return tryRead() || [];
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
/**
|
|
106
|
+
/** Pick the SP whose tab we'll open to trigger the SAML flow.
|
|
107
|
+
*
|
|
108
|
+
* Preference order:
|
|
109
|
+
* 1. A SP whose login-status cache currently shows ✗ — opening it forces
|
|
110
|
+
* a SAML redirect to the IdP, which is exactly what we want when the
|
|
111
|
+
* IdP cookie has expired even though some sibling SPs still have a
|
|
112
|
+
* valid session cookie (Mantis can be ✓ while pmdb/tp are ✗ because
|
|
113
|
+
* per-SP cookies are independent of the IdP SSO state).
|
|
114
|
+
* 2. Otherwise first installed SP with a base_url (legacy fallback).
|
|
115
|
+
*
|
|
116
|
+
* Without (1) the IdP login flow would open Mantis (first in saml_sps),
|
|
117
|
+
* see the cookie still good, declare "no form needed", and exit — leaving
|
|
118
|
+
* the broken siblings unrefreshed.
|
|
119
|
+
*/
|
|
107
120
|
function pickTriggerUrl(saml_sps: string[]): string | null {
|
|
121
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
122
|
+
const { getCachedResult } = require('./login-status') as typeof import('./login-status');
|
|
123
|
+
let fallback: string | null = null;
|
|
108
124
|
for (const id of saml_sps) {
|
|
109
125
|
const c = getInstalledConnector(id);
|
|
110
126
|
const url = (c?.config as { base_url?: string } | undefined)?.base_url;
|
|
111
|
-
if (url)
|
|
127
|
+
if (!url) continue;
|
|
128
|
+
const normalized = url.replace(/\/+$/, '/');
|
|
129
|
+
fallback ||= normalized;
|
|
130
|
+
const cached = getCachedResult(`connector:${id}`);
|
|
131
|
+
if (cached && cached.ok === false) {
|
|
132
|
+
return normalized;
|
|
133
|
+
}
|
|
112
134
|
}
|
|
113
|
-
return
|
|
135
|
+
return fallback;
|
|
114
136
|
}
|
|
115
137
|
|
|
116
138
|
async function runOneIdp(block: IdpTemplateBlock, req: IdpLoginRequest): Promise<IdpLoginEntry> {
|
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
|
+
```
|
package/mcp/server.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forge Management MCP — server factory.
|
|
3
|
+
*
|
|
4
|
+
* Transport-agnostic: this builds the McpServer and registers tools. It knows
|
|
5
|
+
* nothing about stdio vs HTTP. The Next.js route (`app/api/mcp`) hosts it over
|
|
6
|
+
* Streamable HTTP today; a hosted entrypoint can wrap the same server later.
|
|
7
|
+
*
|
|
8
|
+
* All tools run IN-PROCESS inside the Forge server and call `lib/` directly.
|
|
9
|
+
* The client never touches the database — see mcp/README.md (the contract).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
13
|
+
import { registerTools } from './tools/index';
|
|
14
|
+
|
|
15
|
+
/** Per-connection context, resolved server-side from the authenticated request. */
|
|
16
|
+
export interface ManagementContext {
|
|
17
|
+
/** Reserved for future per-key audit logging — not yet wired into tools. */
|
|
18
|
+
actor?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function createManagementMcpServer(ctx: ManagementContext = {}): McpServer {
|
|
22
|
+
const server = new McpServer({
|
|
23
|
+
name: 'forge-management',
|
|
24
|
+
version: '0.1.0',
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
registerTools(server, ctx);
|
|
28
|
+
|
|
29
|
+
return server;
|
|
30
|
+
}
|