From cdc8b66e74730ab1ed99a9f24a077f9077d2dbf5 Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Mon, 21 Sep 2026 12:45:05 +0800 Subject: [PATCH 01/13] test(contracts): establish C10 evidence foundation and C01 RED process tests Add shared, hash-locked synthetic argv vectors and a Java 8-compatible child probe. Add fail-closed layered Surefire reporting with 12 self-tests and pinned OpenSpec strict CI. This is the intentional C01 RED checkpoint; no product Java sources changed. Keep C10 integration and the remaining OpenSpec changes open. --- .github/workflows/contracts.yml | 126 +++++++++++++ docs/implementation/c10-c01-execution.md | 23 +++ scripts/contract_report.py | 111 ++++++++++++ scripts/tests/test_contract_report.py | 96 ++++++++++ .../opencli/contract/ContractProbe.java | 16 ++ .../contract/OpenCliArgvContractTest.java | 171 ++++++++++++++++++ .../resources/opencli-contracts/v1/SHA256SUMS | 2 + .../resources/opencli-contracts/v1/argv.tsv | 6 + .../opencli-contracts/v1/sources.lock.json | 58 ++++++ 9 files changed, 609 insertions(+) create mode 100644 .github/workflows/contracts.yml create mode 100644 docs/implementation/c10-c01-execution.md create mode 100644 scripts/contract_report.py create mode 100644 scripts/tests/test_contract_report.py create mode 100644 src/test/java/io/github/easy4j/opencli/contract/ContractProbe.java create mode 100644 src/test/java/io/github/easy4j/opencli/contract/OpenCliArgvContractTest.java create mode 100644 src/test/resources/opencli-contracts/v1/SHA256SUMS create mode 100644 src/test/resources/opencli-contracts/v1/argv.tsv create mode 100644 src/test/resources/opencli-contracts/v1/sources.lock.json diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml new file mode 100644 index 0000000..4c53c65 --- /dev/null +++ b/.github/workflows/contracts.yml @@ -0,0 +1,126 @@ +name: OpenCLI Contracts + +on: + push: + branches: + - 'feature/1.0.x' + - 'feature/2.0.x' + - 'feature/3.0.x' + - 'feature/*-contract-hardening' + workflow_dispatch: + +permissions: + contents: read + +jobs: + specification: + name: OpenSpec strict and evidence-runner tests + runs-on: ubuntu-latest + timeout-minutes: 10 + env: + OPENSPEC_TELEMETRY: '0' + DO_NOT_TRACK: '1' + CI: 'true' + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Verify report runner and shared fixture hashes + run: | + python3 -m unittest discover -s scripts/tests -v + (cd src/test/resources/opencli-contracts/v1 && sha256sum -c SHA256SUMS) + - name: Install pinned official OpenSpec + run: npm install --prefix "$RUNNER_TEMP/opencli-openspec" --ignore-scripts --no-audit --no-fund @fission-ai/openspec@1.13.1 + - name: Validate all proposals and each change with strict mode + shell: bash + run: | + export PATH="$RUNNER_TEMP/opencli-openspec/node_modules/.bin:$PATH" + mkdir -p .spec-evidence + openspec --version | tee .spec-evidence/version.txt + openspec list --json > .spec-evidence/list.json + failed=0 + for path in openspec/changes/*; do + [[ -d "$path" && "$(basename "$path")" != archive ]] || continue + change="$(basename "$path")" + if openspec validate "$change" --strict --no-interactive > ".spec-evidence/$change.log" 2>&1; then + printf '%s\tPASS\n' "$change" + else + cat ".spec-evidence/$change.log" + failed=1 + fi + done + if ! openspec validate --all --strict --no-interactive > .spec-evidence/all.log 2>&1; then + cat .spec-evidence/all.log + failed=1 + fi + exit "$failed" + - name: Upload specification evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: openspec-strict-${{ github.sha }} + path: .spec-evidence + if-no-files-found: error + + jvm: + name: Branch-specific JVM contract verification + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@v7 + - name: Select declared branch line + id: line + shell: bash + run: | + case "$GITHUB_REF_NAME" in + feature/1.0.x*) echo 'java=8' >> "$GITHUB_OUTPUT" ;; + feature/2.0.x*) echo 'java=17' >> "$GITHUB_OUTPUT" ;; + feature/3.0.x*) echo 'java=21' >> "$GITHUB_OUTPUT" ;; + *) echo 'Unsupported branch line' >&2; exit 1 ;; + esac + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: ${{ steps.line.outputs.java }} + cache: maven + - name: Record exact source and actual tools + run: | + mkdir -p .contract-evidence + git rev-parse HEAD > .contract-evidence/head.txt + java -version > .contract-evidence/java.txt 2>&1 + bash ./mvnw -version > .contract-evidence/maven.txt 2>&1 + - name: Full clean verify using this branch wrapper + id: verify + shell: bash + run: | + set +e + bash ./mvnw -B --no-transfer-progress clean verify > .contract-evidence/maven.log 2>&1 + result=$? + printf '%s\n' "$result" > .contract-evidence/maven.exit + tail -100 .contract-evidence/maven.log + exit "$result" + - name: Require nonempty, non-skipped contract evidence + if: always() + shell: bash + run: | + test -f .contract-evidence/maven.exit + python3 scripts/contract_report.py \ + --reports target/surefire-reports \ + --head "$(cat .contract-evidence/head.txt)" \ + --branch "$GITHUB_REF_NAME" \ + --java-version-file .contract-evidence/java.txt \ + --maven-version-file .contract-evidence/maven.txt \ + --exit-code "$(cat .contract-evidence/maven.exit)" \ + --suite argv=io.github.easy4j.opencli.contract.OpenCliArgvContractTest \ + --output .contract-evidence/report.json + - name: Upload actual JVM evidence + if: always() + uses: actions/upload-artifact@v7 + with: + name: contracts-jdk-${{ steps.line.outputs.java }}-${{ github.sha }} + path: | + .contract-evidence + target/surefire-reports + target/site/jacoco + if-no-files-found: error diff --git a/docs/implementation/c10-c01-execution.md b/docs/implementation/c10-c01-execution.md new file mode 100644 index 0000000..dab1be4 --- /dev/null +++ b/docs/implementation/c10-c01-execution.md @@ -0,0 +1,23 @@ +# C10 foundation and C01 implementation ledger + +## Approved ordering and scope + +The user approved C10 foundation, then C01/C02/C03/C04/C05/C09, then C06/C07/C08, and only then C10 integration closure. `feature/2.0.x` is the canonical implementation line. This file records execution, not completion of the ten changes. + +An isolated implementation branch starts at `d0c8056990f7a47fcc202acffa387ba066bcfc67`. The 1.x/3.x baseline refs and permitted JDK/Jackson/Maven differences are recorded in `src/test/resources/opencli-contracts/v1/sources.lock.json`. No changes to `main`, dependency versions or coverage thresholds are part of this increment. + +## C10 foundation + +Shared synthetic UTF-8/base64 argv vectors preserve empty and trailing empty fields. They have content hashes and a specification ref, not an invented upstream capture provenance. A Java 8-compatible child prints each actual argument. The regression suite uses the real SDK executor, adapter and Browser paths; a Recording executor is not used to prove process behavior. + +The report runner separates enumeration, argv, protocol, typed-result and real-execution evidence. Only explicitly selected suites contribute; the real-execution layer remains NOT_RUN because a Java argv probe is not a live OpenCLI website test. Missing reports, zero tests, skipped required tests, nonzero Maven exit, malformed XML and inconsistent testcase counts fail closed. + +Local runner TDD: 12 failures before the runner existed, then 12 passing tests. The probe compiled with `javac --release 8` on JDK 21 and emitted an empty token unchanged. This does not constitute an actual JDK 8 runtime test. Full JVM evidence is produced by the branch-specific GitHub Actions workflow using each line's checked-in Maven wrapper. + +## C01 RED checkpoint + +The initial Java contract suite deliberately demands lossless values before any product source is changed. The first CI run must be inspected for assertion failures at the real child boundary, not treated as a completed fix. Compilation errors or tool setup failures are not valid RED proof. + +## Still open + +C01 schema-aware repeated/false option handling, C02/C03/C04/C05/C09 production fixes, discovery, Browser result models, context/diagnostics and three-branch integration closure are not complete. Official OpenSpec strict is configured but must be observed at the exact workflow run before claiming it passed. No OpenSpec implementation tasks are pre-checked. diff --git a/scripts/contract_report.py b/scripts/contract_report.py new file mode 100644 index 0000000..19fcbba --- /dev/null +++ b/scripts/contract_report.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Build fail-closed, layer-specific evidence from real Surefire XML reports. + +This is not a code coverage percentage or a claim that OpenCLI websites work. +Only explicitly selected suites contribute to a layer. No network is used. +""" +import argparse +import json +import pathlib +import re +import sys +import xml.etree.ElementTree as ET + +LAYERS = ('enumeration', 'argv', 'protocol', 'typed-result', 'real-execution') + + +def _suite_summary(suite): + declared = {key: int(suite.attrib.get(key, '0')) for key in ('tests', 'failures', 'errors', 'skipped')} + cases = suite.findall('testcase') + actual = {'tests': len(cases), 'failures': sum(c.find('failure') is not None for c in cases), + 'errors': sum(c.find('error') is not None for c in cases), + 'skipped': sum(c.find('skipped') is not None for c in cases)} + if declared != actual or any(v < 0 for v in declared.values()): + raise ValueError('declared testcase counts disagree with XML evidence') + actual['executed'] = actual['tests'] - actual['skipped'] + return actual + + +def build_report(reports_dir, *, head, branch, java_version, maven_version, exit_code, required_suites): + """Return a JSON-serializable report; malformed/missing evidence fails closed.""" + problems = [] + if not re.fullmatch(r'[0-9a-fA-F]{40}', head or ''): + problems.append('exact 40-character source HEAD is required') + if not branch or not java_version.strip() or not maven_version.strip(): + problems.append('branch and actual tool versions are required') + if exit_code != 0: + problems.append('verification command exited nonzero') + if not required_suites or any(layer not in LAYERS or not names for layer, names in required_suites.items()): + problems.append('non-empty required suites must use recognized layers') + suites = {} + for path in sorted(pathlib.Path(reports_dir).glob('TEST-*.xml')): + try: + root = ET.parse(path).getroot() + nodes = [root] if root.tag == 'testsuite' else list(root.findall('testsuite')) + if not nodes: + raise ValueError('no testsuite') + for suite in nodes: + name = suite.attrib['name'] + if name in suites: + raise ValueError('duplicate testsuite') + suites[name] = _suite_summary(suite) + except (ET.ParseError, OSError, KeyError, ValueError): + # Do not copy testcase failure bodies or captured application output. + problems.append('invalid or duplicate Surefire report: ' + path.name) + layers = {} + for layer in LAYERS: + names = list(required_suites.get(layer, [])) + counts = dict.fromkeys(('tests', 'executed', 'skipped', 'failures', 'errors'), 0) + missing = [name for name in names if name not in suites] + for name in names: + for key in counts: + counts[key] += suites.get(name, {}).get(key, 0) + state = 'NOT_RUN' + if names: + state = 'PASS' if (not missing and counts['executed'] > 0 and + not any(counts[key] for key in ('skipped', 'failures', 'errors'))) else 'FAIL' + layers[layer] = dict(counts, status=state, requiredSuites=names, missingSuites=missing) + if state == 'FAIL': + problems.append('required layer lacks passing, non-skipped execution: ' + layer) + return {'schemaVersion': 1, 'status': 'FAIL' if problems else 'PASS', + 'head': head, 'branch': branch, 'javaVersion': java_version, + 'mavenVersion': maven_version, 'commandExitCode': exit_code, + 'layers': layers, 'problems': problems, + 'scope': 'Explicit Surefire suites only; synthetic argv probes are not live OpenCLI verification.'} + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--reports', type=pathlib.Path, required=True) + parser.add_argument('--head', required=True) + parser.add_argument('--branch', required=True) + parser.add_argument('--java-version-file', type=pathlib.Path, required=True) + parser.add_argument('--maven-version-file', type=pathlib.Path, required=True) + parser.add_argument('--exit-code', type=int, required=True) + parser.add_argument('--suite', action='append', required=True, help='layer=fully.qualified.TestClass; repeatable') + parser.add_argument('--output', type=pathlib.Path, required=True) + args = parser.parse_args(argv) + required = {} + for item in args.suite: + layer, separator, name = item.partition('=') + if not separator or layer not in LAYERS or not name: + parser.error('--suite must be recognized-layer=fully.qualified.TestClass') + if name in required.setdefault(layer, []): + parser.error('duplicate --suite') + required[layer].append(name) + try: + report = build_report(args.reports, head=args.head, branch=args.branch, + java_version=args.java_version_file.read_text(encoding='utf-8'), + maven_version=args.maven_version_file.read_text(encoding='utf-8'), + exit_code=args.exit_code, required_suites=required) + except OSError as exc: + print('Missing tool-version evidence: ' + exc.__class__.__name__, file=sys.stderr) + return 1 + args.output.parent.mkdir(parents=True, exist_ok=True) + args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + '\n', encoding='utf-8') + print(json.dumps({'status': report['status'], 'head': report['head'], 'layers': report['layers']})) + return 0 if report['status'] == 'PASS' else 1 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/scripts/tests/test_contract_report.py b/scripts/tests/test_contract_report.py new file mode 100644 index 0000000..f466e0a --- /dev/null +++ b/scripts/tests/test_contract_report.py @@ -0,0 +1,96 @@ +import importlib.util +import json +import pathlib +import tempfile +import unittest + +SCRIPT = pathlib.Path(__file__).resolve().parents[1] / 'contract_report.py' +HEAD = 'd0c8056990f7a47fcc202acffa387ba066bcfc67' +SUITE = 'io.github.easy4j.opencli.contract.OpenCliArgvContractTest' + + +class ContractReportTest(unittest.TestCase): + def setUp(self): + self.assertTrue(SCRIPT.is_file(), 'C10 contract report runner is not implemented') + spec = importlib.util.spec_from_file_location('contract_report', SCRIPT) + self.module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.module) + self.temp = tempfile.TemporaryDirectory() + self.addCleanup(self.temp.cleanup) + self.root = pathlib.Path(self.temp.name) + + def xml(self, tests=2, skipped=0, failures=0, errors=0): + executed = tests - skipped + cases = [] + for i in range(tests): + child = '' if i >= executed else ('' if i < failures else ('' if i < failures + errors else '')) + cases.append('' + child + '') + (self.root / ('TEST-' + SUITE + '.xml')).write_text( + '' + ''.join(cases) + '', encoding='utf-8') + + def report(self, **kwargs): + return self.module.build_report(self.root, head=kwargs.get('head', HEAD), + branch='feature/2.0.x', java_version='openjdk 17 (test fixture)', + maven_version='Apache Maven (test fixture)', exit_code=kwargs.get('exit_code', 0), + required_suites={'argv': [SUITE]}) + + def test_complete_argv_evidence_passes_but_live_is_not_run(self): + self.xml() + report = self.report() + self.assertEqual('PASS', report['status']) + self.assertEqual(2, report['layers']['argv']['executed']) + self.assertEqual('NOT_RUN', report['layers']['real-execution']['status']) + self.assertEqual(HEAD, report['head']) + json.dumps(report) + + def test_missing_report_fails(self): + self.assertEqual('FAIL', self.report()['status']) + + def test_zero_tests_fails(self): + self.xml(tests=0) + self.assertEqual('FAIL', self.report()['status']) + + def test_all_skipped_fails(self): + self.xml(tests=2, skipped=2) + self.assertEqual('FAIL', self.report()['status']) + + def test_partly_skipped_fails(self): + self.xml(tests=2, skipped=1) + self.assertEqual('FAIL', self.report()['status']) + + def test_failure_fails(self): + self.xml(failures=1) + self.assertEqual('FAIL', self.report()['status']) + + def test_error_fails(self): + self.xml(errors=1) + self.assertEqual('FAIL', self.report()['status']) + + def test_nonzero_command_exit_fails_despite_green_xml(self): + self.xml() + self.assertEqual('FAIL', self.report(exit_code=1)['status']) + + def test_malformed_xml_fails_closed(self): + (self.root / 'TEST-broken.xml').write_text('', '', 1), encoding='utf-8') + self.assertEqual('FAIL', self.report()['status']) + + +if __name__ == '__main__': + unittest.main() diff --git a/src/test/java/io/github/easy4j/opencli/contract/ContractProbe.java b/src/test/java/io/github/easy4j/opencli/contract/ContractProbe.java new file mode 100644 index 0000000..3c50a12 --- /dev/null +++ b/src/test/java/io/github/easy4j/opencli/contract/ContractProbe.java @@ -0,0 +1,16 @@ +package io.github.easy4j.opencli.contract; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +/** Offline child process: emits every actual JVM argument, including empty values. */ +public final class ContractProbe { + private ContractProbe() { } + + public static void main(String[] args) { + System.out.println("argc:" + args.length); + for (String arg : args) { + System.out.println("arg:" + Base64.getEncoder().encodeToString(arg.getBytes(StandardCharsets.UTF_8))); + } + } +} diff --git a/src/test/java/io/github/easy4j/opencli/contract/OpenCliArgvContractTest.java b/src/test/java/io/github/easy4j/opencli/contract/OpenCliArgvContractTest.java new file mode 100644 index 0000000..e50a631 --- /dev/null +++ b/src/test/java/io/github/easy4j/opencli/contract/OpenCliArgvContractTest.java @@ -0,0 +1,171 @@ +package io.github.easy4j.opencli.contract; + +import io.github.easy4j.opencli.OpenCliProperties; +import io.github.easy4j.opencli.browser.OpenCliBrowserClient; +import io.github.easy4j.opencli.core.OpenCliAdapterChannel; +import io.github.easy4j.opencli.core.OpenCliAdapterCommandRequest; +import io.github.easy4j.opencli.core.OpenCliArgSupport; +import io.github.easy4j.opencli.core.OpenCliExecutor; +import io.github.easy4j.opencli.core.OpenCliResult; +import java.io.BufferedReader; +import java.io.File; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.DynamicTest; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; +import static org.junit.jupiter.api.Assertions.*; + +/** Shared Java 8-compatible contract tests, with a real child rather than Recording executor. */ +class OpenCliArgvContractTest { + private static OpenCliProperties properties() { + OpenCliProperties p = new OpenCliProperties(); + String exe = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java"; + p.setExecutable(new File(new File(System.getProperty("java.home"), "bin"), exe).getAbsolutePath()); + p.setLeadingArguments(new ArrayList<>(Arrays.asList("-cp", + System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")), + ContractProbe.class.getName()))); + p.setCommandTimeoutMillis(10000L); + return p; + } + + private static List received(OpenCliResult result) throws Exception { + assertTrue(result.isSuccess()); + List actual = new ArrayList<>(); + try (BufferedReader reader = new BufferedReader(new StringReader(result.getStdout()))) { + String header = reader.readLine(); + assertNotNull(header, "child produced no argv evidence"); + assertTrue(header.startsWith("argc:"), "missing child protocol header"); + int count = Integer.parseInt(header.substring(5)); + for (int i = 0; i < count; i++) { + String line = reader.readLine(); + assertNotNull(line, "incomplete child output"); + assertTrue(line.startsWith("arg:")); + actual.add(new String(Base64.getDecoder().decode(line.substring(4)), StandardCharsets.UTF_8)); + } + assertNull(reader.readLine(), "unexpected extra child output"); + } + return actual; + } + + @TestFactory + Stream sharedVectorsReachEveryRawEntryPoint() throws Exception { + List tests = new ArrayList<>(); + InputStream resource = getClass().getResourceAsStream("/opencli-contracts/v1/argv.tsv"); + assertNotNull(resource, "shared fixture is required"); + try (BufferedReader reader = new BufferedReader(new InputStreamReader(resource, StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + String[] fields = line.split("\t", -1); + List expected = new ArrayList<>(); + for (int i = 1; i < fields.length; i++) { + expected.add(new String(Base64.getDecoder().decode(fields[i]), StandardCharsets.UTF_8)); + } + for (int mode = 0; mode < 4; mode++) { + final int entry = mode; + tests.add(DynamicTest.dynamicTest(fields[0] + "/entry-" + mode, () -> { + OpenCliExecutor executor = new OpenCliExecutor(properties()); + List before = new ArrayList<>(expected); + OpenCliResult result; + if (entry == 0) { + result = executor.invoke(expected); + } else if (entry == 1) { + result = executor.invoke(expected.toArray(new String[0])); + } else { + OpenCliAdapterChannel channel = new OpenCliAdapterChannel(executor, expected.get(0)); + List rest = expected.subList(1, expected.size()); + result = entry == 2 ? channel.invoke(rest) : channel.invoke(rest.toArray(new String[0])); + } + assertEquals(before, expected, "caller list changed"); + assertEquals(expected, received(result), "full argv changed at child boundary"); + })); + } + } + } + assertEquals(24, tests.size(), "fixture denominator changed; review sources.lock.json"); + return tests.stream(); + } + + @Test + void structuredPositionalsAndValuesReachChildUnchanged() throws Exception { + Map options = new LinkedHashMap<>(); + options.put("text", " value "); + OpenCliAdapterCommandRequest request = OpenCliAdapterCommandRequest.builder() + .subcommand("echo").positional("").positional(" positional ").options(options).build(); + assertEquals(Arrays.asList("demo", "echo", "", " positional ", "--text", " value "), + received(new OpenCliAdapterChannel(new OpenCliExecutor(properties()), "demo").invoke(request))); + } + + @Test + void typedBrowserFillCanClearAField() throws Exception { + OpenCliBrowserClient browser = new OpenCliBrowserClient(new OpenCliExecutor(properties())); + assertEquals(Arrays.asList("browser", "contract", "fill", "#input", ""), + received(browser.session("contract").fill("#input", "", null, null))); + } + + @Test + void rawMergePreservesEmptyAndPaddedValues() throws Exception { + List prefix = Arrays.asList("demo", "echo", ""); + List extra = Arrays.asList(" x ", "--", "-literal"); + assertEquals(Arrays.asList("demo", "echo", "", " x ", "--", "-literal"), + received(new OpenCliExecutor(properties()).invoke(OpenCliArgSupport.merge(prefix, extra)))); + assertEquals(Arrays.asList("demo", "echo", ""), prefix); + } + + @Test + void leadingArgumentsPreserveEmptyAndPaddedValues() throws Exception { + OpenCliProperties p = properties(); + p.getLeadingArguments().add(""); + p.getLeadingArguments().add(" leading "); + assertEquals(Arrays.asList("", " leading ", "demo"), + received(new OpenCliExecutor(p).invoke("demo"))); + } + + @Test + void nullRawTokenIsRejectedWithoutLeakingOtherTokens() { + OpenCliExecutor executor = new OpenCliExecutor(properties()); + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, + () -> executor.invoke(Arrays.asList("demo", "SECRET_MARKER", null))); + assertTrue(error.getMessage().contains("2"), "validation must identify null index"); + assertFalse(error.getMessage().contains("SECRET_MARKER")); + } + + @Test + void blankCommandIdentifierIsRejectedBeforeSpawn() { + OpenCliExecutor executor = new OpenCliExecutor(properties()); + assertThrows(IllegalArgumentException.class, () -> executor.invoke(Arrays.asList("", "demo"))); + assertThrows(IllegalArgumentException.class, () -> OpenCliAdapterCommandRequest.builder() + .subcommand(" ").build().toSubcommandAndArgs()); + } + + @Test + void nullVarargsContainerIsRejectedConsistently() { + OpenCliExecutor executor = new OpenCliExecutor(properties()); + assertThrows(NullPointerException.class, () -> executor.invoke((String[]) null)); + assertThrows(NullPointerException.class, + () -> new OpenCliAdapterChannel(executor, "demo").invoke((String[]) null)); + } + + @Test + void nullLeadingTokenIsRejectedBeforeSpawn() { + OpenCliProperties p = properties(); + p.getLeadingArguments().add(null); + assertThrows(IllegalArgumentException.class, () -> new OpenCliExecutor(p).invoke("demo")); + } + + @Test + void nullMergedTokenIsRejected() { + assertThrows(IllegalArgumentException.class, + () -> OpenCliArgSupport.merge(Arrays.asList("demo", null), Collections.emptyList())); + } +} diff --git a/src/test/resources/opencli-contracts/v1/SHA256SUMS b/src/test/resources/opencli-contracts/v1/SHA256SUMS new file mode 100644 index 0000000..c7a5e08 --- /dev/null +++ b/src/test/resources/opencli-contracts/v1/SHA256SUMS @@ -0,0 +1,2 @@ +e7e6dd57e81e24e92a7cb0de0e1204f95025f80310f5dc34d820aa7419636f78 argv.tsv +2fa30626c415bb3c152bc4c2827cd9282ba710654f6a56fb7bde70a83b2bc192 sources.lock.json diff --git a/src/test/resources/opencli-contracts/v1/argv.tsv b/src/test/resources/opencli-contracts/v1/argv.tsv new file mode 100644 index 0000000..bb4ce3a --- /dev/null +++ b/src/test/resources/opencli-contracts/v1/argv.tsv @@ -0,0 +1,6 @@ +ordinary ZGVtbw== ZWNobw== aGVsbG8= +empty ZGVtbw== ZWNobw== +whitespace ZGVtbw== ZWNobw== ICB4ICA= ICAg +unicode-newline ZGVtbw== ZWNobw== CuS4reaWhwo= 8J+Zgg== +literal-shell ZGVtbw== ZWNobw== JChwcmludGYgU0hPVUxEX05PVF9SVU4pOyAq LS0= LWxpdGVyYWw= +equals-quotes ZGVtbw== ZWNobw== LS1rZXk9YT1i ImxpdGVyYWwi Qzpc6Lev5b6EXGZpbGUgbmFtZQ== diff --git a/src/test/resources/opencli-contracts/v1/sources.lock.json b/src/test/resources/opencli-contracts/v1/sources.lock.json new file mode 100644 index 0000000..a1ed16b --- /dev/null +++ b/src/test/resources/opencli-contracts/v1/sources.lock.json @@ -0,0 +1,58 @@ +{ + "schemaVersion": 1, + "observedAt": "2026-09-21", + "scope": "Synthetic offline argv vectors; not captures from OpenCLI or websites.", + "source": { + "kind": "specification", + "requirements": [ + "OC-ARGV-001", + "OC-ARGV-002", + "OC-ARGV-004", + "OC-ARGV-005" + ], + "ref": "d0c8056990f7a47fcc202acffa387ba066bcfc67", + "path": "openspec/changes/harden-opencli-argv-contract/specs/opencli-argv-contract/spec.md" + }, + "files": [ + { + "path": "argv.tsv", + "sha256": "e7e6dd57e81e24e92a7cb0de0e1204f95025f80310f5dc34d820aa7419636f78", + "vectors": 6, + "encoding": "case-id then tab-separated base64 UTF-8 tokens; preserve trailing empty fields" + } + ], + "branches": [ + { + "branch": "feature/1.0.x", + "base": "abba809f11dae68437c39d2ea5a2f4cf8798c0ef", + "java": 8, + "jackson": 2, + "mavenMajor": 3 + }, + { + "branch": "feature/2.0.x", + "base": "d0c8056990f7a47fcc202acffa387ba066bcfc67", + "java": 17, + "jackson": 2, + "mavenMajor": 3, + "canonical": true + }, + { + "branch": "feature/3.0.x", + "base": "6e38904bdfcae90ec617e8d29bf3d8cf2f002893", + "java": 21, + "jackson": 3, + "mavenMajor": 4 + } + ], + "allowedDifferences": [ + "JDK baseline", + "Jackson imports/decoder internals", + "Maven wrapper/POM schema" + ], + "forbiddenDifferences": [ + "raw token contents/order/count", + "null validation", + "observable semantics of shared vectors" + ] +} From 94de053f4ae7a0912201c2bbeab81b8332f660b8 Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Mon, 21 Sep 2026 12:47:42 +0800 Subject: [PATCH 02/13] fix(ci): restore wrapper launcher and preserve contract evidence artifacts The 2.x script invokes MavenWrapperMain but its launcher JAR was absent. Reuse the existing repository 3.x launcher blob; retain Maven 3.9.16 distribution. Use non-hidden scoped evidence directories so upload-artifact can find reports. No product-source changes; the C01 regression suite remains the RED checkpoint. --- .github/workflows/contracts.yml | 48 ++++++++++++++------------ .mvn/wrapper/maven-wrapper.jar | Bin 0 -> 50710 bytes .mvn/wrapper/maven-wrapper.properties | 2 +- 3 files changed, 27 insertions(+), 23 deletions(-) create mode 100644 .mvn/wrapper/maven-wrapper.jar mode change 100755 => 100644 .mvn/wrapper/maven-wrapper.properties diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 4c53c65..5f9c448 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -36,22 +36,22 @@ jobs: shell: bash run: | export PATH="$RUNNER_TEMP/opencli-openspec/node_modules/.bin:$PATH" - mkdir -p .spec-evidence - openspec --version | tee .spec-evidence/version.txt - openspec list --json > .spec-evidence/list.json + mkdir -p spec-evidence + openspec --version | tee spec-evidence/version.txt + openspec list --json > spec-evidence/list.json failed=0 for path in openspec/changes/*; do [[ -d "$path" && "$(basename "$path")" != archive ]] || continue change="$(basename "$path")" - if openspec validate "$change" --strict --no-interactive > ".spec-evidence/$change.log" 2>&1; then + if openspec validate "$change" --strict --no-interactive > "spec-evidence/$change.log" 2>&1; then printf '%s\tPASS\n' "$change" else - cat ".spec-evidence/$change.log" + cat "spec-evidence/$change.log" failed=1 fi done - if ! openspec validate --all --strict --no-interactive > .spec-evidence/all.log 2>&1; then - cat .spec-evidence/all.log + if ! openspec validate --all --strict --no-interactive > spec-evidence/all.log 2>&1; then + cat spec-evidence/all.log failed=1 fi exit "$failed" @@ -60,7 +60,7 @@ jobs: uses: actions/upload-artifact@v7 with: name: openspec-strict-${{ github.sha }} - path: .spec-evidence + path: spec-evidence if-no-files-found: error jvm: @@ -86,41 +86,45 @@ jobs: cache: maven - name: Record exact source and actual tools run: | - mkdir -p .contract-evidence - git rev-parse HEAD > .contract-evidence/head.txt - java -version > .contract-evidence/java.txt 2>&1 - bash ./mvnw -version > .contract-evidence/maven.txt 2>&1 + mkdir -p contract-evidence + git rev-parse HEAD > contract-evidence/head.txt + java -version > contract-evidence/java.txt 2>&1 + if ! bash ./mvnw -version > contract-evidence/maven.txt 2>&1; then + cat contract-evidence/maven.txt + exit 1 + fi + cat contract-evidence/java.txt contract-evidence/maven.txt - name: Full clean verify using this branch wrapper id: verify shell: bash run: | set +e - bash ./mvnw -B --no-transfer-progress clean verify > .contract-evidence/maven.log 2>&1 + bash ./mvnw -B --no-transfer-progress clean verify > contract-evidence/maven.log 2>&1 result=$? - printf '%s\n' "$result" > .contract-evidence/maven.exit - tail -100 .contract-evidence/maven.log + printf '%s\n' "$result" > contract-evidence/maven.exit + tail -100 contract-evidence/maven.log exit "$result" - name: Require nonempty, non-skipped contract evidence if: always() shell: bash run: | - test -f .contract-evidence/maven.exit + test -f contract-evidence/maven.exit python3 scripts/contract_report.py \ --reports target/surefire-reports \ - --head "$(cat .contract-evidence/head.txt)" \ + --head "$(cat contract-evidence/head.txt)" \ --branch "$GITHUB_REF_NAME" \ - --java-version-file .contract-evidence/java.txt \ - --maven-version-file .contract-evidence/maven.txt \ - --exit-code "$(cat .contract-evidence/maven.exit)" \ + --java-version-file contract-evidence/java.txt \ + --maven-version-file contract-evidence/maven.txt \ + --exit-code "$(cat contract-evidence/maven.exit)" \ --suite argv=io.github.easy4j.opencli.contract.OpenCliArgvContractTest \ - --output .contract-evidence/report.json + --output contract-evidence/report.json - name: Upload actual JVM evidence if: always() uses: actions/upload-artifact@v7 with: name: contracts-jdk-${{ steps.line.outputs.java }}-${{ github.sha }} path: | - .contract-evidence + contract-evidence target/surefire-reports target/site/jacoco if-no-files-found: error diff --git a/.mvn/wrapper/maven-wrapper.jar b/.mvn/wrapper/maven-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..2cc7d4a55c0cd0092912bf49ae38b3a9e3fd0054 GIT binary patch literal 50710 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 EfAhh=DF6Tf literal 0 HcmV?d00001 diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties old mode 100755 new mode 100644 index 216df05..27e7135 --- a/.mvn/wrapper/maven-wrapper.properties +++ b/.mvn/wrapper/maven-wrapper.properties @@ -1,3 +1,3 @@ wrapperVersion=3.3.4 -distributionType=only-script +distributionType=bin distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip From c93a25c182f0b0acd15cdfa01089599b52601854 Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Mon, 21 Sep 2026 12:54:22 +0800 Subject: [PATCH 03/13] fix(argv): preserve literal values across executor adapter and typed paths Observed RED at 94de053: 33 real-child contract tests, 21 failures, 0 errors and 0 skips; full suite 2027 tests. Snapshot submitted tokens, reject null elements without disclosing values, preserve empty/padded/newline arguments and validate blank command identifiers. Align two legacy filtering assertions with OC-ARGV-001 without removing tests. Ordered schema-aware options and remaining C01 closure are still pending. --- .../opencli/core/OpenCliAdapterChannel.java | 84 ++++--------- .../core/OpenCliAdapterCommandRequest.java | 62 +++------- .../opencli/core/OpenCliArgSupport.java | 80 ++++++------ .../easy4j/opencli/core/OpenCliExecutor.java | 116 ++++++------------ .../OpenCliAdapterCommandRequestTest.java | 4 +- .../opencli/core/OpenCliArgSupportTest.java | 12 +- 6 files changed, 135 insertions(+), 223 deletions(-) diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterChannel.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterChannel.java index 8d2bbe1..062d73f 100644 --- a/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterChannel.java +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterChannel.java @@ -2,34 +2,27 @@ import io.github.easy4j.opencli.util.OpenCliStrings; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Objects; import lombok.extern.slf4j.Slf4j; /** - * 针对单个 OpenCLI adapter 的轻量通道:自动在 argv 前插入 adapter id。 - */ -@Slf4j/** - - * Lightweight channel for a single OpenCLI adapter: automatically prepends the adapter id - * to every argv invocation. - + * Lightweight channel for one OpenCLI adapter. Values are literal argv tokens; + * only the separately supplied adapter identifier is normalized. * - * @author Loong Wan - * @since 3.0.0 - */ - +@Slf4j public final class OpenCliAdapterChannel { private final OpenCliExecutor executor; private final String adapterId; /** - * @param executor 共享执行器,不得为 null - * @param adapterId 文档中的 adapter 名(如 {@code twitter}),不得为空白 + * @param executor shared executor + * @param adapterId nonblank adapter identifier */ public OpenCliAdapterChannel(OpenCliExecutor executor, String adapterId) { this.executor = Objects.requireNonNull(executor, "executor"); @@ -39,37 +32,31 @@ public OpenCliAdapterChannel(OpenCliExecutor executor, String adapterId) { } } - /** - * @return 当前通道绑定的 adapter id - */ + /** @return this channel's adapter identifier */ public String getAdapterId() { return adapterId; } /** - * 调用 {@code opencli }。 + * Invoke an adapter with a snapshot of the supplied literal arguments. + * An empty list invokes its root; null elements are rejected. * - * @param subcommandAndArgs 子命令及后续参数;不得为 null,可为空(仅调 adapter 根命令时) - * @return 成功时的 {@link OpenCliResult} + * @param subcommandAndArgs subcommand and subsequent values + * @return execution result */ public OpenCliResult invoke(List subcommandAndArgs) { - Objects.requireNonNull(subcommandAndArgs, "subcommandAndArgs"); - List tokens = new ArrayList<>(); + List rest = OpenCliArgSupport.snapshotValues(subcommandAndArgs, "subcommandAndArgs"); + List tokens = new ArrayList<>(rest.size() + 1); tokens.add(adapterId); - for (String s : subcommandAndArgs) { - if (OpenCliStrings.isNotBlank(s)) { - tokens.add(s.trim()); - } - } - log.debug("OpenCLI adapter invoke adapterId={} subcommandSummary={}", adapterId, summarizeSubcommand(tokens)); + tokens.addAll(rest); + // Do not put prompt text or positional values in default diagnostic logs. + log.debug("OpenCLI adapter invoke argvSize={}", tokens.size()); return executor.invoke(tokens); } /** - * 通过 {@link OpenCliAdapterCommandRequest} 发起调用(推荐测试与 SDK 侧结构化入口)。 - * - * @param request 结构化子命令请求,不得为 null - * @return 执行结果 + * @param request structured command request + * @return execution result */ public OpenCliResult invoke(OpenCliAdapterCommandRequest request) { Objects.requireNonNull(request, "request"); @@ -77,38 +64,11 @@ public OpenCliResult invoke(OpenCliAdapterCommandRequest request) { } /** - * {@link #invoke(List)} 的可变参数形式。 - * - * @param subcommandAndArgs 子命令及 flag - * @return 执行结果 + * @param subcommandAndArgs subcommand and literal values + * @return execution result */ public OpenCliResult invoke(String... subcommandAndArgs) { - List list = new ArrayList<>(); - if (Objects.nonNull(subcommandAndArgs)) { - for (String s : subcommandAndArgs) { - if (OpenCliStrings.isNotBlank(s)) { - list.add(s.trim()); - } - } - } - return invoke(list); - } - - private static String summarizeSubcommand(List tokens) { - if (tokens.size() <= 1) { - return "(root)"; - } - int limit = Math.min(tokens.size(), 4); - StringBuilder sb = new StringBuilder(); - for (int i = 1; i < limit; i++) { - if (i > 1) { - sb.append(' '); - } - sb.append(tokens.get(i)); - } - if (tokens.size() > limit) { - sb.append(" ..."); - } - return sb.toString(); + Objects.requireNonNull(subcommandAndArgs, "subcommandAndArgs"); + return invoke(Arrays.asList(subcommandAndArgs)); } } diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequest.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequest.java index eea957d..6ea38da 100644 --- a/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequest.java +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequest.java @@ -13,33 +13,18 @@ import lombok.Singular; /** - * 结构化 adapter 子命令请求:由子命令名、positional 参数与命名 options 构建 argv, - * 供 {@link OpenCliAdapterChannel#invoke(OpenCliAdapterCommandRequest)} 及覆盖测试使用。 - *

- * 禁止在测试中手工拼接 {@code List.of("sub", "--flag", "value")};应通过 builder 建模参数。 - *

- */ -@Getter -@Builder/** - - * Structured adapter subcommand request: builds argv from a subcommand name, - * positional arguments, and named options for use with - * {@link OpenCliAdapterChannel#invoke(OpenCliAdapterCommandRequest)}. - * - *

Avoid manually assembling {@code List.of("sub", "--flag", "value")} in tests; - * use the builder to model parameters instead.

- + * Structured adapter request. Positional and valued-option contents are literal; + * command and option identifiers are validated separately. + * The legacy options map represents a single-valued subset: Boolean values + * retain their historical presence-only flag semantics. * - * @author Loong Wan - * @since 3.0.0 - */ - +@Getter +@Builder public final class OpenCliAdapterCommandRequest { - /** 子命令名(不含 adapter id)。 */ private final String subcommand; @Getter(AccessLevel.NONE) @@ -50,9 +35,7 @@ public final class OpenCliAdapterCommandRequest { @Builder.Default private final Map options = Collections.emptyMap(); - /** - * @return positional 参数副本 - */ + /** @return an immutable copy of positional values */ public List getPositionals() { if (Objects.isNull(positionals)) { return Collections.emptyList(); @@ -60,9 +43,7 @@ public List getPositionals() { return Collections.unmodifiableList(new ArrayList<>(positionals)); } - /** - * @return 命名选项副本 - */ + /** @return an immutable copy of named options */ public Map getOptions() { if (Objects.isNull(options)) { return Collections.emptyMap(); @@ -71,20 +52,17 @@ public Map getOptions() { } /** - * 将本请求转换为 {@link OpenCliAdapterChannel#invoke(List)} 所需的 token 列表。 - * - * @return 以 subcommand 开头、随后 positional、再 options 的 argv 片段 + * @return subcommand, then unchanged positional values and named options */ public List toSubcommandAndArgs() { Objects.requireNonNull(subcommand, "subcommand"); + if (OpenCliStrings.isBlank(subcommand)) { + throw new IllegalArgumentException("subcommand must not be blank"); + } List tokens = new ArrayList<>(); tokens.add(subcommand.trim()); - if (Objects.nonNull(positionals)) { - for (String p : positionals) { - if (OpenCliStrings.isNotBlank(p)) { - tokens.add(p.trim()); - } - } + if (positionals != null) { + tokens.addAll(OpenCliArgSupport.snapshotValues(positionals, "positionals")); } if (Objects.nonNull(options)) { for (Map.Entry entry : options.entrySet()) { @@ -106,16 +84,14 @@ private static void appendOption(List target, String name, Object value) return; } target.add(flag); - target.add(String.valueOf(value).trim()); + target.add(String.valueOf(value)); } /** - * 从 manifest 风格的 options map 创建请求(测试资源反序列化辅助)。 - * - * @param subcommand 子命令 - * @param positionals positional 列表,可为 null - * @param options 选项 map,可为 null - * @return 请求实例 + * @param subcommand command identifier + * @param positionals optional positional values + * @param options optional legacy single-value options + * @return a structured request */ public static OpenCliAdapterCommandRequest of( String subcommand, diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliArgSupport.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliArgSupport.java index 34c3620..dc5cc9f 100644 --- a/src/main/java/io/github/easy4j/opencli/core/OpenCliArgSupport.java +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliArgSupport.java @@ -1,55 +1,61 @@ package io.github.easy4j.opencli.core; -import io.github.easy4j.opencli.util.OpenCliStrings; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Objects; /** * CLI argument assembly utilities: merges business segments with pass-through - * {@code additionalRawArgs}. + * {@code additionalRawArgs} without changing argument values. * * @author Loong Wan * @since 3.0.0 - */public final class OpenCliArgSupport { + */ +public final class OpenCliArgSupport { private OpenCliArgSupport() { } /** - * 将前缀片段与可选附加片段合并为连续 argv(过滤 null/空白)。 + * Capture values before execution or queuing. Validate indices without exposing other values. + */ + static List snapshotValues(List values, String field) { + Objects.requireNonNull(values, field); + List copy = new ArrayList<>(values); + for (int i = 0; i < copy.size(); i++) { + if (copy.get(i) == null) { + throw new IllegalArgumentException(field + "[" + i + "] must not be null"); + } + } + return Collections.unmodifiableList(copy); + } + + /** + * Merge optional segments into a new argv list. Null segments are absent; + * null elements are invalid, while empty and whitespace-only values are preserved. * - * @param prefix 子命令与已建模参数,可为 null - * @param additionalRawArgs 额外原生参数,可为 null - * @return 新列表副本 + * @param prefix command and modeled arguments, or null + * @param additionalRawArgs extra literal arguments, or null + * @return a new list, without modifying either source */ public static List merge(List prefix, List additionalRawArgs) { List out = new ArrayList<>(); - if (Objects.nonNull(prefix)) { - for (String s : prefix) { - if (OpenCliStrings.isNotBlank(s)) { - out.add(s.trim()); - } - } + if (prefix != null) { + out.addAll(snapshotValues(prefix, "prefix")); } - if (Objects.nonNull(additionalRawArgs)) { - for (String s : additionalRawArgs) { - if (OpenCliStrings.isNotBlank(s)) { - out.add(s.trim()); - } - } + if (additionalRawArgs != null) { + out.addAll(snapshotValues(additionalRawArgs, "additionalRawArgs")); } return out; } /** - * 追加 {@code --name=value}(value 含空格时由调用方决定是否使用 - * {@link OpenCliExecutor#appendQuotedKeyValue(CommandLine, String, String)}; - * 此处仅做简单拼接)。 + * Append one literal {@code --name=value} token. * - * @param target 目标列表,不得为 null - * @param name 完整名称(含 {@code --},不含 {@code =}) - * @param value 非空值 + * @param target destination list + * @param name full option name, including {@code --} + * @param value non-null value, which may be empty */ public static void addOptionEquals(List target, String name, String value) { Objects.requireNonNull(target, "target"); @@ -63,11 +69,11 @@ public static void addOptionEquals(List target, String name, String valu } /** - * 追加 {@code --flag value} 双 token 形式。 + * Append a {@code --flag value} pair without altering the value. * - * @param target 目标列表 - * @param flag 如 {@code --limit} - * @param value 非空值 + * @param target destination list + * @param flag option name + * @param value non-null value, which may be empty */ public static void addOptionPair(List target, String flag, String value) { Objects.requireNonNull(target, "target"); @@ -78,11 +84,11 @@ public static void addOptionPair(List target, String flag, String value) } /** - * 当 {@code value} 非 null 时追加 {@code --flag value}。 + * Append a pair when the optional value is non-null. * - * @param target 目标 argv 列表 - * @param flag 选项名 - * @param value 可为 null + * @param target destination list + * @param flag option name + * @param value optional value */ public static void addOptionPairIfPresent(List target, String flag, Object value) { if (Objects.nonNull(value)) { @@ -91,11 +97,11 @@ public static void addOptionPairIfPresent(List target, String flag, Obje } /** - * 当 {@code enabled} 为 {@code true} 时追加 boolean flag(无值)。 + * Append a presence-only flag when enabled. This helper is not a valued boolean option. * - * @param target 目标 argv 列表 - * @param flag 如 {@code --follow} - * @param enabled 开关,null/false 时不追加 + * @param target destination list + * @param flag option name + * @param enabled true to append; null/false to omit */ public static void addFlagIfTrue(List target, String flag, Boolean enabled) { if (Boolean.TRUE.equals(enabled)) { diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java index f313578..edb7c6a 100644 --- a/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java @@ -16,7 +16,7 @@ import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -29,83 +29,64 @@ import org.apache.commons.exec.ExecuteWatchdog; /** - * 基于 Apache Commons Exec 的 OpenCLI 子进程执行封装。 - *

- * {@link #invoke(List)} 接受的参数为「紧跟可执行名之后」的完整 token 列表,形如 - * {@code [adapter, subcommand, ...]};本地模式下会自动拼接 {@link OpenCliProperties} 的 - * {@code leadingArguments}。 - *

- *

- * 当 {@link OpenCliProperties} 的 {@code executionTarget} 为 - * {@link OpenCliExecutionTarget#REMOTE_AGENT_HTTP} 时, - * 通过 {@link OpenCliRemoteAgentHttpClient} 调用远端 {@code POST /collect};此时 {@code leadingArguments} 不参与请求, - * argv 会被解析为 {@link OpenCliCollectRequest}(与 opencli-admin {@code agent_server} 契约一致)。 - *

- */ -@Slf4j -@Getter/** - * OpenCLI subprocess execution wrapper based on Apache Commons Exec. * - *

{@link #invoke(List)} accepts a token list that follows the executable name, - * typically {@code [adapter, subcommand, ...]}. In local mode, {@link OpenCliProperties} - * {@code leadingArguments} are automatically prepended.

+ *

{@link #invoke(List)} accepts literal argv tokens following the executable name. + * Local invocation prepends {@link OpenCliProperties#getLeadingArguments()}. + * Empty and whitespace-only values are preserved; null tokens are rejected before + * transport selection. Command identifiers are validated separately.

* - *

When {@link OpenCliProperties#getExecutionTarget()} is - * {@link OpenCliExecutionTarget#REMOTE_AGENT_HTTP}, the invocation is forwarded to a - * remote Agent via {@link OpenCliRemoteAgentHttpClient#collect(OpenCliCollectRequest)}.

- + *

When the execution target is {@link OpenCliExecutionTarget#REMOTE_AGENT_HTTP}, + * invocation uses the legacy opencli-admin {@code /collect} contract. That protocol's + * representation limits are separate from the local raw argv contract.

* - * @author Loong Wan - * @since 3.0.0 - */ - +@Slf4j +@Getter public class OpenCliExecutor { private final OpenCliProperties properties; - /** - * 懒加载,仅远程模式使用。 - */ + /** Lazily initialized for remote mode. */ private volatile OpenCliRemoteAgentHttpClient remoteAgentHttpClient; - /** - * @param properties 运行时配置,不得为 null - */ + /** @param properties runtime configuration */ public OpenCliExecutor(OpenCliProperties properties) { this.properties = Objects.requireNonNull(properties, "properties"); SubprocessExecutionSupport.configureMaxConcurrentExecutions(properties.getMaxConcurrentExecutions()); } /** - * 执行 {@code opencli ...} 完整 argv(不含可执行文件本身)。 + * Invoke a snapshot of the complete argv vector, excluding the executable. * - * @param adapterAndRest 至少包含 adapter 名,后续为子命令与 flag;不得为 null - * @return 包含成功标记的执行结果 + * @param adapterAndRest nonempty command and literal values + * @return execution result */ public OpenCliResult invoke(List adapterAndRest) { - Objects.requireNonNull(adapterAndRest, "adapterAndRest"); + List tokens = OpenCliArgSupport.snapshotValues(adapterAndRest, "adapterAndRest"); + if (tokens.isEmpty()) { + throw new IllegalArgumentException("adapterAndRest must contain at least the command identifier"); + } + if (OpenCliStrings.isBlank(tokens.get(0))) { + throw new IllegalArgumentException("adapterAndRest[0] command identifier must not be blank"); + } if (properties.getExecutionTarget() == OpenCliExecutionTarget.REMOTE_AGENT_HTTP) { - log.debug("OpenCLI invoke remote agent argvSize={}", adapterAndRest.size()); + log.debug("OpenCLI invoke remote agent argvSize={}", tokens.size()); OpenCliCollectRequest req = OpenCliArgvToCollectParser.parse( - adapterAndRest, + tokens, properties.getRemoteOutputFormat(), properties.getRemoteCollectMode(), properties.getRemoteCdpEndpoint()); return remoteAgent().collect(req); } - log.debug("OpenCLI invoke local argvSize={}", adapterAndRest.size()); - CommandLine cmd = buildCommandLine(adapterAndRest); + log.debug("OpenCLI invoke local argvSize={}", tokens.size()); + CommandLine cmd = buildCommandLine(tokens); return run(cmd); } - /** - * @return 远程 Agent HTTP 客户端(懒加载) - */ private OpenCliRemoteAgentHttpClient remoteAgent() { if (Objects.isNull(remoteAgentHttpClient)) { synchronized (this) { @@ -118,57 +99,40 @@ private OpenCliRemoteAgentHttpClient remoteAgent() { } /** - * 便捷重载:可变参数形式。 - * - * @param adapterAndRest adapter 及后续 CLI token - * @return 执行结果 + * @param adapterAndRest command and literal values + * @return execution result */ public OpenCliResult invoke(String... adapterAndRest) { - List list = new ArrayList<>(); - if (Objects.nonNull(adapterAndRest)) { - for (String s : adapterAndRest) { - if (OpenCliStrings.isNotBlank(s)) { - list.add(s.trim()); - } - } - } - return invoke(list); + Objects.requireNonNull(adapterAndRest, "adapterAndRest"); + return invoke(Arrays.asList(adapterAndRest)); } - /** - * 拼装 {@link CommandLine}:executable + leading + tokens。 - */ private CommandLine buildCommandLine(List adapterAndRest) { - if (adapterAndRest.isEmpty()) { - throw new IllegalArgumentException("adapterAndRest must contain at least the adapter id"); - } String exe = properties.getExecutable(); if (OpenCliStrings.isBlank(exe)) { throw new IllegalStateException("opencli.executable must not be blank"); } CommandLine cmd = new CommandLine(exe.trim()); - appendCleanArgs(cmd, properties.getLeadingArguments()); - appendCleanArgs(cmd, adapterAndRest); + appendLiteralArgs(cmd, properties.getLeadingArguments(), "leadingArguments"); + appendLiteralArgs(cmd, adapterAndRest, "adapterAndRest"); return cmd; } - private static void appendCleanArgs(CommandLine cmd, List args) { - if (Objects.isNull(args) || args.isEmpty()) { + private static void appendLiteralArgs(CommandLine cmd, List args, String field) { + if (args == null) { return; } - for (String a : args) { - if (OpenCliStrings.isNotBlank(a)) { - cmd.addArgument(a.trim(), false); - } + for (String value : OpenCliArgSupport.snapshotValues(args, field)) { + cmd.addArgument(value, false); } } /** - * 将 {@code --key=value} 以句柄安全形式追加(含空格时由 Commons Exec 处理)。 + * Append a {@code --key=value} token using Commons Exec quoting. * - * @param cmd 命令行 - * @param key 必须以 {@code --} 开头 - * @param value 非空值 + * @param cmd command line + * @param key option name starting with {@code --} + * @param value non-null value */ public static void appendQuotedKeyValue(CommandLine cmd, String key, String value) { Objects.requireNonNull(key, "key"); diff --git a/src/test/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequestTest.java b/src/test/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequestTest.java index 8f2c36b..d98b0f5 100644 --- a/src/test/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequestTest.java +++ b/src/test/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequestTest.java @@ -116,7 +116,7 @@ void shouldReturnImmutableOptions() { } @Test - void shouldSkipNullPositionalInToSubcommandAndArgs() { + void shouldPreserveEmptyPositionalInToSubcommandAndArgs() { OpenCliAdapterCommandRequest req = OpenCliAdapterCommandRequest.builder() .subcommand("sub") .positional("a") @@ -124,7 +124,7 @@ void shouldSkipNullPositionalInToSubcommandAndArgs() { .positional("b") .build(); List argv = req.toSubcommandAndArgs(); - assertEquals(Arrays.asList("sub", "a", "b"), argv); + assertEquals(Arrays.asList("sub", "a", "", "b"), argv); } @Test diff --git a/src/test/java/io/github/easy4j/opencli/core/OpenCliArgSupportTest.java b/src/test/java/io/github/easy4j/opencli/core/OpenCliArgSupportTest.java index f5efc59..977ec52 100644 --- a/src/test/java/io/github/easy4j/opencli/core/OpenCliArgSupportTest.java +++ b/src/test/java/io/github/easy4j/opencli/core/OpenCliArgSupportTest.java @@ -15,9 +15,15 @@ void shouldMergeNonNullLists() { } @Test - void shouldFilterNullAndBlankWhenMerging() { - List result = OpenCliArgSupport.merge(Arrays.asList("a", null, "", " ", "b"), Arrays.asList("c")); - assertEquals(Arrays.asList("a", "b", "c"), result); + void shouldRejectNullElementWhenMerging() { + assertThrows(IllegalArgumentException.class, + () -> OpenCliArgSupport.merge(Arrays.asList("a", null, "", " ", "b"), Arrays.asList("c"))); + } + + @Test + void shouldPreserveEmptyAndBlankWhenMerging() { + assertEquals(Arrays.asList("a", "", " ", "b", "c"), + OpenCliArgSupport.merge(Arrays.asList("a", "", " ", "b"), Arrays.asList("c"))); } @Test From 6f830b4f2e5b87bb57eb6b3b669fd299ee48ee39 Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Mon, 21 Sep 2026 12:57:47 +0800 Subject: [PATCH 04/13] fix(argv): bypass Commons Exec internal argument trimming The first repair left 11 real-child assertions failing because CommandLine.Argument trims its stored value even with handleQuoting=false. Preserve a separate literal vector at the native launch boundary; do not relax whitespace/newline assertions. No implicit shell parsing, quoting, or substitution is added. --- .../opencli/core/LiteralCommandLine.java | 46 +++++++++++++++++++ .../easy4j/opencli/core/OpenCliExecutor.java | 2 +- 2 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/main/java/io/github/easy4j/opencli/core/LiteralCommandLine.java diff --git a/src/main/java/io/github/easy4j/opencli/core/LiteralCommandLine.java b/src/main/java/io/github/easy4j/opencli/core/LiteralCommandLine.java new file mode 100644 index 0000000..0ed24e4 --- /dev/null +++ b/src/main/java/io/github/easy4j/opencli/core/LiteralCommandLine.java @@ -0,0 +1,46 @@ +package io.github.easy4j.opencli.core; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import org.apache.commons.exec.CommandLine; + +/** + * Internal literal argv bridge. Commons Exec's Argument constructor trims even + * when handleQuoting is false, so raw values cannot be stored in its argument list. + * Keep the original vector and supply it directly to the native launcher instead. + * + * @author Loong Wan + * @since 3.0.0 + */ +final class LiteralCommandLine extends CommandLine { + private final List literalArguments = new ArrayList<>(); + + LiteralCommandLine(String executable) { + super(executable); + } + + @Override + public CommandLine addArgument(String argument, boolean handleQuoting) { + if (handleQuoting) { + throw new IllegalArgumentException("Literal argv does not support implicit quoting"); + } + literalArguments.add(Objects.requireNonNull(argument, "argument")); + return this; + } + + @Override + public String[] getArguments() { + return literalArguments.toArray(new String[0]); + } + + @Override + public String[] toStrings() { + String[] result = new String[literalArguments.size() + 1]; + result[0] = getExecutable(); + for (int i = 0; i < literalArguments.size(); i++) { + result[i + 1] = literalArguments.get(i); + } + return result; + } +} diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java index edb7c6a..99b1abe 100644 --- a/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java @@ -112,7 +112,7 @@ private CommandLine buildCommandLine(List adapterAndRest) { if (OpenCliStrings.isBlank(exe)) { throw new IllegalStateException("opencli.executable must not be blank"); } - CommandLine cmd = new CommandLine(exe.trim()); + CommandLine cmd = new LiteralCommandLine(exe.trim()); appendLiteralArgs(cmd, properties.getLeadingArguments(), "leadingArguments"); appendLiteralArgs(cmd, adapterAndRest, "adapterAndRest"); return cmd; From d02b16ddc5e0fe7c1d98c6847f4b2999cf2f2566 Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Mon, 21 Sep 2026 13:00:34 +0800 Subject: [PATCH 05/13] test(argv): add ordered option and immutable request RED contracts Cover repeatable option occurrences, explicit valued false, positive/negative flags, nonrepeatable and legacy collisions, malformed schemas, and captured mutable inputs. Use public API reflection so absent API is an assertion failure, not compile failure. The existing 33 real-child contracts remain enabled. --- .../OpenCliStructuredArgvContractTest.java | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 src/test/java/io/github/easy4j/opencli/contract/OpenCliStructuredArgvContractTest.java diff --git a/src/test/java/io/github/easy4j/opencli/contract/OpenCliStructuredArgvContractTest.java b/src/test/java/io/github/easy4j/opencli/contract/OpenCliStructuredArgvContractTest.java new file mode 100644 index 0000000..c23685d --- /dev/null +++ b/src/test/java/io/github/easy4j/opencli/contract/OpenCliStructuredArgvContractTest.java @@ -0,0 +1,180 @@ +package io.github.easy4j.opencli.contract; + +import io.github.easy4j.opencli.OpenCliProperties; +import io.github.easy4j.opencli.core.OpenCliAdapterChannel; +import io.github.easy4j.opencli.core.OpenCliAdapterCommandRequest; +import io.github.easy4j.opencli.core.OpenCliExecutor; +import io.github.easy4j.opencli.core.OpenCliResult; +import java.io.File; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** Public binary/API contract: missing methods fail assertions rather than preventing the RED build. */ +class OpenCliStructuredArgvContractTest { + private static Class type(String simpleName) { + return assertDoesNotThrow(() -> Class.forName("io.github.easy4j.opencli.core." + simpleName), + "required ordered-option API is not implemented"); + } + + private static Object call(Class owner, Object receiver, String name, Class[] parameterTypes, Object... args) { + Method method = assertDoesNotThrow(() -> owner.getMethod(name, parameterTypes), + "required public method is not implemented: " + name); + try { + return method.invoke(receiver, args); + } catch (InvocationTargetException ex) { + Throwable cause = ex.getCause(); + if (cause instanceof RuntimeException) { + throw (RuntimeException) cause; + } + throw new AssertionError("unexpected checked failure", cause); + } catch (ReflectiveOperationException ex) { + throw new AssertionError("public API is inaccessible", ex); + } + } + + private static Object schema(String form, String name, boolean repeatable) { + Class owner = type("OpenCliOptionSchema"); + return "value".equals(form) + ? call(owner, null, form, new Class[]{String.class, boolean.class}, name, repeatable) + : call(owner, null, form, new Class[]{String.class}, name); + } + + private static Object value(Object definition, Object value) { + return call(type("OpenCliOption"), null, "value", + new Class[]{type("OpenCliOptionSchema"), Object.class}, definition, value); + } + + private static Object flag(String form, Object definition) { + return call(type("OpenCliOption"), null, form, + new Class[]{type("OpenCliOptionSchema")}, definition); + } + + private static void add(Object builder, Object occurrence) { + call(builder.getClass(), builder, "option", new Class[]{type("OpenCliOption")}, occurrence); + } + + private static List run(OpenCliAdapterCommandRequest request) { + OpenCliProperties p = new OpenCliProperties(); + String exe = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java"; + p.setExecutable(new File(new File(System.getProperty("java.home"), "bin"), exe).getAbsolutePath()); + p.setLeadingArguments(new ArrayList<>(Arrays.asList("-cp", + System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")), + ContractProbe.class.getName()))); + p.setCommandTimeoutMillis(10000L); + OpenCliResult result = new OpenCliAdapterChannel(new OpenCliExecutor(p), "demo").invoke(request); + assertTrue(result.isSuccess()); + String[] lines = result.getStdout().split("\r?\n"); + List actual = new ArrayList<>(); + for (int i = 1; i < lines.length; i++) { + assertTrue(lines[i].startsWith("arg:")); + actual.add(new String(Base64.getDecoder().decode(lines[i].substring(4)), StandardCharsets.UTF_8)); + } + assertEquals("argc:" + actual.size(), lines[0]); + return actual; + } + + @Test + void orderedRepeatedValuesAndExplicitFalseReachChild() { + Object tags = schema("value", "--tag", true); + Object enabled = schema("value", "--enabled", false); + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo"); + add(b, value(tags, " A ")); + add(b, value(enabled, false)); + add(b, value(tags, "")); + assertEquals(Arrays.asList("demo", "echo", "--tag", " A ", "--enabled", "false", "--tag", ""), run(b.build())); + } + + @Test + void explicitNegationIsDifferentFromAbsenceAndPresence() { + Object cache = schema("negatableFlag", "--cache", false); + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo"); + add(b, flag("negated", cache)); + assertEquals(Arrays.asList("demo", "echo", "--no-cache"), run(b.build())); + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder present = OpenCliAdapterCommandRequest.builder().subcommand("echo"); + add(present, flag("present", cache)); + assertEquals(Arrays.asList("demo", "echo", "--cache"), run(present.build())); + assertEquals(Arrays.asList("demo", "echo"), run(OpenCliAdapterCommandRequest.builder().subcommand("echo").build())); + } + + @Test + void flagSchemaCannotSilentlyConsumeAValue() { + Object verbose = schema("flag", "--verbose", false); + assertThrows(IllegalArgumentException.class, () -> value(verbose, false)); + assertThrows(IllegalArgumentException.class, () -> flag("negated", verbose)); + } + + @Test + void nonrepeatableOptionCannotAppearTwice() { + Object once = schema("value", "--limit", false); + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo"); + add(b, value(once, 1)); + add(b, value(once, 2)); + assertThrows(IllegalArgumentException.class, () -> b.build().toSubcommandAndArgs()); + } + + @Test + void legacyAndOrderedOptionsCannotCollide() { + Object limit = schema("value", "--limit", true); + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo") + .options(Collections.singletonMap("limit", "1")); + add(b, value(limit, "2")); + assertThrows(IllegalArgumentException.class, () -> b.build().toSubcommandAndArgs()); + } + + @Test + void mutableOccurrenceValueIsCapturedWhenCreated() { + StringBuilder text = new StringBuilder(" before "); + Object occurrence = value(schema("value", "--text", false), text); + text.append("after"); + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo"); + add(b, occurrence); + assertEquals(Arrays.asList("demo", "echo", "--text", " before "), run(b.build())); + } + + @Test + void malformedSchemaIdentifiersAreRejected() { + Class owner = type("OpenCliOptionSchema"); + assertThrows(IllegalArgumentException.class, + () -> call(owner, null, "flag", new Class[]{String.class}, "--x y")); + assertThrows(IllegalArgumentException.class, + () -> call(owner, null, "flag", new Class[]{String.class}, "")); + } + + @Test + void legacyMapIsCapturedRatherThanAliased() { + Map options = new LinkedHashMap<>(); + options.put("text", " before "); + OpenCliAdapterCommandRequest request = OpenCliAdapterCommandRequest.builder().subcommand("echo").options(options).build(); + options.put("text", "after"); + assertEquals(Arrays.asList("demo", "echo", "--text", " before "), run(request)); + } + + @Test + void mutableLegacyValueCannotChangeAnExistingRequest() { + StringBuilder text = new StringBuilder(" before "); + OpenCliAdapterCommandRequest request = OpenCliAdapterCommandRequest.builder().subcommand("echo") + .options(Collections.singletonMap("text", text)).build(); + text.append("after"); + assertEquals(Arrays.asList("demo", "echo", "--text", " before "), run(request)); + } + + @Test + void reusingBuilderDoesNotMutatePreviousRequest() { + Object tags = schema("value", "--tag", true); + OpenCliAdapterCommandRequest.OpenCliAdapterCommandRequestBuilder b = OpenCliAdapterCommandRequest.builder().subcommand("echo"); + add(b, value(tags, "A")); + OpenCliAdapterCommandRequest first = b.build(); + add(b, value(tags, "B")); + assertEquals(Arrays.asList("demo", "echo", "--tag", "A"), run(first)); + } +} From 48b14388bc1c86fcf1dff2705f0140a541a3fa75 Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Mon, 21 Sep 2026 13:03:33 +0800 Subject: [PATCH 06/13] feat(argv): add schema-checked ordered options and immutable request inputs Observed second RED at d02b16d: 2038 tests, 10 assertion failures, 0 errors/skips. Add explicit flag/value/negatable schemas and immutable option occurrences. Preserve occurrence order and valued false; reject duplicate/mixed-schema ambiguity. Snapshot legacy maps and mutable values while retaining Boolean flag compatibility. Do not claim capability discovery or final three-branch integration is complete. --- .../core/OpenCliAdapterCommandRequest.java | 141 +++++++++++++----- .../easy4j/opencli/core/OpenCliOption.java | 73 +++++++++ .../opencli/core/OpenCliOptionSchema.java | 73 +++++++++ 3 files changed, 247 insertions(+), 40 deletions(-) create mode 100644 src/main/java/io/github/easy4j/opencli/core/OpenCliOption.java create mode 100644 src/main/java/io/github/easy4j/opencli/core/OpenCliOptionSchema.java diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequest.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequest.java index 6ea38da..91e1dd2 100644 --- a/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequest.java +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliAdapterCommandRequest.java @@ -1,22 +1,26 @@ package io.github.easy4j.opencli.core; import io.github.easy4j.opencli.util.OpenCliStrings; +import java.math.BigDecimal; +import java.math.BigInteger; import java.util.ArrayList; import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.Singular; /** - * Structured adapter request. Positional and valued-option contents are literal; - * command and option identifiers are validated separately. - * The legacy options map represents a single-valued subset: Boolean values - * retain their historical presence-only flag semantics. + * Structured adapter request with immutable literal values and ordered options. + * The legacy options Map retains Boolean presence-only semantics; use + * {@link OpenCliOption} for repeated values, valued false and explicit negation. * * @author Loong Wan * @since 3.0.0 @@ -24,7 +28,6 @@ @Getter @Builder public final class OpenCliAdapterCommandRequest { - private final String subcommand; @Getter(AccessLevel.NONE) @@ -32,28 +35,45 @@ public final class OpenCliAdapterCommandRequest { private final List positionals; @Getter(AccessLevel.NONE) - @Builder.Default - private final Map options = Collections.emptyMap(); + private final Map options; + + @Getter(AccessLevel.NONE) + @Singular("option") + private final List orderedOptions; + + /** + * Builder customisation snapshots legacy option values before they can be + * changed through the caller's Map or a mutable value object. + */ + public static class OpenCliAdapterCommandRequestBuilder { + private Map options; + + /** @param source legacy single-value options @return this builder */ + public OpenCliAdapterCommandRequestBuilder options(Map source) { + options = snapshotOptions(source); + return this; + } + } /** @return an immutable copy of positional values */ public List getPositionals() { - if (Objects.isNull(positionals)) { - return Collections.emptyList(); - } - return Collections.unmodifiableList(new ArrayList<>(positionals)); + return positionals == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(positionals)); } - /** @return an immutable copy of named options */ + /** @return an immutable snapshot of legacy options */ public Map getOptions() { - if (Objects.isNull(options)) { - return Collections.emptyMap(); - } - return Collections.unmodifiableMap(new LinkedHashMap<>(options)); + return options == null ? Collections.emptyMap() + : Collections.unmodifiableMap(new LinkedHashMap<>(options)); } - /** - * @return subcommand, then unchanged positional values and named options - */ + /** @return immutable, ordered option occurrences */ + public List getOrderedOptions() { + return orderedOptions == null ? Collections.emptyList() + : Collections.unmodifiableList(new ArrayList<>(orderedOptions)); + } + + /** @return the validated subcommand and complete literal argv */ public List toSubcommandAndArgs() { Objects.requireNonNull(subcommand, "subcommand"); if (OpenCliStrings.isBlank(subcommand)) { @@ -64,27 +84,74 @@ public List toSubcommandAndArgs() { if (positionals != null) { tokens.addAll(OpenCliArgSupport.snapshotValues(positionals, "positionals")); } - if (Objects.nonNull(options)) { + Set legacyFlags = new HashSet<>(); + if (options != null) { for (Map.Entry entry : options.entrySet()) { - appendOption(tokens, entry.getKey(), entry.getValue()); + String flag = appendLegacyOption(tokens, entry.getKey(), entry.getValue()); + if (flag != null && !legacyFlags.add(flag)) { + throw new IllegalArgumentException("Ambiguous duplicate legacy option identifier"); + } + } + } + Map seen = new HashMap<>(); + Map wireNames = new HashMap<>(); + if (orderedOptions != null) { + for (int i = 0; i < orderedOptions.size(); i++) { + OpenCliOption occurrence = orderedOptions.get(i); + if (occurrence == null) { + throw new IllegalArgumentException("orderedOptions[" + i + "] must not be null"); + } + OpenCliOptionSchema schema = occurrence.getSchema(); + String name = schema.getName(); + List argv = occurrence.toTokens(); + String wireName = argv.get(0); + if (legacyFlags.contains(name) || legacyFlags.contains(wireName)) { + throw new IllegalArgumentException("Legacy and ordered options must not overlap"); + } + OpenCliOptionSchema previous = seen.put(name, schema); + if (previous != null && (!schema.isRepeatable() || !previous.equals(schema))) { + throw new IllegalArgumentException("Repeated option is not allowed by one consistent schema"); + } + String previousCanonical = wireNames.put(wireName, name); + if (previousCanonical != null && !previousCanonical.equals(name)) { + throw new IllegalArgumentException("Different schemas emit the same option identifier"); + } + tokens.addAll(argv); } } return tokens; } - private static void appendOption(List target, String name, Object value) { - if (OpenCliStrings.isBlank(name) || Objects.isNull(value)) { - return; - } - String flag = name.startsWith("-") ? name.trim() : "--" + name.trim(); - if (value instanceof Boolean) { - if (((Boolean) value).booleanValue()) { - target.add(flag); + private static Map snapshotOptions(Map source) { + if (source == null) { return Collections.emptyMap(); } + Map snapshot = new LinkedHashMap<>(); + for (Map.Entry entry : new LinkedHashMap<>(source).entrySet()) { + Object value = entry.getValue(); + if (value != null) { + Class type = value.getClass(); + if (type != String.class && type != Boolean.class && type != Byte.class + && type != Short.class && type != Integer.class && type != Long.class + && type != Float.class && type != Double.class && type != BigInteger.class + && type != BigDecimal.class) { + value = String.valueOf(value); + } } - return; + snapshot.put(entry.getKey(), value); } + return Collections.unmodifiableMap(snapshot); + } + + private static String appendLegacyOption(List target, String name, Object value) { + if (OpenCliStrings.isBlank(name) || value == null || Boolean.FALSE.equals(value)) { + return null; + } + String normalized = name.trim(); + String flag = normalized.startsWith("-") ? normalized : "--" + normalized; target.add(flag); - target.add(String.valueOf(value)); + if (!(value instanceof Boolean)) { + target.add(String.valueOf(value)); + } + return flag; } /** @@ -94,16 +161,10 @@ private static void appendOption(List target, String name, Object value) * @return a structured request */ public static OpenCliAdapterCommandRequest of( - String subcommand, - List positionals, - Map options) { + String subcommand, List positionals, Map options) { OpenCliAdapterCommandRequestBuilder b = builder().subcommand(subcommand); - if (Objects.nonNull(positionals)) { - b.positionals(positionals); - } - if (Objects.nonNull(options) && !options.isEmpty()) { - b.options(new LinkedHashMap<>(options)); - } + if (positionals != null) { b.positionals(positionals); } + if (options != null) { b.options(options); } return b.build(); } } diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliOption.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliOption.java new file mode 100644 index 0000000..5e5277e --- /dev/null +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliOption.java @@ -0,0 +1,73 @@ +package io.github.easy4j.opencli.core; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Objects; + +/** + * One immutable, schema-checked option occurrence. Preserve occurrence ordering + * by adding these to a request builder rather than using a Map for repeated flags. + * + * @author Loong Wan + * @since 3.0.0 + */ +public final class OpenCliOption { + private final OpenCliOptionSchema schema; + private final String value; + private final boolean negated; + + private OpenCliOption(OpenCliOptionSchema schema, String value, boolean negated) { + this.schema = schema; + this.value = value; + this.negated = negated; + } + + /** + * @param schema a valued-option definition + * @param value non-null value; false is the literal value "false", not absence + * @return an occurrence capturing the value immediately + */ + public static OpenCliOption value(OpenCliOptionSchema schema, Object value) { + Objects.requireNonNull(schema, "schema"); + Objects.requireNonNull(value, "value"); + if (schema.getKind() != OpenCliOptionSchema.Kind.VALUE) { + throw new IllegalArgumentException("A flag schema cannot consume a value"); + } + return new OpenCliOption(schema, String.valueOf(value), false); + } + + /** @param schema a flag definition @return explicit positive presence */ + public static OpenCliOption present(OpenCliOptionSchema schema) { + Objects.requireNonNull(schema, "schema"); + if (schema.getKind() == OpenCliOptionSchema.Kind.VALUE) { + throw new IllegalArgumentException("A valued option requires a value"); + } + return new OpenCliOption(schema, null, false); + } + + /** @param schema a negatable flag definition @return an explicit negative flag */ + public static OpenCliOption negated(OpenCliOptionSchema schema) { + Objects.requireNonNull(schema, "schema"); + if (schema.getKind() != OpenCliOptionSchema.Kind.NEGATABLE_FLAG) { + throw new IllegalArgumentException("This option schema does not declare negation"); + } + return new OpenCliOption(schema, null, true); + } + + /** @return immutable input definition */ + public OpenCliOptionSchema getSchema() { return schema; } + + /** @return the captured value, or null for a flag */ + public String getValue() { return value; } + + /** @return whether this is explicit negative presence */ + public boolean isNegated() { return negated; } + + /** @return immutable literal tokens; no quoting or trimming is applied */ + public List toTokens() { + String flag = negated ? "--no-" + schema.getName().substring(2) : schema.getName(); + return value == null ? Collections.singletonList(flag) + : Collections.unmodifiableList(Arrays.asList(flag, value)); + } +} diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliOptionSchema.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliOptionSchema.java new file mode 100644 index 0000000..3289973 --- /dev/null +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliOptionSchema.java @@ -0,0 +1,73 @@ +package io.github.easy4j.opencli.core; + +import java.util.Objects; +import java.util.regex.Pattern; + +/** + * Explicit option input semantics. Definitions come from a known command contract, + * not guesses about a raw argument vector. This is not an output schema. + * + * @author Loong Wan + * @since 3.0.0 + */ +public final class OpenCliOptionSchema { + /** Input arity and negation semantics. */ + public enum Kind { FLAG, VALUE, NEGATABLE_FLAG } + + private static final Pattern NAME = Pattern.compile("(?:--[A-Za-z0-9][A-Za-z0-9-]*|-[A-Za-z0-9])"); + private final String name; + private final Kind kind; + private final boolean repeatable; + + private OpenCliOptionSchema(String name, Kind kind, boolean repeatable) { + Objects.requireNonNull(name, "name"); + if (!NAME.matcher(name).matches()) { + throw new IllegalArgumentException("Option schema requires a valid flag identifier"); + } + if (kind == Kind.NEGATABLE_FLAG && (!name.startsWith("--") || name.startsWith("--no-"))) { + throw new IllegalArgumentException("Negatable schema requires a positive long flag identifier"); + } + this.name = name; + this.kind = kind; + this.repeatable = repeatable; + } + + /** @param name flag identifier @return a presence-only, nonrepeatable flag */ + public static OpenCliOptionSchema flag(String name) { + return new OpenCliOptionSchema(name, Kind.FLAG, false); + } + + /** + * @param name option identifier + * @param repeatable whether repeated occurrences are accepted by the command + * @return an option taking one literal value per occurrence + */ + public static OpenCliOptionSchema value(String name, boolean repeatable) { + return new OpenCliOptionSchema(name, Kind.VALUE, repeatable); + } + + /** @param name positive long flag identifier @return a flag supporting explicit negation */ + public static OpenCliOptionSchema negatableFlag(String name) { + return new OpenCliOptionSchema(name, Kind.NEGATABLE_FLAG, false); + } + + /** @return canonical flag identifier */ + public String getName() { return name; } + + /** @return declared input kind */ + public Kind getKind() { return kind; } + + /** @return whether the command accepts repeated occurrences */ + public boolean isRepeatable() { return repeatable; } + + @Override + public boolean equals(Object other) { + if (this == other) { return true; } + if (!(other instanceof OpenCliOptionSchema)) { return false; } + OpenCliOptionSchema that = (OpenCliOptionSchema) other; + return name.equals(that.name) && kind == that.kind && repeatable == that.repeatable; + } + + @Override + public int hashCode() { return Objects.hash(name, kind, repeatable); } +} From 31bde9cc4bda23519b6a81d66df7ddb781d94548 Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Mon, 21 Sep 2026 13:12:35 +0800 Subject: [PATCH 07/13] test(process): add C02 bounded lifecycle RED contracts Use self-bounded offline child processes to expose limiter replacement, queue deadline bypass, unbounded stdout/stderr and interrupted-child leaks. Also require explicit shared runtime and pre-cancel semantics. No production lifecycle implementation changes in this RED checkpoint. --- .../opencli/contract/LifecycleProbe.java | 40 +++ .../contract/OpenCliProcessContractTest.java | 233 ++++++++++++++++++ 2 files changed, 273 insertions(+) create mode 100644 src/test/java/io/github/easy4j/opencli/contract/LifecycleProbe.java create mode 100644 src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessContractTest.java diff --git a/src/test/java/io/github/easy4j/opencli/contract/LifecycleProbe.java b/src/test/java/io/github/easy4j/opencli/contract/LifecycleProbe.java new file mode 100644 index 0000000..f6e6874 --- /dev/null +++ b/src/test/java/io/github/easy4j/opencli/contract/LifecycleProbe.java @@ -0,0 +1,40 @@ +package io.github.easy4j.opencli.contract; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.Arrays; + +/** Offline, self-bounded child fixture. Release files allow test cleanup even against broken SDKs. */ +public final class LifecycleProbe { + private LifecycleProbe() { } + + public static void main(String[] args) throws Exception { + String mode = args[0]; + if ("stdout".equals(mode) || "stderr".equals(mode)) { + byte[] block = new byte[8192]; + Arrays.fill(block, (byte) 'x'); + int remaining = Integer.parseInt(args[1]); + while (remaining > 0) { + int size = Math.min(block.length, remaining); + if ("stdout".equals(mode)) { System.out.write(block, 0, size); } + else { System.err.write(block, 0, size); } + remaining -= size; + } + return; + } + Path marker = Paths.get(args[1]); + Files.write(marker, "started".getBytes(StandardCharsets.UTF_8)); + if ("write".equals(mode)) { return; } + Path release = Paths.get(args[2]); + long start = System.nanoTime(); + int tick = 0; + while (!Files.exists(release) && System.nanoTime() - start < 10_000_000_000L) { + if ("heartbeat".equals(mode)) { + Files.write(marker, Integer.toString(++tick).getBytes(StandardCharsets.UTF_8)); + } + Thread.sleep(20L); + } + } +} diff --git a/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessContractTest.java b/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessContractTest.java new file mode 100644 index 0000000..5cb4397 --- /dev/null +++ b/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessContractTest.java @@ -0,0 +1,233 @@ +package io.github.easy4j.opencli.contract; + +import io.github.easy4j.opencli.OpenCliProperties; +import io.github.easy4j.opencli.core.OpenCliExecutor; +import io.github.easy4j.opencli.core.OpenCliResult; +import io.github.easy4j.opencli.exception.OpenCliException; +import io.github.easy4j.opencli.exception.OpenCliTimeoutException; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +/** C02 tests observe actual fixture processes, not private semaphore counters. */ +@Timeout(20) +class OpenCliProcessContractTest { + @TempDir Path dir; + + private static OpenCliProperties properties(int maxConcurrent) { + OpenCliProperties p = new OpenCliProperties(); + String exe = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java"; + p.setExecutable(new File(new File(System.getProperty("java.home"), "bin"), exe).getAbsolutePath()); + p.setLeadingArguments(new ArrayList<>(Arrays.asList("-cp", + System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")), + LifecycleProbe.class.getName()))); + p.setCommandTimeoutMillis(10000L); + p.setMaxConcurrentExecutions(maxConcurrent); + return p; + } + + private static boolean awaitFile(Path path, long millis) throws Exception { + long start = System.nanoTime(); + while (System.nanoTime() - start < TimeUnit.MILLISECONDS.toNanos(millis)) { + if (Files.exists(path)) { return true; } + Thread.sleep(10L); + } + return Files.exists(path); + } + + private static void release(Path path) { + try { Files.write(path, new byte[]{1}); } + catch (Exception ex) { throw new AssertionError("fixture cleanup failed", ex); } + } + + private static Object getter(Object object, String name) { + assertNotNull(object, "partial evidence is required"); + return assertDoesNotThrow(() -> object.getClass().getMethod(name).invoke(object), + "required execution evidence is missing: " + name); + } + + private static Object details(OpenCliResult result) { return getter(result, "getExecutionDetails"); } + + @Test + void anotherClientCannotReplaceAnActiveClientsLimiter() throws Exception { + OpenCliExecutor a = new OpenCliExecutor(properties(1)); + Path first = dir.resolve("first"); + Path second = dir.resolve("second"); + Path gate = dir.resolve("release"); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future one = workers.submit(() -> a.invoke("hold", first.toString(), gate.toString())); + assertTrue(awaitFile(first, 3000), "first child did not start"); + new OpenCliExecutor(properties(4)); + Future two = workers.submit(() -> a.invoke("write", second.toString())); + assertFalse(awaitFile(second, 600), "constructing B bypassed A's active limiter"); + release(gate); + assertTrue(one.get(5, TimeUnit.SECONDS).isSuccess()); + assertTrue(two.get(5, TimeUnit.SECONDS).isSuccess()); + } finally { + release(gate); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void queuedDeadlineExpiresWithoutSpawning() throws Exception { + OpenCliProperties p = properties(1); + OpenCliExecutor executor = new OpenCliExecutor(p); + Path first = dir.resolve("first"); + Path second = dir.resolve("must-not-start"); + Path gate = dir.resolve("release"); + ExecutorService worker = Executors.newSingleThreadExecutor(); + ScheduledExecutorService cleanup = Executors.newSingleThreadScheduledExecutor(); + try { + Future one = worker.submit(() -> executor.invoke("hold", first.toString(), gate.toString())); + assertTrue(awaitFile(first, 3000)); + p.setCommandTimeoutMillis(50L); + cleanup.schedule(() -> release(gate), 1500L, TimeUnit.MILLISECONDS); + long started = System.nanoTime(); + OpenCliTimeoutException failure = assertThrows(OpenCliTimeoutException.class, + () -> executor.invoke("write", second.toString())); + long elapsed = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); + assertTrue(elapsed < 750L, "queue wait ignored total deadline: " + elapsed); + assertFalse(Files.exists(second), "queue-expired child was started"); + Object evidence = details(failure.getPartialResult()); + assertEquals("QUEUE_TIMEOUT", String.valueOf(getter(evidence, "getTerminationReason"))); + assertEquals(false, getter(evidence, "isProcessStarted")); + assertNull(failure.getPartialResult().getExitCode()); + release(gate); + assertTrue(one.get(5, TimeUnit.SECONDS).isSuccess()); + } finally { + release(gate); + worker.shutdownNow(); + cleanup.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + assertTrue(cleanup.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void stdoutDefaultBudgetFailsInsteadOfReturningUnboundedSuccess() { + OpenCliException failure = assertThrows(OpenCliException.class, + () -> new OpenCliExecutor(properties(1)).invoke("stdout", Integer.toString(9 * 1024 * 1024))); + OpenCliResult partial = failure.getPartialResult(); + Object evidence = details(partial); + assertEquals("OUTPUT_LIMIT", String.valueOf(getter(evidence, "getTerminationReason"))); + assertEquals(8L * 1024 * 1024, ((Number) getter(evidence, "getStdoutCapturedBytes")).longValue()); + assertTrue(((Number) getter(evidence, "getStdoutObservedBytes")).longValue() > 8L * 1024 * 1024); + assertEquals(true, getter(evidence, "isStdoutTruncated")); + assertFalse(partial.isSuccess()); + } + + @Test + void stderrHasItsOwnSmallerBudget() { + OpenCliException failure = assertThrows(OpenCliException.class, + () -> new OpenCliExecutor(properties(1)).invoke("stderr", Integer.toString(3 * 1024 * 1024))); + Object evidence = details(failure.getPartialResult()); + assertEquals("OUTPUT_LIMIT", String.valueOf(getter(evidence, "getTerminationReason"))); + assertEquals(2L * 1024 * 1024, ((Number) getter(evidence, "getStderrCapturedBytes")).longValue()); + assertEquals(true, getter(evidence, "isStderrTruncated")); + } + + @Test + void interruptionStopsTheOwnedHeartbeatAndRestoresFlag() throws Exception { + OpenCliExecutor executor = new OpenCliExecutor(properties(1)); + Path heartbeat = dir.resolve("heartbeat"); + Path gate = dir.resolve("release"); + AtomicBoolean restored = new AtomicBoolean(); + AtomicReference error = new AtomicReference<>(); + Thread caller = new Thread(() -> { + try { executor.invoke("heartbeat", heartbeat.toString(), gate.toString()); } + catch (Throwable failure) { error.set(failure); restored.set(Thread.currentThread().isInterrupted()); } + }, "contract-interrupted-caller"); + try { + caller.start(); + assertTrue(awaitFile(heartbeat, 3000)); + Thread.sleep(80L); + caller.interrupt(); + caller.join(1500L); + assertFalse(caller.isAlive(), "interrupted call did not finish cleanup"); + assertTrue(error.get() instanceof OpenCliException); + assertTrue(restored.get(), "caller interrupt flag was lost"); + String observed = new String(Files.readAllBytes(heartbeat), StandardCharsets.UTF_8); + Thread.sleep(250L); + assertEquals(observed, new String(Files.readAllBytes(heartbeat), StandardCharsets.UTF_8), + "owned child kept running after interrupted call returned"); + OpenCliException failure = (OpenCliException) error.get(); + assertEquals("CANCELLED", String.valueOf(getter(details(failure.getPartialResult()), "getTerminationReason"))); + assertEquals("ROOT_EXIT_CONFIRMED", String.valueOf(getter(details(failure.getPartialResult()), "getCleanupState"))); + Path next = dir.resolve("next"); + assertTrue(executor.invoke("write", next.toString()).isSuccess(), "permit leaked after cancellation"); + } finally { + release(gate); + caller.interrupt(); + caller.join(5000L); + } + } + + @Test + void negativeConcurrencyIsNotSilentlyTreatedAsDefault() { + assertThrows(IllegalArgumentException.class, () -> new OpenCliExecutor(properties(-1))); + } + + @Test + void explicitSharedRuntimeLimitsBothClients() throws Exception { + Class runtimeType = assertDoesNotThrow(() -> Class.forName("io.github.easy4j.opencli.core.OpenCliProcessRuntime")); + Object runtime = runtimeType.getConstructor(int.class).newInstance(1); + OpenCliExecutor a = OpenCliExecutor.class.getConstructor(OpenCliProperties.class, runtimeType) + .newInstance(properties(4), runtime); + OpenCliExecutor b = OpenCliExecutor.class.getConstructor(OpenCliProperties.class, runtimeType) + .newInstance(properties(4), runtime); + Path first = dir.resolve("shared-first"); + Path second = dir.resolve("shared-second"); + Path gate = dir.resolve("release"); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future one = workers.submit(() -> a.invoke("hold", first.toString(), gate.toString())); + assertTrue(awaitFile(first, 3000)); + Future two = workers.submit(() -> b.invoke("write", second.toString())); + assertFalse(awaitFile(second, 500), "shared runtime did not enforce its one permit"); + release(gate); + assertTrue(one.get(5, TimeUnit.SECONDS).isSuccess()); + assertTrue(two.get(5, TimeUnit.SECONDS).isSuccess()); + } finally { + release(gate); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void preCancelledRequestNeverStartsAProcess() throws Exception { + Class tokenType = assertDoesNotThrow(() -> Class.forName("io.github.easy4j.opencli.core.OpenCliCancellationToken")); + Object token = tokenType.getConstructor().newInstance(); + tokenType.getMethod("cancel").invoke(token); + Path marker = dir.resolve("pre-cancelled"); + OpenCliExecutor executor = new OpenCliExecutor(properties(1)); + java.lang.reflect.InvocationTargetException failure = assertThrows(java.lang.reflect.InvocationTargetException.class, + () -> OpenCliExecutor.class.getMethod("invoke", List.class, tokenType) + .invoke(executor, Arrays.asList("write", marker.toString()), token)); + assertTrue(failure.getCause() instanceof OpenCliException); + OpenCliResult partial = ((OpenCliException) failure.getCause()).getPartialResult(); + assertEquals("CANCELLED", String.valueOf(getter(details(partial), "getTerminationReason"))); + assertEquals(false, getter(details(partial), "isProcessStarted")); + assertNull(partial.getExitCode()); + assertFalse(Files.exists(marker)); + } +} From 98e3db1f8883b8485ec8a428238173dc1148dfb4 Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Mon, 21 Sep 2026 13:19:31 +0800 Subject: [PATCH 08/13] fix(process): enforce stable capacity deadlines and bounded native capture C02 RED at 31bde9c: 2046 tests, 8 failures, 0 errors/skips. Use fixed per-client or explicit shared runtimes, monotonic queue/execution budgets, 8MiB stdout and 2MiB stderr caps, per-call cancellation and owned-root cleanup. Preserve lifecycle reasons, byte counts and unknown descendant status in results. Quarantine runtimes with unconfirmed owned root/reader cleanup; keep legacy API bridge. Remote cancellation and unverified descendant ownership are not claimed. --- .../easy4j/opencli/OpenCliProperties.java | 107 +---- .../core/OpenCliCancellationToken.java | 14 + .../opencli/core/OpenCliExecutionDetails.java | 33 ++ .../easy4j/opencli/core/OpenCliExecutor.java | 293 +++++-------- .../opencli/core/OpenCliProcessRuntime.java | 37 ++ .../easy4j/opencli/core/OpenCliResult.java | 38 +- .../support/SubprocessExecutionSupport.java | 409 ++++++++++++++---- .../OpenCliExecutableFailureException.java | 14 +- 8 files changed, 541 insertions(+), 404 deletions(-) create mode 100644 src/main/java/io/github/easy4j/opencli/core/OpenCliCancellationToken.java create mode 100644 src/main/java/io/github/easy4j/opencli/core/OpenCliExecutionDetails.java create mode 100644 src/main/java/io/github/easy4j/opencli/core/OpenCliProcessRuntime.java diff --git a/src/main/java/io/github/easy4j/opencli/OpenCliProperties.java b/src/main/java/io/github/easy4j/opencli/OpenCliProperties.java index 6c87513..e60a87a 100644 --- a/src/main/java/io/github/easy4j/opencli/OpenCliProperties.java +++ b/src/main/java/io/github/easy4j/opencli/OpenCliProperties.java @@ -7,112 +7,46 @@ import lombok.Data; /** - * OpenCLI runtime configuration POJO with no Spring dependency. - *

- * 描述可执行文件、工作目录、超时、全局 argv 前缀、远端 Agent 以及需要注入子进程的环境变量 - *(例如 {@code OPENCLI_CDP_ENDPOINT})。Spring Boot 可由上层以 - * {@code @ConfigurationProperties(prefix = "opencli")} 绑定同名字段。 - *

- *

- * {@link #commandTimeoutMillis} 在本地模式下用于子进程 Watchdog;在 - * {@link OpenCliExecutionTarget#REMOTE_AGENT_HTTP} 模式下用作 HTTP 客户端超时上限。 - *

- */ -@Data/** - - * OpenCLI runtime configuration POJO with no Spring dependency. - * - *

Describes the executable path, working directory, timeout, global argv prefix, - * remote Agent settings, and environment variables injected into the subprocess - * (e.g. {@code OPENCLI_CDP_ENDPOINT}). Spring Boot applications can bind these - * fields via {@code @ConfigurationProperties(prefix = "opencli")}.

+ * OpenCLI runtime configuration without Spring dependencies. + * Local execution uses a monotonic submission-to-exit deadline; cleanup has a + * separate finite grace. Remote HTTP retains its own transport timeout semantics. * - *

{@link #commandTimeoutMillis} is used as the subprocess watchdog timeout in local - * mode and as the HTTP client timeout in {@link OpenCliExecutionTarget#REMOTE_AGENT_HTTP} mode.

- - * - * @author Loong Wan - * @since 3.0.0 - */ - +@Data public class OpenCliProperties { - - /** - * 执行目标:本机进程或与 opencli-admin 兼容的远端 Agent。 - */ private OpenCliExecutionTarget executionTarget = OpenCliExecutionTarget.LOCAL_PROCESS; - - /** - * 远端 Agent 根 URL(不含尾斜杠),例如 {@code http://192.168.1.10:19823}。 - *

仅当 {@link #executionTarget} 为 {@link OpenCliExecutionTarget#REMOTE_AGENT_HTTP} 时必填。

- */ + /** Root URL of an opencli-admin compatible Agent. */ private String remoteAgentBaseUrl; - - /** - * 传给 Agent collect 的 {@code mode}:{@code bridge} 或 {@code cdp}(与 Agent 环境一致)。 - */ private String remoteCollectMode = "cdp"; - - /** - * 传给 Agent collect 的默认 {@code format}(可被 argv 中的 {@code -f} 覆盖)。 - */ private String remoteOutputFormat = "json"; - - /** - * 对应 collect body 的 {@code cdp_endpoint};空表示由 Agent 使用自身 {@code OPENCLI_CDP_ENDPOINT}。 - */ private String remoteCdpEndpoint = ""; - - /** - * 为 true 时,远程模式下将 Agent HTTP 响应原文写入 {@link io.github.easy4j.opencli.core.OpenCliResult} 的 - * {@code remoteRawHttpBody} 字段; - * 本地模式无效果。大响应时请谨慎开启。 - */ + /** Raw HTTP capture is explicitly opt-in and may contain sensitive business data. */ private boolean remoteCaptureRawHttpResponse = false; - - /** - * OpenCLI 可执行文件名或绝对路径;默认假定已在 {@code PATH} 中。 - */ private String executable = "opencli"; - - /** - * 子进程工作目录;为空时使用 JVM 当前目录。 - */ private String workingDirectory; - - /** - * 单次调用超时(毫秒):本地模式用于子进程 Watchdog;远程模式用于 Agent HTTP 请求。 - */ + /** Total local queue plus execution budget; remote mode uses an HTTP timeout. */ private long commandTimeoutMillis = 300_000L; - - /** - * 本机 CLI 子进程最大并发数;小于等于 0 时使用 CPU 核心数与 2 的较大值。 - */ + /** Positive per-client capacity, zero for max(2, cores); negative is invalid. Captured at construction. */ private int maxConcurrentExecutions = 0; - - /** - * 启动探测({@code opencli list})专用超时(毫秒);小于等于 0 时探测使用 30 秒。 - */ + /** Maximum bytes retained from stdout for one local invocation. */ + private int maxStdoutBytes = 8 * 1024 * 1024; + /** Maximum bytes retained from stderr for one local invocation. */ + private int maxStderrBytes = 2 * 1024 * 1024; + /** Independent bounded cleanup grace, in milliseconds. */ + private long cleanupGraceMillis = 5_000L; private long startupProbeTimeoutMillis = 30_000L; - - /** - * 附加到 {@code opencli} 之后的最前参数(在 adapter 名之前),便于预留 profile 等扩展。 - */ + /** Literal prefix arguments placed before the command identifier. */ private List leadingArguments = new ArrayList<>(); - - /** - * 合并进子进程环境的键值;覆盖同名系统环境变量。 - */ + /** Variables overlaying the inherited process environment. */ private Map environment = new LinkedHashMap<>(); /** - * 复制为「仅本机子进程」配置,供边缘 WebSocket Agent 处理中心下发的 {@code collect} 时使用, - * 避免误将 collect 再次转发为 {@link OpenCliExecutionTarget#REMOTE_AGENT_HTTP} 而形成回路。 + * Copy into a local-only configuration for reverse-Agent execution without + * forwarding a received command back to a remote Agent. * - * @return 新实例,不会改变当前对象 + * @return an independent local configuration */ public OpenCliProperties copyForLocalCliExecution() { OpenCliProperties c = new OpenCliProperties(); @@ -120,6 +54,9 @@ public OpenCliProperties copyForLocalCliExecution() { c.setWorkingDirectory(this.workingDirectory); c.setCommandTimeoutMillis(this.commandTimeoutMillis); c.setMaxConcurrentExecutions(this.maxConcurrentExecutions); + c.setMaxStdoutBytes(this.maxStdoutBytes); + c.setMaxStderrBytes(this.maxStderrBytes); + c.setCleanupGraceMillis(this.cleanupGraceMillis); c.setStartupProbeTimeoutMillis(this.startupProbeTimeoutMillis); c.setLeadingArguments(new ArrayList<>(this.leadingArguments)); c.setEnvironment(new LinkedHashMap<>(this.environment)); diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliCancellationToken.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliCancellationToken.java new file mode 100644 index 0000000..c90d2db --- /dev/null +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliCancellationToken.java @@ -0,0 +1,14 @@ +package io.github.easy4j.opencli.core; + +import java.util.concurrent.atomic.AtomicBoolean; + +/** Cooperative cancellation for one local invocation; cancellation never affects another invocation. */ +public final class OpenCliCancellationToken { + private final AtomicBoolean cancelled = new AtomicBoolean(); + + /** Request cancellation. This operation is idempotent. */ + public void cancel() { cancelled.set(true); } + + /** @return whether cancellation has been requested */ + public boolean isCancelled() { return cancelled.get(); } +} diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutionDetails.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutionDetails.java new file mode 100644 index 0000000..98530e3 --- /dev/null +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutionDetails.java @@ -0,0 +1,33 @@ +package io.github.easy4j.opencli.core; + +import lombok.Builder; +import lombok.Getter; + +/** Immutable execution evidence. Observed byte counts describe bytes read, not bytes produced remotely. */ +@Getter +@Builder +public final class OpenCliExecutionDetails { + /** First terminal condition selected by the invocation owner. */ + public enum TerminationReason { + PROCESS_EXIT, QUEUE_TIMEOUT, EXECUTION_TIMEOUT, OUTPUT_LIMIT, + CANCELLED, SPAWN_FAILED, IO_FAILURE, CLEANUP_UNCONFIRMED, RUNTIME_UNAVAILABLE + } + + /** Confirmation concerns the directly owned child, not an arbitrary process tree. */ + public enum CleanupState { NOT_STARTED, ROOT_EXIT_CONFIRMED, UNCONFIRMED } + + private final TerminationReason terminationReason; + private final CleanupState cleanupState; + private final boolean processStarted; + private final boolean streamsDrained; + private final long stdoutCapturedBytes; + private final long stdoutObservedBytes; + private final boolean stdoutTruncated; + private final long stderrCapturedBytes; + private final long stderrObservedBytes; + private final boolean stderrTruncated; + private final long elapsedMillis; + private final long queueWaitMillis; + /** This portable backend does not claim ownership/termination of detached daemon descendants. */ + private final boolean descendantsExitConfirmed; +} diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java index 99b1abe..ebb35af 100644 --- a/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliExecutor.java @@ -2,69 +2,78 @@ import io.github.easy4j.opencli.OpenCliExecutionTarget; import io.github.easy4j.opencli.OpenCliProperties; +import io.github.easy4j.opencli.core.OpenCliExecutionDetails.TerminationReason; +import io.github.easy4j.opencli.core.support.SubprocessExecutionSupport; import io.github.easy4j.opencli.exception.OpenCliException; import io.github.easy4j.opencli.exception.OpenCliExecutableFailureException; import io.github.easy4j.opencli.exception.OpenCliNonZeroExitException; import io.github.easy4j.opencli.exception.OpenCliTimeoutException; -import io.github.easy4j.opencli.parser.OpenCliParsedFields; import io.github.easy4j.opencli.remote.OpenCliArgvToCollectParser; import io.github.easy4j.opencli.remote.OpenCliCollectRequest; import io.github.easy4j.opencli.remote.OpenCliRemoteAgentHttpClient; -import io.github.easy4j.opencli.core.support.SubprocessExecutionSupport; import io.github.easy4j.opencli.util.OpenCliStrings; -import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.util.Arrays; import java.util.HashMap; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; import lombok.Getter; -import lombok.extern.slf4j.Slf4j; import org.apache.commons.exec.CommandLine; -import org.apache.commons.exec.DefaultExecuteResultHandler; -import org.apache.commons.exec.ExecuteException; -import org.apache.commons.exec.ExecuteWatchdog; /** - * OpenCLI subprocess execution wrapper based on Apache Commons Exec. - * - *

{@link #invoke(List)} accepts literal argv tokens following the executable name. - * Local invocation prepends {@link OpenCliProperties#getLeadingArguments()}. - * Empty and whitespace-only values are preserved; null tokens are rejected before - * transport selection. Command identifiers are validated separately.

- * - *

When the execution target is {@link OpenCliExecutionTarget#REMOTE_AGENT_HTTP}, - * invocation uses the legacy opencli-admin {@code /collect} contract. That protocol's - * representation limits are separate from the local raw argv contract.

+ * Literal-argv executor with a stable capacity owner, total local deadline and + * bounded output. Existing synchronous entry points are retained. Raw HTTP + * collect remains a separate legacy protocol, not a lossless process transport. * * @author Loong Wan * @since 3.0.0 */ -@Slf4j @Getter public class OpenCliExecutor { - private final OpenCliProperties properties; - - /** Lazily initialized for remote mode. */ + private final OpenCliProcessRuntime processRuntime; private volatile OpenCliRemoteAgentHttpClient remoteAgentHttpClient; - /** @param properties runtime configuration */ + /** @param properties configuration; process capacity is captured once */ public OpenCliExecutor(OpenCliProperties properties) { + this(properties, new OpenCliProcessRuntime(Objects.requireNonNull(properties, "properties") + .getMaxConcurrentExecutions())); + } + + /** + * @param properties configuration + * @param processRuntime an explicitly shared, stable local capacity owner + */ + public OpenCliExecutor(OpenCliProperties properties, OpenCliProcessRuntime processRuntime) { this.properties = Objects.requireNonNull(properties, "properties"); - SubprocessExecutionSupport.configureMaxConcurrentExecutions(properties.getMaxConcurrentExecutions()); + this.processRuntime = Objects.requireNonNull(processRuntime, "processRuntime"); + } + + /** @param adapterAndRest command and literal values @return execution result */ + public OpenCliResult invoke(List adapterAndRest) { + return invokeInternal(adapterAndRest, new OpenCliCancellationToken(), false); } /** - * Invoke a snapshot of the complete argv vector, excluding the executable. + * Cancellable local invocation. The legacy HTTP protocol does not claim a + * cancellable remote process; explicit tokens are rejected in remote mode. * - * @param adapterAndRest nonempty command and literal values + * @param adapterAndRest command and literal values + * @param cancellationToken cancellation for this invocation only * @return execution result */ - public OpenCliResult invoke(List adapterAndRest) { + public OpenCliResult invoke(List adapterAndRest, OpenCliCancellationToken cancellationToken) { + return invokeInternal(adapterAndRest, Objects.requireNonNull(cancellationToken, "cancellationToken"), true); + } + + private OpenCliResult invokeInternal(List adapterAndRest, + OpenCliCancellationToken cancellationToken, boolean explicitCancellation) { + long submittedAtNanos = System.nanoTime(); + long timeoutMillis = properties.getCommandTimeoutMillis(); List tokens = OpenCliArgSupport.snapshotValues(adapterAndRest, "adapterAndRest"); if (tokens.isEmpty()) { throw new IllegalArgumentException("adapterAndRest must contain at least the command identifier"); @@ -73,24 +82,21 @@ public OpenCliResult invoke(List adapterAndRest) { throw new IllegalArgumentException("adapterAndRest[0] command identifier must not be blank"); } if (properties.getExecutionTarget() == OpenCliExecutionTarget.REMOTE_AGENT_HTTP) { - log.debug("OpenCLI invoke remote agent argvSize={}", tokens.size()); - OpenCliCollectRequest req = - OpenCliArgvToCollectParser.parse( - tokens, - properties.getRemoteOutputFormat(), - properties.getRemoteCollectMode(), - properties.getRemoteCdpEndpoint()); + if (explicitCancellation) { + throw new UnsupportedOperationException("Explicit process cancellation is local-only for legacy collect"); + } + OpenCliCollectRequest req = OpenCliArgvToCollectParser.parse(tokens, + properties.getRemoteOutputFormat(), properties.getRemoteCollectMode(), properties.getRemoteCdpEndpoint()); return remoteAgent().collect(req); } - log.debug("OpenCLI invoke local argvSize={}", tokens.size()); - CommandLine cmd = buildCommandLine(tokens); - return run(cmd); + CommandLine commandLine = buildCommandLine(tokens); + return run(commandLine, timeoutMillis, submittedAtNanos, cancellationToken); } private OpenCliRemoteAgentHttpClient remoteAgent() { - if (Objects.isNull(remoteAgentHttpClient)) { + if (remoteAgentHttpClient == null) { synchronized (this) { - if (Objects.isNull(remoteAgentHttpClient)) { + if (remoteAgentHttpClient == null) { remoteAgentHttpClient = new OpenCliRemoteAgentHttpClient(properties); } } @@ -98,194 +104,107 @@ private OpenCliRemoteAgentHttpClient remoteAgent() { return remoteAgentHttpClient; } - /** - * @param adapterAndRest command and literal values - * @return execution result - */ + /** @param adapterAndRest command and literal values @return execution result */ public OpenCliResult invoke(String... adapterAndRest) { Objects.requireNonNull(adapterAndRest, "adapterAndRest"); return invoke(Arrays.asList(adapterAndRest)); } - private CommandLine buildCommandLine(List adapterAndRest) { - String exe = properties.getExecutable(); - if (OpenCliStrings.isBlank(exe)) { + private CommandLine buildCommandLine(List tokens) { + String executable = properties.getExecutable(); + if (OpenCliStrings.isBlank(executable)) { throw new IllegalStateException("opencli.executable must not be blank"); } - CommandLine cmd = new LiteralCommandLine(exe.trim()); + String normalized = executable.trim(); + String lower = normalized.toLowerCase(Locale.ROOT); + if (System.getProperty("os.name").startsWith("Windows") + && (lower.endsWith(".cmd") || lower.endsWith(".bat"))) { + throw new UnsupportedOperationException("Use a native node executable plus the CLI JavaScript path; batch shims are not literal argv transports"); + } + CommandLine cmd = new LiteralCommandLine(normalized); appendLiteralArgs(cmd, properties.getLeadingArguments(), "leadingArguments"); - appendLiteralArgs(cmd, adapterAndRest, "adapterAndRest"); + appendLiteralArgs(cmd, tokens, "adapterAndRest"); return cmd; } - private static void appendLiteralArgs(CommandLine cmd, List args, String field) { - if (args == null) { - return; - } - for (String value : OpenCliArgSupport.snapshotValues(args, field)) { - cmd.addArgument(value, false); - } + private static void appendLiteralArgs(CommandLine cmd, List values, String field) { + if (values == null) { return; } + for (String value : OpenCliArgSupport.snapshotValues(values, field)) { cmd.addArgument(value, false); } } - /** - * Append a {@code --key=value} token using Commons Exec quoting. - * - * @param cmd command line - * @param key option name starting with {@code --} - * @param value non-null value - */ + /** Legacy explicit-quoting helper; not used by the literal process path. */ public static void appendQuotedKeyValue(CommandLine cmd, String key, String value) { Objects.requireNonNull(key, "key"); Objects.requireNonNull(value, "value"); if (!key.startsWith("--")) { - throw new IllegalArgumentException("CLI key must start with '--', got: " + key); + throw new IllegalArgumentException("CLI key must start with '--'"); } String prefix = key.endsWith("=") ? key.substring(0, key.length() - 1) : key; cmd.addArgument(prefix + "=" + value, true); } - private OpenCliResult run(CommandLine commandLine) { - long timeoutMs = properties.getCommandTimeoutMillis(); - if (timeoutMs <= 0) { + private OpenCliResult run(CommandLine commandLine, long timeoutMillis, long submittedAtNanos, + OpenCliCancellationToken cancellationToken) { + if (timeoutMillis <= 0) { throw new IllegalStateException("opencli.command-timeout-millis must be positive"); } - - File workingDirectory = resolveWorkingDirectory(); - Map environment = buildEnvironment(); - SubprocessExecutionSupport.ExecutionRequest request = - new SubprocessExecutionSupport.ExecutionRequest( - commandLine, workingDirectory, environment, timeoutMs); - + SubprocessExecutionSupport.ExecutionRequest request = new SubprocessExecutionSupport.ExecutionRequest( + commandLine, resolveWorkingDirectory(), buildEnvironment(), timeoutMillis, + properties.getMaxStdoutBytes(), properties.getMaxStderrBytes(), properties.getCleanupGraceMillis(), + submittedAtNanos, cancellationToken); try { - SubprocessExecutionSupport.RunSession session = SubprocessExecutionSupport.execute(request); - return completeAfterWait( - commandLine, - timeoutMs, - session.getStdout(), - session.getStderr(), - session.getHandler(), - session.getWatchdog(), - session.isWaitTimedOut()); - } catch (IOException e) { - log.warn("OpenCLI spawn failed commandLine={}, message={}", commandLine, e.getMessage()); - throw new OpenCliExecutableFailureException( - "OpenCLI could not be started (check PATH or executable path): " + commandLine, e); - } catch (InterruptedException e) { + return complete(processRuntime.execute(request)); + } catch (IOException ex) { + throw new OpenCliExecutableFailureException("OpenCLI process could not be started", ex); + } catch (InterruptedException ex) { Thread.currentThread().interrupt(); - log.warn("OpenCLI interrupted commandLine={}", commandLine); - throw new OpenCliException("Interrupted while awaiting OpenCLI subprocess", e, null); + throw new OpenCliException("Interrupted while awaiting OpenCLI subprocess", ex, null); } } + private OpenCliResult complete(SubprocessExecutionSupport.RunSession session) { + String stdout = new String(session.getStdout().toByteArray(), StandardCharsets.UTF_8); + String stderr = new String(session.getStderr().toByteArray(), StandardCharsets.UTF_8); + OpenCliExecutionDetails details = session.getExecutionDetails(); + TerminationReason reason = details.getTerminationReason(); + Integer exit = session.getObservedExitCode(); + boolean success = reason == TerminationReason.PROCESS_EXIT && Integer.valueOf(0).equals(exit) + && details.isStreamsDrained(); + OpenCliResult result = OpenCliResult.builder().stdout(stdout).stderr(stderr).exitCode(exit) + .success(success).parsed(OpenCliOutputParser.parseBestEffort(stdout, stderr)) + .executionDetails(details).build(); + if (success) { return result; } + if (reason == TerminationReason.QUEUE_TIMEOUT || reason == TerminationReason.EXECUTION_TIMEOUT) { + throw new OpenCliTimeoutException("OpenCLI deadline exceeded: " + reason, result); + } + if (reason == TerminationReason.SPAWN_FAILED) { + throw new OpenCliExecutableFailureException("OpenCLI process could not be started", session.getIoFailure(), result); + } + if (reason == TerminationReason.PROCESS_EXIT && exit != null && exit != 0) { + throw new OpenCliNonZeroExitException("OpenCLI returned nonzero exitCode=" + exit, result); + } + throw new OpenCliException("OpenCLI execution ended: " + reason, session.getIoFailure(), result); + } + private File resolveWorkingDirectory() { - String wdProperty = properties.getWorkingDirectory(); - if (OpenCliStrings.isNotBlank(wdProperty)) { - File wd = new File(wdProperty.trim()); - if (!wd.isDirectory()) { - throw new OpenCliExecutableFailureException( - "opencli.working-directory is not an existing directory: " + wd.getAbsolutePath(), null); + String value = properties.getWorkingDirectory(); + if (OpenCliStrings.isNotBlank(value)) { + File directory = new File(value.trim()); + if (!directory.isDirectory()) { + throw new OpenCliExecutableFailureException("opencli.working-directory is not an existing directory", null); } - return wd; + return directory; } return null; } - private OpenCliResult completeAfterWait( - CommandLine commandLine, - long timeoutMs, - ByteArrayOutputStream out, - ByteArrayOutputStream err, - DefaultExecuteResultHandler handler, - ExecuteWatchdog watchdog, - boolean waitTimedOut) { - String stdoutStr = new String(out.toByteArray(), StandardCharsets.UTF_8); - String stderrStr = new String(err.toByteArray(), StandardCharsets.UTF_8); - OpenCliParsedFields parsed = OpenCliOutputParser.parseBestEffort(stdoutStr, stderrStr); - - if (waitTimedOut || watchdog.killedProcess()) { - log.warn("OpenCLI timed out commandLine={} timeoutMs={}", commandLine, timeoutMs); - OpenCliResult partial = snapshot(stdoutStr, stderrStr, readExitQuietly(handler), parsed); - throw new OpenCliTimeoutException( - "OpenCLI timed out after " + timeoutMs + " ms: " + commandLine, partial); - } - - Exception asyncFailure = handler.getException(); - if (asyncFailure instanceof ExecuteException) { - ExecuteException ex = (ExecuteException) asyncFailure; - log.warn("OpenCLI failed exitCode={} commandLine={}", ex.getExitValue(), commandLine); - OpenCliResult failed = snapshot(stdoutStr, stderrStr, normalizeExitValue(ex.getExitValue()), parsed); - throw new OpenCliNonZeroExitException( - "OpenCLI failed (exitCode=" + ex.getExitValue() + "): " + commandLine, failed); - } - if (Objects.nonNull(asyncFailure)) { - log.error("OpenCLI async failure commandLine={}", commandLine, asyncFailure); - OpenCliResult snapshot = snapshot(stdoutStr, stderrStr, readExitQuietly(handler), parsed); - throw new OpenCliException( - "OpenCLI async failure: " + commandLine + " cause=" + asyncFailure.getMessage(), - asyncFailure, snapshot); - } - - final int exit; - try { - exit = handler.getExitValue(); - } catch (IllegalStateException e) { - throw new OpenCliException( - "OpenCLI completed without observable exit code: " + commandLine, - e, - snapshot(stdoutStr, stderrStr, null, parsed)); - } - - if (exit != 0) { - log.warn("OpenCLI non-zero exit exitCode={} commandLine={}", exit, commandLine); - OpenCliResult failed = snapshot(stdoutStr, stderrStr, exit, parsed); - throw new OpenCliNonZeroExitException( - "OpenCLI non-zero exit (exitCode=" + exit + "): " + commandLine, failed); - } - - return OpenCliResult.builder() - .stdout(stdoutStr) - .stderr(stderrStr) - .exitCode(exit) - .success(true) - .parsed(parsed) - .build(); - } - private Map buildEnvironment() { Map env = new HashMap<>(System.getenv()); - if (Objects.nonNull(properties.getEnvironment())) { - for (Map.Entry e : properties.getEnvironment().entrySet()) { - if (Objects.nonNull(e.getKey()) && Objects.nonNull(e.getValue())) { - env.put(e.getKey(), e.getValue()); - } + if (properties.getEnvironment() != null) { + for (Map.Entry entry : new HashMap<>(properties.getEnvironment()).entrySet()) { + if (entry.getKey() != null && entry.getValue() != null) { env.put(entry.getKey(), entry.getValue()); } } } return env; } - - private static Integer readExitQuietly(DefaultExecuteResultHandler handler) { - try { - return normalizeExitValue(handler.getExitValue()); - } catch (IllegalStateException e) { - return null; - } - } - - private static Integer normalizeExitValue(int raw) { - if (raw == org.apache.commons.exec.Executor.INVALID_EXITVALUE) { - return null; - } - return raw; - } - - private static OpenCliResult snapshot( - String stdoutStr, String stderrStr, Integer exitCode, OpenCliParsedFields parsed) { - return OpenCliResult.builder() - .stdout(Objects.isNull(stdoutStr) ? "" : stdoutStr) - .stderr(Objects.isNull(stderrStr) ? "" : stderrStr) - .exitCode(exitCode) - .success(false) - .parsed(parsed) - .build(); - } } diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliProcessRuntime.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliProcessRuntime.java new file mode 100644 index 0000000..d373f64 --- /dev/null +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliProcessRuntime.java @@ -0,0 +1,37 @@ +package io.github.easy4j.opencli.core; + +import io.github.easy4j.opencli.core.support.SubprocessExecutionSupport; +import java.io.IOException; + +/** + * Stable process-capacity owner. Pass the same instance to multiple executors to + * share a limit deliberately; constructing an unrelated executor cannot replace it. + */ +public final class OpenCliProcessRuntime { + private final SubprocessExecutionSupport.Runtime runtime; + + /** @param maxConcurrent positive limit, or zero for the CPU-derived default; negative is invalid */ + public OpenCliProcessRuntime(int maxConcurrent) { + runtime = new SubprocessExecutionSupport.Runtime(maxConcurrent); + } + + /** @return fixed capacity for this runtime */ + public int getMaxConcurrentExecutions() { return runtime.getMaxConcurrentExecutions(); } + + /** @return whether unconfirmed resource cleanup has quarantined this runtime */ + public boolean isQuarantined() { return runtime.isQuarantined(); } + + /** + * Low-level bridge used by the SDK executor. Results retain bounded process evidence; + * the executor maps terminal conditions to the existing SDK exception hierarchy. + * + * @param request immutable submission snapshot + * @return terminal execution evidence + * @throws IOException retained for compatibility with the low-level execution API + * @throws InterruptedException retained for compatibility; observed interruption is normally returned as CANCELLED + */ + public SubprocessExecutionSupport.RunSession execute(SubprocessExecutionSupport.ExecutionRequest request) + throws IOException, InterruptedException { + return runtime.execute(request); + } +} diff --git a/src/main/java/io/github/easy4j/opencli/core/OpenCliResult.java b/src/main/java/io/github/easy4j/opencli/core/OpenCliResult.java index 2dbfd7a..b1fd2e4 100644 --- a/src/main/java/io/github/easy4j/opencli/core/OpenCliResult.java +++ b/src/main/java/io/github/easy4j/opencli/core/OpenCliResult.java @@ -5,45 +5,23 @@ import lombok.Getter; /** - * 单次 OpenCLI 调用的原始结果载体。 - *

- * {@link #remoteRawHttpBody} 仅在 {@link io.github.easy4j.opencli.OpenCliExecutionTarget#REMOTE_AGENT_HTTP} - * 且 {@link io.github.easy4j.opencli.OpenCliProperties} 的 {@code remoteCaptureRawHttpResponse} 为 true 时填充, - * 为 Agent 返回的完整 HTTP 响应体,便于审计或与 {@code stdout}(由 {@code items} 重组)对照。 - *

- */ -@Getter -@Builder/** - - * Raw result carrier for a single OpenCLI invocation. - * - *

{@link #remoteRawHttpBody} is only populated when using - * {@link io.github.easy4j.opencli.OpenCliExecutionTarget#REMOTE_AGENT_HTTP} - * and {@link io.github.easy4j.opencli.OpenCliProperties#isRemoteCaptureRawHttpResponse()} - * is {@code true}.

- + * Raw result for one OpenCLI invocation. Raw output is business data, not a safe + * diagnostic string. Local execution adds bounded lifecycle evidence; legacy + * remote responses do not acquire an invented process exit or cleanup state. * - * @author Loong Wan - * @since 3.0.0 - */ - +@Getter +@Builder public class OpenCliResult { - private final String stdout; - private final String stderr; - private final Integer exitCode; - private final boolean success; - private final OpenCliParsedFields parsed; - - /** - * 远端 Agent HTTP 响应全文;本地子进程模式或非调试场景下为 null。 - */ + /** Only populated by explicit remote HTTP raw capture. */ private final String remoteRawHttpBody; + /** Observed local execution metadata; null for a legacy remote result. */ + private final OpenCliExecutionDetails executionDetails; } diff --git a/src/main/java/io/github/easy4j/opencli/core/support/SubprocessExecutionSupport.java b/src/main/java/io/github/easy4j/opencli/core/support/SubprocessExecutionSupport.java index f6d77bb..5334915 100644 --- a/src/main/java/io/github/easy4j/opencli/core/support/SubprocessExecutionSupport.java +++ b/src/main/java/io/github/easy4j/opencli/core/support/SubprocessExecutionSupport.java @@ -1,167 +1,386 @@ package io.github.easy4j.opencli.core.support; -import lombok.Getter; -import org.apache.commons.exec.CommandLine; -import org.apache.commons.exec.DefaultExecuteResultHandler; -import org.apache.commons.exec.DefaultExecutor; -import org.apache.commons.exec.ExecuteWatchdog; -import org.apache.commons.exec.PumpStreamHandler; - +import io.github.easy4j.opencli.core.OpenCliCancellationToken; +import io.github.easy4j.opencli.core.OpenCliExecutionDetails; +import io.github.easy4j.opencli.core.OpenCliExecutionDetails.CleanupState; +import io.github.easy4j.opencli.core.OpenCliExecutionDetails.TerminationReason; import java.io.ByteArrayOutputStream; import java.io.File; import java.io.IOException; -import java.time.Duration; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.Semaphore; -import java.util.concurrent.atomic.AtomicReference; +import java.util.concurrent.TimeUnit; +import lombok.Getter; +import org.apache.commons.exec.CommandLine; +import org.apache.commons.exec.DefaultExecuteResultHandler; +import org.apache.commons.exec.ExecuteException; +import org.apache.commons.exec.ExecuteWatchdog; +import java.time.Duration; /** - * Subprocess execution support based on Apache Commons Exec: watchdog timeout, - * bounded {@code waitFor}, and concurrency throttling. - * - * @author Loong Wan - * @since 3.0.0 - */public final class SubprocessExecutionSupport { - - /** Watchdog 触发后,handler 收尾等待的上限(毫秒)。 */ + * Bounded, owned native-process execution. ProcessBuilder receives an exact argv + * vector, never a shell string. Commons Exec handler/watchdog views remain for + * compatibility with the earlier low-level RunSession API. + */ +public final class SubprocessExecutionSupport { public static final long WAIT_GRACE_MILLIS = 5_000L; + public static final int DEFAULT_STDOUT_LIMIT = 8 * 1024 * 1024; + public static final int DEFAULT_STDERR_LIMIT = 2 * 1024 * 1024; + private static final long POLL_NANOS = TimeUnit.MILLISECONDS.toNanos(10L); + private static final int DEFAULT_MAX_CONCURRENT = Math.max(2, java.lang.Runtime.getRuntime().availableProcessors()); + private static final Object LEGACY_LOCK = new Object(); + private static Runtime legacyRuntime = new Runtime(0); + private static int legacySubmissions; - private static final int DEFAULT_MAX_CONCURRENT = Math.max(2, Runtime.getRuntime().availableProcessors()); - - private static final AtomicReference CONCURRENCY_LIMIT = - new AtomicReference<>(new Semaphore(DEFAULT_MAX_CONCURRENT)); - - private SubprocessExecutionSupport() { - } + private SubprocessExecutionSupport() { } /** - * 配置本机 CLI 子进程全局并发上限;{@code maxConcurrent <= 0} 时恢复为默认值。 + * Configure only the deprecated static bridge, never an existing SDK client. + * Reconfiguration with active or queued submissions is rejected rather than + * creating a second live permit pool. * - * @param maxConcurrent 允许同时运行的子进程数 + * @param maxConcurrent positive capacity or zero for the default + * @deprecated pass an explicit OpenCliProcessRuntime to executors instead */ + @Deprecated public static void configureMaxConcurrentExecutions(int maxConcurrent) { - if (maxConcurrent <= 0) { - CONCURRENCY_LIMIT.set(new Semaphore(DEFAULT_MAX_CONCURRENT)); - return; + synchronized (LEGACY_LOCK) { + if (legacySubmissions != 0) { + throw new IllegalStateException("Cannot reconfigure the legacy runtime while submissions exist"); + } + legacyRuntime = new Runtime(maxConcurrent); } - CONCURRENCY_LIMIT.set(new Semaphore(maxConcurrent)); } - /** - * @return 未显式配置时的默认并发上限 - */ - public static int defaultMaxConcurrentExecutions() { - return DEFAULT_MAX_CONCURRENT; - } + public static int defaultMaxConcurrentExecutions() { return DEFAULT_MAX_CONCURRENT; } - /** - * 在并发许可内启动子进程并阻塞至结束、超时或被强制销毁。 - */ + /** Legacy entry point using one stable, explicitly configured runtime. */ public static RunSession execute(ExecutionRequest request) throws IOException, InterruptedException { Objects.requireNonNull(request, "request"); - Semaphore limit = CONCURRENCY_LIMIT.get(); - limit.acquire(); + Runtime selected; + synchronized (LEGACY_LOCK) { + selected = legacyRuntime; + legacySubmissions++; + } try { - return executeWithinLimit(request); + return selected.execute(request); } finally { - limit.release(); + synchronized (LEGACY_LOCK) { legacySubmissions--; } } } - private static RunSession executeWithinLimit(ExecutionRequest request) throws IOException, InterruptedException { - long timeoutMs = Math.max(1L, request.getTimeoutMillis()); - ByteArrayOutputStream out = new ByteArrayOutputStream(); - ByteArrayOutputStream err = new ByteArrayOutputStream(); + /** Internal capacity/cleanup owner exposed through OpenCliProcessRuntime. */ + public static final class Runtime { + private final int maxConcurrentExecutions; + private final Semaphore permits; + private volatile boolean quarantined; - DefaultExecutor.Builder builder = DefaultExecutor.builder(); - if (request.getWorkingDirectory() != null) { - builder.setWorkingDirectory(request.getWorkingDirectory()); + public Runtime(int maxConcurrent) { + if (maxConcurrent < 0) { + throw new IllegalArgumentException("maxConcurrentExecutions must not be negative"); + } + maxConcurrentExecutions = maxConcurrent == 0 ? DEFAULT_MAX_CONCURRENT : maxConcurrent; + permits = new Semaphore(maxConcurrentExecutions, true); } - DefaultExecutor executor = builder.get(); - executor.setStreamHandler(new PumpStreamHandler(out, err)); - - ExecuteWatchdog watchdog = - ExecuteWatchdog.builder().setTimeout(Duration.ofMillis(timeoutMs)).get(); - executor.setWatchdog(watchdog); - - DefaultExecuteResultHandler handler = new DefaultExecuteResultHandler(); - Map environment = request.getEnvironment(); - if (environment != null) { - executor.execute(request.getCommandLine(), environment, handler); - } else { - executor.execute(request.getCommandLine(), handler); + + public int getMaxConcurrentExecutions() { return maxConcurrentExecutions; } + public boolean isQuarantined() { return quarantined; } + + public RunSession execute(ExecutionRequest request) throws IOException, InterruptedException { + Objects.requireNonNull(request, "request"); + long budget = nanos(request.timeoutMillis, "timeoutMillis"); + BoundedCapture out = new BoundedCapture(request.stdoutLimitBytes); + BoundedCapture err = new BoundedCapture(request.stderrLimitBytes); + Process process = null; + Reader stdoutReader = null; + Reader stderrReader = null; + ExecuteWatchdog watchdog = ExecuteWatchdog.builder() + .setTimeout(Duration.ofMillis(request.timeoutMillis)).get(); + TerminationReason reason = null; + IOException ioFailure = null; + boolean acquired = false; + boolean interrupted = false; + long queueWaitMillis = 0L; + try { + while (!acquired && reason == null) { + if (Thread.interrupted()) { + interrupted = true; + reason = TerminationReason.CANCELLED; + } else if (request.cancellationToken.isCancelled()) { + reason = TerminationReason.CANCELLED; + } else if (quarantined) { + reason = TerminationReason.RUNTIME_UNAVAILABLE; + } else { + long remaining = remaining(request.submittedAtNanos, budget); + if (remaining <= 0) { + reason = TerminationReason.QUEUE_TIMEOUT; + } else { + acquired = permits.tryAcquire(Math.min(POLL_NANOS, remaining), TimeUnit.NANOSECONDS); + } + } + } + queueWaitMillis = elapsedMillis(request.submittedAtNanos); + if (acquired && reason == null) { + if (Thread.interrupted()) { + interrupted = true; + reason = TerminationReason.CANCELLED; + } else if (request.cancellationToken.isCancelled()) { + reason = TerminationReason.CANCELLED; + } else if (quarantined) { + reason = TerminationReason.RUNTIME_UNAVAILABLE; + } else if (remaining(request.submittedAtNanos, budget) <= 0) { + reason = TerminationReason.QUEUE_TIMEOUT; + } + } + if (acquired && reason == null) { + ProcessBuilder builder = new ProcessBuilder(request.nativeArgv); + if (request.workingDirectory != null) { builder.directory(request.workingDirectory); } + if (request.environment != null) { + builder.environment().clear(); + builder.environment().putAll(request.environment); + } + process = builder.start(); + process.getOutputStream().close(); + stdoutReader = new Reader(process.getInputStream(), out, "opencli-stdout"); + stderrReader = new Reader(process.getErrorStream(), err, "opencli-stderr"); + stdoutReader.start(); + stderrReader.start(); + long remaining = remaining(request.submittedAtNanos, budget); + watchdog = ExecuteWatchdog.builder().setTimeout(Duration.ofMillis( + Math.max(1L, TimeUnit.NANOSECONDS.toMillis(Math.max(0L, remaining))))).get(); + watchdog.start(process); + while (reason == null) { + if (out.isTruncated() || err.isTruncated()) { + reason = TerminationReason.OUTPUT_LIMIT; + } else if (Thread.interrupted()) { + interrupted = true; + reason = TerminationReason.CANCELLED; + } else if (request.cancellationToken.isCancelled()) { + reason = TerminationReason.CANCELLED; + } else if (stdoutReader.failure != null || stderrReader.failure != null) { + reason = TerminationReason.IO_FAILURE; + } else if (!process.isAlive() && !stdoutReader.isAlive() && !stderrReader.isAlive()) { + reason = watchdog.killedProcess() ? TerminationReason.EXECUTION_TIMEOUT : TerminationReason.PROCESS_EXIT; + } else if (watchdog.killedProcess() || remaining(request.submittedAtNanos, budget) <= 0) { + reason = TerminationReason.EXECUTION_TIMEOUT; + } else { + TimeUnit.NANOSECONDS.sleep(Math.min(POLL_NANOS, Math.max(1L, + remaining(request.submittedAtNanos, budget)))); + } + } + } + } catch (InterruptedException ex) { + interrupted = true; + reason = TerminationReason.CANCELLED; + } catch (IOException ex) { + ioFailure = ex; + reason = process == null ? TerminationReason.SPAWN_FAILED : TerminationReason.IO_FAILURE; + } finally { + watchdog.stop(); + long cleanupStart = System.nanoTime(); + long cleanupBudget = nanos(request.cleanupGraceMillis, "cleanupGraceMillis"); + if (process != null) { + if (process.isAlive()) { process.destroy(); } + boolean forceSent = false; + while (remaining(cleanupStart, cleanupBudget) > 0 + && (process.isAlive() || alive(stdoutReader) || alive(stderrReader))) { + if (Thread.interrupted()) { interrupted = true; } + if (process.isAlive() && !forceSent + && System.nanoTime() - cleanupStart >= Math.min(TimeUnit.MILLISECONDS.toNanos(100L), cleanupBudget / 2)) { + process.destroyForcibly(); + forceSent = true; + } + try { + TimeUnit.NANOSECONDS.sleep(Math.min(POLL_NANOS, + Math.max(1L, remaining(cleanupStart, cleanupBudget)))); + } catch (InterruptedException ex) { + interrupted = true; + } + } + if (process.isAlive()) { process.destroyForcibly(); } + if (process.isAlive() || alive(stdoutReader) || alive(stderrReader)) { + // A fixed runtime cannot accumulate unlimited uncertain children/readers. + quarantined = true; + if (reason == TerminationReason.PROCESS_EXIT) { reason = TerminationReason.CLEANUP_UNCONFIRMED; } + } + } + out.freeze(); + err.freeze(); + if (acquired) { permits.release(); } + if (interrupted) { Thread.currentThread().interrupt(); } + } + if (reason == TerminationReason.PROCESS_EXIT && (out.isTruncated() || err.isTruncated())) { + reason = TerminationReason.OUTPUT_LIMIT; + } + Integer exit = process != null && !process.isAlive() ? process.exitValue() : null; + DefaultExecuteResultHandler handler = new DefaultExecuteResultHandler(); + if (ioFailure != null) { + handler.onProcessFailed(new ExecuteException("Native process I/O failure", exit == null ? -1 : exit, ioFailure)); + } else if (exit != null && exit != 0) { + handler.onProcessFailed(new ExecuteException("Native process returned nonzero status", exit)); + } else if (exit != null) { + handler.onProcessComplete(exit); + } + OpenCliExecutionDetails details = OpenCliExecutionDetails.builder() + .terminationReason(reason) + .cleanupState(process == null ? CleanupState.NOT_STARTED + : process.isAlive() ? CleanupState.UNCONFIRMED : CleanupState.ROOT_EXIT_CONFIRMED) + .processStarted(process != null).streamsDrained(!alive(stdoutReader) && !alive(stderrReader)) + .stdoutCapturedBytes(out.size()).stdoutObservedBytes(out.observed()).stdoutTruncated(out.isTruncated()) + .stderrCapturedBytes(err.size()).stderrObservedBytes(err.observed()).stderrTruncated(err.isTruncated()) + .elapsedMillis(elapsedMillis(request.submittedAtNanos)).queueWaitMillis(queueWaitMillis) + .descendantsExitConfirmed(false).build(); + return new RunSession(out, err, handler, watchdog, request.timeoutMillis, + reason == TerminationReason.QUEUE_TIMEOUT || reason == TerminationReason.EXECUTION_TIMEOUT, + details, exit, ioFailure); + } + } + + private static boolean alive(Thread thread) { return thread != null && thread.isAlive(); } + private static long remaining(long start, long budget) { return budget - (System.nanoTime() - start); } + private static long elapsedMillis(long start) { return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - start); } + + private static long nanos(long millis, String field) { + if (millis <= 0 || millis > Long.MAX_VALUE / 1_000_000L) { + throw new IllegalArgumentException(field + " must be positive and fit a monotonic nanosecond budget"); + } + return millis * 1_000_000L; + } + + private static final class BoundedCapture extends ByteArrayOutputStream { + private final int limit; + private long observed; + private boolean truncated; + private boolean frozen; + + BoundedCapture(int limit) { + super(Math.min(8192, limit)); + this.limit = limit; } - boolean finished = awaitResult(handler, timeoutMs + WAIT_GRACE_MILLIS); - boolean waitTimedOut = !finished; - if (waitTimedOut) { - watchdog.destroyProcess(); - awaitResult(handler, WAIT_GRACE_MILLIS); + @Override + public synchronized void write(byte[] bytes, int offset, int length) { + if (frozen) { return; } + observed = observed > Long.MAX_VALUE - length ? Long.MAX_VALUE : observed + length; + int retained = Math.min(length, limit - count); + super.write(bytes, offset, retained); + truncated |= retained < length; } - return new RunSession(out, err, handler, watchdog, timeoutMs, waitTimedOut); + @Override + public synchronized void write(int value) { write(new byte[]{(byte) value}, 0, 1); } + synchronized boolean isTruncated() { return truncated; } + synchronized long observed() { return observed; } + synchronized void freeze() { frozen = true; } } - private static boolean awaitResult(DefaultExecuteResultHandler handler, long timeoutMillis) - throws InterruptedException { - long deadline = System.currentTimeMillis() + Math.max(1L, timeoutMillis); - while (!handler.hasResult()) { - if (System.currentTimeMillis() >= deadline) { - return false; + private static final class Reader extends Thread { + private final InputStream input; + private final BoundedCapture capture; + private volatile IOException failure; + + Reader(InputStream input, BoundedCapture capture, String name) { + super(name); + this.input = input; + this.capture = capture; + setDaemon(true); + } + + @Override + public void run() { + try (InputStream stream = input) { + byte[] buffer = new byte[8192]; + int size; + while ((size = stream.read(buffer)) != -1) { + capture.write(buffer, 0, size); + if (capture.isTruncated()) { return; } + } + } catch (IOException ex) { + failure = ex; } - Thread.sleep(Math.min(50L, deadline - System.currentTimeMillis())); } - return true; } @Getter public static final class ExecutionRequest { - private final CommandLine commandLine; + private final List nativeArgv; private final File workingDirectory; private final Map environment; private final long timeoutMillis; + private final int stdoutLimitBytes; + private final int stderrLimitBytes; + private final long cleanupGraceMillis; + private final long submittedAtNanos; + private final OpenCliCancellationToken cancellationToken; - public ExecutionRequest( - CommandLine commandLine, - File workingDirectory, - Map environment, - long timeoutMillis) { + public ExecutionRequest(CommandLine commandLine, File workingDirectory, + Map environment, long timeoutMillis) { + this(commandLine, workingDirectory, environment, timeoutMillis, + DEFAULT_STDOUT_LIMIT, DEFAULT_STDERR_LIMIT, WAIT_GRACE_MILLIS, + System.nanoTime(), new OpenCliCancellationToken()); + } + + public ExecutionRequest(CommandLine commandLine, File workingDirectory, + Map environment, long timeoutMillis, int stdoutLimitBytes, + int stderrLimitBytes, long cleanupGraceMillis, long submittedAtNanos, + OpenCliCancellationToken cancellationToken) { this.commandLine = Objects.requireNonNull(commandLine, "commandLine"); + List argv = new ArrayList<>(Arrays.asList(commandLine.toStrings())); + for (int i = 0; i < argv.size(); i++) { + if (argv.get(i) == null) { throw new IllegalArgumentException("nativeArgv[" + i + "] must not be null"); } + } + nativeArgv = Collections.unmodifiableList(argv); this.workingDirectory = workingDirectory; - this.environment = environment; + this.environment = environment == null ? null : Collections.unmodifiableMap(new HashMap<>(environment)); + nanos(timeoutMillis, "timeoutMillis"); + nanos(cleanupGraceMillis, "cleanupGraceMillis"); + if (stdoutLimitBytes <= 0 || stderrLimitBytes <= 0) { + throw new IllegalArgumentException("stdout/stderr capture budgets must be positive"); + } this.timeoutMillis = timeoutMillis; + this.stdoutLimitBytes = stdoutLimitBytes; + this.stderrLimitBytes = stderrLimitBytes; + this.cleanupGraceMillis = cleanupGraceMillis; + this.submittedAtNanos = submittedAtNanos; + this.cancellationToken = Objects.requireNonNull(cancellationToken, "cancellationToken"); } } @Getter public static final class RunSession { - private final ByteArrayOutputStream stdout; private final ByteArrayOutputStream stderr; private final DefaultExecuteResultHandler handler; private final ExecuteWatchdog watchdog; private final long timeoutMillis; private final boolean waitTimedOut; + private final OpenCliExecutionDetails executionDetails; + private final Integer observedExitCode; + private final IOException ioFailure; - RunSession( - ByteArrayOutputStream stdout, - ByteArrayOutputStream stderr, - DefaultExecuteResultHandler handler, - ExecuteWatchdog watchdog, - long timeoutMillis, - boolean waitTimedOut) { + RunSession(ByteArrayOutputStream stdout, ByteArrayOutputStream stderr, + DefaultExecuteResultHandler handler, ExecuteWatchdog watchdog, long timeoutMillis, + boolean waitTimedOut, OpenCliExecutionDetails executionDetails, + Integer observedExitCode, IOException ioFailure) { this.stdout = stdout; this.stderr = stderr; this.handler = handler; this.watchdog = watchdog; this.timeoutMillis = timeoutMillis; this.waitTimedOut = waitTimedOut; + this.executionDetails = executionDetails; + this.observedExitCode = observedExitCode; + this.ioFailure = ioFailure; } - public boolean timedOut() { - return waitTimedOut || watchdog.killedProcess(); - } + public boolean timedOut() { return waitTimedOut; } } } diff --git a/src/main/java/io/github/easy4j/opencli/exception/OpenCliExecutableFailureException.java b/src/main/java/io/github/easy4j/opencli/exception/OpenCliExecutableFailureException.java index 378bd95..270f2c7 100644 --- a/src/main/java/io/github/easy4j/opencli/exception/OpenCliExecutableFailureException.java +++ b/src/main/java/io/github/easy4j/opencli/exception/OpenCliExecutableFailureException.java @@ -2,14 +2,14 @@ import io.github.easy4j.opencli.core.OpenCliResult; -/** - * Thrown when the OpenCLI executable cannot be started (PATH, permissions, invalid arguments, etc.). - * - * @author Loong Wan - * @since 3.0.0 - */public class OpenCliExecutableFailureException extends OpenCliException { - +/** Failure to start the configured executable. */ +public class OpenCliExecutableFailureException extends OpenCliException { public OpenCliExecutableFailureException(String message, Throwable cause) { super(message, cause, null); } + + /** Retains a no-process-started snapshot without inventing an exit code. */ + public OpenCliExecutableFailureException(String message, Throwable cause, OpenCliResult partialResult) { + super(message, cause, partialResult); + } } From 3bacc8d09b1eeeaa05d6137e2a7e9c350d6622ce Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Mon, 21 Sep 2026 13:24:21 +0800 Subject: [PATCH 09/13] test(contracts): extend lifecycle boundaries and layered execution evidence Add UTF-8 byte-boundary, budget validation, running/queued cancellation, configuration copy and repeated timeout regression tests. Report both argv suites and process suites separately from live OpenCLI evidence. Runner extension observed local RED then GREEN: 13 self-tests pass. Archive only committed source alongside exact-head CI evidence for reproducible review. --- .github/workflows/contracts.yml | 5 + scripts/contract_report.py | 5 +- scripts/tests/test_contract_report.py | 9 + .../opencli/contract/LifecycleProbe.java | 6 +- .../contract/OpenCliProcessBoundaryTest.java | 162 ++++++++++++++++++ 5 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessBoundaryTest.java diff --git a/.github/workflows/contracts.yml b/.github/workflows/contracts.yml index 5f9c448..58a9d9c 100644 --- a/.github/workflows/contracts.yml +++ b/.github/workflows/contracts.yml @@ -88,6 +88,8 @@ jobs: run: | mkdir -p contract-evidence git rev-parse HEAD > contract-evidence/head.txt + git archive -o contract-evidence/source.tar HEAD + sha256sum contract-evidence/source.tar > contract-evidence/source.sha256 java -version > contract-evidence/java.txt 2>&1 if ! bash ./mvnw -version > contract-evidence/maven.txt 2>&1; then cat contract-evidence/maven.txt @@ -117,6 +119,9 @@ jobs: --maven-version-file contract-evidence/maven.txt \ --exit-code "$(cat contract-evidence/maven.exit)" \ --suite argv=io.github.easy4j.opencli.contract.OpenCliArgvContractTest \ + --suite argv=io.github.easy4j.opencli.contract.OpenCliStructuredArgvContractTest \ + --suite process=io.github.easy4j.opencli.contract.OpenCliProcessContractTest \ + --suite process=io.github.easy4j.opencli.contract.OpenCliProcessBoundaryTest \ --output contract-evidence/report.json - name: Upload actual JVM evidence if: always() diff --git a/scripts/contract_report.py b/scripts/contract_report.py index 19fcbba..b0c8c78 100644 --- a/scripts/contract_report.py +++ b/scripts/contract_report.py @@ -11,7 +11,7 @@ import sys import xml.etree.ElementTree as ET -LAYERS = ('enumeration', 'argv', 'protocol', 'typed-result', 'real-execution') +LAYERS = ('enumeration', 'argv', 'process', 'protocol', 'typed-result', 'real-execution') def _suite_summary(suite): @@ -50,7 +50,6 @@ def build_report(reports_dir, *, head, branch, java_version, maven_version, exit raise ValueError('duplicate testsuite') suites[name] = _suite_summary(suite) except (ET.ParseError, OSError, KeyError, ValueError): - # Do not copy testcase failure bodies or captured application output. problems.append('invalid or duplicate Surefire report: ' + path.name) layers = {} for layer in LAYERS: @@ -71,7 +70,7 @@ def build_report(reports_dir, *, head, branch, java_version, maven_version, exit 'head': head, 'branch': branch, 'javaVersion': java_version, 'mavenVersion': maven_version, 'commandExitCode': exit_code, 'layers': layers, 'problems': problems, - 'scope': 'Explicit Surefire suites only; synthetic argv probes are not live OpenCLI verification.'} + 'scope': 'Explicit Surefire suites only; synthetic argv/process probes are not live OpenCLI verification.'} def main(argv=None): diff --git a/scripts/tests/test_contract_report.py b/scripts/tests/test_contract_report.py index f466e0a..b9bfd2e 100644 --- a/scripts/tests/test_contract_report.py +++ b/scripts/tests/test_contract_report.py @@ -44,6 +44,15 @@ def test_complete_argv_evidence_passes_but_live_is_not_run(self): self.assertEqual(HEAD, report['head']) json.dumps(report) + def test_process_layer_is_separate_from_real_execution(self): + self.xml() + report = self.module.build_report(self.root, head=HEAD, branch='feature/2.0.x', + java_version='fixture-jdk', maven_version='fixture-maven', exit_code=0, + required_suites={'process': [SUITE]}) + self.assertEqual('PASS', report['status']) + self.assertEqual(2, report['layers']['process']['executed']) + self.assertEqual('NOT_RUN', report['layers']['real-execution']['status']) + def test_missing_report_fails(self): self.assertEqual('FAIL', self.report()['status']) diff --git a/src/test/java/io/github/easy4j/opencli/contract/LifecycleProbe.java b/src/test/java/io/github/easy4j/opencli/contract/LifecycleProbe.java index f6e6874..88247cf 100644 --- a/src/test/java/io/github/easy4j/opencli/contract/LifecycleProbe.java +++ b/src/test/java/io/github/easy4j/opencli/contract/LifecycleProbe.java @@ -6,12 +6,16 @@ import java.nio.file.Paths; import java.util.Arrays; -/** Offline, self-bounded child fixture. Release files allow test cleanup even against broken SDKs. */ +/** Offline, self-bounded child fixture. Release files allow cleanup even against a broken SDK. */ public final class LifecycleProbe { private LifecycleProbe() { } public static void main(String[] args) throws Exception { String mode = args[0]; + if ("utf8".equals(mode)) { + System.out.write("中文".getBytes(StandardCharsets.UTF_8)); + return; + } if ("stdout".equals(mode) || "stderr".equals(mode)) { byte[] block = new byte[8192]; Arrays.fill(block, (byte) 'x'); diff --git a/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessBoundaryTest.java b/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessBoundaryTest.java new file mode 100644 index 0000000..389140c --- /dev/null +++ b/src/test/java/io/github/easy4j/opencli/contract/OpenCliProcessBoundaryTest.java @@ -0,0 +1,162 @@ +package io.github.easy4j.opencli.contract; + +import io.github.easy4j.opencli.OpenCliProperties; +import io.github.easy4j.opencli.core.OpenCliCancellationToken; +import io.github.easy4j.opencli.core.OpenCliExecutionDetails.TerminationReason; +import io.github.easy4j.opencli.core.OpenCliExecutor; +import io.github.easy4j.opencli.core.OpenCliResult; +import io.github.easy4j.opencli.exception.OpenCliException; +import io.github.easy4j.opencli.exception.OpenCliTimeoutException; +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.io.TempDir; +import static org.junit.jupiter.api.Assertions.*; + +/** Additional finite-budget and immutable-submission regression vectors. */ +@Timeout(20) +class OpenCliProcessBoundaryTest { + @TempDir Path dir; + + private static OpenCliProperties properties() { + OpenCliProperties p = new OpenCliProperties(); + String exe = System.getProperty("os.name").startsWith("Windows") ? "java.exe" : "java"; + p.setExecutable(new File(new File(System.getProperty("java.home"), "bin"), exe).getAbsolutePath()); + p.setLeadingArguments(new ArrayList<>(Arrays.asList("-cp", + System.getProperty("surefire.test.class.path", System.getProperty("java.class.path")), + LifecycleProbe.class.getName()))); + p.setCommandTimeoutMillis(10000L); + p.setMaxConcurrentExecutions(1); + return p; + } + + private static void await(Path path) throws Exception { + long started = System.nanoTime(); + while (!Files.exists(path) && System.nanoTime() - started < TimeUnit.SECONDS.toNanos(3L)) { Thread.sleep(10L); } + assertTrue(Files.exists(path), "fixture did not start"); + } + + @Test + void utf8TruncationReportsBytesNotReencodedCharacters() { + OpenCliProperties p = properties(); + p.setMaxStdoutBytes(4); + OpenCliException failure = assertThrows(OpenCliException.class, () -> new OpenCliExecutor(p).invoke("utf8")); + OpenCliResult partial = failure.getPartialResult(); + assertNotNull(partial); + assertEquals(TerminationReason.OUTPUT_LIMIT, partial.getExecutionDetails().getTerminationReason()); + assertEquals(4L, partial.getExecutionDetails().getStdoutCapturedBytes()); + assertEquals(6L, partial.getExecutionDetails().getStdoutObservedBytes()); + assertEquals("中\uFFFD", partial.getStdout()); + assertTrue(partial.getExecutionDetails().isStdoutTruncated()); + } + + @Test + void invalidBudgetsFailBeforeChildCreation() { + for (int choice = 0; choice < 5; choice++) { + OpenCliProperties p = properties(); + if (choice == 0) { p.setMaxStdoutBytes(0); } + if (choice == 1) { p.setMaxStderrBytes(-1); } + if (choice == 2) { p.setCleanupGraceMillis(0); } + if (choice == 3) { p.setCommandTimeoutMillis(Long.MAX_VALUE); } + if (choice == 4) { p.setCleanupGraceMillis(Long.MAX_VALUE); } + Path marker = dir.resolve("invalid-" + choice); + assertThrows(IllegalArgumentException.class, () -> new OpenCliExecutor(p).invoke("write", marker.toString())); + assertFalse(Files.exists(marker)); + } + } + + @Test + void explicitCancellationOfRunningChildRetainsBoundedEvidence() throws Exception { + OpenCliExecutor executor = new OpenCliExecutor(properties()); + OpenCliCancellationToken token = new OpenCliCancellationToken(); + Path heartbeat = dir.resolve("heartbeat"); + Path release = dir.resolve("release"); + ExecutorService worker = Executors.newSingleThreadExecutor(); + try { + Future future = worker.submit(() -> executor.invoke( + Arrays.asList("heartbeat", heartbeat.toString(), release.toString()), token)); + await(heartbeat); + token.cancel(); + ExecutionException failed = assertThrows(ExecutionException.class, () -> future.get(3, TimeUnit.SECONDS)); + assertTrue(failed.getCause() instanceof OpenCliException); + OpenCliResult partial = ((OpenCliException) failed.getCause()).getPartialResult(); + assertEquals(TerminationReason.CANCELLED, partial.getExecutionDetails().getTerminationReason()); + assertTrue(partial.getExecutionDetails().isProcessStarted()); + assertFalse(partial.getExecutionDetails().isDescendantsExitConfirmed()); + String stopped = new String(Files.readAllBytes(heartbeat), StandardCharsets.UTF_8); + Thread.sleep(100L); + assertEquals(stopped, new String(Files.readAllBytes(heartbeat), StandardCharsets.UTF_8)); + assertTrue(executor.invoke("write", dir.resolve("next").toString()).isSuccess()); + } finally { + Files.write(release, new byte[]{1}); + worker.shutdownNow(); + assertTrue(worker.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void queuedTokenCancellationDoesNotStartTheWaitingChild() throws Exception { + OpenCliExecutor executor = new OpenCliExecutor(properties()); + Path first = dir.resolve("first"); + Path second = dir.resolve("second"); + Path gate = dir.resolve("release"); + OpenCliCancellationToken token = new OpenCliCancellationToken(); + ExecutorService workers = Executors.newFixedThreadPool(2); + try { + Future one = workers.submit(() -> executor.invoke("hold", first.toString(), gate.toString())); + await(first); + Future two = workers.submit(() -> executor.invoke(Arrays.asList("write", second.toString()), token)); + Thread.sleep(100L); + token.cancel(); + ExecutionException failed = assertThrows(ExecutionException.class, () -> two.get(2, TimeUnit.SECONDS)); + OpenCliResult partial = ((OpenCliException) failed.getCause()).getPartialResult(); + assertEquals(TerminationReason.CANCELLED, partial.getExecutionDetails().getTerminationReason()); + assertFalse(partial.getExecutionDetails().isProcessStarted()); + assertNull(partial.getExitCode()); + assertFalse(Files.exists(second)); + Files.write(gate, new byte[]{1}); + assertTrue(one.get(3, TimeUnit.SECONDS).isSuccess()); + } finally { + Files.write(gate, new byte[]{1}); + workers.shutdownNow(); + assertTrue(workers.awaitTermination(5, TimeUnit.SECONDS)); + } + } + + @Test + void timeoutAndCaptureConfigurationAreCopiedForReverseWorkers() { + OpenCliProperties p = properties(); + p.setMaxStdoutBytes(123); + p.setMaxStderrBytes(456); + p.setCleanupGraceMillis(789); + OpenCliProperties copy = p.copyForLocalCliExecution(); + assertEquals(123, copy.getMaxStdoutBytes()); + assertEquals(456, copy.getMaxStderrBytes()); + assertEquals(789L, copy.getCleanupGraceMillis()); + } + + @Test + void repeatedExecutionTimeoutsDoNotBecomeNonzeroOrIoFailures() { + OpenCliProperties p = properties(); + p.setCommandTimeoutMillis(100L); + OpenCliExecutor executor = new OpenCliExecutor(p); + for (int i = 0; i < 5; i++) { + Path marker = dir.resolve("timeout-" + i); + OpenCliTimeoutException failure = assertThrows(OpenCliTimeoutException.class, + () -> executor.invoke("hold", marker.toString(), dir.resolve("never-release").toString())); + assertEquals(TerminationReason.EXECUTION_TIMEOUT, failure.getPartialResult().getExecutionDetails().getTerminationReason()); + assertFalse(executor.getProcessRuntime().isQuarantined()); + } + } +} From d2690ce76692d593044614e20f4dd2ae1e080176 Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Mon, 21 Sep 2026 13:38:16 +0800 Subject: [PATCH 10/13] docs(implementation): record verified C10 C01 C02 progress and open review gates Record exact RED/GREEN commits, 2052-test evidence, 43 argv/14 process contracts, 13 evidence-runner self-tests and ten official OpenSpec strict validations. Keep remaining edge cases, independent review, platform/backport work and the three-branch integration gate explicitly open; no OpenSpec tasks are pre-closed. Preserve the intentional trailing TSV separator with a path-scoped attribute. No product Java or dependency changes in this documentation checkpoint. --- .gitattributes | 2 + docs/implementation/c10-c01-execution.md | 80 ++++++++++++++++++++---- openspec/implementation-status.json | 34 ++++++++++ 3 files changed, 104 insertions(+), 12 deletions(-) create mode 100644 .gitattributes create mode 100644 openspec/implementation-status.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c9683ed --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# The last TSV field intentionally encodes an empty argv token. Do not trim it. +src/test/resources/opencli-contracts/v1/argv.tsv whitespace=-blank-at-eol diff --git a/docs/implementation/c10-c01-execution.md b/docs/implementation/c10-c01-execution.md index dab1be4..679e197 100644 --- a/docs/implementation/c10-c01-execution.md +++ b/docs/implementation/c10-c01-execution.md @@ -1,23 +1,79 @@ -# C10 foundation and C01 implementation ledger +# C10 基础与 C01 / C02 第一批实施记录 -## Approved ordering and scope +> 2026-09-21。状态:IMPLEMENTATION_IN_PROGRESS / REVIEW_REQUIRED。 +> 本文记录实际实现和实际验证,不代表十个 Change 已完成,也不关闭三分支集成门禁。 -The user approved C10 foundation, then C01/C02/C03/C04/C05/C09, then C06/C07/C08, and only then C10 integration closure. `feature/2.0.x` is the canonical implementation line. This file records execution, not completion of the ten changes. +## 1. 执行顺序与分支边界 -An isolated implementation branch starts at `d0c8056990f7a47fcc202acffa387ba066bcfc67`. The 1.x/3.x baseline refs and permitted JDK/Jackson/Maven differences are recorded in `src/test/resources/opencli-contracts/v1/sources.lock.json`. No changes to `main`, dependency versions or coverage thresholds are part of this increment. +顺序保持:C10 共享测试基础 → C01/C02/C03/C04/C05/C09 → C06/C07/C08 → C10 最终集成。 -## C10 foundation +标准实现线为 `feature/2.0.x`。本批在隔离分支 `feature/2.0.x-contract-hardening` 实施,基点为 `d0c8056990f7a47fcc202acffa387ba066bcfc67`。1.x 基点 `abba809f11dae68437c39d2ea5a2f4cf8798c0ef`,3.x 基点 `6e38904bdfcae90ec617e8d29bf3d8cf2f002893`。共享 fixture 的 sources.lock.json 保存三线 Java/Jackson/Maven 允许差异。当前实施没有移动这三个原始 feature 分支,也没有修改 main。 -Shared synthetic UTF-8/base64 argv vectors preserve empty and trailing empty fields. They have content hashes and a specification ref, not an invented upstream capture provenance. A Java 8-compatible child prints each actual argument. The regression suite uses the real SDK executor, adapter and Browser paths; a Recording executor is not used to prove process behavior. +本批未开始 C03/C04/C05/C06/C07/C08 的新实现;移除本地 Executor/Adapter 默认参数日志只是 C09 的局部改进,不等于整个 SDK 的日志/异常隐私治理完成。 -The report runner separates enumeration, argv, protocol, typed-result and real-execution evidence. Only explicitly selected suites contribute; the real-execution layer remains NOT_RUN because a Java argv probe is not a live OpenCLI website test. Missing reports, zero tests, skipped required tests, nonzero Maven exit, malformed XML and inconsistent testcase counts fail closed. +## 2. 实际 RED → GREEN -Local runner TDD: 12 failures before the runner existed, then 12 passing tests. The probe compiled with `javac --release 8` on JDK 21 and emitted an empty token unchanged. This does not constitute an actual JDK 8 runtime test. Full JVM evidence is produced by the branch-specific GitHub Actions workflow using each line's checked-in Maven wrapper. +| 阶段 | 精确提交 | 验证结果 | GitHub Actions run | +|---|---|---|---| +| C01 原始参数 RED | `94de053f4ae7a0912201c2bbeab81b8332f660b8` | 全套 2027 项;21 failures、0 errors、0 skipped。新增 33 项参数契约中 21 项失败 | [35562286431](https://github.com/easy-4-java/opencli-java-sdk/actions/runs/35562286431) | +| SDK 层修复后的剩余 RED | `c93a25c182f0b0acd15cdfa01089599b52601854` | 2028 项;仍有 11 项失败,原因在 Commons Exec 内部 trim | [35562634249](https://github.com/easy-4-java/opencli-java-sdk/actions/runs/35562634249) | +| 原始参数 GREEN | `6f830b4f2e5b87bb57eb6b3b669fd299ee48ee39` | clean verify、参数契约门禁、官方 strict、报告上传通过 | [35562782412](https://github.com/easy-4-java/opencli-java-sdk/actions/runs/35562782412) | +| 有序选项/快照 RED | `d02b16ddc5e0fe7c1d98c6847f4b2999cf2f2566` | 2038 项;10 failures、0 errors、0 skipped;原始 33 项参数契约仍通过 | [35562952517](https://github.com/easy-4-java/opencli-java-sdk/actions/runs/35562952517) | +| 有序选项/快照 GREEN | `48b14388bc1c86fcf1dff2705f0140a541a3fa75` | 完整验证与官方 strict 通过 | [35563156619](https://github.com/easy-4-java/opencli-java-sdk/actions/runs/35563156619) | +| C02 资源行为 RED | `31bde9cc4bda23519b6a81d66df7ddb781d94548` | 2046 项;新增八项全部失败;0 errors、0 skipped | [35563763025](https://github.com/easy-4-java/opencli-java-sdk/actions/runs/35563763025) | +| 执行核 GREEN | `98e3db1f8883b8485ec8a428238173dc1148dfb4` | 完整验证、资源回归、官方 strict 通过 | [35564181728](https://github.com/easy-4-java/opencli-java-sdk/actions/runs/35564181728) | +| 扩展边界 GREEN | `3bacc8d09b1eeeaa05d6137e2a7e9c350d6622ce` | Surefire XML 实际合计 2052 项,0 failures、0 errors、0 skipped;argv 43 项、process 14 项 | [35564455068](https://github.com/easy-4-java/opencli-java-sdk/actions/runs/35564455068) | -## C01 RED checkpoint +初始 `cdc8b66` 的 CI 因 Wrapper JAR 缺失、隐藏证据目录未被上传而失败;它不是有效行为 RED。修复构建基础后才得到表中的真实失败证据。2.x Wrapper 复用仓库已有的 launcher JAR,保留 Maven 3.9.16 分发;未修改 POM 依赖版本或降低 JaCoCo 门槛。 -The initial Java contract suite deliberately demands lossless values before any product source is changed. The first CI run must be inspected for assertion failures at the real child boundary, not treated as a completed fix. Compilation errors or tool setup failures are not valid RED proof. +## 3. C10 已建立的基础 -## Still open +`src/test/resources/opencli-contracts/v1/` 包含版本化的 UTF-8/base64 参数向量、内容 hash、来源 lock、三线基点和允许差异。向量明确标为 synthetic offline,不伪装成上游或网站采集结果。TSV 最后一列可以为空;限定到该文件的 .gitattributes 保留末尾分隔符,不允许格式化器把空参数删除。 -C01 schema-aware repeated/false option handling, C02/C03/C04/C05/C09 production fixes, discovery, Browser result models, context/diagnostics and three-branch integration closure are not complete. Official OpenSpec strict is configured but must be observed at the exact workflow run before claiming it passed. No OpenSpec implementation tasks are pre-checked. +`ContractProbe` 输出真实 JVM 参数,覆盖 Executor List/varargs、Adapter List/varargs、结构化 request 与 Browser fill 路径。不是只断言 Recording executor 的前两项。 + +`scripts/contract_report.py` 分开记录 enumeration、argv、process、protocol、typed-result、real-execution。缺报告、零测试、required suite 被跳过、实际 Maven exit 非零、坏 XML、声明数量与实际 testcase 不一致均失败。13 项 Python 检查器测试经过本地 RED→GREEN,并在 CI 再次运行。 + +NOT_RUN 表示本轮没有为该层选择新的契约证据,不表示既有协议/解析测试被跳过;完整 Maven 套件仍运行全部既有测试。Java 子进程探针不等于 live OpenCLI/网站验证。 + +`.github/workflows/contracts.yml` 使用分支对应 JDK 与 checked-in Wrapper,记录精确 HEAD、实际 Java/Maven 版本、退出码、Surefire XML、JaCoCo 和分层报告。所有十个 Change 均使用官方 OpenSpec 1.13.1 逐项及 `--all --strict --no-interactive` 校验;在上述绿色 run 中均通过。 + +## 4. C01 的实现内容和兼容约束 + +参数值原样保留空字符串、空白、换行、Unicode、引号、`--` 和含 `=` 的内容。null 元素按字段和索引报错,不在错误中拼接其它参数值。命令标识符与值分开校验;输入在排队之前做快照。 + +Commons Exec 的 Argument 构造器在 handleQuoting=false 时仍 trim;先由 LiteralCommandLine 保留原始向量,当前执行核再把完整向量直接交给 ProcessBuilder。没有自动 shell 展开或拼接 shell 字符串。 + +OpenCliOptionSchema / OpenCliOption 增量支持带值选项、可重复有序 occurrences、显式 false 和否定 flag,拒绝 schema 冲突与 legacy/ordered 重叠。旧 Map 中 Boolean 仍保持历史 presence-only 行为;不能把旧 false 静默改成否定 flag。Map 和可变对象的常规值在构造时捕获。 + +Windows `.cmd/.bat` 被明确拒绝,调用方应使用原生 node 可执行文件及 JS 路径;Windows/macOS 实际执行尚未验证。legacy appendQuotedKeyValue 保留,不作为新的 literal 路径使用。 + +## 5. C02 的实现内容和边界 + +每个 Executor 默认持有稳定的容量 owner;显式共享 OpenCliProcessRuntime 才跨 Client 共用许可。0 使用 CPU 派生默认值,负数拒绝。旧静态 bridge 仅保留给兼容调用,且在存在提交时拒绝重配;构造新 Client 不再修改它。 + +System.nanoTime 驱动提交到执行的总预算。排队到期不创建子进程;启动前取消也不创建子进程。stdout 默认捕获 8 MiB,stderr 2 MiB;超过预算返回 OUTPUT_LIMIT 失败和有界部分输出。字段分别记录 retained/observed 字节数与截断标记;UTF-8 截断采用替换字符解码,字节计数不根据重新编码后的文本计算。 + +清理只有有限 grace,默认 5 秒;中断标记会恢复,许可在收尾后释放。主进程或读取线程未确认结束时,runtime 被隔离,不继续积累新的不确定资源。结果携带 terminationReason、cleanupState、processStarted、streamsDrained、字节计数和 elapsed/queue 时间。 + +主进程确认退出不等于所有后代退出。当前 portable backend 固定保留 descendantsExitConfirmed=false;不按进程名/PID 批量终止共享 daemon 或用户浏览器。同步 follow 仍受有限 timeout/capture 约束;没有实现无限流式 sink 或远端取消协议。 + +## 6. 尚未满足的关闭条件 + +C01/C02 的 Change 继续 IN_PROGRESS;不能因为上述测试通过就整体勾选或 archive。 + +- C01:自定义 Object.toString() 返回 null 的输入应补专门拒绝测试;Windows/macOS 和三版本线实际完整回归尚未完成。 +- C02:底层 stop/destroy 抛 unchecked 异常时的收尾保护需要故障注入验证和补强;未知清理、取消/自然退出竞争及未确认后代的验收仍需继续。 +- C09:HTTP、WS、诊断和异常 cause 链仍需独立隐私回归;本地参数日志减少不构成全域完成。 +- C03/C04/C05 尚未进入本轮新实现,C06/C07/C08 也尚未进入;不得跳过这些项关闭 C10。 +- 1.x/JDK8、3.x/JDK21、真实 OpenCLI 输出采集、CodeGraph 索引以及独立代码评审未完成。不能用本批 JDK17 证据代替它们。 + +## 7. 实际产物验证 + +扩展边界 run 35564455068 的 JDK artifact SHA-256:`86cc81f83792b5c65d648c04db1afee6970f46335583c7ad8ff2cdb34b6f4897`。 +其中 committed-source tar SHA-256:`c11fe761f44af076f29dffe8aa10f3000c9d1d421bcfc83aa2191da4afe08d35`。 +该 tar 在容器重建的 Git tree 精确等于 `f421af093023fb11683fcd4334806f12d9aa140c`,与 3bacc8d 的远端树一致;这是固定源码快照,不是声称已 clone 完整 Git 历史或同步用户 Mac。 + +官方 strict artifact SHA-256:`aac29fdd4c5c3f7c9c556e7a92036d4956c630609ad5a9f37ddd25dfc1c24a24`;all.log 实际为 10 passed / 0 failed,version.txt 为 1.13.1。 + +后续提交会使 HEAD 改变,最终状态应读取该精确 HEAD 的新 CI,而不是重复使用旧绿色结果。 diff --git a/openspec/implementation-status.json b/openspec/implementation-status.json new file mode 100644 index 0000000..2ecbb45 --- /dev/null +++ b/openspec/implementation-status.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 1, + "asOf": "2026-09-21", + "state": "IMPLEMENTATION_IN_PROGRESS", + "canonicalLine": "feature/2.0.x", + "implementationBranch": "feature/2.0.x-contract-hardening", + "verifiedCodeHead": "3bacc8d09b1eeeaa05d6137e2a7e9c350d6622ce", + "verifiedCodeTree": "f421af093023fb11683fcd4334806f12d9aa140c", + "runId": 35564455068, + "javaTests": {"tests": 2052, "failures": 0, "errors": 0, "skipped": 0}, + "selectedContracts": {"argv": 43, "process": 14}, + "pythonRunnerSelfTests": 13, + "officialOpenSpec": {"version": "1.13.1", "strictChangesPassed": 10, "failed": 0}, + "changes": { + "C10": "FOUNDATION_IMPLEMENTED_IN_CANONICAL_BRANCH; FINAL_GATE_OPEN", + "C01": "CORE_IMPLEMENTED; EDGE_CASES_AND_MULTIBRANCH_REVIEW_PENDING", + "C02": "CORE_IMPLEMENTED; CLEANUP_FAULT_INJECTION_AND_MULTIBRANCH_REVIEW_PENDING", + "C03": "NOT_STARTED", + "C04": "NOT_STARTED", + "C05": "NOT_STARTED", + "C06": "NOT_STARTED", + "C07": "NOT_STARTED", + "C08": "NOT_STARTED", + "C09": "LOCAL_LOG_REDUCTION_ONLY; FULL_PRIVACY_CONTRACT_NOT_IMPLEMENTED" + }, + "originalFeatureBranchesChanged": false, + "mainChanged": false, + "localMacSynchronized": false, + "threeBranchIntegrationGate": "OPEN", + "independentCodeReview": "PENDING", + "codeGraph": "NOT_RUN", + "liveOpenCliVerification": "NOT_RUN", + "ledger": "../docs/implementation/c10-c01-execution.md" +} From 41ffefd7d5f7052336edc0445490f20f64946fa7 Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Mon, 21 Sep 2026 14:01:40 +0800 Subject: [PATCH 11/13] ci(contracts): provide scoped offline reproduction dependencies Resolve baseline build and the proposed safe YAML parser for C03. Export only public Maven artifacts and distribution, never credentials or settings. Bundle runs only when its workflow changes or is explicitly dispatched. --- .github/workflows/repro-bundle.yml | 42 ++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .github/workflows/repro-bundle.yml diff --git a/.github/workflows/repro-bundle.yml b/.github/workflows/repro-bundle.yml new file mode 100644 index 0000000..7c4d9ed --- /dev/null +++ b/.github/workflows/repro-bundle.yml @@ -0,0 +1,42 @@ +name: Offline contract reproduction bundle +on: + push: + branches: ['feature/2.0.x-contract-hardening'] + paths: ['.github/workflows/repro-bundle.yml'] + workflow_dispatch: +permissions: + contents: read +jobs: + bundle: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '17' + cache: maven + - name: Verify baseline and resolve the proposed safe YAML parser + run: | + bash ./mvnw -B --no-transfer-progress clean verify + bash ./mvnw -B --no-transfer-progress org.apache.maven.plugins:maven-dependency-plugin:3.8.1:get -Dartifact=org.yaml:snakeyaml:2.6 + - name: Package only public Maven artifacts and distribution + shell: bash + run: | + mkdir -p repro-bundle + git rev-parse HEAD > repro-bundle/source-head.txt + java -version > repro-bundle/java.txt 2>&1 + bash ./mvnw -version > repro-bundle/maven.txt 2>&1 + # Never include settings.xml, toolchains.xml, Git credentials or environment dumps. + tar -czf repro-bundle/maven-repository.tar.gz -C "$HOME/.m2" repository + distribution="$(find "$HOME/.m2/wrapper/dists" -type d -name apache-maven-3.9.16 -print -quit)" + test -n "$distribution" + tar -czf repro-bundle/maven-distribution.tar.gz -C "$(dirname "$distribution")" apache-maven-3.9.16 + (cd repro-bundle && sha256sum *.tar.gz > SHA256SUMS) + - uses: actions/upload-artifact@v7 + with: + name: opencli-offline-repro-${{ github.sha }} + path: repro-bundle + retention-days: 3 + if-no-files-found: error From 6c5c9b3aaa51665e8218311fd895ae48b4734781 Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Mon, 21 Sep 2026 14:34:45 +0800 Subject: [PATCH 12/13] ci(contracts): capture pinned OpenCLI descriptive schemas in isolation Run only bounded version/help probes from npm OpenCLI 1.8.8 with lifecycle scripts disabled. Retain package provenance, hashes and descriptive artifacts; do not invoke website operations. --- .github/workflows/upstream-contracts.yml | 46 ++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 .github/workflows/upstream-contracts.yml diff --git a/.github/workflows/upstream-contracts.yml b/.github/workflows/upstream-contracts.yml new file mode 100644 index 0000000..1f1100d --- /dev/null +++ b/.github/workflows/upstream-contracts.yml @@ -0,0 +1,46 @@ +name: Pinned upstream descriptive contracts +on: + push: + branches: ['feature/2.0.x-contract-hardening'] + paths: ['.github/workflows/upstream-contracts.yml'] + workflow_dispatch: +permissions: + contents: read +jobs: + capture: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v4 + with: + node-version: '22' + - name: Install pinned OpenCLI without lifecycle scripts + shell: bash + run: | + mkdir -p upstream-evidence "$RUNNER_TEMP/opencli-prefix" "$RUNNER_TEMP/opencli-isolated-home" + npm view @jackwener/opencli@1.8.8 version gitHead dist --json > upstream-evidence/npm-provenance.json + npm install --prefix "$RUNNER_TEMP/opencli-prefix" --ignore-scripts --no-audit --no-fund --save-exact @jackwener/opencli@1.8.8 + - name: Capture only bounded version and structured help + shell: bash + run: | + export HOME="$RUNNER_TEMP/opencli-isolated-home" + CLI="$RUNNER_TEMP/opencli-prefix/node_modules/@jackwener/opencli/dist/src/main.js" + timeout 20 node "$CLI" --version > upstream-evidence/version.txt + timeout 20 node "$CLI" --help -f json > upstream-evidence/root-help.json + for namespace in browser daemon auth skills plugin adapter profile; do + timeout 20 node "$CLI" "$namespace" --help -f json > "upstream-evidence/$namespace-help.json" + done + cp "$RUNNER_TEMP/opencli-prefix/node_modules/@jackwener/opencli/cli-manifest.json" upstream-evidence/cli-manifest.json + cp "$RUNNER_TEMP/opencli-prefix/node_modules/@jackwener/opencli/package.json" upstream-evidence/package.json + tar -czf upstream-evidence/opencli-package.tar.gz -C "$RUNNER_TEMP/opencli-prefix/node_modules/@jackwener/opencli" dist/src cli-manifest.json package.json + node --version > upstream-evidence/node-version.txt + date -u +%FT%TZ > upstream-evidence/captured-at.txt + (cd upstream-evidence && sha256sum *.json *.txt *.tar.gz > SHA256SUMS) + - uses: actions/upload-artifact@v7 + if: always() + with: + name: opencli-1.8.8-descriptive-${{ github.sha }} + path: upstream-evidence + retention-days: 14 + if-no-files-found: error From 80ebb3b4235761e3aca35db0a8e3c4fee2f6c007 Mon Sep 17 00:00:00 2001 From: Loong Wan Date: Mon, 21 Sep 2026 14:37:08 +0800 Subject: [PATCH 13/13] fix(contracts): build exact upstream ref when npm version is unpublished The registry returned E404 for @jackwener/opencli@1.8.8. Keep that evidence. Build the previously audited 8271afc commit and label descriptive captures as source-built. Do not silently substitute another upstream version. --- .github/workflows/upstream-contracts.yml | 26 ++++++++++++++++-------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/.github/workflows/upstream-contracts.yml b/.github/workflows/upstream-contracts.yml index 1f1100d..6a79916 100644 --- a/.github/workflows/upstream-contracts.yml +++ b/.github/workflows/upstream-contracts.yml @@ -15,32 +15,40 @@ jobs: - uses: actions/setup-node@v4 with: node-version: '22' - - name: Install pinned OpenCLI without lifecycle scripts + - name: Checkout exact upstream source, not an assumed npm publication + uses: actions/checkout@v7 + with: + repository: jackwener/OpenCLI + ref: 8271afc67e8504bda94c147f446ee29775d08274 + path: upstream-src + persist-credentials: false + - name: Build the locked source with dependency lifecycle scripts disabled shell: bash run: | - mkdir -p upstream-evidence "$RUNNER_TEMP/opencli-prefix" "$RUNNER_TEMP/opencli-isolated-home" - npm view @jackwener/opencli@1.8.8 version gitHead dist --json > upstream-evidence/npm-provenance.json - npm install --prefix "$RUNNER_TEMP/opencli-prefix" --ignore-scripts --no-audit --no-fund --save-exact @jackwener/opencli@1.8.8 + mkdir -p upstream-evidence "$RUNNER_TEMP/opencli-isolated-home" + git -C upstream-src rev-parse HEAD > upstream-evidence/upstream-head.txt + npm view @jackwener/opencli@1.8.8 version gitHead dist --json > upstream-evidence/npm-provenance.json 2> upstream-evidence/npm-provenance.stderr || printf 'UNAVAILABLE_IN_REGISTRY\n' > upstream-evidence/npm-status.txt + (cd upstream-src && npm ci --ignore-scripts --no-audit --no-fund && npm run build) - name: Capture only bounded version and structured help shell: bash run: | export HOME="$RUNNER_TEMP/opencli-isolated-home" - CLI="$RUNNER_TEMP/opencli-prefix/node_modules/@jackwener/opencli/dist/src/main.js" + CLI="$GITHUB_WORKSPACE/upstream-src/dist/src/main.js" timeout 20 node "$CLI" --version > upstream-evidence/version.txt timeout 20 node "$CLI" --help -f json > upstream-evidence/root-help.json for namespace in browser daemon auth skills plugin adapter profile; do timeout 20 node "$CLI" "$namespace" --help -f json > "upstream-evidence/$namespace-help.json" done - cp "$RUNNER_TEMP/opencli-prefix/node_modules/@jackwener/opencli/cli-manifest.json" upstream-evidence/cli-manifest.json - cp "$RUNNER_TEMP/opencli-prefix/node_modules/@jackwener/opencli/package.json" upstream-evidence/package.json - tar -czf upstream-evidence/opencli-package.tar.gz -C "$RUNNER_TEMP/opencli-prefix/node_modules/@jackwener/opencli" dist/src cli-manifest.json package.json + cp upstream-src/cli-manifest.json upstream-evidence/cli-manifest.json + cp upstream-src/package.json upstream-evidence/package.json + tar -czf upstream-evidence/opencli-source.tar.gz -C upstream-src src cli-manifest.json package.json package-lock.json node --version > upstream-evidence/node-version.txt date -u +%FT%TZ > upstream-evidence/captured-at.txt (cd upstream-evidence && sha256sum *.json *.txt *.tar.gz > SHA256SUMS) - uses: actions/upload-artifact@v7 if: always() with: - name: opencli-1.8.8-descriptive-${{ github.sha }} + name: opencli-source-8271afc-descriptive-${{ github.sha }} path: upstream-evidence retention-days: 14 if-no-files-found: error