mirror of
https://github.com/affaan-m/ECC.git
synced 2026-09-08 07:37:48 +02:00
fix(pi): prevent recursive compiled OMP hook execution
Forward-port #2911 for #2909 and exercise the real adapter lifecycle with a recorded process boundary, including unavailable Node and invalid overrides. Co-authored-by: DavidHLP <lysf15520112973@163.com>
This commit is contained in:
+10
-2
@@ -90,8 +90,9 @@ The `extensions/index.ts` file handles:
|
|||||||
4. **Context injection** — Parses `hookSpecificOutput.additionalContext` from the SessionStart
|
4. **Context injection** — Parses `hookSpecificOutput.additionalContext` from the SessionStart
|
||||||
hook and appends it to the system prompt on the next `before_agent_start`, wrapped in an
|
hook and appends it to the system prompt on the next `before_agent_start`, wrapped in an
|
||||||
`<ecc-session-context>` block. Non-JSON hook output is tolerated, not treated as an error
|
`<ecc-session-context>` block. Non-JSON hook output is tolerated, not treated as an error
|
||||||
5. **Hook isolation** — Failing, missing, or slow hooks degrade to a warning and never
|
5. **Hook isolation** — Failing, missing, slow, or misconfigured hooks degrade to
|
||||||
terminate the Pi session. Hook execution is bounded by a timeout and an output limit
|
a warning and never terminate the Pi session. Hook execution is bounded by a
|
||||||
|
timeout and an output limit
|
||||||
6. **Package resolution** — Resolves hook scripts from the installed package via `__dirname`,
|
6. **Package resolution** — Resolves hook scripts from the installed package via `__dirname`,
|
||||||
never from `process.cwd()`, so a global install works from any project directory. Hooks
|
never from `process.cwd()`, so a global install works from any project directory. Hooks
|
||||||
still *run* in the user's project directory, so project detection stays correct
|
still *run* in the user's project directory, so project detection stays correct
|
||||||
@@ -99,6 +100,13 @@ The `extensions/index.ts` file handles:
|
|||||||
All hook execution is non-shell (`execFile` without shell interpretation), so paths containing
|
All hook execution is non-shell (`execFile` without shell interpretation), so paths containing
|
||||||
spaces, tabs, or shell metacharacters are safe.
|
spaces, tabs, or shell metacharacters are safe.
|
||||||
|
|
||||||
|
Hook runtime selection uses the host `process.execPath` only under Node.
|
||||||
|
Without an override, compiled OMP/Bun falls back to `node` instead of
|
||||||
|
recursively launching the OMP binary as a hook runner. Set `ECC_HOOK_NODE` to
|
||||||
|
an explicit absolute Node executable path when `node` is not available on
|
||||||
|
`PATH`.
|
||||||
|
Relative values are rejected when the hook runs and surfaced as a warning.
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
Intentionally **out of scope** for this first adapter (to be added independently):
|
Intentionally **out of scope** for this first adapter (to be added independently):
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
const path = require("node:path")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a real Node executable for hook scripts.
|
||||||
|
*
|
||||||
|
* Compiled OMP may report `process.release.name` as `node` even though its
|
||||||
|
* `process.execPath` points to the OMP launcher. Bun is detected separately via
|
||||||
|
* `process.versions.bun`; both fall back to `node` unless `ECC_HOOK_NODE`
|
||||||
|
* supplies an explicit absolute path.
|
||||||
|
*
|
||||||
|
* @param options - Runtime metadata and an optional absolute Node override.
|
||||||
|
* @returns The executable path to use for hook scripts.
|
||||||
|
* @throws {Error} If the hook runtime override is non-empty and relative.
|
||||||
|
*/
|
||||||
|
function resolveHookRuntime({
|
||||||
|
execPath = process.execPath,
|
||||||
|
releaseName = process.release?.name,
|
||||||
|
bunVersion = process.versions?.bun,
|
||||||
|
override = process.env.ECC_HOOK_NODE,
|
||||||
|
} = {}) {
|
||||||
|
const isNodeRuntime =
|
||||||
|
releaseName === "node" &&
|
||||||
|
!bunVersion &&
|
||||||
|
/^(?:node|nodejs)(?:\.exe)?$/i.test(path.basename(execPath))
|
||||||
|
const overridePath = override?.trim()
|
||||||
|
if (overridePath) {
|
||||||
|
if (!path.isAbsolute(overridePath)) {
|
||||||
|
throw new Error("ECC_HOOK_NODE must be an absolute path: " + overridePath)
|
||||||
|
}
|
||||||
|
return overridePath
|
||||||
|
}
|
||||||
|
return isNodeRuntime ? execPath : "node"
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { resolveHookRuntime }
|
||||||
+22
-7
@@ -15,16 +15,20 @@
|
|||||||
* Design constraints (see .pi/README.md):
|
* Design constraints (see .pi/README.md):
|
||||||
* - Hooks resolve relative to THIS file, never `process.cwd()`, so a global
|
* - Hooks resolve relative to THIS file, never `process.cwd()`, so a global
|
||||||
* `pi install` works from any project directory.
|
* `pi install` works from any project directory.
|
||||||
* - Hooks execute via `execFile(process.execPath, [...])` with no shell, so
|
* - Hooks execute via `execFile(hookRuntime, [...])` with no shell, so paths
|
||||||
* paths containing spaces or shell metacharacters are safe.
|
* containing spaces or shell metacharacters are safe. The hook runtime is
|
||||||
* - Hook failures are isolated: a broken, missing, or slow hook degrades to a
|
* selected separately because compiled OMP may report `process.release.name`
|
||||||
* warning and never terminates the Pi session.
|
* as `node` while `process.execPath` points back to `omp`; Bun is detected
|
||||||
|
* separately via `process.versions.bun`.
|
||||||
|
* - Hook failures are isolated: a broken, missing, slow, or misconfigured hook
|
||||||
|
* degrades to a warning and never terminates the Pi session.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { execFile } from "node:child_process"
|
import { execFile } from "node:child_process"
|
||||||
import * as fs from "node:fs"
|
import * as fs from "node:fs"
|
||||||
import * as os from "node:os"
|
import * as os from "node:os"
|
||||||
import * as path from "node:path"
|
import * as path from "node:path"
|
||||||
|
import { resolveHookRuntime } from "./hook-runtime.js"
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Minimal structural types mirroring `@earendil-works/pi-coding-agent`.
|
* Minimal structural types mirroring `@earendil-works/pi-coding-agent`.
|
||||||
@@ -175,8 +179,9 @@ interface HookResult {
|
|||||||
/**
|
/**
|
||||||
* Run an ECC hook through ECC's own runner.
|
* Run an ECC hook through ECC's own runner.
|
||||||
*
|
*
|
||||||
* Never rejects: a missing runner, a non-zero exit, a timeout, or a spawn error
|
* Never rejects: an invalid runtime override, a missing runner, a non-zero exit,
|
||||||
* all resolve to a `failure` string that the caller surfaces as a warning.
|
* a timeout, or a spawn error all resolve to a `failure` string that the caller
|
||||||
|
* surfaces as a warning.
|
||||||
*/
|
*/
|
||||||
function runEccHook(
|
function runEccHook(
|
||||||
spec: HookSpec,
|
spec: HookSpec,
|
||||||
@@ -189,9 +194,19 @@ function runEccHook(
|
|||||||
resolve({ stdout: "", failure: `hook runner not found at ${HOOK_RUNNER}` })
|
resolve({ stdout: "", failure: `hook runner not found at ${HOOK_RUNNER}` })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let hookRuntime: string
|
||||||
|
try {
|
||||||
|
hookRuntime = resolveHookRuntime()
|
||||||
|
} catch (error) {
|
||||||
|
resolve({
|
||||||
|
stdout: "",
|
||||||
|
failure: `${spec.id}: ${(error as Error).message}`,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const child = execFile(
|
const child = execFile(
|
||||||
process.execPath,
|
hookRuntime,
|
||||||
[HOOK_RUNNER, spec.id, spec.script, spec.profiles],
|
[HOOK_RUNNER, spec.id, spec.script, spec.profiles],
|
||||||
{
|
{
|
||||||
// Hooks inspect the user's project, so they run there. Only the script
|
// Hooks inspect the user's project, so they run there. Only the script
|
||||||
|
|||||||
@@ -45,7 +45,11 @@ const fs = require("fs")
|
|||||||
const os = require("os")
|
const os = require("os")
|
||||||
const path = require("path")
|
const path = require("path")
|
||||||
const { spawnSync, execFile } = require("child_process")
|
const { spawnSync, execFile } = require("child_process")
|
||||||
|
const { resolveHookRuntime } = require(
|
||||||
|
path.join(__dirname, "..", "..", ".pi", "extensions", "hook-runtime.js")
|
||||||
|
)
|
||||||
|
|
||||||
|
/** Run a single adapter test and report the result. */
|
||||||
async function runTest(name, fn) {
|
async function runTest(name, fn) {
|
||||||
try {
|
try {
|
||||||
await fn()
|
await fn()
|
||||||
@@ -70,9 +74,9 @@ function stripComments(source) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mirrors the adapter's own hook invocation (`runEccHook` in
|
* Invokes ECC's hook runner like the adapter (`runEccHook` in
|
||||||
* .pi/extensions/index.ts): same binary (`process.execPath`), same argv
|
* .pi/extensions/index.ts): it uses the test host's Node executable with the
|
||||||
* shape, same stdin-JSON payload, same env keys. No shell is used anywhere.
|
* same argv shape and JSON payload on stdin. No shell is used.
|
||||||
*/
|
*/
|
||||||
function runHookRunner(eccRoot, hookId, relScript, profiles, payload, extraEnv, cwd) {
|
function runHookRunner(eccRoot, hookId, relScript, profiles, payload, extraEnv, cwd) {
|
||||||
const runner = path.join(eccRoot, "scripts", "hooks", "run-with-flags.js")
|
const runner = path.join(eccRoot, "scripts", "hooks", "run-with-flags.js")
|
||||||
@@ -283,6 +287,7 @@ function isDisabledByEnvMirror(value) {
|
|||||||
return typeof value === "string" && DISABLED_VALUES_MIRROR.has(value.trim().toLowerCase())
|
return typeof value === "string" && DISABLED_VALUES_MIRROR.has(value.trim().toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Run the Pi adapter regression suite. */
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log("\n=== Testing .pi/extensions/index.ts (Pi thin adapter) ===\n")
|
console.log("\n=== Testing .pi/extensions/index.ts (Pi thin adapter) ===\n")
|
||||||
|
|
||||||
@@ -320,8 +325,8 @@ async function main() {
|
|||||||
"expected the adapter to invoke hooks via child_process.execFile(...)"
|
"expected the adapter to invoke hooks via child_process.execFile(...)"
|
||||||
)
|
)
|
||||||
assert.ok(
|
assert.ok(
|
||||||
extensionSource.includes("process.execPath"),
|
extensionSource.includes("resolveHookRuntime"),
|
||||||
"expected hooks to be spawned with process.execPath, not a hardcoded 'node' string"
|
"expected the adapter to select a hook runtime before execFile(...)"
|
||||||
)
|
)
|
||||||
|
|
||||||
const shellExecPattern = /(?<!execFile)\bexec\s*\(/
|
const shellExecPattern = /(?<!execFile)\bexec\s*\(/
|
||||||
@@ -343,6 +348,95 @@ async function main() {
|
|||||||
"the path-with-spaces / injection risk execFile(...) with no shell was meant to avoid"
|
"the path-with-spaces / injection risk execFile(...) with no shell was meant to avoid"
|
||||||
)
|
)
|
||||||
}],
|
}],
|
||||||
|
["selects a real Node runtime instead of compiled OMP's masquerading process.execPath", () => {
|
||||||
|
const runtimeSource = fs.readFileSync(
|
||||||
|
path.join(repoRoot, ".pi", "extensions", "hook-runtime.js"),
|
||||||
|
"utf8"
|
||||||
|
)
|
||||||
|
const runHookStart = extensionSource.indexOf("function runEccHook")
|
||||||
|
const runHookEnd = extensionSource.indexOf("function resolveHookCwd")
|
||||||
|
const runHookSource = extensionSource.slice(runHookStart, runHookEnd)
|
||||||
|
const beforeRunHookSource = extensionSource.slice(0, runHookStart)
|
||||||
|
assert.ok(
|
||||||
|
extensionSource.includes('from "./hook-runtime.js"'),
|
||||||
|
"expected the adapter to import the shared hook runtime selector"
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
!beforeRunHookSource.includes("resolveHookRuntime()") &&
|
||||||
|
/try\s*\{\s*hookRuntime = resolveHookRuntime\(\)\s*\}\s*catch/.test(runHookSource) &&
|
||||||
|
/execFile\(\s*hookRuntime,/.test(runHookSource),
|
||||||
|
"expected runEccHook to resolve its runtime inside the guarded hook path rather than " +
|
||||||
|
"during module initialization"
|
||||||
|
)
|
||||||
|
assert.ok(
|
||||||
|
runtimeSource.includes("process.versions?.bun") &&
|
||||||
|
runtimeSource.includes("path.basename(execPath)") &&
|
||||||
|
runtimeSource.includes("path.isAbsolute(overridePath)") &&
|
||||||
|
runtimeSource.includes(
|
||||||
|
'throw new Error("ECC_HOOK_NODE must be an absolute path: " + overridePath)'
|
||||||
|
),
|
||||||
|
"expected the selector to reject Bun/OMP runtimes, require absolute overrides, and " +
|
||||||
|
"fall back to PATH node"
|
||||||
|
)
|
||||||
|
}],
|
||||||
|
|
||||||
|
["resolves hook runtimes across Node, compiled OMP, and explicit override cases", () => {
|
||||||
|
assert.strictEqual(
|
||||||
|
resolveHookRuntime({ execPath: "/usr/bin/node", override: "" }),
|
||||||
|
"/usr/bin/node"
|
||||||
|
)
|
||||||
|
assert.strictEqual(
|
||||||
|
resolveHookRuntime({ execPath: "/usr/bin/nodejs", override: "" }),
|
||||||
|
"/usr/bin/nodejs"
|
||||||
|
)
|
||||||
|
assert.strictEqual(
|
||||||
|
resolveHookRuntime({
|
||||||
|
execPath: "/usr/bin/node",
|
||||||
|
bunVersion: "1.4.0",
|
||||||
|
override: "",
|
||||||
|
}),
|
||||||
|
"node"
|
||||||
|
)
|
||||||
|
assert.strictEqual(
|
||||||
|
resolveHookRuntime({
|
||||||
|
execPath: "/usr/bin/node",
|
||||||
|
releaseName: "bun",
|
||||||
|
override: "",
|
||||||
|
}),
|
||||||
|
"node"
|
||||||
|
)
|
||||||
|
assert.strictEqual(
|
||||||
|
resolveHookRuntime({
|
||||||
|
execPath: "/home/user/.omp/bin/omp",
|
||||||
|
releaseName: "node",
|
||||||
|
override: "",
|
||||||
|
}),
|
||||||
|
"node"
|
||||||
|
)
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
resolveHookRuntime({
|
||||||
|
execPath: "/usr/bin/node",
|
||||||
|
override: "./node",
|
||||||
|
}),
|
||||||
|
/ECC_HOOK_NODE must be an absolute path: \.\/node/
|
||||||
|
)
|
||||||
|
assert.strictEqual(
|
||||||
|
resolveHookRuntime({
|
||||||
|
execPath: "/usr/bin/node",
|
||||||
|
override: " /opt/node/bin/node ",
|
||||||
|
}),
|
||||||
|
"/opt/node/bin/node"
|
||||||
|
)
|
||||||
|
assert.strictEqual(
|
||||||
|
resolveHookRuntime({
|
||||||
|
execPath: "/home/user/.omp/bin/omp",
|
||||||
|
bunVersion: "1.4.0",
|
||||||
|
override: " /opt/node/bin/node ",
|
||||||
|
}),
|
||||||
|
"/opt/node/bin/node"
|
||||||
|
)
|
||||||
|
}],
|
||||||
|
|
||||||
["registers Pi's documented pi.on(...) lifecycle, not the undocumented app.events bus", () => {
|
["registers Pi's documented pi.on(...) lifecycle, not the undocumented app.events bus", () => {
|
||||||
assert.ok(
|
assert.ok(
|
||||||
|
|||||||
@@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Run the real adapter against a recording process boundary. A simulated OMP
|
||||||
|
// executable is never launched, so a regression cannot create a process storm.
|
||||||
|
const assert = require('assert');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const vm = require('vm');
|
||||||
|
const { EventEmitter } = require('events');
|
||||||
|
const ts = require('typescript');
|
||||||
|
|
||||||
|
const extensionDir = path.resolve(__dirname, '../../.pi/extensions');
|
||||||
|
const extensionSource = fs.readFileSync(path.join(extensionDir, 'index.ts'), 'utf8');
|
||||||
|
const compiled = ts.transpileModule(extensionSource, {
|
||||||
|
compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2020 }
|
||||||
|
}).outputText;
|
||||||
|
|
||||||
|
/** Load the adapter and its runtime selector with the same host metadata. */
|
||||||
|
function loadAdapter(host, spawnError) {
|
||||||
|
const launches = [];
|
||||||
|
const handlers = new Map();
|
||||||
|
const warnings = [];
|
||||||
|
const runtimeModule = { exports: {} };
|
||||||
|
const simulatedProcess = { env: {}, release: { name: 'node' }, versions: {}, ...host };
|
||||||
|
vm.runInNewContext(fs.readFileSync(path.join(extensionDir, 'hook-runtime.js'), 'utf8'), {
|
||||||
|
module: runtimeModule, require, process: simulatedProcess
|
||||||
|
});
|
||||||
|
const adapterModule = { exports: {} };
|
||||||
|
const recordExecFile = (file, args, options, callback) => {
|
||||||
|
const call = { file, args, options };
|
||||||
|
launches.push(call);
|
||||||
|
const child = new EventEmitter();
|
||||||
|
child.stdin = new EventEmitter();
|
||||||
|
child.stdin.end = input => {
|
||||||
|
call.input = JSON.parse(input);
|
||||||
|
callback(spawnError || null, '');
|
||||||
|
};
|
||||||
|
return child;
|
||||||
|
};
|
||||||
|
vm.runInNewContext(compiled, {
|
||||||
|
module: adapterModule,
|
||||||
|
exports: adapterModule.exports,
|
||||||
|
__dirname: extensionDir,
|
||||||
|
process: simulatedProcess,
|
||||||
|
require: name => {
|
||||||
|
if (name === 'node:child_process') return { execFile: recordExecFile };
|
||||||
|
if (name === './hook-runtime.js') return runtimeModule.exports;
|
||||||
|
return require(name);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
adapterModule.exports.default({
|
||||||
|
on: (name, handler) => handlers.set(name, handler),
|
||||||
|
registerCommand: () => {}
|
||||||
|
});
|
||||||
|
const context = {
|
||||||
|
cwd: path.resolve(__dirname, '../..'),
|
||||||
|
sessionManager: { getSessionId: () => 'runtime-regression' },
|
||||||
|
ui: { notify: message => warnings.push(message) }
|
||||||
|
};
|
||||||
|
return { launches, warnings, run: () => handlers.get('session_start')({ reason: 'resume' }, context) };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exercise the actual lifecycle entrypoint without spawning host executables. */
|
||||||
|
async function main() {
|
||||||
|
let passed = 0;
|
||||||
|
let failed = 0;
|
||||||
|
const explicitNode = path.resolve('test node runtime', 'node');
|
||||||
|
const cases = [
|
||||||
|
['normal Node', { execPath: process.execPath }, process.execPath],
|
||||||
|
['compiled OMP reporting Node', { execPath: '/fake/omp' }, 'node'],
|
||||||
|
['compiled OMP on Bun', { execPath: '/fake/omp', versions: { bun: '1.4.0' } }, 'node'],
|
||||||
|
['Bun with a Node basename', { execPath: '/fake/node', versions: { bun: '1.4.0' } }, 'node'],
|
||||||
|
['explicit absolute Node path with spaces', {
|
||||||
|
execPath: '/fake/omp', env: { ECC_HOOK_NODE: explicitNode }
|
||||||
|
}, explicitNode]
|
||||||
|
];
|
||||||
|
for (const [name, host, expected] of cases) {
|
||||||
|
try {
|
||||||
|
const adapter = loadAdapter(host);
|
||||||
|
await adapter.run();
|
||||||
|
assert.strictEqual(adapter.launches.length, 1);
|
||||||
|
const launch = adapter.launches[0];
|
||||||
|
assert.strictEqual(launch.file, expected);
|
||||||
|
assert.strictEqual(launch.args[1], 'session:start');
|
||||||
|
assert.strictEqual(launch.input.source, 'resume');
|
||||||
|
assert.strictEqual(launch.input.session_id, 'runtime-regression');
|
||||||
|
assert.ok(launch.options.timeout > 0 && launch.options.timeout <= 30000);
|
||||||
|
assert.ok(launch.options.maxBuffer > 0 && launch.options.maxBuffer <= 16 * 1024 * 1024);
|
||||||
|
assert.ok(!launch.options.shell);
|
||||||
|
assert.strictEqual(adapter.warnings.length, 0);
|
||||||
|
console.log(` ✓ ${name} launches exactly one bounded Node hook`);
|
||||||
|
passed++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` ✗ ${name}: ${error.message}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [name, host, spawnError, expectedLaunches] of [
|
||||||
|
['invalid override', { execPath: '/fake/omp', env: { ECC_HOOK_NODE: './omp' } }, null, 0],
|
||||||
|
['missing PATH node', { execPath: '/fake/omp' }, new Error('spawn node ENOENT'), 1]
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
const adapter = loadAdapter(host, spawnError);
|
||||||
|
await adapter.run();
|
||||||
|
assert.strictEqual(adapter.launches.length, expectedLaunches);
|
||||||
|
assert.strictEqual(adapter.warnings.length, 1);
|
||||||
|
assert.match(adapter.warnings[0], /hook skipped/);
|
||||||
|
console.log(` ✓ ${name} warns without retrying the host executable`);
|
||||||
|
passed++;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(` ✗ ${name}: ${error.message}`);
|
||||||
|
failed++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`\nPassed: ${passed}\nFailed: ${failed}`);
|
||||||
|
process.exitCode = failed ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => { console.error(error); process.exitCode = 1; });
|
||||||
Reference in New Issue
Block a user