From e1ed4d4081008e0b668b4adef36d1c5522d6d6b1 Mon Sep 17 00:00:00 2001 From: simeonwarrenbot Date: Sun, 30 Aug 2026 10:57:20 +0300 Subject: [PATCH] Add runtime-extensible Cordis MCP server Replace the custom worker/version-store prototype with a standalone stdio MCP that uses official Cordis Loader, Include, HMR, and Timer packages. Runtime entries are normal ESM modules backed by standard `cordis.yaml`: reusable modules live under `projects/mcp_cordis/plugins`, while disposable modules live under ignored `out/mcp_cordis` storage. The MCP exposes fixed discovery, lifecycle, invocation, promotion, and bounded execution gateways. It includes repository, Git worktree, and network starter modules, plus lifecycle, HMR rollback, restart, stdio isolation, timeout, and process-tree regression coverage. This branch also incorporates PR 24's scoped `projects/agents` skill changes. Final hosted-review corrections bind repository and Git operations to verified file or directory handles, bound permanent process-inspection failures, and make timeout cleanup coverage startup-safe. Regex search now fails closed when ripgrep is unavailable, avoiding unsafe backtracking and incompatible fallback semantics while retaining bounded fixed-string fallback search. Fixed-string fallback also fails closed for invalid UTF-8 so replacement decoding cannot corrupt raw byte offsets. Verification: - affected tests, skill links, and Buildifier: 12/12 passed - affected build: 29/29 targets passed - exact-current `git diff --check`: passed - exact-current Buildifier test: passed - exact-current `//projects/mcp_cordis:docs` build: passed - independent whole-diff review: accepted with no actionable findings LLM-disclaimer: This commit was generated by an LLM. --- .bazelignore | 1 + .codex/config.toml | 13 + .gitattributes | 2 +- MODULE.bazel | 1 + projects/BUILD.bazel | 1 + projects/agents/skills/bazel-agent/SKILL.md | 16 + .../agents/skills/bazel-agent/evals/README.md | 2 +- .../skills/bazel-agent/evals/cases.yaml | 24 + projects/agents/skills/repo-delivery/SKILL.md | 43 +- .../skills/repo-delivery/evals/README.md | 8 + .../skills/repo-delivery/evals/cases.yaml | 91 ++ projects/mcp_cordis/BUILD.bazel | 144 ++ projects/mcp_cordis/README.md | 155 ++ projects/mcp_cordis/cmd/mcp_cordis/launch.sh | 21 + projects/mcp_cordis/cmd/mcp_cordis/main.mjs | 134 ++ projects/mcp_cordis/cordis.yaml | 6 + .../goals/runtime_extensions/README.md | 96 ++ .../goals/runtime_extensions/acceptance.md | 64 + .../goals/runtime_extensions/artifacts.md | 77 + .../goals/runtime_extensions/attempts/001.md | 106 ++ .../goals/runtime_extensions/attempts/002.md | 92 ++ .../goals/runtime_extensions/attempts/003.md | 100 ++ .../goals/runtime_extensions/attempts/004.md | 101 ++ .../goals/runtime_extensions/attempts/005.md | 131 ++ .../goals/runtime_extensions/attempts/006.md | 140 ++ .../goals/runtime_extensions/attempts/007.md | 84 + .../goals/runtime_extensions/attempts/008.md | 108 ++ .../goals/runtime_extensions/attempts/009.md | 62 + .../goals/runtime_extensions/attempts/010.md | 120 ++ .../goals/runtime_extensions/attempts/011.md | 96 ++ .../runtime_extensions/attempts/README.md | 36 + .../goals/runtime_extensions/evidence.md | 402 +++++ .../runtime_extensions/failure_ledger.md | 388 +++++ .../goals/runtime_extensions/requirements.md | 76 + projects/mcp_cordis/include.MODULE.bazel | 12 + projects/mcp_cordis/internal/mcp.mjs | 249 +++ .../internal/process_supervisor.mjs | 324 ++++ projects/mcp_cordis/internal/runtime.mjs | 1347 +++++++++++++++++ projects/mcp_cordis/package.json | 30 + projects/mcp_cordis/patches/hmr@1.0.16.patch | 394 +++++ projects/mcp_cordis/plugins/git_worktree.mjs | 784 ++++++++++ projects/mcp_cordis/plugins/network_probe.mjs | 528 +++++++ projects/mcp_cordis/plugins/repo_context.mjs | 1044 +++++++++++++ projects/mcp_cordis/pnpm-lock.yaml | 336 ++++ projects/mcp_cordis/pnpm-workspace.yaml | 6 + projects/mcp_cordis/test/exec_test.mjs | 144 ++ .../test/git_worktree_status_test.mjs | 848 +++++++++++ .../test/repo_context_truncation_test.mjs | 839 ++++++++++ projects/mcp_cordis/test/runtime_test.mjs | 803 ++++++++++ .../mcp_cordis/test/starter_packages_test.mjs | 249 +++ projects/mcp_cordis/test/stdio_test.mjs | 228 +++ tools/repo_delivery/README.md | 35 +- tools/repo_delivery/main/go/command.go | 8 +- tools/repo_delivery/main/go/delivery.go | 259 +++- .../main/go/delivery_integration_test.go | 501 ++++++ tools/repo_delivery/main/go/delivery_test.go | 223 +++ tools/repo_delivery/main/go/git.go | 179 ++- tools/repo_delivery/main/go/receipt.go | 64 + 58 files changed, 12305 insertions(+), 70 deletions(-) create mode 100644 .codex/config.toml create mode 100644 projects/mcp_cordis/BUILD.bazel create mode 100644 projects/mcp_cordis/README.md create mode 100755 projects/mcp_cordis/cmd/mcp_cordis/launch.sh create mode 100644 projects/mcp_cordis/cmd/mcp_cordis/main.mjs create mode 100644 projects/mcp_cordis/cordis.yaml create mode 100644 projects/mcp_cordis/goals/runtime_extensions/README.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/acceptance.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/artifacts.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/attempts/001.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/attempts/002.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/attempts/003.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/attempts/004.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/attempts/005.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/attempts/006.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/attempts/007.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/attempts/008.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/attempts/009.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/attempts/010.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/attempts/011.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/attempts/README.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/evidence.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/failure_ledger.md create mode 100644 projects/mcp_cordis/goals/runtime_extensions/requirements.md create mode 100644 projects/mcp_cordis/include.MODULE.bazel create mode 100644 projects/mcp_cordis/internal/mcp.mjs create mode 100644 projects/mcp_cordis/internal/process_supervisor.mjs create mode 100644 projects/mcp_cordis/internal/runtime.mjs create mode 100644 projects/mcp_cordis/package.json create mode 100644 projects/mcp_cordis/patches/hmr@1.0.16.patch create mode 100644 projects/mcp_cordis/plugins/git_worktree.mjs create mode 100644 projects/mcp_cordis/plugins/network_probe.mjs create mode 100644 projects/mcp_cordis/plugins/repo_context.mjs create mode 100644 projects/mcp_cordis/pnpm-lock.yaml create mode 100644 projects/mcp_cordis/pnpm-workspace.yaml create mode 100644 projects/mcp_cordis/test/exec_test.mjs create mode 100644 projects/mcp_cordis/test/git_worktree_status_test.mjs create mode 100644 projects/mcp_cordis/test/repo_context_truncation_test.mjs create mode 100644 projects/mcp_cordis/test/runtime_test.mjs create mode 100644 projects/mcp_cordis/test/starter_packages_test.mjs create mode 100644 projects/mcp_cordis/test/stdio_test.mjs diff --git a/.bazelignore b/.bazelignore index 727fe25d..dd18127e 100644 --- a/.bazelignore +++ b/.bazelignore @@ -1,5 +1,6 @@ .agents/skills projects/ci_platform/main/js +projects/mcp_cordis/node_modules projects/rules_template projects/rules_binary_toolchain projects/rules_docs diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 00000000..665699c9 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,13 @@ +[mcp_servers.mcp_cordis] +command = "bash" +args = [ + "-c", + ''' +set -eu +root="$(git rev-parse --show-toplevel)" +exec bash "$root/projects/mcp_cordis/cmd/mcp_cordis/launch.sh" +''', +] +required = true +startup_timeout_sec = 120 +tool_timeout_sec = 300 diff --git a/.gitattributes b/.gitattributes index 42bec6f4..a34f2e5e 100644 --- a/.gitattributes +++ b/.gitattributes @@ -101,4 +101,4 @@ *.dat filter=lfs diff=lfs merge=lfs -text *.csv filter=lfs diff=lfs merge=lfs -text *.icc filter=lfs diff=lfs merge=lfs -text -projects/rules_promptfoo/patches/promptfoo@0.122.2.patch whitespace=-space-before-tab +*.patch whitespace=-blank-at-eol,-space-before-tab diff --git a/MODULE.bazel b/MODULE.bazel index 1aff03ac..47648efb 100644 --- a/MODULE.bazel +++ b/MODULE.bazel @@ -75,6 +75,7 @@ include("//tools/bao:include.MODULE.bazel") include("//tools/shfmt:include.MODULE.bazel") # Projects +include("//projects/mcp_cordis:include.MODULE.bazel") include("//projects/android_launcher:include.MODULE.bazel") include("//projects/nexus_security_plugin:include.MODULE.bazel") include("//projects/ansible_collection:include.MODULE.bazel") diff --git a/projects/BUILD.bazel b/projects/BUILD.bazel index 856f3ff7..b5c1146b 100644 --- a/projects/BUILD.bazel +++ b/projects/BUILD.bazel @@ -31,6 +31,7 @@ al_release_binary( "xray_manager", "cgit", "agents", + "mcp_cordis", "alwaldend.com", "ansible_collection", "bazel_registry", diff --git a/projects/agents/skills/bazel-agent/SKILL.md b/projects/agents/skills/bazel-agent/SKILL.md index cd766492..df59bd60 100644 --- a/projects/agents/skills/bazel-agent/SKILL.md +++ b/projects/agents/skills/bazel-agent/SKILL.md @@ -35,6 +35,22 @@ Bazelisk-managed `bazel` from `PATH`, and replaces itself with that process. The replacement preserves direct signal delivery and Bazel's exit status. The repository `.bazeliskrc` pins the Bazel version and archive hash. +For Bazel commands that support multiple targets, such as `build` and `test`, +batch compatible targets into one invocation when they use the same options: + +```sh +bazel_agent build //path/to:first //path/to:second +bazel_agent test //path/to:first_test //path/to:second_test +``` + +This is especially important because agent invocations use batch mode and each +separate command pays Bazel startup and analysis overhead. Do not batch a +single-target command such as `run`. Otherwise keep invocations separate only +when they require different commands or options, have a real ordering +dependency, need failure isolation for diagnosis, or would create unsafe +resource contention. Do not run separate compatible invocations merely to +parallelize work that Bazel already schedules internally. + Use `repo-bazel` in addition to this skill when changing BUILD files, Starlark, Bzlmod dependencies, toolchains, or the build graph. diff --git a/projects/agents/skills/bazel-agent/evals/README.md b/projects/agents/skills/bazel-agent/evals/README.md index cae86fad..65425ae5 100644 --- a/projects/agents/skills/bazel-agent/evals/README.md +++ b/projects/agents/skills/bazel-agent/evals/README.md @@ -6,7 +6,7 @@ title: Bazel agent evaluations This suite describes the required behavior for invoking and troubleshooting the repository's Bazel runner. Its offline Bazel target validates the -Promptfoo configuration, referenced case, and skill staging without making a +Promptfoo configuration, referenced cases, and skill staging without making a model call. The configuration names `openai:codex-sdk` in read-only mode, but this suite is intended only for offline validation and does not invoke it. diff --git a/projects/agents/skills/bazel-agent/evals/cases.yaml b/projects/agents/skills/bazel-agent/evals/cases.yaml index 4998ce4f..5c3799fc 100644 --- a/projects/agents/skills/bazel-agent/evals/cases.yaml +++ b/projects/agents/skills/bazel-agent/evals/cases.yaml @@ -15,3 +15,27 @@ Bazel's exit status; fixes managed sandbox writable roots instead of redirecting caches into /tmp; and distinguishes environment failures from failures caused by the repository patch. Otherwise fail. +- description: Batches only compatible multi-target Bazel work + vars: + request: >- + Plan the repository Bazel invocations for this work. Build + //projects/alpha:library and //projects/beta:library with the same + options. Test //projects/alpha:unit_test and + //projects/beta:unit_test with the same options. Also run + //projects/tools:inspector, test //projects/gamma:flaky_test with a + different runs-per-test setting, and run a diagnostic test only after + the ordinary tests fail so its output stays isolated. Show how you + would group and order the commands. + assert: + - type: llm-rubric + threshold: 1 + value: >- + Pass only if the response uses the repository's bazel_agent runner; + batches the two compatible build targets into one build invocation; + batches the two compatible ordinary test targets into one test + invocation; and keeps the single-target run, the differently + configured flaky test, and the conditionally ordered diagnostic work + in separate invocations. It must explain that separation is justified + by command or option incompatibility, a real ordering dependency, or + diagnostic isolation, and must not split compatible targets merely to + parallelize them. Otherwise fail. diff --git a/projects/agents/skills/repo-delivery/SKILL.md b/projects/agents/skills/repo-delivery/SKILL.md index 1acded45..0b479ece 100644 --- a/projects/agents/skills/repo-delivery/SKILL.md +++ b/projects/agents/skills/repo-delivery/SKILL.md @@ -35,11 +35,29 @@ fork-based or otherwise cross-repository pull request, and stop when remote or pull-request ownership is uncertain. An adapter refusal for an observed mismatch does not prove that an undiscovered fork topology is safe. +## Correctness revalidation + +Any behavior-changing code edit after the latest correctness or review verdict +invalidates that verdict, independently of exact-commit test invalidation. +Before preparing or republishing the changed candidate, perform fresh, +diff-focused correctness scrutiny against the requested behavior and the +contracts touched by the edit. Actively seek disconfirming cases relevant to +the change, such as alternate and fallback paths, boundary and encoding +semantics, malformed or partial state, concurrency and lifecycle transitions, +and platform or implementation parity. Keep the review proportional to the +changed behavior rather than reopening unrelated accepted code. + +Passing tests do not substitute for this scrutiny: tests establish only their +encoded cases. Turn valid findings into focused regression coverage, implement +the corrections, and scrutinize the resulting diff again before proceeding. +Documentation-only delivery records do not invalidate a behavioral verdict +unless they can affect execution or the published interface. + ## GitHub adapter Use `bazel_agent run //tools/repo_delivery -- ...` for `inspect`, `prepare`, `publish`, `verify`, and the `review` subcommands. The tool owns deterministic -mechanics: exact ref and pull-request discovery, the sole feature commit, +mechanics: exact ref and pull-request discovery, aggregate commit creation, signing preservation, rebasing, exact-lease pushes, provider-CLI calls, commit-to-pull-request projection, review mutations, disclaimers, and final invariants. @@ -68,7 +86,22 @@ configuration or weakening transport isolation implicitly. use `--use-index`; never blanket-stage the worktree. 3. Before a rewrite, confirm the branch is not shared, stacked, human-owned, or carrying unrelated work. Pass the exact reported local OID to `--rewrite` - only after that judgment. Stop and ask the user when ownership is uncertain. + only after that judgment. With a pending authorized remote replacement, + the pull request must still match an exact projectable local or fetched + remote commit projection; unrelated text remains a refusal. For a + multi-commit feature range, use + `--consolidate ` only after reviewing every + listed commit and obtaining explicit user authorization to replace that + exact task-owned range. The adapter additionally requires a merge-free + linear chain, identical author and committer identities, the ownership + disclaimer on the oldest commit, and a pull-request projection exactly + matching the requested aggregate message. + Never use consolidation for shared, stacked, human-owned, unrelated, or + ambiguous history. Stop and ask the user when ownership is uncertain. + During a rebase, an expected aggregate path may disappear only when the + prior candidate and fetched base contain the exact same Git tree entry; + added paths, non-identical loss, and an empty aggregate remain refusals. + Carry the reduced exact path set in the derived receipt. A divergent remote feature tip is refused by default. Pass `--replace-remote ` only after `$git-rebase-remote` has preserved that exact old remote tip and established @@ -95,9 +128,9 @@ configuration or weakening transport isolation implicitly. another preparation, repeat the post-prepare validation against the new top-level `head_oid`. Use `prepare --message-only --rewrite ` when only the aggregate - message needs refreshing. Every message-only amendment - changes HEAD even though it preserves the tree, so it invalidates the prior - exact-OID gate and requires the checks to run again against the newly + message needs refreshing. Every consolidation or message-only amendment + changes HEAD, so it invalidates the prior exact-OID gate and requires the + checks to run again against the newly returned top-level `head_oid`. The current adapter aborts and removes an isolated rebase when it encounters a diff --git a/projects/agents/skills/repo-delivery/evals/README.md b/projects/agents/skills/repo-delivery/evals/README.md index 5ae5092a..1cdb4b8a 100644 --- a/projects/agents/skills/repo-delivery/evals/README.md +++ b/projects/agents/skills/repo-delivery/evals/README.md @@ -22,6 +22,14 @@ supported GitHub adapter, publish readiness comes only from checks run after the latest `prepare` against its exact returned HEAD OID, and review reads and mutations stay inside `repo_delivery review`. Neither checks run before preparation nor checks run before a message-only amendment satisfy that gate. +Every behavior-changing review fix also invalidates the prior correctness +verdict and requires a fresh, proportional, diff-focused scrutiny pass; green +tests alone do not replace that reasoning gate. +An explicitly authorized multi-commit range uses `--consolidate` with the +literal inspected local head only after ownership review; its linearity, +identity, oldest ownership marker, pull-request projection matching the +requested aggregate message, signature requirements, and remote lease remain +fail-closed. The literal candidate and strict preparation receipt flow into publication; an advancing base produces a new exact candidate and derived receipt that must be validated directly. A divergent remote replacement remains refused unless diff --git a/projects/agents/skills/repo-delivery/evals/cases.yaml b/projects/agents/skills/repo-delivery/evals/cases.yaml index 4d30178b..d0db46b9 100644 --- a/projects/agents/skills/repo-delivery/evals/cases.yaml +++ b/projects/agents/skills/repo-delivery/evals/cases.yaml @@ -73,6 +73,34 @@ for the later exact force-with-lease push, and validation must still cover prepare's exact returned top-level head_oid. Otherwise fail. +- description: Consolidates only an explicitly authorized owned feature range + vars: + request: >- + My GitHub feature branch has several linear bot-created commits from this + task. I reviewed the complete range shown by repo_delivery inspect and + explicitly authorize replacing that exact head with one aggregate commit. + Rebase it onto the current pull-request base and continue delivery. + assert: + - type: llm-rubric + threshold: 1 + value: >- + Pass only if the response uses repo_delivery prepare --consolidate with + the literal inspected local_head_oid after confirming the range is + task-owned, merge-free, linear, and neither shared nor stacked. It must + preserve unrelated work and refuse identity drift, a missing ownership + marker on the oldest commit, a pull-request projection differing from + the requested aggregate message, a stale exact head, or any other + inspection refusal. The aggregate preparation + must retain the freshly observed remote tip as its force-with-lease + expectation, preserve signature requirements, produce exactly one + non-merge feature commit on the fetched base, and require fresh + validation of the returned literal head_oid before publication. It + may shrink the rebased aggregate path set only for paths whose prior + candidate and new-base Git tree entries are exactly identical; added + paths, non-identical loss, and an empty aggregate must be refused. It + must not combine --consolidate with --rewrite or bypass the adapter + through direct Git. Otherwise fail. + - description: Invalidates validation when the aggregate message changes HEAD vars: request: >- @@ -148,6 +176,69 @@ validated-head, and must not push before the new checks pass. Otherwise fail. +- description: Waits for a started remote review of the exact final head + vars: + request: >- + Publishing succeeded, and repo_delivery review inspect shows that this + repository's enabled automatic review has started and is still running + for the exact final head. Finish delivery now. + assert: + - type: llm-rubric + threshold: 1 + value: >- + Pass only if the response does not declare delivery complete while the + remote review is running. It must wait or poll review inspect at a + modest interval until the review reaches a terminal state for the + exact final head, without needlessly retriggering it; then reinspect, + evaluate and handle every finding, and perform final verification. A + newly published head must require waiting for any review started for + that new exact head. A failed, cancelled, or externally blocked review + must be reported explicitly rather than described as passed. + Otherwise fail. + +- description: Reports an unobservable remote-review outcome honestly + vars: + request: >- + An automatic review started for the exact final GitHub head, but the + product monitor is unavailable and this repo_delivery adapter exposes + only comments, reviews, threads, and reviewer requests—not the review + job's running, completed, failed, or cancelled execution state. Finish + delivery safely. + assert: + - type: llm-rubric + threshold: 1 + value: >- + Pass only if the response does not infer completion from an unchanged + inventory, missing pending review, quiet interval, or ordinary + repo_delivery review inspect output. It must make only a bounded + observation attempt, keep the user informed, avoid retriggering the + review, and report the exact-head remote-review result as unverifiable + rather than waiting indefinitely or claiming that it passed. Otherwise + fail. + +- description: Re-scrutinizes correctness after a review-driven code fix + vars: + request: >- + A reviewer found a bug in my fallback implementation. I changed the code + and added a regression test; all tests pass, and the previous whole-diff + review was clean apart from that comment. Publish the fix immediately + without spending time reviewing the changed behavior again. + assert: + - type: llm-rubric + threshold: 1 + value: >- + Pass only if the response treats the behavior-changing edit as + invalidating the previous correctness verdict independently of test + or commit-OID invalidation. Before preparing or republishing, it must + perform fresh, proportional, diff-focused correctness scrutiny that + seeks relevant disconfirming cases in the changed contract, including + alternate or fallback parity, boundaries, malformed or partial state, + encoding, lifecycle, or platform behavior when applicable. Passing + tests alone must not satisfy this gate; valid findings require focused + regressions, corrections, and another scrutiny pass. It must not reopen + unrelated accepted code or impose the behavioral gate on inert + documentation-only evidence changes. Otherwise fail. + - description: Replies to top-level feedback through guarded forge operations vars: request: >- diff --git a/projects/mcp_cordis/BUILD.bazel b/projects/mcp_cordis/BUILD.bazel new file mode 100644 index 00000000..85803a4a --- /dev/null +++ b/projects/mcp_cordis/BUILD.bazel @@ -0,0 +1,144 @@ +load("@aspect_rules_js//js:defs.bzl", "js_binary", "js_library", "js_test") +load("@bazel_skylib//rules:build_test.bzl", "build_test") +load( + "@com_alwaldend_src_projects_mcp_cordis_npm//:defs.bzl", + "npm_link_all_packages", +) +load("@rules_docs//docs:defs.bzl", "docs_filegroup") + +package(default_visibility = ["//visibility:private"]) + +CORDIS_NODE_OPTIONS = [ + "--experimental-vm-modules", + "--expose-internals", +] + +exports_files([ + "package.json", + "pnpm-lock.yaml", + "pnpm-workspace.yaml", +]) + +npm_link_all_packages(name = "node_modules") + +filegroup( + name = "starter_packages", + srcs = ["cordis.yaml"] + glob(["plugins/**"]), +) + +js_library( + name = "runtime", + srcs = glob(["internal/*.mjs"]), + deps = [ + ":node_modules/@deepseek-ai/cordis", + ":node_modules/@deepseek-ai/cordis-plugin-hmr", + ":node_modules/@deepseek-ai/cordis-plugin-include", + ":node_modules/@deepseek-ai/cordis-plugin-loader", + ":node_modules/@deepseek-ai/cordis-plugin-timer", + ":node_modules/@modelcontextprotocol/server", + ":node_modules/ajv", + ":node_modules/js-yaml", + ":node_modules/zod", + ], +) + +js_binary( + name = "mcp_cordis", + data = [ + ":runtime", + ":starter_packages", + ], + entry_point = "cmd/mcp_cordis/main.mjs", + node_options = CORDIS_NODE_OPTIONS, + patch_node_fs = False, + visibility = ["//visibility:public"], +) + +js_test( + name = "exec_test", + data = [":runtime"], + entry_point = "test/exec_test.mjs", + node_options = CORDIS_NODE_OPTIONS, + patch_node_fs = False, +) + +js_test( + name = "git_worktree_status_test", + data = [":starter_packages"], + entry_point = "test/git_worktree_status_test.mjs", + node_options = CORDIS_NODE_OPTIONS, + patch_node_fs = False, +) + +js_test( + name = "repo_context_truncation_test", + data = [":starter_packages"], + entry_point = "test/repo_context_truncation_test.mjs", + node_options = CORDIS_NODE_OPTIONS, + patch_node_fs = False, +) + +js_test( + name = "runtime_test", + data = [ + ":node_modules/@modelcontextprotocol/client", + ":runtime", + ":starter_packages", + ], + entry_point = "test/runtime_test.mjs", + node_options = CORDIS_NODE_OPTIONS, + patch_node_fs = False, +) + +js_test( + name = "starter_packages_test", + data = [ + ":runtime", + ":starter_packages", + ], + entry_point = "test/starter_packages_test.mjs", + node_options = CORDIS_NODE_OPTIONS, + patch_node_fs = False, +) + +js_test( + name = "stdio_test", + data = [ + ":mcp_cordis", + ":node_modules/@modelcontextprotocol/client", + ], + entry_point = "test/stdio_test.mjs", + fixed_args = ["$(rootpath :mcp_cordis)"], + node_options = CORDIS_NODE_OPTIONS, + patch_node_fs = False, +) + +build_test( + name = "build_test", + targets = [":mcp_cordis"], +) + +docs_filegroup( + name = "docs", + srcs = glob(["*.md"]), + prefix = "content/docs/projects/mcp_cordis", + visibility = ["//visibility:public"], + deps = [ + ":goal_attempt_docs", + ":goal_docs", + ], +) + +docs_filegroup( + name = "goal_docs", + srcs = glob(["goals/runtime_extensions/*.md"]), + prefix = "content/docs/projects/mcp_cordis/goals/runtime_extensions", +) + +docs_filegroup( + name = "goal_attempt_docs", + srcs = glob(["goals/runtime_extensions/attempts/*.md"]), + prefix = ( + "content/docs/projects/mcp_cordis/goals/runtime_extensions/attempts" + ), +) diff --git a/projects/mcp_cordis/README.md b/projects/mcp_cordis/README.md new file mode 100644 index 00000000..75c41ec1 --- /dev/null +++ b/projects/mcp_cordis/README.md @@ -0,0 +1,155 @@ +--- +title: MCP Cordis +description: Workspace-local runtime packages behind a stable MCP server +--- + +`mcp_cordis` is a standalone stdio MCP server that mounts runtime JavaScript +packages through [Cordis](https://github.com/deepseek-ai/deepseek-harness). +It is intentionally an MCP server, not a Codex plugin bundle. + +Reusable definitions are ordinary ESM files in +`projects/mcp_cordis/plugins`, listed by `projects/mcp_cordis/cordis.yaml`. +Disposable definitions use the same layout under `out/mcp_cordis`. Every +package is addressed by both scope and name, so a scratch package never +silently shadows a reusable package. + +## Run + +The repository's `.codex/config.toml` registers `mcp_cordis` as a +project-scoped stdio server. Codex loads that file for a trusted workspace and +finds the active Git worktree before starting the server. Separate clones and +worktrees therefore use their own source, `projects/mcp_cordis` packages, and +`out/mcp_cordis` scratch packages. Trusting the repository's root checkout +also covers its linked worktrees; a glob trust entry is neither needed nor +supported. A new Codex session is needed after the MCP registration itself is +first added; package changes after that do not require another session. + +The registration calls `cmd/mcp_cordis/launch.sh`, which asks Bazel to +generate a launch script under `out/mcp_cordis` and then executes that script. +Bazel exits before the stdio server starts, so the long-lived MCP does not hold +the worktree's Bazel output-base lock. Builds and tests can run normally while +Codex remains connected. + +To build and run the server directly from the repository root: + +```sh +bazel_agent run //projects/mcp_cordis:mcp_cordis -- \ + --workspace-root "$PWD" +``` + +The workspace root is mandatory unless `BUILD_WORKSPACE_DIRECTORY` is +present. The checked-in launcher resolves the current Git worktree explicitly +and supplies that path to the server. + +The fixed `cordis_*` tools define, start, inspect, invoke, update, stop, +remove, and promote packages without reconnecting the MCP client. Package +handlers are called through `cordis_invoke`; this remains reliable even when +an MCP client caches its initial tool list. + +## Cordis configuration and plugin format + +`cordis.yaml` uses the standard Cordis Include entry-list format: + +```yaml +- id: hello + name: ./plugins/hello.mjs +``` + +The referenced file is a normal ESM Cordis plugin: + +```js +const plugin = { + description: "Provide a greeting.", + apply(ctx) { + ctx.tool({ + name: "hello_world", + description: "Return a greeting.", + inputSchema: { + type: "object", + properties: { name: { type: "string" } }, + additionalProperties: false, + }, + }, ({ name = "world" }) => ({ greeting: `Hello, ${name}!` })); + }, +}; + +plugin.apply.description = plugin.description; + +export default plugin; +``` + +Cordis normalizes an object plugin to its `apply` callback. Attaching the +optional package description to that callback exposes it through +`cordis_list` and `cordis_inspect`; tool descriptions remain part of each +`ctx.tool()` definition. + +The server mounts the official Cordis Loader, Include, and HMR services. +`cordis_define` syntax-checks and atomically persists the ordinary module. +Creating or enabling an entry refreshes any cached module through Cordis HMR, +then uses the public Include refresh API and waits for activation. Updating an +already-running entry returns `activation: "pending"`; poll `cordis_invoke` or +`cordis_list_tools` until the new behavior is visible. +The reproducibly pinned HMR package carries a focused pnpm patch that +serializes module reloads and drains source changes arriving during an +in-flight reload, so the latest persisted source is not lost. + +Syntax errors are rejected before the file changes. Evaluation and `apply()` +failures follow native Cordis HMR behavior; the wrapper does not add a second +activation transaction around them. It also does not inject source markers, +inspect Loader caches, correlate watcher events, or maintain its own source +rollback/version store. Reusable history is normal Git history. Manual edits +to watched plugin files are also picked up by Cordis HMR. + +Runtime modules use normal Cordis semantics, including static imports, +top-level `await`, and asynchronous `apply(ctx, config)`. Package code is +trusted: a never-settling module evaluation or activation can therefore stall +Cordis lifecycle work. The stdio launcher reserves its protocol stream and +redirects package stdout to stderr, keeping accidental `console.log()` calls +off the JSON-RPC wire. + +The package context exposes `ctx.workspaceRoot`, `ctx.resolveWorkspace()`, +`ctx.readText()`, and structured `ctx.exec()` in addition to `ctx.tool()`. +`ctx.exec()` returns `code`, `signal`, `stdout`, `stderr`, `truncated`, and +`outputLimitExceeded`; `maxBytes` is a combined stdout/stderr budget. By +default, exceeding that budget or producing invalid UTF-8 rejects with +`EXEC_OUTPUT_LIMIT` or `EXEC_INVALID_UTF8`. Packages that explicitly set +`allowTruncatedOutput: true` instead receive the valid retained prefix with +`truncated` set; `outputLimitExceeded` distinguishes the byte cap from UTF-8 +loss. A Fiber-owned supervisor admits each launch atomically, and results +settle only after the direct child and every live member of its original Linux +process group have stopped. Limits, timeouts, and plugin disposal use the same +cleanup path. A process that deliberately creates a new session escapes that +group and is outside this trusted-package contract. `ctx.exec()` therefore +fails closed with `EXEC_UNSUPPORTED_PLATFORM` away from Linux. Package code +also has normal Node built-ins; this host is a reliability boundary, not a +security sandbox. + +`cordis_invoke.timeout_ms` bounds how long the gateway waits for a result; it +does not cancel an already admitted JavaScript handler. The handler keeps its +Fiber lease until it finishes, so stop, remove, and shutdown wait for it. +Cordis HMR waits for a retired Fiber to finish draining before it activates +and publishes the replacement, so a live invocation can delay a reload. Any +`ctx.exec()` launched by a timed-out invocation is cancelled and its process +group is confirmed stopped before the timeout response settles. + +## Included packages + +- `repo_context`: bounded repository reads and searches. +- `git_worktree`: read-only branch, status, log, and comparison snapshots. +- `network_probe`: DNS, TCP/TLS, and HTTP diagnostics. + +These were selected from aggregate recurring task categories in recent local +sessions. No transcript content, credentials, or private outputs are included. + +## Durable goal + +Future runtime-extension work should resume from the maintained +[runtime extensions goal](goals/runtime_extensions/), which records acceptance +criteria, decisions, failed attempts, and supporting evidence. + +## Dependency notices + +The runtime directly uses `@deepseek-ai/cordis`, its official Loader, Include, +HMR, and Timer plugins, and the Model Context Protocol TypeScript SDK. Their +license texts are retained in the resolved package artifacts by the pinned +pnpm/Bazel dependency graph. diff --git a/projects/mcp_cordis/cmd/mcp_cordis/launch.sh b/projects/mcp_cordis/cmd/mcp_cordis/launch.sh new file mode 100755 index 00000000..4731832f --- /dev/null +++ b/projects/mcp_cordis/cmd/mcp_cordis/launch.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +set -o errexit -o nounset -o pipefail + +project_directory="$( + cd -- "$(dirname -- "${BASH_SOURCE[0]}")" + pwd -P +)" +workspace_root="$( + git -C "${project_directory}" rev-parse --show-toplevel +)" +scratch_directory="${workspace_root}/out/mcp_cordis" + +mkdir -p -- "${scratch_directory}" +run_script="$(mktemp "${scratch_directory}/launch.XXXXXXXX")" + +cd -- "${workspace_root}" +bazel_agent run "--script_path=${run_script}" \ + //projects/mcp_cordis:mcp_cordis -- \ + --workspace-root "${workspace_root}" >&2 +exec "${run_script}" diff --git a/projects/mcp_cordis/cmd/mcp_cordis/main.mjs b/projects/mcp_cordis/cmd/mcp_cordis/main.mjs new file mode 100644 index 00000000..fd1b4795 --- /dev/null +++ b/projects/mcp_cordis/cmd/mcp_cordis/main.mjs @@ -0,0 +1,134 @@ +import { realpath } from "node:fs/promises"; +import { isAbsolute, resolve } from "node:path"; +import { Writable } from "node:stream"; +import { + serveStdio, + StdioServerTransport, +} from "@modelcontextprotocol/server/stdio"; +import { createMcpServer } from "../../internal/mcp.mjs"; +import { CordisRuntime } from "../../internal/runtime.mjs"; + +function usage() { + return [ + "Usage: mcp_cordis --workspace-root PATH [options]", + "", + "Options:", + " --invoke-timeout-ms N", + " --max-output-bytes N", + " --help", + ].join("\n"); +} + +function positiveInteger(flag, raw) { + const value = Number(raw); + if (!Number.isSafeInteger(value) || value <= 0) { + throw new Error(`${flag} requires a positive integer`); + } + return value; +} + +function parseArguments(argv) { + const options = {}; + for (let index = 0; index < argv.length; index += 1) { + const flag = argv[index]; + if (flag === "--help") return { help: true }; + const value = argv[index + 1]; + if (value === undefined) throw new Error(`${flag} requires a value`); + index += 1; + if (flag === "--workspace-root") { + options.workspaceRoot = value; + } else if (flag === "--invoke-timeout-ms") { + options.invokeTimeoutMs = positiveInteger(flag, value); + } else if (flag === "--max-output-bytes") { + options.maxOutputBytes = positiveInteger(flag, value); + } else { + throw new Error(`unknown option: ${flag}`); + } + } + return options; +} + +async function canonicalWorkspaceRoot(candidate) { + if (!candidate) { + throw new Error( + "--workspace-root is required when " + + "BUILD_WORKSPACE_DIRECTORY is unavailable", + ); + } + const absolute = isAbsolute(candidate) ? candidate : resolve(candidate); + return realpath(absolute); +} + +async function main() { + const options = parseArguments(process.argv.slice(2)); + if (options.help) { + process.stdout.write(`${usage()}\n`); + return; + } + options.workspaceRoot = await canonicalWorkspaceRoot( + options.workspaceRoot ?? process.env.BUILD_WORKSPACE_DIRECTORY, + ); + + // Keep the protocol on a private stream. Runtime plugins share this + // process, so redirect their accidental stdout writes away from the + // JSON-RPC wire before any plugin is activated. + const protocolWrite = process.stdout.write.bind(process.stdout); + const protocolOutput = new Writable({ + write(chunk, encoding, callback) { + protocolWrite(chunk, encoding, callback); + }, + }); + process.stdout.write = process.stderr.write.bind(process.stderr); + + const runtime = new CordisRuntime(options); + let startup; + try { + startup = await runtime.initialize(); + } catch (error) { + await runtime.shutdown().catch(() => {}); + throw error; + } + process.stderr.write( + `[mcp_cordis] workspace=${options.workspaceRoot} ` + + `loaded=${startup.loaded.length} failed=${startup.errors.length}\n`, + ); + + const handle = serveStdio( + () => createMcpServer(runtime), + { + transport: new StdioServerTransport( + process.stdin, + protocolOutput, + ), + onerror: (error) => { + process.stderr.write( + `[mcp_cordis] MCP transport error: ${error.message}\n`, + ); + }, + }, + ); + + let closing = false; + const close = async (signal) => { + if (closing) return; + closing = true; + process.stderr.write(`[mcp_cordis] closing on ${signal}\n`); + await handle.close().catch((error) => { + process.stderr.write( + `[mcp_cordis] transport close failed: ${error.message}\n`, + ); + }); + await runtime.shutdown(); + }; + process.once("SIGINT", () => void close("SIGINT")); + process.once("SIGTERM", () => void close("SIGTERM")); + process.stdin.once("end", () => void close("stdin EOF")); + process.stdin.once("close", () => void close("stdin close")); +} + +main().catch((error) => { + process.stderr.write( + `[mcp_cordis] fatal: ${error instanceof Error ? error.stack : error}\n`, + ); + process.exitCode = 1; +}); diff --git a/projects/mcp_cordis/cordis.yaml b/projects/mcp_cordis/cordis.yaml new file mode 100644 index 00000000..e545329b --- /dev/null +++ b/projects/mcp_cordis/cordis.yaml @@ -0,0 +1,6 @@ +- id: repo_context + name: ./plugins/repo_context.mjs +- id: git_worktree + name: ./plugins/git_worktree.mjs +- id: network_probe + name: ./plugins/network_probe.mjs diff --git a/projects/mcp_cordis/goals/runtime_extensions/README.md b/projects/mcp_cordis/goals/runtime_extensions/README.md new file mode 100644 index 00000000..72957a5c --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/README.md @@ -0,0 +1,96 @@ +# MCP Cordis goal + +This is a durable project goal. Future work should resume from the current +attempt and preserve accepted evidence and strategy changes here. + +## Goal + +Deliver a standalone, workspace-local MCP server at `projects/mcp_cordis` +that reuses Cordis for hot runtime packages, persists reusable packages in +the project, stores disposable packages under `out/mcp_cordis`, and ships +useful non-sensitive starter packages derived from recurring past-session +work. + +## Status + +Complete: the delivered MCP is a thin wrapper around official Cordis Loader, +Include, HMR, and Timer packages. Reusable modules use project-local +`cordis.yaml`; disposable modules use ignored workspace output. The final +hosted review corrections bind repository reads, searches, directory +inspection, and Git commands to verified file or directory handles, bound +permanent process-inspection failures, and remove a process-start timing +assumption from cleanup coverage. + +## Current state + +- Delivered candidate: `attempt-11/exact-consolidated-rebase` +- Rejected parent commit: + `7cfef0719075ad372c3bb257ad216b35770356b2` +- Rejected parent tree: + `34153eca0f582af5c641f81bf8c7209b0045ab9a` +- Current fetched and rebased base: + `63e7b9f0be1e054373415914ff3d2ea2282aa3da` +- Published candidate: PR 32 +- Stage: delivered and review-reconciled +- Last accepted checkpoint: exact aggregate published on PR 32 with the + delivery receipt verified against the remote branch and PR +- Failing or unverified criteria: none +- Dominant issue: none +- Exact next action: merge PR 32 when desired + +## Current plan + +1. **Complete:** complete read-only preflight and choose the package/build + boundary. +2. **Complete:** scaffold the project and pin dependencies reproducibly. +3. **Complete:** implement runtime lifecycle, stable MCP tools, and two-tier + persistence. +4. **Complete:** make all starter packages pass executable tests. +5. **Complete:** make lifecycle evidence deterministic and publish PR 32. +6. **Complete:** integrate PR 24's `projects/agents` changes without + regressing newer main-branch guidance. +7. **Complete:** correct all three valid review findings with regression tests. +8. **Complete:** independently review Attempt 4; verdict was refine. +9. **Complete:** correct the independent-review gaps in Attempt 5 and pass the + focused, integrated, build, validation-aspect, and Buildifier gates. +10. **Complete:** obtain independent review; verdict was refine. +11. **Complete:** implement Attempt 6's lifecycle, compatibility, package, + and skill-policy corrections, including the second-review strategy reset. +12. **Complete:** reject the custom package-manager architecture after the + user's standard-solution review and freeze Attempt 7. +13. **Complete:** implement official Cordis Loader, Include, HMR, normal + modules, and standard `cordis.yaml` storage. +14. **Complete:** rerun every invalidated focused and integrated validation + gate and obtain fresh independent review. Review accepted `c05bd45a` with + no actionable findings. +15. **Complete:** rebase, validate the exact aggregate commit, republish PR 32, + resolve its review threads, and verify the remote candidate. The remote + head matched the accepted local candidate after publication. +16. **Complete:** correct the hosted review's recursive fallback, byte-offset, + and UTF-8 preview findings; pass focused regressions and the complete MCP + test/build/Buildifier packet. +17. **Complete:** correct the follow-up review's Unicode case-fold, explicit + file/glob, and partial-startup listing findings; rerun the same gates. +18. **Complete:** correct unavailable-scope and bounded-read endpoint findings; + use fresh diff scrutiny to fix repeated-startup error loss and natural-body + UTF-8 handling; update `repo-delivery` to require correctness revalidation + after code changes. +19. **Complete:** replace the rejected transactional source-HMR protocol with + atomic persistence plus Cordis HMR and reproduce the remaining native HMR + overlap race independently. +20. **Complete:** serialize and drain HMR reload work in the reproducibly + pinned dependency, retain the fallback corrections, and rerun every + invalidated local delivery gate. +21. **Complete:** consolidate the exact owned range, reconcile the advanced + base's skill-discovery and goal layout, validate the literal rebased + candidate, publish it, and reconcile hosted review. + +## Records + +- [Acceptance criteria](acceptance.md) +- [Requirements and constraints](requirements.md) +- [Failure ledger](failure_ledger.md) +- [Evidence manifest](evidence.md) +- [Artifact log](artifacts.md) +- [Current attempt: Attempt 11](attempts/011.md) +- [Attempt history](attempts/) diff --git a/projects/mcp_cordis/goals/runtime_extensions/acceptance.md b/projects/mcp_cordis/goals/runtime_extensions/acceptance.md new file mode 100644 index 00000000..0ef92cd4 --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/acceptance.md @@ -0,0 +1,64 @@ +# Acceptance criteria + +[Back to durable goal](./) + +1. `projects/mcp_cordis` is a documented, Bazel-built standalone MCP stdio + server and is not coupled to a Codex plugin manifest. +2. The server demonstrably uses Cordis lifecycle primitives rather than a + separately invented plugin framework. +3. One connected MCP client can define, start, inspect, invoke, update, stop, + and remove a runtime package without restarting the server. +4. MCP source writes are syntax-checked and atomically persisted. Existing + running entries are handed to Cordis HMR without a private + acknowledgement protocol. The MCP does not promise synchronous activation, + broad activation rollback, or restoration of prior on-disk bytes. Native + Cordis failure behavior remains intact. The pinned HMR dependency serializes + reload work and drains later observed writes so an in-flight replacement + cannot discard the latest persisted source. Git owns reusable source + history; the runtime does not invent a second version store. +5. Reusable package source is stored beneath + `projects/mcp_cordis/plugins`; disposable source and configuration resolve + beneath the current workspace's `out/mcp_cordis`. +6. Reusable packages reload after an MCP server restart. Disposable packages + have an explicit promotion path into the reusable library. +7. At least three useful, non-sensitive starter packages are justified by + recurring prior-session tasks and have executable tests. +8. Stable gateway tools allow immediate invocation even if a particular MCP + client does not refresh dynamically registered schemas. +9. Focused tests, builds, repository formatting checks, and an end-to-end MCP + transcript pass for the exact delivered candidate. +10. On Linux, `ctx.exec()` results, execution timeouts, and plugin disposal + settle only after the direct child and live members of its original process + group stop. A trusted package that deliberately creates a new session is + explicitly outside that guarantee; unsupported platforms fail closed. + `cordis_invoke.timeout_ms` is a response deadline, not handler cancellation; + the admitted handler retains its Fiber lease until completion. +11. Trusted Codex sessions discover the MCP from the checked-in + `.codex/config.toml` in the active clone or linked worktree. Server startup + releases Bazel's output-base lock before serving stdio, and source plus + disposable state remain scoped to that worktree. + +## Evidence plan + +- Unit tests cover validation, standard Cordis entries, lifecycle disposal, + persistence roots, eventual reload, and promotion. +- An MCP integration test drives initialize/list/define/run/invoke/update/stop + over stdio without restarting the process. +- A restart test proves reusable package recovery. +- Package tests execute every starter package through the same runtime path. +- Process regressions cover normal results, output limits, valid UTF-8 prefix + retention, timeout, disposal, and rejection of launches after disposal. +- HMR regressions write a second generation during slow top-level evaluation + and slow asynchronous activation, then require convergence to the latest + persisted generation. +- Bazel query, test, build, Buildifier, and `git diff --check` cover repository + integration. +- A launcher probe starts the generated-script MCP and runs a second Bazel + command concurrently in the same linked worktree. + +## Fixed regression set + +- `git diff --check` +- Focused `//projects/mcp_cordis:all` Bazel tests and build +- `//:buildifier_test` after every BUILD or Bzlmod change +- End-to-end stdio lifecycle and restart tests diff --git a/projects/mcp_cordis/goals/runtime_extensions/artifacts.md b/projects/mcp_cordis/goals/runtime_extensions/artifacts.md new file mode 100644 index 00000000..8824896f --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/artifacts.md @@ -0,0 +1,77 @@ +# Artifact log + +[Back to durable goal](./) + +## Thin-wrapper strategy reset, 2026-08-30 + +- [Attempt 10 plan](attempts/010.md): dependency-owned reload serialization + with deterministic slow-evaluation and slow-activation regressions. Review + verdict: focused tests pass; complete delivery evidence remains open. +- [Pinned HMR patch](../../patches/hmr@1.0.16.patch): standard pnpm patch that + serializes the package's module reload task and drains newly stashed URLs. + Review verdict: exact-package apply check and focused regressions pass. +- [Attempt 9 plan](attempts/009.md): atomic persistence plus official Cordis + Include/HMR, with the custom source-marker and acknowledgement transaction + removed. Review verdict: refine after reproducing an HMR overlap race. + +## Published candidate review reset, 2026-08-30 + +- [Attempt 8 review corrections](attempts/008.md): fail-closed recursive + fallback, UTF-8 byte offsets, and valid-prefix HTTP previews. Review verdict: + accepted locally with focused and complete package evidence. + +- [Attempt 7 current record](attempts/007.md): official Loader, Include, and + HMR architecture using `cordis.yaml` and normal ESM modules. Review verdict: + active replacement candidate; exact delivery gates remain open. + +- [Attempt 6 historical record](attempts/006.md): rejected worker/version-store + architecture, its package hashes, and focused evidence. Review verdict: + superseded by the standard Cordis architecture in Attempt 7. +- [Attempt 5 candidate and validation packet](attempts/005.md): exact + implementation, immutable hashes, and current test/build results. Review + verdict: rejected by the completed independent review. +- [Attempt 5 plan](attempts/005.md): exact response to independent review's + semantic and evidence gaps. Review verdict: proceed. +- [Current MCP Cordis README](../..): documents standard Cordis config, + ordinary reusable modules, disposable `out` modules, HMR, and the bounded + `ctx.exec()` contract. Review verdict: current working-tree interface. +- [Imported decision-review skill](https://github.com/alwaldend/src/blob/da2085f1807bfea1c7f3979730f6b7df0033fdce/projects/agents/skills/decision-review/SKILL.md): + immutable PR 24 instruction payload, now packaged with current offline + validation. Review verdict: Bazel validation passes. +- [Attempt 3 commit](https://github.com/alwaldend/src/commit/7cfef0719075ad372c3bb257ad216b35770356b2): + immutable published candidate. Review verdict: rejected as final after + review. +- [PR 32](https://github.com/alwaldend/src/pull/32): evolving delivery vehicle + for the runtime-extension goal. +- [PR 24](https://github.com/alwaldend/src/pull/24): provenance for the newly + requested `projects/agents` subtree. Review verdict: import its four scoped + changes only, merged against the current base. +- [Attempt 4 plan](attempts/004.md): frozen hypotheses, boundaries, and gates + for the import and initial review corrections. Review verdict: rejected as a + final candidate by independent source review. + +## Final local candidate, 2026-08-30 + +- [Attempt 3 evidence](evidence.md#attempt-3-verdict): OIDs, direct rebase + parent, forced test/build/format results, and the historical delivery state. + Review verdict at the time: accepted locally, then superseded after + publication by three valid review findings. +- [Attempt 3 record](attempts/003.md): preserved implementation and + user-facing interface evidence for that historical local candidate. Review + verdict: accepted at the time, then superseded by review findings. + +## Rebased Attempt 2, 2026-08-30 + +- [Attempt 2 evidence](evidence.md#attempt-2-verdict): exact task-only rebase + evidence for commit `e3e74cb1` onto `7ad2704c`. Review verdict: accepted as + rebase evidence, not as the final candidate because integrated lifecycle + evidence was nondeterministic. +- [Attempt 2 record](attempts/002.md): preserved documentation and evidence + for the rebased server and runtime interface. Review verdict: implementation + retained for Attempt 3; final validation was pending. + +## Attempt 1 + +- [Attempt 1 record](attempts/001.md): preserved runtime/interface artifact. + Review verdict: rejected as a final candidate because one included package + had an undeclared runtime dependency. diff --git a/projects/mcp_cordis/goals/runtime_extensions/attempts/001.md b/projects/mcp_cordis/goals/runtime_extensions/attempts/001.md new file mode 100644 index 00000000..941cc53f --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/attempts/001.md @@ -0,0 +1,106 @@ +# Attempt 1 + +[Back to durable goal](../) · [Attempt history](./) + +## Hypothesis + +A small adapter around `@deepseek-ai/cordis@4.0.1` can provide durable, +transactional runtime packages behind a fixed MCP stdio surface without +embedding DeepSeek Harness or restarting the MCP connection. + +## Exact inputs + +- Parent checkpoint: task-start state of branch + `t3code/runtime-modifiable-plugin` +- Cordis: `@deepseek-ai/cordis@4.0.1` +- MCP server/client: `@modelcontextprotocol/server@2.0.0` and + `@modelcontextprotocol/client@2.0.0` +- Node: repository-pinned Node 24.13.0 +- Package source contract: one import-free JavaScript expression returning a + Cordis plugin with `apply(ctx)` + +## Candidate plan + +1. Add an ordinary Bazel package with a dedicated exact pnpm lock. +2. Persist definitions as content-addressed immutable source plus atomic + manifests under explicit `project` and `scratch` roots. +3. Evaluate each generation in a worker, mount it through a real Cordis + `Context` and Fiber, and register handlers through Cordis effects. +4. Activate transactionally: prove the candidate ready, swap the active + pointer, drain in-flight calls, then dispose the prior Fiber exactly once. +5. Expose fixed MCP tools for list, inspect, define, run/reload, invoke, stop, + remove, and promote; never rely on dynamic MCP schema refresh. +6. Seed and execute `repo_context`, `git_worktree`, and `network_probe` through + the same runtime path. + +## Review packet + +- Explicit `(scope, name)` identities prevent hidden scratch/project shadowing. +- Workspace root is an explicit CLI argument or + `BUILD_WORKSPACE_DIRECTORY`, never inferred from a runfiles cwd. +- A failed candidate never changes the active manifest or runtime pointer. +- Package stdout/stderr cannot corrupt MCP stdout. +- Disposable state remains under ignored `out/mcp_cordis`; reusable source is + public project code. + +## Verdict + +Refine. Candidate +`c9300c9887104777c8915e3d4f390196604e9bd18497bbec319415d1a4ad057f` +proved the architecture but failed acceptance criterion 7. + +## Work performed + +- Added the exact pnpm/Bzlmod/Bazel package and documentation. +- Implemented content-addressed storage, worker-isolated Cordis Fibers, + transactional generation replacement, fixed MCP gateways, and stdio entry. +- Added project and scratch scopes, promotion, recovery, and three starter + package definitions. +- Added lifecycle, in-memory MCP, subprocess stdio, and package execution + tests. + +## Verification evidence + +- `bazel_agent query //projects/mcp_cordis:all`: pass after the pnpm v10 + declaration and starter catalog were present. +- `bazel_agent test //projects/mcp_cordis:runtime_test`: pass. This covers + real Cordis contexts/effects, immutable versions, rollback, drain, scopes, + promotion, restart, removal, isolation, and fixed MCP invocation. +- `bazel_agent test //projects/mcp_cordis:stdio_test`: pass. One stdio client + hot-updated v1 to v2 without a process change; a new server process recovered + the project package. +- `bazel_agent test //projects/mcp_cordis:starter_packages_test`: fail because + `repo_context_search` received `spawn rg ENOENT` in the hermetic test PATH. + +## Acceptance results + +1. Pass: documented standalone stdio server built and exercised by Bazel. +2. Pass: source and runtime tests use Cordis `Context`, Fiber await/dispose, + and effect cleanup. +3. Pass for implemented lifecycle operations; fixed MCP use is proven on one + connection. +4. Pass: content hashes, failed syntax/startup rollback, and v1/v2/v3 behavior + are exercised. +5. Pass: roots and exact scope identity are exercised in isolated workspaces. +6. Pass: promotion and server-only project recovery are exercised. +7. Fail: the Git and network package paths were not reached after the + repository search package required an unavailable `rg` executable. +8. Pass: in-memory and real stdio tests invoke new handlers through the stable + gateway without reconnecting. +9. Unverified: the full integrated fixed regression set has not run. + +## Progress, approach, and process audit + +- Criteria 1–6 and 8 improved from unverified to measured passes. Criterion 7 + is an absolute portability failure, not merely a weaker result. +- Passing lifecycle and stdio tests support retaining the direct Cordis, + worker, storage, and fixed-gateway representation. +- The highest-leverage problem is removing the starter package's undeclared + executable assumption while retaining ripgrep as a fast path. +- Continue the architecture but revise `repo_context_search`; no evidence + supports discarding the runtime foundation. +- The largest avoidable delay was 144 seconds in a failed test whose worker was + not registered for unconditional teardown. `node:test` cleanup now registers + before assertions, reducing the next failure cycle to under a second. +- The next feedback loop starts with the single starter target and only then + returns to the integrated regression set. diff --git a/projects/mcp_cordis/goals/runtime_extensions/attempts/002.md b/projects/mcp_cordis/goals/runtime_extensions/attempts/002.md new file mode 100644 index 00000000..c9dfd769 --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/attempts/002.md @@ -0,0 +1,92 @@ +# Attempt 2 + +[Back to durable goal](../) · [Attempt history](./) + +## Targeted failure + +Attempt 1's `repo_context_search` cannot execute when ripgrep is unavailable +from the runtime PATH, preventing all starter packages from passing their +portable Bazel execution test. + +## Hypothesis + +Keeping ripgrep as the preferred engine but falling back on a bounded Node +filesystem search will preserve normal-machine speed and make the reusable +package functional in hermetic or minimal environments. + +## Exact inputs and plan + +- Parent candidate: + `c9300c9887104777c8915e3d4f390196604e9bd18497bbec319415d1a4ad057f` +- Preserve all runtime, storage, MCP, and other starter-package code. +- Add a bounded fallback with workspace path checks, file/byte/result limits, + fixed or regex matching, context lines, and basic glob filtering. +- Store it as a new immutable `repo_context` version; retain Attempt 1's source + version in its manifest history. +- Rerun the starter test first, then the complete focused package checks. + +## Planned review packet + +- Search succeeds without `rg` in Bazel's test PATH. +- Traversal outside the workspace remains rejected. +- All eight starter tools execute through loaded Cordis Fibers. +- Previously passing lifecycle and real stdio checks remain green. + +## Verdict + +Refine. The rebased candidate commit +`e3e74cb1e573867825347292bf17220a5b9a4a0c` fixes criterion 7, but its final +integrated regression failed because the lifecycle test used elapsed time to +infer that an invocation remained in flight. + +## Work performed + +- Added a bounded pure-JavaScript fallback as immutable `repo_context` + version + `fd10633b1569665764e9a526f2cfaf38d1847ee9842934258cefc25f08ea9050` + while preserving ripgrep as the preferred engine and retaining the original + version in manifest history. +- Rebased the complete task commit onto fetched remote `master` + `7ad2704cd27757355ab36ec8eb1bb27ef9e1d91d` with no conflicts. The resulting + tree is `079a0c27b86527c6950cc75b0c8b9dbf572d3e4b`. + +## Verification evidence + +- Pre-rebase + `bazel_agent test //projects/mcp_cordis:starter_packages_test`: pass. All + eight tools executed, including search without `rg`. +- Post-rebase `bazel_agent query //projects/mcp_cordis:all`: pass. +- Post-rebase `bazel_agent test //projects/mcp_cordis:all`: three of four test + targets pass. `runtime_test` fails at its drain-count assertion with actual + `0`, expected `1`. +- The test starts a 150 ms invocation, waits only 20 ms, and then starts a new + worker before swapping generations. Candidate startup has no upper bound + below the old invocation's delay, so the test does not prove the invocation + is still active at the swap. + +## Acceptance results + +1. Pass in the integrated build test. +2. Unverified for final acceptance because the lifecycle regression did not + complete. +3. Unverified for final acceptance for the same reason. +4. Unverified for final acceptance for the same reason. +5. Pass in the previously focused storage/lifecycle evidence; final rerun is + still required. +6. Pass in the subprocess stdio target; final rerun is still required. +7. Pass on the exact rebased candidate through the starter-package target. +8. Pass on the exact rebased candidate through the stdio target. +9. Fail: the complete fixed regression set is not green. + +## Progress, approach, and process audit + +- Criterion 7 measurably improved from fail to pass; no starter package now + assumes ripgrep is installed. +- The runtime representation did not regress. The failing value demonstrates + that the old request finished before the atomic swap, which is permitted; + the test's elapsed-time setup failed to establish its own precondition. +- The highest-leverage issue is evidence quality, not another runtime rewrite. +- Attempt 3 should preserve all delivered runtime bytes and replace only the + drain test's wall-clock inference with a deterministic cross-worker latch. +- The requested rebase and adapter compilation dominated this cycle's wall + time. Focused query feedback fell to under two seconds once caches were warm. diff --git a/projects/mcp_cordis/goals/runtime_extensions/attempts/003.md b/projects/mcp_cordis/goals/runtime_extensions/attempts/003.md new file mode 100644 index 00000000..1aaf1a5b --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/attempts/003.md @@ -0,0 +1,100 @@ +# Attempt 3 + +[Back to durable goal](../) · [Attempt history](./) + +## Targeted failure + +The integrated lifecycle test assumes that a 150 ms invocation remains active +after a new worker has started. Under parallel Bazel execution, the candidate +can become ready only after that invocation has completed, making the expected +drain count nondeterministic. + +## Hypothesis + +A file-backed started/release handshake in the test package will establish the +in-flight precondition independently of worker startup speed and prove that a +generation swap reports and drains exactly one old invocation. + +## Exact inputs and plan + +- Parent commit: + `e3e74cb1e573867825347292bf17220a5b9a4a0c` +- Parent tree: `079a0c27b86527c6950cc75b0c8b9dbf572d3e4b` +- Base commit: `7ad2704cd27757355ab36ec8eb1bb27ef9e1d91d` +- Preserve all product runtime, storage, MCP, package, and build files. +- Extend only the lifecycle test fixture with optional started/release paths. +- Wait for the started marker before activation, keep the old handler blocked + until after the drain count is observed, and release it in `finally` so a + failed assertion cannot strand teardown. +- Rerun the focused lifecycle target first, then the entire recorded regression + set on one amended candidate. + +## Planned review packet + +- The test contains no fixed request duration or startup race. +- Replacement reports exactly one draining call. +- The old call returns v2 and the next call returns v3. +- Cordis cleanup still runs exactly once. +- All project, buildifier, and diff checks pass on the same commit tree. + +## Verdict + +Accept as the final local candidate. Commit +`7cfef0719075ad372c3bb257ad216b35770356b2` and tree +`34153eca0f582af5c641f81bf8c7209b0045ab9a` pass the entire evidence plan. +Remote delivery is pending separate authorization. + +## Work performed + +- Replaced the 150 ms elapsed-time assumption with workspace-local started and + release markers in the lifecycle test fixture. +- Proved the new v3 generation serves calls while the old v2 invocation remains + blocked, then released v2 and proved its Cordis effect disposes exactly once. +- Applied Buildifier's mechanical label ordering to the runtime test data. +- Amended the sole feature commit through the delivery adapter without changing + its direct base parent. + +## Verification evidence + +- `git diff --check HEAD^..HEAD`: pass on the prepared commit. +- `bazel_agent query //projects/mcp_cordis:all`: pass. +- `bazel_agent test //projects/mcp_cordis:runtime_test`: pass after the + deterministic gate. +- `bazel_agent test //projects/mcp_cordis:all --nocache_test_results`: pass, + four of four tests executed on the exact commit. +- `bazel_agent build //projects/mcp_cordis:all`: pass, all nine targets. +- `bazel_agent test //:buildifier_test --nocache_test_results`: pass. +- The delivery receipt records direct base + `7ad2704cd27757355ab36ec8eb1bb27ef9e1d91d`, prepared head `7cfef071`, and + prepared tree `34153eca`. + +## Acceptance results + +1. Pass: documented standalone Bazel-built stdio MCP server. +2. Pass: lifecycle tests exercise real Cordis contexts, Fibers, and effects. +3. Pass: one runtime and one stdio connection exercise the complete mutable + package lifecycle. +4. Pass: immutable versions, rollback, deterministic drain, and exact cleanup + are exercised. +5. Pass: isolated project and scratch roots are exercised. +6. Pass: promotion and subprocess restart recovery are exercised. +7. Pass: three justified starter packages and all eight tools execute. +8. Pass: fixed discovery/invocation gateways work without reconnecting. +9. Pass locally: all recorded checks and the real stdio transcript pass on the + exact candidate tree. Remote repository handoff remains pending authority. + +## Progress, approach, and process audit + +- Criterion 9 improved from a nondeterministic failure to a forced, exact-tree + pass; no technical criterion regressed. +- The explicit gate improves evidence in absolute terms: candidate startup can + take arbitrarily longer than the old call without changing the assertion. +- Independent code review and the measured zero drain count both supported + retaining transactional start-before-swap behavior. +- No defect survived two attempts. The elapsed-time test and Buildifier order + are resolved in their first corrective cycle. +- Adapter and root Buildifier startup dominated wall time; warmed focused tests + remained under ten seconds. Further local optimization would not change the + delivery critical path. +- The only remaining action is remote publication, which cannot proceed from a + rebase-only authorization. diff --git a/projects/mcp_cordis/goals/runtime_extensions/attempts/004.md b/projects/mcp_cordis/goals/runtime_extensions/attempts/004.md new file mode 100644 index 00000000..e9a7e724 --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/attempts/004.md @@ -0,0 +1,101 @@ +# Attempt 4 + +[Back to durable goal](../) · [Attempt history](./) + +## Targeted failures and scope + +PR 32 commit `7cfef071` is remotely reproducible but is not a final candidate: +three review findings are valid. The user also requires the four +`projects/agents` changes from PR 24, whose missing `decision-review` package +explains the current base's dangling instruction reference. + +## Hypotheses + +1. Applying PR 24's changes three-way will preserve newer `goal` guidance while + importing its result-first additions, Bazel batching guidance, and + `decision-review` package. +2. Treating command overflow as bounded success will preserve useful prefixes + for search and diff consumers without leaking child processes or invalid + UTF-8. +3. Enforcing `max_changes` before every porcelain status record will bound all + record kinds uniformly. +4. Closing admission, draining registered package locks, then disposing active + workers will make shutdown linearizable with concurrent run/reload. + +## Exact inputs and boundaries + +- Current and published task commit: `7cfef0719075ad372c3bb257ad216b35770356b2`. +- Current base/direct parent: `7ad2704cd27757355ab36ec8eb1bb27ef9e1d91d`. +- PR 24 base/head: `ada3ed90123c224729f9174c6127c50b933d2f48` + / `da2085f1807bfea1c7f3979730f6b7df0033fdce`. +- Import boundary: only paths beneath `projects/agents` changed by PR 24. +- Review boundary: the three existing PR 32 threads and directly required + regression coverage; no unrelated runtime redesign. +- Preserve all immutable package versions already referenced by manifests. + +## Candidate plan + +1. Merge the PR 24 `bazel-agent` and `goal` hunks into current files; add + `decision-review` plus the offline eval structure required today. +2. Add black-box execution overflow coverage and implement bounded UTF-8-safe + truncation while preserving timeout/spawn errors. +3. Add table-driven status-limit coverage and publish a new immutable + `git_worktree` version with the limit check before record parsing. +4. Add a deterministic concurrent activation/shutdown gate and memoized + shutdown sequence that waits for admitted locks before disposal. +5. Run focused tests first, then both affected packages, Buildifier, exact diff + checks, delivery preparation, exact-candidate validation, publication, and + review-thread replies/resolution. + +## Planned acceptance packet + +- PR 24 provenance maps exactly to the imported agent changes; newer main + guidance remains present. +- `decision-review` validates and its Promptfoo configuration loads offline. +- Output at and beyond the cap is bounded, UTF-8 valid, truncation-marked, and + stopped; under-cap and timeout behavior remain correct. +- Every porcelain-v2 record kind obeys `max_changes`. +- Shutdown cannot resolve before an already-admitted activation is owned and + disposed, and later calls reject `runtime_closed`. +- All previous MCP lifecycle, restart, package, and stdio regressions remain + green on the exact delivered commit. + +## Progress, approach, and process audit + +- Remote publication improved delivery evidence but exposed three absolute + correctness failures; validation success alone was insufficient. +- The PR 24 request resolves an upstream packaging inconsistency and is + independent enough to integrate before runtime corrections. +- The current representation remains viable: every defect has a narrow + controlling mechanism and deterministic test. No evidence supports replacing + Cordis, the worker boundary, or content-addressed persistence. + +## Work performed + +- Imported all four PR 24 agent-tree changes three-way and added the offline + validation files required by current repository policy. +- Changed `ctx.exec()` overflow from an output-limit rejection to a bounded, + UTF-8-valid success result with explicit process-group cleanup and a + truncation flag. +- Published the corrected Git parser as new immutable version `de978...` while + retaining `70d8...` as permanent rollback history. +- Made shutdown join admitted activation locks before its final active-worker + snapshot and added closure checks around initialization storage awaits. +- Added direct regressions plus an actual `git_compare` cap integration case. + +## Verification evidence + +- Four focused Bazel tests: pass. +- Complete MCP package plus three skill eval-config targets: ten of ten pass. +- Complete MCP package build plus three skill libraries: pass; all skill + validation aspects pass. +- Root `//:buildifier_test`: pass. +- JavaScript syntax, exact new version SHA-256, quick skill validation, and + `git diff --check`: pass. + +## Final verdict + +Refine. The product and packaging gates were green, but independent review +found silent output-loss and exact-limit semantics that those gates did not +exercise. Preserve the architectural changes; Attempt 5 changes the affected +contracts and tests rather than discarding the worker/runtime design. diff --git a/projects/mcp_cordis/goals/runtime_extensions/attempts/005.md b/projects/mcp_cordis/goals/runtime_extensions/attempts/005.md new file mode 100644 index 00000000..40098f63 --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/attempts/005.md @@ -0,0 +1,131 @@ +# Attempt 5 + +[Back to durable goal](../) · [Attempt history](./) + +## Targeted failures + +Attempt 4 passed every recorded command but independent review disproved four +completeness claims: malformed UTF-8 loss was unmarked, built-in consumers +silently accepted partial host output, Git status truncation was inferred from +capacity rather than omission and missed newline paths, and imported skill +evals did not cover their added behavior. + +## Hypotheses and plan + +1. Have UTF-8 decoding report whether it dropped any retained bytes and OR + that fact into `truncated`; cover malformed output below and above the cap. +2. Publish new immutable versions for each reusable package whose result fields + need host-truncation propagation. Preserve every previously tracked version + byte-for-byte and make partial fields explicit rather than hiding signal + termination as ordinary success. +3. Parse status records before deciding whether an additional logical change + was omitted, capture NUL-delimited paths with newline-safe expressions, and + cover exact-one, max-two, max-one-of-two, copy, and newline cases. +4. Add offline cases that exercise compatible multi-target Bazel batching, + immutable exact-hash candidate promotion/regression, and durable task-owned + push behavior that excludes disposable output and reports blockers. +5. Track disposal of an activation removed by `#handleUnavailable()` as a + retirement so shutdown cannot return before its worker teardown completes. +6. Rerun each focused target, all affected package/skill tests and builds, + Buildifier, and another independent review before freezing a commit. + +## Boundaries + +- Retain the Cordis/worker/storage architecture and the shutdown correction. +- Do not modify historical hash-named version bytes. +- Do not import any PR 24 path outside `projects/agents`. +- Do not add live, billable eval targets; these cases extend the existing + offline-validated behavioral configurations. +- Do not prepare, commit, or publish until the new review is clean. + +## Planned acceptance packet + +- Every discarded output byte makes `truncated` true. +- Each Git/context field that can be partial exposes that fact; bounded diff + and search remain successful. +- Shutdown joins teardown for generations that become unavailable immediately + before shutdown, not only those still present in the active map. +- Exactly `maximum` status changes reports complete, while an actual additional + parseable change reports truncated; valid newline paths round-trip. +- Eval configs validate with cases for every material imported behavior. +- A second independent read-only review reports no remaining actionable issue. + +## Working-tree result + +- `ctx.exec()` now retains a combined bounded prefix, marks byte overflow and + malformed UTF-8 loss as `truncated`, and stops the child before resolving. +- `repo_context` activates immutable version `abd0db3e...`; root, HEAD, + status, and ripgrep results propagate host truncation explicitly. +- `git_worktree` retains original version `70d8f28d...` unchanged and activates + sole new version `8853aa20...`. The rejected intermediate `de978...` file is + absent. Exact-limit status, newline paths, incomplete NUL records, revision + discovery, history, name-status, diff, and aggregate flags are covered. +- Runtime shutdown is memoized, closes admission, joins admitted package + operations, and tracks disposal after an unavailable activation leaves the + active map. +- PR 24's `projects/agents` subtree is integrated as `bazel-agent`, `goal`, and + packaged `decision-review` changes only. Newer goal-record and delegation + policy is preserved; added eval cases exercise every material imported rule. + +## Validation evidence + +- Focused Bazel regression/eval batch: 8 of 8 tests pass. +- Entire affected MCP package plus skill evals: 12 of 12 tests pass. +- Entire MCP package and all three skill libraries build; every skill + validation aspect passes. +- Root `//:buildifier_test`: 1 of 1 passes. +- `git diff --check`, JavaScript syntax checks, and every retained/new reusable + package content hash pass. + +## Progress, approach, and process audit + +Attempt 5 materially closes every falsified completeness claim from Attempt 4 +without replacing the accepted architecture. New versions are confined to +the two reusable packages whose public result contract changed; historical +bytes remain intact. The test packet now observes semantic completeness rather +than only byte caps or array lengths. The remaining critical path is review +and exact-candidate delivery, not additional implementation. + +Verdict: proceed to independent review. Do not freeze or publish the candidate +until that review is clean. + +## Independent-review correction 1 + +The agent-skill review found that PR 24's absolute "commit and push every +turn" wording contradicted the preserved throwaway-record policy when a +rejected attempt leaves no durable tracked change. The merged skill now makes +delivery conditional on authorized, nonempty durable tracked progress and +explicitly forbids empty/cosmetic commits or promoting disposable `out/` +evidence merely to manufacture a checkpoint. A new offline case covers the +no-durable-output rejection. This changes the candidate and invalidates the +prior goal eval result; rerun the affected skill and integrated gates after +the remaining reviewers report. + +The follow-up review also found that the durable-progress eval incorrectly +required preparation while remote-ref ownership was unknown. Its oracle now +requires exact inspection followed by a safe stop before preparation, rewrite, +or publication until ownership is established. + +The review then found that `decision-review`'s self-contained case could run +meaningfully with the available provider, so offline validation alone did not +satisfy current skill-package policy. A manual, credentialed `promptfoo_test` +target now complements the ordinary offline validation target; it is declared +but will not be executed as part of normal or delivery validation. + +The generic goal skill also carried Blender-specific scene, topology, +datablock, and linked-library mechanics from PR 24. Those details could +misroute domain policy into software and documentation goals. The merged text +now retains only the cross-domain invariant: immutable candidate copies, one +writer per candidate, exact or deterministic component promotion, and +post-promotion regression. Its eval uses a generic protected deliverable. The +`bazel-agent` eval documentation now correctly describes its plural cases. + +The generalized component-promotion rule initially tried to compare a +component-merged aggregate with a whole-candidate hash, which is impossible. +It now distinguishes exact whole-candidate promotion from independently +hashed component promotion and always reruns affected aggregate gates. + +The first manual `decision-review` target reused credentials without isolating +subject and judge state. Its config now uses separate runner-provided Codex +homes and workspaces, an explicit executable override, and isolated proxy +inheritance; its README documents the required absolute-path invocation. diff --git a/projects/mcp_cordis/goals/runtime_extensions/attempts/006.md b/projects/mcp_cordis/goals/runtime_extensions/attempts/006.md new file mode 100644 index 00000000..2af7e89a --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/attempts/006.md @@ -0,0 +1,140 @@ +# Attempt 6 + +[Back to durable goal](../) · [Attempt history](./) + +## Why Attempt 5 was rejected + +Attempt 5 passed every repository gate, but the required second independent +review found release-blocking lifecycle and compatibility defects: worker +termination could orphan detached `ctx.exec()` children, promotion was outside +shutdown admission, unavailable candidates could cross the persistence/swap +boundary, and the new always-partial output contract made retained package +versions lie about completeness. The same review found narrower package and +imported-skill correctness gaps. The candidate is rejected despite green +tests. + +## Strategy reset + +1. Preserve backward compatibility by making partial output an explicit + `ctx.exec()` option. Existing package versions retain rejection on overflow; + new versions opt in and receive an explicit output-limit reason alongside + `truncated`. +2. Close worker disposal admission before spawning, clean child process groups + both before and after admitted handlers settle, and replace immediate parent + termination with graceful disposal followed by the existing bounded forced + fallback. +3. Track complete promotion operations in the runtime admission barrier and + stage candidate availability across persistence so a failed candidate cannot + replace or persist over the working generation. +4. Publish sole new immutable starter versions after correcting unexpected + signal/error handling, exact-boundary flags, omitted-record flags, Git + read-only behavior, robust history framing, and containment checks. Delete + every rejected untracked hash candidate rather than retaining it as history. +5. Pin LF checkout semantics for hash-addressed source and test retained-version + compatibility, exact output boundaries, outer timeouts, settlement-after- + group-confirmed settlement, late-spawn disposal, promotion shutdown, and + candidate failure. +6. Finish the PR 24 merge by keeping generic goal invariants generic and making + the manual `decision-review` eval isolate subject and judge state. + +## Boundaries + +- Do not change any previously tracked hash-named source bytes. +- Do not add PR 24 paths outside `projects/agents`. +- Do not run the credentialed manual Promptfoo target. +- Linux is the supported supervised-execution platform for this repository + target. The guarantee covers the direct child and live descendants that stay + in its original process group; deliberate new sessions are excluded. +- Keep one writer per runtime source group and one writer for all active + content-addressed package candidates/manifests. + +## Acceptance packet + +- No live member of a supervised original process group survives inner timeout, + outer invocation timeout, disposal, shutdown, or successful truncation + settlement. +- Shutdown waits for every admitted mutation, including promotion, and never + publishes a candidate observed unavailable during persistence. +- Historical package versions reject overflow under their original contract; + only explicit opt-in versions return a marked partial prefix. +- Every result field distinguishes complete, locally clipped, host-limited, + failed, and unexpectedly signaled outcomes. +- Exact limits are complete until one additional logical record is observed. +- All content hashes, LF attributes, skill validation, offline eval configs, + focused regressions, integrated tests/build, and Buildifier pass. +- A fresh review of the final Attempt 6 diff reports no release-blocking issue. + +## Work performed + +- Restored the historical `ctx.exec()` contract: overflow and invalid UTF-8 + reject by default, while new packages opt into a bounded marked prefix. +- Moved process launch and output accounting from the disposable worker into a + parent-owned Linux supervisor. Launch admission is atomic in the parent's + event loop, direct exit terminates the original process group before inherited + pipes can hold settlement open, and timeout/disposal results wait for group + non-liveness. +- Narrowed the documented contract honestly: a package that deliberately calls + `setsid()` or otherwise creates a new session is outside the trusted original- + group guarantee; non-Linux execution fails closed. +- Made shutdown actively dispose both active and draining retired generations, + and staged candidate persistence so an unavailable candidate rolls back + without displacing the working generation. +- Published one current source candidate per starter package. The hashes are + `04b06a7d6277c4a6e8513d970f549ad980a780b68755f28d7b402fe8be26c279` + for `git_worktree` and + `94131e058f82328f091613dc68d2717484378066a9c64940d99522c14b48b4d7` + for `repo_context`; historical source bytes remain unchanged. +- Made ripgrep byte-valued JSON fields explicitly incomplete rather than + returning empty text under a false completeness claim. +- Imported PR 24's agent skills, added current validation/eval packaging, + generalized goal policy, and moved this complete goal directory into the + reusable project docs hierarchy. + +## Verification so far + +- Static syntax, manifest/hash, LF-attribute, local-link, and `git diff --check` + checks pass on the corrected working tree. +- Focused process, unavailable-shutdown, and retired-generation tests pass 3/3 + in Bazel invocation `9b1b2d34-8490-474a-b12c-e2052bf2d90b`. +- Process, package-byte-field, and retired-generation tests pass 3/3 in Bazel + invocation `7c680be8-e2a6-4b5b-9b45-4a0390f39a5a`. +- The earlier 14/14 integrated packet, complete affected build, validation + aspects, and Buildifier passed before the supervisor and final package-byte + corrections. Those results are preserved as progress evidence but are + invalidated for final acceptance and must be rerun. + +## Independent-review result + +The first final review rejected the candidate despite green tests. It found +that outer timeouts settled before cleanup, a signal was mislabeled as reaping, +the shared PID publication window made disposal unbounded, inherited pipes +could delay successful commands, retired generations were not actively +disposed by shutdown, ripgrep byte fields could masquerade as text, and the +durable records were stale. The strategy changed from worker-side process +ownership to parent-owned supervision; every other finding has a direct source +or regression correction. Fresh review of that new strategy is pending. + +## Acceptance status + +- Criteria 1-8: pass on implementation and existing executable evidence. +- Criterion 9: unverified after the latest source changes; full exact-candidate + validation and delivery remain. +- Criterion 10: focused regressions pass; fresh adversarial source review is + still pending. + +## Progress, approach, and process audit + +Attempt 6 improved compatibility, package completeness, promotion admission, +and teardown coverage in absolute terms. The repeated late lifecycle findings +showed that PID publication inside a terminable worker was the wrong ownership +boundary, not merely an under-tested implementation. Moving execution to the +parent removes that race and makes cleanup ordering directly observable. The +highest-leverage remaining work is review and full validation of this new +boundary, not more feature expansion. The attempt remains open until that +review accepts one frozen candidate and delivery verifies the same bytes. + +## Decision + +Refine within Attempt 6: the review changed an implementation strategy without +changing the recorded goal or acceptance contract. Do not freeze or deliver +until fresh review and the complete invalidated regression set pass. diff --git a/projects/mcp_cordis/goals/runtime_extensions/attempts/007.md b/projects/mcp_cordis/goals/runtime_extensions/attempts/007.md new file mode 100644 index 00000000..7d5bdf1f --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/attempts/007.md @@ -0,0 +1,84 @@ +# Attempt 7 + +[Back to durable goal](../) · [Attempt history](./) + +## Why Attempt 6 was rejected + +The user correctly identified that each package's `manifest.json`, +content-addressed `versions/` directory, and active/latest pointers were a +custom package manager rather than an MCP or Cordis standard. That mechanism +made reusable source look temporary, duplicated Cordis loader responsibilities, +and drove much of the worker-generation complexity. Green tests cannot justify +shipping the wrong extension model. + +## Decision review + +**Verdict: revise and proceed.** The published DeepSeek Cordis packages provide +the missing standard mechanisms directly: + +- `@deepseek-ai/cordis-plugin-loader` owns runtime entries and lifecycle; +- `@deepseek-ai/cordis-plugin-include` persists entries in `cordis.yaml` and + transactionally refreshes them with rollback; and +- `@deepseek-ai/cordis-plugin-hmr` watches normal modules, imports changed code + before replacement, and restores the prior runtime when reload fails. + +The strongest objection is that HMR is event-driven and failed source reloads +are logged rather than returned to the file writer. MCP-driven updates must +therefore retain prior bytes, wait for a correlated reload result, and restore +the prior source on failure or timeout. Manual external edits retain Cordis +HMR's normal behavior and diagnostics. + +## Frozen plan + +1. Pin the exact released Loader, Include, HMR, Timer, and required peer + dependencies through the project-owned npm lock. +2. Replace package manifests and hash-named versions with normal ESM modules: + reusable modules under `projects/mcp_cordis/plugins/`, disposable modules + under `out/mcp_cordis/plugins/`. +3. Make `projects/mcp_cordis/cordis.yaml` and + `out/mcp_cordis/cordis.yaml` the authoritative standard entry lists. +4. Mount the official Cordis Loader, two Include trees, Timer, and HMR in the + stdio server. Keep the stable MCP gateway, workspace helpers, bounded + process execution, and tool-registration effects as host services. +5. Implement MCP define, update, start, stop, remove, and promotion as atomic + source/config changes followed by Cordis lifecycle acknowledgement and + rollback. Use Git for reusable history; do not create a second version + database. +6. Replace version-store tests with standard-config, restart, HMR rollback, + manual-edit reload, and project/scratch promotion tests. +7. Run the complete affected test/build/Buildifier packet, obtain a fresh + independent review of the standard design, then rebase and deliver PR 32. + +## Boundaries + +- Do not add an MCP Registry `server.json` unless the server is actually being + prepared for registry publication; it describes the whole server, not its + internal Cordis entries. +- Do not retain committed hash-named source snapshots or custom package + manifests. +- Keep reusable source and config in the project and all disposable modules, + atomic-write scratch, state, and logs under `out/mcp_cordis`. +- Keep one normal module per package and one stable entry id per scope. +- Preserve PR 24's scoped `projects/agents` import and the accepted process + execution corrections that remain relevant to the in-process host. + +## Acceptance status + +Implementation and working-tree validation are complete; exact post-rebase +delivery gates remain open. The final architecture adds four narrow host +guards around the official Cordis services: synchronous activation admission +to prevent a never-settling `apply()`, source-token HMR correlation, a private +stdio protocol stream, and Fiber/invocation-owned Linux process supervision. +The real stdio regression covers define, inspect, run, invoke, update, failed +update rollback, stop, remove, logging isolation, and restart recovery. + +Bazel invocation `39711671-375a-413f-8a72-e6f9ff892bd3` passes all 11 affected +tests on the rebased implementation after the import-boundary review +corrections, including Buildifier and all three imported skill configurations. +Invocation `153467fd-e62d-4eab-b260-6754d17fe8e2` builds all 26 affected +targets. Those receipts bind implementation commit `0a93e487`; the following +amendments change only this durable goal record, with proportional diff and +format validation required before publication. Fresh independent review +accepted durable-record commit `c05bd45a` with no actionable findings. +PR 32 was republished at the verified rebased head, its obsolete description +was replaced, and all three prior review threads were resolved. diff --git a/projects/mcp_cordis/goals/runtime_extensions/attempts/008.md b/projects/mcp_cordis/goals/runtime_extensions/attempts/008.md new file mode 100644 index 00000000..32d6105d --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/attempts/008.md @@ -0,0 +1,108 @@ +# Attempt 8 + +[Back to durable goal](../) · [Attempt history](./) + +## Trigger + +The fresh hosted review of the delivered standard-Cordis candidate found three +valid starter-package defects: the JavaScript search fallback recursively read +ignored and hidden files when ripgrep was absent, fallback submatch offsets +used UTF-16 code units instead of ripgrep-compatible UTF-8 bytes, and a bounded +textual HTTP body could end with a replacement character when the byte limit +split a multibyte sequence. + +## Corrections + +- The JavaScript fallback now fails closed for directory searches and supports + only explicitly selected files. This preserves hermetic single-file fallback + without silently weakening ripgrep's hidden and ignore filtering. +- Fixed-string and regular-expression fallback matches calculate `start` and + `end` from UTF-8 byte lengths. +- Textual HTTP previews discard only an incomplete trailing UTF-8 sequence; + the raw retained-byte count and truncation signal remain exact. +- Follow-up review found that case-insensitive fixed matching could still use a + length-changing lowercased index, explicit files did not override globs as + they do in ripgrep, and unfiltered listing reparsed a malformed scope instead + of returning the healthy scope with an error. +- Case-insensitive fixed fallback now matches against the original line with a + Unicode regular expression, explicit files bypass fallback glob filtering, + and unfiltered listing returns per-scope errors alongside healthy packages. + Explicitly listing a malformed scope continues to fail directly. +- Final hosted review showed that a valid config with an unavailable module + still bypassed the malformed-config catch, and bounded reads reported the + requested range end instead of the last retained line. Unfiltered listing + now skips every scope whose Include failed to mount, and bounded reads derive + `endLine` from retained content. +- Fresh diff-focused correctness scrutiny then found that repeated + `initialize()` calls lost the original partial-startup errors. The runtime + now preserves and clones those errors across idempotent initialization. +- The same scrutiny found that incomplete UTF-8 suffix removal also ran for a + naturally completed malformed textual body. It now runs only when the local + byte cap truncates the response; naturally malformed bytes retain the prior + replacement-character preview. +- The repository's `repo-delivery` skill now invalidates prior correctness + verdicts after behavior-changing edits and requires proportional adversarial + scrutiny in addition to test reruns. +- Exact-thread reconciliation after the final hosted review exposed one older + unresolved finding and one new finding: the explicit-file regex fallback + matched UTF-16 surrogate halves, and `cordis_define` plus `cordis_promote` + advertised overwriting operations as non-destructive. Regex fallback now + uses Unicode scalar mode, and both source-overwriting tools carry the + destructive MCP hint. +- The next exact-commit review found two more fallback-boundary defects: + adjacent matches were also emitted as context, and a retained empty line was + reported like a request past EOF. Fallback search now plans a single ordered + event stream from all matching lines, and bounded reads track range existence + independently from textual content. + +## Evidence + +- Focused invocation `b73ce3b4-bfb6-4081-b84f-29c3f763b3a4` passes both + corrected starter-package test targets. +- Complete MCP test invocation `93845f08-cd10-4dfb-88c5-034497dea58a` + passes all 7 tests; build invocation + `d7716a48-9509-44fe-9e37-b5c44904fffd` builds all 16 targets. +- Buildifier invocation `1fb561cc-d336-49db-813f-de26b7fedbe4` passes. +- Regression cases prove directory fallback fails closed while an explicitly + selected hidden file remains available, both fallback engines report byte + offsets for `éneedle`, and a one-byte preview of `é` returns an empty valid + UTF-8 prefix rather than U+FFFD. +- Follow-up focused invocation `c07704be-cdf8-4045-a0c0-4626ebc0d1e7` + passes both affected targets. Complete test invocation + `3fbaa4e1-8ea1-4b3f-a533-51e1ad01f87b` passes all 7 tests, build invocation + `f87e45b6-765e-49c8-a618-cd2eb1efa1ad` builds all 16 targets, and Buildifier + invocation `8542838d-92ae-4999-b1e8-fac631629f6f` passes. +- Final-cycle focused invocation `99866999-c31d-457f-b64b-c7a15073e7a2` + passes both affected MCP tests. Combined affected invocation + `e86a5d1c-cadf-4ff6-9647-3e1050e461e6` passes all 8 MCP and skill tests; + `083fbf55-851e-4db4-b8e5-e5871c874faa` builds all 19 affected targets. + Skill quick validation and Buildifier both pass. +- Focused starter invocation `e80439df-bddd-4a76-b9e1-be6c7f1ed649` + distinguishes a cap-split multibyte prefix from a naturally completed + malformed textual body. +- Exact aggregate validation exposed timing-sensitive evidence: a wall-clock + admission bound failed under load, and the expected invocation timeout could + reject before its assertion was attached. The test now asserts the wrapped + synchronous-admission semantics and attaches the expected rejection before + waiting for its PID fixture. Invocation + `e7d69598-b843-4da4-833b-e024d406b8ca` passes three consecutive runs. +- A later loaded aggregate run exposed a real HMR rollback race. Cordis restores + the prior module cache after a failed import without emitting a reload event; + the host unnecessarily waited for a second filesystem reload that could be + absent. Rollback now accepts the already-restored prior source marker and + waits for HMR only when the failed candidate actually reached the cache. + Invocation `5f9b49e0-d3d3-4f9e-84ec-602dfbe38c77` passes three runs. +- Focused invocation `132e2f33-837c-40b2-83b5-4b06ceadfd0f` passes the + Unicode fallback and real-stdio MCP annotation regressions. +- Complete affected invocation `b19d303e-0aca-4097-a191-e81015dc2982` + passes all 8 MCP and skill tests; build invocation + `1c8aa274-dd72-4e6f-9491-de5e283b2c5c` builds all 19 targets, and + Buildifier invocation `34f6f5c7-7f44-478b-b939-63ac03c3bbb1` passes. +- Focused invocation `2528b6ee-7d82-48c9-a607-298a4cef0b9b` proves adjacent + matches remain ordered match events and empty retained lines report their + actual endpoint while a request past EOF reports `null`. + +## Verdict + +Accept locally. Publish the exact follow-up correction commit, resolve the +hosted review threads, and verify the remote head before final handoff. diff --git a/projects/mcp_cordis/goals/runtime_extensions/attempts/009.md b/projects/mcp_cordis/goals/runtime_extensions/attempts/009.md new file mode 100644 index 00000000..be509629 --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/attempts/009.md @@ -0,0 +1,62 @@ +# Attempt 9 + +[Back to durable goal](../) · [Attempt history](./) + +## Trigger + +The user rejected the growing HMR race-handling layer and explicitly requested +the simplest robust way for an MCP server to load DeepSeek/Cordis plugins. The +published parent is `bc4e5ae97ef9ea968c01b1b2a55403ae032a6a8d`. An +unpublished polling experiment is rejected rather than promoted. + +## Hypothesis + +Atomic persistence plus the official Cordis Include and HMR services is the +smallest reliable boundary. If MCP source mutations stop claiming synchronous +activation or transactional on-disk rollback, the runtime can delete its +source-marker protocol and every dependency on Loader internals while still +loading, invoking, and eventually hot-reloading normal Cordis plugins without +restarting the MCP connection. + +## Frozen plan + +1. Keep standard `cordis.yaml`, ordinary `plugins/*.mjs`, and the two project + and `out/mcp_cordis` scopes. +2. Keep the fixed MCP list/invoke gateway and the existing package context API. +3. Validate module syntax before an MCP write and use atomic file replacement. +4. For an existing running module, return after persistence with + `persisted: true`, `sourceChanged: true`, and `activation: "pending"`; + official Cordis HMR owns eventual activation. Do not claim that every + evaluation or `apply()` failure restores the prior live entry. +5. Keep public Include refresh for entry-list changes, because it is the + official transactional API for starting, stopping, adding, and removing + entries. +6. Delete injected source markers, HMR acknowledgement waiters, polling, + Loader `loadCache` inspection, and MCP-owned source rollback. +7. Update documentation and tests so success means persisted/configured, while + live update is verified by bounded eventual observation. +8. Retain and validate the two pending `repo_context` review corrections for + ordered context events and empty-line endpoints. +9. Register the server in the trusted workspace's `.codex/config.toml`. Use a + worktree-resolving launcher and Bazel's `run --script_path` handoff so the + long-lived MCP does not retain the Bazel output-base lock. +10. Rebase onto `d29f9d471ea467e8dfc75db4eedeedbbae43dc2d`, preserve its + `projects/goal` redesign, and discard the superseded in-place goal-skill + edits rather than replaying them. + +## Planned review packet + +- Focused runtime, stdio, and `repo_context` tests. +- Complete `//projects/mcp_cordis:all` tests and build. +- `repo-delivery` skill tests and root Buildifier. +- Fresh diff-focused scrutiny of update failure, disabled-entry, promotion, + restart, shutdown, and watcher timing paths. +- Independent review and exact PR 32 thread reconciliation before delivery. + +## Current verdict + +Refine. Removing wrapper-side acknowledgement machinery was correct, but an +independent reproduction proved that unmodified HMR 1.0.16 can lose a source +change arriving during an in-flight reload. Attempt 10 keeps the thin wrapper +and moves serialization into a focused, reproducibly pinned dependency patch. +The separate delivery-adapter refusal still prevents the history rewrite. diff --git a/projects/mcp_cordis/goals/runtime_extensions/attempts/010.md b/projects/mcp_cordis/goals/runtime_extensions/attempts/010.md new file mode 100644 index 00000000..4d065206 --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/attempts/010.md @@ -0,0 +1,120 @@ +# Attempt 10 + +[Back to durable goal](../) · [Attempt history](./) + +## Trigger + +Independent review of Attempt 9 reproduced a lost update in pinned +`@deepseek-ai/cordis-plugin-hmr` 1.0.16. A slow top-level-await replacement +followed by a second source write left the latest bytes on disk while the +earlier generation remained live. The public HMR surface has no module-failure +event, so a wrapper-side single-flight gate cannot be both safe and +recoverable. + +## Hypothesis + +The narrowest robust correction belongs in HMR's own reload scheduler. A +standard pnpm dependency patch can serialize `partialReload()` calls, snapshot +each observed change set, and drain changes arriving during an in-flight +reload. `mcp_cordis` then remains a thin persistence and invocation gateway +without source markers, Loader-cache inspection, polling, or acknowledgement +state. + +## Frozen plan + +1. Keep the standard Cordis Loader, Include, Timer, and HMR services and normal + `cordis.yaml` plus ESM plugin files. +2. Patch the exact HMR 1.0.16 artifact through pnpm `patchedDependencies`. + Track one module-refresh task, snapshot its stashed URLs before each reload, + and drain any URLs observed while that reload is running. +3. Patch both the published JavaScript and TypeScript source shipped in the + package; bind the patch through the generated lockfile and Bazel module + extension data. +4. Add explicit release-gated overlapping-update regressions for slow + top-level module evaluation and slow asynchronous `apply()` activation. +5. Refresh a disabled entry's exact cached module through HMR before enabling + it, so activation returns only after the latest persisted source is live. +6. Adopt the fetched base's role-based layout: command files under + `cmd/mcp_cordis`, private implementation under `internal`, and the separate + suite under `test`. +7. Preserve the two accepted `repo_context` review corrections and the + worktree-local launcher. +8. Stop before Git history mutation until the delivery adapter has an + authorized, guarded path for the nine-commit feature range. + +## Current evidence + +- The package registry and upstream repository both expose 1.0.16 as the + latest official HMR release; its source still invokes untracked concurrent + `partialReload()` work and clears the shared stash after one successful run. +- `git apply --check` accepts `patches/hmr@1.0.16.patch` against the exact + resolved package bytes. Its SHA-256 and lockfile patch hash are both + `ec800d86298faacc86c7717ffa1dce7c28116ab1393b8abc198be6ac02c38489`. +- The patch serializes module reloads through complete Cordis Fiber cleanup + and activation, drains newly stashed URLs, preserves and retries changes + after unexpected scheduler failures, and declares its public refresh API in + the shipped TypeScript declarations. +- Bazel invocation `ac79a8e4-f5d9-4dd7-a821-29bce3d8ece6` passes the focused + runtime suite with explicit top-level-evaluation and asynchronous-apply + overlap gates, failed-apply rollback and recovery, manual-edit, and disabled + activation regressions. +- Bazel invocation `65c1932a-896b-470d-9462-086dd93beaff` passes ten runs each + of `runtime_test` and `starter_packages_test`. +- Bazel invocations `6490dd57-49e0-4558-b280-f5625db07208`, + `218e5797-b2d7-44bc-aded-f5df8139ca1c`, and + `09d4ec2c-aac1-4509-8579-5ef8c5eebe39` pass the complete project tests, + complete project build, and root Buildifier check respectively. +- Final independent HMR review accepts the patch identity, stashed-change + draining, complete Fiber cleanup join, public declarations, causal overlap + tests, rollback and recovery, and disabled-entry activation behavior. Its + sole remaining finding was the corrected README publication-order wording. +- The checked-in workspace launcher completed MCP initialization and returned + all ten gateway tools while concurrent Bazel query invocation + `7739c14e-1b22-40c7-94af-b81143e84d4a` completed successfully; SIGINT then + produced a clean server shutdown. +- Preliminary current-tree Bazel invocations + `95d37231-63f9-44f6-9eee-3f0fe8fb4107`, + `d70f61a2-57e5-4ce9-b8fd-27b06bd02a6d`, and + `1e186c82-3bcd-4048-a534-fe747e1cf79c` pass the complete affected test + packet (10/10), affected build packet, and root Buildifier check. +- Guarded delivery inspection found local and remote feature OID + `bc4e5ae97ef9ea968c01b1b2a55403ae032a6a8d`, base OID + `d29f9d471ea467e8dfc75db4eedeedbbae43dc2d`, same-repository PR 32, SSH + transport, and nine linear commits all authored and committed by the task + bot. Its sole refusal is version 1's unconditional multi-commit range + refusal; the fetched base contains the same limitation and no explicit + consolidation authorization. +- The user then explicitly authorized extending the adapter. The new + `prepare --consolidate ` path retains every other refusal and + requires a single-parent chain, identical author and committer identities, + the oldest commit's ownership marker, unchanged pull-request projection, + and signature preservation. It creates one aggregate commit while binding + the original remote head into the normal publication receipt. +- Bazel invocations `574dadd5-51b1-443e-b8c2-50ca5d257eb2` and + `030a8bb4-2a22-4ece-b632-b3c75572bcee` pass the complete adapter suite once, + then its Go, skill-validation, and root Buildifier targets three times. +- Independent adapter review found that the first implementation required an + extra staged edit and therefore could not consolidate an already-clean + range. The corrected gate permits an unchanged index only after exact + consolidation evidence; parent-to-tree scope validation still rejects an + empty aggregate. A clean `--path` integration case proves tree preservation + and one final commit. Bazel invocation + `0d715375-6a8d-4269-ad3e-f8a002888808` passes the corrected suite, and the + independent re-review accepts it with no remaining findings. +- Upstream incorporation review preserves the new `projects/goal` project, + drops the deleted predecessor skill under `projects/agents`, adopts the + role-based MCP layout, and adds root-consumer visibility to the branch-owned + `decision-review` skill. The root discovery-link entry must be added after + the new base is applied. +- After that visibility correction, Bazel invocations + `6099d90d-0ade-43b2-b50b-8f7050c26c32` and + `7f4b37a4-b970-46ea-bc76-b4a60aeeab59` pass the focused skill validation and + root Buildifier check; `git diff --check` also passes. + +## Current verdict + +Proceed. The dependency-layer correction addresses the reproduced race and +failure recovery at Cordis's owning lifecycle boundary while the MCP wrapper +remains a thin persistence and invocation gateway. The complete local project +packet and focused independent review pass. Exact-candidate validation, +rebase, publication, and hosted-thread reconciliation remain open. diff --git a/projects/mcp_cordis/goals/runtime_extensions/attempts/011.md b/projects/mcp_cordis/goals/runtime_extensions/attempts/011.md new file mode 100644 index 00000000..6d383e8e --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/attempts/011.md @@ -0,0 +1,96 @@ +# Attempt 11: exact consolidated rebase + +[Back to attempt history](README.md) | [Back to durable goal](../) + +## Objective + +Replace the nine task-owned feature commits with one aggregate commit, rebase +that exact candidate onto the current remote base, preserve the incoming goal +and skill-discovery layouts, and establish publish-ready evidence without +bypassing `repo_delivery`. + +## Reconciliation + +- The fetched base advanced to + `63e7b9f0be1e054373415914ff3d2ea2282aa3da` and added the reusable + `projects/goal` project, per-skill discovery links, `decision-review`, and + exact-head remote-review waiting rules. +- The old agent-local goal skill remains deleted. The durable MCP goal stays + under `projects/mcp_cordis/goals/runtime_extensions`. +- The branch keeps the upstream root discovery target, including + `decision-review` and `projects/goal/skills/goal`, and combines the incoming + review-waiting policy with the branch's correctness-revalidation policy. + +## Delivery adapter corrections + +The authorized consolidation path exposed four fail-closed edge cases during +the real rebase: + +1. Existing PR text must match the requested aggregate projection, rather + than the obsolete first commit's projection. +2. Explicit staging must handle deleted paths, partial directory deletions, + and a tracked symlink replaced by a directory. +3. Patch files need the standard repository-wide whitespace exceptions for + structural context prefixes. +4. A rebased aggregate path may disappear only when the prior candidate and + new base contain the exact same Git tree entry. The receipt then records + the reduced path set; added paths, non-identical loss, and an empty + aggregate remain refusals. + +Each case has focused integration coverage. Every failed preparation restored +the original branch, index, and worktree before the next correction. + +## Exact candidate evidence + +- `repo_delivery prepare --consolidate` produced one commit on the fetched + base. The first exact code candidate before the final adapter-and-record + update was + `f1c313b0920cb92f2d643dcb5c7d79ab364df058`. +- Bazel query invocation `5ad9400e-f7b8-4688-9b8c-e962f3de8e66` discovered + the affected MCP, delivery, and skill targets. +- Bazel test invocation `13588b19-1f8a-43a7-8006-f9d5d4652670` passed all + 12 affected tests, including Buildifier and discovery-link validation. +- Bazel build invocation `5863ac24-3bb2-47be-99fb-50141e933018` passed all + 29 affected targets. +- The real launcher initialized, listed all ten gateway tools, and remained + live while Bazel query invocation + `4637f187-d05e-4905-8153-18fca8644ea1` completed. SIGINT then terminated + the server as expected. +- `git diff --check` passed and the worktree remained clean after validation. + +## Verdict + +The aggregate was published as a single commit on PR 32 and its delivery +receipt verified the local tree, remote feature ref, current base ancestry, +and PR projection. The exact-head hosted review found three additional issues: + +- repository reads reopened a checked symlink through its lexical alias; +- permanent `/proc` inspection failures could prevent shutdown from settling; +- the process-tree timeout regression assumed Node could start within 100 ms. + +The final correction reads through a canonical, no-follow file handle and +verifies that handle through `/proc/self/fd` before consuming bytes, turns +repeated process-inspection failures into a bounded `EXEC_CLEANUP` result, and +uses a startup-safe timeout in the process-tree regression. The full MCP test +and build packets pass, and both focused targets pass three repeated runs. +The review threads are reconciled through the receipt-bound delivery adapter. +The follow-up exact-head review found the same replaceable-path class in +`git_worktree`: discovery verified one repository directory, but later Git +commands reopened its lexical path. Git discovery and every subsequent +command now use `/proc//fd/` paths backed by verified open directory +handles, while the subprocess working directory remains workspace-local. +Focused mock coverage checks every Git `-C` path and the real Cordis starter +package integration passes. The next pass found the same class in +`repo_context`'s ripgrep and Git metadata branches; both now use verified +descriptor paths for the complete subprocess lifetime, and directory listing +uses the selected directory handle as well. Attempt 11 is accepted and the +goal is complete. The terminal exact-head review then identified that the +JavaScript regular-expression fallback could both block the MCP event loop on +pathological backtracking and disagree with ripgrep's Unicode semantics. The +fallback now fails closed for regex requests when ripgrep is unavailable; +bounded fixed-string search remains available. Focused and complete MCP test +and build packets pass after that correction. The next exact-head pass found +that replacement decoding of invalid UTF-8 also changed fixed-search raw byte +offsets. The fallback now fails closed for such files as well, leaving raw-byte +search semantics to ripgrep; the focused, complete test, and build packets +again pass. diff --git a/projects/mcp_cordis/goals/runtime_extensions/attempts/README.md b/projects/mcp_cordis/goals/runtime_extensions/attempts/README.md new file mode 100644 index 00000000..4a575d2e --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/attempts/README.md @@ -0,0 +1,36 @@ +# Attempt history + +[Back to durable goal](../) + +- [Attempt 11](011.md): consolidate the owned range, reconcile the advanced + base, validate and publish the exact rebased candidate, then incorporate and + reconcile the final hosted review. Complete. +- [Attempt 10](010.md): keep the thin wrapper and patch pinned Cordis HMR to + serialize reloads and drain writes arriving during an in-flight reload. + Complete locally and carried into Attempt 11. +- [Attempt 9](009.md): simplify source updates to validated atomic persistence + plus Cordis HMR, deleting the private acknowledgement transaction. Refined + after independent review reproduced a lost overlapping update. +- [Attempt 8](008.md): correct the final hosted review's fallback filtering, + byte-offset, and UTF-8 body-preview findings. Complete locally. +- [Attempt 7](007.md): replace the custom manifest/version store and worker + generations with official Cordis Loader, Include, HMR, standard + `cordis.yaml`, and normal modules. Delivered, then refined by Attempt 8. +- [Attempt 6](006.md): make bounded execution backward-compatible, close + process/lifecycle admission races, publish exact package completeness, and + replace worker-side spawning after independent review. Rejected because its + package persistence model was custom rather than standard Cordis. +- [Attempt 5](005.md): make every output-loss signal and bounded Git result + exact, then cover the imported skill behaviors. Refine after independent + review found lifecycle, compatibility, and policy defects. +- [Attempt 4](004.md): import PR 24's scoped agent guidance, then correct the + three valid PR 32 review findings. Refine after independent review. +- [Attempt 3](003.md): replace a lifecycle test's elapsed-time inference with + a deterministic started/release handshake. Published, then superseded by + review findings. +- [Attempt 2](002.md): retained the proven runtime and added a bounded search + fallback for hermetic portability. Starter packages pass; refine because + the integrated lifecycle evidence was timing-dependent. +- [Attempt 1](001.md): direct Cordis runtime with MCP v2, worker-isolated + generations, two-tier immutable persistence, and three starter packages. + Rejected because one starter package required an unavailable executable. diff --git a/projects/mcp_cordis/goals/runtime_extensions/evidence.md b/projects/mcp_cordis/goals/runtime_extensions/evidence.md new file mode 100644 index 00000000..5ad38393 --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/evidence.md @@ -0,0 +1,402 @@ +# Evidence manifest + +[Back to durable goal](./) + +## Attempt 8 review-correction evidence + +- Recursive JavaScript fallback fails closed when ripgrep is unavailable; + explicitly selected files remain supported. +- Fallback fixed and regular-expression submatches use UTF-8 byte offsets. +- Bounded textual HTTP previews omit an incomplete UTF-8 suffix. +- Focused tests pass 2/2 in invocation + `b73ce3b4-bfb6-4081-b84f-29c3f763b3a4`; the complete MCP package passes 7/7 + tests in `93845f08-cd10-4dfb-88c5-034497dea58a` and builds all 16 targets in + `d7716a48-9509-44fe-9e37-b5c44904fffd`. +- Buildifier passes in invocation `1fb561cc-d336-49db-813f-de26b7fedbe4`. +- Follow-up review corrections preserve original-line indexes for Unicode + case-insensitive matching, make explicit fallback files override globs, and + return healthy scopes plus structured errors from unfiltered listing after + partial startup. +- Follow-up focused tests pass 2/2 in + `c07704be-cdf8-4045-a0c0-4626ebc0d1e7`; complete MCP tests pass 7/7 in + `3fbaa4e1-8ea1-4b3f-a533-51e1ad01f87b`, all 16 targets build in + `f87e45b6-765e-49c8-a618-cd2eb1efa1ad`, and Buildifier passes in + `8542838d-92ae-4999-b1e8-fac631629f6f`. +- Final review corrections skip every unavailable Include scope and report only + retained line endpoints. Fresh correctness scrutiny also preserves startup + errors across repeated initialization and limits UTF-8 suffix removal to + locally truncated HTTP bodies. +- Focused tests pass 2/2 in `99866999-c31d-457f-b64b-c7a15073e7a2`. + Combined MCP and `repo-delivery` tests pass 8/8 in + `e86a5d1c-cadf-4ff6-9647-3e1050e461e6`; all 19 affected targets build in + `083fbf55-851e-4db4-b8e5-e5871c874faa`. Skill quick validation and + Buildifier pass. +- Focused HTTP evidence passes in + `e80439df-bddd-4a76-b9e1-be6c7f1ed649`. +- Runtime evidence uses semantic admission assertions and eagerly attaches the + expected timeout rejection; three consecutive runs pass in + `e7d69598-b843-4da4-833b-e024d406b8ca`. +- HMR rollback accepts an already-restored prior cache marker after failed + import; otherwise it still waits for an exact correlated reload. Three + runtime runs pass in `5f9b49e0-d3d3-4f9e-84ec-602dfbe38c77`. +- Regex fallback uses Unicode scalar mode, so `.` reports one four-byte match + for `😀`, and the real MCP tool catalog marks `cordis_define` and + `cordis_promote` as potentially destructive. Both focused regressions pass + in `132e2f33-837c-40b2-83b5-4b06ceadfd0f`. +- The complete affected packet passes 8/8 tests in + `b19d303e-0aca-4097-a191-e81015dc2982`, builds all 19 targets in + `1c8aa274-dd72-4e6f-9491-de5e283b2c5c`, and passes Buildifier in + `34f6f5c7-7f44-478b-b939-63ac03c3bbb1`. +- Fallback context is emitted once in line order and never reclassifies a + matching line as context. Bounded reads distinguish a retained empty line + from EOF. Focused invocation `2528b6ee-7d82-48c9-a607-298a4cef0b9b` + passes both regressions. + +## Attempt 7 working-tree evidence + +- The custom `manifest.json`, hash-named `versions/`, storage layer, activation + worker, and package worker have been removed. +- Official `@deepseek-ai/cordis-plugin-loader`, `-include`, `-hmr`, and + `-timer` packages are pinned. Bazel launches Node with the HMR package's + documented `--expose-internals` requirement. +- Project entries use `projects/mcp_cordis/cordis.yaml` and ordinary modules + under `plugins/`; scratch entries use the same layout under + `out/mcp_cordis`. +- Runtime integration proves create, live HMR update, failed activation with + on-disk/live rollback, stop, run, promotion, removal, and restart recovery. +- Direct starter-module tests and the complete starter runtime test pass. A + real stdio client proves the complete lifecycle, failed-update rollback, + package-log isolation, and restart recovery without replacing the MCP + process during an update. +- Runtime regressions prove exact source-limit round-trip, invalid-timeout + side-effect exclusion, never-settling activation rejection, handler-lease + draining, and direct/descendant process-group non-liveness at settlement. +- Affected test and Buildifier invocation + `39711671-375a-413f-8a72-e6f9ff892bd3` passed 11/11 on the rebased + implementation after the final import-boundary corrections. Affected build + invocation `153467fd-e62d-4eab-b260-6754d17fe8e2` passed all 26 targets. +- These full receipts bind implementation commit `0a93e487`. Later amendments + are confined to the durable goal record and require proportional diff and + formatting validation before publication. +- Fresh independent review accepted durable-record commit `c05bd45a` with no + actionable findings. +- PR 32 was republished at the verified rebased head. Its description now + records the standard Cordis architecture, and all three obsolete review + threads are resolved. + +## Preflight evidence + +- Official OpenAI documentation confirms local Codex clients can connect + directly to stdio MCP servers and read server instructions. +- Official DeepSeek documentation states that its dynamic Cordis definitions + are process-local and memory-only, establishing the need for the requested + persistence layer. +- Cordis `4.0.1` exposes the required `Context`, `plugin`, `Fiber.await`, + `Fiber.dispose`, and effect-scoped cleanup primitives without depending on + DeepSeek Harness's agent/session/browser packages. +- MCP SDK v2 provides a stable stdio server and fixed tool registration. Codex + does not reliably refresh dynamically added tool schemas, so the accepted + design keeps a fixed list/invoke gateway. +- Repository review selected an ordinary root-workspace Bazel package with a + project-owned pnpm lock and Bzlmod dependency fragment. +- Safe aggregate analysis of 40 top-level recent sessions selected + `repo_context`, `git_worktree`, and `network_probe` as the initial reusable + packages. No transcript or secret-bearing content will be copied. + +## Attempt 1 verdict + +- Architecture: one worker and Cordis root/fiber per active package generation. +- Storage: immutable content-addressed source and atomic manifests in explicit + `project` or `scratch` scopes. +- Update rule: start and validate a candidate, atomically swap the active + generation, then drain and dispose the previous generation. +- MCP rule: stdout is protocol-only; package output is redirected to stderr and + `out/mcp_cordis/logs`. +- Candidate hash: + `c9300c9887104777c8915e3d4f390196604e9bd18497bbec319415d1a4ad057f`. +- Focused query, lifecycle/in-memory MCP, and subprocess stdio tests pass. +- Starter execution test fails at `repo_context_search` with + `spawn rg ENOENT`; candidate rejected and Attempt 2 opened. + +## Attempt 2 verdict + +- Candidate commit: + `e3e74cb1e573867825347292bf17220a5b9a4a0c`. +- Candidate tree: `079a0c27b86527c6950cc75b0c8b9dbf572d3e4b`. +- Fetched base and direct parent: + `7ad2704cd27757355ab36ec8eb1bb27ef9e1d91d`. +- The delivery preparation receipt confirms an exact, conflict-free rebase, + task-only path scope, and absence of a remote feature ref or pull request. +- Focused post-rebase query passes. +- Integrated post-rebase result: three of four tests pass. Starter packages, + stdio, and build coverage pass; lifecycle evidence fails at an elapsed-time + drain precondition with actual `0`, expected `1`. +- Verdict: refine the test evidence in Attempt 3; do not change the runtime + architecture based on this measurement. + +## Attempt 3 verdict + +- Final local commit: + `7cfef0719075ad372c3bb257ad216b35770356b2`. +- Final local tree: `34153eca0f582af5c641f81bf8c7209b0045ab9a`. +- Direct parent and fetched base: + `7ad2704cd27757355ab36ec8eb1bb27ef9e1d91d`. +- Forced project tests: four of four pass on the exact commit. +- Complete project build: nine of nine targets pass. +- Forced root Buildifier and exact commit diff check: pass. +- Review verdict: accept as the final local candidate. Publication was stopped + before execution because a rebase-only request does not authorize remote + push/PR mutation. + +## Attempt 4 preflight + +- PR 32 was published at exact commit `7cfef071`; remote branch and PR tree + matched the local receipt. +- Current remote `master` is `7ad2704c`, the direct parent of the published + task commit; repository inspection reports `needs_rebase: false`. +- PR 24 head is `da2085f1`, based on `ada3ed90`. Its `projects/agents` diff is + exactly four files: one `bazel-agent` update, one `goal` update, and two new + `decision-review` files. +- Three PR 32 review threads were independently diagnosed as valid. Their + controlling fixes are bounded-success execution, pre-record change limits, + and shutdown admission closure followed by lock draining. +- Verdict: reject `7cfef071` as final; proceed with the scoped three-way import + and three behavior-changing corrections in Attempt 4. + +## Attempt 4 working-tree evidence + +- PR 24 import: `bazel-agent` batching guidance, result-first `goal` guidance, + and the exact `decision-review` instruction blob are present. Newer + throwaway-record and bounded-delegation guidance remains intact. +- Execution overflow now retains a combined arrival-order byte prefix, returns + only valid UTF-8, stops live process-group members, and reports `truncated` + rather than rejecting. Timeout and spawn errors retain rejection semantics. +- `git_worktree` keeps `70d8...` as immutable history and activates new exact + content hash `de978...`; its record limit is checked before every porcelain + record matcher. +- Shutdown closes admission, joins the exact admitted package-lock snapshot, + then disposes the final active set and awaits retirements through one + memoized promise. Initialization rechecks closure after awaited storage + boundaries. +- Four focused Bazel tests pass, including all new regression targets and + `decision-review` offline validation. +- Integrated Bazel test invocation passes ten of ten tests: the whole MCP + package plus all three imported/updated skill configurations. +- Full affected build passes and validates `bazel-agent`, `goal`, and + `decision-review`; root Buildifier passes. +- Verdict: behaviorally acceptable as a working-tree candidate. Independent + diff review and exact commit-bound validation are still required. + +## Attempt 4 independent-review verdict + +- Max-change review proved the bound placement but rejected the completeness + flag when exactly `maximum` records exist, the missing second-record test, + and newline-unsafe pathname capture. +- Execution review accepted byte bounding, process-group cleanup, and runfiles, + but + rejected silent invalid-UTF-8 loss and incomplete propagation of host + truncation through reusable package result fields. +- PR 24 provenance review accepted the three-way import and packaging, but + found no eval cases for compatible Bazel batching, immutable candidate + promotion, or durable-versus-throwaway push behavior. +- Verdict: refine; passing Attempt 4 checks do not qualify it for commit. + +## Attempt 5 working-tree evidence + +- Execution tests cover ASCII overflow, multi-byte boundary overflow, + malformed UTF-8 both below and above the byte cap, combined stdout/stderr + budgeting, timeout rejection, normal nonzero exit, and process-group + non-liveness at settlement. +- `git_worktree` active hash is + `8853aa20665778aeec43e03f2fe975445002d56ed33ea1cf38bef3946381f60d`; + its manifest retains only that version and unchanged historical hash + `70d8f28dc947d19410b8e79bad90cb416303107243d72d062dd70e30f97a2c3b`. +- `repo_context` active hash is + `abd0db3e26ec970dcf5cc3ec21b9f2b2c452f9302edc0e10c5231a140b92fbc0`; + all three manifest version hashes match their exact source bytes. +- Focused MCP regressions and three skill eval configurations pass 8/8. +- Full affected test packet passes 12/12; full affected build and three + `rules_skill` validation aspects pass; root Buildifier passes 1/1. +- Exact commit and post-rebase evidence remain pending, so this is a green + working-tree candidate rather than a delivered checkpoint. + +## Attempt 6 working-tree evidence + +- Current immutable starter hashes are + `04b06a7d6277c4a6e8513d970f549ad980a780b68755f28d7b402fe8be26c279` + for `git_worktree` and + `94131e058f82328f091613dc68d2717484378066a9c64940d99522c14b48b4d7` + for `repo_context`; line wrapping does not introduce whitespace into the + first identifier. +- Historical version bytes retain their exact filename hashes. The checkout + enforces LF for every hash-addressed JavaScript source. +- The parent activation now owns every `ctx.exec()` child handle. Focused tests + prove immediate live-process absence after inner timeout, outer timeout, + startup timeout, output overflow, normal completion with a background child, + and a background child inheriting output pipes. +- Runtime regressions prove shutdown admits complete promotion, rolls back a + candidate that fails during active-version persistence, and actively disposes + a retired generation whose admitted handler remains gated. +- Bazel invocation `9b1b2d34-8490-474a-b12c-e2052bf2d90b` passes the current + process, unavailable-shutdown, and runtime-admission targets 3/3. +- Bazel invocation `7c680be8-e2a6-4b5b-9b45-4a0390f39a5a` passes the current + process, ripgrep-byte-field, and runtime-admission targets 3/3. +- Fresh whole-diff and adversarial supervisor reviews are running. Full + integrated, build, skill-aspect, Buildifier, exact-commit, rebase, and remote + verification remain unverified after the latest changes. + +## Attempt 10 focused HMR evidence + +- Subject: dirty Attempt 10 tree using + `@deepseek-ai/cordis-plugin-hmr` 1.0.16 with pnpm patch hash + `ec800d86298faacc86c7717ffa1dce7c28116ab1393b8abc198be6ac02c38489`. +- Primary dependency evidence: the npm registry lists 1.0.16 as the latest + release, and current upstream `vendor/hmr/src/index.ts` retains the same + untracked debounced `partialReload()` plus shared-stash reset behavior. +- Independent reproduction: a slow top-level-await generation followed by a + second write reached `{live: "slow", diskLatest: true}` with the unpatched + package. +- Patch applicability: `git apply --check` accepts + `patches/hmr@1.0.16.patch` against the exact resolved 1.0.16 package files. +- Lock generation: Bazel-managed pnpm invocation + `a1c50484-20c6-4935-8832-92029d0de3c6` completed successfully with no + unrelated dependency resolution changes. +- Causal runtime regressions: Bazel invocation + `ac79a8e4-f5d9-4dd7-a821-29bce3d8ece6` passed explicit release-gated + top-level evaluation and asynchronous activation overlaps, failed apply + rollback and recovery, manual editing, and deterministic disabled activation. +- Repetition: Bazel invocation `65c1932a-896b-470d-9462-086dd93beaff` + passed ten runs each of `runtime_test` and `starter_packages_test`. +- Complete project packet: invocations + `6490dd57-49e0-4558-b280-f5625db07208`, + `218e5797-b2d7-44bc-aded-f5df8139ca1c`, and + `09d4ec2c-aac1-4509-8579-5ef8c5eebe39` passed all project tests, all project + builds, and root Buildifier respectively. +- Project-layout adaptation: Bazel invocation + `ea2c6c6e-b2e1-425d-bedb-2ac9679de6c5` passed `runtime_test` after moving + the command, internal implementation, launcher, and test suite to their + role-based directories. +- Independent final HMR review accepted patch identity, stashed-change + draining, complete Fiber cleanup join, declarations, causal overlap tests, + rollback and recovery, and disabled-entry activation. Its only finding was + a README sentence describing the superseded publication order; that contract + text is corrected in the current tree. +- The real `cmd/mcp_cordis/launch.sh` completed MCP initialization and listed + all ten tools. While that stdio server remained live, Bazel query invocation + `7739c14e-1b22-40c7-94af-b81143e84d4a` completed successfully, proving the + launcher releases the workspace Bazel lock before serving; SIGINT shut the + server down cleanly. +- Preliminary current-tree Bazel invocations + `95d37231-63f9-44f6-9eee-3f0fe8fb4107`, + `d70f61a2-57e5-4ce9-b8fd-27b06bd02a6d`, and + `1e186c82-3bcd-4048-a534-fe747e1cf79c` pass the complete affected test + packet (10/10), affected build packet, and root Buildifier check. +- Guarded delivery inspection bound same-repository PR 32 to local and remote + feature OID `bc4e5ae97ef9ea968c01b1b2a55403ae032a6a8d`, fetched base OID + `d29f9d471ea467e8dfc75db4eedeedbbae43dc2d`, and SSH transport. All nine + linear feature commits have the task-bot author and committer identity; the + only refusal is version 1's unconditional multi-commit consolidation guard, + which is unchanged on the fetched base. +- The user explicitly authorized the narrow adapter extension. Its exact-head + consolidation path verifies linearity, identity, oldest ownership marker, + pull-request metadata matching the requested aggregate projection, + signature requirements, and every other inspection refusal before creating + one aggregate commit. Integration + coverage proves that the pre-consolidation remote head remains the + receipt-bound publication lease. +- Bazel invocations `574dadd5-51b1-443e-b8c2-50ca5d257eb2` and + `030a8bb4-2a22-4ece-b632-b3c75572bcee` pass the complete adapter suite once, + then its Go, skill-validation, and root Buildifier targets three times. +- Independent adapter review rejected the first implementation because clean + ranges had no staged delta. The correction permits an unchanged index only + behind validated exact consolidation evidence, while parent-to-tree scope + validation continues to reject an empty aggregate. A clean `--path` + regression proves tree preservation and one final commit; Bazel invocation + `0d715375-6a8d-4269-ad3e-f8a002888808` passes, and independent re-review + accepts the corrected adapter with no remaining findings. +- After adding upstream-compatible root-consumer visibility to the + branch-owned `decision-review` skill, Bazel invocations + `6099d90d-0ade-43b2-b50b-8f7050c26c32` and + `7f4b37a4-b970-46ea-bc76-b4a60aeeab59` pass its focused validation and the + root Buildifier check; `git diff --check` also passes. +- Guarded consolidation and rebase produced one candidate commit on fetched + base `63e7b9f0be1e054373415914ff3d2ea2282aa3da`. The adapter now stages + deletions and symlink-to-directory changes and permits a rebased path to + vanish only when the old candidate and new base have the exact same Git tree + entry. +- Exact code-candidate Bazel invocations + `13588b19-1f8a-43a7-8006-f9d5d4652670` and + `5863ac24-3bb2-47be-99fb-50141e933018` passed all 12 affected tests and all + 29 affected builds, including Buildifier and discovery-link validation. + Live launcher initialization listed all ten gateway tools while concurrent + query invocation `4637f187-d05e-4905-8153-18fca8644ea1` passed. +- Verdict: the focused race, recovery, layout, repeated-run, project-wide, + consolidation, and exact code-candidate evidence pass. The final + adapter-and-record rewrite, remote publication, hosted review + reconciliation, and final + receipt verification remain open. + +## Attempt 11 final review evidence + +- Guarded publication and receipt verification established a clean, + single-commit feature branch on base + `63e7b9f0be1e054373415914ff3d2ea2282aa3da`, with the local and remote tree + identical and PR 32 synchronized. +- The exact-head hosted review completed against the published aggregate and + reported three actionable findings: lexical symlink reopening, unbounded + `/proc` inspection retries, and a 100 ms process-start assumption. +- Repository reads now open the canonical target with `O_NOFOLLOW`, validate + the opened descriptor through `/proc/self/fd`, inspect and read through that + same handle, and retain the existing byte bound. A regression proves an + internal symlink read never reopens the lexical alias through `ctx.readText`. +- Process-group verification now skips inaccessible per-PID entries and turns + three consecutive inspection failures into `EXEC_CLEANUP`; a direct + regression proves the retry bound. The timeout cleanup fixture now allows a + five-second startup window before exercising forced group cleanup. +- Bazel invocation `d32cccf7-b5b9-427c-93a1-6b612e32a0aa` passed all seven MCP + tests. Invocation `9d11ce11-1f79-4077-b61a-efa95a5fc3de` built all 16 MCP + targets. Invocation `aadcc7f6-e2b3-4480-8b76-0607b2017415` passed three runs + each of the process-supervisor and repository-context regression targets. +- Final publication, exact-head review completion, thread reconciliation, and + receipt verification are performed by the guarded delivery workflow; the + ignored receipt is the authoritative mutable delivery record. +- Follow-up exact-head review identified lexical reopening in `git_worktree`. + Repository selection and discovered-root use now retain verified directory + handles for the complete tool call, and all Git `-C` arguments address those + handles through `/proc//fd`. The focused command-contract test and real + Cordis starter-package integration pass together in Bazel invocation + `4c3eba8d-32c9-47e9-a257-f8af92ef0c19`; invocation + `6c638120-90cb-446c-b76e-6d39df3640e5` repeats both targets three times. + Invocations `ef83ec38-84ed-466e-abbd-ffd6cef892c1` and + `16e0c322-adc7-4763-8ff4-11d5f38a4172` pass the complete seven-test and + sixteen-target MCP packets. +- The next exact-head pass identified the same lexical reopening in + `repo_context`'s ripgrep and Git metadata branches. Selected search paths and + repository directories now remain open while subprocesses address them + through `/proc//fd`; reported ripgrep paths are mapped back to stable + workspace-relative names. Directory kind and entry inspection also use the + selected handle. Bazel invocation + `a3f4ed56-a44b-46d6-8717-c38ecc7f05eb` passes the focused context contract + and real ripgrep/Git starter integration together. Invocation + `e7af6e8f-823e-40ef-a833-dcb0a704f46f` repeats both three times, while + invocations `9fd4d465-957a-424f-9bb8-f6e10eb93009` and + `4a464ddd-f646-4b9a-aaab-a411bf930ee0` pass the complete seven-test and + sixteen-target MCP packets. +- The terminal exact-head review found that the JavaScript regex fallback + could monopolize the MCP event loop through backtracking and could not match + ripgrep's Unicode regex semantics. Regex search now requires ripgrep when + the executable is unavailable, while the bounded fixed-string fallback is + preserved. Bazel invocation `b98534c0-1667-462b-81d5-ee393fca343b` + passes the focused context and real starter integration targets; + invocations `4689e7cb-9600-4b35-ad7a-97e02cd6730c` and + `557f322c-4fad-4358-bf5f-98ad9b8972b0` pass the complete seven-test and + sixteen-target MCP packets. +- The next exact-head pass found that invalid UTF-8 replacement decoding + changed fixed-search byte offsets when ripgrep was unavailable. The fallback + now verifies that decoded text round-trips to the original bytes and fails + closed otherwise. Bazel invocation + `449ea2c4-4d52-44d2-b7d1-b1e9745359bd` passes the focused context and real + starter integration targets; invocations + `77bae252-af20-4242-89d8-7d6bc242b64f` and + `3a0bd136-a327-416a-8128-aaaec171bd9c` pass the complete seven-test and + sixteen-target MCP packets. diff --git a/projects/mcp_cordis/goals/runtime_extensions/failure_ledger.md b/projects/mcp_cordis/goals/runtime_extensions/failure_ledger.md new file mode 100644 index 00000000..8fb08080 --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/failure_ledger.md @@ -0,0 +1,388 @@ +# Failure ledger + +[Back to durable goal](./) + +## Attempt 8 hosted review finds starter fallback and encoding gaps + +- Candidate: published standard-Cordis commit `deaa2dcd`. +- Result: hosted review found three valid defects after the earlier local + whole-diff review had accepted the runtime architecture. +- Causes: the no-ripgrep fallback recursively traversed ignored and hidden + files, JavaScript string indexes were exposed as byte offsets, and a bounded + textual HTTP body decoded an incomplete trailing UTF-8 sequence. +- Strategy delta: fail closed for directory fallback, retain explicit-file + fallback with true UTF-8 offsets, and decode only a complete UTF-8 prefix. +- Regression guard: explicit hidden-file versus directory cases, fixed and + regex non-ASCII offsets, and a one-byte multibyte HTTP preview must pass with + the complete MCP test/build packet. +- Follow-up causes: lowercasing a line could change UTF-16 length before offset + projection, fallback glob filtering contradicted ripgrep's explicit-path + precedence, and unfiltered listing did not preserve a healthy scope after + partial startup. +- Follow-up guard: length-changing `İx`, explicit file plus excluding glob, and + malformed-project/healthy-scratch listing cases pass with the complete MCP + packet. +- Final-review causes: config parsing alone did not identify a scope whose + Include never mounted, and bounded reads reused the requested endpoint after + clipping content. +- Fresh-scrutiny cause: idempotent initialization returned an empty error list + after partial startup because only the first call retained local failures. +- Additional fresh-scrutiny cause: UTF-8 suffix trimming did not distinguish a + byte-cap split from a naturally completed malformed response body. +- Final guard: missing-module and malformed-config scopes are both skipped by + unfiltered listing, clipped reads report retained endpoints, and repeated + initialization preserves structured scope errors. Textual bodies trim an + incomplete suffix only when locally truncated. `repo-delivery` now makes this + correctness scrutiny an explicit post-edit gate. + +## Exact aggregate runtime evidence was timing-sensitive + +- Candidate: post-review Attempt 8 aggregate validation. +- Result: the complete packet failed only when system load delayed a + wall-clock `<400 ms` assertion and allowed an expected 300 ms rejection to + occur before its assertion was attached. +- Cause: the test inferred synchronous admission from elapsed time and created + a temporary unhandled-rejection window while waiting for a PID file. +- Strategy delta: assert the wrapped synchronous-admission error semantics and + attach the expected rejection before awaiting the fixture handshake. +- Regression guard: the complete runtime target passes three consecutive + concurrent runs without loosening production deadlines. + +## Failed-import rollback waited for an event Cordis does not emit + +- Candidate: loaded exact aggregate validation after the evidence correction. +- Result: a failed source update occasionally reported + `reload_rollback_failed` even though Cordis had already restored the prior + module cache. +- Cause: failed HMR import restores its cache and returns without emitting + `hmr/reload`; the host always required a second reload event after restoring + prior bytes. +- Strategy delta: after restoring bytes, accept the exact prior cache marker + immediately; retain correlated HMR waiting when the failed candidate marker + actually reached the cache. +- Regression guard: three concurrent runtime runs prove failed-update rollback, + source restoration, and subsequent healthy invocation. + +## Attempt 1, check 1: npm repository analysis + +- Command: `bazel_agent query //projects/mcp_cordis:all` +- Result: failed before target analysis. +- Cause: rules_js requires pnpm v10 workspaces to declare + `onlyBuiltDependencies`, including when lifecycle actions are disabled. +- Evidence: repository fetch failed in + `verify_lifecycle_hooks_specified` with that exact requirement. +- Strategy delta: declare an empty lifecycle allowlist in the project-owned + workspace and regenerate its exact lock before rerunning the same query. +- Regression guard: successful focused query and build of the translated npm + repository. + +## Attempt 1, check 2: package query after npm correction + +- Command: `bazel_agent query //projects/mcp_cordis:all` +- Result: npm translation succeeded; target loading stopped because the + deliberately non-empty starter-package glob had not yet been populated. +- Cause: implementation was queried while the parallel starter-package draft + was still outstanding. +- Strategy delta: retain the non-empty invariant and add the three accepted + packages before rerunning the same query. +- Regression guard: the `starter_packages` target must contain real files and + the focused query must succeed. + +## Attempt 1, check 3: runtime test process did not terminate + +- Command: `bazel_agent test //projects/mcp_cordis:runtime_test` +- Result: both test bodies completed in under 400 ms, one failed, but a worker + retained by the failing test kept the process alive until the run was + interrupted at 144 seconds. +- Cause: the test registered cleanup only along its success path, so the first + assertion failure leaked its runtime and obscured the underlying defect. +- Strategy delta: register unconditional `node:test` teardown before the first + assertion, then rerun to expose the real behavioral failure promptly. +- Regression guard: the target must terminate normally on both passing and + failing assertions. + +## Attempt 1, check 4: activation error assertion mismatch + +- Command: focused runtime test after unconditional teardown. +- Result: target terminated in 0.8 seconds; the MCP gateway test passed and + the lifecycle test stopped at its syntax-rollback assertion. +- Cause: the runtime correctly exposed `activation_failed` as the error's + machine-readable `code`, while the test searched only its human message. +- Strategy delta: assert the stable code and separately check the underlying + syntax diagnostic. +- Regression guard: failed activation retains the working v2 generation and + reports both the stable wrapper code and candidate cause. + +## Starter search depends on undeclared ripgrep (occurrence 1) + +- Command: `bazel_agent test + //projects/mcp_cordis:starter_packages_test`. +- Result: failed in 0.4 seconds with `spawn rg ENOENT`. +- Cause: `repo_context_search` invoked the preferred ripgrep engine without a + fallback, while the Bazel test PATH intentionally does not expose the host + installation. +- Attempted strategy: none before this measurement. +- Strategy delta: Attempt 2 adds a bounded in-process fallback while retaining + ripgrep when available. +- Regression guard: the unchanged hermetic starter test must exercise a + successful search and all remaining package handlers. +- Latest result: resolved in Attempt 2. The unchanged hermetic test passes with + the new bounded JavaScript fallback and executes all eight tools. + +## Lifecycle drain test uses an elapsed-time race (occurrence 1) + +- Command: `bazel_agent test //projects/mcp_cordis:all` on rebased commit + `e3e74cb1e573867825347292bf17220a5b9a4a0c`. +- Result: three test targets pass; `runtime_test` reports actual drain count + `0`, expected `1`. +- Cause: the test delays the old request for 150 ms but must start and validate + a replacement worker before retirement. Nothing guarantees the swap occurs + before the fixed request delay expires. +- Attempted strategy: a 20 ms sleep before starting replacement; this proves + that the old request started, not that it remains active at the later swap. +- Strategy delta: Attempt 3 uses an explicit started marker and release latch. +- Regression guard: run the focused lifecycle test and complete suite with no + wall-clock assumption controlling the drain-count assertion. + +## BUILD data labels not in Buildifier order (occurrence 1) + +- Command: `bazel_agent test //:buildifier_test` during Attempt 3. +- Result: failed with an exact three-line ordering diff in + `projects/mcp_cordis/BUILD.bazel`. +- Cause: the external-style `:node_modules/...` label followed the two shorter + local labels in `runtime_test.data`. +- Strategy delta: apply Buildifier's exact lexical ordering and rerun the same + repository formatter target. +- Regression guard: `//:buildifier_test` must pass on the frozen candidate. +- Latest result: resolved. The forced Buildifier test passes on commit + `7cfef071`. + +## Remote publication lacks explicit authorization (occurrence 1) + +- Intended command: repository delivery `publish` using the exact preparation + receipt and validated head `7cfef071`. +- Result: the approval gate rejected execution before any push or pull request + mutation. +- Cause: the latest user instruction explicitly requested a rebase and did not + authorize the separate consequential remote publication operation. +- Safe attempts: preparation, exact-tree validation, and local commit are + complete; no workaround or indirect mutation was attempted. +- Exact unblocker: explicit user authorization to push this branch and create + or update its pull request. +- Latest result: resolved. The user explicitly requested push and continued + goal execution; PR 32 was published at `7cfef071` and remains the authorized + delivery vehicle for the corrected candidate. + +## Published candidate has three valid review defects (occurrence 1) + +- Candidate: PR 32 commit `7cfef0719075ad372c3bb257ad216b35770356b2`. +- Result: automated review found three independently reproducible defects. +- Causes: output overflow rejects instead of returning bounded data with a + truncation marker; branch records bypass `max_changes`; shutdown snapshots + active workers before an admitted activation finishes. +- Strategy delta: Attempt 4 changes each controlling mechanism and adds a + black-box regression for each, rather than suppressing or merely replying to + the review. +- Regression guard: republish only when all focused tests, the full MCP Cordis + package, imported skill validation, and Buildifier pass on one exact commit. +- Latest result: resolved in the Attempt 4 working tree. Four focused tests, + all ten integrated tests, the complete package build, three skill-validation + aspects, and root Buildifier pass. Exact-commit rerun remains required. + +## Attempt 4 independent review rejects completeness claims (occurrence 1) + +- Candidate: uncommitted Attempt 4 working tree after all recorded tests + passed. +- Result: four directly related semantic and evidence gaps survived. +- Causes: invalid UTF-8 loss did not set `truncated`; several starter fields + ignored host truncation; Git status inferred truncation from result length + instead of actual omission and used newline-unsafe path regexes; imported + skill eval cases did not exercise their new contracts. +- Strategy delta: Attempt 5 makes loss explicit, propagates truncation by + field, parses one-record lookahead semantics, adds newline/exact-limit tests, + and expands offline eval cases before another integrated run. +- Regression guard: independent review must find no correctness issue before + delivery preparation; a green test suite alone is insufficient. + +## Unavailable worker teardown escapes shutdown tracking (occurrence 1) + +- Evidence: independent shutdown review of Attempt 4. +- Cause: `#handleUnavailable()` removes the activation from `#active`, while + its worker termination is asynchronous and was not added to `#retirements`. +- Strategy delta: track the activation's idempotent `dispose()` promise as a + retirement at the same moment it is removed. +- Regression guard: a deterministic unavailable-then-shutdown scenario must + prove shutdown does not finish before the teardown promise. + +## Main references an absent decision-review skill (occurrence 1) + +- Evidence: current base `7ad2704c` mentions `decision-review` in `AGENTS.md`, + but the referenced package is absent from that tree. +- Cause: the skill exists in open PR 24, not in the rebased `master` commit. +- Strategy delta: import only PR 24's four `projects/agents` changes, merged + against current guidance, and supply validation assets required by current + repository policy. +- Regression guard: build and validate the imported skill through its Bazel + `skill_library` and offline Promptfoo target. +- Latest result: resolved in the working tree. `decision-review` matches PR + 24's instruction blob and passes quick validation, offline Promptfoo loading, + and the repository skill-validation aspect. + +## Attempt 5 independent review rejects lifecycle and compatibility + +- Candidate: green uncommitted Attempt 5 working tree. +- Result: focused tests passed 8/8, integrated tests passed 12/12, the affected + build and skill aspects passed, and Buildifier passed; independent review + nevertheless found release-blocking defects. +- Causes: immediate `Worker.terminate()` can bypass detached-child cleanup; + already-admitted handlers can spawn after one-shot disposal cleanup; + promotion is outside the shutdown lock snapshot; an unavailable candidate's + one-shot notification can be ignored during persistence; and always-success + output truncation silently changes retained package-version semantics. +- Additional gaps: command signals/failures, exact-bound search and omitted + records, Git optional writes/history framing, LF hash portability, weak + integration assertions, and contradictory or domain-specific imported-skill + rules. +- Strategy delta: Attempt 6 versions partial-output behavior explicitly, + closes process and runtime admission, creates sole final package candidates, + and binds every completeness claim to a direct regression. +- Regression guard: no delivery preparation until a fresh review accepts the + new candidate after all invalidated gates pass. + +## Attempt 6 first final review rejects worker-side process ownership + +- Candidate: green Attempt 6 working tree before parent-owned supervision. +- Result: focused tests, the 14/14 integrated packet, build, skill validation, + and Buildifier passed; independent reviews still rejected release. +- Causes: outer timeouts settled before cleanup; signaling a process group was + called reaping; the worker's native-spawn/PID-publication window could not be + both bounded and orphan-safe; direct exit could leave inherited pipes open; + shutdown awaited but did not dispose retired generations; ripgrep byte fields + could be returned as empty complete text; and durable records were stale. +- Strategy delta: the parent activation now owns process spawning and group + cleanup, runtime tracks retired activations rather than only their promises, + byte fields set explicit truncation, and the durable goal records every + verdict and invalidated gate. +- Regression guard: immediate non-liveness assertions, inherited-pipe cleanup, + fatal cleanup ordering, retired-generation shutdown, byte-field cases, and a + fresh adversarial review must pass before final integrated validation. + +## Custom manifests and hash versions are the wrong extension model + +- Candidate: published PR 32 checkpoint `7cfef071` and its Attempt 6 + descendants. +- Result: the user rejected the per-package `manifest.json`, immutable + `versions/`, and active-pointer design as nonstandard and temporary-looking. +- Cause: the runtime had grown a second package manager instead of using + Cordis Loader entries, Include-backed configuration, HMR, and Git history. +- Strategy delta: Attempt 7 deletes the custom store and workers, pins the + official services, and uses `cordis.yaml` plus ordinary ESM modules. +- Regression guard: no package manifest, hash-named source snapshot, custom + storage layer, or version-file `.gitattributes` rule may remain. + +## In-process standard Cordis needs reliability admission guards + +- Candidate: independently reviewed Attempt 7 working tree. +- Result: review found that accidental stdout writes could corrupt stdio, + Promise/async-iterator activation or top-level await could wedge lifecycle + mutation, filename-only HMR events could acknowledge the wrong write, and + response timeout could leave invocation-owned children running. +- Strategy delta: reserve a private protocol stream, use Node's ESM parser to + reject top-level await, require synchronous object activation, correlate + managed source with a named-export token, and use an invocation-scoped + supervisor that cancels and joins `ctx.exec()`. +- Regression guard: real stdio logging, hung activation, source round-trip, + invalid/expired invocation, and descendant non-liveness tests all pass. + +## Official HMR requires Node internal-module access + +- Command: first Attempt 7 `runtime_test` initialization. +- Result: HMR rejected startup with + `--expose-internals is required for HMR service`. +- Cause: the optional native fallback peer was not reliably visible through + Bazel's strict pnpm layout. +- Strategy delta: every runtime-bearing Bazel launcher passes the official + package's supported `--expose-internals` Node flag. Automatic peer install is + disabled because all required peers are pinned explicitly and the optional + native fallback is unnecessary. +- Regression guard: the complete project build and all runtime tests must boot + through the Bazel launchers. + +## Root Fiber uid zero skips watcher disposal + +- Command: runtime and starter tests without Node's force-exit option. +- Result: both test bodies passed but timed out with live `FSEventWrap` + resources after `runtime.shutdown()`. +- Cause: shutdown guarded `root.fiber.dispose()` with `root.fiber.uid`; + Cordis assigns the root Fiber uid `0`, so the truthiness check skipped every + root-owned cleanup effect, including HMR watchers. +- Strategy delta: dispose whenever the root Fiber exists, independent of its + numeric uid. Remove force-exit workarounds from tests and keep stdio shutdown + graceful. +- Regression guard: runtime, starter, and real stdio tests must exit normally + without `--test-force-exit` or `process.exit()`. + +## Review completion is not thread reconciliation + +- Candidate: published commit `cfab0fb5` after a completed hosted review. +- Result: the review summary completed, but GraphQL thread inspection exposed + one unresolved older regex-parity finding and one new annotation finding. +- Cause: regex fallback omitted JavaScript Unicode mode and matched surrogate + halves; source-overwriting MCP tools declared `destructiveHint: false`. +- Strategy delta: enable Unicode scalar matching, mark both potentially + overwriting tools destructive, and treat the review-thread graph—not the + summary state—as the authoritative review ledger. +- Regression guard: an astral `.` fallback match must be one UTF-8 span, and + real stdio tool discovery must expose both destructive annotations. + +## Fallback event and empty-range boundaries diverge + +- Candidate: published review correction `bc4e5ae9`. +- Result: the next hosted review found duplicate, out-of-order fallback context + around adjacent matches and a `null` endpoint for an existing empty line. +- Cause: context filtering knew only the current matching line, while bounded + reads inferred range existence from non-empty selected text. +- Strategy delta: precompute match classifications, emit the union of match and + context lines once in source order, and track whether the requested range + exists separately from its content. +- Regression guard: adjacent matches followed by context must emit + match/match/context in line order; a selected empty line reports its line + number while a start past EOF reports `null`. + +## Delivery adapter refuses the multi-commit feature range + +- Candidate: local Attempt 9 changes above published head `bc4e5ae9`, with + fetched base `d29f9d47`. +- Result: read-only delivery inspection reports nine feature commits and + refuses preparation; version 1 will not infer consolidation ownership. +- Cause: only the first commit carries the adapter's ownership disclaimer, + while the adapter supports preparation of at most one feature commit. +- Rejected workaround: direct rebase, reset, cherry-pick, or a replacement + branch would bypass the GitHub adapter's explicit safety refusal. +- Required strategy delta: obtain scope to add a guarded exact-head, + merge-base-aware consolidation path to `repo_delivery`, or have that support + land separately before resuming the rebase. +- Resolution: the user explicitly authorized a guarded adapter extension. + `repo_delivery prepare --consolidate ` now verifies a + merge-free linear range, identical author and committer identities, the + oldest commit's ownership marker, unchanged pull-request projection, + signature preservation, and every unrelated refusal before replacing the + range. Its integration test also proves the prior remote tip remains the + receipt-bound publication lease. + +## Cordis HMR loses a write during an in-flight reload + +- Candidate: Attempt 9 with unmodified + `@deepseek-ai/cordis-plugin-hmr` 1.0.16. +- Result: a deterministic slow top-level-await replacement followed by a + second write left the latest source on disk but the first replacement live. +- Cause: debounced `partialReload()` work was not serialized; each successful + run reset one shared stash even when a later change arrived during import. +- Rejected wrapper workaround: public HMR emits neither import-failure nor + settled-activation events, so a wrapper gate would either reopen unsafely on + a timeout or wedge permanently after failure. +- Strategy delta: Attempt 10 uses standard pnpm patching to serialize the + owning HMR task, snapshot each change set, and drain changes observed while + it runs. +- Regression guard: overlapping writes during both slow module evaluation and + slow asynchronous activation must converge to the latest persisted source. diff --git a/projects/mcp_cordis/goals/runtime_extensions/requirements.md b/projects/mcp_cordis/goals/runtime_extensions/requirements.md new file mode 100644 index 00000000..730da2f9 --- /dev/null +++ b/projects/mcp_cordis/goals/runtime_extensions/requirements.md @@ -0,0 +1,76 @@ +# Requirements and constraints + +[Back to durable goal](./) + +## User requirements + +- The project is named `mcp_cordis` and lives under `projects/`. +- Reuse Cordis itself rather than reimplementing its lifecycle architecture. +- Keep reusable runtime code under the project for later reuse. +- Keep disposable runtime code under `out`. +- Seed the project with packages based on recurring past-session needs. +- Security hardening is not the priority on this dedicated LLM machine. +- Codex must load the MCP from project-scoped configuration so different + clones and linked worktrees use their own source and disposable state. + +## Repository constraints + +- Use `bazel_agent` for every Bazel command. +- Pin external dependencies reproducibly and retain required notices. +- Keep disposable task scratch under `out/mcp_cordis`; maintain this explicitly + requested reusable goal under `projects/mcp_cordis/goals`. +- Preserve unrelated working-tree changes. +- Prefer a narrowly scoped project and focused validation. + +## Assumptions + +- "Based on past sessions" means extracting generic recurring workflows, not + copying private conversation text, credentials, or secret-bearing data. +- Host-only JavaScript packages are the initial scope; browser UI packages are + not required for the first working server. +- A stable gateway invocation tool is required because MCP clients differ in + when they consume tool-list change notifications. + +## Requirement changes + +- 2026-08-30: project name changed from `agent_extension_host` to + `codex_cordis`, then finally to the client-neutral `mcp_cordis`. +- 2026-08-30: storage clarified as two-tier: reusable project packages and + disposable `out` packages. +- 2026-08-30: the user promoted the runtime-extension goal directory from + disposable `out` coordination to durable project documentation for future + reuse. +- 2026-08-30: remote `master` advanced and the user explicitly requested a + rebase before implementation continued. The task commit was rebased from + `775f44d3b56146005e44980f3cf948785f963ba0` onto + `7ad2704cd27757355ab36ec8eb1bb27ef9e1d91d`; all prior checks were treated as + invalid until rerun. +- 2026-08-30: after publication, the user explicitly expanded the delivery to + include only the `projects/agents` subtree changes from PR 24. PR 24 changes + four files there: `bazel-agent`, `goal`, and the new `decision-review` + package. Import those changes three-way against current `master`; do not + import its unrelated render or infrastructure content. +- 2026-08-30: the user rejected the custom per-package manifests and requested + standard solutions. Runtime persistence must use official Cordis loader + entries and `cordis.yaml`; normal reusable files and Git replace committed + content-addressed source history. +- 2026-08-30: after review-driven fixes exposed additional correctness gaps, + the user required `repo-delivery` to invalidate prior correctness verdicts + after code changes and require fresh diff-focused scrutiny beyond green + tests. +- 2026-08-30: the user rejected the HMR race-handling complexity and explicitly + required the simplest robust MCP wrapper for loading DeepSeek/Cordis plugins. + Source-tool success therefore means validated atomic persistence and an + official Cordis load/reload request, not a custom synchronous activation + transaction or on-disk rollback protocol. +- 2026-08-30: the user required per-workspace Codex loading. The checked-in + `.codex/config.toml` must resolve the active linked worktree; the launcher + must release Bazel's output-base lock before the MCP begins serving stdio. +- 2026-08-30: remote `master` advanced again to + `d29f9d471ea467e8dfc75db4eedeedbbae43dc2d`. The user requested another + rebase and incorporation review. Preserve the new `projects/goal` redesign + and do not resurrect its deleted predecessor under `projects/agents`. +- 2026-08-30: after the delivery adapter refused the exact nine-commit task + range, the user explicitly authorized extending `repo_delivery` with a + guarded exact-head consolidation operation and then repeated the request to + rebase, review, and incorporate the upstream changes. diff --git a/projects/mcp_cordis/include.MODULE.bazel b/projects/mcp_cordis/include.MODULE.bazel new file mode 100644 index 00000000..06c3741c --- /dev/null +++ b/projects/mcp_cordis/include.MODULE.bazel @@ -0,0 +1,12 @@ +npm = use_extension("@aspect_rules_js//npm:extensions.bzl", "npm") +npm.npm_translate_lock( + name = "com_alwaldend_src_projects_mcp_cordis_npm", + data = [ + "//projects/mcp_cordis:patches/hmr@1.0.16.patch", + "//projects/mcp_cordis:pnpm-workspace.yaml", + ], + pnpm_lock = "//projects/mcp_cordis:pnpm-lock.yaml", + run_lifecycle_hooks = False, + verify_node_modules_ignored = "//:.bazelignore", +) +use_repo(npm, "com_alwaldend_src_projects_mcp_cordis_npm") diff --git a/projects/mcp_cordis/internal/mcp.mjs b/projects/mcp_cordis/internal/mcp.mjs new file mode 100644 index 00000000..b6eee3e1 --- /dev/null +++ b/projects/mcp_cordis/internal/mcp.mjs @@ -0,0 +1,249 @@ +import { McpServer } from "@modelcontextprotocol/server"; +import { z } from "zod"; + +const nameSchema = z + .string() + .regex(/^[a-z][a-z0-9_]{0,63}$/); +const scopeSchema = z.enum(["project", "scratch"]); +const argumentsSchema = z.record(z.string(), z.unknown()); + +function toolResult(value) { + return { + content: [{ type: "text", text: JSON.stringify(value, null, 2) }], + structuredContent: value, + }; +} + +function toolError(error) { + const value = { + error: { + code: error?.code ?? "runtime_error", + message: error instanceof Error ? error.message : String(error), + ...(error?.details === undefined + ? {} + : { details: error.details }), + }, + }; + return { ...toolResult(value), isError: true }; +} + +function handler(callback) { + return async (input) => { + try { + return toolResult(await callback(input)); + } catch (error) { + return toolError(error); + } + }; +} + +export function createMcpServer(runtime) { + const server = new McpServer( + { name: "mcp-cordis", version: "0.1.0" }, + { + instructions: [ + "Use cordis_list_tools to discover live package handlers.", + "Call them through cordis_invoke immediately; no MCP reload", + "or client restart is needed. Every package identity includes", + "an explicit project or scratch scope.", + ].join(" "), + }, + ); + registerMcpTools(server, runtime); + return server; +} + +export function registerMcpTools(server, runtime) { + server.registerTool( + "cordis_list", + { + description: "List standard Cordis entries and their live state.", + inputSchema: z.object({ + scope: scopeSchema.optional(), + }), + annotations: { readOnlyHint: true }, + }, + handler(({ scope }) => runtime.listPackages({ scope })), + ); + + server.registerTool( + "cordis_inspect", + { + description: "Inspect one scoped Cordis entry and normal module.", + inputSchema: z.object({ + scope: scopeSchema, + name: nameSchema, + include_source: z.boolean().default(false), + }), + annotations: { readOnlyHint: true }, + }, + handler(({ scope, name, include_source: includeSource }) => { + return runtime.inspect({ scope, name, includeSource }); + }), + ); + + server.registerTool( + "cordis_define", + { + description: [ + "Atomically persist a normal ESM Cordis plugin module.", + "A newly created active entry loads through Cordis Include", + "before this call returns. Re-enabling a stored entry first", + "refreshes its cached module. Updates to a running entry", + "return activation=pending during the live swap.", + ].join(" "), + inputSchema: z.object({ + scope: scopeSchema.default("scratch"), + name: nameSchema, + source: z.string().min(1).max(2_000_256), + activate: z.boolean().optional(), + }), + annotations: { destructiveHint: true }, + }, + handler((input) => runtime.define(input)), + ); + + server.registerTool( + "cordis_run", + { + description: [ + "Enable a stored Cordis entry after refreshing any cached", + "module through Cordis HMR. Activation finishes before this", + "call returns.", + ].join(" "), + inputSchema: z.object({ + scope: scopeSchema, + name: nameSchema, + }), + annotations: { destructiveHint: false }, + }, + handler((input) => runtime.run(input)), + ); + + server.registerTool( + "cordis_reload", + { + description: [ + "Atomically rewrite the entry's current module bytes so", + "official Cordis HMR performs an eventual live reload.", + ].join(" "), + inputSchema: z.object({ + scope: scopeSchema, + name: nameSchema, + }), + annotations: { destructiveHint: false }, + }, + handler((input) => runtime.reload(input)), + ); + + server.registerTool( + "cordis_list_tools", + { + description: [ + "List handlers currently registered by live Cordis package", + "Fibers. Use their scope/package/name with cordis_invoke.", + ].join(" "), + inputSchema: z.object({ + scope: scopeSchema.optional(), + package: nameSchema.optional(), + }), + annotations: { readOnlyHint: true }, + }, + handler(({ scope, package: packageName }) => { + return runtime.listTools({ scope, packageName }); + }), + ); + + server.registerTool( + "cordis_invoke", + { + description: [ + "Invoke a live package handler over this same MCP connection.", + "A catalog version can guard against stale discoveries.", + ].join(" "), + inputSchema: z.object({ + scope: scopeSchema, + package: nameSchema, + tool: nameSchema, + arguments: argumentsSchema.default({}), + timeout_ms: z.number().int().min(1).max(300_000).optional(), + catalog_version: z.number().int().min(1).optional(), + }), + }, + handler(({ + scope, + package: packageName, + tool, + arguments: args, + timeout_ms: timeoutMs, + catalog_version: catalogVersion, + }) => { + return runtime.invoke({ + scope, + packageName, + tool, + arguments: args, + timeoutMs, + catalogVersion, + }); + }), + ); + + server.registerTool( + "cordis_stop", + { + description: [ + "Disable one live Cordis entry while retaining its module.", + ].join(" "), + inputSchema: z.object({ + scope: scopeSchema, + name: nameSchema, + }), + annotations: { destructiveHint: false }, + }, + handler((input) => runtime.stop(input)), + ); + + server.registerTool( + "cordis_remove", + { + description: [ + "Stop and permanently remove one scoped Cordis entry and", + "its normal module file.", + ].join(" "), + inputSchema: z.object({ + scope: scopeSchema, + name: nameSchema, + }), + annotations: { destructiveHint: true }, + }, + handler((input) => runtime.remove(input)), + ); + + server.registerTool( + "cordis_promote", + { + description: [ + "Copy a scratch Cordis module into the reusable project", + "config, optionally activating the promoted entry.", + ].join(" "), + inputSchema: z.object({ + name: nameSchema, + target_name: nameSchema.optional(), + activate: z.boolean().default(false), + }), + annotations: { destructiveHint: true }, + }, + handler(({ + name, + target_name: targetName, + activate, + }) => { + return runtime.promote({ + name, + targetName, + activate, + }); + }), + ); +} diff --git a/projects/mcp_cordis/internal/process_supervisor.mjs b/projects/mcp_cordis/internal/process_supervisor.mjs new file mode 100644 index 00000000..2113e6ea --- /dev/null +++ b/projects/mcp_cordis/internal/process_supervisor.mjs @@ -0,0 +1,324 @@ +import { spawn } from "node:child_process"; +import { readdir, readFile } from "node:fs/promises"; +import process from "node:process"; + +const PROCESS_POLL_INTERVAL_MS = 10; +const PROCESS_INSPECTION_FAILURE_LIMIT = 3; + +function codedError(code, message, ErrorType = Error) { + const error = new ErrorType(message); + error.code = code; + return error; +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function validUtf8PrefixLength(buffer) { + let index = 0; + while (index < buffer.length) { + const first = buffer[index]; + if (first <= 0x7f) { + index += 1; + continue; + } + + let width; + let secondMinimum = 0x80; + let secondMaximum = 0xbf; + if (first >= 0xc2 && first <= 0xdf) { + width = 2; + } else if (first === 0xe0) { + width = 3; + secondMinimum = 0xa0; + } else if (first >= 0xe1 && first <= 0xec) { + width = 3; + } else if (first === 0xed) { + width = 3; + secondMaximum = 0x9f; + } else if (first >= 0xee && first <= 0xef) { + width = 3; + } else if (first === 0xf0) { + width = 4; + secondMinimum = 0x90; + } else if (first >= 0xf1 && first <= 0xf3) { + width = 4; + } else if (first === 0xf4) { + width = 4; + secondMaximum = 0x8f; + } else { + return index; + } + + if (index + width > buffer.length) return index; + const second = buffer[index + 1]; + if (second < secondMinimum || second > secondMaximum) return index; + for (let offset = 2; offset < width; offset += 1) { + const continuation = buffer[index + offset]; + if (continuation < 0x80 || continuation > 0xbf) return index; + } + index += width; + } + return index; +} + +function decodeUtf8Prefix(output) { + const buffer = Buffer.concat(output.parts, output.bytes); + const length = validUtf8PrefixLength(buffer); + return { + dropped: length < buffer.length, + text: buffer.subarray(0, length).toString("utf8"), + }; +} + +function parseProcStat(stat) { + const closingParenthesis = stat.lastIndexOf(")"); + if (closingParenthesis < 0) return undefined; + const fields = stat.slice(closingParenthesis + 2).trim().split(/\s+/u); + if (fields.length < 3) return undefined; + const group = Number(fields[2]); + if (!Number.isSafeInteger(group) || group <= 0) return undefined; + return { group, state: fields[0] }; +} + +async function processGroupHasLiveMember(group) { + const entries = await readdir("/proc", { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory() || !/^\d+$/u.test(entry.name)) continue; + const stat = await readFile(`/proc/${entry.name}/stat`, "utf8") + .catch((error) => { + if (["EACCES", "ENOENT", "EPERM"].includes(error?.code)) { + return undefined; + } + throw error; + }); + if (stat === undefined) continue; + const parsed = parseProcStat(stat); + if (parsed?.group !== group) continue; + if (parsed.state !== "Z" && parsed.state !== "X") return true; + } + return false; +} + +function signalProcessGroup(child) { + if (!Number.isSafeInteger(child.pid) || child.pid <= 0) return undefined; + try { + process.kill(-child.pid, "SIGKILL"); + return undefined; + } catch (error) { + if (error?.code === "ESRCH") return undefined; + if (child.exitCode === null && child.signalCode === null) { + try { + child.kill("SIGKILL"); + } catch { + // Preserve the process-group failure as the useful error. + } + } + return error; + } +} + +export async function waitForProcessGroup( + group, + child, + inspect = processGroupHasLiveMember, +) { + if (!Number.isSafeInteger(group) || group <= 0) return; + let inspectionFailures = 0; + while (true) { + signalProcessGroup(child); + try { + if (!await inspect(group)) return; + inspectionFailures = 0; + } catch (error) { + inspectionFailures += 1; + if (inspectionFailures >= PROCESS_INSPECTION_FAILURE_LIMIT) { + return codedError( + "EXEC_CLEANUP", + `could not verify process-group cleanup: ${error.message}`, + ); + } + } + await new Promise((resolve) => { + setTimeout(resolve, PROCESS_POLL_INTERVAL_MS); + }); + } +} + +function destroyOutput(child) { + child.stdout?.destroy(); + child.stderr?.destroy(); +} + +export class ProcessSupervisor { + #accepting = true; + #active = new Set(); + + execute({ file, args, options }) { + if (!this.#accepting) { + return Promise.reject(codedError( + "EXEC_DISPOSED", + "ctx.exec is unavailable because the package is disposing", + )); + } + + let child; + try { + child = spawn(file, args, { + cwd: options.cwd, + detached: true, + env: options.env, + shell: false, + stdio: ["ignore", "pipe", "pipe"], + }); + } catch (error) { + return Promise.reject(error); + } + + const outcome = deferred(); + const done = deferred(); + const record = { + child, + done: done.promise, + exitCode: null, + exitSignal: null, + finalizeStarted: false, + forcedError: undefined, + groupStopped: undefined, + outputLimitExceeded: false, + promise: outcome.promise, + spawnError: undefined, + stderr: { bytes: 0, parts: [] }, + stdout: { bytes: 0, parts: [] }, + outputBytes: 0, + }; + this.#active.add(record); + + const waitForGroupStopped = () => { + record.groupStopped ??= waitForProcessGroup(child.pid, child); + return record.groupStopped; + }; + + const stop = (error = undefined) => { + if (error !== undefined) record.forcedError ??= error; + signalProcessGroup(child); + destroyOutput(child); + }; + record.stop = stop; + + const collect = (destination, chunk) => { + if (record.forcedError || record.outputLimitExceeded) return; + const retainedBytes = Math.min( + chunk.length, + options.maxBytes - record.outputBytes, + ); + if (retainedBytes > 0) { + destination.parts.push(Buffer.from( + chunk.subarray(0, retainedBytes), + )); + destination.bytes += retainedBytes; + record.outputBytes += retainedBytes; + } + if (retainedBytes === chunk.length) return; + + record.outputLimitExceeded = true; + if (!options.allowTruncatedOutput) { + record.forcedError = codedError( + "EXEC_OUTPUT_LIMIT", + `process output exceeded the ` + + `${options.maxBytes}-byte limit`, + RangeError, + ); + } + stop(); + }; + + const finalize = async () => { + if (record.finalizeStarted) return; + record.finalizeStarted = true; + clearTimeout(timeoutTimer); + const cleanupError = await waitForGroupStopped(); + this.#active.delete(record); + + const decodedStdout = decodeUtf8Prefix(record.stdout); + const decodedStderr = decodeUtf8Prefix(record.stderr); + const invalidUtf8 = decodedStdout.dropped || + decodedStderr.dropped; + const error = record.spawnError ?? record.forcedError ?? + cleanupError ?? + (invalidUtf8 && !options.allowTruncatedOutput + ? codedError( + "EXEC_INVALID_UTF8", + "process output was not valid UTF-8", + ) + : undefined); + + if (error !== undefined) { + outcome.reject(error); + } else { + outcome.resolve({ + code: record.exitCode, + signal: record.exitSignal, + stdout: decodedStdout.text, + stderr: decodedStderr.text, + truncated: record.outputLimitExceeded || invalidUtf8, + outputLimitExceeded: record.outputLimitExceeded, + }); + } + done.resolve(); + }; + + const timeoutTimer = setTimeout(() => { + stop(codedError( + "EXEC_TIMEOUT", + `process exceeded the ${options.timeoutMs} ms timeout`, + )); + }, options.timeoutMs); + timeoutTimer.unref(); + + child.stdout?.on("data", (chunk) => collect(record.stdout, chunk)); + child.stderr?.on("data", (chunk) => collect(record.stderr, chunk)); + const outputError = (error) => { + stop(codedError( + "EXEC_OUTPUT_ERROR", + `failed to read process output: ${error.message}`, + )); + }; + child.stdout?.on("error", outputError); + child.stderr?.on("error", outputError); + child.once("error", (error) => { + record.spawnError = error; + stop(); + }); + child.once("exit", (code, signal) => { + record.exitCode = code; + record.exitSignal = signal; + signalProcessGroup(child); + void waitForGroupStopped().then(async () => { + await new Promise((resolve) => setImmediate(resolve)); + destroyOutput(child); + }); + }); + child.once("close", (code, signal) => { + record.exitCode = code; + record.exitSignal = signal; + void finalize(); + }); + + return record.promise; + } + + async close(error) { + this.#accepting = false; + const records = [...this.#active]; + for (const record of records) record.stop(error); + await Promise.allSettled(records.map((record) => record.done)); + } +} diff --git a/projects/mcp_cordis/internal/runtime.mjs b/projects/mcp_cordis/internal/runtime.mjs new file mode 100644 index 00000000..cf726da0 --- /dev/null +++ b/projects/mcp_cordis/internal/runtime.mjs @@ -0,0 +1,1347 @@ +import { createHash, randomUUID } from "node:crypto"; +import { AsyncLocalStorage } from "node:async_hooks"; +import { + mkdir, + open, + readFile, + rename, + rm, + writeFile, +} from "node:fs/promises"; +import * as path from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { SourceTextModule } from "node:vm"; +import { Context } from "@deepseek-ai/cordis"; +import Hmr from "@deepseek-ai/cordis-plugin-hmr"; +import Include, { + entryListSchema, +} from "@deepseek-ai/cordis-plugin-include"; +import Loader from "@deepseek-ai/cordis-plugin-loader"; +import Timer from "@deepseek-ai/cordis-plugin-timer"; +import Ajv2020 from "ajv/dist/2020.js"; +import * as yaml from "js-yaml"; +import { ProcessSupervisor } from "./process_supervisor.mjs"; + +const MAX_SOURCE_BYTES = 2_000_000; +const MAX_READ_BYTES = 64 * 1024 * 1024; +const NAME_PATTERN = /^[a-z][a-z0-9_]{0,63}$/u; +const TOOL_NAME_PATTERN = NAME_PATTERN; +const LEGACY_SOURCE_MARKER_PATTERN = + /^(#![^\n]*\n)?export const __mcp_cordis_source_sha256 = "[a-f0-9]{64}";\n/u; + +export class RuntimeError extends Error { + constructor(code, message, details = undefined) { + super(message); + this.name = "RuntimeError"; + this.code = code; + this.details = details; + } +} + +function codedError(code, message, ErrorType = Error) { + const error = new ErrorType(message); + error.code = code; + return error; +} + +function plainError(error) { + return { + code: error?.code ?? "runtime_error", + message: error instanceof Error ? error.message : String(error), + ...(error?.details === undefined ? {} : { details: error.details }), + }; +} + +function deferred() { + let resolve; + let reject; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, reject, resolve }; +} + +function packageKey(scope, name) { + return `${validateScope(scope)}:${validateName(name)}`; +} + +function validateScope(scope) { + if (scope !== "project" && scope !== "scratch") { + throw new TypeError("scope must be project or scratch"); + } + return scope; +} + +function validateName(name) { + if (typeof name !== "string" || !NAME_PATTERN.test(name)) { + throw new TypeError("name must match [a-z][a-z0-9_]{0,63}"); + } + return name; +} + +function validateSource(source) { + if (typeof source !== "string" || source.length === 0) { + throw new TypeError("source must be a non-empty string"); + } + if (Buffer.byteLength(source) > MAX_SOURCE_BYTES) { + throw new RangeError( + `source exceeds the ${MAX_SOURCE_BYTES}-byte limit`, + ); + } + return source.endsWith("\n") ? source : `${source}\n`; +} + +function validateModuleSource(source, identifier = "runtime module") { + let module; + try { + module = new SourceTextModule(source, { identifier }); + } catch (error) { + throw new RuntimeError( + "invalid_module_source", + `source is not a valid ESM module: ${error.message}`, + ); + } +} + +function prepareSource(source) { + if (typeof source !== "string" || source.length === 0) { + throw new TypeError("source must be a non-empty string"); + } + const legacyMarker = source.match(LEGACY_SOURCE_MARKER_PATTERN); + if (legacyMarker) { + source = `${legacyMarker[1] ?? ""}` + + source.slice(legacyMarker[0].length); + } + source = validateSource(source); + validateModuleSource(source); + return source; +} + +function positiveInteger(value, fallback, label, maximum = Infinity) { + if (value === undefined) return fallback; + if (!Number.isSafeInteger(value) || value <= 0 || value > maximum) { + throw new TypeError(`${label} must be a positive integer`); + } + return value; +} + +function resolveInsideWorkspace( + workspaceRoot, + relativePath = ".", + { allowAbsolute = false } = {}, +) { + if (typeof relativePath !== "string" || relativePath.includes("\0")) { + throw new TypeError( + "workspace path must be a string without NUL bytes", + ); + } + if (path.isAbsolute(relativePath) && !allowAbsolute) { + throw new TypeError("workspace path must be relative"); + } + + const resolved = path.resolve(workspaceRoot, relativePath); + const relative = path.relative(workspaceRoot, resolved); + if ( + relative === ".." || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new RangeError("workspace path escapes the workspace root"); + } + return resolved; +} + +function isInside(root, candidate) { + const relative = path.relative(root, candidate); + return relative === "" || ( + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +async function readTextBounded(filename, maxBytes) { + const handle = await open(filename, "r"); + try { + const buffer = Buffer.allocUnsafe(maxBytes + 1); + let offset = 0; + while (offset < buffer.length) { + const { bytesRead } = await handle.read( + buffer, + offset, + buffer.length - offset, + offset, + ); + if (!bytesRead) break; + offset += bytesRead; + } + if (offset > maxBytes) { + throw new RangeError( + `file exceeds the ${maxBytes}-byte read limit`, + ); + } + return buffer.subarray(0, offset).toString("utf8"); + } finally { + await handle.close(); + } +} + +function jsonClone(value, label) { + if (value === undefined) return null; + let encoded; + try { + encoded = JSON.stringify(value, (_key, part) => { + const type = typeof part; + if ( + type === "bigint" || + type === "function" || + type === "symbol" || + type === "undefined" + ) { + throw new TypeError(`${label} is not JSON-serializable`); + } + return part; + }); + } catch (error) { + throw new TypeError( + `${label} is not JSON-serializable: ${error.message}`, + { cause: error }, + ); + } + if (encoded === undefined) { + throw new TypeError(`${label} is not JSON-serializable`); + } + return JSON.parse(encoded); +} + +function normalizeArguments(args) { + if (args === undefined || args === null) return {}; + if (typeof args !== "object" || Array.isArray(args)) { + throw new TypeError("tool arguments must be an object"); + } + return args; +} + +function formatValidationErrors(errors) { + return (errors ?? []).map((error) => { + const location = error.instancePath || "/"; + return `${location} ${error.message ?? "is invalid"}`; + }).join("; "); +} + +function normalizeToolDefinition(definition, handler, ajv) { + if ( + !definition || + typeof definition !== "object" || + Array.isArray(definition) + ) { + throw new TypeError("tool definition must be an object"); + } + if (typeof handler !== "function") { + throw new TypeError("tool handler must be a function"); + } + const { name } = definition; + if (typeof name !== "string" || !TOOL_NAME_PATTERN.test(name)) { + throw new TypeError( + "tool definition.name must match [a-z][a-z0-9_]{0,63}", + ); + } + if ( + definition.description !== undefined && + typeof definition.description !== "string" + ) { + throw new TypeError("tool definition.description must be a string"); + } + if ( + !definition.inputSchema || + typeof definition.inputSchema !== "object" || + Array.isArray(definition.inputSchema) + ) { + throw new TypeError("tool definition.inputSchema must be an object"); + } + + const inputSchema = jsonClone( + definition.inputSchema, + `input schema for ${name}`, + ); + let validate; + try { + validate = ajv.compile(inputSchema); + } catch (error) { + throw new TypeError( + `invalid JSON Schema for tool ${name}: ${error.message}`, + { cause: error }, + ); + } + return { + handler, + validate, + metadata: { + name, + description: definition.description ?? "", + inputSchema, + }, + }; +} + +async function atomicWrite(filename, content) { + await mkdir(path.dirname(filename), { recursive: true }); + const temporary = path.join( + path.dirname(filename), + `.${path.basename(filename)}.${process.pid}.${randomUUID()}.tmp`, + ); + try { + await writeFile(temporary, content, { + encoding: "utf8", + flag: "wx", + mode: 0o644, + }); + await rename(temporary, filename); + } catch (error) { + await rm(temporary, { force: true }).catch(() => {}); + throw error; + } +} + +async function readMaybe(filename) { + try { + return await readFile(filename, "utf8"); + } catch (error) { + if (error?.code === "ENOENT") return undefined; + throw error; + } +} + +function hashSource(source) { + return createHash("sha256").update(source).digest("hex"); +} + +function managedSpecifier(name) { + return `./plugins/${validateName(name)}.mjs`; +} + +function parseEntries(content, filename) { + let value; + try { + value = yaml.load(content, { schema: entryListSchema }); + } catch (error) { + throw new RuntimeError( + "invalid_cordis_config", + `failed to parse ${filename}: ${error.message}`, + ); + } + if (!Array.isArray(value)) { + throw new RuntimeError( + "invalid_cordis_config", + `${filename} must contain a top-level entry list`, + ); + } + return value; +} + +function dumpEntries(entries) { + return yaml.dump(entries, { + schema: entryListSchema, + indent: 2, + lineWidth: 79, + noRefs: true, + sortKeys: false, + }); +} + +function withTimeout(promise, timeoutMs, error) { + let timer; + const timeout = new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(error), timeoutMs); + timer.unref(); + }); + return Promise.race([promise, timeout]).finally(() => { + clearTimeout(timer); + }); +} + +export class CordisRuntime { + #admissions = new Set(); + #ajv = new Ajv2020({ allErrors: true, strict: true }); + #catalogVersion = 1; + #closed = false; + #includes = new Map(); + #initialized = false; + #invocationSupervisors = new AsyncLocalStorage(); + #mutation = Promise.resolve(); + #shutdownPromise; + #scopeErrors = new Map(); + #supervisors = new WeakMap(); + #tools = new Map(); + + constructor({ + workspaceRoot, + projectRoot = path.join(workspaceRoot, "projects", "mcp_cordis"), + invokeTimeoutMs = 30_000, + maxOutputBytes = 1_048_576, + }) { + this.workspaceRoot = path.resolve(workspaceRoot); + this.projectRoot = path.resolve(projectRoot); + this.scratchRoot = path.join( + this.workspaceRoot, + "out", + "mcp_cordis", + ); + if (!isInside(this.workspaceRoot, this.projectRoot)) { + throw new RangeError("projectRoot must be inside workspaceRoot"); + } + this.invokeTimeoutMs = positiveInteger( + invokeTimeoutMs, + 30_000, + "invokeTimeoutMs", + ); + this.maxOutputBytes = positiveInteger( + maxOutputBytes, + 1_048_576, + "maxOutputBytes", + MAX_READ_BYTES, + ); + this.configFiles = { + project: path.join(this.projectRoot, "cordis.yaml"), + scratch: path.join(this.scratchRoot, "cordis.yaml"), + }; + this.pluginRoots = { + project: path.join(this.projectRoot, "plugins"), + scratch: path.join(this.scratchRoot, "plugins"), + }; + } + + get catalogVersion() { + return this.#catalogVersion; + } + + initialize() { + this.#assertOpen(); + return this.#admit(() => this.#initializeInternal()); + } + + async #initializeInternal() { + if (this.#initialized) { + return { + loaded: this.#loadedPackages(), + errors: [...this.#scopeErrors].map(([scope, error]) => ({ + scope, + error: structuredClone(error), + })), + }; + } + await mkdir(this.pluginRoots.scratch, { recursive: true }); + if (await readMaybe(this.configFiles.scratch) === undefined) { + await atomicWrite(this.configFiles.scratch, "[]\n"); + } + + this.root = new Context(); + this.root.baseUrl = pathToFileURL( + `${this.workspaceRoot}${path.sep}`, + ).href; + this.#installHostApi(); + + await this.#mount(Loader, { baseUrl: this.root.baseUrl }); + await this.#mount(Timer); + await this.#mount(Hmr, { + base: this.workspaceRoot, + root: [ + path.relative(this.workspaceRoot, this.configFiles.project), + path.relative(this.workspaceRoot, this.pluginRoots.project), + path.relative(this.workspaceRoot, this.configFiles.scratch), + path.relative(this.workspaceRoot, this.pluginRoots.scratch), + ], + ignored: ["**/node_modules", "**/.*"], + }); + this.root.loader.builtins.include = Include; + + const errors = []; + for (const scope of ["project", "scratch"]) { + try { + await this.#validateConfigSources(scope); + const id = await this.root.loader.create({ + id: `${scope}_entries`, + name: "cordis:include", + config: { + path: pathToFileURL(this.configFiles[scope]).href, + initial: [], + enableLogs: true, + }, + }); + const include = this.root.loader.resolve(id).subtree; + if (!(include instanceof Include)) { + throw new Error(`${scope} include did not expose a tree`); + } + this.#includes.set(scope, include); + } catch (error) { + const failure = { scope, error: plainError(error) }; + errors.push(failure); + this.#scopeErrors.set( + scope, + structuredClone(failure.error), + ); + process.stderr.write( + `[mcp_cordis] ${scope} Cordis config failed: ` + + `${JSON.stringify(failure.error)}\n`, + ); + } + } + await this.root.loader.await(); + this.#initialized = true; + return { loaded: this.#loadedPackages(), errors }; + } + + async #mount(plugin, config = undefined) { + const fiber = this.root.plugin(plugin, config); + await fiber.await(); + return fiber; + } + + #installHostApi() { + const runtime = this; + this.root.workspaceRoot = this.workspaceRoot; + this.root.resolveWorkspace = function (relativePath = ".") { + return resolveInsideWorkspace(runtime.workspaceRoot, relativePath); + }; + this.root.readText = function (relativePath, options = {}) { + if ( + !options || + typeof options !== "object" || + Array.isArray(options) + ) { + throw new TypeError("readText options must be an object"); + } + const maxBytes = positiveInteger( + options.maxBytes, + runtime.maxOutputBytes, + "readText options.maxBytes", + MAX_READ_BYTES, + ); + return readTextBounded( + resolveInsideWorkspace(runtime.workspaceRoot, relativePath), + maxBytes, + ); + }; + this.root.exec = function (file, args = [], options = {}) { + return runtime.#execute(this, file, args, options); + }; + this.root.tool = function (definition, handler) { + return runtime.#registerTool(this, definition, handler); + }; + } + + #owningEntry(context) { + let fiber = context.fiber; + while (fiber) { + if (fiber.entry) return fiber.entry; + const next = fiber.parent?.fiber; + if (!next || next === fiber) break; + fiber = next; + } + throw new RuntimeError( + "unmanaged_plugin", + "host helpers require a Cordis loader-managed plugin", + ); + } + + #identity(context) { + const entry = this.#owningEntry(context); + const base = path.resolve( + fileURLToPath(entry.parent.tree.ctx.baseUrl), + ); + let scope; + if (base === this.projectRoot) { + scope = "project"; + } else if (base === this.scratchRoot) { + scope = "scratch"; + } else { + throw new RuntimeError( + "unmanaged_plugin", + `loader entry ${entry.id} is outside managed Cordis configs`, + ); + } + // Entry.id is qualified by owning Include entries (for example, + // "scratch_entries:echo"). The persisted child id is the package + // identity inside this scoped config. + const name = validateName(entry.options.id); + return { entry, key: packageKey(scope, name), name, scope }; + } + + #registerTool(context, definition, handler) { + const identity = this.#identity(context); + const record = { + ...normalizeToolDefinition(definition, handler, this.#ajv), + context, + name: identity.name, + scope: identity.scope, + }; + let packageTools = this.#tools.get(identity.key); + if (!packageTools) { + packageTools = new Map(); + this.#tools.set(identity.key, packageTools); + } + const toolName = record.metadata.name; + context.effect(() => { + if (packageTools.has(toolName)) { + throw new Error(`tool ${toolName} is already registered`); + } + packageTools.set(toolName, record); + this.#catalogVersion += 1; + return () => { + if (packageTools.get(toolName) !== record) return; + packageTools.delete(toolName); + if (packageTools.size === 0) { + this.#tools.delete(identity.key); + } + this.#catalogVersion += 1; + }; + }, `mcp_cordis.tool(${JSON.stringify(toolName)})`); + } + + #supervisor(context) { + const fiber = context.fiber; + let supervisor = this.#supervisors.get(fiber); + if (supervisor) return supervisor; + supervisor = new ProcessSupervisor(); + this.#supervisors.set(fiber, supervisor); + context.effect(() => () => supervisor.close(codedError( + "EXEC_DISPOSED", + "ctx.exec was cancelled during Cordis plugin disposal", + )), "mcp_cordis.host_processes"); + return supervisor; + } + + #execute(context, file, args, options) { + this.#identity(context); + if (typeof file !== "string" || !file || file.includes("\0")) { + throw new TypeError("exec file must be a non-empty string"); + } + if ( + !Array.isArray(args) || + args.some((argument) => typeof argument !== "string") + ) { + throw new TypeError("exec args must be an array of strings"); + } + if (!options || typeof options !== "object" || Array.isArray(options)) { + throw new TypeError("exec options must be an object"); + } + if ( + options.allowTruncatedOutput !== undefined && + typeof options.allowTruncatedOutput !== "boolean" + ) { + throw new TypeError( + "exec options.allowTruncatedOutput must be a boolean", + ); + } + if (process.platform !== "linux") { + throw codedError( + "EXEC_UNSUPPORTED_PLATFORM", + "ctx.exec requires Linux process-group supervision", + ); + } + + const cwd = resolveInsideWorkspace( + this.workspaceRoot, + options.cwd ?? ".", + { allowAbsolute: true }, + ); + const maxBytes = positiveInteger( + options.maxBytes, + this.maxOutputBytes, + "exec options.maxBytes", + MAX_READ_BYTES, + ); + const timeoutMs = positiveInteger( + options.timeoutMs, + 30_000, + "exec options.timeoutMs", + 60 * 60 * 1000, + ); + if ( + options.env !== undefined && + ( + !options.env || + typeof options.env !== "object" || + Array.isArray(options.env) + ) + ) { + throw new TypeError("exec options.env must be an object"); + } + const env = { ...process.env }; + for (const [key, value] of Object.entries(options.env ?? {})) { + if (value === undefined || value === null) { + delete env[key]; + } else if (typeof value === "string") { + env[key] = value; + } else { + throw new TypeError( + `exec environment variable ${key} must be a string`, + ); + } + } + const supervisor = this.#invocationSupervisors.getStore() ?? + this.#supervisor(context); + return supervisor.execute({ + file, + args, + options: { + cwd, + env, + maxBytes, + timeoutMs, + allowTruncatedOutput: + options.allowTruncatedOutput === true, + }, + }); + } + + async define({ + scope = "scratch", + name, + source, + activate = undefined, + }) { + this.#assertOpen(); + validateScope(scope); + validateName(name); + return this.#mutate(() => this.#defineUnlocked({ + scope, + name, + source, + activate, + })); + } + + async #defineUnlocked({ scope, name, source, activate }) { + source = prepareSource(source); + const state = await this.#configState(scope); + const index = state.entries.findIndex((entry) => entry.id === name); + const existing = index < 0 ? undefined : state.entries[index]; + if (existing && existing.name !== managedSpecifier(name)) { + throw new RuntimeError( + "unmanaged_entry", + `${packageKey(scope, name)} does not use its managed module path`, + ); + } + const filename = this.#sourceFile(scope, name); + let sourceChanged = false; + let reloadPending = false; + + if (existing) { + const replacement = await this.#replaceSource( + scope, + name, + source, + ); + sourceChanged = replacement.changed; + if (activate === true && existing.disabled) { + await this.root.hmr.refreshFile(filename); + const entries = structuredClone(state.entries); + delete entries[index].disabled; + await this.#commitConfig(scope, state.content, entries); + } + reloadPending = sourceChanged && !existing.disabled; + } else { + await atomicWrite(filename, source); + sourceChanged = true; + const entries = structuredClone(state.entries); + entries.push({ + id: name, + name: managedSpecifier(name), + ...(activate === true ? {} : { disabled: true }), + }); + try { + await this.#commitConfig(scope, state.content, entries); + } catch (error) { + try { + await rm(filename, { force: true }); + } catch (cleanupError) { + throw new RuntimeError( + "define_cleanup_failed", + `failed to define ${packageKey(scope, name)} and ` + + "remove its unreferenced module", + { + operation: plainError(error), + cleanup: plainError(cleanupError), + }, + ); + } + throw error; + } + } + + const snapshot = await this.inspect({ + scope, + name, + includeSource: false, + }); + return { + created: !existing, + updated: Boolean(existing), + persisted: true, + sourceChanged, + ...(reloadPending ? { activation: "pending" } : {}), + ...snapshot, + }; + } + + async run({ scope, name }) { + this.#assertOpen(); + validateScope(scope); + validateName(name); + return this.#mutate(async () => { + await this.root.hmr.awaitRefresh(); + const sourceFile = this.#sourceFile(scope, name); + validateModuleSource( + await readFile(sourceFile, "utf8"), + sourceFile, + ); + const state = await this.#configState(scope); + const index = state.entries.findIndex((entry) => entry.id === name); + if (index < 0) { + throw new RuntimeError( + "package_not_found", + `package ${packageKey(scope, name)} does not exist`, + ); + } + const live = this.#entry(scope, name); + if (!state.entries[index].disabled && live?.fiber?.uid) { + return { changed: false, ...await this.inspect({ scope, name }) }; + } + const wasDisabled = Boolean(state.entries[index].disabled); + if (wasDisabled) { + await this.root.hmr.refreshFile(sourceFile); + const entries = structuredClone(state.entries); + delete entries[index].disabled; + await this.#commitConfig(scope, state.content, entries); + } else { + await live.refresh(); + await this.root.loader.await(); + } + return { + changed: true, + ...await this.inspect({ scope, name }), + }; + }); + } + + async reload({ scope, name }) { + this.#assertOpen(); + validateScope(scope); + validateName(name); + return this.#mutate(async () => { + await this.root.hmr.awaitRefresh(); + const sourceFile = this.#sourceFile(scope, name); + const source = prepareSource(await readFile(sourceFile, "utf8")); + const update = await this.#replaceSource( + scope, + name, + source, + { force: true }, + ); + const snapshot = await this.inspect({ scope, name }); + return { + changed: update.changed, + ...(snapshot.enabled ? { activation: "pending" } : {}), + ...snapshot, + }; + }); + } + + async stop({ scope, name }) { + this.#assertOpen(); + validateScope(scope); + validateName(name); + return this.#mutate(async () => { + await this.root.hmr.awaitRefresh(); + const state = await this.#configState(scope); + const index = state.entries.findIndex((entry) => entry.id === name); + if (index < 0) { + throw new RuntimeError( + "package_not_found", + `package ${packageKey(scope, name)} does not exist`, + ); + } + if (state.entries[index].disabled) { + return { + stopped: false, + ...await this.inspect({ scope, name }), + }; + } + const entries = structuredClone(state.entries); + entries[index].disabled = true; + await this.#commitConfig(scope, state.content, entries); + return { stopped: true, ...await this.inspect({ scope, name }) }; + }); + } + + async remove({ scope, name }) { + this.#assertOpen(); + validateScope(scope); + validateName(name); + return this.#mutate(async () => { + await this.root.hmr.awaitRefresh(); + const state = await this.#configState(scope); + const entries = state.entries.filter((entry) => entry.id !== name); + if (entries.length === state.entries.length) { + await rm(this.#sourceFile(scope, name), { force: true }); + return { + scope, + name, + removed: false, + catalogVersion: this.#catalogVersion, + }; + } + await this.#commitConfig(scope, state.content, entries); + try { + await rm(this.#sourceFile(scope, name), { force: true }); + } catch (error) { + try { + await this.#commitConfig( + scope, + dumpEntries(entries), + state.entries, + ); + } catch (rollbackError) { + throw new RuntimeError( + "remove_rollback_failed", + `failed to remove ${packageKey(scope, name)} and ` + + "restore its config entry", + { + operation: plainError(error), + rollback: plainError(rollbackError), + }, + ); + } + throw error; + } + return { + scope, + name, + removed: true, + catalogVersion: this.#catalogVersion, + }; + }); + } + + async promote({ + name, + targetName = undefined, + activate = false, + }) { + this.#assertOpen(); + validateName(name); + if (targetName !== undefined) validateName(targetName); + const destination = targetName ?? name; + return this.#mutate(async () => { + await this.root.hmr.awaitRefresh(); + const source = await readFile( + this.#sourceFile("scratch", name), + "utf8", + ); + const promoted = await this.#defineUnlocked({ + scope: "project", + name: destination, + source, + activate, + }); + return { + ...promoted, + promotedFrom: { scope: "scratch", name }, + }; + }); + } + + async listPackages({ scope = undefined } = {}) { + this.#assertOpen(); + if (scope !== undefined) validateScope(scope); + const scopes = scope ? [scope] : ["project", "scratch"]; + const packages = []; + const errors = []; + for (const currentScope of scopes) { + if (!this.#includes.has(currentScope)) { + if (scope !== undefined) this.#include(currentScope); + errors.push({ + scope: currentScope, + error: structuredClone( + this.#scopeErrors.get(currentScope) ?? plainError( + new RuntimeError( + "scope_unavailable", + `${currentScope} cordis.yaml did not load`, + ), + ), + ), + }); + continue; + } + let state; + try { + state = await this.#configState(currentScope); + } catch (error) { + if (scope !== undefined) throw error; + errors.push({ + scope: currentScope, + error: plainError(error), + }); + continue; + } + for (const entry of state.entries) { + if (!NAME_PATTERN.test(entry.id ?? "")) continue; + packages.push(await this.#snapshot( + currentScope, + entry.id, + entry, + false, + )); + } + } + packages.sort((left, right) => { + return `${left.scope}/${left.name}`.localeCompare( + `${right.scope}/${right.name}`, + ); + }); + return { catalogVersion: this.#catalogVersion, packages, errors }; + } + + async inspect({ scope, name, includeSource = false }) { + this.#assertOpen(); + validateScope(scope); + validateName(name); + const state = await this.#configState(scope); + const entry = state.entries.find((candidate) => candidate.id === name); + if (!entry) { + throw new RuntimeError( + "package_not_found", + `package ${packageKey(scope, name)} does not exist`, + ); + } + return { + catalogVersion: this.#catalogVersion, + ...await this.#snapshot(scope, name, entry, includeSource), + }; + } + + async #snapshot(scope, name, options, includeSource) { + const live = this.#entry(scope, name); + const sourceFile = this.#sourceFile(scope, name); + const source = await readMaybe(sourceFile); + const callback = live?.fiber?.runtime?.callback; + return { + scope, + name, + description: typeof callback?.description === "string" + ? callback.description + : "", + module: options.name, + enabled: !Boolean(options.disabled), + running: Boolean(live?.fiber?.uid), + sourceFile: path.relative(this.workspaceRoot, sourceFile), + sourceSha256: source === undefined ? null : hashSource(source), + ...(includeSource ? { source: source ?? null } : {}), + }; + } + + listTools({ scope = undefined, packageName = undefined } = {}) { + this.#assertOpen(); + if (scope !== undefined) validateScope(scope); + if (packageName !== undefined) validateName(packageName); + const tools = []; + for (const [key, packageTools] of this.#tools) { + const separator = key.indexOf(":"); + const currentScope = key.slice(0, separator); + const currentName = key.slice(separator + 1); + if (scope !== undefined && scope !== currentScope) continue; + if (packageName !== undefined && packageName !== currentName) { + continue; + } + for (const record of packageTools.values()) { + tools.push({ + scope: currentScope, + package: currentName, + ...record.metadata, + }); + } + } + tools.sort((left, right) => { + return `${left.scope}/${left.package}/${left.name}`.localeCompare( + `${right.scope}/${right.package}/${right.name}`, + ); + }); + return { catalogVersion: this.#catalogVersion, tools }; + } + + async invoke({ + scope, + packageName, + tool, + arguments: args = {}, + timeoutMs = undefined, + catalogVersion = undefined, + }) { + this.#assertOpen(); + validateScope(scope); + validateName(packageName); + validateName(tool); + args = normalizeArguments(args); + if ( + catalogVersion !== undefined && + catalogVersion !== this.#catalogVersion + ) { + throw new RuntimeError( + "stale_catalog", + `catalog ${catalogVersion} is stale; current catalog is ` + + `${this.#catalogVersion}`, + { catalogVersion: this.#catalogVersion }, + ); + } + const record = this.#tools.get( + packageKey(scope, packageName), + )?.get(tool); + if (!record) { + throw new RuntimeError( + "tool_not_found", + `tool ${scope}:${packageName}/${tool} is not running`, + ); + } + if (!record.validate(args)) { + throw new RuntimeError( + "invalid_arguments", + formatValidationErrors(record.validate.errors), + { errors: record.validate.errors }, + ); + } + const selectedTimeout = positiveInteger( + timeoutMs, + this.invokeTimeoutMs, + "timeoutMs", + 300_000, + ); + + const done = deferred(); + let disposeLease; + try { + disposeLease = record.context.effect( + () => () => done.promise, + `mcp_cordis.invoke(${JSON.stringify(tool)})`, + ); + } catch (error) { + throw new RuntimeError( + "package_not_running", + `package ${packageKey(scope, packageName)} is disposing`, + plainError(error), + ); + } + + let completed = false; + const invocationSupervisor = new ProcessSupervisor(); + const call = Promise.resolve() + .then(() => this.#invocationSupervisors.run( + invocationSupervisor, + () => record.handler(args), + )) + .then((value) => jsonClone(value, `result from ${tool}`)) + .finally(() => { + completed = true; + }); + void call.catch(() => {}); + const timeoutError = new RuntimeError( + "invoke_timeout", + `tool invocation exceeded ${selectedTimeout} ms`, + ); + try { + const value = await withTimeout( + call, + selectedTimeout, + timeoutError, + ); + return { + catalogVersion: this.#catalogVersion, + scope, + package: packageName, + tool, + value, + }; + } finally { + await invocationSupervisor.close(codedError( + "EXEC_DISPOSED", + completed + ? "ctx.exec invocation scope has completed" + : "ctx.exec was cancelled at the invocation deadline", + )); + if (completed) { + done.resolve(); + await disposeLease(); + } else { + void call.finally(async () => { + done.resolve(); + await disposeLease(); + }).catch((error) => { + process.stderr.write( + `[mcp_cordis] invocation lease cleanup failed: ` + + `${error instanceof Error ? error.message : error}\n`, + ); + }); + } + } + } + + async #replaceSource(scope, name, source, { force = false } = {}) { + const filename = this.#sourceFile(scope, name); + const previous = await readMaybe(filename); + if (!force && previous === source) { + return { changed: false }; + } + const entry = this.#entry(scope, name); + if (entry && entry.options.name !== managedSpecifier(name)) { + throw new RuntimeError( + "unmanaged_entry", + `${packageKey(scope, name)} does not use its managed module path`, + ); + } + await atomicWrite(filename, source); + return { changed: true }; + } + + async #configState(scope) { + validateScope(scope); + const content = await readFile(this.configFiles[scope], "utf8"); + return { + content, + entries: parseEntries(content, this.configFiles[scope]), + }; + } + + async #validateConfigSources(scope) { + const state = await this.#configState(scope); + for (const entry of state.entries) { + if ( + !NAME_PATTERN.test(entry.id ?? "") || + entry.name !== managedSpecifier(entry.id) + ) { + throw new RuntimeError( + "unsupported_cordis_entry", + `${scope} cordis.yaml contains an unsupported entry`, + ); + } + const sourceFile = this.#sourceFile(scope, entry.id); + validateModuleSource( + await readFile(sourceFile, "utf8"), + sourceFile, + ); + } + } + + async #commitConfig(scope, previousContent, entries) { + const include = this.#include(scope); + const content = dumpEntries(entries); + if (content === previousContent) return; + await atomicWrite(this.configFiles[scope], content); + try { + await include.refresh(); + await this.root.loader.await(); + } catch (error) { + let rollbackError; + try { + await atomicWrite(this.configFiles[scope], previousContent); + await include.refresh(); + await this.root.loader.await(); + } catch (failure) { + rollbackError = failure; + } + throw new RuntimeError( + rollbackError + ? "config_rollback_failed" + : "activation_failed", + `failed to apply ${scope} cordis.yaml` + + (rollbackError ? " and restore its prior entries" : ""), + { + activation: plainError(error), + ...(rollbackError + ? { rollback: plainError(rollbackError) } + : {}), + }, + ); + } + } + + #include(scope) { + const include = this.#includes.get(validateScope(scope)); + if (!include) { + throw new RuntimeError( + "scope_unavailable", + `${scope} cordis.yaml did not load`, + ); + } + return include; + } + + #entry(scope, name) { + return this.#include(scope).store[validateName(name)]; + } + + #sourceFile(scope, name) { + return path.join( + this.pluginRoots[validateScope(scope)], + `${validateName(name)}.mjs`, + ); + } + + #loadedPackages() { + const loaded = []; + for (const [scope, include] of this.#includes) { + for (const entry of Object.values(include.store)) { + if (!entry.fiber?.uid) continue; + loaded.push({ + scope, + name: entry.options.id, + running: true, + }); + } + } + loaded.sort((left, right) => { + return `${left.scope}/${left.name}`.localeCompare( + `${right.scope}/${right.name}`, + ); + }); + return loaded; + } + + #mutate(callback) { + this.#assertOpen(); + const preceding = this.#mutation.catch(() => {}); + const current = preceding.then(() => { + return callback(); + }); + this.#mutation = current; + return this.#admit(() => current); + } + + #admit(callback) { + const admitted = Promise.resolve().then(callback); + this.#admissions.add(admitted); + return admitted.finally(() => this.#admissions.delete(admitted)); + } + + shutdown() { + if (this.#shutdownPromise) return this.#shutdownPromise; + this.#closed = true; + const admitted = [...this.#admissions]; + this.#shutdownPromise = (async () => { + await Promise.allSettled(admitted); + // The root Fiber has uid 0, so truthiness would skip all Cordis + // effects, including HMR watcher disposal. + if (this.root?.fiber) { + await this.root.fiber.dispose(); + } + })(); + return this.#shutdownPromise; + } + + #assertOpen() { + if (this.#closed) { + throw new RuntimeError("runtime_closed", "runtime is closed"); + } + } +} diff --git a/projects/mcp_cordis/package.json b/projects/mcp_cordis/package.json new file mode 100644 index 00000000..8f95ce99 --- /dev/null +++ b/projects/mcp_cordis/package.json @@ -0,0 +1,30 @@ +{ + "name": "@alwaldend/mcp-cordis", + "version": "0.1.0", + "private": true, + "packageManager": "pnpm@10.34.5", + "type": "module", + "engines": { + "node": ">=24" + }, + "dependencies": { + "@deepseek-ai/cordis": "4.0.1", + "@deepseek-ai/cordis-plugin-hmr": "1.0.16", + "@deepseek-ai/cordis-plugin-include": "1.0.6", + "@deepseek-ai/cordis-plugin-loader": "1.0.2", + "@deepseek-ai/cordis-plugin-timer": "1.1.3", + "@modelcontextprotocol/server": "2.0.0", + "ajv": "8.20.0", + "js-yaml": "4.3.2", + "zod": "4.5.4" + }, + "devDependencies": { + "@modelcontextprotocol/client": "2.0.0" + }, + "pnpm": { + "patchedDependencies": { + "@deepseek-ai/cordis-plugin-hmr@1.0.16": + "patches/hmr@1.0.16.patch" + } + } +} diff --git a/projects/mcp_cordis/patches/hmr@1.0.16.patch b/projects/mcp_cordis/patches/hmr@1.0.16.patch new file mode 100644 index 00000000..a666b854 --- /dev/null +++ b/projects/mcp_cordis/patches/hmr@1.0.16.patch @@ -0,0 +1,394 @@ +diff --git a/lib/index.js b/lib/index.js +--- a/lib/index.js ++++ b/lib/index.js +@@ -84,6 +84,8 @@ + configs = /* @__PURE__ */ new Map(); + configRefreshes = /* @__PURE__ */ new WeakMap(); + refreshTasks = /* @__PURE__ */ new Set(); ++ moduleRefresh; ++ moduleRefreshRequested = false; + /** + * Changes from externals will always trigger a full reload. + * Externals are the dependency tree of the CLI worker entry point. +@@ -199,7 +200,7 @@ + ignored: (path) => match(relative(watchBaseDir, path)), + ignoreInitial: true + }); +- const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce); ++ const refreshModules = this.ctx.debounce(() => this.refreshModules(), this.config.debounce); + const onChange = (kind, path) => { + this.ctx.logger.debug("%s detected at %C", kind, path); + const filename = resolve(watchBaseDir, path); +@@ -215,7 +216,7 @@ + if (this.externals.has(url)) return loader.exit(); + if (loader.internal.loadCache.has(url)) { + this.stashed.add(url); +- return partialReload(); ++ return refreshModules(); + } + this.ctx.emit("hmr/change", url); + }; +@@ -265,6 +267,43 @@ + state.running = task; + this.refreshTasks.add(task); + } ++ refreshModules() { ++ if (this.moduleRefresh) { ++ this.moduleRefreshRequested = true; ++ return; ++ } ++ const task = (async () => { ++ do { ++ this.moduleRefreshRequested = false; ++ const stashed = this.stashed; ++ this.stashed = /* @__PURE__ */ new Set(); ++ try { ++ await this.partialReload(stashed); ++ } catch (reason) { ++ for (const url of stashed) this.stashed.add(url); ++ const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason }); ++ this.ctx.logger.warn("module reload failed"); ++ this.ctx.logger.warn(error); ++ if (!this.moduleRefreshRequested) break; ++ } ++ } while (this.stashed.size); ++ })().finally(() => { ++ this.moduleRefresh = void 0; ++ this.refreshTasks.delete(task); ++ }); ++ this.moduleRefresh = task; ++ this.refreshTasks.add(task); ++ } ++ async awaitRefresh() { ++ while (this.moduleRefresh) await this.moduleRefresh; ++ } ++ async refreshFile(filename) { ++ const url = pathToFileURL(filename).href; ++ if (!this.internal.loadCache.has(url)) return; ++ this.stashed.add(url); ++ this.refreshModules(); ++ await this.awaitRefresh(); ++ } + getOuterStack = () => []; + async getLinked(url) { + const job = this.internal.loadCache.get(url); +@@ -279,12 +318,12 @@ + * dependents are accepted. A file is declined if all its dependents are + * declined or if it's an external. + */ +- async analyzeChanges() { ++ async analyzeChanges(stashed) { + const pending = []; +- this.accepted = new Set(this.stashed); ++ this.accepted = new Set(stashed); + this.declined = new Set(this.externals); + const isExcluded = (url) => url.startsWith("node:") || url.includes("/node_modules/"); +- await Promise.all([...this.stashed].map(async (url) => { ++ await Promise.all([...stashed].map(async (url) => { + const children = await this.getLinked(url); + for (const child of children) { + if (this.accepted.has(child) || this.declined.has(child) || isExcluded(child)) continue; +@@ -321,8 +360,8 @@ + } + for (const url of pending) this.declined.add(url); + } +- async partialReload() { +- await this.analyzeChanges(); ++ async partialReload(stashed) { ++ await this.analyzeChanges(stashed); + const pending = /* @__PURE__ */ new Map(); + const reloads = /* @__PURE__ */ new Map(); + const nameMap = Object.create(null); +@@ -390,12 +429,30 @@ + handleError(this.ctx, e); + return rollback(); + } +- const reload = (plugin, runtime) => { +- if (!runtime) return; +- for (const oldFiber of runtime.fibers) { +- const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber._config, this.getOuterStack); +- fiber.entry = oldFiber.entry; ++ const fibers = /* @__PURE__ */ new Map(); ++ for (const [plugin, { runtime }] of reloads) { ++ if (!runtime) continue; ++ fibers.set(plugin, [...runtime.fibers].map((fiber) => ({ ++ fiber, ++ parent: fiber.parent, ++ config: fiber._config, ++ entry: fiber.entry ++ }))); ++ } ++ const dispose = async (plugin) => { ++ const runtime = this.ctx.registry.get(plugin); ++ const active = runtime ? [...runtime.fibers] : []; ++ this.ctx.registry.delete(plugin); ++ await Promise.all(active.map(async (fiber) => { ++ while (fiber.inertia) await fiber.inertia; ++ })); ++ }; ++ const reload = async (plugin, records = fibers.get(plugin) ?? []) => { ++ for (const record of records) { ++ const fiber = record.parent.registry.plugin(plugin, record.config, this.getOuterStack); ++ fiber.entry = record.entry; + if (fiber.entry) fiber.entry.fiber = fiber; ++ await fiber; + } + }; + try { +@@ -403,13 +460,14 @@ + if (!runtime) continue; + const path = relative(this.baseDir, fileURLToPath(filename)); + try { +- this.ctx.registry.delete(plugin); ++ await dispose(plugin); + } catch (err) { + this.ctx.logger.warn("failed to dispose plugin at %C", path); + this.ctx.logger.warn(err); ++ throw err; + } + try { +- reload(attempts[filename], runtime); ++ await reload(attempts[filename], fibers.get(plugin)); + this.ctx.logger.info("reload plugin at %C", path); + } catch (err) { + this.ctx.logger.warn("failed to reload plugin at %C", path); +@@ -422,8 +480,8 @@ + for (const [plugin, { filename, runtime }] of reloads) { + if (!runtime) continue; + try { +- this.ctx.registry.delete(attempts[filename]); +- reload(plugin, runtime); ++ await dispose(attempts[filename]); ++ await reload(plugin, fibers.get(plugin)); + } catch (err) { + this.ctx.logger.warn(err); + } +@@ -431,7 +489,6 @@ + return; + } + this.ctx.emit("hmr/reload", reloads); +- this.stashed = /* @__PURE__ */ new Set(); + } + }; + (function(Hmr) { +diff --git a/src/index.ts b/src/index.ts +--- a/src/index.ts ++++ b/src/index.ts +@@ -1,4 +1,4 @@ +-import { Context, Service, type Plugin } from '@deepseek-ai/cordis' ++import { Context, Service, type Fiber, type Plugin } from '@deepseek-ai/cordis' + import type { Dict } from '@deepseek-ai/cosmokit' + import { ModuleLoader, type ModuleJob, type ResolveResult } from '@deepseek-ai/cordis-plugin-loader' + import type { Include } from '@deepseek-ai/cordis-plugin-include' +@@ -93,6 +93,8 @@ + private readonly configs = new Map() + private readonly configRefreshes = new WeakMap() + private readonly refreshTasks = new Set>() ++ private moduleRefresh?: Promise ++ private moduleRefreshRequested = false + + /** + * Changes from externals will always trigger a full reload. +@@ -239,7 +240,7 @@ + ignoreInitial: true, + }) + +- const partialReload = this.ctx.debounce(() => this.partialReload(), this.config.debounce) ++ const refreshModules = this.ctx.debounce(() => this.refreshModules(), this.config.debounce) + + const onChange = (kind: 'add' | 'change' | 'unlink', path: string) => { + this.ctx.logger.debug('%s detected at %C', kind, path) +@@ -264,7 +265,7 @@ + // in loadCache, so this check covers all module formats. + if (loader.internal!.loadCache.has(url)) { + this.stashed.add(url) +- return partialReload() ++ return refreshModules() + } + + this.ctx.emit('hmr/change', url) +@@ -323,6 +325,46 @@ + this.refreshTasks.add(task) + } + ++ private refreshModules() { ++ if (this.moduleRefresh) { ++ this.moduleRefreshRequested = true ++ return ++ } ++ const task = (async () => { ++ do { ++ this.moduleRefreshRequested = false ++ const stashed = this.stashed ++ this.stashed = new Set() ++ try { ++ await this.partialReload(stashed) ++ } catch (reason) { ++ for (const url of stashed) this.stashed.add(url) ++ const error = reason instanceof Error ? reason : new Error(String(reason), { cause: reason }) ++ this.ctx.logger.warn('module reload failed') ++ this.ctx.logger.warn(error) ++ if (!this.moduleRefreshRequested) break ++ } ++ } while (this.stashed.size) ++ })().finally(() => { ++ this.moduleRefresh = undefined ++ this.refreshTasks.delete(task) ++ }) ++ this.moduleRefresh = task ++ this.refreshTasks.add(task) ++ } ++ ++ async awaitRefresh() { ++ while (this.moduleRefresh) await this.moduleRefresh ++ } ++ ++ async refreshFile(filename: string) { ++ const url = pathToFileURL(filename).href ++ if (!this.internal.loadCache.has(url)) return ++ this.stashed.add(url) ++ this.refreshModules() ++ await this.awaitRefresh() ++ } ++ + // hide stack trace from HMR + getOuterStack = (): string[] => [ + // ' at HMR.partialReload ()', +@@ -342,15 +384,15 @@ + * dependents are accepted. A file is declined if all its dependents are + * declined or if it's an external. + */ +- private async analyzeChanges() { ++ private async analyzeChanges(stashed: Set) { + const pending: string[] = [] + +- this.accepted = new Set(this.stashed) ++ this.accepted = new Set(stashed) + this.declined = new Set(this.externals) + + const isExcluded = (url: string) => url.startsWith('node:') || url.includes('/node_modules/') + +- await Promise.all([...this.stashed].map(async (url) => { ++ await Promise.all([...stashed].map(async (url) => { + const children = await this.getLinked(url) + for (const child of children) { + if (this.accepted.has(child) || this.declined.has(child) || isExcluded(child)) continue +@@ -397,8 +439,8 @@ + } + } + +- private async partialReload() { +- await this.analyzeChanges() ++ private async partialReload(stashed: Set) { ++ await this.analyzeChanges(stashed) + + const pending = new Map() + const reloads = new Map() +@@ -499,12 +541,37 @@ + return rollback() + } + +- const reload = (plugin: any, runtime: Plugin.Runtime) => { +- if (!runtime) return +- for (const oldFiber of runtime.fibers) { +- const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber._config, this.getOuterStack) +- fiber.entry = oldFiber.entry ++ const fibers = new Map>() ++ for (const [plugin, { runtime }] of reloads) { ++ if (!runtime) continue ++ fibers.set(plugin, [...runtime.fibers].map(fiber => ({ ++ fiber, ++ parent: fiber.parent, ++ config: fiber._config, ++ entry: fiber.entry, ++ }))) ++ } ++ ++ const dispose = async (plugin: any) => { ++ const runtime = this.ctx.registry.get(plugin) ++ const active = runtime ? [...runtime.fibers] : [] ++ this.ctx.registry.delete(plugin) ++ await Promise.all(active.map(async (fiber) => { ++ while (fiber.inertia) await fiber.inertia ++ })) ++ } ++ ++ const reload = async (plugin: any, records = fibers.get(plugin) ?? []) => { ++ for (const record of records) { ++ const fiber = record.parent.registry.plugin(plugin, record.config, this.getOuterStack) ++ fiber.entry = record.entry + if (fiber.entry) fiber.entry.fiber = fiber ++ await fiber + } + } + +@@ -514,14 +581,15 @@ + const path = relative(this.baseDir, fileURLToPath(filename)) + + try { +- this.ctx.registry.delete(plugin) ++ await dispose(plugin) + } catch (err) { + this.ctx.logger.warn('failed to dispose plugin at %C', path) + this.ctx.logger.warn(err) ++ throw err + } + + try { +- reload(attempts[filename], runtime) ++ await reload(attempts[filename], fibers.get(plugin)) + this.ctx.logger.info('reload plugin at %C', path) + } catch (err) { + this.ctx.logger.warn('failed to reload plugin at %C', path) +@@ -535,8 +603,8 @@ + for (const [plugin, { filename, runtime }] of reloads) { + if (!runtime) continue + try { +- this.ctx.registry.delete(attempts[filename]) +- reload(plugin, runtime) ++ await dispose(attempts[filename]) ++ await reload(plugin, fibers.get(plugin)) + } catch (err) { + this.ctx.logger.warn(err) + } +@@ -545,7 +613,6 @@ + } + + this.ctx.emit('hmr/reload', reloads) +- this.stashed = new Set() + } + } + +diff --git a/lib/types/index.d.ts b/lib/types/index.d.ts +--- a/lib/types/index.d.ts ++++ b/lib/types/index.d.ts +@@ -30,6 +30,8 @@ declare class Hmr extends Service { + private readonly configs; + private readonly configRefreshes; + private readonly refreshTasks; ++ private moduleRefresh; ++ private moduleRefreshRequested; + /** + * Changes from externals will always trigger a full reload. + * Externals are the dependency tree of the CLI worker entry point. +@@ -62,6 +64,8 @@ declare class Hmr extends Service { + private _resolve; + [Service.init](): AsyncGenerator<() => Promise, void, unknown>; + private refreshConfig; ++ awaitRefresh(): Promise; ++ refreshFile(filename: string): Promise; + getOuterStack: () => string[]; + getLinked(url: string): Promise; + /** +@@ -84,4 +88,4 @@ declare namespace Hmr { + const Config: z; + } + export default Hmr; +-//# sourceMappingURL=index.d.ts.map +\ No newline at end of file ++//# sourceMappingURL=index.d.ts.map diff --git a/projects/mcp_cordis/plugins/git_worktree.mjs b/projects/mcp_cordis/plugins/git_worktree.mjs new file mode 100644 index 00000000..292d9eef --- /dev/null +++ b/projects/mcp_cordis/plugins/git_worktree.mjs @@ -0,0 +1,784 @@ +import { constants as fsConstants } from "node:fs"; +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import process from "node:process"; + +export const description = + "Read-only Git worktree snapshots and commit comparisons."; + +const plugin = { + name: "git_worktree", + description, + + apply(ctx) { + const workspaceRoot = path.resolve(ctx.resolveWorkspace(".")); + const canonicalWorkspaceRoot = fs.realpath(workspaceRoot); + const MAX_DIFF_BYTES = 2 * 1024 * 1024; + + function integer(value, fallback, minimum, maximum, label) { + const selected = value === undefined ? fallback : value; + if (!Number.isInteger(selected)) { + throw new TypeError(`${label} must be an integer`); + } + if (selected < minimum || selected > maximum) { + throw new RangeError( + `${label} must be between ${minimum} and ${maximum}`, + ); + } + return selected; + } + + function string(value, label, maximum, allowEmpty = false) { + if (typeof value !== "string") { + throw new TypeError(`${label} must be a string`); + } + if (value.includes("\0") || value.includes("\n") || + value.includes("\r")) { + throw new TypeError(`${label} contains an invalid control character`); + } + if ((!allowEmpty && value.length === 0) || value.length > maximum) { + throw new RangeError(`${label} has an invalid length`); + } + return value; + } + + function clipUtf8(value, maximum) { + const source = Buffer.from(String(value), "utf8"); + if (source.length <= maximum) { + return { + text: source.toString("utf8"), + bytes: source.length, + truncated: false, + }; + } + let end = maximum; + while (end > 0 && (source[end] & 0xc0) === 0x80) { + end -= 1; + } + return { + text: source.subarray(0, end).toString("utf8"), + bytes: end, + truncated: true, + }; + } + + function resultText(result, field) { + const value = result[field]; + if (Buffer.isBuffer(value)) { + return value.toString("utf8"); + } + return value === undefined ? "" : String(value); + } + + function executionState(result, label) { + if (!result || typeof result !== "object") { + throw new Error(`${label} returned an invalid execution result`); + } + const outputLimitExceeded = result.outputLimitExceeded === true; + if (result.truncated && !outputLimitExceeded) { + throw new Error( + `${label} returned incomplete output without an output-limit marker`, + ); + } + if (outputLimitExceeded) { + return { code: null, outputLimitExceeded: true }; + } + if (result.signal !== null && result.signal !== undefined) { + throw new Error(`${label} was terminated by ${result.signal}`); + } + const code = result.code ?? result.exitCode; + if (!Number.isInteger(code)) { + throw new Error(`${label} ended without an exit code`); + } + return { code, outputLimitExceeded: false }; + } + + function separatedFields(value, separator) { + if (value === "") { + return { fields: [], malformed: false }; + } + const terminated = value.endsWith(separator); + const fields = value.split(separator); + fields.pop(); + return { + fields, + malformed: !terminated, + }; + } + + function escapes(base, candidate) { + const relative = path.relative(base, candidate); + return relative === ".." || relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative); + } + + async function execute(repo, args, options = {}) { + return ctx.exec("git", ["-C", repo, ...args], { + cwd: workspaceRoot, + env: { + GIT_ALTERNATE_OBJECT_DIRECTORIES: null, + GIT_COMMON_DIR: null, + GIT_CONFIG: null, + GIT_CONFIG_COUNT: null, + GIT_CONFIG_PARAMETERS: null, + GIT_DIR: null, + GIT_GRAFT_FILE: null, + GIT_IMPLICIT_WORK_TREE: null, + GIT_INDEX_FILE: null, + GIT_INTERNAL_SUPER_PREFIX: null, + GIT_NAMESPACE: null, + GIT_NO_LAZY_FETCH: "1", + GIT_NO_REPLACE_OBJECTS: null, + GIT_OBJECT_DIRECTORY: null, + GIT_OPTIONAL_LOCKS: "0", + GIT_PREFIX: null, + GIT_REPLACE_REF_BASE: null, + GIT_SHALLOW_FILE: null, + GIT_TERMINAL_PROMPT: "0", + GIT_WORK_TREE: null, + }, + allowTruncatedOutput: true, + timeoutMs: options.timeoutMs ?? 15_000, + maxBytes: options.maxBytes ?? 512 * 1024, + }); + } + + async function bindDirectory(absolute, label) { + const [workspace, canonical] = await Promise.all([ + canonicalWorkspaceRoot, + fs.realpath(absolute), + ]); + if (escapes(workspace, canonical)) { + throw new RangeError(`${label} is outside the workspace`); + } + const handle = await fs.open( + canonical, + fsConstants.O_RDONLY | + fsConstants.O_DIRECTORY | + fsConstants.O_NOFOLLOW, + ); + try { + const opened = await fs.realpath(`/proc/self/fd/${handle.fd}`); + if (escapes(workspace, opened)) { + throw new RangeError(`${label} is outside the workspace`); + } + return { + absolute: opened, + commandPath: `/proc/${process.pid}/fd/${handle.fd}`, + handle, + }; + } catch (error) { + await handle.close(); + throw error; + } + } + + async function repository(requested = ".") { + const selected = string(requested, "repo", 4096); + if (path.isAbsolute(selected)) { + throw new TypeError("repo must be relative to the workspace"); + } + const candidate = path.resolve(ctx.resolveWorkspace(selected)); + if (escapes(workspaceRoot, candidate)) { + throw new RangeError("the Git repository is outside the workspace"); + } + const selectedDirectory = await bindDirectory( + candidate, + "the selected Git directory", + ); + let discovery; + try { + discovery = await execute( + selectedDirectory.commandPath, + ["rev-parse", "--show-toplevel"], + { maxBytes: 16 * 1024 }, + ); + } finally { + await selectedDirectory.handle.close(); + } + const state = executionState(discovery, "git rev-parse"); + if (state.outputLimitExceeded) { + throw new Error("Git repository discovery output was truncated"); + } + if (state.code !== 0) { + throw new Error( + `${selected} is not inside a Git worktree: ` + + clipUtf8(resultText(discovery, "stderr"), 4096).text, + ); + } + const discovered = path.resolve(resultText(discovery, "stdout").trim()); + const directory = await bindDirectory( + discovered, + "the Git repository", + ); + const relative = path.relative(workspaceRoot, directory.absolute); + return { + absolute: directory.absolute, + close: () => directory.handle.close(), + commandPath: directory.commandPath, + relative: relative.split(path.sep).join("/") || ".", + }; + } + + async function withRepository(requested, operation) { + const repo = await repository(requested); + try { + return await operation(repo); + } finally { + await repo.close(); + } + } + + function revision(value, label) { + const selected = string(value, label, 256); + if (selected.startsWith("-")) { + throw new TypeError(`${label} must not begin with a hyphen`); + } + return selected; + } + + async function resolveRevision(repo, value, label) { + const selected = revision(value, label); + const result = await execute( + repo, + [ + "rev-parse", + "--verify", + "--quiet", + "--end-of-options", + `${selected}^{commit}`, + ], + { maxBytes: 16 * 1024 }, + ); + const state = executionState(result, `git rev-parse for ${label}`); + if (state.outputLimitExceeded) { + throw new Error(`Git output was truncated while resolving ${label}`); + } + if (state.code !== 0) { + throw new Error(`${label} does not resolve to a commit`); + } + const hash = resultText(result, "stdout").trim(); + if (!/^[0-9a-f]{40,64}$/i.test(hash)) { + throw new Error(`Git returned an invalid hash for ${label}`); + } + return hash; + } + + function selectedPaths(repo, values) { + if (values === undefined) { + return []; + } + if (!Array.isArray(values) || values.length > 64) { + throw new RangeError("paths must contain at most 64 entries"); + } + return values.map((value, index) => { + const selected = string(value, `paths[${index}]`, 4096); + if (path.isAbsolute(selected)) { + throw new TypeError(`paths[${index}] must be relative`); + } + const absolute = path.resolve(repo, selected); + const relative = path.relative(repo, absolute); + if (escapes(repo, absolute)) { + throw new RangeError(`paths[${index}] resolves outside the repo`); + } + const workspaceRelative = path.relative(workspaceRoot, absolute); + ctx.resolveWorkspace(workspaceRelative); + return relative.split(path.sep).join("/") || "."; + }); + } + + function parseStatus(value, maximum, outputLimitExceeded = false) { + const framing = separatedFields(value, "\0"); + const records = framing.fields; + const branch = {}; + const changes = []; + let truncated = framing.malformed; + for (let index = 0; index < records.length; index += 1) { + const record = records[index]; + if (!record) { + continue; + } + if (record.startsWith("# branch.oid ")) { + branch.oid = record.slice(13) === "(initial)" + ? null + : record.slice(13); + continue; + } + if (record.startsWith("# branch.head ")) { + branch.head = record.slice(14) === "(detached)" + ? null + : record.slice(14); + continue; + } + if (record.startsWith("# branch.upstream ")) { + branch.upstream = record.slice(18); + continue; + } + if (record.startsWith("# branch.ab ")) { + const match = /^# branch\.ab \+(\d+) -(\d+)$/.exec(record); + if (match) { + branch.ahead = Number(match[1]); + branch.behind = Number(match[2]); + } + continue; + } + + let consumed = 0; + let change; + let match = /^1 (\S+) \S+ \S+ \S+ \S+ \S+ \S+ ([\s\S]*)$/.exec( + record, + ); + if (match) { + change = { kind: "ordinary", status: match[1], path: match[2] }; + } else { + match = /^2 (\S+) \S+ \S+ \S+ \S+ \S+ \S+ \S+ ([\s\S]*)$/.exec( + record, + ); + } + if (!change && match) { + const originalPath = records[index + 1] ?? null; + if (originalPath !== null) { + consumed = 1; + change = { + kind: "rename_or_copy", + status: match[1], + path: match[2], + originalPath, + }; + } + } + if (!change) { + match = /^u (\S+) \S+ \S+ \S+ \S+ \S+ \S+ \S+ \S+ ([\s\S]*)$/.exec( + record, + ); + if (match) { + change = { kind: "unmerged", status: match[1], path: match[2] }; + } + } + if (!change) { + match = /^([?!]) ([\s\S]*)$/.exec(record); + if (match) { + change = { + kind: match[1] === "?" ? "untracked" : "ignored", + status: match[1], + path: match[2], + }; + } + } + if (!change) { + truncated = true; + continue; + } + if (changes.length >= maximum) { + truncated = true; + break; + } + changes.push(change); + index += consumed; + } + return { + branch, + changes, + truncated, + framingTruncated: framing.malformed || outputLimitExceeded, + }; + } + + function parseLog(value, maximum, outputLimitExceeded = false) { + const framing = separatedFields(value, "\0"); + const fields = framing.fields; + const commits = []; + let truncated = framing.malformed || outputLimitExceeded; + for (let index = 0; index + 4 < fields.length; index += 5) { + const hash = fields[index]; + const parents = fields[index + 1]; + const author = fields[index + 2]; + const authoredAt = fields[index + 3]; + const subject = fields[index + 4]; + if (!/^[0-9a-f]{40,64}$/i.test(hash) || + (parents && !parents.split(" ").every((parent) => { + return /^[0-9a-f]{40,64}$/i.test(parent); + })) || !authoredAt) { + truncated = true; + continue; + } + const clippedAuthor = clipUtf8(author, 512); + const clippedSubject = clipUtf8(subject, 4096); + truncated ||= clippedAuthor.truncated || clippedSubject.truncated; + commits.push({ + hash, + parents: parents ? parents.split(" ") : [], + author: clippedAuthor.text, + authoredAt, + subject: clippedSubject.text, + }); + } + if (fields.length % 5 !== 0) { + truncated = true; + } + if (commits.length > maximum) { + truncated = true; + } + return { + commits: commits.slice(0, maximum), + truncated, + }; + } + + function parseNameStatus(value, outputLimitExceeded = false) { + const framing = separatedFields(value, "\0"); + const fields = framing.fields; + const files = []; + let truncated = framing.malformed; + for (let index = 0; index < fields.length; index += 1) { + const status = fields[index]; + if (!status) { + continue; + } + let file; + let consumed; + if (/^[RC]\d+$/.test(status)) { + const oldPath = fields[index + 1]; + const selectedPath = fields[index + 2]; + if (oldPath === undefined || oldPath === "" || + selectedPath === undefined || selectedPath === "") { + truncated = true; + break; + } + file = { status, oldPath, path: selectedPath }; + consumed = 2; + } else { + const selectedPath = fields[index + 1]; + if (selectedPath === undefined || selectedPath === "") { + truncated = true; + break; + } + file = { status, path: selectedPath }; + consumed = 1; + } + if (files.length >= 1000) { + truncated = true; + break; + } + files.push(file); + index += consumed; + } + return { files, truncated }; + } + + ctx.tool( + { + name: "git_snapshot", + description: + "Return bounded branch, status, history, and diff-stat data " + + "without modifying Git.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + repo: { type: "string", default: ".", maxLength: 4096 }, + log_limit: { + type: "integer", + minimum: 0, + maximum: 100, + default: 10, + }, + max_changes: { + type: "integer", + minimum: 1, + maximum: 1000, + default: 200, + }, + include_diff_stat: { type: "boolean", default: true }, + }, + }, + }, + async (input = {}) => { + return withRepository(input.repo ?? ".", async (repo) => { + const logLimit = integer( + input.log_limit, + 10, + 0, + 100, + "log_limit", + ); + const maxChanges = integer( + input.max_changes, + 200, + 1, + 1000, + "max_changes", + ); + const commands = [ + execute( + repo.commandPath, + [ + "status", + "--porcelain=v2", + "--branch", + "-z", + "--untracked-files=all", + ], + { maxBytes: 1024 * 1024 }, + ), + ]; + if (logLimit > 0) { + commands.push(execute( + repo.commandPath, + [ + "log", + "-z", + "--no-show-signature", + `--max-count=${logLimit + 1}`, + "--format=%H%x00%P%x00%aN%x00%aI%x00%s", + ], + { maxBytes: 512 * 1024 }, + )); + } + if (input.include_diff_stat !== false) { + commands.push(execute( + repo.commandPath, + ["diff", "--no-ext-diff", "--no-textconv", "--stat=120,80"], + { maxBytes: 128 * 1024 }, + )); + commands.push(execute( + repo.commandPath, + [ + "diff", + "--cached", + "--no-ext-diff", + "--no-textconv", + "--stat=120,80", + ], + { maxBytes: 128 * 1024 }, + )); + } + + const results = await Promise.all(commands); + const statusResult = results.shift(); + const statusState = executionState(statusResult, "git status"); + if (!statusState.outputLimitExceeded && statusState.code !== 0) { + throw new Error( + `git status failed: ` + + clipUtf8(resultText(statusResult, "stderr"), 4096).text, + ); + } + const status = parseStatus( + resultText(statusResult, "stdout"), + maxChanges, + statusState.outputLimitExceeded, + ); + const output = { + repo: repo.relative, + branch: status.branch, + changes: status.changes, + branchTruncated: status.framingTruncated, + changesTruncated: + status.truncated || statusState.outputLimitExceeded, + statusTruncated: + status.truncated || statusState.outputLimitExceeded, + }; + if (logLimit > 0) { + const logResult = results.shift(); + const logState = executionState(logResult, "git log"); + if (!logState.outputLimitExceeded && logState.code !== 0) { + const historyError = clipUtf8( + resultText(logResult, "stderr"), + 4096, + ); + output.commits = []; + output.historyAvailable = false; + output.historyTruncated = false; + output.historyError = historyError.text; + output.historyErrorTruncated = historyError.truncated; + } else { + const history = parseLog( + resultText(logResult, "stdout"), + logLimit, + logState.outputLimitExceeded, + ); + output.commits = history.commits; + output.historyAvailable = true; + output.historyTruncated = history.truncated; + } + } + if (input.include_diff_stat !== false) { + const workingResult = results.shift(); + const stagedResult = results.shift(); + const workingState = executionState( + workingResult, + "git diff --stat", + ); + const stagedState = executionState( + stagedResult, + "git diff --cached --stat", + ); + if (!workingState.outputLimitExceeded && workingState.code !== 0) { + throw new Error( + "git diff --stat failed: " + clipUtf8( + resultText(workingResult, "stderr"), + 4096, + ).text, + ); + } + if (!stagedState.outputLimitExceeded && stagedState.code !== 0) { + throw new Error( + "git diff --cached --stat failed: " + clipUtf8( + resultText(stagedResult, "stderr"), + 4096, + ).text, + ); + } + const working = clipUtf8( + resultText(workingResult, "stdout"), + 128 * 1024, + ); + const staged = clipUtf8( + resultText(stagedResult, "stdout"), + 128 * 1024, + ); + output.diffStat = { + working: working.text, + workingTruncated: working.truncated || + workingState.outputLimitExceeded, + staged: staged.text, + stagedTruncated: staged.truncated || + stagedState.outputLimitExceeded, + }; + } + return output; + }); + }, + ); + + ctx.tool( + { + name: "git_compare", + description: + "Compare two commits with bounded name-status and unified " + + "diff output.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["base", "head"], + properties: { + repo: { type: "string", default: ".", maxLength: 4096 }, + base: { type: "string", minLength: 1, maxLength: 256 }, + head: { type: "string", minLength: 1, maxLength: 256 }, + paths: { + type: "array", + maxItems: 64, + items: { type: "string", minLength: 1, maxLength: 4096 }, + }, + context_lines: { + type: "integer", + minimum: 0, + maximum: 100, + default: 3, + }, + max_bytes: { + type: "integer", + minimum: 1024, + maximum: MAX_DIFF_BYTES, + default: 256 * 1024, + }, + }, + }, + }, + async (input) => { + return withRepository(input.repo ?? ".", async (repo) => { + const [base, head] = await Promise.all([ + resolveRevision(repo.commandPath, input.base, "base"), + resolveRevision(repo.commandPath, input.head, "head"), + ]); + const paths = selectedPaths(repo.absolute, input.paths); + const context = integer( + input.context_lines, + 3, + 0, + 100, + "context_lines", + ); + const maximum = integer( + input.max_bytes, + 256 * 1024, + 1024, + MAX_DIFF_BYTES, + "max_bytes", + ); + const separator = paths.length > 0 ? ["--", ...paths] : []; + const [namesResult, diffResult] = await Promise.all([ + execute( + repo.commandPath, + [ + "diff", + "--name-status", + "-z", + "--no-ext-diff", + "--no-textconv", + base, + head, + ...separator, + ], + { maxBytes: 256 * 1024 }, + ), + execute( + repo.commandPath, + [ + "diff", + "--no-color", + "--no-ext-diff", + "--no-textconv", + `--unified=${context}`, + base, + head, + ...separator, + ], + { maxBytes: maximum, timeoutMs: 30_000 }, + ), + ]); + const namesState = executionState( + namesResult, + "git diff --name-status", + ); + const diffState = executionState(diffResult, "git diff"); + if ((!namesState.outputLimitExceeded && namesState.code !== 0) || + (!diffState.outputLimitExceeded && diffState.code !== 0)) { + throw new Error( + "git diff failed: " + clipUtf8( + resultText(diffResult, "stderr") || + resultText(namesResult, "stderr"), + 4096, + ).text, + ); + } + const diff = clipUtf8(resultText(diffResult, "stdout"), maximum); + const names = parseNameStatus( + resultText(namesResult, "stdout"), + namesState.outputLimitExceeded, + ); + const filesTruncated = names.truncated || + namesState.outputLimitExceeded; + const diffTruncated = diff.truncated || + diffState.outputLimitExceeded; + return { + repo: repo.relative, + base, + head, + paths, + files: names.files, + filesTruncated, + diff: diff.text, + diffBytes: diff.bytes, + diffTruncated, + truncated: filesTruncated || diffTruncated, + }; + }); + }, + ); + }, +}; + +plugin.apply.description = description; + +export default plugin; diff --git a/projects/mcp_cordis/plugins/network_probe.mjs b/projects/mcp_cordis/plugins/network_probe.mjs new file mode 100644 index 00000000..6ca0e9c7 --- /dev/null +++ b/projects/mcp_cordis/plugins/network_probe.mjs @@ -0,0 +1,528 @@ +import { promises as dns } from "node:dns"; +import * as net from "node:net"; +import { performance } from "node:perf_hooks"; +import * as tls from "node:tls"; + +export const description = "Bounded DNS, TCP/TLS, and HTTP diagnostics."; + +const plugin = { + name: "network_probe", + description, + + apply(ctx) { + const MAX_BODY_BYTES = 1024 * 1024; + const RECORD_TYPES = new Set([ + "A", + "AAAA", + "CAA", + "CNAME", + "MX", + "NAPTR", + "NS", + "PTR", + "SOA", + "SRV", + "TXT", + ]); + + function integer(value, fallback, minimum, maximum, label) { + const selected = value === undefined ? fallback : value; + if (!Number.isInteger(selected)) { + throw new TypeError(`${label} must be an integer`); + } + if (selected < minimum || selected > maximum) { + throw new RangeError( + `${label} must be between ${minimum} and ${maximum}`, + ); + } + return selected; + } + + function string(value, label, maximum, allowEmpty = false) { + if (typeof value !== "string") { + throw new TypeError(`${label} must be a string`); + } + if (/[\0\r\n]/.test(value)) { + throw new TypeError(`${label} contains an invalid control character`); + } + if ((!allowEmpty && value.length === 0) || value.length > maximum) { + throw new RangeError(`${label} has an invalid length`); + } + return value; + } + + function host(value) { + const selected = string(value, "host", 253); + if (/\s/.test(selected)) { + throw new TypeError("host must not contain whitespace"); + } + return selected; + } + + function clipUtf8(value, maximum) { + const source = Buffer.from(String(value), "utf8"); + if (source.length <= maximum) { + return { + text: source.toString("utf8"), + bytes: source.length, + truncated: false, + }; + } + let end = maximum; + while (end > 0 && (source[end] & 0xc0) === 0x80) { + end -= 1; + } + return { + text: source.subarray(0, end).toString("utf8"), + bytes: end, + truncated: true, + }; + } + + function decodeUtf8Prefix(source) { + let start = source.length - 1; + while (start >= 0 && (source[start] & 0xc0) === 0x80) { + start -= 1; + } + if (start < 0) return ""; + const leading = source[start]; + const expected = leading < 0x80 + ? 1 + : leading >= 0xc2 && leading <= 0xdf + ? 2 + : leading >= 0xe0 && leading <= 0xef + ? 3 + : leading >= 0xf0 && leading <= 0xf4 ? 4 : 1; + const end = source.length - start < expected ? start : source.length; + return source.subarray(0, end).toString("utf8"); + } + + function errorDetails(error) { + return { + code: typeof error?.code === "string" ? error.code : null, + message: clipUtf8(error?.message ?? String(error), 2048).text, + }; + } + + function elapsed(started) { + return Math.round((performance.now() - started) * 1000) / 1000; + } + + function boundedRecords(records, maximumRecords, maximumBytes) { + const selected = []; + let truncated = records.length > maximumRecords; + for (const record of records.slice(0, maximumRecords)) { + selected.push(record); + if (Buffer.byteLength(JSON.stringify(selected), "utf8") > maximumBytes) { + selected.pop(); + truncated = true; + break; + } + } + return { records: selected, truncated }; + } + + ctx.tool( + { + name: "dns_lookup", + description: + "Resolve one DNS record type with an optional resolver and hard bounds.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["host"], + properties: { + host: { type: "string", minLength: 1, maxLength: 253 }, + rrtype: { + type: "string", + enum: [...RECORD_TYPES], + default: "A", + }, + resolver: { type: "string", minLength: 1, maxLength: 300 }, + timeout_ms: { + type: "integer", + minimum: 100, + maximum: 30_000, + default: 5_000, + }, + max_records: { + type: "integer", + minimum: 1, + maximum: 100, + default: 25, + }, + max_bytes: { + type: "integer", + minimum: 1024, + maximum: 256 * 1024, + default: 64 * 1024, + }, + }, + }, + }, + async (input) => { + const hostname = host(input.host); + const rrtype = String(input.rrtype ?? "A").toUpperCase(); + if (!RECORD_TYPES.has(rrtype)) { + throw new RangeError(`unsupported rrtype: ${rrtype}`); + } + const timeoutMs = integer( + input.timeout_ms, + 5_000, + 100, + 30_000, + "timeout_ms", + ); + const maxRecords = integer( + input.max_records, + 25, + 1, + 100, + "max_records", + ); + const maxBytes = integer( + input.max_bytes, + 64 * 1024, + 1024, + 256 * 1024, + "max_bytes", + ); + const resolver = new dns.Resolver({ timeout: timeoutMs, tries: 1 }); + if (input.resolver !== undefined) { + resolver.setServers([string(input.resolver, "resolver", 300)]); + } + + const started = performance.now(); + let timer; + try { + const timeout = new Promise((_, reject) => { + timer = setTimeout(() => { + const error = new Error(`DNS lookup timed out after ${timeoutMs}ms`); + error.code = "ETIMEDOUT"; + resolver.cancel(); + reject(error); + }, timeoutMs); + }); + const resolved = await Promise.race([ + resolver.resolve(hostname, rrtype), + timeout, + ]); + const records = Array.isArray(resolved) ? resolved : [resolved]; + const bounded = boundedRecords(records, maxRecords, maxBytes); + return { + ok: true, + host: hostname, + rrtype, + resolver: input.resolver ?? null, + records: bounded.records, + truncated: bounded.truncated, + elapsedMs: elapsed(started), + }; + } catch (error) { + return { + ok: false, + host: hostname, + rrtype, + resolver: input.resolver ?? null, + error: errorDetails(error), + elapsedMs: elapsed(started), + }; + } finally { + clearTimeout(timer); + } + }, + ); + + ctx.tool( + { + name: "tcp_probe", + description: + "Open a bounded TCP or TLS connection without sending application data.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["host", "port"], + properties: { + host: { type: "string", minLength: 1, maxLength: 253 }, + port: { type: "integer", minimum: 1, maximum: 65535 }, + timeout_ms: { + type: "integer", + minimum: 100, + maximum: 30_000, + default: 5_000, + }, + tls: { type: "boolean", default: false }, + server_name: { type: "string", minLength: 1, maxLength: 253 }, + verify_certificate: { type: "boolean", default: true }, + }, + }, + }, + async (input) => { + const hostname = host(input.host); + const port = integer(input.port, undefined, 1, 65535, "port"); + const timeoutMs = integer( + input.timeout_ms, + 5_000, + 100, + 30_000, + "timeout_ms", + ); + const useTls = input.tls === true; + const serverName = input.server_name === undefined + ? (net.isIP(hostname) ? undefined : hostname) + : host(input.server_name); + const started = performance.now(); + + return new Promise((resolve) => { + let settled = false; + let timer; + const options = { host: hostname, port }; + const socket = useTls + ? tls.connect({ + ...options, + servername: serverName, + rejectUnauthorized: input.verify_certificate !== false, + }) + : net.connect(options); + + const finish = (output) => { + if (settled) { + return; + } + settled = true; + clearTimeout(timer); + socket.removeAllListeners(); + socket.destroy(); + resolve({ + host: hostname, + port, + tls: useTls, + elapsedMs: elapsed(started), + ...output, + }); + }; + + timer = setTimeout(() => { + finish({ + ok: false, + error: { + code: "ETIMEDOUT", + message: `connection timed out after ${timeoutMs}ms`, + }, + }); + }, timeoutMs); + socket.once("error", (error) => { + finish({ ok: false, error: errorDetails(error) }); + }); + socket.once(useTls ? "secureConnect" : "connect", () => { + const output = { + ok: true, + localAddress: socket.localAddress ?? null, + localPort: socket.localPort ?? null, + remoteAddress: socket.remoteAddress ?? null, + remoteFamily: socket.remoteFamily ?? null, + remotePort: socket.remotePort ?? null, + }; + if (useTls) { + const certificate = socket.getPeerCertificate(false); + output.authorized = socket.authorized; + output.authorizationError = socket.authorizationError ?? null; + output.protocol = socket.getProtocol(); + output.cipher = socket.getCipher()?.standardName ?? + socket.getCipher()?.name ?? null; + output.alpnProtocol = socket.alpnProtocol || null; + output.certificate = certificate && + Object.keys(certificate).length > 0 + ? { + subject: certificate.subject ?? null, + issuer: certificate.issuer ?? null, + validFrom: certificate.valid_from ?? null, + validTo: certificate.valid_to ?? null, + fingerprint256: certificate.fingerprint256 ?? null, + } + : null; + } + finish(output); + }); + }); + }, + ); + + ctx.tool( + { + name: "http_probe", + description: + "Issue a bounded HTTP GET or HEAD request and return metadata plus a body preview.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["url"], + properties: { + url: { type: "string", minLength: 1, maxLength: 8192 }, + method: { type: "string", enum: ["GET", "HEAD"], default: "GET" }, + headers: { + type: "object", + maxProperties: 32, + additionalProperties: { + type: "string", + maxLength: 8192, + }, + }, + timeout_ms: { + type: "integer", + minimum: 100, + maximum: 60_000, + default: 10_000, + }, + max_body_bytes: { + type: "integer", + minimum: 0, + maximum: MAX_BODY_BYTES, + default: 64 * 1024, + }, + follow_redirects: { type: "boolean", default: false }, + include_sensitive_headers: { type: "boolean", default: false }, + }, + }, + }, + async (input) => { + const selectedUrl = string(input.url, "url", 8192); + const url = new URL(selectedUrl); + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new TypeError("url must use http or https"); + } + if (url.username || url.password) { + throw new TypeError("credentials must not be embedded in url"); + } + const method = String(input.method ?? "GET").toUpperCase(); + if (method !== "GET" && method !== "HEAD") { + throw new RangeError("method must be GET or HEAD"); + } + const headers = input.headers ?? {}; + if (headers === null || Array.isArray(headers) || + typeof headers !== "object") { + throw new TypeError("headers must be an object"); + } + const entries = Object.entries(headers); + if (entries.length > 32) { + throw new RangeError("headers must contain at most 32 entries"); + } + for (const [name, value] of entries) { + string(name, "header name", 256); + string(value, `header ${name}`, 8192, true); + } + const timeoutMs = integer( + input.timeout_ms, + 10_000, + 100, + 60_000, + "timeout_ms", + ); + const maximum = integer( + input.max_body_bytes, + 64 * 1024, + 0, + MAX_BODY_BYTES, + "max_body_bytes", + ); + const controller = new AbortController(); + const timer = setTimeout(() => { + controller.abort(new Error(`HTTP probe timed out after ${timeoutMs}ms`)); + }, timeoutMs); + const started = performance.now(); + + try { + const response = await fetch(url, { + method, + headers, + redirect: input.follow_redirects === true ? "follow" : "manual", + signal: controller.signal, + }); + const responseHeaders = {}; + const sensitive = new Set([ + "proxy-authenticate", + "set-cookie", + "www-authenticate", + ]); + let headerBytes = 0; + let headersTruncated = false; + for (const [name, value] of response.headers) { + if (sensitive.has(name) && input.include_sensitive_headers !== true) { + responseHeaders[name] = "[redacted]"; + continue; + } + const clipped = clipUtf8(value, 8192); + const bytes = Buffer.byteLength(name, "utf8") + clipped.bytes; + if (headerBytes + bytes > 64 * 1024) { + headersTruncated = true; + break; + } + responseHeaders[name] = clipped.text; + headerBytes += bytes; + headersTruncated ||= clipped.truncated; + } + + const chunks = []; + let keptBytes = 0; + let bodyTruncated = false; + if (response.body && method !== "HEAD") { + const reader = response.body.getReader(); + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + const chunk = Buffer.from(value); + const remaining = maximum - keptBytes; + if (remaining > 0) { + chunks.push(chunk.subarray(0, remaining)); + keptBytes += Math.min(chunk.length, remaining); + } + if (chunk.length > remaining) { + bodyTruncated = true; + await reader.cancel("body preview limit reached"); + break; + } + } + } + const body = Buffer.concat(chunks, keptBytes); + const contentType = response.headers.get("content-type") ?? ""; + const textual = /^(text\/)|json|javascript|xml|x-www-form-urlencoded/i + .test(contentType); + return { + ok: true, + requestedUrl: url.toString(), + finalUrl: response.url, + redirected: response.redirected, + status: response.status, + statusText: response.statusText, + headers: responseHeaders, + headersTruncated, + body: textual + ? bodyTruncated + ? decodeUtf8Prefix(body) + : body.toString("utf8") + : body.toString("base64"), + bodyEncoding: textual ? "utf8" : "base64", + bodyBytes: keptBytes, + bodyTruncated, + elapsedMs: elapsed(started), + }; + } catch (error) { + return { + ok: false, + requestedUrl: url.toString(), + error: errorDetails(error), + elapsedMs: elapsed(started), + }; + } finally { + clearTimeout(timer); + } + }, + ); + }, +}; + +plugin.apply.description = description; + +export default plugin; diff --git a/projects/mcp_cordis/plugins/repo_context.mjs b/projects/mcp_cordis/plugins/repo_context.mjs new file mode 100644 index 00000000..d32545fe --- /dev/null +++ b/projects/mcp_cordis/plugins/repo_context.mjs @@ -0,0 +1,1044 @@ +import * as fs from "node:fs/promises"; +import { constants as fsConstants } from "node:fs"; +import * as path from "node:path"; +import process from "node:process"; + +export const description = "Bounded repository context, reads, and searches."; + +const plugin = { + name: "repo_context", + description, + + apply(ctx) { + const root = path.resolve(ctx.resolveWorkspace(".")); + const canonicalRoot = fs.realpath(root); + const MAX_FILE_BYTES = 2 * 1024 * 1024; + const MAX_OUTPUT_BYTES = 1024 * 1024; + + function integer(value, fallback, minimum, maximum, label) { + const selected = value === undefined ? fallback : value; + if (!Number.isInteger(selected)) { + throw new TypeError(`${label} must be an integer`); + } + if (selected < minimum || selected > maximum) { + throw new RangeError( + `${label} must be between ${minimum} and ${maximum}`, + ); + } + return selected; + } + + function string(value, label, maximum, allowEmpty = false) { + if (typeof value !== "string") { + throw new TypeError(`${label} must be a string`); + } + if (value.includes("\0")) { + throw new TypeError(`${label} must not contain a NUL byte`); + } + if ((!allowEmpty && value.length === 0) || value.length > maximum) { + throw new RangeError(`${label} has an invalid length`); + } + return value; + } + + function workspacePath(value, label = "path") { + const requested = string(value ?? ".", label, 4096); + if (path.isAbsolute(requested)) { + throw new TypeError(`${label} must be relative to the workspace`); + } + const absolute = path.resolve(ctx.resolveWorkspace(requested)); + const relative = path.relative(root, absolute); + if (escapes(root, absolute)) { + throw new RangeError(`${label} resolves outside the workspace`); + } + return { + absolute, + relative: relative.split(path.sep).join("/") || ".", + }; + } + + function clipUtf8(value, maximum) { + const source = Buffer.from(String(value), "utf8"); + if (source.length <= maximum) { + return { text: source.toString("utf8"), bytes: source.length, + truncated: false }; + } + let end = maximum; + while (end > 0 && (source[end] & 0xc0) === 0x80) { + end -= 1; + } + return { + text: source.subarray(0, end).toString("utf8"), + bytes: end, + truncated: true, + }; + } + + function resultText(result, field) { + const value = result[field]; + if (Buffer.isBuffer(value)) { + return value.toString("utf8"); + } + return value === undefined ? "" : String(value); + } + + function textLines(content) { + if (content.length === 0) return []; + const lines = content.split(/\r?\n/); + if (content.endsWith("\n")) lines.pop(); + return lines; + } + + function executionState(result, label) { + if (!result || typeof result !== "object") { + throw new Error(`${label} returned an invalid execution result`); + } + const outputLimitExceeded = result.outputLimitExceeded === true; + if (result.truncated && !outputLimitExceeded) { + throw new Error( + `${label} returned incomplete output without an output-limit marker`, + ); + } + if (outputLimitExceeded) { + return { code: null, outputLimitExceeded: true }; + } + if (result.signal !== null && result.signal !== undefined) { + throw new Error(`${label} was terminated by ${result.signal}`); + } + const code = result.code ?? result.exitCode; + if (!Number.isInteger(code)) { + throw new Error(`${label} ended without an exit code`); + } + return { code, outputLimitExceeded: false }; + } + + function escapes(base, candidate) { + const relative = path.relative(base, candidate); + return relative === ".." || relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative); + } + + async function canonicalPath(absolute, label) { + const [workspace, selected] = await Promise.all([ + canonicalRoot, + fs.realpath(absolute), + ]); + if (escapes(workspace, selected)) { + throw new RangeError(`${label} resolves outside the workspace`); + } + return selected; + } + + async function bindPath(absolute, label) { + const canonical = await canonicalPath(absolute, label); + const handle = await fs.open( + canonical, + fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW, + ); + try { + const [workspace, opened, info] = await Promise.all([ + canonicalRoot, + fs.realpath(`/proc/self/fd/${handle.fd}`), + handle.stat(), + ]); + if (escapes(workspace, opened)) { + throw new RangeError(`${label} resolves outside the workspace`); + } + return { + absolute: opened, + commandPath: `/proc/${process.pid}/fd/${handle.fd}`, + handle, + info, + }; + } catch (error) { + await handle.close(); + throw error; + } + } + + async function run(file, args, options = {}) { + return ctx.exec(file, args, { + cwd: options.cwd ?? root, + ...(file === "git" ? { + env: { + GIT_ALTERNATE_OBJECT_DIRECTORIES: null, + GIT_COMMON_DIR: null, + GIT_CONFIG: null, + GIT_CONFIG_COUNT: null, + GIT_CONFIG_PARAMETERS: null, + GIT_DIR: null, + GIT_GRAFT_FILE: null, + GIT_IMPLICIT_WORK_TREE: null, + GIT_INDEX_FILE: null, + GIT_INTERNAL_SUPER_PREFIX: null, + GIT_NAMESPACE: null, + GIT_NO_LAZY_FETCH: "1", + GIT_NO_REPLACE_OBJECTS: null, + GIT_OBJECT_DIRECTORY: null, + GIT_OPTIONAL_LOCKS: "0", + GIT_PREFIX: null, + GIT_REPLACE_REF_BASE: null, + GIT_SHALLOW_FILE: null, + GIT_TERMINAL_PROMPT: "0", + GIT_WORK_TREE: null, + }, + } : {}), + allowTruncatedOutput: true, + timeoutMs: options.timeoutMs ?? 10_000, + maxBytes: options.maxBytes ?? 512 * 1024, + }); + } + + async function readFile(location, maximum = MAX_FILE_BYTES) { + const binding = await bindPath( + location.absolute, + location.relative, + ); + try { + if (!binding.info.isFile()) { + throw new TypeError(`${location.relative} is not a regular file`); + } + if (binding.info.size > maximum) { + throw new RangeError( + `${location.relative} is ${binding.info.size} bytes; ` + + `limit is ${maximum}`, + ); + } + const buffer = Buffer.allocUnsafe(maximum + 1); + let bytes = 0; + while (bytes < buffer.length) { + const result = await binding.handle.read( + buffer, + bytes, + buffer.length - bytes, + bytes, + ); + if (result.bytesRead === 0) break; + bytes += result.bytesRead; + } + if (bytes > maximum) { + throw new RangeError(`${location.relative} exceeded its byte limit`); + } + const raw = buffer.subarray(0, bytes); + return { + content: raw.toString("utf8"), + bytes, + raw, + }; + } finally { + await binding.handle.close(); + } + } + + async function fallbackSearch({ + query, + paths, + fixed, + caseSensitive, + contextLines, + maxMatches, + }) { + const MAX_SCAN_FILES = 10_000; + const MAX_SCAN_BYTES = 64 * 1024 * 1024; + const MAX_RESULT_BYTES = 4 * 1024 * 1024; + const matches = []; + let matchCount = 0; + let scannedBytes = 0; + let scannedFiles = 0; + let resultBytes = 2; + let truncated = false; + let detailsTruncated = false; + + if (!fixed) { + throw new Error( + "ripgrep is required for regular-expression searches", + ); + } + const fixedExpression = !caseSensitive + ? new RegExp( + query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + "gidu", + ) + : undefined; + + function emit(entry) { + const bytes = Buffer.byteLength(JSON.stringify(entry), "utf8") + + (matches.length === 0 ? 0 : 1); + if (resultBytes + bytes > MAX_RESULT_BYTES) { + truncated = true; + return false; + } + matches.push(entry); + resultBytes += bytes; + return true; + } + + function submatches(line) { + const found = []; + const occurrences = []; + if (caseSensitive) { + let offset = 0; + while (true) { + const start = line.indexOf(query, offset); + if (start < 0) break; + occurrences.push({ start, text: query }); + offset = start + query.length; + } + } else { + fixedExpression.lastIndex = 0; + for (const match of line.matchAll(fixedExpression)) { + occurrences.push({ start: match.index, text: match[0] }); + } + } + for (const occurrence of occurrences) { + if (found.length >= 32) { + detailsTruncated = true; + break; + } + const text = clipUtf8(occurrence.text, 512); + const byteStart = Buffer.byteLength( + line.slice(0, occurrence.start), + "utf8", + ); + const byteEnd = byteStart + Buffer.byteLength( + occurrence.text, + "utf8", + ); + detailsTruncated ||= text.truncated; + found.push({ + start: byteStart, + end: byteEnd, + text: text.text, + }); + } + return found; + } + + async function searchFile(relativePath) { + if (truncated) return; + const normalized = relativePath.split(path.sep).join("/"); + const location = workspacePath(normalized); + const info = await fs.stat(location.absolute); + if (!info.isFile() || info.size > MAX_FILE_BYTES) return; + if (scannedFiles >= MAX_SCAN_FILES || + scannedBytes + info.size > MAX_SCAN_BYTES) { + truncated = true; + return; + } + scannedFiles += 1; + scannedBytes += info.size; + const file = await readFile(location); + if (!Buffer.from(file.content, "utf8").equals(file.raw)) { + throw new Error( + "ripgrep is required to search files containing invalid UTF-8", + ); + } + if (file.content.includes("\0")) return; + const lines = textLines(file.content); + const foundByLine = lines.map((line) => submatches(line)); + const selectedMatches = []; + let omittedMatch = false; + for (let index = 0; index < lines.length; index += 1) { + if (foundByLine[index].length === 0) continue; + if (matchCount + selectedMatches.length >= maxMatches) { + omittedMatch = true; + continue; + } + selectedMatches.push(index); + } + + const selectedSet = new Set(selectedMatches); + const outputLines = new Set(); + for (const index of selectedMatches) { + const start = Math.max(0, index - contextLines); + const end = Math.min(lines.length - 1, index + contextLines); + for (let outputIndex = start; outputIndex <= end; outputIndex += 1) { + if (foundByLine[outputIndex].length === 0 || + selectedSet.has(outputIndex)) { + outputLines.add(outputIndex); + } + } + } + + const orderedLines = [...outputLines].sort((left, right) => left - right); + for (const index of orderedLines) { + const isMatch = selectedSet.has(index); + const clipped = clipUtf8(lines[index], 4096); + detailsTruncated ||= clipped.truncated; + const emitted = emit({ + kind: isMatch ? "match" : "context", + path: normalized, + line: index + 1, + text: clipped.text, + textTruncated: clipped.truncated, + ...(isMatch ? { submatches: foundByLine[index] } : {}), + }); + if (!emitted) break; + if (isMatch) matchCount += 1; + } + truncated ||= omittedMatch; + } + + async function visit(relativePath, selected = false) { + if (truncated) return; + const location = workspacePath(relativePath); + let info = await fs.lstat(location.absolute); + if (info.isSymbolicLink()) { + if (!selected) return; + await canonicalPath(location.absolute, location.relative); + info = await fs.stat(location.absolute); + } + if (info.isFile()) { + await searchFile(location.relative); + return; + } + if (!info.isDirectory()) return; + if (selected) { + throw new Error( + "ripgrep is required to search directories; the JavaScript " + + "fallback accepts only explicitly selected files", + ); + } + const entries = await fs.readdir(location.absolute, { + withFileTypes: true, + }); + entries.sort((left, right) => left.name.localeCompare(right.name)); + for (const entry of entries) { + if (entry.name === ".git" || entry.name === "node_modules") { + continue; + } + await visit(path.join(location.relative, entry.name)); + if (truncated) break; + } + } + + for (const selectedPath of paths) { + await visit(selectedPath, true); + if (truncated) break; + } + return { + query, + matches, + matchCount, + truncated: truncated || detailsTruncated, + engine: "javascript", + scannedFiles, + scannedBytes, + resultBytes, + }; + } + + async function gitContext(directory, maximum) { + const workspace = await canonicalRoot; + const selected = await bindPath(directory, "selected Git directory"); + let rootResult; + try { + rootResult = await run( + "git", + ["-C", selected.commandPath, "rev-parse", "--show-toplevel"], + { maxBytes: 16 * 1024 }, + ); + } finally { + await selected.handle.close(); + } + const rootState = executionState(rootResult, "git rev-parse"); + if (rootState.outputLimitExceeded) { + return { + error: "Git repository root discovery output was truncated", + rootTruncated: true, + }; + } + if (rootState.code !== 0) { + return null; + } + + const rootText = resultText(rootResult, "stdout").trim(); + if (!path.isAbsolute(rootText)) { + throw new Error("git rev-parse returned an invalid repository root"); + } + let repository; + try { + repository = await bindPath(path.resolve(rootText), "Git repository"); + } catch (error) { + if (error instanceof RangeError) { + return { + error: "the selected path belongs to a repository outside the " + + "workspace", + }; + } + throw error; + } + const relative = path.relative(workspace, repository.absolute); + + let headResult; + let statusResult; + try { + [headResult, statusResult] = await Promise.all([ + run( + "git", + [ + "-C", + repository.commandPath, + "rev-parse", + "--verify", + "HEAD", + ], + { maxBytes: 16 * 1024 }, + ), + run( + "git", + [ + "-C", + repository.commandPath, + "status", + "--short", + "--branch", + "--untracked-files=no", + ], + { maxBytes: maximum }, + ), + ]); + } finally { + await repository.handle.close(); + } + const headState = executionState(headResult, "git rev-parse HEAD"); + const statusState = executionState(statusResult, "git status"); + const output = { + root: relative.split(path.sep).join("/") || ".", + rootTruncated: false, + }; + if (headState.outputLimitExceeded) { + output.head = null; + output.headAvailable = false; + output.headTruncated = true; + } else if (headState.code === 0) { + const head = resultText(headResult, "stdout").trim(); + if (!/^[0-9a-f]{40,64}$/i.test(head)) { + throw new Error("git rev-parse HEAD returned an invalid hash"); + } + output.head = head; + output.headAvailable = true; + output.headTruncated = false; + } else { + output.head = null; + output.headAvailable = false; + output.headTruncated = false; + output.headError = clipUtf8( + resultText(headResult, "stderr"), + 4096, + ).text; + } + + if (!statusState.outputLimitExceeded && statusState.code !== 0) { + output.status = null; + output.statusAvailable = false; + output.statusTruncated = false; + output.statusError = clipUtf8( + resultText(statusResult, "stderr"), + 4096, + ).text; + } else { + const clipped = clipUtf8( + resultText(statusResult, "stdout"), + maximum, + ); + output.status = clipped.text; + output.statusAvailable = true; + output.statusTruncated = + clipped.truncated || statusState.outputLimitExceeded; + } + return output; + } + + async function instructionContext(directory, maximum) { + const workspace = await canonicalRoot; + const directories = []; + let current = directory; + while (true) { + directories.push(current); + if (current === workspace) { + break; + } + const parent = path.dirname(current); + if (parent === current || escapes(workspace, parent)) { + break; + } + current = parent; + } + directories.reverse(); + + const documents = []; + let remaining = maximum; + let truncated = false; + for (const candidateDirectory of directories) { + const candidate = path.join(candidateDirectory, "AGENTS.md"); + try { + const info = await fs.stat(candidate); + if (!info.isFile()) { + continue; + } + if (remaining === 0 && info.size > 0) { + truncated = true; + continue; + } + const relative = path.relative(workspace, candidate) + .split(path.sep).join("/"); + const file = await readFile( + workspacePath(relative), + Math.min(MAX_FILE_BYTES, Math.max(info.size, 1)), + ); + const clipped = clipUtf8(file.content, remaining); + documents.push({ + path: relative, + content: clipped.text, + truncated: clipped.truncated, + }); + remaining -= clipped.bytes; + truncated ||= clipped.truncated; + } catch (error) { + if (error?.code !== "ENOENT") { + throw error; + } + } + } + return { + documents, + usedBytes: maximum - remaining, + truncated, + }; + } + + ctx.tool( + { + name: "repo_context_get", + description: + "Describe a workspace path, its Git state, and applicable " + + "AGENTS.md files.", + inputSchema: { + type: "object", + additionalProperties: false, + properties: { + path: { type: "string", default: ".", maxLength: 4096 }, + include_git: { type: "boolean", default: true }, + include_instructions: { type: "boolean", default: true }, + max_bytes: { + type: "integer", + minimum: 1024, + maximum: MAX_OUTPUT_BYTES, + default: 128 * 1024, + }, + }, + }, + }, + async (input = {}) => { + const location = workspacePath(input.path ?? "."); + const maximum = integer( + input.max_bytes, + 128 * 1024, + 1024, + MAX_OUTPUT_BYTES, + "max_bytes", + ); + const selected = await bindPath( + location.absolute, + location.relative, + ); + try { + const absolute = selected.absolute; + const info = selected.info; + const directory = info.isDirectory() + ? absolute + : path.dirname(absolute); + const output = { + path: location.relative, + kind: info.isDirectory() + ? "directory" + : info.isFile() ? "file" : "other", + size: info.size, + }; + + let remaining = maximum; + if (input.include_git !== false) { + const git = await gitContext( + directory, + Math.min(remaining, 64 * 1024), + ); + output.git = git; + if (git?.status) { + remaining -= Buffer.byteLength(git.status, "utf8"); + } + } + if (input.include_instructions !== false) { + const instructions = await instructionContext(directory, remaining); + output.instructions = instructions.documents; + output.instructionsTruncated = instructions.truncated; + remaining -= instructions.usedBytes; + } + + if (info.isDirectory()) { + const allEntries = (await fs.readdir(selected.commandPath, { + withFileTypes: true, + })) + .sort((left, right) => left.name.localeCompare(right.name)); + const entries = allEntries.slice(0, 128) + .map((entry) => ({ + name: entry.name, + kind: entry.isDirectory() + ? "directory" + : entry.isFile() ? "file" : "other", + })); + output.entries = entries; + output.entriesTruncated = allEntries.length > 128; + } + output.maxBytes = maximum; + output.usedTextBytes = maximum - remaining; + return output; + } finally { + await selected.handle.close(); + } + }, + ); + + ctx.tool( + { + name: "repo_context_search", + description: + "Search workspace files with ripgrep and return bounded " + + "structured matches.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["query"], + properties: { + query: { type: "string", minLength: 1, maxLength: 4096 }, + paths: { + type: "array", + maxItems: 32, + items: { type: "string", minLength: 1, maxLength: 4096 }, + default: ["."], + }, + globs: { + type: "array", + maxItems: 32, + items: { type: "string", minLength: 1, maxLength: 256 }, + default: [], + }, + fixed: { type: "boolean", default: true }, + case_sensitive: { type: "boolean", default: true }, + context_lines: { + type: "integer", + minimum: 0, + maximum: 20, + default: 0, + }, + max_matches: { + type: "integer", + minimum: 1, + maximum: 500, + default: 100, + }, + }, + }, + }, + async (input) => { + const query = string(input.query, "query", 4096); + const contextLines = integer( + input.context_lines, + 0, + 0, + 20, + "context_lines", + ); + const maxMatches = integer( + input.max_matches, + 100, + 1, + 500, + "max_matches", + ); + const selectedPaths = input.paths ?? ["."]; + if (!Array.isArray(selectedPaths) || selectedPaths.length > 32) { + throw new RangeError("paths must contain at most 32 entries"); + } + const locations = selectedPaths.map((value, index) => + workspacePath(value, `paths[${index}]`), + ); + const paths = locations.map((location) => location.relative); + const bindings = []; + try { + for (let index = 0; index < locations.length; index += 1) { + const binding = await bindPath( + locations[index].absolute, + `paths[${index}]`, + ); + bindings.push({ + ...binding, + relative: locations[index].relative, + }); + } + } catch (error) { + await Promise.all(bindings.map((binding) => binding.handle.close())); + throw error; + } + const globs = input.globs ?? []; + if (!Array.isArray(globs) || globs.length > 32) { + throw new RangeError("globs must contain at most 32 entries"); + } + const validatedGlobs = globs.map((value, index) => + string(value, `globs[${index}]`, 256), + ); + + const args = [ + "--json", + "--no-config", + "--max-columns=4096", + "--max-columns-preview", + ]; + if (input.fixed !== false) { + args.push("--fixed-strings"); + } + args.push(input.case_sensitive === false + ? "--ignore-case" + : "--case-sensitive"); + if (contextLines > 0) { + args.push("--context", String(contextLines)); + } + for (const glob of validatedGlobs) { + args.push("--glob", glob); + } + args.push( + "--", + query, + ...bindings.map((binding) => binding.commandPath), + ); + + let execution; + try { + try { + execution = await run("rg", args, { + maxBytes: 4 * 1024 * 1024, + timeoutMs: 20_000, + }); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + return await fallbackSearch({ + query, + paths, + fixed: input.fixed !== false, + caseSensitive: input.case_sensitive !== false, + contextLines, + maxMatches, + }); + } + } finally { + await Promise.all(bindings.map((binding) => binding.handle.close())); + } + + function displayPath(reported) { + for (const binding of bindings) { + if (reported === binding.commandPath) return binding.relative; + const prefix = `${binding.commandPath}/`; + if (!reported.startsWith(prefix)) continue; + const suffix = reported.slice(prefix.length); + return binding.relative === "." + ? suffix + : `${binding.relative}/${suffix}`; + } + return reported; + } + const state = executionState(execution, "ripgrep"); + if (!state.outputLimitExceeded && + state.code !== 0 && state.code !== 1) { + throw new Error( + `ripgrep exited with ${state.code}: ` + + clipUtf8(resultText(execution, "stderr"), 4096).text, + ); + } + + const matches = []; + let matchCount = 0; + let parseTruncated = state.outputLimitExceeded; + const stdout = resultText(execution, "stdout"); + const lines = stdout.split("\n"); + if (state.outputLimitExceeded && !stdout.endsWith("\n")) { + lines.pop(); + } + for (const line of lines) { + if (!line) { + continue; + } + let event; + try { + event = JSON.parse(line); + } catch { + parseTruncated = true; + continue; + } + if (event.type !== "match" && event.type !== "context") { + continue; + } + if (event.type === "match" && matchCount >= maxMatches) { + parseTruncated = true; + break; + } + const data = event.data; + const lineBytes = typeof data.lines?.bytes === "string"; + const pathBytes = typeof data.path?.bytes === "string"; + const lineText = clipUtf8(data.lines?.text ?? "", 4096); + parseTruncated ||= lineText.truncated || lineBytes || pathBytes; + const entry = { + kind: event.type, + path: displayPath(data.path?.text ?? ""), + pathTruncated: pathBytes, + line: data.line_number ?? null, + text: lineText.text.replace(/\r?\n$/, ""), + textTruncated: lineText.truncated || lineBytes, + }; + if (event.type === "match") { + const sourceSubmatches = data.submatches ?? []; + parseTruncated ||= sourceSubmatches.length > 32; + entry.submatches = sourceSubmatches.slice(0, 32).map( + (submatch) => { + const matchBytes = + typeof submatch.match?.bytes === "string"; + const text = clipUtf8( + submatch.match?.text ?? "", + 512, + ); + parseTruncated ||= text.truncated || matchBytes; + return { + start: submatch.start, + end: submatch.end, + text: text.text, + textTruncated: text.truncated || matchBytes, + }; + }, + ); + matchCount += 1; + } + matches.push(entry); + } + return { + query, + matches, + matchCount, + truncated: parseTruncated, + engine: "ripgrep", + }; + }, + ); + + ctx.tool( + { + name: "repo_context_read", + description: + "Read bounded line ranges from regular files inside the workspace.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["files"], + properties: { + files: { + type: "array", + minItems: 1, + maxItems: 32, + items: { + type: "object", + additionalProperties: false, + required: ["path"], + properties: { + path: { type: "string", minLength: 1, maxLength: 4096 }, + start_line: { type: "integer", minimum: 1 }, + end_line: { type: "integer", minimum: 1 }, + }, + }, + }, + max_total_bytes: { + type: "integer", + minimum: 1, + maximum: MAX_OUTPUT_BYTES, + default: 128 * 1024, + }, + }, + }, + }, + async (input) => { + if (!Array.isArray(input.files) || input.files.length === 0 || + input.files.length > 32) { + throw new RangeError("files must contain between 1 and 32 entries"); + } + const maximum = integer( + input.max_total_bytes, + 128 * 1024, + 1, + MAX_OUTPUT_BYTES, + "max_total_bytes", + ); + let remaining = maximum; + const files = []; + for (let index = 0; index < input.files.length; index += 1) { + const request = input.files[index]; + const location = workspacePath( + request.path, + `files[${index}].path`, + ); + const start = integer( + request.start_line, + 1, + 1, + Number.MAX_SAFE_INTEGER, + `files[${index}].start_line`, + ); + const end = integer( + request.end_line, + Number.MAX_SAFE_INTEGER, + 1, + Number.MAX_SAFE_INTEGER, + `files[${index}].end_line`, + ); + if (end < start) { + throw new RangeError("end_line must not be before start_line"); + } + const file = await readFile(location); + const lines = textLines(file.content); + const rangeExists = start <= lines.length; + const selected = !rangeExists + ? "" + : lines.slice(start - 1, Math.min(end, lines.length)).join("\n"); + const clipped = clipUtf8(selected, remaining); + const retainedNewlines = clipped.text.match(/\n/g)?.length ?? 0; + const retainedEnd = clipped.text === "" + ? null + : start + retainedNewlines - + (clipped.truncated && clipped.text.endsWith("\n") ? 1 : 0); + files.push({ + path: location.relative, + startLine: start, + endLine: clipped.truncated + ? retainedEnd + : rangeExists ? Math.min(end, lines.length) : null, + totalLines: lines.length, + content: clipped.text, + bytes: clipped.bytes, + truncated: clipped.truncated, + }); + remaining -= clipped.bytes; + if (remaining === 0) { + break; + } + } + return { + files, + maxTotalBytes: maximum, + usedBytes: maximum - remaining, + truncated: files.length < input.files.length || + files.some((file) => file.truncated), + }; + }, + ); + }, +}; + +plugin.apply.description = description; + +export default plugin; diff --git a/projects/mcp_cordis/pnpm-lock.yaml b/projects/mcp_cordis/pnpm-lock.yaml new file mode 100644 index 00000000..35ca20c0 --- /dev/null +++ b/projects/mcp_cordis/pnpm-lock.yaml @@ -0,0 +1,336 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: false + excludeLinksFromLockfile: false + +patchedDependencies: + '@deepseek-ai/cordis-plugin-hmr@1.0.16': + hash: ec800d86298faacc86c7717ffa1dce7c28116ab1393b8abc198be6ac02c38489 + path: patches/hmr@1.0.16.patch + +importers: + + .: + dependencies: + '@deepseek-ai/cordis': + specifier: 4.0.1 + version: 4.0.1(@deepseek-ai/cordis-plugin-include@1.0.6)(@deepseek-ai/cordis-plugin-loader@1.0.2) + '@deepseek-ai/cordis-plugin-hmr': + specifier: 1.0.16 + version: 1.0.16(patch_hash=ec800d86298faacc86c7717ffa1dce7c28116ab1393b8abc198be6ac02c38489)(@deepseek-ai/cordis-plugin-timer@1.1.3(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/cordis@4.0.1) + '@deepseek-ai/cordis-plugin-include': + specifier: 1.0.6 + version: 1.0.6(@deepseek-ai/cordis-plugin-loader@1.0.2)(@deepseek-ai/cordis@4.0.1) + '@deepseek-ai/cordis-plugin-loader': + specifier: 1.0.2 + version: 1.0.2(@deepseek-ai/cordis@4.0.1) + '@deepseek-ai/cordis-plugin-timer': + specifier: 1.1.3 + version: 1.1.3(@deepseek-ai/cordis@4.0.1) + '@modelcontextprotocol/server': + specifier: 2.0.0 + version: 2.0.0 + ajv: + specifier: 8.20.0 + version: 8.20.0 + js-yaml: + specifier: 4.3.2 + version: 4.3.2 + zod: + specifier: 4.5.4 + version: 4.5.4 + devDependencies: + '@modelcontextprotocol/client': + specifier: 2.0.0 + version: 2.0.0 + +packages: + + '@babel/code-frame@7.29.7': + resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@deepseek-ai/cordis-plugin-hmr@1.0.16': + resolution: {integrity: sha512-S7VHfylDg1+Y5O6fdYYZu0KFRAj/snHywcFPnX3KmQYa/qv2Q8IXi4zAt9ABq0BJYqhNoqRlbpGMfIjabgXjKA==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/cordis-plugin-timer': ^1.1.3 + + '@deepseek-ai/cordis-plugin-include@1.0.6': + resolution: {integrity: sha512-i1VXrZCbv6tk/iUgedCNjrxxArbWT3IvRZGB5sdqJ3ectnihivXXQbRZ8JJ73DSmAPvlMGmrbtjFAfm10yvXRg==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + '@deepseek-ai/cordis-plugin-loader': ^1.0.2 + + '@deepseek-ai/cordis-plugin-loader@1.0.2': + resolution: {integrity: sha512-RIW9hoVyhYDWdCI9BsvtZccPde1ECLC4OAxupwowGTak78vwVTVdb3HezTSOK1Y1/Ax3Ru0LA1pYOB04CnTxIQ==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + node-addon-require-builtin: ^0.1.4 + peerDependenciesMeta: + node-addon-require-builtin: + optional: true + + '@deepseek-ai/cordis-plugin-timer@1.1.3': + resolution: {integrity: sha512-IOkey1VNmJwYa5U8bksyMOnM7mtirqjjbWLr3xA8QybLMK0sMooG3a6iJwtvd8P3MFwo3FQibEg+JPixp37MEw==} + peerDependencies: + '@deepseek-ai/cordis': ^4.0.1 + + '@deepseek-ai/cordis@4.0.1': + resolution: {integrity: sha512-YBdskTU2Po1kru3GgcUWUbkTsPMA9LkSQDAY8rBkFJeajdgcQad3QPJZE26JyK99Xb6HaASvoXg2DSUTeN/0Nw==} + hasBin: true + peerDependencies: + '@deepseek-ai/cordis-plugin-include': ^1.0.6 + '@deepseek-ai/cordis-plugin-loader': ^1.0.2 + peerDependenciesMeta: + '@deepseek-ai/cordis-plugin-include': + optional: true + '@deepseek-ai/cordis-plugin-loader': + optional: true + + '@deepseek-ai/cosmokit@1.8.2': + resolution: {integrity: sha512-muBOKtSrUKU5m/xpq8ZXWL6hQ/jgd4PhU2PqH97bcxIiLEJfNwZOGQEx4t/aS/GgxRAR+ra9pMHPMtTHU4sqqA==} + + '@deepseek-ai/schemastery@3.18.1': + resolution: {integrity: sha512-Qn0FCSwCQnpnj6SB31I6i2sIKgKWnkbJM8O0EU91Gv2UsYVvtZTl6IA0sCwk2e2MZf5S8w5hpq9QkeVvK9qwxg==} + + '@modelcontextprotocol/client@2.0.0': + resolution: {integrity: sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw==} + engines: {node: '>=20'} + + '@modelcontextprotocol/core@2.0.0': + resolution: {integrity: sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==} + engines: {node: '>=20'} + + '@modelcontextprotocol/server@2.0.0': + resolution: {integrity: sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==} + engines: {node: '>=20'} + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + + chokidar@4.0.3: + resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} + engines: {node: '>= 14.16.0'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + eventsource-parser@3.1.1: + resolution: {integrity: sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.6: + resolution: {integrity: sha512-7Ical1vFEMr0onbVzEDIreM22I4khW+fzyQPwvAFWBp1iwdshSZRsL4jjRvPG9JP1uiqMHRto+YU6R2/CzDz5Q==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jose@6.2.10: + resolution: {integrity: sha512-iiW7J9qRFlGxvCOIBDBDxFePQSn7ZMAnrYGhrrOo6siO/MIqwfyilLR27pkfDgUk+raLuzADS8A3S/KLBisc0g==} + + js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + js-yaml@4.3.2: + resolution: {integrity: sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==} + hasBin: true + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + readdirp@4.1.2: + resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} + engines: {node: '>= 14.18.0'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + zod@4.5.4: + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==} + +snapshots: + + '@babel/code-frame@7.29.7': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + js-tokens: 4.0.0 + picocolors: 1.1.1 + + '@babel/helper-validator-identifier@7.29.7': {} + + '@deepseek-ai/cordis-plugin-hmr@1.0.16(patch_hash=ec800d86298faacc86c7717ffa1dce7c28116ab1393b8abc198be6ac02c38489)(@deepseek-ai/cordis-plugin-timer@1.1.3(@deepseek-ai/cordis@4.0.1))(@deepseek-ai/cordis@4.0.1)': + dependencies: + '@babel/code-frame': 7.29.7 + '@deepseek-ai/cordis': 4.0.1(@deepseek-ai/cordis-plugin-include@1.0.6)(@deepseek-ai/cordis-plugin-loader@1.0.2) + '@deepseek-ai/cordis-plugin-timer': 1.1.3(@deepseek-ai/cordis@4.0.1) + '@deepseek-ai/cosmokit': 1.8.2 + '@deepseek-ai/schemastery': 3.18.1 + chokidar: 4.0.3 + picomatch: 4.0.7 + + '@deepseek-ai/cordis-plugin-include@1.0.6(@deepseek-ai/cordis-plugin-loader@1.0.2)(@deepseek-ai/cordis@4.0.1)': + dependencies: + '@deepseek-ai/cordis': 4.0.1(@deepseek-ai/cordis-plugin-include@1.0.6)(@deepseek-ai/cordis-plugin-loader@1.0.2) + '@deepseek-ai/cordis-plugin-loader': 1.0.2(@deepseek-ai/cordis@4.0.1) + '@deepseek-ai/cosmokit': 1.8.2 + js-yaml: 4.3.2 + + '@deepseek-ai/cordis-plugin-loader@1.0.2(@deepseek-ai/cordis@4.0.1)': + dependencies: + '@deepseek-ai/cordis': 4.0.1(@deepseek-ai/cordis-plugin-include@1.0.6)(@deepseek-ai/cordis-plugin-loader@1.0.2) + '@deepseek-ai/cosmokit': 1.8.2 + + '@deepseek-ai/cordis-plugin-timer@1.1.3(@deepseek-ai/cordis@4.0.1)': + dependencies: + '@deepseek-ai/cordis': 4.0.1(@deepseek-ai/cordis-plugin-include@1.0.6)(@deepseek-ai/cordis-plugin-loader@1.0.2) + '@deepseek-ai/cosmokit': 1.8.2 + + '@deepseek-ai/cordis@4.0.1(@deepseek-ai/cordis-plugin-include@1.0.6)(@deepseek-ai/cordis-plugin-loader@1.0.2)': + dependencies: + '@deepseek-ai/cosmokit': 1.8.2 + '@standard-schema/spec': 1.1.0 + optionalDependencies: + '@deepseek-ai/cordis-plugin-include': 1.0.6(@deepseek-ai/cordis-plugin-loader@1.0.2)(@deepseek-ai/cordis@4.0.1) + '@deepseek-ai/cordis-plugin-loader': 1.0.2(@deepseek-ai/cordis@4.0.1) + + '@deepseek-ai/cosmokit@1.8.2': {} + + '@deepseek-ai/schemastery@3.18.1': + dependencies: + '@deepseek-ai/cosmokit': 1.8.2 + '@standard-schema/spec': 1.1.0 + + '@modelcontextprotocol/client@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.1.1 + jose: 6.2.10 + pkce-challenge: 5.0.1 + zod: 4.5.4 + + '@modelcontextprotocol/core@2.0.0': + dependencies: + zod: 4.5.4 + + '@modelcontextprotocol/server@2.0.0': + dependencies: + '@modelcontextprotocol/core': 2.0.0 + zod: 4.5.4 + + '@standard-schema/spec@1.1.0': {} + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.6 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + argparse@2.0.1: {} + + chokidar@4.0.3: + dependencies: + readdirp: 4.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + eventsource-parser@3.1.1: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.1.1 + + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.6: {} + + isexe@2.0.0: {} + + jose@6.2.10: {} + + js-tokens@4.0.0: {} + + js-yaml@4.3.2: + dependencies: + argparse: 2.0.1 + + json-schema-traverse@1.0.0: {} + + path-key@3.1.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pkce-challenge@5.0.1: {} + + readdirp@4.1.2: {} + + require-from-string@2.0.2: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + zod@4.5.4: {} diff --git a/projects/mcp_cordis/pnpm-workspace.yaml b/projects/mcp_cordis/pnpm-workspace.yaml new file mode 100644 index 00000000..31ab8723 --- /dev/null +++ b/projects/mcp_cordis/pnpm-workspace.yaml @@ -0,0 +1,6 @@ +packages: + - . + +autoInstallPeers: false + +onlyBuiltDependencies: [] diff --git a/projects/mcp_cordis/test/exec_test.mjs b/projects/mcp_cordis/test/exec_test.mjs new file mode 100644 index 00000000..b698ac83 --- /dev/null +++ b/projects/mcp_cordis/test/exec_test.mjs @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import { join } from "node:path"; +import process from "node:process"; +import test from "node:test"; + +import { + ProcessSupervisor, + waitForProcessGroup, +} from "../internal/process_supervisor.mjs"; + +const temporaryRoot = process.env.TEST_TMPDIR; +assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + +async function processIsLive(pid) { + try { + const stat = await readFile(`/proc/${pid}/stat`, "utf8"); + const state = stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/u)[0]; + return state !== "Z" && state !== "X"; + } catch (error) { + if (error?.code === "ENOENT") return false; + throw error; + } +} + +function execute(supervisor, program, overrides = {}) { + return supervisor.execute({ + file: process.execPath, + args: ["-e", program], + options: { + cwd: process.cwd(), + env: { ...process.env }, + maxBytes: 1024, + timeoutMs: 5_000, + allowTruncatedOutput: false, + ...overrides, + }, + }); +} + +test("process supervisor returns bounded stdout, stderr, and status", async () => { + const supervisor = new ProcessSupervisor(); + const result = await execute( + supervisor, + "process.stdout.write('ok'); process.stderr.write('warn'); " + + "process.exitCode = 7;", + ); + assert.deepEqual(result, { + code: 7, + signal: null, + stdout: "ok", + stderr: "warn", + truncated: false, + outputLimitExceeded: false, + }); + await supervisor.close(); +}); + +test("process supervisor rejects excess output by default", async () => { + const supervisor = new ProcessSupervisor(); + await assert.rejects( + execute(supervisor, "process.stdout.write('abcdefgh');", { + maxBytes: 4, + }), + (error) => error.code === "EXEC_OUTPUT_LIMIT", + ); + await supervisor.close(); +}); + +test("process supervisor can return a valid UTF-8 truncated prefix", async () => { + const supervisor = new ProcessSupervisor(); + const result = await execute( + supervisor, + "process.stdout.write('a€tail');", + { maxBytes: 3, allowTruncatedOutput: true }, + ); + assert.equal(result.stdout, "a"); + assert.equal(result.truncated, true); + assert.equal(result.outputLimitExceeded, true); + await supervisor.close(); +}); + +test("process supervisor times out and closes its process group", async () => { + const supervisor = new ProcessSupervisor(); + const directory = await mkdtemp(join(temporaryRoot, "exec-group-")); + const pidFile = join(directory, "pids"); + const program = ` + const { spawn } = require("node:child_process"); + const { writeFileSync } = require("node:fs"); + const descendant = spawn(process.execPath, [ + "-e", "setInterval(() => {}, 1000)", + ], { stdio: "ignore" }); + writeFileSync(${JSON.stringify(pidFile)}, + process.pid + " " + descendant.pid); + setInterval(() => {}, 1000); + `; + await assert.rejects( + execute(supervisor, program, { + timeoutMs: 5_000, + }), + (error) => error.code === "EXEC_TIMEOUT", + ); + const pids = (await readFile(pidFile, "utf8")) + .trim() + .split(" ") + .map(Number); + assert.equal(pids.length, 2); + for (const pid of pids) assert.equal(await processIsLive(pid), false); + + const pending = execute( + supervisor, + "setInterval(() => {}, 1000);", + { timeoutMs: 5_000 }, + ); + await new Promise((resolve) => setTimeout(resolve, 25)); + const disposed = Object.assign(new Error("disposed"), { + code: "EXEC_DISPOSED", + }); + await supervisor.close(disposed); + await assert.rejects(pending, (error) => error.code === "EXEC_DISPOSED"); + await assert.rejects( + execute(supervisor, "", {}), + (error) => error.code === "EXEC_DISPOSED", + ); +}); + +test("process-group verification bounds permanent proc failures", async () => { + const failure = Object.assign(new Error("proc unavailable"), { + code: "ENOENT", + }); + let attempts = 0; + const error = await waitForProcessGroup( + 1, + { exitCode: 0, pid: 0, signalCode: null }, + async () => { + attempts += 1; + throw failure; + }, + ); + + assert.equal(attempts, 3); + assert.equal(error.code, "EXEC_CLEANUP"); + assert.match(error.message, /proc unavailable/); +}); diff --git a/projects/mcp_cordis/test/git_worktree_status_test.mjs b/projects/mcp_cordis/test/git_worktree_status_test.mjs new file mode 100644 index 00000000..06da01cb --- /dev/null +++ b/projects/mcp_cordis/test/git_worktree_status_test.mjs @@ -0,0 +1,848 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp } from "node:fs/promises"; +import { join, resolve } from "node:path"; +import process from "node:process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import plugin from "../plugins/git_worktree.mjs"; + +const workspaceRoot = fileURLToPath(new URL( + "../plugins/", + import.meta.url, +)); +const objectHash = "0".repeat(40); +const temporaryRoot = process.env.TEST_TMPDIR; +assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + +const fixtures = [ + { + name: "ordinary", + entries: [ + { + records: [ + `1 .M N... 100644 100644 100644 ${objectHash} ` + + `${objectHash} ordinary-one`, + ], + expected: { + kind: "ordinary", + status: ".M", + path: "ordinary-one", + }, + }, + { + records: [ + `1 M. N... 100644 100644 100644 ${objectHash} ` + + `${objectHash} ordinary-two`, + ], + expected: { + kind: "ordinary", + status: "M.", + path: "ordinary-two", + }, + }, + ], + }, + { + name: "rename and copy", + entries: [ + { + records: [ + `2 R. N... 100644 100644 100644 ${objectHash} ` + + `${objectHash} R100 renamed-one`, + "original-one", + ], + expected: { + kind: "rename_or_copy", + status: "R.", + path: "renamed-one", + originalPath: "original-one", + }, + }, + { + records: [ + `2 C. N... 100644 100644 100644 ${objectHash} ` + + `${objectHash} C100 copied-two`, + "original-two", + ], + expected: { + kind: "rename_or_copy", + status: "C.", + path: "copied-two", + originalPath: "original-two", + }, + }, + ], + }, + { + name: "unmerged", + entries: [ + { + records: [ + `u UU N... 100644 100644 100644 100644 ` + + `${objectHash} ${objectHash} ${objectHash} ` + + "unmerged-one", + ], + expected: { + kind: "unmerged", + status: "UU", + path: "unmerged-one", + }, + }, + { + records: [ + `u AA N... 100644 100644 100644 100644 ` + + `${objectHash} ${objectHash} ${objectHash} ` + + "unmerged-two", + ], + expected: { + kind: "unmerged", + status: "AA", + path: "unmerged-two", + }, + }, + ], + }, + { + name: "untracked", + entries: [ + { + records: ["? untracked-one"], + expected: { + kind: "untracked", + status: "?", + path: "untracked-one", + }, + }, + { + records: ["? untracked-two"], + expected: { + kind: "untracked", + status: "?", + path: "untracked-two", + }, + }, + ], + }, + { + name: "ignored", + entries: [ + { + records: ["! ignored-one"], + expected: { + kind: "ignored", + status: "!", + path: "ignored-one", + }, + }, + { + records: ["! ignored-two"], + expected: { + kind: "ignored", + status: "!", + path: "ignored-two", + }, + }, + ], + }, +]; + +function commandResult(stdout = "", options = {}) { + return { + code: 0, + outputLimitExceeded: false, + signal: null, + stderr: "", + stdout, + truncated: false, + ...options, + }; +} + +function outputLimited(stdout = "", stderr = "") { + return commandResult(stdout, { + code: null, + outputLimitExceeded: true, + signal: "SIGTERM", + stderr, + truncated: true, + }); +} + +function plain(value) { + return JSON.parse(JSON.stringify(value)); +} + +function statusOutput(entries) { + const records = [ + `# branch.oid ${objectHash}`, + "# branch.head main", + ...entries.flatMap((entry) => entry.records), + ]; + return `${records.join("\0")}\0`; +} + +function logRecord({ + hash = objectHash, + parents = "", + author = "Example Author", + authoredAt = "2026-08-30T00:00:00Z", + subject = "subject", +} = {}) { + return [hash, parents, author, authoredAt, subject].join("\0") + "\0"; +} + +function loadHandlers(execute, calls = [], selectedRoot = workspaceRoot) { + const handlers = new Map(); + plugin.apply({ + exec: async (file, args, options) => { + assert.equal(file, "git"); + calls.push({ args: [...args], options: { ...options } }); + return execute(args, options); + }, + resolveWorkspace: (relativePath) => { + return resolve(selectedRoot, relativePath); + }, + tool: (definition, handler) => { + handlers.set(definition.name, handler); + }, + }); + handlers.calls = calls; + return handlers; +} + +function loadSnapshotHandler(output, statusOptions = {}) { + return loadHandlers((args) => { + if (args.includes("--show-toplevel")) { + return commandResult(`${workspaceRoot}\n`); + } + if (args.includes("status")) { + return commandResult(output, statusOptions); + } + throw new Error(`unexpected Git invocation: ${args.join(" ")}`); + }).get("git_snapshot"); +} + +function snapshotRequest(maxChanges) { + return { + repo: ".", + log_limit: 0, + max_changes: maxChanges, + include_diff_stat: false, + }; +} + +test("git_snapshot bounds every porcelain-v2 record kind", async (t) => { + for (const fixture of fixtures) { + await t.test(fixture.name, async () => { + const expected = fixture.entries.map((entry) => entry.expected); + const handler = loadSnapshotHandler(statusOutput(fixture.entries)); + + const limited = await handler(snapshotRequest(1)); + assert.deepEqual(plain(limited.changes), expected.slice(0, 1)); + assert.equal(limited.changesTruncated, true); + assert.equal(limited.statusTruncated, true); + assert.equal(limited.branchTruncated, false); + + const complete = await handler(snapshotRequest(2)); + assert.deepEqual(plain(complete.changes), expected); + assert.equal(complete.changesTruncated, false); + assert.equal(complete.statusTruncated, false); + assert.equal(complete.branchTruncated, false); + + const exact = await loadSnapshotHandler(statusOutput( + fixture.entries.slice(0, 1), + ))(snapshotRequest(1)); + assert.deepEqual(plain(exact.changes), expected.slice(0, 1)); + assert.equal(exact.changesTruncated, false); + assert.equal(exact.statusTruncated, false); + assert.equal(exact.branchTruncated, false); + }); + } +}); + +test("git_snapshot preserves newlines in NUL-delimited paths", async () => { + const entries = [ + { + records: [ + `1 .M N... 100644 100644 100644 ${objectHash} ` + + `${objectHash} ordinary\npath`, + ], + expected: { + kind: "ordinary", + status: ".M", + path: "ordinary\npath", + }, + }, + { + records: [ + `2 R. N... 100644 100644 100644 ${objectHash} ` + + `${objectHash} R100 renamed\npath`, + "original\npath", + ], + expected: { + kind: "rename_or_copy", + status: "R.", + path: "renamed\npath", + originalPath: "original\npath", + }, + }, + { + records: ["? untracked\npath"], + expected: { + kind: "untracked", + status: "?", + path: "untracked\npath", + }, + }, + ]; + const snapshot = await loadSnapshotHandler(statusOutput(entries))( + snapshotRequest(entries.length), + ); + + assert.deepEqual( + plain(snapshot.changes), + entries.map((entry) => entry.expected), + ); + assert.equal(snapshot.changesTruncated, false); +}); + +test( + "git_snapshot reports host truncation and drops partial records", + async () => { + const completeEntry = fixtures[0].entries[0]; + const incompleteRename = + `2 R. N... 100644 100644 100644 ${objectHash} ` + + `${objectHash} R100 missing-original`; + const status = statusOutput([completeEntry]) + + `${incompleteRename}\0`; + const completeCommit = [ + objectHash, + "", + "Example Author", + "2026-08-30T00:00:00Z", + "complete subject", + ].join("\0") + "\0"; + const handlers = loadHandlers((args) => { + if (args.includes("--show-toplevel")) { + return commandResult(`${workspaceRoot}\n`); + } + if (args.includes("status")) { + return outputLimited(status); + } + if (args.includes("log")) { + return outputLimited(`${completeCommit}partial commit`); + } + if (args.includes("--stat=120,80")) { + if (args.includes("--cached")) { + return commandResult("staged complete\n"); + } + return outputLimited("working complete\npartial"); + } + throw new Error(`unexpected Git invocation: ${args.join(" ")}`); + }); + const snapshot = await handlers.get("git_snapshot")({ + repo: ".", + log_limit: 2, + max_changes: 10, + include_diff_stat: true, + }); + + assert.deepEqual(plain(snapshot.changes), [completeEntry.expected]); + assert.equal(snapshot.changesTruncated, true); + assert.equal(snapshot.statusTruncated, true); + assert.equal(snapshot.branchTruncated, true); + assert.equal(snapshot.commits.length, 1); + assert.equal(snapshot.commits[0].subject, "complete subject"); + assert.equal(snapshot.historyAvailable, true); + assert.equal(snapshot.historyTruncated, true); + assert.equal(snapshot.diffStat.working, "working complete\npartial"); + assert.equal(snapshot.diffStat.workingTruncated, true); + assert.equal(snapshot.diffStat.staged, "staged complete\n"); + assert.equal(snapshot.diffStat.stagedTruncated, false); + }, +); + +test("git_snapshot rejects truncated repository discovery", async () => { + const handler = loadHandlers((args) => { + assert.ok(args.includes("--show-toplevel")); + return outputLimited(workspaceRoot); + }).get("git_snapshot"); + + await assert.rejects( + handler(snapshotRequest(1)), + /repository discovery output was truncated/, + ); +}); + +function loadCompareHandler(namesResult, diffResult, revisionOptions = {}) { + return loadHandlers((args) => { + if (args.includes("--show-toplevel")) { + return commandResult(`${workspaceRoot}\n`); + } + if (args.includes("--verify")) { + const label = args.at(-1).startsWith("base") ? "base" : "head"; + return commandResult(`${objectHash}\n`, revisionOptions[label]); + } + if (args.includes("--name-status")) { + return namesResult; + } + if (args.includes("--no-color")) { + return diffResult; + } + throw new Error(`unexpected Git invocation: ${args.join(" ")}`); + }).get("git_compare"); +} + +function compareRequest() { + return { + repo: ".", + base: "base", + head: "head", + max_bytes: 1024, + }; +} + +test("git_compare rejects truncated revision discovery", async () => { + const handler = loadCompareHandler( + commandResult(""), + commandResult(""), + { + base: { + code: null, + outputLimitExceeded: true, + signal: "SIGTERM", + truncated: true, + }, + }, + ); + + await assert.rejects( + handler(compareRequest()), + /output was truncated while resolving base/, + ); +}); + +test("git_compare separates files and diff truncation", async (t) => { + await t.test("host-truncated name status", async () => { + const names = [ + "M", + "complete.txt", + "R100", + "renamed.txt", + ].join("\0") + "\0partial-original"; + const comparison = await loadCompareHandler( + outputLimited(names), + commandResult("complete diff\n"), + )(compareRequest()); + + assert.deepEqual(plain(comparison.files), [ + { status: "M", path: "complete.txt" }, + ]); + assert.equal(comparison.filesTruncated, true); + assert.equal(comparison.diffTruncated, false); + assert.equal(comparison.truncated, true); + }); + + await t.test("host-truncated diff", async () => { + const comparison = await loadCompareHandler( + commandResult("M\0complete.txt\0"), + outputLimited("partial diff"), + )(compareRequest()); + + assert.equal(comparison.filesTruncated, false); + assert.equal(comparison.diffTruncated, true); + assert.equal(comparison.truncated, true); + }); + + await t.test("complete outputs", async () => { + const comparison = await loadCompareHandler( + commandResult("M\0complete.txt\0"), + commandResult("complete diff\n"), + )(compareRequest()); + + assert.equal(comparison.filesTruncated, false); + assert.equal(comparison.diffTruncated, false); + assert.equal(comparison.truncated, false); + }); +}); + +test("git_snapshot frames history independently of RS and US", async () => { + const subject = `legal ${"\x1e"} record and ${"\x1f"} unit separators`; + const handlers = loadHandlers((args) => { + if (args.includes("--show-toplevel")) { + return commandResult(`${workspaceRoot}\n`); + } + if (args.includes("status")) return commandResult(statusOutput([])); + if (args.includes("log")) { + assert.ok(args.includes("--max-count=2")); + assert.ok(args.includes("--no-show-signature")); + return commandResult(logRecord({ subject })); + } + throw new Error(`unexpected Git invocation: ${args.join(" ")}`); + }); + const snapshot = await handlers.get("git_snapshot")({ + ...snapshotRequest(1), + log_limit: 1, + }); + + assert.equal(snapshot.commits.length, 1); + assert.equal(snapshot.commits[0].subject, subject); + assert.equal(snapshot.historyAvailable, true); + assert.equal(snapshot.historyTruncated, false); +}); + +test("git_snapshot reports every history truncation cause", async (t) => { + async function snapshotFor(logOutput, limit = 1) { + const handlers = loadHandlers((args) => { + if (args.includes("--show-toplevel")) { + return commandResult(`${workspaceRoot}\n`); + } + if (args.includes("status")) { + return commandResult(statusOutput([])); + } + if (args.includes("log")) return commandResult(logOutput); + throw new Error(`unexpected Git invocation: ${args.join(" ")}`); + }); + return handlers.get("git_snapshot")({ + ...snapshotRequest(1), + log_limit: limit, + }); + } + + await t.test("an observed extra commit", async () => { + const snapshot = await snapshotFor( + logRecord() + logRecord({ hash: "1".repeat(40) }), + ); + assert.equal(snapshot.commits.length, 1); + assert.equal(snapshot.historyTruncated, true); + }); + + await t.test("locally clipped author and subject", async () => { + const snapshot = await snapshotFor(logRecord({ + author: "a".repeat(513), + subject: "s".repeat(4097), + })); + assert.equal(Buffer.byteLength(snapshot.commits[0].author), 512); + assert.equal(Buffer.byteLength(snapshot.commits[0].subject), 4096); + assert.equal(snapshot.historyTruncated, true); + }); + + await t.test("a malformed field group", async () => { + const snapshot = await snapshotFor(logRecord() + "bad\0group\0"); + assert.equal(snapshot.commits.length, 1); + assert.equal(snapshot.historyTruncated, true); + }); +}); + +test( + "git_snapshot distinguishes nonzero history from complete history", + async () => { + const handlers = loadHandlers((args) => { + if (args.includes("--show-toplevel")) { + return commandResult(`${workspaceRoot}\n`); + } + if (args.includes("status")) { + return commandResult(statusOutput([])); + } + if (args.includes("log")) { + return commandResult("", { code: 128, stderr: "no history" }); + } + throw new Error(`unexpected Git invocation: ${args.join(" ")}`); + }); + const snapshot = await handlers.get("git_snapshot")({ + ...snapshotRequest(1), + log_limit: 1, + }); + + assert.deepEqual(plain(snapshot.commits), []); + assert.equal(snapshot.historyAvailable, false); + assert.equal(snapshot.historyTruncated, false); + assert.equal(snapshot.historyError, "no history"); + assert.equal(snapshot.historyErrorTruncated, false); + }, +); + +test("git_snapshot marks a clipped history error", async () => { + const handlers = loadHandlers((args) => { + if (args.includes("--show-toplevel")) { + return commandResult(`${workspaceRoot}\n`); + } + if (args.includes("status")) return commandResult(statusOutput([])); + if (args.includes("log")) { + return commandResult("", { + code: 128, + stderr: "e".repeat(5000), + }); + } + throw new Error(`unexpected Git invocation: ${args.join(" ")}`); + }); + const snapshot = await handlers.get("git_snapshot")({ + ...snapshotRequest(1), + log_limit: 1, + }); + + assert.equal(Buffer.byteLength(snapshot.historyError), 4096); + assert.equal(snapshot.historyErrorTruncated, true); +}); + +test("git_snapshot rejects both diff-stat command failures", async (t) => { + for (const failed of ["working", "staged"]) { + await t.test(failed, async () => { + const handlers = loadHandlers((args) => { + if (args.includes("--show-toplevel")) { + return commandResult(`${workspaceRoot}\n`); + } + if (args.includes("status")) { + return commandResult(statusOutput([])); + } + if (args.includes("--stat=120,80")) { + const staged = args.includes("--cached"); + if ((failed === "staged") === staged) { + return commandResult("", { + code: 2, + stderr: `${failed} failed`, + }); + } + return commandResult(""); + } + throw new Error( + `unexpected Git invocation: ${args.join(" ")}`, + ); + }); + await assert.rejects( + handlers.get("git_snapshot")({ + ...snapshotRequest(1), + include_diff_stat: true, + }), + new RegExp(`${failed} failed`), + ); + }); + } +}); + +test("git tools reject unexpected signal terminations", async (t) => { + const signalResult = commandResult("partial", { + code: null, + signal: "SIGTERM", + }); + const snapshotCases = ["status", "log", "working stat", "staged stat"]; + for (const failed of snapshotCases) { + await t.test(failed, async () => { + const handlers = loadHandlers((args) => { + if (args.includes("--show-toplevel")) { + return commandResult(`${workspaceRoot}\n`); + } + if (args.includes("status")) { + return failed === "status" + ? signalResult + : commandResult(statusOutput([])); + } + if (args.includes("log")) { + return failed === "log" + ? signalResult + : commandResult(logRecord()); + } + if (args.includes("--stat=120,80")) { + const kind = args.includes("--cached") + ? "staged stat" + : "working stat"; + return failed === kind ? signalResult : commandResult(""); + } + throw new Error( + `unexpected Git invocation: ${args.join(" ")}`, + ); + }); + await assert.rejects( + handlers.get("git_snapshot")({ + ...snapshotRequest(1), + log_limit: failed === "status" ? 0 : 1, + include_diff_stat: failed.includes("stat"), + }), + /terminated by SIGTERM/, + ); + }); + } + + for (const failed of ["names", "diff"]) { + await t.test(failed, async () => { + const handler = loadCompareHandler( + failed === "names" + ? signalResult + : commandResult("M\0file\0"), + failed === "diff" + ? signalResult + : commandResult("diff\n"), + ); + await assert.rejects(handler(compareRequest()), /SIGTERM/); + }); + } +}); + +test("git tools reject unmarked incomplete output", async () => { + const handler = loadSnapshotHandler(statusOutput([]), { + truncated: true, + }); + await assert.rejects( + handler(snapshotRequest(1)), + /without an output-limit marker/, + ); +}); + +test("git_snapshot discards an unframed status record", async () => { + const complete = statusOutput([]); + const partial = `? partial-path`; + const snapshot = await loadSnapshotHandler(complete + partial)( + snapshotRequest(2), + ); + + assert.deepEqual(plain(snapshot.changes), []); + assert.equal(snapshot.changesTruncated, true); + assert.equal(snapshot.statusTruncated, true); + assert.equal(snapshot.branchTruncated, true); +}); + +test("git tools reject ordinary command failures", async (t) => { + await t.test("status", async () => { + const handler = loadSnapshotHandler("", { + code: 2, + stderr: "status failed", + }); + await assert.rejects(handler(snapshotRequest(1)), /status failed/); + }); + + for (const failed of ["names", "diff"]) { + await t.test(failed, async () => { + const failure = commandResult("", { + code: 2, + stderr: `${failed} failed`, + }); + const handler = loadCompareHandler( + failed === "names" ? failure : commandResult(""), + failed === "diff" ? failure : commandResult(""), + ); + await assert.rejects( + handler(compareRequest()), + new RegExp(`${failed} failed`), + ); + }); + } +}); + +test( + "Git commands use read-only options and verified directory handles", + async () => { + const calls = []; + const handlers = loadHandlers((args) => { + if (args.includes("--show-toplevel")) { + return commandResult(`${workspaceRoot}\n`); + } + if (args.includes("--verify")) { + return commandResult(`${objectHash}\n`); + } + if (args.includes("status")) return commandResult(statusOutput([])); + if (args.includes("log")) return commandResult(logRecord()); + if (args.includes("--name-status")) return commandResult(""); + if (args.includes("diff")) return commandResult(""); + throw new Error(`unexpected Git invocation: ${args.join(" ")}`); + }, calls); + + await handlers.get("git_snapshot")({ + ...snapshotRequest(1), + log_limit: 1, + include_diff_stat: true, + }); + await handlers.get("git_compare")(compareRequest()); + + assert.ok(calls.length >= 9); + for (const call of calls) { + assert.match( + call.args[1], + new RegExp(`^/proc/${process.pid}/fd/\\d+$`), + ); + assert.equal(call.options.allowTruncatedOutput, true); + assert.equal(call.options.env.GIT_OPTIONAL_LOCKS, "0"); + assert.equal(call.options.env.GIT_NO_LAZY_FETCH, "1"); + assert.equal(call.options.env.GIT_TERMINAL_PROMPT, "0"); + for (const name of [ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_CONFIG", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_PARAMETERS", + "GIT_DIR", + "GIT_GRAFT_FILE", + "GIT_IMPLICIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_INTERNAL_SUPER_PREFIX", + "GIT_NAMESPACE", + "GIT_NO_REPLACE_OBJECTS", + "GIT_OBJECT_DIRECTORY", + "GIT_PREFIX", + "GIT_REPLACE_REF_BASE", + "GIT_SHALLOW_FILE", + "GIT_WORK_TREE", + ]) { + assert.equal(call.options.env[name], null); + } + } + }, +); + +test("containment accepts '..config' but rejects parent escapes", async () => { + const root = await mkdtemp(join(temporaryRoot, "git-containment-")); + const legalRepo = resolve(root, "..config"); + await mkdir(legalRepo); + const snapshotHandlers = loadHandlers((args) => { + if (args.includes("--show-toplevel")) { + return commandResult(`${legalRepo}\n`); + } + if (args.includes("status")) return commandResult(statusOutput([])); + throw new Error(`unexpected Git invocation: ${args.join(" ")}`); + }, [], root); + + const legalSnapshot = await snapshotHandlers.get("git_snapshot")({ + ...snapshotRequest(1), + repo: "..config", + }); + assert.equal(legalSnapshot.repo, "..config"); + + const compareHandlers = loadHandlers((args) => { + if (args.includes("--show-toplevel")) { + return commandResult(`${root}\n`); + } + if (args.includes("--verify")) { + return commandResult(`${objectHash}\n`); + } + if (args.includes("--name-status")) return commandResult(""); + if (args.includes("--no-color")) return commandResult(""); + throw new Error(`unexpected Git invocation: ${args.join(" ")}`); + }, [], root); + + const legalCompare = await compareHandlers.get("git_compare")({ + ...compareRequest(), + paths: ["..config/file"], + }); + assert.deepEqual(plain(legalCompare.paths), ["..config/file"]); + + await assert.rejects( + compareHandlers.get("git_compare")({ + ...compareRequest(), + paths: ["../outside"], + }), + /outside the repo/, + ); + + const outside = await mkdtemp(join(temporaryRoot, "git-outside-")); + await mkdir(resolve(root, "inside")); + const outsideHandlers = loadHandlers((args) => { + assert.ok(args.includes("--show-toplevel")); + return commandResult(`${outside}\n`); + }, [], root); + await assert.rejects( + outsideHandlers.get("git_snapshot")({ + ...snapshotRequest(1), + repo: "inside", + }), + /outside the workspace/, + ); +}); diff --git a/projects/mcp_cordis/test/repo_context_truncation_test.mjs b/projects/mcp_cordis/test/repo_context_truncation_test.mjs new file mode 100644 index 00000000..76c95d07 --- /dev/null +++ b/projects/mcp_cordis/test/repo_context_truncation_test.mjs @@ -0,0 +1,839 @@ +import assert from "node:assert/strict"; +import { + mkdir, + mkdtemp, + readFile, + symlink, + writeFile, +} from "node:fs/promises"; +import { join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import plugin from "../plugins/repo_context.mjs"; + +const workspaceRoot = fileURLToPath(new URL( + "../plugins/", + import.meta.url, +)); +const temporaryRoot = process.env.TEST_TMPDIR; +const objectHash = "0123456789abcdef".repeat(2) + "01234567"; +const normalStatus = "## main\n M fixture.txt\n"; + +function execution(stdout, overrides = {}) { + return { + code: 0, + outputLimitExceeded: false, + signal: null, + stderr: "", + stdout, + truncated: false, + ...overrides, + }; +} + +function outputLimited(stdout = "", stderr = "") { + return execution(stdout, { + code: null, + outputLimitExceeded: true, + signal: "SIGTERM", + stderr, + truncated: true, + }); +} + +function loadHandlers(overrides = {}, selectedRoot = workspaceRoot) { + const handlers = new Map(); + const calls = []; + const results = { + root: execution(`${selectedRoot}\n`), + head: execution(`${objectHash}\n`), + status: execution(normalStatus), + rg: execution(""), + ...overrides, + }; + plugin.apply({ + exec: async (file, args, options) => { + calls.push({ file, args: [...args], options: { ...options } }); + if (file === "rg") { + if (results.rg instanceof Error) throw results.rg; + return results.rg; + } + assert.equal(file, "git"); + if (args.includes("--show-toplevel")) return results.root; + if (args.includes("--verify")) return results.head; + if (args.includes("status")) return results.status; + throw new Error(`unexpected Git invocation: ${args.join(" ")}`); + }, + resolveWorkspace: (relativePath) => { + return resolve(selectedRoot, relativePath); + }, + readText: overrides.readText ?? ((relativePath) => { + return readFile(resolve(selectedRoot, relativePath), "utf8"); + }), + tool: (definition, handler) => { + handlers.set(definition.name, handler); + }, + }); + return { calls, handlers }; +} + +async function getContext( + overrides = {}, + maxBytes = 1024, + selectedRoot = workspaceRoot, +) { + const loaded = loadHandlers(overrides, selectedRoot); + const handler = loaded.handlers.get("repo_context_get"); + const value = await handler({ + path: ".", + include_instructions: false, + max_bytes: maxBytes, + }); + return { ...loaded, value }; +} + +function rgMatch(line, text = "needle") { + return JSON.stringify({ + type: "match", + data: { + path: { text: "fixture.txt" }, + line_number: line, + lines: { text: `${text}\n` }, + submatches: [{ + start: 0, + end: text.length, + match: { text }, + }], + }, + }); +} + +function rgBytesMatch(field) { + const event = JSON.parse(rgMatch(1)); + const encoded = Buffer.from("non-utf8\xff", "latin1").toString("base64"); + if (field === "path") event.data.path = { bytes: encoded }; + if (field === "lines") event.data.lines = { bytes: encoded }; + if (field === "submatch") { + event.data.submatches[0].match = { bytes: encoded }; + } + return JSON.stringify(event); +} + +test("repo_context_get rejects truncated Git root discovery", async () => { + const root = outputLimited(`${workspaceRoot}\n`); + const { calls, value } = await getContext({ root }); + + assert.equal(value.git.rootTruncated, true); + assert.match(value.git.error, /root discovery output was truncated/i); + assert.equal("root" in value.git, false); + assert.equal(calls.length, 1); +}); + +test( + "repo_context_get never reports a truncated HEAD as complete", + async () => { + const head = outputLimited(objectHash.slice(0, 20)); + const { value } = await getContext({ head }); + + assert.equal(value.git.root, "."); + assert.equal(value.git.head, null); + assert.equal(value.git.headTruncated, true); + assert.equal(value.git.statusTruncated, false); + }, +); + +test("repo_context_get propagates host status truncation", async () => { + const status = outputLimited(normalStatus); + const { value } = await getContext({ status }); + + assert.equal(value.git.status, normalStatus); + assert.equal(value.git.statusTruncated, true); +}); + +test("repo_context_get reports local status clipping", async () => { + const status = execution("x".repeat(1500)); + const { value } = await getContext({ status }); + + assert.equal(Buffer.byteLength(value.git.status, "utf8"), 1024); + assert.equal(value.git.statusTruncated, true); +}); + +test("repo_context_get marks complete Git results explicitly", async () => { + const { value } = await getContext(); + + assert.equal(value.git.root, "."); + assert.equal(value.git.rootTruncated, false); + assert.equal(value.git.head, objectHash); + assert.equal(value.git.headTruncated, false); + assert.equal(value.git.status, normalStatus); + assert.equal(value.git.statusTruncated, false); +}); + +test("repo_context_search preserves ripgrep truncation", async () => { + const rg = outputLimited(""); + const { handlers } = loadHandlers({ rg }); + const value = await handlers.get("repo_context_search")({ + query: "needle", + }); + + assert.equal(value.engine, "ripgrep"); + assert.equal(value.truncated, true); +}); + +test("repo_context_get distinguishes nonzero Git results", async (t) => { + await t.test("root means no repository", async () => { + const root = execution("", { code: 128, stderr: "not a repo" }); + const { value } = await getContext({ root }); + assert.equal(value.git, null); + }); + + await t.test("HEAD is explicitly unavailable", async () => { + const head = execution("", { code: 128, stderr: "unborn" }); + const { value } = await getContext({ head }); + assert.equal(value.git.head, null); + assert.equal(value.git.headAvailable, false); + assert.equal(value.git.headTruncated, false); + assert.equal(value.git.headError, "unborn"); + }); + + await t.test("status is explicitly unavailable", async () => { + const status = execution("", { code: 2, stderr: "status failed" }); + const { value } = await getContext({ status }); + assert.equal(value.git.status, null); + assert.equal(value.git.statusAvailable, false); + assert.equal(value.git.statusTruncated, false); + assert.equal(value.git.statusError, "status failed"); + }); +}); + +test("repo_context rejects unexpected process signals", async (t) => { + const signaled = execution("partial", { + code: null, + signal: "SIGTERM", + }); + for (const command of ["root", "head", "status", "rg"]) { + await t.test(command, async () => { + if (command === "rg") { + const { handlers } = loadHandlers({ rg: signaled }); + await assert.rejects( + handlers.get("repo_context_search")({ query: "needle" }), + /terminated by SIGTERM/, + ); + return; + } + await assert.rejects( + getContext({ [command]: signaled }), + /terminated by SIGTERM/, + ); + }); + } +}); + +test("repo_context rejects unmarked incomplete output", async () => { + const root = execution(`${workspaceRoot}\n`, { truncated: true }); + await assert.rejects( + getContext({ root }), + /without an output-limit marker/, + ); +}); + +test("repo_context_search distinguishes ripgrep exits", async (t) => { + await t.test("exit one is a complete empty result", async () => { + const { handlers } = loadHandlers({ rg: execution("", { code: 1 }) }); + const value = await handlers.get("repo_context_search")({ + query: "needle", + }); + assert.equal(value.matchCount, 0); + assert.equal(value.truncated, false); + }); + + await t.test("other nonzero exits reject", async () => { + const { handlers } = loadHandlers({ + rg: execution("", { code: 2, stderr: "bad regex" }), + }); + await assert.rejects( + handlers.get("repo_context_search")({ query: "needle" }), + /ripgrep exited with 2: bad regex/, + ); + }); +}); + +test("ripgrep max_matches needs an observed extra match", async (t) => { + async function search(lines) { + const { handlers } = loadHandlers({ + rg: execution(`${lines.join("\n")}\n`), + }); + return handlers.get("repo_context_search")({ + query: "needle", + max_matches: 1, + }); + } + + await t.test("exactly one is complete", async () => { + const value = await search([rgMatch(1)]); + assert.equal(value.matchCount, 1); + assert.equal(value.matches.length, 1); + assert.equal(value.truncated, false); + }); + + await t.test("a second observed match truncates", async () => { + const value = await search([rgMatch(1), rgMatch(2)]); + assert.equal(value.matchCount, 1); + assert.equal(value.matches.length, 1); + assert.equal(value.truncated, true); + }); +}); + +test("ripgrep byte fields never masquerade as complete text", async (t) => { + for (const field of ["path", "lines", "submatch"]) { + await t.test(field, async () => { + const { handlers } = loadHandlers({ + rg: execution(`${rgBytesMatch(field)}\n`), + }); + const value = await handlers.get("repo_context_search")({ + query: "needle", + }); + assert.equal(value.matchCount, 1); + assert.equal(value.truncated, true); + if (field === "path") { + assert.equal(value.matches[0].path, ""); + assert.equal(value.matches[0].pathTruncated, true); + } else if (field === "lines") { + assert.equal(value.matches[0].text, ""); + assert.equal(value.matches[0].textTruncated, true); + } else { + assert.equal(value.matches[0].submatches[0].text, ""); + assert.equal( + value.matches[0].submatches[0].textTruncated, + true, + ); + } + }); + } +}); + +test("ripgrep drops an incomplete output-limited JSON event", async () => { + const rg = outputLimited(`${rgMatch(1)}\n{"type":"match"`); + const { handlers } = loadHandlers({ rg }); + const value = await handlers.get("repo_context_search")({ + query: "needle", + max_matches: 5, + }); + + assert.equal(value.matchCount, 1); + assert.equal(value.matches.length, 1); + assert.equal(value.truncated, true); +}); + +test("JavaScript fallback uses exact max_matches semantics", async (t) => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const root = await mkdtemp(join(temporaryRoot, "repo-fallback-")); + await writeFile(join(root, "fixture.txt"), "needle\n"); + const missing = Object.assign(new Error("missing rg"), { code: "ENOENT" }); + + async function search(content) { + await writeFile(join(root, "fixture.txt"), content); + const loaded = loadHandlers({ rg: missing }, root); + return loaded.handlers.get("repo_context_search")({ + query: "needle", + paths: ["fixture.txt"], + max_matches: 1, + }); + } + + await t.test("exactly one is complete", async () => { + const value = await search("needle\n"); + assert.equal(value.engine, "javascript"); + assert.equal(value.matchCount, 1); + assert.equal(value.truncated, false); + }); + + await t.test("an observed second match truncates", async () => { + const value = await search("needle\nneedle\n"); + assert.equal(value.matchCount, 1); + assert.equal(value.truncated, true); + }); +}); + +test("JavaScript fallback reports locally clipped details", async (t) => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const root = await mkdtemp(join(temporaryRoot, "repo-fallback-")); + const missing = Object.assign(new Error("missing rg"), { code: "ENOENT" }); + + async function search(query, content) { + await writeFile(join(root, "fixture.txt"), content); + const loaded = loadHandlers({ rg: missing }, root); + return loaded.handlers.get("repo_context_search")({ + query, + paths: ["fixture.txt"], + max_matches: 2, + }); + } + + await t.test("a long submatch", async () => { + const query = "n".repeat(600); + const value = await search(query, `${query}\n`); + assert.equal(value.matches[0].submatches[0].text.length, 512); + assert.equal(value.truncated, true); + }); + + await t.test("a 33rd submatch", async () => { + const value = await search("n", `${"n".repeat(33)}\n`); + assert.equal(value.matches[0].submatches.length, 32); + assert.equal(value.truncated, true); + }); + + await t.test("a long matching line", async () => { + const value = await search("needle", `needle${"x".repeat(5000)}\n`); + assert.equal(Buffer.byteLength(value.matches[0].text), 4096); + assert.equal(value.matches[0].textTruncated, true); + assert.equal(value.truncated, true); + }); +}); + +test("JavaScript fallback reports UTF-8 byte offsets", async () => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const root = await mkdtemp(join(temporaryRoot, "repo-fallback-")); + await writeFile(join(root, "fixture.txt"), "éneedle\n"); + const missing = Object.assign(new Error("missing rg"), { code: "ENOENT" }); + const { handlers } = loadHandlers({ rg: missing }, root); + + const value = await handlers.get("repo_context_search")({ + query: "needle", + paths: ["fixture.txt"], + }); + assert.deepEqual( + value.matches[0].submatches[0], + { start: 2, end: 8, text: "needle" }, + ); +}); + +test("case-insensitive fallback keeps original-line offsets", async () => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const root = await mkdtemp(join(temporaryRoot, "repo-fallback-")); + await writeFile(join(root, "fixture.txt"), "İx\n"); + const missing = Object.assign(new Error("missing rg"), { code: "ENOENT" }); + const { handlers } = loadHandlers({ rg: missing }, root); + const value = await handlers.get("repo_context_search")({ + query: "x", + paths: ["fixture.txt"], + case_sensitive: false, + }); + + assert.deepEqual( + value.matches[0].submatches[0], + { start: 2, end: 3, text: "x" }, + ); +}); + +test("regular-expression fallback requires ripgrep", async () => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const root = await mkdtemp(join(temporaryRoot, "repo-fallback-")); + await writeFile(join(root, "fixture.txt"), "😀\n"); + const missing = Object.assign(new Error("missing rg"), { code: "ENOENT" }); + const { handlers } = loadHandlers({ rg: missing }, root); + await assert.rejects( + handlers.get("repo_context_search")({ + query: "(a+)+$", + paths: ["fixture.txt"], + fixed: false, + }), + /ripgrep is required for regular-expression searches/, + ); +}); + +test("invalid UTF-8 fallback requires ripgrep", async () => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const root = await mkdtemp(join(temporaryRoot, "repo-fallback-")); + await writeFile( + join(root, "fixture.txt"), + Buffer.concat([Buffer.from([0xff]), Buffer.from("needle")]), + ); + const missing = Object.assign(new Error("missing rg"), { code: "ENOENT" }); + const { handlers } = loadHandlers({ rg: missing }, root); + await assert.rejects( + handlers.get("repo_context_search")({ + query: "needle", + paths: ["fixture.txt"], + }), + /ripgrep is required to search files containing invalid UTF-8/, + ); +}); + +test("fallback context excludes matching lines and stays ordered", async () => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const root = await mkdtemp(join(temporaryRoot, "repo-fallback-")); + await writeFile(join(root, "fixture.txt"), "needle\nneedle\nafter\n"); + const missing = Object.assign(new Error("missing rg"), { code: "ENOENT" }); + const { handlers } = loadHandlers({ rg: missing }, root); + const value = await handlers.get("repo_context_search")({ + query: "needle", + paths: ["fixture.txt"], + context_lines: 1, + }); + + assert.deepEqual( + value.matches.map(({ kind, line }) => ({ kind, line })), + [ + { kind: "match", line: 1 }, + { kind: "match", line: 2 }, + { kind: "context", line: 3 }, + ], + ); +}); + +test("fallback context does not invent a line after final newline", async (t) => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const missing = Object.assign(new Error("missing rg"), { code: "ENOENT" }); + + await t.test("one newline ends the matching line", async () => { + const root = await mkdtemp(join(temporaryRoot, "repo-fallback-")); + await writeFile(join(root, "fixture.txt"), "needle\n"); + const { handlers } = loadHandlers({ rg: missing }, root); + const value = await handlers.get("repo_context_search")({ + query: "needle", + paths: ["fixture.txt"], + context_lines: 1, + }); + + assert.deepEqual( + value.matches.map(({ kind, line }) => ({ kind, line })), + [{ kind: "match", line: 1 }], + ); + }); + + await t.test("a second newline is a real empty line", async () => { + const root = await mkdtemp(join(temporaryRoot, "repo-fallback-")); + await writeFile(join(root, "fixture.txt"), "needle\n\n"); + const { handlers } = loadHandlers({ rg: missing }, root); + const value = await handlers.get("repo_context_search")({ + query: "needle", + paths: ["fixture.txt"], + context_lines: 1, + }); + + assert.deepEqual( + value.matches.map(({ kind, line, text }) => ({ kind, line, text })), + [ + { kind: "match", line: 1, text: "needle" }, + { kind: "context", line: 2, text: "" }, + ], + ); + }); +}); + +test("explicit fallback files override globs", async () => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const root = await mkdtemp(join(temporaryRoot, "repo-fallback-")); + await writeFile(join(root, "fixture.txt"), "needle\n"); + const missing = Object.assign(new Error("missing rg"), { code: "ENOENT" }); + const { handlers } = loadHandlers({ rg: missing }, root); + const value = await handlers.get("repo_context_search")({ + query: "needle", + paths: ["fixture.txt"], + globs: ["*.js"], + }); + + assert.equal(value.matchCount, 1); + assert.equal(value.matches[0].path, "fixture.txt"); +}); + +test("JavaScript fallback fails closed for directory recursion", async () => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const root = await mkdtemp(join(temporaryRoot, "repo-fallback-")); + await writeFile(join(root, ".env"), "needle=secret\n"); + const missing = Object.assign(new Error("missing rg"), { code: "ENOENT" }); + const { handlers } = loadHandlers({ rg: missing }, root); + + await assert.rejects( + handlers.get("repo_context_search")({ query: "needle" }), + /ripgrep is required to search directories/, + ); + const explicit = await handlers.get("repo_context_search")({ + query: "needle", + paths: [".env"], + }); + assert.equal(explicit.matchCount, 1); + assert.equal(explicit.matches[0].path, ".env"); +}); + +test("instruction truncation reports exact-byte omissions", async (t) => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + + await t.test( + "exact content without a later document is complete", + async () => { + const root = await mkdtemp( + join(temporaryRoot, "repo-instructions-"), + ); + await writeFile(join(root, "AGENTS.md"), "a".repeat(1024)); + const { handlers } = loadHandlers({}, root); + const value = await handlers.get("repo_context_get")({ + path: ".", + include_git: false, + max_bytes: 1024, + }); + assert.equal(value.instructions.length, 1); + assert.equal(value.instructions[0].truncated, false); + assert.equal(value.instructionsTruncated, false); + }, + ); + + await t.test("a later applicable document is reported", async () => { + const root = await mkdtemp(join(temporaryRoot, "repo-instructions-")); + await mkdir(join(root, "nested")); + await writeFile(join(root, "AGENTS.md"), "a".repeat(1024)); + await writeFile(join(root, "nested", "AGENTS.md"), "later\n"); + const { handlers } = loadHandlers({}, root); + const value = await handlers.get("repo_context_get")({ + path: "nested", + include_git: false, + max_bytes: 1024, + }); + assert.equal(value.instructions.length, 1); + assert.equal(value.instructions[0].path, "AGENTS.md"); + assert.equal(value.instructionsTruncated, true); + }); +}); + +test("directory entry truncation observes the 129th entry", async (t) => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + + async function contextWithEntries(count) { + const root = await mkdtemp(join(temporaryRoot, "repo-entries-")); + await Promise.all(Array.from({ length: count }, (_, index) => { + const name = `entry-${String(index).padStart(3, "0")}`; + return writeFile(join(root, name), ""); + })); + const { handlers } = loadHandlers({}, root); + return handlers.get("repo_context_get")({ + path: ".", + include_git: false, + include_instructions: false, + max_bytes: 1024, + }); + } + + await t.test("128 entries are complete", async () => { + const value = await contextWithEntries(128); + assert.equal(value.entries.length, 128); + assert.equal(value.entriesTruncated, false); + }); + + await t.test("129 entries are truncated", async () => { + const value = await contextWithEntries(129); + assert.equal(value.entries.length, 128); + assert.equal(value.entriesTruncated, true); + }); +}); + +test("repo_context accepts legal '..config' path components", async () => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const root = await mkdtemp(join(temporaryRoot, "repo-path-")); + await mkdir(join(root, "..config")); + const { handlers } = loadHandlers({}, root); + const value = await handlers.get("repo_context_get")({ + path: "..config", + include_git: false, + include_instructions: false, + max_bytes: 1024, + }); + assert.equal(value.path, "..config"); +}); + +test("repo_context rejects symlink escapes", async () => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const root = await mkdtemp(join(temporaryRoot, "repo-symlink-root-")); + const outside = await mkdtemp( + join(temporaryRoot, "repo-symlink-outside-"), + ); + await writeFile(join(outside, "outside.txt"), "outside\n"); + await symlink(join(outside, "outside.txt"), join(root, "escape.txt")); + const { handlers } = loadHandlers({}, root); + + await assert.rejects( + handlers.get("repo_context_get")({ + path: "escape.txt", + include_git: false, + include_instructions: false, + max_bytes: 1024, + }), + /outside the workspace/, + ); + await assert.rejects( + handlers.get("repo_context_read")({ + files: [{ path: "escape.txt" }], + max_total_bytes: 1024, + }), + /outside the workspace/, + ); + await assert.rejects( + handlers.get("repo_context_search")({ + query: "outside", + paths: ["escape.txt"], + }), + /outside the workspace/, + ); +}); + +test( + "fallback follows an explicitly selected internal symlink", + async () => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const root = await mkdtemp(join(temporaryRoot, "repo-symlink-root-")); + await writeFile(join(root, "target.txt"), "needle\n"); + await symlink("target.txt", join(root, "alias.txt")); + const missing = Object.assign( + new Error("missing rg"), + { code: "ENOENT" }, + ); + const { handlers } = loadHandlers({ rg: missing }, root); + const value = await handlers.get("repo_context_search")({ + query: "needle", + paths: ["alias.txt"], + max_matches: 2, + }); + + assert.equal(value.engine, "javascript"); + assert.equal(value.matchCount, 1); + assert.equal(value.matches[0].path, "alias.txt"); + assert.equal(value.truncated, false); + }, +); + +test("repo_context reads the verified symlink target", async () => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const root = await mkdtemp(join(temporaryRoot, "repo-symlink-root-")); + await writeFile(join(root, "target.txt"), "inside\n"); + await symlink("target.txt", join(root, "alias.txt")); + const { handlers } = loadHandlers({ + readText: async () => { + throw new Error("the lexical alias was reopened"); + }, + }, root); + + const value = await handlers.get("repo_context_read")({ + files: [{ path: "alias.txt" }], + max_total_bytes: 1024, + }); + + assert.equal(value.files[0].content, "inside"); +}); + +test("repo_context opts into subprocess partial output", async () => { + const { calls, handlers } = loadHandlers({ + rg: execution(`${rgMatch(1)}\n`), + }); + await handlers.get("repo_context_get")({ + path: ".", + include_instructions: false, + max_bytes: 1024, + }); + await handlers.get("repo_context_search")({ query: "needle" }); + + for (const call of calls) { + assert.equal(call.options.allowTruncatedOutput, true); + if (call.file === "git") { + assert.match( + call.args[1], + new RegExp(`^/proc/${process.pid}/fd/\\d+$`), + ); + assert.equal(call.options.env.GIT_OPTIONAL_LOCKS, "0"); + assert.equal(call.options.env.GIT_NO_LAZY_FETCH, "1"); + for (const name of [ + "GIT_ALTERNATE_OBJECT_DIRECTORIES", + "GIT_COMMON_DIR", + "GIT_CONFIG", + "GIT_CONFIG_COUNT", + "GIT_CONFIG_PARAMETERS", + "GIT_DIR", + "GIT_GRAFT_FILE", + "GIT_IMPLICIT_WORK_TREE", + "GIT_INDEX_FILE", + "GIT_INTERNAL_SUPER_PREFIX", + "GIT_NAMESPACE", + "GIT_NO_REPLACE_OBJECTS", + "GIT_OBJECT_DIRECTORY", + "GIT_PREFIX", + "GIT_REPLACE_REF_BASE", + "GIT_SHALLOW_FILE", + "GIT_WORK_TREE", + ]) { + assert.equal(call.options.env[name], null); + } + } else { + assert.equal(call.file, "rg"); + assert.match( + call.args.at(-1), + new RegExp(`^/proc/${process.pid}/fd/\\d+$`), + ); + } + } +}); + +test("bounded reads report only retained lines", async () => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const root = await mkdtemp(join(temporaryRoot, "repo-read-")); + await writeFile(join(root, "fixture.txt"), "abcdef\nsecond\nthird\n"); + const { handlers } = loadHandlers({}, root); + const value = await handlers.get("repo_context_read")({ + files: [{ path: "fixture.txt", start_line: 1, end_line: 3 }], + max_total_bytes: 3, + }); + + assert.equal(value.files[0].content, "abc"); + assert.equal(value.files[0].endLine, 1); + assert.equal(value.files[0].truncated, true); + + const lineBoundary = await handlers.get("repo_context_read")({ + files: [{ path: "fixture.txt", start_line: 1, end_line: 3 }], + max_total_bytes: 7, + }); + assert.equal(lineBoundary.files[0].content, "abcdef\n"); + assert.equal(lineBoundary.files[0].endLine, 1); + assert.equal(lineBoundary.files[0].truncated, true); +}); + +test("bounded reads distinguish empty retained lines from EOF", async () => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const root = await mkdtemp(join(temporaryRoot, "repo-read-")); + await writeFile(join(root, "fixture.txt"), "first\n\nthird"); + const { handlers } = loadHandlers({}, root); + const emptyLine = await handlers.get("repo_context_read")({ + files: [{ path: "fixture.txt", start_line: 2, end_line: 2 }], + }); + const pastEnd = await handlers.get("repo_context_read")({ + files: [{ path: "fixture.txt", start_line: 4, end_line: 4 }], + }); + + assert.equal(emptyLine.files[0].content, ""); + assert.equal(emptyLine.files[0].endLine, 2); + assert.equal(emptyLine.files[0].truncated, false); + assert.equal(pastEnd.files[0].endLine, null); +}); + +test("bounded reads do not count terminal split sentinels", async (t) => { + assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + const root = await mkdtemp(join(temporaryRoot, "repo-read-")); + const { handlers } = loadHandlers({}, root); + + await t.test("final newline does not create another line", async () => { + await writeFile(join(root, "fixture.txt"), "first\n"); + const value = await handlers.get("repo_context_read")({ + files: [{ path: "fixture.txt", start_line: 2 }], + }); + + assert.equal(value.files[0].endLine, null); + assert.equal(value.files[0].totalLines, 1); + }); + + await t.test("an empty file has no lines", async () => { + await writeFile(join(root, "fixture.txt"), ""); + const value = await handlers.get("repo_context_read")({ + files: [{ path: "fixture.txt", start_line: 1 }], + }); + + assert.equal(value.files[0].endLine, null); + assert.equal(value.files[0].totalLines, 0); + }); +}); diff --git a/projects/mcp_cordis/test/runtime_test.mjs b/projects/mcp_cordis/test/runtime_test.mjs new file mode 100644 index 00000000..672f6ec6 --- /dev/null +++ b/projects/mcp_cordis/test/runtime_test.mjs @@ -0,0 +1,803 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import process from "node:process"; +import test from "node:test"; + +import { Client } from "@modelcontextprotocol/client"; +import { InMemoryTransport } from "@modelcontextprotocol/server"; +import { createMcpServer } from "../internal/mcp.mjs"; +import { CordisRuntime } from "../internal/runtime.mjs"; + +const temporaryRoot = process.env.TEST_TMPDIR; +assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + +async function workspace(label) { + const root = await mkdtemp(join(temporaryRoot, `${label}-`)); + const project = join(root, "projects", "mcp_cordis"); + await mkdir(join(project, "plugins"), { recursive: true }); + await writeFile(join(project, "cordis.yaml"), "[]\n"); + return root; +} + +function plugin(generation) { + return `import { readFile, writeFile } from "node:fs/promises"; +export default { + description: ${JSON.stringify(`test plugin ${generation}`)}, + apply(ctx) { + ctx.tool({ + name: "echo_value", + description: "Return the active generation.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["value"], + properties: { value: { type: "integer" } } + } + }, ({ value }) => ({ generation: ${JSON.stringify(generation)}, value })); + ctx.tool({ + name: "slow_value", + description: "Return after a bounded delay.", + inputSchema: { + type: "object", + additionalProperties: false, + required: ["delay_ms"], + properties: { + delay_ms: { type: "integer", minimum: 1, maximum: 1000 }, + completion_path: { type: "string" } + } + } + }, async ({ delay_ms: delayMs, completion_path: completionPath }) => { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + if (completionPath) { + await writeFile(ctx.resolveWorkspace(completionPath), "complete"); + } + return { generation: ${JSON.stringify(generation)} }; + }); + } +}; +`; +} + +function slowEvaluationPlugin(generation, markerPath, releasePath) { + return plugin(generation).replace( + "export default {", + `await writeFile(${JSON.stringify(markerPath)}, "started"); +while (await readFile(${JSON.stringify(releasePath)}, "utf8").catch(() => "") !== "release") { + await new Promise((resolve) => setTimeout(resolve, 10)); +} +export default {`, + ); +} + +function gatedApplyPlugin(generation, markerPath, releasePath) { + return plugin(generation).replace( + " apply(ctx) {", + ` async apply(ctx) { + await writeFile(${JSON.stringify(markerPath)}, "started"); + while (await readFile(${JSON.stringify(releasePath)}, "utf8").catch(() => "") !== "release") { + await new Promise((resolve) => setTimeout(resolve, 10)); + }`, + ); +} + +function markedApplyPlugin(generation, markerPath) { + return plugin(generation).replace( + " apply(ctx) {", + ` async apply(ctx) { + await writeFile(${JSON.stringify(markerPath)}, "started");`, + ); +} + +function failingApplyPlugin(generation, markerPath) { + return plugin(generation).replace( + " apply(ctx) {", + ` async apply() { + await writeFile(${JSON.stringify(markerPath)}, "started"); + throw new Error("intentional replacement failure");`, + ); +} + +async function invoke(runtime, scope, packageName, value) { + return runtime.invoke({ + scope, + packageName, + tool: "echo_value", + arguments: { value }, + }); +} + +async function waitFor(callback, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs; + let lastError; + while (Date.now() < deadline) { + try { + if (await callback()) return; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail( + `condition did not become true within ${timeoutMs} ms` + + (lastError ? `: ${lastError.message}` : ""), + ); +} + +function nextHmrReload(runtime, label, timeoutMs = 5_000) { + return new Promise((resolve, reject) => { + let dispose; + const timer = setTimeout(() => { + void dispose(); + reject(new Error( + `Cordis HMR did not reload ${label} within ${timeoutMs} ms`, + )); + }, timeoutMs); + timer.unref(); + dispose = runtime.root.on("hmr/reload", () => { + clearTimeout(timer); + void dispose(); + resolve(); + }); + }); +} + +async function processIsLive(pid) { + try { + const stat = await readFile(`/proc/${pid}/stat`, "utf8"); + const state = stat.slice(stat.lastIndexOf(")") + 2).split(/\s+/u)[0]; + return state !== "Z" && state !== "X"; + } catch (error) { + if (error?.code === "ENOENT") return false; + throw error; + } +} + +function processPlugin(activationPidPath = undefined) { + const childProgram = ` + const { spawn } = require("node:child_process"); + const { writeFileSync } = require("node:fs"); + const descendant = spawn(process.execPath, [ + "-e", "setInterval(() => {}, 1000)", + ], { stdio: "ignore" }); + writeFileSync(process.argv[1], process.pid + " " + descendant.pid); + setInterval(() => {}, 1000); + `; + const activation = activationPidPath === undefined + ? "" + : `void launch(${JSON.stringify(activationPidPath)}).catch(() => {});`; + return `import { readFile } from "node:fs/promises"; +export default { + apply(ctx) { + const launch = (pidPath) => ctx.exec(process.execPath, [ + "-e", ${JSON.stringify(childProgram)}, ctx.resolveWorkspace(pidPath), + ], { timeoutMs: 30000 }); + ${activation} + ctx.tool({ + name: "exec_tree", + inputSchema: { + type: "object", + required: ["pid_path"], + properties: { pid_path: { type: "string" } }, + additionalProperties: false + } + }, ({ pid_path: pidPath }) => launch(pidPath)); + ctx.tool({ + name: "fire_and_forget_tree", + inputSchema: { + type: "object", + required: ["pid_path"], + properties: { pid_path: { type: "string" } }, + additionalProperties: false + } + }, async ({ pid_path: pidPath }) => { + const execution = launch(pidPath); + void execution.catch(() => {}); + while (true) { + try { + await readFile(ctx.resolveWorkspace(pidPath)); + return { started: true }; + } catch (error) { + if (error.code !== "ENOENT") throw error; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + } + }); + } +}; +`; +} + +async function readPids(root, relativePath) { + return (await readFile(join(root, relativePath), "utf8")) + .trim() + .split(" ") + .map(Number); +} + +async function assertProcessesStopped(pids) { + assert.equal(pids.length, 2); + for (const pid of pids) assert.equal(await processIsLive(pid), false); +} + +test("standard Cordis modules use native eventual HMR", async (t) => { + const root = await workspace("runtime"); + const runtime = new CordisRuntime({ workspaceRoot: root }); + t.after(() => runtime.shutdown()); + + assert.deepEqual(await runtime.initialize(), { loaded: [], errors: [] }); + const created = await runtime.define({ + scope: "scratch", + name: "echo", + source: plugin("v1"), + activate: true, + }); + assert.equal(created.module, "./plugins/echo.mjs"); + assert.equal(created.description, ""); + assert.equal(created.running, true); + assert.deepEqual((await invoke(runtime, "scratch", "echo", 1)).value, { + generation: "v1", + value: 1, + }); + const invalidCompletionPath = + "out/mcp_cordis/invalid_timeout_complete"; + await assert.rejects( + runtime.invoke({ + scope: "scratch", + packageName: "echo", + tool: "slow_value", + arguments: { + delay_ms: 40, + completion_path: invalidCompletionPath, + }, + timeoutMs: 0, + }), + /timeoutMs must be a positive integer/u, + ); + await new Promise((resolve) => setTimeout(resolve, 60)); + await assert.rejects( + readFile(join(root, invalidCompletionPath)), + (error) => error.code === "ENOENT", + ); + + const completionPath = "out/mcp_cordis/slow_value_complete"; + await assert.rejects( + runtime.invoke({ + scope: "scratch", + packageName: "echo", + tool: "slow_value", + arguments: { delay_ms: 75, completion_path: completionPath }, + timeoutMs: 10, + }), + (error) => error.code === "invoke_timeout", + ); + let stopped = false; + const drainingStop = runtime.stop({ scope: "scratch", name: "echo" }) + .then(() => { + stopped = true; + }); + await new Promise((resolve) => setTimeout(resolve, 20)); + assert.equal(stopped, false); + await drainingStop; + assert.equal( + await readFile(join(root, completionPath), "utf8"), + "complete", + ); + await runtime.run({ scope: "scratch", name: "echo" }); + assert.equal((await invoke(runtime, "scratch", "echo", 9)).value.value, 9); + assert.equal( + (await runtime.inspect({ scope: "scratch", name: "echo" })).enabled, + true, + "run must persist the enabled state", + ); + + const updated = await runtime.define({ + scope: "scratch", + name: "echo", + source: plugin("v2"), + }); + assert.equal(updated.persisted, true); + assert.equal(updated.sourceChanged, true); + assert.equal(updated.activation, "pending"); + assert.equal(updated.running, true); + await waitFor(async () => { + return (await invoke(runtime, "scratch", "echo", 2)).value + .generation === "v2"; + }); + const scratchSource = join( + root, + "out", + "mcp_cordis", + "plugins", + "echo.mjs", + ); + const v2Source = await readFile(scratchSource, "utf8"); + assert.equal(v2Source, plugin("v2")); + await assert.rejects( + runtime.define({ + scope: "scratch", + name: "echo", + source: "export default {", + }), + (error) => error.code === "invalid_module_source", + ); + assert.equal(await readFile(scratchSource, "utf8"), v2Source); + assert.deepEqual((await invoke(runtime, "scratch", "echo", 3)).value, { + generation: "v2", + value: 3, + }); + + const manualReload = nextHmrReload(runtime, "manual edit"); + await writeFile(scratchSource, plugin("manual")); + await manualReload; + await waitFor(async () => { + return (await invoke(runtime, "scratch", "echo", 3)).value + .generation === "manual"; + }); + assert.equal( + (await runtime.inspect({ scope: "scratch", name: "echo" })).enabled, + true, + "manual reload must preserve the enabled state", + ); + + await runtime.define({ + scope: "scratch", + name: "echo", + source: plugin("superseded"), + }); + await waitFor(async () => { + return (await invoke(runtime, "scratch", "echo", 3)).value + .generation === "superseded"; + }); + await runtime.define({ + scope: "scratch", + name: "echo", + source: plugin("latest"), + }); + await waitFor(async () => { + return (await invoke(runtime, "scratch", "echo", 3)).value + .generation === "latest"; + }); + assert.equal( + (await runtime.inspect({ scope: "scratch", name: "echo" })).enabled, + true, + "ordinary reloads must preserve the enabled state", + ); + + const slowReloadMarker = join( + root, + "out", + "mcp_cordis", + "slow_reload_started", + ); + const slowReloadRelease = join( + root, + "out", + "mcp_cordis", + "slow_reload_release", + ); + await runtime.define({ + scope: "scratch", + name: "echo", + source: slowEvaluationPlugin( + "slow", + slowReloadMarker, + slowReloadRelease, + ), + }); + const slowReloadRefresh = runtime.root.hmr.refreshFile(scratchSource); + await waitFor(async () => { + return await readFile(slowReloadMarker, "utf8") === "started"; + }); + await runtime.define({ + scope: "scratch", + name: "echo", + source: plugin("rapid_latest"), + }); + const rapidRefresh = runtime.root.hmr.refreshFile(scratchSource); + await writeFile(slowReloadRelease, "release"); + await Promise.all([slowReloadRefresh, rapidRefresh]); + await waitFor(async () => { + return (await invoke(runtime, "scratch", "echo", 3)).value + .generation === "rapid_latest"; + }); + + const slowApplyMarker = join( + root, + "out", + "mcp_cordis", + "slow_apply_started", + ); + const slowApplyRelease = join( + root, + "out", + "mcp_cordis", + "slow_apply_release", + ); + const latestApplyMarker = join( + root, + "out", + "mcp_cordis", + "latest_apply_started", + ); + await runtime.define({ + scope: "scratch", + name: "echo", + source: gatedApplyPlugin( + "slow_apply", + slowApplyMarker, + slowApplyRelease, + ), + }); + const slowApplyRefresh = runtime.root.hmr.refreshFile(scratchSource); + await waitFor(async () => { + return await readFile(slowApplyMarker, "utf8") === "started"; + }); + await runtime.define({ + scope: "scratch", + name: "echo", + source: markedApplyPlugin("apply_latest", latestApplyMarker), + }); + const applyRefresh = runtime.root.hmr.refreshFile(scratchSource); + await assert.rejects( + readFile(latestApplyMarker, "utf8"), + (error) => error.code === "ENOENT", + ); + await writeFile(slowApplyRelease, "release"); + await Promise.all([slowApplyRefresh, applyRefresh]); + assert.equal(await readFile(latestApplyMarker, "utf8"), "started"); + await waitFor(async () => { + return (await invoke(runtime, "scratch", "echo", 3)).value + .generation === "apply_latest"; + }); + const beforeOverlapStop = await runtime.inspect({ + scope: "scratch", + name: "echo", + }); + assert.equal( + beforeOverlapStop.enabled, + true, + JSON.stringify(beforeOverlapStop), + ); + + const failedApplyMarker = join( + root, + "out", + "mcp_cordis", + "failed_apply_started", + ); + await runtime.define({ + scope: "scratch", + name: "echo", + source: failingApplyPlugin("failed_apply", failedApplyMarker), + }); + const failedRefresh = runtime.root.hmr.refreshFile(scratchSource); + await waitFor(async () => { + return await readFile(failedApplyMarker, "utf8") === "started"; + }); + await failedRefresh; + await waitFor(async () => { + return (await invoke(runtime, "scratch", "echo", 3)).value + .generation === "apply_latest"; + }); + const afterFailedReplacement = await runtime.inspect({ + scope: "scratch", + name: "echo", + }); + assert.equal(afterFailedReplacement.enabled, true); + assert.equal(afterFailedReplacement.running, true); + + await runtime.define({ + scope: "scratch", + name: "echo", + source: plugin("recovered"), + }); + await runtime.root.hmr.refreshFile(scratchSource); + await waitFor(async () => { + return (await invoke(runtime, "scratch", "echo", 3)).value + .generation === "recovered"; + }); + + const stoppedAfterOverlap = await runtime.stop({ + scope: "scratch", + name: "echo", + }); + assert.equal( + stoppedAfterOverlap.running, + false, + JSON.stringify(stoppedAfterOverlap), + ); + await assert.rejects( + invoke(runtime, "scratch", "echo", 4), + (error) => error.code === "tool_not_found", + ); + const reactivated = await runtime.define({ + scope: "scratch", + name: "echo", + source: plugin("reactivated"), + activate: true, + }); + assert.equal(reactivated.activation, undefined); + assert.equal(reactivated.running, true); + assert.equal( + (await invoke(runtime, "scratch", "echo", 4)).value.generation, + "reactivated", + ); + + await runtime.stop({ scope: "scratch", name: "echo" }); + const disabledUpdate = await runtime.define({ + scope: "scratch", + name: "echo", + source: plugin("disabled_update"), + }); + assert.equal(disabledUpdate.activation, undefined); + const enabled = await runtime.define({ + scope: "scratch", + name: "echo", + source: plugin("disabled_update"), + activate: true, + }); + assert.equal(enabled.activation, undefined); + assert.equal( + (await invoke(runtime, "scratch", "echo", 5)).value.generation, + "disabled_update", + ); + + const promoted = await runtime.promote({ + name: "echo", + targetName: "reusable_echo", + activate: true, + }); + assert.equal(promoted.scope, "project"); + assert.equal( + (await invoke(runtime, "project", "reusable_echo", 6)).value.generation, + "disabled_update", + ); + assert.match( + await readFile( + join(root, "projects", "mcp_cordis", "cordis.yaml"), + "utf8", + ), + /id: reusable_echo[\s\S]*name: \.\/plugins\/reusable_echo\.mjs/u, + ); + + await runtime.remove({ scope: "scratch", name: "echo" }); + assert.equal( + (await runtime.listPackages({ scope: "scratch" })).packages.length, + 0, + ); + + await runtime.shutdown(); + const restarted = new CordisRuntime({ workspaceRoot: root }); + t.after(() => restarted.shutdown()); + const startup = await restarted.initialize(); + assert.deepEqual(startup.errors, []); + assert.equal( + (await invoke(restarted, "project", "reusable_echo", 7)).value + .generation, + "disabled_update", + ); +}); + +test("unfiltered listing preserves a healthy scope after partial startup", async (t) => { + for (const [label, config] of [ + ["malformed config", "[\n"], + [ + "missing module", + "- id: missing\n name: ./plugins/missing.mjs\n", + ], + ]) { + await t.test(label, async (t) => { + const root = await workspace("partial-startup"); + await writeFile( + join(root, "projects", "mcp_cordis", "cordis.yaml"), + config, + ); + const runtime = new CordisRuntime({ workspaceRoot: root }); + t.after(() => runtime.shutdown()); + + const startup = await runtime.initialize(); + assert.equal(startup.errors.length, 1); + assert.equal(startup.errors[0].scope, "project"); + const originalMessage = startup.errors[0].error.message; + startup.errors[0].error.message = "caller mutation"; + const repeatedStartup = await runtime.initialize(); + assert.equal(repeatedStartup.errors.length, 1); + assert.equal(repeatedStartup.errors[0].scope, "project"); + assert.equal( + repeatedStartup.errors[0].error.message, + originalMessage, + ); + await runtime.define({ + scope: "scratch", + name: "healthy", + source: plugin("healthy"), + }); + + const listed = await runtime.listPackages(); + assert.deepEqual( + listed.packages.map(({ scope, name }) => ({ scope, name })), + [{ scope: "scratch", name: "healthy" }], + ); + assert.equal(listed.errors.length, 1); + assert.equal(listed.errors[0].scope, "project"); + await assert.rejects( + runtime.listPackages({ scope: "project" }), + (error) => error.code === "scope_unavailable", + ); + }); + } +}); + +test("MCP gateway exposes and invokes a runtime plugin", async (t) => { + const root = await workspace("mcp"); + const runtime = new CordisRuntime({ workspaceRoot: root }); + await runtime.initialize(); + const server = createMcpServer(runtime); + const client = new Client({ name: "mcp-cordis-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = + InMemoryTransport.createLinkedPair(); + t.after(async () => { + await client.close().catch(() => {}); + await server.close().catch(() => {}); + await runtime.shutdown(); + }); + await Promise.all([ + client.connect(clientTransport), + server.connect(serverTransport), + ]); + + const result = await client.callTool({ + name: "cordis_define", + arguments: { + scope: "scratch", + name: "echo", + source: plugin("mcp"), + activate: true, + }, + }); + assert.equal(result.isError, undefined); + const invoked = await client.callTool({ + name: "cordis_invoke", + arguments: { + scope: "scratch", + package: "echo", + tool: "echo_value", + arguments: { value: 8 }, + }, + }); + assert.deepEqual(invoked.structuredContent.value, { + generation: "mcp", + value: 8, + }); +}); + +test("standard asynchronous Cordis plugins are supported", async (t) => { + const root = await workspace("async-activation"); + const runtime = new CordisRuntime({ workspaceRoot: root }); + t.after(() => runtime.shutdown()); + await runtime.initialize(); + + const created = await runtime.define({ + scope: "scratch", + name: "asynchronous", + source: `await Promise.resolve(); + export default { + async apply(ctx) { + await Promise.resolve(); + ctx.tool({ + name: "async_value", + inputSchema: { + type: "object", + additionalProperties: false + } + }, () => ({ ready: true })); + } + };`, + activate: true, + }); + assert.equal(created.running, true); + const invoked = await runtime.invoke({ + scope: "scratch", + packageName: "asynchronous", + tool: "async_value", + }); + assert.deepEqual(invoked.value, { ready: true }); +}); + +test("source at the payload limit round-trips without metadata", async (t) => { + const root = await workspace("source-limit"); + const runtime = new CordisRuntime({ workspaceRoot: root }); + t.after(() => runtime.shutdown()); + await runtime.initialize(); + + const source = `//${"x".repeat(1_999_997)}\n`; + assert.equal(Buffer.byteLength(source), 2_000_000); + await runtime.define({ scope: "scratch", name: "limit", source }); + const inspected = await runtime.inspect({ + scope: "scratch", + name: "limit", + includeSource: true, + }); + assert.equal(Buffer.byteLength(inspected.source), 2_000_000); + assert.equal(inspected.source, source); + const roundTrip = await runtime.define({ + scope: "scratch", + name: "limit", + source: inspected.source, + }); + assert.equal(roundTrip.updated, true); + const legacyRoundTrip = await runtime.define({ + scope: "scratch", + name: "limit", + source: `export const __mcp_cordis_source_sha256 = ` + + `"${"a".repeat(64)}";\n${source}`, + }); + assert.equal(legacyRoundTrip.sourceChanged, false); + assert.equal( + (await runtime.inspect({ + scope: "scratch", + name: "limit", + includeSource: true, + })).source, + source, + ); +}); + +test("invocation and Fiber disposal join process-tree cleanup", async (t) => { + const root = await workspace("process-wiring"); + const runtime = new CordisRuntime({ workspaceRoot: root }); + t.after(() => runtime.shutdown()); + await runtime.initialize(); + + await runtime.define({ + scope: "scratch", + name: "process_tools", + source: processPlugin(), + activate: true, + }); + const timeoutPidPath = "out/mcp_cordis/invoke_timeout_pids"; + const timedInvocation = runtime.invoke({ + scope: "scratch", + packageName: "process_tools", + tool: "exec_tree", + arguments: { pid_path: timeoutPidPath }, + timeoutMs: 300, + }); + const timedRejection = assert.rejects( + timedInvocation, + (error) => error.code === "invoke_timeout", + ); + await waitFor(async () => Boolean(await readFile( + join(root, timeoutPidPath), + ))); + await timedRejection; + await assertProcessesStopped(await readPids(root, timeoutPidPath)); + + const forgottenPidPath = "out/mcp_cordis/fire_and_forget_pids"; + const forgottenInvocation = runtime.invoke({ + scope: "scratch", + packageName: "process_tools", + tool: "fire_and_forget_tree", + arguments: { pid_path: forgottenPidPath }, + }); + await waitFor(async () => Boolean(await readFile( + join(root, forgottenPidPath), + ))); + const stopped = runtime.stop({ scope: "scratch", name: "process_tools" }); + await Promise.all([forgottenInvocation, stopped]); + await assertProcessesStopped(await readPids(root, forgottenPidPath)); + + const activationPidPath = "out/mcp_cordis/activation_pids"; + await runtime.define({ + scope: "scratch", + name: "activation_process", + source: processPlugin(activationPidPath), + activate: true, + }); + await waitFor(async () => Boolean(await readFile( + join(root, activationPidPath), + ))); + await runtime.stop({ scope: "scratch", name: "activation_process" }); + await assertProcessesStopped(await readPids(root, activationPidPath)); +}); diff --git a/projects/mcp_cordis/test/starter_packages_test.mjs b/projects/mcp_cordis/test/starter_packages_test.mjs new file mode 100644 index 00000000..389d6978 --- /dev/null +++ b/projects/mcp_cordis/test/starter_packages_test.mjs @@ -0,0 +1,249 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { cp, mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import test from "node:test"; +import { CordisRuntime } from "../internal/runtime.mjs"; + +const execute = promisify(execFile); +const temporaryRoot = process.env.TEST_TMPDIR; +assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + +async function git(root, ...args) { + return execute("git", ["-C", root, ...args], { + encoding: "utf8", + maxBuffer: 1024 * 1024, + }); +} + +async function startHttpServer(t) { + const server = createServer((request, response) => { + response.setHeader("Content-Type", "text/plain; charset=utf-8"); + response.setHeader("Set-Cookie", "session=must-not-leak"); + if (request.url === "/utf8") { + response.end("éx"); + return; + } + if (request.url === "/invalid-utf8") { + response.end(Buffer.from([0xc3])); + return; + } + response.end(`probe:${request.method}:` + "x".repeat(4096)); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + t.after(() => new Promise((resolve) => server.close(resolve))); + return server.address().port; +} + +test("checked-in starter packages execute through Cordis", async (t) => { + const root = await mkdtemp(join(temporaryRoot, "starters-")); + const destination = join(root, "projects", "mcp_cordis"); + await mkdir(destination, { recursive: true }); + const plugins = fileURLToPath(new URL("../plugins", import.meta.url)); + const config = fileURLToPath(new URL("../cordis.yaml", import.meta.url)); + await cp(plugins, join(destination, "plugins"), { recursive: true }); + await cp(config, join(destination, "cordis.yaml")); + + await writeFile(join(root, "AGENTS.md"), "fixture instructions\n"); + await writeFile(join(root, "README.md"), "fixture readme\n"); + await writeFile(join(root, "large.txt"), "base\n"); + await writeFile(join(root, "sample.txt"), "needle_one\n"); + await git(root, "init", "--quiet"); + await git(root, "config", "user.name", "MCP Cordis Test"); + await git(root, "config", "user.email", "mcp-cordis@example.invalid"); + await git(root, "config", "commit.gpgsign", "false"); + await git(root, "add", "."); + await git(root, "commit", "--quiet", "-m", "first"); + const base = (await git(root, "rev-parse", "HEAD")).stdout.trim(); + await writeFile( + join(root, "large.txt"), + Array.from( + { length: 300 }, + (_, index) => `expanded line ${index.toString().padStart(3, "0")}`, + ).join("\n") + "\n", + ); + await writeFile(join(root, "sample.txt"), "needle_one\nneedle_two\n"); + await git(root, "add", "large.txt", "sample.txt"); + await git(root, "commit", "--quiet", "-m", "second"); + const head = (await git(root, "rev-parse", "HEAD")).stdout.trim(); + await writeFile( + join(root, "sample.txt"), + "needle_one\nneedle_two\ndirty\n", + ); + + const runtime = new CordisRuntime({ workspaceRoot: root }); + t.after(() => runtime.shutdown()); + const startup = await runtime.initialize(); + assert.equal(startup.errors.length, 0); + assert.deepEqual( + startup.loaded.map((item) => item.name).sort(), + ["git_worktree", "network_probe", "repo_context"], + ); + const snapshots = await runtime.listPackages({ scope: "project" }); + for (const item of snapshots.packages) { + assert.match(item.description, /\S/u, item.name); + } + assert.equal(runtime.listTools().tools.length, 8); + + const context = await runtime.invoke({ + scope: "project", + packageName: "repo_context", + tool: "repo_context_get", + arguments: { path: ".", max_bytes: 16_384 }, + }); + assert.equal(context.value.path, "."); + assert.equal(context.value.instructions[0].path, "AGENTS.md"); + + const search = await runtime.invoke({ + scope: "project", + packageName: "repo_context", + tool: "repo_context_search", + arguments: { + query: "needle_two", + paths: ["sample.txt"], + max_matches: 5, + }, + }); + assert.equal(search.value.matchCount, 1); + assert.equal(search.value.matches[0].line, 2); + + const read = await runtime.invoke({ + scope: "project", + packageName: "repo_context", + tool: "repo_context_read", + arguments: { + files: [{ path: "sample.txt", start_line: 2, end_line: 3 }], + max_total_bytes: 32, + }, + }); + assert.equal(read.value.files[0].content, "needle_two\ndirty"); + await assert.rejects( + runtime.invoke({ + scope: "project", + packageName: "repo_context", + tool: "repo_context_read", + arguments: { files: [{ path: "../outside" }] }, + }), + /outside|escape/i, + ); + + const snapshot = await runtime.invoke({ + scope: "project", + packageName: "git_worktree", + tool: "git_snapshot", + arguments: { repo: ".", log_limit: 2 }, + }); + assert.equal(snapshot.value.branch.oid, head); + assert.ok(snapshot.value.changes.some((item) => { + return item.path === "sample.txt"; + })); + assert.equal(snapshot.value.commits.length, 2); + + const comparison = await runtime.invoke({ + scope: "project", + packageName: "git_worktree", + tool: "git_compare", + arguments: { repo: ".", base, head, max_bytes: 32_768 }, + }); + assert.equal(comparison.value.base, base); + assert.equal(comparison.value.head, head); + assert.match(comparison.value.diff, /needle_two/); + + const boundedComparison = await runtime.invoke({ + scope: "project", + packageName: "git_worktree", + tool: "git_compare", + arguments: { repo: ".", base, head, max_bytes: 1024 }, + }); + assert.equal(boundedComparison.value.truncated, true); + assert.equal(boundedComparison.value.filesTruncated, false); + assert.equal(boundedComparison.value.diffTruncated, true); + assert.equal(boundedComparison.value.diffBytes, 1024); + assert.equal( + Buffer.byteLength(boundedComparison.value.diff, "utf8"), + 1024, + ); + assert.match( + boundedComparison.value.diff, + /^diff --git a\/large\.txt b\/large\.txt/, + ); + assert.match(boundedComparison.value.diff, /expanded line 000/); + assert.match(boundedComparison.value.diff, /^[\x00-\x7f]+$/); + assert.deepEqual( + boundedComparison.value.files.map((item) => item.path).sort(), + ["large.txt", "sample.txt"], + ); + + const port = await startHttpServer(t); + const tcp = await runtime.invoke({ + scope: "project", + packageName: "network_probe", + tool: "tcp_probe", + arguments: { host: "127.0.0.1", port, timeout_ms: 2_000 }, + }); + assert.equal(tcp.value.ok, true); + assert.equal(tcp.value.remotePort, port); + + const http = await runtime.invoke({ + scope: "project", + packageName: "network_probe", + tool: "http_probe", + arguments: { + url: `http://127.0.0.1:${port}/health`, + max_body_bytes: 32, + timeout_ms: 2_000, + }, + }); + assert.equal(http.value.ok, true); + assert.equal(http.value.status, 200); + assert.equal(http.value.bodyBytes, 32); + assert.equal(http.value.bodyTruncated, true); + assert.equal(http.value.headers["set-cookie"], "[redacted]"); + + const splitUtf8 = await runtime.invoke({ + scope: "project", + packageName: "network_probe", + tool: "http_probe", + arguments: { + url: `http://127.0.0.1:${port}/utf8`, + max_body_bytes: 1, + timeout_ms: 2_000, + }, + }); + assert.equal(splitUtf8.value.body, ""); + assert.equal(splitUtf8.value.bodyBytes, 1); + assert.equal(splitUtf8.value.bodyTruncated, true); + + const invalidUtf8 = await runtime.invoke({ + scope: "project", + packageName: "network_probe", + tool: "http_probe", + arguments: { + url: `http://127.0.0.1:${port}/invalid-utf8`, + max_body_bytes: 32, + timeout_ms: 2_000, + }, + }); + assert.equal(invalidUtf8.value.body, "\ufffd"); + assert.equal(invalidUtf8.value.bodyBytes, 1); + assert.equal(invalidUtf8.value.bodyTruncated, false); + + const dns = await runtime.invoke({ + scope: "project", + packageName: "network_probe", + tool: "dns_lookup", + arguments: { + host: "localhost", + rrtype: "A", + timeout_ms: 1_000, + }, + }); + assert.equal(dns.value.host, "localhost"); + assert.equal(typeof dns.value.ok, "boolean"); +}); diff --git a/projects/mcp_cordis/test/stdio_test.mjs b/projects/mcp_cordis/test/stdio_test.mjs new file mode 100644 index 00000000..fec364e2 --- /dev/null +++ b/projects/mcp_cordis/test/stdio_test.mjs @@ -0,0 +1,228 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, writeFile } from "node:fs/promises"; +import { isAbsolute, join, resolve } from "node:path"; +import test from "node:test"; +import { Client } from "@modelcontextprotocol/client"; +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio"; + +const temporaryRoot = process.env.TEST_TMPDIR; +assert.ok(temporaryRoot, "Bazel must provide TEST_TMPDIR"); + +function source(generation) { + return `export default { + description: "stdio ${generation}", + apply(ctx) { + console.log("plugin apply log ${generation}"); + ctx.tool({ + name: "stdio_echo", + description: "Return the stdio test generation.", + inputSchema: { + type: "object", + properties: { value: {} }, + additionalProperties: false + } + }, ({ value = null }) => { + process.stdout.write("plugin invoke log ${generation}\\n"); + return { + generation: ${JSON.stringify(generation)}, + value + }; + }); + } + }; +`; +} + +async function call(client, name, args) { + const result = await client.callTool({ name, arguments: args }); + const value = result.structuredContent ?? + JSON.parse(result.content[0].text); + if (result.isError) { + throw new Error(`${name} failed: ${JSON.stringify(value)}`); + } + return value; +} + +async function waitFor(callback, timeoutMs = 5_000) { + const deadline = Date.now() + timeoutMs; + let lastError; + while (Date.now() < deadline) { + try { + const value = await callback(); + if (value !== undefined) return value; + } catch (error) { + lastError = error; + } + await new Promise((resolve) => setTimeout(resolve, 20)); + } + assert.fail( + `condition did not become true within ${timeoutMs} ms` + + (lastError ? `: ${lastError.message}` : ""), + ); +} + +async function connect(binary, workspaceRoot) { + const transport = new StdioClientTransport({ + command: binary, + args: ["--workspace-root", workspaceRoot], + stderr: "pipe", + }); + let stderr = ""; + transport.stderr.on("data", (chunk) => { + if (stderr.length < 65_536) stderr += chunk.toString("utf8"); + }); + const client = new Client({ + name: "mcp-cordis-stdio-test", + version: "1.0.0", + }); + try { + await client.connect(transport); + } catch (error) { + throw new Error( + `stdio connection failed: ${error.message}\n${stderr}`, + ); + } + return { client, transport, stderr: () => stderr }; +} + +test("real stdio connection hot-updates and recovers project state", async (t) => { + const rawBinary = process.argv[2]; + assert.ok(rawBinary, "the Bazel target must pass the server launcher"); + const binary = isAbsolute(rawBinary) ? rawBinary : resolve(rawBinary); + const workspaceRoot = await mkdtemp(join(temporaryRoot, "stdio-")); + const projectRoot = join(workspaceRoot, "projects", "mcp_cordis"); + await mkdir(join(projectRoot, "plugins"), { recursive: true }); + await writeFile(join(projectRoot, "cordis.yaml"), "[]\n"); + + const firstConnection = await connect(binary, workspaceRoot); + let restarted; + t.after(async () => { + await restarted?.client.close().catch(() => {}); + await restarted?.transport.close().catch(() => {}); + await firstConnection.client.close().catch(() => {}); + await firstConnection.transport.close().catch(() => {}); + }); + const firstPid = firstConnection.transport.pid; + assert.ok(firstPid); + const listed = await firstConnection.client.listTools(); + assert.equal(listed.tools.length, 10); + const toolsByName = new Map(listed.tools.map((tool) => [tool.name, tool])); + assert.equal( + toolsByName.get("cordis_define")?.annotations?.destructiveHint, + true, + ); + assert.equal( + toolsByName.get("cordis_promote")?.annotations?.destructiveHint, + true, + ); + + await call(firstConnection.client, "cordis_define", { + scope: "project", + name: "stdio_package", + source: source("v1"), + activate: false, + }); + const stored = await call(firstConnection.client, "cordis_inspect", { + scope: "project", + name: "stdio_package", + include_source: true, + }); + assert.equal(stored.running, false); + assert.match(stored.source, /stdio v1/u); + await call(firstConnection.client, "cordis_run", { + scope: "project", + name: "stdio_package", + }); + const first = await call(firstConnection.client, "cordis_invoke", { + scope: "project", + package: "stdio_package", + tool: "stdio_echo", + arguments: { value: 1 }, + }); + assert.deepEqual(first.value, { generation: "v1", value: 1 }); + + const update = await call( + firstConnection.client, + "cordis_define", + { + scope: "project", + name: "stdio_package", + source: source("v2"), + }, + ); + assert.equal(update.persisted, true); + assert.equal(update.activation, "pending"); + const second = await waitFor(async () => { + const value = await call(firstConnection.client, "cordis_invoke", { + scope: "project", + package: "stdio_package", + tool: "stdio_echo", + arguments: { value: 2 }, + }); + return value.value.generation === "v2" ? value : undefined; + }); + assert.deepEqual(second.value, { generation: "v2", value: 2 }); + assert.equal(firstConnection.transport.pid, firstPid); + + await assert.rejects( + call(firstConnection.client, "cordis_define", { + scope: "project", + name: "stdio_package", + source: "export default {", + }), + /cordis_define failed/u, + ); + const afterInvalidSource = await call( + firstConnection.client, + "cordis_invoke", + { + scope: "project", + package: "stdio_package", + tool: "stdio_echo", + arguments: { value: "rollback" }, + }, + ); + assert.equal(afterInvalidSource.value.generation, "v2"); + + await call(firstConnection.client, "cordis_stop", { + scope: "project", + name: "stdio_package", + }); + const stopped = await call(firstConnection.client, "cordis_inspect", { + scope: "project", + name: "stdio_package", + }); + assert.equal(stopped.running, false); + await call(firstConnection.client, "cordis_remove", { + scope: "project", + name: "stdio_package", + }); + const removed = await call(firstConnection.client, "cordis_list", { + scope: "project", + }); + assert.equal(removed.packages.length, 0); + + await call(firstConnection.client, "cordis_define", { + scope: "project", + name: "persistent_package", + source: source("v2"), + activate: true, + }); + await firstConnection.client.close(); + await firstConnection.transport.close(); + + restarted = await connect(binary, workspaceRoot); + assert.notEqual(restarted.transport.pid, firstPid); + const recovered = await call(restarted.client, "cordis_invoke", { + scope: "project", + package: "persistent_package", + tool: "stdio_echo", + arguments: { value: "restart" }, + }); + assert.deepEqual(recovered.value, { + generation: "v2", + value: "restart", + }); + await restarted.client.close(); + await restarted.transport.close(); +}); diff --git a/tools/repo_delivery/README.md b/tools/repo_delivery/README.md index fae80fb6..f2fe5ef4 100644 --- a/tools/repo_delivery/README.md +++ b/tools/repo_delivery/README.md @@ -36,6 +36,8 @@ bazel_agent run //tools/repo_delivery -- prepare \ --receipt-file out/task/prepare.json \ --path path/to/task/file \ --rewrite # omit when the range has no commit +# For an explicitly reviewed task-owned multi-commit range, use +# --consolidate instead of --rewrite. # Run every required validation against the top-level literal head_oid. bazel_agent run //tools/repo_delivery -- publish \ --base master \ @@ -54,6 +56,24 @@ ambiguous history. A mismatched, malformed, absent, or unnecessary authorization is refused. The final fresh snapshot and preparation receipt bind the same old remote OID for the later exact force-with-lease push. +`prepare --consolidate ` is the explicit +ownership authorization for replacing a multi-commit feature range with one +aggregate commit. It is not inferred from author names. The adapter still +requires a merge-free linear chain, one author and committer identity across +the range, the ownership disclaimer on its oldest commit, pull-request +metadata matching the requested aggregate message, and the exact inspected +head. +It preserves any signature requirement found in the range and keeps the +fetched remote feature tip as the publication lease. `--consolidate` and +`--rewrite` are mutually exclusive. + +When a clean replay onto an advanced base makes an expected aggregate path +disappear from the resulting diff, the adapter accepts that shrink only when +the prior candidate and the new base have byte-identical Git tree entries at +that path. A new path, a non-identical disappearance, or an entirely empty +aggregate remains a refusal. The derived receipt records the reduced exact +aggregate path set. + Never populate `--validated-head` by resolving the current `HEAD` during publication. Carry the literal OID returned by `prepare`; otherwise a checkout change after validation could authorize an unvalidated commit. @@ -91,8 +111,14 @@ Use repeated `--path` flags for fully task-owned paths, or pre-stage only task-owned hunks and use `--use-index`. Both modes bind the complete existing feature diff, not merely the paths newly staged by that invocation. `--message-only --rewrite ` preserves the tree but changes the -commit OID. Every prepare or message-only amendment therefore requires fresh -validation against its returned exact head. +commit OID. Consolidation also changes the commit OID and parent structure. +Every prepare, consolidation, or message-only amendment therefore requires +fresh validation against its returned exact head. +When an authorized remote replacement is pending, rewrite evidence accepts an +existing pull request only if its metadata matches the exact projectable local +or fetched-remote commit projection. A legacy remote tail that lacks an +aggregate disclaimer cannot block a matching local aggregate, and unrelated +pull-request text remains a refusal. Publish and receipt-bound verify require a clean index and reject staged, unstaged, or untracked changes in the prepared task scope. They preserve @@ -110,7 +136,10 @@ tree, and derived receipt. Validate that returned head directly, then retry also supports a guarded retry when the remote already equals its prepared head but pull-request creation or metadata synchronization stopped partway through. Replacement pull-request identities and any state other than the exact prior -or desired projection are refused. +or desired projection are refused. Multi-commit consolidation likewise +requires an existing pull request to equal the requested aggregate message's +projection, so consolidation cannot silently replace independently edited +pull-request text. The tool creates commits with Git plumbing and pushes one exact ref with hooks disabled. It rejects shallow, promisor, or grafted history and ignores diff --git a/tools/repo_delivery/main/go/command.go b/tools/repo_delivery/main/go/command.go index 4fe30ed7..081ba081 100644 --- a/tools/repo_delivery/main/go/command.go +++ b/tools/repo_delivery/main/go/command.go @@ -227,7 +227,7 @@ func newPrepareCommand( options := &prepareOptions{} command := &cobra.Command{ Use: "prepare", - Short: "Create or amend the sole feature commit and rebase it", + Short: "Create, amend, or consolidate the feature commit and rebase it", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, args []string) error { delivery, err := deliveryFromConfig(ctx, config, getenv, runner) @@ -283,6 +283,12 @@ func newPrepareCommand( "", "exact local commit OID authorized for amendment", ) + flags.StringVar( + &options.ConsolidateOID, + "consolidate", + "", + "exact local multi-commit head authorized for ownership consolidation", + ) flags.StringVar( &options.ReplaceRemoteOID, "replace-remote", diff --git a/tools/repo_delivery/main/go/delivery.go b/tools/repo_delivery/main/go/delivery.go index 07e8374e..f04e28cb 100644 --- a/tools/repo_delivery/main/go/delivery.go +++ b/tools/repo_delivery/main/go/delivery.go @@ -284,10 +284,7 @@ func (d *delivery) inspect( if report.UniqueCommitCount > 1 { report.Refusals = append( report.Refusals, - fmt.Sprintf( - "feature range contains %d commits; v1 will not infer consolidation ownership", - report.UniqueCommitCount, - ), + multiCommitRefusal(report.UniqueCommitCount), ) } if len(report.MergeCommits) != 0 { @@ -330,9 +327,16 @@ type prepareOptions struct { UseIndex bool MessageOnly bool RewriteOID string + ConsolidateOID string ReplaceRemoteOID string } +type consolidationEvidence struct { + ParentOID string + AuthorOID string + SignatureSource []string +} + type prepareReport struct { Inspection *inspection `json:"inspection"` HeadOID string `json:"head_oid"` @@ -376,7 +380,25 @@ func (d *delivery) prepare( if err != nil { return nil, err } - if err := ensureNoRefusals(report); err != nil { + if options.RewriteOID != "" && options.ConsolidateOID != "" { + return nil, fmt.Errorf("--rewrite and --consolidate are mutually exclusive") + } + if options.MessageOnly && options.ConsolidateOID != "" { + return nil, fmt.Errorf("--message-only cannot be combined with --consolidate") + } + var consolidation *consolidationEvidence + if options.ConsolidateOID != "" { + consolidation, err = d.requireConsolidationEvidence( + ctx, + report, + options.ConsolidateOID, + message, + ) + if err != nil { + return nil, err + } + } + if err := ensurePreparationRefusals(report, consolidation != nil); err != nil { return nil, err } if err := requireRemoteReplacementAuthorization( @@ -457,7 +479,7 @@ func (d *delivery) prepare( if err := d.requireRewriteEvidence(ctx, report, options.RewriteOID); err != nil { return nil, err } - parentOID, err := d.candidateParent(ctx, report) + parentOID, err := d.candidateParent(ctx, report, nil) if err != nil { return nil, err } @@ -504,9 +526,9 @@ func (d *delivery) prepare( ); err != nil { return nil, err } - } else if report.UniqueCommitCount != 0 { + } else if report.UniqueCommitCount > 1 && consolidation == nil { return nil, fmt.Errorf( - "feature commit count is %d, want zero or one", + "feature commit count is %d; use --consolidate with the exact inspected head after ownership review", report.UniqueCommitCount, ) } else if options.RewriteOID != "" { @@ -577,7 +599,7 @@ func (d *delivery) prepare( if err != nil { return nil, err } - if !hasChanges { + if !hasChanges && consolidation == nil { return nil, fmt.Errorf("the prepared index is empty") } indexTree, err := d.repository.indexTree(ctx) @@ -611,7 +633,7 @@ func (d *delivery) prepare( if confirmedIndexTree != indexTree { return nil, fmt.Errorf("index changed during final scope verification") } - parentOID, err := d.candidateParent(ctx, report) + parentOID, err := d.candidateParent(ctx, report, consolidation) if err != nil { return nil, err } @@ -646,16 +668,31 @@ func (d *delivery) prepare( if err != nil { return nil, err } - preparedHead, err = d.repository.commitChecked( - ctx, - message, - report.UniqueCommitCount == 1, - report.LocalHeadOID, - indexTree, - report.Branch, - transaction.noteInstalledCheckout, - scope.AuthorizedPaths, - ) + if consolidation == nil { + preparedHead, err = d.repository.commitChecked( + ctx, + message, + report.UniqueCommitCount == 1, + report.LocalHeadOID, + indexTree, + report.Branch, + transaction.noteInstalledCheckout, + scope.AuthorizedPaths, + ) + } else { + preparedHead, err = d.repository.commitConsolidatedChecked( + ctx, + message, + report.LocalHeadOID, + indexTree, + report.Branch, + consolidation.ParentOID, + consolidation.AuthorOID, + consolidation.SignatureSource, + transaction.noteInstalledCheckout, + scope.AuthorizedPaths, + ) + } if err != nil { return nil, err } @@ -852,6 +889,7 @@ func (d *delivery) finishPreparation( "the freshly fetched base requires a rebase, but the worktree is not clean", ) } + rebasedScope := scope rebasedHead, err := d.repository.rebase( ctx, snapshot.BaseOID, @@ -863,18 +901,22 @@ func (d *delivery) finishPreparation( candidateRepository *gitRepository, candidateHead string, ) error { - return verifyAggregateScopeInRepository( + var reconcileErr error + rebasedScope, reconcileErr = reconcileRebasedAggregateScope( checkContext, candidateRepository, snapshot.BaseOID, candidateHead, + preparedHead, scope, ) + return reconcileErr }, ) if err != nil { return nil, err } + scope = rebasedScope currentHead = rebasedHead observedBranch, observedHead, err := d.repository.branchHead(ctx) if err != nil { @@ -954,10 +996,14 @@ func (d *delivery) finishPreparation( func (d *delivery) candidateParent( ctx context.Context, report *inspection, + consolidation *consolidationEvidence, ) (string, error) { if report.UniqueCommitCount == 0 { return report.LocalHeadOID, nil } + if consolidation != nil { + return consolidation.ParentOID, nil + } parents, err := d.repository.commitParents(ctx, report.LocalHeadOID) if err != nil { return "", err @@ -971,6 +1017,134 @@ func (d *delivery) candidateParent( return parents[0], nil } +func ensurePreparationRefusals( + report *inspection, + consolidating bool, +) error { + if !consolidating { + return ensureNoRefusals(report) + } + want := multiCommitRefusal(report.UniqueCommitCount) + remaining := make([]string, 0, len(report.Refusals)) + removed := false + for _, refusal := range report.Refusals { + if !removed && refusal == want { + removed = true + continue + } + remaining = append(remaining, refusal) + } + if !removed { + return fmt.Errorf("delivery refused: the expected multi-commit refusal was absent") + } + if len(remaining) != 0 { + return fmt.Errorf("delivery refused: %s", strings.Join(remaining, "; ")) + } + return nil +} + +func multiCommitRefusal(count int) string { + return fmt.Sprintf( + "feature range contains %d commits; explicit --consolidate ownership authorization is required", + count, + ) +} + +func (d *delivery) requireConsolidationEvidence( + _ context.Context, + report *inspection, + expectedHead string, + message string, +) (*consolidationEvidence, error) { + if !isObjectID(expectedHead) { + return nil, fmt.Errorf("--consolidate must be a full Git object ID") + } + if expectedHead != report.LocalHeadOID { + return nil, fmt.Errorf("--consolidate differs from the exact inspected local head") + } + if report.UniqueCommitCount < 2 { + return nil, fmt.Errorf( + "--consolidate requires at least two feature commits, found %d", + report.UniqueCommitCount, + ) + } + if len(report.MergeCommits) != 0 { + return nil, fmt.Errorf("--consolidate refuses a feature range containing merges") + } + if len(report.FeatureCommits) != report.UniqueCommitCount { + return nil, fmt.Errorf("feature commit inventory is incomplete") + } + first := report.FeatureCommits[0] + if !first.HasDisclaimer { + return nil, fmt.Errorf( + "the oldest feature commit lacks the required ownership marker", + ) + } + if len(first.Parents) != 1 || !isObjectID(first.Parents[0]) { + return nil, fmt.Errorf("the oldest feature commit lacks one exact parent") + } + previous := first.Parents[0] + signatureSources := make([]string, 0, len(report.FeatureCommits)) + for _, commit := range report.FeatureCommits { + if len(commit.Parents) != 1 || commit.Parents[0] != previous { + return nil, fmt.Errorf("feature commits are not one linear parent chain") + } + if commit.AuthorName != first.AuthorName || + commit.AuthorEmail != first.AuthorEmail || + commit.CommitterName != first.CommitterName || + commit.CommitterEmail != first.CommitterEmail { + return nil, fmt.Errorf( + "feature commit %s has a different author or committer identity", + commit.OID, + ) + } + if commit.SignatureStatus == "B" { + return nil, fmt.Errorf("feature commit %s has a bad signature", commit.OID) + } + signatureSources = append(signatureSources, commit.OID) + previous = commit.OID + } + if previous != report.LocalHeadOID { + return nil, fmt.Errorf("feature commit chain does not end at the inspected head") + } + if report.PullRequest != nil { + projection, err := messageProjection(message) + if err != nil { + return nil, err + } + if !pullRequestMatchesProjection( + *report.PullRequest, + projection, + report.Base, + report.Branch, + ) { + return nil, fmt.Errorf( + "pull request title or body is not the requested consolidation projection; preserve possible human edits", + ) + } + } + return &consolidationEvidence{ + ParentOID: first.Parents[0], + AuthorOID: first.OID, + SignatureSource: signatureSources, + }, nil +} + +func messageProjection(message string) (commitProjection, error) { + normalized := normalizeText(message) + parts := strings.SplitN(normalized, "\n\n", 2) + if len(parts) != 2 || strings.Contains(parts[0], "\n") { + return commitProjection{}, fmt.Errorf( + "commit message must separate its one-line subject from the body with a blank line", + ) + } + body, err := pullRequestBody(parts[1]) + if err != nil { + return commitProjection{}, err + } + return commitProjection{Title: parts[0], Body: body}, nil +} + func (d *delivery) preflightMessageOnly( ctx context.Context, report *inspection, @@ -1267,22 +1441,27 @@ func (d *delivery) requireRewriteEvidence( return fmt.Errorf("the existing feature commit lacks the required ownership marker") } if report.PullRequest != nil { - sourceOID := report.RemoteHeadOID - if sourceOID == "" { - sourceOID = report.LocalHeadOID - } - projection, err := d.repository.projection(ctx, sourceOID) - if err != nil { - return err + sources := []string{report.LocalHeadOID} + if report.RemoteHeadOID != "" && + report.RemoteHeadOID != report.LocalHeadOID { + sources = append(sources, report.RemoteHeadOID) + } + matches := false + for _, sourceOID := range sources { + projection, projectionErr := d.repository.projection(ctx, sourceOID) + if projectionErr == nil && pullRequestMatchesProjection( + *report.PullRequest, + projection, + report.Base, + report.Branch, + ) { + matches = true + break + } } - if !pullRequestMatchesProjection( - *report.PullRequest, - projection, - report.Base, - report.Branch, - ) { + if !matches { return fmt.Errorf( - "pull request title or body is not the prior commit projection; preserve possible human edits", + "pull request title or body is not an exact owned local or remote commit projection; preserve possible human edits", ) } } @@ -1627,6 +1806,7 @@ func (d *delivery) publish( return nil, err } candidateHead := report.LocalHeadOID + rebasedScope := receipt.Scope if !baseIsAncestor { if !baseAdvanced { return nil, fmt.Errorf( @@ -1653,13 +1833,16 @@ func (d *delivery) publish( candidateRepository *gitRepository, candidateHead string, ) error { - return verifyAggregateScopeInRepository( + var reconcileErr error + rebasedScope, reconcileErr = reconcileRebasedAggregateScope( checkContext, candidateRepository, snapshot.BaseOID, candidateHead, + report.LocalHeadOID, receipt.Scope, ) + return reconcileErr }, ) if err != nil { @@ -1676,7 +1859,7 @@ func (d *delivery) publish( ctx, snapshot.BaseOID, candidateHead, - receipt.Scope, + rebasedScope, ); err != nil { return nil, fmt.Errorf("verify rebased aggregate scope: %w", err) } @@ -1689,7 +1872,7 @@ func (d *delivery) publish( candidateTree, snapshot.RemoteHeadOID, receipt.ExpectedPullRequest, - receipt.Scope, + rebasedScope, ) if err != nil { return nil, err diff --git a/tools/repo_delivery/main/go/delivery_integration_test.go b/tools/repo_delivery/main/go/delivery_integration_test.go index ee6d2a58..ff8bd70b 100644 --- a/tools/repo_delivery/main/go/delivery_integration_test.go +++ b/tools/repo_delivery/main/go/delivery_integration_test.go @@ -353,6 +353,177 @@ func (f integrationDeliveryFixture) advanceBase(t *testing.T) { runTestGit(t, f.seed, "push", "origin", "master") } +func TestPrepareStagesExplicitTrackedDeletion(t *testing.T) { + fixture := newIntegrationDeliveryFixture(t) + if err := os.MkdirAll( + filepath.Join(fixture.work, "out", "delivery"), + 0o700, + ); err != nil { + t.Fatalf("create delivery output directory: %v", err) + } + writeTestFile( + t, + filepath.Join(fixture.work, "out", "delivery", "commit.md"), + "Delete owned file\n\nExercise explicit deletion staging.\n", + ) + if err := os.Remove(filepath.Join(fixture.work, "base.txt")); err != nil { + t.Fatalf("remove tracked file: %v", err) + } + prepared, err := fixture.delivery.prepare( + context.Background(), + prepareOptions{ + MessageFile: "out/delivery/commit.md", + ReceiptFile: "out/delivery/prepare.json", + Paths: []string{"base.txt"}, + }, + ) + if err != nil { + t.Fatalf("prepare() error = %v", err) + } + if _, err := os.Stat(filepath.Join(fixture.work, "base.txt")); !os.IsNotExist(err) { + t.Fatalf("deleted path stat error = %v, want not-exist", err) + } + if output := runTestGit( + t, + fixture.work, + "ls-tree", + "--name-only", + prepared.HeadOID, + "--", + "base.txt", + ); output != "" { + t.Fatalf("prepared tree still contains deleted path: %q", output) + } +} + +func TestPrepareStagesPartialDeletionUnderExplicitDirectory(t *testing.T) { + fixture := newIntegrationDeliveryFixture(t) + if err := os.MkdirAll( + filepath.Join(fixture.work, "owned"), + 0o700, + ); err != nil { + t.Fatalf("create owned directory: %v", err) + } + keepPath := filepath.Join(fixture.work, "owned", "keep.txt") + deletePath := filepath.Join(fixture.work, "owned", "delete.txt") + writeTestFile(t, keepPath, "before\n") + writeTestFile(t, deletePath, "delete me\n") + runTestGit(t, fixture.work, "add", "owned") + runTestGit( + t, + fixture.work, + "commit", + "-m", + "Owned directory", + "-m", + commitDisclaimer, + ) + originalHead := runTestGit(t, fixture.work, "rev-parse", "HEAD") + writeTestFile(t, keepPath, "after\n") + if err := os.Remove(deletePath); err != nil { + t.Fatalf("remove tracked child: %v", err) + } + if err := os.MkdirAll( + filepath.Join(fixture.work, "out", "delivery"), + 0o700, + ); err != nil { + t.Fatalf("create delivery output directory: %v", err) + } + writeTestFile( + t, + filepath.Join(fixture.work, "out", "delivery", "commit.md"), + "Update owned directory\n\nExercise partial deletion staging.\n", + ) + prepared, err := fixture.delivery.prepare( + context.Background(), + prepareOptions{ + MessageFile: "out/delivery/commit.md", + ReceiptFile: "out/delivery/prepare.json", + Paths: []string{"owned"}, + RewriteOID: originalHead, + }, + ) + if err != nil { + t.Fatalf("prepare() error = %v", err) + } + if contents, err := os.ReadFile(keepPath); err != nil { + t.Fatalf("read retained child: %v", err) + } else if string(contents) != "after\n" { + t.Fatalf("retained child contents = %q", contents) + } + if output := runTestGit( + t, + fixture.work, + "ls-tree", + "--name-only", + prepared.HeadOID, + "--", + "owned/delete.txt", + ); output != "" { + t.Fatalf("prepared tree still contains deleted child: %q", output) + } +} + +func TestPrepareStagesSymlinkReplacedByExplicitDirectory(t *testing.T) { + fixture := newIntegrationDeliveryFixture(t) + linkPath := filepath.Join(fixture.work, "owned") + if err := os.Symlink("base.txt", linkPath); err != nil { + t.Fatalf("create tracked symlink: %v", err) + } + runTestGit(t, fixture.work, "add", "owned") + runTestGit( + t, + fixture.work, + "commit", + "-m", + "Owned symlink", + "-m", + commitDisclaimer, + ) + originalHead := runTestGit(t, fixture.work, "rev-parse", "HEAD") + if err := os.Remove(linkPath); err != nil { + t.Fatalf("remove tracked symlink: %v", err) + } + if err := os.Mkdir(linkPath, 0o700); err != nil { + t.Fatalf("create replacement directory: %v", err) + } + writeTestFile(t, filepath.Join(linkPath, "child.txt"), "child\n") + if err := os.MkdirAll( + filepath.Join(fixture.work, "out", "delivery"), + 0o700, + ); err != nil { + t.Fatalf("create delivery output directory: %v", err) + } + writeTestFile( + t, + filepath.Join(fixture.work, "out", "delivery", "commit.md"), + "Replace owned symlink\n\nExercise directory type-change staging.\n", + ) + prepared, err := fixture.delivery.prepare( + context.Background(), + prepareOptions{ + MessageFile: "out/delivery/commit.md", + ReceiptFile: "out/delivery/prepare.json", + Paths: []string{"owned"}, + RewriteOID: originalHead, + }, + ) + if err != nil { + t.Fatalf("prepare() error = %v", err) + } + if output := runTestGit( + t, + fixture.work, + "ls-tree", + "--name-only", + prepared.HeadOID, + "--", + "owned/child.txt", + ); output != "owned/child.txt" { + t.Fatalf("prepared tree child = %q, want owned/child.txt", output) + } +} + func TestDeliveryPreparePublishVerify(t *testing.T) { fixture := newIntegrationDeliveryFixture(t) prepared := fixture.prepare(t) @@ -401,6 +572,234 @@ func TestDeliveryPreparePublishVerify(t *testing.T) { } } +func TestPrepareConsolidatesExactOwnedLinearRange(t *testing.T) { + fixture := newIntegrationDeliveryFixture(t) + if err := os.MkdirAll( + filepath.Join(fixture.work, "out", "delivery"), + 0o700, + ); err != nil { + t.Fatalf("create delivery output directory: %v", err) + } + messagePath := filepath.Join( + fixture.work, + "out", + "delivery", + "commit.md", + ) + writeTestFile( + t, + messagePath, + "Consolidate delivery fixture\n\nExercise guarded range ownership.\n", + ) + featurePath := filepath.Join(fixture.work, "feature.txt") + writeTestFile(t, featurePath, "first\n") + runTestGit(t, fixture.work, "add", "feature.txt") + runTestGit( + t, + fixture.work, + "commit", + "-m", + "Owned feature", + "-m", + commitDisclaimer, + ) + message, err := withCommitDisclaimer( + "Consolidate delivery fixture\n\nExercise guarded range ownership.\n", + ) + if err != nil { + t.Fatalf("withCommitDisclaimer() error = %v", err) + } + projection, err := messageProjection(message) + if err != nil { + t.Fatalf("messageProjection() error = %v", err) + } + writeTestFile(t, featurePath, "second\n") + runTestGit(t, fixture.work, "add", "feature.txt") + runTestGit(t, fixture.work, "commit", "-m", "Follow-up correction") + originalHead := runTestGit(t, fixture.work, "rev-parse", "HEAD") + runTestGit(t, fixture.work, "push", "origin", "feature") + fixture.forge.pull = &pullRequest{ + ID: "integration-pr", + Number: 1, + URL: "https://github.com/owner/repo/pull/1", + State: "OPEN", + Title: projection.Title, + Body: projection.Body, + AuthorLogin: "task-bot", + BaseRefName: "master", + HeadRefName: "feature", + HeadRepositoryOwner: "owner", + HeadRepositoryName: "repo", + } + writeTestFile(t, featurePath, "pending\n") + + prepared, err := fixture.delivery.prepare( + context.Background(), + prepareOptions{ + MessageFile: "out/delivery/commit.md", + ReceiptFile: "out/delivery/prepare.json", + Paths: []string{"feature.txt"}, + ConsolidateOID: originalHead, + }, + ) + if err != nil { + t.Fatalf("prepare() error = %v", err) + } + if prepared.Inspection.UniqueCommitCount != 2 { + t.Fatalf( + "inspected feature count = %d, want 2", + prepared.Inspection.UniqueCommitCount, + ) + } + if prepared.HeadOID == originalHead { + t.Fatal("consolidation did not replace the original feature head") + } + if count := runTestGit( + t, + fixture.work, + "rev-list", + "--count", + prepared.Receipt.BaseOID+".."+prepared.HeadOID, + ); count != "1" { + t.Fatalf("final feature commit count = %s, want 1", count) + } + if parent := runTestGit( + t, + fixture.work, + "show", + "--no-patch", + "--format=%P", + prepared.HeadOID, + ); parent != prepared.Receipt.BaseOID { + t.Fatalf("consolidated parent = %s, want %s", parent, prepared.Receipt.BaseOID) + } + if contents, err := os.ReadFile(featurePath); err != nil { + t.Fatalf("read consolidated feature: %v", err) + } else if string(contents) != "pending\n" { + t.Fatalf("consolidated contents = %q", contents) + } + if !prepared.Receipt.ExpectedRemoteHead.Present || + prepared.Receipt.ExpectedRemoteHead.OID != originalHead { + t.Fatalf( + "receipt remote expectation = %#v, want %s", + prepared.Receipt.ExpectedRemoteHead, + originalHead, + ) + } +} + +func TestPrepareConsolidatesCleanExactOwnedRange(t *testing.T) { + fixture := newIntegrationDeliveryFixture(t) + if err := os.MkdirAll( + filepath.Join(fixture.work, "out", "delivery"), + 0o700, + ); err != nil { + t.Fatalf("create delivery output directory: %v", err) + } + writeTestFile( + t, + filepath.Join(fixture.work, "out", "delivery", "commit.md"), + "Consolidate clean delivery fixture\n", + ) + featurePath := filepath.Join(fixture.work, "feature.txt") + writeTestFile(t, featurePath, "first\n") + runTestGit(t, fixture.work, "add", "feature.txt") + runTestGit( + t, + fixture.work, + "commit", + "-m", + "Owned feature", + "-m", + commitDisclaimer, + ) + writeTestFile(t, featurePath, "second\n") + runTestGit(t, fixture.work, "add", "feature.txt") + runTestGit(t, fixture.work, "commit", "-m", "Follow-up correction") + originalHead := runTestGit(t, fixture.work, "rev-parse", "HEAD") + originalTree := runTestGit(t, fixture.work, "rev-parse", "HEAD^{tree}") + + prepared, err := fixture.delivery.prepare( + context.Background(), + prepareOptions{ + MessageFile: "out/delivery/commit.md", + ReceiptFile: "out/delivery/prepare.json", + Paths: []string{"feature.txt"}, + ConsolidateOID: originalHead, + }, + ) + if err != nil { + t.Fatalf("prepare() error = %v", err) + } + if prepared.TreeOID != originalTree { + t.Fatalf("consolidated tree = %s, want %s", prepared.TreeOID, originalTree) + } + if count := runTestGit( + t, + fixture.work, + "rev-list", + "--count", + prepared.Receipt.BaseOID+".."+prepared.HeadOID, + ); count != "1" { + t.Fatalf("final feature commit count = %s, want 1", count) + } +} + +func TestPrepareRefusesMultiCommitRangeWithoutExactConsolidation(t *testing.T) { + fixture := newIntegrationDeliveryFixture(t) + if err := os.MkdirAll( + filepath.Join(fixture.work, "out", "delivery"), + 0o700, + ); err != nil { + t.Fatalf("create delivery output directory: %v", err) + } + writeTestFile( + t, + filepath.Join(fixture.work, "out", "delivery", "commit.md"), + "Consolidate delivery fixture\n", + ) + featurePath := filepath.Join(fixture.work, "feature.txt") + writeTestFile(t, featurePath, "first\n") + runTestGit(t, fixture.work, "add", "feature.txt") + runTestGit( + t, + fixture.work, + "commit", + "-m", + "Owned feature", + "-m", + commitDisclaimer, + ) + writeTestFile(t, featurePath, "second\n") + runTestGit(t, fixture.work, "add", "feature.txt") + runTestGit(t, fixture.work, "commit", "-m", "Follow-up correction") + originalHead := runTestGit(t, fixture.work, "rev-parse", "HEAD") + writeTestFile(t, featurePath, "pending\n") + + for name, consolidateOID := range map[string]string{ + "absent": "", + "stale": testOID('a'), + } { + t.Run(name, func(t *testing.T) { + _, err := fixture.delivery.prepare( + context.Background(), + prepareOptions{ + MessageFile: "out/delivery/commit.md", + ReceiptFile: "out/delivery/prepare.json", + Paths: []string{"feature.txt"}, + ConsolidateOID: consolidateOID, + }, + ) + if err == nil { + t.Fatal("prepare() unexpectedly succeeded") + } + if got := runTestGit(t, fixture.work, "rev-parse", "HEAD"); got != originalHead { + t.Fatalf("HEAD = %s after refusal, want %s", got, originalHead) + } + }) + } +} + func TestVerifyRefusesPullRequestMetadataEditAfterInitialInspection( t *testing.T, ) { @@ -1268,6 +1667,56 @@ func TestPublishStopsBeforePushWhenBaseAdvanceChangesHead(t *testing.T) { } } +func TestPublishRebaseDropsOnlyPathsIdenticalInAdvancedBase(t *testing.T) { + fixture := newIntegrationDeliveryFixture(t) + if err := os.MkdirAll( + filepath.Join(fixture.work, "out", "delivery"), + 0o700, + ); err != nil { + t.Fatalf("create delivery scratch: %v", err) + } + writeTestFile( + t, + filepath.Join(fixture.work, "out", "delivery", "commit.md"), + "Add overlapping feature\n\nKeep the non-upstream portion.\n", + ) + writeTestFile(t, filepath.Join(fixture.work, "shared.txt"), "shared\n") + writeTestFile(t, filepath.Join(fixture.work, "unique.txt"), "unique\n") + prepared, err := fixture.delivery.prepare( + context.Background(), + prepareOptions{ + MessageFile: "out/delivery/commit.md", + ReceiptFile: "out/delivery/prepare.json", + Paths: []string{"shared.txt", "unique.txt"}, + }, + ) + if err != nil { + t.Fatalf("prepare() error = %v", err) + } + writeTestFile(t, filepath.Join(fixture.seed, "shared.txt"), "shared\n") + runTestGit(t, fixture.seed, "add", "shared.txt") + runTestGit(t, fixture.seed, "commit", "-m", "Add shared base file") + runTestGit(t, fixture.seed, "push", "origin", "master") + + report, err := fixture.delivery.publish( + context.Background(), + publishOptions{ + ValidatedHead: prepared.HeadOID, + ReceiptFile: "out/delivery/prepare.json", + }, + ) + var revalidation *revalidationRequiredError + if !errors.As(err, &revalidation) { + t.Fatalf("publish() error = %v, want revalidation", err) + } + if report == nil || report.Receipt == nil || !reflect.DeepEqual( + report.Receipt.Scope.AggregatePaths, + []string{"unique.txt"}, + ) { + t.Fatalf("rebased aggregate scope = %#v", report) + } +} + func TestPreparePathConstrainsExistingAggregateDiff(t *testing.T) { fixture := newIntegrationDeliveryFixture(t) writeTestFile(t, filepath.Join(fixture.work, "outside.txt"), "outside\n") @@ -1410,6 +1859,58 @@ func TestMessageOnlyRetainsPreAmendSnapshot(t *testing.T) { } } +func TestRewriteEvidenceAcceptsLocalProjectionWhenRemoteCannotProject(t *testing.T) { + fixture := newIntegrationDeliveryFixture(t) + prepared := fixture.prepare(t) + projection, err := fixture.delivery.repository.projection( + context.Background(), + prepared.HeadOID, + ) + if err != nil { + t.Fatalf("project local aggregate: %v", err) + } + runTestGit(t, fixture.seed, "switch", "-c", "feature") + writeTestFile(t, filepath.Join(fixture.seed, "legacy.txt"), "legacy\n") + runTestGit(t, fixture.seed, "add", "legacy.txt") + runTestGit(t, fixture.seed, "commit", "-m", "Legacy remote tail") + runTestGit(t, fixture.seed, "push", "origin", "feature") + fixture.forge.pull = &pullRequest{ + ID: "integration-pr", + Number: 1, + URL: "https://github.com/owner/repo/pull/1", + State: "OPEN", + Title: projection.Title, + Body: projection.Body, + AuthorLogin: "task-bot", + BaseRefName: "master", + HeadRefName: "feature", + HeadRepositoryOwner: "owner", + HeadRepositoryName: "repo", + } + report, err := fixture.delivery.inspect(context.Background()) + if err != nil { + t.Fatalf("inspect divergent rewrite fixture: %v", err) + } + if report.RemoteHeadOID == "" || report.RemoteHeadOID == prepared.HeadOID { + t.Fatalf("remote head = %q, want divergent legacy commit", report.RemoteHeadOID) + } + if err := fixture.delivery.requireRewriteEvidence( + context.Background(), + report, + prepared.HeadOID, + ); err != nil { + t.Fatalf("requireRewriteEvidence() error = %v", err) + } + report.PullRequest.Body = "Human-edited body.\n" + if err := fixture.delivery.requireRewriteEvidence( + context.Background(), + report, + prepared.HeadOID, + ); err == nil || !strings.Contains(err.Error(), "preserve possible human edits") { + t.Fatalf("human-edit error = %v", err) + } +} + func TestPreparedIndexReceiptFreezesExactAggregatePaths(t *testing.T) { fixture := newIntegrationDeliveryFixture(t) if err := os.MkdirAll( diff --git a/tools/repo_delivery/main/go/delivery_test.go b/tools/repo_delivery/main/go/delivery_test.go index 313a3b20..61d3e428 100644 --- a/tools/repo_delivery/main/go/delivery_test.go +++ b/tools/repo_delivery/main/go/delivery_test.go @@ -330,6 +330,229 @@ func TestPrepareReplaceRemoteFlagPreservesLiteralOID(t *testing.T) { } } +func TestPrepareConsolidateFlagPreservesLiteralOID(t *testing.T) { + t.Parallel() + want := testOID('c') + command := newPrepareCommand( + context.Background(), + &deliveryConfig{}, + func(string) string { return "" }, + io.Discard, + nil, + ) + if err := command.Flags().Parse([]string{ + "--message-file", "out/task/message.md", + "--receipt-file", "out/task/prepare.json", + "--consolidate", want, + }); err != nil { + t.Fatalf("Parse() error = %v", err) + } + got, err := command.Flags().GetString("consolidate") + if err != nil { + t.Fatalf("GetString() error = %v", err) + } + if got != want { + t.Fatalf("--consolidate = %q, want %q", got, want) + } +} + +func TestRequireConsolidationEvidence(t *testing.T) { + t.Parallel() + parentOID := testOID('1') + firstOID := testOID('2') + headOID := testOID('3') + base := inspection{ + LocalHeadOID: headOID, + UniqueCommitCount: 2, + FeatureCommits: []featureCommit{ + { + OID: firstOID, + Parents: []string{parentOID}, + AuthorName: "Task Bot", + AuthorEmail: "task@example.com", + CommitterName: "Task Bot", + CommitterEmail: "task@example.com", + SignatureStatus: "N", + HasDisclaimer: true, + }, + { + OID: headOID, + Parents: []string{firstOID}, + AuthorName: "Task Bot", + AuthorEmail: "task@example.com", + CommitterName: "Task Bot", + CommitterEmail: "task@example.com", + SignatureStatus: "N", + }, + }, + } + evidence, err := (&delivery{}).requireConsolidationEvidence( + context.Background(), + &base, + headOID, + "Consolidated change\n\nDetails.\n\n"+commitDisclaimer+"\n", + ) + if err != nil { + t.Fatalf("requireConsolidationEvidence() error = %v", err) + } + if evidence.ParentOID != parentOID || evidence.AuthorOID != firstOID || + !reflect.DeepEqual(evidence.SignatureSource, []string{firstOID, headOID}) { + t.Fatalf("consolidation evidence = %#v", evidence) + } + + tests := []struct { + name string + mutate func(*inspection) + expectedOID string + want string + }{ + { + name: "stale exact head", + expectedOID: testOID('4'), + want: "differs from the exact inspected local head", + }, + { + name: "missing ownership marker", + mutate: func(report *inspection) { + report.FeatureCommits[0].HasDisclaimer = false + }, + expectedOID: headOID, + want: "lacks the required ownership marker", + }, + { + name: "nonlinear chain", + mutate: func(report *inspection) { + report.FeatureCommits[1].Parents = []string{parentOID} + }, + expectedOID: headOID, + want: "not one linear parent chain", + }, + { + name: "different identity", + mutate: func(report *inspection) { + report.FeatureCommits[1].CommitterEmail = "human@example.com" + }, + expectedOID: headOID, + want: "different author or committer identity", + }, + { + name: "bad signature", + mutate: func(report *inspection) { + report.FeatureCommits[1].SignatureStatus = "B" + }, + expectedOID: headOID, + want: "has a bad signature", + }, + } + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + report := base + report.FeatureCommits = append( + []featureCommit(nil), + base.FeatureCommits..., + ) + for index := range report.FeatureCommits { + report.FeatureCommits[index].Parents = append( + []string(nil), + report.FeatureCommits[index].Parents..., + ) + } + if test.mutate != nil { + test.mutate(&report) + } + _, err := (&delivery{}).requireConsolidationEvidence( + context.Background(), + &report, + test.expectedOID, + "Consolidated change\n\nDetails.\n\n"+commitDisclaimer+"\n", + ) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want substring %q", err, test.want) + } + }) + } +} + +func TestRequireConsolidationEvidencePreservesRequestedPullRequest(t *testing.T) { + t.Parallel() + parentOID := testOID('1') + firstOID := testOID('2') + headOID := testOID('3') + message := "Consolidated change\n\nCurrent aggregate description.\n\n" + + commitDisclaimer + "\n" + projection, err := messageProjection(message) + if err != nil { + t.Fatalf("messageProjection() error = %v", err) + } + report := inspection{ + LocalHeadOID: headOID, + UniqueCommitCount: 2, + Base: "master", + Branch: "feature", + PullRequest: &pullRequest{ + Title: projection.Title, + Body: projection.Body, + BaseRefName: "master", + HeadRefName: "feature", + }, + FeatureCommits: []featureCommit{ + { + OID: firstOID, + Parents: []string{parentOID}, + AuthorName: "Task Bot", + AuthorEmail: "task@example.com", + CommitterName: "Task Bot", + CommitterEmail: "task@example.com", + SignatureStatus: "N", + HasDisclaimer: true, + }, + { + OID: headOID, + Parents: []string{firstOID}, + AuthorName: "Task Bot", + AuthorEmail: "task@example.com", + CommitterName: "Task Bot", + CommitterEmail: "task@example.com", + SignatureStatus: "N", + }, + }, + } + if _, err := (&delivery{}).requireConsolidationEvidence( + context.Background(), + &report, + headOID, + message, + ); err != nil { + t.Fatalf("requireConsolidationEvidence() error = %v", err) + } + report.PullRequest.Body = "Human-edited body.\n" + if _, err := (&delivery{}).requireConsolidationEvidence( + context.Background(), + &report, + headOID, + message, + ); err == nil || !strings.Contains(err.Error(), "requested consolidation projection") { + t.Fatalf("error = %v, want requested projection refusal", err) + } +} + +func TestEnsurePreparationRefusalsOnlyWaivesMultiCommitOwnership(t *testing.T) { + t.Parallel() + report := inspection{ + UniqueCommitCount: 2, + Refusals: []string{multiCommitRefusal(2)}, + } + if err := ensurePreparationRefusals(&report, true); err != nil { + t.Fatalf("ensurePreparationRefusals() error = %v", err) + } + report.Refusals = append(report.Refusals, "unrelated refusal") + if err := ensurePreparationRefusals(&report, true); err == nil || + !strings.Contains(err.Error(), "unrelated refusal") { + t.Fatalf("error = %v, want unrelated refusal", err) + } +} + func TestRequireRemoteReplacementAuthorization(t *testing.T) { t.Parallel() remoteOID := testOID('a') diff --git a/tools/repo_delivery/main/go/git.go b/tools/repo_delivery/main/go/git.go index b19ab40a..dab7a7f2 100644 --- a/tools/repo_delivery/main/go/git.go +++ b/tools/repo_delivery/main/go/git.go @@ -1647,19 +1647,40 @@ func (s *preparationState) stagePaths( // Force tracked entries through the clean/content pipeline before the // ordinary all-changes add. This avoids trusting a racy same-size, // same-timestamp stat match copied from the main index. - renormalizeArguments := []string{ - "--literal-pathspecs", - "add", - "--renormalize", - "--", + trackedArguments := []string{"--literal-pathspecs", "ls-files", "-z", "--"} + trackedArguments = append(trackedArguments, paths...) + tracked, err := g.runEnvironment(ctx, environment, trackedArguments...) + if err != nil { + return fmt.Errorf("enumerate tracked explicit task paths: %w", err) } - renormalizeArguments = append(renormalizeArguments, paths...) - if _, err := g.runEnvironment( - ctx, - environment, - renormalizeArguments..., - ); err != nil { - return fmt.Errorf("force-refresh explicit tracked task paths: %w", err) + renormalizePaths := make([]string, 0, len(paths)) + for _, path := range strings.Split(tracked.Stdout, "\x00") { + if path == "" { + continue + } + if info, err := os.Lstat(filepath.Join(g.directory, filepath.FromSlash(path))); err == nil { + if !info.IsDir() { + renormalizePaths = append(renormalizePaths, path) + } + } else if !os.IsNotExist(err) { + return fmt.Errorf("inspect explicit task path %q: %w", path, err) + } + } + if len(renormalizePaths) != 0 { + renormalizeArguments := []string{ + "--literal-pathspecs", + "add", + "--renormalize", + "--", + } + renormalizeArguments = append(renormalizeArguments, renormalizePaths...) + if _, err := g.runEnvironment( + ctx, + environment, + renormalizeArguments..., + ); err != nil { + return fmt.Errorf("force-refresh explicit tracked task paths: %w", err) + } } arguments := []string{"--literal-pathspecs", "add", "-A", "--"} arguments = append(arguments, paths...) @@ -2149,6 +2170,29 @@ func (g *gitRepository) changedPaths( return paths, nil } +func (g *gitRepository) pathEntry( + ctx context.Context, + object string, + path string, +) (string, error) { + if !isObjectID(object) { + return "", fmt.Errorf("tree entry object is not a full Git object ID") + } + result, err := g.run( + ctx, + "--literal-pathspecs", + "ls-tree", + "-z", + object, + "--", + path, + ) + if err != nil { + return "", fmt.Errorf("inspect tree entry %q: %w", path, err) + } + return result.Stdout, nil +} + func strictNULPaths(value string) ([]string, error) { if value == "" { return nil, nil @@ -2173,7 +2217,16 @@ func (g *gitRepository) rangeDiffCheck( if err := requireObjectIDRange(base, head); err != nil { return err } - if _, err := g.run(ctx, "diff", "--check", base, head); err != nil { + result, err := g.run(ctx, "diff", "--check", base, head) + if err != nil { + detail := strings.TrimSpace(redactCredentials(result.Stdout)) + if detail != "" { + return fmt.Errorf( + "aggregate git diff --check failed: %s: %w", + detail, + err, + ) + } return fmt.Errorf("aggregate git diff --check failed: %w", err) } return nil @@ -2748,10 +2801,93 @@ func (g *gitRepository) commitChecked( branch string, observer installedCheckoutObserver, flagPathSets ...[]string, +) (string, error) { + parents := []string{expectedHead} + authorOID := "" + signatureSources := []string(nil) + if amend { + var err error + parents, err = g.commitParents(ctx, expectedHead) + if err != nil { + return "", err + } + authorOID = expectedHead + signatureSources = []string{expectedHead} + } + return g.commitPlannedChecked( + ctx, + message, + expectedHead, + expectedTree, + branch, + parents, + authorOID, + signatureSources, + observer, + flagPathSets..., + ) +} + +func (g *gitRepository) commitConsolidatedChecked( + ctx context.Context, + message string, + expectedHead string, + expectedTree string, + branch string, + parentOID string, + authorOID string, + signatureSources []string, + observer installedCheckoutObserver, + flagPathSets ...[]string, +) (string, error) { + if !isObjectID(parentOID) || !isObjectID(authorOID) { + return "", fmt.Errorf("consolidated commit plan contains an invalid object ID") + } + if len(signatureSources) == 0 { + return "", fmt.Errorf("consolidated commit plan lacks signature sources") + } + for _, oid := range signatureSources { + if !isObjectID(oid) { + return "", fmt.Errorf("consolidated signature source is not a full object ID") + } + } + return g.commitPlannedChecked( + ctx, + message, + expectedHead, + expectedTree, + branch, + []string{parentOID}, + authorOID, + signatureSources, + observer, + flagPathSets..., + ) +} + +func (g *gitRepository) commitPlannedChecked( + ctx context.Context, + message string, + expectedHead string, + expectedTree string, + branch string, + expectedParents []string, + authorOID string, + signatureSources []string, + observer installedCheckoutObserver, + flagPathSets ...[]string, ) (string, error) { if len(flagPathSets) > 1 { return "", fmt.Errorf("commit accepts at most one index-flag path set") } + if len(expectedParents) > 1 { + return "", fmt.Errorf("aggregate commit accepts at most one parent") + } + for _, parent := range expectedParents { + if !isObjectID(parent) { + return "", fmt.Errorf("aggregate commit parent is not a full object ID") + } + } if err := g.requireCompleteHistory(ctx); err != nil { return "", err } @@ -2788,14 +2924,12 @@ func (g *gitRepository) commitChecked( expectedTree, ) } - expectedParents := []string{expectedHead} var expectedAuthor *commitAuthor - if amend { - expectedParents, err = g.commitParents(ctx, expectedHead) - if err != nil { - return "", err + if authorOID != "" { + if !isObjectID(authorOID) { + return "", fmt.Errorf("aggregate author source is not a full object ID") } - author, err := g.author(ctx, expectedHead) + author, err := g.author(ctx, authorOID) if err != nil { return "", err } @@ -2805,8 +2939,11 @@ func (g *gitRepository) commitChecked( if err != nil { return "", err } - if amend && !sign { - sign, err = g.commitHasSignature(ctx, expectedHead) + for _, source := range signatureSources { + if sign { + break + } + sign, err = g.commitHasSignature(ctx, source) if err != nil { return "", err } diff --git a/tools/repo_delivery/main/go/receipt.go b/tools/repo_delivery/main/go/receipt.go index 83f14636..b62aac0a 100644 --- a/tools/repo_delivery/main/go/receipt.go +++ b/tools/repo_delivery/main/go/receipt.go @@ -594,6 +594,70 @@ func verifyAggregateScopeInRepository( return refusePathsOutside(paths, scope.AuthorizedPaths, "aggregate") } +func reconcileRebasedAggregateScope( + ctx context.Context, + repository *gitRepository, + baseOID string, + headOID string, + priorHeadOID string, + scope aggregateScope, +) (aggregateScope, error) { + if err := scope.validate(); err != nil { + return aggregateScope{}, err + } + if err := repository.rangeDiffCheck(ctx, baseOID, headOID); err != nil { + return aggregateScope{}, err + } + paths, err := repository.changedPaths(ctx, baseOID, headOID) + if err != nil { + return aggregateScope{}, err + } + expected := make(map[string]bool, len(scope.AggregatePaths)) + for _, path := range scope.AggregatePaths { + expected[path] = true + } + observed := make(map[string]bool, len(paths)) + for _, path := range paths { + if !expected[path] { + return aggregateScope{}, fmt.Errorf( + "rebased aggregate introduced path %q", + path, + ) + } + observed[path] = true + } + for _, path := range scope.AggregatePaths { + if observed[path] { + continue + } + priorEntry, err := repository.pathEntry(ctx, priorHeadOID, path) + if err != nil { + return aggregateScope{}, err + } + baseEntry, err := repository.pathEntry(ctx, baseOID, path) + if err != nil { + return aggregateScope{}, err + } + if priorEntry != baseEntry { + return aggregateScope{}, fmt.Errorf( + "rebased aggregate lost non-identical path %q", + path, + ) + } + } + if len(paths) == 0 { + return aggregateScope{}, fmt.Errorf("rebased aggregate path scope is empty") + } + if err := refusePathsOutside(paths, scope.AuthorizedPaths, "aggregate"); err != nil { + return aggregateScope{}, err + } + scope.AggregatePaths = paths + if err := scope.validate(); err != nil { + return aggregateScope{}, err + } + return scope, nil +} + func (d *delivery) validateReceiptContext( ctx context.Context, receipt preparationReceipt,