mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-20 16:47:59 +02:00
Merge remote-tracking branch 'origin/main' into pr-3146
# Conflicts: # docs/es/rules/common/agents.md # docs/ja-JP/rules/common/agents.md # docs/tr/rules/common/agents.md # docs/zh-CN/rules/common/agents.md # rules/common/agents.md
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
## Agentes Disponibles
|
||||
|
||||
Los agentes de ECC se distribuyen con el plugin `ecc@ecc`, no en `~/.claude/agents/`.
|
||||
Se invocan a través de la herramienta Agent con un `subagent_type` con scope del plugin:
|
||||
Se invocan a través de la herramienta Agent con un `subagent_type` con ámbito de plugin:
|
||||
|
||||
Agent(subagent_type: "ecc:planner", prompt: "...")
|
||||
|
||||
@@ -21,6 +21,8 @@ Se invocan a través de la herramienta Agent con un `subagent_type` con scope de
|
||||
| ecc:rust-reviewer | Revisión de código Rust | Proyectos Rust |
|
||||
| ecc:harmonyos-app-resolver | Desarrollo de apps HarmonyOS | Proyectos HarmonyOS/ArkTS |
|
||||
|
||||
Para el roster completo de 68 agentes, ver `/ecc:ecc-guide`.
|
||||
|
||||
## Uso Inmediato de Agentes
|
||||
|
||||
Sin necesidad de prompt del usuario:
|
||||
|
||||
+1867
-653
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,8 @@
|
||||
|
||||
## 利用可能な Agent
|
||||
|
||||
ECC エージェントは `ecc@ecc` プラグインに同梱されており、`~/.claude/agents/` にはありません。
|
||||
プラグインスコープの `subagent_type` を使用して Agent ツールから呼び出します:
|
||||
ECC の Agent は `ecc@ecc` プラグインに同梱されており、`~/.claude/agents/` には配置されません。
|
||||
Agent ツールではプラグインスコープの `subagent_type` で呼び出します:
|
||||
|
||||
Agent(subagent_type: "ecc:planner", prompt: "...")
|
||||
|
||||
@@ -19,6 +19,8 @@ ECC エージェントは `ecc@ecc` プラグインに同梱されており、`~
|
||||
| ecc:refactor-cleaner | デッドコードクリーンアップ | コードメンテナンス |
|
||||
| ecc:doc-updater | ドキュメント | ドキュメント更新 |
|
||||
|
||||
全 68 Agent の一覧は `/ecc:ecc-guide` を参照。
|
||||
|
||||
## Agent の即座の使用
|
||||
|
||||
ユーザープロンプト不要:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
## Mevcut Agent'lar
|
||||
|
||||
ECC agent'ları `ecc@ecc` eklentisi ile birlikte gelir, `~/.claude/agents/` içinde değildirler.
|
||||
Plugin kapsamı `subagent_type` ile Agent aracılığıyla çağrılır:
|
||||
ECC agent'ları `ecc@ecc` eklentisiyle birlikte gelir, `~/.claude/agents/` dizininde bulunmaz.
|
||||
Agent aracıyla eklenti kapsamlı bir `subagent_type` ile çağrılır:
|
||||
|
||||
Agent(subagent_type: "ecc:planner", prompt: "...")
|
||||
|
||||
@@ -20,6 +20,8 @@ Plugin kapsamı `subagent_type` ile Agent aracılığıyla çağrılır:
|
||||
| ecc:doc-updater | Dokümantasyon | Dokümanları güncelleme |
|
||||
| ecc:rust-reviewer | Rust kod incelemesi | Rust projeleri |
|
||||
|
||||
68 agent'ın tam listesi için `/ecc:ecc-guide` bölümüne bakın.
|
||||
|
||||
## Anlık Agent Kullanımı
|
||||
|
||||
Kullanıcı istemi gerekmez:
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
## 可用智能体
|
||||
|
||||
ECC 智能体随 `ecc@ecc` 插件一起分发,而非位于 `~/.claude/agents/` 中。
|
||||
它们通过具有插件作用域 `subagent_type` 的 Agent 工具调用:
|
||||
ECC 智能体随 `ecc@ecc` 插件一起分发,不在 `~/.claude/agents/` 目录中。
|
||||
它们通过 Agent 工具以插件作用域的 `subagent_type` 调用:
|
||||
|
||||
Agent(subagent_type: "ecc:planner", prompt: "...")
|
||||
|
||||
@@ -20,6 +20,8 @@ ECC 智能体随 `ecc@ecc` 插件一起分发,而非位于 `~/.claude/agents/`
|
||||
| ecc:doc-updater | 文档 | 更新文档 |
|
||||
| ecc:rust-reviewer | Rust 代码审查 | Rust 项目 |
|
||||
|
||||
完整 68 个智能体的清单参见 `/ecc:ecc-guide`。
|
||||
|
||||
## 即时智能体使用
|
||||
|
||||
无需用户提示:
|
||||
|
||||
@@ -21,6 +21,8 @@ They are invoked through the Agent tool with a plugin-scoped `subagent_type`:
|
||||
| ecc:rust-reviewer | Rust code review | Rust projects |
|
||||
| ecc:harmonyos-app-resolver | HarmonyOS app development | HarmonyOS/ArkTS projects |
|
||||
|
||||
For the full roster of 68 agents, see `/ecc:ecc-guide`.
|
||||
|
||||
## Immediate Agent Usage
|
||||
|
||||
No user prompt needed:
|
||||
|
||||
@@ -117,16 +117,172 @@ if [[ -f "go.mod" ]] && command -v go >/dev/null 2>&1; then
|
||||
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
|
||||
|
||||
|
||||
if [[ "$ran_any_check" -eq 0 ]]; then
|
||||
log "No supported checks found in this repository. Skipping."
|
||||
else
|
||||
|
||||
@@ -1101,6 +1101,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 +1128,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 +1146,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 +1162,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
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
@@ -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,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,
|
||||
};
|
||||
@@ -89,6 +89,26 @@ Triggers on: `rm -rf`, `git reset --hard`, `git push --force`, `drop table`, etc
|
||||
2. What this specific command verifies or produces
|
||||
```
|
||||
|
||||
## Parallel Batches and Partial Application
|
||||
|
||||
The first-touch gate evaluates each tool call independently. When several
|
||||
edits to a file that has not been touched yet are sent in one parallel
|
||||
batch, the first call is denied and the denial marks the file as checked,
|
||||
so the sibling edits in that batch are applied. Nothing is rolled back:
|
||||
the file can end up holding the sibling edits without the denied one.
|
||||
|
||||
The denial message names the file and warns that batch siblings may
|
||||
already have been applied. Treat it literally:
|
||||
|
||||
- Send dependent edits to a not-yet-touched file sequentially, not in a
|
||||
parallel batch. A definition and its first use, or an import and its
|
||||
call site, must not ride in the same batch.
|
||||
- After a first-touch denial, present the facts, retry the denied edit,
|
||||
and re-read the file before building on anything else from the batch.
|
||||
|
||||
A batch-wide lock is not possible: hooks see tool calls one at a time, so
|
||||
the gate cannot know which calls arrived together.
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Option A: Use the ECC hook (zero install)
|
||||
|
||||
@@ -70,8 +70,13 @@ class OllamaProvider(LLMProvider):
|
||||
"messages": [msg.to_dict() for msg in input.messages],
|
||||
"stream": False,
|
||||
}
|
||||
options: dict[str, Any] = {}
|
||||
if input.temperature != 1.0:
|
||||
payload["options"] = {"temperature": input.temperature}
|
||||
options["temperature"] = input.temperature
|
||||
if input.max_tokens is not None:
|
||||
options["num_predict"] = input.max_tokens
|
||||
if options:
|
||||
payload["options"] = options
|
||||
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
req = urllib.request.Request(url, data=data, headers={"Content-Type": "application/json"})
|
||||
|
||||
@@ -1845,6 +1845,87 @@ function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('allows #2886 migration-doc heredoc repro with DROP TABLE prose', () => {
|
||||
expectAllow(
|
||||
[
|
||||
"cat > migration-notes.md <<'EOF'",
|
||||
"This migration will DROP TABLE old_sessions once we've verified nothing reads from it anymore.",
|
||||
'EOF'
|
||||
].join('\n'),
|
||||
'issue #2886 cat heredoc repro'
|
||||
);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('allows destructive SQL prose inside a tee heredoc', () => {
|
||||
expectAllow(
|
||||
[
|
||||
"tee migration-notes.md <<'EOF'",
|
||||
'This migration will DROP TABLE old_sessions after verification.',
|
||||
'EOF'
|
||||
].join('\n'),
|
||||
'tee heredoc SQL prose'
|
||||
);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('allows destructive rm prose inside a path-qualified cat heredoc', () => {
|
||||
expectAllow(
|
||||
[
|
||||
"/bin/cat > notes.md <<'EOF'",
|
||||
'Cleanup steps mention rm -rf old-cache; do not run yet.',
|
||||
'EOF'
|
||||
].join('\n'),
|
||||
'path-qualified cat heredoc prose'
|
||||
);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('allows destructive prose inside a command-wrapped cat heredoc', () => {
|
||||
expectAllow(
|
||||
[
|
||||
"command cat > notes.md <<'EOF'",
|
||||
'Notes: DELETE FROM sessions; truncate staging.',
|
||||
'EOF'
|
||||
].join('\n'),
|
||||
'command-wrapped cat heredoc prose'
|
||||
);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('still denies real destructive commands (not heredoc prose)', () => {
|
||||
expectDestructiveDeny('rm -rf /tmp/real-destructive-target', 'real rm -rf');
|
||||
expectDestructiveDeny('git reset --hard', 'real git reset --hard');
|
||||
expectDestructiveDeny('drop table old_sessions', 'real drop table command text');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('fails closed when tee pipes heredoc payload into a shell', () => {
|
||||
expectDestructiveDeny(
|
||||
['tee notes.md <<EOF | bash', 'rm -rf /tmp/tee-piped-shell-target', 'EOF'].join('\n'),
|
||||
'tee piped to shell'
|
||||
);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('denies substitutions inside literal quote characters in an unquoted heredoc', () => {
|
||||
for (const payload of [
|
||||
@@ -3091,6 +3172,93 @@ function runTests() {
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// --- Batch consistency (#3136): a parallel batch of edits to one ---
|
||||
// not-yet-touched file partially applies: the first denial marks the
|
||||
// file checked, so sibling edits in the same batch are allowed. Hooks
|
||||
// see calls one at a time and cannot lock a batch, so the contract is
|
||||
// that the denial itself names the file and warns that batch siblings
|
||||
// may already have been applied.
|
||||
clearState();
|
||||
if (
|
||||
test('first-touch Edit denial warns about applied batch siblings (#3136)', () => {
|
||||
// Two edits to the same unchecked file, sent as a parallel batch.
|
||||
// Each hook invocation is its own process, exactly as in a batch.
|
||||
const editA = {
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: '/src/batch-target.js', old_string: 'a', new_string: 'b' }
|
||||
};
|
||||
const editB = {
|
||||
tool_name: 'Edit',
|
||||
tool_input: { file_path: '/src/batch-target.js', old_string: 'c', new_string: 'd' }
|
||||
};
|
||||
|
||||
const first = parseOutput(runHook(editA).stdout);
|
||||
assert.strictEqual(first.hookSpecificOutput.permissionDecision, 'deny', 'first edit of the batch is gated');
|
||||
const firstReason = first.hookSpecificOutput.permissionDecisionReason;
|
||||
assert.ok(firstReason.includes('/src/batch-target.js'), 'denial names the exact file');
|
||||
assert.ok(
|
||||
firstReason.includes('parallel batch'),
|
||||
'denial warns that batch siblings may already have been applied'
|
||||
);
|
||||
assert.ok(
|
||||
firstReason.includes('Re-read'),
|
||||
'denial tells the agent to re-read the file before building on siblings'
|
||||
);
|
||||
|
||||
// Sibling edit in the same batch: judged against post-denial state,
|
||||
// so it applies. The warning above is what makes this visible.
|
||||
const second = parseOutput(runHook(editB).stdout);
|
||||
if (second && second.hookSpecificOutput) {
|
||||
assert.notStrictEqual(second.hookSpecificOutput.permissionDecision, 'deny', 'batch sibling is not re-gated');
|
||||
}
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
clearState();
|
||||
if (
|
||||
test('condensed Edit denial also warns about applied batch siblings (#3136)', () => {
|
||||
writeState({ checked: [], last_active: Date.now(), fact_force_denials: 3 });
|
||||
const result = runHook({ tool_name: 'Edit', tool_input: { file_path: '/src/batch-condensed.js' } });
|
||||
const output = parseOutput(result.stdout);
|
||||
assert.strictEqual(output.hookSpecificOutput.permissionDecision, 'deny');
|
||||
const reason = output.hookSpecificOutput.permissionDecisionReason;
|
||||
assert.ok(reason.includes('parallel batch'), 'condensed denial keeps the batch-sibling warning');
|
||||
assert.ok(!reason.includes('\n'), 'condensed denial stays a single line');
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
clearState();
|
||||
if (
|
||||
test('first-touch Write and MultiEdit denials warn about applied batch siblings (#3136)', () => {
|
||||
const writeOut = parseOutput(
|
||||
runHook({ tool_name: 'Write', tool_input: { file_path: '/src/batch-new.js', content: 'x' } }).stdout
|
||||
);
|
||||
assert.strictEqual(writeOut.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.ok(
|
||||
writeOut.hookSpecificOutput.permissionDecisionReason.includes('parallel batch'),
|
||||
'Write denial carries the batch-sibling warning'
|
||||
);
|
||||
|
||||
const multiOut = parseOutput(
|
||||
runHook({
|
||||
tool_name: 'MultiEdit',
|
||||
tool_input: { edits: [{ file_path: '/src/batch-multi.js', old_string: 'a', new_string: 'b' }] }
|
||||
}).stdout
|
||||
);
|
||||
assert.strictEqual(multiOut.hookSpecificOutput.permissionDecision, 'deny');
|
||||
assert.ok(
|
||||
multiOut.hookSpecificOutput.permissionDecisionReason.includes('parallel batch'),
|
||||
'MultiEdit denial carries the batch-sibling warning'
|
||||
);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// Cleanup only the temp directory created by this test file.
|
||||
try {
|
||||
if (fs.existsSync(stateDir)) {
|
||||
|
||||
@@ -306,6 +306,246 @@ if (
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
function writeExecutable(filePath, body) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, body);
|
||||
fs.chmodSync(filePath, 0o755);
|
||||
}
|
||||
|
||||
// The Python arm of the hook, exercised without a real interpreter: the stubs
|
||||
// record the argv they were handed, which is what the virtualenv-path regression
|
||||
// is actually about.
|
||||
function runHermeticPythonPrePush({
|
||||
venvName = null,
|
||||
venvExit = 0,
|
||||
trackVenv = false,
|
||||
trackedVenvBasename = 'python',
|
||||
trackedSymlinkVenv = false,
|
||||
pytestCmd = null,
|
||||
overrideStub = false,
|
||||
pathPytestVersionLine = null,
|
||||
} = {}) {
|
||||
const tempDir = createTempDir('codex-pre-push-py-');
|
||||
const projectDir = path.join(tempDir, 'project');
|
||||
const callsPath = path.join(tempDir, 'calls.txt');
|
||||
fs.mkdirSync(projectDir);
|
||||
fs.writeFileSync(path.join(projectDir, 'pyproject.toml'), '[project]\nname = "demo"\n');
|
||||
const initialized = spawnSync('git', ['init', '--quiet'], { cwd: projectDir });
|
||||
assert.strictEqual(initialized.status, 0, initialized.stderr?.toString());
|
||||
|
||||
// Every stub records the argv it was handed. That record is the assertion: it is
|
||||
// how a test tells a preserved path from a split one, and a command that was run
|
||||
// once from one the hook probed first.
|
||||
const record = `printf '%s\\n' "$0|$*" >> "${toBashPath(callsPath)}"`;
|
||||
|
||||
// A tracked venv has to live inside the repository to be trackable at all, and is
|
||||
// found by directory-name discovery rather than by VIRTUAL_ENV.
|
||||
const venvDir = venvName === null ? null : path.join(trackVenv ? projectDir : tempDir, venvName);
|
||||
const venvPython = venvDir === null
|
||||
? null
|
||||
: path.join(venvDir, 'bin', trackVenv ? trackedVenvBasename : 'python');
|
||||
if (venvPython !== null) {
|
||||
writeExecutable(venvPython, `#!/bin/sh\n${record}\ncase " $* " in *" -c "*) exit 0 ;; esac\nexit ${venvExit}\n`);
|
||||
if (trackVenv) {
|
||||
// Staged, not committed: `git ls-files` reads the index, so this is enough to
|
||||
// make the file repository-controlled without needing a committer identity.
|
||||
const added = spawnSync('git', ['add', '-f', '--', venvPython], { cwd: projectDir });
|
||||
assert.strictEqual(added.status, 0, added.stderr?.toString());
|
||||
}
|
||||
}
|
||||
|
||||
// The shape that defeats a naive `git ls-files -- .venv/bin/python` check: the
|
||||
// repository commits `.venv` as a symlink to its own root plus a tracked
|
||||
// `bin/python`, so git is asked about a path it has never indexed.
|
||||
if (trackedSymlinkVenv) {
|
||||
writeExecutable(path.join(projectDir, 'bin', 'python'), `#!/bin/sh\n${record}\nexit 0\n`);
|
||||
fs.symlinkSync('.', path.join(projectDir, '.venv'));
|
||||
const added = spawnSync('git', ['add', '-f', '--', 'bin/python', '.venv'], { cwd: projectDir });
|
||||
assert.strictEqual(added.status, 0, added.stderr?.toString());
|
||||
}
|
||||
|
||||
// Deliberately does NOT special-case --version: an operator's wrapper would not
|
||||
// either, and the recorded calls are what prove the hook never probed it.
|
||||
const overrideStubPath = overrideStub ? path.join(tempDir, 'bin', 'wrapper') : null;
|
||||
if (overrideStubPath !== null) {
|
||||
writeExecutable(overrideStubPath, `#!/bin/sh\n${record}\nexit 0\n`);
|
||||
}
|
||||
|
||||
const pathBin = pathPytestVersionLine === null ? null : path.join(tempDir, 'pathbin');
|
||||
if (pathBin !== null) {
|
||||
writeExecutable(
|
||||
path.join(pathBin, 'pytest'),
|
||||
`#!/bin/sh\nif [ "$1" = "--version" ]; then printf '%s\\n' '${pathPytestVersionLine}'; exit 0; fi\n${record}\nexit 0\n`,
|
||||
);
|
||||
}
|
||||
|
||||
const override = overrideStubPath === null ? pytestCmd : toBashPath(overrideStubPath);
|
||||
// Built from nothing rather than from process.env. The hook reads VIRTUAL_ENV and
|
||||
// ECC_PYTEST_CMD from the ambient environment, so a developer running this suite
|
||||
// inside an activated virtualenv, or with ECC_PYTEST_CMD exported, would resolve a
|
||||
// pytest the fixture never created. Omitted, not blanked: now that a variable set
|
||||
// to nothing is itself an override, blanking it here would make every one of these
|
||||
// tests take that branch.
|
||||
const env = {
|
||||
PATH: pathBin === null
|
||||
? process.env.PATH
|
||||
: `${toBashPath(pathBin)}${path.delimiter}${process.env.PATH}`,
|
||||
HOME: process.env.HOME ?? '',
|
||||
ECC_SKIP_GIT_HOOKS: '0',
|
||||
ECC_SKIP_PREPUSH: '0',
|
||||
MSYS_NO_PATHCONV: '1',
|
||||
...(venvDir === null || trackVenv ? {} : { VIRTUAL_ENV: toBashPath(venvDir) }),
|
||||
...(override === null ? {} : { ECC_PYTEST_CMD: override }),
|
||||
};
|
||||
|
||||
const result = runBash(prePushHook, {
|
||||
env,
|
||||
cwd: projectDir,
|
||||
preservePath: false,
|
||||
input: Buffer.from('refs/heads/main 1111111111111111111111111111111111111111 refs/heads/main 0000000000000000000000000000000000000000\n'),
|
||||
});
|
||||
const calls = fs.existsSync(callsPath)
|
||||
? fs.readFileSync(callsPath, 'utf8').trim().split(/\r?\n/).filter(Boolean)
|
||||
: [];
|
||||
cleanup(tempDir);
|
||||
return { result, calls, venvPython, overrideStubPath };
|
||||
}
|
||||
|
||||
if (
|
||||
test('pre-push runs pytest from a virtualenv whose path contains spaces', () => {
|
||||
const { result, calls, venvPython } = runHermeticPythonPrePush({ venvName: 'my venv' });
|
||||
const python = toBashPath(venvPython);
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepStrictEqual(calls, [
|
||||
`${python}|-I -c import pytest`,
|
||||
`${python}|-m pytest -q`,
|
||||
], JSON.stringify({ calls, python, stdout: result.stdout, stderr: result.stderr }, null, 2));
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('pre-push refuses to run a virtualenv python that the repository tracks', () => {
|
||||
const { result, calls } = runHermeticPythonPrePush({ venvName: '.venv', trackVenv: true });
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepStrictEqual(calls, [], JSON.stringify(calls));
|
||||
assert.match(result.stdout, /the repository ships it/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// A case-folded spelling, because macOS resolves `$venv/bin/python` to a committed
|
||||
// `Python` while git matches index pathspecs case-sensitively. Skipped where the
|
||||
// filesystem is case-sensitive and the two names cannot collide.
|
||||
if (fs.existsSync(__filename.toUpperCase()) || fs.existsSync(__filename.toLowerCase())) {
|
||||
if (
|
||||
test('pre-push refuses a tracked interpreter committed under a folded case', () => {
|
||||
const { result, calls } = runHermeticPythonPrePush({
|
||||
venvName: '.venv',
|
||||
trackVenv: true,
|
||||
trackedVenvBasename: 'Python',
|
||||
});
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepStrictEqual(calls, [], JSON.stringify(calls));
|
||||
assert.match(result.stdout, /the repository ships it/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
if (
|
||||
test('pre-push refuses a tracked interpreter reached through a committed symlink', () => {
|
||||
const { result, calls } = runHermeticPythonPrePush({ trackedSymlinkVenv: true });
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepStrictEqual(calls, [], JSON.stringify(calls));
|
||||
assert.match(result.stdout, /the repository ships it/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('pre-push blocks the push when the resolved pytest fails', () => {
|
||||
const { result } = runHermeticPythonPrePush({ venvName: 'venv-red', venvExit: 1 });
|
||||
assert.notStrictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stderr, /pytest failed \(exit 1\)/);
|
||||
assert.doesNotMatch(result.stdout, /Verification checks passed/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('pre-push does not block when pytest collected no tests (exit 5)', () => {
|
||||
const { result } = runHermeticPythonPrePush({ venvName: 'venv-empty', venvExit: 5 });
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stdout, /collected no tests \(exit 5\)/);
|
||||
assert.match(result.stdout, /rootdir, testpaths, and conftest\.py/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('pre-push runs an ECC_PYTEST_CMD override exactly once, without probing it', () => {
|
||||
const { result, calls, overrideStubPath } = runHermeticPythonPrePush({ overrideStub: true });
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.deepStrictEqual(calls, [`${toBashPath(overrideStubPath)}|-q`], JSON.stringify(calls));
|
||||
// The override is not verified to be pytest, so it must at least be loud.
|
||||
assert.match(result.stdout, /via ECC_PYTEST_CMD/);
|
||||
assert.match(result.stdout, /does\n?.*not check that it is pytest/s);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
// Both blank forms, because they used to disagree: an unquoted empty value fell
|
||||
// through to discovery while whitespace failed the push. A venv is present so a
|
||||
// fall-through would be visible as a pass rather than as an absence.
|
||||
for (const [label, blank] of [['empty', ''], ['whitespace', ' ']]) {
|
||||
if (
|
||||
test(`pre-push fails closed when ECC_PYTEST_CMD is set to ${label}`, () => {
|
||||
const { result, calls } = runHermeticPythonPrePush({
|
||||
venvName: 'venv-blank',
|
||||
pytestCmd: blank,
|
||||
});
|
||||
assert.notStrictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stderr, /ECC_PYTEST_CMD is set but names no command/);
|
||||
assert.deepStrictEqual(calls, [], JSON.stringify(calls));
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
}
|
||||
|
||||
if (
|
||||
test('pre-push rejects a PATH pytest that does not identify itself as pytest', () => {
|
||||
const { result, calls } = runHermeticPythonPrePush({
|
||||
pathPytestVersionLine: 'true (GNU coreutils) 9.0',
|
||||
});
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.match(result.stdout, /no pytest found/);
|
||||
assert.deepStrictEqual(calls, []);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
if (
|
||||
test('pre-push accepts a PATH pytest that reports a pytest version', () => {
|
||||
const { result, calls } = runHermeticPythonPrePush({ pathPytestVersionLine: 'pytest 8.0.0' });
|
||||
assert.strictEqual(result.status, 0, `${result.stdout}\n${result.stderr}`);
|
||||
assert.strictEqual(calls.length, 1, JSON.stringify(calls));
|
||||
assert.match(calls[0], /\|-q$/);
|
||||
})
|
||||
)
|
||||
passed++;
|
||||
else failed++;
|
||||
|
||||
|
||||
if (
|
||||
test('check-plugin-cache fails when the installed cache is missing manifest-referenced files', () => {
|
||||
const homeDir = createTempDir('codex-plugin-cache-home-');
|
||||
|
||||
@@ -7,6 +7,7 @@ const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const { execFileSync, spawnSync } = require('child_process');
|
||||
const crypto = require('crypto');
|
||||
const yaml = require('js-yaml');
|
||||
const { applyInstallPlan } = require('../../scripts/lib/install/apply');
|
||||
|
||||
@@ -593,6 +594,182 @@ function runTests() {
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('home installs do not copy the repo .agents staging directory into Claude or Codex homes', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
try {
|
||||
const claudeResult = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(claudeResult.code, 0, claudeResult.stderr);
|
||||
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'agents', 'architect.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md')));
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(claudeRoot, '.agents')),
|
||||
'Claude home must not receive the repo .agents staging directory'
|
||||
);
|
||||
|
||||
const claudeState = readJson(path.join(claudeRoot, 'ecc', 'install-state.json'));
|
||||
assert.ok(
|
||||
!claudeState.operations.some(operation => (
|
||||
String(operation.sourceRelativePath || '').replace(/\\/g, '/').split('/')[0] === '.agents'
|
||||
)),
|
||||
'Claude install-state must not record .agents copy operations'
|
||||
);
|
||||
|
||||
const codexResult = run(['--target', 'codex', '--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(codexResult.code, 0, codexResult.stderr);
|
||||
|
||||
const codexRoot = path.join(homeDir, '.codex');
|
||||
assert.ok(fs.existsSync(path.join(codexRoot, 'agents', 'architect.md')));
|
||||
assert.ok(fs.existsSync(path.join(codexRoot, 'skills', 'tdd-workflow', 'SKILL.md')));
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(codexRoot, '.agents')),
|
||||
'Codex home must not receive the repo .agents staging directory'
|
||||
);
|
||||
|
||||
const codexState = readJson(path.join(codexRoot, 'ecc-install-state.json'));
|
||||
assert.ok(
|
||||
!codexState.operations.some(operation => (
|
||||
String(operation.sourceRelativePath || '').replace(/\\/g, '/').split('/')[0] === '.agents'
|
||||
)),
|
||||
'Codex install-state must not record .agents copy operations'
|
||||
);
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('reconciles legacy .agents files and state operations on Claude and Codex home upgrades', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
const digest = content => crypto.createHash('sha256').update(content).digest('hex');
|
||||
const legacyOperation = (destinationPath, sourceRelativePath, installedContent) => ({
|
||||
kind: 'copy-file',
|
||||
moduleId: 'agents-core',
|
||||
sourceRelativePath,
|
||||
destinationPath,
|
||||
strategy: 'preserve-relative-path',
|
||||
ownership: 'managed',
|
||||
scaffoldOnly: false,
|
||||
contentSha256: digest(installedContent),
|
||||
});
|
||||
const writeFile = (filePath, content) => {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
||||
fs.writeFileSync(filePath, content);
|
||||
};
|
||||
const writeLegacyState = (statePath, target, operations) => {
|
||||
writeFile(statePath, `${JSON.stringify({
|
||||
schemaVersion: 'ecc.install.v1',
|
||||
installedAt: '2026-09-01T00:00:00.000Z',
|
||||
target,
|
||||
request: {
|
||||
profile: 'core',
|
||||
modules: [],
|
||||
includeComponents: [],
|
||||
excludeComponents: [],
|
||||
legacyLanguages: [],
|
||||
legacyMode: false,
|
||||
hookConsent: target.target === 'claude' ? 'enabled' : null,
|
||||
},
|
||||
resolution: { selectedModules: ['agents-core'], skippedModules: [] },
|
||||
source: { repoVersion: '2.2.1', repoCommit: null, manifestVersion: 1 },
|
||||
operations,
|
||||
}, null, 2)}\n`);
|
||||
};
|
||||
|
||||
try {
|
||||
// Claude home seeded as installed before the .agents exclusion.
|
||||
const claudeRoot = path.join(homeDir, '.claude');
|
||||
const claudeStatePath = path.join(claudeRoot, 'ecc', 'install-state.json');
|
||||
const claudeSkillCopy = path.join(claudeRoot, '.agents', 'skills', 'legacy-skill', 'SKILL.md');
|
||||
const claudeModifiedCopy = path.join(claudeRoot, '.agents', 'plugins', 'marketplace.json');
|
||||
const claudeUserFile = path.join(claudeRoot, '.agents', 'user-note.txt');
|
||||
writeFile(claudeSkillCopy, '# legacy skill\n');
|
||||
writeFile(claudeModifiedCopy, '{"edited": true}\n');
|
||||
writeFile(claudeUserFile, 'user notes\n');
|
||||
writeLegacyState(claudeStatePath, {
|
||||
id: 'claude-home', target: 'claude', kind: 'home',
|
||||
root: claudeRoot, installStatePath: claudeStatePath,
|
||||
}, [
|
||||
legacyOperation(claudeSkillCopy, '.agents/skills/legacy-skill/SKILL.md', '# legacy skill\n'),
|
||||
legacyOperation(claudeModifiedCopy, '.agents/plugins/marketplace.json', '{"original": true}\n'),
|
||||
]);
|
||||
|
||||
const claudeResult = run(['--profile', 'core', '--enable-hooks'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(claudeResult.code, 0, claudeResult.stderr);
|
||||
|
||||
assert.ok(!fs.existsSync(claudeSkillCopy), 'Unchanged managed .agents file should be removed');
|
||||
assert.ok(
|
||||
claudeResult.stdout.includes(
|
||||
`- removed ${path.join(fs.realpathSync(claudeRoot), '.agents', 'skills', 'legacy-skill', 'SKILL.md')}`
|
||||
),
|
||||
'Install output should log one line per removed path'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(claudeModifiedCopy, 'utf8'),
|
||||
'{"edited": true}\n',
|
||||
'Modified managed file must be preserved'
|
||||
);
|
||||
assert.strictEqual(
|
||||
fs.readFileSync(claudeUserFile, 'utf8'),
|
||||
'user notes\n',
|
||||
'Files the state does not own must not be touched'
|
||||
);
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(claudeRoot, '.agents', 'skills')),
|
||||
'Emptied .agents subdirectories should be pruned'
|
||||
);
|
||||
|
||||
const claudeState = readJson(claudeStatePath);
|
||||
assert.ok(
|
||||
!claudeState.operations.some(operation => (
|
||||
String(operation.sourceRelativePath || '').replace(/\\/g, '/').split('/')[0] === '.agents'
|
||||
)),
|
||||
'Claude install-state must drop the excluded .agents operations'
|
||||
);
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'agents', 'architect.md')));
|
||||
assert.ok(fs.existsSync(path.join(claudeRoot, 'skills', 'tdd-workflow', 'SKILL.md')));
|
||||
|
||||
// Codex home seeded the same way; both recorded files are unchanged.
|
||||
const codexRoot = path.join(homeDir, '.codex');
|
||||
const codexStatePath = path.join(codexRoot, 'ecc-install-state.json');
|
||||
const codexSkillCopy = path.join(codexRoot, '.agents', 'skills', 'legacy-skill', 'SKILL.md');
|
||||
const codexMarketplaceCopy = path.join(codexRoot, '.agents', 'plugins', 'marketplace.json');
|
||||
writeFile(codexSkillCopy, '# legacy skill\n');
|
||||
writeFile(codexMarketplaceCopy, '{"original": true}\n');
|
||||
writeLegacyState(codexStatePath, {
|
||||
id: 'codex-home', target: 'codex', kind: 'home',
|
||||
root: codexRoot, installStatePath: codexStatePath,
|
||||
}, [
|
||||
legacyOperation(codexSkillCopy, '.agents/skills/legacy-skill/SKILL.md', '# legacy skill\n'),
|
||||
legacyOperation(codexMarketplaceCopy, '.agents/plugins/marketplace.json', '{"original": true}\n'),
|
||||
]);
|
||||
|
||||
const codexResult = run(['--target', 'codex', '--profile', 'core'], { cwd: projectDir, homeDir });
|
||||
assert.strictEqual(codexResult.code, 0, codexResult.stderr);
|
||||
|
||||
assert.ok(
|
||||
!fs.existsSync(path.join(codexRoot, '.agents')),
|
||||
'Fully reconciled .agents directory should be pruned from the Codex home'
|
||||
);
|
||||
const codexState = readJson(codexStatePath);
|
||||
assert.ok(
|
||||
!codexState.operations.some(operation => (
|
||||
String(operation.sourceRelativePath || '').replace(/\\/g, '/').split('/')[0] === '.agents'
|
||||
)),
|
||||
'Codex install-state must drop the excluded .agents operations'
|
||||
);
|
||||
assert.ok(fs.existsSync(path.join(codexRoot, 'agents', 'architect.md')));
|
||||
assert.ok(fs.existsSync(path.join(codexRoot, 'skills', 'tdd-workflow', 'SKILL.md')));
|
||||
} finally {
|
||||
cleanup(homeDir);
|
||||
cleanup(projectDir);
|
||||
}
|
||||
})) passed++; else failed++;
|
||||
|
||||
if (test('preserves existing top-level Claude rules and skills during managed install', () => {
|
||||
const homeDir = createTempDir('install-apply-home-');
|
||||
const projectDir = createTempDir('install-apply-project-');
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import json
|
||||
import urllib.request
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
@@ -5,6 +8,7 @@ import pytest
|
||||
from llm.core.types import LLMInput, Message, Role, ToolDefinition
|
||||
from llm.providers.claude import ClaudeProvider
|
||||
from llm.providers.constants import EMPTY_FILTERED_RESPONSE_ERROR
|
||||
from llm.providers.ollama import OllamaProvider
|
||||
from llm.providers.openai import OpenAIProvider
|
||||
|
||||
|
||||
@@ -114,6 +118,56 @@ def test_openai_provider_allows_missing_usage():
|
||||
assert output.usage is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("max_tokens", "temperature", "expected_options"),
|
||||
[
|
||||
(128, 1.0, {"num_predict": 128}),
|
||||
(128, 0.2, {"temperature": 0.2, "num_predict": 128}),
|
||||
(128, 0.0, {"temperature": 0.0, "num_predict": 128}),
|
||||
(0, 1.0, {"num_predict": 0}),
|
||||
(None, 1.0, {}),
|
||||
(None, 0.2, {"temperature": 0.2}),
|
||||
],
|
||||
)
|
||||
def test_ollama_provider_serializes_generation_options(
|
||||
monkeypatch, max_tokens, temperature, expected_options
|
||||
):
|
||||
requests = []
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
requests.append((request, timeout))
|
||||
return BytesIO(b'{"message": {"content": "ok"}, "done_reason": "stop"}')
|
||||
|
||||
monkeypatch.setattr(urllib.request, "urlopen", fake_urlopen)
|
||||
provider = OllamaProvider(base_url="http://localhost:11434", default_model="llama3.2")
|
||||
|
||||
output = provider.generate(
|
||||
LLMInput(
|
||||
messages=[Message(role=Role.USER, content="hi")],
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
)
|
||||
)
|
||||
|
||||
assert len(requests) == 1
|
||||
request, timeout = requests[0]
|
||||
expected_payload = {
|
||||
"model": "llama3.2",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"stream": False,
|
||||
}
|
||||
if expected_options:
|
||||
expected_payload["options"] = expected_options
|
||||
assert json.loads(request.data) == expected_payload
|
||||
assert request.full_url == "http://localhost:11434/api/chat"
|
||||
assert request.get_method() == "POST"
|
||||
assert request.get_header("Content-type") == "application/json"
|
||||
assert timeout == 60
|
||||
assert output.content == "ok"
|
||||
assert output.model == "llama3.2"
|
||||
assert output.stop_reason == "stop"
|
||||
|
||||
|
||||
def test_claude_provider_serializes_tools_for_messages_api():
|
||||
provider = ClaudeProvider(api_key="test")
|
||||
client = _AnthropicClient()
|
||||
|
||||
Reference in New Issue
Block a user