@dshforge/agent-plugins 0.0.0-stage → 0.1.1
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/README.md +63 -2
- package/cordis.patch.yml +5 -0
- package/lib/client.js +771 -0
- package/lib/discovery.js +191 -0
- package/lib/index.js +45 -0
- package/lib/install.js +199 -0
- package/lib/mcp.js +186 -0
- package/lib/paths.js +47 -0
- package/lib/rpc.js +147 -0
- package/lib/skills.js +54 -0
- package/lib/validation.js +218 -0
- package/package.json +65 -4
- package/schemas/1.0.0/mcp.schema.json +120 -0
- package/schemas/1.0.0/plugin.schema.json +65 -0
package/lib/discovery.js
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { readdir, readFile, realpath, stat } from 'node:fs/promises'
|
|
2
|
+
import { basename, dirname, join } from 'node:path'
|
|
3
|
+
import {
|
|
4
|
+
INVALID_AGENT_PLUGIN, errorMessage, invalidAgentPlugin, isMissingPath,
|
|
5
|
+
parseSkillDocument, validateManifest, validateMcpDocument, validateMcpServer,
|
|
6
|
+
} from './validation.js'
|
|
7
|
+
import { resolveExistingInside } from './paths.js'
|
|
8
|
+
|
|
9
|
+
/** Discover Agent Plugins below one explicit user-owned directory. */
|
|
10
|
+
export async function discoverAgentPlugins({ pluginsDirectory }) {
|
|
11
|
+
const plugins = []
|
|
12
|
+
const diagnostics = []
|
|
13
|
+
let entries
|
|
14
|
+
try {
|
|
15
|
+
entries = await readdir(pluginsDirectory, { withFileTypes: true })
|
|
16
|
+
} catch (error) {
|
|
17
|
+
if (isMissingPath(error)) return { plugins, diagnostics }
|
|
18
|
+
throw error
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
22
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
|
23
|
+
const candidate = join(pluginsDirectory, entry.name)
|
|
24
|
+
let root
|
|
25
|
+
try {
|
|
26
|
+
root = await realpath(candidate)
|
|
27
|
+
if (!(await stat(root)).isDirectory()) continue
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (isMissingPath(error)) continue
|
|
30
|
+
throw error
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let manifest
|
|
34
|
+
try {
|
|
35
|
+
const document = await readJson(root, join(root, 'plugin.json'))
|
|
36
|
+
const validated = validateManifest(document, join(root, 'plugin.json'))
|
|
37
|
+
manifest = validated.manifest
|
|
38
|
+
diagnostics.push(...validated.diagnostics.map(issue => ({ ...issue, plugin: manifest.name })))
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (isMissingPath(error) || error.code === INVALID_AGENT_PLUGIN) {
|
|
41
|
+
const reason = errorMessage(error)
|
|
42
|
+
diagnostics.push({ component: 'manifest', path: join(root, 'plugin.json'), message: reason, plugin: entry.name })
|
|
43
|
+
plugins.push({
|
|
44
|
+
name: entry.name, directoryName: entry.name, root, manifest: null, status: 'invalid', invalidReason: reason,
|
|
45
|
+
skills: [], mcpServers: [],
|
|
46
|
+
})
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
throw error
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const [skills, mcp] = await Promise.all([
|
|
53
|
+
discoverSkills(root, manifest.name, diagnostics),
|
|
54
|
+
discoverMcp(root, manifest, diagnostics),
|
|
55
|
+
])
|
|
56
|
+
plugins.push({
|
|
57
|
+
name: manifest.name,
|
|
58
|
+
directoryName: entry.name,
|
|
59
|
+
root,
|
|
60
|
+
manifest,
|
|
61
|
+
status: 'ready',
|
|
62
|
+
skills,
|
|
63
|
+
mcpServers: mcp.servers,
|
|
64
|
+
mcpPresent: mcp.present,
|
|
65
|
+
...mcp.error === undefined ? {} : { mcpError: mcp.error },
|
|
66
|
+
})
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const seenSkills = new Set()
|
|
70
|
+
for (const plugin of plugins) {
|
|
71
|
+
for (const skill of plugin.skills) {
|
|
72
|
+
if (seenSkills.has(skill.name)) {
|
|
73
|
+
diagnostics.push({
|
|
74
|
+
component: 'skills', path: skill.path, plugin: plugin.name,
|
|
75
|
+
message: `duplicate skill ${JSON.stringify(skill.name)}; the first package in sorted order wins`,
|
|
76
|
+
})
|
|
77
|
+
}
|
|
78
|
+
seenSkills.add(skill.name)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return { plugins, diagnostics }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function discoverSkills(root, pluginName, diagnostics) {
|
|
85
|
+
const skillsRoot = join(root, 'skills')
|
|
86
|
+
let resolvedRoot
|
|
87
|
+
try {
|
|
88
|
+
resolvedRoot = await resolveExistingInside(root, skillsRoot)
|
|
89
|
+
if (!(await stat(resolvedRoot)).isDirectory()) {
|
|
90
|
+
throw invalidAgentPlugin('skills must be a directory when present', skillsRoot)
|
|
91
|
+
}
|
|
92
|
+
} catch (error) {
|
|
93
|
+
if (isMissingPath(error)) return []
|
|
94
|
+
if (error.code === INVALID_AGENT_PLUGIN) {
|
|
95
|
+
diagnostics.push({ component: 'skills', path: skillsRoot, message: errorMessage(error), plugin: pluginName })
|
|
96
|
+
return []
|
|
97
|
+
}
|
|
98
|
+
throw error
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const entries = await readdir(resolvedRoot, { withFileTypes: true })
|
|
102
|
+
const skills = []
|
|
103
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
104
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue
|
|
105
|
+
const directory = join(resolvedRoot, entry.name)
|
|
106
|
+
try {
|
|
107
|
+
const resolvedDirectory = await resolveExistingInside(root, directory)
|
|
108
|
+
if (!(await stat(resolvedDirectory)).isDirectory()) continue
|
|
109
|
+
const path = await resolveExistingInside(root, join(resolvedDirectory, 'SKILL.md'))
|
|
110
|
+
if (!(await stat(path)).isFile()) continue
|
|
111
|
+
const document = parseSkillDocument(await readFile(path, 'utf8'), entry.name, path)
|
|
112
|
+
skills.push({
|
|
113
|
+
name: document.name,
|
|
114
|
+
description: document.description,
|
|
115
|
+
path,
|
|
116
|
+
directory: resolvedDirectory,
|
|
117
|
+
packageDirectory: entry.name,
|
|
118
|
+
...document.license === undefined ? {} : { license: document.license },
|
|
119
|
+
...document.compatibility === undefined ? {} : { compatibility: document.compatibility },
|
|
120
|
+
...document.metadata === undefined ? {} : { metadata: document.metadata },
|
|
121
|
+
...document.allowedTools === undefined ? {} : { allowedTools: document.allowedTools },
|
|
122
|
+
})
|
|
123
|
+
} catch (error) {
|
|
124
|
+
if (isMissingPath(error)) continue
|
|
125
|
+
if (error.code === INVALID_AGENT_PLUGIN) {
|
|
126
|
+
diagnostics.push({ component: 'skills', path: directory, message: errorMessage(error), plugin: pluginName })
|
|
127
|
+
continue
|
|
128
|
+
}
|
|
129
|
+
throw error
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return skills
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async function discoverMcp(root, manifest, diagnostics) {
|
|
136
|
+
const path = join(root, 'mcp.json')
|
|
137
|
+
let document
|
|
138
|
+
try {
|
|
139
|
+
document = await readJson(root, path)
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (isMissingPath(error)) return { servers: [], present: false }
|
|
142
|
+
if (error.code === INVALID_AGENT_PLUGIN) return { servers: [], present: true, error: errorMessage(error) }
|
|
143
|
+
throw error
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
validateMcpDocument(document, manifest.$schema, path)
|
|
148
|
+
} catch (error) {
|
|
149
|
+
if (error.code === INVALID_AGENT_PLUGIN) return { servers: [], present: true, error: errorMessage(error) }
|
|
150
|
+
throw error
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const servers = []
|
|
154
|
+
for (const [name, config] of Object.entries(document.mcpServers).sort(([a], [b]) => a.localeCompare(b))) {
|
|
155
|
+
try {
|
|
156
|
+
validateMcpServer(name, config, path)
|
|
157
|
+
servers.push({ name, ...config })
|
|
158
|
+
} catch (error) {
|
|
159
|
+
if (error.code === INVALID_AGENT_PLUGIN) {
|
|
160
|
+
diagnostics.push({ component: 'mcp', path, message: errorMessage(error), plugin: manifest.name })
|
|
161
|
+
continue
|
|
162
|
+
}
|
|
163
|
+
throw error
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return { servers, present: true }
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Revalidate and read one skill body for an on-demand provider get(). */
|
|
170
|
+
export async function loadAgentSkill({ pluginRoot, skillDirectory, packageDirectory, signal }) {
|
|
171
|
+
const root = await realpath(pluginRoot)
|
|
172
|
+
validateManifest(await readJson(root, join(root, 'plugin.json')), join(root, 'plugin.json'))
|
|
173
|
+
const resolvedSkillsRoot = await resolveExistingInside(root, join(root, 'skills'))
|
|
174
|
+
const directory = await resolveExistingInside(root, skillDirectory)
|
|
175
|
+
if (dirname(directory) !== resolvedSkillsRoot || !(await stat(directory)).isDirectory()) return undefined
|
|
176
|
+
const path = await resolveExistingInside(root, join(directory, 'SKILL.md'))
|
|
177
|
+
if (!(await stat(path)).isFile()) return undefined
|
|
178
|
+
const expectedName = packageDirectory ?? basename(skillDirectory)
|
|
179
|
+
return parseSkillDocument(await readFile(path, { encoding: 'utf8', signal }), expectedName, path)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
async function readJson(root, path) {
|
|
183
|
+
const resolved = await resolveExistingInside(root, path)
|
|
184
|
+
if (!(await stat(resolved)).isFile()) throw invalidAgentPlugin('expected a regular JSON file', path)
|
|
185
|
+
const text = await readFile(resolved, 'utf8')
|
|
186
|
+
try {
|
|
187
|
+
return JSON.parse(text)
|
|
188
|
+
} catch (error) {
|
|
189
|
+
throw invalidAgentPlugin(`invalid JSON: ${errorMessage(error)}`, path)
|
|
190
|
+
}
|
|
191
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { homedir } from 'node:os'
|
|
2
|
+
import { join } from 'node:path'
|
|
3
|
+
import { discoverAgentPlugins } from './discovery.js'
|
|
4
|
+
import { createAgentSkillProvider } from './skills.js'
|
|
5
|
+
import { registerAgentPluginsRpc } from './rpc.js'
|
|
6
|
+
import { mountAgentPluginMcpServers } from './mcp.js'
|
|
7
|
+
import { installAgentPlugin } from './install.js'
|
|
8
|
+
|
|
9
|
+
export const name = 'agent-plugins'
|
|
10
|
+
export const inject = ['skills', 'connection', 'webServer']
|
|
11
|
+
|
|
12
|
+
/** Mount Agent Plugin discovery and the strict Skills provider. */
|
|
13
|
+
export async function apply(ctx) {
|
|
14
|
+
const pluginsDirectory = join(homedir(), '.agents', 'plugins')
|
|
15
|
+
let invalidateSkills = () => {}
|
|
16
|
+
ctx.effect(() => ctx.skills.registerProvider((control) => {
|
|
17
|
+
invalidateSkills = control.invalidate
|
|
18
|
+
return createAgentSkillProvider({ pluginsDirectory })
|
|
19
|
+
}), 'agent-plugins: skills provider')
|
|
20
|
+
const dataDirectory = join(process.env.DSH_HOME ?? join(homedir(), '.dsh'), 'agent-plugins', 'data')
|
|
21
|
+
let catalog = await discoverAgentPlugins({ pluginsDirectory })
|
|
22
|
+
const install = async (spec, signal) => {
|
|
23
|
+
const installed = await installAgentPlugin({ pluginsDirectory, spec, signal })
|
|
24
|
+
catalog = await discoverAgentPlugins({ pluginsDirectory })
|
|
25
|
+
invalidateSkills()
|
|
26
|
+
ctx.emit('plugin-manager/changed', { reason: 'install' })
|
|
27
|
+
const plugin = catalog.plugins.find(entry => entry.directoryName === installed.name && entry.status === 'ready')
|
|
28
|
+
if (plugin !== undefined) await mountAgentPluginMcpServers(ctx, [plugin], { dataDirectory })
|
|
29
|
+
return installed
|
|
30
|
+
}
|
|
31
|
+
let rpcRegistered = false
|
|
32
|
+
for (const issue of catalog.diagnostics) {
|
|
33
|
+
ctx.logger.warn(`[agent-plugins] ${issue.plugin ?? 'catalog'}: ${issue.message}`)
|
|
34
|
+
}
|
|
35
|
+
const register = () => {
|
|
36
|
+
if (rpcRegistered || ctx.get('webServer') === undefined) return
|
|
37
|
+
rpcRegistered = true
|
|
38
|
+
ctx.effect(() => registerAgentPluginsRpc(ctx, () => catalog, { install }), 'agent-plugins: catalog route')
|
|
39
|
+
}
|
|
40
|
+
register()
|
|
41
|
+
ctx.on('internal/service', (name) => {
|
|
42
|
+
if (name === 'webServer') register()
|
|
43
|
+
})
|
|
44
|
+
await mountAgentPluginMcpServers(ctx, catalog.plugins, { dataDirectory })
|
|
45
|
+
}
|
package/lib/install.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import { execa } from 'execa'
|
|
2
|
+
import { cp, mkdir, mkdtemp, readFile, rename, rm, rmdir } from 'node:fs/promises'
|
|
3
|
+
import { randomUUID } from 'node:crypto'
|
|
4
|
+
import { tmpdir } from 'node:os'
|
|
5
|
+
import { join } from 'node:path'
|
|
6
|
+
import { resolveExistingInside } from './paths.js'
|
|
7
|
+
import { errorMessage, invalidAgentPlugin, validateManifest } from './validation.js'
|
|
8
|
+
|
|
9
|
+
const GIT_OPERATION_TIMEOUT_MS = 15_000
|
|
10
|
+
const GIT_PROBE_TIMEOUT_MS = 5_000
|
|
11
|
+
const HTTP_GIT_URL = /^(?:git\+)?https:\/\//i
|
|
12
|
+
const SCP_GIT_URL = /^git@[^:\s]+:[^\s]+$/i
|
|
13
|
+
const SSH_GIT_URL = /^(?:git\+)?ssh:\/\//i
|
|
14
|
+
const FILE_GIT_URL = /^(?:git\+)?file:\/\//i
|
|
15
|
+
const PATH_PARAMETER = /&path(?:=|:)([^#]*)$/
|
|
16
|
+
const PATH_SEGMENT = /^[A-Za-z0-9._@+-]+$/
|
|
17
|
+
const REF_PATTERN = /^[A-Za-z0-9._~^/:@+-]+$/
|
|
18
|
+
|
|
19
|
+
/** Parse a DSH-compatible Git install spec with optional ref and path parameter. */
|
|
20
|
+
export function parseGitInstallSpec(raw) {
|
|
21
|
+
const input = raw.trim()
|
|
22
|
+
if (input === '' || input.length > 2048 || input.startsWith('-')) {
|
|
23
|
+
throw installError('agent-plugins/invalid-spec', 'Git install spec must be a non-empty value of at most 2048 characters')
|
|
24
|
+
}
|
|
25
|
+
let base = input
|
|
26
|
+
let path
|
|
27
|
+
const pathMatch = PATH_PARAMETER.exec(base)
|
|
28
|
+
if (pathMatch) {
|
|
29
|
+
path = normalizePackagePath(pathMatch[1])
|
|
30
|
+
base = base.slice(0, pathMatch.index)
|
|
31
|
+
}
|
|
32
|
+
const hashAt = base.indexOf('#')
|
|
33
|
+
const ref = hashAt === -1 ? undefined : base.slice(hashAt + 1)
|
|
34
|
+
const address = hashAt === -1 ? base : base.slice(0, hashAt)
|
|
35
|
+
if (ref !== undefined && !REF_PATTERN.test(ref)) {
|
|
36
|
+
throw installError('agent-plugins/invalid-spec', 'Git ref contains unsupported characters')
|
|
37
|
+
}
|
|
38
|
+
if (HTTP_GIT_URL.test(address)) {
|
|
39
|
+
const url = new URL(address.replace(/^git\+/i, ''))
|
|
40
|
+
if (url.username !== '' || url.password !== '' || url.search !== '' || url.hostname === '') {
|
|
41
|
+
throw installError('agent-plugins/invalid-spec', 'HTTPS Git URL must not contain credentials or a query')
|
|
42
|
+
}
|
|
43
|
+
if (url.pathname.split('/').filter(Boolean).length < 2 || /\.(?:tgz|tar\.gz)$/i.test(url.pathname)) {
|
|
44
|
+
throw installError('agent-plugins/invalid-spec', 'HTTPS Git URL must name a repository, not a tarball')
|
|
45
|
+
}
|
|
46
|
+
return { kind: 'https', url: `${url.href}${url.href.endsWith('.git') ? '' : '.git'}`, path, ref }
|
|
47
|
+
}
|
|
48
|
+
if (SCP_GIT_URL.test(address)) return { kind: 'scp', url: address, path, ref }
|
|
49
|
+
if (SSH_GIT_URL.test(address)) {
|
|
50
|
+
const url = new URL(address.replace(/^git\+/i, ''))
|
|
51
|
+
if (url.hostname === '' || url.pathname === '' || url.password !== '' || url.search !== '') {
|
|
52
|
+
throw installError('agent-plugins/invalid-spec', 'SSH Git URL must name a host and repository without credentials or a query')
|
|
53
|
+
}
|
|
54
|
+
return { kind: 'ssh', url: address.replace(/^git\+/i, ''), path, ref }
|
|
55
|
+
}
|
|
56
|
+
if (FILE_GIT_URL.test(address)) {
|
|
57
|
+
const url = new URL(address.replace(/^git\+/i, ''))
|
|
58
|
+
if (url.hostname !== '' || url.pathname === '' || url.search !== '') {
|
|
59
|
+
throw installError('agent-plugins/invalid-spec', 'Local file Git URL must have no host or query')
|
|
60
|
+
}
|
|
61
|
+
return { kind: 'file', url: address.replace(/^git\+/i, ''), path, ref }
|
|
62
|
+
}
|
|
63
|
+
throw installError('agent-plugins/invalid-spec', 'Expected an HTTPS .git URL, an SCP-style git@host:path, or an SSH Git URL')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Install one Agent Plugin without requiring package.json or running install scripts. */
|
|
67
|
+
export async function installAgentPlugin({ pluginsDirectory, spec, signal }) {
|
|
68
|
+
const parsed = parseGitInstallSpec(spec)
|
|
69
|
+
const temporary = await mkdtemp(join(tmpdir(), 'agent-plugin-install-'))
|
|
70
|
+
try {
|
|
71
|
+
const repository = join(temporary, 'repo')
|
|
72
|
+
const transport = await chooseGitTransport(parsed, temporary, signal)
|
|
73
|
+
await runGit(temporary, ['init', '--quiet', repository], signal)
|
|
74
|
+
await runGit(temporary, ['-C', repository, 'remote', 'add', 'origin', transport], signal)
|
|
75
|
+
if (parsed.path !== undefined) {
|
|
76
|
+
await runGit(temporary, ['-C', repository, 'sparse-checkout', 'set', parsed.path], signal)
|
|
77
|
+
}
|
|
78
|
+
await runGit(temporary, ['-C', repository, 'fetch', '--depth', '1', 'origin', parsed.ref ?? 'HEAD'], signal)
|
|
79
|
+
await runGit(temporary, ['-C', repository, 'checkout', '--detach', 'FETCH_HEAD'], signal)
|
|
80
|
+
|
|
81
|
+
const root = await resolveExistingInside(repository, join(repository, parsed.path ?? ''))
|
|
82
|
+
const manifestPath = join(root, 'plugin.json')
|
|
83
|
+
let document
|
|
84
|
+
try {
|
|
85
|
+
document = JSON.parse(await readFile(manifestPath, 'utf8'))
|
|
86
|
+
} catch (error) {
|
|
87
|
+
throw invalidAgentPlugin(`invalid plugin.json: ${errorMessage(error)}`, manifestPath)
|
|
88
|
+
}
|
|
89
|
+
const { manifest } = validateManifest(document, manifestPath)
|
|
90
|
+
await mkdir(pluginsDirectory, { recursive: true })
|
|
91
|
+
const destination = join(pluginsDirectory, manifest.name)
|
|
92
|
+
const staging = join(pluginsDirectory, `.${manifest.name}.install-${randomUUID()}`)
|
|
93
|
+
await cp(root, staging, {
|
|
94
|
+
recursive: true, force: false, errorOnExist: true, verbatimSymlinks: true,
|
|
95
|
+
filter: source => source !== join(root, '.git'),
|
|
96
|
+
})
|
|
97
|
+
try {
|
|
98
|
+
await mkdir(destination)
|
|
99
|
+
await rmdir(destination)
|
|
100
|
+
await rename(staging, destination)
|
|
101
|
+
} catch (error) {
|
|
102
|
+
await rm(staging, { recursive: true, force: true })
|
|
103
|
+
if (error.code === 'EEXIST' || error.code === 'ENOTEMPTY') {
|
|
104
|
+
throw installError('agent-plugins/already-installed', `Agent Plugin ${manifest.name} is already installed`)
|
|
105
|
+
}
|
|
106
|
+
throw error
|
|
107
|
+
}
|
|
108
|
+
return { name: manifest.name, root: destination, manifest }
|
|
109
|
+
} finally {
|
|
110
|
+
await rm(temporary, { recursive: true, force: true })
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function normalizePackagePath(value) {
|
|
115
|
+
const path = value.replace(/^\/+/, '')
|
|
116
|
+
if (path === '' || path.includes('\\') || path.split('/').some(segment => segment === '' || segment === '.' || segment === '..' || !PATH_SEGMENT.test(segment))) {
|
|
117
|
+
throw installError('agent-plugins/invalid-spec', 'Git path must be a relative directory without . or .. segments')
|
|
118
|
+
}
|
|
119
|
+
return path
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function chooseGitTransport(spec, cwd, signal, probe = readable) {
|
|
123
|
+
if (spec.kind === 'https') {
|
|
124
|
+
if (await probe(spec.url, cwd, signal)) return spec.url
|
|
125
|
+
const ssh = sshGitSpec(spec)
|
|
126
|
+
if (signal?.aborted) throw installError('agent-plugins/cancelled', 'Git installation was cancelled')
|
|
127
|
+
if (ssh !== undefined && await probe(ssh, cwd, signal)) return ssh
|
|
128
|
+
if (signal?.aborted) throw installError('agent-plugins/cancelled', 'Git installation was cancelled')
|
|
129
|
+
return spec.url
|
|
130
|
+
}
|
|
131
|
+
if (spec.kind === 'scp' || spec.kind === 'ssh') {
|
|
132
|
+
if (await probe(spec.url, cwd, signal)) return spec.url
|
|
133
|
+
const https = httpsGitSpec(spec)
|
|
134
|
+
if (signal?.aborted) throw installError('agent-plugins/cancelled', 'Git installation was cancelled')
|
|
135
|
+
if (https !== undefined && await probe(https, cwd, signal)) return https
|
|
136
|
+
if (signal?.aborted) throw installError('agent-plugins/cancelled', 'Git installation was cancelled')
|
|
137
|
+
}
|
|
138
|
+
return spec.url
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function httpsGitSpec(spec) {
|
|
142
|
+
if (spec.kind === 'scp') {
|
|
143
|
+
const match = /^git@([^:]+):(.+)$/.exec(spec.url)
|
|
144
|
+
return match === null ? undefined : `https://${match[1]}/${match[2]}`
|
|
145
|
+
}
|
|
146
|
+
const url = new URL(spec.url)
|
|
147
|
+
if (url.hostname === '' || url.pathname === '') return undefined
|
|
148
|
+
return `https://${url.hostname}${url.pathname}`
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function sshGitSpec(spec) {
|
|
152
|
+
const url = new URL(spec.url)
|
|
153
|
+
if (url.username !== '' || url.password !== '' || url.search !== '') return undefined
|
|
154
|
+
return `git@${url.hostname}:${url.pathname.replace(/^\//, '')}`
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
async function readable(repository, cwd, signal) {
|
|
158
|
+
try {
|
|
159
|
+
await runGit(cwd, ['-c', 'core.askPass=', 'ls-remote', '--exit-code', '--', repository, 'HEAD'], signal, GIT_PROBE_TIMEOUT_MS)
|
|
160
|
+
return true
|
|
161
|
+
} catch {
|
|
162
|
+
return false
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async function runGit(cwd, args, signal, timeoutMs = GIT_OPERATION_TIMEOUT_MS) {
|
|
167
|
+
const result = await execa('git', args, {
|
|
168
|
+
cwd,
|
|
169
|
+
env: {
|
|
170
|
+
...process.env,
|
|
171
|
+
LC_ALL: 'C',
|
|
172
|
+
GIT_TERMINAL_PROMPT: '0',
|
|
173
|
+
GCM_INTERACTIVE: 'never',
|
|
174
|
+
GIT_ASKPASS: '',
|
|
175
|
+
SSH_ASKPASS: '',
|
|
176
|
+
SSH_ASKPASS_REQUIRE: 'never',
|
|
177
|
+
},
|
|
178
|
+
extendEnv: false,
|
|
179
|
+
stdin: 'ignore',
|
|
180
|
+
stdout: 'pipe',
|
|
181
|
+
stderr: 'pipe',
|
|
182
|
+
reject: false,
|
|
183
|
+
timeout: timeoutMs,
|
|
184
|
+
cancelSignal: signal,
|
|
185
|
+
killDescendants: true,
|
|
186
|
+
killSignal: 'SIGKILL',
|
|
187
|
+
})
|
|
188
|
+
if (result.failed) {
|
|
189
|
+
const detail = (result.stderr || result.stdout || result.shortMessage || 'git command failed').trim().slice(-2000)
|
|
190
|
+
throw installError('agent-plugins/git-failed', detail)
|
|
191
|
+
}
|
|
192
|
+
return result
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function installError(code, message) {
|
|
196
|
+
const error = new Error(message)
|
|
197
|
+
error.code = code
|
|
198
|
+
return error
|
|
199
|
+
}
|
package/lib/mcp.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { createHash, randomBytes } from 'node:crypto'
|
|
2
|
+
import { createServer } from 'node:http'
|
|
3
|
+
import { Readable } from 'node:stream'
|
|
4
|
+
import { pipeline } from 'node:stream/promises'
|
|
5
|
+
import { mkdir } from 'node:fs/promises'
|
|
6
|
+
import { join, resolve } from 'node:path'
|
|
7
|
+
import * as McpClient from '@deepseek-ai/dsh-mcp-client'
|
|
8
|
+
import { resolveLaunchInside } from './paths.js'
|
|
9
|
+
import { errorMessage, invalidAgentPlugin } from './validation.js'
|
|
10
|
+
|
|
11
|
+
/** Derive a stable DSH-safe server identity from package directory and server key. */
|
|
12
|
+
export function mcpServerName(packageDirectory, serverKey) {
|
|
13
|
+
const identity = `${packageDirectory}/${serverKey}`
|
|
14
|
+
const suffix = createHash('sha256').update(identity).digest('hex').slice(0, 8)
|
|
15
|
+
const normalized = identity.replace(/[^A-Za-z0-9_-]/g, '_').replace(/^_+|_+$/g, '') || 'agent'
|
|
16
|
+
return `${normalized.slice(0, 32 - suffix.length - 1)}_${suffix}`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Expand both plugin variables once, without rescanning replacement text. */
|
|
20
|
+
export function expandPluginVariables(value, pluginRoot, pluginData) {
|
|
21
|
+
return value.replace(/\$\{PLUGIN_(ROOT|DATA)\}/g, (_match, name) => name === 'ROOT' ? pluginRoot : pluginData)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Translate one validated Agent Plugins server into the existing DSH client config. */
|
|
25
|
+
export async function createMcpClientConfig({ plugin, server, dataDirectory }) {
|
|
26
|
+
const serverName = mcpServerName(plugin.directoryName, server.name)
|
|
27
|
+
if (server.type === 'streamable-http') {
|
|
28
|
+
return McpClient.Config({
|
|
29
|
+
transport: 'streamable-http',
|
|
30
|
+
serverName,
|
|
31
|
+
url: server.url,
|
|
32
|
+
headers: server.headers ?? {},
|
|
33
|
+
failOnStartupError: false,
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
if (server.type !== 'stdio') {
|
|
37
|
+
throw invalidAgentPlugin(`unsupported MCP transport ${JSON.stringify(server.type)}`, join(plugin.root, 'mcp.json'))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const pluginRoot = plugin.root
|
|
41
|
+
const pluginData = join(dataDirectory, plugin.directoryName)
|
|
42
|
+
await mkdir(pluginData, { recursive: true })
|
|
43
|
+
const command = server.command.startsWith('./')
|
|
44
|
+
? await resolveLaunchInside(pluginRoot, resolve(pluginRoot, server.command))
|
|
45
|
+
: server.command
|
|
46
|
+
const rawCwd = server.cwd ?? '${PLUGIN_ROOT}'
|
|
47
|
+
const expandedCwd = expandPluginVariables(rawCwd, pluginRoot, pluginData)
|
|
48
|
+
const cwdRoot = rawCwd.startsWith('${PLUGIN_DATA}') ? pluginData : pluginRoot
|
|
49
|
+
const cwd = await resolveLaunchInside(cwdRoot, resolve(cwdRoot, expandedCwd))
|
|
50
|
+
const args = (server.args ?? []).map(value => expandPluginVariables(value, pluginRoot, pluginData))
|
|
51
|
+
const env = Object.fromEntries(Object.entries(server.env ?? {})
|
|
52
|
+
.map(([name, value]) => [name, expandPluginVariables(value, pluginRoot, pluginData)]))
|
|
53
|
+
env.PLUGIN_ROOT = pluginRoot
|
|
54
|
+
env.PLUGIN_DATA = pluginData
|
|
55
|
+
|
|
56
|
+
return McpClient.Config({
|
|
57
|
+
transport: 'stdio',
|
|
58
|
+
serverName,
|
|
59
|
+
command,
|
|
60
|
+
args,
|
|
61
|
+
env,
|
|
62
|
+
cwd,
|
|
63
|
+
failOnStartupError: false,
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Mount every valid server independently; one failure never blocks siblings. */
|
|
68
|
+
export async function mountAgentPluginMcpServers(ctx, plugins, { dataDirectory }) {
|
|
69
|
+
const proxies = []
|
|
70
|
+
const lifecycle = { disposed: false }
|
|
71
|
+
ctx.effect(() => async () => {
|
|
72
|
+
lifecycle.disposed = true
|
|
73
|
+
await Promise.all(proxies.map(proxy => proxy.close()))
|
|
74
|
+
}, 'agent-plugins: redirect-denying HTTP forwarders')
|
|
75
|
+
const starts = []
|
|
76
|
+
for (const plugin of plugins) {
|
|
77
|
+
for (const server of plugin.mcpServers) {
|
|
78
|
+
starts.push((async () => {
|
|
79
|
+
let proxy
|
|
80
|
+
try {
|
|
81
|
+
const config = await createMcpClientConfig({ plugin, server, dataDirectory })
|
|
82
|
+
const guarded = await guardMcpEndpoint(server, config, proxies, lifecycle)
|
|
83
|
+
proxy = guarded.proxy
|
|
84
|
+
if (lifecycle.disposed) throw new Error('Agent Plugins mount was disposed during startup')
|
|
85
|
+
return await ctx.plugin(McpClient, guarded.config)
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (proxy) {
|
|
88
|
+
const index = proxies.indexOf(proxy)
|
|
89
|
+
if (index >= 0) proxies.splice(index, 1)
|
|
90
|
+
await proxy.close().catch(closeError => {
|
|
91
|
+
ctx.logger.warn(`[agent-plugins] MCP ${plugin.name}/${server.name}: redirect guard cleanup failed: ${errorMessage(closeError)}`)
|
|
92
|
+
})
|
|
93
|
+
}
|
|
94
|
+
ctx.logger.warn(`[agent-plugins] MCP ${plugin.name}/${server.name}: ${errorMessage(error)}`)
|
|
95
|
+
return undefined
|
|
96
|
+
}
|
|
97
|
+
})())
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return (await Promise.all(starts)).filter(Boolean)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Refuse every redirect response for credentialed endpoints, per actual response. */
|
|
104
|
+
async function guardMcpEndpoint(server, config, proxies, lifecycle) {
|
|
105
|
+
if (server.type !== 'streamable-http' || Object.keys(server.headers ?? {}).length === 0) return { config, proxy: undefined }
|
|
106
|
+
const proxy = await createRedirectGuard(server.url)
|
|
107
|
+
if (lifecycle.disposed) {
|
|
108
|
+
await proxy.close()
|
|
109
|
+
throw new Error('Agent Plugins mount was disposed during proxy creation')
|
|
110
|
+
}
|
|
111
|
+
proxies.push(proxy)
|
|
112
|
+
return { config: { ...config, url: proxy.url }, proxy }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function createRedirectGuard(targetUrl) {
|
|
116
|
+
const token = randomBytes(16).toString('hex')
|
|
117
|
+
const controllers = new Set()
|
|
118
|
+
const server = createServer(async (request, response) => {
|
|
119
|
+
const requestUrl = new URL(request.url, 'http://127.0.0.1')
|
|
120
|
+
if (requestUrl.pathname !== `/${token}`) {
|
|
121
|
+
response.writeHead(404).end()
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
const upstreamUrl = new URL(targetUrl)
|
|
125
|
+
if (requestUrl.search) upstreamUrl.search = requestUrl.search
|
|
126
|
+
const controller = new AbortController()
|
|
127
|
+
controllers.add(controller)
|
|
128
|
+
response.on('close', () => controller.abort())
|
|
129
|
+
try {
|
|
130
|
+
const headers = { ...request.headers }
|
|
131
|
+
delete headers.host
|
|
132
|
+
delete headers.connection
|
|
133
|
+
delete headers['content-length']
|
|
134
|
+
delete headers['transfer-encoding']
|
|
135
|
+
const upstream = await fetch(upstreamUrl, {
|
|
136
|
+
method: request.method,
|
|
137
|
+
headers,
|
|
138
|
+
body: ['GET', 'HEAD'].includes(request.method) ? undefined : request,
|
|
139
|
+
duplex: 'half',
|
|
140
|
+
redirect: 'manual',
|
|
141
|
+
signal: controller.signal,
|
|
142
|
+
})
|
|
143
|
+
if ([301, 302, 303, 307, 308].includes(upstream.status)) {
|
|
144
|
+
response.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
|
145
|
+
response.end('Agent Plugins redirect with configured headers refused')
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
const responseHeaders = {}
|
|
149
|
+
for (const [name, value] of upstream.headers) {
|
|
150
|
+
if (!['connection', 'keep-alive', 'transfer-encoding', 'content-length', 'content-encoding'].includes(name)) {
|
|
151
|
+
responseHeaders[name] = value
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
response.writeHead(upstream.status, responseHeaders)
|
|
155
|
+
if (upstream.body) await pipeline(Readable.fromWeb(upstream.body), response, { signal: controller.signal })
|
|
156
|
+
else response.end()
|
|
157
|
+
} catch (error) {
|
|
158
|
+
if (!controller.signal.aborted) {
|
|
159
|
+
if (response.headersSent) {
|
|
160
|
+
response.destroy()
|
|
161
|
+
} else {
|
|
162
|
+
response.writeHead(502, { 'content-type': 'text/plain; charset=utf-8' })
|
|
163
|
+
response.end('MCP upstream request failed')
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
} finally {
|
|
167
|
+
controllers.delete(controller)
|
|
168
|
+
}
|
|
169
|
+
})
|
|
170
|
+
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
171
|
+
const address = server.address()
|
|
172
|
+
if (!address || typeof address === 'string') throw new Error('unable to bind MCP redirect guard')
|
|
173
|
+
let closed = false
|
|
174
|
+
return {
|
|
175
|
+
url: `http://127.0.0.1:${address.port}/${token}`,
|
|
176
|
+
async close() {
|
|
177
|
+
if (closed) return
|
|
178
|
+
closed = true
|
|
179
|
+
for (const controller of controllers) controller.abort()
|
|
180
|
+
server.close()
|
|
181
|
+
server.closeAllConnections()
|
|
182
|
+
server.closeIdleConnections()
|
|
183
|
+
server.unref()
|
|
184
|
+
},
|
|
185
|
+
}
|
|
186
|
+
}
|
package/lib/paths.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { realpath } from 'node:fs/promises'
|
|
2
|
+
import { basename, dirname, isAbsolute, relative, resolve, sep } from 'node:path'
|
|
3
|
+
import { invalidAgentPlugin, isMissingPath } from './validation.js'
|
|
4
|
+
|
|
5
|
+
/** Whether target resolves lexically inside root, including root itself. */
|
|
6
|
+
export function pathInside(root, target) {
|
|
7
|
+
const child = relative(root, target)
|
|
8
|
+
return child === '' || (child !== '..' && !child.startsWith(`..${sep}`) && !isAbsolute(child))
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Resolve an existing path and require it to remain inside the resolved root. */
|
|
12
|
+
export async function resolveExistingInside(root, candidate) {
|
|
13
|
+
const resolvedRoot = await realpath(root)
|
|
14
|
+
const resolvedCandidate = await realpath(candidate)
|
|
15
|
+
if (!pathInside(resolvedRoot, resolvedCandidate)) {
|
|
16
|
+
throw invalidAgentPlugin('package path resolves outside the plugin root', candidate)
|
|
17
|
+
}
|
|
18
|
+
return resolvedCandidate
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Resolve a launch path even when its final segments do not exist yet. */
|
|
22
|
+
export async function resolveLaunchInside(root, candidate) {
|
|
23
|
+
const resolvedRoot = await realpath(root)
|
|
24
|
+
const resolvedCandidate = await canonicalizeWithMissing(candidate)
|
|
25
|
+
if (!pathInside(resolvedRoot, resolvedCandidate)) {
|
|
26
|
+
throw invalidAgentPlugin('launch path resolves outside its allowed root', candidate)
|
|
27
|
+
}
|
|
28
|
+
return resolvedCandidate
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function canonicalizeWithMissing(candidate) {
|
|
32
|
+
const absolute = resolve(candidate)
|
|
33
|
+
const suffix = []
|
|
34
|
+
let current = absolute
|
|
35
|
+
while (true) {
|
|
36
|
+
try {
|
|
37
|
+
const resolved = await realpath(current)
|
|
38
|
+
return suffix.length === 0 ? resolved : resolve(resolved, ...suffix)
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (!isMissingPath(error)) throw error
|
|
41
|
+
const parent = dirname(current)
|
|
42
|
+
if (parent === current) throw error
|
|
43
|
+
suffix.unshift(basename(current))
|
|
44
|
+
current = parent
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|