@aion0/forge 0.8.0 → 0.8.2

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 (50) hide show
  1. package/RELEASE_NOTES.md +25 -101
  2. package/app/api/connectors/[id]/settings/route.ts +31 -37
  3. package/app/api/connectors/[id]/test/route.ts +260 -0
  4. package/app/api/connectors/install-local/route.ts +211 -0
  5. package/app/api/connectors/marketplace/route.ts +79 -0
  6. package/app/api/connectors/route.ts +41 -46
  7. package/app/api/jobs/route.ts +1 -0
  8. package/app/api/monitor/route.ts +26 -0
  9. package/app/api/skills/install-local/route.ts +282 -0
  10. package/components/ConnectorsPanel.tsx +526 -211
  11. package/components/MonitorPanel.tsx +43 -0
  12. package/components/SettingsModal.tsx +1 -0
  13. package/components/SkillsPanel.tsx +42 -1
  14. package/lib/agents/claude-adapter.ts +4 -0
  15. package/lib/agents/types.ts +6 -0
  16. package/lib/chat/agent-loop.ts +14 -23
  17. package/lib/chat/local-memory.ts +2 -2
  18. package/lib/chat/memory-store.ts +1 -1
  19. package/lib/chat/protocols/http.ts +2 -2
  20. package/lib/chat/protocols/shell.ts +2 -2
  21. package/lib/chat/session-store.ts +2 -2
  22. package/lib/chat/tool-dispatcher.ts +21 -21
  23. package/lib/connectors/migration.ts +110 -0
  24. package/lib/connectors/registry.ts +328 -0
  25. package/lib/connectors/sync.ts +305 -0
  26. package/lib/connectors/types.ts +253 -0
  27. package/lib/help-docs/00-overview.md +1 -0
  28. package/lib/help-docs/17-connectors.md +241 -189
  29. package/lib/help-docs/21-build-connector.md +314 -0
  30. package/lib/help-docs/CLAUDE.md +4 -2
  31. package/lib/init.ts +25 -0
  32. package/lib/jobs/dispatcher.ts +28 -8
  33. package/lib/jobs/scheduler.ts +21 -3
  34. package/lib/jobs/store.ts +11 -2
  35. package/lib/jobs/types.ts +12 -0
  36. package/lib/pipeline-scheduler.ts +3 -2
  37. package/lib/pipeline.ts +135 -13
  38. package/lib/plugins/registry.ts +9 -42
  39. package/lib/plugins/types.ts +4 -129
  40. package/lib/settings.ts +7 -0
  41. package/lib/skills.ts +27 -1
  42. package/lib/task-manager.ts +62 -2
  43. package/package.json +3 -1
  44. package/src/core/db/database.ts +4 -0
  45. package/lib/builtin-plugins/github-api.yaml +0 -93
  46. package/lib/builtin-plugins/gitlab.yaml +0 -860
  47. package/lib/builtin-plugins/mantis.probe.js +0 -176
  48. package/lib/builtin-plugins/mantis.yaml +0 -964
  49. package/lib/builtin-plugins/pmdb.yaml +0 -178
  50. package/lib/builtin-plugins/teams.yaml +0 -913
package/RELEASE_NOTES.md CHANGED
@@ -1,107 +1,31 @@
1
- # Forge v0.8.0
1
+ # Forge v0.8.2
2
2
 
3
- Released: 2026-05-19
3
+ Released: 2026-05-20
4
4
 
5
- ## Changes since v0.6.3
6
-
7
- ### Features
8
- - feat: job preview auto-fill + forge worktree CLI + faster startup
9
-
10
- ### Documentation
11
- - docs: example pipeline for a Forge Job driving Mantis → branch triage
12
- - docs: Jobs — scheduled connector polls that fan out to pipelines or chat
13
- - docs: connector declarative-extract spec + handoff + updated help doc
14
- - docs: connector packaging section + plugin category ADR
15
- - docs: ADR for browser-agent data architecture (Temper write strategy)
16
- - docs: RFC + implementation plan for browser-agent / connector system
5
+ ## Changes since v0.8.1
17
6
 
18
7
  ### Other
19
- - feat(chat): local memory fallback + simplified /chat web page
20
- - feat(security): encrypt plugin secrets at rest + mask on API
21
- - feat(pipeline): print fetch-bug-details + download-attachments to task log
22
- - feat(mantis+pipeline): attachment download into worktree for vision analysis
23
- - fix(pipeline): push-and-mr detects revoked glab token + prints fix cmd
24
- - fix(pipeline): push-and-mr surfaces glab output + MR-list fallback
25
- - feat(pipeline/mantis): fetch-bug-details exposes ALL fields downstream
26
- - fix(pipeline-ui): fetch full pipeline on click — summary breaks detail view
27
- - feat(jobs): expose {{total_matching}} placeholder in chat summary mode
28
- - fix(mantis): regex escape — YAML | block passes backslashes verbatim
29
- - fix(time): normalize all SQLite datetimes to ISO Z + add settings.timezone
30
- - feat(jobs/mantis): target='main' chat dispatch + total_matching count
31
- - fix(mantis): reset all filter fields before applying — no session leak
32
- - feat(chat): persistent main session + clear-messages + fork
33
- - feat(jobs): chat 'summary' dispatch mode — bundle N items into 1 LLM analysis
34
- - perf(pipeline-ui): lazy-load runs per workflow, drop 5s full poll
35
- - perf(watcher): defer initial sync + skip entry_count on first pass
36
- - feat(pipeline-ui): 'Load older runs' button per workflow
37
- - perf(pipeline): default limit 100 + ?workflow / ?before cursor
38
- - perf(pipeline): mtime cache + summary mode for list — 1.6MB → <50KB
39
- - perf(init): instrument 5s gap + demote skills sync failure to warn
40
- - feat(teams): _probe diagnostic tool — DOM state in one call
41
- - fix(chat): unwrap Node fetch 'fetch failed' with concrete remediation
42
- - fix(teams): char-by-char typing for search input (keyboard events)
43
- - feat(teams): v0.8.0 — send_message 3-tier resolver (left-rail → expand groups → search)
44
- - feat(mantis): v0.4.3 — accurate filters via show_status + handler_id
45
- - fix(pipeline): real workdir support + worktree-setup emits path only
46
- - fix(pipeline): node tasks always start fresh Claude session
47
- - fix(jobs): warn loudly when input_template keys render to empty
48
- - feat(jobs): 'Force run' — reset dedup + run, one click
49
- - fix(jobs): drop dedup_key from triggerPipeline — UNIQUE blocked replay
50
- - feat(gitlab): full HTTP-API connector (32 tools) — replaces DOM scraping
51
- - fix(mantis): bug notes always carry an id — fallback strategies + hash
52
- - feat(chat): connector narrowing — /teams forces tool use; bare mention narrows tool list
53
- - fix(chat): connector tools always surfaced + stronger 'use the tool' prompt
54
- - fix(notify-test): explain 'fetch failed' (corp SSL / proxy / DNS)
55
- - fix(notify-test): drop Markdown parse_mode + split chat_id + surface real reason
56
- - fix(mantis): search_bugs — fix_schedule (version) filter + auto-pagination
57
- - fix(mantis): search_bugs honours status + project_id, drops broken query syntax
58
- - feat(health): startup probe + Pipelines banner for missing CLIs (glab/gh/git/jq)
59
- - feat(pipeline): mantis-bug-fix-and-mr builtin + connector-tool API
60
- - feat(mantis): notes carry stable note_id so Jobs can dedup on them
61
- - feat(jobs): per-run verbose execution log persisted to job_runs.log
62
- - feat(web): Jobs first in Automation, deep-link to logs filtered by job id
63
- - fix(web): Automation sub-tabs squished header; new sub-toolbar row fix(terminal): Split Right/Down → SVG icons; mouse hint to tooltip feat(jobs): notes column — backfill + items_path mismatch surfaced
64
- - feat(web): Automation hub — merge Tasks/Pipelines nav + add Jobs view
65
- - feat(jobs): trace flow — Dashboard deep-link + CLI dispatches view
66
- - feat(jobs): Jobs backend — scheduled connector polls + dispatch
67
- - fix(settings): don't overwrite agent apiKey with masked placeholder
68
- - feat(chat): port Temper memory to Forge — pinned blocks + memory_* tools
69
- - feat(chat): reuse API agent profiles + add baseUrl (LiteLLM support)
70
- - feat(bridge): drop pairing — bridge auths with the user's Forge token
71
- - feat(connectors): Phase 3 — multi-protocol runtime (http, shell)
72
- - feat(chat): Phase 5 part 3 — Telegram /chat command
73
- - feat(chat): Phase 5 part 2 — forge chat CLI subcommand
74
- - feat(chat): Phase 5 part 1 — chat-standalone (8408) + Next proxy
75
- - fix(bridge): spawn browser-bridge from lib/init.ts too
76
- - feat(chat): Phase 2 part 3 — HTTP API + WS push streaming
77
- - feat(chat): Phase 2 part 2 — tool dispatcher + agent loop
78
- - feat(chat): Phase 2 part 1 — chat backend foundation
79
- - feat(bridge): Phase 1 — Forge↔Extension WebSocket bridge
80
- - feat(connectors): teams v0.7.1 — send_message no longer requires confirmation
81
- - feat(connectors): teams v0.7.0 — robust wait + send_message by name
82
- - feat(connectors): teams v0.6.0 — read_chat + read_channel by name
83
- - feat(connectors): teams v0.5.0 — channels support (list + read posts)
84
- - feat(connectors): pmdb v0.2.0 — read-only Fortinet PMDB connector
85
- - feat(connectors): teams v0.4.0 — send_message (destructive)
86
- - feat(connectors): teams v0.3.0 — rewritten from real DOM probe
87
- - feat(connectors): multi-runner — manifest declares execution context
88
- - feat(connectors): teams (Microsoft Teams) — first-cut declarative manifest
89
- - chore(connectors): bump gitlab to 0.2.0
90
- - feat(connectors): gitlab — declarative manifest with 6 tools
91
- - feat(connectors): declarative extract — manifest carries page + script
92
- - chore(connectors): bump mantis to 0.2.0 — full tool set landed
93
- - feat(mcp): user-managed external MCP servers in .mcp.json
94
- - feat(connectors): mantis probe — auto-copy JSON result to clipboard
95
- - fix(connectors): mantis probe — support 1.x classic URL + checkbox row marker
96
- - feat(connectors): mantis selector probe sibling script
97
- - chore(connectors): bump mantis + gitlab-browser to 0.1.1
98
- - docs(connectors): DOM extraction is the default path, not REST APIs
99
- - feat(ui): dedicated Connectors panel separate from Plugins
100
- - fix(connectors-api): rely on middleware auth, drop explicit token check
101
- - feat(api): connectors discovery + per-user settings endpoints
102
- - feat(plugins): connector category + browser-side mode, mantis + gitlab manifests
103
- - feat(gitlab): \`gitlab-issue-fix-and-review\` pipeline + scanner
104
- - fix(mcp): mirror .forge/mcp.json to project root so claude picks it up
8
+ - feat(skills): local skill upload (.md / .zip), parallel to connector install-local
9
+ - fix(pipeline): auto-sync gitlab connector PAT into glab auth + env
10
+ - feat(jobs): Job.skills + thread through to claude --append-system-prompt
11
+ - fix(connectors): friendlier error when browser-probe handler missing
12
+ - fix(pipeline): allow retry on running nodes + reap orphans on boot
13
+ - feat(connectors): browser-side test probe via extension bridge
14
+ - feat(connectors): manifest-driven Test button
15
+ - docs(connectors): 21-build-connector.md + AI routing for authoring
16
+ - feat(connectors): Upload button + drag-and-drop in marketplace
17
+ - feat(connectors): install-local APIaccept YAML or zip
18
+ - refactor(connectors): move marketplace from Settings to SkillsPanel tab
19
+ - fix(connectors): surface fetch root cause + show installed in marketplace
20
+ - docs(connectors): rewrite 17-connectors.md for marketplace model
21
+ - feat(connectors): drop builtin yamls + purge connector code from plugin/
22
+ - feat(connectors): one-shot migration from plugin-configs.json
23
+ - feat(connectors): Settings Marketplace panel
24
+ - feat(connectors): marketplace API /api/connectors/marketplace
25
+ - feat(connectors): route /api/connectors + chat through new registry
26
+ - feat(connectors): sync pull registry + manifests from forge-connectors
27
+ - feat(connectors): registry load manifests from <dataDir>/connectors/
28
+ - feat(connectors): extract independent types in lib/connectors/
105
29
 
106
30
 
107
- **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.6.3...v0.8.0
31
+ **Full Changelog**: https://github.com/aiwatching/forge/compare/v0.8.1...v0.8.2
@@ -1,28 +1,31 @@
1
1
  /**
2
- * Per-user settings for a connector plugin.
2
+ * Per-user settings for an installed connector.
3
3
  *
4
- * Browser-side connectors don't have a Forge-side executor, so "install" is
5
- * really just "save your host URL + preferences". We map connector `settings`
6
- * onto the existing plugin config storage to avoid a second store.
4
+ * GET → masked settings + schema + installed flag
5
+ * POST save settings (preserves stored secrets if client returns
6
+ * the bullet placeholder unchanged)
7
+ *
8
+ * Secrets (fields with type === 'secret') are encrypted at rest by
9
+ * lib/connectors/registry.ts (AES-256-GCM via lib/crypto.ts) and
10
+ * masked here so the plaintext never crosses the wire on GET.
7
11
  */
8
12
 
9
13
  import { NextResponse } from 'next/server';
10
14
  import {
11
- getPlugin,
12
- getInstalledPlugin,
13
- installPlugin,
14
- updatePluginConfig,
15
- getConnectorsForPlugin,
16
- } from '@/lib/plugins/registry';
17
- import type { PluginFieldSchema } from '@/lib/plugins/types';
15
+ getConnector,
16
+ getInstalledConnector,
17
+ setConnectorConfig,
18
+ } from '@/lib/connectors/registry';
19
+ import type { ConnectorFieldSchema } from '@/lib/connectors/types';
18
20
 
19
- function settingsSchemaFor(id: string): Record<string, PluginFieldSchema> {
20
- const def = getPlugin(id);
21
+ function settingsSchemaFor(id: string): Record<string, ConnectorFieldSchema> {
22
+ const def = getConnector(id);
21
23
  if (!def) return {};
22
- // For 1:N suites, merge settings across all entries (caller can scope by entry id).
23
- const merged: Record<string, PluginFieldSchema> = {};
24
- for (const c of getConnectorsForPlugin(def)) {
25
- if (c.settings) Object.assign(merged, c.settings);
24
+ const merged: Record<string, ConnectorFieldSchema> = {};
25
+ if (def.settings) Object.assign(merged, def.settings);
26
+ // 1:N suites: union of each entry's settings
27
+ for (const entry of def.connectors || []) {
28
+ if (entry.settings) Object.assign(merged, entry.settings);
26
29
  }
27
30
  return merged;
28
31
  }
@@ -38,12 +41,12 @@ function defaultsFor(id: string): Record<string, any> {
38
41
 
39
42
  const SECRET_MASK = '••••••••';
40
43
 
41
- function isSecretField(schema: PluginFieldSchema | undefined): boolean {
44
+ function isSecretField(schema: ConnectorFieldSchema | undefined): boolean {
42
45
  if (!schema) return false;
43
46
  return schema.type === 'secret' || (schema.type as string) === 'password';
44
47
  }
45
48
 
46
- function maskSecrets(settings: Record<string, any>, schema: Record<string, PluginFieldSchema>): Record<string, any> {
49
+ function maskSecrets(settings: Record<string, any>, schema: Record<string, ConnectorFieldSchema>): Record<string, any> {
47
50
  const out: Record<string, any> = { ...settings };
48
51
  for (const [k, v] of Object.entries(schema)) {
49
52
  if (isSecretField(v) && typeof out[k] === 'string' && out[k]) {
@@ -56,7 +59,7 @@ function maskSecrets(settings: Record<string, any>, schema: Record<string, Plugi
56
59
  function restoreSecrets(
57
60
  incoming: Record<string, any>,
58
61
  existing: Record<string, any>,
59
- schema: Record<string, PluginFieldSchema>,
62
+ schema: Record<string, ConnectorFieldSchema>,
60
63
  ): Record<string, any> {
61
64
  const out: Record<string, any> = { ...incoming };
62
65
  for (const [k, v] of Object.entries(schema)) {
@@ -70,11 +73,9 @@ function restoreSecrets(
70
73
 
71
74
  export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
72
75
  const { id } = await params;
73
- const def = getPlugin(id);
74
- if (!def || def.category !== 'connector') {
75
- return NextResponse.json({ error: 'connector not found' }, { status: 404 });
76
- }
77
- const inst = getInstalledPlugin(id);
76
+ const def = getConnector(id);
77
+ if (!def) return NextResponse.json({ error: 'connector not found' }, { status: 404 });
78
+ const inst = getInstalledConnector(id);
78
79
  const stored = inst?.config || {};
79
80
  const schema = settingsSchemaFor(id);
80
81
  const merged = { ...defaultsFor(id), ...stored };
@@ -87,10 +88,8 @@ export async function GET(req: Request, { params }: { params: Promise<{ id: stri
87
88
 
88
89
  export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
89
90
  const { id } = await params;
90
- const def = getPlugin(id);
91
- if (!def || def.category !== 'connector') {
92
- return NextResponse.json({ error: 'connector not found' }, { status: 404 });
93
- }
91
+ const def = getConnector(id);
92
+ if (!def) return NextResponse.json({ error: 'connector not found' }, { status: 404 });
94
93
 
95
94
  const body = await req.json().catch(() => ({}));
96
95
  const settings = body?.settings ?? body;
@@ -98,15 +97,10 @@ export async function POST(req: Request, { params }: { params: Promise<{ id: str
98
97
  return NextResponse.json({ error: 'settings must be an object' }, { status: 400 });
99
98
  }
100
99
 
101
- const existing = getInstalledPlugin(id);
100
+ const existing = getInstalledConnector(id);
102
101
  const schema = settingsSchemaFor(id);
103
102
  const merged = restoreSecrets(settings, existing?.config || {}, schema);
104
- const ok = existing
105
- ? updatePluginConfig(id, merged)
106
- : installPlugin(id, merged);
107
-
108
- if (!ok) {
109
- return NextResponse.json({ error: 'failed to persist settings' }, { status: 500 });
110
- }
103
+ const ok = setConnectorConfig(id, merged);
104
+ if (!ok) return NextResponse.json({ error: 'failed to persist settings' }, { status: 500 });
111
105
  return NextResponse.json({ ok: true, settings: maskSecrets(merged, schema) });
112
106
  }
@@ -0,0 +1,260 @@
1
+ /**
2
+ * POST /api/connectors/[id]/test
3
+ *
4
+ * Reachability probe for a connector. Runs the manifest's `test:`
5
+ * block (an HTTP request) against the user's saved settings, returns
6
+ * a structured result for the Settings → Connectors UI to render.
7
+ *
8
+ * {
9
+ * ok: true,
10
+ * status: 200,
11
+ * message: "Authenticated as zliu (Zhen Liu)",
12
+ * duration_ms: 152
13
+ * }
14
+ *
15
+ * On failure:
16
+ *
17
+ * {
18
+ * ok: false,
19
+ * status: 401,
20
+ * error: "Token was revoked. You have to re-authorize from the user.",
21
+ * body_preview: "{...}"
22
+ * }
23
+ *
24
+ * The probe is HTTP-only — connectors that need a different mechanism
25
+ * (browser DOM, shell exec) simply omit `test:` and the UI hides the
26
+ * button.
27
+ */
28
+
29
+ import { NextResponse } from 'next/server';
30
+ import {
31
+ getConnector,
32
+ getConnectorEntries,
33
+ getInstalledConnector,
34
+ } from '@/lib/connectors/registry';
35
+ import { expandSettingsTokens, expandAllTokens } from '@/lib/plugins/templates';
36
+ import { bridgeRpc } from '@/lib/chat/bridge-client';
37
+ import type { ConnectorDefinition, ConnectorTest, HttpRequestSpec } from '@/lib/connectors/types';
38
+
39
+ const DEFAULT_TIMEOUT_MS = 15_000;
40
+ const MAX_BODY_PREVIEW = 1024;
41
+
42
+ function expandString(s: string, settings: Record<string, unknown>): string {
43
+ return expandAllTokens(s, settings as Record<string, any>, {});
44
+ }
45
+
46
+ function buildUrl(spec: HttpRequestSpec, settings: Record<string, unknown>): string {
47
+ let url = expandString(spec.url, settings);
48
+ if (spec.query) {
49
+ const u = new URL(url);
50
+ for (const [k, raw] of Object.entries(spec.query)) {
51
+ u.searchParams.set(k, expandString(String(raw), settings));
52
+ }
53
+ url = u.toString();
54
+ }
55
+ return url;
56
+ }
57
+
58
+ function buildHeaders(spec: HttpRequestSpec, settings: Record<string, unknown>): Headers {
59
+ const h = new Headers();
60
+ if (spec.headers) {
61
+ for (const [k, raw] of Object.entries(spec.headers)) {
62
+ h.set(k, expandString(String(raw), settings));
63
+ }
64
+ }
65
+ return h;
66
+ }
67
+
68
+ function buildBody(
69
+ spec: HttpRequestSpec,
70
+ settings: Record<string, unknown>,
71
+ ): { body?: string; contentType?: string } {
72
+ if (spec.body == null) return {};
73
+ if (typeof spec.body === 'string') {
74
+ return { body: expandString(spec.body, settings) };
75
+ }
76
+ const out: Record<string, unknown> = {};
77
+ for (const [k, v] of Object.entries(spec.body)) {
78
+ out[k] = typeof v === 'string' ? expandString(v, settings) : v;
79
+ }
80
+ return { body: JSON.stringify(out), contentType: 'application/json' };
81
+ }
82
+
83
+ /**
84
+ * Render `{{path.to.value}}` placeholders against a parsed JSON body.
85
+ * Missing paths render as "?", non-string scalars get stringified.
86
+ */
87
+ function renderTemplate(template: string, body: unknown): string {
88
+ return template.replace(/\{\{([^{}]+)\}\}/g, (_match, expr) => {
89
+ const path = String(expr).trim().split('.').filter(Boolean);
90
+ let cur: any = body;
91
+ for (const p of path) {
92
+ if (cur && typeof cur === 'object' && p in cur) cur = cur[p];
93
+ else return '?';
94
+ }
95
+ if (cur == null) return '?';
96
+ return typeof cur === 'string' ? cur : JSON.stringify(cur);
97
+ });
98
+ }
99
+
100
+ interface TestResult {
101
+ ok: boolean;
102
+ status?: number;
103
+ message?: string;
104
+ error?: string;
105
+ duration_ms?: number;
106
+ body_preview?: string;
107
+ }
108
+
109
+ async function runHttpProbe(test: ConnectorTest, settings: Record<string, unknown>): Promise<TestResult> {
110
+ const spec = test.request;
111
+ if (!spec?.url) return { ok: false, error: 'test.request.url is required for http probe' };
112
+
113
+ const method = (spec.method || 'GET').toUpperCase();
114
+ const url = buildUrl(spec, settings);
115
+ const headers = buildHeaders(spec, settings);
116
+ const { body, contentType } = buildBody(spec, settings);
117
+ if (body != null && contentType && !headers.has('content-type')) {
118
+ headers.set('content-type', contentType);
119
+ }
120
+
121
+ const timeoutMs = test.timeout_ms || DEFAULT_TIMEOUT_MS;
122
+ const okStatus = test.ok_status?.length ? test.ok_status : [200];
123
+
124
+ const ctrl = new AbortController();
125
+ const timer = setTimeout(() => ctrl.abort(), timeoutMs);
126
+ const t0 = Date.now();
127
+ let res: Response;
128
+ try {
129
+ res = await fetch(url, { method, headers, body, signal: ctrl.signal });
130
+ } catch (e) {
131
+ clearTimeout(timer);
132
+ const err = e as Error & { cause?: unknown };
133
+ const cause = err.cause instanceof Error ? `: ${err.cause.message}` : '';
134
+ return {
135
+ ok: false,
136
+ error: `request failed: ${err.message}${cause}`,
137
+ duration_ms: Date.now() - t0,
138
+ };
139
+ }
140
+ clearTimeout(timer);
141
+
142
+ const duration = Date.now() - t0;
143
+ const text = await res.text().catch(() => '');
144
+ const preview = text.length > MAX_BODY_PREVIEW ? text.slice(0, MAX_BODY_PREVIEW) + '…' : text;
145
+
146
+ if (!okStatus.includes(res.status)) {
147
+ let errMsg = `HTTP ${res.status} ${res.statusText}`;
148
+ try {
149
+ const j = JSON.parse(text);
150
+ if (typeof j?.error === 'string') errMsg += `: ${j.error}`;
151
+ else if (typeof j?.message === 'string') errMsg += `: ${j.message}`;
152
+ else if (typeof j?.error_description === 'string') errMsg += `: ${j.error_description}`;
153
+ } catch {}
154
+ return {
155
+ ok: false,
156
+ status: res.status,
157
+ error: errMsg,
158
+ duration_ms: duration,
159
+ body_preview: preview,
160
+ };
161
+ }
162
+
163
+ let parsedBody: unknown = null;
164
+ try { parsedBody = JSON.parse(text); } catch {}
165
+ const message = test.ok_template
166
+ ? renderTemplate(test.ok_template, parsedBody)
167
+ : `OK (HTTP ${res.status})`;
168
+ return {
169
+ ok: true,
170
+ status: res.status,
171
+ message,
172
+ duration_ms: duration,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Browser probe: ask the paired extension to land on the connector's
178
+ * host_match URL and report whether `login_redirect` was hit.
179
+ *
180
+ * The extension implements `connector.probe` (see
181
+ * lib/help-docs/21-build-connector.md for the wire contract). On
182
+ * sites the user is logged in to, it returns
183
+ * { ok: true, url: '<final tab URL>' }
184
+ * On a redirect to the login page it returns
185
+ * { ok: false, error: 'login required', url: '<login url>' }
186
+ * On extension absence we throw, caller surfaces it.
187
+ */
188
+ async function runBrowserProbe(
189
+ def: ConnectorDefinition,
190
+ settings: Record<string, unknown>,
191
+ ): Promise<TestResult> {
192
+ const entries = getConnectorEntries(def);
193
+ const entry = entries[0];
194
+ const hostMatch = def.host_match || entry?.host_match;
195
+ const loginRedirect = def.login_redirect || entry?.login_redirect;
196
+ if (!hostMatch) {
197
+ return { ok: false, error: 'browser probe requires host_match on the manifest' };
198
+ }
199
+ const expandedHost = expandSettingsTokens(hostMatch, settings as any);
200
+ const expandedLoginRedirect = loginRedirect
201
+ ? expandSettingsTokens(loginRedirect, settings as any)
202
+ : undefined;
203
+
204
+ const t0 = Date.now();
205
+ let value: unknown;
206
+ try {
207
+ value = await bridgeRpc('connector.probe', {
208
+ pluginId: def.id,
209
+ host_match: expandedHost,
210
+ login_redirect: expandedLoginRedirect,
211
+ runner: def.runner || entry?.runner || 'main',
212
+ timeout_ms: def.test?.timeout_ms || 30_000,
213
+ });
214
+ } catch (e) {
215
+ const raw = (e as Error).message || String(e);
216
+ let friendly = raw;
217
+ if (raw.includes('unknown method: connector.probe')) {
218
+ friendly =
219
+ 'Your Forge browser extension is out of date — it doesn\'t know how to run browser probes yet. ' +
220
+ 'Rebuild the extension (pnpm ext in forge-browser-extension), then chrome://extensions → Reload, and try Test again.';
221
+ } else if (raw.includes('bridge') && raw.includes('unreachable')) {
222
+ friendly =
223
+ 'Forge browser bridge is unreachable on port 8407. Restart Forge (forge server restart) or check that the browser-bridge standalone is running.';
224
+ } else if (raw.includes('no paired extensions') || raw.includes('no connected')) {
225
+ friendly =
226
+ 'Forge browser extension not connected. Install the extension from forge-browser-extension/dist, pin it, and sign in with your Forge URL + admin password.';
227
+ }
228
+ return {
229
+ ok: false,
230
+ error: friendly,
231
+ duration_ms: Date.now() - t0,
232
+ };
233
+ }
234
+ const r = (value || {}) as { ok?: boolean; url?: string; error?: string };
235
+ return {
236
+ ok: !!r.ok,
237
+ message: r.ok ? `Session active${r.url ? ` · ${r.url}` : ''}` : undefined,
238
+ error: r.ok ? undefined : (r.error || 'login required'),
239
+ duration_ms: Date.now() - t0,
240
+ };
241
+ }
242
+
243
+ export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
244
+ const { id } = await params;
245
+ const def = getConnector(id);
246
+ if (!def) return NextResponse.json({ ok: false, error: 'connector not found' }, { status: 404 });
247
+ if (!def.test) {
248
+ return NextResponse.json({ ok: false, error: 'connector has no test block' }, { status: 400 });
249
+ }
250
+ const inst = getInstalledConnector(id);
251
+ if (!inst) {
252
+ return NextResponse.json({ ok: false, error: 'connector not installed' }, { status: 400 });
253
+ }
254
+
255
+ const probe = def.test.probe || 'http';
256
+ const r = probe === 'browser'
257
+ ? await runBrowserProbe(def, inst.config)
258
+ : await runHttpProbe(def.test, inst.config);
259
+ return NextResponse.json(r);
260
+ }