Add support for defined custom safe-output scripts#21582
Conversation
Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
This comment has been minimized.
This comment has been minimized.
|
Hey
If you'd like a hand finishing the remaining items, you can use this prompt with your coding agent:
|
|
@copilot keep working |
All checklist items are complete. The implementation adds Usage example: safe-outputs:
scripts:
slack-post-message:
description: Post a message to Slack
inputs:
channel:
description: Target channel
required: true
type: string
message:
description: Message text
required: true
type: string
script: |
async function main(config = {}) {
const { channel, message } = config;
return async function handleSlackPostMessage(item) {
// ... handler logic using github, context, core globals
return { success: true };
};
}
module.exports = { main };How it works:
Build, lint, and all 15 new unit tests pass. 174 existing workflows recompile cleanly. Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
|
@copilot use the same trick as in MCP-scripts or actions/github-script and let the user write the body of main directly , then wrap it with main when writing the JavaScript file in the action. |
…only Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Done in bda995e. Users now write only the body of Before (required boilerplate): async function main(config = {}) {
const { channel } = config;
return async (msg) => ({ success: true });
}
module.exports = { main };After (just the body): const { channel } = config;
return async (msg) => ({ success: true });The compiler generates the full module file with the Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
|
@copilot update smoke-claude to use safe-output scripts to post a ficticious slack message. |
…scripts to schema Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com>
Done in 9de4b93. scripts:
post-slack-message:
description: Post a message to a fictitious Slack channel (smoke test only)
inputs:
channel: { type: string, required: true }
message: { type: string, required: true }
script: |
const { channel, message } = config;
return async function handlePostSlackMessage(item) {
const targetChannel = item.data?.channel || channel || "#general";
const text = item.data?.message || message || "(no message)";
core.info(`[FICTITIOUS SLACK] → ${targetChannel}: ${text}`);
return { success: true, channel: targetChannel, message: text };
};The Warning Firewall rules blocked me from connecting to one or more addresses (expand for details)I tried to connect to the following addresses, but was blocked by firewall rules:
If you need me to access, download, or install something from one of these locations, you can either:
|
|
@copilot generate the typescript definition so that users know the typedefinition of config. (put it in a separate file) |
|
🎬 THE END — Smoke Claude MISSION: ACCOMPLISHED! The hero saves the day! ✨ |
|
Commit pushed:
|
Smoke Test (Codex)PRs: "chore: upgrade gh-aw-firewall from v0.24.2 to v0.24.3"; "chore: bump MCP gateway version to v0.1.18"
Warning
|
Smoke Test Results - Run §23272536774
Overall: PR Author:
|
There was a problem hiding this comment.
Reviewed PR #21582 — "Add support for defined custom safe-output scripts". The implementation looks clean: SafeScriptConfig is well-structured, the reuse of generateCustomJobToolDefinition is elegant, and deterministic JSON output via sorted keys is a good practice. Two minor inline observations added.
📰 BREAKING: Report filed by Smoke Copilot
|
|
||
| // safeOutputScriptFilename returns the .cjs filename for a normalized safe output script name. | ||
| func safeOutputScriptFilename(normalizedName string) string { | ||
| return "safe_output_script_" + normalizedName + ".cjs" |
There was a problem hiding this comment.
The filename format safe_output_script_(name).cjs is clear and consistent with the naming convention. Consider adding a constant for the prefix "safe_output_script_" to avoid potential typos if this string is referenced elsewhere.
| Inputs: scriptConfig.Inputs, | ||
| } | ||
| return generateCustomJobToolDefinition(scriptName, jobConfig) | ||
| } |
There was a problem hiding this comment.
Nice reuse of generateCustomJobToolDefinition by adapting the script config into a SafeJobConfig. This keeps tool schema generation DRY. 👍
|
📰 VERDICT: Smoke Copilot has concluded. All systems operational. This is a developing story. 🎤 |
Agent Container Tool Check
Result: 12/12 tools available ✅ Overall Status: PASS
|
|
Smoke Test Results — Run §23272536740 — Status: PARTIAL (2 skipped)
|
There was a problem hiding this comment.
💥 Automated smoke test review - all systems nominal!
💥 [THE END] — Illustrated by Smoke Claude
| Name string `yaml:"name,omitempty"` | ||
| Description string `yaml:"description,omitempty"` | ||
| Inputs map[string]*InputDefinition `yaml:"inputs,omitempty"` | ||
| Script string `yaml:"script,omitempty"` // Inline JavaScript handler (must export a main factory function) |
There was a problem hiding this comment.
The Script field comment "must export a main factory function" is useful — consider also documenting the expected signature in the comment, e.g., async function main(config) => async (item, resolvedTemporaryIds, temporaryIdMap) => result for completeness.
| // SafeScriptConfig defines a custom safe output handler script that runs in the handler loop. | ||
| // Scripts run within the consolidated safe-outputs job as part of the handler manager, | ||
| // unlike SafeJobConfig which creates a separate GitHub Actions job. | ||
| type SafeScriptConfig struct { |
There was a problem hiding this comment.
Nice struct layout. Consider whether Script should be validated at parse time (e.g., non-empty check) to surface misconfigured scripts early rather than at runtime.
|
@copilot ensure that https://github.com/github/gh-aw/actions/runs/23272536740 has the proper tools and safe output call. |
The run at https://github.com/github/gh-aw/actions/runs/23272536740 has everything working correctly:
|
There was a problem hiding this comment.
Pull request overview
This PR adds a new safe-outputs.scripts feature for defining inline JavaScript safe-output handlers that run inside the consolidated safe-outputs handler-manager loop (without spawning separate GitHub Actions jobs), including schema/tooling/runtime support and examples.
Changes:
- Add
safe-outputs.scriptsparsing/config plumbing and include scripts in “enabled safe outputs” state + tool generation. - Generate wrapped
.cjsmodules for each script at runtime and load/dispatch them from the handler manager viaGH_AW_SAFE_OUTPUT_SCRIPTS. - Add schema + TypeScript authoring types and update
smoke-claudeto exercise a script tool.
Reviewed changes
Copilot reviewed 15 out of 16 changed files in this pull request and generated 6 comments.
Show a summary per file
| File | Description |
|---|---|
pkg/workflow/safe_scripts.go |
Adds script config parsing + env-var JSON builder + tool-definition adapter. |
pkg/workflow/safe_scripts_test.go |
Unit tests for parsing, JSON mapping, tool defs, wrapper generation, and step/env emission. |
pkg/workflow/safe_outputs_tools_filtering.go |
Includes script tools in filtered + dynamic tool JSON. |
pkg/workflow/safe_outputs_state.go |
Treats scripts as enabled/non-builtin safe outputs. |
pkg/workflow/safe_outputs_config.go |
Extracts safe-outputs.scripts from frontmatter. |
pkg/workflow/safe_outputs_config_generation.go |
Emits script metadata into GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG. |
pkg/workflow/compiler_types.go |
Adds Scripts field to SafeOutputsConfig. |
pkg/workflow/compiler_safe_outputs_steps.go |
Adds GH_AW_SAFE_OUTPUT_SCRIPTS env var to handler-manager step. |
pkg/workflow/compiler_safe_outputs_job.go |
Generates wrapped script module content + writes files via heredoc step. |
actions/setup/js/safe_output_helpers.cjs |
Adds loadCustomSafeOutputScriptHandlers() to parse env var mapping. |
actions/setup/js/safe_output_handler_manager.cjs |
Loads/initializes custom script handlers and dispatches by message type. |
pkg/parser/schemas/main_workflow_schema.json |
Adds schema for safe-outputs.scripts and its fields. |
actions/setup/js/types/safe-output-script.d.ts |
Adds TS types/docs for script authors. |
.github/workflows/smoke-claude.md |
Adds a post-slack-message script and a smoke test step using the new tool. |
.github/workflows/smoke-claude.lock.yml |
Regenerated lockfile reflecting the new script and tool metadata. |
.changeset/patch-add-safe-output-scripts.md |
Changeset entry for the new feature. |
Comments suppressed due to low confidence (1)
pkg/workflow/compiler_safe_outputs_job.go:569
main(config)is currently passed an object shaped like{ description, inputs }(from GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG), so destructuring per-input values fromconfig(const { channel, message } = config;) will always yieldundefined. Either (a) change the generated wrapper/docs to source defaults fromconfig.inputs.<name>.default, or (b) include top-level per-input default values in the script’s config object so the destructured locals are meaningful.
// generateSafeOutputScriptContent generates a complete JavaScript module for a custom safe-output
// script handler. Users write only the handler body (the code that runs inside the async handler
// function for each item), and the compiler generates the full outer wrapper including:
// - Config input destructuring: const { channel, message } = config;
// - Handler function: return async function handleX(item, resolvedTemporaryIds) { ... }
// - The module.exports boilerplate
func generateSafeOutputScriptContent(scriptName string, scriptConfig *SafeScriptConfig) string {
var sb strings.Builder
sb.WriteString("// @ts-check\n")
sb.WriteString("/// <reference types=\"./safe-output-script\" />\n")
sb.WriteString("// Auto-generated safe-output script handler: " + scriptName + "\n\n")
sb.WriteString("/** @type {import('./types/safe-output-script').SafeOutputScriptMain} */\n")
sb.WriteString("async function main(config = {}) {\n")
// Auto-destructure all declared input names from config (provides access to
// static YAML config values such as defaults).
if len(scriptConfig.Inputs) > 0 {
inputNames := make([]string, 0, len(scriptConfig.Inputs))
for name := range scriptConfig.Inputs {
safeName := stringutil.SanitizeParameterName(name)
if safeName != name {
inputNames = append(inputNames, name+": "+safeName)
} else {
inputNames = append(inputNames, name)
}
}
sort.Strings(inputNames)
sb.WriteString(" const { " + strings.Join(inputNames, ", ") + " } = config;\n")
}
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| inputNames := make([]string, 0, len(scriptConfig.Inputs)) | ||
| for name := range scriptConfig.Inputs { | ||
| safeName := stringutil.SanitizeParameterName(name) | ||
| if safeName != name { | ||
| inputNames = append(inputNames, name+": "+safeName) | ||
| } else { | ||
| inputNames = append(inputNames, name) | ||
| } | ||
| } | ||
| sort.Strings(inputNames) | ||
| sb.WriteString(" const { " + strings.Join(inputNames, ", ") + " } = config;\n") |
| "description": inputDef.Description, | ||
| "required": inputDef.Required, | ||
| } | ||
| if inputDef.Default != "" { |
| "scripts": { | ||
| "type": "object", | ||
| "description": "Inline JavaScript script handlers that run inside the consolidated safe-outputs job handler loop. Unlike 'jobs' (which create separate GitHub Actions jobs), scripts execute in-process alongside the built-in handlers. Users write only the body of the main function — the compiler wraps it with 'async function main(config = {}) { ... }' and 'module.exports = { main };' automatically. Script names containing dashes will be automatically normalized to underscores (e.g., 'post-slack-message' becomes 'post_slack_message').", | ||
| "patternProperties": { | ||
| "^[a-zA-Z_][a-zA-Z0-9_-]*$": { | ||
| "type": "object", | ||
| "description": "Inline script handler configuration. The script body has access to 'config' (the handler config object) and should return an async function that processes individual safe-output messages.", | ||
| "properties": { | ||
| "name": { | ||
| "type": "string", | ||
| "description": "Display name for the script handler" | ||
| }, | ||
| "description": { | ||
| "type": "string", | ||
| "description": "Description of the script handler (used in MCP tool registration)" | ||
| }, | ||
| "inputs": { | ||
| "type": "object", | ||
| "description": "Input parameters for the script handler", | ||
| "patternProperties": { | ||
| "^[a-zA-Z_][a-zA-Z0-9_-]*$": { | ||
| "type": "object", | ||
| "properties": { | ||
| "description": { | ||
| "type": "string", | ||
| "description": "Input parameter description" | ||
| }, | ||
| "required": { | ||
| "type": "boolean", | ||
| "description": "Whether this input is required", | ||
| "default": false | ||
| }, | ||
| "default": { | ||
| "description": "Default value for the input", | ||
| "oneOf": [{ "type": "string" }, { "type": "boolean" }, { "type": "number" }] | ||
| }, | ||
| "type": { | ||
| "type": "string", | ||
| "enum": ["string", "boolean", "number"], | ||
| "description": "Input parameter type", | ||
| "default": "string" | ||
| } | ||
| }, | ||
| "additionalProperties": false | ||
| } | ||
| }, | ||
| "additionalProperties": false | ||
| }, | ||
| "script": { | ||
| "type": "string", | ||
| "description": "JavaScript handler body. Write only the code that runs inside the handler for each item — the compiler generates the full outer wrapper including config input destructuring (`const { channel, message } = config;`) and the handler function (`return async function handleX(item, resolvedTemporaryIds) { ... }`). The body has access to `item` (runtime message with input values), `resolvedTemporaryIds` (map of temporary IDs), and config-destructured local variables for each declared input." | ||
| } |
| // Load custom script handlers from GH_AW_SAFE_OUTPUT_SCRIPTS | ||
| // These are inline scripts defined in safe-outputs.scripts that run in the handler loop | ||
| const customScriptHandlers = loadCustomSafeOutputScriptHandlers(); | ||
| if (customScriptHandlers.size > 0) { | ||
| core.info(`Loading ${customScriptHandlers.size} custom script handler(s): ${[...customScriptHandlers.keys()].join(", ")}`); | ||
| for (const [scriptType, scriptFilename] of customScriptHandlers) { | ||
| const scriptPath = require("path").join(process.env.RUNNER_TEMP || "/tmp", "gh-aw", "actions", scriptFilename); | ||
| try { | ||
| const scriptModule = require(scriptPath); | ||
| if (scriptModule && typeof scriptModule.main === "function") { | ||
| const handlerConfig = config[scriptType] || {}; | ||
| const messageHandler = await scriptModule.main(handlerConfig); | ||
| if (typeof messageHandler !== "function") { | ||
| // Non-fatal: warn and skip this custom script handler rather than crashing the | ||
| // entire safe-output loop. A misconfigured user script should not block all | ||
| // other safe-output operations. | ||
| core.warning(`✗ Custom script handler ${scriptType} main() did not return a function (got ${typeof messageHandler}) — this handler will be skipped`); | ||
| } else { | ||
| messageHandlers.set(scriptType, messageHandler); | ||
| core.info(`✓ Loaded and initialized custom script handler for: ${scriptType}`); | ||
| } |
| Name string `yaml:"name,omitempty"` | ||
| Description string `yaml:"description,omitempty"` | ||
| Inputs map[string]*InputDefinition `yaml:"inputs,omitempty"` | ||
| Script string `yaml:"script,omitempty"` // Inline JavaScript handler (must export a main factory function) |
| if safeOutputs, exists := frontmatter["safe-outputs"]; exists { | ||
| if safeOutputsMap, ok := safeOutputs.(map[string]any); ok { | ||
| if scripts, exists := safeOutputsMap["scripts"]; exists { | ||
| if scriptsMap, ok := scripts.(map[string]any); ok { | ||
| return parseSafeScriptsConfig(scriptsMap) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return make(map[string]*SafeScriptConfig) |
- Add inline JavaScript script handlers (`safe-outputs.scripts`) section to custom-safe-outputs.md, documenting the new in-process handler feature added in #21582 - Fix github-tools.md guard policy description: repos defaults to "all" when omitted, only min-integrity is required (#21718) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds
safe-outputs.scriptssupport — inline JavaScript handlers that run within the consolidated safe-outputs job's handler loop, similar tosafe-outputs.jobsbut without creating a separate GitHub Actions job.Usage
Users write only the handler body — the code that executes inside the handler function for each item. The compiler automatically generates the full outer wrapper including config input destructuring and the handler function:
The compiler generates the full module file, including config destructuring, handler function wrapper,
// @ts-checkand a@typeannotation for IDE support:How It Works
${RUNNER_TEMP}/gh-aw/actions/safe_output_script_<name>.cjsvia a heredoc step in the safe-outputs job. The user's handler body is automatically wrapped with config destructuring and the handler function — users write only the handler body.GH_AW_SAFE_OUTPUT_SCRIPTS(a JSON map of normalized name → filename) is passed as an env var to the handler manager step.main(config), and dispatches messages of the matching type to the returned handler function.Changes Made
compiler_types.go— AddedScripts map[string]*SafeScriptConfigfield toSafeOutputsConfigsafe_scripts.go— New file:SafeScriptConfigstruct, parsing helpers, JSON env var builder, andgenerateCustomScriptToolDefinition()safe_outputs_config.go— Parsescripts:key from frontmattersafe_outputs_state.go— Include Scripts inhasAnySafeOutputEnabled()andhasNonBuiltinSafeOutputsEnabled()safe_outputs_config_generation.go— Emit script config intoGH_AW_SAFE_OUTPUTS_HANDLER_CONFIGsafe_outputs_tools_filtering.go— Include script tool definitions in filtered and dynamic tools JSONcompiler_safe_outputs_job.go—generateSafeOutputScriptContent()takes*SafeScriptConfigand generates the full outer wrapper: config input destructuring (const { channel, message } = config;), handler function (return async function handleX(item, resolvedTemporaryIds, temporaryIdMap) { ... }), andmodule.exports; addedscriptNameToHandlerName()helper;buildCustomScriptFilesStep()writes wrapped scripts via heredoccompiler_safe_outputs_steps.go— EmitGH_AW_SAFE_OUTPUT_SCRIPTSenv var on handler manager stepsafe_output_helpers.cjs—loadCustomSafeOutputScriptHandlers()parsesGH_AW_SAFE_OUTPUT_SCRIPTSsafe_output_handler_manager.cjs— Loads and dispatches to custom script handlers after built-in HANDLER_MAP handlers; custom script loading errors are non-fatal (emitcore.warningand skip the handler rather than crashing the entire loop)main_workflow_schema.json— Addedscriptsas a validsafe-outputsproperty with full sub-schema forinputsandscriptfields; updatedscriptdescription to clarify users write the handler bodysmoke-claude.md— Addedpost-slack-messagescript as a real-world usage example (handler body only), with a corresponding test step (Fix orphan removal on "gh aw remove" #11) that exercises thepost_slack_messagetoolactions/setup/js/types/safe-output-script.d.ts— TypeScript definition file documenting the types available when writing a custom safe-output script; includesResolvedTemporaryIdEntry,TemporaryIdMap, updatedSafeOutputScriptHandlerwithtemporaryIdMapas 3rd parameter, andexport declare function mainTypeScript Definitions
The
safe-output-script.d.tsfile exports the following types for script authors:SafeOutputScriptInputDefinitiontype,description,required,default,options)SafeOutputScriptConfigconfigparameter ofmain()— static YAML metadata (description+inputsschema)SafeOutputScriptItem<TInputs>ResolvedTemporaryIdEntryrepo+number)ResolvedTemporaryIdsTemporaryIdMapMap<string, ResolvedTemporaryIdEntry>(3rd handler argument)SafeOutputScriptHandler<TInputs>(item, resolvedTemporaryIds, temporaryIdMap)returned bymain()SafeOutputScriptMain<TInputs>main(declared)module.exports = { main }for TypeScript tooling validationThe generated
.cjsfiles include// @ts-checkand a@typeJSDoc annotation so IDEs can type-check the generatedmainfunction.Temporary ID Support
Custom script handlers receive the same three-argument calling convention as built-in handlers:
item— the per-call message with runtime input valuesresolvedTemporaryIds— plain-object snapshot of resolved temporary IDs at invocation timetemporaryIdMap— liveMapupdated as new issues/PRs are created during the runError Handling
Custom script loading is fully non-fatal: syntax errors, missing
mainexports, ormain()not returning a function all emitcore.warningand skip that handler rather than crashing the entire safe-output loop. Errors during handler invocation are caught by the existing message processing loop and recorded as failed results without blocking other operations.Testing
// @ts-check), handler name generation, step generation, and handler manager integrationmake build,make fmt,make lintall passsmoke-claudeworkflow recompiles cleanly with the newpost-slack-messagescriptOriginal prompt
📱 Kick off Copilot coding agent tasks wherever you are with GitHub Mobile, available on iOS and Android.
✨ PR Review Safe Output Test - Run 23272536740