From f9801fb16b9139c3fb16c441e64a663d7251515f Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 15 Aug 2026 01:54:27 -0400 Subject: [PATCH 1/4] test: define Nasiko control-plane integration contract --- tests/ci/nasiko-control-plane.test.js | 230 ++++++++++++++++++++++++++ 1 file changed, 230 insertions(+) create mode 100644 tests/ci/nasiko-control-plane.test.js diff --git a/tests/ci/nasiko-control-plane.test.js b/tests/ci/nasiko-control-plane.test.js new file mode 100644 index 000000000..abdd0a74b --- /dev/null +++ b/tests/ci/nasiko-control-plane.test.js @@ -0,0 +1,230 @@ +/** + * Contract and lifecycle tests for the opt-in Nasiko control-plane bridge. + */ + +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const REPO_ROOT = path.join(__dirname, '..', '..'); + +function read(relativePath) { + return fs.readFileSync(path.join(REPO_ROOT, relativePath), 'utf8'); +} + +function readJson(relativePath) { + return JSON.parse(read(relativePath)); +} + +async function runTest(name, testFunction) { + try { + await testFunction(); + console.log(` ✓ ${name}`); + return true; + } catch (error) { + console.log(` ✗ ${name}`); + console.error(` ${error.message}`); + return false; + } +} + +function sha256Digest(value) { + const crypto = require('crypto'); + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +async function main() { + console.log('\n=== Testing Nasiko control-plane integration ===\n'); + + const tests = [ + ['qualifies only pinned platform releases and rejects latest', () => { + const { + getQualifiedRelease, + normalizePlatform, + } = require('../../scripts/lib/nasiko-release'); + + assert.deepStrictEqual(normalizePlatform('darwin', 'arm64'), { + os: 'darwin', + arch: 'arm64', + binaryName: 'nasiko', + }); + assert.deepStrictEqual(normalizePlatform('win32', 'x64'), { + os: 'windows', + arch: 'amd64', + binaryName: 'nasiko.exe', + }); + assert.match(getQualifiedRelease('v0.1.0', 'linux', 'x64').manifestDigest, /^sha256:[a-f0-9]{64}$/); + assert.throws(() => getQualifiedRelease('latest', 'darwin', 'arm64'), /pinned version/i); + assert.throws(() => getQualifiedRelease('v1.0.0', 'darwin', 'arm64'), /not qualified/i); + assert.throws(() => normalizePlatform('freebsd', 'x64'), /unsupported platform/i); + assert.throws(() => normalizePlatform('darwin', 'ia32'), /unsupported architecture/i); + }], + ['requires explicit consent while dry-run remains offline and read-only', async () => { + const { installNasiko } = require('../../scripts/lib/nasiko-release'); + let fetchCount = 0; + const dependencies = { + fetchBytes: async () => { + fetchCount += 1; + throw new Error('dry-run fetched the network'); + }, + platform: 'darwin', + arch: 'arm64', + }; + + await assert.rejects( + installNasiko({ version: 'v0.1.0', yes: false }, dependencies), + /explicit --yes/i + ); + const plan = await installNasiko({ version: 'v0.1.0', dryRun: true }, dependencies); + assert.strictEqual(plan.dryRun, true); + assert.strictEqual(plan.version, 'v0.1.0'); + assert.strictEqual(plan.registryOrigin, 'https://registry.nasiko.dev'); + assert.strictEqual(fetchCount, 0); + }], + ['verifies manifest and blob digests before an atomic install', async () => { + const { installNasiko } = require('../../scripts/lib/nasiko-release'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-green-')); + const manifest = Buffer.from(JSON.stringify({ + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + layers: [{ + mediaType: 'application/gzip', + digest: sha256Digest(Buffer.from('verified archive')), + size: 16, + }], + })); + const archive = Buffer.from('verified archive'); + + try { + const result = await installNasiko({ + version: 'v0.1.0', + yes: true, + installDir: installRoot, + }, { + platform: 'darwin', + arch: 'arm64', + releaseOverride: { manifestDigest: sha256Digest(manifest) }, + fetchBytes: async (url) => url.includes('/manifests/') ? manifest : archive, + inspectArchive: () => [{ path: 'nasiko', type: 'file' }], + extractArchive: (_archivePath, destination) => { + fs.writeFileSync(path.join(destination, 'nasiko'), '#!/bin/sh\necho nasiko v0.1.0\n', { mode: 0o755 }); + }, + runVersion: executable => ({ status: 0, stdout: `${executable}: nasiko v0.1.0\n`, stderr: '' }), + }); + assert.strictEqual(result.installed, true); + assert.strictEqual(result.version, 'v0.1.0'); + assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), true); + assert.strictEqual(fs.existsSync(path.join(installRoot, '.ecc-nasiko-install.json')), true); + } finally { + fs.rmSync(installRoot, { recursive: true, force: true }); + } + }], + ['rejects digest mismatch and unsafe archive entries without installing', async () => { + const { installNasiko, validateArchiveEntries } = require('../../scripts/lib/nasiko-release'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-reject-')); + const manifest = Buffer.from('{"schemaVersion":2,"layers":[]}'); + try { + await assert.rejects( + installNasiko({ version: 'v0.1.0', yes: true, installDir: installRoot }, { + platform: 'darwin', + arch: 'arm64', + releaseOverride: { manifestDigest: `sha256:${'0'.repeat(64)}` }, + fetchBytes: async () => manifest, + }), + /manifest digest mismatch/i + ); + assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), false); + assert.throws( + () => validateArchiveEntries([{ path: '../nasiko', type: 'file' }], 'nasiko'), + /unsafe archive/i + ); + assert.throws( + () => validateArchiveEntries([{ path: 'nasiko', type: 'symlink' }], 'nasiko'), + /regular file/i + ); + } finally { + fs.rmSync(installRoot, { recursive: true, force: true }); + } + }], + ['routes read-only status through an explicit absolute executable', () => { + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-status-')); + const executable = path.join(fixtureRoot, 'nasiko'); + fs.writeFileSync(executable, '#!/bin/sh\nprintf "nasiko v0.1.0\\n"\n', { mode: 0o755 }); + try { + const result = spawnSync(process.execPath, [ + path.join(REPO_ROOT, 'scripts', 'ecc.js'), + 'nasiko', + 'status', + '--json', + ], { + encoding: 'utf8', + env: { ...process.env, ECC_NASIKO_CLI_EXECUTABLE: executable }, + }); + assert.strictEqual(result.status, 0, result.stderr); + const status = JSON.parse(result.stdout); + assert.strictEqual(status.installed, true); + assert.strictEqual(status.version, 'v0.1.0'); + assert.strictEqual(status.executable, executable); + } finally { + fs.rmSync(fixtureRoot, { recursive: true, force: true }); + } + }], + ['ships a canonical opt-in skill without silently bundling Nasiko', () => { + const skill = read('skills/nasiko-control-plane/SKILL.md'); + assert.match(skill, /^name: nasiko-control-plane$/m); + assert.match(skill, /ecc nasiko status/i); + assert.match(skill, /explicit.*consent|explicit.*--yes/i); + assert.match(skill, /pinned.*v0\.1\.0/i); + assert.match(skill, /telemetry.*opt-in/i); + assert.match(skill, /never.*secrets|never.*credentials/i); + assert.match(skill, /install.*does not prove/i); + assert.doesNotMatch(skill, /curl[^\n]*\|[^\n]*bash|irm[^\n]*\|[^\n]*iex/i); + + const modules = readJson('manifests/install-modules.json').modules; + const module = modules.find(candidate => candidate.id === 'nasiko-control-plane'); + assert.ok(module, 'nasiko-control-plane module is missing'); + assert.deepStrictEqual(module.paths, ['skills/nasiko-control-plane']); + assert.deepStrictEqual(module.dependencies, ['platform-configs']); + assert.strictEqual(module.defaultInstall, false); + assert.strictEqual(module.stability, 'experimental'); + + const components = readJson('manifests/install-components.json').components; + assert.deepStrictEqual( + components.find(candidate => candidate.id === 'capability:nasiko-control-plane'), + { + id: 'capability:nasiko-control-plane', + family: 'capability', + description: 'Explicitly gated Nasiko control-plane installation, status, and agent-operations guidance with pinned artifact verification and opt-in telemetry boundaries.', + modules: ['nasiko-control-plane'], + } + ); + + const profiles = readJson('manifests/install-profiles.json').profiles; + for (const profile of Object.values(profiles)) { + assert.ok(!profile.modules.includes('nasiko-control-plane')); + } + + const packageJson = readJson('package.json'); + assert.ok(packageJson.files.includes('skills/nasiko-control-plane/')); + assert.ok(packageJson.files.includes('scripts/nasiko.js')); + assert.ok(packageJson.files.includes('scripts/lib/nasiko-release.js')); + assert.ok(!packageJson.dependencies?.nasiko); + assert.ok(!packageJson.optionalDependencies?.nasiko); + }], + ]; + + let passed = 0; + let failed = 0; + for (const [name, testFunction] of tests) { + if (await runTest(name, testFunction)) passed += 1; + else failed += 1; + } + + console.log(`\nPassed: ${passed}`); + console.log(`Failed: ${failed}`); + process.exit(failed > 0 ? 1 : 0); +} + +main(); From 0d39ae83ddf7a0857c8fa1f0ffe3c5115f6c4450 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 15 Aug 2026 01:57:26 -0400 Subject: [PATCH 2/4] feat: add pinned Nasiko control-plane bridge --- manifests/install-components.json | 8 + manifests/install-modules.json | 30 ++ package.json | 3 + scripts/ecc.js | 8 + scripts/lib/nasiko-release.js | 323 ++++++++++++++++++ scripts/nasiko.js | 134 ++++++++ skills/nasiko-control-plane/SKILL.md | 44 +++ .../nasiko-control-plane/agents/openai.yaml | 4 + 8 files changed, 554 insertions(+) create mode 100644 scripts/lib/nasiko-release.js create mode 100644 scripts/nasiko.js create mode 100644 skills/nasiko-control-plane/SKILL.md create mode 100644 skills/nasiko-control-plane/agents/openai.yaml diff --git a/manifests/install-components.json b/manifests/install-components.json index a5f976a94..971f86607 100644 --- a/manifests/install-components.json +++ b/manifests/install-components.json @@ -202,6 +202,14 @@ "ito-compute" ] }, + { + "id": "capability:nasiko-control-plane", + "family": "capability", + "description": "Explicitly gated Nasiko control-plane installation, status, and agent-operations guidance with pinned artifact verification and opt-in telemetry boundaries.", + "modules": [ + "nasiko-control-plane" + ] + }, { "id": "capability:social", "family": "capability", diff --git a/manifests/install-modules.json b/manifests/install-modules.json index 8c0ea11d8..f4560b593 100644 --- a/manifests/install-modules.json +++ b/manifests/install-modules.json @@ -635,6 +635,36 @@ "cost": "light", "stability": "beta" }, + { + "id": "nasiko-control-plane", + "kind": "skills", + "description": "Explicitly gated Nasiko control-plane installation, status, and agent-operations guidance with pinned artifact verification and opt-in telemetry boundaries.", + "paths": [ + "skills/nasiko-control-plane" + ], + "targets": [ + "claude", + "claude-project", + "cursor", + "antigravity", + "codex", + "gemini", + "opencode", + "codebuddy", + "joycode", + "qwen", + "zed", + "hermes", + "openclaw", + "kimi" + ], + "dependencies": [ + "platform-configs" + ], + "defaultInstall": false, + "cost": "light", + "stability": "experimental" + }, { "id": "social-distribution", "kind": "skills", diff --git a/package.json b/package.json index 56ac663db..d4ed22ab2 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,8 @@ "scripts/install-guided.js", "scripts/install-plan.js", "scripts/ito.js", + "scripts/nasiko.js", + "scripts/lib/nasiko-release.js", "scripts/lib/", "scripts/list-installed.js", "scripts/loop-status.js", @@ -233,6 +235,7 @@ "skills/ito-compute/", "skills/ito-inference/", "skills/ito-training/", + "skills/nasiko-control-plane/", "skills/investor-materials/", "skills/investor-outreach/", "skills/iterative-retrieval/", diff --git a/scripts/ecc.js b/scripts/ecc.js index 3caff5735..8a92fa302 100755 --- a/scripts/ecc.js +++ b/scripts/ecc.js @@ -39,6 +39,10 @@ const COMMANDS = { script: 'ito.js', description: 'Invoke the separately installed canonical Itô compute CLI', }, + nasiko: { + script: 'nasiko.js', + description: 'Install or inspect the optional pinned Nasiko control-plane CLI', + }, memory: { script: 'memory.js', description: 'Share durable context across Claude, Codex, Hermes, and other harnesses', @@ -110,6 +114,7 @@ const PRIMARY_COMMANDS = [ 'consult', 'control-pane', 'ito', + 'nasiko', 'memory', 'list-installed', 'doctor', @@ -168,6 +173,9 @@ Examples: ecc ito auth ecc ito find --gpu h200 --count 8 --nodes 1 --gpus-per-node 8 --days 30 --storage-tb 1 --start-window 2099-08-15 --max-rate 3.00 --form-factor bare_metal --contract-type reservation --fabric infiniband --region us-east-1 ecc ito status --json + ecc nasiko status --json + ecc nasiko install --version v0.1.0 --dry-run --json + ecc nasiko install --version v0.1.0 --yes --json ecc ito evals --cluster clu_prod_example --live-sixtytwo --nodes gpu-01,gpu-02 --config-dir /absolute/path/to/qualification-config ecc memory init ecc memory handoff --from codex --target claude --title "Continue migration" --stdin diff --git a/scripts/lib/nasiko-release.js b/scripts/lib/nasiko-release.js new file mode 100644 index 000000000..0ffc4b618 --- /dev/null +++ b/scripts/lib/nasiko-release.js @@ -0,0 +1,323 @@ +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const https = require('https'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); + +const REGISTRY_ORIGIN = 'https://registry.nasiko.dev'; +const REPOSITORY = 'nasiko/nasiko'; +const MAX_MANIFEST_BYTES = 1024 * 1024; +const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024; +const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/; + +const QUALIFIED_RELEASES = Object.freeze({ + 'v0.1.0': Object.freeze({ + 'linux/amd64': 'sha256:0df748a40f3d714b6b6a3376a1d13a224c05bdb8d1628f31ace5a7bee8ceb9de', + 'linux/arm64': 'sha256:655021a129c7df4621a80d16ea4eab38018530bfe9a20da646641d9f4ac5c249', + 'darwin/amd64': 'sha256:b4188482621efd7da5a2ab630f653665ab5f80b9aceae448b6bb5fc93e003f06', + 'darwin/arm64': 'sha256:ce7e54fa19f989a5d125c4409b3587ca9503bb5a07bc5ff223c60e0fbad437f0', + 'windows/amd64': 'sha256:0760fe1fc98e8fedb66796aaf891a1de9268af1338c5e88b949656fda5d9f045', + }), +}); + +function normalizePlatform(platform = process.platform, architecture = process.arch) { + const osName = platform === 'win32' ? 'windows' : platform; + if (!['linux', 'darwin', 'windows'].includes(osName)) { + throw new Error(`Unsupported platform: ${platform}`); + } + const arch = architecture === 'x64' ? 'amd64' : architecture; + if (!['amd64', 'arm64'].includes(arch)) { + throw new Error(`Unsupported architecture: ${architecture}`); + } + if (osName === 'windows' && arch !== 'amd64') { + throw new Error(`Unsupported architecture for Windows: ${architecture}`); + } + return { + os: osName, + arch, + binaryName: osName === 'windows' ? 'nasiko.exe' : 'nasiko', + }; +} + +function getQualifiedRelease(version, platform = process.platform, architecture = process.arch) { + if (!/^v\d+\.\d+\.\d+$/.test(String(version || ''))) { + throw new Error('Nasiko installation requires a pinned version such as v0.1.0; latest is not allowed.'); + } + const release = QUALIFIED_RELEASES[version]; + if (!release) { + throw new Error(`Nasiko ${version} is not qualified by this ECC release.`); + } + const normalized = normalizePlatform(platform, architecture); + const manifestDigest = release[`${normalized.os}/${normalized.arch}`]; + if (!manifestDigest) { + throw new Error(`Nasiko ${version} is not qualified for ${normalized.os}/${normalized.arch}.`); + } + return { version, ...normalized, manifestDigest }; +} + +function digestBytes(bytes) { + return `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`; +} + +function assertDigest(bytes, expectedDigest, label) { + if (!SHA256_PATTERN.test(expectedDigest)) { + throw new Error(`${label} has an invalid expected digest.`); + } + const actualDigest = digestBytes(bytes); + if (actualDigest !== expectedDigest) { + throw new Error(`${label} digest mismatch: expected ${expectedDigest}, got ${actualDigest}.`); + } +} + +function validateManifest(manifestBytes) { + let manifest; + try { + manifest = JSON.parse(manifestBytes.toString('utf8')); + } catch (_error) { + throw new Error('Nasiko manifest is not valid JSON.'); + } + if (manifest.schemaVersion !== 2 || !Array.isArray(manifest.layers) || manifest.layers.length !== 1) { + throw new Error('Nasiko manifest must contain exactly one OCI layer.'); + } + const layer = manifest.layers[0]; + if (layer.mediaType !== 'application/gzip' || !SHA256_PATTERN.test(layer.digest)) { + throw new Error('Nasiko manifest layer is not a qualified gzip artifact.'); + } + if (!Number.isSafeInteger(layer.size) || layer.size <= 0 || layer.size > MAX_ARCHIVE_BYTES) { + throw new Error('Nasiko manifest layer size is outside the allowed range.'); + } + return { digest: layer.digest, size: layer.size }; +} + +function validateArchiveEntries(entries, expectedBinaryName) { + if (!Array.isArray(entries) || entries.length !== 1) { + throw new Error('Unsafe archive: expected exactly one binary file.'); + } + const [entry] = entries; + const normalizedPath = String(entry.path || '').replace(/^\.\//, ''); + if (normalizedPath !== expectedBinaryName || normalizedPath.includes('..') || path.isAbsolute(normalizedPath)) { + throw new Error('Unsafe archive path: expected only the Nasiko binary.'); + } + if (entry.type !== 'file') { + throw new Error('Nasiko archive entry must be a regular file.'); + } + return true; +} + +function fetchBytes(url, options = {}) { + const maxBytes = options.maxBytes || MAX_ARCHIVE_BYTES; + const timeoutMs = options.timeoutMs || 15000; + const parsed = new URL(url); + if (parsed.origin !== REGISTRY_ORIGIN || parsed.protocol !== 'https:') { + return Promise.reject(new Error('Nasiko download origin is not allowed.')); + } + return new Promise((resolve, reject) => { + const request = https.get(parsed, { + headers: options.accept ? { Accept: options.accept } : {}, + }, response => { + if (response.statusCode >= 300 && response.statusCode < 400) { + response.resume(); + reject(new Error('Nasiko registry redirects are not allowed.')); + return; + } + if (response.statusCode !== 200) { + response.resume(); + reject(new Error(`Nasiko registry returned HTTP ${response.statusCode}.`)); + return; + } + const chunks = []; + let totalBytes = 0; + response.on('data', chunk => { + totalBytes += chunk.length; + if (totalBytes > maxBytes) { + request.destroy(new Error('Nasiko registry response exceeded the size limit.')); + return; + } + chunks.push(chunk); + }); + response.on('end', () => resolve(Buffer.concat(chunks))); + response.on('error', reject); + }); + request.setTimeout(timeoutMs, () => request.destroy(new Error('Nasiko registry request timed out.'))); + request.on('error', reject); + }); +} + +function inspectArchive(archivePath) { + const result = spawnSync('tar', ['-tvzf', archivePath], { + encoding: 'utf8', + shell: false, + timeout: 15000, + }); + if (result.status !== 0) { + throw new Error('Nasiko archive inspection failed.'); + } + return result.stdout.split(/\r?\n/).filter(Boolean).map(line => { + const typeMarker = line[0]; + const entryPath = line.trim().split(/\s+/).at(-1); + return { + path: entryPath, + type: typeMarker === '-' ? 'file' : typeMarker === 'l' ? 'symlink' : 'other', + }; + }); +} + +function extractArchive(archivePath, destination) { + const result = spawnSync('tar', ['-xzf', archivePath, '-C', destination], { + encoding: 'utf8', + shell: false, + timeout: 30000, + }); + if (result.status !== 0) { + throw new Error('Nasiko archive extraction failed.'); + } +} + +function runVersion(executable) { + return spawnSync(executable, ['--version'], { + encoding: 'utf8', + shell: false, + timeout: 10000, + }); +} + +function defaultInstallDirectory(normalized, environment = process.env, homeDirectory = os.homedir()) { + if (normalized.os === 'windows') { + if (!environment.LOCALAPPDATA) throw new Error('LOCALAPPDATA is required on Windows.'); + return path.join(environment.LOCALAPPDATA, 'nasiko', 'bin'); + } + return path.join(homeDirectory, '.local', 'bin'); +} + +function validateInstallDirectory(installDirectory) { + if (typeof installDirectory !== 'string' || installDirectory.includes('\0') || !path.isAbsolute(installDirectory)) { + throw new Error('Nasiko install directory must be an absolute path.'); + } + const resolved = path.resolve(installDirectory); + if (resolved === path.parse(resolved).root) { + throw new Error('Nasiko cannot install directly into a filesystem root.'); + } + return resolved; +} + +function assertDirectoryNotSymlink(directoryPath) { + if (!fs.existsSync(directoryPath)) return; + const stats = fs.lstatSync(directoryPath); + if (!stats.isDirectory() || stats.isSymbolicLink()) { + throw new Error('Nasiko install directory must be a real directory, not a symlink.'); + } +} + +async function installNasiko(options = {}, dependencies = {}) { + const version = options.version || 'v0.1.0'; + const qualified = getQualifiedRelease( + version, + dependencies.platform || process.platform, + dependencies.arch || process.arch + ); + const release = dependencies.releaseOverride + ? { ...qualified, ...dependencies.releaseOverride } + : qualified; + const installDirectory = validateInstallDirectory(options.installDir || defaultInstallDirectory( + release, + dependencies.environment || process.env, + dependencies.homeDirectory || os.homedir() + )); + const destination = path.join(installDirectory, release.binaryName); + const plan = { + dryRun: Boolean(options.dryRun), + version, + platform: release.os, + architecture: release.arch, + manifestDigest: release.manifestDigest, + registryOrigin: REGISTRY_ORIGIN, + destination, + }; + if (options.dryRun) return plan; + if (!options.yes) throw new Error('Nasiko installation requires explicit --yes consent.'); + + assertDirectoryNotSymlink(installDirectory); + fs.mkdirSync(installDirectory, { recursive: true, mode: 0o755 }); + assertDirectoryNotSymlink(installDirectory); + if (fs.existsSync(destination)) { + if (fs.lstatSync(destination).isSymbolicLink()) { + throw new Error('Refusing to replace a symlinked Nasiko executable.'); + } + const existing = (dependencies.runVersion || runVersion)(destination); + const output = `${existing.stdout || ''}\n${existing.stderr || ''}`; + if (existing.status === 0 && output.includes(version)) { + return { ...plan, dryRun: false, installed: true, reused: true }; + } + throw new Error('An incompatible Nasiko executable already exists at the destination.'); + } + + const retrieve = dependencies.fetchBytes || fetchBytes; + const manifestUrl = `${REGISTRY_ORIGIN}/v2/${REPOSITORY}/manifests/${release.manifestDigest}`; + const manifestBytes = await retrieve(manifestUrl, { + accept: 'application/vnd.oci.image.manifest.v1+json', + maxBytes: MAX_MANIFEST_BYTES, + }); + assertDigest(manifestBytes, release.manifestDigest, 'Nasiko manifest'); + const layer = validateManifest(manifestBytes); + const archiveUrl = `${REGISTRY_ORIGIN}/v2/${REPOSITORY}/blobs/${layer.digest}`; + const archiveBytes = await retrieve(archiveUrl, { maxBytes: MAX_ARCHIVE_BYTES }); + if (archiveBytes.length !== layer.size) throw new Error('Nasiko archive size mismatch.'); + assertDigest(archiveBytes, layer.digest, 'Nasiko archive'); + + const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-install-')); + try { + const archivePath = path.join(temporaryDirectory, 'nasiko.tar.gz'); + const extractionDirectory = path.join(temporaryDirectory, 'extract'); + fs.mkdirSync(extractionDirectory, { mode: 0o700 }); + fs.writeFileSync(archivePath, archiveBytes, { mode: 0o600 }); + const inspect = dependencies.inspectArchive || inspectArchive; + validateArchiveEntries(inspect(archivePath), release.binaryName); + (dependencies.extractArchive || extractArchive)(archivePath, extractionDirectory); + const extractedBinary = path.join(extractionDirectory, release.binaryName); + const extractedStats = fs.lstatSync(extractedBinary); + if (!extractedStats.isFile() || extractedStats.isSymbolicLink()) { + throw new Error('Extracted Nasiko binary is not a regular file.'); + } + fs.chmodSync(extractedBinary, 0o755); + const stagedDestination = path.join(installDirectory, `.${release.binaryName}.tmp-${process.pid}`); + fs.copyFileSync(extractedBinary, stagedDestination, fs.constants.COPYFILE_EXCL); + fs.chmodSync(stagedDestination, 0o755); + fs.renameSync(stagedDestination, destination); + const versionResult = (dependencies.runVersion || runVersion)(destination); + const versionOutput = `${versionResult.stdout || ''}\n${versionResult.stderr || ''}`; + if (versionResult.status !== 0 || !versionOutput.includes(version)) { + fs.rmSync(destination, { force: true }); + throw new Error('Installed Nasiko binary did not report the qualified version.'); + } + const metadata = { + version, + platform: release.os, + architecture: release.arch, + manifestDigest: release.manifestDigest, + artifactDigest: layer.digest, + installedPath: destination, + }; + const metadataPath = path.join(installDirectory, '.ecc-nasiko-install.json'); + const temporaryMetadata = `${metadataPath}.tmp-${process.pid}`; + fs.writeFileSync(temporaryMetadata, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 }); + fs.renameSync(temporaryMetadata, metadataPath); + return { ...plan, dryRun: false, installed: true, reused: false, artifactDigest: layer.digest }; + } finally { + fs.rmSync(temporaryDirectory, { recursive: true, force: true }); + } +} + +module.exports = { + QUALIFIED_RELEASES, + REGISTRY_ORIGIN, + digestBytes, + fetchBytes, + getQualifiedRelease, + installNasiko, + normalizePlatform, + validateArchiveEntries, + validateInstallDirectory, +}; diff --git a/scripts/nasiko.js b/scripts/nasiko.js new file mode 100644 index 000000000..772b803dc --- /dev/null +++ b/scripts/nasiko.js @@ -0,0 +1,134 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const { + installNasiko, + normalizePlatform, + validateInstallDirectory, +} = require('./lib/nasiko-release'); + +function helpText() { + return ` +ECC Nasiko control-plane bridge + +Usage: + ecc nasiko status [--json] + ecc nasiko install --version v0.1.0 --yes [--install-dir ] [--json] + ecc nasiko install --version v0.1.0 --dry-run [--install-dir ] [--json] + +The installer is opt-in, accepts only ECC-qualified pinned releases, downloads +content-addressed OCI artifacts from registry.nasiko.dev, verifies SHA-256 +digests before extraction, and never executes fetched shell or PowerShell code. +`; +} + +function parseInstallArguments(argumentsList) { + let options = { dryRun: false, installDir: undefined, json: false, version: undefined, yes: false }; + for (let index = 0; index < argumentsList.length; index += 1) { + const argument = argumentsList[index]; + if (argument === '--version' || argument === '--install-dir') { + const value = argumentsList[index + 1]; + if (!value || value.startsWith('--')) throw new Error(`Missing value for ${argument}.`); + options = { + ...options, + [argument === '--version' ? 'version' : 'installDir']: value, + }; + index += 1; + } else if (argument === '--yes' || argument === '-y') { + options = { ...options, yes: true }; + } else if (argument === '--dry-run') { + options = { ...options, dryRun: true }; + } else if (argument === '--json') { + options = { ...options, json: true }; + } else { + throw new Error(`Unknown Nasiko install argument: ${argument}`); + } + } + if (!options.version) throw new Error('Nasiko install requires --version v0.1.0.'); + if (options.installDir) validateInstallDirectory(options.installDir); + return options; +} + +function defaultExecutablePath() { + const normalized = normalizePlatform(); + if (normalized.os === 'windows') { + return process.env.LOCALAPPDATA + ? path.join(process.env.LOCALAPPDATA, 'nasiko', 'bin', normalized.binaryName) + : null; + } + return path.join(os.homedir(), '.local', 'bin', normalized.binaryName); +} + +function resolveExecutable() { + const configured = process.env.ECC_NASIKO_CLI_EXECUTABLE; + const candidate = configured || defaultExecutablePath(); + if (!candidate) return null; + if (!path.isAbsolute(candidate)) { + throw new Error('ECC_NASIKO_CLI_EXECUTABLE must be an absolute path.'); + } + if (!fs.existsSync(candidate)) return null; + const stats = fs.lstatSync(candidate); + if (!stats.isFile() || stats.isSymbolicLink()) { + throw new Error('Nasiko executable must be a regular file, not a symlink.'); + } + return candidate; +} + +function readStatus() { + const executable = resolveExecutable(); + if (!executable) return { installed: false, version: null, executable: null }; + const result = spawnSync(executable, ['--version'], { + encoding: 'utf8', + shell: false, + timeout: 10000, + }); + if (result.status !== 0) { + throw new Error('Nasiko executable failed its version check.'); + } + const output = `${result.stdout || ''}\n${result.stderr || ''}`; + const version = output.match(/\bv\d+\.\d+\.\d+\b/)?.[0] || null; + if (!version) throw new Error('Nasiko executable returned an unrecognized version.'); + return { installed: true, version, executable }; +} + +async function main(argumentsList = process.argv.slice(2)) { + const [command, ...rest] = argumentsList; + if (!command || command === '--help' || command === '-h' || command === 'help') { + process.stdout.write(helpText()); + return 0; + } + if (command === 'status') { + const unknown = rest.filter(argument => argument !== '--json'); + if (unknown.length > 0) throw new Error(`Unknown Nasiko status argument: ${unknown[0]}`); + const status = readStatus(); + if (rest.includes('--json')) process.stdout.write(`${JSON.stringify(status, null, 2)}\n`); + else process.stdout.write(status.installed + ? `Nasiko ${status.version} is installed at ${status.executable}.\n` + : 'Nasiko is not installed in the ECC-qualified location.\n'); + return 0; + } + if (command === 'install') { + const options = parseInstallArguments(rest); + const result = await installNasiko(options); + if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + else if (result.dryRun) process.stdout.write(`Would install Nasiko ${result.version} to ${result.destination}.\n`); + else process.stdout.write(`${result.reused ? 'Using existing' : 'Installed'} Nasiko ${result.version} at ${result.destination}.\n`); + return 0; + } + throw new Error(`Unsupported Nasiko command: ${command}`); +} + +if (require.main === module) { + main().then(code => { + process.exitCode = code; + }).catch(error => { + process.stderr.write(`Error: ${String(error?.message || error).replace(/[\r\n]+/g, ' ')}\n`); + process.exitCode = 1; + }); +} + +module.exports = { main, parseInstallArguments, readStatus, resolveExecutable }; diff --git a/skills/nasiko-control-plane/SKILL.md b/skills/nasiko-control-plane/SKILL.md new file mode 100644 index 000000000..3002c580c --- /dev/null +++ b/skills/nasiko-control-plane/SKILL.md @@ -0,0 +1,44 @@ +--- +name: nasiko-control-plane +description: Install, detect, and operate the optional Nasiko agent control plane through ECC with pinned artifacts, explicit consent, and telemetry and secrets boundaries. +--- + +# Nasiko Control Plane + +Use this skill when a user explicitly asks to install, inspect, or operate the +Nasiko control plane with ECC. + +## Safety contract + +- Begin with `ecc nasiko status --json`. Status is read-only. +- Installation always requires explicit user consent and `--yes`. +- Install only an ECC-qualified pinned version, currently `v0.1.0`. +- Preview first with `ecc nasiko install --version v0.1.0 --dry-run --json`. +- Install with `ecc nasiko install --version v0.1.0 --yes --json` only after the + user reviews the version, registry origin, digest, and destination. +- Never replace the qualified command with a downloaded shell or PowerShell + bootstrap script. +- Never put secrets or credentials in command arguments, logs, skill output, + install metadata, or ECC state. +- Nasiko telemetry and any sharing with Nasiko or Ito must be opt-in and + separately disclosed. Installation is not telemetry consent. + +## Lifecycle boundary + +The initial ECC bridge supports only qualified installation and read-only +status. Use the canonical Nasiko CLI directly for connection, authentication, +launch, deployment, or shutdown until those verbs have their own verified ECC +contracts. Do not guess CLI verbs. + +Installing the CLI does not prove that a control-plane server is running, an +agent is governed, routing or ACLs work, observability is complete, telemetry +was enabled, or Ito compute is connected. Report each state separately. + +## Failure behavior + +- If the platform, architecture, version, manifest, digest, archive, binary, or + destination fails validation, stop without executing the artifact. +- Do not fall back to `latest`. +- Do not search arbitrary `PATH` entries. Use ECC's qualified location or an + explicit absolute `ECC_NASIKO_CLI_EXECUTABLE` for development verification. +- Do not treat a partial or ambiguous installation as success. diff --git a/skills/nasiko-control-plane/agents/openai.yaml b/skills/nasiko-control-plane/agents/openai.yaml new file mode 100644 index 000000000..6168412b7 --- /dev/null +++ b/skills/nasiko-control-plane/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Nasiko Control Plane" + short_description: "Safely install and inspect the optional Nasiko control plane" + default_prompt: "Use $nasiko-control-plane to inspect or explicitly install the pinned Nasiko CLI without enabling telemetry or exposing secrets." From 9ba25b9360a6520f280a5436d5c8cf8e0a4ffcd2 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 15 Aug 2026 02:22:29 -0400 Subject: [PATCH 3/4] fix: harden Nasiko artifact lifecycle --- .claude-plugin/marketplace.json | 2 +- .claude-plugin/plugin.json | 2 +- AGENTS.md | 4 +- README.md | 4 +- README.zh-CN.md | 2 +- docs/tr/AGENTS.md | 4 +- docs/zh-CN/AGENTS.md | 4 +- docs/zh-CN/README.md | 6 +- manifests/install-profiles.json | 1 + package.json | 1 - scripts/lib/nasiko-release.js | 434 ++++++++++------------ scripts/nasiko.js | 74 ++-- skills/nasiko-control-plane/SKILL.md | 9 +- tests/ci/nasiko-control-plane.test.js | 135 +++++-- tests/scripts/npm-publish-surface.test.js | 3 + 15 files changed, 388 insertions(+), 297 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e017b66e5..caa21ae15 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -11,7 +11,7 @@ { "name": "ecc", "source": "./", - "description": "Harness-native ECC operator layer - 68 agents, 284 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", + "description": "Harness-native ECC operator layer - 68 agents, 285 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses", "version": "2.2.0", "author": { "name": "Affaan Mustafa", diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 3b348f34b..0a1436d35 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ecc", "version": "2.2.0", - "description": "Harness-native ECC plugin for engineering teams - 68 agents, 284 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses", + "description": "Harness-native ECC plugin for engineering teams - 68 agents, 285 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses", "author": { "name": "Affaan Mustafa", "url": "https://x.com/affaanmustafa" diff --git a/AGENTS.md b/AGENTS.md index 3c6bf777b..4235ea156 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — Agent Instructions -This is a **production-ready AI coding plugin** providing 68 specialized agents, 284 skills, 94 commands, and automated hook workflows for software development. +This is a **production-ready AI coding plugin** providing 68 specialized agents, 285 skills, 94 commands, and automated hook workflows for software development. **Version:** 2.2.0 @@ -154,7 +154,7 @@ Troubleshoot failures: check test isolation → verify mocks → fix implementat ``` agents/ — 68 specialized subagents -skills/ — 284 workflow skills and domain knowledge +skills/ — 285 workflow skills and domain knowledge commands/ — 94 slash commands hooks/ — Trigger-based automations rules/ — Always-follow guidelines (common + per-language) diff --git a/README.md b/README.md index 17cd1b201..a88dd810e 100644 --- a/README.md +++ b/README.md @@ -130,12 +130,12 @@ Instead of rebuilding that process in every prompt, you install it once and make ECC is MIT-licensed open source. It works best with Claude Code today, has a supported Codex sync path, and provides capability-limited adapters for Cursor, OpenCode, Gemini, Zed, GitHub Copilot, Antigravity, Qwen, and other harnesses. See the [support status matrix](#platform-support) before assuming feature parity. -Access to 68 agents, 284 skills, and 94 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work. +Access to 68 agents, 285 skills, and 94 legacy command shims, plus hooks, rules, memory, continuous learning, and AgentShield security scanning. The agents are specialized for planning, review, build repair, security, architecture, and domain work. | Included | Count | What it gives you | | ---------------- | ----------: | ------------------------------------------------------------------------------------ | | Agents | 68 agents | Planning, review, build repair, security, architecture, and domain work | -| Skills | 284 skills | TDD, research, security, docs, frontend, data, ML, operations, and more | +| Skills | 285 skills | TDD, research, security, docs, frontend, data, ML, operations, and more | | Commands | 94 commands | Convenient entry points while ECC moves to a skills-first surface | | Hooks and memory | Runtime | Enforcement, session summaries, continuous learning, instincts, and context controls | | Rules | Selective | Always-loaded standards you choose by language or project | diff --git a/README.zh-CN.md b/README.zh-CN.md index 86728c0cc..0c5647d0d 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -196,7 +196,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/" /plugin list ecc@ecc ``` -**完成!** 你现在可以使用 68 个代理、284 个技能和 94 个命令。 +**完成!** 你现在可以使用 68 个代理、285 个技能和 94 个命令。 ### multi-* 命令需要额外配置 diff --git a/docs/tr/AGENTS.md b/docs/tr/AGENTS.md index 2c54176f6..6124dff3c 100644 --- a/docs/tr/AGENTS.md +++ b/docs/tr/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — Agent Talimatları -Bu, yazılım geliştirme için 68 özel agent, 284 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**. +Bu, yazılım geliştirme için 68 özel agent, 285 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**. **Sürüm:** 2.2.0 @@ -142,7 +142,7 @@ Başarısızlık sorunlarını giderin: test izolasyonunu kontrol edin → mockl ``` agents/ — 68 özel subagent -skills/ — 284 iş akışı skillleri ve alan bilgisi +skills/ — 285 iş akışı skillleri ve alan bilgisi commands/ — 94 slash command hooks/ — Tetikleyici tabanlı otomasyonlar rules/ — Her zaman uyulması gereken kurallar (ortak + dile özel) diff --git a/docs/zh-CN/AGENTS.md b/docs/zh-CN/AGENTS.md index fb88d94c3..404cceaca 100644 --- a/docs/zh-CN/AGENTS.md +++ b/docs/zh-CN/AGENTS.md @@ -1,6 +1,6 @@ # Everything Claude Code (ECC) — 智能体指令 -这是一个**生产就绪的 AI 编码插件**,提供 68 个专业代理、284 项技能、94 条命令以及自动化钩子工作流,用于软件开发。 +这是一个**生产就绪的 AI 编码插件**,提供 68 个专业代理、285 项技能、94 条命令以及自动化钩子工作流,用于软件开发。 **版本:** 2.2.0 @@ -147,7 +147,7 @@ ``` agents/ — 68 个专业子代理 -skills/ — 284 个工作流技能和领域知识 +skills/ — 285 个工作流技能和领域知识 commands/ — 94 个斜杠命令 hooks/ — 基于触发的自动化 rules/ — 始终遵循的指导方针(通用 + 每种语言) diff --git a/docs/zh-CN/README.md b/docs/zh-CN/README.md index f13d8aeae..3674c1614 100644 --- a/docs/zh-CN/README.md +++ b/docs/zh-CN/README.md @@ -260,7 +260,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/" /plugin list ecc@ecc ``` -**搞定!** 你现在可以使用 68 个智能体、284 项技能和 94 个命令了。 +**搞定!** 你现在可以使用 68 个智能体、285 项技能和 94 个命令了。 *** @@ -1174,7 +1174,7 @@ opencode |---------|---------------|----------|--------| | 智能体 | PASS: 68 个 | PASS: 12 个 | **Claude Code 领先** | | 命令 | PASS: 94 个 | PASS: 35 个 | **Claude Code 领先** | -| 技能 | PASS: 284 项 | PASS: 37 项 | **Claude Code 领先** | +| 技能 | PASS: 285 项 | PASS: 37 项 | **Claude Code 领先** | | 钩子 | PASS: 8 种事件类型 | PASS: 11 种事件 | **OpenCode 更多!** | | 规则 | PASS: 29 条 | PASS: 13 条指令 | **Claude Code 领先** | | MCP 服务器 | PASS: 14 个 | PASS: 完整 | **完全对等** | @@ -1282,7 +1282,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以 |---------|-----------------------|------------|-----------|----------| | **智能体** | 68 | 共享 (AGENTS.md) | 共享 (AGENTS.md) | 12 | | **命令** | 94 | 共享 | 基于指令 | 35 | -| **技能** | 284 | 共享 | 10 (原生格式) | 37 | +| **技能** | 285 | 共享 | 10 (原生格式) | 37 | | **钩子事件** | 8 种类型 | 15 种类型 | SessionStart(1 种类型) | 11 种类型 | | **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | 1 个 SessionStart 引导脚本 | 插件钩子 | | **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 | diff --git a/manifests/install-profiles.json b/manifests/install-profiles.json index 15e429943..25091775e 100644 --- a/manifests/install-profiles.json +++ b/manifests/install-profiles.json @@ -89,6 +89,7 @@ "optimization-workflows", "prediction-market-skills", "ito-compute", + "nasiko-control-plane", "social-distribution", "media-generation", "orchestration", diff --git a/package.json b/package.json index d4ed22ab2..c4a8e73cf 100644 --- a/package.json +++ b/package.json @@ -119,7 +119,6 @@ "scripts/install-plan.js", "scripts/ito.js", "scripts/nasiko.js", - "scripts/lib/nasiko-release.js", "scripts/lib/", "scripts/list-installed.js", "scripts/loop-status.js", diff --git a/scripts/lib/nasiko-release.js b/scripts/lib/nasiko-release.js index 0ffc4b618..21efaeafc 100644 --- a/scripts/lib/nasiko-release.js +++ b/scripts/lib/nasiko-release.js @@ -5,57 +5,46 @@ const fs = require('fs'); const https = require('https'); const os = require('os'); const path = require('path'); -const { spawnSync } = require('child_process'); +const zlib = require('zlib'); const REGISTRY_ORIGIN = 'https://registry.nasiko.dev'; const REPOSITORY = 'nasiko/nasiko'; +const SOURCE_URL = 'https://github.com/Nasiko-Labs/nasiko'; +const LICENSE = 'Apache-2.0'; +const METADATA_FILENAME = '.ecc-nasiko-install.json'; const MAX_MANIFEST_BYTES = 1024 * 1024; const MAX_ARCHIVE_BYTES = 100 * 1024 * 1024; +const MAX_BINARY_BYTES = 64 * 1024 * 1024; +const MAX_METADATA_BYTES = 64 * 1024; const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/; const QUALIFIED_RELEASES = Object.freeze({ 'v0.1.0': Object.freeze({ - 'linux/amd64': 'sha256:0df748a40f3d714b6b6a3376a1d13a224c05bdb8d1628f31ace5a7bee8ceb9de', - 'linux/arm64': 'sha256:655021a129c7df4621a80d16ea4eab38018530bfe9a20da646641d9f4ac5c249', - 'darwin/amd64': 'sha256:b4188482621efd7da5a2ab630f653665ab5f80b9aceae448b6bb5fc93e003f06', - 'darwin/arm64': 'sha256:ce7e54fa19f989a5d125c4409b3587ca9503bb5a07bc5ff223c60e0fbad437f0', - 'windows/amd64': 'sha256:0760fe1fc98e8fedb66796aaf891a1de9268af1338c5e88b949656fda5d9f045', + 'linux/amd64': Object.freeze({ manifestDigest: 'sha256:0df748a40f3d714b6b6a3376a1d13a224c05bdb8d1628f31ace5a7bee8ceb9de', binaryDigest: 'sha256:94a2bcab2d3832257e0111480bb7c3dcac81d63a7ae9bac53206a92c0957ee0f' }), + 'linux/arm64': Object.freeze({ manifestDigest: 'sha256:655021a129c7df4621a80d16ea4eab38018530bfe9a20da646641d9f4ac5c249', binaryDigest: 'sha256:85f9fa5cfbed6c276fce6df2d71e9d0c66d7d8e8d46a40297464833d38db7a7e' }), + 'darwin/amd64': Object.freeze({ manifestDigest: 'sha256:b4188482621efd7da5a2ab630f653665ab5f80b9aceae448b6bb5fc93e003f06', binaryDigest: 'sha256:ed6232e0bb96a2dcfd86d3c25f86021091600d52f09250403a54528bfe8100a3' }), + 'darwin/arm64': Object.freeze({ manifestDigest: 'sha256:ce7e54fa19f989a5d125c4409b3587ca9503bb5a07bc5ff223c60e0fbad437f0', binaryDigest: 'sha256:3c60f862b04eea1b9a633593b39f1a443d9ca2123cf3edfd150d313e95f3894b' }), + 'windows/amd64': Object.freeze({ manifestDigest: 'sha256:0760fe1fc98e8fedb66796aaf891a1de9268af1338c5e88b949656fda5d9f045', binaryDigest: 'sha256:0f57672d24fc3c70e4cbbf22864e65b35b9978ddaae3793803841536e682929b' }), }), }); function normalizePlatform(platform = process.platform, architecture = process.arch) { const osName = platform === 'win32' ? 'windows' : platform; - if (!['linux', 'darwin', 'windows'].includes(osName)) { - throw new Error(`Unsupported platform: ${platform}`); - } + if (!['linux', 'darwin', 'windows'].includes(osName)) throw new Error(`Unsupported platform: ${platform}`); const arch = architecture === 'x64' ? 'amd64' : architecture; - if (!['amd64', 'arm64'].includes(arch)) { - throw new Error(`Unsupported architecture: ${architecture}`); - } - if (osName === 'windows' && arch !== 'amd64') { - throw new Error(`Unsupported architecture for Windows: ${architecture}`); - } - return { - os: osName, - arch, - binaryName: osName === 'windows' ? 'nasiko.exe' : 'nasiko', - }; + if (!['amd64', 'arm64'].includes(arch)) throw new Error(`Unsupported architecture: ${architecture}`); + if (osName === 'windows' && arch !== 'amd64') throw new Error(`Unsupported architecture for Windows: ${architecture}`); + return { os: osName, arch, binaryName: osName === 'windows' ? 'nasiko.exe' : 'nasiko' }; } function getQualifiedRelease(version, platform = process.platform, architecture = process.arch) { if (!/^v\d+\.\d+\.\d+$/.test(String(version || ''))) { throw new Error('Nasiko installation requires a pinned version such as v0.1.0; latest is not allowed.'); } - const release = QUALIFIED_RELEASES[version]; - if (!release) { - throw new Error(`Nasiko ${version} is not qualified by this ECC release.`); - } const normalized = normalizePlatform(platform, architecture); - const manifestDigest = release[`${normalized.os}/${normalized.arch}`]; - if (!manifestDigest) { - throw new Error(`Nasiko ${version} is not qualified for ${normalized.os}/${normalized.arch}.`); - } - return { version, ...normalized, manifestDigest }; + const qualification = QUALIFIED_RELEASES[version]?.[`${normalized.os}/${normalized.arch}`]; + if (!qualification) throw new Error(`Nasiko ${version} is not qualified for ${normalized.os}/${normalized.arch}.`); + return { version, ...normalized, ...qualification, license: LICENSE, sourceUrl: SOURCE_URL }; } function digestBytes(bytes) { @@ -63,22 +52,14 @@ function digestBytes(bytes) { } function assertDigest(bytes, expectedDigest, label) { - if (!SHA256_PATTERN.test(expectedDigest)) { - throw new Error(`${label} has an invalid expected digest.`); - } - const actualDigest = digestBytes(bytes); - if (actualDigest !== expectedDigest) { - throw new Error(`${label} digest mismatch: expected ${expectedDigest}, got ${actualDigest}.`); - } + if (!SHA256_PATTERN.test(expectedDigest)) throw new Error(`${label} has an invalid expected digest.`); + const actual = digestBytes(bytes); + if (actual !== expectedDigest) throw new Error(`${label} digest mismatch: expected ${expectedDigest}, got ${actual}.`); } -function validateManifest(manifestBytes) { +function validateManifest(bytes) { let manifest; - try { - manifest = JSON.parse(manifestBytes.toString('utf8')); - } catch (_error) { - throw new Error('Nasiko manifest is not valid JSON.'); - } + try { manifest = JSON.parse(bytes.toString('utf8')); } catch (_error) { throw new Error('Nasiko manifest is not valid JSON.'); } if (manifest.schemaVersion !== 2 || !Array.isArray(manifest.layers) || manifest.layers.length !== 1) { throw new Error('Nasiko manifest must contain exactly one OCI layer.'); } @@ -92,98 +73,63 @@ function validateManifest(manifestBytes) { return { digest: layer.digest, size: layer.size }; } -function validateArchiveEntries(entries, expectedBinaryName) { - if (!Array.isArray(entries) || entries.length !== 1) { - throw new Error('Unsafe archive: expected exactly one binary file.'); +function readTarString(block, offset, length) { + return block.subarray(offset, offset + length).toString('utf8').replace(/\0.*$/, ''); +} + +function extractQualifiedTarGzip(archiveBytes, expectedName) { + let tar; + try { tar = zlib.gunzipSync(archiveBytes, { maxOutputLength: MAX_BINARY_BYTES + 2048 }); } + catch (_error) { throw new Error('Nasiko archive is invalid or exceeds the decompressed size limit.'); } + let offset = 0; + let binary = null; + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + if (header.every(byte => byte === 0)) break; + const name = readTarString(header, 0, 100); + const prefix = readTarString(header, 345, 155); + const type = String.fromCharCode(header[156] || 48); + const rawSize = readTarString(header, 124, 12).trim(); + const size = Number.parseInt(rawSize || '0', 8); + const start = offset + 512; + const end = start + size; + if (!Number.isSafeInteger(size) || size < 0 || end > tar.length) throw new Error('Nasiko archive is truncated.'); + const payload = tar.subarray(start, end); + const isBinary = !prefix && name === expectedName && (type === '0' || type === '\0'); + const isAppleDouble = !prefix && name === `._${expectedName}` && type === '0' && size <= 1024 * 1024; + const isPaxMetadata = !prefix && name === `PaxHeader/${expectedName}` && type === 'x' && size <= 64 * 1024 + && !/(?:^|\n)(?:path|linkpath)=/i.test(payload.toString('utf8')); + if (isBinary && !binary && size > 0 && size <= MAX_BINARY_BYTES) binary = Buffer.from(payload); + else if (!isAppleDouble && !isPaxMetadata) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.'); + offset = start + Math.ceil(size / 512) * 512; } - const [entry] = entries; - const normalizedPath = String(entry.path || '').replace(/^\.\//, ''); - if (normalizedPath !== expectedBinaryName || normalizedPath.includes('..') || path.isAbsolute(normalizedPath)) { - throw new Error('Unsafe archive path: expected only the Nasiko binary.'); - } - if (entry.type !== 'file') { - throw new Error('Nasiko archive entry must be a regular file.'); - } - return true; + if (!binary) throw new Error('Unsafe Nasiko archive: expected exactly one bounded regular binary file.'); + return binary; } function fetchBytes(url, options = {}) { - const maxBytes = options.maxBytes || MAX_ARCHIVE_BYTES; - const timeoutMs = options.timeoutMs || 15000; const parsed = new URL(url); - if (parsed.origin !== REGISTRY_ORIGIN || parsed.protocol !== 'https:') { - return Promise.reject(new Error('Nasiko download origin is not allowed.')); - } + if (parsed.origin !== REGISTRY_ORIGIN || parsed.protocol !== 'https:') return Promise.reject(new Error('Nasiko download origin is not allowed.')); + const maxBytes = options.maxBytes || MAX_ARCHIVE_BYTES; return new Promise((resolve, reject) => { - const request = https.get(parsed, { - headers: options.accept ? { Accept: options.accept } : {}, - }, response => { - if (response.statusCode >= 300 && response.statusCode < 400) { - response.resume(); - reject(new Error('Nasiko registry redirects are not allowed.')); - return; - } - if (response.statusCode !== 200) { - response.resume(); - reject(new Error(`Nasiko registry returned HTTP ${response.statusCode}.`)); - return; - } + const request = https.get(parsed, { headers: options.accept ? { Accept: options.accept } : {} }, response => { + if (response.statusCode >= 300 && response.statusCode < 400) { response.resume(); reject(new Error('Nasiko registry redirects are not allowed.')); return; } + if (response.statusCode !== 200) { response.resume(); reject(new Error(`Nasiko registry returned HTTP ${response.statusCode}.`)); return; } const chunks = []; - let totalBytes = 0; + let total = 0; response.on('data', chunk => { - totalBytes += chunk.length; - if (totalBytes > maxBytes) { - request.destroy(new Error('Nasiko registry response exceeded the size limit.')); - return; - } - chunks.push(chunk); + total += chunk.length; + if (total > maxBytes) request.destroy(new Error('Nasiko registry response exceeded the size limit.')); + else chunks.push(chunk); }); response.on('end', () => resolve(Buffer.concat(chunks))); response.on('error', reject); }); - request.setTimeout(timeoutMs, () => request.destroy(new Error('Nasiko registry request timed out.'))); + request.setTimeout(options.timeoutMs || 15000, () => request.destroy(new Error('Nasiko registry request timed out.'))); request.on('error', reject); }); } -function inspectArchive(archivePath) { - const result = spawnSync('tar', ['-tvzf', archivePath], { - encoding: 'utf8', - shell: false, - timeout: 15000, - }); - if (result.status !== 0) { - throw new Error('Nasiko archive inspection failed.'); - } - return result.stdout.split(/\r?\n/).filter(Boolean).map(line => { - const typeMarker = line[0]; - const entryPath = line.trim().split(/\s+/).at(-1); - return { - path: entryPath, - type: typeMarker === '-' ? 'file' : typeMarker === 'l' ? 'symlink' : 'other', - }; - }); -} - -function extractArchive(archivePath, destination) { - const result = spawnSync('tar', ['-xzf', archivePath, '-C', destination], { - encoding: 'utf8', - shell: false, - timeout: 30000, - }); - if (result.status !== 0) { - throw new Error('Nasiko archive extraction failed.'); - } -} - -function runVersion(executable) { - return spawnSync(executable, ['--version'], { - encoding: 'utf8', - shell: false, - timeout: 10000, - }); -} - function defaultInstallDirectory(normalized, environment = process.env, homeDirectory = os.homedir()) { if (normalized.os === 'windows') { if (!environment.LOCALAPPDATA) throw new Error('LOCALAPPDATA is required on Windows.'); @@ -192,132 +138,158 @@ function defaultInstallDirectory(normalized, environment = process.env, homeDire return path.join(homeDirectory, '.local', 'bin'); } -function validateInstallDirectory(installDirectory) { - if (typeof installDirectory !== 'string' || installDirectory.includes('\0') || !path.isAbsolute(installDirectory)) { - throw new Error('Nasiko install directory must be an absolute path.'); - } - const resolved = path.resolve(installDirectory); - if (resolved === path.parse(resolved).root) { - throw new Error('Nasiko cannot install directly into a filesystem root.'); - } - return resolved; +function validateInstallDirectory(directory) { + if (typeof directory !== 'string' || directory.includes('\0') || !path.isAbsolute(directory)) throw new Error('Nasiko install directory must be an absolute path.'); + if (/^(?:\\\\|\\\\\?\\|\\\\\.\\)/.test(directory)) throw new Error('Nasiko install directory must be on a local filesystem.'); + const resolved = path.resolve(directory); + if (resolved === path.parse(resolved).root) throw new Error('Nasiko cannot install directly into a filesystem root.'); + let ancestor = resolved; + while (!fs.existsSync(ancestor)) ancestor = path.dirname(ancestor); + const canonical = fs.realpathSync(ancestor); + return path.join(canonical, path.relative(ancestor, resolved)); } -function assertDirectoryNotSymlink(directoryPath) { - if (!fs.existsSync(directoryPath)) return; - const stats = fs.lstatSync(directoryPath); - if (!stats.isDirectory() || stats.isSymbolicLink()) { - throw new Error('Nasiko install directory must be a real directory, not a symlink.'); +function assertPrivateInstallDirectory(directory) { + const stats = fs.lstatSync(directory); + if (!stats.isDirectory() || stats.isSymbolicLink()) throw new Error('Nasiko install directory must be a real directory, not a symlink.'); + if (process.platform !== 'win32') { + if (typeof process.getuid === 'function' && stats.uid !== process.getuid()) throw new Error('Nasiko install directory must be owned by the current user.'); + if ((stats.mode & 0o022) !== 0) throw new Error('Nasiko install directory must not be group- or world-writable.'); } } +function metadataPathFor(executable) { return path.join(path.dirname(executable), METADATA_FILENAME); } + +function readMetadata(executable) { + try { + const metadataPath = metadataPathFor(executable); + const stats = fs.lstatSync(metadataPath); + if (!stats.isFile() || stats.isSymbolicLink() || stats.size <= 0 || stats.size > MAX_METADATA_BYTES) return null; + return JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + } + catch (_error) { return null; } +} + +function inspectInstalledNasiko(executable, resolveRelease = getQualifiedRelease) { + if (!executable || !fs.existsSync(executable)) return { installed: false, qualified: false, version: null, executable: executable || null }; + const stats = fs.lstatSync(executable); + if (!stats.isFile() || stats.isSymbolicLink()) throw new Error('Nasiko executable must be a regular file, not a symlink.'); + if (stats.size <= 0 || stats.size > MAX_BINARY_BYTES) return { installed: true, qualified: false, version: null, executable, binaryDigest: null, metadataPath: metadataPathFor(executable) }; + const binaryDigest = digestBytes(fs.readFileSync(executable)); + const metadata = readMetadata(executable); + let release = null; + try { if (metadata) release = resolveRelease(metadata.version, metadata.platform, metadata.architecture); } catch (_error) { release = null; } + const qualified = Boolean(release + && metadata.installedPath === executable + && metadata.manifestDigest === release.manifestDigest + && metadata.binaryDigest === release.binaryDigest + && binaryDigest === release.binaryDigest + && metadata.license === release.license + && metadata.sourceUrl === release.sourceUrl); + return { installed: true, qualified, version: qualified ? metadata.version : null, executable, binaryDigest, metadataPath: metadataPathFor(executable) }; +} + +function writeMetadataExclusive(metadataPath, metadata) { + fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); +} + +function acquireLifecycleLock(installDirectory) { + const lockPath = path.join(installDirectory, '.ecc-nasiko-lifecycle.lock'); + let descriptor; + try { descriptor = fs.openSync(lockPath, 'wx', 0o600); } + catch (error) { + if (error.code === 'EEXIST') throw new Error('Another Nasiko lifecycle operation is already in progress.'); + throw error; + } + return () => { + fs.closeSync(descriptor); + fs.rmSync(lockPath, { force: true }); + }; +} + async function installNasiko(options = {}, dependencies = {}) { const version = options.version || 'v0.1.0'; - const qualified = getQualifiedRelease( - version, - dependencies.platform || process.platform, - dependencies.arch || process.arch - ); - const release = dependencies.releaseOverride - ? { ...qualified, ...dependencies.releaseOverride } - : qualified; - const installDirectory = validateInstallDirectory(options.installDir || defaultInstallDirectory( - release, - dependencies.environment || process.env, - dependencies.homeDirectory || os.homedir() - )); + const base = getQualifiedRelease(version, dependencies.platform || process.platform, dependencies.arch || process.arch); + const release = dependencies.releaseOverride ? { ...base, ...dependencies.releaseOverride } : base; + const installDirectory = validateInstallDirectory(options.installDir || defaultInstallDirectory(release, dependencies.environment || process.env, dependencies.homeDirectory || os.homedir())); const destination = path.join(installDirectory, release.binaryName); - const plan = { - dryRun: Boolean(options.dryRun), - version, - platform: release.os, - architecture: release.arch, - manifestDigest: release.manifestDigest, - registryOrigin: REGISTRY_ORIGIN, - destination, - }; + const plan = { dryRun: Boolean(options.dryRun), version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, binaryDigest: release.binaryDigest, registryOrigin: REGISTRY_ORIGIN, destination, license: release.license, sourceUrl: release.sourceUrl }; if (options.dryRun) return plan; if (!options.yes) throw new Error('Nasiko installation requires explicit --yes consent.'); - - assertDirectoryNotSymlink(installDirectory); fs.mkdirSync(installDirectory, { recursive: true, mode: 0o755 }); - assertDirectoryNotSymlink(installDirectory); - if (fs.existsSync(destination)) { - if (fs.lstatSync(destination).isSymbolicLink()) { - throw new Error('Refusing to replace a symlinked Nasiko executable.'); - } - const existing = (dependencies.runVersion || runVersion)(destination); - const output = `${existing.stdout || ''}\n${existing.stderr || ''}`; - if (existing.status === 0 && output.includes(version)) { - return { ...plan, dryRun: false, installed: true, reused: true }; - } - throw new Error('An incompatible Nasiko executable already exists at the destination.'); - } - - const retrieve = dependencies.fetchBytes || fetchBytes; - const manifestUrl = `${REGISTRY_ORIGIN}/v2/${REPOSITORY}/manifests/${release.manifestDigest}`; - const manifestBytes = await retrieve(manifestUrl, { - accept: 'application/vnd.oci.image.manifest.v1+json', - maxBytes: MAX_MANIFEST_BYTES, - }); - assertDigest(manifestBytes, release.manifestDigest, 'Nasiko manifest'); - const layer = validateManifest(manifestBytes); - const archiveUrl = `${REGISTRY_ORIGIN}/v2/${REPOSITORY}/blobs/${layer.digest}`; - const archiveBytes = await retrieve(archiveUrl, { maxBytes: MAX_ARCHIVE_BYTES }); - if (archiveBytes.length !== layer.size) throw new Error('Nasiko archive size mismatch.'); - assertDigest(archiveBytes, layer.digest, 'Nasiko archive'); - - const temporaryDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-install-')); + assertPrivateInstallDirectory(installDirectory); + const releaseLock = acquireLifecycleLock(installDirectory); + const metadataPath = metadataPathFor(destination); + let destinationOwned = false; + let metadataOwned = false; try { - const archivePath = path.join(temporaryDirectory, 'nasiko.tar.gz'); - const extractionDirectory = path.join(temporaryDirectory, 'extract'); - fs.mkdirSync(extractionDirectory, { mode: 0o700 }); - fs.writeFileSync(archivePath, archiveBytes, { mode: 0o600 }); - const inspect = dependencies.inspectArchive || inspectArchive; - validateArchiveEntries(inspect(archivePath), release.binaryName); - (dependencies.extractArchive || extractArchive)(archivePath, extractionDirectory); - const extractedBinary = path.join(extractionDirectory, release.binaryName); - const extractedStats = fs.lstatSync(extractedBinary); - if (!extractedStats.isFile() || extractedStats.isSymbolicLink()) { - throw new Error('Extracted Nasiko binary is not a regular file.'); + if (fs.existsSync(destination) || fs.existsSync(metadataPath)) { + const existing = inspectInstalledNasiko(destination); + if (existing.qualified && existing.version === version) return { ...plan, dryRun: false, installed: true, reused: true }; + throw new Error('An unqualified or incompatible Nasiko executable or receipt already exists at the destination.'); } - fs.chmodSync(extractedBinary, 0o755); - const stagedDestination = path.join(installDirectory, `.${release.binaryName}.tmp-${process.pid}`); - fs.copyFileSync(extractedBinary, stagedDestination, fs.constants.COPYFILE_EXCL); - fs.chmodSync(stagedDestination, 0o755); - fs.renameSync(stagedDestination, destination); - const versionResult = (dependencies.runVersion || runVersion)(destination); - const versionOutput = `${versionResult.stdout || ''}\n${versionResult.stderr || ''}`; - if (versionResult.status !== 0 || !versionOutput.includes(version)) { - fs.rmSync(destination, { force: true }); - throw new Error('Installed Nasiko binary did not report the qualified version.'); - } - const metadata = { - version, - platform: release.os, - architecture: release.arch, - manifestDigest: release.manifestDigest, - artifactDigest: layer.digest, - installedPath: destination, - }; - const metadataPath = path.join(installDirectory, '.ecc-nasiko-install.json'); - const temporaryMetadata = `${metadataPath}.tmp-${process.pid}`; - fs.writeFileSync(temporaryMetadata, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600 }); - fs.renameSync(temporaryMetadata, metadataPath); + const retrieve = dependencies.fetchBytes || fetchBytes; + const manifestBytes = await retrieve(`${REGISTRY_ORIGIN}/v2/${REPOSITORY}/manifests/${release.manifestDigest}`, { accept: 'application/vnd.oci.image.manifest.v1+json', maxBytes: MAX_MANIFEST_BYTES }); + assertDigest(manifestBytes, release.manifestDigest, 'Nasiko manifest'); + const layer = validateManifest(manifestBytes); + const archiveBytes = await retrieve(`${REGISTRY_ORIGIN}/v2/${REPOSITORY}/blobs/${layer.digest}`, { maxBytes: MAX_ARCHIVE_BYTES }); + if (archiveBytes.length !== layer.size) throw new Error('Nasiko archive size mismatch.'); + assertDigest(archiveBytes, layer.digest, 'Nasiko archive'); + const binary = (dependencies.extractBinary || extractQualifiedTarGzip)(archiveBytes, release.binaryName); + assertDigest(binary, release.binaryDigest, 'Nasiko binary'); + if (dependencies.beforePublish) dependencies.beforePublish(destination); + const descriptor = fs.openSync(destination, 'wx', 0o700); + destinationOwned = true; + try { fs.writeFileSync(descriptor, binary); fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); } + assertDigest(fs.readFileSync(destination), release.binaryDigest, 'Published Nasiko binary'); + const metadata = { version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, artifactDigest: layer.digest, binaryDigest: release.binaryDigest, installedPath: destination, license: release.license, sourceUrl: release.sourceUrl }; + (dependencies.writeMetadata || writeMetadataExclusive)(metadataPath, metadata); + metadataOwned = true; return { ...plan, dryRun: false, installed: true, reused: false, artifactDigest: layer.digest }; - } finally { - fs.rmSync(temporaryDirectory, { recursive: true, force: true }); - } + } catch (error) { + if (metadataOwned) fs.rmSync(metadataPath, { force: true }); + if (destinationOwned) fs.rmSync(destination, { force: true }); + throw error; + } finally { releaseLock(); } } -module.exports = { - QUALIFIED_RELEASES, - REGISTRY_ORIGIN, - digestBytes, - fetchBytes, - getQualifiedRelease, - installNasiko, - normalizePlatform, - validateArchiveEntries, - validateInstallDirectory, -}; +function uninstallNasiko(options = {}, dependencies = {}) { + const version = options.version || 'v0.1.0'; + const release = getQualifiedRelease(version, dependencies.platform || process.platform, dependencies.arch || process.arch); + const installDirectory = validateInstallDirectory(options.installDir || defaultInstallDirectory(release, dependencies.environment || process.env, dependencies.homeDirectory || os.homedir())); + const destination = path.join(installDirectory, release.binaryName); + const plan = { dryRun: Boolean(options.dryRun), version, destination }; + if (options.dryRun) return plan; + if (!options.yes) throw new Error('Nasiko uninstall requires explicit --yes consent.'); + if (!fs.existsSync(installDirectory)) return { ...plan, dryRun: false, removed: false }; + assertPrivateInstallDirectory(installDirectory); + const releaseLock = acquireLifecycleLock(installDirectory); + const metadataPath = metadataPathFor(destination); + const suffix = `${process.pid}-${crypto.randomBytes(6).toString('hex')}`; + const binaryTombstone = `${destination}.remove-${suffix}`; + const metadataTombstone = `${metadataPath}.remove-${suffix}`; + let binaryStaged = false; + let metadataStaged = false; + const rename = dependencies.rename || fs.renameSync; + try { + const status = (dependencies.inspectInstalled || inspectInstalledNasiko)(destination); + if (!status.installed) return { ...plan, dryRun: false, removed: false }; + if (!status.qualified || status.version !== version) throw new Error('Refusing to remove an unqualified or modified Nasiko executable.'); + rename(destination, binaryTombstone); + binaryStaged = true; + rename(metadataPath, metadataTombstone); + metadataStaged = true; + const cleanupPending = []; + try { fs.rmSync(metadataTombstone); } catch (_error) { cleanupPending.push(metadataTombstone); } + metadataStaged = false; + try { fs.rmSync(binaryTombstone); } catch (_error) { cleanupPending.push(binaryTombstone); } + binaryStaged = false; + return { ...plan, dryRun: false, removed: true, cleanupPending }; + } catch (error) { + if (metadataStaged && !fs.existsSync(metadataPath)) rename(metadataTombstone, metadataPath); + if (binaryStaged && !fs.existsSync(destination)) rename(binaryTombstone, destination); + throw error; + } finally { releaseLock(); } +} + +module.exports = { QUALIFIED_RELEASES, REGISTRY_ORIGIN, digestBytes, extractQualifiedTarGzip, fetchBytes, getQualifiedRelease, inspectInstalledNasiko, installNasiko, normalizePlatform, uninstallNasiko, validateInstallDirectory }; diff --git a/scripts/nasiko.js b/scripts/nasiko.js index 772b803dc..d1c06526b 100644 --- a/scripts/nasiko.js +++ b/scripts/nasiko.js @@ -4,10 +4,11 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); -const { spawnSync } = require('child_process'); const { + inspectInstalledNasiko, installNasiko, normalizePlatform, + uninstallNasiko, validateInstallDirectory, } = require('./lib/nasiko-release'); @@ -16,9 +17,10 @@ function helpText() { ECC Nasiko control-plane bridge Usage: - ecc nasiko status [--json] + ecc nasiko status [--install-dir ] [--json] ecc nasiko install --version v0.1.0 --yes [--install-dir ] [--json] ecc nasiko install --version v0.1.0 --dry-run [--install-dir ] [--json] + ecc nasiko uninstall --version v0.1.0 --yes [--install-dir ] [--json] The installer is opt-in, accepts only ECC-qualified pinned releases, downloads content-addressed OCI artifacts from registry.nasiko.dev, verifies SHA-256 @@ -26,7 +28,7 @@ digests before extraction, and never executes fetched shell or PowerShell code. `; } -function parseInstallArguments(argumentsList) { +function parseMutationArguments(argumentsList, command = 'install') { let options = { dryRun: false, installDir: undefined, json: false, version: undefined, yes: false }; for (let index = 0; index < argumentsList.length; index += 1) { const argument = argumentsList[index]; @@ -45,10 +47,10 @@ function parseInstallArguments(argumentsList) { } else if (argument === '--json') { options = { ...options, json: true }; } else { - throw new Error(`Unknown Nasiko install argument: ${argument}`); + throw new Error(`Unknown Nasiko ${command} argument: ${argument}`); } } - if (!options.version) throw new Error('Nasiko install requires --version v0.1.0.'); + if (!options.version) throw new Error(`Nasiko ${command} requires --version v0.1.0.`); if (options.installDir) validateInstallDirectory(options.installDir); return options; } @@ -63,9 +65,12 @@ function defaultExecutablePath() { return path.join(os.homedir(), '.local', 'bin', normalized.binaryName); } -function resolveExecutable() { +function resolveExecutable(options = {}) { const configured = process.env.ECC_NASIKO_CLI_EXECUTABLE; - const candidate = configured || defaultExecutablePath(); + const normalized = normalizePlatform(); + const candidate = options.installDir + ? path.join(validateInstallDirectory(options.installDir), normalized.binaryName) + : configured || defaultExecutablePath(); if (!candidate) return null; if (!path.isAbsolute(candidate)) { throw new Error('ECC_NASIKO_CLI_EXECUTABLE must be an absolute path.'); @@ -78,21 +83,25 @@ function resolveExecutable() { return candidate; } -function readStatus() { - const executable = resolveExecutable(); +function readStatus(options = {}) { + const executable = resolveExecutable(options); if (!executable) return { installed: false, version: null, executable: null }; - const result = spawnSync(executable, ['--version'], { - encoding: 'utf8', - shell: false, - timeout: 10000, - }); - if (result.status !== 0) { - throw new Error('Nasiko executable failed its version check.'); + return inspectInstalledNasiko(executable); +} + +function parseStatusArguments(argumentsList) { + let options = { installDir: undefined, json: false }; + for (let index = 0; index < argumentsList.length; index += 1) { + const argument = argumentsList[index]; + if (argument === '--json') options = { ...options, json: true }; + else if (argument === '--install-dir') { + const value = argumentsList[index + 1]; + if (!value || value.startsWith('--')) throw new Error('Missing value for --install-dir.'); + options = { ...options, installDir: validateInstallDirectory(value) }; + index += 1; + } else throw new Error(`Unknown Nasiko status argument: ${argument}`); } - const output = `${result.stdout || ''}\n${result.stderr || ''}`; - const version = output.match(/\bv\d+\.\d+\.\d+\b/)?.[0] || null; - if (!version) throw new Error('Nasiko executable returned an unrecognized version.'); - return { installed: true, version, executable }; + return options; } async function main(argumentsList = process.argv.slice(2)) { @@ -102,23 +111,32 @@ async function main(argumentsList = process.argv.slice(2)) { return 0; } if (command === 'status') { - const unknown = rest.filter(argument => argument !== '--json'); - if (unknown.length > 0) throw new Error(`Unknown Nasiko status argument: ${unknown[0]}`); - const status = readStatus(); - if (rest.includes('--json')) process.stdout.write(`${JSON.stringify(status, null, 2)}\n`); - else process.stdout.write(status.installed - ? `Nasiko ${status.version} is installed at ${status.executable}.\n` + const options = parseStatusArguments(rest); + const status = readStatus(options); + if (options.json) process.stdout.write(`${JSON.stringify(status, null, 2)}\n`); + else process.stdout.write(status.qualified + ? `Qualified Nasiko ${status.version} is installed at ${status.executable}.\n` + : status.installed + ? `An unqualified Nasiko file exists at ${status.executable}; it was not executed.\n` : 'Nasiko is not installed in the ECC-qualified location.\n'); return 0; } if (command === 'install') { - const options = parseInstallArguments(rest); + const options = parseMutationArguments(rest, 'install'); const result = await installNasiko(options); if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); else if (result.dryRun) process.stdout.write(`Would install Nasiko ${result.version} to ${result.destination}.\n`); else process.stdout.write(`${result.reused ? 'Using existing' : 'Installed'} Nasiko ${result.version} at ${result.destination}.\n`); return 0; } + if (command === 'uninstall') { + const options = parseMutationArguments(rest, 'uninstall'); + const result = uninstallNasiko(options); + if (options.json) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`); + else if (result.dryRun) process.stdout.write(`Would uninstall Nasiko ${result.version} from ${result.destination}.\n`); + else process.stdout.write(result.removed ? `Uninstalled Nasiko ${result.version}.\n` : 'Nasiko was not installed.\n'); + return 0; + } throw new Error(`Unsupported Nasiko command: ${command}`); } @@ -131,4 +149,4 @@ if (require.main === module) { }); } -module.exports = { main, parseInstallArguments, readStatus, resolveExecutable }; +module.exports = { main, parseInstallArguments: parseMutationArguments, parseMutationArguments, parseStatusArguments, readStatus, resolveExecutable }; diff --git a/skills/nasiko-control-plane/SKILL.md b/skills/nasiko-control-plane/SKILL.md index 3002c580c..bb95391d7 100644 --- a/skills/nasiko-control-plane/SKILL.md +++ b/skills/nasiko-control-plane/SKILL.md @@ -16,6 +16,11 @@ Nasiko control plane with ECC. - Preview first with `ecc nasiko install --version v0.1.0 --dry-run --json`. - Install with `ecc nasiko install --version v0.1.0 --yes --json` only after the user reviews the version, registry origin, digest, and destination. +- Remove only a still-qualified ECC-managed binary with + `ecc nasiko uninstall --version v0.1.0 --yes --json`. Preview removal with + `--dry-run` first. +- The qualified source is `https://github.com/Nasiko-Labs/nasiko`, licensed + under Apache-2.0; artifact and extracted-binary SHA-256 values are pinned. - Never replace the qualified command with a downloaded shell or PowerShell bootstrap script. - Never put secrets or credentials in command arguments, logs, skill output, @@ -25,8 +30,8 @@ Nasiko control plane with ECC. ## Lifecycle boundary -The initial ECC bridge supports only qualified installation and read-only -status. Use the canonical Nasiko CLI directly for connection, authentication, +The initial ECC bridge supports qualified installation, read-only status, and +ownership-checked uninstall. Use the canonical Nasiko CLI directly for connection, authentication, launch, deployment, or shutdown until those verbs have their own verified ECC contracts. Do not guess CLI verbs. diff --git a/tests/ci/nasiko-control-plane.test.js b/tests/ci/nasiko-control-plane.test.js index abdd0a74b..b67e8338d 100644 --- a/tests/ci/nasiko-control-plane.test.js +++ b/tests/ci/nasiko-control-plane.test.js @@ -56,6 +56,8 @@ async function main() { binaryName: 'nasiko.exe', }); assert.match(getQualifiedRelease('v0.1.0', 'linux', 'x64').manifestDigest, /^sha256:[a-f0-9]{64}$/); + assert.match(getQualifiedRelease('v0.1.0', 'linux', 'x64').binaryDigest, /^sha256:[a-f0-9]{64}$/); + assert.strictEqual(getQualifiedRelease('v0.1.0', 'linux', 'x64').license, 'Apache-2.0'); assert.throws(() => getQualifiedRelease('latest', 'darwin', 'arm64'), /pinned version/i); assert.throws(() => getQualifiedRelease('v1.0.0', 'darwin', 'arm64'), /not qualified/i); assert.throws(() => normalizePlatform('freebsd', 'x64'), /unsupported platform/i); @@ -86,6 +88,7 @@ async function main() { ['verifies manifest and blob digests before an atomic install', async () => { const { installNasiko } = require('../../scripts/lib/nasiko-release'); const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-green-')); + const binary = Buffer.from('#!/bin/sh\necho nasiko 0.1.0\n'); const manifest = Buffer.from(JSON.stringify({ schemaVersion: 2, mediaType: 'application/vnd.oci.image.manifest.v1+json', @@ -105,24 +108,50 @@ async function main() { }, { platform: 'darwin', arch: 'arm64', - releaseOverride: { manifestDigest: sha256Digest(manifest) }, - fetchBytes: async (url) => url.includes('/manifests/') ? manifest : archive, - inspectArchive: () => [{ path: 'nasiko', type: 'file' }], - extractArchive: (_archivePath, destination) => { - fs.writeFileSync(path.join(destination, 'nasiko'), '#!/bin/sh\necho nasiko v0.1.0\n', { mode: 0o755 }); + releaseOverride: { + manifestDigest: sha256Digest(manifest), + binaryDigest: sha256Digest(binary), }, - runVersion: executable => ({ status: 0, stdout: `${executable}: nasiko v0.1.0\n`, stderr: '' }), + fetchBytes: async (url) => url.includes('/manifests/') ? manifest : archive, + extractBinary: () => binary, }); assert.strictEqual(result.installed, true); assert.strictEqual(result.version, 'v0.1.0'); assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), true); assert.strictEqual(fs.existsSync(path.join(installRoot, '.ecc-nasiko-install.json')), true); + const { getQualifiedRelease, inspectInstalledNasiko, uninstallNasiko } = require('../../scripts/lib/nasiko-release'); + const fakeRelease = { + ...getQualifiedRelease('v0.1.0', 'darwin', 'arm64'), + manifestDigest: sha256Digest(manifest), + binaryDigest: sha256Digest(binary), + }; + const preview = await uninstallNasiko({ installDir: installRoot, dryRun: true }, { + platform: 'darwin', arch: 'arm64', releaseOverride: result, + }); + assert.strictEqual(preview.dryRun, true); + let renameCount = 0; + await assert.rejects(async () => uninstallNasiko({ installDir: installRoot, yes: true }, { + platform: 'darwin', arch: 'arm64', + inspectInstalled: destination => inspectInstalledNasiko(destination, () => fakeRelease), + rename: (source, destination) => { + renameCount += 1; + if (renameCount === 2) throw new Error('metadata staging unavailable'); + fs.renameSync(source, destination); + }, + }), /metadata staging unavailable/i); + assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), true); + assert.strictEqual(fs.existsSync(path.join(installRoot, '.ecc-nasiko-install.json')), true); + await uninstallNasiko({ installDir: installRoot, yes: true }, { + platform: 'darwin', arch: 'arm64', + inspectInstalled: destination => inspectInstalledNasiko(destination, () => fakeRelease), + }); + assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), false); } finally { fs.rmSync(installRoot, { recursive: true, force: true }); } }], ['rejects digest mismatch and unsafe archive entries without installing', async () => { - const { installNasiko, validateArchiveEntries } = require('../../scripts/lib/nasiko-release'); + const { extractQualifiedTarGzip, installNasiko } = require('../../scripts/lib/nasiko-release'); const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-reject-')); const manifest = Buffer.from('{"schemaVersion":2,"layers":[]}'); try { @@ -136,22 +165,16 @@ async function main() { /manifest digest mismatch/i ); assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), false); - assert.throws( - () => validateArchiveEntries([{ path: '../nasiko', type: 'file' }], 'nasiko'), - /unsafe archive/i - ); - assert.throws( - () => validateArchiveEntries([{ path: 'nasiko', type: 'symlink' }], 'nasiko'), - /regular file/i - ); + assert.throws(() => extractQualifiedTarGzip(Buffer.from('not gzip'), 'nasiko'), /invalid|size limit/i); } finally { fs.rmSync(installRoot, { recursive: true, force: true }); } }], - ['routes read-only status through an explicit absolute executable', () => { + ['read-only status never executes an unqualified explicit executable', () => { const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-status-')); const executable = path.join(fixtureRoot, 'nasiko'); - fs.writeFileSync(executable, '#!/bin/sh\nprintf "nasiko v0.1.0\\n"\n', { mode: 0o755 }); + const marker = path.join(fixtureRoot, 'executed'); + fs.writeFileSync(executable, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\nprintf "nasiko 0.1.0\\n"\n`, { mode: 0o755 }); try { const result = spawnSync(process.execPath, [ path.join(REPO_ROOT, 'scripts', 'ecc.js'), @@ -165,12 +188,80 @@ async function main() { assert.strictEqual(result.status, 0, result.stderr); const status = JSON.parse(result.stdout); assert.strictEqual(status.installed, true); - assert.strictEqual(status.version, 'v0.1.0'); + assert.strictEqual(status.qualified, false); + assert.strictEqual(status.version, null); assert.strictEqual(status.executable, executable); + assert.strictEqual(fs.existsSync(marker), false); } finally { fs.rmSync(fixtureRoot, { recursive: true, force: true }); } }], + ['rejects and never executes an unqualified pre-existing binary', async () => { + const { installNasiko } = require('../../scripts/lib/nasiko-release'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-existing-')); + const executable = path.join(installRoot, 'nasiko'); + const marker = path.join(installRoot, 'executed'); + fs.writeFileSync(executable, `#!/bin/sh\ntouch ${JSON.stringify(marker)}\necho nasiko 0.1.0\n`, { mode: 0o755 }); + try { + await assert.rejects( + installNasiko({ version: 'v0.1.0', yes: true, installDir: installRoot }, { + platform: 'darwin', arch: 'arm64', + }), + /unqualified|digest|metadata/i + ); + assert.strictEqual(fs.existsSync(marker), false); + } finally { + fs.rmSync(installRoot, { recursive: true, force: true }); + } + }], + ['rolls back a published binary when metadata persistence fails', async () => { + const { installNasiko } = require('../../scripts/lib/nasiko-release'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-rollback-')); + const binary = Buffer.from('qualified binary'); + const archive = Buffer.from('verified archive'); + const manifest = Buffer.from(JSON.stringify({ + schemaVersion: 2, + mediaType: 'application/vnd.oci.image.manifest.v1+json', + layers: [{ mediaType: 'application/gzip', digest: sha256Digest(archive), size: archive.length }], + })); + try { + await assert.rejects( + installNasiko({ version: 'v0.1.0', yes: true, installDir: installRoot }, { + platform: 'darwin', + arch: 'arm64', + releaseOverride: { + manifestDigest: sha256Digest(manifest), + binaryDigest: sha256Digest(binary), + }, + fetchBytes: async url => url.includes('/manifests/') ? manifest : archive, + extractBinary: () => binary, + writeMetadata: () => { throw new Error('metadata unavailable'); }, + }), + /metadata unavailable/i + ); + assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), false); + } finally { + fs.rmSync(installRoot, { recursive: true, force: true }); + } + }], + ['never overwrites or deletes a destination created during publication', async () => { + const { installNasiko } = require('../../scripts/lib/nasiko-release'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-race-')); + const binary = Buffer.from('qualified binary'); + const intruder = Buffer.from('concurrent owner'); + const archive = Buffer.from('verified archive'); + const manifest = Buffer.from(JSON.stringify({ schemaVersion: 2, layers: [{ mediaType: 'application/gzip', digest: sha256Digest(archive), size: archive.length }] })); + try { + await assert.rejects(installNasiko({ version: 'v0.1.0', yes: true, installDir: installRoot }, { + platform: 'darwin', arch: 'arm64', + releaseOverride: { manifestDigest: sha256Digest(manifest), binaryDigest: sha256Digest(binary) }, + fetchBytes: async url => url.includes('/manifests/') ? manifest : archive, + extractBinary: () => binary, + beforePublish: destination => fs.writeFileSync(destination, intruder, { flag: 'wx' }), + }), /exist/i); + assert.deepStrictEqual(fs.readFileSync(path.join(installRoot, 'nasiko')), intruder); + } finally { fs.rmSync(installRoot, { recursive: true, force: true }); } + }], ['ships a canonical opt-in skill without silently bundling Nasiko', () => { const skill = read('skills/nasiko-control-plane/SKILL.md'); assert.match(skill, /^name: nasiko-control-plane$/m); @@ -180,6 +271,7 @@ async function main() { assert.match(skill, /telemetry.*opt-in/i); assert.match(skill, /never.*secrets|never.*credentials/i); assert.match(skill, /install.*does not prove/i); + assert.match(skill, /ecc nasiko uninstall/i); assert.doesNotMatch(skill, /curl[^\n]*\|[^\n]*bash|irm[^\n]*\|[^\n]*iex/i); const modules = readJson('manifests/install-modules.json').modules; @@ -202,14 +294,15 @@ async function main() { ); const profiles = readJson('manifests/install-profiles.json').profiles; - for (const profile of Object.values(profiles)) { - assert.ok(!profile.modules.includes('nasiko-control-plane')); + assert.ok(profiles.full.modules.includes('nasiko-control-plane')); + for (const [profileId, profile] of Object.entries(profiles)) { + if (profileId !== 'full') assert.ok(!profile.modules.includes('nasiko-control-plane')); } const packageJson = readJson('package.json'); assert.ok(packageJson.files.includes('skills/nasiko-control-plane/')); assert.ok(packageJson.files.includes('scripts/nasiko.js')); - assert.ok(packageJson.files.includes('scripts/lib/nasiko-release.js')); + assert.ok(packageJson.files.includes('scripts/lib/')); assert.ok(!packageJson.dependencies?.nasiko); assert.ok(!packageJson.optionalDependencies?.nasiko); }], diff --git a/tests/scripts/npm-publish-surface.test.js b/tests/scripts/npm-publish-surface.test.js index 3ca4662cc..4f6a8d48d 100644 --- a/tests/scripts/npm-publish-surface.test.js +++ b/tests/scripts/npm-publish-surface.test.js @@ -62,6 +62,7 @@ function buildExpectedPublishPaths(repoRoot) { "scripts/loop-status.js", "scripts/memory.js", "scripts/memory-mcp.mjs", + "scripts/nasiko.js", "scripts/observability-readiness.js", "scripts/plan-canvas.js", "scripts/operator-readiness-dashboard.js", @@ -160,6 +161,8 @@ function main() { "scripts/ito.js", "scripts/memory.js", "scripts/memory-mcp.mjs", + "scripts/nasiko.js", + "scripts/lib/nasiko-release.js", "scripts/lib/memory-vault-format.js", "scripts/lib/memory-vault.js", "scripts/discussion-audit.js", From 28a8fda5680bb2d7ba0e1c328d85356cb24bebf4 Mon Sep 17 00:00:00 2001 From: Affaan Mustafa Date: Sat, 15 Aug 2026 21:47:57 -0400 Subject: [PATCH 4/4] fix: close Nasiko filesystem race windows --- scripts/lib/nasiko-release.js | 85 ++++++++++++++++++++------- scripts/nasiko.js | 8 +-- tests/ci/nasiko-control-plane.test.js | 34 ++++++++++- 3 files changed, 99 insertions(+), 28 deletions(-) diff --git a/scripts/lib/nasiko-release.js b/scripts/lib/nasiko-release.js index 21efaeafc..e04bf999f 100644 --- a/scripts/lib/nasiko-release.js +++ b/scripts/lib/nasiko-release.js @@ -144,7 +144,11 @@ function validateInstallDirectory(directory) { const resolved = path.resolve(directory); if (resolved === path.parse(resolved).root) throw new Error('Nasiko cannot install directly into a filesystem root.'); let ancestor = resolved; - while (!fs.existsSync(ancestor)) ancestor = path.dirname(ancestor); + while (!fs.existsSync(ancestor)) { + const parent = path.dirname(ancestor); + if (parent === ancestor) throw new Error('Nasiko install directory has no resolvable filesystem ancestor.'); + ancestor = parent; + } const canonical = fs.realpathSync(ancestor); return path.join(canonical, path.relative(ancestor, resolved)); } @@ -160,22 +164,54 @@ function assertPrivateInstallDirectory(directory) { function metadataPathFor(executable) { return path.join(path.dirname(executable), METADATA_FILENAME); } +function readBoundedRegularFile(filePath, maximumBytes) { + const noFollow = fs.constants.O_NOFOLLOW; + const descriptor = fs.openSync(filePath, fs.constants.O_RDONLY | (noFollow || 0)); + try { + const stats = fs.fstatSync(descriptor); + if (!stats.isFile() || stats.size <= 0 || stats.size > maximumBytes) return null; + if (!noFollow) { + const pathStats = fs.lstatSync(filePath); + if (pathStats.isSymbolicLink() + || pathStats.dev !== stats.dev + || pathStats.ino !== stats.ino + || pathStats.birthtimeMs !== stats.birthtimeMs) { + const error = new Error('Nasiko managed files must not be symbolic links or reparse points.'); + error.code = 'ELOOP'; + throw error; + } + } + const bytes = Buffer.allocUnsafe(stats.size); + let total = 0; + while (total < bytes.length) { + const count = fs.readSync(descriptor, bytes, total, bytes.length - total, total); + if (count === 0) return null; + total += count; + } + if (fs.fstatSync(descriptor).size !== stats.size) return null; + return bytes; + } finally { fs.closeSync(descriptor); } +} + function readMetadata(executable) { try { - const metadataPath = metadataPathFor(executable); - const stats = fs.lstatSync(metadataPath); - if (!stats.isFile() || stats.isSymbolicLink() || stats.size <= 0 || stats.size > MAX_METADATA_BYTES) return null; - return JSON.parse(fs.readFileSync(metadataPath, 'utf8')); + const bytes = readBoundedRegularFile(metadataPathFor(executable), MAX_METADATA_BYTES); + return bytes ? JSON.parse(bytes.toString('utf8')) : null; } catch (_error) { return null; } } function inspectInstalledNasiko(executable, resolveRelease = getQualifiedRelease) { - if (!executable || !fs.existsSync(executable)) return { installed: false, qualified: false, version: null, executable: executable || null }; - const stats = fs.lstatSync(executable); - if (!stats.isFile() || stats.isSymbolicLink()) throw new Error('Nasiko executable must be a regular file, not a symlink.'); - if (stats.size <= 0 || stats.size > MAX_BINARY_BYTES) return { installed: true, qualified: false, version: null, executable, binaryDigest: null, metadataPath: metadataPathFor(executable) }; - const binaryDigest = digestBytes(fs.readFileSync(executable)); + if (!executable) return { installed: false, qualified: false, version: null, executable: null }; + let binary; + try { binary = readBoundedRegularFile(executable, MAX_BINARY_BYTES); } + catch (error) { + if (error.code === 'ENOENT') return { installed: false, qualified: false, version: null, executable }; + if (error.code === 'ELOOP') throw new Error('Nasiko executable must be a regular file, not a symlink.'); + throw error; + } + if (!binary) return { installed: true, qualified: false, version: null, executable, binaryDigest: null, metadataPath: metadataPathFor(executable) }; + const binaryDigest = digestBytes(binary); const metadata = readMetadata(executable); let release = null; try { if (metadata) release = resolveRelease(metadata.version, metadata.platform, metadata.architecture); } catch (_error) { release = null; } @@ -193,17 +229,23 @@ function writeMetadataExclusive(metadataPath, metadata) { fs.writeFileSync(metadataPath, `${JSON.stringify(metadata, null, 2)}\n`, { mode: 0o600, flag: 'wx' }); } -function acquireLifecycleLock(installDirectory) { +function acquireLifecycleLock(installDirectory, fileSystem = fs) { const lockPath = path.join(installDirectory, '.ecc-nasiko-lifecycle.lock'); let descriptor; - try { descriptor = fs.openSync(lockPath, 'wx', 0o600); } + try { + descriptor = fileSystem.openSync(lockPath, 'wx', 0o600); + fileSystem.writeFileSync(descriptor, `${JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() })}\n`); + fileSystem.fsyncSync(descriptor); + } catch (error) { - if (error.code === 'EEXIST') throw new Error('Another Nasiko lifecycle operation is already in progress.'); + if (error.code === 'EEXIST') throw new Error(`Another Nasiko lifecycle operation is already in progress; inspect ${lockPath} before recovering a stale lock.`); + if (descriptor !== undefined) { + try { fileSystem.closeSync(descriptor); } finally { fileSystem.rmSync(lockPath, { force: true }); } + } throw error; } return () => { - fs.closeSync(descriptor); - fs.rmSync(lockPath, { force: true }); + try { fileSystem.closeSync(descriptor); } finally { fileSystem.rmSync(lockPath, { force: true }); } }; } @@ -223,8 +265,8 @@ async function installNasiko(options = {}, dependencies = {}) { let destinationOwned = false; let metadataOwned = false; try { - if (fs.existsSync(destination) || fs.existsSync(metadataPath)) { - const existing = inspectInstalledNasiko(destination); + const existing = inspectInstalledNasiko(destination); + if (existing.installed) { if (existing.qualified && existing.version === version) return { ...plan, dryRun: false, installed: true, reused: true }; throw new Error('An unqualified or incompatible Nasiko executable or receipt already exists at the destination.'); } @@ -240,8 +282,11 @@ async function installNasiko(options = {}, dependencies = {}) { if (dependencies.beforePublish) dependencies.beforePublish(destination); const descriptor = fs.openSync(destination, 'wx', 0o700); destinationOwned = true; - try { fs.writeFileSync(descriptor, binary); fs.fsyncSync(descriptor); } finally { fs.closeSync(descriptor); } - assertDigest(fs.readFileSync(destination), release.binaryDigest, 'Published Nasiko binary'); + try { + fs.writeFileSync(descriptor, binary); + fs.fsyncSync(descriptor); + if (fs.fstatSync(descriptor).size !== binary.length) throw new Error('Published Nasiko binary size mismatch.'); + } finally { fs.closeSync(descriptor); } const metadata = { version, platform: release.os, architecture: release.arch, manifestDigest: release.manifestDigest, artifactDigest: layer.digest, binaryDigest: release.binaryDigest, installedPath: destination, license: release.license, sourceUrl: release.sourceUrl }; (dependencies.writeMetadata || writeMetadataExclusive)(metadataPath, metadata); metadataOwned = true; @@ -292,4 +337,4 @@ function uninstallNasiko(options = {}, dependencies = {}) { } finally { releaseLock(); } } -module.exports = { QUALIFIED_RELEASES, REGISTRY_ORIGIN, digestBytes, extractQualifiedTarGzip, fetchBytes, getQualifiedRelease, inspectInstalledNasiko, installNasiko, normalizePlatform, uninstallNasiko, validateInstallDirectory }; +module.exports = { QUALIFIED_RELEASES, REGISTRY_ORIGIN, acquireLifecycleLock, digestBytes, extractQualifiedTarGzip, fetchBytes, getQualifiedRelease, inspectInstalledNasiko, installNasiko, normalizePlatform, uninstallNasiko, validateInstallDirectory }; diff --git a/scripts/nasiko.js b/scripts/nasiko.js index d1c06526b..27c9c5ddf 100644 --- a/scripts/nasiko.js +++ b/scripts/nasiko.js @@ -1,7 +1,6 @@ #!/usr/bin/env node 'use strict'; -const fs = require('fs'); const os = require('os'); const path = require('path'); const { @@ -75,17 +74,12 @@ function resolveExecutable(options = {}) { if (!path.isAbsolute(candidate)) { throw new Error('ECC_NASIKO_CLI_EXECUTABLE must be an absolute path.'); } - if (!fs.existsSync(candidate)) return null; - const stats = fs.lstatSync(candidate); - if (!stats.isFile() || stats.isSymbolicLink()) { - throw new Error('Nasiko executable must be a regular file, not a symlink.'); - } return candidate; } function readStatus(options = {}) { const executable = resolveExecutable(options); - if (!executable) return { installed: false, version: null, executable: null }; + if (!executable) return { installed: false, qualified: false, version: null, executable: null }; return inspectInstalledNasiko(executable); } diff --git a/tests/ci/nasiko-control-plane.test.js b/tests/ci/nasiko-control-plane.test.js index b67e8338d..8f9d7746f 100644 --- a/tests/ci/nasiko-control-plane.test.js +++ b/tests/ci/nasiko-control-plane.test.js @@ -85,6 +85,23 @@ async function main() { assert.strictEqual(plan.registryOrigin, 'https://registry.nasiko.dev'); assert.strictEqual(fetchCount, 0); }], + ['cleans an exclusively created lifecycle lock when initialization fails', () => { + const { acquireLifecycleLock } = require('../../scripts/lib/nasiko-release'); + const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-lock-')); + const lockPath = path.join(installRoot, '.ecc-nasiko-lifecycle.lock'); + try { + const failingFileSystem = { + ...fs, + writeFileSync: () => { throw new Error('lock metadata unavailable'); }, + }; + assert.throws(() => acquireLifecycleLock(installRoot, failingFileSystem), /metadata unavailable/i); + assert.strictEqual(fs.existsSync(lockPath), false); + const releaseLock = acquireLifecycleLock(installRoot); + assert.strictEqual(fs.existsSync(lockPath), true); + releaseLock(); + assert.strictEqual(fs.existsSync(lockPath), false); + } finally { fs.rmSync(installRoot, { recursive: true, force: true }); } + }], ['verifies manifest and blob digests before an atomic install', async () => { const { installNasiko } = require('../../scripts/lib/nasiko-release'); const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-green-')); @@ -126,9 +143,11 @@ async function main() { binaryDigest: sha256Digest(binary), }; const preview = await uninstallNasiko({ installDir: installRoot, dryRun: true }, { - platform: 'darwin', arch: 'arm64', releaseOverride: result, + platform: 'darwin', arch: 'arm64', }); assert.strictEqual(preview.dryRun, true); + assert.strictEqual(preview.version, 'v0.1.0'); + assert.strictEqual(preview.destination, path.join(fs.realpathSync(installRoot), 'nasiko')); let renameCount = 0; await assert.rejects(async () => uninstallNasiko({ installDir: installRoot, yes: true }, { platform: 'darwin', arch: 'arm64', @@ -146,6 +165,7 @@ async function main() { inspectInstalled: destination => inspectInstalledNasiko(destination, () => fakeRelease), }); assert.strictEqual(fs.existsSync(path.join(installRoot, 'nasiko')), false); + assert.strictEqual(fs.existsSync(path.join(installRoot, '.ecc-nasiko-install.json')), false); } finally { fs.rmSync(installRoot, { recursive: true, force: true }); } @@ -196,6 +216,18 @@ async function main() { fs.rmSync(fixtureRoot, { recursive: true, force: true }); } }], + ['read-only status has a stable absent result shape', () => { + const { readStatus } = require('../../scripts/nasiko'); + const fixtureRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-absent-')); + try { + assert.deepStrictEqual(readStatus({ installDir: fixtureRoot }), { + installed: false, + qualified: false, + version: null, + executable: path.join(fs.realpathSync(fixtureRoot), 'nasiko'), + }); + } finally { fs.rmSync(fixtureRoot, { recursive: true, force: true }); } + }], ['rejects and never executes an unqualified pre-existing binary', async () => { const { installNasiko } = require('../../scripts/lib/nasiko-release'); const installRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-nasiko-existing-'));