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-agentic-workflows-backend-ownership.md

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

34 changes: 34 additions & 0 deletions actions/setup/js/start_mcp_gateway.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,7 @@ async function main() {
core.setFailed("ERROR: Configuration is missing required 'gateway' section");
return;
}
applyAgenticWorkflowsRunnerIdentity(configObj);
applyGatewayRuntimeTimeouts(configObj, process.env);
mcpConfig = JSON.stringify(configObj);
if (!("port" in gw) || gw.port == null) {
Expand Down Expand Up @@ -1480,7 +1481,40 @@ if (require.main === module) {
});
}

/**
* Backend containers do not inherit the gateway container's Docker user.
* Keep private downloaded logs runner-owned so artifact redaction can read them.
* @param {Record<string, unknown>} config
* @param {number | undefined} [uid]
* @param {number | undefined} [gid]
*/
function applyAgenticWorkflowsRunnerIdentity(config, uid = process.getuid?.(), gid = process.getgid?.()) {
const servers = config.mcpServers;
if (!servers || typeof servers !== "object" || !("agenticworkflows" in servers)) return;
const server = servers.agenticworkflows;
if (!server || typeof server !== "object" || !("container" in server) || typeof server.container !== "string" || !server.container) return;
const args = "args" in server ? server.args : [];
if (!Array.isArray(args) || !args.every(arg => typeof arg === "string")) {
throw new Error("Invalid agenticworkflows container arguments");
}
// Docker parses any suffix after -u as a username or UID, including -uroot.
if (args.some(arg => arg === "--user" || arg === "-u" || arg.startsWith("--user=") || (arg.startsWith("-u") && !arg.startsWith("--")))) return;
Comment thread
pelikhan marked this conversation as resolved.
if (!Number.isSafeInteger(uid) || !Number.isSafeInteger(gid) || uid === undefined || gid === undefined || uid < 0 || gid < 0) {
throw new Error("Runner identity is required for the agenticworkflows container");
}
const env = "env" in server ? server.env : {};
if (!env || typeof env !== "object" || Array.isArray(env)) {
throw new Error("Invalid agenticworkflows container environment");
}
Object.assign(server, {
args: [...args, "--user", `${uid}:${gid}`],
// /tmp is private to this backend container; /root is not writable as the runner.
env: { HOME: "/tmp", ...env },
});
}

module.exports = {
applyAgenticWorkflowsRunnerIdentity,
applyOTLPIgnoreIfMissing,
clearGatewayStartupMarker,
createGatewayStderrPath,
Expand Down
73 changes: 73 additions & 0 deletions actions/setup/js/start_mcp_gateway.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import path from "path";
import http from "http";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
applyAgenticWorkflowsRunnerIdentity,
applyOTLPIgnoreIfMissing,
clearGatewayStartupMarker,
createGatewayStderrPath,
Expand Down Expand Up @@ -35,6 +36,78 @@ import {
} from "./start_mcp_gateway.cjs";
import { writeMcpConfiguration } from "./mcp_configuration.cjs";

describe("agenticworkflows backend ownership", () => {
it.each(["localhost/gh-aw:dev", "alpine:3.24"])("uses the runner identity without widening private artifact modes (%s)", container => {
const env = { GITHUB_TOKEN: "${GITHUB_TOKEN}", GITHUB_ACTOR: "${GITHUB_ACTOR}" };
const backend = { container, args: ["--network", "host", "-w", "/workspace"], env };
const other = { container: "example/server", args: [] };
const config = { mcpServers: { agenticworkflows: backend, other } };
applyAgenticWorkflowsRunnerIdentity(config, 1001, 123);
expect(backend.args).toEqual(["--network", "host", "-w", "/workspace", "--user", "1001:123"]);
expect(backend.env).toEqual({ HOME: "/tmp", ...env });
expect(other).toEqual({ container: "example/server", args: [] });
applyAgenticWorkflowsRunnerIdentity(config, 1001, 123);
expect(backend.args.filter(arg => arg === "--user")).toHaveLength(1);
});

it.each([["--user", "42:43"], ["--user=42:43"], ["-u", "42:43"], ["-u42:43"], ["--user", "app:staff"], ["--user=app:staff"], ["-u", "app:staff"], ["-uroot"], ["-uapp:staff"], ["-u=app:staff"]])(
"preserves an explicitly configured container user: %j",
(...args) => {
const backend = { container: "example/server", args, env: { HOME: "/custom" } };
const original = structuredClone(backend);
applyAgenticWorkflowsRunnerIdentity({ mcpServers: { agenticworkflows: backend } }, 1001, 123);
applyAgenticWorkflowsRunnerIdentity({ mcpServers: { agenticworkflows: backend } }, 1001, 123);
expect(backend).toEqual(original);
}
);

it.each(["-update", "-unshare"])("preserves %s as Docker's attached -u username, not an unrelated flag", arg => {
const backend = { container: "example/server", args: [arg], env: { HOME: "/custom" } };
const original = structuredClone(backend);
applyAgenticWorkflowsRunnerIdentity({ mcpServers: { agenticworkflows: backend } }, 1001, 123);
expect(backend).toEqual(original);
});

it("does not confuse --userns with an explicit container user", () => {
const backend = { container: "example/server", args: ["--userns=host"], env: { HOME: "/custom" } };
applyAgenticWorkflowsRunnerIdentity({ mcpServers: { agenticworkflows: backend } }, 1001, 123);
expect(backend).toEqual({ container: "example/server", args: ["--userns=host", "--user", "1001:123"], env: { HOME: "/custom" } });
});

it("preserves a configured HOME and supports default container arguments", () => {
const backend = { container: "alpine:3.24", env: { HOME: "/custom" } };
applyAgenticWorkflowsRunnerIdentity({ mcpServers: { agenticworkflows: backend } }, 1001, 123);
expect(backend).toEqual({ container: "alpine:3.24", args: ["--user", "1001:123"], env: { HOME: "/custom" } });
});

it.each([{}, { mcpServers: {} }, { mcpServers: { agenticworkflows: { url: "https://example.com/mcp" } } }])("leaves absent and HTTP backends unchanged", config => {
const original = structuredClone(config);
applyAgenticWorkflowsRunnerIdentity(config, 1001, 123);
expect(config).toEqual(original);
});

it.each([
[-1, 123],
[1001, -1],
[NaN, 123],
[1001, Infinity],
[1.5, 123],
])("rejects invalid runner identities (%s, %s)", (uid, gid) => {
const backend = { container: "alpine:3.24" };
expect(() => applyAgenticWorkflowsRunnerIdentity({ mcpServers: { agenticworkflows: backend } }, uid, gid)).toThrow("Runner identity is required");
expect(backend).toEqual({ container: "alpine:3.24" });
});

it.each([{ args: "invalid" }, { args: [42] }, { env: [] }, { env: null }])("rejects malformed backend configuration without publishing it", fields => {
expect(() => applyAgenticWorkflowsRunnerIdentity({ mcpServers: { agenticworkflows: { container: "alpine:3.24", ...fields } } }, 1001, 123)).toThrow(/Invalid agenticworkflows container/);
});

it("applies identity before serializing the configuration sent to the gateway", () => {
const source = fs.readFileSync(new URL("./start_mcp_gateway.cjs", import.meta.url), "utf8");
expect(source.indexOf("applyAgenticWorkflowsRunnerIdentity(configObj)")).toBeLessThan(source.indexOf("mcpConfig = JSON.stringify(configObj)"));
});
});

describe("start_mcp_gateway logging", () => {
it("records only a sanitized configuration snapshot before launching the gateway", () => {
const source = fs.readFileSync(new URL("./start_mcp_gateway.cjs", import.meta.url), "utf8");
Expand Down
2 changes: 2 additions & 0 deletions docs/src/content/docs/reference/tools.md
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ tools:

See [GH-AW as an MCP Server](/gh-aw/reference/gh-aw-as-mcp-server/) for available operations.

The built-in container runs with the runner's UID and GID unless a container user is explicitly configured. This keeps downloaded logs runner-owned without widening their owner-only permissions, so the post-agent secret-redaction step can read them. Its default `HOME` is the backend container's ephemeral `/tmp`, not `/root`. Redaction failures still remove unsanitized sources and fail the step.

Workflow-writing tools (`add`, `update`, `fix`, and `compile`, including dry-run compilation) are disabled by default. Trusted workflows can opt in at server startup:

```aw wrap
Expand Down
Loading