fix: harden Nasiko artifact lifecycle

This commit is contained in:
Affaan Mustafa
2026-08-15 02:22:29 -04:00
parent 0d39ae83dd
commit 9ba25b9360
15 changed files with 388 additions and 297 deletions
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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 |
+1 -1
View File
@@ -196,7 +196,7 @@ Copy-Item -Recurse rules/typescript "$HOME/.claude/rules/"
/plugin list ecc@ecc
```
**完成!** 你现在可以使用 68 个代理、284 个技能和 94 个命令。
**完成!** 你现在可以使用 68 个代理、285 个技能和 94 个命令。
### multi-* 命令需要额外配置
+2 -2
View File
@@ -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)
+2 -2
View File
@@ -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/ — 始终遵循的指导方针(通用 + 每种语言)
+3 -3
View File
@@ -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 种类型 | SessionStart1 种类型) | 11 种类型 |
| **钩子脚本** | 20+ 个脚本 | 16 个脚本 (DRY 适配器) | 1 个 SessionStart 引导脚本 | 插件钩子 |
| **规则** | 34 (通用 + 语言) | 34 (YAML 前页) | 基于指令 | 13 条指令 |
+1
View File
@@ -89,6 +89,7 @@
"optimization-workflows",
"prediction-market-skills",
"ito-compute",
"nasiko-control-plane",
"social-distribution",
"media-generation",
"orchestration",
-1
View File
@@ -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",
+203 -231
View File
@@ -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 };
+46 -28
View File
@@ -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 <absolute-path>] [--json]
ecc nasiko install --version v0.1.0 --yes [--install-dir <absolute-path>] [--json]
ecc nasiko install --version v0.1.0 --dry-run [--install-dir <absolute-path>] [--json]
ecc nasiko uninstall --version v0.1.0 --yes [--install-dir <absolute-path>] [--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 };
+7 -2
View File
@@ -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.
+114 -21
View File
@@ -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);
}],
@@ -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",