@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.
@@ -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
+ }