mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-18 14:07:33 +02:00
Merge upstream/main into feat/bengali-language-support
Keep both the new Polski locale and Bengali in every language switcher, locale list and alias map; cross-link docs/pl and docs/bn. Resync catalog counts (69 agents, 294 skills) and the skill-trigger registry digest. Co-Authored-By: Kishor Morol <kishormorol@gmail.com>
This commit is contained in:
@@ -133,6 +133,7 @@ function determineInstallCwd(record, repoRoot) {
|
||||
// install-apply.js if its package.json identifies it as ECC — otherwise a
|
||||
// cloned project that ships a nested `evil/{package.json,scripts/install-apply.js}`
|
||||
// could drive auto-update into executing attacker code (GHSA-hfpv-w6mp-5g95).
|
||||
// 'everything-claude-code' is the legacy 1.x package name, kept for existing checkouts.
|
||||
const ECC_PACKAGE_NAMES = new Set(['ecc-universal', 'everything-claude-code']);
|
||||
|
||||
function validateRepoRoot(repoRoot) {
|
||||
|
||||
Executable
+390
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Regenerate pi/core/ deterministically from manifests/pi-core.json.
|
||||
*
|
||||
* The profile is a curated, Pi-native, skills+prompts-only package:
|
||||
* pi/core/package.json - { name: "ecc-pi-core", version: <root VERSION>, license: MIT,
|
||||
* keywords: ["pi-package", "skills"],
|
||||
* pi: { skills: ["./skills"], prompts: ["./commands"] } }
|
||||
* pi/core/LICENSE - copy of the root LICENSE
|
||||
* pi/core/README.md - short generated overview
|
||||
* pi/core/CURATION.md - every excluded skill/command with its reason
|
||||
* pi/core/skills/ - curated skill directories (council renamed to ecc-council)
|
||||
* pi/core/commands/ - curated prompt command files
|
||||
*
|
||||
* Safety checks (build fails if violated; semantics documented in
|
||||
* manifests/pi-core.json safety.semantics):
|
||||
* - no callable http(s) endpoints (documentation links allowed via the host
|
||||
* allowlist in manifests/pi-core.json, plus localhost/example placeholders
|
||||
* and non-FQDN internal hostnames)
|
||||
* - no runtime download-and-run forms: pipe-to-shell (curl|sh, wget|sh) or
|
||||
* fetch-and-run npx (-y/--yes, pkg@version, create-*, degit, skills add);
|
||||
* skills whose own operation downloads tooling are excluded in the manifest
|
||||
* - no secrets or tokens
|
||||
* - no absolute per-user home paths (/Users/..., /home/..., C:\Users\...)
|
||||
* - no symlinks
|
||||
* - every SKILL.md has frontmatter with name == directory name and a description
|
||||
* - no duplicate skill names
|
||||
*
|
||||
* Scoped exceptions for incidental mentions (anti-pattern warnings, detection
|
||||
* examples) live in manifests/pi-core.json safety.scanAllowlist with a reason.
|
||||
*
|
||||
* Usage: node scripts/build-pi-core.js [--check]
|
||||
* --check rebuild and verify pi/core is already up to date (exit 1 on drift)
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const MANIFEST_PATH = path.join(ROOT, 'manifests', 'pi-core.json');
|
||||
const manifest = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8'));
|
||||
|
||||
const PROFILE_DIR = path.join(ROOT, manifest.profile.dir);
|
||||
const SKILLS_SRC = path.join(ROOT, 'skills');
|
||||
const COMMANDS_SRC = path.join(ROOT, 'commands');
|
||||
const VERSION = fs.readFileSync(path.join(ROOT, 'VERSION'), 'utf8').trim();
|
||||
const CHECK_MODE = process.argv.includes('--check');
|
||||
|
||||
const violations = [];
|
||||
function fail(msg) {
|
||||
violations.push(msg);
|
||||
}
|
||||
|
||||
// ---------- partition completeness -----------------------------------------
|
||||
// Every on-disk skill dir and command file must be classified exactly once in
|
||||
// the manifest, so new content cannot silently bypass curation.
|
||||
{
|
||||
const onDiskSkills = fs.readdirSync(SKILLS_SRC, { withFileTypes: true })
|
||||
.filter(e => e.isDirectory()).map(e => e.name);
|
||||
const known = new Set([...manifest.skills.include, ...Object.keys(manifest.skills.exclude)]);
|
||||
for (const dir of onDiskSkills) {
|
||||
if (!known.has(dir)) fail(`skills/${dir} is not classified in manifests/pi-core.json`);
|
||||
}
|
||||
for (const name of known) {
|
||||
if (!onDiskSkills.includes(name)) fail(`manifests/pi-core.json references missing skills/${name}`);
|
||||
}
|
||||
const onDiskCommands = fs.readdirSync(COMMANDS_SRC).filter(f => f.endsWith('.md'));
|
||||
const knownCmd = new Set([...manifest.commands.include, ...Object.keys(manifest.commands.exclude)]);
|
||||
for (const f of onDiskCommands) {
|
||||
if (!knownCmd.has(f)) fail(`commands/${f} is not classified in manifests/pi-core.json`);
|
||||
}
|
||||
for (const f of knownCmd) {
|
||||
if (!onDiskCommands.includes(f)) fail(`manifests/pi-core.json references missing commands/${f}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- deterministic copy ----------------------------------------------
|
||||
/** Recursively list files under dir, sorted; reject symlinks. */
|
||||
function listFiles(dir, base) {
|
||||
const out = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
||||
const full = path.join(dir, entry.name);
|
||||
const rel = base ? `${base}/${entry.name}` : entry.name;
|
||||
if (entry.isSymbolicLink()) {
|
||||
fail(`symlink found in profile source: ${rel}`);
|
||||
} else if (entry.isDirectory()) {
|
||||
out.push(...listFiles(full, rel));
|
||||
} else if (entry.isFile()) {
|
||||
out.push({ full, rel });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function copyFile(src, dest) {
|
||||
fs.mkdirSync(path.dirname(dest), { recursive: true });
|
||||
fs.copyFileSync(src, dest);
|
||||
}
|
||||
|
||||
/** Parse YAML frontmatter minimally: returns { name, description } or null. */
|
||||
function parseFrontmatter(text) {
|
||||
const m = text.replace(/^\uFEFF/, '').match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
||||
if (!m) return null;
|
||||
const name = (m[1].match(/^name:\s*["']?(.+?)["']?\s*$/m) || [])[1];
|
||||
const description = (m[1].match(/^description:\s*["']?([\s\S]+?)["']?\s*$/m) || [])[1];
|
||||
return { name: name || '', description: description || '', raw: m[1] };
|
||||
}
|
||||
|
||||
// ---------- rebuild pi/core --------------------------------------------------
|
||||
const tmpDir = path.join(ROOT, 'pi', '.core-build-tmp');
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(tmpDir, { recursive: true });
|
||||
|
||||
const rename = manifest.skills.rename || {};
|
||||
const skillDescriptions = [];
|
||||
const seenNames = new Map();
|
||||
|
||||
for (const dirName of manifest.skills.include) {
|
||||
const srcDir = path.join(SKILLS_SRC, dirName);
|
||||
const outName = rename[dirName] || dirName;
|
||||
const destDir = path.join(tmpDir, 'skills', outName);
|
||||
const skillMd = path.join(srcDir, 'SKILL.md');
|
||||
if (!fs.existsSync(skillMd)) {
|
||||
fail(`skills/${dirName}/SKILL.md is missing`);
|
||||
continue;
|
||||
}
|
||||
const text = fs.readFileSync(skillMd, 'utf8');
|
||||
const fm = parseFrontmatter(text);
|
||||
if (!fm) {
|
||||
fail(`skills/${dirName}/SKILL.md has no parseable frontmatter`);
|
||||
} else {
|
||||
if (fm.name !== dirName) fail(`skills/${dirName}/SKILL.md frontmatter name "${fm.name}" != directory name`);
|
||||
if (!fm.description) fail(`skills/${dirName}/SKILL.md frontmatter has no description`);
|
||||
skillDescriptions.push(fm.description);
|
||||
if (seenNames.has(outName)) fail(`duplicate skill name in profile: ${outName} (skills/${dirName} and skills/${seenNames.get(outName)})`);
|
||||
seenNames.set(outName, dirName);
|
||||
}
|
||||
for (const f of listFiles(srcDir, '')) {
|
||||
let content = null;
|
||||
if (rename[dirName] && f.rel === 'SKILL.md') {
|
||||
// Rename the skill inside pi/core only; the root skill keeps its name.
|
||||
content = fs.readFileSync(f.full, 'utf8')
|
||||
.replace(/^name:\s*["']?[^\n"']+["']?\s*$/m, `name: ${outName}`);
|
||||
fs.mkdirSync(path.dirname(path.join(destDir, f.rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(destDir, f.rel), content);
|
||||
} else {
|
||||
copyFile(f.full, path.join(destDir, f.rel));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const file of manifest.commands.include) {
|
||||
copyFile(path.join(COMMANDS_SRC, file), path.join(tmpDir, 'commands', file));
|
||||
}
|
||||
|
||||
copyFile(path.join(ROOT, 'LICENSE'), path.join(tmpDir, 'LICENSE'));
|
||||
|
||||
const profilePackage = {
|
||||
name: manifest.profile.packageName,
|
||||
version: VERSION,
|
||||
license: manifest.profile.license,
|
||||
keywords: manifest.profile.keywords,
|
||||
pi: { skills: ['./skills'], prompts: ['./commands'] },
|
||||
};
|
||||
fs.writeFileSync(path.join(tmpDir, 'package.json'), JSON.stringify(profilePackage, null, 2) + '\n');
|
||||
|
||||
const skillCount = manifest.skills.include.length;
|
||||
const commandCount = manifest.commands.include.length;
|
||||
const descChars = skillDescriptions.reduce((n, d) => n + d.length, 0);
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, 'README.md'), `# ecc-pi-core
|
||||
|
||||
A curated, Pi-native profile of ECC: ${skillCount} portable
|
||||
engineering skills and ${commandCount} pure prompt-workflow commands, with no extensions,
|
||||
no hooks, no runtime downloads, and no network or SaaS dependencies.
|
||||
|
||||
## Contents
|
||||
|
||||
- \`skills/\` - language, framework, testing/TDD, code review, security review,
|
||||
planning, refactoring, docs, and git/PR workflow skills.
|
||||
- \`commands/\` - prompt commands that are pure prompt workflows.
|
||||
- \`CURATION.md\` - every excluded skill and command with its reason.
|
||||
|
||||
## Use
|
||||
|
||||
Copy this directory into your project (or pin a release tarball) and load it with the
|
||||
Pi coding agent:
|
||||
|
||||
\`\`\`sh
|
||||
pi --no-extensions --extension pi/core
|
||||
\`\`\`
|
||||
|
||||
Offline load test (as run in CI):
|
||||
|
||||
\`\`\`sh
|
||||
PI_OFFLINE=1 pi --offline --mode rpc --no-session --no-context-files --no-extensions \\
|
||||
--extension pi/core </dev/null >/dev/null
|
||||
\`\`\`
|
||||
|
||||
## Regenerate
|
||||
|
||||
\`pi/core\` is generated from \`manifests/pi-core.json\` and committed so release
|
||||
tarballs contain it verbatim. After changing the manifest or any included source
|
||||
content, run:
|
||||
|
||||
\`\`\`sh
|
||||
node scripts/build-pi-core.js
|
||||
\`\`\`
|
||||
|
||||
and commit the result. CI verifies the committed profile is up to date.
|
||||
`);
|
||||
|
||||
{
|
||||
const lines = [];
|
||||
lines.push('# Curation');
|
||||
lines.push('');
|
||||
lines.push(`pi/core includes ${skillCount} of ${Object.keys(manifest.skills.exclude).length + skillCount} skills ` +
|
||||
`and ${commandCount} of ${Object.keys(manifest.commands.exclude).length + commandCount} commands from the root of ECC.`);
|
||||
lines.push('Everything excluded is listed here with its reason.');
|
||||
lines.push('');
|
||||
lines.push('## Rules');
|
||||
lines.push('');
|
||||
lines.push('Include: ' + manifest.curationRules.include.join('; ') + '.');
|
||||
lines.push('');
|
||||
lines.push('Exclude anything that:');
|
||||
for (const rule of manifest.curationRules.exclude) lines.push(`- ${rule}`);
|
||||
lines.push('');
|
||||
lines.push('## Excluded skills');
|
||||
lines.push('');
|
||||
lines.push('| Skill | Reason |');
|
||||
lines.push('|---|---|');
|
||||
for (const [name, reason] of Object.entries(manifest.skills.exclude)) {
|
||||
lines.push(`| \`${name}\` | ${reason.replace(/\|/g, '\\|')} |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## Excluded commands');
|
||||
lines.push('');
|
||||
lines.push('| Command | Reason |');
|
||||
lines.push('|---|---|');
|
||||
for (const [file, reason] of Object.entries(manifest.commands.exclude)) {
|
||||
lines.push(`| \`${file.replace(/\.md$/, '')}\` | ${reason.replace(/\|/g, '\\|')} |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## Renames');
|
||||
lines.push('');
|
||||
for (const [from, to] of Object.entries(rename)) {
|
||||
lines.push(`- \`${from}\` is shipped as \`${to}\` inside pi/core (the root skill keeps its original name).`);
|
||||
}
|
||||
lines.push('');
|
||||
fs.writeFileSync(path.join(tmpDir, 'CURATION.md'), lines.join('\n'));
|
||||
}
|
||||
|
||||
// ---------- safety scans -----------------------------------------------------
|
||||
//
|
||||
// Scanner semantics (mirrors the curation rules in manifests/pi-core.json):
|
||||
// - URLs: documentation links are allowed via safety.urlAllowlistHosts.
|
||||
// Placeholder hosts (example.com/org/net and subdomains) and non-FQDN
|
||||
// internal hostnames (localhost, docker service names like "api" or "db")
|
||||
// are always allowed; they are not callable endpoints.
|
||||
// - Runtime downloads: pipe-to-shell (curl|sh, wget|sh) and fetch-and-run
|
||||
// npx forms (-y/--yes, pkg@version, create-*, degit, "skills add") fail.
|
||||
// Local-first `npx <tool>` (jest, tsc, playwright, prisma, ...) and
|
||||
// standard project dependency installation (pip install <dep>, npm i) are
|
||||
// the reader's own project workflow, not the profile downloading code to
|
||||
// run itself, so they are allowed; skills whose own operation downloads
|
||||
// tooling are excluded in the manifest instead (see CURATION.md).
|
||||
// - Home paths: absolute per-user paths (/Users/..., /home/..., C:\Users\...)
|
||||
// fail. Portable tilde references (~/.cache/...) are user-agnostic and OK.
|
||||
const defaultHosts = new Set(manifest.safety.defaultAllowedHosts || []);
|
||||
const allowHosts = new Set([...(manifest.safety.urlAllowlistHosts || []), ...defaultHosts]);
|
||||
const scanAllowlist = manifest.safety.scanAllowlist || [];
|
||||
const PLACEHOLDER_SUFFIXES = ['.example.com', '.example.org', '.example.net'];
|
||||
|
||||
function isAllowlisted(relPath, line) {
|
||||
return scanAllowlist.some(e => relPath === e.path && line.includes(e.contains));
|
||||
}
|
||||
|
||||
function hostAllowed(hostname) {
|
||||
if (!hostname) return false;
|
||||
if (allowHosts.has(hostname)) return true;
|
||||
if (!hostname.includes('.')) return true; // localhost, docker service names, placeholders
|
||||
return PLACEHOLDER_SUFFIXES.some(s => hostname.endsWith(s));
|
||||
}
|
||||
|
||||
const URL_RE = /https?:\/\/([A-Za-z0-9._-]+)(?::\d+)?[^\s)\]"'`<>}]*/g;
|
||||
const SECRET_RES = [
|
||||
{ re: /AKIA[0-9A-Z]{16}/, label: 'AWS access key' },
|
||||
{ re: /ghp_[A-Za-z0-9]{20,}/, label: 'GitHub PAT' },
|
||||
{ re: /github_pat_[A-Za-z0-9_]{22,}/, label: 'GitHub fine-grained PAT' },
|
||||
{ re: /sk-[A-Za-z0-9_-]{20,}/, label: 'OpenAI-style key' },
|
||||
{ re: /xox[baprs]-[A-Za-z0-9-]{10,}/, label: 'Slack token' },
|
||||
{ re: /AIza[0-9A-Za-z_-]{35}/, label: 'Google API key' },
|
||||
{ re: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/, label: 'private key block' },
|
||||
{ re: /eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/, label: 'JWT' },
|
||||
];
|
||||
const INSTALL_RES = [
|
||||
{ re: /\b(?:curl|wget)\b[^\n|]*\|\s*(?:sudo\s+)?(?:ba|z|fi)?sh\b/, label: 'pipe-to-shell (curl|sh / wget|sh)' },
|
||||
{ re: /\bnpx\s+(?:-y\b|--yes\b)/, label: 'npx -y/--yes (fetch-and-run)' },
|
||||
{ re: /\bnpx\s+(?:--\S+\s+)*[A-Za-z@][^\s]*@\d/, label: 'npx pkg@version (fetch-and-run)' },
|
||||
{ re: /\bnpx\s+(?:--\S+\s+)*(?:create-[a-z-]+|degit\b|skills\s+add\b)/, label: 'npx scaffold/fetch form' },
|
||||
{ re: /\bpipx\s+install\b/, label: 'pipx install' },
|
||||
];
|
||||
const HOME_RES = [
|
||||
{ re: /\/Users\/[^\s)"'`\]<>|]+/, label: 'macOS home path' },
|
||||
{ re: /\/home\/[^\s)"'`\]<>|]+/, label: 'Linux home path' },
|
||||
{ re: /[A-Za-z]:\\Users\\[^\s)"'`\]<>|]+/, label: 'Windows home path' },
|
||||
];
|
||||
const GENERATED_FILES = new Set(['package.json', 'README.md', 'CURATION.md']);
|
||||
|
||||
function scanTextFile(relPath, text) {
|
||||
if (GENERATED_FILES.has(relPath)) return; // generated from the manifest itself
|
||||
const lines = text.split('\n');
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const at = `${relPath}:${i + 1}`;
|
||||
const allowlisted = isAllowlisted(relPath, line);
|
||||
for (const m of line.matchAll(URL_RE)) {
|
||||
if (!hostAllowed(m[1]) && !allowlisted) {
|
||||
fail(`${at}: non-allowlisted URL host ${m[1]} (${m[0].slice(0, 100)})`);
|
||||
}
|
||||
}
|
||||
if (allowlisted) continue;
|
||||
for (const { re, label } of SECRET_RES) {
|
||||
if (re.test(line)) fail(`${at}: possible secret (${label})`);
|
||||
}
|
||||
for (const { re, label } of INSTALL_RES) {
|
||||
if (re.test(line)) fail(`${at}: runtime download pattern (${label})`);
|
||||
}
|
||||
for (const { re, label } of HOME_RES) {
|
||||
if (re.test(line)) fail(`${at}: absolute home path (${label})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const f of listFiles(tmpDir, '')) {
|
||||
const rel = f.rel;
|
||||
const buf = fs.readFileSync(f.full);
|
||||
if (buf.includes(0)) continue; // binary file: nothing textual to scan
|
||||
scanTextFile(rel, buf.toString('utf8'));
|
||||
}
|
||||
|
||||
// ---------- emit report -------------------------------------------------------
|
||||
const report = [
|
||||
`pi/core: ${skillCount} skills, ${commandCount} commands`,
|
||||
`skill description text: ${descChars} characters (target: under ~40000)`,
|
||||
];
|
||||
if (descChars > 42000) fail(`skill description text too large: ${descChars} characters`);
|
||||
|
||||
if (violations.length) {
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
console.error('pi/core build FAILED:');
|
||||
for (const v of violations) console.error(' - ' + v);
|
||||
console.error(report.join('\n'));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (CHECK_MODE) {
|
||||
// Compare tmpDir against the committed profile without touching it.
|
||||
const committed = listFiles(PROFILE_DIR, '').map(f => f.rel).sort();
|
||||
const built = listFiles(tmpDir, '').map(f => f.rel).sort();
|
||||
let drift = false;
|
||||
if (committed.join('\n') !== built.join('\n')) {
|
||||
console.error('pi/core file list drift:');
|
||||
const cSet = new Set(committed), bSet = new Set(built);
|
||||
for (const f of committed) if (!bSet.has(f)) console.error(' only committed: ' + f);
|
||||
for (const f of built) if (!cSet.has(f)) console.error(' only built: ' + f);
|
||||
drift = true;
|
||||
} else {
|
||||
for (const rel of committed) {
|
||||
const a = fs.readFileSync(path.join(PROFILE_DIR, rel));
|
||||
const b = fs.readFileSync(path.join(tmpDir, rel));
|
||||
if (!a.equals(b)) { console.error('pi/core content drift: ' + rel); drift = true; }
|
||||
}
|
||||
}
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
if (drift) {
|
||||
console.error('Run: node scripts/build-pi-core.js');
|
||||
process.exit(1);
|
||||
}
|
||||
console.log('pi/core is up to date.');
|
||||
console.log(report.join('\n'));
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
fs.rmSync(PROFILE_DIR, { recursive: true, force: true });
|
||||
fs.renameSync(tmpDir, PROFILE_DIR);
|
||||
console.log(report.join('\n'));
|
||||
console.log(`wrote ${manifest.profile.dir}/`);
|
||||
@@ -18,6 +18,8 @@ const path = require('path');
|
||||
const ROOT = path.join(__dirname, '../..');
|
||||
const README_PATH = path.join(ROOT, 'README.md');
|
||||
const AGENTS_PATH = path.join(ROOT, 'AGENTS.md');
|
||||
const SOUL_PATH = path.join(ROOT, 'SOUL.md');
|
||||
const GEMINI_PATH = path.join(ROOT, '.gemini', 'GEMINI.md');
|
||||
const README_ZH_CN_PATH = path.join(ROOT, 'README.zh-CN.md');
|
||||
const DOCS_ZH_CN_README_PATH = path.join(ROOT, 'docs', 'zh-CN', 'README.md');
|
||||
const DOCS_ZH_CN_AGENTS_PATH = path.join(ROOT, 'docs', 'zh-CN', 'AGENTS.md');
|
||||
@@ -273,6 +275,30 @@ function parseAgentsDocExpectations(agentsContent) {
|
||||
return expectations;
|
||||
}
|
||||
|
||||
function parseCrossHarnessIdentityExpectations(content, source) {
|
||||
const match = content.match(/with\s+(\d+)\s+specialized agents,\s+(\d+)\s+skills,\s+(?:and\s+)?(\d+)\s+commands/i);
|
||||
if (!match) {
|
||||
throw new Error(`${source} is missing the catalog summary line`);
|
||||
}
|
||||
|
||||
return [
|
||||
{ category: 'agents', mode: 'exact', expected: Number(match[1]), source },
|
||||
{ category: 'skills', mode: 'exact', expected: Number(match[2]), source },
|
||||
{ category: 'commands', mode: 'exact', expected: Number(match[3]), source },
|
||||
];
|
||||
}
|
||||
|
||||
function syncCrossHarnessIdentity(content, catalog, source) {
|
||||
return replaceOrThrow(
|
||||
content,
|
||||
/(with\s+)(\d+)(\s+specialized agents,\s+)(\d+)(\s+skills,\s+(?:and\s+)?)(\d+)(\s+commands)/i,
|
||||
(_, prefix, __, agentsSuffix, ___, skillsSuffix, ____, commandsSuffix) => (
|
||||
`${prefix}${catalog.agents.count}${agentsSuffix}${catalog.skills.count}${skillsSuffix}${catalog.commands.count}${commandsSuffix}`
|
||||
),
|
||||
source
|
||||
);
|
||||
}
|
||||
|
||||
function parseZhAgentsDocExpectations(agentsContent) {
|
||||
const summaryMatch = agentsContent.match(/提供\s+(\d+)\s+个专业代理、\s*(\d+)(\+)?\s*项技能、\s*(\d+)\s+条命令/i);
|
||||
if (!summaryMatch) {
|
||||
@@ -563,6 +589,8 @@ function createDocumentSpecs(paths = {}) {
|
||||
const {
|
||||
readmePath = README_PATH,
|
||||
agentsPath = AGENTS_PATH,
|
||||
soulPath = SOUL_PATH,
|
||||
geminiPath = GEMINI_PATH,
|
||||
zhRootReadmePath = README_ZH_CN_PATH,
|
||||
zhDocsReadmePath = DOCS_ZH_CN_README_PATH,
|
||||
zhDocsAgentsPath = DOCS_ZH_CN_AGENTS_PATH,
|
||||
@@ -581,6 +609,16 @@ function createDocumentSpecs(paths = {}) {
|
||||
parseExpectations: parseAgentsDocExpectations,
|
||||
syncContent: syncEnglishAgents,
|
||||
},
|
||||
{
|
||||
filePath: soulPath,
|
||||
parseExpectations: content => parseCrossHarnessIdentityExpectations(content, 'SOUL.md'),
|
||||
syncContent: (content, catalog) => syncCrossHarnessIdentity(content, catalog, 'SOUL.md'),
|
||||
},
|
||||
{
|
||||
filePath: geminiPath,
|
||||
parseExpectations: content => parseCrossHarnessIdentityExpectations(content, '.gemini/GEMINI.md'),
|
||||
syncContent: (content, catalog) => syncCrossHarnessIdentity(content, catalog, '.gemini/GEMINI.md'),
|
||||
},
|
||||
{
|
||||
filePath: zhRootReadmePath,
|
||||
parseExpectations: parseZhRootReadmeExpectations,
|
||||
@@ -633,6 +671,8 @@ function createDocumentSpecsForRoot(root) {
|
||||
return createDocumentSpecs({
|
||||
readmePath: path.join(root, 'README.md'),
|
||||
agentsPath: path.join(root, 'AGENTS.md'),
|
||||
soulPath: path.join(root, 'SOUL.md'),
|
||||
geminiPath: path.join(root, '.gemini', 'GEMINI.md'),
|
||||
zhRootReadmePath: path.join(root, 'README.zh-CN.md'),
|
||||
zhDocsReadmePath: path.join(root, 'docs', 'zh-CN', 'README.md'),
|
||||
zhDocsAgentsPath: path.join(root, 'docs', 'zh-CN', 'AGENTS.md'),
|
||||
@@ -742,6 +782,7 @@ module.exports = {
|
||||
formatExpectation,
|
||||
main,
|
||||
parseAgentsDocExpectations,
|
||||
parseCrossHarnessIdentityExpectations,
|
||||
parseCatalogDescriptionExpectations,
|
||||
parseReadmeExpectations,
|
||||
parseZhAgentsDocExpectations,
|
||||
@@ -751,6 +792,7 @@ module.exports = {
|
||||
syncCatalogDescription,
|
||||
syncEnglishAgents,
|
||||
syncEnglishReadme,
|
||||
syncCrossHarnessIdentity,
|
||||
syncZhAgents,
|
||||
syncZhDocsReadme,
|
||||
syncZhRootReadme,
|
||||
|
||||
Executable
+110
@@ -0,0 +1,110 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Offline load test for the pi/core profile.
|
||||
*
|
||||
* Requires the Pi coding agent CLI on PATH (`pi`). Spawns:
|
||||
*
|
||||
* PI_OFFLINE=1 pi --offline --mode rpc --no-session --no-context-files \
|
||||
* --no-extensions --skill pi/core/skills --prompt-template pi/core/commands
|
||||
*
|
||||
* and asserts:
|
||||
* 1. the process exits 0 (the profile loads cleanly, fully offline);
|
||||
* 2. the RPC `get_commands` response succeeds and reports exactly the
|
||||
* commands listed in manifests/pi-core.json (proof the prompt tree was
|
||||
* actually parsed, not just accepted as a path).
|
||||
*
|
||||
* Flag note: the natural `--extension pi/core` only loads a package's
|
||||
* `pi.extensions` entries; pi/core is a skills+prompts-only package, so the
|
||||
* equivalent resource flags `--skill` and `--prompt-template` are used.
|
||||
*
|
||||
* Usage: node scripts/ci/pi-core-load-test.js
|
||||
*/
|
||||
|
||||
'use strict';
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..', '..');
|
||||
const PROFILE = path.join(ROOT, 'pi', 'core');
|
||||
const manifest = JSON.parse(
|
||||
fs.readFileSync(path.join(ROOT, 'manifests', 'pi-core.json'), 'utf8')
|
||||
);
|
||||
|
||||
const expected = manifest.commands.include
|
||||
.map(f => f.replace(/\.md$/, ''))
|
||||
.sort();
|
||||
|
||||
const child = spawn(
|
||||
'pi',
|
||||
[
|
||||
'--offline',
|
||||
'--mode', 'rpc',
|
||||
'--no-session',
|
||||
'--no-context-files',
|
||||
'--no-extensions',
|
||||
'--skill', path.join(PROFILE, 'skills'),
|
||||
'--prompt-template', path.join(PROFILE, 'commands'),
|
||||
],
|
||||
{ env: { ...process.env, PI_OFFLINE: '1' } }
|
||||
);
|
||||
|
||||
let buffer = '';
|
||||
let response = null;
|
||||
let stderr = '';
|
||||
const watchdog = setTimeout(() => {
|
||||
console.error('timed out waiting for pi RPC response');
|
||||
child.kill('SIGKILL');
|
||||
process.exit(1);
|
||||
}, 120000);
|
||||
|
||||
child.stderr.on('data', d => { stderr += d; });
|
||||
child.stdout.on('data', d => {
|
||||
buffer += d;
|
||||
let idx;
|
||||
while ((idx = buffer.indexOf('\n')) !== -1) {
|
||||
const line = buffer.slice(0, idx);
|
||||
buffer = buffer.slice(idx + 1);
|
||||
try {
|
||||
const msg = JSON.parse(line);
|
||||
if (msg.id === '1' && msg.type === 'response' && msg.command === 'get_commands') {
|
||||
response = msg;
|
||||
child.stdin.end();
|
||||
}
|
||||
} catch { /* ignore non-JSON chatter */ }
|
||||
}
|
||||
});
|
||||
child.on('error', err => {
|
||||
clearTimeout(watchdog);
|
||||
console.error('failed to spawn pi:', err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
child.stdin.write('{"id":"1","type":"get_commands"}\n');
|
||||
|
||||
child.on('close', status => {
|
||||
clearTimeout(watchdog);
|
||||
if (status !== 0) {
|
||||
console.error(`pi exited ${status}`);
|
||||
if (stderr) console.error(stderr);
|
||||
process.exit(1);
|
||||
}
|
||||
if (!response || response.success !== true) {
|
||||
console.error('get_commands RPC did not succeed');
|
||||
process.exit(1);
|
||||
}
|
||||
const loaded = (response.data.commands || [])
|
||||
.filter(c => c.source === 'prompt')
|
||||
.map(c => c.name)
|
||||
.sort();
|
||||
const missing = expected.filter(c => !loaded.includes(c));
|
||||
const extra = loaded.filter(c => !expected.includes(c));
|
||||
if (missing.length || extra.length) {
|
||||
console.error('pi/core command load mismatch');
|
||||
if (missing.length) console.error(' missing: ' + missing.join(', '));
|
||||
if (extra.length) console.error(' unexpected: ' + extra.join(', '));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`pi offline load OK: ${loaded.length}/${expected.length} commands loaded from pi/core`);
|
||||
});
|
||||
@@ -0,0 +1,365 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('node:fs');
|
||||
const { performance } = require('node:perf_hooks');
|
||||
|
||||
const API_VERSION = '2022-11-28';
|
||||
const SHA = /^[0-9a-f]{40}$/;
|
||||
const MAX_PAGES = 10;
|
||||
const MAX_ITEMS = 1000;
|
||||
const DEFAULT_ATTEMPTS = 20;
|
||||
const DEFAULT_DELAY_MS = 30_000;
|
||||
const TOTAL_TIMEOUT_MS = 600_000;
|
||||
const REQUEST_TIMEOUT_MS = 15_000;
|
||||
const CI_PATH = '.github/workflows/ci.yml';
|
||||
const CODEQL_PATH = 'dynamic/github-code-scanning/codeql';
|
||||
// Repository policy: default CodeQL must complete all three categories in ONE
|
||||
// attempt. A new category requires an explicit policy update, not silent approval.
|
||||
const REQUIRED_CODEQL = ['Analyze (actions)', 'Analyze (javascript-typescript)', 'Analyze (python)'];
|
||||
const ACTIONS_APP = { id: 15368, slug: 'github-actions' };
|
||||
|
||||
const record = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const id = value => Number.isSafeInteger(value) && value > 0;
|
||||
const sha = value => typeof value === 'string' && SHA.test(value);
|
||||
const text = value => typeof value === 'string' && value.length > 0;
|
||||
const resultShape = value => record(value) && text(value.status)
|
||||
&& (value.conclusion === null || text(value.conclusion));
|
||||
const repoShape = value => record(value) && id(value.id) && text(value.full_name);
|
||||
const objectShape = value => record(value) && text(value.type) && sha(value.sha);
|
||||
const referenceShape = value => record(value) && text(value.ref) && objectShape(value.object);
|
||||
const tagShape = value => record(value) && sha(value.sha) && text(value.tag)
|
||||
&& objectShape(value.object) && record(value.verification)
|
||||
&& typeof value.verification.verified === 'boolean' && text(value.verification.reason);
|
||||
const workflowShape = value => record(value) && id(value.id) && text(value.path) && text(value.state);
|
||||
const runShape = value => resultShape(value) && id(value.id) && id(value.workflow_id)
|
||||
&& text(value.path) && sha(value.head_sha) && text(value.head_branch) && text(value.event)
|
||||
&& id(value.run_attempt) && id(value.check_suite_id)
|
||||
&& repoShape(value.repository) && repoShape(value.head_repository);
|
||||
const checkShape = value => resultShape(value) && id(value.id) && text(value.name)
|
||||
&& sha(value.head_sha) && record(value.check_suite) && id(value.check_suite.id)
|
||||
&& record(value.app) && id(value.app.id) && text(value.app.slug);
|
||||
const jobShape = value => resultShape(value) && id(value.id) && text(value.name)
|
||||
&& id(value.run_id) && id(value.run_attempt) && sha(value.head_sha)
|
||||
&& text(value.head_branch) && text(value.check_run_url);
|
||||
|
||||
function requiredEnvironment(env = process.env) {
|
||||
const inputs = {
|
||||
repository: env.GITHUB_REPOSITORY,
|
||||
releaseSha: env.RELEASE_SHA,
|
||||
releaseTag: env.RELEASE_TAG,
|
||||
token: env.GITHUB_TOKEN,
|
||||
tagObjectSha: env.RELEASE_TAG_OBJECT_SHA,
|
||||
};
|
||||
for (const name of ['repository', 'releaseSha', 'releaseTag', 'token']) {
|
||||
if (!text(inputs[name])) throw new Error(`Missing required release gate input: ${name}`);
|
||||
}
|
||||
validateInputs(inputs);
|
||||
return inputs;
|
||||
}
|
||||
|
||||
function validateInputs(inputs) {
|
||||
if (!/^[A-Za-z0-9_-][A-Za-z0-9_.-]*\/[A-Za-z0-9_-][A-Za-z0-9_.-]*$/.test(inputs.repository || '')) {
|
||||
throw new Error('Invalid release repository');
|
||||
}
|
||||
if (!sha(inputs.releaseSha)) throw new Error('RELEASE_SHA must be a full lowercase commit SHA');
|
||||
if (!/^v[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?$/.test(inputs.releaseTag || '')) {
|
||||
throw new Error('RELEASE_TAG is not a supported version tag');
|
||||
}
|
||||
if (!text(inputs.token)) throw new Error('Missing release gate token');
|
||||
if (inputs.tagObjectSha !== undefined && !sha(inputs.tagObjectSha)) {
|
||||
throw new Error('Invalid expected tag object SHA');
|
||||
}
|
||||
}
|
||||
|
||||
function setting(value, fallback, maximum) {
|
||||
const parsed = value === undefined ? fallback : Number(value);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > maximum) {
|
||||
throw new Error('Release gate settings must be positive integers within their finite limits');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
class ReleaseGateDeadlineError extends Error {}
|
||||
|
||||
function createGithubClient(inputs, fetchImpl = fetch, options = {}) {
|
||||
validateInputs(inputs);
|
||||
const now = options.now || (() => performance.now());
|
||||
const deadline = now() + setting(options.timeoutMs, TOTAL_TIMEOUT_MS, TOTAL_TIMEOUT_MS);
|
||||
const requestMs = setting(options.requestTimeoutMs, REQUEST_TIMEOUT_MS, REQUEST_TIMEOUT_MS);
|
||||
const base = `https://api.github.com/repos/${inputs.repository}`;
|
||||
|
||||
function remaining() {
|
||||
const left = deadline - now();
|
||||
if (left <= 0) throw new ReleaseGateDeadlineError('Release gate global deadline exceeded');
|
||||
return left;
|
||||
}
|
||||
|
||||
async function bounded(operation, limit) {
|
||||
const controller = new AbortController();
|
||||
let timer;
|
||||
try {
|
||||
return await Promise.race([
|
||||
Promise.resolve().then(() => operation(controller.signal)),
|
||||
new Promise((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new ReleaseGateDeadlineError('Release gate request or global deadline exceeded'));
|
||||
}, Math.min(limit, remaining()));
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function urlFor(pathOrUrl) {
|
||||
const url = new URL(pathOrUrl === '' || pathOrUrl.startsWith('/') ? base + pathOrUrl : pathOrUrl);
|
||||
if (url.origin !== 'https://api.github.com' || url.username || url.password || url.hash
|
||||
|| (url.pathname !== `/repos/${inputs.repository}` && !url.pathname.startsWith(`/repos/${inputs.repository}/`))) {
|
||||
throw new Error('GitHub API URL escaped the release repository');
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
async function page(url, validator) {
|
||||
remaining();
|
||||
return bounded(async signal => {
|
||||
const response = await fetchImpl(url.toString(), {
|
||||
redirect: 'error', signal,
|
||||
headers: {
|
||||
Accept: 'application/vnd.github+json',
|
||||
Authorization: `Bearer ${inputs.token}`,
|
||||
'X-GitHub-Api-Version': API_VERSION,
|
||||
},
|
||||
});
|
||||
if (!response.ok) throw new Error(`GitHub API failed with status ${response.status}`);
|
||||
let payload;
|
||||
try { payload = await response.json(); } catch { throw new Error('Invalid GitHub API JSON response'); }
|
||||
if (!validator(payload)) throw new Error('GitHub API response validation failed');
|
||||
remaining();
|
||||
return { payload, link: response.headers?.get?.('link') };
|
||||
}, requestMs);
|
||||
}
|
||||
|
||||
async function get(path, validator) {
|
||||
return (await page(urlFor(path), validator)).payload;
|
||||
}
|
||||
|
||||
async function pages(path, key, itemValidator) {
|
||||
const first = urlFor(path);
|
||||
const seen = new Set();
|
||||
const identities = new Set();
|
||||
let next = first;
|
||||
let total;
|
||||
const items = [];
|
||||
while (next) {
|
||||
const identity = paginationIdentity(next, first);
|
||||
if (seen.has(identity)) throw new Error('GitHub API pagination cycle');
|
||||
if (seen.size >= MAX_PAGES) throw new Error('GitHub API page limit exceeded');
|
||||
seen.add(identity);
|
||||
const { payload, link } = await page(next, value => record(value)
|
||||
&& Number.isSafeInteger(value.total_count) && value.total_count >= 0
|
||||
&& Array.isArray(value[key]));
|
||||
if (payload.total_count > MAX_ITEMS || payload[key].length > 100) {
|
||||
throw new Error('GitHub API item limit exceeded');
|
||||
}
|
||||
if (total !== undefined && total !== payload.total_count) throw new Error('GitHub API collection total changed');
|
||||
total = payload.total_count;
|
||||
for (const item of payload[key]) {
|
||||
if (!itemValidator(item)) throw new Error('GitHub API response validation failed');
|
||||
if (identities.has(item.id)) throw new Error('Ambiguous duplicate GitHub API item');
|
||||
identities.add(item.id);
|
||||
items.push(item);
|
||||
}
|
||||
if (items.length > MAX_ITEMS || items.length > total) throw new Error('GitHub API item limit or total exceeded');
|
||||
const linkUrl = nextPageUrl(link);
|
||||
next = linkUrl ? urlFor(linkUrl) : null;
|
||||
}
|
||||
if (items.length !== total) throw new Error('Incomplete GitHub API collection total');
|
||||
return items;
|
||||
}
|
||||
|
||||
return { get, pages, pause: sleep => bounded(signal => sleep(signal), remaining()), remaining };
|
||||
}
|
||||
|
||||
function paginationIdentity(url, first) {
|
||||
const query = candidate => {
|
||||
const keys = [...candidate.searchParams.keys()];
|
||||
if (new Set(keys).size !== keys.length) throw new Error('Ambiguous pagination query');
|
||||
return [...candidate.searchParams].filter(([key]) => key !== 'page').sort().map(pair => JSON.stringify(pair)).join(',');
|
||||
};
|
||||
const page = url.searchParams.get('page');
|
||||
if (url.pathname !== first.pathname || query(url) !== query(first)
|
||||
|| (page !== null && !/^[1-9][0-9]*$/.test(page))) {
|
||||
throw new Error('GitHub API pagination escaped the endpoint collection');
|
||||
}
|
||||
return `${url.pathname}?${query(url)}&page=${page || '1'}`;
|
||||
}
|
||||
|
||||
function nextPageUrl(header) {
|
||||
if (!header) return null;
|
||||
let next = null;
|
||||
for (const entry of header.split(',')) {
|
||||
const match = entry.trim().match(/^<([^>]+)>;\s*rel="(next|prev|first|last)"$/);
|
||||
if (!match) throw new Error('Malformed GitHub API pagination Link');
|
||||
if (match[2] === 'next') {
|
||||
if (next) throw new Error('Ambiguous GitHub API next page');
|
||||
next = match[1];
|
||||
}
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
async function verifySignedAnnotatedTag(inputs, fetchImpl = fetch, options = {}) {
|
||||
validateInputs(inputs);
|
||||
const client = options.client || createGithubClient(inputs, fetchImpl, options);
|
||||
const reference = await client.get(`/git/ref/tags/${encodeURIComponent(inputs.releaseTag)}`, referenceShape);
|
||||
if (reference.ref !== `refs/tags/${inputs.releaseTag}` || reference.object.type !== 'tag') {
|
||||
throw new Error('Release ref must match the requested annotated tag; lightweight tags are rejected');
|
||||
}
|
||||
if (inputs.tagObjectSha && reference.object.sha !== inputs.tagObjectSha) {
|
||||
throw new Error('Release tag object changed after initial verification');
|
||||
}
|
||||
const tag = await client.get(`/git/tags/${reference.object.sha}`, tagShape);
|
||||
if (tag.sha !== reference.object.sha || tag.tag !== inputs.releaseTag) {
|
||||
throw new Error('Signed tag object identity or name does not match the release ref');
|
||||
}
|
||||
// GitHub signature validity is not a project-specific authorized-signer list.
|
||||
if (tag.verification.verified !== true || tag.verification.reason !== 'valid') {
|
||||
throw new Error('Release tag signature is not verified');
|
||||
}
|
||||
if (tag.object.type !== 'commit' || tag.object.sha !== inputs.releaseSha) {
|
||||
throw new Error('Verified release tag does not point at the checked-out commit');
|
||||
}
|
||||
return tag.sha;
|
||||
}
|
||||
|
||||
async function trustedProducers(client, inputs) {
|
||||
const repository = await client.get('', value => repoShape(value) && value.default_branch === 'main');
|
||||
if (repository.full_name !== inputs.repository) throw new Error('Repository identity mismatch');
|
||||
const workflows = await client.pages('/actions/workflows?per_page=100', 'workflows', workflowShape);
|
||||
const select = path => {
|
||||
const matches = workflows.filter(workflow => workflow.path === path);
|
||||
if (matches.length !== 1 || matches[0].state !== 'active') throw new Error('Missing or ambiguous active trusted workflow');
|
||||
return matches[0];
|
||||
};
|
||||
return { repository, ci: select(CI_PATH), codeql: select(CODEQL_PATH) };
|
||||
}
|
||||
|
||||
function selectRuns(runs, inputs, trusted) {
|
||||
const sameRepo = repo => repo.id === trusted.repository.id && repo.full_name === inputs.repository;
|
||||
const select = (workflow, event) => runs.filter(run => run.workflow_id === workflow.id
|
||||
&& run.path === workflow.path && run.head_sha === inputs.releaseSha && run.head_branch === 'main'
|
||||
&& run.event === event && sameRepo(run.repository) && sameRepo(run.head_repository))
|
||||
.sort((a, b) => b.id - a.id || b.run_attempt - a.run_attempt)[0];
|
||||
return { ci: select(trusted.ci, 'push'), codeql: select(trusted.codeql, 'dynamic') };
|
||||
}
|
||||
|
||||
function statusOf(result, label, pendingLabel = label) {
|
||||
if (!result) return { state: 'pending', reason: `${pendingLabel} run not found for release SHA` };
|
||||
if (result.status !== 'completed') return { state: 'pending', reason: `${pendingLabel} is ${result.status}` };
|
||||
return result.conclusion === 'success' ? { state: 'passed' }
|
||||
: { state: 'failed', reason: `${label} concluded ${result.conclusion}` };
|
||||
}
|
||||
|
||||
function assessExactShaGates(selected, checks, jobs, inputs) {
|
||||
for (const [name, run] of Object.entries(selected)) {
|
||||
const assessment = statusOf(run, name);
|
||||
if (assessment.state !== 'passed') return assessment;
|
||||
}
|
||||
const run = selected.codeql;
|
||||
if (jobs.some(job => !REQUIRED_CODEQL.includes(job.name))) {
|
||||
throw new Error('Unexpected CodeQL category; review the explicit required-category policy');
|
||||
}
|
||||
for (const name of REQUIRED_CODEQL) {
|
||||
const matches = jobs.filter(job => job.name === name);
|
||||
if (matches.length > 1) throw new Error('Ambiguous required CodeQL job');
|
||||
const job = matches[0];
|
||||
if (!job) return { state: 'pending', reason: `CodeQL job "${name}" missing from selected attempt` };
|
||||
if (job.run_id !== run.id || job.run_attempt !== run.run_attempt
|
||||
|| job.head_sha !== inputs.releaseSha || job.head_branch !== 'main') {
|
||||
throw new Error('CodeQL job does not belong to the selected run attempt');
|
||||
}
|
||||
const check = checks.find(candidate => job.check_run_url
|
||||
=== `https://api.github.com/repos/${inputs.repository}/check-runs/${candidate.id}`);
|
||||
if (!check || check.name !== name || check.head_sha !== inputs.releaseSha
|
||||
|| check.check_suite.id !== run.check_suite_id || check.app.id !== ACTIONS_APP.id
|
||||
|| check.app.slug !== ACTIONS_APP.slug) {
|
||||
return { state: 'pending', reason: `CodeQL check "${name}" missing or not bound to trusted job` };
|
||||
}
|
||||
for (const [kind, result] of [['job', job], ['check', check]]) {
|
||||
const assessment = statusOf(result, name, `CodeQL ${kind} "${name}"`);
|
||||
if (assessment.state !== 'passed') return assessment;
|
||||
}
|
||||
}
|
||||
return { state: 'passed' };
|
||||
}
|
||||
|
||||
function defaultSleep(delay, signal) {
|
||||
return new Promise(resolve => {
|
||||
const timer = setTimeout(resolve, delay);
|
||||
signal.addEventListener('abort', () => { clearTimeout(timer); resolve(); }, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForExactShaGates(inputs, fetchImpl = fetch, sleep = defaultSleep, options = {}) {
|
||||
const client = options.client || createGithubClient(inputs, fetchImpl, options);
|
||||
const attempts = setting(options.attempts ?? process.env.RELEASE_GATE_ATTEMPTS, DEFAULT_ATTEMPTS, DEFAULT_ATTEMPTS);
|
||||
const delay = setting(options.delayMs ?? process.env.RELEASE_GATE_DELAY_MS, DEFAULT_DELAY_MS, DEFAULT_DELAY_MS);
|
||||
let lastReason = 'no gate assessment completed';
|
||||
try {
|
||||
const trusted = await trustedProducers(client, inputs);
|
||||
const readRuns = async () => selectRuns(await client.pages(
|
||||
`/actions/runs?head_sha=${inputs.releaseSha}&branch=main&per_page=100`, 'workflow_runs', runShape
|
||||
), inputs, trusted);
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
const selected = await readRuns();
|
||||
let assessment = statusOf(selected.ci, 'CI');
|
||||
if (assessment.state === 'passed') assessment = statusOf(selected.codeql, 'CodeQL');
|
||||
if (assessment.state === 'passed') {
|
||||
const run = selected.codeql;
|
||||
const jobs = await client.pages(`/actions/runs/${run.id}/attempts/${run.run_attempt}/jobs?per_page=100`, 'jobs', jobShape);
|
||||
const checks = await client.pages(`/check-suites/${run.check_suite_id}/check-runs?filter=all&per_page=100`, 'check_runs', checkShape);
|
||||
assessment = assessExactShaGates(selected, checks, jobs, inputs);
|
||||
if (assessment.state === 'passed') {
|
||||
// Do not approve an attempt superseded while its jobs/checks were read.
|
||||
const finalRuns = await readRuns();
|
||||
if (JSON.stringify(finalRuns) === JSON.stringify(selected)) return;
|
||||
assessment = { state: 'pending', reason: 'Trusted CI or CodeQL run changed during verification' };
|
||||
}
|
||||
}
|
||||
if (assessment.state === 'failed') throw new Error(assessment.reason);
|
||||
lastReason = assessment.reason;
|
||||
if (attempt < attempts) await client.pause(signal => sleep(delay, signal));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ReleaseGateDeadlineError) {
|
||||
throw new Error(`${error.message}; last pending gate: ${lastReason}`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
throw new Error(`Timed out waiting for successful exact-SHA CI and CodeQL checks; last pending gate: ${lastReason}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const inputs = requiredEnvironment();
|
||||
const tagOnly = process.argv.includes('--tag-only');
|
||||
if (tagOnly && !inputs.tagObjectSha) throw new Error('Tag-only recheck requires the original tag object SHA');
|
||||
const client = createGithubClient(inputs);
|
||||
const tagObjectSha = await verifySignedAnnotatedTag(inputs, fetch, { client });
|
||||
if (!tagOnly) await waitForExactShaGates(inputs, fetch, defaultSleep, { client });
|
||||
if (process.env.GITHUB_OUTPUT) {
|
||||
fs.appendFileSync(process.env.GITHUB_OUTPUT, `release_sha=${inputs.releaseSha}\ntag_object_sha=${tagObjectSha}\n`);
|
||||
}
|
||||
console.log(tagOnly ? 'Verified unchanged release tag snapshot.'
|
||||
: 'Verified signed annotated tag and successful exact-SHA CI/CodeQL gates.');
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch(error => {
|
||||
console.error(`Release gate verification failed: ${error.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { assessExactShaGates, createGithubClient, requiredEnvironment, verifySignedAnnotatedTag, waitForExactShaGates };
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* NanoClaw v2 — Barebones Agent REPL for Everything Claude Code
|
||||
* NanoClaw v2 — Barebones Agent REPL for ECC
|
||||
*
|
||||
* Zero external dependencies. Session-aware REPL around `claude -p`.
|
||||
*/
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* scripts/codemaps/generate.ts
|
||||
*
|
||||
* Codemap Generator for everything-claude-code (ECC)
|
||||
* Codemap Generator for ECC
|
||||
*
|
||||
* Scans the current working directory and generates architectural
|
||||
* codemap documentation under docs/CODEMAPS/ as specified by the
|
||||
|
||||
@@ -81,7 +81,9 @@ require_file "$CONFIG_FILE" "Global config.toml"
|
||||
require_file "$AGENTS_FILE" "Global AGENTS.md"
|
||||
|
||||
if [[ -f "$AGENTS_FILE" ]]; then
|
||||
if search_file '^# Everything Claude Code \(ECC\)' "$AGENTS_FILE"; then
|
||||
# Accept the current "# ECC" heading and the legacy expanded-name heading
|
||||
# that older syncs wrote into existing ~/.codex/AGENTS.md files.
|
||||
if search_file '^# (ECC|Everything Claude Code \(ECC\))( |$)' "$AGENTS_FILE"; then
|
||||
ok "AGENTS contains ECC root instructions"
|
||||
else
|
||||
fail "AGENTS missing ECC root instructions"
|
||||
|
||||
@@ -94,7 +94,7 @@ const DEFAULT_MCP_STARTUP_TIMEOUT_TOML = `startup_timeout_sec = ${DEFAULT_MCP_ST
|
||||
// mcp-configs/mcp-servers.json. Existing user-managed entries are never
|
||||
// touched by the merge (add-only), except the known-invalid repair below.
|
||||
const ECC_SERVERS = {
|
||||
'chrome-devtools': dlxServer('chrome-devtools', 'chrome-devtools-mcp@latest', { startup_timeout_sec: DEFAULT_MCP_STARTUP_TIMEOUT_SEC }, DEFAULT_MCP_STARTUP_TIMEOUT_TOML)
|
||||
'chrome-devtools': dlxServer('chrome-devtools', 'chrome-devtools-mcp@1.10.1', { startup_timeout_sec: DEFAULT_MCP_STARTUP_TIMEOUT_SEC }, DEFAULT_MCP_STARTUP_TIMEOUT_TOML)
|
||||
};
|
||||
|
||||
// ECC <= 2.0.0 emitted [mcp_servers.exa] with a `url` key. Codex rejects
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
// Slash commands: /ecc /help /skill /docs /release
|
||||
//
|
||||
// Env: DISCORD_BOT_TOKEN (required), DISCORD_APP_ID (required),
|
||||
// ECC_REPO (path to local clone, default ~/GitHub/ECC/everything-claude-code),
|
||||
// ECC_REPO (path to local clone, default ~/GitHub/ECC),
|
||||
// DISCORD_INVITE (optional, shown in /ecc)
|
||||
//
|
||||
// Crash-only design: any gateway close, error, or missed heartbeat ack exits
|
||||
@@ -21,7 +21,12 @@ if (!TOKEN || !APP_ID) {
|
||||
console.error('missing DISCORD_BOT_TOKEN / DISCORD_APP_ID');
|
||||
process.exit(1);
|
||||
}
|
||||
const REPO = process.env.ECC_REPO || join(homedir(), 'GitHub/ECC/everything-claude-code');
|
||||
// Prefer ~/GitHub/ECC. The nested legacy checkout path is still accepted so
|
||||
// existing hosts keep working without setting ECC_REPO.
|
||||
const REPO = process.env.ECC_REPO || [
|
||||
join(homedir(), 'GitHub/ECC'),
|
||||
join(homedir(), 'GitHub/ECC/everything-claude-code'),
|
||||
].find(dir => existsSync(join(dir, 'package.json'))) || join(homedir(), 'GitHub/ECC');
|
||||
const REPO_URL = 'https://github.com/affaan-m/ECC';
|
||||
const INVITE = process.env.DISCORD_INVITE || '';
|
||||
const API = 'https://discord.com/api/v10';
|
||||
@@ -133,12 +138,12 @@ const HELP = [
|
||||
|
||||
const handlers = {
|
||||
ecc: () => [
|
||||
'**Everything Claude Code (ECC)** — the agent harness performance system.',
|
||||
'**ECC** — the agent harness performance system.',
|
||||
'Skills, agents, rules, hooks, MCP conventions, and operator workflows that move across Claude Code, Codex, OpenCode, Cursor, Gemini, and Zed.',
|
||||
'',
|
||||
`- repo: ${REPO_URL}`,
|
||||
'- site: https://ecc.tools',
|
||||
`- install: \`/plugin marketplace add affaan-m/everything-claude-code\` then \`/plugin install ecc\``,
|
||||
`- install: \`/plugin marketplace add affaan-m/ECC\` then \`/plugin install ecc\``,
|
||||
INVITE ? `- invite a friend: ${INVITE}` : '',
|
||||
].filter(Boolean).join('\n'),
|
||||
|
||||
|
||||
+64
-1
@@ -19,6 +19,7 @@
|
||||
# GAN_PROJECT_DIR — Working directory (default: current dir)
|
||||
# GAN_SKIP_PLANNER — Set to "true" to skip planner phase
|
||||
# GAN_EVAL_MODE — playwright, screenshot, or code-only (default: playwright)
|
||||
# playwright requires a connected MCP server named "playwright"
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -96,6 +97,38 @@ score_passes() {
|
||||
awk -v s="$score" -v t="$threshold" 'BEGIN { exit !(s >= t) }'
|
||||
}
|
||||
|
||||
playwright_mcp_is_connected() {
|
||||
local status
|
||||
local status_value
|
||||
local check_mark
|
||||
local heavy_check_mark
|
||||
status=$(NO_COLOR=1 claude mcp get playwright 2>/dev/null) || return 1
|
||||
status_value=$(printf '%s\n' "$status" | awk '
|
||||
/^[[:space:]]*Status:[[:space:]]*/ {
|
||||
sub(/^[[:space:]]*Status:[[:space:]]*/, "")
|
||||
sub(/[[:space:]]*$/, "")
|
||||
print
|
||||
exit
|
||||
}
|
||||
')
|
||||
check_mark=$(printf '\342\234\223')
|
||||
heavy_check_mark=$(printf '\342\234\224')
|
||||
|
||||
[ "$status_value" = "$check_mark Connected" ] || \
|
||||
[ "$status_value" = "$heavy_check_mark Connected" ]
|
||||
}
|
||||
|
||||
evaluator_tools_for_mode() {
|
||||
local base_tools="Read,Write,Bash,Grep,Glob"
|
||||
local playwright_tools="mcp__playwright__browser_navigate,mcp__playwright__browser_click,mcp__playwright__browser_take_screenshot,mcp__playwright__browser_snapshot,mcp__playwright__browser_type,mcp__playwright__browser_fill_form,mcp__playwright__browser_resize,mcp__playwright__browser_press_key"
|
||||
|
||||
if [ "$1" = "playwright" ]; then
|
||||
printf '%s,%s\n' "$base_tools" "$playwright_tools"
|
||||
else
|
||||
printf '%s\n' "$base_tools"
|
||||
fi
|
||||
}
|
||||
|
||||
elapsed() {
|
||||
local now=$(date +%s)
|
||||
local diff=$((now - START_TIME))
|
||||
@@ -104,6 +137,30 @@ elapsed() {
|
||||
|
||||
# ─── Setup ───────────────────────────────────────────────────────────────────
|
||||
|
||||
case "$EVAL_MODE" in
|
||||
playwright)
|
||||
if ! playwright_mcp_is_connected; then
|
||||
fail "GAN_EVAL_MODE=playwright requires a connected MCP server named 'playwright'."
|
||||
fail "Run 'claude mcp get playwright' to inspect its status, or choose GAN_EVAL_MODE=screenshot or code-only."
|
||||
exit 1
|
||||
fi
|
||||
;;
|
||||
screenshot|code-only)
|
||||
;;
|
||||
*)
|
||||
fail "Unsupported GAN_EVAL_MODE. Expected playwright, screenshot, or code-only."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
EVALUATOR_TOOLS=$(evaluator_tools_for_mode "$EVAL_MODE")
|
||||
EVALUATOR_OPTIONS=(--allowedTools "$EVALUATOR_TOOLS")
|
||||
if [ "$EVAL_MODE" != "playwright" ]; then
|
||||
# Allow rules only pre-approve calls. Deny this server's tools explicitly
|
||||
# in modes that inspect existing screenshots or source instead.
|
||||
EVALUATOR_OPTIONS+=(--disallowedTools 'mcp__playwright__*')
|
||||
fi
|
||||
|
||||
phase "GAN-STYLE HARNESS — Setup"
|
||||
|
||||
log "Brief: ${CYAN}${BRIEF}${NC}"
|
||||
@@ -205,8 +262,14 @@ Update gan-harness/generator-state.md." \
|
||||
# ── EVALUATE ──
|
||||
echo -e "${RED}>> EVALUATOR (iteration $i)${NC}"
|
||||
|
||||
if [ "$EVAL_MODE" = "playwright" ] && ! playwright_mcp_is_connected; then
|
||||
fail "The Playwright MCP server disconnected before evaluator iteration $i."
|
||||
fail "Run 'claude mcp get playwright' to inspect its status, then retry the harness."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
claude -p --model "$EVALUATOR_MODEL" \
|
||||
--allowedTools "Read,Write,Bash,Grep,Glob" \
|
||||
"${EVALUATOR_OPTIONS[@]}" \
|
||||
"You are the Evaluator in a GAN-style harness. Read agents/gan-evaluator.md for full instructions.
|
||||
|
||||
Iteration: $i
|
||||
|
||||
@@ -216,7 +216,8 @@ function hasFileWithExtension(rootDir, relativeDir, extensions) {
|
||||
|
||||
function detectTargetMode(rootDir) {
|
||||
const packageJson = safeParseJson(safeRead(rootDir, 'package.json'));
|
||||
if (packageJson?.name === 'everything-claude-code') {
|
||||
// 'everything-claude-code' is the legacy package name, kept for older checkouts.
|
||||
if (['ecc-universal', 'everything-claude-code'].includes(packageJson?.name)) {
|
||||
return 'repo';
|
||||
}
|
||||
|
||||
@@ -232,6 +233,8 @@ function detectTargetMode(rootDir) {
|
||||
return 'consumer';
|
||||
}
|
||||
|
||||
// The everything-claude-code entries below are legacy install ids, kept so
|
||||
// existing 1.x installs are still detected.
|
||||
const ECC_PLUGIN_KEY_PATTERNS = [
|
||||
/^ecc@/i,
|
||||
/^everything-claude-code@/i,
|
||||
@@ -831,7 +834,7 @@ function getConsumerChecks(rootDir) {
|
||||
points: 4,
|
||||
scopes: ['repo'],
|
||||
path: '~/.claude/plugins/ecc/ (legacy everything-claude-code paths also supported)',
|
||||
description: 'Everything Claude Code is installed for the active user or project',
|
||||
description: 'ECC is installed for the active user or project',
|
||||
pass: Boolean(pluginInstall),
|
||||
fix: 'Install the ECC plugin for this user or project before auditing project-specific harness quality.',
|
||||
},
|
||||
|
||||
+575
-364
File diff suppressed because it is too large
Load Diff
@@ -43,9 +43,12 @@ const PROTECTED_FILES = new Set([
|
||||
'prettier.config.js',
|
||||
'prettier.config.cjs',
|
||||
'prettier.config.mjs',
|
||||
// Biome
|
||||
// Biome's discovered filenames. Custom --config-path/extends targets need
|
||||
// reference context; an arbitrary biome.* basename is not sufficient.
|
||||
'biome.json',
|
||||
'biome.jsonc',
|
||||
'.biome.json',
|
||||
'.biome.jsonc',
|
||||
// Ruff (Python)
|
||||
'.ruff.toml',
|
||||
'ruff.toml',
|
||||
@@ -57,11 +60,74 @@ const PROTECTED_FILES = new Set([
|
||||
'.stylelintrc',
|
||||
'.stylelintrc.json',
|
||||
'.stylelintrc.yml',
|
||||
'.stylelintrc.yaml',
|
||||
'.stylelintrc.js',
|
||||
'.stylelintrc.cjs',
|
||||
'.stylelintrc.mjs',
|
||||
// Stylelint's current spelling; only the legacy `.stylelintrc*` forms were
|
||||
// listed, so a project using the documented `stylelint.config.js` had no
|
||||
// protection at all.
|
||||
'stylelint.config.js',
|
||||
'stylelint.config.cjs',
|
||||
'stylelint.config.mjs',
|
||||
'stylelint.config.ts',
|
||||
'stylelint.config.mts',
|
||||
'stylelint.config.cts',
|
||||
'.markdownlint.json',
|
||||
'.markdownlint.jsonc',
|
||||
'.markdownlint.yaml',
|
||||
'.markdownlintrc'
|
||||
'.markdownlint.yml',
|
||||
'.markdownlint.cjs',
|
||||
'.markdownlint.mjs',
|
||||
'.markdownlintrc',
|
||||
// markdownlint-cli2 reads its own config names, not `.markdownlint.*`.
|
||||
'.markdownlint-cli2.jsonc',
|
||||
'.markdownlint-cli2.yaml',
|
||||
'.markdownlint-cli2.cjs',
|
||||
'.markdownlint-cli2.mjs',
|
||||
// Ignore files are the cheapest way to make a check pass without touching
|
||||
// the code OR the config: adding one path to .eslintignore silences the
|
||||
// failing file outright. Blocking the config while leaving its ignore list
|
||||
// open left the hook's whole purpose one line away from being defeated.
|
||||
// First-time creation stays allowed by the same existence check below.
|
||||
'.eslintignore',
|
||||
'.prettierignore',
|
||||
'.stylelintignore',
|
||||
'.markdownlintignore'
|
||||
]);
|
||||
|
||||
/**
|
||||
* Exact basenames only catch a tool's canonical entry point. Real repos split
|
||||
* flat config across files: a shared `eslint.config.base.mjs` holding the
|
||||
* ignore list and rule severities, imported by per-workspace
|
||||
* `eslint.config.mjs` files. That is the common monorepo shape, and matching
|
||||
* basenames alone protected the leaves while leaving the trunk -- the file that
|
||||
* actually carries the rules -- freely editable.
|
||||
*
|
||||
* These patterns cover `<tool>.config.<qualifier>.<ext>` and
|
||||
* `.<tool>rc.<qualifier>.<ext>` for the linters and formatters listed above.
|
||||
* They are case-insensitive for the same reason the Set lookup above is.
|
||||
*
|
||||
* Deliberately NOT matched: build and test tooling -- `vite.config.ts`,
|
||||
* `vitest.config.ts`, `jest.config.js`, `playwright.config.ts`,
|
||||
* `tsconfig.json`. This hook exists to stop a LINTER config being weakened in
|
||||
* place of fixing the code; editing a bundler or test-runner config is
|
||||
* ordinary work, and sweeping those in would make the hook obstructive.
|
||||
*/
|
||||
const PROTECTED_PATTERNS = [
|
||||
// eslint.config.base.mjs, prettier.config.shared.cjs, stylelint.config.local.js ...
|
||||
/^(eslint|prettier|stylelint|commitlint|oxlint)\.config(\.[A-Za-z0-9_-]+)*\.(js|mjs|cjs|ts|mts|cts)$/i,
|
||||
// .eslintrc.base.json, .prettierrc.shared.yml ...
|
||||
/^\.(eslintrc|prettierrc|stylelintrc|markdownlintrc)(\.[A-Za-z0-9_-]+)*\.(js|cjs|mjs|json|jsonc|yml|yaml|toml)$/i,
|
||||
];
|
||||
|
||||
function isProtectedName(basename) {
|
||||
const lower = basename.toLowerCase();
|
||||
return PROTECTED_FILES.has(basename)
|
||||
|| PROTECTED_FILES.has(lower)
|
||||
|| PROTECTED_PATTERNS.some((re) => re.test(basename));
|
||||
}
|
||||
|
||||
function parseInput(inputOrRaw) {
|
||||
if (typeof inputOrRaw === 'string') {
|
||||
try {
|
||||
@@ -101,7 +167,7 @@ function run(inputOrRaw, options = {}) {
|
||||
// silently overwrite the real config while the guard returned exit 0.
|
||||
// On genuinely case-sensitive filesystems this only costs a false positive
|
||||
// on a distinct file that differs from a protected name by case alone.
|
||||
if (PROTECTED_FILES.has(basename) || PROTECTED_FILES.has(basename.toLowerCase())) {
|
||||
if (isProtectedName(basename)) {
|
||||
// Allow first-time creation — there's no existing config to weaken.
|
||||
// The hook's purpose is blocking modifications; writing a brand-new
|
||||
// config file in a project that has none is a legitimate bootstrap
|
||||
|
||||
@@ -53,11 +53,15 @@ const ROUTINE_POWERSHELL_NARROW_RECOVERY_HINT =
|
||||
const ECC_DISABLE_VALUES = new Set(['0', 'false', 'off', 'disabled', 'disable']);
|
||||
const ECC_ENABLE_VALUES = new Set(['1', 'true', 'on', 'enabled', 'enable', 'yes']);
|
||||
|
||||
// SQL-keyword + dd patterns stay as a single regex — they are stable
|
||||
// phrases without shell-flag ordering concerns. Quoted strings are
|
||||
// stripped before this regex runs so a commit message mentioning
|
||||
// "drop table" no longer triggers a false positive.
|
||||
const DESTRUCTIVE_SQL_DD = /\b(drop\s+table|delete\s+from|truncate|dd\s+if=)\b/i;
|
||||
// SQL keywords remain a phrase check. Quoted strings are stripped before
|
||||
// this regex runs so a commit message mentioning "drop table" stays passive.
|
||||
// `dd if=` used to be a fourth arm here. Matching it as text could not work:
|
||||
// the arm ended in `=`, so the shared trailing \b required the NEXT character
|
||||
// to be a word character and `dd if=/dev/zero` slipped through while
|
||||
// `echo dd if=x` — which runs no dd at all — was gated. The boundary decided
|
||||
// the verdict instead of the command position, so dd moved to isDestructiveDd()
|
||||
// alongside the other token-based detectors (#2642).
|
||||
const DESTRUCTIVE_SQL = /\b(drop\s+table|delete\s+from|truncate)\b/i;
|
||||
|
||||
// Operator-supplied additional destructive patterns. Lazily compiled from
|
||||
// `GATEGUARD_BASH_EXTRA_DESTRUCTIVE` (regex source) on first use, then
|
||||
@@ -287,6 +291,9 @@ function tokenizeAllowlistedShellWords(input) {
|
||||
}
|
||||
|
||||
const SHELL_SEGMENT_SEPARATORS = new Set([';', '|', '&', '\n', '\r']);
|
||||
// Keep only the lexical information needed for Bash's reserved word `time`.
|
||||
// Quoted/escaped `time` is an external command, not a shell pipeline prefix.
|
||||
const SHELL_TIME_TOKENS = new WeakMap();
|
||||
|
||||
/**
|
||||
* Quote-aware split of a command line into segments, with quotes removed from
|
||||
@@ -303,30 +310,55 @@ const SHELL_SEGMENT_SEPARATORS = new Set([';', '|', '&', '\n', '\r']);
|
||||
function quoteAwareSegments(input) {
|
||||
const segments = [];
|
||||
let words = [];
|
||||
let timeTokens = new Set();
|
||||
let current = '';
|
||||
let hasWord = false;
|
||||
let literalWord = true;
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
|
||||
const flushWord = () => {
|
||||
if (hasWord) words.push(current);
|
||||
if (hasWord) {
|
||||
if (literalWord && ['time', '-p', '--'].includes(current)) timeTokens.add(words.length);
|
||||
words.push(current);
|
||||
}
|
||||
current = '';
|
||||
hasWord = false;
|
||||
literalWord = true;
|
||||
};
|
||||
const flushSegment = () => {
|
||||
flushWord();
|
||||
if (words.length) segments.push(words);
|
||||
if (words.length) {
|
||||
if (timeTokens.size) SHELL_TIME_TOKENS.set(words, timeTokens);
|
||||
segments.push(words);
|
||||
}
|
||||
words = [];
|
||||
timeTokens = new Set();
|
||||
};
|
||||
|
||||
for (const ch of String(input || '')) {
|
||||
const source = String(input || '');
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (escaped) {
|
||||
current += ch;
|
||||
hasWord = true;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (ch === '\\') {
|
||||
if (ch === '\\' && quote !== "'") {
|
||||
const next = source[i + 1];
|
||||
// Single quotes preserve every backslash; double quotes only escape
|
||||
// shell-special characters. env -S must receive those literal bytes.
|
||||
if (quote === '"' && next && !['$', '`', '"', '\\', '\n'].includes(next)) {
|
||||
current += ch;
|
||||
hasWord = true;
|
||||
continue;
|
||||
}
|
||||
if (next === '\n') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
literalWord = false;
|
||||
escaped = true;
|
||||
hasWord = true;
|
||||
continue;
|
||||
@@ -339,6 +371,7 @@ function quoteAwareSegments(input) {
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
literalWord = false;
|
||||
hasWord = true; // entering a quote starts a word, even if its content is empty
|
||||
continue;
|
||||
}
|
||||
@@ -421,65 +454,307 @@ const SUDO_VALUE_FLAGS = new Set([
|
||||
'--command-timeout',
|
||||
]);
|
||||
|
||||
const DOAS_VALUE_FLAGS = new Set(['-u', '-C']);
|
||||
const EXEC_VALUE_FLAGS = new Set(['-a']);
|
||||
const ENV_VALUE_FLAGS = new Set(['-u', '--unset', '-C', '--chdir', '-a', '--argv0', '-S', '--split-string']);
|
||||
const SHELL_ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
||||
|
||||
/**
|
||||
* Advance past `sudo`/`doas`/`env` wrappers including their flags and
|
||||
* `VAR=value` assignments, so `sudo -u postgres psql ...` and
|
||||
* `env PGUSER=postgres psql ...` still resolve to the real command.
|
||||
* Split one literal env -S argument into argv, never into shell programs.
|
||||
* Operators, substitutions and variable spellings stay literal text; no host
|
||||
* environment is read. A dynamic executable name therefore remains opaque.
|
||||
* Unterminated quotes, unknown escapes and invalid quoted \c return null.
|
||||
* This is bounded literal parsing, not GNU env variable interpolation.
|
||||
*
|
||||
* @param {string} source
|
||||
* @returns {string[] | null}
|
||||
*/
|
||||
function splitEnvWords(source) {
|
||||
const words = [];
|
||||
let word = '';
|
||||
let hasWord = false;
|
||||
let quote = null;
|
||||
const flush = () => {
|
||||
if (hasWord) words.push(word);
|
||||
word = '';
|
||||
hasWord = false;
|
||||
};
|
||||
const escapes = { f: '\f', n: '\n', r: '\r', t: '\t', v: '\v' };
|
||||
for (let i = 0; i < source.length; i += 1) {
|
||||
const ch = source[i];
|
||||
if (ch === '\\' && quote !== "'") {
|
||||
const next = source[++i];
|
||||
if (next === undefined) return null;
|
||||
if (next === 'c') {
|
||||
if (quote) return null;
|
||||
flush();
|
||||
return words;
|
||||
}
|
||||
if (next === '_') {
|
||||
if (quote) {
|
||||
word += ' ';
|
||||
hasWord = true;
|
||||
} else flush();
|
||||
continue;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(escapes, next)) word += escapes[next];
|
||||
else if (['#', '$', '"', "'", '\\'].includes(next)) word += next;
|
||||
else return null;
|
||||
hasWord = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (ch === quote) quote = null;
|
||||
else word += ch;
|
||||
hasWord = true;
|
||||
continue;
|
||||
}
|
||||
if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
hasWord = true;
|
||||
} else if (ch === '#' && !hasWord) {
|
||||
break;
|
||||
} else if (/\s/.test(ch)) {
|
||||
flush();
|
||||
} else {
|
||||
word += ch;
|
||||
hasWord = true;
|
||||
}
|
||||
}
|
||||
if (quote) return null;
|
||||
flush();
|
||||
return words;
|
||||
}
|
||||
|
||||
/** Locate a value-taking flag, including the tail of a short-option cluster. */
|
||||
function wrapperValueOption(arg, valueFlags) {
|
||||
if (arg.startsWith('--')) {
|
||||
const separator = arg.indexOf('=');
|
||||
const name = separator === -1 ? arg : arg.slice(0, separator);
|
||||
return valueFlags.has(name)
|
||||
? { name, value: separator === -1 ? undefined : arg.slice(separator + 1) }
|
||||
: null;
|
||||
}
|
||||
if (!arg.startsWith('-')) return null;
|
||||
for (let i = 1; i < arg.length; i += 1) {
|
||||
const name = `-${arg[i]}`;
|
||||
if (valueFlags.has(name)) {
|
||||
return { name, value: i + 1 < arg.length ? arg.slice(i + 1) : undefined };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Explicit external-launcher argv grammars for dd, SQL clients and shell-wrapper discovery.
|
||||
// Unknown flags do not justify guessing which later argument executes.
|
||||
// This literal allowlist cannot prove arbitrary custom-wrapper semantics or
|
||||
// resolve dynamically selected executables; quoted operand text stays data.
|
||||
const DD_LAUNCHER_OPTIONS = {
|
||||
xargs: {
|
||||
values: new Set(['-a', '--arg-file', '-d', '--delimiter', '-E', '-I', '-J', '-L', '-n', '--max-args', '-P', '--max-procs', '-s', '--max-chars', '--process-slot-var']),
|
||||
optional: new Set(['-e', '--eof', '-i', '--replace', '-l', '--max-lines']),
|
||||
flags: new Set(['-0', '--null', '-r', '--no-run-if-empty', '-t', '--verbose', '-p', '--interactive', '-x', '--exit', '-o', '--open-tty', '--show-limits'])
|
||||
},
|
||||
timeout: {
|
||||
values: new Set(['-k', '--kill-after', '-s', '--signal']),
|
||||
optional: new Set(),
|
||||
flags: new Set(['-v', '--verbose', '--foreground', '--preserve-status'])
|
||||
},
|
||||
nice: { values: new Set(['-n', '--adjustment']), optional: new Set(), flags: new Set() },
|
||||
nohup: { values: new Set(), optional: new Set(), flags: new Set() },
|
||||
time: {
|
||||
values: new Set(['-f', '--format', '-o', '--output-file']),
|
||||
optional: new Set(),
|
||||
flags: new Set(['-p', '--portability', '-a', '--append', '-q', '--quiet', '-v', '--verbose']),
|
||||
nonCommand: new Set(['--help', '-V', '--version'])
|
||||
},
|
||||
stdbuf: {
|
||||
values: new Set(['-i', '--input', '-o', '--output', '-e', '--error']),
|
||||
optional: new Set(), flags: new Set()
|
||||
},
|
||||
ionice: {
|
||||
values: new Set(['-c', '--class', '-n', '--classdata']),
|
||||
optional: new Set(), flags: new Set(['-t', '--ignore']),
|
||||
// These modes query/change existing processes rather than launch argv.
|
||||
nonCommand: new Set(['-p', '--pid', '-P', '--pgid', '-u', '--uid'])
|
||||
},
|
||||
setsid: {
|
||||
values: new Set(), optional: new Set(),
|
||||
flags: new Set(['-c', '--ctty', '-f', '--fork', '-w', '--wait'])
|
||||
}
|
||||
};
|
||||
|
||||
/** Return the command position after one explicitly supported launcher's options. */
|
||||
function ddLauncherCommandIndex(argv, index, name) {
|
||||
const { values, optional, flags, nonCommand } = DD_LAUNCHER_OPTIONS[name];
|
||||
// GNU time uses getopt_long: unique prefixes resolve against its complete
|
||||
// eight-option table, including terminating help/version. Other launchers
|
||||
// retain their explicit spellings; this does not affect shell-keyword time.
|
||||
const timeLongOptions = name === 'time'
|
||||
? [...values, ...flags, ...nonCommand].filter(flag => flag.startsWith('--'))
|
||||
: null;
|
||||
index += 1;
|
||||
while (index < argv.length) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--') {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if (!arg.startsWith('-') || arg === '-') break;
|
||||
// nice retains the historical -N / --N priority spellings.
|
||||
if (name === 'nice' && /^--?\d+$/.test(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('--')) {
|
||||
const separator = arg.indexOf('=');
|
||||
let flag = separator === -1 ? arg : arg.slice(0, separator);
|
||||
if (timeLongOptions && !timeLongOptions.includes(flag)) {
|
||||
const matches = timeLongOptions.filter(option => option.startsWith(flag));
|
||||
if (matches.length !== 1) return argv.length;
|
||||
[flag] = matches;
|
||||
}
|
||||
if (nonCommand && nonCommand.has(flag)) return argv.length;
|
||||
if (values.has(flag)) index += separator === -1 ? 2 : 1;
|
||||
else if (optional.has(flag) || (separator === -1 && flags.has(flag))) index += 1;
|
||||
else return argv.length;
|
||||
continue;
|
||||
}
|
||||
let consumesNext = false;
|
||||
for (let offset = 1; offset < arg.length; offset += 1) {
|
||||
const flag = `-${arg[offset]}`;
|
||||
if (nonCommand && nonCommand.has(flag)) return argv.length;
|
||||
if (values.has(flag)) {
|
||||
consumesNext = offset + 1 === arg.length;
|
||||
break;
|
||||
}
|
||||
if (optional.has(flag)) break;
|
||||
if (!flags.has(flag)) return argv.length;
|
||||
}
|
||||
index += consumesNext ? 2 : 1;
|
||||
}
|
||||
// timeout's duration is data, followed by exactly one executable position.
|
||||
return name === 'timeout' ? index + 1 : index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve leading assignments, shell prefixes and sudo/doas/env into command
|
||||
* argv. Wrapper-specific option values never become executable names. Every
|
||||
* wrapper/split consumes source bytes, so the input-size budget bounds nested
|
||||
* expansion without rejecting a valid long chain at an arbitrary depth.
|
||||
*
|
||||
* @param {string[]} tokens dequoted tokens for one segment
|
||||
* @returns {number} index of the real command token
|
||||
* @param {boolean} [allowShellBuiltins] false for external argv (e.g. find -exec)
|
||||
* @param {boolean} [allowDdLaunchers] opt-in; other shared callers retain their grammar
|
||||
* @returns {string[]} normalized argv, or [] when no literal command resolves
|
||||
*/
|
||||
function unwrapLeadWrappers(tokens) {
|
||||
function unwrapLeadWrappers(tokens, allowShellBuiltins = true, allowDdLaunchers = false) {
|
||||
let argv = tokens.slice();
|
||||
let index = 0;
|
||||
for (let guard = 0; guard < 4; guard += 1) {
|
||||
if (index >= tokens.length) return index;
|
||||
const base = commandBasename(tokens[index]);
|
||||
if (base === 'sudo' || base === 'doas') {
|
||||
let allowAssignments = true;
|
||||
let allowShellTime = allowShellBuiltins;
|
||||
const timeTokens = SHELL_TIME_TOKENS.get(tokens);
|
||||
let budget = tokens.reduce((size, token) => size + token.length + 1, 1);
|
||||
while (index < argv.length && budget-- > 0) {
|
||||
while (allowAssignments && index < argv.length && SHELL_ASSIGNMENT.test(argv[index])) {
|
||||
index += 1;
|
||||
while (index < tokens.length) {
|
||||
const flag = tokens[index];
|
||||
allowShellTime = false;
|
||||
}
|
||||
if (index >= argv.length) return [];
|
||||
const base = commandBasename(argv[index]);
|
||||
if (allowDdLaunchers && allowShellTime && argv[index] === 'time' && timeTokens && timeTokens.has(index)) {
|
||||
// Current Bash accepts only raw -p and -- as reserved-time options.
|
||||
// Quotes/escapes make them executable words, unlike external time argv.
|
||||
// The next command/exec builtin or assignment keeps shell semantics.
|
||||
index += 1;
|
||||
if (argv[index] === '-p' && timeTokens.has(index)) index += 1;
|
||||
if (argv[index] === '--' && timeTokens.has(index)) index += 1;
|
||||
continue;
|
||||
}
|
||||
if (allowShellBuiltins && base === 'command') {
|
||||
allowShellTime = false;
|
||||
index += 1;
|
||||
while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') {
|
||||
const flag = argv[index++];
|
||||
if (flag === '--') break;
|
||||
// -v/-V (including -pv) only describe names; no command executes.
|
||||
if (!/^-[pVv]+$/.test(flag) || /[vV]/.test(flag)) return [];
|
||||
}
|
||||
allowAssignments = false;
|
||||
continue;
|
||||
}
|
||||
if (allowShellBuiltins && base === 'exec') {
|
||||
allowShellTime = false;
|
||||
index += 1;
|
||||
while (index < argv.length && argv[index].startsWith('-') && argv[index] !== '-') {
|
||||
const flag = argv[index];
|
||||
if (flag === '--') {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
const option = wrapperValueOption(flag, EXEC_VALUE_FLAGS);
|
||||
const flagLetters = option ? flag.slice(1, flag.indexOf('a')) : flag.slice(1);
|
||||
if (!/^[cl]*$/.test(flagLetters)) return [];
|
||||
index += option && option.value === undefined ? 2 : 1;
|
||||
}
|
||||
// exec replaces the shell with an external executable; its argument
|
||||
// 'command' is not the shell's builtin, and A=1 is not an assignment.
|
||||
allowShellBuiltins = false;
|
||||
allowAssignments = false;
|
||||
continue;
|
||||
}
|
||||
if (allowDdLaunchers && Object.prototype.hasOwnProperty.call(DD_LAUNCHER_OPTIONS, base)) {
|
||||
index = ddLauncherCommandIndex(argv, index, base);
|
||||
allowShellTime = false;
|
||||
allowShellBuiltins = false;
|
||||
allowAssignments = false;
|
||||
continue;
|
||||
}
|
||||
if (base === 'sudo' || base === 'doas') {
|
||||
allowShellTime = false;
|
||||
allowShellBuiltins = false;
|
||||
allowAssignments = true;
|
||||
const valueFlags = base === 'sudo' ? SUDO_VALUE_FLAGS : DOAS_VALUE_FLAGS;
|
||||
index += 1;
|
||||
while (index < argv.length) {
|
||||
const flag = argv[index];
|
||||
if (flag === '--') {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if (flag === '-' || !flag.startsWith('-')) break;
|
||||
if (SUDO_VALUE_FLAGS.has(flag)) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (/^--[^=]+=.*$/.test(flag)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
const option = wrapperValueOption(flag, valueFlags);
|
||||
index += option && option.value === undefined ? 2 : 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (base === 'env') {
|
||||
allowShellTime = false;
|
||||
allowShellBuiltins = false;
|
||||
allowAssignments = true;
|
||||
index += 1;
|
||||
while (index < tokens.length) {
|
||||
const arg = tokens[index];
|
||||
if (arg === '--' || arg === '-' || arg === '-i' || arg === '--ignore-environment') {
|
||||
while (index < argv.length) {
|
||||
const arg = argv[index];
|
||||
if (arg === '--') {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
const option = wrapperValueOption(arg, ENV_VALUE_FLAGS);
|
||||
if (option && (option.name === '-S' || option.name === '--split-string')) {
|
||||
const separate = option.value === undefined;
|
||||
const source = separate ? argv[index + 1] : option.value;
|
||||
if (source === undefined || budget-- <= 0) return [];
|
||||
const expanded = splitEnvWords(source);
|
||||
if (!expanded) return [];
|
||||
argv = [...expanded, ...argv.slice(index + (separate ? 2 : 1))];
|
||||
index = 0;
|
||||
continue;
|
||||
}
|
||||
if (arg === '-u' || arg === '--unset') {
|
||||
index += 2;
|
||||
if (option) {
|
||||
index += option.value === undefined ? 2 : 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '-C' || arg === '--chdir') {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (/^--unset=.*$/.test(arg) || /^--chdir=.*$/.test(arg) || /^--argv0=.*$/.test(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('-') && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
|
||||
if (arg.startsWith('-') || SHELL_ASSIGNMENT.test(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -487,9 +762,9 @@ function unwrapLeadWrappers(tokens) {
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
return argv.slice(index);
|
||||
}
|
||||
return index;
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -502,10 +777,9 @@ function unwrapLeadWrappers(tokens) {
|
||||
*/
|
||||
function isDestructiveSqlClient(tokens) {
|
||||
if (!tokens || tokens.length === 0) return false;
|
||||
const start = unwrapLeadWrappers(tokens);
|
||||
if (start >= tokens.length) return false;
|
||||
if (!SQL_CLIENT_COMMANDS.has(commandBasename(tokens[start]))) return false;
|
||||
return DESTRUCTIVE_SQL_DD.test(stripSqlLiterals(tokens.slice(start).join(' ')));
|
||||
const argv = unwrapLeadWrappers(tokens, true, true);
|
||||
if (!SQL_CLIENT_COMMANDS.has(commandBasename(argv[0]))) return false;
|
||||
return DESTRUCTIVE_SQL.test(stripSqlLiterals(argv.join(' ')));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -519,18 +793,23 @@ function isDestructiveSqlClient(tokens) {
|
||||
*/
|
||||
function isDestructiveQuoteAware(raw, depth = 0) {
|
||||
if (depth > 4) return false;
|
||||
for (const tokens of quoteAwareSegments(raw)) {
|
||||
if (tokens.length === 0) continue;
|
||||
if (isDestructiveRm(tokens)) return true;
|
||||
if (isDestructiveGit(tokens)) return true;
|
||||
if (isDestructiveSqlClient(tokens)) return true;
|
||||
if (isDestructiveFindExec(tokens.join(' '))) return true;
|
||||
const wi = unwrapLeadWrappers(tokens);
|
||||
const base = wi < tokens.length ? commandBasename(tokens[wi]) : '';
|
||||
if (SHELL_WRAPPERS.has(base)) {
|
||||
const ci = tokens.indexOf('-c', wi);
|
||||
if (ci !== -1 && tokens[ci + 1] && isDestructiveQuoteAware(tokens[ci + 1], depth + 1)) {
|
||||
return true;
|
||||
// The outer command was preprocessed already; shell -c introduces a new
|
||||
// program whose literal heredoc data must also stay outside execution.
|
||||
const executable = depth === 0 ? raw : stripHeredocBodies(raw);
|
||||
for (const body of collectExecutableBodies(executable)) {
|
||||
for (const tokens of quoteAwareSegments(body)) {
|
||||
if (tokens.length === 0) continue;
|
||||
if (isDestructiveRm(tokens)) return true;
|
||||
if (isDestructiveGit(tokens)) return true;
|
||||
if (isDestructiveDd(tokens)) return true;
|
||||
if (isDestructiveSqlClient(tokens)) return true;
|
||||
if (isDestructiveFindExec(tokens)) return true;
|
||||
const argv = unwrapLeadWrappers(tokens, true, true);
|
||||
if (SHELL_WRAPPERS.has(commandBasename(argv[0]))) {
|
||||
const ci = argv.indexOf('-c', 1);
|
||||
if (ci !== -1 && argv[ci + 1] && isDestructiveQuoteAware(argv[ci + 1], depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -552,6 +831,27 @@ function commandBasename(token) {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect a `dd` invocation carrying an `if=` or `of=` operand.
|
||||
* Keep the existing input-file gate and include output-only writes from stdin.
|
||||
*
|
||||
* Token-based rather than a regex arm because the verdict has to depend on
|
||||
* `dd` being the command, not on `dd if=` appearing anywhere in the line:
|
||||
* `echo dd if=/dev/zero` executes nothing. dd operands are order-free, so
|
||||
* `dd of=/dev/sda if=/dev/zero` counts too — a text pattern anchored on
|
||||
* `dd\s+if=` missed that spelling entirely.
|
||||
*
|
||||
* Leading `sudo` / `doas` / `env`, their flags, and `VAR=value` assignment
|
||||
* prefixes are skipped so `sudo dd if=/dev/zero` stays the dd invocation it is.
|
||||
*
|
||||
* @param {string[]} tokens
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isDestructiveDd(tokens, allowShellBuiltins = true) {
|
||||
const argv = unwrapLeadWrappers(tokens, allowShellBuiltins, true);
|
||||
return commandBasename(argv[0]) === 'dd' && argv.slice(1).some(operand => /^(?:if|of)=/i.test(operand));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect `rm` invocations that recursively force-delete files. Handles
|
||||
* combined (`-rf`, `-fr`, `-Rf`) and split (`-r -f`) flag forms.
|
||||
@@ -878,87 +1178,79 @@ function collectExecutableBodies(raw) {
|
||||
return bodies;
|
||||
}
|
||||
|
||||
// Find predicates consume their arguments even when a value spells '-exec'.
|
||||
const FIND_VALUE_PREDICATES = new Set([
|
||||
'-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex',
|
||||
'-type', '-xtype', '-maxdepth', '-mindepth', '-mtime', '-mmin', '-atime', '-amin',
|
||||
'-ctime', '-cmin', '-newer', '-anewer', '-cnewer', '-used', '-uid', '-gid', '-user',
|
||||
'-group', '-perm', '-size', '-inum', '-links', '-fstype', '-context', '-lname', '-ilname',
|
||||
'-printf', '-fprint', '-fprint0', '-fls', '-samefile', '-files0-from', '-regextype'
|
||||
]);
|
||||
const FIND_EXEC_ACTIONS = new Set(['-exec', '-execdir', '-ok', '-okdir']);
|
||||
|
||||
/**
|
||||
* Detect destructive commands inside `find ... -exec` invocations.
|
||||
* Handles `-exec rm {} \;`, `-exec rm -rf {} \;`, `-exec rmdir {} \;`,
|
||||
* `-exec unlink {} \;`, `-exec git reset --hard {} \;`.
|
||||
* Inspect each find executable action without mistaking its argv for another
|
||||
* action. -ok/-okdir still run the command after their own confirmation, so
|
||||
* they retain the explicit destructive gate. A + terminates exec/execdir only
|
||||
* after {}; elsewhere it remains an ordinary argument.
|
||||
*
|
||||
* @param {string} command
|
||||
* @param {string | string[]} command raw segment or already dequoted argv
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isDestructiveFindExec(command) {
|
||||
const raw = String(command || '');
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const quoteAware = Array.isArray(command);
|
||||
const tokens = quoteAware ? command : tokenize(String(command || '').trim());
|
||||
if (commandBasename(tokens[0]) !== 'find') return false;
|
||||
|
||||
// Tokenize the whole command line
|
||||
const tokens = tokenize(trimmed);
|
||||
if (!tokens || tokens.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Must start with `find`
|
||||
if (commandBasename(tokens[0]) !== 'find') {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the `-exec` token
|
||||
const execIndex = tokens.indexOf('-exec');
|
||||
if (execIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Collect tokens after `-exec` until we hit a terminator (`;`, `\;`, or `+`)
|
||||
const execTokens = [];
|
||||
for (let i = execIndex + 1; i < tokens.length; i++) {
|
||||
const token = tokens[i];
|
||||
if (token === ';' || token === '\\;' || token === '+') {
|
||||
break;
|
||||
for (let index = 1; index < tokens.length; index += 1) {
|
||||
const action = tokens[index];
|
||||
if (action === '-fprintf') {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
execTokens.push(token);
|
||||
}
|
||||
|
||||
if (execTokens.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const baseCmd = commandBasename(execTokens[0]);
|
||||
|
||||
// Directly destructive commands inside -exec
|
||||
if (baseCmd === 'rmdir' || baseCmd === 'unlink') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// `rm` with any flags (including none) inside -exec is destructive
|
||||
if (baseCmd === 'rm') {
|
||||
return true;
|
||||
}
|
||||
|
||||
// `git reset --hard` inside -exec
|
||||
if (baseCmd === 'git') {
|
||||
const sub = findGitSubcommand(execTokens);
|
||||
if (sub && sub.command === 'reset' && sub.rest.includes('--hard')) {
|
||||
return true;
|
||||
if (FIND_VALUE_PREDICATES.has(action) || /^-newer[a-zA-Z]{2}$/.test(action)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (!FIND_EXEC_ACTIONS.has(action)) continue;
|
||||
const execTokens = [];
|
||||
for (index += 1; index < tokens.length; index += 1) {
|
||||
const token = tokens[index];
|
||||
if (token === ';' || token === '\\;' || (
|
||||
token === '+' && (action === '-exec' || action === '-execdir') &&
|
||||
execTokens[execTokens.length - 1] === '{}'
|
||||
)) break;
|
||||
execTokens.push(token);
|
||||
}
|
||||
if (execTokens.length === 0) continue;
|
||||
// The legacy raw fallback can split quoted prose into apparent actions.
|
||||
// Preserve its old rm/Git coverage, but classify dd only from real argv.
|
||||
if (quoteAware && isDestructiveDd(execTokens, false)) return true;
|
||||
const baseCmd = commandBasename(execTokens[0]);
|
||||
// Preserve main's existing rm/rmdir/unlink and git-reset handling.
|
||||
if (baseCmd === 'rm' || baseCmd === 'rmdir' || baseCmd === 'unlink') return true;
|
||||
if (baseCmd === 'git') {
|
||||
const sub = findGitSubcommand(execTokens);
|
||||
if (sub && sub.command === 'reset' && sub.rest.includes('--hard')) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isDestructiveBash(command) {
|
||||
// The SQL/dd phrases live in command bodies, not as flag-bearing
|
||||
// arguments, so we still match them by regex — but on the input
|
||||
// SQL phrases live in command bodies, not as flag-bearing
|
||||
// arguments, so we still match them by regex - but on the input
|
||||
// after quoting AND subshell delimiters are normalized so phrases
|
||||
// inside `$(...)` or backticks are also caught.
|
||||
const raw = String(command || '');
|
||||
// Keep main's heredoc stripping: a phrase inside a heredoc body is data, not a
|
||||
// command. dd is no longer part of this regex — see DESTRUCTIVE_SQL.
|
||||
const executable = stripHeredocBodies(raw);
|
||||
const flattened = explodeSubshells(stripQuotedStrings(executable));
|
||||
if (DESTRUCTIVE_SQL_DD.test(flattened)) return true;
|
||||
if (DESTRUCTIVE_SQL.test(flattened)) return true;
|
||||
|
||||
// Operator-supplied additional destructive patterns. Same scope as the
|
||||
// built-in SQL/dd regex: matched against the quote-stripped, subshell-
|
||||
// built-in SQL regex: matched against the quote-stripped, subshell-
|
||||
// exploded command so a phrase inside `$(...)` or backticks is caught.
|
||||
const extra = getExtraDestructiveRegex();
|
||||
if (extra && extra.test(flattened)) return true;
|
||||
@@ -982,7 +1274,7 @@ function isDestructiveBash(command) {
|
||||
const segments = bodies.flatMap(splitCommandSegments);
|
||||
for (const segment of segments) {
|
||||
const stripped = stripQuotedStrings(segment);
|
||||
if (DESTRUCTIVE_SQL_DD.test(stripped)) return true;
|
||||
if (DESTRUCTIVE_SQL.test(stripped)) return true;
|
||||
if (extra && extra.test(stripped)) return true;
|
||||
const tokens = tokenize(segment);
|
||||
if (isDestructiveRm(tokens)) return true;
|
||||
|
||||
@@ -0,0 +1,406 @@
|
||||
'use strict';
|
||||
|
||||
// Finite literal shell scanner for block-no-verify. This is not an interpreter:
|
||||
// aliases, generated programs, expansion results and foreign languages remain
|
||||
// opaque. Every scan/queued region spends one shared input-proportional budget.
|
||||
function createBudget(length) {
|
||||
let remaining = 24 * (length + 1) + 4096;
|
||||
return { spend(amount = 1) {
|
||||
remaining -= amount;
|
||||
if (remaining < 0) throw new RangeError('Shell scan work budget exceeded');
|
||||
} };
|
||||
}
|
||||
|
||||
function continuationEnd(input, index) {
|
||||
if (input[index] !== '\\') return index;
|
||||
if (input[index + 1] === '\n') return index + 2;
|
||||
if (input[index + 1] === '\r' && input[index + 2] === '\n') return index + 3;
|
||||
return index;
|
||||
}
|
||||
|
||||
// Delimiters undergo quote removal, not command expansion. Keeping this small
|
||||
// reader shared with substitution matching prevents body punctuation becoming
|
||||
// shell syntax while locating the enclosing execution region.
|
||||
function heredocDelimiter(input, start, budget) {
|
||||
if (!input.startsWith('<<', start) || input[start + 2] === '<') return null;
|
||||
const operator = input[start + 2] === '-' ? '<<-' : '<<';
|
||||
let i = start + operator.length;
|
||||
while (i < input.length) {
|
||||
budget.spend();
|
||||
if (input[i] === ' ' || input[i] === '\t' || input[i] === '\r') { i++; continue; }
|
||||
const continued = continuationEnd(input, i);
|
||||
if (continued === i) break;
|
||||
i = continued;
|
||||
}
|
||||
let value = '';
|
||||
let quote = null;
|
||||
let began = false;
|
||||
let quoted = false;
|
||||
for (; i < input.length; i++) {
|
||||
budget.spend();
|
||||
const c = input[i];
|
||||
if (quote === "'") {
|
||||
if (c === "'") quote = null;
|
||||
else value += c;
|
||||
continue;
|
||||
}
|
||||
if (c === '\\') {
|
||||
const continued = continuationEnd(input, i);
|
||||
if (continued !== i) { i = continued - 1; continue; }
|
||||
began = true; quoted = true;
|
||||
const next = input[i + 1];
|
||||
if (next === undefined) { value += c; continue; }
|
||||
if (quote === '"' && !'"$`\\'.includes(next)) value += '\\';
|
||||
value += next; i++; continue;
|
||||
}
|
||||
if (quote === '"') {
|
||||
if (c === '"') quote = null;
|
||||
else value += c;
|
||||
continue;
|
||||
}
|
||||
if (c === "'" || c === '"') { quote = c; began = true; quoted = true; continue; }
|
||||
if (/[\s;|&()<>]/.test(c) || (!began && c === '#')) break;
|
||||
began = true; value += c;
|
||||
}
|
||||
return began ? { operator, word: { value, quoted }, end: i } : null;
|
||||
}
|
||||
|
||||
function heredocLine(input, start, joinContinuations, budget) {
|
||||
const parts = [];
|
||||
let i = start;
|
||||
let fragment = start;
|
||||
while (i < input.length && input[i] !== '\n') {
|
||||
budget.spend();
|
||||
if (joinContinuations && input[i] === '\\') {
|
||||
const continued = continuationEnd(input, i);
|
||||
if (continued !== i) {
|
||||
budget.spend(i - fragment + 1);
|
||||
parts.push(input.slice(fragment, i));
|
||||
i = continued; fragment = i; continue;
|
||||
}
|
||||
// An escaped backslash cannot itself quote the following newline. Keep
|
||||
// the pair unchanged; only the unpaired final slash joins physical lines.
|
||||
if (i + 1 < input.length) { i += 2; continue; }
|
||||
}
|
||||
i++;
|
||||
}
|
||||
budget.spend(3 * (i - start + 1));
|
||||
parts.push(input.slice(fragment, i));
|
||||
const newline = i < input.length;
|
||||
return { text: parts.join(''), newline, end: newline ? i + 1 : i };
|
||||
}
|
||||
|
||||
function heredocBody(input, start, redirect, budget) {
|
||||
const lines = [];
|
||||
let length = 0;
|
||||
let i = start;
|
||||
while (i < input.length) {
|
||||
// Bash removes unquoted backslash-newline before testing the ending
|
||||
// delimiter. Quoted delimiters retain physical lines. The original end
|
||||
// offset is kept separately so the next actual command is never swallowed.
|
||||
const line = heredocLine(input, i, !redirect.word.quoted, budget);
|
||||
budget.spend(2 * (line.text.length + 1));
|
||||
const text = redirect.operator === '<<-' ? line.text.replace(/^\t+/, '') : line.text;
|
||||
i = line.end;
|
||||
if (text.replace(/\r$/, '') === redirect.word.value) break;
|
||||
lines.push(text);
|
||||
length += text.length;
|
||||
if (line.newline) { lines.push('\n'); length++; }
|
||||
}
|
||||
budget.spend(length + 1);
|
||||
return { body: lines.join(''), end: i };
|
||||
}
|
||||
|
||||
function legacyRegion(input, start, budget) {
|
||||
const decoded = [];
|
||||
let i = start + 1;
|
||||
for (; i < input.length; i++) {
|
||||
budget.spend();
|
||||
const c = input[i];
|
||||
if (c === '`') break;
|
||||
if (c === '\\' && '$`\\\n'.includes(input[i + 1] || '\u0000')) {
|
||||
// One old-style substitution layer only. Escapes outside an executable
|
||||
// backtick region are still handled by the outer lexer as literal data.
|
||||
const next = input[++i];
|
||||
if (next !== '\n') decoded.push(next);
|
||||
} else decoded.push(c);
|
||||
}
|
||||
budget.spend(decoded.length + 1);
|
||||
return { text: decoded.join(''), end: i < input.length ? i + 1 : i };
|
||||
}
|
||||
|
||||
// Locate a nested execution region without evaluating any supplied text.
|
||||
// Balanced substitutions have independent quote state; malformed regions extend
|
||||
// to EOF and remain conservatively inspectable instead of silently disappearing.
|
||||
function executionRegion(input, start, budget) {
|
||||
if (input[start] === '`') return legacyRegion(input, start, budget);
|
||||
const from = start + 2;
|
||||
const frame = () => ({ quote: null, depth: 1, cases: [], word: '', quotedWord: false, commandStart: true, heredocs: [] });
|
||||
const stack = [frame()];
|
||||
function endWord(state) {
|
||||
if (!state.word) return;
|
||||
const phase = state.cases[state.cases.length - 1];
|
||||
if (!state.quotedWord && state.word === 'esac' && (state.commandStart || phase === 'pattern')) state.cases.pop();
|
||||
else if (!state.quotedWord && state.word === 'case' && state.commandStart) state.cases.push('subject');
|
||||
else if (phase === 'subject') state.cases[state.cases.length - 1] = 'in';
|
||||
else if (!state.quotedWord && state.word === 'in' && phase === 'in') state.cases[state.cases.length - 1] = 'pattern';
|
||||
state.commandStart = false;
|
||||
state.word = ''; state.quotedWord = false;
|
||||
}
|
||||
for (let i = from; i < input.length; i++) {
|
||||
budget.spend();
|
||||
const state = stack[stack.length - 1];
|
||||
const c = input[i];
|
||||
if (state.quote === "'" || state.quote === "$'") {
|
||||
if (state.quote === "$'" && c === '\\' && i + 1 < input.length) { i++; continue; }
|
||||
if (c === "'") state.quote = null;
|
||||
continue;
|
||||
}
|
||||
if (c === '\\') { state.word += input[i + 1] || ''; state.quotedWord = true; i++; continue; }
|
||||
if (c === '$' && input[i + 1] === '(') {
|
||||
state.word += '$()'; state.quotedWord = true; stack.push(frame()); i++; continue;
|
||||
}
|
||||
if (c === '`') {
|
||||
const region = legacyRegion(input, i, budget);
|
||||
state.word += '`'; state.quotedWord = true; i = region.end - 1; continue;
|
||||
}
|
||||
if (state.quote === '"') {
|
||||
if (c === '"') state.quote = null;
|
||||
continue;
|
||||
}
|
||||
if (c === '$' && input[i + 1] === "'") {
|
||||
state.quote = "$'"; state.word += "$'"; state.quotedWord = true; i++; continue;
|
||||
}
|
||||
if (c === '"' || c === "'") { state.quote = c; state.word += c; state.quotedWord = true; continue; }
|
||||
if (c === '#' && /[\s;|&()]/.test(input[i - 1] || ' ')) {
|
||||
while (i < input.length && input[i] !== '\n') { budget.spend(); i++; }
|
||||
i--; // Process the newline, including any pending heredoc bodies.
|
||||
continue;
|
||||
}
|
||||
if (c === '<' && input.startsWith('<<<', i)) { endWord(state); i += 2; continue; }
|
||||
if (c === '<' && input[i + 1] === '<' && input[i + 2] !== '<') {
|
||||
endWord(state);
|
||||
const delimiter = heredocDelimiter(input, i, budget);
|
||||
if (delimiter) { state.heredocs.push(delimiter); i = delimiter.end - 1; continue; }
|
||||
}
|
||||
if (c === '\n' && state.heredocs.length) {
|
||||
endWord(state);
|
||||
let next = i + 1;
|
||||
for (const redirect of state.heredocs) next = heredocBody(input, next, redirect, budget).end;
|
||||
state.heredocs.length = 0;
|
||||
state.commandStart = true; i = next - 1; continue;
|
||||
}
|
||||
if (/[\s;|&()]/.test(c)) endWord(state);
|
||||
else state.word += c;
|
||||
const phase = state.cases[state.cases.length - 1];
|
||||
// A case pattern's closing ')' is not the end of $(...). Only literal
|
||||
// keyword positions affect this state; quoted or echo operands do not.
|
||||
if (c === ')' && phase === 'pattern') {
|
||||
state.cases[state.cases.length - 1] = 'body'; state.commandStart = true; continue;
|
||||
}
|
||||
if (c === '(' && phase === 'pattern') continue;
|
||||
if (c === ';' && input[i + 1] === ';' && phase === 'body') {
|
||||
state.cases[state.cases.length - 1] = 'pattern'; state.commandStart = true; i++; continue;
|
||||
}
|
||||
if (/[;|&\n]/.test(c)) state.commandStart = true;
|
||||
if (c === '(') state.depth++;
|
||||
if (c === ')') {
|
||||
state.depth--;
|
||||
if (state.depth === 0) {
|
||||
stack.pop();
|
||||
if (stack.length === 0) {
|
||||
budget.spend(i - from + 1);
|
||||
return { text: input.slice(from, i), end: i + 1 };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
budget.spend(input.length - from + 1);
|
||||
return { text: input.slice(from), end: input.length };
|
||||
}
|
||||
|
||||
function hasExpansion(input, index, processSubstitution = false) {
|
||||
return input[index] === '`' || (input[index] === '$' && input[index + 1] === '(') ||
|
||||
(processSubstitution && '<>'.includes(input[index]) && input[index + 1] === '(');
|
||||
}
|
||||
|
||||
// Unquoted heredoc bodies expand even inside quote characters in the body.
|
||||
// Backslash still protects $, ` and backslash; quoted delimiters skip this pass.
|
||||
function scanExpansions(input, budget) {
|
||||
const nested = [];
|
||||
for (let i = 0; i < input.length;) {
|
||||
budget.spend();
|
||||
if (input[i] === '\\' && /[$`\\\r\n]/.test(input[i + 1] || '')) {
|
||||
const continued = continuationEnd(input, i);
|
||||
i = continued !== i ? continued : i + 2;
|
||||
continue;
|
||||
}
|
||||
if (hasExpansion(input, i)) {
|
||||
const region = executionRegion(input, i, budget);
|
||||
nested.push(region.text); i = region.end;
|
||||
} else i++;
|
||||
}
|
||||
return nested;
|
||||
}
|
||||
|
||||
function scanShell(input, budget) {
|
||||
const commands = [];
|
||||
const nested = [];
|
||||
const pendingHeredocs = [];
|
||||
const rootScope = { parent: null, isolated: false, conditional: false };
|
||||
let scope = rootScope;
|
||||
const command = pipeFrom => ({ words: [], redirects: [], pipeFrom, nested: [], scope });
|
||||
let current = command(null);
|
||||
let word = null;
|
||||
let quote = null;
|
||||
let pendingRedirect = null;
|
||||
let i = 0;
|
||||
function begin() {
|
||||
if (!word) word = { value: '', start: i, end: i, quoted: false, literal: true, dynamic: false };
|
||||
}
|
||||
function flushWord() {
|
||||
if (!word) return;
|
||||
word.end = i;
|
||||
word.raw = input.slice(word.start, i);
|
||||
if (pendingRedirect) {
|
||||
const redirect = { operator: pendingRedirect, word, body: '' };
|
||||
current.redirects.push(redirect);
|
||||
if (pendingRedirect === '<<' || pendingRedirect === '<<-') pendingHeredocs.push({ redirect, owner: current });
|
||||
pendingRedirect = null;
|
||||
} else current.words.push(word);
|
||||
word = null;
|
||||
}
|
||||
function flushCommand(pipe = false, background = false) {
|
||||
flushWord();
|
||||
const previous = current;
|
||||
previous.pipeTo = pipe;
|
||||
previous.background = background;
|
||||
if (previous.closedScope && (pipe || background)) {
|
||||
previous.closedScope.isolated = true;
|
||||
previous.closedScope.pipelineLast = false;
|
||||
}
|
||||
const first = previous.words[0];
|
||||
if (first && !first.quoted && ['if', 'then', 'elif', 'else', 'while', 'until', 'do', 'case', 'for', 'select', 'function'].includes(first.value)) scope.conditional = true;
|
||||
if (previous.words.length || previous.redirects.length) commands.push(previous);
|
||||
current = command(pipe ? previous : null);
|
||||
pendingRedirect = null;
|
||||
}
|
||||
function consumeHeredocs() {
|
||||
for (const { redirect, owner } of pendingHeredocs) {
|
||||
const region = heredocBody(input, i, redirect, budget);
|
||||
redirect.body = region.body;
|
||||
i = region.end;
|
||||
if (!redirect.word.quoted) {
|
||||
const regions = scanExpansions(redirect.body, budget);
|
||||
for (const text of regions) { budget.spend(); nested.push(text); owner.nested.push(text); }
|
||||
}
|
||||
}
|
||||
pendingHeredocs.length = 0;
|
||||
}
|
||||
while (i < input.length) {
|
||||
budget.spend();
|
||||
const c = input[i];
|
||||
if (quote === "'" || quote === "$'") {
|
||||
if (quote === "$'" && c === '\\' && i + 1 < input.length) {
|
||||
const next = input[i + 1];
|
||||
// Preserve boundaries without claiming general ANSI-C escape expansion.
|
||||
word.value += next === "'" || next === '\\' ? next : c + next;
|
||||
i += 2; continue;
|
||||
}
|
||||
if (c === "'") quote = null;
|
||||
else word.value += c;
|
||||
i++; continue;
|
||||
}
|
||||
const continued = continuationEnd(input, i);
|
||||
if (continued !== i) { i = continued; continue; }
|
||||
if (c === '\\') {
|
||||
begin(); word.quoted = true;
|
||||
const next = input[i + 1];
|
||||
if (next === undefined) { word.value += c; i++; continue; }
|
||||
if (quote === '"' && !'"$`\\'.includes(next)) word.value += '\\';
|
||||
word.value += next; i += 2; continue;
|
||||
}
|
||||
if (hasExpansion(input, i, quote === null)) {
|
||||
begin(); word.literal = false; word.dynamic = true;
|
||||
const region = executionRegion(input, i, budget);
|
||||
nested.push(region.text); current.nested.push(region.text); word.value += '\u0000'; i = region.end; continue;
|
||||
}
|
||||
// Parameter expansions remain opaque values. Escaped/single/ANSI-C
|
||||
// quoted dollars have already been consumed as data above.
|
||||
if (c === '$' && /[A-Za-z0-9_@*#?$!{-]/.test(input[i + 1] || '')) {
|
||||
begin(); word.dynamic = true;
|
||||
}
|
||||
if (quote === '"') {
|
||||
if (c === '"') quote = null;
|
||||
else word.value += c;
|
||||
i++; continue;
|
||||
}
|
||||
if (c === '$' && input[i + 1] === "'") {
|
||||
// ANSI-C escaped quotes do not close the word; its contents never expand.
|
||||
// Numeric/control escapes and generated names remain outside this grammar.
|
||||
begin(); word.quoted = true; quote = "$'"; i += 2; continue;
|
||||
}
|
||||
if (c === '"' || c === "'") { begin(); word.quoted = true; quote = c; i++; continue; }
|
||||
if (c === '#' && !word) {
|
||||
while (i < input.length && input[i] !== '\n') { budget.spend(); i++; }
|
||||
continue;
|
||||
}
|
||||
const braceKeyword = (c === '{' || c === '}') && !word && current.words.length === 0 && /[\s;&|]/.test(input[i + 1] || ' ');
|
||||
if (c === '\n' || c === ';' || c === '&' || c === '|' || c === '(' || c === ')' || braceKeyword) {
|
||||
const pipe = c === '|' && input[i + 1] !== '|';
|
||||
const background = c === '&' && input[i + 1] !== '&';
|
||||
if ((c === '&' || c === '|') && input[i + 1] === c) scope.conditional = true;
|
||||
const incomingPipe = Boolean(current.pipeFrom);
|
||||
if (c === '(') {
|
||||
flushWord();
|
||||
// A function definition does not execute its body. Function grammar
|
||||
// is unsupported: keep pre-definition alternatives instead of using
|
||||
// flattened body mutations to certify a later command as safe.
|
||||
if (current.words.length === 1 && !current.words[0].quoted &&
|
||||
/^[A-Za-z_][A-Za-z0-9_]*$/.test(current.words[0].value)) scope.conditional = true;
|
||||
}
|
||||
flushCommand(pipe, background);
|
||||
if (c === '(' || (braceKeyword && c === '{')) {
|
||||
scope = { parent: scope, isolated: c === '(' || incomingPipe, conditional: false, pipelineLast: c === '{' && incomingPipe };
|
||||
current.scope = scope;
|
||||
} else if ((c === ')' || (braceKeyword && c === '}')) && scope.parent) {
|
||||
const closedScope = scope;
|
||||
scope = scope.parent;
|
||||
current.scope = scope;
|
||||
current.closedScope = closedScope;
|
||||
// Ordered metadata only; there is no executable argv in this event.
|
||||
commands.push({ ...command(null), scopeExit: closedScope });
|
||||
}
|
||||
i += (c === '&' || c === '|') && input[i + 1] === c ? 2 : 1;
|
||||
if (c === '\n') consumeHeredocs();
|
||||
continue;
|
||||
}
|
||||
if (c === '<' && input[i + 1] === '<' && input[i + 2] !== '<') {
|
||||
const delimiter = heredocDelimiter(input, i, budget);
|
||||
if (delimiter) {
|
||||
if (word && /^\d+$/.test(word.value)) word = null;
|
||||
flushWord();
|
||||
const redirect = { operator: delimiter.operator, word: delimiter.word, body: '' };
|
||||
current.redirects.push(redirect); pendingHeredocs.push({ redirect, owner: current });
|
||||
i = delimiter.end; pendingRedirect = null; continue;
|
||||
}
|
||||
}
|
||||
if (c === '<' || c === '>') {
|
||||
// An immediately adjacent numeric word is a descriptor, not an argv word.
|
||||
if (word && /^\d+$/.test(word.value)) word = null;
|
||||
flushWord();
|
||||
let operator = c;
|
||||
if (input[i + 1] === c) operator += c;
|
||||
if (operator === '<<' && input[i + 2] === '<') operator = '<<<';
|
||||
else if (operator === '<<' && input[i + 2] === '-') operator = '<<-';
|
||||
else if (input[i + 1] === '&') operator += '&';
|
||||
pendingRedirect = operator; i += operator.length; continue;
|
||||
}
|
||||
if (/\s/.test(c)) { flushWord(); i++; continue; }
|
||||
begin(); word.value += c; i++;
|
||||
}
|
||||
flushCommand();
|
||||
return { commands, nested, rootScope };
|
||||
}
|
||||
|
||||
module.exports = { createBudget, scanShell, scanExpansions };
|
||||
@@ -722,7 +722,7 @@ async function main() {
|
||||
// compaction resume the model would re-run /fw-task-new (or any
|
||||
// ARGUMENTS-bearing slash skill) with the last ARGUMENTS it saw,
|
||||
// duplicating issues/branches/Notion tasks. Tracking upstream at
|
||||
// https://github.com/affaan-m/everything-claude-code/issues/1534
|
||||
// https://github.com/affaan-m/ECC/issues/1534
|
||||
const guarded = [
|
||||
'HISTORICAL REFERENCE ONLY — NOT LIVE INSTRUCTIONS.',
|
||||
'The block below is a frozen summary of a PRIOR conversation that',
|
||||
|
||||
@@ -97,6 +97,7 @@ function parsePluginList(stdout) {
|
||||
plugin.id === CURRENT_PLUGIN_ID
|
||||
|| String(plugin.id || '').startsWith('ecc@')
|
||||
|| LEGACY_PLUGIN_IDS.has(plugin.id)
|
||||
// Legacy install id prefix, kept for existing installs.
|
||||
|| String(plugin.id || '').startsWith('everything-claude-code@')
|
||||
);
|
||||
if (!isRelevant) continue;
|
||||
@@ -400,6 +401,7 @@ function currentEccPlugins(plugins) {
|
||||
function assertNoConflictingEccPlugins(plugins) {
|
||||
const legacy = plugins.find(plugin => (
|
||||
LEGACY_PLUGIN_IDS.has(plugin?.id)
|
||||
// Legacy install id prefix, kept for existing installs.
|
||||
|| String(plugin?.id || '').startsWith('everything-claude-code@')
|
||||
));
|
||||
if (legacy) {
|
||||
|
||||
@@ -49,7 +49,9 @@ function renderControlPlaneViewHtml() {
|
||||
.empty { color: #6e7681; font-size: 12px; }
|
||||
#legend { position: absolute; left: 12px; bottom: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; }
|
||||
#meta { position: absolute; right: 12px; top: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; text-align: right; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
|
||||
.shape { display: inline-block; width: 12px; margin-right: 5px; text-align: center; font-weight: 700; }
|
||||
/* Off-screen, not display:none, so assistive tech still reads the node. */
|
||||
.sr { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0; clip: rect(0 0 0 0); clip-path: inset(50%); overflow: hidden; white-space: nowrap; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -60,13 +62,14 @@ function renderControlPlaneViewHtml() {
|
||||
</header>
|
||||
<div id="wrap">
|
||||
<div id="stage">
|
||||
<canvas id="c"></canvas>
|
||||
<canvas id="c" role="img" aria-label="Control-plane projection. See the Lanes panel for a text alternative.">Control-plane projection; see the Lanes panel for per-task risk.</canvas>
|
||||
<div id="meta"></div>
|
||||
<div id="legend">
|
||||
<div><span class="dot" style="background:#3fb950"></span>clear</div>
|
||||
<div><span class="dot" style="background:#e3b341"></span>traffic advisory (transmit)</div>
|
||||
<div><span class="dot" style="background:#ff7b72"></span>resolution advisory (steer)</div>
|
||||
<div><span class="shape" style="color:#2ea043">●</span>clear</div>
|
||||
<div><span class="shape" style="color:#e3b341">■</span>traffic advisory (transmit)</div>
|
||||
<div><span class="shape" style="color:#ff7b72">▲</span>resolution advisory (steer)</div>
|
||||
</div>
|
||||
<div id="announce" class="sr" role="status" aria-live="polite" aria-atomic="true"></div>
|
||||
</div>
|
||||
<div id="side">
|
||||
<h2>Events</h2>
|
||||
@@ -80,6 +83,9 @@ function renderControlPlaneViewHtml() {
|
||||
var canvas = document.getElementById('c');
|
||||
var ctx = canvas.getContext('2d');
|
||||
var view = { tasks: [], lanes: [], pairs: [], events: [], projection: { agents: [] }, thresholds: { ta: 0.35, ra: 0.7 } };
|
||||
// Last message handed to the live region, so a poll that changes nothing
|
||||
// stays silent.
|
||||
var lastSpoken = null;
|
||||
|
||||
function resize() {
|
||||
var r = canvas.parentElement.getBoundingClientRect();
|
||||
@@ -91,10 +97,35 @@ function renderControlPlaneViewHtml() {
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
// The previous clear/resolution palette had similar relative luminance.
|
||||
// Separate luminance values plus redundant shapes and text reduce reliance
|
||||
// on hue; palette math alone does not establish a user's visual experience.
|
||||
function riskLevel(risk) {
|
||||
if (risk >= view.thresholds.ra) return 'resolution';
|
||||
if (risk >= view.thresholds.ta) return 'traffic';
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
function riskColor(risk) {
|
||||
if (risk >= view.thresholds.ra) return '#ff7b72';
|
||||
if (risk >= view.thresholds.ta) return '#e3b341';
|
||||
return '#3fb950';
|
||||
return '#2ea043';
|
||||
}
|
||||
|
||||
// Shape is the second, non-colour channel: circle / square / triangle.
|
||||
function drawRiskMarker(x, y, radius, level) {
|
||||
ctx.beginPath();
|
||||
if (level === 'resolution') {
|
||||
ctx.moveTo(x, y - radius);
|
||||
ctx.lineTo(x + radius, y + radius);
|
||||
ctx.lineTo(x - radius, y + radius);
|
||||
ctx.closePath();
|
||||
} else if (level === 'traffic') {
|
||||
ctx.rect(x - radius, y - radius, radius * 2, radius * 2);
|
||||
} else {
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
}
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Fit the projected points into the canvas with a margin. The PCA scores
|
||||
@@ -142,8 +173,9 @@ function renderControlPlaneViewHtml() {
|
||||
var t = taskById[a.agentId] || {};
|
||||
var files = (t.workingSet && t.workingSet.fileCount) || 1;
|
||||
var radius = 6 + Math.sqrt(files) * 3;
|
||||
ctx.fillStyle = riskColor(a.maxRisk || 0);
|
||||
ctx.beginPath(); ctx.arc(p[0], p[1], radius, 0, Math.PI * 2); ctx.fill();
|
||||
var risk = a.maxRisk || 0;
|
||||
ctx.fillStyle = riskColor(risk);
|
||||
drawRiskMarker(p[0], p[1], radius, riskLevel(risk));
|
||||
ctx.fillStyle = '#c9d1d9';
|
||||
ctx.font = '11px -apple-system, system-ui, sans-serif';
|
||||
ctx.fillText(String(a.agentId).slice(0, 18), p[0] + radius + 4, p[1] + 3);
|
||||
@@ -198,7 +230,9 @@ function renderControlPlaneViewHtml() {
|
||||
var st = document.createElement('span'); st.textContent = t.harness + ' / ' + t.state + ' / ' + (t.workingSet.fileCount || 0) + ' files';
|
||||
var risk = document.createElement('span'); risk.className = 'risk';
|
||||
risk.style.color = riskColor(t.projection.maxRisk || 0);
|
||||
risk.textContent = t.projection.point ? Math.round((t.projection.maxRisk || 0) * 100) + '%' : 'no pair';
|
||||
risk.textContent = (t.projection.point
|
||||
? Math.round((t.projection.maxRisk || 0) * 100) + '% - ' + riskLevel(t.projection.maxRisk || 0)
|
||||
: 'no pair');
|
||||
row.appendChild(idEl); row.appendChild(st); row.appendChild(risk);
|
||||
el.appendChild(row);
|
||||
});
|
||||
@@ -206,6 +240,40 @@ function renderControlPlaneViewHtml() {
|
||||
});
|
||||
}
|
||||
|
||||
// Wording shared by the canvas label and the live region so an outage reads
|
||||
// the same way however the operator reaches it.
|
||||
var UNAVAILABLE = 'Control-plane data is unavailable. Advisories and steering are unknown.';
|
||||
// Polls are not sequenced, so a slow request can settle out of order. Only
|
||||
// the newest poll that has already settled may update the view: a success
|
||||
// from a superseded poll would show older counts, and a failure from a
|
||||
// superseded poll would erase newer counts. Anchoring to the last settled
|
||||
// poll rather than the last started one also lets a failure land while a
|
||||
// newer poll is still pending, instead of leaving stale guidance on screen.
|
||||
var settledPoll = 0;
|
||||
// Monotonic id handed to each poll as it starts.
|
||||
var pollSeq = 0;
|
||||
// A poll that never answers must not stay pending forever, or the last
|
||||
// steering guidance stays on screen indefinitely.
|
||||
var TIMEOUT_MS = 10000;
|
||||
|
||||
// Polling runs every few seconds, so only speak when the advisory and
|
||||
// steering counts actually move. Repeating an unchanged summary would talk
|
||||
// over the operator without telling them anything new.
|
||||
function announce(message) {
|
||||
if (message === lastSpoken) return;
|
||||
lastSpoken = message;
|
||||
document.getElementById('announce').textContent = message;
|
||||
}
|
||||
|
||||
function unavailable() {
|
||||
document.getElementById('status').textContent = 'offline';
|
||||
// The last guidance is now stale, so replace it rather than leaving the
|
||||
// live region claiming the airspace is clear. The canvas label goes with
|
||||
// it, or it would still report the last successful counts.
|
||||
canvas.setAttribute('aria-label', UNAVAILABLE);
|
||||
announce(UNAVAILABLE);
|
||||
}
|
||||
|
||||
function apply(data) {
|
||||
if (!data || data.schemaVersion !== 'ecc.control-plane.view.v1' ||
|
||||
!['tasks', 'lanes', 'pairs', 'events'].every(function (key) { return Array.isArray(data[key]); }) ||
|
||||
@@ -213,22 +281,68 @@ function renderControlPlaneViewHtml() {
|
||||
!Number.isFinite(data.thresholds.ta) || !Number.isFinite(data.thresholds.ra)) {
|
||||
throw new Error('Invalid control-plane view');
|
||||
}
|
||||
var previous = view;
|
||||
var drawStarted = false;
|
||||
view = Object.assign({}, data);
|
||||
renderEvents(); renderLanes(); draw();
|
||||
try {
|
||||
renderEvents(); renderLanes();
|
||||
drawStarted = true;
|
||||
draw();
|
||||
} catch (error) {
|
||||
view = previous;
|
||||
try {
|
||||
renderEvents(); renderLanes();
|
||||
if (drawStarted) draw();
|
||||
} catch (_) {
|
||||
// Keep the accepted model even if the DOM cannot be restored.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
var c = view.counts || {};
|
||||
var summary = (c.tasks || 0) + ' tasks in ' + (c.lanes || 0) + ' lanes, ' +
|
||||
(c.advisories || 0) + ' advisories, ' + (c.resolutions || 0) + ' steering. ' +
|
||||
'See the Lanes panel for per-task risk.';
|
||||
canvas.setAttribute('aria-label', summary);
|
||||
document.getElementById('status').textContent =
|
||||
(c.tasks || 0) + ' tasks in ' + (c.lanes || 0) + ' lanes | ' + (c.agents || 0) + ' with edits | ' +
|
||||
(c.advisories || 0) + ' advisories (' + (c.resolutions || 0) + ' steering)' +
|
||||
(view.inventory && view.inventory.status !== 'ok' ? ' | inventory ' + view.inventory.status : '');
|
||||
|
||||
announce((c.advisories || 0) + ' advisories, ' +
|
||||
(c.resolutions || 0) + ' steering. ' +
|
||||
((c.resolutions || 0) > 0 ? 'Steering is required.' : 'No steering is required.'));
|
||||
}
|
||||
|
||||
function poll() {
|
||||
fetch('/api/control-plane').then(function (r) {
|
||||
var token = ++pollSeq;
|
||||
var timer = null;
|
||||
var controller = new AbortController();
|
||||
// Reject on a timer so a hung request cannot keep the previous guidance on
|
||||
// screen forever. The abort is what wakes this poll up, so a timeout is
|
||||
// reported as an outage rather than swallowed.
|
||||
timer = setTimeout(function () { controller.abort(); }, TIMEOUT_MS);
|
||||
// Claim before rendering or reporting failure: two JSON bodies may settle
|
||||
// in the same turn, before a later cleanup continuation can run. Equality
|
||||
// lets a render failure report unavailable for the token that just claimed.
|
||||
function claim() {
|
||||
if (token < settledPoll) return false;
|
||||
settledPoll = token;
|
||||
return true;
|
||||
}
|
||||
function settle() {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
fetch('/api/control-plane', { signal: controller.signal }).then(function (r) {
|
||||
if (!r.ok) throw new Error('Control-plane request failed');
|
||||
return r.json();
|
||||
}).then(apply).catch(function () {
|
||||
document.getElementById('status').textContent = 'offline';
|
||||
});
|
||||
}).then(function (data) {
|
||||
// A newer poll already owns the view, so do not resurrect older counts.
|
||||
if (!claim()) return;
|
||||
apply(data);
|
||||
}).catch(function () {
|
||||
if (!claim()) return;
|
||||
unavailable();
|
||||
}).then(settle, settle);
|
||||
}
|
||||
|
||||
resize();
|
||||
|
||||
+144
-43
@@ -410,7 +410,7 @@ function renderControlPaneHtml() {
|
||||
</div>
|
||||
<div id="app" hidden></div>
|
||||
<script>
|
||||
const state = { query: '' };
|
||||
const state = { query: '', shownQuery: '' };
|
||||
const $ = selector => document.querySelector(selector);
|
||||
const escapeHtml = value => String(value ?? '').replace(/[&<>"']/g, char => ({
|
||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||
@@ -436,6 +436,31 @@ function renderControlPaneHtml() {
|
||||
target.textContent = '';
|
||||
}
|
||||
|
||||
// The board keeps the last snapshot on screen, so a failed refresh has to
|
||||
// say that the data is no longer live, and since when.
|
||||
let loadedAt = null;
|
||||
// Loads are numbered as they start and finish in any order. The board shows
|
||||
// the newest data any load brought, and the error box the outcome of the
|
||||
// newest load that has finished, so a load that finishes late can neither
|
||||
// replace newer data nor overrule a newer outcome.
|
||||
const SNAPSHOT_DEADLINE_MS = 10000;
|
||||
const SNAPSHOT_POLL_INTERVAL_MS = 15000;
|
||||
let snapshotsInFlight = 0;
|
||||
let loadsStarted = 0;
|
||||
let newestFinished = 0;
|
||||
let shownLoad = 0;
|
||||
let failure = null;
|
||||
function showRefreshFailure(error) {
|
||||
const since = loadedAt
|
||||
? ' The data below is from ' + loadedAt.toLocaleString() + '.'
|
||||
: '';
|
||||
showError('#app', 'Live refresh failed.' + since + '\\n' + formatError(error));
|
||||
}
|
||||
function showFailure() {
|
||||
if (failure.live) showRefreshFailure(failure.error);
|
||||
else showError('#app', failure.error);
|
||||
}
|
||||
|
||||
async function readJsonResponse(response) {
|
||||
let payload;
|
||||
try {
|
||||
@@ -471,18 +496,17 @@ function renderControlPaneHtml() {
|
||||
['Unread', summary.unreadMessages],
|
||||
['Tokens', fmt.format(summary.totalTokens || 0)],
|
||||
];
|
||||
$('#metrics').innerHTML = items.map(([label, value]) =>
|
||||
return items.map(([label, value]) =>
|
||||
'<div class="metric"><span>' + escapeHtml(label) + '</span><strong>' + escapeHtml(value) + '</strong></div>'
|
||||
).join('');
|
||||
}
|
||||
|
||||
function renderSessions(sessions) {
|
||||
if (!sessions.length) {
|
||||
$('#sessions').innerHTML = '<div class="empty">No ECC2 sessions found.</div>';
|
||||
return;
|
||||
return '<div class="empty">No ECC2 sessions found.</div>';
|
||||
}
|
||||
|
||||
$('#sessions').innerHTML = '<table><thead><tr><th>State</th><th>Session</th><th>Harness</th><th>Worktree</th><th>Updated</th></tr></thead><tbody>' +
|
||||
return '<table><thead><tr><th>State</th><th>Session</th><th>Harness</th><th>Worktree</th><th>Updated</th></tr></thead><tbody>' +
|
||||
sessions.map(session => '<tr>' +
|
||||
'<td>' + statePill(session.state) + '</td>' +
|
||||
'<td><strong>' + escapeHtml(session.id) + '</strong><br><span class="subtle">' + escapeHtml(session.task) + '</span></td>' +
|
||||
@@ -493,13 +517,13 @@ function renderControlPaneHtml() {
|
||||
'</tbody></table>';
|
||||
}
|
||||
|
||||
function renderWorkItems(workItems) {
|
||||
function renderWorkItems(workItems, allowActions) {
|
||||
const summary = workItems || { totalCount: 0, openCount: 0, blockedCount: 0, doneCount: 0, kanban: {}, items: [] };
|
||||
const items = Array.isArray(summary.items) ? summary.items : [];
|
||||
const kanban = summary.kanban || {};
|
||||
const needsAssignment = Array.isArray(summary.needsAssignment) ? summary.needsAssignment : [];
|
||||
const assignment = summary.assignment || { agent: 0, human: 0, unassigned: 0 };
|
||||
$('#work-item-count').textContent = summary.openCount + ' open / ' + summary.blockedCount + ' blocked'
|
||||
const count = summary.openCount + ' open / ' + summary.blockedCount + ' blocked'
|
||||
+ ' / ' + (assignment.agent || 0) + ' agent / ' + (assignment.human || 0) + ' human'
|
||||
+ (needsAssignment.length ? ' / ' + needsAssignment.length + ' need owner' : '');
|
||||
|
||||
@@ -509,11 +533,10 @@ function renderControlPaneHtml() {
|
||||
).join('') + '</div>';
|
||||
|
||||
if (!items.length) {
|
||||
$('#work-items').innerHTML = laneHtml + '<div class="empty">No agent work items found.</div>';
|
||||
return;
|
||||
return { count, html: laneHtml + '<div class="empty">No agent work items found.</div>' };
|
||||
}
|
||||
|
||||
$('#work-items').innerHTML = laneHtml + items.slice(0, 8).map(item => {
|
||||
const html = laneHtml + items.slice(0, 8).map(item => {
|
||||
const branch = item.branch || (item.metadata && item.metadata.branch) || '';
|
||||
const mergeGate = item.mergeGate || (item.metadata && item.metadata.mergeGate) || '';
|
||||
const blocker = item.blocker || (item.metadata && item.metadata.blocker) || '';
|
||||
@@ -526,7 +549,7 @@ function renderControlPaneHtml() {
|
||||
const moveButtons = ['ready', 'running', 'blocked', 'done'].map(lane =>
|
||||
'<button type="button" data-wi-action="move" data-wi-id="' + idAttr + '" data-wi-lane="' + lane + '">' + escapeHtml(lane) + '</button>'
|
||||
).join('');
|
||||
const controls = state.allowActions
|
||||
const controls = allowActions
|
||||
? '<div class="row">'
|
||||
+ (assigneeKind === 'unassigned' ? '<button type="button" data-wi-action="claim" data-wi-id="' + idAttr + '">Claim</button>' : '')
|
||||
+ moveButtons
|
||||
@@ -542,6 +565,10 @@ function renderControlPaneHtml() {
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
return { count, html };
|
||||
}
|
||||
|
||||
function bindWorkItemActions() {
|
||||
document.querySelectorAll('#work-items [data-wi-action]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
const id = button.getAttribute('data-wi-id');
|
||||
@@ -555,13 +582,12 @@ function renderControlPaneHtml() {
|
||||
}
|
||||
|
||||
function renderKnowledge(knowledge) {
|
||||
$('#knowledge-count').textContent = knowledge.entityCount + ' entities';
|
||||
const count = knowledge.entityCount + ' entities';
|
||||
if (!knowledge.results.length) {
|
||||
$('#knowledge').innerHTML = '<div class="empty">No recall results for this query.</div>';
|
||||
return;
|
||||
return { count, html: '<div class="empty">No recall results for this query.</div>' };
|
||||
}
|
||||
|
||||
$('#knowledge').innerHTML = knowledge.results.map(result => {
|
||||
const html = knowledge.results.map(result => {
|
||||
const entity = result.entity;
|
||||
const obs = result.latestObservation;
|
||||
return '<div class="result">' +
|
||||
@@ -572,16 +598,16 @@ function renderControlPaneHtml() {
|
||||
'<div class="subtle">terms: ' + escapeHtml((result.matchedTerms || []).join(', ') || '-') + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
return { count, html };
|
||||
}
|
||||
|
||||
function renderConnectors(connectors) {
|
||||
$('#connector-count').textContent = connectors.length + ' configured';
|
||||
const count = connectors.length + ' configured';
|
||||
if (!connectors.length) {
|
||||
$('#connectors').innerHTML = '<div class="empty">No memory connectors configured.</div>';
|
||||
return;
|
||||
return { count, html: '<div class="empty">No memory connectors configured.</div>' };
|
||||
}
|
||||
|
||||
$('#connectors').innerHTML = connectors.map(connector => {
|
||||
const html = connectors.map(connector => {
|
||||
const status = connector.syncedSources > 0 ? '<span class="pill good">synced</span>' : '<span class="pill warn">not synced</span>';
|
||||
return '<div class="connector">' +
|
||||
'<div class="row"><strong>' + escapeHtml(connector.name) + '</strong>' + status + '</div>' +
|
||||
@@ -589,16 +615,20 @@ function renderControlPaneHtml() {
|
||||
'<div class="subtle">sources ' + escapeHtml(connector.syncedSources) + ' - last ' + escapeHtml(connector.lastSyncedAt || '-') + '</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
return { count, html };
|
||||
}
|
||||
|
||||
function renderActions(actions) {
|
||||
$('#actions').innerHTML = actions.map(action => '<div class="action">' +
|
||||
return actions.map(action => '<div class="action">' +
|
||||
'<div class="row"><strong>' + escapeHtml(action.label) + '</strong>' +
|
||||
(action.executable ? '<button data-action="' + escapeHtml(action.id) + '">Run</button>' : '<span class="pill">copy</span>') + '</div>' +
|
||||
'<div class="subtle">' + escapeHtml(action.description) + '</div>' +
|
||||
'<code>' + escapeHtml(action.commandLine) + '</code>' +
|
||||
'</div>').join('');
|
||||
|
||||
}
|
||||
|
||||
function bindActions() {
|
||||
document.querySelectorAll('[data-action]').forEach(button => {
|
||||
button.addEventListener('click', () => {
|
||||
runAction(button.dataset.action);
|
||||
@@ -611,10 +641,12 @@ function renderControlPaneHtml() {
|
||||
output.textContent = 'Running ' + actionId + '...';
|
||||
|
||||
try {
|
||||
// An action runs for the query its card on the board was built for.
|
||||
// After a failed load that is not always the query typed last.
|
||||
const response = await fetch('/api/actions/' + encodeURIComponent(actionId), {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ query: state.query })
|
||||
body: JSON.stringify({ query: state.shownQuery })
|
||||
});
|
||||
const payload = await readJsonResponse(response);
|
||||
output.textContent = JSON.stringify(payload, null, 2);
|
||||
@@ -624,27 +656,95 @@ function renderControlPaneHtml() {
|
||||
}
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const url = new URL('/api/snapshot', window.location.href);
|
||||
if (state.query) url.searchParams.set('query', state.query);
|
||||
const response = await fetch(url);
|
||||
const snapshot = await readJsonResponse(response);
|
||||
$('#query').value = snapshot.knowledge.query || state.query;
|
||||
$('#db-path').textContent = snapshot.database.exists ? snapshot.dbPath : 'database missing';
|
||||
state.allowActions = Boolean(snapshot.execution.allowActions);
|
||||
$('#action-status').textContent = state.allowActions ? 'local allowlist' : 'read-only';
|
||||
renderMetrics(snapshot.summary);
|
||||
renderSessions(snapshot.sessions);
|
||||
renderWorkItems(snapshot.workItems);
|
||||
renderKnowledge(snapshot.knowledge);
|
||||
renderConnectors(snapshot.connectors);
|
||||
renderActions(snapshot.actions.map(action => ({
|
||||
...action,
|
||||
executable: snapshot.execution.allowActions && action.executable
|
||||
})));
|
||||
async function load(live = false) {
|
||||
const id = ++loadsStarted;
|
||||
const query = state.query;
|
||||
snapshotsInFlight++;
|
||||
let snapshotTimer = null;
|
||||
try {
|
||||
const url = new URL('/api/snapshot', window.location.href);
|
||||
if (query) url.searchParams.set('query', query);
|
||||
const controller = new AbortController();
|
||||
let rejectDeadline;
|
||||
const deadline = new Promise((_resolve, reject) => { rejectDeadline = reject; });
|
||||
snapshotTimer = setTimeout(() => {
|
||||
// Reject first so the fixed timeout wins over an abort rejection.
|
||||
rejectDeadline(new Error('Snapshot request timed out after 10 seconds.'));
|
||||
controller.abort();
|
||||
}, SNAPSHOT_DEADLINE_MS);
|
||||
// Only fetch/body decoding races the deadline. Late abort-ignoring
|
||||
// responses have no rendering or state side effects after losing.
|
||||
const snapshot = await Promise.race([
|
||||
(async () => {
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
return readJsonResponse(response);
|
||||
})(),
|
||||
deadline
|
||||
]);
|
||||
// A snapshot that cannot be shown fails its load like one that could
|
||||
// not be fetched, and older data may still take the board.
|
||||
if (id > shownLoad) {
|
||||
render(snapshot, query);
|
||||
shownLoad = id;
|
||||
}
|
||||
} catch (error) {
|
||||
if (id < newestFinished) return;
|
||||
newestFinished = id;
|
||||
failure = { error, live };
|
||||
showFailure();
|
||||
return;
|
||||
} finally {
|
||||
if (snapshotTimer !== null) clearTimeout(snapshotTimer);
|
||||
snapshotsInFlight--;
|
||||
}
|
||||
if (id < newestFinished) {
|
||||
// A newer load failed first. This data is still the newest on the
|
||||
// board, so the failure stays, dated by it.
|
||||
if (failure && id === shownLoad) showFailure();
|
||||
return;
|
||||
}
|
||||
newestFinished = id;
|
||||
failure = null;
|
||||
clearError('#app');
|
||||
}
|
||||
|
||||
function render(snapshot, query) {
|
||||
// Prepare every fragment before changing the board. Malformed late
|
||||
// sections must not leave new results with the prior action context.
|
||||
const allowActions = Boolean(snapshot.execution.allowActions);
|
||||
const workItems = renderWorkItems(snapshot.workItems, allowActions);
|
||||
const knowledge = renderKnowledge(snapshot.knowledge);
|
||||
const connectors = renderConnectors(snapshot.connectors);
|
||||
const updates = [
|
||||
['#query', 'value', snapshot.knowledge.query ?? query],
|
||||
['#db-path', 'textContent', snapshot.database.exists ? snapshot.dbPath : 'database missing'],
|
||||
['#action-status', 'textContent', allowActions ? 'local allowlist' : 'read-only'],
|
||||
['#metrics', 'innerHTML', renderMetrics(snapshot.summary)],
|
||||
['#sessions', 'innerHTML', renderSessions(snapshot.sessions)],
|
||||
['#work-item-count', 'textContent', workItems.count],
|
||||
['#work-items', 'innerHTML', workItems.html],
|
||||
['#knowledge-count', 'textContent', knowledge.count],
|
||||
['#knowledge', 'innerHTML', knowledge.html],
|
||||
['#connector-count', 'textContent', connectors.count],
|
||||
['#connectors', 'innerHTML', connectors.html],
|
||||
['#actions', 'innerHTML', renderActions(snapshot.actions.map(action => ({
|
||||
...action,
|
||||
executable: allowActions && action.executable
|
||||
})))]
|
||||
].map(([selector, property, value]) => {
|
||||
const target = $(selector);
|
||||
if (!target) throw new Error('Missing control pane element: ' + selector);
|
||||
return { target, property, value };
|
||||
});
|
||||
const completedAt = new Date();
|
||||
updates.forEach(({ target, property, value }) => { target[property] = value; });
|
||||
state.allowActions = allowActions;
|
||||
state.shownQuery = query;
|
||||
loadedAt = completedAt;
|
||||
bindWorkItemActions();
|
||||
bindActions();
|
||||
}
|
||||
|
||||
$('#query-form').addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
state.query = $('#query').value.trim();
|
||||
@@ -679,11 +779,12 @@ function renderControlPaneHtml() {
|
||||
.catch(error => showError('#app', error));
|
||||
};
|
||||
|
||||
// Live board: refresh on a gentle interval; pause while a prompt/tab is hidden.
|
||||
// Only automatic polls coalesce. Manual/query loads retain ordered overlap.
|
||||
// Browser suspension may delay timers; this is not a wall-clock server SLA.
|
||||
setInterval(() => {
|
||||
if (document.hidden) return;
|
||||
load().catch(() => {});
|
||||
}, 15000);
|
||||
if (document.hidden || snapshotsInFlight > 0) return;
|
||||
load(true).catch(error => showError('#app', error));
|
||||
}, SNAPSHOT_POLL_INTERVAL_MS);
|
||||
|
||||
load().catch(error => showError('#app', error));
|
||||
</script>
|
||||
|
||||
+395
-299
@@ -10,8 +10,15 @@ const { readInstallState, validateInstallState } = require('./install-state');
|
||||
const { assertWithinTrustedRoot } = require('./path-safety');
|
||||
const { createInstallPlanFromRequest } = require('./install/runtime');
|
||||
const { assertNoNewUserOwnedFile, prepareUserOwnedFileGuard } = require('./install/ownership-guard');
|
||||
const { writeFileNoFollow: guardedWriteFile } = require('./install/guarded-write');
|
||||
const { withOpenCodeInstallLocks } = require('./install/opencode-install-lock');
|
||||
const { isCodexUserConfig } = require('./install/codex-user-config');
|
||||
const { getRecordedHookConsent } = require('./install/hook-consent');
|
||||
const {
|
||||
disableOpenCodeHookPluginRegistration,
|
||||
getDisabledOpenCodePluginContent,
|
||||
getRecordedHookConsent,
|
||||
withHookConsent,
|
||||
} = require('./install/hook-consent');
|
||||
const {
|
||||
prepareClaudeSkillMigration,
|
||||
} = require('./install/claude-skill-migration');
|
||||
@@ -232,6 +239,12 @@ function transformCopyFileContent(operation, content) {
|
||||
if (operation.contentTransform === 'antigravity-agent-frontmatter') {
|
||||
return adaptAntigravityAgent(content, operation.sourceRelativePath);
|
||||
}
|
||||
if (operation.contentTransform === 'opencode-disable-ecc-hooks') {
|
||||
return disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath);
|
||||
}
|
||||
if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') {
|
||||
return getDisabledOpenCodePluginContent();
|
||||
}
|
||||
throw new Error(`Unknown install content transform: ${operation.contentTransform}`);
|
||||
}
|
||||
|
||||
@@ -450,66 +463,15 @@ function createChangedDestinationError(action) {
|
||||
);
|
||||
}
|
||||
|
||||
function getStableParentStat(filePath, action) {
|
||||
const parentStat = fs.lstatSync(path.dirname(filePath));
|
||||
if (!parentStat.isDirectory() || parentStat.isSymbolicLink()) {
|
||||
throw createChangedDestinationError(action);
|
||||
}
|
||||
return parentStat;
|
||||
}
|
||||
|
||||
function assertPinnedWriteDestination(
|
||||
filePath,
|
||||
fileDescriptor,
|
||||
expectedParentStat,
|
||||
trustedRoot,
|
||||
action
|
||||
) {
|
||||
const liveDestination = getManagedDestination(filePath, trustedRoot, action);
|
||||
if (path.resolve(liveDestination.managedPath) !== path.resolve(filePath)) {
|
||||
throw createChangedDestinationError(action);
|
||||
}
|
||||
|
||||
const liveParentStat = getStableParentStat(filePath, action);
|
||||
if (!hasSameFileIdentity(expectedParentStat, liveParentStat)) {
|
||||
throw createChangedDestinationError(action);
|
||||
}
|
||||
|
||||
const descriptorStat = fs.fstatSync(fileDescriptor);
|
||||
const livePathStat = fs.lstatSync(liveDestination.managedPath);
|
||||
if (
|
||||
!descriptorStat.isFile()
|
||||
|| !livePathStat.isFile()
|
||||
|| livePathStat.isSymbolicLink()
|
||||
|| !hasSameFileIdentity(descriptorStat, livePathStat)
|
||||
) {
|
||||
throw createChangedDestinationError(action);
|
||||
}
|
||||
}
|
||||
|
||||
function writeFileNoFollow(filePath, content, mode, trustedRoot, action) {
|
||||
const expectedParentStat = getStableParentStat(filePath, action);
|
||||
const flags = fs.constants.O_WRONLY
|
||||
| fs.constants.O_CREAT
|
||||
| (fs.constants.O_NOFOLLOW || 0);
|
||||
const fileDescriptor = fs.openSync(filePath, flags, mode);
|
||||
|
||||
try {
|
||||
assertPinnedWriteDestination(
|
||||
filePath,
|
||||
fileDescriptor,
|
||||
expectedParentStat,
|
||||
trustedRoot,
|
||||
action
|
||||
);
|
||||
fs.ftruncateSync(fileDescriptor, 0);
|
||||
fs.writeFileSync(fileDescriptor, content);
|
||||
if (mode !== undefined) {
|
||||
fs.fchmodSync(fileDescriptor, mode);
|
||||
}
|
||||
} finally {
|
||||
fs.closeSync(fileDescriptor);
|
||||
}
|
||||
function writeFileNoFollow(filePath, content, mode, trustedRoot, action, writeOptions = {}) {
|
||||
return guardedWriteFile(filePath, content, {
|
||||
...writeOptions,
|
||||
mode,
|
||||
action,
|
||||
validateDestination(destinationPath) {
|
||||
return getManagedDestination(destinationPath, trustedRoot, action).managedPath;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function readFileWithMetadataNoFollow(filePath, encoding) {
|
||||
@@ -564,7 +526,7 @@ function assertClaudeSettingsDestination(operation, trustedRoot, target = null)
|
||||
assertClaudeSettingsPath(operation.destinationPath, trustedRoot);
|
||||
}
|
||||
|
||||
function writeContainedFile(destinationPath, content, trustedRoot, action, mode) {
|
||||
function writeContainedFile(destinationPath, content, trustedRoot, action, mode, writeOptions) {
|
||||
const preparedDestination = prepareContainedWriteDestination(destinationPath, trustedRoot, action);
|
||||
const finalDestination = getManagedDestination(
|
||||
preparedDestination,
|
||||
@@ -576,7 +538,8 @@ function writeContainedFile(destinationPath, content, trustedRoot, action, mode)
|
||||
content,
|
||||
mode,
|
||||
trustedRoot,
|
||||
action
|
||||
action,
|
||||
writeOptions
|
||||
);
|
||||
return finalDestination;
|
||||
}
|
||||
@@ -782,7 +745,8 @@ function executeRepairOperation(
|
||||
trustedRoot,
|
||||
linkIndex = null,
|
||||
target = null,
|
||||
settingsLockHeld = false
|
||||
settingsLockHeld = false,
|
||||
writeOptions = {}
|
||||
) {
|
||||
// Install-state is attacker-controllable; never write/delete outside the
|
||||
// adapter-derived trusted root, regardless of what the state file claims
|
||||
@@ -800,7 +764,8 @@ function executeRepairOperation(
|
||||
getExpectedCopyFileContent(operation, source.content, linkIndex),
|
||||
trustedRoot,
|
||||
'repair',
|
||||
source.mode & 0o777
|
||||
source.mode & 0o777,
|
||||
writeOptions
|
||||
);
|
||||
} else {
|
||||
copyContainedFile(sourcePath, operation.destinationPath, trustedRoot, 'repair');
|
||||
@@ -1179,7 +1144,11 @@ function inspectManagedOperation(repoRoot, trustedRoot, operation, linkIndex = n
|
||||
|
||||
let contentMatches;
|
||||
try {
|
||||
contentMatches = hasRecordedContentDigest(operation)
|
||||
// A deactivation transform changes the desired bytes. A historical
|
||||
// active-file digest proves ownership, not completion of that transition.
|
||||
const deactivatesOpenCode = operation.contentTransform === 'opencode-disable-ecc-hooks'
|
||||
|| operation.contentTransform === 'opencode-disable-plugin-entrypoint';
|
||||
contentMatches = hasRecordedContentDigest(operation) && !deactivatesOpenCode
|
||||
? fileMatchesRecordedContent(inspectedPath, operation)
|
||||
: operation.contentTransform || isMarkdownPath(operation.destinationPath)
|
||||
? readFileNoFollow(inspectedPath, 'utf8') === getExpectedCopyFileContent(
|
||||
@@ -1629,6 +1598,24 @@ function analyzeRecord(record, context) {
|
||||
};
|
||||
}
|
||||
|
||||
let planningFailureReported = false;
|
||||
if (record.adapter.target === 'opencode') {
|
||||
let checks;
|
||||
try {
|
||||
checks = prepareOpenCodeHookDeactivationChecks(record, context, { requireInactive: true });
|
||||
} catch (error) {
|
||||
planningFailureReported = true;
|
||||
issues.push(buildIssue('error', 'resolution-unavailable', error.message));
|
||||
}
|
||||
if (checks) {
|
||||
try {
|
||||
for (const check of checks) assertOpenCodeRepairHookDeactivation(check.plan, check.options);
|
||||
} catch (error) {
|
||||
issues.push(buildIssue('error', 'opencode-hook-consent-violation', error.message));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(state.target.root)) {
|
||||
issues.push(buildIssue('error', 'missing-target-root', `Target root does not exist: ${state.target.root}`));
|
||||
}
|
||||
@@ -1731,7 +1718,7 @@ function analyzeRecord(record, context) {
|
||||
issues.push(buildIssue('warning', 'repo-version-mismatch', `Recorded repo version ${state.source.repoVersion} differs from current repo version ${context.packageVersion}`));
|
||||
}
|
||||
|
||||
if (!state.request.legacyMode) {
|
||||
if (!state.request.legacyMode && !planningFailureReported) {
|
||||
try {
|
||||
const desiredPlan = resolveRecordedManifestPlan(record, context);
|
||||
|
||||
@@ -1758,7 +1745,7 @@ function analyzeRecord(record, context) {
|
||||
}
|
||||
|
||||
function buildDoctorReport(options = {}) {
|
||||
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
|
||||
const repoRoot = options.repoRoot ? path.resolve(options.repoRoot) : DEFAULT_REPO_ROOT;
|
||||
const manifests = loadInstallManifests({ repoRoot });
|
||||
const records = discoverInstalledStates({
|
||||
homeDir: options.homeDir,
|
||||
@@ -1821,7 +1808,8 @@ function createRepairPlanFromRecord(record, context, options = {}) {
|
||||
);
|
||||
const statePreview = buildRecordedStatePreview(state, context, operations);
|
||||
|
||||
return {
|
||||
const recordedPlan = {
|
||||
sourceRoot: context.repoRoot,
|
||||
mode: state.request.legacyMode ? 'legacy' : 'recorded',
|
||||
target: record.adapter.target,
|
||||
adapter: record.adapter,
|
||||
@@ -1830,9 +1818,13 @@ function createRepairPlanFromRecord(record, context, options = {}) {
|
||||
installStatePath: state.target.installStatePath,
|
||||
warnings: [],
|
||||
languages: Array.isArray(state.request.legacyLanguages) ? [...state.request.legacyLanguages] : [],
|
||||
selectedModuleIds: [...(state.resolution.selectedModules || [])],
|
||||
operations,
|
||||
statePreview
|
||||
};
|
||||
return record.adapter.target === 'opencode'
|
||||
? withHookConsent(recordedPlan, getRecordedHookConsent(state))
|
||||
: recordedPlan;
|
||||
}
|
||||
|
||||
const desiredPlan = resolveRecordedManifestPlan(record, context, options);
|
||||
@@ -1882,8 +1874,11 @@ function writeRefreshedInstallState(record, statePreview, writtenPaths = []) {
|
||||
return { ...operation };
|
||||
}
|
||||
// Refreshing a ledger is not a file write. Keep the last installed digest
|
||||
// for untouched shared configs so a concurrent user edit is never claimed.
|
||||
if (isCodexUserConfig(record, operation)
|
||||
// for untouched shared configs and OpenCode activations so a concurrent
|
||||
// user edit is never claimed, even by a partial repair checkpoint.
|
||||
if ((isCodexUserConfig(record, operation)
|
||||
|| (record.adapter.target === 'opencode'
|
||||
&& require('./install/apply').getOpenCodeActivationKind(record, operation)))
|
||||
&& !writtenPaths.some(writtenPath => path.relative(writtenPath, operation.destinationPath) === '')) {
|
||||
const previousOperation = (record.state.operations || []).find(previous => (
|
||||
previous.destinationPath
|
||||
@@ -1923,10 +1918,13 @@ function prepareRepairMigration(plan, record) {
|
||||
installStatePath: record.installStatePath,
|
||||
statePreview: buildAdapterDerivedStatePreview(plan.statePreview, record),
|
||||
};
|
||||
const skillMigration = prepareClaudeSkillMigration(trustedPlan);
|
||||
const migration = record.adapter.id === 'codex-home'
|
||||
? prepareUserOwnedFileGuard(trustedPlan, skillMigration)
|
||||
: skillMigration;
|
||||
const initialMigration = prepareClaudeSkillMigration(trustedPlan);
|
||||
const guardedMigration = record.adapter.id === 'codex-home'
|
||||
? prepareUserOwnedFileGuard(trustedPlan, initialMigration)
|
||||
: initialMigration;
|
||||
const migration = trustedPlan.target === 'opencode'
|
||||
? require('./install/apply').prepareHookConsentMigration(trustedPlan, guardedMigration)
|
||||
: guardedMigration;
|
||||
return {
|
||||
migration,
|
||||
plan: {
|
||||
@@ -1941,8 +1939,58 @@ function prepareRepairMigration(plan, record) {
|
||||
};
|
||||
}
|
||||
|
||||
function assertOpenCodeRepairHookDeactivation(plan, options = {}) {
|
||||
if (plan.target !== 'opencode') {
|
||||
return new Map();
|
||||
}
|
||||
// The installer imports lifecycle helpers. Resolve this shared read-only
|
||||
// guard lazily so both paths enforce the same discovery/ownership boundary.
|
||||
const { assertOpenCodeHookDeactivationReady } = require('./install/apply');
|
||||
return assertOpenCodeHookDeactivationReady(plan, options);
|
||||
}
|
||||
|
||||
function prepareOpenCodeHookDeactivationChecks(record, context, options = {}) {
|
||||
if (record.adapter.target !== 'opencode') {
|
||||
return [];
|
||||
}
|
||||
const rawPlan = createRepairPlanFromRecord(record, context, {
|
||||
// Planning must reject unsafe existing activations before a repair build
|
||||
// writes compiled files. Missing payload is still validated/built later.
|
||||
exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE],
|
||||
});
|
||||
if (record.legacyLayout === 'opencode') {
|
||||
const state = record.state;
|
||||
const legacyPlan = withHookConsent({
|
||||
sourceRoot: context.repoRoot,
|
||||
target: 'opencode',
|
||||
adapter: record.adapter,
|
||||
targetRoot: record.targetRoot,
|
||||
installRoot: record.targetRoot,
|
||||
installStatePath: record.installStatePath,
|
||||
selectedModuleIds: [...(state.resolution.selectedModules || [])],
|
||||
operations: getManagedOperations(state),
|
||||
statePreview: buildAdapterDerivedStatePreview(state, record),
|
||||
}, getRecordedHookConsent(state));
|
||||
// Migration does not replay operations into the old root. Inspect existing
|
||||
// activations there, without treating historical merge-json records as new
|
||||
// writes; the canonical destination is validated separately below.
|
||||
return [
|
||||
{ plan: { ...legacyPlan, operations: [] }, options: { allowVerifiedLegacyRemoval: true } },
|
||||
{ plan: rawPlan, options },
|
||||
];
|
||||
}
|
||||
const { plan } = prepareRepairMigration(rawPlan, record);
|
||||
return [{ plan, options }];
|
||||
}
|
||||
|
||||
function preflightOpenCodeHookDeactivation(record, context, options = {}) {
|
||||
for (const check of prepareOpenCodeHookDeactivationChecks(record, context, options)) {
|
||||
assertOpenCodeRepairHookDeactivation(check.plan, check.options);
|
||||
}
|
||||
}
|
||||
|
||||
function repairInstalledStates(options = {}) {
|
||||
const repoRoot = options.repoRoot || DEFAULT_REPO_ROOT;
|
||||
const repoRoot = options.repoRoot ? path.resolve(options.repoRoot) : DEFAULT_REPO_ROOT;
|
||||
const manifests = loadInstallManifests({ repoRoot });
|
||||
const context = {
|
||||
repoRoot,
|
||||
@@ -1965,35 +2013,130 @@ function repairInstalledStates(options = {}) {
|
||||
&& (!record.legacy || record.legacyLayout === 'opencode')
|
||||
));
|
||||
|
||||
const results = records.map(record => {
|
||||
if (record.error) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'error',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
error: record.error
|
||||
};
|
||||
}
|
||||
|
||||
let releaseSettingsLock = null;
|
||||
try {
|
||||
const settingsPathToLock = !options.dryRun
|
||||
&& getManagedOperations(record.state || {}).some(
|
||||
operation => operation.kind === 'update-claude-settings'
|
||||
)
|
||||
? getClaudeSettingsPath(record.targetRoot)
|
||||
: null;
|
||||
if (settingsPathToLock) {
|
||||
releaseSettingsLock = acquireSettingsLock(settingsPathToLock);
|
||||
const results = records.map(initialRecord => {
|
||||
let record = initialRecord;
|
||||
const performRepair = (opencodeLease) => {
|
||||
if (record.error) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'error',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
error: record.error
|
||||
};
|
||||
}
|
||||
const needsOpencodeBuild = record.adapter.target === 'opencode'
|
||||
&& hasOpencodeBuildError(getOpencodeBuildValidationIssues(context));
|
||||
const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT);
|
||||
|
||||
if (record.legacyLayout === 'opencode') {
|
||||
if (needsOpencodeBuild && !options.dryRun) {
|
||||
let releaseSettingsLock = null;
|
||||
try {
|
||||
preflightOpenCodeHookDeactivation(record, context);
|
||||
const settingsPathToLock = !options.dryRun
|
||||
&& getManagedOperations(record.state || {}).some(
|
||||
operation => operation.kind === 'update-claude-settings'
|
||||
)
|
||||
? getClaudeSettingsPath(record.targetRoot)
|
||||
: null;
|
||||
if (settingsPathToLock) {
|
||||
releaseSettingsLock = acquireSettingsLock(settingsPathToLock);
|
||||
}
|
||||
const needsOpencodeBuild = record.adapter.target === 'opencode'
|
||||
&& hasOpencodeBuildError(getOpencodeBuildValidationIssues(context));
|
||||
const opencodeBuildRepairPath = path.join(context.repoRoot, OPENCODE_BUILD_ARTIFACT);
|
||||
|
||||
if (record.legacyLayout === 'opencode') {
|
||||
if (needsOpencodeBuild && !options.dryRun) {
|
||||
try {
|
||||
buildOpencodeRunner(context.repoRoot);
|
||||
} catch (error) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'error',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
error: formatBuildErrorMessage(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const canonicalPlan = createRepairPlanFromRecord(record, context, {
|
||||
exemptValidationCodes: options.dryRun && needsOpencodeBuild
|
||||
? [OPENCODE_PLUGIN_NOT_BUILT_CODE]
|
||||
: [],
|
||||
});
|
||||
assertOpenCodeRepairHookDeactivation(canonicalPlan);
|
||||
const plannedRepairs = [...new Set([
|
||||
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
|
||||
...canonicalPlan.operations.map(operation => operation.destinationPath),
|
||||
...getManagedOperations(record.state).map(operation => operation.destinationPath),
|
||||
record.installStatePath,
|
||||
])];
|
||||
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'planned',
|
||||
installStatePath: canonicalPlan.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs,
|
||||
stateRefreshed: false,
|
||||
warnings: canonicalPlan.warnings,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Load lazily to avoid a module cycle during install-lifecycle startup.
|
||||
const { applyInstallPlan } = require('./install/apply');
|
||||
const appliedPlan = applyInstallPlan(canonicalPlan, { opencodeLease });
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'repaired',
|
||||
installStatePath: canonicalPlan.installStatePath,
|
||||
repairedPaths: [
|
||||
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
|
||||
...canonicalPlan.operations.map(operation => operation.destinationPath),
|
||||
],
|
||||
plannedRepairs: [],
|
||||
stateRefreshed: true,
|
||||
warnings: appliedPlan.warnings,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (needsOpencodeBuild && options.dryRun) {
|
||||
const rawPlan = createRepairPlanFromRecord(record, context, {
|
||||
exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE],
|
||||
});
|
||||
const { plan: desiredPlan } = prepareRepairMigration(rawPlan, record);
|
||||
const operationHealth = summarizeManagedOperationHealth(
|
||||
context.repoRoot,
|
||||
record.targetRoot,
|
||||
desiredPlan.operations,
|
||||
record.adapter.target
|
||||
);
|
||||
const unsafeOperationResult = getUnsafeOperationResult(
|
||||
record,
|
||||
operationHealth
|
||||
);
|
||||
if (unsafeOperationResult) {
|
||||
return unsafeOperationResult;
|
||||
}
|
||||
const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))];
|
||||
const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)];
|
||||
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'planned',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs,
|
||||
stateRefreshed: false,
|
||||
warnings: desiredPlan.warnings,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
if (needsOpencodeBuild) {
|
||||
try {
|
||||
buildOpencodeRunner(context.repoRoot);
|
||||
} catch (error) {
|
||||
@@ -2003,65 +2146,24 @@ function repairInstalledStates(options = {}) {
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
error: formatBuildErrorMessage(error),
|
||||
error: formatBuildErrorMessage(error)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const canonicalPlan = createRepairPlanFromRecord(record, context, {
|
||||
exemptValidationCodes: options.dryRun && needsOpencodeBuild
|
||||
? [OPENCODE_PLUGIN_NOT_BUILT_CODE]
|
||||
: [],
|
||||
});
|
||||
const plannedRepairs = [...new Set([
|
||||
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
|
||||
...canonicalPlan.operations.map(operation => operation.destinationPath),
|
||||
...getManagedOperations(record.state).map(operation => operation.destinationPath),
|
||||
record.installStatePath,
|
||||
])];
|
||||
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'planned',
|
||||
installStatePath: canonicalPlan.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs,
|
||||
stateRefreshed: false,
|
||||
warnings: canonicalPlan.warnings,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
// Load lazily to avoid a module cycle during install-lifecycle startup.
|
||||
const { applyInstallPlan } = require('./install/apply');
|
||||
const appliedPlan = applyInstallPlan(canonicalPlan);
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'repaired',
|
||||
installStatePath: canonicalPlan.installStatePath,
|
||||
repairedPaths: [
|
||||
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
|
||||
...canonicalPlan.operations.map(operation => operation.destinationPath),
|
||||
],
|
||||
plannedRepairs: [],
|
||||
stateRefreshed: true,
|
||||
warnings: appliedPlan.warnings,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (needsOpencodeBuild && options.dryRun) {
|
||||
const rawPlan = createRepairPlanFromRecord(record, context, {
|
||||
exemptValidationCodes: [OPENCODE_PLUGIN_NOT_BUILT_CODE],
|
||||
});
|
||||
const { plan: desiredPlan } = prepareRepairMigration(rawPlan, record);
|
||||
const rawPlan = createRepairPlanFromRecord(record, context);
|
||||
const {
|
||||
migration,
|
||||
plan: desiredPlan,
|
||||
} = prepareRepairMigration(rawPlan, record);
|
||||
const activationSnapshot = assertOpenCodeRepairHookDeactivation(desiredPlan);
|
||||
const operationHealth = summarizeManagedOperationHealth(
|
||||
context.repoRoot,
|
||||
record.targetRoot,
|
||||
desiredPlan.operations,
|
||||
record.adapter.target
|
||||
);
|
||||
|
||||
const unsafeOperationResult = getUnsafeOperationResult(
|
||||
record,
|
||||
operationHealth
|
||||
@@ -2069,175 +2171,169 @@ function repairInstalledStates(options = {}) {
|
||||
if (unsafeOperationResult) {
|
||||
return unsafeOperationResult;
|
||||
}
|
||||
const repairOperations = [...operationHealth.missing.map(entry => ({ ...entry.operation })), ...operationHealth.drifted.map(entry => ({ ...entry.operation }))];
|
||||
const plannedRepairs = [opencodeBuildRepairPath, ...repairOperations.map(operation => operation.destinationPath)];
|
||||
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'planned',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs,
|
||||
stateRefreshed: false,
|
||||
warnings: desiredPlan.warnings,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
if (needsOpencodeBuild) {
|
||||
try {
|
||||
buildOpencodeRunner(context.repoRoot);
|
||||
} catch (error) {
|
||||
if (operationHealth.missingSource.length > 0) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'error',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
error: formatBuildErrorMessage(error)
|
||||
warnings: desiredPlan.warnings,
|
||||
error: `Missing source file(s): ${operationHealth.missingSource.map(entry => entry.sourcePath).join(', ')}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const rawPlan = createRepairPlanFromRecord(record, context);
|
||||
const {
|
||||
migration,
|
||||
plan: desiredPlan,
|
||||
} = prepareRepairMigration(rawPlan, record);
|
||||
const operationHealth = summarizeManagedOperationHealth(
|
||||
context.repoRoot,
|
||||
record.targetRoot,
|
||||
desiredPlan.operations,
|
||||
record.adapter.target
|
||||
);
|
||||
const repairOperations = [
|
||||
...operationHealth.missing.map(entry => ({ ...entry.operation })),
|
||||
...operationHealth.drifted.map(entry => ({ ...entry.operation })),
|
||||
...desiredPlan.operations
|
||||
.filter(operation => (
|
||||
operation.kind === 'update-claude-settings'
|
||||
&& operation.previousManagedHooks
|
||||
&& !isDeepStrictEqual(operation.previousManagedHooks, operation.managedHooks)
|
||||
))
|
||||
.map(operation => ({ ...operation })),
|
||||
].filter((operation, index, items) => items.findIndex(candidate => (
|
||||
candidate.kind === operation.kind
|
||||
&& candidate.destinationPath === operation.destinationPath
|
||||
)) === index);
|
||||
const repairLinkIndex = buildLinkIndexForOperations(desiredPlan.operations, record.targetRoot);
|
||||
const legacyMigrationPaths = migration.legacyOperationsToRemove.map(
|
||||
operation => operation.destinationPath
|
||||
);
|
||||
const plannedRepairs = [...new Set([
|
||||
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
|
||||
...repairOperations.map(operation => operation.destinationPath),
|
||||
...legacyMigrationPaths,
|
||||
])];
|
||||
|
||||
const unsafeOperationResult = getUnsafeOperationResult(
|
||||
record,
|
||||
operationHealth
|
||||
);
|
||||
if (unsafeOperationResult) {
|
||||
return unsafeOperationResult;
|
||||
}
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: plannedRepairs.length > 0 ? 'planned' : 'ok',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs,
|
||||
stateRefreshed: plannedRepairs.length === 0,
|
||||
warnings: desiredPlan.warnings,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
if (operationHealth.missingSource.length > 0) {
|
||||
const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0;
|
||||
const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : [];
|
||||
if (desiredPlan.target === 'opencode') {
|
||||
const { assertOpenCodeActivationUnchanged } = require('./install/apply');
|
||||
// Health inspection must not let a changed activation become owned by
|
||||
// a bridge checkpoint before the per-write check can reject it.
|
||||
for (const destinationPath of activationSnapshot.keys()) {
|
||||
assertOpenCodeActivationUnchanged(desiredPlan, { destinationPath }, activationSnapshot);
|
||||
}
|
||||
}
|
||||
if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) {
|
||||
writeRefreshedInstallState(record, migration.bridgeState);
|
||||
}
|
||||
|
||||
for (const operation of repairOperations) {
|
||||
if (desiredPlan.target === 'opencode') {
|
||||
const { assertOpenCodeActivationUnchanged } = require('./install/apply');
|
||||
assertOpenCodeActivationUnchanged(desiredPlan, operation, activationSnapshot);
|
||||
}
|
||||
if (record.adapter.id === 'codex-home') {
|
||||
assertNoNewUserOwnedFile(migration, operation, desiredPlan);
|
||||
}
|
||||
const repairedPath = executeRepairOperation(
|
||||
context.repoRoot,
|
||||
operation,
|
||||
record.targetRoot,
|
||||
repairLinkIndex,
|
||||
record.adapter.target,
|
||||
Boolean(releaseSettingsLock),
|
||||
require('./install/apply').getOpenCodeActivationWriteOptions(operation, activationSnapshot)
|
||||
);
|
||||
if (repairedPath) {
|
||||
repairedPaths.push(repairedPath);
|
||||
}
|
||||
}
|
||||
if (hasLegacyMigration) {
|
||||
for (const operation of migration.legacyOperationsToRemove) {
|
||||
const removedPath = removeContainedPath(
|
||||
operation.destinationPath,
|
||||
record.targetRoot,
|
||||
'migrate managed Claude skill',
|
||||
{ force: true }
|
||||
);
|
||||
if (removedPath) {
|
||||
repairedPaths.push(removedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
const changedInstalledBytes = repairOperations.length > 0
|
||||
|| needsOpencodeBuild
|
||||
|| hasLegacyMigration;
|
||||
const statePreviewToWrite = changedInstalledBytes
|
||||
? desiredPlan.statePreview
|
||||
: {
|
||||
...desiredPlan.statePreview,
|
||||
installedAt: record.state.installedAt,
|
||||
source: { ...record.state.source },
|
||||
};
|
||||
assertOpenCodeRepairHookDeactivation(desiredPlan, { requireInactive: true });
|
||||
writeRefreshedInstallState(record, statePreviewToWrite, repairedPaths);
|
||||
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: (repairOperations.length > 0 || needsOpencodeBuild || hasLegacyMigration)
|
||||
? 'repaired'
|
||||
: 'ok',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths,
|
||||
plannedRepairs: [],
|
||||
stateRefreshed: true,
|
||||
warnings: desiredPlan.warnings,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'error',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
warnings: desiredPlan.warnings,
|
||||
error: `Missing source file(s): ${operationHealth.missingSource.map(entry => entry.sourcePath).join(', ')}`
|
||||
error: error.message
|
||||
};
|
||||
} finally {
|
||||
if (releaseSettingsLock) releaseSettingsLock();
|
||||
}
|
||||
|
||||
const repairOperations = [
|
||||
...operationHealth.missing.map(entry => ({ ...entry.operation })),
|
||||
...operationHealth.drifted.map(entry => ({ ...entry.operation })),
|
||||
...desiredPlan.operations
|
||||
.filter(operation => (
|
||||
operation.kind === 'update-claude-settings'
|
||||
&& operation.previousManagedHooks
|
||||
&& !isDeepStrictEqual(operation.previousManagedHooks, operation.managedHooks)
|
||||
))
|
||||
.map(operation => ({ ...operation })),
|
||||
].filter((operation, index, items) => items.findIndex(candidate => (
|
||||
candidate.kind === operation.kind
|
||||
&& candidate.destinationPath === operation.destinationPath
|
||||
)) === index);
|
||||
const repairLinkIndex = buildLinkIndexForOperations(desiredPlan.operations, record.targetRoot);
|
||||
const legacyMigrationPaths = migration.legacyOperationsToRemove.map(
|
||||
operation => operation.destinationPath
|
||||
);
|
||||
const plannedRepairs = [...new Set([
|
||||
...(needsOpencodeBuild ? [opencodeBuildRepairPath] : []),
|
||||
...repairOperations.map(operation => operation.destinationPath),
|
||||
...legacyMigrationPaths,
|
||||
])];
|
||||
|
||||
if (options.dryRun) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: plannedRepairs.length > 0 ? 'planned' : 'ok',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs,
|
||||
stateRefreshed: plannedRepairs.length === 0,
|
||||
warnings: desiredPlan.warnings,
|
||||
error: null
|
||||
};
|
||||
}
|
||||
|
||||
const hasLegacyMigration = migration.legacyOperationsToRemove.length > 0;
|
||||
const repairedPaths = needsOpencodeBuild ? [opencodeBuildRepairPath] : [];
|
||||
if (migration.requiresBridgeState && (repairOperations.length > 0 || hasLegacyMigration)) {
|
||||
writeRefreshedInstallState(record, migration.bridgeState);
|
||||
}
|
||||
|
||||
for (const operation of repairOperations) {
|
||||
if (record.adapter.id === 'codex-home') {
|
||||
assertNoNewUserOwnedFile(migration, operation, desiredPlan);
|
||||
};
|
||||
if (record.adapter.target !== 'opencode' || options.dryRun) return performRepair();
|
||||
let repairResult;
|
||||
try {
|
||||
const adapter = getInstallTargetAdapter('opencode');
|
||||
const targetRoot = adapter.resolveRoot({
|
||||
homeDir: context.homeDir, projectRoot: context.projectRoot,
|
||||
repoRoot: context.projectRoot, env: context.env,
|
||||
});
|
||||
const { getOpenCodeInstallRoots, assertOpenCodeLeaseCoverage } = require('./install/apply');
|
||||
const roots = getOpenCodeInstallRoots({ adapter, targetRoot, homeDir: context.homeDir });
|
||||
return withOpenCodeInstallLocks(roots, lease => {
|
||||
assertOpenCodeLeaseCoverage({ adapter, targetRoot, homeDir: context.homeDir }, lease);
|
||||
// Discovery precedes acquisition. Never repair from that stale state.
|
||||
record = buildDiscoveryRecord(adapter, context, record.legacyLayout === 'opencode'
|
||||
? getLegacyOpencodeLocation(context.homeDir) : null);
|
||||
if (!record.exists || record.error) {
|
||||
throw new Error(record.error || 'OpenCode install-state disappeared before repair.');
|
||||
}
|
||||
const repairedPath = executeRepairOperation(
|
||||
context.repoRoot,
|
||||
operation,
|
||||
record.targetRoot,
|
||||
repairLinkIndex,
|
||||
record.adapter.target,
|
||||
Boolean(releaseSettingsLock)
|
||||
);
|
||||
if (repairedPath) {
|
||||
repairedPaths.push(repairedPath);
|
||||
}
|
||||
}
|
||||
if (hasLegacyMigration) {
|
||||
for (const operation of migration.legacyOperationsToRemove) {
|
||||
const removedPath = removeContainedPath(
|
||||
operation.destinationPath,
|
||||
record.targetRoot,
|
||||
'migrate managed Claude skill',
|
||||
{ force: true }
|
||||
);
|
||||
if (removedPath) {
|
||||
repairedPaths.push(removedPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
const changedInstalledBytes = repairOperations.length > 0
|
||||
|| needsOpencodeBuild
|
||||
|| hasLegacyMigration;
|
||||
const statePreviewToWrite = changedInstalledBytes
|
||||
? desiredPlan.statePreview
|
||||
: {
|
||||
...desiredPlan.statePreview,
|
||||
installedAt: record.state.installedAt,
|
||||
source: { ...record.state.source },
|
||||
};
|
||||
writeRefreshedInstallState(record, statePreviewToWrite, repairedPaths);
|
||||
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: (repairOperations.length > 0 || needsOpencodeBuild || hasLegacyMigration)
|
||||
? 'repaired'
|
||||
: 'ok',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths,
|
||||
plannedRepairs: [],
|
||||
stateRefreshed: true,
|
||||
warnings: desiredPlan.warnings,
|
||||
error: null
|
||||
};
|
||||
repairResult = performRepair(lease);
|
||||
return repairResult;
|
||||
});
|
||||
} catch (error) {
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
status: 'error',
|
||||
installStatePath: record.installStatePath,
|
||||
repairedPaths: [],
|
||||
plannedRepairs: [],
|
||||
error: error.message
|
||||
};
|
||||
} finally {
|
||||
if (releaseSettingsLock) releaseSettingsLock();
|
||||
if (repairResult?.status === 'error') {
|
||||
return { ...repairResult, releaseError: error.message };
|
||||
}
|
||||
return { adapter: record.adapter, status: 'error', installStatePath: record.installStatePath,
|
||||
repairedPaths: repairResult?.repairedPaths || [], plannedRepairs: [], error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ const COMPONENT_FAMILY_PREFIXES = {
|
||||
skill: 'skill:',
|
||||
locale: 'locale:',
|
||||
};
|
||||
const SUPPORTED_LOCALES = Object.freeze(['ja', 'zh-CN', 'ko-KR', 'pt-BR', 'ru', 'tr', 'vi-VN', 'zh-TW', 'de-DE', 'uk-UA', 'bn']);
|
||||
const SUPPORTED_LOCALES = Object.freeze(['ja', 'zh-CN', 'ko-KR', 'pt-BR', 'ru', 'tr', 'vi-VN', 'zh-TW', 'de-DE', 'uk-UA', 'pl', 'bn']);
|
||||
const LOCALE_ALIAS_TO_COMPONENT_ID = Object.freeze({
|
||||
'ja': 'locale:ja',
|
||||
'ja-JP': 'locale:ja',
|
||||
@@ -34,6 +34,8 @@ const LOCALE_ALIAS_TO_COMPONENT_ID = Object.freeze({
|
||||
'de': 'locale:de-de',
|
||||
'uk-UA': 'locale:uk-ua',
|
||||
'uk': 'locale:uk-ua',
|
||||
'pl': 'locale:pl',
|
||||
'pl-PL': 'locale:pl',
|
||||
'bn': 'locale:bn',
|
||||
'bn-BD': 'locale:bn'
|
||||
});
|
||||
|
||||
@@ -9,7 +9,16 @@ const {
|
||||
withCommitAttributionDisabled,
|
||||
} = require('../claude-commit-attribution');
|
||||
const { readInstallState, writeInstallState } = require('../install-state');
|
||||
const { assertHookConsentReady, planMaterializesHookRuntime } = require('./hook-consent');
|
||||
const {
|
||||
assertHookConsentReady,
|
||||
disableOpenCodeHookPluginRegistration,
|
||||
getDisabledOpenCodePluginContent,
|
||||
getRecordedHookConsent,
|
||||
getOpenCodeActivationPathKind,
|
||||
getOpenCodeSourceActivationKind,
|
||||
planMaterializesHookRuntime,
|
||||
shouldDisableOpenCodeHooks,
|
||||
} = require('./hook-consent');
|
||||
const {
|
||||
getClaudeSettingsPath,
|
||||
mergeManagedHooks,
|
||||
@@ -33,7 +42,10 @@ const {
|
||||
prepareUserOwnedFileGuard,
|
||||
preserveUnwrittenFiles,
|
||||
} = require('./ownership-guard');
|
||||
const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration');
|
||||
const { cleanupLegacyOpencodeInstall, getLegacyLocationForPlan, inspectLegacyOpencodeState,
|
||||
verifyManagedLegacyFile } = require('./opencode-legacy-migration');
|
||||
const { writeFileNoFollow } = require('./guarded-write');
|
||||
const { withOpenCodeInstallLocks } = require('./opencode-install-lock');
|
||||
const {
|
||||
completeExcludedPathsReconciliation,
|
||||
prepareExcludedPathsReconciliation,
|
||||
@@ -52,6 +64,12 @@ function transformInstallContent(operation, content) {
|
||||
if (operation.contentTransform === 'antigravity-agent-frontmatter') {
|
||||
return adaptAntigravityAgent(content, operation.sourceRelativePath);
|
||||
}
|
||||
if (operation.contentTransform === 'opencode-disable-ecc-hooks') {
|
||||
return disableOpenCodeHookPluginRegistration(content, operation.sourceRelativePath);
|
||||
}
|
||||
if (operation.contentTransform === 'opencode-disable-plugin-entrypoint') {
|
||||
return getDisabledOpenCodePluginContent();
|
||||
}
|
||||
throw new Error(`Unknown install content transform: ${operation.contentTransform}`);
|
||||
}
|
||||
|
||||
@@ -292,6 +310,254 @@ function comparablePath(filePath) {
|
||||
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||
}
|
||||
|
||||
function getOpenCodeActivationKind(plan, operation) {
|
||||
const relative = operation.destinationPath
|
||||
? path.relative(plan.targetRoot, operation.destinationPath).split(path.sep).join('/').toLowerCase()
|
||||
: '';
|
||||
return getOpenCodeActivationPathKind(relative) || getOpenCodeSourceActivationKind(operation);
|
||||
}
|
||||
|
||||
function readOpenCodeAliasForAttribution(plan, destinationPath) {
|
||||
try {
|
||||
const operation = { destinationPath };
|
||||
assertSafeInstallOperation(plan, operation);
|
||||
if (!fs.lstatSync(destinationPath).isFile()) return null;
|
||||
return readInstalledFileNoFollow(plan, operation);
|
||||
} catch {
|
||||
// Optional, unrecorded aliases have no ECC ownership until their bytes
|
||||
// prove it. Never follow an unsafe path or relax recorded/planned guards.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Finite, exact public ECC entrypoint history reachable from d3b8a3e908904e242ed2dbe66af62cca71131419.
|
||||
// Refusal evidence only: matching bytes never grant ownership or permission to
|
||||
// adopt, rewrite or delete an unrecorded file. Unknown modified/compiled variants
|
||||
// are not covered. No history lookup or plugin execution occurs at runtime.
|
||||
const LEGACY_OPENCODE_PLUGIN_DIGESTS = Object.freeze([
|
||||
// a0600a00fbe3a193a44584ad55800ce82cec62af:.opencode/plugins/index.ts (blob 3a98f0ba6510d436cc9cf3e2161f8f69771d968a)
|
||||
'7dd2d255da5d4344eb38ca93cf1765e425b0c01943f6e45428614662ebee0d4b',
|
||||
// a0600a00fbe3a193a44584ad55800ce82cec62af:.opencode/plugins/ecc-hooks.ts (blob bf06c03f8ff6bdd835c5266921758a6db85152bf)
|
||||
'5db9b59434af0d5971538f0176779733b8146d7a71fbd9055ae0183c26754068',
|
||||
// 91ba9b4cf6c47c8130829004f8bb64762a76ccbb:.opencode/plugins/ecc-hooks.ts (blob 4aabde61203d4473e04d5a10803b0560b8c596e4)
|
||||
'c683b9321d8b5fbc6889b1740f4583c4f94c84554ee97e2072f61de45c661bda',
|
||||
// 1a8beb71c5282ddfe77c72ab0290961a820e3d89:.opencode/plugins/ecc-hooks.ts (blob 69b59727e552991a79c196aab8ec128173329d9a)
|
||||
'1e890ce162325c9d7c579b0716383b6c297179edb78084c4b59cc8bf766ceb71',
|
||||
// e65f12bf7ea474a6f5ac96991a251673f474b445:.opencode/plugins/index.ts (blob c1e17a1595403080490fcec6820c1485bb6afba9)
|
||||
'965c5fac76ce0c3ceb3836814f5eb9ede8c9db50373a508c734f949cb321a21a',
|
||||
// e65f12bf7ea474a6f5ac96991a251673f474b445:.opencode/plugins/ecc-hooks.ts (blob 54881ad868c276ff0d50cc83d8ae938464ad02da)
|
||||
'5c043b84693a654fffe4b407e87411b28c9af8b92f5ef49a03caab7b27f03102',
|
||||
// 2cdc218c45a81ce46035832b13bf68d91137301e:.opencode/plugins/ecc-hooks.ts (blob d496e61a538131ff6f33b2e6d941544e3d94c5d8)
|
||||
'73692e599d271bbb9b7aac59f97e193518af2b5db3a3505af0376c8d8657220a',
|
||||
// 5929d246946eeb5d147612ba06d60c575c5a4e21:.opencode/plugins/ecc-hooks.ts (blob 472f80f5ae9500fa9a0a7885b6ce4dc4b409d3d6)
|
||||
'd7a410380ed2e0bcb613110b2810221d03a8944e50766bc7a4db2eb1b44446b6',
|
||||
// ca185ef5f7667078a1e70a763bd3a9c71c48acf0:.opencode/plugins/ecc-hooks.ts (blob 22b1132f0964bd4ba5c1a4ad1bafa605de99eb6a)
|
||||
'0345093b34e537d350c5b5aa0296511f558aa767e5104fb5ef05069013f3b5b6',
|
||||
// 28e53a0bc10e286f68b53bb1e3b3f049021e57b9:.opencode/plugins/ecc-hooks.ts (blob 47265c0ebd031168d8e3a18f30036864338cd22c)
|
||||
'e20ecd53714b1fd55baeff796c6f4538ebd4bae71ca1e791b2b8e29575061846',
|
||||
// 591ab5cbd3f2f65860ea91c226e410b1502c8e2e:.opencode/plugins/ecc-hooks.ts (blob 49124c255003eb5517178a8ecad7dd453303df33)
|
||||
'b9c22c76ae2464c9410963579ee5ff49003e4d79e32b69c228539ae7b15f5104',
|
||||
// 6f452d48d258b39f4f6e1171b7ca18c6f7f61ad5:.opencode/plugins/ecc-hooks.ts (blob 6336081e97c4345f02adfdc0b9ad9e27dccdec04)
|
||||
'c7122565cf97b896cc3da9009bf06daca7513b3e9ba7448c26b8872591dbf3f7',
|
||||
// 3a08b0c7a85bda69ee9922a103e077a04d538150:.opencode/plugins/ecc-hooks.ts (blob bad6a4cecf2270a7d8a919daeb6541c94a810c48)
|
||||
'e438603c13206365068b400063df0af98bd587842b80f09b97fe57f7ddef56e0',
|
||||
// 29edd57708bee26f16363c16a28fec7f6b09f53f:.opencode/plugins/ecc-hooks.ts (blob 05792ce9ae86a785b746bdb843572e4b5bc93130)
|
||||
'6a9063b2f67334a78d269d53f95679c33d6d126260f8e5fc7cc941fea9b903e8',
|
||||
// 8141f6904f14fa8a83131e1cb5b6507d687e25bb:.opencode/plugins/ecc-hooks.ts (blob 606bcb7c59aa5e459d2093ffb9cf9208d1184c30)
|
||||
'a198b640fd1faf1c75e96909eabf4ae24899de127b2447490eed813766013836',
|
||||
// 6d613f67dd24189a8bb7fb1a2f5e535957f46a58:.opencode/plugins/index.ts (blob ca58596901d816147ac4eff525f1a885d36bd094)
|
||||
'e89aaa309b7a0578bb69af4a2425744fcf14c2556cd34a1036bbcba448a0b517',
|
||||
// 6d613f67dd24189a8bb7fb1a2f5e535957f46a58:.opencode/plugins/ecc-hooks.ts (blob 31cfa8ac31ac3cbc5c51b4b275017ef18b8f9033)
|
||||
'd66a43e43ef9669589de593e8f94e750ee11d3c75cb5fe79e81636ef738c3e45',
|
||||
// affbd334858368518c5baf5f84f74034dea1ea6f:.opencode/plugins/ecc-hooks.ts (blob ff8628b5fd47181cbee54367dc4e32e5392cde1a)
|
||||
'4874a12639fd58da59a54fe5b2461c0ddd2eeca6770111615caa402ff0e95693',
|
||||
// 0a87323eda77ee412fa3a3bf028a577536966505:.opencode/plugins/ecc-hooks.ts (blob fa96b805685e3c3e6f86535debca5ab8a5bea1ef)
|
||||
'4e2330f340e074208cd323c1a833667032ce8db4febc4eaeda354bc49cb58bc4',
|
||||
// a0a1eda8fc4828e58dc8aabcec4e25f9ef038a0a:.opencode/plugins/ecc-hooks.ts (blob 51bde010b4d426676b52ffc9567b1da80f4ca510)
|
||||
'ca9abadee5d072121677168752fb4f3b16ce9fc7eb55a3c9c7f517377e0bf69b',
|
||||
// 05acc275307a09eea89080619a35d7dbd20b128b:.opencode/plugins/ecc-hooks.ts (blob 9e4ab3fcd50f6610cfdfa5a7d0d71c2374f65745)
|
||||
'96998990d6aac0b9535ab6ab60a0be1c284ffcca7e4ba04f1cfa0e67409a8146',
|
||||
// a2b3cc1600e9cab58147ef01c03f9889b5a8cc86:.opencode/plugins/ecc-hooks.ts (blob 58a209283f70efe1dbfef5d78b4d72764c67f027)
|
||||
'0697bfed6e6ad887443a32810e83adb5316d2c9c0490b98f9fcb6ea52bea84e3',
|
||||
// 0c7deb26a344db095c04a213eba5634d4ccce030:.opencode/plugins/ecc-hooks.ts (blob 9193bb412920a1f1d5af98fda0a66d1e3295f46f)
|
||||
'd666a94e9d0ccbcfdeffd59b624938cd44706571eec3771974c57fdbe28577c1',
|
||||
// 48b883d7412914b04c8b185d9a82685b105d1734:.opencode/plugins/ecc-hooks.ts (blob 3053314750a61dbcdb06a9cca39492304457f582)
|
||||
'16fe21ca801a613a0ae2fc1f8dd5c8474138dc31c75ea35cd8695884397f1f15',
|
||||
// d70bab85e33af7a03b78c70dba7a7ce3b01d1b17:.opencode/plugins/ecc-hooks.ts (blob 1f158d7999f5f100e386587a94a90a08d512e278)
|
||||
'0354270a5fc26809d0795ecc7eef1dee91de4905d58f26d5828d43af24767b96',
|
||||
// 0e9f613fd196f6d4157765b17d39c2c42ebbf564:.opencode/plugins/ecc-hooks.ts (blob 50d23bfde3607832446fda26b25a3ed3e527e5d1)
|
||||
'513190b6c935dac472efd11818b20d7f2479ec1f7be06ef7f4d241c2493ad9e3',
|
||||
// 6d440c036df2c1b2fec957627d1202c3708e0627:.opencode/plugins/index.ts (blob d19a91f1a686d6ed060d08eddeb5aa05a4be6b75)
|
||||
'e42c733adb177f84cea813663aa34c7868dbaa98c96950d0ef91cd211b8aa169',
|
||||
// 6d440c036df2c1b2fec957627d1202c3708e0627:.opencode/plugins/ecc-hooks.ts (blob b64ffae7ce10cab9e5ed9b04cec23d62db9036e7)
|
||||
'503ea491cbeadff5bf59b936a75bff65caaf1d71e47a953a9b9b790be780efef',
|
||||
]);
|
||||
|
||||
function knownOpenCodePluginDigests(plan) {
|
||||
// Historical refusal fingerprints do not depend on a current source checkout.
|
||||
const digests = new Set(LEGACY_OPENCODE_PLUGIN_DIGESTS);
|
||||
if (typeof plan.sourceRoot !== 'string' || !path.isAbsolute(plan.sourceRoot)) return digests;
|
||||
const sourcePlan = { ...plan, targetRoot: plan.sourceRoot };
|
||||
for (const directory of ['.opencode/plugins', '.opencode/dist/plugins']) {
|
||||
for (const name of ['ecc-hooks', 'index']) {
|
||||
for (const extension of ['ts', 'js', 'mjs', 'cjs']) {
|
||||
const content = readOpenCodeAliasForAttribution(sourcePlan,
|
||||
path.join(plan.sourceRoot, directory, `${name}.${extension}`));
|
||||
if (content !== null) digests.add(crypto.createHash('sha256').update(content).digest('hex'));
|
||||
}
|
||||
}
|
||||
}
|
||||
return digests;
|
||||
}
|
||||
|
||||
function openCodeActivationCandidates(plan, previousOperations) {
|
||||
const candidates = new Map();
|
||||
for (const operation of [...previousOperations, ...plan.operations]) {
|
||||
if (getOpenCodeActivationKind(plan, operation) && operation.destinationPath) {
|
||||
candidates.set(comparablePath(operation.destinationPath), operation);
|
||||
}
|
||||
}
|
||||
// Old installs can leave unrecorded aliases, but names such as index.js
|
||||
// are also used by unrelated plugins. Attribute only exact ECC artifacts.
|
||||
const knownDigests = knownOpenCodePluginDigests(plan);
|
||||
for (const name of ['ecc-hooks', 'index']) {
|
||||
for (const extension of ['ts', 'js', 'mjs', 'cjs']) {
|
||||
const destinationPath = path.join(plan.targetRoot, 'plugins', `${name}.${extension}`);
|
||||
const key = comparablePath(destinationPath);
|
||||
if (!candidates.has(key)) {
|
||||
const content = readOpenCodeAliasForAttribution(plan, destinationPath);
|
||||
const digest = content === null ? null : crypto.createHash('sha256').update(content).digest('hex');
|
||||
if (knownDigests.has(digest)) {
|
||||
candidates.set(key, {
|
||||
sourceRelativePath: `.opencode/plugins/${name}.${extension}`,
|
||||
destinationPath,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function activationIsInactive(kind, operation, content) {
|
||||
if (kind === 'plugin') {
|
||||
return content.toString('utf8') === getDisabledOpenCodePluginContent();
|
||||
}
|
||||
const text = content.toString('utf8');
|
||||
// Validate first so malformed JSON retains its source context.
|
||||
disableOpenCodeHookPluginRegistration(text, operation.sourceRelativePath || operation.destinationPath);
|
||||
const config = JSON.parse(text);
|
||||
return !Array.isArray(config.plugin) || !config.plugin.includes('./plugins');
|
||||
}
|
||||
|
||||
function assertOpenCodeHookDeactivationReady(plan, options = {}) {
|
||||
if (!shouldDisableOpenCodeHooks(plan)) {
|
||||
return new Map();
|
||||
}
|
||||
assertSafeInstallOperation(plan, { destinationPath: plan.installStatePath });
|
||||
const previousState = readPreviousInstallState(plan);
|
||||
if (previousState && (
|
||||
previousState.target.id !== plan.adapter.id
|
||||
|| comparablePath(previousState.target.root) !== comparablePath(plan.targetRoot)
|
||||
|| comparablePath(previousState.target.installStatePath) !== comparablePath(plan.installStatePath)
|
||||
)) {
|
||||
throw new Error('Refusing OpenCode hook deactivation: install-state target mismatch.');
|
||||
}
|
||||
const previous = new Map(((previousState && previousState.operations) || [])
|
||||
.filter(operation => operation.ownership === 'managed' && operation.destinationPath)
|
||||
.map(operation => [comparablePath(operation.destinationPath), operation]));
|
||||
const desired = new Map(plan.operations.filter(operation => getOpenCodeActivationKind(plan, operation))
|
||||
.map(operation => [comparablePath(operation.destinationPath), operation]));
|
||||
const snapshot = new Map();
|
||||
for (const [key, operation] of openCodeActivationCandidates(plan, [...previous.values(), ...(options.legacyOperations || [])])) {
|
||||
const kind = getOpenCodeActivationKind(plan, operation);
|
||||
if (kind === 'package') {
|
||||
throw new Error(`Unsupported OpenCode package metadata deactivation: ${operation.destinationPath}`);
|
||||
}
|
||||
const expectedTransform = kind === 'plugin'
|
||||
? 'opencode-disable-plugin-entrypoint' : 'opencode-disable-ecc-hooks';
|
||||
const replacement = desired.get(key);
|
||||
// Validate planned activation even when its destination does not yet exist.
|
||||
// Recorded operations may name an unrelated source or use render-template.
|
||||
if (replacement && (replacement.kind !== 'copy-file'
|
||||
|| replacement.contentTransform !== expectedTransform)) {
|
||||
throw new Error(`Refusing OpenCode hook deactivation: unsupported activation operation at ${operation.destinationPath}`);
|
||||
}
|
||||
const content = readInstalledFileNoFollow(plan, operation);
|
||||
if (content === null && fs.existsSync(operation.destinationPath)) {
|
||||
throw new Error(`Refusing OpenCode hook deactivation: non-file activation at ${operation.destinationPath}`);
|
||||
}
|
||||
const digest = content === null ? null : crypto.createHash('sha256').update(content).digest('hex');
|
||||
snapshot.set(key, digest);
|
||||
if (content === null) continue;
|
||||
const inactive = activationIsInactive(kind, operation, content);
|
||||
if (options.requireInactive) {
|
||||
if (!inactive) {
|
||||
throw new Error(`OpenCode hook activation remains active at ${operation.destinationPath}`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const recorded = previous.get(key);
|
||||
if (inactive && (kind === 'plugin' || options.allowVerifiedLegacyRemoval || !recorded)) continue;
|
||||
if (options.allowVerifiedLegacyRemoval && recorded) {
|
||||
const verified = verifyManagedLegacyFile(recorded, {
|
||||
targetRoot: plan.targetRoot, installStatePath: plan.installStatePath,
|
||||
}, plan.sourceRoot);
|
||||
if (verified.destinationPath && verified.digest === digest) continue;
|
||||
}
|
||||
if (!replacement || replacement.kind !== 'copy-file'
|
||||
|| replacement.contentTransform !== expectedTransform
|
||||
|| !recorded || recorded.contentSha256 !== digest) {
|
||||
throw new Error(`Refusing OpenCode hook deactivation: user-owned, modified, unverifiable or stale activation at ${operation.destinationPath}`);
|
||||
}
|
||||
}
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function assertOpenCodeActivationUnchanged(plan, operation, snapshot) {
|
||||
const key = comparablePath(operation.destinationPath);
|
||||
if (!snapshot.has(key)) return;
|
||||
const content = readInstalledFileNoFollow(plan, operation);
|
||||
const digest = content === null ? null : crypto.createHash('sha256').update(content).digest('hex');
|
||||
if (digest !== snapshot.get(key)) {
|
||||
throw new Error(`Refusing OpenCode hook deactivation: activation changed after preflight at ${operation.destinationPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getOpenCodeActivationWriteOptions(operation, snapshot) {
|
||||
const key = comparablePath(operation.destinationPath);
|
||||
if (!snapshot.has(key)) return {};
|
||||
const digest = snapshot.get(key);
|
||||
return { expectedContent: Object.freeze(digest === null
|
||||
? { kind: 'absent' } : { kind: 'sha256', digest }) };
|
||||
}
|
||||
|
||||
function getOpenCodeInstallRoots(plan) {
|
||||
const roots = [plan.targetRoot];
|
||||
const legacy = getLegacyLocationForPlan(plan);
|
||||
const inspection = inspectLegacyOpencodeState(legacy);
|
||||
if (inspection.status === 'unreadable') throw new Error(inspection.error);
|
||||
if (inspection.status === 'valid') roots.push(legacy.targetRoot);
|
||||
return roots;
|
||||
}
|
||||
|
||||
function inspectLegacyOpenCodeDeactivation(plan) {
|
||||
const location = getLegacyLocationForPlan(plan);
|
||||
if (!location || comparablePath(location.targetRoot) === comparablePath(plan.targetRoot)) return null;
|
||||
const inspection = inspectLegacyOpencodeState(location);
|
||||
if (inspection.status === 'unreadable') throw new Error(inspection.error);
|
||||
if (inspection.status !== 'valid') return null;
|
||||
const legacyPlan = { ...plan, ...location, operations: [] };
|
||||
assertOpenCodeHookDeactivationReady(legacyPlan, { allowVerifiedLegacyRemoval: true });
|
||||
return { plan: legacyPlan, operations: inspection.state.operations.filter(operation => operation.ownership === 'managed') };
|
||||
}
|
||||
|
||||
function assertOpenCodeLeaseCoverage(plan, lease) {
|
||||
if (plan.adapter?.target !== 'opencode') return;
|
||||
// Reuse checks opaque ownership without acquiring extra roots out of order.
|
||||
withOpenCodeInstallLocks(getOpenCodeInstallRoots(plan), () => {}, lease);
|
||||
}
|
||||
|
||||
function findPreviousManagedHooks(previousState, plan, operation) {
|
||||
if (
|
||||
!previousState
|
||||
@@ -347,6 +613,27 @@ function preflightClaudeSettingsOperations(plan) {
|
||||
}
|
||||
|
||||
function prepareHookConsentMigration(plan, migration) {
|
||||
if (shouldDisableOpenCodeHooks(plan) && migration.requiresBridgeState) {
|
||||
const previousState = readPreviousInstallState(plan);
|
||||
if (previousState) {
|
||||
const previousConsent = getRecordedHookConsent(previousState);
|
||||
return {
|
||||
...migration,
|
||||
// A checkpoint is not a completed consent transition. On failure,
|
||||
// retain the previous decision until every activation is inactive.
|
||||
bridgeState: {
|
||||
...migration.bridgeState,
|
||||
request: { ...migration.bridgeState.request, hookConsent: previousConsent },
|
||||
resolution: {
|
||||
...migration.bridgeState.resolution,
|
||||
selectedModules: previousConsent === 'enabled'
|
||||
? [...new Set([...migration.bridgeState.resolution.selectedModules, 'hooks-runtime'])]
|
||||
: migration.bridgeState.resolution.selectedModules,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
if (plan.hookConsent !== 'declined') {
|
||||
return migration;
|
||||
}
|
||||
@@ -400,6 +687,7 @@ function prepareHookConsentMigration(plan, migration) {
|
||||
}
|
||||
|
||||
function previewInstallPlan(plan) {
|
||||
assertOpenCodeHookDeactivationReady(plan);
|
||||
const migration = prepareHookConsentMigration(
|
||||
plan,
|
||||
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
|
||||
@@ -429,6 +717,14 @@ function previewInstallPlan(plan) {
|
||||
|
||||
function applyInstallPlan(plan, dependencies = {}) {
|
||||
assertHookConsentReady(plan);
|
||||
if (plan.adapter?.target === 'opencode') {
|
||||
assertSafeInstallOperation(plan, { destinationPath: plan.installStatePath });
|
||||
return withOpenCodeInstallLocks(
|
||||
getOpenCodeInstallRoots(plan),
|
||||
lease => applyInstallPlanLocked(plan, { ...dependencies, opencodeLease: lease }, false),
|
||||
dependencies.opencodeLease
|
||||
);
|
||||
}
|
||||
const isClaudeManualTarget = plan.adapter
|
||||
&& (plan.adapter.target === 'claude' || plan.adapter.target === 'claude-project');
|
||||
const settingsPathToLock = isClaudeManualTarget
|
||||
@@ -453,6 +749,9 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
if (typeof beforeInstallStateRead === 'function') {
|
||||
beforeInstallStateRead({ plan });
|
||||
}
|
||||
assertOpenCodeLeaseCoverage(plan, dependencies.opencodeLease);
|
||||
const legacyActivation = inspectLegacyOpenCodeDeactivation(plan);
|
||||
const activationSnapshot = assertOpenCodeHookDeactivationReady(plan);
|
||||
const migration = prepareExcludedPathsReconciliation(
|
||||
plan,
|
||||
prepareHookConsentMigration(
|
||||
@@ -499,6 +798,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
beforeOperationWrite({ plan: appliedPlan, operation });
|
||||
}
|
||||
assertNoNewUserOwnedFile(migration, operation, appliedPlan);
|
||||
assertOpenCodeActivationUnchanged(appliedPlan, operation, activationSnapshot);
|
||||
|
||||
if (
|
||||
operation.kind === 'update-claude-settings'
|
||||
@@ -585,7 +885,20 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
index: linkIndex,
|
||||
})
|
||||
: transformed;
|
||||
fs.writeFileSync(operation.destinationPath, installedContent, 'utf8');
|
||||
const writeOptions = getOpenCodeActivationWriteOptions(operation, activationSnapshot);
|
||||
if (writeOptions.expectedContent) {
|
||||
writeFileNoFollow(operation.destinationPath, installedContent, {
|
||||
...writeOptions,
|
||||
action: 'install OpenCode activation',
|
||||
validateDestination(destinationPath) {
|
||||
assertSafeInstallOperation(appliedPlan, { destinationPath });
|
||||
assertSafeClaudeSkillOperation(appliedPlan, { destinationPath });
|
||||
return destinationPath;
|
||||
},
|
||||
});
|
||||
} else {
|
||||
fs.writeFileSync(operation.destinationPath, installedContent, 'utf8');
|
||||
}
|
||||
writtenDestinations.add(operation.destinationPath);
|
||||
continue;
|
||||
}
|
||||
@@ -605,6 +918,9 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
);
|
||||
}
|
||||
|
||||
// Include preserved user configs omitted from the write plan: they must
|
||||
// still be inactive before we record a completed install.
|
||||
assertOpenCodeHookDeactivationReady(plan, { requireInactive: true });
|
||||
finalState = stateWithContentDigests(migration.finalState, appliedPlan);
|
||||
if (typeof beforeInstallStateWrite === 'function') {
|
||||
beforeInstallStateWrite({ plan: appliedPlan, state: finalState });
|
||||
@@ -658,6 +974,9 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
];
|
||||
}
|
||||
|
||||
assertOpenCodeLeaseCoverage(appliedPlan, dependencies.opencodeLease);
|
||||
// Recheck removable bytes after canonical writes, before legacy cleanup.
|
||||
inspectLegacyOpenCodeDeactivation(appliedPlan);
|
||||
let opencodeMigrationWarnings = [];
|
||||
try {
|
||||
const opencodeMigration = cleanupLegacyOpencodeInstall(appliedPlan);
|
||||
@@ -673,6 +992,12 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
];
|
||||
}
|
||||
|
||||
if (legacyActivation) {
|
||||
assertOpenCodeHookDeactivationReady(legacyActivation.plan, {
|
||||
requireInactive: true, legacyOperations: legacyActivation.operations,
|
||||
});
|
||||
}
|
||||
|
||||
let excludedPathsRemoved = [];
|
||||
let excludedPathsWarnings = [];
|
||||
try {
|
||||
@@ -705,6 +1030,13 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
|
||||
module.exports = {
|
||||
applyInstallPlan,
|
||||
assertOpenCodeActivationUnchanged,
|
||||
assertOpenCodeLeaseCoverage,
|
||||
assertOpenCodeHookDeactivationReady,
|
||||
getOpenCodeActivationKind,
|
||||
getOpenCodeActivationWriteOptions,
|
||||
getOpenCodeInstallRoots,
|
||||
assertSafeInstallOperation,
|
||||
prepareHookConsentMigration,
|
||||
previewInstallPlan,
|
||||
};
|
||||
|
||||
@@ -5,6 +5,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const INVALID_LOCK_STALE_MS = 5 * 60 * 1000;
|
||||
const acquiredLockIdentities = new WeakMap();
|
||||
|
||||
function sameFileIdentity(left, right) {
|
||||
if (left.ino !== right.ino) {
|
||||
@@ -19,7 +20,7 @@ function sameFileIdentity(left, right) {
|
||||
return left.dev === right.dev;
|
||||
}
|
||||
|
||||
function createSettingsLock(lockPath) {
|
||||
function createSettingsLock(lockPath, label = 'Claude settings') {
|
||||
const tempPath = `${lockPath}.create-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
|
||||
let descriptor;
|
||||
let ownedStats;
|
||||
@@ -43,18 +44,28 @@ function createSettingsLock(lockPath) {
|
||||
fs.rmSync(tempPath, { force: true });
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
const release = () => {
|
||||
if (released) return;
|
||||
const quarantinePath = `${lockPath}.release-${process.pid}-${crypto.randomBytes(8).toString('hex')}`;
|
||||
fs.renameSync(lockPath, quarantinePath);
|
||||
const quarantinedStats = fs.lstatSync(quarantinePath, { bigint: true });
|
||||
if (!sameFileIdentity(quarantinedStats, ownedStats)) {
|
||||
if (!fs.existsSync(lockPath)) fs.renameSync(quarantinePath, lockPath);
|
||||
throw new Error(`Refusing to release a changed Claude settings lock: ${lockPath}`);
|
||||
throw new Error(`Refusing to release a changed ${label} lock: ${lockPath}`);
|
||||
}
|
||||
released = true;
|
||||
fs.rmSync(quarantinePath, { force: true });
|
||||
};
|
||||
// Retain the identity observed through the creation descriptor. A pathname
|
||||
// sampled after publication may already refer to a replacement lock.
|
||||
acquiredLockIdentities.set(release, Object.freeze({ dev: ownedStats.dev, ino: ownedStats.ino }));
|
||||
return release;
|
||||
}
|
||||
|
||||
function getSettingsLockIdentity(release) {
|
||||
const identity = acquiredLockIdentities.get(release);
|
||||
if (!identity) throw new Error('No acquired settings lock identity for this release function.');
|
||||
return identity;
|
||||
}
|
||||
|
||||
function inspectSettingsLock(lockPath) {
|
||||
@@ -91,7 +102,7 @@ function processIsAlive(pid) {
|
||||
}
|
||||
}
|
||||
|
||||
function recoverSettingsLock(lockPath) {
|
||||
function recoverSettingsLock(lockPath, label = 'Claude settings') {
|
||||
const recoveryPath = `${lockPath}.recover`;
|
||||
try {
|
||||
fs.mkdirSync(recoveryPath, { mode: 0o700 });
|
||||
@@ -106,7 +117,7 @@ function recoverSettingsLock(lockPath) {
|
||||
try {
|
||||
inspected = inspectSettingsLock(lockPath);
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return createSettingsLock(lockPath);
|
||||
if (error && error.code === 'ENOENT') return createSettingsLock(lockPath, label);
|
||||
throw error;
|
||||
}
|
||||
const validOwner = Number.isSafeInteger(inspected.metadata && inspected.metadata.pid)
|
||||
@@ -123,27 +134,27 @@ function recoverSettingsLock(lockPath) {
|
||||
return null;
|
||||
}
|
||||
fs.rmSync(quarantinePath, { force: true });
|
||||
return createSettingsLock(lockPath);
|
||||
return createSettingsLock(lockPath, label);
|
||||
} finally {
|
||||
fs.rmSync(recoveryPath, { recursive: true, force: true });
|
||||
fs.rmSync(quarantinePath, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function acquireSettingsLock(settingsPath) {
|
||||
function acquireSettingsLock(settingsPath, { label = 'Claude settings' } = {}) {
|
||||
const lockPath = `${settingsPath}.ecc.lock`;
|
||||
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
||||
try {
|
||||
return createSettingsLock(lockPath);
|
||||
return createSettingsLock(lockPath, label);
|
||||
} catch (error) {
|
||||
if (!error || error.code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const recovered = recoverSettingsLock(lockPath);
|
||||
const recovered = recoverSettingsLock(lockPath, label);
|
||||
if (recovered) return recovered;
|
||||
throw new Error(
|
||||
`Another ECC process is updating Claude settings: ${settingsPath}. `
|
||||
`Another ECC process is updating ${label}: ${settingsPath}. `
|
||||
+ `If no ECC process is active, inspect and remove ${lockPath}.`
|
||||
);
|
||||
}
|
||||
@@ -175,6 +186,7 @@ function runWithSettingsLock(settingsPath, callback) {
|
||||
|
||||
module.exports = {
|
||||
acquireSettingsLock,
|
||||
getSettingsLockIdentity,
|
||||
runWithSettingsLock,
|
||||
sameFileIdentity,
|
||||
};
|
||||
|
||||
@@ -10,7 +10,9 @@ let cachedValidator = null;
|
||||
|
||||
function readJson(filePath, label) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
// Windows PowerShell 5.1 writes a BOM with Set-Content -Encoding UTF8.
|
||||
const content = fs.readFileSync(filePath, 'utf8').replace(/^\uFEFF/, '');
|
||||
return JSON.parse(content);
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid JSON in ${label}: ${error.message}`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { sameFileIdentity } = require('./claude-settings-lock');
|
||||
|
||||
function snapshotExpectedContent(expectedContent) {
|
||||
if (expectedContent === undefined) return undefined;
|
||||
if (expectedContent && expectedContent.kind === 'absent') {
|
||||
return Object.freeze({ kind: 'absent' });
|
||||
}
|
||||
if (expectedContent && expectedContent.kind === 'sha256'
|
||||
&& typeof expectedContent.digest === 'string' && /^[a-f0-9]{64}$/i.test(expectedContent.digest)) {
|
||||
return Object.freeze({ kind: 'sha256', digest: expectedContent.digest.toLowerCase() });
|
||||
}
|
||||
throw new TypeError('Invalid expectedContent for guarded write.');
|
||||
}
|
||||
|
||||
function changedDestination(action, filePath, expectedContent, cause) {
|
||||
const message = expectedContent
|
||||
? `Refusing OpenCode hook deactivation: activation changed after preflight at ${filePath}`
|
||||
: `Refusing to ${action}: managed destination changed during the write.`;
|
||||
const error = new Error(message);
|
||||
if (cause) {
|
||||
error.cause = cause;
|
||||
if (cause.code) error.code = cause.code;
|
||||
}
|
||||
return error;
|
||||
}
|
||||
|
||||
function writeFileNoFollow(filePath, content, {
|
||||
mode,
|
||||
action = 'write managed file',
|
||||
validateDestination,
|
||||
expectedContent: requestedContent,
|
||||
} = {}) {
|
||||
if (typeof validateDestination !== 'function') {
|
||||
throw new TypeError('writeFileNoFollow requires validateDestination.');
|
||||
}
|
||||
const destination = path.resolve(filePath);
|
||||
const expectedContent = snapshotExpectedContent(requestedContent);
|
||||
const bytes = typeof content === 'string' ? Buffer.from(content) : content;
|
||||
if (!Buffer.isBuffer(bytes)) throw new TypeError('Managed file content must be a string or Buffer.');
|
||||
const changed = cause => changedDestination(action, destination, expectedContent, cause);
|
||||
function validatePath() {
|
||||
const validated = validateDestination(destination);
|
||||
if (typeof validated !== 'string' || path.resolve(validated) !== destination) throw changed();
|
||||
}
|
||||
function parentStats() {
|
||||
const stats = fs.lstatSync(path.dirname(destination), { bigint: true });
|
||||
if (!stats.isDirectory() || stats.isSymbolicLink()) throw changed();
|
||||
return stats;
|
||||
}
|
||||
validatePath();
|
||||
const parent = parentStats();
|
||||
const flags = (expectedContent ? fs.constants.O_RDWR : fs.constants.O_WRONLY)
|
||||
| (!expectedContent || expectedContent.kind === 'absent' ? fs.constants.O_CREAT : 0)
|
||||
| (expectedContent && expectedContent.kind === 'absent' ? fs.constants.O_EXCL : 0)
|
||||
| (fs.constants.O_NOFOLLOW || 0);
|
||||
let descriptor;
|
||||
try {
|
||||
descriptor = fs.openSync(destination, flags, mode);
|
||||
} catch (error) {
|
||||
if (expectedContent) throw changed(error);
|
||||
throw error;
|
||||
}
|
||||
function assertPinned() {
|
||||
validatePath();
|
||||
const descriptorStat = fs.fstatSync(descriptor, { bigint: true });
|
||||
const liveStat = fs.lstatSync(destination, { bigint: true });
|
||||
if (!sameFileIdentity(parent, parentStats()) || !descriptorStat.isFile()
|
||||
|| !liveStat.isFile() || liveStat.isSymbolicLink() || !sameFileIdentity(descriptorStat, liveStat)) {
|
||||
throw changed();
|
||||
}
|
||||
return descriptorStat;
|
||||
}
|
||||
let primaryError;
|
||||
try {
|
||||
const before = assertPinned();
|
||||
if (expectedContent && expectedContent.kind === 'absent' && before.size !== 0n) throw changed();
|
||||
if (expectedContent && expectedContent.kind === 'sha256') {
|
||||
const digest = crypto.createHash('sha256').update(fs.readFileSync(descriptor)).digest('hex');
|
||||
const after = assertPinned();
|
||||
if (!sameFileIdentity(before, after) || before.size !== after.size
|
||||
|| before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs
|
||||
|| digest !== expectedContent.digest) throw changed();
|
||||
}
|
||||
// Observed changes are rejected before truncation. This is not a CAS against
|
||||
// arbitrary editors: callers coordinate participating writers with a lease.
|
||||
fs.ftruncateSync(descriptor, 0);
|
||||
for (let offset = 0; offset < bytes.length;) {
|
||||
const written = fs.writeSync(descriptor, bytes, offset, bytes.length - offset, offset);
|
||||
if (!Number.isInteger(written) || written <= 0 || written > bytes.length - offset) {
|
||||
throw new Error(`Refusing to ${action}: file write made no valid progress.`);
|
||||
}
|
||||
offset += written;
|
||||
}
|
||||
if (mode !== undefined) fs.fchmodSync(descriptor, mode);
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
} finally {
|
||||
try {
|
||||
fs.closeSync(descriptor);
|
||||
} catch (error) {
|
||||
if (primaryError) primaryError.closeError = error;
|
||||
else primaryError = error;
|
||||
}
|
||||
}
|
||||
// An exclusive creation that later fails is not unlinked: the pathname may
|
||||
// already belong to another writer. Keep the refusal visible to the caller.
|
||||
if (primaryError) throw primaryError;
|
||||
}
|
||||
|
||||
module.exports = { writeFileNoFollow };
|
||||
@@ -38,11 +38,65 @@ const HOOK_CAPABILITY_GROUPS = Object.freeze([
|
||||
|
||||
const HOOK_CONSENT_DECISIONS = Object.freeze(['enabled', 'declined']);
|
||||
const HOOK_RUNTIME_MODULE_ID = 'hooks-runtime';
|
||||
const OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM = 'opencode-disable-ecc-hooks';
|
||||
const OPENCODE_DISABLE_PLUGIN_TRANSFORM = 'opencode-disable-plugin-entrypoint';
|
||||
|
||||
function normalizeOperationPath(value) {
|
||||
return String(value || '').replace(/\\/g, '/').toLowerCase();
|
||||
}
|
||||
|
||||
function disableOpenCodeHookPluginRegistration(content, sourceRelativePath) {
|
||||
let config;
|
||||
try {
|
||||
config = JSON.parse(content);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse ${sourceRelativePath}: ${error.message}`);
|
||||
}
|
||||
if (!config || typeof config !== 'object' || Array.isArray(config)) {
|
||||
throw new Error(`Invalid ${sourceRelativePath}: expected a JSON object`);
|
||||
}
|
||||
if (config.plugin !== undefined && !Array.isArray(config.plugin)) {
|
||||
throw new Error(`Invalid ${sourceRelativePath}: plugin must be an array`);
|
||||
}
|
||||
|
||||
if (!Array.isArray(config.plugin)) {
|
||||
return `${JSON.stringify(config, null, 2)}\n`;
|
||||
}
|
||||
|
||||
return `${JSON.stringify({
|
||||
...config,
|
||||
plugin: config.plugin.filter(plugin => plugin !== './plugins'),
|
||||
}, null, 2)}\n`;
|
||||
}
|
||||
|
||||
function getOpenCodeActivationPathKind(value) {
|
||||
const relative = normalizeOperationPath(value);
|
||||
if (relative === 'opencode.json') return 'config';
|
||||
if (/^plugins\/[^/]+\/package\.json$/.test(relative)) return 'package';
|
||||
if (/^plugins\/(?:[^/]+\.(?:[cm]?js|ts)|[^/]+\/index\.(?:[cm]?js|ts))$/.test(relative)) return 'plugin';
|
||||
return null;
|
||||
}
|
||||
|
||||
function getOpenCodeSourceActivationKind(operation = {}) {
|
||||
const source = normalizeOperationPath(operation.sourceRelativePath);
|
||||
if (!source.startsWith('.opencode/')) return null;
|
||||
return getOpenCodeActivationPathKind(source.replace(/^\.opencode\/(?:dist\/)?/, ''));
|
||||
}
|
||||
|
||||
function isOpenCodePluginEntrypoint(operation = {}) {
|
||||
return getOpenCodeSourceActivationKind(operation) === 'plugin';
|
||||
}
|
||||
|
||||
function getDisabledOpenCodePluginContent() {
|
||||
// OpenCode discovers plugins independently of opencode.json registration.
|
||||
// Do not import the original module: even module initialization has effects.
|
||||
return 'export default async () => ({});\n';
|
||||
}
|
||||
|
||||
function isOpenCodeHookActivationOperation(operation = {}) {
|
||||
return getOpenCodeSourceActivationKind(operation) !== null;
|
||||
}
|
||||
|
||||
function isHookRuntimeOperation(operation = {}) {
|
||||
if (
|
||||
operation.kind === 'update-claude-settings'
|
||||
@@ -51,6 +105,15 @@ function isHookRuntimeOperation(operation = {}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isOpenCodeHookActivationOperation(operation)) {
|
||||
return !(
|
||||
operation.kind === 'copy-file'
|
||||
&& operation.contentTransform === (isOpenCodePluginEntrypoint(operation)
|
||||
? OPENCODE_DISABLE_PLUGIN_TRANSFORM
|
||||
: OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM)
|
||||
);
|
||||
}
|
||||
|
||||
const source = normalizeOperationPath(operation.sourceRelativePath);
|
||||
const destination = normalizeOperationPath(operation.destinationPath);
|
||||
return (
|
||||
@@ -93,6 +156,60 @@ function withoutHookRuntimeId(values) {
|
||||
return (Array.isArray(values) ? values : []).filter(value => value !== HOOK_RUNTIME_MODULE_ID);
|
||||
}
|
||||
|
||||
function withoutOpenCodeHookActivation(operation) {
|
||||
if (getOpenCodeSourceActivationKind(operation) === 'package') {
|
||||
throw new Error(`Unsupported OpenCode package metadata deactivation: ${operation.sourceRelativePath}`);
|
||||
}
|
||||
if (
|
||||
!isOpenCodeHookActivationOperation(operation)
|
||||
|| operation.kind !== 'copy-file'
|
||||
) {
|
||||
return operation;
|
||||
}
|
||||
return {
|
||||
...operation,
|
||||
contentTransform: isOpenCodePluginEntrypoint(operation)
|
||||
? OPENCODE_DISABLE_PLUGIN_TRANSFORM
|
||||
: OPENCODE_DISABLE_ECC_HOOKS_TRANSFORM,
|
||||
};
|
||||
}
|
||||
|
||||
function transformOpenCodeHookActivationOperations(operations) {
|
||||
return (Array.isArray(operations) ? operations : []).map(withoutOpenCodeHookActivation);
|
||||
}
|
||||
|
||||
function planSelectsHookRuntime(plan = {}) {
|
||||
return (
|
||||
Array.isArray(plan.selectedModuleIds)
|
||||
&& plan.selectedModuleIds.includes(HOOK_RUNTIME_MODULE_ID)
|
||||
) || (
|
||||
Array.isArray(plan.operations)
|
||||
&& plan.operations.some(operation => operation.moduleId === HOOK_RUNTIME_MODULE_ID)
|
||||
);
|
||||
}
|
||||
|
||||
function disableUnselectedOpenCodeHooks(plan) {
|
||||
if (plan.target !== 'opencode' || planSelectsHookRuntime(plan)) {
|
||||
return plan;
|
||||
}
|
||||
|
||||
return {
|
||||
...plan,
|
||||
operations: transformOpenCodeHookActivationOperations(plan.operations),
|
||||
statePreview: plan.statePreview
|
||||
? {
|
||||
...plan.statePreview,
|
||||
operations: transformOpenCodeHookActivationOperations(plan.statePreview.operations),
|
||||
}
|
||||
: plan.statePreview,
|
||||
};
|
||||
}
|
||||
|
||||
function shouldDisableOpenCodeHooks(plan = {}) {
|
||||
return plan.target === 'opencode'
|
||||
&& (plan.hookConsent === 'declined' || !planSelectsHookRuntime(plan));
|
||||
}
|
||||
|
||||
function setStatePreviewHookConsent(statePreview, hookConsent) {
|
||||
if (!statePreview || !statePreview.request) {
|
||||
return statePreview;
|
||||
@@ -123,7 +240,11 @@ function getRecordedHookConsent(state = {}) {
|
||||
return 'enabled';
|
||||
}
|
||||
|
||||
if (planMaterializesHookRuntime(state)) {
|
||||
// Older OpenCode installs copied activation files without asking for consent.
|
||||
// Their presence cannot establish that the user opted in to automatic hooks.
|
||||
if ((Array.isArray(state.operations) ? state.operations : []).some(operation => (
|
||||
!isOpenCodeHookActivationOperation(operation) && isHookRuntimeOperation(operation)
|
||||
))) {
|
||||
return 'enabled';
|
||||
}
|
||||
|
||||
@@ -134,11 +255,17 @@ function stripHookRuntimeFromPlan(plan) {
|
||||
const hadHookRuntimeModule = Array.isArray(plan.selectedModuleIds)
|
||||
&& plan.selectedModuleIds.includes('hooks-runtime');
|
||||
const operations = (Array.isArray(plan.operations) ? plan.operations : [])
|
||||
.map(operation => (
|
||||
plan.target === 'opencode' ? withoutOpenCodeHookActivation(operation) : operation
|
||||
))
|
||||
.filter(operation => !isHookRuntimeOperation(operation));
|
||||
const statePreview = plan.statePreview
|
||||
? {
|
||||
...plan.statePreview,
|
||||
operations: (Array.isArray(plan.statePreview.operations) ? plan.statePreview.operations : [])
|
||||
.map(operation => (
|
||||
plan.target === 'opencode' ? withoutOpenCodeHookActivation(operation) : operation
|
||||
))
|
||||
.filter(operation => !isHookRuntimeOperation(operation)),
|
||||
resolution: plan.statePreview.resolution
|
||||
? {
|
||||
@@ -167,10 +294,11 @@ function withHookConsent(plan, hookConsent = null) {
|
||||
if (hookConsent === 'declined') {
|
||||
return { ...stripHookRuntimeFromPlan(plan), hookConsent };
|
||||
}
|
||||
const effectivePlan = disableUnselectedOpenCodeHooks(plan);
|
||||
return {
|
||||
...plan,
|
||||
...effectivePlan,
|
||||
hookConsent,
|
||||
statePreview: setStatePreviewHookConsent(plan.statePreview, hookConsent),
|
||||
statePreview: setStatePreviewHookConsent(effectivePlan.statePreview, hookConsent),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -193,10 +321,18 @@ function assertHookConsentReady(plan = {}) {
|
||||
module.exports = {
|
||||
HOOK_CAPABILITY_GROUPS,
|
||||
assertHookConsentReady,
|
||||
disableUnselectedOpenCodeHooks,
|
||||
disableOpenCodeHookPluginRegistration,
|
||||
getDisabledOpenCodePluginContent,
|
||||
getOpenCodeActivationPathKind,
|
||||
getOpenCodeSourceActivationKind,
|
||||
formatHookCapabilityDisclosure,
|
||||
getRecordedHookConsent,
|
||||
isHookRuntimeOperation,
|
||||
isOpenCodeHookActivationOperation,
|
||||
isOpenCodePluginEntrypoint,
|
||||
planMaterializesHookRuntime,
|
||||
resolveHookConsentFlags,
|
||||
shouldDisableOpenCodeHooks,
|
||||
withHookConsent,
|
||||
};
|
||||
|
||||
@@ -6,6 +6,7 @@ const path = require('path');
|
||||
const { isWithinRoot, realpathNearestExisting } = require('../path-safety');
|
||||
|
||||
const CURRENT_PLUGIN_ID = 'ecc@ecc';
|
||||
// Legacy plugin ids, kept so existing installs are still recognized.
|
||||
const LEGACY_PLUGIN_IDS = new Set([
|
||||
'everything-claude-code@everything-claude-code',
|
||||
'everything-claude-code@ecc',
|
||||
@@ -49,6 +50,7 @@ function findManualClaudePlugin(options = {}) {
|
||||
['ecc', 'plugin.json'],
|
||||
['ecc@ecc', '.claude-plugin', 'plugin.json'],
|
||||
['ecc@ecc', 'plugin.json'],
|
||||
// Legacy install dir, kept for existing installs.
|
||||
['everything-claude-code', '.claude-plugin', 'plugin.json'],
|
||||
['everything-claude-code', 'plugin.json'],
|
||||
];
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { realpathNearestExisting } = require('../path-safety');
|
||||
const { acquireSettingsLock, getSettingsLockIdentity, sameFileIdentity } = require('./claude-settings-lock');
|
||||
|
||||
const activeLeases = new WeakMap();
|
||||
const STATE_FILENAME = 'ecc-install-state.json';
|
||||
const comparablePath = value => process.platform === 'win32' ? value.toLowerCase() : value;
|
||||
|
||||
function inspectEntry(filePath) {
|
||||
try { return fs.lstatSync(filePath, { bigint: true }); }
|
||||
catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
||||
}
|
||||
|
||||
function canonicalRoots(roots) {
|
||||
if (!Array.isArray(roots)) throw new TypeError('OpenCode install roots must be an array.');
|
||||
const deduplicated = new Map();
|
||||
for (const root of roots) {
|
||||
if (typeof root !== 'string' || !path.isAbsolute(root)) {
|
||||
throw new TypeError('OpenCode install root must be an absolute trusted path.');
|
||||
}
|
||||
const stats = inspectEntry(root);
|
||||
if (stats && (stats.isSymbolicLink() || !stats.isDirectory())) {
|
||||
throw new Error(`Refusing OpenCode install lock through a non-directory or symlink root: ${root}`);
|
||||
}
|
||||
const canonical = realpathNearestExisting(root);
|
||||
deduplicated.set(comparablePath(canonical), canonical);
|
||||
}
|
||||
return [...deduplicated.values()].sort((left, right) => {
|
||||
const a = comparablePath(left);
|
||||
const b = comparablePath(right);
|
||||
return a < b ? -1 : a > b ? 1 : 0;
|
||||
});
|
||||
}
|
||||
|
||||
function assertLockLocation(root, expectedRoot) {
|
||||
const stats = inspectEntry(root);
|
||||
if (!stats || !stats.isDirectory() || stats.isSymbolicLink()
|
||||
|| comparablePath(fs.realpathSync(root)) !== comparablePath(root)
|
||||
|| (expectedRoot && !sameFileIdentity(stats, expectedRoot))) {
|
||||
throw new Error(`Refusing changed OpenCode install lock root: ${root}`);
|
||||
}
|
||||
const lock = inspectEntry(path.join(root, `${STATE_FILENAME}.ecc.lock`));
|
||||
if (lock && (!lock.isFile() || lock.isSymbolicLink())) {
|
||||
throw new Error(`Refusing non-file or symlink OpenCode install lock: ${root}`);
|
||||
}
|
||||
return { root: stats, lock };
|
||||
}
|
||||
|
||||
function assertOwnedLock(owned) {
|
||||
const live = assertLockLocation(owned.root, owned.rootStats);
|
||||
if (!live.lock || !sameFileIdentity(live.lock, owned.lockStats)) {
|
||||
throw new Error(`Refusing changed OpenCode install lock: ${owned.root}`);
|
||||
}
|
||||
}
|
||||
|
||||
function annotateCleanupFailure(primary, property, value) {
|
||||
try {
|
||||
// Own data properties avoid invoking caller getters/setters. Frozen values,
|
||||
// primitives and rejecting proxy traps simply retain no extra diagnostic.
|
||||
Object.defineProperty(primary, property, { value, configurable: true, enumerable: true, writable: true });
|
||||
} catch {
|
||||
// Diagnostics must never replace the exact primary thrown value.
|
||||
}
|
||||
}
|
||||
|
||||
function releaseOwned(ownedLocks) {
|
||||
const failures = [];
|
||||
for (const owned of [...ownedLocks].reverse()) {
|
||||
try {
|
||||
assertOwnedLock(owned);
|
||||
owned.release();
|
||||
} catch (error) {
|
||||
failures.push(error);
|
||||
}
|
||||
}
|
||||
// Finish every safe release before touching any caller-owned error object.
|
||||
if (failures.length > 0) {
|
||||
if (failures.length > 1) annotateCleanupFailure(failures[0], 'releaseErrors', failures.slice(1));
|
||||
throw failures[0];
|
||||
}
|
||||
}
|
||||
|
||||
function acquireOpenCodeInstallLocks(roots, existingLease) {
|
||||
const orderedRoots = canonicalRoots(roots);
|
||||
if (existingLease !== undefined) {
|
||||
const existing = existingLease && typeof existingLease === 'object' && activeLeases.get(existingLease);
|
||||
if (!existing) throw new Error('Invalid or inactive OpenCode install lease.');
|
||||
const covered = new Set(existing.map(owned => comparablePath(owned.root)));
|
||||
if (orderedRoots.some(root => !covered.has(comparablePath(root)))) {
|
||||
throw new Error('OpenCode install lease does not cover every required root.');
|
||||
}
|
||||
existing.forEach(assertOwnedLock);
|
||||
return { lease: existingLease, release() {} };
|
||||
}
|
||||
const ownedLocks = [];
|
||||
try {
|
||||
for (const root of orderedRoots) {
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
const before = assertLockLocation(root);
|
||||
const release = acquireSettingsLock(path.join(root, STATE_FILENAME), { label: 'OpenCode installation' });
|
||||
// Bind descriptor-derived ownership before any path revalidation. Never
|
||||
// adopt the identity of a replacement published at the lock pathname.
|
||||
const owned = { root, rootStats: before.root, release,
|
||||
lockStats: getSettingsLockIdentity(release) };
|
||||
ownedLocks.push(owned);
|
||||
assertOwnedLock(owned);
|
||||
}
|
||||
} catch (error) {
|
||||
try { releaseOwned(ownedLocks); }
|
||||
catch (releaseError) { annotateCleanupFailure(error, 'releaseError', releaseError); }
|
||||
throw error;
|
||||
}
|
||||
const lease = Object.freeze({});
|
||||
activeLeases.set(lease, ownedLocks);
|
||||
return {
|
||||
lease,
|
||||
release() {
|
||||
if (!activeLeases.has(lease)) return;
|
||||
activeLeases.delete(lease);
|
||||
releaseOwned(ownedLocks);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function withOpenCodeInstallLocks(roots, callback, existingLease) {
|
||||
if (typeof callback !== 'function') throw new TypeError('OpenCode install lock callback must be a function.');
|
||||
const holder = acquireOpenCodeInstallLocks(roots, existingLease);
|
||||
let didThrow = false;
|
||||
let primaryError;
|
||||
let result;
|
||||
try { result = callback(holder.lease); }
|
||||
catch (error) { didThrow = true; primaryError = error; }
|
||||
try { holder.release(); }
|
||||
catch (error) {
|
||||
if (didThrow) annotateCleanupFailure(primaryError, 'releaseError', error);
|
||||
else { didThrow = true; primaryError = error; }
|
||||
}
|
||||
if (didThrow) throw primaryError;
|
||||
return result;
|
||||
}
|
||||
|
||||
module.exports = { acquireOpenCodeInstallLocks, withOpenCodeInstallLocks };
|
||||
@@ -4,7 +4,7 @@ const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { readInstallState } = require('../install-state');
|
||||
const { readInstallState, validateInstallState } = require('../install-state');
|
||||
const { assertWithinTrustedRoot } = require('../path-safety');
|
||||
|
||||
const OPENCODE_TARGET = 'opencode';
|
||||
@@ -65,10 +65,14 @@ function inspectLegacyOpencodeState(location) {
|
||||
return { status: 'absent', state: null, error: null };
|
||||
}
|
||||
try {
|
||||
if (!pathExists(location.installStatePath)) {
|
||||
if (!pathExists(location.targetRoot)) {
|
||||
return { status: 'absent', state: null, error: null };
|
||||
}
|
||||
const rootStat = fs.lstatSync(location.targetRoot);
|
||||
if (!rootStat.isDirectory() || rootStat.isSymbolicLink()) {
|
||||
return { status: 'invalid', state: null, error: null };
|
||||
}
|
||||
if (!pathExists(location.installStatePath)) return { status: 'absent', state: null, error: null };
|
||||
const stateStat = fs.lstatSync(location.installStatePath);
|
||||
if (
|
||||
!rootStat.isDirectory()
|
||||
@@ -78,7 +82,11 @@ function inspectLegacyOpencodeState(location) {
|
||||
) {
|
||||
return { status: 'invalid', state: null, error: null };
|
||||
}
|
||||
const state = readInstallState(location.installStatePath);
|
||||
const { content } = hashFileNoFollow(location.installStatePath);
|
||||
let state;
|
||||
try { state = JSON.parse(content.toString('utf8')); }
|
||||
catch { return { status: 'invalid', state: null, error: null }; }
|
||||
if (!validateInstallState(state).valid) return { status: 'invalid', state: null, error: null };
|
||||
const isOpencode = state.target.target === OPENCODE_TARGET
|
||||
|| state.target.id === 'opencode-home';
|
||||
if (
|
||||
@@ -98,17 +106,19 @@ function inspectLegacyOpencodeState(location) {
|
||||
}
|
||||
}
|
||||
|
||||
function hashFileNoFollow(filePath) {
|
||||
function hashFileNoFollow(filePath, fileSystem = fs) {
|
||||
// The filesystem seam supplies operations, never the read-only access policy.
|
||||
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
||||
const descriptor = fs.openSync(filePath, flags);
|
||||
// Read-only opens ignore mode; adapters still receive an owner-only default.
|
||||
const descriptor = fileSystem.openSync(filePath, flags, 0o600);
|
||||
try {
|
||||
const before = fs.fstatSync(descriptor, { bigint: true });
|
||||
const before = fileSystem.fstatSync(descriptor, { bigint: true });
|
||||
if (!before.isFile()) {
|
||||
throw new Error(`Refusing to read a non-file at ${filePath}`);
|
||||
}
|
||||
const content = fs.readFileSync(descriptor);
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
const finalPathStat = fs.lstatSync(filePath, { bigint: true });
|
||||
const content = fileSystem.readFileSync(descriptor);
|
||||
const after = fileSystem.fstatSync(descriptor, { bigint: true });
|
||||
const finalPathStat = fileSystem.lstatSync(filePath, { bigint: true });
|
||||
const unchanged = before.dev === after.dev
|
||||
&& before.ino === after.ino
|
||||
&& before.size === after.size
|
||||
@@ -123,11 +133,12 @@ function hashFileNoFollow(filePath) {
|
||||
throw new Error(`Refusing to read a file that changed during validation: ${filePath}`);
|
||||
}
|
||||
return {
|
||||
content,
|
||||
digest: crypto.createHash('sha256').update(content).digest('hex'),
|
||||
stat: after,
|
||||
};
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
fileSystem.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -202,7 +213,7 @@ function verifyManagedLegacyFile(operation, location, sourceRoot) {
|
||||
if (source.digest !== destination.digest) {
|
||||
return { retainedPath: destinationPath };
|
||||
}
|
||||
return { destinationPath, stat: destination.stat };
|
||||
return { destinationPath, digest: destination.digest, stat: destination.stat };
|
||||
}
|
||||
|
||||
function pathExistsWith(fileSystem, filePath) {
|
||||
@@ -257,6 +268,13 @@ function removeVerifiedLegacyFile(entry, location, fileSystem = fs) {
|
||||
identityError.code = 'ESTALE';
|
||||
throw identityError;
|
||||
}
|
||||
// Recheck bytes after quarantine: an in-place edit retains the same inode.
|
||||
// Production cleanup entries carry the digest verified against ledger/source.
|
||||
if (entry.digest && hashFileNoFollow(quarantinePath, fileSystem).digest !== entry.digest) {
|
||||
const changed = new Error(`Legacy OpenCode file changed during quarantine: ${safePath}`);
|
||||
changed.code = 'ESTALE';
|
||||
throw changed;
|
||||
}
|
||||
fileSystem.rmSync(quarantinePath);
|
||||
fileSystem.rmdirSync(quarantineDir);
|
||||
return true;
|
||||
@@ -393,6 +411,8 @@ function cleanupLegacyOpencodeInstall(plan) {
|
||||
module.exports = {
|
||||
cleanupLegacyOpencodeInstall,
|
||||
getLegacyOpencodeLocation,
|
||||
getLegacyLocationForPlan,
|
||||
inspectLegacyOpencodeState,
|
||||
removeVerifiedLegacyFile,
|
||||
verifyManagedLegacyFile,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ const { execFileSync } = require('child_process');
|
||||
const { resolveInstallPlan } = require('../install-manifests');
|
||||
const { getInstallTargetAdapter } = require('../install-targets/registry');
|
||||
const { resolveInvocationEnvironment } = require('../invocation-environment');
|
||||
const { disableUnselectedOpenCodeHooks } = require('./hook-consent');
|
||||
const { readHooksConfig } = require('../hooks-config');
|
||||
const {
|
||||
materializeManagedHooks,
|
||||
@@ -315,7 +316,7 @@ function createManifestInstallPlan(options = {}) {
|
||||
source
|
||||
});
|
||||
|
||||
return {
|
||||
return disableUnselectedOpenCodeHooks({
|
||||
mode: options.mode || 'manifest',
|
||||
sourceRoot,
|
||||
target,
|
||||
@@ -341,7 +342,7 @@ function createManifestInstallPlan(options = {}) {
|
||||
excludedModuleIds: plan.excludedModuleIds,
|
||||
operations,
|
||||
statePreview
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* ranking degrades to confidence-only (unchanged behaviour).
|
||||
*
|
||||
* Resolves part (b) of:
|
||||
* https://github.com/affaan-m/everything-claude-code/issues/2371
|
||||
* https://github.com/affaan-m/ECC/issues/2371
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -15,7 +15,7 @@ const http = require('http');
|
||||
const path = require('path');
|
||||
|
||||
const { buildAllowedHostnames, isAllowedHostHeader, isAllowedOrigin } = require('../loopback-guard');
|
||||
const { renderMarkdown } = require('./markdown');
|
||||
const { escapeHtml, renderMarkdown } = require('./markdown');
|
||||
const { artifactSdkJs } = require('./sdk');
|
||||
const {
|
||||
canvasCss,
|
||||
@@ -101,9 +101,21 @@ function sendJson(res, statusCode, payload) {
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
// Artifact pages render inside a sandboxed iframe (no allow-same-origin) and
|
||||
// legitimately run CDN scripts (Mermaid) plus inline loaders, so the default
|
||||
// restrictive CSP cannot apply. A sandbox-only CSP mirrors the iframe
|
||||
// attribute instead: scripts keep working, but the document gets an opaque
|
||||
// origin, which neuters direct-navigation abuse of the loopback API (no CORS
|
||||
// reads, JSON POSTs are preflight-blocked) without changing in-iframe
|
||||
// behavior. A hostile <meta> CSP in a raw HTML artifact can only narrow this
|
||||
// further, never loosen it.
|
||||
const ARTIFACT_CSP = 'sandbox allow-scripts allow-forms allow-popups';
|
||||
|
||||
function sendHtml(res, statusCode, html, { csp = true } = {}) {
|
||||
const headers = { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store' };
|
||||
if (csp) {
|
||||
if (csp === 'artifact') {
|
||||
headers['content-security-policy'] = ARTIFACT_CSP;
|
||||
} else if (csp) {
|
||||
headers['content-security-policy'] =
|
||||
"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-src 'self'";
|
||||
}
|
||||
@@ -111,6 +123,112 @@ function sendHtml(res, statusCode, html, { csp = true } = {}) {
|
||||
res.end(html);
|
||||
}
|
||||
|
||||
// Sibling assets have an explicit 64 MiB resource limit (413 above it), separate
|
||||
// from request-body limits. Reads use at most the sampled size plus one sentinel.
|
||||
const MAX_ARTIFACT_ASSET_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
function assetReadError(code = 'EARTIFACT_UNSAFE') {
|
||||
return Object.assign(new Error('Artifact sibling read refused'), { code });
|
||||
}
|
||||
|
||||
function sameAssetIdentity(before, after) {
|
||||
return before.dev === after.dev && before.ino === after.ino && before.mode === after.mode;
|
||||
}
|
||||
|
||||
function sameAssetFile(before, after) {
|
||||
return after.isFile() && sameAssetIdentity(before, after) && before.size === after.size
|
||||
&& (before.mtimeNs ?? before.mtimeMs) === (after.mtimeNs ?? after.mtimeMs)
|
||||
&& (before.ctimeNs ?? before.ctimeMs) === (after.ctimeNs ?? after.ctimeMs);
|
||||
}
|
||||
|
||||
function assetByteLength(stats) {
|
||||
if (typeof stats.size !== 'bigint' && !Number.isSafeInteger(stats.size)) throw assetReadError();
|
||||
const size = BigInt(stats.size);
|
||||
if (size < 0n) throw assetReadError();
|
||||
if (size > BigInt(MAX_ARTIFACT_ASSET_BYTES)) throw assetReadError('EARTIFACT_TOO_LARGE');
|
||||
return Number(size);
|
||||
}
|
||||
|
||||
function snapshotAssetChain(realTarget) {
|
||||
const root = path.parse(realTarget).root;
|
||||
const parts = path.relative(root, realTarget).split(path.sep).filter(Boolean);
|
||||
const paths = [root];
|
||||
for (const part of parts) paths.push(path.join(paths[paths.length - 1], part));
|
||||
return paths.map((entryPath, index) => {
|
||||
const stats = fs.lstatSync(entryPath, { bigint: true });
|
||||
const leaf = index === paths.length - 1;
|
||||
if (stats.isSymbolicLink() || !(leaf ? stats.isFile() : stats.isDirectory())) throw assetReadError();
|
||||
return { path: entryPath, stats, leaf };
|
||||
});
|
||||
}
|
||||
|
||||
function revalidateAssetPath({ baseDir, resolved, realBase, realTarget, chain }) {
|
||||
if (fs.realpathSync(baseDir) !== realBase || fs.realpathSync(resolved) !== realTarget) throw assetReadError();
|
||||
for (const entry of chain) {
|
||||
const current = fs.lstatSync(entry.path, { bigint: true });
|
||||
if (current.isSymbolicLink() || !(entry.leaf ? sameAssetFile(entry.stats, current)
|
||||
: current.isDirectory() && sameAssetIdentity(entry.stats, current))) throw assetReadError();
|
||||
}
|
||||
}
|
||||
|
||||
function readAssetDescriptor(fd, size) {
|
||||
const buffer = Buffer.alloc(size + 1);
|
||||
let bytes = 0;
|
||||
while (bytes < buffer.length) {
|
||||
const requested = buffer.length - bytes;
|
||||
const count = fs.readSync(fd, buffer, bytes, requested, bytes);
|
||||
if (!Number.isInteger(count) || count < 0 || count > requested) throw assetReadError();
|
||||
if (count === 0) break;
|
||||
bytes += count;
|
||||
}
|
||||
if (bytes !== size) throw assetReadError();
|
||||
return buffer.subarray(0, bytes);
|
||||
}
|
||||
|
||||
// Canonical ancestors are sampled from the filesystem root, not just realBase.
|
||||
// Revalidation detects observed replacements, but portable pathname operations
|
||||
// are not atomic openat confinement: repeated swap/restore or same-inode content
|
||||
// races can evade observations. This helper covers siblings only, not the direct
|
||||
// session.file read. O_NOFOLLOW protects the leaf only where the flag exists.
|
||||
function readSiblingAsset(baseDir, resolved) {
|
||||
const realBase = fs.realpathSync(baseDir);
|
||||
const realTarget = fs.realpathSync(resolved);
|
||||
const relative = path.relative(realBase, realTarget);
|
||||
if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) throw assetReadError();
|
||||
const chain = snapshotAssetChain(realTarget);
|
||||
const expected = chain[chain.length - 1].stats;
|
||||
const size = assetByteLength(expected);
|
||||
const snapshot = { baseDir, resolved, realBase, realTarget, chain };
|
||||
revalidateAssetPath(snapshot);
|
||||
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0) | (fs.constants.O_NONBLOCK || 0);
|
||||
let fd;
|
||||
try {
|
||||
fd = fs.openSync(realTarget, flags);
|
||||
} catch (error) {
|
||||
if (error.code === 'ELOOP' || error.code === 'ENOTDIR') throw assetReadError();
|
||||
throw error;
|
||||
}
|
||||
let primaryError;
|
||||
let data;
|
||||
try {
|
||||
if (!sameAssetFile(expected, fs.fstatSync(fd, { bigint: true }))) throw assetReadError();
|
||||
revalidateAssetPath(snapshot);
|
||||
data = readAssetDescriptor(fd, size);
|
||||
if (!sameAssetFile(expected, fs.fstatSync(fd, { bigint: true }))) throw assetReadError();
|
||||
revalidateAssetPath(snapshot);
|
||||
} catch (error) {
|
||||
primaryError = error;
|
||||
} finally {
|
||||
try { fs.closeSync(fd); } catch (error) {
|
||||
// A close error may mean the fd is already released; never retry it or
|
||||
// replace the primary read/validation failure. Close-only failure refuses.
|
||||
if (!primaryError) primaryError = error;
|
||||
}
|
||||
}
|
||||
if (primaryError) throw primaryError;
|
||||
return { data, realTarget };
|
||||
}
|
||||
|
||||
function createPlanCanvasServer({
|
||||
store,
|
||||
host = DEFAULT_HOST,
|
||||
@@ -493,7 +611,7 @@ function createPlanCanvasServer({
|
||||
try {
|
||||
content = fs.readFileSync(session.file, 'utf8');
|
||||
} catch {
|
||||
return sendHtml(res, 404, `<h1>Artifact missing</h1><p>${session.file} no longer exists.</p>`, { csp: false });
|
||||
return sendHtml(res, 404, `<h1>Artifact missing</h1><p>${escapeHtml(session.file)} no longer exists.</p>`);
|
||||
}
|
||||
const ext = path.extname(session.file).toLowerCase();
|
||||
if (ext === '.md' || ext === '.markdown') {
|
||||
@@ -501,30 +619,43 @@ function createPlanCanvasServer({
|
||||
title: path.basename(session.file),
|
||||
sdkSrc: '/sdk.js'
|
||||
});
|
||||
return sendHtml(res, 200, html, { csp: false });
|
||||
return sendHtml(res, 200, html, { csp: 'artifact' });
|
||||
}
|
||||
const sdkTag = '<script src="/sdk.js"></script>';
|
||||
const injected = content.includes('</body>')
|
||||
? content.replace('</body>', `${sdkTag}\n</body>`)
|
||||
: `${content}\n${sdkTag}`;
|
||||
return sendHtml(res, 200, injected, { csp: false });
|
||||
return sendHtml(res, 200, injected, { csp: 'artifact' });
|
||||
}
|
||||
|
||||
// Sibling assets resolve relative to the artifact's directory and must
|
||||
// stay confined to it.
|
||||
// stay confined to it. The prefix check alone is insufficient: a symlink
|
||||
// inside the directory can point outside it, so the check is repeated
|
||||
// against the real paths and fails closed when they cannot be resolved.
|
||||
const baseDir = path.dirname(session.file);
|
||||
const resolved = path.resolve(baseDir, assetPath);
|
||||
if (resolved !== baseDir && !resolved.startsWith(baseDir + path.sep)) {
|
||||
return sendJson(res, 403, { error: 'asset path escapes artifact directory' });
|
||||
}
|
||||
let realTarget;
|
||||
let data;
|
||||
try {
|
||||
data = fs.readFileSync(resolved);
|
||||
} catch {
|
||||
({ data, realTarget } = readSiblingAsset(baseDir, resolved));
|
||||
} catch (error) {
|
||||
if (error.code === 'EARTIFACT_TOO_LARGE') return sendJson(res, 413, { error: 'asset too large' });
|
||||
if (error.code === 'EARTIFACT_UNSAFE') return sendJson(res, 403, { error: 'asset changed or unsafe' });
|
||||
return sendJson(res, 404, { error: 'asset not found' });
|
||||
}
|
||||
const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()] || 'application/octet-stream';
|
||||
res.writeHead(200, { 'content-type': type, 'cache-control': 'no-store' });
|
||||
// MIME comes from the link/request name first so a symlinked asset keeps
|
||||
// the type the page asked for; the target extension is the fallback.
|
||||
const type = CONTENT_TYPES[path.extname(resolved).toLowerCase()]
|
||||
|| CONTENT_TYPES[path.extname(realTarget).toLowerCase()]
|
||||
|| 'application/octet-stream';
|
||||
const headers = { 'content-type': type, 'cache-control': 'no-store' };
|
||||
if (type.startsWith('text/html') || type === 'image/svg+xml') {
|
||||
headers['content-security-policy'] = ARTIFACT_CSP;
|
||||
}
|
||||
res.writeHead(200, headers);
|
||||
return res.end(data);
|
||||
}
|
||||
|
||||
|
||||
@@ -8,11 +8,12 @@
|
||||
* tri-platform branch) and scripts/control-pane.js (which had a darwin-only
|
||||
* branch that silently no-op'd on Windows/Linux). This helper:
|
||||
*
|
||||
* 1. Dispatches `open` / `cmd /c start` / `xdg-open` based on process.platform
|
||||
* 2. Wires the child's 'error' event so ENOENT / EACCES propagate to the caller
|
||||
* instead of being swallowed by detached spawns
|
||||
* 3. Returns a structured { opened, reason } result so CLI consumers can
|
||||
* surface the truth (browser did/did not open) instead of a lying true/false
|
||||
* 1. Dispatches `open` / a fixed PowerShell launcher / `xdg-open` by platform
|
||||
* 2. Handles the child's 'error' event so a missing launcher does not cause
|
||||
* an unhandled error after a detached spawn
|
||||
* 3. Returns a structured { opened, reason } result for the launch request.
|
||||
* Later asynchronous errors cannot change the returned result; success
|
||||
* does not prove a browser opened.
|
||||
*
|
||||
* The signature is intentionally small (single function, no class) so callers
|
||||
* can import without picking up the rest of scripts/lib.
|
||||
@@ -22,6 +23,44 @@
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const WINDOWS_BROWSER_URL = 'ECC_BROWSER_URL';
|
||||
// Only this constant is encoded as PowerShell source. The validated URL is
|
||||
// process-environment data, never command text or an interpolated argument.
|
||||
const WINDOWS_BROWSER_SCRIPT = `$ErrorActionPreference = 'Stop'
|
||||
try {
|
||||
$value = [System.Environment]::GetEnvironmentVariable('ECC_BROWSER_URL', 'Process')
|
||||
[System.Environment]::SetEnvironmentVariable('ECC_BROWSER_URL', $null, 'Process')
|
||||
$uri = $null
|
||||
if (-not [System.Uri]::TryCreate($value, [System.UriKind]::Absolute, [ref]$uri) -or @('http', 'https') -notcontains $uri.Scheme -or $uri.UserInfo) { exit 1 }
|
||||
$info = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$info.FileName = $value
|
||||
$info.UseShellExecute = $true
|
||||
[void][System.Diagnostics.Process]::Start($info)
|
||||
} catch { exit 1 }
|
||||
`;
|
||||
const WINDOWS_BROWSER_COMMAND = Buffer.from(WINDOWS_BROWSER_SCRIPT, 'utf16le').toString('base64');
|
||||
|
||||
function normalizeBrowserUrl(value) {
|
||||
if (typeof value !== 'string' || !value || value !== value.trim()
|
||||
|| value.includes('\\') || !/^https?:\/\//i.test(value)) return null;
|
||||
for (const character of value) {
|
||||
const code = character.charCodeAt(0);
|
||||
if (code < 32 || code === 127) return null;
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
if (!['http:', 'https:'].includes(url.protocol) || !url.hostname || url.username || url.password) return null;
|
||||
return url.href;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function windowsBrowserEnvironment(url, environment) {
|
||||
const entries = Object.entries(environment).filter(([key]) => key.toUpperCase() !== WINDOWS_BROWSER_URL);
|
||||
return { ...Object.fromEntries(entries), [WINDOWS_BROWSER_URL]: url };
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the platform-appropriate opener command + args.
|
||||
* Returns [cmd, args] suitable for child_process.spawn.
|
||||
@@ -32,32 +71,41 @@ const { spawn } = require('child_process');
|
||||
*/
|
||||
function openerCommandFor(platform, url) {
|
||||
if (platform === 'darwin') return ['open', [url]];
|
||||
if (platform === 'win32') return ['cmd', ['/c', 'start', '', url]];
|
||||
if (platform === 'win32') {
|
||||
return ['powershell.exe', ['-NoLogo', '-NoProfile', '-NonInteractive', '-EncodedCommand', WINDOWS_BROWSER_COMMAND]];
|
||||
}
|
||||
return ['xdg-open', [url]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a URL in the user's default browser, dispatching per-platform.
|
||||
* Open an absolute HTTP/S URL in the default browser, dispatching per-platform.
|
||||
*
|
||||
* Always returns a structured result so callers can:
|
||||
* - show a clear error to the agent (no silent failures)
|
||||
* - keep JSON CLI output truthful when browsers cannot launch
|
||||
* Returns the synchronous launch-request result. Asynchronous child errors
|
||||
* are handled, but are not an acknowledgment that a browser opened.
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {NodeJS.Platform} [platform] - injectable for tests; defaults to process.platform
|
||||
* @param {typeof spawn} [spawnProcess] - injectable process launcher for tests
|
||||
* @param {NodeJS.ProcessEnv} [environment] - optional Windows child environment for tests
|
||||
* @returns {{ opened: boolean, reason: string }}
|
||||
*/
|
||||
function openBrowser(url, platform = process.platform) {
|
||||
if (typeof url !== 'string' || url.length === 0) {
|
||||
function openBrowser(url, platform = process.platform, spawnProcess = spawn, environment) {
|
||||
const normalizedUrl = normalizeBrowserUrl(url);
|
||||
if (!normalizedUrl) {
|
||||
return { opened: false, reason: 'invalid-url' };
|
||||
}
|
||||
|
||||
const [cmd, args] = openerCommandFor(platform, url);
|
||||
const [cmd, args] = openerCommandFor(platform, normalizedUrl);
|
||||
let child;
|
||||
try {
|
||||
child = spawn(cmd, args, {
|
||||
child = spawnProcess(cmd, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
shell: false,
|
||||
...(platform === 'win32' ? {
|
||||
windowsHide: true,
|
||||
env: windowsBrowserEnvironment(normalizedUrl, environment === undefined ? process.env : environment),
|
||||
} : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -67,7 +115,7 @@ function openBrowser(url, platform = process.platform) {
|
||||
}
|
||||
|
||||
// Listen for ENOENT/EACCES/etc that would otherwise be silently swallowed
|
||||
// when the user has no `open` / `xdg-open` / `start` available.
|
||||
// when the user has no `open` / `xdg-open` / `powershell.exe` available.
|
||||
let capturedError = null;
|
||||
child.on('error', (err) => {
|
||||
capturedError = err && err.code ? err.code : 'spawn-error';
|
||||
|
||||
@@ -423,18 +423,22 @@ function currentClause(prefix) {
|
||||
return prefix.slice(clauseStart + 1).trim();
|
||||
}
|
||||
|
||||
function invokesContainerResult(prefix) {
|
||||
function invokesContainerResult(prefix, options = {}) {
|
||||
const clause = currentClause(prefix);
|
||||
const pipelineStart = clause.lastIndexOf('|');
|
||||
const pipelineCommand = clause.slice(pipelineStart + 1).trim();
|
||||
const isForeachLoopHeader = Boolean(options.groupingExpression) &&
|
||||
pipelineStart === -1 &&
|
||||
/^foreach$/i.test(pipelineCommand);
|
||||
return /(?:^|\s)(?:&|\.)\s*$/.test(clause) ||
|
||||
/\.\s*(?:foreach|where)\s*$/i.test(clause) ||
|
||||
/-(?:action|begin|command|end|expression|filter|initializationscript|parallel|process|scriptblock)(?:\s*:\s*)?$/i.test(clause) ||
|
||||
/^(?:(?:[\w.-]+\\)?(?:foreach-object|where-object|foreach|where|invoke-command|start-job|measure-command)|%|\?)(?:\s|$)/i.test(pipelineCommand);
|
||||
(!isForeachLoopHeader &&
|
||||
/^(?:(?:[\w.-]+\\)?(?:foreach-object|where-object|foreach|where|invoke-command|start-job|measure-command)|%|\?)(?:\s|$)/i.test(pipelineCommand));
|
||||
}
|
||||
|
||||
function invokesDynamicResult(prefix) {
|
||||
return invokesContainerResult(prefix) ||
|
||||
function invokesDynamicResult(prefix, options = {}) {
|
||||
return invokesContainerResult(prefix, options) ||
|
||||
/(?:^|\s)(?:iex|invoke-expression)\s*$/i.test(currentClause(prefix));
|
||||
}
|
||||
|
||||
@@ -850,6 +854,9 @@ function extractExecutableContainers(input, options = {}) {
|
||||
|
||||
const withinDoubleQuote = quote === '"';
|
||||
const prefix = context;
|
||||
const invokesContainer = invokesContainerResult(prefix, {
|
||||
groupingExpression: isGroupingExpression,
|
||||
});
|
||||
const invokedAfter = isInvokedAfterContainer(input, group.end);
|
||||
const createsScriptBlock = /\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create\s*$/i.test(
|
||||
currentClause(prefix)
|
||||
@@ -863,7 +870,7 @@ function extractExecutableContainers(input, options = {}) {
|
||||
options: {
|
||||
executeBareScriptBlocks: Boolean(options.executeBareScriptBlocks) ||
|
||||
invokedAfter || executesNestedScriptBlocks ||
|
||||
(!isScriptBlock && invokesContainerResult(prefix)),
|
||||
(!isScriptBlock && invokesContainer),
|
||||
},
|
||||
});
|
||||
} else {
|
||||
@@ -883,7 +890,7 @@ function extractExecutableContainers(input, options = {}) {
|
||||
}
|
||||
let resolvedCommand = null;
|
||||
if (!isScriptBlock) {
|
||||
if (isSubexpression || invokesContainerResult(prefix)) {
|
||||
if (isSubexpression || invokesContainer) {
|
||||
resolvedCommand = staticOutputResult(group.body);
|
||||
if (resolvedCommand === null && isSubexpression) {
|
||||
const scalarReference = variableReference(group.body);
|
||||
@@ -904,16 +911,16 @@ function extractExecutableContainers(input, options = {}) {
|
||||
const executableBlockExpression = /\{|\[\s*(?:system\.management\.automation\.)?scriptblock\s*\]\s*::\s*create/i.test(
|
||||
maskQuotedStrings(group.body)
|
||||
);
|
||||
if (!resolvedCommand && !isScriptBlock && invokesDynamicResult(prefix) && !executableBlockExpression) {
|
||||
if (!resolvedCommand && !isScriptBlock && invokesDynamicResult(prefix, {
|
||||
groupingExpression: isGroupingExpression,
|
||||
}) && !executableBlockExpression) {
|
||||
resolvedCommand = DYNAMIC_EXECUTION_MARKER;
|
||||
}
|
||||
if (resolvedCommand) {
|
||||
for (let offset = 0; offset < resolvedCommand.length; offset += 1) {
|
||||
masked[index + offset] = resolvedCommand[offset];
|
||||
}
|
||||
masked[index] = resolvedCommand;
|
||||
if (!withinDoubleQuote) appendContext(resolvedCommand);
|
||||
} else if (isScriptBlock) {
|
||||
if (invokesContainerResult(prefix)) {
|
||||
if (invokesContainer) {
|
||||
context = prefix;
|
||||
} else {
|
||||
resetContext();
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* Cross-platform (Windows, macOS, Linux) project type detection
|
||||
* by inspecting files in the working directory.
|
||||
*
|
||||
* Resolves: https://github.com/affaan-m/everything-claude-code/issues/293
|
||||
* Resolves: https://github.com/affaan-m/ECC/issues/293
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
|
||||
@@ -5,6 +5,7 @@ const path = require('path');
|
||||
const os = require('os');
|
||||
|
||||
const CURRENT_PLUGIN_SLUG = 'ecc';
|
||||
// Legacy install id, kept for existing installs.
|
||||
const LEGACY_PLUGIN_SLUG = 'everything-claude-code';
|
||||
const CURRENT_PLUGIN_HANDLE = `${CURRENT_PLUGIN_SLUG}@${CURRENT_PLUGIN_SLUG}`;
|
||||
const LEGACY_PLUGIN_HANDLE = `${LEGACY_PLUGIN_SLUG}@${LEGACY_PLUGIN_SLUG}`;
|
||||
|
||||
@@ -225,7 +225,7 @@ async function cmdOpen(file, args, { stateDir, port }) {
|
||||
return {
|
||||
status: 'open',
|
||||
url,
|
||||
browser: launched ? 'opened' : 'not opened',
|
||||
browser: launched ? 'launch requested' : 'not opened',
|
||||
browserReason: launchResult.reason,
|
||||
next_step:
|
||||
'Run `ecc-plan-canvas await <file>` and leave it running; it returns when the human sends feedback, a verdict, or ends the session.'
|
||||
|
||||
+4
-3
@@ -77,7 +77,7 @@ fi
|
||||
if [[ "$OLD_VERSION" == "$VERSION" ]]; then
|
||||
echo "Error: Version $VERSION is already declared in release metadata."
|
||||
echo "After the merged commit passes CI, publish it through the tag workflow:"
|
||||
echo " git tag \"v$VERSION\""
|
||||
echo " git tag -s \"v$VERSION\" -m \"Release v$VERSION\""
|
||||
echo " git push origin \"v$VERSION\""
|
||||
exit 1
|
||||
fi
|
||||
@@ -328,10 +328,11 @@ node scripts/build-opencode.js
|
||||
node tests/scripts/build-opencode.test.js
|
||||
node tests/plugin-manifest.test.js
|
||||
|
||||
# Stage, commit, tag, and push
|
||||
# Stage, commit, explicitly sign an annotated tag, and push. Signing failure
|
||||
# stops here under set -e; no personal tag.gpgSign default is assumed.
|
||||
git add "$ROOT_PACKAGE_JSON" "$PACKAGE_LOCK_JSON" "$ROOT_AGENTS_MD" "$TR_AGENTS_MD" "$ZH_CN_AGENTS_MD" "$AGENT_YAML" "$VERSION_FILE" "$PLUGIN_JSON" "$MARKETPLACE_JSON" "$CODEX_MARKETPLACE_JSON" "$CODEX_PLUGIN_JSON" "$CODEX_MARKETPLACE_PLUGIN_JSON" "$OPENCODE_PACKAGE_JSON" "$OPENCODE_PACKAGE_LOCK_JSON" "$OPENCODE_ECC_HOOKS_PLUGIN" "$README_FILE" "$ROOT_ZH_CN_README_FILE" "$TR_README_FILE" "$PT_BR_README_FILE" "$ZH_CN_README_FILE" "$SELECTIVE_INSTALL_ARCHITECTURE_DOC"
|
||||
git commit -m "chore: bump plugin version to $VERSION"
|
||||
git tag "v$VERSION"
|
||||
git tag -s "v$VERSION" -m "Release v$VERSION"
|
||||
git push origin main "v$VERSION"
|
||||
|
||||
echo "Released v$VERSION"
|
||||
|
||||
@@ -167,7 +167,7 @@ ${chalk.yellow('4.')} Evolve into skills: ${chalk.cyan('/evolve')}
|
||||
|
||||
footer() {
|
||||
console.log(chalk.gray('─'.repeat(60)));
|
||||
console.log(chalk.dim(` Powered by Everything Claude Code • ecc.tools`));
|
||||
console.log(chalk.dim(` Powered by ECC • ecc.tools`));
|
||||
console.log(chalk.dim(` GitHub App: github.com/apps/skill-creator`));
|
||||
console.log('\n');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
# Sync Everything Claude Code (ECC) assets into a local Codex CLI setup.
|
||||
# Sync ECC assets into a local Codex CLI setup.
|
||||
# - Backs up ~/.codex config and AGENTS.md
|
||||
# - Merges ECC AGENTS.md into existing AGENTS.md (marker-based, preserves user content)
|
||||
# - Generates prompt files from commands/*.md
|
||||
|
||||
Reference in New Issue
Block a user