@aion0/forge 0.10.40 → 0.10.42

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 (61) hide show
  1. package/CLAUDE.md +1 -1
  2. package/RELEASE_NOTES.md +4 -7
  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 +920 -23
  10. package/app/api/projects/clone/route.ts +51 -0
  11. package/app/api/settings/route.ts +11 -2
  12. package/app/chat/page.tsx +8 -5
  13. package/bin/forge-server.mjs +98 -1
  14. package/cli/mw.mjs +16 -6
  15. package/cli/mw.ts +19 -6
  16. package/components/ConnectorsPanel.tsx +85 -13
  17. package/components/CraftTerminal.tsx +12 -3
  18. package/components/Dashboard.tsx +55 -17
  19. package/components/DocTerminal.tsx +12 -6
  20. package/components/EnterpriseBadge.tsx +420 -0
  21. package/components/LoginStatusPanel.tsx +15 -1
  22. package/components/OnboardingWizard.tsx +418 -31
  23. package/components/SettingsModal.tsx +382 -63
  24. package/components/SkillsPanel.tsx +116 -91
  25. package/components/WebTerminal.tsx +36 -13
  26. package/dev-test.sh +34 -1
  27. package/install.sh +29 -2
  28. package/lib/agents/claude-adapter.ts +18 -4
  29. package/lib/agents/index.ts +33 -4
  30. package/lib/auth/login-status.ts +14 -0
  31. package/lib/chat/agent-loop.ts +23 -1
  32. package/lib/chat/llm/anthropic.ts +6 -1
  33. package/lib/chat/protocols/http.ts +15 -2
  34. package/lib/chat/tool-dispatcher.ts +163 -1
  35. package/lib/connectors/registry.ts +69 -4
  36. package/lib/connectors/sync.ts +536 -138
  37. package/lib/connectors/test-runner.ts +21 -3
  38. package/lib/connectors/types.ts +36 -4
  39. package/lib/connectors/wizard-template.ts +161 -0
  40. package/lib/dirs.ts +5 -0
  41. package/lib/enterprise-known.ts +34 -0
  42. package/lib/enterprise-secret.ts +87 -0
  43. package/lib/enterprise.ts +208 -0
  44. package/lib/help-docs/00-overview.md +12 -0
  45. package/lib/help-docs/01-settings.md +47 -1
  46. package/lib/help-docs/17-connectors.md +25 -22
  47. package/lib/help-docs/CLAUDE.md +1 -0
  48. package/lib/init.ts +13 -6
  49. package/lib/marketplace-sync.ts +70 -0
  50. package/lib/memory/temper-provision.ts +92 -0
  51. package/lib/pipeline-gc.ts +5 -2
  52. package/lib/pipeline.ts +26 -21
  53. package/lib/plugins/templates.ts +76 -3
  54. package/lib/projects.ts +85 -0
  55. package/lib/settings.ts +10 -0
  56. package/lib/telegram-bot.ts +14 -2
  57. package/lib/workflow-marketplace.ts +174 -108
  58. package/package.json +1 -1
  59. package/{middleware.ts → proxy.ts} +2 -1
  60. package/src/core/db/database.ts +8 -2
  61. package/templates/connector-config-template.json +0 -7
@@ -167,7 +167,12 @@ export const anthropicAdapter: LlmAdapter = {
167
167
  const content: ContentBlock[] = [];
168
168
  let textBuf = '';
169
169
  for await (const part of result.fullStream) {
170
- if (part.type === 'text-delta') {
170
+ if (part.type === 'text-delta' || part.type === 'reasoning-delta') {
171
+ // Treat reasoning-delta the same as text-delta — Fortinet
172
+ // relay endpoints (forti-k2, forti-coder, etc.) return their
173
+ // entire response as reasoning blocks instead of regular text.
174
+ // Without this branch the model output silently disappears
175
+ // (assistant message saved with blocks=[]).
171
176
  textBuf += part.text;
172
177
  cb.onTextDelta(part.text);
173
178
  } else if (part.type === 'tool-call') {
@@ -396,6 +396,16 @@ function buildFormBody(spec: string | Record<string, unknown> | undefined, injec
396
396
  // the actual key+value choices to per-user instance config without
397
397
  // hardcoding them in the manifest. Rows with empty name or value
398
398
  // are dropped.
399
+ //
400
+ // Values pass through expandAllTokens — Jenkins' inject_params
401
+ // typically reads cross-connector refs like
402
+ // { name: TOKEN_USER, value: "{connector:gitlab.token_name}" }
403
+ // { name: TOKEN_PASSWORD, value: "{connector:gitlab.token}" }
404
+ // and we want the runtime gitlab value, not the literal string.
405
+ // Key gets the same treatment so connectors can template that too.
406
+ // Rows where either side resolves to empty (or stays an unresolved
407
+ // {settings|args}.X) get dropped — same guard as the static inject
408
+ // branch above.
399
409
  if (injectFrom) {
400
410
  let rows: any = (settings as any)[injectFrom];
401
411
  if (typeof rows === 'string') {
@@ -404,9 +414,12 @@ function buildFormBody(spec: string | Record<string, unknown> | undefined, injec
404
414
  if (Array.isArray(rows)) {
405
415
  for (const row of rows) {
406
416
  if (!row || typeof row !== 'object') continue;
407
- const k = typeof row.name === 'string' ? row.name.trim() : '';
408
- const v = typeof row.value === 'string' ? row.value : (row.value == null ? '' : String(row.value));
417
+ const rawK = typeof row.name === 'string' ? row.name : '';
418
+ const rawV = typeof row.value === 'string' ? row.value : (row.value == null ? '' : String(row.value));
419
+ const k = expandAllTokens(rawK, settings, args).trim();
420
+ const v = expandAllTokens(rawV, settings, args);
409
421
  if (!k || !v) continue;
422
+ if (/\{(settings|args|connector:)/.test(k) || /\{(settings|args|connector:)/.test(v)) continue;
410
423
  obj[k] = v;
411
424
  }
412
425
  }
@@ -19,7 +19,8 @@ import {
19
19
  getConnectorEntries,
20
20
  } from '../connectors/registry';
21
21
  import { expandSettingsTokens } from '../plugins/templates';
22
- import type { ConnectorEntry, ConnectorTool } from '../connectors/types';
22
+ import { resolveWizardTemplate } from '../connectors/wizard-template';
23
+ import type { ConnectorEntry, ConnectorTool, ConnectorDefinition } from '../connectors/types';
23
24
 
24
25
  export interface ToolCall {
25
26
  id: string;
@@ -39,6 +40,44 @@ export type BuiltinHandler = (input: unknown) => Promise<string>;
39
40
  const BUILTINS: Record<string, BuiltinHandler> = {
40
41
  get_current_time: async () => new Date().toISOString(),
41
42
 
43
+ // Register an enterprise marketplace key so subsequent connector /
44
+ // workflow lookups overlay the named tenant's private repo on top of
45
+ // the public marketplace. The user typically pastes a key string like
46
+ // `fortinet:github_pat_…` directly into chat — see lib/enterprise.ts for
47
+ // the two accepted formats. Triggers an immediate sync so the new
48
+ // source is visible without manual Refresh.
49
+ add_enterprise_key: async (input) => {
50
+ const { key } = (input as { key?: string } | undefined) || {};
51
+ if (!key || typeof key !== 'string' || !key.trim()) {
52
+ return 'Error: key is required (paste the full enterprise key string).';
53
+ }
54
+ const { addEnterpriseKey } = await import('../enterprise');
55
+ const r = addEnterpriseKey(key.trim());
56
+ if (!r.ok) return `Could not add key: ${r.reason}`;
57
+
58
+ // Best-effort sync — failures here don't undo the key add; user can
59
+ // hit Refresh in Settings later. Connector first (since enterprise
60
+ // registry.json lives in the connector cache), then workflow
61
+ // (re-reads that same cache for recipes/pipelines).
62
+ const syncDetail: string[] = [];
63
+ try {
64
+ const { syncRegistry } = await import('../connectors/sync');
65
+ const s = await syncRegistry({ refreshInstalled: false });
66
+ syncDetail.push(`connectors: ${s.ok ? `${s.registry_count} entries` : `failed (${s.error})`}`);
67
+ } catch (e) {
68
+ syncDetail.push(`connectors: failed (${(e as Error).message})`);
69
+ }
70
+ try {
71
+ const { syncMarketplace } = await import('../workflow-marketplace');
72
+ const w = await syncMarketplace();
73
+ syncDetail.push(`workflows: ${w.ok ? `${w.recipes} recipes + ${w.pipelines} pipelines` : `failed (${w.error})`}`);
74
+ } catch (e) {
75
+ syncDetail.push(`workflows: failed (${(e as Error).message})`);
76
+ }
77
+
78
+ return `✓ Added enterprise key for tenant "${r.tenant_id}". Sync: ${syncDetail.join('; ')}. The new source will surface in Settings → Marketplace Providers and in any marketplace listing.`;
79
+ },
80
+
42
81
  // Trigger a pipeline workflow defined under flows/<name>.yaml. Mirrors
43
82
  // the same MCP tool used by Claude Code tasks (forge-mcp-server.ts), but
44
83
  // available directly inside the chat agent so users can say "run the
@@ -504,6 +543,17 @@ export const BUILTIN_TOOL_DEFS: BuiltinToolDef[] = [
504
543
  description: 'Get the current local time as an ISO 8601 string. Use whenever the user asks about "now" or "today".',
505
544
  input_schema: { type: 'object', properties: {} },
506
545
  },
546
+ {
547
+ name: 'add_enterprise_key',
548
+ description: 'Register an enterprise marketplace key the user has pasted (e.g. "fortinet:github_pat_…" short form, or "github_pat_…@github.com/owner/forge-enterprise-agent-name" long form). Adds the tenant\'s private repo as a higher-priority marketplace source over public forge-connectors / forge-workflow. Call this when the user says something like "here\'s my enterprise key", "我有 fortinet 的 key 给你", or pastes a string matching either format. Triggers an immediate marketplace sync; the new source becomes visible in Settings → Marketplace Providers and in any marketplace listing. DO NOT invent or hallucinate keys — only call with the exact string the user provided.',
549
+ input_schema: {
550
+ type: 'object',
551
+ properties: {
552
+ key: { type: 'string', description: 'The enterprise key string verbatim, as pasted by the user.' },
553
+ },
554
+ required: ['key'],
555
+ },
556
+ },
507
557
  {
508
558
  name: 'trigger_pipeline',
509
559
  description: 'Trigger a Forge pipeline workflow (YAML under flows/). Two-step usage: (1) call with NO args first — returns every workflow + its input schema (which fields are required vs have defaults). (2) call again with workflow=<name> and input={...} passing ONLY required fields and any optional fields the user explicitly specified. NEVER pass invented placeholder values for optional fields with defaults — omit them and the default is used. On success returns JSON: {ok, pipeline_id, workflow, status, terminal, errors?, warning?, hint?}. If the run is still running, follow the hint — call start_watch on get_pipeline_status and STOP polling in this conversation.',
@@ -756,6 +806,80 @@ function buildConnectorPayload(
756
806
  };
757
807
  }
758
808
 
809
+ /**
810
+ * Walk the active wizard template's `_prompts` and find the one whose
811
+ * `${key}` is substituted into `<connectorId>.config.<field>`. Returns
812
+ * the prompt's url + label hint so the chat-side preflight can mirror
813
+ * the wizard's "click here to create your PAT" UX.
814
+ *
815
+ * Returns null when no template / no matching prompt. Best-effort —
816
+ * the chat error still gives the user enough to act on.
817
+ */
818
+ function lookupWizardPromptHint(
819
+ connectorId: string,
820
+ field: string,
821
+ ): { url?: string; url_label?: string; prompt_label?: string } | null {
822
+ const resolved = resolveWizardTemplate();
823
+ if (!resolved) return null;
824
+ const tmpl = resolved.template as any;
825
+ if (!tmpl || typeof tmpl !== 'object') return null;
826
+ const connectorBlock = tmpl[connectorId];
827
+ const rawVal = connectorBlock?.config?.[field];
828
+ if (typeof rawVal !== 'string') return null;
829
+ const m = rawVal.match(/^\$\{([a-zA-Z0-9_]+)\}$/);
830
+ if (!m) return null;
831
+ const promptKey = m[1];
832
+ const p = tmpl._prompts?.[promptKey];
833
+ if (!p) return null;
834
+ return {
835
+ url: typeof p.url === 'string' ? p.url : undefined,
836
+ url_label: typeof p.url_label === 'string' ? p.url_label : undefined,
837
+ prompt_label: typeof p.label === 'string' ? p.label : undefined,
838
+ };
839
+ }
840
+
841
+ /**
842
+ * Check that every `required: true` setting on the connector has a
843
+ * non-empty value in the user's installed config. Returns a chat-
844
+ * friendly error string (with PAT-creation URL when the wizard template
845
+ * declares one) when something's missing, else null.
846
+ */
847
+ function preflightConnectorSettings(
848
+ def: ConnectorDefinition,
849
+ settings: Record<string, any>,
850
+ ): string | null {
851
+ const schema = def.settings || {};
852
+ const missing: Array<{ key: string; label: string; hint: ReturnType<typeof lookupWizardPromptHint> }> = [];
853
+ for (const [key, fdef] of Object.entries(schema)) {
854
+ if (!fdef?.required) continue;
855
+ const v = settings?.[key];
856
+ const empty = v == null
857
+ || (typeof v === 'string' && v.trim() === '')
858
+ || (Array.isArray(v) && v.length === 0);
859
+ if (!empty) continue;
860
+ missing.push({
861
+ key,
862
+ label: fdef.label || key,
863
+ hint: lookupWizardPromptHint(def.id, key),
864
+ });
865
+ }
866
+ if (missing.length === 0) return null;
867
+
868
+ const lines = [
869
+ `${def.name} isn't configured yet — needs ${missing.length === 1 ? '1 field' : `${missing.length} fields`} before tools can run:`,
870
+ '',
871
+ ];
872
+ for (const m of missing) {
873
+ lines.push(`• ${m.label} (${m.key})`);
874
+ if (m.hint?.url) {
875
+ lines.push(` ${m.hint.url_label || 'Get it here'}: ${m.hint.url}`);
876
+ }
877
+ }
878
+ lines.push('');
879
+ lines.push(`Then open Settings → Connectors → ${def.name} to paste the values in.`);
880
+ return lines.join('\n');
881
+ }
882
+
759
883
  // ─── Public entry ─────────────────────────────────────────
760
884
 
761
885
  export interface DispatchOptions {
@@ -856,6 +980,44 @@ export async function dispatchTool(
856
980
  effectiveSettings = { ...located.settings, ...inst };
857
981
  }
858
982
 
983
+ // Post-overlay arg defaults — for any tool parameter the LLM omitted,
984
+ // check effectiveSettings for a `default_<paramName>` field and use
985
+ // its value. Lets connectors declare instance-level defaults (e.g.
986
+ // Jenkins `default_job_path = "fortinac-build-7.6-token"`) so users
987
+ // can say "build fortinac" without spelling out the job path every
988
+ // time. Strict: only string args, only when missing or blank, only
989
+ // when the default is non-empty.
990
+ // Stem-strip param + setting suffixes so default_project_path can fill a
991
+ // project_id arg (GitLab accepts URL-encoded paths as :id, and the wizard
992
+ // baked the user's default as `_path`). Exact match wins; stem match is
993
+ // the safety net.
994
+ const stem = (n: string) => n.replace(/_(id|path|name|key|slug)$/, '');
995
+ for (const pname of Object.keys(located.tool.parameters || {})) {
996
+ const current = argInput[pname];
997
+ if (current != null && !(typeof current === 'string' && current.trim() === '')) continue;
998
+ let fallback = (effectiveSettings as any)?.[`default_${pname}`];
999
+ if (typeof fallback !== 'string' || !fallback.trim()) {
1000
+ const want = stem(pname);
1001
+ for (const k of Object.keys(effectiveSettings || {})) {
1002
+ if (!k.startsWith('default_')) continue;
1003
+ if (stem(k.slice('default_'.length)) !== want) continue;
1004
+ const v = (effectiveSettings as any)[k];
1005
+ if (typeof v === 'string' && v.trim()) { fallback = v; break; }
1006
+ }
1007
+ }
1008
+ if (typeof fallback === 'string' && fallback.trim() !== '') {
1009
+ argInput[pname] = fallback;
1010
+ }
1011
+ }
1012
+
1013
+ // Preflight: any required setting blank? Bail with a wizard-style
1014
+ // error pointing at the credential-creation URL instead of letting
1015
+ // the call HTTP-401 (or browser-script silently misbehave) at the
1016
+ // user. Mirrors the wizard's required-field message verbatim when
1017
+ // a matching ${key} prompt exists.
1018
+ const cfgGap = preflightConnectorSettings(def, effectiveSettings);
1019
+ if (cfgGap) return { content: cfgGap, is_error: true };
1020
+
859
1021
  try {
860
1022
  let result: ToolResult;
861
1023
  switch (protocol) {
@@ -107,12 +107,24 @@ export function getConnectorEntries(def: ConnectorDefinition): ConnectorEntry[]
107
107
 
108
108
  /**
109
109
  * Wire shape of `<dataDir>/connector-configs.json`:
110
- * { "<id>": { config, installed_version, enabled } }
110
+ * { "<id>": { config, installed_version, enabled, installed_source_id } }
111
+ *
112
+ * `installed_source_id` records which marketplace source the on-disk
113
+ * manifest came from — `public`, `enterprise-<tenant_id>`, or undefined
114
+ * for install-local / pre-enterprise rows. UI uses it for the source
115
+ * badge; sync.ts updates it on every refresh so it stays in step with
116
+ * the priority-winning source.
111
117
  */
112
118
  interface ConnectorConfigRow {
113
119
  config: Record<string, unknown>;
114
120
  installed_version: string;
115
121
  enabled?: boolean;
122
+ installed_source_id?: string;
123
+ /** E3: which department template last wrote this row. Future
124
+ * wizard re-runs default the dept picker to this value so a user
125
+ * on FortiNAC doesn't get bumped to FortiWeb when they re-open
126
+ * the wizard. */
127
+ installed_dept?: string;
116
128
  }
117
129
 
118
130
  type ConfigStore = Record<string, ConnectorConfigRow>;
@@ -161,6 +173,18 @@ function transformConnectorSecrets(
161
173
 
162
174
  if (t === 'secret' || t === 'password') {
163
175
  if (typeof value !== 'string' || !value) return value;
176
+ // Template tokens (`{base_url}`, `{connector:gitlab.token}`,
177
+ // `{user.login}`, `{settings.x}`, `{secret:scope.path}`) are literal
178
+ // references resolved at request time by expandAllTokens. Encrypting
179
+ // them would (a) hide the indirection from users browsing their
180
+ // connector settings, and (b) treat the *resolved* value as
181
+ // plaintext on decrypt. Skip the encrypt step for any value that
182
+ // looks like a single token reference (`{[A-Za-z0-9_.:]+}`).
183
+ // Real PATs / api-tokens never contain `{` or `}`, so the false-
184
+ // positive risk on a legitimate secret value is zero.
185
+ if (op === 'encrypt' && /^\{[A-Za-z0-9_.:]+\}$/.test(value.trim())) {
186
+ return value;
187
+ }
164
188
  if (op === 'encrypt') return isEncrypted(value) ? value : (() => {
165
189
  try { return encryptSecret(value); } catch { return value; }
166
190
  })();
@@ -241,6 +265,7 @@ function saveStore(store: ConfigStore): void {
241
265
  config: encryptedConfig,
242
266
  installed_version: row.installed_version,
243
267
  enabled: row.enabled !== false,
268
+ installed_source_id: row.installed_source_id,
244
269
  };
245
270
  }
246
271
  writeFileSync(configsFile(), JSON.stringify(out, null, 2), { mode: 0o600 });
@@ -253,6 +278,14 @@ export interface InstalledConnector {
253
278
  config: Record<string, unknown>;
254
279
  installed_version: string;
255
280
  enabled: boolean;
281
+ /** Marketplace source that provided the on-disk manifest:
282
+ * `public` | `enterprise-<tenant_id>` | undefined for install-local
283
+ * or pre-enterprise rows. */
284
+ installed_source_id?: string;
285
+ /** E3: department name (matches template `_department_name`) the
286
+ * row was last applied under. Used by the wizard to default the
287
+ * dept picker to whatever the user last chose. */
288
+ installed_dept?: string;
256
289
  }
257
290
 
258
291
  export function isInstalled(id: string): boolean {
@@ -287,6 +320,8 @@ export function listInstalledConnectors(): InstalledConnector[] {
287
320
  config: row.config || {},
288
321
  installed_version: row.installed_version || def.version,
289
322
  enabled: row.enabled !== false,
323
+ installed_source_id: row.installed_source_id,
324
+ installed_dept: row.installed_dept,
290
325
  });
291
326
  }
292
327
  return out;
@@ -302,6 +337,8 @@ export function getInstalledConnector(id: string): InstalledConnector | null {
302
337
  config: row.config || {},
303
338
  installed_version: row.installed_version || def.version,
304
339
  enabled: row.enabled !== false,
340
+ installed_source_id: row.installed_source_id,
341
+ installed_dept: row.installed_dept,
305
342
  };
306
343
  }
307
344
 
@@ -309,8 +346,18 @@ export function getInstalledConnector(id: string): InstalledConnector | null {
309
346
  * Install (or upgrade) a connector by writing its manifest YAML and
310
347
  * recording the version in the config store. Existing user config is
311
348
  * preserved across upgrades.
349
+ *
350
+ * `opts.source_id` records which marketplace source provided this
351
+ * manifest. Sync passes the priority-winning source id; install-local
352
+ * leaves it undefined. Preserves the previous source_id when undefined
353
+ * is passed so a manual re-install doesn't accidentally orphan the
354
+ * source tracking.
312
355
  */
313
- export function installConnector(id: string, manifestYaml: string): boolean {
356
+ export function installConnector(
357
+ id: string,
358
+ manifestYaml: string,
359
+ opts: { source_id?: string } = {},
360
+ ): boolean {
314
361
  const def = parseManifest(manifestYaml);
315
362
  if (!def || def.id !== id) return false;
316
363
  const dir = join(connectorsDir(), id);
@@ -318,11 +365,12 @@ export function installConnector(id: string, manifestYaml: string): boolean {
318
365
  writeFileSync(join(dir, 'manifest.yaml'), manifestYaml, { mode: 0o600 });
319
366
 
320
367
  const store = loadStore();
321
- const prev = store[id]?.config || {};
368
+ const prev = store[id];
322
369
  store[id] = {
323
- config: prev,
370
+ config: prev?.config || {},
324
371
  installed_version: def.version,
325
372
  enabled: true,
373
+ installed_source_id: opts.source_id !== undefined ? opts.source_id : prev?.installed_source_id,
326
374
  };
327
375
  saveStore(store);
328
376
  return true;
@@ -371,6 +419,23 @@ export function setConnectorConfig(
371
419
  store[id] = { ...row, config };
372
420
  }
373
421
  saveStore(store);
422
+ // Creds may have changed — drop any cached probe so the Login Status
423
+ // panel re-probes on next view. Dynamic import to avoid pulling the
424
+ // auth subsystem (and its disk I/O) into every registry consumer.
425
+ import('../auth/login-status').then(m => m.invalidateCachedResult(`connector:${id}`)).catch(() => {});
426
+ return true;
427
+ }
428
+
429
+ /** E3: stamp the dept that owns this connector's last applied config.
430
+ * Called by applyConnectors when the template carries _department_name.
431
+ * Removing the dept = pass undefined; reading is via the underlying
432
+ * store row. */
433
+ export function setConnectorDept(id: string, dept: string | undefined): boolean {
434
+ const store = loadStore();
435
+ if (!store[id]) return false;
436
+ if (dept && dept.trim()) store[id].installed_dept = dept.trim();
437
+ else delete store[id].installed_dept;
438
+ saveStore(store);
374
439
  return true;
375
440
  }
376
441