mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-16 05:05:30 +02:00
Merge remote-tracking branch 'upstream/main' into codex/docs-governance-audit-foundation
# Conflicts: # .claude-plugin/marketplace.json # .claude-plugin/plugin.json
This commit is contained in:
@@ -6,7 +6,7 @@
|
||||
"plugins": [
|
||||
{
|
||||
"name": "ecc",
|
||||
"version": "2.2.1",
|
||||
"version": "2.2.2",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./"
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"name": "ecc",
|
||||
"source": "./",
|
||||
"description": "Harness-native ECC operator layer - 68 agents, 293 skills, 94 legacy command shims, reusable hooks, rules, selective install profiles, and production-ready workflows for Claude Code, Codex, OpenCode, Cursor, and related agent harnesses",
|
||||
"version": "2.2.1",
|
||||
"version": "2.2.2",
|
||||
"author": {
|
||||
"name": "Affaan Mustafa",
|
||||
"email": "me@affaanmustafa.com"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ecc",
|
||||
"version": "2.2.1",
|
||||
"version": "2.2.2",
|
||||
"description": "Harness-native ECC plugin for engineering teams - 68 agents, 293 skills, 94 legacy command shims, reusable hooks, rules, MCP conventions, and operator workflows for Claude Code plus adjacent agent harnesses",
|
||||
"author": {
|
||||
"name": "Affaan Mustafa",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ecc",
|
||||
"version": "2.2.1",
|
||||
"version": "2.2.2",
|
||||
"description": "Harness-native ECC workflows for Codex: shared skills, production-ready MCP configs, and selective-install-aligned conventions for TDD, security scanning, code review, and autonomous development.",
|
||||
"author": {
|
||||
"name": "Affaan Mustafa",
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
# Package manager setup
|
||||
- name: Setup pnpm
|
||||
if: matrix.pm == 'pnpm' && matrix.node != '18.x'
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
# Keep an explicit pnpm major because this repo's packageManager is Yarn.
|
||||
version: 10
|
||||
@@ -267,6 +267,11 @@ jobs:
|
||||
- name: Run Python tests
|
||||
run: python -m pytest tests/test_*.py -m "not integration"
|
||||
|
||||
- name: Test minimum supported OpenAI SDK
|
||||
run: |
|
||||
python -m pip install 'openai==2.34.0'
|
||||
python -m pytest tests/test_provider_tools.py tests/test_atlas_provider.py tests/test_astraflow_provider.py tests/test_resolver.py
|
||||
|
||||
security:
|
||||
name: Security Scan
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Setup pnpm
|
||||
if: inputs.package-manager == 'pnpm' && inputs.node-version != '18.x'
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
|
||||
uses: pnpm/action-setup@ea17c68df8912ef543352723c149a84f56e3d413 # v6.1.0
|
||||
with:
|
||||
# Keep an explicit pnpm major because this repo's packageManager is Yarn.
|
||||
version: 10
|
||||
|
||||
@@ -23,7 +23,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ecc-universal",
|
||||
"version": "2.2.1",
|
||||
"version": "2.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ecc-universal",
|
||||
"version": "2.2.1",
|
||||
"version": "2.2.2",
|
||||
"license": "MIT",
|
||||
"devDependencies": {
|
||||
"@opencode-ai/plugin": "^1.4.3",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ecc-universal",
|
||||
"version": "2.2.1",
|
||||
"version": "2.2.2",
|
||||
"description": "ECC plugin for OpenCode - agents, commands, hooks, and skills",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
||||
@@ -481,7 +481,7 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
|
||||
* Triggers: Before shell command execution
|
||||
* Action: Sets PROJECT_ROOT, PACKAGE_MANAGER, DETECTED_LANGUAGES, ECC_VERSION
|
||||
*/
|
||||
"shell.env": async () => {
|
||||
"shell.env": async (_input: { cwd: string }, output: { env: Record<string, string> }) => {
|
||||
const env: Record<string, string> = {
|
||||
ECC_VERSION: getECCVersion(),
|
||||
ECC_PLUGIN: "true",
|
||||
@@ -523,7 +523,8 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
|
||||
env.PRIMARY_LANGUAGE = detected[0]
|
||||
}
|
||||
|
||||
return env
|
||||
// OpenCode reads the supplied output object and ignores callback return values.
|
||||
output.env = { ...output.env, ...env }
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -531,13 +532,16 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
|
||||
* OpenCode-specific: Control context compaction behavior
|
||||
*
|
||||
* Triggers: Before context compaction
|
||||
* Action: Push ECC context block and custom compaction prompt
|
||||
* Action: Push ECC context block and compaction guidance
|
||||
*/
|
||||
"experimental.session.compacting": async () => {
|
||||
"experimental.session.compacting": async (
|
||||
_input: { sessionID: string },
|
||||
output: { context: string[]; prompt?: string }
|
||||
) => {
|
||||
const contextBlock = [
|
||||
"# ECC Context (preserve across compaction)",
|
||||
"",
|
||||
"## Active Plugin: ECC v2.2.1",
|
||||
"## Active Plugin: ECC v2.2.2",
|
||||
"- Hooks: file.edited, tool.execute.before/after, session.created/idle/deleted, shell.env, compacting, permission.ask",
|
||||
"- Tools: run-tests, check-coverage, security-audit, format-code, lint-check, git-summary, changed-files",
|
||||
"- Agents: 13 specialized (planner, architect, tdd-guide, code-reviewer, security-reviewer, build-error-resolver, e2e-runner, refactor-cleaner, doc-updater, go-reviewer, go-build-resolver, database-reviewer, python-reviewer)",
|
||||
@@ -558,9 +562,16 @@ export const ECCHooksPlugin: ECCHooksPluginFn = async ({
|
||||
contextBlock.push("")
|
||||
}
|
||||
|
||||
return {
|
||||
context: contextBlock.join("\n"),
|
||||
compaction_prompt: "Focus on preserving: 1) Current task status and progress, 2) Key decisions made, 3) Files created/modified, 4) Remaining work items, 5) Any security concerns flagged. Discard: verbose tool outputs, intermediate exploration, redundant file listings.",
|
||||
const eccContext = [
|
||||
contextBlock.join("\n"),
|
||||
"Focus on preserving: 1) Current task status and progress, 2) Key decisions made, 3) Files created/modified, 4) Remaining work items, 5) Any security concerns flagged. Discard: verbose tool outputs, intermediate exploration, redundant file listings.",
|
||||
]
|
||||
|
||||
// OpenCode requires output assignment and skips context when a prompt is set.
|
||||
if (output.prompt !== undefined) {
|
||||
output.prompt = [output.prompt, ...eccContext].join("\n\n")
|
||||
} else {
|
||||
output.context = [...output.context, ...eccContext]
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
+44
-3
@@ -141,6 +141,10 @@ const DISABLED_VALUES = new Set(["0", "false", "off", "none", "disabled"])
|
||||
/**
|
||||
* Optional Pi companion packages. ECC works without every one of these; they
|
||||
* are reported by `/ecc-doctor` so users can see which extras are available.
|
||||
*
|
||||
* These are capability names, not exact install specs. See
|
||||
* `findInstalledCompanion` for how an entry is matched against what Pi has
|
||||
* actually installed.
|
||||
*/
|
||||
const COMPANION_PACKAGES = [
|
||||
"pi-subagents",
|
||||
@@ -475,6 +479,41 @@ function normalizePiPackageName(entry: unknown): string | undefined {
|
||||
return versionAt > 0 ? spec.slice(0, versionAt) : spec
|
||||
}
|
||||
|
||||
/**
|
||||
* The installed package satisfying a companion entry, or undefined if none is.
|
||||
*
|
||||
* An exact name match is the ordinary case. An UNSCOPED companion entry is
|
||||
* also satisfied by a scoped package with the same bare name --
|
||||
* `@tintinweb/pi-subagents` satisfies `pi-subagents`. The subagents capability
|
||||
* is published to npm by more than one maintainer under that same bare name,
|
||||
* and a user running a scoped fork has the capability installed by any
|
||||
* meaning of the word; reporting "not installed" at them while its tools are
|
||||
* live in their session is a false negative, and the suggested
|
||||
* `pi install npm:pi-subagents` would push them into installing a second
|
||||
* extension that registers the same tool names.
|
||||
*
|
||||
* A SCOPED companion entry is matched exactly, because there the scope is
|
||||
* part of the identity the entry names, not incidental packaging.
|
||||
*/
|
||||
function findInstalledCompanion(companion: string, installed: Set<string>): string | undefined {
|
||||
if (installed.has(companion)) {
|
||||
return companion
|
||||
}
|
||||
|
||||
if (companion.startsWith("@")) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const scopedSuffix = `/${companion}`
|
||||
for (const name of installed) {
|
||||
if (name.startsWith("@") && name.endsWith(scopedSuffix)) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function countDirectories(dir: string): number {
|
||||
try {
|
||||
return fs.readdirSync(dir, { withFileTypes: true }).filter(entry => entry.isDirectory()).length
|
||||
@@ -547,10 +586,12 @@ function buildDoctorReport(ctx: ExtensionContext): string {
|
||||
|
||||
const installed = listInstalledPiPackages(ctx.cwd)
|
||||
for (const name of COMPANION_PACKAGES) {
|
||||
const present = installed.has(name)
|
||||
lines.push(` ${present ? "installed " : "not installed"} ${name}`)
|
||||
if (!present) {
|
||||
const match = findInstalledCompanion(name, installed)
|
||||
lines.push(` ${match ? "installed " : "not installed"} ${name}`)
|
||||
if (!match) {
|
||||
lines.push(` install with: pi install npm:${name}`)
|
||||
} else if (match !== name) {
|
||||
lines.push(` satisfied by: ${match}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Security Evidence — PR #3172 / #3171
|
||||
|
||||
Commit under review: observe.sh Layer-1 allowlist adds `sdk-cli`.
|
||||
|
||||
## Changed security-sensitive surface
|
||||
- `skills/continuous-learning-v2/hooks/observe.sh` (agent hook entrypoint allowlist)
|
||||
|
||||
## Threat model (bounded)
|
||||
- **Risk if missing `sdk-cli`**: interactive Agent SDK CLI sessions never observe (availability/coverage gap).
|
||||
- **Risk if allowlist too broad**: non-interactive bots could start the observer. Mitigated by Layers 2–5 (`ECC_HOOK_PROFILE=minimal`, `ECC_SKIP_OBSERVE=1`, `agent_id`, path exclusions) — unchanged by this PR.
|
||||
- **No secrets / auth tokens / billing / webhook handlers** were modified.
|
||||
|
||||
## Security-focused validation artifacts (this PR)
|
||||
1. **Focused security regression test** (new): `tests/hooks/observe-entrypoint-security.test.js`
|
||||
- Asserts source allowlist includes `sdk-cli`
|
||||
- Asserts Layer-1 allows: `cli`, `sdk-ts`, `sdk-cli`, `claude-desktop`, `claude-vscode`
|
||||
- Asserts Layer-1 rejects: `unknown-bot`, `ci-bot`
|
||||
2. **Supply-chain IOC scan** (repo gate): `npm run security:ioc-scan`
|
||||
|
||||
## Command output (local)
|
||||
|
||||
### observe-entrypoint-security.test.js
|
||||
```text
|
||||
|
||||
=== observe.sh Layer-1 entrypoint security (#3171) ===
|
||||
|
||||
✓ source allowlist includes sdk-cli
|
||||
✓ Layer-1 allows cli
|
||||
✓ Layer-1 allows sdk-ts
|
||||
✓ Layer-1 allows sdk-cli
|
||||
✓ Layer-1 allows claude-desktop
|
||||
✓ Layer-1 allows claude-vscode
|
||||
✓ Layer-1 rejects unknown-bot
|
||||
✓ Layer-1 rejects ci-bot
|
||||
|
||||
All Layer-1 security checks passed.
|
||||
```
|
||||
|
||||
### npm run security:ioc-scan
|
||||
```text
|
||||
|
||||
> ecc-universal@2.2.1 security:ioc-scan
|
||||
> node scripts/ci/scan-supply-chain-iocs.js
|
||||
|
||||
Supply-chain IOC scan passed for /workspace/pr-work/ECC-3171 (12 files inspected)
|
||||
```
|
||||
|
||||
## Conclusion
|
||||
Allowlist change is covered by a dedicated security regression test plus the repository IOC scan. Unknown entrypoints remain denied at Layer-1.
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This is a **production-ready AI coding plugin** providing 68 specialized agents, 293 skills, 94 commands, and automated hook workflows for software development.
|
||||
|
||||
**Version:** 2.2.1
|
||||
**Version:** 2.2.2
|
||||
|
||||
## Core Principles
|
||||
|
||||
|
||||
+30
-2
@@ -1,10 +1,38 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
## 2.2.2 - 2026-09-15
|
||||
|
||||
### Fixed
|
||||
|
||||
- Claude settings updates now tolerate a missing Windows device ID while retaining full-precision inode checks and strict matching when both device IDs are available.
|
||||
#### Packaging
|
||||
|
||||
- Explicitly include the compiled OpenCode payload in the npm package and verify that packing builds it from a clean state with lifecycle scripts enabled.
|
||||
|
||||
#### Memory and MCP
|
||||
|
||||
- Distinguish incomplete memory reads from missing records and classify directory traversal failures (`90ef62cb`, `8321021c`).
|
||||
- Accept the reserved `_meta` parameter on memory MCP ping requests (`380f4b35`).
|
||||
|
||||
#### Hooks and Windows compatibility
|
||||
|
||||
- Keep `hooks.json` within Claude Code's schema by moving stable hook metadata into a validated sidecar (`1ac07903`).
|
||||
- Handle stuck optional values and long-option prefixes in the no-verify guard (`4f373874`).
|
||||
- Support Windows linter paths and ESLint 9 (`2083c983`).
|
||||
- Tolerate missing Windows device IDs in settings updates while retaining full-precision inode checks and strict matching when both device IDs are available (`d3af582b`).
|
||||
|
||||
#### Workflow guidance and catalog
|
||||
|
||||
- Filter epic sync issues by label (`3033436d`).
|
||||
- Remove instructions to auto-merge dependency bumps and synchronize localized merge authority (`22d7ed51`, `678c6dea`).
|
||||
- Keep common naming and Boolean guidance language-neutral (`072e4684`, `a0ecb793`, `013ed0a8`).
|
||||
- Distinguish the `prp-pr` command alias (`cc91c24f`).
|
||||
- Correct Rails skill discovery, invoice tax calculation order, and framework documentation (`b6ddd13a`).
|
||||
- Remove Serply and Squish catalog entries (`c4904e3f`).
|
||||
|
||||
#### Dependency security
|
||||
|
||||
- Update `lru` to 0.18.2 for RUSTSEC-2026-0253 (`4fc950c4`).
|
||||
- Update `js-yaml` to 4.3.2 for GHSA-2883-xcg3-v3hh (`549c1469`).
|
||||
|
||||
## 2.2.0 - 2026-08-25
|
||||
|
||||
|
||||
@@ -170,7 +170,7 @@ Access to 68 agents, 293 skills, and 94 legacy command shims, plus hooks, rules,
|
||||
For Claude Code plugin setup, updates, scope changes, and hook-profile changes:
|
||||
|
||||
```bash
|
||||
npx ecc-universal@2.2.1 setup
|
||||
npx ecc-universal@2.2.2 setup
|
||||
```
|
||||
|
||||
If npm reports a version or cache error, confirm the registry version before retrying:
|
||||
@@ -183,12 +183,12 @@ ECC 2.2 supports the same guided setup through modern package runners:
|
||||
|
||||
| Package runner | Guided setup command |
|
||||
|---|---|
|
||||
| npm / npx | `npx ecc-universal@2.2.1 setup` |
|
||||
| pnpm | `pnpm dlx ecc-universal@2.2.1 setup` |
|
||||
| Yarn 2+ | `yarn dlx ecc-universal@2.2.1 setup` |
|
||||
| Bun | `bunx ecc-universal@2.2.1 setup` |
|
||||
| npm / npx | `npx ecc-universal@2.2.2 setup` |
|
||||
| pnpm | `pnpm dlx ecc-universal@2.2.2 setup` |
|
||||
| Yarn 2+ | `yarn dlx ecc-universal@2.2.2 setup` |
|
||||
| Bun | `bunx ecc-universal@2.2.2 setup` |
|
||||
|
||||
The examples select [the published ECC 2.2.1 release](https://www.npmjs.com/package/ecc-universal/v/2.2.1), matching this repository's release version. A version pin is not a security audit or an integrity check. Review the release source and registry integrity before running package code; use a reviewed checkout for unreleased changes.
|
||||
The examples select [the published ECC 2.2.2 release](https://www.npmjs.com/package/ecc-universal/v/2.2.2), matching this repository's release version. A version pin is not a security audit or an integrity check. Review the release source and registry integrity before running package code; use a reviewed checkout for unreleased changes.
|
||||
|
||||
Yarn Classic 1 does not provide `yarn dlx`; use `npx`, install the package globally, or upgrade Yarn for a temporary one-shot run.
|
||||
|
||||
@@ -197,7 +197,7 @@ The wizard inventories the official marketplace and every native Claude install
|
||||
To configure more than one coding agent in one reviewed flow, use the multi-harness wizard:
|
||||
|
||||
```bash
|
||||
npx ecc-universal@2.2.1 install --guided
|
||||
npx ecc-universal@2.2.2 install --guided
|
||||
```
|
||||
|
||||
It lets you select any combination of Claude Code, Codex, and Kimi Code, shows each install channel and destination, preflights every selection before the first write, and asks for one final confirmation.
|
||||
@@ -211,7 +211,7 @@ It lets you select any combination of Claude Code, Codex, and Kimi Code, shows e
|
||||
For automation, make every provider-specific choice explicit:
|
||||
|
||||
```bash
|
||||
npx ecc-universal@2.2.1 install --guided \
|
||||
npx ecc-universal@2.2.2 install --guided \
|
||||
--harness claude --harness codex --harness kimi \
|
||||
--claude-scope local --claude-hooks standard \
|
||||
--profile core --yes
|
||||
@@ -220,16 +220,16 @@ npx ecc-universal@2.2.1 install --guided \
|
||||
Verify the native guided Codex path and managed Kimi path without writing first:
|
||||
|
||||
```bash
|
||||
npx ecc-universal@2.2.1 install --guided --harness codex --dry-run
|
||||
npx ecc-universal@2.2.1 install --profile core --target kimi --dry-run
|
||||
npx ecc-universal@2.2.2 install --guided --harness codex --dry-run
|
||||
npx ecc-universal@2.2.2 install --profile core --target kimi --dry-run
|
||||
```
|
||||
|
||||
Additional package-name commands are also available through the 2.2 alias:
|
||||
|
||||
```bash
|
||||
npx ecc-universal@2.2.1 consult "security reviews" --target claude
|
||||
npx ecc-universal@2.2.1 install --profile minimal --target claude --with capability:machine-learning
|
||||
npx ecc-universal@2.2.1 doctor --target kimi
|
||||
npx ecc-universal@2.2.2 consult "security reviews" --target claude
|
||||
npx ecc-universal@2.2.2 install --profile minimal --target claude --with capability:machine-learning
|
||||
npx ecc-universal@2.2.2 doctor --target kimi
|
||||
```
|
||||
|
||||
Do not use `npx ecc-install --profile minimal --target claude`: `ecc-install` is a binary name inside `ecc-universal`, not a separately published npm package.
|
||||
@@ -401,7 +401,7 @@ Deep per-harness notes (feature parity, hook adapters, limitations) live in [Pla
|
||||
Use this when you want ECC's rules, agents, commands, platform config, and core workflows without runtime hooks:
|
||||
|
||||
```bash
|
||||
npx ecc-universal@2.2.1 install --profile minimal --target claude
|
||||
npx ecc-universal@2.2.2 install --profile minimal --target claude
|
||||
```
|
||||
|
||||
From a source checkout, the equivalent command is:
|
||||
@@ -586,11 +586,11 @@ If you installed from the universal package, run these commands from the same
|
||||
project directory used for installation:
|
||||
|
||||
```bash
|
||||
npx ecc-universal@2.2.1 list-installed
|
||||
npx ecc-universal@2.2.1 doctor
|
||||
npx ecc-universal@2.2.1 repair
|
||||
npx ecc-universal@2.2.1 uninstall --dry-run
|
||||
npx ecc-universal@2.2.1 uninstall
|
||||
npx ecc-universal@2.2.2 list-installed
|
||||
npx ecc-universal@2.2.2 doctor
|
||||
npx ecc-universal@2.2.2 repair
|
||||
npx ecc-universal@2.2.2 uninstall --dry-run
|
||||
npx ecc-universal@2.2.2 uninstall
|
||||
```
|
||||
|
||||
From a source checkout, inspect the managed state before reinstalling:
|
||||
@@ -779,7 +779,7 @@ The `ito-compute-cli` package is currently unpublished. Build it locally from th
|
||||
|
||||
## What's New
|
||||
|
||||
Current release: **2.2.1** (2026-08-31). Highlights of the 2.2 line:
|
||||
Current release: **2.2.2** (2026-08-31). Highlights of the 2.2 line:
|
||||
|
||||
- Guided, manifest-driven setup across Claude Code, Codex, and Kimi Code, with install-state ownership, doctor, repair, and uninstall.
|
||||
- Native Antigravity install, a thin Pi adapter, and the packed-artifact release gate tested on Linux, macOS, and Windows.
|
||||
@@ -1196,7 +1196,7 @@ ECC's Memory Vault gives Claude, Codex, Hermes, OpenClaw, Kimi, and other harnes
|
||||
Skill-only, minimal, manual, and Claude plugin installs do not put the Memory Vault runtime on `PATH`. Install the npm runtime separately before using the CLI or optional MCP server:
|
||||
|
||||
```bash
|
||||
npm install -g ecc-universal@2.2.1
|
||||
npm install -g ecc-universal@2.2.2
|
||||
ecc memory init --scope project
|
||||
ecc memory search "authentication migration" --target-harness codex
|
||||
ecc memory doctor
|
||||
@@ -1591,7 +1591,7 @@ opencode
|
||||
|
||||
**Option 2: Install as npm package**
|
||||
```bash
|
||||
npm install ecc-universal@2.2.1
|
||||
npm install ecc-universal@2.2.2
|
||||
```
|
||||
|
||||
Then add to your `opencode.json`:
|
||||
|
||||
+1
-1
@@ -80,7 +80,7 @@
|
||||
|
||||
## 最新动态
|
||||
|
||||
### v2.2.1 — 引导式多 Harness 安装(2026年8月)
|
||||
### v2.2.2 — 引导式多 Harness 安装(2026年8月)
|
||||
|
||||
新增可审查的 Claude Code、Codex 与 Kimi Code 多 Harness 安装流程,并提供同步的 npm 命令入口。
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
spec_version: "0.1.0"
|
||||
name: ecc
|
||||
version: 2.2.1
|
||||
version: 2.2.2
|
||||
description: "Initial gitagent export surface for ECC's shared skill catalog, governance, and identity. Native agents, commands, and hooks remain authoritative in the repository while manifest coverage expands."
|
||||
author: affaan-m
|
||||
license: MIT
|
||||
|
||||
@@ -703,7 +703,7 @@ Suggested payload:
|
||||
"skippedModules": []
|
||||
},
|
||||
"source": {
|
||||
"repoVersion": "2.2.1",
|
||||
"repoVersion": "2.2.2",
|
||||
"repoCommit": "git-sha",
|
||||
"manifestVersion": 1
|
||||
},
|
||||
|
||||
@@ -79,7 +79,7 @@ Este repositório contém apenas o código. Os guias explicam tudo.
|
||||
|
||||
## O Que Há de Novo
|
||||
|
||||
### v2.2.1 — Instalação Guiada para Múltiplos Harnesses (Ago 2026)
|
||||
### v2.2.2 — Instalação Guiada para Múltiplos Harnesses (Ago 2026)
|
||||
|
||||
Adiciona uma instalação revisável para Claude Code, Codex e Kimi Code, com uma entrada de comando npm sincronizada.
|
||||
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
Bu, yazılım geliştirme için 68 özel agent, 293 skill, 94 command ve otomatik hook iş akışları sağlayan **üretime hazır bir AI kodlama eklentisidir**.
|
||||
|
||||
**Sürüm:** 2.2.1
|
||||
**Sürüm:** 2.2.2
|
||||
|
||||
## Temel İlkeler
|
||||
|
||||
|
||||
+1
-1
@@ -79,7 +79,7 @@ Bu repository yalnızca ham kodu içerir. Rehberler her şeyi açıklıyor.
|
||||
|
||||
## Yenilikler
|
||||
|
||||
### v2.2.1 — Rehberli Çoklu Harness Kurulumu (Ağu 2026)
|
||||
### v2.2.2 — Rehberli Çoklu Harness Kurulumu (Ağu 2026)
|
||||
|
||||
Claude Code, Codex ve Kimi Code için incelenebilir çoklu harness kurulumu ve eşitlenmiş npm komut girişi eklendi.
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
这是一个**生产就绪的 AI 编码插件**,提供 68 个专业代理、293 项技能、94 条命令以及自动化钩子工作流,用于软件开发。
|
||||
|
||||
**版本:** 2.2.1
|
||||
**版本:** 2.2.2
|
||||
|
||||
## 核心原则
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@
|
||||
|
||||
## 最新动态
|
||||
|
||||
### v2.2.1 — 引导式多 Harness 安装(2026年8月)
|
||||
### v2.2.2 — 引导式多 Harness 安装(2026年8月)
|
||||
|
||||
新增可审查的 Claude Code、Codex 与 Kimi Code 多 Harness 安装流程,并提供同步的 npm 命令入口。
|
||||
|
||||
@@ -1292,7 +1292,7 @@ ECC 是**第一个最大化利用每个主要 AI 编码工具的插件**。以
|
||||
| **上下文文件** | CLAUDE.md + AGENTS.md | AGENTS.md | AGENTS.md | AGENTS.md |
|
||||
| **秘密检测** | 基于钩子 | beforeSubmitPrompt 钩子 | 基于沙箱 | 基于钩子 |
|
||||
| **自动格式化** | PostToolUse 钩子 | afterFileEdit 钩子 | N/A | file.edited 钩子 |
|
||||
| **版本** | 插件 | 插件 | 参考配置 | 2.2.1 |
|
||||
| **版本** | 插件 | 插件 | 参考配置 | 2.2.2 |
|
||||
|
||||
**关键架构决策:**
|
||||
|
||||
|
||||
Generated
+8
-8
@@ -2375,9 +2375,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.4+spec-1.1.0"
|
||||
version = "1.1.6+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5"
|
||||
checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
@@ -2528,9 +2528,9 @@ checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.4.0"
|
||||
version = "3.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "972d7902c8735f2695410b8aed7df6ed12a47394aa1c8d7af49f0497b731a94d"
|
||||
checksum = "af5546be8f5378d5414f83733f5c9a2526f4645829edbc1c41790aeef1b38e8b"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"cookie_store",
|
||||
@@ -2548,9 +2548,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.6.1"
|
||||
version = "0.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "da5f78b09e6941e1a0f2e30e695e4b120377b54d5e0aec11b594bb57b3971613"
|
||||
checksum = "5b0809a01d1ca5a51ca70db32bb2a19157582a526505ef3c19e3b343a59aa5ad"
|
||||
dependencies = [
|
||||
"base64 0.23.1",
|
||||
"http",
|
||||
@@ -2590,9 +2590,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.26.0"
|
||||
version = "1.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
|
||||
checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
|
||||
dependencies = [
|
||||
"atomic",
|
||||
"getrandom 0.4.2",
|
||||
|
||||
@@ -14,6 +14,12 @@ It is usable as an alpha for local experimentation, but it is **not** the finish
|
||||
- worktree-aware session scaffolding
|
||||
- basic multi-session state and output tracking
|
||||
|
||||
Dashboard output is hydrated from SQLite at startup and after recovery, then
|
||||
synchronized with a monotonic database cursor. Because session runners are
|
||||
separate processes, the database remains the cross-process source of truth
|
||||
while steady-state refreshes read only the rows appended since the previous
|
||||
dashboard tick.
|
||||
|
||||
## What This Is For
|
||||
|
||||
ECC 2.0 is the layer above individual harness installs.
|
||||
|
||||
@@ -5,6 +5,8 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
pub const OUTPUT_BUFFER_LIMIT: usize = 1000;
|
||||
/// Maximum number of cross-process output rows applied during one dashboard refresh.
|
||||
pub const OUTPUT_DELTA_BATCH_LIMIT: usize = 4096;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum OutputStream {
|
||||
@@ -113,16 +115,6 @@ impl SessionOutputStore {
|
||||
});
|
||||
}
|
||||
|
||||
pub fn replace_lines(&self, session_id: &str, lines: Vec<OutputLine>) {
|
||||
let mut buffer: VecDeque<OutputLine> = lines.into_iter().collect();
|
||||
|
||||
while buffer.len() > self.capacity {
|
||||
let _ = buffer.pop_front();
|
||||
}
|
||||
|
||||
self.lock_buffers().insert(session_id.to_string(), buffer);
|
||||
}
|
||||
|
||||
pub fn lines(&self, session_id: &str) -> Vec<OutputLine> {
|
||||
self.lock_buffers()
|
||||
.get(session_id)
|
||||
|
||||
@@ -28,6 +28,31 @@ pub struct StateStore {
|
||||
conn: Connection,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct SessionOutputRecord {
|
||||
pub id: i64,
|
||||
pub session_id: String,
|
||||
pub line: OutputLine,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct SessionOutputBatch {
|
||||
pub cursor: i64,
|
||||
pub records: Vec<SessionOutputRecord>,
|
||||
}
|
||||
|
||||
/// Converts one persisted output row into the dashboard's typed record.
|
||||
fn output_record_from_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<SessionOutputRecord> {
|
||||
let stream: String = row.get(2)?;
|
||||
let text: String = row.get(3)?;
|
||||
let timestamp: String = row.get(4)?;
|
||||
Ok(SessionOutputRecord {
|
||||
id: row.get(0)?,
|
||||
session_id: row.get(1)?,
|
||||
line: OutputLine::new(OutputStream::from_db_value(&stream), text, timestamp),
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
|
||||
pub struct HarnessAuditEntry {
|
||||
pub id: i64,
|
||||
@@ -4000,6 +4025,53 @@ impl StateStore {
|
||||
Ok(lines)
|
||||
}
|
||||
|
||||
/// Returns a bounded recent-output snapshot and its highest persisted row ID.
|
||||
pub(crate) fn get_output_snapshot(
|
||||
&self,
|
||||
limit_per_session: usize,
|
||||
) -> Result<SessionOutputBatch> {
|
||||
let limit_per_session = i64::try_from(limit_per_session.max(1)).unwrap_or(i64::MAX);
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, session_id, stream, line, timestamp
|
||||
FROM (
|
||||
SELECT id, session_id, stream, line, timestamp,
|
||||
ROW_NUMBER() OVER (PARTITION BY session_id ORDER BY id DESC) AS row_num
|
||||
FROM session_output
|
||||
)
|
||||
WHERE row_num <= ?1
|
||||
ORDER BY id ASC",
|
||||
)?;
|
||||
let records = stmt
|
||||
.query_map(rusqlite::params![limit_per_session], output_record_from_row)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let cursor = records.last().map(|record| record.id).unwrap_or(0);
|
||||
|
||||
Ok(SessionOutputBatch { cursor, records })
|
||||
}
|
||||
|
||||
/// Returns at most `limit` output rows newer than `cursor` in insertion order.
|
||||
pub(crate) fn get_output_since(
|
||||
&self,
|
||||
cursor: i64,
|
||||
limit: usize,
|
||||
) -> Result<SessionOutputBatch> {
|
||||
let cursor = cursor.max(0);
|
||||
let limit = i64::try_from(limit.max(1)).unwrap_or(i64::MAX);
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, session_id, stream, line, timestamp
|
||||
FROM session_output
|
||||
WHERE id > ?1
|
||||
ORDER BY id ASC
|
||||
LIMIT ?2",
|
||||
)?;
|
||||
let records = stmt
|
||||
.query_map(rusqlite::params![cursor, limit], output_record_from_row)?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let cursor = records.last().map(|record| record.id).unwrap_or(cursor);
|
||||
|
||||
Ok(SessionOutputBatch { cursor, records })
|
||||
}
|
||||
|
||||
pub fn insert_tool_log(
|
||||
&self,
|
||||
session_id: &str,
|
||||
@@ -7382,6 +7454,69 @@ mod tests {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_cursor_reads_a_bounded_snapshot_then_only_new_rows() -> Result<()> {
|
||||
let tempdir = TestDir::new("store-output-cursor")?;
|
||||
let db = StateStore::open(&tempdir.path().join("state.db"))?;
|
||||
|
||||
db.insert_session(&build_session("session-1", SessionState::Running))?;
|
||||
db.insert_session(&build_session("session-2", SessionState::Running))?;
|
||||
db.append_output_line("session-1", OutputStream::Stdout, "one-a")?;
|
||||
db.append_output_line("session-2", OutputStream::Stderr, "two-a")?;
|
||||
db.append_output_line("session-1", OutputStream::Stdout, "one-b")?;
|
||||
db.append_output_line("session-2", OutputStream::Stdout, "two-b")?;
|
||||
db.append_output_line("session-1", OutputStream::Stdout, "one-c")?;
|
||||
|
||||
let snapshot = db.get_output_snapshot(2)?;
|
||||
assert_eq!(snapshot.cursor, 5);
|
||||
assert_eq!(
|
||||
snapshot
|
||||
.records
|
||||
.iter()
|
||||
.map(|record| (record.session_id.as_str(), record.line.text.as_str()))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("session-2", "two-a"),
|
||||
("session-1", "one-b"),
|
||||
("session-2", "two-b"),
|
||||
("session-1", "one-c"),
|
||||
]
|
||||
);
|
||||
|
||||
db.append_output_line("session-2", OutputStream::Stderr, "two-c")?;
|
||||
db.append_output_line("session-1", OutputStream::Stdout, "one-d")?;
|
||||
let delta = db.get_output_since(snapshot.cursor, 1)?;
|
||||
assert_eq!(delta.cursor, 6);
|
||||
assert_eq!(delta.records.len(), 1);
|
||||
assert_eq!(delta.records[0].session_id, "session-2");
|
||||
assert_eq!(delta.records[0].line.text, "two-c");
|
||||
|
||||
let next = db.get_output_since(delta.cursor, 1)?;
|
||||
assert_eq!(next.cursor, 7);
|
||||
assert_eq!(next.records.len(), 1);
|
||||
assert_eq!(next.records[0].session_id, "session-1");
|
||||
assert_eq!(next.records[0].line.text, "one-d");
|
||||
|
||||
let empty = db.get_output_since(next.cursor, 1)?;
|
||||
assert_eq!(empty.cursor, next.cursor);
|
||||
assert!(empty.records.is_empty());
|
||||
|
||||
let query_plan = db
|
||||
.conn
|
||||
.prepare(
|
||||
"EXPLAIN QUERY PLAN SELECT id FROM session_output WHERE id > ?1 ORDER BY id ASC",
|
||||
)?
|
||||
.query_map(rusqlite::params![snapshot.cursor], |row| {
|
||||
row.get::<_, String>(3)
|
||||
})?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
assert!(query_plan
|
||||
.iter()
|
||||
.any(|detail| detail.contains("INTEGER PRIMARY KEY") && detail.contains("rowid>?")));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_round_trip_tracks_unread_counts_and_read_state() -> Result<()> {
|
||||
let tempdir = TestDir::new("store-messages")?;
|
||||
|
||||
+343
-48
@@ -10,7 +10,6 @@ use ratatui::{
|
||||
use regex::Regex;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||
use std::time::UNIX_EPOCH;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use super::widgets::{budget_state, format_currency, format_token_count, BudgetState, TokenMeter};
|
||||
use crate::comms;
|
||||
@@ -19,12 +18,12 @@ use crate::notifications::{DesktopNotifier, NotificationEvent, WebhookNotifier};
|
||||
use crate::observability::ToolLogEntry;
|
||||
use crate::session::manager;
|
||||
use crate::session::output::{
|
||||
OutputEvent, OutputLine, OutputStream, SessionOutputStore, OUTPUT_BUFFER_LIMIT,
|
||||
OutputLine, OutputStream, OUTPUT_BUFFER_LIMIT, OUTPUT_DELTA_BATCH_LIMIT,
|
||||
};
|
||||
use crate::session::store::{DaemonActivity, FileActivityOverlap, StateStore};
|
||||
use crate::session::store::{DaemonActivity, FileActivityOverlap, SessionOutputRecord, StateStore};
|
||||
use crate::session::{
|
||||
ContextObservationPriority, DecisionLogEntry, FileActivityEntry, Session, SessionGrouping,
|
||||
SessionBoardMeta, SessionHarnessInfo, SessionMessage, SessionState,
|
||||
ContextObservationPriority, DecisionLogEntry, FileActivityEntry, Session, SessionBoardMeta,
|
||||
SessionGrouping, SessionHarnessInfo, SessionMessage, SessionState,
|
||||
};
|
||||
use crate::worktree;
|
||||
|
||||
@@ -79,16 +78,42 @@ struct TestRunSummary {
|
||||
passed: usize,
|
||||
}
|
||||
|
||||
/// Consumes an output cache and returns a new bounded cache with `records` appended.
|
||||
fn append_output_records(
|
||||
mut cache: HashMap<String, Vec<OutputLine>>,
|
||||
records: Vec<SessionOutputRecord>,
|
||||
) -> HashMap<String, Vec<OutputLine>> {
|
||||
let mut touched_sessions = HashSet::new();
|
||||
for record in records {
|
||||
cache
|
||||
.entry(record.session_id.clone())
|
||||
.or_default()
|
||||
.push(record.line);
|
||||
touched_sessions.insert(record.session_id);
|
||||
}
|
||||
|
||||
for session_id in touched_sessions {
|
||||
if let Some(lines) = cache.get_mut(&session_id) {
|
||||
let overflow = lines.len().saturating_sub(OUTPUT_BUFFER_LIMIT);
|
||||
if overflow > 0 {
|
||||
lines.drain(..overflow);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cache
|
||||
}
|
||||
|
||||
pub struct Dashboard {
|
||||
db: StateStore,
|
||||
cfg: Config,
|
||||
output_store: SessionOutputStore,
|
||||
output_rx: broadcast::Receiver<OutputEvent>,
|
||||
notifier: DesktopNotifier,
|
||||
webhook_notifier: WebhookNotifier,
|
||||
sessions: Vec<Session>,
|
||||
session_harnesses: HashMap<String, SessionHarnessInfo>,
|
||||
session_output_cache: HashMap<String, Vec<OutputLine>>,
|
||||
session_output_generations: HashMap<String, chrono::DateTime<Utc>>,
|
||||
output_cursor: Option<i64>,
|
||||
unread_message_counts: HashMap<String, usize>,
|
||||
approval_queue_counts: HashMap<String, usize>,
|
||||
approval_queue_preview: Vec<SessionMessage>,
|
||||
@@ -502,15 +527,8 @@ fn load_session_harnesses(
|
||||
}
|
||||
|
||||
impl Dashboard {
|
||||
/// Builds the dashboard and hydrates its initial bounded output snapshot.
|
||||
pub fn new(db: StateStore, cfg: Config) -> Self {
|
||||
Self::with_output_store(db, cfg, SessionOutputStore::default())
|
||||
}
|
||||
|
||||
pub fn with_output_store(
|
||||
db: StateStore,
|
||||
cfg: Config,
|
||||
output_store: SessionOutputStore,
|
||||
) -> Self {
|
||||
let pane_size_percent = configured_pane_size(&cfg, cfg.pane_layout);
|
||||
let initial_cost_metrics_signature = metrics_file_signature(&cfg.cost_metrics_path());
|
||||
let initial_tool_activity_signature =
|
||||
@@ -528,12 +546,15 @@ impl Dashboard {
|
||||
.iter()
|
||||
.map(|session| (session.id.clone(), session.state.clone()))
|
||||
.collect();
|
||||
let session_output_generations = sessions
|
||||
.iter()
|
||||
.map(|session| (session.id.clone(), session.created_at))
|
||||
.collect();
|
||||
let initial_approval_message_id = db
|
||||
.latest_unread_approval_message()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|message| message.id);
|
||||
let output_rx = output_store.subscribe();
|
||||
let notifier = DesktopNotifier::new(cfg.desktop_notifications.clone());
|
||||
let webhook_notifier = WebhookNotifier::new(cfg.webhook_notifications.clone());
|
||||
let mut session_table_state = TableState::default();
|
||||
@@ -544,13 +565,13 @@ impl Dashboard {
|
||||
let mut dashboard = Self {
|
||||
db,
|
||||
cfg,
|
||||
output_store,
|
||||
output_rx,
|
||||
notifier,
|
||||
webhook_notifier,
|
||||
sessions,
|
||||
session_harnesses,
|
||||
session_output_cache: HashMap::new(),
|
||||
session_output_generations,
|
||||
output_cursor: None,
|
||||
unread_message_counts: HashMap::new(),
|
||||
approval_queue_counts: HashMap::new(),
|
||||
approval_queue_preview: Vec::new(),
|
||||
@@ -624,6 +645,7 @@ impl Dashboard {
|
||||
dashboard.sync_handoff_backlog_counts();
|
||||
dashboard.sync_board_meta();
|
||||
dashboard.sync_global_handoff_backlog();
|
||||
dashboard.sync_output_cache();
|
||||
dashboard.sync_selected_output();
|
||||
dashboard.sync_selected_diff();
|
||||
dashboard.sync_selected_messages();
|
||||
@@ -3211,6 +3233,7 @@ impl Dashboard {
|
||||
));
|
||||
}
|
||||
|
||||
/// Refreshes persisted dashboard state while preserving the output cursor.
|
||||
pub fn refresh(&mut self) {
|
||||
self.sync_from_store();
|
||||
}
|
||||
@@ -3993,15 +4016,6 @@ impl Dashboard {
|
||||
}
|
||||
|
||||
pub async fn tick(&mut self) {
|
||||
loop {
|
||||
match self.output_rx.try_recv() {
|
||||
Ok(_event) => {}
|
||||
Err(broadcast::error::TryRecvError::Empty) => break,
|
||||
Err(broadcast::error::TryRecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::TryRecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(error) = manager::activate_pending_worktree_sessions(&self.db, &self.cfg).await {
|
||||
tracing::warn!("Failed to activate queued worktree sessions: {error}");
|
||||
}
|
||||
@@ -4073,18 +4087,22 @@ impl Dashboard {
|
||||
)
|
||||
}
|
||||
|
||||
/// Synchronizes dashboard state, deferring output recovery until sessions load.
|
||||
fn sync_from_store(&mut self) {
|
||||
let (heartbeat_enforcement, budget_enforcement, conflict_enforcement) =
|
||||
self.sync_runtime_metrics();
|
||||
let selected_id = self.selected_session_id().map(ToOwned::to_owned);
|
||||
self.sessions = match self.db.list_sessions() {
|
||||
let sessions_refreshed = match self.db.list_sessions() {
|
||||
Ok(mut sessions) => {
|
||||
sort_sessions_for_display(&mut sessions);
|
||||
sessions
|
||||
self.sessions = sessions;
|
||||
true
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!("Failed to refresh sessions: {error}");
|
||||
Vec::new()
|
||||
self.output_cursor = None;
|
||||
self.sessions.clear();
|
||||
false
|
||||
}
|
||||
};
|
||||
self.session_harnesses = load_session_harnesses(&self.db, &self.cfg, &self.sessions);
|
||||
@@ -4103,7 +4121,9 @@ impl Dashboard {
|
||||
self.sync_approval_notifications();
|
||||
self.sync_global_handoff_backlog();
|
||||
self.sync_daemon_activity();
|
||||
self.sync_output_cache();
|
||||
if sessions_refreshed {
|
||||
self.sync_output_cache();
|
||||
}
|
||||
self.sync_selection_by_id(selected_id.as_deref());
|
||||
self.ensure_selected_pane_visible();
|
||||
self.sync_selected_output();
|
||||
@@ -4481,25 +4501,43 @@ impl Dashboard {
|
||||
}
|
||||
|
||||
fn sync_output_cache(&mut self) {
|
||||
let active_session_ids: HashSet<_> = self
|
||||
let active_session_generations: HashMap<_, _> = self
|
||||
.sessions
|
||||
.iter()
|
||||
.map(|session| session.id.as_str())
|
||||
.map(|session| (session.id.clone(), session.created_at))
|
||||
.collect();
|
||||
self.session_output_cache
|
||||
.retain(|session_id, _| active_session_ids.contains(session_id.as_str()));
|
||||
let cached_generations = &self.session_output_generations;
|
||||
self.session_output_cache = std::mem::take(&mut self.session_output_cache)
|
||||
.into_iter()
|
||||
.filter(|(session_id, _)| {
|
||||
active_session_generations.get(session_id) == cached_generations.get(session_id)
|
||||
})
|
||||
.collect();
|
||||
self.session_output_generations = active_session_generations;
|
||||
|
||||
for session in &self.sessions {
|
||||
match self.db.get_output_lines(&session.id, OUTPUT_BUFFER_LIMIT) {
|
||||
Ok(lines) => {
|
||||
self.output_store.replace_lines(&session.id, lines.clone());
|
||||
self.session_output_cache.insert(session.id.clone(), lines);
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::warn!("Failed to load session output for {}: {error}", session.id);
|
||||
}
|
||||
let batch = match self.output_cursor {
|
||||
Some(cursor) => self
|
||||
.db
|
||||
.get_output_since(cursor, OUTPUT_DELTA_BATCH_LIMIT),
|
||||
None => self.db.get_output_snapshot(OUTPUT_BUFFER_LIMIT),
|
||||
};
|
||||
let batch = match batch {
|
||||
Ok(batch) => batch,
|
||||
Err(error) => {
|
||||
tracing::warn!("Failed to refresh session output cache: {error}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if self.output_cursor.is_none() {
|
||||
self.session_output_cache = HashMap::new();
|
||||
}
|
||||
self.output_cursor = Some(batch.cursor);
|
||||
|
||||
self.session_output_cache = append_output_records(
|
||||
std::mem::take(&mut self.session_output_cache),
|
||||
batch.records,
|
||||
);
|
||||
}
|
||||
|
||||
fn ensure_selected_pane_visible(&mut self) {
|
||||
@@ -5212,6 +5250,7 @@ impl Dashboard {
|
||||
.map(|session| session.id.as_str())
|
||||
}
|
||||
|
||||
/// Returns the selected session's currently cached output window.
|
||||
fn selected_output_lines(&self) -> &[OutputLine] {
|
||||
self.selected_session_id()
|
||||
.and_then(|session_id| self.session_output_cache.get(session_id))
|
||||
@@ -13147,6 +13186,260 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_cache_appends_rows_written_by_another_process_without_rehydrating() -> Result<()> {
|
||||
let db_path =
|
||||
std::env::temp_dir().join(format!("ecc2-output-cursor-{}.db", Uuid::new_v4()));
|
||||
let db = StateStore::open(&db_path)?;
|
||||
let session = sample_session("session-1", "claude", SessionState::Running, None, 0, 0);
|
||||
db.insert_session(&session)?;
|
||||
db.append_output_line("session-1", OutputStream::Stdout, "persisted-before-open")?;
|
||||
|
||||
let mut dashboard = Dashboard::new(db, Config::default());
|
||||
assert!(dashboard
|
||||
.selected_output_text()
|
||||
.contains("persisted-before-open"));
|
||||
dashboard
|
||||
.session_output_cache
|
||||
.entry("session-1".to_string())
|
||||
.or_default()
|
||||
.push(test_output_line(OutputStream::Stdout, "cache-only"));
|
||||
|
||||
let child = Command::new(std::env::current_exe()?)
|
||||
.args([
|
||||
"--exact",
|
||||
"tui::dashboard::tests::output_cursor_child_writer",
|
||||
"--ignored",
|
||||
"--nocapture",
|
||||
])
|
||||
.env("ECC2_OUTPUT_CURSOR_CHILD_DB", &db_path)
|
||||
.status()?;
|
||||
assert!(child.success(), "child output writer should succeed");
|
||||
dashboard.refresh();
|
||||
|
||||
let text = dashboard.selected_output_text();
|
||||
assert!(text.contains("persisted-before-open"));
|
||||
assert!(text.contains("cache-only"));
|
||||
assert!(text.contains("persisted-after-open"));
|
||||
|
||||
dashboard.sync_output_cache();
|
||||
assert_eq!(
|
||||
dashboard
|
||||
.selected_output_lines()
|
||||
.iter()
|
||||
.filter(|line| line.text == "persisted-after-open")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(db_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "helper invoked by output cursor cross-process test"]
|
||||
fn output_cursor_child_writer() -> Result<()> {
|
||||
let Some(db_path) = std::env::var_os("ECC2_OUTPUT_CURSOR_CHILD_DB") else {
|
||||
return Ok(());
|
||||
};
|
||||
StateStore::open(Path::new(&db_path))?.append_output_line(
|
||||
"session-1",
|
||||
OutputStream::Stderr,
|
||||
"persisted-after-open",
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_cache_rehydrates_after_transient_session_list_failure() -> Result<()> {
|
||||
let db_path =
|
||||
std::env::temp_dir().join(format!("ecc2-output-recovery-{}.db", Uuid::new_v4()));
|
||||
let db = StateStore::open(&db_path)?;
|
||||
let session = sample_session("session-1", "claude", SessionState::Running, None, 0, 0);
|
||||
db.insert_session(&session)?;
|
||||
db.append_output_line("session-1", OutputStream::Stdout, "persisted-output")?;
|
||||
|
||||
let mut dashboard = Dashboard::new(db, Config::default());
|
||||
assert!(dashboard
|
||||
.selected_output_text()
|
||||
.contains("persisted-output"));
|
||||
dashboard
|
||||
.session_output_cache
|
||||
.entry("session-1".to_string())
|
||||
.or_default()
|
||||
.push(test_output_line(OutputStream::Stdout, "cache-only"));
|
||||
|
||||
let schema = rusqlite::Connection::open(&db_path)?;
|
||||
schema.execute("ALTER TABLE sessions RENAME TO unavailable_sessions", [])?;
|
||||
dashboard.sync_from_store();
|
||||
assert!(dashboard.sessions.is_empty());
|
||||
assert!(dashboard.session_output_cache["session-1"]
|
||||
.iter()
|
||||
.any(|line| line.text == "cache-only"));
|
||||
assert!(dashboard.output_cursor.is_none());
|
||||
|
||||
dashboard.sync_from_store();
|
||||
assert!(dashboard.session_output_cache["session-1"]
|
||||
.iter()
|
||||
.any(|line| line.text == "cache-only"));
|
||||
assert!(dashboard.output_cursor.is_none());
|
||||
|
||||
schema.execute("ALTER TABLE unavailable_sessions RENAME TO sessions", [])?;
|
||||
dashboard.sync_from_store();
|
||||
|
||||
assert_eq!(dashboard.sessions.len(), 1);
|
||||
assert!(dashboard
|
||||
.selected_output_text()
|
||||
.contains("persisted-output"));
|
||||
assert!(!dashboard.selected_output_text().contains("cache-only"));
|
||||
|
||||
let _ = std::fs::remove_file(db_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_cache_tracks_session_add_delete_and_same_id_recreation() -> Result<()> {
|
||||
let db_path =
|
||||
std::env::temp_dir().join(format!("ecc2-output-lifecycle-{}.db", Uuid::new_v4()));
|
||||
let db = StateStore::open(&db_path)?;
|
||||
db.insert_session(&sample_session(
|
||||
"session-1",
|
||||
"claude",
|
||||
SessionState::Running,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
))?;
|
||||
db.append_output_line("session-1", OutputStream::Stdout, "first-session")?;
|
||||
|
||||
let mut dashboard = Dashboard::new(db, Config::default());
|
||||
let external = StateStore::open(&db_path)?;
|
||||
external.insert_session(&sample_session(
|
||||
"session-2",
|
||||
"codex",
|
||||
SessionState::Running,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
))?;
|
||||
external.append_output_line("session-2", OutputStream::Stderr, "new-session")?;
|
||||
dashboard.sync_from_store();
|
||||
|
||||
assert!(dashboard
|
||||
.sessions
|
||||
.iter()
|
||||
.any(|session| session.id == "session-2"));
|
||||
assert_eq!(
|
||||
dashboard.session_output_cache["session-2"][0].text,
|
||||
"new-session"
|
||||
);
|
||||
|
||||
external.delete_session("session-2")?;
|
||||
let replacement_time = Utc::now() + chrono::Duration::seconds(1);
|
||||
external.insert_session(&Session {
|
||||
created_at: replacement_time,
|
||||
updated_at: replacement_time,
|
||||
last_heartbeat_at: replacement_time,
|
||||
..sample_session("session-2", "codex", SessionState::Running, None, 0, 0)
|
||||
})?;
|
||||
external.append_output_line("session-2", OutputStream::Stdout, "replacement-session")?;
|
||||
dashboard.sync_from_store();
|
||||
|
||||
let replacement = &dashboard.session_output_cache["session-2"];
|
||||
assert_eq!(replacement.len(), 1);
|
||||
assert_eq!(replacement[0].text, "replacement-session");
|
||||
|
||||
let _ = std::fs::remove_file(db_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_cache_retries_delta_after_transient_output_query_failure() -> Result<()> {
|
||||
let db_path =
|
||||
std::env::temp_dir().join(format!("ecc2-output-query-retry-{}.db", Uuid::new_v4()));
|
||||
let db = StateStore::open(&db_path)?;
|
||||
db.insert_session(&sample_session(
|
||||
"session-1",
|
||||
"claude",
|
||||
SessionState::Running,
|
||||
None,
|
||||
0,
|
||||
0,
|
||||
))?;
|
||||
db.append_output_line("session-1", OutputStream::Stdout, "persisted-before")?;
|
||||
|
||||
let mut dashboard = Dashboard::new(db, Config::default());
|
||||
dashboard
|
||||
.session_output_cache
|
||||
.get_mut("session-1")
|
||||
.expect("hydrated output")
|
||||
.push(test_output_line(OutputStream::Stdout, "cache-only"));
|
||||
let cursor = dashboard.output_cursor;
|
||||
|
||||
let schema = rusqlite::Connection::open(&db_path)?;
|
||||
schema.execute(
|
||||
"ALTER TABLE session_output RENAME TO unavailable_session_output",
|
||||
[],
|
||||
)?;
|
||||
dashboard.sync_output_cache();
|
||||
assert_eq!(dashboard.output_cursor, cursor);
|
||||
assert!(dashboard.session_output_cache["session-1"]
|
||||
.iter()
|
||||
.any(|line| line.text == "cache-only"));
|
||||
|
||||
schema.execute(
|
||||
"ALTER TABLE unavailable_session_output RENAME TO session_output",
|
||||
[],
|
||||
)?;
|
||||
StateStore::open(&db_path)?.append_output_line(
|
||||
"session-1",
|
||||
OutputStream::Stderr,
|
||||
"persisted-after",
|
||||
)?;
|
||||
dashboard.sync_output_cache();
|
||||
|
||||
let output = &dashboard.session_output_cache["session-1"];
|
||||
assert!(output.iter().any(|line| line.text == "cache-only"));
|
||||
assert_eq!(
|
||||
output
|
||||
.iter()
|
||||
.filter(|line| line.text == "persisted-after")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(db_path);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn append_output_records_bounds_each_session_to_the_latest_window() {
|
||||
let mut cache = HashMap::from([(
|
||||
"session-2".to_string(),
|
||||
vec![test_output_line(OutputStream::Stderr, "other-session")],
|
||||
)]);
|
||||
let records = (0..(OUTPUT_BUFFER_LIMIT + 5))
|
||||
.map(|index| crate::session::store::SessionOutputRecord {
|
||||
id: index as i64 + 1,
|
||||
session_id: "session-1".to_string(),
|
||||
line: test_output_line(OutputStream::Stdout, &format!("line-{index}")),
|
||||
})
|
||||
.collect();
|
||||
|
||||
cache = append_output_records(cache, records);
|
||||
|
||||
let session_lines = cache.get("session-1").expect("session output");
|
||||
assert_eq!(session_lines.len(), OUTPUT_BUFFER_LIMIT);
|
||||
assert_eq!(
|
||||
session_lines.first().map(|line| line.text.as_str()),
|
||||
Some("line-5")
|
||||
);
|
||||
assert_eq!(
|
||||
session_lines.last().map(|line| line.text.as_str()),
|
||||
Some(format!("line-{}", OUTPUT_BUFFER_LIMIT + 4).as_str())
|
||||
);
|
||||
assert_eq!(cache["session-2"][0].text, "other-session");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn submit_search_tracks_matches_and_sets_navigation_note() {
|
||||
let mut dashboard = test_dashboard(
|
||||
@@ -14917,8 +15210,10 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let output_store = SessionOutputStore::default();
|
||||
let output_rx = output_store.subscribe();
|
||||
let session_output_generations = sessions
|
||||
.iter()
|
||||
.map(|session| (session.id.clone(), session.created_at))
|
||||
.collect();
|
||||
let mut session_table_state = TableState::default();
|
||||
if !sessions.is_empty() {
|
||||
session_table_state.select(Some(selected_session));
|
||||
@@ -14928,13 +15223,13 @@ diff --git a/src/lib.rs b/src/lib.rs
|
||||
db: StateStore::open(Path::new(":memory:")).expect("open test db"),
|
||||
pane_size_percent: configured_pane_size(&cfg, cfg.pane_layout),
|
||||
cfg,
|
||||
output_store,
|
||||
output_rx,
|
||||
notifier,
|
||||
webhook_notifier,
|
||||
sessions,
|
||||
session_harnesses,
|
||||
session_output_cache: HashMap::new(),
|
||||
session_output_generations,
|
||||
output_cursor: None,
|
||||
unread_message_counts: HashMap::new(),
|
||||
approval_queue_counts: HashMap::new(),
|
||||
approval_queue_preview: Vec::new(),
|
||||
|
||||
+2
-1
@@ -35,12 +35,13 @@ $scriptDir = Split-Path -Parent $scriptPath
|
||||
$installerScript = Join-Path -Path (Join-Path -Path $scriptDir -ChildPath 'scripts') -ChildPath 'install-apply.js'
|
||||
|
||||
# Auto-install Node dependencies when running from a git clone
|
||||
# SECURITY: --ignore-scripts blocks preinstall/postinstall RCE from a compromised dependency.
|
||||
$nodeModules = Join-Path -Path $scriptDir -ChildPath 'node_modules'
|
||||
if (-not (Test-Path -LiteralPath $nodeModules)) {
|
||||
Write-Host '[ECC] Installing dependencies...'
|
||||
Push-Location $scriptDir
|
||||
try {
|
||||
& npm install --no-audit --no-fund --loglevel=error
|
||||
& npm install --ignore-scripts --no-audit --no-fund --loglevel=error
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Write-Error "npm install failed with exit code $LASTEXITCODE"
|
||||
exit $LASTEXITCODE
|
||||
|
||||
+4
-2
@@ -14,10 +14,12 @@ while [ -L "$SCRIPT_PATH" ]; do
|
||||
done
|
||||
SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)"
|
||||
|
||||
# Auto-install Node dependencies when running from a git clone
|
||||
# Auto-install Node dependencies when running from a git clone.
|
||||
# SECURITY: --ignore-scripts blocks preinstall/postinstall RCE from a
|
||||
# compromised dependency. ECC deps are pure JS (no native build step).
|
||||
if [ ! -d "$SCRIPT_DIR/node_modules" ]; then
|
||||
echo "[ECC] Installing dependencies..."
|
||||
(cd "$SCRIPT_DIR" && npm install --no-audit --no-fund --loglevel=error)
|
||||
(cd "$SCRIPT_DIR" && npm install --ignore-scripts --no-audit --no-fund --loglevel=error)
|
||||
fi
|
||||
|
||||
# On MSYS2/Git Bash, convert the POSIX path to a Windows path so Node.js
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "ecc-universal",
|
||||
"version": "2.2.1",
|
||||
"version": "2.2.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "ecc-universal",
|
||||
"version": "2.2.1",
|
||||
"version": "2.2.2",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@iarna/toml": "2.2.5",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ecc-universal",
|
||||
"version": "2.2.1",
|
||||
"version": "2.2.2",
|
||||
"description": "Harness-native agent operating system for Codex, OpenCode, Cursor, Gemini, Claude Code, and terminal workflows - skills, hooks, rules, MCP conventions, and operator control-plane patterns",
|
||||
"main": ".opencode/dist/index.js",
|
||||
"types": ".opencode/dist/index.d.ts",
|
||||
@@ -62,6 +62,7 @@
|
||||
".hermes/",
|
||||
".kimi/",
|
||||
".opencode/",
|
||||
".opencode/dist/",
|
||||
".pi/",
|
||||
".openclaw/",
|
||||
".qwen/",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "ecc",
|
||||
"version": "2.2.1",
|
||||
"version": "2.2.2",
|
||||
"description": "Harness-native ECC workflows for Codex: shared skills, production-ready MCP configs, and selective-install-aligned conventions for TDD, security scanning, code review, and autonomous development.",
|
||||
"author": {
|
||||
"name": "Affaan Mustafa",
|
||||
|
||||
+1
-1
@@ -20,7 +20,7 @@ classifiers = [
|
||||
|
||||
dependencies = [
|
||||
"anthropic>=0.120.2",
|
||||
"openai>=1.30.0",
|
||||
"openai>=2.34.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
|
||||
+43
-10
@@ -95,21 +95,54 @@ function askClaude(systemPrompt, history, userMessage, model) {
|
||||
}
|
||||
args.push('-p');
|
||||
|
||||
// On Windows the `claude` binary installed via npm is `claude.cmd`/`claude.ps1`,
|
||||
// and Node's spawn() cannot resolve those wrappers via PATH without shell: true.
|
||||
// But shell mode concatenates args *unescaped*, so a multi-line prompt passed as
|
||||
// an arg gets mangled (newlines and the `===` section markers truncate it, and
|
||||
// claude receives an empty prompt). Fix: send the prompt over stdin via `input`
|
||||
// and keep only the short, safe flags (`--model`, `-p`) as args.
|
||||
// 'claude' is a hardcoded literal here (not user input), so shell mode is safe.
|
||||
const result = spawnSync('claude', args, {
|
||||
// SECURITY: a model value like `x & calc &` breaks out when Node
|
||||
// concatenates command+args unquoted under cmd.exe (DEP0190), so the model
|
||||
// token is validated and only fixed flags reach the command line.
|
||||
if (model && !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(model)) {
|
||||
return `[Error: invalid model name]`;
|
||||
}
|
||||
// On Windows the `claude` binary is usually a .cmd shim, which Node
|
||||
// >=18.20/20.12 refuses to spawn directly (CVE-2024-27980 mitigation), and
|
||||
// .ps1 shims are not directly executable at all. Resolve a natively
|
||||
// executable target first; only .cmd/.bat go through cmd.exe, using the
|
||||
// same quoted-command-line pattern as scripts/hooks/mcp-health-check.js so
|
||||
// space-containing paths survive as single tokens. .ps1 is never executed
|
||||
// directly — fall through to bare `claude` (pre-change behavior) instead.
|
||||
// cmd.exe expands %NAME% even inside double-quoted strings, so reject
|
||||
// percent-delimited executable paths rather than route them through the shell.
|
||||
function quoteWinToken(token) {
|
||||
if (/%/.test(token)) return null;
|
||||
return /[\s"&|<>^();]/.test(token) ? '"' + token.replace(/"/g, '""') + '"' : token;
|
||||
}
|
||||
let bin = 'claude';
|
||||
let useShell = false;
|
||||
if (process.platform === 'win32') {
|
||||
const { spawnSync: spawnWhere } = require('child_process');
|
||||
for (const ext of ['.exe', '.cmd', '.bat']) {
|
||||
let found = null;
|
||||
try {
|
||||
found = spawnWhere('where', [`claude${ext}`], { encoding: 'utf8' });
|
||||
} catch { /* ignore */ }
|
||||
if (found && found.status === 0 && found.stdout && found.stdout.trim()) {
|
||||
bin = found.stdout.trim().split(/\r?\n/)[0];
|
||||
useShell = /\.(cmd|bat)$/i.test(bin);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (useShell && quoteWinToken(bin) === null) {
|
||||
useShell = false;
|
||||
}
|
||||
}
|
||||
const spawnOpts = {
|
||||
input: fullPrompt,
|
||||
encoding: 'utf8',
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
env: { ...process.env, CLAUDECODE: '' },
|
||||
timeout: 300000,
|
||||
shell: process.platform === 'win32'
|
||||
});
|
||||
};
|
||||
const result = useShell
|
||||
? spawnSync([bin, ...args].map(quoteWinToken).join(' '), { ...spawnOpts, shell: true })
|
||||
: spawnSync(bin, args, { ...spawnOpts, shell: false });
|
||||
|
||||
if (result.error) {
|
||||
return `[Error: ${result.error.message}]`;
|
||||
|
||||
@@ -5,12 +5,13 @@ set -euo pipefail
|
||||
# Blocks commits that add high-signal secrets.
|
||||
|
||||
if [[ "${ECC_SKIP_GIT_HOOKS:-0}" == "1" || "${ECC_SKIP_PRECOMMIT:-0}" == "1" ]]; then
|
||||
printf '[ECC pre-commit] WARNING: hook bypassed via env (ECC_SKIP_*=1)\n' >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -f ".ecc-hooks-disable" || -f ".git/ecc-hooks-disable" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
# NOTE: file-based disables (.ecc-hooks-disable) were removed — a malicious
|
||||
# repo could ship that file and silently turn off secret scanning exactly
|
||||
# where it is most needed. Use the env bypass above (audible warning) instead.
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
exit 0
|
||||
|
||||
@@ -5,12 +5,12 @@ set -euo pipefail
|
||||
# Runs a lightweight verification flow before pushes.
|
||||
|
||||
if [[ "${ECC_SKIP_GIT_HOOKS:-0}" == "1" || "${ECC_SKIP_PREPUSH:-0}" == "1" ]]; then
|
||||
printf '[ECC pre-push] WARNING: hook bypassed via env (ECC_SKIP_*=1)\n' >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [[ -f ".ecc-hooks-disable" || -f ".git/ecc-hooks-disable" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
# NOTE: file-based disables (.ecc-hooks-disable) were removed — a malicious
|
||||
# repo could ship that file and silently disable verification.
|
||||
|
||||
if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
||||
exit 0
|
||||
@@ -85,8 +85,14 @@ run_node_script() {
|
||||
}
|
||||
|
||||
if [[ -f "package.json" ]]; then
|
||||
pm="$(detect_pm)"
|
||||
log "Node project detected (package manager: $pm)"
|
||||
# SECURITY: executing a cloned repo's lint/test/build scripts on push is
|
||||
# arbitrary code execution (package.json scripts run as you). Opt-in only:
|
||||
# set ECC_PREPUSH_RUN_CHECKS=1 for repos you trust.
|
||||
if [[ "${ECC_PREPUSH_RUN_CHECKS:-0}" != "1" ]]; then
|
||||
printf '[ECC pre-push] Node project detected but ECC_PREPUSH_RUN_CHECKS!=1; skipping repo script execution (set =1 to opt in).\n' >&2
|
||||
else
|
||||
pm="$(detect_pm)"
|
||||
log "Node project detected (package manager: $pm)"
|
||||
|
||||
for script_name in lint typecheck test build; do
|
||||
if has_node_script "$script_name"; then
|
||||
@@ -98,7 +104,9 @@ if [[ -f "package.json" ]]; then
|
||||
fi
|
||||
done
|
||||
|
||||
fi
|
||||
if [[ "${ECC_PREPUSH_AUDIT:-0}" == "1" ]]; then
|
||||
pm="${pm:-$(detect_pm)}"
|
||||
ran_any_check=1
|
||||
log "Running dependency audit (ECC_PREPUSH_AUDIT=1)"
|
||||
case "$pm" in
|
||||
@@ -111,6 +119,9 @@ if [[ -f "package.json" ]]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# SECURITY: go test / pytest execute repo-controlled code (TestMain,
|
||||
# conftest.py). Same opt-in gate as Node scripts above.
|
||||
if [[ "${ECC_PREPUSH_RUN_CHECKS:-0}" == "1" ]]; then
|
||||
if [[ -f "go.mod" ]] && command -v go >/dev/null 2>&1; then
|
||||
ran_any_check=1
|
||||
log "Go project detected. Running: go test ./..."
|
||||
@@ -281,6 +292,11 @@ if [[ -f "pyproject.toml" || -f "requirements.txt" ]]; then
|
||||
log " venv, env, uv, poetry, PATH). Set ECC_PYTEST_CMD to point at it."
|
||||
fi
|
||||
fi
|
||||
else
|
||||
if [[ -f "go.mod" || -f "pyproject.toml" || -f "requirements.txt" ]]; then
|
||||
log "Go/Python project detected but ECC_PREPUSH_RUN_CHECKS!=1; skipping test execution."
|
||||
fi
|
||||
fi
|
||||
|
||||
|
||||
if [[ "$ran_any_check" -eq 0 ]]; then
|
||||
|
||||
@@ -41,6 +41,23 @@ log "Mode: $MODE"
|
||||
log "Source hooks: $SOURCE_DIR"
|
||||
log "Global hooks destination: $DEST_DIR"
|
||||
|
||||
prev_hooks_path="$(git config --global core.hooksPath || true)"
|
||||
if [[ -n "$prev_hooks_path" && "$prev_hooks_path" != "$DEST_DIR" ]]; then
|
||||
# SECURITY: never silently displace another tool's global hooks — that
|
||||
# turns every commit/push in every repo into ECC code execution and breaks
|
||||
# the user's existing security controls. Require explicit opt-in to replace.
|
||||
if [[ "${ECC_FORCE_GLOBAL_HOOKS:-0}" != "1" ]]; then
|
||||
log "ERROR: global core.hooksPath already set to: $prev_hooks_path"
|
||||
log "Refusing to overwrite. Options:"
|
||||
log " 1) Per-repo install (recommended): git config core.hooksPath \"$DEST_DIR\""
|
||||
log " 2) Force replace: ECC_FORCE_GLOBAL_HOOKS=1 $0"
|
||||
log " 3) Restore afterwards: git config --global core.hooksPath \"$prev_hooks_path\""
|
||||
exit 1
|
||||
fi
|
||||
log "WARNING: replacing previous global hooksPath: $prev_hooks_path (ECC_FORCE_GLOBAL_HOOKS=1)"
|
||||
log "Restore with: git config --global core.hooksPath \"$prev_hooks_path\""
|
||||
fi
|
||||
|
||||
if [[ -d "$DEST_DIR" ]]; then
|
||||
log "Backing up existing hooks directory to $BACKUP_DIR"
|
||||
run_or_echo mkdir -p "$BACKUP_DIR"
|
||||
@@ -51,15 +68,8 @@ run_or_echo mkdir -p "$DEST_DIR"
|
||||
run_or_echo cp "$SOURCE_DIR/pre-commit" "$DEST_DIR/pre-commit"
|
||||
run_or_echo cp "$SOURCE_DIR/pre-push" "$DEST_DIR/pre-push"
|
||||
run_or_echo chmod +x "$DEST_DIR/pre-commit" "$DEST_DIR/pre-push"
|
||||
|
||||
if [[ "$MODE" == "apply" ]]; then
|
||||
prev_hooks_path="$(git config --global core.hooksPath || true)"
|
||||
if [[ -n "$prev_hooks_path" ]]; then
|
||||
log "Previous global hooksPath: $prev_hooks_path"
|
||||
fi
|
||||
fi
|
||||
run_or_echo git config --global core.hooksPath "$DEST_DIR"
|
||||
|
||||
log "Installed ECC global git hooks."
|
||||
log "Disable per repo by creating .ecc-hooks-disable in project root."
|
||||
log "Temporary bypass: ECC_SKIP_PRECOMMIT=1 or ECC_SKIP_PREPUSH=1"
|
||||
log "Per-repo alternative (recommended): git config core.hooksPath \"$DEST_DIR\""
|
||||
log "Temporary bypass (audible): ECC_SKIP_GIT_HOOKS=1 (logs a warning to stderr)"
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
*
|
||||
* Reads transcript_path from Stop hook stdin, sums usage across all
|
||||
* assistant turns in the session JSONL, and appends one row to
|
||||
* ~/.claude/metrics/costs.jsonl.
|
||||
* ~/.claude/metrics/costs.jsonl. It also atomically publishes the latest
|
||||
* cumulative row under metrics/cost-snapshots/ so frequent PostToolUse
|
||||
* hooks do not need to rescan the unbounded history.
|
||||
*
|
||||
* Stop hook stdin payload: { session_id, transcript_path, cwd, hook_event_name, ... }
|
||||
* The Stop payload does NOT include `usage` or `model` directly. The previous
|
||||
@@ -40,8 +42,12 @@
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { ensureDir, appendFile, getClaudeDir } = require('../lib/utils');
|
||||
const { ensureDir, getClaudeDir } = require('../lib/utils');
|
||||
const { sanitizeSessionId } = require('../lib/session-bridge');
|
||||
const {
|
||||
appendSessionCostRow,
|
||||
warnSessionCostSnapshotFailure
|
||||
} = require('../lib/session-cost-snapshot');
|
||||
|
||||
const HARNESS_COST_MAX_AGE_SECONDS = 300;
|
||||
|
||||
@@ -103,7 +109,17 @@ function isSonnet5(model) {
|
||||
|
||||
function toNumber(v) {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
return Number.isFinite(n) && n >= 0 ? n : 0;
|
||||
}
|
||||
|
||||
function normalizeUsageTotals(totals) {
|
||||
return {
|
||||
inputTokens: toNumber(totals.inputTokens),
|
||||
outputTokens: toNumber(totals.outputTokens),
|
||||
cacheWriteTokens: toNumber(totals.cacheWriteTokens),
|
||||
cacheReadTokens: toNumber(totals.cacheReadTokens),
|
||||
model: totals.model
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -161,7 +177,9 @@ function sumUsageFromTranscript(transcriptPath) {
|
||||
cacheReadTokens += toNumber(u.cache_read_input_tokens);
|
||||
}
|
||||
|
||||
return { inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model };
|
||||
return normalizeUsageTotals({
|
||||
inputTokens, outputTokens, cacheWriteTokens, cacheReadTokens, model
|
||||
});
|
||||
}
|
||||
|
||||
// 1MB, matching the other Stop hooks. The Stop payload carries
|
||||
@@ -242,7 +260,11 @@ process.stdin.on('end', () => {
|
||||
estimated_cost_usd: estimatedCostUsd
|
||||
};
|
||||
|
||||
appendFile(path.join(metricsDir, 'costs.jsonl'), `${JSON.stringify(row)}\n`);
|
||||
try {
|
||||
appendSessionCostRow(metricsDir, sessionId, row);
|
||||
} catch (error) {
|
||||
warnSessionCostSnapshotFailure('publication', metricsDir, sessionId, error);
|
||||
}
|
||||
} catch {
|
||||
// Non-blocking — never fail the Stop hook.
|
||||
}
|
||||
|
||||
@@ -14,6 +14,10 @@ const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { sanitizeSessionId, readBridge, writeBridgeAtomic } = require('../lib/session-bridge');
|
||||
const {
|
||||
readSessionCostSnapshot,
|
||||
warnSessionCostSnapshotFailure
|
||||
} = require('../lib/session-cost-snapshot');
|
||||
const { getClaudeDir } = require('../lib/utils');
|
||||
|
||||
const MAX_STDIN = 1024 * 1024;
|
||||
@@ -22,11 +26,6 @@ const RECENT_TOOLS_SIZE = 5;
|
||||
const HASH_INPUT_LIMIT = 2048;
|
||||
const WARNING_CACHE_PREFIX = 'ecc-metrics-cost-warnings-';
|
||||
|
||||
function toNumber(value) {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) ? n : 0;
|
||||
}
|
||||
|
||||
function stableStringify(value, depth = 0) {
|
||||
if (depth > 4) return '[depth-limit]';
|
||||
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
||||
@@ -134,60 +133,51 @@ function writeCostWarningIfChanged(kind, costsPath, signature, message) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Read cumulative cost for a session from costs.jsonl.
|
||||
* Read cumulative cost for a session.
|
||||
*
|
||||
* Scans the full file because each row is a cumulative session total
|
||||
* (see cost-tracker.js docblock) and the row we need is the last one
|
||||
* matching `sessionId`. The previous implementation read only the
|
||||
* trailing 8 KiB; any session whose latest cumulative row was pushed
|
||||
* past that window by newer rows from other sessions silently dropped
|
||||
* to zero — the opposite sign of the double-count bug fixed in the
|
||||
* previous commit.
|
||||
* The Stop hook publishes an atomic per-session cursor snapshot, so a stable
|
||||
* PostToolUse path reads O(1) metadata and newly appended data is O(delta)
|
||||
* instead of reparsing unbounded history.
|
||||
* Older ECC installations and damaged/missing snapshots remain compatible:
|
||||
* they fall back to scanning costs.jsonl for the last cumulative row.
|
||||
*
|
||||
* costs.jsonl is append-only and unbounded today (no rotation in
|
||||
* cost-tracker.js). At a typical ~150 bytes per row, even 100k rows
|
||||
* is ~15 MB and a single sync read on every PostToolUse hook is in
|
||||
* the low milliseconds. If rotation lands later, this scan becomes
|
||||
* even cheaper.
|
||||
* The fallback deliberately scans the whole file. A fixed tail window loses
|
||||
* sessions whose newest row has been pushed back by other sessions.
|
||||
*/
|
||||
function readSessionCost(sessionId) {
|
||||
let costsPath = path.join('metrics', 'costs.jsonl');
|
||||
try {
|
||||
costsPath = path.join(getClaudeDir(), 'metrics', 'costs.jsonl');
|
||||
const content = fs.readFileSync(costsPath, 'utf8');
|
||||
const lines = content.split('\n').filter(Boolean);
|
||||
|
||||
let totalCost = 0;
|
||||
let totalIn = 0;
|
||||
let totalOut = 0;
|
||||
let malformed = 0;
|
||||
const malformedHasher = crypto.createHash('sha256');
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const row = JSON.parse(line);
|
||||
if (row.session_id === sessionId) {
|
||||
totalCost = toNumber(row.estimated_cost_usd);
|
||||
totalIn = toNumber(row.input_tokens);
|
||||
totalOut = toNumber(row.output_tokens);
|
||||
}
|
||||
} catch {
|
||||
malformed += 1;
|
||||
malformedHasher.update(line).update('\0');
|
||||
}
|
||||
}
|
||||
// One aggregated breadcrumb per call rather than one per bad row, so a
|
||||
// log-flooded costs.jsonl stays diagnosable without overwhelming stderr.
|
||||
// Suppress repeats for the same malformed-line signature across hook
|
||||
// subprocesses, so a persistent bad row should not spam stderr.
|
||||
if (malformed > 0) {
|
||||
const metricsDir = path.join(getClaudeDir(), 'metrics');
|
||||
costsPath = path.join(metricsDir, 'costs.jsonl');
|
||||
const snapshotResult = readSessionCostSnapshot(metricsDir, sessionId);
|
||||
if (snapshotResult.malformed > 0) {
|
||||
writeCostWarningIfChanged(
|
||||
'malformed',
|
||||
costsPath,
|
||||
`${malformed}:${malformedHasher.digest('hex').slice(0, 16)}`,
|
||||
`[ecc-metrics-bridge] skipped ${malformed} malformed line(s) in ${costsPath}\n`
|
||||
`${snapshotResult.malformed}:${snapshotResult.malformedSignature}`,
|
||||
`[ecc-metrics-bridge] skipped ${snapshotResult.malformed} malformed line(s) during the snapshot scan of ${costsPath}\n`
|
||||
);
|
||||
}
|
||||
return { totalCost, totalIn, totalOut };
|
||||
if (snapshotResult.invalid > 0) {
|
||||
writeCostWarningIfChanged(
|
||||
'invalid-row',
|
||||
costsPath,
|
||||
`${snapshotResult.invalid}:${snapshotResult.invalidSignature}`,
|
||||
`[ecc-metrics-bridge] skipped ${snapshotResult.invalid} invalid cumulative row(s) for ${sessionId} during the snapshot scan of ${costsPath}\n`
|
||||
);
|
||||
}
|
||||
if (snapshotResult.snapshotError) {
|
||||
warnSessionCostSnapshotFailure(
|
||||
'repair',
|
||||
metricsDir,
|
||||
sessionId,
|
||||
snapshotResult.snapshotError
|
||||
);
|
||||
}
|
||||
const row = snapshotResult.row;
|
||||
return row
|
||||
? { totalCost: row.estimated_cost_usd, totalIn: row.input_tokens, totalOut: row.output_tokens }
|
||||
: { totalCost: 0, totalIn: 0, totalOut: 0 };
|
||||
} catch (err) {
|
||||
// ENOENT is the common case (no Stop event has fired yet this session)
|
||||
// and is not actually a failure — stay silent on it. Anything else
|
||||
@@ -259,7 +249,7 @@ function run(rawInput) {
|
||||
if (recent.length > RECENT_TOOLS_SIZE) recent.shift();
|
||||
bridge.recent_tools = recent;
|
||||
|
||||
// Update cost from costs.jsonl tail
|
||||
// Use the O(1) session snapshot, with JSONL compatibility fallback.
|
||||
const costs = readSessionCost(sessionId);
|
||||
bridge.total_cost_usd = Math.round(costs.totalCost * 1e6) / 1e6;
|
||||
bridge.total_input_tokens = costs.totalIn;
|
||||
|
||||
@@ -182,6 +182,12 @@ function extractMcpTargetFromRaw(raw) {
|
||||
}
|
||||
|
||||
function resolveServerConfig(serverName) {
|
||||
// SECURITY: serverName flows into env-var lookup and shell-adjacent paths.
|
||||
// Reject anything outside a strict token so config-controlled names cannot
|
||||
// inject shell metachars ($(..), backticks, ;) downstream.
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(String(serverName || ''))) {
|
||||
return null;
|
||||
}
|
||||
for (const filePath of configPaths()) {
|
||||
const data = readJsonFile(filePath);
|
||||
const server = data?.mcpServers?.[serverName]
|
||||
@@ -306,9 +312,21 @@ function probeCommandServer(serverName, config) {
|
||||
const command = config.command;
|
||||
const args = Array.isArray(config.args) ? config.args.map(arg => String(arg)) : [];
|
||||
const timeoutMs = envNumber('ECC_MCP_HEALTH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS);
|
||||
// SECURITY: config.env comes from repo-committed MCP configs. Never let it
|
||||
// override process-critical loader vars that turn into code execution
|
||||
// (LD_PRELOAD, DYLD_*, NODE_OPTIONS, PATH tampering, etc.).
|
||||
const BLOCKED_ENV_PREFIXES = ['LD_', 'DYLD_', 'NODE_OPTIONS', 'NODE_PATH', 'PATH', 'PYTHONPATH', 'RUBYLIB', 'PERL5LIB'];
|
||||
const rawEnv = (config.env && typeof config.env === 'object' && !Array.isArray(config.env) ? config.env : {});
|
||||
const safeConfigEnv = {};
|
||||
for (const [k, v] of Object.entries(rawEnv)) {
|
||||
if (BLOCKED_ENV_PREFIXES.some(p => String(k).toUpperCase().startsWith(p))) {
|
||||
continue;
|
||||
}
|
||||
safeConfigEnv[k] = String(v);
|
||||
}
|
||||
const mergedEnv = {
|
||||
...process.env,
|
||||
...(config.env && typeof config.env === 'object' && !Array.isArray(config.env) ? config.env : {})
|
||||
...safeConfigEnv
|
||||
};
|
||||
|
||||
let done = false;
|
||||
@@ -515,6 +533,38 @@ function probeCommandServer(serverName, config) {
|
||||
async function probeServer(serverName, resolvedConfig) {
|
||||
const config = resolvedConfig.config;
|
||||
|
||||
// SECURITY: cloning a malicious repo must not auto-execute its MCP servers.
|
||||
// Workspace configs (cwd .claude.json / .claude/settings.json) are untrusted
|
||||
// by default; only probe them with explicit operator opt-in.
|
||||
// Home configs (~/.claude.json) and explicit ECC_MCP_CONFIG_PATH remain allowed.
|
||||
try {
|
||||
const src = String(resolvedConfig.source || '');
|
||||
const cwd = process.cwd();
|
||||
const home = require('os').homedir();
|
||||
const pathMod = require('path');
|
||||
// A config file in the user's home directory (~/.claude.json or
|
||||
// ~/.claude/settings.json) is always trusted regardless of cwd.
|
||||
const isHomeSource = src === pathMod.join(home, '.claude.json')
|
||||
|| src === pathMod.join(home, '.claude', 'settings.json')
|
||||
|| src.startsWith(pathMod.join(home, '.claude') + pathMod.sep);
|
||||
if (!isHomeSource) {
|
||||
const isWorkspaceSource = src === pathMod.join(cwd, '.claude.json')
|
||||
|| src === pathMod.join(cwd, '.claude', 'settings.json')
|
||||
|| src.startsWith(cwd + pathMod.sep + '.claude' + pathMod.sep);
|
||||
if (isWorkspaceSource && !/^(1|true|yes)$/i.test(String(process.env.ECC_MCP_ALLOW_WORKSPACE_PROBE || ''))) {
|
||||
return {
|
||||
ok: false,
|
||||
failureCode: null,
|
||||
reason: 'untrusted workspace MCP config skipped (set ECC_MCP_ALLOW_WORKSPACE_PROBE=1 to probe)',
|
||||
source: resolvedConfig.source
|
||||
};
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fail closed on path errors for workspace sources is handled below;
|
||||
// continue to normal probing for non-workspace sources.
|
||||
}
|
||||
|
||||
if (config.type === 'http' || config.url) {
|
||||
const result = await requestHttp(config.url, config.headers || {}, envNumber('ECC_MCP_HEALTH_TIMEOUT_MS', DEFAULT_TIMEOUT_MS));
|
||||
|
||||
@@ -546,6 +596,15 @@ async function probeServer(serverName, resolvedConfig) {
|
||||
}
|
||||
|
||||
function reconnectCommand(serverName) {
|
||||
// SECURITY: reconnect commands are shell strings from env. Disabled by
|
||||
// default; require explicit opt-in so a malicious .env/direnv cannot gain
|
||||
// shell execution through this hook.
|
||||
if (!/^(1|true|yes)$/i.test(String(process.env.ECC_MCP_RECONNECT_ALLOW || ''))) {
|
||||
return null;
|
||||
}
|
||||
if (!/^[A-Za-z0-9_-]{1,64}$/.test(String(serverName || ''))) {
|
||||
return null;
|
||||
}
|
||||
const key = `ECC_MCP_RECONNECT_${String(serverName).toUpperCase().replace(/[^A-Z0-9]/g, '_')}`;
|
||||
const command = process.env[key] || process.env.ECC_MCP_RECONNECT_COMMAND || '';
|
||||
if (!command.trim()) {
|
||||
@@ -563,8 +622,60 @@ function attemptReconnect(serverName) {
|
||||
return { attempted: false, success: false, reason: 'no reconnect command configured' };
|
||||
}
|
||||
|
||||
const result = spawnSync(command, {
|
||||
shell: true,
|
||||
// SECURITY: never run reconnect strings through a shell. Split on
|
||||
// whitespace (no glob/expansion/substitution) and spawn directly.
|
||||
// Supports single/double quotes for paths with spaces (e.g. node
|
||||
// "/tmp/dir with space/reconnect.js"). No variable, command, tilde, or
|
||||
// glob expansion is performed. {server} was already validated above.
|
||||
function splitReconnectCommand(s) {
|
||||
const parts = [];
|
||||
let cur = '';
|
||||
let quote = null;
|
||||
let inToken = false;
|
||||
for (let i = 0; i < s.length; i++) {
|
||||
const ch = s[i];
|
||||
if (quote) {
|
||||
if (ch === quote) {
|
||||
quote = null;
|
||||
} else if (ch === '\\' && quote === '"' && i + 1 < s.length && (s[i + 1] === '"' || s[i + 1] === '\\')) {
|
||||
cur += s[i + 1];
|
||||
i++;
|
||||
} else {
|
||||
cur += ch;
|
||||
}
|
||||
} else if (ch === '"' || ch === "'") {
|
||||
quote = ch;
|
||||
inToken = true;
|
||||
} else if (/\s/.test(ch)) {
|
||||
if (inToken) {
|
||||
parts.push(cur);
|
||||
cur = '';
|
||||
inToken = false;
|
||||
}
|
||||
} else {
|
||||
cur += ch;
|
||||
inToken = true;
|
||||
}
|
||||
}
|
||||
if (quote) {
|
||||
return null; // unbalanced quote
|
||||
}
|
||||
if (inToken) {
|
||||
parts.push(cur);
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
const parts = splitReconnectCommand(String(command).trim());
|
||||
if (!parts || parts.length === 0) {
|
||||
return { attempted: false, success: false, reason: 'invalid reconnect command' };
|
||||
}
|
||||
const [bin, ...argv] = parts;
|
||||
if (/[&|<>^%!`$();]/.test(bin) || argv.some(a => /[`$]/.test(a))) {
|
||||
return { attempted: false, success: false, reason: 'reconnect command contains unsafe characters' };
|
||||
}
|
||||
|
||||
const result = spawnSync(bin, argv, {
|
||||
shell: false,
|
||||
env: process.env,
|
||||
cwd: process.cwd(),
|
||||
encoding: 'utf8',
|
||||
|
||||
@@ -22,9 +22,31 @@ if [[ "$ENABLED" != "yes" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
SCRIPT_PATH="${PLUGIN_ROOT}/${REL_SCRIPT_PATH}"
|
||||
if [[ ! -f "$SCRIPT_PATH" ]]; then
|
||||
echo "[Hook] Script not found for ${HOOK_ID}: ${SCRIPT_PATH}" >&2
|
||||
# Reject traversal / absolute / env-escape paths before touching the filesystem.
|
||||
# Mirrors the containment check in run-with-flags.js (resolvedRoot prefix).
|
||||
case "$REL_SCRIPT_PATH" in
|
||||
/*|\\*|~*|*..*|*\$*|*\`*|*\|*|*\;*|*\&*|*\<*|*\>*|*\"*|*\'*|*\ *|*" "*)
|
||||
echo "[Hook] Path traversal rejected for ${HOOK_ID}: ${REL_SCRIPT_PATH}" >&2
|
||||
printf '%s' "$INPUT"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# Canonicalize PLUGIN_ROOT (CLAUDE_PLUGIN_ROOT is env-controlled) and the
|
||||
# candidate script path, then enforce containment inside the plugin root.
|
||||
PLUGIN_ROOT_CANON="$(realpath -m "$PLUGIN_ROOT" 2>/dev/null || readlink -f "$PLUGIN_ROOT" 2>/dev/null || printf '%s' "$PLUGIN_ROOT")"
|
||||
SCRIPT_PATH="${PLUGIN_ROOT_CANON}/${REL_SCRIPT_PATH}"
|
||||
SCRIPT_CANON="$(realpath -m "$SCRIPT_PATH" 2>/dev/null || readlink -f "$SCRIPT_PATH" 2>/dev/null || printf '%s' "$SCRIPT_PATH")"
|
||||
case "$SCRIPT_CANON" in
|
||||
"$PLUGIN_ROOT_CANON"/*) ;;
|
||||
*)
|
||||
echo "[Hook] Path traversal rejected for ${HOOK_ID}: ${REL_SCRIPT_PATH}" >&2
|
||||
printf '%s' "$INPUT"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
if [[ ! -f "$SCRIPT_CANON" ]]; then
|
||||
echo "[Hook] Script not found for ${HOOK_ID}: ${SCRIPT_CANON}" >&2
|
||||
printf '%s' "$INPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -33,4 +55,4 @@ fi
|
||||
# This is needed by scripts like observe.sh that behave differently for PreToolUse vs PostToolUse
|
||||
HOOK_PHASE="${HOOK_ID%%:*}"
|
||||
|
||||
printf '%s' "$INPUT" | "$SCRIPT_PATH" "$HOOK_PHASE"
|
||||
printf '%s' "$INPUT" | "$SCRIPT_CANON" "$HOOK_PHASE"
|
||||
|
||||
@@ -227,7 +227,18 @@ async function main() {
|
||||
// which would interfere with the parent process or cause double execution.
|
||||
let hookModule;
|
||||
const src = fs.readFileSync(scriptPath, 'utf8');
|
||||
const hasRunExport = /\bmodule\.exports\b/.test(src) && /\brun\b/.test(src);
|
||||
// Gate require() on concrete export syntax, not a bare word match: the old
|
||||
// /\bmodule\.exports\b/ && /\brun\b/ test fired on comments, strings, and
|
||||
// unrelated properties, causing require() — and its module-scope side
|
||||
// effects — to run for hooks that export no run(). Still lexical (no parser
|
||||
// dependency), but requires an actual export assignment form.
|
||||
const RUN_EXPORT_PATTERNS = [
|
||||
/module\.exports\s*\.\s*run\s*=/,
|
||||
/exports\s*\.\s*run\s*=/,
|
||||
/module\.exports\s*=\s*\{[^}]*\brun\b/,
|
||||
/module\.exports\s*=\s*(async\s+)?function\s+run\b/,
|
||||
];
|
||||
const hasRunExport = RUN_EXPORT_PATTERNS.some(re => re.test(src));
|
||||
|
||||
if (hasRunExport) {
|
||||
try {
|
||||
|
||||
@@ -131,8 +131,15 @@ function removeOpenedRegularFile(filePath, opened) {
|
||||
function atomicWriteJson(filePath, value) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
||||
const tempPath = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.renameSync(tempPath, filePath);
|
||||
try {
|
||||
fs.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
|
||||
fs.renameSync(tempPath, filePath);
|
||||
} catch (error) {
|
||||
// A failed write/rename must not leave a .tmp-<pid>-<timestamp> file
|
||||
// beside the canonical state file; repeated failures would accumulate them.
|
||||
fs.rmSync(tempPath, { force: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function readState(statePath) {
|
||||
|
||||
@@ -9,6 +9,8 @@ const { loadInstallManifests } = require('./install-manifests');
|
||||
const { readInstallState, validateInstallState } = require('./install-state');
|
||||
const { assertWithinTrustedRoot } = require('./path-safety');
|
||||
const { createInstallPlanFromRequest } = require('./install/runtime');
|
||||
const { assertNoNewUserOwnedFile, prepareUserOwnedFileGuard } = require('./install/ownership-guard');
|
||||
const { isCodexUserConfig } = require('./install/codex-user-config');
|
||||
const { getRecordedHookConsent } = require('./install/hook-consent');
|
||||
const {
|
||||
prepareClaudeSkillMigration,
|
||||
@@ -1871,7 +1873,7 @@ function assertValidInstallStateForWrite(state, label) {
|
||||
throw new Error(`Invalid install-state (${label}): ${details}`);
|
||||
}
|
||||
|
||||
function writeRefreshedInstallState(record, statePreview) {
|
||||
function writeRefreshedInstallState(record, statePreview, writtenPaths = []) {
|
||||
const trustedStatePreview = buildAdapterDerivedStatePreview(statePreview, record);
|
||||
const stateWithCurrentDigests = {
|
||||
...trustedStatePreview,
|
||||
@@ -1879,6 +1881,19 @@ function writeRefreshedInstallState(record, statePreview) {
|
||||
if (!operation.destinationPath) {
|
||||
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)
|
||||
&& !writtenPaths.some(writtenPath => path.relative(writtenPath, operation.destinationPath) === '')) {
|
||||
const previousOperation = (record.state.operations || []).find(previous => (
|
||||
previous.destinationPath
|
||||
&& path.relative(previous.destinationPath, operation.destinationPath) === ''
|
||||
));
|
||||
const { contentSha256: _plannedDigest, ...operationWithoutDigest } = operation;
|
||||
return previousOperation && previousOperation.contentSha256
|
||||
? { ...operationWithoutDigest, contentSha256: previousOperation.contentSha256 }
|
||||
: operationWithoutDigest;
|
||||
}
|
||||
try {
|
||||
const contentSha256 = crypto.createHash('sha256')
|
||||
.update(readFileNoFollow(operation.destinationPath))
|
||||
@@ -1908,7 +1923,10 @@ function prepareRepairMigration(plan, record) {
|
||||
installStatePath: record.installStatePath,
|
||||
statePreview: buildAdapterDerivedStatePreview(plan.statePreview, record),
|
||||
};
|
||||
const migration = prepareClaudeSkillMigration(trustedPlan);
|
||||
const skillMigration = prepareClaudeSkillMigration(trustedPlan);
|
||||
const migration = record.adapter.id === 'codex-home'
|
||||
? prepareUserOwnedFileGuard(trustedPlan, skillMigration)
|
||||
: skillMigration;
|
||||
return {
|
||||
migration,
|
||||
plan: {
|
||||
@@ -2157,6 +2175,9 @@ function repairInstalledStates(options = {}) {
|
||||
}
|
||||
|
||||
for (const operation of repairOperations) {
|
||||
if (record.adapter.id === 'codex-home') {
|
||||
assertNoNewUserOwnedFile(migration, operation, desiredPlan);
|
||||
}
|
||||
const repairedPath = executeRepairOperation(
|
||||
context.repoRoot,
|
||||
operation,
|
||||
@@ -2192,7 +2213,7 @@ function repairInstalledStates(options = {}) {
|
||||
installedAt: record.state.installedAt,
|
||||
source: { ...record.state.source },
|
||||
};
|
||||
writeRefreshedInstallState(record, statePreviewToWrite);
|
||||
writeRefreshedInstallState(record, statePreviewToWrite, repairedPaths);
|
||||
|
||||
return {
|
||||
adapter: record.adapter,
|
||||
|
||||
@@ -498,7 +498,7 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
if (typeof beforeOperationWrite === 'function') {
|
||||
beforeOperationWrite({ plan: appliedPlan, operation });
|
||||
}
|
||||
assertNoNewUserOwnedFile(migration, operation);
|
||||
assertNoNewUserOwnedFile(migration, operation, appliedPlan);
|
||||
|
||||
if (
|
||||
operation.kind === 'update-claude-settings'
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { assertWithinTrustedRoot } = require('../path-safety');
|
||||
|
||||
function isCodexUserConfig(plan, operation) {
|
||||
if (plan.adapter.id !== 'codex-home' || operation.kind !== 'copy-file') {
|
||||
return false;
|
||||
}
|
||||
const relativePath = path.relative(plan.targetRoot, operation.destinationPath);
|
||||
const name = process.platform === 'win32' ? relativePath.toLowerCase() : relativePath;
|
||||
return name === 'config.toml' || name === (process.platform === 'win32' ? 'agents.md' : 'AGENTS.md');
|
||||
}
|
||||
|
||||
function readConfigDigest(plan, destinationPath) {
|
||||
assertWithinTrustedRoot(destinationPath, plan.targetRoot, 'inspect Codex user configuration');
|
||||
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
||||
const descriptor = fs.openSync(destinationPath, flags);
|
||||
try {
|
||||
const opened = fs.fstatSync(descriptor, { bigint: true });
|
||||
const current = fs.lstatSync(destinationPath, { bigint: true });
|
||||
if (!opened.isFile() || !current.isFile() || current.isSymbolicLink()
|
||||
|| opened.ino !== current.ino || opened.dev !== current.dev) {
|
||||
throw new Error(`Refusing to inspect changed Codex configuration: ${destinationPath}`);
|
||||
}
|
||||
assertWithinTrustedRoot(destinationPath, plan.targetRoot, 'inspect Codex user configuration');
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(descriptor)).digest('hex');
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function hasEditedCodexUserConfig(plan, operation, previousOperation) {
|
||||
if (!isCodexUserConfig(plan, operation)) {
|
||||
return false;
|
||||
}
|
||||
let digest;
|
||||
try {
|
||||
digest = readConfigDigest(plan, operation.destinationPath);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return false; // A missing scaffold can still be restored.
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
// Compare with the bytes ECC actually installed, never the newest template.
|
||||
// Old ledgers without a digest cannot prove that an existing file is unchanged.
|
||||
const recorded = previousOperation && previousOperation.contentSha256;
|
||||
return !/^[a-f0-9]{64}$/i.test(recorded || '') || digest !== recorded.toLowerCase();
|
||||
}
|
||||
|
||||
module.exports = { hasEditedCodexUserConfig, isCodexUserConfig };
|
||||
@@ -4,6 +4,7 @@ const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { readInstallState } = require('../install-state');
|
||||
const { hasEditedCodexUserConfig } = require('./codex-user-config');
|
||||
|
||||
function pathExists(filePath) {
|
||||
try {
|
||||
@@ -60,24 +61,32 @@ function prepareUserOwnedFileGuard(plan, migration) {
|
||||
);
|
||||
const managedDestinations = new Set(previousManagedOperations.keys());
|
||||
|
||||
const appliedOperations = [];
|
||||
const plannedOperations = (migration && migration.appliedOperations) || [];
|
||||
const plannedDestinations = new Set(plannedOperations.map(operation => comparablePath(operation.destinationPath)));
|
||||
// Selective reinstalls retain earlier modules in the ledger. Inspect those
|
||||
// entries too, without turning them into additional writes in this install.
|
||||
const retainedOperations = ((migration.finalState && migration.finalState.operations) || [])
|
||||
.filter(operation => !plannedDestinations.has(comparablePath(operation.destinationPath))
|
||||
&& previousManagedOperations.has(comparablePath(operation.destinationPath)));
|
||||
const skippedOperations = [];
|
||||
const warnings = [];
|
||||
for (const operation of (migration && migration.appliedOperations) || []) {
|
||||
for (const operation of [...plannedOperations, ...retainedOperations]) {
|
||||
const previousOperation = previousManagedOperations.get(comparablePath(operation.destinationPath));
|
||||
const editedConfig = previousOperation
|
||||
&& hasEditedCodexUserConfig(plan, operation, previousOperation);
|
||||
if (
|
||||
operation
|
||||
&& operation.kind === 'copy-file'
|
||||
&& operation.destinationPath
|
||||
&& pathExists(operation.destinationPath)
|
||||
&& !managedDestinations.has(comparablePath(operation.destinationPath))
|
||||
&& (!managedDestinations.has(comparablePath(operation.destinationPath)) || editedConfig)
|
||||
) {
|
||||
skippedOperations.push(operation);
|
||||
warnings.push(
|
||||
`Skipped user-owned file ${operation.destinationPath}: the existing file is not recorded in ECC install-state.`
|
||||
);
|
||||
warnings.push(editedConfig
|
||||
? `Preserved user configuration ${operation.destinationPath}: changed or unverifiable since installation. ECC no longer manages this file; apply future configuration updates manually.`
|
||||
: `Skipped user-owned file ${operation.destinationPath}: the existing file is not recorded in ECC install-state.`);
|
||||
continue;
|
||||
}
|
||||
appliedOperations.push(operation);
|
||||
}
|
||||
|
||||
if (skippedOperations.length === 0) {
|
||||
@@ -87,6 +96,9 @@ function prepareUserOwnedFileGuard(plan, migration) {
|
||||
const skippedDestinations = new Set(
|
||||
skippedOperations.map(operation => comparablePath(operation.destinationPath))
|
||||
);
|
||||
const appliedOperations = plannedOperations.filter(operation => (
|
||||
!skippedDestinations.has(comparablePath(operation.destinationPath))
|
||||
));
|
||||
const filterStateOperations = operations => (operations || [])
|
||||
.filter(operation => !skippedDestinations.has(comparablePath(operation.destinationPath)));
|
||||
|
||||
@@ -125,7 +137,11 @@ function prepareUserOwnedFileGuard(plan, migration) {
|
||||
};
|
||||
}
|
||||
|
||||
function assertNoNewUserOwnedFile(migration, operation) {
|
||||
function assertNoNewUserOwnedFile(migration, operation, plan) {
|
||||
const previousOperation = migration.previousManagedOperations.get(comparablePath(operation.destinationPath));
|
||||
if (plan && hasEditedCodexUserConfig(plan, operation, previousOperation)) {
|
||||
throw new Error(`Refusing to overwrite user configuration changed after planning: ${operation.destinationPath}. Rerun to preserve it.`);
|
||||
}
|
||||
if (operation.kind !== 'copy-file'
|
||||
|| migration.managedDestinations.has(comparablePath(operation.destinationPath))
|
||||
|| !pathExists(operation.destinationPath)) {
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { writeFileAtomic } = require('./atomic-write');
|
||||
const { sanitizeSessionId } = require('./session-bridge');
|
||||
|
||||
const COST_SNAPSHOT_SCHEMA_VERSION = 'ecc.cost-snapshot.v1';
|
||||
const COST_SNAPSHOT_DIRECTORY = 'cost-snapshots';
|
||||
const COST_LOG_FILENAME = 'costs.jsonl';
|
||||
const READ_CHUNK_BYTES = 64 * 1024;
|
||||
const MAX_JSONL_LINE_BYTES = 1024 * 1024;
|
||||
const MAX_SCAN_BYTES = 16 * 1024 * 1024;
|
||||
const FINGERPRINT_WINDOW_BYTES = 256;
|
||||
const PRUNE_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
||||
const SNAPSHOT_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const MAX_SNAPSHOTS = 512;
|
||||
const WARNING_CACHE_PREFIX = 'ecc-cost-snapshot-warnings-';
|
||||
|
||||
function assertSafeSessionId(sessionId) {
|
||||
if (sanitizeSessionId(sessionId) !== sessionId) {
|
||||
throw new Error('Cost snapshot requires a safe session ID');
|
||||
}
|
||||
}
|
||||
|
||||
function getSnapshotDirectory(metricsDir) {
|
||||
return path.join(metricsDir, COST_SNAPSHOT_DIRECTORY);
|
||||
}
|
||||
|
||||
function getCostSnapshotPath(metricsDir, sessionId) {
|
||||
assertSafeSessionId(sessionId);
|
||||
return path.join(getSnapshotDirectory(metricsDir), `session-${sessionId}.json`);
|
||||
}
|
||||
|
||||
function isValidCostRow(row, sessionId) {
|
||||
return row?.session_id === sessionId
|
||||
&& typeof row.estimated_cost_usd === 'number'
|
||||
&& Number.isFinite(row.estimated_cost_usd)
|
||||
&& row.estimated_cost_usd >= 0
|
||||
&& typeof row.input_tokens === 'number'
|
||||
&& Number.isFinite(row.input_tokens)
|
||||
&& row.input_tokens >= 0
|
||||
&& typeof row.output_tokens === 'number'
|
||||
&& Number.isFinite(row.output_tokens)
|
||||
&& row.output_tokens >= 0;
|
||||
}
|
||||
|
||||
function readJsonFile(filePath) {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function chooseNewerCumulativeRow(currentRow, nextRow) {
|
||||
if (!currentRow) return nextRow;
|
||||
const nextDominates = nextRow.input_tokens >= currentRow.input_tokens
|
||||
&& nextRow.output_tokens >= currentRow.output_tokens
|
||||
&& nextRow.estimated_cost_usd >= currentRow.estimated_cost_usd;
|
||||
const currentDominates = currentRow.input_tokens >= nextRow.input_tokens
|
||||
&& currentRow.output_tokens >= nextRow.output_tokens
|
||||
&& currentRow.estimated_cost_usd >= nextRow.estimated_cost_usd;
|
||||
if (nextDominates && !currentDominates) return nextRow;
|
||||
if (currentDominates && !nextDominates) return currentRow;
|
||||
const nextTimestamp = Date.parse(nextRow.timestamp);
|
||||
const currentTimestamp = Date.parse(currentRow.timestamp);
|
||||
if (Number.isFinite(nextTimestamp) && Number.isFinite(currentTimestamp)) {
|
||||
return nextTimestamp >= currentTimestamp ? nextRow : currentRow;
|
||||
}
|
||||
return nextRow;
|
||||
}
|
||||
|
||||
function sourceIdentity(stat) {
|
||||
return `${stat.dev}:${stat.ino}`;
|
||||
}
|
||||
|
||||
function hashWindow(descriptor, position, length) {
|
||||
const buffer = Buffer.alloc(length);
|
||||
if (length > 0) fs.readSync(descriptor, buffer, 0, length, position);
|
||||
return crypto.createHash('sha256').update(buffer).digest('hex');
|
||||
}
|
||||
|
||||
function fingerprintProcessedPrefix(descriptor, offset) {
|
||||
const windowLength = Math.min(FINGERPRINT_WINDOW_BYTES, offset);
|
||||
const middleStart = Math.max(0, Math.floor((offset - windowLength) / 2));
|
||||
return {
|
||||
start: hashWindow(descriptor, 0, windowLength),
|
||||
middle: hashWindow(descriptor, middleStart, windowLength),
|
||||
end: hashWindow(descriptor, offset - windowLength, windowLength)
|
||||
};
|
||||
}
|
||||
|
||||
function fingerprintsMatch(left, right) {
|
||||
return left?.start === right?.start
|
||||
&& left?.middle === right?.middle
|
||||
&& left?.end === right?.end;
|
||||
}
|
||||
|
||||
function validSnapshotBase(snapshot, descriptor, stat, sessionId) {
|
||||
if (snapshot?.schema_version !== COST_SNAPSHOT_SCHEMA_VERSION) return null;
|
||||
if (snapshot.row !== null && !isValidCostRow(snapshot.row, sessionId)) return null;
|
||||
const source = snapshot.source;
|
||||
if (source?.identity !== sourceIdentity(stat)) return null;
|
||||
if (!Number.isSafeInteger(source.offset_bytes) || source.offset_bytes < 0) return null;
|
||||
if (source.offset_bytes > stat.size) return null;
|
||||
if (source.offset_bytes === stat.size && source.mtime_ms !== stat.mtimeMs) return null;
|
||||
if (!fingerprintsMatch(
|
||||
source.fingerprint,
|
||||
fingerprintProcessedPrefix(descriptor, source.offset_bytes)
|
||||
)) return null;
|
||||
return {
|
||||
row: snapshot.row,
|
||||
offset: source.offset_bytes,
|
||||
discardingLine: source.discarding_line === true
|
||||
};
|
||||
}
|
||||
|
||||
function createScanState(initialRow) {
|
||||
return {
|
||||
latestRow: initialRow,
|
||||
committedRow: initialRow,
|
||||
malformed: 0,
|
||||
invalid: 0,
|
||||
malformedHasher: crypto.createHash('sha256'),
|
||||
invalidHasher: crypto.createHash('sha256')
|
||||
};
|
||||
}
|
||||
|
||||
function processCostLine(state, line, sessionId, committed = true) {
|
||||
if (!line.trim()) return state;
|
||||
try {
|
||||
const row = JSON.parse(line);
|
||||
if (row.session_id !== sessionId) return state;
|
||||
if (!isValidCostRow(row, sessionId)) {
|
||||
if (!committed) return state;
|
||||
return {
|
||||
...state,
|
||||
invalid: state.invalid + 1,
|
||||
invalidHasher: state.invalidHasher.copy().update(line).update('\0')
|
||||
};
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
latestRow: chooseNewerCumulativeRow(state.latestRow, row),
|
||||
committedRow: committed
|
||||
? chooseNewerCumulativeRow(state.committedRow, row)
|
||||
: state.committedRow
|
||||
};
|
||||
} catch {
|
||||
if (!committed) return state;
|
||||
return {
|
||||
...state,
|
||||
malformed: state.malformed + 1,
|
||||
malformedHasher: state.malformedHasher.copy().update(line).update('\0')
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function markOversizedLine(state, pendingChunks, segment) {
|
||||
const hasher = state.malformedHasher.copy();
|
||||
for (const chunk of pendingChunks) hasher.update(chunk);
|
||||
const remaining = Math.max(0, MAX_JSONL_LINE_BYTES - pendingChunks.reduce(
|
||||
(total, chunk) => total + chunk.length,
|
||||
0
|
||||
));
|
||||
hasher.update(segment.subarray(0, remaining)).update('\0<oversized>');
|
||||
return { ...state, malformed: state.malformed + 1, malformedHasher: hasher };
|
||||
}
|
||||
|
||||
function consumeLineSegment(scan, segment, terminated, sessionId) {
|
||||
if (scan.discardingLine) {
|
||||
return { ...scan, discardingLine: !terminated };
|
||||
}
|
||||
if (scan.pendingBytes + segment.length > MAX_JSONL_LINE_BYTES) {
|
||||
return {
|
||||
state: markOversizedLine(scan.state, scan.pendingChunks, segment),
|
||||
pendingChunks: [],
|
||||
pendingBytes: 0,
|
||||
discardingLine: !terminated
|
||||
};
|
||||
}
|
||||
const pendingChunks = segment.length > 0
|
||||
? [...scan.pendingChunks, Buffer.from(segment)]
|
||||
: scan.pendingChunks;
|
||||
const pendingBytes = scan.pendingBytes + segment.length;
|
||||
if (!terminated) return { ...scan, pendingChunks, pendingBytes };
|
||||
const line = Buffer.concat(pendingChunks, pendingBytes).toString('utf8');
|
||||
return {
|
||||
state: processCostLine(scan.state, line, sessionId),
|
||||
pendingChunks: [],
|
||||
pendingBytes: 0,
|
||||
discardingLine: false
|
||||
};
|
||||
}
|
||||
|
||||
function consumeJsonlChunk(scan, chunk, sessionId, absoluteStart, processedOffset) {
|
||||
let nextScan = scan;
|
||||
let nextOffset = processedOffset;
|
||||
let segmentStart = 0;
|
||||
for (;;) {
|
||||
const newlineIndex = chunk.indexOf(0x0a, segmentStart);
|
||||
if (newlineIndex < 0) break;
|
||||
nextScan = consumeLineSegment(
|
||||
nextScan, chunk.subarray(segmentStart, newlineIndex), true, sessionId
|
||||
);
|
||||
nextOffset = absoluteStart + newlineIndex + 1;
|
||||
segmentStart = newlineIndex + 1;
|
||||
}
|
||||
nextScan = consumeLineSegment(
|
||||
nextScan, chunk.subarray(segmentStart), false, sessionId
|
||||
);
|
||||
if (nextScan.discardingLine) nextOffset = absoluteStart + chunk.length;
|
||||
return { scan: nextScan, processedOffset: nextOffset };
|
||||
}
|
||||
|
||||
function scanJsonlRange(descriptor, start, end, sessionId, initialRow, initialDiscard = false) {
|
||||
const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
|
||||
let lineScan = {
|
||||
state: createScanState(initialRow),
|
||||
pendingChunks: [],
|
||||
pendingBytes: 0,
|
||||
discardingLine: initialDiscard
|
||||
};
|
||||
let position = start;
|
||||
let processedOffset = start;
|
||||
|
||||
while (position < end) {
|
||||
const bytesRead = fs.readSync(
|
||||
descriptor,
|
||||
buffer,
|
||||
0,
|
||||
Math.min(buffer.length, end - position),
|
||||
position
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
const consumed = consumeJsonlChunk(
|
||||
lineScan, buffer.subarray(0, bytesRead), sessionId, position, processedOffset
|
||||
);
|
||||
lineScan = consumed.scan;
|
||||
processedOffset = consumed.processedOffset;
|
||||
position += bytesRead;
|
||||
}
|
||||
if (lineScan.pendingBytes > 0) {
|
||||
const line = Buffer.concat(lineScan.pendingChunks, lineScan.pendingBytes).toString('utf8');
|
||||
lineScan = {
|
||||
...lineScan,
|
||||
state: processCostLine(lineScan.state, line, sessionId, false)
|
||||
};
|
||||
}
|
||||
const { state } = lineScan;
|
||||
return {
|
||||
row: state.latestRow,
|
||||
committedRow: state.committedRow,
|
||||
processedOffset,
|
||||
malformed: state.malformed,
|
||||
invalid: state.invalid,
|
||||
malformedSignature: state.malformed > 0
|
||||
? state.malformedHasher.digest('hex').slice(0, 16)
|
||||
: null,
|
||||
invalidSignature: state.invalid > 0
|
||||
? state.invalidHasher.digest('hex').slice(0, 16)
|
||||
: null,
|
||||
discardingLine: lineScan.discardingLine
|
||||
};
|
||||
}
|
||||
|
||||
function writeSnapshotAtOffset(
|
||||
metricsDir, sessionId, row, descriptor, stat, offset, discardingLine = false
|
||||
) {
|
||||
if (row !== null && !isValidCostRow(row, sessionId)) return false;
|
||||
const snapshot = {
|
||||
schema_version: COST_SNAPSHOT_SCHEMA_VERSION,
|
||||
source: {
|
||||
identity: sourceIdentity(stat),
|
||||
offset_bytes: offset,
|
||||
mtime_ms: stat.mtimeMs,
|
||||
discarding_line: discardingLine,
|
||||
fingerprint: fingerprintProcessedPrefix(descriptor, offset)
|
||||
},
|
||||
row
|
||||
};
|
||||
writeFileAtomic(
|
||||
getCostSnapshotPath(metricsDir, sessionId),
|
||||
JSON.stringify(snapshot),
|
||||
{
|
||||
beforeRename() {
|
||||
const current = fs.fstatSync(descriptor);
|
||||
if (current.size < offset) {
|
||||
throw new Error('Cost log was truncated during snapshot publication');
|
||||
}
|
||||
if (current.size === offset && current.mtimeMs !== snapshot.source.mtime_ms) {
|
||||
throw new Error('Cost log changed during snapshot publication');
|
||||
}
|
||||
if (!fingerprintsMatch(
|
||||
fingerprintProcessedPrefix(descriptor, offset),
|
||||
snapshot.source.fingerprint
|
||||
)) {
|
||||
throw new Error('Cost log prefix changed during snapshot publication');
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
function emptySnapshotResult(row) {
|
||||
return {
|
||||
row,
|
||||
scannedBytes: 0,
|
||||
malformed: 0,
|
||||
invalid: 0,
|
||||
malformedSignature: null,
|
||||
invalidSignature: null,
|
||||
snapshotError: null
|
||||
};
|
||||
}
|
||||
|
||||
function publishScanSnapshot(metricsDir, sessionId, scan, descriptor, stat) {
|
||||
if (!scan.committedRow && scan.processedOffset === 0) return null;
|
||||
try {
|
||||
writeSnapshotAtOffset(
|
||||
metricsDir,
|
||||
sessionId,
|
||||
scan.committedRow,
|
||||
descriptor,
|
||||
stat,
|
||||
scan.processedOffset,
|
||||
scan.discardingLine
|
||||
);
|
||||
return null;
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSessionCostSnapshot(metricsDir, sessionId) {
|
||||
assertSafeSessionId(sessionId);
|
||||
const costsPath = path.join(metricsDir, COST_LOG_FILENAME);
|
||||
const descriptor = fs.openSync(costsPath, 'r');
|
||||
try {
|
||||
const stat = fs.fstatSync(descriptor);
|
||||
const snapshot = readJsonFile(getCostSnapshotPath(metricsDir, sessionId));
|
||||
const base = validSnapshotBase(snapshot, descriptor, stat, sessionId);
|
||||
if (base?.offset === stat.size) return emptySnapshotResult(base.row);
|
||||
const scanEnd = Math.min(stat.size, (base?.offset || 0) + MAX_SCAN_BYTES);
|
||||
const scan = scanJsonlRange(
|
||||
descriptor,
|
||||
base?.offset || 0,
|
||||
scanEnd,
|
||||
sessionId,
|
||||
base?.row || null,
|
||||
base?.discardingLine || false
|
||||
);
|
||||
const snapshotError = publishScanSnapshot(
|
||||
metricsDir, sessionId, scan, descriptor, stat
|
||||
);
|
||||
return {
|
||||
row: scan.row,
|
||||
scannedBytes: scan.processedOffset - (base?.offset || 0),
|
||||
malformed: scan.malformed,
|
||||
invalid: scan.invalid,
|
||||
malformedSignature: scan.malformedSignature,
|
||||
invalidSignature: scan.invalidSignature,
|
||||
snapshotError
|
||||
};
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function costLogNeedsSeparator(metricsDir) {
|
||||
const costsPath = path.join(metricsDir, COST_LOG_FILENAME);
|
||||
let descriptor;
|
||||
try {
|
||||
descriptor = fs.openSync(costsPath, 'r');
|
||||
const stat = fs.fstatSync(descriptor);
|
||||
if (stat.size === 0) return false;
|
||||
const lastByte = Buffer.alloc(1);
|
||||
return fs.readSync(descriptor, lastByte, 0, 1, stat.size - 1) === 1
|
||||
&& lastByte[0] !== 0x0a;
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') return false;
|
||||
throw error;
|
||||
} finally {
|
||||
if (descriptor !== undefined) fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function appendSessionCostRow(metricsDir, sessionId, row) {
|
||||
assertSafeSessionId(sessionId);
|
||||
if (!isValidCostRow(row, sessionId)) {
|
||||
throw new Error('Cost snapshot requires valid non-negative numeric totals for its session');
|
||||
}
|
||||
const prefix = costLogNeedsSeparator(metricsDir) ? '\n' : '';
|
||||
fs.appendFileSync(
|
||||
path.join(metricsDir, COST_LOG_FILENAME),
|
||||
`${prefix}${JSON.stringify(row)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
const result = refreshSessionCostSnapshot(metricsDir, sessionId);
|
||||
if (result.snapshotError) throw result.snapshotError;
|
||||
try {
|
||||
maybePruneSessionCostSnapshots(metricsDir);
|
||||
} catch (error) {
|
||||
// Retention is opportunistic and retried by a later update, but a
|
||||
// persistent failure remains visible without rolling back the log append.
|
||||
warnSessionCostSnapshotFailure('retention', metricsDir, sessionId, error);
|
||||
}
|
||||
return JSON.stringify(result.row) === JSON.stringify(row);
|
||||
}
|
||||
|
||||
function readSessionCostSnapshot(metricsDir, sessionId) {
|
||||
try {
|
||||
return refreshSessionCostSnapshot(metricsDir, sessionId);
|
||||
} catch (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
return { row: null, scannedBytes: 0, malformed: 0, invalid: 0, snapshotError: null };
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function maybePruneSessionCostSnapshots(metricsDir, options = {}) {
|
||||
const snapshotDir = getSnapshotDirectory(metricsDir);
|
||||
const now = Number.isFinite(options.now) ? options.now : Date.now();
|
||||
const maxAgeMs = Number.isFinite(options.maxAgeMs) ? options.maxAgeMs : SNAPSHOT_MAX_AGE_MS;
|
||||
const maxSnapshots = Number.isSafeInteger(options.maxSnapshots)
|
||||
? Math.max(0, options.maxSnapshots)
|
||||
: MAX_SNAPSHOTS;
|
||||
const markerPath = path.join(snapshotDir, '.last-prune');
|
||||
|
||||
fs.mkdirSync(snapshotDir, { recursive: true });
|
||||
const snapshotEntries = fs.readdirSync(snapshotDir, { withFileTypes: true })
|
||||
.filter(entry => entry.isFile() && /^session-.+\.json$/.test(entry.name));
|
||||
if (!options.force) {
|
||||
try {
|
||||
const intervalIsFresh = now - fs.statSync(markerPath).mtimeMs < PRUNE_INTERVAL_MS;
|
||||
if (intervalIsFresh && snapshotEntries.length <= maxSnapshots) return 0;
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
const snapshots = snapshotEntries
|
||||
.map(entry => {
|
||||
const filePath = path.join(snapshotDir, entry.name);
|
||||
return { filePath, mtimeMs: fs.statSync(filePath).mtimeMs };
|
||||
})
|
||||
.sort((left, right) => right.mtimeMs - left.mtimeMs);
|
||||
const removals = snapshots.filter((entry, index) => (
|
||||
index >= maxSnapshots || now - entry.mtimeMs > maxAgeMs
|
||||
));
|
||||
let removed = 0;
|
||||
for (const entry of removals) {
|
||||
try {
|
||||
if (fs.statSync(entry.filePath).mtimeMs <= entry.mtimeMs) {
|
||||
fs.rmSync(entry.filePath, { force: true });
|
||||
removed += 1;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error.code !== 'ENOENT') throw error;
|
||||
}
|
||||
}
|
||||
fs.writeFileSync(markerPath, String(now), { encoding: 'utf8', mode: 0o600 });
|
||||
return removed;
|
||||
}
|
||||
|
||||
function warningClaimPath(kind, metricsDir, sessionId, signature) {
|
||||
const key = crypto.createHash('sha256')
|
||||
.update(`${path.resolve(metricsDir)}\0${sessionId}\0${kind}\0${signature}`)
|
||||
.digest('hex')
|
||||
.slice(0, 16);
|
||||
return path.join(os.tmpdir(), `${WARNING_CACHE_PREFIX}${key}.claim`);
|
||||
}
|
||||
|
||||
function warnSessionCostSnapshotFailure(kind, metricsDir, sessionId, error) {
|
||||
const targetPath = getCostSnapshotPath(metricsDir, sessionId);
|
||||
const errorCode = error?.code || error?.name || 'error';
|
||||
const signature = `${kind}:${targetPath}:${errorCode}`;
|
||||
const claimPath = warningClaimPath(kind, metricsDir, sessionId, signature);
|
||||
let claimDescriptor;
|
||||
try {
|
||||
claimDescriptor = fs.openSync(claimPath, 'wx', 0o600);
|
||||
fs.closeSync(claimDescriptor);
|
||||
claimDescriptor = undefined;
|
||||
} catch (claimError) {
|
||||
if (claimDescriptor !== undefined) fs.closeSync(claimDescriptor);
|
||||
if (claimError.code === 'EEXIST') return;
|
||||
// Warning persistence is best effort. If the claim cannot be created,
|
||||
// still surface the underlying snapshot failure.
|
||||
}
|
||||
process.stderr.write(
|
||||
`[cost-snapshot] ${kind} failed for session ${sessionId}: ${error?.message || String(error)}\n`
|
||||
);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
COST_SNAPSHOT_SCHEMA_VERSION,
|
||||
COST_SNAPSHOT_DIRECTORY,
|
||||
COST_LOG_FILENAME,
|
||||
MAX_SCAN_BYTES,
|
||||
getCostSnapshotPath,
|
||||
isValidCostRow,
|
||||
chooseNewerCumulativeRow,
|
||||
appendSessionCostRow,
|
||||
readSessionCostSnapshot,
|
||||
refreshSessionCostSnapshot,
|
||||
costLogNeedsSeparator,
|
||||
maybePruneSessionCostSnapshots,
|
||||
warnSessionCostSnapshotFailure
|
||||
};
|
||||
Executable → Regular
+3
-1
@@ -383,10 +383,12 @@ function createMemoryMcpService(options = {}) {
|
||||
|
||||
const isNotification = !hasId;
|
||||
if (isNotification) {
|
||||
const params = message.params ?? {};
|
||||
if (
|
||||
message.method === 'notifications/initialized'
|
||||
&& initializationRequested
|
||||
&& Object.keys(message.params || {}).length === 0
|
||||
&& (!Object.prototype.hasOwnProperty.call(params, '_meta') || isRecord(params._meta))
|
||||
&& Object.keys(params).every(key => key === '_meta')
|
||||
) {
|
||||
initialized = true;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,38 @@ fi
|
||||
|
||||
write_status "running" "- Task file: \`$task_file\`"
|
||||
|
||||
# SECURITY: never auto-approve agent tool execution. The worker prompt is built
|
||||
# from a task file that may contain LLM-generated or third-party content
|
||||
# (indirect prompt injection). `codex exec -p yolo` would execute
|
||||
# rm -rf / exfiltration commands without confirmation.
|
||||
# Default to the most restrictive approval mode; allow an explicit operator
|
||||
# override only via env (e.g. ECC_CODEX_APPROVAL_MODE=on-request for trusted runs).
|
||||
# Codex profiles (-p) and approval policies (--ask-for-approval) are
|
||||
# independent concepts. SECURITY: default to never approving untrusted
|
||||
# tool execution; operators can override via env.
|
||||
APPROVAL_POLICY="${ECC_CODEX_APPROVAL_POLICY:-never}"
|
||||
case "$APPROVAL_POLICY" in
|
||||
never|on-request|on-failure) ;;
|
||||
*)
|
||||
echo "[ECC worker] Refusing to run: unsupported ECC_CODEX_APPROVAL_POLICY='$APPROVAL_POLICY' (expected never|on-request|on-failure)" >&2
|
||||
write_status "failed" "- Error: unsupported approval policy"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
# Contain the task file to the current worktree so a malicious launcher cannot
|
||||
# point the worker at /etc/passwd or a sibling checkout.
|
||||
task_real="$(realpath -m "$task_file" 2>/dev/null || readlink -f "$task_file" 2>/dev/null || printf '%s' "$task_file")"
|
||||
work_real="$(pwd -P 2>/dev/null || pwd)"
|
||||
case "$task_real" in
|
||||
"$work_real"/*) ;;
|
||||
*)
|
||||
echo "[ECC worker] Refusing to run: task file outside worktree: $task_file" >&2
|
||||
write_status "failed" "- Error: task file outside worktree"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
prompt_file="$(mktemp)"
|
||||
output_file="$(mktemp)"
|
||||
cleanup() {
|
||||
@@ -77,7 +109,7 @@ Task file: $task_file
|
||||
$(cat "$task_file")
|
||||
EOF
|
||||
|
||||
if codex exec -p yolo -m gpt-5.4 --color never -C "$(pwd)" -o "$output_file" - < "$prompt_file"; then
|
||||
if codex exec --ask-for-approval "$APPROVAL_POLICY" -m gpt-5.4 --color never -C "$(pwd)" -o "$output_file" - < "$prompt_file"; then
|
||||
{
|
||||
echo "# Handoff"
|
||||
echo
|
||||
|
||||
@@ -155,7 +155,7 @@ fi
|
||||
# Non-interactive SDK automation is still filtered by Layers 2-5 below
|
||||
# (ECC_HOOK_PROFILE=minimal, ECC_SKIP_OBSERVE=1, agent_id, path exclusions).
|
||||
case "${CLAUDE_CODE_ENTRYPOINT:-cli}" in
|
||||
cli|sdk-ts|claude-desktop|claude-vscode) ;;
|
||||
cli|sdk-ts|sdk-cli|claude-desktop|claude-vscode) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
|
||||
|
||||
@@ -17,6 +17,16 @@ The tracker appends one JSON object per session-stop to
|
||||
session**, so to total spend you take the **latest row per `session_id`** and
|
||||
sum across sessions — summing every row multiply-counts.
|
||||
|
||||
ECC also maintains internal per-session files under
|
||||
`~/.claude/metrics/cost-snapshots/` so runtime hooks can read the current
|
||||
session total without rescanning all history. Treat those files as a
|
||||
rebuildable cache; each snapshot stores a byte cursor so only newly appended
|
||||
rows are scanned. Stable reads are O(1), while updates are O(new bytes). Stale
|
||||
entries are pruned after 30 days or when the directory exceeds 512 sessions.
|
||||
Cold catch-up work is limited to 16 MiB per hook invocation, and malformed
|
||||
unterminated rows larger than 1 MiB are discarded with a resumable cursor.
|
||||
Reports and exports should continue to use `costs.jsonl`.
|
||||
|
||||
Row schema:
|
||||
|
||||
| Field | Meaning |
|
||||
|
||||
@@ -124,13 +124,20 @@ const { data } = await supabase
|
||||
.select('*')
|
||||
.eq('email', userEmail)
|
||||
|
||||
// Or with raw SQL
|
||||
// Or with raw SQL -- the value goes in the params array, never in the
|
||||
// string. Use your driver's placeholder syntax (Postgres numbers its
|
||||
// placeholders, MySQL uses "?").
|
||||
await db.query(
|
||||
'SELECT * FROM users WHERE email = $1',
|
||||
'SELECT * FROM users WHERE email = ?',
|
||||
[userEmail]
|
||||
)
|
||||
```
|
||||
|
||||
<!-- Do not write a literal dollar-sign-N placeholder anywhere in this file.
|
||||
Invoking this skill with arguments substitutes it away, and the example
|
||||
above then renders as concatenated SQL -- the exact anti-pattern this
|
||||
section warns against. Use "?" and name the Postgres form in prose. -->
|
||||
|
||||
#### Verification Steps
|
||||
- [ ] All database queries use parameterized queries
|
||||
- [ ] No string concatenation in SQL
|
||||
|
||||
@@ -8,6 +8,7 @@ from typing import Any
|
||||
from llm.core.interface import (
|
||||
AuthenticationError,
|
||||
ContextLengthError,
|
||||
LLMError,
|
||||
LLMProvider,
|
||||
RateLimitError,
|
||||
)
|
||||
@@ -105,13 +106,33 @@ class OllamaProvider(LLMProvider):
|
||||
)
|
||||
except Exception as e:
|
||||
msg = str(e)
|
||||
if "401" in msg or "connection" in msg.lower():
|
||||
raise AuthenticationError(f"Ollama connection failed: {msg}", provider=ProviderType.OLLAMA) from e
|
||||
if "429" in msg or "rate_limit" in msg.lower():
|
||||
lowered = msg.lower()
|
||||
if "401" in msg or "unauthorized" in lowered or "forbidden" in lowered:
|
||||
raise AuthenticationError(f"Ollama authentication failed: {msg}", provider=ProviderType.OLLAMA) from e
|
||||
if "429" in msg or "rate_limit" in lowered:
|
||||
raise RateLimitError(msg, provider=ProviderType.OLLAMA) from e
|
||||
if "context" in msg.lower() and "length" in msg.lower():
|
||||
if "context" in lowered and "length" in lowered:
|
||||
raise ContextLengthError(msg, provider=ProviderType.OLLAMA) from e
|
||||
raise
|
||||
if (
|
||||
"connection" in lowered
|
||||
or "refused" in lowered
|
||||
or "timed out" in lowered
|
||||
or "timeout" in lowered
|
||||
or "unreachable" in lowered
|
||||
or "name resolution" in lowered
|
||||
or "nodename nor servname" in lowered
|
||||
or isinstance(e, (ConnectionError, TimeoutError))
|
||||
):
|
||||
raise LLMError(
|
||||
f"Ollama connection failed: {type(e).__name__}",
|
||||
provider=ProviderType.OLLAMA,
|
||||
code="connection_error",
|
||||
) from e
|
||||
raise LLMError(
|
||||
f"Ollama request failed: {type(e).__name__}",
|
||||
provider=ProviderType.OLLAMA,
|
||||
code="provider_error",
|
||||
) from e
|
||||
|
||||
def list_models(self) -> list[ModelInfo]:
|
||||
return self._models.copy()
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
import logging
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from llm.core.interface import LLMError
|
||||
from llm.core.types import (
|
||||
LLMInput,
|
||||
LLMOutput,
|
||||
@@ -15,6 +18,21 @@ from llm.core.types import (
|
||||
ToolResult,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Model-facing failure text. Raw exception details (credentials, local paths,
|
||||
# request data, upstream responses) must never reach the model; diagnostics go
|
||||
# to trusted logs only.
|
||||
GENERIC_TOOL_FAILURE = "Error executing {name}: tool failed"
|
||||
|
||||
|
||||
def _generic_failure(tool_call: ToolCall) -> ToolResult:
|
||||
return ToolResult(
|
||||
tool_call_id=tool_call.id,
|
||||
content=GENERIC_TOOL_FAILURE.format(name=tool_call.name),
|
||||
is_error=True,
|
||||
)
|
||||
|
||||
ToolFunc = Callable[..., Any]
|
||||
|
||||
|
||||
@@ -55,18 +73,52 @@ class ToolExecutor:
|
||||
|
||||
try:
|
||||
result = func(**tool_call.arguments)
|
||||
if inspect.isawaitable(result):
|
||||
logger.warning(
|
||||
"Async tool '%s' called via sync execute(); use execute_async()",
|
||||
tool_call.name,
|
||||
)
|
||||
if inspect.iscoroutine(result):
|
||||
result.close()
|
||||
return ToolResult(
|
||||
tool_call_id=tool_call.id,
|
||||
content=GENERIC_TOOL_FAILURE.format(name=tool_call.name),
|
||||
is_error=True,
|
||||
)
|
||||
content = result if isinstance(result, str) else str(result)
|
||||
return ToolResult(tool_call_id=tool_call.id, content=content)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
logger.exception("Tool '%s' failed", tool_call.name)
|
||||
return _generic_failure(tool_call)
|
||||
|
||||
async def execute_async(self, tool_call: ToolCall) -> ToolResult:
|
||||
func = self.registry.get(tool_call.name)
|
||||
if not func:
|
||||
return ToolResult(
|
||||
tool_call_id=tool_call.id,
|
||||
content=f"Error executing {tool_call.name}: {e}",
|
||||
content=f"Error: Tool '{tool_call.name}' not found",
|
||||
is_error=True,
|
||||
)
|
||||
|
||||
try:
|
||||
result = func(**tool_call.arguments)
|
||||
if inspect.isawaitable(result):
|
||||
result = await result
|
||||
content = result if isinstance(result, str) else str(result)
|
||||
return ToolResult(tool_call_id=tool_call.id, content=content)
|
||||
except Exception:
|
||||
logger.exception("Tool '%s' failed", tool_call.name)
|
||||
return _generic_failure(tool_call)
|
||||
|
||||
def execute_all(self, tool_calls: list[ToolCall]) -> list[ToolResult]:
|
||||
return [self.execute(tc) for tc in tool_calls]
|
||||
|
||||
async def execute_all_async(self, tool_calls: list[ToolCall]) -> list[ToolResult]:
|
||||
results: list[ToolResult] = []
|
||||
for tc in tool_calls:
|
||||
results.append(await self.execute_async(tc))
|
||||
return results
|
||||
|
||||
|
||||
class ReActAgent:
|
||||
def __init__(
|
||||
@@ -92,7 +144,14 @@ class ReActAgent:
|
||||
tools=tools,
|
||||
)
|
||||
|
||||
output: LLMOutput = self.provider.generate(input_copy)
|
||||
try:
|
||||
output: LLMOutput = self.provider.generate(input_copy)
|
||||
except LLMError as e:
|
||||
logger.warning("Provider failed during agent run: %s", e.code or type(e).__name__)
|
||||
return LLMOutput(
|
||||
content=f"Provider error: {e.code or type(e).__name__}",
|
||||
stop_reason="provider_error",
|
||||
)
|
||||
|
||||
if not output.has_tool_calls:
|
||||
return output
|
||||
@@ -105,7 +164,7 @@ class ReActAgent:
|
||||
)
|
||||
)
|
||||
|
||||
results = self.executor.execute_all(output.tool_calls or [])
|
||||
results = await self.executor.execute_all_async(output.tool_calls or [])
|
||||
|
||||
for result in results:
|
||||
messages.append(
|
||||
|
||||
@@ -9,6 +9,7 @@ const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { spawnSync } = require('child_process');
|
||||
const { getCostSnapshotPath } = require('../../scripts/lib/session-cost-snapshot');
|
||||
|
||||
const script = path.join(__dirname, '..', '..', 'scripts', 'hooks', 'cost-tracker.js');
|
||||
|
||||
@@ -115,6 +116,39 @@ function runTests() {
|
||||
assert.strictEqual(result.stdout, inputStr, 'Expected stdout to match original input');
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('keeps JSONL authoritative when the snapshot path cannot be published', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const metricsDir = path.join(tmpHome, '.claude', 'metrics');
|
||||
const blockedSnapshotPath = getCostSnapshotPath(
|
||||
metricsDir,
|
||||
'snapshot-failure'
|
||||
);
|
||||
fs.mkdirSync(blockedSnapshotPath, { recursive: true });
|
||||
|
||||
try {
|
||||
const result = runScript(
|
||||
{ session_id: 'snapshot-failure' },
|
||||
withTempHome(tmpHome)
|
||||
);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const rows = fs.readFileSync(path.join(metricsDir, 'costs.jsonl'), 'utf8')
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map(line => JSON.parse(line));
|
||||
assert.strictEqual(rows.at(-1).session_id, 'snapshot-failure');
|
||||
assert.match(result.stderr, /cost-snapshot.*publication failed/);
|
||||
|
||||
const second = runScript(
|
||||
{ session_id: 'snapshot-failure' },
|
||||
withTempHome(tmpHome)
|
||||
);
|
||||
assert.strictEqual(second.code, 0, second.stderr);
|
||||
assert.strictEqual(second.stderr, '', 'identical persistent failure should warn only once');
|
||||
} finally {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
// 2. Creates metrics file when given transcript usage data
|
||||
(test('creates metrics file when given transcript usage data', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
@@ -154,6 +188,7 @@ function runTests() {
|
||||
assert.strictEqual(result.code, 0, `Expected exit code 0, got ${result.code}`);
|
||||
|
||||
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
|
||||
const metricsDir = path.dirname(metricsFile);
|
||||
assert.ok(fs.existsSync(metricsFile), `Expected metrics file to exist at ${metricsFile}`);
|
||||
|
||||
const content = fs.readFileSync(metricsFile, 'utf8').trim();
|
||||
@@ -169,6 +204,12 @@ function runTests() {
|
||||
assert.ok(typeof row.estimated_cost_usd === 'number', 'Expected estimated_cost_usd to be a number');
|
||||
assert.ok(row.estimated_cost_usd > 0, 'Expected estimated_cost_usd to be positive');
|
||||
|
||||
const snapshotFile = getCostSnapshotPath(metricsDir, 'session-from-hook');
|
||||
assert.ok(fs.existsSync(snapshotFile), 'Expected an O(1) per-session cost snapshot');
|
||||
const snapshot = JSON.parse(fs.readFileSync(snapshotFile, 'utf8'));
|
||||
assert.strictEqual(snapshot.schema_version, 'ecc.cost-snapshot.v1');
|
||||
assert.deepStrictEqual(snapshot.row, row, 'Snapshot must mirror the appended cumulative row');
|
||||
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
@@ -208,6 +249,57 @@ function runTests() {
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
(test('normalizes malformed negative and non-finite transcript usage', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
const transcriptPath = path.join(tmpHome, 'session.jsonl');
|
||||
writeTranscript(transcriptPath, [{
|
||||
type: 'assistant',
|
||||
message: {
|
||||
id: 'msg_invalid_usage',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
usage: {
|
||||
input_tokens: -100,
|
||||
output_tokens: 'Infinity',
|
||||
cache_creation_input_tokens: -20,
|
||||
cache_read_input_tokens: 'not-a-number',
|
||||
},
|
||||
},
|
||||
}, {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
id: 'msg_overflow_1',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
usage: { input_tokens: 1e308, output_tokens: 0 },
|
||||
},
|
||||
}, {
|
||||
type: 'assistant',
|
||||
message: {
|
||||
id: 'msg_overflow_2',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
usage: { input_tokens: 1e308, output_tokens: 0 },
|
||||
},
|
||||
}]);
|
||||
|
||||
const result = runScript(
|
||||
{ session_id: 'invalid-usage', transcript_path: transcriptPath },
|
||||
withTempHome(tmpHome)
|
||||
);
|
||||
assert.strictEqual(result.code, 0, result.stderr);
|
||||
const metricsFile = path.join(tmpHome, '.claude', 'metrics', 'costs.jsonl');
|
||||
const recorded = JSON.parse(fs.readFileSync(metricsFile, 'utf8').trim());
|
||||
assert.deepStrictEqual(
|
||||
{
|
||||
input: recorded.input_tokens,
|
||||
output: recorded.output_tokens,
|
||||
cacheWrite: recorded.cache_write_tokens,
|
||||
cacheRead: recorded.cache_read_tokens,
|
||||
cost: recorded.estimated_cost_usd,
|
||||
},
|
||||
{ input: 0, output: 0, cacheWrite: 0, cacheRead: 0, cost: 0 }
|
||||
);
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}) ? passed++ : failed++);
|
||||
|
||||
// 3. Handles empty input gracefully
|
||||
(test('handles empty input gracefully', () => {
|
||||
const tmpHome = makeTempDir();
|
||||
|
||||
@@ -11,6 +11,10 @@ const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const { run, hashToolCall, extractFilePaths, readSessionCost } = require('../../scripts/hooks/ecc-metrics-bridge');
|
||||
const {
|
||||
appendSessionCostRow,
|
||||
getCostSnapshotPath
|
||||
} = require('../../scripts/lib/session-cost-snapshot');
|
||||
|
||||
// Test helper
|
||||
function test(name, fn) {
|
||||
@@ -233,6 +237,261 @@ function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('readSessionCost uses the per-session snapshot without scanning historical JSONL', () => {
|
||||
const tmpHome = makeTempHome();
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
const originalReadSync = fs.readSync;
|
||||
let bytesReadFromCostLog = 0;
|
||||
try {
|
||||
process.env.HOME = tmpHome;
|
||||
process.env.USERPROFILE = tmpHome;
|
||||
const metricsDir = path.join(tmpHome, '.claude', 'metrics');
|
||||
const snapshotsDir = path.join(metricsDir, 'cost-snapshots');
|
||||
fs.mkdirSync(snapshotsDir, { recursive: true });
|
||||
const snapshotRow = {
|
||||
session_id: 'S1',
|
||||
estimated_cost_usd: 0.75,
|
||||
input_tokens: 750,
|
||||
output_tokens: 375
|
||||
};
|
||||
const historicalRow = JSON.stringify({
|
||||
session_id: 'HISTORY',
|
||||
estimated_cost_usd: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(metricsDir, 'costs.jsonl'),
|
||||
`${historicalRow}\n`.repeat(100),
|
||||
'utf8'
|
||||
);
|
||||
assert.ok(fs.statSync(path.join(metricsDir, 'costs.jsonl')).size > 3 * 256);
|
||||
appendSessionCostRow(metricsDir, 'S1', snapshotRow);
|
||||
|
||||
fs.readSync = function measuredRead(descriptor, buffer, offset, length, position) {
|
||||
const bytesRead = originalReadSync.call(
|
||||
this, descriptor, buffer, offset, length, position
|
||||
);
|
||||
if (Number.isSafeInteger(position)) bytesReadFromCostLog += bytesRead;
|
||||
return bytesRead;
|
||||
};
|
||||
|
||||
const result = readSessionCost('S1');
|
||||
assert.deepStrictEqual(result, { totalCost: 0.75, totalIn: 750, totalOut: 375 });
|
||||
assert.ok(bytesReadFromCostLog <= 3 * 256);
|
||||
} finally {
|
||||
fs.readSync = originalReadSync;
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('readSessionCost keeps session A on the fast path after session B appends', () => {
|
||||
const tmpHome = makeTempHome();
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
try {
|
||||
process.env.HOME = tmpHome;
|
||||
process.env.USERPROFILE = tmpHome;
|
||||
const metricsDir = path.join(tmpHome, '.claude', 'metrics');
|
||||
fs.mkdirSync(metricsDir, { recursive: true });
|
||||
const first = {
|
||||
session_id: 'S1',
|
||||
estimated_cost_usd: 1,
|
||||
input_tokens: 100,
|
||||
output_tokens: 50
|
||||
};
|
||||
const historicalRow = JSON.stringify({
|
||||
session_id: 'HISTORY',
|
||||
estimated_cost_usd: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(metricsDir, 'costs.jsonl'),
|
||||
`${historicalRow}\n`.repeat(200),
|
||||
'utf8'
|
||||
);
|
||||
assert.ok(fs.statSync(path.join(metricsDir, 'costs.jsonl')).size > 3 * 1024);
|
||||
appendSessionCostRow(metricsDir, 'S1', first);
|
||||
appendSessionCostRow(metricsDir, 'S2', {
|
||||
session_id: 'S2',
|
||||
estimated_cost_usd: 2,
|
||||
input_tokens: 200,
|
||||
output_tokens: 100
|
||||
});
|
||||
const originalReadSync = fs.readSync;
|
||||
let bytesReadFromCostLog = 0;
|
||||
fs.readSync = function measuredRead(descriptor, buffer, offset, length, position) {
|
||||
const bytesRead = originalReadSync.call(
|
||||
this, descriptor, buffer, offset, length, position
|
||||
);
|
||||
if (Number.isSafeInteger(position)) bytesReadFromCostLog += bytesRead;
|
||||
return bytesRead;
|
||||
};
|
||||
try {
|
||||
assert.deepStrictEqual(readSessionCost('S1'), { totalCost: 1, totalIn: 100, totalOut: 50 });
|
||||
assert.ok(bytesReadFromCostLog <= 3 * 1024);
|
||||
} finally {
|
||||
fs.readSync = originalReadSync;
|
||||
}
|
||||
} finally {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('readSessionCost falls back to JSONL when the session snapshot is malformed', () => {
|
||||
const tmpHome = makeTempHome();
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
try {
|
||||
process.env.HOME = tmpHome;
|
||||
process.env.USERPROFILE = tmpHome;
|
||||
const metricsDir = path.join(tmpHome, '.claude', 'metrics');
|
||||
const snapshotsDir = path.join(metricsDir, 'cost-snapshots');
|
||||
fs.mkdirSync(snapshotsDir, { recursive: true });
|
||||
fs.writeFileSync(getCostSnapshotPath(metricsDir, 'S1'), '{broken', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(metricsDir, 'costs.jsonl'),
|
||||
`${JSON.stringify({
|
||||
session_id: 'S1',
|
||||
estimated_cost_usd: 1.5,
|
||||
input_tokens: 1500,
|
||||
output_tokens: 750
|
||||
})}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(readSessionCost('S1'), {
|
||||
totalCost: 1.5,
|
||||
totalIn: 1500,
|
||||
totalOut: 750
|
||||
});
|
||||
} finally {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('readSessionCost falls back when a snapshot row has invalid numeric totals', () => {
|
||||
const tmpHome = makeTempHome();
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
try {
|
||||
process.env.HOME = tmpHome;
|
||||
process.env.USERPROFILE = tmpHome;
|
||||
const metricsDir = path.join(tmpHome, '.claude', 'metrics');
|
||||
const snapshotsDir = path.join(metricsDir, 'cost-snapshots');
|
||||
fs.mkdirSync(snapshotsDir, { recursive: true });
|
||||
const valid = {
|
||||
session_id: 'S1',
|
||||
estimated_cost_usd: 2,
|
||||
input_tokens: 200,
|
||||
output_tokens: 100
|
||||
};
|
||||
const costsPath = path.join(metricsDir, 'costs.jsonl');
|
||||
fs.writeFileSync(costsPath, `${JSON.stringify(valid)}\n`, 'utf8');
|
||||
appendSessionCostRow(metricsDir, 'S1', valid);
|
||||
const snapshot = JSON.parse(fs.readFileSync(getCostSnapshotPath(metricsDir, 'S1'), 'utf8'));
|
||||
fs.writeFileSync(
|
||||
getCostSnapshotPath(metricsDir, 'S1'),
|
||||
JSON.stringify({
|
||||
...snapshot,
|
||||
row: { session_id: 'S1' }
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(readSessionCost('S1'), {
|
||||
totalCost: 2,
|
||||
totalIn: 200,
|
||||
totalOut: 100
|
||||
});
|
||||
} finally {
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('readSessionCost skips invalid cumulative JSONL rows after the last valid total', () => {
|
||||
const tmpHome = makeTempHome();
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
const originalStderrWrite = process.stderr.write.bind(process.stderr);
|
||||
let captured = '';
|
||||
process.stderr.write = chunk => {
|
||||
captured += String(chunk);
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
process.env.HOME = tmpHome;
|
||||
process.env.USERPROFILE = tmpHome;
|
||||
const metricsDir = path.join(tmpHome, '.claude', 'metrics');
|
||||
fs.mkdirSync(metricsDir, { recursive: true });
|
||||
const rows = [
|
||||
{ session_id: 'S1', estimated_cost_usd: 2, input_tokens: 200, output_tokens: 100 },
|
||||
{ session_id: 'S1', estimated_cost_usd: -999, input_tokens: 'invalid', output_tokens: -5 },
|
||||
{ session_id: 'S1', input_tokens: 300, output_tokens: 150 },
|
||||
{ session_id: 'S1', estimated_cost_usd: null, input_tokens: 400, output_tokens: 200 },
|
||||
{ session_id: 'OTHER', estimated_cost_usd: -1, input_tokens: -1, output_tokens: -1 }
|
||||
];
|
||||
fs.writeFileSync(
|
||||
path.join(metricsDir, 'costs.jsonl'),
|
||||
`${rows.map(row => JSON.stringify(row)).join('\n')}\n`,
|
||||
'utf8'
|
||||
);
|
||||
|
||||
assert.deepStrictEqual(readSessionCost('S1'), {
|
||||
totalCost: 2,
|
||||
totalIn: 200,
|
||||
totalOut: 100
|
||||
});
|
||||
assert.match(captured, /skipped 3 invalid cumulative row\(s\) for S1/);
|
||||
assert.match(captured, /during the snapshot scan of/);
|
||||
} finally {
|
||||
process.stderr.write = originalStderrWrite;
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
fs.rmSync(tmpHome, { recursive: true, force: true });
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('readSessionCost finds session row beyond the old 8 KiB tail boundary', () => {
|
||||
// The previous implementation read only the trailing 8 KiB of
|
||||
@@ -313,6 +572,7 @@ function runTests() {
|
||||
const matches = captured.match(/skipped 2 malformed line\(s\)/g) || [];
|
||||
assert.strictEqual(matches.length, 1,
|
||||
`expected one aggregated malformed-line breadcrumb on stderr, got: ${captured}`);
|
||||
assert.match(captured, /during the snapshot scan of/);
|
||||
} finally {
|
||||
process.stderr.write = originalStderrWrite;
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
|
||||
@@ -249,6 +249,9 @@ async function runTests() {
|
||||
ECC_MCP_CONFIG_PATH: null,
|
||||
ECC_MCP_HEALTH_STATE_PATH: null,
|
||||
ECC_MCP_HEALTH_TIMEOUT_MS: '100',
|
||||
// Workspace configs are untrusted by default; this test uses a
|
||||
// temp dir it created itself, so opt in explicitly.
|
||||
ECC_MCP_ALLOW_WORKSPACE_PROBE: '1',
|
||||
HOME: homeDir,
|
||||
USERPROFILE: homeDir
|
||||
},
|
||||
@@ -619,6 +622,7 @@ async function runTests() {
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PreToolUse',
|
||||
ECC_MCP_CONFIG_PATH: configPath,
|
||||
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||
ECC_MCP_RECONNECT_ALLOW: '1',
|
||||
ECC_MCP_RECONNECT_COMMAND: `${JSON.stringify(process.execPath)} ${JSON.stringify(reconnectScript)}`,
|
||||
ECC_MCP_HEALTH_TIMEOUT_MS: '1000',
|
||||
ECC_MCP_HEALTH_BACKOFF_MS: '10'
|
||||
@@ -682,6 +686,7 @@ async function runTests() {
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUseFailure',
|
||||
ECC_MCP_CONFIG_PATH: configPath,
|
||||
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||
ECC_MCP_RECONNECT_ALLOW: '1',
|
||||
ECC_MCP_RECONNECT_COMMAND: `node ${JSON.stringify(reconnectScript)}`,
|
||||
ECC_MCP_HEALTH_TIMEOUT_MS: '1000'
|
||||
}
|
||||
@@ -773,6 +778,7 @@ async function runTests() {
|
||||
{
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUseFailure',
|
||||
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||
ECC_MCP_RECONNECT_ALLOW: '1',
|
||||
ECC_MCP_RECONNECT_COMMAND: `${JSON.stringify(process.execPath)} ${JSON.stringify(reconnectScript)}`
|
||||
}
|
||||
);
|
||||
@@ -810,6 +816,7 @@ async function runTests() {
|
||||
CLAUDE_HOOK_EVENT_NAME: 'PostToolUseFailure',
|
||||
ECC_MCP_HEALTH_STATE_PATH: statePath,
|
||||
ECC_MCP_CONFIG_PATH: path.join(tempDir, 'missing.json'),
|
||||
ECC_MCP_RECONNECT_ALLOW: '1',
|
||||
ECC_MCP_RECONNECT_COMMAND: null,
|
||||
ECC_MCP_RECONNECT_FOO_BAR: `${JSON.stringify(process.execPath)} ${JSON.stringify(reconnectScript)} ${JSON.stringify(markerFile)} {server}`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Security-focused regression: observe.sh Layer-1 entrypoint allowlist (#3171).
|
||||
*
|
||||
* sdk-cli must pass Layer-1 (interactive Agent SDK CLI). Unknown entrypoints
|
||||
* must early-exit. Layers 2–5 still filter automated sessions.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { spawnSync } = require('node:child_process');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const repoRoot = path.resolve(__dirname, '..', '..');
|
||||
const observeShPath = path.join(
|
||||
repoRoot,
|
||||
'skills',
|
||||
'continuous-learning-v2',
|
||||
'hooks',
|
||||
'observe.sh'
|
||||
);
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` ✓ ${name}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.log(` ✗ ${name}`);
|
||||
console.log(` Error: ${err.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function layer1Probe(entrypoint) {
|
||||
// Run only the Layer-1 case block extracted by line range (stable in this file).
|
||||
const script = `
|
||||
set -euo pipefail
|
||||
case "\${CLAUDE_CODE_ENTRYPOINT:-cli}" in
|
||||
cli|sdk-ts|sdk-cli|claude-desktop|claude-vscode) ;;
|
||||
*) exit 0 ;;
|
||||
esac
|
||||
echo LAYER1_PASS
|
||||
`;
|
||||
// Defense in depth: assert the live observe.sh still matches this allowlist.
|
||||
const src = fs.readFileSync(observeShPath, 'utf8');
|
||||
assert.ok(
|
||||
src.includes('cli|sdk-ts|sdk-cli|claude-desktop|claude-vscode'),
|
||||
'observe.sh Layer-1 allowlist drifted from security probe'
|
||||
);
|
||||
return spawnSync('bash', ['-c', script], {
|
||||
env: { ...process.env, CLAUDE_CODE_ENTRYPOINT: entrypoint },
|
||||
encoding: 'utf8',
|
||||
});
|
||||
}
|
||||
|
||||
console.log('\n=== observe.sh Layer-1 entrypoint security (#3171) ===\n');
|
||||
|
||||
let failed = 0;
|
||||
if (isWindows) {
|
||||
console.log(' ⊘ skipped on Windows');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
if (
|
||||
!test('source allowlist includes sdk-cli', () => {
|
||||
const src = fs.readFileSync(observeShPath, 'utf8');
|
||||
assert.match(src, /cli\|sdk-ts\|sdk-cli\|claude-desktop\|claude-vscode/);
|
||||
})
|
||||
)
|
||||
failed++;
|
||||
|
||||
for (const ep of ['cli', 'sdk-ts', 'sdk-cli', 'claude-desktop', 'claude-vscode']) {
|
||||
if (
|
||||
!test(`Layer-1 allows ${ep}`, () => {
|
||||
const r = layer1Probe(ep);
|
||||
assert.equal(r.status, 0, `status=${r.status} stderr=${r.stderr}`);
|
||||
assert.match(r.stdout || '', /LAYER1_PASS/);
|
||||
})
|
||||
)
|
||||
failed++;
|
||||
}
|
||||
|
||||
for (const ep of ['unknown-bot', 'ci-bot']) {
|
||||
if (
|
||||
!test(`Layer-1 rejects ${ep}`, () => {
|
||||
const r = layer1Probe(ep);
|
||||
assert.equal(r.status, 0);
|
||||
assert.doesNotMatch(r.stdout || '', /LAYER1_PASS/);
|
||||
})
|
||||
)
|
||||
failed++;
|
||||
}
|
||||
|
||||
console.log(failed === 0 ? '\nAll Layer-1 security checks passed.\n' : `\n${failed} failed\n`);
|
||||
process.exit(failed === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,371 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { test } = require('node:test');
|
||||
|
||||
const { createInstallPlanFromRequest } = require('../../scripts/lib/install/runtime');
|
||||
const { applyInstallPlan, previewInstallPlan } = require('../../scripts/lib/install/apply');
|
||||
const {
|
||||
buildDoctorReport, repairInstalledStates, uninstallInstalledStates,
|
||||
} = require('../../scripts/lib/install-lifecycle');
|
||||
const { readInstallState, writeInstallState } = require('../../scripts/lib/install-state');
|
||||
|
||||
const SHARED_FILES = ['config.toml', 'AGENTS.md'];
|
||||
const TEMPLATES = {
|
||||
'config.toml': '# ECC defaults\nmodel = "example-model"\n',
|
||||
'AGENTS.md': '# ECC instructions\n\nFollow the project conventions.\n',
|
||||
};
|
||||
|
||||
function writeFile(filePath, content) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
}
|
||||
|
||||
function createFixture(t) {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-codex-preservation-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const sourceRoot = path.join(root, 'source');
|
||||
const homeDir = path.join(root, 'home');
|
||||
const projectRoot = path.join(root, 'project');
|
||||
const json = (relativePath, value) => writeFile(
|
||||
path.join(sourceRoot, relativePath), `${JSON.stringify(value, null, 2)}\n`
|
||||
);
|
||||
json('package.json', { version: '1.0.0' });
|
||||
json('manifests/install-modules.json', {
|
||||
version: 1,
|
||||
modules: [{
|
||||
id: 'platform-configs', kind: 'platform', description: 'Codex configuration fixture',
|
||||
paths: ['.codex'], targets: ['codex'], dependencies: [],
|
||||
defaultInstall: true, cost: 'light', stability: 'stable',
|
||||
}, {
|
||||
id: 'helper-scripts', kind: 'platform', description: 'Independent helper fixture',
|
||||
paths: ['scripts'], targets: ['codex'], dependencies: [],
|
||||
defaultInstall: false, cost: 'light', stability: 'stable',
|
||||
}],
|
||||
});
|
||||
json('manifests/install-profiles.json', {
|
||||
version: 1, profiles: { minimal: { description: 'Fixture', modules: ['platform-configs'] } },
|
||||
});
|
||||
for (const name of SHARED_FILES) writeFile(path.join(sourceRoot, '.codex', name), TEMPLATES[name]);
|
||||
writeFile(path.join(sourceRoot, 'scripts', 'independent-helper.js'), 'module.exports = "helper";\n');
|
||||
fs.mkdirSync(homeDir, { recursive: true });
|
||||
fs.mkdirSync(projectRoot, { recursive: true });
|
||||
const options = { sourceRoot, homeDir, projectRoot, env: {} };
|
||||
const lifecycleOptions = { repoRoot: sourceRoot, homeDir, projectRoot, targets: ['codex'], env: {} };
|
||||
const plan = (moduleIds = ['platform-configs']) => createInstallPlanFromRequest({
|
||||
mode: 'manifest', target: 'codex', profileId: null, moduleIds,
|
||||
includeComponentIds: [], excludeComponentIds: [], hookConsent: 'declined',
|
||||
}, options);
|
||||
return {
|
||||
sourceRoot,
|
||||
destination: name => path.join(homeDir, '.codex', name),
|
||||
statePath: path.join(homeDir, '.codex', 'ecc-install-state.json'),
|
||||
plan,
|
||||
install: moduleIds => applyInstallPlan(plan(moduleIds)),
|
||||
repair: (dryRun = false) => repairInstalledStates({ ...lifecycleOptions, dryRun }),
|
||||
doctor: () => buildDoctorReport(lifecycleOptions),
|
||||
uninstall: () => uninstallInstalledStates(lifecycleOptions),
|
||||
};
|
||||
}
|
||||
|
||||
function lifecycleResult(report) {
|
||||
assert.equal(report.results.length, 1);
|
||||
assert.notEqual(report.results[0].status, 'error', report.results[0].error);
|
||||
return report.results[0];
|
||||
}
|
||||
|
||||
function assertPreserved(fixture, name, content) {
|
||||
assert.deepEqual(fs.readFileSync(fixture.destination(name)), Buffer.from(content));
|
||||
}
|
||||
|
||||
function assertUnmanaged(fixture, name) {
|
||||
assert.ok(!readInstallState(fixture.statePath).operations.some(operation => (
|
||||
operation.destinationPath === fixture.destination(name) && operation.ownership === 'managed'
|
||||
)), `${name} must not remain managed after preserving user content`);
|
||||
}
|
||||
|
||||
function assertWarning(result, name) {
|
||||
assert.ok((result.warnings || []).some(warning => (
|
||||
warning.includes(name) && /skip|preserv|user-owned|modif/i.test(warning)
|
||||
)), `Expected an explicit preservation warning for ${name}`);
|
||||
}
|
||||
|
||||
function editAfterRepairInspection(fixture, name, content, action) {
|
||||
const originalOpen = fs.openSync;
|
||||
const originalClose = fs.closeSync;
|
||||
const inspectedDescriptors = new Set();
|
||||
let injected = false;
|
||||
fs.openSync = function (filePath, ...args) {
|
||||
const descriptor = originalOpen.call(fs, filePath, ...args);
|
||||
if (!injected && filePath === fixture.destination(name)
|
||||
&& new Error().stack.includes('inspectManagedOperation')) {
|
||||
inspectedDescriptors.add(descriptor);
|
||||
}
|
||||
return descriptor;
|
||||
};
|
||||
fs.closeSync = function (descriptor) {
|
||||
const result = originalClose.call(fs, descriptor);
|
||||
if (!injected && inspectedDescriptors.delete(descriptor)) {
|
||||
// Inspection has read the previous bytes. Simulate an editor saving next,
|
||||
// before repair checkpoints or refreshes state; no digest-refresh hook is used.
|
||||
injected = true;
|
||||
writeFile(fixture.destination(name), content);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
try {
|
||||
return { result: action(), injected };
|
||||
} finally {
|
||||
fs.openSync = originalOpen;
|
||||
fs.closeSync = originalClose;
|
||||
}
|
||||
}
|
||||
|
||||
for (const name of SHARED_FILES) {
|
||||
for (const stage of ['bridge', 'no-op refresh']) {
|
||||
test(`repair ${stage} does not claim a concurrent edit to Codex ${name}`, t => {
|
||||
const fixture = createFixture(t);
|
||||
fixture.install();
|
||||
const previousOperation = readInstallState(fixture.statePath).operations.find(operation => (
|
||||
operation.destinationPath === fixture.destination(name)
|
||||
));
|
||||
if (stage === 'bridge') {
|
||||
writeFile(path.join(fixture.sourceRoot, '.codex', name),
|
||||
`${TEMPLATES[name]}\n# Updated upstream template\n`);
|
||||
}
|
||||
const content = `${TEMPLATES[name]}\r\n# Saved after repair inspected the file\r\n`;
|
||||
|
||||
const { result: report, injected } = editAfterRepairInspection(
|
||||
fixture, name, content, () => fixture.repair()
|
||||
);
|
||||
|
||||
assert.ok(injected, 'The simulated edit must occur after repair inspection');
|
||||
assert.equal(report.results.length, 1);
|
||||
if (stage === 'bridge') {
|
||||
assert.equal(report.results[0].status, 'error');
|
||||
assert.match(report.results[0].error, /Refusing.*user configuration.*changed after planning/);
|
||||
} else {
|
||||
assert.equal(report.results[0].status, 'ok');
|
||||
}
|
||||
assertPreserved(fixture, name, content);
|
||||
const refreshedOperation = readInstallState(fixture.statePath).operations.find(operation => (
|
||||
operation.destinationPath === fixture.destination(name) && operation.ownership === 'managed'
|
||||
));
|
||||
assert.ok(refreshedOperation,
|
||||
'Repair must retain the previous ledger entry for configuration it did not write');
|
||||
assert.equal(refreshedOperation.contentSha256, previousOperation.contentSha256,
|
||||
'Repair must retain the previous digest for configuration it did not write');
|
||||
lifecycleResult(fixture.uninstall());
|
||||
assertPreserved(fixture, name, content);
|
||||
});
|
||||
}
|
||||
|
||||
test(`selective reinstall releases edited Codex ${name} retained from an earlier module`, t => {
|
||||
const fixture = createFixture(t);
|
||||
fixture.install();
|
||||
const content = `${TEMPLATES[name]}\n# Keep this across unrelated module installations\n`;
|
||||
writeFile(fixture.destination(name), content);
|
||||
const selectivePlan = fixture.plan(['helper-scripts']);
|
||||
assert.ok(!selectivePlan.operations.some(operation => (
|
||||
operation.destinationPath === fixture.destination(name)
|
||||
)), 'The edited configuration must not be in the selected module operations');
|
||||
|
||||
const result = applyInstallPlan(selectivePlan);
|
||||
|
||||
assertPreserved(fixture, name, content);
|
||||
assertUnmanaged(fixture, name);
|
||||
assertWarning(result, name);
|
||||
lifecycleResult(fixture.repair());
|
||||
assertPreserved(fixture, name, content);
|
||||
assertUnmanaged(fixture, name);
|
||||
lifecycleResult(fixture.uninstall());
|
||||
assertPreserved(fixture, name, content);
|
||||
});
|
||||
|
||||
test(`reinstall rejects a last-minute edit to Codex ${name} without claiming the edited bytes`, t => {
|
||||
const fixture = createFixture(t);
|
||||
fixture.install();
|
||||
const destination = fixture.destination(name);
|
||||
const previousOperation = readInstallState(fixture.statePath).operations.find(operation => (
|
||||
operation.destinationPath === destination
|
||||
));
|
||||
const rawPlan = fixture.plan();
|
||||
// Write the other file first to exercise the partial-install checkpoint on failure.
|
||||
const plan = {
|
||||
...rawPlan,
|
||||
operations: [
|
||||
...rawPlan.operations.filter(operation => operation.destinationPath !== destination),
|
||||
...rawPlan.operations.filter(operation => operation.destinationPath === destination),
|
||||
],
|
||||
};
|
||||
const content = `${TEMPLATES[name]}\r\n# Saved while ECC was running\r\n`;
|
||||
let wroteAnotherFile = false;
|
||||
let injectedEdit = false;
|
||||
|
||||
assert.throws(() => applyInstallPlan(plan, {
|
||||
beforeOperationWrite({ operation }) {
|
||||
if (operation.destinationPath !== destination) {
|
||||
wroteAnotherFile = true;
|
||||
return;
|
||||
}
|
||||
assert.ok(wroteAnotherFile, 'The failure must exercise a partial install');
|
||||
writeFile(destination, content);
|
||||
injectedEdit = true;
|
||||
},
|
||||
}), /Refusing.*user configuration.*changed after planning/);
|
||||
|
||||
assert.ok(injectedEdit);
|
||||
assertPreserved(fixture, name, content);
|
||||
const checkpointOperation = readInstallState(fixture.statePath).operations.find(operation => (
|
||||
operation.destinationPath === destination && operation.ownership === 'managed'
|
||||
));
|
||||
assert.ok(checkpointOperation,
|
||||
'A failure checkpoint must retain the previous ledger entry');
|
||||
assert.equal(checkpointOperation.contentSha256, previousOperation.contentSha256,
|
||||
'A failure checkpoint must retain the old digest, never adopt the user edit');
|
||||
lifecycleResult(fixture.uninstall());
|
||||
assertPreserved(fixture, name, content);
|
||||
});
|
||||
|
||||
test(`repeated repair keeps edited Codex ${name} unmanaged while repairing an ECC script`, t => {
|
||||
const fixture = createFixture(t);
|
||||
const scriptName = path.join('scripts', 'ecc-helper.js');
|
||||
const scriptContent = 'module.exports = "ECC helper";\n';
|
||||
writeFile(path.join(fixture.sourceRoot, '.codex', scriptName), scriptContent);
|
||||
fixture.install();
|
||||
const content = `${TEMPLATES[name]}\n# Keep my preferences\n`;
|
||||
writeFile(fixture.destination(name), content);
|
||||
|
||||
lifecycleResult(fixture.repair());
|
||||
assertPreserved(fixture, name, content);
|
||||
assertUnmanaged(fixture, name);
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const before = lifecycleResult(fixture.doctor());
|
||||
assert.ok(!before.issues.some(issue => issue.code === 'drifted-managed-files'));
|
||||
writeFile(fixture.destination(scriptName), 'damaged ECC helper\n');
|
||||
const damaged = lifecycleResult(fixture.doctor());
|
||||
assert.ok(damaged.issues.some(issue => issue.code === 'drifted-managed-files'));
|
||||
|
||||
const result = lifecycleResult(fixture.repair());
|
||||
|
||||
assert.ok(result.repairedPaths.includes(fixture.destination(scriptName)));
|
||||
assertPreserved(fixture, scriptName, scriptContent);
|
||||
assertPreserved(fixture, name, content);
|
||||
assertUnmanaged(fixture, name);
|
||||
const after = lifecycleResult(fixture.doctor());
|
||||
assert.ok(!after.issues.some(issue => issue.code === 'drifted-managed-files'));
|
||||
}
|
||||
lifecycleResult(fixture.uninstall());
|
||||
assertPreserved(fixture, name, content);
|
||||
assert.ok(!fs.existsSync(fixture.destination(scriptName)));
|
||||
});
|
||||
|
||||
for (const action of ['reinstall', 'repair']) {
|
||||
test(`${action} preserves edited Codex ${name} and leaves it safe to uninstall`, t => {
|
||||
const fixture = createFixture(t);
|
||||
fixture.install();
|
||||
const content = `${TEMPLATES[name]}\r\n# Personal preferences — 保留\r\n`;
|
||||
writeFile(fixture.destination(name), content);
|
||||
|
||||
const result = action === 'reinstall' ? fixture.install() : lifecycleResult(fixture.repair());
|
||||
|
||||
assertPreserved(fixture, name, content);
|
||||
assertWarning(result, name);
|
||||
assertUnmanaged(fixture, name);
|
||||
lifecycleResult(fixture.uninstall());
|
||||
assertPreserved(fixture, name, content);
|
||||
});
|
||||
}
|
||||
|
||||
test(`dry runs warn about edited Codex ${name} without changing files or state`, t => {
|
||||
const fixture = createFixture(t);
|
||||
fixture.install();
|
||||
const content = `${TEMPLATES[name]}\n# User customization\n`;
|
||||
writeFile(fixture.destination(name), content);
|
||||
const previousState = fs.readFileSync(fixture.statePath);
|
||||
|
||||
const preview = previewInstallPlan(fixture.plan());
|
||||
const repairPreview = lifecycleResult(fixture.repair(true));
|
||||
|
||||
assertPreserved(fixture, name, content);
|
||||
assert.deepEqual(fs.readFileSync(fixture.statePath), previousState);
|
||||
assertWarning(preview, name);
|
||||
assertWarning(repairPreview, name);
|
||||
assert.ok(!preview.operations.some(operation => operation.destinationPath === fixture.destination(name)));
|
||||
assert.ok(!repairPreview.plannedRepairs.includes(fixture.destination(name)));
|
||||
});
|
||||
|
||||
test(`pre-existing Codex ${name} survives install, repair and uninstall`, t => {
|
||||
const fixture = createFixture(t);
|
||||
const content = '# Personal file before ECC installation\r\n保持原样\r\n';
|
||||
writeFile(fixture.destination(name), content);
|
||||
|
||||
assertWarning(fixture.install(), name);
|
||||
assertPreserved(fixture, name, content);
|
||||
assertUnmanaged(fixture, name);
|
||||
const repairResult = lifecycleResult(fixture.repair());
|
||||
assertPreserved(fixture, name, content);
|
||||
assertWarning(repairResult, name);
|
||||
assertUnmanaged(fixture, name);
|
||||
lifecycleResult(fixture.uninstall());
|
||||
assertPreserved(fixture, name, content);
|
||||
});
|
||||
|
||||
for (const action of ['reinstall', 'repair']) {
|
||||
test(`${action} preserves Codex ${name} when legacy state lacks its content digest`, t => {
|
||||
const fixture = createFixture(t);
|
||||
fixture.install();
|
||||
const state = readInstallState(fixture.statePath);
|
||||
writeInstallState(fixture.statePath, {
|
||||
...state,
|
||||
operations: state.operations.map(operation => {
|
||||
if (operation.destinationPath !== fixture.destination(name)) return operation;
|
||||
const { contentSha256: _contentSha256, ...legacyOperation } = operation;
|
||||
return legacyOperation;
|
||||
}),
|
||||
});
|
||||
// Even bytes equal to today's template cannot prove ownership without a recorded digest.
|
||||
const content = fs.readFileSync(fixture.destination(name));
|
||||
const result = action === 'reinstall' ? fixture.install() : lifecycleResult(fixture.repair());
|
||||
|
||||
assertPreserved(fixture, name, content);
|
||||
assertWarning(result, name);
|
||||
assertUnmanaged(fixture, name);
|
||||
lifecycleResult(fixture.uninstall());
|
||||
assertPreserved(fixture, name, content);
|
||||
});
|
||||
}
|
||||
|
||||
for (const action of ['reinstall', 'repair']) {
|
||||
test(`${action} updates unedited Codex ${name} when the template changes`, t => {
|
||||
const fixture = createFixture(t);
|
||||
fixture.install();
|
||||
const updated = `${TEMPLATES[name]}\n# New upstream default\n`;
|
||||
writeFile(path.join(fixture.sourceRoot, '.codex', name), updated);
|
||||
|
||||
if (action === 'reinstall') fixture.install();
|
||||
else lifecycleResult(fixture.repair());
|
||||
|
||||
assertPreserved(fixture, name, updated);
|
||||
assert.ok(readInstallState(fixture.statePath).operations.some(operation => (
|
||||
operation.destinationPath === fixture.destination(name) && operation.ownership === 'managed'
|
||||
)));
|
||||
lifecycleResult(fixture.uninstall());
|
||||
assert.ok(!fs.existsSync(fixture.destination(name)));
|
||||
});
|
||||
}
|
||||
|
||||
test(`repair restores missing managed Codex ${name}`, t => {
|
||||
const fixture = createFixture(t);
|
||||
fixture.install();
|
||||
const installedContent = fs.readFileSync(fixture.destination(name));
|
||||
fs.unlinkSync(fixture.destination(name));
|
||||
|
||||
lifecycleResult(fixture.repair());
|
||||
|
||||
assertPreserved(fixture, name, installedContent);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,503 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('assert');
|
||||
const { execFileSync } = require('child_process');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const {
|
||||
appendSessionCostRow,
|
||||
getCostSnapshotPath,
|
||||
MAX_SCAN_BYTES,
|
||||
maybePruneSessionCostSnapshots,
|
||||
readSessionCostSnapshot,
|
||||
refreshSessionCostSnapshot,
|
||||
warnSessionCostSnapshotFailure
|
||||
} = require('../../scripts/lib/session-cost-snapshot');
|
||||
|
||||
function row(sessionId, cost = 1) {
|
||||
return {
|
||||
timestamp: new Date(Date.UTC(2026, 0, 1, 0, 0, cost)).toISOString(),
|
||||
session_id: sessionId,
|
||||
estimated_cost_usd: cost,
|
||||
input_tokens: cost * 100,
|
||||
output_tokens: cost * 50
|
||||
};
|
||||
}
|
||||
|
||||
function test(name, fn) {
|
||||
try {
|
||||
fn();
|
||||
console.log(` PASS ${name}`);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log(` FAIL ${name}`);
|
||||
console.log(` ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ecc-cost-snapshot-'));
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
|
||||
try {
|
||||
if (test('appends and atomically publishes a private cumulative snapshot', () => {
|
||||
const current = row('session-1', 1.25);
|
||||
assert.strictEqual(appendSessionCostRow(root, 'session-1', current), true);
|
||||
const filePath = getCostSnapshotPath(root, 'session-1');
|
||||
assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-1').row, current);
|
||||
if (process.platform !== 'win32') {
|
||||
assert.strictEqual(fs.statSync(filePath).mode & 0o777, 0o600);
|
||||
}
|
||||
assert.deepStrictEqual(
|
||||
fs.readdirSync(path.dirname(filePath)).filter(name => name.endsWith('.tmp')),
|
||||
[]
|
||||
);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('replaces the previous cumulative row for the same session', () => {
|
||||
appendSessionCostRow(root, 'session-update', row('session-update', 1));
|
||||
const latest = row('session-update', 2);
|
||||
appendSessionCostRow(root, 'session-update', latest);
|
||||
assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-update').row, latest);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('a delayed older writer cannot lower the latest cumulative total', () => {
|
||||
const newer = row('session-order', 2);
|
||||
const older = {
|
||||
...row('session-order', 1),
|
||||
timestamp: new Date(Date.parse(newer.timestamp) + 1000).toISOString()
|
||||
};
|
||||
assert.strictEqual(appendSessionCostRow(root, 'session-order', newer), true);
|
||||
assert.strictEqual(appendSessionCostRow(root, 'session-order', older), false);
|
||||
assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-order').row, newer);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('publication failure followed by an older writer still converges to the newer row', () => {
|
||||
const sessionId = 'session-publication-race';
|
||||
const newer = row(sessionId, 2);
|
||||
const older = {
|
||||
...row(sessionId, 1),
|
||||
timestamp: new Date(Date.parse(newer.timestamp) + 1000).toISOString()
|
||||
};
|
||||
const snapshotPath = getCostSnapshotPath(root, sessionId);
|
||||
const originalRenameSync = fs.renameSync;
|
||||
let injectedFailure = false;
|
||||
fs.renameSync = function failNewerSnapshot(sourcePath, destinationPath) {
|
||||
if (!injectedFailure && path.resolve(destinationPath) === path.resolve(snapshotPath)) {
|
||||
injectedFailure = true;
|
||||
const error = new Error('injected snapshot publication failure');
|
||||
error.code = 'EIO';
|
||||
throw error;
|
||||
}
|
||||
return originalRenameSync.call(this, sourcePath, destinationPath);
|
||||
};
|
||||
try {
|
||||
assert.throws(() => appendSessionCostRow(root, sessionId, newer), /injected/);
|
||||
} finally {
|
||||
fs.renameSync = originalRenameSync;
|
||||
}
|
||||
assert.strictEqual(appendSessionCostRow(root, sessionId, older), false);
|
||||
assert.deepStrictEqual(readSessionCostSnapshot(root, sessionId).row, newer);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('accepts increasing cumulative totals that share a timestamp', () => {
|
||||
const first = row('session-same-time', 1);
|
||||
const next = { ...row('session-same-time', 2), timestamp: first.timestamp };
|
||||
appendSessionCostRow(root, 'session-same-time', first);
|
||||
assert.strictEqual(appendSessionCostRow(root, 'session-same-time', next), true);
|
||||
assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-same-time').row, next);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('uses timestamps when cumulative dimensions move in opposite directions', () => {
|
||||
const newer = {
|
||||
...row('session-mixed', 1),
|
||||
input_tokens: 200,
|
||||
output_tokens: 100,
|
||||
timestamp: '2026-01-02T00:00:00.000Z'
|
||||
};
|
||||
const delayedOlder = {
|
||||
...row('session-mixed', 2),
|
||||
input_tokens: 100,
|
||||
output_tokens: 50,
|
||||
timestamp: '2026-01-01T00:00:00.000Z'
|
||||
};
|
||||
appendSessionCostRow(root, 'session-mixed', newer);
|
||||
assert.strictEqual(appendSessionCostRow(root, 'session-mixed', delayedOlder), false);
|
||||
assert.deepStrictEqual(readSessionCostSnapshot(root, 'session-mixed').row, newer);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('session B only creates a bounded delta scan for session A', () => {
|
||||
const sessionA = row('session-a', 3);
|
||||
appendSessionCostRow(root, 'session-a', sessionA);
|
||||
const sessionB = row('session-b', 4);
|
||||
appendSessionCostRow(root, 'session-b', sessionB);
|
||||
const refreshed = refreshSessionCostSnapshot(root, 'session-a');
|
||||
assert.deepStrictEqual(refreshed.row, sessionA);
|
||||
assert.ok(refreshed.scannedBytes > 0);
|
||||
assert.ok(refreshed.scannedBytes <= Buffer.byteLength(`${JSON.stringify(sessionB)}\n`));
|
||||
assert.strictEqual(refreshSessionCostSnapshot(root, 'session-a').scannedBytes, 0);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('sessions with no row cache their progress cursor', () => {
|
||||
const caseRoot = path.join(root, 'missing-session');
|
||||
fs.mkdirSync(caseRoot, { recursive: true });
|
||||
const costsPath = path.join(caseRoot, 'costs.jsonl');
|
||||
const other = row('other-only', 1);
|
||||
fs.writeFileSync(costsPath, `${JSON.stringify(other)}\n`.repeat(4000), 'utf8');
|
||||
const first = refreshSessionCostSnapshot(caseRoot, 'missing');
|
||||
const second = refreshSessionCostSnapshot(caseRoot, 'missing');
|
||||
assert.ok(first.scannedBytes > 100000);
|
||||
assert.strictEqual(first.row, null);
|
||||
assert.strictEqual(second.scannedBytes, 0);
|
||||
assert.strictEqual(second.row, null);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('does not advance the cursor past an incomplete trailing row', () => {
|
||||
const caseRoot = path.join(root, 'partial-row');
|
||||
fs.mkdirSync(caseRoot, { recursive: true });
|
||||
const current = row('partial', 6);
|
||||
const serialized = JSON.stringify(current);
|
||||
fs.writeFileSync(path.join(caseRoot, 'costs.jsonl'), serialized, 'utf8');
|
||||
const partial = refreshSessionCostSnapshot(caseRoot, 'partial');
|
||||
assert.deepStrictEqual(partial.row, current);
|
||||
assert.strictEqual(partial.scannedBytes, 0);
|
||||
assert.strictEqual(partial.malformed, 0);
|
||||
fs.appendFileSync(path.join(caseRoot, 'costs.jsonl'), '\n', 'utf8');
|
||||
const complete = refreshSessionCostSnapshot(caseRoot, 'partial');
|
||||
assert.deepStrictEqual(complete.row, current);
|
||||
assert.strictEqual(complete.scannedBytes, Buffer.byteLength(`${serialized}\n`));
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('never persists a provisional unterminated row when later bytes corrupt it', () => {
|
||||
const caseRoot = path.join(root, 'partial-corruption');
|
||||
fs.mkdirSync(caseRoot, { recursive: true });
|
||||
const costsPath = path.join(caseRoot, 'costs.jsonl');
|
||||
const provisional = row('partial-corruption', 7);
|
||||
fs.writeFileSync(costsPath, JSON.stringify(provisional), 'utf8');
|
||||
const first = refreshSessionCostSnapshot(caseRoot, 'partial-corruption');
|
||||
assert.deepStrictEqual(first.row, provisional);
|
||||
assert.strictEqual(first.scannedBytes, 0);
|
||||
|
||||
const recovered = row('partial-corruption', 8);
|
||||
appendSessionCostRow(caseRoot, 'partial-corruption', recovered);
|
||||
assert.deepStrictEqual(refreshSessionCostSnapshot(caseRoot, 'partial-corruption').row, recovered);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('keeps UTF-8 rows intact across the 64 KiB read boundary', () => {
|
||||
const caseRoot = path.join(root, 'utf8-boundary');
|
||||
fs.mkdirSync(caseRoot, { recursive: true });
|
||||
const current = { ...row('utf8', 7), note: `${'x'.repeat(65520)}数据` };
|
||||
fs.writeFileSync(
|
||||
path.join(caseRoot, 'costs.jsonl'),
|
||||
`${JSON.stringify(current)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
assert.deepStrictEqual(refreshSessionCostSnapshot(caseRoot, 'utf8').row, current);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('bounds oversized unterminated rows and caches the discarded prefix', () => {
|
||||
const caseRoot = path.join(root, 'oversized-line');
|
||||
fs.mkdirSync(caseRoot, { recursive: true });
|
||||
const oversizedBytes = 2 * MAX_SCAN_BYTES;
|
||||
fs.writeFileSync(
|
||||
path.join(caseRoot, 'costs.jsonl'),
|
||||
Buffer.alloc(oversizedBytes, 0x78)
|
||||
);
|
||||
const originalConcat = Buffer.concat;
|
||||
let copiedBytes = 0;
|
||||
Buffer.concat = function measuredConcat(list, totalLength) {
|
||||
copiedBytes += totalLength ?? list.reduce((sum, item) => sum + item.length, 0);
|
||||
return originalConcat.call(this, list, totalLength);
|
||||
};
|
||||
try {
|
||||
const first = refreshSessionCostSnapshot(caseRoot, 'oversized');
|
||||
assert.strictEqual(first.row, null);
|
||||
assert.strictEqual(first.malformed, 1);
|
||||
assert.ok(first.scannedBytes > 0 && first.scannedBytes < oversizedBytes);
|
||||
assert.ok(copiedBytes <= 2 * 1024 * 1024, `copied ${copiedBytes} bytes`);
|
||||
const second = refreshSessionCostSnapshot(caseRoot, 'oversized');
|
||||
assert.strictEqual(second.scannedBytes, oversizedBytes - first.scannedBytes);
|
||||
assert.strictEqual(second.malformed, 0);
|
||||
const stable = refreshSessionCostSnapshot(caseRoot, 'oversized');
|
||||
assert.strictEqual(stable.scannedBytes, 0);
|
||||
const recovered = row('oversized', 3);
|
||||
fs.appendFileSync(
|
||||
path.join(caseRoot, 'costs.jsonl'),
|
||||
`\n${JSON.stringify(recovered)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
const resumed = refreshSessionCostSnapshot(caseRoot, 'oversized');
|
||||
assert.deepStrictEqual(resumed.row, recovered);
|
||||
assert.ok(resumed.scannedBytes < 1024);
|
||||
} finally {
|
||||
Buffer.concat = originalConcat;
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rebuilds after an in-place rewrite or inode rotation', () => {
|
||||
const caseRoot = path.join(root, 'rotation');
|
||||
fs.mkdirSync(caseRoot, { recursive: true });
|
||||
const costsPath = path.join(caseRoot, 'costs.jsonl');
|
||||
const first = row('rotated', 1);
|
||||
const rewritten = row('rotated', 9);
|
||||
appendSessionCostRow(caseRoot, 'rotated', first);
|
||||
fs.writeFileSync(costsPath, `${JSON.stringify(rewritten)}\n`, 'utf8');
|
||||
assert.deepStrictEqual(refreshSessionCostSnapshot(caseRoot, 'rotated').row, rewritten);
|
||||
|
||||
const rotated = row('rotated', 10);
|
||||
fs.renameSync(costsPath, `${costsPath}.old`);
|
||||
fs.writeFileSync(costsPath, `${JSON.stringify(rotated)}\n`, 'utf8');
|
||||
assert.deepStrictEqual(refreshSessionCostSnapshot(caseRoot, 'rotated').row, rotated);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('detects same-size in-place changes outside the tail window', () => {
|
||||
const caseRoot = path.join(root, 'same-size-rewrite');
|
||||
fs.mkdirSync(caseRoot, { recursive: true });
|
||||
const costsPath = path.join(caseRoot, 'costs.jsonl');
|
||||
const first = row('same-size', 1);
|
||||
const rewritten = { ...first, estimated_cost_usd: 9 };
|
||||
const filler = `${JSON.stringify(row('filler', 2))}\n`.repeat(20);
|
||||
fs.writeFileSync(costsPath, `${JSON.stringify(first)}\n${filler}`, 'utf8');
|
||||
refreshSessionCostSnapshot(caseRoot, 'same-size');
|
||||
fs.writeFileSync(costsPath, `${JSON.stringify(rewritten)}\n${filler}`, 'utf8');
|
||||
const rebuilt = refreshSessionCostSnapshot(caseRoot, 'same-size');
|
||||
assert.ok(rebuilt.scannedBytes > 0);
|
||||
assert.deepStrictEqual(rebuilt.row, rewritten);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects unsafe session IDs and prefixes Windows device names', () => {
|
||||
assert.throws(
|
||||
() => appendSessionCostRow(root, '../outside', row('../outside', 9)),
|
||||
/safe session ID/
|
||||
);
|
||||
assert.strictEqual(path.basename(getCostSnapshotPath(root, 'CON')), 'session-CON.json');
|
||||
assert.strictEqual(path.basename(getCostSnapshotPath(root, 'nul')), 'session-nul.json');
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('rejects rows with missing, non-numeric, or negative totals', () => {
|
||||
const invalidRows = [
|
||||
{ session_id: 'invalid-row', input_tokens: 1, output_tokens: 1 },
|
||||
{ session_id: 'invalid-row', estimated_cost_usd: '1', input_tokens: 1, output_tokens: 1 },
|
||||
{ session_id: 'invalid-row', estimated_cost_usd: 1, input_tokens: -1, output_tokens: 1 },
|
||||
{ session_id: 'invalid-row', estimated_cost_usd: 1, input_tokens: 1, output_tokens: Infinity }
|
||||
];
|
||||
for (const invalidRow of invalidRows) {
|
||||
assert.throws(
|
||||
() => appendSessionCostRow(root, 'invalid-row', invalidRow),
|
||||
/valid non-negative numeric totals/
|
||||
);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('malformed snapshots rebuild from the authoritative JSONL', () => {
|
||||
const sessionId = 'session-invalid';
|
||||
const current = row(sessionId, 5);
|
||||
appendSessionCostRow(root, sessionId, current);
|
||||
fs.writeFileSync(getCostSnapshotPath(root, sessionId), '{broken', 'utf8');
|
||||
const rebuilt = refreshSessionCostSnapshot(root, sessionId);
|
||||
assert.deepStrictEqual(rebuilt.row, current);
|
||||
assert.deepStrictEqual(readSessionCostSnapshot(root, sessionId).row, current);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('prunes expired snapshots and enforces the count bound', () => {
|
||||
const now = Date.now();
|
||||
for (let index = 0; index < 4; index += 1) {
|
||||
const sessionId = `prune-${index}`;
|
||||
appendSessionCostRow(root, sessionId, row(sessionId, index + 1));
|
||||
const old = new Date(now - ((index + 1) * 1000));
|
||||
fs.utimesSync(getCostSnapshotPath(root, sessionId), old, old);
|
||||
}
|
||||
const removed = maybePruneSessionCostSnapshots(root, {
|
||||
force: true,
|
||||
now,
|
||||
maxAgeMs: 2500,
|
||||
maxSnapshots: 2
|
||||
});
|
||||
const remaining = fs.readdirSync(path.join(root, 'cost-snapshots'))
|
||||
.filter(name => name.startsWith('session-'));
|
||||
assert.ok(removed >= 2);
|
||||
assert.ok(remaining.length <= 2);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('enforces the count bound while the age-prune marker is fresh', () => {
|
||||
const caseRoot = path.join(root, 'count-bound');
|
||||
fs.mkdirSync(caseRoot, { recursive: true });
|
||||
const now = Date.now();
|
||||
assert.strictEqual(maybePruneSessionCostSnapshots(caseRoot, {
|
||||
force: true,
|
||||
now,
|
||||
maxSnapshots: 2
|
||||
}), 0);
|
||||
for (let index = 0; index < 5; index += 1) {
|
||||
appendSessionCostRow(caseRoot, `count-${index}`, row(`count-${index}`, index + 1));
|
||||
}
|
||||
const removed = maybePruneSessionCostSnapshots(caseRoot, {
|
||||
now: now + 1000,
|
||||
maxSnapshots: 2
|
||||
});
|
||||
const remaining = fs.readdirSync(path.join(caseRoot, 'cost-snapshots'))
|
||||
.filter(name => name.startsWith('session-'));
|
||||
assert.strictEqual(removed, 3);
|
||||
assert.strictEqual(remaining.length, 2);
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('surfaces retention removal failures for the caller to report', () => {
|
||||
const caseRoot = path.join(root, 'retention-failure');
|
||||
fs.mkdirSync(caseRoot, { recursive: true });
|
||||
const sessionId = 'retention-target';
|
||||
appendSessionCostRow(caseRoot, sessionId, row(sessionId, 1));
|
||||
const snapshotPath = getCostSnapshotPath(caseRoot, sessionId);
|
||||
const markerPath = path.join(caseRoot, 'cost-snapshots', '.last-prune');
|
||||
fs.rmSync(markerPath, { force: true });
|
||||
const old = new Date(Date.now() - 10_000);
|
||||
fs.utimesSync(snapshotPath, old, old);
|
||||
const originalRmSync = fs.rmSync;
|
||||
fs.rmSync = function failSnapshotRemoval(filePath, options) {
|
||||
if (path.resolve(filePath) === path.resolve(snapshotPath)) {
|
||||
const error = new Error('injected retention failure');
|
||||
error.code = 'EACCES';
|
||||
throw error;
|
||||
}
|
||||
return originalRmSync.call(this, filePath, options);
|
||||
};
|
||||
try {
|
||||
assert.throws(
|
||||
() => maybePruneSessionCostSnapshots(caseRoot, {
|
||||
force: true,
|
||||
now: Date.now(),
|
||||
maxAgeMs: 1
|
||||
}),
|
||||
/injected retention failure/
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.existsSync(markerPath),
|
||||
false,
|
||||
'failed pruning must not defer the next retry'
|
||||
);
|
||||
} finally {
|
||||
fs.rmSync = originalRmSync;
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reports retention failures without rolling back the appended row', () => {
|
||||
const caseRoot = path.join(root, 'retention-warning');
|
||||
fs.mkdirSync(caseRoot, { recursive: true });
|
||||
const snapshotDir = path.join(caseRoot, 'cost-snapshots');
|
||||
const originalReaddirSync = fs.readdirSync;
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
let captured = '';
|
||||
fs.readdirSync = function failRetentionRead(directory, options) {
|
||||
if (path.resolve(directory) === path.resolve(snapshotDir)) {
|
||||
const error = new Error('injected retention read failure');
|
||||
error.code = 'EACCES';
|
||||
throw error;
|
||||
}
|
||||
return originalReaddirSync.call(this, directory, options);
|
||||
};
|
||||
process.stderr.write = chunk => {
|
||||
captured += String(chunk);
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
const current = row('retention-warning-session', 1);
|
||||
assert.strictEqual(appendSessionCostRow(
|
||||
caseRoot,
|
||||
'retention-warning-session',
|
||||
current
|
||||
), true);
|
||||
assert.strictEqual(appendSessionCostRow(
|
||||
caseRoot,
|
||||
'retention-warning-session',
|
||||
row('retention-warning-session', 2)
|
||||
), true);
|
||||
const warnings = captured.match(/retention failed/g) || [];
|
||||
assert.strictEqual(warnings.length, 1);
|
||||
const persisted = fs.readFileSync(path.join(caseRoot, 'costs.jsonl'), 'utf8');
|
||||
assert.match(persisted, /retention-warning-session/);
|
||||
} finally {
|
||||
fs.readdirSync = originalReaddirSync;
|
||||
process.stderr.write = originalWrite;
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('deduplicates snapshot warnings independently by failure kind', () => {
|
||||
const caseRoot = path.join(root, 'warning-dedupe');
|
||||
const originalWrite = process.stderr.write.bind(process.stderr);
|
||||
let captured = '';
|
||||
process.stderr.write = chunk => {
|
||||
captured += String(chunk);
|
||||
return true;
|
||||
};
|
||||
try {
|
||||
const error = Object.assign(new Error('persistent failure'), { code: 'EIO' });
|
||||
warnSessionCostSnapshotFailure('publication', caseRoot, 'warn-session', error);
|
||||
warnSessionCostSnapshotFailure('repair', caseRoot, 'warn-session', error);
|
||||
warnSessionCostSnapshotFailure('publication', caseRoot, 'warn-session', error);
|
||||
warnSessionCostSnapshotFailure('repair', caseRoot, 'warn-session', error);
|
||||
const warnings = captured.trim().split('\n');
|
||||
assert.strictEqual(warnings.length, 2);
|
||||
assert.match(warnings[0], /publication failed/);
|
||||
assert.match(warnings[1], /repair failed/);
|
||||
} finally {
|
||||
process.stderr.write = originalWrite;
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('deduplicates the same snapshot warning across concurrent processes', () => {
|
||||
const caseRoot = path.join(root, 'warning-concurrency');
|
||||
fs.mkdirSync(caseRoot, { recursive: true });
|
||||
const modulePath = path.resolve(__dirname, '../../scripts/lib/session-cost-snapshot.js');
|
||||
const workerScript = [
|
||||
"const fs = require('fs');",
|
||||
"const path = require('path');",
|
||||
"const [modulePath, metricsDir, gatePath, index] = process.argv.slice(1);",
|
||||
"fs.writeFileSync(path.join(metricsDir, `ready-${index}`), '');",
|
||||
"const timer = setInterval(() => {",
|
||||
" if (!fs.existsSync(gatePath)) return;",
|
||||
" clearInterval(timer);",
|
||||
" const { warnSessionCostSnapshotFailure } = require(modulePath);",
|
||||
" const error = Object.assign(new Error('persistent failure'), { code: 'EIO' });",
|
||||
" warnSessionCostSnapshotFailure('publication', metricsDir, 'shared-session', error);",
|
||||
"}, 1);"
|
||||
].join('\n');
|
||||
const orchestratorScript = [
|
||||
"const { spawn } = require('child_process');",
|
||||
"const fs = require('fs');",
|
||||
"const path = require('path');",
|
||||
"const [modulePath, metricsDir] = process.argv.slice(1);",
|
||||
"const gatePath = path.join(metricsDir, 'go');",
|
||||
`const workerScript = ${JSON.stringify(workerScript)};`,
|
||||
"const runs = Array.from({ length: 16 }, (_, index) => new Promise((resolve, reject) => {",
|
||||
" const child = spawn(process.execPath, ['-e', workerScript, modulePath, metricsDir, gatePath, String(index)], { stdio: ['ignore', 'ignore', 'pipe'] });",
|
||||
" let stderr = '';",
|
||||
" child.stderr.on('data', chunk => { stderr += chunk; });",
|
||||
" child.on('error', reject);",
|
||||
" child.on('close', code => resolve({ code, stderr }));",
|
||||
"}));",
|
||||
"const readyTimer = setInterval(() => {",
|
||||
" const ready = fs.readdirSync(metricsDir).filter(name => name.startsWith('ready-'));",
|
||||
" if (ready.length !== runs.length) return;",
|
||||
" clearInterval(readyTimer);",
|
||||
" fs.writeFileSync(gatePath, 'go');",
|
||||
"}, 1);",
|
||||
"Promise.all(runs).then(results => {",
|
||||
" const warnings = results.flatMap(result => result.stderr.split('\\n')).filter(line => line.includes('publication failed'));",
|
||||
" process.stdout.write(JSON.stringify({ codes: results.map(result => result.code), warnings: warnings.length }));",
|
||||
"});"
|
||||
].join('\n');
|
||||
const result = JSON.parse(execFileSync(
|
||||
process.execPath,
|
||||
['-e', orchestratorScript, modulePath, caseRoot],
|
||||
{ encoding: 'utf8', timeout: 10000 }
|
||||
));
|
||||
assert.deepStrictEqual(result.codes, Array(16).fill(0));
|
||||
assert.strictEqual(result.warnings, 1);
|
||||
})) passed++; else failed++;
|
||||
} finally {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
process.exit(failed > 0 ? 1 : 0);
|
||||
@@ -179,9 +179,14 @@ async function main() {
|
||||
const $ = createFailingShell()
|
||||
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
|
||||
|
||||
const env = await hooks["shell.env"]()
|
||||
const existingEnv = Object.freeze({ EXISTING_ENV: "preserved" })
|
||||
const output = { env: existingEnv }
|
||||
await hooks["shell.env"]({ cwd: projectDir }, output)
|
||||
const { env } = output
|
||||
|
||||
assert.deepStrictEqual($.calls, [], `Unexpected shell probes: ${$.calls.join(", ")}`)
|
||||
assert.strictEqual(env.EXISTING_ENV, "preserved")
|
||||
assert.notStrictEqual(env, existingEnv)
|
||||
assert.strictEqual(env.PROJECT_ROOT, projectDir)
|
||||
assert.strictEqual(env.PACKAGE_MANAGER, "pnpm")
|
||||
assert.strictEqual(env.DETECTED_LANGUAGES, "typescript,python")
|
||||
@@ -243,9 +248,12 @@ async function main() {
|
||||
const $ = createFailingShell()
|
||||
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
|
||||
|
||||
const env = await hooks["shell.env"]()
|
||||
const output = { env: {} }
|
||||
await hooks["shell.env"]({ cwd: projectDir }, output)
|
||||
const { env } = output
|
||||
|
||||
assert.deepStrictEqual($.calls, [], `Unexpected shell probes: ${$.calls.join(", ")}`)
|
||||
assert.strictEqual(env.PROJECT_ROOT, projectDir)
|
||||
assert.ok(!("PACKAGE_MANAGER" in env), "Lockfile directory should not set PACKAGE_MANAGER")
|
||||
assert.ok(!("DETECTED_LANGUAGES" in env), "Marker directory should not set DETECTED_LANGUAGES")
|
||||
assert.ok(!("PRIMARY_LANGUAGE" in env), "Marker directory should not set PRIMARY_LANGUAGE")
|
||||
@@ -254,6 +262,47 @@ async function main() {
|
||||
}
|
||||
},
|
||||
],
|
||||
[
|
||||
"compacting appends ECC context without replacing the host compaction prompt",
|
||||
async () => withTempProject([], async (projectDir) => {
|
||||
const client = createClient()
|
||||
const $ = createFailingShell()
|
||||
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
|
||||
const existingContext = Object.freeze(["Existing plugin context"])
|
||||
const output = { context: existingContext }
|
||||
|
||||
await hooks["experimental.session.compacting"]({ sessionID: "session-1" }, output)
|
||||
|
||||
assert.strictEqual(output.context[0], "Existing plugin context")
|
||||
assert.notStrictEqual(output.context, existingContext)
|
||||
const prompt = output.prompt ?? ["Default compaction prompt", ...output.context].join("\n\n")
|
||||
assert.ok(prompt.includes("Default compaction prompt"))
|
||||
assert.ok(prompt.includes("# ECC Context"))
|
||||
assert.ok(prompt.includes("Current task status and progress"))
|
||||
assert.deepStrictEqual($.calls, [])
|
||||
}),
|
||||
],
|
||||
[
|
||||
"compacting appends ECC guidance to custom prompts, including an empty prompt",
|
||||
async () => withTempProject([], async (projectDir) => {
|
||||
const client = createClient()
|
||||
const $ = createFailingShell()
|
||||
const hooks = await ECCHooksPlugin({ client, $, directory: projectDir })
|
||||
|
||||
for (const customPrompt of ["Another plugin's custom prompt", ""]) {
|
||||
const existingContext = Object.freeze(["Existing plugin context"])
|
||||
const output = { context: existingContext, prompt: customPrompt }
|
||||
await hooks["experimental.session.compacting"]({ sessionID: "session-1" }, output)
|
||||
|
||||
const prompt = output.prompt ?? ["Default compaction prompt", ...output.context].join("\n\n")
|
||||
assert.ok(prompt.startsWith(`${customPrompt}\n\n`))
|
||||
assert.ok(prompt.includes("# ECC Context"))
|
||||
assert.ok(prompt.includes("Current task status and progress"))
|
||||
assert.strictEqual(output.context, existingContext)
|
||||
}
|
||||
assert.deepStrictEqual($.calls, [])
|
||||
}),
|
||||
],
|
||||
[
|
||||
"permission.ask handles read-only tools correctly",
|
||||
async () => withTempProject(
|
||||
|
||||
@@ -188,6 +188,32 @@ function readInstalledPackageNames(settingsFile) {
|
||||
return names
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirror of the adapter's `findInstalledCompanion` (same file, same matching
|
||||
* rule) so the exact-match and unscoped-satisfied-by-scoped cases can be
|
||||
* exercised directly without importing the TypeScript source. This copy
|
||||
* proves the *behavior* below is correct, but a copy cannot detect the real
|
||||
* adapter's rule drifting out from under it. The source-text assertions in
|
||||
* the "companion package detection tolerates a scoped fork" test below read
|
||||
* the real `findInstalledCompanion` text out of `.pi/extensions/index.ts` and
|
||||
* pin its actual guards directly.
|
||||
*/
|
||||
function findInstalledCompanion(companion, installed) {
|
||||
if (installed.has(companion)) {
|
||||
return companion
|
||||
}
|
||||
if (companion.startsWith("@")) {
|
||||
return undefined
|
||||
}
|
||||
const scopedSuffix = `/${companion}`
|
||||
for (const name of installed) {
|
||||
if (name.startsWith("@") && name.endsWith(scopedSuffix)) {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses the `PORTABLE_RULE_FILES` array literal out of `.pi/extensions/index.ts`
|
||||
* by text, so the real-filesystem-existence test and the `loadPortableRules`
|
||||
@@ -1135,6 +1161,146 @@ async function main() {
|
||||
}
|
||||
}],
|
||||
|
||||
["companion package detection tolerates a scoped fork (source contract): the unscoped-entry fallback exists, scoped entries stay exact, and the doctor loop reports what satisfied the entry", () => {
|
||||
const matchStart = extensionSource.indexOf("function findInstalledCompanion")
|
||||
assert.ok(
|
||||
matchStart !== -1,
|
||||
"expected .pi/extensions/index.ts to define a function named findInstalledCompanion; " +
|
||||
"a bare installed.has(name) check reports an installed scoped fork such as " +
|
||||
"@tintinweb/pi-subagents as missing, and then tells the user to run " +
|
||||
"`pi install npm:pi-subagents`, which would put a SECOND extension registering " +
|
||||
"the same tool names into their session"
|
||||
)
|
||||
const nextFunctionStart = extensionSource.indexOf("\nfunction ", matchStart + 1)
|
||||
const matchSource =
|
||||
nextFunctionStart === -1
|
||||
? extensionSource.slice(matchStart)
|
||||
: extensionSource.slice(matchStart, nextFunctionStart)
|
||||
|
||||
assert.ok(
|
||||
/if\s*\(\s*installed\.has\(\s*companion\s*\)\s*\)/.test(matchSource),
|
||||
"expected findInstalledCompanion in .pi/extensions/index.ts to check the exact name " +
|
||||
"first; an exact install is the ordinary case and must not be routed through the " +
|
||||
"scoped-fork scan"
|
||||
)
|
||||
assert.ok(
|
||||
/if\s*\(\s*companion\.startsWith\(\s*["'`]@["'`]\s*\)\s*\)\s*\{\s*return undefined/.test(
|
||||
matchSource
|
||||
),
|
||||
"expected findInstalledCompanion in .pi/extensions/index.ts to bail out for a SCOPED " +
|
||||
"companion entry before the fallback; for an entry like " +
|
||||
"@juicesharp/rpiv-todo the scope is part of the identity ECC is naming, so some " +
|
||||
"other publisher's rpiv-todo must not silently satisfy it"
|
||||
)
|
||||
assert.ok(
|
||||
/name\.startsWith\(\s*["'`]@["'`]\s*\)\s*&&\s*name\.endsWith\(\s*scopedSuffix\s*\)/.test(
|
||||
matchSource
|
||||
),
|
||||
"expected findInstalledCompanion in .pi/extensions/index.ts to match an installed " +
|
||||
"scoped package by the '@scope/' + exact bare name shape; matching on endsWith " +
|
||||
"alone would let a package named my-pi-subagents satisfy the pi-subagents entry"
|
||||
)
|
||||
|
||||
const withoutComments = stripComments(extensionSource)
|
||||
assert.ok(
|
||||
!/installed\.has\(name\)/.test(withoutComments),
|
||||
"found a bare installed.has(name) still used as executable code in " +
|
||||
".pi/extensions/index.ts; the /ecc-doctor companion loop must go through " +
|
||||
"findInstalledCompanion so a scoped fork is not reported as missing"
|
||||
)
|
||||
assert.ok(
|
||||
/satisfied by/.test(extensionSource),
|
||||
"expected the /ecc-doctor companion loop in .pi/extensions/index.ts to name the " +
|
||||
"package that satisfied an entry when it is not an exact match; reporting a " +
|
||||
"bare 'installed' for @tintinweb/pi-subagents under the pi-subagents line hides " +
|
||||
"which implementation is actually loaded, which is the first thing to know when " +
|
||||
"its behavior differs from the unscoped package's"
|
||||
)
|
||||
}],
|
||||
|
||||
["companion package matching (behavioral mirror): an unscoped entry is satisfied by a scoped fork, a scoped entry is matched exactly", () => {
|
||||
assert.strictEqual(
|
||||
findInstalledCompanion("pi-subagents", new Set(["pi-subagents"])),
|
||||
"pi-subagents",
|
||||
"expected an exactly-installed companion to be reported as itself"
|
||||
)
|
||||
assert.strictEqual(
|
||||
findInstalledCompanion("pi-subagents", new Set(["@tintinweb/pi-subagents"])),
|
||||
"@tintinweb/pi-subagents",
|
||||
"expected a scoped fork to satisfy the unscoped pi-subagents entry; the subagents " +
|
||||
"capability is published under that bare name by more than one maintainer, and a " +
|
||||
"user running the scoped one has working Agent/SubagentWorkflow tools in session " +
|
||||
"while /ecc-doctor was calling it missing"
|
||||
)
|
||||
assert.strictEqual(
|
||||
findInstalledCompanion("pi-subagents", new Set(["pi-subagents", "@tintinweb/pi-subagents"])),
|
||||
"pi-subagents",
|
||||
"expected the exact match to win when both are installed, so the reported name is " +
|
||||
"stable rather than depending on Set iteration order"
|
||||
)
|
||||
assert.strictEqual(
|
||||
findInstalledCompanion("pi-subagents", new Set(["my-pi-subagents"])),
|
||||
undefined,
|
||||
"expected an unscoped package that merely ENDS WITH the companion name to not " +
|
||||
"satisfy it; only a @scope/ prefix counts"
|
||||
)
|
||||
assert.strictEqual(
|
||||
findInstalledCompanion("pi-subagents", new Set(["@acme/my-pi-subagents"])),
|
||||
undefined,
|
||||
"expected a scoped package whose bare name merely ends with the companion name to " +
|
||||
"not satisfy it; the segment after the scope must equal the companion name"
|
||||
)
|
||||
assert.strictEqual(
|
||||
findInstalledCompanion("@juicesharp/rpiv-todo", new Set(["@juicesharp/rpiv-todo"])),
|
||||
"@juicesharp/rpiv-todo",
|
||||
"expected an exactly-installed scoped companion to be reported as itself"
|
||||
)
|
||||
assert.strictEqual(
|
||||
findInstalledCompanion("@juicesharp/rpiv-todo", new Set(["@someoneelse/rpiv-todo"])),
|
||||
undefined,
|
||||
"expected a DIFFERENT scope to not satisfy a scoped companion entry; ECC names that " +
|
||||
"scope deliberately, so relaxing this direction would report an unrelated " +
|
||||
"publisher's package as the one ECC documents"
|
||||
)
|
||||
assert.strictEqual(
|
||||
findInstalledCompanion("@juicesharp/rpiv-todo", new Set(["rpiv-todo"])),
|
||||
undefined,
|
||||
"expected an unscoped package to not satisfy a scoped companion entry"
|
||||
)
|
||||
assert.strictEqual(
|
||||
findInstalledCompanion("pi-subagents", new Set()),
|
||||
undefined,
|
||||
"expected an empty install set to satisfy nothing"
|
||||
)
|
||||
}],
|
||||
|
||||
["every COMPANION_PACKAGES entry this repo ships is still resolvable by the matcher it is checked with", () => {
|
||||
const constStart = extensionSource.indexOf("const COMPANION_PACKAGES")
|
||||
assert.ok(
|
||||
constStart !== -1,
|
||||
"expected to find a COMPANION_PACKAGES array literal in .pi/extensions/index.ts"
|
||||
)
|
||||
const constEnd = extensionSource.indexOf("]", constStart)
|
||||
const companions = Array.from(
|
||||
extensionSource.slice(constStart, constEnd + 1).matchAll(/["'`](@?[\w./-]+)["'`]/g)
|
||||
).map(match => match[1])
|
||||
|
||||
assert.ok(
|
||||
companions.length > 0,
|
||||
"expected to parse at least one companion package name out of COMPANION_PACKAGES"
|
||||
)
|
||||
|
||||
for (const companion of companions) {
|
||||
assert.strictEqual(
|
||||
findInstalledCompanion(companion, new Set([companion])),
|
||||
companion,
|
||||
`expected the companion entry ${companion} to be recognized when it is installed ` +
|
||||
"under exactly its own name; an entry the matcher cannot resolve would be " +
|
||||
"reported as permanently missing no matter what the user installs"
|
||||
)
|
||||
}
|
||||
}],
|
||||
|
||||
// ---- Group 4: engineering-rules injection -------------------------
|
||||
|
||||
["PORTABLE_RULE_FILES lists exactly ECC's 7 Pi-portable rule files and excludes the 3 Claude-Code-only ones", () => {
|
||||
|
||||
@@ -184,7 +184,8 @@ function main() {
|
||||
assert.strictEqual(result.status, 0, result.stderr)
|
||||
}],
|
||||
["npm pack includes the compiled OpenCode dist payload", () => {
|
||||
const result = spawnSync("npm", ["pack", "--dry-run", "--json"], {
|
||||
fs.rmSync(path.dirname(distEntry), { recursive: true, force: true })
|
||||
const result = spawnSync("npm", ["pack", "--dry-run", "--json", "--ignore-scripts=false"], {
|
||||
cwd: repoRoot,
|
||||
encoding: "utf8",
|
||||
shell: process.platform === "win32",
|
||||
|
||||
@@ -169,6 +169,7 @@ function runHermeticPrePush({
|
||||
includeCorepack = true,
|
||||
includePnpm = false,
|
||||
audit = false,
|
||||
runChecks = true,
|
||||
} = {}) {
|
||||
const tempDir = createTempDir('codex-pre-push-');
|
||||
const binDir = path.join(tempDir, 'bin');
|
||||
@@ -204,6 +205,7 @@ ${includePnpm ? functionStub('pnpm', false) : ''}
|
||||
PATH: toBashPath(binDir),
|
||||
BASH_ENV: toBashPath(bashEnv),
|
||||
ECC_PREPUSH_AUDIT: audit ? '1' : '0',
|
||||
ECC_PREPUSH_RUN_CHECKS: runChecks ? '1' : '0',
|
||||
ECC_SKIP_GIT_HOOKS: '0',
|
||||
ECC_SKIP_PREPUSH: '0',
|
||||
MSYS_NO_PATHCONV: '1',
|
||||
@@ -221,7 +223,7 @@ ${includePnpm ? functionStub('pnpm', false) : ''}
|
||||
|
||||
if (
|
||||
test('pre-push uses Corepack pinned pnpm and runs every required verification script', () => {
|
||||
const { result, calls } = runHermeticPrePush();
|
||||
const { result, calls } = runHermeticPrePush({ runChecks: true });
|
||||
assert.strictEqual(result.status, 0, JSON.stringify(result, null, 2));
|
||||
assert.deepStrictEqual(calls, [
|
||||
'pnpm run lint',
|
||||
@@ -264,7 +266,7 @@ else failed++;
|
||||
|
||||
if (
|
||||
test('pre-push stops immediately when a required verification script fails', () => {
|
||||
const { result, calls } = runHermeticPrePush({ failScript: 'typecheck' });
|
||||
const { result, calls } = runHermeticPrePush({ runChecks: true, failScript: 'typecheck' });
|
||||
assert.notStrictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepStrictEqual(calls, ['pnpm run lint', 'pnpm run typecheck']);
|
||||
assert.match(result.stderr, /typecheck failed/);
|
||||
@@ -273,9 +275,20 @@ if (
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('pre-push skips verification scripts by default when opt-in is not set', () => {
|
||||
const { result, calls } = runHermeticPrePush({ runChecks: false });
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepStrictEqual(calls, []);
|
||||
assert.match(result.stderr, /ECC_PREPUSH_RUN_CHECKS!=1/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('pre-push runs the production audit through Corepack pnpm', () => {
|
||||
const { result, calls } = runHermeticPrePush({ audit: true });
|
||||
const { result, calls } = runHermeticPrePush({ runChecks: true, audit: true });
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepStrictEqual(calls, [
|
||||
'pnpm run lint',
|
||||
|
||||
@@ -6,6 +6,8 @@ const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { version } = require('../../package.json');
|
||||
|
||||
const README = path.join(__dirname, '..', '..', 'README.md');
|
||||
const RULES_README = path.join(__dirname, '..', '..', 'rules', 'README.md');
|
||||
const CODEX_AGENTS = path.join(__dirname, '..', '..', '.codex', 'AGENTS.md');
|
||||
@@ -53,10 +55,10 @@ function runTests() {
|
||||
|
||||
if (test('README leads with the idempotent guided plugin setup path', () => {
|
||||
const topClaudeSectionIndex = readme.indexOf('## Install with Claude Code');
|
||||
const topGuidedCommandIndex = readme.indexOf('npx ecc-universal@2.2.1 setup', topClaudeSectionIndex);
|
||||
const topGuidedCommandIndex = readme.indexOf(`npx ecc-universal@${version} setup`, topClaudeSectionIndex);
|
||||
const nativePluginCommandIndex = readme.indexOf('/plugin marketplace add', topClaudeSectionIndex);
|
||||
const installSectionIndex = readme.indexOf('## Install ECC');
|
||||
const guidedCommandIndex = readme.indexOf('npx ecc-universal@2.2.1 setup', installSectionIndex);
|
||||
const guidedCommandIndex = readme.indexOf(`npx ecc-universal@${version} setup`, installSectionIndex);
|
||||
const claudeDetailsIndex = readme.indexOf('### Claude Code details', installSectionIndex);
|
||||
|
||||
assert.ok(
|
||||
@@ -95,9 +97,9 @@ function runTests() {
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('README documents modern package-runner alternatives', () => {
|
||||
assert.ok(readme.includes('pnpm dlx ecc-universal@2.2.1 setup'));
|
||||
assert.ok(readme.includes('yarn dlx ecc-universal@2.2.1 setup'));
|
||||
assert.ok(readme.includes('bunx ecc-universal@2.2.1 setup'));
|
||||
assert.ok(readme.includes(`pnpm dlx ecc-universal@${version} setup`));
|
||||
assert.ok(readme.includes(`yarn dlx ecc-universal@${version} setup`));
|
||||
assert.ok(readme.includes(`bunx ecc-universal@${version} setup`));
|
||||
assert.ok(
|
||||
readme.includes('Yarn Classic 1 does not provide `yarn dlx`'),
|
||||
'README should not advertise the modern Yarn command to Yarn Classic users'
|
||||
@@ -122,10 +124,10 @@ function runTests() {
|
||||
'README should document doctor before reinstalling'
|
||||
);
|
||||
for (const command of [
|
||||
'npx ecc-universal@2.2.1 list-installed',
|
||||
'npx ecc-universal@2.2.1 doctor',
|
||||
'npx ecc-universal@2.2.1 repair',
|
||||
'npx ecc-universal@2.2.1 uninstall --dry-run',
|
||||
`npx ecc-universal@${version} list-installed`,
|
||||
`npx ecc-universal@${version} doctor`,
|
||||
`npx ecc-universal@${version} repair`,
|
||||
`npx ecc-universal@${version} uninstall --dry-run`,
|
||||
]) {
|
||||
assert.ok(
|
||||
readme.includes(command),
|
||||
@@ -148,7 +150,7 @@ function runTests() {
|
||||
'README should document the shell minimal profile command'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('npx ecc-universal@2.2.1 install --profile minimal --target claude'),
|
||||
readme.includes(`npx ecc-universal@${version} install --profile minimal --target claude`),
|
||||
'README should document the published universal-package minimal profile command'
|
||||
);
|
||||
assert.ok(
|
||||
@@ -175,7 +177,7 @@ function runTests() {
|
||||
'README should surface component discovery before install steps'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('npx ecc-universal@2.2.1 consult "security reviews" --target claude'),
|
||||
readme.includes(`npx ecc-universal@${version} consult "security reviews" --target claude`),
|
||||
'README should document the packaged consult command'
|
||||
);
|
||||
assert.ok(
|
||||
@@ -193,15 +195,15 @@ function runTests() {
|
||||
|
||||
if (test('README gives the native guided Codex and managed Kimi dry-run paths', () => {
|
||||
assert.ok(
|
||||
readme.includes('npx ecc-universal@2.2.1 install --guided --harness codex --dry-run'),
|
||||
readme.includes(`npx ecc-universal@${version} install --guided --harness codex --dry-run`),
|
||||
'README should verify Codex through the native guided reconciler'
|
||||
);
|
||||
assert.ok(
|
||||
!readme.includes('npx ecc-universal@2.2.1 install --profile core --target codex --dry-run'),
|
||||
!readme.includes(`npx ecc-universal@${version} install --profile core --target codex --dry-run`),
|
||||
'README should not present the legacy managed Codex adapter as the native lifecycle'
|
||||
);
|
||||
assert.ok(
|
||||
readme.includes('npx ecc-universal@2.2.1 install --profile core --target kimi --dry-run')
|
||||
readme.includes(`npx ecc-universal@${version} install --profile core --target kimi --dry-run`)
|
||||
);
|
||||
for (const target of ['cursor', 'gemini', 'opencode', 'codebuddy', 'joycode', 'qwen', 'zed', 'hermes', 'openclaw']) {
|
||||
assert.ok(readme.includes(`\`${target}\``), `README should name the ${target} target`);
|
||||
@@ -313,7 +315,6 @@ function runTests() {
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('README binds package runners to the release and avoids unaudited bootstraps', () => {
|
||||
const version = JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'package.json'))).version;
|
||||
const runners = [...readme.matchAll(/(?:npx |pnpm dlx |yarn dlx |bunx )(ecc-universal[^\s`]+)/g)];
|
||||
assert.ok(runners.length >= 15);
|
||||
for (const match of runners) assert.strictEqual(match[1], `ecc-universal@${version}`);
|
||||
@@ -321,7 +322,7 @@ function runTests() {
|
||||
assert.ok(!/npm install -g opencode(?:\s|$)/m.test(readme));
|
||||
assert.match(readme, /version pin is not a security audit/i);
|
||||
assert.match(readme, /already installed.*reviewed.*AgentShield/i);
|
||||
assert.ok(readme.includes('https://www.npmjs.com/package/ecc-universal/v/2.2.1'));
|
||||
assert.ok(readme.includes(`https://www.npmjs.com/package/ecc-universal/v/${version}`));
|
||||
})) passed++; else failed++;
|
||||
|
||||
console.log(`\nResults: Passed: ${passed}, Failed: ${failed}`);
|
||||
|
||||
@@ -806,6 +806,76 @@ async function main() {
|
||||
assert.strictEqual(invalidParams.error.code, -32600);
|
||||
});
|
||||
|
||||
for (const [label, params] of [
|
||||
['omitted params', undefined],
|
||||
['empty params', {}],
|
||||
['empty metadata', { _meta: {} }],
|
||||
['extension metadata', { _meta: { 'example.com/trace': 'sample' } }],
|
||||
]) {
|
||||
await test(`accepts initialized notifications with ${label}`, async () => {
|
||||
const { createMemoryMcpService } = await import(pathToFileURL(SERVER).href);
|
||||
const service = createMemoryMcpService({ harness: 'claude' });
|
||||
const initialized = await service.handle({
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-11-25', capabilities: {},
|
||||
clientInfo: { name: 'metadata-test', version: '1.0.0' },
|
||||
},
|
||||
});
|
||||
assert.strictEqual(initialized.error, undefined);
|
||||
const notification = await service.handle({
|
||||
jsonrpc: '2.0', method: 'notifications/initialized',
|
||||
...(params === undefined ? {} : { params }),
|
||||
});
|
||||
assert.strictEqual(notification, null);
|
||||
const listed = await service.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
|
||||
assert.strictEqual(listed.error, undefined);
|
||||
assert.ok(listed.result.tools.some(tool => tool.name === 'memory_search'));
|
||||
});
|
||||
}
|
||||
|
||||
await test('ignores initialized notifications with malformed metadata or unknown params', async () => {
|
||||
const { createMemoryMcpService } = await import(pathToFileURL(SERVER).href);
|
||||
for (const params of [
|
||||
...[null, [], 'invalid', 1, false].map(_meta => ({ _meta })),
|
||||
{ _meta: {}, unexpected: true },
|
||||
]) {
|
||||
const service = createMemoryMcpService({ harness: 'claude' });
|
||||
await service.handle({
|
||||
jsonrpc: '2.0', id: 1, method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-11-25', capabilities: {},
|
||||
clientInfo: { name: 'metadata-test', version: '1.0.0' },
|
||||
},
|
||||
});
|
||||
assert.strictEqual(await service.handle({
|
||||
jsonrpc: '2.0', method: 'notifications/initialized', params,
|
||||
}), null);
|
||||
const listed = await service.handle({ jsonrpc: '2.0', id: 2, method: 'tools/list' });
|
||||
assert.strictEqual(listed.error?.code, -32002, JSON.stringify(params));
|
||||
}
|
||||
});
|
||||
|
||||
await test('does not initialize from a metadata notification sent before initialize', async () => {
|
||||
const { createMemoryMcpService } = await import(pathToFileURL(SERVER).href);
|
||||
const service = createMemoryMcpService({ harness: 'claude' });
|
||||
const notification = {
|
||||
jsonrpc: '2.0', method: 'notifications/initialized', params: { _meta: {} },
|
||||
};
|
||||
assert.strictEqual(await service.handle(notification), null);
|
||||
const before = await service.handle({ jsonrpc: '2.0', id: 1, method: 'tools/list' });
|
||||
assert.strictEqual(before.error?.code, -32002);
|
||||
await service.handle({
|
||||
jsonrpc: '2.0', id: 2, method: 'initialize',
|
||||
params: {
|
||||
protocolVersion: '2025-11-25', capabilities: {},
|
||||
clientInfo: { name: 'metadata-test', version: '1.0.0' },
|
||||
},
|
||||
});
|
||||
const after = await service.handle({ jsonrpc: '2.0', id: 3, method: 'tools/list' });
|
||||
assert.strictEqual(after.error?.code, -32002);
|
||||
});
|
||||
|
||||
await test('bounds queued transport work under a single-chunk request flood', async () => {
|
||||
const {
|
||||
MAX_PENDING_MESSAGES,
|
||||
|
||||
@@ -122,8 +122,12 @@ function buildExpectedPublishPaths(repoRoot) {
|
||||
[...modules.flatMap((module) => module.paths || []), ...extraPaths, ...exclusionPaths].map(normalizePublishPath)
|
||||
)
|
||||
|
||||
// npm needs an explicit entry to include this gitignored build output.
|
||||
const requiredBuildPaths = [".opencode/dist"]
|
||||
|
||||
return [...combined]
|
||||
.filter((publishPath) => !isCoveredByAncestor(publishPath, combined))
|
||||
.concat(requiredBuildPaths)
|
||||
.sort()
|
||||
}
|
||||
|
||||
|
||||
+54
-2
@@ -1,5 +1,5 @@
|
||||
from llm.core.types import ToolCall, ToolDefinition
|
||||
from llm.tools import ToolExecutor, ToolRegistry
|
||||
from llm.core.types import LLMInput, LLMOutput, Message, Role, ToolCall, ToolDefinition
|
||||
from llm.tools import ReActAgent, ToolExecutor, ToolRegistry
|
||||
|
||||
|
||||
class TestToolRegistry:
|
||||
@@ -83,3 +83,55 @@ class TestToolExecutor:
|
||||
assert len(results) == 2
|
||||
assert results[0].content == "result1"
|
||||
assert results[1].content == "result2"
|
||||
|
||||
|
||||
class TestAsyncTools:
|
||||
def test_execute_async_awaits_coroutine(self):
|
||||
import asyncio
|
||||
|
||||
registry = ToolRegistry()
|
||||
|
||||
async def fetch(url: str = "") -> str:
|
||||
return f"fetched:{url}"
|
||||
|
||||
registry.register(ToolDefinition(name="fetch", description="", parameters={}), fetch)
|
||||
|
||||
executor = ToolExecutor(registry)
|
||||
result = asyncio.run(
|
||||
executor.execute_async(ToolCall(id="1", name="fetch", arguments={"url": "x"}))
|
||||
)
|
||||
|
||||
assert result.tool_call_id == "1"
|
||||
assert result.content == "fetched:x"
|
||||
assert result.is_error is False
|
||||
|
||||
def test_react_agent_runs_awaitable_tool(self):
|
||||
import asyncio
|
||||
|
||||
async def lookup(key: str = "") -> str:
|
||||
return f"value:{key}"
|
||||
|
||||
registry = ToolRegistry()
|
||||
registry.register(ToolDefinition(name="lookup", description="", parameters={}), lookup)
|
||||
|
||||
seen = {}
|
||||
|
||||
class FakeProvider:
|
||||
def generate(self, agent_input):
|
||||
if "done" not in seen:
|
||||
seen["done"] = True
|
||||
return LLMOutput(
|
||||
content="",
|
||||
tool_calls=[ToolCall(id="1", name="lookup", arguments={"key": "k"})],
|
||||
)
|
||||
tool_messages = [m for m in agent_input.messages if m.role == Role.TOOL]
|
||||
assert len(tool_messages) == 1
|
||||
assert tool_messages[0].content == "value:k"
|
||||
return LLMOutput(content="done")
|
||||
|
||||
agent = ReActAgent(provider=FakeProvider(), executor=ToolExecutor(registry))
|
||||
output = asyncio.run(
|
||||
agent.run(LLMInput(messages=[Message(role=Role.USER, content="hi")]))
|
||||
)
|
||||
|
||||
assert output.content == "done"
|
||||
|
||||
Reference in New Issue
Block a user