mirror of
https://github.com/affaan-m/everything-claude-code.git
synced 2026-06-11 02:33:10 +08:00
Compare commits
12 Commits
fix/atomic
...
patch-1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5ecee70196 | ||
|
|
ba0c4d13a8 | ||
|
|
e989c0ef0e | ||
|
|
f93e8f6869 | ||
|
|
116e61d8cb | ||
|
|
d904edc615 | ||
|
|
5acb01a276 | ||
|
|
7c2f71315b | ||
|
|
28548f67ba | ||
|
|
33ed494adf | ||
|
|
b068069b9b | ||
|
|
e3483fda15 |
82
README.md
82
README.md
@@ -4,9 +4,9 @@
|
||||
|
||||

|
||||
|
||||
[](https://github.com/affaan-m/everything-claude-code/stargazers)
|
||||
[](https://github.com/affaan-m/everything-claude-code/network/members)
|
||||
[](https://github.com/affaan-m/everything-claude-code/graphs/contributors)
|
||||
[](https://github.com/affaan-m/ECC/stargazers)
|
||||
[](https://github.com/affaan-m/ECC/network/members)
|
||||
[](https://github.com/affaan-m/ECC/graphs/contributors)
|
||||
[](https://www.npmjs.com/package/ecc-universal)
|
||||
[](https://www.npmjs.com/package/ecc-agentshield)
|
||||
[](https://github.com/marketplace/ecc-tools)
|
||||
@@ -59,7 +59,7 @@ ECC v2.0.0-rc.1 adds the public Hermes operator story on top of that reusable la
|
||||
</a>
|
||||
</td>
|
||||
<td width="25%" align="center">
|
||||
<a href="https://github.com/affaan-m/everything-claude-code/discussions">
|
||||
<a href="https://github.com/affaan-m/ECC/discussions">
|
||||
<strong>Community</strong>
|
||||
<br />
|
||||
<sub>Discussions · Q&A · Show & Tell</sub>
|
||||
@@ -172,7 +172,7 @@ This repo is the raw code only. The guides explain everything.
|
||||
|
||||
### v1.4.1 — Bug Fix (Feb 2026)
|
||||
|
||||
- **Fixed instinct import content loss** — `parse_instinct_file()` was silently dropping all content after frontmatter (Action, Evidence, Examples sections) during `/instinct-import`. ([#148](https://github.com/affaan-m/everything-claude-code/issues/148), [#161](https://github.com/affaan-m/everything-claude-code/pull/161))
|
||||
- **Fixed instinct import content loss** — `parse_instinct_file()` was silently dropping all content after frontmatter (Action, Evidence, Examples sections) during `/instinct-import`. ([#148](https://github.com/affaan-m/ECC/issues/148), [#161](https://github.com/affaan-m/ECC/pull/161))
|
||||
|
||||
### v1.4.0 — Multi-Language Rules, Installation Wizard & PM2 (Feb 2026)
|
||||
|
||||
@@ -196,7 +196,7 @@ This repo is the raw code only. The guides explain everything.
|
||||
- **Session management** — `/sessions` command for session history
|
||||
- **Continuous learning v2** — Instinct-based learning with confidence scoring, import/export, evolution
|
||||
|
||||
See the full changelog in [Releases](https://github.com/affaan-m/everything-claude-code/releases).
|
||||
See the full changelog in [Releases](https://github.com/affaan-m/ECC/releases).
|
||||
|
||||
---
|
||||
|
||||
@@ -265,7 +265,7 @@ npx ecc install --profile minimal --target claude --with capability:machine-lear
|
||||
|
||||
```bash
|
||||
# Add marketplace
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
/plugin marketplace add https://github.com/affaan-m/ECC
|
||||
|
||||
# Install plugin
|
||||
/plugin install ecc@ecc
|
||||
@@ -275,7 +275,7 @@ npx ecc install --profile minimal --target claude --with capability:machine-lear
|
||||
|
||||
ECC now has three public identifiers, and they are not interchangeable:
|
||||
|
||||
- GitHub source repo: `affaan-m/everything-claude-code`
|
||||
- GitHub source repo: `affaan-m/ECC`
|
||||
- Claude marketplace/plugin identifier: `ecc@ecc`
|
||||
- npm package: `ecc-universal`
|
||||
|
||||
@@ -295,8 +295,8 @@ This is intentional. Anthropic marketplace/plugin installs are keyed by a canoni
|
||||
|
||||
```bash
|
||||
# Clone the repo first
|
||||
git clone https://github.com/affaan-m/everything-claude-code.git
|
||||
cd everything-claude-code
|
||||
git clone https://github.com/affaan-m/ECC.git
|
||||
cd ECC
|
||||
|
||||
# Install dependencies (pick your package manager)
|
||||
npm install # or: pnpm install | yarn install | bun install
|
||||
@@ -494,7 +494,7 @@ Windows PowerShell:
|
||||
This repo is a **Claude Code plugin** - install it directly or copy components manually.
|
||||
|
||||
```
|
||||
everything-claude-code/
|
||||
ECC/
|
||||
|-- .claude-plugin/ # Plugin and marketplace manifests
|
||||
| |-- plugin.json # Plugin metadata and component paths
|
||||
| |-- marketplace.json # Marketplace catalog for /plugin marketplace add
|
||||
@@ -812,7 +812,7 @@ Claude Code v2.1+ **automatically loads** `hooks/hooks.json` from any installed
|
||||
Duplicate hooks file detected: ./hooks/hooks.json resolves to already-loaded file
|
||||
```
|
||||
|
||||
**History:** This has caused repeated fix/revert cycles in this repo ([#29](https://github.com/affaan-m/everything-claude-code/issues/29), [#52](https://github.com/affaan-m/everything-claude-code/issues/52), [#103](https://github.com/affaan-m/everything-claude-code/issues/103)). The behavior changed between Claude Code versions, leading to confusion. We now have a regression test to prevent this from being reintroduced.
|
||||
**History:** This has caused repeated fix/revert cycles in this repo ([#29](https://github.com/affaan-m/ECC/issues/29), [#52](https://github.com/affaan-m/ECC/issues/52), [#103](https://github.com/affaan-m/ECC/issues/103)). The behavior changed between Claude Code versions, leading to confusion. We now have a regression test to prevent this from being reintroduced.
|
||||
|
||||
---
|
||||
|
||||
@@ -824,7 +824,7 @@ The easiest way to use this repo - install as a Claude Code plugin:
|
||||
|
||||
```bash
|
||||
# Add this repo as a marketplace
|
||||
/plugin marketplace add https://github.com/affaan-m/everything-claude-code
|
||||
/plugin marketplace add https://github.com/affaan-m/ECC
|
||||
|
||||
# Install the plugin
|
||||
/plugin install ecc@ecc
|
||||
@@ -838,7 +838,7 @@ Or add directly to your `~/.claude/settings.json`:
|
||||
"ecc": {
|
||||
"source": {
|
||||
"source": "github",
|
||||
"repo": "affaan-m/everything-claude-code"
|
||||
"repo": "affaan-m/ECC"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -854,20 +854,21 @@ This gives you instant access to all commands, agents, skills, and hooks.
|
||||
>
|
||||
> ```bash
|
||||
> # Clone the repo first
|
||||
> git clone https://github.com/affaan-m/everything-claude-code.git
|
||||
> git clone https://github.com/affaan-m/ECC.git
|
||||
> cd ECC
|
||||
>
|
||||
> # Option A: User-level rules (applies to all projects)
|
||||
> mkdir -p ~/.claude/rules/ecc
|
||||
> cp -r everything-claude-code/rules/common ~/.claude/rules/ecc/
|
||||
> cp -r everything-claude-code/rules/typescript ~/.claude/rules/ecc/ # pick your stack
|
||||
> cp -r everything-claude-code/rules/python ~/.claude/rules/ecc/
|
||||
> cp -r everything-claude-code/rules/golang ~/.claude/rules/ecc/
|
||||
> cp -r everything-claude-code/rules/php ~/.claude/rules/ecc/
|
||||
> cp -r rules/common ~/.claude/rules/ecc/
|
||||
> cp -r rules/typescript ~/.claude/rules/ecc/ # pick your stack
|
||||
> cp -r rules/python ~/.claude/rules/ecc/
|
||||
> cp -r rules/golang ~/.claude/rules/ecc/
|
||||
> cp -r rules/php ~/.claude/rules/ecc/
|
||||
>
|
||||
> # Option B: Project-level rules (applies to current project only)
|
||||
> mkdir -p .claude/rules/ecc
|
||||
> cp -r everything-claude-code/rules/common .claude/rules/ecc/
|
||||
> cp -r everything-claude-code/rules/typescript .claude/rules/ecc/ # pick your stack
|
||||
> cp -r rules/common .claude/rules/ecc/
|
||||
> cp -r rules/typescript .claude/rules/ecc/ # pick your stack
|
||||
> ```
|
||||
|
||||
---
|
||||
@@ -878,34 +879,35 @@ If you prefer manual control over what's installed:
|
||||
|
||||
```bash
|
||||
# Clone the repo
|
||||
git clone https://github.com/affaan-m/everything-claude-code.git
|
||||
git clone https://github.com/affaan-m/ECC.git
|
||||
cd ECC
|
||||
|
||||
# Copy agents to your Claude config
|
||||
cp everything-claude-code/agents/*.md ~/.claude/agents/
|
||||
cp agents/*.md ~/.claude/agents/
|
||||
|
||||
# Copy rules directories (common + language-specific)
|
||||
mkdir -p ~/.claude/rules/ecc
|
||||
cp -r everything-claude-code/rules/common ~/.claude/rules/ecc/
|
||||
cp -r everything-claude-code/rules/typescript ~/.claude/rules/ecc/ # pick your stack
|
||||
cp -r everything-claude-code/rules/python ~/.claude/rules/ecc/
|
||||
cp -r everything-claude-code/rules/golang ~/.claude/rules/ecc/
|
||||
cp -r everything-claude-code/rules/php ~/.claude/rules/ecc/
|
||||
cp -r everything-claude-code/rules/arkts ~/.claude/rules/ecc/
|
||||
cp -r rules/common ~/.claude/rules/ecc/
|
||||
cp -r rules/typescript ~/.claude/rules/ecc/ # pick your stack
|
||||
cp -r rules/python ~/.claude/rules/ecc/
|
||||
cp -r rules/golang ~/.claude/rules/ecc/
|
||||
cp -r rules/php ~/.claude/rules/ecc/
|
||||
cp -r rules/arkts ~/.claude/rules/ecc/
|
||||
|
||||
# Copy skills first (primary workflow surface)
|
||||
# Recommended (new users): core/general skills only
|
||||
mkdir -p ~/.claude/skills/ecc
|
||||
cp -r everything-claude-code/.agents/skills/* ~/.claude/skills/ecc/
|
||||
cp -r everything-claude-code/skills/search-first ~/.claude/skills/ecc/
|
||||
cp -r .agents/skills/* ~/.claude/skills/ecc/
|
||||
cp -r skills/search-first ~/.claude/skills/ecc/
|
||||
|
||||
# Optional: add niche/framework-specific skills only when needed
|
||||
# for s in django-patterns django-tdd laravel-patterns springboot-patterns quarkus-patterns; do
|
||||
# cp -r everything-claude-code/skills/$s ~/.claude/skills/ecc/
|
||||
# cp -r skills/$s ~/.claude/skills/ecc/
|
||||
# done
|
||||
|
||||
# Optional: keep maintained slash-command compatibility during migration
|
||||
mkdir -p ~/.claude/commands
|
||||
cp everything-claude-code/commands/*.md ~/.claude/commands/
|
||||
cp commands/*.md ~/.claude/commands/
|
||||
|
||||
# Retired shims live in legacy-command-shims/commands/.
|
||||
# Copy individual files from there only if you still need old names such as /tdd.
|
||||
@@ -1083,7 +1085,7 @@ This shows all available agents, commands, and skills from the plugin.
|
||||
<details>
|
||||
<summary><b>My hooks aren't working / I see "Duplicate hooks file" errors</b></summary>
|
||||
|
||||
This is the most common issue. **Do NOT add a `"hooks"` field to `.claude-plugin/plugin.json`.** Claude Code v2.1+ automatically loads `hooks/hooks.json` from installed plugins. Explicitly declaring it causes duplicate detection errors. See [#29](https://github.com/affaan-m/everything-claude-code/issues/29), [#52](https://github.com/affaan-m/everything-claude-code/issues/52), [#103](https://github.com/affaan-m/everything-claude-code/issues/103).
|
||||
This is the most common issue. **Do NOT add a `"hooks"` field to `.claude-plugin/plugin.json`.** Claude Code v2.1+ automatically loads `hooks/hooks.json` from installed plugins. Explicitly declaring it causes duplicate detection errors. See [#29](https://github.com/affaan-m/ECC/issues/29), [#52](https://github.com/affaan-m/ECC/issues/52), [#103](https://github.com/affaan-m/ECC/issues/103).
|
||||
</details>
|
||||
|
||||
<details>
|
||||
@@ -1128,11 +1130,11 @@ Yes. Use Option 2 (manual installation) and copy only what you need:
|
||||
|
||||
```bash
|
||||
# Just agents
|
||||
cp everything-claude-code/agents/*.md ~/.claude/agents/
|
||||
cp agents/*.md ~/.claude/agents/
|
||||
|
||||
# Just rules
|
||||
mkdir -p ~/.claude/rules/ecc/
|
||||
cp -r everything-claude-code/rules/common ~/.claude/rules/ecc/
|
||||
cp -r rules/common ~/.claude/rules/ecc/
|
||||
```
|
||||
|
||||
Each component is fully independent.
|
||||
@@ -1145,7 +1147,7 @@ Yes. ECC is cross-platform:
|
||||
- **Cursor**: Pre-translated configs in `.cursor/`. See [Cursor IDE Support](#cursor-ide-support).
|
||||
- **Gemini CLI**: Experimental project-local support via `.gemini/GEMINI.md` and shared installer plumbing.
|
||||
- **OpenCode**: Full plugin support in `.opencode/`. See [OpenCode Support](#opencode-support).
|
||||
- **Codex**: First-class support for both macOS app and CLI, with adapter drift guards and SessionStart fallback. See PR [#257](https://github.com/affaan-m/everything-claude-code/pull/257).
|
||||
- **Codex**: First-class support for both macOS app and CLI, with adapter drift guards and SessionStart fallback. See PR [#257](https://github.com/affaan-m/ECC/pull/257).
|
||||
- **GitHub Copilot (VS Code)**: Instruction and prompt layer via `.github/copilot-instructions.md`, `.vscode/settings.json`, and `.github/prompts/`. See [GitHub Copilot Support](#github-copilot-support).
|
||||
- **Antigravity**: Tightly integrated setup for workflows, skills, and flattened rules in `.agent/`. See [Antigravity Guide](docs/ANTIGRAVITY-GUIDE.md).
|
||||
- **JoyCode / CodeBuddy**: Project-local selective install adapters for commands, agents, skills, and flattened rules. See [JoyCode Adapter Guide](docs/JOYCODE-GUIDE.md).
|
||||
@@ -1488,7 +1490,7 @@ OpenCode's plugin system is MORE sophisticated than Claude Code with 20+ event t
|
||||
|
||||
**Option 1: Use directly**
|
||||
```bash
|
||||
cd everything-claude-code
|
||||
cd ECC
|
||||
opencode
|
||||
```
|
||||
|
||||
@@ -1738,7 +1740,7 @@ This project is free and open source. Sponsors help keep it maintained and growi
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#affaan-m/everything-claude-code&Date)
|
||||
[](https://star-history.com/#affaan-m/ECC&Date)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -114,7 +114,31 @@ function isDangerousInvisibleCodePoint(codePoint) {
|
||||
(codePoint >= 0x202A && codePoint <= 0x202E) ||
|
||||
(codePoint >= 0x2066 && codePoint <= 0x2069) ||
|
||||
(codePoint >= 0xFE00 && codePoint <= 0xFE0F) ||
|
||||
(codePoint >= 0xE0100 && codePoint <= 0xE01EF)
|
||||
(codePoint >= 0xE0100 && codePoint <= 0xE01EF) ||
|
||||
// Unicode Tag block (U+E0000–U+E007F). Tag characters were proposed
|
||||
// for language tagging in Unicode 3.1 and have been deprecated since
|
||||
// Unicode 5.1, so no legitimate text uses them. They are the canonical
|
||||
// vector for "ASCII smuggling" / "Tag smuggling" prompt injection:
|
||||
// an attacker hides instructions inside ASCII-looking strings (PR
|
||||
// bodies, SKILL.md, frontmatter), the LLM consumes the tag bytes,
|
||||
// and the human reviewer sees nothing.
|
||||
(codePoint >= 0xE0000 && codePoint <= 0xE007F) ||
|
||||
// U+180E MONGOLIAN VOWEL SEPARATOR — formerly classified as a space
|
||||
// separator, reclassified as a format control in Unicode 6.3; renders
|
||||
// as zero-width and routinely abused for homograph / smuggling.
|
||||
codePoint === 0x180E ||
|
||||
// U+115F / U+1160 HANGUL CHOSEONG/JUNGSEONG FILLER — zero-width fillers
|
||||
// used in Korean text shaping; abused as invisible characters.
|
||||
codePoint === 0x115F ||
|
||||
codePoint === 0x1160 ||
|
||||
// U+2061–U+2064 invisible math operators (FUNCTION APPLICATION,
|
||||
// INVISIBLE TIMES, INVISIBLE SEPARATOR, INVISIBLE PLUS). Zero-width
|
||||
// and not used outside math typesetting; legitimate Markdown / source
|
||||
// does not contain them.
|
||||
(codePoint >= 0x2061 && codePoint <= 0x2064) ||
|
||||
// U+3164 HANGUL FILLER — zero-width filler reportedly used in Discord
|
||||
// / Twitter smuggling attacks; not used in legitimate Korean text.
|
||||
codePoint === 0x3164
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { sanitizeSessionId, readBridge } = require('../lib/session-bridge');
|
||||
const { sanitizeSessionId, readBridge, renameWithRetry } = require('../lib/session-bridge');
|
||||
|
||||
const CONTEXT_WARNING_PCT = 35;
|
||||
const CONTEXT_CRITICAL_PCT = 25;
|
||||
@@ -61,15 +62,30 @@ function readWarnState(sessionId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Write debounce state.
|
||||
* Write debounce state atomically (unique-suffix tmp then rename).
|
||||
*
|
||||
* The tmp path includes `process.pid` plus a random nonce so concurrent
|
||||
* PostToolUse subprocesses writing to the same session's warn-state
|
||||
* file do not clobber each other's tmp mid-write. Without the unique
|
||||
* suffix, two writers race over a shared `${target}.tmp` and produce
|
||||
* either a corrupted payload or an ENOENT throw on the second rename.
|
||||
*
|
||||
* Same pattern as `writeBridgeAtomic` in `scripts/lib/session-bridge.js`
|
||||
* and `writeCostWarningIfChanged` in `scripts/hooks/ecc-metrics-bridge.js`.
|
||||
*
|
||||
* @param {string} sessionId
|
||||
* @param {object} state
|
||||
*/
|
||||
function writeWarnState(sessionId, state) {
|
||||
const target = getWarnPath(sessionId);
|
||||
const tmp = `${target}.tmp`;
|
||||
const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(state), 'utf8');
|
||||
fs.renameSync(tmp, target);
|
||||
try {
|
||||
renameWithRetry(tmp, target);
|
||||
} catch (err) {
|
||||
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* without scanning large JSONL logs on every invocation.
|
||||
*/
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
@@ -51,15 +52,80 @@ function readBridge(sessionId) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Write bridge data atomically (write .tmp then rename).
|
||||
* Write bridge data atomically (write unique-suffix tmp then rename).
|
||||
*
|
||||
* The tmp path includes `process.pid` plus a random nonce so concurrent
|
||||
* writers (e.g. PostToolUse `ecc-metrics-bridge` and the background
|
||||
* `ecc-statusline`, both writing to the same session bridge) do not
|
||||
* clobber each other's tmp file mid-write. With a fixed `.tmp` suffix
|
||||
* two writers could both call `writeFileSync` against the same path
|
||||
* before either reaches `renameSync`, causing one writer's payload to
|
||||
* silently overwrite the other and the second `renameSync` to throw
|
||||
* ENOENT once the rename consumes the file.
|
||||
*
|
||||
* Same pattern already used by `writeCostWarningIfChanged` in
|
||||
* `scripts/hooks/ecc-metrics-bridge.js` (commit 9b1d8918) for the
|
||||
* cost-warning cache; this commit applies it to the session-bridge
|
||||
* primitive too.
|
||||
*
|
||||
* @param {string} sessionId - Already-sanitized session ID
|
||||
* @param {object} data
|
||||
*/
|
||||
function writeBridgeAtomic(sessionId, data) {
|
||||
const target = getBridgePath(sessionId);
|
||||
const tmp = `${target}.tmp`;
|
||||
const tmp = `${target}.${process.pid}.${crypto.randomBytes(4).toString('hex')}.tmp`;
|
||||
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
|
||||
fs.renameSync(tmp, target);
|
||||
try {
|
||||
renameWithRetry(tmp, target);
|
||||
} catch (err) {
|
||||
try { fs.unlinkSync(tmp); } catch { /* ignore */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace a file via rename, retrying briefly on transient OS-level errors.
|
||||
*
|
||||
* POSIX `rename(2)` is atomic between source and destination, so concurrent
|
||||
* writers each rename onto the same target without conflict. Windows
|
||||
* `MoveFileExW` is different: it fails with EPERM/EACCES/EBUSY if the
|
||||
* target is currently being renamed by *another* process — a short race
|
||||
* window that fires reliably under our PostToolUse + statusline concurrency.
|
||||
*
|
||||
* To stay portable, retry up to 5 times with exponential backoff (20 ms,
|
||||
* 40, 80, 160, 320) on the Windows-only transient codes. POSIX runs hit
|
||||
* the first try and exit immediately. Other error codes (ENOENT, ENOSPC,
|
||||
* EROFS, …) re-throw without retry — they are not transient.
|
||||
*
|
||||
* Sleep uses `Atomics.wait` on a throwaway SharedArrayBuffer so the
|
||||
* retry path does not busy-spin the CPU. This works on the main thread
|
||||
* in Node ≥ 17 (and on workers in earlier versions).
|
||||
*
|
||||
* @param {string} tmp
|
||||
* @param {string} target
|
||||
*/
|
||||
function renameWithRetry(tmp, target) {
|
||||
const RETRY_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
||||
const MAX_ATTEMPTS = 5;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
fs.renameSync(tmp, target);
|
||||
return;
|
||||
} catch (err) {
|
||||
if (attempt + 1 >= MAX_ATTEMPTS || !RETRY_CODES.has(err.code)) {
|
||||
throw err;
|
||||
}
|
||||
const delayMs = 20 << attempt;
|
||||
try {
|
||||
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, delayMs);
|
||||
} catch {
|
||||
// Atomics.wait throws on the main thread in some older runtimes;
|
||||
// fall back to a brief busy-wait so the retry path still has a delay.
|
||||
const until = Date.now() + delayMs;
|
||||
while (Date.now() < until) { /* spin */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,6 +142,7 @@ module.exports = {
|
||||
getBridgePath,
|
||||
readBridge,
|
||||
writeBridgeAtomic,
|
||||
renameWithRetry,
|
||||
resolveSessionId,
|
||||
MAX_SESSION_ID_LENGTH
|
||||
};
|
||||
|
||||
@@ -135,6 +135,130 @@ function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// Concurrency contract: two processes writing to the same session
|
||||
// bridge must not throw ENOENT and must never leave a corrupt JSON
|
||||
// file behind. The previous implementation used a fixed `${target}.tmp`
|
||||
// suffix; with concurrent writers it raced over a shared tmp path,
|
||||
// producing both ENOENT on rename and (occasionally) a half-written
|
||||
// payload on the destination.
|
||||
//
|
||||
// This test exercises the atomic-rename primitive only — it does NOT
|
||||
// attempt to defend against the read-modify-write race in callers,
|
||||
// which is a separate concern. Each subprocess writes its own
|
||||
// independent payload N times; we assert (a) every process exits 0
|
||||
// (no ENOENT bubbled up) and (b) the final file is always parseable
|
||||
// JSON whose contents match one of the two writers' last payloads.
|
||||
|
||||
if (
|
||||
test('concurrent writeBridgeAtomic does not throw ENOENT or corrupt the bridge file', () => {
|
||||
// Spawn two child processes that BOTH stay alive at the same time
|
||||
// and call writeBridgeAtomic in a tight loop. `spawnSync` would
|
||||
// run them sequentially (blocking on each), which would never
|
||||
// exercise the race the fix targets. Instead a sync runner script
|
||||
// launches both as async `spawn` children inside its own process,
|
||||
// waits for both to exit, and reports their statuses on stdout —
|
||||
// and the test calls *that* runner via `spawnSync`. The runner is
|
||||
// the only place that needs the event loop.
|
||||
const { spawnSync } = require('child_process');
|
||||
const path = require('path');
|
||||
const testId = `test-bridge-race-${Date.now()}-${process.pid}`;
|
||||
const writerPath = path.join(__dirname, '..', '__tmp_bridge_writer.js');
|
||||
const runnerPath = path.join(__dirname, '..', '__tmp_bridge_race_runner.js');
|
||||
const bridgeLib = path.join(__dirname, '..', '..', 'scripts', 'lib', 'session-bridge');
|
||||
fs.writeFileSync(
|
||||
writerPath,
|
||||
[
|
||||
"const { writeBridgeAtomic } = require(" + JSON.stringify(bridgeLib) + ");",
|
||||
"const [, , sid, tag] = process.argv;",
|
||||
"for (let i = 0; i < 200; i++) {",
|
||||
" writeBridgeAtomic(sid, { writer: tag, i });",
|
||||
"}",
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
runnerPath,
|
||||
[
|
||||
"'use strict';",
|
||||
"const { spawn } = require('child_process');",
|
||||
"const [, , writerPath, sid] = process.argv;",
|
||||
"const c1 = spawn(process.execPath, [writerPath, sid, 'A'], { stdio: ['ignore','pipe','pipe'] });",
|
||||
"const c2 = spawn(process.execPath, [writerPath, sid, 'B'], { stdio: ['ignore','pipe','pipe'] });",
|
||||
"const exits = {};",
|
||||
"const stderrs = { A: '', B: '' };",
|
||||
"c1.stderr.on('data', chunk => { stderrs.A += chunk.toString(); });",
|
||||
"c2.stderr.on('data', chunk => { stderrs.B += chunk.toString(); });",
|
||||
"let done = 0;",
|
||||
"function onExit(tag) { return function(code) { exits[tag] = code; if (++done === 2) finish(); }; }",
|
||||
"c1.on('exit', onExit('A'));",
|
||||
"c2.on('exit', onExit('B'));",
|
||||
"function finish() {",
|
||||
" process.stdout.write(JSON.stringify({ exits, stderrs }));",
|
||||
" process.exit(0);",
|
||||
"}",
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
try {
|
||||
const result = spawnSync('node', [runnerPath, writerPath, testId], { encoding: 'utf8' });
|
||||
assert.strictEqual(result.status, 0,
|
||||
`race runner should exit 0, got ${result.status}: ${result.stderr}`);
|
||||
const parsed = JSON.parse(result.stdout);
|
||||
assert.strictEqual(parsed.exits.A, 0,
|
||||
`writer A should exit 0 (no ENOENT), got ${parsed.exits.A}: ${parsed.stderrs.A}`);
|
||||
assert.strictEqual(parsed.exits.B, 0,
|
||||
`writer B should exit 0 (no ENOENT), got ${parsed.exits.B}: ${parsed.stderrs.B}`);
|
||||
// Final file must be parseable JSON and belong to one of the writers.
|
||||
const final = readBridge(testId);
|
||||
assert.ok(final && typeof final === 'object',
|
||||
`expected parseable JSON object, got: ${JSON.stringify(final)}`);
|
||||
assert.ok(final.writer === 'A' || final.writer === 'B',
|
||||
`expected last-writer-wins payload, got: ${JSON.stringify(final)}`);
|
||||
} finally {
|
||||
try { fs.unlinkSync(getBridgePath(testId)); } catch { /* ignore */ }
|
||||
try { fs.unlinkSync(writerPath); } catch { /* ignore */ }
|
||||
try { fs.unlinkSync(runnerPath); } catch { /* ignore */ }
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('writeBridgeAtomic cleans up its tmp file on renameSync failure', () => {
|
||||
// Trigger renameSync failure by passing a sessionId whose path is
|
||||
// already a directory. The tmp file exists at this point; the fix
|
||||
// must not leak it behind.
|
||||
const path = require('path');
|
||||
const testId = `test-bridge-cleanup-${Date.now()}-${process.pid}`;
|
||||
const target = getBridgePath(testId);
|
||||
const os = require('os');
|
||||
const tmpDir = os.tmpdir();
|
||||
// Plant a directory at the target path so renameSync (target.tmp → target) fails.
|
||||
fs.mkdirSync(target);
|
||||
try {
|
||||
assert.throws(
|
||||
() => writeBridgeAtomic(testId, { x: 1 }),
|
||||
// renameSync of a regular file onto an existing directory throws
|
||||
// EISDIR on Linux, EPERM on macOS, ENOTDIR on some BSDs. Accept
|
||||
// any of those so the test stays portable across CI runners.
|
||||
/EISDIR|EPERM|ENOTDIR|ENOENT/,
|
||||
'expected rename failure to surface'
|
||||
);
|
||||
// Count any leaked tmp files. The pid+nonce suffix is unique per
|
||||
// call, so we look for any matching pattern under os.tmpdir().
|
||||
const prefix = path.basename(target) + '.' + process.pid + '.';
|
||||
const leaked = fs.readdirSync(tmpDir).filter(f => f.startsWith(prefix) && f.endsWith('.tmp'));
|
||||
assert.strictEqual(leaked.length, 0,
|
||||
`expected no leaked tmp files after rename failure, found: ${leaked.join(', ')}`);
|
||||
} finally {
|
||||
try { fs.rmdirSync(target); } catch { /* ignore */ }
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// resolveSessionId tests
|
||||
console.log('\nresolveSessionId:');
|
||||
|
||||
|
||||
@@ -109,6 +109,74 @@ if (
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// Invisible code points newly covered by the denylist. These were missing
|
||||
// from the previous denylist and silently passed through both detection and
|
||||
// `--write` mode. Each is a documented LLM-prompt-injection vector
|
||||
// (Tag block "ASCII smuggling"; the other invisibles are widely cited in
|
||||
// homograph / Discord / Twitter smuggling references).
|
||||
|
||||
const NEWLY_COVERED_RANGES = [
|
||||
{ codePoint: 0xE0041, label: 'Tag block U+E0041 (TAG LATIN CAPITAL LETTER A)' },
|
||||
{ codePoint: 0xE007F, label: 'Tag block U+E007F (CANCEL TAG, range end)' },
|
||||
{ codePoint: 0x180E, label: 'U+180E MONGOLIAN VOWEL SEPARATOR' },
|
||||
{ codePoint: 0x115F, label: 'U+115F HANGUL CHOSEONG FILLER' },
|
||||
{ codePoint: 0x1160, label: 'U+1160 HANGUL JUNGSEONG FILLER' },
|
||||
{ codePoint: 0x2061, label: 'U+2061 FUNCTION APPLICATION' },
|
||||
{ codePoint: 0x2064, label: 'U+2064 INVISIBLE PLUS (range end)' },
|
||||
{ codePoint: 0x3164, label: 'U+3164 HANGUL FILLER' },
|
||||
];
|
||||
|
||||
for (const { codePoint, label } of NEWLY_COVERED_RANGES) {
|
||||
if (
|
||||
test(`detects ${label}`, () => {
|
||||
const root = makeTempRoot('ecc-unicode-newly-covered-');
|
||||
fs.mkdirSync(path.join(root, 'docs'), { recursive: true });
|
||||
const hex = codePoint.toString(16).toUpperCase().padStart(4, '0');
|
||||
fs.writeFileSync(
|
||||
path.join(root, 'docs', `probe-${hex}.md`),
|
||||
`# Probe\n\nBenign${String.fromCodePoint(codePoint)}text\n`
|
||||
);
|
||||
const result = runCheck(root);
|
||||
assert.notStrictEqual(result.status, 0,
|
||||
`expected exit non-zero on U+${hex}, got ${result.status}: ${result.stderr}`);
|
||||
assert.match(result.stderr, new RegExp(`dangerous-invisible U\\+${hex}`),
|
||||
`expected violation message for U+${hex}, got: ${result.stderr}`);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
if (
|
||||
test('write mode strips newly-covered invisibles from markdown', () => {
|
||||
const root = makeTempRoot('ecc-unicode-newly-covered-write-');
|
||||
fs.mkdirSync(path.join(root, 'docs'), { recursive: true });
|
||||
const tagHidden = [...Array(5)].map((_, i) => String.fromCodePoint(0xE0041 + i)).join('');
|
||||
const mongolianHidden = String.fromCodePoint(0x180E);
|
||||
const filePath = path.join(root, 'docs', 'mixed.md');
|
||||
fs.writeFileSync(filePath, `# Title\n\nBenign${tagHidden}${mongolianHidden}text.\n`);
|
||||
|
||||
const writeResult = runCheck(root, ['--write']);
|
||||
assert.strictEqual(writeResult.status, 0,
|
||||
`expected --write to succeed, got ${writeResult.status}: ${writeResult.stderr}`);
|
||||
|
||||
const sanitized = fs.readFileSync(filePath, 'utf8');
|
||||
assert.doesNotMatch(sanitized, /[\u{E0000}-\u{E007F}]/u,
|
||||
'expected tag block characters stripped');
|
||||
assert.doesNotMatch(sanitized, /\u{180E}/u,
|
||||
'expected U+180E stripped');
|
||||
assert.strictEqual(sanitized, '# Title\n\nBenigntext.\n',
|
||||
'expected only the invisible characters removed, surrounding text preserved');
|
||||
|
||||
// Re-run without --write; should now pass cleanly.
|
||||
const clean = runCheck(root);
|
||||
assert.strictEqual(clean.status, 0,
|
||||
`expected post-sanitize re-run to pass, got: ${clean.stderr}`);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('skips Python virtual environments', () => {
|
||||
const root = makeTempRoot('ecc-unicode-venv-');
|
||||
|
||||
Reference in New Issue
Block a user