Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/patch-use-prompt-file-for-copilot.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

85 changes: 78 additions & 7 deletions actions/setup/js/copilot_driver.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
* - Maximum 3 retry attempts after the initial run.
*
* Usage: node copilot_driver.cjs <command> [args...]
* Example: node copilot_driver.cjs copilot --add-dir /tmp/ --prompt "..."
* Example: node copilot_driver.cjs copilot --add-dir /tmp/ --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt
*/

"use strict";
Expand All @@ -37,6 +37,9 @@ const INITIAL_DELAY_MS = 5000;
const BACKOFF_MULTIPLIER = 2;
// Maximum delay cap in milliseconds
const MAX_DELAY_MS = 60000;
// If prompt files are larger than this threshold, avoid inlining into argv.
const PROMPT_FILE_INLINE_THRESHOLD_BYTES = 100 * 1024;
const PROMPT_FILE_INLINE_THRESHOLD_LABEL = "100KB";

// Pattern to detect transient CAPIError 400 in copilot output
const CAPI_ERROR_400_PATTERN = /CAPIError:\s*400/;
Expand Down Expand Up @@ -167,7 +170,7 @@ function runProcess(command, args, attempt) {
return new Promise(resolve => {
const startTime = Date.now();
// Redact --prompt value from logs to avoid leaking prompt content
const safeArgs = args.map((arg, i) => (args[i - 1] === "--prompt" ? "<redacted>" : arg));
const safeArgs = args.map((arg, i) => (args[i - 1] === "--prompt" || args[i - 1] === "-p" ? "<redacted>" : arg));
log(`attempt ${attempt + 1}: spawning: ${command} ${safeArgs.join(" ")}`);

const child = spawn(command, args, {
Expand Down Expand Up @@ -235,6 +238,63 @@ function runProcess(command, args, attempt) {
});
}

/**
* Build a compact fallback prompt that asks the agent to read instructions from disk.
* @param {string} promptFile
* @returns {string}
*/
function buildPromptFileFallbackInstruction(promptFile) {
return `Read the full instructions from ${promptFile} and execute them exactly as written.`;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The buildPromptFileFallbackInstruction returns a bare instruction string telling the agent to read from disk. However, if the prompt file path contains spaces or special characters, this instruction might not convey the path accurately. Consider quoting the path in the instruction string or documenting that prompt file paths must not contain spaces.

}

/**
* Replace --prompt-file arguments with -p prompt text to support older Copilot CLIs.
* For files over 100KB, emit a compact fallback prompt that instructs the agent to
* read and execute the full prompt file from disk.
* @param {string[]} args
* @returns {string[]}
*/
function resolvePromptFileArgs(args) {
/** @type {string[]} */
const resolvedArgs = [];

for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg !== "--prompt-file") {
resolvedArgs.push(arg);
continue;
}

if (i + 1 >= args.length) {
log("warning: --prompt-file provided without a path; leaving arguments unchanged");
resolvedArgs.push(arg);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

The resolvePromptFileArgs function reads the entire file synchronously before spawning the process. For the 100KB fallback case this is avoided, but for files slightly under the threshold the full content is still loaded into memory and passed as a CLI argument. Consider logging the inlined prompt size for observability.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Smoke Test Observation: The resolvePromptFileArgs function correctly handles the edge cases (missing path, oversized file, unreadable file). The PROMPT_FILE_INLINE_THRESHOLD_BYTES = 100 * 1024 threshold is well-chosen for avoiding ARG_MAX issues. Good defensive coding here!

continue;
}
const promptFile = args[i + 1];

try {
const stat = fs.statSync(promptFile);
log(`resolved --prompt-file: path=${promptFile} size=${stat.size}B`);

if (stat.size > PROMPT_FILE_INLINE_THRESHOLD_BYTES) {
log(`prompt file exceeds ${PROMPT_FILE_INLINE_THRESHOLD_LABEL}; using compact fallback prompt`);
resolvedArgs.push("-p", buildPromptFileFallbackInstruction(promptFile));
} else {
const promptText = fs.readFileSync(promptFile, "utf8");
resolvedArgs.push("-p", promptText);
}
i++; // Skip the prompt-file path argument
} catch (error) {
const err = /** @type {Error} */ error;
log(`warning: failed to resolve --prompt-file ${promptFile}: ${err.message}; leaving arguments unchanged`);
resolvedArgs.push(arg, promptFile);
i++; // Skip the prompt-file path argument
}
}

return resolvedArgs;
}

/**
* Main entry point: run copilot with retry logic for partially-executed sessions.
*/
Expand All @@ -249,14 +309,15 @@ async function main() {
log(`starting: command=${command} maxRetries=${MAX_RETRIES} initialDelayMs=${INITIAL_DELAY_MS}` + ` backoffMultiplier=${BACKOFF_MULTIPLIER} maxDelayMs=${MAX_DELAY_MS}` + ` nodeVersion=${process.version} platform=${process.platform}`);

await checkCommandAccessible(command);
const resolvedArgs = resolvePromptFileArgs(args);

let delay = INITIAL_DELAY_MS;
let lastExitCode = 1;
const driverStartTime = Date.now();

for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
// Add --continue flag on retries so the copilot session continues from where it left off
const currentArgs = attempt > 0 ? [...args, "--continue"] : args;
const currentArgs = attempt > 0 ? [...resolvedArgs, "--continue"] : resolvedArgs;

if (attempt > 0) {
log(`retry ${attempt}/${MAX_RETRIES}: sleeping ${delay}ms before next attempt with --continue`);
Expand Down Expand Up @@ -335,7 +396,17 @@ async function main() {
process.exit(lastExitCode);
}

main().catch(err => {
log(`unexpected error: ${err.message}`);
process.exit(1);
});
if (typeof module !== "undefined" && module.exports) {
module.exports = {
PROMPT_FILE_INLINE_THRESHOLD_BYTES,
buildPromptFileFallbackInstruction,
resolvePromptFileArgs,
};
}

if (require.main === module) {
main().catch(err => {
log(`unexpected error: ${err.message}`);
process.exit(1);
});
}
31 changes: 31 additions & 0 deletions actions/setup/js/copilot_driver.test.cjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { describe, it, expect } from "vitest";
import { createRequire } from "module";
import fs from "fs";
import os from "os";
import path from "path";

const require = createRequire(import.meta.url);
const { resolvePromptFileArgs, buildPromptFileFallbackInstruction, PROMPT_FILE_INLINE_THRESHOLD_BYTES } = require("./copilot_driver.cjs");

describe("copilot_driver.cjs", () => {
// Test the core logic patterns used by the driver without importing the module
Expand Down Expand Up @@ -284,6 +291,30 @@ describe("copilot_driver.cjs", () => {
});
});

describe("prompt-file support", () => {
it("inlines small prompt files as -p", () => {
const promptFile = path.join(os.tmpdir(), `copilot-driver-small-${Date.now()}.txt`);
fs.writeFileSync(promptFile, "small prompt body", "utf8");

const resolved = resolvePromptFileArgs(["--add-dir", "/tmp", "--prompt-file", promptFile, "--allow-all-tools"]);
expect(resolved).toEqual(["--add-dir", "/tmp", "-p", "small prompt body", "--allow-all-tools"]);
});

it("uses compact fallback prompt when prompt file is larger than 100KB", () => {
const promptFile = path.join(os.tmpdir(), `copilot-driver-large-${Date.now()}.txt`);
fs.writeFileSync(promptFile, "x".repeat(PROMPT_FILE_INLINE_THRESHOLD_BYTES + 1), "utf8");

const resolved = resolvePromptFileArgs(["--prompt-file", promptFile, "--allow-all-tools"]);
expect(resolved).toEqual(["-p", buildPromptFileFallbackInstruction(promptFile), "--allow-all-tools"]);
});

it("keeps --prompt-file arguments unchanged when file resolution fails", () => {
const missingPath = path.join(os.tmpdir(), `copilot-driver-missing-${Date.now()}.txt`);
const resolved = resolvePromptFileArgs(["--prompt-file", missingPath, "--allow-all-tools"]);
expect(resolved).toEqual(["--prompt-file", missingPath, "--allow-all-tools"]);
});
});

describe("formatDuration", () => {
// Inline the same logic as the driver's formatDuration for unit testing
function formatDuration(ms) {
Expand Down
13 changes: 6 additions & 7 deletions pkg/workflow/copilot_engine_execution.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,8 +175,8 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st
}

// Build the command - model is always passed via COPILOT_MODEL env var (see env block below).
// The --add-dir "${GITHUB_WORKSPACE}" and --prompt args are appended raw (not through
// shellJoinArgs) because they contain shell variable references that must expand at runtime.
// The --add-dir "${GITHUB_WORKSPACE}" arg is appended raw (not through shellJoinArgs)
// because it contains a shell variable reference that must expand at runtime.
//
// When a driver script is provided (GetDriverScriptName), wrap the copilot invocation with
// `node <driver> <commandName> <args>` to enable retry logic for transient CAPIError 400 errors.
Expand All @@ -196,11 +196,11 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st
}

if sandboxEnabled {
// Sandbox mode: add workspace dir and inline prompt (read inside AWF container)
copilotCommand = fmt.Sprintf(`%s %s --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"`, execPrefix, shellJoinArgs(copilotArgs))
// Sandbox mode: add workspace dir and pass prompt file path directly
copilotCommand = fmt.Sprintf(`%s %s --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt`, execPrefix, shellJoinArgs(copilotArgs))
} else {
// Non-sandbox mode: prompt is read from a shell variable set earlier in the script
copilotCommand = fmt.Sprintf(`%s %s --prompt "$COPILOT_CLI_INSTRUCTION"`, execPrefix, shellJoinArgs(copilotArgs))
// Non-sandbox mode: pass prompt file path directly
copilotCommand = fmt.Sprintf(`%s %s --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt`, execPrefix, shellJoinArgs(copilotArgs))
}
Comment on lines 198 to 204
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

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

--prompt-file is emitted unconditionally, but this engine supports pinning Copilot CLI versions via engine.version. If an older pinned version doesn’t recognize --prompt-file, the workflow will fail at startup. Consider adding a version gate (similar to copilotSupportsNoAskUser) with a minimum version constant, and fall back to the previous --prompt behavior when --prompt-file isn’t supported.

Copilot uses AI. Check for mistakes.
Comment on lines 198 to 204
Copy link

Copilot AI Apr 15, 2026

Choose a reason for hiding this comment

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

The prompt path is now hard-coded in the command even though GH_AW_PROMPT is set in the step env later in this function. To avoid duplicating the prompt path in multiple places (and simplify future changes), consider using the env var in the command (and quoting it) rather than repeating /tmp/gh-aw/aw-prompts/prompt.txt.

See below for a potential fix:

		// Sandbox mode: add workspace dir and use the configured prompt file path from the step env
		copilotCommand = fmt.Sprintf(`%s %s --add-dir "${GITHUB_WORKSPACE}" --prompt-file "${GH_AW_PROMPT}"`, execPrefix, shellJoinArgs(copilotArgs))
	} else {
		// Non-sandbox mode: use the configured prompt file path from the step env
		copilotCommand = fmt.Sprintf(`%s %s --prompt-file "${GH_AW_PROMPT}"`, execPrefix, shellJoinArgs(copilotArgs))

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Good suggestion! Using \$\{GH_AW_PROMPT} from the step env would centralize the prompt path and reduce duplication. The current hard-coded path works for the standard setup, but your approach is more maintainable and resilient to future path changes.

📰 BREAKING: Report filed by Smoke Copilot · ● 1M

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔍 Smoke Test Observation: The --prompt-file path is hard-coded to /tmp/gh-aw/aw-prompts/prompt.txt in both sandbox and non-sandbox modes. This looks consistent with the existing convention, but consider whether a constant or an env var reference (\$\{GH_AW_PROMPT}) would make future path changes easier to manage.


// Conditionally wrap with sandbox (AWF only)
Expand Down Expand Up @@ -262,7 +262,6 @@ func (e *CopilotEngine) GetExecutionSteps(workflowData *WorkflowData, logFile st
command = fmt.Sprintf(`set -o pipefail
touch %s
(umask 177 && touch %s)
COPILOT_CLI_INSTRUCTION="$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"
%s%s 2>&1 | tee %s`, AgentStepSummaryPath, logFile, mkdirCommands.String(), copilotCommand, logFile)
}

Expand Down
20 changes: 13 additions & 7 deletions pkg/workflow/copilot_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,14 @@ func TestCopilotEngineExecutionSteps(t *testing.T) {
t.Errorf("Expected command to contain log file name in step content:\n%s", stepContent)
}

if !strings.Contains(stepContent, "--prompt-file /tmp/gh-aw/aw-prompts/prompt.txt") {
t.Errorf("Expected command to pass prompt file path directly, got:\n%s", stepContent)
}

if strings.Contains(stepContent, "COPILOT_CLI_INSTRUCTION=") {
t.Errorf("Expected command to avoid loading prompt into shell variable, got:\n%s", stepContent)
}

if !strings.Contains(stepContent, "COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}") {
t.Errorf("Expected COPILOT_GITHUB_TOKEN environment variable in step content:\n%s", stepContent)
}
Expand Down Expand Up @@ -728,7 +736,7 @@ func TestCopilotEngineShellEscaping(t *testing.T) {
}
}

func TestCopilotEngineInstructionPromptNotEscaped(t *testing.T) {
func TestCopilotEnginePromptFilePath(t *testing.T) {
engine := NewCopilotEngine()
workflowData := &WorkflowData{
Name: "test-workflow",
Expand Down Expand Up @@ -760,14 +768,12 @@ func TestCopilotEngineInstructionPromptNotEscaped(t *testing.T) {
t.Fatalf("Could not find copilot command in step content:\n%s", stepContent)
}

// The $COPILOT_CLI_INSTRUCTION should NOT be wrapped in additional single quotes
if strings.Contains(copilotCommand, `'"$COPILOT_CLI_INSTRUCTION"'`) {
t.Errorf("$COPILOT_CLI_INSTRUCTION should not be wrapped in single quotes: %s", copilotCommand)
if !strings.Contains(copilotCommand, "--prompt-file /tmp/gh-aw/aw-prompts/prompt.txt") {
t.Errorf("Expected prompt to be passed via --prompt-file, got: %s", copilotCommand)
}

// The $COPILOT_CLI_INSTRUCTION should remain double-quoted for variable expansion
if !strings.Contains(copilotCommand, `"$COPILOT_CLI_INSTRUCTION"`) {
t.Errorf("$COPILOT_CLI_INSTRUCTION should remain double-quoted: %s", copilotCommand)
if strings.Contains(copilotCommand, "--prompt ") {
t.Errorf("Expected no inline --prompt argument expansion, got: %s", copilotCommand)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -399,7 +399,7 @@ jobs:
(umask 177 && touch /tmp/gh-aw/agent-stdio.log)
# shellcheck disable=SC1003
sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.20 --skip-pull --enable-api-proxy \
-- /bin/bash -c '${GH_AW_NODE_BIN:-node} ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
-- /bin/bash -c '${GH_AW_NODE_BIN:-node} ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
env:
COPILOT_AGENT_RUNNER_TYPE: STANDALONE
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -400,7 +400,7 @@ jobs:
(umask 177 && touch /tmp/gh-aw/agent-stdio.log)
# shellcheck disable=SC1003
sudo -E awf --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --allow-domains api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --image-tag 0.25.20 --skip-pull --enable-api-proxy \
-- /bin/bash -c '${GH_AW_NODE_BIN:-node} ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt "$(cat /tmp/gh-aw/aw-prompts/prompt.txt)"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
-- /bin/bash -c '${GH_AW_NODE_BIN:-node} ${RUNNER_TEMP}/gh-aw/actions/copilot_driver.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
env:
COPILOT_AGENT_RUNNER_TYPE: STANDALONE
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
Expand Down