diff --git a/.gitea/workflows/p0a-ci.yml b/.gitea/workflows/p0a-ci.yml new file mode 100644 index 0000000..9473ab3 --- /dev/null +++ b/.gitea/workflows/p0a-ci.yml @@ -0,0 +1,41 @@ +name: Dada P0-A isolated Windows CI + +on: + push: + pull_request: + workflow_dispatch: + +jobs: + validate-and-package: + runs-on: [self-hosted, windows, x64, dada-isolated] + timeout-minutes: 60 + env: + CI: "true" + DADA_EXTERNAL_MODE: mock + steps: + - name: Check out frozen revision + uses: actions/checkout@v4 + - name: Enable frozen package manager + shell: powershell + run: corepack enable + - name: Install frozen dependencies + shell: powershell + run: pnpm install --frozen-lockfile + - name: Verify isolated CI policy + shell: powershell + run: node scripts/verify-ci-isolation.mjs + - name: Run complete automated suite + shell: powershell + run: pnpm test:all + - name: Build candidate portable package + shell: powershell + run: pnpm package:portable + - name: Upload candidate package and evidence + uses: actions/upload-artifact@v4 + with: + name: dada-p0a-candidate-${{ gitea.sha }} + path: | + .build/portable-release/*.zip + .build/portable-release/*.sha256 + artifacts/tdd/** + if-no-files-found: error diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index bdd7969..a62f488 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -59,7 +59,7 @@ export interface CreateAppOptions { publicAssets?: PublicAssetResolver; } -const supportGateDirectory = resolve("apps/web/support-gate"); +const supportGateDirectory = resolve(process.env.DADA_SUPPORT_GATE_ROOT ?? "apps/web/support-gate"); const supportGateHtml = readFileSync(resolve(supportGateDirectory, "index.html"), "utf8"); const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8"); const supportGateJavaScript = readFileSync(resolve(supportGateDirectory, "support-gate.js"), "utf8"); diff --git a/apps/api/src/local-data-root.ts b/apps/api/src/local-data-root.ts index 33d9f22..258db9d 100644 --- a/apps/api/src/local-data-root.ts +++ b/apps/api/src/local-data-root.ts @@ -192,7 +192,8 @@ export function resolvePathWithinRoot(root: string, objectKey: string) { } function openInstanceDatabase(databasePath: string) { - const database = new Database(databasePath); + const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING; + const database = new Database(databasePath, nativeBinding ? { nativeBinding } : undefined); database.pragma("journal_mode = WAL"); database.exec(` CREATE TABLE instance_metadata ( diff --git a/apps/api/src/managed-storage.ts b/apps/api/src/managed-storage.ts index 58af953..4b19087 100644 --- a/apps/api/src/managed-storage.ts +++ b/apps/api/src/managed-storage.ts @@ -146,7 +146,8 @@ export class ManagedStorage { throw new Error("database_outside_data_root"); } mkdirSync(dirname(this.databasePath), { recursive: true }); - this.database = new Database(this.databasePath); + const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING; + this.database = new Database(this.databasePath, nativeBinding ? { nativeBinding } : undefined); this.database.pragma("journal_mode = WAL"); this.database.pragma("foreign_keys = ON"); this.database.pragma("busy_timeout = 5000"); diff --git a/apps/worker/src/storage-status.ts b/apps/worker/src/storage-status.ts index b8d0444..9c64047 100644 --- a/apps/worker/src/storage-status.ts +++ b/apps/worker/src/storage-status.ts @@ -9,7 +9,8 @@ export class WorkerStorageStatus { private readonly database: BetterSqlite3.Database; constructor(databasePath: string) { - this.database = new Database(databasePath); + const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING; + this.database = new Database(databasePath, nativeBinding ? { nativeBinding } : undefined); this.database.pragma("busy_timeout = 5000"); } diff --git a/ci/runner-isolation-policy.json b/ci/runner-isolation-policy.json new file mode 100644 index 0000000..5623262 --- /dev/null +++ b/ci/runner-isolation-policy.json @@ -0,0 +1,12 @@ +{ + "schema_version": "1.0", + "required_labels": ["self-hosted", "windows", "x64", "dada-isolated"], + "capacity": 1, + "external_mode": "mock", + "forbidden_inputs": [ + "windows_credential_manager", + "LocalDataRoot", + "canonical_asset_archive" + ], + "artifact_scope": ["portable-package", "tdd-evidence"] +} diff --git a/package.json b/package.json index d7d7f60..f2c7bc4 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", - "test:package": "pnpm run typecheck && pnpm --filter @dada/shared-contracts build && pnpm --filter @dada/web build && pnpm --filter @dada/api build && pnpm --filter @dada/worker build && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs", + "test:package": "pnpm run typecheck && node --test tests/package/wp0-09-portable.test.mjs && node scripts/package-smoke.mjs && node scripts/loopback-boundary-smoke.mjs", + "package:portable": "node scripts/build-portable.mjs", "generate:openapi": "node scripts/generate-openapi.mjs", "check:openapi": "node scripts/check-openapi.mjs", "validate:tdd-trace": "node scripts/validate-tdd-trace.mjs", @@ -36,7 +37,9 @@ "test:wp0-07": "node scripts/run-wp0-07-validation.mjs", "test:wp0-07:red": "node scripts/run-wp0-07-validation.mjs --phase red", "test:wp0-08": "node scripts/run-wp0-08-validation.mjs", - "test:wp0-08:red": "node scripts/run-wp0-08-validation.mjs --phase red" + "test:wp0-08:red": "node scripts/run-wp0-08-validation.mjs --phase red", + "test:wp0-09": "node scripts/run-wp0-09-validation.mjs", + "test:wp0-09:red": "node scripts/run-wp0-09-validation.mjs --phase red" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/scripts/build-portable.mjs b/scripts/build-portable.mjs new file mode 100644 index 0000000..9f56550 --- /dev/null +++ b/scripts/build-portable.mjs @@ -0,0 +1,12 @@ +import { resolve } from "node:path"; + +import { buildAndValidatePortablePackage } from "./lib/portable-package.mjs"; + +const outputIndex = process.argv.indexOf("--output"); +const outputRoot = outputIndex >= 0 ? resolve(process.argv[outputIndex + 1]) : resolve(".build", "portable-release"); +const result = await buildAndValidatePortablePackage({ outputRoot }); +console.log(JSON.stringify({ + package: result.packageManifest.package_name, + sha256: result.packageManifest.zip_sha256, + status: result.status, +}, null, 2)); diff --git a/scripts/lib/portable-package.mjs b/scripts/lib/portable-package.mjs new file mode 100644 index 0000000..58bc5e8 --- /dev/null +++ b/scripts/lib/portable-package.mjs @@ -0,0 +1,395 @@ +import { createHash } from "node:crypto"; +import { createRequire } from "node:module"; +import { spawn, spawnSync } from "node:child_process"; +import { + copyFileSync, + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + readFileSync, + readdirSync, + rmdirSync, + statSync, + unlinkSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, dirname, join, relative, resolve, sep } from "node:path"; + +import { frozenRuntime } from "../frozen-versions.mjs"; + +const repositoryRoot = resolve(import.meta.dirname, "..", ".."); +const fixedPort = 43121; +const textExtensions = new Set([".cjs", ".css", ".html", ".js", ".json", ".mjs", ".txt", ".xml"]); +const developmentOnlyDependencyDirectories = new Set([".github", "benchmark", "benchmarks", "docs", "examples", "test", "tests"]); + +function debug(message) { + if (process.env.DADA_PACKAGE_DEBUG === "1") process.stderr.write(`[portable-package] ${message}\n`); +} + +function run(command, args, options = {}) { + const executable = process.platform === "win32" && command === "pnpm" ? (process.env.ComSpec ?? "cmd.exe") : command; + const actualArgs = executable === command ? args : ["/d", "/s", "/c", [command, ...args].join(" ")]; + const result = spawnSync(executable, actualArgs, { cwd: repositoryRoot, encoding: "utf8", stdio: "pipe", ...options }); + if (result.status !== 0) { + throw new Error(`${command} ${args.join(" ")} failed:\n${result.stdout ?? ""}\n${result.stderr ?? ""}`); + } + return result.stdout?.trim() ?? ""; +} + +function ensureBuildOutput(path) { + const buildRoot = resolve(repositoryRoot, ".build"); + const resolved = resolve(path); + if (resolved !== buildRoot && !resolved.startsWith(`${buildRoot}${sep}`)) { + throw new Error("Portable package output must remain under the repository .build directory."); + } + return resolved; +} + +function json(path) { + return JSON.parse(readFileSync(path, "utf8")); +} + +function writeJson(path, value) { + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(value, null, 2)}\n`); +} + +function copyTree(source, destination, filter = () => true) { + if (!filter(source)) return; + const attributes = statSync(source); + if (attributes.isDirectory()) { + mkdirSync(destination, { recursive: true }); + for (const entry of readdirSync(source)) { + copyTree(join(source, entry), join(destination, entry), filter); + } + return; + } + if (attributes.isFile()) copyFileSync(source, destination); +} + +function removeTree(path) { + if (!existsSync(path)) return; + const attributes = lstatSync(path); + if (!attributes.isDirectory() || attributes.isSymbolicLink()) { + unlinkSync(path); + return; + } + for (const entry of readdirSync(path)) removeTree(join(path, entry)); + rmdirSync(path); +} + +function packageRootFromEntry(entryPath, expectedName) { + let current = dirname(entryPath); + while (current !== dirname(current)) { + const manifestPath = join(current, "package.json"); + if (existsSync(manifestPath) && json(manifestPath).name === expectedName) return current; + current = dirname(current); + } + throw new Error(`Cannot resolve package root for ${expectedName}.`); +} + +function copyRuntimeDependencies(sourceRoot, destinationRoot, rootNames) { + debug(`resolve dependencies for ${relative(repositoryRoot, sourceRoot)}`); + const records = new Map(); + function copyResolved(name, requireFrom, destinationNodeModules, ancestors) { + let entry; + try { + entry = requireFrom.resolve(name); + } catch (error) { + throw new Error(`Runtime dependency ${name} is unavailable from ${sourceRoot}.`, { cause: error }); + } + const root = packageRootFromEntry(entry, name); + const manifest = json(join(root, "package.json")); + const identity = `${manifest.name}@${manifest.version}`; + const destination = join(destinationNodeModules, ...name.split("/")); + debug(`copy dependency ${identity}`); + mkdirSync(dirname(destination), { recursive: true }); + copyTree(root, destination, (source) => { + const name = basename(source); + return name !== "node_modules" && !developmentOnlyDependencyDirectories.has(name); + }); + records.set(identity, { name: manifest.name, version: manifest.version }); + if (ancestors.has(identity)) return; + const nestedAncestors = new Set(ancestors).add(identity); + const nestedRequire = createRequire(join(root, "package.json")); + for (const dependency of Object.keys(manifest.dependencies ?? {})) { + copyResolved(dependency, nestedRequire, join(destination, "node_modules"), nestedAncestors); + } + } + const rootRequire = createRequire(join(sourceRoot, "package.json")); + for (const name of rootNames) copyResolved(name, rootRequire, join(destinationRoot, "node_modules"), new Set()); + return [...records.values()] + .sort((left, right) => left.name.localeCompare(right.name)); +} + +function listFiles(root) { + const files = []; + const reparsePoints = []; + function walk(current) { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const path = join(current, entry.name); + const attributes = lstatSync(path); + if (attributes.isSymbolicLink()) reparsePoints.push(relative(root, path).replaceAll("\\", "/")); + else if (entry.isDirectory()) walk(path); + else if (entry.isFile()) files.push(path); + } + } + walk(root); + return { files, reparsePoints }; +} + +function fileSha256(path) { + return createHash("sha256").update(readFileSync(path)).digest("hex").toUpperCase(); +} + +function copyApplication(source, destination, runtimeDependencies) { + mkdirSync(destination, { recursive: true }); + copyTree(join(source, "dist"), join(destination, "dist")); + writeJson(join(destination, "package.json"), { private: true, type: "module" }); + return copyRuntimeDependencies(source, destination, runtimeDependencies); +} + +function buildArtifacts(stagingRoot) { + debug("build workspace artifacts"); + run("pnpm", ["--filter", "@dada/shared-contracts", "build"]); + run("pnpm", ["--filter", "@dada/web", "build"]); + run("pnpm", ["--filter", "@dada/api", "build"]); + run("pnpm", ["--filter", "@dada/worker", "build"]); + run("dotnet", ["restore", "supervisor/Dada.Supervisor/Dada.Supervisor.csproj", "--configfile", "NuGet.Config"]); + run("dotnet", [ + "publish", + "supervisor/Dada.Supervisor/Dada.Supervisor.csproj", + "--configuration", "Release", + "--no-restore", + "--output", stagingRoot, + "-p:DebugType=None", + "-p:DebugSymbols=false", + ]); +} + +function createZip(packageDirectory, zipPath) { + const escapedPackage = packageDirectory.replaceAll("'", "''"); + const escapedZip = zipPath.replaceAll("'", "''"); + run("powershell.exe", [ + "-NoProfile", + "-NonInteractive", + "-Command", + `Compress-Archive -LiteralPath '${escapedPackage}' -DestinationPath '${escapedZip}' -CompressionLevel Optimal`, + ]); +} + +async function waitForHealth(child) { + const deadline = Date.now() + 15_000; + let lastError; + while (Date.now() < deadline) { + if (child.exitCode !== null) throw new Error(`Packaged API exited early with code ${child.exitCode}.`); + try { + const response = await fetch(`http://127.0.0.1:${fixedPort}/healthz`, { headers: { Host: `127.0.0.1:${fixedPort}` } }); + if (response.ok) return response.json(); + } catch (error) { + lastError = error; + } + await new Promise((resolveDelay) => setTimeout(resolveDelay, 100)); + } + throw new Error("Packaged API did not become healthy on fixed port 43121.", { cause: lastError }); +} + +async function verifyExtractedPackage(zipPath, packageName) { + const extractRoot = mkdtempSync(join(tmpdir(), "dada-wp0-09-")); + try { + const escapedZip = zipPath.replaceAll("'", "''"); + const escapedExtract = extractRoot.replaceAll("'", "''"); + run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", `Expand-Archive -LiteralPath '${escapedZip}' -DestinationPath '${escapedExtract}'`]); + const packageRoot = join(extractRoot, packageName); + const nativeResult = spawnSync(join(packageRoot, "runtime", "node.exe"), [join(packageRoot, "server", "native-smoke.cjs")], { + cwd: packageRoot, + encoding: "utf8", + }); + if (nativeResult.status !== 0) throw new Error(`Packaged native module failed:\n${nativeResult.stderr ?? ""}`); + const native = JSON.parse(nativeResult.stdout.trim()); + const supervisorProbe = spawnSync(join(packageRoot, "Dada.exe"), ["--package-layout-probe"], { + cwd: packageRoot, + encoding: "utf8", + windowsHide: true, + }); + if (supervisorProbe.status !== 2) throw new Error(`Packaged Dada.exe usage probe exited with ${supervisorProbe.status}.`); + const api = spawn(join(packageRoot, "runtime", "node.exe"), [join(packageRoot, "server", "api.mjs")], { + cwd: packageRoot, + env: { ...process.env, DADA_SUPPORT_GATE_ROOT: join(packageRoot, "web", "support-gate") }, + stdio: "ignore", + windowsHide: true, + }); + try { + const health = await waitForHealth(api); + const releaseGate = await fetch(`http://127.0.0.1:${fixedPort}/api/v1/support/check`, { + body: JSON.stringify({ + brands: [{ brand: "Google Chrome", version: "150" }], + full_version_list: [{ brand: "Google Chrome", version: "150.0.0.0" }], + platform: "Windows", + }), + headers: { + "content-type": "application/json", + "sec-ch-ua": '"Google Chrome";v="150"', + "sec-ch-ua-full-version-list": '"Google Chrome";v="150.0.0.0"', + "sec-ch-ua-platform": '"Windows"', + }, + method: "POST", + }); + if (releaseGate.status !== 426) throw new Error(`Candidate RELEASE.json unexpectedly passed with ${releaseGate.status}.`); + return { + api: { executable: "runtime/node.exe", health, pid: api.pid, release_gate: { status_code: releaseGate.status }, status: "passed" }, + native, + supervisor: { credential_store_access: false, executable: "Dada.exe", exit_code: supervisorProbe.status, status: "passed" }, + }; + } finally { + if (api.exitCode === null) { + const exited = new Promise((resolveExit) => api.once("exit", resolveExit)); + api.kill(); + await exited; + } + } + } finally { + removeTree(extractRoot); + } +} + +function scanPackage(packageDirectory) { + const { files, reparsePoints } = listFiles(packageDirectory); + const disallowedMatches = []; + const repositoryPath = repositoryRoot.toLowerCase(); + const windowsUserPath = `${process.env.SystemDrive ?? "C:"}\\Users\\`.toLowerCase(); + const userName = (process.env.USERNAME ?? "").toLowerCase(); + for (const path of files) { + const extension = path.slice(path.lastIndexOf(".")).toLowerCase(); + if (!textExtensions.has(extension)) continue; + const content = readFileSync(path, "utf8").toLowerCase(); + const relativePath = relative(packageDirectory, path).replaceAll("\\", "/"); + if (content.includes(repositoryPath)) disallowedMatches.push({ path: relativePath, rule: "repository_absolute_path" }); + if (content.includes(windowsUserPath)) disallowedMatches.push({ path: relativePath, rule: "windows_user_path" }); + if (userName.length >= 3 && content.includes(userName)) disallowedMatches.push({ path: relativePath, rule: "windows_username" }); + if (/\b(?:sk|key)-[a-z0-9_-]{16,}\b/i.test(content)) disallowedMatches.push({ path: relativePath, rule: "credential_shape" }); + if (!relativePath.includes("/node_modules/") && !relativePath.startsWith("LICENSES/")) { + for (const email of content.match(/[a-z0-9._%+-]+@[a-z0-9.-]+\.[a-z]{2,}/gi) ?? []) { + if (!email.endsWith(".invalid")) disallowedMatches.push({ path: relativePath, rule: "non_fixture_email" }); + } + } + } + return { disallowed_matches: disallowedMatches, reparse_points: reparsePoints, scanned_files: files.length, status: disallowedMatches.length === 0 && reparsePoints.length === 0 ? "passed" : "failed" }; +} + +export async function buildAndValidatePortablePackage({ evidenceDirectory, outputRoot }) { + if (process.platform !== frozenRuntime.os || process.arch !== frozenRuntime.arch || process.version.slice(1) !== frozenRuntime.node) { + throw new Error("Portable package build requires frozen Node 24.13.0 on win-x64."); + } + const resolvedOutput = ensureBuildOutput(outputRoot); + removeTree(resolvedOutput); + mkdirSync(resolvedOutput, { recursive: true }); + const packageManifest = json(join(repositoryRoot, "package.json")); + const appVersion = packageManifest.version; + const packageName = `Dada-P0A-${appVersion}-win-x64`; + const packageDirectory = join(resolvedOutput, packageName); + const supervisorPublish = join(resolvedOutput, "supervisor-publish"); + buildArtifacts(supervisorPublish); + debug("assemble package layout"); + mkdirSync(packageDirectory, { recursive: true }); + + const supervisorExecutable = join(supervisorPublish, "Dada.Supervisor.exe"); + if (!existsSync(supervisorExecutable)) throw new Error("Supervisor publish did not produce Dada.Supervisor.exe."); + copyFileSync(supervisorExecutable, join(packageDirectory, "Dada.exe")); + for (const name of ["Dada.Supervisor.deps.json", "Dada.Supervisor.dll", "Dada.Supervisor.runtimeconfig.json"]) { + copyFileSync(join(supervisorPublish, name), join(packageDirectory, name)); + } + mkdirSync(join(packageDirectory, "runtime"), { recursive: true }); + copyFileSync(process.execPath, join(packageDirectory, "runtime", "node.exe")); + + const serverRoot = join(packageDirectory, "server"); + debug("copy API application"); + const apiDependencies = copyApplication(join(repositoryRoot, "apps", "api"), join(serverRoot, "api"), ["@fastify/swagger", "@sinclair/typebox", "better-sqlite3", "fastify"]); + debug("copy Worker application"); + const workerDependencies = copyApplication(join(repositoryRoot, "apps", "worker"), join(serverRoot, "worker"), ["better-sqlite3"]); + const sharedDestination = join(serverRoot, "api", "node_modules", "@dada", "shared-contracts"); + mkdirSync(sharedDestination, { recursive: true }); + copyTree(join(repositoryRoot, "packages", "shared-contracts", "dist"), join(sharedDestination, "dist")); + copyFileSync(join(repositoryRoot, "packages", "shared-contracts", "package.json"), join(sharedDestination, "package.json")); + writeFileSync(join(serverRoot, "api.mjs"), 'import "./api/dist/main.js";\n'); + writeFileSync(join(serverRoot, "worker.mjs"), 'import "./worker/dist/worker.js";\n'); + + const nativeSource = join(repositoryRoot, "apps", "api", "node_modules", "better-sqlite3", "prebuilds", "win32-x64.node"); + mkdirSync(join(serverRoot, "native"), { recursive: true }); + copyFileSync(nativeSource, join(serverRoot, "native", "better_sqlite3.node")); + writeFileSync(join(serverRoot, "native-smoke.cjs"), [ + '"use strict";', + 'const { createRequire } = require("node:module");', + 'const { join } = require("node:path");', + 'const requireApi = createRequire(join(__dirname, "api", "package.json"));', + 'const Database = requireApi("better-sqlite3");', + 'const database = new Database(":memory:", { nativeBinding: join(__dirname, "native", "better_sqlite3.node") });', + 'const row = database.prepare("select 1 as ok").get();', + 'database.close();', + 'process.stdout.write(JSON.stringify({ status: row.ok === 1 ? "passed" : "failed" }));', + '', + ].join("\n")); + + copyTree(join(repositoryRoot, "apps", "web", "dist"), join(packageDirectory, "web")); + copyTree(join(repositoryRoot, "apps", "web", "support-gate"), join(packageDirectory, "web", "support-gate")); + writeJson(join(packageDirectory, "migrations", "manifest.json"), { migrations: [], schema_version: "0" }); + writeJson(join(packageDirectory, "asset-metadata", "manifest.json"), { resources: [], schema_version: "1.0", source: "external_read_only" }); + writeJson(join(packageDirectory, "LICENSES", "third-party.json"), { api: apiDependencies, runtime: { node: frozenRuntime.node }, schema_version: "1.0", worker: workerDependencies }); + + const commit = run("git", ["rev-parse", "HEAD"]); + writeJson(join(packageDirectory, "RELEASE.json"), { + app_version: appVersion, + browsers: [], + build_commit: commit, + release_status: "candidate_unvalidated", + schema_version: "0", + windows_build: null, + }); + writeFileSync(join(packageDirectory, "START-HERE.txt"), [ + "Dada P0-A candidate package", + "", + "This candidate is unsigned and is not a final P0-A release.", + "Verify the adjacent SHA-256 file before first launch.", + "Windows SmartScreen may warn on first launch because the executable is unsigned.", + "For an antivirus alert, compare the package hash with the Gitea build record.", + "Do not disable antivirus protection, add broad exclusions, or skip hash verification.", + "To update, exit Dada from the tray and replace the complete program directory.", + "Dada uses 127.0.0.1:43121 and does not support LAN or remote access.", + "A final RELEASE.json is created only after WP-7 acceptance.", + "", + ].join("\r\n")); + + removeTree(supervisorPublish); + const packageScan = scanPackage(packageDirectory); + if (packageScan.status !== "passed") throw new Error(`Portable package scan failed: ${JSON.stringify(packageScan.disallowed_matches)}`); + const zipPath = join(resolvedOutput, `${packageName}.zip`); + createZip(packageDirectory, zipPath); + const zipSha256 = fileSha256(zipPath); + const shaPath = `${zipPath}.sha256`; + writeFileSync(shaPath, `${zipSha256} ${basename(zipPath)}\n`); + const processTree = await verifyExtractedPackage(zipPath, packageName); + const fileEntries = listFiles(packageDirectory).files.map((path) => ({ + path: relative(packageDirectory, path).replaceAll("\\", "/"), + sha256: fileSha256(path), + size: statSync(path).size, + })).sort((left, right) => left.path.localeCompare(right.path)); + const finalManifest = { + app_version: appVersion, + files: fileEntries, + fixed_port: fixedPort, + package_name: packageName, + release_status: "candidate_unvalidated", + schema_version: "1.0", + zip_sha256: zipSha256, + }; + if (evidenceDirectory) { + mkdirSync(evidenceDirectory, { recursive: true }); + writeJson(join(evidenceDirectory, "package-manifest.json"), finalManifest); + writeFileSync(join(evidenceDirectory, "sha256.txt"), `${zipSha256} ${basename(zipPath)}\n`); + writeJson(join(evidenceDirectory, "package-scan.json"), packageScan); + writeJson(join(evidenceDirectory, "process-tree.json"), processTree); + } + return { packageManifest: finalManifest, packageScan, processTree, status: "passed" }; +} diff --git a/scripts/run-wp0-09-validation.mjs b/scripts/run-wp0-09-validation.mjs new file mode 100644 index 0000000..a75d7aa --- /dev/null +++ b/scripts/run-wp0-09-validation.mjs @@ -0,0 +1,69 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const phaseIndex = process.argv.indexOf("--phase"); +const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green"; +if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`); +const runId = process.env.DADA_TDD_RUN_ID ?? `wp0-09-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const caseId = "TDD-WP0-PKG-001-portable-zip"; +const caseDirectory = resolve(runDirectory, "cases", caseId); +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +mkdirSync(caseDirectory, { recursive: true }); + +const commandSpecs = phase === "red" + ? [["node --test tests/package/wp0-09-portable.test.mjs", ["node", "--test", "tests/package/wp0-09-portable.test.mjs"]]] + : [ + ["pnpm test:security", ["pnpm", "test:security"]], + ["pnpm test:package", ["pnpm", "test:package"]], + ["pnpm validate:tdd-trace", ["pnpm", "validate:tdd-trace"]], + ]; +const environment = { ...process.env, DADA_EVIDENCE_DIR_PACKAGE: caseDirectory }; +const startedAt = new Date().toISOString(); +const commands = []; +for (const [command, invocation] of commandSpecs) { + const started_at = new Date().toISOString(); + const [program, ...args] = invocation; + const executable = process.platform === "win32" ? (process.env.ComSpec ?? "cmd.exe") : program; + const actualArgs = process.platform === "win32" ? ["/d", "/s", "/c", [program, ...args].join(" ")] : args; + const execution = spawnSync(executable, actualArgs, { encoding: "utf8", env: environment }); + if (execution.stdout) process.stdout.write(execution.stdout); + if (execution.stderr) process.stderr.write(execution.stderr); + commands.push({ command, exit_code: execution.status ?? 1, finished_at: new Date().toISOString(), started_at }); +} + +writeFileSync(resolve(caseDirectory, "commands.json"), `${JSON.stringify({ commands, phase, run_id: runId, schema_version: "1.0" }, null, 2)}\n`); +const evidenceRefs = ["package-manifest.json", "sha256.txt", "package-scan.json", "process-tree.json"]; +const missingEvidence = phase === "green" ? evidenceRefs.filter((path) => !existsSync(resolve(caseDirectory, path))) : []; +const commandState = phase === "red" ? commands.every((item) => item.exit_code !== 0) : commands.every((item) => item.exit_code === 0); +const status = phase === "red" ? (commandState ? "red_confirmed" : "failed") : (commandState && missingEvidence.length === 0 ? "passed" : "failed"); +const result = { + acceptance_criteria: ["AC-24", "AC-41", "AC-56"], + automation: ["automated"], + commit: spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(), + environment: { arch: process.arch, node: process.version.slice(1), os: process.platform }, + evidence_refs: evidenceRefs, + finished_at: new Date().toISOString(), + layer: ["PACKAGE_SECURITY"], + manifest: { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() }, + missing_evidence: missingEvidence, + parent_family: "TDD-WP0-PKG-001", + phase, + release_gate: ["work_package:WP-0", "release:P0-A"], + requirements: ["NFR-01", "NFR-09"], + run_id: runId, + schema_version: "1.0", + started_at: startedAt, + status, + task_id: "TASK-WP0-09", + test_id: caseId, + work_package: "WP-0", + worktree_under_test: spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim() ? "uncommitted implementation" : "clean committed implementation", +}; +writeFileSync(resolve(caseDirectory, "result.json"), `${JSON.stringify(result, null, 2)}\n`); +const summary = { cases: [{ missing_evidence: missingEvidence, status, test_id: caseId }], phase, run_id: runId, status }; +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`); +console.log(JSON.stringify(summary, null, 2)); +if (status !== (phase === "red" ? "red_confirmed" : "passed")) process.exit(1); diff --git a/scripts/verify-ci-isolation.mjs b/scripts/verify-ci-isolation.mjs new file mode 100644 index 0000000..2c3a7f2 --- /dev/null +++ b/scripts/verify-ci-isolation.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; + +const policy = JSON.parse(readFileSync("ci/runner-isolation-policy.json", "utf8")); +const workflow = readFileSync(".gitea/workflows/p0a-ci.yml", "utf8"); + +for (const label of policy.required_labels) assert.match(workflow, new RegExp(`\\b${label}\\b`)); +assert.match(workflow, /pnpm install --frozen-lockfile/); +assert.match(workflow, /DADA_EXTERNAL_MODE:\s*mock/); +assert.match(workflow, /pnpm test:all/); +assert.match(workflow, /pnpm package:portable/); + +const forbiddenEnvironment = [ + "DADA_LOCAL_DATA_ROOT", + "DADA_ASSET_ROOT", + "AI_GATEWAY_API_KEY", + "RESEND_API_KEY", + "AMAP_API_KEY", +]; +const present = forbiddenEnvironment.filter((name) => Object.hasOwn(process.env, name)); +assert.deepEqual(present, [], `Isolated CI received forbidden environment inputs: ${present.join(", ")}`); + +console.log(JSON.stringify({ + external_mode: policy.external_mode, + forbidden_environment_inputs: present, + required_labels: policy.required_labels, + schema_version: policy.schema_version, + status: "passed", +}, null, 2)); diff --git a/supervisor/Dada.Supervisor/SupervisorRuntime.cs b/supervisor/Dada.Supervisor/SupervisorRuntime.cs index dae1956..41c6830 100644 --- a/supervisor/Dada.Supervisor/SupervisorRuntime.cs +++ b/supervisor/Dada.Supervisor/SupervisorRuntime.cs @@ -40,8 +40,8 @@ internal sealed class SupervisorRuntime : IAsyncDisposable } var node = Path.Combine(AppContext.BaseDirectory, "runtime", "node.exe"); - var apiEntry = Path.Combine(AppContext.BaseDirectory, "apps", "api", "dist", "main.js"); - var workerEntry = Path.Combine(AppContext.BaseDirectory, "apps", "worker", "dist", "worker.js"); + var apiEntry = Path.Combine(AppContext.BaseDirectory, "server", "api.mjs"); + var workerEntry = Path.Combine(AppContext.BaseDirectory, "server", "worker.mjs"); if (!File.Exists(node) || !File.Exists(apiEntry) || !File.Exists(workerEntry)) return SupervisorState.StartupFailed; api = CreateComponent(node, apiEntry, ChildRole.Api, SupervisorState.ApiDegraded); @@ -57,6 +57,9 @@ internal sealed class SupervisorRuntime : IAsyncDisposable var component = new ManagedComponentSupervisor(async cancellationToken => { var startInfo = new ProcessStartInfo(node); + startInfo.WorkingDirectory = AppContext.BaseDirectory; + startInfo.Environment["DADA_SQLITE_NATIVE_BINDING"] = Path.Combine(AppContext.BaseDirectory, "server", "native", "better_sqlite3.node"); + startInfo.Environment["DADA_SUPPORT_GATE_ROOT"] = Path.Combine(AppContext.BaseDirectory, "web", "support-gate"); startInfo.ArgumentList.Add(entry); var child = await ManagedChildProcess.StartAsync(startInfo, role, credentials, cancellationToken); child.StatusReceived += status => diff --git a/tests/package/wp0-09-portable.test.mjs b/tests/package/wp0-09-portable.test.mjs new file mode 100644 index 0000000..8e4e5b7 --- /dev/null +++ b/tests/package/wp0-09-portable.test.mjs @@ -0,0 +1,25 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { resolve } from "node:path"; + +import { buildAndValidatePortablePackage } from "../../scripts/lib/portable-package.mjs"; + +test("builds an isolated candidate portable package", async () => { + const outputRoot = resolve(".build", "wp0-09-portable-test"); + const evidenceDirectory = process.env.DADA_EVIDENCE_DIR_PACKAGE + ? resolve(process.env.DADA_EVIDENCE_DIR_PACKAGE) + : undefined; + const result = await buildAndValidatePortablePackage({ evidenceDirectory, outputRoot }); + + assert.equal(result.status, "passed"); + assert.equal(result.packageManifest.fixed_port, 43121); + assert.equal(result.packageManifest.release_status, "candidate_unvalidated"); + assert.equal(result.packageScan.disallowed_matches.length, 0); + assert.equal(result.packageScan.reparse_points.length, 0); + assert.equal(result.processTree.api.health.bind_scope, "loopback"); + assert.equal(result.processTree.api.health.port, 43121); + assert.equal(result.processTree.api.release_gate.status_code, 426); + assert.equal(result.processTree.native.status, "passed"); + assert.equal(result.processTree.supervisor.credential_store_access, false); + assert.equal(result.processTree.supervisor.status, "passed"); +});