mirror of
https://github.com/affaan-m/ECC.git
synced 2026-10-20 06:51:44 +02:00
merge: reconcile Polish locale with current main
This commit is contained in:
+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,21 +119,185 @@ 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 ./..."
|
||||
go test ./... || fail "go test failed"
|
||||
fi
|
||||
|
||||
# Resolve how this project runs pytest, into PYTEST_CMD as an argv array.
|
||||
#
|
||||
# Looking only for `pytest` on PATH meant the hook skipped every project that keeps
|
||||
# its tools in a virtualenv -- which is most of them -- and reported "pytest is not
|
||||
# installed" while sitting next to a .venv with pytest in it. A gate that silently
|
||||
# declines to gate is worse than no gate, because the skip line reads like a pass.
|
||||
#
|
||||
# An array rather than one string, because a virtualenv path may contain spaces:
|
||||
# a scalar command splits `/home/me/my env/bin/python` into two paths that do not
|
||||
# exist, and the hook then rejects the push for a reason that has nothing to do
|
||||
# with the code being pushed.
|
||||
#
|
||||
# Echoes the command it will run, so the reason for a skip is always visible.
|
||||
PYTEST_CMD=()
|
||||
|
||||
# Does this command actually run pytest? Accepting `--version` is not evidence --
|
||||
# plenty of programs take it and exit 0 -- so the output has to name pytest. The
|
||||
# version is captured rather than piped: under `set -o pipefail` a `| grep -q` can
|
||||
# report the SIGPIPE of the program it just matched.
|
||||
#
|
||||
# Only ever called on a command this script composed itself. Probing an arbitrary
|
||||
# operator-supplied command is not safe: a wrapper that ignores `--version` and
|
||||
# execs pytest runs the entire suite during the probe, and is then rejected for
|
||||
# not having printed a version.
|
||||
is_pytest() {
|
||||
local version
|
||||
version="$("$@" --version 2>&1)" || return 1
|
||||
grep -qiE 'pytest[[:space:]]+(version[[:space:]]+)?[0-9]' <<<"$version"
|
||||
}
|
||||
|
||||
# Does the repository itself ship this interpreter?
|
||||
#
|
||||
# A virtualenv is never committed -- it is platform-specific binaries, and every
|
||||
# Python project gitignores it. One that IS tracked is the repository handing this
|
||||
# hook an executable and asking it to run. The hook is installed globally, so
|
||||
# cloning a hostile repository and pushing it to your own fork would be enough,
|
||||
# and on a machine with no pytest on PATH this arm is the only thing that would
|
||||
# run at all. A developer's own venv is untracked, so nothing legitimate is lost.
|
||||
#
|
||||
# The path is resolved through symlinks before git is asked, because `git ls-files`
|
||||
# reports paths as indexed and does not follow links. A repository that commits
|
||||
# `.venv` as a symlink to `.` next to a tracked `bin/python` would otherwise be
|
||||
# queried for `.venv/bin/python`, a path git has never heard of, and the answer
|
||||
# would be "untracked". Measured: that shape ran the planted binary twice.
|
||||
repo_ships_interpreter() {
|
||||
local bindir real top
|
||||
bindir="$(cd -P -- "$1" 2>/dev/null && pwd -P)" || return 1
|
||||
[[ -n "$bindir" ]] || return 1
|
||||
real="$bindir/python"
|
||||
top="$(git rev-parse --show-toplevel 2>/dev/null)" || return 1
|
||||
top="$(cd -P -- "$top" 2>/dev/null && pwd -P)" || return 1
|
||||
[[ -n "$top" && "$real" == "$top/"* ]] || return 1
|
||||
# `:(icase)` because git matches index pathspecs case-sensitively even where
|
||||
# core.ignorecase is set, while the filesystem underneath does not. On macOS's
|
||||
# APFS -- the platform this hook most often runs on -- a committed
|
||||
# `.venv/bin/Python` is what `$venv/bin/python` opens and executes, but a
|
||||
# case-sensitive query for the lowercase name finds nothing in the index and the
|
||||
# guard waves it through. Measured: that spelling ran the planted binary twice.
|
||||
git ls-files --error-unmatch -- ":(icase)${real#"$top"/}" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# `-I` isolates the probe: without it Python puts the working directory first on
|
||||
# sys.path, so a repository that commits a `pytest.py` in its root gets that file
|
||||
# imported -- and executed -- by a check whose only job is to answer whether pytest
|
||||
# exists. Measured: a committed pytest.py ran during the probe. Isolation does not
|
||||
# hide a real pytest, which lives in the interpreter's own site-packages.
|
||||
resolve_pytest() {
|
||||
# `${VAR+set}` rather than `-n "${VAR:-}"`, so that a variable set to nothing is
|
||||
# still an override: `ECC_PYTEST_CMD=` and `ECC_PYTEST_CMD=" "` now behave
|
||||
# alike, where the first used to fall through to discovery and the second failed
|
||||
# the push. Falling through is the wrong half of that pair -- an override that
|
||||
# evaluated empty (a command substitution that found nothing, say) would silently
|
||||
# run a different runner than the operator asked for, which is the substitution
|
||||
# this resolver refuses to make anywhere else.
|
||||
#
|
||||
# Not `[[ -v ECC_PYTEST_CMD ]]`: that is bash 4.2, and a stock macOS `/bin/bash`
|
||||
# is 3.2, where it is a syntax error rather than a false. This hook ships to
|
||||
# whatever `env bash` finds.
|
||||
if [[ -n "${ECC_PYTEST_CMD+set}" ]]; then
|
||||
# Taken as given. This is a deliberate override, and the hook cannot inspect it
|
||||
# without running it -- a wrapper script may ignore `--version` and run the
|
||||
# suite, so probing costs a duplicate test run and then blocks the push anyway.
|
||||
# Pointing this at something that is not pytest turns the gate off, and that is
|
||||
# the operator's call to make, not a misconfiguration for the hook to second
|
||||
# guess. Word-split, so the command names something on PATH or an interpreter
|
||||
# whose path has no spaces; a venv with spaces is found by the loop below.
|
||||
read -r -a PYTEST_CMD <<<"$ECC_PYTEST_CMD" || true
|
||||
[[ ${#PYTEST_CMD[@]} -gt 0 ]] || fail "ECC_PYTEST_CMD is set but names no command.\
|
||||
Point it at your test runner, or unset it to fall back to discovery."
|
||||
return 0
|
||||
fi
|
||||
local venv
|
||||
for venv in "${VIRTUAL_ENV:-}" .venv venv env; do
|
||||
if [[ -n "$venv" && -x "$venv/bin/python" ]]; then
|
||||
if repo_ships_interpreter "$venv/bin"; then
|
||||
log "Ignoring $venv/bin/python: the repository ships it."
|
||||
log " A committed virtualenv is an executable the repository controls, and"
|
||||
log " this hook runs on every push in every repository."
|
||||
continue
|
||||
fi
|
||||
if "$venv/bin/python" -I -c "import pytest" >/dev/null 2>&1; then
|
||||
PYTEST_CMD=("$venv/bin/python" -m pytest)
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
if [[ -f "uv.lock" ]] && command -v uv >/dev/null 2>&1; then
|
||||
if uv run --no-sync python -I -c "import pytest" >/dev/null 2>&1; then
|
||||
PYTEST_CMD=(uv run --no-sync pytest)
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
if [[ -f "poetry.lock" ]] && command -v poetry >/dev/null 2>&1; then
|
||||
if poetry run python -I -c "import pytest" >/dev/null 2>&1; then
|
||||
PYTEST_CMD=(poetry run pytest)
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
# `command -v` proves only that a file of that name exists on PATH. This one the
|
||||
# script composed itself, so confirming it costs a harmless `pytest --version`.
|
||||
if command -v pytest >/dev/null 2>&1 && is_pytest pytest; then
|
||||
PYTEST_CMD=(pytest)
|
||||
return 0
|
||||
fi
|
||||
PYTEST_CMD=()
|
||||
return 1
|
||||
}
|
||||
|
||||
if [[ -f "pyproject.toml" || -f "requirements.txt" ]]; then
|
||||
if command -v pytest >/dev/null 2>&1; then
|
||||
if resolve_pytest; then
|
||||
ran_any_check=1
|
||||
log "Python project detected. Running: pytest -q"
|
||||
pytest -q || fail "pytest failed"
|
||||
log "Python project detected. Running: ${PYTEST_CMD[*]} -q"
|
||||
if [[ -n "${ECC_PYTEST_CMD+set}" ]]; then
|
||||
# resolve_pytest deliberately does not verify the override is pytest, because
|
||||
# probing it can run the operator's suite. What this gate can honestly do
|
||||
# about a stale override is refuse to be quiet about it: a bypass announced
|
||||
# on every push is not the silent gate this resolver exists to prevent.
|
||||
log " via ECC_PYTEST_CMD -- the hook runs what you pointed it at, and does"
|
||||
log " not check that it is pytest. Unset it to gate on the real suite."
|
||||
fi
|
||||
pytest_status=0
|
||||
"${PYTEST_CMD[@]}" -q || pytest_status=$?
|
||||
case "$pytest_status" in
|
||||
0) ;;
|
||||
# pytest reserves 5 for NO_TESTS_COLLECTED, which is not a red suite. A
|
||||
# pyproject.toml that only configures ruff or black is still a Python project
|
||||
# by this hook's test, and blocking those pushes would make the gate something
|
||||
# people switch off. Never silent, though: a bad rootdir, testpaths or a
|
||||
# conftest that fails to import also collects nothing, and swallowing that is
|
||||
# the same skip-reads-like-a-pass hole this resolver exists to close.
|
||||
5)
|
||||
log "pytest collected no tests (exit 5). Not gating this push."
|
||||
log " If this repository is supposed to have tests, that is the bug:"
|
||||
log " check rootdir, testpaths, and conftest.py import errors."
|
||||
;;
|
||||
# The code is in the message because 1 (tests failed) and 4 (usage error)
|
||||
# need different responses, and "pytest failed" alone cannot tell them apart.
|
||||
*) fail "pytest failed (exit $pytest_status)" ;;
|
||||
esac
|
||||
else
|
||||
log "Python project detected but pytest is not installed. Skipping."
|
||||
log "Python project detected but no pytest found (checked \$VIRTUAL_ENV, .venv,"
|
||||
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
|
||||
log "No supported checks found in this repository. Skipping."
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -10,16 +10,14 @@ const {
|
||||
} = require('./lib/control-pane/server');
|
||||
const { describeMissingDependencyError } = require('./lib/missing-dependency');
|
||||
|
||||
// openBrowser is now in scripts/lib/platform-launch.js — keep a thin wrapper
|
||||
// for backwards compatibility, but surface the structured result.
|
||||
const { openBrowser: launchOpenBrowser } = require('./lib/platform-launch');
|
||||
function openBrowser(url) {
|
||||
if (process.platform !== 'darwin') return;
|
||||
const child = spawn('open', [url], {
|
||||
stdio: 'ignore',
|
||||
detached: true,
|
||||
});
|
||||
child.on('error', error => {
|
||||
console.error(`[control-pane] failed to open browser: ${error.message}`);
|
||||
});
|
||||
child.unref();
|
||||
const result = launchOpenBrowser(url);
|
||||
if (!result.opened) {
|
||||
console.error(`[control-pane] failed to open browser: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main(argv = process.argv) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -359,6 +359,155 @@ function quoteAwareSegments(input) {
|
||||
|
||||
const SHELL_WRAPPERS = new Set(['sh', 'bash', 'zsh', 'dash', 'ksh']);
|
||||
|
||||
/**
|
||||
* SQL clients whose `-c`/`-e`/positional arguments carry SQL statements.
|
||||
* Quoted SQL (e.g. `psql -c "drop table users"`) is invisible to the
|
||||
* quote-stripping SQL regex, so it is re-checked here against dequoted
|
||||
* tokens where quoted content is preserved (issue #3024). Restricted to
|
||||
* known clients so `git commit -m "drop table"` and `echo "drop table"`
|
||||
* stay allowed.
|
||||
*/
|
||||
const SQL_CLIENT_COMMANDS = new Set([
|
||||
'psql',
|
||||
'postgres',
|
||||
'mysql',
|
||||
'mariadb',
|
||||
'sqlite3',
|
||||
'sqlite',
|
||||
'sqlcmd',
|
||||
'isql',
|
||||
'pgcli',
|
||||
'mycli',
|
||||
'duckdb',
|
||||
'bq',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Strip SQL string literals so phrases inside query data do not trigger
|
||||
* the destructive detector (e.g. `SELECT 'drop table' ...` is a read).
|
||||
* Handles single-quoted literals with '' escapes, double-quoted
|
||||
* identifiers, and dollar-quoted blocks ($$...$$ and $tag$...$tag$).
|
||||
*
|
||||
* @param {string} input
|
||||
* @returns {string}
|
||||
*/
|
||||
function stripSqlLiterals(input) {
|
||||
return String(input || '')
|
||||
.replace(/'(?:[^']|'')*'/g, "''")
|
||||
.replace(/"(?:[^"\\]|\\.)*"/g, '""')
|
||||
.replace(/(\$[A-Za-z_][A-Za-z0-9_]*\$|\$\$)[\s\S]*?\1/g, '$$$$');
|
||||
}
|
||||
|
||||
const SUDO_VALUE_FLAGS = new Set([
|
||||
'-u',
|
||||
'--user',
|
||||
'-g',
|
||||
'--group',
|
||||
'-U',
|
||||
'--other-user',
|
||||
'-p',
|
||||
'--prompt',
|
||||
'-C',
|
||||
'--close-from',
|
||||
'-D',
|
||||
'--chdir',
|
||||
'-h',
|
||||
'--host',
|
||||
'-r',
|
||||
'--role',
|
||||
'-t',
|
||||
'--type',
|
||||
'-T',
|
||||
'--command-timeout',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Advance past `sudo`/`doas`/`env` wrappers including their flags and
|
||||
* `VAR=value` assignments, so `sudo -u postgres psql ...` and
|
||||
* `env PGUSER=postgres psql ...` still resolve to the real command.
|
||||
*
|
||||
* @param {string[]} tokens dequoted tokens for one segment
|
||||
* @returns {number} index of the real command token
|
||||
*/
|
||||
function unwrapLeadWrappers(tokens) {
|
||||
let index = 0;
|
||||
for (let guard = 0; guard < 4; guard += 1) {
|
||||
if (index >= tokens.length) return index;
|
||||
const base = commandBasename(tokens[index]);
|
||||
if (base === 'sudo' || base === 'doas') {
|
||||
index += 1;
|
||||
while (index < tokens.length) {
|
||||
const flag = tokens[index];
|
||||
if (flag === '--') {
|
||||
index += 1;
|
||||
break;
|
||||
}
|
||||
if (flag === '-' || !flag.startsWith('-')) break;
|
||||
if (SUDO_VALUE_FLAGS.has(flag)) {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (/^--[^=]+=.*$/.test(flag)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (base === 'env') {
|
||||
index += 1;
|
||||
while (index < tokens.length) {
|
||||
const arg = tokens[index];
|
||||
if (arg === '--' || arg === '-' || arg === '-i' || arg === '--ignore-environment') {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg === '-u' || arg === '--unset') {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (arg === '-C' || arg === '--chdir') {
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
if (/^--unset=.*$/.test(arg) || /^--chdir=.*$/.test(arg) || /^--argv0=.*$/.test(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith('-') && !/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(arg)) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
return index;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect destructive SQL passed as (possibly quoted) arguments to a known
|
||||
* SQL client. Operates on dequoted tokens from `quoteAwareSegments`, so
|
||||
* `psql -c "drop table users"` joins back to matchable text.
|
||||
*
|
||||
* @param {string[]} tokens dequoted tokens for one segment
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isDestructiveSqlClient(tokens) {
|
||||
if (!tokens || tokens.length === 0) return false;
|
||||
const start = unwrapLeadWrappers(tokens);
|
||||
if (start >= tokens.length) return false;
|
||||
if (!SQL_CLIENT_COMMANDS.has(commandBasename(tokens[start]))) return false;
|
||||
return DESTRUCTIVE_SQL_DD.test(stripSqlLiterals(tokens.slice(start).join(' ')));
|
||||
}
|
||||
|
||||
/**
|
||||
* Quote-aware destructive check: catches quoted command words, newline
|
||||
* separators, quoted `find -exec`, and `sh -c`/`bash -c` wrappers that evade
|
||||
@@ -374,10 +523,12 @@ function isDestructiveQuoteAware(raw, depth = 0) {
|
||||
if (tokens.length === 0) continue;
|
||||
if (isDestructiveRm(tokens)) return true;
|
||||
if (isDestructiveGit(tokens)) return true;
|
||||
if (isDestructiveSqlClient(tokens)) return true;
|
||||
if (isDestructiveFindExec(tokens.join(' '))) return true;
|
||||
const base = commandBasename(tokens[0]);
|
||||
const wi = unwrapLeadWrappers(tokens);
|
||||
const base = wi < tokens.length ? commandBasename(tokens[wi]) : '';
|
||||
if (SHELL_WRAPPERS.has(base)) {
|
||||
const ci = tokens.indexOf('-c');
|
||||
const ci = tokens.indexOf('-c', wi);
|
||||
if (ci !== -1 && tokens[ci + 1] && isDestructiveQuoteAware(tokens[ci + 1], depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
@@ -462,10 +613,65 @@ function findGitSubcommand(tokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch names treated as shared history: a forced update of one of
|
||||
* these rewrites commits other clones build on, even when the push is
|
||||
* lease-checked.
|
||||
*/
|
||||
const SHARED_GIT_BRANCHES = new Set(['main', 'master', 'develop', 'trunk']);
|
||||
|
||||
/**
|
||||
* Decide whether the positional arguments of a `git push` name a shared
|
||||
* branch as the destination of a refspec. The first positional token is
|
||||
* the remote (unless the remote came from `--repo`); every later
|
||||
* positional token is a refspec whose destination is the part after
|
||||
* `:` (or the whole token when there is no `:`). A leading `+` force
|
||||
* marker is stripped. When no refspec is given the target is the
|
||||
* current branch, which the hook cannot know, so this returns false.
|
||||
*
|
||||
* @param {string[]} rest tokens after `push`
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function pushTargetsSharedBranch(rest) {
|
||||
const valueConsuming = new Set(['-o', '--push-option', '--receive-pack', '--exec']);
|
||||
const positional = [];
|
||||
let remoteViaFlag = false;
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
const t = rest[i];
|
||||
if (t === '--repo') {
|
||||
remoteViaFlag = true;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (t.startsWith('--repo=')) {
|
||||
remoteViaFlag = true;
|
||||
continue;
|
||||
}
|
||||
if (valueConsuming.has(t)) {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
if (t.startsWith('-')) continue;
|
||||
positional.push(t);
|
||||
}
|
||||
// Unless the remote came from --repo, positional[0] is the remote and
|
||||
// the rest are refspecs.
|
||||
const refspecs = remoteViaFlag ? positional : positional.slice(1);
|
||||
for (const refspec of refspecs) {
|
||||
const cleaned = refspec.startsWith('+') ? refspec.slice(1) : refspec;
|
||||
const dst = cleaned.includes(':') ? cleaned.slice(cleaned.indexOf(':') + 1) : cleaned;
|
||||
const branch = dst.startsWith('refs/heads/') ? dst.slice('refs/heads/'.length) : dst;
|
||||
if (SHARED_GIT_BRANCHES.has(branch)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect destructive `git` invocations: `reset --hard`, `checkout --`,
|
||||
* `clean -f...`, `push --force` (but not `--force-with-lease`),
|
||||
* `commit --amend`, `rm -rf`.
|
||||
* `clean -f...`, `push --force` (`--force-with-lease` only to a shared
|
||||
* branch), `commit --amend`, `rm -rf`, `branch -D`, `stash drop` /
|
||||
* `stash clear`, `reflog expire` / `reflog delete`, `update-ref -d`,
|
||||
* and `restore` against the worktree.
|
||||
*
|
||||
* @param {string[]} tokens
|
||||
* @returns {boolean}
|
||||
@@ -532,7 +738,9 @@ function isDestructiveGit(tokens) {
|
||||
plusRefspecForce = true;
|
||||
}
|
||||
}
|
||||
return bareForce || (plusRefspecForce && !withLease);
|
||||
if (bareForce || (plusRefspecForce && !withLease)) return true;
|
||||
// A lease-checked force still rewrites a shared branch's history.
|
||||
return withLease && pushTargetsSharedBranch(rest);
|
||||
}
|
||||
|
||||
if (command === 'commit') {
|
||||
@@ -563,6 +771,53 @@ function isDestructiveGit(tokens) {
|
||||
});
|
||||
}
|
||||
|
||||
if (command === 'branch') {
|
||||
// `git branch -D` (long spelling: `--delete --force`) deletes a
|
||||
// branch even when it is unmerged, orphaning its commits. Plain
|
||||
// `-d` refuses when unmerged, so it is safe to leave ungated.
|
||||
let del = false;
|
||||
let force = false;
|
||||
for (const t of rest) {
|
||||
if (t === '--delete') { del = true; continue; }
|
||||
if (t === '--force') { force = true; continue; }
|
||||
if (!t.startsWith('-') || t.startsWith('--')) continue;
|
||||
const body = t.slice(1);
|
||||
if (body.includes('D')) return true;
|
||||
if (body.includes('d')) del = true;
|
||||
if (body.includes('f')) force = true;
|
||||
}
|
||||
return del && force;
|
||||
}
|
||||
|
||||
if (command === 'stash') {
|
||||
// `drop` destroys one stash entry, `clear` the entire stash.
|
||||
// `list`, `show`, `pop` and `apply` keep the entries recoverable.
|
||||
return rest[0] === 'drop' || rest[0] === 'clear';
|
||||
}
|
||||
|
||||
if (command === 'reflog') {
|
||||
// `expire` and `delete` remove the recovery net that makes every
|
||||
// other gated git command recoverable.
|
||||
return rest[0] === 'expire' || rest[0] === 'delete';
|
||||
}
|
||||
|
||||
if (command === 'update-ref') {
|
||||
// `git update-ref -d <ref>` deletes a ref directly.
|
||||
return rest.includes('-d') || rest.includes('--delete');
|
||||
}
|
||||
|
||||
if (command === 'restore') {
|
||||
// `git restore <path>` overwrites the working tree from the index
|
||||
// by default, the modern spelling of gated `git checkout -- <path>`.
|
||||
// Only `--staged` alone is non-destructive (it leaves the file on
|
||||
// disk untouched); `--worktree` (the default target) is destructive.
|
||||
const has = (long, short) => rest.some(t =>
|
||||
t === long || (t.startsWith('-') && !t.startsWith('--') && t.slice(1).includes(short)));
|
||||
const staged = has('--staged', 'S');
|
||||
const worktree = has('--worktree', 'W');
|
||||
return worktree || !staged;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -1007,16 +1262,62 @@ function isChecked(key) {
|
||||
|
||||
// --- Sanitize file path against injection ---
|
||||
|
||||
// Unicode policy for sanitizePath, mirroring the repo-wide dangerous set in
|
||||
// scripts/ci/check-unicode-safety.js. Named so the ranges stay auditable and
|
||||
// drift against the CI policy is visible in one place.
|
||||
const ASCII_CONTROL_MAX = 0x1f;
|
||||
const ASCII_DELETE = 0x7f;
|
||||
const C1_CONTROLS = [0x80, 0x9f]; // Unicode C1 control block (U+0080..U+009F)
|
||||
const BIDI_MARKS = [0x200e, 0x200f]; // LRM/RLM
|
||||
const BIDI_EMBEDDINGS = [0x202a, 0x202e]; // LRE..PDF
|
||||
const BIDI_ISOLATES = [0x2066, 0x2069]; // LRI..PDI
|
||||
const ZERO_WIDTHS = [0x200b, 0x200d]; // ZWSP..ZWJ
|
||||
const WORD_JOINER = 0x2060;
|
||||
const BYTE_ORDER_MARK = 0xfeff;
|
||||
const VARIATION_SELECTORS = [0xfe00, 0xfe0f];
|
||||
const VARIATION_SUPPLEMENTS = [0xe0100, 0xe01ef]; // MONGOLIAN..TAGS (VS17..VS256)
|
||||
const TAG_BLOCK = [0xe0000, 0xe007f]; // ASCII-smuggling tag characters
|
||||
const MONGOLIAN_VOWEL_SEPARATOR = 0x180e;
|
||||
const HANGUL_CHOSEONG_FILLER = 0x115f;
|
||||
const HANGUL_JUNGSEONG_FILLER = 0x1160;
|
||||
const HANGUL_FILLER = 0x3164;
|
||||
const INVISIBLE_MATH_OPERATORS = [0x2061, 0x2064]; // FUNCTION APPLICATION..INVISIBLE PLUS
|
||||
const LINE_SEPARATOR = 0x2028;
|
||||
const PARAGRAPH_SEPARATOR = 0x2029;
|
||||
const SANITIZED_PATH_MAX_LENGTH = 500;
|
||||
|
||||
function inRange(code, [lo, hi]) {
|
||||
return code >= lo && code <= hi;
|
||||
}
|
||||
|
||||
function sanitizePath(filePath) {
|
||||
// Strip control chars (including null), bidi overrides, and newlines
|
||||
// Strip control chars (including null), bidi overrides, separators,
|
||||
// and the dangerous invisible characters defined by the constants
|
||||
// above (mirroring scripts/ci/check-unicode-safety.js), so a denial
|
||||
// message cannot carry content a human reviewer cannot see.
|
||||
let sanitized = '';
|
||||
for (const char of String(filePath || '')) {
|
||||
const code = char.codePointAt(0);
|
||||
const isAsciiControl = code <= 0x1f || code === 0x7f;
|
||||
const isBidiOverride = (code >= 0x200e && code <= 0x200f) || (code >= 0x202a && code <= 0x202e) || (code >= 0x2066 && code <= 0x2069);
|
||||
sanitized += isAsciiControl || isBidiOverride ? ' ' : char;
|
||||
const isAsciiControl =
|
||||
code <= ASCII_CONTROL_MAX || code === ASCII_DELETE || inRange(code, C1_CONTROLS);
|
||||
const isBidiOverride =
|
||||
inRange(code, BIDI_MARKS) || inRange(code, BIDI_EMBEDDINGS) || inRange(code, BIDI_ISOLATES);
|
||||
const isUnicodeSeparator = code === LINE_SEPARATOR || code === PARAGRAPH_SEPARATOR;
|
||||
const isDangerousInvisible =
|
||||
inRange(code, ZERO_WIDTHS) ||
|
||||
code === WORD_JOINER ||
|
||||
code === BYTE_ORDER_MARK ||
|
||||
inRange(code, VARIATION_SELECTORS) ||
|
||||
inRange(code, VARIATION_SUPPLEMENTS) ||
|
||||
inRange(code, TAG_BLOCK) ||
|
||||
code === MONGOLIAN_VOWEL_SEPARATOR ||
|
||||
code === HANGUL_CHOSEONG_FILLER ||
|
||||
code === HANGUL_JUNGSEONG_FILLER ||
|
||||
code === HANGUL_FILLER ||
|
||||
inRange(code, INVISIBLE_MATH_OPERATORS);
|
||||
sanitized += isAsciiControl || isBidiOverride || isUnicodeSeparator || isDangerousInvisible ? ' ' : char;
|
||||
}
|
||||
return sanitized.trim().slice(0, 500);
|
||||
return sanitized.trim().slice(0, SANITIZED_PATH_MAX_LENGTH);
|
||||
}
|
||||
|
||||
function normalizeForMatch(value) {
|
||||
@@ -1101,6 +1402,21 @@ function isReadOnlyGitIntrospection(command) {
|
||||
|
||||
// --- Gate messages ---
|
||||
|
||||
/**
|
||||
* Batch-consistency warning (#3136). A first-touch denial marks the file
|
||||
* checked so the retry passes; a parallel batch of edits to one
|
||||
* not-yet-touched file therefore partially applies (first call denied,
|
||||
* siblings allowed). Hooks see calls one at a time and cannot lock a
|
||||
* batch, so the denial must say this out loud: name the file and tell
|
||||
* the agent that siblings may already have been applied.
|
||||
*/
|
||||
function batchSiblingWarning(safePath) {
|
||||
return (
|
||||
`If this call was sent in a parallel batch, other edits to ${safePath} from that batch ` +
|
||||
'may already have been applied. Re-read the file before building on them.'
|
||||
);
|
||||
}
|
||||
|
||||
function editGateMsg(filePath) {
|
||||
const safe = sanitizePath(filePath);
|
||||
return [
|
||||
@@ -1113,6 +1429,8 @@ function editGateMsg(filePath) {
|
||||
'3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
|
||||
"4. Quote the user's current instruction verbatim",
|
||||
'',
|
||||
batchSiblingWarning(safe),
|
||||
'',
|
||||
'Present the facts, then retry the same operation.'
|
||||
].join('\n');
|
||||
}
|
||||
@@ -1129,6 +1447,8 @@ function writeGateMsg(filePath) {
|
||||
'3. If this file reads/writes data files, show field names, structure, and date format (use redacted or synthetic values, not raw production data)',
|
||||
"4. Quote the user's current instruction verbatim",
|
||||
'',
|
||||
batchSiblingWarning(safe),
|
||||
'',
|
||||
'Present the facts, then retry the same operation.'
|
||||
].join('\n');
|
||||
}
|
||||
@@ -1143,6 +1463,7 @@ function condensedGateMsg(action, filePath, ordinal) {
|
||||
return (
|
||||
`[Fact-Forcing Gate] (denial #${ordinal} this session) First ${action} of ${safe}: ` +
|
||||
"briefly state importers/callers, affected API, data schemas if any, and the user's verbatim instruction, then retry. " +
|
||||
`${batchSiblingWarning(safe)} ` +
|
||||
'(Use GATEGUARD_EXEMPT_GLOBS for path-scoped exemptions; ECC_GATEGUARD=off disables this gate.)'
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,16 +3,23 @@
|
||||
const { extractCommandSubstitutions } = require('../lib/shell-substitution');
|
||||
|
||||
/**
|
||||
* Recognize the deliberately narrow passive sink supported by this parser.
|
||||
* Shell operators and substitutions make the payload's destination ambiguous,
|
||||
* so every other form retains the original input for fail-closed checks.
|
||||
* Recognize proven-passive sinks whose heredoc payload is data, not a command
|
||||
* stream. `cat` and `tee` (optionally path-qualified, or wrapped in
|
||||
* `command`/`builtin`/`env`) only write stdin; they do not execute the body.
|
||||
* Shell operators or substitution markers make the destination ambiguous, so
|
||||
* every other form retains the original input for fail-closed checks.
|
||||
*
|
||||
* @param {string} line
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isProvenPassiveHeredocLine(line) {
|
||||
const trimmed = line.trim();
|
||||
return /^cat(?=\s|[<>])/.test(trimmed) && !/[;&|()`]/.test(trimmed);
|
||||
// Fail closed on control operators / grouping / command substitutions.
|
||||
if (/[;&|()`]/.test(trimmed)) return false;
|
||||
// Optional wrapper + optional path prefix + cat|tee, then args or redirect.
|
||||
return /^(?:(?:command|builtin|env)\s+)?(?:(?:\.\/|\/(?:[\w.+-]+\/)*)?(?:cat|tee))(?=\s|[<>])/.test(
|
||||
trimmed
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -99,7 +99,7 @@ function findFileIssues(filePath) {
|
||||
const lineNum = index + 1;
|
||||
|
||||
// Check for console.log
|
||||
if (line.includes('console.log') && !line.trim().startsWith('//') && !line.trim().startsWith('*')) {
|
||||
if (line.includes('console.log') && !line.trim().startsWith('//') && !line.trim().startsWith('*') && !line.trim().startsWith('#')) {
|
||||
issues.push({
|
||||
type: 'console.log',
|
||||
message: `console.log found at line ${lineNum}`,
|
||||
@@ -109,7 +109,7 @@ function findFileIssues(filePath) {
|
||||
}
|
||||
|
||||
// Check for debugger statements
|
||||
if (/\bdebugger\b/.test(line) && !line.trim().startsWith('//')) {
|
||||
if (/\bdebugger\b/.test(line) && !line.trim().startsWith('//') && !line.trim().startsWith('#')) {
|
||||
issues.push({
|
||||
type: 'debugger',
|
||||
message: `debugger statement at line ${lineNum}`,
|
||||
@@ -119,7 +119,7 @@ function findFileIssues(filePath) {
|
||||
}
|
||||
|
||||
// Check for TODO/FIXME without issue reference
|
||||
const todoMatch = line.match(/\/\/\s*(TODO|FIXME):?\s*(.+)/);
|
||||
const todoMatch = line.match(/(?:\/\/|#)\s*(TODO|FIXME):?\s*(\S.*)/);
|
||||
if (todoMatch && !todoMatch[2].match(/#\d+|issue/i)) {
|
||||
issues.push({
|
||||
type: 'todo',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const { getSessionsDir, getDateString, getTimeString, getSessionIdShort, sanitizeSessionId, getProjectName, ensureDir, readFile, writeFile, runCommand, stripAnsi, log } = require('../lib/utils');
|
||||
const { getSessionsDir, getDateString, getTimeString, getSessionIdShort, sanitizeSessionId, getProjectName, getRepoIdentity, ensureDir, readFile, writeFile, runCommand, stripAnsi, log } = require('../lib/utils');
|
||||
const { generateSessionSummary, getContextRemainingPct, getContextThreshold } = require('../lib/llm-summary');
|
||||
|
||||
const SUMMARY_START_MARKER = '<!-- ECC:SUMMARY:START -->';
|
||||
@@ -130,7 +130,8 @@ function getSessionMetadata() {
|
||||
return {
|
||||
project: getProjectName() || 'unknown',
|
||||
branch: branchResult.success ? branchResult.output : 'unknown',
|
||||
worktree: process.cwd()
|
||||
worktree: process.cwd(),
|
||||
repo: getRepoIdentity()
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,16 +146,20 @@ function buildSessionHeader(today, currentTime, metadata, existingContent = '')
|
||||
const date = extractHeaderField(existingContent, 'Date') || today;
|
||||
const started = extractHeaderField(existingContent, 'Started') || currentTime;
|
||||
|
||||
return [
|
||||
const lines = [
|
||||
heading,
|
||||
`**Date:** ${date}`,
|
||||
`**Started:** ${started}`,
|
||||
`**Last Updated:** ${currentTime}`,
|
||||
`**Project:** ${metadata.project}`,
|
||||
`**Branch:** ${metadata.branch}`,
|
||||
`**Worktree:** ${metadata.worktree}`,
|
||||
''
|
||||
].join('\n');
|
||||
`**Worktree:** ${metadata.worktree}`
|
||||
];
|
||||
if (metadata.repo) {
|
||||
lines.push(`**Repo:** ${metadata.repo}`);
|
||||
}
|
||||
lines.push('');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
function mergeSessionHeader(content, today, currentTime, metadata) {
|
||||
|
||||
@@ -14,6 +14,8 @@ const {
|
||||
getSessionSearchDirs,
|
||||
getLearnedSkillsDir,
|
||||
getProjectName,
|
||||
getRepoIdentity,
|
||||
sameRepoIdentity,
|
||||
findFiles,
|
||||
ensureDir,
|
||||
readFile,
|
||||
@@ -254,6 +256,7 @@ function pruneExpiredSessions(searchDirs, retentionDays) {
|
||||
* Session files written by session-end.js contain header fields like:
|
||||
* **Project:** my-project
|
||||
* **Worktree:** /path/to/project
|
||||
* **Repo:** /path/to/main-worktree/.git
|
||||
*
|
||||
* This function reads each session file once, caching its content, and
|
||||
* returns both the selected session object and its already-read content
|
||||
@@ -261,11 +264,18 @@ function pruneExpiredSessions(searchDirs, retentionDays) {
|
||||
*
|
||||
* Priority (highest to lowest):
|
||||
* 1. Exact worktree (cwd) match — most recent
|
||||
* 2. Same project name match for legacy sessions without Worktree metadata
|
||||
* 3. No injection when sessions belong to a different worktree/project
|
||||
* 2. Repository identity match: the session was recorded in another
|
||||
* worktree or subdirectory of the same repository. Identity is the
|
||||
* main worktree's common git dir (issue #3160), taken from the
|
||||
* recorded **Repo:** field or resolved from the recorded **Worktree:**
|
||||
* path for older session files. Unrelated repositories never match.
|
||||
* 3. Same project name match for legacy sessions without Worktree/Repo
|
||||
* metadata
|
||||
* 4. No injection when sessions belong to a different repository
|
||||
*
|
||||
* Sessions are already sorted newest-first, so the first match in each
|
||||
* category wins.
|
||||
* category wins; the scan continues past repository and project matches so
|
||||
* an exact worktree match always takes precedence.
|
||||
*
|
||||
* @param {Array<Object>} sessions - Deduplicated session list, sorted newest-first.
|
||||
* @param {string} cwd - Current working directory (process.cwd()).
|
||||
@@ -279,7 +289,17 @@ function selectMatchingSession(sessions, cwd, currentProject) {
|
||||
|
||||
// Normalize cwd once outside the loop to avoid repeated syscalls
|
||||
const normalizedCwd = normalizePath(cwd);
|
||||
const currentRepoId = getRepoIdentity(cwd);
|
||||
const repoIdByWorktree = new Map();
|
||||
const repoIdOfRecordedWorktree = (recordedWorktree) => {
|
||||
if (!repoIdByWorktree.has(recordedWorktree)) {
|
||||
repoIdByWorktree.set(recordedWorktree, getRepoIdentity(recordedWorktree));
|
||||
}
|
||||
return repoIdByWorktree.get(recordedWorktree);
|
||||
};
|
||||
|
||||
let repoMatch = null;
|
||||
let repoMatchContent = null;
|
||||
let projectMatch = null;
|
||||
let projectMatchContent = null;
|
||||
let readableSessions = 0;
|
||||
@@ -289,9 +309,11 @@ function selectMatchingSession(sessions, cwd, currentProject) {
|
||||
if (!content) continue;
|
||||
readableSessions++;
|
||||
|
||||
// Extract **Worktree:** field
|
||||
// Extract **Worktree:** and **Repo:** fields
|
||||
const worktreeMatch = content.match(/\*\*Worktree:\*\*\s*(.+)$/m);
|
||||
const sessionWorktree = worktreeMatch ? worktreeMatch[1].trim() : '';
|
||||
const repoFieldMatch = content.match(/\*\*Repo:\*\*\s*(.+)$/m);
|
||||
const sessionRepo = repoFieldMatch ? repoFieldMatch[1].trim() : '';
|
||||
|
||||
// Exact worktree match — best possible, return immediately
|
||||
// Normalize both paths to handle symlinks and case-insensitive filesystems
|
||||
@@ -299,9 +321,25 @@ function selectMatchingSession(sessions, cwd, currentProject) {
|
||||
return { session, content, matchReason: 'worktree' };
|
||||
}
|
||||
|
||||
// Repository identity match (#3160): the summary lookup is scoped to the
|
||||
// repository, not the cwd path, so a session recorded in worktree A is
|
||||
// eligible in worktree B only when both resolve to the same common git
|
||||
// dir. Unrelated repositories never share.
|
||||
if (!repoMatch && currentRepoId && (sessionRepo || sessionWorktree)) {
|
||||
// The recorded Repo field may carry a different path form than the
|
||||
// live lookup (8.3 short names on Windows runners, case, separators),
|
||||
// so compare with filesystem-identity fallback rather than ===.
|
||||
const sessionRepoId = sessionRepo || repoIdOfRecordedWorktree(sessionWorktree);
|
||||
if (sessionRepoId && sameRepoIdentity(sessionRepoId, currentRepoId)) {
|
||||
repoMatch = session;
|
||||
repoMatchContent = content;
|
||||
}
|
||||
}
|
||||
|
||||
// Project name match is only safe for legacy session files written before
|
||||
// Worktree metadata existed. A different explicit Worktree is not a match.
|
||||
if (!projectMatch && currentProject && !sessionWorktree) {
|
||||
// Worktree/Repo metadata existed. A different explicit Worktree or Repo
|
||||
// is not a match.
|
||||
if (!projectMatch && currentProject && !sessionWorktree && !sessionRepo) {
|
||||
const projectFieldMatch = content.match(/\*\*Project:\*\*\s*(.+)$/m);
|
||||
const sessionProject = projectFieldMatch ? projectFieldMatch[1].trim() : '';
|
||||
if (sessionProject && sessionProject === currentProject) {
|
||||
@@ -311,6 +349,10 @@ function selectMatchingSession(sessions, cwd, currentProject) {
|
||||
}
|
||||
}
|
||||
|
||||
if (repoMatch) {
|
||||
return { session: repoMatch, content: repoMatchContent, matchReason: 'repo' };
|
||||
}
|
||||
|
||||
if (projectMatch) {
|
||||
return { session: projectMatch, content: projectMatchContent, matchReason: 'project' };
|
||||
}
|
||||
|
||||
@@ -132,6 +132,13 @@ function printHumanPlan(plan, dryRun) {
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(plan.reconciledExcludedPaths) && plan.reconciledExcludedPaths.length > 0) {
|
||||
console.log('\nReconciled excluded paths:');
|
||||
for (const removedPath of plan.reconciledExcludedPaths) {
|
||||
console.log(`- removed ${removedPath}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!dryRun) {
|
||||
console.log(`\nDone. Install-state written to ${plan.installStatePath}`);
|
||||
}
|
||||
|
||||
@@ -570,7 +570,6 @@ function verifyPluginAtScope(options) {
|
||||
|
||||
function ensurePluginAtScope(options) {
|
||||
const run = options.run || runClaude;
|
||||
const configuredHooks = options.hookConfiguration || hookOptions(options.hooks);
|
||||
if (options.installed) {
|
||||
run(
|
||||
['plugin', 'update', CURRENT_PLUGIN_ID, '--scope', options.scope],
|
||||
@@ -582,8 +581,6 @@ function ensurePluginAtScope(options) {
|
||||
[
|
||||
'plugin', 'install', CURRENT_PLUGIN_ID,
|
||||
'--scope', options.scope,
|
||||
'--config', `hooks_enabled=${configuredHooks.hooks_enabled}`,
|
||||
'--config', `hook_profile=${configuredHooks.hook_profile}`,
|
||||
],
|
||||
{ cwd: options.projectRoot, phase: 'plugin-install' }
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -26,8 +26,8 @@ function renderControlPlaneViewHtml() {
|
||||
header nav { margin-left: auto; font-size: 12px; }
|
||||
header nav a { color: #8b949e; margin-left: 12px; text-decoration: none; }
|
||||
header nav a:hover { color: #e6edf3; }
|
||||
#wrap { display: grid; grid-template-columns: 1fr 360px; height: calc(100vh - 49px); }
|
||||
#stage { position: relative; border-right: 1px solid #1f2630; }
|
||||
#wrap { display: grid; grid-template-columns: 1fr 360px; grid-template-rows: minmax(0, 1fr); height: calc(100vh - 49px); }
|
||||
#stage { position: relative; height: 100%; min-height: 0; border-right: 1px solid #1f2630; }
|
||||
canvas { width: 100%; height: 100%; display: block; }
|
||||
#side { padding: 12px 14px; overflow-y: auto; }
|
||||
#side h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #8b949e; margin: 14px 0 8px; }
|
||||
|
||||
@@ -27,11 +27,12 @@ function renderProximityVizHtml() {
|
||||
header { display: flex; align-items: baseline; gap: 12px; padding: 12px 16px; border-bottom: 1px solid #1f2630; }
|
||||
header h1 { font-size: 15px; margin: 0; }
|
||||
header .sub { color: #8b949e; font-size: 12px; }
|
||||
#wrap { display: grid; grid-template-columns: 1fr 320px; height: calc(100vh - 49px); }
|
||||
#stage { position: relative; }
|
||||
#wrap { display: grid; grid-template-columns: 1fr 320px; grid-template-rows: minmax(0, 1fr); height: calc(100vh - 49px); }
|
||||
#stage { position: relative; height: 100%; min-height: 0; }
|
||||
canvas { width: 100%; height: 100%; display: block; }
|
||||
#side { border-left: 1px solid #1f2630; padding: 12px 14px; overflow-y: auto; }
|
||||
#side h2 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: #8b949e; margin: 0 0 8px; }
|
||||
#side h2:not(:first-child) { margin-top: 16px; }
|
||||
.adv { border: 1px solid #1f2630; border-radius: 8px; padding: 8px 10px; margin-bottom: 8px; }
|
||||
.adv.resolution { border-color: #b3402f; }
|
||||
.adv.advisory { border-color: #9a6700; }
|
||||
@@ -41,8 +42,11 @@ function renderProximityVizHtml() {
|
||||
.adv .who { color: #c9d1d9; }
|
||||
.adv .act { color: #8b949e; font-size: 12px; margin-top: 3px; }
|
||||
.empty { color: #6e7681; }
|
||||
.agent-row { display: flex; gap: 8px; align-items: baseline; padding: 3px 0; font-size: 12px; }
|
||||
.agent-row .who { color: #c9d1d9; overflow-wrap: anywhere; }
|
||||
.agent-row .risk { margin-left: auto; color: #8b949e; white-space: nowrap; }
|
||||
#legend { position: absolute; left: 12px; bottom: 12px; font-size: 11px; color: #8b949e; background: rgba(11,14,20,.7); padding: 6px 8px; border-radius: 6px; }
|
||||
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
|
||||
.shape { display: inline-block; width: 12px; margin-right: 5px; text-align: center; font-weight: 700; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -53,16 +57,18 @@ function renderProximityVizHtml() {
|
||||
</header>
|
||||
<div id="wrap">
|
||||
<div id="stage">
|
||||
<canvas id="c"></canvas>
|
||||
<canvas id="c" role="img" aria-label="Agent airspace visualization. See the Agents panel for a text alternative.">Agent airspace visualization; see the Agents panel for per-agent risk.</canvas>
|
||||
<div id="legend">
|
||||
<div><span class="dot" style="background:#3fb950"></span>clear</div>
|
||||
<div><span class="dot" style="background:#e3b341"></span>traffic advisory (transmit)</div>
|
||||
<div><span class="dot" style="background:#ff7b72"></span>resolution (steer)</div>
|
||||
<div><span class="shape" style="color:#3fb950">●</span>clear</div>
|
||||
<div><span class="shape" style="color:#e3b341">■</span>traffic advisory (transmit)</div>
|
||||
<div><span class="shape" style="color:#ff7b72">▲</span>resolution (steer)</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="side">
|
||||
<h2>Advisories</h2>
|
||||
<div id="advisories"><div class="empty">No advisories - airspace clear.</div></div>
|
||||
<h2>Agents</h2>
|
||||
<div id="agents"><div class="empty">No agents.</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
@@ -81,12 +87,33 @@ function renderProximityVizHtml() {
|
||||
}
|
||||
window.addEventListener('resize', resize);
|
||||
|
||||
function riskLevel(risk) {
|
||||
if (risk >= 0.7) return 'resolution';
|
||||
if (risk >= 0.35) return 'advisory';
|
||||
return 'clear';
|
||||
}
|
||||
|
||||
function riskColor(risk) {
|
||||
if (risk >= 0.7) return '#ff7b72';
|
||||
if (risk >= 0.35) return '#e3b341';
|
||||
return '#3fb950';
|
||||
}
|
||||
|
||||
function drawRiskMarker(x, y, radius, level) {
|
||||
ctx.beginPath();
|
||||
if (level === 'resolution') {
|
||||
ctx.moveTo(x, y - radius);
|
||||
ctx.lineTo(x + radius, y + radius);
|
||||
ctx.lineTo(x - radius, y + radius);
|
||||
ctx.closePath();
|
||||
} else if (level === 'advisory') {
|
||||
ctx.rect(x - radius, y - radius, radius * 2, radius * 2);
|
||||
} else {
|
||||
ctx.arc(x, y, radius, 0, Math.PI * 2);
|
||||
}
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// 3D to 2D: rotate around Y, simple perspective.
|
||||
function project(p, w, h) {
|
||||
var x = p[0], y = p[1] || 0, z = p[2] || 0;
|
||||
@@ -122,8 +149,9 @@ function renderProximityVizHtml() {
|
||||
var ag = state.positions[k];
|
||||
var p = pos[ag.agentId];
|
||||
var radius = (6 + Math.sqrt(ag.fileCount || 1) * 3) * p[2];
|
||||
ctx.fillStyle = riskColor(state.riskByAgent[ag.agentId] || 0);
|
||||
ctx.beginPath(); ctx.arc(p[0], p[1], radius, 0, Math.PI * 2); ctx.fill();
|
||||
var agentRisk = state.riskByAgent[ag.agentId] || 0;
|
||||
ctx.fillStyle = riskColor(agentRisk);
|
||||
drawRiskMarker(p[0], p[1], radius, riskLevel(agentRisk));
|
||||
ctx.fillStyle = '#c9d1d9';
|
||||
ctx.font = '11px -apple-system, system-ui, sans-serif';
|
||||
ctx.fillText(String(ag.agentId).slice(0, 18), p[0] + radius + 4, p[1] + 3);
|
||||
@@ -155,6 +183,25 @@ function renderProximityVizHtml() {
|
||||
});
|
||||
}
|
||||
|
||||
function renderAgents() {
|
||||
var box = document.getElementById('agents');
|
||||
box.textContent = '';
|
||||
if (!state.positions.length) {
|
||||
var empty = document.createElement('div'); empty.className = 'empty';
|
||||
empty.textContent = 'No agents.'; box.appendChild(empty); return;
|
||||
}
|
||||
state.positions.forEach(function (agent) {
|
||||
var risk = state.riskByAgent[agent.agentId] || 0;
|
||||
var row = document.createElement('div'); row.className = 'agent-row';
|
||||
var who = document.createElement('span'); who.className = 'who';
|
||||
who.textContent = String(agent.agentId); row.appendChild(who);
|
||||
var riskText = document.createElement('span'); riskText.className = 'risk';
|
||||
riskText.textContent = Math.round(risk * 100) + '% - ' + riskLevel(risk);
|
||||
row.appendChild(riskText);
|
||||
box.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function applySnapshot(prox) {
|
||||
state.positions = prox.positions || [];
|
||||
state.links = prox.links || [];
|
||||
@@ -166,13 +213,20 @@ function renderProximityVizHtml() {
|
||||
});
|
||||
state.riskByAgent = risk;
|
||||
renderAdvisories();
|
||||
renderAgents();
|
||||
var c = prox.counts || {};
|
||||
canvas.setAttribute('aria-label',
|
||||
(c.agents || state.positions.length) + ' agents in airspace, ' +
|
||||
(c.advisories || state.advisories.length) + ' advisories. See the Agents panel for per-agent risk.');
|
||||
document.getElementById('status').textContent =
|
||||
(c.agents || 0) + ' agents - ' + (c.advisories || 0) + ' advisories - ' + (c.resolutions || 0) + ' steering';
|
||||
}
|
||||
|
||||
function poll() {
|
||||
fetch('/api/proximity').then(function (r) { return r.json(); }).then(function (data) {
|
||||
fetch('/api/proximity').then(function (r) {
|
||||
if (!r.ok) throw new Error('http ' + r.status);
|
||||
return r.json();
|
||||
}).then(function (data) {
|
||||
applySnapshot(data && data.enabled ? data : (data || {}));
|
||||
}).catch(function () {
|
||||
document.getElementById('status').textContent = 'offline';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
const path = require('path');
|
||||
|
||||
const {
|
||||
HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
|
||||
createInstallTargetAdapter,
|
||||
createRemappedOperation,
|
||||
isForeignPlatformPath,
|
||||
@@ -52,6 +53,7 @@ module.exports = createInstallTargetAdapter({
|
||||
kind: 'home',
|
||||
rootSegments: ['.claude'],
|
||||
installStatePathSegments: ['ecc', 'install-state.json'],
|
||||
excludedSourcePaths: HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
|
||||
nativeRootRelativePath: '.claude-plugin',
|
||||
planOperations(input, adapter) {
|
||||
const modules = Array.isArray(input.modules)
|
||||
@@ -66,7 +68,7 @@ module.exports = createInstallTargetAdapter({
|
||||
return modules.flatMap(module => {
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
.filter(p => !isForeignPlatformPath(p, adapter.target))
|
||||
.filter(p => !isForeignPlatformPath(p, adapter.target) && !adapter.excludesSourcePath(p))
|
||||
.flatMap(sourceRelativePath => {
|
||||
if (
|
||||
module.id === 'hooks-runtime'
|
||||
|
||||
@@ -9,6 +9,7 @@ const {
|
||||
} = require('./helpers');
|
||||
|
||||
const CLAUDE_ECC_NAMESPACE = 'ecc';
|
||||
const CLAUDE_PROJECT_COMMONJS_PACKAGE = 'manifests/install-assets/claude-project-scripts-package.json';
|
||||
|
||||
function getClaudeManagedDestinationPath(adapter, sourceRelativePath, input) {
|
||||
const normalizedSourcePath = normalizeRelativePath(sourceRelativePath);
|
||||
@@ -65,7 +66,7 @@ module.exports = createInstallTargetAdapter({
|
||||
|
||||
return modules.flatMap(module => {
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
const operations = paths
|
||||
.filter(p => !isForeignPlatformPath(p, 'claude'))
|
||||
.flatMap(sourceRelativePath => {
|
||||
if (
|
||||
@@ -93,6 +94,21 @@ module.exports = createInstallTargetAdapter({
|
||||
|
||||
return [adapter.createScaffoldOperation(module.id, sourceRelativePath, planningInput)];
|
||||
});
|
||||
|
||||
if (module.id !== 'hooks-runtime') {
|
||||
return operations;
|
||||
}
|
||||
|
||||
return [
|
||||
...['hooks', 'lib'].map(directory => createRemappedOperation(
|
||||
adapter,
|
||||
module.id,
|
||||
CLAUDE_PROJECT_COMMONJS_PACKAGE,
|
||||
path.join(adapter.resolveRoot(planningInput), 'scripts', directory, 'package.json'),
|
||||
{ strategy: 'preserve-relative-path' }
|
||||
)),
|
||||
...operations,
|
||||
];
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const { createInstallTargetAdapter } = require('./helpers');
|
||||
const { HOME_INSTALL_EXCLUDED_SOURCE_PATHS, createInstallTargetAdapter } = require('./helpers');
|
||||
|
||||
module.exports = createInstallTargetAdapter({
|
||||
id: 'codex-home',
|
||||
@@ -7,4 +7,5 @@ module.exports = createInstallTargetAdapter({
|
||||
rootSegments: ['.codex'],
|
||||
installStatePathSegments: ['ecc-install-state.json'],
|
||||
nativeRootRelativePath: '.codex',
|
||||
excludedSourcePaths: HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
|
||||
});
|
||||
|
||||
@@ -24,6 +24,14 @@ const PLATFORM_SOURCE_PATH_OWNERS = Object.freeze({
|
||||
'.adal': 'adal',
|
||||
});
|
||||
|
||||
// Source paths that home installs must never copy into a harness home
|
||||
// directory. `.agents` is ECC's repo-local skills/plugins staging area:
|
||||
// project targets such as kimi and antigravity consume it, but neither
|
||||
// Claude Code nor Codex reads a `.agents` directory under ~/.claude or
|
||||
// ~/.codex, so copying it there produces unread files that doctor flags as
|
||||
// drift and repair keeps restoring.
|
||||
const HOME_INSTALL_EXCLUDED_SOURCE_PATHS = Object.freeze(['.agents']);
|
||||
|
||||
function normalizeRelativePath(relativePath) {
|
||||
return String(relativePath || '')
|
||||
.replace(/\\/g, '/')
|
||||
@@ -43,6 +51,14 @@ function isForeignPlatformPath(sourceRelativePath, adapterTarget) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isExcludedSourcePath(sourceRelativePath, excludedSourcePaths = []) {
|
||||
const normalizedPath = normalizeRelativePath(sourceRelativePath);
|
||||
return excludedSourcePaths.some(excluded => {
|
||||
const prefix = normalizeRelativePath(excluded);
|
||||
return prefix !== '' && (normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`));
|
||||
});
|
||||
}
|
||||
|
||||
function resolveBaseRoot(scope, input = {}) {
|
||||
if (scope === 'home') {
|
||||
return input.homeDir || os.homedir();
|
||||
@@ -351,6 +367,9 @@ function createInstallTargetAdapter(config) {
|
||||
strategy: adapter.determineStrategy(normalizedSourcePath),
|
||||
});
|
||||
},
|
||||
excludesSourcePath(sourceRelativePath) {
|
||||
return isExcludedSourcePath(sourceRelativePath, config.excludedSourcePaths);
|
||||
},
|
||||
planOperations(input = {}) {
|
||||
if (typeof config.planOperations === 'function') {
|
||||
return config.planOperations(input, adapter);
|
||||
@@ -360,7 +379,7 @@ function createInstallTargetAdapter(config) {
|
||||
return input.modules.flatMap(module => {
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
.filter(p => !isForeignPlatformPath(p, config.target))
|
||||
.filter(p => !isForeignPlatformPath(p, config.target) && !adapter.excludesSourcePath(p))
|
||||
.map(sourceRelativePath => adapter.createScaffoldOperation(
|
||||
module.id,
|
||||
sourceRelativePath,
|
||||
@@ -372,7 +391,7 @@ function createInstallTargetAdapter(config) {
|
||||
const module = input.module || {};
|
||||
const paths = Array.isArray(module.paths) ? module.paths : [];
|
||||
return paths
|
||||
.filter(p => !isForeignPlatformPath(p, config.target))
|
||||
.filter(p => !isForeignPlatformPath(p, config.target) && !adapter.excludesSourcePath(p))
|
||||
.map(sourceRelativePath => adapter.createScaffoldOperation(
|
||||
module.id,
|
||||
sourceRelativePath,
|
||||
@@ -399,6 +418,8 @@ function createInstallTargetAdapter(config) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
HOME_INSTALL_EXCLUDED_SOURCE_PATHS,
|
||||
isExcludedSourcePath,
|
||||
buildValidationIssue,
|
||||
createFlatFileOperations,
|
||||
createFlatRuleOperations,
|
||||
|
||||
@@ -34,6 +34,10 @@ const {
|
||||
preserveUnwrittenFiles,
|
||||
} = require('./ownership-guard');
|
||||
const { cleanupLegacyOpencodeInstall } = require('./opencode-legacy-migration');
|
||||
const {
|
||||
completeExcludedPathsReconciliation,
|
||||
prepareExcludedPathsReconciliation,
|
||||
} = require('./excluded-paths-reconciliation');
|
||||
const { buildInstallIndex, rewriteRelativeLinks } = require('./link-rewrite');
|
||||
const { adaptAntigravityAgent } = require('./antigravity-agent');
|
||||
|
||||
@@ -449,9 +453,12 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
if (typeof beforeInstallStateRead === 'function') {
|
||||
beforeInstallStateRead({ plan });
|
||||
}
|
||||
const migration = prepareHookConsentMigration(
|
||||
const migration = prepareExcludedPathsReconciliation(
|
||||
plan,
|
||||
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
|
||||
prepareHookConsentMigration(
|
||||
plan,
|
||||
prepareUserOwnedFileGuard(plan, prepareClaudeSkillMigration(plan))
|
||||
)
|
||||
);
|
||||
const appliedPlan = {
|
||||
...plan,
|
||||
@@ -491,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'
|
||||
@@ -666,17 +673,31 @@ function applyInstallPlanLocked(plan, dependencies = {}, settingsLockHeld = fals
|
||||
];
|
||||
}
|
||||
|
||||
let excludedPathsRemoved = [];
|
||||
let excludedPathsWarnings = [];
|
||||
try {
|
||||
const excludedReconciliation = completeExcludedPathsReconciliation(migration, appliedPlan);
|
||||
excludedPathsRemoved = excludedReconciliation.removedPaths;
|
||||
excludedPathsWarnings = excludedReconciliation.warnings;
|
||||
} catch (error) {
|
||||
excludedPathsWarnings = [
|
||||
`Excluded-paths reconciliation did not finish: ${error.message}. Previously managed files under excluded source paths were preserved; remove them manually or rerun the install.`,
|
||||
];
|
||||
}
|
||||
|
||||
return {
|
||||
...plan,
|
||||
statePreview: finalState,
|
||||
plannedOperations: [...plan.operations],
|
||||
operations: migration.appliedOperations,
|
||||
skippedOperations: migration.skippedOperations,
|
||||
reconciledExcludedPaths: excludedPathsRemoved,
|
||||
warnings: [
|
||||
...(Array.isArray(plan.warnings) ? plan.warnings : []),
|
||||
...migration.warnings,
|
||||
...antigravityMigrationWarnings,
|
||||
...opencodeMigrationWarnings,
|
||||
...excludedPathsWarnings,
|
||||
],
|
||||
applied: true,
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,230 @@
|
||||
'use strict';
|
||||
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const { readInstallState } = require('../install-state');
|
||||
const { assertWithinTrustedRoot } = require('../path-safety');
|
||||
const { getInstallTargetAdapter } = require('../install-targets/registry');
|
||||
|
||||
/**
|
||||
* Upgrade reconciliation for excluded source paths (issue #3116).
|
||||
*
|
||||
* Adapters can declare `excludedSourcePaths` (today: `.agents` for the Claude
|
||||
* and Codex home targets). The exclusion stops new copy operations from being
|
||||
* planned, but a home install created before the exclusion still has the
|
||||
* copied files on disk and the copy operations recorded in install-state, so
|
||||
* doctor keeps reporting drift and repair keeps restoring files the target
|
||||
* never reads.
|
||||
*
|
||||
* prepareExcludedPathsReconciliation runs before the new state is written: it
|
||||
* reads the previous install-state and drops the recorded managed operations
|
||||
* whose source path is now excluded. completeExcludedPathsReconciliation runs
|
||||
* after a successful apply: it removes the files those operations recorded,
|
||||
* but only when the recorded content digest still matches, and prunes the
|
||||
* emptied directories. Files the state does not own, modified files,
|
||||
* symlinks, and anything outside the target root are preserved with a
|
||||
* warning.
|
||||
*/
|
||||
|
||||
function comparablePath(filePath) {
|
||||
const resolvedPath = path.resolve(filePath);
|
||||
return process.platform === 'win32' ? resolvedPath.toLowerCase() : resolvedPath;
|
||||
}
|
||||
|
||||
function getReconcilingAdapter(plan) {
|
||||
if (!plan || typeof plan.target !== 'string') {
|
||||
return null;
|
||||
}
|
||||
let adapter;
|
||||
try {
|
||||
adapter = getInstallTargetAdapter(plan.target);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return adapter && typeof adapter.excludesSourcePath === 'function' ? adapter : null;
|
||||
}
|
||||
|
||||
function isRecordedExcludedManagedOperation(adapter, operation) {
|
||||
return Boolean(
|
||||
operation
|
||||
&& operation.ownership === 'managed'
|
||||
&& typeof operation.destinationPath === 'string'
|
||||
&& typeof operation.sourceRelativePath === 'string'
|
||||
&& adapter.excludesSourcePath(operation.sourceRelativePath)
|
||||
);
|
||||
}
|
||||
|
||||
function filterStateOperations(state, shouldDrop) {
|
||||
if (!state || !Array.isArray(state.operations)) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
...state,
|
||||
operations: state.operations.filter(operation => !shouldDrop(operation)),
|
||||
};
|
||||
}
|
||||
|
||||
function prepareExcludedPathsReconciliation(plan, migration) {
|
||||
const adapter = getReconcilingAdapter(plan);
|
||||
if (!adapter || !fs.existsSync(plan.installStatePath)) {
|
||||
return { ...migration, excludedPathCandidates: [] };
|
||||
}
|
||||
|
||||
const previousState = readInstallState(plan.installStatePath);
|
||||
const candidates = ((previousState && previousState.operations) || [])
|
||||
.filter(operation => isRecordedExcludedManagedOperation(adapter, operation));
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { ...migration, excludedPathCandidates: [] };
|
||||
}
|
||||
|
||||
const droppedDestinations = new Set(
|
||||
candidates.map(operation => comparablePath(operation.destinationPath))
|
||||
);
|
||||
const shouldDrop = operation => Boolean(
|
||||
operation
|
||||
&& typeof operation.destinationPath === 'string'
|
||||
&& droppedDestinations.has(comparablePath(operation.destinationPath))
|
||||
&& typeof operation.sourceRelativePath === 'string'
|
||||
&& adapter.excludesSourcePath(operation.sourceRelativePath)
|
||||
);
|
||||
|
||||
return {
|
||||
...migration,
|
||||
bridgeState: filterStateOperations(migration.bridgeState, shouldDrop),
|
||||
finalState: filterStateOperations(migration.finalState, shouldDrop),
|
||||
excludedPathCandidates: candidates,
|
||||
};
|
||||
}
|
||||
|
||||
function pathExists(filePath) {
|
||||
try {
|
||||
fs.lstatSync(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') {
|
||||
return false;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function hashFileNoFollow(filePath) {
|
||||
const flags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW || 0);
|
||||
const descriptor = fs.openSync(filePath, flags);
|
||||
try {
|
||||
const before = fs.fstatSync(descriptor, { bigint: true });
|
||||
if (!before.isFile()) {
|
||||
throw new Error(`Refusing to read a non-file at ${filePath}`);
|
||||
}
|
||||
const content = fs.readFileSync(descriptor);
|
||||
const after = fs.fstatSync(descriptor, { bigint: true });
|
||||
const finalPathStat = fs.lstatSync(filePath, { bigint: true });
|
||||
const unchanged = before.dev === after.dev
|
||||
&& before.ino === after.ino
|
||||
&& before.size === after.size
|
||||
&& after.dev === finalPathStat.dev
|
||||
&& after.ino === finalPathStat.ino
|
||||
&& after.size === finalPathStat.size;
|
||||
if (finalPathStat.isSymbolicLink() || !finalPathStat.isFile() || !unchanged) {
|
||||
throw new Error(`Refusing to read a file that changed during validation: ${filePath}`);
|
||||
}
|
||||
return crypto.createHash('sha256').update(content).digest('hex');
|
||||
} finally {
|
||||
fs.closeSync(descriptor);
|
||||
}
|
||||
}
|
||||
|
||||
function removeEmptyParents(startPath, targetRoot) {
|
||||
let currentPath = path.dirname(startPath);
|
||||
while (comparablePath(currentPath) !== comparablePath(targetRoot)) {
|
||||
const safePath = assertWithinTrustedRoot(
|
||||
currentPath,
|
||||
targetRoot,
|
||||
'reconcile excluded install paths'
|
||||
);
|
||||
if (!pathExists(safePath)) {
|
||||
currentPath = path.dirname(safePath);
|
||||
continue;
|
||||
}
|
||||
const stat = fs.lstatSync(safePath);
|
||||
if (!stat.isDirectory() || stat.isSymbolicLink() || fs.readdirSync(safePath).length > 0) {
|
||||
return;
|
||||
}
|
||||
fs.rmdirSync(safePath);
|
||||
currentPath = path.dirname(safePath);
|
||||
}
|
||||
}
|
||||
|
||||
function completeExcludedPathsReconciliation(migration, plan) {
|
||||
const candidates = (migration && migration.excludedPathCandidates) || [];
|
||||
const removedPaths = [];
|
||||
const warnings = [];
|
||||
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.kind !== 'copy-file') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let safePath;
|
||||
try {
|
||||
safePath = assertWithinTrustedRoot(
|
||||
candidate.destinationPath,
|
||||
plan.targetRoot,
|
||||
'reconcile excluded install paths'
|
||||
);
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
`Preserved previously managed file ${candidate.destinationPath}: ${error.message}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!pathExists(safePath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const stat = fs.lstatSync(safePath);
|
||||
if (stat.isSymbolicLink() || !stat.isFile()) {
|
||||
warnings.push(
|
||||
`Preserved previously managed file ${safePath}: it is not a regular file; remove it manually if unwanted.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof candidate.contentSha256 !== 'string') {
|
||||
warnings.push(
|
||||
`Preserved previously managed file ${safePath}: the recorded operation has no content digest, so the file cannot be verified unchanged; remove it manually if unwanted.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
let currentDigest;
|
||||
try {
|
||||
currentDigest = hashFileNoFollow(safePath);
|
||||
} catch (error) {
|
||||
warnings.push(`Preserved previously managed file ${safePath}: ${error.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (currentDigest !== candidate.contentSha256.toLowerCase()) {
|
||||
warnings.push(
|
||||
`Preserved previously managed file ${safePath}: content changed after install; remove it manually if unwanted.`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
fs.unlinkSync(safePath);
|
||||
removedPaths.push(safePath);
|
||||
removeEmptyParents(safePath, plan.targetRoot);
|
||||
}
|
||||
|
||||
return { removedPaths, warnings };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
completeExcludedPathsReconciliation,
|
||||
prepareExcludedPathsReconciliation,
|
||||
};
|
||||
@@ -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,92 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Shared cross-platform browser launcher.
|
||||
*
|
||||
* Extracted from scripts/plan-canvas.js (which had a working but error-silent
|
||||
* tri-platform branch) and scripts/control-pane.js (which had a darwin-only
|
||||
* branch that silently no-op'd on Windows/Linux). This helper:
|
||||
*
|
||||
* 1. Dispatches `open` / `cmd /c start` / `xdg-open` based on process.platform
|
||||
* 2. Wires the child's 'error' event so ENOENT / EACCES propagate to the caller
|
||||
* instead of being swallowed by detached spawns
|
||||
* 3. Returns a structured { opened, reason } result so CLI consumers can
|
||||
* surface the truth (browser did/did not open) instead of a lying true/false
|
||||
*
|
||||
* The signature is intentionally small (single function, no class) so callers
|
||||
* can import without picking up the rest of scripts/lib.
|
||||
*
|
||||
* Tests live at tests/lib/platform-launch.test.js.
|
||||
*/
|
||||
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
/**
|
||||
* Pick the platform-appropriate opener command + args.
|
||||
* Returns [cmd, args] suitable for child_process.spawn.
|
||||
*
|
||||
* @param {NodeJS.Platform} platform
|
||||
* @param {string} url
|
||||
* @returns {[string, string[]]}
|
||||
*/
|
||||
function openerCommandFor(platform, url) {
|
||||
if (platform === 'darwin') return ['open', [url]];
|
||||
if (platform === 'win32') return ['cmd', ['/c', 'start', '', url]];
|
||||
return ['xdg-open', [url]];
|
||||
}
|
||||
|
||||
/**
|
||||
* Open a URL in the user's default browser, dispatching per-platform.
|
||||
*
|
||||
* Always returns a structured result so callers can:
|
||||
* - show a clear error to the agent (no silent failures)
|
||||
* - keep JSON CLI output truthful when browsers cannot launch
|
||||
*
|
||||
* @param {string} url
|
||||
* @param {NodeJS.Platform} [platform] - injectable for tests; defaults to process.platform
|
||||
* @returns {{ opened: boolean, reason: string }}
|
||||
*/
|
||||
function openBrowser(url, platform = process.platform) {
|
||||
if (typeof url !== 'string' || url.length === 0) {
|
||||
return { opened: false, reason: 'invalid-url' };
|
||||
}
|
||||
|
||||
const [cmd, args] = openerCommandFor(platform, url);
|
||||
let child;
|
||||
try {
|
||||
child = spawn(cmd, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
opened: false,
|
||||
reason: `spawn-threw:${err && err.code ? err.code : 'unknown'}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Listen for ENOENT/EACCES/etc that would otherwise be silently swallowed
|
||||
// when the user has no `open` / `xdg-open` / `start` available.
|
||||
let capturedError = null;
|
||||
child.on('error', (err) => {
|
||||
capturedError = err && err.code ? err.code : 'spawn-error';
|
||||
});
|
||||
|
||||
// Best-effort: detach so we don't keep the parent alive on the launcher.
|
||||
try {
|
||||
child.unref();
|
||||
} catch {
|
||||
/* unref may throw on some platforms; safe to ignore */
|
||||
}
|
||||
|
||||
if (capturedError) {
|
||||
return { opened: false, reason: `child-error:${capturedError}` };
|
||||
}
|
||||
return { opened: true, reason: 'spawned' };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
openBrowser,
|
||||
openerCommandFor,
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -135,6 +135,74 @@ function getGitRepoName() {
|
||||
return path.basename(result.output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the repository identity for a directory: the canonical (real) path of
|
||||
* the repository's common git dir, which is the main worktree's .git
|
||||
* directory. Every linked worktree of one repository resolves to the same
|
||||
* identity, while unrelated repositories never share one.
|
||||
*
|
||||
* @param {string} [dir] - Directory to resolve from (defaults to process.cwd()).
|
||||
* @returns {string|null} The canonical common git dir, or null when dir is
|
||||
* not inside a git repository or does not exist.
|
||||
*/
|
||||
function getRepoIdentity(dir, runCmd = runCommand) {
|
||||
const target = dir || process.cwd();
|
||||
const result = runCmd('git rev-parse --git-common-dir', { cwd: target });
|
||||
if (!result.success || !result.output) return null;
|
||||
const commonDir = path.resolve(target, result.output);
|
||||
try {
|
||||
return fs.realpathSync(commonDir);
|
||||
} catch {
|
||||
return commonDir;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a repository identity path for comparison: canonical (real) form
|
||||
* when it exists, forward slashes, no trailing slash, and lowercase on
|
||||
* Windows where the filesystem is case-insensitive. The platform argument
|
||||
* exists so Windows-shaped git output can be tested on any OS.
|
||||
*
|
||||
* @param {string} p - Path to normalize.
|
||||
* @param {string} [platform] - Platform override (defaults to process.platform).
|
||||
* @returns {string} The normalized path, or '' for empty input.
|
||||
*/
|
||||
function normalizeRepoPath(p, platform = process.platform) {
|
||||
if (!p) return '';
|
||||
let resolved;
|
||||
try {
|
||||
resolved = fs.realpathSync(p);
|
||||
} catch {
|
||||
resolved = path.resolve(p);
|
||||
}
|
||||
const slashed = resolved.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
return platform === 'win32' ? slashed.toLowerCase() : slashed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two repository identity paths. String normalization alone is not
|
||||
* enough on Windows CI runners, where TEMP commonly uses an 8.3 short name
|
||||
* (RUNNER~1): Node's realpath keeps the short form while git reports the
|
||||
* long form for the same directory. When the strings differ, fall back to
|
||||
* filesystem identity (device + inode), which is immune to 8.3 names, case
|
||||
* and separators. Fails closed when either path cannot be statted.
|
||||
*
|
||||
* @param {string} a - First identity path.
|
||||
* @param {string} b - Second identity path.
|
||||
* @returns {boolean} True when both paths name the same directory.
|
||||
*/
|
||||
function sameRepoIdentity(a, b) {
|
||||
if (!a || !b) return false;
|
||||
if (normalizeRepoPath(a) === normalizeRepoPath(b)) return true;
|
||||
try {
|
||||
const sa = fs.statSync(a);
|
||||
const sb = fs.statSync(b);
|
||||
return sa.ino !== 0 && sa.dev === sb.dev && sa.ino === sb.ino;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get project name from git repo or current directory
|
||||
*/
|
||||
@@ -642,6 +710,9 @@ module.exports = {
|
||||
sanitizeSessionId,
|
||||
getSessionIdShort,
|
||||
getGitRepoName,
|
||||
getRepoIdentity,
|
||||
normalizeRepoPath,
|
||||
sameRepoIdentity,
|
||||
getProjectName,
|
||||
|
||||
// File operations
|
||||
|
||||
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
|
||||
|
||||
+5
-16
@@ -20,14 +20,13 @@
|
||||
const fs = require('fs');
|
||||
const http = require('http');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
const {
|
||||
canonicalizeArtifactPath,
|
||||
createSessionStore,
|
||||
resolveStateDir,
|
||||
sessionKeyFor
|
||||
} = require('./lib/plan-canvas/sessions');
|
||||
const { openBrowser } = require('./lib/platform-launch');
|
||||
const {
|
||||
DEFAULT_HOST,
|
||||
createPlanCanvasServer,
|
||||
@@ -194,19 +193,7 @@ async function ensureServer({ stateDir, port }) {
|
||||
throw new Error(`plan-canvas server did not become healthy on port ${port}; check ${path.join(stateDir, 'server.log')}`);
|
||||
}
|
||||
|
||||
function openBrowser(url) {
|
||||
const platform = process.platform;
|
||||
const [cmd, args] =
|
||||
platform === 'darwin' ? ['open', [url]]
|
||||
: platform === 'win32' ? ['cmd', ['/c', 'start', '', url]]
|
||||
: ['xdg-open', [url]];
|
||||
try {
|
||||
spawn(cmd, args, { detached: true, stdio: 'ignore' }).unref();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function output(payload) {
|
||||
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
|
||||
@@ -232,11 +219,13 @@ async function cmdOpen(file, args, { stateDir, port }) {
|
||||
if (res.statusCode === 409) return res.body;
|
||||
if (res.statusCode !== 200) throw new Error(res.body.error || `open failed (HTTP ${res.statusCode})`);
|
||||
const url = `http://${DEFAULT_HOST}:${port}${res.body.url}`;
|
||||
const launched = args.includes('--no-open') ? false : openBrowser(url);
|
||||
const launchResult = args.includes('--no-open') ? { opened: false, reason: 'no-open-flag' } : openBrowser(url);
|
||||
const launched = launchResult.opened;
|
||||
return {
|
||||
status: 'open',
|
||||
url,
|
||||
browser: launched ? 'opened' : 'not opened',
|
||||
browserReason: launchResult.reason,
|
||||
next_step:
|
||||
'Run `ecc-plan-canvas await <file>` and leave it running; it returns when the human sends feedback, a verdict, or ends the session.'
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user