@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.
- package/CLAUDE.md +1 -1
- package/RELEASE_NOTES.md +83 -5
- package/app/api/bridge-info/route.ts +34 -0
- package/app/api/connectors/[id]/test/route.ts +14 -0
- package/app/api/connectors/import-config-template/route.ts +103 -13
- package/app/api/enterprise-keys/route.ts +204 -0
- package/app/api/marketplace/sync-all/route.ts +28 -0
- package/app/api/monitor/route.ts +29 -6
- package/app/api/onboarding/route.ts +897 -23
- package/app/api/projects/clone/route.ts +51 -0
- package/app/api/settings/route.ts +11 -2
- package/bin/forge-server.mjs +98 -1
- package/cli/mw.mjs +16 -6
- package/cli/mw.ts +19 -6
- package/components/ConnectorsPanel.tsx +85 -13
- package/components/CraftTerminal.tsx +12 -3
- package/components/Dashboard.tsx +55 -17
- package/components/DocTerminal.tsx +12 -6
- package/components/EnterpriseBadge.tsx +420 -0
- package/components/LoginStatusPanel.tsx +15 -1
- package/components/OnboardingWizard.tsx +418 -31
- package/components/SettingsModal.tsx +382 -63
- package/components/SkillsPanel.tsx +116 -91
- package/components/WebTerminal.tsx +36 -13
- package/dev-test.sh +34 -1
- package/install.sh +29 -2
- package/lib/agents/claude-adapter.ts +18 -4
- package/lib/agents/index.ts +33 -4
- package/lib/auth/login-status.ts +14 -0
- package/lib/chat/agent-loop.ts +23 -1
- package/lib/chat/protocols/http.ts +15 -2
- package/lib/chat/tool-dispatcher.ts +163 -1
- package/lib/connectors/registry.ts +69 -4
- package/lib/connectors/sync.ts +536 -138
- package/lib/connectors/test-runner.ts +21 -3
- package/lib/connectors/types.ts +36 -4
- package/lib/connectors/wizard-template.ts +161 -0
- package/lib/dirs.ts +5 -0
- package/lib/enterprise-known.ts +34 -0
- package/lib/enterprise-secret.ts +87 -0
- package/lib/enterprise.ts +208 -0
- package/lib/help-docs/00-overview.md +12 -0
- package/lib/help-docs/01-settings.md +47 -1
- package/lib/help-docs/17-connectors.md +25 -22
- package/lib/help-docs/CLAUDE.md +1 -0
- package/lib/init.ts +13 -6
- package/lib/marketplace-sync.ts +70 -0
- package/lib/memory/temper-provision.ts +92 -0
- package/lib/pipeline-gc.ts +5 -2
- package/lib/pipeline.ts +26 -21
- package/lib/plugins/templates.ts +76 -3
- package/lib/projects.ts +85 -0
- package/lib/settings.ts +10 -0
- package/lib/telegram-bot.ts +14 -2
- package/lib/workflow-marketplace.ts +174 -108
- package/package.json +1 -1
- package/{middleware.ts → proxy.ts} +2 -1
- package/src/core/db/database.ts +8 -2
- 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:
|
|
248
|
-
message:
|
|
249
|
-
error:
|
|
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
|
};
|
package/lib/connectors/types.ts
CHANGED
|
@@ -456,9 +456,19 @@ export interface ConnectorTest {
|
|
|
456
456
|
body_match_error?: string;
|
|
457
457
|
|
|
458
458
|
// ── probe: 'browser' ─────────────────────────────────────
|
|
459
|
-
//
|
|
460
|
-
//
|
|
461
|
-
//
|
|
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
|
|
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 |
|