Skip to content
Merged
8 changes: 4 additions & 4 deletions .github/workflows/agentic_commands.yml

Large diffs are not rendered by default.

31 changes: 30 additions & 1 deletion docs/src/content/docs/reference/frontmatter.md
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,36 @@ The agent job checks out each pinned plugin immediately after installing the eng

Shared agentic workflows may also declare plugins. When the same plugin path is declared more than once, identical refs are deduplicated and compatible semantic versions are merged to the highest version. Incompatible major versions or conflicting non-semver refs fail compilation.

Plugin repositories must be public. The checkout step always uses the workflow's default `github.token`, which cannot read private repositories; unlike `skills:`, `plugins:` does not currently support per-entry `github-token`/`github-app` credentials.
Supported entry formats:

- String form (shared authentication):
- `owner/repo@<ref>`
- `owner/repo/plugins/path@<ref>`
- Object form (per-plugin authentication):
- `plugin` (required)
- `github-token` (optional)
- `github-app` (optional)

`github-token` and `github-app` are mutually exclusive for each object entry. By default (string form, or object form without either field), the checkout step uses the workflow's default `github.token`, which cannot read private repositories. Set a per-plugin `github-token` or `github-app` to install a plugin from a private repository:

```yaml wrap
engine: copilot
plugins:
# Public plugin, shared auth via the workflow's default token
- octo-org/agent-plugin@v1

# Per-plugin PAT for a private plugin repository
- plugin: octo-org/private-plugin@6f2a1b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f90
github-token: ${{ secrets.PRIVATE_PLUGIN_TOKEN }}

# Per-plugin GitHub App credentials
- plugin: octo-org/private-marketplace/plugins/example@6f2a1b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f90
github-app:
client-id: ${{ vars.PLUGIN_APP_CLIENT_ID }}
private-key: ${{ secrets.PLUGIN_APP_PRIVATE_KEY }}
```

Because plugin support is implemented per agentic engine, per-plugin credentials only take effect for the checkout step; whether an engine can install a plugin at all still depends on that engine's own Agent Plugins support.

### MCP Scripts (`mcp-scripts:`)

Expand Down
22 changes: 21 additions & 1 deletion pkg/parser/import_field_extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ type importAccumulator struct {
observabilityConfigs []string // observability config JSON blobs from all imports (merged into endpoint array)
engines []string
plugins []string
pluginObjects []map[string]any
safeOutputs []string
gradersBuilder strings.Builder
mcpScripts []string
Expand Down Expand Up @@ -405,7 +406,7 @@ func (acc *importAccumulator) extractConfigFields(fm map[string]any, fullPath st
}

acc.appendJSONBuilderField(fm, "mcp-servers", "{}", &acc.mcpServersBuilder)
acc.plugins = append(acc.plugins, parseStringSliceField(fm["plugins"], false)...)
acc.extractPlugins(fm)
acc.appendJSONSliceField(fm, "safe-outputs", "{}", &acc.safeOutputs)
acc.appendJSONBuilderField(fm, "graders", "{}", &acc.gradersBuilder)
acc.appendJSONSliceField(fm, "mcp-scripts", "{}", &acc.mcpScripts)
Expand Down Expand Up @@ -887,6 +888,7 @@ func parseStringSliceField(value any, keepEmpty bool) []string {
if !ok {
return nil
}

result := make([]string, 0, len(values))
for _, v := range values {
if s, ok := v.(string); ok {
Expand All @@ -902,6 +904,23 @@ func parseStringSliceField(value any, keepEmpty bool) []string {
return result
}

func (acc *importAccumulator) extractPlugins(fm map[string]any) {
values, ok := fm["plugins"].([]any)
if !ok {
return
}
for _, value := range values {
switch typed := value.(type) {
case string:
if typed != "" {
acc.plugins = append(acc.plugins, typed)
}
case map[string]any:
acc.pluginObjects = append(acc.pluginObjects, typed)
}
}
}

func isModelPolicyKey(key string) bool {
return key == modelPolicyAllowedKey || key == modelPolicyBlockedKey
}
Expand Down Expand Up @@ -968,6 +987,7 @@ func (acc *importAccumulator) buildImportsResult() *ImportsResult {
MergedMCPServers: acc.mcpServersBuilder.String(),
MergedEngines: acc.engines,
MergedPlugins: acc.plugins,
MergedPluginObjects: acc.pluginObjects,
MergedSafeOutputs: acc.safeOutputs,
MergedGraders: acc.gradersBuilder.String(),
MergedMCPScripts: acc.mcpScripts,
Expand Down
1 change: 1 addition & 0 deletions pkg/parser/import_processor.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type ImportsResult struct {
MergedMCPServers string // Merged mcp-servers configuration from all imports
MergedEngines []string // Merged engine configurations from all imports
MergedPlugins []string // Agent Plugin references from all imports (merged after main-workflow plugins)
MergedPluginObjects []map[string]any // Object-form Agent Plugin references from all imports
MergedSafeOutputs []string // Merged safe-outputs configurations from all imports
MergedGraders string // Merged graders configuration from all imports (JSON objects, one per line)
MergedMCPScripts []string // Merged mcp-scripts configurations from all imports
Expand Down
38 changes: 32 additions & 6 deletions pkg/parser/schemas/main_workflow_schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2455,16 +2455,42 @@
},
"plugins": {
"type": "array",
"description": "\u26a0\ufe0f Experimental. Agent Plugins to install after the agentic engine. Each GitHub repository reference must include a ref that the compiler resolves to a commit SHA. Using this field emits a compile-time warning.",
"description": "\u26a0\ufe0f Experimental. Agent Plugins to install after the agentic engine. Each GitHub repository reference must include a ref that the compiler resolves to a commit SHA. Using this field emits a compile-time warning. Entries may also be objects to configure per-plugin authentication via github-token or github-app, for installing plugins from private repositories.",
"items": {
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*@[A-Za-z0-9._/-]+$",
"description": "A plugin repository reference in owner/repository[/path]@ref format"
"oneOf": [
{
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*@[A-Za-z0-9._/-]+$",
"description": "A plugin repository reference in owner/repository[/path]@ref format"
},
{
"type": "object",
"description": "Object-form plugin reference with per-plugin authentication.",
"required": ["plugin"],
"additionalProperties": false,
Comment on lines +2467 to +2471

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 6addd0f: imported object-form plugin entries now retain their structured credentials through ImportsResult and workflow assembly, with regression coverage.

"properties": {
"plugin": {
"type": "string",
"minLength": 1,
"pattern": "^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)*@[A-Za-z0-9._/-]+$",
"description": "A plugin repository reference in owner/repository[/path]@ref format"
},
"github-token": {
"$ref": "#/$defs/github_token",
"description": "Optional GitHub token expression used only for this plugin's checkout, for installing plugins from private repositories."
},
"github-app": {
"$ref": "#/$defs/github_app",
"description": "Optional GitHub App config used to mint a token for this plugin's checkout, for installing plugins from private repositories."
}
}
}
]
},
"minItems": 1,
"uniqueItems": true,
"examples": [["github/awesome-copilot/plugins/example@v1"]]
"examples": [["github/awesome-copilot/plugins/example@v1"], [{"plugin": "octo-org/private-plugin@main", "github-token": "${{ secrets.PRIVATE_PLUGIN_TOKEN }}"}]]
},
"permissions": {
"description": "GitHub token permissions for the workflow. Controls what the GITHUB_TOKEN can access during execution. Use the principle of least privilege - only grant the minimum permissions needed.",
Expand Down
4 changes: 4 additions & 0 deletions pkg/workflow/compiler_orchestrator_frontmatter.go
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,10 @@ func (c *Compiler) validateMainWorkflowSchemaAndEventFilters(cleanPath string, f
orchestratorFrontmatterLog.Printf("Skills frontmatter validation failed: %v", err)
return err
}
if err := validateFrontmatterPlugins(frontmatterForValidation); err != nil {
orchestratorFrontmatterLog.Printf("Plugins frontmatter validation failed: %v", err)
return err
}

// Validate event filter mutual exclusivity (branches/branches-ignore, paths/paths-ignore)
if err := ValidateEventFilters(frontmatterForValidation); err != nil {
Expand Down
9 changes: 9 additions & 0 deletions pkg/workflow/compiler_yaml_ai_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,15 @@ func (c *Compiler) generateEngineInstallAndPreAgentSteps(yaml *strings.Builder,
}

if pluginInstaller, ok := engine.(PluginInstallationProvider); ok {
pluginAuthSteps := c.generatePluginAuthTokenSteps(data)
compilerYamlLog.Printf("Adding %d plugin auth token steps for %s", len(pluginAuthSteps), engine.GetID())
for _, step := range pluginAuthSteps {
for _, line := range step {
yaml.WriteString(line)
yaml.WriteByte('\n')
}
}

pluginInstallSteps := pluginInstaller.GetPluginInstallationSteps(data)
compilerYamlLog.Printf("Adding %d plugin installation steps for %s", len(pluginInstallSteps), engine.GetID())
for _, step := range pluginInstallSteps {
Expand Down
3 changes: 3 additions & 0 deletions pkg/workflow/frontmatter_parsing.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ func ParseFrontmatterConfig(frontmatter map[string]any) (*FrontmatterConfig, err
if rawSkills, ok := frontmatter["skills"].([]any); ok {
config.SkillReferences = parseRawSkillReferences(rawSkills)
}
if rawPlugins, ok := frontmatter["plugins"].([]any); ok {
config.PluginReferences = parseRawPluginReferences(rawPlugins)
}
if ambientFolders, err := extractAmbientFolders(frontmatter); err != nil {
return nil, err
} else if ambientFolders != nil {
Expand Down
3 changes: 2 additions & 1 deletion pkg/workflow/frontmatter_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,8 @@ type FrontmatterConfig struct {
Labels []string `json:"labels,omitempty"`
Skills []any `json:"skills,omitempty"`
SkillReferences []SkillReference `json:"-"`
Plugins []string `json:"plugins,omitempty"`
Plugins []any `json:"plugins,omitempty"`
PluginReferences []PluginReference `json:"-"`
AmbientFolders []string `json:"ambient-folders,omitempty"`
GitHubApp *GitHubAppConfig `json:"github-app,omitempty"`

Expand Down
99 changes: 83 additions & 16 deletions pkg/workflow/plugin_installation.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,18 +112,70 @@ func pluginStagingName(parsed parsedSkillRefSpec, index int) string {
return fmt.Sprintf("plugin-%d-%s", index, strings.Join(repoParts[2:], "__"))
}

// pluginAppTokenStepID returns the step ID used to mint (and later reference) the
// GitHub App installation token for the index-th plugin's github-app credential.
func pluginAppTokenStepID(index int) string {
return fmt.Sprintf("plugin-app-token-%d", index)
}

// pluginTokenExpression resolves the checkout token expression for the index-th plugin,
// based on its optional per-plugin github-token/github-app credential. Returns an empty
// string when no credential is configured, in which case the checkout step omits the
// "token" input entirely and actions/checkout falls back to the workflow's default token.
func pluginTokenExpression(workflowData *WorkflowData, index int) string {
if workflowData == nil || index < 0 || index >= len(workflowData.PluginReferences) {
return ""
}
ref := workflowData.PluginReferences[index]
if ref.GitHubApp != nil {
token := fmt.Sprintf("${{ steps.%s.outputs.token }}", pluginAppTokenStepID(index))
if ref.GitHubApp.shouldIgnoreMissingKey() {
token = combineTokenExpressions(token, getEffectiveGitHubToken(""))
}
return token
}
return ref.GitHubToken
}

// generatePluginAuthTokenSteps mints GitHub App installation tokens for every plugin
// whose github-app credential is configured. Each minting step is emitted before the
// engine's plugin installation steps (which include the checkout step consuming the
// minted token), regardless of which agentic engine is selected: the checkout of a
// plugin's pinned commit is engine-agnostic and always happens before any
// engine-specific installation command runs.
func (c *Compiler) generatePluginAuthTokenSteps(workflowData *WorkflowData) []GitHubActionStep {
if workflowData == nil || len(workflowData.PluginReferences) == 0 {
return nil
}

var steps []GitHubActionStep
for i, ref := range workflowData.PluginReferences {
if ref.GitHubApp == nil {
continue
}
repoParts := strings.Split(parseSkillRefSpec(ref.Plugin).repoPath, "/")
if len(repoParts) < 2 {
continue
}
lines := c.buildGitHubAppTokenMintStepWithMeta(
ref.GitHubApp,
nil,
repoParts[1],
strings.Join(repoParts[:2], "/"),
fmt.Sprintf("Generate GitHub App token for agent plugin %d", i+1),
pluginAppTokenStepID(i),
)
steps = append(steps, GitHubActionStep(strings.Split(strings.TrimSuffix(strings.Join(lines, ""), "\n"), "\n")))
}
return steps
}

func generatePluginInstallationSteps(workflowData *WorkflowData, spec pluginInstallSpec) []GitHubActionStep {
if workflowData == nil || len(workflowData.Plugins) == 0 {
return nil
}

// Each plugin contributes at most two steps. Guard the capacity computation so
// the multiplication can never overflow for an unexpectedly large plugin list.
capacity := len(workflowData.Plugins)
if capacity <= math.MaxInt/2 {
capacity *= 2
}
steps := make([]GitHubActionStep, 0, capacity)
steps := make([]GitHubActionStep, 0, pluginInstallationStepCapacity(len(workflowData.Plugins)))
checkoutAction := getActionPinForData("actions/checkout", workflowData)
for i, plugin := range workflowData.Plugins {
parsed := parseSkillRefSpec(plugin)
Expand All @@ -141,15 +193,7 @@ func generatePluginInstallationSteps(workflowData *WorkflowData, spec pluginInst
checkoutPath := pluginCheckoutPath(i)
installPath := pluginCheckoutSubpath(parsed, i)

steps = append(steps, GitHubActionStep{
" - name: Checkout agent plugin " + parsed.repoPath,
" uses: " + checkoutAction,
" with:",
" repository: " + repository,
" ref: " + parsed.ref,
" path: " + checkoutPath,
" persist-credentials: false",
})
steps = append(steps, newPluginCheckoutStep(workflowData, checkoutAction, parsed, repository, checkoutPath, i))

if spec.CustomInstall != nil {
steps = append(steps, spec.CustomInstall(parsed, checkoutPath, installPath, i)...)
Expand Down Expand Up @@ -185,3 +229,26 @@ func generatePluginInstallationSteps(workflowData *WorkflowData, spec pluginInst

return steps
}

func pluginInstallationStepCapacity(pluginCount int) int {
if pluginCount <= math.MaxInt/2 {
return pluginCount * 2
}
return pluginCount
}

func newPluginCheckoutStep(workflowData *WorkflowData, checkoutAction string, parsed parsedSkillRefSpec, repository, checkoutPath string, index int) GitHubActionStep {
step := GitHubActionStep{
" - name: Checkout agent plugin " + parsed.repoPath,
" uses: " + checkoutAction,
" with:",
" repository: " + repository,
" ref: " + parsed.ref,
" path: " + checkoutPath,
" persist-credentials: false",
}
if tokenExpr := pluginTokenExpression(workflowData, index); tokenExpr != "" {
step = append(step, " token: "+tokenExpr)
}
return step
}
Loading
Loading