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
4 changes: 4 additions & 0 deletions .github/workflows/msix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ on:
- 'pnpm-lock.yaml'
- 'scripts/build-msix.mjs'
- 'scripts/msix.mjs'
- 'packages/mcp-server/scripts/**'
- '.github/workflows/msix.yml'

concurrency:
Expand Down Expand Up @@ -51,6 +52,9 @@ jobs:

- run: pnpm msix:build

# The sidecar had its Authenticode table cut off, so prove the SEA binary still starts.
- run: pnpm --filter @zplab/mcp-server build:sea && pnpm --filter @zplab/mcp-server smoke:sea

# Launched through its package identity, as a Store install runs.
- name: smoke-launch the packaged app
# Windows PowerShell, since the UI Automation assemblies are .NET Framework only.
Expand Down
6 changes: 4 additions & 2 deletions packages/mcp-server/scripts/build-sea.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { parseArgs } from "node:util";
import { stripCertificateTable } from "./pe.mjs";

const pkgDir = dirname(dirname(fileURLToPath(import.meta.url)));
const { values } = parseArgs({
Expand Down Expand Up @@ -86,8 +87,9 @@ if (triple) {
}
}
copyFileSync(values.node, exe);
// postject invalidates the signature and macOS arm64 refuses unsigned
// binaries: strip before injecting, ad-hoc re-sign after.
// postject's appended section would leave the Authenticode entry pointing inside the file.
if (isWindows) stripCertificateTable(exe);
// macOS arm64 refuses unsigned binaries, so the ad-hoc signature goes back on after injecting.
if (isMac) run("codesign", ["--remove-signature", exe]);
runBin("postject/dist/cli.js", [
exe,
Expand Down
5 changes: 5 additions & 0 deletions packages/mcp-server/scripts/pe.d.mts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
// Types for the node-only PE helpers, so peScripts.test.ts can import them under the app tsconfig.
export declare function certificateTable(image: Buffer): { offset: number; length: number } | undefined;
export declare function withoutCertificateTable(image: Buffer): Buffer;
export declare function stripCertificateTable(file: string): void;
export declare function hasCertificateTable(file: string): boolean;
44 changes: 44 additions & 0 deletions packages/mcp-server/scripts/pe.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Split out of build-sea.mjs so peScripts.test.ts can check the header arithmetic on a synthetic binary.
import { readFileSync, writeFileSync } from "node:fs";

const E_LFANEW = 0x3c;
const PE32_PLUS = 0x20b;
// Data directory 4, at optional header + 112 + 4 * 8, holding a file offset rather than an RVA.
const CERTIFICATE_DIRECTORY = 144;

function certificateDirectoryAt(image) {
const pe = image.length >= E_LFANEW + 4 ? image.readUInt32LE(E_LFANEW) : 0;
if (pe === 0 || image.toString("latin1", pe, pe + 4) !== "PE\0\0") throw new Error("not a PE image");
const optional = pe + 4 + 20;
if (image.readUInt16LE(optional) !== PE32_PLUS) throw new Error("not a PE32+ image");
return optional + CERTIFICATE_DIRECTORY;
}

/** The Authenticode table an image carries, undefined when it has none. */
export function certificateTable(image) {
const at = certificateDirectoryAt(image);
const offset = image.readUInt32LE(at);
const length = image.readUInt32LE(at + 4);
return offset === 0 && length === 0 ? undefined : { offset, length };
}

/** The image without its Authenticode table. The table must close the file, so nothing but the signature is cut. */
export function withoutCertificateTable(image) {
const table = certificateTable(image);
if (!table) return image;
if (table.offset + table.length !== image.length) {
throw new Error(`certificate table ends at ${table.offset + table.length}, file at ${image.length}`);
}
const stripped = Buffer.from(image.subarray(0, table.offset));
const at = certificateDirectoryAt(stripped);
stripped.fill(0, at, at + 8);
return stripped;
}

export function stripCertificateTable(file) {
writeFileSync(file, withoutCertificateTable(readFileSync(file)));
}

export function hasCertificateTable(file) {
return certificateTable(readFileSync(file)) !== undefined;
}
21 changes: 17 additions & 4 deletions packages/mcp-server/scripts/smoke-sea.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,11 @@ try {

const tools = await rpc(2, "tools/list", {});
const names = tools.result?.tools?.map((t) => t.name) ?? [];
check("all 8 tools", names.length === 8, names.join(","));
const expected = [
"create_draft", "validate_draft", "patch_design", "export_zpl", "validate_zpl", "import_zpl",
"get_schema", "open_in_app", "edit_design", "raster_image", "get_current_design",
];
check("hosted tool set", expected.every((n) => names.includes(n)) && names.length === expected.length, names.join(","));

const created = await rpc(3, "tools/call", {
name: "create_draft",
Expand Down Expand Up @@ -135,10 +139,19 @@ try {
});
check("wrong token 401", denied.status === 401);

const oia = await rpc(4, "tools/call", { name: "open_in_app", arguments: { designFile: design } });
check("open_in_app", JSON.parse(oia.result.content[0].text).ok === true);
// Until a session is announced the window tools answer with a reason, so attach the way the app does on boot.
const attached = await fetch(`${BASE}app-attach`, { method: "POST", headers: H, body: JSON.stringify({ session: "smoke" }) });
check("app-attach", attached.ok);

// open_in_app resolves only on the receipt, so hold the call until the event is answered.
const pendingOpen = rpc(4, "tools/call", { name: "open_in_app", arguments: { designFile: design } });
await new Promise((r) => setTimeout(r, 200));
check("openDraft stdout event", events.some((e) => e.zplabEvent === "openDraft"));
const draft = events.find((e) => e.zplabEvent === "openDraft");
check("openDraft stdout event", !!draft);
if (draft) {
await fetch(`${BASE}draft-receipt`, { method: "POST", headers: H, body: JSON.stringify({ id: draft.id, ok: true, replacedObjects: 0 }) });
check("open_in_app", JSON.parse((await pendingOpen).result.content[0].text).ok === true);
}

const pendingCall = rpc(5, "tools/call", { name: "get_current_design", arguments: {} });
await new Promise((r) => setTimeout(r, 300));
Expand Down
7 changes: 6 additions & 1 deletion scripts/build-msix.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { createRequire } from 'node:module';
import { basename, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { manifestExecutable, packageVersion, readManifest, stampVersion, storeVersion } from './msix.mjs';
import { hasCertificateTable } from '../packages/mcp-server/scripts/pe.mjs';

const ROOT = fileURLToPath(new URL('..', import.meta.url));
const TAURI = join(ROOT, 'src-tauri');
Expand Down Expand Up @@ -91,7 +92,11 @@ run(makepri, ['createconfig', '/cf', priconfig, '/dq', 'en-US_scale-100', '/o'])
run(process.execPath, [TAURI_CLI, 'build', '--target', TARGET, '--config', join(TAURI, 'tauri.msix.conf.json'), '--config', JSON.stringify({ version })]);

const release = join(targetDir, TARGET, 'release');
for (const exe of executables) copyFileSync(join(release, exe), join(layout, exe));
for (const exe of executables) {
// A carried-over table makes that signing fail with ERROR_BAD_EXE_FORMAT.
if (hasCertificateTable(join(release, exe))) throw new Error(`${join(release, exe)} carries a certificate table, build:sea must strip it`);
copyFileSync(join(release, exe), join(layout, exe));
}
run(makepri, ['new', '/pr', layout, '/cf', priconfig, '/mn', join(layout, 'AppxManifest.xml'), '/of', join(layout, 'resources.pri'), '/o']);

const msix = join(out, `ZPLab_${version}_x64.msix`);
Expand Down
78 changes: 78 additions & 0 deletions src/lib/peScripts.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { afterAll, describe, it, expect } from "vitest";
import { mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { certificateTable, hasCertificateTable, stripCertificateTable, withoutCertificateTable } from "../../packages/mcp-server/scripts/pe.mjs";

const PE_AT = 0x80;
const OPTIONAL_AT = PE_AT + 4 + 20;
// PE32+ data directories start at optional header + 112, the Certificate Table is entry 4.
const DIRECTORY_AT = OPTIONAL_AT + 112 + 4 * 8;

/** A PE32+ image with a body and, when given, an Authenticode table the directory points at. */
function image(magic: number, body: Buffer, table?: { offset: number; length: number; bytes?: Buffer }): Buffer {
const header = Buffer.alloc(OPTIONAL_AT + 240);
header.write("MZ", 0, "latin1");
header.writeUInt32LE(PE_AT, 0x3c);
header.write("PE\0\0", PE_AT, "latin1");
header.writeUInt16LE(magic, OPTIONAL_AT);
if (table) {
header.writeUInt32LE(table.offset, DIRECTORY_AT);
header.writeUInt32LE(table.length, DIRECTORY_AT + 4);
}
return Buffer.concat([header, body, table?.bytes ?? Buffer.alloc(0)]);
}

const body = Buffer.alloc(64, 0xab);
const signature = Buffer.alloc(24, 0xcd);
const signedAt = OPTIONAL_AT + 240 + body.length;

const scratch = mkdtempSync(join(tmpdir(), "pe-"));
afterAll(() => rmSync(scratch, { recursive: true, force: true }));

describe("the Authenticode table of a PE32+ image", () => {
it("is cut off together with its directory entry when it closes the file", () => {
const signed = image(0x20b, body, { offset: signedAt, length: signature.length, bytes: signature });
expect(certificateTable(signed)).toEqual({ offset: signedAt, length: 24 });
const stripped = withoutCertificateTable(signed);
expect(stripped.length).toBe(signedAt);
expect(certificateTable(stripped)).toBeUndefined();
expect(stripped.subarray(OPTIONAL_AT + 240)).toEqual(body);
expect(certificateTable(signed)).toEqual({ offset: signedAt, length: 24 });
});

it("strips a file on disk and reports what it carries", () => {
const file = join(scratch, "x.exe");
writeFileSync(file, image(0x20b, body, { offset: signedAt, length: signature.length, bytes: signature }));
expect(hasCertificateTable(file)).toBe(true);
stripCertificateTable(file);
expect(hasCertificateTable(file)).toBe(false);
expect(statSync(file).size).toBe(signedAt);
});

/** Node's own signed image pins the directory offset the synthetic layout only asserts. */
it("finds the table at the end of a real signed node.exe", () => {
const real = readFileSync(process.execPath);
const pe = real.length > 0x40 ? real.readUInt32LE(0x3c) : 0;
const pe32Plus = pe > 0 && real.toString("latin1", pe, pe + 4) === "PE\0\0" && real.readUInt16LE(pe + 24) === 0x20b;
const table = pe32Plus ? certificateTable(real) : undefined;
if (!table) return;
expect(table.offset + table.length).toBe(real.length);
});

it("leaves an unsigned image as it is", () => {
const unsigned = image(0x20b, body);
expect(certificateTable(unsigned)).toBeUndefined();
expect(withoutCertificateTable(unsigned)).toBe(unsigned);
});

it("refuses to cut when the entry points inside the file, as it does after postject appends a section", () => {
const grown = Buffer.concat([image(0x20b, body, { offset: signedAt, length: 24, bytes: signature }), Buffer.alloc(4096)]);
expect(() => withoutCertificateTable(grown)).toThrow(/ends at/);
});

it("refuses images that are not PE32+", () => {
expect(() => certificateTable(image(0x10b, body))).toThrow(/PE32\+/);
expect(() => certificateTable(Buffer.from("not an image"))).toThrow(/not a PE/);
});
});
Loading