diff --git a/.opencode/commands/instinct-status.md b/.opencode/commands/instinct-status.md index df33ae715..c6e2e0b12 100644 --- a/.opencode/commands/instinct-status.md +++ b/.opencode/commands/instinct-status.md @@ -16,7 +16,7 @@ fallback), then run the instinct CLI. This avoids reading a stale legacy active under `~/.claude/plugins/cache/...` (#2037). ```bash -ECC_ROOT="${CLAUDE_PLUGIN_ROOT:-$(node -e "var r=(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i 0) { ```bash node -e " -const aa = require((function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=p.join(o.homedir(),'.claude');function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i/dev/null || true; wait "$relay_pid" 2>/dev/null || true' EXIT + codex login +) ``` The networked service sets `NPM_CONFIG_PREFIX=/tmp/ecc-npm` and adds its `bin` @@ -442,7 +445,8 @@ on the executable `/tmp` mount while the root filesystem remains read-only. Complete the sign-in in the host browser. Each attempt mints a fresh `state`/`code`, so always follow the newest printed link. The token stays in the disposable container's `~/.codex/auth.json`; removing the named container -deletes that credential copy. Stop the relay after the login attempt. Only one container can hold host port 1455 at a time +deletes that credential copy. The login block stops the relay on success or failure. +Only one container can hold host port 1455 at a time because the OAuth redirect targets a fixed port. The host publish is restricted to loopback. Inside the container, the relay diff --git a/scripts/lib/resolve-ecc-root.js b/scripts/lib/resolve-ecc-root.js index 5adc3deb1..b6f6eaf25 100644 --- a/scripts/lib/resolve-ecc-root.js +++ b/scripts/lib/resolve-ecc-root.js @@ -154,7 +154,7 @@ function normalizePluginRootForPlatform(rootDir, platform = process.platform) { * const _r = ; * const sm = require(_r + '/scripts/lib/session-manager'); */ -const INLINE_RESOLVE = `(function(){var p=require('path'),f=require('fs'),o=require('os');var e=process.env.CLAUDE_PLUGIN_ROOT;if(e&&e.trim())return e.trim();var d=(process.env.CLAUDE_CONFIG_DIR||'').trim()||p.join(o.homedir(),'.claude');if(d==='~')d=o.homedir();else if(d.charAt(0)==='~'&&(d.charAt(1)==='/'||d.charCodeAt(1)===92))d=p.join(o.homedir(),d.slice(2));d=p.resolve(d);function L(x){try{return require(p.join(x,'scripts','lib','resolve-ecc-root')).resolveEccRoot()}catch(_){return null}}var r=L(d);if(r)return r;var s=['ecc','ecc@ecc','marketplaces/ecc','everything-claude-code','everything-claude-code@everything-claude-code','marketplaces/everything-claude-code'];for(var i=0;i/dev/null || true; wait "$relay_pid" 2>/dev/null || true' EXIT + codex login +) ``` The networked service sets `NPM_CONFIG_PREFIX=/tmp/ecc-npm` and adds its `bin` @@ -442,7 +445,8 @@ on the executable `/tmp` mount while the root filesystem remains read-only. Complete the sign-in in the host browser. Each attempt mints a fresh `state`/`code`, so always follow the newest printed link. The token stays in the disposable container's `~/.codex/auth.json`; removing the named container -deletes that credential copy. Stop the relay after the login attempt. Only one container can hold host port 1455 at a time +deletes that credential copy. The login block stops the relay on success or failure. +Only one container can hold host port 1455 at a time because the OAuth redirect targets a fixed port. The host publish is restricted to loopback. Inside the container, the relay diff --git a/skills/gateguard/SKILL.md b/skills/gateguard/SKILL.md index 5db374d63..208174601 100644 --- a/skills/gateguard/SKILL.md +++ b/skills/gateguard/SKILL.md @@ -400,8 +400,8 @@ An Edit, Write, NotebookEdit, or MultiEdit entry is allowed, with an characters, never credit. - **Shell scope:** a shell segment only credits a target equal to or inside one of its path operands (resolved against the tool `cwd`; a glob operand - must match the target or a directory above it, and a git pathspec's `*` - also matches `/`), or inside the `cwd` when it has none: + must match the target or a directory above it, and a default git pathspec's + `*` also matches `/`), or inside the `cwd` when it has none: `rg payment docs` or `grep -rn payment /usr/share/doc` never credit `src/payment.py`. An input redirection before any operand (`rg payment < file`) or a `<` inside a word reads stdin and never counts. diff --git a/tests/lib/command-plugin-root.test.js b/tests/lib/command-plugin-root.test.js index 86d090803..818eee73c 100644 --- a/tests/lib/command-plugin-root.test.js +++ b/tests/lib/command-plugin-root.test.js @@ -160,6 +160,32 @@ for (const broken of [false, true]) { } finally { fs.rmSync(temp, { recursive: true, force: true }); } }); } +for (const level of ['slug', 'org']) { + test(`actual command skips non-directory cache children at ${level} level`, () => { + const temp = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-stray-cache-')); + try { + const root = path.join(temp, 'plugins/cache/everything-claude-code/vendor/1.0.0'); + fs.mkdirSync(path.join(root, 'scripts/lib'), { recursive: true }); + fs.mkdirSync(path.join(root, ECC_SKILL_SENTINEL), { recursive: true }); + fs.copyFileSync(path.join(__dirname, '../../scripts/lib/resolve-ecc-root.js'), path.join(root, 'scripts/lib/resolve-ecc-root.js')); + fs.writeFileSync(path.join(root, 'scripts/lib/utils.js'), '// fixture'); + fs.writeFileSync(path.join(root, 'scripts/auto-update.js'), "console.log('UPDATER:'+__dirname)"); + const cache = path.join(temp, 'plugins/cache/ecc'); + const strayDir = level === 'slug' ? cache : path.join(cache, 'vendor'); + fs.mkdirSync(strayDir, { recursive: true }); + fs.writeFileSync(path.join(strayDir, 'stray.txt'), 'preserve'); + const env = { ...process.env, HOME: temp, USERPROFILE: temp, CLAUDE_CONFIG_DIR: temp }; + delete env.CLAUDE_PLUGIN_ROOT; + const [bash] = resolveWindowsBashCandidates(['bash.exe', 'bash'], { env }); + const shell = process.platform === 'win32' ? bash : 'bash'; + const assignment = fs.readFileSync(path.join(__dirname, '../../commands/auto-update.md'), 'utf8').split('\n').find(line => line.startsWith('ECC_ROOT=')); + const result = spawnSync(shell, ['-s'], { input: assignment + '\nnode "$ECC_ROOT/scripts/auto-update.js"\n', env, cwd: temp, encoding: 'utf8' }); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stdout.trim(), `UPDATER:${fs.realpathSync(path.join(root, 'scripts'))}`); + assert.equal(fs.readFileSync(path.join(strayDir, 'stray.txt'), 'utf8'), 'preserve'); + } finally { fs.rmSync(temp, { recursive: true, force: true }); } + }); +} console.log(`Passed: ${passed}`); console.log(`Failed: ${failed}`); diff --git a/tests/lib/skill-evolution-storage.test.js b/tests/lib/skill-evolution-storage.test.js index c37706638..6da8933b1 100644 --- a/tests/lib/skill-evolution-storage.test.js +++ b/tests/lib/skill-evolution-storage.test.js @@ -162,7 +162,7 @@ test('rollback exclusive publication failure preserves live bytes and permits a const writeFileSync = fs.writeFileSync; const injectedError = Object.assign(new Error('Injected snapshot publication failure'), { code: 'EIO' }); fs.writeFileSync = (file, ...args) => { - if (file === path.join(versionsDir, 'v2.md') && args[1]?.flag === 'wx') throw injectedError; + if (typeof file === 'number' && args[0] === '# Original\n') throw injectedError; return writeFileSync(file, ...args); }; try { @@ -181,6 +181,137 @@ test('rollback exclusive publication failure preserves live bytes and permits a assert.equal(versioning.getEvolutionLog(skillDir, 'amendments').at(-1).event, 'rollback'); }); +test('failed live rollback write preserves version and applied history, then retries without a duplicate', fixture => { + const { root, skillDir, versionsDir } = fixture; + versioning.createVersion(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Unsnapshotted work\n'); + const logPath = versioning.getEvolutionLogPath(skillDir, 'amendments'); + const priorLog = fs.readFileSync(logPath, 'utf8'); + const writeFileSync = fs.writeFileSync; + const injectedError = Object.assign(new Error('Injected live write failure'), { code: 'EACCES' }); + fs.writeFileSync = (file, ...args) => { + if (file === path.join(skillDir, 'SKILL.md')) throw injectedError; + return writeFileSync(file, ...args); + }; + try { + assert.throws(() => versioning.rollbackTo(skillDir, 1), error => error === injectedError); + } finally { + fs.writeFileSync = writeFileSync; + } + assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Unsnapshotted work\n'); + assert.deepEqual(fs.readdirSync(versionsDir), ['v1.md']); + assert.equal(versioning.getCurrentVersion(skillDir), 1); + assert.equal(fs.readFileSync(logPath, 'utf8'), priorLog); + const report = health.collectSkillHealth({ skillsRoot: path.join(root, 'skills'), homeDir: root }); + assert.equal(report.skills.find(skill => skill.skill_id === 'demo').current_version, 'v1'); + const restored = versioning.rollbackTo(skillDir, 1); + assert.equal(restored.version, 2); + assert.equal(fs.readFileSync(restored.path, 'utf8'), fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8')); + assert.equal(versioning.getEvolutionLog(skillDir, 'amendments').filter(row => row.event === 'rollback').length, 1); +}); + +test('failed rollback preserves a foreign snapshot replacement and both error details', fixture => { + const { skillDir, versionsDir } = fixture; + versioning.createVersion(skillDir); + const live = path.join(skillDir, 'SKILL.md'); + fs.writeFileSync(live, '# Unsnapshotted work\n'); + const target = path.join(versionsDir, 'v2.md'); + const writeFileSync = fs.writeFileSync; + const primary = Object.assign(new Error('Injected live refusal'), { code: 'EACCES' }); + fs.writeFileSync = (file, ...args) => { + if (file === live) { + fs.renameSync(target, path.join(versionsDir, 'owned-attempt.saved')); + writeFileSync(target, '# Foreign snapshot\n'); + throw primary; + } + return writeFileSync(file, ...args); + }; + try { + assert.throws(() => versioning.rollbackTo(skillDir, 1), error => + error.primaryError === primary && /changed ownership/.test(error.recoveryError.message) && /recovery is pending/.test(error.message)); + } finally { + fs.writeFileSync = writeFileSync; + } + assert.equal(fs.readFileSync(live, 'utf8'), '# Unsnapshotted work\n'); + assert.equal(fs.readFileSync(target, 'utf8'), '# Foreign snapshot\n'); + assert.equal(versioning.getEvolutionLog(skillDir, 'amendments').filter(row => row.event === 'rollback').length, 0); +}); + +test('descriptor metadata and final-link failures never expose an empty or incomplete version', fixture => { + const { skillDir, versionsDir } = fixture; + versioning.createVersion(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Unsnapshotted work\n'); + const logPath = versioning.getEvolutionLogPath(skillDir, 'amendments'); + const priorLog = fs.readFileSync(logPath, 'utf8'); + for (const operation of ['fstatSync', 'linkSync']) { + const original = fs[operation]; + const injectedError = Object.assign(new Error(`Injected ${operation} failure`), { code: 'EIO' }); + fs[operation] = () => { throw injectedError; }; + try { + assert.throws(() => versioning.rollbackTo(skillDir, 1), error => error === injectedError || (error.primaryError === injectedError && /recovery is pending/.test(error.message))); + } finally { + fs[operation] = original; + } + assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Unsnapshotted work\n'); + assert.equal(fs.existsSync(path.join(versionsDir, 'v2.md')), false); + assert.deepEqual(versioning.listVersions(skillDir).map(row => row.version), [1]); + assert.equal(versioning.getCurrentVersion(skillDir), 1); + assert.equal(fs.readFileSync(logPath, 'utf8'), priorLog); + } + const restored = versioning.rollbackTo(skillDir, 1); + assert.equal(restored.version, 2); + assert.equal(fs.readFileSync(restored.path, 'utf8'), fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8')); +}); + +test('exclusive snapshot publication preserves a path occupied after allocation', fixture => { + const { skillDir, versionsDir } = fixture; + versioning.createVersion(skillDir); + fs.writeFileSync(path.join(skillDir, 'SKILL.md'), '# Unsnapshotted work\n'); + const target = path.join(versionsDir, 'v2.md'); + const linkSync = fs.linkSync; + fs.linkSync = (source, destination) => { + if (destination === target) fs.writeFileSync(target, '# Foreign snapshot\n', { flag: 'wx' }); + return linkSync(source, destination); + }; + try { + assert.throws(() => versioning.rollbackTo(skillDir, 1), error => error.code === 'EEXIST'); + } finally { + fs.linkSync = linkSync; + } + assert.equal(fs.readFileSync(path.join(skillDir, 'SKILL.md'), 'utf8'), '# Unsnapshotted work\n'); + assert.equal(fs.readFileSync(target, 'utf8'), '# Foreign snapshot\n'); + assert.deepEqual(fs.readdirSync(versionsDir), ['v1.md', 'v2.md']); + assert.equal(versioning.getEvolutionLog(skillDir, 'amendments').filter(row => row.event === 'rollback').length, 0); +}); + +test('snapshot staging cleanup preserves foreign entries and permits a clean later retry', fixture => { + const { skillDir, versionsDir } = fixture; + versioning.createVersion(skillDir); + const live = path.join(skillDir, 'SKILL.md'); + fs.writeFileSync(live, '# Unsnapshotted work\n'); + const linkSync = fs.linkSync; + let foreignPath; + fs.linkSync = (source, destination) => { + foreignPath = path.join(path.dirname(source), 'foreign-owned.txt'); + fs.writeFileSync(foreignPath, 'foreign work'); + return linkSync(source, destination); + }; + try { + assert.throws(() => versioning.rollbackTo(skillDir, 1), error => /recovery is pending/.test(error.message)); + } finally { + fs.linkSync = linkSync; + } + assert.equal(fs.readFileSync(foreignPath, 'utf8'), 'foreign work'); + assert.equal(fs.readFileSync(live, 'utf8'), '# Unsnapshotted work\n'); + assert.equal(fs.existsSync(path.join(versionsDir, 'v2.md')), false); + assert.equal(versioning.getCurrentVersion(skillDir), 1); + assert.equal(versioning.getEvolutionLog(skillDir, 'amendments').filter(row => row.event === 'rollback').length, 0); + const restored = versioning.rollbackTo(skillDir, 1); + assert.equal(restored.version, 2); + assert.equal(fs.readFileSync(restored.path, 'utf8'), fs.readFileSync(live, 'utf8')); + assert.equal(fs.readFileSync(foreignPath, 'utf8'), 'foreign work'); +}); + test('successful rollback snapshots restored content and retains earlier targets for another rollback', fixture => { const { skillDir } = fixture; versioning.createVersion(skillDir); diff --git a/tests/lib/statusline-render.test.js b/tests/lib/statusline-render.test.js index a46fd96f4..b85843f07 100644 --- a/tests/lib/statusline-render.test.js +++ b/tests/lib/statusline-render.test.js @@ -280,7 +280,7 @@ function runTests() { assert.strictEqual(summary.disabledCount, 2); }); - console.log(`\nResults: ${passed} passed, ${failed} failed\n`); + console.log(`\nResults: Passed: ${passed}, Failed: ${failed}\n`); return { passed, failed }; }