diff --git a/.github/scripts/next-release-version.sh b/.github/scripts/next-release-version.sh new file mode 100755 index 00000000..a5766479 --- /dev/null +++ b/.github/scripts/next-release-version.sh @@ -0,0 +1,65 @@ +#!/usr/bin/env bash + +set -euo pipefail + +if [ "$#" -ne 2 ]; then + echo "usage: $0 " >&2 + exit 2 +fi + +CURRENT_TAG="$1" +REVISION_RANGE="$2" + +if [[ ! "$CURRENT_TAG" =~ ^v([0-9]+)[.]([0-9]+)[.]([0-9]+)$ ]]; then + echo "current release must be a stable vMAJOR.MINOR.PATCH tag (got '$CURRENT_TAG')" >&2 + exit 2 +fi + +# Documentation, workflow, and test-only changes remain auditable in git but do +# not produce a deployable release. Any unrecognised path is treated as +# deployable so a newly added runtime component cannot silently miss a release. +DEPLOYABLE=false +while IFS= read -r path; do + case "$path" in + .github/*|docs/*|tests/*|*.md|*/README|*/README.*|*.test.*|*.spec.*) + ;; + *) + DEPLOYABLE=true + break + ;; + esac +done < <(git diff --name-only "$REVISION_RANGE") + +if [ "$DEPLOYABLE" = "false" ]; then + exit 0 +fi + +COMMIT_MESSAGES="$(git log --format='%s%n%b' "$REVISION_RANGE")" +BUMP=patch + +if grep -Eq '^[[:alnum:]_-]+(\([^)]*\))?!:' <<<"$COMMIT_MESSAGES" \ + || grep -Eq '^BREAKING([ -])CHANGE:' <<<"$COMMIT_MESSAGES"; then + BUMP=major +elif grep -Eq '^feat(\([^)]*\))?:' <<<"$COMMIT_MESSAGES"; then + BUMP=minor +fi + +VERSION="${CURRENT_TAG#v}" +IFS=. read -r MAJOR MINOR PATCH <<<"$VERSION" + +case "$BUMP" in + major) + MAJOR=$((MAJOR + 1)) + MINOR=0 + PATCH=0 + ;; + minor) + MINOR=$((MINOR + 1)) + PATCH=0 + ;; + patch) + PATCH=$((PATCH + 1)) + ;; +esac + +printf 'v%s.%s.%s\n' "$MAJOR" "$MINOR" "$PATCH" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2bdeda21..07622ee7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -43,6 +43,9 @@ jobs: - name: Compose bridge configuration run: node tests/compose-bridge-config.cjs + - name: Release versioning + run: tests/release-versioning.sh + - name: Validate sandbox Dockerfiles run: | docker buildx build --check -f api/Dockerfile . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 55c7a116..c47b1a0d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,7 +2,11 @@ # file is inert inside the monorepo — GitHub only runs workflows from the repo # root — and becomes a root workflow in the published repo. # -# Two entry points feed one job: +# Three entry points feed one job: +# +# * successful CI on main — automatically releases deployable changes. The +# next repository version follows Conventional Commit intent; changes that +# only touch docs, workflows, or tests do not cut a release. # # * workflow_dispatch — pick a version in the Actions UI. The chart is # packaged before the tag is created, so a packaging failure aborts while @@ -18,10 +22,13 @@ name: Release on: + workflow_run: + workflows: ['CI'] + types: [completed] workflow_dispatch: inputs: version: - description: 'Version to release, e.g. v2.0.0 or v2.1.0-rc1. Must match helm/codeapi/Chart.yaml appVersion.' + description: 'Repository version to release, e.g. v1.0.0 or v1.1.0-rc1.' required: true type: string draft: @@ -36,12 +43,20 @@ permissions: contents: write concurrency: - group: release-${{ github.event.inputs.version || github.ref_name }} + # Automatic runs serialize against one another. If main advances before an + # older run starts, version resolution skips the stale SHA and the newest + # successful run releases the full range instead. + group: release-${{ github.event_name == 'workflow_run' && 'main' || github.event.inputs.version || github.ref_name }} cancel-in-progress: false jobs: release: name: Tag and publish + if: >- + github.event_name != 'workflow_run' || + (github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main') runs-on: ubuntu-latest timeout-minutes: 30 steps: @@ -50,19 +65,60 @@ jobs: # Full history and tags: resolving whether this release is the newest # stable one compares it against every other tag in the repository. fetch-depth: 0 + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.ref }} - name: Resolve and validate version id: version env: EVENT_NAME: ${{ github.event_name }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} INPUT_VERSION: ${{ github.event.inputs.version }} INPUT_DRAFT: ${{ github.event.inputs.draft }} REF_NAME: ${{ github.ref_name }} REF_TYPE: ${{ github.ref_type }} + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + SKIP=false + if [ "$EVENT_NAME" = "workflow_run" ]; then + if [ "$(git rev-parse HEAD)" != "$HEAD_SHA" ]; then + echo "::error::Checked out SHA does not match the successful CI run" + exit 1 + fi + + git fetch --no-tags origin main:refs/remotes/origin/main + if [ "$(git rev-parse refs/remotes/origin/main)" != "$HEAD_SHA" ]; then + echo "main advanced after this CI run; the newer successful run will release the combined changes" + SKIP=true + fi + + # A rerun after tag creation but before release publication resumes + # the missing release rather than incrementing the version again. + EXACT_TAG="$({ git tag --points-at HEAD || true; } | grep -E '^v[0-9]+[.][0-9]+[.][0-9]+$' | sort -V | tail -n 1)" + if [ "$SKIP" = "false" ] && [ -n "$EXACT_TAG" ]; then + if gh release view "$EXACT_TAG" >/dev/null 2>&1; then + echo "$EXACT_TAG already publishes this commit; nothing to do" + SKIP=true + else + VERSION="$EXACT_TAG" + fi + elif [ "$SKIP" = "false" ]; then + PREVIOUS_TAG="$(git tag --merged HEAD \ + | grep -E '^v[0-9]+[.][0-9]+[.][0-9]+$' \ + | sort -V \ + | tail -n 1)" + if [ -z "$PREVIOUS_TAG" ]; then + echo "::error::Automatic releases require an existing stable vMAJOR.MINOR.PATCH tag" + exit 1 + fi + VERSION="$(.github/scripts/next-release-version.sh "$PREVIOUS_TAG" "$PREVIOUS_TAG..HEAD")" + if [ -z "$VERSION" ]; then + echo "Only documentation, workflow, or test files changed since $PREVIOUS_TAG; no release needed" + SKIP=true + fi + fi + elif [ "$EVENT_NAME" = "workflow_dispatch" ]; then # Releases describe what shipped to main. Dispatching from a topic # branch would tag a commit that is not on the release line. if [ "$REF_TYPE" != "branch" ] || [ "$REF_NAME" != "main" ]; then @@ -74,6 +130,11 @@ jobs: VERSION="$REF_NAME" fi + if [ "$SKIP" = "true" ]; then + echo "skip=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + # A bare "2.0.0" typed into the dispatch box is accepted; everything # downstream works with the v-prefixed form the tag actually uses. case "$VERSION" in @@ -82,14 +143,15 @@ jobs: esac if [[ ! "$VERSION" =~ ^v[0-9]+[.][0-9]+[.][0-9]+(-rc[0-9]+)?$ ]]; then - echo "::error::Release tags must be v.. or v..-rcN, for example v2.0.0 or v2.1.0-rc1 (got '$VERSION')" + echo "::error::Release tags must be v.. or v..-rcN, for example v1.0.0 or v1.1.0-rc1 (got '$VERSION')" exit 1 fi - # v2.1.0-rc1 -> 2.1.0. Release candidates carry the version they are - # candidates for, so they compare against the same appVersion. - BASE_VERSION="${VERSION%%-rc*}" - BASE_VERSION="${BASE_VERSION#v}" + if [ "$EVENT_NAME" = "workflow_run" ] \ + && git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then + echo "::error::Calculated tag $VERSION already exists on a different commit" + exit 1 + fi read_chart_field() { grep -m1 "^$1:" helm/codeapi/Chart.yaml \ @@ -98,14 +160,6 @@ jobs: APP_VERSION="$(read_chart_field appVersion)" CHART_VERSION="$(read_chart_field version)" - # The tag is the app version. Requiring the bump to have landed on - # main first keeps a deployed chart from reporting a version that no - # release ever carried. - if [ "$APP_VERSION" != "$BASE_VERSION" ]; then - echo "::error::Tag $VERSION does not match helm/codeapi/Chart.yaml appVersion ($APP_VERSION). Land the appVersion bump on main before releasing." - exit 1 - fi - if [ "$EVENT_NAME" = "workflow_dispatch" ] \ && git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then echo "::error::Tag $VERSION already exists. Pick a new version, or delete the tag if it was cut in error." @@ -142,8 +196,8 @@ jobs: fi { + echo "skip=false" echo "version=$VERSION" - echo "base_version=$BASE_VERSION" echo "app_version=$APP_VERSION" echo "chart_version=$CHART_VERSION" echo "prerelease=$PRERELEASE" @@ -157,6 +211,7 @@ jobs: # ci.yml depend on it. - name: Package Helm chart id: chart + if: steps.version.outputs.skip != 'true' run: | set -euo pipefail @@ -183,17 +238,20 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Create tag - if: github.event_name == 'workflow_dispatch' + if: steps.version.outputs.skip != 'true' && github.event_name != 'push' env: VERSION: ${{ steps.version.outputs.version }} run: | set -euo pipefail git config user.name 'github-actions[bot]' git config user.email '41898282+github-actions[bot]@users.noreply.github.com' - git tag -a "$VERSION" -m "$VERSION" - git push origin "refs/tags/$VERSION" + if ! git rev-parse -q --verify "refs/tags/$VERSION" >/dev/null; then + git tag -a "$VERSION" -m "$VERSION" + git push origin "refs/tags/$VERSION" + fi - name: Publish release + if: steps.version.outputs.skip != 'true' env: GH_TOKEN: ${{ github.token }} VERSION: ${{ steps.version.outputs.version }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 37ecc984..e7b95797 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -25,10 +25,12 @@ Practical consequences: ## Releases Tagged releases are cut from `main` as `vMAJOR.MINOR.PATCH` (with `-rcN` for -release candidates), and each one carries the packaged Helm chart. The version -comes from `helm/codeapi/Chart.yaml`'s `appVersion`, so a version bump lands on -`main` through the pull request flow above before it can be released. See -[docs/RELEASING.md](docs/RELEASING.md) for the full process. +release candidates), and each one carries the packaged Helm chart. Repository, +API, service, and chart versions advance independently; component version bumps +land on `main` through the pull request flow above before they are included in a +release. Successful `main` CI automatically releases deployable changes while +documentation, workflow, and test-only changes are skipped. See +[docs/RELEASING.md](docs/RELEASING.md) for the full process and manual path. ## Development diff --git a/README.md b/README.md index bad5b66e..3856b254 100644 --- a/README.md +++ b/README.md @@ -108,14 +108,14 @@ Deployments should pin a [tagged release](https://github.com/LibreChat-AI/code-i rather than track `main`, which moves whenever an internal snapshot is merged: ```bash -git clone --branch v2.0.0 --depth 1 https://github.com/LibreChat-AI/code-interpreter.git +git clone --branch v1.0.0 --depth 1 https://github.com/LibreChat-AI/code-interpreter.git ``` Every release attaches `codeapi-.tgz`, the packaged Helm chart with its Redis and MinIO subcharts vendored: ```bash -helm install codeapi ./codeapi-0.3.0.tgz -f my-values.yaml +helm install codeapi ./codeapi-0.3.1.tgz -f my-values.yaml ``` Versions are `vMAJOR.MINOR.PATCH`, with `-rcN` release candidates published as diff --git a/docs/RELEASING.md b/docs/RELEASING.md index dd00c96d..3b2ff3ef 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -6,44 +6,66 @@ tags are cut. ## Versioning A release is named `vMAJOR.MINOR.PATCH`, optionally with a `-rcN` suffix for a -release candidate — `v2.0.0`, `v2.1.0-rc1`. That version is the **app -version**: `helm/codeapi/Chart.yaml`'s `appVersion` is its source of truth, and -the release workflow refuses any tag that disagrees with it. A release -candidate carries the version it is a candidate for, so `v2.1.0-rc1` also -requires `appVersion: "2.1.0"`. +release candidate — `v1.0.0`, `v1.1.0-rc1`. This is the public repository's +release sequence and is independent from the versions of the components it +contains. The first public release is therefore `v1.0.0` even though the API, +service, and Helm chart already have their own version histories. -Two other version numbers are deliberately independent: +Component versions are deliberately independent: +- `helm/codeapi/Chart.yaml`'s `appVersion` identifies the API version deployed + by the chart. - `helm/codeapi/Chart.yaml`'s `version` is the **chart** version. Bump it when the chart's templates or values change, not when the app changes. It names the packaged chart attached to the release (`codeapi-.tgz`). - `service/package.json`'s `version` tracks the Lambda service package alone. -By convention `api/package.json`'s `version` is kept in step with `appVersion`, -so the API package and the tag agree. Nothing enforces it. +By convention `api/package.json`'s `version` is kept in step with `appVersion`. +Nothing enforces it. -## Cutting a release +## Automatic releases -1. Land the `appVersion` bump on `main` first. `main` takes no direct pushes - (see [CONTRIBUTING.md](../CONTRIBUTING.md)), so it arrives through a sync - pull request from the internal monorepo or a community pull request. Bump - the chart `version` too if the chart changed. +After the full **CI** workflow succeeds for the current tip of `main`, the +release workflow examines everything since the last stable repository tag. It +cuts a release when that range changes deployable files and skips ranges that +only change documentation, GitHub workflows, or tests. If several merges land +while CI is running, the newest successful run releases them together. + +The next version follows Conventional Commit intent across the unreleased +range: + +- a `BREAKING CHANGE:` footer or `type!:` subject bumps the major version; +- a `feat:` subject bumps the minor version; +- every other deployable change bumps the patch version. + +This makes the safe fallback a patch release even when a merge title does not +follow the convention. The workflow packages the Helm chart before creating +the tag, so a packaging failure leaves the version available for a retry. A +rerun also resumes publication if the tag was created before a later step +failed. + +## Manual releases + +1. Land every intended component version bump on `main` first. `main` takes no + direct pushes (see [CONTRIBUTING.md](../CONTRIBUTING.md)), so changes arrive + through a sync pull request from the internal monorepo or a community pull + request. Bump the chart `version` only if the chart changed. 2. Run the **Release** workflow from the Actions tab against `main`, entering - the version (`v2.1.0`). Tick *draft* to review the generated notes before - they go public. + the next repository version (`v1.1.0`). Tick *draft* to review the generated + notes before they go public. -The workflow validates the version, packages the Helm chart, then creates the -annotated tag and publishes the release. Packaging runs before tagging so a -failure — a rate-limited subchart pull, most likely — leaves the version -unused and the run safe to retry. +Use this path when intentionally overriding the automatically selected version, +cutting a release candidate, or recovering while automatic releases are +disabled. The workflow validates the version, packages the Helm chart, then +creates the annotated tag and publishes the release. A tag pushed by hand works as well, and takes the same path from validation onward: ```bash git checkout main && git pull -git tag -a v2.1.0 -m v2.1.0 -git push origin v2.1.0 +git tag -a v1.1.0 -m v1.1.0 +git push origin v1.1.0 ``` ## What the release contains @@ -64,7 +86,7 @@ repository, so re-cutting an older patch cannot drag it backwards. Delete the release and its tag, then re-run the workflow: ```bash -gh release delete v2.1.0 --cleanup-tag --yes +gh release delete v1.1.0 --cleanup-tag --yes ``` Republishing the same version is only safe while nobody has deployed it. Once diff --git a/docs/remote-bridge/README.md b/docs/remote-bridge/README.md index b9de3ac2..f66499b2 100644 --- a/docs/remote-bridge/README.md +++ b/docs/remote-bridge/README.md @@ -3,6 +3,10 @@ Remote Code Bridge makes an operator-owned VM a stateful Code API execution environment without exposing that VM to inbound internet traffic. +For an end-to-end host setup, including pairing, named environments, systemd, +launchd, GitHub App credentials, upgrades, verification, and recovery, see the +[self-hosted worker runbook](./worker-runbook.md). + ```text LibreChat -> Code API -> Redis assignment ^ | @@ -200,6 +204,7 @@ Expose the Code API deployment as an environment under the Agents endpoint: endpoints: agents: statefulCodeSessions: + allowedEnvironments: [user, agent-user, conversation] environments: - id: my-vm name: My VM diff --git a/docs/remote-bridge/projects.md b/docs/remote-bridge/projects.md new file mode 100644 index 00000000..987ce112 --- /dev/null +++ b/docs/remote-bridge/projects.md @@ -0,0 +1,69 @@ +# Projects and worktrees + +The intended experience is to select a project on an attached machine, choose +the current checkout or a new worktree, and have subsequent tool calls and +approval resumes use that selection without repeating a working directory. + +## Delivery sequence + +1. Local project inventory (`librechat-code projects --root `). + Discover bounded Git metadata without changing registration or authority. +2. Negotiated project selection across the worker, Code API, and LibreChat. + Persist the selection and enforce the same project boundary in file tools, + commands, programmatic execution, environment actions, and approval resumes. +3. Worktree creation and setup with durable operation receipts. Publish a new + selection only after Git creation, setup, and registration have completed. +4. Worktree selection in the composer and an approved agent operation. Allow + an authenticated owner to narrow a selection to a newly created worktree + with a compare-and-set against the prior conversation decision. +5. Explicit listing and removal, with binding checks and recovery for uncertain + outcomes. Retention policy determines eligibility, not automatic permission + to destroy uncommitted work or unpublished commits. + +Only the first step is implemented by the inventory command. Existing explicit +workspace registration remains available for independent project directories. + +## Boundaries that must remain consistent + +- Discovery metadata is advisory. A remote is not an authorization grant, a + trusted repository identity, or automatically a codegraph repository ID. + Keep the host in normalized remotes to distinguish identically named repos. +- A discovery root may authorize enumeration while an execution root narrows + writes to one selected project. A broad parent must not remain an independent + concurrent execution lane alongside its descendants. +- Path-derived IDs must be scoped by the registered root. Admission must + validate the current directory identity; inventory cannot reserve a path + against replacement after discovery. +- Discovery must not run on every status request. A future worker catalog + needs bounded caching, coalesced refreshes, and explicit generation changes. +- A linked worktree shares Git metadata with its parent. Project IDs alone + cannot make those metadata mutations independent. Admission needs both a + filesystem boundary and coordination for the common Git directory. +- A worktree beneath its parent checkout overlaps that checkout. Either use + disjoint execution roots under a discovery grant or explicitly exclude and + coordinate descendant worktrees before relaxing root exclusion. +- Setup and dependency links must remain within the execution policy. Sharing + writable dependency directories between supposedly isolated worktrees + reintroduces overlap and requires an explicit operator decision. +- Dynamic registration requires versioned capabilities and fenced catalog + generations. Old consumers must not silently drop a project selection and + execute against its broader parent. Deploy consumers before producers. +- Create, setup, registration, and conversation binding form a recoverable + lifecycle. A network retry must find the same worktree, not create a second + one. Failed setup leaves it unavailable; uncertain mutation quarantines it. +- Approval decisions must include the exact target and operation. Creating a + branch changes repository state and follows mutation policy. An additive + operation is not automatically exempt from required approval. +- Cleanup must coordinate live bindings and active execution. A missing remote + branch alone does not prove a worktree is disposable. + +## Acceptance cases for selection and lifecycle + +Verify separate repositories under one discovery root, two chats sharing one +project, two worktrees sharing Git metadata, directory replacement, stale +catalog generations, old/new consumer combinations, and cross-principal access. +Exercise file tools, commands, programmatic execution, and environment actions +through the same persisted selection. Include pause/resume, cancellation during +creation and setup, process death before registration, retry after binding, and +removal racing an active conversation. Use disposable local fixtures before +testing the hosted deployment. diff --git a/docs/remote-bridge/worker-runbook.md b/docs/remote-bridge/worker-runbook.md new file mode 100644 index 00000000..7511a265 --- /dev/null +++ b/docs/remote-bridge/worker-runbook.md @@ -0,0 +1,527 @@ +# Self-hosted worker runbook + +This runbook attaches an operator-controlled laptop or VM to a LibreChat Code +API deployment. The worker makes an outbound HTTPS connection; it does not +open an inbound port. The same procedure works for one machine or many +principal-bound machines. + +The guide uses a named environment and the native Sandbox Runtime (SRT). It +covers a restricted personal-machine deployment and the `trusted-vm` preset, +where a separate VM boundary is responsible for most host isolation. + +## 1. Understand the boundaries + +Four independently managed components participate: + +1. LibreChat stores the environment record, resolves its principal, applies + administrator/user policy, and selects the worker for a conversation. +2. Code API authenticates the selection, queues and fences assignments, and + exposes the outbound bridge. +3. `@librechat/code` runs on the attached machine, owns local workspace + admission, rotates its bridge credential, and executes tools through SRT. +4. The machine owner controls the OS, workspace, network, credentials, and + service lifecycle. + +Pairing authenticates a worker. It does not make the host trustworthy, attest +the host policy, or replace tool approval. A `trusted-vm` worker is appropriate +only when the VM boundary is already operated as the security boundary. + +## 2. Configure LibreChat and Code API + +Deploy Code API's remote-bridge profile before pairing a machine. At minimum, +use paired authentication and dynamic routing so one bridge can serve many +principal-bound workers: + +```dotenv +CODEAPI_SANDBOX_BACKEND=remote-bridge +CODEAPI_EXECUTION_PROFILE=stateful +CODEAPI_RUNTIME_SESSION_MODE=affinity +CODEAPI_BRIDGE_AUTH_MODE=paired +CODEAPI_BRIDGE_DYNAMIC_WORKERS=true +CODEAPI_BRIDGE_TOKEN= +``` + +The administrator token belongs only on the Code API/control-plane host. Never +put it on an attached machine. Configure Redis and the remaining Code API +settings as described in the [Remote Code Bridge guide](./README.md). + +Expose that Code API endpoint to LibreChat and explicitly choose which +state-sharing scopes the deployment permits: + +```yaml +endpoints: + agents: + capabilities: + [ + deferred_tools, + execute_code, + file_search, + web_search, + artifacts, + subagents, + actions, + context, + skills, + memory, + ask_user_question, + tools, + chain, + ocr, + stateful_code_sessions, + ] + statefulCodeSessions: + allowedEnvironments: [user, agent-user, conversation] + environments: + - id: attached-workers + name: Attached machines + type: attached + baseURL: https://code.example.com/v1 + default: true +``` + +The example preserves LibreChat's default capabilities and adds the opt-in +`stateful_code_sessions` capability. Adjust the list to the deployment's +policy. Exactly one configured Code environment must be the default. The three +sharing scopes mean: + +- `user`: reuse an environment for the signed-in user; +- `agent-user`: reuse it for one agent and user; and +- `conversation`: isolate reuse to one conversation. + +These scopes determine session reuse. They do not weaken a worker's filesystem +root or share one user's principal-bound machine with another user. + +Enable stateful code sessions on the intended agent and select the attached +environment. Start with file writes and command execution set to `ask`; expose +`allow` or `deny` only when the deployment and machine policy permit them. +Worker capabilities are a ceiling: a conversation setting cannot enable a +command or write that the worker did not advertise. + +## 3. Roll out compatible consumers first + +Before enabling `--environment` on a worker: + +1. Deploy a LibreChat version that accepts named environment descriptors. +2. Deploy the matching Code API API and queue-worker processes. +3. Update `@librechat/code` on the attached machine. +4. Only then restart the worker with `--environment`. + +An old worker remains compatible with new consumers until the opt-in flag is +used. An old strict consumer can reject a new worker's environment metadata. +During a rolling deployment, update every API/queue replica before changing +workers. + +Record the exact source commit or package version at every tier. Do not infer a +worker's version from the Code API server: the worker is a separate process on +a separate machine. + +## 4. Prepare the worker host + +Install: + +- Node.js 20.11 or newer (Node.js 24 is supported); +- Git; +- `bubblewrap`, `socat`, and `ripgrep` on Linux; +- Bash 5.2 or newer and `jq` when Bash Programmatic Tool Calling is enabled; + and +- GitHub CLI and Git LFS only when the workflows need them. + +For example, install the system dependencies on Ubuntu with: + +```bash +sudo apt-get update +sudo apt-get install -y bash bubblewrap git jq ripgrep socat +``` + +On macOS, install the optional PTC and GitHub tools with: + +```bash +brew install bash gh git-lfs jq ripgrep +``` + +Install Node.js through the host's managed package source or version manager. +Bun is not required by `@librechat/code`. + +Keep source, application state, environment definitions, and credentials in +separate paths. For example: + +```text +/opt/librechat-code/releases// pinned worker source/build +/srv/code-workspaces/ coding roots +/etc/librechat-code/environments/ operator-owned YAML definitions +~/.config/librechat/code/ paired identity +~/.config/librechat-code/github-app.pem optional GitHub App key +``` + +Every ancestor of a definition, identity, key, quarantine file, or workspace +root must be owned by the worker account or root. It must not be writable by +group or other users. Sticky shared directories such as `/tmp` are handled +separately, but should not hold durable configuration. + +The workspace remains writable by its owner. For a dedicated service account, +a typical root is: + +```bash +sudo install -d -o librechat-code -g librechat-code -m 0750 /srv/code-workspaces +``` + +Do not register a home directory or another root containing credentials, +shell history, SSH keys, or unrelated projects. + +## 5. Install a pinned worker + +Use a published version when available. To install from source, keep a pinned +checkout and build only the worker package: + +```bash +git clone https://github.com/LibreChat-AI/code-interpreter.git /opt/librechat-code/source +cd /opt/librechat-code/source +git fetch origin main +git checkout --detach +npm ci --prefix packages/code +npm run build --prefix packages/code +cd packages/code +sudo npm link +``` + +Confirm that `/usr/local/bin/librechat-code` resolves to the intended build. +Do not replace a running release until the new build and its native imports +have succeeded. Keeping releases in commit-named directories makes rollback a +service-path change instead of a rebuild. + +## 6. Pair the machine + +Create a pairing in LibreChat's Code environments UI when available. The +pairing must be bound to the intended deployment, tenant, user, role, or group. +The code is single-use and expires after ten minutes. + +Redeem it on the worker machine: + +```bash +librechat-code pair https://code.example.com/v1 '' \ + --worker-id code-example123 +``` + +Run pairing as the same operating-system account that will run the worker. If +the systemd service uses `User=librechat-code`, run the command as that account +or supply an explicit identity path the account can read and replace. + +The CLI generates the Ed25519 private key locally and saves the identity under +`~/.config/librechat/code/` with owner-only permissions. Do not transmit or +copy that file through chat. The bridge credential expires after fifteen +minutes, but a running worker rotates it automatically. A normal restart does +not require re-pairing. + +For a custom location, use `--identity` during pairing and set +`LIBRECHAT_CODE_IDENTITY_FILE` in the service. Keep the worker ID stable: agent +defaults and conversations refer to the environment record associated with +that identity. + +## 7. Define named environments + +Store definitions outside every workspace root. A broad, multi-project VM can +preserve an existing `primary` binding without pretending the root is one Git +repository: + +```yaml +# /etc/librechat-code/environments/primary.yaml +name: primary +root: /srv/code-workspaces +``` + +For a single project, descriptive repository metadata and fixed actions may be +useful: + +```yaml +name: app-dev +root: /srv/code-workspaces/app +repo: example/app +ref: main +setup: + command: npm ci + timeoutMs: 300000 +actions: + - name: typecheck + command: npm run typecheck + timeoutMs: 120000 + - name: test + command: npm test + timeoutMs: 300000 +``` + +Important semantics: + +- `name` is both the workspace ID and its current display name. Preserve an + existing ID such as `primary` to preserve agent/conversation bindings. +- `repo` and `ref` are labels. They do not clone, fetch, or check out anything. +- `root` must already exist. Relative roots resolve from the definition file. +- Setup runs before registration on every worker start. It must be idempotent. +- A setup failure or timeout prevents registration and leaves a durable + quarantine marker for operator inspection. +- Actions are fixed operator commands. The model selects only the action name + and fingerprint; it cannot inject arguments, a command, or a working + directory. +- Actions still pass through LibreChat approval and worker command policy. +- Up to 32 roots may be declared, and they must not overlap. A broad parent + environment cannot coexist with child project environments. + +Definitions contain policy rather than secrets. A root-owned file may be +readable by the service account, but must not be group/other writable. For +example: + +```bash +sudo install -d -o root -g librechat-code -m 0750 /etc/librechat-code/environments +sudo install -o root -g librechat-code -m 0640 primary.yaml \ + /etc/librechat-code/environments/primary.yaml +``` + +Do not combine `--environment` with `--worker-dir`, `--default-workspace`, +`--workspace`, `--workspace-id`, or `--workspace-name`. Remove the equivalent +`LIBRECHAT_CODE_WORKER_DIR`, `LIBRECHAT_CODE_WORKSPACE_ID`, and +`LIBRECHAT_CODE_WORKSPACE_NAME` settings too. + +## 8. Choose a command policy + +For a personal machine, use the default `restricted` policy and explicitly +allow only required network destinations. + +For a separately secured VM whose outer boundary is managed by the operator, +the worker may use: + +```text +--allow-workspace-writes +--allow-workspace-commands +--command-policy-preset trusted-vm +``` + +`trusted-vm` is a policy preset, not an unsandboxed execution mode. SRT still +protects the bridge identity, GitHub credentials, worker configuration, and +control sockets. The preset deliberately permits broader workspace and network +behavior because the VM owner accepts responsibility for the host boundary. + +LibreChat's tool approval remains independent. Enabling commands on a worker +does not authorize a user or agent to bypass `ask` or `deny` policy. + +## 9. Optionally configure a GitHub App + +Prefer a GitHub App over a personal token. Install it only on repositories the +agent may use and grant the minimum permissions its workflows require. Git +clone/fetch/push generally needs repository Contents access; API-based pull +request workflows also need Pull requests access. + +Store the downloaded private key outside every workspace. Unlike an +environment definition, the key must have no group or other access and must be +readable by the service account: + +```bash +install -d -m 0700 ~/.config/librechat-code +install -m 0600 app.private-key.pem ~/.config/librechat-code/github-app.pem +``` + +Configure the worker, preferably in a separate service drop-in: + +```ini +[Service] +Environment=LIBRECHAT_CODE_GITHUB_APP_ID=12345 +Environment=LIBRECHAT_CODE_GITHUB_INSTALLATION_ID=67890 +Environment=LIBRECHAT_CODE_GITHUB_PRIVATE_KEY_FILE=/home/librechat-code/.config/librechat-code/github-app.pem +``` + +The trusted worker mints short-lived installation tokens. Sandboxed commands +receive masked Git/`gh` credentials only for the configured GitHub hosts; the +token is not written to the repository, remote URL, or Git configuration. + +## 10. Run under systemd + +Use a dedicated service account in a multi-user deployment. This example keeps +the paired identity in its default location: + +```ini +# /etc/systemd/system/librechat-code.service +[Unit] +Description=LibreChat attached code worker +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=librechat-code +Group=librechat-code +WorkingDirectory=/srv/code-workspaces +Environment=HOME=/home/librechat-code +Environment=NODE_ENV=production +Environment=LIBRECHAT_CODE_WORKER_ID=code-example123 +ExecStart=/usr/local/bin/librechat-code run \ + --environment /etc/librechat-code/environments/primary.yaml \ + --allow-workspace-writes \ + --allow-workspace-commands +Restart=always +RestartSec=5s +TimeoutStopSec=35s +KillMode=control-group +UMask=0077 +LimitNOFILE=65536 + +[Install] +WantedBy=multi-user.target +``` + +For a trusted VM, append `--command-policy-preset trusted-vm` to `ExecStart`. +After installing or changing a unit or drop-in, reload it before restart: + +```bash +sudo systemd-analyze verify librechat-code.service +sudo systemctl daemon-reload +sudo systemctl enable --now librechat-code.service +``` + +`systemctl restart` alone does not load a changed unit definition. + +## 11. Run under launchd on macOS + +Use absolute executable and release paths in the property list. Keep the paired +identity in the logged-in user's private configuration directory: + +```xml +ProgramArguments + + /absolute/path/to/node + /opt/librechat-code/releases/COMMIT/packages/code/dist/cli.js + run + --environment + /Users/worker/.config/librechat/code/environments/primary.yaml + --allow-workspace-writes + --allow-workspace-commands + +EnvironmentVariables + + LIBRECHAT_CODE_WORKER_ID + code-example123 + LIBRECHAT_CODE_IDENTITY_FILE + /Users/worker/.config/librechat/code/code-example123.json + +``` + +Editing the plist does not update launchd's cached job. Reload it: + +```bash +launchctl bootout gui/$(id -u) ~/Library/LaunchAgents/ai.librechat.code.plist +launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ai.librechat.code.plist +``` + +`launchctl kickstart -k` restarts the already-loaded definition and therefore +continues using stale paths after a plist edit. + +## 12. Verify the complete path + +Do not stop at “the process is running.” Check: + +1. The service command points to the intended version and environment file. +2. The native executor child started. +3. The worker has an established outbound HTTPS connection to Code API. +4. Code API reports the worker `online: true` and `ready: true` with the expected + workspace IDs and operations. +5. LibreChat lists the environment for the expected principal. +6. A disposable chat can select the workspace, read a file, perform an approved + write, execute a command, and retain state on the next turn. +7. Stop/cancellation prevents a delayed mutation. +8. A denied action remains denied even when the worker uses `trusted-vm`. + +Useful host checks: + +```bash +systemctl show librechat-code.service -p ExecStart -p MainPID -p NRestarts +journalctl -u librechat-code.service --since '10 minutes ago' +ss -tpn | grep librechat-code +``` + +The Code API status endpoint requires its administrator credential. Filter the +response before sharing it; do not expose tokens, pairings, bindings, or host +paths in logs or chat. + +## 13. Upgrade and roll back + +For each upgrade: + +1. Read the release notes and confirm whether LibreChat/Code API consumers must + land first. +2. Stage and build the new worker beside the current release. +3. Run focused package/native checks. +4. Update the service path or pinned checkout. +5. Reload the service manager definition when it changed. +6. Restart once and verify the complete path above. +7. Retain the previous release until the worker has completed real work. + +For source-linked installations, verify both `git rev-parse HEAD` and the +actual executable target. Updating a Code API checkout on another host does not +update this worker. + +Rollback by restoring the previous executable/service path and restarting. Do +not roll a new-metadata worker back behind the minimum consumer version while +it still advertises named environments. + +## 14. Recover safely + +### Expired bridge credential + +A running worker refreshes its short-lived credential automatically. If a +machine is offline long enough that refresh can no longer authenticate, issue +a fresh one-time pairing for the same worker ID and redeem it with a newly +generated keypair. Reusing the worker ID preserves the LibreChat environment +record and its agent assignments; creating a new ID creates a new environment. + +### Failed environment setup or uncertain mutation + +Inspect or restore the affected workspace first. Then, with the normal worker +stopped, clear the local quarantine using the same identity/deployment context: + +```bash +librechat-code clear-workspace-quarantine \ + --worker-dir /srv/code-workspaces/app \ + --workspace-id app-dev +``` + +If Code API also retains a server-side workspace fence, run the normal worker +configuration once with `--reset-workspace-quarantine app-dev`, wait for it to +exit successfully, and then start the normal service. The reset flag does not +replace the local clear command. + +Never clear quarantine merely to make the worker start. It represents a setup, +command, cancellation, or settlement whose effects may be incomplete. + +## 15. Common failures + +- **`--environment cannot be combined...`:** remove old workspace flags and + equivalent environment variables. +- **Definition or root rejected as replaceable:** remove group/other write + permission from every path ancestor; keep owner write. +- **Worker starts but old command/path remains:** run + `systemctl daemon-reload`, or fully boot out/bootstrap a changed launchd + plist. +- **Worker online but not ready:** check native sandbox preparation, definition + validation, setup, quarantine, and readiness logs. +- **Setup repeats on restart:** setup is intentionally per-start; make it + idempotent or remove it. +- **Git works on the host but not in tools:** verify the App installation, + permissions, private-key mode/owner, and allowed GitHub domains. +- **Repository label is present but files are absent:** `repo`/`ref` are + metadata; clone or mount the repository yourself. +- **Existing chats lose their workspace:** preserve the original workspace ID + in `name`, commonly `primary`. +- **Multiple project roots are rejected:** roots cannot overlap; remove the + broad parent or keep it as the only environment. + +## Final checklist + +- [ ] LibreChat and every Code API replica support the worker protocol. +- [ ] Worker version/source commit is recorded. +- [ ] Pairing is principal-bound and the identity file is private. +- [ ] Definitions are outside roots and immutable to sandboxed tools. +- [ ] Workspace ancestors are not group/other writable. +- [ ] GitHub App is optional, least-privilege, and installed only where needed. +- [ ] Approval policy remains enforced independently of worker capability. +- [ ] Service manager uses the intended executable and configuration. +- [ ] Worker is online, ready, and advertises the expected workspace. +- [ ] Read, approved mutation, command, persistence, denial, and cancellation + are tested. +- [ ] Upgrade and quarantine-recovery procedures are recorded for the operator. diff --git a/launcher/src/main.rs b/launcher/src/main.rs index 7771b9b9..27f08f92 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -433,6 +433,7 @@ fn is_allowed_guest_env_key(key: &str, egress_gateway_enabled: bool) -> bool { "SANDBOX_LOG_LEVEL", "SANDBOX_MAX_CONCURRENT_JOBS", "SANDBOX_MAX_FILE_SIZE", + "SANDBOX_MAX_INPUT_FILES", "SANDBOX_MAX_NESTING_DEPTH", "SANDBOX_MAX_OPEN_FILES", "SANDBOX_MAX_OUTPUT_FILES", @@ -850,6 +851,13 @@ mod tests { } } + #[test] + fn guest_env_allowlist_forwards_input_file_limit_in_both_egress_modes() { + for egress_gateway_enabled in [false, true] { + assert!(is_allowed_guest_env_key("SANDBOX_MAX_INPUT_FILES", egress_gateway_enabled)); + } + } + #[test] fn guest_env_allowlist_preserves_legacy_file_server_url_only_without_egress_gateway() { assert!(is_allowed_guest_env_key("FILE_SERVER_URL", false)); diff --git a/packages/code/README.md b/packages/code/README.md index 8b98618f..48bf769d 100644 --- a/packages/code/README.md +++ b/packages/code/README.md @@ -3,6 +3,9 @@ Provider-neutral protocol and worker CLI for attaching a stateful, sandboxed code environment to LibreChat Code API. +For a complete machine setup and operations guide, see the +[self-hosted worker runbook](../../docs/remote-bridge/worker-runbook.md). + The CLI owns the runtime-supervisor seam. Native workspace commands use Anthropic's open-source Sandbox Runtime (SRT) on the worker machine. The bundled endpoint adapter can also connect to an already-running loopback Code @@ -11,6 +14,43 @@ container/NsJail profile. The worker connects outbound to Code API, long-polls for assignments, sends them to the local runtime, and returns fenced results. The VM does not need an inbound public port. +## Inspect local projects + +Before registering a directory containing several checkouts, inspect its Git +projects on the worker machine: + +```bash +librechat-code projects --root /srv/projects +``` + +The command prints JSON with `projects`, `truncated`, and `incomplete`. Each +project contains a path relative to the requested directory, a path-derived ID, +and the current origin, branch, and HEAD. Origins are normalized to +`host[:port]/namespace/repository`, including nested namespaces; URL credentials, +query strings, and fragments are omitted. An unsupported configured origin is +redacted to null and marks the inventory incomplete. +A detached HEAD has a null branch; an unborn branch has a null HEAD. IDs stay +stable when branches change, but moving or renaming a directory changes its ID. +IDs are local to the supplied discovery root. + +Discovery runs only when requested. Its default limits are three directory +levels, 10,000 entries, 256 projects, and a ten-second processing budget with +bounded Git subprocess output and timeouts. +The time budget starts before resolving the root and is checked between native +filesystem operations; it cannot interrupt a kernel call stalled on a filesystem. +Use a responsive local filesystem. Discovery skips hidden directories, +dependencies, symlinks, and children of an identified repository. Linked +worktrees and submodules using a `.git` file are skipped and set `incomplete`: +their shared Git metadata needs separate admission before independent execution. +An empty project list does not prevent registering a non-Git directory. + +This is a local inventory command. It does not clone, register roots, pair a +worker, change the sandbox, or automatically select a conversation workspace. +For the existing picker and independent lease slots, explicitly register the +chosen non-overlapping project directories with `--workspace` or `--environment`. +Do not also register their parent directory. Treat the inventory as a snapshot; +normal workspace admission must validate any directory selected from it. + ## Pair Hardened deployments use a one-time code instead of copying a long-lived diff --git a/packages/code/src/cli.ts b/packages/code/src/cli.ts index 00eca576..7406e267 100644 --- a/packages/code/src/cli.ts +++ b/packages/code/src/cli.ts @@ -5,6 +5,7 @@ import { realpath, stat } from 'node:fs/promises'; import { basename, resolve, relative, isAbsolute, sep } from 'node:path'; import { pairBridgeWorker } from './pairing.js'; +import { discoverProjects } from './projects.js'; import { loadCodeEnvironment, assertEnvironmentDefinitionsOutsideRoots, @@ -1237,6 +1238,16 @@ async function clearMutationQuarantine(args: string[]): Promise { async function main(): Promise { const args = process.argv.slice(2); + if (args[0] === 'projects') { + const root = option(args, '--root'); + if (!root || args.slice(1).some((arg, index, rest) => + arg !== '--root' && rest[index - 1] !== '--root' && !arg.startsWith('--root='))) { + throw new Error('Usage: librechat-code projects --root '); + } + const inventory = await discoverProjects({ root }); + process.stdout.write(`${JSON.stringify(inventory, null, 2)}\n`); + return; + } if (args[0] === 'relay') { await relay(); return; diff --git a/packages/code/src/projects.test.ts b/packages/code/src/projects.test.ts new file mode 100644 index 00000000..98ce0949 --- /dev/null +++ b/packages/code/src/projects.test.ts @@ -0,0 +1,368 @@ +import assert from 'node:assert/strict'; +import { execFile } from 'node:child_process'; +import { + chmod, + mkdtemp, + mkdir, + rm, + symlink, + writeFile, +} from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import fs from 'node:fs/promises'; +import { syncBuiltinESMExports } from 'node:module'; +import { join } from 'node:path'; +import { promisify } from 'node:util'; +import { fileURLToPath } from 'node:url'; +import test from 'node:test'; +import type { TestContext } from 'node:test'; +import { discoverProjects, projectRemote } from './projects.js'; + +const exec = promisify(execFile); +async function fixture(t: TestContext) { + const root = await mkdtemp(join(tmpdir(), 'code-projects-')); + t.after(() => rm(root, { recursive: true, force: true })); + return root; +} +async function repo(root: string, path: string) { + const directory = join(root, path); + await mkdir(directory, { recursive: true }); + await exec('git', ['init', '--initial-branch=dev', directory]); + return directory; +} + +test('discovers real sibling repositories with stable IDs and bounded metadata', async t => { + const root = await fixture(t); + const a = await repo(root, 'a'); + await repo(root, 'nested/b'); + await exec('git', [ + '-C', + a, + 'remote', + 'add', + 'origin', + 'https://user:secret@github.com/example/app.git?token=secret', + ]); + await exec('git', [ + '-C', + a, + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.com', + 'commit', + '--allow-empty', + '-m', + 'initial', + ]); + const before = await discoverProjects({ root }); + assert.equal(before.incomplete, false); + assert.equal(before.truncated, false); + assert.deepEqual( + before.projects.map(p => p.path), + ['a', 'nested/b'] + ); + assert.equal(before.projects[0].remote, 'github.com/example/app'); + assert.equal(before.projects[0].branch, 'dev'); + assert.match(before.projects[0].head!, /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/); + assert.equal(before.projects[1].head, null); + assert.ok(!JSON.stringify(before).includes('secret')); + await exec('git', ['-C', a, 'checkout', '-b', 'next']); + const after = await discoverProjects({ root }); + assert.deepEqual( + after.projects.map(p => p.id), + before.projects.map(p => p.id) + ); + assert.equal(after.projects[0].branch, 'next'); +}); + +test('does not walk dependencies, hidden directories, symlinks or repository children', async t => { + const root = await fixture(t); + await repo(root, 'node_modules/ignored'); + await repo(root, '.hidden/ignored'); + await repo(root, 'parent'); + await repo(root, 'parent/nested'); + const outside = await fixture(t); + await repo(outside, 'external'); + await symlink(outside, join(root, 'alias'), 'dir'); + const inventory = await discoverProjects({ root }); + assert.deepEqual( + inventory.projects.map(p => p.path), + ['parent'] + ); +}); + +test('linked worktrees are reported incomplete until shared git metadata is admitted', async t => { + const root = await fixture(t); + await mkdir(join(root, 'linked')); + await writeFile( + join(root, 'linked', '.git'), + 'gitdir: /outside/metadata\n' + ); + const inventory = await discoverProjects({ root }); + assert.equal(inventory.incomplete, true); + assert.deepEqual(inventory.projects, []); +}); + +test('oversized Git metadata is incomplete rather than silently reported absent', async t => { + const root = await fixture(t); + const directory = await repo(root, 'app'); + await exec('git', [ + '-C', + directory, + 'config', + 'remote.origin.url', + 'x'.repeat(10_000), + ]); + const inventory = await discoverProjects({ root }); + assert.equal(inventory.incomplete, true); + assert.equal(inventory.projects[0].remote, null); +}); + +test('a valid branch beyond the metadata bound reports incomplete', async t => { + const root = await fixture(t); + const directory = await repo(root, 'app'); + const branch = ['a'.repeat(100), 'b'.repeat(100), 'c'.repeat(100)].join( + '/' + ); + await exec('git', [ + '-C', + directory, + 'symbolic-ref', + 'HEAD', + `refs/heads/${branch}`, + ]); + const inventory = await discoverProjects({ root }); + assert.equal(inventory.incomplete, true); + assert.equal(inventory.projects[0].branch, null); +}); + +test('unreadable Git markers report incomplete', async t => { + if (process.platform === 'win32' || process.getuid?.() === 0) { + t.skip('requires POSIX permissions under an unprivileged account'); + return; + } + const root = await fixture(t); + const directory = await repo(root, 'app'); + await chmod(directory, 0o400); + try { + assert.equal( + (await discoverProjects({ root: directory })).incomplete, + true + ); + } finally { + await chmod(directory, 0o700); + } +}); + +test('root resolution consumes the processing budget and pre-abort wins', async t => { + const root = await fixture(t); + let ticks = 0; + t.mock.method(Date, 'now', () => (ticks++ === 0 ? 0 : 20_000)); + const inventory = await discoverProjects({ root }); + assert.equal(inventory.truncated, true); + assert.deepEqual(inventory.projects, []); + const reason = new Error('cancelled before filesystem access'); + await assert.rejects( + discoverProjects({ + root: join(root, 'missing'), + signal: AbortSignal.abort(reason), + }), + error => error === reason + ); +}); + +test('empty directory completion checks a late deadline and cancellation', async t => { + const root = await fixture(t); + const original = fs.opendir; + let clock = 0; + let cancel: AbortController | undefined; + t.mock.method(Date, 'now', () => clock); + const openMock = t.mock.method( + fs, + 'opendir', + async (path: Parameters[0]) => { + const directory = await original(path); + clock = 20_000; + cancel?.abort(); + return directory; + } + ); + syncBuiltinESMExports(); + try { + assert.equal((await discoverProjects({ root })).truncated, true); + clock = 0; + cancel = new AbortController(); + await assert.rejects( + discoverProjects({ root, signal: cancel.signal }), + { name: 'AbortError' } + ); + } finally { + openMock.mock.restore(); + syncBuiltinESMExports(); + } +}); + +test('late filesystem boundaries stop before starting the next operation', async t => { + const root = await fixture(t); + for (const boundary of [3, 4, 5]) { + for (const abort of [false, true]) { + let calls = 0; + let clock = 0; + const controller = new AbortController(); + const now = t.mock.method(Date, 'now', () => clock); + const mocks = ['lstat', 'realpath', 'opendir'].map(name => { + const original = fs[name as 'lstat']; + return t.mock.method( + fs, + name as 'lstat', + async (...args: Parameters) => { + calls++; + try { + return await original(...args); + } finally { + if (calls === boundary) { + clock = 20_000; + if (abort) controller.abort(); + } + } + } + ); + }); + syncBuiltinESMExports(); + try { + const discovery = discoverProjects({ + root, + signal: controller.signal, + }); + if (abort) + await assert.rejects(discovery, { name: 'AbortError' }); + else assert.equal((await discovery).truncated, true); + assert.equal(calls, boundary); + } finally { + for (const mock of mocks) mock.mock.restore(); + now.mock.restore(); + syncBuiltinESMExports(); + } + } + } +}); + +test('unsupported configured origins are distinguishable from missing origins', async t => { + const root = await fixture(t); + const directory = await repo(root, 'app'); + await exec('git', [ + '-C', + directory, + 'config', + 'remote.origin.url', + '/private/local/repo', + ]); + const inventory = await discoverProjects({ root }); + assert.equal(inventory.incomplete, true); + assert.equal(inventory.projects[0].remote, null); +}); + +test('project, entry and depth ceilings report partial discovery', async t => { + const root = await fixture(t); + await repo(root, 'a'); + await repo(root, 'b'); + await repo(root, 'nested/deeper/c'); + assert.equal( + (await discoverProjects({ root, maxProjects: 1 })).truncated, + true + ); + assert.equal( + (await discoverProjects({ root, maxEntries: 1 })).truncated, + true + ); + assert.equal( + (await discoverProjects({ root, maxDepth: 1 })).truncated, + true + ); + await assert.rejects(discoverProjects({ root, maxDepth: 100 }), /limit/); + await assert.rejects( + discoverProjects({ root, signal: AbortSignal.abort() }) + ); +}); + +test('root checkout uses dot and detached HEAD has no branch', async t => { + const root = await fixture(t); + await repo(root, '.'); + await exec('git', [ + '-C', + root, + '-c', + 'user.name=Test', + '-c', + 'user.email=test@example.com', + 'commit', + '--allow-empty', + '-m', + 'initial', + ]); + await exec('git', ['-C', root, 'checkout', '--detach']); + const inventory = await discoverProjects({ root }); + assert.equal(inventory.projects[0].path, '.'); + assert.equal(inventory.projects[0].branch, null); +}); + +test('repository identity retains host and drops credentials, query and fragments', () => { + assert.equal( + projectRemote('ssh://git@example.com:2222/org/repo.git'), + 'example.com:2222/org/repo' + ); + assert.equal( + projectRemote('https://example.com:8443/org/repo.git'), + 'example.com:8443/org/repo' + ); + assert.equal( + projectRemote('git@github.com:org/repo.git'), + 'github.com/org/repo' + ); + assert.equal( + projectRemote('ssh://git@example.com/org/repo.git'), + 'example.com/org/repo' + ); + assert.equal( + projectRemote('https://token@example.com/org/repo.git?secret#fragment'), + 'example.com/org/repo' + ); + assert.equal(projectRemote('/home/user/private'), null); + assert.equal(projectRemote('file:///home/user/private'), null); + assert.equal( + projectRemote('https://example.com/group/subgroup/repo.git'), + 'example.com/group/subgroup/repo' + ); + assert.equal( + projectRemote('git@example.com:group/subgroup/repo.git'), + 'example.com/group/subgroup/repo' + ); +}); + +test('CLI inventories a real checkout without pairing or starting a worker', async t => { + const root = await fixture(t); + await repo(root, 'app'); + const { stdout, stderr } = await exec( + process.execPath, + [ + fileURLToPath(new URL('./cli.js', import.meta.url)), + 'projects', + '--root', + root, + ], + { env: { PATH: process.env.PATH }, timeout: 15_000 } + ); + const result = JSON.parse(stdout); + assert.equal(stderr, ''); + assert.equal(result.projects[0].path, 'app'); + assert.equal(result.projects[0].branch, 'dev'); + assert.equal(result.incomplete, false); + await assert.rejects( + exec(process.execPath, [ + fileURLToPath(new URL('./cli.js', import.meta.url)), + 'projects', + ]), + /Usage: librechat-code projects/ + ); +}); diff --git a/packages/code/src/projects.ts b/packages/code/src/projects.ts new file mode 100644 index 00000000..1e2fcbd3 --- /dev/null +++ b/packages/code/src/projects.ts @@ -0,0 +1,295 @@ +import { execFile } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { lstat, opendir, realpath } from 'node:fs/promises'; +import { isAbsolute, relative, resolve, sep } from 'node:path'; +import { promisify } from 'node:util'; + +const exec = promisify(execFile); +const skipped = new Set(['node_modules', 'vendor']); + +export interface LocalProject { + id: string; + path: string; + remote: string | null; + branch: string | null; + head: string | null; +} + +export interface ProjectInventory { + projects: LocalProject[]; + truncated: boolean; + incomplete: boolean; +} + +export interface ProjectDiscoveryOptions { + root: string; + maxDepth?: number; + maxProjects?: number; + maxEntries?: number; + timeoutMs?: number; + signal?: AbortSignal; +} + +/** Public repository identity only: never propagate credentials or URL query data. */ +export function projectRemote(value: string): string | null { + let host: string; + let path: string; + try { + const scp = /^(?:[^/@:\s]+@)?([^/:\s]+):([^\s]+)$/.exec(value); + if (scp && !value.includes('://')) { + host = scp[1]; + path = scp[2]; + } else { + const url = new URL(value); + if (!['https:', 'http:', 'ssh:', 'git:'].includes(url.protocol)) + return null; + host = url.host; + path = url.pathname.replace(/^\//, ''); + } + path = path.replace(/\.git$/, ''); + if ( + !/^[A-Za-z0-9.-]+(?::[0-9]+)?$/.test(host) || + !/^[A-Za-z0-9_.-]+(?:\/[A-Za-z0-9_.-]+)+$/.test(path) + ) + return null; + if (path.split('/').some(part => part === '.' || part === '..')) + return null; + return `${host.toLowerCase()}/${path}`; + } catch { + return null; + } +} + +function limit( + value: number | undefined, + fallback: number, + maximum: number +): number { + const resolved = value ?? fallback; + if (!Number.isSafeInteger(resolved) || resolved < 1 || resolved > maximum) + throw new Error('Invalid project discovery limit'); + return resolved; +} + +/** Bounded local inventory; it does not grant roots or mutate a checkout. */ +export async function discoverProjects( + options: ProjectDiscoveryOptions +): Promise { + const maxDepth = limit(options.maxDepth, 3, 16); + const maxProjects = limit(options.maxProjects, 256, 256); + const maxEntries = limit(options.maxEntries, 10_000, 100_000); + const timeoutMs = limit(options.timeoutMs, 10_000, 60_000); + const deadline = Date.now() + timeoutMs; + const result: ProjectInventory = { + projects: [], + truncated: false, + incomplete: false, + }; + let entries = 0; + const expired = (): boolean => { + options.signal?.throwIfAborted(); + if (Date.now() < deadline) return false; + result.truncated = true; + return true; + }; + options.signal?.throwIfAborted(); + const root = await realpath(options.root); + if (expired()) return result; + const rootStat = await lstat(root); + if (expired()) return result; + if (!rootStat.isDirectory()) + throw new Error('Project root must be a directory'); + const queue = [{ path: root, depth: 0 }]; + const git = async ( + path: string, + args: string[], + expectedExitCodes: number[] = [] + ): Promise => { + if (expired()) return null; + try { + const { stdout } = await exec( + 'git', + [ + '--no-optional-locks', + '-C', + path, + '-c', + 'core.fsmonitor=false', + ...args, + ], + { + env: { + PATH: process.env.PATH, + SYSTEMROOT: process.env.SYSTEMROOT, + GIT_CONFIG_NOSYSTEM: '1', + GIT_CONFIG_GLOBAL: '/dev/null', + GIT_TERMINAL_PROMPT: '0', + GIT_OPTIONAL_LOCKS: '0', + LC_ALL: 'C', + }, + encoding: 'utf8', + maxBuffer: 4096, + timeout: Math.max(1, Math.min(1500, deadline - Date.now())), + signal: options.signal, + } + ); + return stdout.trim(); + } catch (error) { + options.signal?.throwIfAborted(); + const expected = + error instanceof Error && + 'code' in error && + typeof error.code === 'number' && + expectedExitCodes.includes(error.code); + if (!expected) result.incomplete = true; + return null; + } + }; + for (let index = 0; index < queue.length; index++) { + if (expired()) break; + const current = queue[index]; + try { + // Revalidate queued directories; never traverse a replaced symlink. + const currentStat = await lstat(current.path); + if (expired()) break; + if (currentStat.isSymbolicLink()) { + result.incomplete = true; + continue; + } + const canonical = await realpath(current.path); + if (expired()) break; + const rel = relative(root, canonical); + if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + result.incomplete = true; + continue; + } + const marker = await lstat(resolve(current.path, '.git')).catch( + error => { + if ( + !(error instanceof Error) || + !('code' in error) || + error.code !== 'ENOENT' + ) + throw error; + return undefined; + } + ); + if (expired()) break; + if (marker) { + // Linked worktrees and submodules need separate shared-gitdir admission. + if (!marker.isDirectory() || marker.isSymbolicLink()) { + result.incomplete = true; + continue; + } + if (result.projects.length === maxProjects) { + result.truncated = true; + break; + } + const top = await git(current.path, [ + 'rev-parse', + '--show-toplevel', + ]); + if (expired()) break; + const canonicalTop = top + ? await realpath(top).catch(() => null) + : null; + if (expired()) break; + if (!top || canonicalTop !== canonical) { + result.incomplete = true; + continue; + } + const remote = await git( + current.path, + [ + 'config', + '--local', + '--no-includes', + '--get', + 'remote.origin.url', + ], + [1] + ); + const branch = await git( + current.path, + ['symbolic-ref', '--quiet', '--short', 'HEAD'], + [1] + ); + const head = await git( + current.path, + ['rev-parse', '--verify', 'HEAD'], + branch ? [128] : [] + ); + const path = rel.split(sep).join('/') || '.'; + const normalizedRemote = remote ? projectRemote(remote) : null; + const validBranch = + branch && + branch.length <= 256 && + !/[\x00-\x1f\x7f]/.test(branch) + ? branch + : null; + const validHead = + head && /^[a-f0-9]{40}(?:[a-f0-9]{24})?$/.test(head) + ? head + : null; + if ( + (remote !== null && normalizedRemote === null) || + (branch !== null && validBranch === null) || + (head !== null && validHead === null) + ) + result.incomplete = true; + result.projects.push({ + id: `project-${createHash('sha256') + .update(path) + .digest('hex') + .slice(0, 32)}`, + path, + remote: normalizedRemote, + branch: validBranch, + head: validHead, + }); + continue; + } + if (current.depth === maxDepth) { + result.truncated = true; + continue; + } + const directory = await opendir(current.path); + // Close a newly opened handle even when cancellation won during open. + try { + if (expired()) { + await directory.close(); + break; + } + } catch (error) { + await directory.close(); + throw error; + } + for await (const entry of directory) { + if (expired() || ++entries > maxEntries) { + result.truncated = true; + result.projects.sort((a, b) => + a.path < b.path ? -1 : a.path > b.path ? 1 : 0 + ); + return result; + } + if ( + entry.isDirectory() && + !entry.name.startsWith('.') && + !skipped.has(entry.name) + ) + queue.push({ + path: resolve(current.path, entry.name), + depth: current.depth + 1, + }); + } + } catch { + options.signal?.throwIfAborted(); + result.incomplete = true; + } + } + result.projects.sort((a, b) => + a.path < b.path ? -1 : a.path > b.path ? 1 : 0 + ); + expired(); + return result; +} diff --git a/service/src/file-server.ts b/service/src/file-server.ts index 22302042..00b91e4a 100644 --- a/service/src/file-server.ts +++ b/service/src/file-server.ts @@ -11,11 +11,11 @@ import { sendFileDownload } from './file-download'; import path from 'path'; import IORedis from 'ioredis'; import express from 'express'; -import { Client } from 'minio'; +import { createMinioClient } from './minio-client'; import { nanoid } from 'nanoid'; import { PassThrough } from 'stream'; import { pipeline } from 'stream/promises'; -import type { BucketItem, BucketItemStat, ClientOptions } from 'minio'; +import type { BucketItem, BucketItemStat, Client } from 'minio'; import type { Readable } from 'stream'; import type * as tls from 'tls'; import type * as t from './types'; @@ -40,69 +40,6 @@ app.use(httpMetricsMiddleware); const bucketName = process.env.MINIO_BUCKET ?? 'test-bucket'; -type IamProviderModule = { IamAwsProvider?: new (opts: object) => unknown; default?: new (opts: object) => unknown }; - -async function createMinioClient(): Promise { - const irsaExplicit = process.env.MINIO_USE_IRSA?.toLowerCase() === 'true'; - const irsaEnvVars = Boolean(process.env.AWS_WEB_IDENTITY_TOKEN_FILE) && Boolean(process.env.AWS_ROLE_ARN); - const useIrsa = irsaExplicit || irsaEnvVars; - - const baseConfig: ClientOptions = { - endPoint: process.env.MINIO_ENDPOINT ?? 'localhost', - port: process.env.MINIO_NO_PORT?.toLowerCase() === 'true' ? undefined : parseInt(process.env.MINIO_PORT ?? '9000'), - useSSL: process.env.MINIO_USE_SSL?.toLowerCase() === 'true', - region: process.env.MINIO_REGION ?? process.env.AWS_REGION ?? 'us-east-1', - }; - - if (useIrsa) { - logger.info('Using IRSA (IamAwsProvider) for S3 authentication', { - tokenFile: process.env.AWS_WEB_IDENTITY_TOKEN_FILE, - roleArn: process.env.AWS_ROLE_ARN, - region: baseConfig.region, - }); - - /** IamAwsProvider exists in minio 8.0.6+ but isn't exported from main module - * Try multiple import paths for compatibility with different runtimes (bun, ts-node, node) - */ - let IamAwsProviderClass: new (opts: object) => unknown; - try { - const mod = await import('minio/dist/main/IamAwsProvider.js') as IamProviderModule; - IamAwsProviderClass = (mod.IamAwsProvider ?? mod.default)!; - } catch (primaryError) { - try { - // Fallback for bun: resolve path using require if available (CJS context) - let resolvePath = 'node_modules/minio/'; - try { - // eslint-disable-next-line @typescript-eslint/no-require-imports - resolvePath = require.resolve('minio').replace(/dist\/.*$/, ''); - } catch { - // require.resolve not available (ESM context), use default path - } - const mod = await import(`${resolvePath}dist/main/IamAwsProvider.js`) as IamProviderModule; - IamAwsProviderClass = (mod.IamAwsProvider ?? mod.default)!; - } catch (fallbackError) { - logger.error('Failed to load IamAwsProvider', { primaryError, fallbackError }); - throw new Error('Could not load IamAwsProvider for IRSA authentication. Ensure minio >= 8.0.6 is installed.'); - } - } - - const credentialsProvider = new IamAwsProviderClass({}); - - return new Client({ - ...baseConfig, - credentialsProvider: credentialsProvider as ClientOptions['credentialsProvider'], - }); - } - - logger.info('Using explicit credentials for MinIO/S3 authentication'); - return new Client({ - ...baseConfig, - accessKey: process.env.MINIO_ACCESS_KEY ?? '', - secretKey: process.env.MINIO_SECRET_KEY ?? '', - sessionToken: process.env.MINIO_SESSION_TOKEN, - }); -} - let minioClient: Client; let storageInitialized = false; diff --git a/service/src/minio-client.test.ts b/service/src/minio-client.test.ts new file mode 100644 index 00000000..13259d03 --- /dev/null +++ b/service/src/minio-client.test.ts @@ -0,0 +1,101 @@ +import { expect, test } from 'bun:test'; +import { createHash } from 'node:crypto'; +import { Readable } from 'node:stream'; +import { createMinioClient } from './minio-client'; + +const MiB = 1024 * 1024; + +// Exercise the real SDK against a local S3 HTTP fixture. No storage account, +// Redis, or file-server listener is needed to test the production client. +test.each([1024, 8 * MiB, 20 * MiB + 17])( + 'unknown-length upload of %i bytes uses bounded parts without losing bytes', + async size => { + const parts = new Map(); + const lengths: number[] = []; + const uploaded: { body?: Buffer; contentType?: string | null; originalFilename?: string | null } = {}; + const xml = (body: string) => new Response(body, { + headers: { 'Content-Type': 'application/xml' }, + }); + const server = Bun.serve({ + hostname: '127.0.0.1', + port: 0, + async fetch(req) { + const url = new URL(req.url); + if (req.method === 'GET' && url.searchParams.has('uploads')) { + return xml('false'); + } + if (req.method === 'POST' && url.searchParams.has('uploads')) { + uploaded.contentType = req.headers.get('content-type'); + uploaded.originalFilename = req.headers.get('x-amz-meta-original-filename'); + return xml('test-upload'); + } + if (req.method === 'PUT' && url.searchParams.has('partNumber')) { + const body = Buffer.from(await req.arrayBuffer()); + lengths.push(body.length); + if (Number(req.headers.get('content-length')) !== body.length || + req.headers.get('content-md5') !== createHash('md5').update(body).digest('base64')) { + return new Response('Invalid part length or checksum', { status: 400 }); + } + parts.set(Number(url.searchParams.get('partNumber')), body); + return new Response(null, { + headers: { ETag: `"${createHash('md5').update(body).digest('hex')}"` }, + }); + } + if (req.method === 'POST' && url.searchParams.has('uploadId')) { + const manifest = await req.text(); + const ordered = [...manifest.matchAll(/(\d+)<\/PartNumber>/g)] + .map(match => parts.get(Number(match[1]))); + if (ordered.length !== parts.size || ordered.some(part => !part)) { + return new Response('Invalid multipart completion', { status: 400 }); + } + uploaded.body = Buffer.concat(ordered as Buffer[]); + return xml('http://localhost/test-bucket/input.bintest-bucketinput.bin"complete"'); + } + return new Response('Unexpected S3 request', { status: 400 }); + }, + }); + const settings: Record = { + MINIO_ENDPOINT: '127.0.0.1', + MINIO_PORT: String(server.port), + MINIO_NO_PORT: 'false', + MINIO_USE_SSL: 'false', + MINIO_REGION: 'us-east-1', + MINIO_USE_IRSA: 'false', + AWS_WEB_IDENTITY_TOKEN_FILE: undefined, + AWS_ROLE_ARN: undefined, + MINIO_ACCESS_KEY: 'test-access', + MINIO_SECRET_KEY: 'test-secret', + MINIO_SESSION_TOKEN: undefined, + }; + const saved = Object.fromEntries(Object.keys(settings).map(key => [key, process.env[key]])); + try { + for (const [key, value] of Object.entries(settings)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + const client = await createMinioClient(); + const expected = Buffer.alloc(size); + for (let i = 0; i < expected.length; i++) expected[i] = i % 251; + function* chunks() { + for (let offset = 0; offset < size; offset += 64 * 1024) { + yield expected.subarray(offset, Math.min(size, offset + 64 * 1024)); + } + } + await client.putObject('test-bucket', 'input.bin', Readable.from(chunks()), undefined, { + 'Content-Type': 'application/octet-stream', + 'X-Amz-Meta-Original-Filename': 'input.bin', + }); + expect(lengths.length).toBe(Math.ceil(size / (8 * MiB))); + expect(lengths.every(length => length <= 8 * MiB)).toBe(true); + expect(uploaded.body?.equals(expected)).toBe(true); + expect(uploaded.contentType).toBe('application/octet-stream'); + expect(uploaded.originalFilename).toBe('input.bin'); + } finally { + for (const [key, value] of Object.entries(saved)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await server.stop(true); + } + }, +); diff --git a/service/src/minio-client.ts b/service/src/minio-client.ts new file mode 100644 index 00000000..07af2891 --- /dev/null +++ b/service/src/minio-client.ts @@ -0,0 +1,69 @@ +import { Client, type ClientOptions } from 'minio'; +import logger from './fileServerLogger'; + +type IamProviderModule = { IamAwsProvider?: new (opts: object) => unknown; default?: new (opts: object) => unknown }; + +export async function createMinioClient(): Promise { + const irsaExplicit = process.env.MINIO_USE_IRSA?.toLowerCase() === 'true'; + const irsaEnvVars = Boolean(process.env.AWS_WEB_IDENTITY_TOKEN_FILE) && Boolean(process.env.AWS_ROLE_ARN); + const useIrsa = irsaExplicit || irsaEnvVars; + + const baseConfig: ClientOptions = { + // Unknown-length streams otherwise grow SDK parts to 528 MiB (the 5 TiB + // object limit / 10,000 parts). Bound each multipart buffer instead. + partSize: 8 * 1024 * 1024, + endPoint: process.env.MINIO_ENDPOINT ?? 'localhost', + port: process.env.MINIO_NO_PORT?.toLowerCase() === 'true' ? undefined : parseInt(process.env.MINIO_PORT ?? '9000'), + useSSL: process.env.MINIO_USE_SSL?.toLowerCase() === 'true', + region: process.env.MINIO_REGION ?? process.env.AWS_REGION ?? 'us-east-1', + }; + + if (useIrsa) { + logger.info('Using IRSA (IamAwsProvider) for S3 authentication', { + tokenFile: process.env.AWS_WEB_IDENTITY_TOKEN_FILE, + roleArn: process.env.AWS_ROLE_ARN, + region: baseConfig.region, + }); + + /** IamAwsProvider exists in minio 8.0.6+ but isn't exported from main module + * Try multiple import paths for compatibility with different runtimes (bun, ts-node, node) + */ + let IamAwsProviderClass: new (opts: object) => unknown; + try { + const mod = await import('minio/dist/main/IamAwsProvider.js') as IamProviderModule; + IamAwsProviderClass = (mod.IamAwsProvider ?? mod.default)!; + } catch (primaryError) { + try { + // Fallback for bun: resolve path using require if available (CJS context) + let resolvePath = 'node_modules/minio/'; + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + resolvePath = require.resolve('minio').replace(/dist\/.*$/, ''); + } catch { + // require.resolve not available (ESM context), use default path + } + const mod = await import(`${resolvePath}dist/main/IamAwsProvider.js`) as IamProviderModule; + IamAwsProviderClass = (mod.IamAwsProvider ?? mod.default)!; + } catch (fallbackError) { + logger.error('Failed to load IamAwsProvider', { primaryError, fallbackError }); + throw new Error('Could not load IamAwsProvider for IRSA authentication. Ensure minio >= 8.0.6 is installed.'); + } + } + + const credentialsProvider = new IamAwsProviderClass({}); + + return new Client({ + ...baseConfig, + credentialsProvider: credentialsProvider as ClientOptions['credentialsProvider'], + }); + } + + logger.info('Using explicit credentials for MinIO/S3 authentication'); + return new Client({ + ...baseConfig, + accessKey: process.env.MINIO_ACCESS_KEY ?? '', + secretKey: process.env.MINIO_SECRET_KEY ?? '', + sessionToken: process.env.MINIO_SESSION_TOKEN, + }); +} + diff --git a/tests/release-versioning.sh b/tests/release-versioning.sh new file mode 100755 index 00000000..3ab80bef --- /dev/null +++ b/tests/release-versioning.sh @@ -0,0 +1,56 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RESOLVER="$ROOT/.github/scripts/next-release-version.sh" +TEST_REPO="$(mktemp -d)" +trap 'rm -rf "$TEST_REPO"' EXIT + +git -C "$TEST_REPO" init -q +git -C "$TEST_REPO" config user.name test +git -C "$TEST_REPO" config user.email test@example.com + +commit_file() { + local path="$1" + local content="$2" + local message="$3" + + mkdir -p "$TEST_REPO/$(dirname "$path")" + printf '%s\n' "$content" > "$TEST_REPO/$path" + git -C "$TEST_REPO" add "$path" + git -C "$TEST_REPO" commit -q -m "$message" +} + +assert_version() { + local expected="$1" + local actual + actual="$(cd "$TEST_REPO" && bash "$RESOLVER" v1.2.3 v1.2.3..HEAD)" + if [ "$actual" != "$expected" ]; then + echo "expected '$expected', got '$actual'" >&2 + exit 1 + fi +} + +commit_file api/runtime.ts initial 'chore: initial release' +git -C "$TEST_REPO" tag v1.2.3 + +commit_file docs/guide.md docs 'docs: clarify deployment' +assert_version '' + +commit_file api/runtime.ts fix 'fix: repair execution' +assert_version v1.2.4 + +git -C "$TEST_REPO" reset -q --hard v1.2.3 +commit_file api/runtime.ts feature 'feat: add execution mode' +assert_version v1.3.0 + +git -C "$TEST_REPO" reset -q --hard v1.2.3 +commit_file api/runtime.ts breaking 'feat!: replace execution protocol' +assert_version v2.0.0 + +git -C "$TEST_REPO" reset -q --hard v1.2.3 +commit_file service/config.ts config 'chore: tune runtime defaults' +assert_version v1.2.4 + +echo 'release versioning tests passed'