@namzu/sdk 3.0.0 → 3.2.0
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/CHANGELOG.md +223 -0
- package/dist/advisory/__tests__/consultation-context.test.d.ts +2 -0
- package/dist/advisory/__tests__/consultation-context.test.d.ts.map +1 -0
- package/dist/advisory/__tests__/consultation-context.test.js +124 -0
- package/dist/advisory/__tests__/consultation-context.test.js.map +1 -0
- package/dist/advisory/context.d.ts +25 -0
- package/dist/advisory/context.d.ts.map +1 -1
- package/dist/advisory/context.js +18 -0
- package/dist/advisory/context.js.map +1 -1
- package/dist/advisory/executor.d.ts.map +1 -1
- package/dist/advisory/executor.js +25 -3
- package/dist/advisory/executor.js.map +1 -1
- package/dist/compaction/__tests__/context-reducer.test.d.ts +2 -0
- package/dist/compaction/__tests__/context-reducer.test.d.ts.map +1 -0
- package/dist/compaction/__tests__/context-reducer.test.js +197 -0
- package/dist/compaction/__tests__/context-reducer.test.js.map +1 -0
- package/dist/compaction/factory.d.ts +7 -0
- package/dist/compaction/factory.d.ts.map +1 -1
- package/dist/compaction/factory.js +7 -0
- package/dist/compaction/factory.js.map +1 -1
- package/dist/compaction/index.d.ts +2 -0
- package/dist/compaction/index.d.ts.map +1 -1
- package/dist/compaction/index.js +1 -0
- package/dist/compaction/index.js.map +1 -1
- package/dist/compaction/interface.d.ts +13 -0
- package/dist/compaction/interface.d.ts.map +1 -1
- package/dist/compaction/managers/null.d.ts +3 -0
- package/dist/compaction/managers/null.d.ts.map +1 -1
- package/dist/compaction/managers/null.js +3 -0
- package/dist/compaction/managers/null.js.map +1 -1
- package/dist/compaction/managers/slidingWindow.d.ts +6 -0
- package/dist/compaction/managers/slidingWindow.d.ts.map +1 -1
- package/dist/compaction/managers/slidingWindow.js +6 -0
- package/dist/compaction/managers/slidingWindow.js.map +1 -1
- package/dist/compaction/managers/structured.d.ts +10 -0
- package/dist/compaction/managers/structured.d.ts.map +1 -1
- package/dist/compaction/managers/structured.js +10 -0
- package/dist/compaction/managers/structured.js.map +1 -1
- package/dist/compaction/reducer.d.ts +86 -0
- package/dist/compaction/reducer.d.ts.map +1 -0
- package/dist/compaction/reducer.js +77 -0
- package/dist/compaction/reducer.js.map +1 -0
- package/dist/connector/builtins/__tests__/oauth2-auth.test.d.ts +2 -0
- package/dist/connector/builtins/__tests__/oauth2-auth.test.d.ts.map +1 -0
- package/dist/connector/builtins/__tests__/oauth2-auth.test.js +54 -0
- package/dist/connector/builtins/__tests__/oauth2-auth.test.js.map +1 -0
- package/dist/connector/builtins/http.d.ts.map +1 -1
- package/dist/connector/builtins/http.js +24 -2
- package/dist/connector/builtins/http.js.map +1 -1
- package/dist/connector/builtins/http.test.js +18 -2
- package/dist/connector/builtins/http.test.js.map +1 -1
- package/dist/connector/index.d.ts +2 -2
- package/dist/connector/index.d.ts.map +1 -1
- package/dist/connector/index.js +1 -1
- package/dist/connector/index.js.map +1 -1
- package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.d.ts +2 -0
- package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.d.ts.map +1 -0
- package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.js +214 -0
- package/dist/connector/mcp/__tests__/prompts-and-lifecycle.test.js.map +1 -0
- package/dist/connector/mcp/client.d.ts +52 -1
- package/dist/connector/mcp/client.d.ts.map +1 -1
- package/dist/connector/mcp/client.js +86 -0
- package/dist/connector/mcp/client.js.map +1 -1
- package/dist/connector/mcp/discovery.d.ts +12 -1
- package/dist/connector/mcp/discovery.d.ts.map +1 -1
- package/dist/connector/mcp/discovery.js +19 -4
- package/dist/connector/mcp/discovery.js.map +1 -1
- package/dist/connector/mcp/index.d.ts +2 -2
- package/dist/connector/mcp/index.d.ts.map +1 -1
- package/dist/connector/mcp/index.js +1 -1
- package/dist/connector/mcp/index.js.map +1 -1
- package/dist/connector/mcp/server.d.ts +42 -1
- package/dist/connector/mcp/server.d.ts.map +1 -1
- package/dist/connector/mcp/server.js +77 -4
- package/dist/connector/mcp/server.js.map +1 -1
- package/dist/manager/agent/__tests__/depth-limit-authority.test.d.ts +2 -0
- package/dist/manager/agent/__tests__/depth-limit-authority.test.d.ts.map +1 -0
- package/dist/manager/agent/__tests__/depth-limit-authority.test.js +58 -0
- package/dist/manager/agent/__tests__/depth-limit-authority.test.js.map +1 -0
- package/dist/plugin/__tests__/discovery-scopes.test.d.ts +2 -0
- package/dist/plugin/__tests__/discovery-scopes.test.d.ts.map +1 -0
- package/dist/plugin/__tests__/discovery-scopes.test.js +97 -0
- package/dist/plugin/__tests__/discovery-scopes.test.js.map +1 -0
- package/dist/plugin/__tests__/enable-contributions.test.js +5 -1
- package/dist/plugin/__tests__/enable-contributions.test.js.map +1 -1
- package/dist/plugin/__tests__/mcp-admission.test.d.ts +2 -0
- package/dist/plugin/__tests__/mcp-admission.test.d.ts.map +1 -0
- package/dist/plugin/__tests__/mcp-admission.test.js +192 -0
- package/dist/plugin/__tests__/mcp-admission.test.js.map +1 -0
- package/dist/plugin/lifecycle.d.ts +41 -0
- package/dist/plugin/lifecycle.d.ts.map +1 -1
- package/dist/plugin/lifecycle.js +29 -1
- package/dist/plugin/lifecycle.js.map +1 -1
- package/dist/plugin/loader.d.ts +39 -3
- package/dist/plugin/loader.d.ts.map +1 -1
- package/dist/plugin/loader.js +37 -4
- package/dist/plugin/loader.js.map +1 -1
- package/dist/public-runtime.d.ts +5 -3
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +11 -3
- package/dist/public-runtime.js.map +1 -1
- package/dist/public-types.d.ts +2 -2
- package/dist/public-types.d.ts.map +1 -1
- package/dist/rag/__tests__/namespace-isolation.test.d.ts +2 -0
- package/dist/rag/__tests__/namespace-isolation.test.d.ts.map +1 -0
- package/dist/rag/__tests__/namespace-isolation.test.js +80 -0
- package/dist/rag/__tests__/namespace-isolation.test.js.map +1 -0
- package/dist/rag/ingestion.d.ts.map +1 -1
- package/dist/rag/ingestion.js +1 -0
- package/dist/rag/ingestion.js.map +1 -1
- package/dist/rag/retriever.d.ts.map +1 -1
- package/dist/rag/retriever.js +2 -0
- package/dist/rag/retriever.js.map +1 -1
- package/dist/rag/vector-store.d.ts.map +1 -1
- package/dist/rag/vector-store.js +6 -0
- package/dist/rag/vector-store.js.map +1 -1
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +113 -109
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/runtime/query/__tests__/capability-negotiation.test.js +59 -0
- package/dist/runtime/query/__tests__/capability-negotiation.test.js.map +1 -1
- package/dist/runtime/query/__tests__/per-step-skills.test.d.ts +10 -0
- package/dist/runtime/query/__tests__/per-step-skills.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/per-step-skills.test.js +122 -0
- package/dist/runtime/query/__tests__/per-step-skills.test.js.map +1 -0
- package/dist/runtime/query/__tests__/per-step-tool-choice.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/per-step-tool-choice.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/per-step-tool-choice.test.js +153 -0
- package/dist/runtime/query/__tests__/per-step-tool-choice.test.js.map +1 -0
- package/dist/runtime/query/__tests__/resume-run.test.d.ts +2 -0
- package/dist/runtime/query/__tests__/resume-run.test.d.ts.map +1 -0
- package/dist/runtime/query/__tests__/resume-run.test.js +211 -0
- package/dist/runtime/query/__tests__/resume-run.test.js.map +1 -0
- package/dist/runtime/query/executor.d.ts +1 -0
- package/dist/runtime/query/executor.d.ts.map +1 -1
- package/dist/runtime/query/executor.js +10 -1
- package/dist/runtime/query/executor.js.map +1 -1
- package/dist/runtime/query/index.d.ts +10 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +26 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/iteration/index.d.ts.map +1 -1
- package/dist/runtime/query/iteration/index.js +118 -34
- package/dist/runtime/query/iteration/index.js.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction-model-routing.test.d.ts +2 -0
- package/dist/runtime/query/iteration/phases/compaction-model-routing.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/compaction-model-routing.test.js +96 -0
- package/dist/runtime/query/iteration/phases/compaction-model-routing.test.js.map +1 -0
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +95 -5
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.d.ts +2 -0
- package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.js +180 -0
- package/dist/runtime/query/iteration/phases/context-reducer-dispatch.test.js.map +1 -0
- package/dist/runtime/query/iteration/phases/context.d.ts +9 -0
- package/dist/runtime/query/iteration/phases/context.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/context.js.map +1 -1
- package/dist/runtime/query/resume-run.d.ts +70 -0
- package/dist/runtime/query/resume-run.d.ts.map +1 -0
- package/dist/runtime/query/resume-run.js +46 -0
- package/dist/runtime/query/resume-run.js.map +1 -0
- package/dist/telemetry/__tests__/model-call-span.test.d.ts +2 -0
- package/dist/telemetry/__tests__/model-call-span.test.d.ts.map +1 -0
- package/dist/telemetry/__tests__/model-call-span.test.js +147 -0
- package/dist/telemetry/__tests__/model-call-span.test.js.map +1 -0
- package/dist/telemetry/__tests__/span-closure.test.d.ts +2 -0
- package/dist/telemetry/__tests__/span-closure.test.d.ts.map +1 -0
- package/dist/telemetry/__tests__/span-closure.test.js +124 -0
- package/dist/telemetry/__tests__/span-closure.test.js.map +1 -0
- package/dist/tools/advisory/index.js +1 -1
- package/dist/tools/advisory/index.js.map +1 -1
- package/dist/tools/builtins/__tests__/edit-commits-atomically.test.d.ts +2 -0
- package/dist/tools/builtins/__tests__/edit-commits-atomically.test.d.ts.map +1 -0
- package/dist/tools/builtins/__tests__/edit-commits-atomically.test.js +78 -0
- package/dist/tools/builtins/__tests__/edit-commits-atomically.test.js.map +1 -0
- package/dist/tools/builtins/__tests__/edit.test.js +242 -1
- package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
- package/dist/tools/builtins/__tests__/stale-file-refusal.test.d.ts +2 -0
- package/dist/tools/builtins/__tests__/stale-file-refusal.test.d.ts.map +1 -0
- package/dist/tools/builtins/__tests__/stale-file-refusal.test.js +187 -0
- package/dist/tools/builtins/__tests__/stale-file-refusal.test.js.map +1 -0
- package/dist/tools/builtins/atomic-write-file.d.ts +1 -1
- package/dist/tools/builtins/atomic-write-file.d.ts.map +1 -1
- package/dist/tools/builtins/atomic-write-file.js +31 -2
- package/dist/tools/builtins/atomic-write-file.js.map +1 -1
- package/dist/tools/builtins/content-fingerprint.d.ts +23 -0
- package/dist/tools/builtins/content-fingerprint.d.ts.map +1 -0
- package/dist/tools/builtins/content-fingerprint.js +30 -0
- package/dist/tools/builtins/content-fingerprint.js.map +1 -0
- package/dist/tools/builtins/edit.d.ts.map +1 -1
- package/dist/tools/builtins/edit.js +172 -31
- package/dist/tools/builtins/edit.js.map +1 -1
- package/dist/tools/builtins/read-file.d.ts.map +1 -1
- package/dist/tools/builtins/read-file.js +5 -2
- package/dist/tools/builtins/read-file.js.map +1 -1
- package/dist/tools/builtins/write-file.d.ts.map +1 -1
- package/dist/tools/builtins/write-file.js +75 -27
- package/dist/tools/builtins/write-file.js.map +1 -1
- package/dist/tools/coordinator/__tests__/ask-user-question.test.js +55 -0
- package/dist/tools/coordinator/__tests__/ask-user-question.test.js.map +1 -1
- package/dist/tools/coordinator/__tests__/plan-dependencies.test.d.ts +2 -0
- package/dist/tools/coordinator/__tests__/plan-dependencies.test.d.ts.map +1 -0
- package/dist/tools/coordinator/__tests__/plan-dependencies.test.js +126 -0
- package/dist/tools/coordinator/__tests__/plan-dependencies.test.js.map +1 -0
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +75 -5
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/coordinator/plan-dependencies.d.ts +43 -0
- package/dist/tools/coordinator/plan-dependencies.d.ts.map +1 -0
- package/dist/tools/coordinator/plan-dependencies.js +148 -0
- package/dist/tools/coordinator/plan-dependencies.js.map +1 -0
- package/dist/types/agent/supervisor.d.ts +15 -0
- package/dist/types/agent/supervisor.d.ts.map +1 -1
- package/dist/types/connector/core.d.ts +34 -0
- package/dist/types/connector/core.d.ts.map +1 -1
- package/dist/types/connector/definition.d.ts +10 -0
- package/dist/types/connector/definition.d.ts.map +1 -1
- package/dist/types/connector/mcp.d.ts +13 -0
- package/dist/types/connector/mcp.d.ts.map +1 -1
- package/dist/types/rag/retrieval.d.ts +16 -0
- package/dist/types/rag/retrieval.d.ts.map +1 -1
- package/dist/types/rag/storage.d.ts +9 -0
- package/dist/types/rag/storage.d.ts.map +1 -1
- package/dist/types/rag/vector.d.ts +11 -0
- package/dist/types/rag/vector.d.ts.map +1 -1
- package/dist/types/router/task-router.d.ts +19 -0
- package/dist/types/router/task-router.d.ts.map +1 -1
- package/dist/types/run/prepare-step.d.ts +56 -3
- package/dist/types/run/prepare-step.d.ts.map +1 -1
- package/dist/types/tool/index.d.ts +17 -1
- package/dist/types/tool/index.d.ts.map +1 -1
- package/dist/types/tool/index.js.map +1 -1
- package/dist/types/toolset/index.d.ts +22 -0
- package/dist/types/toolset/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/advisory/__tests__/consultation-context.test.ts +191 -0
- package/src/advisory/context.ts +32 -0
- package/src/advisory/executor.ts +30 -3
- package/src/compaction/__tests__/context-reducer.test.ts +239 -0
- package/src/compaction/factory.ts +7 -0
- package/src/compaction/index.ts +8 -0
- package/src/compaction/interface.ts +13 -0
- package/src/compaction/managers/null.ts +3 -0
- package/src/compaction/managers/slidingWindow.ts +6 -0
- package/src/compaction/managers/structured.ts +10 -0
- package/src/compaction/reducer.ts +154 -0
- package/src/connector/builtins/__tests__/oauth2-auth.test.ts +73 -0
- package/src/connector/builtins/http.test.ts +28 -2
- package/src/connector/builtins/http.ts +26 -2
- package/src/connector/index.ts +6 -2
- package/src/connector/mcp/__tests__/prompts-and-lifecycle.test.ts +286 -0
- package/src/connector/mcp/client.ts +95 -0
- package/src/connector/mcp/discovery.ts +19 -4
- package/src/connector/mcp/index.ts +6 -2
- package/src/connector/mcp/server.ts +101 -3
- package/src/manager/agent/__tests__/depth-limit-authority.test.ts +74 -0
- package/src/plugin/__tests__/discovery-scopes.test.ts +133 -0
- package/src/plugin/__tests__/enable-contributions.test.ts +5 -1
- package/src/plugin/__tests__/mcp-admission.test.ts +242 -0
- package/src/plugin/lifecycle.ts +57 -1
- package/src/plugin/loader.ts +57 -3
- package/src/public-runtime.ts +16 -1
- package/src/public-types.ts +5 -0
- package/src/rag/__tests__/namespace-isolation.test.ts +109 -0
- package/src/rag/ingestion.ts +1 -0
- package/src/rag/retriever.ts +2 -0
- package/src/rag/vector-store.ts +5 -0
- package/src/registry/tool/execute.ts +123 -119
- package/src/runtime/query/__tests__/capability-negotiation.test.ts +69 -0
- package/src/runtime/query/__tests__/per-step-skills.test.ts +154 -0
- package/src/runtime/query/__tests__/per-step-tool-choice.test.ts +180 -0
- package/src/runtime/query/__tests__/resume-run.test.ts +262 -0
- package/src/runtime/query/executor.ts +9 -1
- package/src/runtime/query/index.ts +39 -0
- package/src/runtime/query/iteration/index.ts +130 -34
- package/src/runtime/query/iteration/phases/compaction-model-routing.test.ts +125 -0
- package/src/runtime/query/iteration/phases/compaction.ts +106 -5
- package/src/runtime/query/iteration/phases/context-reducer-dispatch.test.ts +238 -0
- package/src/runtime/query/iteration/phases/context.ts +11 -0
- package/src/runtime/query/resume-run.ts +93 -0
- package/src/telemetry/__tests__/model-call-span.test.ts +189 -0
- package/src/telemetry/__tests__/span-closure.test.ts +153 -0
- package/src/tools/advisory/index.ts +1 -1
- package/src/tools/builtins/__tests__/edit-commits-atomically.test.ts +103 -0
- package/src/tools/builtins/__tests__/edit.test.ts +324 -1
- package/src/tools/builtins/__tests__/stale-file-refusal.test.ts +258 -0
- package/src/tools/builtins/atomic-write-file.ts +30 -2
- package/src/tools/builtins/content-fingerprint.ts +33 -0
- package/src/tools/builtins/edit.ts +188 -36
- package/src/tools/builtins/read-file.ts +5 -2
- package/src/tools/builtins/write-file.ts +79 -28
- package/src/tools/coordinator/__tests__/ask-user-question.test.ts +68 -0
- package/src/tools/coordinator/__tests__/plan-dependencies.test.ts +186 -0
- package/src/tools/coordinator/index.ts +116 -42
- package/src/tools/coordinator/plan-dependencies.ts +175 -0
- package/src/types/agent/supervisor.ts +15 -0
- package/src/types/connector/core.ts +34 -0
- package/src/types/connector/definition.ts +10 -0
- package/src/types/connector/mcp.ts +14 -0
- package/src/types/rag/retrieval.ts +16 -0
- package/src/types/rag/storage.ts +9 -0
- package/src/types/rag/vector.ts +11 -0
- package/src/types/router/task-router.ts +19 -0
- package/src/types/run/prepare-step.ts +58 -3
- package/src/types/tool/index.ts +17 -1
- package/src/types/toolset/index.ts +22 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import type { AgentTaskContext } from '../../../types/agent/index.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which `maxDepth` is in force.
|
|
7
|
+
*
|
|
8
|
+
* Two exist. `AgentManagerConfig.maxDepth` is enforced in
|
|
9
|
+
* `AgentManager.sendMessage`; `SupervisorAgentConfig.maxDepth` is read by
|
|
10
|
+
* nothing. For a recursion bound that is the worst way to be wrong — the
|
|
11
|
+
* number a reviewer sees in the supervisor config is not the number
|
|
12
|
+
* stopping runaway delegation.
|
|
13
|
+
*
|
|
14
|
+
* The first version of these tests passed the config where the registry
|
|
15
|
+
* goes, so every case silently ran against the default of 3 — and the case
|
|
16
|
+
* that used 3 "passed" while proving nothing. Configuring a value the
|
|
17
|
+
* default is not is what makes this a test.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
async function manager(maxDepth: number) {
|
|
21
|
+
const { AgentManager } = await import('../lifecycle.js')
|
|
22
|
+
const registry = { resolve: vi.fn(), get: vi.fn(), getAll: vi.fn(() => []) }
|
|
23
|
+
const deps = { createAgent: vi.fn(), taskStore: undefined }
|
|
24
|
+
return new AgentManager(registry as never, { maxDepth }, deps as never)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function contextAtDepth(depth: number): AgentTaskContext {
|
|
28
|
+
return {
|
|
29
|
+
parentRunId: 'run_x',
|
|
30
|
+
parentAgentId: 'agent_x',
|
|
31
|
+
parentAbortController: new AbortController(),
|
|
32
|
+
depth,
|
|
33
|
+
tenantId: 'tnt_x',
|
|
34
|
+
} as unknown as AgentTaskContext
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function send(m: Awaited<ReturnType<typeof manager>>, depth: number) {
|
|
38
|
+
return m.sendMessage(
|
|
39
|
+
{ agentId: 'a', prompt: 'go', tenantId: 'tnt_x' } as never,
|
|
40
|
+
contextAtDepth(depth),
|
|
41
|
+
)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('the manager owns the recursion bound', () => {
|
|
45
|
+
it('refuses a task at the configured depth', async () => {
|
|
46
|
+
// 5, deliberately not the default of 3: a limit that happens to equal
|
|
47
|
+
// the default cannot tell "configured" from "ignored".
|
|
48
|
+
const mgr = await manager(5)
|
|
49
|
+
|
|
50
|
+
await expect(send(mgr, 5)).rejects.toThrow(/Max task depth 5/)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('refuses past it too, not only exactly at it', async () => {
|
|
54
|
+
const mgr = await manager(2)
|
|
55
|
+
|
|
56
|
+
await expect(send(mgr, 7)).rejects.toThrow(/Max task depth 2/)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('reports the depth reached alongside the limit', async () => {
|
|
60
|
+
const mgr = await manager(2)
|
|
61
|
+
|
|
62
|
+
// A limit without the current value sends someone to instrument the
|
|
63
|
+
// run to learn how deep it actually got.
|
|
64
|
+
await expect(send(mgr, 4)).rejects.toThrow(/current: 4/)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('allows a task below the bound to proceed past this check', async () => {
|
|
68
|
+
const mgr = await manager(5)
|
|
69
|
+
|
|
70
|
+
// It gets past the depth gate and fails later on the stubbed
|
|
71
|
+
// registry — which is the point: the gate did not stop it.
|
|
72
|
+
await expect(send(mgr, 1)).rejects.not.toThrow(/Max task depth/)
|
|
73
|
+
})
|
|
74
|
+
})
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import { PluginRuntimeConfigSchema } from '../../config/runtime.js'
|
|
7
|
+
import {
|
|
8
|
+
PLUGIN_MANIFEST_FILENAME,
|
|
9
|
+
PROJECT_PLUGIN_DIR,
|
|
10
|
+
USER_PLUGIN_DIR,
|
|
11
|
+
} from '../../constants/plugin/index.js'
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* `allowedScopes` read exactly like a trust boundary and enforced nothing.
|
|
15
|
+
*
|
|
16
|
+
* A plugin is arbitrary code with hooks into tool execution, and the two
|
|
17
|
+
* scopes are not equally trusted: a project plugin is reviewable in the repo
|
|
18
|
+
* the agent works on, a user plugin comes from a home directory the repo's
|
|
19
|
+
* reviewers never see. `PluginRuntimeConfig` carried `enabled`,
|
|
20
|
+
* `autoDiscovery` and `allowedScopes`; nothing anywhere consulted any of the
|
|
21
|
+
* three, and discovery scanned both locations unconditionally.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
let home: string
|
|
25
|
+
let workdir: string
|
|
26
|
+
|
|
27
|
+
vi.mock('node:os', async () => {
|
|
28
|
+
const actual = await vi.importActual<typeof import('node:os')>('node:os')
|
|
29
|
+
return { ...actual, homedir: () => home }
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
async function plantPlugin(dir: string, name: string): Promise<void> {
|
|
33
|
+
const pluginDir = join(dir, name)
|
|
34
|
+
await mkdir(pluginDir, { recursive: true })
|
|
35
|
+
await writeFile(
|
|
36
|
+
join(pluginDir, PLUGIN_MANIFEST_FILENAME),
|
|
37
|
+
JSON.stringify({ name, version: '1.0.0' }),
|
|
38
|
+
'utf-8',
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
beforeEach(async () => {
|
|
43
|
+
home = await mkdtemp(join(tmpdir(), 'namzu-home-'))
|
|
44
|
+
workdir = await mkdtemp(join(tmpdir(), 'namzu-work-'))
|
|
45
|
+
await plantPlugin(join(workdir, PROJECT_PLUGIN_DIR), 'project-plugin')
|
|
46
|
+
await plantPlugin(join(home, USER_PLUGIN_DIR), 'user-plugin')
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
afterEach(async () => {
|
|
50
|
+
await Promise.all([
|
|
51
|
+
rm(home, { recursive: true, force: true }),
|
|
52
|
+
rm(workdir, { recursive: true, force: true }),
|
|
53
|
+
])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
async function discover(
|
|
57
|
+
options?: Parameters<typeof import('../loader.js').discoverAllPluginDirs>[1],
|
|
58
|
+
) {
|
|
59
|
+
const { discoverAllPluginDirs } = await import('../loader.js')
|
|
60
|
+
return discoverAllPluginDirs(workdir, options)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
describe('a scope the host did not allow is not scanned', () => {
|
|
64
|
+
it('finds both when nothing is said', async () => {
|
|
65
|
+
const found = await discover()
|
|
66
|
+
|
|
67
|
+
expect(found.project).toHaveLength(1)
|
|
68
|
+
expect(found.user).toHaveLength(1)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('keeps user plugins out when only project is allowed', async () => {
|
|
72
|
+
const found = await discover({ allowedScopes: ['project'] })
|
|
73
|
+
|
|
74
|
+
expect(found.project).toHaveLength(1)
|
|
75
|
+
// The setting that reads like a boundary now is one.
|
|
76
|
+
expect(found.user).toEqual([])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('keeps project plugins out when only user is allowed', async () => {
|
|
80
|
+
const found = await discover({ allowedScopes: ['user'] })
|
|
81
|
+
|
|
82
|
+
expect(found.project).toEqual([])
|
|
83
|
+
expect(found.user).toHaveLength(1)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('finds nothing when neither scope is allowed', async () => {
|
|
87
|
+
const found = await discover({ allowedScopes: [] })
|
|
88
|
+
|
|
89
|
+
expect(found).toEqual({ project: [], user: [] })
|
|
90
|
+
})
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
describe('the runtime switches turn discovery off', () => {
|
|
94
|
+
it('discovers nothing when the plugin runtime is disabled', async () => {
|
|
95
|
+
const found = await discover({ enabled: false })
|
|
96
|
+
|
|
97
|
+
expect(found).toEqual({ project: [], user: [] })
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('discovers nothing when auto-discovery is off', async () => {
|
|
101
|
+
const found = await discover({ autoDiscovery: false })
|
|
102
|
+
|
|
103
|
+
expect(found).toEqual({ project: [], user: [] })
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('a default-parsed config discovers nothing, because the runtime ships off', async () => {
|
|
107
|
+
// `enabled` defaults to false in the schema. A host who parses the
|
|
108
|
+
// config and passes it gets the documented default rather than a
|
|
109
|
+
// silently-on plugin runtime.
|
|
110
|
+
const found = await discover(PluginRuntimeConfigSchema.parse({}))
|
|
111
|
+
|
|
112
|
+
expect(found).toEqual({ project: [], user: [] })
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('an enabled default-parsed config finds both scopes', async () => {
|
|
116
|
+
const found = await discover(PluginRuntimeConfigSchema.parse({ enabled: true }))
|
|
117
|
+
|
|
118
|
+
expect(found.project).toHaveLength(1)
|
|
119
|
+
expect(found.user).toHaveLength(1)
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
it('a parsed config carries its scope restriction through', async () => {
|
|
123
|
+
const config = PluginRuntimeConfigSchema.parse({
|
|
124
|
+
enabled: true,
|
|
125
|
+
allowedScopes: ['project'],
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
const found = await discover(config)
|
|
129
|
+
|
|
130
|
+
expect(found.project).toHaveLength(1)
|
|
131
|
+
expect(found.user).toEqual([])
|
|
132
|
+
})
|
|
133
|
+
})
|
|
@@ -11,11 +11,15 @@ const mockDisconnect = vi.fn(async (): Promise<void> => undefined)
|
|
|
11
11
|
const mockListTools = vi.fn(async (): Promise<unknown[]> => [])
|
|
12
12
|
|
|
13
13
|
vi.mock('../../connector/mcp/client.js', () => ({
|
|
14
|
-
MCPClient: vi.fn().mockImplementation(() => ({
|
|
14
|
+
MCPClient: vi.fn().mockImplementation((config: { serverName: string }) => ({
|
|
15
15
|
id: 'mcp-client-mock',
|
|
16
16
|
connect: mockConnect,
|
|
17
17
|
disconnect: mockDisconnect,
|
|
18
18
|
listTools: mockListTools,
|
|
19
|
+
// The real client has always had this; the mock did not, which went
|
|
20
|
+
// unnoticed while nothing on this path asked the client which server
|
|
21
|
+
// it was talking to. Admission does — a policy is per server name.
|
|
22
|
+
getState: () => ({ serverName: config.serverName }),
|
|
19
23
|
})),
|
|
20
24
|
}))
|
|
21
25
|
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
2
|
+
import { tmpdir } from 'node:os'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
5
|
+
|
|
6
|
+
import type { MCPToolDefinition } from '../../types/connector/index.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A plugin's MCP server decided what entered the agent's tool registry.
|
|
10
|
+
*
|
|
11
|
+
* `MCPToolDiscovery` has held the admission boundary — a per-server
|
|
12
|
+
* allow/deny policy, and detection for a server whose tool set changes
|
|
13
|
+
* between discoveries — since it was written, and nothing outside its own
|
|
14
|
+
* tests ever constructed one. `PluginLifecycleManager.attachMCPServer`, the
|
|
15
|
+
* only code in the tree that connects a real MCP server, called
|
|
16
|
+
* `client.listTools()` and registered whatever came back.
|
|
17
|
+
*
|
|
18
|
+
* So least privilege was inverted at the one place it mattered: the remote
|
|
19
|
+
* side chose. Tools land as `deferred` and a run's `allowedTools` filters the
|
|
20
|
+
* model-visible catalogue, so this was not "arbitrary tools reach the model
|
|
21
|
+
* immediately" — but the check written for exactly this was not on the path.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
let advertised: MCPToolDefinition[] = []
|
|
25
|
+
let clientCount = 0
|
|
26
|
+
|
|
27
|
+
vi.mock('../../connector/mcp/client.js', () => ({
|
|
28
|
+
MCPClient: class {
|
|
29
|
+
readonly id: string
|
|
30
|
+
private serverName: string
|
|
31
|
+
constructor(config: { serverName: string }) {
|
|
32
|
+
this.id = `client_${clientCount++}`
|
|
33
|
+
this.serverName = config.serverName
|
|
34
|
+
}
|
|
35
|
+
async connect(): Promise<void> {}
|
|
36
|
+
async disconnect(): Promise<void> {}
|
|
37
|
+
isConnected(): boolean {
|
|
38
|
+
return true
|
|
39
|
+
}
|
|
40
|
+
getState() {
|
|
41
|
+
return { serverName: this.serverName }
|
|
42
|
+
}
|
|
43
|
+
async listTools(): Promise<MCPToolDefinition[]> {
|
|
44
|
+
return advertised
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
}))
|
|
48
|
+
|
|
49
|
+
function tool(name: string): MCPToolDefinition {
|
|
50
|
+
return { name, description: name, inputSchema: { type: 'object', properties: {} } }
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const log = {
|
|
54
|
+
debug: vi.fn(),
|
|
55
|
+
info: vi.fn(),
|
|
56
|
+
warn: vi.fn(),
|
|
57
|
+
error: vi.fn(),
|
|
58
|
+
child: () => log,
|
|
59
|
+
} as never
|
|
60
|
+
|
|
61
|
+
let root: string
|
|
62
|
+
|
|
63
|
+
interface Harness {
|
|
64
|
+
readonly registered: string[]
|
|
65
|
+
readonly manager: import('../lifecycle.js').PluginLifecycleManager
|
|
66
|
+
enable(name: string): Promise<import('../../types/ids/index.js').PluginId>
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function harness(config: Record<string, unknown> = {}): Promise<Harness> {
|
|
70
|
+
const { PluginLifecycleManager } = await import('../lifecycle.js')
|
|
71
|
+
const { PluginRegistry } = await import('../../registry/plugin/index.js')
|
|
72
|
+
|
|
73
|
+
const registered: string[] = []
|
|
74
|
+
const toolRegistry = {
|
|
75
|
+
register: (definition: { name: string }) => registered.push(definition.name),
|
|
76
|
+
unregister: () => undefined,
|
|
77
|
+
get: () => undefined,
|
|
78
|
+
} as never
|
|
79
|
+
|
|
80
|
+
const manager = new PluginLifecycleManager({
|
|
81
|
+
pluginRegistry: new PluginRegistry(),
|
|
82
|
+
toolRegistry,
|
|
83
|
+
log,
|
|
84
|
+
...config,
|
|
85
|
+
} as never)
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
registered,
|
|
89
|
+
manager,
|
|
90
|
+
enable: async (name: string) => {
|
|
91
|
+
const dir = join(root, name)
|
|
92
|
+
await mkdir(dir, { recursive: true })
|
|
93
|
+
await writeFile(
|
|
94
|
+
join(dir, 'plugin.json'),
|
|
95
|
+
JSON.stringify({
|
|
96
|
+
name,
|
|
97
|
+
version: '1.0.0',
|
|
98
|
+
description: 'MCP server plugin fixture',
|
|
99
|
+
mcpServers: [{ name: 'files', command: 'node', args: ['server.js'] }],
|
|
100
|
+
}),
|
|
101
|
+
'utf-8',
|
|
102
|
+
)
|
|
103
|
+
const plugin = await manager.install(dir, 'project')
|
|
104
|
+
await manager.enable(plugin.id)
|
|
105
|
+
return plugin.id
|
|
106
|
+
},
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
beforeEach(async () => {
|
|
111
|
+
root = await mkdtemp(join(tmpdir(), 'namzu-mcp-admit-'))
|
|
112
|
+
clientCount = 0
|
|
113
|
+
advertised = [tool('read_file'), tool('write_file'), tool('delete_everything')]
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
afterEach(async () => {
|
|
117
|
+
await rm(root, { recursive: true, force: true })
|
|
118
|
+
vi.clearAllMocks()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
describe('what a plugin server advertises is not what the registry gets', () => {
|
|
122
|
+
it('admits only the tools an allowlist names', async () => {
|
|
123
|
+
const h = await harness({ mcpToolPolicies: { files: { allow: ['read_file', 'write_file'] } } })
|
|
124
|
+
|
|
125
|
+
await h.enable('srv')
|
|
126
|
+
|
|
127
|
+
expect(h.registered.some((n) => n.endsWith('read_file'))).toBe(true)
|
|
128
|
+
expect(h.registered.some((n) => n.endsWith('write_file'))).toBe(true)
|
|
129
|
+
// The one the server offered and the host never agreed to.
|
|
130
|
+
expect(h.registered.some((n) => n.endsWith('delete_everything'))).toBe(false)
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('refuses a denied tool through the wildcard policy', async () => {
|
|
134
|
+
const h = await harness({ mcpToolPolicies: { '*': { deny: ['delete_everything'] } } })
|
|
135
|
+
|
|
136
|
+
await h.enable('srv')
|
|
137
|
+
|
|
138
|
+
expect(h.registered).toHaveLength(2)
|
|
139
|
+
expect(h.registered.some((n) => n.endsWith('delete_everything'))).toBe(false)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('still admits everything when no policy is configured', async () => {
|
|
143
|
+
// Adding a boundary must not turn every existing host's working plugin
|
|
144
|
+
// into a broken one.
|
|
145
|
+
const h = await harness()
|
|
146
|
+
|
|
147
|
+
await h.enable('srv')
|
|
148
|
+
|
|
149
|
+
expect(h.registered).toHaveLength(3)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('namespaces what it admits, exactly as before', async () => {
|
|
153
|
+
const h = await harness({ mcpToolPolicies: { files: { allow: ['read_file'] } } })
|
|
154
|
+
|
|
155
|
+
await h.enable('srv')
|
|
156
|
+
|
|
157
|
+
expect(h.registered[0]).toContain('mcp__files__read_file')
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
describe('a server that changes its tools between connections is reported', () => {
|
|
162
|
+
it('says nothing the first time, having nothing to compare against', async () => {
|
|
163
|
+
const onMCPToolDrift = vi.fn()
|
|
164
|
+
const h = await harness({ onMCPToolDrift })
|
|
165
|
+
|
|
166
|
+
await h.enable('srv')
|
|
167
|
+
|
|
168
|
+
expect(onMCPToolDrift).not.toHaveBeenCalled()
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
it('reports a tool the server grew after it was first approved', async () => {
|
|
172
|
+
const onMCPToolDrift = vi.fn()
|
|
173
|
+
const h = await harness({ onMCPToolDrift })
|
|
174
|
+
const first = await h.enable('srv-a')
|
|
175
|
+
|
|
176
|
+
// The rug pull: advertise something benign while a host is deciding,
|
|
177
|
+
// swap it afterwards. A NEW client connects to the SAME server, which
|
|
178
|
+
// is why the remembered set is keyed by server name — keyed by client
|
|
179
|
+
// id, as it was, every connection was its own first and this could
|
|
180
|
+
// never fire.
|
|
181
|
+
await h.manager.disable(first)
|
|
182
|
+
advertised = [...advertised, tool('exfiltrate')]
|
|
183
|
+
await h.enable('srv-b')
|
|
184
|
+
|
|
185
|
+
expect(onMCPToolDrift).toHaveBeenCalledTimes(1)
|
|
186
|
+
expect(onMCPToolDrift.mock.calls[0]?.[0]).toMatchObject({
|
|
187
|
+
serverName: 'files',
|
|
188
|
+
drift: { added: ['exfiltrate'] },
|
|
189
|
+
})
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('reports a tool whose shape changed under the same name', async () => {
|
|
193
|
+
const onMCPToolDrift = vi.fn()
|
|
194
|
+
const h = await harness({ onMCPToolDrift })
|
|
195
|
+
const first = await h.enable('srv-a')
|
|
196
|
+
|
|
197
|
+
await h.manager.disable(first)
|
|
198
|
+
// Same name, different input shape — the version a name-only check
|
|
199
|
+
// misses entirely.
|
|
200
|
+
advertised = [
|
|
201
|
+
{
|
|
202
|
+
name: 'read_file',
|
|
203
|
+
description: 'read a file',
|
|
204
|
+
inputSchema: { type: 'object', properties: { path: { type: 'string' } } },
|
|
205
|
+
},
|
|
206
|
+
tool('write_file'),
|
|
207
|
+
tool('delete_everything'),
|
|
208
|
+
]
|
|
209
|
+
await h.enable('srv-b')
|
|
210
|
+
|
|
211
|
+
expect(onMCPToolDrift).toHaveBeenCalledTimes(1)
|
|
212
|
+
expect(onMCPToolDrift.mock.calls[0]?.[0]).toMatchObject({ drift: { changed: ['read_file'] } })
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it('says nothing when the server offers exactly what it offered before', async () => {
|
|
216
|
+
const onMCPToolDrift = vi.fn()
|
|
217
|
+
const h = await harness({ onMCPToolDrift })
|
|
218
|
+
const first = await h.enable('srv-a')
|
|
219
|
+
|
|
220
|
+
await h.manager.disable(first)
|
|
221
|
+
await h.enable('srv-b')
|
|
222
|
+
|
|
223
|
+
expect(onMCPToolDrift).not.toHaveBeenCalled()
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
it('compares what was ADMITTED, not what was advertised', async () => {
|
|
227
|
+
const onMCPToolDrift = vi.fn()
|
|
228
|
+
const h = await harness({
|
|
229
|
+
mcpToolPolicies: { files: { allow: ['read_file'] } },
|
|
230
|
+
onMCPToolDrift,
|
|
231
|
+
})
|
|
232
|
+
const first = await h.enable('srv-a')
|
|
233
|
+
|
|
234
|
+
await h.manager.disable(first)
|
|
235
|
+
// A tool the policy refuses either way. Reporting drift for it would
|
|
236
|
+
// train a host to ignore the warning that matters.
|
|
237
|
+
advertised = [...advertised, tool('another_denied_one')]
|
|
238
|
+
await h.enable('srv-b')
|
|
239
|
+
|
|
240
|
+
expect(onMCPToolDrift).not.toHaveBeenCalled()
|
|
241
|
+
})
|
|
242
|
+
})
|
package/src/plugin/lifecycle.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { join } from 'node:path'
|
|
|
2
2
|
import { pathToFileURL } from 'node:url'
|
|
3
3
|
import { mcpToolToToolDefinition } from '../connector/mcp/adapter.js'
|
|
4
4
|
import { MCPClient } from '../connector/mcp/client.js'
|
|
5
|
+
import { MCPToolDiscovery } from '../connector/mcp/discovery.js'
|
|
6
|
+
import type { MCPToolDiscoveryOptions } from '../connector/mcp/discovery.js'
|
|
7
|
+
import type { MCPToolPolicy } from '../connector/mcp/policy.js'
|
|
5
8
|
import {
|
|
6
9
|
DEFAULT_HOOK_PRIORITY,
|
|
7
10
|
HOOK_TIMEOUT_MS,
|
|
@@ -37,6 +40,32 @@ export interface PluginLifecycleManagerConfig {
|
|
|
37
40
|
toolRegistry: ToolRegistryContract
|
|
38
41
|
log: Logger
|
|
39
42
|
hookTimeoutMs?: number
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* What each MCP server a plugin brings is allowed to contribute, keyed by
|
|
46
|
+
* server name. `'*'` covers every server not named explicitly.
|
|
47
|
+
*
|
|
48
|
+
* Absent admits everything, which is what this path did unconditionally
|
|
49
|
+
* until now. `MCPToolDiscovery` has held this boundary — and the drift
|
|
50
|
+
* detection below — since it was written, and nothing outside its own
|
|
51
|
+
* tests ever constructed one: `attachMCPServer` called `listTools()`
|
|
52
|
+
* directly and registered whatever came back. So the least-privilege
|
|
53
|
+
* check existed, was tested, was exported, and was not on the path any
|
|
54
|
+
* real MCP server takes.
|
|
55
|
+
*/
|
|
56
|
+
mcpToolPolicies?: Readonly<Record<string, MCPToolPolicy>>
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Called when a server's admitted tool set differs from the last time it
|
|
60
|
+
* was discovered.
|
|
61
|
+
*
|
|
62
|
+
* Reported rather than blocked, for the reason `MCPToolDiscovery`
|
|
63
|
+
* already gives: a development server legitimately changes between runs,
|
|
64
|
+
* while a production one changing mid-session is the rug pull — advertise
|
|
65
|
+
* something benign at approval time, swap it afterwards. Only the host
|
|
66
|
+
* knows which it is looking at.
|
|
67
|
+
*/
|
|
68
|
+
onMCPToolDrift?: MCPToolDiscoveryOptions['onDrift']
|
|
40
69
|
}
|
|
41
70
|
|
|
42
71
|
export class PluginLifecycleManager {
|
|
@@ -55,12 +84,32 @@ export class PluginLifecycleManager {
|
|
|
55
84
|
private pluginContributions: Map<PluginId, PluginContributionRecord> = new Map()
|
|
56
85
|
private hookTimeoutMs: number
|
|
57
86
|
private log: Logger
|
|
87
|
+
/**
|
|
88
|
+
* The admission boundary for everything a plugin's MCP servers advertise.
|
|
89
|
+
*
|
|
90
|
+
* One instance for the whole manager rather than one per plugin, and that
|
|
91
|
+
* is the part that matters: the instance is what remembers each server's
|
|
92
|
+
* previously admitted tool set, so a server that changes between one
|
|
93
|
+
* plugin being disabled and the next being enabled is still noticed. A
|
|
94
|
+
* per-plugin instance would forget on every teardown, which is precisely
|
|
95
|
+
* the window a rug pull uses.
|
|
96
|
+
*
|
|
97
|
+
* Its client list is deliberately left empty — `discoverFrom` takes the
|
|
98
|
+
* client directly, so registering it here would be bookkeeping nothing
|
|
99
|
+
* reads.
|
|
100
|
+
*/
|
|
101
|
+
private mcpDiscovery: MCPToolDiscovery
|
|
58
102
|
|
|
59
103
|
constructor(config: PluginLifecycleManagerConfig) {
|
|
60
104
|
this.pluginRegistry = config.pluginRegistry
|
|
61
105
|
this.toolRegistry = config.toolRegistry
|
|
62
106
|
this.hookTimeoutMs = config.hookTimeoutMs ?? HOOK_TIMEOUT_MS
|
|
63
107
|
this.log = config.log.child({ component: 'PluginLifecycleManager' })
|
|
108
|
+
this.mcpDiscovery = new MCPToolDiscovery([], {
|
|
109
|
+
...(config.mcpToolPolicies ? { policies: config.mcpToolPolicies } : {}),
|
|
110
|
+
...(config.onMCPToolDrift ? { onDrift: config.onMCPToolDrift } : {}),
|
|
111
|
+
logger: this.log,
|
|
112
|
+
})
|
|
64
113
|
}
|
|
65
114
|
|
|
66
115
|
/**
|
|
@@ -257,7 +306,14 @@ export class PluginLifecycleManager {
|
|
|
257
306
|
await client.connect()
|
|
258
307
|
contributions.mcpClients.push(client)
|
|
259
308
|
|
|
260
|
-
|
|
309
|
+
// Through the boundary, not around it. This used to call
|
|
310
|
+
// `client.listTools()` and register everything the server answered
|
|
311
|
+
// with, so the remote side decided what entered the agent's registry
|
|
312
|
+
// — least privilege inverted. `discoverFrom` applies the per-server
|
|
313
|
+
// allow/deny policy and reports a tool set that changed since the
|
|
314
|
+
// last discovery.
|
|
315
|
+
const discovered = await this.mcpDiscovery.discoverFrom(client)
|
|
316
|
+
const mcpTools = discovered.map((d) => d.tool)
|
|
261
317
|
for (const mcpTool of mcpTools) {
|
|
262
318
|
const baseDef = mcpToolToToolDefinition(mcpTool, client, config.name)
|
|
263
319
|
// Double-underscore between serverName and toolName so that e.g.
|
package/src/plugin/loader.ts
CHANGED
|
@@ -88,23 +88,77 @@ export function assertEnableable(manifest: PluginManifest): void {
|
|
|
88
88
|
)
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
/** Where a plugin came from. Project plugins ship with the repo; user plugins live in the home directory. */
|
|
92
|
+
export type PluginScope = 'project' | 'user'
|
|
93
|
+
|
|
91
94
|
/**
|
|
92
|
-
*
|
|
93
|
-
*
|
|
95
|
+
* The discovery half of {@link PluginRuntimeConfig}.
|
|
96
|
+
*
|
|
97
|
+
* Structural rather than an import so this module keeps depending on nothing
|
|
98
|
+
* — a parsed `PluginRuntimeConfig` satisfies it as-is.
|
|
99
|
+
*/
|
|
100
|
+
export interface PluginDiscoveryOptions {
|
|
101
|
+
/** Whether the plugin runtime is on at all. `false` discovers nothing. */
|
|
102
|
+
readonly enabled?: boolean
|
|
103
|
+
/** Whether to scan for plugins. `false` discovers nothing. */
|
|
104
|
+
readonly autoDiscovery?: boolean
|
|
105
|
+
/** Which locations may be scanned. Absent means both. */
|
|
106
|
+
readonly allowedScopes?: readonly PluginScope[]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Find plugin directories, in the locations the caller permits.
|
|
111
|
+
*
|
|
112
|
+
* `allowedScopes` is a trust boundary, not a filter applied afterwards. A
|
|
113
|
+
* plugin is arbitrary code with hooks into tool execution, and the two scopes
|
|
114
|
+
* are not equally trusted: project plugins are reviewable in the repo the
|
|
115
|
+
* agent is working on, while user plugins come from a home directory the
|
|
116
|
+
* repo's reviewers never see. `['project']` is how a host says the second
|
|
117
|
+
* kind is not allowed to run here.
|
|
118
|
+
*
|
|
119
|
+
* It was previously declared and unread — `PluginRuntimeConfig` carried
|
|
120
|
+
* `enabled`, `autoDiscovery` and `allowedScopes`, nothing anywhere consulted
|
|
121
|
+
* any of them, and this function scanned both locations unconditionally. A
|
|
122
|
+
* host who set `allowedScopes: ['project']` got user plugins anyway, from a
|
|
123
|
+
* setting that reads exactly like a boundary.
|
|
124
|
+
*
|
|
125
|
+
* A disallowed scope is NOT SCANNED rather than scanned and dropped.
|
|
126
|
+
* Filtering after the fact still reads the directory, which is both pointless
|
|
127
|
+
* work and a small disclosure — the returned count would tell a caller how
|
|
128
|
+
* many plugins live somewhere they said they would not look.
|
|
129
|
+
*
|
|
130
|
+
* Passing nothing keeps the old behaviour: both scopes, no gate. Existing
|
|
131
|
+
* callers are unaffected, and a caller who opts in gets what the config says.
|
|
94
132
|
*/
|
|
95
133
|
export async function discoverAllPluginDirs(
|
|
96
134
|
workingDirectory?: string,
|
|
135
|
+
options?: PluginDiscoveryOptions,
|
|
97
136
|
): Promise<{ project: string[]; user: string[] }> {
|
|
137
|
+
if (options?.enabled === false || options?.autoDiscovery === false) {
|
|
138
|
+
logger.debug('Plugin discovery skipped', {
|
|
139
|
+
enabled: options.enabled,
|
|
140
|
+
autoDiscovery: options.autoDiscovery,
|
|
141
|
+
})
|
|
142
|
+
return { project: [], user: [] }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const scopes = options?.allowedScopes
|
|
146
|
+
const mayScan = (scope: PluginScope): boolean => !scopes || scopes.includes(scope)
|
|
147
|
+
|
|
98
148
|
const projectDir = workingDirectory
|
|
99
149
|
? join(workingDirectory, PROJECT_PLUGIN_DIR)
|
|
100
150
|
: join(process.cwd(), PROJECT_PLUGIN_DIR)
|
|
101
151
|
const userDir = join(homedir(), USER_PLUGIN_DIR)
|
|
102
152
|
|
|
103
|
-
const [project, user] = await Promise.all([
|
|
153
|
+
const [project, user] = await Promise.all([
|
|
154
|
+
mayScan('project') ? discoverPlugins(projectDir) : Promise.resolve([]),
|
|
155
|
+
mayScan('user') ? discoverPlugins(userDir) : Promise.resolve([]),
|
|
156
|
+
])
|
|
104
157
|
|
|
105
158
|
logger.debug('Plugin discovery complete', {
|
|
106
159
|
projectCount: project.length,
|
|
107
160
|
userCount: user.length,
|
|
161
|
+
...(scopes ? { allowedScopes: scopes } : {}),
|
|
108
162
|
})
|
|
109
163
|
|
|
110
164
|
return { project, user }
|
package/src/public-runtime.ts
CHANGED
|
@@ -89,6 +89,12 @@ export type { ToolGrantKeys } from './runtime/query/tool-grants.js'
|
|
|
89
89
|
export { toWireRunStatus } from './contracts/run-status.js'
|
|
90
90
|
// Durable run state: the snapshot a different process picks a run up from.
|
|
91
91
|
export { captureRunState, loadRunState } from './runtime/query/run-state.js'
|
|
92
|
+
// …and the driver that joins the snapshot back to a running loop. Without
|
|
93
|
+
// it every host wrote the same wiring, and in practice none did.
|
|
94
|
+
export { resumeRun } from './runtime/query/resume-run.js'
|
|
95
|
+
export type { ResumeOutcome, ResumeRunParams } from './runtime/query/resume-run.js'
|
|
96
|
+
// The scope type was internal, so a host calling `loadRunState` could not
|
|
97
|
+
// name the argument it had to construct.
|
|
92
98
|
export type { RunStateScope } from './runtime/query/run-state.js'
|
|
93
99
|
export { prepareReplayState } from './runtime/query/replay/prepare.js'
|
|
94
100
|
export { listCheckpoints } from './runtime/query/replay/list.js'
|
|
@@ -215,7 +221,14 @@ export type { LocalSandboxProviderOptions } from './sandbox/index.js'
|
|
|
215
221
|
// The classified provider-failure surface: a driver states what went wrong
|
|
216
222
|
// first-hand, and the run boundary reads it to choose between a pause and a
|
|
217
223
|
// failure.
|
|
218
|
-
|
|
224
|
+
// `classifyProviderHttpStatus` and `bodySaysContextOverflow` are here
|
|
225
|
+
// because a driver outside this repo needs the same classification the
|
|
226
|
+
// first-party ones use — a status code alone does not separate a context
|
|
227
|
+
// overflow from an ordinary bad request, and re-deriving that per driver
|
|
228
|
+
// is how the classifications drift apart.
|
|
229
|
+
export {
|
|
230
|
+
bodySaysContextOverflow,
|
|
231
|
+
classifyProviderHttpStatus,
|
|
219
232
|
isCallerAbortError,
|
|
220
233
|
isProviderRequestError,
|
|
221
234
|
providerHttpError,
|
|
@@ -254,6 +267,7 @@ export {
|
|
|
254
267
|
HybridExecutionContext,
|
|
255
268
|
LocalExecutionContext,
|
|
256
269
|
MCPClient,
|
|
270
|
+
MCPMethodNotFound,
|
|
257
271
|
MCPConnectorBridge,
|
|
258
272
|
MCPServer,
|
|
259
273
|
MCPToolDiscovery,
|
|
@@ -489,6 +503,7 @@ export {
|
|
|
489
503
|
lookupContextWindow,
|
|
490
504
|
resolveContextWindow,
|
|
491
505
|
createConversationManager,
|
|
506
|
+
createSlidingWindowReducer,
|
|
492
507
|
extractFromAssistantMessage,
|
|
493
508
|
extractFromToolCall,
|
|
494
509
|
extractFromToolResult,
|