moving
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
/** Build the npm-free hook + collaboration CLI payload staged into Tauri. */
|
||||
/** Build the npm-free hook, memory MCP, and collaboration CLI payload staged into Tauri. */
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
@@ -10,6 +10,10 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const tsc = path.join(root, 'node_modules', 'typescript', 'bin', 'tsc');
|
||||
|
||||
const projects = [
|
||||
'packages/shared/tsconfig.json',
|
||||
'packages/hive-mind-core/tsconfig.json',
|
||||
'packages/core/tsconfig.json',
|
||||
'packages/wiki-compiler/tsconfig.json',
|
||||
'packages/hive-mind-cli/tsconfig.json',
|
||||
'packages/hive-mind-hooks-claude-code/tsconfig.json',
|
||||
'packages/hive-mind-hooks-claude-desktop/tsconfig.json',
|
||||
@@ -18,6 +22,7 @@ const projects = [
|
||||
'packages/hive-mind-hooks-cursor/tsconfig.json',
|
||||
'packages/hive-mind-hooks-hermes/tsconfig.json',
|
||||
'packages/hive-mind-hooks-openclaw/tsconfig.json',
|
||||
'packages/memory-mcp/tsconfig.json',
|
||||
];
|
||||
|
||||
if (!fs.existsSync(tsc)) {
|
||||
@@ -25,7 +30,7 @@ if (!fs.existsSync(tsc)) {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log('[build-hook-runtime] Building CLI and seven hook adapters...');
|
||||
console.log('[build-hook-runtime] Building CLI, seven hook adapters, and memory MCP...');
|
||||
execFileSync(process.execPath, [tsc, '--build', ...projects], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
@@ -47,6 +52,7 @@ const expected = [
|
||||
'packages/hive-mind-hooks-hermes/dist/bin/hermes-hooks.js',
|
||||
'packages/hive-mind-hooks-openclaw/dist/bin/openclaw-hooks.js',
|
||||
'packages/hive-mind-hooks-openclaw/dist/handler.bundle.cjs',
|
||||
'packages/memory-mcp/dist/index.js',
|
||||
];
|
||||
const missing = expected.filter((entry) => !fs.existsSync(path.join(root, entry)));
|
||||
if (missing.length > 0) {
|
||||
|
||||
@@ -10,15 +10,89 @@
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const root = path.resolve(__dirname, '..');
|
||||
const resourcesDir = path.join(root, 'app', 'src-tauri', 'resources');
|
||||
const outFile = path.join(resourcesDir, 'service.js');
|
||||
const sourceMapFile = `${outFile}.map`;
|
||||
const entryPoint = path.join(root, 'packages', 'server', 'src', 'local', 'service.ts');
|
||||
const marketplaceDbRelative = 'packages/marketplace/marketplace.db';
|
||||
const marketplaceDb = path.join(root, 'packages', 'marketplace', 'marketplace.db');
|
||||
const marketplaceResource = path.join(resourcesDir, 'marketplace.db');
|
||||
const provenancePrefix = '// Waggle-Sidecar-Provenance: ';
|
||||
const buildScriptRelative = 'scripts/build-sidecar.mjs';
|
||||
const entryPointRelative = 'packages/server/src/local/service.ts';
|
||||
|
||||
function sha256(value) {
|
||||
return createHash('sha256').update(value).digest('hex');
|
||||
}
|
||||
|
||||
function readGitBlob(revision, relative) {
|
||||
return execFileSync(
|
||||
'git',
|
||||
['-C', root, 'cat-file', 'blob', `${revision}:${relative}`],
|
||||
{ maxBuffer: 64 * 1024 * 1024, windowsHide: true },
|
||||
);
|
||||
}
|
||||
|
||||
function repositoryRelative(absolutePath) {
|
||||
const relative = path.relative(root, absolutePath);
|
||||
if (!relative || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) {
|
||||
throw new Error(`Sidecar input is outside the repository: ${absolutePath}`);
|
||||
}
|
||||
return relative.split(path.sep).join('/');
|
||||
}
|
||||
|
||||
function addImplicitBuildInputs(localInputs, inputRelative) {
|
||||
let current = path.dirname(path.join(root, ...inputRelative.split('/')));
|
||||
while (true) {
|
||||
for (const name of ['package.json', 'tsconfig.json']) {
|
||||
const candidate = path.join(current, name);
|
||||
if (fs.existsSync(candidate)) localInputs.add(repositoryRelative(candidate));
|
||||
}
|
||||
if (current === root) break;
|
||||
const parent = path.dirname(current);
|
||||
if (parent === current) break;
|
||||
current = parent;
|
||||
}
|
||||
}
|
||||
|
||||
function addExtendedTsconfigs(localInputs) {
|
||||
const visited = new Set();
|
||||
const pending = [...localInputs].filter((relative) => path.basename(relative) === 'tsconfig.json');
|
||||
while (pending.length > 0) {
|
||||
const relative = pending.pop();
|
||||
if (visited.has(relative)) continue;
|
||||
visited.add(relative);
|
||||
const absolute = path.join(root, ...relative.split('/'));
|
||||
const parsed = ts.parseConfigFileTextToJson(absolute, fs.readFileSync(absolute, 'utf8'));
|
||||
if (parsed.error) throw new Error(`Could not parse sidecar tsconfig: ${relative}`);
|
||||
const rawExtends = parsed.config?.extends;
|
||||
const extendedConfigs = Array.isArray(rawExtends)
|
||||
? rawExtends
|
||||
: typeof rawExtends === 'string'
|
||||
? [rawExtends]
|
||||
: [];
|
||||
for (const extendedConfig of extendedConfigs) {
|
||||
if (typeof extendedConfig !== 'string' || !extendedConfig.startsWith('.')) continue;
|
||||
let extended = path.resolve(path.dirname(absolute), extendedConfig);
|
||||
if (!fs.existsSync(extended) && fs.existsSync(`${extended}.json`)) extended += '.json';
|
||||
const extendedRelative = repositoryRelative(extended);
|
||||
if (!fs.existsSync(extended)) {
|
||||
throw new Error(`Extended sidecar tsconfig is missing: ${extendedRelative}`);
|
||||
}
|
||||
localInputs.add(extendedRelative);
|
||||
pending.push(extendedRelative);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Metafile goes to a temp path (NOT resources/) so it's neither bundled into
|
||||
// the app nor left as an untracked repo artifact. stage-sidecar-deps.mjs reads
|
||||
// it back from the same well-known path. Keep the two in sync.
|
||||
@@ -26,6 +100,10 @@ const metaFile = path.join(os.tmpdir(), 'waggle-sidecar-meta.json');
|
||||
|
||||
// Ensure resources directory exists
|
||||
fs.mkdirSync(resourcesDir, { recursive: true });
|
||||
// Production resources are shipped verbatim by Tauri. Remove maps left by an
|
||||
// older build before bundling so full TypeScript sources cannot ride along in
|
||||
// an installer even when the resources directory is reused.
|
||||
fs.rmSync(sourceMapFile, { force: true });
|
||||
|
||||
console.log('[build-sidecar] Bundling server into', outFile);
|
||||
|
||||
@@ -57,30 +135,58 @@ const EXTERNAL = [
|
||||
];
|
||||
|
||||
try {
|
||||
if (
|
||||
!fs.existsSync(marketplaceDb)
|
||||
|| !fs.lstatSync(marketplaceDb).isFile()
|
||||
|| fs.lstatSync(marketplaceDb).isSymbolicLink()
|
||||
) {
|
||||
throw new Error(`Required marketplace database is missing or unsafe: ${marketplaceDb}`);
|
||||
}
|
||||
const sourceRevision = execFileSync(
|
||||
'git',
|
||||
['-C', root, 'rev-parse', 'HEAD'],
|
||||
{ encoding: 'utf8', windowsHide: true },
|
||||
).trim().toLowerCase();
|
||||
if (!/^[0-9a-f]{40}$/.test(sourceRevision)) {
|
||||
throw new Error(`Could not resolve an exact source revision: ${sourceRevision}`);
|
||||
}
|
||||
const marketplaceGitBlob = readGitBlob(sourceRevision, marketplaceDbRelative);
|
||||
if (!fs.readFileSync(marketplaceDb).equals(marketplaceGitBlob)) {
|
||||
throw new Error('canonical marketplace database does not match exact source revision');
|
||||
}
|
||||
|
||||
// Dynamic import esbuild (available via vite dependency)
|
||||
const esbuild = await import('esbuild');
|
||||
|
||||
const result = await esbuild.build({
|
||||
entryPoints: [entryPoint],
|
||||
absWorkingDir: root,
|
||||
bundle: true,
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
format: 'esm',
|
||||
outfile: outFile,
|
||||
external: EXTERNAL,
|
||||
// P4/D12: @waggle/shared and @waggle/hive-mind-core export ONLY their
|
||||
// gitignored dist/ (unlike core/agent/server, which export src/*.ts).
|
||||
// P4/D12: workspace package metadata can resolve to gitignored dist/.
|
||||
// Alias all bundled first-party packages to their tracked source entry.
|
||||
// Without these aliases the bundle silently embeds whatever dist/ was
|
||||
// last compiled — the same stale-server-in-the-binary class D12 exists
|
||||
// to kill — and a clean checkout can't build at all without
|
||||
// build:packages. Alias to source so the bundle ALWAYS compiles from
|
||||
// src, like the vitest aliases do. (No subpath imports of either
|
||||
// package exist — verified before aliasing the bare names.)
|
||||
// src, like the vitest aliases do. Exact subpath aliases must precede
|
||||
// their package root so esbuild never appends a subpath to index.ts.
|
||||
alias: {
|
||||
'@waggle/agent/external-process-env': path.join(root, 'packages', 'agent', 'src', 'external-process-env.ts'),
|
||||
'@waggle/agent': path.join(root, 'packages', 'agent', 'src', 'index.ts'),
|
||||
'@waggle/core': path.join(root, 'packages', 'core', 'src', 'index.ts'),
|
||||
'@waggle/marketplace': path.join(root, 'packages', 'marketplace', 'src', 'index.ts'),
|
||||
'@waggle/shared': path.join(root, 'packages', 'shared', 'src', 'index.ts'),
|
||||
'@waggle/hive-mind-core': path.join(root, 'packages', 'hive-mind-core', 'src', 'index.ts'),
|
||||
'@waggle/waggle-dance': path.join(root, 'packages', 'waggle-dance', 'src', 'index.ts'),
|
||||
'@waggle/weaver': path.join(root, 'packages', 'weaver', 'src', 'index.ts'),
|
||||
'@waggle/wiki-compiler': path.join(root, 'packages', 'wiki-compiler', 'src', 'index.ts'),
|
||||
},
|
||||
sourcemap: true,
|
||||
sourcemap: false,
|
||||
minify: true,
|
||||
// metafile lets stage-sidecar-deps.mjs enumerate exactly which of the
|
||||
// EXTERNAL packages the bundle actually `require()`s at runtime, so it
|
||||
@@ -108,16 +214,82 @@ try {
|
||||
fs.writeFileSync(metaFile, JSON.stringify(result.metafile));
|
||||
console.log('[build-sidecar] Wrote esbuild metafile', metaFile);
|
||||
|
||||
const trackedFiles = new Set(
|
||||
execFileSync(
|
||||
'git',
|
||||
['-C', root, 'ls-files', '-z'],
|
||||
{ encoding: 'utf8', windowsHide: true },
|
||||
).split('\0').filter(Boolean),
|
||||
);
|
||||
const localInputs = new Set([buildScriptRelative, 'package.json', 'package-lock.json']);
|
||||
for (const input of Object.keys(result.metafile.inputs)) {
|
||||
const relative = repositoryRelative(path.resolve(root, input));
|
||||
if (relative === 'node_modules' || relative.startsWith('node_modules/')) continue;
|
||||
localInputs.add(relative);
|
||||
}
|
||||
for (const relative of [...localInputs]) {
|
||||
addImplicitBuildInputs(localInputs, relative);
|
||||
}
|
||||
addExtendedTsconfigs(localInputs);
|
||||
|
||||
const sourceInputs = [...localInputs]
|
||||
.sort((left, right) => left < right ? -1 : left > right ? 1 : 0)
|
||||
.map((relative) => {
|
||||
if (!trackedFiles.has(relative)) {
|
||||
throw new Error(`Sidecar repository input is not tracked: ${relative}`);
|
||||
}
|
||||
const absolute = path.join(root, ...relative.split('/'));
|
||||
const stat = fs.lstatSync(absolute);
|
||||
if (!stat.isFile() || stat.isSymbolicLink()) {
|
||||
throw new Error(`Sidecar repository input is not a regular file: ${relative}`);
|
||||
}
|
||||
return {
|
||||
path: relative,
|
||||
sha256: sha256(fs.readFileSync(absolute)),
|
||||
};
|
||||
});
|
||||
const bundlePayload = fs.readFileSync(outFile);
|
||||
const provenance = {
|
||||
schemaVersion: 1,
|
||||
sourceRevision,
|
||||
entryPoint: entryPointRelative,
|
||||
sourceInputs,
|
||||
bundlePayload: {
|
||||
sizeBytes: bundlePayload.byteLength,
|
||||
sha256: sha256(bundlePayload),
|
||||
},
|
||||
};
|
||||
const provenanceLine = Buffer.from(
|
||||
`${provenancePrefix}${Buffer.from(JSON.stringify(provenance)).toString('base64')}\n`,
|
||||
'utf8',
|
||||
);
|
||||
const provenanceTemp = `${outFile}.provenance-${process.pid}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(provenanceTemp, Buffer.concat([provenanceLine, bundlePayload]));
|
||||
fs.rmSync(outFile, { force: true });
|
||||
fs.renameSync(provenanceTemp, outFile);
|
||||
} finally {
|
||||
fs.rmSync(provenanceTemp, { force: true });
|
||||
}
|
||||
console.log(
|
||||
`[build-sidecar] Embedded ${sourceInputs.length} source inputs at ${sourceRevision.slice(0, 12)}`,
|
||||
);
|
||||
|
||||
const stat = fs.statSync(outFile);
|
||||
const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
|
||||
console.log(`[build-sidecar] Done. Output: ${sizeMB} MB`);
|
||||
|
||||
// Copy marketplace seed database if it exists
|
||||
const marketplaceDb = path.join(root, 'packages', 'marketplace', 'seed', 'marketplace.db');
|
||||
if (fs.existsSync(marketplaceDb)) {
|
||||
fs.copyFileSync(marketplaceDb, path.join(resourcesDir, 'marketplace.db'));
|
||||
console.log('[build-sidecar] Copied marketplace.db seed');
|
||||
// Production startup seeds the user's writable DB from this immutable
|
||||
// packaged resource. Publish the exact Git blob through a temporary file so
|
||||
// a failed write can never leave a partial database in the bundle.
|
||||
const marketplaceTemp = `${marketplaceResource}.stage-${process.pid}.tmp`;
|
||||
try {
|
||||
fs.writeFileSync(marketplaceTemp, marketplaceGitBlob, { flag: 'wx' });
|
||||
fs.renameSync(marketplaceTemp, marketplaceResource);
|
||||
} finally {
|
||||
fs.rmSync(marketplaceTemp, { force: true });
|
||||
}
|
||||
console.log('[build-sidecar] Copied canonical marketplace.db');
|
||||
} catch (err) {
|
||||
console.error('[build-sidecar] Build failed:', err.message);
|
||||
process.exit(1);
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -32,25 +33,48 @@ if (arch === 'universal') {
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (platform === 'darwin' && arch !== 'arm64' && arch !== 'x64') {
|
||||
throw new Error(`[bundle-native-deps] FATAL — unsupported macOS target architecture: ${arch}`);
|
||||
}
|
||||
|
||||
console.log(`[bundle-native-deps] Platform: ${platform}-${arch}`);
|
||||
|
||||
// Ensure output directory
|
||||
// A previous matrix leg must never satisfy this build with stale native files.
|
||||
fs.rmSync(nativeDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(nativeDir, { recursive: true });
|
||||
fs.mkdirSync(path.join(nativeDir, 'onnxruntime'), { recursive: true });
|
||||
fs.writeFileSync(path.join(nativeDir, '.gitkeep'), '');
|
||||
fs.writeFileSync(path.join(nativeDir, 'onnxruntime', '.gitkeep'), '');
|
||||
|
||||
let totalFiles = 0;
|
||||
let totalBytes = 0;
|
||||
|
||||
function assertDarwinArchitecture(srcPath) {
|
||||
if (platform !== 'darwin' || !/\.(?:dylib|node)$/i.test(srcPath)) return;
|
||||
const expected = arch === 'x64' ? 'x86_64' : 'arm64';
|
||||
const architectures = execFileSync('/usr/bin/lipo', ['-archs', srcPath], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim().split(/\s+/);
|
||||
if (!architectures.includes(expected)) {
|
||||
throw new Error(
|
||||
`[bundle-native-deps] FATAL — ${path.relative(root, srcPath)} has Mach-O architecture `
|
||||
+ `${architectures.join(', ') || 'unknown'}, expected ${expected}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function copyFile(src, destName) {
|
||||
const srcPath = path.join(root, src);
|
||||
const destPath = path.join(nativeDir, destName);
|
||||
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
console.warn(`[bundle-native-deps] WARNING: ${src} not found — skipping`);
|
||||
return false;
|
||||
throw new Error(`[bundle-native-deps] FATAL — required native dependency ${src} is missing`);
|
||||
}
|
||||
|
||||
const sourceSize = fs.statSync(srcPath).size;
|
||||
if (sourceSize === 0) throw new Error(`[bundle-native-deps] FATAL — required native dependency ${src} is empty`);
|
||||
assertDarwinArchitecture(srcPath);
|
||||
fs.copyFileSync(srcPath, destPath);
|
||||
const size = fs.statSync(destPath).size;
|
||||
totalBytes += size;
|
||||
@@ -62,24 +86,28 @@ function copyFile(src, destName) {
|
||||
function copyDir(srcDir, destSubDir) {
|
||||
const srcPath = path.join(root, srcDir);
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
console.warn(`[bundle-native-deps] WARNING: ${srcDir} not found — skipping`);
|
||||
return;
|
||||
throw new Error(`[bundle-native-deps] FATAL — required native dependency directory ${srcDir} is missing`);
|
||||
}
|
||||
|
||||
const destPath = path.join(nativeDir, destSubDir);
|
||||
fs.mkdirSync(destPath, { recursive: true });
|
||||
|
||||
let copied = 0;
|
||||
for (const file of fs.readdirSync(srcPath)) {
|
||||
const fullSrc = path.join(srcPath, file);
|
||||
const stat = fs.statSync(fullSrc);
|
||||
if (stat.isFile()) {
|
||||
if (stat.size === 0) throw new Error(`[bundle-native-deps] FATAL — required native dependency ${fullSrc} is empty`);
|
||||
assertDarwinArchitecture(fullSrc);
|
||||
const destFile = path.join(destPath, file);
|
||||
fs.copyFileSync(fullSrc, destFile);
|
||||
totalBytes += stat.size;
|
||||
totalFiles++;
|
||||
copied++;
|
||||
console.log(` ${destSubDir}/${file} (${(stat.size / 1024 / 1024).toFixed(1)} MB)`);
|
||||
}
|
||||
}
|
||||
if (copied === 0) throw new Error(`[bundle-native-deps] FATAL — required native dependency directory ${srcDir} is empty`);
|
||||
}
|
||||
|
||||
// 1. better-sqlite3
|
||||
@@ -89,10 +117,9 @@ copyFile('node_modules/better-sqlite3/build/Release/better_sqlite3.node', 'bette
|
||||
// 2. sqlite-vec
|
||||
console.log('[bundle-native-deps] sqlite-vec:');
|
||||
const vecOs = platform === 'win32' ? 'windows' : platform === 'darwin' ? 'darwin' : 'linux';
|
||||
const vecArch = arch === 'arm64' ? 'aarch64' : 'x64';
|
||||
const vecExt = platform === 'win32' ? 'dll' : platform === 'darwin' ? 'dylib' : 'so';
|
||||
copyFile(
|
||||
`node_modules/sqlite-vec-${vecOs}-${vecArch}/vec0.${vecExt}`,
|
||||
`node_modules/sqlite-vec-${vecOs}-${arch}/vec0.${vecExt}`,
|
||||
`vec0.${vecExt}`,
|
||||
);
|
||||
|
||||
|
||||
@@ -1,130 +1,387 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Downloads the correct Node.js binary for the current platform and places it
|
||||
* in app/src-tauri/resources/ for Tauri bundling.
|
||||
* Download and verify the official Node.js distribution used by the desktop
|
||||
* sidecar. The full archive is required because it is the authoritative source
|
||||
* for the matching Node binary, Node license, and bundled npm runtime.
|
||||
*
|
||||
* Defaults to the Node version running this script so copied native modules
|
||||
* from node_modules match the bundled runtime ABI. Set
|
||||
* WAGGLE_BUNDLED_NODE_VERSION to pin a different version intentionally.
|
||||
*
|
||||
* Uses Node.js official distribution (https://nodejs.org/dist/).
|
||||
* Caches in scripts/.cache/ to avoid re-downloading.
|
||||
* Desktop packaging pins one exact supported Node.js release. Release and PR
|
||||
* workflows install dependencies with the same version, and the extracted
|
||||
* runtime proves it can load the installed native SQLite binding before any
|
||||
* packaged runtime resource is replaced.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
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 cacheDir = path.join(__dirname, '.cache');
|
||||
const stagedRuntimeDir = path.join(
|
||||
resourcesDir,
|
||||
'node_modules',
|
||||
'waggle-node-runtime',
|
||||
);
|
||||
const SAFE_NPM_BRACE_EXPANSION_VERSION = '2.1.4';
|
||||
const safeNpmBraceExpansionSource = path.join(
|
||||
root,
|
||||
'node_modules',
|
||||
'archiver-utils',
|
||||
'node_modules',
|
||||
'brace-expansion',
|
||||
);
|
||||
const SAFE_NPM_IP_ADDRESS_VERSION = '10.4.0';
|
||||
const safeNpmIpAddressSource = path.join(root, 'node_modules', 'ip-address');
|
||||
|
||||
const NODE_VERSION = process.env.WAGGLE_BUNDLED_NODE_VERSION ?? process.versions.node;
|
||||
const DESKTOP_NODE_VERSION = '22.23.2';
|
||||
const NODE_VERSION = DESKTOP_NODE_VERSION;
|
||||
if (!/^\d+\.\d+\.\d+$/.test(NODE_VERSION)) {
|
||||
console.error(`[bundle-node] FATAL — invalid Node.js version: ${NODE_VERSION}`);
|
||||
console.error(`[bundle-node] FATAL - invalid Node.js version: ${NODE_VERSION}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const platform = process.platform;
|
||||
const arch = process.env.TARGET_ARCH || process.arch;
|
||||
|
||||
// macOS "universal" is not a valid download target: nodejs.org ships per-arch
|
||||
// binaries (node-vX-darwin-arm64 / -x64), and a universal run would fall
|
||||
// through to the x64 tarball and ship an x64-only node in an arm64 bundle.
|
||||
// Build each arch separately and lipo the app bundle instead.
|
||||
if (arch === 'universal') {
|
||||
console.error(
|
||||
'[bundle-node] FATAL — TARGET_ARCH=universal is not supported.\n'
|
||||
+ ' Node.js ships per-arch binaries. Build each arch separately:\n'
|
||||
+ ' TARGET_ARCH=arm64 (aarch64-apple-darwin) and TARGET_ARCH=x64\n'
|
||||
+ ' (x86_64-apple-darwin) — see release.yml\'s macOS matrix and the app\n'
|
||||
+ ' tauri:build:mac:arm64 / :x64 scripts.',
|
||||
'[bundle-node] FATAL - TARGET_ARCH=universal is not supported.\n'
|
||||
+ ' Node.js ships per-arch binaries. Build arm64 and x64 separately.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!['x64', 'arm64'].includes(arch)) {
|
||||
console.error(`[bundle-node] FATAL - unsupported target architecture: ${arch}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const archivePlatform = platform === 'win32'
|
||||
? 'win'
|
||||
: platform === 'darwin'
|
||||
? 'darwin'
|
||||
: platform === 'linux'
|
||||
? 'linux'
|
||||
: null;
|
||||
if (!archivePlatform) {
|
||||
console.error(`[bundle-node] FATAL - unsupported platform: ${platform}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const archiveExtension = platform === 'win32' ? 'zip' : 'tar.gz';
|
||||
const distributionName = `node-v${NODE_VERSION}-${archivePlatform}-${arch}`;
|
||||
const archiveName = `${distributionName}.${archiveExtension}`;
|
||||
const distributionUrl = `https://nodejs.org/dist/v${NODE_VERSION}`;
|
||||
const archivePath = path.join(cacheDir, archiveName);
|
||||
const shasumsPath = path.join(cacheDir, `node-v${NODE_VERSION}-SHASUMS256.txt`);
|
||||
const extractDir = path.join(cacheDir, `${distributionName}-verified`);
|
||||
const extractedRoot = path.join(extractDir, distributionName);
|
||||
const nodeSource = platform === 'win32'
|
||||
? path.join(extractedRoot, 'node.exe')
|
||||
: path.join(extractedRoot, 'bin', 'node');
|
||||
const npmSource = path.join(
|
||||
extractedRoot,
|
||||
...(platform === 'win32'
|
||||
? ['node_modules', 'npm']
|
||||
: ['lib', 'node_modules', 'npm']),
|
||||
);
|
||||
const nodeLicenseSource = path.join(extractedRoot, 'LICENSE');
|
||||
const destBinary = path.join(resourcesDir, platform === 'win32' ? 'node.exe' : 'node');
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[bundle-node] FATAL - ${message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function assertNativeRuntimeCompatible() {
|
||||
const betterSqlitePath = path.join(root, 'node_modules', 'better-sqlite3');
|
||||
const probe = `
|
||||
const path = require('node:path');
|
||||
const Database = require(path.join(process.argv[1], 'node_modules', 'better-sqlite3'));
|
||||
const database = new Database(':memory:');
|
||||
const row = database.prepare('SELECT 1 AS ok').get();
|
||||
database.close();
|
||||
if (row?.ok !== 1) throw new Error('SQLite query probe returned an invalid result');
|
||||
process.stdout.write(JSON.stringify({
|
||||
version: process.versions.node,
|
||||
abi: process.versions.modules,
|
||||
arch: process.arch,
|
||||
}));
|
||||
`;
|
||||
|
||||
try {
|
||||
const output = execFileSync(nodeSource, ['-e', probe, root], {
|
||||
cwd: root,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
const runtime = JSON.parse(output);
|
||||
if (runtime.version !== NODE_VERSION || runtime.arch !== arch) {
|
||||
fail(
|
||||
`native ABI compatibility probe used Node.js v${runtime.version ?? 'unknown'} `
|
||||
+ `(${runtime.arch ?? 'unknown'}, ABI ${runtime.abi ?? 'unknown'}); expected `
|
||||
+ `v${NODE_VERSION} (${arch})`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`[bundle-node] Native ABI probe passed with Node.js v${runtime.version} `
|
||||
+ `(${runtime.arch}, ABI ${runtime.abi})`,
|
||||
);
|
||||
} catch (error) {
|
||||
const detail = String(error?.stderr ?? error?.message ?? error).trim().slice(0, 4_000);
|
||||
fail(
|
||||
`native ABI compatibility probe failed for Node.js v${NODE_VERSION} (${arch}) `
|
||||
+ `against ${betterSqlitePath}. Reinstall dependencies with Node.js `
|
||||
+ `v${NODE_VERSION} (npm ci) before packaging. ${detail}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function download(url, destination) {
|
||||
console.log(`[bundle-node] Downloading ${url}`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) fail(`download failed: HTTP ${response.status} (${url})`);
|
||||
const temporary = `${destination}.${process.pid}.tmp`;
|
||||
fs.writeFileSync(temporary, Buffer.from(await response.arrayBuffer()));
|
||||
fs.renameSync(temporary, destination);
|
||||
}
|
||||
|
||||
function sha256(file) {
|
||||
return createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
||||
}
|
||||
|
||||
function expectedArchiveHash(shasums) {
|
||||
for (const line of shasums.split(/\r?\n/)) {
|
||||
const match = /^([a-f0-9]{64})\s+\*?(.+)$/.exec(line.trim());
|
||||
if (match?.[2] === archiveName) return match[1];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractedRuntimeComplete() {
|
||||
return [
|
||||
nodeSource,
|
||||
nodeLicenseSource,
|
||||
path.join(npmSource, 'LICENSE'),
|
||||
path.join(npmSource, 'bin', 'npm-cli.js'),
|
||||
path.join(npmSource, 'bin', 'npx-cli.js'),
|
||||
].every((file) => fs.existsSync(file) && fs.lstatSync(file).isFile());
|
||||
}
|
||||
|
||||
function directorySizeBytes(dir) {
|
||||
let bytes = 0;
|
||||
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()) bytes += fs.statSync(full).size;
|
||||
}
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function writeWrappers() {
|
||||
const binDir = path.join(stagedRuntimeDir, 'bin');
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
if (platform === 'win32') {
|
||||
const wrapper = (cli) => [
|
||||
'@ECHO OFF',
|
||||
'SETLOCAL',
|
||||
'SET "NODE_EXE=%~dp0\\..\\..\\..\\node.exe"',
|
||||
`SET "NPM_CLI_JS=%~dp0\\..\\node_modules\\npm\\bin\\${cli}-cli.js"`,
|
||||
'"%NODE_EXE%" "%NPM_CLI_JS%" %*',
|
||||
'EXIT /B %ERRORLEVEL%',
|
||||
'',
|
||||
].join('\r\n');
|
||||
fs.writeFileSync(path.join(binDir, 'npm.cmd'), wrapper('npm'), 'utf8');
|
||||
fs.writeFileSync(path.join(binDir, 'npx.cmd'), wrapper('npx'), 'utf8');
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = (cli) => [
|
||||
'#!/bin/sh',
|
||||
'SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)',
|
||||
`exec "$SCRIPT_DIR/../../../node" "$SCRIPT_DIR/../node_modules/npm/bin/${cli}-cli.js" "$@"`,
|
||||
'',
|
||||
].join('\n');
|
||||
for (const cli of ['npm', 'npx']) {
|
||||
const wrapperPath = path.join(binDir, cli);
|
||||
fs.writeFileSync(wrapperPath, wrapper(cli), 'utf8');
|
||||
fs.chmodSync(wrapperPath, 0o755);
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(cacheDir, { recursive: true });
|
||||
fs.mkdirSync(resourcesDir, { recursive: true });
|
||||
|
||||
const destBinary = platform === 'win32'
|
||||
? path.join(resourcesDir, 'node.exe')
|
||||
: path.join(resourcesDir, 'node');
|
||||
|
||||
// Check cache
|
||||
const cacheKey = `node-v${NODE_VERSION}-${platform}-${arch}`;
|
||||
const cachedBinary = path.join(cacheDir, platform === 'win32' ? `${cacheKey}.exe` : cacheKey);
|
||||
|
||||
if (fs.existsSync(cachedBinary)) {
|
||||
console.log(`[bundle-node] Using cached Node.js v${NODE_VERSION} (${platform}-${arch})`);
|
||||
fs.copyFileSync(cachedBinary, destBinary);
|
||||
if (platform !== 'win32') {
|
||||
fs.chmodSync(destBinary, 0o755);
|
||||
async function loadExpectedHash(refresh = false) {
|
||||
if (refresh) fs.rmSync(shasumsPath, { force: true });
|
||||
if (!fs.existsSync(shasumsPath)) {
|
||||
await download(`${distributionUrl}/SHASUMS256.txt`, shasumsPath);
|
||||
}
|
||||
const size = (fs.statSync(destBinary).size / 1024 / 1024).toFixed(1);
|
||||
console.log(`[bundle-node] → ${destBinary} (${size} MB)`);
|
||||
process.exit(0);
|
||||
const expected = expectedArchiveHash(fs.readFileSync(shasumsPath, 'utf8'));
|
||||
if (expected) return expected;
|
||||
if (!refresh) return loadExpectedHash(true);
|
||||
fail(`official SHASUMS256.txt has no entry for ${archiveName}`);
|
||||
}
|
||||
|
||||
// Download
|
||||
if (platform === 'win32') {
|
||||
// Windows: direct .exe download
|
||||
const url = `https://nodejs.org/dist/v${NODE_VERSION}/win-${arch}/node.exe`;
|
||||
console.log(`[bundle-node] Downloading Node.js v${NODE_VERSION} (${platform}-${arch})...`);
|
||||
console.log(` ${url}`);
|
||||
let expectedHash = await loadExpectedHash();
|
||||
let refreshedShasums = false;
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
console.error(`[bundle-node] Download failed: HTTP ${response.status}`);
|
||||
process.exit(1);
|
||||
if (fs.existsSync(archivePath) && sha256(archivePath) !== expectedHash) {
|
||||
expectedHash = await loadExpectedHash(true);
|
||||
refreshedShasums = true;
|
||||
if (sha256(archivePath) !== expectedHash) {
|
||||
console.warn(`[bundle-node] Discarding checksum-mismatched cache: ${archiveName}`);
|
||||
fs.rmSync(archivePath, { force: true });
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
fs.writeFileSync(cachedBinary, buffer);
|
||||
fs.copyFileSync(cachedBinary, destBinary);
|
||||
|
||||
} else {
|
||||
// macOS/Linux: download .tar.gz and extract bin/node
|
||||
const nodeArch = arch === 'arm64' ? 'arm64' : 'x64';
|
||||
const osPart = platform === 'darwin' ? 'darwin' : 'linux';
|
||||
const archiveName = `node-v${NODE_VERSION}-${osPart}-${nodeArch}.tar.gz`;
|
||||
const url = `https://nodejs.org/dist/v${NODE_VERSION}/${archiveName}`;
|
||||
|
||||
console.log(`[bundle-node] Downloading Node.js v${NODE_VERSION} (${osPart}-${nodeArch})...`);
|
||||
console.log(` ${url}`);
|
||||
|
||||
const archivePath = path.join(cacheDir, archiveName);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
console.error(`[bundle-node] Download failed: HTTP ${response.status}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
fs.writeFileSync(archivePath, buffer);
|
||||
|
||||
// Extract bin/node from the tarball. The archive root is the versioned dir
|
||||
// (node-vX-os-arch/), so the member must include that prefix; --strip-
|
||||
// components=1 then drops it so the file lands at <extractDir>/bin/node.
|
||||
// BSD tar (macOS) matches members against the FULL archived path, so a bare
|
||||
// "bin/node" matches nothing → "tar: bin/node: Not found in archive".
|
||||
// (safe — no user input in args)
|
||||
const extractDir = path.join(cacheDir, 'extract');
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
const member = `node-v${NODE_VERSION}-${osPart}-${nodeArch}/bin/node`;
|
||||
execFileSync('tar', ['xzf', archivePath, '-C', extractDir, '--strip-components=1', member], {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
|
||||
const extractedNode = path.join(extractDir, 'bin', 'node');
|
||||
fs.copyFileSync(extractedNode, cachedBinary);
|
||||
fs.copyFileSync(cachedBinary, destBinary);
|
||||
fs.chmodSync(destBinary, 0o755);
|
||||
|
||||
// Cleanup extracted files
|
||||
}
|
||||
if (!fs.existsSync(archivePath)) {
|
||||
await download(`${distributionUrl}/${archiveName}`, archivePath);
|
||||
}
|
||||
let actualHash = sha256(archivePath);
|
||||
if (actualHash !== expectedHash && !refreshedShasums) {
|
||||
expectedHash = await loadExpectedHash(true);
|
||||
refreshedShasums = true;
|
||||
actualHash = sha256(archivePath);
|
||||
}
|
||||
if (actualHash !== expectedHash) {
|
||||
fs.rmSync(archivePath, { force: true });
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
fail(`SHA-256 mismatch for ${archiveName}: expected ${expectedHash}, got ${actualHash}`);
|
||||
}
|
||||
console.log(`[bundle-node] Verified ${archiveName} against official SHASUMS256.txt`);
|
||||
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
execFileSync('tar', ['-xf', archivePath, '-C', extractDir], { stdio: 'inherit' });
|
||||
if (!extractedRuntimeComplete()) {
|
||||
fail(`verified archive is missing Node, npm, or required license files: ${archiveName}`);
|
||||
}
|
||||
|
||||
const size = (fs.statSync(destBinary).size / 1024 / 1024).toFixed(1);
|
||||
console.log(`[bundle-node] Node.js v${NODE_VERSION} (${platform}-${arch}) → ${destBinary} (${size} MB)`);
|
||||
assertNativeRuntimeCompatible();
|
||||
|
||||
fs.copyFileSync(nodeSource, destBinary);
|
||||
if (platform !== 'win32') fs.chmodSync(destBinary, 0o755);
|
||||
|
||||
fs.rmSync(stagedRuntimeDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.join(stagedRuntimeDir, 'node_modules'), { recursive: true });
|
||||
fs.copyFileSync(nodeLicenseSource, path.join(stagedRuntimeDir, 'NODE-LICENSE'));
|
||||
fs.cpSync(npmSource, path.join(stagedRuntimeDir, 'node_modules', 'npm'), {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
});
|
||||
const safeNpmBraceExpansionManifest = path.join(
|
||||
safeNpmBraceExpansionSource,
|
||||
'package.json',
|
||||
);
|
||||
if (!fs.existsSync(safeNpmBraceExpansionManifest)) {
|
||||
fail('lock-installed brace-expansion hardening source is missing');
|
||||
}
|
||||
const safeNpmBraceExpansion = JSON.parse(
|
||||
fs.readFileSync(safeNpmBraceExpansionManifest, 'utf8'),
|
||||
);
|
||||
if (safeNpmBraceExpansion.version !== SAFE_NPM_BRACE_EXPANSION_VERSION) {
|
||||
fail(
|
||||
`lock-installed brace-expansion is ${safeNpmBraceExpansion.version}; `
|
||||
+ `expected ${SAFE_NPM_BRACE_EXPANSION_VERSION}`,
|
||||
);
|
||||
}
|
||||
const stagedNpmBraceExpansion = path.join(
|
||||
stagedRuntimeDir,
|
||||
'node_modules',
|
||||
'npm',
|
||||
'node_modules',
|
||||
'brace-expansion',
|
||||
);
|
||||
fs.rmSync(stagedNpmBraceExpansion, { recursive: true, force: true });
|
||||
fs.cpSync(safeNpmBraceExpansionSource, stagedNpmBraceExpansion, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
});
|
||||
const stagedNpmBraceExpansionVersion = JSON.parse(
|
||||
fs.readFileSync(path.join(stagedNpmBraceExpansion, 'package.json'), 'utf8'),
|
||||
).version;
|
||||
if (stagedNpmBraceExpansionVersion !== SAFE_NPM_BRACE_EXPANSION_VERSION) {
|
||||
fail(
|
||||
`staged npm brace-expansion is ${stagedNpmBraceExpansionVersion}; `
|
||||
+ `expected ${SAFE_NPM_BRACE_EXPANSION_VERSION}`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`[bundle-node] Hardened bundled npm with brace-expansion `
|
||||
+ `${SAFE_NPM_BRACE_EXPANSION_VERSION}`,
|
||||
);
|
||||
const safeNpmIpAddressManifest = path.join(safeNpmIpAddressSource, 'package.json');
|
||||
if (!fs.existsSync(safeNpmIpAddressManifest)) {
|
||||
fail('lock-installed ip-address hardening source is missing');
|
||||
}
|
||||
const safeNpmIpAddress = JSON.parse(
|
||||
fs.readFileSync(safeNpmIpAddressManifest, 'utf8'),
|
||||
);
|
||||
if (safeNpmIpAddress.version !== SAFE_NPM_IP_ADDRESS_VERSION) {
|
||||
fail(
|
||||
`lock-installed ip-address is ${safeNpmIpAddress.version}; `
|
||||
+ `expected ${SAFE_NPM_IP_ADDRESS_VERSION}`,
|
||||
);
|
||||
}
|
||||
const stagedNpmIpAddress = path.join(
|
||||
stagedRuntimeDir,
|
||||
'node_modules',
|
||||
'npm',
|
||||
'node_modules',
|
||||
'ip-address',
|
||||
);
|
||||
fs.rmSync(stagedNpmIpAddress, { recursive: true, force: true });
|
||||
fs.cpSync(safeNpmIpAddressSource, stagedNpmIpAddress, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
});
|
||||
const stagedNpmIpAddressVersion = JSON.parse(
|
||||
fs.readFileSync(path.join(stagedNpmIpAddress, 'package.json'), 'utf8'),
|
||||
).version;
|
||||
if (stagedNpmIpAddressVersion !== SAFE_NPM_IP_ADDRESS_VERSION) {
|
||||
fail(
|
||||
`staged npm ip-address is ${stagedNpmIpAddressVersion}; `
|
||||
+ `expected ${SAFE_NPM_IP_ADDRESS_VERSION}`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`[bundle-node] Hardened bundled npm with ip-address ${SAFE_NPM_IP_ADDRESS_VERSION}`,
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(stagedRuntimeDir, 'package.json'),
|
||||
`${JSON.stringify({
|
||||
name: 'waggle-node-runtime',
|
||||
private: true,
|
||||
version: NODE_VERSION,
|
||||
description: 'Verified Node.js npm runtime staged for the Waggle desktop sidecar',
|
||||
}, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
writeWrappers();
|
||||
|
||||
const npmManifest = JSON.parse(
|
||||
fs.readFileSync(path.join(stagedRuntimeDir, 'node_modules', 'npm', 'package.json'), 'utf8'),
|
||||
);
|
||||
for (const cli of ['npm', 'npx']) {
|
||||
const cliPath = path.join(stagedRuntimeDir, 'node_modules', 'npm', 'bin', `${cli}-cli.js`);
|
||||
const version = execFileSync(destBinary, [cliPath, '--version'], {
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
}).trim();
|
||||
if (version !== npmManifest.version) {
|
||||
fail(`${cli} preflight returned ${version}; expected npm ${npmManifest.version}`);
|
||||
}
|
||||
}
|
||||
|
||||
const nodeSize = (fs.statSync(destBinary).size / 1024 / 1024).toFixed(1);
|
||||
const npmSize = (directorySizeBytes(stagedRuntimeDir) / 1024 / 1024).toFixed(1);
|
||||
console.log(
|
||||
`[bundle-node] Node.js v${NODE_VERSION} + npm v${npmManifest.version} `
|
||||
+ `(${platform}-${arch}) -> resources (${nodeSize} MB node, ${npmSize} MB npm runtime)`,
|
||||
);
|
||||
|
||||
3422
scripts/certify-windows-installer.ps1
Normal file
3422
scripts/certify-windows-installer.ps1
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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.',
|
||||
|
||||
309
scripts/oss-drift-baseline.json
Normal file
309
scripts/oss-drift-baseline.json
Normal file
@@ -0,0 +1,309 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"mapping": {
|
||||
"canonical": "packages/hive-mind-core/src",
|
||||
"oss": "packages/core/src",
|
||||
"ignoredDirectories": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
],
|
||||
"ignoredFileSuffixes": [
|
||||
".test.ts",
|
||||
".tsbuildinfo"
|
||||
]
|
||||
},
|
||||
"parityPaths": [
|
||||
{
|
||||
"path": "harvest/claude-code-adapter.ts",
|
||||
"sha256": "13d4c7965b35c6cc66f53445ed752121cbba1b8b8ab80d196f3357977acc8d03"
|
||||
},
|
||||
{
|
||||
"path": "harvest/decision-derivation.ts",
|
||||
"sha256": "97e026befe980c4f754db5639fd96b715f09c351316f87a41e69899839d55f52"
|
||||
},
|
||||
{
|
||||
"path": "harvest/stable-id.ts",
|
||||
"sha256": "b1d8568bf3799f5054b6f2fc4119db4c8f67a0a49b22ff0b818a95c17a2eba56"
|
||||
},
|
||||
{
|
||||
"path": "mind/erasure.ts",
|
||||
"sha256": "6d5958c1b8e73a774324e1a4ca0348160b4abf3ff338d3cd794ef024d5ec5c5a"
|
||||
},
|
||||
{
|
||||
"path": "mind/inprocess-reranker.ts",
|
||||
"sha256": "0ed4a70147e0f7a17fdd14c3cc1127979bd653e3786a1a70189a5ab0996ec9f9"
|
||||
}
|
||||
],
|
||||
"intentionalAdaptations": [
|
||||
{
|
||||
"path": "harvest/chatgpt-adapter.ts",
|
||||
"kinds": ["branding", "import", "logger"],
|
||||
"canonicalSha256": "24603b8ecb18f795c5cbbbe483113dbb24f08fab6b1bce2fb19ee7127f8957d0",
|
||||
"ossSha256": "97b4aec1356a98f9ba72ed373954934a657db4579cc67906b7fa9ee52a458c1a"
|
||||
},
|
||||
{
|
||||
"path": "harvest/chunk-utils.ts",
|
||||
"kinds": ["import", "layout"],
|
||||
"canonicalSha256": "868f8ede1f2201d214a012d9cc9c7157a9418205d8bb2eff8ed872d4658f6c6e",
|
||||
"ossSha256": "88d2bf92cae38cc975baff5eb75c5cab89cb6bda2262ec7c381635589b10ea18"
|
||||
},
|
||||
{
|
||||
"path": "harvest/claude-adapter.ts",
|
||||
"kinds": ["branding", "import", "logger"],
|
||||
"canonicalSha256": "3a22d18cf1ef2b12fa8689bd0909c2b34abfae3a9918c5ef8ca9a8cbfba8a522",
|
||||
"ossSha256": "6dad506184e24ba98969fcb0a5885468167fd98da80fd29363ca131a7c7a46e1"
|
||||
},
|
||||
{
|
||||
"path": "harvest/extract-memory-lanes.ts",
|
||||
"kinds": ["import"],
|
||||
"canonicalSha256": "800daac77c81e9daa3ecc0a8efa1f7203f6f41b64ad931e66cb97bc899d5d46d",
|
||||
"ossSha256": "58e36562dfebd95cc1c2243746fcb2e755184fc7e2a5a05f334da2b6200855c1"
|
||||
},
|
||||
{
|
||||
"path": "harvest/gemini-adapter.ts",
|
||||
"kinds": ["branding", "import", "logger"],
|
||||
"canonicalSha256": "e68a34a62653f5387ae204eaa92b8bc861fc11d5b449db1eaa3f08573b95680c",
|
||||
"ossSha256": "c4346791f439c34514b93dd9eef905bda72b255665b97fc14d60469338c32dda"
|
||||
},
|
||||
{
|
||||
"path": "harvest/index.ts",
|
||||
"kinds": ["import", "layout"],
|
||||
"canonicalSha256": "9f7bfdc50c031866071ec998b5eca65ec6ded832fb9ff07bbb654e2ea5b68820",
|
||||
"ossSha256": "fda20a50ba0247808c245397d65e2858b50924cffc6c140668854c70496e580c"
|
||||
},
|
||||
{
|
||||
"path": "harvest/markdown-adapter.ts",
|
||||
"kinds": ["branding", "import", "logger"],
|
||||
"canonicalSha256": "651c7cb76c9634896d69978f3537b74ca7f7fdd14aefe36e1c769aafbcab4e95",
|
||||
"ossSha256": "77f495fcdbca87e90acf791d8c3c0e18021b2fec4dee5c5a827bd2b465b63b9d"
|
||||
},
|
||||
{
|
||||
"path": "harvest/pdf-adapter.ts",
|
||||
"kinds": ["branding", "import", "logger"],
|
||||
"canonicalSha256": "b6be7a2017d96e92eb20f9bffa949a8c433633b9ed9d4e741db089bb9588d07f",
|
||||
"ossSha256": "2896d0425781152eafbd5bc7a3076ca95f587e896ab0f9dea69afe99f6baf094"
|
||||
},
|
||||
{
|
||||
"path": "harvest/perplexity-adapter.ts",
|
||||
"kinds": ["branding", "import", "logger"],
|
||||
"canonicalSha256": "95b54dac195949f2b53c21f908f1f0fc04564b55eac892ce757e81d53f0793cd",
|
||||
"ossSha256": "20d7366d9848916994ac9f834ee3cd03700fde671c632ba5564a7122c8195454"
|
||||
},
|
||||
{
|
||||
"path": "harvest/plaintext-adapter.ts",
|
||||
"kinds": ["branding", "import", "logger"],
|
||||
"canonicalSha256": "14fa80a29642a448391a529a5a8dd0b33460c09e0223b984bb293ec73ede10d0",
|
||||
"ossSha256": "1e24105088181c46302718b886fe9a4400ce05a9e8a64345c7b6bc78b222bcd1"
|
||||
},
|
||||
{
|
||||
"path": "harvest/prompts.ts",
|
||||
"kinds": ["branding"],
|
||||
"canonicalSha256": "c672d702e335af33c6674f4b7b2c3536541fe1f5482b49660d4412d99620baca",
|
||||
"ossSha256": "2730b9cfdfc5fcf3940d7a43b86f3ebce0e53e4ed1127fd665c137434e9e83ac"
|
||||
},
|
||||
{
|
||||
"path": "harvest/raw-types.ts",
|
||||
"kinds": ["import", "layout"],
|
||||
"canonicalSha256": "2a10f0374d91c4f6e3f87c0fd77fc9ade24a669f7d08f2b283c653ca114284c1",
|
||||
"ossSha256": "1bf0a4c45090466589a8b49694fb182deb6ab7e8442b61eb65cb3387bd62f0aa"
|
||||
},
|
||||
{
|
||||
"path": "harvest/run-store.ts",
|
||||
"kinds": ["import", "logger"],
|
||||
"canonicalSha256": "3ea55f2884ad8cfa40aaeeb7398f5a962dbe29436396696ba9776038fe5c8ab5",
|
||||
"ossSha256": "956c976f0113c26f1b5c6e9826d96b72203b3b6468f926d6af768342d5cebadc"
|
||||
},
|
||||
{
|
||||
"path": "harvest/source-store.ts",
|
||||
"kinds": ["import", "logger"],
|
||||
"canonicalSha256": "5f82788d632dfb85c243313e074a280bd7a49564ec525c9ad90496d12d1fb54d",
|
||||
"ossSha256": "97d12f2c48564c748c394d09343a160c2e526a36830562139fb0cc579d020cc0"
|
||||
},
|
||||
{
|
||||
"path": "harvest/types.ts",
|
||||
"kinds": ["import", "layout"],
|
||||
"canonicalSha256": "16f4757b7049617425e753ba4c14eb54c63ef113e4aaa23990ee6cf2188053ad",
|
||||
"ossSha256": "2eda81e6277c61a8920f60ce1815785de5521f27915f8704577c771b1135a0b7"
|
||||
},
|
||||
{
|
||||
"path": "harvest/universal-adapter.ts",
|
||||
"kinds": ["branding", "import", "logger"],
|
||||
"canonicalSha256": "a39368bc35d242ec6693b0c4964bac0dcdff80b9f659dccbbd5d18e99a894b32",
|
||||
"ossSha256": "a9dfefd30d367f6d5618baeb1687a2b93074104d907719cae426f9fb1162de2b"
|
||||
},
|
||||
{
|
||||
"path": "harvest/url-egress-guard.ts",
|
||||
"kinds": ["branding", "import", "logger"],
|
||||
"canonicalSha256": "4940b28d7ea06e460ad97a522214fac27a803e691b4fbfe110bf97e8dfd98ce5",
|
||||
"ossSha256": "eacc5e69ee71b2000e7bf26a8ac2c9e64f15db884978789e24d708aeacc74668"
|
||||
},
|
||||
{
|
||||
"path": "injection-scanner.ts",
|
||||
"kinds": ["layout"],
|
||||
"canonicalSha256": "a1b5db9afc71873262df1db9ae89d0821c4122e1f38d982ef048f3080db51adf",
|
||||
"ossSha256": "beede3f82c66106367a2ae58f3bcfdec4884c5b9fb2b792ea08b88cd7284d9d1"
|
||||
},
|
||||
{
|
||||
"path": "logger.ts",
|
||||
"kinds": ["branding", "logger"],
|
||||
"canonicalSha256": "cca0f01bd7bfaeea7166b4d51ad677320c241f68024885bc688f5d31307b12fe",
|
||||
"ossSha256": "0ce6746ba197b80a067a10388764568bf97477766e924266ee4a48ca6d01a888"
|
||||
},
|
||||
{
|
||||
"path": "memory-ingress-guard.ts",
|
||||
"kinds": ["import"],
|
||||
"canonicalSha256": "5c795775b580d20343471067d060192e5065787f35602713563512b2fde06007",
|
||||
"ossSha256": "a00b66a3814aea50d6926b55996af883f06ee561760d463085847669183b98de"
|
||||
},
|
||||
{
|
||||
"path": "mind/api-embedder.ts",
|
||||
"kinds": ["branding", "import", "logger"],
|
||||
"canonicalSha256": "16ad89d908dc3810e793d838c6ab8448394a6f7145dde374cd20980b72eb216d",
|
||||
"ossSha256": "9ab9c766e138cf19a9a78a9e8a6f6b42e9f62048371b07a5123e4c0388f79c27"
|
||||
},
|
||||
{
|
||||
"path": "mind/awareness.ts",
|
||||
"kinds": ["import", "logger"],
|
||||
"canonicalSha256": "bdc30923a6c753a7749850c9ab436dda111a816d18028c4274521ad08484cf36",
|
||||
"ossSha256": "13fb221475e76fea2fe6ece872a3e34eb3c7bbe3c67d6d2215304878a9ac74b2"
|
||||
},
|
||||
{
|
||||
"path": "mind/chunker.ts",
|
||||
"kinds": ["import"],
|
||||
"canonicalSha256": "f6121b2b1db5a1b5dcce39a909692dc96a997a95331cad1b6d2f3cc49d3d4bbd",
|
||||
"ossSha256": "e5166ce85e1c93fa155e1e9bda636ecc50b56fa41b3f059186520f71fb8302f3"
|
||||
},
|
||||
{
|
||||
"path": "mind/concept-tracker.ts",
|
||||
"kinds": ["import", "logger"],
|
||||
"canonicalSha256": "779ab17aad0d3751ea40d8eb558a63a7e36031b3fa97cd29fe211d2551965c51",
|
||||
"ossSha256": "0aba6c53edd4e7357081e1722e08005f328ecbaa8e69add9efb8bf7c76d055a5"
|
||||
},
|
||||
{
|
||||
"path": "mind/content-hash.ts",
|
||||
"kinds": ["import"],
|
||||
"canonicalSha256": "354a9648d480dea048dcd78ca784a56efcc417c7574e1fbea779d3df95da074f",
|
||||
"ossSha256": "e1d34ca9b3cd1ce0351509ab04fdb4a6d96b78f96005b2ca23229b5bcee40bfc"
|
||||
},
|
||||
{
|
||||
"path": "mind/embeddings.ts",
|
||||
"kinds": ["import", "logger"],
|
||||
"canonicalSha256": "74ce421fc82964ca8928d40be6cfb91370445692b49758601b3a5057d586a218",
|
||||
"ossSha256": "827a9ec88ef33c7ff5838b9b9ac00fae0d12e0c16d0d2061415e6cbf9520fae1"
|
||||
},
|
||||
{
|
||||
"path": "mind/entity-normalizer.ts",
|
||||
"kinds": ["import"],
|
||||
"canonicalSha256": "936ecb94d368fa1f010a047c3205f09331cfdd77111493af37909b0ae1cce1a2",
|
||||
"ossSha256": "ea4ef0ecb8879eca6241a48a92c0e209fd00a64ef0325ee12ef480195021c695"
|
||||
},
|
||||
{
|
||||
"path": "mind/fts-sanitize.ts",
|
||||
"kinds": ["import"],
|
||||
"canonicalSha256": "4affefd57348f58ef9a0de915e43548c9b13ebb3976b74b60b281798eedaae4e",
|
||||
"ossSha256": "cb24324005ae69d35af69325c545555a2e79d0d71ed5695e7e71a12963ff4b48"
|
||||
},
|
||||
{
|
||||
"path": "mind/litellm-embedder.ts",
|
||||
"kinds": ["branding", "import", "logger"],
|
||||
"canonicalSha256": "87e1da22f2e0047d2f9b754f5faf353c04654a12392fe1c2dca2ced12a84d712",
|
||||
"ossSha256": "4f2d43f1a17961e8f94bff3a06b3008cd3b69a0807e03232fb96fe386308c1a4"
|
||||
},
|
||||
{
|
||||
"path": "mind/ollama-embedder.ts",
|
||||
"kinds": ["branding", "import", "logger"],
|
||||
"canonicalSha256": "9fef2d801ab2c6918d0c352fa3b1fcc77f1a1c77070cd166f785d13ccfd4fe30",
|
||||
"ossSha256": "1f2f543ed5d8f97a841bae27dc436f11f21d3dbd55473f303f5ef8f20bb4341a"
|
||||
},
|
||||
{
|
||||
"path": "mind/ontology.ts",
|
||||
"kinds": ["import"],
|
||||
"canonicalSha256": "2c301a0ed4b5aecb470e33e8eba34f03ab985a9d4431e6a5ab5cbc36e3b222a0",
|
||||
"ossSha256": "859ead772389e5854ca1c5681b36ef2f551e3d2bf8f8d9247823eaee68fe8666"
|
||||
},
|
||||
{
|
||||
"path": "mind/parse-date-window.ts",
|
||||
"kinds": ["import"],
|
||||
"canonicalSha256": "4ba482d24958a4188f0e3ba0871f8d8199dc37b2999aa42010bac0631fe89365",
|
||||
"ossSha256": "de0a8e7f59072031d4ff916f8b10c7451ee8607e0351ddf385e687b13633b252"
|
||||
},
|
||||
{
|
||||
"path": "mind/recall-context.ts",
|
||||
"kinds": ["import", "logger"],
|
||||
"canonicalSha256": "56f291422e5f4d22c5d0eb5b5dc3a65bf814146963677d63e6378f070da5bcf3",
|
||||
"ossSha256": "30529798ae7669d16a24765689d4f4b24287b8fdb0fa94a87adc19acc99fe5a8"
|
||||
},
|
||||
{
|
||||
"path": "mind/reconcile.ts",
|
||||
"kinds": ["import", "logger"],
|
||||
"canonicalSha256": "3defdff6d3d5c831297c54143e525cf2f9539e9726a66f5288c1ea288c54e384",
|
||||
"ossSha256": "6fd2b97b9a18d791dd89c71bd7c155be0719982cd7ee2110ebf1644cea7e5b87"
|
||||
},
|
||||
{
|
||||
"path": "mind/resolve-relative-date.ts",
|
||||
"kinds": ["import"],
|
||||
"canonicalSha256": "bc2d8ec1baa95b12b4a374e6dce851e7290042ebcb27d22389098f0f4a624adc",
|
||||
"ossSha256": "6afaee5857dc533b0a6730d6acd6fd4530291322db5e19c6bd9782cc5aad40f5"
|
||||
},
|
||||
{
|
||||
"path": "mind/scoring.ts",
|
||||
"kinds": ["import"],
|
||||
"canonicalSha256": "8bcc3225c3aba5a9ef98d6db10206b0761c4e06ac94e3644f4881f8b80c4a017",
|
||||
"ossSha256": "6660a2ba157b6e84561222b24ade3bcd68202e722b4a3ce6d9d716f9eba13f98"
|
||||
},
|
||||
{
|
||||
"path": "mind/sessions.ts",
|
||||
"kinds": ["import", "logger"],
|
||||
"canonicalSha256": "4fc4ad9be81016310ce89ef142ed5cd7d7a99224750fb7a664ad2fd7ffe514ab",
|
||||
"ossSha256": "94d56a29840bb317b5391bca8782c6901b88dbf7aafff6bac3f051242e0d8343"
|
||||
},
|
||||
{
|
||||
"path": "mind/transformers-model-load.ts",
|
||||
"kinds": ["branding", "import", "logger"],
|
||||
"canonicalSha256": "43c13897de7ea8eb5ce27d89f9e5a50e58afb6aa47b266efbc2b0517d8920e92",
|
||||
"ossSha256": "3df62eed18a28d0863ae667ea7d79d687e8809114250bd17dfd337972f3d113d"
|
||||
}
|
||||
],
|
||||
"knownReviewedBlockers": [
|
||||
{ "path": "harvest/dedup.ts", "state": "different", "disposition": "reconcile" },
|
||||
{ "path": "harvest/extract-kg-entities.ts", "state": "only-canonical", "disposition": "reconcile" },
|
||||
{ "path": "harvest/pipeline.ts", "state": "different", "disposition": "reconcile" },
|
||||
{ "path": "harvest/url-adapter.ts", "state": "different", "disposition": "reconcile" },
|
||||
{ "path": "hook-runtime.ts", "state": "only-canonical", "disposition": "product-curation" },
|
||||
{ "path": "index.ts", "state": "different", "disposition": "reconcile" },
|
||||
{ "path": "mind/db.ts", "state": "different", "disposition": "reconcile" },
|
||||
{ "path": "mind/embedding-provider.ts", "state": "different", "disposition": "reconcile" },
|
||||
{ "path": "mind/frames.ts", "state": "different", "disposition": "reconcile" },
|
||||
{ "path": "mind/identity.ts", "state": "different", "disposition": "reconcile" },
|
||||
{ "path": "mind/inprocess-embedder.ts", "state": "different", "disposition": "reconcile" },
|
||||
{ "path": "mind/knowledge.ts", "state": "different", "disposition": "reconcile" },
|
||||
{ "path": "mind/llm-extractor.ts", "state": "only-oss", "disposition": "reconcile" },
|
||||
{ "path": "mind/raw-archive.ts", "state": "different", "disposition": "reconcile" },
|
||||
{ "path": "mind/raw-detail-lane.ts", "state": "different", "disposition": "reconcile" },
|
||||
{ "path": "mind/schema.ts", "state": "different", "disposition": "reconcile" },
|
||||
{ "path": "mind/search.ts", "state": "different", "disposition": "reconcile" },
|
||||
{ "path": "mind/supersede.ts", "state": "only-canonical", "disposition": "product-curation" },
|
||||
{ "path": "mind/suppression.ts", "state": "different", "disposition": "reconcile" },
|
||||
{ "path": "multi-mind-cache.ts", "state": "different", "disposition": "reconcile" },
|
||||
{ "path": "multi-mind.ts", "state": "only-canonical", "disposition": "product-curation" },
|
||||
{ "path": "workspace-manager.ts", "state": "different", "disposition": "reconcile" }
|
||||
],
|
||||
"unreviewedDifferences": [
|
||||
{ "path": "harvest/raw-turns.ts", "state": "different" }
|
||||
],
|
||||
"forbiddenExports": {
|
||||
"paths": [
|
||||
"mind/evolution-runs.ts",
|
||||
"mind/execution-traces.ts",
|
||||
"mind/improvement-signals.ts"
|
||||
],
|
||||
"pathPrefixes": [
|
||||
"vault.ts",
|
||||
"compliance/"
|
||||
],
|
||||
"markers": [
|
||||
{ "path": "mind/db.ts", "token": "install_audit" },
|
||||
{ "path": "mind/schema.ts", "token": "install_audit" }
|
||||
]
|
||||
}
|
||||
}
|
||||
566
scripts/oss-drift-check.mjs
Normal file
566
scripts/oss-drift-check.mjs
Normal file
@@ -0,0 +1,566 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
lstatSync,
|
||||
readFileSync,
|
||||
readdirSync,
|
||||
realpathSync,
|
||||
} from 'node:fs';
|
||||
import { dirname, isAbsolute, join, posix, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const BASELINE_PATH = join(SCRIPT_DIR, 'oss-drift-baseline.json');
|
||||
const MAPPING = Object.freeze({
|
||||
canonical: 'packages/hive-mind-core/src',
|
||||
oss: 'packages/core/src',
|
||||
ignoredDirectories: ['dist', 'node_modules'],
|
||||
ignoredFileSuffixes: ['.test.ts', '.tsbuildinfo'],
|
||||
});
|
||||
const FORBIDDEN_EXPORTS = Object.freeze({
|
||||
paths: [
|
||||
'mind/evolution-runs.ts',
|
||||
'mind/execution-traces.ts',
|
||||
'mind/improvement-signals.ts',
|
||||
],
|
||||
pathPrefixes: ['vault.ts', 'compliance/'],
|
||||
markers: [
|
||||
{ path: 'mind/db.ts', token: 'install_audit' },
|
||||
{ path: 'mind/schema.ts', token: 'install_audit' },
|
||||
],
|
||||
});
|
||||
const VALID_ADAPTATION_KINDS = new Set(['branding', 'import', 'layout', 'logger']);
|
||||
const VALID_STATES = new Set(['different', 'only-canonical', 'only-oss']);
|
||||
const VALID_DISPOSITIONS = new Set([
|
||||
'forward-port',
|
||||
'product-curation',
|
||||
'reconcile',
|
||||
'reverse-port',
|
||||
]);
|
||||
const HASH_PATTERN = /^[a-f0-9]{64}$/;
|
||||
const decoder = new TextDecoder('utf-8', { fatal: true });
|
||||
|
||||
class ConfigurationError extends Error {}
|
||||
|
||||
function compareOrdinal(left, right) {
|
||||
return left < right ? -1 : left > right ? 1 : 0;
|
||||
}
|
||||
|
||||
function assertRecord(value, label) {
|
||||
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new ConfigurationError(`${label} must be an object`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertExactKeys(value, expected, label) {
|
||||
assertRecord(value, label);
|
||||
const actual = Object.keys(value).sort(compareOrdinal);
|
||||
const wanted = [...expected].sort(compareOrdinal);
|
||||
if (JSON.stringify(actual) !== JSON.stringify(wanted)) {
|
||||
throw new ConfigurationError(
|
||||
`${label} has unexpected keys: expected ${wanted.join(', ')}, got ${actual.join(', ')}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertExactArray(actual, expected, label) {
|
||||
if (!Array.isArray(actual) || JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new ConfigurationError(`${label} does not match the hardcoded publication policy`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertCanonicalPath(value, label) {
|
||||
if (
|
||||
typeof value !== 'string' ||
|
||||
value.length === 0 ||
|
||||
value.includes('\\') ||
|
||||
isAbsolute(value) ||
|
||||
value.startsWith('/') ||
|
||||
value.split('/').some((segment) => segment === '' || segment === '.' || segment === '..') ||
|
||||
posix.normalize(value) !== value
|
||||
) {
|
||||
throw new ConfigurationError(`${label} must be a canonical repository-relative path`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertSortedUnique(values, label, pathSelector = (value) => value) {
|
||||
if (!Array.isArray(values)) {
|
||||
throw new ConfigurationError(`${label} must be an array`);
|
||||
}
|
||||
const paths = values.map(pathSelector);
|
||||
const expected = [...new Set(paths)].sort(compareOrdinal);
|
||||
if (JSON.stringify(paths) !== JSON.stringify(expected)) {
|
||||
throw new ConfigurationError(`${label} must be sorted by path and contain no duplicates`);
|
||||
}
|
||||
}
|
||||
|
||||
function loadBaseline() {
|
||||
let baseline;
|
||||
try {
|
||||
baseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf-8'));
|
||||
} catch (error) {
|
||||
throw new ConfigurationError(`cannot read baseline ${BASELINE_PATH}: ${error.message}`);
|
||||
}
|
||||
|
||||
assertExactKeys(
|
||||
baseline,
|
||||
[
|
||||
'schemaVersion',
|
||||
'mapping',
|
||||
'parityPaths',
|
||||
'intentionalAdaptations',
|
||||
'knownReviewedBlockers',
|
||||
'unreviewedDifferences',
|
||||
'forbiddenExports',
|
||||
],
|
||||
'baseline',
|
||||
);
|
||||
if (baseline.schemaVersion !== 1) {
|
||||
throw new ConfigurationError('baseline.schemaVersion must be 1');
|
||||
}
|
||||
|
||||
assertExactKeys(
|
||||
baseline.mapping,
|
||||
['canonical', 'oss', 'ignoredDirectories', 'ignoredFileSuffixes'],
|
||||
'baseline.mapping',
|
||||
);
|
||||
for (const key of Object.keys(MAPPING)) {
|
||||
const expected = MAPPING[key];
|
||||
if (Array.isArray(expected)) {
|
||||
assertExactArray(baseline.mapping[key], expected, `baseline.mapping.${key}`);
|
||||
} else if (baseline.mapping[key] !== expected) {
|
||||
throw new ConfigurationError(`baseline.mapping.${key} does not match the hardcoded mapping`);
|
||||
}
|
||||
}
|
||||
|
||||
assertExactKeys(
|
||||
baseline.forbiddenExports,
|
||||
['paths', 'pathPrefixes', 'markers'],
|
||||
'baseline.forbiddenExports',
|
||||
);
|
||||
assertExactArray(
|
||||
baseline.forbiddenExports.paths,
|
||||
FORBIDDEN_EXPORTS.paths,
|
||||
'baseline.forbiddenExports.paths',
|
||||
);
|
||||
assertExactArray(
|
||||
baseline.forbiddenExports.pathPrefixes,
|
||||
FORBIDDEN_EXPORTS.pathPrefixes,
|
||||
'baseline.forbiddenExports.pathPrefixes',
|
||||
);
|
||||
assertExactArray(
|
||||
baseline.forbiddenExports.markers,
|
||||
FORBIDDEN_EXPORTS.markers,
|
||||
'baseline.forbiddenExports.markers',
|
||||
);
|
||||
|
||||
assertSortedUnique(baseline.parityPaths, 'baseline.parityPaths', (entry) => entry?.path);
|
||||
for (const [index, entry] of baseline.parityPaths.entries()) {
|
||||
const label = `baseline.parityPaths[${index}]`;
|
||||
assertExactKeys(entry, ['path', 'sha256'], label);
|
||||
assertCanonicalPath(entry.path, `${label}.path`);
|
||||
if (!HASH_PATTERN.test(entry.sha256)) {
|
||||
throw new ConfigurationError(`${label}.sha256 must be a lowercase SHA-256 hash`);
|
||||
}
|
||||
}
|
||||
|
||||
assertSortedUnique(
|
||||
baseline.intentionalAdaptations,
|
||||
'baseline.intentionalAdaptations',
|
||||
(entry) => entry?.path,
|
||||
);
|
||||
for (const [index, entry] of baseline.intentionalAdaptations.entries()) {
|
||||
const label = `baseline.intentionalAdaptations[${index}]`;
|
||||
assertExactKeys(entry, ['path', 'kinds', 'canonicalSha256', 'ossSha256'], label);
|
||||
assertCanonicalPath(entry.path, `${label}.path`);
|
||||
assertSortedUnique(entry.kinds, `${label}.kinds`);
|
||||
if (entry.kinds.length === 0 || entry.kinds.some((kind) => !VALID_ADAPTATION_KINDS.has(kind))) {
|
||||
throw new ConfigurationError(`${label}.kinds contains an unsupported adaptation kind`);
|
||||
}
|
||||
if (!HASH_PATTERN.test(entry.canonicalSha256) || !HASH_PATTERN.test(entry.ossSha256)) {
|
||||
throw new ConfigurationError(`${label} must contain lowercase SHA-256 hashes`);
|
||||
}
|
||||
if (entry.canonicalSha256 === entry.ossSha256) {
|
||||
throw new ConfigurationError(`${label} must describe an actual adaptation, not parity`);
|
||||
}
|
||||
}
|
||||
|
||||
assertSortedUnique(
|
||||
baseline.knownReviewedBlockers,
|
||||
'baseline.knownReviewedBlockers',
|
||||
(entry) => entry?.path,
|
||||
);
|
||||
for (const [index, entry] of baseline.knownReviewedBlockers.entries()) {
|
||||
const label = `baseline.knownReviewedBlockers[${index}]`;
|
||||
assertExactKeys(entry, ['path', 'state', 'disposition'], label);
|
||||
assertCanonicalPath(entry.path, `${label}.path`);
|
||||
if (!VALID_STATES.has(entry.state)) {
|
||||
throw new ConfigurationError(`${label}.state is unsupported`);
|
||||
}
|
||||
if (!VALID_DISPOSITIONS.has(entry.disposition)) {
|
||||
throw new ConfigurationError(`${label}.disposition is unsupported`);
|
||||
}
|
||||
}
|
||||
|
||||
assertSortedUnique(
|
||||
baseline.unreviewedDifferences,
|
||||
'baseline.unreviewedDifferences',
|
||||
(entry) => entry?.path,
|
||||
);
|
||||
for (const [index, entry] of baseline.unreviewedDifferences.entries()) {
|
||||
const label = `baseline.unreviewedDifferences[${index}]`;
|
||||
assertExactKeys(entry, ['path', 'state'], label);
|
||||
assertCanonicalPath(entry.path, `${label}.path`);
|
||||
if (!VALID_STATES.has(entry.state)) {
|
||||
throw new ConfigurationError(`${label}.state is unsupported`);
|
||||
}
|
||||
}
|
||||
|
||||
const classified = [
|
||||
...baseline.parityPaths.map((entry) => entry.path),
|
||||
...baseline.intentionalAdaptations.map((entry) => entry.path),
|
||||
...baseline.knownReviewedBlockers.map((entry) => entry.path),
|
||||
...baseline.unreviewedDifferences.map((entry) => entry.path),
|
||||
];
|
||||
const duplicate = classified.find((path, index) => classified.indexOf(path) !== index);
|
||||
if (duplicate) {
|
||||
throw new ConfigurationError(`baseline classifies ${duplicate} more than once`);
|
||||
}
|
||||
const privateCategory = classified.find((path) => isPrivateExclusion(path));
|
||||
if (privateCategory) {
|
||||
throw new ConfigurationError(`baseline must not reclassify private exclusion ${privateCategory}`);
|
||||
}
|
||||
|
||||
return baseline;
|
||||
}
|
||||
|
||||
function resolveGitRoot(candidate, label) {
|
||||
let resolved;
|
||||
try {
|
||||
resolved = realpathSync(candidate);
|
||||
const root = execFileSync('git', ['-C', resolved, 'rev-parse', '--show-toplevel'], {
|
||||
encoding: 'utf-8',
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
}).trim();
|
||||
return realpathSync(root);
|
||||
} catch (error) {
|
||||
throw new ConfigurationError(`${label} is not a readable Git worktree: ${candidate}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readUtf8Normalized(filePath) {
|
||||
let text;
|
||||
try {
|
||||
text = decoder.decode(readFileSync(filePath));
|
||||
} catch (error) {
|
||||
throw new ConfigurationError(`cannot read UTF-8 source file ${filePath}: ${error.message}`);
|
||||
}
|
||||
return text.replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n');
|
||||
}
|
||||
|
||||
function inventory(root, label) {
|
||||
let rootStat;
|
||||
try {
|
||||
rootStat = lstatSync(root);
|
||||
} catch {
|
||||
throw new ConfigurationError(`${label} mapped source directory is missing: ${root}`);
|
||||
}
|
||||
if (rootStat.isSymbolicLink() || !rootStat.isDirectory()) {
|
||||
throw new ConfigurationError(`${label} mapped source must be a real directory: ${root}`);
|
||||
}
|
||||
|
||||
const files = new Map();
|
||||
const casing = new Map();
|
||||
const walk = (directory, prefix = '') => {
|
||||
let entries;
|
||||
try {
|
||||
entries = readdirSync(directory, { withFileTypes: true }).sort((left, right) =>
|
||||
compareOrdinal(left.name, right.name),
|
||||
);
|
||||
} catch (error) {
|
||||
throw new ConfigurationError(`cannot inventory ${label} directory ${directory}: ${error.message}`);
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const relativePath = prefix ? `${prefix}/${entry.name}` : entry.name;
|
||||
const absolutePath = join(directory, entry.name);
|
||||
const stat = lstatSync(absolutePath);
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new ConfigurationError(`${label} mapped inventory contains symlink: ${relativePath}`);
|
||||
}
|
||||
if (stat.isDirectory()) {
|
||||
if (!MAPPING.ignoredDirectories.includes(entry.name)) {
|
||||
walk(absolutePath, relativePath);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (!stat.isFile()) {
|
||||
throw new ConfigurationError(`${label} mapped inventory contains special node: ${relativePath}`);
|
||||
}
|
||||
if (MAPPING.ignoredFileSuffixes.some((suffix) => relativePath.endsWith(suffix))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const folded = relativePath.toLowerCase();
|
||||
const previous = casing.get(folded);
|
||||
if (previous && previous !== relativePath) {
|
||||
throw new ConfigurationError(
|
||||
`${label} mapped inventory has case collision: ${previous} / ${relativePath}`,
|
||||
);
|
||||
}
|
||||
casing.set(folded, relativePath);
|
||||
const text = readUtf8Normalized(absolutePath);
|
||||
files.set(relativePath, {
|
||||
hash: createHash('sha256').update(text, 'utf-8').digest('hex'),
|
||||
text,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
walk(root);
|
||||
if (files.size === 0) {
|
||||
throw new ConfigurationError(`${label} mapped inventory is empty: ${root}`);
|
||||
}
|
||||
return files;
|
||||
}
|
||||
|
||||
function resolveMappedRoot(repositoryRoot, relativePath, label) {
|
||||
let current = repositoryRoot;
|
||||
for (const segment of relativePath.split('/')) {
|
||||
current = join(current, segment);
|
||||
let stat;
|
||||
try {
|
||||
stat = lstatSync(current);
|
||||
} catch {
|
||||
throw new ConfigurationError(`${label} mapped path component is missing: ${current}`);
|
||||
}
|
||||
if (stat.isSymbolicLink()) {
|
||||
throw new ConfigurationError(`${label} mapped path contains ancestor symlink: ${current}`);
|
||||
}
|
||||
if (!stat.isDirectory()) {
|
||||
throw new ConfigurationError(`${label} mapped path component is not a directory: ${current}`);
|
||||
}
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
function isPrivateExclusion(path) {
|
||||
if (FORBIDDEN_EXPORTS.paths.includes(path)) return true;
|
||||
return FORBIDDEN_EXPORTS.pathPrefixes.some((prefix) =>
|
||||
prefix.endsWith('/') ? path.startsWith(prefix) : path === prefix || path.startsWith(`${prefix}/`),
|
||||
);
|
||||
}
|
||||
|
||||
function observedState(canonical, oss) {
|
||||
if (canonical && oss) return canonical.hash === oss.hash ? 'equal' : 'different';
|
||||
if (canonical) return 'only-canonical';
|
||||
if (oss) return 'only-oss';
|
||||
return 'absent';
|
||||
}
|
||||
|
||||
function hasForbiddenMarker(text, token) {
|
||||
const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`\\b${escaped}\\b`, 'i').test(text);
|
||||
}
|
||||
|
||||
function gitReceipt(root, scopedPaths) {
|
||||
const head = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], {
|
||||
encoding: 'utf-8',
|
||||
windowsHide: true,
|
||||
}).trim();
|
||||
const status = execFileSync(
|
||||
'git',
|
||||
['-C', root, 'status', '--porcelain=v1', '--untracked-files=all'],
|
||||
{ encoding: 'utf-8', windowsHide: true },
|
||||
).trim();
|
||||
const scopedStatus = execFileSync(
|
||||
'git',
|
||||
['-C', root, 'status', '--porcelain=v1', '--untracked-files=all', '--', ...scopedPaths],
|
||||
{ encoding: 'utf-8', windowsHide: true },
|
||||
).trim();
|
||||
return { head, dirty: status.length > 0, scopedStatus };
|
||||
}
|
||||
|
||||
function showSection(title, entries) {
|
||||
console.log(`${title}: ${entries.length}`);
|
||||
for (const entry of [...entries].sort(compareOrdinal)) {
|
||||
console.log(` ${entry}`);
|
||||
}
|
||||
}
|
||||
|
||||
function run() {
|
||||
if (process.argv.length > 3) {
|
||||
throw new ConfigurationError('usage: node scripts/oss-drift-check.mjs [path-to-oss-checkout]');
|
||||
}
|
||||
const baseline = loadBaseline();
|
||||
const canonicalRoot = resolveGitRoot(join(SCRIPT_DIR, '..'), 'canonical repository');
|
||||
const requestedOss =
|
||||
process.argv[2] ??
|
||||
(process.env.OSS_HIVE_MIND_DIR?.trim() || resolve(canonicalRoot, '..', 'hive-mind'));
|
||||
const ossRoot = resolveGitRoot(resolve(process.cwd(), requestedOss), 'OSS repository');
|
||||
if (canonicalRoot.toLowerCase() === ossRoot.toLowerCase()) {
|
||||
throw new ConfigurationError('canonical and OSS repositories must be different Git worktrees');
|
||||
}
|
||||
|
||||
const canonicalFiles = inventory(
|
||||
resolveMappedRoot(canonicalRoot, MAPPING.canonical, 'canonical'),
|
||||
'canonical',
|
||||
);
|
||||
const ossFiles = inventory(resolveMappedRoot(ossRoot, MAPPING.oss, 'OSS'), 'OSS');
|
||||
const crossRepoCasing = new Map();
|
||||
for (const path of [...canonicalFiles.keys(), ...ossFiles.keys()]) {
|
||||
const folded = path.toLowerCase();
|
||||
const previous = crossRepoCasing.get(folded);
|
||||
if (previous && previous !== path) {
|
||||
throw new ConfigurationError(`mapped inventories have case collision: ${previous} / ${path}`);
|
||||
}
|
||||
crossRepoCasing.set(folded, path);
|
||||
}
|
||||
|
||||
const parity = new Map(baseline.parityPaths.map((entry) => [entry.path, entry]));
|
||||
const adaptations = new Map(baseline.intentionalAdaptations.map((entry) => [entry.path, entry]));
|
||||
const blockers = new Map(baseline.knownReviewedBlockers.map((entry) => [entry.path, entry]));
|
||||
const knownUnreviewed = new Map(
|
||||
baseline.unreviewedDifferences.map((entry) => [entry.path, entry]),
|
||||
);
|
||||
const reviewedAdaptations = [];
|
||||
const intentionalExclusions = [];
|
||||
const knownBlockers = [];
|
||||
const unreviewed = [];
|
||||
const forbidden = [];
|
||||
const observedPaths = new Set();
|
||||
const union = [...new Set([...canonicalFiles.keys(), ...ossFiles.keys()])].sort(compareOrdinal);
|
||||
|
||||
for (const path of union) {
|
||||
observedPaths.add(path);
|
||||
const canonical = canonicalFiles.get(path);
|
||||
const oss = ossFiles.get(path);
|
||||
const state = observedState(canonical, oss);
|
||||
if (isPrivateExclusion(path)) {
|
||||
if (oss) {
|
||||
forbidden.push(`FORBIDDEN-OSS-CONTENT ${path}`);
|
||||
} else if (canonical) {
|
||||
intentionalExclusions.push(path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const parityEntry = parity.get(path);
|
||||
if (parityEntry) {
|
||||
if (
|
||||
state === 'equal' &&
|
||||
canonical.hash === parityEntry.sha256 &&
|
||||
oss.hash === parityEntry.sha256
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
unreviewed.push(`BASELINE-DRIFT ${path} expected equal, observed ${state}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const adaptation = adaptations.get(path);
|
||||
if (adaptation) {
|
||||
if (
|
||||
state === 'different' &&
|
||||
canonical.hash === adaptation.canonicalSha256 &&
|
||||
oss.hash === adaptation.ossSha256
|
||||
) {
|
||||
reviewedAdaptations.push(`${path} [${adaptation.kinds.join(',')}]`);
|
||||
} else {
|
||||
unreviewed.push(`BASELINE-DRIFT ${path} reviewed adaptation bytes changed (${state})`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const blocker = blockers.get(path);
|
||||
if (blocker) {
|
||||
if (state === blocker.state) {
|
||||
knownBlockers.push(`${path} (${state}; ${blocker.disposition})`);
|
||||
} else {
|
||||
unreviewed.push(
|
||||
`BASELINE-DRIFT ${path} expected ${blocker.state}, observed ${state}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const unreviewedEntry = knownUnreviewed.get(path);
|
||||
if (unreviewedEntry) {
|
||||
if (state === unreviewedEntry.state) {
|
||||
unreviewed.push(`${path} (${state})`);
|
||||
} else {
|
||||
unreviewed.push(
|
||||
`BASELINE-DRIFT ${path} expected ${unreviewedEntry.state}, observed ${state}`,
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
unreviewed.push(`NEW ${path} (${state})`);
|
||||
}
|
||||
|
||||
for (const path of [
|
||||
...parity.keys(),
|
||||
...adaptations.keys(),
|
||||
...blockers.keys(),
|
||||
...knownUnreviewed.keys(),
|
||||
]) {
|
||||
if (!observedPaths.has(path)) {
|
||||
unreviewed.push(`STALE-BASELINE ${path} (absent)`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const marker of FORBIDDEN_EXPORTS.markers) {
|
||||
const file = ossFiles.get(marker.path);
|
||||
if (!file) continue;
|
||||
if (hasForbiddenMarker(file.text, marker.token)) {
|
||||
forbidden.push(`FORBIDDEN-OSS-MARKER ${marker.path}:${marker.token}`);
|
||||
}
|
||||
}
|
||||
|
||||
const canonicalReceipt = gitReceipt(canonicalRoot, [
|
||||
MAPPING.canonical,
|
||||
'scripts/oss-drift-check.mjs',
|
||||
'scripts/oss-drift-baseline.json',
|
||||
'scripts/oss-drift-check.sh',
|
||||
]);
|
||||
const ossReceipt = gitReceipt(ossRoot, [MAPPING.oss]);
|
||||
if (canonicalReceipt.scopedStatus) {
|
||||
unreviewed.push('SCOPED-DIRTY canonical checker, baseline, wrapper, or mapped source');
|
||||
}
|
||||
if (ossReceipt.scopedStatus) {
|
||||
unreviewed.push('SCOPED-DIRTY OSS mapped source');
|
||||
}
|
||||
console.log(
|
||||
`CANONICAL ${canonicalReceipt.head} ${canonicalReceipt.dirty ? 'dirty' : 'clean'} ${canonicalRoot}`,
|
||||
);
|
||||
console.log(`OSS ${ossReceipt.head} ${ossReceipt.dirty ? 'dirty' : 'clean'} ${ossRoot}`);
|
||||
console.log(`PARITY: ${parity.size}`);
|
||||
showSection('REVIEWED ADAPTATIONS', reviewedAdaptations);
|
||||
showSection('INTENTIONAL OSS EXCLUSIONS', intentionalExclusions);
|
||||
showSection('KNOWN REVIEWED BLOCKERS', knownBlockers);
|
||||
showSection('UNREVIEWED DIFFERENCES', unreviewed);
|
||||
showSection('FORBIDDEN EXPORTS', forbidden);
|
||||
|
||||
const blocked = knownBlockers.length || unreviewed.length || forbidden.length;
|
||||
if (blocked) {
|
||||
console.log(
|
||||
'[oss-drift-check] release blocked: reconcile in the canonical monorepo, then use a curated forward-port; never publish a raw split.',
|
||||
);
|
||||
}
|
||||
return blocked ? 1 : 0;
|
||||
}
|
||||
|
||||
try {
|
||||
process.exitCode = run();
|
||||
} catch (error) {
|
||||
if (error instanceof ConfigurationError) {
|
||||
console.error(`[oss-drift-check] CONFIGURATION ERROR: ${error.message}`);
|
||||
} else {
|
||||
console.error(`[oss-drift-check] UNEXPECTED ERROR: ${error?.stack ?? error}`);
|
||||
}
|
||||
process.exitCode = 2;
|
||||
}
|
||||
@@ -1,123 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# oss-drift-check.sh — detect source drift between the canonical monorepo
|
||||
# substrate and the public OSS mirror (github.com/marolinik/hive-mind).
|
||||
#
|
||||
# WHY THIS EXISTS (§7.5 policy, ratified 2026-06-11):
|
||||
# The monorepo is the SOLE source of truth for the memory substrate; the
|
||||
# OSS mirror is generated FROM it. That invariant broke once: the
|
||||
# cross-encoder reranker (inprocess-reranker.ts + HybridSearch options)
|
||||
# was authored directly on the OSS repo during the LoCoMo benchmark arc
|
||||
# and existed ONLY there — discovered by the W4 recon (2026-06-11),
|
||||
# reverse-ported in W4.2 (f47ee8f). This script makes that class of
|
||||
# drift cheap to detect BEFORE it compounds.
|
||||
#
|
||||
# WHAT IT DOES:
|
||||
# Recursively diffs the substrate source trees (src/ only — dist, deps,
|
||||
# lockfiles, and docs churn excluded) between the monorepo and a local
|
||||
# checkout of the OSS repo. Reports per-file status:
|
||||
# ONLY-IN-OSS → candidate reverse-port (the W4.2 failure mode)
|
||||
# ONLY-IN-MONO → not yet exported (fine if a split is pending)
|
||||
# DIFFERS → divergent edits — inspect immediately
|
||||
# Exit 0 = clean, exit 1 = drift found, exit 2 = setup error.
|
||||
#
|
||||
# USAGE:
|
||||
# bash scripts/oss-drift-check.sh [path-to-oss-checkout]
|
||||
# Default OSS path: ../hive-mind (sibling clone), override via arg or
|
||||
# OSS_HIVE_MIND_DIR env var.
|
||||
#
|
||||
# WHEN TO RUN (maintainer ritual — manual, not CI):
|
||||
# - before every OSS release push (alongside oss-subtree-split.sh)
|
||||
# - after any benchmark/experiment arc that touched a hive-mind checkout
|
||||
#
|
||||
# Mapping (OSS repo keeps its own package layout):
|
||||
# monorepo packages/hive-mind-core/src ↔ oss packages/core/src
|
||||
# (extend MAPPINGS below as more packages get mirrored surfaces)
|
||||
#!/usr/bin/env sh
|
||||
set -eu
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
OSS_DIR="${1:-${OSS_HIVE_MIND_DIR:-$REPO_ROOT/../hive-mind}}"
|
||||
|
||||
if [[ ! -d "$OSS_DIR/.git" ]]; then
|
||||
echo "[oss-drift-check] ERROR: OSS checkout not found at: $OSS_DIR" >&2
|
||||
echo "[oss-drift-check] Clone it first: git clone https://github.com/marolinik/hive-mind \"$OSS_DIR\"" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# mono-relative-dir : oss-relative-dir
|
||||
MAPPINGS=(
|
||||
"packages/hive-mind-core/src:packages/core/src"
|
||||
)
|
||||
|
||||
# Excluded from comparison:
|
||||
# - build artifacts (dist, node_modules, .tsbuildinfo)
|
||||
# - *.test.ts — test LAYOUT is a permanent convention difference (OSS
|
||||
# co-locates tests beside src; the monorepo keeps them in tests/), so
|
||||
# co-located tests would be unfixable noise. Source drift is the target.
|
||||
IGNORE_RE='(^|/)(dist|node_modules|\.tsbuildinfo)(/|$)|\.test\.ts$'
|
||||
|
||||
drift=0
|
||||
|
||||
for mapping in "${MAPPINGS[@]}"; do
|
||||
mono_dir="${mapping%%:*}"
|
||||
oss_dir="${mapping##*:}"
|
||||
echo "[oss-drift-check] ${mono_dir} ↔ ${OSS_DIR}/${oss_dir}"
|
||||
|
||||
if [[ ! -d "$mono_dir" ]]; then
|
||||
echo "[oss-drift-check] ERROR: monorepo dir missing: $mono_dir" >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! -d "$OSS_DIR/$oss_dir" ]]; then
|
||||
echo "[oss-drift-check] ERROR: OSS dir missing: $OSS_DIR/$oss_dir" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# File inventories (relative paths), excluding build artifacts.
|
||||
mono_files=$(cd "$mono_dir" && find . -type f | sed 's|^\./||' | grep -Ev "$IGNORE_RE" | sort)
|
||||
oss_files=$(cd "$OSS_DIR/$oss_dir" && find . -type f | sed 's|^\./||' | grep -Ev "$IGNORE_RE" | sort)
|
||||
|
||||
only_oss=$(comm -13 <(echo "$mono_files") <(echo "$oss_files"))
|
||||
only_mono=$(comm -23 <(echo "$mono_files") <(echo "$oss_files"))
|
||||
common=$(comm -12 <(echo "$mono_files") <(echo "$oss_files"))
|
||||
|
||||
if [[ -n "$only_oss" ]]; then
|
||||
drift=1
|
||||
echo " ONLY-IN-OSS (candidate reverse-port — the W4.2 failure mode):"
|
||||
echo "$only_oss" | sed 's/^/ /'
|
||||
fi
|
||||
if [[ -n "$only_mono" ]]; then
|
||||
drift=1
|
||||
echo " ONLY-IN-MONO (pending export — fine if a split is queued):"
|
||||
echo "$only_mono" | sed 's/^/ /'
|
||||
fi
|
||||
|
||||
differing=""
|
||||
while IFS= read -r f; do
|
||||
[[ -z "$f" ]] && continue
|
||||
if ! diff -q "$mono_dir/$f" "$OSS_DIR/$oss_dir/$f" >/dev/null 2>&1; then
|
||||
differing+=" $f"$'\n'
|
||||
fi
|
||||
done <<< "$common"
|
||||
|
||||
if [[ -n "$differing" ]]; then
|
||||
drift=1
|
||||
echo " DIFFERS (divergent edits — inspect immediately):"
|
||||
printf '%s' "$differing"
|
||||
fi
|
||||
|
||||
if [[ -z "$only_oss" && -z "$only_mono" && -z "$differing" ]]; then
|
||||
echo " ✓ clean"
|
||||
fi
|
||||
echo
|
||||
done
|
||||
|
||||
if [[ $drift -eq 1 ]]; then
|
||||
echo "[oss-drift-check] DRIFT DETECTED. Policy (§7.5): the monorepo is the"
|
||||
echo "[oss-drift-check] sole source — reverse-port ONLY-IN-OSS work here first,"
|
||||
echo "[oss-drift-check] then regenerate the mirror via scripts/oss-subtree-split.sh."
|
||||
exit 1
|
||||
fi
|
||||
echo "[oss-drift-check] All mapped surfaces clean."
|
||||
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
exec node "$SCRIPT_DIR/oss-drift-check.mjs" "$@"
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
# 1. PROPRIETARY FILES. `packages/hive-mind-core/src/mind/` contains
|
||||
# evolution-runs.ts, execution-traces.ts, improvement-signals.ts — Waggle
|
||||
# proprietary, EXCLUDED from the public mirror. A raw split carries them.
|
||||
# (The hard abort guard below refuses to emit a branch that contains them,
|
||||
# (The hard abort guard below refuses to publish a ref that contains them,
|
||||
# so the leak can't happen silently — but the guard is a backstop, not the
|
||||
# sync mechanism.)
|
||||
# 2. INTERLEAVED PROPRIETARY CONTENT. The `install_audit` table DDL + its
|
||||
@@ -43,13 +43,16 @@
|
||||
# bash scripts/oss-subtree-split.sh # split all hive-mind-* packages
|
||||
# bash scripts/oss-subtree-split.sh hive-mind-core # split only one package
|
||||
#
|
||||
# Idempotent: re-running drops + recreates the export branches with current state.
|
||||
# Idempotent: re-running replaces export refs only after a candidate passes every guard.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(git rev-parse --show-toplevel)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
VALIDATED_BRANCHES=()
|
||||
VALIDATED_SHAS=()
|
||||
|
||||
# Default: split all hive-mind-* packages. Override via CLI args for targeted split.
|
||||
if [[ $# -gt 0 ]]; then
|
||||
PACKAGES=("$@")
|
||||
@@ -74,22 +77,20 @@ for pkg in "${PACKAGES[@]}"; do
|
||||
BRANCH="oss-${pkg}-export"
|
||||
|
||||
if [[ ! -d "$PREFIX" ]]; then
|
||||
echo "[oss-subtree-split] SKIP $pkg — directory $PREFIX not found." >&2
|
||||
continue
|
||||
echo "[oss-subtree-split] ERROR: requested package directory $PREFIX was not found." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Drop existing export branch if present (idempotent).
|
||||
if git show-ref --verify --quiet "refs/heads/$BRANCH"; then
|
||||
echo "[oss-subtree-split] Dropping existing branch $BRANCH"
|
||||
git branch -D "$BRANCH" >/dev/null
|
||||
echo "[oss-subtree-split] Splitting $PREFIX → detached candidate commit"
|
||||
CANDIDATE_SHA=$(git subtree split --prefix="$PREFIX" | tail -n 1)
|
||||
if ! git cat-file -e "${CANDIDATE_SHA}^{commit}" 2>/dev/null; then
|
||||
echo "[oss-subtree-split] ERROR: subtree split did not return a valid commit." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
echo "[oss-subtree-split] Splitting $PREFIX → $BRANCH"
|
||||
git subtree split --prefix="$PREFIX" --branch="$BRANCH"
|
||||
|
||||
# Top-level summary for audit.
|
||||
TOP_LEVEL=$(git ls-tree --name-only "$BRANCH" | sort | tr '\n' ' ')
|
||||
echo "[oss-subtree-split] $BRANCH HEAD top-level: $TOP_LEVEL"
|
||||
TOP_LEVEL=$(git ls-tree --name-only "$CANDIDATE_SHA" | sort | tr '\n' ' ')
|
||||
echo "[oss-subtree-split] candidate HEAD top-level: $TOP_LEVEL"
|
||||
|
||||
# Negative assertion: monorepo-bleed sentinel. The subtree-split's prefix=
|
||||
# arg already guarantees the export contains ONLY the subtree, but we
|
||||
@@ -98,9 +99,9 @@ for pkg in "${PACKAGES[@]}"; do
|
||||
# Forbidden = paths that ONLY exist as monorepo siblings, never as package contents.
|
||||
for forbidden in apps packages sidecar .planning .scratch .mind benchmarks; do
|
||||
if echo "$TOP_LEVEL" | grep -qE "(^| )$forbidden( |$)"; then
|
||||
echo "[oss-subtree-split] ERROR: $BRANCH contains forbidden monorepo-level entry '$forbidden'." >&2
|
||||
echo "[oss-subtree-split] ERROR: candidate contains forbidden monorepo-level entry '$forbidden'." >&2
|
||||
echo "[oss-subtree-split] This indicates the subtree-split misbehaved or proprietary content leaked." >&2
|
||||
echo "[oss-subtree-split] Inspect with: git checkout $BRANCH && ls" >&2
|
||||
echo "[oss-subtree-split] No export refs were changed." >&2
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
@@ -120,10 +121,10 @@ for pkg in "${PACKAGES[@]}"; do
|
||||
"src/vault.ts"
|
||||
"src/compliance"
|
||||
)
|
||||
BRANCH_FILES=$(git ls-tree -r --name-only "$BRANCH")
|
||||
BRANCH_FILES=$(git ls-tree -r --name-only "$CANDIDATE_SHA")
|
||||
for pf in "${FORBIDDEN_FILES[@]}"; do
|
||||
if echo "$BRANCH_FILES" | grep -qE "(^|/)${pf}(/|\$|\.ts\$)"; then
|
||||
echo "[oss-subtree-split] ERROR: $BRANCH contains PROPRIETARY path '$pf'." >&2
|
||||
echo "[oss-subtree-split] ERROR: candidate contains PROPRIETARY path '$pf'." >&2
|
||||
echo "[oss-subtree-split] This export is NOT safe to push to the public OSS mirror." >&2
|
||||
echo "[oss-subtree-split] These files are Waggle-proprietary (§7.5) and must be removed" >&2
|
||||
echo "[oss-subtree-split] by the curated forward-port, not pushed raw. ABORTING." >&2
|
||||
@@ -132,15 +133,52 @@ for pkg in "${PACKAGES[@]}"; do
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[oss-subtree-split] ✓ $BRANCH split complete (no monorepo-level leak, no proprietary files)"
|
||||
VALIDATED_BRANCHES+=("$BRANCH")
|
||||
VALIDATED_SHAS+=("$CANDIDATE_SHA")
|
||||
echo "[oss-subtree-split] ✓ candidate validated (no monorepo-level leak, no proprietary files)"
|
||||
echo "[oss-subtree-split] NOTE: this is a RAW history branch — NOT OSS-publishable as-is"
|
||||
echo "[oss-subtree-split] (wrong layout + interleaved install_audit). Curate before any push."
|
||||
echo
|
||||
done
|
||||
|
||||
# Update stable LOCAL inspection refs only after every requested package passes.
|
||||
# Preflight checked-out refs, capture expected old OIDs, then use one compare-and-
|
||||
# swap transaction so a lock/race/failure cannot leave a partially updated set.
|
||||
ZERO_OID=$(printf '%040d' 0)
|
||||
EXPECTED_OLD_SHAS=()
|
||||
if ! WORKTREE_LIST=$(git worktree list --porcelain); then
|
||||
echo "[oss-subtree-split] ERROR: could not inventory checked-out worktree refs." >&2
|
||||
exit 4
|
||||
fi
|
||||
for i in "${!VALIDATED_BRANCHES[@]}"; do
|
||||
ref="refs/heads/${VALIDATED_BRANCHES[$i]}"
|
||||
if grep -Fxq "branch $ref" <<< "$WORKTREE_LIST"; then
|
||||
echo "[oss-subtree-split] ERROR: refusing to update checked-out ref $ref." >&2
|
||||
exit 4
|
||||
fi
|
||||
if git show-ref --verify --quiet "$ref"; then
|
||||
EXPECTED_OLD_SHAS+=("$(git rev-parse "$ref")")
|
||||
else
|
||||
EXPECTED_OLD_SHAS+=("$ZERO_OID")
|
||||
fi
|
||||
done
|
||||
|
||||
if ! {
|
||||
echo start
|
||||
for i in "${!VALIDATED_BRANCHES[@]}"; do
|
||||
printf 'update refs/heads/%s %s %s\n' \
|
||||
"${VALIDATED_BRANCHES[$i]}" "${VALIDATED_SHAS[$i]}" "${EXPECTED_OLD_SHAS[$i]}"
|
||||
done
|
||||
echo prepare
|
||||
echo commit
|
||||
} | git update-ref --stdin; then
|
||||
echo "[oss-subtree-split] ERROR: atomic export-ref transaction failed; no refs were changed." >&2
|
||||
exit 4
|
||||
fi
|
||||
|
||||
echo "[oss-subtree-split] All splits complete. Local branches ready:"
|
||||
for pkg in "${PACKAGES[@]}"; do
|
||||
echo " oss-${pkg}-export"
|
||||
for branch in "${VALIDATED_BRANCHES[@]}"; do
|
||||
echo " $branch"
|
||||
done
|
||||
echo
|
||||
echo "[oss-subtree-split] These branches are for INSPECTION / as a curation starting"
|
||||
|
||||
@@ -1,140 +1,16 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Local-dev parity check for memory substrate (waggle-os ↔ hive-mind).
|
||||
# RETIRED 2026-04-30 — the memory substrate moved to
|
||||
# packages/hive-mind-core/src and the public mirror adopted a curated layout.
|
||||
# This pre-migration injector cannot prove current parity and must not run.
|
||||
#
|
||||
# Mirrors the logic of `.github/workflows/mind-parity-check.yml` so you can
|
||||
# run the same check locally before pushing. Useful when you're modifying
|
||||
# `packages/core/src/mind/` or `packages/core/src/harvest/` and want to
|
||||
# catch parity failures without waiting for CI.
|
||||
#
|
||||
# USAGE:
|
||||
# scripts/parity-check.sh [--keep-injected]
|
||||
#
|
||||
# Options:
|
||||
# --keep-injected Don't clean up CI-injected -hive-mind suffix files
|
||||
# after the run (useful for inspecting what CI sees).
|
||||
#
|
||||
# REQUIREMENTS:
|
||||
# - hive-mind checked out at one of:
|
||||
# D:/Projects/hive-mind (default Windows path)
|
||||
# ~/Projects/hive-mind (default Unix path)
|
||||
# $HIVE_MIND_PATH (override)
|
||||
# - npm + Node + a working `npx vitest`
|
||||
# - The waggle-os repo as the cwd
|
||||
#
|
||||
# See `.github/sync.md` for full design rationale and `.parity-allowlist`
|
||||
# policy.
|
||||
# Current maintainer workflow:
|
||||
# 1. Read AGENTS.md §7.5 and packages/hive-mind-core/CONTRIBUTING.md.
|
||||
# 2. Run scripts/oss-drift-check.sh against a clean OSS checkout.
|
||||
# 3. Prepare and review a maintainer-curated forward-port.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
KEEP_INJECTED=0
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--keep-injected) KEEP_INJECTED=1 ;;
|
||||
-h|--help)
|
||||
sed -n '2,28p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown arg: $arg" >&2
|
||||
echo "Run with --help for usage" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Locate hive-mind checkout
|
||||
if [ -n "${HIVE_MIND_PATH:-}" ]; then
|
||||
hive_root="$HIVE_MIND_PATH"
|
||||
elif [ -d "D:/Projects/hive-mind/packages/core/src/mind" ]; then
|
||||
hive_root="D:/Projects/hive-mind"
|
||||
elif [ -d "$HOME/Projects/hive-mind/packages/core/src/mind" ]; then
|
||||
hive_root="$HOME/Projects/hive-mind"
|
||||
else
|
||||
echo "::error:: hive-mind checkout not found. Set HIVE_MIND_PATH or clone marolinik/hive-mind to D:/Projects/hive-mind." >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "hive-mind root: $hive_root"
|
||||
|
||||
# Verify cwd is waggle-os
|
||||
if [ ! -f "packages/core/src/mind/db.ts" ]; then
|
||||
echo "::error:: this script must run from the waggle-os repo root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Parse .parity-allowlist
|
||||
allowlist_file=".parity-allowlist"
|
||||
allowlist_basenames=()
|
||||
if [ -f "$allowlist_file" ]; then
|
||||
while IFS= read -r line; do
|
||||
clean="${line%%#*}"
|
||||
clean="$(echo "$clean" | tr -d '[:space:]')"
|
||||
[ -z "$clean" ] && continue
|
||||
allowlist_basenames+=("$clean")
|
||||
done < "$allowlist_file"
|
||||
echo "Allowlist: ${allowlist_basenames[*]:-<none>}"
|
||||
fi
|
||||
is_allowlisted() {
|
||||
local name="$1"
|
||||
for a in "${allowlist_basenames[@]:-}"; do
|
||||
[ "$a" = "$name" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Inject — track ONLY files we add new (not overwrite committed ones)
|
||||
target_dir="packages/core/tests/mind"
|
||||
source_dir="$hive_root/packages/core/src/mind"
|
||||
mkdir -p tmp
|
||||
> tmp/parity-injected.txt
|
||||
|
||||
injected=0
|
||||
overwrote=0
|
||||
skipped=0
|
||||
for src in "$source_dir"/*.test.ts; do
|
||||
[ -e "$src" ] || continue
|
||||
base="$(basename "$src" .test.ts)"
|
||||
target_name="${base}-hive-mind.test.ts"
|
||||
target_path="$target_dir/$target_name"
|
||||
|
||||
if is_allowlisted "$target_name"; then
|
||||
skipped=$((skipped + 1))
|
||||
continue
|
||||
fi
|
||||
|
||||
if [ -f "$target_path" ]; then
|
||||
# File is committed (Step 2 port — possibly with bespoke header
|
||||
# comments documenting port provenance + adaptation rationale).
|
||||
# Don't overwrite: the committed version IS what CI/local should
|
||||
# exercise. We track which files we observed already-present so the
|
||||
# operator sees the count.
|
||||
overwrote=$((overwrote + 1))
|
||||
continue
|
||||
fi
|
||||
injected=$((injected + 1))
|
||||
echo "$target_path" >> tmp/parity-injected.txt
|
||||
cp "$src" "$target_path"
|
||||
sed -i "s|from \"\\./|from \"../../src/mind/|g" "$target_path"
|
||||
sed -i "s|from '\\./|from '../../src/mind/|g" "$target_path"
|
||||
done
|
||||
echo "Injected: $injected new + $overwrote already-committed-skip + $skipped allowlisted-skip"
|
||||
|
||||
# Run the suite
|
||||
echo ""
|
||||
echo "## Running combined waggle-os + hive-mind suite..."
|
||||
exit_code=0
|
||||
npx vitest run --reporter=default "$target_dir" || exit_code=$?
|
||||
|
||||
# Cleanup
|
||||
if [ $KEEP_INJECTED -eq 0 ]; then
|
||||
while IFS= read -r f; do
|
||||
[ -n "$f" ] && rm -f "$f"
|
||||
done < tmp/parity-injected.txt
|
||||
echo ""
|
||||
echo "Cleaned up $(wc -l < tmp/parity-injected.txt) injected files"
|
||||
else
|
||||
echo ""
|
||||
echo "Kept injected files (--keep-injected). Track in tmp/parity-injected.txt"
|
||||
fi
|
||||
|
||||
exit $exit_code
|
||||
echo "[parity-check] RETIRED: this pre-migration parity injector is disabled." >&2
|
||||
echo "[parity-check] Use scripts/oss-drift-check.sh and the curated workflow in AGENTS.md §7.5." >&2
|
||||
exit 2
|
||||
|
||||
918
scripts/publish-windows-release.ps1
Normal file
918
scripts/publish-windows-release.ps1
Normal file
@@ -0,0 +1,918 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[ValidateSet('bootstrap', 'upgrade')]
|
||||
[string]$Mode
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Assert-RemoteTagCommit {
|
||||
param([string]$Tag, [string]$ExpectedCommit)
|
||||
|
||||
$remoteTagLines = @(
|
||||
git ls-remote --tags origin "refs/tags/$Tag" "refs/tags/$Tag^{}"
|
||||
)
|
||||
if ($LASTEXITCODE -ne 0 -or $remoteTagLines.Count -eq 0) {
|
||||
throw "Could not resolve remote release tag $Tag"
|
||||
}
|
||||
$resolvedTagLine = @(
|
||||
$remoteTagLines |
|
||||
Where-Object { $_ -match '\^\{\}$' } |
|
||||
Select-Object -First 1
|
||||
)
|
||||
if ($resolvedTagLine.Count -eq 0) {
|
||||
$resolvedTagLine = @($remoteTagLines | Select-Object -First 1)
|
||||
}
|
||||
$remoteTagSha = (
|
||||
[regex]::Split(([string]$resolvedTagLine[0]).Trim(), '\s+')
|
||||
)[0]
|
||||
if (-not [string]::Equals(
|
||||
$remoteTagSha,
|
||||
$ExpectedCommit,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw "Remote release tag $Tag no longer resolves to the certified commit"
|
||||
}
|
||||
}
|
||||
|
||||
function Get-ProtectedSignerIdentity {
|
||||
$approvedSubject = [string]$env:WINDOWS_CODESIGN_APPROVED_SUBJECT
|
||||
$approvedThumbprint = [string]$env:WINDOWS_CODESIGN_APPROVED_THUMBPRINT
|
||||
$hasApprovedSubject = -not [string]::IsNullOrWhiteSpace($approvedSubject)
|
||||
$hasApprovedThumbprint = -not [string]::IsNullOrWhiteSpace($approvedThumbprint)
|
||||
if ($hasApprovedSubject -eq $hasApprovedThumbprint) {
|
||||
throw 'Exactly one protected publication signer identity must be configured'
|
||||
}
|
||||
if ($hasApprovedSubject) {
|
||||
return [pscustomobject][ordered]@{
|
||||
mode = 'subject'
|
||||
subject = $approvedSubject
|
||||
thumbprint = $null
|
||||
}
|
||||
}
|
||||
|
||||
$normalizedThumbprint = ($approvedThumbprint -replace '\s', '').ToUpperInvariant()
|
||||
if ($normalizedThumbprint -notmatch '^[0-9A-F]{40}$') {
|
||||
throw 'Protected publication signer thumbprint is invalid'
|
||||
}
|
||||
return [pscustomobject][ordered]@{
|
||||
mode = 'thumbprint'
|
||||
subject = $null
|
||||
thumbprint = $normalizedThumbprint
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-ExpectedAuthenticodeSignature {
|
||||
param([System.IO.FileInfo]$Artifact, [object]$ExpectedIdentity)
|
||||
|
||||
$signature = Get-AuthenticodeSignature -FilePath $Artifact.FullName
|
||||
if ($signature.Status -ne 'Valid' -or
|
||||
[string]$signature.SignatureType -ne 'Authenticode' -or
|
||||
$null -eq $signature.SignerCertificate -or
|
||||
$null -eq $signature.TimeStamperCertificate) {
|
||||
throw "Artifact signature is no longer valid for the approved signer: $($Artifact.FullName)"
|
||||
}
|
||||
$signerSubject = [string]$signature.SignerCertificate.Subject
|
||||
$signerThumbprint = (
|
||||
[string]$signature.SignerCertificate.Thumbprint -replace '\s', ''
|
||||
).ToUpperInvariant()
|
||||
$matchesProtectedIdentity = if ($ExpectedIdentity.mode -ceq 'subject') {
|
||||
[string]::Equals(
|
||||
$signerSubject,
|
||||
[string]$ExpectedIdentity.subject,
|
||||
[System.StringComparison]::Ordinal
|
||||
)
|
||||
} else {
|
||||
[string]::Equals(
|
||||
$signerThumbprint,
|
||||
[string]$ExpectedIdentity.thumbprint,
|
||||
[System.StringComparison]::Ordinal
|
||||
)
|
||||
}
|
||||
if (-not $matchesProtectedIdentity) {
|
||||
throw "Artifact signature is no longer valid for the approved signer: $($Artifact.FullName)"
|
||||
}
|
||||
return [pscustomobject][ordered]@{
|
||||
subject = $signerSubject
|
||||
thumbprint = $signerThumbprint
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-ReceiptSignerIdentity {
|
||||
param([object]$ReceiptArtifact, [object]$ActualIdentity, [string]$Label)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace([string]$ReceiptArtifact.signerSubject) -or
|
||||
[string]::IsNullOrWhiteSpace([string]$ReceiptArtifact.signerThumbprint) -or
|
||||
-not [string]::Equals(
|
||||
[string]$ReceiptArtifact.signerSubject,
|
||||
[string]$ActualIdentity.subject,
|
||||
[System.StringComparison]::Ordinal
|
||||
) -or
|
||||
-not [string]::Equals(
|
||||
([string]$ReceiptArtifact.signerThumbprint -replace '\s', '').ToUpperInvariant(),
|
||||
[string]$ActualIdentity.thumbprint,
|
||||
[System.StringComparison]::Ordinal
|
||||
)) {
|
||||
throw "$Label signer identity does not match the certified Authenticode artifact"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-LifecycleReceiptSignerIdentities {
|
||||
param(
|
||||
[object]$Receipt,
|
||||
[object]$PreviousLiveIdentity,
|
||||
[object]$CandidateLiveIdentity
|
||||
)
|
||||
|
||||
Assert-ReceiptSignerIdentity `
|
||||
$Receipt.previousInstaller `
|
||||
$PreviousLiveIdentity `
|
||||
'Upgrade previous-installer receipt'
|
||||
Assert-ReceiptSignerIdentity `
|
||||
$Receipt.previousInstalledApp `
|
||||
$PreviousLiveIdentity `
|
||||
'Upgrade previous-installed-app receipt'
|
||||
Assert-ReceiptSignerIdentity `
|
||||
$Receipt.installer `
|
||||
$CandidateLiveIdentity `
|
||||
'Upgrade candidate-installer receipt'
|
||||
Assert-ReceiptSignerIdentity `
|
||||
$Receipt.installedApp `
|
||||
$CandidateLiveIdentity `
|
||||
'Upgrade candidate-installed-app receipt'
|
||||
}
|
||||
|
||||
function Assert-PassingWindowsCertificateReceipt {
|
||||
param([object]$Receipt, [string]$ExpectedMode, [string]$Label)
|
||||
|
||||
$schemaVersionIsIntegral = (
|
||||
$Receipt.schemaVersion -is [int] -or
|
||||
$Receipt.schemaVersion -is [long]
|
||||
)
|
||||
if (-not $schemaVersionIsIntegral -or
|
||||
[long]$Receipt.schemaVersion -ne 4 -or
|
||||
-not ($Receipt.certificationMode -is [string]) -or
|
||||
-not [string]::Equals(
|
||||
$Receipt.certificationMode,
|
||||
$ExpectedMode,
|
||||
[System.StringComparison]::Ordinal
|
||||
) -or
|
||||
-not ($Receipt.status -is [string]) -or
|
||||
-not [string]::Equals(
|
||||
$Receipt.status,
|
||||
'passed',
|
||||
[System.StringComparison]::Ordinal
|
||||
)) {
|
||||
throw "$Label is not a passing schema-v4 certificate"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-PassingWindowsCertificateCheck {
|
||||
param([object]$Receipt, [string]$CheckName, [string]$Label)
|
||||
|
||||
$check = $Receipt.checks.PSObject.Properties[$CheckName]
|
||||
if ($null -eq $check -or -not [bool]::True.Equals($check.Value)) {
|
||||
throw "$Label check did not pass: $CheckName"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-ExactReleaseAssets {
|
||||
param([object]$Release, [object[]]$ExpectedAssets)
|
||||
|
||||
$expectedNames = [string[]]@(
|
||||
$ExpectedAssets | ForEach-Object { [string]$_.Name }
|
||||
)
|
||||
$actualNames = [string[]]@(
|
||||
$Release.assets | ForEach-Object { [string]$_.name }
|
||||
)
|
||||
[System.Array]::Sort($expectedNames, [System.StringComparer]::Ordinal)
|
||||
[System.Array]::Sort($actualNames, [System.StringComparer]::Ordinal)
|
||||
if ($expectedNames.Count -ne $actualNames.Count) {
|
||||
throw 'Published release assets do not exactly match the certified artifact set'
|
||||
}
|
||||
for ($index = 0; $index -lt $expectedNames.Count; $index += 1) {
|
||||
if (-not [string]::Equals(
|
||||
$expectedNames[$index],
|
||||
$actualNames[$index],
|
||||
[System.StringComparison]::Ordinal
|
||||
)) {
|
||||
throw 'Published release assets do not exactly match the certified artifact set'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Set-ReadOnlyCreatedReleaseId {
|
||||
param([string]$Value)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($Value) -or
|
||||
$null -ne (
|
||||
Get-Variable -Name createdReleaseId -Scope Script `
|
||||
-ErrorAction SilentlyContinue
|
||||
)) {
|
||||
throw 'Created release identity must be set exactly once'
|
||||
}
|
||||
Set-Variable `
|
||||
-Name createdReleaseId `
|
||||
-Scope Script `
|
||||
-Value $Value `
|
||||
-Option ReadOnly
|
||||
}
|
||||
|
||||
function Assert-ReleaseIdentity {
|
||||
param(
|
||||
[object]$Release,
|
||||
[string]$ExpectedId,
|
||||
[string]$ExpectedTag,
|
||||
[bool]$ExpectedDraft,
|
||||
[string]$Label
|
||||
)
|
||||
|
||||
if ([string]::IsNullOrWhiteSpace($ExpectedId) -or
|
||||
[string]$Release.id -cne $ExpectedId -or
|
||||
[string]$Release.tagName -cne $ExpectedTag -or
|
||||
[string]$Release.name -cne "Waggle $ExpectedTag" -or
|
||||
-not ($Release.isDraft -is [bool]) -or
|
||||
-not [bool]::Equals($Release.isDraft, $ExpectedDraft) -or
|
||||
-not ($Release.isPrerelease -is [bool]) -or
|
||||
[bool]::True.Equals($Release.isPrerelease)) {
|
||||
throw "$Label identity does not match the newly-created release"
|
||||
}
|
||||
}
|
||||
|
||||
function New-ReleaseAssetManifest {
|
||||
param([System.IO.FileInfo[]]$Assets)
|
||||
|
||||
$manifest = @(
|
||||
foreach ($asset in $Assets) {
|
||||
if ($null -eq $asset -or
|
||||
-not $asset.Exists -or
|
||||
[System.IO.Path]::GetFileName($asset.Name) -cne $asset.Name) {
|
||||
throw 'Release asset manifest contains an invalid local file'
|
||||
}
|
||||
[pscustomobject]@{
|
||||
Name = [string]$asset.Name
|
||||
FullName = [string]$asset.FullName
|
||||
SizeBytes = [int64]$asset.Length
|
||||
Sha256 = (
|
||||
Get-FileHash -LiteralPath $asset.FullName -Algorithm SHA256
|
||||
).Hash.ToUpperInvariant()
|
||||
}
|
||||
}
|
||||
)
|
||||
for ($left = 0; $left -lt $manifest.Count; $left += 1) {
|
||||
for ($right = $left + 1; $right -lt $manifest.Count; $right += 1) {
|
||||
if ([string]::Equals(
|
||||
[string]$manifest[$left].Name,
|
||||
[string]$manifest[$right].Name,
|
||||
[System.StringComparison]::Ordinal
|
||||
)) {
|
||||
throw 'Release asset manifest contains a duplicate exact filename'
|
||||
}
|
||||
}
|
||||
}
|
||||
return $manifest
|
||||
}
|
||||
|
||||
function Assert-ReleaseAssetFileMatchesManifest {
|
||||
param(
|
||||
[string]$Path,
|
||||
[object]$ExpectedAsset,
|
||||
[string]$Label
|
||||
)
|
||||
|
||||
$asset = Get-Item -LiteralPath $Path -ErrorAction Stop
|
||||
$sha256 = (
|
||||
Get-FileHash -LiteralPath $asset.FullName -Algorithm SHA256
|
||||
).Hash
|
||||
if (-not ($asset -is [System.IO.FileInfo]) -or
|
||||
[string]$asset.Name -cne [string]$ExpectedAsset.Name -or
|
||||
[int64]$asset.Length -ne [int64]$ExpectedAsset.SizeBytes -or
|
||||
-not [string]::Equals(
|
||||
$sha256,
|
||||
[string]$ExpectedAsset.Sha256,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw "$Label does not match the immutable release-asset manifest"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-LocalReleaseAssetsUnchanged {
|
||||
param([object[]]$Manifest)
|
||||
|
||||
foreach ($expectedAsset in $Manifest) {
|
||||
Assert-ReleaseAssetFileMatchesManifest `
|
||||
([string]$expectedAsset.FullName) `
|
||||
$expectedAsset `
|
||||
"Local release asset $($expectedAsset.Name)"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-RemoteReleaseAssetContents {
|
||||
param(
|
||||
[string]$Tag,
|
||||
[object]$Release,
|
||||
[object[]]$Manifest,
|
||||
[string]$Stage
|
||||
)
|
||||
|
||||
Assert-ExactReleaseAssets $Release $Manifest
|
||||
Assert-LocalReleaseAssetsUnchanged $Manifest
|
||||
if ([string]::IsNullOrWhiteSpace([string]$env:RUNNER_TEMP)) {
|
||||
throw 'RUNNER_TEMP is required for remote release-asset verification'
|
||||
}
|
||||
$downloadRoot = Join-Path `
|
||||
([string]$env:RUNNER_TEMP) `
|
||||
"waggle-release-$Stage-$([guid]::NewGuid().ToString('N'))"
|
||||
New-Item -ItemType Directory -Path $downloadRoot -ErrorAction Stop |
|
||||
Out-Null
|
||||
try {
|
||||
gh release download $Tag --dir $downloadRoot
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Could not download $Stage release assets for byte verification"
|
||||
}
|
||||
$downloadedFiles = @(
|
||||
Get-ChildItem -LiteralPath $downloadRoot -File
|
||||
)
|
||||
if ($downloadedFiles.Count -ne $Manifest.Count) {
|
||||
throw "$Stage release download does not contain the exact asset count"
|
||||
}
|
||||
foreach ($expectedAsset in $Manifest) {
|
||||
Assert-ReleaseAssetFileMatchesManifest `
|
||||
(Join-Path $downloadRoot ([string]$expectedAsset.Name)) `
|
||||
$expectedAsset `
|
||||
"$Stage remote release asset $($expectedAsset.Name)"
|
||||
}
|
||||
} finally {
|
||||
if (Test-Path -LiteralPath $downloadRoot) {
|
||||
Remove-Item -LiteralPath $downloadRoot -Recurse -Force
|
||||
}
|
||||
}
|
||||
Assert-LocalReleaseAssetsUnchanged $Manifest
|
||||
}
|
||||
|
||||
function Assert-ManagedModelAndMemoryEvidence {
|
||||
param([object]$Receipt, [string]$Label)
|
||||
|
||||
if (-not [bool]::True.Equals($Receipt.managedModelVerified)) {
|
||||
throw "$Label does not prove a managed local-model chat completion"
|
||||
}
|
||||
if ([string]$Receipt.managedModelDigest -notmatch '^sha256:[0-9a-f]{64}$') {
|
||||
throw "$Label does not bind the managed model to an immutable manifest digest"
|
||||
}
|
||||
if (-not [string]::Equals(
|
||||
[string]$Receipt.certifiedTier,
|
||||
'FREE',
|
||||
[System.StringComparison]::Ordinal
|
||||
)) {
|
||||
throw "$Label does not certify the Solo/FREE tier"
|
||||
}
|
||||
if ([string]::IsNullOrWhiteSpace(
|
||||
[string]$Receipt.lifecycleData.workspaceId
|
||||
) -or
|
||||
[long]$Receipt.lifecycleData.personalFrameId -lt 1 -or
|
||||
[long]$Receipt.lifecycleData.workspaceFrameId -lt 1 -or
|
||||
[int]$Receipt.lifecycleData.preUninstallManifestEntryCount -lt 1 -or
|
||||
[string]$Receipt.lifecycleData.preUninstallManifestSha256 -notmatch '^[0-9A-F]{64}$' -or
|
||||
[string]$Receipt.lifecycleData.postUninstallManifestSha256 -notmatch '^[0-9A-F]{64}$' -or
|
||||
-not [string]::Equals(
|
||||
[string]$Receipt.lifecycleData.preUninstallManifestSha256,
|
||||
[string]$Receipt.lifecycleData.postUninstallManifestSha256,
|
||||
[System.StringComparison]::Ordinal
|
||||
)) {
|
||||
throw "$Label does not bind real workspace and memory data to an unchanged uninstall manifest"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-ReceiptSourceHashes {
|
||||
param(
|
||||
[object[]]$Receipts,
|
||||
[System.IO.FileInfo]$Installer,
|
||||
[string]$SourceRevision
|
||||
)
|
||||
|
||||
$currentCertifierHash = (
|
||||
Get-FileHash -LiteralPath 'scripts/certify-windows-installer.ps1' `
|
||||
-Algorithm SHA256
|
||||
).Hash
|
||||
$currentHookHash = (
|
||||
Get-FileHash -LiteralPath 'app/src-tauri/nsis/installer.nsi' `
|
||||
-Algorithm SHA256
|
||||
).Hash
|
||||
$releaseDirectory = Split-Path -Parent (
|
||||
Split-Path -Parent $Installer.DirectoryName
|
||||
)
|
||||
$generatedInstallerScripts = @(
|
||||
Get-ChildItem -LiteralPath (Join-Path $releaseDirectory 'nsis') `
|
||||
-Recurse -Filter 'installer.nsi' -File
|
||||
)
|
||||
if ($generatedInstallerScripts.Count -ne 1) {
|
||||
throw 'Could not uniquely resolve generated NSIS source during publication'
|
||||
}
|
||||
$currentGeneratedInstallerHash = (
|
||||
Get-FileHash -LiteralPath $generatedInstallerScripts[0].FullName `
|
||||
-Algorithm SHA256
|
||||
).Hash
|
||||
$currentSidecarPath = 'app/src-tauri/resources/service.js'
|
||||
if (-not (Test-Path -LiteralPath $currentSidecarPath -PathType Leaf)) {
|
||||
throw 'Current source-bound sidecar bundle is missing during publication'
|
||||
}
|
||||
$currentSidecarBytes = [System.IO.File]::ReadAllBytes(
|
||||
(Get-Item -LiteralPath $currentSidecarPath).FullName
|
||||
)
|
||||
$sidecarNewlineIndex = [Array]::IndexOf($currentSidecarBytes, [byte]10)
|
||||
if ($sidecarNewlineIndex -le 0) {
|
||||
throw 'Current sidecar bundle is missing embedded provenance'
|
||||
}
|
||||
$sidecarFirstLine = [System.Text.Encoding]::UTF8.GetString(
|
||||
$currentSidecarBytes,
|
||||
0,
|
||||
$sidecarNewlineIndex
|
||||
)
|
||||
$sidecarPrefix = '// Waggle-Sidecar-Provenance: '
|
||||
if (-not $sidecarFirstLine.StartsWith($sidecarPrefix, [System.StringComparison]::Ordinal)) {
|
||||
throw 'Current sidecar bundle is missing embedded provenance'
|
||||
}
|
||||
$sidecarEncodedProvenance = $sidecarFirstLine.Substring($sidecarPrefix.Length)
|
||||
try {
|
||||
$sidecarProvenanceBytes = [Convert]::FromBase64String($sidecarEncodedProvenance)
|
||||
if (-not [string]::Equals(
|
||||
[Convert]::ToBase64String($sidecarProvenanceBytes),
|
||||
$sidecarEncodedProvenance,
|
||||
[System.StringComparison]::Ordinal
|
||||
)) {
|
||||
throw 'non-canonical provenance'
|
||||
}
|
||||
$sidecarManifest = [System.Text.Encoding]::UTF8.GetString($sidecarProvenanceBytes) |
|
||||
ConvertFrom-Json
|
||||
} catch {
|
||||
throw 'Current sidecar bundle has invalid embedded provenance'
|
||||
}
|
||||
if (-not [string]::Equals(
|
||||
[string]$sidecarManifest.sourceRevision,
|
||||
$SourceRevision,
|
||||
[System.StringComparison]::Ordinal
|
||||
) -or @($sidecarManifest.sourceInputs).Count -lt 4) {
|
||||
throw 'Current sidecar provenance does not bind the publication source revision'
|
||||
}
|
||||
$sidecarProvenanceHasher = [System.Security.Cryptography.SHA256]::Create()
|
||||
try {
|
||||
$currentSidecarProvenanceHash = (
|
||||
[System.BitConverter]::ToString(
|
||||
$sidecarProvenanceHasher.ComputeHash($sidecarProvenanceBytes)
|
||||
)
|
||||
).Replace('-', '')
|
||||
} finally {
|
||||
$sidecarProvenanceHasher.Dispose()
|
||||
}
|
||||
$currentSidecarHash = (
|
||||
Get-FileHash -LiteralPath $currentSidecarPath -Algorithm SHA256
|
||||
).Hash
|
||||
$currentSidecarSourceInputCount = @($sidecarManifest.sourceInputs).Count
|
||||
|
||||
foreach ($certificateData in $Receipts) {
|
||||
if (-not [string]::Equals(
|
||||
$currentCertifierHash,
|
||||
[string]$certificateData.evidence.certifierSha256,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
) -or
|
||||
-not [string]::Equals(
|
||||
$currentHookHash,
|
||||
[string]$certificateData.evidence.installerHookSha256,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
) -or
|
||||
-not [string]::Equals(
|
||||
$currentGeneratedInstallerHash,
|
||||
[string]$certificateData.evidence.generatedInstallerScriptSha256,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
) -or
|
||||
-not [string]::Equals(
|
||||
$currentSidecarHash,
|
||||
[string]$certificateData.evidence.sidecarBundleSha256,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
) -or
|
||||
-not [string]::Equals(
|
||||
$currentSidecarProvenanceHash,
|
||||
[string]$certificateData.evidence.sidecarProvenanceSha256,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
) -or
|
||||
-not [string]::Equals(
|
||||
$SourceRevision,
|
||||
[string]$certificateData.evidence.sidecarSourceRevision,
|
||||
[System.StringComparison]::Ordinal
|
||||
) -or
|
||||
[int]$certificateData.evidence.sidecarSourceInputCount -ne
|
||||
$currentSidecarSourceInputCount
|
||||
) {
|
||||
throw 'Lifecycle receipt source hashes do not match the release checkout'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-ReleaseDoesNotExist {
|
||||
param([string]$Tag)
|
||||
|
||||
$probeOutput = @(gh release view $Tag --json id 2>&1)
|
||||
$probeExitCode = $LASTEXITCODE
|
||||
if ($probeExitCode -eq 0) {
|
||||
throw "Refusing to use a pre-existing release: $Tag"
|
||||
}
|
||||
$probeText = [string]::Join("`n", [string[]]$probeOutput)
|
||||
if ($probeText -notmatch '(?i)(release not found|HTTP\s+404)') {
|
||||
throw "Could not prove that release $Tag does not already exist"
|
||||
}
|
||||
}
|
||||
|
||||
function Assert-PublicationTagBindings {
|
||||
param([string]$ReleaseMode, [string]$Tag, [string]$Commit)
|
||||
|
||||
if ($ReleaseMode -ceq 'upgrade') {
|
||||
Assert-RemoteTagCommit `
|
||||
([string]$env:WINDOWS_UPGRADE_BASE_TAG) `
|
||||
([string]$env:WAGGLE_UPGRADE_BASE_COMMIT)
|
||||
}
|
||||
Assert-RemoteTagCommit $Tag $Commit
|
||||
}
|
||||
|
||||
if (-not [string]::Equals(
|
||||
$Mode,
|
||||
[string]$env:WAGGLE_RELEASE_MODE,
|
||||
[System.StringComparison]::Ordinal
|
||||
)) {
|
||||
throw 'Publisher mode does not match the resolved Windows release mode'
|
||||
}
|
||||
|
||||
$tag = [string]$env:GITHUB_REF_NAME
|
||||
$sourceRevision = [string]$env:GITHUB_SHA
|
||||
if ($tag -notmatch '^v\d+\.\d+\.\d+$' -or
|
||||
$sourceRevision -cnotmatch '^[0-9a-f]{40}$') {
|
||||
throw 'Publication requires an exact release tag and lowercase commit identity'
|
||||
}
|
||||
|
||||
$baselineInputs = @(
|
||||
[string]$env:WINDOWS_UPGRADE_BASE_TAG,
|
||||
[string]$env:WINDOWS_UPGRADE_BASE_ASSET_NAME,
|
||||
[string]$env:WINDOWS_UPGRADE_BASE_SHA256,
|
||||
[string]$env:WINDOWS_UPGRADE_BASE_COMMIT
|
||||
)
|
||||
if (@($baselineInputs | Where-Object { $_ -ne $_.Trim() }).Count -gt 0) {
|
||||
throw 'Protected Windows upgrade-baseline inputs must not contain surrounding whitespace'
|
||||
}
|
||||
|
||||
if ($Mode -ceq 'bootstrap') {
|
||||
$expectedBootstrapIdentity = "v0.2.0@$env:GITHUB_SHA"
|
||||
if ($tag -cne 'v0.2.0' -or
|
||||
-not [string]::Equals(
|
||||
[string]$env:WINDOWS_BOOTSTRAP_RELEASE_IDENTITY,
|
||||
$expectedBootstrapIdentity,
|
||||
[System.StringComparison]::Ordinal
|
||||
) -or
|
||||
@($baselineInputs | Where-Object {
|
||||
-not [string]::IsNullOrEmpty($_)
|
||||
}).Count -ne 0) {
|
||||
throw 'Bootstrap publication is not bound to the exact authorized v0.2.0 release'
|
||||
}
|
||||
} else {
|
||||
if (-not [string]::IsNullOrEmpty(
|
||||
[string]$env:WINDOWS_BOOTSTRAP_RELEASE_IDENTITY
|
||||
) -or
|
||||
@($baselineInputs | Where-Object {
|
||||
[string]::IsNullOrEmpty($_)
|
||||
}).Count -ne 0) {
|
||||
throw 'Upgrade publication requires an empty bootstrap authorization and all baseline inputs'
|
||||
}
|
||||
}
|
||||
|
||||
$installers = @(
|
||||
Get-ChildItem -LiteralPath 'app/src-tauri/target' -Recurse `
|
||||
-Filter '*-setup.exe' -File |
|
||||
Where-Object { $_.DirectoryName -match '[\\/]bundle[\\/]nsis$' }
|
||||
)
|
||||
if ($installers.Count -ne 1) {
|
||||
throw "Expected exactly one certified NSIS installer, found $($installers.Count)"
|
||||
}
|
||||
$installer = $installers[0]
|
||||
$cleanReceipt = Get-Item -LiteralPath (
|
||||
Join-Path $installer.DirectoryName 'windows-installer-certificate.json'
|
||||
)
|
||||
$upgradeReceiptPath = Join-Path `
|
||||
$installer.DirectoryName `
|
||||
'windows-installer-upgrade-certificate.json'
|
||||
if ($Mode -ceq 'bootstrap' -and
|
||||
(Test-Path -LiteralPath $upgradeReceiptPath)) {
|
||||
throw 'Bootstrap publication found an unexpected upgrade certificate'
|
||||
}
|
||||
|
||||
$cleanReceiptData = Get-Content -Raw -LiteralPath $cleanReceipt.FullName |
|
||||
ConvertFrom-Json
|
||||
Assert-PassingWindowsCertificateReceipt `
|
||||
$cleanReceiptData 'same-version-repair' 'Windows clean-install receipt'
|
||||
|
||||
if (-not [string]::Equals(
|
||||
[string]$cleanReceiptData.evidence.sourceRevision,
|
||||
$sourceRevision,
|
||||
[System.StringComparison]::Ordinal
|
||||
) -or
|
||||
-not [string]::Equals(
|
||||
[string]$cleanReceiptData.installer.name,
|
||||
$installer.Name,
|
||||
[System.StringComparison]::Ordinal
|
||||
) -or
|
||||
[int64]$cleanReceiptData.installer.sizeBytes -ne $installer.Length) {
|
||||
throw 'Clean-install receipt identity does not match the release artifact and commit'
|
||||
}
|
||||
|
||||
$candidateVersion = [string](
|
||||
Get-Content -Raw -LiteralPath 'app/src-tauri/tauri.conf.json' |
|
||||
ConvertFrom-Json
|
||||
).version
|
||||
if ($candidateVersion -notmatch '^\d+\.\d+\.\d+$' -or
|
||||
$tag -cne "v$candidateVersion" -or
|
||||
[string]$env:WAGGLE_CERTIFIED_CANDIDATE_VERSION -cne $candidateVersion -or
|
||||
($Mode -ceq 'bootstrap' -and $candidateVersion -cne '0.2.0')) {
|
||||
throw 'Published candidate version is not independently bound to the release tag'
|
||||
}
|
||||
|
||||
$installerSha256 = (
|
||||
Get-FileHash -LiteralPath $installer.FullName -Algorithm SHA256
|
||||
).Hash
|
||||
if (-not [string]::Equals(
|
||||
$installerSha256,
|
||||
[string]$env:WAGGLE_CERTIFIED_CANDIDATE_SHA256,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
) -or
|
||||
-not [string]::Equals(
|
||||
$installerSha256,
|
||||
[string]$cleanReceiptData.installer.sha256,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw 'Certified installer hash no longer matches the clean receipt and pre-publication output'
|
||||
}
|
||||
|
||||
$approvedSignerIdentity = Get-ProtectedSignerIdentity
|
||||
$installerSignerIdentity = Assert-ExpectedAuthenticodeSignature $installer $approvedSignerIdentity
|
||||
if ($cleanReceiptData.installer.authenticodeStatus -ne 'Valid' -or
|
||||
$cleanReceiptData.installedApp.authenticodeStatus -ne 'Valid' -or
|
||||
$cleanReceiptData.installer.signatureType -ne 'Authenticode' -or
|
||||
$cleanReceiptData.installedApp.signatureType -ne 'Authenticode' -or
|
||||
[string]::IsNullOrWhiteSpace(
|
||||
[string]$cleanReceiptData.installer.timestampAuthorityThumbprint
|
||||
) -or
|
||||
[string]::IsNullOrWhiteSpace(
|
||||
[string]$cleanReceiptData.installedApp.timestampAuthorityThumbprint
|
||||
)) {
|
||||
throw 'Clean-install receipt does not preserve valid signer and timestamp evidence'
|
||||
}
|
||||
Assert-ReceiptSignerIdentity `
|
||||
$cleanReceiptData.installer $installerSignerIdentity 'Clean-install installer receipt'
|
||||
Assert-ReceiptSignerIdentity `
|
||||
$cleanReceiptData.installedApp $installerSignerIdentity 'Clean-install installed-app receipt'
|
||||
Assert-ManagedModelAndMemoryEvidence `
|
||||
$cleanReceiptData `
|
||||
'Clean-install receipt'
|
||||
|
||||
$cleanRequiredChecks = @(
|
||||
'sourceRevision', 'sourceFilesClean', 'sidecarSourceProvenance',
|
||||
'generatedInstallerInclude',
|
||||
'profileDataDeletionAbsent', 'baseAppDataDeletionNeutralized',
|
||||
'authenticodeSignature', 'authenticodeSigner', 'authenticodeTimestamp',
|
||||
'installedAppAuthenticodeSignature', 'installedAppAuthenticodeSigner',
|
||||
'installedAppAuthenticodeTimestamp', 'silentInstall',
|
||||
'vaultKeyAclRestricted', 'windowsInboxTools', 'noModelChatSetupRequired',
|
||||
'soloTier', 'dockerIndependentRuntimePrerequisites',
|
||||
'managedRuntimeBootstrap', 'managedModelPull', 'managedModelChat',
|
||||
'managedModelProxyRestartChat', 'managedRuntimeCleanup',
|
||||
'sameVersionRepair', 'repairSoloTier', 'repairManagedModelDigestPreserved',
|
||||
'relaunchAfterRepair', 'silentUninstall', 'defaultProfileDataDir',
|
||||
'realWorkspaceAndMemorySeeded', 'repairRealWorkspaceAndMemoryPreserved',
|
||||
'uninstallRealWorkspaceAndMemoryPreserved', 'configuredDataDirPreserved',
|
||||
'profileDataPathPreserved', 'certificateProfileCleanup',
|
||||
'externalProfileRootsUnchanged', 'environmentRestored'
|
||||
)
|
||||
foreach ($checkName in $cleanRequiredChecks) {
|
||||
Assert-PassingWindowsCertificateCheck `
|
||||
$cleanReceiptData $checkName 'Clean-install receipt'
|
||||
}
|
||||
|
||||
$receiptDataSet = @($cleanReceiptData)
|
||||
if ($Mode -ceq 'bootstrap') {
|
||||
$releaseAssets = @($installer, $cleanReceipt)
|
||||
} else {
|
||||
$upgradeReceipt = Get-Item -LiteralPath $upgradeReceiptPath
|
||||
$receiptData = Get-Content -Raw -LiteralPath $upgradeReceipt.FullName |
|
||||
ConvertFrom-Json
|
||||
Assert-PassingWindowsCertificateReceipt `
|
||||
$receiptData 'version-to-version-upgrade' 'Windows lifecycle receipt'
|
||||
|
||||
if (-not [string]::Equals(
|
||||
[string]$receiptData.evidence.sourceRevision,
|
||||
$sourceRevision,
|
||||
[System.StringComparison]::Ordinal
|
||||
) -or
|
||||
-not [string]::Equals(
|
||||
[string]$receiptData.installer.name,
|
||||
$installer.Name,
|
||||
[System.StringComparison]::Ordinal
|
||||
) -or
|
||||
[int64]$receiptData.installer.sizeBytes -ne $installer.Length -or
|
||||
-not [string]::Equals(
|
||||
$installerSha256,
|
||||
[string]$receiptData.installer.sha256,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw 'Upgrade receipt does not match the release artifact and commit'
|
||||
}
|
||||
|
||||
$baseInstaller = Get-Item -LiteralPath (
|
||||
[string]$env:WAGGLE_UPGRADE_BASE_INSTALLER_PATH
|
||||
)
|
||||
$baseSha256 = (
|
||||
Get-FileHash -LiteralPath $baseInstaller.FullName -Algorithm SHA256
|
||||
).Hash
|
||||
if (-not [string]::Equals(
|
||||
[string]$env:WAGGLE_UPGRADE_BASE_COMMIT,
|
||||
[string]$env:WINDOWS_UPGRADE_BASE_COMMIT,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw 'Certified Windows upgrade baseline commit no longer matches the protected commit'
|
||||
}
|
||||
Assert-RemoteTagCommit `
|
||||
([string]$env:WINDOWS_UPGRADE_BASE_TAG) `
|
||||
([string]$env:WAGGLE_UPGRADE_BASE_COMMIT)
|
||||
if ([string]$env:WINDOWS_UPGRADE_BASE_TAG -notmatch '^v(?<version>\d+\.\d+\.\d+)$' -or
|
||||
$Matches['version'] -cne [string]$receiptData.upgrade.previousVersion -or
|
||||
$Matches['version'] -cne [string]$env:WAGGLE_UPGRADE_BASE_VERSION -or
|
||||
[string]$receiptData.upgrade.previousSourceRevision -cne
|
||||
[string]$env:WAGGLE_UPGRADE_BASE_COMMIT -or
|
||||
[string]$receiptData.upgrade.candidateVersion -cne $candidateVersion -or
|
||||
[string]$receiptData.upgrade.observedPreviousVersion -cne
|
||||
[string]$receiptData.upgrade.previousVersion -or
|
||||
[string]$receiptData.upgrade.observedCandidateVersion -cne
|
||||
[string]$receiptData.upgrade.candidateVersion) {
|
||||
throw 'Lifecycle receipt does not bind the observed upgrade versions to the protected release versions'
|
||||
}
|
||||
if ($baseInstaller.Name -cne [string]$env:WINDOWS_UPGRADE_BASE_ASSET_NAME -or
|
||||
[string]$receiptData.previousInstaller.name -cne $baseInstaller.Name -or
|
||||
[int64]$receiptData.previousInstaller.sizeBytes -ne $baseInstaller.Length -or
|
||||
-not [string]::Equals(
|
||||
$baseSha256,
|
||||
[string]$env:WINDOWS_UPGRADE_BASE_SHA256,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
) -or
|
||||
-not [string]::Equals(
|
||||
$baseSha256,
|
||||
[string]$receiptData.previousInstaller.sha256,
|
||||
[System.StringComparison]::OrdinalIgnoreCase
|
||||
)) {
|
||||
throw 'Lifecycle receipt previous installer does not match the protected upgrade baseline'
|
||||
}
|
||||
if ($receiptData.installer.authenticodeStatus -ne 'Valid' -or
|
||||
$receiptData.installedApp.authenticodeStatus -ne 'Valid' -or
|
||||
$receiptData.previousInstaller.authenticodeStatus -ne 'Valid' -or
|
||||
$receiptData.previousInstalledApp.authenticodeStatus -ne 'Valid' -or
|
||||
$receiptData.installer.signatureType -ne 'Authenticode' -or
|
||||
$receiptData.installedApp.signatureType -ne 'Authenticode' -or
|
||||
$receiptData.previousInstaller.signatureType -ne 'Authenticode' -or
|
||||
$receiptData.previousInstalledApp.signatureType -ne 'Authenticode') {
|
||||
throw 'Lifecycle receipt does not contain valid previous and candidate signatures'
|
||||
}
|
||||
$baseInstallerSignerIdentity = Assert-ExpectedAuthenticodeSignature $baseInstaller $approvedSignerIdentity
|
||||
Assert-LifecycleReceiptSignerIdentities `
|
||||
$receiptData $baseInstallerSignerIdentity $installerSignerIdentity
|
||||
foreach ($timestampThumbprint in @(
|
||||
$receiptData.previousInstaller.timestampAuthorityThumbprint,
|
||||
$receiptData.previousInstalledApp.timestampAuthorityThumbprint,
|
||||
$receiptData.installer.timestampAuthorityThumbprint,
|
||||
$receiptData.installedApp.timestampAuthorityThumbprint
|
||||
)) {
|
||||
if ([string]::IsNullOrWhiteSpace([string]$timestampThumbprint)) {
|
||||
throw 'Lifecycle receipt is missing a validated Authenticode timestamp'
|
||||
}
|
||||
}
|
||||
Assert-ManagedModelAndMemoryEvidence $receiptData 'Upgrade receipt'
|
||||
if ([string]::IsNullOrWhiteSpace(
|
||||
[string]$receiptData.upgrade.managedModelName
|
||||
) -or
|
||||
[string]$receiptData.upgrade.managedModelDigest -notmatch
|
||||
'^sha256:[0-9a-f]{64}$' -or
|
||||
-not [string]::Equals(
|
||||
[string]$receiptData.upgrade.managedModelName,
|
||||
[string]$receiptData.managedModel.name,
|
||||
[System.StringComparison]::Ordinal
|
||||
) -or
|
||||
-not [string]::Equals(
|
||||
[string]$receiptData.upgrade.managedModelDigest,
|
||||
[string]$receiptData.managedModelDigest,
|
||||
[System.StringComparison]::Ordinal
|
||||
)) {
|
||||
throw 'Upgrade receipt does not preserve the previous managed-model identity and digest'
|
||||
}
|
||||
|
||||
$upgradeRequiredChecks = @(
|
||||
'sourceRevision', 'sourceFilesClean', 'sidecarSourceProvenance',
|
||||
'generatedInstallerInclude',
|
||||
'profileDataDeletionAbsent', 'baseAppDataDeletionNeutralized',
|
||||
'authenticodeSignature', 'authenticodeSigner', 'authenticodeTimestamp',
|
||||
'installedAppAuthenticodeSignature', 'installedAppAuthenticodeSigner',
|
||||
'installedAppAuthenticodeTimestamp', 'previousInstallerHash',
|
||||
'previousInstallerAuthenticodeSignature',
|
||||
'previousInstallerAuthenticodeSigner',
|
||||
'previousInstallerAuthenticodeTimestamp',
|
||||
'previousInstalledAppAuthenticodeSignature',
|
||||
'previousInstalledAppAuthenticodeSigner',
|
||||
'previousInstalledAppAuthenticodeTimestamp', 'candidateInstallerHash',
|
||||
'versionOrder', 'previousVersion', 'candidateVersion', 'previousInstall',
|
||||
'previousLaunch', 'sameApprovedSigner', 'versionToVersionUpgrade',
|
||||
'upgradeSameInstallDirectory', 'upgradeConfiguredDataPreserved',
|
||||
'upgradeProfileDataPreserved', 'upgradeVaultKeyPreserved',
|
||||
'upgradeRegistrations', 'candidateLaunch', 'relaunchAfterUpgrade',
|
||||
'silentInstall', 'vaultKeyAclRestricted', 'windowsInboxTools', 'soloTier',
|
||||
'previousSoloTier', 'dockerIndependentRuntimePrerequisites',
|
||||
'managedRuntimeBootstrap', 'managedModelPull', 'managedModelChat',
|
||||
'managedModelProxyRestartChat', 'managedRuntimeCleanup',
|
||||
'previousManagedModelSeeded', 'upgradeManagedModelPreserved',
|
||||
'sameVersionRepair', 'repairSoloTier', 'repairManagedModelDigestPreserved',
|
||||
'relaunchAfterRepair', 'silentUninstall', 'defaultProfileDataDir',
|
||||
'realWorkspaceAndMemorySeeded', 'upgradeRealWorkspaceAndMemoryPreserved',
|
||||
'repairRealWorkspaceAndMemoryPreserved',
|
||||
'uninstallRealWorkspaceAndMemoryPreserved',
|
||||
'configuredDataDirPreserved', 'profileDataPathPreserved',
|
||||
'certificateProfileCleanup', 'externalProfileRootsUnchanged', 'environmentRestored'
|
||||
)
|
||||
foreach ($checkName in $upgradeRequiredChecks) {
|
||||
Assert-PassingWindowsCertificateCheck `
|
||||
$receiptData $checkName 'Upgrade receipt'
|
||||
}
|
||||
$receiptDataSet = @($cleanReceiptData, $receiptData)
|
||||
$releaseAssets = @($installer, $cleanReceipt, $upgradeReceipt)
|
||||
}
|
||||
|
||||
$nonPassingChecks = @(
|
||||
foreach ($certificateData in $receiptDataSet) {
|
||||
$certificateData.checks.PSObject.Properties |
|
||||
Where-Object { -not [bool]::True.Equals($_.Value) }
|
||||
}
|
||||
)
|
||||
if ($nonPassingChecks.Count -gt 0) {
|
||||
throw "Lifecycle receipts contain non-passing checks: $($nonPassingChecks.Name -join ', ')"
|
||||
}
|
||||
|
||||
$releaseAssetManifest = @(New-ReleaseAssetManifest $releaseAssets)
|
||||
Assert-LocalReleaseAssetsUnchanged $releaseAssetManifest
|
||||
Assert-ReceiptSourceHashes $receiptDataSet $installer $sourceRevision
|
||||
Assert-PublicationTagBindings $Mode $tag $sourceRevision
|
||||
Assert-ReleaseDoesNotExist $tag
|
||||
|
||||
gh release create $tag --verify-tag --draft --title "Waggle $tag" `
|
||||
--notes 'See the release notes for details.'
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Could not create a dedicated $Mode draft release: $tag"
|
||||
}
|
||||
$releaseData = gh release view $tag `
|
||||
--json id,isDraft,isPrerelease,tagName,name,assets |
|
||||
ConvertFrom-Json
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Could not inspect newly-created $Mode draft release $tag"
|
||||
}
|
||||
Set-ReadOnlyCreatedReleaseId ([string]$releaseData.id)
|
||||
Assert-ReleaseIdentity `
|
||||
$releaseData $createdReleaseId $tag $true 'Newly-created draft release'
|
||||
if (@($releaseData.assets).Count -ne 0) {
|
||||
throw "Could not verify newly-created $Mode draft release $tag"
|
||||
}
|
||||
|
||||
Assert-PublicationTagBindings $Mode $tag $sourceRevision
|
||||
$releaseAssetPaths = [string[]]@(
|
||||
$releaseAssetManifest | ForEach-Object { $_.FullName }
|
||||
)
|
||||
gh release upload $tag @releaseAssetPaths
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Could not upload certified Windows $Mode assets to $tag"
|
||||
}
|
||||
$uploadedRelease = gh release view $tag `
|
||||
--json id,isDraft,isPrerelease,tagName,name,assets |
|
||||
ConvertFrom-Json
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Could not verify uploaded $Mode draft $tag"
|
||||
}
|
||||
Assert-ReleaseIdentity `
|
||||
$uploadedRelease $createdReleaseId $tag $true 'Uploaded draft release'
|
||||
Assert-ExactReleaseAssets $uploadedRelease $releaseAssets
|
||||
Assert-RemoteReleaseAssetContents $tag $uploadedRelease $releaseAssetManifest 'uploaded'
|
||||
|
||||
Assert-PublicationTagBindings $Mode $tag $sourceRevision
|
||||
gh release edit $tag --draft=false --prerelease=false
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Could not publish certified Windows $Mode release $tag"
|
||||
}
|
||||
$publishedRelease = gh release view $tag `
|
||||
--json id,isDraft,isPrerelease,tagName,name,assets |
|
||||
ConvertFrom-Json
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Published Windows $Mode release $tag is not final"
|
||||
}
|
||||
Assert-ReleaseIdentity `
|
||||
$publishedRelease $createdReleaseId $tag $false 'Published release'
|
||||
Assert-ExactReleaseAssets $publishedRelease $releaseAssets
|
||||
Assert-RemoteReleaseAssetContents $tag $publishedRelease $releaseAssetManifest 'published'
|
||||
707
scripts/qualify-smart-router.test.ts
Normal file
707
scripts/qualify-smart-router.test.ts
Normal file
@@ -0,0 +1,707 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { createServer } from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { describe, it } from 'vitest';
|
||||
import { CostTracker } from '../packages/agent/src/cost-tracker.js';
|
||||
import { MindDB } from '../packages/hive-mind-core/src/mind/db.js';
|
||||
import { ExecutionTraceStore } from '../packages/hive-mind-core/src/mind/execution-traces.js';
|
||||
import {
|
||||
assertQualifiedChatCase,
|
||||
assertQualifiedToolContextCase,
|
||||
assertCopiedModelIdentity,
|
||||
assertObservedDispatch,
|
||||
assertObservedProviderUsage,
|
||||
assertRuntimeStartOwned,
|
||||
assertSourceSnapshot,
|
||||
aliasesForOwnedCleanup,
|
||||
BUDGET_PROMPT,
|
||||
buildOwnedProxyTarget,
|
||||
buildRouterSettings,
|
||||
buildSanitizedEnvironment,
|
||||
canonicalizeManifestDigest,
|
||||
extractDispatchedToolNames,
|
||||
extractProviderUsage,
|
||||
parseSse,
|
||||
partitionWindowsProcesses,
|
||||
postJsonForStatus,
|
||||
requestSidecarSessionToken,
|
||||
recordAliasBeforeCopy,
|
||||
seedQualificationDailySpend,
|
||||
startAuditProxy,
|
||||
} from './qualify-smart-router.js';
|
||||
import { routeMessage } from '../packages/agent/src/smart-router.js';
|
||||
|
||||
describe('qualify-smart-router helpers', () => {
|
||||
it('seeds qualification spend through the durable ledger before server startup', async () => {
|
||||
const dataDir = await mkdtemp(path.join(os.tmpdir(), 'waggle-router-spend-seed-'));
|
||||
const timestamp = new Date().toISOString();
|
||||
const day = timestamp.slice(0, 10);
|
||||
try {
|
||||
await seedQualificationDailySpend(dataDir, 0.8, timestamp);
|
||||
const db = new MindDB(path.join(dataDir, 'personal.mind'));
|
||||
try {
|
||||
const persisted = new ExecutionTraceStore(db).getTotalCostSince(`${day}T00:00:00.000Z`);
|
||||
assert.equal(persisted, 0.8);
|
||||
const tracker = new CostTracker({});
|
||||
tracker.initializeDailyCarryover(day, persisted);
|
||||
assert.equal(tracker.getDailyTotal(), 0.8);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
} finally {
|
||||
await rm(dataDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('binds session bootstrap to the exact dynamic sidecar authority', async () => {
|
||||
const baseUrl = 'http://127.0.0.1:49152';
|
||||
const fetchImpl = async (input: string | URL, init?: RequestInit) => {
|
||||
assert.equal(String(input), `${baseUrl}/api/auth/session-token`);
|
||||
const headers = new Headers(init?.headers);
|
||||
assert.equal(headers.get('origin'), baseUrl);
|
||||
assert.equal(headers.get('sec-fetch-site'), 'same-origin');
|
||||
return new Response(JSON.stringify({ token: 'session-token' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
const response = await requestSidecarSessionToken(baseUrl, fetchImpl);
|
||||
assert.equal(response.token, 'session-token');
|
||||
});
|
||||
|
||||
it('strictly parses JSON SSE events', () => {
|
||||
const events = parseSse([
|
||||
'event: token',
|
||||
'data: {"content":"hello"}',
|
||||
'',
|
||||
'event: model_switch',
|
||||
'data: {"model":"ollama/fallback","reason":"primary unavailable; configured fallback selected","primary":"ollama/primary"}',
|
||||
'',
|
||||
'event: done',
|
||||
'data: {"content":"hello","model":"ollama/fallback","toolsUsed":[]}',
|
||||
'',
|
||||
].join('\r\n'));
|
||||
|
||||
assert.deepEqual(events, [
|
||||
{ event: 'token', data: { content: 'hello' } },
|
||||
{
|
||||
event: 'model_switch',
|
||||
data: {
|
||||
model: 'ollama/fallback',
|
||||
reason: 'primary unavailable; configured fallback selected',
|
||||
primary: 'ollama/primary',
|
||||
},
|
||||
},
|
||||
{ event: 'done', data: { content: 'hello', model: 'ollama/fallback', toolsUsed: [] } },
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects malformed or ambiguous SSE frames', () => {
|
||||
assert.throws(() => parseSse('data: {"content":"orphan"}\n\n'), /event field/i);
|
||||
assert.throws(() => parseSse('event: done\nevent: done\ndata: {}\n\n'), /duplicate event/i);
|
||||
assert.throws(() => parseSse('event: done\ndata: not-json\n\n'), /invalid JSON/i);
|
||||
assert.throws(() => parseSse('event: done\nunknown: value\n\n'), /unsupported SSE field/i);
|
||||
});
|
||||
|
||||
it('requires HTTP SSE success, one done, content, expected model, and exact switch policy', () => {
|
||||
const runtimeMetrics = {
|
||||
estimatedSystemPromptTokens: 7_437,
|
||||
providerInputTokens: 8_005,
|
||||
providerOutputTokens: 244,
|
||||
timeToFirstTokenMs: 5_497,
|
||||
agentLatencyMs: 6_102,
|
||||
totalServerLatencyMs: 6_198,
|
||||
};
|
||||
const rawSse = [
|
||||
'event: tool',
|
||||
'data: {"name":"auto_recall","input":{"query":"test"}}',
|
||||
'',
|
||||
'event: tool_result',
|
||||
'data: {"name":"auto_recall","result":"No relevant memories found","isError":false}',
|
||||
'',
|
||||
'event: model_switch',
|
||||
'data: {"model":"ollama/fallback","reason":"ollama/primary unavailable; configured fallback selected","primary":"ollama/primary"}',
|
||||
'',
|
||||
'event: token',
|
||||
'data: {"content":"fallback answer"}',
|
||||
'',
|
||||
'event: done',
|
||||
`data: ${JSON.stringify({
|
||||
content: 'fallback answer',
|
||||
model: 'ollama/fallback',
|
||||
toolsUsed: [],
|
||||
usage: { inputTokens: 8_005, outputTokens: 244 },
|
||||
contextMetrics: runtimeMetrics,
|
||||
})}`,
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const result = assertQualifiedChatCase({
|
||||
httpStatus: 200,
|
||||
contentType: 'text/event-stream; charset=utf-8',
|
||||
rawSse,
|
||||
expectedModel: 'ollama/fallback',
|
||||
expectedSwitch: {
|
||||
model: 'ollama/fallback',
|
||||
primary: 'ollama/primary',
|
||||
reason: 'ollama/primary unavailable; configured fallback selected',
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.content, 'fallback answer');
|
||||
assert.equal(result.events.filter(({ event }) => event === 'done').length, 1);
|
||||
assert.equal(result.events.filter(({ event }) => event === 'model_switch').length, 1);
|
||||
});
|
||||
|
||||
it('qualifies a bounded, relevant production chat tool context from at least 29 eligible tools', () => {
|
||||
const selectedToolNames = [
|
||||
'read_file', 'search_files', 'search_content', 'git_status',
|
||||
...Array.from({ length: 10 }, (_, index) => `code_tool_${index}`),
|
||||
];
|
||||
const rawSse = (
|
||||
contextMetrics: Record<string, unknown>,
|
||||
usage: Record<string, unknown> = {
|
||||
inputTokens: contextMetrics.providerInputTokens,
|
||||
outputTokens: contextMetrics.providerOutputTokens,
|
||||
},
|
||||
) => [
|
||||
'event: token',
|
||||
'data: {"content":"tool context qualified"}',
|
||||
'',
|
||||
'event: done',
|
||||
`data: ${JSON.stringify({
|
||||
content: 'tool context qualified',
|
||||
model: 'ollama/primary',
|
||||
toolsUsed: [],
|
||||
usage,
|
||||
contextMetrics,
|
||||
})}`,
|
||||
'',
|
||||
].join('\n');
|
||||
const validMetrics = {
|
||||
toolCatalogCount: 78,
|
||||
toolEligibleCount: 52,
|
||||
toolSelectedCount: 14,
|
||||
toolOmittedCount: 38,
|
||||
transmittedToolSchemaChars: 6_120,
|
||||
estimatedToolSchemaTokens: 1_530,
|
||||
selectorLatencyMs: 6,
|
||||
estimatedSystemPromptTokens: 7_437,
|
||||
providerInputTokens: 8_005,
|
||||
providerOutputTokens: 244,
|
||||
timeToFirstTokenMs: 5_497,
|
||||
agentLatencyMs: 6_102,
|
||||
totalServerLatencyMs: 6_198,
|
||||
};
|
||||
const qualify = (
|
||||
overrides: Record<string, unknown> = {},
|
||||
dispatchedToolNames: string[] = selectedToolNames,
|
||||
usage?: Record<string, unknown>,
|
||||
) => assertQualifiedToolContextCase({
|
||||
httpStatus: 200,
|
||||
contentType: 'text/event-stream; charset=utf-8',
|
||||
rawSse: rawSse({ ...validMetrics, ...overrides }, usage),
|
||||
expectedModel: 'ollama/primary',
|
||||
selectedToolNames: dispatchedToolNames,
|
||||
});
|
||||
|
||||
const qualified = qualify();
|
||||
assert.deepEqual(qualified.toolContext.selectedToolNames, selectedToolNames);
|
||||
assert.equal(qualified.toolContext.toolEligibleCount, 52);
|
||||
assert.equal(qualified.toolContext.toolSelectedCount, 14);
|
||||
|
||||
const compacted = qualify({
|
||||
estimatedSystemPromptTokens: 7_304,
|
||||
providerInputTokens: 6_983,
|
||||
});
|
||||
assert.equal(compacted.runtimeMetrics.providerInputTokens, 6_983);
|
||||
|
||||
assert.throws(() => qualify({ toolEligibleCount: 28, toolOmittedCount: 14 }), /at least 29 eligible/i);
|
||||
assert.throws(() => qualify({ toolSelectedCount: 15, toolOmittedCount: 37 }), /at most 14 tools/i);
|
||||
assert.throws(() => qualify({ transmittedToolSchemaChars: 8_001, estimatedToolSchemaTokens: 2_001 }), /8,000 schema characters/i);
|
||||
assert.throws(() => qualify({ toolOmittedCount: 37 }), /eligible minus selected/i);
|
||||
assert.throws(() => qualify({}, selectedToolNames.slice(0, -1)), /selected names/i);
|
||||
assert.throws(() => qualify({}, Array(14).fill('calendar_tool')), /unique selected names/i);
|
||||
assert.throws(() => qualify({}, Array.from({ length: 14 }, (_, index) => `calendar_tool_${index}`)), /code-inspection relevance/i);
|
||||
assert.throws(() => qualify({ selectorLatencyMs: 251 }), /250ms/i);
|
||||
assert.throws(() => qualify({ providerInputTokens: 2_050 }), /provider input.*90%/i);
|
||||
assert.throws(() => qualify({ providerInputTokens: 6_000 }), /provider input.*90%/i);
|
||||
assert.throws(
|
||||
() => qualify({ providerInputTokens: 6_693 }),
|
||||
/provider input.*90%.*6,694.*received 6693/i,
|
||||
);
|
||||
assert.doesNotThrow(() => qualify({ providerInputTokens: 6_694 }));
|
||||
assert.throws(
|
||||
() => qualify({ estimatedSystemPromptTokens: 6_000, providerInputTokens: 6_499 }),
|
||||
/provider input.*90%.*6,500.*received 6499/i,
|
||||
);
|
||||
assert.doesNotThrow(() => qualify({
|
||||
estimatedSystemPromptTokens: 6_000,
|
||||
providerInputTokens: 6_500,
|
||||
}));
|
||||
assert.throws(() => qualify({ providerInputTokens: undefined }), /positive integer providerInputTokens/i);
|
||||
assert.throws(() => qualify({ providerOutputTokens: 0 }), /positive integer providerOutputTokens/i);
|
||||
assert.throws(
|
||||
() => qualify(
|
||||
{ providerOutputTokens: 245 },
|
||||
selectedToolNames,
|
||||
{ inputTokens: 8_005, outputTokens: 244 },
|
||||
),
|
||||
/token counts.*done\.usage/i,
|
||||
);
|
||||
assert.throws(() => qualify({ timeToFirstTokenMs: undefined }), /finite timeToFirstTokenMs/i);
|
||||
assert.throws(() => qualify({ agentLatencyMs: -1 }), /finite agentLatencyMs/i);
|
||||
assert.throws(() => qualify({ totalServerLatencyMs: Number.POSITIVE_INFINITY }), /finite totalServerLatencyMs/i);
|
||||
assert.throws(() => qualify({ timeToFirstTokenMs: 6_199 }), /timeToFirstTokenMs.*totalServerLatencyMs/i);
|
||||
assert.throws(() => qualify({ agentLatencyMs: 6_199 }), /agentLatencyMs.*totalServerLatencyMs/i);
|
||||
assert.throws(
|
||||
() => qualify({ timeToFirstTokenMs: 15_001, totalServerLatencyMs: 15_002 }),
|
||||
/timeToFirstTokenMs.*15,000.*received 15,001/i,
|
||||
);
|
||||
assert.throws(
|
||||
() => qualify({ agentLatencyMs: 60_001, totalServerLatencyMs: 60_002 }),
|
||||
/agentLatencyMs.*60,000/i,
|
||||
);
|
||||
assert.throws(
|
||||
() => qualify({ totalServerLatencyMs: 60_001 }),
|
||||
/totalServerLatencyMs.*60,000/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed on errors, duplicate done events, wrong models, or unexpected switches', () => {
|
||||
const done = 'event: token\ndata: {"content":"ok"}\n\nevent: done\ndata: {"content":"ok","model":"ollama/primary","toolsUsed":[]}\n\n';
|
||||
assert.throws(() => assertQualifiedChatCase({
|
||||
httpStatus: 500,
|
||||
contentType: 'text/event-stream',
|
||||
rawSse: done,
|
||||
expectedModel: 'ollama/primary',
|
||||
}), /HTTP 200/i);
|
||||
assert.throws(() => assertQualifiedChatCase({
|
||||
httpStatus: 200,
|
||||
contentType: 'application/json',
|
||||
rawSse: done,
|
||||
expectedModel: 'ollama/primary',
|
||||
}), /text\/event-stream/i);
|
||||
assert.throws(() => assertQualifiedChatCase({
|
||||
httpStatus: 200,
|
||||
contentType: 'text/event-stream',
|
||||
rawSse: `event: error\ndata: {"message":"boom"}\n\n${done}`,
|
||||
expectedModel: 'ollama/primary',
|
||||
}), /zero error/i);
|
||||
assert.throws(() => assertQualifiedChatCase({
|
||||
httpStatus: 200,
|
||||
contentType: 'text/event-stream',
|
||||
rawSse: done + done,
|
||||
expectedModel: 'ollama/primary',
|
||||
}), /exactly one done/i);
|
||||
assert.throws(() => assertQualifiedChatCase({
|
||||
httpStatus: 200,
|
||||
contentType: 'text/event-stream',
|
||||
rawSse: done,
|
||||
expectedModel: 'ollama/budget',
|
||||
}), /done\.model/i);
|
||||
assert.throws(() => assertQualifiedChatCase({
|
||||
httpStatus: 200,
|
||||
contentType: 'text/event-stream',
|
||||
rawSse: `event: model_switch\ndata: {"model":"ollama/fallback","reason":"unexpected","primary":"ollama/primary"}\n\n${done}`,
|
||||
expectedModel: 'ollama/primary',
|
||||
}), /zero model_switch/i);
|
||||
assert.throws(() => assertQualifiedChatCase({
|
||||
httpStatus: 200,
|
||||
contentType: 'text/event-stream',
|
||||
rawSse: `${done}event: step\ndata: {"content":"late"}\n\n`,
|
||||
expectedModel: 'ollama/primary',
|
||||
}), /final event/i);
|
||||
assert.throws(() => assertQualifiedChatCase({
|
||||
httpStatus: 200,
|
||||
contentType: 'text/event-stream',
|
||||
rawSse: 'event: token\ndata: {"content":"different"}\n\nevent: done\ndata: {"content":"ok","model":"ollama/primary","toolsUsed":[]}\n\n',
|
||||
expectedModel: 'ollama/primary',
|
||||
}), /token content/i);
|
||||
assert.throws(() => assertQualifiedChatCase({
|
||||
httpStatus: 200,
|
||||
contentType: 'text/event-stream',
|
||||
rawSse: 'event: token\ndata: {"content":"ok"}\n\nevent: done\ndata: {"content":"ok","model":"ollama/primary","toolsUsed":["bash"]}\n\n',
|
||||
expectedModel: 'ollama/primary',
|
||||
}), /zero tools/i);
|
||||
assert.throws(() => assertQualifiedChatCase({
|
||||
httpStatus: 200,
|
||||
contentType: 'text/event-stream',
|
||||
rawSse: 'event: tool\ndata: {"name":"bash","input":{}}\n\nevent: token\ndata: {"content":"ok"}\n\nevent: done\ndata: {"content":"ok","model":"ollama/primary","toolsUsed":[]}\n\n',
|
||||
expectedModel: 'ollama/primary',
|
||||
}), /zero tools/i);
|
||||
});
|
||||
|
||||
it('canonicalizes immutable Ollama manifest digests and rejects weak identities', () => {
|
||||
const digest = 'A'.repeat(64);
|
||||
assert.equal(canonicalizeManifestDigest(digest), `sha256:${'a'.repeat(64)}`);
|
||||
assert.equal(canonicalizeManifestDigest(`sha256:${digest}`), `sha256:${'a'.repeat(64)}`);
|
||||
assert.throws(() => canonicalizeManifestDigest('latest'), /manifest digest/i);
|
||||
});
|
||||
|
||||
it('requires a newly started owned watchdog and Ollama process', () => {
|
||||
assert.doesNotThrow(() => assertRuntimeStartOwned({
|
||||
installedNow: false,
|
||||
startedNow: true,
|
||||
status: { source: 'waggle-managed', running: true },
|
||||
}, [
|
||||
{ processId: 101, name: 'node.exe', executablePath: 'C:\\node.exe', creationDate: 'one' },
|
||||
{ processId: 102, name: 'ollama.exe', executablePath: 'C:\\proof\\ollama.exe', creationDate: 'two' },
|
||||
]));
|
||||
assert.throws(() => assertRuntimeStartOwned({
|
||||
installedNow: false,
|
||||
startedNow: false,
|
||||
status: { source: 'waggle-managed', running: true },
|
||||
}, []), /newly started/i);
|
||||
});
|
||||
|
||||
it('does not classify the qualifier or launcher as an owned runtime process', () => {
|
||||
const runtimeRoot = 'C:\\proof\\managed-runtime';
|
||||
const snapshot = partitionWindowsProcesses([
|
||||
{
|
||||
processId: 100,
|
||||
name: 'node.exe',
|
||||
executablePath: 'C:\\Program Files\\nodejs\\node.exe',
|
||||
creationDate: 'one',
|
||||
commandLine: `node qualify-smart-router.ts --runtime-data-dir ${runtimeRoot}`,
|
||||
},
|
||||
{
|
||||
processId: 101,
|
||||
name: 'node.exe',
|
||||
executablePath: 'C:\\Program Files\\nodejs\\node.exe',
|
||||
creationDate: 'two',
|
||||
commandLine: `node -e "const stopTree = () => {}; process.once('disconnect', stopTree)" ${runtimeRoot}\\runtimes\\ollama\\0.32.0\\ollama.exe ["serve"]`,
|
||||
},
|
||||
{
|
||||
processId: 102,
|
||||
name: 'ollama.exe',
|
||||
executablePath: `${runtimeRoot}\\runtimes\\ollama\\0.32.0\\ollama.exe`,
|
||||
creationDate: 'three',
|
||||
commandLine: 'ollama.exe serve',
|
||||
},
|
||||
{
|
||||
processId: 103,
|
||||
name: 'ollama.exe',
|
||||
executablePath: 'C:\\Program Files\\Ollama\\ollama.exe',
|
||||
creationDate: 'four',
|
||||
commandLine: 'ollama.exe serve',
|
||||
},
|
||||
], runtimeRoot, new Set([100]));
|
||||
|
||||
assert.deepEqual(snapshot.owned.map(({ processId }) => processId), [101, 102]);
|
||||
assert.deepEqual(snapshot.externalOllama.map(({ processId }) => processId), [103]);
|
||||
});
|
||||
|
||||
it('never mutates aliases when runtime ownership was not confirmed', () => {
|
||||
assert.deepEqual(aliasesForOwnedCleanup(false, ['primary:latest', 'fallback:latest']), []);
|
||||
assert.deepEqual(aliasesForOwnedCleanup(true, ['primary:latest', 'fallback:latest']), [
|
||||
'primary:latest',
|
||||
'fallback:latest',
|
||||
]);
|
||||
});
|
||||
|
||||
it('records an owned alias before a copy response can fail', async () => {
|
||||
const attemptedAliases = new Set<string>();
|
||||
await assert.rejects(() => recordAliasBeforeCopy(
|
||||
attemptedAliases,
|
||||
'primary:latest',
|
||||
async () => { throw new Error('response lost'); },
|
||||
), /response lost/i);
|
||||
assert.deepEqual([...attemptedAliases], ['primary:latest']);
|
||||
});
|
||||
|
||||
it('accepts an empty successful Ollama copy response and rejects failed status', async () => {
|
||||
await assert.doesNotReject(() => postJsonForStatus(
|
||||
'http://127.0.0.1:11434/api/copy',
|
||||
{ source: 'base', destination: 'alias' },
|
||||
async () => new Response(null, { status: 200 }),
|
||||
));
|
||||
await assert.rejects(() => postJsonForStatus(
|
||||
'http://127.0.0.1:11434/api/copy',
|
||||
{ source: 'base', destination: 'alias' },
|
||||
async () => new Response('copy failed', { status: 500 }),
|
||||
), /HTTP 500.*copy failed/i);
|
||||
});
|
||||
|
||||
it('pins audit-proxy requests to the owned runtime origin', () => {
|
||||
assert.equal(
|
||||
buildOwnedProxyTarget('/v1/chat/completions?proof=1', 'http://127.0.0.1:51111').href,
|
||||
'http://127.0.0.1:51111/v1/chat/completions?proof=1',
|
||||
);
|
||||
assert.throws(() => buildOwnedProxyTarget(
|
||||
'http://foreign.example/v1/chat/completions',
|
||||
'http://127.0.0.1:51111',
|
||||
), /absolute-form/i);
|
||||
assert.throws(() => buildOwnedProxyTarget(
|
||||
'//foreign.example/v1/chat/completions',
|
||||
'http://127.0.0.1:51111',
|
||||
), /absolute-form/i);
|
||||
});
|
||||
|
||||
it('binds every copied alias to the immutable base-model digest', () => {
|
||||
const digest = 'a'.repeat(64);
|
||||
const aliases = {
|
||||
primary: 'router-primary:latest',
|
||||
budget: 'router-budget:latest',
|
||||
fallback: 'router-fallback:latest',
|
||||
};
|
||||
assert.deepEqual(assertCopiedModelIdentity({
|
||||
baseModel: 'qwen3:1.7b',
|
||||
aliases,
|
||||
models: [
|
||||
{ name: 'qwen3:1.7b', digest },
|
||||
...Object.values(aliases).map((name) => ({ name, digest: `sha256:${digest}` })),
|
||||
],
|
||||
}), {
|
||||
baseModelDigest: `sha256:${digest}`,
|
||||
aliasDigests: Object.fromEntries(Object.values(aliases).map((name) => [name, `sha256:${digest}`])),
|
||||
});
|
||||
assert.throws(() => assertCopiedModelIdentity({
|
||||
baseModel: 'qwen3:1.7b',
|
||||
aliases,
|
||||
models: [
|
||||
{ name: 'qwen3:1.7b', digest },
|
||||
{ name: aliases.primary, digest: 'b'.repeat(64) },
|
||||
{ name: aliases.budget, digest },
|
||||
{ name: aliases.fallback, digest },
|
||||
],
|
||||
}), /does not match the base model digest/i);
|
||||
});
|
||||
|
||||
it('requires independently observed Ollama dispatch to the expected alias', () => {
|
||||
const dispatches = [
|
||||
{
|
||||
at: 'now',
|
||||
path: '/v1/chat/completions',
|
||||
model: 'router-primary:latest',
|
||||
bodySha256: 'a'.repeat(64),
|
||||
toolNames: [],
|
||||
providerUsage: { inputTokens: 8_005, outputTokens: 244 },
|
||||
},
|
||||
];
|
||||
assert.deepEqual(assertObservedDispatch(dispatches, 0, 'router-primary:latest'), dispatches);
|
||||
assert.throws(() => assertObservedDispatch(dispatches, 0, 'router-budget:latest'), /observed Ollama dispatch/i);
|
||||
assert.deepEqual(
|
||||
assertObservedProviderUsage(dispatches, { inputTokens: 8_005, outputTokens: 244 }),
|
||||
{ inputTokens: 8_005, outputTokens: 244 },
|
||||
);
|
||||
assert.throws(
|
||||
() => assertObservedProviderUsage(
|
||||
[{ ...dispatches[0], providerUsage: null }],
|
||||
{ inputTokens: 8_005, outputTokens: 244 },
|
||||
),
|
||||
/provider-observed usage/i,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertObservedProviderUsage(dispatches, { inputTokens: 8_004, outputTokens: 244 }),
|
||||
/does not match.*done usage/i,
|
||||
);
|
||||
assert.throws(
|
||||
() => assertObservedProviderUsage([
|
||||
{ ...dispatches[0], providerUsage: { inputTokens: 7_000, outputTokens: 122 } },
|
||||
{ ...dispatches[0], providerUsage: { inputTokens: 7_000, outputTokens: 122 } },
|
||||
], { inputTokens: 14_000, outputTokens: 244 }),
|
||||
/exactly one provider dispatch/i,
|
||||
);
|
||||
});
|
||||
|
||||
it('extracts provider-observed token usage from the upstream Ollama stream', () => {
|
||||
const raw = [
|
||||
'data: {"choices":[{"delta":{"content":"ok"}}]}',
|
||||
'',
|
||||
'data: {"choices":[],"usage":{"prompt_tokens":14011,"completion_tokens":244}}',
|
||||
'',
|
||||
'data: [DONE]',
|
||||
'',
|
||||
].join('\n');
|
||||
assert.deepEqual(extractProviderUsage(raw), { inputTokens: 14_011, outputTokens: 244 });
|
||||
assert.equal(extractProviderUsage('data: {"choices":[]}\n\ndata: [DONE]\n\n'), null);
|
||||
assert.equal(extractProviderUsage('data: {"usage":{"prompt_tokens":0,"completion_tokens":2}}\n\n'), null);
|
||||
});
|
||||
|
||||
it('streams audited provider bytes and records usage before delayed HTTP EOF', async () => {
|
||||
let releaseEof = (): void => {};
|
||||
const eofGate = new Promise<void>((resolve) => { releaseEof = resolve; });
|
||||
const upstream = createServer(async (request, response) => {
|
||||
for await (const _chunk of request) {
|
||||
// Drain the request before starting the controlled response.
|
||||
}
|
||||
response.writeHead(200, { 'content-type': 'text/event-stream' });
|
||||
response.write('data: {"choices":[{"delta":{"content":"ok"}}]}\n\n');
|
||||
response.write('data: {"choices":[],"usage":{"prompt_tokens":14011,"completion_tokens":244}}\n\n');
|
||||
response.write('data: [DONE]\n\n');
|
||||
await eofGate;
|
||||
response.end();
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
upstream.once('error', reject);
|
||||
upstream.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const address = upstream.address();
|
||||
assert.ok(address && typeof address !== 'string');
|
||||
const dispatches: Parameters<typeof startAuditProxy>[0]['dispatches'] = [];
|
||||
const proxy = await startAuditProxy({
|
||||
port: 0,
|
||||
targetEndpoint: `http://127.0.0.1:${address.port}`,
|
||||
dispatches,
|
||||
});
|
||||
const proxyAddress = proxy.address();
|
||||
assert.ok(proxyAddress && typeof proxyAddress !== 'string');
|
||||
let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${proxyAddress.port}/v1/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ model: 'router-primary:latest', stream: true }),
|
||||
signal: AbortSignal.timeout(2_000),
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(response.body);
|
||||
reader = response.body.getReader();
|
||||
const readWithin = async () => {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
reader!.read(),
|
||||
new Promise<never>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('Timed out before streamed [DONE]')), 1_000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
};
|
||||
let wire = '';
|
||||
while (!wire.includes('data: [DONE]')) {
|
||||
const next = await readWithin();
|
||||
assert.equal(next.done, false);
|
||||
wire += Buffer.from(next.value).toString('utf8');
|
||||
}
|
||||
assert.match(wire, /"content":"ok"/);
|
||||
assert.deepEqual(dispatches[0]?.providerUsage, { inputTokens: 14_011, outputTokens: 244 });
|
||||
releaseEof();
|
||||
while (!(await reader.read()).done) {
|
||||
// Drain the clean EOF.
|
||||
}
|
||||
} finally {
|
||||
releaseEof();
|
||||
await reader?.cancel().catch(() => undefined);
|
||||
proxy.closeAllConnections();
|
||||
upstream.closeAllConnections();
|
||||
await Promise.all([
|
||||
new Promise<void>((resolve) => proxy.close(() => resolve())),
|
||||
new Promise<void>((resolve) => upstream.close(() => resolve())),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it('extracts transmitted OpenAI tool names from the audited provider payload', () => {
|
||||
assert.deepEqual(extractDispatchedToolNames({
|
||||
tools: [
|
||||
{ type: 'function', function: { name: 'read_file', parameters: { type: 'object' } } },
|
||||
{ type: 'function', function: { name: 'search_files', parameters: { type: 'object' } } },
|
||||
],
|
||||
}), ['read_file', 'search_files']);
|
||||
assert.deepEqual(extractDispatchedToolNames({ model: 'router-primary:latest' }), []);
|
||||
assert.throws(() => extractDispatchedToolNames({ tools: [{}] }), /function metadata/i);
|
||||
});
|
||||
|
||||
it('builds the real model-pilot settings payload', () => {
|
||||
assert.deepEqual(buildRouterSettings({
|
||||
primary: 'router-primary:latest',
|
||||
budget: 'router-budget:latest',
|
||||
fallback: 'router-fallback:latest',
|
||||
}), {
|
||||
defaultModel: 'ollama/router-primary:latest',
|
||||
budgetModel: 'ollama/router-budget:latest',
|
||||
fallbackModel: 'ollama/router-fallback:latest',
|
||||
dailyBudget: 1,
|
||||
budgetHardCap: false,
|
||||
budgetThreshold: 0.8,
|
||||
providers: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the exact live arithmetic prompt inside the closed budget allowlist', () => {
|
||||
assert.deepEqual(routeMessage(BUDGET_PROMPT, 'ollama/primary', 'ollama/budget'), {
|
||||
model: 'ollama/budget',
|
||||
reason: 'simple_turn',
|
||||
});
|
||||
});
|
||||
|
||||
it('fails sealing when the source snapshot changes during qualification', () => {
|
||||
assert.doesNotThrow(() => assertSourceSnapshot({
|
||||
expectedRevision: 'a'.repeat(40),
|
||||
expectedScriptSha256: 'b'.repeat(64),
|
||||
actualRevision: 'a'.repeat(40),
|
||||
trackedStatus: '',
|
||||
actualScriptSha256: 'b'.repeat(64),
|
||||
}));
|
||||
assert.throws(() => assertSourceSnapshot({
|
||||
expectedRevision: 'a'.repeat(40),
|
||||
expectedScriptSha256: 'b'.repeat(64),
|
||||
actualRevision: 'c'.repeat(40),
|
||||
trackedStatus: '',
|
||||
actualScriptSha256: 'b'.repeat(64),
|
||||
}), /source revision changed/i);
|
||||
assert.throws(() => assertSourceSnapshot({
|
||||
expectedRevision: 'a'.repeat(40),
|
||||
expectedScriptSha256: 'b'.repeat(64),
|
||||
actualRevision: 'a'.repeat(40),
|
||||
trackedStatus: ' M packages/server/src/local/routes/chat.ts',
|
||||
actualScriptSha256: 'b'.repeat(64),
|
||||
}), /tracked source changed/i);
|
||||
});
|
||||
|
||||
it('removes every provider, proxy, Docker, and inherited Waggle runtime credential', () => {
|
||||
const environment = buildSanitizedEnvironment({
|
||||
PATH: 'safe-path',
|
||||
ANTHROPIC_API_KEY: 'secret',
|
||||
OPENAI_API_KEY: 'secret',
|
||||
GEMINI_API_KEY: 'secret',
|
||||
GOOGLE_API_KEY: 'secret',
|
||||
XAI_API_KEY: 'secret',
|
||||
DEEPSEEK_API_KEY: 'secret',
|
||||
MISTRAL_API_KEY: 'secret',
|
||||
DASHSCOPE_API_KEY: 'secret',
|
||||
MINIMAX_API_KEY: 'secret',
|
||||
ZHIPU_API_KEY: 'secret',
|
||||
MOONSHOT_API_KEY: 'secret',
|
||||
PERPLEXITY_API_KEY: 'secret',
|
||||
OPENROUTER_API_KEY: 'secret',
|
||||
VOYAGE_API_KEY: 'secret',
|
||||
WAGGLE_VOYAGE_API_KEY: 'secret',
|
||||
LITELLM_API_KEY: 'secret',
|
||||
LITELLM_MASTER_KEY: 'secret',
|
||||
WAGGLE_LITELLM_URL: 'https://proxy.example',
|
||||
DOCKER_HOST: 'tcp://docker.example',
|
||||
WAGGLE_DATA_DIR: 'foreign-data',
|
||||
OLLAMA_HOST: 'http://127.0.0.1:11434',
|
||||
http_proxy: 'http://lowercase-proxy.example',
|
||||
https_proxy: 'http://lowercase-proxy.example',
|
||||
docker_host: 'tcp://lowercase-docker.example',
|
||||
ollama_host: 'http://127.0.0.1:59999',
|
||||
waggle_data_dir: 'lowercase-foreign-data',
|
||||
}, {
|
||||
WAGGLE_DATA_DIR: 'isolated-data',
|
||||
OLLAMA_HOST: 'http://127.0.0.1:51111',
|
||||
});
|
||||
|
||||
assert.equal(environment.PATH, 'safe-path');
|
||||
assert.equal(environment.WAGGLE_DATA_DIR, 'isolated-data');
|
||||
assert.equal(environment.OLLAMA_HOST, 'http://127.0.0.1:51111');
|
||||
for (const name of [
|
||||
'ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GEMINI_API_KEY', 'GOOGLE_API_KEY',
|
||||
'XAI_API_KEY', 'DEEPSEEK_API_KEY', 'MISTRAL_API_KEY', 'DASHSCOPE_API_KEY',
|
||||
'MINIMAX_API_KEY', 'ZHIPU_API_KEY', 'MOONSHOT_API_KEY', 'PERPLEXITY_API_KEY',
|
||||
'OPENROUTER_API_KEY', 'VOYAGE_API_KEY', 'WAGGLE_VOYAGE_API_KEY',
|
||||
'LITELLM_API_KEY', 'LITELLM_MASTER_KEY', 'WAGGLE_LITELLM_URL', 'DOCKER_HOST',
|
||||
'http_proxy', 'https_proxy', 'docker_host', 'ollama_host', 'waggle_data_dir',
|
||||
]) {
|
||||
assert.equal(environment[name], undefined, `${name} was not sanitized`);
|
||||
}
|
||||
});
|
||||
});
|
||||
1372
scripts/qualify-smart-router.ts
Normal file
1372
scripts/qualify-smart-router.ts
Normal file
File diff suppressed because it is too large
Load Diff
262
scripts/read-tauri-bootstrap-token.mjs
Normal file
262
scripts/read-tauri-bootstrap-token.mjs
Normal file
@@ -0,0 +1,262 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import process from 'node:process';
|
||||
import WebSocket from 'ws';
|
||||
|
||||
const DEFAULT_TIMEOUT_MS = 60_000;
|
||||
const MIN_TOKEN_LENGTH = 32;
|
||||
const MAX_TOKEN_LENGTH = 200;
|
||||
|
||||
function fail(message) {
|
||||
console.error(`tauri-bootstrap-token: ${message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
port: null,
|
||||
timeoutMs: DEFAULT_TIMEOUT_MS,
|
||||
selfTest: false,
|
||||
allowLegacyUi: false,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const argument = argv[index];
|
||||
if (argument === '--self-test') {
|
||||
options.selfTest = true;
|
||||
continue;
|
||||
}
|
||||
if (argument === '--allow-legacy-ui') {
|
||||
options.allowLegacyUi = true;
|
||||
continue;
|
||||
}
|
||||
if (argument === '--port' || argument === '--timeout-ms') {
|
||||
const value = argv[index + 1];
|
||||
index += 1;
|
||||
if (value === undefined) throw new Error(`${argument} requires a value`);
|
||||
if (argument === '--port') options.port = Number(value);
|
||||
else options.timeoutMs = Number(value);
|
||||
continue;
|
||||
}
|
||||
if (argument.startsWith('--port=')) options.port = Number(argument.slice(7));
|
||||
else if (argument.startsWith('--timeout-ms=')) options.timeoutMs = Number(argument.slice(13));
|
||||
else throw new Error(`unknown argument: ${argument}`);
|
||||
}
|
||||
if (!Number.isInteger(options.port) || options.port < 1024 || options.port > 65535) {
|
||||
throw new Error('port must be an integer between 1024 and 65535');
|
||||
}
|
||||
if (!Number.isInteger(options.timeoutMs) || options.timeoutMs < 1000 || options.timeoutMs > 300_000) {
|
||||
throw new Error('timeout-ms must be an integer between 1000 and 300000');
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function isValidEndpoint(value) {
|
||||
return value !== null
|
||||
&& typeof value === 'object'
|
||||
&& Number.isInteger(value.port)
|
||||
&& value.port > 0
|
||||
&& value.port <= 65535
|
||||
&& typeof value.instanceId === 'string'
|
||||
&& value.instanceId.trim().length > 0
|
||||
&& typeof value.bootstrapToken === 'string'
|
||||
&& value.bootstrapToken.length >= MIN_TOKEN_LENGTH
|
||||
&& value.bootstrapToken.length <= MAX_TOKEN_LENGTH;
|
||||
}
|
||||
|
||||
function isValidDesktopRuntime(value, allowLegacyUi = false) {
|
||||
return isValidEndpoint(value)
|
||||
&& value.uiReady === true
|
||||
&& value.uiStartupState === (allowLegacyUi ? 'legacy-ready' : 'ready')
|
||||
&& typeof value.uiPath === 'string'
|
||||
&& value.uiPath.startsWith('/')
|
||||
&& Number.isInteger(value.uiTextLength)
|
||||
&& value.uiTextLength > 0;
|
||||
}
|
||||
|
||||
async function fetchTargets(port) {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/json/list`);
|
||||
if (!response.ok) throw new Error(`WebView debug endpoint returned HTTP ${response.status}`);
|
||||
const targets = await response.json();
|
||||
if (!Array.isArray(targets)) throw new Error('WebView debug endpoint returned an invalid target list');
|
||||
return targets.filter((target) => target?.type === 'page' && typeof target.webSocketDebuggerUrl === 'string');
|
||||
}
|
||||
|
||||
async function evaluateEnsureService(webSocketUrl, timeoutMs, allowLegacyUi) {
|
||||
const socket = new WebSocket(webSocketUrl);
|
||||
let nextId = 1;
|
||||
const pending = new Map();
|
||||
const timer = setTimeout(() => {
|
||||
for (const pendingRequest of pending.values()) pendingRequest.reject(new Error('CDP request timed out'));
|
||||
pending.clear();
|
||||
socket.close();
|
||||
}, timeoutMs);
|
||||
|
||||
try {
|
||||
await new Promise((resolve, reject) => {
|
||||
const openTimer = setTimeout(() => reject(new Error('CDP WebSocket open timed out')), timeoutMs);
|
||||
socket.addEventListener('open', () => {
|
||||
clearTimeout(openTimer);
|
||||
resolve();
|
||||
}, { once: true });
|
||||
socket.addEventListener('error', () => {
|
||||
clearTimeout(openTimer);
|
||||
reject(new Error('CDP WebSocket connection failed'));
|
||||
}, { once: true });
|
||||
});
|
||||
|
||||
socket.addEventListener('message', (event) => {
|
||||
let message;
|
||||
try {
|
||||
message = JSON.parse(String(event.data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const request = pending.get(message.id);
|
||||
if (!request) return;
|
||||
pending.delete(message.id);
|
||||
if (message.error) request.reject(new Error('Tauri IPC evaluation failed'));
|
||||
else request.resolve(message.result);
|
||||
});
|
||||
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const id = nextId;
|
||||
nextId += 1;
|
||||
pending.set(id, { resolve, reject });
|
||||
socket.send(JSON.stringify({
|
||||
id,
|
||||
method: 'Runtime.evaluate',
|
||||
params: {
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
expression: `
|
||||
(async () => {
|
||||
const invoke = globalThis.__TAURI_INTERNALS__?.invoke;
|
||||
if (typeof invoke !== 'function') throw new Error('Tauri IPC is unavailable');
|
||||
const endpoint = await invoke('ensure_service');
|
||||
const deadline = Date.now() + 10_000;
|
||||
for (;;) {
|
||||
const root = document.getElementById('root');
|
||||
const startup = root?.querySelector('[data-waggle-startup]');
|
||||
const startupState = startup?.getAttribute('data-waggle-startup') ?? null;
|
||||
const readyState = root?.getAttribute('data-waggle-ui-ready') ?? null;
|
||||
const uiTextLength = root?.innerText?.trim().length ?? 0;
|
||||
const strictUiReady = Boolean(
|
||||
root
|
||||
&& root.childElementCount > 0
|
||||
&& !startup
|
||||
&& readyState === 'ready'
|
||||
&& uiTextLength > 0
|
||||
);
|
||||
const legacyUiReady = Boolean(
|
||||
root
|
||||
&& root.childElementCount > 0
|
||||
&& !startup
|
||||
&& uiTextLength > 0
|
||||
);
|
||||
const uiReady = ${allowLegacyUi ? 'legacyUiReady' : 'strictUiReady'};
|
||||
if (uiReady || startupState === 'failed' || Date.now() >= deadline) {
|
||||
return {
|
||||
...endpoint,
|
||||
uiPath: globalThis.location?.pathname ?? '',
|
||||
uiReady,
|
||||
uiStartupState: ${allowLegacyUi ? "uiReady ? 'legacy-ready' : (readyState ?? startupState)" : 'readyState ?? startupState'},
|
||||
uiTextLength,
|
||||
};
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
})()
|
||||
`,
|
||||
},
|
||||
}));
|
||||
});
|
||||
const remote = result?.result?.value;
|
||||
if (!isValidEndpoint(remote)) throw new Error('Tauri returned an invalid service endpoint');
|
||||
if (!isValidDesktopRuntime(remote, allowLegacyUi)) {
|
||||
throw new Error('Waggle UI did not render its application shell');
|
||||
}
|
||||
return {
|
||||
bootstrapToken: remote.bootstrapToken,
|
||||
uiPath: remote.uiPath,
|
||||
uiReady: remote.uiReady,
|
||||
uiStartupState: remote.uiStartupState,
|
||||
uiTextLength: remote.uiTextLength,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveToken(port, timeoutMs, allowLegacyUi) {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError = 'WebView target not ready';
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
const targets = await fetchTargets(port);
|
||||
for (const target of targets) {
|
||||
try {
|
||||
return await evaluateEnsureService(
|
||||
target.webSocketDebuggerUrl,
|
||||
Math.min(15_000, deadline - Date.now()),
|
||||
allowLegacyUi,
|
||||
);
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : 'CDP evaluation failed';
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error instanceof Error ? error.message : 'WebView debug endpoint unavailable';
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(lastError);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (process.argv.includes('--self-test')) {
|
||||
if (typeof WebSocket !== 'function') throw new Error('WebSocket client is unavailable');
|
||||
const valid = 'a'.repeat(MIN_TOKEN_LENGTH);
|
||||
if (!isValidEndpoint({ port: 3333, instanceId: 'test-instance', bootstrapToken: valid })) {
|
||||
throw new Error('valid endpoint fixture rejected');
|
||||
}
|
||||
if (isValidEndpoint({ port: 3333, instanceId: 'test-instance', bootstrapToken: 'short' })) {
|
||||
throw new Error('short token fixture accepted');
|
||||
}
|
||||
const validRuntime = {
|
||||
port: 3333,
|
||||
instanceId: 'test-instance',
|
||||
bootstrapToken: valid,
|
||||
uiPath: '/home',
|
||||
uiReady: true,
|
||||
uiStartupState: 'ready',
|
||||
uiTextLength: 10,
|
||||
};
|
||||
if (!isValidDesktopRuntime(validRuntime)) throw new Error('valid desktop runtime rejected');
|
||||
if (isValidDesktopRuntime({ ...validRuntime, uiReady: false })) {
|
||||
throw new Error('blank desktop runtime accepted');
|
||||
}
|
||||
if (isValidDesktopRuntime({ ...validRuntime, uiTextLength: 0 })) {
|
||||
throw new Error('empty desktop runtime accepted');
|
||||
}
|
||||
if (isValidDesktopRuntime({ ...validRuntime, uiStartupState: 'loading' })) {
|
||||
throw new Error('loading desktop runtime accepted');
|
||||
}
|
||||
if (isValidDesktopRuntime({ ...validRuntime, uiStartupState: 'failed' })) {
|
||||
throw new Error('failed desktop runtime accepted');
|
||||
}
|
||||
if (!isValidDesktopRuntime({ ...validRuntime, uiStartupState: 'legacy-ready' }, true)) {
|
||||
throw new Error('valid legacy desktop runtime rejected');
|
||||
}
|
||||
if (isValidDesktopRuntime({ ...validRuntime, uiReady: false, uiStartupState: 'legacy-ready' }, true)) {
|
||||
throw new Error('blank legacy desktop runtime accepted');
|
||||
}
|
||||
console.log(JSON.stringify({ pass: true, cases: 9 }));
|
||||
return;
|
||||
}
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const runtime = await resolveToken(options.port, options.timeoutMs, options.allowLegacyUi);
|
||||
console.log(JSON.stringify(runtime));
|
||||
}
|
||||
|
||||
main().catch((error) => fail(error instanceof Error ? error.message : 'unknown failure'));
|
||||
71
scripts/seal-persona-acceptance.ts
Normal file
71
scripts/seal-persona-acceptance.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
import {
|
||||
buildPersonaAcceptanceSeal,
|
||||
type PersonaAcceptanceSeal,
|
||||
type PersonaAcceptanceSealManifest,
|
||||
} from '../tests/vision/persona-acceptance-seal';
|
||||
|
||||
function arg(name: string): string | null {
|
||||
const index = process.argv.indexOf(name);
|
||||
return index >= 0 ? process.argv[index + 1] ?? null : null;
|
||||
}
|
||||
|
||||
function renderMarkdown(seal: PersonaAcceptanceSeal): string {
|
||||
const rescored = seal.receipts.filter(receipt => receipt.scoreMode === 'derived-rescore').length;
|
||||
const rows = seal.receipts.map(receipt => (
|
||||
`| ${receipt.personaId} | ${receipt.repeat} | ${receipt.score} | ${receipt.scoreMode} | ${receipt.model ?? 'unknown'} | ${receipt.estimatedCostUsd} | ${receipt.sourceRevision.slice(0, 12)} | \`${receipt.artifactSha256.slice(0, 16)}\` |`
|
||||
));
|
||||
const diagnosticRows = seal.diagnosticCostLedger.map(entry => (
|
||||
`| ${entry.id} | ${entry.amountUsd} | ${entry.evidence.replace(/\|/g, '\\|')} |`
|
||||
));
|
||||
return [
|
||||
'# Waggle 10-Persona Paid Acceptance Seal',
|
||||
'',
|
||||
`- Status: **${seal.status.toUpperCase()}**`,
|
||||
`- Receipts: **${seal.completedReceiptCount}/${seal.expectedReceiptCount}**`,
|
||||
`- Threshold: **${seal.threshold}/100 for every receipt**`,
|
||||
`- Waggle-estimated accepted-run cost: **$${seal.acceptedEstimatedCostUsd}**`,
|
||||
`- Operator-recorded diagnostic cost (with evidence note): **$${seal.diagnosticRecordedCostUsd}**`,
|
||||
`- Total recorded spend: **$${seal.totalRecordedSpendUsd}**`,
|
||||
`- Manifest SHA-256: \`${seal.manifestSha256}\``,
|
||||
`- Derived rescoring disclosures: **${rescored}**`,
|
||||
'',
|
||||
'| Persona | Repeat | Score | Provenance | Model | Estimated cost USD | Source revision | Artifact SHA-256 |',
|
||||
'|---|---:|---:|---|---|---:|---|---|',
|
||||
...rows,
|
||||
'',
|
||||
'## Diagnostic Cost Evidence',
|
||||
'',
|
||||
'| ID | Cost USD | Evidence |',
|
||||
'|---|---:|---|',
|
||||
...(diagnosticRows.length > 0 ? diagnosticRows : ['| None | 0.000000 | No diagnostic spend recorded |']),
|
||||
'',
|
||||
'This report is generated only after exact 10 x 3 slot coverage passes live-provider health, transport, browser, persistence, isolation, current-scorer, and provenance checks.',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const manifestArg = arg('--manifest');
|
||||
const jsonArg = arg('--json');
|
||||
const markdownArg = arg('--markdown');
|
||||
if (!manifestArg || !jsonArg || !markdownArg) {
|
||||
console.error('Usage: npm run persona:seal -- --manifest <manifest.json> --json <seal.json> --markdown <report.md>');
|
||||
process.exitCode = 2;
|
||||
} else {
|
||||
const manifestPath = resolve(manifestArg);
|
||||
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8')) as PersonaAcceptanceSealManifest;
|
||||
const seal = buildPersonaAcceptanceSeal(manifest);
|
||||
if (seal.status !== 'ready') {
|
||||
console.error(JSON.stringify(seal, null, 2));
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
const jsonPath = resolve(jsonArg);
|
||||
const markdownPath = resolve(markdownArg);
|
||||
mkdirSync(dirname(jsonPath), { recursive: true });
|
||||
mkdirSync(dirname(markdownPath), { recursive: true });
|
||||
writeFileSync(jsonPath, `${JSON.stringify(seal, null, 2)}\n`, 'utf8');
|
||||
writeFileSync(markdownPath, renderMarkdown(seal), 'utf8');
|
||||
console.log(JSON.stringify({ status: seal.status, jsonPath, markdownPath }, null, 2));
|
||||
}
|
||||
}
|
||||
37
scripts/stage-agent-pptx-runtime.mjs
Normal file
37
scripts/stage-agent-pptx-runtime.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
import { copyFile, mkdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const sourceRoot = path.join(root, 'vendor', 'pptxgenjs');
|
||||
const agentDist = path.join(root, 'packages', 'agent', 'dist');
|
||||
const targetRoot = path.join(agentDist, 'vendor', 'pptxgenjs');
|
||||
|
||||
const manifest = JSON.parse(await readFile(path.join(sourceRoot, 'package.json'), 'utf8'));
|
||||
if (manifest.name !== 'pptxgenjs' || manifest.version !== '4.0.1-waggle.0') {
|
||||
throw new Error(
|
||||
`Refusing to stage unexpected PPTX runtime ${String(manifest.name)}@${String(manifest.version)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const builtEntry = path.join(agentDist, 'presentation-tools.js');
|
||||
if (!(await stat(builtEntry)).isFile()) {
|
||||
throw new Error(`Agent build output is missing: ${builtEntry}`);
|
||||
}
|
||||
|
||||
const sourceImport = "import PptxGenJS from 'pptxgenjs';";
|
||||
const stagedImport = "import PptxGenJS from './vendor/pptxgenjs/dist/pptxgen.cjs.js';";
|
||||
const builtSource = await readFile(builtEntry, 'utf8');
|
||||
if (!builtSource.includes(sourceImport)) {
|
||||
throw new Error(`Agent PPTX import is not recognized: ${builtEntry}`);
|
||||
}
|
||||
|
||||
await rm(targetRoot, { recursive: true, force: true });
|
||||
await mkdir(path.join(targetRoot, 'dist'), { recursive: true });
|
||||
await copyFile(path.join(sourceRoot, 'LICENSE'), path.join(targetRoot, 'LICENSE'));
|
||||
await copyFile(path.join(sourceRoot, 'package.json'), path.join(targetRoot, 'package.json'));
|
||||
await copyFile(
|
||||
path.join(sourceRoot, 'dist', 'pptxgen.cjs.js'),
|
||||
path.join(targetRoot, 'dist', 'pptxgen.cjs.js'),
|
||||
);
|
||||
await writeFile(builtEntry, builtSource.replace(sourceImport, stagedImport), 'utf8');
|
||||
@@ -13,10 +13,10 @@
|
||||
* EXTERNAL list — some of those, e.g. mammoth/sharp, aren't actually
|
||||
* reached).
|
||||
* 2. Walk the transitive production-dependency closure of that set from the
|
||||
* repo's own node_modules and copy each package dir verbatim — preserving
|
||||
* prebuilt native .node binaries in place (better-sqlite3/build/Release,
|
||||
* onnxruntime-node/bin) so require('better-sqlite3') both RESOLVES and
|
||||
* FINDS its binary via the package's own relative loader.
|
||||
* repo's own node_modules. Third-party packages retain their published
|
||||
* runtime layout; first-party workspaces are reduced to dist, manifest,
|
||||
* and license notices so source/build artifacts never enter the installer.
|
||||
* Prebuilt native binaries stay in their package-relative locations.
|
||||
*
|
||||
* Run after build-sidecar.mjs, before `tauri build`. Arch-parameterized: honors
|
||||
* TARGET_ARCH (like bundle-native-deps.mjs) to prune onnxruntime-node's
|
||||
@@ -39,6 +39,7 @@ const resourcesDir = path.join(root, 'app', 'src-tauri', 'resources');
|
||||
// Must match the metafile path written by build-sidecar.mjs (temp, not repo).
|
||||
const metaFile = path.join(os.tmpdir(), 'waggle-sidecar-meta.json');
|
||||
const stageDir = path.join(resourcesDir, 'node_modules');
|
||||
const bundledNpmRuntimeDir = path.join(stageDir, 'waggle-node-runtime');
|
||||
const hookRuntimeBuild = path.join(root, 'scripts', 'build-hook-runtime.mjs');
|
||||
const HOOK_RUNTIME_ROOTS = new Set([
|
||||
'@waggle/hive-mind-cli',
|
||||
@@ -49,7 +50,11 @@ const HOOK_RUNTIME_ROOTS = new Set([
|
||||
'@waggle/hive-mind-hooks-cursor',
|
||||
'@waggle/hive-mind-hooks-hermes',
|
||||
'@waggle/hive-mind-hooks-openclaw',
|
||||
'waggle-memory-mcp',
|
||||
]);
|
||||
// These are loaded through computed require() calls, so esbuild's metafile
|
||||
// cannot discover them even though installed desktop features require them.
|
||||
const DYNAMIC_RUNTIME_ROOTS = new Set(['adm-zip']);
|
||||
|
||||
const platform = process.platform;
|
||||
const arch = process.env.TARGET_ARCH || process.arch;
|
||||
@@ -102,6 +107,11 @@ const RUNTIME_PRUNED_DIR_NAMES = new Set([
|
||||
'test',
|
||||
'tests',
|
||||
]);
|
||||
const SOURCE_ARTIFACT_PATTERN = /(?:\.map|\.(?:[cm]?ts|tsx)|\.tsbuildinfo)$/i;
|
||||
const WORKSPACE_RUNTIME_ENTRY_PATTERN = /^(?:dist|package\.json|licen[cs]e(?:\.(?:md|txt))?|notice(?:\.(?:md|txt))?)$/i;
|
||||
const MANUAL_WORKSPACE_RUNTIME_TARGETS = new Map([
|
||||
['@waggle/hive-mind-hooks-openclaw', ['dist/handler.bundle.cjs']],
|
||||
]);
|
||||
const WINDOWS_1252_EXTRA_CODEPOINTS = new Set([
|
||||
0x20ac, 0x201a, 0x0192, 0x201e, 0x2026, 0x2020, 0x2021, 0x02c6, 0x2030,
|
||||
0x0160, 0x2039, 0x0152, 0x017d, 0x2018, 0x2019, 0x201c, 0x201d, 0x2022,
|
||||
@@ -160,12 +170,19 @@ function resolvePkgDir(name, fromDir) {
|
||||
|
||||
let copiedPackages = 0;
|
||||
let prunedRuntimeDirs = 0;
|
||||
let prunedRuntimeFiles = 0;
|
||||
let strippedSourceMapDirectives = 0;
|
||||
const stagedWorkspaceNames = new Set();
|
||||
|
||||
function isWorkspacePackageDir(pkgDir) {
|
||||
const realDir = fs.realpathSync.native(pkgDir);
|
||||
return [path.join(root, 'packages'), path.join(root, 'apps')].some((workspaceRoot) => {
|
||||
const relative = path.relative(workspaceRoot, realDir);
|
||||
return relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative);
|
||||
return relative !== ''
|
||||
&& !relative.startsWith(`..${path.sep}`)
|
||||
&& relative !== '..'
|
||||
&& !path.isAbsolute(relative)
|
||||
&& !relative.includes(path.sep);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -174,14 +191,39 @@ function copyPackage(srcDir, name) {
|
||||
const destDir = path.join(stageDir, name);
|
||||
if (fs.existsSync(destDir)) return; // already staged (dedup by flat name)
|
||||
fs.mkdirSync(path.dirname(destDir), { recursive: true });
|
||||
const copyOptions = { recursive: true, dereference: true };
|
||||
if (isWorkspacePackageDir(srcDir)) {
|
||||
// npm does not publish a workspace package's local node_modules. Copying
|
||||
// it from a dereferenced workspace symlink would leak dev-only packages;
|
||||
// production dependencies are staged separately from the manifest below.
|
||||
copyOptions.filter = (source) => path.basename(source) !== 'node_modules';
|
||||
stagedWorkspaceNames.add(name);
|
||||
const entries = fs.readdirSync(srcDir)
|
||||
.filter((entry) => WORKSPACE_RUNTIME_ENTRY_PATTERN.test(entry));
|
||||
for (const required of ['package.json', 'dist']) {
|
||||
if (!entries.includes(required)) {
|
||||
throw new Error(`Workspace package ${name} has no ${required} runtime payload`);
|
||||
}
|
||||
}
|
||||
fs.mkdirSync(destDir, { recursive: true });
|
||||
for (const entry of entries) {
|
||||
if (entry === 'package.json') {
|
||||
const runtimeManifest = readManifest(srcDir);
|
||||
delete runtimeManifest.devDependencies;
|
||||
delete runtimeManifest.files;
|
||||
delete runtimeManifest.scripts;
|
||||
delete runtimeManifest.types;
|
||||
delete runtimeManifest.typings;
|
||||
fs.writeFileSync(
|
||||
path.join(destDir, entry),
|
||||
`${JSON.stringify(runtimeManifest, null, 2)}\n`,
|
||||
'utf8',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
fs.cpSync(path.join(srcDir, entry), path.join(destDir, entry), {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
fs.cpSync(srcDir, destDir, { recursive: true, dereference: true });
|
||||
}
|
||||
fs.cpSync(srcDir, destDir, copyOptions);
|
||||
copiedPackages++;
|
||||
}
|
||||
|
||||
@@ -320,6 +362,33 @@ function pruneRuntimeOnlyDirs(dir) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove first-party source/build artifacts and dangling source-map directives. */
|
||||
function pruneFirstPartyBuildArtifacts(dir) {
|
||||
if (!fs.existsSync(dir)) return;
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
pruneFirstPartyBuildArtifacts(full);
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && SOURCE_ARTIFACT_PATTERN.test(entry.name)) {
|
||||
fs.rmSync(full, { force: true });
|
||||
prunedRuntimeFiles++;
|
||||
continue;
|
||||
}
|
||||
if (entry.isFile() && /\.(?:[cm]?js)$/i.test(entry.name)) {
|
||||
const source = fs.readFileSync(full, 'utf8');
|
||||
const runtimeOnly = source
|
||||
.replace(/^[ \t]*\/\/[#@]\s*sourceMappingURL\s*=.*(?:\r?\n|$)/gm, '')
|
||||
.replace(/\/\*[#@]\s*sourceMappingURL\s*=.*?\*\//gs, '');
|
||||
if (runtimeOnly !== source) {
|
||||
fs.writeFileSync(full, runtimeOnly, 'utf8');
|
||||
strippedSourceMapDirectives++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isWindows1252PathSafe(value) {
|
||||
for (const char of value) {
|
||||
const code = char.codePointAt(0) || 0;
|
||||
@@ -344,6 +413,104 @@ function listFiles(dir) {
|
||||
return files;
|
||||
}
|
||||
|
||||
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 workspaceRuntimeTargets(manifest, packageName) {
|
||||
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_WORKSPACE_RUNTIME_TARGETS.get(packageName) || []) {
|
||||
targets.add(entry);
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
function validateWorkspaceRuntimeTargets(name, packageDir) {
|
||||
const failures = [];
|
||||
const manifest = readManifest(packageDir);
|
||||
const distDir = path.resolve(packageDir, 'dist');
|
||||
const realPackageDir = fs.realpathSync.native(packageDir);
|
||||
const realDistDir = fs.realpathSync.native(distDir);
|
||||
const realDistWithinPackage = realDistDir.startsWith(`${realPackageDir}${path.sep}`);
|
||||
for (const target of workspaceRuntimeTargets(manifest, name)) {
|
||||
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 realWithinDist = false;
|
||||
if (withinDist && fs.existsSync(resolved)) {
|
||||
const stat = fs.lstatSync(resolved);
|
||||
regularRuntimeFile = stat.isFile() && !stat.isSymbolicLink();
|
||||
if (regularRuntimeFile) {
|
||||
const realTarget = fs.realpathSync.native(resolved);
|
||||
realWithinDist = realTarget.startsWith(`${realDistDir}${path.sep}`);
|
||||
}
|
||||
}
|
||||
if (
|
||||
!withinDist
|
||||
|| !realDistWithinPackage
|
||||
|| !realWithinDist
|
||||
|| !regularRuntimeFile
|
||||
|| target.includes('*')
|
||||
) {
|
||||
failures.push(`${name} -> ${target}`);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
|
||||
function assertWorkspaceRuntimeOnly() {
|
||||
const unexpected = [];
|
||||
for (const name of stagedWorkspaceNames) {
|
||||
const packageDir = path.join(stageDir, name);
|
||||
for (const entry of fs.readdirSync(packageDir, { withFileTypes: true })) {
|
||||
if (!WORKSPACE_RUNTIME_ENTRY_PATTERN.test(entry.name)) {
|
||||
unexpected.push(`${name}/${entry.name}`);
|
||||
}
|
||||
}
|
||||
for (const file of listFiles(packageDir)) {
|
||||
if (SOURCE_ARTIFACT_PATTERN.test(file)) {
|
||||
unexpected.push(path.relative(stageDir, file).split(path.sep).join('/'));
|
||||
}
|
||||
if (
|
||||
/\.(?:[cm]?js)$/i.test(file)
|
||||
&& /(?:\/\/|\/\*)[#@]\s*sourceMappingURL\s*=/.test(fs.readFileSync(file, 'utf8'))
|
||||
) {
|
||||
unexpected.push(`${path.relative(stageDir, file).split(path.sep).join('/')} -> sourceMappingURL`);
|
||||
}
|
||||
}
|
||||
unexpected.push(...validateWorkspaceRuntimeTargets(name, packageDir));
|
||||
}
|
||||
|
||||
if (unexpected.length === 0) return;
|
||||
console.error(
|
||||
'[stage-sidecar-deps] FATAL - first-party runtime payload contains source/build artifacts:\n'
|
||||
+ unexpected.map((entry) => ` - ${entry}`).join('\n'),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function listPackageDirs(nodeModulesDir) {
|
||||
if (!fs.existsSync(nodeModulesDir)) return [];
|
||||
const packageDirs = [];
|
||||
@@ -426,6 +593,32 @@ function dirSizeMB(dir) {
|
||||
return (bytes / 1024 / 1024).toFixed(1);
|
||||
}
|
||||
|
||||
function preserveBundledNpmRuntime() {
|
||||
const required = [
|
||||
path.join(bundledNpmRuntimeDir, 'package.json'),
|
||||
path.join(bundledNpmRuntimeDir, 'NODE-LICENSE'),
|
||||
path.join(bundledNpmRuntimeDir, 'node_modules', 'npm', 'LICENSE'),
|
||||
path.join(bundledNpmRuntimeDir, 'node_modules', 'npm', 'bin', 'npm-cli.js'),
|
||||
path.join(bundledNpmRuntimeDir, 'node_modules', 'npm', 'bin', 'npx-cli.js'),
|
||||
path.join(bundledNpmRuntimeDir, 'bin', platform === 'win32' ? 'npm.cmd' : 'npm'),
|
||||
path.join(bundledNpmRuntimeDir, 'bin', platform === 'win32' ? 'npx.cmd' : 'npx'),
|
||||
];
|
||||
const missing = required.filter((file) => !fs.existsSync(file));
|
||||
if (missing.length > 0) {
|
||||
console.error(
|
||||
'[stage-sidecar-deps] FATAL - bundled npm runtime is incomplete:\n'
|
||||
+ missing.map((file) => ` - ${path.relative(resourcesDir, file)}`).join('\n')
|
||||
+ '\n Run `node scripts/bundle-node.mjs` before staging sidecar dependencies.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-node-runtime-'));
|
||||
const snapshot = path.join(temporaryRoot, 'waggle-node-runtime');
|
||||
fs.cpSync(bundledNpmRuntimeDir, snapshot, { recursive: true, dereference: true });
|
||||
return { snapshot, temporaryRoot };
|
||||
}
|
||||
|
||||
// ── main ───────────────────────────────────────────────────────────
|
||||
console.log(`[stage-sidecar-deps] Platform: ${platform}-${arch}`);
|
||||
|
||||
@@ -435,24 +628,57 @@ console.log(`[stage-sidecar-deps] Platform: ${platform}-${arch}`);
|
||||
execFileSync(process.execPath, [hookRuntimeBuild], { cwd: root, stdio: 'inherit' });
|
||||
|
||||
// Fresh stage dir each run so a removed dep never lingers in a stale bundle.
|
||||
fs.rmSync(stageDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(stageDir, { recursive: true });
|
||||
const preservedNpmRuntime = preserveBundledNpmRuntime();
|
||||
try {
|
||||
fs.rmSync(stageDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(stageDir, { recursive: true });
|
||||
fs.cpSync(preservedNpmRuntime.snapshot, bundledNpmRuntimeDir, {
|
||||
recursive: true,
|
||||
dereference: true,
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(preservedNpmRuntime.temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
const externals = readExternalPackages();
|
||||
const runtimeRoots = new Set([...externals, ...HOOK_RUNTIME_ROOTS]);
|
||||
const runtimeRoots = new Set([
|
||||
...externals,
|
||||
...HOOK_RUNTIME_ROOTS,
|
||||
...DYNAMIC_RUNTIME_ROOTS,
|
||||
]);
|
||||
const staged = [...runtimeRoots].filter((n) => !SKIP.has(n)).sort();
|
||||
const skipped = [...externals].filter((n) => SKIP.has(n)).sort();
|
||||
console.log(`[stage-sidecar-deps] Bundle/runtime roots: ${runtimeRoots.size} (${staged.length} to stage, ${skipped.length} skipped)`);
|
||||
if (skipped.length) console.log(`[stage-sidecar-deps] skipped: ${skipped.join(', ')}`);
|
||||
|
||||
const closure = stageClosure(runtimeRoots);
|
||||
const archiveParser = readManifest(path.join(stageDir, 'adm-zip'));
|
||||
const [archiveParserMajor, archiveParserMinor] = String(archiveParser.version || '')
|
||||
.split('.')
|
||||
.map(Number);
|
||||
if (!(archiveParserMajor > 0 || archiveParserMinor >= 6)) {
|
||||
console.error(
|
||||
'[stage-sidecar-deps] FATAL - adm-zip runtime root must be version 0.6.0 or newer',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
pruneOnnxRuntime();
|
||||
pruneSkipListed(stageDir);
|
||||
pruneRuntimeOnlyDirs(stageDir);
|
||||
for (const name of stagedWorkspaceNames) {
|
||||
pruneFirstPartyBuildArtifacts(path.join(stageDir, name));
|
||||
}
|
||||
if (prunedRuntimeDirs > 0) {
|
||||
console.log(`[stage-sidecar-deps] Pruned ${prunedRuntimeDirs} runtime-unused package artifact dir(s)`);
|
||||
}
|
||||
if (prunedRuntimeFiles > 0) {
|
||||
console.log(`[stage-sidecar-deps] Pruned ${prunedRuntimeFiles} first-party source/build artifact file(s)`);
|
||||
}
|
||||
if (strippedSourceMapDirectives > 0) {
|
||||
console.log(`[stage-sidecar-deps] Stripped ${strippedSourceMapDirectives} first-party source-map directive(s)`);
|
||||
}
|
||||
assertWorkspaceRuntimeOnly();
|
||||
assertWindowsMsiSafeResourcePaths();
|
||||
assertStagedNodeModulesSelfContained();
|
||||
|
||||
|
||||
1231
scripts/test-windows-external-agents.ps1
Normal file
1231
scripts/test-windows-external-agents.ps1
Normal file
File diff suppressed because it is too large
Load Diff
2239
scripts/test-windows-official-auth-canaries.ps1
Normal file
2239
scripts/test-windows-official-auth-canaries.ps1
Normal file
File diff suppressed because it is too large
Load Diff
1426
scripts/verify-codex-hook-discovery.mjs
Normal file
1426
scripts/verify-codex-hook-discovery.mjs
Normal file
File diff suppressed because it is too large
Load Diff
2882
scripts/verify-codex-tool-denial.mjs
Normal file
2882
scripts/verify-codex-tool-denial.mjs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user