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
2 changes: 2 additions & 0 deletions docs/pylon-publication.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ A missing protected-environment variable, omitted secret, unavailable installati

`.github/workflows/pylon-preview-release.yml` runs only for an exact canonical push to `refs/heads/pylon`. It uses Node `22.23.2` and npm `11.10.1`, packs twice with build networking disabled, compares all subjects byte for byte, and installs the first pack on Ubuntu Linux and macOS. Ubuntu is the supported gate for Linux and WSL2; native Windows publication support is deferred.

The offline build enters its network namespace through `sudo unshare`. Before writing its receipt or spawning build commands, the release entrypoint restores the invoking non-root `SUDO_UID` and `SUDO_GID`, clearing supplementary groups first. It verifies the resulting real and effective identities; malformed sudo identities or failed credential changes stop the build. The build and its children keep the isolated network namespace, while output directories and artifacts belong to the runner that prepares the preview manifest afterward. Ordinary non-sudo builds retain their caller identity.

The preview identity is:

```text
Expand Down
2 changes: 2 additions & 0 deletions scripts/build-pylon-prime-agent-release.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { rmSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { restoreSudoBuildIdentity } from "./lib/pylon-release-identity.mjs";
import {
assertCleanSource,
assertPinnedToolchain,
Expand Down Expand Up @@ -54,6 +55,7 @@ function buildOffline(environment) {
}

try {
restoreSudoBuildIdentity();
const args = parseArgs(process.argv.slice(2));
rmSync(join(root, PYLON_RELEASE_BUILD_RECEIPT), { force: true });
assertCleanSource(root, { rejectIgnoredInputs: true });
Expand Down
29 changes: 29 additions & 0 deletions scripts/lib/pylon-release-identity.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
function sudoIdentity(value, name) {
if (typeof value !== "string" || !/^[1-9][0-9]*$/.test(value)) {
throw new Error(`Sudo release builds require a non-root ${name}.`);
}
const id = Number(value);
if (!Number.isSafeInteger(id) || id >= 0xffffffff) {
throw new Error(`Sudo release builds require a valid ${name}.`);
}
return id;
}

// The caller creates the isolated network namespace before starting this entrypoint.
// Restore its file ownership before any build writes or subprocesses can run.
export function restoreSudoBuildIdentity(credentials = process, environment = process.env) {
if (credentials.getuid?.() !== 0) return;
if (environment.SUDO_UID === undefined && environment.SUDO_GID === undefined) return;
const uid = sudoIdentity(environment.SUDO_UID, "SUDO_UID");
const gid = sudoIdentity(environment.SUDO_GID, "SUDO_GID");
credentials.setgroups([]);
credentials.setgid(gid);
credentials.setuid(uid);
if (
credentials.getuid() !== uid || credentials.geteuid() !== uid ||
credentials.getgid() !== gid || credentials.getegid() !== gid ||
credentials.getgroups().some((group) => group !== gid)
) {
throw new Error("Sudo release build did not restore the invoking identity.");
}
}
61 changes: 61 additions & 0 deletions scripts/pylon-prime-agent-release.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "nod
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { test } from "node:test";
import { restoreSudoBuildIdentity } from "./lib/pylon-release-identity.mjs";
import {
assertImmutableReleaseUrl,
createInternalShrinkwrap,
Expand Down Expand Up @@ -41,6 +42,66 @@ const version = "0.8.1";
const toolchain = { node: PYLON_RELEASE_NODE_VERSION, npm: PYLON_RELEASE_NPM_VERSION };
const lockfileSha256 = "a".repeat(64);

function buildCredentials() {
let uid = 0;
let gid = 0;
let groups = [0, 27];
const changes = [];
return {
changes,
getuid: () => uid,
geteuid: () => uid,
getgid: () => gid,
getegid: () => gid,
getgroups: () => [...groups, gid],
setgroups: (value) => { changes.push("groups"); groups = value; },
setgid: (value) => { changes.push("gid"); gid = value; },
setuid: (value) => { changes.push("uid"); uid = value; },
};
}

test("sudo release builds restore the invoking owner without supplementary privileges", () => {
const credentials = buildCredentials();
restoreSudoBuildIdentity(credentials, { SUDO_UID: "1001", SUDO_GID: "1002" });
assert.equal(credentials.getuid(), 1001);
assert.equal(credentials.getgid(), 1002);
assert.deepEqual(credentials.getgroups(), [1002]);
assert.deepEqual(credentials.changes, ["groups", "gid", "uid"]);
});

test("malformed sudo identities fail before changing any credentials", () => {
for (const bad of [undefined, "", "0", "-1", "01", "1.0", "1e3", " 1000", "4294967295", "9007199254740992"]) {
for (const field of ["SUDO_UID", "SUDO_GID"]) {
const credentials = buildCredentials();
assert.throws(() => restoreSudoBuildIdentity(credentials, {
SUDO_UID: "1000", SUDO_GID: "1000", [field]: bad,
}), /Sudo release builds require/);
assert.deepEqual(credentials.changes, []);
}
}
});

test("ordinary release invocations retain their existing identity", () => {
const directRoot = buildCredentials();
restoreSudoBuildIdentity(directRoot, {});
assert.deepEqual(directRoot.changes, []);
const user = { ...buildCredentials(), getuid: () => 1000 };
restoreSudoBuildIdentity(user, { SUDO_UID: "invalid", SUDO_GID: "invalid" });
assert.deepEqual(user.changes, []);
assert.doesNotThrow(() => restoreSudoBuildIdentity({}, {}));
});

test("failed sudo identity restoration is terminal and preserves the original error", () => {
for (const operation of ["setgroups", "setgid", "setuid"]) {
const credentials = buildCredentials();
const error = Object.assign(new Error("denied"), { code: "EPERM" });
credentials[operation] = () => { throw error; };
assert.throws(() => restoreSudoBuildIdentity(credentials, { SUDO_UID: "1000", SUDO_GID: "1000" }), (actual) => actual === error);
}
const privileged = { ...buildCredentials(), geteuid: () => 0 };
assert.throws(() => restoreSudoBuildIdentity(privileged, { SUDO_UID: "1000", SUDO_GID: "1000" }), /did not restore/);
});

function fakeArtifacts() {
return PYLON_RELEASE_PACKAGES.map((releasePackage, index) => ({
package: releasePackage.packageName,
Expand Down
Loading