@aion0/forge 0.8.1 → 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.
- package/RELEASE_NOTES.md +25 -6
- package/app/api/connectors/[id]/settings/route.ts +31 -37
- package/app/api/connectors/[id]/test/route.ts +260 -0
- package/app/api/connectors/install-local/route.ts +211 -0
- package/app/api/connectors/marketplace/route.ts +79 -0
- package/app/api/connectors/route.ts +41 -46
- package/app/api/jobs/route.ts +1 -0
- package/app/api/skills/install-local/route.ts +282 -0
- package/components/ConnectorsPanel.tsx +526 -211
- package/components/SettingsModal.tsx +1 -0
- package/components/SkillsPanel.tsx +42 -1
- package/lib/agents/claude-adapter.ts +4 -0
- package/lib/agents/types.ts +6 -0
- package/lib/chat/agent-loop.ts +13 -22
- package/lib/chat/protocols/http.ts +1 -1
- package/lib/chat/protocols/shell.ts +1 -1
- package/lib/chat/tool-dispatcher.ts +20 -20
- package/lib/connectors/migration.ts +110 -0
- package/lib/connectors/registry.ts +328 -0
- package/lib/connectors/sync.ts +305 -0
- package/lib/connectors/types.ts +253 -0
- package/lib/help-docs/00-overview.md +1 -0
- package/lib/help-docs/17-connectors.md +241 -189
- package/lib/help-docs/21-build-connector.md +314 -0
- package/lib/help-docs/CLAUDE.md +4 -2
- package/lib/init.ts +25 -0
- package/lib/jobs/dispatcher.ts +28 -8
- package/lib/jobs/scheduler.ts +21 -3
- package/lib/jobs/store.ts +11 -2
- package/lib/jobs/types.ts +12 -0
- package/lib/pipeline-scheduler.ts +3 -2
- package/lib/pipeline.ts +135 -13
- package/lib/plugins/registry.ts +9 -42
- package/lib/plugins/types.ts +4 -129
- package/lib/settings.ts +7 -0
- package/lib/skills.ts +27 -1
- package/lib/task-manager.ts +62 -2
- package/package.json +3 -1
- package/src/core/db/database.ts +4 -0
- package/lib/builtin-plugins/github-api.yaml +0 -93
- package/lib/builtin-plugins/gitlab.yaml +0 -860
- package/lib/builtin-plugins/mantis.probe.js +0 -176
- package/lib/builtin-plugins/mantis.yaml +0 -964
- package/lib/builtin-plugins/pmdb.yaml +0 -178
- package/lib/builtin-plugins/teams.yaml +0 -913
package/lib/pipeline.ts
CHANGED
|
@@ -9,7 +9,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
9
9
|
import { existsSync, readdirSync, readFileSync, writeFileSync, mkdirSync, statSync } from 'node:fs';
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
import YAML from 'yaml';
|
|
12
|
-
import { createTask, getTask, onTaskEvent, taskModelOverrides } from './task-manager';
|
|
12
|
+
import { createTask, getTask, onTaskEvent, taskModelOverrides, taskAppendSystemPromptOverrides } from './task-manager';
|
|
13
13
|
import { getProjectInfo } from './projects';
|
|
14
14
|
import { loadSettings } from './settings';
|
|
15
15
|
import { getAgent, listAgents } from './agents';
|
|
@@ -114,6 +114,13 @@ export interface Pipeline {
|
|
|
114
114
|
nodeOrder: string[]; // for UI display
|
|
115
115
|
createdAt: string;
|
|
116
116
|
completedAt?: string;
|
|
117
|
+
/**
|
|
118
|
+
* Skill names from the forge-skills registry to make available to
|
|
119
|
+
* every task this pipeline spawns. Composed into the task's
|
|
120
|
+
* --append-system-prompt. Carried in pipeline state so retries +
|
|
121
|
+
* recovery use the same set as the original run.
|
|
122
|
+
*/
|
|
123
|
+
skills?: string[];
|
|
117
124
|
// Conversation mode state
|
|
118
125
|
conversation?: {
|
|
119
126
|
config: ConversationConfig;
|
|
@@ -687,6 +694,22 @@ nodes:
|
|
|
687
694
|
print(out)
|
|
688
695
|
PY
|
|
689
696
|
)
|
|
697
|
+
# Force glab to use the Forge-managed GitLab PAT. Forge injects
|
|
698
|
+
# GITLAB_TOKEN from the gitlab connector's config; without
|
|
699
|
+
# rewriting it here, glab can stick to its stale per-host token
|
|
700
|
+
# in ~/.config/glab-cli/config.yml and 401 with "Token was
|
|
701
|
+
# revoked" — even when the env is set. glab auth login --token
|
|
702
|
+
# is idempotent and writes the right host config in 100 ms.
|
|
703
|
+
if [ -n "$GITLAB_TOKEN" ]; then
|
|
704
|
+
REMOTE_HOST_FOR_AUTH=$(git -C "$PROJECT_PATH" config --get remote.origin.url 2>/dev/null \\
|
|
705
|
+
| sed -E 's#^(https?://)?(git@)?([^:/]+).*#\\3#')
|
|
706
|
+
if [ -n "$REMOTE_HOST_FOR_AUTH" ]; then
|
|
707
|
+
echo "Refreshing glab auth for $REMOTE_HOST_FOR_AUTH from Forge connector token"
|
|
708
|
+
echo "$GITLAB_TOKEN" | glab auth login --hostname "$REMOTE_HOST_FOR_AUTH" --token-stdin >/dev/null 2>&1 || \\
|
|
709
|
+
echo "(glab auth login refresh failed — falling through; will likely 401)" >&2
|
|
710
|
+
fi
|
|
711
|
+
fi
|
|
712
|
+
|
|
690
713
|
# Bug assignee → MR reviewer. Mantis assignee comes as "Jane Doe (jdoe)";
|
|
691
714
|
# extract the (username) and pass to glab. If parens absent or empty,
|
|
692
715
|
# skip the flag — glab errors on --reviewer "" .
|
|
@@ -717,15 +740,17 @@ nodes:
|
|
|
717
740
|
echo "$GLAB_OUT" | head -50
|
|
718
741
|
echo "--- end glab output ---"
|
|
719
742
|
|
|
720
|
-
# Common, very actionable failure: corp GitLab revoked the
|
|
721
|
-
# token.
|
|
722
|
-
#
|
|
743
|
+
# Common, very actionable failure: corp GitLab revoked the
|
|
744
|
+
# token. Forge already pushes the connector PAT here via
|
|
745
|
+
# GITLAB_TOKEN + a glab auth login --token-stdin refresh
|
|
746
|
+
# earlier in this step, so the only way to land here is if the
|
|
747
|
+
# connector PAT itself is wrong/revoked. Point the user at
|
|
748
|
+
# Settings, not at glab auth login.
|
|
723
749
|
if echo "$GLAB_OUT" | grep -qE '401|invalid_token|Token was revoked|unauthorized'; then
|
|
724
|
-
HOST=$(echo "$PROJECT_PATH" | sed -E 's#^.+@?##; s#:.*##' || true)
|
|
725
750
|
REMOTE_HOST=$(git config --get remote.origin.url 2>/dev/null | sed -E 's#^(https?://)?(git@)?([^:/]+).*#\\3#')
|
|
726
|
-
echo "ERROR:
|
|
727
|
-
echo "Fix:
|
|
728
|
-
echo "Then retry this node from the pipeline UI." >&2
|
|
751
|
+
echo "ERROR: GitLab rejected the token for \${REMOTE_HOST:-this GitLab server}." >&2
|
|
752
|
+
echo "Fix: open Forge → Settings → Skills → Connectors tab → GitLab, paste a fresh Personal Access Token, click Save + Test." >&2
|
|
753
|
+
echo "Then retry this node from the pipeline UI — Forge will push the new token to glab automatically." >&2
|
|
729
754
|
fi
|
|
730
755
|
|
|
731
756
|
MR_URL=$(echo "$GLAB_OUT" | grep -oE 'https://[^[:space:]]+/-/merge_requests/[0-9]+' | head -1)
|
|
@@ -1273,7 +1298,25 @@ function releaseProjectLock(projectPath: string, pipelineId: string) {
|
|
|
1273
1298
|
|
|
1274
1299
|
// ─── Pipeline Execution ───────────────────────────────────
|
|
1275
1300
|
|
|
1276
|
-
|
|
1301
|
+
/**
|
|
1302
|
+
* Render the skills list into an --append-system-prompt block. Empty
|
|
1303
|
+
* input → empty string (caller passes undefined to skip the flag).
|
|
1304
|
+
*/
|
|
1305
|
+
function renderSkillsAppendPrompt(skills: string[] | undefined): string {
|
|
1306
|
+
if (!skills || skills.length === 0) return '';
|
|
1307
|
+
const lines = skills.map((s) => ` /${s}`).join('\n');
|
|
1308
|
+
return [
|
|
1309
|
+
'For this task, the following Forge skills are available and should be used as appropriate:',
|
|
1310
|
+
lines,
|
|
1311
|
+
"Read each skill's SKILL.md before invoking. Prefer invoking these skills (with /<name>) over reimplementing their workflows inline.",
|
|
1312
|
+
].join('\n');
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
export function startPipeline(
|
|
1316
|
+
workflowName: string,
|
|
1317
|
+
input: Record<string, string>,
|
|
1318
|
+
opts: { skills?: string[] } = {},
|
|
1319
|
+
): Pipeline {
|
|
1277
1320
|
const workflow = getWorkflow(workflowName);
|
|
1278
1321
|
if (!workflow) throw new Error(`Workflow not found: ${workflowName}`);
|
|
1279
1322
|
|
|
@@ -1303,6 +1346,7 @@ export function startPipeline(workflowName: string, input: Record<string, string
|
|
|
1303
1346
|
nodes,
|
|
1304
1347
|
nodeOrder,
|
|
1305
1348
|
createdAt: new Date().toISOString(),
|
|
1349
|
+
skills: opts.skills && opts.skills.length ? [...opts.skills] : undefined,
|
|
1306
1350
|
};
|
|
1307
1351
|
|
|
1308
1352
|
savePipeline(pipeline);
|
|
@@ -1407,6 +1451,10 @@ function scheduleNextConversationTurn(pipeline: Pipeline, contextForAgent: strin
|
|
|
1407
1451
|
conversationId: '', // fresh session — no resume for conversation mode
|
|
1408
1452
|
});
|
|
1409
1453
|
pipelineTaskIds.add(task.id);
|
|
1454
|
+
{
|
|
1455
|
+
const skillsAppend = renderSkillsAppendPrompt(pipeline.skills);
|
|
1456
|
+
if (skillsAppend) taskAppendSystemPromptOverrides.set(task.id, skillsAppend);
|
|
1457
|
+
}
|
|
1410
1458
|
|
|
1411
1459
|
// Add pending message
|
|
1412
1460
|
const names = agentNames || resolveAgentNames(config.agents);
|
|
@@ -1803,10 +1851,60 @@ function recoverStuckPipelines() {
|
|
|
1803
1851
|
}
|
|
1804
1852
|
}
|
|
1805
1853
|
|
|
1854
|
+
/**
|
|
1855
|
+
* One-shot at startup: cancel tasks that pipeline nodes think are
|
|
1856
|
+
* still running. When forge dies (crash, restart, kill -9) the task
|
|
1857
|
+
* row in SQLite is left as `running` even though the child process
|
|
1858
|
+
* is long gone, so the existing periodic recoverStuckPipelines just
|
|
1859
|
+
* sees `task.status === 'running'` and waits forever. This pass
|
|
1860
|
+
* marks those tasks as cancelled, letting the next recovery tick
|
|
1861
|
+
* transition the node to `failed` — at which point the user can
|
|
1862
|
+
* hit Retry from the pipeline UI.
|
|
1863
|
+
*
|
|
1864
|
+
* Safe because by definition any forge child task from before the
|
|
1865
|
+
* boot is dead: when the parent went down, the OS reaped its
|
|
1866
|
+
* subtree. No race with legitimately-running tasks — the very first
|
|
1867
|
+
* forge worker boot is the only moment this needs to fire.
|
|
1868
|
+
*/
|
|
1869
|
+
let reapedOrphans = false;
|
|
1870
|
+
function reapOrphanedPipelineTasks() {
|
|
1871
|
+
if (reapedOrphans) return;
|
|
1872
|
+
reapedOrphans = true;
|
|
1873
|
+
try {
|
|
1874
|
+
const pipelines = listPipelines().filter(p => p.status === 'running');
|
|
1875
|
+
let reaped = 0;
|
|
1876
|
+
for (const pipeline of pipelines) {
|
|
1877
|
+
for (const node of Object.values(pipeline.nodes)) {
|
|
1878
|
+
if (node.status === 'running' && node.taskId) {
|
|
1879
|
+
const t = getTask(node.taskId);
|
|
1880
|
+
if (t && t.status === 'running') {
|
|
1881
|
+
try {
|
|
1882
|
+
const { cancelTask } = require('./task-manager');
|
|
1883
|
+
cancelTask(node.taskId);
|
|
1884
|
+
reaped += 1;
|
|
1885
|
+
} catch (err) {
|
|
1886
|
+
console.warn(`[pipeline] reap orphan: cancelTask(${node.taskId}) threw:`, err);
|
|
1887
|
+
}
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
if (reaped > 0) {
|
|
1893
|
+
console.log(`[pipeline] reaped ${reaped} orphaned task(s) left running from a previous boot`);
|
|
1894
|
+
}
|
|
1895
|
+
} catch (err) {
|
|
1896
|
+
console.warn('[pipeline] reapOrphanedPipelineTasks failed:', err);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// One-shot orphan reap on boot, then standard recovery loop.
|
|
1901
|
+
setTimeout(() => {
|
|
1902
|
+
reapOrphanedPipelineTasks();
|
|
1903
|
+
recoverStuckPipelines();
|
|
1904
|
+
}, 5000);
|
|
1905
|
+
|
|
1806
1906
|
// Run recovery every 30 seconds
|
|
1807
1907
|
setInterval(recoverStuckPipelines, 30_000);
|
|
1808
|
-
// Also run once on load
|
|
1809
|
-
setTimeout(recoverStuckPipelines, 5000);
|
|
1810
1908
|
|
|
1811
1909
|
/**
|
|
1812
1910
|
* Retry a single failed node. Cascades-reset to any downstream nodes that
|
|
@@ -1828,8 +1926,23 @@ export async function retryNode(pipelineId: string, nodeId: string): Promise<{ o
|
|
|
1828
1926
|
|
|
1829
1927
|
const nodeState = pipeline.nodes[nodeId];
|
|
1830
1928
|
if (!nodeState) return { ok: false, error: `node '${nodeId}' is not in this pipeline` };
|
|
1831
|
-
|
|
1832
|
-
|
|
1929
|
+
// Allow retry on 'failed' OR 'running'. Running covers the
|
|
1930
|
+
// forge-restart-orphaned case: the node thinks it's executing but
|
|
1931
|
+
// the underlying task is dead. Anything else (pending/done/skipped)
|
|
1932
|
+
// is a misclick.
|
|
1933
|
+
if (nodeState.status !== 'failed' && nodeState.status !== 'running') {
|
|
1934
|
+
return { ok: false, error: `node is in status '${nodeState.status}' — only failed or running nodes can be retried` };
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
// If the node is "running", best-effort terminate the underlying task
|
|
1938
|
+
// so it doesn't keep occupying the project lock / project slot.
|
|
1939
|
+
if (nodeState.status === 'running' && nodeState.taskId) {
|
|
1940
|
+
try {
|
|
1941
|
+
const { cancelTask } = require('./task-manager');
|
|
1942
|
+
cancelTask(nodeState.taskId);
|
|
1943
|
+
} catch (err) {
|
|
1944
|
+
console.warn(`[pipeline] retryNode: cancelTask(${nodeState.taskId}) threw:`, err);
|
|
1945
|
+
}
|
|
1833
1946
|
}
|
|
1834
1947
|
|
|
1835
1948
|
const workflow = getWorkflow(pipeline.workflowName);
|
|
@@ -2088,6 +2201,15 @@ async function scheduleReadyNodes(pipeline: Pipeline, workflow: Workflow) {
|
|
|
2088
2201
|
conversationId: '',
|
|
2089
2202
|
});
|
|
2090
2203
|
pipelineTaskIds.add(task.id);
|
|
2204
|
+
|
|
2205
|
+
// Skills from the pipeline (forwarded from a job's `skills`) become
|
|
2206
|
+
// an --append-system-prompt on the task's claude invocation. We
|
|
2207
|
+
// attach the override BEFORE the task runner picks the task up so
|
|
2208
|
+
// the first attempt sees it.
|
|
2209
|
+
const skillsAppend = renderSkillsAppendPrompt(pipeline.skills);
|
|
2210
|
+
if (skillsAppend) {
|
|
2211
|
+
taskAppendSystemPromptOverrides.set(task.id, skillsAppend);
|
|
2212
|
+
}
|
|
2091
2213
|
// Pipeline tasks use the same model selection as normal tasks:
|
|
2092
2214
|
// agent model > global taskModel. No separate pipelineModel override.
|
|
2093
2215
|
|
package/lib/plugins/registry.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { join, dirname } from 'node:path';
|
|
|
14
14
|
import { homedir } from 'node:os';
|
|
15
15
|
import { fileURLToPath } from 'node:url';
|
|
16
16
|
import YAML from 'yaml';
|
|
17
|
-
import type { PluginDefinition, InstalledPlugin, PluginSource
|
|
17
|
+
import type { PluginDefinition, InstalledPlugin, PluginSource } from './types';
|
|
18
18
|
import { encryptSecret, decryptSecret, isEncrypted } from '../crypto';
|
|
19
19
|
|
|
20
20
|
const _filename = typeof __filename !== 'undefined' ? __filename : fileURLToPath(import.meta.url);
|
|
@@ -33,62 +33,29 @@ function loadPluginYaml(filePath: string): PluginDefinition | null {
|
|
|
33
33
|
const raw = readFileSync(filePath, 'utf-8');
|
|
34
34
|
const def = YAML.parse(raw) as PluginDefinition;
|
|
35
35
|
if (!def.id || !def.name) return null;
|
|
36
|
-
|
|
37
|
-
// Everything else must declare at least one action.
|
|
38
|
-
const isConnector = def.category === 'connector';
|
|
39
|
-
if (!isConnector && !def.actions) return null;
|
|
40
|
-
if (isConnector && !def.tools && !def.connectors?.length) return null;
|
|
41
|
-
// Defaults
|
|
36
|
+
if (!def.actions) return null;
|
|
42
37
|
if (!def.config) def.config = {};
|
|
43
38
|
if (!def.params) def.params = {};
|
|
44
|
-
if (!def.actions) def.actions = {};
|
|
45
39
|
if (!def.icon) def.icon = '🔌';
|
|
46
40
|
if (!def.version) def.version = '0.0.1';
|
|
47
|
-
if (!def.category) def.category =
|
|
48
|
-
if (!def.mode) def.mode =
|
|
41
|
+
if (!def.category) def.category = 'pipeline-node';
|
|
42
|
+
if (!def.mode) def.mode = 'server-side';
|
|
49
43
|
return def;
|
|
50
44
|
} catch {
|
|
51
45
|
return null;
|
|
52
46
|
}
|
|
53
47
|
}
|
|
54
48
|
|
|
55
|
-
/** Heuristic: shape-detect a connector even if `category` is omitted. */
|
|
56
|
-
function isConnectorByShape(def: PluginDefinition): boolean {
|
|
57
|
-
return Boolean(def.tools || def.connectors?.length);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
/**
|
|
61
|
-
* Normalize a plugin to its connector list.
|
|
62
|
-
* - 1:1 shape (top-level `tools`) → synthesizes a single Connector with id === plugin id.
|
|
63
|
-
* - 1:N shape (`connectors[]`) → returned as-is.
|
|
64
|
-
* - Non-connector plugins → returns [].
|
|
65
|
-
*/
|
|
66
|
-
export function getConnectorsForPlugin(def: PluginDefinition): Connector[] {
|
|
67
|
-
if (def.connectors?.length) return def.connectors;
|
|
68
|
-
if (def.tools) {
|
|
69
|
-
return [{
|
|
70
|
-
id: def.id,
|
|
71
|
-
host_permissions: def.host_permissions,
|
|
72
|
-
tools: def.tools,
|
|
73
|
-
settings: def.settings,
|
|
74
|
-
host_match: def.host_match,
|
|
75
|
-
login_redirect: def.login_redirect,
|
|
76
|
-
runner: def.runner,
|
|
77
|
-
}];
|
|
78
|
-
}
|
|
79
|
-
return [];
|
|
80
|
-
}
|
|
81
|
-
|
|
82
49
|
// ─── Config Storage ──────────────────────────────────────
|
|
83
50
|
|
|
84
51
|
/**
|
|
85
|
-
* Return field names declared as `type: secret`
|
|
86
|
-
*
|
|
87
|
-
*
|
|
52
|
+
* Return field names declared as `type: secret` on the plugin's
|
|
53
|
+
* top-level `config` schema. Used to decide which fields are
|
|
54
|
+
* encrypted on disk.
|
|
88
55
|
*/
|
|
89
56
|
export function getSecretFieldNames(def: PluginDefinition | null): string[] {
|
|
90
|
-
if (!def?.
|
|
91
|
-
return Object.entries(def.
|
|
57
|
+
if (!def?.config) return [];
|
|
58
|
+
return Object.entries(def.config)
|
|
92
59
|
.filter(([, schema]) => {
|
|
93
60
|
const t = String((schema as any)?.type || '');
|
|
94
61
|
return t === 'secret' || t === 'password';
|
package/lib/plugins/types.ts
CHANGED
|
@@ -9,18 +9,12 @@
|
|
|
9
9
|
/** Plugin action execution type */
|
|
10
10
|
export type PluginActionType = 'http' | 'poll' | 'shell' | 'script';
|
|
11
11
|
|
|
12
|
-
/** What kind of plugin this is — drives marketplace filtering
|
|
13
|
-
export type PluginCategory = '
|
|
12
|
+
/** What kind of plugin this is — drives marketplace filtering. */
|
|
13
|
+
export type PluginCategory = 'pipeline-node' | 'mcp-server' | 'tool' | 'other';
|
|
14
14
|
|
|
15
15
|
/** Where a plugin's tools execute. Default is server-side (Forge node process). */
|
|
16
16
|
export type PluginMode = 'server-side' | 'browser-side' | 'hybrid';
|
|
17
17
|
|
|
18
|
-
/**
|
|
19
|
-
* Which execution context the Forge browser extension uses to run a
|
|
20
|
-
* connector's scripts. See PluginDefinition.runner for the full rationale.
|
|
21
|
-
*/
|
|
22
|
-
export type PluginRunner = 'main' | 'isolated';
|
|
23
|
-
|
|
24
18
|
/** Schema field definition for config/params */
|
|
25
19
|
export interface PluginFieldSchema {
|
|
26
20
|
type: 'string' | 'number' | 'boolean' | 'secret' | 'json' | 'select';
|
|
@@ -59,106 +53,6 @@ export interface PluginAction {
|
|
|
59
53
|
output?: Record<string, string>; // { fieldName: "$.json.path" or "$body" or "$stdout" }
|
|
60
54
|
}
|
|
61
55
|
|
|
62
|
-
/**
|
|
63
|
-
* Where the extension's generic runner should land the active tab before
|
|
64
|
-
* executing a tool's script.
|
|
65
|
-
*/
|
|
66
|
-
export interface ConnectorPage {
|
|
67
|
-
/** Template URL; supports {base_url}, {settings.*}, {args.*}. */
|
|
68
|
-
url: string;
|
|
69
|
-
/**
|
|
70
|
-
* Substring; if the current tab URL already contains it, skip navigation.
|
|
71
|
-
* Templated values are expanded the same way as `url` (settings server-side,
|
|
72
|
-
* args at runtime). Omit to always navigate.
|
|
73
|
-
*/
|
|
74
|
-
on_target?: string;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* A tool a browser-side connector exposes to the extension's LLM.
|
|
79
|
-
*
|
|
80
|
-
* The script body runs in the user's tab via chrome.scripting.executeScript
|
|
81
|
-
* (page context, has document/fetch/etc, no chrome.* APIs). The extension is
|
|
82
|
-
* a generic runner — it doesn't know about Mantis or GitLab specifically.
|
|
83
|
-
* Adding a new connector means dropping a manifest with `page` + `script`
|
|
84
|
-
* per tool; no extension rebuild required.
|
|
85
|
-
*
|
|
86
|
-
* See docs/Connector-DeclarativeExtract-Spec.md.
|
|
87
|
-
*/
|
|
88
|
-
export interface ConnectorTool {
|
|
89
|
-
description: string;
|
|
90
|
-
parameters?: Record<string, PluginFieldSchema>;
|
|
91
|
-
/** Requires user confirmation before invoking (e.g. add_comment, close_issue). */
|
|
92
|
-
destructive?: boolean;
|
|
93
|
-
/** Free-form description of the return shape — surfaced to the LLM. */
|
|
94
|
-
returns?: string;
|
|
95
|
-
/** Where to navigate before running the script. */
|
|
96
|
-
page?: ConnectorPage;
|
|
97
|
-
/**
|
|
98
|
-
* JavaScript function body. Receives `args` (the LLM's parsed parameters).
|
|
99
|
-
* Returns a JSON-serializable value. Runs in the user's tab — must be
|
|
100
|
-
* self-contained (no closures over Forge/extension code).
|
|
101
|
-
*/
|
|
102
|
-
script?: string;
|
|
103
|
-
|
|
104
|
-
// ─── Phase 3: multi-protocol tools (server-side execution) ──
|
|
105
|
-
/**
|
|
106
|
-
* Where the tool runs. Default 'browser' (extension runner via bridge).
|
|
107
|
-
* 'browser' — page DOM script via chrome.scripting (legacy default)
|
|
108
|
-
* 'http' — Forge issues an HTTP request server-side
|
|
109
|
-
* 'shell' — Forge spawns a process (arg array, no shell:true)
|
|
110
|
-
*/
|
|
111
|
-
protocol?: 'browser' | 'http' | 'shell';
|
|
112
|
-
/** http protocol: request shape. Templates {base_url}, {settings.*}, {args.*}. */
|
|
113
|
-
request?: HttpRequestSpec;
|
|
114
|
-
/** shell protocol: command + args (each templated independently — no shell injection). */
|
|
115
|
-
command?: string[];
|
|
116
|
-
/** shell protocol: working directory (templated). */
|
|
117
|
-
cwd?: string;
|
|
118
|
-
/** shell protocol: extra env vars (values templated). */
|
|
119
|
-
env?: Record<string, string>;
|
|
120
|
-
/** shell/http: timeout in milliseconds. Default 30000, max 300000. */
|
|
121
|
-
timeout_ms?: number;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
export interface HttpRequestSpec {
|
|
125
|
-
method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'HEAD';
|
|
126
|
-
/** Full URL. Templated. */
|
|
127
|
-
url: string;
|
|
128
|
-
/** Header values are templated. */
|
|
129
|
-
headers?: Record<string, string>;
|
|
130
|
-
/** Query params. Values templated. Appended after any existing ? on url. */
|
|
131
|
-
query?: Record<string, string>;
|
|
132
|
-
/** Body. If string: sent as-is (after templating). If object: JSON.stringify'd; values templated where strings. */
|
|
133
|
-
body?: string | Record<string, unknown>;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* One connector inside a plugin. Most plugins are 1:1 with a single connector
|
|
138
|
-
* declared via top-level `tools`/`host_permissions`/`settings` fields on
|
|
139
|
-
* PluginDefinition. Use `connectors[]` only when one auth covers multiple
|
|
140
|
-
* sibling adapters (Atlassian, Google Workspace, M365).
|
|
141
|
-
*/
|
|
142
|
-
export interface Connector {
|
|
143
|
-
id: string;
|
|
144
|
-
host_permissions?: string[];
|
|
145
|
-
tools: Record<string, ConnectorTool>;
|
|
146
|
-
/** Per-user settings (host URL, default project, etc.) — rendered as a form by the extension. */
|
|
147
|
-
settings?: Record<string, PluginFieldSchema>;
|
|
148
|
-
/**
|
|
149
|
-
* Chrome match pattern. Runner uses this to find/open authenticated tabs
|
|
150
|
-
* for this connector. Supports {base_url}, {settings.*}.
|
|
151
|
-
*/
|
|
152
|
-
host_match?: string;
|
|
153
|
-
/**
|
|
154
|
-
* Substring. If the tab URL contains this after a navigation, treat as
|
|
155
|
-
* "user not logged in" and abort with loginRequired: true.
|
|
156
|
-
*/
|
|
157
|
-
login_redirect?: string;
|
|
158
|
-
/** See PluginDefinition.runner. */
|
|
159
|
-
runner?: PluginRunner;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
56
|
/** Plugin definition (loaded from plugin.yaml) */
|
|
163
57
|
export interface PluginDefinition {
|
|
164
58
|
id: string;
|
|
@@ -168,21 +62,12 @@ export interface PluginDefinition {
|
|
|
168
62
|
author?: string;
|
|
169
63
|
description?: string;
|
|
170
64
|
|
|
171
|
-
/** Classifies the plugin for marketplace
|
|
65
|
+
/** Classifies the plugin for marketplace filtering. */
|
|
172
66
|
category?: PluginCategory;
|
|
173
67
|
|
|
174
|
-
/** Where this plugin's tools execute.
|
|
68
|
+
/** Where this plugin's tools execute. */
|
|
175
69
|
mode?: PluginMode;
|
|
176
70
|
|
|
177
|
-
/**
|
|
178
|
-
* Which extension execution context runs this connector's scripts.
|
|
179
|
-
* Default `main` for back-compat (Mantis, GitLab — permissive-CSP sites).
|
|
180
|
-
* Use `isolated` for sites with strict CSP that blocks `unsafe-eval`
|
|
181
|
-
* (Teams, github.com, banks). Per-plugin, not per-tool — a host has
|
|
182
|
-
* one CSP profile.
|
|
183
|
-
*/
|
|
184
|
-
runner?: PluginRunner;
|
|
185
|
-
|
|
186
71
|
/** Global config — set once when installing the plugin */
|
|
187
72
|
config: Record<string, PluginFieldSchema>;
|
|
188
73
|
|
|
@@ -194,16 +79,6 @@ export interface PluginDefinition {
|
|
|
194
79
|
|
|
195
80
|
/** Default action to run if none specified */
|
|
196
81
|
defaultAction?: string;
|
|
197
|
-
|
|
198
|
-
// ─── Connector fields (category === 'connector') ─────────────────────────
|
|
199
|
-
/** 1:1 sugar — when present, treated as a single connector with id === plugin id. */
|
|
200
|
-
host_permissions?: string[];
|
|
201
|
-
tools?: Record<string, ConnectorTool>;
|
|
202
|
-
settings?: Record<string, PluginFieldSchema>;
|
|
203
|
-
host_match?: string;
|
|
204
|
-
login_redirect?: string;
|
|
205
|
-
/** 1:N escape hatch — only for same-vendor suites with shared auth. */
|
|
206
|
-
connectors?: Connector[];
|
|
207
82
|
}
|
|
208
83
|
|
|
209
84
|
/** Installed plugin instance (definition + user config values) */
|
package/lib/settings.ts
CHANGED
|
@@ -65,6 +65,12 @@ export interface Settings {
|
|
|
65
65
|
manageClaudeConfig: boolean;
|
|
66
66
|
notificationRetentionDays: number;
|
|
67
67
|
skillsRepoUrl: string;
|
|
68
|
+
/**
|
|
69
|
+
* Remote registry for connector manifests. Points to the raw-content
|
|
70
|
+
* URL of a forge-connectors-style repo. Setting empty disables sync
|
|
71
|
+
* (offline only). Default: `aiwatching/forge-connectors`.
|
|
72
|
+
*/
|
|
73
|
+
connectorsRepoUrl: string;
|
|
68
74
|
displayName: string;
|
|
69
75
|
displayEmail: string;
|
|
70
76
|
favoriteProjects: string[];
|
|
@@ -119,6 +125,7 @@ const defaults: Settings = {
|
|
|
119
125
|
manageClaudeConfig: true,
|
|
120
126
|
notificationRetentionDays: 30,
|
|
121
127
|
skillsRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-skills/main',
|
|
128
|
+
connectorsRepoUrl: 'https://raw.githubusercontent.com/aiwatching/forge-connectors/main',
|
|
122
129
|
displayName: 'Forge',
|
|
123
130
|
displayEmail: '',
|
|
124
131
|
favoriteProjects: [],
|
package/lib/skills.ts
CHANGED
|
@@ -27,6 +27,8 @@ interface SkillItem {
|
|
|
27
27
|
installedVersion: string;
|
|
28
28
|
hasUpdate: boolean;
|
|
29
29
|
deletedRemotely: boolean;
|
|
30
|
+
/** 'registry' (synced from forge-skills) or 'local' (uploaded). */
|
|
31
|
+
source: 'registry' | 'local';
|
|
30
32
|
}
|
|
31
33
|
|
|
32
34
|
function db() {
|
|
@@ -118,9 +120,13 @@ export async function syncSkills(): Promise<{ synced: number; enriched: number;
|
|
|
118
120
|
tx();
|
|
119
121
|
|
|
120
122
|
// Step 3: Handle items no longer in registry
|
|
123
|
+
// Skip 'local'-source rows entirely — they were uploaded by the
|
|
124
|
+
// user via /api/skills/install-local and have no upstream
|
|
125
|
+
// counterpart, so absence from registry.json is expected.
|
|
121
126
|
const registryNames = new Set(rawItems.map((s: any) => s.name));
|
|
122
|
-
const dbItems = db().prepare('SELECT name, installed_global, installed_projects FROM skills').all() as any[];
|
|
127
|
+
const dbItems = db().prepare('SELECT name, installed_global, installed_projects, source FROM skills').all() as any[];
|
|
123
128
|
for (const row of dbItems) {
|
|
129
|
+
if (row.source === 'local') continue;
|
|
124
130
|
if (!registryNames.has(row.name)) {
|
|
125
131
|
const hasLocal = !!row.installed_global || JSON.parse(row.installed_projects || '[]').length > 0;
|
|
126
132
|
if (hasLocal) {
|
|
@@ -218,6 +224,7 @@ export function listSkills(): SkillItem[] {
|
|
|
218
224
|
installedVersion,
|
|
219
225
|
hasUpdate: isInstalled && !!registryVersion && !!installedVersion && compareVersions(registryVersion, installedVersion) > 0,
|
|
220
226
|
deletedRemotely: !!r.deleted_remotely,
|
|
227
|
+
source: r.source === 'local' ? 'local' : 'registry',
|
|
221
228
|
};
|
|
222
229
|
});
|
|
223
230
|
}
|
|
@@ -299,6 +306,25 @@ export async function installGlobal(name: string): Promise<void> {
|
|
|
299
306
|
.run(skill.version || '', name);
|
|
300
307
|
}
|
|
301
308
|
|
|
309
|
+
/**
|
|
310
|
+
* Idempotent: if the skill is already installed for projectPath (or
|
|
311
|
+
* globally) and the on-disk version matches the registry, do nothing.
|
|
312
|
+
* Otherwise pull a fresh copy into the project. Used by job dispatch
|
|
313
|
+
* to guarantee that any skill named in `job.skills` is reachable to
|
|
314
|
+
* the spawned task — no friction "install this first" prompt.
|
|
315
|
+
*/
|
|
316
|
+
export async function ensureInstalledInProject(name: string, projectPath: string): Promise<{ installed: boolean; reason: string }> {
|
|
317
|
+
const skill = db().prepare('SELECT * FROM skills WHERE name = ?').get(name) as any;
|
|
318
|
+
if (!skill) return { installed: false, reason: 'skill not found in registry' };
|
|
319
|
+
const existing = (() => { try { return JSON.parse(skill.installed_projects || '[]') as string[]; } catch { return [] as string[]; } })();
|
|
320
|
+
const upToDate = (skill.installed_version || '') === (skill.version || '');
|
|
321
|
+
if (existing.includes(projectPath) && upToDate) {
|
|
322
|
+
return { installed: true, reason: 'already installed at version ' + skill.version };
|
|
323
|
+
}
|
|
324
|
+
await installProject(name, projectPath);
|
|
325
|
+
return { installed: true, reason: existing.includes(projectPath) ? 'updated to ' + skill.version : 'installed' };
|
|
326
|
+
}
|
|
327
|
+
|
|
302
328
|
export async function installProject(name: string, projectPath: string): Promise<void> {
|
|
303
329
|
const skill = db().prepare('SELECT * FROM skills WHERE name = ?').get(name) as any;
|
|
304
330
|
if (!skill) throw new Error(`Skill "${name}" not found`);
|
package/lib/task-manager.ts
CHANGED
|
@@ -44,6 +44,14 @@ function db() {
|
|
|
44
44
|
// Per-task model overrides (used by pipeline to set pipelineModel)
|
|
45
45
|
export const taskModelOverrides = new Map<string, string>();
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* Per-task append-system-prompt overrides. Used by Forge job/pipeline
|
|
49
|
+
* to point the agent at specific skills for this run. Keyed by task
|
|
50
|
+
* id; lives in-process (re-applied via the pipeline state on restart
|
|
51
|
+
* since we re-create the task there).
|
|
52
|
+
*/
|
|
53
|
+
export const taskAppendSystemPromptOverrides = new Map<string, string>();
|
|
54
|
+
|
|
47
55
|
// ─── CRUD ────────────────────────────────────────────────────
|
|
48
56
|
|
|
49
57
|
export function createTask(opts: {
|
|
@@ -351,6 +359,50 @@ async function processNextTask() {
|
|
|
351
359
|
}
|
|
352
360
|
}
|
|
353
361
|
|
|
362
|
+
/**
|
|
363
|
+
* Surface installed-connector PATs as env vars to shell tasks. Lets
|
|
364
|
+
* pipeline shell steps (push-and-mr, gh-issue-fix, ...) use the same
|
|
365
|
+
* credential the user typed into Settings → Connectors instead of
|
|
366
|
+
* relying on `glab auth login` / `gh auth login` having been run
|
|
367
|
+
* separately (and not revoked).
|
|
368
|
+
*
|
|
369
|
+
* Only well-known connectors map to env vars. Token never leaves the
|
|
370
|
+
* server — the bash child inherits the env directly.
|
|
371
|
+
*/
|
|
372
|
+
function connectorEnv(): Record<string, string> {
|
|
373
|
+
try {
|
|
374
|
+
const { getInstalledConnector } = require('./connectors/registry');
|
|
375
|
+
const out: Record<string, string> = {};
|
|
376
|
+
const gitlab = getInstalledConnector('gitlab');
|
|
377
|
+
if (gitlab?.enabled) {
|
|
378
|
+
const tok = typeof gitlab.config?.token === 'string' ? gitlab.config.token.trim() : '';
|
|
379
|
+
if (tok) {
|
|
380
|
+
// glab CLI honours GITLAB_TOKEN; HTTP libs honour CI_JOB_TOKEN-style names too,
|
|
381
|
+
// but GITLAB_TOKEN covers glab + most curl-based scripts in our pipelines.
|
|
382
|
+
out.GITLAB_TOKEN = tok;
|
|
383
|
+
if (typeof gitlab.config?.base_url === 'string' && gitlab.config.base_url) {
|
|
384
|
+
// glab uses GITLAB_URI for the API host on self-hosted instances.
|
|
385
|
+
try {
|
|
386
|
+
const u = new URL(String(gitlab.config.base_url));
|
|
387
|
+
out.GITLAB_URI = u.origin;
|
|
388
|
+
} catch {}
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
const gh = getInstalledConnector('github-api');
|
|
393
|
+
if (gh?.enabled) {
|
|
394
|
+
const tok = typeof gh.config?.token === 'string' ? gh.config.token.trim() : '';
|
|
395
|
+
if (tok) {
|
|
396
|
+
out.GITHUB_TOKEN = tok;
|
|
397
|
+
out.GH_TOKEN = tok; // gh CLI honours either; set both for safety.
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
return out;
|
|
401
|
+
} catch {
|
|
402
|
+
return {};
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
354
406
|
function executeShellTask(task: Task): Promise<void> {
|
|
355
407
|
return new Promise((resolve) => {
|
|
356
408
|
updateTaskStatus(task.id, 'running');
|
|
@@ -365,7 +417,11 @@ function executeShellTask(task: Task): Promise<void> {
|
|
|
365
417
|
const shell = process.env.SHELL && process.env.SHELL.endsWith('bash') ? process.env.SHELL : '/bin/bash';
|
|
366
418
|
const child = spawn(shell, ['-c', task.prompt], {
|
|
367
419
|
cwd: task.projectPath,
|
|
368
|
-
env: {
|
|
420
|
+
env: {
|
|
421
|
+
...process.env,
|
|
422
|
+
PATH: process.env.PATH || '/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin',
|
|
423
|
+
...connectorEnv(),
|
|
424
|
+
},
|
|
369
425
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
370
426
|
});
|
|
371
427
|
|
|
@@ -433,9 +489,13 @@ function executeTask(task: Task): Promise<void> {
|
|
|
433
489
|
conversationId: task.conversationId || undefined,
|
|
434
490
|
skipPermissions: true,
|
|
435
491
|
outputFormat: adapter.config.capabilities?.supportsStreamJson ? 'stream-json' : undefined,
|
|
492
|
+
appendSystemPrompt: taskAppendSystemPromptOverrides.get(task.id),
|
|
436
493
|
});
|
|
437
494
|
|
|
438
|
-
|
|
495
|
+
// Surface connector PATs (GITLAB_TOKEN, GITHUB_TOKEN, …) so the
|
|
496
|
+
// child's Bash invocations (claude's Bash tool, gh/glab shells)
|
|
497
|
+
// pick them up automatically. Same logic as executeShellTask.
|
|
498
|
+
const env = { ...process.env, ...connectorEnv(), ...(spawnOpts.env || {}) };
|
|
439
499
|
delete env.CLAUDECODE;
|
|
440
500
|
|
|
441
501
|
updateTaskStatus(task.id, 'running');
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@aion0/forge",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.2",
|
|
4
4
|
"description": "Unified AI workflow platform — multi-model task orchestration, persistent sessions, web terminal, remote access",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -34,12 +34,14 @@
|
|
|
34
34
|
"@anthropic-ai/sdk": "^0.96.0",
|
|
35
35
|
"@auth/core": "^0.34.3",
|
|
36
36
|
"@modelcontextprotocol/sdk": "^1.28.0",
|
|
37
|
+
"@types/adm-zip": "^0.5.8",
|
|
37
38
|
"@xterm/addon-fit": "^0.11.0",
|
|
38
39
|
"@xterm/addon-search": "^0.16.0",
|
|
39
40
|
"@xterm/addon-unicode11": "^0.9.0",
|
|
40
41
|
"@xterm/addon-webgl": "^0.19.0",
|
|
41
42
|
"@xterm/xterm": "^6.0.0",
|
|
42
43
|
"@xyflow/react": "^12.10.1",
|
|
44
|
+
"adm-zip": "^0.5.17",
|
|
43
45
|
"ai": "^6.0.116",
|
|
44
46
|
"better-sqlite3": "^12.6.2",
|
|
45
47
|
"esbuild": "^0.27.3",
|