diff --git a/setup/index.js b/setup/index.js index 678ae89b..491d1d99 100644 --- a/setup/index.js +++ b/setup/index.js @@ -17,7 +17,7 @@ const inputParentSpanId = getActionInput("PARENT_SPAN_ID"); const inputJobName = getActionInput("JOB_NAME"); const inputOTLPOIDCToken = getActionInput("OTLP_OIDC_TOKEN"); -const result = spawnSync(path.join(__dirname, "setup.sh"), [], { +const result = spawnSync("bash", [path.join(__dirname, "setup.sh")], { stdio: "inherit", env: Object.assign({}, process.env, { INPUT_SAFE_OUTPUT_CUSTOM_TOKENS: safeOutputCustomTokens, diff --git a/setup/js/convert_gateway_config_copilot.cjs b/setup/js/convert_gateway_config_copilot.cjs index 60f0eac7..4db17de5 100644 --- a/setup/js/convert_gateway_config_copilot.cjs +++ b/setup/js/convert_gateway_config_copilot.cjs @@ -56,6 +56,13 @@ function resolveCopilotConfigOutputPath() { */ function transformCopilotEntry(entry, urlPrefix) { return normalizeGatewayEntry(entry, urlPrefix, transformed => { + // The gateway expresses backend tool timeouts in seconds, while Copilot + // expects its client-side per-server timeout in milliseconds. + if (transformed.timeout === undefined && typeof transformed.toolTimeout === "number" && Number.isFinite(transformed.toolTimeout) && transformed.toolTimeout > 0) { + transformed.timeout = transformed.toolTimeout * 1000; + } + delete transformed.toolTimeout; + // Add tools field if not present if (!transformed.tools) { transformed.tools = ["*"]; diff --git a/setup/js/frontmatter_hash_pure.cjs b/setup/js/frontmatter_hash_pure.cjs index 8f2f1f3b..4665d676 100644 --- a/setup/js/frontmatter_hash_pure.cjs +++ b/setup/js/frontmatter_hash_pure.cjs @@ -130,7 +130,7 @@ async function computeFrontmatterHash(workflowPath, options = {}) { log(`canonical.body-text (inlined-imports): ${canonical["body-text"].substring(0, 200)}...`); } else { // Extract template expressions with env. or vars. - const expressions = extractRelevantTemplateExpressions(markdown); + const expressions = mergeSortedUniqueStrings(extractRelevantTemplateExpressions(markdown), await collectRuntimeImportTemplateExpressions(frontmatterText, markdown, baseDir, fileReader)); if (expressions.length > 0) { canonical["template-expressions"] = expressions; log(`canonical.template-expressions: ${JSON.stringify(expressions)}`); @@ -349,6 +349,213 @@ function extractRelevantTemplateExpressions(markdown) { return [...new Set(expressions)].sort(); } +/** + * Extract all GitHub Actions template expressions. + * @param {string} markdown + * @returns {string[]} + */ +function extractAllTemplateExpressions(markdown) { + const expressions = []; + const regex = /\$\{\{([^}]+)\}\}/g; + let match; + + while ((match = regex.exec(markdown)) !== null) { + if (!match[1].trim()) continue; + expressions.push(match[0]); + } + + return [...new Set(expressions)].sort(); +} + +/** + * @typedef {Object} RuntimeImportReference + * @property {string} path + * @property {number|null} startLine + * @property {number|null} endLine + */ + +/** + * Collect template expressions from content that may be added to the prompt by + * runtime imports. + * @param {string} frontmatterText + * @param {string} markdown + * @param {string} baseDir + * @param {Function} fileReader + * @returns {Promise} + */ +async function collectRuntimeImportTemplateExpressions(frontmatterText, markdown, baseDir, fileReader) { + const seen = new Set(); + let expressions = await extractRuntimeImportTemplateExpressionsFromMarkdown(markdown, baseDir, seen, fileReader); + + const importedBodies = await collectImportedBodies(frontmatterText, baseDir, new Set(), fileReader); + for (const body of importedBodies) { + expressions = mergeSortedUniqueStrings(expressions, extractAllTemplateExpressions(body)); + expressions = mergeSortedUniqueStrings(expressions, await extractRuntimeImportTemplateExpressionsFromMarkdown(body, baseDir, seen, fileReader)); + } + + return expressions; +} + +/** + * @param {string} markdown + * @param {string} baseDir + * @param {Set} seen + * @param {Function} fileReader + * @returns {Promise} + */ +async function extractRuntimeImportTemplateExpressionsFromMarkdown(markdown, baseDir, seen, fileReader) { + const refs = extractRuntimeImportReferences(markdown); + let expressions = []; + + for (const ref of refs) { + const body = await readRuntimeImportBodyForHash(ref, baseDir, seen, fileReader); + if (body === null) continue; + expressions = mergeSortedUniqueStrings(expressions, extractAllTemplateExpressions(body)); + expressions = mergeSortedUniqueStrings(expressions, await extractRuntimeImportTemplateExpressionsFromMarkdown(body, baseDir, seen, fileReader)); + } + + return expressions; +} + +/** + * @param {string} markdown + * @returns {RuntimeImportReference[]} + */ +function extractRuntimeImportReferences(markdown) { + if (!markdown) return []; + + const refs = []; + const seen = new Set(); + const regex = /\{\{#(?:runtime-import|import)\??(?:[ \t]+|[ \t]*:[ \t]*)([^{}]+?)\}\}/g; + let match; + while ((match = regex.exec(markdown)) !== null) { + const target = match[1].trim(); + if (!target) continue; + const rangeMatch = target.match(/^(.+?):(\d+)-(\d+)$/); + const ref = rangeMatch ? { path: rangeMatch[1].trim(), startLine: Number.parseInt(rangeMatch[2], 10), endLine: Number.parseInt(rangeMatch[3], 10) } : { path: target, startLine: null, endLine: null }; + if (/^https?:\/\//i.test(ref.path)) continue; + const key = `${ref.path.replace(/\\/g, "/")}:${ref.startLine ?? 0}-${ref.endLine ?? 0}`; + if (seen.has(key)) continue; + refs.push(ref); + seen.add(key); + } + return refs; +} + +/** + * @param {RuntimeImportReference} ref + * @param {string} baseDir + * @param {Set} seen + * @param {Function} fileReader + * @returns {Promise} + */ +async function readRuntimeImportBodyForHash(ref, baseDir, seen, fileReader) { + for (const candidate of runtimeImportHashCandidatePaths(ref.path, baseDir)) { + const key = `${candidate}:${ref.startLine ?? 0}-${ref.endLine ?? 0}`; + if (seen.has(key)) return null; + if (fs.existsSync(candidate) && !runtimeImportHashRealPathAllowed(candidate, baseDir)) continue; + try { + let content = await fileReader(candidate); + seen.add(key); + if (ref.startLine !== null || ref.endLine !== null) { + content = applyRuntimeImportLineRangeForHash(content, ref.startLine, ref.endLine); + } + const { markdown } = extractFrontmatterAndBody(content); + return markdown; + } catch (err) { + continue; + } + } + return null; +} + +function runtimeImportHashRealPathAllowed(candidate, baseDir) { + const workspaceRoot = runtimeImportHashWorkspaceRoot(baseDir); + for (const base of [path.join(workspaceRoot, ".github"), path.join(workspaceRoot, ".agents")]) { + if (realPathWithinBaseForHash(candidate, base)) return true; + } + return false; +} + +function realPathWithinBaseForHash(pathToCheck, baseDir) { + try { + const realBase = fs.realpathSync(baseDir); + const realPath = fs.realpathSync(pathToCheck); + const relativePath = path.relative(realBase, realPath); + return relativePath !== ".." && !relativePath.startsWith(`..${path.sep}`) && !path.isAbsolute(relativePath); + } catch { + return false; + } +} + +/** + * @param {string} content + * @param {number|null} startLine + * @param {number|null} endLine + * @returns {string} + */ +function applyRuntimeImportLineRangeForHash(content, startLine, endLine) { + const lines = content.split("\n"); + const start = startLine && startLine > 0 ? startLine : 1; + const end = endLine && endLine > 0 && endLine <= lines.length ? endLine : lines.length; + if (start > end || start > lines.length) return ""; + return lines.slice(start - 1, end).join("\n"); +} + +/** + * @param {string} importPath + * @param {string} baseDir + * @returns {string[]} + */ +function runtimeImportHashCandidatePaths(importPath, baseDir) { + let normalized = String(importPath || "") + .trim() + .replace(/\\/g, "/"); + if (normalized.startsWith("/")) { + normalized = normalized.replace(/^\/+/, ""); + if (!normalized.startsWith(".github/") && !normalized.startsWith(".agents/")) { + return []; + } + } + if (normalized.startsWith("./")) { + normalized = normalized.substring(2); + } + if (!normalized || normalized.startsWith("../") || normalized.includes("/../")) { + return []; + } + + const workspaceRoot = runtimeImportHashWorkspaceRoot(baseDir); + if (normalized.startsWith(".agents/") || normalized.startsWith(".github/")) { + return [path.join(workspaceRoot, normalized)]; + } + + return [path.join(workspaceRoot, ".github", normalized), path.join(workspaceRoot, ".github", "workflows", normalized), path.join(baseDir, normalized)].map(p => path.normalize(p)).filter((p, index, all) => all.indexOf(p) === index); +} + +/** + * @param {string} baseDir + * @returns {string} + */ +function runtimeImportHashWorkspaceRoot(baseDir) { + const normalized = baseDir.replace(/\\/g, "/"); + const markerIndex = normalized.indexOf("/.github/"); + if (markerIndex >= 0) { + return normalized.substring(0, markerIndex); + } + if (normalized.endsWith("/.github")) { + return path.dirname(baseDir); + } + return baseDir; +} + +/** + * @param {...string[]} groups + * @returns {string[]} + */ +function mergeSortedUniqueStrings(...groups) { + return [...new Set(groups.flat().filter(Boolean))].sort(); +} + /** * Marshals data to canonical JSON with sorted keys * @param {any} data - The data to marshal @@ -743,6 +950,9 @@ module.exports = { extractFrontmatterAndBody, extractImportsFromText, extractRelevantTemplateExpressions, + extractAllTemplateExpressions, + collectRuntimeImportTemplateExpressions, + extractRuntimeImportReferences, marshalCanonicalJSON, marshalSorted, extractHashFromLockFile, diff --git a/setup/js/generate_git_bundle.cjs b/setup/js/generate_git_bundle.cjs index 46858dfe..c2c3a088 100644 --- a/setup/js/generate_git_bundle.cjs +++ b/setup/js/generate_git_bundle.cjs @@ -292,7 +292,7 @@ async function generateGitBundle(branchName, baseBranch, options = {}) { try { execGitSync([...noHooksArgs, "worktree", "add", "--detach", tempWorktree, baseCommitSha], { cwd }); execGitSync([...noHooksArgs, "am", "--3way", patchResult.patchPath], { cwd: tempWorktree }); - execGitSync(["bundle", "create", bundlePath, `${baseCommitSha}..HEAD`], { cwd: tempWorktree }); + execGitSync([...noHooksArgs, "bundle", "create", bundlePath, `${baseCommitSha}..HEAD`], { cwd: tempWorktree }); } finally { try { execGitSync([...noHooksArgs, "worktree", "remove", "--force", tempWorktree], { cwd }); diff --git a/setup/js/runtime_import.cjs b/setup/js/runtime_import.cjs index edccb219..6a639f16 100644 --- a/setup/js/runtime_import.cjs +++ b/setup/js/runtime_import.cjs @@ -14,6 +14,7 @@ const { closeUnterminatedSubAgentMarkers } = require("./extract_inline_sub_agent const fs = require("fs"); const path = require("path"); +const crypto = require("crypto"); /** * Makes any "## skill:"/"## agent:" block in a runtime-imported chunk of @@ -400,6 +401,15 @@ function isSafeExpression(expr) { return false; } +/** + * Generates the compiler's hash-based environment variable name for a GitHub expression. + * @param {string} expr - The GitHub expression content without ${{ }}. + * @returns {string} + */ +function generateHashedExpressionEnvVarName(expr) { + return "GH_AW_EXPR_" + crypto.createHash("sha256").update(expr).digest("hex").slice(0, 8).toUpperCase(); +} + /** * Evaluates a safe GitHub Actions expression at runtime * @param {string} expr - The expression to evaluate (without ${{ }}) @@ -455,6 +465,12 @@ function evaluateExpression(expr) { if (envValue !== undefined && envValue !== null) { return envValue; } + + const hashedEnvVarName = generateHashedExpressionEnvVarName(trimmed); + const hashedEnvValue = process.env[hashedEnvVarName]; + if (hashedEnvValue !== undefined && hashedEnvValue !== null) { + return hashedEnvValue; + } // If not found in environment, continue to try other evaluation methods below } @@ -972,7 +988,7 @@ function generatePlaceholderName(expr) { * Resolves a runtime-import file path to its normalized absolute path. * @param {string} filepathOrUrl - File path (not URL) * @param {string} workspaceDir - The GITHUB_WORKSPACE directory path - * @returns {{filepath: string, normalizedPath: string}} + * @returns {{filepath: string, normalizedPath: string, normalizedBaseFolder: string}} */ function resolveRuntimeImportFilePath(filepathOrUrl, workspaceDir) { if (/^https?:\/\//i.test(filepathOrUrl)) { @@ -1031,11 +1047,11 @@ function resolveRuntimeImportFilePath(filepathOrUrl, workspaceDir) { baseFolder = workspaceDir; absolutePath = path.resolve(workspaceDir, filepath); normalizedPath = path.normalize(absolutePath); - normalizedBaseFolder = path.normalize(baseFolder); + normalizedBaseFolder = path.normalize(path.join(workspaceDir, ".agents")); // Security check: ensure the resolved path is within the workspace - const relativePath = path.relative(normalizedBaseFolder, normalizedPath); - if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) { + const relativePath = path.relative(path.normalize(baseFolder), normalizedPath); + if (relativePath === ".." || relativePath.startsWith(".." + path.sep) || path.isAbsolute(relativePath)) { throw new Error(`${ERR_CONFIG}: Security: Path ${filepathOrUrl} must be within workspace (resolves to: ${relativePath})`); } // Additional check: ensure path stays within .agents folder @@ -1057,7 +1073,29 @@ function resolveRuntimeImportFilePath(filepathOrUrl, workspaceDir) { } } - return { filepath, normalizedPath }; + return { filepath, normalizedPath, normalizedBaseFolder }; +} + +/** + * Verifies the resolved runtime-import target remains inside the allowed base + * after following symlinks. + * @param {string} normalizedPath + * @param {string} normalizedBaseFolder + * @param {string} filepathOrUrl + */ +function assertRuntimeImportRealPathWithinBase(normalizedPath, normalizedBaseFolder, filepathOrUrl) { + let realPath; + let realBase; + try { + realPath = fs.realpathSync(normalizedPath); + realBase = fs.realpathSync(normalizedBaseFolder); + } catch (err) { + throw new Error(`${ERR_SYSTEM}: Failed to resolve runtime import path ${filepathOrUrl}: ${getErrorMessage(err)}`, { cause: err }); + } + const relativePath = path.relative(realBase, realPath); + if (relativePath === ".." || relativePath.startsWith(".." + path.sep) || path.isAbsolute(relativePath)) { + throw new Error(`${ERR_VALIDATION}: Security: Path ${filepathOrUrl} must remain within its allowed folder after resolving symlinks`); + } } /** @@ -1077,7 +1115,7 @@ async function processRuntimeImport(filepathOrUrl, optional, workspaceDir, start } // Otherwise, process as a file - const { filepath, normalizedPath } = resolveRuntimeImportFilePath(filepathOrUrl, workspaceDir); + const { filepath, normalizedPath, normalizedBaseFolder } = resolveRuntimeImportFilePath(filepathOrUrl, workspaceDir); // Check if file exists if (!fs.existsSync(normalizedPath)) { @@ -1087,6 +1125,7 @@ async function processRuntimeImport(filepathOrUrl, optional, workspaceDir, start } throw new Error(`${ERR_SYSTEM}: Runtime import file not found: ${normalizedPath}`); } + assertRuntimeImportRealPathWithinBase(normalizedPath, normalizedBaseFolder, filepathOrUrl); // Read the file let content; diff --git a/setup/setup.sh b/setup/setup.sh index 6ba276a2..3eb617af 100755 --- a/setup/setup.sh +++ b/setup/setup.sh @@ -40,24 +40,32 @@ create_dir() { fi } +# Git Bash does not automatically convert Windows environment-variable paths. +# Preserve RUNNER_TEMP for JavaScript consumers, but use a Bash-compatible path here. +if [[ "$(uname -o 2>/dev/null)" == "Msys" ]] && command -v cygpath >/dev/null 2>&1; then + RUNNER_TEMP_BASH="$(cygpath -u "${RUNNER_TEMP}")" +else + RUNNER_TEMP_BASH="${RUNNER_TEMP}" +fi + # GH_AW_ROOT uses RUNNER_TEMP for write access on both GitHub-hosted and self-hosted runners. # RUNNER_TEMP is guaranteed to be set by GitHub Actions and is always writable. -GH_AW_ROOT="${RUNNER_TEMP}/gh-aw" +GH_AW_ROOT="${RUNNER_TEMP_BASH}/gh-aw" # Verify RUNNER_TEMP is set and the directory has write access -if [ -z "${RUNNER_TEMP}" ]; then +if [ -z "${RUNNER_TEMP_BASH}" ]; then echo "::error::RUNNER_TEMP environment variable is not set. This script must run in a GitHub Actions environment." exit 1 fi -if [ ! -d "${RUNNER_TEMP}" ]; then - echo "::error::RUNNER_TEMP directory does not exist: ${RUNNER_TEMP}" +if [ ! -d "${RUNNER_TEMP_BASH}" ]; then + echo "::error::RUNNER_TEMP directory does not exist: ${RUNNER_TEMP_BASH}" exit 1 fi -if [ ! -w "${RUNNER_TEMP}" ]; then - echo "::error::RUNNER_TEMP directory is not writable: ${RUNNER_TEMP}" - echo "::error::The runner user ($(whoami)) does not have write access to ${RUNNER_TEMP}" +if [ ! -w "${RUNNER_TEMP_BASH}" ]; then + echo "::error::RUNNER_TEMP directory is not writable: ${RUNNER_TEMP_BASH}" + echo "::error::The runner user ($(whoami)) does not have write access to ${RUNNER_TEMP_BASH}" exit 1 fi @@ -66,9 +74,9 @@ fi # (runtime tree). When RUNNER_TEMP=/tmp both paths collapse into /tmp/gh-aw, giving # the agent write access to compiled scripts, prompts, and MCP configs that must stay # immutable. Fail fast here rather than silently running with a broken security boundary. -RESOLVED_RUNNER_TEMP="$(cd "${RUNNER_TEMP}" && pwd -P)" +RESOLVED_RUNNER_TEMP="$(cd "${RUNNER_TEMP_BASH}" && pwd -P)" if [ -z "${RESOLVED_RUNNER_TEMP}" ]; then - echo "::error::Failed to resolve canonical path for RUNNER_TEMP: ${RUNNER_TEMP}" + echo "::error::Failed to resolve canonical path for RUNNER_TEMP: ${RUNNER_TEMP_BASH}" exit 1 fi if [ "${RESOLVED_RUNNER_TEMP%/}" = "/tmp" ]; then @@ -76,10 +84,13 @@ if [ "${RESOLVED_RUNNER_TEMP%/}" = "/tmp" ]; then exit 1 fi -debug_log "Using RUNNER_TEMP: ${RUNNER_TEMP} (resolved: ${RESOLVED_RUNNER_TEMP}, writable: yes)" +debug_log "Using RUNNER_TEMP: ${RUNNER_TEMP_BASH} (resolved: ${RESOLVED_RUNNER_TEMP}, writable: yes)" # Get destination from input or use default DESTINATION="${INPUT_DESTINATION:-${GH_AW_ROOT}/actions}" +if [[ "$(uname -o 2>/dev/null)" == "Msys" ]] && command -v cygpath >/dev/null 2>&1; then + DESTINATION="$(cygpath -u "${DESTINATION}")" +fi # Get safe-output-custom-tokens flag from input (default: false) SAFE_OUTPUT_CUSTOM_TOKENS_ENABLED="${INPUT_SAFE_OUTPUT_CUSTOM_TOKENS:-false}" diff --git a/setup/sh/check_mcp_servers.sh b/setup/sh/check_mcp_servers.sh index c33d96b3..408308b9 100755 --- a/setup/sh/check_mcp_servers.sh +++ b/setup/sh/check_mcp_servers.sh @@ -49,12 +49,13 @@ GATEWAY_API_KEY="$3" # does not echo the `required` flag from the input configuration, so the caller # forwards the names of servers declared with `required: false` here. OPTIONAL_SERVERS="${GH_AW_MCP_OPTIONAL_SERVERS:-}" +DEFERRED_SERVERS="${GH_AW_MCP_DEFERRED_SERVERS:-}" -# is_optional_server NAME → 0 when the server is declared non-critical -is_optional_server() { +# server_in_list NAME LIST → 0 when LIST contains the exact comma-delimited name +server_in_list() { local name="$1" + local remaining="$2" local entry - local remaining="$OPTIONAL_SERVERS" [ -z "$remaining" ] && return 1 while [ -n "$remaining" ]; do entry="${remaining%%,*}" @@ -69,6 +70,17 @@ is_optional_server() { return 1 } +is_optional_server() { + server_in_list "$1" "$OPTIONAL_SERVERS" +} + +is_deferred_server() { + # Deferral is a closed compiler-owned lifecycle contract. Do not allow this + # internal environment variable to suppress checks for arbitrary MCP servers. + [ "$1" = "awf-enclave" ] || return 1 + server_in_list "$1" "$DEFERRED_SERVERS" +} + # Start overall timing SCRIPT_START_TIME=$(date +%s%3N) @@ -110,6 +122,7 @@ SERVERS_CHECKED=0 SERVERS_SUCCEEDED=0 SERVERS_FAILED=0 SERVERS_SKIPPED=0 +SERVERS_DEFERRED=0 REQUIRED_SERVERS_FAILED=0 # Retry configuration for slow-starting servers @@ -130,6 +143,12 @@ while IFS= read -r SERVER_NAME; do continue fi + if is_deferred_server "$SERVER_NAME"; then + echo "↷ $SERVER_NAME: deferred until its AWF-owned backend starts" + SERVERS_DEFERRED=$((SERVERS_DEFERRED + 1)) + continue + fi + # Check whether server is marked optional in configuration JSON or via the # GH_AW_MCP_OPTIONAL_SERVERS environment variable. # Servers are required by default; set required: false to degrade failures to warnings. @@ -277,7 +296,7 @@ print_timing $SCRIPT_START_TIME "Overall MCP server checks" echo "" if [ $REQUIRED_SERVERS_FAILED -gt 0 ]; then echo "ERROR: $REQUIRED_SERVERS_FAILED required server(s) failed connectivity check" - echo "Succeeded: $SERVERS_SUCCEEDED, Failed: $SERVERS_FAILED, Skipped: $SERVERS_SKIPPED" + echo "Succeeded: $SERVERS_SUCCEEDED, Failed: $SERVERS_FAILED, Skipped: $SERVERS_SKIPPED, Deferred: $SERVERS_DEFERRED" echo "" echo "One or more startup-critical MCP servers failed ping/initialize/tools/list" echo "after multiple retry attempts with progressive timeouts (10s, 20s, 30s)." @@ -289,6 +308,9 @@ if [ $REQUIRED_SERVERS_FAILED -gt 0 ]; then echo "" echo "Check the MCP server output above and individual server logs for more details." exit 1 +elif [ $SERVERS_SUCCEEDED -eq 0 ] && [ $SERVERS_FAILED -eq 0 ] && [ $SERVERS_DEFERRED -gt 0 ]; then + echo "✓ Deferred $SERVERS_DEFERRED late-starting server(s) to their owner readiness checks" + exit 0 elif [ $SERVERS_SUCCEEDED -eq 0 ] && [ $SERVERS_FAILED -eq 0 ]; then echo "ERROR: No HTTP servers were successfully checked" echo "This could indicate:" @@ -299,7 +321,7 @@ elif [ $SERVERS_SUCCEEDED -eq 0 ] && [ $SERVERS_FAILED -eq 0 ]; then exit 1 elif [ $SERVERS_SUCCEEDED -eq 0 ]; then echo "ERROR: All $SERVERS_FAILED optional server(s) failed; no successful connections" - echo "Succeeded: 0, Failed: $SERVERS_FAILED, Skipped: $SERVERS_SKIPPED" + echo "Succeeded: 0, Failed: $SERVERS_FAILED, Skipped: $SERVERS_SKIPPED, Deferred: $SERVERS_DEFERRED" echo "" echo "All configured HTTP MCP servers are optional but none connected successfully." echo "At least one server must connect for the gateway to be considered healthy." @@ -309,9 +331,9 @@ elif [ $SERVERS_SUCCEEDED -eq 0 ]; then else if [ $SERVERS_FAILED -gt 0 ]; then echo "WARNING: $SERVERS_FAILED optional server(s) failed connectivity check; continuing startup" - echo "✓ Checks completed with warnings ($SERVERS_SUCCEEDED succeeded, $SERVERS_FAILED failed, $SERVERS_SKIPPED skipped)" + echo "✓ Checks completed with warnings ($SERVERS_SUCCEEDED succeeded, $SERVERS_FAILED failed, $SERVERS_SKIPPED skipped, $SERVERS_DEFERRED deferred)" else - echo "✓ All checks passed ($SERVERS_SUCCEEDED succeeded, $SERVERS_SKIPPED skipped)" + echo "✓ All checks passed ($SERVERS_SUCCEEDED succeeded, $SERVERS_SKIPPED skipped, $SERVERS_DEFERRED deferred)" fi exit 0 fi diff --git a/setup/sh/install_copilot_cli.sh b/setup/sh/install_copilot_cli.sh index 15ece7c2..57b28a9f 100755 --- a/setup/sh/install_copilot_cli.sh +++ b/setup/sh/install_copilot_cli.sh @@ -61,6 +61,16 @@ for arg in "$@"; do esac done +OS="$(uname -s)" +IS_WINDOWS=false +case "$OS" in + MINGW*|MSYS*|CYGWIN*) + IS_WINDOWS=true + ROOTLESS=true + INSTALL_DIR="${HOME}/.local/bin" + ;; +esac + # In rootless mode, install into the user's home directory instead of /usr/local/bin # so that ARC/DinD runners with allowPrivilegeEscalation: false can run without sudo. if [ "$ROOTLESS" = "true" ]; then @@ -105,8 +115,7 @@ echo "Cleaning up stale AWF chroot directories..." maybe_sudo find /tmp -maxdepth 1 -name 'awf-*-chroot-home' -type d -exec rm -rf -- {} + 2>/dev/null || true maybe_sudo find /tmp -maxdepth 1 -name 'awf-chroot-*' -type d -exec rm -rf -- {} + 2>/dev/null || true -# Detect OS and architecture -OS="$(uname -s)" +# Detect architecture ARCH="$(uname -m)" # Map architecture to Copilot CLI naming @@ -120,10 +129,15 @@ esac case "$OS" in Linux) PLATFORM="linux" ;; Darwin) PLATFORM="darwin" ;; + MINGW*|MSYS*|CYGWIN*) PLATFORM="win32" ;; *) echo "ERROR: Unsupported operating system: ${OS}"; exit 1 ;; esac -TARBALL_NAME="copilot-${PLATFORM}-${ARCH_NAME}.tar.gz" +if [ "$IS_WINDOWS" = "true" ]; then + TARBALL_NAME="copilot-${PLATFORM}-${ARCH_NAME}.zip" +else + TARBALL_NAME="copilot-${PLATFORM}-${ARCH_NAME}.tar.gz" +fi REQUESTED_VERSION="${VERSION:-latest}" echo "Installing GitHub Copilot CLI${VERSION:+ version $VERSION} (os: ${OS}, arch: ${ARCH})..." @@ -615,8 +629,24 @@ echo "✓ Checksum verification passed for ${TARBALL_NAME}" # Extract and install binary echo "Installing binary to ${INSTALL_DIR}..." -maybe_sudo tar -xz -C "${INSTALL_DIR}" -f "${TEMP_DIR}/${TARBALL_NAME}" -maybe_sudo chmod +x "${INSTALL_DIR}/copilot" +if [ "$IS_WINDOWS" = "true" ]; then + if command -v unzip >/dev/null 2>&1; then + unzip -qo "${TEMP_DIR}/${TARBALL_NAME}" -d "${INSTALL_DIR}" + elif command -v 7z >/dev/null 2>&1; then + 7z x -y "-o${INSTALL_DIR}" "${TEMP_DIR}/${TARBALL_NAME}" >/dev/null + else + echo "ERROR: unzip or 7z is required for Windows installation" + exit 1 + fi + cat > "${INSTALL_DIR}/copilot" </dev/null 2>&1; then + echo "::error::jq is required to bind the enclave GitHub policy to the workflow run" + exit 1 +fi + +derive_proxy_upstream_env + +MCP_GATEWAY_ENCLAVE_CAPABILITY_KEY="$(openssl rand -hex 32)" +if [[ ! "$MCP_GATEWAY_ENCLAVE_CAPABILITY_KEY" =~ ^[0-9a-f]{64}$ ]]; then + echo "::error::Failed to generate enclave GitHub proxy capability key" + exit 1 +fi +echo "::add-mask::${MCP_GATEWAY_ENCLAVE_CAPABILITY_KEY}" +export MCP_GATEWAY_ENCLAVE_CAPABILITY_KEY + +JOB_HASH="$(printf '%s' "$GITHUB_JOB" | openssl dgst -sha256 -r | cut -d' ' -f1 | cut -c1-12)" +PROXY_IDENTITY="gh-aw-egh-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-${JOB_HASH}" +if [[ ! "$PROXY_IDENTITY" =~ ^[a-z0-9][a-z0-9-]{0,63}$ ]]; then + echo "::error::Failed to derive a valid enclave GitHub proxy identity" + exit 1 +fi + +MCP_GATEWAY_ENCLAVE_POLICY_JSON="$( + jq -c --arg workflow_run_id "$PROXY_IDENTITY" \ + '.workflow_run_id = $workflow_run_id' <<<"$POLICY_TEMPLATE" +)" +echo "::add-mask::${MCP_GATEWAY_ENCLAVE_POLICY_JSON}" +export MCP_GATEWAY_ENCLAVE_POLICY_JSON + +mkdir -p "$MCP_LOG_DIR" +chmod 700 "$MCP_LOG_DIR" +rm -rf "${MCP_LOG_DIR}/proxy-tls" +docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + +docker run -d --name "$CONTAINER_NAME" \ + --network bridge \ + --label "${RUN_LABEL}=${PROXY_IDENTITY}" \ + --user "$(id -u):$(id -g)" \ + -e GH_TOKEN \ + -e GH_HOST \ + -e GITHUB_HOST \ + -e GITHUB_ENTERPRISE_HOST \ + -e GITHUB_SERVER_URL \ + -e GITHUB_API_URL \ + -e GITHUB_GRAPHQL_URL \ + -e GITHUB_COPILOT_BASE_URL \ + -e MCP_GATEWAY_ENCLAVE_POLICY_JSON \ + -e MCP_GATEWAY_ENCLAVE_CAPABILITY_KEY \ + -v "$MCP_LOG_DIR:$MCP_LOG_DIR" \ + "$CONTAINER_IMAGE" proxy \ + --listen "0.0.0.0:${PORT}" \ + --log-dir "$MCP_LOG_DIR" \ + --tls-dns-name "$PROXY_ALIAS" \ + --tls + +PROXY_READY=false +for ((attempt = 1; attempt <= 30; attempt++)); do + if [[ -f "$CA_CERT" ]]; then + PROXY_IP="$(docker inspect --format '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$CONTAINER_NAME")" + if [[ -n "$PROXY_IP" ]] && curl -sf --cacert "$CA_CERT" \ + --resolve "${PROXY_ALIAS}:${PORT}:${PROXY_IP}" \ + "https://${PROXY_ALIAS}:${PORT}/api/v3/health" -o /dev/null; then + PROXY_READY=true + break + fi + fi + sleep 1 +done + +if [[ "$PROXY_READY" != "true" ]]; then + echo "::error::Enclave GitHub proxy failed to become ready" + docker rm -f "$CONTAINER_NAME" >/dev/null 2>&1 || true + exit 1 +fi + +{ + printf '%s=%s\n' AWF_ENCLAVE_GITHUB_PROXY_CONTAINER "$CONTAINER_NAME" + printf '%s=%s\n' AWF_ENCLAVE_GITHUB_PROXY_IDENTITY "$PROXY_IDENTITY" + printf '%s=%s\n' AWF_ENCLAVE_GITHUB_PROXY_CA_CERT "$CA_CERT" + printf '%s=%s\n' MCP_GATEWAY_ENCLAVE_CAPABILITY_KEY "$MCP_GATEWAY_ENCLAVE_CAPABILITY_KEY" +} >>"$GITHUB_ENV" diff --git a/setup/sh/stop_enclave_github_proxy.sh b/setup/sh/stop_enclave_github_proxy.sh new file mode 100644 index 00000000..52473554 --- /dev/null +++ b/setup/sh/stop_enclave_github_proxy.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set +o histexpand +set -euo pipefail + +docker rm -f awmg-enclave-github-proxy >/dev/null 2>&1 || true +MCP_LOG_DIR="${RUNNER_TEMP:-/tmp}/gh-aw/enclave-github-proxy-logs" +rm -rf "$MCP_LOG_DIR" +printf 'MCP_GATEWAY_ENCLAVE_CAPABILITY_KEY=\n' >> "$GITHUB_ENV"