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,