Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
abc2ba0
Initial plan
Copilot Jul 16, 2026
631dad0
fix: add ensureSafeDirectoryTrust to bridge path to fix dubious owner…
Copilot Jul 16, 2026
fef0d2e
Merge branch 'main' into copilot/deep-report-safe-outputs-fix
pelikhan Jul 16, 2026
c0efbf6
Fix safe directory env count parsing
Copilot Jul 16, 2026
6077c7b
Expand safe directory trust tests
Copilot Jul 16, 2026
d4d78eb
Restore safe.directory bridge debug logging
Copilot Jul 17, 2026
1a76cdb
Merge branch 'main' into copilot/deep-report-safe-outputs-fix
pelikhan Jul 17, 2026
b7fd8e0
chore: start final PR finisher pass
Copilot Jul 17, 2026
06ce580
fix: clean stale awf chroot temp dirs
Copilot Jul 17, 2026
065273f
Merge branch 'main' into copilot/deep-report-safe-outputs-fix
pelikhan Jul 18, 2026
634cba0
fix: preclean stale awf chroot dirs before awf install
Copilot Jul 18, 2026
6469a66
fix: preclean stale awf dirs before awf startup
Copilot Jul 18, 2026
c0ef934
Merge remote-tracking branch 'origin/main' into copilot/deep-report-s…
Copilot Jul 18, 2026
2e6b2f2
Apply remaining changes
Copilot Jul 18, 2026
04095c3
Plan merge-conflict resolution
Copilot Jul 19, 2026
a36b552
Merge remote-tracking branch 'refs/remotes/origin/main' into copilot/…
Copilot Jul 19, 2026
5951c85
Merge remote-tracking branch 'refs/remotes/origin/main' into copilot/…
Copilot Jul 19, 2026
00aab14
Merge remote-tracking branch 'origin/main' into copilot/deep-report-s…
Copilot Jul 19, 2026
ab66c06
docs: add trimleftright to linter README
Copilot Jul 19, 2026
278c968
Merge branch 'main' into copilot/deep-report-safe-outputs-fix
pelikhan Jul 19, 2026
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
23 changes: 19 additions & 4 deletions actions/setup/clean.sh
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,11 @@ if [ -d "${tmpDir}" ]; then
fi
fi

# Remove AWF chroot home directories under /tmp (e.g. /tmp/awf-*-chroot-home).
# These are created by AWF when running with --enable-host-access on GitHub-hosted runners.
# Files inside may be owned by root (written by Docker containers or privileged AWF processes),
# causing EACCES failures if cleanup is attempted without sudo.
# Remove AWF chroot directories under /tmp (e.g. /tmp/awf-*-chroot-home and
# /tmp/awf-chroot-*). These are created by AWF when running with
# --enable-host-access on GitHub-hosted runners. Files inside may be owned by
# root (written by Docker containers or privileged AWF processes), causing
# EACCES failures if cleanup is attempted without sudo.
if awf_chroot_home_dirs="$(sudo find /tmp -maxdepth 1 -name 'awf-*-chroot-home' -type d -print 2>/dev/null)"; then
if [ -n "${awf_chroot_home_dirs}" ]; then
if sudo find /tmp -maxdepth 1 -name 'awf-*-chroot-home' -type d -exec rm -rf -- {} + 2>/dev/null; then
Expand All @@ -58,3 +59,17 @@ if awf_chroot_home_dirs="$(sudo find /tmp -maxdepth 1 -name 'awf-*-chroot-home'
else
echo "Warning: unable to inspect /tmp/awf-*-chroot-home directories with sudo" >&2
fi

if awf_chroot_dirs="$(sudo find /tmp -maxdepth 1 -name 'awf-chroot-*' -type d -print 2>/dev/null)"; then
if [ -n "${awf_chroot_dirs}" ]; then
if sudo find /tmp -maxdepth 1 -name 'awf-chroot-*' -type d -exec rm -rf -- {} + 2>/dev/null; then
echo "Cleaned up /tmp/awf-chroot-* directories (sudo)"
else
echo "Warning: failed to clean /tmp/awf-chroot-* directories" >&2
fi
else
echo "No /tmp/awf-chroot-* directories found"
fi
else
echo "Warning: unable to inspect /tmp/awf-chroot-* directories with sudo" >&2
fi
63 changes: 62 additions & 1 deletion actions/setup/js/chroot_home_cleanup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const __dirname = path.dirname(fileURLToPath(import.meta.url));
const POST_SCRIPT_PATH = path.join(__dirname, "..", "post.js");
const CLEAN_SCRIPT_PATH = path.join(__dirname, "..", "clean.sh");
const INSTALL_COPILOT_CLI_SCRIPT_PATH = path.join(__dirname, "..", "sh", "install_copilot_cli.sh");
const INSTALL_AWF_BINARY_SCRIPT_PATH = path.join(__dirname, "..", "sh", "install_awf_binary.sh");

const tempDirs = [];

Expand Down Expand Up @@ -100,6 +101,19 @@ describe("post.js chroot-home cleanup", () => {
expect(result.stdout).toContain("Cleaned up 2 /tmp/awf-*-chroot-home directories");
expect(fs.readFileSync(logPath, "utf8")).toContain("-exec rm -rf -- {} +");
});

it("logs count of cleaned chroot directories", () => {
const { fakeBin, logPath } = createFakeSudoEnvironment();
const result = runPostScript({
PATH: `${fakeBin}:${process.env.PATH}`,
FAKE_SUDO_LOG: logPath,
FAKE_FIND_PRINT_OUTPUT: "/tmp/awf-chroot-a\n/tmp/awf-chroot-b\n",
});

expect(result.status).toBe(0);
expect(result.stdout).toContain("Cleaned up 2 /tmp/awf-chroot-* directories");
expect(fs.readFileSync(logPath, "utf8")).toContain("-name awf-chroot-* -type d -exec rm -rf -- {} +");
});
});

describe("clean.sh chroot-home cleanup", () => {
Expand Down Expand Up @@ -136,20 +150,67 @@ describe("clean.sh chroot-home cleanup", () => {
expect(result.stdout).toContain("Cleaned up /tmp/awf-*-chroot-home directories (sudo)");
expect(fs.readFileSync(logPath, "utf8")).toContain("-exec rm -rf -- {} +");
});

it("logs successful cleanup when chroot directories are found", () => {
const { fakeBin, logPath, root } = createFakeSudoEnvironment();
const destination = path.join(root, "destination");
fs.mkdirSync(destination, { recursive: true });

const result = runCleanScript({
PATH: `${fakeBin}:${process.env.PATH}`,
FAKE_SUDO_LOG: logPath,
FAKE_FIND_PRINT_OUTPUT: "/tmp/awf-chroot-a\n",
INPUT_DESTINATION: destination,
});

expect(result.status).toBe(0);
expect(result.stdout).toContain("Cleaned up /tmp/awf-chroot-* directories (sudo)");
expect(fs.readFileSync(logPath, "utf8")).toContain("-name awf-chroot-* -type d -exec rm -rf -- {} +");
});
});

describe("install_copilot_cli.sh chroot-home cleanup", () => {
it("cleans stale chroot-home directories before starting Copilot CLI installation", () => {
const script = fs.readFileSync(INSTALL_COPILOT_CLI_SCRIPT_PATH, "utf8");

const ownershipFixIndex = script.indexOf('sudo chown -R "$(id -u):$(id -g)" "$COPILOT_DIR"');
const cleanupBannerIndex = script.indexOf('echo "Cleaning up stale AWF chroot home directories..."');
const cleanupBannerIndex = script.indexOf('echo "Cleaning up stale AWF chroot directories..."');
const cleanupCommandIndex = script.indexOf(
"sudo find /tmp -maxdepth 1 -name 'awf-*-chroot-home' -type d -exec rm -rf -- {} + 2>/dev/null || true"
);
const cleanupChrootCommandIndex = script.indexOf(
"sudo find /tmp -maxdepth 1 -name 'awf-chroot-*' -type d -exec rm -rf -- {} + 2>/dev/null || true"
);

expect(ownershipFixIndex).toBeGreaterThanOrEqual(0);
expect(cleanupBannerIndex).toBeGreaterThan(ownershipFixIndex);
expect(cleanupCommandIndex).toBeGreaterThan(cleanupBannerIndex);
expect(cleanupChrootCommandIndex).toBeGreaterThan(cleanupCommandIndex);
});
});

describe("install_awf_binary.sh chroot-home cleanup", () => {
it("cleans stale chroot directories before starting AWF installation", () => {
const script = fs.readFileSync(INSTALL_AWF_BINARY_SCRIPT_PATH, "utf8");

const rootlessPreflightIndex = script.indexOf('if ! { mkdir -p "${AWF_INSTALL_DIR}" && [ -w "${AWF_INSTALL_DIR}" ]; }; then');
const cleanupBannerIndex = script.indexOf('echo "Cleaning up stale AWF chroot directories..."');
const sudoGuardIndex = script.indexOf("if command -v sudo >/dev/null 2>&1; then");
const cleanupHomeCommandIndex = script.indexOf(
`sudo find /tmp -maxdepth 1 -name 'awf-*-chroot-home' -type d \\( -user root -o -user "$(id -un)" \\) -exec rm -rf -- {} + || true`
);
const cleanupChrootCommandIndex = script.indexOf(
`sudo find /tmp -maxdepth 1 -name 'awf-chroot-*' -type d \\( -user root -o -user "$(id -un)" \\) -exec rm -rf -- {} + || true`
);
const sudoWarningIndex = script.indexOf('echo "Warning: sudo is unavailable; skipping stale AWF chroot cleanup" >&2');
const downloadUrlIndex = script.indexOf("# Download URLs");

expect(rootlessPreflightIndex).toBeGreaterThanOrEqual(0);
expect(cleanupBannerIndex).toBeGreaterThan(rootlessPreflightIndex);
expect(sudoGuardIndex).toBeGreaterThan(cleanupBannerIndex);
expect(cleanupHomeCommandIndex).toBeGreaterThan(sudoGuardIndex);
expect(cleanupChrootCommandIndex).toBeGreaterThan(cleanupHomeCommandIndex);
expect(sudoWarningIndex).toBeGreaterThan(cleanupChrootCommandIndex);
expect(downloadUrlIndex).toBeGreaterThan(cleanupChrootCommandIndex);
});
});
49 changes: 49 additions & 0 deletions actions/setup/js/git_helpers.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,54 @@ function getGitAuthEnv(token) {
};
}

/**
* Ensure the given directory is trusted for git in the current process context.
* Injects safe.directory via GIT_CONFIG_COUNT/KEY/VALUE env vars so that all
* subsequent git commands in this process inherit the trust without writing to
* ~/.gitconfig (no persistent global git config side effects).
*
* This is specifically needed for the "bridge" path (the Process Safe Outputs step
* running outside the Docker container) where HOME or the uid may differ from the
* in-container user that originally configured safe.directory in ~/.gitconfig.
*
* GIT_CONFIG_COUNT/KEY/VALUE is supported since git 2.31.
*
* @param {string} gitCwd - The directory to trust (e.g. GITHUB_WORKSPACE or a repo checkout path)
* @param {Object|Function} [logger] - Optional logger object
* with a debug method (for example the MCP server) or a direct debug function.
* @returns {void}
Comment thread
github-actions[bot] marked this conversation as resolved.
*/
function ensureSafeDirectoryTrust(gitCwd, logger) {
if (!gitCwd) return;

// Check if gitCwd is already present in the injected env-var config to avoid
// duplicate entries when the handler is called more than once in the same process.
// Malformed pre-existing GIT_CONFIG_COUNT values (NaN, negative, fractional,
// or unsafe integers) fall back to 0 to avoid corrupting the env-var config chain.
const rawCount = process.env.GIT_CONFIG_COUNT || "0";
const parsedCount = Number(rawCount);
const existingCount = Number.isSafeInteger(parsedCount) && parsedCount >= 0 ? parsedCount : 0;
for (let i = 0; i < existingCount; i++) {
if (process.env[`GIT_CONFIG_KEY_${i}`] === "safe.directory" && process.env[`GIT_CONFIG_VALUE_${i}`] === gitCwd) {
return;
}
}

const idx = existingCount;
process.env.GIT_CONFIG_COUNT = String(existingCount + 1);
process.env[`GIT_CONFIG_KEY_${idx}`] = "safe.directory";
process.env[`GIT_CONFIG_VALUE_${idx}`] = gitCwd;
let debug;
if (typeof logger === "function") {
debug = logger;
} else if (typeof logger?.debug === "function") {
debug = logger.debug.bind(logger);
} else if (typeof globalThis.core?.debug === "function") {
debug = globalThis.core.debug.bind(globalThis.core);
}
debug?.(`Configured git safe.directory for bridge context: ${gitCwd}`);
}

/**
* Safely execute git command using spawnSync with args array to prevent shell injection.
*
Expand Down Expand Up @@ -603,6 +651,7 @@ async function linearizeRangeAsCommit(baseRef, commitMessage, execApi, opts = {}
}

module.exports = {
ensureSafeDirectoryTrust,
execGitSync,
backfillCommitObjects,
ensureFullHistoryForBundle,
Expand Down
150 changes: 150 additions & 0 deletions actions/setup/js/git_helpers.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,156 @@ describe("git_helpers.cjs", () => {
});
});

describe("ensureSafeDirectoryTrust", () => {
let originalEnv;

beforeEach(() => {
originalEnv = { ...process.env };
// Clean up GIT_CONFIG_* vars injected by a previous test
for (const key of Object.keys(process.env)) {
if (key.startsWith("GIT_CONFIG_")) {
delete process.env[key];
}
}
});

afterEach(() => {
// Restore to original, removing any vars added during the test
for (const key of Object.keys(process.env)) {
if (!(key in originalEnv)) {
delete process.env[key];
}
}
Object.assign(process.env, originalEnv);
});

it("should export ensureSafeDirectoryTrust function", async () => {
const { ensureSafeDirectoryTrust } = await import("./git_helpers.cjs");
expect(typeof ensureSafeDirectoryTrust).toBe("function");
});

it("should set GIT_CONFIG_* env vars for the given directory", async () => {
const { ensureSafeDirectoryTrust } = await import("./git_helpers.cjs");

ensureSafeDirectoryTrust("/workspace/repo");

expect(process.env.GIT_CONFIG_COUNT).toBe("1");
expect(process.env.GIT_CONFIG_KEY_0).toBe("safe.directory");
expect(process.env.GIT_CONFIG_VALUE_0).toBe("/workspace/repo");
});

it("should not add a duplicate entry when called twice with the same directory", async () => {
const { ensureSafeDirectoryTrust } = await import("./git_helpers.cjs");

ensureSafeDirectoryTrust("/workspace/repo");
ensureSafeDirectoryTrust("/workspace/repo");

expect(process.env.GIT_CONFIG_COUNT).toBe("1");
});

it("should append a new entry when called with a different directory", async () => {
const { ensureSafeDirectoryTrust } = await import("./git_helpers.cjs");

ensureSafeDirectoryTrust("/workspace/repo-a");
ensureSafeDirectoryTrust("/workspace/repo-b");

expect(process.env.GIT_CONFIG_COUNT).toBe("2");
expect(process.env.GIT_CONFIG_KEY_0).toBe("safe.directory");
expect(process.env.GIT_CONFIG_VALUE_0).toBe("/workspace/repo-a");
expect(process.env.GIT_CONFIG_KEY_1).toBe("safe.directory");
expect(process.env.GIT_CONFIG_VALUE_1).toBe("/workspace/repo-b");
});

it("should be a no-op when called with an empty string", async () => {
const { ensureSafeDirectoryTrust } = await import("./git_helpers.cjs");

ensureSafeDirectoryTrust("");

expect(process.env.GIT_CONFIG_COUNT).toBeUndefined();
});

it("should be a no-op when called with undefined/falsy", async () => {
const { ensureSafeDirectoryTrust } = await import("./git_helpers.cjs");

ensureSafeDirectoryTrust(undefined);

expect(process.env.GIT_CONFIG_COUNT).toBeUndefined();
});

it("should preserve existing GIT_CONFIG_* entries set by getGitAuthEnv", async () => {
const { ensureSafeDirectoryTrust, getGitAuthEnv } = await import("./git_helpers.cjs");

// Simulate what getGitAuthEnv returns being already applied via env
const authEnv = getGitAuthEnv("test-token");
Object.assign(process.env, authEnv);
const existingConfigCount = parseInt(authEnv.GIT_CONFIG_COUNT, 10);

ensureSafeDirectoryTrust("/workspace/repo");

// The count should be incremented by 1.
expect(parseInt(process.env.GIT_CONFIG_COUNT, 10)).toBe(existingConfigCount + 1);
// Existing auth entries preserved
for (let i = 0; i < existingConfigCount; i++) {
expect(process.env[`GIT_CONFIG_KEY_${i}`]).toBe(authEnv[`GIT_CONFIG_KEY_${i}`]);
expect(process.env[`GIT_CONFIG_VALUE_${i}`]).toBe(authEnv[`GIT_CONFIG_VALUE_${i}`]);
}
// New safe.directory entry appended
expect(process.env[`GIT_CONFIG_KEY_${existingConfigCount}`]).toBe("safe.directory");
expect(process.env[`GIT_CONFIG_VALUE_${existingConfigCount}`]).toBe("/workspace/repo");
});

it("should handle malformed GIT_CONFIG_COUNT values gracefully", async () => {
const { ensureSafeDirectoryTrust } = await import("./git_helpers.cjs");

for (const malformedCount of ["not-a-number", "-1", "1.5", String(Number.MAX_SAFE_INTEGER + 1)]) {
for (const key of Object.keys(process.env)) {
if (key.startsWith("GIT_CONFIG_")) {
delete process.env[key];
}
}

process.env.GIT_CONFIG_COUNT = malformedCount;

ensureSafeDirectoryTrust("/workspace/repo");

expect(process.env.GIT_CONFIG_COUNT).toBe("1");
expect(process.env.GIT_CONFIG_KEY_0).toBe("safe.directory");
expect(process.env.GIT_CONFIG_VALUE_0).toBe("/workspace/repo");
}
});

it("should not require a shimmed core global", async () => {
const { ensureSafeDirectoryTrust } = await import("./git_helpers.cjs");
const originalCore = global.core;

global.core = undefined;

try {
expect(() => ensureSafeDirectoryTrust("/workspace/repo")).not.toThrow();
expect(process.env.GIT_CONFIG_COUNT).toBe("1");
expect(process.env.GIT_CONFIG_KEY_0).toBe("safe.directory");
expect(process.env.GIT_CONFIG_VALUE_0).toBe("/workspace/repo");
} finally {
global.core = originalCore;
}
});

it("should use a provided logger when core is not shimmed", async () => {
const { ensureSafeDirectoryTrust } = await import("./git_helpers.cjs");
const originalCore = global.core;
const logger = { debug: vi.fn() };

global.core = undefined;

try {
ensureSafeDirectoryTrust("/workspace/repo", logger);
expect(logger.debug).toHaveBeenCalledWith("Configured git safe.directory for bridge context: /workspace/repo");
} finally {
global.core = originalCore;
}
});
});

describe("ensureFullHistoryForBundle", () => {
it("should unshallow the repository when the repository is shallow", async () => {
const { ensureFullHistoryForBundle } = await import("./git_helpers.cjs");
Expand Down
17 changes: 15 additions & 2 deletions actions/setup/js/push_to_pull_request_branch.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const { checkFileProtection, checkFileProtectionPostApply } = require("./manifes
const { buildWorkflowRunUrl } = require("./workflow_metadata_helpers.cjs");
const { renderTemplateFromFile, buildProtectedFileList, getPromptPath } = require("./messages_core.cjs");
const { overridePersistedExtraheader, restorePersistedExtraheader } = require("./git_auth_helpers.cjs");
const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits, isShallowOrSparseCheckout, linearizeRangeAsCommit } = require("./git_helpers.cjs");
const { ensureFullHistoryForBundle, extractBundlePrerequisiteCommits, isShallowOrSparseCheckout, linearizeRangeAsCommit, ensureSafeDirectoryTrust } = require("./git_helpers.cjs");
const { normalizeCommitSHA } = require("./commit_sha_helpers.cjs");
const { findRepoCheckout } = require("./find_repo_checkout.cjs");
const { getThreatDetectedMarker } = require("./threat_detection_warning.cjs");
Expand Down Expand Up @@ -326,6 +326,13 @@ async function main(config = {}) {
const { defaultTargetRepo, allowedRepos } = resolveTargetRepoConfig(config);
const githubClient = await createAuthenticatedGitHubClient(config);

// Ensure the workspace is trusted in the bridge process (Process Safe Outputs step).
// The bridge runs outside the Docker container as a potentially different user/HOME,
// so the in-container `git config --global safe.directory` may not be visible here.
// Using GIT_CONFIG_* env vars avoids relying on ~/.gitconfig and covers cases where
// the conditional "Configure Git credentials" step was skipped or HOME differs.
Comment thread
github-actions[bot] marked this conversation as resolved.
ensureSafeDirectoryTrust(process.env.GITHUB_WORKSPACE || process.cwd());

// Git network operations authenticate using the credentials actions/checkout
// persisted into .git/config for the safe_outputs job (persist-credentials: true,
// using the resolved push token). We intentionally do NOT inject an additional
Expand Down Expand Up @@ -677,6 +684,12 @@ async function main(config = {}) {

// Base options for all git exec calls - includes cwd when running in a subdirectory checkout
const baseGitOpts = repoCwd ? { cwd: repoCwd } : {};

// For cross-repo checkouts, also trust the specific subdirectory. The factory-level call
// covers GITHUB_WORKSPACE; this per-message call covers subdirectory checkout paths.
if (repoCwd) {
ensureSafeDirectoryTrust(repoCwd);
}
let pullRequest;
try {
const response = await githubClient.rest.pulls.get({
Expand Down Expand Up @@ -1116,7 +1129,7 @@ async function main(config = {}) {
} catch {
// Ignore
}
return { success: false, error: "Failed to apply bundle" };
return { success: false, error: `Failed to apply bundle: ${getErrorMessage(bundleError)}` };
}
} else {
// Patch transport (non-default): git am --3way
Expand Down
Loading
Loading