Skip to content
Open
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 README.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,3 +193,8 @@ This optional test uses `web-ext` and a disposable headless profile, never your
signed-in profile. It exercises the real compatibility layer against local
HTTP fixtures and verifies lifecycle delivery and foreground-tab preservation.
It does not exercise the signed-in Codex native transport.

For an unsigned review build on macOS, Linux, or Windows, commit the working tree
and run `pnpm package:portable`. It uses Node and Git, writes ZIP/XPI and a review-source
archive under `dist/`, and preserves the release checksum filenames. The existing
PowerShell packaging command and release workflow are unchanged.
6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
"scripts": {
"check": "node scripts/check-version.mjs && node scripts/verify-extension.mjs",
"version:set": "node scripts/set-version.mjs",
"test": "npm run check && node tests/test-amo-auth.mjs && node tests/test-companion-required.mjs && node tests/test-sidepanel-focus-compat.mjs && node tests/test-sidepanel-host-access.mjs && node tests/test-sidepanel-bootstrap.mjs && node tests/test-sidepanel-companion-setup.mjs && node tests/test-chatgpt-feature-parity.mjs && node tests/test-firefox-compat.mjs && node tests/test-editing-assets.mjs && node tests/test-native-host.mjs",
"test": "npm run check && node tests/test-amo-auth.mjs && node tests/test-companion-required.mjs && node tests/test-sidepanel-focus-compat.mjs && node tests/test-sidepanel-host-access.mjs && node tests/test-sidepanel-bootstrap.mjs && node tests/test-sidepanel-companion-setup.mjs && node tests/test-chatgpt-feature-parity.mjs && node tests/test-firefox-compat.mjs && node tests/test-editing-assets.mjs && node tests/test-native-host.mjs && node tests/test-portable-packaging.mjs",
"package": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/package-extension.ps1",
"test:live": "node tests/test-firefox-lifecycle-live.mjs"
"test:package": "node tests/test-portable-packaging.mjs",
"test:live": "node tests/test-firefox-lifecycle-live.mjs",
"package:portable": "node scripts/package-extension.mjs"
}
}
131 changes: 131 additions & 0 deletions scripts/package-extension.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env node
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
import { deflateRawSync } from "node:zlib";
import { createHash } from "node:crypto";

const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
process.chdir(root);
for (const script of ["check-version.mjs", "verify-extension.mjs"]) {
execFileSync(process.execPath, [path.join("scripts", script)], { stdio: "inherit" });
}
if (execFileSync("git", ["status", "--porcelain", "--untracked-files=normal"], { encoding: "utf8" }).trim()) {
throw new Error("Commit the source before packaging so the source archive matches the extension.");
}
// Freeze one tree for both artifacts. Checkout filters (such as autocrlf)
// and ignored local files must never affect the reviewed package bytes.
const commit = execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim();
const entries = execFileSync("git", ["ls-tree", "-rz", "--full-tree", commit], {
encoding: "utf8", maxBuffer: 16 * 1024 * 1024,
}).split("\0").filter(Boolean).map(entry => {
const match = /^(\d+) (\w+) ([a-f\d]+)\t([\s\S]+)$/u.exec(entry);
if (!match || match[2] !== "blob" || !["100644", "100755"].includes(match[1])) {
throw new Error(`Refusing to package non-regular committed entry ${entry}`);
}
return { name: match[4], object: match[3], mode: parseInt(match[1], 8) };
}).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
const blobs = execFileSync("git", ["cat-file", "--batch"], {
input: entries.map(entry => entry.object).join("\n") + "\n",
maxBuffer: 256 * 1024 * 1024,
});
let cursor = 0;
const sourceFiles = entries.map(entry => {
const end = blobs.indexOf(10, cursor);
const header = blobs.subarray(cursor, end).toString("ascii").split(" ");
const size = Number(header[2]);
if (end < 0 || header[0] !== entry.object || header[1] !== "blob" || !Number.isSafeInteger(size)) {
throw new Error(`Invalid committed blob for ${entry.name}`);
}
cursor = end + 1;
const data = blobs.subarray(cursor, cursor + size);
cursor += size + 1;
return { name: entry.name, data, mode: entry.mode };
});
const { version } = JSON.parse(sourceFiles.find(file => file.name === "version.json").data);
const stem = `codex-computer-use-firefox-zen-${version}`;
const dist = path.join(root, "dist");
fs.mkdirSync(dist, { recursive: true });

const crcTable = Array.from({ length: 256 }, (_, value) => {
for (let bit = 0; bit < 8; bit++) value = value & 1 ? 0xedb88320 ^ (value >>> 1) : value >>> 1;
return value >>> 0;
});
function crc32(data) {
let crc = 0xffffffff;
for (const byte of data) crc = crcTable[(crc ^ byte) & 255] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}

const files = sourceFiles.filter(file => file.name.startsWith("extension/"))
.map(file => ({ name: file.name.slice("extension/".length), data: file.data, mode: file.mode }));

function zip(files) {
if (files.length >= 65535) throw new Error("ZIP64 is not supported by this packager.");
const parts = [];
const central = [];
let offset = 0;
for (const { name, data, mode } of files) {
const filename = Buffer.from(name);
const compressed = deflateRawSync(data, { level: 9 });
const crc = crc32(data);
if (filename.length > 65535 || data.length > 0xffffffff || offset > 0xffffffff) {
throw new Error(`ZIP64 is required for ${name}.`);
}
// ZIP-standard paths, UTF-8 names, and a fixed 1980-01-01 timestamp.
const local = Buffer.alloc(30);
local.writeUInt32LE(0x04034b50);
local.writeUInt16LE(20, 4);
local.writeUInt16LE(0x800, 6);
local.writeUInt16LE(8, 8);
local.writeUInt16LE(0x21, 12);
local.writeUInt32LE(crc, 14);
local.writeUInt32LE(compressed.length, 18);
local.writeUInt32LE(data.length, 22);
local.writeUInt16LE(filename.length, 26);
const entry = Buffer.alloc(46);
entry.writeUInt32LE(0x02014b50);
entry.writeUInt16LE((3 << 8) | 20, 4); // Unix creator: preserve committed file modes.
entry.writeUInt16LE(20, 6);
entry.writeUInt16LE(0x800, 8);
entry.writeUInt16LE(8, 10);
entry.writeUInt16LE(0x21, 14);
entry.writeUInt32LE(crc, 16);
entry.writeUInt32LE(compressed.length, 20);
entry.writeUInt32LE(data.length, 24);
entry.writeUInt16LE(filename.length, 28);
entry.writeUInt32LE((mode << 16) >>> 0, 38);
entry.writeUInt32LE(offset, 42);
parts.push(local, filename, compressed);
central.push(entry, filename);
offset += local.length + filename.length + compressed.length;
}
const centralData = Buffer.concat(central);
if (offset + centralData.length > 0xffffffff) throw new Error("ZIP64 is required for this archive.");
const end = Buffer.alloc(22);
end.writeUInt32LE(0x06054b50);
end.writeUInt16LE(files.length, 8);
end.writeUInt16LE(files.length, 10);
end.writeUInt32LE(centralData.length, 12);
end.writeUInt32LE(offset, 16);
return Buffer.concat([...parts, centralData, end]);
}
const archive = zip(files);
for (const suffix of ["zip", "xpi"]) fs.writeFileSync(path.join(dist, `${stem}.${suffix}`), archive);
fs.writeFileSync(path.join(dist, `${stem}-source.zip`), zip(sourceFiles));

const artifacts = [`${stem}.zip`, `${stem}.xpi`, `${stem}-source.zip`];
const checksums = artifacts.map((name) => {
const checksum = `${createHash("sha256").update(fs.readFileSync(path.join(dist, name))).digest("hex")} ${name}\n`;
// Preserve the existing release workflow's per-artifact checksum names.
fs.writeFileSync(path.join(dist, name.endsWith(".zip") ? name.slice(0, -4) + ".sha256" : name + ".sha256"), checksum);
return checksum;
});
fs.writeFileSync(path.join(dist, "SHA256SUMS"), checksums.join(""));
console.log(JSON.stringify({
version,
commit,
files: files.length,
artifacts: artifacts.map((name) => ({ path: path.join(dist, name), bytes: fs.statSync(path.join(dist, name)).size })),
}, null, 2));
65 changes: 65 additions & 0 deletions tests/test-portable-packaging.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { execFileSync } from "node:child_process";
import { inflateRawSync } from "node:zlib";
import { fileURLToPath } from "node:url";

const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
const temp = fs.mkdtempSync(path.resolve(os.tmpdir(), "portable-packaging-"));
let checkout = path.join(temp, "checkout");
const git = (...args) => execFileSync("git", args, { cwd: checkout, encoding: "utf8" });
function zipEntries(buffer) {
const entries = new Map();
let offset = 0;
while (buffer.readUInt32LE(offset) === 0x04034b50) {
const compressedSize = buffer.readUInt32LE(offset + 18);
const nameSize = buffer.readUInt16LE(offset + 26);
const extraSize = buffer.readUInt16LE(offset + 28);
const name = buffer.subarray(offset + 30, offset + 30 + nameSize).toString();
const start = offset + 30 + nameSize + extraSize;
entries.set(name, inflateRawSync(buffer.subarray(start, start + compressedSize)));
offset = start + compressedSize;
}
return entries;
}
try {
execFileSync("git", ["clone", "--quiet", "--no-hardlinks", root, checkout]);
// Test the script being edited, even before its parent commit is published.
fs.copyFileSync(path.join(root, "scripts/package-extension.mjs"), path.join(checkout, "scripts/package-extension.mjs"));
git("add", "scripts/package-extension.mjs");
// A disposable fixture must not depend on the contributor's signing key.
git("-c", "user.name=Packaging regression", "-c", "user.email=packaging@localhost",
"-c", "commit.gpgsign=false", "commit", "--quiet", "--allow-empty", "-m", "Canonical packaging fixture");
const { version } = JSON.parse(fs.readFileSync(path.join(checkout, "version.json")));
const stem = `codex-computer-use-firefox-zen-${version}`;
const build = () => {
execFileSync(process.execPath, ["scripts/package-extension.mjs"], { cwd: checkout, stdio: "pipe" });
return ["zip", "xpi", "source.zip"].map(suffix => fs.readFileSync(
path.join(checkout, "dist", suffix === "source.zip" ? `${stem}-source.zip` : `${stem}.${suffix}`),
));
};
const baseline = build();
assert.deepEqual(baseline[0], baseline[1], "ZIP and XPI must carry identical bytes.");
const packaged = zipEntries(baseline[0]);
const source = zipEntries(baseline[2]);
assert.equal(packaged.size, [...source.keys()].filter(name => name.startsWith("extension/")).length);
for (const [name, data] of packaged) {
assert.deepEqual(data, source.get(`extension/${name}`), `Review source must contain the exact bytes for ${name}`);
}
fs.appendFileSync(path.join(checkout, ".git/info/exclude"), "\nextension/debug.log\n");
fs.writeFileSync(path.join(checkout, "extension/debug.log"), "ignored local secret\n");
assert.equal(git("status", "--porcelain").trim(), "", "The contaminating local file is ignored.");
assert.deepEqual(build(), baseline, "Ignored local files must not affect either archive.");
const crlfCheckout = path.join(temp, "crlf-checkout");
execFileSync("git", ["clone", "--quiet", "--no-hardlinks", "--config", "core.autocrlf=true", checkout, crlfCheckout]);
checkout = crlfCheckout;
assert.equal(git("status", "--porcelain").trim(), "", "CRLF checkout remains clean.");
assert.ok(fs.readFileSync(path.join(checkout, "extension/manifest.json"), "utf8").includes("\r\n"),
"The fixture must actually exercise checkout byte conversion.");
assert.deepEqual(build(), baseline, "Checkout line-ending filters must not affect committed package bytes.");
console.log(JSON.stringify({ ok: true, exactReviewSource: true, ignoredFilesExcluded: true, checkoutFiltersIgnored: true }));
} finally {
fs.rmSync(temp, { recursive: true, force: true });
}