@aion0/forge 0.10.40 → 0.10.41

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.
Files changed (59) hide show
  1. package/CLAUDE.md +1 -1
  2. package/RELEASE_NOTES.md +83 -5
  3. package/app/api/bridge-info/route.ts +34 -0
  4. package/app/api/connectors/[id]/test/route.ts +14 -0
  5. package/app/api/connectors/import-config-template/route.ts +103 -13
  6. package/app/api/enterprise-keys/route.ts +204 -0
  7. package/app/api/marketplace/sync-all/route.ts +28 -0
  8. package/app/api/monitor/route.ts +29 -6
  9. package/app/api/onboarding/route.ts +897 -23
  10. package/app/api/projects/clone/route.ts +51 -0
  11. package/app/api/settings/route.ts +11 -2
  12. package/bin/forge-server.mjs +98 -1
  13. package/cli/mw.mjs +16 -6
  14. package/cli/mw.ts +19 -6
  15. package/components/ConnectorsPanel.tsx +85 -13
  16. package/components/CraftTerminal.tsx +12 -3
  17. package/components/Dashboard.tsx +55 -17
  18. package/components/DocTerminal.tsx +12 -6
  19. package/components/EnterpriseBadge.tsx +420 -0
  20. package/components/LoginStatusPanel.tsx +15 -1
  21. package/components/OnboardingWizard.tsx +418 -31
  22. package/components/SettingsModal.tsx +382 -63
  23. package/components/SkillsPanel.tsx +116 -91
  24. package/components/WebTerminal.tsx +36 -13
  25. package/dev-test.sh +34 -1
  26. package/install.sh +29 -2
  27. package/lib/agents/claude-adapter.ts +18 -4
  28. package/lib/agents/index.ts +33 -4
  29. package/lib/auth/login-status.ts +14 -0
  30. package/lib/chat/agent-loop.ts +23 -1
  31. package/lib/chat/protocols/http.ts +15 -2
  32. package/lib/chat/tool-dispatcher.ts +163 -1
  33. package/lib/connectors/registry.ts +69 -4
  34. package/lib/connectors/sync.ts +536 -138
  35. package/lib/connectors/test-runner.ts +21 -3
  36. package/lib/connectors/types.ts +36 -4
  37. package/lib/connectors/wizard-template.ts +161 -0
  38. package/lib/dirs.ts +5 -0
  39. package/lib/enterprise-known.ts +34 -0
  40. package/lib/enterprise-secret.ts +87 -0
  41. package/lib/enterprise.ts +208 -0
  42. package/lib/help-docs/00-overview.md +12 -0
  43. package/lib/help-docs/01-settings.md +47 -1
  44. package/lib/help-docs/17-connectors.md +25 -22
  45. package/lib/help-docs/CLAUDE.md +1 -0
  46. package/lib/init.ts +13 -6
  47. package/lib/marketplace-sync.ts +70 -0
  48. package/lib/memory/temper-provision.ts +92 -0
  49. package/lib/pipeline-gc.ts +5 -2
  50. package/lib/pipeline.ts +26 -21
  51. package/lib/plugins/templates.ts +76 -3
  52. package/lib/projects.ts +85 -0
  53. package/lib/settings.ts +10 -0
  54. package/lib/telegram-bot.ts +14 -2
  55. package/lib/workflow-marketplace.ts +174 -108
  56. package/package.json +1 -1
  57. package/{middleware.ts → proxy.ts} +2 -1
  58. package/src/core/db/database.ts +8 -2
  59. package/templates/connector-config-template.json +0 -7
@@ -223,6 +223,12 @@ async function runBrowserProbe(
223
223
  pluginId: def.id,
224
224
  host_match: expandedHost,
225
225
  login_redirect: expandedLoginRedirect,
226
+ // Optional DOM-based auth check for sites that don't redirect to
227
+ // /login when unauthed (e.g. PMDB renders the login form inline).
228
+ // Extension v0.2.15+ runs querySelector after navigation; match →
229
+ // probe fails with "login required". Older extensions ignore it
230
+ // and fall back to URL-only checking.
231
+ auth_required_selector: def.test?.auth_required_selector || undefined,
226
232
  runner: def.runner || entry?.runner || 'main',
227
233
  timeout_ms: def.test?.timeout_ms || 30_000,
228
234
  });
@@ -243,10 +249,22 @@ async function runBrowserProbe(
243
249
  return { ok: false, error: friendly, duration_ms: Date.now() - t0 };
244
250
  }
245
251
  const r = (value || {}) as { ok?: boolean; url?: string; error?: string };
252
+ // A bare `ok: true` is not enough: an older extension navigates to
253
+ // host_match, hits a DNS / TCP failure (Chrome shows chrome-error://),
254
+ // then reports back without realising the request actually failed. We
255
+ // require the final URL to (a) exist and (b) not be a Chrome error
256
+ // page before treating the probe as successful.
257
+ const failedNetwork = !r.url || r.url.startsWith('chrome-error://') || r.url.startsWith('about:');
258
+ const succeeded = !!r.ok && !failedNetwork;
246
259
  return {
247
- ok: !!r.ok,
248
- message: r.ok ? `Session active${r.url ? ` · ${r.url}` : ''}` : undefined,
249
- error: r.ok ? undefined : (r.error || 'login required'),
260
+ ok: succeeded,
261
+ message: succeeded ? `Session active${r.url ? ` · ${r.url}` : ''}` : undefined,
262
+ error: succeeded
263
+ ? undefined
264
+ : (r.error
265
+ || (failedNetwork
266
+ ? `Network unreachable — extension reached ${r.url || '(no url)'}. VPN / hostname / firewall?`
267
+ : 'login required')),
250
268
  url: r.url,
251
269
  duration_ms: Date.now() - t0,
252
270
  };
@@ -456,9 +456,19 @@ export interface ConnectorTest {
456
456
  body_match_error?: string;
457
457
 
458
458
  // ── probe: 'browser' ─────────────────────────────────────
459
- // No fields required — the extension reuses the connector's
460
- // top-level host_match + login_redirect (1:1) or the first entry's
461
- // (1:N) to find the tab and detect login redirects.
459
+ // Extension reuses the connector's top-level host_match +
460
+ // login_redirect (1:1) or the first entry's (1:N) to navigate
461
+ // and detect login redirects.
462
+ /**
463
+ * CSS selector that, if present on the loaded page, indicates the
464
+ * user is NOT logged in (e.g. `form#login`, `a[href*="/sign_in"]`).
465
+ * Used by sites that render a login form inline instead of
466
+ * redirecting to `/login` — without this, the URL-based probe
467
+ * passes even when the user can't see real content. Extension v0.2.15+
468
+ * runs `document.querySelector(selector)` after navigation; non-null
469
+ * match → probe fails with "login required".
470
+ */
471
+ auth_required_selector?: string;
462
472
 
463
473
  /** Override the default timeout (http: 15s, browser: 30s). */
464
474
  timeout_ms?: number;
@@ -568,9 +578,31 @@ export interface ConnectorMarketEntry {
568
578
  compatible: boolean;
569
579
  /**
570
580
  * Where this entry originates:
571
- * 'registry' — listed in the remote registry (may also be installed)
581
+ * 'registry' — listed in a remote registry (may also be installed)
572
582
  * 'local' — installed via /api/connectors/install-local, no
573
583
  * registry counterpart (no Update path)
574
584
  */
575
585
  source: 'registry' | 'local';
586
+ /**
587
+ * Which registry source provided the winning entry. `public` = the
588
+ * default forge-connectors repo; `enterprise-<tenant>` = a private
589
+ * enterprise repo. Absent for `source: 'local'`. UI uses this for
590
+ * the source badge (e.g. `🔒 Fortinet`).
591
+ */
592
+ source_id?: string;
593
+ /**
594
+ * When `update_available` is true, this is the source that *publishes*
595
+ * the new version (the winning registry entry). Often differs from
596
+ * source_id, which reflects the *installed* manifest's origin — e.g.
597
+ * installed from public, enterprise just added with a newer version.
598
+ * UI uses this to show "Update from enterprise-fortinet" so users
599
+ * know what they're about to pull before they click.
600
+ */
601
+ update_source_id?: string;
602
+ /**
603
+ * Other sources that also list this id but were outranked. Surfaced
604
+ * in the marketplace detail view so users can see "this `mantis`
605
+ * comes from fortinet — public also has one (hidden)".
606
+ */
607
+ hidden_sources?: { source_id: string; display_name: string; version: string }[];
576
608
  }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Wizard template resolution — picks the right onboarding template
3
+ * JSON for the current Forge instance.
4
+ *
5
+ * Priority (highest wins):
6
+ *
7
+ * 1. User-uploaded override at <dataDir>/config-template.json — admin
8
+ * explicitly pasted a custom template via the import API; that
9
+ * always wins.
10
+ * 2. Enterprise sources, in configured priority order. Each enterprise
11
+ * registry.json may declare a `wizard_template` path; sync caches
12
+ * the resolved JSON alongside the registry (P1.W3). First enterprise
13
+ * source that has a cached template wins.
14
+ * 3. Public source. forge-connectors registry.json may declare a public
15
+ * default (P1.W6 seeds this).
16
+ * 4. Bundled fallback shipped inside Forge main (templates/
17
+ * connector-config-template.json). Callers JSON-import that
18
+ * directly — this module deliberately doesn't, so the bundled file
19
+ * stays a passive last-line-of-defence the caller owns.
20
+ *
21
+ * The returned `source` tag lets callers display a "using template from
22
+ * Fortinet" affordance in the wizard, or log which tier resolved.
23
+ *
24
+ * No network — every read is from a per-source cache file the sync
25
+ * layer wrote on its last successful pull. Returns `null` for tiers 1–3
26
+ * when nothing is cached, leaving the caller free to fall through to
27
+ * its bundled default.
28
+ */
29
+
30
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
31
+ import { join } from 'node:path';
32
+ import { getDataDir } from '../dirs';
33
+ import { listEnterpriseSourceMetas } from './sync';
34
+
35
+ export interface ResolvedWizardTemplate {
36
+ /** `'user' | 'enterprise-<tenant_id>' | 'public'`. `'bundled'` is the caller's responsibility. */
37
+ source: string;
38
+ template: unknown;
39
+ }
40
+
41
+ function userOverridePath(): string {
42
+ return join(getDataDir(), 'config-template.json');
43
+ }
44
+
45
+ function sourceTemplatePath(sourceId: string): string {
46
+ return join(getDataDir(), 'connectors', 'sources', sourceId, 'wizard-template.json');
47
+ }
48
+
49
+ function sourceDeptPath(sourceId: string, deptId: string): string {
50
+ return join(getDataDir(), 'connectors', 'sources', sourceId, 'wizards', `${deptId}.json`);
51
+ }
52
+
53
+ function sourceDeptIndexFile(sourceId: string): string {
54
+ return join(getDataDir(), 'connectors', 'sources', sourceId, 'wizards', '_index.json');
55
+ }
56
+
57
+ export interface DeptInfo {
58
+ id: string;
59
+ display_name: string;
60
+ /** False when the dept exists in the registry without a `path` — the
61
+ * user can still pick it as their department, but template
62
+ * customization falls through to public/bundled defaults. */
63
+ has_template: boolean;
64
+ }
65
+
66
+ /**
67
+ * Return the list of department templates cached for a source, or [] if
68
+ * the source has only a single (non-dept-aware) template. The index is
69
+ * written by sync.ts when the source's registry advertises
70
+ * `wizard_templates: [...]`.
71
+ */
72
+ export function listSourceDepartments(sourceId: string): DeptInfo[] {
73
+ const idx = readJsonOrNull(sourceDeptIndexFile(sourceId));
74
+ if (Array.isArray(idx)) {
75
+ return idx
76
+ .filter((d: any) => d && typeof d.id === 'string')
77
+ .map((d: any) => ({
78
+ id: d.id,
79
+ display_name: d.display_name || d.id,
80
+ // Legacy index entries (pre template-less) had no `has_template`
81
+ // — assume true so existing caches keep behaving as before.
82
+ has_template: d.has_template !== false,
83
+ }));
84
+ }
85
+ return [];
86
+ }
87
+
88
+ function readJsonOrNull(path: string): unknown | null {
89
+ if (!existsSync(path)) return null;
90
+ try {
91
+ return JSON.parse(readFileSync(path, 'utf-8'));
92
+ } catch {
93
+ return null;
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Resolve the wizard template across all priority tiers. Returns
99
+ * `null` if no tier above the bundled fallback has anything — callers
100
+ * are expected to drop to their statically-imported bundled JSON in
101
+ * that case.
102
+ *
103
+ * When `sourceId` is given, bypass the priority chain and read ONLY
104
+ * that source's cached template. Used by the per-tenant wizard flow
105
+ * (E2): the UI passes `?source_id=enterprise-fortinet` to scope to a
106
+ * specific tenant instead of resolving across all of them. Returns
107
+ * `null` if the named source has no cached template (caller decides
108
+ * whether to fall back or surface a "not synced yet" error).
109
+ */
110
+ export function resolveWizardTemplate(sourceId?: string, deptId?: string): ResolvedWizardTemplate | null {
111
+ if (sourceId) {
112
+ if (sourceId === 'user') {
113
+ const user = readJsonOrNull(userOverridePath());
114
+ return user ? { source: 'user', template: user } : null;
115
+ }
116
+ // E3: dept-scoped lookup. When `deptId` is given, read the per-dept
117
+ // file. When omitted, prefer the dept index's first entry (so
118
+ // sources that ship only dept templates still resolve a sensible
119
+ // default), then fall back to the legacy single template.
120
+ if (deptId) {
121
+ const t = readJsonOrNull(sourceDeptPath(sourceId, deptId));
122
+ return t ? { source: sourceId, template: t } : null;
123
+ }
124
+ const depts = listSourceDepartments(sourceId);
125
+ if (depts.length > 0) {
126
+ const first = readJsonOrNull(sourceDeptPath(sourceId, depts[0].id));
127
+ if (first) return { source: sourceId, template: first };
128
+ }
129
+ const t = readJsonOrNull(sourceTemplatePath(sourceId));
130
+ return t ? { source: sourceId, template: t } : null;
131
+ }
132
+
133
+ // Tier 1 — user override
134
+ const user = readJsonOrNull(userOverridePath());
135
+ if (user) return { source: 'user', template: user };
136
+
137
+ // Tier 2 — enterprise sources, priority order. Each source may ship
138
+ // either the legacy single wizard-template.json or the E3 multi-dept
139
+ // wizards/ tree; both shapes count as "has a template" for this tier.
140
+ for (const source of listEnterpriseSourceMetas()) {
141
+ const depts = listSourceDepartments(source.id);
142
+ if (depts.length > 0) {
143
+ const first = readJsonOrNull(sourceDeptPath(source.id, depts[0].id));
144
+ if (first) return { source: source.id, template: first };
145
+ }
146
+ const t = readJsonOrNull(sourceTemplatePath(source.id));
147
+ if (t) return { source: source.id, template: t };
148
+ }
149
+
150
+ // Tier 3 — public source cache (same dual-shape lookup).
151
+ const publicDepts = listSourceDepartments('public');
152
+ if (publicDepts.length > 0) {
153
+ const first = readJsonOrNull(sourceDeptPath('public', publicDepts[0].id));
154
+ if (first) return { source: 'public', template: first };
155
+ }
156
+ const publicT = readJsonOrNull(sourceTemplatePath('public'));
157
+ if (publicT) return { source: 'public', template: publicT };
158
+
159
+ // Tier 4 — caller falls back to bundled
160
+ return null;
161
+ }
package/lib/dirs.ts CHANGED
@@ -85,6 +85,11 @@ export function migrateDataDir(): void {
85
85
  console.log('[forge] Migration complete. Old files kept as backup.');
86
86
  }
87
87
 
88
+ /** Encrypted enterprise keys file — array of { tenant_id, repo_url, github_pat } */
89
+ export function getEnterpriseKeysPath(): string {
90
+ return join(getDataDir(), '.enterprise-keys.json');
91
+ }
92
+
88
93
  /** Claude config directory — skills, commands, sessions, etc. Override
89
94
  * via the `CLAUDE_HOME` env var (same name claude CLI uses); defaults
90
95
  * to `~/.claude`. The settings.yaml `claudeHome` field was removed in
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Hard-coded mapping: tenant_id → enterprise repo URL.
3
+ *
4
+ * Short enterprise keys look like `<tenant_id>:<github_pat>` (e.g.
5
+ * `fortinet:github_pat_xxx`). The tenant_id is the lowercase company
6
+ * name itself — same string is used as the symmetric key for any
7
+ * `ent:…` secrets the template ships (see lib/enterprise-secret.ts).
8
+ * Keeping one name means there's nothing for users to look up.
9
+ *
10
+ * Long form (`<github_pat>@<repo_url>`) bypasses this table and is the
11
+ * escape hatch for ad-hoc / unregistered tenants.
12
+ *
13
+ * Adding a new tenant requires bumping Forge — that is intentional: it
14
+ * keeps the list of "blessed" enterprise repos small and reviewable.
15
+ * A future resolver service (design §1) can replace this if needed.
16
+ */
17
+
18
+ export interface KnownTenant {
19
+ tenant_id: string;
20
+ display_name: string;
21
+ repo_url: string;
22
+ }
23
+
24
+ export const KNOWN_TENANTS: Record<string, KnownTenant> = {
25
+ fortinet: {
26
+ tenant_id: 'fortinet',
27
+ display_name: 'Fortinet',
28
+ repo_url: 'github.com/aiwatching/forge-enterprise-agent-fortinet',
29
+ },
30
+ };
31
+
32
+ export function lookupTenant(tenant_id: string): KnownTenant | null {
33
+ return KNOWN_TENANTS[tenant_id.toLowerCase()] || null;
34
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Enterprise secret obfuscation — light symmetric crypto for tokens
3
+ * that need to live in an enterprise repo's wizard template (which
4
+ * ships through git) without sitting in plaintext for casual scrapers.
5
+ *
6
+ * This is OBFUSCATION, not real security. The key is derived purely
7
+ * from the enterprise name (`fortinet`, `acme`, …), so anyone with
8
+ * the algorithm and the company name can decrypt — but a clone of
9
+ * the repo on its own gives nothing useful.
10
+ *
11
+ * Threat model:
12
+ * ✓ Github web-scraper / search bot grabs the repo. They see
13
+ * `ent:…` blobs; without knowing the algo + tenant name, the
14
+ * token doesn't fall out.
15
+ * ✓ Casual leak (someone posts the template content on Slack).
16
+ * Plaintext token doesn't appear in the paste.
17
+ * ✗ Determined attacker who knows this is Fortinet's repo and runs
18
+ * the decryption with key='fortinet'. They get the token.
19
+ *
20
+ * For real secrets that must survive a determined attacker, prompt
21
+ * the user instead (`_prompts` block) and let Forge encrypt locally
22
+ * with the per-instance `.encrypt-key`.
23
+ *
24
+ * Format: `ent:<iv-base64>.<tag-base64>.<ciphertext-base64>`
25
+ * Algorithm: AES-256-GCM, key = SHA-256(enterpriseName.toLowerCase().trim())
26
+ */
27
+
28
+ import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
29
+
30
+ const PREFIX = 'ent:';
31
+
32
+ function deriveKey(enterpriseName: string): Buffer {
33
+ return createHash('sha256').update(enterpriseName.toLowerCase().trim()).digest();
34
+ }
35
+
36
+ export function isEnterpriseSecret(value: unknown): value is string {
37
+ return typeof value === 'string' && value.startsWith(PREFIX);
38
+ }
39
+
40
+ export function encryptForEnterprise(plaintext: string, enterpriseName: string): string {
41
+ if (!plaintext) return '';
42
+ const key = deriveKey(enterpriseName);
43
+ const iv = randomBytes(12);
44
+ const cipher = createCipheriv('aes-256-gcm', key, iv);
45
+ const enc = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
46
+ const tag = cipher.getAuthTag();
47
+ return `${PREFIX}${iv.toString('base64')}.${tag.toString('base64')}.${enc.toString('base64')}`;
48
+ }
49
+
50
+ export function decryptForEnterprise(blob: string, enterpriseName: string): string {
51
+ if (!isEnterpriseSecret(blob)) return blob;
52
+ try {
53
+ const payload = blob.slice(PREFIX.length);
54
+ const [ivB64, tagB64, encB64] = payload.split('.');
55
+ if (!ivB64 || !tagB64 || !encB64) return '';
56
+ const key = deriveKey(enterpriseName);
57
+ const iv = Buffer.from(ivB64, 'base64');
58
+ const tag = Buffer.from(tagB64, 'base64');
59
+ const data = Buffer.from(encB64, 'base64');
60
+ const decipher = createDecipheriv('aes-256-gcm', key, iv);
61
+ decipher.setAuthTag(tag);
62
+ return Buffer.concat([decipher.update(data), decipher.final()]).toString('utf-8');
63
+ } catch {
64
+ // Wrong key → auth tag mismatch → throws. Treat as "couldn't decrypt"
65
+ // and return empty so callers don't silently use a garbage value.
66
+ return '';
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Walk an arbitrary JSON value, decrypting any string that looks like
72
+ * an `ent:…` blob. Non-strings + non-blobs pass through unchanged.
73
+ * Useful for the wizard-import path — apply this to the resolved
74
+ * template before merging into settings.
75
+ */
76
+ export function decryptDeep(value: unknown, enterpriseName: string): unknown {
77
+ if (isEnterpriseSecret(value)) return decryptForEnterprise(value, enterpriseName);
78
+ if (Array.isArray(value)) return value.map((v) => decryptDeep(v, enterpriseName));
79
+ if (value && typeof value === 'object') {
80
+ const out: Record<string, unknown> = {};
81
+ for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
82
+ out[k] = decryptDeep(v, enterpriseName);
83
+ }
84
+ return out;
85
+ }
86
+ return value;
87
+ }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Enterprise key parsing — turn an array of opaque key strings into a
3
+ * structured list of marketplace sources for sync / registry to consume.
4
+ *
5
+ * Two key formats:
6
+ * short: `<tenant_id>:<github_pat>` → looked up via KNOWN_TENANTS
7
+ * long: `<github_pat>@<repo_url>` → tenant_id derived from repo
8
+ *
9
+ * Pure parsing — does no I/O. Persistence lives in lib/settings.ts; HTTP
10
+ * lives in lib/connectors/sync.ts.
11
+ */
12
+
13
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
14
+ import { dirname } from 'node:path';
15
+ import { lookupTenant } from './enterprise-known';
16
+ import { encryptSecret, decryptSecret, isEncrypted } from './crypto';
17
+ import { getEnterpriseKeysPath } from './dirs';
18
+
19
+ export interface EnterpriseSource {
20
+ tenant_id: string;
21
+ display_name: string;
22
+ repo_url: string;
23
+ github_pat: string;
24
+ priority: number;
25
+ }
26
+
27
+ export interface ParseResult {
28
+ sources: EnterpriseSource[];
29
+ errors: { key_preview: string; reason: string }[];
30
+ }
31
+
32
+ const SHORT_RE = /^([a-z0-9_-]+):(.+)$/i;
33
+ const LONG_RE = /^(.+)@(github\.com\/[A-Za-z0-9_.\/-]+)$/;
34
+
35
+ function preview(key: string): string {
36
+ if (key.length <= 12) return key.slice(0, 4) + '…';
37
+ return key.slice(0, 6) + '…' + key.slice(-4);
38
+ }
39
+
40
+ export function parseKey(key: string, priority: number): EnterpriseSource | { error: string } {
41
+ const trimmed = key.trim();
42
+ if (!trimmed) return { error: 'empty' };
43
+
44
+ const longMatch = trimmed.match(LONG_RE);
45
+ if (longMatch) {
46
+ const [, pat, repo_url] = longMatch;
47
+ const tenant_id = deriveTenantFromRepo(repo_url);
48
+ return {
49
+ tenant_id,
50
+ display_name: tenant_id,
51
+ repo_url,
52
+ github_pat: pat,
53
+ priority,
54
+ };
55
+ }
56
+
57
+ const shortMatch = trimmed.match(SHORT_RE);
58
+ if (shortMatch) {
59
+ const [, tenant_id, pat] = shortMatch;
60
+ const known = lookupTenant(tenant_id);
61
+ if (!known) {
62
+ return { error: `unknown tenant '${tenant_id}' — use long form '<pat>@<repo_url>' or update KNOWN_TENANTS` };
63
+ }
64
+ return {
65
+ tenant_id: known.tenant_id,
66
+ display_name: known.display_name,
67
+ repo_url: known.repo_url,
68
+ github_pat: pat,
69
+ priority,
70
+ };
71
+ }
72
+
73
+ return { error: 'unrecognized format — expected `<tenant>:<pat>` or `<pat>@<repo_url>`' };
74
+ }
75
+
76
+ export function parseKeys(keys: string[]): ParseResult {
77
+ const sources: EnterpriseSource[] = [];
78
+ const errors: ParseResult['errors'] = [];
79
+ const seenTenants = new Set<string>();
80
+
81
+ keys.forEach((key, idx) => {
82
+ const result = parseKey(key, idx);
83
+ if ('error' in result) {
84
+ errors.push({ key_preview: preview(key), reason: result.error });
85
+ return;
86
+ }
87
+ // Defensive dedup: even if `.enterprise-keys.json` somehow has two
88
+ // entries for the same tenant (e.g. legacy pre-dedup persist), the
89
+ // marketplace still surfaces only one source. Earlier index wins
90
+ // — matches how priority works for non-dup tenants.
91
+ if (seenTenants.has(result.tenant_id)) return;
92
+ seenTenants.add(result.tenant_id);
93
+ sources.push(result);
94
+ });
95
+
96
+ return { sources, errors };
97
+ }
98
+
99
+ function deriveTenantFromRepo(repo_url: string): string {
100
+ const parts = repo_url.split('/');
101
+ const last = parts[parts.length - 1] || 'unknown';
102
+ const m = last.match(/forge-enterprise-agent-(.+)/);
103
+ return m ? m[1] : last;
104
+ }
105
+
106
+ // ───────────────────────── persistence ─────────────────────────
107
+ // Keys are stored as an encrypted JSON array at <dataDir>/.enterprise-keys.json.
108
+ // The on-disk shape is { v: 1, keys: ["enc:...", "enc:..."] } so we can
109
+ // rotate the encryption format later without breaking existing files.
110
+
111
+ interface KeyFile {
112
+ v: 1;
113
+ keys: string[]; // each entry is encryptSecret(rawKey)
114
+ }
115
+
116
+ function readKeyFile(): KeyFile {
117
+ const path = getEnterpriseKeysPath();
118
+ if (!existsSync(path)) return { v: 1, keys: [] };
119
+ try {
120
+ const raw = readFileSync(path, 'utf-8');
121
+ const parsed = JSON.parse(raw);
122
+ if (!parsed || !Array.isArray(parsed.keys)) return { v: 1, keys: [] };
123
+ return { v: 1, keys: parsed.keys };
124
+ } catch {
125
+ return { v: 1, keys: [] };
126
+ }
127
+ }
128
+
129
+ function writeKeyFile(file: KeyFile): void {
130
+ const path = getEnterpriseKeysPath();
131
+ const dir = dirname(path);
132
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
133
+ writeFileSync(path, JSON.stringify(file, null, 2), { mode: 0o600 });
134
+ }
135
+
136
+ /** Return raw key strings (decrypted) in priority order. */
137
+ export function getEnterpriseKeys(): string[] {
138
+ const file = readKeyFile();
139
+ return file.keys.map(k => isEncrypted(k) ? decryptSecret(k) : k).filter(Boolean);
140
+ }
141
+
142
+ /** Replace the full key list. Order = priority (index 0 = highest). */
143
+ export function setEnterpriseKeys(rawKeys: string[]): void {
144
+ const file: KeyFile = {
145
+ v: 1,
146
+ keys: rawKeys.filter(k => k && k.trim()).map(k => encryptSecret(k.trim())),
147
+ };
148
+ writeKeyFile(file);
149
+ }
150
+
151
+ /** Append a key (returns false if a key with the same tenant_id already exists). */
152
+ export function addEnterpriseKey(rawKey: string): { ok: true; tenant_id: string } | { ok: false; reason: string } {
153
+ const parsed = parseKey(rawKey, 0);
154
+ if ('error' in parsed) return { ok: false, reason: parsed.error };
155
+
156
+ const existing = getEnterpriseKeys();
157
+ const existingSources = parseKeys(existing).sources;
158
+ if (existingSources.some(s => s.tenant_id === parsed.tenant_id)) {
159
+ return { ok: false, reason: `tenant '${parsed.tenant_id}' already configured — remove it first` };
160
+ }
161
+
162
+ setEnterpriseKeys([...existing, rawKey]);
163
+ return { ok: true, tenant_id: parsed.tenant_id };
164
+ }
165
+
166
+ /**
167
+ * Replace the key for a tenant_id with a new raw key. The new key's
168
+ * parsed tenant_id must match the target (otherwise it's an add, not an
169
+ * update — callers should remove+add explicitly).
170
+ */
171
+ export function updateEnterpriseKey(
172
+ tenant_id: string,
173
+ rawKey: string,
174
+ ): { ok: true } | { ok: false; reason: string } {
175
+ const parsed = parseKey(rawKey, 0);
176
+ if ('error' in parsed) return { ok: false, reason: parsed.error };
177
+ if (parsed.tenant_id !== tenant_id) {
178
+ return { ok: false, reason: `key resolves to tenant '${parsed.tenant_id}', not '${tenant_id}' — remove the existing key first` };
179
+ }
180
+ const existing = getEnterpriseKeys();
181
+ let found = false;
182
+ const next = existing.map(k => {
183
+ const r = parseKey(k, 0);
184
+ if ('error' in r || r.tenant_id !== tenant_id) return k;
185
+ found = true;
186
+ return rawKey;
187
+ });
188
+ if (!found) return { ok: false, reason: `no key for tenant '${tenant_id}'` };
189
+ setEnterpriseKeys(next);
190
+ return { ok: true };
191
+ }
192
+
193
+ /** Remove all keys for a tenant_id. Returns true if anything was removed. */
194
+ export function removeEnterpriseKey(tenant_id: string): boolean {
195
+ const existing = getEnterpriseKeys();
196
+ const filtered = existing.filter(k => {
197
+ const r = parseKey(k, 0);
198
+ return 'error' in r ? true : r.tenant_id !== tenant_id;
199
+ });
200
+ if (filtered.length === existing.length) return false;
201
+ setEnterpriseKeys(filtered);
202
+ return true;
203
+ }
204
+
205
+ /** Convenience: parsed + ordered source list for sync / registry to consume. */
206
+ export function listEnterpriseSources(): EnterpriseSource[] {
207
+ return parseKeys(getEnterpriseKeys()).sources;
208
+ }
@@ -30,6 +30,18 @@ forge server start
30
30
 
31
31
  Open `http://localhost:8403`. First launch prompts you to set an admin password.
32
32
 
33
+ ### Optional — enterprise marketplace at install time
34
+
35
+ Forge users inside a company that publishes its own connectors / pipelines through a private GitHub repo (an "enterprise marketplace") can register the key at install:
36
+
37
+ ```bash
38
+ ./install.sh --enterprise-key=<tenant>:<github_pat>
39
+ # or multiple
40
+ ./install.sh --enterprise-key=fortinet:pat_xxx --enterprise-key=acme:pat_yyy
41
+ ```
42
+
43
+ Equivalently, do it any time afterwards via `forge --add-enterprise-key --enterprise-key=...`, or paste the key into Forge chat ("here's our enterprise key: …"). See `01-settings.md` for the full picture.
44
+
33
45
  ## Requirements
34
46
  - Node.js >= 20
35
47
  - tmux (`brew install tmux` on macOS)
@@ -196,7 +196,52 @@ Provider → adapter mapping for chat:
196
196
 
197
197
  ## Encrypted Fields
198
198
 
199
- `telegramBotToken` and `telegramTunnelPassword` are encrypted with AES-256-GCM. Agent profile `apiKey` fields are also encrypted. The encryption key is stored at `~/.forge/data/.encrypt-key`.
199
+ `telegramBotToken` and `telegramTunnelPassword` are encrypted with AES-256-GCM. Agent profile `apiKey` fields are also encrypted. Enterprise marketplace keys (see below) live in `<dataDir>/.enterprise-keys.json` and are encrypted with the same key. The encryption key is stored at `~/.forge/data/.encrypt-key`.
200
+
201
+ ## Marketplace Providers (enterprise keys)
202
+
203
+ Forge's connector + workflow marketplaces are layered. The default `forge-connectors` / `forge-workflow` repos are everyone's baseline; on top of those you can register one or more **enterprise repos** that override matching ids with company-specific versions and add private ones. This is opt-in — without any keys, you see only the public catalog.
204
+
205
+ ### Key formats
206
+
207
+ - **Short** — `<tenant_id>:<github_pat>` (e.g. `fortinet:github_pat_xxxxx`). The tenant short code is resolved to a known enterprise repo URL via `lib/enterprise-known.ts`.
208
+ - **Long** — `<github_pat>@<repo_url>` (e.g. `github_pat_xxxxx@github.com/myorg/forge-enterprise-agent-foo`). Escape hatch for tenants not in the known list.
209
+
210
+ The PAT must be a GitHub **Fine-grained Personal Access Token** scoped to the enterprise repo with `Contents: Read`.
211
+
212
+ ### How to register
213
+
214
+ Three equivalent paths — pick whichever fits the moment:
215
+
216
+ ```bash
217
+ # A) at install time
218
+ ./install.sh --enterprise-key=fortinet:github_pat_xxxxx
219
+
220
+ # B) one-shot from the CLI (persist + exit, no server start)
221
+ forge --add-enterprise-key --enterprise-key=fortinet:github_pat_xxxxx
222
+
223
+ # C) inside Forge chat — paste the key, the assistant calls add_enterprise_key
224
+ ```
225
+
226
+ After registration:
227
+
228
+ - The connector + workflow marketplaces auto-sync once so the new source appears immediately.
229
+ - The Marketplace UI shows a `🔒 <tenant>` badge on every connector/pipeline sourced from this repo; the detail pane's "Sources" section lists the winning source plus any lower-priority sources that were outranked.
230
+ - The Dashboard top nav shows `🔒 <tenant>` so you always know which enterprise overlays are active.
231
+
232
+ ### Manage via Settings → Marketplace Providers
233
+
234
+ The Settings modal section lists every configured source with its priority + masked PAT preview. Add a new key, remove an existing one, or see what's loaded. Removing a source keeps already-installed connectors/workflows on disk (so you don't lose work) — they just stop receiving updates from that source.
235
+
236
+ ### Priority rules
237
+
238
+ - Sources are ordered by registration sequence; the first key added wins on conflict.
239
+ - For each id (e.g. `mantis`), the highest-priority source providing it is "in use"; lower sources land in `hidden_sources` (visible in the marketplace detail page).
240
+ - A `connector-config-template.json` (onboarding wizard pre-fills) is also resolved across sources — user-uploaded at `<dataDir>/config-template.json` first, then enterprise sources by priority, then public, then the Forge-bundled fallback.
241
+
242
+ ### What stays out of public
243
+
244
+ Fortinet-internal connectors (`nac`, `fortincm`, `tp`, `scap`, `pmdb`), Fortinet-flavored pipelines (`fortinet-*`), and the Fortinet wizard template live only in the private `forge-enterprise-agent-fortinet` repo. Public `forge-connectors` keeps generic versions of `gitlab` / `mantis` / `blackduck` etc. with empty `base_url` defaults.
200
245
 
201
246
  ## Settings UI
202
247
 
@@ -208,6 +253,7 @@ The Settings modal has these sections:
208
253
  | **Document Roots** | Markdown/Obsidian vault paths |
209
254
  | **Agents** | Detected CLI agents + configuration |
210
255
  | **Profiles** | Agent profiles (CLI + API) — API profiles also power Forge chat |
256
+ | **Marketplace Providers** | Enterprise marketplace keys — add/remove tenants whose private connector + pipeline + wizard repos overlay on top of public |
211
257
  | **Memory (Temper)** | Long-term memory backend for the chat agent. When Temper URL+key are blank, chat falls back to a local SQLite store with the same block/episode tools (keyword search only — no semantic/graph search). |
212
258
  | **Chat backend** | Pick the default API profile for chat (extension / CLI / Telegram) |
213
259
  | **Telegram** | Bot token, chat ID, notification toggles |