From 8511d6af6e1c890efc2bcba012a8ca17b4b4db12 Mon Sep 17 00:00:00 2001 From: 777genius Date: Thu, 21 May 2026 12:57:29 +0300 Subject: [PATCH] fix(linux): install cli launcher in packages --- .github/workflows/release.yml | 3 + package.json | 17 ++- resources/afterInstall.sh | 4 +- resources/linux/bin/agent-teams-ai | 18 +++ .../verifyLinuxInstallers.cjs | 138 ++++++++++++++++++ .../electronBuilderLinuxPackaging.test.ts | 36 +++++ 6 files changed, 214 insertions(+), 2 deletions(-) create mode 100755 resources/linux/bin/agent-teams-ai create mode 100644 scripts/electron-builder/verifyLinuxInstallers.cjs create mode 100644 test/main/build/electronBuilderLinuxPackaging.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 601173e2..2a31c879 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -642,6 +642,9 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: pnpm pack:linux --publish never + - name: Validate Linux installers + run: node ./scripts/electron-builder/verifyLinuxInstallers.cjs release + - name: Validate packaged bundle (Linux) run: node ./scripts/electron-builder/verifyBundle.cjs "release/linux-unpacked" linux x64 diff --git a/package.json b/package.json index b0d73263..da6d1a1b 100644 --- a/package.json +++ b/package.json @@ -327,7 +327,22 @@ "artifactName": "Agent.Teams.AI-${version}.${ext}" }, "deb": { - "afterInstall": "resources/afterInstall.sh" + "afterInstall": "resources/afterInstall.sh", + "fpm": [ + "resources/linux/bin/agent-teams-ai=/usr/bin/agent-teams-ai" + ] + }, + "rpm": { + "afterInstall": "resources/afterInstall.sh", + "fpm": [ + "resources/linux/bin/agent-teams-ai=/usr/bin/agent-teams-ai" + ] + }, + "pacman": { + "afterInstall": "resources/afterInstall.sh", + "fpm": [ + "resources/linux/bin/agent-teams-ai=/usr/bin/agent-teams-ai" + ] }, "nsis": { "artifactName": "Agent.Teams.AI.Setup.${version}.${ext}", diff --git a/resources/afterInstall.sh b/resources/afterInstall.sh index 4f472b3a..195071de 100755 --- a/resources/afterInstall.sh +++ b/resources/afterInstall.sh @@ -1,8 +1,10 @@ #!/bin/bash +set -e + # Fix chrome-sandbox permissions for SUID sandbox on Linux # See: https://github.com/electron/electron/issues/17972 -SANDBOX_PATH="/opt/${productFilename}/chrome-sandbox" +SANDBOX_PATH="/opt/${sanitizedProductName}/chrome-sandbox" if [ -f "$SANDBOX_PATH" ]; then chown root:root "$SANDBOX_PATH" diff --git a/resources/linux/bin/agent-teams-ai b/resources/linux/bin/agent-teams-ai new file mode 100755 index 00000000..e897b1e3 --- /dev/null +++ b/resources/linux/bin/agent-teams-ai @@ -0,0 +1,18 @@ +#!/bin/sh +set -eu + +if [ "${AGENT_TEAMS_AI_BIN:-}" != "" ]; then + exec "$AGENT_TEAMS_AI_BIN" "$@" +fi + +for executable in \ + "/opt/Agent-Teams-AI/agent-teams-ai" \ + "/opt/Agent Teams AI/agent-teams-ai" +do + if [ -x "$executable" ]; then + exec "$executable" "$@" + fi +done + +echo "agent-teams-ai: installed app executable was not found under /opt/Agent-Teams-AI" >&2 +exit 127 diff --git a/scripts/electron-builder/verifyLinuxInstallers.cjs b/scripts/electron-builder/verifyLinuxInstallers.cjs new file mode 100644 index 00000000..fa465f98 --- /dev/null +++ b/scripts/electron-builder/verifyLinuxInstallers.cjs @@ -0,0 +1,138 @@ +#!/usr/bin/env node +/* global Buffer, console, module, process, require */ +/* eslint-disable @typescript-eslint/no-require-imports */ +const fs = require('fs'); +const path = require('path'); +const { execFileSync, spawnSync } = require('child_process'); + +function fail(message) { + console.error(`[verifyLinuxInstallers] ${message}`); + process.exit(1); +} + +function run(command, args, input) { + const result = spawnSync(command, args, { + input, + encoding: input ? undefined : 'utf8', + maxBuffer: 128 * 1024 * 1024, + }); + if (result.status !== 0) { + const stderr = Buffer.isBuffer(result.stderr) + ? result.stderr.toString('utf8') + : result.stderr || ''; + throw new Error(`${command} ${args.join(' ')} failed: ${stderr}`); + } + return Buffer.isBuffer(result.stdout) ? result.stdout : Buffer.from(result.stdout || '', 'utf8'); +} + +function readArMember(archivePath, memberName) { + return execFileSync('ar', ['p', archivePath, memberName], { + maxBuffer: 128 * 1024 * 1024, + }); +} + +function getTarCompressionFlag(memberName) { + if (memberName.endsWith('.tar.xz')) return 'J'; + if (memberName.endsWith('.tar.gz')) return 'z'; + if (memberName.endsWith('.tar.bz2')) return 'j'; + fail(`Unsupported deb tar member compression: ${memberName}`); +} + +function getDebMember(archivePath, prefix) { + const members = execFileSync('ar', ['t', archivePath], { encoding: 'utf8' }) + .split(/\r?\n/) + .filter(Boolean); + const member = members.find((entry) => entry.startsWith(prefix)); + if (!member) { + fail(`Missing ${prefix} member in ${archivePath}`); + } + return member; +} + +function listTar(tarBuffer, memberName, verbose = false) { + const flag = getTarCompressionFlag(memberName); + const mode = verbose ? `-tv${flag}f` : `-t${flag}f`; + return run('tar', [mode, '-'], tarBuffer).toString('utf8'); +} + +function extractTarFile(tarBuffer, memberName, filePath) { + const flag = getTarCompressionFlag(memberName); + return run('tar', [`-xO${flag}f`, '-', filePath], tarBuffer).toString('utf8'); +} + +function assertContains(haystack, needle, label) { + if (!haystack.includes(needle)) { + fail(`${label} does not contain ${needle}`); + } +} + +function verifyDeb(debPath) { + const dataMember = getDebMember(debPath, 'data.tar.'); + const controlMember = getDebMember(debPath, 'control.tar.'); + const data = readArMember(debPath, dataMember); + const control = readArMember(debPath, controlMember); + const dataList = listTar(data, dataMember); + const dataVerboseList = listTar(data, dataMember, true); + + assertContains(dataList, './usr/bin/agent-teams-ai\n', path.basename(debPath)); + assertContains(dataList, './opt/Agent-Teams-AI/agent-teams-ai\n', path.basename(debPath)); + assertContains(dataList, './opt/Agent-Teams-AI/chrome-sandbox\n', path.basename(debPath)); + + const launcherLine = dataVerboseList + .split(/\r?\n/) + .find((line) => line.endsWith('./usr/bin/agent-teams-ai')); + if (!launcherLine || !launcherLine.startsWith('-rwx')) { + fail(`/usr/bin/agent-teams-ai is not executable in ${debPath}`); + } + + const launcher = extractTarFile(data, dataMember, './usr/bin/agent-teams-ai'); + assertContains(launcher, '/opt/Agent-Teams-AI/agent-teams-ai', 'CLI launcher'); + if (launcher.includes('--no-sandbox')) { + fail('CLI launcher must not force --no-sandbox'); + } + + const postinst = extractTarFile(control, controlMember, './postinst'); + assertContains( + postinst, + 'SANDBOX_PATH="/opt/Agent-Teams-AI/chrome-sandbox"', + 'deb postinst' + ); + assertContains(postinst, 'chmod 4755 "$SANDBOX_PATH"', 'deb postinst'); +} + +function main() { + const releaseDir = path.resolve(process.argv[2] || 'release'); + if (!fs.existsSync(releaseDir)) { + fail(`Release directory does not exist: ${releaseDir}`); + } + + const debs = fs + .readdirSync(releaseDir) + .filter((entry) => entry.endsWith('.deb')) + .map((entry) => path.join(releaseDir, entry)); + if (debs.length === 0) { + fail(`No .deb packages found in ${releaseDir}`); + } + + for (const deb of debs) { + verifyDeb(deb); + console.log(`[verifyLinuxInstallers] OK ${path.basename(deb)}`); + } +} + +if (require.main === module) { + try { + main(); + } catch (error) { + fail(error instanceof Error ? error.message : String(error)); + } +} + +module.exports = { + _internal: { + extractTarFile, + getDebMember, + listTar, + verifyDeb, + }, +}; diff --git a/test/main/build/electronBuilderLinuxPackaging.test.ts b/test/main/build/electronBuilderLinuxPackaging.test.ts new file mode 100644 index 00000000..a07b7838 --- /dev/null +++ b/test/main/build/electronBuilderLinuxPackaging.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment node +import fs from 'node:fs'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +const repoRoot = path.resolve(__dirname, '../../..'); + +describe('electron-builder Linux packaging', () => { + it('installs a package-owned CLI launcher into PATH for fpm Linux packages', () => { + const packageJson = JSON.parse(fs.readFileSync(path.join(repoRoot, 'package.json'), 'utf8')); + const launcherPath = path.join(repoRoot, 'resources/linux/bin/agent-teams-ai'); + const launcher = fs.readFileSync(launcherPath, 'utf8'); + const launcherMode = fs.statSync(launcherPath).mode; + + for (const target of ['deb', 'rpm', 'pacman'] as const) { + expect(packageJson.build[target].afterInstall).toBe('resources/afterInstall.sh'); + expect(packageJson.build[target].fpm).toContain( + 'resources/linux/bin/agent-teams-ai=/usr/bin/agent-teams-ai' + ); + } + expect(launcher).toContain('#!/bin/sh'); + expect(launcher).toContain('/opt/Agent-Teams-AI/agent-teams-ai'); + expect(launcher).not.toContain('--no-sandbox'); + if (process.platform !== 'win32') { + expect(launcherMode & 0o111).not.toBe(0); + } + }); + + it('fixes chrome-sandbox permissions at the actual fpm install directory', () => { + const afterInstall = fs.readFileSync(path.join(repoRoot, 'resources/afterInstall.sh'), 'utf8'); + + expect(afterInstall).toContain('/opt/${sanitizedProductName}/chrome-sandbox'); + expect(afterInstall).not.toContain('/opt/${productFilename}/chrome-sandbox'); + }); +});