moving
This commit is contained in:
@@ -15,31 +15,639 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const resourcesDir = path.join(root, 'app', 'src-tauri', 'resources');
|
||||
const stagedDepsDir = path.join(resourcesDir, 'node_modules');
|
||||
const bundledNpmRuntimeDir = path.join(stagedDepsDir, 'waggle-node-runtime');
|
||||
const bundledNpmBinDir = path.join(bundledNpmRuntimeDir, 'bin');
|
||||
const bundledNpmPackageDir = path.join(bundledNpmRuntimeDir, 'node_modules', 'npm');
|
||||
const marketplaceDbRelative = 'packages/marketplace/marketplace.db';
|
||||
const targetArch = process.env.TARGET_ARCH || process.arch;
|
||||
const SIDECAR_PROVENANCE_PREFIX = '// Waggle-Sidecar-Provenance: ';
|
||||
const SOURCE_ARTIFACT_PATTERN = /(?:\.map|\.(?:[cm]?ts|tsx)|\.tsbuildinfo)$/i;
|
||||
const FIRST_PARTY_RUNTIME_ENTRY_PATTERN = /^(?:dist|package\.json|licen[cs]e(?:\.(?:md|txt))?|notice(?:\.(?:md|txt))?)$/i;
|
||||
const MANUAL_FIRST_PARTY_RUNTIME_TARGETS = new Map([
|
||||
['@waggle/hive-mind-hooks-openclaw', ['dist/handler.bundle.cjs']],
|
||||
]);
|
||||
const REQUIRED_SHARP_VERSION = '0.35.3';
|
||||
const REQUIRED_BETTER_SQLITE_RANGE = '>=12.6.2 <13';
|
||||
const STAGED_DEPENDENCY_VERSION_ALLOWLISTS = new Map([
|
||||
['brace-expansion', new Set(['1.1.18', '2.1.4', '5.0.9'])],
|
||||
['fast-uri', new Set(['3.1.5'])],
|
||||
['ip-address', new Set(['10.4.0'])],
|
||||
]);
|
||||
const STAGED_DEPENDENCY_DENYLIST = new Set(['js-yaml']);
|
||||
|
||||
const missing = [];
|
||||
const unsafe = [];
|
||||
|
||||
function listFiles(dir) {
|
||||
const files = [];
|
||||
const stack = [dir];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (entry.isDirectory()) stack.push(full);
|
||||
else if (entry.isFile()) files.push(full);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function resourceRelative(file) {
|
||||
return path.relative(resourcesDir, file).split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function sha256File(file) {
|
||||
return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
function readGitBlob(revision, relative) {
|
||||
return execFileSync(
|
||||
'git',
|
||||
['-C', root, 'cat-file', 'blob', `${revision}:${relative}`],
|
||||
{ maxBuffer: 64 * 1024 * 1024, windowsHide: true },
|
||||
);
|
||||
}
|
||||
|
||||
function listSqliteSidecars(dir) {
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
const sidecars = [];
|
||||
const stack = [dir];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
const full = path.join(current, entry.name);
|
||||
if (/\.db-(?:wal|shm|journal)$/i.test(entry.name)) sidecars.push(full);
|
||||
if (entry.isDirectory()) stack.push(full);
|
||||
}
|
||||
}
|
||||
return sidecars.sort();
|
||||
}
|
||||
|
||||
function parseSidecarProvenance(serviceBuffer) {
|
||||
const newlineIndex = serviceBuffer.indexOf(0x0a);
|
||||
if (newlineIndex < 0) {
|
||||
throw new Error('resources/service.js is missing embedded provenance');
|
||||
}
|
||||
const firstLine = serviceBuffer.subarray(0, newlineIndex).toString('utf8');
|
||||
if (!firstLine.startsWith(SIDECAR_PROVENANCE_PREFIX)) {
|
||||
throw new Error('resources/service.js is missing embedded provenance');
|
||||
}
|
||||
const encoded = firstLine.slice(SIDECAR_PROVENANCE_PREFIX.length);
|
||||
if (
|
||||
encoded.length === 0
|
||||
|| encoded.length % 4 !== 0
|
||||
|| !/^[A-Za-z0-9+/]+={0,2}$/.test(encoded)
|
||||
) {
|
||||
throw new Error('resources/service.js embedded provenance is not canonical base64');
|
||||
}
|
||||
const decoded = Buffer.from(encoded, 'base64');
|
||||
if (decoded.toString('base64') !== encoded) {
|
||||
throw new Error('resources/service.js embedded provenance is not canonical base64');
|
||||
}
|
||||
|
||||
let provenance;
|
||||
try {
|
||||
provenance = JSON.parse(decoded.toString('utf8'));
|
||||
} catch {
|
||||
throw new Error('resources/service.js embedded provenance is not valid JSON');
|
||||
}
|
||||
if (!provenance || typeof provenance !== 'object' || Array.isArray(provenance)) {
|
||||
throw new Error('resources/service.js embedded provenance must be an object');
|
||||
}
|
||||
if (provenance.schemaVersion !== 1) {
|
||||
throw new Error('resources/service.js embedded provenance schemaVersion must be 1');
|
||||
}
|
||||
if (!/^[0-9a-f]{40}$/.test(provenance.sourceRevision)) {
|
||||
throw new Error('resources/service.js embedded provenance sourceRevision is invalid');
|
||||
}
|
||||
if (provenance.entryPoint !== 'packages/server/src/local/service.ts') {
|
||||
throw new Error('resources/service.js embedded provenance entryPoint is invalid');
|
||||
}
|
||||
if (!Array.isArray(provenance.sourceInputs) || provenance.sourceInputs.length === 0) {
|
||||
throw new Error('resources/service.js embedded provenance sourceInputs are missing');
|
||||
}
|
||||
|
||||
const requiredInputs = new Set([
|
||||
'package-lock.json',
|
||||
'package.json',
|
||||
'packages/server/src/local/service.ts',
|
||||
'scripts/build-sidecar.mjs',
|
||||
]);
|
||||
let previousPath = null;
|
||||
for (const input of provenance.sourceInputs) {
|
||||
if (!input || typeof input !== 'object' || Array.isArray(input)) {
|
||||
throw new Error('resources/service.js embedded provenance source input is invalid');
|
||||
}
|
||||
const relative = input.path;
|
||||
if (
|
||||
typeof relative !== 'string'
|
||||
|| relative.length === 0
|
||||
|| relative.includes('\\')
|
||||
|| relative.includes('\0')
|
||||
|| path.posix.isAbsolute(relative)
|
||||
|| relative.split('/').some((part) => part === '' || part === '.' || part === '..')
|
||||
|| relative.split('/').includes('node_modules')
|
||||
) {
|
||||
throw new Error('resources/service.js embedded provenance source input path is unsafe');
|
||||
}
|
||||
if (previousPath !== null && previousPath >= relative) {
|
||||
throw new Error('resources/service.js embedded provenance source inputs are not unique and sorted');
|
||||
}
|
||||
if (!/^[0-9a-f]{64}$/.test(input.sha256)) {
|
||||
throw new Error('resources/service.js embedded provenance source input hash is invalid');
|
||||
}
|
||||
previousPath = relative;
|
||||
requiredInputs.delete(relative);
|
||||
}
|
||||
if (requiredInputs.size > 0) {
|
||||
throw new Error('resources/service.js embedded provenance omits required source inputs');
|
||||
}
|
||||
|
||||
const payload = serviceBuffer.subarray(newlineIndex + 1);
|
||||
if (
|
||||
!provenance.bundlePayload
|
||||
|| typeof provenance.bundlePayload !== 'object'
|
||||
|| !Number.isSafeInteger(provenance.bundlePayload.sizeBytes)
|
||||
|| provenance.bundlePayload.sizeBytes < 1
|
||||
|| !/^[0-9a-f]{64}$/.test(provenance.bundlePayload.sha256)
|
||||
|| provenance.bundlePayload.sizeBytes !== payload.byteLength
|
||||
|| provenance.bundlePayload.sha256 !== createHash('sha256').update(payload).digest('hex')
|
||||
) {
|
||||
throw new Error('resources/service.js payload does not match embedded provenance');
|
||||
}
|
||||
|
||||
return provenance;
|
||||
}
|
||||
|
||||
function expectedSourceRevision() {
|
||||
const index = process.argv.indexOf('--expected-source-revision');
|
||||
const supplied = index >= 0 ? process.argv[index + 1] : null;
|
||||
if (index >= 0 && !supplied) {
|
||||
throw new Error('--expected-source-revision requires a 40-character revision');
|
||||
}
|
||||
const revision = supplied ?? execFileSync(
|
||||
'git',
|
||||
['-C', root, 'rev-parse', 'HEAD'],
|
||||
{ encoding: 'utf8', windowsHide: true },
|
||||
).trim();
|
||||
if (!/^[0-9a-f]{40}$/.test(revision)) {
|
||||
throw new Error('expected source revision must be exactly 40 lowercase hexadecimal characters');
|
||||
}
|
||||
return revision;
|
||||
}
|
||||
|
||||
function readManifest(packageDir) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(packageDir, 'package.json'), 'utf8'));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function listPackageDirs(nodeModulesDir) {
|
||||
if (!fs.existsSync(nodeModulesDir)) return [];
|
||||
const packageDirs = [];
|
||||
const stack = [nodeModulesDir];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const full = path.join(current, entry.name);
|
||||
if (fs.existsSync(path.join(full, 'package.json'))) packageDirs.push(full);
|
||||
stack.push(full);
|
||||
}
|
||||
}
|
||||
return packageDirs;
|
||||
}
|
||||
|
||||
function isSupportedBetterSqliteVersion(version) {
|
||||
if (typeof version !== 'string') return false;
|
||||
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
|
||||
if (!match) return false;
|
||||
const [major, minor, patch] = match.slice(1).map(Number);
|
||||
if (![major, minor, patch].every(Number.isSafeInteger)) return false;
|
||||
return major === 12 && (minor > 6 || (minor === 6 && patch >= 2));
|
||||
}
|
||||
|
||||
function stagedDependencyVersionFailures(nodeModulesDir, packageManifests) {
|
||||
const manifests = packageManifests ?? listPackageDirs(nodeModulesDir)
|
||||
.map((packageDir) => [packageDir, readManifest(packageDir)]);
|
||||
const failures = [];
|
||||
|
||||
for (const [packageDir, manifest] of manifests) {
|
||||
const relative = path.relative(nodeModulesDir, packageDir).split(path.sep).join('/');
|
||||
const normalizedRelative = relative.toLowerCase();
|
||||
const isBetterSqlitePath = (
|
||||
normalizedRelative === 'better-sqlite3'
|
||||
|| normalizedRelative.endsWith('/node_modules/better-sqlite3')
|
||||
);
|
||||
const allowedVersions = STAGED_DEPENDENCY_VERSION_ALLOWLISTS.get(manifest.name);
|
||||
if (allowedVersions && !allowedVersions.has(manifest.version)) {
|
||||
failures.push(
|
||||
`node_modules/${relative} contains ${manifest.name}@${manifest.version}; `
|
||||
+ `allowed versions: ${[...allowedVersions].join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (isBetterSqlitePath && manifest.name !== 'better-sqlite3') {
|
||||
failures.push(
|
||||
`node_modules/${relative} must identify as better-sqlite3; `
|
||||
+ `found name ${JSON.stringify(manifest.name)}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
(isBetterSqlitePath || manifest.name === 'better-sqlite3')
|
||||
&& !isSupportedBetterSqliteVersion(manifest.version)
|
||||
) {
|
||||
failures.push(
|
||||
`node_modules/${relative} contains better-sqlite3@${manifest.version}; `
|
||||
+ `required version: ${REQUIRED_BETTER_SQLITE_RANGE}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
(
|
||||
manifest.name === 'sharp'
|
||||
|| (
|
||||
typeof manifest.name === 'string'
|
||||
&& /^@img\/sharp-(?!libvips-)/.test(manifest.name)
|
||||
)
|
||||
)
|
||||
&& manifest.version !== REQUIRED_SHARP_VERSION
|
||||
) {
|
||||
failures.push(
|
||||
`node_modules/${relative} contains ${manifest.name}@${manifest.version}; `
|
||||
+ `required version: ${REQUIRED_SHARP_VERSION}`,
|
||||
);
|
||||
}
|
||||
if (STAGED_DEPENDENCY_DENYLIST.has(manifest.name)) {
|
||||
failures.push(`node_modules/${relative} contains development-only ${manifest.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
const bundledBraceDir = path.join(
|
||||
nodeModulesDir,
|
||||
'waggle-node-runtime',
|
||||
'node_modules',
|
||||
'npm',
|
||||
'node_modules',
|
||||
'brace-expansion',
|
||||
);
|
||||
const bundledBraceVersion = readManifest(bundledBraceDir).version;
|
||||
if (bundledBraceVersion !== '2.1.4') {
|
||||
failures.push(
|
||||
`node_modules/waggle-node-runtime/node_modules/npm/node_modules/brace-expansion `
|
||||
+ `must be exactly 2.1.4; found ${bundledBraceVersion ?? 'missing'}`,
|
||||
);
|
||||
}
|
||||
|
||||
return failures;
|
||||
}
|
||||
|
||||
function localWorkspacePackageNames() {
|
||||
const names = new Set();
|
||||
for (const workspaceRoot of ['packages', 'apps'].map((entry) => path.join(root, entry))) {
|
||||
if (!fs.existsSync(workspaceRoot)) continue;
|
||||
for (const entry of fs.readdirSync(workspaceRoot, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue;
|
||||
const manifest = readManifest(path.join(workspaceRoot, entry.name));
|
||||
if (typeof manifest.name === 'string') names.add(manifest.name);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function collectRuntimeExportTargets(value, targets, condition = '') {
|
||||
if (condition === 'types') return;
|
||||
if (typeof value === 'string') {
|
||||
targets.add(value);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) collectRuntimeExportTargets(entry, targets, condition);
|
||||
return;
|
||||
}
|
||||
if (!value || typeof value !== 'object') return;
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
collectRuntimeExportTargets(entry, targets, key);
|
||||
}
|
||||
}
|
||||
|
||||
function firstPartyRuntimeTargets(manifest) {
|
||||
const targets = new Set();
|
||||
if (typeof manifest.main === 'string') targets.add(manifest.main);
|
||||
if (typeof manifest.module === 'string') targets.add(manifest.module);
|
||||
if (typeof manifest.bin === 'string') targets.add(manifest.bin);
|
||||
else if (manifest.bin && typeof manifest.bin === 'object') {
|
||||
for (const entry of Object.values(manifest.bin)) {
|
||||
if (typeof entry === 'string') targets.add(entry);
|
||||
}
|
||||
}
|
||||
collectRuntimeExportTargets(manifest.exports, targets);
|
||||
for (const entry of MANUAL_FIRST_PARTY_RUNTIME_TARGETS.get(manifest.name) || []) {
|
||||
targets.add(entry);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
function validateFirstPartyRuntimeTargets(packageDir, manifest) {
|
||||
const failures = [];
|
||||
const distDir = path.resolve(packageDir, 'dist');
|
||||
for (const target of firstPartyRuntimeTargets(manifest)) {
|
||||
const relative = target.replace(/^\.\//, '').split('/').join(path.sep);
|
||||
const resolved = path.resolve(packageDir, relative);
|
||||
const withinDist = resolved.startsWith(`${distDir}${path.sep}`);
|
||||
let regularRuntimeFile = false;
|
||||
let realDistWithinPackage = false;
|
||||
let realWithinDist = false;
|
||||
if (withinDist && fs.existsSync(resolved)) {
|
||||
const stat = fs.lstatSync(resolved);
|
||||
regularRuntimeFile = stat.isFile() && !stat.isSymbolicLink();
|
||||
if (regularRuntimeFile) {
|
||||
const realPackageDir = fs.realpathSync.native(packageDir);
|
||||
const realDistDir = fs.realpathSync.native(distDir);
|
||||
const realTarget = fs.realpathSync.native(resolved);
|
||||
realDistWithinPackage = realDistDir.startsWith(`${realPackageDir}${path.sep}`);
|
||||
realWithinDist = realTarget.startsWith(`${realDistDir}${path.sep}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!withinDist
|
||||
|| !realDistWithinPackage
|
||||
|| !realWithinDist
|
||||
|| !regularRuntimeFile
|
||||
|| target.includes('*')
|
||||
) {
|
||||
failures.push(target);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
const dependencyOnlyIndex = process.argv.indexOf('--dependency-versions-only');
|
||||
if (dependencyOnlyIndex >= 0) {
|
||||
const target = process.argv[dependencyOnlyIndex + 1];
|
||||
if (!target) {
|
||||
console.error('[check-sidecar-resources] --dependency-versions-only requires a directory');
|
||||
process.exit(1);
|
||||
}
|
||||
const failures = stagedDependencyVersionFailures(path.resolve(target));
|
||||
if (failures.length > 0) {
|
||||
for (const failure of failures) console.error(failure);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('[check-sidecar-resources] staged dependency versions are release-safe');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
let expectedRevision = null;
|
||||
try {
|
||||
expectedRevision = expectedSourceRevision();
|
||||
} catch (err) {
|
||||
unsafe.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
|
||||
const servicePath = path.join(resourcesDir, 'service.js');
|
||||
if (!fs.existsSync(servicePath)) {
|
||||
missing.push('resources/service.js (run: node scripts/build-sidecar.mjs)');
|
||||
} else {
|
||||
const serviceBuffer = fs.readFileSync(servicePath);
|
||||
const service = serviceBuffer.toString('utf8');
|
||||
if (/(?:\/\/|\/\*)[#@]\s*sourceMappingURL\s*=/.test(service)) {
|
||||
unsafe.push('resources/service.js contains a sourceMappingURL directive');
|
||||
}
|
||||
try {
|
||||
const provenance = parseSidecarProvenance(serviceBuffer);
|
||||
if (expectedRevision && provenance.sourceRevision !== expectedRevision) {
|
||||
throw new Error('resources/service.js source revision does not match expected revision');
|
||||
}
|
||||
for (const input of provenance.sourceInputs) {
|
||||
const absolute = path.join(root, ...input.path.split('/'));
|
||||
let sourceSafe = false;
|
||||
if (fs.existsSync(absolute)) {
|
||||
const stat = fs.lstatSync(absolute);
|
||||
const relative = path.relative(root, absolute);
|
||||
sourceSafe = stat.isFile()
|
||||
&& !stat.isSymbolicLink()
|
||||
&& relative !== ''
|
||||
&& !relative.startsWith(`..${path.sep}`)
|
||||
&& !path.isAbsolute(relative)
|
||||
&& sha256File(absolute) === input.sha256;
|
||||
}
|
||||
if (!sourceSafe) {
|
||||
throw new Error(
|
||||
`resources/service.js source input hash does not match current source: ${input.path}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
unsafe.push(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
const canonicalMarketplaceDb = path.join(root, ...marketplaceDbRelative.split('/'));
|
||||
const marketplaceResource = path.join(resourcesDir, 'marketplace.db');
|
||||
for (const suffix of ['-wal', '-shm', '-journal']) {
|
||||
if (fs.existsSync(`${canonicalMarketplaceDb}${suffix}`)) {
|
||||
unsafe.push(`packages/marketplace/marketplace.db${suffix} must not be present while staging`);
|
||||
}
|
||||
}
|
||||
for (const sidecar of listSqliteSidecars(resourcesDir)) {
|
||||
unsafe.push(`resources/${resourceRelative(sidecar)} must not be packaged`);
|
||||
}
|
||||
const canonicalMarketplaceIsRegular = fs.existsSync(canonicalMarketplaceDb)
|
||||
&& fs.lstatSync(canonicalMarketplaceDb).isFile()
|
||||
&& !fs.lstatSync(canonicalMarketplaceDb).isSymbolicLink();
|
||||
const marketplaceResourceIsRegular = fs.existsSync(marketplaceResource)
|
||||
&& fs.lstatSync(marketplaceResource).isFile()
|
||||
&& !fs.lstatSync(marketplaceResource).isSymbolicLink();
|
||||
if (!canonicalMarketplaceIsRegular) {
|
||||
missing.push('packages/marketplace/marketplace.db canonical build input');
|
||||
}
|
||||
let marketplaceGitBlob = null;
|
||||
if (expectedRevision) {
|
||||
try {
|
||||
marketplaceGitBlob = readGitBlob(expectedRevision, marketplaceDbRelative);
|
||||
} catch {
|
||||
unsafe.push('canonical marketplace database is missing from exact source revision');
|
||||
}
|
||||
}
|
||||
const canonicalMarketplaceBytes = canonicalMarketplaceIsRegular
|
||||
? fs.readFileSync(canonicalMarketplaceDb)
|
||||
: null;
|
||||
if (
|
||||
marketplaceGitBlob
|
||||
&& canonicalMarketplaceBytes
|
||||
&& !canonicalMarketplaceBytes.equals(marketplaceGitBlob)
|
||||
) {
|
||||
unsafe.push(
|
||||
'packages/marketplace/marketplace.db canonical marketplace database does not match exact source revision',
|
||||
);
|
||||
}
|
||||
if (!fs.existsSync(marketplaceResource)) {
|
||||
missing.push('resources/marketplace.db (run: node scripts/build-sidecar.mjs)');
|
||||
} else if (!marketplaceResourceIsRegular) {
|
||||
unsafe.push('resources/marketplace.db must be a regular file');
|
||||
} else {
|
||||
const marketplaceResourceBytes = fs.readFileSync(marketplaceResource);
|
||||
if (marketplaceGitBlob && !marketplaceResourceBytes.equals(marketplaceGitBlob)) {
|
||||
unsafe.push(
|
||||
'resources/marketplace.db does not match the canonical marketplace database at the exact source revision',
|
||||
);
|
||||
} else if (
|
||||
canonicalMarketplaceBytes
|
||||
&& !marketplaceResourceBytes.equals(canonicalMarketplaceBytes)
|
||||
) {
|
||||
unsafe.push('resources/marketplace.db does not match the canonical marketplace database');
|
||||
}
|
||||
}
|
||||
|
||||
const sourceArtifacts = fs.existsSync(resourcesDir)
|
||||
? fs.readdirSync(resourcesDir, { withFileTypes: true })
|
||||
.filter((entry) => /\.(?:map|tsx?)$/i.test(entry.name))
|
||||
.map((entry) => entry.name)
|
||||
: [];
|
||||
for (const artifact of sourceArtifacts) {
|
||||
unsafe.push(`resources/${artifact} must not be packaged`);
|
||||
}
|
||||
|
||||
const stagedPackageDirs = listPackageDirs(stagedDepsDir);
|
||||
const stagedPackageManifests = stagedPackageDirs
|
||||
.map((packageDir) => [packageDir, readManifest(packageDir)]);
|
||||
for (const failure of stagedDependencyVersionFailures(stagedDepsDir, stagedPackageManifests)) {
|
||||
unsafe.push(`resources/${failure}`);
|
||||
}
|
||||
const firstPartyRoot = path.join(stagedDepsDir, '@waggle');
|
||||
const firstPartyPackageDirs = new Map();
|
||||
if (fs.existsSync(firstPartyRoot)) {
|
||||
for (const packageEntry of fs.readdirSync(firstPartyRoot, { withFileTypes: true })) {
|
||||
if (!packageEntry.isDirectory()) continue;
|
||||
firstPartyPackageDirs.set(
|
||||
path.join(firstPartyRoot, packageEntry.name),
|
||||
`@waggle/${packageEntry.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const workspacePackageNames = localWorkspacePackageNames();
|
||||
for (const name of workspacePackageNames) {
|
||||
const directPackageDir = path.join(stagedDepsDir, ...name.split('/'));
|
||||
if (fs.existsSync(directPackageDir)) {
|
||||
firstPartyPackageDirs.set(directPackageDir, name);
|
||||
}
|
||||
}
|
||||
for (const [packageDir, manifest] of stagedPackageManifests) {
|
||||
const { name } = manifest;
|
||||
if (
|
||||
typeof name === 'string'
|
||||
&& (name.startsWith('@waggle/') || workspacePackageNames.has(name))
|
||||
) {
|
||||
firstPartyPackageDirs.set(packageDir, name);
|
||||
}
|
||||
}
|
||||
for (const [packageDir, expectedName] of firstPartyPackageDirs) {
|
||||
let manifest = {};
|
||||
const manifestPath = path.join(packageDir, 'package.json');
|
||||
try {
|
||||
const stat = fs.lstatSync(manifestPath);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
throw new Error('manifest must be a regular file');
|
||||
}
|
||||
const parsed = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
throw new Error('manifest must contain a JSON object');
|
||||
}
|
||||
if (parsed.name !== expectedName) {
|
||||
throw new Error(`manifest name ${JSON.stringify(parsed.name)} does not match ${expectedName}`);
|
||||
}
|
||||
manifest = parsed;
|
||||
} catch (err) {
|
||||
unsafe.push(
|
||||
`resources/${resourceRelative(manifestPath)} is missing or invalid: `
|
||||
+ (err instanceof Error ? err.message : String(err)),
|
||||
);
|
||||
}
|
||||
for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) {
|
||||
if (FIRST_PARTY_RUNTIME_ENTRY_PATTERN.test(entry.name)) continue;
|
||||
const relative = resourceRelative(path.join(packageDir, entry.name));
|
||||
unsafe.push(`resources/${relative} is not a runtime package entry`);
|
||||
}
|
||||
for (const file of listFiles(packageDir)) {
|
||||
if (SOURCE_ARTIFACT_PATTERN.test(file)) {
|
||||
unsafe.push(`resources/${resourceRelative(file)} must not be packaged`);
|
||||
}
|
||||
if (
|
||||
/\.(?:[cm]?js)$/i.test(file)
|
||||
&& /(?:\/\/|\/\*)[#@]\s*sourceMappingURL\s*=/.test(fs.readFileSync(file, 'utf8'))
|
||||
) {
|
||||
unsafe.push(`resources/${resourceRelative(file)} contains a sourceMappingURL directive`);
|
||||
}
|
||||
}
|
||||
for (const target of validateFirstPartyRuntimeTargets(packageDir, manifest)) {
|
||||
unsafe.push(
|
||||
`resources/${resourceRelative(packageDir)} has an invalid or missing runtime target: ${target}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nodeBinary = process.platform === 'win32' ? 'node.exe' : 'node';
|
||||
const nodePath = path.join(resourcesDir, nodeBinary);
|
||||
const npmCliPath = path.join(bundledNpmPackageDir, 'bin', 'npm-cli.js');
|
||||
const npxCliPath = path.join(bundledNpmPackageDir, 'bin', 'npx-cli.js');
|
||||
const npmWrapperPath = path.join(
|
||||
bundledNpmBinDir,
|
||||
process.platform === 'win32' ? 'npm.cmd' : 'npm',
|
||||
);
|
||||
const npxWrapperPath = path.join(
|
||||
bundledNpmBinDir,
|
||||
process.platform === 'win32' ? 'npx.cmd' : 'npx',
|
||||
);
|
||||
const bundledNpmFiles = [
|
||||
path.join(bundledNpmRuntimeDir, 'package.json'),
|
||||
path.join(bundledNpmRuntimeDir, 'NODE-LICENSE'),
|
||||
path.join(bundledNpmPackageDir, 'LICENSE'),
|
||||
npmCliPath,
|
||||
npxCliPath,
|
||||
npmWrapperPath,
|
||||
npxWrapperPath,
|
||||
];
|
||||
for (const file of bundledNpmFiles) {
|
||||
if (!fs.existsSync(file) || !fs.lstatSync(file).isFile()) {
|
||||
missing.push(`resources/${resourceRelative(file)} (run: node scripts/bundle-node.mjs)`);
|
||||
}
|
||||
}
|
||||
if (process.platform !== 'win32') {
|
||||
for (const wrapper of [npmWrapperPath, npxWrapperPath]) {
|
||||
if (fs.existsSync(wrapper) && (fs.statSync(wrapper).mode & 0o111) === 0) {
|
||||
unsafe.push(`resources/${resourceRelative(wrapper)} is not executable`);
|
||||
}
|
||||
}
|
||||
}
|
||||
let bundledNodeVersion = null;
|
||||
if (!fs.existsSync(nodePath)) {
|
||||
missing.push(`resources/${nodeBinary} (run: node scripts/bundle-node.mjs)`);
|
||||
} else {
|
||||
try {
|
||||
const bundledAbi = execFileSync(nodePath, ['-p', 'process.versions.modules'], {
|
||||
const bundledRuntime = JSON.parse(execFileSync(nodePath, [
|
||||
'-p',
|
||||
'JSON.stringify({ arch: process.arch, version: process.versions.node })',
|
||||
], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
const currentAbi = process.versions.modules;
|
||||
if (bundledAbi !== currentAbi) {
|
||||
}).trim());
|
||||
bundledNodeVersion = bundledRuntime.version;
|
||||
// The checker may run under a different Node major than the bundled runtime.
|
||||
// The native-module probe below is the authoritative ABI compatibility check.
|
||||
if (bundledRuntime.arch !== targetArch) {
|
||||
missing.push(
|
||||
`resources/${nodeBinary} ABI ${bundledAbi} does not match current Node ABI ${currentAbi} ` +
|
||||
'(run: node scripts/bundle-node.mjs with the same Node used for npm install/stage-sidecar-deps)',
|
||||
`resources/${nodeBinary} architecture ${bundledRuntime.arch} does not match target ${targetArch}`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -47,11 +655,97 @@ if (!fs.existsSync(nodePath)) {
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
fs.existsSync(nodePath)
|
||||
&& bundledNpmFiles.every((file) => fs.existsSync(file))
|
||||
) {
|
||||
try {
|
||||
const runtimeManifest = readManifest(bundledNpmRuntimeDir);
|
||||
const npmManifest = readManifest(bundledNpmPackageDir);
|
||||
if (runtimeManifest.name !== 'waggle-node-runtime') {
|
||||
throw new Error('runtime manifest has an unexpected name');
|
||||
}
|
||||
if (runtimeManifest.version !== bundledNodeVersion) {
|
||||
throw new Error(
|
||||
`runtime manifest Node ${runtimeManifest.version} does not match bundled Node ${bundledNodeVersion}`,
|
||||
);
|
||||
}
|
||||
if (typeof npmManifest.version !== 'string' || npmManifest.version.length === 0) {
|
||||
throw new Error('npm manifest has no version');
|
||||
}
|
||||
const runBundledCli = (cliPath) => execFileSync(nodePath, [cliPath, '--version'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
const runWrapper = (wrapperPath) => {
|
||||
if (process.platform === 'win32') {
|
||||
return execFileSync(process.env.ComSpec || 'cmd.exe', [
|
||||
'/d',
|
||||
'/s',
|
||||
'/c',
|
||||
`""${wrapperPath}" --version"`,
|
||||
], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsVerbatimArguments: true,
|
||||
}).trim();
|
||||
}
|
||||
return execFileSync(wrapperPath, ['--version'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
};
|
||||
const versions = [
|
||||
runBundledCli(npmCliPath),
|
||||
runBundledCli(npxCliPath),
|
||||
runWrapper(npmWrapperPath),
|
||||
runWrapper(npxWrapperPath),
|
||||
];
|
||||
if (versions.some((version) => version !== npmManifest.version)) {
|
||||
throw new Error(`npm/npx version mismatch: ${versions.join(', ')}`);
|
||||
}
|
||||
} catch (err) {
|
||||
missing.push(
|
||||
`resources bundled npm/npx runtime probe failed (${err instanceof Error ? err.message : String(err)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const nativeDir = path.join(resourcesDir, 'native');
|
||||
const nativeEntries = fs.existsSync(nativeDir)
|
||||
? fs.readdirSync(nativeDir).filter((e) => e !== '.gitkeep' && e !== 'onnxruntime')
|
||||
: [];
|
||||
if (nativeEntries.length === 0) {
|
||||
const requiredWindowsNativeFiles = [
|
||||
'better_sqlite3.node',
|
||||
'vec0.dll',
|
||||
'onnxruntime/onnxruntime_binding.node',
|
||||
];
|
||||
if (process.platform === 'win32') {
|
||||
for (const entry of requiredWindowsNativeFiles) {
|
||||
if (!fs.existsSync(path.join(nativeDir, ...entry.split('/')))) {
|
||||
missing.push(
|
||||
`resources/native/${entry} (run: node scripts/bundle-native-deps.mjs)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else if (process.platform === 'darwin') {
|
||||
const requiredMacNativeFiles = [
|
||||
'better_sqlite3.node',
|
||||
'vec0.dylib',
|
||||
'onnxruntime/onnxruntime_binding.node',
|
||||
];
|
||||
for (const entry of requiredMacNativeFiles) {
|
||||
if (!fs.existsSync(path.join(nativeDir, ...entry.split('/')))) {
|
||||
missing.push(`resources/native/${entry} (run: node scripts/bundle-native-deps.mjs)`);
|
||||
}
|
||||
}
|
||||
const onnxDir = path.join(nativeDir, 'onnxruntime');
|
||||
const hasOnnxLibrary = fs.existsSync(onnxDir)
|
||||
&& fs.readdirSync(onnxDir).some((entry) => entry.endsWith('.dylib'));
|
||||
if (!hasOnnxLibrary) {
|
||||
missing.push('resources/native/onnxruntime/*.dylib (run: node scripts/bundle-native-deps.mjs)');
|
||||
}
|
||||
} else if (nativeEntries.length === 0) {
|
||||
missing.push('resources/native/* (run: node scripts/bundle-native-deps.mjs)');
|
||||
}
|
||||
|
||||
@@ -62,10 +756,187 @@ if (nativeEntries.length === 0) {
|
||||
// npm scripts / CI (stage-sidecar-deps.mjs), NOT the arch-blind beforeBuildCommand
|
||||
// hook — so a raw `npx tauri build` that skips those would package a sidecar that
|
||||
// dies with MODULE_NOT_FOUND on first boot. Probe a canonical external.
|
||||
const stagedDepsDir = path.join(resourcesDir, 'node_modules');
|
||||
if (!fs.existsSync(path.join(stagedDepsDir, 'better-sqlite3', 'package.json'))) {
|
||||
const stagedBetterSqlite = path.join(stagedDepsDir, 'better-sqlite3');
|
||||
const stagedOnnxRuntime = path.join(stagedDepsDir, 'onnxruntime-node');
|
||||
const stagedTransformers = path.join(stagedDepsDir, '@huggingface', 'transformers');
|
||||
const stagedTransformersEntry = path.join(
|
||||
stagedTransformers,
|
||||
'dist',
|
||||
'transformers.node.cjs',
|
||||
);
|
||||
const stagedSharp = path.join(stagedDepsDir, 'sharp');
|
||||
const stagedSharpWindowsBinding = path.join(
|
||||
stagedDepsDir,
|
||||
'@img',
|
||||
'sharp-win32-x64',
|
||||
'lib',
|
||||
`sharp-win32-x64-${REQUIRED_SHARP_VERSION}.node`,
|
||||
);
|
||||
const vecExtension = path.join(
|
||||
nativeDir,
|
||||
`vec0.${process.platform === 'win32' ? 'dll' : process.platform === 'darwin' ? 'dylib' : 'so'}`,
|
||||
);
|
||||
if (!fs.existsSync(path.join(stagedBetterSqlite, 'package.json'))) {
|
||||
missing.push('resources/node_modules/* (run: node scripts/stage-sidecar-deps.mjs)');
|
||||
}
|
||||
if (!fs.existsSync(path.join(stagedOnnxRuntime, 'package.json'))) {
|
||||
missing.push('resources/node_modules/onnxruntime-node (run: node scripts/stage-sidecar-deps.mjs)');
|
||||
}
|
||||
if (!fs.existsSync(path.join(stagedTransformers, 'package.json'))) {
|
||||
missing.push(
|
||||
'resources/node_modules/@huggingface/transformers '
|
||||
+ '(run: node scripts/stage-sidecar-deps.mjs)',
|
||||
);
|
||||
}
|
||||
if (!fs.existsSync(stagedTransformersEntry)) {
|
||||
missing.push(
|
||||
'resources/node_modules/@huggingface/transformers/dist/transformers.node.cjs '
|
||||
+ '(run: node scripts/stage-sidecar-deps.mjs)',
|
||||
);
|
||||
}
|
||||
if (!fs.existsSync(path.join(stagedSharp, 'package.json'))) {
|
||||
missing.push('resources/node_modules/sharp (run: node scripts/stage-sidecar-deps.mjs)');
|
||||
}
|
||||
if (
|
||||
process.platform === 'win32'
|
||||
&& targetArch === 'x64'
|
||||
&& !fs.existsSync(stagedSharpWindowsBinding)
|
||||
) {
|
||||
missing.push(
|
||||
`resources/node_modules/@img/sharp-win32-x64/lib/`
|
||||
+ `sharp-win32-x64-${REQUIRED_SHARP_VERSION}.node `
|
||||
+ '(run: node scripts/stage-sidecar-deps.mjs)',
|
||||
);
|
||||
}
|
||||
if (
|
||||
fs.existsSync(nodePath)
|
||||
&& fs.existsSync(path.join(stagedBetterSqlite, 'package.json'))
|
||||
&& marketplaceResourceIsRegular
|
||||
) {
|
||||
let marketplaceProbeRoot;
|
||||
try {
|
||||
marketplaceProbeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-marketplace-probe-'));
|
||||
const marketplaceProbeDb = path.join(marketplaceProbeRoot, 'marketplace.db');
|
||||
fs.copyFileSync(marketplaceResource, marketplaceProbeDb);
|
||||
const marketplaceProbe = [
|
||||
'const Database = require(process.argv[1]);',
|
||||
'const database = new Database(process.argv[2], { readonly: true, fileMustExist: true });',
|
||||
'const integrity = database.pragma("integrity_check", { simple: true });',
|
||||
'if (integrity !== "ok") throw new Error(`integrity_check: ${integrity}`);',
|
||||
'const foreignKeys = database.pragma("foreign_key_check");',
|
||||
'if (foreignKeys.length !== 0) throw new Error("foreign_key_check failed");',
|
||||
'const tables = database.prepare("SELECT name FROM sqlite_master WHERE type = \'table\' AND name IN (\'sources\', \'packages\')").all();',
|
||||
'database.close();',
|
||||
'if (new Set(tables.map((row) => row.name)).size !== 2) throw new Error("required tables missing");',
|
||||
].join('');
|
||||
execFileSync(nodePath, [
|
||||
'-e',
|
||||
marketplaceProbe,
|
||||
stagedBetterSqlite,
|
||||
marketplaceProbeDb,
|
||||
], {
|
||||
cwd: marketplaceProbeRoot,
|
||||
env: { ...process.env, NODE_PATH: stagedDepsDir },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch {
|
||||
unsafe.push('resources/marketplace.db failed its SQLite integrity/schema probe');
|
||||
} finally {
|
||||
if (marketplaceProbeRoot) {
|
||||
fs.rmSync(marketplaceProbeRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
if (
|
||||
fs.existsSync(nodePath)
|
||||
&& fs.existsSync(path.join(stagedTransformers, 'package.json'))
|
||||
&& fs.existsSync(stagedTransformersEntry)
|
||||
&& fs.existsSync(path.join(stagedSharp, 'package.json'))
|
||||
&& (
|
||||
process.platform !== 'win32'
|
||||
|| targetArch !== 'x64'
|
||||
|| fs.existsSync(stagedSharpWindowsBinding)
|
||||
)
|
||||
) {
|
||||
try {
|
||||
const imageProbe = [
|
||||
'const { RawImage } = require(process.argv[1]);',
|
||||
'const sharp = require(process.argv[2]);',
|
||||
'if (process.argv[3]) require(process.argv[3]);',
|
||||
'if (sharp.versions?.emscripten) throw new Error("Sharp fell back to WASM");',
|
||||
'void (async () => {',
|
||||
'const image = new RawImage(',
|
||||
'Uint8Array.from([255,0,0,255,0,255,0,255,0,0,255,255,255,255,255,255]),',
|
||||
'2,2,4);',
|
||||
'const buffer = await image.toSharp().resize(1, 1).png().toBuffer();',
|
||||
'const signature = Buffer.from([137,80,78,71,13,10,26,10]);',
|
||||
'if (buffer.length < signature.length || !buffer.subarray(0, 8).equals(signature)) {',
|
||||
'throw new Error("Sharp PNG probe failed");',
|
||||
'}',
|
||||
'})().catch((error) => { console.error(error); process.exit(1); });',
|
||||
].join('');
|
||||
execFileSync(nodePath, [
|
||||
'-e',
|
||||
imageProbe,
|
||||
stagedTransformersEntry,
|
||||
stagedSharp,
|
||||
...(
|
||||
process.platform === 'win32' && targetArch === 'x64'
|
||||
? [stagedSharpWindowsBinding]
|
||||
: []
|
||||
),
|
||||
], {
|
||||
cwd: resourcesDir,
|
||||
env: { ...process.env, NODE_PATH: stagedDepsDir },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch (err) {
|
||||
missing.push(
|
||||
`resources image runtime probe failed for @huggingface/transformers and sharp `
|
||||
+ `using bundled ${nodeBinary} for ${targetArch} `
|
||||
+ `(${err instanceof Error ? err.message : String(err)})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
fs.existsSync(nodePath)
|
||||
&& fs.existsSync(path.join(stagedBetterSqlite, 'package.json'))
|
||||
&& fs.existsSync(path.join(stagedOnnxRuntime, 'package.json'))
|
||||
&& fs.existsSync(vecExtension)
|
||||
) {
|
||||
try {
|
||||
const probe = [
|
||||
'const Database = require(process.argv[1]);',
|
||||
'if (process.arch !== process.argv[2]) throw new Error(`architecture ${process.arch}`);',
|
||||
'const database = new Database(\':memory:\');',
|
||||
'database.loadExtension(process.argv[3]);',
|
||||
'const row = database.prepare(\'SELECT 1 AS ok\').get();',
|
||||
'const vec = database.prepare(\'SELECT vec_version() AS version\').get();',
|
||||
'database.close();',
|
||||
'if (row.ok !== 1) throw new Error(\'SQLite query failed\');',
|
||||
'if (typeof vec.version !== \'string\' || vec.version.length === 0) throw new Error(\'sqlite-vec query failed\');',
|
||||
'const onnx = require(process.argv[4]);',
|
||||
'if (typeof onnx.InferenceSession !== \'function\') throw new Error(\'ONNX binding failed\');',
|
||||
].join('');
|
||||
execFileSync(nodePath, [
|
||||
'-e',
|
||||
probe,
|
||||
stagedBetterSqlite,
|
||||
targetArch,
|
||||
vecExtension,
|
||||
stagedOnnxRuntime,
|
||||
], {
|
||||
cwd: resourcesDir,
|
||||
env: { ...process.env, NODE_PATH: stagedDepsDir },
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
} catch {
|
||||
missing.push(
|
||||
`resources native runtime probe failed for better-sqlite3, sqlite-vec, or onnxruntime-node `
|
||||
+ `using bundled ${nodeBinary} for ${targetArch}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// External agents and hook management run directly from this staged payload;
|
||||
// none of these packages are available from npm in a packaged installation.
|
||||
@@ -78,6 +949,8 @@ const hookRuntimeEntries = [
|
||||
'@waggle/hive-mind-hooks-cursor/dist/bin/cursor-hooks.js',
|
||||
'@waggle/hive-mind-hooks-hermes/dist/bin/hermes-hooks.js',
|
||||
'@waggle/hive-mind-hooks-openclaw/dist/bin/openclaw-hooks.js',
|
||||
'@waggle/hive-mind-hooks-openclaw/dist/handler.bundle.cjs',
|
||||
'waggle-memory-mcp/dist/index.js',
|
||||
];
|
||||
for (const entry of hookRuntimeEntries) {
|
||||
if (!fs.existsSync(path.join(stagedDepsDir, ...entry.split('/')))) {
|
||||
@@ -85,9 +958,10 @@ for (const entry of hookRuntimeEntries) {
|
||||
}
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error('[check-sidecar-resources] FATAL — sidecar runtime artifacts missing:');
|
||||
if (missing.length > 0 || unsafe.length > 0) {
|
||||
console.error('[check-sidecar-resources] FATAL — sidecar resources are not release-safe:');
|
||||
for (const m of missing) console.error(` - ${m}`);
|
||||
for (const item of unsafe) console.error(` - ${item}`);
|
||||
console.error(
|
||||
'[check-sidecar-resources] Stage them with the bundle scripts (set TARGET_ARCH for\n' +
|
||||
'cross-arch builds) or use the npm tauri:build* scripts / CI, which run them for you.',
|
||||
|
||||
Reference in New Issue
Block a user