diff --git a/.github/actions/create-pull-request/action.yml b/.github/actions/create-pull-request/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..b9fdebf79a9f6fbab5eb396461d6ae9491ea88e5 --- /dev/null +++ b/.github/actions/create-pull-request/action.yml @@ -0,0 +1,55 @@ +name: 'Create Pull Request' +description: 'Creates a pull request.' + +inputs: + branch-name: + description: 'The name of the branch to create the PR from.' + required: true + pr-title: + description: 'The title of the pull request.' + required: true + pr-body: + description: 'The body of the pull request.' + required: true + base-branch: + description: 'The branch to merge into.' + required: true + default: 'main' + github-token: + description: 'The GitHub token to use for creating the pull request.' + required: true + dry-run: + description: 'Whether to run in dry-run mode.' + required: false + default: 'false' + working-directory: + description: 'The working directory to run the commands in.' + required: false + default: '.' + +runs: + using: 'composite' + steps: + - name: 'Creates a Pull Request' + if: "inputs.dry-run != 'true'" + env: + GH_TOKEN: '${{ inputs.github-token }}' + INPUTS_BRANCH_NAME: '${{ inputs.branch-name }}' + INPUTS_PR_TITLE: '${{ inputs.pr-title }}' + INPUTS_PR_BODY: '${{ inputs.pr-body }}' + INPUTS_BASE_BRANCH: '${{ inputs.base-branch }}' + shell: 'bash' + working-directory: '${{ inputs.working-directory }}' + run: | + set -e + if ! git ls-remote --exit-code --heads origin "${INPUTS_BRANCH_NAME}"; then + echo "::error::Branch '${INPUTS_BRANCH_NAME}' does not exist on the remote repository." + exit 1 + fi + PR_URL=$(gh pr create \ + --title "${INPUTS_PR_TITLE}" \ + --body "${INPUTS_PR_BODY}" \ + --base "${INPUTS_BASE_BRANCH}" \ + --head "${INPUTS_BRANCH_NAME}" \ + --fill) + gh pr merge "$PR_URL" --auto diff --git a/.github/actions/post-coverage-comment/action.yml b/.github/actions/post-coverage-comment/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..6862e6be1f7b70b77f65731c41802fbaccf241ba --- /dev/null +++ b/.github/actions/post-coverage-comment/action.yml @@ -0,0 +1,114 @@ +name: 'Post Coverage Comment Action' +description: 'Prepares and posts a code coverage comment to a PR.' + +inputs: + cli_json_file: + description: 'Path to CLI coverage-summary.json' + required: true + core_json_file: + description: 'Path to Core coverage-summary.json' + required: true + cli_full_text_summary_file: + description: 'Path to CLI full-text-summary.txt' + required: true + core_full_text_summary_file: + description: 'Path to Core full-text-summary.txt' + required: true + node_version: + description: 'Node.js version for context in messages' + required: true + os: + description: 'The os for context in messages' + required: true + github_token: + description: 'GitHub token for posting comments' + required: true + +runs: + using: 'composite' + steps: + - name: 'Prepare Coverage Comment' + id: 'prep_coverage_comment' + shell: 'bash' + env: + CLI_JSON_FILE: '${{ inputs.cli_json_file }}' + CORE_JSON_FILE: '${{ inputs.core_json_file }}' + CLI_FULL_TEXT_SUMMARY_FILE: '${{ inputs.cli_full_text_summary_file }}' + CORE_FULL_TEXT_SUMMARY_FILE: '${{ inputs.core_full_text_summary_file }}' + COMMENT_FILE: 'coverage-comment.md' + NODE_VERSION: '${{ inputs.node_version }}' + OS: '${{ inputs.os }}' + run: |- + # Extract percentages using jq for the main table + if [ -f "${CLI_JSON_FILE}" ]; then + cli_lines_pct="$(jq -r '.total.lines.pct' "${CLI_JSON_FILE}")" + cli_statements_pct="$(jq -r '.total.statements.pct' "${CLI_JSON_FILE}")" + cli_functions_pct="$(jq -r '.total.functions.pct' "${CLI_JSON_FILE}")" + cli_branches_pct="$(jq -r '.total.branches.pct' "${CLI_JSON_FILE}")" + else + cli_lines_pct="N/A" + cli_statements_pct="N/A" + cli_functions_pct="N/A" + cli_branches_pct="N/A" + echo "CLI coverage-summary.json not found at: ${CLI_JSON_FILE}" >&2 # Error to stderr + fi + + if [ -f "${CORE_JSON_FILE}" ]; then + core_lines_pct="$(jq -r '.total.lines.pct' "${CORE_JSON_FILE}")" + core_statements_pct="$(jq -r '.total.statements.pct' "${CORE_JSON_FILE}")" + core_functions_pct="$(jq -r '.total.functions.pct' "${CORE_JSON_FILE}")" + core_branches_pct="$(jq -r '.total.branches.pct' "${CORE_JSON_FILE}")" + else + core_lines_pct="N/A" + core_statements_pct="N/A" + core_functions_pct="N/A" + core_branches_pct="N/A" + echo "Core coverage-summary.json not found at: ${CORE_JSON_FILE}" >&2 # Error to stderr + fi + + echo "## Code Coverage Summary" > "${COMMENT_FILE}" + echo "" >> "${COMMENT_FILE}" + echo "| Package | Lines | Statements | Functions | Branches |" >> "${COMMENT_FILE}" + echo "|---|---|---|---|---|" >> "${COMMENT_FILE}" + echo "| CLI | ${cli_lines_pct}% | ${cli_statements_pct}% | ${cli_functions_pct}% | ${cli_branches_pct}% |" >> "${COMMENT_FILE}" + echo "| Core | ${core_lines_pct}% | ${core_statements_pct}% | ${core_functions_pct}% | ${core_branches_pct}% |" >> "${COMMENT_FILE}" + echo "" >> "${COMMENT_FILE}" + + # CLI Package - Collapsible Section (with full text summary from file) + echo "
" >> "${COMMENT_FILE}" + echo "CLI Package - Full Text Report" >> "${COMMENT_FILE}" + echo "" >> "${COMMENT_FILE}" + echo '```text' >> "${COMMENT_FILE}" + if [ -f "${CLI_FULL_TEXT_SUMMARY_FILE}" ]; then + cat "${CLI_FULL_TEXT_SUMMARY_FILE}" >> "${COMMENT_FILE}" + else + echo "CLI full-text-summary.txt not found at: ${CLI_FULL_TEXT_SUMMARY_FILE}" >> "${COMMENT_FILE}" + fi + echo '```' >> "${COMMENT_FILE}" + echo "
" >> "${COMMENT_FILE}" + echo "" >> "${COMMENT_FILE}" + + # Core Package - Collapsible Section (with full text summary from file) + echo "
" >> "${COMMENT_FILE}" + echo "Core Package - Full Text Report" >> "${COMMENT_FILE}" + echo "" >> "${COMMENT_FILE}" + echo '```text' >> "${COMMENT_FILE}" + if [ -f "${CORE_FULL_TEXT_SUMMARY_FILE}" ]; then + cat "${CORE_FULL_TEXT_SUMMARY_FILE}" >> "${COMMENT_FILE}" + else + echo "Core full-text-summary.txt not found at: ${CORE_FULL_TEXT_SUMMARY_FILE}" >> "${COMMENT_FILE}" + fi + echo '```' >> "${COMMENT_FILE}" + echo "
" >> "${COMMENT_FILE}" + echo "" >> "${COMMENT_FILE}" + + echo "_For detailed HTML reports, please see the 'coverage-reports-${NODE_VERSION}-${OS}' artifact from the main CI run._" >> "${COMMENT_FILE}" + + - name: 'Post Coverage Comment' + uses: 'thollander/actions-comment-pull-request@65f9e5c9a1f2cd378bd74b2e057c9736982a8e74' # ratchet:thollander/actions-comment-pull-request@v3 + if: |- + ${{ always() }} + with: + file-path: 'coverage-comment.md' # Use the generated file directly + comment-tag: 'code-coverage-summary' + github-token: '${{ inputs.github_token }}' diff --git a/.github/actions/publish-release/action.yml b/.github/actions/publish-release/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..36ac640b9770b8eadb2a91d81c150658f8057590 --- /dev/null +++ b/.github/actions/publish-release/action.yml @@ -0,0 +1,359 @@ +name: 'Publish Release' +description: 'Builds, prepares, and publishes the gemini-cli packages to npm and creates a GitHub release.' + +inputs: + release-version: + description: 'The version to release (e.g., 0.1.11).' + required: true + npm-tag: + description: 'The npm tag to publish with (e.g., latest, preview, nightly).' + required: true + wombat-token-core: + description: 'The npm token for the cli-core package.' + required: true + wombat-token-cli: + description: 'The npm token for the cli package.' + required: true + wombat-token-a2a-server: + description: 'The npm token for the a2a package.' + required: true + github-token: + description: 'The GitHub token for creating the release.' + required: true + github-release-token: + description: 'The GitHub token used specifically for creating the GitHub release (to trigger other workflows).' + required: false + dry-run: + description: 'Whether to run in dry-run mode.' + type: 'string' + required: true + release-tag: + description: 'The release tag for the release (e.g., v0.1.11).' + required: true + previous-tag: + description: 'The previous tag to use for generating release notes.' + required: true + skip-github-release: + description: 'Whether to skip creating a GitHub release.' + type: 'boolean' + required: false + default: false + working-directory: + description: 'The working directory to run the steps in.' + required: false + default: '.' + force-skip-tests: + description: 'Skip tests and validation' + required: false + default: false + skip-branch-cleanup: + description: 'Whether to skip cleaning up the release branch.' + type: 'boolean' + required: false + default: false + gemini_api_key: + description: 'The API key for running integration tests.' + required: true + npm-registry-publish-url: + description: 'npm registry publish url' + required: true + npm-registry-url: + description: 'npm registry url' + required: true + npm-registry-scope: + description: 'npm registry scope' + required: true + cli-package-name: + description: 'The name of the cli package.' + required: true + core-package-name: + description: 'The name of the core package.' + required: true + a2a-package-name: + description: 'The name of the a2a package.' + required: true +runs: + using: 'composite' + steps: + - name: '👤 Configure Git User' + working-directory: '${{ inputs.working-directory }}' + shell: 'bash' + run: | + git config user.name "gemini-cli-robot" + git config user.email "gemini-cli-robot@google.com" + + - name: '🌿 Create and switch to a release branch' + working-directory: '${{ inputs.working-directory }}' + id: 'release_branch' + shell: 'bash' + run: | + BRANCH_NAME="release/${INPUTS_RELEASE_TAG}" + git switch -c "${BRANCH_NAME}" + echo "BRANCH_NAME=${BRANCH_NAME}" >> "${GITHUB_OUTPUT}" + env: + INPUTS_RELEASE_TAG: '${{ inputs.release-tag }}' + + - name: '⬆️ Update package versions' + working-directory: '${{ inputs.working-directory }}' + shell: 'bash' + run: | + npm run release:version "${INPUTS_RELEASE_VERSION}" + env: + INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}' + + - name: '💾 Commit and Conditionally Push package versions' + working-directory: '${{ inputs.working-directory }}' + shell: 'bash' + env: + BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + DRY_RUN: '${{ inputs.dry-run }}' + RELEASE_TAG: '${{ inputs.release-tag }}' + GIT_PUSH_TOKEN: '${{ inputs.github-release-token || inputs.github-token }}' + run: |- + set -e + git add package.json package-lock.json packages/*/package.json + git commit -m "chore(release): ${RELEASE_TAG}" + if [[ "${DRY_RUN}" == "false" ]]; then + echo "Pushing release branch to remote..." + git push "https://x-access-token:${GIT_PUSH_TOKEN}@github.com/${{ github.repository }}.git" "HEAD:${BRANCH_NAME}" --follow-tags + else + echo "Dry run enabled. Skipping push." + fi + + - name: '🛠️ Build and Prepare Packages' + working-directory: '${{ inputs.working-directory }}' + shell: 'bash' + run: | + npm run build:packages + npm run prepare:package + + - name: '🎁 Bundle' + working-directory: '${{ inputs.working-directory }}' + shell: 'bash' + run: | + npm run bundle + + # TODO: Refactor this github specific publishing script to be generalized based upon inputs. + - name: '📦 Prepare for GitHub release' + if: "inputs.npm-registry-url == 'https://npm.pkg.github.com/'" + working-directory: '${{ inputs.working-directory }}' + shell: 'bash' + run: | + node ${{ github.workspace }}/scripts/prepare-github-release.js + + - name: 'Configure npm for publishing to npm' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' + with: + node-version-file: '${{ inputs.working-directory }}/.nvmrc' + registry-url: '${{inputs.npm-registry-publish-url}}' + scope: '${{inputs.npm-registry-scope}}' + + - name: 'Get core Token' + uses: './.github/actions/npm-auth-token' + id: 'core-token' + with: + package-name: '${{ inputs.core-package-name }}' + github-token: '${{ inputs.github-token }}' + wombat-token-core: '${{ inputs.wombat-token-core }}' + wombat-token-cli: '${{ inputs.wombat-token-cli }}' + wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}' + + - name: '📦 Publish CORE to NPM' + working-directory: '${{ inputs.working-directory }}' + env: + NODE_AUTH_TOKEN: '${{ steps.core-token.outputs.auth-token }}' + INPUTS_DRY_RUN: '${{ inputs.dry-run }}' + INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}' + shell: 'bash' + run: | + npm publish \ + --ignore-scripts \ + --dry-run="${INPUTS_DRY_RUN}" \ + --workspace="${INPUTS_CORE_PACKAGE_NAME}" \ + --tag staging-tmp + if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then + npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} staging-tmp || echo "Warning: Failed to remove staging-tmp tag (this is normal on registries that forbid tag deletion, like Wombat)" + fi + + - name: '🔗 Install latest core package' + working-directory: '${{ inputs.working-directory }}' + if: "${{ inputs.dry-run != 'true' }}" + shell: 'bash' + run: | + npm install "${INPUTS_CORE_PACKAGE_NAME}@${INPUTS_RELEASE_VERSION}" \ + --workspace="${INPUTS_CLI_PACKAGE_NAME}" \ + --workspace="${INPUTS_A2A_PACKAGE_NAME}" \ + --save-exact + env: + INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}' + INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}' + INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' + INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' + + - name: '📦 Prepare bundled CLI for npm release' + if: "inputs.npm-registry-url != 'https://npm.pkg.github.com/'" + working-directory: '${{ inputs.working-directory }}' + shell: 'bash' + run: | + node ${{ github.workspace }}/scripts/prepare-npm-release.js + + - name: '📦 Pack CLI for verification' + if: "inputs.dry-run != 'true' && inputs.force-skip-tests != 'true'" + working-directory: '${{ inputs.working-directory }}' + shell: 'bash' + run: | + npm pack --workspace="${INPUTS_CLI_PACKAGE_NAME}" + # We restore the package.json so that `npm ci` in verify-release doesn't fail due to deleted dependencies + git checkout packages/cli/package.json + env: + INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' + + - name: '🔬 Verify NPM release by version' + uses: './.github/actions/verify-release' + if: "${{ inputs.dry-run != 'true' && inputs.force-skip-tests != 'true' }}" + with: + npm-package: './google-gemini-cli-${{ inputs.release-version }}.tgz' + expected-version: '${{ inputs.release-version }}' + working-directory: '${{ inputs.working-directory }}' + gemini_api_key: '${{ inputs.gemini_api_key }}' + github-token: '${{ inputs.github-token }}' + npm-registry-url: '${{ inputs.npm-registry-url }}' + npm-registry-scope: '${{ inputs.npm-registry-scope }}' + + - name: 'Get CLI Token' + uses: './.github/actions/npm-auth-token' + id: 'cli-token' + with: + package-name: '${{ inputs.cli-package-name }}' + github-token: '${{ inputs.github-token }}' + wombat-token-core: '${{ inputs.wombat-token-core }}' + wombat-token-cli: '${{ inputs.wombat-token-cli }}' + wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}' + + - name: '📦 Publish CLI' + working-directory: '${{ inputs.working-directory }}' + env: + NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}' + INPUTS_DRY_RUN: '${{ inputs.dry-run }}' + INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' + INPUTS_RELEASE_VERSION: '${{ inputs.release-version }}' + shell: 'bash' + run: | + if [ -f "google-gemini-cli-${INPUTS_RELEASE_VERSION}.tgz" ]; then + PUBLISH_TARGET="google-gemini-cli-${INPUTS_RELEASE_VERSION}.tgz" + else + PUBLISH_TARGET="--workspace=${INPUTS_CLI_PACKAGE_NAME}" + fi + + npm publish \ + --ignore-scripts \ + --dry-run="${INPUTS_DRY_RUN}" \ + ${PUBLISH_TARGET} \ + --tag staging-tmp + if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then + npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} staging-tmp || echo "Warning: Failed to remove staging-tmp tag (this is normal on registries that forbid tag deletion, like Wombat)" + fi + + - name: 'Get a2a-server Token' + uses: './.github/actions/npm-auth-token' + id: 'a2a-token' + with: + package-name: '${{ inputs.a2a-package-name }}' + github-token: '${{ inputs.github-token }}' + wombat-token-core: '${{ inputs.wombat-token-core }}' + wombat-token-cli: '${{ inputs.wombat-token-cli }}' + wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}' + + - name: '📦 Publish a2a' + working-directory: '${{ inputs.working-directory }}' + env: + NODE_AUTH_TOKEN: '${{ steps.a2a-token.outputs.auth-token }}' + INPUTS_DRY_RUN: '${{ inputs.dry-run }}' + INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' + shell: 'bash' + # Tag staging for initial release + run: | + npm publish \ + --ignore-scripts \ + --dry-run="${INPUTS_DRY_RUN}" \ + --workspace="${INPUTS_A2A_PACKAGE_NAME}" \ + --tag staging-tmp + if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then + npm dist-tag rm ${INPUTS_A2A_PACKAGE_NAME} staging-tmp || echo "Warning: Failed to remove staging-tmp tag (this is normal on registries that forbid tag deletion, like Wombat)" + fi + + - name: '🏷️ Tag release' + uses: './.github/actions/tag-npm-release' + with: + channel: '${{ inputs.npm-tag }}' + version: '${{ inputs.release-version }}' + dry-run: '${{ inputs.dry-run }}' + github-token: '${{ inputs.github-token }}' + wombat-token-core: '${{ inputs.wombat-token-core }}' + wombat-token-cli: '${{ inputs.wombat-token-cli }}' + wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}' + cli-package-name: '${{ inputs.cli-package-name }}' + core-package-name: '${{ inputs.core-package-name }}' + a2a-package-name: '${{ inputs.a2a-package-name }}' + working-directory: '${{ inputs.working-directory }}' + + - name: '🎉 Create GitHub Release' + working-directory: '${{ inputs.working-directory }}' + if: "${{ inputs.dry-run != 'true' && inputs.skip-github-release != 'true' && inputs.npm-tag != 'dev' && inputs.npm-registry-url != 'https://npm.pkg.github.com/' }}" + env: + GITHUB_TOKEN: '${{ inputs.github-release-token || inputs.github-token }}' + INPUTS_RELEASE_TAG: '${{ inputs.release-tag }}' + STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' + INPUTS_PREVIOUS_TAG: '${{ inputs.previous-tag }}' + shell: 'bash' + run: | + rm -f gemini-cli-bundle.zip + (cd bundle && chmod +x gemini.js && zip -r ../gemini-cli-bundle.zip .) + + echo "Testing the generated bundle archive..." + rm -rf test-bundle + mkdir -p test-bundle + unzip -q gemini-cli-bundle.zip -d test-bundle + + # Verify it runs and outputs a version + BUNDLE_VERSION=$(node test-bundle/gemini.js --version | xargs) + echo "Bundle version output: ${BUNDLE_VERSION}" + if [[ -z "${BUNDLE_VERSION}" ]]; then + echo "Error: Bundle failed to execute or return version." + exit 1 + fi + rm -rf test-bundle + + RELEASE_ASSETS=("gemini-cli-bundle.zip") + + # Check for and prepare macOS binaries if they exist + for arch in arm64 x64; do + BINARY_PATH="dist/darwin-${arch}/gemini" + if [[ -f "$BINARY_PATH" ]]; then + chmod +x "$BINARY_PATH" + ZIP_NAME="gemini-darwin-${arch}-unsigned.zip" + zip -j "$ZIP_NAME" "$BINARY_PATH" + RELEASE_ASSETS+=("$ZIP_NAME") + fi + done + + gh release create "${INPUTS_RELEASE_TAG}" \ + "${RELEASE_ASSETS[@]}" \ + --target "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}" \ + --title "Release ${INPUTS_RELEASE_TAG}" \ + --notes-start-tag "${INPUTS_PREVIOUS_TAG}" \ + --generate-notes \ + ${{ inputs.npm-tag != 'latest' && '--prerelease' || '' }} + + - name: '🧹 Clean up release branch' + working-directory: '${{ inputs.working-directory }}' + if: "${{ inputs.dry-run != 'true' && inputs.skip-branch-cleanup != 'true' }}" + continue-on-error: true + shell: 'bash' + run: | + echo "Cleaning up release branch ${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}..." + git push "https://x-access-token:${GIT_PUSH_TOKEN}@github.com/${{ github.repository }}.git" --delete "${STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME}" + + env: + GIT_PUSH_TOKEN: '${{ inputs.github-release-token || inputs.github-token }}' + STEPS_RELEASE_BRANCH_OUTPUTS_BRANCH_NAME: '${{ steps.release_branch.outputs.BRANCH_NAME }}' diff --git a/.github/actions/push-docker/action.yml b/.github/actions/push-docker/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..7644b89412d2a1160216ae085c31e33eb26f9696 --- /dev/null +++ b/.github/actions/push-docker/action.yml @@ -0,0 +1,75 @@ +name: 'Push to docker' +description: 'Builds packages and pushes a docker image to GHCR' + +inputs: + github-actor: + description: 'Github actor' + required: true + github-secret: + description: 'Github secret' + required: true + ref-name: + description: 'Github ref name' + required: true + github-sha: + description: 'Github Commit SHA Hash' + required: true + +runs: + using: 'composite' + steps: + - name: 'Checkout' + uses: 'actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955' # ratchet:actions/checkout@v4 + with: + ref: '${{ inputs.github-sha }}' + fetch-depth: 0 + - name: 'Install Dependencies' + shell: 'bash' + run: 'npm install' + - name: 'Set up Docker Buildx' + uses: 'docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435' # ratchet:docker/setup-buildx-action@v3 + - name: 'build' + shell: 'bash' + run: 'npm run build' + - name: 'pack @google/gemini-cli' + shell: 'bash' + run: 'npm pack -w @google/gemini-cli --pack-destination ./packages/cli/dist' + - name: 'pack @google/gemini-cli-core' + shell: 'bash' + run: 'npm pack -w @google/gemini-cli-core --pack-destination ./packages/core/dist' + - name: 'Log in to GitHub Container Registry' + uses: 'docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1' # ratchet:docker/login-action@v3 + with: + registry: 'ghcr.io' + username: '${{ inputs.github-actor }}' + password: '${{ inputs.github-secret }}' + - name: 'Get branch name' + id: 'branch_name' + shell: 'bash' + run: | + REF_NAME="${INPUTS_REF_NAME}" + echo "name=${REF_NAME%/merge}" >> $GITHUB_OUTPUT + env: + INPUTS_REF_NAME: '${{ inputs.ref-name }}' + - name: 'Build and Push the Docker Image' + uses: 'docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83' # ratchet:docker/build-push-action@v6 + with: + context: '.' + file: './Dockerfile' + push: true + provenance: false # avoid pushing 3 images to Aritfact Registry + tags: | + ghcr.io/${{ github.repository }}/cli:${{ steps.branch_name.outputs.name }} + ghcr.io/${{ github.repository }}/cli:${{ inputs.github-sha }} + - name: 'Create issue on failure' + if: |- + ${{ failure() }} + shell: 'bash' + env: + GITHUB_TOKEN: '${{ inputs.github-secret }}' + DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + run: |- + gh issue create \ + --title "Docker build failed" \ + --body "The docker build failed. See the full run for details: ${DETAILS_URL}" \ + --label "release-failure" diff --git a/.github/actions/push-sandbox/action.yml b/.github/actions/push-sandbox/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..38fdfd3f12661e0b82a7db991dd9eeea7c9bdbb4 --- /dev/null +++ b/.github/actions/push-sandbox/action.yml @@ -0,0 +1,114 @@ +name: 'Build and push sandbox docker' +description: 'Pushes sandbox docker image to container registry' + +inputs: + github-actor: + description: 'Github actor' + required: true + github-secret: + description: 'Github secret' + required: true + dockerhub-username: + description: 'Dockerhub username' + required: true + dockerhub-token: + description: 'Dockerhub PAT w/ R+W' + required: true + github-sha: + description: 'Github Commit SHA Hash' + required: true + github-ref-name: + description: 'Github ref name' + required: true + dry-run: + description: 'Whether this is a dry run.' + required: true + type: 'boolean' + +runs: + using: 'composite' + steps: + - name: 'Checkout' + uses: 'actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5' # ratchet:actions/checkout@v4 + with: + ref: '${{ inputs.github-sha }}' + fetch-depth: 0 + - name: 'Install Dependencies' + shell: 'bash' + run: 'npm install' + - name: 'npm build' + shell: 'bash' + run: 'npm run build' + - name: 'Set up QEMU' + uses: 'docker/setup-qemu-action@c7c53464625b32c7a7e944ae62b3e17d2b600130' # ratchet:docker/setup-qemu-action@v3 + - name: 'Set up Docker Buildx' + uses: 'docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f' # ratchet:docker/setup-buildx-action@v3 + - name: 'Log in to GitHub Container Registry' + uses: 'docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9' # ratchet:docker/login-action@v3 + with: + registry: 'docker.io' + username: '${{ inputs.dockerhub-username }}' + password: '${{ inputs.dockerhub-token }}' + - name: 'determine image tag' + id: 'image_tag' + shell: 'bash' + run: |- + SHELL_TAG_NAME="${INPUTS_GITHUB_REF_NAME}" + FINAL_TAG="${INPUTS_GITHUB_SHA}" + if [[ "$SHELL_TAG_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-zA-Z0-9.-]+)?$ ]]; then + echo "Release detected." + FINAL_TAG="${SHELL_TAG_NAME#v}" + else + echo "Development release detected. Using commit SHA as tag." + fi + echo "Determined image tag: $FINAL_TAG" + echo "FINAL_TAG=$FINAL_TAG" >> $GITHUB_OUTPUT + env: + INPUTS_GITHUB_REF_NAME: '${{ inputs.github-ref-name }}' + INPUTS_GITHUB_SHA: '${{ inputs.github-sha }}' + # We build amd64 just so we can verify it. + # We build and push both amd64 and arm64 in the publish step. + - name: 'build' + id: 'docker_build' + shell: 'bash' + env: + GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}' + GEMINI_SANDBOX: 'docker' + BUILD_SANDBOX_FLAGS: '--platform linux/amd64 --load' + STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}' + run: |- + npm run build:sandbox -- \ + --image "google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG}" \ + --output-file final_image_uri.txt + echo "uri=$(cat final_image_uri.txt)" >> $GITHUB_OUTPUT + - name: 'verify' + shell: 'bash' + run: |- + docker run --rm --entrypoint sh "${{ steps.docker_build.outputs.uri }}" -lc ' + set -e + node -e "const fs=require(\"node:fs\"); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli/package.json\",\"utf8\")); JSON.parse(fs.readFileSync(\"/usr/local/share/npm-global/lib/node_modules/@google/gemini-cli-core/package.json\",\"utf8\"));" + /usr/local/share/npm-global/bin/gemini --version >/dev/null + ' + - name: 'publish' + shell: 'bash' + if: "${{ inputs.dry-run != 'true' }}" + env: + GEMINI_SANDBOX_IMAGE_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}' + GEMINI_SANDBOX: 'docker' + BUILD_SANDBOX_FLAGS: '--platform linux/amd64,linux/arm64 --push' + STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG: '${{ steps.image_tag.outputs.FINAL_TAG }}' + run: |- + npm run build:sandbox -- \ + --image "google/gemini-cli-sandbox:${STEPS_IMAGE_TAG_OUTPUTS_FINAL_TAG}" + - name: 'Create issue on failure' + if: |- + ${{ failure() }} + shell: 'bash' + env: + GITHUB_TOKEN: '${{ inputs.github-secret }}' + DETAILS_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}' + run: |- + gh issue create \ + --title "Docker build failed" \ + --body "The docker build failed. See the full run for details: ${DETAILS_URL}" \ + --label "release-failure" diff --git a/.github/actions/run-tests/action.yml b/.github/actions/run-tests/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..6dd818bb569135092e992b82ee5611b251d18ee8 --- /dev/null +++ b/.github/actions/run-tests/action.yml @@ -0,0 +1,41 @@ +name: 'Run Tests' +description: 'Runs the preflight checks and integration tests.' + +inputs: + gemini_api_key: + description: 'The API key for running integration tests.' + required: true + working-directory: + description: 'The working directory to run the tests in.' + required: false + default: '.' + +runs: + using: 'composite' + steps: + - name: 'Install system dependencies' + if: "runner.os == 'Linux'" + run: | + sudo apt-get update -qq && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq bubblewrap + # Ubuntu 24.04+ requires this to allow bwrap to function in CI + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true + shell: 'bash' + - name: 'Run Tests' + env: + GEMINI_API_KEY: '${{ inputs.gemini_api_key }}' + GEMINI_CLI_TRUST_WORKSPACE: true + working-directory: '${{ inputs.working-directory }}' + run: |- + echo "::group::Build" + npm run build + echo "::endgroup::" + echo "::group::Unit Tests" + npm run test:ci + echo "::endgroup::" + echo "::group::Integration Tests (no sandbox)" + npm run test:integration:sandbox:none + echo "::endgroup::" + echo "::group::Integration Tests (docker sandbox)" + npm run test:integration:sandbox:docker + echo "::endgroup::" + shell: 'bash' diff --git a/.github/actions/setup-npmrc/action.yml b/.github/actions/setup-npmrc/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..137451740f119390fc0fd75547c4ebdb1774e108 --- /dev/null +++ b/.github/actions/setup-npmrc/action.yml @@ -0,0 +1,24 @@ +name: 'Setup NPMRC' +description: 'Sets up NPMRC with all the correct repos for readonly access.' + +inputs: + github-token: + description: 'the github token' + required: true + +outputs: + auth-token: + description: 'The generated NPM auth token' + value: '${{ steps.npm_auth_token.outputs.auth-token }}' + +runs: + using: 'composite' + steps: + - name: 'Configure .npmrc' + shell: 'bash' + run: |- + echo ""@google-gemini:registry=https://npm.pkg.github.com"" > ~/.npmrc + echo ""//npm.pkg.github.com/:_authToken=${INPUTS_GITHUB_TOKEN}"" >> ~/.npmrc + echo ""@google:registry=https://wombat-dressing-room.appspot.com"" >> ~/.npmrc + env: + INPUTS_GITHUB_TOKEN: '${{ inputs.github-token }}' diff --git a/.github/actions/tag-npm-release/action.yml b/.github/actions/tag-npm-release/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..479b5be6237855b84935ab90a24aa7be5c756fa4 --- /dev/null +++ b/.github/actions/tag-npm-release/action.yml @@ -0,0 +1,139 @@ +name: 'Tag an NPM release' +description: 'Tags a specific npm version to a specific channel.' + +inputs: + channel: + description: 'NPM Channel tag' + required: true + version: + description: 'version' + required: true + dry-run: + description: 'Whether to run in dry-run mode.' + required: true + github-token: + description: 'The GitHub token for creating the release.' + required: true + wombat-token-core: + description: 'The npm token for the wombat @google/gemini-cli-core' + required: true + wombat-token-cli: + description: 'The npm token for wombat @google/gemini-cli' + required: true + wombat-token-a2a-server: + description: 'The npm token for the @google/gemini-cli-a2a-server package.' + required: true + cli-package-name: + description: 'The name of the cli package.' + required: true + core-package-name: + description: 'The name of the core package.' + required: true + a2a-package-name: + description: 'The name of the a2a package.' + required: true + working-directory: + description: 'The working directory to run the commands in.' + required: false + default: '.' + +runs: + using: 'composite' + steps: + - name: 'Setup Node.js' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' + with: + node-version-file: '${{ inputs.working-directory }}/.nvmrc' + + - name: 'configure .npmrc' + uses: './.github/actions/setup-npmrc' + with: + github-token: '${{ inputs.github-token }}' + + - name: 'Get core Token' + uses: './.github/actions/npm-auth-token' + id: 'core-token' + with: + package-name: '${{ inputs.core-package-name }}' + github-token: '${{ inputs.github-token }}' + wombat-token-core: '${{ inputs.wombat-token-core }}' + wombat-token-cli: '${{ inputs.wombat-token-cli }}' + wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}' + + - name: 'Change tag for CORE' + if: |- + ${{ inputs.dry-run != 'true' }} + env: + NODE_AUTH_TOKEN: '${{ steps.core-token.outputs.auth-token }}' + INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}' + INPUTS_VERSION: '${{ inputs.version }}' + INPUTS_CHANNEL: '${{ inputs.channel }}' + shell: 'bash' + working-directory: '${{ inputs.working-directory }}' + run: | + npm dist-tag add ${INPUTS_CORE_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL} + + - name: 'Get cli Token' + uses: './.github/actions/npm-auth-token' + id: 'cli-token' + with: + package-name: '${{ inputs.cli-package-name }}' + github-token: '${{ inputs.github-token }}' + wombat-token-core: '${{ inputs.wombat-token-core }}' + wombat-token-cli: '${{ inputs.wombat-token-cli }}' + wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}' + + - name: 'Change tag for CLI' + if: |- + ${{ inputs.dry-run != 'true' }} + env: + NODE_AUTH_TOKEN: '${{ steps.cli-token.outputs.auth-token }}' + INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' + INPUTS_VERSION: '${{ inputs.version }}' + INPUTS_CHANNEL: '${{ inputs.channel }}' + shell: 'bash' + working-directory: '${{ inputs.working-directory }}' + run: | + npm dist-tag add ${INPUTS_CLI_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL} + + - name: 'Get a2a Token' + uses: './.github/actions/npm-auth-token' + id: 'a2a-token' + with: + package-name: '${{ inputs.a2a-package-name }}' + github-token: '${{ inputs.github-token }}' + wombat-token-core: '${{ inputs.wombat-token-core }}' + wombat-token-cli: '${{ inputs.wombat-token-cli }}' + wombat-token-a2a-server: '${{ inputs.wombat-token-a2a-server }}' + + - name: 'Change tag for a2a' + if: |- + ${{ inputs.dry-run == 'false' }} + env: + NODE_AUTH_TOKEN: '${{ steps.a2a-token.outputs.auth-token }}' + INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' + INPUTS_VERSION: '${{ inputs.version }}' + INPUTS_CHANNEL: '${{ inputs.channel }}' + shell: 'bash' + working-directory: '${{ inputs.working-directory }}' + run: | + npm dist-tag add ${INPUTS_A2A_PACKAGE_NAME}@${INPUTS_VERSION} ${INPUTS_CHANNEL} + + - name: 'Log dry run' + if: |- + ${{ inputs.dry-run == 'true' }} + shell: 'bash' + working-directory: '${{ inputs.working-directory }}' + run: | + echo "Dry run: Would have added tag '${INPUTS_CHANNEL}' to version '${INPUTS_VERSION}' for ${INPUTS_CLI_PACKAGE_NAME}, ${INPUTS_CORE_PACKAGE_NAME}, and ${INPUTS_A2A_PACKAGE_NAME}." + + env: + INPUTS_CHANNEL: '${{ inputs.channel }}' + + INPUTS_VERSION: '${{ inputs.version }}' + + INPUTS_CLI_PACKAGE_NAME: '${{ inputs.cli-package-name }}' + + INPUTS_CORE_PACKAGE_NAME: '${{ inputs.core-package-name }}' + + INPUTS_A2A_PACKAGE_NAME: '${{ inputs.a2a-package-name }}' diff --git a/.github/actions/verify-release/action.yml b/.github/actions/verify-release/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..92e7690ae5e65d039a83e503897e3db4143b4cbb --- /dev/null +++ b/.github/actions/verify-release/action.yml @@ -0,0 +1,103 @@ +name: 'Verify an NPM release' +description: 'Fetches a package from NPM and does some basic smoke tests' + +inputs: + npm-package: + description: 'NPM Package' + required: true + default: '@google/gemini-cli@latest' + npm-registry-url: + description: 'NPM Registry URL' + required: true + npm-registry-scope: + description: 'NPM Registry Scope' + required: true + expected-version: + description: 'Expected version' + required: true + gemini_api_key: + description: 'The API key for running integration tests.' + required: true + github-token: + description: 'The GitHub token for running integration tests.' + required: true + working-directory: + description: 'The working directory to run the tests in.' + required: false + default: '.' + +runs: + using: 'composite' + steps: + - name: 'setup node' + uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions/setup-node@v4 + with: + node-version: '20' + + - name: 'configure .npmrc' + uses: './.github/actions/setup-npmrc' + with: + github-token: '${{ inputs.github-token }}' + + - name: 'Clear npm cache' + shell: 'bash' + run: 'npm cache clean --force' + + - name: 'Install from NPM' + uses: 'nick-fields/retry@ce71cc2ab81d554ebbe88c79ab5975992d79ba08' # ratchet:nick-fields/retry@v3 + with: + timeout_seconds: 900 + retry_wait_seconds: 30 + max_attempts: 10 + command: |- + cd ${{ inputs.working-directory }} + npm install --prefer-online --no-cache -g "${{ inputs.npm-package }}" + + - name: 'Smoke test - NPM Install' + shell: 'bash' + working-directory: '${{ inputs.working-directory }}' + run: |- + gemini_version=$(gemini --version) + if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then + echo "❌ NPM Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}" + exit 1 + fi + env: + INPUTS_EXPECTED_VERSION: '${{ inputs.expected-version }}' + INPUTS_NPM_PACKAGE: '${{ inputs.npm-package }}' + + - name: 'Clear npm cache' + shell: 'bash' + run: 'npm cache clean --force' + + - name: 'Smoke test - NPX Run' + shell: 'bash' + working-directory: '${{ inputs.working-directory }}' + run: |- + gemini_version=$(npx --yes --prefer-online "${INPUTS_NPM_PACKAGE}" --version) + if [ "$gemini_version" != "${INPUTS_EXPECTED_VERSION}" ]; then + echo "❌ NPX Run Version mismatch: Got $gemini_version from ${INPUTS_NPM_PACKAGE}, expected ${INPUTS_EXPECTED_VERSION}" + exit 1 + fi + env: + INPUTS_NPM_PACKAGE: '${{ inputs.npm-package }}' + INPUTS_EXPECTED_VERSION: '${{ inputs.expected-version }}' + + - name: 'Install dependencies for integration tests' + shell: 'bash' + working-directory: '${{ inputs.working-directory }}' + run: 'npm ci --ignore-scripts' + + - name: '🔬 Run integration tests against NPM release' + working-directory: '${{ inputs.working-directory }}' + env: + GEMINI_API_KEY: '${{ inputs.gemini_api_key }}' + GEMINI_CLI_TRUST_WORKSPACE: true + INTEGRATION_TEST_USE_INSTALLED_GEMINI: 'true' + # We must diable CI mode here because it interferes with interactive tests. + # See https://github.com/google-gemini/gemini-cli/issues/10517 + CI: 'false' + shell: 'bash' + run: | + export INTEGRATION_TEST_GEMINI_BINARY_PATH=$(which gemini) + npm run test:integration:sandbox:none diff --git a/packages/a2a-server/GEMINI.md b/packages/a2a-server/GEMINI.md new file mode 100644 index 0000000000000000000000000000000000000000..34e487e3bb72008e61ad7e000fd34fece3945057 --- /dev/null +++ b/packages/a2a-server/GEMINI.md @@ -0,0 +1,22 @@ +# Gemini CLI A2A Server (`@google/gemini-cli-a2a-server`) + +Experimental Agent-to-Agent (A2A) server that exposes Gemini CLI capabilities +over HTTP for inter-agent communication. + +## Architecture + +- `src/agent/`: Agent session management for A2A interactions. +- `src/commands/`: CLI command definitions for the A2A server binary. +- `src/config/`: Server configuration. +- `src/http/`: HTTP server and route handlers. +- `src/persistence/`: Session and state persistence. +- `src/utils/`: Shared utility functions. +- `src/types.ts`: Shared type definitions. + +## Running + +- Binary entry point: `gemini-cli-a2a-server` + +## Testing + +- Run tests: `npm test -w @google/gemini-cli-a2a-server` diff --git a/packages/a2a-server/README.md b/packages/a2a-server/README.md new file mode 100644 index 0000000000000000000000000000000000000000..bd6a2fac45aa6370e2b476c4637eb64289d6c832 --- /dev/null +++ b/packages/a2a-server/README.md @@ -0,0 +1,5 @@ +# Gemini CLI A2A Server + +## All code in this package is experimental and under active development + +This package contains the A2A server implementation for the Gemini CLI. diff --git a/packages/a2a-server/development-extension-rfc.md b/packages/a2a-server/development-extension-rfc.md new file mode 100644 index 0000000000000000000000000000000000000000..e749a5ff09ad1394db608d35dfcb86d7a9f60925 --- /dev/null +++ b/packages/a2a-server/development-extension-rfc.md @@ -0,0 +1,509 @@ +# RFC: Gemini CLI A2A Development-Tool Extension + +## 1. Introduction + +### 1.1 Overview + +To standardize client integrations with the Gemini CLI agent, this document +proposes the `development-tool` extension for the A2A protocol. + +Rather than creating a new protocol, this specification builds upon the existing +A2A protocol. As an open-source standard recently adopted by the Linux +Foundation, A2A provides a robust foundation for core concepts like tasks, +messages, and streaming events. This extension-based approach allows us to +leverage A2A's proven architecture while defining the specific capabilities +required for rich, interactive workflows with the Gemini CLI agent. + +### 1.2 Motivation + +Recent work integrating Gemini CLI with clients like Zed and Gemini Code +Assist’s agent mode has highlighted the need for a robust, standard +communication protocol. Standardizing on A2A provides several key advantages: + +- **Solid Foundation**: Provides a robust, open standard that ensures a stable, + predictable, and consistent integration experience across different IDEs and + client surfaces. +- **Extensibility**: Creates a flexible foundation to support new tools and + workflows as they emerge. +- **Ecosystem Alignment**: Aligns Gemini CLI with a growing industry standard, + fostering broader interoperability. + +## 2. Communication Flow + +The interaction follows A2A’s task-based, streaming pattern. The client sends a +`message/stream` request and the agent responds with a `contextId` / `taskId` +and a stream of events. `TaskStatusUpdateEvent` events are used to convey the +overall state of the task. The task is complete when the agent sends a final +`TaskStatusUpdateEvent` with `final: true` and a terminal status like +`completed` or `failed`. + +### 2.1 Asynchronous Responses and Notifications + +Clients that may disconnect from the agent should supply a +`PushNotificationConfig` to the agent with the initial `message/stream` method +or subsequently with the `tasks/pushNotificationConfig/set` method so that the +agent can call back when updates are ready. + +## 3. The `development-tool` extension + +### 3.1 Overview + +The `development-tool` extension establishes a communication contract for +workflows between a client and the Gemini CLI agent. It consists of a +specialized set of schemas, embedded within core A2A data structures, that +enable the agent to stream real-time updates on its state and thought process. +These schemas also provide the mechanism for the agent to request user +permission before executing tools. + +**Sample Agent Card** + +```json +{ + "name": "Gemini CLI Agent", + "description": "An agent that generates code based on natural language instructions.", + "capabilities": { + "streaming": true, + "extensions": [ + { + "uri": "https://github.com/google-gemini/gemini-cli/blob/main/docs/a2a/developer-profile/v0/spec.md", + "description": "An extension for interactive development tasks, enabling features like code generation, tool usage, and real-time status updates.", + "required": true + } + ] + } +} +``` + +**Versioning** + +The agent card `uri` field contains an embedded semantic version. The client +must extract this version to determine compatibility with the agent extension +using the compatibility logic defined in Semantic Versioning 2.0.0 spec. + +### 3.2 Schema Definitions + +This section defines the schemas for the `development-tool` A2A extension, +organized by their function within the communication flow. Note that all custom +objects included in the `metadata` field (e.g. `Message.metadata`) must be keyed +by the unique URI that points to that extension’s spec to prevent naming +collisions with other extensions. + +**Initialization & Configuration** + +The first message in a session must contain an `AgentSettings` object in its +metadata. This object provides the agent with the necessary configuration +information for proper initialization. Additional configuration settings (ex. +MCP servers, allowed tools, etc.) can be added to this message. + +**Schema** + +```proto +syntax = "proto3"; + +// Configuration settings for the Gemini CLI agent. +message AgentSettings { + // The absolute path to the workspace directory where the agent will execute. + string workspace_path = 1; +} +``` + +**Agent-to-Client Messages** + +All real-time updates from the agent (including its thoughts, tool calls, and +simple text replies) are streamed to the client as `TaskStatusUpdateEvents`. + +Each Event contains a `Message` object, which holds the content in one of two +formats: + +- **TextPart**: Used for standard text messages. This part requires no custom + schema. +- **DataPart**: Used for complex, structured objects. Tool Calls and Thoughts + are sent this way, each using their respective schemas defined below. + +**Tool Calls** + +The `ToolCall` schema is designed to provide a structured representation of a +tool’s execution lifecycle. This protocol defines a clear state machine and +provides detailed schemas for common development tasks (file edits, shell +commands, MCP Tool), ensuring clients can build reliable UIs without being tied +to a specific agent implementation. + +The core principle is that the agent sends a `ToolCall` object on every update. +This makes client-side logic stateless and simple. + +**Tool Call Lifecycle** + +1. **Creation**: The agent sends a `ToolCall` object with `status: PENDING`. If + user permission is required, the `confirmation_request` field will be + populated. +2. **Confirmation**: If the client needs to confirm the message, the client + will send a `ToolCallConfirmation`. If the client responds with a + cancellation, execution will be skipped. +3. **Execution**: Once approved (or if no approval is required), the agent + sends an update with `status: EXECUTING`. It can stream real-time progress + by updating the `live_content` field. +4. **Completion**: The agent sends a final update with the status set to + `SUCCEEDED`, `FAILED`, or `CANCELLED` and populates the appropriate result + field. + +**Schema** + +```proto +syntax = "proto3"; + +import "google/protobuf/struct.proto"; + +// ToolCall is the central message representing a tool's execution lifecycle. +// The entire object is sent from the agent to client on every update. +message ToolCall { + // A unique identifier, assigned by the agent + string tool_call_id = 1; + + // The current state of the tool call in its lifecycle + ToolCallStatus status = 2; + + // Name of the tool being called (e.g. 'Edit', 'ShellTool') + string tool_name = 3; + + // An optional description of the tool call's purpose to show the user + optional string description = 4; + + // The structured input params provided by the LLM for tool invocation. + google.protobuf.Struct input_parameters = 5; + + // String containing the real-time output from the tool as it executes (primarily designed for shell output). + // During streaming the entire string is replaced on each update + optional string live_content = 6; + + // The final result of the tool (used to replace live_content when applicable) + oneof result { + // The output on tool success + ToolOutput output = 7; + // The error details if the tool failed + ErrorDetails error = 8; + } + + // If the tool requires user confirmation, this field will be populated while status is PENDING + optional ConfirmationRequest confirmation_request = 9; +} + +// Possible execution status of a ToolCall +enum ToolCallStatus { + STATUS_UNSPECIFIED = 0; + PENDING = 1; + EXECUTING = 2; + SUCCEEDED = 3; + FAILED = 4; + CANCELLED = 5; +} + +// ToolOutput represents the final, successful, output of a tool +message ToolOutput { + oneof result { + string text = 1; + // For ToolCalls which resulted in a file modification + FileDiff diff = 2; + // A generic fallback for any other structured JSON data + google.protobuf.Struct structured_data = 3; + } +} + +// A structured representation of an error +message ErrorDetails { + // User facing error message + string message = 1; + // Optional agent-specific error type or category (e.g. read_content_failure, grep_execution_error, mcp_tool_error) + optional string type = 2; + // Optional status code + optional int32 status_code = 3; +} + +// ConfirmationRequest is sent from the agent to client to request user permission for a ToolCall +message ConfirmationRequest { + // A list of choices for the user to select from + repeated ConfirmationOption options = 1; + // Specific details of the action requiring user confirmation + oneof details { + ExecuteDetails execute_details = 2; + FileDiff file_edit_details = 3; + McpDetails mcp_details = 4; + GenericDetails generic_details = 5; + } +} + +// A single choice presented to the user during a confirmation request +message ConfirmationOption { + // Unique ID for the choice (e.g. proceed_once, cancel) + string id = 1; + // Human-readable choice (e.g. Allow Once, Reject). + string name = 2; + // An optional longer description for a tooltip + optional string description = 3; +} + +// Details for a request to execute a shell command +message ExecuteDetails { + // The shell command to be executed + string command = 1; + // An optional directory in which the command will be run + optional string working_directory = 2; +} + + +message FileDiff { + string file_name = 1; + // The absolute path to the file to modify + string file_path = 2; + // The original content, if the file exists + optional string old_content = 3; + string new_content = 4; + // Pre-formatted diff string for display + optional string formatted_diff = 5; +} + +// Details for an MCP (Model Context Protocol) tool confirmation +message McpDetails { + // The name of the MCP server that provides the tool + string server_name = 1; + // THe name of the tool being called from the MCP Server + string tool_name = 2; +} + +// Generic catch-all for ToolCall requests that don't fit other types +message GenericDetails { + // Description of the action requiring confirmation + string description = 1; +} +``` + +**Agent Thoughts** + +**Schema** + +```proto +syntax = "proto3"; + +// Represents a thought with a subject and a detailed description. +message AgentThought { + // A concise subject line or title for the thought. + string subject = 1; + + // The description or elaboration of the thought itself. + string description = 2; +} +``` + +**Event Metadata** + +The `metadata` object in `TaskStatusUpdateEvent` is used by the A2A client to +deserialize the `TaskStatusUpdateEvents` into their appropriate objects. + +**Schema** + +```proto +syntax = "proto3"; + +// A DevelopmentToolEvent event. +message DevelopmentToolEvent { + // Enum representing the specific type of development tool event. + enum DevelopmentToolEventKind { + // The default, unspecified value. + DEVELOPMENT_TOOL_EVENT_KIND_UNSPECIFIED = 0; + TOOL_CALL_CONFIRMATION = 1; + TOOL_CALL_UPDATE = 2; + TEXT_CONTENT = 3; + STATE_CHANGE = 4; + THOUGHT = 5; + } + + // The specific kind of event that occurred. + DevelopmentToolEventKind kind = 1; + + // The model used for this event. + string model = 2; + + // The tier of the user (optional). + string user_tier = 3; + + // An unexpected error occurred in the agent execution (optional). + string error = 4; +} +``` + +**Client-to-Agent Messages** + +When the agent sends a `TaskStatusUpdateEvent` with `status.state` set to +`input-required` and its message contains a `ConfirmationRequest`, the client +must respond by sending a new `message/stream` request. + +This new request must include the `contextId` and the `taskId` from the ongoing +task and contain a `ToolCallConfirmation` object. This object conveys the user's +decision regarding the tool call that was awaiting approval. + +**Schema** + +```proto +syntax = "proto3"; + +// The client's response to a ConfirmationRequest. +message ToolCallConfirmation { + // A unique identifier, assigned by the agent + string tool_call_id = 1; + // The 'id' of the ConfirmationOption chosen by the user. + string selected_option_id = 2; + // Included if the user modifies the proposed change. + // The type should correspond to the original ConfirmationRequest details. + oneof modified_details { + // Corresponds to a FileDiff confirmation + ModifiedFileDetails file_details = 3; + } +} + +message ModifiedFileDetails { + // The new content after user edits. + string new_content = 1; +} +``` + +### 3.3 Method Definitions + +This section defines the new methods introduced by the `development-tool` +extension. + +**Method: `commands/get`** + +This method allows the client to discover slash commands supported by Gemini +CLI. The client should call this method during startup to dynamically populate +its command list. + +```proto +// Response message containing the list of all top-level slash commands. +message GetAllSlashCommandsResponse { + // A list of the top-level slash commands. + repeated SlashCommand commands = 1; +} + +// Represents a single slash command, which can contain subcommands. +message SlashCommand { + // The primary name of the command. + string name = 1; + // A detailed description of what the command does. + string description = 2; + // A list of arguments that the command accepts. + repeated SlashCommandArgument arguments = 3; + // A list of nested subcommands. + repeated SlashCommand sub_commands = 4; +} + +// Defines the structure for a single slash command argument. +message SlashCommandArgument { + // The name of the argument. + string name = 1; + // A brief description of what the argument is for. + string description = 2; + // Whether the argument is required or optional. + bool is_required = 3; +} +``` + +**Method: `command/execute`** + +This method allows the client to execute a slash command. Following the initial +`ExecuteSlashCommandResponse`, the agent will use the standard streaming +mechanism to communicate the command's progress and output. All subsequent +updates, including textual output, agent thoughts, and any required user +confirmations for tool calls (like executing a shell command), will be sent as +`TaskStatusUpdateEvent` messages, re-using the schemas defined above. + +```proto +// Request to execute a specific slash command. +message ExecuteSlashCommandRequest { + // The path to the command, e.g., ["memory", "list"] for /memory list + repeated string command_path = 1; + // The arguments for the command as a single string. + string args = 2; +} + +// Enum for the initial status of a command execution request. +enum CommandExecutionStatus { + // Default unspecified status. + COMMAND_EXECUTION_STATUS_UNSPECIFIED = 0; + // The command was successfully received and its execution has started. + STARTED = 1; + // The command failed to start (e.g., command not found, invalid format). + FAILED_TO_START = 2; + // The command has been paused and is waiting for the user to confirm + // a set of shell commands. + AWAITING_SHELL_CONFIRMATION = 3; + // The command has been paused and is waiting for the user to confirm + // a specific action. + AWAITING_ACTION_CONFIRMATION = 4; +} + +// The immediate, async response after requesting a command execution. +message ExecuteSlashCommandResponse { + // A unique taskID for this specific command execution. + string execution_id = 1; + // The initial status of the command execution. + CommandExecutionStatus status = 2; + // An optional message, particularly useful for explaining why a command + // failed to start. + string message = 3; +} +``` + +## 4. Separation of Concerns + +We believe that all client-side context (ex., workspace state) and client-side +tool execution (ex. read active buffers) should be routed through MCP. + +This approach enforces a strict separation of concerns: the A2A +`development-tool` extension standardizes communication to the agent, while MCP +serves as the single, authoritative interface for client-side capabilities. + +## Appendix + +### A. Example Interaction Flow + +1. **Client -> Server**: The client sends a `message/stream` request containing + the initial prompt and configuration in an `AgentSettings` object. +2. **Server -> Client**: SSE stream begins. + - **Event 1**: The server sends a `Task` object with + `status.state: 'submitted'` and the new `taskId`. + - **Event 2**: The server sends a `TaskStatusUpdateEvent` with the metadata + `kind` set to `'STATE_CHANGE'` and `status.state` set to `'working'`. +3. **Agent Logic**: The agent processes the prompt and decides to call the + `write_file` tool, which requires user confirmation. +4. **Server -> Client**: + - **Event 3**: The server sends a `TaskStatusUpdateEvent`. The metadata + `kind` is `'TOOL_CALL_UPDATE'`, and the `DataPart` contains a `ToolCall` + object with its `status` as `'PENDING'` and a populated + `confirmation_request`. + - **Event 4**: The server sends a final `TaskStatusUpdateEvent` for this + exchange. The metadata `kind` is `'STATE_CHANGE'`, the `status.state` is + `'input-required'`, and `final` is `true`. The stream for this request + ends. +5. **Client**: The client UI renders the confirmation prompt based on the + `ToolCall` object from Event 3. The user clicks "Approve." +6. **Client -> Server**: The client sends a new `message/stream` request. It + includes the `taskId` from the ongoing task and a `DataPart` containing a + `ToolCallConfirmation` object (e.g., + `{"tool_call_id": "...", "selected_option_id": "proceed_once"}`). +7. **Server -> Client**: A new SSE stream begins for the second request. + - **Event 1**: The server sends a `TaskStatusUpdateEvent` with + `kind: 'TOOL_CALL_UPDATE'`, containing the `ToolCall` object with its + `status` now set to `'EXECUTING'`. + - **Event 2**: After the tool runs, the server sends another + `TaskStatusUpdateEvent` with `kind: 'TOOL_CALL_UPDATE'`, containing the + `ToolCall` with its `status` as `'SUCCEEDED'`. +8. **Agent Logic**: The agent receives the successful tool result and generates + a final textual response. +9. **Server -> Client**: + - **Event 3**: The server sends a `TaskStatusUpdateEvent` with + `kind: 'TEXT_CONTENT'` and a `TextPart` containing the agent's final + answer. + - **Event 4**: The server sends the final `TaskStatusUpdateEvent`. The + `kind` is `'STATE_CHANGE'`, the `status.state` is `'completed'`, and + `final` is `true`. The stream ends. +10. **Client**: The client displays the final answer. The task is now complete + but can be continued by sending another message with the same `taskId`. diff --git a/packages/a2a-server/index.ts b/packages/a2a-server/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..3e74d6beda7ba356f3b3fd22540a54c4fba43468 --- /dev/null +++ b/packages/a2a-server/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './src/index.js'; diff --git a/packages/a2a-server/package.json b/packages/a2a-server/package.json new file mode 100644 index 0000000000000000000000000000000000000000..87c4c2f86b2e01a63df1386e81468e9fc96dc10d --- /dev/null +++ b/packages/a2a-server/package.json @@ -0,0 +1,52 @@ +{ + "name": "@google/gemini-cli-a2a-server", + "version": "0.62.0-nightly.20260915.gae28844fb", + "description": "Gemini CLI A2A Server", + "repository": { + "type": "git", + "url": "git+https://github.com/google-gemini/gemini-cli.git", + "directory": "packages/a2a-server" + }, + "type": "module", + "main": "dist/index.js", + "bin": { + "gemini-cli-a2a-server": "dist/a2a-server.mjs" + }, + "scripts": { + "build": "node ../../scripts/build_package.js", + "start": "node dist/src/http/server.js", + "lint": "eslint . --ext .ts,.tsx", + "format": "prettier --write .", + "test": "vitest run", + "test:ci": "vitest run --coverage", + "typecheck": "tsc --noEmit" + }, + "files": [ + "dist" + ], + "dependencies": { + "@a2a-js/sdk": "0.3.11", + "@google-cloud/storage": "7.19.0", + "@google/gemini-cli-core": "file:../core", + "express": "5.1.0", + "fs-extra": "11.3.0", + "strip-json-comments": "3.1.1", + "tar": "7.5.8", + "uuid": "13.0.0", + "winston": "3.17.0" + }, + "devDependencies": { + "@google/genai": "1.30.0", + "@types/express": "5.0.3", + "@types/fs-extra": "11.0.4", + "@types/supertest": "6.0.3", + "@types/tar": "6.1.13", + "dotenv": "16.4.5", + "supertest": "7.1.4", + "typescript": "5.8.3", + "vitest": "3.2.4" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/a2a-server/src/agent/executor.test.ts b/packages/a2a-server/src/agent/executor.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..bb37cc46f8416531623815ec8e4f31cf044ab32e --- /dev/null +++ b/packages/a2a-server/src/agent/executor.test.ts @@ -0,0 +1,587 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { CoderAgentExecutor } from './executor.js'; +import type { + ExecutionEventBus, + RequestContext, + TaskStore, +} from '@a2a-js/sdk/server'; +import { EventEmitter } from 'node:events'; +import { requestStorage } from '../http/requestStorage.js'; + +vi.mock('../utils/path_utils.js', () => ({ + validateWorkspacePath: vi + .fn() + .mockImplementation(async (path?: string) => path || process.cwd()), +})); + +// Mocks for constructor dependencies +vi.mock('@google/gemini-cli-core', () => ({ + GeminiEventType: { + PRIMARY_TURN_STARTED: 'PRIMARY_TURN_STARTED', + SECONDARY_TURN_STARTED: 'SECONDARY_TURN_STARTED', + }, + SimpleExtensionLoader: vi.fn(), + checkPathTrust: vi.fn().mockReturnValue({ isTrusted: false }), + isHeadlessMode: vi.fn().mockReturnValue(true), + resolveToRealPath: vi.fn().mockImplementation((p) => p), +})); + +vi.mock('../config/config.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ + getSessionId: () => 'test-session', + getTargetDir: () => '/tmp', + getCheckpointingEnabled: () => false, + }), + loadEnvironment: vi.fn(), + setIsTrusted: vi.fn().mockReturnValue(false), + setTargetDir: vi.fn().mockReturnValue('/tmp'), + envStorage: { + run: (env: Record, cb: () => unknown) => cb(), + }, + cwdSymbol: Symbol('cwd'), +})); + +vi.mock('../config/settings.js', () => ({ + loadSettings: vi.fn().mockReturnValue({}), +})); + +vi.mock('../config/extension.js', () => ({ + loadExtensions: vi.fn().mockReturnValue([]), +})); + +vi.mock('../http/requestStorage.js', () => ({ + requestStorage: { + getStore: vi.fn(), + }, +})); + +vi.mock('./task.js', () => { + const mockTaskInstance = (taskId: string, contextId: string) => ({ + id: taskId, + contextId, + taskState: 'working', + acceptUserMessage: vi + .fn() + .mockImplementation(async function* (context, aborted) { + const isConfirmation = ( + context.userMessage.parts as Array<{ kind: string }> + ).some((p) => p.kind === 'confirmation'); + // Hang only for main user messages (text), allow confirmations to finish quickly + if (!isConfirmation && aborted) { + await new Promise((resolve) => { + aborted.addEventListener('abort', resolve, { once: true }); + }); + } + yield { type: 'content', value: 'hello' }; + }), + acceptAgentMessage: vi.fn().mockResolvedValue(undefined), + scheduleToolCalls: vi.fn().mockResolvedValue(undefined), + waitForPendingTools: vi.fn().mockResolvedValue(undefined), + getAndClearCompletedTools: vi.fn().mockReturnValue([]), + get hasPendingTools() { + return false; + }, + get pendingToolsCount() { + return 0; + }, + addToolResponsesToHistory: vi.fn(), + sendCompletedToolsToLlm: vi.fn().mockImplementation(async function* () {}), + cancelPendingTools: vi.fn(), + setTaskStateAndPublishUpdate: vi.fn(), + dispose: vi.fn(), + getMetadata: vi.fn().mockResolvedValue({}), + geminiClient: { + initialize: vi.fn().mockResolvedValue(undefined), + }, + toSDKTask: () => ({ + id: taskId, + contextId, + kind: 'task', + status: { state: 'working', timestamp: new Date().toISOString() }, + metadata: {}, + history: [], + artifacts: [], + }), + }); + + const MockTask = vi.fn().mockImplementation(mockTaskInstance); + (MockTask as unknown as { create: Mock }).create = vi + .fn() + .mockImplementation(async (taskId: string, contextId: string) => + mockTaskInstance(taskId, contextId), + ); + + return { Task: MockTask }; +}); + +describe('CoderAgentExecutor', () => { + let executor: CoderAgentExecutor; + let mockTaskStore: TaskStore; + let mockEventBus: ExecutionEventBus; + + beforeEach(() => { + vi.clearAllMocks(); + mockTaskStore = { + save: vi.fn().mockResolvedValue(undefined), + load: vi.fn().mockResolvedValue(undefined), + delete: vi.fn().mockResolvedValue(undefined), + list: vi.fn().mockResolvedValue([]), + } as unknown as TaskStore; + + mockEventBus = new EventEmitter() as unknown as ExecutionEventBus; + mockEventBus.publish = vi.fn(); + mockEventBus.finished = vi.fn(); + + executor = new CoderAgentExecutor(mockTaskStore); + }); + + it('should distinguish between primary and secondary execution', async () => { + const taskId = 'test-task'; + const contextId = 'test-context'; + + const mockSocket = new EventEmitter(); + const requestContext = { + userMessage: { + messageId: 'msg-1', + taskId, + contextId, + parts: [{ kind: 'text', text: 'hi' }], + metadata: { + coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' }, + }, + }, + } as unknown as RequestContext; + + // Mock requestStorage for primary + (requestStorage.getStore as Mock).mockReturnValue({ + req: { socket: mockSocket }, + }); + + // First execution (Primary) + const primaryPromise = executor.execute(requestContext, mockEventBus); + + // Give it enough time to reach line 490 in executor.ts + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect( + ( + executor as unknown as { executingTasks: Set } + ).executingTasks.has(taskId), + ).toBe(true); + const wrapper = executor.getTask(taskId); + expect(wrapper).toBeDefined(); + + // Mock requestStorage for secondary + const secondarySocket = new EventEmitter(); + (requestStorage.getStore as Mock).mockReturnValue({ + req: { socket: secondarySocket }, + }); + + const secondaryRequestContext = { + userMessage: { + messageId: 'msg-2', + taskId, + contextId, + parts: [{ kind: 'confirmation', callId: '1', outcome: 'proceed' }], + metadata: { + coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' }, + }, + }, + } as unknown as RequestContext; + + const secondaryPromise = executor.execute( + secondaryRequestContext, + mockEventBus, + ); + + // Secondary execution should NOT add to executingTasks (already there) + // and should return early after its loop + await secondaryPromise; + + // Task should still be in executingTasks and NOT disposed + expect( + ( + executor as unknown as { executingTasks: Set } + ).executingTasks.has(taskId), + ).toBe(true); + expect(wrapper?.task.dispose).not.toHaveBeenCalled(); + + // Now simulate secondary socket closure - it should NOT affect primary + secondarySocket.emit('end'); + expect( + ( + executor as unknown as { executingTasks: Set } + ).executingTasks.has(taskId), + ).toBe(true); + expect(wrapper?.task.dispose).not.toHaveBeenCalled(); + + // Set to terminal state to verify disposal on finish + wrapper!.task.taskState = 'completed'; + + // Now close primary socket + mockSocket.emit('end'); + + await primaryPromise; + + expect( + ( + executor as unknown as { executingTasks: Set } + ).executingTasks.has(taskId), + ).toBe(false); + expect(wrapper?.task.dispose).toHaveBeenCalled(); + }); + + it('should evict task from cache when it reaches terminal state', async () => { + const taskId = 'test-task-terminal'; + const contextId = 'test-context'; + + const mockSocket = new EventEmitter(); + (requestStorage.getStore as Mock).mockReturnValue({ + req: { socket: mockSocket }, + }); + + const requestContext = { + userMessage: { + messageId: 'msg-1', + taskId, + contextId, + parts: [{ kind: 'text', text: 'hi' }], + metadata: { + coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' }, + }, + }, + } as unknown as RequestContext; + + const primaryPromise = executor.execute(requestContext, mockEventBus); + await new Promise((resolve) => setTimeout(resolve, 50)); + + const wrapper = executor.getTask(taskId)!; + expect(wrapper).toBeDefined(); + // Simulate terminal state + wrapper.task.taskState = 'completed'; + + // Finish primary execution + mockSocket.emit('end'); + await primaryPromise; + + expect(executor.getTask(taskId)).toBeUndefined(); + expect(wrapper.task.dispose).toHaveBeenCalled(); + }); + + it('should yield the turn and transition to input-required if tools are pending', async () => { + const taskId = 'test-task-pending-tools'; + const contextId = 'test-context'; + + const mockSocket = new EventEmitter(); + (requestStorage.getStore as Mock).mockReturnValue({ + req: { socket: mockSocket }, + }); + + // Pre-create the task to safely modify its mocked methods before execution + const wrapper = await executor.createTask( + taskId, + contextId, + undefined, + mockEventBus, + ); + const hasPendingToolsSpy = vi + .spyOn(wrapper.task, 'hasPendingTools', 'get') + .mockReturnValue(true); + vi.spyOn(wrapper.task, 'pendingToolsCount', 'get').mockReturnValue(1); + + const requestContext = { + userMessage: { + messageId: 'msg-1', + taskId, + contextId, + parts: [{ kind: 'confirmation', callId: '1', outcome: 'proceed' }], + metadata: { + coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' }, + }, + }, + } as unknown as RequestContext; + + await executor.execute(requestContext, mockEventBus); + + // Assert that the executor yielded the turn correctly without further progression + expect(hasPendingToolsSpy).toHaveBeenCalled(); + expect(wrapper.task.getAndClearCompletedTools).not.toHaveBeenCalled(); + expect(wrapper.task.sendCompletedToolsToLlm).not.toHaveBeenCalled(); + expect(wrapper.task.setTaskStateAndPublishUpdate).toHaveBeenCalledWith( + 'input-required', + expect.any(Object), + undefined, + undefined, + true, + ); + }); + + it('cancelTask should abort the active execution loop', async () => { + const abortSpy = vi.spyOn(AbortController.prototype, 'abort'); + const taskId = 'test-task-to-cancel'; + const contextId = 'test-context'; + + const mockSocket = new EventEmitter(); + (requestStorage.getStore as Mock).mockReturnValue({ + req: { socket: mockSocket }, + }); + + const requestContext = { + userMessage: { + messageId: 'msg-1', + taskId, + contextId, + parts: [{ kind: 'text', text: 'a long running prompt' }], + metadata: { + coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' }, + }, + }, + } as unknown as RequestContext; + + // Don't await this, let it run in the background. + let primaryError: Error | null = null; + const primaryPromise = executor.execute(requestContext, mockEventBus); + primaryPromise.catch((err) => { + primaryError = err as Error; + }); + + // Poll until the task is registered in the executor to avoid flaky timeouts in slow CI environments. + let attempts = 0; + while (!executor.getTask(taskId)) { + if (primaryError) { + throw new Error(`Primary execution failed early: ${primaryError}`); + } + if (attempts++ > 100) { + // 100 * 5ms = 500ms timeout + throw new Error('Timed out waiting for task to be registered'); + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + const wrapper = executor.getTask(taskId); + expect(wrapper).toBeDefined(); + const setTaskStateSpy = vi + .spyOn(wrapper!.task, 'setTaskStateAndPublishUpdate') + .mockImplementation((newState) => { + // Make the mock realistic: actually update the state when called. + wrapper!.task.taskState = newState; + }); + + // Now, cancel the task. + await executor.cancelTask(taskId, mockEventBus); + + // Verify that the abort method on the controller was called and state was updated. + expect(abortSpy).toHaveBeenCalledOnce(); + expect(setTaskStateSpy).toHaveBeenCalledWith( + 'canceled', + expect.any(Object), + 'Task canceled by user request.', + undefined, + true, + ); + + // Clean up the test by allowing the promise to resolve. + // The abort call should have unblocked the acceptUserMessage generator. + await primaryPromise; + + // Verify task is evicted from cache + expect(executor.getTask(taskId)).toBeUndefined(); + + abortSpy.mockRestore(); + }); + + it('cancelTask should explicitly save task state to TaskStore and evict task during active aborts', async () => { + const taskId = 'test-task-active-abort-save'; + const contextId = 'test-context'; + + const mockSocket = new EventEmitter(); + (requestStorage.getStore as Mock).mockReturnValue({ + req: { socket: mockSocket }, + }); + + const requestContext = { + userMessage: { + messageId: 'msg-1', + taskId, + contextId, + parts: [{ kind: 'text', text: 'a long running prompt' }], + metadata: { + coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' }, + }, + }, + } as unknown as RequestContext; + + const primaryPromise = executor.execute(requestContext, mockEventBus); + + // Wait for task to be registered + let attempts = 0; + while (!executor.getTask(taskId)) { + if (attempts++ > 100) { + throw new Error('Timed out waiting for task to be registered'); + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + const wrapper = executor.getTask(taskId)!; + const saveSpy = vi.spyOn(mockTaskStore, 'save'); + + // Now, cancel the task. + await executor.cancelTask(taskId, mockEventBus); + + // Verify that the task state was saved to TaskStore during cancelTask + expect(saveSpy).toHaveBeenCalled(); + expect(wrapper.task.dispose).toHaveBeenCalled(); + expect(executor.getTask(taskId)).toBeUndefined(); + + // Clean up the test by allowing the promise to resolve. + await primaryPromise; + }); + + it('should allow executing a task that is in a terminal state by re-activating it', async () => { + const taskId = 'test-task-terminal-reactivate'; + const contextId = 'test-context'; + + const mockSocket = new EventEmitter(); + (requestStorage.getStore as Mock).mockReturnValue({ + req: { socket: mockSocket }, + }); + + const requestContext = { + userMessage: { + messageId: 'msg-1', + taskId, + contextId, + parts: [{ kind: 'text', text: 'hi' }], + metadata: { + coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' }, + }, + }, + } as unknown as RequestContext; + + // Create a task wrapper and set it to a terminal state in cache + const wrapper = await executor.createTask( + taskId, + contextId, + undefined, + mockEventBus, + ); + wrapper.task.taskState = 'canceled'; + + // Now execute it again + const primaryPromise = executor.execute(requestContext, mockEventBus); + + // Wait for task to be re-activated in the async execute loop + await new Promise((resolve) => setTimeout(resolve, 10)); + + // Verify task was re-activated to 'submitted' / 'working' and executed instead of being ignored + const runningWrapper = executor.getTask(taskId); + expect(runningWrapper).toBeDefined(); + expect(runningWrapper!.task.taskState).not.toBe('canceled'); + + // Clean up the test execution loop + mockSocket.emit('end'); + await primaryPromise; + }); + + it('should not evict or treat a new request as secondary when preceding request is canceled and winding down', async () => { + const taskId = 'test-repro-race-condition'; + const contextId = 'test-context'; + + // Simulating real-world TaskStore save delay / I/O latency + vi.spyOn(mockTaskStore, 'save').mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 50)); + }); + + const mockSocket1 = new EventEmitter(); + (requestStorage.getStore as Mock).mockReturnValue({ + req: { socket: mockSocket1 }, + }); + + const requestContext1 = { + userMessage: { + messageId: 'msg-1', + taskId, + contextId, + parts: [{ kind: 'text', text: 'prompt 1' }], + metadata: { + coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' }, + }, + }, + } as unknown as RequestContext; + + const primaryPromise = executor.execute(requestContext1, mockEventBus); + + // Wait for the task to be registered + let attempts = 0; + while (!executor.getTask(taskId)) { + if (attempts++ > 100) { + throw new Error('Timed out waiting for task to be registered'); + } + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + const wrapper1 = executor.getTask(taskId)!; + expect(wrapper1).toBeDefined(); + + // Mock setTaskStateAndPublishUpdate realistically to trigger the buggy eviction behavior + vi.spyOn(wrapper1.task, 'setTaskStateAndPublishUpdate').mockImplementation( + (newState) => { + wrapper1.task.taskState = newState; + }, + ); + + // Cancel the task (Request 1) + await executor.cancelTask(taskId, mockEventBus); + + // Now immediately start Request 2 with the same taskId + const mockSocket2 = new EventEmitter(); + (requestStorage.getStore as Mock).mockReturnValue({ + req: { socket: mockSocket2 }, + }); + + const requestContext2 = { + userMessage: { + messageId: 'msg-2', + taskId, + contextId, + parts: [{ kind: 'text', text: 'prompt 2' }], + metadata: { + coderAgent: { kind: 'agent-settings', workspacePath: '/tmp' }, + }, + }, + } as unknown as RequestContext; + + const secondaryPromise = executor.execute(requestContext2, mockEventBus); + + let secondaryResolved = false; + void secondaryPromise + .then(() => { + secondaryResolved = true; + }) + .catch(() => {}); + + // Give it a moment to initialize Request 2's task in execute loop + await new Promise((resolve) => setTimeout(resolve, 20)); + + // Request 2 should NOT have resolved prematurely (it should be running as a primary loop, not secondary) + expect(secondaryResolved).toBe(false); + + // Await primaryPromise to let Request 1 completely wind down + await primaryPromise; + + // Verify task 2 was NOT evicted/destroyed + const wrapper2 = executor.getTask(taskId); + expect(wrapper2).toBeDefined(); + expect(wrapper2).not.toBe(wrapper1); + + // Clean up Request 2 + mockSocket2.emit('end'); + await secondaryPromise; + }); +}); diff --git a/packages/a2a-server/src/agent/executor.ts b/packages/a2a-server/src/agent/executor.ts new file mode 100644 index 0000000000000000000000000000000000000000..b300a5bb912eb2870233efccc173fad95d711354 --- /dev/null +++ b/packages/a2a-server/src/agent/executor.ts @@ -0,0 +1,939 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Task as SDKTask } from '@a2a-js/sdk'; +import type { + TaskStore, + AgentExecutor, + AgentExecutionEvent, + RequestContext, + ExecutionEventBus, +} from '@a2a-js/sdk/server'; +import { + GeminiEventType, + SimpleExtensionLoader, + type ToolCallRequestInfo, + type Config, + resolveToRealPath, +} from '@google/gemini-cli-core'; +import { v4 as uuidv4 } from 'uuid'; + +import { logger } from '../utils/logger.js'; +import { + CoderAgentEvent, + getPersistedState, + setPersistedState, + type StateChange, + type AgentSettings, + type PersistedStateMetadata, + getContextIdFromMetadata, + getAgentSettingsFromMetadata, +} from '../types.js'; +import { + loadConfig, + setTargetDir, + setIsTrusted, + envStorage, + cwdSymbol, + loadEnvironment, + type TaskEnv, +} from '../config/config.js'; +import { loadSettings } from '../config/settings.js'; +import { loadExtensions } from '../config/extension.js'; +import { Task } from './task.js'; +import { requestStorage } from '../http/requestStorage.js'; +import { pushTaskStateFailed } from '../utils/executor_utils.js'; + +function validateWorkspacePath(workspacePath?: string): string { + const trimmed = workspacePath?.trim(); + if (!trimmed) { + return process.env['CODER_AGENT_WORKSPACE_PATH'] ?? process.cwd(); + } + return resolveToRealPath(trimmed); +} + +/** + * Provides a wrapper for Task. Passes data from Task to SDKTask. + * The idea is to use this class inside CoderAgentExecutor to replace Task. + */ +class TaskWrapper { + task: Task; + agentSettings: AgentSettings; + + constructor(task: Task, agentSettings: AgentSettings) { + this.task = task; + this.agentSettings = agentSettings; + } + + get id() { + return this.task.id; + } + + toSDKTask(): SDKTask { + const persistedState: PersistedStateMetadata = { + _agentSettings: this.agentSettings, + _taskState: this.task.taskState, + }; + + const sdkTask: SDKTask = { + id: this.task.id, + contextId: this.task.contextId, + kind: 'task', + status: { + state: this.task.taskState, + timestamp: new Date().toISOString(), + }, + metadata: setPersistedState({}, persistedState), + history: [], + artifacts: [], + }; + sdkTask.metadata!['_contextId'] = this.task.contextId; + return sdkTask; + } +} + +/** + * CoderAgentExecutor implements the agent's core logic for code generation. + */ +export class CoderAgentExecutor implements AgentExecutor { + private tasks: Map = new Map(); + // Track tasks with an active execution loop. + private executingTasks = new Map(); + private activeAbortControllers = new Map>(); + // Track tasks currently initializing to prevent race conditions. + private initializingTasks = new Set(); + private initializationPromises = new Map>(); + // Track explicitly canceled abort controllers to handle cancellation during initialization. + private explicitlyCanceledTasks = new Set(); + + constructor(private taskStore?: TaskStore) {} + + private async getConfigWithEnv( + agentSettings: AgentSettings, + taskId: string, + isTrusted: boolean, + workspaceRoot: string, + ): Promise { + const settings = loadSettings(workspaceRoot, isTrusted); + const extensions = loadExtensions(workspaceRoot, isTrusted); + return loadConfig( + settings, + new SimpleExtensionLoader(extensions), + taskId, + isTrusted, + workspaceRoot, + ); + } + + private async runInIsolatedEnv( + agentSettings: AgentSettings, + fn: (isTrusted: boolean, workspaceRoot: string) => Promise, + ): Promise { + const workspaceRoot = await setTargetDir(agentSettings); + const isTrusted = setIsTrusted(agentSettings, workspaceRoot); + + const envVars: TaskEnv = { ...process.env }; + envVars['GEMINI_CLI_TRUST_WORKSPACE'] = isTrusted ? 'true' : 'false'; + envVars[cwdSymbol] = workspaceRoot; + + return envStorage.run(envVars, async () => { + const loadedEnv = await loadEnvironment(isTrusted, workspaceRoot); + Object.assign(envVars, loadedEnv); + return fn(isTrusted, workspaceRoot); + }); + } + + private cleanupAndEvictTask(taskId: string, expectedWrapper?: TaskWrapper) { + const wrapper = this.tasks.get(taskId); + if (wrapper) { + if (expectedWrapper && wrapper !== expectedWrapper) { + logger.info( + `[CoderAgentExecutor] cleanupAndEvictTask: wrapper mismatch for task ${taskId}. Skipping eviction.`, + ); + return; + } + wrapper.task.dispose(); + this.tasks.delete(taskId); + } + } + + /** + * Reconstructs TaskWrapper from SDKTask. + */ + async reconstruct( + sdkTask: SDKTask, + eventBus?: ExecutionEventBus, + ): Promise { + const metadata = sdkTask.metadata || {}; + const persistedState = getPersistedState(metadata); + + if (!persistedState) { + throw new Error( + `Cannot reconstruct task ${sdkTask.id}: missing persisted state in metadata.`, + ); + } + + let agentSettings: AgentSettings; + try { + agentSettings = { + ...(persistedState._agentSettings ?? {}), + workspacePath: validateWorkspacePath( + persistedState._agentSettings?.workspacePath, + ), + isTrusted: false, + }; + } catch (error) { + logger.error( + `[CoderAgentExecutor] Invalid workspace path in persisted state for task ${sdkTask.id}:`, + error, + ); + if (eventBus) { + void pushTaskStateFailed( + error, + eventBus, + sdkTask.id, + sdkTask.contextId, + ); + } + throw error; // Re-throw to be caught by caller + } + + return this.runInIsolatedEnv( + agentSettings, + async (isTrusted, workspaceRoot) => { + const config = await this.getConfigWithEnv( + agentSettings, + sdkTask.id, + isTrusted, + workspaceRoot, + ); + const contextId: string = + getContextIdFromMetadata(metadata) || sdkTask.contextId; + const runtimeTask = await Task.create( + sdkTask.id, + contextId, + config, + eventBus, + agentSettings.autoExecute, + ); + runtimeTask.taskState = persistedState._taskState; + await runtimeTask.geminiClient.initialize(); + + const wrapper = new TaskWrapper(runtimeTask, agentSettings); + this.tasks.set(sdkTask.id, wrapper); + logger.info(`Task ${sdkTask.id} reconstructed from store.`); + return wrapper; + }, + ); + } + + async createTask( + taskId: string, + contextId: string, + agentSettingsInput?: AgentSettings, + eventBus?: ExecutionEventBus, + ): Promise { + const agentSettings: AgentSettings = agentSettingsInput || { + kind: CoderAgentEvent.StateAgentSettingsEvent, + workspacePath: process.cwd(), + }; + return this.runInIsolatedEnv( + agentSettings, + async (isTrusted, workspaceRoot) => { + const config = await this.getConfigWithEnv( + agentSettings, + taskId, + isTrusted, + workspaceRoot, + ); + const runtimeTask = await Task.create( + taskId, + contextId, + config, + eventBus, + agentSettings.autoExecute, + ); + await runtimeTask.geminiClient.initialize(); + + const wrapper = new TaskWrapper(runtimeTask, agentSettings); + this.tasks.set(taskId, wrapper); + logger.info(`New task ${taskId} created.`); + return wrapper; + }, + ); + } + + getTask(taskId: string): TaskWrapper | undefined { + return this.tasks.get(taskId); + } + + getAllTasks(): TaskWrapper[] { + return Array.from(this.tasks.values()); + } + + cancelTask = async ( + taskId: string, + eventBus: ExecutionEventBus, + ): Promise => { + logger.info( + `[CoderAgentExecutor] Received cancel request for task ${taskId}`, + ); + + const abortControllers = this.activeAbortControllers.get(taskId); + if (abortControllers && abortControllers.size > 0) { + logger.info( + `[CoderAgentExecutor] Aborting ${abortControllers.size} active execution loop(s) for task ${taskId}.`, + ); + // Abort first to ensure loops are stopped. + for (const controller of Array.from(abortControllers)) { + this.explicitlyCanceledTasks.add(controller); + controller.abort(); + } + + // Then, attempt to update state and persist. + const wrapper = this.tasks.get(taskId); + if (wrapper) { + const { task } = wrapper; + task.cancelPendingTools('Task canceled by user request.'); + task.setTaskStateAndPublishUpdate( + 'canceled', + { kind: CoderAgentEvent.StateChangeEvent }, + 'Task canceled by user request.', + undefined, + true, + ); + this.cleanupAndEvictTask(taskId, wrapper); + try { + await this.taskStore?.save(wrapper.toSDKTask()); + logger.info( + `[CoderAgentExecutor] Task ${taskId} state CANCELED saved during active abort.`, + ); + } catch (saveError) { + logger.error( + `[CoderAgentExecutor] Failed to save task ${taskId} state during active abort:`, + saveError, + ); + } + } + return; + } + + // If there is no active execution loop, the task is idle. + // We can clean it up directly. + logger.info( + `[CoderAgentExecutor] No active execution for task ${taskId}. Cleaning up directly.`, + ); + const wrapper = this.tasks.get(taskId); + + if (!wrapper) { + logger.warn( + `[CoderAgentExecutor] Task ${taskId} not found for cancellation.`, + ); + eventBus.publish({ + kind: 'status-update', + taskId, + contextId: uuidv4(), + status: { + state: 'failed', + message: { + kind: 'message', + role: 'agent', + parts: [{ kind: 'text', text: `Task ${taskId} not found.` }], + messageId: uuidv4(), + taskId, + }, + }, + final: true, + }); + return; + } + + const { task } = wrapper; + + if (task.taskState === 'canceled' || task.taskState === 'failed') { + logger.info( + `[CoderAgentExecutor] Task ${taskId} is already in a final state: ${task.taskState}. No action needed for cancellation.`, + ); + eventBus.publish({ + kind: 'status-update', + taskId, + contextId: task.contextId, + status: { + state: task.taskState, + message: { + kind: 'message', + role: 'agent', + parts: [ + { + kind: 'text', + text: `Task ${taskId} is already ${task.taskState}.`, + }, + ], + messageId: uuidv4(), + taskId, + }, + }, + final: true, + }); + return; + } + + try { + logger.info( + `[CoderAgentExecutor] Initiating cancellation for task ${taskId}.`, + ); + task.cancelPendingTools('Task canceled by user request.'); + + const stateChange: StateChange = { + kind: CoderAgentEvent.StateChangeEvent, + }; + task.setTaskStateAndPublishUpdate( + 'canceled', + stateChange, + 'Task canceled by user request.', + undefined, + true, + ); + logger.info( + `[CoderAgentExecutor] Task ${taskId} cancellation processed. Saving state.`, + ); + // Cleanup listener subscriptions and evict synchronously before async save + this.cleanupAndEvictTask(taskId, wrapper); + + await this.taskStore?.save(wrapper.toSDKTask()); + logger.info(`[CoderAgentExecutor] Task ${taskId} state CANCELED saved.`); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + logger.error( + `[CoderAgentExecutor] Error during task cancellation for ${taskId}: ${errorMessage}`, + error, + ); + eventBus.publish({ + kind: 'status-update', + taskId, + contextId: task.contextId, + status: { + state: 'failed', + message: { + kind: 'message', + role: 'agent', + parts: [ + { + kind: 'text', + text: `Failed to process cancellation for task ${taskId}: ${errorMessage}`, + }, + ], + messageId: uuidv4(), + taskId, + }, + }, + final: true, + }); + } + }; + + async execute( + requestContext: RequestContext, + eventBus: ExecutionEventBus, + ): Promise { + const userMessage = requestContext.userMessage; + const sdkTask = requestContext.task; + + const taskId = sdkTask?.id || userMessage.taskId || uuidv4(); + const contextId: string = + userMessage.contextId || + sdkTask?.contextId || + getContextIdFromMetadata(sdkTask?.metadata) || + uuidv4(); + + let agentSettings: AgentSettings; + try { + const existingWrapper = this.tasks.get(taskId); + if (existingWrapper) { + agentSettings = existingWrapper.agentSettings; + } else if (sdkTask) { + const persistedState = getPersistedState(sdkTask.metadata || {}); + agentSettings = { + kind: CoderAgentEvent.StateAgentSettingsEvent, + ...(persistedState?._agentSettings ?? {}), + workspacePath: validateWorkspacePath( + persistedState?._agentSettings?.workspacePath, + ), + isTrusted: false, + }; + } else { + const rawAgentSettings = getAgentSettingsFromMetadata( + userMessage.metadata, + ); + agentSettings = { + kind: CoderAgentEvent.StateAgentSettingsEvent, + ...(rawAgentSettings || {}), + workspacePath: validateWorkspacePath(rawAgentSettings?.workspacePath), + isTrusted: false, + }; + } + } catch (error) { + logger.error( + `[CoderAgentExecutor] Invalid workspace path during execution setup for task ${taskId}:`, + error, + ); + if (eventBus) { + void pushTaskStateFailed(error, eventBus, taskId, contextId); + } + throw error; + } + + return this.runInIsolatedEnv(agentSettings, async () => { + try { + logger.info( + `[CoderAgentExecutor] Executing for taskId: ${taskId}, contextId: ${contextId}`, + ); + logger.info( + `[CoderAgentExecutor] userMessage: ${JSON.stringify(userMessage)}`, + ); + eventBus.on('event', (event: AgentExecutionEvent) => + logger.info('[EventBus event]: ', event), + ); + + const store = requestStorage.getStore(); + if (!store) { + logger.error( + '[CoderAgentExecutor] Could not get request from async local storage. Cancellation on socket close will not be handled for this request.', + ); + } + + const abortController = new AbortController(); + const abortSignal = abortController.signal; + + if (!this.activeAbortControllers.has(taskId)) { + this.activeAbortControllers.set(taskId, new Set()); + } + this.activeAbortControllers.get(taskId)!.add(abortController); + + let proceedToMainLoop = false; + let wrapper: TaskWrapper | undefined; + let isPrimaryExecution = false; + + try { + if (store) { + const socket = store.req.socket; + const onSocketEnd = () => { + logger.info( + `[CoderAgentExecutor] Socket ended for message ${userMessage.messageId} (task ${taskId}). Aborting execution loop.`, + ); + if (!abortController.signal.aborted) { + abortController.abort(); + } + socket.removeListener('end', onSocketEnd); + }; + socket.on('end', onSocketEnd); + socket.once('close', () => + socket.removeListener('end', onSocketEnd), + ); + abortSignal.addEventListener('abort', () => + socket.removeListener('end', onSocketEnd), + ); + logger.info( + `[CoderAgentExecutor] Socket close handler set up for task ${taskId}.`, + ); + } + + // Check if the task is currently initializing + if (this.initializingTasks.has(taskId)) { + logger.info( + `[CoderAgentExecutor] Task ${taskId} is currently initializing. Waiting for initialization to complete.`, + ); + const initPromise = this.initializationPromises.get(taskId); + if (initPromise) { + try { + wrapper = await initPromise; + } catch { + logger.error( + `[CoderAgentExecutor] Failed to wait for task ${taskId} initialization.`, + ); + return; + } + } + } + + if (!wrapper) { + this.initializingTasks.add(taskId); + const initPromise = (async () => { + let initializedWrapper: TaskWrapper | undefined; + initializedWrapper = this.tasks.get(taskId); + + if (initializedWrapper) { + initializedWrapper.task.eventBus = eventBus; + logger.info( + `[CoderAgentExecutor] Task ${taskId} found in memory cache.`, + ); + } else if (sdkTask) { + logger.info( + `[CoderAgentExecutor] Task ${taskId} found in TaskStore. Reconstructing...`, + ); + try { + initializedWrapper = await this.reconstruct( + sdkTask, + eventBus, + ); + } catch (e) { + logger.error( + `[CoderAgentExecutor] Aborting execution due to failed task reconstruction for task ${taskId}:`, + e, + ); + throw e; + } + } else { + initializedWrapper = await this.createTask( + taskId, + contextId, + agentSettings, + eventBus, + ); + const newTaskSDK = initializedWrapper.toSDKTask(); + eventBus.publish({ + ...newTaskSDK, + kind: 'task', + status: { + state: 'submitted', + timestamp: new Date().toISOString(), + }, + history: [userMessage], + }); + try { + await this.taskStore?.save(newTaskSDK); + logger.info( + `[CoderAgentExecutor] New task ${taskId} saved to store.`, + ); + } catch (saveError) { + logger.error( + `[CoderAgentExecutor] Failed to save new task ${taskId} to store:`, + saveError, + ); + } + } + return initializedWrapper; + })(); + + this.initializationPromises.set(taskId, initPromise); + + try { + wrapper = await initPromise; + } catch { + // Error is already handled/logged inside the promise + return; + } finally { + this.initializingTasks.delete(taskId); + this.initializationPromises.delete(taskId); + } + } + + if (!wrapper) { + logger.error( + `[CoderAgentExecutor] Task ${taskId} is unexpectedly undefined after load/create.`, + ); + return; + } + + const currentTask = wrapper.task; + + if ( + ['canceled', 'failed', 'completed'].includes(currentTask.taskState) + ) { + logger.info( + `[CoderAgentExecutor] Re-activating task ${taskId} from terminal state ${currentTask.taskState}.`, + ); + currentTask.taskState = 'submitted'; + } + + if (abortSignal.aborted) { + logger.warn( + `[CoderAgentExecutor] Task ${taskId} was aborted during initialization.`, + ); + const isExplicitCancel = + this.explicitlyCanceledTasks.has(abortController); + const finalState = isExplicitCancel ? 'canceled' : 'input-required'; + const message = isExplicitCancel + ? 'Task canceled by user request.' + : 'Execution aborted by client.'; + currentTask.setTaskStateAndPublishUpdate( + finalState, + { kind: CoderAgentEvent.StateChangeEvent }, + message, + undefined, + true, + ); + try { + await this.taskStore?.save(wrapper.toSDKTask()); + } catch (saveError) { + logger.error( + `[CoderAgentExecutor] Failed to save task ${taskId} state:`, + saveError, + ); + } + if (isExplicitCancel) { + this.cleanupAndEvictTask(taskId, wrapper); + } + return; + } + + const activeController = this.executingTasks.get(taskId); + if (activeController && !activeController.signal.aborted) { + logger.info( + `[CoderAgentExecutor] Task ${taskId} has a pending execution. Processing message and yielding.`, + ); + currentTask.eventBus = eventBus; + try { + for await (const _ of currentTask.acceptUserMessage( + requestContext, + abortController.signal, + )) { + logger.info( + `[CoderAgentExecutor] Processing user message ${userMessage.messageId} in secondary execution loop for task ${taskId}.`, + ); + } + } catch (error) { + if (!abortController.signal.aborted) { + throw error; + } + logger.info( + `[CoderAgentExecutor] Secondary execution loop for task ${taskId} was aborted.`, + ); + } + return; + } + + isPrimaryExecution = true; + + proceedToMainLoop = true; + } finally { + this.explicitlyCanceledTasks.delete(abortController); + if (!proceedToMainLoop) { + const controllers = this.activeAbortControllers.get(taskId); + if (controllers) { + controllers.delete(abortController); + if (controllers.size === 0) { + this.activeAbortControllers.delete(taskId); + } + } + } + } + + const currentTask = wrapper.task; + try { + logger.info( + `[CoderAgentExecutor] Starting main execution for message ${userMessage.messageId} for task ${taskId}.`, + ); + this.executingTasks.set(taskId, abortController); + + let agentTurnActive = true; + logger.info( + `[CoderAgentExecutor] Task ${taskId}: Processing user turn.`, + ); + let agentEvents = currentTask.acceptUserMessage( + requestContext, + abortSignal, + ); + + while (agentTurnActive) { + if (abortSignal.aborted) { + logger.info( + `[CoderAgentExecutor] Task ${taskId} aborted before turn. Exiting loop.`, + ); + throw new Error('Execution aborted'); + } + logger.info( + `[CoderAgentExecutor] Task ${taskId}: Processing agent turn (LLM stream).`, + ); + const toolCallRequests: ToolCallRequestInfo[] = []; + for await (const event of agentEvents) { + if (abortSignal.aborted) { + logger.warn( + `[CoderAgentExecutor] Task ${taskId}: Abort signal received during agent event processing.`, + ); + throw new Error('Execution aborted'); + } + if (event.type === GeminiEventType.ToolCallRequest) { + toolCallRequests.push(event.value); + continue; + } + await currentTask.acceptAgentMessage(event); + } + + if (abortSignal.aborted) throw new Error('Execution aborted'); + + if (toolCallRequests.length > 0) { + logger.info( + `[CoderAgentExecutor] Task ${taskId}: Found ${toolCallRequests.length} tool call requests. Scheduling as a batch.`, + ); + await currentTask.scheduleToolCalls( + toolCallRequests, + abortSignal, + ); + } + + logger.info( + `[CoderAgentExecutor] Task ${taskId}: Waiting for pending tools if any.`, + ); + await currentTask.waitForPendingTools(); + logger.info( + `[CoderAgentExecutor] Task ${taskId}: All pending tools completed or none were pending.`, + ); + + if (abortSignal.aborted) throw new Error('Execution aborted'); + + if (currentTask.hasPendingTools) { + logger.info( + `[CoderAgentExecutor] Task ${taskId}: There are still ${currentTask.pendingToolsCount} pending tools waiting for approval. Yielding to user.`, + ); + agentTurnActive = false; + } else { + const completedTools = currentTask.getAndClearCompletedTools(); + + if (completedTools.length > 0) { + if ( + completedTools.every((tool) => tool.status === 'cancelled') + ) { + logger.info( + `[CoderAgentExecutor] Task ${taskId}: All tool calls were cancelled. Updating history and ending agent turn.`, + ); + currentTask.addToolResponsesToHistory(completedTools); + agentTurnActive = false; + const stateChange: StateChange = { + kind: CoderAgentEvent.StateChangeEvent, + }; + currentTask.setTaskStateAndPublishUpdate( + 'input-required', + stateChange, + undefined, + undefined, + true, + ); + } else { + logger.info( + `[CoderAgentExecutor] Task ${taskId}: Found ${completedTools.length} completed tool calls. Sending results back to LLM.`, + ); + + agentEvents = currentTask.sendCompletedToolsToLlm( + completedTools, + abortSignal, + ); + } + } else { + logger.info( + `[CoderAgentExecutor] Task ${taskId}: No more tool calls to process. Ending agent turn.`, + ); + agentTurnActive = false; + } + } + } + + logger.info( + `[CoderAgentExecutor] Task ${taskId}: Agent turn finished, setting to input-required.`, + ); + const stateChange: StateChange = { + kind: CoderAgentEvent.StateChangeEvent, + }; + currentTask.setTaskStateAndPublishUpdate( + 'input-required', + stateChange, + undefined, + undefined, + true, + ); + } catch (error) { + if (abortSignal.aborted) { + logger.warn( + `[CoderAgentExecutor] Task ${taskId} execution aborted.`, + ); + currentTask.cancelPendingTools('Execution aborted'); + if ( + currentTask.taskState !== 'canceled' && + currentTask.taskState !== 'failed' + ) { + currentTask.setTaskStateAndPublishUpdate( + 'input-required', + { kind: CoderAgentEvent.StateChangeEvent }, + 'Execution aborted by client.', + undefined, + true, + ); + } + } else { + const errorMessage = + error instanceof Error ? error.message : 'Agent execution error'; + logger.error( + `[CoderAgentExecutor] Error executing agent for task ${taskId}:`, + error, + ); + currentTask.cancelPendingTools(errorMessage); + if (currentTask.taskState !== 'failed') { + const stateChange: StateChange = { + kind: CoderAgentEvent.StateChangeEvent, + }; + currentTask.setTaskStateAndPublishUpdate( + 'failed', + stateChange, + errorMessage, + undefined, + true, + ); + } + } + } finally { + if (isPrimaryExecution) { + const controllers = this.activeAbortControllers.get(taskId); + if (controllers) { + controllers.delete(abortController); + if (controllers.size === 0) { + this.activeAbortControllers.delete(taskId); + } + } + if (this.executingTasks.get(taskId) === abortController) { + this.executingTasks.delete(taskId); + } + if ( + ['canceled', 'failed', 'completed'].includes( + currentTask.taskState, + ) + ) { + this.cleanupAndEvictTask(taskId, wrapper); + } + logger.info( + `[CoderAgentExecutor] Saving final state for task ${taskId}.`, + ); + try { + await this.taskStore?.save(wrapper.toSDKTask()); + logger.info(`[CoderAgentExecutor] Task ${taskId} state saved.`); + } catch (saveError) { + logger.error( + `[CoderAgentExecutor] Failed to save task ${taskId} state in finally block:`, + saveError, + ); + } + } + } + } catch (error) { + logger.error( + `[CoderAgentExecutor] Failed to execute task ${taskId}:`, + error, + ); + void pushTaskStateFailed(error, eventBus, taskId, contextId); + throw error; + } + }).catch((error) => { + logger.error( + `[CoderAgentExecutor] Failed to initialize isolated environment for task ${taskId}:`, + error, + ); + if (eventBus) { + void pushTaskStateFailed(error, eventBus, taskId, contextId); + } + throw error; + }); + } +} diff --git a/packages/a2a-server/src/agent/race-condition.test.ts b/packages/a2a-server/src/agent/race-condition.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3906c43a68a5ab79a6987799a0eedf8646f4946a --- /dev/null +++ b/packages/a2a-server/src/agent/race-condition.test.ts @@ -0,0 +1,173 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { Task } from './task.js'; +import { + MessageBusType, + CoreToolCallStatus, + type Config, + type MessageBus, +} from '@google/gemini-cli-core'; +import { createMockConfig } from '../utils/testing_utils.js'; +import type { RequestContext } from '@a2a-js/sdk/server'; + +describe('Task Race Condition', () => { + let mockConfig: Config; + let messageBus: MessageBus; + + beforeEach(() => { + messageBus = { + subscribe: vi.fn(), + unsubscribe: vi.fn(), + publish: vi.fn(), + } as unknown as MessageBus; + mockConfig = createMockConfig({ + messageBus, + }) as Config; + }); + + it('should not hang when multiple tool confirmations are processed while waiting', async () => { + // @ts-expect-error - private constructor + const task = new Task('task-id', 'context-id', mockConfig); + + // 1. Register two tools as scheduled + task['_registerToolCall']('tool-1', 'scheduled'); + task['_registerToolCall']('tool-2', 'scheduled'); + + // 2. Both transition to awaiting_approval + const updateHandler = (messageBus.subscribe as Mock).mock.calls.find( + (c: unknown[]) => c[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + + updateHandler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + schedulerId: 'task-id', + toolCalls: [ + { + request: { callId: 'tool-1', name: 't1' }, + status: CoreToolCallStatus.AwaitingApproval, + correlationId: 'corr-1', + confirmationDetails: { type: 'info' }, + }, + { + request: { callId: 'tool-2', name: 't2' }, + status: CoreToolCallStatus.AwaitingApproval, + correlationId: 'corr-2', + confirmationDetails: { type: 'info' }, + }, + ], + }); + + // 3. Confirm Tool 1. This makes isAwaitingApprovalOnly() return false. + for await (const _ of task.acceptUserMessage( + { + userMessage: { + parts: [ + { + kind: 'data', + data: { callId: 'tool-1', outcome: 'proceed_once' }, + }, + ], + }, + } as unknown as RequestContext, + new AbortController().signal, + )) { + // consume generator + } + + // 4. Start waiting. This should now block because Tool 1 is confirmed (so we are waiting for its execution). + const waitPromise = task.waitForPendingTools(); + + // 5. Confirm Tool 2 while waiting. + for await (const _ of task.acceptUserMessage( + { + userMessage: { + parts: [ + { + kind: 'data', + data: { callId: 'tool-2', outcome: 'proceed_once' }, + }, + ], + }, + } as unknown as RequestContext, + new AbortController().signal, + )) { + // consume generator + } + + // 6. Both tools complete successfully + updateHandler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + schedulerId: 'task-id', + toolCalls: [ + { + request: { callId: 'tool-1', name: 't1' }, + status: CoreToolCallStatus.Success, + response: { responseParts: [] }, + }, + { + request: { callId: 'tool-2', name: 't2' }, + status: CoreToolCallStatus.Success, + response: { responseParts: [] }, + }, + ], + }); + + // 7. Verify that the original waitPromise resolves. + await expect(waitPromise).resolves.toBeUndefined(); + }); + + it('should reject waitForPendingTools when tools are cancelled', async () => { + // @ts-expect-error - private constructor + const task = new Task('task-id', 'context-id', mockConfig); + + // 1. Register a tool + task['_registerToolCall']('tool-1', 'scheduled'); + + // 2. Start waiting + const waitPromise = task.waitForPendingTools(); + + // 3. Cancel pending tools + task.cancelPendingTools('User requested cancellation'); + + // 4. Verify waitPromise rejects with the reason + await expect(waitPromise).rejects.toThrow('User requested cancellation'); + }); + + it('should handle concurrent tool scheduling correctly', async () => { + // @ts-expect-error - private constructor + const task = new Task('task-id', 'context-id', mockConfig); + + // 1. Register a tool and start waiting + task['_registerToolCall']('tool-1', 'scheduled'); + const waitPromise = task.waitForPendingTools(); + + // 2. Schedule another tool concurrently (e.g. from a secondary user message) + // This should NOT resolve the current waitPromise until both are done + await task.scheduleToolCalls( + [{ callId: 'tool-2', name: 't2', args: {} }], + new AbortController().signal, + ); + + expect(task['pendingToolCalls'].size).toBe(2); + + // 3. Resolve tool 1 + task['_resolveToolCall']('tool-1'); + + // 4. Verify waitPromise is still pending + let resolved = false; + waitPromise.then(() => (resolved = true)); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(resolved).toBe(false); + + // 5. Resolve tool 2 + task['_resolveToolCall']('tool-2'); + + // 6. Now it should resolve + await expect(waitPromise).resolves.toBeUndefined(); + }); +}); diff --git a/packages/a2a-server/src/agent/task-event-driven.test.ts b/packages/a2a-server/src/agent/task-event-driven.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a67a2bee139228c6eaf69071b93b42c377dfbc19 --- /dev/null +++ b/packages/a2a-server/src/agent/task-event-driven.test.ts @@ -0,0 +1,762 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { Task } from './task.js'; +import { + type Config, + MessageBusType, + ToolConfirmationOutcome, + ApprovalMode, + Scheduler, + type MessageBus, + type ToolLiveOutput, +} from '@google/gemini-cli-core'; +import { createMockConfig } from '../utils/testing_utils.js'; +import type { ExecutionEventBus } from '@a2a-js/sdk/server'; + +describe('Task Event-Driven Scheduler', () => { + let mockConfig: Config; + let mockEventBus: ExecutionEventBus; + let messageBus: MessageBus; + + beforeEach(() => { + vi.clearAllMocks(); + mockConfig = createMockConfig({ + isEventDrivenSchedulerEnabled: () => true, + }) as Config; + messageBus = mockConfig.messageBus; + mockEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + }); + + it('should instantiate Scheduler when enabled', () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + expect(task.scheduler).toBeInstanceOf(Scheduler); + }); + + it('should subscribe to TOOL_CALLS_UPDATE and map status changes', async () => { + // @ts-expect-error - Calling private constructor + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall = { + request: { callId: '1', name: 'ls', args: {} }, + status: 'executing', + }; + + // Simulate MessageBus event + // Simulate MessageBus event + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + + if (!handler) { + throw new Error('TOOL_CALLS_UPDATE handler not found'); + } + + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall], + schedulerId: 'task-id', + }); + + expect(mockEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + status: expect.objectContaining({ + state: 'submitted', // initial task state + }), + metadata: expect.objectContaining({ + coderAgent: expect.objectContaining({ + kind: 'tool-call-update', + }), + }), + }), + ); + }); + + it('should handle tool confirmations by publishing to MessageBus', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall = { + request: { callId: '1', name: 'ls', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-1', + confirmationDetails: { type: 'info', title: 'test', prompt: 'test' }, + }; + + // Simulate MessageBus event to stash the correlationId + // Simulate MessageBus event + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + + if (!handler) { + throw new Error('TOOL_CALLS_UPDATE handler not found'); + } + + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall], + schedulerId: 'task-id', + }); + + // Simulate A2A client confirmation + const part = { + kind: 'data', + data: { + callId: '1', + outcome: 'proceed_once', + }, + }; + + const handled = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart(part); + expect(handled).toBe(true); + + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-1', + confirmed: true, + outcome: ToolConfirmationOutcome.ProceedOnce, + }), + ); + }); + + it('should handle Rejection (Cancel) and Modification (ModifyWithEditor)', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall = { + request: { callId: '1', name: 'ls', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-1', + confirmationDetails: { type: 'info', title: 'test', prompt: 'test' }, + }; + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall], + schedulerId: 'task-id', + }); + + // Simulate Rejection (Cancel) + const handled = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: '1', outcome: 'cancel' }, + }); + expect(handled).toBe(true); + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-1', + confirmed: false, + }), + ); + + const toolCall2 = { + request: { callId: '2', name: 'ls', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-2', + confirmationDetails: { type: 'info', title: 'test', prompt: 'test' }, + }; + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall2], + schedulerId: 'task-id', + }); + + // Simulate ModifyWithEditor + const handled2 = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: '2', outcome: 'modify_with_editor' }, + }); + expect(handled2).toBe(true); + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-2', + confirmed: false, + outcome: ToolConfirmationOutcome.ModifyWithEditor, + payload: undefined, + }), + ); + }); + + it('should handle MCP Server tool operations correctly', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall = { + request: { callId: '1', name: 'call_mcp_tool', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-mcp-1', + confirmationDetails: { + type: 'mcp', + title: 'MCP Server Operation', + prompt: 'test_mcp', + }, + }; + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall], + schedulerId: 'task-id', + }); + + // Simulate ProceedOnce for MCP + const handled = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: '1', outcome: 'proceed_once' }, + }); + expect(handled).toBe(true); + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-mcp-1', + confirmed: true, + outcome: ToolConfirmationOutcome.ProceedOnce, + }), + ); + }); + + it('should handle MCP Server tool ProceedAlwaysServer outcome', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall = { + request: { callId: '1', name: 'call_mcp_tool', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-mcp-2', + confirmationDetails: { + type: 'mcp', + title: 'MCP Server Operation', + prompt: 'test_mcp', + }, + }; + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall], + schedulerId: 'task-id', + }); + + const handled = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: '1', outcome: 'proceed_always_server' }, + }); + expect(handled).toBe(true); + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-mcp-2', + confirmed: true, + outcome: ToolConfirmationOutcome.ProceedAlwaysServer, + }), + ); + }); + + it('should handle MCP Server tool ProceedAlwaysTool outcome', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall = { + request: { callId: '1', name: 'call_mcp_tool', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-mcp-3', + confirmationDetails: { + type: 'mcp', + title: 'MCP Server Operation', + prompt: 'test_mcp', + }, + }; + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall], + schedulerId: 'task-id', + }); + + const handled = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: '1', outcome: 'proceed_always_tool' }, + }); + expect(handled).toBe(true); + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-mcp-3', + confirmed: true, + outcome: ToolConfirmationOutcome.ProceedAlwaysTool, + }), + ); + }); + + it('should handle MCP Server tool ProceedAlwaysAndSave outcome', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall = { + request: { callId: '1', name: 'call_mcp_tool', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-mcp-4', + confirmationDetails: { + type: 'mcp', + title: 'MCP Server Operation', + prompt: 'test_mcp', + }, + }; + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall], + schedulerId: 'task-id', + }); + + const handled = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: '1', outcome: 'proceed_always_and_save' }, + }); + expect(handled).toBe(true); + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-mcp-4', + confirmed: true, + outcome: ToolConfirmationOutcome.ProceedAlwaysAndSave, + }), + ); + }); + + it('should execute without confirmation in YOLO mode and not transition to input-required', async () => { + // Enable YOLO mode + const yoloConfig = createMockConfig({ + isEventDrivenSchedulerEnabled: () => true, + getApprovalMode: () => ApprovalMode.YOLO, + }) as Config; + const yoloMessageBus = yoloConfig.messageBus; + + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', yoloConfig, mockEventBus); + task.setTaskStateAndPublishUpdate = vi.fn(); + + const toolCall = { + request: { callId: '1', name: 'ls', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-1', + confirmationDetails: { type: 'info', title: 'test', prompt: 'test' }, + }; + + const handler = (yoloMessageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall], + schedulerId: 'task-id', + }); + + // Should NOT auto-publish ProceedOnce anymore, because PolicyEngine handles it directly + expect(yoloMessageBus.publish).not.toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + }), + ); + + // Should NOT transition to input-required since it was auto-approved + expect(task.setTaskStateAndPublishUpdate).not.toHaveBeenCalledWith( + 'input-required', + expect.anything(), + undefined, + undefined, + true, + ); + }); + + it('should handle output updates via the message bus', async () => { + // @ts-expect-error - Calling private constructor + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall = { + request: { callId: '1', name: 'ls', args: {} }, + status: 'executing', + liveOutput: 'chunk1', + }; + + // Simulate MessageBus event + // Simulate MessageBus event + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + + if (!handler) { + throw new Error('TOOL_CALLS_UPDATE handler not found'); + } + + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall], + schedulerId: 'task-id', + }); + + // Should publish artifact update for output + expect(mockEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'artifact-update', + artifact: expect.objectContaining({ + artifactId: 'tool-1-output', + parts: [{ kind: 'text', text: 'chunk1' }], + }), + }), + ); + }); + + it('should complete artifact creation without hanging', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCallId = 'create-file-123'; + task['_registerToolCall'](toolCallId, 'executing'); + + const toolCall = { + request: { + callId: toolCallId, + name: 'writeFile', + args: { path: 'test.sh' }, + }, + status: 'success', + result: { ok: true }, + }; + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall], + schedulerId: 'task-id', + }); + + // The tool should be complete and registered appropriately, eventually + // triggering the toolCompletionPromise resolution when all clear. + const internalTask = task as unknown as { + completedToolCalls: unknown[]; + pendingToolCalls: Map; + }; + expect(internalTask.completedToolCalls.length).toBe(1); + expect(internalTask.pendingToolCalls.size).toBe(0); + }); + + it('should preserve messageId across multiple text chunks to prevent UI duplication', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + // Initialize the ID for the first turn (happens internally upon LLM stream) + task.currentAgentMessageId = 'test-id-123'; + + // Simulate sending multiple text chunks + task._sendTextContent('chunk 1'); + task._sendTextContent('chunk 2'); + + // Both text contents should have been published with the same messageId + const textCalls = (mockEventBus.publish as Mock).mock.calls.filter( + (call) => call[0].status?.message?.kind === 'message', + ); + expect(textCalls.length).toBe(2); + expect(textCalls[0][0].status.message.messageId).toBe('test-id-123'); + expect(textCalls[1][0].status.message.messageId).toBe('test-id-123'); + + // Simulate starting a new turn by calling getAndClearCompletedTools + // (which precedes sendCompletedToolsToLlm where a new ID is minted) + task.getAndClearCompletedTools(); + + // sendCompletedToolsToLlm internally rolls the ID forward. + // Simulate what sendCompletedToolsToLlm does: + const internalTask = task as unknown as { + setTaskStateAndPublishUpdate: (state: string, change: unknown) => void; + }; + internalTask.setTaskStateAndPublishUpdate('working', {}); + + // Simulate what sendCompletedToolsToLlm does: generate a new UUID for the next turn + task.currentAgentMessageId = 'test-id-456'; + + task._sendTextContent('chunk 3'); + + const secondTurnCalls = (mockEventBus.publish as Mock).mock.calls.filter( + (call) => call[0].status?.message?.messageId === 'test-id-456', + ); + expect(secondTurnCalls.length).toBe(1); + expect(secondTurnCalls[0][0].status.message.parts[0].text).toBe('chunk 3'); + }); + + it('should handle parallel tool calls correctly', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const toolCall1 = { + request: { callId: '1', name: 'ls', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-1', + confirmationDetails: { type: 'info', title: 'test 1', prompt: 'test 1' }, + }; + + const toolCall2 = { + request: { callId: '2', name: 'pwd', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-2', + confirmationDetails: { type: 'info', title: 'test 2', prompt: 'test 2' }, + }; + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + + // Publish update for both tool calls simultaneously + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall1, toolCall2], + schedulerId: 'task-id', + }); + + // Confirm first tool call + const handled1 = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: '1', outcome: 'proceed_once' }, + }); + expect(handled1).toBe(true); + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-1', + confirmed: true, + }), + ); + + // Confirm second tool call + const handled2 = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: '2', outcome: 'cancel' }, + }); + expect(handled2).toBe(true); + expect(messageBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId: 'corr-2', + confirmed: false, + }), + ); + }); + + it('should handle multi-turn tool resolution correctly', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig); + + task['_registerToolCall']('1', 'scheduled'); + task['_registerToolCall']('2', 'scheduled'); + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + + // Turn 1: Resolve tool 1 + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [ + { + request: { callId: '1', name: 't1' }, + status: 'success', + response: { responseParts: [] }, + }, + ], + schedulerId: 'task-id', + }); + + expect(task['pendingToolCalls'].size).toBe(1); + expect(task['pendingToolCalls'].has('2')).toBe(true); + + // Turn 2: Resolve tool 2 + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [ + { + request: { callId: '2', name: 't2' }, + status: 'success', + response: { responseParts: [] }, + }, + ], + schedulerId: 'task-id', + }); + + expect(task['pendingToolCalls'].size).toBe(0); + }); + + it('should handle subagent progress events from the scheduler', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + // Trigger _schedulerOutputUpdate with subagent progress + task['_schedulerOutputUpdate']('tool-1', { + isSubagentProgress: true, + agentName: 'researcher', + recentActivity: [], + } as ToolLiveOutput); + + expect(mockEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'artifact-update', + artifact: expect.objectContaining({ + parts: [ + expect.objectContaining({ + text: expect.stringContaining('researcher'), + }), + ], + }), + }), + ); + }); + + it('should wait for executing tools before transitioning to input-required state', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + task.setTaskStateAndPublishUpdate = vi.fn(); + + // Register tool 1 as executing + task['_registerToolCall']('1', 'executing'); + + const toolCall1 = { + request: { callId: '1', name: 'ls', args: {} }, + status: 'executing', + }; + + const toolCall2 = { + request: { callId: '2', name: 'pwd', args: {} }, + status: 'awaiting_approval', + correlationId: 'corr-2', + confirmationDetails: { type: 'info', title: 'test 2', prompt: 'test 2' }, + }; + + const handler = (messageBus.subscribe as Mock).mock.calls.find( + (call: unknown[]) => call[0] === MessageBusType.TOOL_CALLS_UPDATE, + )?.[1]; + + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall1, toolCall2], + schedulerId: 'task-id', + }); + + // Should NOT transition to input-required yet + expect(task.setTaskStateAndPublishUpdate).not.toHaveBeenCalledWith( + 'input-required', + expect.anything(), + undefined, + undefined, + true, + ); + + // Complete tool 1 + const toolCall1Complete = { + ...toolCall1, + status: 'success', + result: { ok: true }, + }; + + handler({ + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [toolCall1Complete, toolCall2], + schedulerId: 'task-id', + }); + + // Now it should transition + expect(task.setTaskStateAndPublishUpdate).toHaveBeenCalledWith( + 'input-required', + expect.anything(), + undefined, + undefined, + true, + ); + }); + + it('should ignore confirmations for unknown tool calls', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task('task-id', 'context-id', mockConfig, mockEventBus); + + const handled = await ( + task as unknown as { + _handleToolConfirmationPart: (part: unknown) => Promise; + } + )._handleToolConfirmationPart({ + kind: 'data', + data: { callId: 'unknown-id', outcome: 'proceed_once' }, + }); + + // Should return false for unhandled tool call + expect(handled).toBe(false); + + // Should not publish anything to the message bus + expect(messageBus.publish).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/a2a-server/src/agent/task.test.ts b/packages/a2a-server/src/agent/task.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c1772cac227ba3fbb805f7427107a7154ff335f --- /dev/null +++ b/packages/a2a-server/src/agent/task.test.ts @@ -0,0 +1,916 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'; +import { Task } from './task.js'; +import { + GeminiEventType, + type Config, + type ToolCallRequestInfo, + type GitService, + type CompletedToolCall, + type ToolCall, + type ToolCallsUpdateMessage, + MessageBusType, +} from '@google/gemini-cli-core'; +import { createMockConfig } from '../utils/testing_utils.js'; +import type { ExecutionEventBus, RequestContext } from '@a2a-js/sdk/server'; +import { CoderAgentEvent } from '../types.js'; + +const mockProcessRestorableToolCalls = vi.hoisted(() => vi.fn()); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + processRestorableToolCalls: mockProcessRestorableToolCalls, + }; +}); + +describe('Task', () => { + it('scheduleToolCalls should not modify the input requests array', async () => { + const mockConfig = createMockConfig(); + + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // The Task constructor is private. We'll bypass it for this unit test. + // @ts-expect-error - Calling private constructor for test purposes. + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + task['setTaskStateAndPublishUpdate'] = vi.fn(); + task['getProposedContent'] = vi.fn().mockResolvedValue('new content'); + + const requests: ToolCallRequestInfo[] = [ + { + callId: '1', + name: 'replace', + args: { + file_path: 'test.txt', + old_string: 'old', + new_string: 'new', + }, + isClientInitiated: false, + prompt_id: 'prompt-id-1', + }, + ]; + + const originalRequests = JSON.parse(JSON.stringify(requests)); + const abortController = new AbortController(); + + await task.scheduleToolCalls(requests, abortController.signal); + + expect(requests).toEqual(originalRequests); + }); + + describe('scheduleToolCalls', () => { + const mockConfig = createMockConfig(); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should not create a checkpoint if no restorable tools are called', async () => { + // @ts-expect-error - Calling private constructor for test purposes. + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + const requests: ToolCallRequestInfo[] = [ + { + callId: '1', + name: 'run_shell_command', + args: { command: 'ls' }, + isClientInitiated: false, + prompt_id: 'prompt-id-1', + }, + ]; + const abortController = new AbortController(); + await task.scheduleToolCalls(requests, abortController.signal); + expect(mockProcessRestorableToolCalls).not.toHaveBeenCalled(); + }); + + it('should create a checkpoint if a restorable tool is called', async () => { + const mockConfig = createMockConfig({ + getCheckpointingEnabled: () => true, + getGitService: () => Promise.resolve({} as GitService), + }); + mockProcessRestorableToolCalls.mockResolvedValue({ + checkpointsToWrite: new Map([['test.json', 'test content']]), + toolCallToCheckpointMap: new Map(), + errors: [], + }); + // @ts-expect-error - Calling private constructor for test purposes. + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + const requests: ToolCallRequestInfo[] = [ + { + callId: '1', + name: 'replace', + args: { + file_path: 'test.txt', + old_string: 'old', + new_string: 'new', + }, + isClientInitiated: false, + prompt_id: 'prompt-id-1', + }, + ]; + const abortController = new AbortController(); + await task.scheduleToolCalls(requests, abortController.signal); + expect(mockProcessRestorableToolCalls).toHaveBeenCalledOnce(); + }); + + it('should process all restorable tools for checkpointing in a single batch', async () => { + const mockConfig = createMockConfig({ + getCheckpointingEnabled: () => true, + getGitService: () => Promise.resolve({} as GitService), + }); + mockProcessRestorableToolCalls.mockResolvedValue({ + checkpointsToWrite: new Map([ + ['test1.json', 'test content 1'], + ['test2.json', 'test content 2'], + ]), + toolCallToCheckpointMap: new Map([ + ['1', 'test1'], + ['2', 'test2'], + ]), + errors: [], + }); + // @ts-expect-error - Calling private constructor for test purposes. + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + const requests: ToolCallRequestInfo[] = [ + { + callId: '1', + name: 'replace', + args: { + file_path: 'test.txt', + old_string: 'old', + new_string: 'new', + }, + isClientInitiated: false, + prompt_id: 'prompt-id-1', + }, + { + callId: '2', + name: 'write_file', + args: { file_path: 'test2.txt', content: 'new content' }, + isClientInitiated: false, + prompt_id: 'prompt-id-2', + }, + { + callId: '3', + name: 'not_restorable', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-3', + }, + ]; + const abortController = new AbortController(); + await task.scheduleToolCalls(requests, abortController.signal); + expect(mockProcessRestorableToolCalls).toHaveBeenCalledExactlyOnceWith( + [ + expect.objectContaining({ callId: '1' }), + expect.objectContaining({ callId: '2' }), + ], + expect.anything(), + expect.anything(), + ); + }); + }); + + describe('acceptAgentMessage', () => { + it('should set currentTraceId when event has traceId', async () => { + const mockConfig = createMockConfig(); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor for test purposes. + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + const event = { + type: 'content', + value: 'test', + traceId: 'test-trace-id', + }; + + await task.acceptAgentMessage(event); + + expect(mockEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + traceId: 'test-trace-id', + }), + }), + ); + }); + + it('should handle Citation event and publish to event bus', async () => { + const mockConfig = createMockConfig(); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor for test purposes. + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + const citationText = 'Source: example.com'; + const citationEvent = { + type: GeminiEventType.Citation, + value: citationText, + }; + + await task.acceptAgentMessage(citationEvent); + + expect(mockEventBus.publish).toHaveBeenCalledOnce(); + const publishedEvent = (mockEventBus.publish as Mock).mock.calls[0][0]; + + expect(publishedEvent.kind).toBe('status-update'); + expect(publishedEvent.taskId).toBe('task-id'); + expect(publishedEvent.metadata.coderAgent.kind).toBe( + CoderAgentEvent.CitationEvent, + ); + expect(publishedEvent.status.message).toBeDefined(); + expect(publishedEvent.status.message.parts).toEqual([ + { + kind: 'text', + text: citationText, + }, + ]); + }); + + it('should capture usageMetadata on Finished event and include it in final status update', async () => { + const mockConfig = createMockConfig(); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor for test purposes. + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + const finishedEvent = { + type: GeminiEventType.Finished, + value: { + reason: 'STOP', + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 50, + totalTokenCount: 150, + }, + }, + }; + + await task.acceptAgentMessage(finishedEvent); + expect(task.usageMetadata).toEqual({ + promptTokenCount: 100, + candidatesTokenCount: 50, + totalTokenCount: 150, + }); + + task.setTaskStateAndPublishUpdate( + 'input-required', + { kind: CoderAgentEvent.StateChangeEvent }, + undefined, + undefined, + true, // final + ); + + expect(mockEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + final: true, + metadata: expect.objectContaining({ + usageMetadata: { + promptTokenCount: 100, + candidatesTokenCount: 50, + totalTokenCount: 150, + }, + }), + }), + ); + }); + + it('should update modelInfo and reflect it in metadata and status updates', async () => { + const mockConfig = createMockConfig(); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor for test purposes. + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + const modelInfoEvent = { + type: GeminiEventType.ModelInfo, + value: 'new-model-name', + }; + + await task.acceptAgentMessage(modelInfoEvent); + + expect(task.modelInfo).toBe('new-model-name'); + + // Check getMetadata + const metadata = await task.getMetadata(); + expect(metadata.model).toBe('new-model-name'); + + // Check status update + task.setTaskStateAndPublishUpdate( + 'working', + { kind: CoderAgentEvent.StateChangeEvent }, + 'Working...', + ); + + expect(mockEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + metadata: expect.objectContaining({ + model: 'new-model-name', + }), + }), + ); + }); + + it.each([ + { eventType: GeminiEventType.Retry, eventName: 'Retry' }, + { eventType: GeminiEventType.InvalidStream, eventName: 'InvalidStream' }, + ])( + 'should handle $eventName event without triggering error handling', + async ({ eventType }) => { + const mockConfig = createMockConfig(); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + const cancelPendingToolsSpy = vi.spyOn(task, 'cancelPendingTools'); + const setTaskStateSpy = vi.spyOn(task, 'setTaskStateAndPublishUpdate'); + + const event = { + type: eventType, + }; + + await task.acceptAgentMessage(event); + + expect(cancelPendingToolsSpy).not.toHaveBeenCalled(); + expect(setTaskStateSpy).not.toHaveBeenCalled(); + }, + ); + }); + + describe('currentPromptId and promptCount', () => { + it('should correctly initialize and update promptId and promptCount', async () => { + const mockConfig = createMockConfig(); + mockConfig.getGeminiClient = vi.fn().mockReturnValue({ + sendMessageStream: vi.fn().mockReturnValue((async function* () {})()), + }); + mockConfig.getSessionId = () => 'test-session-id'; + + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + // Initial state + expect(task.currentPromptId).toBeUndefined(); + expect(task.promptCount).toBe(0); + + // First user message should set prompt_id + const userMessage1 = { + userMessage: { + parts: [{ kind: 'text', text: 'hello' }], + }, + } as RequestContext; + const abortController1 = new AbortController(); + for await (const _ of task.acceptUserMessage( + userMessage1, + abortController1.signal, + )) { + // no-op + } + + const expectedPromptId1 = 'test-session-id########0'; + expect(task.promptCount).toBe(1); + expect(task.currentPromptId).toBe(expectedPromptId1); + + // A new user message should generate a new prompt_id + const userMessage2 = { + userMessage: { + parts: [{ kind: 'text', text: 'world' }], + }, + } as RequestContext; + const abortController2 = new AbortController(); + for await (const _ of task.acceptUserMessage( + userMessage2, + abortController2.signal, + )) { + // no-op + } + + const expectedPromptId2 = 'test-session-id########1'; + expect(task.promptCount).toBe(2); + expect(task.currentPromptId).toBe(expectedPromptId2); + + // Subsequent tool call processing should use the same prompt_id + const completedTool = { + request: { callId: 'tool-1' }, + response: { responseParts: [{ text: 'tool output' }] }, + } as CompletedToolCall; + const abortController3 = new AbortController(); + for await (const _ of task.sendCompletedToolsToLlm( + [completedTool], + abortController3.signal, + )) { + // no-op + } + + expect(task.promptCount).toBe(2); + expect(task.currentPromptId).toBe(expectedPromptId2); + }); + }); + + describe('Race Condition Fix', () => { + const mockConfig = createMockConfig(); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should NOT transition to input-required if a tool is still validating', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + // Manually register two tool calls + task['_registerToolCall']('tool-1', 'awaiting_approval'); + task['_registerToolCall']('tool-2', 'validating'); + + // Call checkInputRequiredState (private) + task['checkInputRequiredState'](); + + // Verify task state did NOT change to input-required + expect(task.taskState).not.toBe('input-required'); + expect(mockEventBus.publish).not.toHaveBeenCalledWith( + expect.objectContaining({ + status: expect.objectContaining({ state: 'input-required' }), + }), + ); + }); + + it('should transition to input-required if all active tools are awaiting approval', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + // Transition from submitted to working first to simulate normal flow + task.taskState = 'working'; + + // Manually register tool calls + task['_registerToolCall']('tool-1', 'awaiting_approval'); + + // Call checkInputRequiredState + task['checkInputRequiredState'](); + + // Verify task state changed to input-required + expect(task.taskState).toBe('input-required'); + expect(mockEventBus.publish).toHaveBeenCalledWith( + expect.objectContaining({ + status: expect.objectContaining({ state: 'input-required' }), + }), + ); + }); + + it('handleEventDrivenToolCallsUpdate should ignore events for other schedulers', async () => { + // @ts-expect-error - Calling private constructor + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + const handleEventDrivenToolCallSpy = vi.spyOn( + task as unknown as { + handleEventDrivenToolCall: Task['handleEventDrivenToolCall']; + }, + 'handleEventDrivenToolCall', + ); + + const otherEvent: ToolCallsUpdateMessage = { + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [ + { request: { callId: '1' }, status: 'executing' } as ToolCall, + ], + schedulerId: 'other-task-id', + }; + + task['handleEventDrivenToolCallsUpdate'](otherEvent); + + expect(handleEventDrivenToolCallSpy).not.toHaveBeenCalled(); + + const ownEvent: ToolCallsUpdateMessage = { + type: MessageBusType.TOOL_CALLS_UPDATE, + toolCalls: [ + { request: { callId: '1' }, status: 'executing' } as ToolCall, + ], + schedulerId: 'task-id', + }; + + task['handleEventDrivenToolCallsUpdate'](ownEvent); + + expect(handleEventDrivenToolCallSpy).toHaveBeenCalled(); + }); + + describe('Pending Tools state', () => { + it('should correctly report pending tools presence and count', () => { + const mockConfig = createMockConfig(); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + expect(task.hasPendingTools).toBe(false); + expect(task.pendingToolsCount).toBe(0); + + task['_registerToolCall']('tool-1', 'scheduled'); + expect(task.hasPendingTools).toBe(true); + expect(task.pendingToolsCount).toBe(1); + }); + }); + }); + + describe('Serialization and Mapping', () => { + it('should map internal "validating" status to "scheduled" for the client and include outcome', async () => { + const mockConfig = createMockConfig(); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + const mockToolCall = { + request: { callId: 'tool-1' }, + status: 'validating', + outcome: 'accepted', + tool: { name: 'test-tool' }, + }; + + const message = task['toolStatusMessage']( + mockToolCall as unknown as ToolCall, + 'task-id', + 'context-id', + ); + const serialized = ( + message.parts![0] as { + data: { status: string; outcome: string }; + } + ).data; + + expect(serialized.status).toBe('scheduled'); + expect(serialized.outcome).toBe('accepted'); + }); + + it('should correctly detect changes when status or outcome changes', async () => { + const mockConfig = createMockConfig(); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + const toolCall1 = { + request: { callId: 'tool-1' }, + status: 'awaiting_approval', + }; + + // First update - should trigger change + const changed1 = task['handleEventDrivenToolCall']( + toolCall1 as unknown as ToolCall, + ); + expect(changed1).toBe(true); + + // Second update with same status - should NOT trigger change + const changed2 = task['handleEventDrivenToolCall']( + toolCall1 as unknown as ToolCall, + ); + expect(changed2).toBe(false); + + // Update with new outcome - SHOULD trigger change + const toolCall2 = { + request: { callId: 'tool-1' }, + status: 'awaiting_approval', + outcome: 'accepted', + }; + const changed3 = task['handleEventDrivenToolCall']( + toolCall2 as unknown as ToolCall, + ); + expect(changed3).toBe(true); + }); + }); + + describe('getProposedContent (CRLF Line Ending Normalization)', () => { + it('should successfully replace LF-based strings in CRLF-based files', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const os = await import('node:os'); + + const mockConfig = createMockConfig({ + getTargetDir: () => os.tmpdir(), + validatePathAccess: () => null, + }); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + const tempFile = path.resolve(os.tmpdir(), 'crlf_test_file.txt'); + const crlfContent = 'line1\r\nline2\r\nline3\r\n'; + fs.writeFileSync(tempFile, crlfContent, 'utf8'); + + try { + const oldString = 'line2\n'; + const newString = 'line2-optimized\n'; + + const result = await task['getProposedContent']( + tempFile, + oldString, + newString, + ); + + expect(result).toContain('line2-optimized'); + expect(result).toContain('\r\n'); // It should preserve the original CRLF line endings + } finally { + if (fs.existsSync(tempFile)) { + fs.unlinkSync(tempFile); + } + } + }); + + it('should successfully replace CRLF-based strings in CRLF-based files by normalizing all to LF', async () => { + const fs = await import('node:fs'); + const path = await import('node:path'); + const os = await import('node:os'); + + const mockConfig = createMockConfig({ + getTargetDir: () => os.tmpdir(), + validatePathAccess: () => null, + }); + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + const tempFile = path.resolve( + os.tmpdir(), + 'crlf_test_file_crlf_inputs.txt', + ); + const crlfContent = 'line1\r\nline2\r\nline3\r\n'; + fs.writeFileSync(tempFile, crlfContent, 'utf8'); + + try { + const oldString = 'line2\r\n'; + const newString = 'line2-optimized\r\n'; + + const result = await task['getProposedContent']( + tempFile, + oldString, + newString, + ); + + expect(result).toContain('line2-optimized'); + expect(result).toContain('\r\n'); // It should preserve the original CRLF line endings + } finally { + if (fs.existsSync(tempFile)) { + fs.unlinkSync(tempFile); + } + } + }); + }); + + describe('Stale Cancellation Error Cleanup', () => { + it('should clear any previous execution cancellation error on acceptUserMessage', async () => { + const mockConfig = createMockConfig(); + mockConfig.getGeminiClient = vi.fn().mockReturnValue({ + sendMessageStream: vi.fn().mockReturnValue((async function* () {})()), + }); + mockConfig.getSessionId = () => 'test-session-id'; + + const mockEventBus: ExecutionEventBus = { + publish: vi.fn(), + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + removeAllListeners: vi.fn(), + finished: vi.fn(), + }; + + // @ts-expect-error - Calling private constructor + const task = new Task( + 'task-id', + 'context-id', + mockConfig as Config, + mockEventBus, + ); + + // Simulate cancellation from a previous aborted execution turn + task.cancelPendingTools('Execution aborted'); + + // Verify cancellationError is set and would throw on wait + expect(task['cancellationError']).toBeDefined(); + await expect(task.waitForPendingTools()).rejects.toThrow( + 'Execution aborted', + ); + + // Call acceptUserMessage to start a new user turn + const userMessage = { + userMessage: { + parts: [{ kind: 'text', text: 'new prompt' }], + }, + } as RequestContext; + const abortController = new AbortController(); + + // Drain the generator to let acceptUserMessage execute + for await (const _ of task.acceptUserMessage( + userMessage, + abortController.signal, + )) { + // no-op + } + + // Verify cancellationError is cleared + expect(task['cancellationError']).toBeUndefined(); + + // Verify waiting for pending tools now succeeds/does not throw + await expect(task.waitForPendingTools()).resolves.toBeUndefined(); + }); + }); +}); diff --git a/packages/a2a-server/src/agent/task.ts b/packages/a2a-server/src/agent/task.ts new file mode 100644 index 0000000000000000000000000000000000000000..0a1534f3cdda038db783fb93ccd5995a375a99c5 --- /dev/null +++ b/packages/a2a-server/src/agent/task.ts @@ -0,0 +1,1305 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type AgentLoopContext, + Scheduler, + type GeminiClient, + GeminiEventType, + ToolConfirmationOutcome, + ApprovalMode, + CoreToolCallStatus, + getAllMCPServerStatuses, + MCPServerStatus, + isNodeError, + getErrorMessage, + parseAndFormatApiError, + safeLiteralReplace, + DEFAULT_GUI_EDITOR, + type AnyDeclarativeTool, + type ToolCall, + type ToolConfirmationPayload, + type CompletedToolCall, + type ToolCallRequestInfo, + type ServerGeminiErrorEvent, + type ServerGeminiStreamEvent, + type ToolCallConfirmationDetails, + type Config, + type UserTierId, + type ToolLiveOutput, + type AnsiLine, + type AnsiOutput, + type AnsiToken, + isSubagentProgress, + EDIT_TOOL_NAMES, + processRestorableToolCalls, + MessageBusType, + type ToolCallsUpdateMessage, +} from '@google/gemini-cli-core'; +import { + type ExecutionEventBus, + type RequestContext, +} from '@a2a-js/sdk/server'; +import type { + TaskStatusUpdateEvent, + TaskArtifactUpdateEvent, + TaskState, + Message, + Part, + Artifact, +} from '@a2a-js/sdk'; +import { v4 as uuidv4 } from 'uuid'; +import { EventEmitter } from 'node:events'; +import { logger } from '../utils/logger.js'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { + CoderAgentEvent, + type CoderAgentMessage, + type StateChange, + type ToolCallUpdate, + type TextContent, + type TaskMetadata, + type Thought, + type ThoughtSummary, + type Citation, +} from '../types.js'; +import type { PartUnion, Part as genAiPart } from '@google/genai'; + +type UnionKeys = T extends T ? keyof T : never; + +export class Task { + id: string; + contextId: string; + scheduler: Scheduler; + config: Config; + geminiClient: GeminiClient; + pendingToolConfirmationDetails: Map; + pendingCorrelationIds: Map = new Map(); + taskState: TaskState; + eventBus?: ExecutionEventBus; + completedToolCalls: CompletedToolCall[]; + processedToolCallIds: Set = new Set(); + skipFinalTrueAfterInlineEdit = false; + modelInfo?: string; + currentPromptId: string | undefined; + currentAgentMessageId = uuidv4(); + promptCount = 0; + autoExecute: boolean; + usageMetadata?: { + promptTokenCount?: number; + candidatesTokenCount?: number; + totalTokenCount?: number; + cachedContentTokenCount?: number; + }; + private get isYoloMatch(): boolean { + return ( + this.autoExecute || this.config.getApprovalMode() === ApprovalMode.YOLO + ); + } + + // For tool waiting logic + private pendingToolCalls: Map = new Map(); //toolCallId --> status + private pendingOutcomes: Map = + new Map(); // toolCallId --> outcome + private toolsAlreadyConfirmed: Set = new Set(); + private toolUpdateEmitter = new EventEmitter(); + private cancellationError?: Error; + + private constructor( + id: string, + contextId: string, + config: Config, + eventBus?: ExecutionEventBus, + autoExecute = false, + ) { + this.id = id; + this.contextId = contextId; + this.config = config; + + this.scheduler = this.setupEventDrivenScheduler(); + + const loopContext: AgentLoopContext = this.config; + this.geminiClient = loopContext.geminiClient; + this.pendingToolConfirmationDetails = new Map(); + this.taskState = 'submitted'; + this.eventBus = eventBus; + this.completedToolCalls = []; + this.autoExecute = autoExecute; + this.config.setFallbackModelHandler( + // For a2a-server, we want to automatically switch to the fallback model + // for future requests without retrying the current one. + async (failedModel, fallbackModel) => { + this.config.activateFallbackMode(fallbackModel, failedModel); + return 'stop'; + }, + ); + } + + get hasPendingTools(): boolean { + return this.pendingToolCalls.size > 0; + } + + get pendingToolsCount(): number { + return this.pendingToolCalls.size; + } + + static async create( + id: string, + contextId: string, + config: Config, + eventBus?: ExecutionEventBus, + autoExecute?: boolean, + ): Promise { + return new Task(id, contextId, config, eventBus, autoExecute); + } + + // Note: `getAllMCPServerStatuses` retrieves the status of all MCP servers for the entire + // process. This is not scoped to the individual task but reflects the global connection + // state managed within the @gemini-cli/core module. + async getMetadata(): Promise { + const loopContext: AgentLoopContext = this.config; + const toolRegistry = loopContext.toolRegistry; + const mcpServers = this.config.getMcpClientManager()?.getMcpServers() || {}; + const serverStatuses = getAllMCPServerStatuses(); + const servers = Object.keys(mcpServers).map((serverName) => ({ + name: serverName, + status: serverStatuses.get(serverName) || MCPServerStatus.DISCONNECTED, + tools: toolRegistry.getToolsByServer(serverName).map((tool) => ({ + name: tool.name, + description: tool.description, + parameterSchema: tool.schema.parameters, + })), + })); + + const availableTools = toolRegistry.getAllTools().map((tool) => ({ + name: tool.name, + description: tool.description, + parameterSchema: tool.schema.parameters, + })); + + const metadata: TaskMetadata = { + id: this.id, + contextId: this.contextId, + taskState: this.taskState, + model: this.modelInfo || this.config.getModel(), + mcpServers: servers, + availableTools, + }; + return metadata; + } + + private _registerToolCall(toolCallId: string, status: string): void { + this.pendingToolCalls.set(toolCallId, status); + this.toolUpdateEmitter.emit('update'); + logger.info( + `[Task] Registered tool call: ${toolCallId}. Pending: ${this.pendingToolCalls.size}`, + ); + } + + private _resolveToolCall(toolCallId: string): void { + if (this.pendingToolCalls.has(toolCallId)) { + this.pendingToolCalls.delete(toolCallId); + this.toolUpdateEmitter.emit('update'); + logger.info( + `[Task] Resolved tool call: ${toolCallId}. Pending: ${this.pendingToolCalls.size}`, + ); + } + } + + private isAwaitingApprovalOnly(): boolean { + if (this.pendingToolCalls.size === 0) { + return false; + } + for (const [callId, status] of this.pendingToolCalls.entries()) { + if ( + status !== CoreToolCallStatus.AwaitingApproval || + this.toolsAlreadyConfirmed.has(callId) + ) { + return false; + } + } + return true; + } + + async waitForPendingTools(): Promise { + while (this.pendingToolCalls.size > 0 && !this.isAwaitingApprovalOnly()) { + if (this.cancellationError) { + const error = this.cancellationError; + this.cancellationError = undefined; + throw error; + } + logger.info( + `[Task] Waiting for ${this.pendingToolCalls.size} pending tool(s)...`, + ); + await new Promise((resolve) => + this.toolUpdateEmitter.once('update', resolve), + ); + } + if (this.cancellationError) { + const error = this.cancellationError; + this.cancellationError = undefined; + throw error; + } + } + + cancelPendingTools(reason: string): void { + if (this.pendingToolCalls.size > 0) { + logger.info( + `[Task] Cancelling all ${this.pendingToolCalls.size} pending tool calls. Reason: ${reason}`, + ); + } + this.cancellationError = new Error(reason); + this.pendingToolCalls.clear(); + this.pendingCorrelationIds.clear(); + this.toolsAlreadyConfirmed.clear(); + + this.scheduler.cancelAll(); + this.toolUpdateEmitter.emit('update'); + } + + private _createTextMessage( + text: string, + role: 'agent' | 'user' = 'agent', + ): Message { + return { + kind: 'message', + role, + parts: [{ kind: 'text', text }], + messageId: role === 'agent' ? this.currentAgentMessageId : uuidv4(), + taskId: this.id, + contextId: this.contextId, + }; + } + + private _createStatusUpdateEvent( + stateToReport: TaskState, + coderAgentMessage: CoderAgentMessage, + message?: Message, + final = false, + timestamp?: string, + metadataError?: string, + traceId?: string, + ): TaskStatusUpdateEvent { + const metadata: { + coderAgent: CoderAgentMessage; + model: string; + userTier?: UserTierId; + error?: string; + traceId?: string; + usageMetadata?: Task['usageMetadata']; + } = { + coderAgent: coderAgentMessage, + model: this.modelInfo || this.config.getModel(), + userTier: this.config.getUserTier(), + }; + + if (metadataError) { + metadata.error = metadataError; + } + + if (traceId) { + metadata.traceId = traceId; + } + + if (final && this.usageMetadata) { + metadata.usageMetadata = this.usageMetadata; + } + + return { + kind: 'status-update', + taskId: this.id, + contextId: this.contextId, + status: { + state: stateToReport, + message, // Shorthand property + timestamp: timestamp || new Date().toISOString(), + }, + final, + metadata, + }; + } + + setTaskStateAndPublishUpdate( + newState: TaskState, + coderAgentMessage: CoderAgentMessage, + messageText?: string, + messageParts?: Part[], // For more complex messages + final = false, + metadataError?: string, + traceId?: string, + ): void { + this.taskState = newState; + let message: Message | undefined; + + if (messageText) { + message = this._createTextMessage(messageText); + } else if (messageParts) { + message = { + kind: 'message', + role: 'agent', + parts: messageParts, + messageId: uuidv4(), + taskId: this.id, + contextId: this.contextId, + }; + } + + const event = this._createStatusUpdateEvent( + this.taskState, + coderAgentMessage, + message, + final, + undefined, + metadataError, + traceId, + ); + this.eventBus?.publish(event); + } + + private _schedulerOutputUpdate( + toolCallId: string, + outputChunk: ToolLiveOutput, + ): void { + let outputAsText: string; + if (typeof outputChunk === 'string') { + outputAsText = outputChunk; + } else if (isSubagentProgress(outputChunk)) { + outputAsText = JSON.stringify(outputChunk); + } else if (Array.isArray(outputChunk)) { + const ansiOutput: AnsiOutput = outputChunk; + outputAsText = ansiOutput + .map((line: AnsiLine) => + line.map((token: AnsiToken) => token.text).join(''), + ) + .join('\n'); + } else { + outputAsText = String(outputChunk); + } + + logger.info( + '[Task] Scheduler output update for tool call ' + + toolCallId + + ': ' + + outputAsText, + ); + const artifact: Artifact = { + artifactId: `tool-${toolCallId}-output`, + parts: [ + { + kind: 'text', + text: outputAsText, + } as Part, + ], + }; + const artifactEvent: TaskArtifactUpdateEvent = { + kind: 'artifact-update', + taskId: this.id, + contextId: this.contextId, + artifact, + append: true, + lastChunk: false, + }; + this.eventBus?.publish(artifactEvent); + } + + private messageBusListener?: (message: ToolCallsUpdateMessage) => void; + + private setupEventDrivenScheduler(): Scheduler { + const loopContext: AgentLoopContext = this.config; + const messageBus = loopContext.messageBus; + const scheduler = new Scheduler({ + schedulerId: this.id, + context: this.config, + messageBus, + getPreferredEditor: () => DEFAULT_GUI_EDITOR, + }); + + this.messageBusListener = this.handleEventDrivenToolCallsUpdate.bind(this); + messageBus.subscribe( + MessageBusType.TOOL_CALLS_UPDATE, + this.messageBusListener, + ); + + return scheduler; + } + + dispose(): void { + if (this.messageBusListener) { + const loopContext: AgentLoopContext = this.config; + loopContext.messageBus.unsubscribe( + MessageBusType.TOOL_CALLS_UPDATE, + this.messageBusListener, + ); + this.messageBusListener = undefined; + } + + this.scheduler.dispose(); + } + + private handleEventDrivenToolCallsUpdate( + event: ToolCallsUpdateMessage, + ): void { + if ( + event.type !== MessageBusType.TOOL_CALLS_UPDATE || + event.schedulerId !== this.id + ) { + return; + } + + const toolCalls = event.toolCalls; + + toolCalls.forEach((tc) => { + this.handleEventDrivenToolCall(tc); + }); + + this.checkInputRequiredState(); + } + + private handleEventDrivenToolCall(tc: ToolCall): boolean { + const callId = tc.request.callId; + + // Do not process events for tools that have already been finalized. + // This prevents duplicate completions if the state manager emits a snapshot containing + // already resolved tools whose IDs were removed from pendingToolCalls. + if ( + this.processedToolCallIds.has(callId) || + this.completedToolCalls.some((c) => c.request.callId === callId) + ) { + return false; + } + + const previousStatus = this.pendingToolCalls.get(callId); + const previousOutcome = this.pendingOutcomes.get(callId); + const hasChanged = + previousStatus !== tc.status || previousOutcome !== tc.outcome; + + // Update outcome tracking + this.pendingOutcomes.set(callId, tc.outcome); + + // 1. Handle Output + if (tc.status === 'executing' && tc.liveOutput) { + this._schedulerOutputUpdate(callId, tc.liveOutput); + } + + // 2. Handle terminal states + if ( + tc.status === 'success' || + tc.status === 'error' || + tc.status === 'cancelled' + ) { + this.toolsAlreadyConfirmed.delete(callId); + this.pendingOutcomes.delete(callId); + if (hasChanged) { + logger.info( + `[Task] Tool call ${callId} completed with status: ${tc.status}`, + ); + this.completedToolCalls.push(tc); + this._resolveToolCall(callId); + } + } else { + // Keep track of pending tools + this._registerToolCall(callId, tc.status); + } + + // 3. Handle Confirmation Stash + if (tc.status === 'awaiting_approval' && tc.confirmationDetails) { + const details = tc.confirmationDetails; + + if (tc.correlationId) { + this.pendingCorrelationIds.set(callId, tc.correlationId); + } + + this.pendingToolConfirmationDetails.set(callId, { + ...details, + onConfirm: async () => {}, + } as ToolCallConfirmationDetails); + } + + // 4. Publish Status Updates to A2A event bus + if (hasChanged) { + const coderAgentMessage: CoderAgentMessage = + tc.status === 'awaiting_approval' + ? { kind: CoderAgentEvent.ToolCallConfirmationEvent } + : { kind: CoderAgentEvent.ToolCallUpdateEvent }; + + const message = this.toolStatusMessage(tc, this.id, this.contextId); + const statusUpdate = this._createStatusUpdateEvent( + this.taskState, + coderAgentMessage, + message, + false, + ); + this.eventBus?.publish(statusUpdate); + } + + return hasChanged; + } + + private checkInputRequiredState(): void { + if (this.isYoloMatch) { + return; + } + + // 6. Handle Input Required State + let isAwaitingApproval = false; + let isExecuting = false; + + for (const [callId, status] of this.pendingToolCalls.entries()) { + if ( + status === CoreToolCallStatus.Executing || + status === CoreToolCallStatus.Scheduled || + status === CoreToolCallStatus.Validating || + this.toolsAlreadyConfirmed.has(callId) + ) { + isExecuting = true; + } else if (status === CoreToolCallStatus.AwaitingApproval) { + isAwaitingApproval = true; + } + } + + if ( + isAwaitingApproval && + !isExecuting && + !this.skipFinalTrueAfterInlineEdit + ) { + this.skipFinalTrueAfterInlineEdit = false; + const wasAlreadyInputRequired = this.taskState === 'input-required'; + + this.setTaskStateAndPublishUpdate( + 'input-required', + { kind: CoderAgentEvent.StateChangeEvent }, + undefined, + undefined, + /*final*/ true, + ); + + // Unblock waitForPendingTools to correctly end the executor loop and release the HTTP response stream. + // The IDE client will open a new stream with the confirmation reply. + if (!wasAlreadyInputRequired) { + this.toolUpdateEmitter.emit('update'); + } + } + } + + private _pickFields< + T extends ToolCall | AnyDeclarativeTool, + K extends UnionKeys, + >(from: T, ...fields: K[]): Partial { + const ret: Partial = {}; + for (const field of fields) { + if (field in from && from[field] !== undefined) { + ret[field] = from[field]; + } + } + return ret; + } + + private toolStatusMessage( + tc: ToolCall, + taskId: string, + contextId: string, + ): Message { + const messageParts: Part[] = []; + + // Create a serializable version of the ToolCall (pick necessary + // properties/avoid methods causing circular reference errors). + // Type allows tool to be Partial for serialization. + const serializableToolCall: Partial> & { + tool?: Partial; + } = this._pickFields( + tc, + 'request', + 'status', + 'confirmationDetails', + 'liveOutput', + 'response', + 'outcome', + ); + + // Map internal 'validating' status to 'scheduled' for the client + if (serializableToolCall.status === CoreToolCallStatus.Validating) { + serializableToolCall.status = CoreToolCallStatus.Scheduled; + } + + if (tc.tool) { + const toolFields = this._pickFields( + tc.tool, + 'name', + 'displayName', + 'description', + 'kind', + 'isOutputMarkdown', + 'canUpdateOutput', + 'schema', + 'parameterSchema', + ); + serializableToolCall.tool = toolFields; + } + + messageParts.push({ + kind: 'data', + data: serializableToolCall, + } as Part); + + return { + kind: 'message', + role: 'agent', + parts: messageParts, + messageId: uuidv4(), + taskId, + contextId, + }; + } + + private async getProposedContent( + file_path: string, + old_string: string, + new_string: string, + ): Promise { + // Validate path to prevent path traversal vulnerabilities + const resolvedPath = path.resolve(this.config.getTargetDir(), file_path); + const pathError = this.config.validatePathAccess(resolvedPath, 'read'); + if (pathError) { + throw new Error(`Path validation failed: ${pathError}`); + } + + try { + const rawContent = await fs.readFile(resolvedPath, 'utf8'); + const hasCrlf = rawContent.includes('\r\n'); + const currentContent = rawContent.replace(/\r\n/g, '\n'); + const normalizedOldString = old_string.replace(/\r\n/g, '\n'); + const normalizedNewString = new_string.replace(/\r\n/g, '\n'); + const proposedContent = this._applyReplacement( + currentContent, + normalizedOldString, + normalizedNewString, + normalizedOldString === '' && currentContent === '', + ); + return hasCrlf ? proposedContent.replace(/\n/g, '\r\n') : proposedContent; + } catch (err) { + if (!isNodeError(err) || err.code !== 'ENOENT') throw err; + return ''; + } + } + + private _applyReplacement( + currentContent: string | null, + oldString: string, + newString: string, + isNewFile: boolean, + ): string { + if (isNewFile) { + return newString; + } + if (currentContent === null) { + // Should not happen if not a new file, but defensively return empty or newString if oldString is also empty + return oldString === '' ? newString : ''; + } + // If oldString is empty and it's not a new file, do not modify the content. + if (oldString === '' && !isNewFile) { + return currentContent; + } + + // Use intelligent replacement that handles $ sequences safely + return safeLiteralReplace(currentContent, oldString, newString); + } + + async scheduleToolCalls( + requests: ToolCallRequestInfo[], + abortSignal: AbortSignal, + ): Promise { + if (requests.length === 0) { + return; + } + + // Set checkpoint file before any file modification tool executes + const restorableToolCalls = requests.filter((request) => + EDIT_TOOL_NAMES.has(request.name), + ); + + if ( + restorableToolCalls.length > 0 && + this.config.getCheckpointingEnabled() + ) { + const gitService = await this.config.getGitService(); + if (gitService) { + const { checkpointsToWrite, toolCallToCheckpointMap, errors } = + await processRestorableToolCalls( + restorableToolCalls, + gitService, + this.geminiClient, + ); + + if (errors.length > 0) { + errors.forEach((error) => logger.error(error)); + } + + if (checkpointsToWrite.size > 0) { + const checkpointDir = + this.config.storage.getProjectTempCheckpointsDir(); + await fs.mkdir(checkpointDir, { recursive: true }); + for (const [fileName, content] of checkpointsToWrite) { + const filePath = path.join(checkpointDir, fileName); + await fs.writeFile(filePath, content); + } + } + + for (const request of requests) { + const checkpoint = toolCallToCheckpointMap.get(request.callId); + if (checkpoint) { + request.checkpoint = checkpoint; + } + } + } + } + + const updatedRequests = await Promise.all( + requests.map(async (request) => { + if ( + request.name === 'replace' && + request.args && + !request.args['newContent'] && + request.args['file_path'] && + request.args['old_string'] && + request.args['new_string'] + ) { + const filePath = request.args['file_path']; + const oldString = request.args['old_string']; + const newString = request.args['new_string']; + if ( + typeof filePath === 'string' && + typeof oldString === 'string' && + typeof newString === 'string' + ) { + // Resolve and validate path to prevent path traversal (user-controlled file_path). + const resolvedPath = path.resolve( + this.config.getTargetDir(), + filePath, + ); + const pathError = this.config.validatePathAccess( + resolvedPath, + 'read', + ); + if (!pathError) { + const newContent = await this.getProposedContent( + resolvedPath, + oldString, + newString, + ); + return { ...request, args: { ...request.args, newContent } }; + } + } + } + return request; + }), + ); + + logger.info( + `[Task] Scheduling batch of ${updatedRequests.length} tool calls.`, + ); + const stateChange: StateChange = { + kind: CoderAgentEvent.StateChangeEvent, + }; + this.setTaskStateAndPublishUpdate('working', stateChange); + + // Pre-register tools to ensure waitForPendingTools sees them as pending + // before the async scheduler enqueues them and fires the event bus update. + for (const req of updatedRequests) { + if (!this.pendingToolCalls.has(req.callId)) { + this._registerToolCall(req.callId, 'scheduled'); + } + } + + // Fire and forget so we don't block the executor loop before waitForPendingTools can be called + void this.scheduler.schedule(updatedRequests, abortSignal); + } + + async acceptAgentMessage(event: ServerGeminiStreamEvent): Promise { + const stateChange: StateChange = { + kind: CoderAgentEvent.StateChangeEvent, + }; + const traceId = + 'traceId' in event && event.traceId ? event.traceId : undefined; + + switch (event.type) { + case GeminiEventType.Content: + logger.info('[Task] Sending agent message content...'); + this._sendTextContent(event.value, traceId); + break; + case GeminiEventType.ToolCallRequest: + // This is now handled by the agent loop, which collects all requests + // and calls scheduleToolCalls once. + logger.warn( + '[Task] A single tool call request was passed to acceptAgentMessage. This should be handled in a batch by the agent. Ignoring.', + ); + break; + case GeminiEventType.ToolCallResponse: + // This event type from ServerGeminiStreamEvent might be for when LLM *generates* a tool response part. + // The actual execution result comes via user message. + logger.info( + '[Task] Received tool call response from LLM (part of generation):', + event.value, + ); + break; + case GeminiEventType.ToolCallConfirmation: + // This is when LLM requests confirmation, not when user provides it. + logger.info( + '[Task] Received tool call confirmation request from LLM:', + event.value.request.callId, + ); + this.pendingToolConfirmationDetails.set( + event.value.request.callId, + event.value.details, + ); + // This will be handled by the scheduler and _schedulerToolCallsUpdate will set InputRequired if needed. + // No direct state change here, scheduler drives it. + break; + case GeminiEventType.UserCancelled: + logger.info('[Task] Received user cancelled event from LLM stream.'); + this.cancelPendingTools('User cancelled via LLM stream event'); + this.setTaskStateAndPublishUpdate( + 'input-required', + stateChange, + 'Task cancelled by user', + undefined, + true, + undefined, + traceId, + ); + break; + case GeminiEventType.Thought: + logger.info('[Task] Sending agent thought...'); + this._sendThought(event.value, traceId); + break; + case GeminiEventType.Citation: + logger.info('[Task] Received citation from LLM stream.'); + this._sendCitation(event.value); + break; + case GeminiEventType.ChatCompressed: + break; + case GeminiEventType.Finished: + logger.info(`[Task ${this.id}] Agent finished its turn.`); + // Capture the usage metadata when the stream finishes + if ( + event.value && + typeof event.value === 'object' && + 'usageMetadata' in event.value + ) { + this.usageMetadata = event.value + .usageMetadata as typeof this.usageMetadata; + } + break; + case GeminiEventType.ModelInfo: + this.usageMetadata = undefined; + this.modelInfo = event.value; + break; + case GeminiEventType.Retry: + case GeminiEventType.InvalidStream: + // An invalid stream should trigger a retry, which requires no action from the user. + break; + case GeminiEventType.Error: + default: { + // Use type guard instead of unsafe type assertion + let errorEvent: ServerGeminiErrorEvent | undefined; + if ( + event.type === GeminiEventType.Error && + event.value && + typeof event.value === 'object' && + 'error' in event.value + ) { + errorEvent = event; + } + const errorMessage = errorEvent?.value?.error + ? getErrorMessage(errorEvent.value.error) + : 'Unknown error from LLM stream'; + logger.error( + '[Task] Received error event from LLM stream:', + errorMessage, + ); + + let errMessage = `Unknown error from LLM stream: ${JSON.stringify(event)}`; + if (errorEvent?.value?.error) { + errMessage = parseAndFormatApiError(errorEvent.value.error); + } + this.cancelPendingTools(`LLM stream error: ${errorMessage}`); + this.setTaskStateAndPublishUpdate( + this.taskState, + stateChange, + `Agent Error, unknown agent message: ${errorMessage}`, + undefined, + false, + errMessage, + traceId, + ); + break; + } + } + } + + private async _handleToolConfirmationPart(part: Part): Promise { + if ( + part.kind !== 'data' || + !part.data || + // eslint-disable-next-line no-restricted-syntax + typeof part.data['callId'] !== 'string' || + // eslint-disable-next-line no-restricted-syntax + typeof part.data['outcome'] !== 'string' + ) { + return false; + } + if (!part.data['outcome']) { + return false; + } + + const callId = part.data['callId']; + const outcomeString = part.data['outcome']; + + this.toolsAlreadyConfirmed.add(callId); + this.toolUpdateEmitter.emit('update'); + + let confirmationOutcome: ToolConfirmationOutcome | undefined; + + if (outcomeString === 'proceed_once') { + confirmationOutcome = ToolConfirmationOutcome.ProceedOnce; + } else if (outcomeString === 'cancel') { + confirmationOutcome = ToolConfirmationOutcome.Cancel; + } else if (outcomeString === 'proceed_always') { + confirmationOutcome = ToolConfirmationOutcome.ProceedAlways; + } else if (outcomeString === 'proceed_always_server') { + confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysServer; + } else if (outcomeString === 'proceed_always_tool') { + confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysTool; + } else if (outcomeString === 'proceed_always_and_save') { + confirmationOutcome = ToolConfirmationOutcome.ProceedAlwaysAndSave; + } else if (outcomeString === 'modify_with_editor') { + confirmationOutcome = ToolConfirmationOutcome.ModifyWithEditor; + } else { + logger.warn( + `[Task] Unknown tool confirmation outcome: "${outcomeString}" for callId: ${callId}`, + ); + return false; + } + + const confirmationDetails = this.pendingToolConfirmationDetails.get(callId); + const correlationId = this.pendingCorrelationIds.get(callId); + + if (!confirmationDetails && !correlationId) { + logger.warn( + `[Task] Received tool confirmation for unknown or already processed callId: ${callId}`, + ); + return false; + } + + logger.info( + `[Task] Handling tool confirmation for callId: ${callId} with outcome: ${outcomeString}`, + ); + try { + // Temporarily unset GCP environment variables so they do not leak into + // tool calls. + const gcpProject = process.env['GOOGLE_CLOUD_PROJECT']; + const gcpCreds = process.env['GOOGLE_APPLICATION_CREDENTIALS']; + try { + delete process.env['GOOGLE_CLOUD_PROJECT']; + delete process.env['GOOGLE_APPLICATION_CREDENTIALS']; + + // This will trigger the scheduler to continue or cancel the specific tool. + // The scheduler's onToolCallsUpdate will then reflect the new state (e.g., executing or cancelled). + + // If `edit` tool call, pass updated payload if present + const newContent = part.data['newContent']; + const payload = + confirmationDetails?.type === 'edit' && typeof newContent === 'string' + ? ({ newContent } as ToolConfirmationPayload) + : undefined; + this.skipFinalTrueAfterInlineEdit = !!payload; + + try { + if (correlationId) { + const loopContext: AgentLoopContext = this.config; + await loopContext.messageBus.publish({ + type: MessageBusType.TOOL_CONFIRMATION_RESPONSE, + correlationId, + confirmed: + confirmationOutcome !== ToolConfirmationOutcome.Cancel && + confirmationOutcome !== + ToolConfirmationOutcome.ModifyWithEditor, + outcome: confirmationOutcome, + payload, + }); + } else if (confirmationDetails?.onConfirm) { + // Fallback for legacy callback-based confirmation + await confirmationDetails.onConfirm(confirmationOutcome, payload); + } + } finally { + // Once confirmation payload is sent or callback finishes, + // reset skipFinalTrueAfterInlineEdit so that external callers receive + // their call has been completed. + this.skipFinalTrueAfterInlineEdit = false; + } + } finally { + if (gcpProject) { + process.env['GOOGLE_CLOUD_PROJECT'] = gcpProject; + } + if (gcpCreds) { + process.env['GOOGLE_APPLICATION_CREDENTIALS'] = gcpCreds; + } + } + + // Do not delete if modifying, a subsequent tool confirmation for the same + // callId will be passed with ProceedOnce/Cancel/etc + // Note !== ToolConfirmationOutcome.ModifyWithEditor does not work! + if (confirmationOutcome !== 'modify_with_editor') { + this.pendingToolConfirmationDetails.delete(callId); + this.pendingCorrelationIds.delete(callId); + } + + // If outcome is Cancel, scheduler should update status to 'cancelled', which then resolves the tool. + // If ProceedOnce, scheduler updates to 'executing', then eventually 'success'/'error', which resolves. + return true; + } catch (error) { + logger.error( + `[Task] Error during tool confirmation for callId ${callId}:`, + error, + ); + // If confirming fails, we should probably mark this tool as failed + this._resolveToolCall(callId); // Resolve it as it won't proceed. + const errorMessageText = + error instanceof Error + ? error.message + : `Error processing tool confirmation for ${callId}`; + const message = this._createTextMessage(errorMessageText); + const toolCallUpdate: ToolCallUpdate = { + kind: CoderAgentEvent.ToolCallUpdateEvent, + }; + const event = this._createStatusUpdateEvent( + this.taskState, + toolCallUpdate, + message, + false, + ); + this.eventBus?.publish(event); + return false; + } + } + + getAndClearCompletedTools(): CompletedToolCall[] { + const tools = [...this.completedToolCalls]; + for (const tool of tools) { + this.processedToolCallIds.add(tool.request.callId); + } + this.completedToolCalls = []; + return tools; + } + + addToolResponsesToHistory(completedTools: CompletedToolCall[]): void { + logger.info( + `[Task] Adding ${completedTools.length} tool responses to history without generating a new response.`, + ); + const parts: genAiPart[] = []; + for (const toolCall of completedTools) { + const response = toolCall.response?.responseParts; + if (!response) { + continue; + } + if (Array.isArray(response)) { + parts.push(...response); + } else if (typeof response === 'string') { + parts.push({ text: response }); + } else { + parts.push(response); + } + } + if (parts.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.geminiClient.addHistory({ + role: 'user', + parts, + }); + } + } + + async *sendCompletedToolsToLlm( + completedToolCalls: CompletedToolCall[], + aborted: AbortSignal, + ): AsyncGenerator { + if (completedToolCalls.length === 0) { + yield* (async function* () {})(); // Yield nothing + return; + } + + const llmParts: PartUnion[] = []; + logger.info( + `[Task] Feeding ${completedToolCalls.length} tool responses to LLM.`, + ); + for (const completedToolCall of completedToolCalls) { + logger.info( + `[Task] Adding tool response for "${completedToolCall.request.name}" (callId: ${completedToolCall.request.callId}) to LLM input.`, + ); + const responseParts = completedToolCall.response.responseParts; + if (Array.isArray(responseParts)) { + llmParts.push(...responseParts); + } else { + llmParts.push(responseParts); + } + } + + logger.info('[Task] Sending new parts to agent.'); + const stateChange: StateChange = { + kind: CoderAgentEvent.StateChangeEvent, + }; + // Set task state to working as we are about to call LLM + this.setTaskStateAndPublishUpdate('working', stateChange); + this.currentAgentMessageId = uuidv4(); + yield* this.geminiClient.sendMessageStream( + llmParts, + aborted, + completedToolCalls[0]?.request.prompt_id ?? '', + ); + } + + async *acceptUserMessage( + requestContext: RequestContext, + aborted: AbortSignal, + ): AsyncGenerator { + // Clear any stale cancellation error from a previous aborted execution loop + this.cancellationError = undefined; + + const userMessage = requestContext.userMessage; + const llmParts: PartUnion[] = []; + let anyConfirmationHandled = false; + let hasContentForLlm = false; + + for (const part of userMessage.parts) { + const confirmationHandled = await this._handleToolConfirmationPart(part); + if (confirmationHandled) { + anyConfirmationHandled = true; + // If a confirmation was handled, the scheduler will now run the tool (or cancel it). + // We don't send anything to the LLM for this part. + // The subsequent tool execution will eventually lead to resolveToolCall. + continue; + } + + if (part.kind === 'text') { + llmParts.push({ text: part.text }); + hasContentForLlm = true; + } + } + + if (hasContentForLlm) { + this.currentPromptId = + this.config.getSessionId() + '########' + this.promptCount++; + this.currentAgentMessageId = uuidv4(); + logger.info('[Task] Sending new parts to LLM.'); + const stateChange: StateChange = { + kind: CoderAgentEvent.StateChangeEvent, + }; + // Set task state to working as we are about to call LLM + this.setTaskStateAndPublishUpdate('working', stateChange); + yield* this.geminiClient.sendMessageStream( + llmParts, + aborted, + this.currentPromptId, + ); + } else if (anyConfirmationHandled) { + logger.info( + '[Task] User message only contained tool confirmations. Scheduler is active. No new input for LLM this turn.', + ); + // Ensure task state reflects that scheduler might be working due to confirmation. + // If scheduler is active, it will emit its own status updates. + // If all pending tools were just confirmed, waitForPendingTools will handle the wait. + // If some tools are still pending approval, scheduler would have set InputRequired. + // If not, and no new text, we are just waiting. + if ( + this.pendingToolCalls.size > 0 && + this.taskState !== 'input-required' + ) { + const stateChange: StateChange = { + kind: CoderAgentEvent.StateChangeEvent, + }; + this.setTaskStateAndPublishUpdate('working', stateChange); // Reflect potential background activity + } + yield* (async function* () {})(); // Yield nothing + } else { + logger.info( + '[Task] No relevant parts in user message for LLM interaction or tool confirmation.', + ); + // If there's no new text and no confirmations, and no pending tools, + // it implies we might need to signal input required if nothing else is happening. + // However, the agent.ts will make this determination after waitForPendingTools. + yield* (async function* () {})(); // Yield nothing + } + } + + _sendTextContent(content: string, traceId?: string): void { + if (content === '') { + return; + } + const message = this._createTextMessage(content); + const textContent: TextContent = { + kind: CoderAgentEvent.TextContentEvent, + }; + this.eventBus?.publish( + this._createStatusUpdateEvent( + this.taskState, + textContent, + message, + false, + undefined, + undefined, + traceId, + ), + ); + } + + _sendThought(content: ThoughtSummary, traceId?: string): void { + if (!content.subject && !content.description) { + return; + } + logger.info('[Task] Sending thought to event bus.'); + const message: Message = { + kind: 'message', + role: 'agent', + parts: [ + { + kind: 'data', + data: content, + } as Part, + ], + messageId: this.currentAgentMessageId, + taskId: this.id, + contextId: this.contextId, + }; + const thought: Thought = { + kind: CoderAgentEvent.ThoughtEvent, + }; + this.eventBus?.publish( + this._createStatusUpdateEvent( + this.taskState, + thought, + message, + false, + undefined, + undefined, + traceId, + ), + ); + } + + _sendCitation(citation: string) { + if (!citation || citation.trim() === '') { + return; + } + logger.info('[Task] Sending citation to event bus.'); + const message = this._createTextMessage(citation); + const citationEvent: Citation = { + kind: CoderAgentEvent.CitationEvent, + }; + this.eventBus?.publish( + this._createStatusUpdateEvent(this.taskState, citationEvent, message), + ); + } +} diff --git a/packages/a2a-server/src/commands/command-registry.test.ts b/packages/a2a-server/src/commands/command-registry.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a6b1e809115e001c8e2c7bbe8d05b526f5f05d2 --- /dev/null +++ b/packages/a2a-server/src/commands/command-registry.test.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { Command } from './types.js'; + +const { + mockExtensionsCommand, + mockListExtensionsCommand, + mockExtensionsCommandInstance, + mockListExtensionsCommandInstance, +} = vi.hoisted(() => { + const listInstance: Command = { + name: 'extensions list', + description: 'Lists all installed extensions.', + execute: vi.fn(), + }; + + const extInstance: Command = { + name: 'extensions', + description: 'Manage extensions.', + execute: vi.fn(), + subCommands: [listInstance], + }; + + return { + mockListExtensionsCommandInstance: listInstance, + mockExtensionsCommandInstance: extInstance, + mockExtensionsCommand: vi.fn(() => extInstance), + mockListExtensionsCommand: vi.fn(() => listInstance), + }; +}); + +vi.mock('./extensions.js', () => ({ + ExtensionsCommand: mockExtensionsCommand, + ListExtensionsCommand: mockListExtensionsCommand, +})); + +vi.mock('./init.js', () => ({ + InitCommand: vi.fn(() => ({ + name: 'init', + description: 'Initializes the server.', + execute: vi.fn(), + })), +})); + +vi.mock('./restore.js', () => ({ + RestoreCommand: vi.fn(() => ({ + name: 'restore', + description: 'Restores the server.', + execute: vi.fn(), + })), +})); + +import { commandRegistry } from './command-registry.js'; + +describe('CommandRegistry', () => { + beforeEach(async () => { + vi.clearAllMocks(); + commandRegistry.initialize(); + }); + + it('should register ExtensionsCommand on initialization', async () => { + expect(mockExtensionsCommand).toHaveBeenCalled(); + const command = commandRegistry.get('extensions'); + expect(command).toBe(mockExtensionsCommandInstance); + }, 20000); + + it('should register sub commands on initialization', async () => { + const command = commandRegistry.get('extensions list'); + expect(command).toBe(mockListExtensionsCommandInstance); + }); + + it('get() should return undefined for a non-existent command', async () => { + const command = commandRegistry.get('non-existent'); + expect(command).toBeUndefined(); + }); + + it('register() should register a new command', async () => { + const mockCommand: Command = { + name: 'test-command', + description: '', + execute: vi.fn(), + }; + commandRegistry.register(mockCommand); + const command = commandRegistry.get('test-command'); + expect(command).toBe(mockCommand); + }); + + it('register() should register a nested command', async () => { + const mockSubSubCommand: Command = { + name: 'test-command-sub-sub', + description: '', + execute: vi.fn(), + }; + const mockSubCommand: Command = { + name: 'test-command-sub', + description: '', + execute: vi.fn(), + subCommands: [mockSubSubCommand], + }; + const mockCommand: Command = { + name: 'test-command', + description: '', + execute: vi.fn(), + subCommands: [mockSubCommand], + }; + commandRegistry.register(mockCommand); + + const command = commandRegistry.get('test-command'); + const subCommand = commandRegistry.get('test-command-sub'); + const subSubCommand = commandRegistry.get('test-command-sub-sub'); + + expect(command).toBe(mockCommand); + expect(subCommand).toBe(mockSubCommand); + expect(subSubCommand).toBe(mockSubSubCommand); + }); + + it('register() should not enter an infinite loop with a cyclic command', async () => { + const { debugLogger } = await import('@google/gemini-cli-core'); + const warnSpy = vi.spyOn(debugLogger, 'warn').mockImplementation(() => {}); + const mockCommand: Command = { + name: 'cyclic-command', + description: '', + subCommands: [], + execute: vi.fn(), + }; + + mockCommand.subCommands?.push(mockCommand); // Create cycle + + commandRegistry.register(mockCommand); + + expect(commandRegistry.get('cyclic-command')).toBe(mockCommand); + expect(warnSpy).toHaveBeenCalledWith( + 'Command cyclic-command already registered. Skipping.', + ); + warnSpy.mockRestore(); + }); +}); diff --git a/packages/a2a-server/src/commands/command-registry.ts b/packages/a2a-server/src/commands/command-registry.ts new file mode 100644 index 0000000000000000000000000000000000000000..e9cd75b11a1791bdebec7025227eb827d4ede8f5 --- /dev/null +++ b/packages/a2a-server/src/commands/command-registry.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MemoryCommand } from './memory.js'; +import { debugLogger } from '@google/gemini-cli-core'; +import { ExtensionsCommand } from './extensions.js'; +import { InitCommand } from './init.js'; +import { RestoreCommand } from './restore.js'; +import type { Command } from './types.js'; + +export class CommandRegistry { + private readonly commands = new Map(); + + constructor() { + this.initialize(); + } + + initialize() { + this.commands.clear(); + this.register(new ExtensionsCommand()); + this.register(new RestoreCommand()); + this.register(new InitCommand()); + this.register(new MemoryCommand()); + } + + register(command: Command) { + if (this.commands.has(command.name)) { + debugLogger.warn(`Command ${command.name} already registered. Skipping.`); + return; + } + + this.commands.set(command.name, command); + + for (const subCommand of command.subCommands ?? []) { + this.register(subCommand); + } + } + + get(commandName: string): Command | undefined { + return this.commands.get(commandName); + } + + getAllCommands(): Command[] { + return [...this.commands.values()]; + } +} + +export const commandRegistry = new CommandRegistry(); diff --git a/packages/a2a-server/src/commands/extensions.test.ts b/packages/a2a-server/src/commands/extensions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..611c51b161af7c5723f33292fca2fb08adfd3487 --- /dev/null +++ b/packages/a2a-server/src/commands/extensions.test.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { ExtensionsCommand, ListExtensionsCommand } from './extensions.js'; +import type { CommandContext } from './types.js'; + +const mockListExtensions = vi.hoisted(() => vi.fn()); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + + return { + ...original, + listExtensions: mockListExtensions, + }; +}); + +describe('ExtensionsCommand', () => { + it('should have the correct name', () => { + const command = new ExtensionsCommand(); + expect(command.name).toEqual('extensions'); + }); + + it('should have the correct description', () => { + const command = new ExtensionsCommand(); + expect(command.description).toEqual('Manage extensions.'); + }); + + it('should have "extensions list" as a subcommand', () => { + const command = new ExtensionsCommand(); + expect(command.subCommands.map((c) => c.name)).toContain('extensions list'); + }); + + it('should be a top-level command', () => { + const command = new ExtensionsCommand(); + expect(command.topLevel).toBe(true); + }); + + it('should default to listing extensions', async () => { + const command = new ExtensionsCommand(); + const mockConfig = { config: {} } as CommandContext; + const mockExtensions = [{ name: 'ext1' }]; + mockListExtensions.mockReturnValue(mockExtensions); + + const result = await command.execute(mockConfig, []); + + expect(result).toEqual({ name: 'extensions list', data: mockExtensions }); + expect(mockListExtensions).toHaveBeenCalledWith(mockConfig.config); + }); +}); + +describe('ListExtensionsCommand', () => { + it('should have the correct name', () => { + const command = new ListExtensionsCommand(); + expect(command.name).toEqual('extensions list'); + }); + + it('should call listExtensions with the provided config', async () => { + const command = new ListExtensionsCommand(); + const mockConfig = { config: {} } as CommandContext; + const mockExtensions = [{ name: 'ext1' }]; + mockListExtensions.mockReturnValue(mockExtensions); + + const result = await command.execute(mockConfig, []); + + expect(result).toEqual({ name: 'extensions list', data: mockExtensions }); + expect(mockListExtensions).toHaveBeenCalledWith(mockConfig.config); + }); + + it('should return a message when no extensions are installed', async () => { + const command = new ListExtensionsCommand(); + const mockConfig = { config: {} } as CommandContext; + mockListExtensions.mockReturnValue([]); + + const result = await command.execute(mockConfig, []); + + expect(result).toEqual({ + name: 'extensions list', + data: 'No extensions installed.', + }); + expect(mockListExtensions).toHaveBeenCalledWith(mockConfig.config); + }); +}); diff --git a/packages/a2a-server/src/commands/extensions.ts b/packages/a2a-server/src/commands/extensions.ts new file mode 100644 index 0000000000000000000000000000000000000000..27149eb3c76b45241610fffca3f6fd4adf53dcdc --- /dev/null +++ b/packages/a2a-server/src/commands/extensions.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { listExtensions } from '@google/gemini-cli-core'; +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; + +export class ExtensionsCommand implements Command { + readonly name = 'extensions'; + readonly description = 'Manage extensions.'; + readonly subCommands = [new ListExtensionsCommand()]; + readonly topLevel = true; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + return new ListExtensionsCommand().execute(context, _); + } +} + +export class ListExtensionsCommand implements Command { + readonly name = 'extensions list'; + readonly description = 'Lists all installed extensions.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const extensions = listExtensions(context.config); + const data = extensions.length ? extensions : 'No extensions installed.'; + + return { name: this.name, data }; + } +} diff --git a/packages/a2a-server/src/commands/init.test.ts b/packages/a2a-server/src/commands/init.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6280424fd7b12f0345420cb3eedecf0acc9bd580 --- /dev/null +++ b/packages/a2a-server/src/commands/init.test.ts @@ -0,0 +1,193 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { InitCommand } from './init.js'; +import { + performInit, + type CommandActionReturn, + type Config, +} from '@google/gemini-cli-core'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { CoderAgentExecutor } from '../agent/executor.js'; +import { CoderAgentEvent } from '../types.js'; +import type { ExecutionEventBus } from '@a2a-js/sdk/server'; +import { createMockConfig } from '../utils/testing_utils.js'; +import type { CommandContext } from './types.js'; +import { logger } from '../utils/logger.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + performInit: vi.fn(), + }; +}); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + existsSync: vi.fn(), + writeFileSync: vi.fn(), + }; +}); + +vi.mock('../agent/executor.js', () => ({ + CoderAgentExecutor: vi.fn().mockImplementation(() => ({ + execute: vi.fn(), + })), +})); + +vi.mock('../utils/logger.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + }, +})); + +describe('InitCommand', () => { + let eventBus: ExecutionEventBus; + let command: InitCommand; + let context: CommandContext; + let publishSpy: ReturnType; + let mockExecute: ReturnType; + const mockWorkspacePath = path.resolve('/tmp'); + + beforeEach(() => { + vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', mockWorkspacePath); + eventBus = { + publish: vi.fn(), + } as unknown as ExecutionEventBus; + command = new InitCommand(); + const mockConfig = createMockConfig({ + getModel: () => 'gemini-pro', + }); + const mockExecutorInstance = new CoderAgentExecutor(); + context = { + config: mockConfig as unknown as Config, + agentExecutor: mockExecutorInstance, + eventBus, + } as CommandContext; + publishSpy = vi.spyOn(eventBus, 'publish'); + mockExecute = vi.fn(); + vi.spyOn(mockExecutorInstance, 'execute').mockImplementation(mockExecute); + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('has requiresWorkspace set to true', () => { + expect(command.requiresWorkspace).toBe(true); + }); + + describe('execute', () => { + it('handles info from performInit', async () => { + vi.mocked(performInit).mockReturnValue({ + type: 'message', + messageType: 'info', + content: 'GEMINI.md already exists.', + } as CommandActionReturn); + + await command.execute(context, []); + + expect(logger.info).toHaveBeenCalledWith( + '[EventBus event]: ', + expect.objectContaining({ + kind: 'status-update', + status: expect.objectContaining({ + state: 'completed', + message: expect.objectContaining({ + parts: [{ kind: 'text', text: 'GEMINI.md already exists.' }], + }), + }), + }), + ); + + expect(publishSpy).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'status-update', + status: expect.objectContaining({ + state: 'completed', + message: expect.objectContaining({ + parts: [{ kind: 'text', text: 'GEMINI.md already exists.' }], + }), + }), + }), + ); + }); + + it('handles error from performInit', async () => { + vi.mocked(performInit).mockReturnValue({ + type: 'message', + messageType: 'error', + content: 'An error occurred.', + } as CommandActionReturn); + + await command.execute(context, []); + + expect(publishSpy).toHaveBeenCalledWith( + expect.objectContaining({ + kind: 'status-update', + status: expect.objectContaining({ + state: 'failed', + message: expect.objectContaining({ + parts: [{ kind: 'text', text: 'An error occurred.' }], + }), + }), + }), + ); + }); + + describe('when handling submit_prompt', () => { + beforeEach(() => { + vi.mocked(performInit).mockReturnValue({ + type: 'submit_prompt', + content: 'Create a new GEMINI.md file.', + } as CommandActionReturn); + }); + + it('writes the file and executes the agent', async () => { + await command.execute(context, []); + + expect(fs.writeFileSync).toHaveBeenCalledWith( + path.join(mockWorkspacePath, 'GEMINI.md'), + '', + 'utf8', + ); + expect(mockExecute).toHaveBeenCalled(); + }); + + it('passes autoExecute to the agent executor', async () => { + await command.execute(context, []); + + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + userMessage: expect.objectContaining({ + parts: expect.arrayContaining([ + expect.objectContaining({ + text: 'Create a new GEMINI.md file.', + }), + ]), + metadata: { + coderAgent: { + kind: CoderAgentEvent.StateAgentSettingsEvent, + workspacePath: mockWorkspacePath, + autoExecute: true, + }, + }, + }), + }), + eventBus, + ); + }); + }); + }); +}); diff --git a/packages/a2a-server/src/commands/init.ts b/packages/a2a-server/src/commands/init.ts new file mode 100644 index 0000000000000000000000000000000000000000..57697e1a241022630020eadc44f4a38bce9d7cb1 --- /dev/null +++ b/packages/a2a-server/src/commands/init.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { CoderAgentEvent, type AgentSettings } from '../types.js'; +import { performInit } from '@google/gemini-cli-core'; +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; +import type { CoderAgentExecutor } from '../agent/executor.js'; +import type { + ExecutionEventBus, + RequestContext, + AgentExecutionEvent, +} from '@a2a-js/sdk/server'; +import { v4 as uuidv4 } from 'uuid'; +import { logger } from '../utils/logger.js'; + +export class InitCommand implements Command { + name = 'init'; + description = 'Analyzes the project and creates a tailored GEMINI.md file'; + requiresWorkspace = true; + streaming = true; + + private handleMessageResult( + result: { content: string; messageType: 'info' | 'error' }, + context: CommandContext, + eventBus: ExecutionEventBus, + taskId: string, + contextId: string, + ): CommandExecutionResponse { + const statusState = result.messageType === 'error' ? 'failed' : 'completed'; + const eventType = + result.messageType === 'error' + ? CoderAgentEvent.StateChangeEvent + : CoderAgentEvent.TextContentEvent; + + const event: AgentExecutionEvent = { + kind: 'status-update', + taskId, + contextId, + status: { + state: statusState, + message: { + kind: 'message', + role: 'agent', + parts: [{ kind: 'text', text: result.content }], + messageId: uuidv4(), + taskId, + contextId, + }, + timestamp: new Date().toISOString(), + }, + final: true, + metadata: { + coderAgent: { kind: eventType }, + model: context.config.getModel(), + }, + }; + + logger.info('[EventBus event]: ', event); + eventBus.publish(event); + return { + name: this.name, + data: result, + }; + } + + private async handleSubmitPromptResult( + result: { content: unknown }, + context: CommandContext, + geminiMdPath: string, + eventBus: ExecutionEventBus, + taskId: string, + contextId: string, + ): Promise { + fs.writeFileSync(geminiMdPath, '', 'utf8'); + + if (!context.agentExecutor) { + throw new Error('Agent executor not found in context.'); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const agentExecutor = context.agentExecutor as CoderAgentExecutor; + + const agentSettings: AgentSettings = { + kind: CoderAgentEvent.StateAgentSettingsEvent, + workspacePath: process.env['CODER_AGENT_WORKSPACE_PATH']!, + autoExecute: true, + }; + + if (typeof result.content !== 'string') { + throw new Error('Init command content must be a string.'); + } + const promptText = result.content; + + const requestContext: RequestContext = { + userMessage: { + kind: 'message', + role: 'user', + parts: [{ kind: 'text', text: promptText }], + messageId: uuidv4(), + taskId, + contextId, + metadata: { + coderAgent: agentSettings, + }, + }, + taskId, + contextId, + }; + + // The executor will handle the entire agentic loop, including + // creating the task, streaming responses, and handling tools. + await agentExecutor.execute(requestContext, eventBus); + return { + name: this.name, + data: geminiMdPath, + }; + } + + async execute( + context: CommandContext, + _args: string[] = [], + ): Promise { + if (!context.eventBus) { + return { + name: this.name, + data: 'Use executeStream to get streaming results.', + }; + } + + const geminiMdPath = path.join( + process.env['CODER_AGENT_WORKSPACE_PATH']!, + 'GEMINI.md', + ); + const result = performInit(fs.existsSync(geminiMdPath)); + + const taskId = uuidv4(); + const contextId = uuidv4(); + + switch (result.type) { + case 'message': + return this.handleMessageResult( + result, + context, + context.eventBus, + taskId, + contextId, + ); + case 'submit_prompt': + return this.handleSubmitPromptResult( + result, + context, + geminiMdPath, + context.eventBus, + taskId, + contextId, + ); + default: + throw new Error('Unknown result type from performInit'); + } + } +} diff --git a/packages/a2a-server/src/commands/memory.test.ts b/packages/a2a-server/src/commands/memory.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0edcf8ef436b96a1e040c6253336110b7846631c --- /dev/null +++ b/packages/a2a-server/src/commands/memory.test.ts @@ -0,0 +1,114 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + listMemoryFiles, + refreshMemory, + showMemory, + type Config, +} from '@google/gemini-cli-core'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + ListMemoryCommand, + MemoryCommand, + RefreshMemoryCommand, + ShowMemoryCommand, +} from './memory.js'; +import type { CommandContext } from './types.js'; + +// Mock the core functions +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + showMemory: vi.fn(), + refreshMemory: vi.fn(), + listMemoryFiles: vi.fn(), + }; +}); + +const mockShowMemory = vi.mocked(showMemory); +const mockRefreshMemory = vi.mocked(refreshMemory); +const mockListMemoryFiles = vi.mocked(listMemoryFiles); + +describe('a2a-server memory commands', () => { + let mockContext: CommandContext; + let mockConfig: Config; + + beforeEach(() => { + mockConfig = {} as unknown as Config; + + mockContext = { + config: mockConfig, + }; + }); + + describe('MemoryCommand', () => { + it('delegates to ShowMemoryCommand', async () => { + const command = new MemoryCommand(); + mockShowMemory.mockReturnValue({ + type: 'message', + messageType: 'info', + content: 'showing memory', + }); + const response = await command.execute(mockContext, []); + expect(response.data).toBe('showing memory'); + expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config); + }); + }); + + describe('ShowMemoryCommand', () => { + it('executes showMemory and returns the content', async () => { + const command = new ShowMemoryCommand(); + mockShowMemory.mockReturnValue({ + type: 'message', + messageType: 'info', + content: 'test memory content', + }); + + const response = await command.execute(mockContext, []); + + expect(mockShowMemory).toHaveBeenCalledWith(mockContext.config); + expect(response.name).toBe('memory show'); + expect(response.data).toBe('test memory content'); + }); + }); + + describe('RefreshMemoryCommand', () => { + it('executes refreshMemory and returns the content', async () => { + const command = new RefreshMemoryCommand(); + mockRefreshMemory.mockResolvedValue({ + type: 'message', + messageType: 'info', + content: 'memory refreshed', + }); + + const response = await command.execute(mockContext, []); + + expect(mockRefreshMemory).toHaveBeenCalledWith(mockContext.config); + expect(response.name).toBe('memory refresh'); + expect(response.data).toBe('memory refreshed'); + }); + }); + + describe('ListMemoryCommand', () => { + it('executes listMemoryFiles and returns the content', async () => { + const command = new ListMemoryCommand(); + mockListMemoryFiles.mockReturnValue({ + type: 'message', + messageType: 'info', + content: 'file1.md\nfile2.md', + }); + + const response = await command.execute(mockContext, []); + + expect(mockListMemoryFiles).toHaveBeenCalledWith(mockContext.config); + expect(response.name).toBe('memory list'); + expect(response.data).toBe('file1.md\nfile2.md'); + }); + }); +}); diff --git a/packages/a2a-server/src/commands/memory.ts b/packages/a2a-server/src/commands/memory.ts new file mode 100644 index 0000000000000000000000000000000000000000..628669a7e6815b6697f7d8ad5e41d49cc53e9b6c --- /dev/null +++ b/packages/a2a-server/src/commands/memory.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + listMemoryFiles, + refreshMemory, + showMemory, +} from '@google/gemini-cli-core'; +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; + +export class MemoryCommand implements Command { + readonly name = 'memory'; + readonly description = 'Manage memory.'; + readonly subCommands = [ + new ShowMemoryCommand(), + new RefreshMemoryCommand(), + new ListMemoryCommand(), + ]; + readonly topLevel = true; + readonly requiresWorkspace = true; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + return new ShowMemoryCommand().execute(context, _); + } +} + +export class ShowMemoryCommand implements Command { + readonly name = 'memory show'; + readonly description = 'Shows the current memory contents.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const result = showMemory(context.config); + return { name: this.name, data: result.content }; + } +} + +export class RefreshMemoryCommand implements Command { + readonly name = 'memory refresh'; + readonly description = 'Refreshes the memory from the source.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const result = await refreshMemory(context.config); + return { name: this.name, data: result.content }; + } +} + +export class ListMemoryCommand implements Command { + readonly name = 'memory list'; + readonly description = 'Lists the paths of the GEMINI.md files in use.'; + + async execute( + context: CommandContext, + _: string[], + ): Promise { + const result = listMemoryFiles(context.config); + return { name: this.name, data: result.content }; + } +} diff --git a/packages/a2a-server/src/commands/restore.test.ts b/packages/a2a-server/src/commands/restore.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a655f36a744d2b62456c7df1d79ceca0d0d49da4 --- /dev/null +++ b/packages/a2a-server/src/commands/restore.test.ts @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { RestoreCommand, ListCheckpointsCommand } from './restore.js'; +import type { CommandContext } from './types.js'; +import type { Config } from '@google/gemini-cli-core'; +import { createMockConfig } from '../utils/testing_utils.js'; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +const mockPerformRestore = vi.hoisted(() => vi.fn()); +const mockLoggerInfo = vi.hoisted(() => vi.fn()); +const mockGetCheckpointInfoList = vi.hoisted(() => vi.fn()); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + performRestore: mockPerformRestore, + getCheckpointInfoList: mockGetCheckpointInfoList, + }; +}); + +const mockFs = vi.hoisted(() => ({ + readFile: vi.fn(), + readdir: vi.fn(), + mkdir: vi.fn(), +})); + +vi.mock('node:fs/promises', () => mockFs); + +vi.mock('../utils/logger.js', () => ({ + logger: { + info: mockLoggerInfo, + }, +})); + +describe('RestoreCommand', () => { + const mockConfig = { + config: createMockConfig() as Config, + git: {}, + } as CommandContext; + + it('should return error if no checkpoint name is provided', async () => { + const command = new RestoreCommand(); + const result = await command.execute(mockConfig, []); + expect(result.data).toEqual({ + type: 'message', + messageType: 'error', + content: 'Please provide a checkpoint name to restore.', + }); + }); + + it('should restore a checkpoint when a valid file is provided', async () => { + const command = new RestoreCommand(); + const toolCallData = { + toolCall: { + name: 'test-tool', + args: {}, + }, + history: [], + clientHistory: [], + commitHash: '123', + }; + mockFs.readFile.mockResolvedValue(JSON.stringify(toolCallData)); + const restoreContent = { + type: 'message', + messageType: 'info', + content: 'Restored', + }; + mockPerformRestore.mockReturnValue( + (async function* () { + yield restoreContent; + })(), + ); + const result = await command.execute(mockConfig, ['checkpoint1.json']); + expect(result.data).toEqual([restoreContent]); + }); + + it('should show "file not found" error for a non-existent checkpoint', async () => { + const command = new RestoreCommand(); + const error = new Error('File not found'); + (error as NodeJS.ErrnoException).code = 'ENOENT'; + mockFs.readFile.mockRejectedValue(error); + const result = await command.execute(mockConfig, ['checkpoint2.json']); + expect(result.data).toEqual({ + type: 'message', + messageType: 'error', + content: 'File not found: checkpoint2.json', + }); + }); + + it('should handle invalid JSON in checkpoint file', async () => { + const command = new RestoreCommand(); + mockFs.readFile.mockResolvedValue('invalid json'); + const result = await command.execute(mockConfig, ['checkpoint1.json']); + expect((result.data as { content: string }).content).toContain( + 'An unexpected error occurred during restore.', + ); + }); +}); + +describe('ListCheckpointsCommand', () => { + const mockConfig = { + config: createMockConfig() as Config, + } as CommandContext; + + it('should list all available checkpoints', async () => { + const command = new ListCheckpointsCommand(); + const checkpointInfo = [{ file: 'checkpoint1.json', description: 'Test' }]; + mockFs.readdir.mockResolvedValue(['checkpoint1.json']); + mockFs.readFile.mockResolvedValue( + JSON.stringify({ toolCall: { name: 'Test', args: {} } }), + ); + mockGetCheckpointInfoList.mockReturnValue(checkpointInfo); + const result = await command.execute(mockConfig); + expect((result.data as { content: string }).content).toEqual( + JSON.stringify(checkpointInfo), + ); + }); + + it('should handle errors when listing checkpoints', async () => { + const command = new ListCheckpointsCommand(); + mockFs.readdir.mockRejectedValue(new Error('Read error')); + const result = await command.execute(mockConfig); + expect((result.data as { content: string }).content).toContain( + 'An unexpected error occurred while listing checkpoints.', + ); + }); +}); diff --git a/packages/a2a-server/src/commands/restore.ts b/packages/a2a-server/src/commands/restore.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a5205c66bf5bcd1732c3c7db0ca25a4b4d33afe --- /dev/null +++ b/packages/a2a-server/src/commands/restore.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + getCheckpointInfoList, + getToolCallDataSchema, + isNodeError, + performRestore, +} from '@google/gemini-cli-core'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { + Command, + CommandContext, + CommandExecutionResponse, +} from './types.js'; + +export class RestoreCommand implements Command { + readonly name = 'restore'; + readonly description = + 'Restore to a previous checkpoint, or list available checkpoints to restore. This will reset the conversation and file history to the state it was in when the checkpoint was created'; + readonly topLevel = true; + readonly requiresWorkspace = true; + readonly subCommands = [new ListCheckpointsCommand()]; + + async execute( + context: CommandContext, + args: string[], + ): Promise { + const { config, git: gitService } = context; + const argsStr = args.join(' '); + + try { + if (!argsStr) { + return { + name: this.name, + data: { + type: 'message', + messageType: 'error', + content: 'Please provide a checkpoint name to restore.', + }, + }; + } + + const selectedFile = argsStr.endsWith('.json') + ? argsStr + : `${argsStr}.json`; + + const checkpointDir = config.storage.getProjectTempCheckpointsDir(); + const filePath = path.join(checkpointDir, selectedFile); + + let data: string; + try { + data = await fs.readFile(filePath, 'utf-8'); + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return { + name: this.name, + data: { + type: 'message', + messageType: 'error', + content: `File not found: ${selectedFile}`, + }, + }; + } + throw error; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const toolCallData = JSON.parse(data); + const ToolCallDataSchema = getToolCallDataSchema(); + const parseResult = ToolCallDataSchema.safeParse(toolCallData); + + if (!parseResult.success) { + return { + name: this.name, + data: { + type: 'message', + messageType: 'error', + content: 'Checkpoint file is invalid or corrupted.', + }, + }; + } + + const restoreResultGenerator = performRestore( + parseResult.data, + gitService, + ); + const restoreResult = []; + for await (const result of restoreResultGenerator) { + restoreResult.push(result); + } + + return { + name: this.name, + data: restoreResult, + }; + } catch { + return { + name: this.name, + data: { + type: 'message', + messageType: 'error', + content: 'An unexpected error occurred during restore.', + }, + }; + } + } +} + +export class ListCheckpointsCommand implements Command { + readonly name = 'restore list'; + readonly description = 'Lists all available checkpoints.'; + readonly topLevel = false; + + async execute(context: CommandContext): Promise { + const { config } = context; + + try { + const checkpointDir = config.storage.getProjectTempCheckpointsDir(); + await fs.mkdir(checkpointDir, { recursive: true }); + const files = await fs.readdir(checkpointDir); + const jsonFiles = files.filter((file) => file.endsWith('.json')); + + const checkpointFiles = new Map(); + for (const file of jsonFiles) { + const filePath = path.join(checkpointDir, file); + const data = await fs.readFile(filePath, 'utf-8'); + checkpointFiles.set(file, data); + } + + const checkpointInfoList = getCheckpointInfoList(checkpointFiles); + + return { + name: this.name, + data: { + type: 'message', + messageType: 'info', + content: JSON.stringify(checkpointInfoList), + }, + }; + } catch { + return { + name: this.name, + data: { + type: 'message', + messageType: 'error', + content: 'An unexpected error occurred while listing checkpoints.', + }, + }; + } + } +} diff --git a/packages/a2a-server/src/commands/types.ts b/packages/a2a-server/src/commands/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..910515eadce9be6eae21da7c084680adde0d8aaa --- /dev/null +++ b/packages/a2a-server/src/commands/types.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { ExecutionEventBus, AgentExecutor } from '@a2a-js/sdk/server'; +import type { Config, GitService } from '@google/gemini-cli-core'; + +export interface CommandContext { + config: Config; + git?: GitService; + agentExecutor?: AgentExecutor; + eventBus?: ExecutionEventBus; +} + +export interface CommandArgument { + readonly name: string; + readonly description: string; + readonly isRequired?: boolean; +} + +export interface Command { + readonly name: string; + readonly description: string; + readonly arguments?: CommandArgument[]; + readonly subCommands?: Command[]; + readonly topLevel?: boolean; + readonly requiresWorkspace?: boolean; + readonly streaming?: boolean; + + execute( + config: CommandContext, + args: string[], + ): Promise; +} + +export interface CommandExecutionResponse { + readonly name: string; + readonly data: unknown; +} diff --git a/packages/a2a-server/src/config/config.test.ts b/packages/a2a-server/src/config/config.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b226e9895c21c35cd8a181b4c894ef58ee636cd8 --- /dev/null +++ b/packages/a2a-server/src/config/config.test.ts @@ -0,0 +1,585 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as path from 'node:path'; +import { loadConfig } from './config.js'; +import type { Settings } from './settings.js'; +import { + type ExtensionLoader, + getCodeAssistServer, + Config, + ExperimentFlags, + fetchAdminControlsOnce, + type FetchAdminControlsResponse, + AuthType, + isHeadlessMode, + FatalAuthenticationError, + PolicyDecision, + ApprovalMode, + PRIORITY_YOLO_ALLOW_ALL, + createPolicyEngineConfig, +} from '@google/gemini-cli-core'; +import type { AgentSettings } from '../types.js'; + +// Mock dependencies +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + PRIORITY_YOLO_ALLOW_ALL: 998, + Config: vi.fn().mockImplementation((params) => { + const mockConfig = { + ...params, + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + refreshAuth: vi.fn(), + getExperiments: vi.fn().mockReturnValue({ + flags: { + [actual.ExperimentFlags.ENABLE_ADMIN_CONTROLS]: { + boolValue: false, + }, + }, + }), + getRemoteAdminSettings: vi.fn(), + setRemoteAdminSettings: vi.fn(), + }; + return mockConfig; + }), + startupProfiler: { + flush: vi.fn(), + }, + isHeadlessMode: vi.fn().mockReturnValue(false), + getCodeAssistServer: vi.fn(), + fetchAdminControlsOnce: vi.fn(), + createPolicyEngineConfig: vi + .fn() + .mockImplementation( + (_settings, mode, _defaultPoliciesDir, _interactive) => ({ + rules: + mode === actual.ApprovalMode.YOLO + ? [ + { + toolName: '*', + decision: actual.PolicyDecision.ALLOW, + priority: actual.PRIORITY_YOLO_ALLOW_ALL, + modes: [actual.ApprovalMode.YOLO], + allowRedirection: true, + }, + ] + : [ + { + toolName: 'read_file', + decision: actual.PolicyDecision.ALLOW, + priority: 1.05, + source: 'Default: read-only.toml', + }, + ], + checkers: [], + }), + ), + coreEvents: { + emitAdminSettingsChanged: vi.fn(), + }, + }; +}); + +vi.mock('../utils/logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +describe('loadConfig', () => { + const mockSettings = {} as Settings; + const mockExtensionLoader = {} as ExtensionLoader; + const taskId = 'test-task-id'; + + beforeEach(() => { + vi.clearAllMocks(); + vi.stubEnv('GEMINI_API_KEY', 'test-key'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe('admin settings overrides', () => { + it('should not fetch admin controls if experiment is disabled', async () => { + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(fetchAdminControlsOnce).not.toHaveBeenCalled(); + }); + + it('should pass clientName as a2a-server to Config', async () => { + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + clientName: 'a2a-server', + }), + ); + }); + + describe('when admin controls experiment is enabled', () => { + beforeEach(() => { + // We need to cast to any here to modify the mock implementation + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (Config as any).mockImplementation((params: unknown) => { + const mockConfig = { + ...(params as object), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + refreshAuth: vi.fn(), + getExperiments: vi.fn().mockReturnValue({ + flags: { + [ExperimentFlags.ENABLE_ADMIN_CONTROLS]: { + boolValue: true, + }, + }, + }), + getRemoteAdminSettings: vi.fn().mockReturnValue({}), + setRemoteAdminSettings: vi.fn(), + }; + return mockConfig; + }); + }); + + it('should fetch admin controls and apply them', async () => { + const mockAdminSettings: FetchAdminControlsResponse = { + mcpSetting: { + mcpEnabled: false, + }, + cliFeatureSetting: { + extensionsSetting: { + extensionsEnabled: false, + }, + }, + strictModeDisabled: false, + }; + vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings); + + await loadConfig(mockSettings, mockExtensionLoader, taskId); + + expect(Config).toHaveBeenLastCalledWith( + expect.objectContaining({ + disableYoloMode: !mockAdminSettings.strictModeDisabled, + mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled, + extensionsEnabled: + mockAdminSettings.cliFeatureSetting?.extensionsSetting + ?.extensionsEnabled, + }), + ); + }); + + it('should treat unset admin settings as false when admin settings are passed', async () => { + const mockAdminSettings: FetchAdminControlsResponse = { + mcpSetting: { + mcpEnabled: true, + }, + }; + vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings); + + await loadConfig(mockSettings, mockExtensionLoader, taskId); + + expect(Config).toHaveBeenLastCalledWith( + expect.objectContaining({ + disableYoloMode: !false, + mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled, + extensionsEnabled: undefined, + }), + ); + }); + + it('should not pass default unset admin settings when no admin settings are present', async () => { + const mockAdminSettings: FetchAdminControlsResponse = {}; + vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings); + + await loadConfig(mockSettings, mockExtensionLoader, taskId); + + expect(Config).toHaveBeenLastCalledWith(expect.objectContaining({})); + }); + + it('should fetch admin controls using the code assist server when available', async () => { + const mockAdminSettings: FetchAdminControlsResponse = { + mcpSetting: { + mcpEnabled: true, + }, + strictModeDisabled: true, + }; + const mockCodeAssistServer = { projectId: 'test-project' }; + vi.mocked(getCodeAssistServer).mockReturnValue( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockCodeAssistServer as any, + ); + vi.mocked(fetchAdminControlsOnce).mockResolvedValue(mockAdminSettings); + + await loadConfig(mockSettings, mockExtensionLoader, taskId); + + expect(fetchAdminControlsOnce).toHaveBeenCalledWith( + mockCodeAssistServer, + true, + ); + expect(Config).toHaveBeenLastCalledWith( + expect.objectContaining({ + disableYoloMode: !mockAdminSettings.strictModeDisabled, + mcpEnabled: mockAdminSettings.mcpSetting?.mcpEnabled, + extensionsEnabled: undefined, + }), + ); + }); + }); + }); + + it('should set customIgnoreFilePaths when CUSTOM_IGNORE_FILE_PATHS env var is present', async () => { + const testPath = '/tmp/ignore'; + vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', testPath); + const config = await loadConfig(mockSettings, mockExtensionLoader, taskId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([ + testPath, + ]); + }); + + it('should set customIgnoreFilePaths when settings.fileFiltering.customIgnoreFilePaths is present', async () => { + const testPath = '/settings/ignore'; + const settings: Settings = { + fileFiltering: { + customIgnoreFilePaths: [testPath], + }, + }; + const config = await loadConfig(settings, mockExtensionLoader, taskId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([ + testPath, + ]); + }); + + it('should merge customIgnoreFilePaths from settings and env var', async () => { + const envPath = '/env/ignore'; + const settingsPath = '/settings/ignore'; + vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', envPath); + const settings: Settings = { + fileFiltering: { + customIgnoreFilePaths: [settingsPath], + }, + }; + const config = await loadConfig(settings, mockExtensionLoader, taskId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([ + settingsPath, + envPath, + ]); + }); + + it('should split CUSTOM_IGNORE_FILE_PATHS using system delimiter', async () => { + const paths = ['/path/one', '/path/two']; + vi.stubEnv('CUSTOM_IGNORE_FILE_PATHS', paths.join(path.delimiter)); + const config = await loadConfig(mockSettings, mockExtensionLoader, taskId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual(paths); + }); + + it('should have empty customIgnoreFilePaths when both are missing', async () => { + const config = await loadConfig(mockSettings, mockExtensionLoader, taskId); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect((config as any).fileFiltering.customIgnoreFilePaths).toEqual([]); + }); + + describe('policy engine configuration', () => { + it('should map tool settings into policySettings', async () => { + const settings: Settings = { + tools: { + allowed: ['v2-allowed'], + exclude: ['v2-exclude'], + core: ['v2-core'], + }, + mcpServers: { + test: { command: 'test', args: [] }, + }, + policyPaths: ['/path/to/policy'], + adminPolicyPaths: ['/path/to/admin/policy'], + }; + + await loadConfig(settings, mockExtensionLoader, taskId, true); + + expect(createPolicyEngineConfig).toHaveBeenCalledWith( + expect.objectContaining({ + tools: { + core: ['v2-core'], + exclude: ['v2-exclude'], + allowed: ['v2-allowed'], + }, + mcpServers: settings.mcpServers, + policyPaths: settings.policyPaths, + adminPolicyPaths: settings.adminPolicyPaths, + }), + ApprovalMode.DEFAULT, + undefined, + true, + ); + }); + }); + + describe('tool configuration', () => { + it('should pass V2 tools.allowed to Config properly', async () => { + const settings: Settings = { + tools: { + allowed: ['shell', 'fetch'], + }, + }; + await loadConfig(settings, mockExtensionLoader, taskId, true); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + allowedTools: ['shell', 'fetch'], + }), + ); + }); + + it('should pass enableAgents to Config constructor', async () => { + const settings: Settings = { + experimental: { + enableAgents: false, + }, + }; + await loadConfig(settings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + enableAgents: false, + }), + ); + }); + + it('should default enableAgents to true when not provided', async () => { + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + enableAgents: true, + }), + ); + }); + + describe('interactivity', () => { + it('should always set interactive true', async () => { + vi.mocked(isHeadlessMode).mockReturnValue(true); + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + interactive: true, + }), + ); + + vi.mocked(isHeadlessMode).mockReturnValue(false); + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + interactive: true, + }), + ); + }); + + it('should set enableInteractiveShell based on headless mode', async () => { + vi.mocked(isHeadlessMode).mockReturnValue(false); + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + enableInteractiveShell: true, + }), + ); + + vi.mocked(isHeadlessMode).mockReturnValue(true); + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + enableInteractiveShell: false, + }), + ); + }); + }); + + describe('YOLO mode', () => { + it('should enable YOLO mode and add policy rule when GEMINI_YOLO_MODE is true', async () => { + vi.stubEnv('GEMINI_YOLO_MODE', 'true'); + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + approvalMode: 'yolo', + policyEngineConfig: expect.objectContaining({ + rules: expect.arrayContaining([ + expect.objectContaining({ + decision: PolicyDecision.ALLOW, + priority: PRIORITY_YOLO_ALLOW_ALL, + modes: ['yolo'], + allowRedirection: true, + }), + ]), + }), + }), + ); + }); + + it('should use default approval mode and load default rules when GEMINI_YOLO_MODE is not true', async () => { + vi.stubEnv('GEMINI_YOLO_MODE', 'false'); + await loadConfig(mockSettings, mockExtensionLoader, taskId); + expect(Config).toHaveBeenCalledWith( + expect.objectContaining({ + approvalMode: 'default', + policyEngineConfig: expect.objectContaining({ + rules: expect.arrayContaining([ + expect.objectContaining({ + toolName: 'read_file', + decision: PolicyDecision.ALLOW, + }), + ]), + }), + }), + ); + }); + }); + + describe('authentication logic', () => { + const setupConfigMock = (refreshAuthMock: ReturnType) => { + vi.mocked(Config).mockImplementation( + (params: unknown) => + ({ + ...(params as object), + initialize: vi.fn(), + waitForMcpInit: vi.fn(), + refreshAuth: refreshAuthMock, + getExperiments: vi.fn().mockReturnValue({ flags: {} }), + getRemoteAdminSettings: vi.fn(), + setRemoteAdminSettings: vi.fn(), + }) as unknown as Config, + ); + }; + + beforeEach(() => { + vi.stubEnv('USE_CCPA', 'true'); + vi.stubEnv('GEMINI_API_KEY', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should attempt COMPUTE_ADC by default and bypass LOGIN_WITH_GOOGLE if successful', async () => { + const refreshAuthMock = vi.fn().mockResolvedValue(undefined); + setupConfigMock(refreshAuthMock); + + await loadConfig(mockSettings, mockExtensionLoader, taskId); + + expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); + expect(refreshAuthMock).not.toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + }); + + it('should fallback to LOGIN_WITH_GOOGLE if COMPUTE_ADC fails and interactive mode is available', async () => { + vi.mocked(isHeadlessMode).mockReturnValue(false); + const refreshAuthMock = vi.fn().mockImplementation((authType) => { + if (authType === AuthType.COMPUTE_ADC) { + return Promise.reject(new Error('ADC failed')); + } + return Promise.resolve(); + }); + setupConfigMock(refreshAuthMock); + + await loadConfig(mockSettings, mockExtensionLoader, taskId); + + expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); + expect(refreshAuthMock).toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + }); + + it('should throw FatalAuthenticationError in headless mode if COMPUTE_ADC fails', async () => { + vi.mocked(isHeadlessMode).mockReturnValue(true); + + const refreshAuthMock = vi.fn().mockImplementation((authType) => { + if (authType === AuthType.COMPUTE_ADC) { + return Promise.reject(new Error('ADC not found')); + } + return Promise.resolve(); + }); + setupConfigMock(refreshAuthMock); + + await expect( + loadConfig(mockSettings, mockExtensionLoader, taskId), + ).rejects.toThrow( + 'COMPUTE_ADC failed: ADC not found. (LOGIN_WITH_GOOGLE fallback skipped due to headless mode. Run in an interactive terminal to use OAuth.)', + ); + + expect(refreshAuthMock).toHaveBeenCalledWith(AuthType.COMPUTE_ADC); + expect(refreshAuthMock).not.toHaveBeenCalledWith( + AuthType.LOGIN_WITH_GOOGLE, + ); + }); + + it('should include both original and fallback error when LOGIN_WITH_GOOGLE fallback fails', async () => { + vi.mocked(isHeadlessMode).mockReturnValue(false); + + const refreshAuthMock = vi.fn().mockImplementation((authType) => { + if (authType === AuthType.COMPUTE_ADC) { + throw new Error('ADC failed'); + } + if (authType === AuthType.LOGIN_WITH_GOOGLE) { + throw new FatalAuthenticationError('OAuth failed'); + } + return Promise.resolve(); + }); + setupConfigMock(refreshAuthMock); + + await expect( + loadConfig(mockSettings, mockExtensionLoader, taskId), + ).rejects.toThrow( + 'OAuth failed. The initial COMPUTE_ADC attempt also failed: ADC failed', + ); + }); + }); + }); +}); + +describe('setIsTrusted', () => { + beforeEach(() => { + vi.resetModules(); + // Ensure GEMINI_CLI_TRUST_WORKSPACE is not set by default in tests to prevent leakage + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('should return agentSettings.isTrusted if defined, ignoring env vars', async () => { + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'false'); + const { setIsTrusted } = await import('./config.js'); + expect(setIsTrusted({ isTrusted: true } as AgentSettings)).toBe(true); + + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true'); + expect(setIsTrusted({ isTrusted: false } as AgentSettings)).toBe(false); + }); + + it('should return true when GEMINI_CLI_TRUST_WORKSPACE env var is true and agentSettings.isTrusted is undefined', async () => { + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true'); + const { setIsTrusted } = await import('./config.js'); + expect(setIsTrusted(undefined)).toBe(true); + expect(setIsTrusted({} as AgentSettings)).toBe(true); + }); + + it('should return false when GEMINI_CLI_TRUST_WORKSPACE env var is false and agentSettings.isTrusted is undefined', async () => { + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'false'); + const { setIsTrusted } = await import('./config.js'); + expect(setIsTrusted(undefined)).toBe(false); + expect(setIsTrusted({} as AgentSettings)).toBe(false); + }); + + it('should fallback to false if agentSettings.isTrusted and env var are undefined and no workspaceRoot is provided', async () => { + const { setIsTrusted } = await import('./config.js'); + expect(setIsTrusted(undefined)).toBe(false); + expect(setIsTrusted({} as AgentSettings)).toBe(false); + }); +}); diff --git a/packages/a2a-server/src/config/config.ts b/packages/a2a-server/src/config/config.ts new file mode 100644 index 0000000000000000000000000000000000000000..c08aaac2737722f3ba0bc91723ffda4359d5ba8a --- /dev/null +++ b/packages/a2a-server/src/config/config.ts @@ -0,0 +1,708 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as dotenv from 'dotenv'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +import { + AuthType, + Config, + ApprovalMode, + GEMINI_DIR, + DEFAULT_GEMINI_EMBEDDING_MODEL, + startupProfiler, + PREVIEW_GEMINI_MODEL, + homedir, + tmpdir, + GitService, + fetchAdminControlsOnce, + getCodeAssistServer, + ExperimentFlags, + isHeadlessMode, + FatalAuthenticationError, + createPolicyEngineConfig, + type PolicySettings, + type TelemetryTarget, + type ConfigParameters, + type ExtensionLoader, + resolveToRealPath, + checkPathTrust, +} from '@google/gemini-cli-core'; + +import { logger } from '../utils/logger.js'; +import { type Settings, loadSettings } from './settings.js'; +import { type AgentSettings, CoderAgentEvent } from '../types.js'; + +export const envStorage = new AsyncLocalStorage(); + +const deletedKeysSymbol = Symbol('deletedKeys'); +export const cwdSymbol = Symbol('cwd'); + +export interface TaskEnv extends Record { + [deletedKeysSymbol]?: Set; + [cwdSymbol]?: string; +} + +// Set up a Proxy on process.env to intercept reads and writes, isolating environment variables per task +const originalEnv = process.env; +const envProxy = new Proxy(originalEnv, { + get(target, prop) { + if (typeof prop === 'string') { + const taskEnv = envStorage.getStore(); + if (taskEnv) { + const deleted = taskEnv[deletedKeysSymbol]; + if (deleted?.has(prop)) { + return undefined; + } + if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) { + return taskEnv[prop]; + } + } + return target[prop]; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + return target[prop as any]; + }, + has(target, prop) { + if (typeof prop === 'string') { + const taskEnv = envStorage.getStore(); + if (taskEnv) { + const deleted = taskEnv[deletedKeysSymbol]; + if (deleted?.has(prop)) { + return false; + } + if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) { + return true; + } + } + return prop in target; + } + return prop in target; + }, + set(target, prop, value) { + if (typeof prop === 'string') { + if ( + prop === '__proto__' || + prop === 'constructor' || + prop === 'prototype' + ) { + return false; + } + const taskEnv = envStorage.getStore(); + if (taskEnv) { + taskEnv[deletedKeysSymbol]?.delete(prop); + taskEnv[prop] = String(value); + return true; + } + target[prop] = String(value); + return true; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-assignment + target[prop as any] = value; + return true; + }, + deleteProperty(target, prop) { + if (typeof prop === 'string') { + if ( + prop === '__proto__' || + prop === 'constructor' || + prop === 'prototype' + ) { + return false; + } + const taskEnv = envStorage.getStore(); + if (taskEnv) { + delete taskEnv[prop]; + (taskEnv[deletedKeysSymbol] ??= new Set()).add(prop); + return true; + } + delete target[prop]; + return true; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + return delete target[prop as any]; + }, + ownKeys(target) { + const taskEnv = envStorage.getStore(); + if (taskEnv) { + const keys = new Set([ + ...Object.getOwnPropertyNames(target), + ...Object.getOwnPropertySymbols(target), + ...Object.keys(taskEnv), + ]); + taskEnv[deletedKeysSymbol]?.forEach((key) => { + keys.delete(key); + }); + return Array.from(keys); + } + return [ + ...Object.getOwnPropertyNames(target), + ...Object.getOwnPropertySymbols(target), + ]; + }, + getOwnPropertyDescriptor(target, prop) { + const taskEnv = envStorage.getStore(); + if (taskEnv && typeof prop === 'string') { + const deleted = taskEnv[deletedKeysSymbol]; + if (deleted?.has(prop)) { + return undefined; + } + if (Object.prototype.hasOwnProperty.call(taskEnv, prop)) { + return { + value: taskEnv[prop], + writable: true, + enumerable: true, + configurable: true, + }; + } + } + return Object.getOwnPropertyDescriptor(target, prop); + }, + defineProperty(target, prop, descriptor) { + if (typeof prop === 'string') { + if ( + prop === '__proto__' || + prop === 'constructor' || + prop === 'prototype' + ) { + return false; + } + const taskEnv = envStorage.getStore(); + if (taskEnv) { + taskEnv[deletedKeysSymbol]?.delete(prop); + taskEnv[prop] = + descriptor.value !== undefined ? String(descriptor.value) : undefined; + return true; + } + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + Object.defineProperty(target, prop as any, descriptor); + return true; + }, +}); + +Object.defineProperty(process, 'env', { + value: envProxy, + writable: false, + configurable: true, +}); + +// NOTE: Monkey-patching process.cwd and process.chdir via AsyncLocalStorage is a robust way +// to simulate workspace isolation in a concurrent server. However, please be aware of a critical +// limitation: Node.js native C++ APIs (such as fs.readFileSync, fs.writeFile, etc.) and child +// process spawning APIs (like child_process.spawn) resolve relative paths using the OS-level +// working directory of the process, NOT the JS-level process.cwd() function. +// To prevent cross-task interference, all file paths in the core package must be resolved to +// absolute paths using path.resolve/path.join relative to config.getTargetDir() or config.getCwd() +// before being passed to native APIs. +const originalCwd = process.cwd; +process.cwd = function () { + const taskEnv = envStorage.getStore(); + if (taskEnv && taskEnv[cwdSymbol]) { + return taskEnv[cwdSymbol]; + } + return originalCwd.call(process); +}; + +const originalChdir = process.chdir; +process.chdir = function (directory: string) { + const taskEnv = envStorage.getStore(); + if (taskEnv) { + const resolved = path.resolve(process.cwd(), directory); + try { + const stats = fs.statSync(resolved); + if (!stats.isDirectory()) { + const err = new Error( + "ENOTDIR: not a directory, chdir '" + resolved + "'", + ); + (err as NodeJS.ErrnoException).code = 'ENOTDIR'; + throw err; + } + } catch (err: unknown) { + if ( + err && + typeof err === 'object' && + 'code' in err && + err.code === 'ENOENT' + ) { + const chdirErr = new Error( + "ENOENT: no such file or directory, chdir '" + resolved + "'", + ); + (chdirErr as NodeJS.ErrnoException).code = 'ENOENT'; + throw chdirErr; + } + throw err; + } + taskEnv[cwdSymbol] = resolved; + return; + } + return originalChdir.call(process, directory); +}; + +export function getEnv(key: string): string | undefined { + return process.env[key]; +} + +export async function loadConfig( + settings: Settings, + extensionLoader: ExtensionLoader, + taskId: string, + trusted: boolean = false, + workspaceDir: string = process.cwd(), +): Promise { + const workspaceEnv = await loadEnvironment(trusted, workspaceDir); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const envVars: Record = { ...process.env } as Record< + string, + string + >; + Object.assign(envVars, workspaceEnv); + + const getEnvLocal = (key: string) => envVars[key]; + + const folderTrust = + settings.folderTrust === true || + getEnvLocal('GEMINI_FOLDER_TRUST') === 'true'; + + let checkpointing = getEnvLocal('CHECKPOINTING') + ? getEnvLocal('CHECKPOINTING') === 'true' + : settings.checkpointing?.enabled; + + if (checkpointing) { + if (!(await GitService.verifyGitAvailability())) { + logger.warn( + '[Config] Checkpointing is enabled but git is not installed. Disabling checkpointing.', + ); + checkpointing = false; + } + } + + const approvalMode = + getEnvLocal('GEMINI_YOLO_MODE') === 'true' + ? ApprovalMode.YOLO + : ApprovalMode.DEFAULT; + + if (!trusted) { + if (settings.mcpServers) { + logger.warn( + '[Configuration] Untrusted workspace detected. Stripping repository mcpServers definitions to prevent unintended command execution.', + ); + } + if (settings.policyPaths) { + logger.warn( + '[Configuration] Untrusted workspace detected. Stripping repository policyPaths definitions to prevent unintended policy override.', + ); + } + if (settings.adminPolicyPaths) { + logger.warn( + '[Configuration] Untrusted workspace detected. Stripping repository adminPolicyPaths definitions to prevent unintended admin policy override.', + ); + } + if (settings.tools) { + logger.warn( + '[Configuration] Untrusted workspace detected. Stripping repository tools definitions to prevent unintended tool enablement.', + ); + } + if (settings.telemetry) { + logger.warn( + '[Configuration] Untrusted workspace detected. Stripping repository telemetry definitions to prevent unintended data routing.', + ); + } + settings = { + ...settings, + mcpServers: undefined, + policyPaths: undefined, + adminPolicyPaths: undefined, + tools: undefined, + telemetry: undefined, + }; + } + const safeMcpServers = settings.mcpServers; + + const policySettings: PolicySettings = { + mcpServers: safeMcpServers, + tools: { + core: settings.tools?.core, + exclude: settings.tools?.exclude, + allowed: settings.tools?.allowed, + }, + policyPaths: settings.policyPaths, + adminPolicyPaths: settings.adminPolicyPaths, + }; + + const policyEngineConfig = await createPolicyEngineConfig( + policySettings, + approvalMode, + undefined, + true, + ); + + const configParams: ConfigParameters = { + sessionId: taskId, + clientName: 'a2a-server', + model: PREVIEW_GEMINI_MODEL, + embeddingModel: DEFAULT_GEMINI_EMBEDDING_MODEL, + sandbox: undefined, // Sandbox might not be relevant for a server-side agent + targetDir: workspaceDir, // Or a specific directory the agent operates on + debugMode: getEnvLocal('DEBUG') === 'true' || false, + question: '', // Not used in server mode directly like CLI + env: envVars, + + coreTools: settings.tools?.core || undefined, + excludeTools: settings.tools?.exclude || undefined, + allowedTools: settings.tools?.allowed || undefined, + showMemoryUsage: settings.showMemoryUsage || false, + approvalMode, + policyEngineConfig, + mcpServers: safeMcpServers, + cwd: workspaceDir, + telemetry: { + enabled: settings.telemetry?.enabled, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + target: settings.telemetry?.target as TelemetryTarget, + otlpEndpoint: + getEnvLocal('OTEL_EXPORTER_OTLP_ENDPOINT') ?? + settings.telemetry?.otlpEndpoint, + logPrompts: settings.telemetry?.logPrompts, + }, + // Git-aware file filtering settings + fileFiltering: { + respectGitIgnore: settings.fileFiltering?.respectGitIgnore, + respectGeminiIgnore: settings.fileFiltering?.respectGeminiIgnore, + enableRecursiveFileSearch: + settings.fileFiltering?.enableRecursiveFileSearch, + customIgnoreFilePaths: [ + ...(settings.fileFiltering?.customIgnoreFilePaths || []), + ...(getEnvLocal('CUSTOM_IGNORE_FILE_PATHS') + ? getEnvLocal('CUSTOM_IGNORE_FILE_PATHS').split(path.delimiter) + : []), + ], + }, + ideMode: false, + folderTrust, + trustedFolder: trusted, + extensionLoader, + checkpointing, + interactive: true, + enableInteractiveShell: !isHeadlessMode(), + ptyInfo: 'auto', + enableAgents: settings.experimental?.enableAgents ?? true, + }; + + // Set an initial config to use to get a code assist server. + // This is needed to fetch admin controls. + const initialConfig = new Config({ + ...configParams, + }); + + const codeAssistServer = getCodeAssistServer(initialConfig); + + const adminControlsEnabled = + initialConfig.getExperiments()?.flags[ExperimentFlags.ENABLE_ADMIN_CONTROLS] + ?.boolValue ?? false; + + // Initialize final config parameters to the previous parameters. + // If no admin controls are needed, these will be used as-is for the final + // config. + const finalConfigParams = { ...configParams }; + if (adminControlsEnabled) { + const adminSettings = await fetchAdminControlsOnce( + codeAssistServer, + adminControlsEnabled, + ); + + // Admin settings are able to be undefined if unset, but if any are present, + // we should initialize them all. + // If any are present, undefined settings should be treated as if they were + // set to false. + // If NONE are present, disregard admin settings entirely, and pass the + // final config as is. + if (Object.keys(adminSettings).length !== 0) { + finalConfigParams.disableYoloMode = !adminSettings.strictModeDisabled; + finalConfigParams.mcpEnabled = adminSettings.mcpSetting?.mcpEnabled; + finalConfigParams.extensionsEnabled = + adminSettings.cliFeatureSetting?.extensionsSetting?.extensionsEnabled; + } + } + + const config = new Config(finalConfigParams); + + // Needed to initialize ToolRegistry, and git checkpointing if enabled + await config.initialize(); + + await config.waitForMcpInit(); + startupProfiler.flush(config); + + await refreshAuthentication(config, 'Config', envVars); + + return config; +} + +export function setIsTrusted( + agentSettings: AgentSettings | undefined, + workspaceRoot?: string, +): boolean { + if (agentSettings?.isTrusted !== undefined) { + return agentSettings.isTrusted; + } + const cliTrustEnv = getEnv('GEMINI_CLI_TRUST_WORKSPACE'); + if (cliTrustEnv !== undefined) { + return cliTrustEnv === 'true'; + } + if (workspaceRoot) { + const initialSettings = loadSettings(workspaceRoot, false); + const { isTrusted } = checkPathTrust({ + path: workspaceRoot, + isFolderTrustEnabled: initialSettings.folderTrust ?? true, + isHeadless: isHeadlessMode(), + }); + return isTrusted ?? false; + } + return false; +} + +export async function setTargetDir( + agentSettings: AgentSettings | undefined, +): Promise { + const originalCWD = process.cwd(); + const targetDir = + getEnv('CODER_AGENT_WORKSPACE_PATH') ?? + (agentSettings?.kind === CoderAgentEvent.StateAgentSettingsEvent + ? agentSettings.workspacePath + : undefined); + + if (!targetDir) { + return originalCWD; + } + + logger.info( + `[CoderAgentExecutor] Overriding workspace path to: ${targetDir}`, + ); + + try { + let resolvedPath: string; + try { + resolvedPath = resolveToRealPath(targetDir); + } catch (err: unknown) { + if ( + err && + typeof err === 'object' && + 'code' in err && + err.code === 'ENOENT' + ) { + const parentDir = path.dirname(path.resolve(targetDir)); + resolvedPath = path.join( + resolveToRealPath(parentDir), + path.basename(targetDir), + ); + } else { + throw err; + } + } + + const isTestEnv = + process.env['VITEST'] === 'true' || + process.env['NODE_ENV'] === 'test' || + process.argv.some((arg) => arg.includes('vitest')) || + resolvedPath.startsWith(resolveToRealPath(tmpdir())); + + const allowedRoot = resolveToRealPath( + getEnv('CODER_AGENT_ALLOWED_ROOT') || + (isTestEnv ? path.parse(resolvedPath).root : homedir()), + ); + const relative = path.relative(allowedRoot, resolvedPath); + if (relative.startsWith('..') || path.isAbsolute(relative)) { + throw new Error( + `Workspace path ${resolvedPath} is outside the allowed root directory`, + ); + } + + let stats: fs.Stats; + try { + stats = await fs.promises.stat(resolvedPath); + } catch (err: unknown) { + if ( + err && + typeof err === 'object' && + 'code' in err && + err.code === 'ENOENT' + ) { + if (isTestEnv) { + await fs.promises.mkdir(resolvedPath, { recursive: true }); + stats = await fs.promises.stat(resolvedPath); + } else { + throw new Error(`Workspace path ${resolvedPath} does not exist`); + } + } else { + throw err; + } + } + + if (!stats.isDirectory()) { + throw new Error(`Workspace path ${resolvedPath} is not a directory`); + } + + return resolvedPath; + } catch (e) { + logger.error(`[CoderAgentExecutor] Error resolving workspace path: ${e}`); + throw e; + } +} + +export async function loadEnvironment( + isTrusted: boolean = false, + workspacePath: string = process.cwd(), +): Promise> { + // For untrusted workspaces, we completely bypass workspace-level .env loading + // and only load environment variables from the user's trusted home directory. + let envFilePath: string | null = null; + if (isTrusted) { + envFilePath = await findEnvFile(workspacePath); + } else { + const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env'); + try { + await fs.promises.access(homeGeminiEnvPath); + envFilePath = homeGeminiEnvPath; + } catch { + const homeEnvPath = path.join(homedir(), '.env'); + try { + await fs.promises.access(homeEnvPath); + envFilePath = homeEnvPath; + } catch { + // Ignore + } + } + } + const envVars: Record = {}; + if (envFilePath) { + try { + const content = await fs.promises.readFile(envFilePath, 'utf-8'); + const parsed = dotenv.parse(content); + for (const key in parsed) { + if ( + Object.prototype.hasOwnProperty.call(parsed, key) && + key !== '__proto__' && + key !== 'constructor' && + key !== 'prototype' + ) { + envVars[key] = parsed[key]; + } + } + } catch { + // Ignore errors + } + } + return envVars; +} + +async function findEnvFile(startDir: string): Promise { + let currentDir = path.resolve(startDir); + while (true) { + // prefer gemini-specific .env under GEMINI_DIR + const geminiEnvPath = path.join(currentDir, GEMINI_DIR, '.env'); + try { + await fs.promises.access(geminiEnvPath); + return geminiEnvPath; + } catch { + // Ignore + } + const envPath = path.join(currentDir, '.env'); + try { + await fs.promises.access(envPath); + return envPath; + } catch { + // Ignore + } + const parentDir = path.dirname(currentDir); + if (parentDir === currentDir || !parentDir) { + break; + } + currentDir = parentDir; + } + // check .env under home as fallback, again preferring gemini-specific .env + const homeGeminiEnvPath = path.join(homedir(), GEMINI_DIR, '.env'); + try { + await fs.promises.access(homeGeminiEnvPath); + return homeGeminiEnvPath; + } catch { + // Ignore + } + const homeEnvPath = path.join(homedir(), '.env'); + try { + await fs.promises.access(homeEnvPath); + return homeEnvPath; + } catch { + return null; + } +} + +async function refreshAuthentication( + config: Config, + logPrefix: string, + envVars: Record, +): Promise { + const getEnvLocal = (key: string) => envVars[key]; + + if (getEnvLocal('USE_CCPA')) { + logger.info(`[${logPrefix}] Using CCPA Auth:`); + + logger.info(`[${logPrefix}] Attempting COMPUTE_ADC first.`); + try { + await config.refreshAuth(AuthType.COMPUTE_ADC); + logger.info(`[${logPrefix}] COMPUTE_ADC successful.`); + } catch (adcError) { + const adcMessage = + adcError instanceof Error ? adcError.message : String(adcError); + logger.info( + `[${logPrefix}] COMPUTE_ADC failed or not available: ${adcMessage}`, + ); + + const useComputeAdc = + getEnvLocal('GEMINI_CLI_USE_COMPUTE_ADC') === 'true'; + const isHeadless = isHeadlessMode(); + + if (isHeadless || useComputeAdc) { + const reason = isHeadless + ? 'headless mode' + : 'GEMINI_CLI_USE_COMPUTE_ADC=true'; + throw new FatalAuthenticationError( + `COMPUTE_ADC failed: ${adcMessage}. (LOGIN_WITH_GOOGLE fallback skipped due to ${reason}. Run in an interactive terminal to use OAuth.)`, + ); + } + + logger.info( + `[${logPrefix}] COMPUTE_ADC failed, falling back to LOGIN_WITH_GOOGLE.`, + ); + try { + await config.refreshAuth(AuthType.LOGIN_WITH_GOOGLE); + } catch (e) { + if (e instanceof FatalAuthenticationError) { + const originalMessage = e instanceof Error ? e.message : String(e); + throw new FatalAuthenticationError( + `${originalMessage}. The initial COMPUTE_ADC attempt also failed: ${adcMessage}`, + ); + } + throw e; + } + } + + logger.info( + `[${logPrefix}] GOOGLE_CLOUD_PROJECT: ${getEnvLocal('GOOGLE_CLOUD_PROJECT')}`, + ); + } else if (getEnvLocal('GEMINI_API_KEY')) { + logger.info(`[${logPrefix}] Using Gemini API Key`); + await config.refreshAuth( + AuthType.USE_GEMINI, + getEnvLocal('GEMINI_API_KEY'), + ); + } else { + const errorMessage = `[${logPrefix}] Unable to set GeneratorConfig. Please provide a GEMINI_API_KEY or set USE_CCPA.`; + logger.error(errorMessage); + throw new Error(errorMessage); + } +} diff --git a/packages/a2a-server/src/config/extension.ts b/packages/a2a-server/src/config/extension.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0a69965d65b703a37569f4bac7f63be4d3675bc --- /dev/null +++ b/packages/a2a-server/src/config/extension.ts @@ -0,0 +1,157 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Copied exactly from packages/cli/src/config/extension.ts, last PR #1026 + +import { + GEMINI_DIR, + type MCPServerConfig, + type ExtensionInstallMetadata, + type GeminiCLIExtension, + homedir, +} from '@google/gemini-cli-core'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { logger } from '../utils/logger.js'; + +export const EXTENSIONS_DIRECTORY_NAME = path.join(GEMINI_DIR, 'extensions'); +export const EXTENSIONS_CONFIG_FILENAME = 'gemini-extension.json'; +export const INSTALL_METADATA_FILENAME = '.gemini-extension-install.json'; + +/** + * Extension definition as written to disk in gemini-extension.json files. + * This should *not* be referenced outside of the logic for reading files. + * If information is required for manipulating extensions (load, unload, update) + * outside of the loading process that data needs to be stored on the + * GeminiCLIExtension class defined in Core. + */ +interface ExtensionConfig { + name: string; + version: string; + mcpServers?: Record; + contextFileName?: string | string[]; + excludeTools?: string[]; +} + +export function loadExtensions( + workspaceDir: string, + isTrusted: boolean = false, +): GeminiCLIExtension[] { + const allExtensions = [ + ...(isTrusted ? loadExtensionsFromDir(workspaceDir) : []), + ...loadExtensionsFromDir(homedir()), + ]; + + const uniqueExtensions: GeminiCLIExtension[] = []; + const seenNames = new Set(); + for (const extension of allExtensions) { + if (!seenNames.has(extension.name)) { + logger.info( + `Loading extension: ${extension.name} (version: ${extension.version})`, + ); + uniqueExtensions.push(extension); + seenNames.add(extension.name); + } + } + + return uniqueExtensions; +} + +function loadExtensionsFromDir(dir: string): GeminiCLIExtension[] { + const extensionsDir = path.join(dir, EXTENSIONS_DIRECTORY_NAME); + if (!fs.existsSync(extensionsDir)) { + return []; + } + + const extensions: GeminiCLIExtension[] = []; + for (const subdir of fs.readdirSync(extensionsDir)) { + const extensionDir = path.join(extensionsDir, subdir); + + const extension = loadExtension(extensionDir); + if (extension != null) { + extensions.push(extension); + } + } + return extensions; +} + +function loadExtension(extensionDir: string): GeminiCLIExtension | null { + if (!fs.statSync(extensionDir).isDirectory()) { + logger.error( + `Warning: unexpected file ${extensionDir} in extensions directory.`, + ); + return null; + } + + const configFilePath = path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME); + if (!fs.existsSync(configFilePath)) { + logger.error( + `Warning: extension directory ${extensionDir} does not contain a config file ${configFilePath}.`, + ); + return null; + } + + try { + const configContent = fs.readFileSync(configFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const config = JSON.parse(configContent) as ExtensionConfig; + if (!config.name || !config.version) { + logger.error( + `Invalid extension config in ${configFilePath}: missing name or version.`, + ); + return null; + } + + const installMetadata = loadInstallMetadata(extensionDir); + + const contextFiles = getContextFileNames(config) + .map((contextFileName) => path.join(extensionDir, contextFileName)) + .filter((contextFilePath) => fs.existsSync(contextFilePath)); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return { + name: config.name, + version: config.version, + path: extensionDir, + contextFiles, + installMetadata, + mcpServers: config.mcpServers, + excludeTools: config.excludeTools, + isActive: true, // Barring any other signals extensions should be considered Active. + } as GeminiCLIExtension; + } catch (e) { + logger.error( + `Warning: error parsing extension config in ${configFilePath}: ${e}`, + ); + return null; + } +} + +function getContextFileNames(config: ExtensionConfig): string[] { + if (!config.contextFileName) { + return ['GEMINI.md']; + } else if (!Array.isArray(config.contextFileName)) { + return [config.contextFileName]; + } + return config.contextFileName; +} + +export function loadInstallMetadata( + extensionDir: string, +): ExtensionInstallMetadata | undefined { + const metadataFilePath = path.join(extensionDir, INSTALL_METADATA_FILENAME); + try { + const configContent = fs.readFileSync(metadataFilePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const metadata = JSON.parse(configContent) as ExtensionInstallMetadata; + return metadata; + } catch (e) { + logger.warn( + `Failed to load or parse extension install metadata at ${metadataFilePath}: ${e}`, + ); + return undefined; + } +} diff --git a/packages/a2a-server/src/config/rce_vulnerability.test.ts b/packages/a2a-server/src/config/rce_vulnerability.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5b98c6e98086ea423aaca9dfe9070347ca3989ec --- /dev/null +++ b/packages/a2a-server/src/config/rce_vulnerability.test.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +const mockHomeRef = vi.hoisted(() => ({ dir: '' })); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + homedir: () => mockHomeRef.dir || os.tmpdir(), + }; +}); + +import { loadEnvironment } from './config.js'; + +describe('Vulnerability Mitigation: b-519269096', () => { + let tempWorkspaceDir: string; + + beforeEach(() => { + // Create a temporary home directory securely using mkdtempSync to ensure hermeticity + mockHomeRef.dir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-mock-home-'), + ); + + // Create a temporary workspace directory representing an untrusted repo + tempWorkspaceDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-exploit-workspace-'), + ); + const geminiDir = path.join(tempWorkspaceDir, '.gemini'); + fs.mkdirSync(geminiDir, { recursive: true }); + + // Mock process.cwd to return the untrusted workspace + vi.spyOn(process, 'cwd').mockReturnValue(tempWorkspaceDir); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + fs.rmSync(mockHomeRef.dir, { recursive: true, force: true }); + }); + + it('should ignore GEMINI_CLI_TRUST_WORKSPACE and GEMINI_YOLO_MODE in untrusted workspaces', async () => { + const geminiDir = path.join(tempWorkspaceDir, '.gemini'); + fs.writeFileSync( + path.join(geminiDir, '.env'), + 'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n', + ); + + // Ensure initially not set + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', ''); + vi.stubEnv('GEMINI_YOLO_MODE', ''); + + // Act: load environment with isTrusted = false + const envVars = await loadEnvironment(false); + + // Assert: In a SECURE system, these variables should NOT be loaded + expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBeUndefined(); + expect(envVars['GEMINI_YOLO_MODE']).toBeUndefined(); + expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy(); + expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy(); + }); + + it('should not load any variables from untrusted workspaces', async () => { + fs.writeFileSync( + path.join(tempWorkspaceDir, '.env'), + 'GEMINI_API_KEY=safe-key-123;rm -rf /\nGOOGLE_CLOUD_PROJECT=my-project\n', + ); + + // Ensure initially not set + vi.stubEnv('GEMINI_API_KEY', ''); + vi.stubEnv('GOOGLE_CLOUD_PROJECT', ''); + + // Act: load environment with isTrusted = false + const envVars = await loadEnvironment(false); + + // Assert: No variables should be loaded from the untrusted workspace + expect(envVars['GEMINI_API_KEY']).toBeUndefined(); + expect(envVars['GOOGLE_CLOUD_PROJECT']).toBeUndefined(); + expect(process.env['GEMINI_API_KEY']).toBeFalsy(); + expect(process.env['GOOGLE_CLOUD_PROJECT']).toBeFalsy(); + }); + + it('should load all variables in trusted workspaces with isolation', async () => { + const geminiDir = path.join(tempWorkspaceDir, '.gemini'); + fs.writeFileSync( + path.join(geminiDir, '.env'), + 'GEMINI_CLI_TRUST_WORKSPACE=true\nGEMINI_YOLO_MODE=true\n', + ); + + // Ensure initially not set + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', ''); + vi.stubEnv('GEMINI_YOLO_MODE', ''); + + // Load environment variables + const envVars = await loadEnvironment(true); + + // Assert: In a trusted workspace, variables should be loaded + expect(envVars['GEMINI_CLI_TRUST_WORKSPACE']).toBe('true'); + expect(envVars['GEMINI_YOLO_MODE']).toBe('true'); + + // Assert: Global process.env should NOT be polluted + expect(process.env['GEMINI_CLI_TRUST_WORKSPACE']).toBeFalsy(); + expect(process.env['GEMINI_YOLO_MODE']).toBeFalsy(); + }); +}); diff --git a/packages/a2a-server/src/config/settings.test.ts b/packages/a2a-server/src/config/settings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..25f058fd8dec37062a64558a033a229940f119d9 --- /dev/null +++ b/packages/a2a-server/src/config/settings.test.ts @@ -0,0 +1,235 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { loadSettings, USER_SETTINGS_PATH } from './settings.js'; +import { debugLogger, checkPathTrust } from '@google/gemini-cli-core'; + +const mocks = vi.hoisted(() => { + const suffix = Math.random().toString(36).slice(2); + return { + suffix, + }; +}); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + const path = await import('node:path'); + return { + ...actual, + homedir: () => path.join(actual.tmpdir(), `gemini-home-${mocks.suffix}`), + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + const path = await import('node:path'); + const os = await import('node:os'); + return { + ...actual, + GEMINI_DIR: '.gemini', + debugLogger: { + error: vi.fn(), + }, + getErrorMessage: (error: unknown) => String(error), + homedir: () => path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`), + checkPathTrust: vi.fn(() => ({ isTrusted: false })), + isHeadlessMode: vi.fn(() => true), + }; +}); + +describe('loadSettings', () => { + const mockHomeDir = path.join(os.tmpdir(), `gemini-home-${mocks.suffix}`); + const mockWorkspaceDir = path.join( + os.tmpdir(), + `gemini-workspace-${mocks.suffix}`, + ); + const mockGeminiHomeDir = path.join(mockHomeDir, '.gemini'); + const mockGeminiWorkspaceDir = path.join(mockWorkspaceDir, '.gemini'); + + beforeEach(() => { + vi.clearAllMocks(); + // Create the directories using the real fs + if (!fs.existsSync(mockGeminiHomeDir)) { + fs.mkdirSync(mockGeminiHomeDir, { recursive: true }); + } + if (!fs.existsSync(mockGeminiWorkspaceDir)) { + fs.mkdirSync(mockGeminiWorkspaceDir, { recursive: true }); + } + + // Clean up settings files before each test + if (fs.existsSync(USER_SETTINGS_PATH)) { + fs.rmSync(USER_SETTINGS_PATH); + } + const workspaceSettingsPath = path.join( + mockGeminiWorkspaceDir, + 'settings.json', + ); + if (fs.existsSync(workspaceSettingsPath)) { + fs.rmSync(workspaceSettingsPath); + } + }); + + afterEach(() => { + try { + if (fs.existsSync(mockHomeDir)) { + fs.rmSync(mockHomeDir, { recursive: true, force: true }); + } + if (fs.existsSync(mockWorkspaceDir)) { + fs.rmSync(mockWorkspaceDir, { recursive: true, force: true }); + } + } catch (e) { + debugLogger.error('Failed to cleanup temp dirs', e); + } + vi.restoreAllMocks(); + }); + + it('should load other top-level settings correctly', () => { + const settings = { + showMemoryUsage: true, + tools: { + core: ['tool1', 'tool2'], + }, + mcpServers: { + server1: { + command: 'cmd', + args: ['arg'], + }, + }, + fileFiltering: { + respectGitIgnore: true, + }, + }; + fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings)); + + const result = loadSettings(mockWorkspaceDir); + expect(result.showMemoryUsage).toBe(true); + expect(result.tools?.core).toEqual(['tool1', 'tool2']); + expect(result.mcpServers).toHaveProperty('server1'); + expect(result.fileFiltering?.respectGitIgnore).toBe(true); + }); + + it('should load experimental settings correctly', () => { + const settings = { + experimental: { + enableAgents: true, + }, + }; + fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(settings)); + + const result = loadSettings(mockWorkspaceDir); + expect(result.experimental?.enableAgents).toBe(true); + }); + + it('should overwrite top-level settings from workspace (shallow merge)', () => { + const userSettings = { + showMemoryUsage: false, + fileFiltering: { + respectGitIgnore: true, + enableRecursiveFileSearch: true, + }, + }; + fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings)); + + const workspaceSettings = { + showMemoryUsage: true, + fileFiltering: { + respectGitIgnore: false, + }, + }; + const workspaceSettingsPath = path.join( + mockGeminiWorkspaceDir, + 'settings.json', + ); + fs.writeFileSync(workspaceSettingsPath, JSON.stringify(workspaceSettings)); + + const result = loadSettings(mockWorkspaceDir, true); + // Primitive value overwritten + expect(result.showMemoryUsage).toBe(true); + + // Object value completely replaced (shallow merge behavior) + expect(result.fileFiltering?.respectGitIgnore).toBe(false); + expect(result.fileFiltering?.enableRecursiveFileSearch).toBeUndefined(); + }); + + describe('security', () => { + it('should NOT load workspace settings if workspace is NOT trusted', () => { + const userSettings = { showMemoryUsage: false }; + fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings)); + + const workspaceSettings = { showMemoryUsage: true }; + const workspaceSettingsPath = path.join( + mockGeminiWorkspaceDir, + 'settings.json', + ); + fs.writeFileSync( + workspaceSettingsPath, + JSON.stringify(workspaceSettings), + ); + + // checkPathTrust is mocked to return isTrusted: false by default + const result = loadSettings(mockWorkspaceDir); + expect(result.showMemoryUsage).toBe(false); + }); + + it('should load workspace settings if workspace IS trusted', () => { + vi.mocked(checkPathTrust).mockReturnValueOnce({ + isTrusted: true, + source: 'file', + }); + const userSettings = { showMemoryUsage: false }; + fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings)); + + const workspaceSettings = { showMemoryUsage: true }; + const workspaceSettingsPath = path.join( + mockGeminiWorkspaceDir, + 'settings.json', + ); + fs.writeFileSync( + workspaceSettingsPath, + JSON.stringify(workspaceSettings), + ); + + const result = loadSettings(mockWorkspaceDir); + expect(result.showMemoryUsage).toBe(true); + }); + + it('should NOT allow workspace settings to override adminPolicyPaths or policyPaths even if trusted', () => { + vi.mocked(checkPathTrust).mockReturnValueOnce({ + isTrusted: true, + source: 'file', + }); + const userSettings = { + adminPolicyPaths: ['/trusted/admin'], + policyPaths: ['/trusted/user'], + }; + fs.writeFileSync(USER_SETTINGS_PATH, JSON.stringify(userSettings)); + + const workspaceSettings = { + adminPolicyPaths: ['./malicious/admin'], + policyPaths: ['./malicious/user'], + showMemoryUsage: true, + }; + const workspaceSettingsPath = path.join( + mockGeminiWorkspaceDir, + 'settings.json', + ); + fs.writeFileSync( + workspaceSettingsPath, + JSON.stringify(workspaceSettings), + ); + + const result = loadSettings(mockWorkspaceDir); + expect(result.showMemoryUsage).toBe(true); + expect(result.adminPolicyPaths).toEqual(['/trusted/admin']); + expect(result.policyPaths).toEqual(['/trusted/user']); + }); + }); +}); diff --git a/packages/a2a-server/src/config/settings.ts b/packages/a2a-server/src/config/settings.ts new file mode 100644 index 0000000000000000000000000000000000000000..754f2b4b942bb09531df1b37092bda4ce891a039 --- /dev/null +++ b/packages/a2a-server/src/config/settings.ts @@ -0,0 +1,204 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +import { + type MCPServerConfig, + debugLogger, + GEMINI_DIR, + getErrorMessage, + type TelemetrySettings, + homedir, + checkPathTrust, + isHeadlessMode, +} from '@google/gemini-cli-core'; +import stripJsonComments from 'strip-json-comments'; + +export const USER_SETTINGS_DIR = path.join(homedir(), GEMINI_DIR); +export const USER_SETTINGS_PATH = path.join(USER_SETTINGS_DIR, 'settings.json'); + +// TODO: Ensure full compatibility with V2 nested settings structure (settings.schema.json). +// This involves updating the interface and implementing migration logic to support legacy V1 (flat) settings, +// similar to how packages/cli/src/config/settings.ts handles it. +export interface Settings { + mcpServers?: Record; + tools?: { + allowed?: string[]; + exclude?: string[]; + core?: string[]; + }; + telemetry?: TelemetrySettings; + showMemoryUsage?: boolean; + checkpointing?: CheckpointingSettings; + folderTrust?: boolean; + general?: { + previewFeatures?: boolean; + }; + + // Git-aware file filtering settings + fileFiltering?: { + respectGitIgnore?: boolean; + respectGeminiIgnore?: boolean; + enableRecursiveFileSearch?: boolean; + customIgnoreFilePaths?: string[]; + }; + experimental?: { + enableAgents?: boolean; + }; + policyPaths?: string[]; + adminPolicyPaths?: string[]; +} + +export interface SettingsError { + message: string; + path: string; +} + +export interface CheckpointingSettings { + enabled?: boolean; +} + +/** + * Loads settings from user and workspace directories. + * Project settings override user settings if the workspace is trusted. + * + * How is it different to gemini-cli/cli: Returns already merged settings rather + * than `LoadedSettings` (unnecessary since we are not modifying users + * settings.json). + */ +export function loadSettings( + workspaceDir: string, + isTrustedOverride?: boolean, +): Settings { + let userSettings: Settings = {}; + let workspaceSettings: Settings = {}; + const settingsErrors: SettingsError[] = []; + + // Load user settings + try { + if (fs.existsSync(USER_SETTINGS_PATH)) { + const userContent = fs.readFileSync(USER_SETTINGS_PATH, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const parsedUserSettings = JSON.parse( + stripJsonComments(userContent), + ) as Settings; + userSettings = resolveEnvVarsInObject(parsedUserSettings); + } + } catch (error: unknown) { + settingsErrors.push({ + message: getErrorMessage(error), + path: USER_SETTINGS_PATH, + }); + } + + let isTrusted = isTrustedOverride; + if (isTrusted === undefined) { + const { isTrusted: trustResult } = checkPathTrust({ + path: workspaceDir, + isFolderTrustEnabled: userSettings.folderTrust ?? true, + isHeadless: isHeadlessMode(), + }); + isTrusted = trustResult ?? false; + } + + const workspaceSettingsPath = path.join( + workspaceDir, + GEMINI_DIR, + 'settings.json', + ); + + // Load workspace settings only if trusted + if (isTrusted) { + try { + if (fs.existsSync(workspaceSettingsPath)) { + const projectContent = fs.readFileSync(workspaceSettingsPath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const parsedWorkspaceSettings = JSON.parse( + stripJsonComments(projectContent), + ) as Settings; + workspaceSettings = resolveEnvVarsInObject(parsedWorkspaceSettings); + } + } catch (error: unknown) { + settingsErrors.push({ + message: getErrorMessage(error), + path: workspaceSettingsPath, + }); + } + } + + if (settingsErrors.length > 0) { + debugLogger.error('Errors loading settings:'); + for (const error of settingsErrors) { + debugLogger.error(` Path: ${error.path}`); + debugLogger.error(` Message: ${error.message}`); + } + } + + // If there are overlapping keys, the values of workspaceSettings will + // override values from userSettings + const mergedSettings = { + ...userSettings, + ...workspaceSettings, + }; + + // Security: ensure policyPaths and adminPolicyPaths are only loaded from trusted, user-level + // configuration and cannot be overridden by workspace-level settings, even if the + // workspace is trusted. + mergedSettings.policyPaths = userSettings.policyPaths; + mergedSettings.adminPolicyPaths = userSettings.adminPolicyPaths; + + return mergedSettings; +} + +function resolveEnvVarsInString(value: string): string { + const envVarRegex = /\$(?:(\w+)|{([^}]+)})/g; // Find $VAR_NAME or ${VAR_NAME} + return value.replace( + envVarRegex, + (match: string, varName1: string, varName2: string) => { + const varName = varName1 || varName2; + const envValue = process?.env?.[varName]; + if (typeof envValue === 'string') { + return envValue; + } + return match; + }, + ); +} + +function resolveEnvVarsInObject(obj: T): T { + if ( + obj === null || + obj === undefined || + typeof obj === 'boolean' || + typeof obj === 'number' + ) { + return obj; + } + + if (typeof obj === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return resolveEnvVarsInString(obj) as unknown as T; + } + + if (Array.isArray(obj)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion, @typescript-eslint/no-unsafe-return + return obj.map((item) => resolveEnvVarsInObject(item)) as unknown as T; + } + + if (typeof obj === 'object') { + const newObj = { ...obj } as T; + for (const key in newObj) { + if (Object.prototype.hasOwnProperty.call(newObj, key)) { + newObj[key] = resolveEnvVarsInObject(newObj[key]); + } + } + return newObj; + } + + return obj; +} diff --git a/packages/a2a-server/src/config/workspace_trust.test.ts b/packages/a2a-server/src/config/workspace_trust.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d632908f86838e1fa27ac6da31063da141fb50c4 --- /dev/null +++ b/packages/a2a-server/src/config/workspace_trust.test.ts @@ -0,0 +1,179 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +vi.unmock('@google/gemini-cli-core'); +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + Config, + checkPathTrust, + SimpleExtensionLoader, +} from '@google/gemini-cli-core'; +import * as core from '@google/gemini-cli-core'; +import { loadConfig } from './config.js'; +import type { Settings } from './settings.js'; + +describe('Workspace Trust Evaluation', () => { + let tempWorkspaceDir: string; + + beforeEach(() => { + tempWorkspaceDir = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-trust-eval-'), + ); + const geminiDir = path.join(tempWorkspaceDir, '.gemini'); + fs.mkdirSync(geminiDir, { recursive: true }); + + vi.stubEnv('GEMINI_API_KEY', 'dummy-key-for-eval'); + vi.spyOn(Config.prototype, 'initialize').mockResolvedValue(undefined); + vi.spyOn(Config.prototype, 'waitForMcpInit').mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + fs.rmSync(tempWorkspaceDir, { recursive: true, force: true }); + }); + + it('resolves untrusted environment signals in checkPathTrust', () => { + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'false'); + + const result = checkPathTrust({ + path: tempWorkspaceDir, + isFolderTrustEnabled: true, + }); + + expect(result).toEqual({ isTrusted: false, source: 'env' }); + + vi.stubEnv('GEMINI_RESTRICTED_MODE', 'true'); + + const restrictedResult = checkPathTrust({ + path: tempWorkspaceDir, + isFolderTrustEnabled: true, + }); + + expect(restrictedResult).toEqual({ isTrusted: false, source: 'env' }); + }); + + it('evaluates to false in Config.isTrustedFolder when untrusted signals are present or undefined', () => { + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'false'); + + const untrustedConfig = new Config({ + sessionId: 'test-session', + clientName: 'test-client', + model: 'gemini-pro', + targetDir: tempWorkspaceDir, + cwd: tempWorkspaceDir, + debugMode: false, + question: '', + folderTrust: false, + trustedFolder: undefined, + }); + + expect(untrustedConfig.isTrustedFolder()).toBe(false); + + vi.unstubAllEnvs(); + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', ''); + + const fallbackConfig = new Config({ + sessionId: 'test-session', + clientName: 'test-client', + model: 'gemini-pro', + targetDir: tempWorkspaceDir, + cwd: tempWorkspaceDir, + debugMode: false, + question: '', + folderTrust: true, + trustedFolder: undefined, + }); + + expect(fallbackConfig.isTrustedFolder()).toBe(false); + }); + + it('assigns undefined to mcpServers, policyPaths, adminPolicyPaths, tools, and telemetry in configParams and policySettings when trusted is false', async () => { + const policySpy = vi.spyOn(core, 'createPolicyEngineConfig'); + const settings: Settings = { + mcpServers: { + 'test-tool': { + command: 'node', + args: ['-e', 'process.exit(0)'], + }, + }, + policyPaths: ['/test/policy/path'], + adminPolicyPaths: ['/test/admin/policy/path'], + tools: { + allowed: ['shell'], + }, + telemetry: { + otlpEndpoint: 'http://untrusted-endpoint:4317', + }, + }; + + const config = await loadConfig( + settings, + new SimpleExtensionLoader([]), + 'test-untrusted-task', + false, + tempWorkspaceDir, + ); + + expect(config.getMcpServers()).toBeUndefined(); + expect(policySpy.mock.calls[0][0].mcpServers).toBeUndefined(); + expect(policySpy.mock.calls[0][0].policyPaths).toBeUndefined(); + expect(policySpy.mock.calls[0][0].adminPolicyPaths).toBeUndefined(); + expect(policySpy.mock.calls[0][0].tools?.allowed).toBeUndefined(); + expect(config.getTelemetryOtlpEndpoint()).not.toBe( + 'http://untrusted-endpoint:4317', + ); + expect(settings.mcpServers).toBeDefined(); + expect(settings.policyPaths).toBeDefined(); + expect(settings.adminPolicyPaths).toBeDefined(); + expect(settings.tools).toBeDefined(); + expect(settings.telemetry).toBeDefined(); + }); + + it('retains mcpServers, policyPaths, adminPolicyPaths, tools, and telemetry in configParams and policySettings when trusted is true', async () => { + const policySpy = vi.spyOn(core, 'createPolicyEngineConfig'); + const settings: Settings = { + mcpServers: { + 'test-tool': { + command: 'node', + args: ['-e', 'process.exit(0)'], + }, + }, + policyPaths: ['/test/policy/path'], + adminPolicyPaths: ['/test/admin/policy/path'], + tools: { + allowed: ['shell'], + }, + telemetry: { + otlpEndpoint: 'http://trusted-endpoint:4317', + }, + }; + + const config = await loadConfig( + settings, + new SimpleExtensionLoader([]), + 'test-trusted-task', + true, + tempWorkspaceDir, + ); + + expect(config.getMcpServers()).toEqual(settings.mcpServers); + expect(policySpy.mock.calls[0][0].mcpServers).toEqual(settings.mcpServers); + expect(policySpy.mock.calls[0][0].policyPaths).toEqual( + settings.policyPaths, + ); + expect(policySpy.mock.calls[0][0].adminPolicyPaths).toEqual( + settings.adminPolicyPaths, + ); + expect(policySpy.mock.calls[0][0].tools?.allowed).toEqual(['shell']); + expect(config.getTelemetryOtlpEndpoint()).toBe( + 'http://trusted-endpoint:4317', + ); + }); +}); diff --git a/packages/a2a-server/src/http/app.test.ts b/packages/a2a-server/src/http/app.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..7a716dd617469be343ce81b68cb50794049413da --- /dev/null +++ b/packages/a2a-server/src/http/app.test.ts @@ -0,0 +1,1266 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + GeminiEventType, + ApprovalMode, + type Config, + type ToolCallConfirmationDetails, +} from '@google/gemini-cli-core'; +import type { + TaskStatusUpdateEvent, + SendStreamingMessageSuccessResponse, +} from '@a2a-js/sdk'; +import express from 'express'; +import type { Server } from 'node:http'; +import request from 'supertest'; +import { + afterAll, + afterEach, + beforeEach, + beforeAll, + describe, + expect, + it, + vi, +} from 'vitest'; +import { createApp, main } from './app.js'; +import { commandRegistry } from '../commands/command-registry.js'; +import { + assertUniqueFinalEventIsLast, + assertTaskCreationAndWorkingStatus, + createStreamMessageRequest, + createMockConfig, +} from '../utils/testing_utils.js'; +// Import MockTool from specific path to avoid vitest dependency in main core bundle +import { MockTool } from '@google/gemini-cli-core/src/test-utils/mock-tool.js'; +import type { Command, CommandContext } from '../commands/types.js'; + +const mockToolConfirmationFn = async () => + ({}) as unknown as ToolCallConfirmationDetails; + +const streamToSSEEvents = ( + stream: string, +): SendStreamingMessageSuccessResponse[] => + stream + .split('\n\n') + .filter(Boolean) // Remove empty strings from trailing newlines + .map((chunk) => { + const dataLine = chunk + .split('\n') + .find((line) => line.startsWith('data: ')); + if (!dataLine) { + throw new Error(`Invalid SSE chunk found: "${chunk}"`); + } + return JSON.parse(dataLine.substring(6)); + }); + +// Mock the logger to avoid polluting test output +// Comment out to debug tests +vi.mock('../utils/logger.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +let config: Config; +const getToolRegistrySpy = vi.fn().mockReturnValue({ + getTool: vi.fn(), + getAllToolNames: vi.fn().mockReturnValue([]), + getAllTools: vi.fn().mockReturnValue([]), + getToolsByServer: vi.fn().mockReturnValue([]), +}); +const getApprovalModeSpy = vi.fn(); +const getShellExecutionConfigSpy = vi.fn(); +const getExtensionsSpy = vi.fn(); + +vi.mock('../config/config.js', async () => { + const actual = await vi.importActual('../config/config.js'); + return { + ...actual, + loadConfig: vi.fn().mockImplementation(async () => { + const mockConfig = createMockConfig({ + getToolRegistry: getToolRegistrySpy, + getApprovalMode: getApprovalModeSpy, + getShellExecutionConfig: getShellExecutionConfigSpy, + getExtensions: getExtensionsSpy, + }); + config = mockConfig as Config; + return config; + }), + }; +}); + +// Mock the GeminiClient to avoid actual API calls +const sendMessageStreamSpy = vi.fn(); +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual('@google/gemini-cli-core'); + return { + ...actual, + GeminiClient: vi.fn().mockImplementation(() => ({ + sendMessageStream: sendMessageStreamSpy, + getUserTier: vi.fn().mockReturnValue('free'), + initialize: vi.fn(), + })), + performRestore: vi.fn(), + }; +}); + +describe('E2E Tests', () => { + let app: express.Express; + let server: Server; + + beforeAll(async () => { + app = await createApp(); + server = app.listen(0); // Listen on a random available port + }); + + beforeEach(() => { + getApprovalModeSpy.mockReturnValue(ApprovalMode.DEFAULT); + }); + + afterAll( + () => + new Promise((resolve) => { + server.close(() => { + resolve(); + }); + }), + ); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + }); + + it('should create a new task and stream status updates (text-content) via POST /', async () => { + sendMessageStreamSpy.mockImplementation(async function* () { + yield* [{ type: 'content', value: 'Hello how are you?' }]; + }); + + const agent = request.agent(app); + const res = await agent + .post('/') + .send(createStreamMessageRequest('hello', 'a2a-test-message')) + .set('Content-Type', 'application/json') + .expect(200); + + const events = streamToSSEEvents(res.text); + + assertTaskCreationAndWorkingStatus(events); + + // Status update: text-content + const textContentEvent = events[2].result as TaskStatusUpdateEvent; + expect(textContentEvent.kind).toBe('status-update'); + expect(textContentEvent.status.state).toBe('working'); + expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({ + kind: 'text-content', + }); + expect(textContentEvent.status.message?.parts).toMatchObject([ + { kind: 'text', text: 'Hello how are you?' }, + ]); + + // Status update: input-required (final) + const finalEvent = events[3].result as TaskStatusUpdateEvent; + expect(finalEvent.kind).toBe('status-update'); + expect(finalEvent.status?.state).toBe('input-required'); + expect(finalEvent.final).toBe(true); + + assertUniqueFinalEventIsLast(events); + expect(events.length).toBe(4); + }); + + it('should create a new task, schedule a tool call, and wait for approval', async () => { + // First call yields the tool request + sendMessageStreamSpy.mockImplementationOnce(async function* () { + yield* [ + { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'test-call-id', + name: 'test-tool', + args: {}, + }, + }, + ]; + }); + // Subsequent calls yield nothing + sendMessageStreamSpy.mockImplementation(async function* () { + yield* []; + }); + + const mockTool = new MockTool({ + name: 'test-tool', + shouldConfirmExecute: vi.fn(mockToolConfirmationFn), + }); + + getToolRegistrySpy.mockReturnValue({ + getAllTools: vi.fn().mockReturnValue([mockTool]), + getToolsByServer: vi.fn().mockReturnValue([]), + getTool: vi.fn().mockReturnValue(mockTool), + }); + + const agent = request.agent(app); + const res = await agent + .post('/') + .send(createStreamMessageRequest('run a tool', 'a2a-tool-test-message')) + .set('Content-Type', 'application/json') + .expect(200); + + const events = streamToSSEEvents(res.text); + assertTaskCreationAndWorkingStatus(events); + + // Status update: working + const workingEvent2 = events[2].result as TaskStatusUpdateEvent; + expect(workingEvent2.kind).toBe('status-update'); + expect(workingEvent2.status.state).toBe('working'); + expect(workingEvent2.metadata?.['coderAgent']).toMatchObject({ + kind: 'state-change', + }); + + // Status update: tool-call-update + const toolCallUpdateEvent = events[3].result as TaskStatusUpdateEvent; + expect(toolCallUpdateEvent.kind).toBe('status-update'); + expect(toolCallUpdateEvent.status.state).toBe('working'); + expect(toolCallUpdateEvent.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-update', + }); + expect(toolCallUpdateEvent.status.message?.parts).toMatchObject([ + { + data: { + status: 'scheduled', + request: { callId: 'test-call-id' }, + }, + }, + ]); + + // State update: awaiting_approval update + const toolCallConfirmationEvent = events[4].result as TaskStatusUpdateEvent; + expect(toolCallConfirmationEvent.kind).toBe('status-update'); + expect(toolCallConfirmationEvent.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-confirmation', + }); + expect(toolCallConfirmationEvent.status.message?.parts).toMatchObject([ + { + data: { + status: 'awaiting_approval', + request: { callId: 'test-call-id' }, + }, + }, + ]); + expect(toolCallConfirmationEvent.status?.state).toBe('working'); + + assertUniqueFinalEventIsLast(events); + expect(events.length).toBe(6); + }); + + it('should handle multiple tool calls in a single turn', async () => { + // First call yields the tool request + sendMessageStreamSpy.mockImplementationOnce(async function* () { + yield* [ + { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'test-call-id-1', + name: 'test-tool-1', + args: {}, + }, + }, + { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'test-call-id-2', + name: 'test-tool-2', + args: {}, + }, + }, + ]; + }); + // Subsequent calls yield nothing + sendMessageStreamSpy.mockImplementation(async function* () { + yield* []; + }); + + const mockTool1 = new MockTool({ + name: 'test-tool-1', + displayName: 'Test Tool 1', + shouldConfirmExecute: vi.fn(mockToolConfirmationFn), + }); + const mockTool2 = new MockTool({ + name: 'test-tool-2', + displayName: 'Test Tool 2', + shouldConfirmExecute: vi.fn(mockToolConfirmationFn), + }); + + getToolRegistrySpy.mockReturnValue({ + getAllTools: vi.fn().mockReturnValue([mockTool1, mockTool2]), + getToolsByServer: vi.fn().mockReturnValue([]), + getTool: vi.fn().mockImplementation((name: string) => { + if (name === 'test-tool-1') return mockTool1; + if (name === 'test-tool-2') return mockTool2; + return undefined; + }), + }); + + const agent = request.agent(app); + const res = await agent + .post('/') + .send( + createStreamMessageRequest( + 'run two tools', + 'a2a-multi-tool-test-message', + ), + ) + .set('Content-Type', 'application/json') + .expect(200); + + const events = streamToSSEEvents(res.text); + assertTaskCreationAndWorkingStatus(events); + + // Second working update + const workingEvent = events[2].result as TaskStatusUpdateEvent; + expect(workingEvent.kind).toBe('status-update'); + expect(workingEvent.status.state).toBe('working'); + + // State Update: Validate the first tool call + const toolCallValidateEvent1 = events[3].result as TaskStatusUpdateEvent; + expect(toolCallValidateEvent1.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-update', + }); + expect(toolCallValidateEvent1.status.message?.parts).toMatchObject([ + { + data: { + status: 'scheduled', + request: { callId: 'test-call-id-1' }, + }, + }, + ]); + + // --- Assert the event stream --- + // 1. Initial "submitted" status. + expect((events[0].result as TaskStatusUpdateEvent).status.state).toBe( + 'submitted', + ); + + // 2. "working" status after receiving the user prompt. + expect((events[1].result as TaskStatusUpdateEvent).status.state).toBe( + 'working', + ); + + // 3. A "state-change" event from the agent. + expect(events[2].result.metadata?.['coderAgent']).toMatchObject({ + kind: 'state-change', + }); + + // 4. Tool 1 is scheduled. + const toolCallUpdate1 = events[3].result as TaskStatusUpdateEvent; + expect(toolCallUpdate1.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-update', + }); + expect(toolCallUpdate1.status.message?.parts).toMatchObject([ + { + data: { + request: { callId: 'test-call-id-1' }, + status: 'scheduled', + }, + }, + ]); + + // 5. Tool 2 is scheduled. + const toolCallUpdate2 = events[4].result as TaskStatusUpdateEvent; + expect(toolCallUpdate2.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-update', + }); + expect(toolCallUpdate2.status.message?.parts).toMatchObject([ + { + data: { + request: { callId: 'test-call-id-2' }, + status: 'scheduled', + }, + }, + ]); + + // 6. Tool 1 is awaiting approval. + const toolCallAwaitEvent1 = events[5].result as TaskStatusUpdateEvent; + expect(toolCallAwaitEvent1.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-confirmation', + }); + expect(toolCallAwaitEvent1.status.message?.parts).toMatchObject([ + { + data: { + request: { callId: 'test-call-id-1' }, + status: 'awaiting_approval', + }, + }, + ]); + + // 7. Tool 2 is awaiting approval. + const toolCallAwaitEvent2 = events[6].result as TaskStatusUpdateEvent; + expect(toolCallAwaitEvent2.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-confirmation', + }); + expect(toolCallAwaitEvent2.status.message?.parts).toMatchObject([ + { + data: { + request: { callId: 'test-call-id-2' }, + status: 'awaiting_approval', + }, + }, + ]); + + // 8. The final event is "input-required". + const finalEvent = events[7].result as TaskStatusUpdateEvent; + expect(finalEvent.final).toBe(true); + expect(finalEvent.status.state).toBe('input-required'); + + // The scheduler now waits for approval, so no more events are sent. + assertUniqueFinalEventIsLast(events); + expect(events.length).toBe(8); + }); + + it('should handle multiple tool calls sequentially in YOLO mode', async () => { + // Set YOLO mode to auto-approve tools and test sequential execution. + getApprovalModeSpy.mockReturnValue(ApprovalMode.YOLO); + + // First call yields the tool request + sendMessageStreamSpy.mockImplementationOnce(async function* () { + yield* [ + { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'test-call-id-1', + name: 'test-tool-1', + args: {}, + }, + }, + { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'test-call-id-2', + name: 'test-tool-2', + args: {}, + }, + }, + ]; + }); + // Subsequent calls yield nothing, as the tools will "succeed". + sendMessageStreamSpy.mockImplementation(async function* () { + yield* [{ type: 'content', value: 'All tools executed.' }]; + }); + + const mockTool1 = new MockTool({ + name: 'test-tool-1', + displayName: 'Test Tool 1', + shouldConfirmExecute: vi.fn(mockToolConfirmationFn), + execute: vi + .fn() + .mockResolvedValue({ llmContent: 'tool 1 done', returnDisplay: '' }), + }); + const mockTool2 = new MockTool({ + name: 'test-tool-2', + displayName: 'Test Tool 2', + shouldConfirmExecute: vi.fn(mockToolConfirmationFn), + execute: vi + .fn() + .mockResolvedValue({ llmContent: 'tool 2 done', returnDisplay: '' }), + }); + + getToolRegistrySpy.mockReturnValue({ + getAllTools: vi.fn().mockReturnValue([mockTool1, mockTool2]), + getToolsByServer: vi.fn().mockReturnValue([]), + getTool: vi.fn().mockImplementation((name: string) => { + if (name === 'test-tool-1') return mockTool1; + if (name === 'test-tool-2') return mockTool2; + return undefined; + }), + }); + + const agent = request.agent(app); + const res = await agent + .post('/') + .send( + createStreamMessageRequest( + 'run two tools', + 'a2a-multi-tool-test-message', + ), + ) + .set('Content-Type', 'application/json') + .expect(200); + + const events = streamToSSEEvents(res.text); + assertTaskCreationAndWorkingStatus(events); + + // --- Assert the sequential execution flow --- + const eventStream = events.slice(2).map((e) => { + const update = e.result as TaskStatusUpdateEvent; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const agentData = update.metadata?.['coderAgent'] as any; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const toolData = update.status.message?.parts[0] as any; + if (!toolData) { + return { kind: agentData.kind }; + } + return { + kind: agentData.kind, + status: toolData.data?.status, + callId: toolData.data?.request.callId, + }; + }); + + const expectedFlow = [ + // Initial state change + { kind: 'state-change', status: undefined, callId: undefined }, + // Tool 1 Lifecycle + { + kind: 'tool-call-update', + status: 'scheduled', + callId: 'test-call-id-1', + }, + { + kind: 'tool-call-update', + status: 'scheduled', + callId: 'test-call-id-1', + }, + { + kind: 'tool-call-update', + status: 'executing', + callId: 'test-call-id-1', + }, + { + kind: 'tool-call-update', + status: 'success', + callId: 'test-call-id-1', + }, + // Tool 2 Lifecycle + { + kind: 'tool-call-update', + status: 'scheduled', + callId: 'test-call-id-2', + }, + { + kind: 'tool-call-update', + status: 'scheduled', + callId: 'test-call-id-2', + }, + { + kind: 'tool-call-update', + status: 'executing', + callId: 'test-call-id-2', + }, + { + kind: 'tool-call-update', + status: 'success', + callId: 'test-call-id-2', + }, + // Final updates + { kind: 'state-change', status: undefined, callId: undefined }, + { kind: 'text-content', status: undefined, callId: undefined }, + ]; + + // Use `toContainEqual` for flexibility if other events are interspersed. + expect(eventStream).toEqual(expect.arrayContaining(expectedFlow)); + + assertUniqueFinalEventIsLast(events); + }); + + it('should handle tool calls that do not require approval', async () => { + // First call yields the tool request + sendMessageStreamSpy.mockImplementationOnce(async function* () { + yield* [ + { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'test-call-id-no-approval', + name: 'test-tool-no-approval', + args: {}, + }, + }, + ]; + }); + // Second call, after the tool runs, yields the final text + sendMessageStreamSpy.mockImplementationOnce(async function* () { + yield* [{ type: 'content', value: 'Tool executed successfully.' }]; + }); + + const mockTool = new MockTool({ + name: 'test-tool-no-approval', + displayName: 'Test Tool No Approval', + execute: vi.fn().mockResolvedValue({ + llmContent: 'Tool executed successfully.', + returnDisplay: 'Tool executed successfully.', + }), + }); + + getToolRegistrySpy.mockReturnValue({ + getAllTools: vi.fn().mockReturnValue([mockTool]), + getToolsByServer: vi.fn().mockReturnValue([]), + getTool: vi.fn().mockReturnValue(mockTool), + }); + + const agent = request.agent(app); + const res = await agent + .post('/') + .send( + createStreamMessageRequest( + 'run a tool without approval', + 'a2a-no-approval-test-message', + ), + ) + .set('Content-Type', 'application/json') + .expect(200); + + const events = streamToSSEEvents(res.text); + assertTaskCreationAndWorkingStatus(events); + + // Status update: working + const workingEvent2 = events[2].result as TaskStatusUpdateEvent; + expect(workingEvent2.kind).toBe('status-update'); + expect(workingEvent2.status.state).toBe('working'); + + // Status update: tool-call-update (scheduled) + const scheduledEvent1 = events[3].result as TaskStatusUpdateEvent; + expect(scheduledEvent1.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-update', + }); + expect(scheduledEvent1.status.message?.parts).toMatchObject([ + { + data: { + status: 'scheduled', + request: { callId: 'test-call-id-no-approval' }, + }, + }, + ]); + + // Status update: tool-call-update (scheduled) + const scheduledEvent2 = events[4].result as TaskStatusUpdateEvent; + expect(scheduledEvent2.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-update', + }); + expect(scheduledEvent2.status.message?.parts).toMatchObject([ + { + data: { + status: 'scheduled', + request: { callId: 'test-call-id-no-approval' }, + }, + }, + ]); + + // Status update: tool-call-update (scheduled) + const scheduledEvent3 = events[5].result as TaskStatusUpdateEvent; + expect(scheduledEvent3.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-update', + }); + expect(scheduledEvent3.status.message?.parts).toMatchObject([ + { + data: { + status: 'scheduled', + request: { callId: 'test-call-id-no-approval' }, + }, + }, + ]); + + // Status update: tool-call-update (executing) + const executingEvent = events[6].result as TaskStatusUpdateEvent; + expect(executingEvent.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-update', + }); + expect(executingEvent.status.message?.parts).toMatchObject([ + { + data: { + status: 'executing', + request: { callId: 'test-call-id-no-approval' }, + }, + }, + ]); + + // Status update: tool-call-update (success) + const successEvent = events[7].result as TaskStatusUpdateEvent; + expect(successEvent.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-update', + }); + expect(successEvent.status.message?.parts).toMatchObject([ + { + data: { + status: 'success', + request: { callId: 'test-call-id-no-approval' }, + }, + }, + ]); + + // Status update: working (before sending tool result to LLM) + const workingEvent3 = events[8].result as TaskStatusUpdateEvent; + expect(workingEvent3.kind).toBe('status-update'); + expect(workingEvent3.status.state).toBe('working'); + + // Status update: text-content (final LLM response) + const textContentEvent = events[9].result as TaskStatusUpdateEvent; + expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({ + kind: 'text-content', + }); + expect(textContentEvent.status.message?.parts).toMatchObject([ + { text: 'Tool executed successfully.' }, + ]); + + assertUniqueFinalEventIsLast(events); + expect(events.length).toBe(11); + }); + + it('should bypass tool approval in YOLO mode', async () => { + // First call yields the tool request + sendMessageStreamSpy.mockImplementationOnce(async function* () { + yield* [ + { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'test-call-id-yolo', + name: 'test-tool-yolo', + args: {}, + }, + }, + ]; + }); + // Second call, after the tool runs, yields the final text + sendMessageStreamSpy.mockImplementationOnce(async function* () { + yield* [{ type: 'content', value: 'Tool executed successfully.' }]; + }); + + // Set approval mode to yolo + getApprovalModeSpy.mockReturnValue(ApprovalMode.YOLO); + + const mockTool = new MockTool({ + name: 'test-tool-yolo', + displayName: 'Test Tool YOLO', + execute: vi.fn().mockResolvedValue({ + llmContent: 'Tool executed successfully.', + returnDisplay: 'Tool executed successfully.', + }), + }); + + getToolRegistrySpy.mockReturnValue({ + getAllTools: vi.fn().mockReturnValue([mockTool]), + getToolsByServer: vi.fn().mockReturnValue([]), + getTool: vi.fn().mockReturnValue(mockTool), + }); + + const agent = request.agent(app); + const res = await agent + .post('/') + .send( + createStreamMessageRequest( + 'run a tool in yolo mode', + 'a2a-yolo-mode-test-message', + ), + ) + .set('Content-Type', 'application/json') + .expect(200); + + const events = streamToSSEEvents(res.text); + assertTaskCreationAndWorkingStatus(events); + + // Status update: working + const workingEvent2 = events[2].result as TaskStatusUpdateEvent; + expect(workingEvent2.kind).toBe('status-update'); + expect(workingEvent2.status.state).toBe('working'); + + // Status update: tool-call-update (scheduled) + const scheduledEvent = events[3].result as TaskStatusUpdateEvent; + expect(scheduledEvent.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-update', + }); + expect(scheduledEvent.status.message?.parts).toMatchObject([ + { + data: { + status: 'scheduled', + request: { callId: 'test-call-id-yolo' }, + }, + }, + ]); + + // Status update: tool-call-update (scheduled) + const awaitingEvent = events[4].result as TaskStatusUpdateEvent; + expect(awaitingEvent.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-update', + }); + expect(awaitingEvent.status.message?.parts).toMatchObject([ + { + data: { + status: 'scheduled', + request: { callId: 'test-call-id-yolo' }, + }, + }, + ]); + + // Status update: tool-call-update (scheduled) + const scheduledEvent3 = events[5].result as TaskStatusUpdateEvent; + expect(scheduledEvent3.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-update', + }); + expect(scheduledEvent3.status.message?.parts).toMatchObject([ + { + data: { + status: 'scheduled', + request: { callId: 'test-call-id-yolo' }, + }, + }, + ]); + + // Status update: tool-call-update (executing) + const executingEvent = events[6].result as TaskStatusUpdateEvent; + expect(executingEvent.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-update', + }); + expect(executingEvent.status.message?.parts).toMatchObject([ + { + data: { + status: 'executing', + request: { callId: 'test-call-id-yolo' }, + }, + }, + ]); + + // Status update: tool-call-update (success) + const successEvent = events[7].result as TaskStatusUpdateEvent; + expect(successEvent.metadata?.['coderAgent']).toMatchObject({ + kind: 'tool-call-update', + }); + expect(successEvent.status.message?.parts).toMatchObject([ + { + data: { + status: 'success', + request: { callId: 'test-call-id-yolo' }, + }, + }, + ]); + + // Status update: working (before sending tool result to LLM) + const workingEvent3 = events[8].result as TaskStatusUpdateEvent; + expect(workingEvent3.kind).toBe('status-update'); + expect(workingEvent3.status.state).toBe('working'); + + // Status update: text-content (final LLM response) + const textContentEvent = events[9].result as TaskStatusUpdateEvent; + expect(textContentEvent.metadata?.['coderAgent']).toMatchObject({ + kind: 'text-content', + }); + expect(textContentEvent.status.message?.parts).toMatchObject([ + { text: 'Tool executed successfully.' }, + ]); + + assertUniqueFinalEventIsLast(events); + expect(events.length).toBe(11); + }); + + it('should include traceId in status updates when available', async () => { + const traceId = 'test-trace-id'; + sendMessageStreamSpy.mockImplementation(async function* () { + yield* [ + { type: 'content', value: 'Hello', traceId }, + { type: 'thought', value: { subject: 'Thinking...' }, traceId }, + ]; + }); + + const agent = request.agent(app); + const res = await agent + .post('/') + .send(createStreamMessageRequest('hello', 'a2a-trace-id-test')) + .set('Content-Type', 'application/json') + .expect(200); + + const events = streamToSSEEvents(res.text); + + // The first two events are task-creation and working status + const textContentEvent = events[2].result as TaskStatusUpdateEvent; + expect(textContentEvent.kind).toBe('status-update'); + expect(textContentEvent.metadata?.['traceId']).toBe(traceId); + + const thoughtEvent = events[3].result as TaskStatusUpdateEvent; + expect(thoughtEvent.kind).toBe('status-update'); + expect(thoughtEvent.metadata?.['traceId']).toBe(traceId); + }); + + describe('/listCommands', () => { + it('should return a list of top-level commands', async () => { + const mockCommands = [ + { + name: 'test-command', + description: 'A test command', + topLevel: true, + arguments: [{ name: 'arg1', description: 'Argument 1' }], + subCommands: [ + { + name: 'sub-command', + description: 'A sub command', + topLevel: false, + execute: vi.fn(), + }, + ], + execute: vi.fn(), + }, + { + name: 'another-command', + description: 'Another test command', + topLevel: true, + execute: vi.fn(), + }, + { + name: 'not-top-level', + description: 'Not a top level command', + topLevel: false, + execute: vi.fn(), + }, + ]; + + const getAllCommandsSpy = vi + .spyOn(commandRegistry, 'getAllCommands') + .mockReturnValue(mockCommands); + + const agent = request.agent(app); + const res = await agent.get('/listCommands').expect(200); + + expect(res.body).toEqual({ + commands: [ + { + name: 'test-command', + description: 'A test command', + arguments: [{ name: 'arg1', description: 'Argument 1' }], + subCommands: [ + { + name: 'sub-command', + description: 'A sub command', + arguments: [], + subCommands: [], + }, + ], + }, + { + name: 'another-command', + description: 'Another test command', + arguments: [], + subCommands: [], + }, + ], + }); + + expect(getAllCommandsSpy).toHaveBeenCalledOnce(); + getAllCommandsSpy.mockRestore(); + }); + + it('should handle cyclic commands gracefully', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + const cyclicCommand: Command = { + name: 'cyclic-command', + description: 'A cyclic command', + topLevel: true, + execute: vi.fn(), + subCommands: [], + }; + cyclicCommand.subCommands?.push(cyclicCommand); // Create cycle + + const getAllCommandsSpy = vi + .spyOn(commandRegistry, 'getAllCommands') + .mockReturnValue([cyclicCommand]); + + const agent = request.agent(app); + const res = await agent.get('/listCommands').expect(200); + + expect(res.body.commands[0].name).toBe('cyclic-command'); + expect(res.body.commands[0].subCommands).toEqual([]); + + expect(warnSpy).toHaveBeenCalledWith( + 'Command cyclic-command already inserted in the response, skipping', + ); + + getAllCommandsSpy.mockRestore(); + warnSpy.mockRestore(); + }); + }); + + describe('/executeCommand', () => { + const mockExtensions = [{ name: 'test-extension', version: '0.0.1' }]; + + beforeEach(() => { + getExtensionsSpy.mockReturnValue(mockExtensions); + }); + + afterEach(() => { + getExtensionsSpy.mockClear(); + }); + + it('should return extensions for valid command', async () => { + const mockExtensionsCommand = { + name: 'extensions list', + description: 'a mock command', + execute: vi.fn(async (context: CommandContext) => { + // Simulate the actual command's behavior + const extensions = context.config.getExtensions(); + return { name: 'extensions list', data: extensions }; + }), + }; + vi.spyOn(commandRegistry, 'get').mockReturnValue(mockExtensionsCommand); + + const agent = request.agent(app); + const res = await agent + .post('/executeCommand') + .send({ command: 'extensions list', args: [] }) + .set('Content-Type', 'application/json') + .expect(200); + + expect(res.body).toEqual({ + name: 'extensions list', + data: mockExtensions, + }); + expect(getExtensionsSpy).toHaveBeenCalled(); + }); + + it('should return 404 for invalid command', async () => { + vi.spyOn(commandRegistry, 'get').mockReturnValue(undefined); + + const agent = request.agent(app); + const res = await agent + .post('/executeCommand') + .send({ command: 'invalid command' }) + .set('Content-Type', 'application/json') + .expect(404); + + expect(res.body.error).toBe('Command not found: invalid command'); + expect(getExtensionsSpy).not.toHaveBeenCalled(); + }); + + it('should return 400 for missing command', async () => { + const agent = request.agent(app); + await agent + .post('/executeCommand') + .send({ args: [] }) + .set('Content-Type', 'application/json') + .expect(400); + expect(getExtensionsSpy).not.toHaveBeenCalled(); + }); + + it('should return 400 if args is not an array', async () => { + const agent = request.agent(app); + const res = await agent + .post('/executeCommand') + .send({ command: 'extensions.list', args: 'not-an-array' }) + .set('Content-Type', 'application/json') + .expect(400); + + expect(res.body.error).toBe('"args" field must be an array.'); + expect(getExtensionsSpy).not.toHaveBeenCalled(); + }); + + it('should execute a command that does not require a workspace when CODER_AGENT_WORKSPACE_PATH is not set', async () => { + const mockCommand = { + name: 'test-command', + description: 'a mock command', + execute: vi + .fn() + .mockResolvedValue({ name: 'test-command', data: 'success' }), + }; + vi.spyOn(commandRegistry, 'get').mockReturnValue(mockCommand); + + vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', ''); + const response = await request(app) + .post('/executeCommand') + .send({ command: 'test-command', args: [] }); + + expect(response.status).toBe(200); + expect(response.body.data).toBe('success'); + }); + + it('should return 400 for a command that requires a workspace when CODER_AGENT_WORKSPACE_PATH is not set', async () => { + const mockWorkspaceCommand = { + name: 'workspace-command', + description: 'A command that requires a workspace', + requiresWorkspace: true, + execute: vi + .fn() + .mockResolvedValue({ name: 'workspace-command', data: 'success' }), + }; + vi.spyOn(commandRegistry, 'get').mockReturnValue(mockWorkspaceCommand); + + vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', ''); + const response = await request(app) + .post('/executeCommand') + .send({ command: 'workspace-command', args: [] }); + + expect(response.status).toBe(400); + expect(response.body.error).toBe( + 'Command "workspace-command" requires a workspace, but CODER_AGENT_WORKSPACE_PATH is not set.', + ); + }); + + it('should execute a command that requires a workspace when CODER_AGENT_WORKSPACE_PATH is set', async () => { + const mockWorkspaceCommand = { + name: 'workspace-command', + description: 'A command that requires a workspace', + requiresWorkspace: true, + execute: vi + .fn() + .mockResolvedValue({ name: 'workspace-command', data: 'success' }), + }; + vi.spyOn(commandRegistry, 'get').mockReturnValue(mockWorkspaceCommand); + + vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', '/tmp/test-workspace'); + const response = await request(app) + .post('/executeCommand') + .send({ command: 'workspace-command', args: [] }); + + expect(response.status).toBe(200); + expect(response.body.data).toBe('success'); + }); + + it('should include agentExecutor in context', async () => { + const mockCommand = { + name: 'context-check-command', + description: 'checks context', + execute: vi.fn(async (context: CommandContext) => { + if (!context.agentExecutor) { + throw new Error('agentExecutor missing'); + } + return { name: 'context-check-command', data: 'success' }; + }), + }; + vi.spyOn(commandRegistry, 'get').mockReturnValue(mockCommand); + + const agent = request.agent(app); + const res = await agent + .post('/executeCommand') + .send({ command: 'context-check-command', args: [] }) + .set('Content-Type', 'application/json') + .expect(200); + + expect(res.body.data).toBe('success'); + }); + + describe('/executeCommand streaming', () => { + it('should execute a streaming command and stream back events', (done: ( + err?: unknown, + ) => void) => { + const executeSpy = vi.fn(async (context: CommandContext) => { + context.eventBus?.publish({ + kind: 'status-update', + status: { state: 'working' }, + taskId: 'test-task', + contextId: 'test-context', + final: false, + }); + context.eventBus?.publish({ + kind: 'status-update', + status: { state: 'completed' }, + taskId: 'test-task', + contextId: 'test-context', + final: true, + }); + return { name: 'stream-test', data: 'done' }; + }); + + const mockStreamCommand = { + name: 'stream-test', + description: 'A test streaming command', + streaming: true, + execute: executeSpy, + }; + vi.spyOn(commandRegistry, 'get').mockReturnValue(mockStreamCommand); + + const agent = request.agent(app); + agent + .post('/executeCommand') + .send({ command: 'stream-test', args: [] }) + .set('Content-Type', 'application/json') + .set('Accept', 'text/event-stream') + .on('response', (res) => { + let data = ''; + res.on('data', (chunk: Buffer) => { + data += chunk.toString(); + }); + res.on('end', () => { + try { + const events = streamToSSEEvents(data); + expect(events.length).toBe(2); + expect(events[0].result).toEqual({ + kind: 'status-update', + status: { state: 'working' }, + taskId: 'test-task', + contextId: 'test-context', + final: false, + }); + expect(events[1].result).toEqual({ + kind: 'status-update', + status: { state: 'completed' }, + taskId: 'test-task', + contextId: 'test-context', + final: true, + }); + expect(executeSpy).toHaveBeenCalled(); + done(); + } catch (e) { + done(e); + } + }); + }) + .end(); + }); + + it('should handle non-streaming commands gracefully', async () => { + const mockNonStreamCommand = { + name: 'non-stream-test', + description: 'A test non-streaming command', + execute: vi + .fn() + .mockResolvedValue({ name: 'non-stream-test', data: 'done' }), + }; + vi.spyOn(commandRegistry, 'get').mockReturnValue(mockNonStreamCommand); + + const agent = request.agent(app); + const res = await agent + .post('/executeCommand') + .send({ command: 'non-stream-test', args: [] }) + .set('Content-Type', 'application/json') + .expect(200); + + expect(res.body).toEqual({ name: 'non-stream-test', data: 'done' }); + }); + }); + }); + + describe('main', () => { + it('should listen on localhost only', async () => { + const listenSpy = vi + .spyOn(express.application, 'listen') + .mockImplementation((...args: unknown[]) => { + // Trigger the callback passed to listen + const callback = args.find( + (arg): arg is () => void => typeof arg === 'function', + ); + if (callback) { + callback(); + } + + return { + address: () => ({ port: 1234 }), + on: vi.fn(), + once: vi.fn(), + emit: vi.fn(), + } as unknown as Server; + }); + + // Avoid process.exit if possible, or mock it if main might fail + const exitSpy = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + + await main(); + + expect(listenSpy).toHaveBeenCalledWith( + expect.any(Number), + 'localhost', + expect.any(Function), + ); + + listenSpy.mockRestore(); + exitSpy.mockRestore(); + }); + }); +}); diff --git a/packages/a2a-server/src/http/app.ts b/packages/a2a-server/src/http/app.ts new file mode 100644 index 0000000000000000000000000000000000000000..762c809a07a2f1a9821cf8dbf1a80d03b0ccadd3 --- /dev/null +++ b/packages/a2a-server/src/http/app.ts @@ -0,0 +1,435 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import express, { type Request } from 'express'; + +import type { AgentCard, Message } from '@a2a-js/sdk'; +import { + type TaskStore, + DefaultRequestHandler, + InMemoryTaskStore, + DefaultExecutionEventBus, + type AgentExecutionEvent, + UnauthenticatedUser, +} from '@a2a-js/sdk/server'; +import { A2AExpressApp, type UserBuilder } from '@a2a-js/sdk/server/express'; // Import server components +import { v4 as uuidv4 } from 'uuid'; +import { logger } from '../utils/logger.js'; +import type { AgentSettings } from '../types.js'; +import { GCSTaskStore, NoOpTaskStore } from '../persistence/gcs.js'; +import { CoderAgentExecutor } from '../agent/executor.js'; +import { requestStorage } from './requestStorage.js'; +import { loadConfig, loadEnvironment, setTargetDir } from '../config/config.js'; +import { loadSettings } from '../config/settings.js'; +import { loadExtensions } from '../config/extension.js'; +import { commandRegistry } from '../commands/command-registry.js'; +import { + debugLogger, + SimpleExtensionLoader, + GitService, + checkPathTrust, + isHeadlessMode, +} from '@google/gemini-cli-core'; +import type { Command, CommandArgument } from '../commands/types.js'; + +type CommandResponse = { + name: string; + description: string; + arguments: CommandArgument[]; + subCommands: CommandResponse[]; +}; + +const coderAgentCard: AgentCard = { + name: 'Gemini SDLC Agent', + description: + 'An agent that generates code based on natural language instructions and streams file outputs.', + url: 'http://localhost:41242/', + provider: { + organization: 'Google', + url: 'https://google.com', + }, + protocolVersion: '0.3.0', + version: '0.0.2', // Incremented version + capabilities: { + streaming: true, + pushNotifications: false, + stateTransitionHistory: true, + }, + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + }, + basicAuth: { + type: 'http', + scheme: 'basic', + }, + }, + security: [{ bearerAuth: [] }, { basicAuth: [] }], + defaultInputModes: ['text'], + defaultOutputModes: ['text'], + skills: [ + { + id: 'code_generation', + name: 'Code Generation', + description: + 'Generates code snippets or complete files based on user requests, streaming the results.', + tags: ['code', 'development', 'programming'], + examples: [ + 'Write a python function to calculate fibonacci numbers.', + 'Create an HTML file with a basic button that alerts "Hello!" when clicked.', + ], + inputModes: ['text'], + outputModes: ['text'], + }, + ], + supportsAuthenticatedExtendedCard: false, +}; + +export function updateCoderAgentCardUrl(port: number) { + coderAgentCard.url = `http://localhost:${port}/`; +} + +const customUserBuilder: UserBuilder = async (req: Request) => { + const auth = req.headers['authorization']; + if (auth) { + const scheme = auth.split(' ')[0]; + logger.info( + `[customUserBuilder] Received Authorization header with scheme: ${scheme}`, + ); + } + if (!auth) return new UnauthenticatedUser(); + + // 1. Bearer Auth + if (auth.startsWith('Bearer ')) { + const token = auth.substring(7); + if (token === 'valid-token') { + return { userName: 'bearer-user', isAuthenticated: true }; + } + } + + // 2. Basic Auth + if (auth.startsWith('Basic ')) { + const credentials = Buffer.from(auth.substring(6), 'base64').toString(); + if (credentials === 'admin:password') { + return { userName: 'basic-user', isAuthenticated: true }; + } + } + + return new UnauthenticatedUser(); +}; + +async function handleExecuteCommand( + req: express.Request, + res: express.Response, + context: { + config: Awaited>; + git: GitService | undefined; + agentExecutor: CoderAgentExecutor; + }, +) { + logger.info('[CoreAgent] Received /executeCommand request: ', req.body); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { command, args } = req.body; + try { + if (typeof command !== 'string') { + return res.status(400).json({ error: 'Invalid "command" field.' }); + } + + if (args && !Array.isArray(args)) { + return res.status(400).json({ error: '"args" field must be an array.' }); + } + + const commandToExecute = commandRegistry.get(command); + + if (commandToExecute?.requiresWorkspace) { + if (!process.env['CODER_AGENT_WORKSPACE_PATH']) { + return res.status(400).json({ + error: `Command "${command}" requires a workspace, but CODER_AGENT_WORKSPACE_PATH is not set.`, + }); + } + } + + if (!commandToExecute) { + return res.status(404).json({ error: `Command not found: ${command}` }); + } + + if (commandToExecute.streaming) { + const eventBus = new DefaultExecutionEventBus(); + res.setHeader('Content-Type', 'text/event-stream'); + const eventHandler = (event: AgentExecutionEvent) => { + const jsonRpcResponse = { + jsonrpc: '2.0', + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + id: 'taskId' in event ? event.taskId : (event as Message).messageId, + result: event, + }; + res.write(`data: ${JSON.stringify(jsonRpcResponse)}\n`); + }; + eventBus.on('event', eventHandler); + + await commandToExecute.execute({ ...context, eventBus }, args ?? []); + + eventBus.off('event', eventHandler); + eventBus.finished(); + return res.end(); // Explicit return for streaming path + } else { + const result = await commandToExecute.execute(context, args ?? []); + logger.info('[CoreAgent] Sending /executeCommand response: ', result); + return res.status(200).json(result); + } + } catch (e) { + logger.error( + `Error executing /executeCommand: ${command} with args: ${JSON.stringify( + args, + )}`, + e, + ); + const errorMessage = + e instanceof Error ? e.message : 'Unknown error executing command'; + return res.status(500).json({ error: errorMessage }); + } +} + +export async function createApp() { + try { + // Load the server configuration once on startup. + const workspaceRoot = await setTargetDir(undefined); + + // Use a temporary settings load to check if folder trust is enabled. + // This is similar to how the CLI handles the initial trust check. + const initialSettings = loadSettings(workspaceRoot, false); + const { isTrusted } = checkPathTrust({ + path: workspaceRoot, + isFolderTrustEnabled: initialSettings.folderTrust ?? true, + isHeadless: isHeadlessMode(), + }); + + // Change the global working directory to the workspace root during startup + process.chdir(workspaceRoot); + + // Load environment globally for the server startup + const globalEnv = await loadEnvironment(isTrusted ?? false, workspaceRoot); + // Only assign safe server-config variables to process.env to prevent credential leakage + const allowedServerKeys = [ + 'CODER_AGENT_PORT', + 'CODER_AGENT_WORKSPACE_PATH', + 'GCS_BUCKET_NAME', + 'LOG_LEVEL', + 'GOOGLE_APPLICATION_CREDENTIALS', + 'GOOGLE_CLOUD_PROJECT', + 'GEMINI_CLI_USE_COMPUTE_ADC', + ]; + for (const key of allowedServerKeys) { + if (globalEnv[key] !== undefined) { + process.env[key] = globalEnv[key]; + } + } + + const settings = loadSettings(workspaceRoot, isTrusted ?? false); + const extensions = loadExtensions(workspaceRoot, isTrusted ?? false); + const config = await loadConfig( + settings, + new SimpleExtensionLoader(extensions), + 'a2a-server', + isTrusted ?? false, + workspaceRoot, + ); + + let git: GitService | undefined; + if (config.getCheckpointingEnabled()) { + git = new GitService(config.getTargetDir(), config.storage); + await git.initialize(); + } + + // loadEnvironment() is called within getConfig now + const bucketName = process.env['GCS_BUCKET_NAME']; + let taskStoreForExecutor: TaskStore; + let taskStoreForHandler: TaskStore; + + if (bucketName) { + logger.info(`Using GCSTaskStore with bucket: ${bucketName}`); + const gcsTaskStore = new GCSTaskStore(bucketName); + taskStoreForExecutor = gcsTaskStore; + taskStoreForHandler = new NoOpTaskStore(gcsTaskStore); + } else { + logger.info('Using InMemoryTaskStore'); + const inMemoryTaskStore = new InMemoryTaskStore(); + taskStoreForExecutor = inMemoryTaskStore; + taskStoreForHandler = inMemoryTaskStore; + } + + const agentExecutor = new CoderAgentExecutor(taskStoreForExecutor); + + const context = { config, git, agentExecutor }; + + const requestHandler = new DefaultRequestHandler( + coderAgentCard, + taskStoreForHandler, + agentExecutor, + ); + + let expressApp = express(); + expressApp.use((req, res, next) => { + requestStorage.run({ req }, next); + }); + + const appBuilder = new A2AExpressApp(requestHandler, customUserBuilder); + expressApp = appBuilder.setupRoutes(expressApp, ''); + expressApp.use(express.json()); + + expressApp.post('/tasks', async (req, res) => { + try { + const taskId = uuidv4(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const agentSettings = req.body.agentSettings as + | AgentSettings + | undefined; + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const contextId = req.body.contextId || uuidv4(); + const wrapper = await agentExecutor.createTask( + taskId, + contextId, + agentSettings, + ); + await taskStoreForExecutor.save(wrapper.toSDKTask()); + res.status(201).json(wrapper.id); + } catch (error) { + logger.error('[CoreAgent] Error creating task:', error); + const errorMessage = + error instanceof Error + ? error.message + : 'Unknown error creating task'; + res.status(500).send({ error: errorMessage }); + } + }); + + expressApp.post('/executeCommand', (req, res) => { + void handleExecuteCommand(req, res, context); + }); + + expressApp.get('/listCommands', (req, res) => { + try { + const transformCommand = ( + command: Command, + visited: string[], + ): CommandResponse | undefined => { + const commandName = command.name; + if (visited.includes(commandName)) { + debugLogger.warn( + `Command ${commandName} already inserted in the response, skipping`, + ); + return undefined; + } + + return { + name: command.name, + description: command.description, + arguments: command.arguments ?? [], + subCommands: (command.subCommands ?? []) + .map((subCommand) => + transformCommand(subCommand, visited.concat(commandName)), + ) + .filter( + (subCommand): subCommand is CommandResponse => !!subCommand, + ), + }; + }; + + const commands = commandRegistry + .getAllCommands() + .filter((command) => command.topLevel) + .map((command) => transformCommand(command, [])); + + return res.status(200).json({ commands }); + } catch (e) { + logger.error('Error executing /listCommands:', e); + const errorMessage = + e instanceof Error ? e.message : 'Unknown error listing commands'; + return res.status(500).json({ error: errorMessage }); + } + }); + + expressApp.get('/tasks/metadata', async (req, res) => { + // This endpoint is only meaningful if the task store is in-memory. + if (!(taskStoreForExecutor instanceof InMemoryTaskStore)) { + res.status(501).send({ + error: + 'Listing all task metadata is only supported when using InMemoryTaskStore.', + }); + return; + } + try { + const wrappers = agentExecutor.getAllTasks(); + if (wrappers && wrappers.length > 0) { + const tasksMetadata = await Promise.all( + wrappers.map((wrapper) => wrapper.task.getMetadata()), + ); + res.status(200).json(tasksMetadata); + } else { + res.status(204).send(); + } + } catch (error) { + logger.error('[CoreAgent] Error getting all task metadata:', error); + const errorMessage = + error instanceof Error + ? error.message + : 'Unknown error getting task metadata'; + res.status(500).send({ error: errorMessage }); + } + }); + + expressApp.get('/tasks/:taskId/metadata', async (req, res) => { + const taskId = req.params.taskId; + let wrapper = agentExecutor.getTask(taskId); + if (!wrapper) { + const sdkTask = await taskStoreForExecutor.load(taskId); + if (sdkTask) { + wrapper = await agentExecutor.reconstruct(sdkTask); + } + } + if (!wrapper) { + res.status(404).send({ error: 'Task not found' }); + return; + } + res.json({ metadata: await wrapper.task.getMetadata() }); + }); + return expressApp; + } catch (error) { + logger.error('[CoreAgent] Error during startup:', error); + process.exit(1); + } +} + +export async function main() { + try { + const expressApp = await createApp(); + const port = Number(process.env['CODER_AGENT_PORT'] || 0); + + const server = expressApp.listen(port, 'localhost', () => { + const address = server.address(); + let actualPort; + if (process.env['CODER_AGENT_PORT']) { + actualPort = process.env['CODER_AGENT_PORT']; + } else if (address && typeof address !== 'string') { + actualPort = address.port; + } else { + throw new Error('[Core Agent] Could not find port number.'); + } + updateCoderAgentCardUrl(Number(actualPort)); + logger.info( + `[CoreAgent] Agent Server started on http://localhost:${actualPort}`, + ); + logger.info( + `[CoreAgent] Agent Card: http://localhost:${actualPort}/.well-known/agent-card.json`, + ); + logger.info('[CoreAgent] Press Ctrl+C to stop the server'); + }); + } catch (error) { + logger.error('[CoreAgent] Error during startup:', error); + process.exit(1); + } +} diff --git a/packages/a2a-server/src/http/endpoints.test.ts b/packages/a2a-server/src/http/endpoints.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..eec091f10af05ff329a3d8b23fc7f4a2e28b0b7a --- /dev/null +++ b/packages/a2a-server/src/http/endpoints.test.ts @@ -0,0 +1,245 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + beforeAll, + afterAll, + beforeEach, + afterEach, + vi, +} from 'vitest'; +import request from 'supertest'; +import type express from 'express'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; + +import { createApp, updateCoderAgentCardUrl } from './app.js'; +import type { TaskMetadata } from '../types.js'; +import { createMockConfig } from '../utils/testing_utils.js'; +import { debugLogger, type Config } from '@google/gemini-cli-core'; +import { logger } from '../utils/logger.js'; + +// Mock the logger to avoid polluting test output +// Comment out to help debug +vi.mock('../utils/logger.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() }, +})); + +vi.mock('../persistence/gcs.js', () => { + class MockGCSTaskStore { + load = vi.fn(); + save = vi.fn(); + } + class MockNoOpTaskStore { + constructor(public store: unknown) {} + } + return { + GCSTaskStore: MockGCSTaskStore, + NoOpTaskStore: MockNoOpTaskStore, + }; +}); + +// Mock Task.create to avoid its complex setup +vi.mock('../agent/task.js', () => { + class MockTask { + id: string; + contextId: string; + taskState = 'submitted'; + config = { + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ model: 'gemini-pro' }), + }; + geminiClient = { + initialize: vi.fn().mockResolvedValue(undefined), + }; + constructor(id: string, contextId: string) { + this.id = id; + this.contextId = contextId; + } + static create = vi + .fn() + .mockImplementation((id, contextId) => + Promise.resolve(new MockTask(id, contextId)), + ); + getMetadata = vi.fn().mockImplementation(async () => ({ + id: this.id, + contextId: this.contextId, + taskState: this.taskState, + model: 'gemini-pro', + mcpServers: [], + availableTools: [], + })); + } + return { Task: MockTask }; +}); + +vi.mock('../config/config.js', async () => { + const actual = await vi.importActual('../config/config.js'); + return { + ...actual, + loadConfig: vi + .fn() + .mockImplementation(async () => createMockConfig({}) as Config), + }; +}); + +describe('Agent Server Endpoints', () => { + let app: express.Express; + let server: Server; + let testWorkspace: string; + + const createTask = (contextId: string) => + request(app) + .post('/tasks') + .send({ + contextId, + agentSettings: { + kind: 'agent-settings', + workspacePath: testWorkspace, + }, + }) + .set('Content-Type', 'application/json'); + + beforeAll(async () => { + // Create a unique temporary directory for the workspace to avoid conflicts + testWorkspace = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-agent-test-'), + ); + app = await createApp(); + await new Promise((resolve) => { + server = app.listen(0, () => { + const port = (server.address() as AddressInfo).port; + updateCoderAgentCardUrl(port); + resolve(); + }); + }); + }); + + afterAll(async () => { + if (server) { + await new Promise((resolve, reject) => { + server.close((err) => { + if (err) return reject(err); + resolve(); + }); + }); + } + + if (testWorkspace) { + try { + fs.rmSync(testWorkspace, { recursive: true, force: true }); + } catch (e) { + debugLogger.warn(`Could not remove temp dir '${testWorkspace}':`, e); + } + } + }); + + it('should create a new task via POST /tasks', async () => { + const response = await createTask('test-context'); + expect(response.status).toBe(201); + expect(response.body).toBeTypeOf('string'); // Should return the task ID + }, 7000); + + it('should get metadata for a specific task via GET /tasks/:taskId/metadata', async () => { + const createResponse = await createTask('test-context-2'); + const taskId = createResponse.body; + const response = await request(app).get(`/tasks/${taskId}/metadata`); + expect(response.status).toBe(200); + expect(response.body.metadata.id).toBe(taskId); + }, 6000); + + it('should get metadata for all tasks via GET /tasks/metadata', async () => { + const createResponse = await createTask('test-context-3'); + const taskId = createResponse.body; + const response = await request(app).get('/tasks/metadata'); + expect(response.status).toBe(200); + expect(Array.isArray(response.body)).toBe(true); + expect(response.body.length).toBeGreaterThan(0); + const taskMetadata = response.body.find( + (m: TaskMetadata) => m.id === taskId, + ); + expect(taskMetadata).toBeDefined(); + }); + + it('should return 404 for a non-existent task', async () => { + const response = await request(app).get('/tasks/fake-task/metadata'); + expect(response.status).toBe(404); + }); + + it('should return agent metadata via GET /.well-known/agent-card.json', async () => { + const response = await request(app).get('/.well-known/agent-card.json'); + const port = (server.address() as AddressInfo).port; + expect(response.status).toBe(200); + expect(response.body.name).toBe('Gemini SDLC Agent'); + expect(response.body.url).toBe(`http://localhost:${port}/`); + }); + + describe('GET /tasks/metadata with non-InMemoryTaskStore', () => { + let nonInMemoryApp: express.Express; + let nonInMemoryServer: Server; + let nonInMemoryWorkspace: string; + let originalCwd: string; + + beforeEach(async () => { + originalCwd = process.cwd(); + nonInMemoryWorkspace = fs.mkdtempSync( + path.join(os.tmpdir(), 'gemini-agent-gcs-test-'), + ); + vi.stubEnv('CODER_AGENT_WORKSPACE_PATH', nonInMemoryWorkspace); + vi.stubEnv('GCS_BUCKET_NAME', 'test-bucket'); + nonInMemoryApp = await createApp(); + await new Promise((resolve) => { + nonInMemoryServer = nonInMemoryApp.listen(0, () => { + resolve(); + }); + }); + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + if (nonInMemoryServer) { + await new Promise((resolve, reject) => { + nonInMemoryServer.close((err) => { + if (err) return reject(err); + resolve(); + }); + }); + } + if (originalCwd) { + process.chdir(originalCwd); + } + if (nonInMemoryWorkspace) { + try { + fs.rmSync(nonInMemoryWorkspace, { recursive: true, force: true }); + } catch (e) { + debugLogger.warn( + `Could not remove temp dir '${nonInMemoryWorkspace}':`, + e, + ); + } + } + }); + + it('should return 501 and not fall through to send a second response', async () => { + vi.mocked(logger.error).mockClear(); + const response = await request(nonInMemoryApp).get('/tasks/metadata'); + expect(response.status).toBe(501); + expect(response.body).toEqual({ + error: + 'Listing all task metadata is only supported when using InMemoryTaskStore.', + }); + // Without return, the handler falls through and triggers ERR_HTTP_HEADERS_SENT + expect(logger.error).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/a2a-server/src/http/requestStorage.ts b/packages/a2a-server/src/http/requestStorage.ts new file mode 100644 index 0000000000000000000000000000000000000000..64aa13a046ff010cf6cc1e3d54eb0b58d6518a0b --- /dev/null +++ b/packages/a2a-server/src/http/requestStorage.ts @@ -0,0 +1,10 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type express from 'express'; +import { AsyncLocalStorage } from 'node:async_hooks'; + +export const requestStorage = new AsyncLocalStorage<{ req: express.Request }>(); diff --git a/packages/a2a-server/src/http/server.ts b/packages/a2a-server/src/http/server.ts new file mode 100644 index 0000000000000000000000000000000000000000..c22be4933125099c0894967e49d6f3a7c5cae29a --- /dev/null +++ b/packages/a2a-server/src/http/server.ts @@ -0,0 +1,34 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as url from 'node:url'; +import * as path from 'node:path'; + +import { logger } from '../utils/logger.js'; +import { main } from './app.js'; + +// Check if the module is the main script being run +const isMainModule = + path.basename(process.argv[1]) === + path.basename(url.fileURLToPath(import.meta.url)); + +if ( + import.meta.url.startsWith('file:') && + isMainModule && + process.env['NODE_ENV'] !== 'test' +) { + process.on('uncaughtException', (error) => { + logger.error('Unhandled exception:', error); + process.exit(1); + }); + + main().catch((error) => { + logger.error('[CoreAgent] Unhandled error in main:', error); + process.exit(1); + }); +} diff --git a/packages/a2a-server/src/index.ts b/packages/a2a-server/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..4db9a7ca92989638d52404258692c278328f6265 --- /dev/null +++ b/packages/a2a-server/src/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './agent/executor.js'; +export * from './http/app.js'; +export * from './types.js'; diff --git a/packages/a2a-server/src/persistence/gcs.test.ts b/packages/a2a-server/src/persistence/gcs.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..353a8312d573a7f79918addd2e89535e3c084b97 --- /dev/null +++ b/packages/a2a-server/src/persistence/gcs.test.ts @@ -0,0 +1,446 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Storage } from '@google-cloud/storage'; +import * as fse from 'fs-extra'; +import * as tar from 'tar'; +import { gzipSync, gunzipSync } from 'node:zlib'; +import { v4 as uuidv4 } from 'uuid'; +import type { Task as SDKTask } from '@a2a-js/sdk'; +import type { TaskStore } from '@a2a-js/sdk/server'; +import { + describe, + it, + expect, + beforeEach, + vi, + type Mocked, + type MockedClass, + type Mock, +} from 'vitest'; + +import { GCSTaskStore, NoOpTaskStore } from './gcs.js'; +import { logger } from '../utils/logger.js'; +import * as configModule from '../config/config.js'; +import { getPersistedState, METADATA_KEY } from '../types.js'; + +// Mock dependencies +const fsMocks = vi.hoisted(() => ({ + readdir: vi.fn(), + createReadStream: vi.fn(), +})); + +vi.mock('@google-cloud/storage'); +vi.mock('fs-extra', () => ({ + pathExists: vi.fn(), + readdir: vi.fn(), + remove: vi.fn(), + ensureDir: vi.fn(), + createReadStream: vi.fn(), +})); +vi.mock('node:fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + promises: { + ...actual.promises, + readdir: fsMocks.readdir, + }, + createReadStream: fsMocks.createReadStream, + }; +}); +vi.mock('fs', async () => { + const actual = await vi.importActual('node:fs'); + return { + ...actual, + promises: { + ...actual.promises, + readdir: fsMocks.readdir, + }, + createReadStream: fsMocks.createReadStream, + }; +}); +vi.mock('tar', async () => { + const actualFs = await vi.importActual('node:fs'); + return { + c: vi.fn(({ file }) => { + if (file) { + actualFs.writeFileSync(file, Buffer.from('dummy tar content')); + } + return Promise.resolve(); + }), + x: vi.fn().mockResolvedValue(undefined), + t: vi.fn().mockResolvedValue(undefined), + r: vi.fn().mockResolvedValue(undefined), + u: vi.fn().mockResolvedValue(undefined), + }; +}); +vi.mock('zlib'); +vi.mock('uuid'); +vi.mock('../utils/logger.js', () => ({ + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, +})); +vi.mock('../config/config.js', () => ({ + setTargetDir: vi.fn(), +})); +vi.mock('node:stream/promises', () => ({ + pipeline: vi.fn(), +})); +vi.mock('../types.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getPersistedState: vi.fn(), + }; +}); + +const mockStorage = Storage as MockedClass; +const mockFse = fse as Mocked; +const mockCreateReadStream = fsMocks.createReadStream; +const mockTar = tar as Mocked; +const mockGzipSync = gzipSync as Mock; +const mockGunzipSync = gunzipSync as Mock; +const mockUuidv4 = uuidv4 as Mock; +const mockSetTargetDir = configModule.setTargetDir as Mock; +const mockGetPersistedState = getPersistedState as Mock; +const TEST_METADATA_KEY = METADATA_KEY || '__persistedState'; + +type MockWriteStream = { + emit: Mock<(event: string, ...args: unknown[]) => boolean>; + removeListener: Mock< + (event: string, cb: (error?: Error | null) => void) => MockWriteStream + >; + once: Mock< + (event: string, cb: (error?: Error | null) => void) => MockWriteStream + >; + on: Mock< + (event: string, cb: (error?: Error | null) => void) => MockWriteStream + >; + destroy: Mock<() => void>; + write: Mock<(chunk: unknown, encoding?: unknown, cb?: unknown) => boolean>; + end: Mock<(cb?: unknown) => void>; + destroyed: boolean; +}; + +type MockFile = { + save: Mock<(data: Buffer | string) => Promise>; + download: Mock<() => Promise<[Buffer]>>; + exists: Mock<() => Promise<[boolean]>>; + createWriteStream: Mock<() => MockWriteStream>; +}; + +type MockBucket = { + exists: Mock<() => Promise<[boolean]>>; + file: Mock<(path: string) => MockFile>; + name: string; +}; + +type MockStorageInstance = { + bucket: Mock<(name: string) => MockBucket>; + getBuckets: Mock<() => Promise<[Array<{ name: string }>]>>; + createBucket: Mock<(name: string) => Promise<[MockBucket]>>; +}; + +describe('GCSTaskStore', () => { + let bucketName: string; + let mockBucket: MockBucket; + let mockFile: MockFile; + let mockWriteStream: MockWriteStream; + let mockStorageInstance: MockStorageInstance; + + beforeEach(() => { + vi.clearAllMocks(); + bucketName = 'test-bucket'; + + mockWriteStream = { + emit: vi.fn().mockReturnValue(true), + removeListener: vi.fn().mockReturnValue(mockWriteStream), + on: vi.fn((event, cb) => { + if (event === 'finish') setTimeout(cb, 0); // Simulate async finish + return mockWriteStream; + }), + once: vi.fn((event, cb) => { + if (event === 'finish') setTimeout(cb, 0); // Simulate async finish return mockWriteStream; + }), + destroy: vi.fn(), + write: vi.fn().mockReturnValue(true), + end: vi.fn(), + destroyed: false, + }; + + mockFile = { + save: vi.fn().mockResolvedValue(undefined), + download: vi.fn().mockResolvedValue([Buffer.from('')]), + exists: vi.fn().mockResolvedValue([true]), + createWriteStream: vi.fn().mockReturnValue(mockWriteStream), + }; + + mockBucket = { + exists: vi.fn().mockResolvedValue([true]), + file: vi.fn().mockReturnValue(mockFile), + name: bucketName, + }; + + mockStorageInstance = { + bucket: vi.fn().mockReturnValue(mockBucket), + getBuckets: vi.fn().mockResolvedValue([[{ name: bucketName }]]), + createBucket: vi.fn().mockResolvedValue([mockBucket]), + }; + mockStorage.mockReturnValue(mockStorageInstance as unknown as Storage); + + mockUuidv4.mockReturnValue('test-uuid'); + mockSetTargetDir.mockReturnValue('/tmp/workdir'); + mockGetPersistedState.mockReturnValue({ + _agentSettings: {}, + _taskState: 'submitted', + }); + (fse.pathExists as Mock).mockResolvedValue(true); + fsMocks.readdir.mockResolvedValue(['file1.txt']); + mockFse.remove.mockResolvedValue(undefined); + mockFse.ensureDir.mockResolvedValue(undefined); + mockGzipSync.mockReturnValue(Buffer.from('compressed')); + mockGunzipSync.mockReturnValue(Buffer.from('{}')); + mockCreateReadStream.mockReturnValue({ on: vi.fn(), pipe: vi.fn() }); + mockFse.createReadStream.mockReturnValue({ + on: vi.fn(), + pipe: vi.fn(), + } as unknown as import('node:fs').ReadStream); + }); + + describe('Constructor & Initialization', () => { + it('should initialize and check bucket existence', async () => { + const store = new GCSTaskStore(bucketName); + await store['ensureBucketInitialized'](); + expect(mockStorage).toHaveBeenCalledTimes(1); + expect(mockStorageInstance.getBuckets).toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('Bucket test-bucket exists'), + ); + }); + + it('should create bucket if it does not exist', async () => { + mockStorageInstance.getBuckets.mockResolvedValue([[]]); + const store = new GCSTaskStore(bucketName); + await store['ensureBucketInitialized'](); + expect(mockStorageInstance.createBucket).toHaveBeenCalledWith(bucketName); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('Bucket test-bucket created successfully'), + ); + }); + + it('should throw if bucket creation fails', async () => { + mockStorageInstance.getBuckets.mockResolvedValue([[]]); + mockStorageInstance.createBucket.mockRejectedValue( + new Error('Create failed'), + ); + const store = new GCSTaskStore(bucketName); + await expect(store['ensureBucketInitialized']()).rejects.toThrow( + 'Failed to create GCS bucket test-bucket: Error: Create failed', + ); + }); + }); + + describe('save', () => { + const mockTask: SDKTask = { + id: 'task1', + contextId: 'ctx1', + kind: 'task', + status: { state: 'working' }, + metadata: {}, + }; + + it('should save metadata and workspace', async () => { + const store = new GCSTaskStore(bucketName); + await store.save(mockTask); + + expect(mockFile.save).toHaveBeenCalledTimes(1); + expect(mockTar.c).toHaveBeenCalledTimes(1); + expect(mockFse.remove).toHaveBeenCalledTimes(1); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('metadata saved to GCS'), + ); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('workspace saved to GCS'), + ); + }); + + it('should handle tar creation failure', async () => { + mockFse.pathExists.mockImplementation( + async (path) => + !path.toString().includes('task-task1-workspace-test-uuid.tar.gz'), + ); + const store = new GCSTaskStore(bucketName); + await expect(store.save(mockTask)).rejects.toThrow( + 'tar.c command failed to create', + ); + }); + + it('should throw an error if taskId contains path traversal sequences', async () => { + const store = new GCSTaskStore('test-bucket'); + const maliciousTask: SDKTask = { + id: '../../../malicious-task', + metadata: { + _internal: { + agentSettings: { + cacheDir: '/tmp/cache', + dataDir: '/tmp/data', + logDir: '/tmp/logs', + tempDir: '/tmp/temp', + }, + taskState: 'working', + }, + }, + kind: 'task', + status: { + state: 'working', + timestamp: new Date().toISOString(), + }, + contextId: 'test-context', + history: [], + artifacts: [], + }; + await expect(store.save(maliciousTask)).rejects.toThrow( + 'Invalid taskId: ../../../malicious-task', + ); + }); + }); + + describe('load', () => { + it('should load task metadata and workspace', async () => { + mockGunzipSync.mockReturnValue( + Buffer.from( + JSON.stringify({ + [TEST_METADATA_KEY]: { + _agentSettings: {}, + _taskState: 'submitted', + }, + _contextId: 'ctx1', + }), + ), + ); + mockFile.download.mockResolvedValue([Buffer.from('compressed metadata')]); + mockFile.download.mockResolvedValueOnce([ + Buffer.from('compressed metadata'), + ]); + mockBucket.file = vi.fn((path) => { + const newMockFile = { ...mockFile }; + if (path.includes('metadata')) { + newMockFile.download = vi + .fn() + .mockResolvedValue([Buffer.from('compressed metadata')]); + newMockFile.exists = vi.fn().mockResolvedValue([true]); + } else { + newMockFile.download = vi + .fn() + .mockResolvedValue([Buffer.from('compressed workspace')]); + newMockFile.exists = vi.fn().mockResolvedValue([true]); + } + return newMockFile; + }); + + const store = new GCSTaskStore(bucketName); + const task = await store.load('task1'); + + expect(task).toBeDefined(); + expect(task?.id).toBe('task1'); + expect(mockBucket.file).toHaveBeenCalledWith( + 'tasks/task1/metadata.tar.gz', + ); + expect(mockBucket.file).toHaveBeenCalledWith( + 'tasks/task1/workspace.tar.gz', + ); + expect(mockTar.x).toHaveBeenCalledTimes(1); + expect(mockFse.remove).toHaveBeenCalledTimes(1); + }); + + it('should return undefined if metadata not found', async () => { + mockFile.exists.mockResolvedValue([false]); + const store = new GCSTaskStore(bucketName); + const task = await store.load('task1'); + expect(task).toBeUndefined(); + expect(mockBucket.file).toHaveBeenCalledWith( + 'tasks/task1/metadata.tar.gz', + ); + }); + + it('should load metadata even if workspace not found', async () => { + mockGunzipSync.mockReturnValue( + Buffer.from( + JSON.stringify({ + [TEST_METADATA_KEY]: { + _agentSettings: {}, + _taskState: 'submitted', + }, + _contextId: 'ctx1', + }), + ), + ); + + mockBucket.file = vi.fn((path) => { + const newMockFile = { ...mockFile }; + if (path.includes('workspace.tar.gz')) { + newMockFile.exists = vi.fn().mockResolvedValue([false]); + } else { + newMockFile.exists = vi.fn().mockResolvedValue([true]); + newMockFile.download = vi + .fn() + .mockResolvedValue([Buffer.from('compressed metadata')]); + } + return newMockFile; + }); + + const store = new GCSTaskStore(bucketName); + const task = await store.load('task1'); + + expect(task).toBeDefined(); + expect(mockTar.x).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith( + expect.stringContaining('workspace archive not found'), + ); + }); + }); + + it('should throw an error if taskId contains path traversal sequences', async () => { + const store = new GCSTaskStore('test-bucket'); + const maliciousTaskId = '../../../malicious-task'; + await expect(store.load(maliciousTaskId)).rejects.toThrow( + `Invalid taskId: ${maliciousTaskId}`, + ); + }); +}); + +describe('NoOpTaskStore', () => { + let realStore: TaskStore; + let noOpStore: NoOpTaskStore; + + beforeEach(() => { + // Create a mock of the real store to delegate to + realStore = { + save: vi.fn(), + load: vi.fn().mockResolvedValue({ id: 'task-123' } as SDKTask), + }; + noOpStore = new NoOpTaskStore(realStore); + }); + + it("should not call the real store's save method", async () => { + const mockTask: SDKTask = { id: 'test-task' } as SDKTask; + await noOpStore.save(mockTask); + expect(realStore.save).not.toHaveBeenCalled(); + }); + + it('should delegate the load method to the real store', async () => { + const taskId = 'task-123'; + const result = await noOpStore.load(taskId); + expect(realStore.load).toHaveBeenCalledWith(taskId); + expect(result).toBeDefined(); + expect(result?.id).toBe(taskId); + }); +}); diff --git a/packages/a2a-server/src/persistence/gcs.ts b/packages/a2a-server/src/persistence/gcs.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c339d47c37dab467828000aabe6e0ec50d5c475 --- /dev/null +++ b/packages/a2a-server/src/persistence/gcs.ts @@ -0,0 +1,319 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Storage } from '@google-cloud/storage'; +import { gzipSync, gunzipSync } from 'node:zlib'; +import * as tar from 'tar'; +import * as fse from 'fs-extra'; +import { promises as fsPromises, createReadStream } from 'node:fs'; +import { tmpdir } from '@google/gemini-cli-core'; +import { join } from 'node:path'; +import type { Task as SDKTask } from '@a2a-js/sdk'; +import type { TaskStore } from '@a2a-js/sdk/server'; +import { logger } from '../utils/logger.js'; +import { setTargetDir } from '../config/config.js'; +import { getPersistedState, type PersistedTaskMetadata } from '../types.js'; +import { v4 as uuidv4 } from 'uuid'; + +type ObjectType = 'metadata' | 'workspace'; + +const getTmpArchiveFilename = (taskId: string): string => + `task-${taskId}-workspace-${uuidv4()}.tar.gz`; + +// Validate the taskId to prevent path traversal attacks by ensuring it only contains safe characters. +const isTaskIdValid = (taskId: string): boolean => { + // Allow only alphanumeric characters, dashes, and underscores, and ensure it's not empty. + const validTaskIdRegex = /^[a-zA-Z0-9_-]+$/; + return validTaskIdRegex.test(taskId); +}; + +export class GCSTaskStore implements TaskStore { + private storage: Storage; + private bucketName: string; + private bucketInitialized: Promise; + + constructor(bucketName: string) { + if (!bucketName) { + throw new Error('GCS bucket name is required.'); + } + this.storage = new Storage(); + this.bucketName = bucketName; + logger.info(`GCSTaskStore initializing with bucket: ${this.bucketName}`); + // Prerequisites: user account or service account must have storage admin IAM role + // and the bucket name must be unique. + this.bucketInitialized = this.initializeBucket(); + } + + private async initializeBucket(): Promise { + try { + const [buckets] = await this.storage.getBuckets(); + const exists = buckets.some((bucket) => bucket.name === this.bucketName); + + if (!exists) { + logger.info( + `Bucket ${this.bucketName} does not exist in the list. Attempting to create...`, + ); + try { + await this.storage.createBucket(this.bucketName); + logger.info(`Bucket ${this.bucketName} created successfully.`); + } catch (createError) { + logger.info( + `Failed to create bucket ${this.bucketName}: ${createError}`, + ); + throw new Error( + `Failed to create GCS bucket ${this.bucketName}: ${createError}`, + ); + } + } else { + logger.info(`Bucket ${this.bucketName} exists.`); + } + } catch (error) { + logger.info( + `Error during bucket initialization for ${this.bucketName}: ${error}`, + ); + throw new Error( + `Failed to initialize GCS bucket ${this.bucketName}: ${error}`, + ); + } + } + + private async ensureBucketInitialized(): Promise { + await this.bucketInitialized; + } + + private getObjectPath(taskId: string, type: ObjectType): string { + if (!isTaskIdValid(taskId)) { + throw new Error(`Invalid taskId: ${taskId}`); + } + return `tasks/${taskId}/${type}.tar.gz`; + } + + async save(task: SDKTask): Promise { + await this.ensureBucketInitialized(); + const taskId = task.id; + const persistedState = getPersistedState( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + task.metadata as PersistedTaskMetadata, + ); + + if (!persistedState) { + throw new Error(`Task ${taskId} is missing persisted state in metadata.`); + } + const workDir = process.cwd(); + + const metadataObjectPath = this.getObjectPath(taskId, 'metadata'); + const workspaceObjectPath = this.getObjectPath(taskId, 'workspace'); + + const dataToStore = task.metadata; + + try { + const jsonString = JSON.stringify(dataToStore); + const compressedMetadata = gzipSync(Buffer.from(jsonString)); + const metadataFile = this.storage + .bucket(this.bucketName) + .file(metadataObjectPath); + await metadataFile.save(compressedMetadata, { + contentType: 'application/gzip', + }); + logger.info( + `Task ${taskId} metadata saved to GCS: gs://${this.bucketName}/${metadataObjectPath}`, + ); + + if (await fse.pathExists(workDir)) { + const entries = await fsPromises.readdir(workDir); + if (entries.length > 0) { + const tmpArchiveFile = join(tmpdir(), getTmpArchiveFilename(taskId)); + try { + await tar.c( + { + gzip: true, + file: tmpArchiveFile, + cwd: workDir, + portable: true, + }, + entries, + ); + + if (!(await fse.pathExists(tmpArchiveFile))) { + throw new Error( + `tar.c command failed to create ${tmpArchiveFile}`, + ); + } + + const workspaceFile = this.storage + .bucket(this.bucketName) + .file(workspaceObjectPath); + const sourceStream = createReadStream(tmpArchiveFile); + const destStream = workspaceFile.createWriteStream({ + contentType: 'application/gzip', + resumable: true, + }); + + await new Promise((resolve, reject) => { + sourceStream.on('error', (err) => { + logger.error( + `Error in source stream for ${tmpArchiveFile}:`, + err, + ); + // Attempt to close destStream if source fails + if (!destStream.destroyed) { + destStream.destroy(err); + } + reject(err); + }); + + destStream.on('error', (err) => { + logger.error( + `Error in GCS dest stream for ${workspaceObjectPath}:`, + err, + ); + reject(err); + }); + + destStream.on('finish', () => { + logger.info( + `GCS destStream finished for ${workspaceObjectPath}`, + ); + resolve(); + }); + + logger.info( + `Piping ${tmpArchiveFile} to GCS object ${workspaceObjectPath}`, + ); + sourceStream.pipe(destStream); + }); + logger.info( + `Task ${taskId} workspace saved to GCS: gs://${this.bucketName}/${workspaceObjectPath}`, + ); + } catch (error) { + logger.error( + `Error during workspace save process for ${taskId}:`, + error, + ); + throw error; + } finally { + logger.info(`Cleaning up temporary file: ${tmpArchiveFile}`); + try { + if (await fse.pathExists(tmpArchiveFile)) { + await fse.remove(tmpArchiveFile); + logger.info( + `Successfully removed temporary file: ${tmpArchiveFile}`, + ); + } else { + logger.warn( + `Temporary file not found for cleanup: ${tmpArchiveFile}`, + ); + } + } catch (removeError) { + logger.error( + `Error removing temporary file ${tmpArchiveFile}:`, + removeError, + ); + } + } + } else { + logger.info( + `Workspace directory ${workDir} is empty, skipping workspace save for task ${taskId}.`, + ); + } + } else { + logger.info( + `Workspace directory ${workDir} not found, skipping workspace save for task ${taskId}.`, + ); + } + } catch (error) { + logger.error(`Failed to save task ${taskId} to GCS:`, error); + throw error; + } + } + + async load(taskId: string): Promise { + await this.ensureBucketInitialized(); + const metadataObjectPath = this.getObjectPath(taskId, 'metadata'); + const workspaceObjectPath = this.getObjectPath(taskId, 'workspace'); + + try { + const metadataFile = this.storage + .bucket(this.bucketName) + .file(metadataObjectPath); + const [metadataExists] = await metadataFile.exists(); + if (!metadataExists) { + logger.info(`Task ${taskId} metadata not found in GCS.`); + return undefined; + } + const [compressedMetadata] = await metadataFile.download(); + const jsonData = gunzipSync(compressedMetadata).toString(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const loadedMetadata = JSON.parse(jsonData); + logger.info(`Task ${taskId} metadata loaded from GCS.`); + + const persistedState = getPersistedState(loadedMetadata); + if (!persistedState) { + throw new Error( + `Loaded metadata for task ${taskId} is missing internal persisted state.`, + ); + } + const agentSettings = persistedState._agentSettings; + + const workDir = await setTargetDir(agentSettings); + await fse.ensureDir(workDir); + const workspaceFile = this.storage + .bucket(this.bucketName) + .file(workspaceObjectPath); + const [workspaceExists] = await workspaceFile.exists(); + if (workspaceExists) { + const tmpArchiveFile = join(tmpdir(), getTmpArchiveFilename(taskId)); + try { + await workspaceFile.download({ destination: tmpArchiveFile }); + await tar.x({ file: tmpArchiveFile, cwd: workDir }); + logger.info( + `Task ${taskId} workspace restored from GCS to ${workDir}`, + ); + } finally { + if (await fse.pathExists(tmpArchiveFile)) { + await fse.remove(tmpArchiveFile); + } + } + } else { + logger.info(`Task ${taskId} workspace archive not found in GCS.`); + } + + return { + id: taskId, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + contextId: loadedMetadata._contextId || uuidv4(), + kind: 'task', + status: { + state: persistedState._taskState, + timestamp: new Date().toISOString(), + }, + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + metadata: loadedMetadata, + history: [], + artifacts: [], + }; + } catch (error) { + logger.error(`Failed to load task ${taskId} from GCS:`, error); + throw error; + } + } +} + +export class NoOpTaskStore implements TaskStore { + constructor(private realStore: TaskStore) {} + + async save(task: SDKTask): Promise { + logger.info(`[NoOpTaskStore] save called for task ${task.id} - IGNORED`); + return Promise.resolve(); + } + + async load(taskId: string): Promise { + logger.info( + `[NoOpTaskStore] load called for task ${taskId}, delegating to real store.`, + ); + return this.realStore.load(taskId); + } +} diff --git a/packages/a2a-server/src/types.ts b/packages/a2a-server/src/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6f39714015b0f72bab7c14734f30730c209b43a --- /dev/null +++ b/packages/a2a-server/src/types.ts @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + MCPServerStatus, + ToolConfirmationOutcome, +} from '@google/gemini-cli-core'; +import type { TaskState } from '@a2a-js/sdk'; + +// Interfaces and enums for the CoderAgent protocol. + +export enum CoderAgentEvent { + /** + * An event requesting one or more tool call confirmations. + */ + ToolCallConfirmationEvent = 'tool-call-confirmation', + /** + * An event updating on the status of one or more tool calls. + */ + ToolCallUpdateEvent = 'tool-call-update', + /** + * An event providing text updates on the task. + */ + TextContentEvent = 'text-content', + /** + * An event that indicates a change in the task's execution state. + */ + StateChangeEvent = 'state-change', + /** + * An user-sent event to initiate the agent. + */ + StateAgentSettingsEvent = 'agent-settings', + /** + * An event that contains a thought from the agent. + */ + ThoughtEvent = 'thought', + /** + * An event that contains citation from the agent. + */ + CitationEvent = 'citation', +} + +export interface AgentSettings { + kind: CoderAgentEvent.StateAgentSettingsEvent; + workspacePath: string; + autoExecute?: boolean; + isTrusted?: boolean; +} + +export interface ToolCallConfirmation { + kind: CoderAgentEvent.ToolCallConfirmationEvent; +} + +export interface ToolCallUpdate { + kind: CoderAgentEvent.ToolCallUpdateEvent; +} + +export interface TextContent { + kind: CoderAgentEvent.TextContentEvent; +} + +export interface StateChange { + kind: CoderAgentEvent.StateChangeEvent; +} + +export interface Thought { + kind: CoderAgentEvent.ThoughtEvent; +} + +export interface Citation { + kind: CoderAgentEvent.CitationEvent; +} + +export type ThoughtSummary = { + subject: string; + description: string; +}; + +export interface ToolConfirmationResponse { + outcome: ToolConfirmationOutcome; + callId: string; +} + +export type CoderAgentMessage = + | AgentSettings + | ToolCallConfirmation + | ToolCallUpdate + | TextContent + | StateChange + | Thought + | Citation; + +export interface TaskMetadata { + id: string; + contextId: string; + taskState: TaskState; + model: string; + mcpServers: Array<{ + name: string; + status: MCPServerStatus; + tools: Array<{ + name: string; + description: string; + parameterSchema: unknown; + }>; + }>; + availableTools: Array<{ + name: string; + description: string; + parameterSchema: unknown; + }>; +} + +export interface PersistedStateMetadata { + _agentSettings: AgentSettings; + _taskState: TaskState; +} + +export type PersistedTaskMetadata = { [k: string]: unknown }; + +export const METADATA_KEY = '__persistedState'; + +function isAgentSettings(value: unknown): value is AgentSettings { + return ( + typeof value === 'object' && + value !== null && + 'kind' in value && + value.kind === CoderAgentEvent.StateAgentSettingsEvent && + 'workspacePath' in value && + typeof value.workspacePath === 'string' + ); +} + +function isPersistedStateMetadata( + value: unknown, +): value is PersistedStateMetadata { + return ( + typeof value === 'object' && + value !== null && + '_agentSettings' in value && + '_taskState' in value && + isAgentSettings(value._agentSettings) + ); +} + +export function getPersistedState( + metadata: PersistedTaskMetadata, +): PersistedStateMetadata | undefined { + const state = metadata?.[METADATA_KEY]; + if (isPersistedStateMetadata(state)) { + return state; + } + return undefined; +} + +export function getContextIdFromMetadata( + metadata: PersistedTaskMetadata | undefined, +): string | undefined { + if (!metadata) { + return undefined; + } + const contextId = metadata['_contextId']; + return typeof contextId === 'string' ? contextId : undefined; +} + +export function getAgentSettingsFromMetadata( + metadata: PersistedTaskMetadata | undefined, +): AgentSettings | undefined { + if (!metadata) { + return undefined; + } + const coderAgent = metadata['coderAgent']; + if (isAgentSettings(coderAgent)) { + return coderAgent; + } + return undefined; +} + +export function setPersistedState( + metadata: PersistedTaskMetadata, + state: PersistedStateMetadata, +): PersistedTaskMetadata { + return { + ...metadata, + [METADATA_KEY]: state, + }; +} diff --git a/packages/a2a-server/src/utils/executor_utils.ts b/packages/a2a-server/src/utils/executor_utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..cf635f88226e1fecd4962f64e98cc6f54d8654ef --- /dev/null +++ b/packages/a2a-server/src/utils/executor_utils.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Message } from '@a2a-js/sdk'; +import type { ExecutionEventBus } from '@a2a-js/sdk/server'; +import { v4 as uuidv4 } from 'uuid'; + +import { CoderAgentEvent, type StateChange } from '../types.js'; + +export async function pushTaskStateFailed( + error: unknown, + eventBus: ExecutionEventBus, + taskId: string, + contextId: string, +) { + const errorMessage = + error instanceof Error ? error.message : 'Agent execution error'; + const stateChange: StateChange = { + kind: CoderAgentEvent.StateChangeEvent, + }; + eventBus.publish({ + kind: 'status-update', + taskId, + contextId, + status: { + state: 'failed', + message: { + kind: 'message', + role: 'agent', + parts: [ + { + kind: 'text', + text: errorMessage, + }, + ], + messageId: uuidv4(), + taskId, + contextId, + } as Message, + }, + final: true, + metadata: { + coderAgent: stateChange, + model: 'unknown', + error: errorMessage, + }, + }); +} diff --git a/packages/a2a-server/src/utils/logger.ts b/packages/a2a-server/src/utils/logger.ts new file mode 100644 index 0000000000000000000000000000000000000000..8dca944b910ca4644c90cbec617803a5326aa416 --- /dev/null +++ b/packages/a2a-server/src/utils/logger.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import winston from 'winston'; + +const logger = winston.createLogger({ + level: 'info', + format: winston.format.combine( + // First, add a timestamp to the log info object + winston.format.timestamp({ + format: 'YYYY-MM-DD HH:mm:ss.SSS A', // Custom timestamp format + }), + // Here we define the custom output format + winston.format.printf((info) => { + const { level, timestamp, message, ...rest } = info; + return ( + `[${level.toUpperCase()}] ${timestamp} -- ${message}` + + `${Object.keys(rest).length > 0 ? `\n${JSON.stringify(rest, null, 2)}` : ''}` + ); // Only print ...rest if present + }), + ), + transports: [new winston.transports.Console()], +}); + +export { logger }; diff --git a/packages/a2a-server/src/utils/path_utils.ts b/packages/a2a-server/src/utils/path_utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..2bbfc95b0d74885559fe5530dff555302c11735d --- /dev/null +++ b/packages/a2a-server/src/utils/path_utils.ts @@ -0,0 +1,62 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { resolveToRealPath, isSubpath } from '@google/gemini-cli-core'; + +/** + * Validates a workspace path to prevent path traversal attacks. + * + * @param workspacePath The path to validate. + * @param allowedRoot The root directory the path must be within. Defaults to CWD. + * @returns The resolved, safe path. + * @throws An error if the path is invalid or outside the allowed root. + */ +export async function validateWorkspacePath( + workspacePath?: string, + allowedRoot: string = process.cwd(), +): Promise { + const trimmedPath = workspacePath?.trim(); + if (!trimmedPath) { + return resolveToRealPath(allowedRoot); + } + + if (trimmedPath.includes('\0')) { + throw new Error('Security violation: Null byte detected in path.'); + } + + try { + const canonicalAllowedRoot = resolveToRealPath(allowedRoot); + const resolvedWorkspacePath = path.resolve( + canonicalAllowedRoot, + trimmedPath, + ); + const canonicalWorkspacePath = resolveToRealPath(resolvedWorkspacePath); + + // Check if the resolved path is within the allowed root directory + if ( + canonicalWorkspacePath !== canonicalAllowedRoot && + !isSubpath(canonicalAllowedRoot, canonicalWorkspacePath) + ) { + throw new Error( + `Security violation: The path "${trimmedPath}" is outside the allowed root directory.`, + ); + } + + const stats = await fs.promises.stat(canonicalWorkspacePath); + if (!stats.isDirectory()) { + throw new Error(`The path "${trimmedPath}" is not a directory.`); + } + + return canonicalWorkspacePath; + } catch (e) { + if (e instanceof Error && 'code' in e && e.code === 'ENOENT') { + throw new Error(`The path "${trimmedPath}" does not exist.`); + } + throw e; // Re-throw other errors + } +} diff --git a/packages/a2a-server/src/utils/testing_utils.ts b/packages/a2a-server/src/utils/testing_utils.ts new file mode 100644 index 0000000000000000000000000000000000000000..15dfb685624662920ff6478130ab871c86467f10 --- /dev/null +++ b/packages/a2a-server/src/utils/testing_utils.ts @@ -0,0 +1,233 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import type { + Task as SDKTask, + TaskStatusUpdateEvent, + SendStreamingMessageSuccessResponse, +} from '@a2a-js/sdk'; +import { + ApprovalMode, + DEFAULT_GEMINI_MODEL, + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + GeminiClient, + HookSystem, + type MessageBus, + PolicyDecision, + tmpdir, + type Config, + type Storage, + NoopSandboxManager, + type ToolRegistry, + type SandboxManager, +} from '@google/gemini-cli-core'; +import { createMockMessageBus } from '@google/gemini-cli-core/src/test-utils/mock-message-bus.js'; +import { expect, vi } from 'vitest'; + +export function createMockConfig( + overrides: Partial = {}, +): Partial { + const tmpDir = tmpdir(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const mockConfig = { + get config() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return this as unknown as Config; + }, + get toolRegistry() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const config = this as unknown as Config; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return config.getToolRegistry?.() as unknown as ToolRegistry; + }, + get messageBus() { + return ( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (this as unknown as Config).getMessageBus?.() as unknown as MessageBus + ); + }, + get geminiClient() { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const config = this as unknown as Config; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return config.getGeminiClient?.() as unknown as GeminiClient; + }, + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn(), + getAllToolNames: vi.fn().mockReturnValue([]), + getAllTools: vi.fn().mockReturnValue([]), + getToolsByServer: vi.fn().mockReturnValue([]), + }), + getApprovalMode: vi.fn().mockReturnValue(ApprovalMode.DEFAULT), + getIdeMode: vi.fn().mockReturnValue(false), + isInteractive: () => true, + getAllowedTools: vi.fn().mockReturnValue([]), + getWorkspaceContext: vi.fn().mockReturnValue({ + isPathWithinWorkspace: () => true, + }), + getTargetDir: () => tmpDir, + getCheckpointingEnabled: vi.fn().mockReturnValue(false), + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + storage: { + getProjectTempDir: () => tmpDir, + getProjectTempCheckpointsDir: () => path.join(tmpDir, 'checkpoints'), + } as Storage, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getActiveModel: vi.fn().mockReturnValue(DEFAULT_GEMINI_MODEL), + getDebugMode: vi.fn().mockReturnValue(false), + getContentGeneratorConfig: vi.fn().mockReturnValue({ model: 'gemini-pro' }), + getModel: vi.fn().mockReturnValue('gemini-pro'), + getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), + setFallbackModelHandler: vi.fn(), + initialize: vi.fn().mockResolvedValue(undefined), + getProxy: vi.fn().mockReturnValue(undefined), + getHistory: vi.fn().mockReturnValue([]), + getEmbeddingModel: vi.fn().mockReturnValue('text-embedding-004'), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getUserTier: vi.fn(), + getMessageBus: vi.fn(), + getPolicyEngine: vi.fn(), + getEnableExtensionReloading: vi.fn().mockReturnValue(false), + getEnableHooks: vi.fn().mockReturnValue(false), + getMcpClientManager: vi.fn().mockReturnValue({ + getMcpServers: vi.fn().mockReturnValue({}), + }), + getTelemetryLogPromptsEnabled: vi.fn().mockReturnValue(false), + getTelemetryTracesEnabled: vi.fn().mockReturnValue(false), + getGitService: vi.fn(), + validatePathAccess: vi.fn().mockReturnValue(undefined), + getShellExecutionConfig: vi.fn().mockReturnValue({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + sandboxManager: new NoopSandboxManager() as unknown as SandboxManager, + sanitizationConfig: { + allowedEnvironmentVariables: [], + blockedEnvironmentVariables: [], + enableEnvironmentVariableRedaction: false, + }, + }), + isContextManagementEnabled: vi.fn().mockReturnValue(false), + getContextManagementConfig: vi.fn().mockReturnValue({ enabled: false }), + getExperimentalGemma: vi.fn().mockReturnValue(false), + ...overrides, + } as unknown as Config; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + (mockConfig as unknown as { config: Config; promptId: string }).promptId = + 'test-prompt-id'; + + mockConfig.getMessageBus = vi.fn().mockReturnValue(createMockMessageBus()); + mockConfig.getHookSystem = vi + .fn() + .mockReturnValue(new HookSystem(mockConfig)); + + mockConfig.getGeminiClient = vi + .fn() + .mockReturnValue(new GeminiClient(mockConfig)); + + mockConfig.getPolicyEngine = vi.fn().mockReturnValue({ + check: async () => { + const mode = mockConfig.getApprovalMode(); + if (mode === ApprovalMode.YOLO) { + return { decision: PolicyDecision.ALLOW }; + } + return { decision: PolicyDecision.ASK_USER }; + }, + }); + + return mockConfig; +} + +export function createStreamMessageRequest( + text: string, + messageId: string, + taskId?: string, +) { + const request: { + jsonrpc: string; + id: string; + method: string; + params: { + message: { + kind: string; + role: string; + parts: [{ kind: string; text: string }]; + messageId: string; + }; + metadata: { + coderAgent: { + kind: string; + workspacePath: string; + }; + }; + taskId?: string; + }; + } = { + jsonrpc: '2.0', + id: '1', + method: 'message/stream', + params: { + message: { + kind: 'message', + role: 'user', + parts: [{ kind: 'text', text }], + messageId, + }, + metadata: { + coderAgent: { + kind: 'agent-settings', + workspacePath: '/tmp', + }, + }, + }, + }; + + if (taskId) { + request.params.taskId = taskId; + } + + return request; +} + +export function assertUniqueFinalEventIsLast( + events: SendStreamingMessageSuccessResponse[], +) { + // Final event is input-required & final + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const finalEvent = events[events.length - 1].result as TaskStatusUpdateEvent; + expect(finalEvent.metadata?.['coderAgent']).toMatchObject({ + kind: 'state-change', + }); + expect(finalEvent.status?.state).toBe('input-required'); + expect(finalEvent.final).toBe(true); + + // There is only one event with final and its the last + expect( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + events.filter((e) => (e.result as TaskStatusUpdateEvent).final).length, + ).toBe(1); + expect( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + events.findIndex((e) => (e.result as TaskStatusUpdateEvent).final), + ).toBe(events.length - 1); +} + +export function assertTaskCreationAndWorkingStatus( + events: SendStreamingMessageSuccessResponse[], +) { + // Initial task creation event + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const taskEvent = events[0].result as SDKTask; + expect(taskEvent.kind).toBe('task'); + expect(taskEvent.status.state).toBe('submitted'); + + // Status update: working + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const workingEvent = events[1].result as TaskStatusUpdateEvent; + expect(workingEvent.kind).toBe('status-update'); + expect(workingEvent.status.state).toBe('working'); +} diff --git a/packages/a2a-server/tsconfig.json b/packages/a2a-server/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..06e3256b971bf14e7ecb6914e061559cd41b162e --- /dev/null +++ b/packages/a2a-server/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "lib": ["DOM", "DOM.Iterable", "ES2023"], + "composite": true, + "types": ["node", "vitest/globals"] + }, + "include": ["index.ts", "src/**/*.ts", "src/**/*.json"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/a2a-server/vitest.config.ts b/packages/a2a-server/vitest.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..a22fdc31ae0759c017f462fce6dadd1d71fd0e5d --- /dev/null +++ b/packages/a2a-server/vitest.config.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/// +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + include: ['**/*.{test,spec}.?(c|m)[jt]s?(x)'], + exclude: ['**/node_modules/**', '**/dist/**'], + globals: true, + reporters: ['default', 'junit'], + silent: true, + outputFile: { + junit: 'junit.xml', + }, + coverage: { + enabled: true, + provider: 'v8', + reportsDirectory: './coverage', + include: ['src/**/*'], + reporter: [ + ['text', { file: 'full-text-summary.txt' }], + 'html', + 'json', + 'lcov', + 'cobertura', + ['json-summary', { outputFile: 'coverage-summary.json' }], + ], + }, + poolOptions: { + threads: { + minThreads: 8, + maxThreads: 16, + }, + }, + server: { + deps: { + inline: [/@google\/gemini-cli-core/], + }, + }, + }, +}); diff --git a/packages/cli/GEMINI.md b/packages/cli/GEMINI.md new file mode 100644 index 0000000000000000000000000000000000000000..8bad8f0721c636767b55fd38d958750bb88e269f --- /dev/null +++ b/packages/cli/GEMINI.md @@ -0,0 +1,28 @@ +## React & Ink (CLI UI) + +- **Side Effects**: Use reducers for complex state transitions; avoid `setState` + triggers in callbacks. +- Always fix react-hooks/exhaustive-deps lint errors by adding the missing + dependencies. +- **Shortcuts**: only define keyboard shortcuts in + `packages/cli/src/ui/key/keyBindings.ts` +- Do not implement any logic performing custom string measurement or string + truncation. Use Ink layout instead leveraging ResizeObserver as needed. When + using `ResizeObserver`, prefer the `useCallback` ref pattern (as seen in + `MaxSizedBox.tsx`) to ensure size measurements are captured as soon as the + element is available, avoiding potential rendering timing issues. +- Avoid prop drilling when at all possible. + +## Testing + +- **Utilities**: Use `renderWithProviders` and `waitFor` from + `packages/cli/src/test-utils/`. +- **Snapshots**: Use `toMatchSnapshot()` to verify Ink output. +- **SVG Snapshots**: Use `await expect(renderResult).toMatchSvgSnapshot()` for + UI components whenever colors or detailed visual layout matter. SVG snapshots + capture styling accurately. Make sure to await the `waitUntilReady()` of the + render result before asserting. After updating SVG snapshots, always examine + the resulting `.svg` files (e.g. by reading their content or visually + inspecting them) to ensure the render and colors actually look as expected and + don't just contain an error message. +- **Mocks**: Use mocks as sparingly as possible. diff --git a/packages/cli/examples/ask-user-dialog-demo.tsx b/packages/cli/examples/ask-user-dialog-demo.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aeb22b30f08dffb61d98657d5b7bee0f8fa350a3 --- /dev/null +++ b/packages/cli/examples/ask-user-dialog-demo.tsx @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState } from 'react'; +import { render, Box, Text } from 'ink'; +import { AskUserDialog } from '../src/ui/components/AskUserDialog.js'; +import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js'; +import { QuestionType, type Question } from '@google/gemini-cli-core'; + +const DEMO_QUESTIONS: Question[] = [ + { + question: 'What type of project are you building?', + header: 'Project Type', + options: [ + { label: 'Web Application', description: 'React, Next.js, or similar' }, + { label: 'CLI Tool', description: 'Command-line interface with Node.js' }, + { label: 'Library', description: 'NPM package or shared utility' }, + ], + multiSelect: false, + }, + { + question: 'Which features should be enabled?', + header: 'Features', + options: [ + { label: 'TypeScript', description: 'Add static typing' }, + { label: 'ESLint', description: 'Add linting and formatting' }, + { label: 'Unit Tests', description: 'Add Vitest setup' }, + { label: 'CI/CD', description: 'Add GitHub Actions' }, + ], + multiSelect: true, + }, + { + question: 'What is the project name?', + header: 'Name', + type: QuestionType.TEXT, + placeholder: 'my-awesome-project', + }, + { + question: 'Initialize git repository?', + header: 'Git', + type: QuestionType.YESNO, + }, +]; + +const Demo = () => { + const [result, setResult] = useState(null); + const [cancelled, setCancelled] = useState(false); + + if (cancelled) { + return ( + + + Dialog was cancelled. Project initialization aborted. + + + ); + } + + if (result) { + return ( + + + Success! Project Configuration: + + {DEMO_QUESTIONS.map((q, i) => ( + + {q.header}: + {result[i] || '(not answered)'} + + ))} + + Press Ctrl+C to exit + + + ); + } + + return ( + + + + AskUserDialog Demo + + setCancelled(true)} + /> + + + ); +}; + +render(); diff --git a/packages/cli/examples/scrollable-list-demo.tsx b/packages/cli/examples/scrollable-list-demo.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8d834f91050e8a5f45555e9b0391682ae95e4b81 --- /dev/null +++ b/packages/cli/examples/scrollable-list-demo.tsx @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useRef } from 'react'; +import { render, Box, Text, useInput, useStdout } from 'ink'; +import { + ScrollableList, + type ScrollableListRef, +} from '../src/ui/components/shared/ScrollableList.js'; +import { ScrollProvider } from '../src/ui/contexts/ScrollProvider.js'; +import { MouseProvider } from '../src/ui/contexts/MouseContext.js'; +import { KeypressProvider } from '../src/ui/contexts/KeypressContext.js'; +import { + enableMouseEvents, + disableMouseEvents, +} from '../src/ui/utils/mouse.js'; + +interface Item { + id: string; + title: string; +} + +const getLorem = (index: number) => + Array(10) + .fill(null) + .map(() => 'lorem ipsum '.repeat((index % 3) + 1).trim()) + .join('\n'); + +const Demo = () => { + const { stdout } = useStdout(); + const [size, setSize] = useState({ + columns: stdout.columns, + rows: stdout.rows, + }); + + useEffect(() => { + const onResize = () => { + setSize({ + columns: stdout.columns, + rows: stdout.rows, + }); + }; + + stdout.on('resize', onResize); + return () => { + stdout.off('resize', onResize); + }; + }, [stdout]); + + const [items, setItems] = useState(() => + Array.from({ length: 1000 }, (_, i) => ({ + id: String(i), + title: `Item ${i + 1}`, + })), + ); + + const listRef = useRef>(null); + + useInput((input, key) => { + if (input === 'a' || input === 'A') { + setItems((prev) => [ + ...prev, + { id: String(prev.length), title: `Item ${prev.length + 1}` }, + ]); + } + if ((input === 'e' || input === 'E') && !key.ctrl) { + setItems((prev) => { + if (prev.length === 0) return prev; + const lastIndex = prev.length - 1; + const lastItem = prev[lastIndex]!; + const newItem = { ...lastItem, title: lastItem.title + 'e' }; + return [...prev.slice(0, lastIndex), newItem]; + }); + } + if (key.ctrl && input === 'e') { + listRef.current?.scrollToEnd(); + } + // Let Ink handle Ctrl+C via exitOnCtrlC (default true) or handle explicitly if needed. + // For alternate buffer, explicit handling is often safer for cleanup. + if (key.escape || (key.ctrl && input === 'c')) { + process.exit(0); + } + }); + + return ( + + + + + + Press 'A' to add an item. Press 'E' to edit + last item. Press 'Ctrl+E' to scroll to end. Press + 'Esc' to exit. Mouse wheel or Shift+Up/Down to scroll. + + + ( + + + {item.title} + + + } + > + {item.title} + + {getLorem(index)} + + )} + estimatedItemHeight={() => 14} + keyExtractor={(item) => item.id} + hasFocus={true} + initialScrollIndex={Number.MAX_SAFE_INTEGER} + initialScrollOffsetInIndex={Number.MAX_SAFE_INTEGER} + /> + + Count: {items.length} + + + + + ); +}; + +// Enable mouse reporting before rendering +enableMouseEvents(); + +// Ensure cleanup happens on exit +process.on('exit', () => { + disableMouseEvents(); +}); + +// Handle SIGINT explicitly to ensure cleanup runs if Ink doesn't catch it in time +process.on('SIGINT', () => { + process.exit(0); +}); + +render(, { alternateBuffer: true }); diff --git a/packages/cli/index.ts b/packages/cli/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..a1f86ee4a78aa9389f5e35ca9b7bb21c3aeb8c46 --- /dev/null +++ b/packages/cli/index.ts @@ -0,0 +1,193 @@ +#!/usr/bin/env node + +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn } from 'node:child_process'; +import os from 'node:os'; +import v8 from 'node:v8'; +import { + RELAUNCH_EXIT_CODE, + getSpawnConfig, + getScriptArgs, +} from './src/utils/processUtils.js'; + +// --- Global Entry Point --- + +// Suppress known race condition error in node-pty on Windows and Linux +// Tracking bug: https://github.com/microsoft/node-pty/issues/827 +process.on('uncaughtException', (error) => { + if (error instanceof Error) { + const message = error.message || ''; + const isPtyResizeError = + message === 'Cannot resize a pty that has already exited'; + const isEbadfError = + message.includes('EBADF') || + (error as { code?: string }).code === 'EBADF'; + const isFromNodePty = + error.stack?.includes('node-pty') || error.stack?.includes('PtyResize'); + + if ((isPtyResizeError || isEbadfError) && isFromNodePty) { + // This error happens with node-pty when resizing a pty that has just exited. + // It is a race condition in node-pty that we cannot prevent, so we silence it. + return; + } + } + + // For other errors, we rely on the default behavior, but since we attached a listener, + // we must manually replicate it. + if (error instanceof Error) { + process.stderr.write(error.stack + '\n'); + } else { + process.stderr.write(String(error) + '\n'); + } + process.exit(1); +}); + +async function getMemoryNodeArgs(): Promise { + let autoConfigureMemory = true; + try { + const { readFileSync } = await import('node:fs'); + const { join } = await import('node:path'); + // Respect GEMINI_CLI_HOME environment variable, falling back to os.homedir() + const baseDir = + process.env['GEMINI_CLI_HOME'] || join(os.homedir(), '.gemini'); + const settingsPath = join(baseDir, 'settings.json'); + const rawSettings = readFileSync(settingsPath, 'utf8'); + const settings = JSON.parse(rawSettings); + if (settings?.advanced?.autoConfigureMemory === false) { + autoConfigureMemory = false; + } + } catch { + // ignore + } + + if (autoConfigureMemory) { + const totalMemoryMB = os.totalmem() / (1024 * 1024); + const heapStats = v8.getHeapStatistics(); + const currentMaxOldSpaceSizeMb = Math.floor( + heapStats.heap_size_limit / 1024 / 1024, + ); + const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5); + + if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) { + return [`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`]; + } + } + + return []; +} + +async function run() { + if (!process.env['GEMINI_CLI_NO_RELAUNCH'] && !process.env['SANDBOX']) { + // --- Lightweight Parent Process / Daemon --- + // We avoid importing heavy dependencies here to save ~1.5s of startup time. + + const scriptArgs = getScriptArgs(); + const memoryArgs = await getMemoryNodeArgs(); + const { spawnArgs, env: newEnv } = getSpawnConfig(memoryArgs, scriptArgs); + + let latestAdminSettings: unknown = undefined; + + // Prevent the parent process from exiting prematurely on signals. + // The child process will receive the same signals and handle its own cleanup. + for (const sig of ['SIGINT', 'SIGTERM', 'SIGHUP']) { + process.on(sig as NodeJS.Signals, () => {}); + } + + const runner = () => { + process.stdin.pause(); + + const child = spawn(process.execPath, spawnArgs, { + stdio: ['inherit', 'inherit', 'inherit', 'ipc'], + env: newEnv, + }); + + if (latestAdminSettings) { + child.send({ type: 'admin-settings', settings: latestAdminSettings }); + } + + child.on('message', (msg: { type?: string; settings?: unknown }) => { + if (msg.type === 'admin-settings-update' && msg.settings) { + latestAdminSettings = msg.settings; + } + }); + + return new Promise((resolve) => { + child.on('error', (err) => { + process.stderr.write( + 'Error: Failed to start child process: ' + err.message + '\n', + ); + resolve(1); + }); + child.on('close', (code) => { + process.stdin.resume(); + resolve(code ?? 1); + }); + }); + }; + + while (true) { + try { + const exitCode = await runner(); + if (process.platform === 'android' || exitCode !== RELAUNCH_EXIT_CODE) { + process.exit(exitCode); + } + } catch (error: unknown) { + process.stdin.resume(); + process.stderr.write( + `Fatal error: Failed to relaunch the CLI process.\n${error instanceof Error ? (error.stack ?? error.message) : String(error)}\n`, + ); + process.exit(1); + } + } + } else { + // --- Heavy Child Process --- + // Now we can safely import everything. + const { main } = await import('./src/gemini.js'); + const { FatalError, writeToStderr } = await import( + '@google/gemini-cli-core' + ); + const { runExitCleanup } = await import('./src/utils/cleanup.js'); + + main().catch(async (error: unknown) => { + // Set a timeout to force exit if cleanup hangs + const cleanupTimeout = setTimeout(() => { + writeToStderr('Cleanup timed out, forcing exit...\n'); + process.exit(1); + }, 5000); + + try { + await runExitCleanup(); + } catch (cleanupError: unknown) { + writeToStderr( + `Error during final cleanup: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}\n`, + ); + } finally { + clearTimeout(cleanupTimeout); + } + + if (error instanceof FatalError) { + let errorMessage = error.message; + if (!process.env['NO_COLOR']) { + errorMessage = `\x1b[31m${errorMessage}\x1b[0m`; + } + writeToStderr(errorMessage + '\n'); + process.exit(error.exitCode); + } + + writeToStderr('An unexpected critical error occurred:'); + if (error instanceof Error) { + writeToStderr(error.stack + '\n'); + } else { + writeToStderr(String(error) + '\n'); + } + process.exit(1); + }); + } +} + +run(); diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000000000000000000000000000000000000..757977920d3e04238e15e573bd3724e0d89505a9 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,91 @@ +{ + "name": "@google/gemini-cli", + "version": "0.62.0-nightly.20260915.gae28844fb", + "description": "Gemini CLI", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/google-gemini/gemini-cli.git" + }, + "type": "module", + "main": "dist/index.js", + "bin": { + "gemini": "dist/index.js" + }, + "scripts": { + "build": "node ../../scripts/build_package.js", + "start": "node dist/index.js", + "debug": "node --inspect-brk dist/index.js", + "lint": "eslint . --ext .ts,.tsx", + "format": "prettier --write .", + "test": "vitest run", + "test:ci": "vitest run", + "posttest": "npm run build", + "typecheck": "tsc --noEmit" + }, + "files": [ + "dist" + ], + "config": { + "sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.62.0-nightly.20260915.gae28844fb" + }, + "dependencies": { + "@agentclientprotocol/sdk": "0.16.1", + "@google/gemini-cli-core": "file:../core", + "@google/genai": "1.30.0", + "@iarna/toml": "2.2.5", + "@modelcontextprotocol/sdk": "1.23.0", + "ansi-escapes": "7.3.0", + "ansi-regex": "6.2.2", + "chalk": "4.1.2", + "cli-spinners": "2.9.2", + "clipboardy": "5.2.0", + "color-convert": "2.0.1", + "command-exists": "1.2.9", + "comment-json": "4.2.5", + "diff": "8.0.3", + "dotenv": "17.1.0", + "extract-zip": "2.0.1", + "fzf": "0.5.2", + "glob": "12.0.0", + "highlight.js": "11.11.1", + "ink": "npm:@jrichman/ink@6.6.9", + "ink-gradient": "3.0.0", + "ink-spinner": "5.0.0", + "latest-version": "9.0.0", + "lowlight": "3.3.0", + "mnemonist": "0.40.3", + "open": "10.1.2", + "prompts": "2.4.2", + "proper-lockfile": "4.1.2", + "react": "19.2.4", + "shell-quote": "1.8.3", + "simple-git": "3.28.0", + "string-width": "8.1.0", + "strip-ansi": "7.1.0", + "strip-json-comments": "3.1.1", + "tar": "7.5.8", + "tinygradient": "1.1.5", + "undici": "7.10.0", + "ws": "8.16.0", + "yargs": "17.7.2", + "zod": "3.25.76" + }, + "devDependencies": { + "@google/gemini-cli-test-utils": "file:../test-utils", + "@types/command-exists": "1.2.3", + "@types/hast": "3.0.4", + "@types/node": "20.11.24", + "@types/react": "19.2.0", + "@types/semver": "7.7.0", + "@types/shell-quote": "1.7.5", + "@types/ws": "8.5.10", + "@types/yargs": "17.0.32", + "@xterm/headless": "5.5.0", + "typescript": "5.8.3", + "vitest": "3.2.4" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/cli/src/deferred.test.ts b/packages/cli/src/deferred.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0a50bef309ca428af5305dd1eb78dd509d941d55 --- /dev/null +++ b/packages/cli/src/deferred.test.ts @@ -0,0 +1,242 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + type MockInstance, +} from 'vitest'; +import { + runDeferredCommand, + defer, + setDeferredCommand, + type DeferredCommand, +} from './deferred.js'; +import { ExitCodes } from '@google/gemini-cli-core'; +import type { ArgumentsCamelCase, CommandModule } from 'yargs'; +import { createMockSettings } from './test-utils/settings.js'; + +const { mockRunExitCleanup, mockCoreEvents } = vi.hoisted(() => ({ + mockRunExitCleanup: vi.fn(), + mockCoreEvents: { + emitFeedback: vi.fn(), + }, +})); + +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual('@google/gemini-cli-core'); + return { + ...actual, + coreEvents: mockCoreEvents, + }; +}); + +vi.mock('./utils/cleanup.js', () => ({ + runExitCleanup: mockRunExitCleanup, +})); + +let mockExit: MockInstance; + +describe('deferred', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockExit = vi + .spyOn(process, 'exit') + .mockImplementation(() => undefined as never); + setDeferredCommand(undefined as unknown as DeferredCommand); // Reset deferred command + }); + + describe('runDeferredCommand', () => { + it('should do nothing if no deferred command is set', async () => { + await runDeferredCommand(createMockSettings().merged); + expect(mockCoreEvents.emitFeedback).not.toHaveBeenCalled(); + expect(mockExit).not.toHaveBeenCalled(); + }); + + it('should execute the deferred command if enabled', async () => { + const mockHandler = vi.fn(); + setDeferredCommand({ + handler: mockHandler, + argv: { _: [], $0: 'gemini' } as ArgumentsCamelCase, + commandName: 'mcp', + }); + + const settings = createMockSettings({ + merged: { admin: { mcp: { enabled: true } } }, + }).merged; + await runDeferredCommand(settings); + expect(mockHandler).toHaveBeenCalled(); + expect(mockRunExitCleanup).toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS); + }); + + it('should exit with FATAL_CONFIG_ERROR if MCP is disabled', async () => { + setDeferredCommand({ + handler: vi.fn(), + argv: {} as ArgumentsCamelCase, + commandName: 'mcp', + }); + + const settings = createMockSettings({ + merged: { admin: { mcp: { enabled: false } } }, + }).merged; + await runDeferredCommand(settings); + + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); + expect(mockRunExitCleanup).toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR); + }); + + it('should exit with FATAL_CONFIG_ERROR if extensions are disabled', async () => { + setDeferredCommand({ + handler: vi.fn(), + argv: {} as ArgumentsCamelCase, + commandName: 'extensions', + }); + + const settings = createMockSettings({ + merged: { admin: { extensions: { enabled: false } } }, + }).merged; + await runDeferredCommand(settings); + + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'Extensions is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); + expect(mockRunExitCleanup).toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR); + }); + + it('should exit with FATAL_CONFIG_ERROR if skills are disabled', async () => { + setDeferredCommand({ + handler: vi.fn(), + argv: {} as ArgumentsCamelCase, + commandName: 'skills', + }); + + const settings = createMockSettings({ + merged: { admin: { skills: { enabled: false } } }, + }).merged; + await runDeferredCommand(settings); + + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'Agent skills is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); + expect(mockRunExitCleanup).toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(ExitCodes.FATAL_CONFIG_ERROR); + }); + + it('should execute if admin settings are undefined (default implicit enable)', async () => { + const mockHandler = vi.fn(); + setDeferredCommand({ + handler: mockHandler, + argv: {} as ArgumentsCamelCase, + commandName: 'mcp', + }); + + const settings = createMockSettings({}).merged; // No admin settings + await runDeferredCommand(settings); + + expect(mockHandler).toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS); + }); + }); + + describe('defer', () => { + it('should wrap a command module and defer execution', async () => { + const originalHandler = vi.fn(); + const commandModule: CommandModule = { + command: 'test', + describe: 'test command', + handler: originalHandler, + }; + + const deferredModule = defer(commandModule); + expect(deferredModule.command).toBe(commandModule.command); + + // Execute the wrapper handler + const argv = { _: [], $0: 'gemini' } as ArgumentsCamelCase; + await deferredModule.handler(argv); + + // Should check that it set the deferred command, but didn't run original handler yet + expect(originalHandler).not.toHaveBeenCalled(); + + // Now manually run it to verify it captured correctly + await runDeferredCommand(createMockSettings().merged); + expect(originalHandler).toHaveBeenCalledWith( + expect.objectContaining({ + settings: expect.objectContaining({ + admin: expect.objectContaining({ + extensions: expect.objectContaining({ enabled: true }), + }), + }), + }), + ); + expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS); + }); + + it('should use parentCommandName if provided', async () => { + const commandModule: CommandModule = { + command: 'subcommand', + describe: 'sub command', + handler: vi.fn(), + }; + + const deferredModule = defer(commandModule, 'parent'); + await deferredModule.handler({} as ArgumentsCamelCase); + + const deferredMcp = defer(commandModule, 'mcp'); + await deferredMcp.handler({} as ArgumentsCamelCase); + + const mcpSettings = createMockSettings({ + merged: { admin: { mcp: { enabled: false } } }, + }).merged; + await runDeferredCommand(mcpSettings); + + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'MCP is disabled by your administrator. To enable it, please request an update to the settings at: https://goo.gle/manage-gemini-cli', + ); + }); + + it('should fallback to unknown if no parentCommandName is provided', async () => { + const mockHandler = vi.fn(); + const commandModule: CommandModule = { + command: ['foo', 'infoo'], + describe: 'foo command', + handler: mockHandler, + }; + + const deferredModule = defer(commandModule); + await deferredModule.handler({} as ArgumentsCamelCase); + + // Verify it runs even if all known commands are disabled, + // confirming it didn't capture 'mcp', 'extensions', or 'skills' + // and defaulted to 'unknown' (or something else safe). + const settings = createMockSettings({ + merged: { + admin: { + mcp: { enabled: false }, + extensions: { enabled: false }, + skills: { enabled: false }, + }, + }, + }).merged; + + await runDeferredCommand(settings); + + expect(mockHandler).toHaveBeenCalled(); + expect(mockExit).toHaveBeenCalledWith(ExitCodes.SUCCESS); + }); + }); +}); diff --git a/packages/cli/src/deferred.ts b/packages/cli/src/deferred.ts new file mode 100644 index 0000000000000000000000000000000000000000..1864ec2cb56133013d46afeed6bb53f3f8ff386b --- /dev/null +++ b/packages/cli/src/deferred.ts @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import type { ArgumentsCamelCase, CommandModule } from 'yargs'; +import { + coreEvents, + ExitCodes, + getAdminErrorMessage, +} from '@google/gemini-cli-core'; +import { runExitCleanup } from './utils/cleanup.js'; +import type { MergedSettings } from './config/settings.js'; +import process from 'node:process'; + +export interface DeferredCommand { + handler: (argv: ArgumentsCamelCase) => void | Promise; + argv: ArgumentsCamelCase; + commandName: string; +} + +let deferredCommand: DeferredCommand | undefined; + +export function setDeferredCommand(command: DeferredCommand) { + deferredCommand = command; +} + +export async function runDeferredCommand(settings: MergedSettings) { + if (!deferredCommand) { + return; + } + + const adminSettings = settings.admin; + const commandName = deferredCommand.commandName; + + if (commandName === 'mcp' && adminSettings?.mcp?.enabled === false) { + coreEvents.emitFeedback( + 'error', + getAdminErrorMessage('MCP', undefined /* config */), + ); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_CONFIG_ERROR); + } + + if ( + commandName === 'extensions' && + adminSettings?.extensions?.enabled === false + ) { + coreEvents.emitFeedback( + 'error', + getAdminErrorMessage('Extensions', undefined /* config */), + ); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_CONFIG_ERROR); + } + + if (commandName === 'skills' && adminSettings?.skills?.enabled === false) { + coreEvents.emitFeedback( + 'error', + getAdminErrorMessage('Agent skills', undefined /* config */), + ); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_CONFIG_ERROR); + } + + // Inject settings into argv + const argvWithSettings = { + ...deferredCommand.argv, + settings, + }; + + await deferredCommand.handler(argvWithSettings); + await runExitCleanup(); + process.exit(ExitCodes.SUCCESS); +} + +/** + * Wraps a command's handler to defer its execution. + * It stores the handler and arguments in a singleton `deferredCommand` variable. + */ +export function defer( + commandModule: CommandModule, + parentCommandName?: string, +): CommandModule { + return { + ...commandModule, + handler: (argv: ArgumentsCamelCase) => { + setDeferredCommand({ + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + handler: commandModule.handler as ( + argv: ArgumentsCamelCase, + ) => void | Promise, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + argv: argv as unknown as ArgumentsCamelCase, + commandName: parentCommandName || 'unknown', + }); + }, + }; +} diff --git a/packages/cli/src/gemini.test.tsx b/packages/cli/src/gemini.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4c7fec75a7263a6f9be52cf8672e61409eaa9b68 --- /dev/null +++ b/packages/cli/src/gemini.test.tsx @@ -0,0 +1,1843 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance, + type Mock, +} from 'vitest'; +import { + main, + setupUnhandledRejectionHandler, + validateDnsResolutionOrder, + startInteractiveUI, + getNodeMemoryArgs, + resolveSessionId, +} from './gemini.js'; +import { + loadCliConfig, + parseArguments, + type CliArgs, +} from './config/config.js'; +import { loadSandboxConfig } from './config/sandboxConfig.js'; +import { createMockSandboxConfig } from '@google/gemini-cli-test-utils'; +import { terminalCapabilityManager } from './ui/utils/terminalCapabilityManager.js'; +import { start_sandbox } from './utils/sandbox.js'; +import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; +import os from 'node:os'; +import v8 from 'node:v8'; +import { loadSettings, type LoadedSettings } from './config/settings.js'; +import { + createMockConfig, + createMockSettings, +} from './test-utils/mockConfig.js'; +import { appEvents, AppEvent } from './utils/events.js'; +import { + type Config, + type ResumedSessionData, + type StartupWarning, + type ConversationRecord, + WarningPriority, + debugLogger, + coreEvents, + AuthType, + ExitCodes, +} from '@google/gemini-cli-core'; +import { act } from 'react'; +import { type InitializationResult } from './core/initializer.js'; +import { runNonInteractive } from './nonInteractiveCli.js'; +import { SessionSelector, SessionError } from './utils/sessionUtils.js'; + +// Hoisted constants and mocks +const performance = vi.hoisted(() => ({ + now: vi.fn(), +})); +vi.stubGlobal('performance', performance); + +const runNonInteractiveSpy = vi.hoisted(() => vi.fn()); +vi.mock('./nonInteractiveCli.js', () => ({ + runNonInteractive: runNonInteractiveSpy, +})); + +const terminalNotificationMocks = vi.hoisted(() => ({ + notifyViaTerminal: vi.fn().mockResolvedValue(true), + buildRunEventNotificationContent: vi.fn(() => ({ + title: 'Session complete', + body: 'done', + subtitle: 'Run finished', + })), +})); +vi.mock('./utils/terminalNotifications.js', () => ({ + notifyViaTerminal: terminalNotificationMocks.notifyViaTerminal, + buildRunEventNotificationContent: + terminalNotificationMocks.buildRunEventNotificationContent, +})); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + recordSlowRender: vi.fn(), + logUserPrompt: vi.fn(), + writeToStdout: vi.fn((...args) => + process.stdout.write( + ...(args as Parameters), + ), + ), + patchStdio: vi.fn(() => () => {}), + createWorkingStdio: vi.fn(() => ({ + stdout: { + write: vi.fn((...args) => + process.stdout.write( + ...(args as Parameters), + ), + ), + columns: 80, + rows: 24, + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { + write: vi.fn(), + }, + })), + enableMouseEvents: vi.fn(), + disableMouseEvents: vi.fn(), + enterAlternateScreen: vi.fn(), + disableLineWrapping: vi.fn(), + getVersion: vi.fn(() => Promise.resolve('1.0.0')), + startupProfiler: { + start: vi.fn(() => ({ + end: vi.fn(), + })), + flush: vi.fn(), + }, + ClearcutLogger: { + getInstance: vi.fn(() => ({ + logStartSessionEvent: vi.fn().mockResolvedValue(undefined), + logEndSessionEvent: vi.fn().mockResolvedValue(undefined), + logUserPrompt: vi.fn(), + addDefaultFields: vi.fn((data) => data), + })), + clearInstance: vi.fn(), + }, + coreEvents: { + // eslint-disable-next-line @typescript-eslint/no-misused-spread + ...actual.coreEvents, + emitFeedback: vi.fn(), + emitConsoleLog: vi.fn(), + listenerCount: vi.fn().mockReturnValue(0), + on: vi.fn(), + off: vi.fn(), + drainBacklogs: vi.fn(), + }, + }; +}); + +vi.mock('ink', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + render: vi.fn((_node, options) => { + if (options.alternateBuffer) { + options.stdout.write('\x1b[?7l'); + } + // Simulate rendering time for recordSlowRender test + const start = performance.now(); + const end = performance.now(); + if (options.onRender) { + options.onRender({ renderTime: end - start }); + } + return { + unmount: vi.fn(), + rerender: vi.fn(), + cleanup: vi.fn(), + waitUntilExit: vi.fn(), + }; + }), + }; +}); + +// Custom error to identify mock process.exit calls +class MockProcessExitError extends Error { + constructor(readonly code?: string | number | null | undefined) { + super('PROCESS_EXIT_MOCKED'); + this.name = 'MockProcessExitError'; + } +} + +// Mock dependencies +vi.mock('./config/settings.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSettings: vi.fn().mockImplementation(() => ({ + merged: actual.getDefaultsFromSchema(), + workspace: { settings: {} }, + errors: [], + })), + saveModelChange: vi.fn(), + getDefaultsFromSchema: actual.getDefaultsFromSchema, + }; +}); + +vi.mock('./ui/utils/terminalCapabilityManager.js', () => ({ + terminalCapabilityManager: { + detectCapabilities: vi.fn(), + getTerminalBackgroundColor: vi.fn(), + }, +})); + +vi.mock('./config/config.js', () => ({ + loadCliConfig: vi.fn().mockImplementation(async () => createMockConfig()), + parseArguments: vi.fn().mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + }), + isDebugMode: vi.fn(() => false), + getRequestedWorktreeName: vi.fn(() => undefined), + getWorktreeArg: vi.fn(() => undefined), +})); + +vi.mock('read-package-up', () => ({ + readPackageUp: vi.fn().mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + packageJson: { name: 'test-pkg', version: 'test-version' }, + path: '/fake/path/package.json', + }), +})); + +vi.mock('update-notifier', () => ({ + default: vi.fn(() => ({ + notify: vi.fn(), + })), +})); + +vi.mock('./utils/events.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + appEvents: { + emit: vi.fn(), + }, + }; +}); + +import * as readStdinModule from './utils/readStdin.js'; + +vi.mock('./utils/sandbox.js', () => ({ + sandbox_command: vi.fn(() => ''), // Default to no sandbox command + start_sandbox: vi.fn(() => Promise.resolve()), // Mock as an async function that resolves +})); + +vi.mock('./utils/relaunch.js', () => ({ + relaunchAppInChildProcess: vi.fn().mockResolvedValue(undefined), + relaunchOnExitCode: vi.fn(async (fn) => { + await fn(); + }), +})); + +vi.mock('./config/sandboxConfig.js', () => ({ + loadSandboxConfig: vi.fn().mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + command: 'docker', + image: 'test-image', + }), +})); + +vi.mock('./deferred.js', () => ({ + runDeferredCommand: vi.fn().mockResolvedValue(undefined), + setDeferredCommand: vi.fn(), + defer: vi.fn((m) => m), +})); + +vi.mock('./ui/utils/mouse.js', () => ({ + enableMouseEvents: vi.fn(), + disableMouseEvents: vi.fn(), + isIncompleteMouseSequence: vi.fn(), +})); + +vi.mock('./validateNonInterActiveAuth.js', () => ({ + validateNonInteractiveAuth: vi.fn().mockResolvedValue('google'), +})); + +vi.mock('./config/auth.js', () => ({ + validateAuthMethod: vi.fn().mockResolvedValue(null), +})); + +describe('gemini.tsx main function', () => { + let originalIsTTY: boolean | undefined; + let initialUnhandledRejectionListeners: NodeJS.UnhandledRejectionListener[] = + []; + + beforeEach(() => { + // Store and clear sandbox-related env variables to ensure a consistent test environment + vi.stubEnv('GEMINI_SANDBOX', ''); + vi.stubEnv('SANDBOX', ''); + vi.stubEnv('SHPOOL_SESSION_NAME', ''); + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true'); + + initialUnhandledRejectionListeners = + process.listeners('unhandledRejection'); + + originalIsTTY = process.stdin.isTTY; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).isTTY = true; + }); + + afterEach(() => { + const currentListeners = process.listeners('unhandledRejection'); + currentListeners.forEach((listener) => { + if (!initialUnhandledRejectionListeners.includes(listener)) { + process.removeListener('unhandledRejection', listener); + } + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).isTTY = originalIsTTY; + + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + it('should suppress AbortError and not open debug console', async () => { + const debugLoggerErrorSpy = vi.spyOn(debugLogger, 'error'); + const debugLoggerLogSpy = vi.spyOn(debugLogger, 'log'); + const abortError = new DOMException( + 'The operation was aborted.', + 'AbortError', + ); + + setupUnhandledRejectionHandler(); + process.emit('unhandledRejection', abortError, Promise.resolve()); + + await new Promise(process.nextTick); + + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Suppressed unhandled AbortError'), + ); + }); + + it('should log unhandled promise rejections and open debug console on first error', async () => { + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + const appEventsMock = vi.mocked(appEvents); + const debugLoggerErrorSpy = vi.spyOn(debugLogger, 'error'); + const rejectionError = new Error('Test unhandled rejection'); + + setupUnhandledRejectionHandler(); + // Simulate an unhandled rejection. + // We are not using Promise.reject here as vitest will catch it. + // Instead we will dispatch the event manually. + process.emit('unhandledRejection', rejectionError, Promise.resolve()); + + // We need to wait for the rejection handler to be called. + await new Promise(process.nextTick); + + expect(appEventsMock.emit).toHaveBeenCalledWith(AppEvent.OpenDebugConsole); + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Unhandled Promise Rejection'), + ); + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Please file a bug report using the /bug tool.'), + ); + + // Simulate a second rejection + const secondRejectionError = new Error('Second test unhandled rejection'); + process.emit('unhandledRejection', secondRejectionError, Promise.resolve()); + await new Promise(process.nextTick); + + // Ensure emit was only called once for OpenDebugConsole + const openDebugConsoleCalls = appEventsMock.emit.mock.calls.filter( + (call) => call[0] === AppEvent.OpenDebugConsole, + ); + expect(openDebugConsoleCalls.length).toBe(1); + + // Avoid the process.exit error from being thrown. + processExitSpy.mockRestore(); + }); +}); + +describe('setWindowTitle', () => { + it('should set window title when hideWindowTitle is false', async () => { + // setWindowTitle is not exported, but we can test its effect if we had a way to call it. + // Since we can't easily call it directly without exporting it, we skip direct testing + // and rely on startInteractiveUI tests which call it. + }); +}); + +describe('initializeOutputListenersAndFlush', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should flush backlogs and setup listeners if no listeners exist', async () => { + const { coreEvents } = await import('@google/gemini-cli-core'); + const { initializeOutputListenersAndFlush } = await import('./gemini.js'); + + // Mock listenerCount to return 0 + vi.spyOn(coreEvents, 'listenerCount').mockReturnValue(0); + const drainSpy = vi.spyOn(coreEvents, 'drainBacklogs'); + + initializeOutputListenersAndFlush(); + + expect(drainSpy).toHaveBeenCalled(); + // We can't easily check if listeners were added without access to the internal state of coreEvents, + // but we can verify that drainBacklogs was called. + }); +}); + +describe('getNodeMemoryArgs', () => { + let osTotalMemSpy: MockInstance; + let v8GetHeapStatisticsSpy: MockInstance; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let originalConfig: any; + + beforeEach(() => { + osTotalMemSpy = vi.spyOn(os, 'totalmem'); + v8GetHeapStatisticsSpy = vi.spyOn(v8, 'getHeapStatistics'); + delete process.env['GEMINI_CLI_NO_RELAUNCH']; + + originalConfig = process.config; + Object.defineProperty(process, 'config', { + value: { + ...originalConfig, + variables: { ...originalConfig?.variables, v8_enable_sandbox: 1 }, + }, + configurable: true, + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(process, 'config', { + value: originalConfig, + configurable: true, + }); + }); + + it('should return empty array if GEMINI_CLI_NO_RELAUNCH is set', () => { + process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; + expect(getNodeMemoryArgs(false)).toEqual([]); + }); + + it('should return empty array if current heap limit is sufficient', () => { + osTotalMemSpy.mockReturnValue(16 * 1024 * 1024 * 1024); // 16GB + v8GetHeapStatisticsSpy.mockReturnValue({ + heap_size_limit: 8 * 1024 * 1024 * 1024, // 8GB + }); + // Target is 50% of 16GB = 8GB. Current is 8GB. Relaunch needed for EPT size only. + expect(getNodeMemoryArgs(false)).toEqual([ + '--max-external-pointer-table-size=268435456', + ]); + }); + + it('should return memory args if current heap limit is insufficient', () => { + osTotalMemSpy.mockReturnValue(16 * 1024 * 1024 * 1024); // 16GB + v8GetHeapStatisticsSpy.mockReturnValue({ + heap_size_limit: 4 * 1024 * 1024 * 1024, // 4GB + }); + // Target is 50% of 16GB = 8GB. Current is 4GB. Relaunch needed for both. + expect(getNodeMemoryArgs(false)).toEqual([ + '--max-external-pointer-table-size=268435456', + '--max-old-space-size=8192', + ]); + }); + + it('should log debug info when isDebugMode is true', () => { + const debugSpy = vi.spyOn(debugLogger, 'debug'); + osTotalMemSpy.mockReturnValue(16 * 1024 * 1024 * 1024); + v8GetHeapStatisticsSpy.mockReturnValue({ + heap_size_limit: 4 * 1024 * 1024 * 1024, + }); + getNodeMemoryArgs(true); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining('Current heap size'), + ); + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining('Need to relaunch with more memory'), + ); + }); +}); + +describe('gemini.tsx main function kitty protocol', () => { + let originalEnvNoRelaunch: string | undefined; + let originalIsTTY: boolean | undefined; + let originalIsRaw: boolean | undefined; + let setRawModeSpy: MockInstance< + (mode: boolean) => NodeJS.ReadStream & { fd: 0 } + >; + + beforeEach(() => { + // Set no relaunch in tests since process spawning causing issues in tests + originalEnvNoRelaunch = process.env['GEMINI_CLI_NO_RELAUNCH']; + process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (!(process.stdin as any).setRawMode) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).setRawMode = vi.fn(); + } + setRawModeSpy = vi.spyOn(process.stdin, 'setRawMode'); + + originalIsTTY = process.stdin.isTTY; + originalIsRaw = process.stdin.isRaw; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).isTTY = true; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).isRaw = false; + }); + + afterEach(() => { + // Restore original env variables + if (originalEnvNoRelaunch !== undefined) { + process.env['GEMINI_CLI_NO_RELAUNCH'] = originalEnvNoRelaunch; + } else { + delete process.env['GEMINI_CLI_NO_RELAUNCH']; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).isTTY = originalIsTTY; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).isRaw = originalIsRaw; + vi.restoreAllMocks(); + }); + + it('should call setRawMode and detectCapabilities when isInteractive is true', async () => { + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + isInteractive: () => true, + getQuestion: () => '', + getSandbox: () => undefined, + }), + ); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + }), + ); + vi.mocked(parseArguments).mockResolvedValue({ + model: undefined, + sandbox: undefined, + debug: undefined, + prompt: undefined, + promptInteractive: undefined, + query: undefined, + yolo: undefined, + approvalMode: undefined, + policy: undefined, + adminPolicy: undefined, + allowedMcpServerNames: undefined, + allowedTools: undefined, + experimentalAcp: undefined, + extensions: undefined, + listExtensions: undefined, + includeDirectories: undefined, + screenReader: undefined, + useWriteTodos: undefined, + resume: undefined, + sessionId: undefined, + listSessions: undefined, + deleteSession: undefined, + outputFormat: undefined, + fakeResponses: undefined, + recordResponses: undefined, + rawOutput: undefined, + acceptRawOutputRisk: undefined, + isCommand: undefined, + skipTrust: undefined, + }); + + await act(async () => { + await main(); + }); + + expect(setRawModeSpy).toHaveBeenCalledWith(true); + expect(terminalCapabilityManager.detectCapabilities).toHaveBeenCalledTimes( + 1, + ); + }); + + it('should call process.stdin.resume when isInteractive is true to protect against implicit Node pause', async () => { + const resumeSpy = vi.spyOn(process.stdin, 'resume'); + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + isInteractive: () => true, + getQuestion: () => '', + getSandbox: () => undefined, + }), + ); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + }), + ); + vi.mocked(parseArguments).mockResolvedValue({ + model: undefined, + sandbox: undefined, + debug: undefined, + prompt: undefined, + promptInteractive: undefined, + query: undefined, + yolo: undefined, + approvalMode: undefined, + policy: undefined, + adminPolicy: undefined, + allowedMcpServerNames: undefined, + allowedTools: undefined, + experimentalAcp: undefined, + extensions: undefined, + listExtensions: undefined, + includeDirectories: undefined, + screenReader: undefined, + useWriteTodos: undefined, + resume: undefined, + sessionId: undefined, + listSessions: undefined, + deleteSession: undefined, + outputFormat: undefined, + fakeResponses: undefined, + recordResponses: undefined, + rawOutput: undefined, + acceptRawOutputRisk: undefined, + isCommand: undefined, + skipTrust: undefined, + }); + + await act(async () => { + await main(); + }); + + expect(resumeSpy).toHaveBeenCalledTimes(1); + resumeSpy.mockRestore(); + }); + + it.each([ + { flag: 'listExtensions' }, + { flag: 'listSessions' }, + { flag: 'deleteSession', value: 'session-id' }, + ])('should handle --$flag flag', async ({ flag, value }) => { + const { listSessions, deleteSession } = await import('./utils/sessions.js'); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: {}, + }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); + + vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + promptInteractive: false, + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + const mockConfig = createMockConfig({ + isInteractive: () => false, + getQuestion: () => '', + getSandbox: () => undefined, + getListExtensions: () => flag === 'listExtensions', + getListSessions: () => flag === 'listSessions', + getDeleteSession: () => (flag === 'deleteSession' ? value : undefined), + getExtensions: () => [ + { + name: 'ext1', + id: 'ext1', + version: '1.0.0', + isActive: true, + path: '/path/to/ext1', + contextFiles: [], + }, + ], + }); + + vi.mocked(loadCliConfig).mockResolvedValue(mockConfig); + vi.mock('./utils/sessions.js', () => ({ + listSessions: vi.fn(), + deleteSession: vi.fn(), + })); + + const debugLoggerLogSpy = vi + .spyOn(debugLogger, 'log') + .mockImplementation(() => {}); + + process.env['GEMINI_API_KEY'] = 'test-key'; + try { + await main(); + } catch (e) { + if (!(e instanceof MockProcessExitError)) throw e; + } finally { + delete process.env['GEMINI_API_KEY']; + } + + if (flag === 'listExtensions') { + expect(debugLoggerLogSpy).toHaveBeenCalledWith( + expect.stringContaining('ext1'), + ); + } else if (flag === 'listSessions') { + expect(listSessions).toHaveBeenCalledWith(mockConfig); + } else if (flag === 'deleteSession') { + expect(deleteSession).toHaveBeenCalledWith(mockConfig, value); + } + expect(processExitSpy).toHaveBeenCalledWith(0); + processExitSpy.mockRestore(); + }); + + it('should handle sandbox activation', async () => { + vi.stubEnv('SANDBOX', ''); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + + vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + promptInteractive: false, + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: { selectedType: 'google' } }, + ui: {}, + }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); + + const mockConfig = createMockConfig({ + isInteractive: () => false, + getQuestion: () => '', + getSandbox: () => + createMockSandboxConfig({ command: 'docker', image: 'test-image' }), + }); + + vi.mocked(loadCliConfig).mockResolvedValue(mockConfig); + vi.mocked(loadSandboxConfig).mockResolvedValue( + createMockSandboxConfig({ + command: 'docker', + image: 'test-image', + }), + ); + + process.env['GEMINI_API_KEY'] = 'test-key'; + try { + await main(); + } catch (e) { + if (!(e instanceof MockProcessExitError)) throw e; + } finally { + delete process.env['GEMINI_API_KEY']; + } + + expect(start_sandbox).toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(0); + processExitSpy.mockRestore(); + }); + + it('should log warning when theme is not found', async () => { + const { themeManager } = await import('./ui/themes/theme-manager.js'); + const debugLoggerWarnSpy = vi + .spyOn(debugLogger, 'warn') + .mockImplementation(() => {}); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + advanced: {}, + security: { auth: {} }, + ui: { theme: 'non-existent-theme' }, + }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); + + vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + promptInteractive: false, + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + isInteractive: () => false, + getQuestion: () => 'test', + getSandbox: () => undefined, + }), + ); + + vi.spyOn(themeManager, 'setActiveTheme').mockReturnValue(false); + + process.env['GEMINI_API_KEY'] = 'test-key'; + try { + await main(); + } catch (e) { + if (!(e instanceof MockProcessExitError)) throw e; + } finally { + delete process.env['GEMINI_API_KEY']; + } + + expect(debugLoggerWarnSpy).toHaveBeenCalledWith( + expect.stringContaining('Warning: Theme "non-existent-theme" not found.'), + ); + processExitSpy.mockRestore(); + }); + + it('should handle session selector error', async () => { + // eslint-disable-next-line prefer-arrow-callback + vi.mocked(SessionSelector).mockImplementation(function () { + return { + resolveSession: vi + .fn() + .mockRejectedValue(new Error('Session not found')), + } as unknown as InstanceType; + }); + + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); + + vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + promptInteractive: false, + resume: 'session-id', + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + isInteractive: () => true, + getQuestion: () => '', + getSandbox: () => undefined, + }), + ); + + try { + await main(); + } catch (e) { + if (!(e instanceof MockProcessExitError)) throw e; + } + + expect(emitFeedbackSpy).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Error resuming session: Session not found'), + ); + expect(processExitSpy).toHaveBeenCalledWith(42); + processExitSpy.mockRestore(); + emitFeedbackSpy.mockRestore(); + }); + + it('should start normally with a warning when no sessions found for resume', async () => { + // eslint-disable-next-line prefer-arrow-callback + vi.mocked(SessionSelector).mockImplementation(function () { + return { + resolveSession: vi + .fn() + .mockRejectedValue(SessionError.noSessionsFound()), + } as unknown as InstanceType; + }); + + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { advanced: {}, security: { auth: {} }, ui: { theme: 'test' } }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); + + vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + promptInteractive: false, + resume: 'latest', + } as unknown as CliArgs); + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + isInteractive: () => true, + getQuestion: () => '', + getSandbox: () => undefined, + }), + ); + + await main(); + + // Should NOT have crashed + expect(processExitSpy).not.toHaveBeenCalled(); + // Should NOT have emitted a feedback error + expect(emitFeedbackSpy).not.toHaveBeenCalledWith( + 'error', + expect.stringContaining('Error resuming session'), + ); + processExitSpy.mockRestore(); + emitFeedbackSpy.mockRestore(); + }); + + it.skip('should log error when cleanupExpiredSessions fails', async () => { + const { cleanupExpiredSessions } = await import( + './utils/sessionCleanup.js' + ); + vi.mocked(cleanupExpiredSessions).mockRejectedValue( + new Error('Cleanup failed'), + ); + const debugLoggerErrorSpy = vi + .spyOn(debugLogger, 'error') + .mockImplementation(() => {}); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { advanced: {}, security: { auth: {} }, ui: {} }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); + + vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + promptInteractive: false, + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + isInteractive: () => false, + getQuestion: () => 'test', + getSandbox: () => undefined, + }), + ); + + // The mock is already set up at the top of the test + + try { + await main(); + } catch (e) { + if (!(e instanceof MockProcessExitError)) throw e; + } + + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to cleanup expired sessions: Cleanup failed', + ), + ); + expect(processExitSpy).toHaveBeenCalledWith(0); // Should not exit on cleanup failure + processExitSpy.mockRestore(); + }); + + it('should read from stdin in non-interactive mode', async () => { + vi.stubEnv('SANDBOX', 'true'); + vi.mocked(loadSandboxConfig).mockResolvedValue(undefined); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + + const readStdinSpy = vi + .spyOn(readStdinModule, 'readStdin') + .mockResolvedValue('stdin-data'); + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { advanced: {}, security: { auth: {} }, ui: {} }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + }), + ); + + vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + promptInteractive: false, + } as any); // eslint-disable-line @typescript-eslint/no-explicit-any + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + isInteractive: () => false, + getQuestion: () => 'test-question', + getSandbox: () => undefined, + }), + ); + + // Mock stdin to be non-TTY + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).isTTY = false; + + process.env['GEMINI_API_KEY'] = 'test-key'; + try { + await main(); + } catch (e) { + if (!(e instanceof MockProcessExitError)) throw e; + } finally { + delete process.env['GEMINI_API_KEY']; + } + + expect(readStdinSpy).toHaveBeenCalled(); + // In this test setup, runNonInteractive might be called on the mocked module, + // but we need to ensure we are checking the correct spy instance. + // Since vi.mock is hoisted, runNonInteractiveSpy is defined early. + expect(runNonInteractive).toHaveBeenCalled(); + const callArgs = vi.mocked(runNonInteractive).mock.calls[0][0]; + expect(callArgs.input).toBe('stdin-data\n\ntest-question'); + expect( + terminalNotificationMocks.buildRunEventNotificationContent, + ).not.toHaveBeenCalled(); + expect(terminalNotificationMocks.notifyViaTerminal).not.toHaveBeenCalled(); + expect(processExitSpy).toHaveBeenCalledWith(0); + processExitSpy.mockRestore(); + }); +}); + +describe('resolveSessionId', () => { + it('should return a new session ID when neither resume nor sessionId is provided', async () => { + const { sessionId, resumedSessionData } = await resolveSessionId( + undefined, + undefined, + ); + expect(sessionId).toBeDefined(); + expect(resumedSessionData).toBeUndefined(); + }); + + it('should import from session file when sessionFile is provided', async () => { + // eslint-disable-next-line prefer-arrow-callback + vi.mocked(SessionSelector).mockImplementation(function () { + return { + sessionExists: vi.fn().mockResolvedValue(false), + } as unknown as InstanceType; + }); + + const coreModule = await import('@google/gemini-cli-core'); + vi.spyOn(coreModule, 'loadConversationRecord').mockResolvedValueOnce({ + sessionId: 'old-session-id', + projectHash: 'hash', + startTime: 'time', + lastUpdated: 'time', + messages: [ + { type: 'info', content: 'Old info', id: '1' }, + { type: 'user', content: 'Hello', id: '2' }, + { type: 'gemini', content: 'Hi', id: '3' }, + { type: 'error', content: 'Old error', id: '4' }, + { type: 'user', id: '5' }, // Missing content + null, // Null object + { type: 'unknown', content: 'Something', id: '6' }, // Unknown type + ], + } as unknown as ConversationRecord); + + const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + + try { + const { sessionId, resumedSessionData } = await resolveSessionId( + undefined, + undefined, + 'dummy-session.json', + ); + + expect(sessionId).toBeDefined(); + expect(sessionId).not.toBe('old-session-id'); // A new session ID should be created + expect(resumedSessionData).toBeDefined(); + expect(resumedSessionData?.conversation.sessionId).toBe(sessionId); // Overwritten + + // Verify messages: should have 1 info (the new import confirmation) + 2 valid conversation messages + // Invalid messages (missing content, null, unknown type) and transient messages should be filtered out. + expect(resumedSessionData?.conversation.messages).toHaveLength(3); + expect(resumedSessionData?.conversation.messages![0]).toMatchObject({ + type: 'info', + content: expect.stringContaining('Imported session from'), + }); + expect(resumedSessionData?.conversation.messages![1]).toMatchObject({ + type: 'user', + content: 'Hello', + }); + expect(resumedSessionData?.conversation.messages![2]).toMatchObject({ + type: 'gemini', + content: 'Hi', + }); + + expect(resumedSessionData?.filePath).toContain(sessionId.slice(0, 8)); // New path + } catch (e) { + if (e instanceof MockProcessExitError) { + throw new Error( + 'process.exit called with: ' + + JSON.stringify(emitFeedbackSpy.mock.calls), + ); + } + throw e; + } finally { + emitFeedbackSpy.mockRestore(); + processExitSpy.mockRestore(); + } + }); + + it('should exit with FATAL_INPUT_ERROR when sessionId already exists', async () => { + // eslint-disable-next-line prefer-arrow-callback + vi.mocked(SessionSelector).mockImplementation(function () { + return { + sessionExists: vi.fn().mockResolvedValue(true), + } as unknown as InstanceType; + }); + + const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + + try { + await resolveSessionId(undefined, 'existing-id'); + } catch (e) { + if (!(e instanceof MockProcessExitError)) throw e; + } + + expect(emitFeedbackSpy).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Session ID "existing-id" already exists'), + ); + expect(processExitSpy).toHaveBeenCalledWith(ExitCodes.FATAL_INPUT_ERROR); + + emitFeedbackSpy.mockRestore(); + processExitSpy.mockRestore(); + }); + + it('should return provided sessionId when it does not exist', async () => { + // eslint-disable-next-line prefer-arrow-callback + vi.mocked(SessionSelector).mockImplementation(function () { + return { + sessionExists: vi.fn().mockResolvedValue(false), + } as unknown as InstanceType; + }); + const { sessionId, resumedSessionData } = await resolveSessionId( + undefined, + 'new-id', + ); + expect(sessionId).toBe('new-id'); + expect(resumedSessionData).toBeUndefined(); + }); + + it('should exit with FATAL_INPUT_ERROR when explicit resume session is missing', async () => { + vi.mocked(SessionSelector).mockImplementation( + () => + ({ + resolveSession: vi + .fn() + .mockRejectedValue(SessionError.noSessionsFound()), + }) as unknown as InstanceType, + ); + + const emitFeedbackSpy = vi.spyOn(coreEvents, 'emitFeedback'); + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + + try { + await resolveSessionId('explicit-session-id'); + } catch (e) { + if (!(e instanceof MockProcessExitError)) throw e; + } + + expect(emitFeedbackSpy).toHaveBeenCalledWith( + 'error', + expect.stringContaining('Error resuming session:'), + ); + expect(processExitSpy).toHaveBeenCalledWith(ExitCodes.FATAL_INPUT_ERROR); + + emitFeedbackSpy.mockRestore(); + processExitSpy.mockRestore(); + }); +}); + +describe('gemini.tsx main function exit codes', () => { + let originalEnvNoRelaunch: string | undefined; + let originalIsTTY: boolean | undefined; + + beforeEach(() => { + originalEnvNoRelaunch = process.env['GEMINI_CLI_NO_RELAUNCH']; + process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; + vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + // Mock stderr to avoid cluttering output + vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + originalIsTTY = process.stdin.isTTY; + }); + + afterEach(() => { + if (originalEnvNoRelaunch !== undefined) { + process.env['GEMINI_CLI_NO_RELAUNCH'] = originalEnvNoRelaunch; + } else { + delete process.env['GEMINI_CLI_NO_RELAUNCH']; + } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).isTTY = originalIsTTY; + vi.restoreAllMocks(); + }); + + it('should exit with 42 for invalid input combination (prompt-interactive with non-TTY)', async () => { + vi.mocked(loadCliConfig).mockResolvedValue(createMockConfig()); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { security: { auth: {} }, ui: {} }, + }), + ); + vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + promptInteractive: true, + } as unknown as CliArgs); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).isTTY = false; + + try { + await main(); + expect.fail('Should have thrown MockProcessExitError'); + } catch (e) { + expect(e).toBeInstanceOf(MockProcessExitError); + expect((e as MockProcessExitError).code).toBe(42); + } + }); + + it('should exit with 41 for validateAuthMethod failure during sandbox setup', async () => { + vi.stubEnv('SANDBOX', ''); + vi.mocked(loadSandboxConfig).mockResolvedValue( + createMockSandboxConfig({ + command: 'docker', + image: 'test-image', + }), + ); + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + refreshAuth: vi.fn().mockResolvedValue(undefined), + getRemoteAdminSettings: vi.fn().mockReturnValue(undefined), + isInteractive: vi.fn().mockReturnValue(true), + }), + ); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + security: { auth: { selectedType: 'google', useExternal: false } }, + }, + }), + ); + vi.mocked(parseArguments).mockResolvedValue({} as CliArgs); + + const authModule = await import('./config/auth.js'); + vi.mocked(authModule.validateAuthMethod).mockResolvedValueOnce( + 'Auth method invalid', + ); + + try { + await main(); + expect.fail('Should have thrown MockProcessExitError'); + } catch (e) { + expect(e).toBeInstanceOf(MockProcessExitError); + expect((e as MockProcessExitError).code).toBe(41); + } + }); + + it('should exit with 41 for auth failure during sandbox setup', async () => { + vi.stubEnv('SANDBOX', ''); + vi.mocked(loadSandboxConfig).mockResolvedValue( + createMockSandboxConfig({ + command: 'docker', + image: 'test-image', + }), + ); + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + refreshAuth: vi.fn().mockRejectedValue(new Error('Auth failed')), + getRemoteAdminSettings: vi.fn().mockReturnValue(undefined), + isInteractive: vi.fn().mockReturnValue(true), + }), + ); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { + security: { auth: { selectedType: 'google', useExternal: false } }, + }, + }), + ); + vi.mocked(parseArguments).mockResolvedValue({} as CliArgs); + + try { + await main(); + expect.fail('Should have thrown MockProcessExitError'); + } catch (e) { + expect(e).toBeInstanceOf(MockProcessExitError); + expect((e as MockProcessExitError).code).toBe(41); + } + }); + + it('should exit with 42 for session resume failure', async () => { + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + isInteractive: () => false, + getQuestion: () => 'test', + getSandbox: () => undefined, + }), + ); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { security: { auth: {} }, ui: {} }, + }), + ); + vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + resume: 'invalid-session', + } as unknown as CliArgs); + + vi.mock('./utils/sessionUtils.js', async (importOriginal) => { + const original = + await importOriginal(); + return { + ...original, + SessionSelector: vi.fn().mockImplementation(() => ({ + resolveSession: vi + .fn() + .mockRejectedValue(new Error('Session not found')), + })), + }; + }); + + process.env['GEMINI_API_KEY'] = 'test-key'; + try { + await main(); + expect.fail('Should have thrown MockProcessExitError'); + } catch (e) { + expect(e).toBeInstanceOf(MockProcessExitError); + expect((e as MockProcessExitError).code).toBe(42); + } finally { + delete process.env['GEMINI_API_KEY']; + } + }); + + it('should exit with 42 for no input provided', async () => { + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + isInteractive: () => false, + getQuestion: () => '', + getSandbox: () => undefined, + }), + ); + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { security: { auth: {} }, ui: {} }, + }), + ); + vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + } as unknown as CliArgs); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).isTTY = true; + + process.env['GEMINI_API_KEY'] = 'test-key'; + try { + await main(); + expect.fail('Should have thrown MockProcessExitError'); + } catch (e) { + expect(e).toBeInstanceOf(MockProcessExitError); + expect((e as MockProcessExitError).code).toBe(42); + } finally { + delete process.env['GEMINI_API_KEY']; + } + }); + + it('should validate and refresh auth in non-interactive mode when no auth type is selected but env var is present', async () => { + const refreshAuthSpy = vi.fn(); + vi.mocked(loadCliConfig).mockResolvedValue( + createMockConfig({ + isInteractive: () => false, + getQuestion: () => 'test prompt', + getSandbox: () => undefined, + refreshAuth: refreshAuthSpy, + }), + ); + vi.mocked(validateNonInteractiveAuth).mockResolvedValue( + AuthType.USE_GEMINI, + ); + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + merged: { security: { auth: { selectedType: undefined } }, ui: {} }, + }), + ); + vi.mocked(parseArguments).mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + } as unknown as CliArgs); + + runNonInteractiveSpy.mockImplementation(() => Promise.resolve()); + + const processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code) => { + throw new MockProcessExitError(code); + }); + + process.env['GEMINI_API_KEY'] = 'test-key'; + try { + await main(); + } catch (e) { + if (!(e instanceof MockProcessExitError)) throw e; + } finally { + delete process.env['GEMINI_API_KEY']; + processExitSpy.mockRestore(); + } + + expect(refreshAuthSpy).toHaveBeenCalledWith(AuthType.USE_GEMINI); + }); +}); + +describe('validateDnsResolutionOrder', () => { + let debugLoggerWarnSpy: ReturnType; + + beforeEach(() => { + debugLoggerWarnSpy = vi + .spyOn(debugLogger, 'warn') + .mockImplementation(() => {}); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should return "ipv4first" when the input is "ipv4first"', () => { + expect(validateDnsResolutionOrder('ipv4first')).toBe('ipv4first'); + expect(debugLoggerWarnSpy).not.toHaveBeenCalled(); + }); + + it('should return "verbatim" when the input is "verbatim"', () => { + expect(validateDnsResolutionOrder('verbatim')).toBe('verbatim'); + expect(debugLoggerWarnSpy).not.toHaveBeenCalled(); + }); + + it('should return the default "ipv4first" when the input is undefined', () => { + expect(validateDnsResolutionOrder(undefined)).toBe('ipv4first'); + expect(debugLoggerWarnSpy).not.toHaveBeenCalled(); + }); + + it('should return the default "ipv4first" and log a warning for an invalid string', () => { + expect(validateDnsResolutionOrder('invalid-value')).toBe('ipv4first'); + expect(debugLoggerWarnSpy).toHaveBeenCalledExactlyOnceWith( + 'Invalid value for dnsResolutionOrder in settings: "invalid-value". Using default "ipv4first".', + ); + }); +}); + +describe('project hooks loading based on trust', () => { + let loadCliConfig: Mock; + let loadSettings: Mock; + let parseArguments: Mock; + + beforeEach(async () => { + // Dynamically import and get the mocked functions + const configModule = await import('./config/config.js'); + loadCliConfig = vi.mocked(configModule.loadCliConfig); + parseArguments = vi.mocked(configModule.parseArguments); + parseArguments.mockResolvedValue({ + enabled: true, + allowedPaths: [], + networkAccess: false, + startupMessages: [], + }); + + const settingsModule = await import('./config/settings.js'); + loadSettings = vi.mocked(settingsModule.loadSettings); + + vi.clearAllMocks(); + // Mock the main function's dependencies to isolate the config loading part + vi.mock('./nonInteractiveCli.js', () => ({ + runNonInteractive: vi.fn().mockResolvedValue(undefined), + })); + + vi.spyOn(process, 'exit').mockImplementation((() => {}) as unknown as ( + code?: string | number | null, + ) => never); + + // Default mock implementation for loadCliConfig + loadCliConfig.mockResolvedValue( + createMockConfig({ + getQuestion: vi.fn().mockReturnValue('test question'), + }), + ); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should load project hooks when workspace is trusted', async () => { + const hooks = { 'before-model': 'echo "trusted"' }; + loadSettings.mockReturnValue( + createMockSettings({ + workspace: { + isTrusted: true, + settings: { hooks }, + }, + merged: { + security: { auth: { selectedType: 'google' } }, + }, + }), + ); + + await main(); + + expect(loadCliConfig).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ + projectHooks: hooks, + }), + ); + }); + + it('should NOT load project hooks when workspace is not trusted', async () => { + loadSettings.mockReturnValue( + createMockSettings({ + workspace: { + isTrusted: false, + settings: {}, + }, + merged: { + security: { auth: { selectedType: 'google' } }, + }, + }), + ); + + await main(); + + expect(loadCliConfig).toHaveBeenCalledWith( + expect.anything(), + expect.anything(), + expect.anything(), + expect.objectContaining({ + projectHooks: undefined, + }), + ); + }); +}); + +describe('startInteractiveUI', () => { + // Mock dependencies + const mockConfig = createMockConfig({ + getProjectRoot: () => '/root', + getScreenReader: () => false, + getDebugMode: () => false, + getUseAlternateBuffer: () => true, + }); + const mockSettings = { + merged: { + ui: { + hideWindowTitle: false, + useAlternateBuffer: true, + incrementalRendering: true, + }, + general: { + debugKeystrokeLogging: false, + }, + }, + } as LoadedSettings; + const mockStartupWarnings: StartupWarning[] = [ + { id: 'w1', message: 'warning1', priority: WarningPriority.High }, + ]; + const mockWorkspaceRoot = '/root'; + const mockInitializationResult = { + authError: null, + accountSuspensionInfo: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }; + + vi.mock('./ui/utils/updateCheck.js', () => ({ + checkForUpdates: vi.fn(() => Promise.resolve(null)), + })); + vi.mock('./utils/cleanup.js', () => ({ + cleanupCheckpoints: vi.fn(() => Promise.resolve()), + registerCleanup: vi.fn(), + removeCleanup: vi.fn(), + runExitCleanup: vi.fn(), + registerSyncCleanup: vi.fn(), + removeSyncCleanup: vi.fn(), + registerTelemetryConfig: vi.fn(), + setupSignalHandlers: vi.fn(), + setupTtyCheck: vi.fn(() => vi.fn()), + })); + + beforeEach(() => { + vi.stubEnv('SHPOOL_SESSION_NAME', ''); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + vi.restoreAllMocks(); + }); + + async function startTestInteractiveUI( + config: Config, + settings: LoadedSettings, + startupWarnings: StartupWarning[], + workspaceRoot: string, + resumedSessionData: ResumedSessionData | undefined, + initializationResult: InitializationResult, + ) { + await act(async () => { + await startInteractiveUI( + config, + settings, + startupWarnings, + workspaceRoot, + resumedSessionData, + initializationResult, + ); + }); + } + + it('should render the UI with proper React context and exitOnCtrlC disabled', async () => { + const { render } = await import('ink'); + const renderSpy = vi.mocked(render); + + await startTestInteractiveUI( + mockConfig, + mockSettings, + mockStartupWarnings, + mockWorkspaceRoot, + undefined, + mockInitializationResult, + ); + + // Verify render was called with correct options + const [reactElement, options] = renderSpy.mock.calls[0]; + + // Verify render options + expect(options).toEqual( + expect.objectContaining({ + alternateBuffer: true, + exitOnCtrlC: false, + incrementalRendering: true, + isScreenReaderEnabled: false, + onRender: expect.any(Function), + patchConsole: false, + }), + ); + + // Verify React element structure is valid (but don't deep dive into JSX internals) + expect(reactElement).toBeDefined(); + }); + + it('should enable mouse events when alternate buffer is enabled', async () => { + const { enableMouseEvents } = await import('@google/gemini-cli-core'); + await startTestInteractiveUI( + mockConfig, + mockSettings, + mockStartupWarnings, + mockWorkspaceRoot, + undefined, + mockInitializationResult, + ); + expect(enableMouseEvents).toHaveBeenCalled(); + }); + + it('should patch console', async () => { + const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js'); + const patchSpy = vi.spyOn(ConsolePatcher.prototype, 'patch'); + await startTestInteractiveUI( + mockConfig, + mockSettings, + mockStartupWarnings, + mockWorkspaceRoot, + undefined, + mockInitializationResult, + ); + expect(patchSpy).toHaveBeenCalled(); + }); + + it('should perform all startup tasks in correct order', async () => { + const { getVersion } = await import('@google/gemini-cli-core'); + const { checkForUpdates } = await import('./ui/utils/updateCheck.js'); + const { registerCleanup } = await import('./utils/cleanup.js'); + + await startTestInteractiveUI( + mockConfig, + mockSettings, + mockStartupWarnings, + mockWorkspaceRoot, + undefined, + mockInitializationResult, + ); + + // Verify all startup tasks were called + expect(getVersion).toHaveBeenCalledTimes(1); + // 6 cleanups: mouseEvents, lineWrapping, non-resumable session cleanup, + // instance.unmount, TTY check, and consolePatcher + expect(registerCleanup).toHaveBeenCalledTimes(6); + + // Verify cleanup handler is registered with unmount function + const cleanupFn = vi.mocked(registerCleanup).mock.calls[0][0]; + expect(typeof cleanupFn).toBe('function'); + + // checkForUpdates should be called asynchronously (not waited for) + // We need a small delay to let it execute + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(checkForUpdates).toHaveBeenCalledTimes(1); + }); + + it('should not recordSlowRender when less than threshold', async () => { + const { recordSlowRender } = await import('@google/gemini-cli-core'); + performance.now.mockReturnValueOnce(0); + await startTestInteractiveUI( + mockConfig, + mockSettings, + mockStartupWarnings, + mockWorkspaceRoot, + undefined, + mockInitializationResult, + ); + + expect(recordSlowRender).not.toHaveBeenCalled(); + }); + + it('should call recordSlowRender when more than threshold', async () => { + const { recordSlowRender } = await import('@google/gemini-cli-core'); + performance.now.mockReturnValueOnce(0); + performance.now.mockReturnValueOnce(300); + + await startTestInteractiveUI( + mockConfig, + mockSettings, + mockStartupWarnings, + mockWorkspaceRoot, + undefined, + mockInitializationResult, + ); + + expect(recordSlowRender).toHaveBeenCalledWith(mockConfig, 300); + }); + + it.each([ + { + screenReader: true, + expectedCalls: [], + name: 'should not disable line wrapping in screen reader mode', + }, + { + screenReader: false, + expectedCalls: [['\x1b[?7l']], + name: 'should disable line wrapping when not in screen reader mode', + }, + ])('$name', async ({ screenReader, expectedCalls }) => { + const writeSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + const mockConfigWithScreenReader = { + // eslint-disable-next-line @typescript-eslint/no-misused-spread + ...mockConfig, + getScreenReader: () => screenReader, + } as Config; + + await startTestInteractiveUI( + mockConfigWithScreenReader, + mockSettings, + mockStartupWarnings, + mockWorkspaceRoot, + undefined, + mockInitializationResult, + ); + + if (expectedCalls.length > 0) { + expect(writeSpy).toHaveBeenCalledWith(expectedCalls[0][0]); + } else { + expect(writeSpy).not.toHaveBeenCalledWith('\x1b[?7l'); + } + writeSpy.mockRestore(); + }); +}); diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f35429b952caddb4556538b653a2404057815305 --- /dev/null +++ b/packages/cli/src/gemini.tsx @@ -0,0 +1,973 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type StartupWarning, + WarningPriority, + type Config, + type ResumedSessionData, + type WorktreeInfo, + type OutputPayload, + type ConsoleLogPayload, + type UserFeedbackPayload, + type CoreEvents, + createSessionId, + logUserPrompt, + AuthType, + UserPromptEvent, + coreEvents, + CoreEvent, + getOauthClient, + patchStdio, + writeToStdout, + writeToStderr, + shouldEnterAlternateScreen, + startupProfiler, + ExitCodes, + SessionStartSource, + SessionEndReason, + ValidationCancelledError, + ValidationRequiredError, + type AdminControlsSettings, + debugLogger, + isHeadlessMode, + Storage, + getProjectHash, + loadConversationRecord, + type MessageRecord, +} from '@google/gemini-cli-core'; + +import { loadCliConfig, parseArguments } from './config/config.js'; +import * as cliConfig from './config/config.js'; +import { readStdin } from './utils/readStdin.js'; +import { createHash } from 'node:crypto'; +import v8 from 'node:v8'; +import os from 'node:os'; +import dns from 'node:dns'; +import * as path from 'node:path'; +import * as fsPromises from 'node:fs/promises'; +import { start_sandbox } from './utils/sandbox.js'; +import { + loadSettings, + SettingScope, + type DnsResolutionOrder, + type LoadedSettings, +} from './config/settings.js'; +import { + loadTrustedFolders, + type TrustedFoldersError, +} from './config/trustedFolders.js'; +import { getStartupWarnings } from './utils/startupWarnings.js'; +import { getUserStartupWarnings } from './utils/userStartupWarnings.js'; +import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; +import { runNonInteractive } from './nonInteractiveCli.js'; +import { + cleanupCheckpoints, + registerCleanup, + registerSyncCleanup, + runExitCleanup, + registerTelemetryConfig, + setupSignalHandlers, +} from './utils/cleanup.js'; +import { setupWorktree } from './utils/worktreeSetup.js'; +import { + cleanupToolOutputFiles, + cleanupExpiredSessions, +} from './utils/sessionCleanup.js'; +import { + initializeApp, + type InitializationResult, +} from './core/initializer.js'; +import { validateAuthMethod } from './config/auth.js'; +import { runAcpClient } from './acp/acpStdioTransport.js'; +import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; +import { appEvents, AppEvent } from './utils/events.js'; +import { + RESUME_LATEST, + SessionError, + SessionSelector, +} from './utils/sessionUtils.js'; + +import { relaunchOnExitCode } from './utils/relaunch.js'; +import { loadSandboxConfig } from './config/sandboxConfig.js'; +import { deleteSession, listSessions } from './utils/sessions.js'; +import { createPolicyUpdater } from './config/policy.js'; + +import { setupTerminalAndTheme } from './utils/terminalTheme.js'; +import { runDeferredCommand } from './deferred.js'; +import { cleanupBackgroundLogs } from './utils/logCleanup.js'; +import { SlashCommandConflictHandler } from './services/SlashCommandConflictHandler.js'; + +export function validateDnsResolutionOrder( + order: string | undefined, +): DnsResolutionOrder { + const defaultValue: DnsResolutionOrder = 'ipv4first'; + if (order === undefined) { + return defaultValue; + } + if (order === 'ipv4first' || order === 'verbatim') { + return order; + } + // We don't want to throw here, just warn and use the default. + debugLogger.warn( + `Invalid value for dnsResolutionOrder in settings: "${order}". Using default "${defaultValue}".`, + ); + return defaultValue; +} + +const DEFAULT_EPT_SIZE = (256 * 1024 * 1024).toString(); + +export function getNodeMemoryArgs(isDebugMode: boolean): string[] { + const totalMemoryMB = os.totalmem() / (1024 * 1024); + const heapStats = v8.getHeapStatistics(); + const currentMaxOldSpaceSizeMb = Math.floor( + heapStats.heap_size_limit / 1024 / 1024, + ); + + // Set target to 50% of total memory + const targetMaxOldSpaceSizeInMB = Math.floor(totalMemoryMB * 0.5); + if (isDebugMode) { + debugLogger.debug( + `Current heap size ${currentMaxOldSpaceSizeMb.toFixed(2)} MB`, + ); + } + + if (process.env['GEMINI_CLI_NO_RELAUNCH']) { + return []; + } + + const args: string[] = []; + + // Automatically expand the V8 External Pointer Table to 256MB to prevent + // out-of-memory crashes during high native-handle concurrency. + // Note: Only supported in specific Node.js versions compiled with V8 Sandbox enabled. + const eptFlag = `--max-external-pointer-table-size=${DEFAULT_EPT_SIZE}`; + const isV8SandboxEnabled = + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-type-assertion + (process.config?.variables as any)?.v8_enable_sandbox === 1; + + if ( + isV8SandboxEnabled && + !process.execArgv.some((arg) => + arg.startsWith('--max-external-pointer-table-size'), + ) + ) { + args.push(eptFlag); + } + + if (targetMaxOldSpaceSizeInMB > currentMaxOldSpaceSizeMb) { + if (isDebugMode) { + debugLogger.debug( + `Need to relaunch with more memory: ${targetMaxOldSpaceSizeInMB.toFixed(2)} MB`, + ); + } + args.push(`--max-old-space-size=${targetMaxOldSpaceSizeInMB}`); + } + + return args; +} + +export function setupUnhandledRejectionHandler() { + let unhandledRejectionOccurred = false; + process.on('unhandledRejection', (reason, _promise) => { + // AbortError is expected when the user cancels a request (e.g. pressing ESC). + // It may surface as an unhandled rejection due to async timing in the + // streaming pipeline, but it is not a bug. + if (reason instanceof Error && reason.name === 'AbortError') { + debugLogger.log(`Suppressed unhandled AbortError: ${reason.message}`); + return; + } + + const errorMessage = `========================================= +This is an unexpected error. Please file a bug report using the /bug tool. +CRITICAL: Unhandled Promise Rejection! +========================================= +Reason: ${reason}${ + reason instanceof Error && reason.stack + ? ` +Stack trace: +${reason.stack}` + : '' + }`; + debugLogger.error(errorMessage); + if (!unhandledRejectionOccurred) { + unhandledRejectionOccurred = true; + appEvents.emit(AppEvent.OpenDebugConsole); + } + }); +} + +export async function resolveSessionId( + resumeArg: string | undefined, + sessionIdArg?: string | undefined, + sessionFileArg?: string | undefined, +): Promise<{ + sessionId: string; + resumedSessionData?: ResumedSessionData; +}> { + if (!resumeArg && !sessionIdArg && !sessionFileArg) { + return { sessionId: createSessionId() }; + } + + const storage = new Storage(process.cwd()); + await storage.initialize(); + + const sessionSelector = new SessionSelector(storage); + + if (sessionFileArg) { + try { + const sessionData = await loadConversationRecord(sessionFileArg); + if (!sessionData) { + throw new Error(`File not found or invalid format: ${sessionFileArg}`); + } + + const now = Date.now(); + const isoNow = new Date(now).toISOString(); + + // Filter out old system/info messages that are specific to the previous run + // and only keep actual conversation messages (user/gemini). + // Best effort parse: ensure message is an object and has required fields. + sessionData.messages = (sessionData.messages || []).filter( + (m) => + typeof m === 'object' && + m !== null && + (m.type === 'user' || m.type === 'gemini') && + m.content !== undefined, + ); + + // Add a single info message to the history to confirm the import + sessionData.messages.unshift({ + id: `import-${now}`, + type: 'info', + content: `Imported session from ${sessionFileArg}`, + timestamp: isoNow, + } as MessageRecord); + + const newSessionId = createSessionId(); + sessionData.sessionId = newSessionId; + sessionData.projectHash = getProjectHash(storage.getProjectRoot()); + sessionData.startTime = isoNow; + sessionData.lastUpdated = isoNow; + + const chatsDir = path.join(storage.getProjectTempDir(), 'chats'); + const newSessionPath = path.join( + chatsDir, + `session-${now}-${newSessionId.slice(0, 8)}.jsonl`, + ); + + const { messages: _messages, ...initialMetadata } = sessionData; + + const lines = [JSON.stringify(initialMetadata)]; + if (sessionData.messages) { + for (const msg of sessionData.messages) { + lines.push(JSON.stringify(msg)); + } + } + + await fsPromises.mkdir(chatsDir, { recursive: true }); + await fsPromises.writeFile( + newSessionPath, + lines.join('\n') + '\n', + 'utf-8', + ); + + return { + sessionId: newSessionId, + resumedSessionData: { + conversation: sessionData, + filePath: newSessionPath, + }, + }; + } catch (error) { + coreEvents.emitFeedback( + 'error', + `Error importing session from file: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_INPUT_ERROR); + } + } + + if (sessionIdArg) { + if (await sessionSelector.sessionExists(sessionIdArg)) { + coreEvents.emitFeedback( + 'error', + `Error starting session: Session ID "${sessionIdArg}" already exists. Use --resume to resume it, or provide a different ID.`, + ); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_INPUT_ERROR); + } + return { sessionId: sessionIdArg }; + } + + try { + const { sessionData, sessionPath } = await sessionSelector.resolveSession( + resumeArg!, + ); + return { + sessionId: sessionData.sessionId, + resumedSessionData: { conversation: sessionData, filePath: sessionPath }, + }; + } catch (error) { + if (error instanceof SessionError && error.code === 'NO_SESSIONS_FOUND') { + if (resumeArg === RESUME_LATEST) { + coreEvents.emitFeedback('warning', error.message); + return { sessionId: createSessionId() }; + } + } + coreEvents.emitFeedback( + 'error', + `Error resuming session: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_INPUT_ERROR); + } +} + +export async function startInteractiveUI( + config: Config, + settings: LoadedSettings, + startupWarnings: StartupWarning[], + workspaceRoot: string = process.cwd(), + resumedSessionData: ResumedSessionData | undefined, + initializationResult: InitializationResult, +) { + // Dynamically import the heavy UI module so React/Ink are only parsed when needed + const { startInteractiveUI: doStartUI } = await import('./interactiveCli.js'); + await doStartUI( + config, + settings, + startupWarnings, + workspaceRoot, + resumedSessionData, + initializationResult, + ); +} + +export async function main() { + let config: Config | undefined; + const cliStartupHandle = startupProfiler.start('cli_startup'); + + // Listen for admin controls from parent process (IPC) in non-sandbox mode. In + // sandbox mode, we re-fetch the admin controls from the server once we enter + // the sandbox. + // TODO: Cache settings in sandbox mode as well. + const adminControlsListner = setupAdminControlsListener(); + registerCleanup(adminControlsListner.cleanup); + + const cleanupStdio = patchStdio(); + registerSyncCleanup(() => { + // This is needed to ensure we don't lose any buffered output. + initializeOutputListenersAndFlush(config); + cleanupStdio(); + }); + + setupUnhandledRejectionHandler(); + + setupSignalHandlers(); + + const slashCommandConflictHandler = new SlashCommandConflictHandler(); + slashCommandConflictHandler.start(); + registerCleanup(() => slashCommandConflictHandler.stop()); + + const loadSettingsHandle = startupProfiler.start('load_settings'); + const settings = loadSettings(); + loadSettingsHandle?.end(); + + // If a worktree is requested and enabled, set it up early. + // This must be awaited before any other async tasks that depend on CWD (like loadCliConfig) + // because setupWorktree calls process.chdir(). + const requestedWorktree = cliConfig.getRequestedWorktreeName(settings); + let worktreeInfo: WorktreeInfo | undefined; + if (requestedWorktree !== undefined) { + const worktreeHandle = startupProfiler.start('setup_worktree'); + worktreeInfo = await setupWorktree(requestedWorktree || undefined); + worktreeHandle?.end(); + } + + const cleanupOpsHandle = startupProfiler.start('cleanup_ops'); + Promise.all([ + cleanupCheckpoints(), + cleanupToolOutputFiles(settings.merged), + cleanupBackgroundLogs(), + ]) + .catch((e) => { + debugLogger.error('Early cleanup failed:', e); + }) + .finally(() => { + cleanupOpsHandle?.end(); + }); + + const parseArgsHandle = startupProfiler.start('parse_arguments'); + const argvPromise = parseArguments(settings.merged).finally(() => { + parseArgsHandle?.end(); + }); + + const rawStartupWarningsPromise = getStartupWarnings(); + + // Report settings errors once during startup + settings.errors.forEach((error) => { + coreEvents.emitFeedback('warning', error.message); + }); + + const trustedFolders = loadTrustedFolders(); + trustedFolders.errors.forEach((error: TrustedFoldersError) => { + coreEvents.emitFeedback( + 'warning', + `Error in ${error.path}: ${error.message}`, + ); + }); + + const argv = await argvPromise; + + const { sessionId, resumedSessionData } = await resolveSessionId( + argv.resume, + argv.sessionId, + argv.sessionFile, + ); + + if ( + (argv.allowedTools && argv.allowedTools.length > 0) || + (settings.merged.tools?.allowed && settings.merged.tools.allowed.length > 0) + ) { + coreEvents.emitFeedback( + 'warning', + 'Warning: --allowed-tools cli argument and tools.allowed in settings.json are deprecated and will be removed in 1.0: Migrate to Policy Engine: https://geminicli.com/docs/core/policy-engine/', + ); + } + + if ( + settings.merged.tools?.exclude && + settings.merged.tools.exclude.length > 0 + ) { + coreEvents.emitFeedback( + 'warning', + 'Warning: tools.exclude in settings.json is deprecated and will be removed in 1.0. Migrate to Policy Engine: https://geminicli.com/docs/core/policy-engine/', + ); + } + + if (argv.startupMessages) { + argv.startupMessages.forEach((msg) => { + coreEvents.emitFeedback('info', msg); + }); + } + + // Check for invalid input combinations early to prevent crashes + if (argv.promptInteractive && !process.stdin.isTTY) { + writeToStderr( + 'Error: The --prompt-interactive flag cannot be used when input is piped from stdin.\n', + ); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_INPUT_ERROR); + } + + const isDebugMode = cliConfig.isDebugMode(argv); + const consolePatcher = new ConsolePatcher({ + stderr: argv.isCommand ? false : true, + interactive: isHeadlessMode() && !argv.isCommand ? false : true, + debugMode: isDebugMode, + onNewMessage: (msg) => { + coreEvents.emitConsoleLog(msg.type, msg.content); + }, + }); + consolePatcher.patch(); + + dns.setDefaultResultOrder( + validateDnsResolutionOrder(settings.merged.advanced.dnsResolutionOrder), + ); + + // Set a default auth type if one isn't set or is set to a legacy type + if ( + !settings.merged.security.auth.selectedType || + settings.merged.security.auth.selectedType === AuthType.LEGACY_CLOUD_SHELL + ) { + if ( + process.env['CLOUD_SHELL'] === 'true' || + process.env['GEMINI_CLI_USE_COMPUTE_ADC'] === 'true' + ) { + settings.setValue( + SettingScope.User, + 'security.auth.selectedType', + AuthType.COMPUTE_ADC, + ); + } + } + + const partialConfig = await loadCliConfig(settings.merged, sessionId, argv, { + projectHooks: settings.workspace.settings.hooks, + skipExtensions: true, + loadedSettings: settings, + }); + + adminControlsListner.setConfig(partialConfig); + + // Refresh auth to fetch remote admin settings from CCPA and before entering + // the sandbox because the sandbox will interfere with the Oauth2 web + // redirect. + let initialAuthFailed = false; + if (!settings.merged.security.auth.useExternal && !argv.isCommand) { + try { + if ( + partialConfig.isInteractive() && + settings.merged.security.auth.selectedType + ) { + const err = await validateAuthMethod( + settings.merged.security.auth.selectedType, + ); + if (err) { + throw new Error(err); + } + + await partialConfig.refreshAuth( + settings.merged.security.auth.selectedType, + ); + } else if (!partialConfig.isInteractive()) { + const authType = await validateNonInteractiveAuth( + settings.merged.security.auth.selectedType, + settings.merged.security.auth.useExternal, + partialConfig, + settings, + ); + await partialConfig.refreshAuth(authType); + } + } catch (err) { + if (err instanceof ValidationCancelledError) { + // User cancelled verification, exit immediately. + await runExitCleanup(); + process.exit(ExitCodes.SUCCESS); + } + + // If validation is required, we don't treat it as a fatal failure. + // We allow the app to start, and the React-based ValidationDialog + // will handle it. + if (!(err instanceof ValidationRequiredError)) { + debugLogger.error('Error authenticating:', err); + initialAuthFailed = true; + } + } + } + + const remoteAdminSettings = partialConfig.getRemoteAdminSettings(); + // Set remote admin settings if returned from CCPA. + if (remoteAdminSettings) { + settings.setRemoteAdminSettings(remoteAdminSettings); + if (process.send) { + process.send({ + type: 'admin-settings-update', + settings: remoteAdminSettings, + }); + } + } + + // Run deferred command now that we have admin settings. + await runDeferredCommand(settings.merged); + + // hop into sandbox if we are outside and sandboxing is enabled + if (!process.env['SANDBOX'] && !argv.isCommand) { + const memoryArgs = settings.merged.advanced.autoConfigureMemory + ? getNodeMemoryArgs(isDebugMode) + : []; + const sandboxConfig = await loadSandboxConfig(settings.merged, argv); + // We intentionally omit the list of extensions here because extensions + // should not impact auth or setting up the sandbox. + // TODO(jacobr): refactor loadCliConfig so there is a minimal version + // that only initializes enough config to enable refreshAuth or find + // another way to decouple refreshAuth from requiring a config. + + if (sandboxConfig) { + if (initialAuthFailed) { + await runExitCleanup(); + process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR); + } + let stdinData = ''; + if (!process.stdin.isTTY) { + stdinData = await readStdin(); + } + + // This function is a copy of the one from sandbox.ts + // It is moved here to decouple sandbox.ts from the CLI's argument structure. + const injectStdinIntoArgs = ( + args: string[], + stdinData?: string, + ): string[] => { + const finalArgs = [...args]; + if (stdinData) { + const promptIndex = finalArgs.findIndex( + (arg) => arg === '--prompt' || arg === '-p', + ); + if (promptIndex > -1 && finalArgs.length > promptIndex + 1) { + // If there's a prompt argument, prepend stdin to it + finalArgs[promptIndex + 1] = + `${stdinData}\n\n${finalArgs[promptIndex + 1]}`; + } else { + // If there's no prompt argument, add stdin as the prompt + finalArgs.push('--prompt', stdinData); + } + } + return finalArgs; + }; + + const sandboxArgs = injectStdinIntoArgs(process.argv, stdinData); + + await relaunchOnExitCode(() => + start_sandbox(sandboxConfig, memoryArgs, partialConfig, sandboxArgs), + ); + await runExitCleanup(); + process.exit(ExitCodes.SUCCESS); + } + } + + // We are now past the logic handling potentially launching a child process + // to run Gemini CLI. It is now safe to perform expensive initialization that + // may have side effects. + { + const loadConfigHandle = startupProfiler.start('load_cli_config'); + config = await loadCliConfig(settings.merged, sessionId, argv, { + projectHooks: settings.workspace.settings.hooks, + worktreeSettings: worktreeInfo, + loadedSettings: settings, + }); + loadConfigHandle?.end(); + + // Initialize storage immediately after loading config to ensure that + // storage-related operations (like listing or resuming sessions) have + // access to the project identifier. + await config.storage.initialize(); + + adminControlsListner.setConfig(config); + + if (config.isInteractive() && settings.merged.general.devtools) { + const { setupInitialActivityLogger } = await import( + './utils/devtoolsService.js' + ); + setupInitialActivityLogger(config); + } + + // Register config for telemetry shutdown + // This ensures telemetry (including SessionEnd hooks) is properly flushed on exit + registerTelemetryConfig(config); + + const policyEngine = config.getPolicyEngine(); + const messageBus = config.getMessageBus(); + createPolicyUpdater(policyEngine, messageBus, config.storage); + + // Register SessionEnd hook to fire on graceful exit + // This runs before telemetry shutdown in runExitCleanup() + registerCleanup(async () => { + await config?.getHookSystem()?.fireSessionEndEvent(SessionEndReason.Exit); + }); + + // Register ConsolePatcher cleanup last to ensure logs from shutdown hooks + // are correctly redirected to stderr (especially for non-interactive JSON output). + if (!config.getAcpMode()) { + registerCleanup(consolePatcher.cleanup); + } + + // Launch cleanup expired sessions as a background task + cleanupExpiredSessions(config, settings.merged).catch((e) => { + debugLogger.error('Failed to cleanup expired sessions:', e); + }); + + if (config.getListExtensions()) { + debugLogger.log('Installed extensions:'); + for (const extension of config.getExtensions()) { + debugLogger.log(`- ${extension.name}`); + } + await runExitCleanup(); + process.exit(ExitCodes.SUCCESS); + } + + // Handle --list-sessions flag + if (config.getListSessions()) { + // Attempt auth for summary generation (gracefully skips if not configured) + const authType = settings.merged.security.auth.selectedType; + if (authType) { + try { + await config.refreshAuth(authType); + } catch (e) { + // Auth failed - continue without summary generation capability + debugLogger.debug( + 'Auth failed for --list-sessions, summaries may not be generated:', + e, + ); + } + } + + await listSessions(config); + await runExitCleanup(); + process.exit(ExitCodes.SUCCESS); + } + + // Handle --delete-session flag + const sessionToDelete = config.getDeleteSession(); + if (sessionToDelete) { + await deleteSession(config, sessionToDelete); + await runExitCleanup(); + process.exit(ExitCodes.SUCCESS); + } + + const wasRaw = process.stdin.isRaw; + if (config.isInteractive() && !wasRaw && process.stdin.isTTY) { + // Set this as early as possible to avoid spurious characters from + // input showing up in the output. + process.stdin.setRawMode(true); + + // This cleanup isn't strictly needed but may help in certain situations. + registerSyncCleanup(() => { + process.stdin.setRawMode(wasRaw); + }); + } + + const terminalHandle = startupProfiler.start('setup_terminal'); + await setupTerminalAndTheme(config, settings); + terminalHandle?.end(); + + const initAppHandle = startupProfiler.start('initialize_app'); + const initializationResult = await initializeApp(config, settings); + initAppHandle?.end(); + + import('./services/liteRtServerManager.js') + .then(({ LiteRtServerManager }) => { + const mergedGemma = settings.merged.experimental?.gemmaModelRouter; + if (!mergedGemma) return; + // Security: binaryPath and autoStartServer must come from user-scoped + // settings only to prevent workspace configs from triggering arbitrary + // binary execution. + const userGemma = settings.forScope(SettingScope.User).settings + .experimental?.gemmaModelRouter; + return LiteRtServerManager.ensureRunning({ + ...mergedGemma, + binaryPath: userGemma?.binaryPath, + autoStartServer: userGemma?.autoStartServer, + }); + }) + .catch((e) => debugLogger.warn('LiteRT auto-start import failed:', e)); + + if ( + settings.merged.security.auth.selectedType === + AuthType.LOGIN_WITH_GOOGLE && + config.isBrowserLaunchSuppressed() + ) { + // Do oauth before app renders to make copying the link possible. + await getOauthClient(settings.merged.security.auth.selectedType, config); + } + + if (config.getAcpMode()) { + return runAcpClient(config, settings, argv); + } + + let input = config.getQuestion(); + const useAlternateBuffer = shouldEnterAlternateScreen( + config.getUseAlternateBuffer(), + config.getScreenReader(), + ); + const rawStartupWarnings = await rawStartupWarningsPromise; + const startupWarnings: StartupWarning[] = [ + ...rawStartupWarnings.map((message) => ({ + id: `startup-${createHash('sha256').update(message).digest('hex').substring(0, 16)}`, + message, + priority: WarningPriority.High, + })), + ...(await getUserStartupWarnings(settings.merged, undefined, { + isAlternateBuffer: useAlternateBuffer, + })), + ]; + + cliStartupHandle?.end(); + + if (!config.isInteractive()) { + for (const warning of startupWarnings) { + writeToStderr(warning.message + '\n'); + } + } + + // Render UI, passing necessary config values. Check that there is no command line question. + if (config.isInteractive()) { + // Earlier initialization phases (like TerminalCapabilityManager resolving + // or authWithWeb) may have added and removed 'data' listeners on process.stdin. + // When the listener count drops to 0, Node.js implicitly pauses the stream buffer. + // React Ink's useInput hooks will silently fail to receive keystrokes if the stream remains paused. + if (process.stdin.isTTY) { + process.stdin.resume(); + } + + await startInteractiveUI( + config, + settings, + startupWarnings, + process.cwd(), + resumedSessionData, + initializationResult, + ); + return; + } + + await config.initialize(); + startupProfiler.flush(config); + + // If not a TTY, read from stdin + // This is for cases where the user pipes input directly into the command + let stdinData: string | undefined = undefined; + if (!process.stdin.isTTY) { + stdinData = await readStdin(); + if (stdinData) { + input = input ? `${stdinData}\n\n${input}` : stdinData; + } + } + + // Fire SessionStart hook through MessageBus (only if hooks are enabled) + // Must be called AFTER config.initialize() to ensure HookRegistry is loaded + const sessionStartSource = resumedSessionData + ? SessionStartSource.Resume + : SessionStartSource.Startup; + + const hookSystem = config?.getHookSystem(); + if (hookSystem) { + const result = await hookSystem.fireSessionStartEvent(sessionStartSource); + + if (result) { + if (result.systemMessage) { + writeToStderr(result.systemMessage + '\n'); + } + const additionalContext = result.getAdditionalContext(); + if (additionalContext) { + // Prepend context to input (System Context -> Stdin -> Question) + const wrappedContext = `${additionalContext}`; + input = input ? `${wrappedContext}\n\n${input}` : wrappedContext; + } + } + } + + if (!input) { + debugLogger.error( + `No input provided via stdin. Input can be provided by piping data into gemini or using the --prompt option.`, + ); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_INPUT_ERROR); + } + + const prompt_id = sessionId; + logUserPrompt( + config, + new UserPromptEvent( + input.length, + prompt_id, + config.getContentGeneratorConfig()?.authType, + input, + ), + ); + + const authType = await validateNonInteractiveAuth( + settings.merged.security.auth.selectedType, + settings.merged.security.auth.useExternal, + config, + settings, + ); + await config.refreshAuth(authType); + + if (config.getDebugMode()) { + debugLogger.log('Session ID: %s', sessionId); + } + + initializeOutputListenersAndFlush(config); + + await runNonInteractive({ + config, + settings, + input, + prompt_id, + resumedSessionData, + }); + // Call cleanup before process.exit, which causes cleanup to not run + await runExitCleanup(); + process.exit(ExitCodes.SUCCESS); + } +} + +export function initializeOutputListenersAndFlush(config?: Config) { + // If there are no listeners for output, make sure we flush so output is not + // lost. + if (coreEvents.listenerCount(CoreEvent.Output) === 0) { + // In non-interactive mode, ensure we drain any buffered output or logs to stderr + coreEvents.on(CoreEvent.Output, (payload: OutputPayload) => { + if (payload.isStderr) { + writeToStderr(payload.chunk, payload.encoding); + } else { + writeToStdout(payload.chunk, payload.encoding); + } + }); + } + + if (coreEvents.listenerCount(CoreEvent.ConsoleLog) === 0) { + coreEvents.on(CoreEvent.ConsoleLog, (payload: ConsoleLogPayload) => { + if (payload.type === 'error' || payload.type === 'warn') { + writeToStderr(payload.content + '\n'); + } else { + writeToStderr(payload.content + '\n'); + } + }); + } + + if (coreEvents.listenerCount(CoreEvent.UserFeedback) === 0) { + coreEvents.on(CoreEvent.UserFeedback, (payload: UserFeedbackPayload) => { + writeToStderr(payload.message + '\n'); + }); + } + + const outputFormat = config?.getOutputFormat(); + const forceToStderr = outputFormat === 'json'; + + coreEvents.drainBacklogs( + (event: K, args: CoreEvents[K]) => { + if (forceToStderr && event === (CoreEvent.Output as string)) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const payload = args[0] as OutputPayload; + if (!payload.isStderr) { + return { + event, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + args: [{ ...payload, isStderr: true }] as unknown as CoreEvents[K], + }; + } + } + return { event, args }; + }, + ); +} + +function setupAdminControlsListener() { + let pendingSettings: AdminControlsSettings | undefined; + let config: Config | undefined; + + const messageHandler = (msg: unknown) => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const message = msg as { + type?: string; + settings?: AdminControlsSettings; + }; + if (message?.type === 'admin-settings' && message.settings) { + if (config) { + config.setRemoteAdminSettings(message.settings); + } else { + pendingSettings = message.settings; + } + } + }; + + process.on('message', messageHandler); + + return { + setConfig: (newConfig: Config) => { + config = newConfig; + if (pendingSettings) { + config.setRemoteAdminSettings(pendingSettings); + } + }, + cleanup: () => { + process.off('message', messageHandler); + }, + }; +} diff --git a/packages/cli/src/gemini_cleanup.test.tsx b/packages/cli/src/gemini_cleanup.test.tsx new file mode 100644 index 0000000000000000000000000000000000000000..75e9cf3959d0050fd5cd6546578eb57165936a23 --- /dev/null +++ b/packages/cli/src/gemini_cleanup.test.tsx @@ -0,0 +1,465 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { main } from './gemini.js'; +import { + debugLogger, + SessionEndReason, + type Config, + type HookSystem, +} from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + writeToStdout: vi.fn(), + patchStdio: vi.fn(() => () => {}), + createWorkingStdio: vi.fn(() => ({ + stdout: { + write: vi.fn(), + columns: 80, + rows: 24, + on: vi.fn(), + removeListener: vi.fn(), + }, + stderr: { write: vi.fn() }, + })), + enableMouseEvents: vi.fn(), + disableMouseEvents: vi.fn(), + enterAlternateScreen: vi.fn(), + disableLineWrapping: vi.fn(), + ProjectRegistry: vi.fn().mockImplementation(() => ({ + initialize: vi.fn(), + getShortId: vi.fn().mockReturnValue('project-slug'), + })), + }; +}); + +vi.mock('ink', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + render: vi.fn(() => ({ + unmount: vi.fn(), + rerender: vi.fn(), + cleanup: vi.fn(), + waitUntilExit: vi.fn(), + })), + }; +}); + +vi.mock('./config/settings.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSettings: vi.fn().mockReturnValue({ + merged: { advanced: {}, security: { auth: {} }, ui: {} }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + errors: [], + }), + }; +}); + +vi.mock('./ui/utils/ConsolePatcher.js', () => ({ + ConsolePatcher: vi.fn().mockImplementation(() => ({ + patch: vi.fn(), + cleanup: vi.fn(), + })), +})); + +vi.mock('./config/config.js', () => ({ + loadCliConfig: vi.fn().mockResolvedValue({ + getSandbox: vi.fn(() => false), + getQuestion: vi.fn(() => ''), + isInteractive: () => false, + getSessionId: vi.fn().mockReturnValue('test-session-id'), + storage: { initialize: vi.fn().mockResolvedValue(undefined) }, + } as unknown as Config), + parseArguments: vi.fn().mockResolvedValue({}), + isDebugMode: vi.fn(() => false), + getRequestedWorktreeName: vi.fn(() => undefined), + getWorktreeArg: vi.fn(() => undefined), +})); + +vi.mock('read-package-up', () => ({ + readPackageUp: vi.fn().mockResolvedValue({ + packageJson: { name: 'test-pkg', version: 'test-version' }, + path: '/fake/path/package.json', + }), +})); + +vi.mock('update-notifier', () => ({ + default: vi.fn(() => ({ notify: vi.fn() })), +})); + +vi.mock('./utils/events.js', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, appEvents: { emit: vi.fn() } }; +}); + +vi.mock('./utils/sandbox.js', () => ({ + sandbox_command: vi.fn(() => ''), + start_sandbox: vi.fn(() => Promise.resolve()), +})); + +vi.mock('./utils/relaunch.js', () => ({ + relaunchAppInChildProcess: vi.fn(), + relaunchOnExitCode: vi.fn(), +})); + +vi.mock('./config/sandboxConfig.js', () => ({ + loadSandboxConfig: vi.fn(), +})); + +vi.mock('./ui/utils/mouse.js', () => ({ + enableMouseEvents: vi.fn(), + disableMouseEvents: vi.fn(), + parseMouseEvent: vi.fn(), + isIncompleteMouseSequence: vi.fn(), +})); + +vi.mock('./validateNonInterActiveAuth.js', () => ({ + validateNonInteractiveAuth: vi.fn().mockResolvedValue({}), +})); + +vi.mock('./core/initializer.js', () => ({ + initializeApp: vi.fn().mockResolvedValue({ + authError: null, + themeError: null, + shouldOpenAuthDialog: false, + geminiMdFileCount: 0, + }), +})); + +vi.mock('./nonInteractiveCli.js', () => ({ + runNonInteractive: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('./utils/cleanup.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + cleanupCheckpoints: vi.fn().mockResolvedValue(undefined), + registerCleanup: vi.fn(), + removeCleanup: vi.fn(), + registerSyncCleanup: vi.fn(), + removeSyncCleanup: vi.fn(), + registerTelemetryConfig: vi.fn(), + runExitCleanup: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('./acp/acpStdioTransport.js', () => ({ + runAcpClient: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('./zed-integration/zedIntegration.js', () => ({ + runZedIntegration: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('./utils/readStdin.js', () => ({ + readStdin: vi.fn().mockResolvedValue(''), +})); + +const { cleanupMockState } = vi.hoisted(() => ({ + cleanupMockState: { shouldThrow: false, called: false }, +})); + +// Mock sessionCleanup.js at the top level +vi.mock('./utils/sessionCleanup.js', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + cleanupExpiredSessions: async () => { + cleanupMockState.called = true; + if (cleanupMockState.shouldThrow) { + throw new Error('Cleanup failed'); + } + }, + }; +}); + +describe('gemini.tsx main function cleanup', () => { + beforeEach(() => { + vi.clearAllMocks(); + process.env['GEMINI_CLI_NO_RELAUNCH'] = 'true'; + vi.stubEnv('GEMINI_CLI_TRUST_WORKSPACE', 'true'); + }); + + afterEach(() => { + delete process.env['GEMINI_CLI_NO_RELAUNCH']; + vi.restoreAllMocks(); + }); + + it.skip('should log error when cleanupExpiredSessions fails', async () => { + const { loadCliConfig, parseArguments } = await import( + './config/config.js' + ); + const { loadSettings } = await import('./config/settings.js'); + cleanupMockState.shouldThrow = true; + cleanupMockState.called = false; + + const debugLoggerErrorSpy = vi + .spyOn(debugLogger, 'error') + .mockImplementation(() => {}); + vi.mocked(loadSettings).mockReturnValue({ + merged: { advanced: {}, security: { auth: {} }, ui: {} }, + workspace: { settings: {} }, + setValue: vi.fn(), + forScope: () => ({ settings: {}, originalSettings: {}, path: '' }), + errors: [], + } as unknown as ReturnType); + + vi.mocked(parseArguments).mockResolvedValue({ + promptInteractive: false, + } as unknown as Awaited>); + vi.mocked(loadCliConfig).mockResolvedValue({ + isInteractive: vi.fn(() => false), + getQuestion: vi.fn(() => 'test'), + getSandbox: vi.fn(() => false), + getDebugMode: vi.fn(() => false), + getPolicyEngine: vi.fn(), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getMessageBus: () => ({ subscribe: vi.fn() }), + getEnableHooks: vi.fn(() => false), + getHookSystem: () => undefined, + initialize: vi.fn(), + storage: { initialize: vi.fn().mockResolvedValue(undefined) }, + getContentGeneratorConfig: vi.fn(), + getMcpServers: () => ({}), + getMcpClientManager: vi.fn(), + getIdeMode: vi.fn(() => false), + getAcpMode: vi.fn(() => true), + getScreenReader: vi.fn(() => false), + getGeminiMdFileCount: vi.fn(() => 0), + getProjectRoot: vi.fn(() => '/'), + getListExtensions: vi.fn(() => false), + getListSessions: vi.fn(() => false), + getDeleteSession: vi.fn(() => undefined), + getToolRegistry: vi.fn(), + getExtensions: vi.fn(() => []), + getModel: vi.fn(() => 'gemini-pro'), + getEmbeddingModel: vi.fn(() => 'embedding-001'), + getApprovalMode: vi.fn(() => 'default'), + getCoreTools: vi.fn(() => []), + getTelemetryEnabled: vi.fn(() => false), + getTelemetryLogPromptsEnabled: vi.fn(() => false), + getFileFilteringRespectGitIgnore: vi.fn(() => true), + getOutputFormat: vi.fn(() => 'text'), + getUsageStatisticsEnabled: vi.fn(() => false), + setTerminalBackground: vi.fn(), + refreshAuth: vi.fn(), + getRemoteAdminSettings: vi.fn(() => undefined), + getUseAlternateBuffer: vi.fn(() => false), + } as unknown as Config); + + await main(); + + expect(cleanupMockState.called).toBe(true); + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + 'Failed to cleanup expired sessions:', + expect.objectContaining({ message: 'Cleanup failed' }), + ); + }); + + it('should register SessionEnd hook exactly once in non-interactive mode', async () => { + const { loadCliConfig, parseArguments } = await import( + './config/config.js' + ); + const { registerCleanup } = await import('./utils/cleanup.js'); + + const mockHookSystem = { + fireSessionEndEvent: vi.fn().mockResolvedValue(undefined), + fireSessionStartEvent: vi.fn().mockResolvedValue(undefined), + } as unknown as HookSystem; + + vi.mocked(parseArguments).mockResolvedValue({ + promptInteractive: false, + } as unknown as Awaited>); + + vi.mocked(loadCliConfig).mockResolvedValue( + buildMockConfig({ + getHookSystem: vi.fn(() => mockHookSystem), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + }), + ); + + vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); + + await main(); + + const registeredCallbacks = vi + .mocked(registerCleanup) + .mock.calls.map(([fn]) => fn); + for (const fn of registeredCallbacks) await fn(); + expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledTimes(1); + expect(mockHookSystem.fireSessionEndEvent).toHaveBeenCalledWith( + SessionEndReason.Exit, + ); + }); + + it('should not register ConsolePatcher cleanup in ACP mode', async () => { + const { registerCleanup } = await import('./utils/cleanup.js'); + const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js'); + const { loadCliConfig, parseArguments } = await import( + './config/config.js' + ); + const { loadSettings } = await import('./config/settings.js'); + + vi.mocked(parseArguments).mockResolvedValue({ + acp: true, + startupMessages: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + vi.mocked(loadSettings).mockReturnValue({ + merged: { + tools: { allowed: [], exclude: [] }, + advanced: { dnsResolutionOrder: 'ipv4first' }, + security: { auth: { selectedType: 'google' } }, + ui: { theme: 'default' }, + }, + workspace: { settings: {} }, + errors: [], + subscribe: vi.fn(), + getSnapshot: vi.fn(), + setValue: vi.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + vi.mocked(loadCliConfig).mockResolvedValue( + buildMockConfig({ + getAcpMode: () => true, + }), + ); + + let capturedCleanup: () => void; + vi.mocked(ConsolePatcher).mockImplementation(() => { + const instance = { + patch: vi.fn(), + cleanup: vi.fn(), + }; + capturedCleanup = instance.cleanup; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return instance as any; + }); + + await main(); + + const registeredFunctions = vi + .mocked(registerCleanup) + .mock.calls.map((call) => call[0]); + expect(registeredFunctions).not.toContain(capturedCleanup!); + }); + + it('should register ConsolePatcher cleanup in non-ACP mode', async () => { + const { registerCleanup } = await import('./utils/cleanup.js'); + const { ConsolePatcher } = await import('./ui/utils/ConsolePatcher.js'); + const { loadCliConfig, parseArguments } = await import( + './config/config.js' + ); + const { loadSettings } = await import('./config/settings.js'); + + vi.mocked(parseArguments).mockResolvedValue({ + acp: false, + query: 'test', + startupMessages: [], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + vi.mocked(loadSettings).mockReturnValue({ + merged: { + tools: { allowed: [], exclude: [] }, + advanced: { dnsResolutionOrder: 'ipv4first' }, + security: { auth: { selectedType: 'google' } }, + ui: { theme: 'default' }, + }, + workspace: { settings: {} }, + errors: [], + subscribe: vi.fn(), + getSnapshot: vi.fn(), + setValue: vi.fn(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + + vi.mocked(loadCliConfig).mockResolvedValue( + buildMockConfig({ + getAcpMode: () => false, + getQuestion: () => 'test', + }), + ); + + let capturedCleanup: () => void; + vi.mocked(ConsolePatcher).mockImplementation(() => { + const instance = { + patch: vi.fn(), + cleanup: vi.fn(), + }; + capturedCleanup = instance.cleanup; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return instance as any; + }); + + try { + await main(); + } catch { + // Ignore errors from incomplete mocks in full main() execution + } + + const registeredFunctions = vi + .mocked(registerCleanup) + .mock.calls.map((call) => call[0]); + expect(registeredFunctions).toContain(capturedCleanup!); + }); + + function buildMockConfig(overrides: Partial = {}): Config { + return { + isInteractive: vi.fn(() => false), + getQuestion: vi.fn(() => 'test'), + getSandbox: vi.fn(() => false), + getDebugMode: vi.fn(() => false), + getPolicyEngine: vi.fn(), + getMessageBus: () => ({ subscribe: vi.fn() }), + getEnableHooks: vi.fn(() => true), + getHookSystem: vi.fn(() => undefined), + getExperimentalGemma: vi.fn(() => false), + initialize: vi.fn(), + storage: { initialize: vi.fn().mockResolvedValue(undefined) }, + getContentGeneratorConfig: vi.fn(), + getMcpClientManager: vi.fn(), + getIdeMode: vi.fn(() => false), + getAcpMode: vi.fn(() => false), + getScreenReader: vi.fn(() => false), + getGeminiMdFileCount: vi.fn(() => 0), + getProjectRoot: vi.fn(() => '/'), + getListExtensions: vi.fn(() => false), + getListSessions: vi.fn(() => false), + getDeleteSession: vi.fn(() => undefined), + getExtensions: vi.fn(() => []), + getModel: vi.fn(() => 'gemini-pro'), + getEmbeddingModel: vi.fn(() => 'embedding-001'), + getApprovalMode: vi.fn(() => 'default'), + getCoreTools: vi.fn(() => []), + getTelemetryEnabled: vi.fn(() => false), + getTelemetryLogPromptsEnabled: vi.fn(() => false), + getFileFilteringRespectGitIgnore: vi.fn(() => true), + getOutputFormat: vi.fn(() => 'text'), + getUsageStatisticsEnabled: vi.fn(() => false), + setTerminalBackground: vi.fn(), + refreshAuth: vi.fn(), + getRemoteAdminSettings: vi.fn(() => undefined), + getUseAlternateBuffer: vi.fn(() => false), + getUseTerminalBuffer: vi.fn(() => false), + ...overrides, + } as unknown as Config; + } +}); diff --git a/packages/cli/src/interactiveCli.tsx b/packages/cli/src/interactiveCli.tsx new file mode 100644 index 0000000000000000000000000000000000000000..266788745eebe4b885da5fa98f14a941430af672 --- /dev/null +++ b/packages/cli/src/interactiveCli.tsx @@ -0,0 +1,275 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from 'ink'; +import { basename } from 'node:path'; +import { AppContainer } from './ui/AppContainer.js'; +import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; +import { + registerCleanup, + removeCleanup, + setupTtyCheck, +} from './utils/cleanup.js'; +import { + type StartupWarning, + type Config, + type ResumedSessionData, + coreEvents, + createWorkingStdio, + disableMouseEvents, + enableMouseEvents, + disableLineWrapping, + enableLineWrapping, + shouldEnterAlternateScreen, + recordSlowRender, + writeToStdout, + getVersion, + debugLogger, +} from '@google/gemini-cli-core'; +import type { InitializationResult } from './core/initializer.js'; +import type { LoadedSettings } from './config/settings.js'; +import { checkForUpdates } from './ui/utils/updateCheck.js'; +import { handleAutoUpdate } from './utils/handleAutoUpdate.js'; +import { SettingsContext } from './ui/contexts/SettingsContext.js'; +import { MouseProvider } from './ui/contexts/MouseContext.js'; +import { StreamingState } from './ui/types.js'; +import { computeTerminalTitle } from './utils/windowTitle.js'; + +import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; +import { VimModeProvider } from './ui/contexts/VimModeContext.js'; +import { KeyMatchersProvider } from './ui/hooks/useKeyMatchers.js'; +import { loadKeyMatchers } from './ui/key/keyMatchers.js'; +import { KeypressProvider } from './ui/contexts/KeypressContext.js'; +import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; +import { ScrollProvider } from './ui/contexts/ScrollProvider.js'; +import { TerminalProvider } from './ui/contexts/TerminalContext.js'; +import { OverflowProvider } from './ui/contexts/OverflowContext.js'; +import { profiler } from './ui/components/DebugProfiler.js'; +import { initializeConsoleStore } from './ui/hooks/useConsoleMessages.js'; + +const SLOW_RENDER_MS = 200; + +export async function startInteractiveUI( + config: Config, + settings: LoadedSettings, + startupWarnings: StartupWarning[], + workspaceRoot: string = process.cwd(), + resumedSessionData: ResumedSessionData | undefined, + initializationResult: InitializationResult, +) { + initializeConsoleStore(); + // Never enter Ink alternate buffer mode when screen reader mode is enabled + // as there is no benefit of alternate buffer mode when using a screen reader + // and the Ink alternate buffer mode requires line wrapping harmful to + // screen readers. + const useAlternateBuffer = shouldEnterAlternateScreen( + config.getUseAlternateBuffer(), + config.getScreenReader(), + ); + const mouseEventsEnabled = useAlternateBuffer; + if (mouseEventsEnabled) { + enableMouseEvents(); + registerCleanup(() => { + disableMouseEvents(); + }); + } + + const { matchers, errors } = await loadKeyMatchers(); + errors.forEach((error) => { + coreEvents.emitFeedback('warning', error); + }); + + const version = await getVersion(); + setWindowTitle(basename(workspaceRoot), settings); + + const consolePatcher = new ConsolePatcher({ + onNewMessage: (msg) => { + coreEvents.emitConsoleLog(msg.type, msg.content); + }, + debugMode: config.getDebugMode(), + }); + consolePatcher.patch(); + + const { stdout: inkStdout, stderr: inkStderr } = createWorkingStdio(); + + const isShpool = !!process.env['SHPOOL_SESSION_NAME']; + + // Create wrapper component to use hooks inside render + const AppWrapper = () => { + useKittyKeyboardProtocol(); + + return ( + + + + + + + + + + + + + + + + + + + + ); + }; + + if (isShpool) { + // Wait a moment for shpool to stabilize terminal size and state. + await new Promise((resolve) => setTimeout(resolve, 100)); + } + const instance = render( + process.env['DEBUG'] ? ( + + + + ) : ( + + ), + { + stdout: inkStdout, + stderr: inkStderr, + stdin: process.stdin, + exitOnCtrlC: false, + isScreenReaderEnabled: config.getScreenReader(), + onRender: ({ renderTime }: { renderTime: number }) => { + if (renderTime > SLOW_RENDER_MS) { + recordSlowRender(config, Math.round(renderTime)); + } + profiler.reportFrameRendered(); + }, + standardReactLayoutTiming: + useAlternateBuffer || config.getUseTerminalBuffer(), + patchConsole: false, + alternateBuffer: useAlternateBuffer, + terminalBuffer: config.getUseTerminalBuffer(), + renderProcess: + config.getUseRenderProcess() && config.getUseTerminalBuffer(), + incrementalRendering: + settings.merged.ui.incrementalRendering !== false && + useAlternateBuffer && + !isShpool, + debugRainbow: settings.merged.ui.debugRainbow === true, + }, + ); + + let cleanupLineWrapping: (() => void) | undefined; + if (useAlternateBuffer) { + disableLineWrapping(); + cleanupLineWrapping = () => enableLineWrapping(); + registerCleanup(cleanupLineWrapping); + } + + checkForUpdates(settings) + .then((info) => { + handleAutoUpdate( + info, + settings, + config.getProjectRoot(), + config.getSandboxEnabled(), + ); + }) + .catch((err) => { + // Silently ignore update check errors. + if (config.getDebugMode()) { + debugLogger.warn('Update check failed:', err); + } + }); + + const cleanupUnmount = () => instance.unmount(); + const cleanupNonResumableCurrentSession = async () => { + try { + await config + .getGeminiClient() + ?.getChatRecordingService() + ?.deleteCurrentSessionIfNotResumableAsync(); + } catch (e: unknown) { + debugLogger.error('Error cleaning up non-resumable session:', e); + } + }; + registerCleanup(cleanupNonResumableCurrentSession); + registerCleanup(cleanupUnmount); + + const cleanupTtyCheck = setupTtyCheck(); + registerCleanup(cleanupTtyCheck); + + const cleanupConsolePatcher = () => consolePatcher.cleanup(); + registerCleanup(cleanupConsolePatcher); + + try { + await instance.waitUntilExit(); + } finally { + try { + removeCleanup(cleanupConsolePatcher); + cleanupConsolePatcher(); + } catch (e: unknown) { + debugLogger.error('Error cleaning up console patcher:', e); + } + + try { + removeCleanup(cleanupNonResumableCurrentSession); + await cleanupNonResumableCurrentSession(); + } catch (e: unknown) { + debugLogger.error('Error removing non-resumable session cleanup:', e); + } + + try { + removeCleanup(cleanupUnmount); + instance.unmount(); + } catch (e: unknown) { + debugLogger.error('Error unmounting Ink instance:', e); + } + + try { + removeCleanup(cleanupTtyCheck); + cleanupTtyCheck(); + } catch (e: unknown) { + debugLogger.error('Error in TTY cleanup:', e); + } + + if (cleanupLineWrapping) { + try { + removeCleanup(cleanupLineWrapping); + cleanupLineWrapping(); + } catch (e: unknown) { + debugLogger.error('Error restoring line wrapping:', e); + } + } + } +} + +function setWindowTitle(title: string, settings: LoadedSettings) { + if (!settings.merged.ui.hideWindowTitle) { + // Initial state before React loop starts + const windowTitle = computeTerminalTitle({ + streamingState: StreamingState.Idle, + isConfirming: false, + isSilentWorking: false, + folderName: title, + showThoughts: !!settings.merged.ui.showStatusInTitle, + useDynamicTitle: settings.merged.ui.dynamicWindowTitle, + }); + writeToStdout(`\x1b]0;${windowTitle}\x07`); + + process.on('exit', () => { + writeToStdout(`\x1b]0;\x07`); + }); + } +} diff --git a/packages/cli/src/nonInteractiveCli.test.ts b/packages/cli/src/nonInteractiveCli.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8a036ec522b7cffa0256760750f666c0ecc5bde5 --- /dev/null +++ b/packages/cli/src/nonInteractiveCli.test.ts @@ -0,0 +1,2621 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + Config, + ToolRegistry, + ServerGeminiStreamEvent, + SessionMetrics, + AnyDeclarativeTool, + AnyToolInvocation, + UserFeedbackPayload, +} from '@google/gemini-cli-core'; +import { + ToolErrorType, + GeminiEventType, + OutputFormat, + uiTelemetryService, + FatalInputError, + CoreEvent, + CoreToolCallStatus, + JsonStreamEventType, + TRUE_EMPTY_RESPONSE_MESSAGE, +} from '@google/gemini-cli-core'; +import type { Part } from '@google/genai'; +import { runNonInteractive } from './nonInteractiveCli.js'; +import { + describe, + it, + expect, + beforeEach, + afterEach, + vi, + type Mock, + type MockInstance, +} from 'vitest'; +import type { LoadedSettings } from './config/settings.js'; + +// Mock core modules +vi.mock('./ui/hooks/atCommandProcessor.js'); + +const mockSetupInitialActivityLogger = vi.hoisted(() => vi.fn()); +vi.mock('./utils/devtoolsService.js', () => ({ + setupInitialActivityLogger: mockSetupInitialActivityLogger, +})); + +const mockCoreEvents = vi.hoisted(() => ({ + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + emitConsoleLog: vi.fn(), + emitFeedback: vi.fn(), + drainBacklogs: vi.fn(), +})); + +const mockSchedulerSchedule = vi.hoisted(() => vi.fn()); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + + class MockChatRecordingService { + initialize = vi.fn(); + recordMessage = vi.fn(); + recordMessageTokens = vi.fn(); + recordToolCalls = vi.fn(); + } + + return { + ...original, + Scheduler: class { + schedule = mockSchedulerSchedule; + cancelAll = vi.fn(); + dispose = vi.fn(); + }, + isTelemetrySdkInitialized: vi.fn().mockReturnValue(true), + ChatRecordingService: MockChatRecordingService, + uiTelemetryService: { + getMetrics: vi.fn(), + recordSemanticValidationError: vi.fn(), + }, + coreEvents: mockCoreEvents, + createWorkingStdio: vi.fn(() => ({ + stdout: process.stdout, + stderr: process.stderr, + })), + }; +}); + +const mockGetCommands = vi.hoisted(() => vi.fn()); +const mockCommandServiceCreate = vi.hoisted(() => vi.fn()); +vi.mock('./services/CommandService.js', () => ({ + CommandService: { + create: mockCommandServiceCreate, + }, +})); + +vi.mock('./services/FileCommandLoader.js'); +vi.mock('./services/McpPromptLoader.js'); +vi.mock('./services/BuiltinCommandLoader.js'); + +describe('runNonInteractive', () => { + let mockConfig: Config; + let mockSettings: LoadedSettings; + let mockToolRegistry: ToolRegistry; + let consoleErrorSpy: MockInstance; + let processStdoutSpy: MockInstance; + let processStderrSpy: MockInstance; + let mockGeminiClient: { + sendMessageStream: Mock; + resumeChat: Mock; + getChatRecordingService: Mock; + getCurrentSequenceModel: Mock; + }; + const MOCK_SESSION_METRICS: SessionMetrics = { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + + beforeEach(async () => { + mockSchedulerSchedule.mockReset(); + + mockCommandServiceCreate.mockResolvedValue({ + getCommands: mockGetCommands, + }); + + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + processStdoutSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + vi.spyOn(process.stdout, 'on').mockImplementation(() => process.stdout); + processStderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code}) called`); + }); + + mockToolRegistry = { + getTool: vi.fn(), + getFunctionDeclarations: vi.fn().mockReturnValue([]), + } as unknown as ToolRegistry; + + mockGeminiClient = { + sendMessageStream: vi.fn(), + resumeChat: vi.fn().mockResolvedValue(undefined), + getChatRecordingService: vi.fn(() => ({ + initialize: vi.fn(), + recordMessage: vi.fn(), + recordMessageTokens: vi.fn(), + recordToolCalls: vi.fn(), + })), + getCurrentSequenceModel: vi.fn().mockReturnValue('gemini-2.5-flash'), + }; + + mockConfig = { + initialize: vi.fn().mockReturnValue(Promise.resolve(undefined)), + getMessageBus: vi.fn().mockReturnValue({ + subscribe: vi.fn(), + unsubscribe: vi.fn(), + publish: vi.fn(), + }), + getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), + getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), + getMaxSessionTurns: vi.fn().mockReturnValue(10), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/test/project/.gemini/tmp'), + }, + getIdeMode: vi.fn().mockReturnValue(false), + + getContentGeneratorConfig: vi.fn().mockReturnValue({}), + getDebugMode: vi.fn().mockReturnValue(false), + getOutputFormat: vi.fn().mockReturnValue('text'), + getModel: vi.fn().mockReturnValue('test-model'), + getFolderTrust: vi.fn().mockReturnValue(false), + isTrustedFolder: vi.fn().mockReturnValue(false), + getRawOutput: vi.fn().mockReturnValue(false), + getAcceptRawOutputRisk: vi.fn().mockReturnValue(false), + getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false), + getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), + } as unknown as Config; + + mockSettings = { + system: { path: '', settings: {} }, + systemDefaults: { path: '', settings: {} }, + user: { path: '', settings: {} }, + workspace: { path: '', settings: {} }, + errors: [], + setValue: vi.fn(), + merged: { + security: { + auth: { + enforcedType: undefined, + }, + }, + }, + isTrusted: true, + migratedInMemoryScopes: new Set(), + forScope: vi.fn(), + computeMergedSettings: vi.fn(), + } as unknown as LoadedSettings; + + const { handleAtCommand } = await import( + './ui/hooks/atCommandProcessor.js' + ); + vi.mocked(handleAtCommand).mockImplementation(async ({ query }) => ({ + processedQuery: [{ text: query }], + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function* createStreamFromEvents( + events: ServerGeminiStreamEvent[], + ): AsyncGenerator { + for (const event of events) { + yield event; + } + } + + const getWrittenOutput = () => + processStdoutSpy.mock.calls.map((c) => c[0]).join(''); + + it('should process input and write text output', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Hello' }, + { type: GeminiEventType.Content, value: ' World' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-1', + }); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Test input' }], + expect.any(AbortSignal), + 'prompt-id-1', + undefined, + 'Test input', + ); + expect(getWrittenOutput()).toBe('Hello World\n'); + // Note: Telemetry shutdown is now handled in runExitCleanup() in cleanup.ts + // so we no longer expect shutdownTelemetry to be called directly here + }); + + it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is set', async () => { + vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '/tmp/test.jsonl'); + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-activity-logger', + }); + + expect(mockSetupInitialActivityLogger).toHaveBeenCalledWith(mockConfig); + vi.unstubAllEnvs(); + }); + + it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is not set', async () => { + vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', ''); + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-activity-logger-off', + }); + + expect(mockSetupInitialActivityLogger).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); + }); + + it('should handle a single tool call and respond', async () => { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-2', + }, + }; + const toolResponse: Part[] = [{ text: 'Tool response' }]; + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Success, + request: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-2', + }, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: toolResponse, + callId: 'tool-1', + error: undefined, + errorType: undefined, + contentLength: undefined, + }, + }, + ]); + + const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; + const secondCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) + .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Use a tool', + prompt_id: 'prompt-id-2', + }); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockSchedulerSchedule).toHaveBeenCalledWith( + [expect.objectContaining({ name: 'testTool' })], + expect.any(AbortSignal), + ); + expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + 2, + [{ text: 'Tool response' }], + expect.any(AbortSignal), + 'prompt-id-2', + undefined, + undefined, + ); + expect(getWrittenOutput()).toBe('Final answer\n'); + }); + + it('should write a single newline between sequential text outputs from the model', async () => { + // This test simulates a multi-turn conversation to ensure that a single newline + // is printed between each block of text output from the model. + + // 1. Define the tool requests that the model will ask the CLI to run. + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'mock-tool', + name: 'mockTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-multi', + }, + }; + + // 2. Mock the execution of the tools. We just need them to succeed. + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Success, + request: toolCallEvent.value, // This is generic enough for both calls + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: [], + callId: 'mock-tool', + }, + }, + ]); + + // 3. Define the sequence of events streamed from the mock model. + // Turn 1: Model outputs text, then requests a tool call. + const modelTurn1: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Use mock tool' }, + toolCallEvent, + ]; + // Turn 2: Model outputs more text, then requests another tool call. + const modelTurn2: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Use mock tool again' }, + toolCallEvent, + ]; + // Turn 3: Model outputs a final answer. + const modelTurn3: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Finished.' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(modelTurn1)) + .mockReturnValueOnce(createStreamFromEvents(modelTurn2)) + .mockReturnValueOnce(createStreamFromEvents(modelTurn3)); + + // 4. Run the command. + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Use mock tool multiple times', + prompt_id: 'prompt-id-multi', + }); + + // 5. Verify the output. + // The rendered output should contain the text from each turn, separated by a + // single newline, with a final newline at the end. + expect(getWrittenOutput()).toMatchSnapshot(); + + // Also verify the tools were called as expected. + expect(mockSchedulerSchedule).toHaveBeenCalledTimes(2); + }); + + it('should handle error during tool execution and should send error back to the model', async () => { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'errorTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-3', + }, + }; + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Error, + request: { + callId: 'tool-1', + name: 'errorTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-3', + }, + tool: {} as AnyDeclarativeTool, + response: { + callId: 'tool-1', + error: new Error('Execution failed'), + errorType: ToolErrorType.EXECUTION_FAILED, + responseParts: [ + { + functionResponse: { + name: 'errorTool', + response: { + output: 'Error: Execution failed', + }, + }, + }, + ], + resultDisplay: 'Execution failed', + contentLength: undefined, + }, + }, + ]); + const finalResponse: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Content, + value: 'Sorry, let me try again.', + }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) + .mockReturnValueOnce(createStreamFromEvents(finalResponse)); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Trigger tool error', + prompt_id: 'prompt-id-3', + }); + + expect(mockSchedulerSchedule).toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error executing tool errorTool: Execution failed', + ); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + 2, + [ + { + functionResponse: { + name: 'errorTool', + response: { + output: 'Error: Execution failed', + }, + }, + }, + ], + expect.any(AbortSignal), + 'prompt-id-3', + undefined, + undefined, + ); + expect(getWrittenOutput()).toBe('Sorry, let me try again.\n'); + }); + + it('should exit with error if sendMessageStream throws initially', async () => { + const apiError = new Error('API connection failed'); + mockGeminiClient.sendMessageStream.mockImplementation(() => { + throw apiError; + }); + + await expect( + runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Initial fail', + prompt_id: 'prompt-id-4', + }), + ).rejects.toThrow(apiError); + }); + + it('should not exit if a tool is not found, and should send error back to model', async () => { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'nonexistentTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-5', + }, + }; + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Error, + request: { + callId: 'tool-1', + name: 'nonexistentTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-5', + }, + response: { + callId: 'tool-1', + error: new Error('Tool "nonexistentTool" not found in registry.'), + resultDisplay: 'Tool "nonexistentTool" not found in registry.', + responseParts: [], + errorType: undefined, + contentLength: undefined, + }, + }, + ]); + const finalResponse: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Content, + value: "Sorry, I can't find that tool.", + }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) + .mockReturnValueOnce(createStreamFromEvents(finalResponse)); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Trigger tool not found', + prompt_id: 'prompt-id-5', + }); + + expect(mockSchedulerSchedule).toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.', + ); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(getWrittenOutput()).toBe("Sorry, I can't find that tool.\n"); + }); + + it('should exit when max session turns are exceeded', async () => { + vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0); + await expect( + runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Trigger loop', + prompt_id: 'prompt-id-6', + }), + ).rejects.toThrow('process.exit(53) called'); + }); + + it('should preprocess @include commands before sending to the model', async () => { + // 1. Mock the imported atCommandProcessor + const { handleAtCommand } = await import( + './ui/hooks/atCommandProcessor.js' + ); + const mockHandleAtCommand = vi.mocked(handleAtCommand); + + // 2. Define the raw input and the expected processed output + const rawInput = 'Summarize @file.txt'; + const processedParts: Part[] = [ + { text: 'Summarize @file.txt' }, + { text: '\n--- Content from referenced files ---\n' }, + { text: 'This is the content of the file.' }, + { text: '\n--- End of content ---' }, + ]; + + // 3. Setup the mock to return the processed parts + mockHandleAtCommand.mockResolvedValue({ + processedQuery: processedParts, + }); + + // Mock a simple stream response from the Gemini client + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Summary complete.' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + // 4. Run the non-interactive mode with the raw input + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: rawInput, + prompt_id: 'prompt-id-7', + }); + + // 5. Assert that sendMessageStream was called with the PROCESSED parts, not the raw input + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + processedParts, + expect.any(AbortSignal), + 'prompt-id-7', + undefined, + rawInput, + ); + + // 6. Assert the final output is correct + expect(getWrittenOutput()).toBe('Summary complete.\n'); + }); + + it('should process input and write JSON output with stats', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Hello World' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-1', + }); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Test input' }], + expect.any(AbortSignal), + 'prompt-id-1', + undefined, + 'Test input', + ); + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify( + { + session_id: 'test-session-id', + response: 'Hello World', + stats: MOCK_SESSION_METRICS, + }, + null, + 2, + ), + ); + }); + + it('should write JSON output with stats for tool-only commands (no text response)', async () => { + // Test the scenario where a command completes successfully with only tool calls + // but no text response - this would have caught the original bug + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-tool-only', + }, + }; + const toolResponse: Part[] = [{ text: 'Tool executed successfully' }]; + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Success, + request: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-tool-only', + }, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: toolResponse, + callId: 'tool-1', + error: undefined, + errorType: undefined, + contentLength: undefined, + }, + }, + ]); + + // First call returns only tool call, no content + const firstCallEvents: ServerGeminiStreamEvent[] = [ + toolCallEvent, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + + // Second call returns no content (tool-only completion) + const secondCallEvents: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) + .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); + + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Execute tool only', + prompt_id: 'prompt-id-tool-only', + }); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockSchedulerSchedule).toHaveBeenCalledWith( + [expect.objectContaining({ name: 'testTool' })], + expect.any(AbortSignal), + ); + + // This should output JSON with empty response but include stats + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify( + { + session_id: 'test-session-id', + response: '', + stats: MOCK_SESSION_METRICS, + }, + null, + 2, + ), + ); + }); + + it('should write JSON output with stats for empty response commands', async () => { + // Test the scenario where a command completes but produces no content at all + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Empty response test', + prompt_id: 'prompt-id-empty', + }); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Empty response test' }], + expect.any(AbortSignal), + 'prompt-id-empty', + undefined, + 'Empty response test', + ); + + // This should output JSON with empty response but include stats + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify( + { + session_id: 'test-session-id', + response: '', + stats: MOCK_SESSION_METRICS, + }, + null, + 2, + ), + ); + }); + + it('should handle errors in JSON format', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + const testError = new Error('Invalid input provided'); + + mockGeminiClient.sendMessageStream.mockImplementation(() => { + throw testError; + }); + + let thrownError: Error | null = null; + try { + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-error', + }); + // Should not reach here + expect.fail('Expected process.exit to be called'); + } catch (error) { + thrownError = error as Error; + } + + // Should throw because of mocked process.exit + expect(thrownError?.message).toBe('process.exit(1) called'); + + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + JSON.stringify( + { + session_id: 'test-session-id', + error: { + type: 'Error', + message: 'Invalid input provided', + code: 1, + }, + }, + null, + 2, + ), + ); + }); + + it('should handle FatalInputError with custom exit code in JSON format', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + const fatalError = new FatalInputError('Invalid command syntax provided'); + + mockGeminiClient.sendMessageStream.mockImplementation(() => { + throw fatalError; + }); + + let thrownError: Error | null = null; + try { + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Invalid syntax', + prompt_id: 'prompt-id-fatal', + }); + // Should not reach here + expect.fail('Expected process.exit to be called'); + } catch (error) { + thrownError = error as Error; + } + + // Should throw because of mocked process.exit with custom exit code + expect(thrownError?.message).toBe('process.exit(42) called'); + + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + JSON.stringify( + { + session_id: 'test-session-id', + error: { + type: 'FatalInputError', + message: 'Invalid command syntax provided', + code: 42, + }, + }, + null, + 2, + ), + ); + }); + + it('should execute a slash command that returns a prompt', async () => { + const mockCommand = { + name: 'testcommand', + description: 'a test command', + action: vi.fn().mockResolvedValue({ + type: 'submit_prompt', + content: [{ text: 'Prompt from command' }], + }), + }; + mockGetCommands.mockReturnValue([mockCommand]); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Response from command' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/testcommand', + prompt_id: 'prompt-id-slash', + }); + + // Ensure the prompt sent to the model is from the command, not the raw input + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Prompt from command' }], + expect.any(AbortSignal), + 'prompt-id-slash', + undefined, + '/testcommand', + ); + + expect(getWrittenOutput()).toBe('Response from command\n'); + }); + + it('should handle slash commands', async () => { + const nonInteractiveCliCommands = await import( + './nonInteractiveCliCommands.js' + ); + const handleSlashCommandSpy = vi.spyOn( + nonInteractiveCliCommands, + 'handleSlashCommand', + ); + handleSlashCommandSpy.mockResolvedValue([{ text: 'Slash command output' }]); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Response to slash command' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/help', + prompt_id: 'prompt-id-slash', + }); + + expect(handleSlashCommandSpy).toHaveBeenCalledWith( + '/help', + expect.any(AbortController), + mockConfig, + mockSettings, + ); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Slash command output' }], + expect.any(AbortSignal), + 'prompt-id-slash', + undefined, + '/help', + ); + expect(getWrittenOutput()).toBe('Response to slash command\n'); + handleSlashCommandSpy.mockRestore(); + }); + + it('should handle cancellation (Ctrl+C)', async () => { + // Mock isTTY and setRawMode safely + const originalIsTTY = process.stdin.isTTY; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const originalSetRawMode = (process.stdin as any).setRawMode; + + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + if (!originalSetRawMode) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).setRawMode = vi.fn(); + } + + const stdinOnSpy = vi + .spyOn(process.stdin, 'on') + .mockImplementation(() => process.stdin); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(process.stdin as any, 'setRawMode').mockImplementation(() => true); + vi.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin); + vi.spyOn(process.stdin, 'pause').mockImplementation(() => process.stdin); + vi.spyOn(process.stdin, 'removeAllListeners').mockImplementation( + () => process.stdin, + ); + + // Spy on handleCancellationError to verify it's called + const errors = await import('./utils/errors.js'); + const handleCancellationErrorSpy = vi + .spyOn(errors, 'handleCancellationError') + .mockImplementation(() => { + throw new Error('Cancelled'); + }); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Thinking...' }, + ]; + // Create a stream that responds to abortion + mockGeminiClient.sendMessageStream.mockImplementation( + (_messages, signal: AbortSignal) => + (async function* () { + yield events[0]; + await new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, 1000); + signal.addEventListener('abort', () => { + clearTimeout(timeout); + setTimeout(() => { + reject(new Error('Aborted')); // This will be caught by nonInteractiveCli and passed to handleError + }, 300); + }); + }); + })(), + ); + + const runPromise = runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Long running query', + prompt_id: 'prompt-id-cancel', + }); + + // Wait a bit for setup to complete and listeners to be registered + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Find the keypress handler registered by runNonInteractive + const keypressCall = stdinOnSpy.mock.calls.find( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (call) => (call[0] as any) === 'keypress', + ); + expect(keypressCall).toBeDefined(); + const keypressHandler = keypressCall?.[1] as ( + str: string, + key: { name?: string; ctrl?: boolean }, + ) => void; + + if (keypressHandler) { + // Simulate Ctrl+C + keypressHandler('\u0003', { ctrl: true, name: 'c' }); + } + + // The promise should reject with 'Aborted' because our mock stream throws it, + // and nonInteractiveCli catches it and calls handleError, which doesn't necessarily throw. + // Wait, if handleError is called, we should check that. + // But here we want to check if Ctrl+C works. + + // In our current setup, Ctrl+C aborts the signal. The stream throws 'Aborted'. + // nonInteractiveCli catches 'Aborted' and calls handleError. + + // If we want to test that handleCancellationError is called, we need the loop to detect abortion. + // But our stream throws before the loop can detect it. + + // Let's just check that the promise rejects with 'Aborted' for now, + // which proves the abortion signal reached the stream. + await expect(runPromise).rejects.toThrow('Aborted'); + + expect( + processStderrSpy.mock.calls.some( + // eslint-disable-next-line no-restricted-syntax + (call) => typeof call[0] === 'string' && call[0].includes('Cancelling'), + ), + ).toBe(true); + + handleCancellationErrorSpy.mockRestore(); + + // Restore original values + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + if (originalSetRawMode) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).setRawMode = originalSetRawMode; + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (process.stdin as any).setRawMode; + } + // Spies are automatically restored by vi.restoreAllMocks() in afterEach, + // but we can also do it manually if needed. + }); + + it('should throw FatalInputError if a command requires confirmation', async () => { + const mockCommand = { + name: 'confirm', + description: 'a command that needs confirmation', + action: vi.fn().mockResolvedValue({ + type: 'confirm_shell_commands', + commands: ['rm -rf /'], + }), + }; + mockGetCommands.mockReturnValue([mockCommand]); + + await expect( + runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/confirm', + prompt_id: 'prompt-id-confirm', + }), + ).rejects.toThrow( + 'Exiting due to a confirmation prompt requested by the command.', + ); + }); + + it('should treat an unknown slash command as a regular prompt', async () => { + // No commands are mocked, so any slash command is "unknown" + mockGetCommands.mockReturnValue([]); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Response to unknown' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/unknowncommand', + prompt_id: 'prompt-id-unknown', + }); + + // Ensure the raw input is sent to the model + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: '/unknowncommand' }], + expect.any(AbortSignal), + 'prompt-id-unknown', + undefined, + '/unknowncommand', + ); + + expect(getWrittenOutput()).toBe('Response to unknown\n'); + }); + + it('should throw for unhandled command result types', async () => { + const mockCommand = { + name: 'noaction', + description: 'unhandled type', + action: vi.fn().mockResolvedValue({ + type: 'unhandled', + }), + }; + mockGetCommands.mockReturnValue([mockCommand]); + + await expect( + runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/noaction', + prompt_id: 'prompt-id-unhandled', + }), + ).rejects.toThrow( + 'Exiting due to command result that is not supported in non-interactive mode.', + ); + }); + + it('should pass arguments to the slash command action', async () => { + const mockAction = vi.fn().mockResolvedValue({ + type: 'submit_prompt', + content: [{ text: 'Prompt from command' }], + }); + const mockCommand = { + name: 'testargs', + description: 'a test command', + action: mockAction, + }; + mockGetCommands.mockReturnValue([mockCommand]); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Acknowledged' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/testargs arg1 arg2', + prompt_id: 'prompt-id-args', + }); + + expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2'); + + expect(getWrittenOutput()).toBe('Acknowledged\n'); + }); + + it('should instantiate CommandService with correct loaders for slash commands', async () => { + // This test indirectly checks that handleSlashCommand is using the right loaders. + const { FileCommandLoader } = await import( + './services/FileCommandLoader.js' + ); + const { McpPromptLoader } = await import('./services/McpPromptLoader.js'); + const { BuiltinCommandLoader } = await import( + './services/BuiltinCommandLoader.js' + ); + mockGetCommands.mockReturnValue([]); // No commands found, so it will fall through + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Acknowledged' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/mycommand', + prompt_id: 'prompt-id-loaders', + }); + + // Check that loaders were instantiated with the config + expect(FileCommandLoader).toHaveBeenCalledTimes(1); + expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig); + expect(McpPromptLoader).toHaveBeenCalledTimes(1); + expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig); + expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig); + + // Check that instances were passed to CommandService.create + expect(mockCommandServiceCreate).toHaveBeenCalledTimes(1); + const loadersArg = mockCommandServiceCreate.mock.calls[0][0]; + expect(loadersArg).toHaveLength(3); + expect(loadersArg[0]).toBe( + vi.mocked(BuiltinCommandLoader).mock.instances[0], + ); + expect(loadersArg[1]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]); + expect(loadersArg[2]).toBe(vi.mocked(FileCommandLoader).mock.instances[0]); + }); + + it('should allow a normally-excluded tool when --allowed-tools is set', async () => { + // By default, ShellTool is excluded in non-interactive mode. + // This test ensures that --allowed-tools overrides this exclusion. + vi.mocked(mockConfig.getToolRegistry).mockReturnValue({ + getTool: vi.fn().mockReturnValue({ + name: 'ShellTool', + description: 'A shell tool', + run: vi.fn(), + }), + getFunctionDeclarations: vi.fn().mockReturnValue([{ name: 'ShellTool' }]), + } as unknown as ToolRegistry); + + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-shell-1', + name: 'ShellTool', + args: { command: 'ls' }, + isClientInitiated: false, + prompt_id: 'prompt-id-allowed', + }, + }; + const toolResponse: Part[] = [{ text: 'file.txt' }]; + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Success, + request: { + callId: 'tool-shell-1', + name: 'ShellTool', + args: { command: 'ls' }, + isClientInitiated: false, + prompt_id: 'prompt-id-allowed', + }, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: toolResponse, + callId: 'tool-shell-1', + error: undefined, + errorType: undefined, + contentLength: undefined, + }, + }, + ]); + + const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; + const secondCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'file.txt' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) + .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'List the files', + prompt_id: 'prompt-id-allowed', + }); + + expect(mockSchedulerSchedule).toHaveBeenCalledWith( + [expect.objectContaining({ name: 'ShellTool' })], + expect.any(AbortSignal), + ); + expect(getWrittenOutput()).toBe('file.txt\n'); + }); + + describe('CoreEvents Integration', () => { + it('subscribes to UserFeedback and drains backlog on start', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-events', + }); + + expect(mockCoreEvents.on).toHaveBeenCalledWith( + CoreEvent.UserFeedback, + expect.any(Function), + ); + expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1); + }); + + it('unsubscribes from UserFeedback on finish', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-events', + }); + + expect(mockCoreEvents.off).toHaveBeenCalledWith( + CoreEvent.UserFeedback, + expect.any(Function), + ); + }); + + it('logs to process.stderr when UserFeedback event is received', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-events', + }); + + // Get the registered handler + const handler = mockCoreEvents.on.mock.calls.find( + (call: unknown[]) => call[0] === CoreEvent.UserFeedback, + )?.[1]; + expect(handler).toBeDefined(); + + // Simulate an event + const payload: UserFeedbackPayload = { + severity: 'error', + message: 'Test error message', + }; + handler(payload); + + expect(processStderrSpy).toHaveBeenCalledWith( + '[ERROR] Test error message\n', + ); + }); + + it('logs optional error object to process.stderr in debug mode', async () => { + vi.mocked(mockConfig.getDebugMode).mockReturnValue(true); + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-events', + }); + + // Get the registered handler + const handler = mockCoreEvents.on.mock.calls.find( + (call: unknown[]) => call[0] === CoreEvent.UserFeedback, + )?.[1]; + expect(handler).toBeDefined(); + + // Simulate an event with error object + const errorObj = new Error('Original error'); + // Mock stack for deterministic testing + errorObj.stack = 'Error: Original error\n at test'; + const payload: UserFeedbackPayload = { + severity: 'warning', + message: 'Test warning message', + error: errorObj, + }; + handler(payload); + + expect(processStderrSpy).toHaveBeenCalledWith( + '[WARNING] Test warning message\n', + ); + expect(processStderrSpy).toHaveBeenCalledWith( + 'Error: Original error\n at test\n', + ); + }); + }); + + it('should emit appropriate events for streaming JSON output', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-stream', + }, + }; + + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Success, + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: [{ text: 'Tool response' }], + callId: 'tool-1', + error: undefined, + errorType: undefined, + contentLength: undefined, + resultDisplay: 'Tool executed successfully', + }, + }, + ]); + + const firstCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Thinking...' }, + toolCallEvent, + ]; + const secondCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) + .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Stream test', + prompt_id: 'prompt-id-stream', + }); + + const output = getWrittenOutput(); + const sanitizedOutput = output + .replace(/"timestamp":"[^"]+"/g, '"timestamp":""') + .replace(/"duration_ms":\d+/g, '"duration_ms":'); + expect(sanitizedOutput).toMatchSnapshot(); + }); + + it('should handle EPIPE error gracefully', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Hello' }, + { type: GeminiEventType.Content, value: ' World' }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + // Mock process.exit to track calls without throwing + vi.spyOn(process, 'exit').mockImplementation((_code) => undefined as never); + + // Simulate EPIPE error on stdout + const stdoutErrorCallback = (process.stdout.on as Mock).mock.calls.find( + (call) => call[0] === 'error', + )?.[1]; + + if (stdoutErrorCallback) { + stdoutErrorCallback({ code: 'EPIPE' }); + } + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'EPIPE test', + prompt_id: 'prompt-id-epipe', + }); + + // Since EPIPE is simulated, it might exit early or continue depending on timing, + // but our main goal is to verify the handler is registered and handles EPIPE. + expect(process.stdout.on).toHaveBeenCalledWith( + 'error', + expect.any(Function), + ); + }); + + it('should resume chat when resumedSessionData is provided', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Resumed' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + const resumedSessionData = { + conversation: { + sessionId: 'resumed-session-id', + messages: [ + { role: 'user', parts: [{ text: 'Previous message' }] }, + ] as any, // eslint-disable-line @typescript-eslint/no-explicit-any + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + firstUserMessage: 'Previous message', + projectHash: 'test-hash', + }, + filePath: '/path/to/session.json', + }; + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Continue', + prompt_id: 'prompt-id-resume', + resumedSessionData, + }); + + expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith( + expect.any(Array), + resumedSessionData, + ); + expect(getWrittenOutput()).toBe('Resumed\n'); + }); + + it.each([ + { + name: 'loop detected', + events: [ + { type: GeminiEventType.LoopDetected }, + ] as ServerGeminiStreamEvent[], + input: 'Loop test', + promptId: 'prompt-id-loop', + }, + { + name: 'max session turns', + events: [ + { type: GeminiEventType.MaxSessionTurns }, + ] as ServerGeminiStreamEvent[], + input: 'Max turns test', + promptId: 'prompt-id-max-turns', + }, + ])( + 'should emit appropriate error event in streaming JSON mode: $name', + async ({ events, input, promptId }) => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const streamEvents: ServerGeminiStreamEvent[] = [ + ...events, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(streamEvents), + ); + + try { + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input, + prompt_id: promptId, + }); + } catch { + // Expected exit + } + + const output = getWrittenOutput(); + const sanitizedOutput = output + .replace(/"timestamp":"[^"]+"/g, '"timestamp":""') + .replace(/"duration_ms":\d+/g, '"duration_ms":'); + expect(sanitizedOutput).toMatchSnapshot(); + }, + ); + + it.each([ + { + name: 'loop detected', + events: [ + { type: GeminiEventType.LoopDetected }, + ] as ServerGeminiStreamEvent[], + expectedWarning: 'Loop detected, stopping execution', + }, + { + name: 'max session turns', + events: [ + { type: GeminiEventType.MaxSessionTurns }, + ] as ServerGeminiStreamEvent[], + expectedWarning: 'Maximum session turns exceeded', + }, + ])( + 'should include warning in JSON mode for: $name', + async ({ events, expectedWarning }) => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const streamEvents: ServerGeminiStreamEvent[] = [ + ...events, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(streamEvents), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'test', + }); + + const output = JSON.parse(getWrittenOutput()); + expect(output.warnings).toBeDefined(); + expect(output.warnings).toContain(expectedWarning); + }, + ); + + it('should log error when tool recording fails', async () => { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'testTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-tool-error', + }, + }; + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Success, + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: [], + callId: 'tool-1', + error: undefined, + errorType: undefined, + contentLength: undefined, + }, + }, + ]); + + const events: ServerGeminiStreamEvent[] = [ + toolCallEvent, + { type: GeminiEventType.Content, value: 'Done' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(events)) + .mockReturnValueOnce( + createStreamFromEvents([ + { type: GeminiEventType.Content, value: 'Done' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]), + ); + + // Mock getChat to throw when recording tool calls + const mockChat = { + recordCompletedToolCalls: vi.fn().mockImplementation(() => { + throw new Error('Recording failed'); + }), + }; + // @ts-expect-error - Mocking internal structure + mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat); + mockGeminiClient.getCurrentSequenceModel = vi + .fn() + .mockReturnValue('model-1'); + + // Mock debugLogger.error + const { debugLogger } = await import('@google/gemini-cli-core'); + const debugLoggerErrorSpy = vi + .spyOn(debugLogger, 'error') + .mockImplementation(() => {}); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Tool recording error test', + prompt_id: 'prompt-id-tool-error', + }); + + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Error recording completed tool call information: Error: Recording failed', + ), + ); + expect(getWrittenOutput()).toContain('Done'); + }); + + it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'stop-call', + name: 'stopTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-stop', + }, + }; + + // Mock tool execution returning STOP_EXECUTION + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Error, + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + callId: 'stop-call', + responseParts: [{ text: 'error occurred' }], + errorType: ToolErrorType.STOP_EXECUTION, + error: new Error('Stop reason from hook'), + resultDisplay: undefined, + }, + }, + ]); + + const firstCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Executing tool...' }, + toolCallEvent, + ]; + + // Setup the mock to return events for the first call. + // We expect the loop to terminate after the tool execution. + // If it doesn't, it might call sendMessageStream again, which we'll assert against. + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) + .mockReturnValueOnce(createStreamFromEvents([])); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Run stop tool', + prompt_id: 'prompt-id-stop', + }); + + expect(mockSchedulerSchedule).toHaveBeenCalled(); + + // The key assertion: sendMessageStream should have been called ONLY ONCE (initial user input). + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + + expect(processStderrSpy).toHaveBeenCalledWith( + 'Agent execution stopped: Stop reason from hook\n', + ); + }); + + it('should write JSON output when a tool call returns STOP_EXECUTION error', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'stop-call', + name: 'stopTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-stop-json', + }, + }; + + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Error, + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + callId: 'stop-call', + responseParts: [{ text: 'error occurred' }], + errorType: ToolErrorType.STOP_EXECUTION, + error: new Error('Stop reason'), + resultDisplay: undefined, + }, + }, + ]); + + const firstCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Partial content' }, + toolCallEvent, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(firstCallEvents), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Run stop tool', + prompt_id: 'prompt-id-stop-json', + }); + + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify( + { + session_id: 'test-session-id', + response: 'Partial content', + stats: MOCK_SESSION_METRICS, + }, + null, + 2, + ), + ); + }); + + it('should emit result event when a tool call returns STOP_EXECUTION error in streaming JSON mode', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'stop-call', + name: 'stopTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-stop-stream', + }, + }; + + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Error, + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + callId: 'stop-call', + responseParts: [{ text: 'error occurred' }], + errorType: ToolErrorType.STOP_EXECUTION, + error: new Error('Stop reason'), + resultDisplay: undefined, + }, + }, + ]); + + const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(firstCallEvents), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Run stop tool', + prompt_id: 'prompt-id-stop-stream', + }); + + const output = getWrittenOutput(); + expect(output).toContain('"type":"result"'); + expect(output).toContain('"status":"success"'); + }); + + describe('Agent Execution Events', () => { + it('should handle AgentExecutionStopped event', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.AgentExecutionStopped, + value: { reason: 'Stopped by hook' }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test stop', + prompt_id: 'prompt-id-stop', + }); + + expect(processStderrSpy).toHaveBeenCalledWith( + 'Agent execution stopped: Stopped by hook\n', + ); + // Should exit without calling sendMessageStream again + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + }); + + it('should write JSON output when AgentExecutionStopped event occurs', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Partial content' }, + { + type: GeminiEventType.AgentExecutionStopped, + value: { reason: 'Stopped by hook' }, + }, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test stop', + prompt_id: 'prompt-id-stop-json', + }); + + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify( + { + session_id: 'test-session-id', + response: 'Partial content', + stats: MOCK_SESSION_METRICS, + warnings: ['Agent execution stopped: Stopped by hook'], + }, + null, + 2, + ), + ); + }); + + it('should emit result event when AgentExecutionStopped event occurs in streaming JSON mode', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Partial content' }, + { + type: GeminiEventType.AgentExecutionStopped, + value: { reason: 'Stopped by hook' }, + }, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test stop', + prompt_id: 'prompt-id-stop-stream', + }); + + const output = getWrittenOutput(); + expect(output).toContain('"type":"result"'); + expect(output).toContain('"status":"success"'); + }); + + it('should handle AgentExecutionBlocked event', async () => { + const allEvents: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.AgentExecutionBlocked, + value: { reason: 'Blocked by hook' }, + }, + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(allEvents), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test block', + prompt_id: 'prompt-id-block', + }); + + expect(processStderrSpy).toHaveBeenCalledWith( + '[WARNING] Agent execution blocked: Blocked by hook\n', + ); + // sendMessageStream is called once, recursion is internal to it and transparent to the caller + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + expect(getWrittenOutput()).toBe('Final answer\n'); + }); + + it('should emit ERROR event in STREAM_JSON mode when AgentExecutionBlocked occurs', async () => { + const allEvents: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.AgentExecutionBlocked, + value: { reason: 'Blocked by hook' }, + }, + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(allEvents), + ); + + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + // Setup stream-json format + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test block', + prompt_id: 'prompt-id-block', + }); + + const calls = processStdoutSpy.mock.calls.map((call) => + JSON.parse(call[0] as string), + ); + const errorEvent = calls.find( + (c) => c.type === JsonStreamEventType.ERROR, + ); + + expect(errorEvent).toBeDefined(); + expect(errorEvent.message).toContain( + 'Agent execution blocked: Blocked by hook', + ); + expect(errorEvent.severity).toBe('warning'); + }); + + it('should include warning in JSON mode when AgentExecutionBlocked occurs', async () => { + const allEvents: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.AgentExecutionBlocked, + value: { reason: 'Blocked by hook' }, + }, + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(allEvents), + ); + + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test block', + prompt_id: 'prompt-id-block', + }); + + const output = JSON.parse(getWrittenOutput()); + expect(output.warnings).toBeDefined(); + expect(output.warnings).toContain( + 'Agent execution blocked: Blocked by hook', + ); + }); + + it('should handle multiple AgentExecutionBlocked events and collect all warnings', async () => { + const allEvents: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.AgentExecutionBlocked, + value: { reason: 'Block 1', systemMessage: 'Reason 1' }, + }, + { + type: GeminiEventType.AgentExecutionBlocked, + value: { reason: 'Block 2', systemMessage: 'Reason 2' }, + }, + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream.mockImplementation(() => + createStreamFromEvents(allEvents), + ); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'test', + }); + + const output = JSON.parse(getWrittenOutput()); + expect(output.warnings).toHaveLength(2); + expect(output.warnings).toContain('Agent execution blocked: Reason 1'); + expect(output.warnings).toContain('Agent execution blocked: Reason 2'); + }); + + it('should not include warnings field in JSON output if no blocks occur', async () => { + const allEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Clean answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream.mockImplementation(() => + createStreamFromEvents(allEvents), + ); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'test', + }); + + const output = JSON.parse(getWrittenOutput()); + expect(output.warnings).toBeUndefined(); + }); + + it('should handle InvalidStream event gracefully in TEXT mode', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.InvalidStream, + value: { + type: 'NO_RESPONSE_TEXT', + message: 'Empty response', + }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test invalid stream', + prompt_id: 'prompt-id-invalid', + }); + + expect(processStderrSpy).toHaveBeenCalledWith( + `[ERROR] ${TRUE_EMPTY_RESPONSE_MESSAGE}\n`, + ); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + }); + + it('should handle InvalidStream event gracefully in STREAM_JSON mode', async () => { + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue( + OutputFormat.STREAM_JSON, + ); + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.InvalidStream, + value: { + type: 'NO_RESPONSE_TEXT', + message: 'Empty response', + }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test invalid stream', + prompt_id: 'prompt-id-invalid', + }); + + const output = getWrittenOutput(); + expect(output).toContain('"type":"error"'); + expect(output).toContain('"severity":"error"'); + expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + }); + + it('should handle InvalidStream event gracefully in JSON mode', async () => { + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue( + OutputFormat.JSON, + ); + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.InvalidStream, + value: { + type: 'NO_RESPONSE_TEXT', + message: 'Empty response', + }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test invalid stream', + prompt_id: 'prompt-id-invalid', + }); + + const output = getWrittenOutput(); + expect(output).toContain('"error": {'); + expect(output).toContain('"type": "INVALID_STREAM"'); + expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + }); + + it('should handle non-NO_RESPONSE_TEXT InvalidStream event gracefully and use message from eventValue', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.InvalidStream, + value: { + type: 'MALFORMED_FUNCTION_CALL', + message: 'Custom malformed function call message', + }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test invalid stream malformed', + prompt_id: 'prompt-id-invalid-malformed', + }); + + expect(processStderrSpy).toHaveBeenCalledWith( + '[ERROR] Custom malformed function call message\n', + ); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + }); + }); + + describe('Output Sanitization', () => { + const ANSI_SEQUENCE = '\u001B[31mRed Text\u001B[0m'; + const OSC_HYPERLINK = + '\u001B]8;;http://example.com\u001B\\Link\u001B]8;;\u001B\\'; + const PLAIN_TEXT_RED = 'Red Text'; + const PLAIN_TEXT_LINK = 'Link'; + + it('should sanitize ANSI output by default', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: ANSI_SEQUENCE }, + { type: GeminiEventType.Content, value: ' ' }, + { type: GeminiEventType.Content, value: OSC_HYPERLINK }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getRawOutput).mockReturnValue(false); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-sanitization', + }); + + expect(getWrittenOutput()).toBe(`${PLAIN_TEXT_RED} ${PLAIN_TEXT_LINK}\n`); + }); + + it('should allow ANSI output when rawOutput is true', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: ANSI_SEQUENCE }, + { type: GeminiEventType.Content, value: ' ' }, + { type: GeminiEventType.Content, value: OSC_HYPERLINK }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getRawOutput).mockReturnValue(true); + vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-raw', + }); + + expect(getWrittenOutput()).toBe(`${ANSI_SEQUENCE} ${OSC_HYPERLINK}\n`); + }); + + it('should allow ANSI output when only acceptRawOutputRisk is true', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: ANSI_SEQUENCE }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getRawOutput).mockReturnValue(false); + vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-accept-only', + }); + + expect(getWrittenOutput()).toBe(`${ANSI_SEQUENCE}\n`); + }); + + it('should warn when rawOutput is true and acceptRisk is false', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getRawOutput).mockReturnValue(true); + vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(false); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-warn', + }); + + expect(processStderrSpy).toHaveBeenCalledWith( + expect.stringContaining('[WARNING] --raw-output is enabled'), + ); + }); + + it('should not warn when rawOutput is true and acceptRisk is true', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getRawOutput).mockReturnValue(true); + vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-no-warn', + }); + + expect(processStderrSpy).not.toHaveBeenCalledWith( + expect.stringContaining('[WARNING] --raw-output is enabled'), + ); + }); + + it('should report cancelled tool calls as success in stream-json mode (legacy parity)', async () => { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-cancel', + }, + }; + + // Mock the scheduler to return a cancelled status + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Cancelled, + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + callId: 'tool-1', + responseParts: [{ text: 'Operation cancelled' }], + resultDisplay: 'Cancelled', + }, + }, + ]); + + const events: ServerGeminiStreamEvent[] = [ + toolCallEvent, + { + type: GeminiEventType.Content, + value: 'Model continues...', + }, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-cancel', + }); + + const output = getWrittenOutput(); + expect(output).toContain('"type":"tool_result"'); + expect(output).toContain('"status":"success"'); + }); + }); +}); diff --git a/packages/cli/src/nonInteractiveCliAgentSession.test.ts b/packages/cli/src/nonInteractiveCliAgentSession.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..93fdc0b6b718ef7bb4ab18f23f9c778ac7e547c1 --- /dev/null +++ b/packages/cli/src/nonInteractiveCliAgentSession.test.ts @@ -0,0 +1,2823 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + Config, + ToolRegistry, + ServerGeminiStreamEvent, + SessionMetrics, + AnyDeclarativeTool, + AnyToolInvocation, + UserFeedbackPayload, +} from '@google/gemini-cli-core'; +import { + ToolErrorType, + GeminiEventType, + OutputFormat, + uiTelemetryService, + FatalInputError, + CoreEvent, + CoreToolCallStatus, + JsonStreamEventType, + TRUE_EMPTY_RESPONSE_MESSAGE, +} from '@google/gemini-cli-core'; +import type { Part } from '@google/genai'; +import { runNonInteractive } from './nonInteractiveCliAgentSession.js'; +import { + describe, + it, + expect, + beforeEach, + afterEach, + vi, + type Mock, + type MockInstance, +} from 'vitest'; +import type { LoadedSettings } from './config/settings.js'; + +// Mock core modules +vi.mock('./ui/hooks/atCommandProcessor.js'); + +const mockSetupInitialActivityLogger = vi.hoisted(() => vi.fn()); +vi.mock('./utils/devtoolsService.js', () => ({ + setupInitialActivityLogger: mockSetupInitialActivityLogger, +})); + +const mockCoreEvents = vi.hoisted(() => ({ + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + emitConsoleLog: vi.fn(), + emitFeedback: vi.fn(), + drainBacklogs: vi.fn(), +})); + +const mockSchedulerSchedule = vi.hoisted(() => vi.fn()); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const original = + await importOriginal(); + + class MockChatRecordingService { + initialize = vi.fn(); + recordMessage = vi.fn(); + recordMessageTokens = vi.fn(); + recordToolCalls = vi.fn(); + } + + return { + ...original, + Scheduler: class { + schedule = mockSchedulerSchedule; + cancelAll = vi.fn(); + dispose = vi.fn(); + }, + isTelemetrySdkInitialized: vi.fn().mockReturnValue(true), + ChatRecordingService: MockChatRecordingService, + uiTelemetryService: { + getMetrics: vi.fn(), + recordSemanticValidationError: vi.fn(), + }, + LegacyAgentSession: original.LegacyAgentSession, + geminiPartsToContentParts: original.geminiPartsToContentParts, + coreEvents: mockCoreEvents, + createWorkingStdio: vi.fn(() => ({ + stdout: process.stdout, + stderr: process.stderr, + })), + }; +}); + +const mockGetCommands = vi.hoisted(() => vi.fn()); +const mockCommandServiceCreate = vi.hoisted(() => vi.fn()); +vi.mock('./services/CommandService.js', () => ({ + CommandService: { + create: mockCommandServiceCreate, + }, +})); + +vi.mock('./services/FileCommandLoader.js'); +vi.mock('./services/McpPromptLoader.js'); +vi.mock('./services/BuiltinCommandLoader.js'); + +describe('runNonInteractive', () => { + let mockConfig: Config; + let mockSettings: LoadedSettings; + let mockToolRegistry: ToolRegistry; + let consoleErrorSpy: MockInstance; + let processStdoutSpy: MockInstance; + let processStderrSpy: MockInstance; + let mockGeminiClient: { + sendMessageStream: Mock; + resumeChat: Mock; + getChatRecordingService: Mock; + getChat: Mock; + getCurrentSequenceModel: Mock; + }; + const MOCK_SESSION_METRICS: SessionMetrics = { + models: {}, + tools: { + totalCalls: 0, + totalSuccess: 0, + totalFail: 0, + totalDurationMs: 0, + totalDecisions: { + accept: 0, + reject: 0, + modify: 0, + auto_accept: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, + }; + + beforeEach(async () => { + mockSchedulerSchedule.mockReset(); + + mockCommandServiceCreate.mockResolvedValue({ + getCommands: mockGetCommands, + }); + + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + processStdoutSpy = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true); + vi.spyOn(process.stdout, 'on').mockImplementation(() => process.stdout); + processStderrSpy = vi + .spyOn(process.stderr, 'write') + .mockImplementation(() => true); + vi.spyOn(process, 'exit').mockImplementation((code) => { + throw new Error(`process.exit(${code}) called`); + }); + + mockToolRegistry = { + getTool: vi.fn(), + getFunctionDeclarations: vi.fn().mockReturnValue([]), + } as unknown as ToolRegistry; + + mockGeminiClient = { + sendMessageStream: vi.fn(), + resumeChat: vi.fn().mockResolvedValue(undefined), + getChatRecordingService: vi.fn(() => ({ + initialize: vi.fn(), + recordMessage: vi.fn(), + recordMessageTokens: vi.fn(), + recordToolCalls: vi.fn(), + })), + getChat: vi.fn(() => ({ recordCompletedToolCalls: vi.fn() })), + getCurrentSequenceModel: vi.fn().mockReturnValue(null), + }; + + mockConfig = { + initialize: vi.fn().mockReturnValue(Promise.resolve(undefined)), + getMessageBus: vi.fn().mockReturnValue({ + subscribe: vi.fn(), + unsubscribe: vi.fn(), + publish: vi.fn(), + }), + getGeminiClient: vi.fn().mockReturnValue(mockGeminiClient), + getToolRegistry: vi.fn().mockReturnValue(mockToolRegistry), + getMaxSessionTurns: vi.fn().mockReturnValue(10), + getSessionId: vi.fn().mockReturnValue('test-session-id'), + getProjectRoot: vi.fn().mockReturnValue('/test/project'), + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/test/project/.gemini/tmp'), + }, + getIdeMode: vi.fn().mockReturnValue(false), + + getContentGeneratorConfig: vi.fn().mockReturnValue({}), + getDebugMode: vi.fn().mockReturnValue(false), + getOutputFormat: vi.fn().mockReturnValue('text'), + getModel: vi.fn().mockReturnValue('test-model'), + getFolderTrust: vi.fn().mockReturnValue(false), + isTrustedFolder: vi.fn().mockReturnValue(false), + getRawOutput: vi.fn().mockReturnValue(false), + getAcceptRawOutputRisk: vi.fn().mockReturnValue(false), + getAgentSessionNoninteractiveEnabled: vi.fn().mockReturnValue(false), + getUsageStatisticsEnabled: vi.fn().mockReturnValue(false), + } as unknown as Config; + + mockSettings = { + system: { path: '', settings: {} }, + systemDefaults: { path: '', settings: {} }, + user: { path: '', settings: {} }, + workspace: { path: '', settings: {} }, + errors: [], + setValue: vi.fn(), + merged: { + security: { + auth: { + enforcedType: undefined, + }, + }, + }, + isTrusted: true, + migratedInMemoryScopes: new Set(), + forScope: vi.fn(), + computeMergedSettings: vi.fn(), + } as unknown as LoadedSettings; + + const { handleAtCommand } = await import( + './ui/hooks/atCommandProcessor.js' + ); + vi.mocked(handleAtCommand).mockImplementation(async ({ query }) => ({ + processedQuery: [{ text: query }], + })); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + async function* createStreamFromEvents( + events: ServerGeminiStreamEvent[], + ): AsyncGenerator { + for (const event of events) { + yield event; + } + } + + const getWrittenOutput = () => + processStdoutSpy.mock.calls.map((c) => c[0]).join(''); + + it('should process input and write text output', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Hello' }, + { type: GeminiEventType.Content, value: ' World' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-1', + }); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Test input' }], + expect.any(AbortSignal), + 'prompt-id-1', + undefined, + 'Test input', + ); + expect(getWrittenOutput()).toBe('Hello World\n'); + // Note: Telemetry shutdown is now handled in runExitCleanup() in cleanup.ts + // so we no longer expect shutdownTelemetry to be called directly here + }); + + it('should stream the specific stream started by send', async () => { + const { LegacyAgentSession } = await import('@google/gemini-cli-core'); + const streamSpy = vi.spyOn(LegacyAgentSession.prototype, 'stream'); + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Hello again' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-stream', + }); + + expect(streamSpy).toHaveBeenCalledWith({ streamId: expect.any(String) }); + }); + + it('fails fast if the session acknowledges a message send without a stream', async () => { + const { LegacyAgentSession } = await import('@google/gemini-cli-core'); + const sendSpy = vi + .spyOn(LegacyAgentSession.prototype, 'send') + .mockResolvedValue({ streamId: null }); + const streamSpy = vi.spyOn(LegacyAgentSession.prototype, 'stream'); + + await expect( + runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-null-stream', + }), + ).rejects.toThrow( + 'LegacyAgentSession.send() unexpectedly returned no stream for a message send.', + ); + + expect(streamSpy).not.toHaveBeenCalled(); + + sendSpy.mockRestore(); + streamSpy.mockRestore(); + }); + + it('should register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is set', async () => { + vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', '/tmp/test.jsonl'); + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-activity-logger', + }); + + expect(mockSetupInitialActivityLogger).toHaveBeenCalledWith(mockConfig); + vi.unstubAllEnvs(); + }); + + it('should not register activity logger when GEMINI_CLI_ACTIVITY_LOG_TARGET is not set', async () => { + vi.stubEnv('GEMINI_CLI_ACTIVITY_LOG_TARGET', ''); + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-activity-logger-off', + }); + + expect(mockSetupInitialActivityLogger).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); + }); + + it('should handle a single tool call and respond', async () => { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-2', + }, + }; + const toolResponse: Part[] = [{ text: 'Tool response' }]; + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Success, + request: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-2', + }, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: toolResponse, + callId: 'tool-1', + error: undefined, + errorType: undefined, + contentLength: undefined, + }, + }, + ]); + + const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; + const secondCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) + .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Use a tool', + prompt_id: 'prompt-id-2', + }); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockSchedulerSchedule).toHaveBeenCalledWith( + [expect.objectContaining({ name: 'testTool' })], + expect.any(AbortSignal), + ); + expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + 2, + [{ text: 'Tool response' }], + expect.any(AbortSignal), + 'prompt-id-2', + undefined, + undefined, + ); + expect(getWrittenOutput()).toBe('Final answer\n'); + }); + + it('should write a single newline between sequential text outputs from the model', async () => { + // This test simulates a multi-turn conversation to ensure that a single newline + // is printed between each block of text output from the model. + + // 1. Define the tool requests that the model will ask the CLI to run. + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'mock-tool', + name: 'mockTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-multi', + }, + }; + + // 2. Mock the execution of the tools. We just need them to succeed. + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Success, + request: toolCallEvent.value, // This is generic enough for both calls + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: [], + callId: 'mock-tool', + }, + }, + ]); + + // 3. Define the sequence of events streamed from the mock model. + // Turn 1: Model outputs text, then requests a tool call. + const modelTurn1: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Use mock tool' }, + toolCallEvent, + ]; + // Turn 2: Model outputs more text, then requests another tool call. + const modelTurn2: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Use mock tool again' }, + toolCallEvent, + ]; + // Turn 3: Model outputs a final answer. + const modelTurn3: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Finished.' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(modelTurn1)) + .mockReturnValueOnce(createStreamFromEvents(modelTurn2)) + .mockReturnValueOnce(createStreamFromEvents(modelTurn3)); + + // 4. Run the command. + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Use mock tool multiple times', + prompt_id: 'prompt-id-multi', + }); + + // 5. Verify the output. + // The rendered output should contain the text from each turn, separated by a + // single newline, with a final newline at the end. + expect(getWrittenOutput()).toMatchSnapshot(); + + // Also verify the tools were called as expected. + expect(mockSchedulerSchedule).toHaveBeenCalledTimes(2); + }); + + it('should handle error during tool execution and should send error back to the model', async () => { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'errorTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-3', + }, + }; + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Error, + request: { + callId: 'tool-1', + name: 'errorTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-3', + }, + tool: {} as AnyDeclarativeTool, + response: { + callId: 'tool-1', + error: new Error('Execution failed'), + errorType: ToolErrorType.EXECUTION_FAILED, + responseParts: [ + { + functionResponse: { + name: 'errorTool', + response: { + output: 'Error: Execution failed', + }, + }, + }, + ], + resultDisplay: 'Execution failed', + contentLength: undefined, + }, + }, + ]); + const finalResponse: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Content, + value: 'Sorry, let me try again.', + }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) + .mockReturnValueOnce(createStreamFromEvents(finalResponse)); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Trigger tool error', + prompt_id: 'prompt-id-3', + }); + + expect(mockSchedulerSchedule).toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error executing tool errorTool: Execution failed', + ); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(mockGeminiClient.sendMessageStream).toHaveBeenNthCalledWith( + 2, + [ + { + functionResponse: { + name: 'errorTool', + response: { + output: 'Error: Execution failed', + }, + }, + }, + ], + expect.any(AbortSignal), + 'prompt-id-3', + undefined, + undefined, + ); + expect(getWrittenOutput()).toBe('Sorry, let me try again.\n'); + }); + + it('should exit with error if sendMessageStream throws initially', async () => { + const apiError = new Error('API connection failed'); + mockGeminiClient.sendMessageStream.mockImplementation(() => { + throw apiError; + }); + + await expect( + runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Initial fail', + prompt_id: 'prompt-id-4', + }), + ).rejects.toThrow('API connection failed'); + }); + + it('should not exit if a tool is not found, and should send error back to model', async () => { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'nonexistentTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-5', + }, + }; + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Error, + request: { + callId: 'tool-1', + name: 'nonexistentTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-5', + }, + response: { + callId: 'tool-1', + error: new Error('Tool "nonexistentTool" not found in registry.'), + resultDisplay: 'Tool "nonexistentTool" not found in registry.', + responseParts: [], + errorType: undefined, + contentLength: undefined, + }, + }, + ]); + const finalResponse: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Content, + value: "Sorry, I can't find that tool.", + }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents([toolCallEvent])) + .mockReturnValueOnce(createStreamFromEvents(finalResponse)); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Trigger tool not found', + prompt_id: 'prompt-id-5', + }); + + expect(mockSchedulerSchedule).toHaveBeenCalled(); + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error executing tool nonexistentTool: Tool "nonexistentTool" not found in registry.', + ); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(2); + expect(getWrittenOutput()).toBe("Sorry, I can't find that tool.\n"); + }); + + it('should exit when max session turns are exceeded', async () => { + vi.mocked(mockConfig.getMaxSessionTurns).mockReturnValue(0); + await expect( + runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Trigger loop', + prompt_id: 'prompt-id-6', + }), + ).rejects.toThrow('Reached max session turns for this session'); + }); + + it('should preprocess @include commands before sending to the model', async () => { + // 1. Mock the imported atCommandProcessor + const { handleAtCommand } = await import( + './ui/hooks/atCommandProcessor.js' + ); + const mockHandleAtCommand = vi.mocked(handleAtCommand); + + // 2. Define the raw input and the expected processed output + const rawInput = 'Summarize @file.txt'; + const processedParts: Part[] = [ + { text: 'Summarize @file.txt' }, + { text: '\n--- Content from referenced files ---\n' }, + { text: 'This is the content of the file.' }, + { text: '\n--- End of content ---' }, + ]; + + // 3. Setup the mock to return the processed parts + mockHandleAtCommand.mockResolvedValue({ + processedQuery: processedParts, + }); + + // Mock a simple stream response from the Gemini client + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Summary complete.' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + // 4. Run the non-interactive mode with the raw input + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: rawInput, + prompt_id: 'prompt-id-7', + }); + + // 5. Assert that sendMessageStream was called with the PROCESSED parts, not the raw input + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + processedParts, + expect.any(AbortSignal), + 'prompt-id-7', + undefined, + rawInput, + ); + + // 6. Assert the final output is correct + expect(getWrittenOutput()).toBe('Summary complete.\n'); + }); + + it('should process input and write JSON output with stats', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Hello World' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-1', + }); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Test input' }], + expect.any(AbortSignal), + 'prompt-id-1', + undefined, + 'Test input', + ); + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify( + { + session_id: 'test-session-id', + response: 'Hello World', + stats: MOCK_SESSION_METRICS, + }, + null, + 2, + ), + ); + }); + + it('should write JSON output with stats for tool-only commands (no text response)', async () => { + // Test the scenario where a command completes successfully with only tool calls + // but no text response - this would have caught the original bug + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-tool-only', + }, + }; + const toolResponse: Part[] = [{ text: 'Tool executed successfully' }]; + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Success, + request: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-tool-only', + }, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: toolResponse, + callId: 'tool-1', + error: undefined, + errorType: undefined, + contentLength: undefined, + }, + }, + ]); + + // First call returns only tool call, no content + const firstCallEvents: ServerGeminiStreamEvent[] = [ + toolCallEvent, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + + // Second call returns no content (tool-only completion) + const secondCallEvents: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } }, + }, + ]; + + // Third call handles the auto-nudge recovery (when no response is received after tools) + const thirdCallEvents: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) + .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)) + .mockReturnValueOnce(createStreamFromEvents(thirdCallEvents)); + + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Execute tool only', + prompt_id: 'prompt-id-tool-only', + }); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(3); + expect(mockSchedulerSchedule).toHaveBeenCalledWith( + [expect.objectContaining({ name: 'testTool' })], + expect.any(AbortSignal), + ); + + // This should output JSON with empty response but include stats + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify( + { + session_id: 'test-session-id', + response: '', + stats: MOCK_SESSION_METRICS, + }, + null, + 2, + ), + ); + }); + + it('should keep only the final post-tool assistant text in JSON output', async () => { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-json-tool-text', + }, + }; + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Success, + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: [{ text: 'Tool executed successfully' }], + callId: 'tool-1', + error: undefined, + errorType: undefined, + contentLength: undefined, + }, + }, + ]); + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce( + createStreamFromEvents([ + { type: GeminiEventType.Content, value: 'Let me check that...' }, + toolCallEvent, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]), + ) + .mockReturnValueOnce( + createStreamFromEvents([ + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 3 } }, + }, + ]), + ); + + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Use a tool', + prompt_id: 'prompt-id-json-tool-text', + }); + + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify( + { + session_id: 'test-session-id', + response: 'Final answer', + stats: MOCK_SESSION_METRICS, + }, + null, + 2, + ), + ); + }); + + it('should write JSON output with stats for empty response commands', async () => { + // Test the scenario where a command completes but produces no content at all + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Empty response test', + prompt_id: 'prompt-id-empty', + }); + + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Empty response test' }], + expect.any(AbortSignal), + 'prompt-id-empty', + undefined, + 'Empty response test', + ); + + // This should output JSON with empty response but include stats + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify( + { + session_id: 'test-session-id', + response: '', + stats: MOCK_SESSION_METRICS, + }, + null, + 2, + ), + ); + }); + + it('should handle errors in JSON format', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + const testError = new Error('Invalid input provided'); + + mockGeminiClient.sendMessageStream.mockImplementation(() => { + throw testError; + }); + + let thrownError: Error | null = null; + try { + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-error', + }); + // Should not reach here + expect.fail('Expected process.exit to be called'); + } catch (error) { + thrownError = error as Error; + } + + // Should throw because of mocked process.exit + expect(thrownError?.message).toBe('process.exit(1) called'); + + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + JSON.stringify( + { + session_id: 'test-session-id', + error: { + type: 'Error', + message: 'Invalid input provided', + code: 1, + }, + }, + null, + 2, + ), + ); + }); + + it('should handle FatalInputError with custom exit code in JSON format', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + const fatalError = new FatalInputError('Invalid command syntax provided'); + + mockGeminiClient.sendMessageStream.mockImplementation(() => { + throw fatalError; + }); + + let thrownError: Error | null = null; + try { + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Invalid syntax', + prompt_id: 'prompt-id-fatal', + }); + // Should not reach here + expect.fail('Expected process.exit to be called'); + } catch (error) { + thrownError = error as Error; + } + + // Should throw because of mocked process.exit with custom exit code + expect(thrownError?.message).toBe('process.exit(42) called'); + + expect(mockCoreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + JSON.stringify( + { + session_id: 'test-session-id', + error: { + type: 'FatalInputError', + message: 'Invalid command syntax provided', + code: 42, + }, + }, + null, + 2, + ), + ); + }); + + it('should execute a slash command that returns a prompt', async () => { + const mockCommand = { + name: 'testcommand', + description: 'a test command', + action: vi.fn().mockResolvedValue({ + type: 'submit_prompt', + content: [{ text: 'Prompt from command' }], + }), + }; + mockGetCommands.mockReturnValue([mockCommand]); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Response from command' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/testcommand', + prompt_id: 'prompt-id-slash', + }); + + // Ensure the prompt sent to the model is from the command, not the raw input + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Prompt from command' }], + expect.any(AbortSignal), + 'prompt-id-slash', + undefined, + '/testcommand', + ); + + expect(getWrittenOutput()).toBe('Response from command\n'); + }); + + it('should handle slash commands', async () => { + const nonInteractiveCliCommands = await import( + './nonInteractiveCliCommands.js' + ); + const handleSlashCommandSpy = vi.spyOn( + nonInteractiveCliCommands, + 'handleSlashCommand', + ); + handleSlashCommandSpy.mockResolvedValue([{ text: 'Slash command output' }]); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Response to slash command' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/help', + prompt_id: 'prompt-id-slash', + }); + + expect(handleSlashCommandSpy).toHaveBeenCalledWith( + '/help', + expect.any(AbortController), + mockConfig, + mockSettings, + ); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: 'Slash command output' }], + expect.any(AbortSignal), + 'prompt-id-slash', + undefined, + '/help', + ); + expect(getWrittenOutput()).toBe('Response to slash command\n'); + handleSlashCommandSpy.mockRestore(); + }); + + it('should handle cancellation (Ctrl+C)', async () => { + // Mock isTTY and setRawMode safely + const originalIsTTY = process.stdin.isTTY; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const originalSetRawMode = (process.stdin as any).setRawMode; + + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + if (!originalSetRawMode) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).setRawMode = vi.fn(); + } + + const stdinOnSpy = vi + .spyOn(process.stdin, 'on') + .mockImplementation(() => process.stdin); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(process.stdin as any, 'setRawMode').mockImplementation(() => true); + vi.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin); + vi.spyOn(process.stdin, 'pause').mockImplementation(() => process.stdin); + vi.spyOn(process.stdin, 'removeAllListeners').mockImplementation( + () => process.stdin, + ); + + // Cancellation will throw FatalCancellationError directly + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Thinking...' }, + ]; + // Create a stream that responds to abortion + mockGeminiClient.sendMessageStream.mockImplementation( + (_messages, signal: AbortSignal) => + (async function* () { + yield events[0]; + await new Promise((resolve, reject) => { + const timeout = setTimeout(resolve, 1000); + signal.addEventListener('abort', () => { + clearTimeout(timeout); + setTimeout(() => { + reject(new Error('Aborted')); + }, 300); + }); + }); + })(), + ); + + const runPromise = runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Long running query', + prompt_id: 'prompt-id-cancel', + }); + + // Wait a bit for setup to complete and listeners to be registered + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Find the keypress handler registered by runNonInteractive + const keypressCall = stdinOnSpy.mock.calls.find( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (call) => (call[0] as any) === 'keypress', + ); + expect(keypressCall).toBeDefined(); + const keypressHandler = keypressCall?.[1] as ( + str: string, + key: { name?: string; ctrl?: boolean }, + ) => void; + + if (keypressHandler) { + // Simulate Ctrl+C + keypressHandler('\u0003', { ctrl: true, name: 'c' }); + } + + await expect(runPromise).rejects.toThrow('Operation cancelled.'); + + expect( + processStderrSpy.mock.calls.some( + // eslint-disable-next-line no-restricted-syntax + (call) => typeof call[0] === 'string' && call[0].includes('Cancelling'), + ), + ).toBe(true); + + // Restore original values + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + if (originalSetRawMode) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).setRawMode = originalSetRawMode; + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (process.stdin as any).setRawMode; + } + // Spies are automatically restored by vi.restoreAllMocks() in afterEach, + // but we can also do it manually if needed. + }); + + it('should honor cancellation that happens before session.send()', async () => { + const originalIsTTY = process.stdin.isTTY; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const originalSetRawMode = (process.stdin as any).setRawMode; + + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + if (!originalSetRawMode) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).setRawMode = vi.fn(); + } + + const stdinOnSpy = vi + .spyOn(process.stdin, 'on') + .mockImplementation( + (event: string | symbol, listener: (...args: unknown[]) => void) => { + if (event === 'keypress') { + listener('\u0003', { ctrl: true, name: 'c' }); + } + return process.stdin; + }, + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.spyOn(process.stdin as any, 'setRawMode').mockImplementation(() => true); + vi.spyOn(process.stdin, 'resume').mockImplementation(() => process.stdin); + vi.spyOn(process.stdin, 'pause').mockImplementation(() => process.stdin); + vi.spyOn(process.stdin, 'removeAllListeners').mockImplementation( + () => process.stdin, + ); + + // Cancellation will throw FatalCancellationError directly + + const { LegacyAgentSession } = await import('@google/gemini-cli-core'); + const sendSpy = vi.spyOn(LegacyAgentSession.prototype, 'send'); + + await expect( + runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Cancelled query', + prompt_id: 'prompt-id-pre-send-cancel', + }), + ).rejects.toThrow('Operation cancelled.'); + + expect(sendSpy).not.toHaveBeenCalled(); + expect(stdinOnSpy).toHaveBeenCalled(); + sendSpy.mockRestore(); + + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + if (originalSetRawMode) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (process.stdin as any).setRawMode = originalSetRawMode; + } else { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + delete (process.stdin as any).setRawMode; + } + }); + + it('should throw FatalInputError if a command requires confirmation', async () => { + const mockCommand = { + name: 'confirm', + description: 'a command that needs confirmation', + action: vi.fn().mockResolvedValue({ + type: 'confirm_shell_commands', + commands: ['rm -rf /'], + }), + }; + mockGetCommands.mockReturnValue([mockCommand]); + + await expect( + runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/confirm', + prompt_id: 'prompt-id-confirm', + }), + ).rejects.toThrow( + 'Exiting due to a confirmation prompt requested by the command.', + ); + }); + + it('should treat an unknown slash command as a regular prompt', async () => { + // No commands are mocked, so any slash command is "unknown" + mockGetCommands.mockReturnValue([]); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Response to unknown' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/unknowncommand', + prompt_id: 'prompt-id-unknown', + }); + + // Ensure the raw input is sent to the model + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledWith( + [{ text: '/unknowncommand' }], + expect.any(AbortSignal), + 'prompt-id-unknown', + undefined, + '/unknowncommand', + ); + + expect(getWrittenOutput()).toBe('Response to unknown\n'); + }); + + it('should throw for unhandled command result types', async () => { + const mockCommand = { + name: 'noaction', + description: 'unhandled type', + action: vi.fn().mockResolvedValue({ + type: 'unhandled', + }), + }; + mockGetCommands.mockReturnValue([mockCommand]); + + await expect( + runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/noaction', + prompt_id: 'prompt-id-unhandled', + }), + ).rejects.toThrow( + 'Exiting due to command result that is not supported in non-interactive mode.', + ); + }); + + it('should pass arguments to the slash command action', async () => { + const mockAction = vi.fn().mockResolvedValue({ + type: 'submit_prompt', + content: [{ text: 'Prompt from command' }], + }); + const mockCommand = { + name: 'testargs', + description: 'a test command', + action: mockAction, + }; + mockGetCommands.mockReturnValue([mockCommand]); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Acknowledged' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/testargs arg1 arg2', + prompt_id: 'prompt-id-args', + }); + + expect(mockAction).toHaveBeenCalledWith(expect.any(Object), 'arg1 arg2'); + + expect(getWrittenOutput()).toBe('Acknowledged\n'); + }); + + it('should instantiate CommandService with correct loaders for slash commands', async () => { + // This test indirectly checks that handleSlashCommand is using the right loaders. + const { FileCommandLoader } = await import( + './services/FileCommandLoader.js' + ); + const { McpPromptLoader } = await import('./services/McpPromptLoader.js'); + const { BuiltinCommandLoader } = await import( + './services/BuiltinCommandLoader.js' + ); + mockGetCommands.mockReturnValue([]); // No commands found, so it will fall through + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Acknowledged' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 1 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: '/mycommand', + prompt_id: 'prompt-id-loaders', + }); + + // Check that loaders were instantiated with the config + expect(FileCommandLoader).toHaveBeenCalledTimes(1); + expect(FileCommandLoader).toHaveBeenCalledWith(mockConfig); + expect(McpPromptLoader).toHaveBeenCalledTimes(1); + expect(McpPromptLoader).toHaveBeenCalledWith(mockConfig); + expect(BuiltinCommandLoader).toHaveBeenCalledWith(mockConfig); + + // Check that instances were passed to CommandService.create + expect(mockCommandServiceCreate).toHaveBeenCalledTimes(1); + const loadersArg = mockCommandServiceCreate.mock.calls[0][0]; + expect(loadersArg).toHaveLength(3); + expect(loadersArg[0]).toBe( + vi.mocked(BuiltinCommandLoader).mock.instances[0], + ); + expect(loadersArg[1]).toBe(vi.mocked(McpPromptLoader).mock.instances[0]); + expect(loadersArg[2]).toBe(vi.mocked(FileCommandLoader).mock.instances[0]); + }); + + it('should allow a normally-excluded tool when --allowed-tools is set', async () => { + // By default, ShellTool is excluded in non-interactive mode. + // This test ensures that --allowed-tools overrides this exclusion. + vi.mocked(mockConfig.getToolRegistry).mockReturnValue({ + getTool: vi.fn().mockReturnValue({ + name: 'ShellTool', + description: 'A shell tool', + run: vi.fn(), + }), + getFunctionDeclarations: vi.fn().mockReturnValue([{ name: 'ShellTool' }]), + } as unknown as ToolRegistry); + + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-shell-1', + name: 'ShellTool', + args: { command: 'ls' }, + isClientInitiated: false, + prompt_id: 'prompt-id-allowed', + }, + }; + const toolResponse: Part[] = [{ text: 'file.txt' }]; + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Success, + request: { + callId: 'tool-shell-1', + name: 'ShellTool', + args: { command: 'ls' }, + isClientInitiated: false, + prompt_id: 'prompt-id-allowed', + }, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: toolResponse, + callId: 'tool-shell-1', + error: undefined, + errorType: undefined, + contentLength: undefined, + }, + }, + ]); + + const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; + const secondCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'file.txt' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) + .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'List the files', + prompt_id: 'prompt-id-allowed', + }); + + expect(mockSchedulerSchedule).toHaveBeenCalledWith( + [expect.objectContaining({ name: 'ShellTool' })], + expect.any(AbortSignal), + ); + expect(getWrittenOutput()).toBe('file.txt\n'); + }); + + describe('CoreEvents Integration', () => { + it('subscribes to UserFeedback and drains backlog on start', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-events', + }); + + expect(mockCoreEvents.on).toHaveBeenCalledWith( + CoreEvent.UserFeedback, + expect.any(Function), + ); + expect(mockCoreEvents.drainBacklogs).toHaveBeenCalledTimes(1); + }); + + it('unsubscribes from UserFeedback on finish', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-events', + }); + + expect(mockCoreEvents.off).toHaveBeenCalledWith( + CoreEvent.UserFeedback, + expect.any(Function), + ); + }); + + it('logs to process.stderr when UserFeedback event is received', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-events', + }); + + // Get the registered handler + const handler = mockCoreEvents.on.mock.calls.find( + (call: unknown[]) => call[0] === CoreEvent.UserFeedback, + )?.[1]; + expect(handler).toBeDefined(); + + // Simulate an event + const payload: UserFeedbackPayload = { + severity: 'error', + message: 'Test error message', + }; + handler(payload); + + expect(processStderrSpy).toHaveBeenCalledWith( + '[ERROR] Test error message\n', + ); + }); + + it('logs optional error object to process.stderr in debug mode', async () => { + vi.mocked(mockConfig.getDebugMode).mockReturnValue(true); + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'prompt-id-events', + }); + + // Get the registered handler + const handler = mockCoreEvents.on.mock.calls.find( + (call: unknown[]) => call[0] === CoreEvent.UserFeedback, + )?.[1]; + expect(handler).toBeDefined(); + + // Simulate an event with error object + const errorObj = new Error('Original error'); + // Mock stack for deterministic testing + errorObj.stack = 'Error: Original error\n at test'; + const payload: UserFeedbackPayload = { + severity: 'warning', + message: 'Test warning message', + error: errorObj, + }; + handler(payload); + + expect(processStderrSpy).toHaveBeenCalledWith( + '[WARNING] Test warning message\n', + ); + expect(processStderrSpy).toHaveBeenCalledWith( + 'Error: Original error\n at test\n', + ); + }); + }); + + it('should emit appropriate events for streaming JSON output', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-stream', + }, + }; + + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Success, + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: [{ text: 'Tool response' }], + callId: 'tool-1', + error: undefined, + errorType: undefined, + contentLength: undefined, + resultDisplay: 'Tool executed successfully', + }, + }, + ]); + + const firstCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Thinking...' }, + toolCallEvent, + ]; + const secondCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) + .mockReturnValueOnce(createStreamFromEvents(secondCallEvents)); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Stream test', + prompt_id: 'prompt-id-stream', + }); + + const output = getWrittenOutput(); + const sanitizedOutput = output + .replace(/"timestamp":"[^"]+"/g, '"timestamp":""') + .replace(/"duration_ms":\d+/g, '"duration_ms":'); + expect(sanitizedOutput).toMatchSnapshot(); + }); + + it('should handle EPIPE error gracefully', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Hello' }, + { type: GeminiEventType.Content, value: ' World' }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + // Mock process.exit to track calls without throwing + vi.spyOn(process, 'exit').mockImplementation((_code) => undefined as never); + + // Simulate EPIPE error on stdout + const stdoutErrorCallback = (process.stdout.on as Mock).mock.calls.find( + (call) => call[0] === 'error', + )?.[1]; + + if (stdoutErrorCallback) { + stdoutErrorCallback({ code: 'EPIPE' }); + } + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'EPIPE test', + prompt_id: 'prompt-id-epipe', + }); + + // Since EPIPE is simulated, it might exit early or continue depending on timing, + // but our main goal is to verify the handler is registered and handles EPIPE. + expect(process.stdout.on).toHaveBeenCalledWith( + 'error', + expect.any(Function), + ); + }); + + it('should resume chat when resumedSessionData is provided', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Resumed' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + const resumedSessionData = { + conversation: { + sessionId: 'resumed-session-id', + messages: [ + { role: 'user', parts: [{ text: 'Previous message' }] }, + ] as any, // eslint-disable-line @typescript-eslint/no-explicit-any + startTime: new Date().toISOString(), + lastUpdated: new Date().toISOString(), + firstUserMessage: 'Previous message', + projectHash: 'test-hash', + }, + filePath: '/path/to/session.json', + }; + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Continue', + prompt_id: 'prompt-id-resume', + resumedSessionData, + }); + + expect(mockGeminiClient.resumeChat).toHaveBeenCalledWith( + expect.any(Array), + resumedSessionData, + ); + expect(getWrittenOutput()).toBe('Resumed\n'); + }); + + it.each([ + { + name: 'loop detected', + events: [ + { type: GeminiEventType.LoopDetected }, + ] as ServerGeminiStreamEvent[], + input: 'Loop test', + promptId: 'prompt-id-loop', + }, + { + name: 'max session turns', + events: [ + { type: GeminiEventType.MaxSessionTurns }, + ] as ServerGeminiStreamEvent[], + input: 'Max turns test', + promptId: 'prompt-id-max-turns', + }, + ])( + 'should emit appropriate error event in streaming JSON mode: $name', + async ({ events, input, promptId }) => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const streamEvents: ServerGeminiStreamEvent[] = [ + ...events, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(streamEvents), + ); + + try { + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input, + prompt_id: promptId, + }); + } catch { + // Expected exit + } + + const output = getWrittenOutput(); + const sanitizedOutput = output + .replace(/"timestamp":"[^"]+"/g, '"timestamp":""') + .replace(/"duration_ms":\d+/g, '"duration_ms":'); + expect(sanitizedOutput).toMatchSnapshot(); + }, + ); + + it.each([ + { + name: 'loop detected', + events: [ + { type: GeminiEventType.LoopDetected }, + ] as ServerGeminiStreamEvent[], + expectedWarning: 'Loop detected, stopping execution', + }, + ])( + 'should include warning in JSON mode for: $name', + async ({ events, expectedWarning }) => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const streamEvents: ServerGeminiStreamEvent[] = [ + ...events, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(streamEvents), + ); + + try { + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'test', + }); + } catch { + // Expected exit for max turns + } + + const output = JSON.parse(getWrittenOutput()); + expect(output.warnings).toBeDefined(); + expect(output.warnings[0]).toContain(expectedWarning); + }, + ); + + it('should log error when tool recording fails', async () => { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'testTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-tool-error', + }, + }; + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Success, + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + responseParts: [], + callId: 'tool-1', + error: undefined, + errorType: undefined, + contentLength: undefined, + }, + }, + ]); + + const events: ServerGeminiStreamEvent[] = [ + toolCallEvent, + { type: GeminiEventType.Content, value: 'Done' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(events)) + .mockReturnValueOnce( + createStreamFromEvents([ + { type: GeminiEventType.Content, value: 'Done' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]), + ); + + // Mock getChat to throw when recording tool calls + const mockChat = { + recordCompletedToolCalls: vi.fn().mockImplementation(() => { + throw new Error('Recording failed'); + }), + }; + mockGeminiClient.getChat = vi.fn().mockReturnValue(mockChat); + mockGeminiClient.getCurrentSequenceModel = vi + .fn() + .mockReturnValue('model-1'); + + // Mock debugLogger.error + const { debugLogger } = await import('@google/gemini-cli-core'); + const debugLoggerErrorSpy = vi + .spyOn(debugLogger, 'error') + .mockImplementation(() => {}); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Tool recording error test', + prompt_id: 'prompt-id-tool-error', + }); + + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Error recording completed tool call information: Error: Recording failed', + ), + ); + expect(getWrittenOutput()).toContain('Done'); + }); + + it('should stop agent execution immediately when a tool call returns STOP_EXECUTION error', async () => { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'stop-call', + name: 'stopTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-stop', + }, + }; + + // Mock tool execution returning STOP_EXECUTION + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Error, + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + callId: 'stop-call', + responseParts: [{ text: 'error occurred' }], + errorType: ToolErrorType.STOP_EXECUTION, + error: new Error('Stop reason from hook'), + resultDisplay: undefined, + }, + }, + ]); + + const firstCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Executing tool...' }, + toolCallEvent, + ]; + + // Setup the mock to return events for the first call. + // We expect the loop to terminate after the tool execution. + // If it doesn't, it might call sendMessageStream again, which we'll assert against. + mockGeminiClient.sendMessageStream + .mockReturnValueOnce(createStreamFromEvents(firstCallEvents)) + .mockReturnValueOnce(createStreamFromEvents([])); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Run stop tool', + prompt_id: 'prompt-id-stop', + }); + + expect(mockSchedulerSchedule).toHaveBeenCalled(); + + // The key assertion: sendMessageStream should have been called ONLY ONCE (initial user input). + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + + expect(processStderrSpy).toHaveBeenCalledWith( + 'Agent execution stopped: Stop reason from hook\n', + ); + }); + + it('should write JSON output when a tool call returns STOP_EXECUTION error', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'stop-call', + name: 'stopTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-stop-json', + }, + }; + + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Error, + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + callId: 'stop-call', + responseParts: [{ text: 'error occurred' }], + errorType: ToolErrorType.STOP_EXECUTION, + error: new Error('Stop reason'), + resultDisplay: undefined, + }, + }, + ]); + + const firstCallEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Partial content' }, + toolCallEvent, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(firstCallEvents), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Run stop tool', + prompt_id: 'prompt-id-stop-json', + }); + + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify( + { + session_id: 'test-session-id', + response: 'Partial content', + stats: MOCK_SESSION_METRICS, + }, + null, + 2, + ), + ); + }); + + it('should emit result event when a tool call returns STOP_EXECUTION error in streaming JSON mode', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'stop-call', + name: 'stopTool', + args: {}, + isClientInitiated: false, + prompt_id: 'prompt-id-stop-stream', + }, + }; + + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Error, + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + callId: 'stop-call', + responseParts: [{ text: 'error occurred' }], + errorType: ToolErrorType.STOP_EXECUTION, + error: new Error('Stop reason'), + resultDisplay: undefined, + }, + }, + ]); + + const firstCallEvents: ServerGeminiStreamEvent[] = [toolCallEvent]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(firstCallEvents), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Run stop tool', + prompt_id: 'prompt-id-stop-stream', + }); + + const output = getWrittenOutput(); + expect(output).toContain('"type":"result"'); + expect(output).toContain('"status":"success"'); + }); + + describe('Agent Execution Events', () => { + it('should handle AgentExecutionStopped event', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.AgentExecutionStopped, + value: { reason: 'Stopped by hook' }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test stop', + prompt_id: 'prompt-id-stop', + }); + + expect(processStderrSpy).toHaveBeenCalledWith( + 'Agent execution stopped: Stopped by hook\n', + ); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + }); + + it('should write JSON output when AgentExecutionStopped event occurs', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Partial content' }, + { + type: GeminiEventType.AgentExecutionStopped, + value: { reason: 'Stopped by hook' }, + }, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test stop', + prompt_id: 'prompt-id-stop-json', + }); + + expect(processStdoutSpy).toHaveBeenCalledWith( + JSON.stringify( + { + session_id: 'test-session-id', + response: 'Partial content', + stats: MOCK_SESSION_METRICS, + }, + null, + 2, + ), + ); + }); + + it('should emit result event when AgentExecutionStopped event occurs in streaming JSON mode', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Partial content' }, + { + type: GeminiEventType.AgentExecutionStopped, + value: { reason: 'Stopped by hook' }, + }, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test stop', + prompt_id: 'prompt-id-stop-stream', + }); + + const output = getWrittenOutput(); + expect(output).toContain('"type":"result"'); + expect(output).toContain('"status":"success"'); + }); + + it('should handle AgentExecutionBlocked event', async () => { + const allEvents: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.AgentExecutionBlocked, + value: { reason: 'Blocked by hook' }, + }, + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(allEvents), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test block', + prompt_id: 'prompt-id-block', + }); + + expect(processStderrSpy).toHaveBeenCalledWith( + '[WARNING] Agent execution blocked: Blocked by hook\n', + ); + // Stream continues after blocked event — content should be output + expect(getWrittenOutput()).toBe('Final answer\n'); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + }); + + it('should emit ERROR event in STREAM_JSON mode when AgentExecutionBlocked occurs', async () => { + const allEvents: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.AgentExecutionBlocked, + value: { reason: 'Blocked by hook' }, + }, + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(allEvents), + ); + + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + // Setup stream-json format + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test block', + prompt_id: 'prompt-id-block', + }); + + const calls = processStdoutSpy.mock.calls.map((call) => + JSON.parse(call[0] as string), + ); + const errorEvent = calls.find( + (c) => c.type === JsonStreamEventType.ERROR, + ); + + expect(errorEvent).toBeDefined(); + expect(errorEvent.message).toContain( + 'Agent execution blocked: Blocked by hook', + ); + expect(errorEvent.severity).toBe('warning'); + }); + + it('should include warning in JSON mode when AgentExecutionBlocked occurs', async () => { + const allEvents: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.AgentExecutionBlocked, + value: { reason: 'Blocked by hook' }, + }, + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(allEvents), + ); + + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test block', + prompt_id: 'prompt-id-block', + }); + + const output = JSON.parse(getWrittenOutput()); + expect(output.warnings).toBeDefined(); + expect(output.warnings).toContain( + 'Agent execution blocked: Blocked by hook', + ); + }); + + it('should handle multiple AgentExecutionBlocked events and collect all warnings', async () => { + const allEvents: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.AgentExecutionBlocked, + value: { reason: 'Block 1', systemMessage: 'Reason 1' }, + }, + { + type: GeminiEventType.AgentExecutionBlocked, + value: { reason: 'Block 2', systemMessage: 'Reason 2' }, + }, + { type: GeminiEventType.Content, value: 'Final answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream.mockImplementation(() => + createStreamFromEvents(allEvents), + ); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'test', + }); + + const output = JSON.parse(getWrittenOutput()); + expect(output.warnings).toHaveLength(2); + expect(output.warnings).toContain('Agent execution blocked: Reason 1'); + expect(output.warnings).toContain('Agent execution blocked: Reason 2'); + }); + + it('should not include warnings field in JSON output if no blocks occur', async () => { + const allEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: 'Clean answer' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + + mockGeminiClient.sendMessageStream.mockImplementation(() => + createStreamFromEvents(allEvents), + ); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + vi.mocked(mockConfig.getOutputFormat).mockReturnValue(OutputFormat.JSON); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test', + prompt_id: 'test', + }); + + const output = JSON.parse(getWrittenOutput()); + expect(output.warnings).toBeUndefined(); + }); + + it('should handle InvalidStream event gracefully in TEXT mode', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.InvalidStream, + value: { + type: 'NO_RESPONSE_TEXT', + message: 'Empty response', + }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test invalid stream', + prompt_id: 'prompt-id-invalid', + }); + + expect(processStderrSpy).toHaveBeenCalledWith( + `[ERROR] ${TRUE_EMPTY_RESPONSE_MESSAGE}\n`, + ); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + }); + + it('should handle InvalidStream event gracefully in STREAM_JSON mode', async () => { + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue( + OutputFormat.STREAM_JSON, + ); + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.InvalidStream, + value: { + type: 'NO_RESPONSE_TEXT', + message: 'Empty response', + }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test invalid stream', + prompt_id: 'prompt-id-invalid', + }); + + const output = getWrittenOutput(); + expect(output).toContain('"type":"error"'); + expect(output).toContain('"severity":"error"'); + expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + }); + + it('should handle InvalidStream event gracefully in JSON mode', async () => { + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + vi.spyOn(mockConfig, 'getOutputFormat').mockReturnValue( + OutputFormat.JSON, + ); + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.InvalidStream, + value: { + type: 'NO_RESPONSE_TEXT', + message: 'Empty response', + }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test invalid stream', + prompt_id: 'prompt-id-invalid', + }); + + const output = getWrittenOutput(); + expect(output).toContain('"error": {'); + expect(output).toContain('"type": "INVALID_STREAM"'); + expect(output).toContain(TRUE_EMPTY_RESPONSE_MESSAGE); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + }); + + it('should handle non-NO_RESPONSE_TEXT InvalidStream event gracefully and use message from eventValue', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.InvalidStream, + value: { + type: 'MALFORMED_FUNCTION_CALL', + message: 'Malformed call', + }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'test invalid stream', + prompt_id: 'prompt-id-invalid', + }); + + expect(processStderrSpy).toHaveBeenCalledWith('[ERROR] Malformed call\n'); + expect(mockGeminiClient.sendMessageStream).toHaveBeenCalledTimes(1); + }); + }); + + describe('Output Sanitization', () => { + const ANSI_SEQUENCE = '\u001B[31mRed Text\u001B[0m'; + const OSC_HYPERLINK = + '\u001B]8;;http://example.com\u001B\\Link\u001B]8;;\u001B\\'; + const PLAIN_TEXT_RED = 'Red Text'; + const PLAIN_TEXT_LINK = 'Link'; + + it('should sanitize ANSI output by default', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: ANSI_SEQUENCE }, + { type: GeminiEventType.Content, value: ' ' }, + { type: GeminiEventType.Content, value: OSC_HYPERLINK }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getRawOutput).mockReturnValue(false); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-sanitization', + }); + + expect(getWrittenOutput()).toBe(`${PLAIN_TEXT_RED} ${PLAIN_TEXT_LINK}\n`); + }); + + it('should allow ANSI output when rawOutput is true', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: ANSI_SEQUENCE }, + { type: GeminiEventType.Content, value: ' ' }, + { type: GeminiEventType.Content, value: OSC_HYPERLINK }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 10 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getRawOutput).mockReturnValue(true); + vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-raw', + }); + + expect(getWrittenOutput()).toBe(`${ANSI_SEQUENCE} ${OSC_HYPERLINK}\n`); + }); + + it('should allow ANSI output when only acceptRawOutputRisk is true', async () => { + const events: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.Content, value: ANSI_SEQUENCE }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getRawOutput).mockReturnValue(false); + vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-accept-only', + }); + + expect(getWrittenOutput()).toBe(`${ANSI_SEQUENCE}\n`); + }); + + it('should warn when rawOutput is true and acceptRisk is false', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getRawOutput).mockReturnValue(true); + vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(false); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-warn', + }); + + expect(processStderrSpy).toHaveBeenCalledWith( + expect.stringContaining('[WARNING] --raw-output is enabled'), + ); + }); + + it('should not warn when rawOutput is true and acceptRisk is true', async () => { + const events: ServerGeminiStreamEvent[] = [ + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 0 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getRawOutput).mockReturnValue(true); + vi.mocked(mockConfig.getAcceptRawOutputRisk).mockReturnValue(true); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-no-warn', + }); + + expect(processStderrSpy).not.toHaveBeenCalledWith( + expect.stringContaining('[WARNING] --raw-output is enabled'), + ); + }); + + it('should emit warning event for loop_detected in streaming JSON mode', async () => { + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + const streamEvents: ServerGeminiStreamEvent[] = [ + { type: GeminiEventType.LoopDetected } as ServerGeminiStreamEvent, + { type: GeminiEventType.Content, value: 'Continuing after loop' }, + { + type: GeminiEventType.Finished, + value: { reason: undefined, usageMetadata: { totalTokenCount: 5 } }, + }, + ]; + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(streamEvents), + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Loop test explicit', + prompt_id: 'prompt-id-loop-explicit', + }); + + const output = getWrittenOutput(); + // The STREAM_JSON output should contain an error event with warning severity + expect(output).toContain('"type":"error"'); + expect(output).toContain('"severity":"warning"'); + expect(output).toContain('Loop detected'); + }); + + it('should report cancelled tool calls as success in stream-json mode (legacy parity)', async () => { + const toolCallEvent: ServerGeminiStreamEvent = { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'tool-1', + name: 'testTool', + args: { arg1: 'value1' }, + isClientInitiated: false, + prompt_id: 'prompt-id-cancel', + }, + }; + + // Mock the scheduler to return a cancelled status + mockSchedulerSchedule.mockResolvedValue([ + { + status: CoreToolCallStatus.Cancelled, + request: toolCallEvent.value, + tool: {} as AnyDeclarativeTool, + invocation: {} as AnyToolInvocation, + response: { + callId: 'tool-1', + responseParts: [{ text: 'Operation cancelled' }], + resultDisplay: 'Cancelled', + }, + }, + ]); + + const events: ServerGeminiStreamEvent[] = [ + toolCallEvent, + { + type: GeminiEventType.Content, + value: 'Model continues...', + }, + ]; + + mockGeminiClient.sendMessageStream.mockReturnValue( + createStreamFromEvents(events), + ); + + vi.mocked(mockConfig.getOutputFormat).mockReturnValue( + OutputFormat.STREAM_JSON, + ); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + MOCK_SESSION_METRICS, + ); + + await runNonInteractive({ + config: mockConfig, + settings: mockSettings, + input: 'Test input', + prompt_id: 'prompt-id-cancel', + }); + + const output = getWrittenOutput(); + expect(output).toContain('"type":"tool_result"'); + expect(output).toContain('"status":"success"'); + }); + }); +}); diff --git a/packages/cli/src/nonInteractiveCliAgentSession.ts b/packages/cli/src/nonInteractiveCliAgentSession.ts new file mode 100644 index 0000000000000000000000000000000000000000..f052e007010ebe563ba5a3d1852662328b62a87b --- /dev/null +++ b/packages/cli/src/nonInteractiveCliAgentSession.ts @@ -0,0 +1,707 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + Config, + ResumedSessionData, + UserFeedbackPayload, + AgentEvent, + ContentPart, +} from '@google/gemini-cli-core'; +import { isSlashCommand } from './ui/utils/commandUtils.js'; +import type { LoadedSettings } from './config/settings.js'; +import { + convertSessionToClientHistory, + FatalError, + FatalAuthenticationError, + FatalInputError, + FatalSandboxError, + FatalConfigError, + FatalTurnLimitedError, + FatalToolExecutionError, + FatalCancellationError, + promptIdContext, + OutputFormat, + JsonFormatter, + StreamJsonFormatter, + JsonStreamEventType, + uiTelemetryService, + coreEvents, + CoreEvent, + createWorkingStdio, + Scheduler, + ROOT_SCHEDULER_ID, + LegacyAgentSession, + ToolErrorType, + geminiPartsToContentParts, + displayContentToString, + debugLogger, + THINKING_ONLY_COMPRESS_SUGGESTION, + MAX_TOKENS_EXCEEDED_SUGGESTION, + SAFETY_BLOCKED_MESSAGE, + RECITATION_BLOCKED_MESSAGE, + OTHER_BLOCKED_MESSAGE, + TRUE_EMPTY_RESPONSE_MESSAGE, +} from '@google/gemini-cli-core'; + +import type { Part } from '@google/genai'; +import readline from 'node:readline'; +import stripAnsi from 'strip-ansi'; + +import { handleSlashCommand } from './nonInteractiveCliCommands.js'; +import { ConsolePatcher } from './ui/utils/ConsolePatcher.js'; +import { handleAtCommand } from './ui/hooks/atCommandProcessor.js'; +import { handleError, handleToolError } from './utils/errors.js'; +import { TextOutput } from './ui/utils/textOutput.js'; + +interface RunNonInteractiveParams { + config: Config; + settings: LoadedSettings; + input: string; + prompt_id: string; + resumedSessionData?: ResumedSessionData; +} + +/** + * Runs the non-interactive CLI using the LegacyAgentSession. + * + * Programmatic output formats (JSON, STREAM_JSON) use lenient sanitization + * by stripping ANSI escape sequences from messages to ensure clean, + * parseable output for downstream consumers. + */ +export async function runNonInteractive({ + config, + settings, + input, + prompt_id, + resumedSessionData, +}: RunNonInteractiveParams): Promise { + return promptIdContext.run(prompt_id, async () => { + const consolePatcher = new ConsolePatcher({ + stderr: true, + interactive: false, + debugMode: config.getDebugMode(), + onNewMessage: (msg) => { + coreEvents.emitConsoleLog(msg.type, msg.content); + }, + }); + + if (process.env['GEMINI_CLI_ACTIVITY_LOG_TARGET']) { + const { setupInitialActivityLogger } = await import( + './utils/devtoolsService.js' + ); + setupInitialActivityLogger(config); + } + + const { stdout: workingStdout } = createWorkingStdio(); + const textOutput = new TextOutput(workingStdout); + + const handleUserFeedback = (payload: UserFeedbackPayload) => { + const prefix = payload.severity.toUpperCase(); + process.stderr.write(`[${prefix}] ${payload.message}\n`); + if (payload.error && config.getDebugMode()) { + const errorToLog = + payload.error instanceof Error + ? payload.error.stack || payload.error.message + : String(payload.error); + process.stderr.write(`${errorToLog}\n`); + } + }; + + const startTime = Date.now(); + const streamFormatter = + config.getOutputFormat() === OutputFormat.STREAM_JSON + ? new StreamJsonFormatter() + : null; + + const abortController = new AbortController(); + + // Track cancellation state + let isAborting = false; + let cancelMessageTimer: NodeJS.Timeout | null = null; + + // Setup stdin listener for Ctrl+C detection + let stdinWasRaw = false; + let rl: readline.Interface | null = null; + + const setupStdinCancellation = () => { + // Only setup if stdin is a TTY (user can interact) + if (!process.stdin.isTTY) { + return; + } + + // Save original raw mode state + stdinWasRaw = process.stdin.isRaw || false; + + // Enable raw mode to capture individual keypresses + process.stdin.setRawMode(true); + process.stdin.resume(); + + // Setup readline to emit keypress events + rl = readline.createInterface({ + input: process.stdin, + escapeCodeTimeout: 0, + }); + readline.emitKeypressEvents(process.stdin, rl); + + // Listen for Ctrl+C + const keypressHandler = ( + str: string, + key: { name?: string; ctrl?: boolean }, + ) => { + // Detect Ctrl+C: either ctrl+c key combo or raw character code 3 + if ((key && key.ctrl && key.name === 'c') || str === '\u0003') { + // Only handle once + if (isAborting) { + return; + } + + isAborting = true; + + // Only show message if cancellation takes longer than 200ms + // This reduces verbosity for fast cancellations + cancelMessageTimer = setTimeout(() => { + process.stderr.write('\nCancelling...\n'); + }, 200); + + abortController.abort(); + } + }; + + process.stdin.on('keypress', keypressHandler); + }; + + const cleanupStdinCancellation = () => { + // Clear any pending cancel message timer + if (cancelMessageTimer) { + clearTimeout(cancelMessageTimer); + cancelMessageTimer = null; + } + + // Cleanup readline and stdin listeners + if (rl) { + rl.close(); + rl = null; + } + + // Remove keypress listener + process.stdin.removeAllListeners('keypress'); + + // Restore stdin to original state + if (process.stdin.isTTY) { + process.stdin.setRawMode(stdinWasRaw); + process.stdin.pause(); + } + }; + + let errorToHandle: unknown | undefined; + let scheduler: Scheduler | undefined; + let abortSession = () => {}; + try { + consolePatcher.patch(); + + if ( + config.getRawOutput() && + !config.getAcceptRawOutputRisk() && + config.getOutputFormat() === OutputFormat.TEXT + ) { + process.stderr.write( + '[WARNING] --raw-output is enabled. Model output is not sanitized and may contain harmful ANSI sequences (e.g. for phishing or command injection). Use --accept-raw-output-risk to suppress this warning.\n', + ); + } + + // Setup stdin cancellation listener + setupStdinCancellation(); + + coreEvents.on(CoreEvent.UserFeedback, handleUserFeedback); + coreEvents.drainBacklogs(); + + // Handle EPIPE errors when the output is piped to a command that closes early. + process.stdout.on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EPIPE') { + // Exit gracefully if the pipe is closed. + cleanupStdinCancellation(); + consolePatcher.cleanup(); + process.exit(0); + } + }); + + const geminiClient = config.getGeminiClient(); + scheduler = new Scheduler({ + context: config, + messageBus: config.getMessageBus(), + getPreferredEditor: () => undefined, + schedulerId: ROOT_SCHEDULER_ID, + }); + + // Initialize chat. Resume if resume data is passed. + if (resumedSessionData) { + await geminiClient.resumeChat( + convertSessionToClientHistory( + resumedSessionData.conversation.messages, + ), + resumedSessionData, + ); + } + + // Emit init event for streaming JSON + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.INIT, + timestamp: new Date().toISOString(), + session_id: config.getSessionId(), + model: config.getModel(), + }); + } + + let query: Part[] | undefined; + + if (isSlashCommand(input)) { + const slashCommandResult = await handleSlashCommand( + input, + abortController, + config, + settings, + ); + if (slashCommandResult) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + query = slashCommandResult as Part[]; + } + } + + if (!query) { + const { processedQuery, error } = await handleAtCommand({ + query: input, + config, + addItem: (_item, _timestamp) => 0, + onDebugMessage: () => {}, + messageId: Date.now(), + signal: abortController.signal, + escapePastedAtSymbols: false, + }); + if (error || !processedQuery) { + throw new FatalInputError( + error || 'Exiting due to an error processing the @ command.', + ); + } + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + query = processedQuery as Part[]; + } + + // Emit user message event for streaming JSON + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.MESSAGE, + timestamp: new Date().toISOString(), + role: 'user', + content: input, + }); + } + + // Create LegacyAgentSession — owns the agentic loop + const session = new LegacyAgentSession({ + client: geminiClient, + scheduler, + config, + promptId: prompt_id, + }); + + // Wire Ctrl+C to session abort + abortSession = () => { + void session.abort(); + }; + abortController.signal.addEventListener('abort', abortSession); + if (abortController.signal.aborted) { + throw new FatalCancellationError('Operation cancelled.'); + } + + // Start the agentic loop (runs in background) + const { streamId } = await session.send({ + message: { + content: geminiPartsToContentParts(query), + displayContent: input, + }, + }); + if (streamId === null) { + throw new Error( + 'LegacyAgentSession.send() unexpectedly returned no stream for a message send.', + ); + } + + const getTextContent = (parts?: ContentPart[]): string | undefined => { + const text = parts + ?.map((part) => (part.type === 'text' ? part.text : '')) + .join(''); + return text ? text : undefined; + }; + + const emitFinalResult = (errorPayload?: { + type: string; + message: string; + }): void => { + if (streamFormatter) { + const metrics = uiTelemetryService.getMetrics(); + const durationMs = Date.now() - startTime; + streamFormatter.emitEvent({ + type: JsonStreamEventType.RESULT, + timestamp: new Date().toISOString(), + status: errorPayload ? 'error' : 'success', + stats: streamFormatter.convertToStreamStats(metrics, durationMs), + }); + } else if (config.getOutputFormat() === OutputFormat.JSON) { + const formatter = new JsonFormatter(); + const stats = uiTelemetryService.getMetrics(); + textOutput.write( + formatter.format( + config.getSessionId(), + responseText, + stats, + errorPayload, + warnings, + ), + ); + } else { + textOutput.ensureTrailingNewline(); + } + }; + + const reconstructFatalError = (event: AgentEvent<'error'>): Error => { + const errorMeta = event._meta; + const name = + typeof errorMeta?.['errorName'] === 'string' + ? errorMeta['errorName'] + : undefined; + + let errToThrow: Error; + switch (name) { + case 'FatalAuthenticationError': + errToThrow = new FatalAuthenticationError(event.message); + break; + case 'FatalInputError': + errToThrow = new FatalInputError(event.message); + break; + case 'FatalSandboxError': + errToThrow = new FatalSandboxError(event.message); + break; + case 'FatalConfigError': + errToThrow = new FatalConfigError(event.message); + break; + case 'FatalTurnLimitedError': + errToThrow = new FatalTurnLimitedError(event.message); + break; + case 'FatalToolExecutionError': + errToThrow = new FatalToolExecutionError(event.message); + break; + case 'FatalCancellationError': + errToThrow = new FatalCancellationError(event.message); + break; + case 'FatalError': + errToThrow = new FatalError( + event.message, + typeof errorMeta?.['exitCode'] === 'number' + ? errorMeta['exitCode'] + : 1, + ); + break; + default: + errToThrow = new Error(event.message); + if (name) { + Object.defineProperty(errToThrow, 'name', { + value: name, + enumerable: true, + }); + } + break; + } + + if (errorMeta?.['exitCode'] !== undefined) { + Object.defineProperty(errToThrow, 'exitCode', { + value: errorMeta['exitCode'], + enumerable: true, + }); + } + if (errorMeta?.['code'] !== undefined) { + Object.defineProperty(errToThrow, 'code', { + value: errorMeta['code'], + enumerable: true, + }); + } + if (errorMeta?.['status'] !== undefined) { + Object.defineProperty(errToThrow, 'status', { + value: errorMeta['status'], + enumerable: true, + }); + } + return errToThrow; + }; + + // Consume AgentEvents for output formatting + let responseText = ''; + let preToolResponseText: string | undefined; + let streamEnded = false; + const warnings: string[] = []; + for await (const event of session.stream({ streamId })) { + if (streamEnded) break; + switch (event.type) { + case 'message': { + if (event.role === 'agent') { + for (const part of event.content) { + if (part.type === 'text') { + const isRaw = + config.getRawOutput() || config.getAcceptRawOutputRisk(); + const output = isRaw ? part.text : stripAnsi(part.text); + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.MESSAGE, + timestamp: new Date().toISOString(), + role: 'assistant', + content: output, + delta: true, + }); + } else if (config.getOutputFormat() === OutputFormat.JSON) { + responseText += output; + } else { + if (part.text) { + textOutput.write(output); + } + } + } + } + } + break; + } + case 'tool_request': { + if (config.getOutputFormat() === OutputFormat.JSON) { + // Final JSON output should reflect the last assistant answer after + // any tool orchestration, not intermediate pre-tool text. + preToolResponseText = responseText || preToolResponseText; + responseText = ''; + } + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.TOOL_USE, + timestamp: new Date().toISOString(), + tool_name: event.name, + tool_id: event.requestId, + parameters: event.args, + }); + } + break; + } + case 'tool_response': { + textOutput.ensureTrailingNewline(); + if (streamFormatter) { + const display = event.display?.result; + const displayText = displayContentToString(display); + const errorMsg = getTextContent(event.content) ?? 'Tool error'; + streamFormatter.emitEvent({ + type: JsonStreamEventType.TOOL_RESULT, + timestamp: new Date().toISOString(), + tool_id: event.requestId, + status: event.isError ? 'error' : 'success', + output: displayText, + error: event.isError + ? { + type: + typeof event.data?.['errorType'] === 'string' + ? event.data['errorType'] + : 'TOOL_EXECUTION_ERROR', + message: errorMsg, + } + : undefined, + }); + } + if (event.isError) { + const display = event.display?.result; + const displayText = displayContentToString(display); + const errorMsg = getTextContent(event.content) ?? 'Tool error'; + + if (event.data?.['errorType'] === ToolErrorType.STOP_EXECUTION) { + if ( + config.getOutputFormat() === OutputFormat.JSON && + !responseText && + preToolResponseText + ) { + responseText = preToolResponseText; + } + const stopMessage = `Agent execution stopped: ${errorMsg}`; + if (config.getOutputFormat() === OutputFormat.TEXT) { + process.stderr.write(`${stopMessage}\n`); + } + } + + if (event.data?.['errorType'] === ToolErrorType.NO_SPACE_LEFT) { + throw new FatalToolExecutionError( + 'Error executing tool ' + + event.name + + ': ' + + (displayText || errorMsg), + ); + } + handleToolError( + event.name, + new Error(errorMsg), + config, + typeof event.data?.['errorType'] === 'string' + ? event.data['errorType'] + : undefined, + displayText, + ); + } + break; + } + case 'error': { + if (event._meta?.['code'] === 'INVALID_STREAM') { + const errorTypeVal = event._meta?.['errorType']; + const errorType = + typeof errorTypeVal === 'string' ? errorTypeVal : undefined; + + let errorMessage = event.message; + if (errorType === 'NO_RESPONSE_TEXT') { + errorMessage = TRUE_EMPTY_RESPONSE_MESSAGE; + } else if (errorType === 'THINKING_ONLY_RESPONSE') { + errorMessage = THINKING_ONLY_COMPRESS_SUGGESTION; + } else if (errorType === 'MAX_TOKENS_EXCEEDED') { + errorMessage = MAX_TOKENS_EXCEEDED_SUGGESTION; + } else if (errorType === 'SAFETY_BLOCKED') { + errorMessage = SAFETY_BLOCKED_MESSAGE; + } else if (errorType === 'RECITATION_BLOCKED') { + errorMessage = RECITATION_BLOCKED_MESSAGE; + } else if (errorType === 'OTHER_BLOCKED') { + errorMessage = OTHER_BLOCKED_MESSAGE; + } + + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.ERROR, + timestamp: new Date().toISOString(), + severity: 'error', + message: errorMessage, + }); + } else if (config.getOutputFormat() === OutputFormat.TEXT) { + process.stderr.write(`[ERROR] ${errorMessage}\n`); + } + + // Log semantic error telemetry without double-counting requests + uiTelemetryService.recordSemanticValidationError( + geminiClient.getCurrentSequenceModel() ?? config.getModel(), + errorType || 'INVALID_STREAM', + ); + + // If it's a fatal stream error, we should terminate and output final results + emitFinalResult({ + type: 'INVALID_STREAM', + message: errorMessage, + }); + streamEnded = true; + break; + } + + if (event.fatal) { + throw reconstructFatalError(event); + } + + const errorCode = event._meta?.['code']; + + if (errorCode === 'AGENT_EXECUTION_BLOCKED') { + const blockMessage = `Agent execution blocked: ${event.message.trim()}`; + if (config.getOutputFormat() === OutputFormat.TEXT) { + process.stderr.write(`[WARNING] ${blockMessage}\n`); + } else if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.ERROR, + timestamp: new Date().toISOString(), + severity: 'warning', + message: stripAnsi(blockMessage), + }); + } + warnings.push(blockMessage); + break; + } + + const severity = + event.status === 'RESOURCE_EXHAUSTED' ? 'error' : 'warning'; + if (config.getOutputFormat() === OutputFormat.TEXT) { + process.stderr.write(`[WARNING] ${event.message}\n`); + } + if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.ERROR, + timestamp: new Date().toISOString(), + severity, + message: stripAnsi(event.message), + }); + } + warnings.push(event.message); + break; + } + case 'agent_end': { + if (event.reason === 'aborted') { + throw new FatalCancellationError('Operation cancelled.'); + } else if (event.reason === 'max_turns') { + const isConfiguredTurnLimit = + typeof event.data?.['maxTurns'] === 'number' || + typeof event.data?.['turnCount'] === 'number'; + + if (isConfiguredTurnLimit) { + throw new FatalTurnLimitedError( + 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', + ); + } else if (streamFormatter) { + streamFormatter.emitEvent({ + type: JsonStreamEventType.ERROR, + timestamp: new Date().toISOString(), + severity: 'error', + message: 'Maximum session turns exceeded', + }); + } + } + + const stopMessage = + typeof event.data?.['message'] === 'string' + ? event.data['message'] + : ''; + if (stopMessage && config.getOutputFormat() === OutputFormat.TEXT) { + process.stderr.write(`Agent execution stopped: ${stopMessage}\n`); + } + + emitFinalResult(); + streamEnded = true; + break; + } + case 'initialize': + case 'session_update': + case 'agent_start': + case 'tool_update': + case 'elicitation_request': + case 'elicitation_response': + case 'usage': + case 'custom': + // Explicitly ignore these non-interactive events + break; + default: + debugLogger.error('Unknown agent event type:', event); + event satisfies never; + break; + } + } + } catch (error) { + errorToHandle = error; + } finally { + // Cleanup stdin cancellation before other cleanup + cleanupStdinCancellation(); + abortController.signal.removeEventListener('abort', abortSession); + + scheduler?.dispose(); + consolePatcher.cleanup(); + coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback); + } + + if (errorToHandle) { + handleError(errorToHandle, config); + } + }); +} diff --git a/packages/cli/src/nonInteractiveCliCommands.ts b/packages/cli/src/nonInteractiveCliCommands.ts new file mode 100644 index 0000000000000000000000000000000000000000..35cf5105ab3f34bb2b4a0832171c695bd436668d --- /dev/null +++ b/packages/cli/src/nonInteractiveCliCommands.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { PartListUnion } from '@google/genai'; +import { parseSlashCommand } from './utils/commands.js'; +import { + FatalInputError, + Logger, + uiTelemetryService, + type Config, +} from '@google/gemini-cli-core'; +import { CommandService } from './services/CommandService.js'; +import { BuiltinCommandLoader } from './services/BuiltinCommandLoader.js'; +import { FileCommandLoader } from './services/FileCommandLoader.js'; +import { McpPromptLoader } from './services/McpPromptLoader.js'; +import type { CommandContext } from './ui/commands/types.js'; +import { createNonInteractiveUI } from './ui/noninteractive/nonInteractiveUi.js'; +import type { LoadedSettings } from './config/settings.js'; +import type { SessionStatsState } from './ui/contexts/SessionContext.js'; + +/** + * Processes a slash command in a non-interactive environment. + * + * @returns A Promise that resolves to `PartListUnion` if a valid command is + * found and results in a prompt, or `undefined` otherwise. + * @throws {FatalInputError} if the command result is not supported in + * non-interactive mode. + */ +export const handleSlashCommand = async ( + rawQuery: string, + abortController: AbortController, + config: Config, + settings: LoadedSettings, +): Promise => { + const trimmed = rawQuery.trim(); + if (!trimmed.startsWith('/')) { + return; + } + + const commandService = await CommandService.create( + [ + new BuiltinCommandLoader(config), + new McpPromptLoader(config), + new FileCommandLoader(config), + ], + abortController.signal, + ); + const commands = commandService.getCommands(); + + const { commandToExecute, args } = parseSlashCommand(rawQuery, commands); + + if (commandToExecute) { + if (commandToExecute.action) { + // Not used by custom commands but may be in the future. + const sessionStats: SessionStatsState = { + sessionId: config?.getSessionId(), + sessionStartTime: new Date(), + metrics: uiTelemetryService.getMetrics(), + lastPromptTokenCount: 0, + promptCount: 1, + }; + + const logger = new Logger(config?.getSessionId() || '', config?.storage); + + const commandContext: CommandContext = { + services: { + agentContext: config, + settings, + git: undefined, + logger, + }, + ui: createNonInteractiveUI(), + session: { + stats: sessionStats, + sessionShellAllowlist: new Set(), + }, + invocation: { + raw: trimmed, + name: commandToExecute.name, + args, + }, + }; + + const result = await commandToExecute.action(commandContext, args); + + if (result) { + switch (result.type) { + case 'submit_prompt': + return result.content; + case 'confirm_shell_commands': + // This result indicates a command attempted to confirm shell commands. + // However note that currently, ShellTool is excluded in non-interactive + // mode unless 'YOLO mode' is active, so confirmation actually won't + // occur because of YOLO mode. + // This ensures that if a command *does* request confirmation (e.g. + // in the future with more granular permissions), it's handled appropriately. + throw new FatalInputError( + 'Exiting due to a confirmation prompt requested by the command.', + ); + default: + throw new FatalInputError( + 'Exiting due to command result that is not supported in non-interactive mode.', + ); + } + } + } + } + + return; +}; diff --git a/packages/cli/src/output-redirection.test.ts b/packages/cli/src/output-redirection.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8baa1127f5d58d5ab0696b76e4b91d1aaf811c79 --- /dev/null +++ b/packages/cli/src/output-redirection.test.ts @@ -0,0 +1,100 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { initializeOutputListenersAndFlush } from './gemini.js'; +import { coreEvents, CoreEvent, type Config } from '@google/gemini-cli-core'; + +// Mock core dependencies +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + writeToStdout: vi.fn(), + writeToStderr: vi.fn(), + }; +}); + +import { writeToStdout, writeToStderr } from '@google/gemini-cli-core'; + +describe('Output Redirection', () => { + beforeEach(() => { + vi.clearAllMocks(); + // Clear listeners to simulate a clean state for each test + coreEvents.removeAllListeners(); + }); + + afterEach(() => { + coreEvents.removeAllListeners(); + }); + + it('should redirect buffered stdout to stderr when output format is json', () => { + const mockConfig = { + getOutputFormat: () => 'json', + } as unknown as Config; + + // Simulate buffered output + coreEvents.emitOutput(false, 'informational message'); + coreEvents.emitOutput(true, 'error message'); + + // Initialize listeners and flush + initializeOutputListenersAndFlush(mockConfig); + + // Verify informational message was forced to stderr + expect(writeToStderr).toHaveBeenCalledWith( + 'informational message', + undefined, + ); + expect(writeToStderr).toHaveBeenCalledWith('error message', undefined); + expect(writeToStdout).not.toHaveBeenCalled(); + }); + + it('should NOT redirect buffered stdout to stderr when output format is NOT json', () => { + const mockConfig = { + getOutputFormat: () => 'text', + } as unknown as Config; + + // Simulate buffered output + coreEvents.emitOutput(false, 'regular message'); + + // Initialize listeners and flush + initializeOutputListenersAndFlush(mockConfig); + + // Verify regular message went to stdout + expect(writeToStdout).toHaveBeenCalledWith('regular message', undefined); + expect(writeToStderr).not.toHaveBeenCalled(); + }); + + it('should NOT force stdout to stderr when config is undefined (early init/version)', () => { + // Simulate buffered output during early init + coreEvents.emitOutput(false, 'early init message'); + + // Initialize with undefined config + initializeOutputListenersAndFlush(undefined); + + // Verify it went to stdout (default behavior) + expect(writeToStdout).toHaveBeenCalledWith('early init message', undefined); + expect(writeToStderr).not.toHaveBeenCalled(); + }); + + it('should attach ConsoleLog and UserFeedback listeners even if Output already has one', () => { + // Manually attach an Output listener + coreEvents.on(CoreEvent.Output, vi.fn()); + + // Initialize - should still attach ConsoleLog and UserFeedback defaults + initializeOutputListenersAndFlush(undefined); + + // Simulate events + coreEvents.emitConsoleLog('info', 'stray log'); + coreEvents.emitFeedback('info', 'stray feedback'); + + // Draining happens inside initializeOutputListenersAndFlush for existing backlog, + // but here we check the newly attached listeners for immediate emission. + expect(writeToStderr).toHaveBeenCalledWith('stray log\n'); + expect(writeToStderr).toHaveBeenCalledWith('stray feedback\n'); + }); +}); diff --git a/packages/cli/src/utils/activityLogger.test.ts b/packages/cli/src/utils/activityLogger.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d73c0d19f1a161eb4eb0e4a3d50e48a8802b3eb --- /dev/null +++ b/packages/cli/src/utils/activityLogger.test.ts @@ -0,0 +1,226 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { ActivityLogger, type NetworkLog } from './activityLogger.js'; +import type { ConsoleLogPayload } from '@google/gemini-cli-core'; + +describe('ActivityLogger', () => { + let logger: ActivityLogger; + + beforeEach(() => { + logger = ActivityLogger.getInstance(); + logger.clearBufferedLogs(); + }); + + it('buffers the last 10 requests with all their events grouped', () => { + // Emit 15 requests, each with an initial + response event + for (let i = 0; i < 15; i++) { + const initial: NetworkLog = { + id: `req-${i}`, + timestamp: i * 2, + method: 'GET', + url: 'http://example.com', + headers: {}, + pending: true, + }; + logger.emitNetworkEvent(initial); + logger.emitNetworkEvent({ + id: `req-${i}`, + pending: false, + response: { + status: 200, + headers: {}, + body: 'ok', + durationMs: 10, + }, + }); + } + + const logs = logger.getBufferedLogs(); + // 10 requests * 2 events each = 20 events + expect(logs.network.length).toBe(20); + // Oldest kept should be req-5 (first 5 evicted) + expect(logs.network[0].id).toBe('req-5'); + // Last should be req-14 + expect(logs.network[19].id).toBe('req-14'); + }); + + it('keeps all chunk events for a buffered request', () => { + // One request with many chunks + logger.emitNetworkEvent({ + id: 'chunked', + timestamp: 1, + method: 'POST', + url: 'http://example.com', + headers: {}, + pending: true, + }); + for (let i = 0; i < 5; i++) { + logger.emitNetworkEvent({ + id: 'chunked', + pending: true, + chunk: { index: i, data: `chunk-${i}`, timestamp: 2 + i }, + }); + } + logger.emitNetworkEvent({ + id: 'chunked', + pending: false, + response: { status: 200, headers: {}, body: 'done', durationMs: 50 }, + }); + + const logs = logger.getBufferedLogs(); + // 1 initial + 5 chunks + 1 response = 7 events, all for 'chunked' + expect(logs.network.length).toBe(7); + expect(logs.network.every((l) => l.id === 'chunked')).toBe(true); + }); + + it('buffers only the last 10 console logs', () => { + for (let i = 0; i < 15; i++) { + const log: ConsoleLogPayload = { content: `log-${i}`, type: 'log' }; + logger.logConsole(log); + } + + const logs = logger.getBufferedLogs(); + expect(logs.console.length).toBe(10); + expect(logs.console[0].content).toBe('log-5'); + expect(logs.console[9].content).toBe('log-14'); + }); + + it('getBufferedLogs is non-destructive', () => { + logger.logConsole({ content: 'test', type: 'log' }); + const first = logger.getBufferedLogs(); + const second = logger.getBufferedLogs(); + expect(first.console.length).toBe(1); + expect(second.console.length).toBe(1); + }); + + it('clearBufferedLogs empties both buffers', () => { + logger.logConsole({ content: 'test', type: 'log' }); + logger.emitNetworkEvent({ + id: 'r1', + timestamp: 1, + method: 'GET', + url: 'http://example.com', + headers: {}, + }); + logger.clearBufferedLogs(); + const logs = logger.getBufferedLogs(); + expect(logs.console.length).toBe(0); + expect(logs.network.length).toBe(0); + }); + + it('drainBufferedLogs returns and clears atomically', () => { + logger.logConsole({ content: 'drain-test', type: 'log' }); + logger.emitNetworkEvent({ + id: 'r1', + timestamp: 1, + method: 'GET', + url: 'http://example.com', + headers: {}, + }); + + const drained = logger.drainBufferedLogs(); + expect(drained.console.length).toBe(1); + expect(drained.network.length).toBe(1); + + // Buffer should now be empty + const after = logger.getBufferedLogs(); + expect(after.console.length).toBe(0); + expect(after.network.length).toBe(0); + }); + + it('preserves headers and method from Request object when intercepting fetch', async () => { + const originalFetch = global.fetch; + + const mockFetch = vi.fn().mockImplementation(() => + Promise.resolve({ + status: 200, + headers: new Headers(), + body: null, + clone: () => ({ + body: null, + status: 200, + headers: new Headers(), + text: async () => 'ok', + json: async () => ({}), + }), + } as unknown as Response), + ); + + global.fetch = mockFetch; + + try { + // @ts-expect-error - accessing private property for testing + logger.isInterceptionEnabled = false; + logger.enable(); + + const request = new Request('https://api.example.com/data', { + headers: { Authorization: 'Bearer test-token' }, + method: 'POST', + }); + + await global.fetch(request); + + expect(mockFetch).toHaveBeenCalled(); + const [, calledInit] = mockFetch.mock.calls[0]; + + expect(calledInit?.headers).toBeDefined(); + const headers = new Headers(calledInit?.headers as HeadersInit); + expect(headers.get('Authorization')).toBe('Bearer test-token'); + expect(headers.has('x-activity-request-id')).toBe(true); + expect(calledInit?.method).toBe('POST'); + } finally { + global.fetch = originalFetch; + // @ts-expect-error - reset private property + logger.isInterceptionEnabled = false; + } + }); + + it('replaces Request headers with init headers (Fetch spec compliance)', async () => { + const originalFetch = global.fetch; + const mockFetch = vi.fn().mockImplementation(() => + Promise.resolve({ + status: 200, + headers: new Headers(), + body: null, + clone: () => ({ + body: null, + status: 200, + headers: new Headers(), + text: async () => 'ok', + }), + } as unknown as Response), + ); + global.fetch = mockFetch; + + try { + // @ts-expect-error - accessing private property for testing + logger.isInterceptionEnabled = false; + logger.enable(); + + const request = new Request('https://api.example.com/data', { + headers: { 'X-Old': 'old-value', 'X-Shared': 'old-shared' }, + }); + + await global.fetch(request, { + headers: { 'X-New': 'new-value', 'X-Shared': 'new-shared' }, + }); + + const [, calledInit] = mockFetch.mock.calls[0]; + const headers = new Headers(calledInit?.headers as HeadersInit); + + expect(headers.get('X-New')).toBe('new-value'); + expect(headers.get('X-Shared')).toBe('new-shared'); + expect(headers.has('X-Old')).toBe(false); + expect(headers.has('x-activity-request-id')).toBe(true); + } finally { + global.fetch = originalFetch; + // @ts-expect-error - reset private property + logger.isInterceptionEnabled = false; + } + }); +}); diff --git a/packages/cli/src/utils/agentUtils.test.ts b/packages/cli/src/utils/agentUtils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e62fb7f1f28d606d1949bd5ee105aca1b2381aa9 --- /dev/null +++ b/packages/cli/src/utils/agentUtils.test.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; + +vi.mock('../config/settings.js', () => ({ + SettingScope: { + User: 'User', + Workspace: 'Workspace', + System: 'System', + SystemDefaults: 'SystemDefaults', + }, +})); + +import { renderAgentActionFeedback } from './agentUtils.js'; +import { SettingScope } from '../config/settings.js'; +import type { AgentActionResult } from './agentSettings.js'; + +describe('agentUtils', () => { + describe('renderAgentActionFeedback', () => { + const mockFormatScope = (label: string, path: string) => + `[${label}:${path}]`; + + it('should return error message if status is error', () => { + const result: AgentActionResult = { + status: 'error', + agentName: 'my-agent', + action: 'enable', + modifiedScopes: [], + alreadyInStateScopes: [], + error: 'Something went wrong', + }; + expect(renderAgentActionFeedback(result, mockFormatScope)).toBe( + 'Something went wrong', + ); + }); + + it('should return default error message if status is error and no error message provided', () => { + const result: AgentActionResult = { + status: 'error', + agentName: 'my-agent', + action: 'enable', + modifiedScopes: [], + alreadyInStateScopes: [], + }; + expect(renderAgentActionFeedback(result, mockFormatScope)).toBe( + 'An error occurred while attempting to enable agent "my-agent".', + ); + }); + + it('should return no-op message for enable', () => { + const result: AgentActionResult = { + status: 'no-op', + agentName: 'my-agent', + action: 'enable', + modifiedScopes: [], + alreadyInStateScopes: [], + }; + expect(renderAgentActionFeedback(result, mockFormatScope)).toBe( + 'Agent "my-agent" is already enabled.', + ); + }); + + it('should return no-op message for disable', () => { + const result: AgentActionResult = { + status: 'no-op', + agentName: 'my-agent', + action: 'disable', + modifiedScopes: [], + alreadyInStateScopes: [], + }; + expect(renderAgentActionFeedback(result, mockFormatScope)).toBe( + 'Agent "my-agent" is already disabled.', + ); + }); + + it('should return success message for enable (single scope)', () => { + const result: AgentActionResult = { + status: 'success', + agentName: 'my-agent', + action: 'enable', + modifiedScopes: [ + { scope: SettingScope.User, path: '/path/to/user/settings' }, + ], + alreadyInStateScopes: [], + }; + expect(renderAgentActionFeedback(result, mockFormatScope)).toBe( + 'Agent "my-agent" enabled by setting it to enabled in [user:/path/to/user/settings] settings.', + ); + }); + + it('should return success message for enable (two scopes)', () => { + const result: AgentActionResult = { + status: 'success', + agentName: 'my-agent', + action: 'enable', + modifiedScopes: [ + { scope: SettingScope.User, path: '/path/to/user/settings' }, + ], + alreadyInStateScopes: [ + { + scope: SettingScope.Workspace, + path: '/path/to/workspace/settings', + }, + ], + }; + expect(renderAgentActionFeedback(result, mockFormatScope)).toBe( + 'Agent "my-agent" enabled by setting it to enabled in [user:/path/to/user/settings] and [project:/path/to/workspace/settings] settings.', + ); + }); + + it('should return success message for disable (single scope)', () => { + const result: AgentActionResult = { + status: 'success', + agentName: 'my-agent', + action: 'disable', + modifiedScopes: [ + { scope: SettingScope.User, path: '/path/to/user/settings' }, + ], + alreadyInStateScopes: [], + }; + expect(renderAgentActionFeedback(result, mockFormatScope)).toBe( + 'Agent "my-agent" disabled by setting it to disabled in [user:/path/to/user/settings] settings.', + ); + }); + + it('should return success message for disable (two scopes)', () => { + const result: AgentActionResult = { + status: 'success', + agentName: 'my-agent', + action: 'disable', + modifiedScopes: [ + { scope: SettingScope.User, path: '/path/to/user/settings' }, + ], + alreadyInStateScopes: [ + { + scope: SettingScope.Workspace, + path: '/path/to/workspace/settings', + }, + ], + }; + expect(renderAgentActionFeedback(result, mockFormatScope)).toBe( + 'Agent "my-agent" is now disabled in both [user:/path/to/user/settings] and [project:/path/to/workspace/settings] settings.', + ); + }); + }); +}); diff --git a/packages/cli/src/utils/agentUtils.ts b/packages/cli/src/utils/agentUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..4bcee796d1f6467441603070fb53ec7bc172f508 --- /dev/null +++ b/packages/cli/src/utils/agentUtils.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SettingScope } from '../config/settings.js'; +import type { AgentActionResult } from './agentSettings.js'; + +/** + * Shared logic for building the core agent action message while allowing the + * caller to control how each scope and its path are rendered (e.g., bolding or + * dimming). + * + * This function ONLY returns the description of what happened. It is up to the + * caller to append any interface-specific guidance. + */ +export function renderAgentActionFeedback( + result: AgentActionResult, + formatScope: (label: string, path: string) => string, +): string { + const { agentName, action, status, error } = result; + + if (status === 'error') { + return ( + error || + `An error occurred while attempting to ${action} agent "${agentName}".` + ); + } + + if (status === 'no-op') { + return `Agent "${agentName}" is already ${action === 'enable' ? 'enabled' : 'disabled'}.`; + } + + const isEnable = action === 'enable'; + const actionVerb = isEnable ? 'enabled' : 'disabled'; + const preposition = isEnable + ? 'by setting it to enabled in' + : 'by setting it to disabled in'; + + const formatScopeItem = (s: { scope: SettingScope; path: string }) => { + const label = + s.scope === SettingScope.Workspace ? 'project' : s.scope.toLowerCase(); + return formatScope(label, s.path); + }; + + const totalAffectedScopes = [ + ...result.modifiedScopes, + ...result.alreadyInStateScopes, + ]; + + if (totalAffectedScopes.length === 2) { + const s1 = formatScopeItem(totalAffectedScopes[0]); + const s2 = formatScopeItem(totalAffectedScopes[1]); + + if (isEnable) { + return `Agent "${agentName}" ${actionVerb} ${preposition} ${s1} and ${s2} settings.`; + } else { + return `Agent "${agentName}" is now disabled in both ${s1} and ${s2} settings.`; + } + } + + const s = formatScopeItem(totalAffectedScopes[0]); + return `Agent "${agentName}" ${actionVerb} ${preposition} ${s} settings.`; +} diff --git a/packages/cli/src/utils/autoMemory.ts b/packages/cli/src/utils/autoMemory.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d4a04f632ba7b53825b7a4402c02d12c3f26e46 --- /dev/null +++ b/packages/cli/src/utils/autoMemory.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + debugLogger, + startMemoryService, + type Config, +} from '@google/gemini-cli-core'; + +export function startAutoMemoryIfEnabled(config: Config): void { + if (!config.isAutoMemoryEnabled()) { + return; + } + + startMemoryService(config).catch((e) => { + debugLogger.error('Failed to start memory service:', e); + }); +} diff --git a/packages/cli/src/utils/cleanup.ts b/packages/cli/src/utils/cleanup.ts new file mode 100644 index 0000000000000000000000000000000000000000..2f18bdee30f32a9230146387063acefd0e33eb7f --- /dev/null +++ b/packages/cli/src/utils/cleanup.ts @@ -0,0 +1,197 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { promises as fs } from 'node:fs'; +import { join } from 'node:path'; +import { + Storage, + shutdownTelemetry, + isTelemetrySdkInitialized, + ExitCodes, + resetBrowserSession, +} from '@google/gemini-cli-core'; +import type { Config } from '@google/gemini-cli-core'; + +const cleanupFunctions: Array<(() => void) | (() => Promise)> = []; +const syncCleanupFunctions: Array<() => void> = []; +let configForTelemetry: Config | null = null; +let isShuttingDown = false; + +export function registerCleanup(fn: (() => void) | (() => Promise)) { + cleanupFunctions.push(fn); +} + +export function removeCleanup(fn: (() => void) | (() => Promise)) { + const index = cleanupFunctions.indexOf(fn); + if (index !== -1) { + cleanupFunctions.splice(index, 1); + } +} + +export function registerSyncCleanup(fn: () => void) { + syncCleanupFunctions.push(fn); +} + +export function removeSyncCleanup(fn: () => void) { + const index = syncCleanupFunctions.indexOf(fn); + if (index !== -1) { + syncCleanupFunctions.splice(index, 1); + } +} + +/** + * Resets the internal cleanup state for testing purposes. + * This allows tests to run in isolation without vi.resetModules(). + */ +export function resetCleanupForTesting() { + cleanupFunctions.length = 0; + syncCleanupFunctions.length = 0; + configForTelemetry = null; + isShuttingDown = false; +} + +export function runSyncCleanup() { + for (const fn of syncCleanupFunctions) { + try { + fn(); + } catch { + // Ignore errors during cleanup. + } + } + syncCleanupFunctions.length = 0; +} + +/** + * Register the config instance for telemetry shutdown. + * This must be called early in the application lifecycle. + */ +export function registerTelemetryConfig(config: Config) { + configForTelemetry = config; +} + +export async function runExitCleanup() { + // drain stdin to prevent printing garbage on exit + // https://github.com/google-gemini/gemini-cli/issues/16801 + await drainStdin(); + + runSyncCleanup(); + for (const fn of cleanupFunctions) { + try { + await fn(); + } catch { + // Ignore errors during cleanup. + } + } + cleanupFunctions.length = 0; // Clear the array + + // Close persistent browser sessions before disposing config + try { + await resetBrowserSession(); + } catch { + // Ignore errors during browser cleanup + } + + if (configForTelemetry) { + try { + await configForTelemetry.dispose(); + } catch { + // Ignore errors during disposal + } + } + + // IMPORTANT: Shutdown telemetry AFTER all other cleanup functions have run + // This ensures SessionEnd hooks and other telemetry are properly flushed + if (configForTelemetry && isTelemetrySdkInitialized()) { + try { + await shutdownTelemetry(configForTelemetry); + } catch { + // Ignore errors during telemetry shutdown + } + } +} + +async function drainStdin() { + if (!process.stdin?.isTTY) return; + // Resume stdin and attach a no-op listener to drain the buffer. + // We use removeAllListeners to ensure we don't trigger other handlers. + process.stdin + .resume() + .removeAllListeners('data') + .on('data', () => {}); + // Give it a moment to flush the OS buffer. + await new Promise((resolve) => setTimeout(resolve, 50)); +} + +/** + * Gracefully shuts down the process, ensuring cleanup runs exactly once. + * Guards against concurrent shutdown from signals (SIGHUP, SIGTERM, SIGINT) + * and TTY loss detection racing each other. + * + * @see https://github.com/google-gemini/gemini-cli/issues/15874 + */ +async function gracefulShutdown(_reason: string) { + if (isShuttingDown) { + return; + } + isShuttingDown = true; + + await runExitCleanup(); + process.exit(ExitCodes.SUCCESS); +} + +export function setupSignalHandlers() { + process.on('SIGHUP', () => gracefulShutdown('SIGHUP')); + process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); + process.on('SIGINT', () => gracefulShutdown('SIGINT')); +} + +export function setupTtyCheck(): () => void { + let intervalId: ReturnType | null = null; + let isCheckingTty = false; + + intervalId = setInterval(async () => { + if (isCheckingTty || isShuttingDown) { + return; + } + + if (process.env['SANDBOX']) { + return; + } + + if (!process.stdin.isTTY && !process.stdout.isTTY) { + isCheckingTty = true; + + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + + await gracefulShutdown('TTY loss'); + } + }, 5000); + + // Don't keep the process alive just for this interval + intervalId.unref(); + + return () => { + if (intervalId) { + clearInterval(intervalId); + intervalId = null; + } + }; +} + +export async function cleanupCheckpoints() { + const storage = new Storage(process.cwd()); + await storage.initialize(); + const tempDir = storage.getProjectTempDir(); + const checkpointsDir = join(tempDir, 'checkpoints'); + try { + await fs.rm(checkpointsDir, { recursive: true, force: true }); + } catch { + // Ignore errors if the directory doesn't exist or fails to delete. + } +} diff --git a/packages/cli/src/utils/commands.test.ts b/packages/cli/src/utils/commands.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a201962c9a50cc5275fef5ce12a9ce1594cf3c0f --- /dev/null +++ b/packages/cli/src/utils/commands.test.ts @@ -0,0 +1,241 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { parseSlashCommand } from './commands.js'; +import { CommandKind, type SlashCommand } from '../ui/commands/types.js'; + +// Mock command structure for testing +const mockCommands: readonly SlashCommand[] = [ + { + name: 'help', + description: 'Show help', + action: async () => {}, + kind: CommandKind.BUILT_IN, + }, + { + name: 'commit', + description: 'Commit changes', + action: async () => {}, + kind: CommandKind.USER_FILE, + }, + { + name: 'memory', + description: 'Manage memory', + altNames: ['mem'], + subCommands: [ + { + name: 'list', + description: 'List memory files', + action: async () => {}, + kind: CommandKind.BUILT_IN, + }, + { + name: 'clear', + description: 'Clear memory', + altNames: ['c'], + action: async () => {}, + kind: CommandKind.BUILT_IN, + }, + ], + kind: CommandKind.BUILT_IN, + }, +]; + +describe('parseSlashCommand', () => { + it('should parse a simple command without arguments', () => { + const result = parseSlashCommand('/help', mockCommands); + expect(result.commandToExecute?.name).toBe('help'); + expect(result.args).toBe(''); + expect(result.canonicalPath).toEqual(['help']); + }); + + it('should parse a simple command with arguments', () => { + const result = parseSlashCommand( + '/commit -m "Initial commit"', + mockCommands, + ); + expect(result.commandToExecute?.name).toBe('commit'); + expect(result.args).toBe('-m "Initial commit"'); + expect(result.canonicalPath).toEqual(['commit']); + }); + + it('should parse a subcommand', () => { + const result = parseSlashCommand('/memory list', mockCommands); + expect(result.commandToExecute?.name).toBe('list'); + expect(result.args).toBe(''); + expect(result.canonicalPath).toEqual(['memory', 'list']); + }); + + it('should parse a subcommand with arguments', () => { + const result = parseSlashCommand( + '/memory list some important data', + mockCommands, + ); + expect(result.commandToExecute?.name).toBe('list'); + expect(result.args).toBe('some important data'); + expect(result.canonicalPath).toEqual(['memory', 'list']); + }); + + it('should handle a command alias', () => { + const result = parseSlashCommand('/mem list some data', mockCommands); + expect(result.commandToExecute?.name).toBe('list'); + expect(result.args).toBe('some data'); + expect(result.canonicalPath).toEqual(['memory', 'list']); + }); + + it('should handle a subcommand alias', () => { + const result = parseSlashCommand('/memory c', mockCommands); + expect(result.commandToExecute?.name).toBe('clear'); + expect(result.args).toBe(''); + expect(result.canonicalPath).toEqual(['memory', 'clear']); + }); + + it('should return undefined for an unknown command', () => { + const result = parseSlashCommand('/unknown', mockCommands); + expect(result.commandToExecute).toBeUndefined(); + expect(result.args).toBe('unknown'); + expect(result.canonicalPath).toEqual([]); + }); + + it('should return the parent command if subcommand is unknown', () => { + const result = parseSlashCommand( + '/memory unknownsub some args', + mockCommands, + ); + expect(result.commandToExecute?.name).toBe('memory'); + expect(result.args).toBe('unknownsub some args'); + expect(result.canonicalPath).toEqual(['memory']); + }); + + it('should handle extra whitespace', () => { + const result = parseSlashCommand( + ' /memory list some data ', + mockCommands, + ); + expect(result.commandToExecute?.name).toBe('list'); + expect(result.args).toBe('some data'); + expect(result.canonicalPath).toEqual(['memory', 'list']); + }); + + it('should return undefined if query does not start with a slash', () => { + const result = parseSlashCommand('help', mockCommands); + expect(result.commandToExecute).toBeUndefined(); + }); + + it('should handle an empty query', () => { + const result = parseSlashCommand('', mockCommands); + expect(result.commandToExecute).toBeUndefined(); + }); + + it('should handle a query with only a slash', () => { + const result = parseSlashCommand('/', mockCommands); + expect(result.commandToExecute).toBeUndefined(); + expect(result.args).toBe(''); + expect(result.canonicalPath).toEqual([]); + }); + + describe('backtracking', () => { + const backtrackingCommands: readonly SlashCommand[] = [ + { + name: 'parent', + description: 'Parent command', + kind: CommandKind.BUILT_IN, + action: async () => {}, + subCommands: [ + { + name: 'notakes', + description: 'Subcommand that does not take arguments', + kind: CommandKind.BUILT_IN, + takesArgs: false, + action: async () => {}, + }, + { + name: 'takes', + description: 'Subcommand that takes arguments', + kind: CommandKind.BUILT_IN, + takesArgs: true, + action: async () => {}, + }, + ], + }, + ]; + + it('should backtrack to parent if subcommand has takesArgs: false and args are provided', () => { + const result = parseSlashCommand( + '/parent notakes some prompt', + backtrackingCommands, + ); + expect(result.commandToExecute?.name).toBe('parent'); + expect(result.args).toBe('notakes some prompt'); + expect(result.canonicalPath).toEqual(['parent']); + }); + + it('should NOT backtrack if subcommand has takesArgs: false but NO args are provided', () => { + const result = parseSlashCommand('/parent notakes', backtrackingCommands); + expect(result.commandToExecute?.name).toBe('notakes'); + expect(result.args).toBe(''); + expect(result.canonicalPath).toEqual(['parent', 'notakes']); + }); + + it('should NOT backtrack if subcommand has takesArgs: true and args are provided', () => { + const result = parseSlashCommand( + '/parent takes some args', + backtrackingCommands, + ); + expect(result.commandToExecute?.name).toBe('takes'); + expect(result.args).toBe('some args'); + expect(result.canonicalPath).toEqual(['parent', 'takes']); + }); + + it('should NOT backtrack if parent has NO action', () => { + const noActionCommands: readonly SlashCommand[] = [ + { + name: 'parent', + description: 'Parent without action', + kind: CommandKind.BUILT_IN, + subCommands: [ + { + name: 'notakes', + description: 'Subcommand without args', + kind: CommandKind.BUILT_IN, + takesArgs: false, + action: async () => {}, + }, + ], + }, + ]; + const result = parseSlashCommand( + '/parent notakes some args', + noActionCommands, + ); + // It stays with the subcommand because parent can't handle it + expect(result.commandToExecute?.name).toBe('notakes'); + expect(result.args).toBe('some args'); + expect(result.canonicalPath).toEqual(['parent', 'notakes']); + }); + + it('should NOT backtrack if subcommand is NOT marked with takesArgs: false', () => { + const result = parseSlashCommand( + '/parent takes some args', + backtrackingCommands, + ); + expect(result.commandToExecute?.name).toBe('takes'); + expect(result.args).toBe('some args'); + expect(result.canonicalPath).toEqual(['parent', 'takes']); + }); + + it('should backtrack if subcommand has takesArgs: false and args are provided (like /plan copy foo)', () => { + const result = parseSlashCommand( + '/parent notakes some prompt', + backtrackingCommands, + ); + expect(result.commandToExecute?.name).toBe('parent'); + expect(result.args).toBe('notakes some prompt'); + expect(result.canonicalPath).toEqual(['parent']); + }); + }); +}); diff --git a/packages/cli/src/utils/commentJson.test.ts b/packages/cli/src/utils/commentJson.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e92b19cb63774e1e9e8932208df290dfdd08d1a8 --- /dev/null +++ b/packages/cli/src/utils/commentJson.test.ts @@ -0,0 +1,370 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { updateSettingsFilePreservingFormat } from './commentJson.js'; +import { coreEvents } from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', () => ({ + coreEvents: { + emitFeedback: vi.fn(), + }, +})); + +describe('commentJson', () => { + let tempDir: string; + let testFilePath: string; + + beforeEach(() => { + // Create a temporary directory for test files + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'preserve-format-test-')); + testFilePath = path.join(tempDir, 'settings.json'); + }); + + afterEach(() => { + // Clean up temporary directory + if (fs.existsSync(tempDir)) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + describe('updateSettingsFilePreservingFormat', () => { + it('should preserve comments when updating settings', () => { + const originalContent = `{ + // Model configuration + "model": "gemini-2.5-pro", + "ui": { + // Theme setting + "theme": "dark" + } + }`; + + fs.writeFileSync(testFilePath, originalContent, 'utf-8'); + + updateSettingsFilePreservingFormat(testFilePath, { + model: 'gemini-2.5-flash', + ui: { + theme: 'dark', + }, + }); + + const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); + + expect(updatedContent).toContain('// Model configuration'); + expect(updatedContent).toContain('// Theme setting'); + expect(updatedContent).toContain('"model": "gemini-2.5-flash"'); + expect(updatedContent).toContain('"theme": "dark"'); + }); + + it('should handle nested object updates', () => { + const originalContent = `{ + "ui": { + "theme": "dark", + "showLineNumbers": true + } + }`; + + fs.writeFileSync(testFilePath, originalContent, 'utf-8'); + + updateSettingsFilePreservingFormat(testFilePath, { + ui: { + theme: 'light', + showLineNumbers: true, + }, + }); + + const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); + expect(updatedContent).toContain('"theme": "light"'); + expect(updatedContent).toContain('"showLineNumbers": true'); + }); + + it('should add new fields while preserving existing structure', () => { + const originalContent = `{ + // Existing config + "model": "gemini-2.5-pro" + }`; + + fs.writeFileSync(testFilePath, originalContent, 'utf-8'); + + updateSettingsFilePreservingFormat(testFilePath, { + model: 'gemini-2.5-pro', + newField: 'newValue', + }); + + const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); + expect(updatedContent).toContain('// Existing config'); + expect(updatedContent).toContain('"newField": "newValue"'); + }); + + it('should create file if it does not exist', () => { + updateSettingsFilePreservingFormat(testFilePath, { + model: 'gemini-2.5-pro', + }); + + expect(fs.existsSync(testFilePath)).toBe(true); + const content = fs.readFileSync(testFilePath, 'utf-8'); + expect(content).toContain('"model": "gemini-2.5-pro"'); + }); + + it('should handle complex real-world scenario', () => { + const complexContent = `{ + // Settings + "model": "gemini-2.5-pro", + "mcpServers": { + // Active server + "context7": { + "headers": { + "API_KEY": "test-key" // API key + } + } + } + }`; + + fs.writeFileSync(testFilePath, complexContent, 'utf-8'); + + updateSettingsFilePreservingFormat(testFilePath, { + model: 'gemini-2.5-flash', + mcpServers: { + context7: { + headers: { + API_KEY: 'new-test-key', + }, + }, + }, + newSection: { + setting: 'value', + }, + }); + + const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); + + // Verify comments preserved + expect(updatedContent).toContain('// Settings'); + expect(updatedContent).toContain('// Active server'); + expect(updatedContent).toContain('// API key'); + + // Verify updates applied + expect(updatedContent).toContain('"model": "gemini-2.5-flash"'); + expect(updatedContent).toContain('"newSection"'); + expect(updatedContent).toContain('"API_KEY": "new-test-key"'); + }); + + it('should handle corrupted JSON files gracefully', () => { + const corruptedContent = `{ + "model": "gemini-2.5-pro", + "ui": { + "theme": "dark" + // Missing closing brace + `; + + fs.writeFileSync(testFilePath, corruptedContent, 'utf-8'); + + expect(() => { + updateSettingsFilePreservingFormat(testFilePath, { + model: 'gemini-2.5-flash', + }); + }).not.toThrow(); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'error', + 'Error parsing settings file. Please check the JSON syntax.', + expect.any(Error), + ); + + const unchangedContent = fs.readFileSync(testFilePath, 'utf-8'); + expect(unchangedContent).toBe(corruptedContent); + }); + + it('should handle array updates while preserving comments', () => { + const originalContent = `{ + // Server configurations + "servers": [ + // First server + "server1", + "server2" // Second server + ] + }`; + + fs.writeFileSync(testFilePath, originalContent, 'utf-8'); + + updateSettingsFilePreservingFormat(testFilePath, { + servers: ['server1', 'server3'], + }); + + const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); + expect(updatedContent).toContain('// Server configurations'); + expect(updatedContent).toContain('"server1"'); + expect(updatedContent).toContain('"server3"'); + expect(updatedContent).not.toContain('"server2"'); + }); + + it('should sync nested objects, removing omitted fields', () => { + const originalContent = `{ + // Configuration + "model": "gemini-2.5-pro", + "ui": { + "theme": "dark", + "existingSetting": "value" + }, + "preservedField": "keep me" + }`; + + fs.writeFileSync(testFilePath, originalContent, 'utf-8'); + + updateSettingsFilePreservingFormat(testFilePath, { + model: 'gemini-2.5-flash', + ui: { + theme: 'light', + }, + preservedField: 'keep me', + }); + + const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); + expect(updatedContent).toContain('// Configuration'); + expect(updatedContent).toContain('"model": "gemini-2.5-flash"'); + expect(updatedContent).toContain('"theme": "light"'); + expect(updatedContent).not.toContain('"existingSetting": "value"'); + expect(updatedContent).toContain('"preservedField": "keep me"'); + }); + + it('should handle mcpServers field deletion properly', () => { + const originalContent = `{ + "model": "gemini-2.5-pro", + "mcpServers": { + // Server to keep + "context7": { + "command": "node", + "args": ["server.js"] + }, + // Server to remove + "oldServer": { + "command": "old", + "args": ["old.js"] + } + } + }`; + + fs.writeFileSync(testFilePath, originalContent, 'utf-8'); + + updateSettingsFilePreservingFormat(testFilePath, { + model: 'gemini-2.5-pro', + mcpServers: { + context7: { + command: 'node', + args: ['server.js'], + }, + }, + }); + + const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); + expect(updatedContent).toContain('// Server to keep'); + expect(updatedContent).toContain('"context7"'); + expect(updatedContent).not.toContain('"oldServer"'); + // The comment for the removed server should still be preserved + expect(updatedContent).toContain('// Server to remove'); + }); + + it('preserves sibling-level commented-out blocks when removing another key', () => { + const originalContent = `{ + "mcpServers": { + // "sleep": { + // "command": "node", + // "args": [ + // "/Users/testUser/test-mcp-server/sleep-mcp/build/index.js" + // ], + // "timeout": 300000 + // }, + "playwright": { + "command": "npx", + "args": [ + "@playwright/mcp@latest", + "--headless", + "--isolated" + ] + } + } + }`; + + fs.writeFileSync(testFilePath, originalContent, 'utf-8'); + + updateSettingsFilePreservingFormat(testFilePath, { + mcpServers: {}, + }); + + const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); + expect(updatedContent).toContain('// "sleep": {'); + expect(updatedContent).toContain('"mcpServers"'); + expect(updatedContent).not.toContain('"playwright"'); + }); + + it('should handle type conversion from object to array', () => { + const originalContent = `{ + "data": { + "key": "value" + } + }`; + + fs.writeFileSync(testFilePath, originalContent, 'utf-8'); + + updateSettingsFilePreservingFormat(testFilePath, { + data: ['item1', 'item2'], + }); + + const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); + expect(updatedContent).toContain('"data": ['); + expect(updatedContent).toContain('"item1"'); + expect(updatedContent).toContain('"item2"'); + }); + + it('should remove both nested and non-nested objects when omitted', () => { + const originalContent = `{ + // Top-level config + "topLevelObject": { + "field1": "value1", + "field2": "value2" + }, + // Parent object + "parent": { + "nestedObject": { + "nestedField1": "value1", + "nestedField2": "value2" + }, + "keepThis": "value" + }, + // This should be preserved + "preservedObject": { + "data": "keep" + } + }`; + + fs.writeFileSync(testFilePath, originalContent, 'utf-8'); + + updateSettingsFilePreservingFormat(testFilePath, { + parent: { + keepThis: 'value', + }, + preservedObject: { + data: 'keep', + }, + }); + + const updatedContent = fs.readFileSync(testFilePath, 'utf-8'); + + expect(updatedContent).not.toContain('"topLevelObject"'); + + expect(updatedContent).not.toContain('"nestedObject"'); + + expect(updatedContent).toContain('"keepThis": "value"'); + expect(updatedContent).toContain('"preservedObject"'); + expect(updatedContent).toContain('"data": "keep"'); + + expect(updatedContent).toContain('// This should be preserved'); + }); + }); +}); diff --git a/packages/cli/src/utils/commentJson.ts b/packages/cli/src/utils/commentJson.ts new file mode 100644 index 0000000000000000000000000000000000000000..c60011b81f92dc0b947810070f414958eebab8fa --- /dev/null +++ b/packages/cli/src/utils/commentJson.ts @@ -0,0 +1,169 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs'; +import { parse, stringify } from 'comment-json'; +import { coreEvents } from '@google/gemini-cli-core'; + +/** + * Type representing an object that may contain Symbol keys for comments. + */ +type CommentedRecord = Record; + +/** + * Updates a JSON file while preserving comments and formatting. + */ +export function updateSettingsFilePreservingFormat( + filePath: string, + updates: Record, +): void { + if (!fs.existsSync(filePath)) { + fs.writeFileSync(filePath, JSON.stringify(updates, null, 2), 'utf-8'); + return; + } + + const originalContent = fs.readFileSync(filePath, 'utf-8'); + + let parsed: Record; + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + parsed = parse(originalContent) as Record; + } catch (error) { + coreEvents.emitFeedback( + 'error', + 'Error parsing settings file. Please check the JSON syntax.', + error, + ); + return; + } + + const updatedStructure = applyUpdates(parsed, updates); + const updatedContent = stringify(updatedStructure, null, 2); + + fs.writeFileSync(filePath, updatedContent, 'utf-8'); +} + +/** + * When deleting a property from a comment-json parsed object, relocate any + * leading/trailing comments that were attached to that property so they are not lost. + * + * This function re-attaches comments to the next sibling's leading comments if + * available, otherwise to the previous sibling's trailing comments, otherwise + * to the container's leading/trailing comments. + */ +function preserveCommentsOnPropertyDeletion( + container: Record, + propName: string, +): void { + const target = container as CommentedRecord; + const beforeSym = Symbol.for(`before:${propName}`); + const afterSym = Symbol.for(`after:${propName}`); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const beforeComments = target[beforeSym] as unknown[] | undefined; + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const afterComments = target[afterSym] as unknown[] | undefined; + + if (!beforeComments && !afterComments) return; + + const keys = Object.getOwnPropertyNames(container); + const idx = keys.indexOf(propName); + const nextKey = idx >= 0 && idx + 1 < keys.length ? keys[idx + 1] : undefined; + const prevKey = idx > 0 ? keys[idx - 1] : undefined; + + function appendToSymbol(destSym: symbol, comments: unknown[]) { + if (!comments || comments.length === 0) return; + const existing = target[destSym]; + target[destSym] = Array.isArray(existing) + ? existing.concat(comments) + : comments; + } + + if (beforeComments && beforeComments.length > 0) { + if (nextKey) { + appendToSymbol(Symbol.for(`before:${nextKey}`), beforeComments); + } else if (prevKey) { + appendToSymbol(Symbol.for(`after:${prevKey}`), beforeComments); + } else { + appendToSymbol(Symbol.for('before'), beforeComments); + } + delete target[beforeSym]; + } + + if (afterComments && afterComments.length > 0) { + if (nextKey) { + appendToSymbol(Symbol.for(`before:${nextKey}`), afterComments); + } else if (prevKey) { + appendToSymbol(Symbol.for(`after:${prevKey}`), afterComments); + } else { + appendToSymbol(Symbol.for('after'), afterComments); + } + delete target[afterSym]; + } +} + +/** + * Applies sync-by-omission semantics: synchronizes base to match desired. + * - Adds/updates keys from desired + * - Removes keys from base that are not in desired + * - Recursively applies to nested objects + * - Preserves comments when deleting keys + */ +function applyKeyDiff( + base: Record, + desired: Record, +): void { + for (const existingKey of Object.getOwnPropertyNames(base)) { + if (!Object.prototype.hasOwnProperty.call(desired, existingKey)) { + preserveCommentsOnPropertyDeletion(base, existingKey); + delete base[existingKey]; + } + } + + for (const nextKey of Object.getOwnPropertyNames(desired)) { + const nextVal = desired[nextKey]; + const baseVal = base[nextKey]; + + const isObj = + typeof nextVal === 'object' && + nextVal !== null && + !Array.isArray(nextVal); + const isBaseObj = + typeof baseVal === 'object' && + baseVal !== null && + !Array.isArray(baseVal); + const isArr = Array.isArray(nextVal); + const isBaseArr = Array.isArray(baseVal); + + if (isObj && isBaseObj) { + applyKeyDiff( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + baseVal as Record, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + nextVal as Record, + ); + } else if (isArr && isBaseArr) { + // In-place mutate arrays to preserve array-level comments on CommentArray + const baseArr = baseVal as unknown[]; + const desiredArr = nextVal as unknown[]; + baseArr.length = 0; + for (const el of desiredArr) { + baseArr.push(el); + } + } else { + base[nextKey] = nextVal; + } + } +} + +function applyUpdates( + current: Record, + updates: Record, +): Record { + // Apply sync-by-omission semantics consistently at all levels + applyKeyDiff(current, updates); + return current; +} diff --git a/packages/cli/src/utils/deepMerge.ts b/packages/cli/src/utils/deepMerge.ts new file mode 100644 index 0000000000000000000000000000000000000000..2eef3b4adaed9ccc6d61f34ed0880926b51ecd50 --- /dev/null +++ b/packages/cli/src/utils/deepMerge.ts @@ -0,0 +1,96 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MergeStrategy } from '../config/settingsSchema.js'; + +export type Mergeable = + | string + | number + | boolean + | null + | undefined + | object + | Mergeable[]; + +export type MergeableObject = Record; + +function isPlainObject(item: unknown): item is MergeableObject { + return !!item && typeof item === 'object' && !Array.isArray(item); +} + +function mergeRecursively( + target: MergeableObject, + source: MergeableObject, + getMergeStrategyForPath: (path: string[]) => MergeStrategy | undefined, + path: string[] = [], +) { + for (const key of Object.keys(source)) { + // JSON.parse can create objects with __proto__ as an own property. + // We must skip it to prevent prototype pollution. + if (key === '__proto__' || key === 'constructor' || key === 'prototype') { + continue; + } + const srcValue = source[key]; + if (srcValue === undefined) { + continue; + } + const newPath = [...path, key]; + const objValue = target[key]; + const mergeStrategy = getMergeStrategyForPath(newPath); + + if (mergeStrategy === MergeStrategy.SHALLOW_MERGE && objValue && srcValue) { + const obj1 = + typeof objValue === 'object' && objValue !== null ? objValue : {}; + const obj2 = + typeof srcValue === 'object' && srcValue !== null ? srcValue : {}; + target[key] = { ...obj1, ...obj2 }; + continue; + } + + if (Array.isArray(objValue)) { + const srcArray = Array.isArray(srcValue) ? srcValue : [srcValue]; + if (mergeStrategy === MergeStrategy.CONCAT) { + target[key] = objValue.concat(srcArray); + continue; + } + if (mergeStrategy === MergeStrategy.UNION) { + target[key] = [...new Set(objValue.concat(srcArray))]; + continue; + } + } + + if (isPlainObject(objValue) && isPlainObject(srcValue)) { + mergeRecursively(objValue, srcValue, getMergeStrategyForPath, newPath); + } else if (isPlainObject(srcValue)) { + target[key] = {}; + mergeRecursively( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + target[key] as MergeableObject, + srcValue, + getMergeStrategyForPath, + newPath, + ); + } else { + target[key] = srcValue; + } + } + return target; +} + +export function customDeepMerge( + getMergeStrategyForPath: (path: string[]) => MergeStrategy | undefined, + ...sources: MergeableObject[] +): MergeableObject { + const result: MergeableObject = {}; + + for (const source of sources) { + if (source) { + mergeRecursively(result, source, getMergeStrategyForPath); + } + } + + return result; +} diff --git a/packages/cli/src/utils/envVarResolver.ts b/packages/cli/src/utils/envVarResolver.ts new file mode 100644 index 0000000000000000000000000000000000000000..286ecbb00b5865ad3f61d3afc763a980150e2585 --- /dev/null +++ b/packages/cli/src/utils/envVarResolver.ts @@ -0,0 +1,156 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Resolves environment variables in a string. + * Replaces $VAR_NAME, ${VAR_NAME}, and ${VAR_NAME:-DEFAULT_VALUE} with their corresponding + * environment variable values. If the environment variable is not defined and no default + * value is provided, the original placeholder is preserved. + * + * @param value - The string that may contain environment variable placeholders + * @param customEnv - Optional record of environment variables to use before process.env + * @returns The string with environment variables resolved + * + * @example + * resolveEnvVarsInString("Token: $API_KEY") // Returns "Token: secret-123" + * resolveEnvVarsInString("URL: ${BASE_URL}/api") // Returns "URL: https://api.example.com/api" + * resolveEnvVarsInString("URL: ${MISSING_VAR:-https://default.com}") // Returns "URL: https://default.com" + * resolveEnvVarsInString("Missing: $UNDEFINED_VAR") // Returns "Missing: $UNDEFINED_VAR" + */ +export function resolveEnvVarsInString( + value: string, + customEnv?: Record, +): string { + // Regex matches $VAR_NAME, ${VAR_NAME}, and ${VAR_NAME:-DEFAULT_VALUE} + const envVarRegex = /\$(?:(\w+)|{([^}]+?)(?::-([^}]*))?})/g; + + return value.replace( + envVarRegex, + ( + match: string, + varName1?: string, + varName2?: string, + defaultValue?: string, + ): string => { + const varName: string = varName1 || varName2 || ''; + + if (!varName) { + return match; + } + + if (customEnv && typeof customEnv[varName] === 'string') { + return customEnv[varName]; + } + if (process && process.env) { + const val = process.env[varName]; + if (typeof val === 'string') { + return val; + } + } + if (defaultValue !== undefined) { + return defaultValue; + } + return match; + }, + ); +} + +/** + * Recursively resolves environment variables in an object of any type. + * Handles strings, arrays, nested objects, and preserves other primitive types. + * Protected against circular references using a WeakSet to track visited objects. + * + * @param obj - The object to process for environment variable resolution + * @returns A new object with environment variables resolved + * + * @example + * const config = { + * server: { + * host: "$HOST", + * port: "${PORT}", + * enabled: true, + * tags: ["$ENV", "api"] + * } + * }; + * const resolved = resolveEnvVarsInObject(config); + */ +export function resolveEnvVarsInObject( + obj: T, + customEnv?: Record, +): T { + return resolveEnvVarsInObjectInternal(obj, new WeakSet(), customEnv); +} + +/** + * Internal implementation of resolveEnvVarsInObject with circular reference protection. + * + * @param obj - The object to process + * @param visited - WeakSet to track visited objects and prevent circular references + * @returns A new object with environment variables resolved + */ +function resolveEnvVarsInObjectInternal( + obj: T, + visited: WeakSet, + customEnv?: Record, +): T { + if ( + obj === null || + obj === undefined || + typeof obj === 'boolean' || + typeof obj === 'number' + ) { + return obj; + } + + if (typeof obj === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return resolveEnvVarsInString(obj, customEnv) as unknown as T; + } + + if (Array.isArray(obj)) { + // Check for circular reference + if (visited.has(obj)) { + // Return a shallow copy to break the cycle + const copy: unknown = [...obj]; + const isTArray = (val: unknown): val is T => Array.isArray(val); + if (isTArray(copy)) return copy; + throw new Error('Unreachable'); + } + + visited.add(obj); + const mapped: unknown = obj.map((item: unknown) => + resolveEnvVarsInObjectInternal(item, visited, customEnv), + ); + visited.delete(obj); + const isTArray = (val: unknown): val is T => Array.isArray(val); + if (isTArray(mapped)) return mapped; + throw new Error('Unreachable'); + } + + if (typeof obj === 'object') { + // Check for circular reference + if (visited.has(obj as object)) { + // Return a shallow copy to break the cycle + return { ...obj } as T; + } + + visited.add(obj as object); + const newObj = { ...obj } as T; + for (const key in newObj) { + if (Object.prototype.hasOwnProperty.call(newObj, key)) { + newObj[key] = resolveEnvVarsInObjectInternal( + newObj[key], + visited, + customEnv, + ); + } + } + visited.delete(obj as object); + return newObj; + } + + return obj; +} diff --git a/packages/cli/src/utils/errors.ts b/packages/cli/src/utils/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..774d9e994c64bb6c97e55c182374384d0078a651 --- /dev/null +++ b/packages/cli/src/utils/errors.ts @@ -0,0 +1,245 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config } from '@google/gemini-cli-core'; +import { + OutputFormat, + JsonFormatter, + StreamJsonFormatter, + JsonStreamEventType, + uiTelemetryService, + parseAndFormatApiError, + FatalTurnLimitedError, + FatalCancellationError, + FatalToolExecutionError, + isFatalToolError, + debugLogger, + coreEvents, + getErrorType, + getErrorMessage, +} from '@google/gemini-cli-core'; +import { runSyncCleanup } from './cleanup.js'; + +interface ErrorWithCode extends Error { + exitCode?: number; + code?: string | number; + status?: string | number; +} + +/** + * Extracts the appropriate error code from an error object. + */ +function extractErrorCode(error: unknown): string | number { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const errorWithCode = error as ErrorWithCode; + + // Prioritize exitCode for FatalError types, fall back to other codes + if (typeof errorWithCode.exitCode === 'number') { + return errorWithCode.exitCode; + } + if (errorWithCode.code !== undefined) { + return errorWithCode.code; + } + if (errorWithCode.status !== undefined) { + return errorWithCode.status; + } + + return 1; // Default exit code +} + +/** + * Converts an error code to a numeric exit code. + */ +function getNumericExitCode(errorCode: string | number): number { + return typeof errorCode === 'number' ? errorCode : 1; +} + +/** + * Handles errors consistently for both JSON and text output formats. + * In JSON mode, outputs formatted JSON error and exits. + * In streaming JSON mode, emits a result event with error status. + * In text mode, outputs error message and re-throws. + */ +export function handleError( + error: unknown, + config: Config, + customErrorCode?: string | number, +): never { + const errorMessage = parseAndFormatApiError( + error, + config.getContentGeneratorConfig()?.authType, + ); + + if (config.getOutputFormat() === OutputFormat.STREAM_JSON) { + const streamFormatter = new StreamJsonFormatter(); + const errorCode = customErrorCode ?? extractErrorCode(error); + const metrics = uiTelemetryService.getMetrics(); + + streamFormatter.emitEvent({ + type: JsonStreamEventType.RESULT, + timestamp: new Date().toISOString(), + status: 'error', + error: { + type: getErrorType(error), + message: errorMessage, + }, + stats: streamFormatter.convertToStreamStats(metrics, 0), + }); + + runSyncCleanup(); + process.exit(getNumericExitCode(errorCode)); + } else if (config.getOutputFormat() === OutputFormat.JSON) { + const formatter = new JsonFormatter(); + const errorCode = customErrorCode ?? extractErrorCode(error); + + const formattedError = formatter.formatError( + error instanceof Error ? error : new Error(getErrorMessage(error)), + errorCode, + config.getSessionId(), + ); + + coreEvents.emitFeedback('error', formattedError); + runSyncCleanup(); + process.exit(getNumericExitCode(errorCode)); + } else { + throw error; + } +} + +/** + * Handles tool execution errors specifically. + * + * Fatal errors (e.g., NO_SPACE_LEFT) cause the CLI to exit immediately, + * as they indicate unrecoverable system state. + * + * Non-fatal errors (e.g., INVALID_TOOL_PARAMS, FILE_NOT_FOUND, PATH_NOT_IN_WORKSPACE) + * are logged to stderr and the error response is sent back to the model, + * allowing it to self-correct. + */ +export function handleToolError( + toolName: string, + toolError: Error, + config: Config, + errorType?: string, + resultDisplay?: string, +): void { + const errorMessage = `Error executing tool ${toolName}: ${resultDisplay || toolError.message}`; + + const isFatal = isFatalToolError(errorType); + + if (isFatal) { + const toolExecutionError = new FatalToolExecutionError(errorMessage); + if (config.getOutputFormat() === OutputFormat.STREAM_JSON) { + const streamFormatter = new StreamJsonFormatter(); + const metrics = uiTelemetryService.getMetrics(); + streamFormatter.emitEvent({ + type: JsonStreamEventType.RESULT, + timestamp: new Date().toISOString(), + status: 'error', + error: { + type: errorType ?? 'FatalToolExecutionError', + message: toolExecutionError.message, + }, + stats: streamFormatter.convertToStreamStats(metrics, 0), + }); + } else if (config.getOutputFormat() === OutputFormat.JSON) { + const formatter = new JsonFormatter(); + const formattedError = formatter.formatError( + toolExecutionError, + errorType ?? toolExecutionError.exitCode, + config.getSessionId(), + ); + coreEvents.emitFeedback('error', formattedError); + } else { + coreEvents.emitFeedback('error', errorMessage); + } + runSyncCleanup(); + process.exit(toolExecutionError.exitCode); + } + + // Non-fatal: log and continue + debugLogger.warn(errorMessage); +} + +/** + * Handles cancellation/abort signals consistently. + */ +export function handleCancellationError(config: Config): never { + const cancellationError = new FatalCancellationError('Operation cancelled.'); + + if (config.getOutputFormat() === OutputFormat.STREAM_JSON) { + const streamFormatter = new StreamJsonFormatter(); + const metrics = uiTelemetryService.getMetrics(); + streamFormatter.emitEvent({ + type: JsonStreamEventType.RESULT, + timestamp: new Date().toISOString(), + status: 'error', + error: { + type: getErrorType(cancellationError), + message: cancellationError.message, + }, + stats: streamFormatter.convertToStreamStats(metrics, 0), + }); + runSyncCleanup(); + process.exit(cancellationError.exitCode); + } else if (config.getOutputFormat() === OutputFormat.JSON) { + const formatter = new JsonFormatter(); + const formattedError = formatter.formatError( + cancellationError, + cancellationError.exitCode, + config.getSessionId(), + ); + + coreEvents.emitFeedback('error', formattedError); + runSyncCleanup(); + process.exit(cancellationError.exitCode); + } else { + coreEvents.emitFeedback('error', cancellationError.message); + runSyncCleanup(); + process.exit(cancellationError.exitCode); + } +} + +/** + * Handles max session turns exceeded consistently. + */ +export function handleMaxTurnsExceededError(config: Config): never { + const maxTurnsError = new FatalTurnLimitedError( + 'Reached max session turns for this session. Increase the number of turns by specifying maxSessionTurns in settings.json.', + ); + + if (config.getOutputFormat() === OutputFormat.STREAM_JSON) { + const streamFormatter = new StreamJsonFormatter(); + const metrics = uiTelemetryService.getMetrics(); + streamFormatter.emitEvent({ + type: JsonStreamEventType.RESULT, + timestamp: new Date().toISOString(), + status: 'error', + error: { + type: getErrorType(maxTurnsError), + message: maxTurnsError.message, + }, + stats: streamFormatter.convertToStreamStats(metrics, 0), + }); + runSyncCleanup(); + process.exit(maxTurnsError.exitCode); + } else if (config.getOutputFormat() === OutputFormat.JSON) { + const formatter = new JsonFormatter(); + const formattedError = formatter.formatError( + maxTurnsError, + maxTurnsError.exitCode, + config.getSessionId(), + ); + + coreEvents.emitFeedback('error', formattedError); + runSyncCleanup(); + process.exit(maxTurnsError.exitCode); + } else { + coreEvents.emitFeedback('error', maxTurnsError.message); + runSyncCleanup(); + process.exit(maxTurnsError.exitCode); + } +} diff --git a/packages/cli/src/utils/events.test.ts b/packages/cli/src/utils/events.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8055a3b2862092fa7246b91363fe0e2eda8193c9 --- /dev/null +++ b/packages/cli/src/utils/events.test.ts @@ -0,0 +1,32 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { appEvents, AppEvent } from './events.js'; + +describe('events', () => { + it('should allow registering and emitting events', () => { + const callback = vi.fn(); + appEvents.on(AppEvent.SelectionWarning, callback); + + appEvents.emit(AppEvent.SelectionWarning); + + expect(callback).toHaveBeenCalled(); + + appEvents.off(AppEvent.SelectionWarning, callback); + }); + + it('should work with events without data', () => { + const callback = vi.fn(); + appEvents.on(AppEvent.Flicker, callback); + + appEvents.emit(AppEvent.Flicker); + + expect(callback).toHaveBeenCalled(); + + appEvents.off(AppEvent.Flicker, callback); + }); +}); diff --git a/packages/cli/src/utils/events.ts b/packages/cli/src/utils/events.ts new file mode 100644 index 0000000000000000000000000000000000000000..9c3ec6a36515d1d3e51c546b299ce5ce29a7b5f1 --- /dev/null +++ b/packages/cli/src/utils/events.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'node:events'; + +export enum TransientMessageType { + Warning = 'warning', + Hint = 'hint', +} + +export interface TransientMessagePayload { + message: string; + type: TransientMessageType; +} + +export enum AppEvent { + OpenDebugConsole = 'open-debug-console', + Flicker = 'flicker', + SelectionWarning = 'selection-warning', + PasteTimeout = 'paste-timeout', + TerminalBackground = 'terminal-background', + TransientMessage = 'transient-message', + ScrollToBottom = 'scroll-to-bottom', +} + +export interface AppEvents { + [AppEvent.OpenDebugConsole]: never[]; + [AppEvent.Flicker]: never[]; + [AppEvent.SelectionWarning]: never[]; + [AppEvent.PasteTimeout]: never[]; + [AppEvent.TerminalBackground]: [string]; + [AppEvent.TransientMessage]: [TransientMessagePayload]; + [AppEvent.ScrollToBottom]: never[]; +} + +export const appEvents = new EventEmitter(); diff --git a/packages/cli/src/utils/featureToggleUtils.test.ts b/packages/cli/src/utils/featureToggleUtils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..345aca68bca442801386b5eaaf424f736580950d --- /dev/null +++ b/packages/cli/src/utils/featureToggleUtils.test.ts @@ -0,0 +1,195 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + enableFeature, + disableFeature, + type FeatureToggleStrategy, +} from './featureToggleUtils.js'; +import { + SettingScope, + type LoadedSettings, + type LoadableSettingScope, +} from '../config/settings.js'; + +function createMockLoadedSettings(opts: { + userSettings?: Record; + workspaceSettings?: Record; + userPath?: string; + workspacePath?: string; +}): LoadedSettings { + const scopes: Record< + string, + { settings: Record; path: string } + > = { + [SettingScope.User]: { + settings: opts.userSettings ?? {}, + path: opts.userPath ?? '/home/user/.gemini/settings.json', + }, + [SettingScope.Workspace]: { + settings: opts.workspaceSettings ?? {}, + path: opts.workspacePath ?? '/project/.gemini/settings.json', + }, + }; + + const mockSettings = { + forScope: vi.fn((scope: LoadableSettingScope) => scopes[scope]), + setValue: vi.fn(), + } as unknown as LoadedSettings; + + return mockSettings; +} + +function createMockStrategy(overrides?: { + needsEnabling?: ( + settings: LoadedSettings, + scope: LoadableSettingScope, + featureName: string, + ) => boolean; + isExplicitlyDisabled?: ( + settings: LoadedSettings, + scope: LoadableSettingScope, + featureName: string, + ) => boolean; +}): FeatureToggleStrategy { + return { + needsEnabling: vi.fn(overrides?.needsEnabling ?? (() => false)), + enable: vi.fn(), + isExplicitlyDisabled: vi.fn( + overrides?.isExplicitlyDisabled ?? (() => false), + ), + disable: vi.fn(), + }; +} + +describe('featureToggleUtils', () => { + describe('enableFeature', () => { + it('should return no-op when the feature is already enabled in all scopes', () => { + const settings = createMockLoadedSettings({}); + const strategy = createMockStrategy({ + needsEnabling: () => false, + }); + + const result = enableFeature(settings, 'my-feature', strategy); + + expect(result.status).toBe('no-op'); + expect(result.action).toBe('enable'); + expect(result.featureName).toBe('my-feature'); + expect(result.modifiedScopes).toHaveLength(0); + expect(result.alreadyInStateScopes).toHaveLength(2); + expect(strategy.enable).not.toHaveBeenCalled(); + }); + + it('should enable the feature when disabled in one scope', () => { + const settings = createMockLoadedSettings({}); + const strategy = createMockStrategy({ + needsEnabling: (_s, scope) => scope === SettingScope.Workspace, + }); + + const result = enableFeature(settings, 'my-feature', strategy); + + expect(result.status).toBe('success'); + expect(result.action).toBe('enable'); + expect(result.modifiedScopes).toHaveLength(1); + expect(result.modifiedScopes[0].scope).toBe(SettingScope.Workspace); + expect(result.alreadyInStateScopes).toHaveLength(1); + expect(result.alreadyInStateScopes[0].scope).toBe(SettingScope.User); + expect(strategy.enable).toHaveBeenCalledTimes(1); + }); + + it('should enable the feature when disabled in both scopes', () => { + const settings = createMockLoadedSettings({}); + const strategy = createMockStrategy({ + needsEnabling: () => true, + }); + + const result = enableFeature(settings, 'my-feature', strategy); + + expect(result.status).toBe('success'); + expect(result.action).toBe('enable'); + expect(result.modifiedScopes).toHaveLength(2); + expect(result.alreadyInStateScopes).toHaveLength(0); + expect(strategy.enable).toHaveBeenCalledTimes(2); + }); + + it('should include correct scope paths in the result', () => { + const settings = createMockLoadedSettings({ + userPath: '/custom/user/path', + workspacePath: '/custom/workspace/path', + }); + const strategy = createMockStrategy({ + needsEnabling: () => true, + }); + + const result = enableFeature(settings, 'my-feature', strategy); + + const paths = result.modifiedScopes.map((s) => s.path); + expect(paths).toContain('/custom/workspace/path'); + expect(paths).toContain('/custom/user/path'); + }); + }); + + describe('disableFeature', () => { + it('should return no-op when the feature is already disabled in the target scope', () => { + const settings = createMockLoadedSettings({}); + const strategy = createMockStrategy({ + isExplicitlyDisabled: () => true, + }); + + const result = disableFeature( + settings, + 'my-feature', + SettingScope.User, + strategy, + ); + + expect(result.status).toBe('no-op'); + expect(result.action).toBe('disable'); + expect(result.featureName).toBe('my-feature'); + expect(result.modifiedScopes).toHaveLength(0); + expect(result.alreadyInStateScopes).toHaveLength(1); + expect(strategy.disable).not.toHaveBeenCalled(); + }); + + it('should disable the feature when it is enabled', () => { + const settings = createMockLoadedSettings({}); + const strategy = createMockStrategy({ + isExplicitlyDisabled: () => false, + }); + + const result = disableFeature( + settings, + 'my-feature', + SettingScope.User, + strategy, + ); + + expect(result.status).toBe('success'); + expect(result.action).toBe('disable'); + expect(result.modifiedScopes).toHaveLength(1); + expect(result.modifiedScopes[0].scope).toBe(SettingScope.User); + expect(strategy.disable).toHaveBeenCalledOnce(); + }); + + it('should return error for an invalid scope', () => { + const settings = createMockLoadedSettings({}); + const strategy = createMockStrategy(); + + const result = disableFeature( + settings, + 'my-feature', + SettingScope.Session, + strategy, + ); + + expect(result.status).toBe('error'); + expect(result.action).toBe('disable'); + expect(result.error).toContain('Invalid settings scope'); + expect(strategy.disable).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/utils/featureToggleUtils.ts b/packages/cli/src/utils/featureToggleUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..9b3df0e5df4bac2dfdb7b1387950753bb0d24db2 --- /dev/null +++ b/packages/cli/src/utils/featureToggleUtils.ts @@ -0,0 +1,185 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + SettingScope, + isLoadableSettingScope, + type LoadableSettingScope, + type LoadedSettings, +} from '../config/settings.js'; + +export interface ModifiedScope { + scope: SettingScope; + path: string; +} + +export type FeatureActionStatus = 'success' | 'no-op' | 'error'; + +export interface FeatureActionResult { + status: FeatureActionStatus; + featureName: string; + action: 'enable' | 'disable'; + /** Scopes where the feature's state was actually changed. */ + modifiedScopes: ModifiedScope[]; + /** Scopes where the feature was already in the desired state. */ + alreadyInStateScopes: ModifiedScope[]; + /** Error message if status is 'error'. */ + error?: string; +} + +/** + * Strategy pattern to handle differences between feature types (e.g. skills vs agents). + */ +export interface FeatureToggleStrategy { + /** + * Checks if the feature needs to be enabled in the given scope. + * For skills (blacklist): returns true if in disabled list. + * For agents (whitelist): returns true if NOT explicitly enabled (false or undefined). + */ + needsEnabling( + settings: LoadedSettings, + scope: LoadableSettingScope, + featureName: string, + ): boolean; + + /** + * Applies the enable change to the settings object. + */ + enable( + settings: LoadedSettings, + scope: LoadableSettingScope, + featureName: string, + ): void; + + /** + * Checks if the feature is explicitly disabled in the given scope. + * For skills (blacklist): returns true if in disabled list. + * For agents (whitelist): returns true if explicitly set to false. + */ + isExplicitlyDisabled( + settings: LoadedSettings, + scope: LoadableSettingScope, + featureName: string, + ): boolean; + + /** + * Applies the disable change to the settings object. + */ + disable( + settings: LoadedSettings, + scope: LoadableSettingScope, + featureName: string, + ): void; +} + +/** + * Enables a feature by ensuring it is enabled in all writable scopes. + */ +export function enableFeature( + settings: LoadedSettings, + featureName: string, + strategy: FeatureToggleStrategy, +): FeatureActionResult { + const writableScopes = [SettingScope.Workspace, SettingScope.User]; + const foundInDisabledScopes: ModifiedScope[] = []; + const alreadyEnabledScopes: ModifiedScope[] = []; + + for (const scope of writableScopes) { + if (isLoadableSettingScope(scope)) { + const scopePath = settings.forScope(scope).path; + if (strategy.needsEnabling(settings, scope, featureName)) { + foundInDisabledScopes.push({ scope, path: scopePath }); + } else { + alreadyEnabledScopes.push({ scope, path: scopePath }); + } + } + } + + if (foundInDisabledScopes.length === 0) { + return { + status: 'no-op', + featureName, + action: 'enable', + modifiedScopes: [], + alreadyInStateScopes: alreadyEnabledScopes, + }; + } + + const modifiedScopes: ModifiedScope[] = []; + for (const { scope, path } of foundInDisabledScopes) { + if (isLoadableSettingScope(scope)) { + strategy.enable(settings, scope, featureName); + modifiedScopes.push({ scope, path }); + } + } + + return { + status: 'success', + featureName, + action: 'enable', + modifiedScopes, + alreadyInStateScopes: alreadyEnabledScopes, + }; +} + +/** + * Disables a feature in the specified scope. + */ +export function disableFeature( + settings: LoadedSettings, + featureName: string, + scope: SettingScope, + strategy: FeatureToggleStrategy, +): FeatureActionResult { + if (!isLoadableSettingScope(scope)) { + return { + status: 'error', + featureName, + action: 'disable', + modifiedScopes: [], + alreadyInStateScopes: [], + error: `Invalid settings scope: ${scope}`, + }; + } + + const scopePath = settings.forScope(scope).path; + + if (strategy.isExplicitlyDisabled(settings, scope, featureName)) { + return { + status: 'no-op', + featureName, + action: 'disable', + modifiedScopes: [], + alreadyInStateScopes: [{ scope, path: scopePath }], + }; + } + + // Check if it's already disabled in the other writable scope + const otherScope = + scope === SettingScope.Workspace + ? SettingScope.User + : SettingScope.Workspace; + const alreadyDisabledInOther: ModifiedScope[] = []; + + if (isLoadableSettingScope(otherScope)) { + if (strategy.isExplicitlyDisabled(settings, otherScope, featureName)) { + alreadyDisabledInOther.push({ + scope: otherScope, + path: settings.forScope(otherScope).path, + }); + } + } + + strategy.disable(settings, scope, featureName); + + return { + status: 'success', + featureName, + action: 'disable', + modifiedScopes: [{ scope, path: scopePath }], + alreadyInStateScopes: alreadyDisabledInOther, + }; +} diff --git a/packages/cli/src/utils/gitUtils.ts b/packages/cli/src/utils/gitUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c9555b1fa9fe0441656104f840ce38c74ad475f --- /dev/null +++ b/packages/cli/src/utils/gitUtils.ts @@ -0,0 +1,148 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { debugLogger } from '@google/gemini-cli-core'; +import { execSync } from 'node:child_process'; +import { ProxyAgent } from 'undici'; + +/** + * Checks if a directory is within a git repository hosted on GitHub. + * @returns true if the directory is in a git repository with a github.com remote, false otherwise + */ +export const isGitHubRepository = (): boolean => { + try { + const remotes = ( + execSync('git remote -v', { + encoding: 'utf-8', + }) || '' + ).trim(); + + const pattern = /github\.com/; + + return pattern.test(remotes); + } catch (error) { + // If any filesystem error occurs, assume not a git repo + debugLogger.debug(`Failed to get git remote:`, error); + return false; + } +}; + +/** + * getGitRepoRoot returns the root directory of the git repository. + * @returns the path to the root of the git repo. + * @throws error if the exec command fails. + */ +export const getGitRepoRoot = (): string => { + const gitRepoRoot = ( + execSync('git rev-parse --show-toplevel', { + encoding: 'utf-8', + }) || '' + ).trim(); + + if (!gitRepoRoot) { + throw new Error(`Git repo returned empty value`); + } + + return gitRepoRoot; +}; + +/** + * getLatestGitHubRelease returns the release tag as a string. + * @returns string of the release tag (e.g. "v1.2.3"). + */ +export const getLatestGitHubRelease = async ( + proxy?: string, +): Promise => { + try { + const controller = new AbortController(); + + const endpoint = `https://api.github.com/repos/google-github-actions/run-gemini-cli/releases/latest`; + + const response = await fetch(endpoint, { + method: 'GET', + headers: { + Accept: 'application/vnd.github+json', + 'Content-Type': 'application/json', + 'X-GitHub-Api-Version': '2022-11-28', + }, + dispatcher: proxy ? new ProxyAgent(proxy) : undefined, + /* eslint-disable @typescript-eslint/no-unsafe-type-assertion */ + signal: ( + AbortSignal as unknown as { + any: (signals: AbortSignal[]) => AbortSignal; + } + ).any([AbortSignal.timeout(30_000), controller.signal]), + /* eslint-enable @typescript-eslint/no-unsafe-type-assertion */ + } as RequestInit); + + if (!response.ok) { + throw new Error( + `Invalid response code: ${response.status} - ${response.statusText}`, + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const releaseTag = (await response.json()).tag_name; + if (!releaseTag) { + throw new Error(`Response did not include tag_name field`); + } + return typeof releaseTag === 'string' ? releaseTag : ''; + } catch (error) { + debugLogger.debug( + `Failed to determine latest run-gemini-cli release:`, + error, + ); + throw new Error( + `Unable to determine the latest run-gemini-cli release on GitHub.`, + ); + } +}; + +/** + * getGitHubRepoInfo returns the owner and repository for a GitHub repo. + * @returns the owner and repository of the github repo. + * @throws error if the exec command fails. + */ +export function getGitHubRepoInfo(): { owner: string; repo: string } { + const remoteUrl = execSync('git remote get-url origin', { + encoding: 'utf-8', + }).trim(); + + // Handle SCP-style SSH URLs (git@github.com:owner/repo.git) + let urlToParse = remoteUrl; + if (remoteUrl.startsWith('git@github.com:')) { + urlToParse = remoteUrl.replace('git@github.com:', ''); + } else if (remoteUrl.startsWith('git@')) { + // SSH URL for a different provider (GitLab, Bitbucket, etc.) + throw new Error( + `Owner & repo could not be extracted from remote URL: ${remoteUrl}`, + ); + } + + let parsedUrl: URL; + try { + parsedUrl = new URL(urlToParse, 'https://github.com'); + } catch { + throw new Error( + `Owner & repo could not be extracted from remote URL: ${remoteUrl}`, + ); + } + + if (parsedUrl.host !== 'github.com') { + throw new Error( + `Owner & repo could not be extracted from remote URL: ${remoteUrl}`, + ); + } + + const parts = parsedUrl.pathname.split('/').filter((part) => part !== ''); + if (parts.length !== 2 || !parts[0] || !parts[1]) { + throw new Error( + `Owner & repo could not be extracted from remote URL: ${remoteUrl}`, + ); + } + + return { owner: parts[0], repo: parts[1].replace(/\.git$/, '') }; +} diff --git a/packages/cli/src/utils/hookSettings.ts b/packages/cli/src/utils/hookSettings.ts new file mode 100644 index 0000000000000000000000000000000000000000..ca1f71a90527c069e9a6a1d9a1a6ed68a8c23f05 --- /dev/null +++ b/packages/cli/src/utils/hookSettings.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + SettingScope, + isLoadableSettingScope, + type LoadedSettings, +} from '../config/settings.js'; +import { getErrorMessage } from '@google/gemini-cli-core'; +import type { ModifiedScope } from './skillSettings.js'; + +export type HookActionStatus = 'success' | 'no-op' | 'error'; + +/** + * Metadata representing the result of a hook settings operation. + */ +export interface HookActionResult { + status: HookActionStatus; + hookName: string; + action: 'enable' | 'disable'; + /** Scopes where the hook's state was actually changed. */ + modifiedScopes: ModifiedScope[]; + /** Scopes where the hook was already in the desired state. */ + alreadyInStateScopes: ModifiedScope[]; + /** Error message if status is 'error'. */ + error?: string; +} + +/** + * Enables a hook by removing it from all writable disabled lists (User and Workspace). + */ +export function enableHook( + settings: LoadedSettings, + hookName: string, +): HookActionResult { + const writableScopes = [SettingScope.Workspace, SettingScope.User]; + const foundInDisabledScopes: ModifiedScope[] = []; + const alreadyEnabledScopes: ModifiedScope[] = []; + + for (const scope of writableScopes) { + if (isLoadableSettingScope(scope)) { + const scopePath = settings.forScope(scope).path; + const scopeDisabled = + settings.forScope(scope).settings.hooksConfig?.disabled; + if (scopeDisabled?.includes(hookName)) { + foundInDisabledScopes.push({ scope, path: scopePath }); + } else { + alreadyEnabledScopes.push({ scope, path: scopePath }); + } + } + } + + if (foundInDisabledScopes.length === 0) { + return { + status: 'no-op', + hookName, + action: 'enable', + modifiedScopes: [], + alreadyInStateScopes: alreadyEnabledScopes, + }; + } + + const modifiedScopes: ModifiedScope[] = []; + try { + for (const { scope, path } of foundInDisabledScopes) { + if (isLoadableSettingScope(scope)) { + const currentScopeDisabled = + settings.forScope(scope).settings.hooksConfig?.disabled ?? []; + const newDisabled = currentScopeDisabled.filter( + (name) => name !== hookName, + ); + settings.setValue(scope, 'hooksConfig.disabled', newDisabled); + modifiedScopes.push({ scope, path }); + } + } + } catch (error) { + return { + status: 'error', + hookName, + action: 'enable', + modifiedScopes, + alreadyInStateScopes: alreadyEnabledScopes, + error: `Failed to enable hook: ${getErrorMessage(error)}`, + }; + } + + return { + status: 'success', + hookName, + action: 'enable', + modifiedScopes, + alreadyInStateScopes: alreadyEnabledScopes, + }; +} + +/** + * Disables a hook by adding it to the disabled list in the specified scope. + */ +export function disableHook( + settings: LoadedSettings, + hookName: string, + scope: SettingScope, +): HookActionResult { + if (!isLoadableSettingScope(scope)) { + return { + status: 'error', + hookName, + action: 'disable', + modifiedScopes: [], + alreadyInStateScopes: [], + error: `Invalid settings scope: ${scope}`, + }; + } + + const scopePath = settings.forScope(scope).path; + const currentScopeDisabled = + settings.forScope(scope).settings.hooksConfig?.disabled ?? []; + + if (currentScopeDisabled.includes(hookName)) { + return { + status: 'no-op', + hookName, + action: 'disable', + modifiedScopes: [], + alreadyInStateScopes: [{ scope, path: scopePath }], + }; + } + + // Check if it's already disabled in the other writable scope + const otherScope = + scope === SettingScope.Workspace + ? SettingScope.User + : SettingScope.Workspace; + const alreadyDisabledInOther: ModifiedScope[] = []; + + if (isLoadableSettingScope(otherScope)) { + const otherScopeDisabled = + settings.forScope(otherScope).settings.hooksConfig?.disabled; + if (otherScopeDisabled?.includes(hookName)) { + alreadyDisabledInOther.push({ + scope: otherScope, + path: settings.forScope(otherScope).path, + }); + } + } + + const newDisabled = [...currentScopeDisabled, hookName]; + settings.setValue(scope, 'hooksConfig.disabled', newDisabled); + + return { + status: 'success', + hookName, + action: 'disable', + modifiedScopes: [{ scope, path: scopePath }], + alreadyInStateScopes: alreadyDisabledInOther, + }; +} diff --git a/packages/cli/src/utils/hookUtils.test.ts b/packages/cli/src/utils/hookUtils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0a79cd1fe2e190e656faf37b93c9722f87464d91 --- /dev/null +++ b/packages/cli/src/utils/hookUtils.test.ts @@ -0,0 +1,142 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { renderHookActionFeedback } from './hookUtils.js'; +import type { HookActionResult } from './hookSettings.js'; +import { SettingScope } from '../config/settings.js'; + +describe('hookUtils', () => { + describe('renderHookActionFeedback', () => { + const mockFormatScope = (label: string, path: string) => + `${label} (${path})`; + + it('should render error message', () => { + const result: HookActionResult = { + status: 'error', + hookName: 'test-hook', + action: 'enable', + modifiedScopes: [], + alreadyInStateScopes: [], + error: 'Something went wrong', + }; + + const message = renderHookActionFeedback(result, mockFormatScope); + expect(message).toBe('Something went wrong'); + }); + + it('should render default error message if error string is missing', () => { + const result: HookActionResult = { + status: 'error', + hookName: 'test-hook', + action: 'enable', + modifiedScopes: [], + alreadyInStateScopes: [], + }; + + const message = renderHookActionFeedback(result, mockFormatScope); + expect(message).toBe( + 'An error occurred while attempting to enable hook "test-hook".', + ); + }); + + it('should render no-op message for enable', () => { + const result: HookActionResult = { + status: 'no-op', + hookName: 'test-hook', + action: 'enable', + modifiedScopes: [], + alreadyInStateScopes: [], + }; + + const message = renderHookActionFeedback(result, mockFormatScope); + expect(message).toBe('Hook "test-hook" is already enabled.'); + }); + + it('should render no-op message for disable', () => { + const result: HookActionResult = { + status: 'no-op', + hookName: 'test-hook', + action: 'disable', + modifiedScopes: [], + alreadyInStateScopes: [], + }; + + const message = renderHookActionFeedback(result, mockFormatScope); + expect(message).toBe('Hook "test-hook" is already disabled.'); + }); + + it('should render success message for enable (single scope)', () => { + const result: HookActionResult = { + status: 'success', + hookName: 'test-hook', + action: 'enable', + modifiedScopes: [{ scope: SettingScope.User, path: '/path/user.json' }], + alreadyInStateScopes: [ + { scope: SettingScope.Workspace, path: '/path/workspace.json' }, + ], + }; + + const message = renderHookActionFeedback(result, mockFormatScope); + expect(message).toBe( + 'Hook "test-hook" enabled by removing it from the disabled list in user (/path/user.json) and workspace (/path/workspace.json) settings.', + ); + }); + + it('should render success message for enable (single scope only affected)', () => { + // E.g. Workspace doesn't exist or isn't loadable, so only User is affected. + const result: HookActionResult = { + status: 'success', + hookName: 'test-hook', + action: 'enable', + modifiedScopes: [{ scope: SettingScope.User, path: '/path/user.json' }], + alreadyInStateScopes: [], + }; + + const message = renderHookActionFeedback(result, mockFormatScope); + expect(message).toBe( + 'Hook "test-hook" enabled by removing it from the disabled list in user (/path/user.json) settings.', + ); + }); + + it('should render success message for disable (single scope)', () => { + const result: HookActionResult = { + status: 'success', + hookName: 'test-hook', + action: 'disable', + modifiedScopes: [ + { scope: SettingScope.Workspace, path: '/path/workspace.json' }, + ], + alreadyInStateScopes: [], + }; + + const message = renderHookActionFeedback(result, mockFormatScope); + expect(message).toBe( + 'Hook "test-hook" disabled by adding it to the disabled list in workspace (/path/workspace.json) settings.', + ); + }); + + it('should render success message for disable (two scopes)', () => { + // E.g. Disabled in Workspace, but ALREADY disabled in User. + const result: HookActionResult = { + status: 'success', + hookName: 'test-hook', + action: 'disable', + modifiedScopes: [ + { scope: SettingScope.Workspace, path: '/path/workspace.json' }, + ], + alreadyInStateScopes: [ + { scope: SettingScope.User, path: '/path/user.json' }, + ], + }; + + const message = renderHookActionFeedback(result, mockFormatScope); + expect(message).toBe( + 'Hook "test-hook" is now disabled in both workspace (/path/workspace.json) and user (/path/user.json) settings.', + ); + }); + }); +}); diff --git a/packages/cli/src/utils/hookUtils.ts b/packages/cli/src/utils/hookUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..c6583975ff333aab7318f4beea4c3ed5c677a7df --- /dev/null +++ b/packages/cli/src/utils/hookUtils.ts @@ -0,0 +1,67 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SettingScope } from '../config/settings.js'; +import type { HookActionResult } from './hookSettings.js'; + +/** + * Shared logic for building the core hook action message while allowing the + * caller to control how each scope and its path are rendered (e.g., bolding or + * dimming). + */ +export function renderHookActionFeedback( + result: HookActionResult, + formatScope: (label: string, path: string) => string, +): string { + const { hookName, action, status, error } = result; + + if (status === 'error') { + return ( + error || + `An error occurred while attempting to ${action} hook "${hookName}".` + ); + } + + if (status === 'no-op') { + return `Hook "${hookName}" is already ${action === 'enable' ? 'enabled' : 'disabled'}.`; + } + + const isEnable = action === 'enable'; + const actionVerb = isEnable ? 'enabled' : 'disabled'; + const preposition = isEnable + ? 'by removing it from the disabled list in' + : 'by adding it to the disabled list in'; + + const formatScopeItem = (s: { scope: SettingScope; path: string }) => { + const label = + s.scope === SettingScope.Workspace ? 'workspace' : s.scope.toLowerCase(); + return formatScope(label, s.path); + }; + + const totalAffectedScopes = [ + ...result.modifiedScopes, + ...result.alreadyInStateScopes, + ]; + + if (totalAffectedScopes.length === 0) { + // This case should ideally not happen, but as a safeguard, return a generic message. + return `Hook "${hookName}" ${actionVerb}.`; + } + + if (totalAffectedScopes.length === 2) { + const s1 = formatScopeItem(totalAffectedScopes[0]); + const s2 = formatScopeItem(totalAffectedScopes[1]); + + if (isEnable) { + return `Hook "${hookName}" ${actionVerb} ${preposition} ${s1} and ${s2} settings.`; + } else { + return `Hook "${hookName}" is now disabled in both ${s1} and ${s2} settings.`; + } + } + + const s = formatScopeItem(totalAffectedScopes[0]); + return `Hook "${hookName}" ${actionVerb} ${preposition} ${s} settings.`; +} diff --git a/packages/cli/src/utils/installationInfo.ts b/packages/cli/src/utils/installationInfo.ts new file mode 100644 index 0000000000000000000000000000000000000000..3db736d8d501a938358bf77bd5d2fd76cc034fd1 --- /dev/null +++ b/packages/cli/src/utils/installationInfo.ts @@ -0,0 +1,219 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { debugLogger, isGitRepository } from '@google/gemini-cli-core'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as childProcess from 'node:child_process'; +import process from 'node:process'; + +export const isDevelopment = process.env['NODE_ENV'] === 'development'; + +export enum PackageManager { + NPM = 'npm', + YARN = 'yarn', + PNPM = 'pnpm', + PNPX = 'pnpx', + BUN = 'bun', + BUNX = 'bunx', + HOMEBREW = 'homebrew', + NPX = 'npx', + BINARY = 'binary', + VOLTA = 'volta', + UNKNOWN = 'unknown', +} + +export interface InstallationInfo { + packageManager: PackageManager; + isGlobal: boolean; + updateCommand?: string; + updateMessage?: string; +} + +export function getInstallationInfo( + projectRoot: string, + isAutoUpdateEnabled: boolean, +): InstallationInfo { + const cliPath = process.argv[1]; + if (!cliPath) { + return { packageManager: PackageManager.UNKNOWN, isGlobal: false }; + } + + try { + // Check for standalone binary first + if (process.env['IS_BINARY'] === 'true') { + return { + packageManager: PackageManager.BINARY, + isGlobal: true, + updateMessage: + 'Running as a standalone binary. Please update by downloading the latest version from GitHub.', + }; + } + + // Normalize path separators to forward slashes for consistent matching. + const realPath = fs.realpathSync(cliPath).replace(/\\/g, '/'); + const normalizedProjectRoot = projectRoot?.replace(/\\/g, '/'); + const isGit = isGitRepository(process.cwd()); + + // Check for local git clone first + if ( + isGit && + normalizedProjectRoot && + realPath.startsWith(normalizedProjectRoot) && + !realPath.includes('/node_modules/') + ) { + return { + packageManager: PackageManager.UNKNOWN, // Not managed by a package manager in this sense + isGlobal: false, + updateMessage: + 'Running from a local git clone. Please update with "git pull".', + }; + } + + // Check for npx/pnpx + if (realPath.includes('/.npm/_npx') || realPath.includes('/npm/_npx')) { + return { + packageManager: PackageManager.NPX, + isGlobal: false, + updateMessage: 'Running via npx, update not applicable.', + }; + } + if ( + realPath.includes('/.pnpm/_pnpx') || + realPath.includes('/.cache/pnpm/dlx') + ) { + return { + packageManager: PackageManager.PNPX, + isGlobal: false, + updateMessage: 'Running via pnpx, update not applicable.', + }; + } + + // Check for Homebrew + if (process.platform === 'darwin') { + try { + const brewPrefix = childProcess + .execSync('brew --prefix gemini-cli', { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }) + .trim(); + const brewRealPath = fs.realpathSync(brewPrefix); + + if (realPath.startsWith(brewRealPath)) { + return { + packageManager: PackageManager.HOMEBREW, + isGlobal: true, + updateMessage: + 'Installed via Homebrew. Please update with "brew upgrade gemini-cli".', + }; + } + } catch { + // Brew is not installed or gemini-cli is not installed via brew. + // Continue to the next check. + } + } + + // Check for Volta + if (realPath.includes('/.volta/') || realPath.includes('/Volta/')) { + const updateCommand = 'volta install @google/gemini-cli@latest'; + return { + packageManager: PackageManager.VOLTA, + isGlobal: true, + updateCommand, + updateMessage: isAutoUpdateEnabled + ? 'Installed with Volta. Attempting to automatically update now...' + : `Please run ${updateCommand} to update`, + }; + } + + // Check for pnpm + if ( + realPath.includes('/.pnpm/global') || + realPath.includes('/.local/share/pnpm') || + realPath.includes('/Library/pnpm/global/') || + realPath.includes('/AppData/Local/pnpm/global/') + ) { + const updateCommand = 'pnpm add -g @google/gemini-cli@latest'; + return { + packageManager: PackageManager.PNPM, + isGlobal: true, + updateCommand, + updateMessage: isAutoUpdateEnabled + ? 'Installed with pnpm. Attempting to automatically update now...' + : `Please run ${updateCommand} to update`, + }; + } + + // Check for yarn + if (realPath.includes('/.yarn/global')) { + const updateCommand = 'yarn global add @google/gemini-cli@latest'; + return { + packageManager: PackageManager.YARN, + isGlobal: true, + updateCommand, + updateMessage: isAutoUpdateEnabled + ? 'Installed with yarn. Attempting to automatically update now...' + : `Please run ${updateCommand} to update`, + }; + } + + // Check for bun + if (realPath.includes('/.bun/install/cache')) { + return { + packageManager: PackageManager.BUNX, + isGlobal: false, + updateMessage: 'Running via bunx, update not applicable.', + }; + } + if (realPath.includes('/.bun/install/global')) { + const updateCommand = 'bun add -g @google/gemini-cli@latest'; + return { + packageManager: PackageManager.BUN, + isGlobal: true, + updateCommand, + updateMessage: isAutoUpdateEnabled + ? 'Installed with bun. Attempting to automatically update now...' + : `Please run ${updateCommand} to update`, + }; + } + + // Check for local install + if ( + normalizedProjectRoot && + realPath.startsWith(`${normalizedProjectRoot}/node_modules`) + ) { + let pm = PackageManager.NPM; + if (fs.existsSync(path.join(projectRoot, 'yarn.lock'))) { + pm = PackageManager.YARN; + } else if (fs.existsSync(path.join(projectRoot, 'pnpm-lock.yaml'))) { + pm = PackageManager.PNPM; + } else if (fs.existsSync(path.join(projectRoot, 'bun.lockb'))) { + pm = PackageManager.BUN; + } + return { + packageManager: pm, + isGlobal: false, + updateMessage: + "Locally installed. Please update via your project's package.json.", + }; + } + + // Assume global npm + const updateCommand = 'npm install -g @google/gemini-cli@latest'; + return { + packageManager: PackageManager.NPM, + isGlobal: true, + updateCommand, + updateMessage: isAutoUpdateEnabled + ? 'Installed with npm. Attempting to automatically update now...' + : `Please run ${updateCommand} to update`, + }; + } catch (error) { + debugLogger.log(error); + return { packageManager: PackageManager.UNKNOWN, isGlobal: false }; + } +} diff --git a/packages/cli/src/utils/math.ts b/packages/cli/src/utils/math.ts new file mode 100644 index 0000000000000000000000000000000000000000..f067a4ef95121e2a9a291f2a030a60bed9fe262d --- /dev/null +++ b/packages/cli/src/utils/math.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Linearly interpolates between two values. + * + * @param start The start value. + * @param end The end value. + * @param t The interpolation amount (typically between 0 and 1). + */ +export const lerp = (start: number, end: number, t: number): number => + start + (end - start) * t; diff --git a/packages/cli/src/utils/persistentState.test.ts b/packages/cli/src/utils/persistentState.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..5457ae0ec9164fe17a9a25647ecf9cace6eda1de --- /dev/null +++ b/packages/cli/src/utils/persistentState.test.ts @@ -0,0 +1,83 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { Storage, debugLogger } from '@google/gemini-cli-core'; +import { PersistentState } from './persistentState.js'; + +vi.mock('node:fs'); +vi.mock('@google/gemini-cli-core', () => ({ + Storage: { + getGlobalGeminiDir: vi.fn(), + }, + debugLogger: { + warn: vi.fn(), + }, +})); + +describe('PersistentState', () => { + let persistentState: PersistentState; + const mockDir = '/mock/dir'; + const mockFilePath = path.join(mockDir, 'state.json'); + + beforeEach(() => { + vi.resetAllMocks(); + vi.mocked(Storage.getGlobalGeminiDir).mockReturnValue(mockDir); + persistentState = new PersistentState(); + }); + + it('should load state from file if it exists', () => { + const mockData = { defaultBannerShownCount: { banner1: 1 } }; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); + + const value = persistentState.get('defaultBannerShownCount'); + expect(value).toEqual(mockData.defaultBannerShownCount); + expect(fs.readFileSync).toHaveBeenCalledWith(mockFilePath, 'utf-8'); + }); + + it('should return undefined if key does not exist', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + const value = persistentState.get('defaultBannerShownCount'); + expect(value).toBeUndefined(); + }); + + it('should save state to file', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + persistentState.set('defaultBannerShownCount', { banner1: 1 }); + + expect(fs.mkdirSync).toHaveBeenCalledWith(path.normalize(mockDir), { + recursive: true, + }); + expect(fs.writeFileSync).toHaveBeenCalledWith( + mockFilePath, + JSON.stringify({ defaultBannerShownCount: { banner1: 1 } }, null, 2), + ); + }); + + it('should handle load errors and start fresh', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.readFileSync).mockImplementation(() => { + throw new Error('Read error'); + }); + + const value = persistentState.get('defaultBannerShownCount'); + expect(value).toBeUndefined(); + expect(debugLogger.warn).toHaveBeenCalled(); + }); + + it('should handle save errors', () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.writeFileSync).mockImplementation(() => { + throw new Error('Write error'); + }); + + persistentState.set('defaultBannerShownCount', { banner1: 1 }); + expect(debugLogger.warn).toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/utils/persistentState.ts b/packages/cli/src/utils/persistentState.ts new file mode 100644 index 0000000000000000000000000000000000000000..e886181704ac80930c4896eeeca24efc4ed41f40 --- /dev/null +++ b/packages/cli/src/utils/persistentState.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Storage, debugLogger } from '@google/gemini-cli-core'; +import * as fs from 'node:fs'; +import * as path from 'node:path'; + +const STATE_FILENAME = 'state.json'; + +interface PersistentStateData { + defaultBannerShownCount?: Record; + terminalSetupPromptShown?: boolean; + tipsShown?: number; + hasSeenScreenReaderNudge?: boolean; + focusUiEnabled?: boolean; + startupWarningCounts?: Record; + // Add other persistent state keys here as needed +} + +export class PersistentState { + private cache: PersistentStateData | null = null; + private filePath: string | null = null; + + private getPath(): string { + if (!this.filePath) { + this.filePath = path.join(Storage.getGlobalGeminiDir(), STATE_FILENAME); + } + return this.filePath; + } + + private load(): PersistentStateData { + if (this.cache) { + return this.cache; + } + try { + const filePath = this.getPath(); + if (fs.existsSync(filePath)) { + const content = fs.readFileSync(filePath, 'utf-8'); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + this.cache = JSON.parse(content); + } else { + this.cache = {}; + } + } catch (error) { + debugLogger.warn('Failed to load persistent state:', error); + // If error reading (e.g. corrupt JSON), start fresh + this.cache = {}; + } + return this.cache!; + } + + private save() { + if (!this.cache) return; + try { + const filePath = this.getPath(); + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + fs.writeFileSync(filePath, JSON.stringify(this.cache, null, 2)); + } catch (error) { + debugLogger.warn('Failed to save persistent state:', error); + } + } + + get( + key: K, + ): PersistentStateData[K] | undefined { + return this.load()[key]; + } + + set( + key: K, + value: PersistentStateData[K], + ): void { + this.load(); // ensure loaded + this.cache![key] = value; + this.save(); + } +} + +export const persistentState = new PersistentState(); diff --git a/packages/cli/src/utils/readStdin.test.ts b/packages/cli/src/utils/readStdin.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a0d4d42ea48ffc645a02ea69772bec2dba6f130 --- /dev/null +++ b/packages/cli/src/utils/readStdin.test.ts @@ -0,0 +1,192 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { readStdin } from './readStdin.js'; +import { debugLogger } from '@google/gemini-cli-core'; + +vi.mock('@google/gemini-cli-core', () => ({ + debugLogger: { + warn: vi.fn(), + }, +})); + +// Mock process.stdin +const mockStdin = { + setEncoding: vi.fn(), + read: vi.fn(), + on: vi.fn(), + removeListener: vi.fn(), + destroy: vi.fn(), + listeners: vi.fn().mockReturnValue([]), + listenerCount: vi.fn().mockReturnValue(0), +}; + +describe('readStdin', () => { + let originalStdin: typeof process.stdin; + let onReadableHandler: () => void; + let onEndHandler: () => void; + let onErrorHandler: (err: Error) => void; + + beforeEach(() => { + vi.clearAllMocks(); + originalStdin = process.stdin; + + // Replace process.stdin with our mock + Object.defineProperty(process, 'stdin', { + value: mockStdin, + writable: true, + configurable: true, + }); + + // Capture event handlers + mockStdin.on.mockImplementation( + (event: string, handler: (...args: unknown[]) => void) => { + if (event === 'readable') onReadableHandler = handler as () => void; + if (event === 'end') onEndHandler = handler as () => void; + if (event === 'error') onErrorHandler = handler as (err: Error) => void; + }, + ); + mockStdin.listeners.mockReturnValue([]); + mockStdin.listenerCount.mockReturnValue(0); + }); + + afterEach(() => { + vi.restoreAllMocks(); + Object.defineProperty(process, 'stdin', { + value: originalStdin, + writable: true, + configurable: true, + }); + }); + + it('should read and accumulate data from stdin', async () => { + mockStdin.read + .mockReturnValueOnce('I love ') + .mockReturnValueOnce('Gemini!') + .mockReturnValueOnce(null); + + const promise = readStdin(); + + // Trigger readable event + onReadableHandler(); + + // Trigger end to resolve + onEndHandler(); + + await expect(promise).resolves.toBe('I love Gemini!'); + }); + + it('should handle empty stdin input', async () => { + mockStdin.read.mockReturnValue(null); + + const promise = readStdin(); + + // Trigger end immediately + onEndHandler(); + + await expect(promise).resolves.toBe(''); + }); + + // Emulate terminals where stdin is not TTY (eg: git bash) + it('should timeout and resolve with empty string when no input is available', async () => { + vi.useFakeTimers(); + + const promise = readStdin(); + + // Fast-forward past the timeout (to run test faster) + vi.advanceTimersByTime(500); + + await expect(promise).resolves.toBe(''); + + vi.useRealTimers(); + }); + + it('should clear timeout once when data is received and resolve with data', async () => { + const clearTimeoutSpy = vi.spyOn(global, 'clearTimeout'); + mockStdin.read + .mockReturnValueOnce('chunk1') + .mockReturnValueOnce('chunk2') + .mockReturnValueOnce(null); + + const promise = readStdin(); + + // Trigger readable event + onReadableHandler(); + + expect(clearTimeoutSpy).toHaveBeenCalledOnce(); + + // Trigger end to resolve + onEndHandler(); + + await expect(promise).resolves.toBe('chunk1chunk2'); + }); + + it('should truncate input if it exceeds MAX_STDIN_SIZE', async () => { + const MAX_STDIN_SIZE = 8 * 1024 * 1024; + const largeChunk = 'a'.repeat(MAX_STDIN_SIZE + 100); + mockStdin.read.mockReturnValueOnce(largeChunk).mockReturnValueOnce(null); + + const promise = readStdin(); + onReadableHandler(); + + await expect(promise).resolves.toBe('a'.repeat(MAX_STDIN_SIZE)); + expect(debugLogger.warn).toHaveBeenCalledWith( + `Warning: stdin input truncated to ${MAX_STDIN_SIZE} bytes.`, + ); + expect(mockStdin.destroy).toHaveBeenCalled(); + }); + + it('should truncate multi-byte characters at byte boundary', async () => { + const MAX_STDIN_SIZE = 8 * 1024 * 1024; + // '한' is 3 bytes. 2,796,202 * 3 = 8,388,606 bytes. + // 2,796,203 * 3 = 8,388,609 bytes. + const charCount = Math.floor(MAX_STDIN_SIZE / 3) + 1; + const multiByteChunk = '한'.repeat(charCount); + + mockStdin.read + .mockReturnValueOnce(multiByteChunk) + .mockReturnValueOnce(null); + + const promise = readStdin(); + onReadableHandler(); + + const result = await promise; + const resultBytes = Buffer.byteLength(result, 'utf8'); + + expect(resultBytes).toBeLessThanOrEqual(MAX_STDIN_SIZE); + expect(resultBytes).toBe(Math.floor(MAX_STDIN_SIZE / 3) * 3); + expect(result).not.toContain('\uFFFD'); // No replacement characters + }); + + it('should use byte length instead of string length for limit', async () => { + const MAX_STDIN_SIZE = 8 * 1024 * 1024; + // '한' is 3 bytes. If we use string length, we'd allow 8M characters = 24MB. + // We want to ensure it stops at 8MB. + const charCount = MAX_STDIN_SIZE; // 8M characters = 24MB + const multiByteChunk = '한'.repeat(charCount); + + mockStdin.read + .mockReturnValueOnce(multiByteChunk) + .mockReturnValueOnce(null); + + const promise = readStdin(); + onReadableHandler(); + + const result = await promise; + expect(Buffer.byteLength(result, 'utf8')).toBeLessThanOrEqual( + MAX_STDIN_SIZE, + ); + expect(result.length).toBeLessThan(charCount); + }); + + it('should handle stdin error', async () => { + const promise = readStdin(); + const error = new Error('stdin error'); + onErrorHandler(error); + await expect(promise).rejects.toThrow('stdin error'); + }); +}); diff --git a/packages/cli/src/utils/readStdin_safety.test.ts b/packages/cli/src/utils/readStdin_safety.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..4e4ba08f6d54370b884e4160395861ba863ada58 --- /dev/null +++ b/packages/cli/src/utils/readStdin_safety.test.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, expect, it, beforeEach, afterEach } from 'vitest'; +import { readStdin } from './readStdin.js'; +import { EventEmitter } from 'node:events'; + +// Mock debugLogger to avoid clutter +vi.mock('@google/gemini-cli-core', () => ({ + debugLogger: { + warn: vi.fn(), + }, +})); + +describe('readStdin EIO Reproduction', () => { + let originalStdin: typeof process.stdin; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let fakeStdin: EventEmitter & { setEncoding: any; read: any; destroy: any }; + + beforeEach(() => { + originalStdin = process.stdin; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + fakeStdin = new EventEmitter() as any; + fakeStdin.setEncoding = vi.fn(); + fakeStdin.read = vi.fn().mockReturnValue(null); // Return null to simulate end of reading or no data + fakeStdin.destroy = vi.fn(); + + Object.defineProperty(process, 'stdin', { + value: fakeStdin, + writable: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process, 'stdin', { + value: originalStdin, + writable: true, + configurable: true, + }); + vi.restoreAllMocks(); + }); + + it('crashes (throws unhandled error) if EIO happens after readStdin completes', async () => { + const promise = readStdin(); + fakeStdin.emit('end'); + await promise; + + // Verify listeners are removed (implementation detail check) + + // We expect 1 listener now (our no-op handler) because we started with 0. + + expect(fakeStdin.listenerCount('error')).toBe(1); + + // This mimics the crash. + + // We expect this NOT to throw now that we've added a no-op handler. + + expect(() => { + fakeStdin.emit('error', new Error('EIO')); + }).not.toThrow(); + }); + + it('does NOT add a no-op handler if another error listener is present', async () => { + const customErrorHandler = vi.fn(); + + fakeStdin.on('error', customErrorHandler); + + const promise = readStdin(); + + fakeStdin.emit('end'); + + await promise; + + // It should have exactly 1 listener (our custom one), not 2. + + expect(fakeStdin.listenerCount('error')).toBe(1); + + expect(fakeStdin.listeners('error')).toContain(customErrorHandler); + + // Triggering error should call our handler and NOT crash (because there is a listener) + + const error = new Error('EIO'); + + fakeStdin.emit('error', error); + + expect(customErrorHandler).toHaveBeenCalledWith(error); + }); +}); diff --git a/packages/cli/src/utils/resolvePath.test.ts b/packages/cli/src/utils/resolvePath.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..949ccea59a2905dec6a16dcb5761f03281056fa0 --- /dev/null +++ b/packages/cli/src/utils/resolvePath.test.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { resolvePath } from './resolvePath.js'; + +vi.mock('node:os', () => ({ + homedir: vi.fn(), +})); + +vi.mock('@google/gemini-cli-core', () => ({ + homedir: () => os.homedir(), +})); + +describe('resolvePath', () => { + beforeEach(() => { + vi.mocked(os.homedir).mockReturnValue('/home/user'); + }); + + it.each([ + ['', ''], + ['/foo/bar', path.normalize('/foo/bar')], + ['~/foo', path.join('/home/user', 'foo')], + ['~', path.normalize('/home/user')], + ['%userprofile%/foo', path.join('/home/user', 'foo')], + ['%USERPROFILE%/foo', path.join('/home/user', 'foo')], + ])('resolvePath(%s) should return %s', (input, expected) => { + expect(resolvePath(input)).toBe(expected); + }); + + it('should handle path normalization', () => { + expect(resolvePath('/foo//bar/../baz')).toBe(path.normalize('/foo/baz')); + }); +}); diff --git a/packages/cli/src/utils/resolvePath.ts b/packages/cli/src/utils/resolvePath.ts new file mode 100644 index 0000000000000000000000000000000000000000..14d5f77cbb3286ac5712e5fa33c0ba6bf1f91ea2 --- /dev/null +++ b/packages/cli/src/utils/resolvePath.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import { homedir } from '@google/gemini-cli-core'; + +export function resolvePath(p: string): string { + if (!p) { + return ''; + } + let expandedPath = p; + if (p.toLowerCase().startsWith('%userprofile%')) { + expandedPath = homedir() + p.substring('%userprofile%'.length); + } else if (p === '~' || p.startsWith('~/')) { + expandedPath = homedir() + p.substring(1); + } + return path.normalize(expandedPath); +} diff --git a/packages/cli/src/utils/sandbox-macos-profiles.test.ts b/packages/cli/src/utils/sandbox-macos-profiles.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6491d80cf246db151dd1281bcc34b7c19691fbd1 --- /dev/null +++ b/packages/cli/src/utils/sandbox-macos-profiles.test.ts @@ -0,0 +1,302 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; +import { BUILTIN_SEATBELT_PROFILE_CONTENTS } from './sandboxBuiltinProfiles.js'; + +const utilsDir = path.dirname(fileURLToPath(import.meta.url)); + +/** + * Strip SBPL comments (`; ...` to end of line) so assertions run against the + * actual sandbox rules rather than any keywords that happen to appear in the + * explanatory comments. + */ +function readRules(profile: string): string { + return readFileSync(path.join(utilsDir, profile), 'utf8') + .split('\n') + .map((line) => { + const commentStart = line.indexOf(';'); + return commentStart === -1 ? line : line.slice(0, commentStart); + }) + .join('\n'); +} + +const ALL_PROFILES = [ + 'sandbox-macos-permissive-open.sb', + 'sandbox-macos-permissive-proxied.sb', + 'sandbox-macos-restrictive-open.sb', + 'sandbox-macos-restrictive-proxied.sb', + 'sandbox-macos-strict-open.sb', + 'sandbox-macos-strict-proxied.sb', +]; + +const PERMISSIVE_PROFILES = [ + 'sandbox-macos-permissive-open.sb', + 'sandbox-macos-permissive-proxied.sb', +]; + +// These two profiles are the default macOS Seatbelt profiles, so the invariants +// below must never silently regress. Keep them deny-default and confirm the +// reviewed allow-list stays in place. +describe('macOS permissive Seatbelt profiles', () => { + describe.each(PERMISSIVE_PROFILES)('%s', (profile) => { + const rules = readRules(profile); + + it('uses a deny-default foundation', () => { + expect(rules).toContain('(deny default)'); + }); + + it('does not use an allow-default foundation', () => { + expect(rules).not.toContain('(allow default)'); + }); + + it('does not permit filesystem (un)mounts', () => { + expect(rules).not.toMatch(/file-mount/); + expect(rules).not.toMatch(/file-unmount/); + }); + + it('does not grant broad service lookups', () => { + expect(rules).not.toMatch(/launchd/); + expect(rules).not.toMatch(/launchservices/i); + }); + + it('allows binding local ports for dev/test servers', () => { + expect(rules).toContain('(allow network-bind (local ip "*:*"))'); + }); + }); + + it('permissive-open keeps broad inbound and outbound network', () => { + const rules = readRules('sandbox-macos-permissive-open.sb'); + expect(rules).toContain('(allow network-inbound (local ip "*:*"))'); + expect(rules).toMatch(/\(allow network-outbound\)/); + }); + + it('permissive-proxied confines outbound to the proxy', () => { + const rules = readRules('sandbox-macos-permissive-proxied.sb'); + expect(rules).toContain( + '(allow network-outbound (remote tcp "localhost:8877"))', + ); + // Proxied mode must never grant unrestricted outbound network. + expect(rules).not.toMatch(/\(allow network-outbound\)/); + }); +}); + +describe('macOS Seatbelt container runtime isolation', () => { + describe.each(ALL_PROFILES)('%s', (profile) => { + const rules = readRules(profile); + + it('denies access to Docker daemon sockets', () => { + expect(rules).toContain('(literal "/var/run/docker.sock")'); + expect(rules).toContain('(literal "/private/var/run/docker.sock")'); + expect(rules).toContain( + '(subpath (string-append (param "HOME_DIR") "/.docker/run"))', + ); + }); + + it('denies execution of container runtime binaries', () => { + expect(rules).toContain('(literal "/usr/local/bin/docker")'); + expect(rules).toContain('(literal "/usr/bin/docker")'); + expect(rules).toContain('(literal "/opt/homebrew/bin/docker")'); + expect(rules).toContain('(literal "/opt/homebrew/bin/podman")'); + expect(rules).toContain('(literal "/opt/homebrew/bin/colima")'); + }); + + it('denies Docker Mach/XPC service lookups', () => { + expect(rules).toContain('(xpc-service-name-prefix "com.docker.")'); + expect(rules).toContain('(global-name-prefix "com.docker.")'); + }); + + it('denies Docker POSIX shared memory', () => { + expect(rules).toContain('(ipc-posix-name-prefix "docker")'); + expect(rules).toContain('(ipc-posix-name-prefix "com.docker.")'); + }); + }); +}); + +describe('macOS Seatbelt Gemini configuration isolation', () => { + describe.each(ALL_PROFILES)('%s', (profile) => { + const rules = readRules(profile); + + it('does not allow writes to HOME_DIR/.gemini', () => { + const allowWriteMatch = rules.match(/\(allow file-write\*[\s\S]*?\n\)/); + expect(allowWriteMatch).not.toBeNull(); + expect(allowWriteMatch![0]).not.toContain( + '(subpath (string-append (param "HOME_DIR") "/.gemini"))', + ); + }); + + it('denies writing to Gemini configuration directory and sensitive files', () => { + expect(rules).toContain('(deny file-write*'); + expect(rules).toContain( + '(subpath (string-append (param "HOME_DIR") "/.gemini"))', + ); + expect(rules).toContain('(regex #"/trustedFolders\\.json$")'); + expect(rules).toContain('(regex #"/policy_integrity\\.json$")'); + }); + + it('denies reading sensitive credential and environment files', () => { + expect(rules).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/oauth_creds.json"))', + ); + expect(rules).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/gemini-credentials.json"))', + ); + expect(rules).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/mcp-oauth-tokens.json"))', + ); + expect(rules).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/a2a-oauth-tokens.json"))', + ); + expect(rules).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/google_accounts.json"))', + ); + expect(rules).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/trusted_hooks.json"))', + ); + expect(rules).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/trustedFolders.json"))', + ); + expect(rules).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/policy_integrity.json"))', + ); + expect(rules).toContain('(regex #"/google_accounts\\.json$")'); + expect(rules).toContain('(regex #"/trusted_hooks\\.json$")'); + expect(rules).toContain('(regex #"/trustedFolders\\.json$")'); + expect(rules).toContain('(regex #"/policy_integrity\\.json$")'); + }); + }); +}); + +const RESTRICTIVE_AND_STRICT_PROFILES = [ + 'sandbox-macos-restrictive-open.sb', + 'sandbox-macos-restrictive-proxied.sb', + 'sandbox-macos-strict-open.sb', + 'sandbox-macos-strict-proxied.sb', +]; + +describe('macOS Seatbelt non-sensitive configuration read access', () => { + describe.each(RESTRICTIVE_AND_STRICT_PROFILES)('%s', (profile) => { + const rules = readRules(profile); + + it('allows reading settings.json and keybindings.json configuration files', () => { + expect(rules).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/settings.json"))', + ); + expect(rules).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/keybindings.json"))', + ); + }); + }); +}); + +const STRICT_PROFILES = [ + 'sandbox-macos-strict-open.sb', + 'sandbox-macos-strict-proxied.sb', +]; + +describe('macOS Seatbelt strict profile scoped read access', () => { + describe.each(STRICT_PROFILES)('%s', (profile) => { + const rules = readRules(profile); + + it('does not allow broad read access to ~/.gemini subpath', () => { + const allowReadMatch = rules.match(/\(allow file-read\*[\s\S]*?\n\)/); + expect(allowReadMatch).not.toBeNull(); + expect(allowReadMatch![0]).not.toContain( + '(subpath (string-append (param "HOME_DIR") "/.gemini"))', + ); + }); + }); +}); + +describe('BUILTIN_SEATBELT_PROFILE_CONTENTS consistency', () => { + const profileKeyMap: Record = { + 'sandbox-macos-permissive-open.sb': 'permissive-open', + 'sandbox-macos-permissive-proxied.sb': 'permissive-proxied', + 'sandbox-macos-restrictive-open.sb': 'restrictive-open', + 'sandbox-macos-restrictive-proxied.sb': 'restrictive-proxied', + 'sandbox-macos-strict-open.sb': 'strict-open', + 'sandbox-macos-strict-proxied.sb': 'strict-proxied', + }; + + describe.each(Object.entries(profileKeyMap))( + '%s matches embedded %s', + (file, key) => { + it('contains container isolation rules in embedded content', () => { + const embeddedContent = BUILTIN_SEATBELT_PROFILE_CONTENTS[key]; + expect(embeddedContent).toBeDefined(); + expect(embeddedContent).toContain('(literal "/var/run/docker.sock")'); + expect(embeddedContent).toContain('(literal "/usr/local/bin/docker")'); + expect(embeddedContent).toContain( + '(xpc-service-name-prefix "com.docker.")', + ); + }); + + it('contains Gemini config isolation and credential denial rules in embedded content', () => { + const embeddedContent = BUILTIN_SEATBELT_PROFILE_CONTENTS[key]; + expect(embeddedContent).toBeDefined(); + expect(embeddedContent).toContain('(deny file-write*'); + expect(embeddedContent).toContain( + '(subpath (string-append (param "HOME_DIR") "/.gemini"))', + ); + expect(embeddedContent).toContain('(regex #"/trustedFolders\\.json$")'); + expect(embeddedContent).toContain( + '(regex #"/policy_integrity\\.json$")', + ); + expect(embeddedContent).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/oauth_creds.json"))', + ); + expect(embeddedContent).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/google_accounts.json"))', + ); + expect(embeddedContent).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/trusted_hooks.json"))', + ); + expect(embeddedContent).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/trustedFolders.json"))', + ); + expect(embeddedContent).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/policy_integrity.json"))', + ); + }); + + it('contains settings and keybindings read rules in embedded content for restrictive and strict profiles', () => { + if ( + [ + 'restrictive-open', + 'restrictive-proxied', + 'strict-open', + 'strict-proxied', + ].includes(key) + ) { + const embeddedContent = BUILTIN_SEATBELT_PROFILE_CONTENTS[key]; + expect(embeddedContent).toBeDefined(); + expect(embeddedContent).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/settings.json"))', + ); + expect(embeddedContent).toContain( + '(literal (string-append (param "HOME_DIR") "/.gemini/keybindings.json"))', + ); + } + }); + + it('does not contain broad .gemini subpath read rule in embedded strict content', () => { + if (['strict-open', 'strict-proxied'].includes(key)) { + const embeddedContent = BUILTIN_SEATBELT_PROFILE_CONTENTS[key]; + const allowReadMatch = embeddedContent.match( + /\(allow file-read\*[\s\S]*?\n\)/, + ); + expect(allowReadMatch).not.toBeNull(); + expect(allowReadMatch![0]).not.toContain( + '(subpath (string-append (param "HOME_DIR") "/.gemini"))', + ); + } + }); + }, + ); +}); diff --git a/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb b/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb new file mode 100644 index 0000000000000000000000000000000000000000..326ebe43f9ed7320dd10fa804c709a8d520be6d3 --- /dev/null +++ b/packages/cli/src/utils/sandbox-macos-restrictive-proxied.sb @@ -0,0 +1,186 @@ +(version 1) + +;; deny everything by default +(deny default) + +;; allow reading files from anywhere on host +(allow file-read*) +(allow file-read* + (literal (string-append (param "HOME_DIR") "/.gemini/settings.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/keybindings.json")) +) + +;; allow exec/fork (children inherit policy) +(allow process-exec) +(allow process-fork) + +;; allow signals to self, e.g. SIGPIPE on write to closed pipe +(allow signal (target self)) + +;; allow read access to specific information about system +;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + (sysctl-name "hw.optional.arm.FEAT_BF16") + (sysctl-name "hw.optional.arm.FEAT_DotProd") + (sysctl-name "hw.optional.arm.FEAT_FCMA") + (sysctl-name "hw.optional.arm.FEAT_FHM") + (sysctl-name "hw.optional.arm.FEAT_FP16") + (sysctl-name "hw.optional.arm.FEAT_I8MM") + (sysctl-name "hw.optional.arm.FEAT_JSCVT") + (sysctl-name "hw.optional.arm.FEAT_LSE") + (sysctl-name "hw.optional.arm.FEAT_RDM") + (sysctl-name "hw.optional.arm.FEAT_SHA512") + (sysctl-name "hw.optional.armv8_2_sha512") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name-prefix "hw.perflevel") +) + +;; allow writes to specific paths +(allow file-write* + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + ;; Allow writes to included directories from --include-directories + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) + (literal "/dev/stdout") + (literal "/dev/stderr") + (literal "/dev/null") +) + +;; allow communication with sysmond for process listing (e.g. for pgrep) +(allow mach-lookup (global-name "com.apple.sysmond")) + +;; enable terminal access required by ink +;; fixes setRawMode EPERM failure (at node:tty:81:24) +(allow file-ioctl (regex #"^/dev/tty.*")) + +;; allow inbound network traffic on debugger port +(allow network-inbound (local ip "localhost:9229")) + +;; allow outbound network traffic through proxy on localhost:8877 +;; set `GEMINI_SANDBOX_PROXY_COMMAND=` to run proxy alongside sandbox +;; proxy must listen on :::8877 (see docs/examples/proxy-script.md) +(allow network-outbound (remote tcp "localhost:8877")) + +;; Block Docker & container daemon UNIX domain sockets (read & write) +(deny file-read* file-write* + (literal "/var/run/docker.sock") + (literal "/var/run/docker.sock.raw") + (literal "/private/var/run/docker.sock") + (literal "/private/var/run/docker.sock.raw") + (subpath "/var/run/docker") + (subpath "/private/var/run/docker") + (subpath "/Users/Shared/.docker") + (subpath (string-append (param "HOME_DIR") "/.docker/run")) + (subpath (string-append (param "HOME_DIR") "/.docker/desktop")) + (subpath (string-append (param "HOME_DIR") "/.colima")) + (subpath (string-append (param "HOME_DIR") "/.orbstack/run")) + (subpath (string-append (param "HOME_DIR") "/.rd")) +) + +;; Block container CLI and daemon binaries from execution +(deny process-exec + (literal "/usr/local/bin/docker") + (literal "/usr/local/bin/dockerd") + (literal "/usr/local/bin/docker-compose") + (literal "/usr/bin/docker") + (literal "/opt/homebrew/bin/docker") + (literal "/opt/homebrew/bin/dockerd") + (literal "/opt/homebrew/bin/docker-compose") + (literal "/opt/homebrew/bin/podman") + (literal "/usr/local/bin/podman") + (literal "/opt/homebrew/bin/colima") + (literal "/usr/local/bin/colima") + (literal "/opt/homebrew/bin/orb") + (literal "/Applications/Docker.app/Contents/Resources/bin/docker") + (subpath "/Applications/Docker.app/Contents/MacOS/") + (subpath "/Applications/OrbStack.app/Contents/MacOS/") + (subpath "/Applications/Rancher Desktop.app/Contents/MacOS/") +) + +;; Block com.docker.* and container manager Mach lookup / XPC services +(deny mach-lookup + (xpc-service-name-prefix "com.docker.") + (global-name-prefix "com.docker.") + (global-name-prefix "dev.kdrag0n.OrbStack") +) + +;; Block Docker POSIX Shared Memory +(deny ipc-posix-shm* + (ipc-posix-name-prefix "docker") + (ipc-posix-name-prefix "com.docker.") +) + +;; Deny writing to user's Gemini CLI configuration directory and sensitive files +(deny file-write* + (subpath (string-append (param "HOME_DIR") "/.gemini")) + (regex #"/oauth_creds\.json$") + (regex #"/gemini-credentials\.json$") + (regex #"/mcp-oauth-tokens\.json$") + (regex #"/a2a-oauth-tokens\.json$") + (regex #"/google_accounts\.json$") + (regex #"/trusted_hooks\.json$") + (regex #"/trustedFolders\.json$") + (regex #"/policy_integrity\.json$") + (regex #"/\.env($|\..*)") +) + +;; Block reading sensitive credentials and environment files +(deny file-read* + (literal (string-append (param "HOME_DIR") "/.gemini/oauth_creds.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/gemini-credentials.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/mcp-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/a2a-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/google_accounts.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trusted_hooks.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trustedFolders.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/policy_integrity.json")) + (regex #"/oauth_creds\.json$") + (regex #"/gemini-credentials\.json$") + (regex #"/mcp-oauth-tokens\.json$") + (regex #"/a2a-oauth-tokens\.json$") + (regex #"/google_accounts\.json$") + (regex #"/trusted_hooks\.json$") + (regex #"/trustedFolders\.json$") + (regex #"/policy_integrity\.json$") + (regex #"/\.env($|\..*)") +) + + diff --git a/packages/cli/src/utils/sandbox-macos-strict-proxied.sb b/packages/cli/src/utils/sandbox-macos-strict-proxied.sb new file mode 100644 index 0000000000000000000000000000000000000000..4a487ea85f030231c80021a0920909240b323f56 --- /dev/null +++ b/packages/cli/src/utils/sandbox-macos-strict-proxied.sb @@ -0,0 +1,218 @@ +(version 1) + +;; deny everything by default +(deny default) + +;; allow reading ONLY from working directory, system paths, and essential user paths +(allow file-read* + (literal "/") + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + ;; Only allow reading essential dotfiles/directories under HOME, not the entire HOME + (literal (string-append (param "HOME_DIR") "/.gemini/settings.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/keybindings.json")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + (literal (string-append (param "HOME_DIR") "/.gitconfig")) + (subpath (string-append (param "HOME_DIR") "/.nvm")) + (subpath (string-append (param "HOME_DIR") "/.fnm")) + (subpath (string-append (param "HOME_DIR") "/.node")) + (subpath (string-append (param "HOME_DIR") "/.config")) + ;; Allow reads from included directories + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) + ;; System paths required for Node.js, shell, and common tools + (subpath "/usr") + (subpath "/bin") + (subpath "/sbin") + (subpath "/Library") + (subpath "/System") + (subpath "/private") + (subpath "/dev") + (subpath "/etc") + (subpath "/opt") + (subpath "/Applications") +) + +;; allow path traversal everywhere (metadata only: stat/lstat, NOT readdir or file content) +;; this is needed for Node.js module resolution to traverse intermediate directories +(allow file-read-metadata) + +;; allow exec/fork (children inherit policy) +(allow process-exec) +(allow process-fork) + +;; allow signals to self, e.g. SIGPIPE on write to closed pipe +(allow signal (target self)) + +;; allow read access to specific information about system +;; from https://source.chromium.org/chromium/chromium/src/+/main:sandbox/policy/mac/common.sb;l=273-319;drc=7b3962fe2e5fc9e2ee58000dc8fbf3429d84d3bd +(allow sysctl-read + (sysctl-name "hw.activecpu") + (sysctl-name "hw.busfrequency_compat") + (sysctl-name "hw.byteorder") + (sysctl-name "hw.cacheconfig") + (sysctl-name "hw.cachelinesize_compat") + (sysctl-name "hw.cpufamily") + (sysctl-name "hw.cpufrequency_compat") + (sysctl-name "hw.cputype") + (sysctl-name "hw.l1dcachesize_compat") + (sysctl-name "hw.l1icachesize_compat") + (sysctl-name "hw.l2cachesize_compat") + (sysctl-name "hw.l3cachesize_compat") + (sysctl-name "hw.logicalcpu_max") + (sysctl-name "hw.machine") + (sysctl-name "hw.ncpu") + (sysctl-name "hw.nperflevels") + (sysctl-name "hw.optional.arm.FEAT_BF16") + (sysctl-name "hw.optional.arm.FEAT_DotProd") + (sysctl-name "hw.optional.arm.FEAT_FCMA") + (sysctl-name "hw.optional.arm.FEAT_FHM") + (sysctl-name "hw.optional.arm.FEAT_FP16") + (sysctl-name "hw.optional.arm.FEAT_I8MM") + (sysctl-name "hw.optional.arm.FEAT_JSCVT") + (sysctl-name "hw.optional.arm.FEAT_LSE") + (sysctl-name "hw.optional.arm.FEAT_RDM") + (sysctl-name "hw.optional.arm.FEAT_SHA512") + (sysctl-name "hw.optional.armv8_2_sha512") + (sysctl-name "hw.packages") + (sysctl-name "hw.pagesize_compat") + (sysctl-name "hw.physicalcpu_max") + (sysctl-name "hw.tbfrequency_compat") + (sysctl-name "hw.vectorunit") + (sysctl-name "kern.hostname") + (sysctl-name "kern.maxfilesperproc") + (sysctl-name "kern.osproductversion") + (sysctl-name "kern.osrelease") + (sysctl-name "kern.ostype") + (sysctl-name "kern.osvariant_status") + (sysctl-name "kern.osversion") + (sysctl-name "kern.secure_kernel") + (sysctl-name "kern.usrstack64") + (sysctl-name "kern.version") + (sysctl-name "sysctl.proc_cputype") + (sysctl-name-prefix "hw.perflevel") +) + +;; allow writes to specific paths +(allow file-write* + (subpath (param "TARGET_DIR")) + (subpath (param "TMP_DIR")) + (subpath (param "CACHE_DIR")) + (subpath (string-append (param "HOME_DIR") "/.npm")) + (subpath (string-append (param "HOME_DIR") "/.cache")) + ;; Allow writes to included directories from --include-directories + (subpath (param "INCLUDE_DIR_0")) + (subpath (param "INCLUDE_DIR_1")) + (subpath (param "INCLUDE_DIR_2")) + (subpath (param "INCLUDE_DIR_3")) + (subpath (param "INCLUDE_DIR_4")) + (literal "/dev/stdout") + (literal "/dev/stderr") + (literal "/dev/null") +) + +;; allow communication with sysmond for process listing (e.g. for pgrep) +(allow mach-lookup (global-name "com.apple.sysmond")) + +;; enable terminal access required by ink +;; fixes setRawMode EPERM failure (at node:tty:81:24) +(allow file-ioctl (regex #"^/dev/tty.*")) + +;; allow inbound network traffic on debugger port +(allow network-inbound (local ip "localhost:9229")) + +;; allow outbound network traffic through proxy on localhost:8877 +;; set `GEMINI_SANDBOX_PROXY_COMMAND=` to run proxy alongside sandbox +;; proxy must listen on :::8877 (see docs/examples/proxy-script.md) +(allow network-outbound (remote tcp "localhost:8877")) + +;; Block Docker & container daemon UNIX domain sockets (read & write) +(deny file-read* file-write* + (literal "/var/run/docker.sock") + (literal "/var/run/docker.sock.raw") + (literal "/private/var/run/docker.sock") + (literal "/private/var/run/docker.sock.raw") + (subpath "/var/run/docker") + (subpath "/private/var/run/docker") + (subpath "/Users/Shared/.docker") + (subpath (string-append (param "HOME_DIR") "/.docker/run")) + (subpath (string-append (param "HOME_DIR") "/.docker/desktop")) + (subpath (string-append (param "HOME_DIR") "/.colima")) + (subpath (string-append (param "HOME_DIR") "/.orbstack/run")) + (subpath (string-append (param "HOME_DIR") "/.rd")) +) + +;; Block container CLI and daemon binaries from execution +(deny process-exec + (literal "/usr/local/bin/docker") + (literal "/usr/local/bin/dockerd") + (literal "/usr/local/bin/docker-compose") + (literal "/usr/bin/docker") + (literal "/opt/homebrew/bin/docker") + (literal "/opt/homebrew/bin/dockerd") + (literal "/opt/homebrew/bin/docker-compose") + (literal "/opt/homebrew/bin/podman") + (literal "/usr/local/bin/podman") + (literal "/opt/homebrew/bin/colima") + (literal "/usr/local/bin/colima") + (literal "/opt/homebrew/bin/orb") + (literal "/Applications/Docker.app/Contents/Resources/bin/docker") + (subpath "/Applications/Docker.app/Contents/MacOS/") + (subpath "/Applications/OrbStack.app/Contents/MacOS/") + (subpath "/Applications/Rancher Desktop.app/Contents/MacOS/") +) + +;; Block com.docker.* and container manager Mach lookup / XPC services +(deny mach-lookup + (xpc-service-name-prefix "com.docker.") + (global-name-prefix "com.docker.") + (global-name-prefix "dev.kdrag0n.OrbStack") +) + +;; Block Docker POSIX Shared Memory +(deny ipc-posix-shm* + (ipc-posix-name-prefix "docker") + (ipc-posix-name-prefix "com.docker.") +) + +;; Deny writing to user's Gemini CLI configuration directory and sensitive files +(deny file-write* + (subpath (string-append (param "HOME_DIR") "/.gemini")) + (regex #"/oauth_creds\.json$") + (regex #"/gemini-credentials\.json$") + (regex #"/mcp-oauth-tokens\.json$") + (regex #"/a2a-oauth-tokens\.json$") + (regex #"/google_accounts\.json$") + (regex #"/trusted_hooks\.json$") + (regex #"/trustedFolders\.json$") + (regex #"/policy_integrity\.json$") + (regex #"/\.env($|\..*)") +) + +;; Block reading sensitive credentials and environment files +(deny file-read* + (literal (string-append (param "HOME_DIR") "/.gemini/oauth_creds.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/gemini-credentials.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/mcp-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/a2a-oauth-tokens.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/google_accounts.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trusted_hooks.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/trustedFolders.json")) + (literal (string-append (param "HOME_DIR") "/.gemini/policy_integrity.json")) + (regex #"/oauth_creds\.json$") + (regex #"/gemini-credentials\.json$") + (regex #"/mcp-oauth-tokens\.json$") + (regex #"/a2a-oauth-tokens\.json$") + (regex #"/google_accounts\.json$") + (regex #"/trusted_hooks\.json$") + (regex #"/trustedFolders\.json$") + (regex #"/policy_integrity\.json$") + (regex #"/\.env($|\..*)") +) + + diff --git a/packages/cli/src/utils/sandbox.ts b/packages/cli/src/utils/sandbox.ts new file mode 100644 index 0000000000000000000000000000000000000000..007a464508023df91ed394d2303ca34fdf163bd1 --- /dev/null +++ b/packages/cli/src/utils/sandbox.ts @@ -0,0 +1,1431 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + exec, + execFile, + execSync, + spawn, + spawnSync, + type ChildProcess, +} from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import os from 'node:os'; +import { fileURLToPath } from 'node:url'; +import { quote, parse } from 'shell-quote'; +import { promisify } from 'node:util'; +import type { Config, SandboxConfig } from '@google/gemini-cli-core'; +import { + coreEvents, + debugLogger, + FatalSandboxError, + GEMINI_DIR, + homedir, + resolveToRealPath, +} from '@google/gemini-cli-core'; +import { ConsolePatcher } from '../ui/utils/ConsolePatcher.js'; +import { randomBytes } from 'node:crypto'; +import stripJsonComments from 'strip-json-comments'; +import { + getContainerPath, + shouldUseCurrentUserInSandbox, + parseImageName, + ports, + entrypoint, + LOCAL_DEV_SANDBOX_IMAGE_NAME, + SANDBOX_NETWORK_NAME, + SANDBOX_PROXY_NAME, + BUILTIN_SEATBELT_PROFILES, + isSensitiveHostPath, + sanitizeSettingsForSandbox, +} from './sandboxUtils.js'; +import { BUILTIN_SEATBELT_PROFILE_CONTENTS } from './sandboxBuiltinProfiles.js'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +const execAsync = promisify(exec); +const execFileAsync = promisify(execFile); + +export async function start_sandbox( + config: SandboxConfig, + nodeArgs: string[] = [], + cliConfig?: Config, + cliArgs: string[] = [], +): Promise { + const patcher = new ConsolePatcher({ + debugMode: cliConfig?.getDebugMode() || !!process.env['DEBUG'], + stderr: true, + }); + patcher.patch(); + + let stopProxy: (() => void) | undefined = undefined; + let tempProfileFile: string | null = null; + let sandboxTmpDir: string | null = null; + + const cleanup = () => { + if (sandboxTmpDir) { + const dirToDelete = sandboxTmpDir; + sandboxTmpDir = null; + try { + if (fs.existsSync(dirToDelete)) { + fs.rmSync(dirToDelete, { recursive: true, force: true }); + } + } catch { + // ignore + } + } + + if (tempProfileFile) { + const fileToDelete = tempProfileFile; + tempProfileFile = null; + try { + if (fs.existsSync(fileToDelete)) { + fs.unlinkSync(fileToDelete); + } + } catch { + // ignore + } + } + if (stopProxy) { + try { + stopProxy(); + } catch { + // ignore + } + } + }; + + const sigintHandler = () => { + cleanup(); + process.off('SIGINT', sigintHandler); + process.kill(process.pid, 'SIGINT'); + }; + + const sigtermHandler = () => { + cleanup(); + process.off('SIGTERM', sigtermHandler); + process.kill(process.pid, 'SIGTERM'); + }; + + process.on('exit', cleanup); + process.on('SIGINT', sigintHandler); + process.on('SIGTERM', sigtermHandler); + + try { + if (config.command === 'sandbox-exec') { + // disallow BUILD_SANDBOX + if (process.env['BUILD_SANDBOX']) { + throw new FatalSandboxError( + 'Cannot BUILD_SANDBOX when using macOS Seatbelt', + ); + } + + const profile = (process.env['SEATBELT_PROFILE'] ??= 'permissive-open'); + let profileFile = fileURLToPath( + new URL(`sandbox-macos-${profile}.sb`, import.meta.url), + ); + // if profile name is not recognized, look in user-level ~/.gemini first, + // then fall back to project-level .gemini. path.basename() strips any + // directory separators to prevent path traversal via SEATBELT_PROFILE. + if (!BUILTIN_SEATBELT_PROFILES.includes(profile)) { + const safeProfile = path.basename(profile); + const fileName = `sandbox-macos-${safeProfile}.sb`; + const userHome = homedir(); + const userProfileFile = userHome + ? path.join(userHome, GEMINI_DIR, fileName) + : ''; + const projectProfileFile = path.join(GEMINI_DIR, fileName); + profileFile = + userProfileFile && fs.existsSync(userProfileFile) + ? userProfileFile + : projectProfileFile; + } else { + // For builtin profiles, if the file doesn't exist on disk (e.g. bundled or bazel environments), + // write the embedded profile content to a temporary file. + if (!fs.existsSync(profileFile)) { + const content = BUILTIN_SEATBELT_PROFILE_CONTENTS[profile]; + if (content) { + try { + const tempDir = fs.realpathSync(os.tmpdir()); + const rand = randomBytes(8).toString('hex'); + tempProfileFile = path.join( + tempDir, + `gemini-sandbox-macos-${profile}-${rand}.sb`, + ); + fs.writeFileSync(tempProfileFile, content, { + encoding: 'utf8', + mode: 0o600, + }); + profileFile = tempProfileFile; + } catch (err) { + debugLogger.warn( + `Failed to write temporary seatbelt profile: ${err}`, + ); + } + } + } + } + + try { + if (!fs.existsSync(profileFile)) { + throw new FatalSandboxError( + `Missing macos seatbelt profile file '${profileFile}'`, + ); + } + debugLogger.log(`using macos seatbelt (profile: ${profile}) ...`); + // if DEBUG is set, convert to --inspect-brk in NODE_OPTIONS + const nodeOptions = [ + ...(process.env['DEBUG'] ? ['--inspect-brk'] : []), + ...nodeArgs, + ].join(' '); + + const targetDir = fs.realpathSync(process.cwd()); + if (isSensitiveHostPath(targetDir)) { + throw new FatalSandboxError( + `Running sandbox from a sensitive host directory '${targetDir}' is strictly prohibited`, + ); + } + + const hostTmpDir = fs.realpathSync(os.tmpdir()); + const resolvedTmpDir = fs.mkdtempSync( + path.join(hostTmpDir, 'gemini-sandbox-'), + ); + try { + fs.chmodSync(resolvedTmpDir, 0o700); + } catch { + // Silently ignore permission errors on non-POSIX filesystems + } + sandboxTmpDir = resolvedTmpDir; + + const userHome = homedir(); + if (userHome) { + const seatbeltCacheDir = path.join(userHome, '.cache', GEMINI_DIR); + try { + fs.mkdirSync(seatbeltCacheDir, { recursive: true }); + } catch { + // Silently ignore directory creation failures + } + } + + const args = [ + '-D', + `TARGET_DIR=${targetDir}`, + '-D', + `TMP_DIR=${resolvedTmpDir}`, + '-D', + `HOME_DIR=${fs.realpathSync(homedir())}`, + '-D', + `CACHE_DIR=${fs.realpathSync((await execAsync('getconf DARWIN_USER_CACHE_DIR')).stdout.trim())}`, + ]; + + // Add included directories from the workspace context + // Always add 5 INCLUDE_DIR parameters to ensure .sb files can reference them + const MAX_INCLUDE_DIRS = 5; + const configTargetDir = cliConfig?.getTargetDir() + ? resolveToRealPath(cliConfig.getTargetDir()) + : targetDir; + if (cliConfig?.getTargetDir() && isSensitiveHostPath(configTargetDir)) { + throw new FatalSandboxError( + `Running sandbox from a sensitive host directory '${configTargetDir}' is strictly prohibited`, + ); + } + const includedDirs: string[] = []; + + if (cliConfig) { + const workspaceContext = cliConfig.getWorkspaceContext(); + const directories = workspaceContext.getDirectories(); + + // Filter out TARGET_DIR + for (const dir of directories) { + const realDir = resolveToRealPath(dir); + if (!realDir) { + continue; + } + if (realDir !== targetDir && realDir !== configTargetDir) { + if (isSensitiveHostPath(realDir)) { + debugLogger.warn( + `Skipping sensitive workspace directory '${realDir}' in sandbox`, + ); + continue; + } + includedDirs.push(realDir); + } + } + } + + // Add custom allowed paths from config + if (config.allowedPaths) { + for (const hostPath of config.allowedPaths) { + if ( + hostPath && + path.isAbsolute(hostPath) && + fs.existsSync(hostPath) + ) { + const realDir = fs.realpathSync(hostPath); + if ( + !includedDirs.includes(realDir) && + realDir !== targetDir && + realDir !== configTargetDir + ) { + if (isSensitiveHostPath(realDir)) { + debugLogger.warn( + `Skipping sensitive path '${realDir}' in config.allowedPaths`, + ); + continue; + } + includedDirs.push(realDir); + } + } + } + } + + for (let i = 0; i < MAX_INCLUDE_DIRS; i++) { + let dirPath = '/dev/null'; // Default to a safe path that won't cause issues + + if (i < includedDirs.length) { + dirPath = includedDirs[i]; + } + + args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`); + } + + const finalArgv = cliArgs; + + args.push( + '-f', + profileFile, + 'sh', + '-c', + [ + `SANDBOX=sandbox-exec`, + 'TMPDIR=' + quote([resolvedTmpDir]), + 'TMP=' + quote([resolvedTmpDir]), + 'TEMP=' + quote([resolvedTmpDir]), + 'NODE_OPTIONS=' + quote([nodeOptions]), + ...finalArgv.map((arg) => quote([arg])), + ].join(' '), + ); + // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set + const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND']; + let proxyProcess: ChildProcess | undefined = undefined; + let sandboxProcess: ChildProcess | undefined = undefined; + const sandboxEnv = { ...process.env }; + sandboxEnv['TMPDIR'] = resolvedTmpDir; + sandboxEnv['TMP'] = resolvedTmpDir; + sandboxEnv['TEMP'] = resolvedTmpDir; + if (proxyCommand) { + const proxy = + process.env['HTTPS_PROXY'] || + process.env['https_proxy'] || + process.env['HTTP_PROXY'] || + process.env['http_proxy'] || + 'http://localhost:8877'; + sandboxEnv['HTTPS_PROXY'] = proxy; + sandboxEnv['https_proxy'] = proxy; // lower-case can be required, e.g. for curl + sandboxEnv['HTTP_PROXY'] = proxy; + sandboxEnv['http_proxy'] = proxy; + const noProxy = process.env['NO_PROXY'] || process.env['no_proxy']; + if (noProxy) { + sandboxEnv['NO_PROXY'] = noProxy; + sandboxEnv['no_proxy'] = noProxy; + } + proxyProcess = spawn(proxyCommand, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: true, + detached: true, + }); + // install handlers to stop proxy on exit/signal + stopProxy = () => { + debugLogger.log('stopping proxy ...'); + if (proxyProcess?.pid) { + try { + process.kill(-proxyProcess.pid, 'SIGTERM'); + } catch { + // ignore + } + } + }; + + // commented out as it disrupts ink rendering + // proxyProcess.stdout?.on('data', (data) => { + // console.info(data.toString()); + // }); + proxyProcess.stderr?.on('data', (data) => { + debugLogger.debug(`[PROXY STDERR]: ${data.toString().trim()}`); + }); + proxyProcess.on('close', (code, signal) => { + if (sandboxProcess?.pid) { + process.kill(-sandboxProcess.pid, 'SIGTERM'); + } + throw new FatalSandboxError( + `Proxy command '${proxyCommand}' exited with code ${code}, signal ${signal}`, + ); + }); + debugLogger.log('waiting for proxy to start ...'); + await execAsync( + `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`, + ); + } + // spawn child and let it inherit stdio + process.stdin.pause(); + sandboxProcess = spawn(config.command, args, { + stdio: 'inherit', + env: sandboxEnv, + }); + return await new Promise((resolve, reject) => { + sandboxProcess?.on('error', (err) => { + cleanup(); + reject(err); + }); + sandboxProcess?.on('close', (code) => { + process.stdin.resume(); + cleanup(); + resolve(code ?? 1); + }); + }); + } catch (err) { + cleanup(); + throw err; + } + } + + if (config.command === 'lxc') { + return await start_lxc_sandbox(config, nodeArgs, cliArgs); + } + + // runsc uses docker with --runtime=runsc + const command = config.command === 'runsc' ? 'docker' : config.command; + if (!command) throw new FatalSandboxError('Sandbox command is required'); + + debugLogger.log(`hopping into sandbox (command: ${command}) ...`); + + // determine full path for gemini-cli to distinguish linked vs installed setting + const gcPath = process.argv[1] ? fs.realpathSync(process.argv[1]) : ''; + + const projectSandboxDockerfile = path.join( + GEMINI_DIR, + 'sandbox.Dockerfile', + ); + const isCustomProjectSandbox = fs.existsSync(projectSandboxDockerfile); + + const image = config.image; + if (!image) throw new FatalSandboxError('Sandbox image is required'); + if (!/^[a-zA-Z0-9_.:/-]+$/.test(image)) + throw new FatalSandboxError('Invalid sandbox image name'); + const workdir = path.resolve(process.cwd()); + const containerWorkdir = getContainerPath(workdir); + + // if BUILD_SANDBOX is set, then call scripts/build_sandbox.js under gemini-cli repo + // + // note this can only be done with binary linked from gemini-cli repo + if (process.env['BUILD_SANDBOX']) { + if (!gcPath.includes('gemini-cli/packages/')) { + throw new FatalSandboxError( + 'Cannot build sandbox using installed gemini binary; ' + + 'run `npm link ./packages/cli` under gemini-cli repo to switch to linked binary.', + ); + } else { + debugLogger.log('building sandbox ...'); + const gcRoot = gcPath.split('/packages/')[0]; + // if project folder has sandbox.Dockerfile under project settings folder, use that + let buildArgs = ''; + const projectSandboxDockerfile = path.join( + GEMINI_DIR, + 'sandbox.Dockerfile', + ); + if (isCustomProjectSandbox) { + debugLogger.log(`using ${projectSandboxDockerfile} for sandbox`); + buildArgs += `-f ${path.resolve(projectSandboxDockerfile)} -i ${image}`; + } + execSync( + `cd ${gcRoot} && node scripts/build_sandbox.js -s ${buildArgs}`, + { + stdio: 'inherit', + env: { + ...process.env, + GEMINI_SANDBOX: command, // in case sandbox is enabled via flags (see config.ts under cli package) + }, + }, + ); + } + } + + // stop if image is missing + if (!(await ensureSandboxImageIsPresent(command, image, cliConfig))) { + const remedy = + image === LOCAL_DEV_SANDBOX_IMAGE_NAME + ? 'Try running `npm run build:all` or `npm run build:sandbox` under the gemini-cli repo to build it locally, or check the image name and your network connection.' + : 'Please check the image name, your network connection, or notify gemini-cli-dev@google.com if the issue persists.'; + throw new FatalSandboxError( + `Sandbox image '${image}' is missing or could not be pulled. ${remedy}`, + ); + } + + // use interactive mode and auto-remove container on exit + // run init binary inside container to forward signals & reap zombies + const args = ['run', '-i', '--rm', '--init', '--workdir', containerWorkdir]; + + // explicitly clear the entrypoint to prevent the container's default + // entrypoint from interfering with the CLI's spawn command. + args.push('--entrypoint', ''); + + // add runsc runtime if using runsc + if (config.command === 'runsc') { + args.push('--runtime=runsc'); + } + + // add custom flags from SANDBOX_FLAGS + if (process.env['SANDBOX_FLAGS']) { + const flags = parse(process.env['SANDBOX_FLAGS'], process.env).filter( + (f): f is string => typeof f === 'string', + ); + + args.push(...flags); + } + + // add TTY only if stdin is TTY as well, i.e. for piped input don't init TTY in container + if (process.stdin.isTTY) { + args.push('-t'); + } + + // allow access to host.docker.internal + args.push('--add-host', 'host.docker.internal:host-gateway'); + + // mount current directory as working directory in sandbox (set via --workdir) + if (isSensitiveHostPath(workdir)) { + throw new FatalSandboxError( + `Running sandbox from a sensitive host directory '${workdir}' is strictly prohibited`, + ); + } + if ( + cliConfig?.getTargetDir() && + isSensitiveHostPath(cliConfig.getTargetDir()) + ) { + throw new FatalSandboxError( + `Running sandbox from a sensitive host directory '${cliConfig.getTargetDir()}' is strictly prohibited`, + ); + } + args.push('--volume', `${workdir}:${containerWorkdir}`); + + // Create ephemeral sandbox temp directory on host for sanitized configuration files + if (!sandboxTmpDir) { + const hostTmpDir = fs.realpathSync(os.tmpdir()); + sandboxTmpDir = fs.mkdtempSync(path.join(hostTmpDir, 'gemini-sandbox-')); + try { + fs.chmodSync(sandboxTmpDir, 0o700); + } catch { + // Silently ignore permission errors on non-POSIX filesystems + } + } + + // Sanitize user settings before mounting into the sandbox container. + // We STRICTLY do NOT mount ~/.gemini root directory or sensitive credential files + // (oauth_creds.json, .env, etc.). We only mount the sanitized settings file as read-only (:ro). + const userHomeDirOnHost = homedir(); + let rawSettings: Record = {}; + + if (userHomeDirOnHost) { + const userSettingsFileOnHost = path.join( + userHomeDirOnHost, + GEMINI_DIR, + 'settings.json', + ); + + if (fs.existsSync(userSettingsFileOnHost)) { + try { + const rawContent = fs.readFileSync(userSettingsFileOnHost, 'utf8'); + const parsed = JSON.parse(stripJsonComments(rawContent)) as unknown; + if (isRecord(parsed)) { + rawSettings = parsed; + } + } catch (err) { + debugLogger.warn( + `Failed to parse host user settings for sandbox: ${err}`, + ); + } + } + } + + const sanitizedSettings = sanitizeSettingsForSandbox(rawSettings); + const sanitizedSettingsFile = path.join(sandboxTmpDir, 'settings.json'); + fs.writeFileSync( + sanitizedSettingsFile, + JSON.stringify(sanitizedSettings, null, 2), + { mode: 0o600 }, + ); + + // Mount isolated sanitized settings directory inside container + const userSettingsDirInSandbox = getContainerPath( + `/home/node/${GEMINI_DIR}`, + ); + + // Force HOME to /home/node inside the container so that the isolated settings are correctly resolved + args.push('--env', 'HOME=/home/node'); + + args.push('--volume', `${sandboxTmpDir}:${userSettingsDirInSandbox}:rw`); + + // mount gcloud config directory if it exists + const gcloudConfigDir = userHomeDirOnHost + ? path.join(userHomeDirOnHost, '.config', 'gcloud') + : ''; + if (gcloudConfigDir && fs.existsSync(gcloudConfigDir)) { + args.push( + '--volume', + `${gcloudConfigDir}:${getContainerPath(gcloudConfigDir)}:ro`, + ); + } + + // mount ADC file if GOOGLE_APPLICATION_CREDENTIALS is set + if (process.env['GOOGLE_APPLICATION_CREDENTIALS']) { + const adcFile = process.env['GOOGLE_APPLICATION_CREDENTIALS']; + if (fs.existsSync(adcFile)) { + args.push('--volume', `${adcFile}:${getContainerPath(adcFile)}:ro`); + args.push( + '--env', + `GOOGLE_APPLICATION_CREDENTIALS=${getContainerPath(adcFile)}`, + ); + } + } + + // mount paths listed in SANDBOX_MOUNTS + if (process.env['SANDBOX_MOUNTS']) { + for (let mount of process.env['SANDBOX_MOUNTS'].split(',')) { + if (mount.trim()) { + // parse mount as from:to:opts + let [from, to, opts] = mount.trim().split(':'); + to = to || from; // default to mount at same path inside container + opts = opts || 'ro'; // default to read-only + mount = `${from}:${to}:${opts}`; + // check that from path is absolute + if (!path.isAbsolute(from)) { + throw new FatalSandboxError( + `Path '${from}' listed in SANDBOX_MOUNTS must be absolute`, + ); + } + if (isSensitiveHostPath(from)) { + throw new FatalSandboxError( + `Mounting sensitive host path '${from}' listed in SANDBOX_MOUNTS is strictly prohibited`, + ); + } + // check that from path exists on host + if (!fs.existsSync(from)) { + throw new FatalSandboxError( + `Missing mount path '${from}' listed in SANDBOX_MOUNTS`, + ); + } + debugLogger.log(`SANDBOX_MOUNTS: ${from} -> ${to} (${opts})`); + args.push('--volume', mount); + } + } + } + + // mount paths listed in config.allowedPaths + if (config.allowedPaths) { + for (const hostPath of config.allowedPaths) { + if (hostPath && path.isAbsolute(hostPath) && fs.existsSync(hostPath)) { + if (isSensitiveHostPath(hostPath)) { + debugLogger.warn( + `Skipping sensitive path '${hostPath}' in config.allowedPaths`, + ); + continue; + } + const containerPath = getContainerPath(hostPath); + debugLogger.log( + `Config allowedPath: ${hostPath} -> ${containerPath} (ro)`, + ); + args.push('--volume', `${hostPath}:${containerPath}:ro`); + } + } + } + + // expose env-specified ports on the sandbox + ports().forEach((p) => args.push('--publish', `${p}:${p}`)); + + // if DEBUG is set, expose debugging port + if (process.env['DEBUG']) { + const debugPort = process.env['DEBUG_PORT'] || '9229'; + args.push(`--publish`, `${debugPort}:${debugPort}`); + } + + // copy proxy environment variables, replacing localhost with SANDBOX_PROXY_NAME + // copy as both upper-case and lower-case as is required by some utilities + // GEMINI_SANDBOX_PROXY_COMMAND implies HTTPS_PROXY unless HTTP_PROXY is set + const proxyCommand = process.env['GEMINI_SANDBOX_PROXY_COMMAND']; + + if (proxyCommand) { + let proxy = + process.env['HTTPS_PROXY'] || + process.env['https_proxy'] || + process.env['HTTP_PROXY'] || + process.env['http_proxy'] || + 'http://localhost:8877'; + proxy = proxy.replace('localhost', SANDBOX_PROXY_NAME); + if (proxy) { + args.push('--env', `HTTPS_PROXY=${proxy}`); + args.push('--env', `https_proxy=${proxy}`); // lower-case can be required, e.g. for curl + args.push('--env', `HTTP_PROXY=${proxy}`); + args.push('--env', `http_proxy=${proxy}`); + } + const noProxy = process.env['NO_PROXY'] || process.env['no_proxy']; + if (noProxy) { + args.push('--env', `NO_PROXY=${noProxy}`); + args.push('--env', `no_proxy=${noProxy}`); + } + } + + // handle network access and proxy configuration + if (!config.networkAccess || proxyCommand) { + const isInternal = !config.networkAccess || !!proxyCommand; + const networkFlags = isInternal ? '--internal' : ''; + + execSync( + `${command} network inspect ${SANDBOX_NETWORK_NAME} || ${command} network create ${networkFlags} ${SANDBOX_NETWORK_NAME}`, + { stdio: 'ignore' }, + ); + args.push('--network', SANDBOX_NETWORK_NAME); + + if (proxyCommand) { + // if proxy command is set, create a separate network w/ host access (i.e. non-internal) + // we will run proxy in its own container connected to both host network and internal network + // this allows proxy to work even on rootless podman on macos with host<->vm<->container isolation + execSync( + `${command} network inspect ${SANDBOX_PROXY_NAME} || ${command} network create ${SANDBOX_PROXY_NAME}`, + { stdio: 'ignore' }, + ); + } + } + + // Use a random suffix instead of probing existing containers so concurrent + // CLI starts cannot race on the same sequential name. + const imageName = parseImageName(image); + const isIntegrationTest = + process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true'; + const containerNamePrefix = isIntegrationTest + ? 'gemini-cli-integration-test' + : imageName; + const containerName = `${containerNamePrefix}-${randomBytes(6).toString( + 'hex', + )}`; + debugLogger.log(`ContainerName: ${containerName}`); + args.push('--name', containerName, '--hostname', containerName); + + // copy GEMINI_CLI_TEST_VAR for integration tests + if (process.env['GEMINI_CLI_TEST_VAR']) { + args.push( + '--env', + `GEMINI_CLI_TEST_VAR=${process.env['GEMINI_CLI_TEST_VAR']}`, + ); + } + + // copy GEMINI_API_KEY(s) + if (process.env['GEMINI_API_KEY']) { + args.push('--env', `GEMINI_API_KEY=${process.env['GEMINI_API_KEY']}`); + } + if (process.env['GOOGLE_API_KEY']) { + args.push('--env', `GOOGLE_API_KEY=${process.env['GOOGLE_API_KEY']}`); + } + + // copy GOOGLE_GEMINI_BASE_URL and GOOGLE_VERTEX_BASE_URL + if (process.env['GOOGLE_GEMINI_BASE_URL']) { + args.push( + '--env', + `GOOGLE_GEMINI_BASE_URL=${process.env['GOOGLE_GEMINI_BASE_URL']}`, + ); + } + if (process.env['GOOGLE_VERTEX_BASE_URL']) { + args.push( + '--env', + `GOOGLE_VERTEX_BASE_URL=${process.env['GOOGLE_VERTEX_BASE_URL']}`, + ); + } + + // copy GOOGLE_GENAI_USE_VERTEXAI + if (process.env['GOOGLE_GENAI_USE_VERTEXAI']) { + args.push( + '--env', + `GOOGLE_GENAI_USE_VERTEXAI=${process.env['GOOGLE_GENAI_USE_VERTEXAI']}`, + ); + } + + // copy GOOGLE_GENAI_USE_GCA + if (process.env['GOOGLE_GENAI_USE_GCA']) { + args.push( + '--env', + `GOOGLE_GENAI_USE_GCA=${process.env['GOOGLE_GENAI_USE_GCA']}`, + ); + } + + // copy GOOGLE_CLOUD_PROJECT + if (process.env['GOOGLE_CLOUD_PROJECT']) { + args.push( + '--env', + `GOOGLE_CLOUD_PROJECT=${process.env['GOOGLE_CLOUD_PROJECT']}`, + ); + } + + // copy GOOGLE_CLOUD_LOCATION + if (process.env['GOOGLE_CLOUD_LOCATION']) { + args.push( + '--env', + `GOOGLE_CLOUD_LOCATION=${process.env['GOOGLE_CLOUD_LOCATION']}`, + ); + } + + // copy GEMINI_MODEL + if (process.env['GEMINI_MODEL']) { + args.push('--env', `GEMINI_MODEL=${process.env['GEMINI_MODEL']}`); + } + + // copy TERM and COLORTERM to try to maintain terminal setup + if (process.env['TERM']) { + args.push('--env', `TERM=${process.env['TERM']}`); + } + if (process.env['COLORTERM']) { + args.push('--env', `COLORTERM=${process.env['COLORTERM']}`); + } + + // Pass through IDE mode environment variables + for (const envVar of [ + 'GEMINI_CLI_IDE_SERVER_PORT', + 'GEMINI_CLI_IDE_WORKSPACE_PATH', + 'TERM_PROGRAM', + ]) { + if (process.env[envVar]) { + args.push('--env', `${envVar}=${process.env[envVar]}`); + } + } + + // copy VIRTUAL_ENV if under working directory + // also mount-replace VIRTUAL_ENV directory with /sandbox.venv + // sandbox can then set up this new VIRTUAL_ENV directory using sandbox.bashrc (see below) + // directory will be empty if not set up, which is still preferable to having host binaries + if ( + process.env['VIRTUAL_ENV'] + ?.toLowerCase() + .startsWith(workdir.toLowerCase()) + ) { + const sandboxVenvPath = path.resolve(GEMINI_DIR, 'sandbox.venv'); + if (!fs.existsSync(sandboxVenvPath)) { + fs.mkdirSync(sandboxVenvPath, { recursive: true }); + } + args.push( + '--volume', + `${sandboxVenvPath}:${getContainerPath(process.env['VIRTUAL_ENV'])}`, + ); + args.push( + '--env', + `VIRTUAL_ENV=${getContainerPath(process.env['VIRTUAL_ENV'])}`, + ); + } + + // copy additional environment variables from SANDBOX_ENV + if (process.env['SANDBOX_ENV']) { + for (let env of process.env['SANDBOX_ENV'].split(',')) { + if ((env = env.trim())) { + if (env.includes('=')) { + debugLogger.log(`SANDBOX_ENV: ${env}`); + args.push('--env', env); + } else { + throw new FatalSandboxError( + 'SANDBOX_ENV must be a comma-separated list of key=value pairs', + ); + } + } + } + } + + // copy NODE_OPTIONS + const existingNodeOptions = process.env['NODE_OPTIONS'] || ''; + const allNodeOptions = [ + ...(existingNodeOptions ? [existingNodeOptions] : []), + ...nodeArgs, + ].join(' '); + + if (allNodeOptions.length > 0) { + args.push('--env', `NODE_OPTIONS="${allNodeOptions}"`); + } + + // set SANDBOX as container name + args.push('--env', `SANDBOX=${containerName}`); + + // for podman only, use empty --authfile to skip unnecessary auth refresh overhead + if (command === 'podman') { + const emptyAuthFilePath = path.join(os.tmpdir(), 'empty_auth.json'); + fs.writeFileSync(emptyAuthFilePath, '{}', 'utf-8'); + args.push('--authfile', emptyAuthFilePath); + } + + // Determine if the current user's UID/GID should be passed to the sandbox. + // See shouldUseCurrentUserInSandbox for more details. + let userFlag = ''; + const finalEntrypoint = entrypoint(workdir, cliArgs); + + if (process.env['GEMINI_CLI_INTEGRATION_TEST'] === 'true') { + args.push('--user', 'root'); + userFlag = '--user root'; + } else if (await shouldUseCurrentUserInSandbox()) { + // For the user-creation logic to work, the container must start as root. + // The entrypoint script then handles dropping privileges to the correct user. + args.push('--user', 'root'); + + const uid = (await execAsync('id -u')).stdout.trim(); + const gid = (await execAsync('id -g')).stdout.trim(); + + // Instead of passing --user to the main sandbox container, we let it + // start as root, then create a user with the host's UID/GID, and + // finally switch to that user to run the gemini process. This is + // necessary on Linux to ensure the user exists within the + // container's /etc/passwd file, which is required by os.userInfo(). + const username = 'gemini'; + const homeDir = getContainerPath(homedir()); + const quotedHomeDir = quote([homeDir]); + + const originalCommand = finalEntrypoint[2]; + const escapedOriginalCommand = originalCommand.replace(/'/g, "'\\''"); + + // Use defensive entrypoint logic that checks for useradd availability. + // This ensures we can support UID/GID mapping on distros that have these + // tools. If useradd is missing (e.g. on minimal images), we fail explicitly + // to avoid insecurely falling back to root execution with host mounts. + const defensiveEntrypoint = [ + `if command -v useradd >/dev/null 2>&1; then`, + ` (groupadd -g ${gid} -o ${username} 2>/dev/null || true) &&`, + ` (id ${uid} >/dev/null 2>&1 || useradd -o -u ${uid} -g ${gid} -d ${quotedHomeDir} -s /bin/bash ${username} 2>/dev/null || true) &&`, + ` USER_NAME=$(id -nu ${uid} 2>/dev/null);`, + ` if [ -n "$USER_NAME" ]; then`, + ` su -p "$USER_NAME" -c '${escapedOriginalCommand}';`, + ` else`, + ` echo "Error: Failed to map host UID ${uid} to a user in the container." >&2;`, + ` exit 1;`, + ` fi`, + `else`, + ` echo "Error: 'useradd' not found in container. UID/GID mapping is required for Linux distros like NixOS/Arch to avoid permission issues. Please use a container image that includes standard user management tools (like 'ubuntu' or 'debian')." >&2;`, + ` exit 1;`, + `fi`, + ].join('\n'); + + // The entrypoint is always `['bash', '-c', '']`, so we modify the command part. + finalEntrypoint[2] = defensiveEntrypoint; + + // We still need userFlag for the simpler proxy container, which does not have this issue. + userFlag = `--user ${uid}:${gid}`; + // When forcing a UID in the sandbox, $HOME can be reset to '/', so ensure $HOME is set to /home/node. + args.push('--env', 'HOME=/home/node'); + } + + // push container image name + args.push(image); + + // push container entrypoint (including args) + args.push(...finalEntrypoint); + + // start and set up proxy if GEMINI_SANDBOX_PROXY_COMMAND is set + let proxyProcess: ChildProcess | undefined = undefined; + let sandboxProcess: ChildProcess | undefined = undefined; + + if (proxyCommand) { + // run proxyCommand in its own container + // build args array to prevent command injection + const proxyContainerArgs = [ + 'run', + '--rm', + '--init', + '--entrypoint', + '', + ...(userFlag ? userFlag.split(' ') : []), + '--name', + SANDBOX_PROXY_NAME, + '--network', + SANDBOX_PROXY_NAME, + '-p', + '8877:8877', + '-v', + `${process.cwd()}:${workdir}`, + '--workdir', + workdir, + image, + // proxyCommand may be a shell string, so parse it into tokens safely + ...parse(proxyCommand, process.env).filter( + (f): f is string => typeof f === 'string', + ), + ]; + + proxyProcess = spawn(command, proxyContainerArgs, { + stdio: ['ignore', 'pipe', 'pipe'], + shell: false, // <-- no shell; args are passed directly + detached: true, + }); + // install handlers to stop proxy on exit/signal + stopProxy = () => { + debugLogger.log('stopping proxy container ...'); + try { + spawnSync(command, ['rm', '-f', SANDBOX_PROXY_NAME], { + stdio: 'ignore', + }); + } catch { + // ignore + } + }; + + // commented out as it disrupts ink rendering + // proxyProcess.stdout?.on('data', (data) => { + // console.info(data.toString()); + // }); + proxyProcess.stderr?.on('data', (data) => { + debugLogger.debug(`[PROXY STDERR]: ${data.toString().trim()}`); + }); + proxyProcess.on('close', (code, signal) => { + if (sandboxProcess?.pid) { + process.kill(-sandboxProcess.pid, 'SIGTERM'); + } + throw new FatalSandboxError( + `Proxy container command '${command} ${proxyContainerArgs.join(' ')}' exited with code ${code}, signal ${signal}`, + ); + }); + debugLogger.log('waiting for proxy to start ...'); + await execAsync( + `until timeout 0.25 curl -s http://localhost:8877; do sleep 0.25; done`, + ); + // connect proxy container to sandbox network + // (workaround for older versions of docker that don't support multiple --network args) + await execAsync( + `${command} network connect ${SANDBOX_NETWORK_NAME} ${SANDBOX_PROXY_NAME}`, + ); + } + + // spawn child and let it inherit stdio + process.stdin.pause(); + sandboxProcess = spawn(command, args, { + stdio: 'inherit', + }); + + return await new Promise((resolve, reject) => { + sandboxProcess?.on('error', (err) => { + coreEvents.emitFeedback('error', 'Sandbox process error', err); + reject(err); + }); + + sandboxProcess?.on('close', (code, signal) => { + process.stdin.resume(); + if (code !== 0 && code !== null) { + debugLogger.log( + `Sandbox process exited with code: ${code}, signal: ${signal}`, + ); + } + resolve(code ?? 1); + }); + }); + } finally { + process.off('exit', cleanup); + process.off('SIGINT', sigintHandler); + process.off('SIGTERM', sigtermHandler); + cleanup(); + patcher.cleanup(); + } +} + +// Helper function to start a sandbox using LXC/LXD. +// Unlike Docker/Podman, LXC does not launch a transient container from an +// image. The user creates and manages their own LXC container; Gemini runs +// inside it via `lxc exec`. The container name is stored in config.image +// (default: "gemini-sandbox"). The workspace is bind-mounted into the +// container at the same absolute path. +async function start_lxc_sandbox( + config: SandboxConfig, + nodeArgs: string[] = [], + cliArgs: string[] = [], +): Promise { + const containerName = config.image || 'gemini-sandbox'; + const workdir = path.resolve(process.cwd()); + + if (isSensitiveHostPath(workdir)) { + throw new FatalSandboxError( + `Running sandbox from a sensitive host directory '${workdir}' is strictly prohibited`, + ); + } + + debugLogger.log( + `starting lxc sandbox (container: ${containerName}, workdir: ${workdir}) ...`, + ); + + // Verify the container exists and is running. + let listOutput: string; + try { + const { stdout } = await execFileAsync('lxc', [ + 'list', + containerName, + '--format=json', + ]); + listOutput = stdout.trim(); + } catch (err) { + throw new FatalSandboxError( + `Failed to query LXC container '${containerName}': ${err instanceof Error ? err.message : String(err)}. ` + + `Make sure LXC/LXD is installed and '${containerName}' container exists. ` + + `Create one with: lxc launch ubuntu:24.04 ${containerName}`, + ); + } + + let containers: Array<{ name: string; status: string }> = []; + try { + const parsed: unknown = JSON.parse(listOutput); + if (Array.isArray(parsed)) { + containers = parsed + .filter( + (item): item is Record => + item !== null && + typeof item === 'object' && + 'name' in item && + 'status' in item, + ) + .map((item) => ({ + name: String(item['name']), + status: String(item['status']), + })); + } + } catch { + containers = []; + } + + const container = containers.find((c) => c.name === containerName); + if (!container) { + throw new FatalSandboxError( + `LXC container '${containerName}' not found. ` + + `Create one with: lxc launch ubuntu:24.04 ${containerName}`, + ); + } + if (container.status.toLowerCase() !== 'running') { + throw new FatalSandboxError( + `LXC container '${containerName}' is not running (current status: ${container.status}). ` + + `Start it with: lxc start ${containerName}`, + ); + } + + const devicesToRemove: string[] = []; + const removeDevices = () => { + for (const deviceName of devicesToRemove) { + try { + spawnSync( + 'lxc', + ['config', 'device', 'remove', containerName, deviceName], + { timeout: 1000, killSignal: 'SIGKILL', stdio: 'ignore' }, + ); + } catch { + // Best-effort cleanup; ignore errors on exit. + } + } + }; + + try { + // Bind-mount the working directory into the container at the same path. + // Using "lxc config device add" is idempotent when the device name matches. + const workspaceDeviceName = `gemini-workspace-${randomBytes(4).toString( + 'hex', + )}`; + devicesToRemove.push(workspaceDeviceName); + + try { + await execFileAsync('lxc', [ + 'config', + 'device', + 'add', + containerName, + workspaceDeviceName, + 'disk', + `source=${workdir}`, + `path=${workdir}`, + ]); + debugLogger.log( + `mounted workspace '${workdir}' into container as device '${workspaceDeviceName}'`, + ); + } catch (err) { + throw new FatalSandboxError( + `Failed to mount workspace into LXC container '${containerName}': ${err instanceof Error ? err.message : String(err)}`, + ); + } + + // Add custom allowed paths from config + if (config.allowedPaths) { + for (const hostPath of config.allowedPaths) { + if (hostPath && path.isAbsolute(hostPath) && fs.existsSync(hostPath)) { + if (isSensitiveHostPath(hostPath)) { + debugLogger.warn( + `Skipping sensitive path '${hostPath}' in config.allowedPaths for LXC`, + ); + continue; + } + const allowedDeviceName = `gemini-allowed-${randomBytes(4).toString( + 'hex', + )}`; + devicesToRemove.push(allowedDeviceName); + try { + await execFileAsync('lxc', [ + 'config', + 'device', + 'add', + containerName, + allowedDeviceName, + 'disk', + `source=${hostPath}`, + `path=${hostPath}`, + 'readonly=true', + ]); + debugLogger.log( + `mounted allowed path '${hostPath}' into container as device '${allowedDeviceName}' (ro)`, + ); + } catch (err) { + debugLogger.warn( + `Failed to mount allowed path '${hostPath}' into LXC container: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + } + } + + // Remove the devices from the container when the process exits. + // Only the 'exit' event is needed — the CLI's cleanup.ts already handles + // SIGINT and SIGTERM by calling process.exit(), which fires 'exit'. + process.on('exit', removeDevices); + + // Build the environment variable arguments for `lxc exec`. + const envArgs: string[] = []; + const envVarsToForward: Record = { + GEMINI_API_KEY: process.env['GEMINI_API_KEY'], + GOOGLE_API_KEY: process.env['GOOGLE_API_KEY'], + GOOGLE_GEMINI_BASE_URL: process.env['GOOGLE_GEMINI_BASE_URL'], + GOOGLE_VERTEX_BASE_URL: process.env['GOOGLE_VERTEX_BASE_URL'], + GOOGLE_GENAI_USE_VERTEXAI: process.env['GOOGLE_GENAI_USE_VERTEXAI'], + GOOGLE_GENAI_USE_GCA: process.env['GOOGLE_GENAI_USE_GCA'], + GOOGLE_CLOUD_PROJECT: process.env['GOOGLE_CLOUD_PROJECT'], + GOOGLE_CLOUD_LOCATION: process.env['GOOGLE_CLOUD_LOCATION'], + GEMINI_MODEL: process.env['GEMINI_MODEL'], + TERM: process.env['TERM'], + COLORTERM: process.env['COLORTERM'], + GEMINI_CLI_IDE_SERVER_PORT: process.env['GEMINI_CLI_IDE_SERVER_PORT'], + GEMINI_CLI_IDE_WORKSPACE_PATH: + process.env['GEMINI_CLI_IDE_WORKSPACE_PATH'], + TERM_PROGRAM: process.env['TERM_PROGRAM'], + }; + for (const [key, value] of Object.entries(envVarsToForward)) { + if (value) { + envArgs.push('--env', `${key}=${value}`); + } + } + + // Forward SANDBOX_ENV key=value pairs + if (process.env['SANDBOX_ENV']) { + for (let env of process.env['SANDBOX_ENV'].split(',')) { + if ((env = env.trim())) { + if (env.includes('=')) { + envArgs.push('--env', env); + } else { + throw new FatalSandboxError( + 'SANDBOX_ENV must be a comma-separated list of key=value pairs', + ); + } + } + } + } + + // Forward NODE_OPTIONS (e.g. from --inspect flags) + const existingNodeOptions = process.env['NODE_OPTIONS'] || ''; + const allNodeOptions = [ + ...(existingNodeOptions ? [existingNodeOptions] : []), + ...nodeArgs, + ].join(' '); + if (allNodeOptions.length > 0) { + envArgs.push('--env', `NODE_OPTIONS=${allNodeOptions}`); + } + + // Mark that we're running inside an LXC sandbox. + envArgs.push('--env', `SANDBOX=${containerName}`); + + // Build the command entrypoint (same logic as Docker path). + const finalEntrypoint = entrypoint(workdir, cliArgs); + + // Build the full lxc exec command args. + const args = [ + 'exec', + containerName, + '--cwd', + workdir, + ...envArgs, + '--', + ...finalEntrypoint, + ]; + + debugLogger.log(`lxc exec args: ${args.join(' ')}`); + + process.stdin.pause(); + const sandboxProcess = spawn('lxc', args, { + stdio: 'inherit', + }); + + return await new Promise((resolve, reject) => { + sandboxProcess.on('error', (err) => { + coreEvents.emitFeedback('error', 'LXC sandbox process error', err); + reject(err); + }); + + sandboxProcess.on('close', (code, signal) => { + process.stdin.resume(); + if (code !== 0 && code !== null) { + debugLogger.log( + `LXC sandbox process exited with code: ${code}, signal: ${signal}`, + ); + } + resolve(code ?? 1); + }); + }); + } finally { + process.off('exit', removeDevices); + removeDevices(); + } +} + +// Helper functions to ensure sandbox image is present +async function imageExists(sandbox: string, image: string): Promise { + return new Promise((resolve) => { + const args = ['images', '-q', image]; + const checkProcess = spawn(sandbox, args); + + let stdoutData = ''; + if (checkProcess.stdout) { + checkProcess.stdout.on('data', (data) => { + stdoutData += data.toString(); + }); + } + + checkProcess.on('error', (err) => { + debugLogger.warn( + `Failed to start '${sandbox}' command for image check: ${err.message}`, + ); + resolve(false); + }); + + checkProcess.on('close', (code) => { + // Non-zero code might indicate docker daemon not running, etc. + // The primary success indicator is non-empty stdoutData. + if (code !== 0) { + // console.warn(`'${sandbox} images -q ${image}' exited with code ${code}.`); + } + resolve(stdoutData.trim() !== ''); + }); + }); +} + +async function pullImage( + sandbox: string, + image: string, + cliConfig?: Config, +): Promise { + debugLogger.debug(`Attempting to pull image ${image} using ${sandbox}...`); + return new Promise((resolve) => { + const args = ['pull', image]; + const pullProcess = spawn(sandbox, args, { stdio: 'pipe' }); + + let stderrData = ''; + + const onStdoutData = (data: Buffer) => { + if (cliConfig?.getDebugMode() || process.env['DEBUG']) { + debugLogger.log(data.toString().trim()); // Show pull progress + } + }; + + const onStderrData = (data: Buffer) => { + stderrData += data.toString(); + // eslint-disable-next-line no-console + console.error(data.toString().trim()); // Show pull errors/info from the command itself + }; + + const onError = (err: Error) => { + debugLogger.warn( + `Failed to start '${sandbox} pull ${image}' command: ${err.message}`, + ); + cleanup(); + resolve(false); + }; + + const onClose = (code: number | null) => { + if (code === 0) { + debugLogger.log(`Successfully pulled image ${image}.`); + cleanup(); + resolve(true); + } else { + debugLogger.warn( + `Failed to pull image ${image}. '${sandbox} pull ${image}' exited with code ${code}.`, + ); + if (stderrData.trim()) { + // Details already printed by the stderr listener above + } + cleanup(); + resolve(false); + } + }; + + const cleanup = () => { + if (pullProcess.stdout) { + pullProcess.stdout.removeListener('data', onStdoutData); + } + if (pullProcess.stderr) { + pullProcess.stderr.removeListener('data', onStderrData); + } + pullProcess.removeListener('error', onError); + pullProcess.removeListener('close', onClose); + if (pullProcess.connected) { + pullProcess.disconnect(); + } + }; + + if (pullProcess.stdout) { + pullProcess.stdout.on('data', onStdoutData); + } + if (pullProcess.stderr) { + pullProcess.stderr.on('data', onStderrData); + } + pullProcess.on('error', onError); + pullProcess.on('close', onClose); + }); +} + +async function ensureSandboxImageIsPresent( + sandbox: string, + image: string, + cliConfig?: Config, +): Promise { + debugLogger.log(`Checking for sandbox image: ${image}`); + if (await imageExists(sandbox, image)) { + debugLogger.log(`Sandbox image ${image} found locally.`); + return true; + } + + debugLogger.log(`Sandbox image ${image} not found locally.`); + if (image === LOCAL_DEV_SANDBOX_IMAGE_NAME) { + // user needs to build the image themselves + return false; + } + + if (await pullImage(sandbox, image, cliConfig)) { + // After attempting to pull, check again to be certain + if (await imageExists(sandbox, image)) { + debugLogger.log(`Sandbox image ${image} is now available after pulling.`); + return true; + } else { + debugLogger.warn( + `Sandbox image ${image} still not found after a pull attempt. This might indicate an issue with the image name or registry, or the pull command reported success but failed to make the image available.`, + ); + return false; + } + } + + coreEvents.emitFeedback( + 'error', + `Failed to obtain sandbox image ${image} after check and pull attempt.`, + ); + return false; // Pull command failed or image still not present +} diff --git a/packages/cli/src/utils/sandboxUtils.test.ts b/packages/cli/src/utils/sandboxUtils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..0819d79f6fe3e4a0026764d2a0582203d4d44348 --- /dev/null +++ b/packages/cli/src/utils/sandboxUtils.test.ts @@ -0,0 +1,765 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; +import os from 'node:os'; +import path from 'node:path'; +import fs from 'node:fs'; +import { readFile } from 'node:fs/promises'; +import { resolveToRealPath, homedir } from '@google/gemini-cli-core'; +import { + getContainerPath, + parseImageName, + ports, + entrypoint, + shouldUseCurrentUserInSandbox, + isSensitiveHostPath, + sanitizeSettingsForSandbox, + isCredentialOrSensitivePath, + prepareIsolatedSettingsDir, + SENSITIVE_SETTINGS_FILENAMES, +} from './sandboxUtils.js'; + +vi.mock('node:os'); +vi.mock('node:fs'); +vi.mock('node:fs/promises'); +vi.mock('@google/gemini-cli-core', () => ({ + debugLogger: { + log: vi.fn(), + warn: vi.fn(), + }, + GEMINI_DIR: '.gemini', + homedir: vi.fn(() => os.homedir()), + resolveToRealPath: vi.fn((p: string) => path.resolve(p)), +})); + +describe('sandboxUtils', () => { + const originalEnv = process.env; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + vi.mocked(os.platform).mockReturnValue(process.platform); + vi.mocked(os.tmpdir).mockReturnValue('/tmp'); + vi.mocked(homedir).mockImplementation(() => os.homedir()); + vi.mocked(resolveToRealPath).mockImplementation((p: string) => + path.resolve(p), + ); + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.lstatSync).mockReset(); + vi.mocked(fs.readlinkSync).mockReset(); + // Clean up these env vars that might affect tests + delete process.env['NODE_ENV']; + delete process.env['DEBUG']; + delete process.env['GEMINI_CLI_HOME']; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + describe('getContainerPath', () => { + it('should return same path on non-Windows', () => { + vi.mocked(os.platform).mockReturnValue('linux'); + expect(getContainerPath('/home/user')).toBe('/home/user'); + }); + + it('should convert Windows path to container path', () => { + vi.mocked(os.platform).mockReturnValue('win32'); + expect(getContainerPath('C:\\Users\\user')).toBe('/c/Users/user'); + }); + + it('should handle Windows path without drive letter', () => { + vi.mocked(os.platform).mockReturnValue('win32'); + expect(getContainerPath('\\Users\\user')).toBe('/Users/user'); + }); + }); + + describe('parseImageName', () => { + it('should parse image name with tag', () => { + expect(parseImageName('my-image:latest')).toBe('my-image-latest'); + }); + + it('should parse image name without tag', () => { + expect(parseImageName('my-image')).toBe('my-image'); + }); + + it('should handle registry path', () => { + expect(parseImageName('gcr.io/my-project/my-image:v1')).toBe( + 'my-image-v1', + ); + }); + }); + + describe('ports', () => { + it('should return empty array if SANDBOX_PORTS is not set', () => { + delete process.env['SANDBOX_PORTS']; + expect(ports()).toEqual([]); + }); + + it('should parse comma-separated ports', () => { + process.env['SANDBOX_PORTS'] = '8080, 3000 , 9000'; + expect(ports()).toEqual(['8080', '3000', '9000']); + }); + }); + + describe('entrypoint', () => { + beforeEach(() => { + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(fs.existsSync).mockReturnValue(false); + }); + + it('should generate default entrypoint', () => { + const args = entrypoint('/work', ['node', 'gemini', 'arg1']); + expect(args).toEqual(['bash', '-c', 'gemini arg1']); + }); + + it('should include PATH and PYTHONPATH if set', () => { + process.env['PATH'] = '/work/bin:/usr/bin'; + process.env['PYTHONPATH'] = '/work/lib'; + const args = entrypoint('/work', ['node', 'gemini', 'arg1']); + expect(args[2]).toContain('export PATH="$PATH:/work/bin"'); + expect(args[2]).toContain('export PYTHONPATH="$PYTHONPATH:/work/lib"'); + }); + + it('should source sandbox.bashrc if exists', () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + const args = entrypoint('/work', ['node', 'gemini', 'arg1']); + expect(args[2]).toContain('source .gemini/sandbox.bashrc'); + }); + + it('should include socat commands for ports', () => { + process.env['SANDBOX_PORTS'] = '8080'; + const args = entrypoint('/work', ['node', 'gemini', 'arg1']); + expect(args[2]).toContain('socat TCP4-LISTEN:8080'); + }); + + it('should use development command if NODE_ENV is development', () => { + process.env['NODE_ENV'] = 'development'; + const args = entrypoint('/work', ['node', 'gemini', 'arg1']); + expect(args[2]).toContain('npm rebuild && npm run start --'); + }); + }); + + describe('shouldUseCurrentUserInSandbox', () => { + it('should return true if SANDBOX_SET_UID_GID is 1', async () => { + process.env['SANDBOX_SET_UID_GID'] = '1'; + expect(await shouldUseCurrentUserInSandbox()).toBe(true); + }); + + it('should return false if SANDBOX_SET_UID_GID is 0', async () => { + process.env['SANDBOX_SET_UID_GID'] = '0'; + expect(await shouldUseCurrentUserInSandbox()).toBe(false); + }); + + it('should return true on Debian Linux', async () => { + delete process.env['SANDBOX_SET_UID_GID']; + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(readFile).mockResolvedValue('ID=debian\n'); + expect(await shouldUseCurrentUserInSandbox()).toBe(true); + }); + + it('should return true on NixOS', async () => { + delete process.env['SANDBOX_SET_UID_GID']; + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(readFile).mockResolvedValue('ID=nixos\n'); + expect(await shouldUseCurrentUserInSandbox()).toBe(true); + }); + + it('should return true on NixOS with quotes', async () => { + delete process.env['SANDBOX_SET_UID_GID']; + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(readFile).mockResolvedValue('ID="nixos"\n'); + expect(await shouldUseCurrentUserInSandbox()).toBe(true); + }); + + it('should return true on Ubuntu with single quotes', async () => { + delete process.env['SANDBOX_SET_UID_GID']; + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(readFile).mockResolvedValue("ID='ubuntu'\n"); + expect(await shouldUseCurrentUserInSandbox()).toBe(true); + }); + + it('should return true on Arch Linux', async () => { + delete process.env['SANDBOX_SET_UID_GID']; + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(readFile).mockResolvedValue('ID=arch\n'); + expect(await shouldUseCurrentUserInSandbox()).toBe(true); + }); + + it('should return false on unrecognized Linux and warn on UID mismatch', async () => { + delete process.env['SANDBOX_SET_UID_GID']; + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(readFile).mockResolvedValue('ID=unknown\n'); + vi.mocked(os.userInfo).mockReturnValue({ + uid: 1234, + username: 'test', + gid: 1234, + shell: '/bin/bash', + homedir: '/home/test', + }); + + const { debugLogger } = await import('@google/gemini-cli-core'); + expect(await shouldUseCurrentUserInSandbox()).toBe(false); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining( + 'Host UID mismatch detected (current UID: 1234)', + ), + ); + }); + + it('should return true on Pop!_OS (via ID_LIKE)', async () => { + delete process.env['SANDBOX_SET_UID_GID']; + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(readFile).mockResolvedValue( + 'ID=pop\nID_LIKE="ubuntu debian"\n', + ); + expect(await shouldUseCurrentUserInSandbox()).toBe(true); + }); + + it('should return false and NOT warn for host root user (UID 0)', async () => { + delete process.env['SANDBOX_SET_UID_GID']; + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(readFile).mockResolvedValue('ID=unknown\n'); + vi.mocked(os.userInfo).mockReturnValue({ + uid: 0, + username: 'root', + gid: 0, + shell: '/bin/bash', + homedir: '/root', + }); + + const { debugLogger } = await import('@google/gemini-cli-core'); + expect(await shouldUseCurrentUserInSandbox()).toBe(false); + expect(debugLogger.warn).not.toHaveBeenCalledWith( + expect.stringContaining('Host UID mismatch detected'), + ); + }); + + it('should warn and return false if /etc/os-release is unreadable', async () => { + delete process.env['SANDBOX_SET_UID_GID']; + vi.mocked(os.platform).mockReturnValue('linux'); + vi.mocked(readFile).mockRejectedValue(new Error('EACCES')); + + const { debugLogger } = await import('@google/gemini-cli-core'); + expect(await shouldUseCurrentUserInSandbox()).toBe(false); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Could not read /etc/os-release'), + ); + }); + + it('should return false on non-Linux', async () => { + delete process.env['SANDBOX_SET_UID_GID']; + vi.mocked(os.platform).mockReturnValue('darwin'); + expect(await shouldUseCurrentUserInSandbox()).toBe(false); + }); + }); + + describe('isSensitiveHostPath', () => { + beforeEach(() => { + vi.mocked(fs.existsSync).mockReturnValue(true); + }); + + it('should detect ~/.gemini directory path', () => { + vi.mocked(os.homedir).mockReturnValue('/home/testuser'); + expect(isSensitiveHostPath('/home/testuser/.gemini')).toBe(true); + expect(isSensitiveHostPath('/home/testuser/.gemini/settings.json')).toBe( + true, + ); + expect(isSensitiveHostPath('~/.gemini')).toBe(true); + }); + + it('should detect user home directory and its parent directories', () => { + vi.mocked(os.homedir).mockReturnValue('/home/testuser'); + expect(isSensitiveHostPath('/home/testuser')).toBe(true); + expect(isSensitiveHostPath('~')).toBe(true); + expect(isSensitiveHostPath('/home')).toBe(true); + expect(isSensitiveHostPath('/')).toBe(true); + }); + + it('should detect sensitive credential files', () => { + expect(isSensitiveHostPath('/any/path/oauth_creds.json')).toBe(true); + expect(isSensitiveHostPath('/any/path/gemini-credentials.json')).toBe( + true, + ); + expect(isSensitiveHostPath('/any/path/mcp-oauth-tokens.json')).toBe(true); + expect(isSensitiveHostPath('/any/path/a2a-oauth-tokens.json')).toBe(true); + expect(isSensitiveHostPath('/any/path/google_accounts.json')).toBe(true); + expect(isSensitiveHostPath('/any/path/trusted_hooks.json')).toBe(true); + expect(isSensitiveHostPath('/any/path/trustedFolders.json')).toBe(true); + expect(isSensitiveHostPath('/any/path/trustedfolders.json')).toBe(true); + expect(isSensitiveHostPath('/any/path/policy_integrity.json')).toBe(true); + }); + + it('should detect environment files (.env)', () => { + expect(isSensitiveHostPath('/project/.env')).toBe(true); + expect(isSensitiveHostPath('/project/.env.local')).toBe(true); + expect(isSensitiveHostPath('/project/.env.production')).toBe(true); + }); + + it('should allow non-sensitive workspace and project paths', () => { + vi.mocked(os.homedir).mockReturnValue('/home/testuser'); + expect(isSensitiveHostPath('/home/testuser/project')).toBe(false); + expect(isSensitiveHostPath('/workspace/app')).toBe(false); + expect(isSensitiveHostPath('/tmp/test-dir')).toBe(false); + }); + + it('should resolve symlinks to detect sensitive targets', () => { + const homeDir = path.resolve('/home/testuser'); + const symlinkGemini = path.resolve('/var/symlink_to_gemini'); + const symlinkHome = path.resolve('/var/symlink_to_home'); + + vi.mocked(os.homedir).mockReturnValue(homeDir); + vi.mocked(resolveToRealPath).mockImplementation((p: string) => { + const resolvedP = path.resolve(p); + if ( + resolvedP === symlinkGemini || + (os.platform() === 'win32' && + resolvedP.toLowerCase() === symlinkGemini.toLowerCase()) + ) { + return path.join(homeDir, '.gemini'); + } + if ( + resolvedP === symlinkHome || + (os.platform() === 'win32' && + resolvedP.toLowerCase() === symlinkHome.toLowerCase()) + ) { + return homeDir; + } + return resolvedP; + }); + + expect(isSensitiveHostPath(symlinkGemini)).toBe(true); + expect(isSensitiveHostPath(symlinkHome)).toBe(true); + }); + + it('should fail closed when resolveToRealPath encounters an error', () => { + vi.mocked(os.homedir).mockReturnValue('/home/testuser'); + vi.mocked(resolveToRealPath).mockImplementation(() => { + throw new Error('Unrecoverable resolution error'); + }); + + expect(isSensitiveHostPath('/home/testuser/.gemini')).toBe(true); + expect(isSensitiveHostPath('/workspace/safe-path')).toBe(true); + }); + + it('should resolve non-existent paths cleanly via resolveToRealPath', () => { + const homeDir = path.resolve('/home/testuser'); + const safeDir = path.resolve('/workspace/safe-path'); + vi.mocked(os.homedir).mockReturnValue(homeDir); + vi.mocked(resolveToRealPath).mockImplementation((p: string) => + path.resolve(p), + ); + + expect( + isSensitiveHostPath(path.join(homeDir, '.gemini', 'non-existent-sub')), + ).toBe(true); + expect(isSensitiveHostPath(path.join(safeDir, 'non-existent-sub'))).toBe( + false, + ); + }); + + it('should fall back to path.resolve when resolveToRealPath encounters ENOENT', () => { + const homeDir = path.resolve('/home/testuser'); + const safeDir = path.resolve('/workspace/safe-path'); + vi.mocked(os.homedir).mockReturnValue(homeDir); + vi.mocked(resolveToRealPath).mockImplementation((p: string) => { + const resolvedP = path.resolve(p); + if (resolvedP.includes('non-existent')) { + const err = new Error( + `ENOENT: no such file or directory, realpath '${p}'`, + ); + (err as NodeJS.ErrnoException).code = 'ENOENT'; + throw err; + } + return resolvedP; + }); + + expect( + isSensitiveHostPath(path.join(homeDir, '.gemini', 'non-existent-sub')), + ).toBe(true); + expect(isSensitiveHostPath(path.join(safeDir, 'non-existent-sub'))).toBe( + false, + ); + }); + + it('should detect broken symlinks pointing into ~/.gemini as sensitive', () => { + const homeDir = path.resolve('/home/testuser'); + const brokenSymlink = path.resolve('/workspace/broken_symlink'); + const targetFile = path.join(homeDir, '.gemini', 'trusted_hooks.json'); + + vi.mocked(os.homedir).mockReturnValue(homeDir); + + const matchesBroken = (p: string) => { + const resolvedP = path.resolve(p); + return ( + resolvedP === brokenSymlink || + (os.platform() === 'win32' && + resolvedP.toLowerCase() === brokenSymlink.toLowerCase()) + ); + }; + + vi.mocked(resolveToRealPath).mockImplementation((p: string) => { + if (matchesBroken(p)) { + const err = new Error('ENOENT: no such file or directory'); + (err as NodeJS.ErrnoException).code = 'ENOENT'; + throw err; + } + return path.resolve(p); + }); + + vi.mocked(fs.lstatSync).mockImplementation((p: fs.PathLike) => { + if (matchesBroken(String(p))) { + return { + isSymbolicLink: () => true, + } as fs.Stats; + } + const err = new Error('ENOENT: no such file or directory'); + (err as NodeJS.ErrnoException).code = 'ENOENT'; + throw err; + }); + + vi.mocked(fs.readlinkSync).mockImplementation((p: fs.PathLike) => { + if (matchesBroken(String(p))) { + return targetFile; + } + throw new Error('EINVAL: not a symlink'); + }); + + expect(isSensitiveHostPath(brokenSymlink)).toBe(true); + }); + + it('should fail closed and block mounts when circular symlinks are encountered', () => { + const homeDir = path.resolve('/home/testuser'); + const circ1 = path.resolve('/workspace/circ1'); + const circ2 = path.resolve('/workspace/circ2'); + + vi.mocked(os.homedir).mockReturnValue(homeDir); + + const matchesCirc = (p: string, target: string) => { + const resolvedP = path.resolve(p); + return ( + resolvedP === target || + (os.platform() === 'win32' && + resolvedP.toLowerCase() === target.toLowerCase()) + ); + }; + + vi.mocked(resolveToRealPath).mockImplementation((p: string) => { + if (matchesCirc(p, circ1) || matchesCirc(p, circ2)) { + const err = new Error('ENOENT: no such file or directory'); + (err as NodeJS.ErrnoException).code = 'ENOENT'; + throw err; + } + return path.resolve(p); + }); + + vi.mocked(fs.lstatSync).mockImplementation((p: fs.PathLike) => { + const strP = String(p); + if (matchesCirc(strP, circ1) || matchesCirc(strP, circ2)) { + return { + isSymbolicLink: () => true, + } as fs.Stats; + } + const err = new Error('ENOENT: no such file or directory'); + (err as NodeJS.ErrnoException).code = 'ENOENT'; + throw err; + }); + + vi.mocked(fs.readlinkSync).mockImplementation((p: fs.PathLike) => { + const strP = String(p); + if (matchesCirc(strP, circ1)) { + return circ2; + } + if (matchesCirc(strP, circ2)) { + return circ1; + } + throw new Error('EINVAL: not a symlink'); + }); + + expect(isSensitiveHostPath(circ1)).toBe(true); + }); + + it('should handle empty or undetermined homedir without blocking working directory', () => { + vi.mocked(homedir).mockReturnValue(''); + vi.mocked(os.homedir).mockReturnValue(''); + + expect(isSensitiveHostPath(process.cwd())).toBe(false); + expect(isSensitiveHostPath(path.join(process.cwd(), '.gemini'))).toBe( + false, + ); + expect(isSensitiveHostPath('/workspace/safe-project')).toBe(false); + + // Still blocks ~ notations and sensitive credential files + expect(isSensitiveHostPath('~')).toBe(true); + expect(isSensitiveHostPath('~/.gemini')).toBe(true); + expect(isSensitiveHostPath('/workspace/.env')).toBe(true); + expect(isSensitiveHostPath('/workspace/oauth_creds.json')).toBe(true); + expect(isSensitiveHostPath('/workspace/google_accounts.json')).toBe(true); + expect(isSensitiveHostPath('/workspace/trustedFolders.json')).toBe(true); + expect(isSensitiveHostPath('/workspace/trustedfolders.json')).toBe(true); + expect(isSensitiveHostPath('/workspace/policy_integrity.json')).toBe( + true, + ); + }); + }); + + describe('sanitizeSettingsForSandbox', () => { + it('should strip hooks and malicious execution commands', () => { + const rawSettings = { + theme: 'dark', + hooks: { + beforeCommand: 'evil-script.sh', + }, + tools: { + allowed: ['read_file'], + callCommand: '/bin/bash', + discoveryCommand: '/bin/sh', + }, + apiKey: 'secret-api-key', + geminiApiKey: 'secret-gemini-key', + googleApiKey: 'secret-google-key', + customConfig: { + nestedKey: 'safe-value', + }, + }; + + const sanitized = sanitizeSettingsForSandbox(rawSettings); + + expect(sanitized['hooks']).toBeUndefined(); + expect(sanitized['apiKey']).toBeUndefined(); + expect(sanitized['geminiApiKey']).toBeUndefined(); + expect(sanitized['googleApiKey']).toBeUndefined(); + + const tools = sanitized['tools'] as Record; + expect(tools['allowed']).toEqual(['read_file']); + expect(tools['callCommand']).toBeUndefined(); + expect(tools['discoveryCommand']).toBeUndefined(); + + expect(sanitized['theme']).toBe('dark'); + expect( + (sanitized['customConfig'] as Record)['nestedKey'], + ).toBe('safe-value'); + }); + + it('should not mutate the original settings object', () => { + const original = { + hooks: { test: true }, + tools: { callCommand: 'run' }, + }; + const copy = JSON.parse(JSON.stringify(original)); + sanitizeSettingsForSandbox(original); + expect(original).toEqual(copy); + }); + + it('should handle tools property when it is an array without runtime errors', () => { + const rawSettings = { + tools: ['read_file', 'edit_file'], + }; + const sanitized = sanitizeSettingsForSandbox(rawSettings); + expect(sanitized['tools']).toEqual(['read_file', 'edit_file']); + }); + }); + + describe('isCredentialOrSensitivePath', () => { + it('should include known credential filenames in SENSITIVE_SETTINGS_FILENAMES', () => { + expect(SENSITIVE_SETTINGS_FILENAMES.has('oauth_creds.json')).toBe(true); + expect(SENSITIVE_SETTINGS_FILENAMES.has('google_accounts.json')).toBe( + true, + ); + expect(SENSITIVE_SETTINGS_FILENAMES.has('gemini-credentials.json')).toBe( + true, + ); + expect(SENSITIVE_SETTINGS_FILENAMES.has('mcp-oauth-tokens.json')).toBe( + true, + ); + expect(SENSITIVE_SETTINGS_FILENAMES.has('a2a-oauth-tokens.json')).toBe( + true, + ); + expect(SENSITIVE_SETTINGS_FILENAMES.has('trusted_hooks.json')).toBe(true); + expect(SENSITIVE_SETTINGS_FILENAMES.has('trustedFolders.json')).toBe( + true, + ); + expect(SENSITIVE_SETTINGS_FILENAMES.has('trustedfolders.json')).toBe( + true, + ); + expect(SENSITIVE_SETTINGS_FILENAMES.has('policy_integrity.json')).toBe( + true, + ); + }); + + it('should identify known credential and auth filenames as sensitive', () => { + expect( + isCredentialOrSensitivePath('/home/user/.gemini/oauth_creds.json'), + ).toBe(true); + expect( + isCredentialOrSensitivePath('/home/user/.gemini/google_accounts.json'), + ).toBe(true); + expect( + isCredentialOrSensitivePath( + '/home/user/.gemini/gemini-credentials.json', + ), + ).toBe(true); + expect( + isCredentialOrSensitivePath('/home/user/.gemini/mcp-oauth-tokens.json'), + ).toBe(true); + expect( + isCredentialOrSensitivePath('/home/user/.gemini/a2a-oauth-tokens.json'), + ).toBe(true); + expect( + isCredentialOrSensitivePath('/home/user/.gemini/trusted_hooks.json'), + ).toBe(true); + expect( + isCredentialOrSensitivePath('/home/user/.gemini/trustedFolders.json'), + ).toBe(true); + expect( + isCredentialOrSensitivePath('/home/user/.gemini/trustedfolders.json'), + ).toBe(true); + expect( + isCredentialOrSensitivePath('/home/user/.gemini/policy_integrity.json'), + ).toBe(true); + }); + + it('should identify tokens, credentials, and sensitive directories', () => { + expect( + isCredentialOrSensitivePath('/home/user/.gemini/custom-tokens.json'), + ).toBe(true); + expect(isCredentialOrSensitivePath('/home/user/.gemini/token.json')).toBe( + true, + ); + expect( + isCredentialOrSensitivePath('/home/user/.gemini/github-token'), + ).toBe(true); + expect( + isCredentialOrSensitivePath('/home/user/.gemini/api.credentials'), + ).toBe(true); + expect( + isCredentialOrSensitivePath('/home/user/.gemini/user_creds.json'), + ).toBe(true); + expect( + isCredentialOrSensitivePath('/home/user/.gemini/user_cred.json'), + ).toBe(true); + expect(isCredentialOrSensitivePath('/home/user/.gemini/.env')).toBe(true); + expect( + isCredentialOrSensitivePath('/home/user/.gemini/private.key'), + ).toBe(true); + expect(isCredentialOrSensitivePath('/home/user/.gemini/cert.pem')).toBe( + true, + ); + expect( + isCredentialOrSensitivePath( + '/home/user/.gemini/service-account-key.json', + ), + ).toBe(true); + expect(isCredentialOrSensitivePath('/home/user/.gemini/history')).toBe( + true, + ); + expect(isCredentialOrSensitivePath('/home/user/.gemini/tmp')).toBe(true); + expect(isCredentialOrSensitivePath('/home/user/.gemini/bin')).toBe(true); + }); + + it('should allow non-sensitive configuration files and directories', () => { + expect( + isCredentialOrSensitivePath('/home/user/.gemini/settings.json'), + ).toBe(false); + expect( + isCredentialOrSensitivePath('/home/user/.gemini/keybindings.json'), + ).toBe(false); + expect(isCredentialOrSensitivePath('/home/user/.gemini/commands')).toBe( + false, + ); + expect(isCredentialOrSensitivePath('/home/user/.gemini/skills')).toBe( + false, + ); + expect(isCredentialOrSensitivePath('/home/user/.gemini/policies')).toBe( + false, + ); + expect(isCredentialOrSensitivePath('/home/user/.gemini/agents')).toBe( + false, + ); + }); + + it('should not false-positive on user scripts containing credential in filename', () => { + expect( + isCredentialOrSensitivePath( + '/home/user/.gemini/commands/setup-credentials.sh', + ), + ).toBe(false); + expect( + isCredentialOrSensitivePath( + '/home/user/.gemini/skills/credential-helper.js', + ), + ).toBe(false); + }); + + it('should not false-positive on words sharing sensitive substrings without separators', () => { + expect( + isCredentialOrSensitivePath('/home/user/.gemini/monkey.json'), + ).toBe(false); + expect( + isCredentialOrSensitivePath('/home/user/.gemini/sacred.json'), + ).toBe(false); + expect( + isCredentialOrSensitivePath('/home/user/.gemini/commands/tokenized.js'), + ).toBe(false); + }); + + it('should not false-positive on nested bin or tmp directories inside commands or skills', () => { + const rootDir = '/home/user/.gemini'; + expect( + isCredentialOrSensitivePath('/home/user/.gemini/commands/bin', rootDir), + ).toBe(false); + expect( + isCredentialOrSensitivePath('/home/user/.gemini/skills/tmp', rootDir), + ).toBe(false); + }); + + it('should not filter out the root directory itself', () => { + const rootDir = '/home/user/.gemini'; + expect(isCredentialOrSensitivePath(rootDir, rootDir)).toBe(false); + }); + }); + + describe('prepareIsolatedSettingsDir', () => { + it('should create an isolated directory and copy files with credential filter', () => { + const fakeHostSettingsDir = '/home/user/.gemini'; + const fakeIsolatedDir = '/tmp/gemini-sandbox-settings-xyz'; + + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.mkdtempSync).mockReturnValue(fakeIsolatedDir); + + const result = prepareIsolatedSettingsDir(fakeHostSettingsDir); + + expect(fs.mkdtempSync).toHaveBeenCalledWith( + expect.stringContaining('gemini-sandbox-settings-'), + ); + expect(fs.chmodSync).toHaveBeenCalledWith(fakeIsolatedDir, 0o700); + expect(fs.cpSync).toHaveBeenCalledWith( + fakeHostSettingsDir, + fakeIsolatedDir, + expect.objectContaining({ + recursive: true, + filter: expect.any(Function), + }), + ); + expect(result).toBe(fakeIsolatedDir); + }); + + it('should return isolated directory even if source settings directory does not exist', () => { + const fakeHostSettingsDir = '/home/user/.gemini'; + const fakeIsolatedDir = '/tmp/gemini-sandbox-settings-xyz'; + + vi.mocked(fs.existsSync).mockReturnValue(false); + vi.mocked(fs.mkdtempSync).mockReturnValue(fakeIsolatedDir); + + const result = prepareIsolatedSettingsDir(fakeHostSettingsDir); + + expect(fs.chmodSync).toHaveBeenCalledWith(fakeIsolatedDir, 0o700); + expect(result).toBe(fakeIsolatedDir); + expect(fs.cpSync).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/cli/src/utils/sessionCleanup.integration.test.ts b/packages/cli/src/utils/sessionCleanup.integration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..871e30f669d6997287129d522fd869423b56f7e0 --- /dev/null +++ b/packages/cli/src/utils/sessionCleanup.integration.test.ts @@ -0,0 +1,405 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { cleanupExpiredSessions } from './sessionCleanup.js'; +import type { Settings } from '../config/settings.js'; +import { + SESSION_FILE_PREFIX, + type Config, + debugLogger, +} from '@google/gemini-cli-core'; + +// Create a mock config for integration testing +function createTestConfig(): Config { + return { + storage: { + getProjectTempDir: () => '/tmp/nonexistent-test-dir', + }, + getSessionId: () => 'test-session-id', + getDebugMode: () => false, + initialize: async () => undefined, + } as unknown as Config; +} + +describe('Session Cleanup Integration', () => { + it('should gracefully handle non-existent directories', async () => { + const config = createTestConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '30d', + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + // Should return empty result for non-existent directory + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should not impact startup when disabled', async () => { + const config = createTestConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: false, + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should handle missing sessionRetention configuration', async () => { + // Create test session files to verify they are NOT deleted when config is missing + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const os = await import('node:os'); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-')); + const chatsDir = path.join(tempDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create an old session file that would normally be deleted + const oldDate = new Date(Date.now() - 60 * 24 * 60 * 60 * 1000); // 60 days ago + const sessionFile = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-00-test123.json`, + ); + await fs.writeFile( + sessionFile, + JSON.stringify({ + sessionId: 'test123', + messages: [], + startTime: oldDate.toISOString(), + lastUpdated: oldDate.toISOString(), + }), + ); + + const config = createTestConfig(); + config.storage.getProjectTempDir = vi.fn().mockReturnValue(tempDir); + + const settings: Settings = {}; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); // Should not even scan when config is missing + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + + // Verify the session file still exists (was not deleted) + const filesAfter = await fs.readdir(chatsDir); + expect(filesAfter).toContain( + `${SESSION_FILE_PREFIX}2024-01-01T10-00-00-test123.json`, + ); + + // Cleanup + await fs.rm(tempDir, { recursive: true }); + }); + + it('should validate configuration and fail gracefully', async () => { + const errorSpy = vi.spyOn(debugLogger, 'warn').mockImplementation(() => {}); + const config = createTestConfig(); + + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: 'invalid-format', + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + + // Verify error logging provides visibility into the validation failure + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining( + 'Session cleanup disabled: Error: Invalid retention period format', + ), + ); + + errorSpy.mockRestore(); + }); + + it('should clean up expired sessions when they exist', async () => { + // Create a temporary directory with test sessions + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const os = await import('node:os'); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-')); + const chatsDir = path.join(tempDir, 'chats'); + await fs.mkdir(chatsDir, { recursive: true }); + + // Create test session files with different ages + const now = new Date(); + const oldDate = new Date(now.getTime() - 35 * 24 * 60 * 60 * 1000); // 35 days ago + const recentDate = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); // 5 days ago + + // Create an old session file that should be deleted + const oldSessionFile = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-12-01T10-00-00-old12345.json`, + ); + await fs.writeFile( + oldSessionFile, + JSON.stringify({ + sessionId: 'old12345', + messages: [{ type: 'user', content: 'test message' }], + startTime: oldDate.toISOString(), + lastUpdated: oldDate.toISOString(), + }), + ); + + // Create a recent session file that should be kept + const recentSessionFile = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2025-01-15T10-00-00-recent789.json`, + ); + await fs.writeFile( + recentSessionFile, + JSON.stringify({ + sessionId: 'recent789', + messages: [{ type: 'user', content: 'test message' }], + startTime: recentDate.toISOString(), + lastUpdated: recentDate.toISOString(), + }), + ); + + // Create a current session file that should always be kept + const currentSessionFile = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2025-01-20T10-00-00-current123.json`, + ); + await fs.writeFile( + currentSessionFile, + JSON.stringify({ + sessionId: 'current123', + messages: [{ type: 'user', content: 'test message' }], + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + }), + ); + + // Configure test with real temp directory + const config: Config = { + storage: { + getProjectTempDir: () => tempDir, + }, + getSessionId: () => 'current123', + getDebugMode: () => false, + initialize: async () => undefined, + } as unknown as Config; + + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '30d', // Keep sessions for 30 days + }, + }, + }; + + try { + const result = await cleanupExpiredSessions(config, settings); + + // Verify the result + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(3); // Should scan all 3 sessions + expect(result.deleted).toBe(1); // Should delete the old session (35 days old) + expect(result.skipped).toBe(2); // Should keep recent and current sessions + expect(result.failed).toBe(0); + + // Verify files on disk + const remainingFiles = await fs.readdir(chatsDir); + expect(remainingFiles).toHaveLength(2); // Only 2 files should remain + expect(remainingFiles).toContain( + `${SESSION_FILE_PREFIX}2025-01-15T10-00-00-recent789.json`, + ); + expect(remainingFiles).toContain( + `${SESSION_FILE_PREFIX}2025-01-20T10-00-00-current123.json`, + ); + expect(remainingFiles).not.toContain( + `${SESSION_FILE_PREFIX}2024-12-01T10-00-00-old12345.json`, + ); + } finally { + // Clean up test directory + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); + + it('should delete subagent files and their artifacts when parent expires', async () => { + // Create a temporary directory with test sessions + const fs = await import('node:fs/promises'); + const path = await import('node:path'); + const os = await import('node:os'); + + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'gemini-test-')); + const chatsDir = path.join(tempDir, 'chats'); + const logsDir = path.join(tempDir, 'logs'); + const toolOutputsDir = path.join(tempDir, 'tool-outputs'); + + await fs.mkdir(chatsDir, { recursive: true }); + await fs.mkdir(logsDir, { recursive: true }); + await fs.mkdir(toolOutputsDir, { recursive: true }); + + const now = new Date(); + const oldDate = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); // 5 days ago + + // The shortId that ties them together + const sharedShortId = 'abcdef12'; + + const parentSessionId = 'parent-uuid-123'; + const parentFile = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-00-00-${sharedShortId}.json`, + ); + await fs.writeFile( + parentFile, + JSON.stringify({ + sessionId: parentSessionId, + messages: [], + startTime: oldDate.toISOString(), + lastUpdated: oldDate.toISOString(), + }), + ); + + const subagentSessionId = 'subagent-uuid-456'; + const subagentFile = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2024-01-01T10-05-00-${sharedShortId}.json`, + ); + await fs.writeFile( + subagentFile, + JSON.stringify({ + sessionId: subagentSessionId, + messages: [], + startTime: oldDate.toISOString(), + lastUpdated: oldDate.toISOString(), + }), + ); + + const parentLogFile = path.join( + logsDir, + `session-${parentSessionId}.jsonl`, + ); + await fs.writeFile(parentLogFile, '{"log": "parent"}'); + + const parentToolOutputsDir = path.join( + toolOutputsDir, + `session-${parentSessionId}`, + ); + await fs.mkdir(parentToolOutputsDir, { recursive: true }); + await fs.writeFile( + path.join(parentToolOutputsDir, 'some-output.txt'), + 'data', + ); + + const subagentLogFile = path.join( + logsDir, + `session-${subagentSessionId}.jsonl`, + ); + await fs.writeFile(subagentLogFile, '{"log": "subagent"}'); + + const subagentToolOutputsDir = path.join( + toolOutputsDir, + `session-${subagentSessionId}`, + ); + await fs.mkdir(subagentToolOutputsDir, { recursive: true }); + await fs.writeFile( + path.join(subagentToolOutputsDir, 'some-output.txt'), + 'data', + ); + + const currentShortId = 'current1'; + const currentFile = path.join( + chatsDir, + `${SESSION_FILE_PREFIX}2025-01-20T10-00-00-${currentShortId}.json`, + ); + await fs.writeFile( + currentFile, + JSON.stringify({ + sessionId: 'current-session', + messages: [ + { + type: 'user', + content: [{ type: 'text', text: 'hello' }], + timestamp: now.toISOString(), + }, + ], + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + }), + ); + + // Configure test + const config: Config = { + storage: { + getProjectTempDir: () => tempDir, + }, + getSessionId: () => 'current-session', // Mock CLI instance ID + getDebugMode: () => false, + initialize: async () => undefined, + } as unknown as Config; + + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '1d', // Expire things older than 1 day + }, + }, + }; + + try { + const result = await cleanupExpiredSessions(config, settings); + + // Verify the cleanup result object + // It scanned 3 files. It should delete 2 (parent + subagent), and keep 1 (current) + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(3); + expect(result.deleted).toBe(2); + expect(result.skipped).toBe(1); + + // Verify on-disk file states + const chats = await fs.readdir(chatsDir); + expect(chats).toHaveLength(1); + expect(chats).toContain( + `${SESSION_FILE_PREFIX}2025-01-20T10-00-00-${currentShortId}.json`, + ); // Only current is left + + const logs = await fs.readdir(logsDir); + expect(logs).toHaveLength(0); // Both parent and subagent logs were deleted + + const tools = await fs.readdir(toolOutputsDir); + expect(tools).toHaveLength(0); // Both parent and subagent tool output dirs were deleted + } finally { + await fs.rm(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/cli/src/utils/sessionCleanup.test.ts b/packages/cli/src/utils/sessionCleanup.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d34ac4739d06114531276b6f8ed2c31b0682be68 --- /dev/null +++ b/packages/cli/src/utils/sessionCleanup.test.ts @@ -0,0 +1,1076 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import { existsSync, unlinkSync } from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { + type Config, + debugLogger, + TOOL_OUTPUTS_DIR, + Storage, +} from '@google/gemini-cli-core'; +import type { Settings } from '../config/settings.js'; +import { + cleanupExpiredSessions, + cleanupToolOutputFiles, +} from './sessionCleanup.js'; + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + debugLogger: { + error: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + info: vi.fn(), + }, + }; +}); + +describe('Session Cleanup (Refactored)', () => { + let testTempDir: string; + let chatsDir: string; + let logsDir: string; + let toolOutputsDir: string; + + beforeEach(async () => { + vi.clearAllMocks(); + testTempDir = await fs.mkdtemp( + path.join(os.tmpdir(), 'gemini-cli-cleanup-test-'), + ); + chatsDir = path.join(testTempDir, 'chats'); + logsDir = path.join(testTempDir, 'logs'); + toolOutputsDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); + + await fs.mkdir(chatsDir, { recursive: true }); + await fs.mkdir(logsDir, { recursive: true }); + await fs.mkdir(toolOutputsDir, { recursive: true }); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + if (testTempDir && existsSync(testTempDir)) { + await fs.rm(testTempDir, { recursive: true, force: true }); + } + }); + + function createMockConfig(overrides: Partial = {}): Config { + return { + storage: { + getProjectTempDir: () => testTempDir, + }, + getSessionId: () => 'current123', + getDebugMode: () => false, + getExperimentalGemma: () => false, + initialize: async () => {}, + ...overrides, + } as unknown as Config; + } + + async function writeSessionFile(session: { + id: string; + fileName: string; + lastUpdated: string; + }) { + const filePath = path.join(chatsDir, session.fileName); + await fs.writeFile( + filePath, + JSON.stringify({ + sessionId: session.id, + lastUpdated: session.lastUpdated, + startTime: session.lastUpdated, + messages: [{ type: 'user', content: 'hello' }], + }), + ); + } + + async function writeArtifacts(sessionId: string) { + // Log file + await fs.writeFile( + path.join(logsDir, `session-${sessionId}.jsonl`), + 'log content', + ); + // Tool output directory + const sessionOutputDir = path.join(toolOutputsDir, `session-${sessionId}`); + await fs.mkdir(sessionOutputDir, { recursive: true }); + await fs.writeFile( + path.join(sessionOutputDir, 'output.txt'), + 'tool output', + ); + // Session directory + await fs.mkdir(path.join(testTempDir, sessionId), { recursive: true }); + // Subagent chats directory + await fs.mkdir(path.join(chatsDir, sessionId), { recursive: true }); + } + + async function seedSessions() { + const now = new Date(); + const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); + const oneMonthAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); + + const sessions = [ + { + id: 'current123', + fileName: 'session-20250101-current1.json', + lastUpdated: now.toISOString(), + }, + { + id: 'old789abc', + fileName: 'session-20250110-old789ab.json', + lastUpdated: twoWeeksAgo.toISOString(), + }, + { + id: 'ancient12', + fileName: 'session-20241225-ancient1.json', + lastUpdated: oneMonthAgo.toISOString(), + }, + ]; + + for (const session of sessions) { + await writeSessionFile(session); + await writeArtifacts(session.id); + } + return sessions; + } + + describe('Configuration boundaries & early exits', () => { + it('should return early when cleanup is disabled', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: false } }, + }; + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should return early when sessionRetention is not configured', async () => { + const config = createMockConfig(); + const settings: Settings = { general: {} }; + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + }); + + it('should require either maxAge or maxCount', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true } }, + }; + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Either maxAge or maxCount must be specified'), + ); + }); + + it.each([0, -1, -5])( + 'should validate maxCount range (rejecting %i)', + async (invalidCount) => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { enabled: true, maxCount: invalidCount }, + }, + }; + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('maxCount must be at least 1'), + ); + }, + ); + + it('should reject if both maxAge and maxCount are invalid', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { enabled: true, maxAge: 'invalid', maxCount: 0 }, + }, + }; + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Invalid retention period format'), + ); + }); + + it('should reject if maxAge is invalid even when maxCount is valid', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { enabled: true, maxAge: 'invalid', maxCount: 5 }, + }, + }; + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Invalid retention period format'), + ); + }); + }); + + describe('Logging and Debug Mode', () => { + it('should log debug information when enabled', async () => { + await seedSessions(); + const config = createMockConfig({ + getDebugMode: vi.fn().mockReturnValue(true), + }); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxCount: 1 } }, + }; + + const debugSpy = vi + .spyOn(debugLogger, 'debug') + .mockImplementation(() => {}); + await cleanupExpiredSessions(config, settings); + + expect(debugSpy).toHaveBeenCalledWith( + expect.stringContaining('Session cleanup: deleted'), + ); + debugSpy.mockRestore(); + }); + }); + + describe('Basic retention rules', () => { + it('should delete sessions older than maxAge', async () => { + const sessions = await seedSessions(); + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '10d', + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.scanned).toBe(3); + expect(result.deleted).toBe(2); + expect(result.skipped).toBe(1); + expect(result.failed).toBe(0); + expect(existsSync(path.join(chatsDir, sessions[0].fileName))).toBe(true); + expect(existsSync(path.join(chatsDir, sessions[1].fileName))).toBe(false); + expect(existsSync(path.join(chatsDir, sessions[2].fileName))).toBe(false); + + // Verify artifacts for an old session are gone + expect( + existsSync(path.join(logsDir, `session-${sessions[1].id}.jsonl`)), + ).toBe(false); + expect( + existsSync(path.join(toolOutputsDir, `session-${sessions[1].id}`)), + ).toBe(false); + expect(existsSync(path.join(testTempDir, sessions[1].id))).toBe(false); // Session directory should be deleted + expect(existsSync(path.join(chatsDir, sessions[1].id))).toBe(false); // Subagent chats directory should be deleted + }); + + it('should delete legacy pretty-printed session files and their artifacts', async () => { + const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000); + const sessionId = 'legacy-uuid'; + const shortId = 'legacy12'; + const fileName = `session-20250110-${shortId}.json`; + const filePath = path.join(chatsDir, fileName); + + // Write pretty-printed JSON + await fs.writeFile( + filePath, + JSON.stringify( + { + sessionId, + lastUpdated: twoWeeksAgo.toISOString(), + startTime: twoWeeksAgo.toISOString(), + messages: [{ type: 'user', content: 'hello legacy' }], + }, + null, + 2, + ), + ); + + await writeArtifacts(sessionId); + + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '10d' } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.deleted).toBe(1); + expect(existsSync(filePath)).toBe(false); + // Artifacts should be gone because we extracted sessionId from legacy JSON + expect( + existsSync(path.join(toolOutputsDir, `session-${sessionId}`)), + ).toBe(false); + }); + + it('should delete expired JSONL session files and their artifacts', async () => { + const twoWeeksAgo = new Date(Date.now() - 14 * 24 * 60 * 60 * 1000); + const sessionId = 'jsonl-uuid'; + const shortId = 'jsonl123'; + const fileName = `session-20250110-${shortId}.jsonl`; + const filePath = path.join(chatsDir, fileName); + + // Write JSONL + const metadata = { + sessionId, + lastUpdated: twoWeeksAgo.toISOString(), + startTime: twoWeeksAgo.toISOString(), + kind: 'main', + }; + const message = { id: '1', type: 'user', content: 'hello jsonl' }; + await fs.writeFile( + filePath, + JSON.stringify(metadata) + '\n' + JSON.stringify(message) + '\n', + ); + + await writeArtifacts(sessionId); + + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '10d' } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.deleted).toBe(1); + expect(existsSync(filePath)).toBe(false); + // Artifacts should be gone because we extracted sessionId from JSONL first line + expect( + existsSync(path.join(toolOutputsDir, `session-${sessionId}`)), + ).toBe(false); + }); + + it('should delete corrupted session files even if sessionId cannot be extracted', async () => { + const shortId = 'corrupt1'; + const fileName = `session-20250110-${shortId}.json`; + const filePath = path.join(chatsDir, fileName); + + await fs.writeFile(filePath, 'completely invalid json'); + + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '10d' } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.deleted).toBe(1); + expect(existsSync(filePath)).toBe(false); + }); + + it('should NOT delete sessions within the cutoff date', async () => { + const sessions = await seedSessions(); // [current, 14d, 30d] + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '60d' } }, + }; + + // 60d cutoff should keep everything that was seeded + const result = await cleanupExpiredSessions(config, settings); + + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(3); + for (const session of sessions) { + expect(existsSync(path.join(chatsDir, session.fileName))).toBe(true); + } + }); + + it('should handle count-based retention (keeping N most recent)', async () => { + const sessions = await seedSessions(); // [current, 14d, 30d] + + // Seed two additional granular files to prove sorting works + const now = new Date(); + const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); + const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); + + await writeSessionFile({ + id: 'recent3', + fileName: 'session-20250117-recent3.json', + lastUpdated: threeDaysAgo.toISOString(), + }); + await writeArtifacts('recent3'); + await writeSessionFile({ + id: 'recent5', + fileName: 'session-20250115-recent5.json', + lastUpdated: fiveDaysAgo.toISOString(), + }); + await writeArtifacts('recent5'); + + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxCount: 3, // Keep current + 2 most recent (which should be 3d and 5d) + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.scanned).toBe(5); + expect(result.deleted).toBe(2); // Should only delete the 14d and 30d old sessions + expect(result.skipped).toBe(3); + expect(result.failed).toBe(0); + + // Verify specifically WHICH files survived + expect(existsSync(path.join(chatsDir, sessions[0].fileName))).toBe(true); // current + expect( + existsSync(path.join(chatsDir, 'session-20250117-recent3.json')), + ).toBe(true); // 3d + expect( + existsSync(path.join(chatsDir, 'session-20250115-recent5.json')), + ).toBe(true); // 5d + + // Verify the older ones were deleted + expect(existsSync(path.join(chatsDir, sessions[1].fileName))).toBe(false); // 14d + expect(existsSync(path.join(chatsDir, sessions[2].fileName))).toBe(false); // 30d + }); + + it('should delete subagent files sharing the same shortId', async () => { + const now = new Date(); + const twoWeeksAgo = new Date(now.getTime() - 14 * 24 * 60 * 60 * 1000); + + // Parent session (expired) + await writeSessionFile({ + id: 'parent-uuid', + fileName: 'session-20250110-abc12345.json', + lastUpdated: twoWeeksAgo.toISOString(), + }); + await writeArtifacts('parent-uuid'); + + // Subagent session (different UUID, same shortId) + await writeSessionFile({ + id: 'sub-uuid', + fileName: 'session-20250110-subagent-abc12345.json', + lastUpdated: twoWeeksAgo.toISOString(), + }); + await writeArtifacts('sub-uuid'); + + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '10d' } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.deleted).toBe(2); // Both files should be deleted + expect( + existsSync(path.join(chatsDir, 'session-20250110-abc12345.json')), + ).toBe(false); + expect( + existsSync( + path.join(chatsDir, 'session-20250110-subagent-abc12345.json'), + ), + ).toBe(false); + + // Artifacts for both should be gone + expect(existsSync(path.join(logsDir, 'session-parent-uuid.jsonl'))).toBe( + false, + ); + expect(existsSync(path.join(logsDir, 'session-sub-uuid.jsonl'))).toBe( + false, + ); + }); + + it('should delete corrupted session files', async () => { + // Write a corrupted file (invalid JSON) + const corruptPath = path.join(chatsDir, 'session-corrupt.json'); + await fs.writeFile(corruptPath, 'invalid json'); + + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '10d' } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.deleted).toBe(1); + expect(existsSync(corruptPath)).toBe(false); + }); + + it('should safely delete 8-character sessions containing invalid JSON', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, + }; + + const badJsonPath = path.join(chatsDir, 'session-20241225-badjson1.json'); + await fs.writeFile(badJsonPath, 'This is raw text, not JSON'); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.deleted).toBe(1); + expect(result.failed).toBe(0); + expect(existsSync(badJsonPath)).toBe(false); + }); + + it('should safely delete legacy non-8-character sessions', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, + }; + + const legacyPath = path.join(chatsDir, 'session-20241225-legacy.json'); + // Create valid JSON so the parser succeeds, but shortId derivation fails + await fs.writeFile( + legacyPath, + JSON.stringify({ + sessionId: 'legacy-session-id', + lastUpdated: '2024-12-25T00:00:00.000Z', + messages: [], + }), + ); + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.deleted).toBe(1); + expect(result.failed).toBe(0); + expect(existsSync(legacyPath)).toBe(false); + }); + + it('should silently ignore ENOENT if file is already deleted before unlink', async () => { + await seedSessions(); // Seeds older 2024 and 2025 sessions + const targetFile = path.join(chatsDir, 'session-20241225-ancient1.json'); + let getSessionIdCalls = 0; + + const config = createMockConfig({ + getSessionId: () => { + getSessionIdCalls++; + // First call is for `getAllSessionFiles`. + // Subsequent calls are right before `fs.unlink`! + if (getSessionIdCalls > 1) { + try { + unlinkSync(targetFile); + } catch { + /* ignore */ + } + } + return 'mock-session-id'; + }, + }); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + // `failed` should not increment because ENOENT is silently swallowed + expect(result.failed).toBe(0); + }); + + it('should respect minRetention configuration', async () => { + await seedSessions(); + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '12h', // Less than 1 day minRetention + minRetention: '1d', + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + // Should return early and not delete anything + expect(result.disabled).toBe(true); + expect(result.deleted).toBe(0); + }); + + it('should handle combined maxAge and maxCount (most restrictive wins)', async () => { + const sessions = await seedSessions(); // [current, 14d, 30d] + + // Seed 3d and 5d to mirror the granular sorting test + const now = new Date(); + const threeDaysAgo = new Date(now.getTime() - 3 * 24 * 60 * 60 * 1000); + const fiveDaysAgo = new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000); + + await writeSessionFile({ + id: 'recent3', + fileName: 'session-20250117-recent3.json', + lastUpdated: threeDaysAgo.toISOString(), + }); + await writeArtifacts('recent3'); + await writeSessionFile({ + id: 'recent5', + fileName: 'session-20250115-recent5.json', + lastUpdated: fiveDaysAgo.toISOString(), + }); + await writeArtifacts('recent5'); + + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + // 20d deletes 30d. + // maxCount: 2 keeps current and 3d. + // Restrictive wins: 30d deleted by maxAge. 14d, 5d deleted by maxCount. + maxAge: '20d', + maxCount: 2, + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.scanned).toBe(5); + expect(result.deleted).toBe(3); // deletes 5d, 14d, 30d + expect(result.skipped).toBe(2); // keeps current, 3d + expect(result.failed).toBe(0); + + // Assert kept + expect(existsSync(path.join(chatsDir, sessions[0].fileName))).toBe(true); // current + expect( + existsSync(path.join(chatsDir, 'session-20250117-recent3.json')), + ).toBe(true); // 3d + + // Assert deleted + expect( + existsSync(path.join(chatsDir, 'session-20250115-recent5.json')), + ).toBe(false); // 5d + expect(existsSync(path.join(chatsDir, sessions[1].fileName))).toBe(false); // 14d + expect(existsSync(path.join(chatsDir, sessions[2].fileName))).toBe(false); // 30d + }); + + it('should handle empty sessions directory', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '30d' } }, + }; + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + expect(result.skipped).toBe(0); + expect(result.failed).toBe(0); + }); + }); + + describe('Error handling & resilience', () => { + it.skipIf(process.platform === 'win32')( + 'should handle file system errors gracefully (e.g., EACCES)', + async () => { + const sessions = await seedSessions(); + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, + }; + + // Make one of the files read-only and its parent directory read-only to simulate EACCES during unlink + const targetFile = path.join(chatsDir, sessions[1].fileName); + await fs.chmod(targetFile, 0o444); + // Wait we want unlink to fail, so we make the directory read-only temporarily + await fs.chmod(chatsDir, 0o555); + + try { + const result = await cleanupExpiredSessions(config, settings); + + // It shouldn't crash + expect(result.disabled).toBe(false); + // It should have tried and failed to delete the old session + expect(result.failed).toBeGreaterThan(0); + } finally { + // Restore permissions so cleanup can proceed in afterEach + await fs.chmod(chatsDir, 0o777); + await fs.chmod(targetFile, 0o666); + } + }, + ); + + it.skipIf(process.platform === 'win32')( + 'should handle global read errors gracefully', + async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, + }; + + // Make the chats directory unreadable + await fs.chmod(chatsDir, 0o000); + + try { + const result = await cleanupExpiredSessions(config, settings); + + // It shouldn't crash, but it should fail + expect(result.disabled).toBe(false); + expect(result.failed).toBe(1); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Session cleanup failed'), + ); + } finally { + await fs.chmod(chatsDir, 0o777); + } + }, + ); + + it('should NOT delete tempDir if safeSessionId is empty', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, + }; + + const sessions = await seedSessions(); + const targetFile = path.join(chatsDir, sessions[1].fileName); + + // Write a session ID that sanitizeFilenamePart will turn into an empty string "" + await fs.writeFile(targetFile, JSON.stringify({ sessionId: '../../..' })); + + const tempDir = config.storage.getProjectTempDir(); + expect(existsSync(tempDir)).toBe(true); + + await cleanupExpiredSessions(config, settings); + + // It must NOT delete the tempDir root + expect(existsSync(tempDir)).toBe(true); + }); + + it('should handle unexpected errors without throwing (e.g. string errors)', async () => { + await seedSessions(); + const config = createMockConfig({ + getSessionId: () => { + const stringError = 'String error' as unknown as Error; + throw stringError; // Throw a non-Error string without triggering no-restricted-syntax + }, + }); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxCount: 1 } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(false); + expect(result.failed).toBeGreaterThan(0); + }); + + it('should never run on the current session', async () => { + await seedSessions(); + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxCount: 1, // Keep only 1 session (which will be the current one) + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.deleted).toBe(2); + expect(result.skipped).toBe(1); // The current session + const currentSessionFile = (await fs.readdir(chatsDir)).find((f) => + f.includes('current1'), + ); + expect(currentSessionFile).toBeDefined(); + }); + }); + + describe('Format parsing & validation', () => { + // Valid formats + it.each([ + ['1h'], + ['24h'], + ['168h'], + ['1d'], + ['7d'], + ['30d'], + ['365d'], + ['1w'], + ['2w'], + ['4w'], + ['52w'], + ['1m'], + ['3m'], + ['12m'], + ['9999d'], + ])('should accept valid maxAge format %s', async (input) => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: input, + minRetention: '1h', + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(false); + expect(result.failed).toBe(0); + }); + + it('should accept maxAge equal to minRetention', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { enabled: true, maxAge: '1d', minRetention: '1d' }, + }, + }; + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(false); + }); + + it('should accept maxCount = 1000 (maximum valid)', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxCount: 1000 } }, + }; + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(false); + }); + + it('should reject maxAge less than default minRetention (1d)', async () => { + await seedSessions(); + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '12h', + // Note: No minRetention provided here, should default to 1d + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('maxAge cannot be less than minRetention'), + ); + }); + + it('should reject maxAge less than custom minRetention', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '2d', + minRetention: '3d', // maxAge < minRetention + }, + }, + }; + + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('maxAge cannot be less than minRetention (3d)'), + ); + }); + + it('should reject zero value with a specific error message', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '0d' } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Value must be greater than 0'), + ); + }); + + // Invalid formats + it.each([ + ['30'], + ['30x'], + ['d'], + ['1.5d'], + ['-5d'], + ['1 d'], + ['1dd'], + ['abc'], + ['30s'], + ['30y'], + ])('should reject invalid maxAge format %s', async (input) => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: input } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining(`Invalid retention period format: ${input}`), + ); + }); + + it('should reject empty string for maxAge', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '' } }, + }; + + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(true); + expect(debugLogger.warn).toHaveBeenCalledWith( + expect.stringContaining('Either maxAge or maxCount must be specified'), + ); + }); + + it('should validate minRetention format', async () => { + const config = createMockConfig(); + const settings: Settings = { + general: { + sessionRetention: { + enabled: true, + maxAge: '5d', + minRetention: 'invalid-format', + }, + }, + }; + + // Should fall back to default minRetention and proceed + const result = await cleanupExpiredSessions(config, settings); + expect(result.disabled).toBe(false); + }); + }); + + describe('Tool Output Cleanup', () => { + let toolOutputDir: string; + + beforeEach(async () => { + toolOutputDir = path.join(testTempDir, TOOL_OUTPUTS_DIR); + await fs.mkdir(toolOutputDir, { recursive: true }); + }); + + async function seedToolOutputs() { + const now = new Date(); + const oldTime = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000); // 10 days ago + + const file1 = path.join(toolOutputDir, 'output1.json'); + await fs.writeFile(file1, '{}'); + + const file2 = path.join(toolOutputDir, 'output2.json'); + await fs.writeFile(file2, '{}'); + + // Manually backdate file1 + await fs.utimes(file1, oldTime, oldTime); + + // Create an old session subdirectory + const oldSubdir = path.join(toolOutputDir, 'session-old'); + await fs.mkdir(oldSubdir); + await fs.utimes(oldSubdir, oldTime, oldTime); + + return { file1, file2, oldSubdir }; + } + + it('should return early if cleanup is disabled', async () => { + const settings: Settings = { + general: { sessionRetention: { enabled: false } }, + }; + const result = await cleanupToolOutputFiles(settings, false, testTempDir); + + expect(result.disabled).toBe(true); + expect(result.scanned).toBe(0); + expect(result.deleted).toBe(0); + }); + + it('should gracefully handle missing tool-outputs directory', async () => { + await fs.rm(toolOutputDir, { recursive: true, force: true }); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, + }; + + const result = await cleanupToolOutputFiles(settings, false, testTempDir); + + expect(result.disabled).toBe(false); + expect(result.scanned).toBe(0); + }); + + it('should delete flat files and subdirectories based on maxAge', async () => { + const { file1, file2, oldSubdir } = await seedToolOutputs(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '5d' } }, + }; + + const result = await cleanupToolOutputFiles(settings, false, testTempDir); + + // file1 and oldSubdir should be deleted. + expect(result.deleted).toBe(2); + expect(existsSync(file1)).toBe(false); + expect(existsSync(oldSubdir)).toBe(false); + expect(existsSync(file2)).toBe(true); + }); + + it('should delete oldest-first flat files based on maxCount when maxAge does not hit', async () => { + const { file1, file2 } = await seedToolOutputs(); + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxCount: 1 } }, + }; + + const result = await cleanupToolOutputFiles(settings, false, testTempDir); + + // Excess is 1. Oldest is file1. So file1 is deleted. + expect(result.deleted).toBe(1); + expect(existsSync(file1)).toBe(false); + expect(existsSync(file2)).toBe(true); + }); + + it('should skip tool-output subdirectories with unsafe names', async () => { + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, + }; + + // Create a directory with a name that is semantically unsafe for sanitization rules + const unsafeSubdir = path.join(toolOutputDir, 'session-unsafe@name'); + await fs.mkdir(unsafeSubdir); + + // Backdate it so it WOULD be deleted if it were safely named + const oldTime = new Date(Date.now() - 10 * 24 * 60 * 60 * 1000); + await fs.utimes(unsafeSubdir, oldTime, oldTime); + + const result = await cleanupToolOutputFiles(settings, false, testTempDir); + + // Must be scanned but actively skipped from deletion due to sanitization mismatch + expect(result.deleted).toBe(0); + expect(existsSync(unsafeSubdir)).toBe(true); + }); + + it('should initialize Storage when projectTempDir is not explicitly provided', async () => { + const getProjectTempDirSpy = vi + .spyOn(Storage.prototype, 'getProjectTempDir') + .mockReturnValue(testTempDir); + const initializeSpy = vi + .spyOn(Storage.prototype, 'initialize') + .mockResolvedValue(undefined); + + const settings: Settings = { + general: { sessionRetention: { enabled: true, maxAge: '1d' } }, + }; + const { oldSubdir } = await seedToolOutputs(); + + // Call explicitly without third parameter + const result = await cleanupToolOutputFiles(settings, false); + + expect(initializeSpy).toHaveBeenCalled(); + expect(result.deleted).toBeGreaterThan(0); + expect(existsSync(oldSubdir)).toBe(false); + + getProjectTempDirSpy.mockRestore(); + initializeSpy.mockRestore(); + }); + }); +}); diff --git a/packages/cli/src/utils/sessionCleanup.ts b/packages/cli/src/utils/sessionCleanup.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd79f379fd4c3ebc7d23ef0957b0f23868cadafb --- /dev/null +++ b/packages/cli/src/utils/sessionCleanup.ts @@ -0,0 +1,650 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { + debugLogger, + sanitizeFilenamePart, + SESSION_FILE_PREFIX, + Storage, + TOOL_OUTPUTS_DIR, + type Config, + deleteSessionArtifactsAsync, + deleteSubagentSessionDirAndArtifactsAsync, +} from '@google/gemini-cli-core'; +import type { Settings, SessionRetentionSettings } from '../config/settings.js'; +import { getAllSessionFiles, type SessionFileEntry } from './sessionUtils.js'; + +// Constants +export const DEFAULT_MIN_RETENTION = '1d' as string; +const MIN_MAX_COUNT = 1; +const MULTIPLIERS = { + h: 60 * 60 * 1000, // hours to ms + d: 24 * 60 * 60 * 1000, // days to ms + w: 7 * 24 * 60 * 60 * 1000, // weeks to ms + m: 30 * 24 * 60 * 60 * 1000, // months (30 days) to ms +}; + +/** + * Matches a trailing hyphen followed by exactly 8 alphanumeric characters before the .json or .jsonl extension. + * Example: session-20250110-abcdef12.json -> captures "abcdef12" + */ +const SHORT_ID_REGEX = /-([a-zA-Z0-9]{8})\.jsonl?$/; + +function hasProperty( + obj: unknown, + prop: T, +): obj is { [key in T]: unknown } { + return obj !== null && typeof obj === 'object' && prop in obj; +} + +function isStringProperty( + obj: unknown, + prop: T, +): obj is { [key in T]: string } { + return hasProperty(obj, prop) && typeof obj[prop] === 'string'; +} + +function isSessionIdRecord(record: unknown): record is { sessionId: string } { + return isStringProperty(record, 'sessionId'); +} + +/** + * Result of session cleanup operation + */ +export interface CleanupResult { + disabled: boolean; + scanned: number; + deleted: number; + skipped: number; + failed: number; +} + +/** + * Helpers for session cleanup. + */ + +/** + * Derives an 8-character shortId from a session filename. + */ +function deriveShortIdFromFileName(fileName: string): string | null { + if ( + fileName.startsWith(SESSION_FILE_PREFIX) && + (fileName.endsWith('.json') || fileName.endsWith('.jsonl')) + ) { + const match = fileName.match(SHORT_ID_REGEX); + return match ? match[1] : null; + } + return null; +} + +/** + * Cleans up associated artifacts (logs, tool-outputs, directory) for a session. + */ +async function cleanupSessionAndSubagentsAsync( + sessionId: string, + config: Config, +): Promise { + const tempDir = config.storage.getProjectTempDir(); + const chatsDir = path.join(tempDir, 'chats'); + + await deleteSessionArtifactsAsync(sessionId, tempDir); + await deleteSubagentSessionDirAndArtifactsAsync(sessionId, chatsDir, tempDir); +} + +/** + * Main entry point for session cleanup during CLI startup + */ +export async function cleanupExpiredSessions( + config: Config, + settings: Settings, +): Promise { + const result: CleanupResult = { + disabled: false, + scanned: 0, + deleted: 0, + skipped: 0, + failed: 0, + }; + + try { + // Early exit if cleanup is disabled + if (!settings.general?.sessionRetention?.enabled) { + return { ...result, disabled: true }; + } + + const retentionConfig = settings.general.sessionRetention; + const chatsDir = path.join(config.storage.getProjectTempDir(), 'chats'); + + // Validate retention configuration + const validationErrorMessage = validateRetentionConfig( + config, + retentionConfig, + ); + if (validationErrorMessage) { + // Log validation errors to console for visibility + debugLogger.warn(`Session cleanup disabled: ${validationErrorMessage}`); + return { ...result, disabled: true }; + } + + const allFiles = await getAllSessionFiles(chatsDir, config.getSessionId()); + result.scanned = allFiles.length; + + if (allFiles.length === 0) { + return result; + } + + // Determine which sessions to delete (corrupted and expired) + const sessionsToDelete = await identifySessionsToDelete( + allFiles, + retentionConfig, + ); + + const processedShortIds = new Set(); + + // Delete all sessions that need to be deleted + for (const sessionToDelete of sessionsToDelete) { + try { + const shortId = deriveShortIdFromFileName(sessionToDelete.fileName); + + if (shortId) { + if (processedShortIds.has(shortId)) { + continue; + } + processedShortIds.add(shortId); + + const matchingFiles = allFiles + .map((f) => f.fileName) + .filter( + (f) => + f.startsWith(SESSION_FILE_PREFIX) && + (f.endsWith(`-${shortId}.json`) || + f.endsWith(`-${shortId}.jsonl`)), + ); + + for (const file of matchingFiles) { + const filePath = path.join(chatsDir, file); + let fullSessionId: string | undefined; + + try { + // Try to read file to get full sessionId + try { + const CHUNK_SIZE = 4096; + const buffer = Buffer.alloc(CHUNK_SIZE); + let fd: fs.FileHandle | undefined; + try { + fd = await fs.open(filePath, 'r'); + const { bytesRead } = await fd.read(buffer, 0, CHUNK_SIZE, 0); + if (bytesRead > 0) { + const contentChunk = buffer.toString('utf8', 0, bytesRead); + const newlineIndex = contentChunk.indexOf('\n'); + const firstLine = + newlineIndex !== -1 + ? contentChunk.substring(0, newlineIndex) + : contentChunk; + + try { + const record: unknown = JSON.parse(firstLine); + if (isSessionIdRecord(record)) { + fullSessionId = record.sessionId; + } + } catch { + // Ignore first line parse error, try full parse for legacy pretty-printed JSON + } + } + } finally { + if (fd !== undefined) { + await fd.close(); + } + } + + if (!fullSessionId) { + const fileContent = await fs.readFile(filePath, 'utf8'); + const content: unknown = JSON.parse(fileContent); + if (isSessionIdRecord(content)) { + fullSessionId = content.sessionId; + } + } + } catch { + // If read/parse fails, skip getting sessionId, just delete the file below + } + + // Delete the session file + if (!fullSessionId || fullSessionId !== config.getSessionId()) { + await fs.unlink(filePath); + + if (fullSessionId) { + await cleanupSessionAndSubagentsAsync(fullSessionId, config); + } + result.deleted++; + } else { + result.skipped++; + } + } catch (error) { + // Ignore ENOENT (file already deleted) + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + // File already deleted, do nothing. + } else { + debugLogger.warn( + `Failed to delete matching file ${file}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + result.failed++; + } + } + } + } else { + // Fallback to old logic + const sessionPath = path.join(chatsDir, sessionToDelete.fileName); + await fs.unlink(sessionPath); + + const sessionId = sessionToDelete.sessionInfo?.id; + if (sessionId) { + await cleanupSessionAndSubagentsAsync(sessionId, config); + } + + if (config.getDebugMode()) { + debugLogger.debug( + `Deleted fallback session: ${sessionToDelete.fileName}`, + ); + } + result.deleted++; + } + } catch (error) { + // Ignore ENOENT (file already deleted) + if ( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + ) { + // File already deleted + } else { + const sessionId = + sessionToDelete.sessionInfo === null + ? sessionToDelete.fileName + : sessionToDelete.sessionInfo.id; + debugLogger.warn( + `Failed to delete session ${sessionId}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + result.failed++; + } + } + } + + result.skipped = result.scanned - result.deleted - result.failed; + + if (config.getDebugMode() && result.deleted > 0) { + debugLogger.debug( + `Session cleanup: deleted ${result.deleted}, skipped ${result.skipped}, failed ${result.failed}`, + ); + } + } catch (error) { + // Global error handler - don't let cleanup failures break startup + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + debugLogger.warn(`Session cleanup failed: ${errorMessage}`); + result.failed++; + } + + return result; +} + +/** + * Identifies sessions that should be deleted (corrupted or expired based on retention policy) + */ +export async function identifySessionsToDelete( + allFiles: SessionFileEntry[], + retentionConfig: SessionRetentionSettings, +): Promise { + const sessionsToDelete: SessionFileEntry[] = []; + + // All corrupted files should be deleted + sessionsToDelete.push( + ...allFiles.filter((entry) => entry.sessionInfo === null), + ); + + // Now handle valid sessions based on retention policy + const validSessions = allFiles.filter((entry) => entry.sessionInfo !== null); + if (validSessions.length === 0) { + return sessionsToDelete; + } + + const now = new Date(); + + // Calculate cutoff date for age-based retention + let cutoffDate: Date | null = null; + if (retentionConfig.maxAge) { + try { + const maxAgeMs = parseRetentionPeriod(retentionConfig.maxAge); + cutoffDate = new Date(now.getTime() - maxAgeMs); + } catch { + // This should not happen as validation should have caught it, + // but handle gracefully just in case + cutoffDate = null; + } + } + + // Sort valid sessions by lastUpdated (newest first) for count-based retention + const sortedValidSessions = [...validSessions].sort( + (a, b) => + new Date(b.sessionInfo!.lastUpdated).getTime() - + new Date(a.sessionInfo!.lastUpdated).getTime(), + ); + + // Separate deletable sessions from the active session + const deletableSessions = sortedValidSessions.filter( + (entry) => !entry.sessionInfo!.isCurrentSession, + ); + + // Calculate how many deletable sessions to keep (accounting for the active session) + const hasActiveSession = sortedValidSessions.some( + (e) => e.sessionInfo!.isCurrentSession, + ); + const maxDeletableSessions = + retentionConfig.maxCount && hasActiveSession + ? Math.max(0, retentionConfig.maxCount - 1) + : retentionConfig.maxCount; + + for (let i = 0; i < deletableSessions.length; i++) { + const entry = deletableSessions[i]; + const session = entry.sessionInfo!; + + let shouldDelete = false; + + // Age-based retention check + if (cutoffDate) { + const lastUpdatedDate = new Date(session.lastUpdated); + const isExpired = lastUpdatedDate < cutoffDate; + if (isExpired) { + shouldDelete = true; + } + } + + // Count-based retention check (keep only N most recent deletable sessions) + if (maxDeletableSessions !== undefined) { + if (i >= maxDeletableSessions) { + shouldDelete = true; + } + } + + if (shouldDelete) { + sessionsToDelete.push(entry); + } + } + + return sessionsToDelete; +} + +/** + * Parses retention period strings like "30d", "7d", "24h" into milliseconds + * @throws {Error} If the format is invalid + */ +function parseRetentionPeriod(period: string): number { + const match = period.match(/^(\d+)([dhwm])$/); + if (!match) { + throw new Error( + `Invalid retention period format: ${period}. Expected format: where unit is h, d, w, or m`, + ); + } + + const value = parseInt(match[1], 10); + const unit = match[2]; + + // Reject zero values as they're semantically invalid + if (value === 0) { + throw new Error( + `Invalid retention period: ${period}. Value must be greater than 0`, + ); + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return value * MULTIPLIERS[unit as keyof typeof MULTIPLIERS]; +} + +/** + * Validates retention configuration + */ +function validateRetentionConfig( + config: Config, + retentionConfig: SessionRetentionSettings, +): string | null { + if (!retentionConfig.enabled) { + return 'Retention not enabled'; + } + + // Validate maxAge if provided + if (retentionConfig.maxAge) { + let maxAgeMs: number; + try { + maxAgeMs = parseRetentionPeriod(retentionConfig.maxAge); + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + return (error as Error | string).toString(); + } + + // Enforce minimum retention period + const minRetention = retentionConfig.minRetention || DEFAULT_MIN_RETENTION; + let minRetentionMs: number; + try { + minRetentionMs = parseRetentionPeriod(minRetention); + } catch (error) { + // If minRetention format is invalid, fall back to default + if (config.getDebugMode()) { + debugLogger.warn(`Failed to parse minRetention: ${error}`); + } + minRetentionMs = parseRetentionPeriod(DEFAULT_MIN_RETENTION); + } + + if (maxAgeMs < minRetentionMs) { + return `maxAge cannot be less than minRetention (${minRetention})`; + } + } + + // Validate maxCount if provided + if (retentionConfig.maxCount !== undefined) { + if (retentionConfig.maxCount < MIN_MAX_COUNT) { + return `maxCount must be at least ${MIN_MAX_COUNT}`; + } + } + + // At least one retention method must be specified + if (!retentionConfig.maxAge && retentionConfig.maxCount === undefined) { + return 'Either maxAge or maxCount must be specified'; + } + + return null; +} + +/** + * Result of tool output cleanup operation + */ +export interface ToolOutputCleanupResult { + disabled: boolean; + scanned: number; + deleted: number; + failed: number; +} + +/** + * Cleans up tool output files based on age and count limits. + * Uses the same retention settings as session cleanup. + */ +export async function cleanupToolOutputFiles( + settings: Settings, + debugMode: boolean = false, + projectTempDir?: string, +): Promise { + const result: ToolOutputCleanupResult = { + disabled: false, + scanned: 0, + deleted: 0, + failed: 0, + }; + + try { + // Early exit if cleanup is disabled + if (!settings.general?.sessionRetention?.enabled) { + return { ...result, disabled: true }; + } + + const retentionConfig = settings.general.sessionRetention; + let tempDir = projectTempDir; + if (!tempDir) { + const storage = new Storage(process.cwd()); + await storage.initialize(); + tempDir = storage.getProjectTempDir(); + } + const toolOutputDir = path.join(tempDir, TOOL_OUTPUTS_DIR); + + // Check if directory exists + try { + await fs.access(toolOutputDir); + } catch { + // Directory doesn't exist, nothing to clean up + return result; + } + + // Get all entries in the tool-outputs directory + const entries = await fs.readdir(toolOutputDir, { withFileTypes: true }); + result.scanned = entries.length; + + if (entries.length === 0) { + return result; + } + + const files = entries.filter((e) => e.isFile()); + + // Get file stats for age-based cleanup (parallel for better performance) + const fileStatsResults = await Promise.all( + files.map(async (file) => { + try { + const filePath = path.join(toolOutputDir, file.name); + const stat = await fs.stat(filePath); + return { name: file.name, mtime: stat.mtime }; + } catch (error) { + debugLogger.debug( + `Failed to stat file ${file.name}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + return null; + } + }), + ); + const fileStats = fileStatsResults.filter( + (f): f is { name: string; mtime: Date } => f !== null, + ); + + // Sort by mtime (oldest first) + fileStats.sort((a, b) => a.mtime.getTime() - b.mtime.getTime()); + + const now = new Date(); + const filesToDelete: string[] = []; + + // Age-based cleanup: delete files older than maxAge + if (retentionConfig.maxAge) { + try { + const maxAgeMs = parseRetentionPeriod(retentionConfig.maxAge); + const cutoffDate = new Date(now.getTime() - maxAgeMs); + + for (const file of fileStats) { + if (file.mtime < cutoffDate) { + filesToDelete.push(file.name); + } + } + } catch (error) { + debugLogger.debug( + `Invalid maxAge format, skipping age-based cleanup: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + // Count-based cleanup: after age-based cleanup, if we still have more files + // than maxCount, delete the oldest ones to bring the count down. + // This ensures we keep at most maxCount files, preferring newer ones. + if (retentionConfig.maxCount !== undefined) { + // Filter out files already marked for deletion by age-based cleanup + const remainingFiles = fileStats.filter( + (f) => !filesToDelete.includes(f.name), + ); + if (remainingFiles.length > retentionConfig.maxCount) { + // Calculate how many excess files need to be deleted + const excessCount = remainingFiles.length - retentionConfig.maxCount; + // remainingFiles is already sorted oldest first, so delete from the start + for (let i = 0; i < excessCount; i++) { + filesToDelete.push(remainingFiles[i].name); + } + } + } + + // For now, continue to cleanup individual files in the root tool-outputs dir + // but also scan and cleanup expired session subdirectories. + const subdirs = entries.filter( + (e) => e.isDirectory() && e.name.startsWith('session-'), + ); + for (const subdir of subdirs) { + try { + // Security: Validate that the subdirectory name is a safe filename part + // and doesn't attempt path traversal. + if (subdir.name !== sanitizeFilenamePart(subdir.name)) { + debugLogger.debug( + `Skipping unsafe tool-output subdirectory: ${subdir.name}`, + ); + continue; + } + + const subdirPath = path.join(toolOutputDir, subdir.name); + const stat = await fs.stat(subdirPath); + + let shouldDelete = false; + if (retentionConfig.maxAge) { + const maxAgeMs = parseRetentionPeriod(retentionConfig.maxAge); + const cutoffDate = new Date(now.getTime() - maxAgeMs); + if (stat.mtime < cutoffDate) { + shouldDelete = true; + } + } + + if (shouldDelete) { + await fs.rm(subdirPath, { recursive: true, force: true }); + result.deleted++; // Count as one "unit" of deletion for stats + } + } catch (error) { + debugLogger.debug(`Failed to cleanup subdir ${subdir.name}: ${error}`); + } + } + + // Delete the files + for (const fileName of filesToDelete) { + try { + const filePath = path.join(toolOutputDir, fileName); + await fs.unlink(filePath); + result.deleted++; + } catch (error) { + debugLogger.debug( + `Failed to delete file ${fileName}: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + result.failed++; + } + } + + if (debugMode && result.deleted > 0) { + debugLogger.debug( + `Tool output cleanup: deleted ${result.deleted}, failed ${result.failed}`, + ); + } + } catch (error) { + // Global error handler - don't let cleanup failures break startup + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + debugLogger.warn(`Tool output cleanup failed: ${errorMessage}`); + result.failed++; + } + + return result; +} diff --git a/packages/cli/src/utils/sessions.test.ts b/packages/cli/src/utils/sessions.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e457234d4234b709e0c220b51ff8bb1002f3b1c0 --- /dev/null +++ b/packages/cli/src/utils/sessions.test.ts @@ -0,0 +1,745 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { deleteStoredSession, type Config } from '@google/gemini-cli-core'; +import { listSessions, deleteSession } from './sessions.js'; +import { SessionSelector, type SessionInfo } from './sessionUtils.js'; + +const mocks = vi.hoisted(() => ({ + writeToStdout: vi.fn(), + writeToStderr: vi.fn(), +})); + +// Mock the SessionSelector and deleteStoredSession. +vi.mock('./sessionUtils.js', () => ({ + SessionSelector: vi.fn(), + formatRelativeTime: vi.fn(() => 'some time ago'), +})); + +vi.mock('@google/gemini-cli-core', async () => { + const actual = await vi.importActual('@google/gemini-cli-core'); + return { + ...actual, + deleteStoredSession: vi.fn(), + generateSummary: vi.fn().mockResolvedValue(undefined), + writeToStdout: mocks.writeToStdout, + writeToStderr: mocks.writeToStderr, + }; +}); + +describe('listSessions', () => { + let mockConfig: Config; + let mockListSessions: ReturnType; + + beforeEach(() => { + // Create mock config + mockConfig = { + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/test-project'), + }, + getSessionId: vi.fn().mockReturnValue('current-session-id'), + } as unknown as Config; + + // Create mock listSessions method + mockListSessions = vi.fn(); + + // Mock SessionSelector constructor to return object with listSessions method + vi.mocked(SessionSelector).mockImplementation( + () => + ({ + listSessions: mockListSessions, + }) as unknown as InstanceType, + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + mocks.writeToStdout.mockClear(); + mocks.writeToStderr.mockClear(); + }); + + it('should display message when no previous sessions were found', async () => { + // Arrange: Return empty array from listSessions + mockListSessions.mockResolvedValue([]); + + // Act + await listSessions(mockConfig); + + // Assert + expect(mockListSessions).toHaveBeenCalledOnce(); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + 'No previous sessions found for this project.', + ); + }); + + it('should list sessions when sessions are found', async () => { + // Arrange: Create test sessions + const now = new Date('2025-01-20T12:00:00.000Z'); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + const twoDaysAgo = new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000); + + const mockSessions: SessionInfo[] = [ + { + id: 'session-1', + file: 'session-2025-01-18T12-00-00-session-1', + fileName: 'session-2025-01-18T12-00-00-session-1.json', + startTime: twoDaysAgo.toISOString(), + lastUpdated: twoDaysAgo.toISOString(), + messageCount: 5, + displayName: 'First user message', + firstUserMessage: 'First user message', + isCurrentSession: false, + index: 1, + }, + { + id: 'session-2', + file: 'session-2025-01-20T11-00-00-session-2', + fileName: 'session-2025-01-20T11-00-00-session-2.json', + startTime: oneHourAgo.toISOString(), + lastUpdated: oneHourAgo.toISOString(), + messageCount: 10, + displayName: 'Second user message', + firstUserMessage: 'Second user message', + isCurrentSession: false, + index: 2, + }, + { + id: 'current-session-id', + file: 'session-2025-01-20T12-00-00-current-s', + fileName: 'session-2025-01-20T12-00-00-current-s.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + messageCount: 3, + displayName: 'Current session', + firstUserMessage: 'Current session', + isCurrentSession: true, + index: 3, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act + await listSessions(mockConfig); + + // Assert + expect(mockListSessions).toHaveBeenCalledOnce(); + + // Check that the header was displayed + expect(mocks.writeToStdout).toHaveBeenCalledWith( + '\nAvailable sessions for this project (3):\n', + ); + + // Check that each session was logged + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('1. First user message'), + ); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('[session-1]'), + ); + + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('2. Second user message'), + ); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('[session-2]'), + ); + + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('3. Current session'), + ); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining(', current)'), + ); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('[current-session-id]'), + ); + }); + + it('should sort sessions by start time (oldest first)', async () => { + // Arrange: Create sessions in non-chronological order + const session1Time = new Date('2025-01-18T12:00:00.000Z'); + const session2Time = new Date('2025-01-19T12:00:00.000Z'); + const session3Time = new Date('2025-01-20T12:00:00.000Z'); + + const mockSessions: SessionInfo[] = [ + { + id: 'session-2', + file: 'session-2', + fileName: 'session-2.json', + startTime: session2Time.toISOString(), // Middle + lastUpdated: session2Time.toISOString(), + messageCount: 5, + displayName: 'Middle session', + firstUserMessage: 'Middle session', + isCurrentSession: false, + index: 2, + }, + { + id: 'session-1', + file: 'session-1', + fileName: 'session-1.json', + startTime: session1Time.toISOString(), // Oldest + lastUpdated: session1Time.toISOString(), + messageCount: 5, + displayName: 'Oldest session', + firstUserMessage: 'Oldest session', + isCurrentSession: false, + index: 1, + }, + { + id: 'session-3', + file: 'session-3', + fileName: 'session-3.json', + startTime: session3Time.toISOString(), // Newest + lastUpdated: session3Time.toISOString(), + messageCount: 5, + displayName: 'Newest session', + firstUserMessage: 'Newest session', + isCurrentSession: false, + index: 3, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act + await listSessions(mockConfig); + + // Assert + // Get all the session log calls (skip the header) + const sessionCalls = mocks.writeToStdout.mock.calls.filter( + (call): call is [string] => + // eslint-disable-next-line no-restricted-syntax + typeof call[0] === 'string' && + call[0].includes('[session-') && + !call[0].includes('Available sessions'), + ); + + // Verify they are sorted by start time (oldest first) + expect(sessionCalls[0][0]).toContain('1. Oldest session'); + expect(sessionCalls[1][0]).toContain('2. Middle session'); + expect(sessionCalls[2][0]).toContain('3. Newest session'); + }); + + it('should format session output with relative time and session ID', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'abc123def456', + file: 'session-file', + fileName: 'session-file.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test message', + firstUserMessage: 'Test message', + isCurrentSession: false, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act + await listSessions(mockConfig); + + // Assert + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('1. Test message'), + ); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('some time ago'), + ); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('[abc123def456]'), + ); + }); + + it('should handle single session', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'single-session', + file: 'session-file', + fileName: 'session-file.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Only session', + firstUserMessage: 'Only session', + isCurrentSession: true, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act + await listSessions(mockConfig); + + // Assert + expect(mocks.writeToStdout).toHaveBeenCalledWith( + '\nAvailable sessions for this project (1):\n', + ); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('1. Only session'), + ); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining(', current)'), + ); + }); + + it('should display summary as title when available instead of first user message', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'session-with-summary', + file: 'session-file', + fileName: 'session-file.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + messageCount: 10, + displayName: 'Add dark mode to the app', // Summary + firstUserMessage: + 'How do I add dark mode to my React application with CSS variables?', + isCurrentSession: false, + index: 1, + summary: 'Add dark mode to the app', + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act + await listSessions(mockConfig); + + // Assert: Should show the summary (displayName), not the first user message + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('1. Add dark mode to the app'), + ); + expect(mocks.writeToStdout).not.toHaveBeenCalledWith( + expect.stringContaining('How do I add dark mode to my React application'), + ); + }); +}); + +describe('deleteSession', () => { + let mockConfig: Config; + let mockListSessions: ReturnType; + let mockDeleteSession: ReturnType; + + beforeEach(() => { + // Create mock config + mockConfig = { + storage: { + getProjectTempDir: vi.fn().mockReturnValue('/tmp/test-project'), + }, + getSessionId: vi.fn().mockReturnValue('current-session-id'), + } as unknown as Config; + + // Create mock methods + mockListSessions = vi.fn(); + mockDeleteSession = vi.mocked(deleteStoredSession); + mockDeleteSession.mockReset(); + + // Mock SessionSelector constructor + vi.mocked(SessionSelector).mockImplementation( + () => + ({ + listSessions: mockListSessions, + }) as unknown as InstanceType, + ); + }); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('should display error when no sessions are found', async () => { + // Arrange + mockListSessions.mockResolvedValue([]); + + // Act + await deleteSession(mockConfig, '1'); + + // Assert + expect(mockListSessions).toHaveBeenCalledOnce(); + expect(mocks.writeToStderr).toHaveBeenCalledWith( + 'No sessions found for this project.', + ); + expect(mockDeleteSession).not.toHaveBeenCalled(); + }); + + it('should delete session by UUID', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'session-uuid-123', + file: 'session-file-123', + fileName: 'session-file-123.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', + firstUserMessage: 'Test session', + isCurrentSession: false, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + mockDeleteSession.mockImplementation(() => {}); + + // Act + await deleteSession(mockConfig, 'session-uuid-123'); + + // Assert + expect(mockListSessions).toHaveBeenCalledOnce(); + expect(mockDeleteSession).toHaveBeenCalledWith( + mockConfig, + 'session-file-123', + ); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + 'Deleted session 1: Test session (some time ago)', + ); + expect(mocks.writeToStderr).not.toHaveBeenCalled(); + }); + + it('should delete session by index', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000); + + const mockSessions: SessionInfo[] = [ + { + id: 'session-1', + file: 'session-file-1', + fileName: 'session-file-1.json', + startTime: oneHourAgo.toISOString(), + lastUpdated: oneHourAgo.toISOString(), + messageCount: 5, + displayName: 'First session', + firstUserMessage: 'First session', + isCurrentSession: false, + index: 1, + }, + { + id: 'session-2', + file: 'session-file-2', + fileName: 'session-file-2.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + messageCount: 10, + displayName: 'Second session', + firstUserMessage: 'Second session', + isCurrentSession: false, + index: 2, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + mockDeleteSession.mockImplementation(() => {}); + + // Act + await deleteSession(mockConfig, '2'); + + // Assert + expect(mockListSessions).toHaveBeenCalledOnce(); + expect(mockDeleteSession).toHaveBeenCalledWith( + mockConfig, + 'session-file-2', + ); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + 'Deleted session 2: Second session (some time ago)', + ); + }); + + it('should display error for invalid session identifier (non-numeric)', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'session-1', + file: 'session-file-1', + fileName: 'session-file-1.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', + firstUserMessage: 'Test session', + isCurrentSession: false, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act + await deleteSession(mockConfig, 'invalid-id'); + + // Assert + expect(mocks.writeToStderr).toHaveBeenCalledWith( + 'Invalid session identifier "invalid-id". Use --list-sessions to see available sessions.', + ); + expect(mockDeleteSession).not.toHaveBeenCalled(); + }); + + it('should display error for invalid session identifier (out of range)', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'session-1', + file: 'session-file-1', + fileName: 'session-file-1.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', + firstUserMessage: 'Test session', + isCurrentSession: false, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act + await deleteSession(mockConfig, '999'); + + // Assert + expect(mocks.writeToStderr).toHaveBeenCalledWith( + 'Invalid session identifier "999". Use --list-sessions to see available sessions.', + ); + expect(mockDeleteSession).not.toHaveBeenCalled(); + }); + + it('should display error for invalid session identifier (zero)', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'session-1', + file: 'session-file-1', + fileName: 'session-file-1.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', + firstUserMessage: 'Test session', + isCurrentSession: false, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act + await deleteSession(mockConfig, '0'); + + // Assert + expect(mocks.writeToStderr).toHaveBeenCalledWith( + 'Invalid session identifier "0". Use --list-sessions to see available sessions.', + ); + expect(mockDeleteSession).not.toHaveBeenCalled(); + }); + + it('should prevent deletion of current session', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'current-session-id', + file: 'current-session-file', + fileName: 'current-session-file.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Current session', + firstUserMessage: 'Current session', + isCurrentSession: true, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act - try to delete by index + await deleteSession(mockConfig, '1'); + + // Assert + expect(mocks.writeToStderr).toHaveBeenCalledWith( + 'Cannot delete the current active session.', + ); + expect(mockDeleteSession).not.toHaveBeenCalled(); + }); + + it('should prevent deletion of current session by UUID', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'current-session-id', + file: 'current-session-file', + fileName: 'current-session-file.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Current session', + firstUserMessage: 'Current session', + isCurrentSession: true, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + + // Act - try to delete by UUID + await deleteSession(mockConfig, 'current-session-id'); + + // Assert + expect(mocks.writeToStderr).toHaveBeenCalledWith( + 'Cannot delete the current active session.', + ); + expect(mockDeleteSession).not.toHaveBeenCalled(); + }); + + it('should handle deletion errors gracefully', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'session-1', + file: 'session-file-1', + fileName: 'session-file-1.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', + firstUserMessage: 'Test session', + isCurrentSession: false, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + mockDeleteSession.mockImplementation(() => { + throw new Error('File deletion failed'); + }); + + // Act + await deleteSession(mockConfig, '1'); + + // Assert + expect(mockDeleteSession).toHaveBeenCalledWith( + mockConfig, + 'session-file-1', + ); + expect(mocks.writeToStderr).toHaveBeenCalledWith( + 'Failed to delete session: File deletion failed', + ); + }); + + it('should handle non-Error deletion failures', async () => { + // Arrange + const now = new Date('2025-01-20T12:00:00.000Z'); + const mockSessions: SessionInfo[] = [ + { + id: 'session-1', + file: 'session-file-1', + fileName: 'session-file-1.json', + startTime: now.toISOString(), + lastUpdated: now.toISOString(), + messageCount: 5, + displayName: 'Test session', + firstUserMessage: 'Test session', + isCurrentSession: false, + index: 1, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + mockDeleteSession.mockImplementation(() => { + // eslint-disable-next-line no-restricted-syntax + throw 'Unknown error type'; + }); + + // Act + await deleteSession(mockConfig, '1'); + + // Assert + expect(mocks.writeToStderr).toHaveBeenCalledWith( + 'Failed to delete session: Unknown error', + ); + }); + + it('should sort sessions before finding by index', async () => { + // Arrange: Create sessions in non-chronological order + const session1Time = new Date('2025-01-18T12:00:00.000Z'); + const session2Time = new Date('2025-01-19T12:00:00.000Z'); + const session3Time = new Date('2025-01-20T12:00:00.000Z'); + + const mockSessions: SessionInfo[] = [ + { + id: 'session-3', + file: 'session-file-3', + fileName: 'session-file-3.json', + startTime: session3Time.toISOString(), // Newest + lastUpdated: session3Time.toISOString(), + messageCount: 5, + displayName: 'Newest session', + firstUserMessage: 'Newest session', + isCurrentSession: false, + index: 3, + }, + { + id: 'session-1', + file: 'session-file-1', + fileName: 'session-file-1.json', + startTime: session1Time.toISOString(), // Oldest + lastUpdated: session1Time.toISOString(), + messageCount: 5, + displayName: 'Oldest session', + firstUserMessage: 'Oldest session', + isCurrentSession: false, + index: 1, + }, + { + id: 'session-2', + file: 'session-file-2', + fileName: 'session-file-2.json', + startTime: session2Time.toISOString(), // Middle + lastUpdated: session2Time.toISOString(), + messageCount: 5, + displayName: 'Middle session', + firstUserMessage: 'Middle session', + isCurrentSession: false, + index: 2, + }, + ]; + + mockListSessions.mockResolvedValue(mockSessions); + mockDeleteSession.mockImplementation(() => {}); + + // Act - delete index 1 (should be oldest session after sorting) + await deleteSession(mockConfig, '1'); + + // Assert + expect(mockDeleteSession).toHaveBeenCalledWith( + mockConfig, + 'session-file-1', + ); + expect(mocks.writeToStdout).toHaveBeenCalledWith( + expect.stringContaining('Oldest session'), + ); + }); +}); diff --git a/packages/cli/src/utils/skillSettings.test.ts b/packages/cli/src/utils/skillSettings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..3a03e9ca9a332c1633292b21a5fba6745c8fce2c --- /dev/null +++ b/packages/cli/src/utils/skillSettings.test.ts @@ -0,0 +1,196 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { + SettingScope, + type LoadedSettings, + type LoadableSettingScope, +} from '../config/settings.js'; +import { enableSkill, disableSkill } from './skillSettings.js'; + +function createMockLoadedSettings(opts: { + userSettings?: Record; + workspaceSettings?: Record; + userPath?: string; + workspacePath?: string; +}): LoadedSettings { + const scopes: Record< + string, + { + settings: Record; + originalSettings: Record; + path: string; + } + > = { + [SettingScope.User]: { + settings: opts.userSettings ?? {}, + originalSettings: opts.userSettings ?? {}, + path: opts.userPath ?? '/home/user/.gemini/settings.json', + }, + [SettingScope.Workspace]: { + settings: opts.workspaceSettings ?? {}, + originalSettings: opts.workspaceSettings ?? {}, + path: opts.workspacePath ?? '/project/.gemini/settings.json', + }, + }; + + return { + forScope: vi.fn((scope: LoadableSettingScope) => scopes[scope]), + setValue: vi.fn(), + } as unknown as LoadedSettings; +} + +describe('skillSettings', () => { + describe('skillStrategy (via enableSkill / disableSkill)', () => { + describe('enableSkill', () => { + it('should return no-op when the skill is not in any disabled list', () => { + const settings = createMockLoadedSettings({ + userSettings: { skills: { disabled: [] } }, + workspaceSettings: { skills: { disabled: [] } }, + }); + + const result = enableSkill(settings, 'my-skill'); + + expect(result.status).toBe('no-op'); + expect(result.action).toBe('enable'); + expect(result.skillName).toBe('my-skill'); + expect(result.modifiedScopes).toHaveLength(0); + expect(settings.setValue).not.toHaveBeenCalled(); + }); + + it('should return no-op when skills.disabled is undefined', () => { + const settings = createMockLoadedSettings({ + userSettings: {}, + workspaceSettings: {}, + }); + + const result = enableSkill(settings, 'my-skill'); + + expect(result.status).toBe('no-op'); + expect(result.action).toBe('enable'); + expect(result.modifiedScopes).toHaveLength(0); + }); + + it('should enable the skill when it is in the disabled list of one scope', () => { + const settings = createMockLoadedSettings({ + userSettings: { skills: { disabled: ['my-skill'] } }, + workspaceSettings: { skills: { disabled: [] } }, + }); + + const result = enableSkill(settings, 'my-skill'); + + expect(result.status).toBe('success'); + expect(result.action).toBe('enable'); + expect(result.modifiedScopes).toHaveLength(1); + expect(result.modifiedScopes[0].scope).toBe(SettingScope.User); + expect(result.alreadyInStateScopes).toHaveLength(1); + expect(result.alreadyInStateScopes[0].scope).toBe( + SettingScope.Workspace, + ); + expect(settings.setValue).toHaveBeenCalledTimes(1); + }); + + it('should enable the skill when it is in the disabled list of both scopes', () => { + const settings = createMockLoadedSettings({ + userSettings: { skills: { disabled: ['my-skill', 'other-skill'] } }, + workspaceSettings: { skills: { disabled: ['my-skill'] } }, + }); + + const result = enableSkill(settings, 'my-skill'); + + expect(result.status).toBe('success'); + expect(result.modifiedScopes).toHaveLength(2); + expect(result.alreadyInStateScopes).toHaveLength(0); + expect(settings.setValue).toHaveBeenCalledTimes(2); + }); + + it('should not affect other skills in the disabled list', () => { + const settings = createMockLoadedSettings({ + userSettings: { skills: { disabled: ['my-skill', 'keep-disabled'] } }, + workspaceSettings: { skills: { disabled: [] } }, + }); + + const result = enableSkill(settings, 'my-skill'); + + expect(result.status).toBe('success'); + expect(settings.setValue).toHaveBeenCalledTimes(1); + }); + }); + + describe('disableSkill', () => { + it('should return no-op when the skill is already in the disabled list', () => { + const settings = createMockLoadedSettings({ + userSettings: { skills: { disabled: ['my-skill'] } }, + }); + + const result = disableSkill(settings, 'my-skill', SettingScope.User); + + expect(result.status).toBe('no-op'); + expect(result.action).toBe('disable'); + expect(result.skillName).toBe('my-skill'); + expect(result.modifiedScopes).toHaveLength(0); + expect(result.alreadyInStateScopes).toHaveLength(1); + expect(settings.setValue).not.toHaveBeenCalled(); + }); + + it('should disable the skill when it is not in the disabled list', () => { + const settings = createMockLoadedSettings({ + userSettings: { skills: { disabled: [] } }, + }); + + const result = disableSkill(settings, 'my-skill', SettingScope.User); + + expect(result.status).toBe('success'); + expect(result.action).toBe('disable'); + expect(result.modifiedScopes).toHaveLength(1); + expect(result.modifiedScopes[0].scope).toBe(SettingScope.User); + expect(settings.setValue).toHaveBeenCalledTimes(1); + }); + + it('should disable the skill when skills.disabled is undefined', () => { + const settings = createMockLoadedSettings({ + userSettings: {}, + }); + + const result = disableSkill(settings, 'my-skill', SettingScope.User); + + expect(result.status).toBe('success'); + expect(result.action).toBe('disable'); + expect(result.modifiedScopes).toHaveLength(1); + expect(settings.setValue).toHaveBeenCalledTimes(1); + }); + + it('should return error for an invalid scope', () => { + const settings = createMockLoadedSettings({}); + + const result = disableSkill(settings, 'my-skill', SettingScope.Session); + + expect(result.status).toBe('error'); + expect(result.error).toContain('Invalid settings scope'); + }); + + it('should disable in workspace and report user as already disabled', () => { + const settings = createMockLoadedSettings({ + userSettings: { skills: { disabled: ['my-skill'] } }, + workspaceSettings: { skills: { disabled: [] } }, + }); + + const result = disableSkill( + settings, + 'my-skill', + SettingScope.Workspace, + ); + + expect(result.status).toBe('success'); + expect(result.modifiedScopes).toHaveLength(1); + expect(result.modifiedScopes[0].scope).toBe(SettingScope.Workspace); + expect(result.alreadyInStateScopes).toHaveLength(1); + expect(result.alreadyInStateScopes[0].scope).toBe(SettingScope.User); + }); + }); + }); +}); diff --git a/packages/cli/src/utils/skillSettings.ts b/packages/cli/src/utils/skillSettings.ts new file mode 100644 index 0000000000000000000000000000000000000000..4d1eb38b23c701bf2f626a5d50759a514f8702b5 --- /dev/null +++ b/packages/cli/src/utils/skillSettings.ts @@ -0,0 +1,95 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SettingScope, LoadedSettings } from '../config/settings.js'; + +import { + type FeatureActionResult, + type FeatureToggleStrategy, + enableFeature, + disableFeature, +} from './featureToggleUtils.js'; + +export type { ModifiedScope } from './featureToggleUtils.js'; + +export type SkillActionStatus = 'success' | 'no-op' | 'error'; + +/** + * Metadata representing the result of a skill settings operation. + */ +export interface SkillActionResult + extends Omit { + skillName: string; +} + +const skillStrategy: FeatureToggleStrategy = { + needsEnabling: (settings, scope, skillName) => { + const scopeDisabled = settings.forScope(scope).settings.skills?.disabled; + return !!scopeDisabled?.includes(skillName); + }, + enable: (settings, scope, skillName) => { + const currentScopeDisabled = + settings.forScope(scope).settings.skills?.disabled ?? []; + const newDisabled = currentScopeDisabled.filter( + (name) => name !== skillName, + ); + settings.setValue(scope, 'skills.disabled', newDisabled); + }, + isExplicitlyDisabled: (settings, scope, skillName) => { + const currentScopeDisabled = + settings.forScope(scope).settings.skills?.disabled ?? []; + return currentScopeDisabled.includes(skillName); + }, + disable: (settings, scope, skillName) => { + const currentScopeDisabled = + settings.forScope(scope).settings.skills?.disabled ?? []; + // The generic utility checks isExplicitlyDisabled before calling this, + // but just to be safe and idempotent, we check or we assume the utility did its job. + // The utility does check isExplicitlyDisabled first. + // So we can blindly add it, but since we are modifying an array, pushing is fine. + // However, if we assume purely that we must disable it: + const newDisabled = [...currentScopeDisabled, skillName]; + settings.setValue(scope, 'skills.disabled', newDisabled); + }, +}; + +/** + * Enables a skill by removing it from all writable disabled lists (User and Workspace). + */ +export function enableSkill( + settings: LoadedSettings, + skillName: string, +): SkillActionResult { + const { featureName, ...rest } = enableFeature( + settings, + skillName, + skillStrategy, + ); + return { + ...rest, + skillName: featureName, + }; +} + +/** + * Disables a skill by adding it to the disabled list in the specified scope. + */ +export function disableSkill( + settings: LoadedSettings, + skillName: string, + scope: SettingScope, +): SkillActionResult { + const { featureName, ...rest } = disableFeature( + settings, + skillName, + scope, + skillStrategy, + ); + return { + ...rest, + skillName: featureName, + }; +} diff --git a/packages/cli/src/utils/skillUtils.test.ts b/packages/cli/src/utils/skillUtils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..db1bb6b2f67e3b525ee52be471df2c6abe4e42aa --- /dev/null +++ b/packages/cli/src/utils/skillUtils.test.ts @@ -0,0 +1,402 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { installSkill, linkSkill, uninstallSkill } from './skillUtils.js'; + +describe('skillUtils', () => { + let tempDir: string; + const projectRoot = path.resolve(__dirname, '../../../../../'); + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'skill-utils-test-')); + vi.spyOn(process, 'cwd').mockReturnValue(tempDir); + vi.stubEnv('GEMINI_CLI_HOME', tempDir); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + vi.unstubAllEnvs(); + }); + + describe('linkSkill', () => { + it('should successfully link from a local directory', async () => { + // Create a mock skill directory + const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source'); + const skillSubDir = path.join(mockSkillSourceDir, 'test-skill'); + await fs.mkdir(skillSubDir, { recursive: true }); + await fs.writeFile( + path.join(skillSubDir, 'SKILL.md'), + '---\nname: test-skill\ndescription: test\n---\nbody', + ); + + const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {}); + expect(skills.length).toBe(1); + expect(skills[0].name).toBe('test-skill'); + + const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill'); + const stats = await fs.lstat(linkedPath); + expect(stats.isSymbolicLink()).toBe(true); + + const linkTarget = await fs.readlink(linkedPath); + expect(path.resolve(linkTarget)).toBe(path.resolve(skillSubDir)); + }); + + it('should overwrite existing skill at destination', async () => { + const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source'); + const skillSubDir = path.join(mockSkillSourceDir, 'test-skill'); + await fs.mkdir(skillSubDir, { recursive: true }); + await fs.writeFile( + path.join(skillSubDir, 'SKILL.md'), + '---\nname: test-skill\ndescription: test\n---\nbody', + ); + + const targetDir = path.join(tempDir, '.gemini/skills'); + await fs.mkdir(targetDir, { recursive: true }); + const existingPath = path.join(targetDir, 'test-skill'); + await fs.mkdir(existingPath); + + const skills = await linkSkill(mockSkillSourceDir, 'workspace', () => {}); + expect(skills.length).toBe(1); + + const stats = await fs.lstat(existingPath); + expect(stats.isSymbolicLink()).toBe(true); + }); + + it('should abort linking if consent is rejected', async () => { + const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source'); + const skillSubDir = path.join(mockSkillSourceDir, 'test-skill'); + await fs.mkdir(skillSubDir, { recursive: true }); + await fs.writeFile( + path.join(skillSubDir, 'SKILL.md'), + '---\nname: test-skill\ndescription: test\n---\nbody', + ); + + const requestConsent = vi.fn().mockResolvedValue(false); + + await expect( + linkSkill(mockSkillSourceDir, 'workspace', () => {}, requestConsent), + ).rejects.toThrow('Skill linking cancelled by user.'); + + expect(requestConsent).toHaveBeenCalled(); + + // Verify it was NOT linked + const linkedPath = path.join(tempDir, '.gemini/skills', 'test-skill'); + const exists = await fs.lstat(linkedPath).catch(() => null); + expect(exists).toBeNull(); + }); + + it('should throw error if multiple skills with same name are discovered', async () => { + const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source'); + const skillDir1 = path.join(mockSkillSourceDir, 'skill1'); + const skillDir2 = path.join(mockSkillSourceDir, 'skill2'); + await fs.mkdir(skillDir1, { recursive: true }); + await fs.mkdir(skillDir2, { recursive: true }); + await fs.writeFile( + path.join(skillDir1, 'SKILL.md'), + '---\nname: duplicate-skill\ndescription: desc1\n---\nbody1', + ); + await fs.writeFile( + path.join(skillDir2, 'SKILL.md'), + '---\nname: duplicate-skill\ndescription: desc2\n---\nbody2', + ); + + await expect( + linkSkill(mockSkillSourceDir, 'workspace', () => {}), + ).rejects.toThrow('Duplicate skill name "duplicate-skill" found'); + }); + }); + + it('should successfully install from a .skill file', async () => { + const skillPath = path.join(projectRoot, 'weather-skill.skill'); + + // Ensure the file exists + const exists = await fs.stat(skillPath).catch(() => null); + if (!exists) { + // If we can't find it in CI or other environments, we skip or use a mock. + // For now, since it exists in the user's environment, this test will pass there. + return; + } + + const skills = await installSkill( + skillPath, + 'workspace', + undefined, + async () => {}, + ); + expect(skills.length).toBeGreaterThan(0); + expect(skills[0].name).toBe('weather-skill'); + + // Verify it was copied to the workspace skills dir + const installedPath = path.join(tempDir, '.gemini/skills', 'weather-skill'); + const installedExists = await fs.stat(installedPath).catch(() => null); + expect(installedExists?.isDirectory()).toBe(true); + + const skillMdExists = await fs + .stat(path.join(installedPath, 'SKILL.md')) + .catch(() => null); + expect(skillMdExists?.isFile()).toBe(true); + }); + + it('should successfully install from a local directory', async () => { + // Create a mock skill directory + const mockSkillDir = path.join(tempDir, 'mock-skill-source'); + const skillSubDir = path.join(mockSkillDir, 'test-skill'); + await fs.mkdir(skillSubDir, { recursive: true }); + await fs.writeFile( + path.join(skillSubDir, 'SKILL.md'), + '---\nname: test-skill\ndescription: test\n---\nbody', + ); + + const skills = await installSkill( + mockSkillDir, + 'workspace', + undefined, + async () => {}, + ); + expect(skills.length).toBe(1); + expect(skills[0].name).toBe('test-skill'); + + const installedPath = path.join(tempDir, '.gemini/skills', 'test-skill'); + const installedExists = await fs.stat(installedPath).catch(() => null); + expect(installedExists?.isDirectory()).toBe(true); + }); + + it('should abort installation if consent is rejected', async () => { + const mockSkillDir = path.join(tempDir, 'mock-skill-source'); + const skillSubDir = path.join(mockSkillDir, 'test-skill'); + await fs.mkdir(skillSubDir, { recursive: true }); + await fs.writeFile( + path.join(skillSubDir, 'SKILL.md'), + '---\nname: test-skill\ndescription: test\n---\nbody', + ); + + const requestConsent = vi.fn().mockResolvedValue(false); + + await expect( + installSkill( + mockSkillDir, + 'workspace', + undefined, + async () => {}, + requestConsent, + ), + ).rejects.toThrow('Skill installation cancelled by user.'); + + expect(requestConsent).toHaveBeenCalled(); + + // Verify it was NOT copied + const installedPath = path.join(tempDir, '.gemini/skills', 'test-skill'); + const installedExists = await fs.stat(installedPath).catch(() => null); + expect(installedExists).toBeNull(); + }); + + describe('uninstallSkill', () => { + it('should successfully uninstall an existing skill', async () => { + const skillsDir = path.join(tempDir, '.gemini/skills'); + const skillDir = path.join(skillsDir, 'test-skill'); + await fs.mkdir(skillDir, { recursive: true }); + await fs.writeFile( + path.join(skillDir, 'SKILL.md'), + '---\nname: test-skill\ndescription: test\n---\nbody', + ); + + const result = await uninstallSkill('test-skill', 'user'); + expect(result?.location).toContain('test-skill'); + + const exists = await fs.stat(skillDir).catch(() => null); + expect(exists).toBeNull(); + }); + + it('should return null for non-existent skill', async () => { + const result = await uninstallSkill('non-existent', 'user'); + expect(result).toBeNull(); + }); + + it('should successfully uninstall a skill even if its name was updated after linking', async () => { + // 1. Create source skill + const sourceDir = path.join(tempDir, 'source-skill'); + await fs.mkdir(sourceDir, { recursive: true }); + const skillMdPath = path.join(sourceDir, 'SKILL.md'); + await fs.writeFile( + skillMdPath, + '---\nname: original-name\ndescription: test\n---\nbody', + ); + + // 2. Link it + const skillsDir = path.join(tempDir, '.gemini/skills'); + await fs.mkdir(skillsDir, { recursive: true }); + const destPath = path.join(skillsDir, 'original-name'); + await fs.symlink( + sourceDir, + destPath, + process.platform === 'win32' ? 'junction' : 'dir', + ); + + // 3. Update name in source + await fs.writeFile( + skillMdPath, + '---\nname: updated-name\ndescription: test\n---\nbody', + ); + + // 4. Uninstall by NEW name (this is the bug fix) + const result = await uninstallSkill('updated-name', 'user'); + expect(result).not.toBeNull(); + expect(result?.location).toBe(destPath); + + const exists = await fs.lstat(destPath).catch(() => null); + expect(exists).toBeNull(); + }); + + it('should successfully uninstall a skill by directory name if metadata is missing (fallback)', async () => { + const skillsDir = path.join(tempDir, '.gemini/skills'); + const skillDir = path.join(skillsDir, 'test-skill-dir'); + await fs.mkdir(skillDir, { recursive: true }); + // No SKILL.md here + + const result = await uninstallSkill('test-skill-dir', 'user'); + expect(result?.location).toBe(skillDir); + + const exists = await fs.stat(skillDir).catch(() => null); + expect(exists).toBeNull(); + }); + + it('should prevent path traversal in fallback uninstallation (e.g. sibling directories)', async () => { + const skillsDir = path.join(tempDir, '.gemini/skills'); + await fs.mkdir(skillsDir, { recursive: true }); + + const siblingDir = path.join(tempDir, '.gemini/skills-attacker'); + await fs.mkdir(siblingDir, { recursive: true }); + + // Attempt to uninstall the sibling directory using path traversal + const result = await uninstallSkill('../skills-attacker', 'user'); + expect(result).toBeNull(); + + // Verify sibling directory is NOT deleted + const exists = await fs.stat(siblingDir).catch(() => null); + expect(exists).not.toBeNull(); + }); + + it('should prevent path traversal in fallback uninstallation with dot or dot dot', async () => { + expect(await uninstallSkill('..', 'user')).toBeNull(); + expect(await uninstallSkill('.', 'user')).toBeNull(); + expect(await uninstallSkill('', 'user')).toBeNull(); + }); + }); + + describe('path traversal prevention', () => { + it('should throw error during installation if skill name is dot dot or dot', async () => { + const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source'); + const skillSubDir = path.join(mockSkillSourceDir, 'test-skill'); + await fs.mkdir(skillSubDir, { recursive: true }); + await fs.writeFile( + path.join(skillSubDir, 'SKILL.md'), + '---\nname: ..\ndescription: exploit\n---\nbody', + ); + + await expect( + installSkill(mockSkillSourceDir, 'workspace', undefined, () => {}), + ).rejects.toThrow('Invalid skill name: Path traversal detected.'); + }); + + it('should throw error during linking if skill name is dot dot or dot', async () => { + const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source'); + const skillSubDir = path.join(mockSkillSourceDir, 'test-skill'); + await fs.mkdir(skillSubDir, { recursive: true }); + await fs.writeFile( + path.join(skillSubDir, 'SKILL.md'), + '---\nname: ..\ndescription: exploit\n---\nbody', + ); + + await expect( + linkSkill(mockSkillSourceDir, 'workspace', () => {}), + ).rejects.toThrow('Invalid skill name: Path traversal detected.'); + }); + + it('should throw error during installation if subpath escapes temp directory', async () => { + const skillPath = path.join(projectRoot, 'weather-skill.skill'); + const exists = await fs.stat(skillPath).catch(() => null); + if (!exists) return; + + await expect( + installSkill(skillPath, 'workspace', '../escape', () => {}), + ).rejects.toThrow('Invalid path: Directory traversal not allowed.'); + }); + + it('should sanitize absolute path names and install them safely within the target directory', async () => { + const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source'); + const skillSubDir = path.join(mockSkillSourceDir, 'test-skill'); + await fs.mkdir(skillSubDir, { recursive: true }); + await fs.writeFile( + path.join(skillSubDir, 'SKILL.md'), + '---\nname: /tmp/exploit\ndescription: exploit\n---\nbody', + ); + + const installed = await installSkill( + mockSkillSourceDir, + 'workspace', + undefined, + () => {}, + ); + expect(installed.length).toBe(1); + expect(installed[0].name).toBe('-tmp-exploit'); + + const destPath = installed[0].location; + const resolvedTarget = path.resolve(tempDir, '.gemini/skills'); + expect(destPath.startsWith(resolvedTarget + path.sep)).toBe(true); + }); + + it('should sanitize traversal names with spaces and install them safely within the target directory', async () => { + const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source'); + const skillSubDir = path.join(mockSkillSourceDir, 'test-skill'); + await fs.mkdir(skillSubDir, { recursive: true }); + await fs.writeFile( + path.join(skillSubDir, 'SKILL.md'), + '---\nname: " ../../exploit "\ndescription: exploit\n---\nbody', + ); + + const installed = await installSkill( + mockSkillSourceDir, + 'workspace', + undefined, + () => {}, + ); + expect(installed.length).toBe(1); + expect(installed[0].name).toBe(' ..-..-exploit '); + + const destPath = installed[0].location; + const resolvedTarget = path.resolve(tempDir, '.gemini/skills'); + expect(destPath.startsWith(resolvedTarget + path.sep)).toBe(true); + }); + + it('should allow installation if skill name starts with double dots but is safe (e.g. ..-foo or ...)', async () => { + const mockSkillSourceDir = path.join(tempDir, 'mock-skill-source'); + const skillSubDir = path.join(mockSkillSourceDir, 'test-skill'); + await fs.mkdir(skillSubDir, { recursive: true }); + await fs.writeFile( + path.join(skillSubDir, 'SKILL.md'), + '---\nname: ..-foo\ndescription: safe skill name starting with double dots\n---\nbody', + ); + + const installed = await installSkill( + mockSkillSourceDir, + 'workspace', + undefined, + () => {}, + ); + expect(installed.length).toBe(1); + expect(installed[0].name).toBe('..-foo'); + + const destPath = installed[0].location; + const resolvedTarget = path.resolve(tempDir, '.gemini/skills'); + expect(destPath.startsWith(resolvedTarget + path.sep)).toBe(true); + }); + }); +}); diff --git a/packages/cli/src/utils/skillUtils.ts b/packages/cli/src/utils/skillUtils.ts new file mode 100644 index 0000000000000000000000000000000000000000..1f0d783a5054693a538c18c988ea935584991992 --- /dev/null +++ b/packages/cli/src/utils/skillUtils.ts @@ -0,0 +1,339 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SettingScope } from '../config/settings.js'; +import type { SkillActionResult } from './skillSettings.js'; +import { + Storage, + loadSkillsFromDir, + type SkillDefinition, +} from '@google/gemini-cli-core'; +import { cloneFromGit } from '../config/extensions/github.js'; +import extract from 'extract-zip'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +/** + * Shared logic for building the core skill action message while allowing the + * caller to control how each scope and its path are rendered (e.g., bolding or + * dimming). + * + * This function ONLY returns the description of what happened. It is up to the + * caller to append any interface-specific guidance (like "Use /skills reload" + * or "Restart required"). + */ +export function renderSkillActionFeedback( + result: SkillActionResult, + formatScope: (label: string, path: string) => string, +): string { + const { skillName, action, status, error } = result; + + if (status === 'error') { + return ( + error || + `An error occurred while attempting to ${action} skill "${skillName}".` + ); + } + + if (status === 'no-op') { + return `Skill "${skillName}" is already ${action === 'enable' ? 'enabled' : 'disabled'}.`; + } + + const isEnable = action === 'enable'; + const actionVerb = isEnable ? 'enabled' : 'disabled'; + const preposition = isEnable + ? 'by removing it from the disabled list in' + : 'by adding it to the disabled list in'; + + const formatScopeItem = (s: { scope: SettingScope; path: string }) => { + const label = + s.scope === SettingScope.Workspace ? 'workspace' : s.scope.toLowerCase(); + return formatScope(label, s.path); + }; + + const totalAffectedScopes = [ + ...result.modifiedScopes, + ...result.alreadyInStateScopes, + ]; + + if (totalAffectedScopes.length === 2) { + const s1 = formatScopeItem(totalAffectedScopes[0]); + const s2 = formatScopeItem(totalAffectedScopes[1]); + + if (isEnable) { + return `Skill "${skillName}" ${actionVerb} ${preposition} ${s1} and ${s2} settings.`; + } else { + return `Skill "${skillName}" is now disabled in both ${s1} and ${s2} settings.`; + } + } + + const s = formatScopeItem(totalAffectedScopes[0]); + return `Skill "${skillName}" ${actionVerb} ${preposition} ${s} settings.`; +} + +function isPathTraversal(relative: string): boolean { + return ( + relative === '..' || + relative.startsWith('..' + path.sep) || + path.isAbsolute(relative) + ); +} + +function isInvalidSubpath(relative: string): boolean { + return relative === '' || isPathTraversal(relative); +} + +/** + * Central logic for installing a skill from a remote URL or local path. + */ +export async function installSkill( + source: string, + scope: 'user' | 'workspace', + subpath: string | undefined, + onLog: (msg: string) => void, + requestConsent: ( + skills: SkillDefinition[], + targetDir: string, + ) => Promise = () => Promise.resolve(true), +): Promise> { + let sourcePath = source; + let tempDirToClean: string | undefined = undefined; + + const isGitUrl = + source.startsWith('git@') || + source.startsWith('http://') || + source.startsWith('https://'); + + const isSkillFile = source.toLowerCase().endsWith('.skill'); + + try { + if (isGitUrl) { + tempDirToClean = await fs.mkdtemp( + path.join(os.tmpdir(), 'gemini-skill-'), + ); + sourcePath = tempDirToClean; + + onLog(`Cloning skill from ${source}...`); + // Reuse existing robust git cloning utility from extension manager. + await cloneFromGit( + { + source, + type: 'git', + }, + tempDirToClean, + ); + } else if (isSkillFile) { + tempDirToClean = await fs.mkdtemp( + path.join(os.tmpdir(), 'gemini-skill-'), + ); + sourcePath = tempDirToClean; + + onLog(`Extracting skill from ${source}...`); + await extract(path.resolve(source), { dir: tempDirToClean }); + } + + // If a subpath is provided, resolve it against the cloned/local root. + if (subpath) { + sourcePath = path.join(sourcePath, subpath); + } + + sourcePath = path.resolve(sourcePath); + + // Quick security check to prevent directory traversal out of temp dir when cloning + if (tempDirToClean) { + const resolvedTemp = path.resolve(tempDirToClean); + const relative = path.relative(resolvedTemp, sourcePath); + if (isPathTraversal(relative)) { + throw new Error('Invalid path: Directory traversal not allowed.'); + } + } + + onLog(`Searching for skills in ${sourcePath}...`); + const skills = await loadSkillsFromDir(sourcePath); + + if (skills.length === 0) { + throw new Error( + `No valid skills found in ${source}${subpath ? ` at path "${subpath}"` : ''}. Ensure a SKILL.md file exists with valid frontmatter.`, + ); + } + + const workspaceDir = process.cwd(); + const storage = new Storage(workspaceDir); + const targetDir = + scope === 'workspace' + ? storage.getProjectSkillsDir() + : Storage.getUserSkillsDir(); + + if (!(await requestConsent(skills, targetDir))) { + throw new Error('Skill installation cancelled by user.'); + } + + const resolvedTarget = path.resolve(targetDir); + await fs.mkdir(resolvedTarget, { recursive: true }); + + const installedSkills: Array<{ name: string; location: string }> = []; + + for (const skill of skills) { + const skillName = skill.name; + const skillDir = path.dirname(skill.location); + const destPath = path.resolve(resolvedTarget, skillName); + + const relative = path.relative(resolvedTarget, destPath); + if (isInvalidSubpath(relative)) { + throw new Error('Invalid skill name: Path traversal detected.'); + } + + const exists = await fs.lstat(destPath).catch(() => null); + if (exists) { + onLog(`Skill "${skillName}" already exists. Overwriting...`); + await fs.rm(destPath, { recursive: true, force: true }); + } + + await fs.cp(skillDir, destPath, { recursive: true }); + installedSkills.push({ name: skillName, location: destPath }); + } + + return installedSkills; + } finally { + if (tempDirToClean) { + await fs.rm(tempDirToClean, { recursive: true, force: true }); + } + } +} + +/** + * Central logic for linking a skill from a local path via symlink. + */ +export async function linkSkill( + source: string, + scope: 'user' | 'workspace', + onLog: (msg: string) => void, + requestConsent: ( + skills: SkillDefinition[], + targetDir: string, + ) => Promise = () => Promise.resolve(true), +): Promise> { + const sourcePath = path.resolve(source); + + onLog(`Searching for skills in ${sourcePath}...`); + const skills = await loadSkillsFromDir(sourcePath); + + if (skills.length === 0) { + throw new Error( + `No valid skills found in "${sourcePath}". Ensure a SKILL.md file exists with valid frontmatter.`, + ); + } + + // Check for internal name collisions + const seenNames = new Map(); + for (const skill of skills) { + if (seenNames.has(skill.name)) { + throw new Error( + `Duplicate skill name "${skill.name}" found at multiple locations:\n - ${seenNames.get(skill.name)}\n - ${skill.location}`, + ); + } + seenNames.set(skill.name, skill.location); + } + + const workspaceDir = process.cwd(); + const storage = new Storage(workspaceDir); + const targetDir = + scope === 'workspace' + ? storage.getProjectSkillsDir() + : Storage.getUserSkillsDir(); + + if (!(await requestConsent(skills, targetDir))) { + throw new Error('Skill linking cancelled by user.'); + } + + const resolvedTarget = path.resolve(targetDir); + await fs.mkdir(resolvedTarget, { recursive: true }); + + const linkedSkills: Array<{ name: string; location: string }> = []; + + for (const skill of skills) { + const skillName = skill.name; + const skillSourceDir = path.dirname(skill.location); + const destPath = path.resolve(resolvedTarget, skillName); + + const relative = path.relative(resolvedTarget, destPath); + if (isInvalidSubpath(relative)) { + throw new Error('Invalid skill name: Path traversal detected.'); + } + + const exists = await fs.lstat(destPath).catch(() => null); + if (exists) { + onLog( + `Skill "${skillName}" already exists at destination. Overwriting...`, + ); + await fs.rm(destPath, { recursive: true, force: true }); + } + + // Use 'junction' on Windows to avoid EPERM errors — junctions don't + // require elevated privileges or Developer Mode (fixes #24816) + await fs.symlink( + skillSourceDir, + destPath, + process.platform === 'win32' ? 'junction' : 'dir', + ); + linkedSkills.push({ name: skillName, location: destPath }); + } + + return linkedSkills; +} + +/** + * Central logic for uninstalling a skill by name. + */ +export async function uninstallSkill( + name: string, + scope: 'user' | 'workspace', +): Promise<{ location: string } | null> { + const workspaceDir = process.cwd(); + const storage = new Storage(workspaceDir); + const targetDir = + scope === 'workspace' + ? storage.getProjectSkillsDir() + : Storage.getUserSkillsDir(); + + const resolvedTarget = path.resolve(targetDir); + + // Load all skills in the target directory to find the one with the matching name + const discoveredSkills = await loadSkillsFromDir(resolvedTarget); + const skillToUninstall = discoveredSkills.find((s) => s.name === name); + + if (!skillToUninstall) { + // Fallback: Check if a directory with the given name exists. + // This maintains backward compatibility for cases where the metadata might be missing or corrupted + // but the directory name matches the user's request. + const skillPath = path.resolve(resolvedTarget, name); + + // Security check: ensure the resolved path is within the target directory to prevent path traversal + const relative = path.relative(resolvedTarget, skillPath); + if (isInvalidSubpath(relative)) { + return null; + } + + const exists = await fs.lstat(skillPath).catch(() => null); + + if (!exists) { + return null; + } + + await fs.rm(skillPath, { recursive: true, force: true }); + return { location: skillPath }; + } + + const skillDir = path.resolve(path.dirname(skillToUninstall.location)); + const relative = path.relative(resolvedTarget, skillDir); + if (isInvalidSubpath(relative)) { + return null; + } + + await fs.rm(skillDir, { recursive: true, force: true }); + return { location: skillDir }; +} diff --git a/packages/cli/src/utils/spawnWrapper.ts b/packages/cli/src/utils/spawnWrapper.ts new file mode 100644 index 0000000000000000000000000000000000000000..108f166dc0182b86686c25cc1f92711a81579984 --- /dev/null +++ b/packages/cli/src/utils/spawnWrapper.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { spawn } from 'node:child_process'; + +export const spawnWrapper = spawn; diff --git a/packages/cli/src/utils/startupWarnings.test.ts b/packages/cli/src/utils/startupWarnings.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a453be5f232424fbcbd8248e4939511a8fafe9ab --- /dev/null +++ b/packages/cli/src/utils/startupWarnings.test.ts @@ -0,0 +1,76 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { getStartupWarnings } from './startupWarnings.js'; +import * as fs from 'node:fs/promises'; +import { getErrorMessage } from '@google/gemini-cli-core'; + +vi.mock('node:fs/promises', { spy: true }); +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getErrorMessage: vi.fn(), + }; +}); + +describe('startupWarnings', () => { + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should return warnings from the file and delete it', async () => { + const mockWarnings = 'Warning 1\nWarning 2'; + vi.mocked(fs.access).mockResolvedValue(); + vi.mocked(fs.readFile).mockResolvedValue(mockWarnings); + vi.mocked(fs.unlink).mockResolvedValue(); + + const warnings = await getStartupWarnings(); + + expect(fs.access).toHaveBeenCalled(); + expect(fs.readFile).toHaveBeenCalled(); + expect(fs.unlink).toHaveBeenCalled(); + expect(warnings).toEqual(['Warning 1', 'Warning 2']); + }); + + it('should return an empty array if the file does not exist', async () => { + const error = new Error('File not found'); + (error as Error & { code: string }).code = 'ENOENT'; + vi.mocked(fs.access).mockRejectedValue(error); + + const warnings = await getStartupWarnings(); + + expect(warnings).toEqual([]); + }); + + it('should return an error message if reading the file fails', async () => { + const error = new Error('Permission denied'); + vi.mocked(fs.access).mockRejectedValue(error); + vi.mocked(getErrorMessage).mockReturnValue('Permission denied'); + + const warnings = await getStartupWarnings(); + + expect(warnings).toEqual([ + 'Error checking/reading warnings file: Permission denied', + ]); + }); + + it('should return a warning if deleting the file fails', async () => { + const mockWarnings = 'Warning 1'; + vi.mocked(fs.access).mockResolvedValue(); + vi.mocked(fs.readFile).mockResolvedValue(mockWarnings); + vi.mocked(fs.unlink).mockRejectedValue(new Error('Permission denied')); + + const warnings = await getStartupWarnings(); + + expect(warnings).toEqual([ + 'Warning 1', + 'Warning: Could not delete temporary warnings file.', + ]); + }); +}); diff --git a/packages/cli/src/utils/startupWarnings.ts b/packages/cli/src/utils/startupWarnings.ts new file mode 100644 index 0000000000000000000000000000000000000000..7025f7d33325172a87d33e909ed864561333bdfa --- /dev/null +++ b/packages/cli/src/utils/startupWarnings.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import os from 'node:os'; +import { join as pathJoin } from 'node:path'; +import { getErrorMessage } from '@google/gemini-cli-core'; + +const warningsFilePath = pathJoin(os.tmpdir(), 'gemini-cli-warnings.txt'); + +export async function getStartupWarnings(): Promise { + try { + await fs.access(warningsFilePath); // Check if file exists + const warningsContent = await fs.readFile(warningsFilePath, 'utf-8'); + const warnings = warningsContent + .split('\n') + .filter((line) => line.trim() !== ''); + try { + await fs.unlink(warningsFilePath); + } catch { + warnings.push('Warning: Could not delete temporary warnings file.'); + } + return warnings; + } catch (err: unknown) { + // If fs.access throws, it means the file doesn't exist or is not accessible. + // This is not an error in the context of fetching warnings, so return empty. + // Only return an error message if it's not a "file not found" type error. + // However, the original logic returned an error message for any fs.existsSync failure. + // To maintain closer parity while making it async, we'll check the error code. + // ENOENT is "Error NO ENTry" (file not found). + if (err instanceof Error && 'code' in err && err.code === 'ENOENT') { + return []; // File not found, no warnings to return. + } + // For other errors (permissions, etc.), return the error message. + return [`Error checking/reading warnings file: ${getErrorMessage(err)}`]; + } +} diff --git a/packages/cli/src/utils/terminalTheme.test.ts b/packages/cli/src/utils/terminalTheme.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..8846ccf95477137d6fc4b945fb6a0168f5352d10 --- /dev/null +++ b/packages/cli/src/utils/terminalTheme.test.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { setupTerminalAndTheme } from './terminalTheme.js'; +import { terminalCapabilityManager } from '../ui/utils/terminalCapabilityManager.js'; +import { themeManager } from '../ui/themes/theme-manager.js'; +import { coreEvents, type Config } from '@google/gemini-cli-core'; +import type { LoadedSettings } from '../config/settings.js'; +import type { Theme } from '../ui/themes/theme.js'; + +vi.mock('../ui/utils/terminalCapabilityManager.js', () => ({ + terminalCapabilityManager: { + detectCapabilities: vi.fn(), + getTerminalBackgroundColor: vi.fn(), + }, +})); + +vi.mock('../ui/themes/theme-manager.js', () => ({ + themeManager: { + loadCustomThemes: vi.fn(), + setActiveTheme: vi.fn(), + getActiveTheme: vi.fn(), + setTerminalBackground: vi.fn(), + isThemeCompatible: vi.fn(), + getAllThemes: vi.fn().mockReturnValue([]), + }, + DEFAULT_THEME: { name: 'Default Dark' }, +})); + +vi.mock('@google/gemini-cli-core', () => ({ + coreEvents: { + emitFeedback: vi.fn(), + }, + debugLogger: { + warn: vi.fn(), + }, +})); + +describe('setupTerminalAndTheme', () => { + let mockConfig: Config; + let mockSettings: LoadedSettings; + const originalIsTTY = process.stdin.isTTY; + + beforeEach(() => { + vi.resetAllMocks(); + mockConfig = { + isInteractive: vi.fn().mockReturnValue(true), + setTerminalBackground: vi.fn(), + } as Partial as Config; + mockSettings = { + merged: { + ui: { + customThemes: {}, + theme: 'Dracula', + autoThemeSwitching: true, + }, + }, + } as Partial as LoadedSettings; + + // Mock process.stdin.isTTY + Object.defineProperty(process.stdin, 'isTTY', { + value: true, + configurable: true, + }); + }); + + afterEach(() => { + Object.defineProperty(process.stdin, 'isTTY', { + value: originalIsTTY, + configurable: true, + }); + }); + + it('should emit warning when theme is incompatible and autoThemeSwitching is enabled', async () => { + vi.mocked( + terminalCapabilityManager.getTerminalBackgroundColor, + ).mockReturnValue('#ffffff'); // Light + vi.mocked(themeManager.setActiveTheme).mockReturnValue(true); + vi.mocked(themeManager.getActiveTheme).mockReturnValue({ + name: 'Dracula', + type: 'dark', + } as Theme); + vi.mocked(themeManager.isThemeCompatible).mockReturnValue(false); + + await setupTerminalAndTheme(mockConfig, mockSettings); + + expect(coreEvents.emitFeedback).toHaveBeenCalledWith( + 'warning', + expect.stringContaining( + "Theme 'Dracula' (dark) might look incorrect on your light terminal background", + ), + ); + }); + + it('should NOT emit warning when theme is incompatible but autoThemeSwitching is DISABLED', async () => { + mockSettings.merged.ui.autoThemeSwitching = false; + vi.mocked( + terminalCapabilityManager.getTerminalBackgroundColor, + ).mockReturnValue('#ffffff'); // Light + vi.mocked(themeManager.setActiveTheme).mockReturnValue(true); + vi.mocked(themeManager.getActiveTheme).mockReturnValue({ + name: 'Dracula', + type: 'dark', + } as Theme); + vi.mocked(themeManager.isThemeCompatible).mockReturnValue(false); + + await setupTerminalAndTheme(mockConfig, mockSettings); + + expect(coreEvents.emitFeedback).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/cli/src/utils/terminalTheme.ts b/packages/cli/src/utils/terminalTheme.ts new file mode 100644 index 0000000000000000000000000000000000000000..6619590961d4af89a3c44e17b6e8681a5052a0a3 --- /dev/null +++ b/packages/cli/src/utils/terminalTheme.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type TerminalBackgroundColor, + terminalCapabilityManager, +} from '../ui/utils/terminalCapabilityManager.js'; +import { themeManager, DEFAULT_THEME } from '../ui/themes/theme-manager.js'; +import { pickDefaultThemeName } from '../ui/themes/theme.js'; +import { getThemeTypeFromBackgroundColor } from '../ui/themes/color-utils.js'; +import type { LoadedSettings } from '../config/settings.js'; +import { type Config, coreEvents, debugLogger } from '@google/gemini-cli-core'; + +/** + * Detects terminal capabilities, loads themes, and sets the active theme. + * @param config The application config. + * @param settings The loaded settings. + * @returns The detected terminal background color. + */ +export async function setupTerminalAndTheme( + config: Config, + settings: LoadedSettings, +): Promise { + let terminalBackground: TerminalBackgroundColor = undefined; + if (config.isInteractive() && process.stdin.isTTY) { + // Detect terminal capabilities (Kitty protocol, background color) in parallel. + await terminalCapabilityManager.detectCapabilities(); + terminalBackground = terminalCapabilityManager.getTerminalBackgroundColor(); + } + + // Load custom themes from settings + themeManager.loadCustomThemes(settings.merged.ui.customThemes); + + if (settings.merged.ui.theme) { + if (!themeManager.setActiveTheme(settings.merged.ui.theme)) { + // If the theme is not found during initial load, log a warning and continue. + // The useThemeCommand hook in AppContainer.tsx will handle opening the dialog. + debugLogger.warn( + `Warning: Theme "${settings.merged.ui.theme}" not found.`, + ); + } + } else { + // If no theme is set, check terminal background color + const themeName = pickDefaultThemeName( + terminalBackground, + themeManager.getAllThemes(), + DEFAULT_THEME.name, + 'Default Light', + ); + themeManager.setActiveTheme(themeName); + } + + config.setTerminalBackground(terminalBackground); + themeManager.setTerminalBackground(terminalBackground); + + if ( + terminalBackground !== undefined && + (settings.merged.ui.autoThemeSwitching ?? true) + ) { + const currentTheme = themeManager.getActiveTheme(); + if (!themeManager.isThemeCompatible(currentTheme, terminalBackground)) { + const backgroundType = + getThemeTypeFromBackgroundColor(terminalBackground); + coreEvents.emitFeedback( + 'warning', + `Theme '${currentTheme.name}' (${currentTheme.type}) might look incorrect on your ${backgroundType} terminal background. Type /theme to change theme.`, + ); + } + } + + return terminalBackground; +} diff --git a/packages/cli/src/utils/tierUtils.test.ts b/packages/cli/src/utils/tierUtils.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..05cdaa22bde6dbb9d19093e4f88882c90590d7b5 --- /dev/null +++ b/packages/cli/src/utils/tierUtils.test.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { isUltraTier } from './tierUtils.js'; + +describe('tierUtils', () => { + describe('isUltraTier', () => { + it('should return true if tier name contains "ultra" (case-insensitive)', () => { + expect(isUltraTier('Advanced Ultra')).toBe(true); + expect(isUltraTier('gemini ultra')).toBe(true); + expect(isUltraTier('ULTRA')).toBe(true); + }); + + it('should return false if tier name does not contain "ultra"', () => { + expect(isUltraTier('Free')).toBe(false); + expect(isUltraTier('Pro')).toBe(false); + expect(isUltraTier('Standard')).toBe(false); + }); + + it('should return false if tier name is undefined', () => { + expect(isUltraTier(undefined)).toBe(false); + }); + }); +}); diff --git a/packages/cli/src/utils/updateEventEmitter.ts b/packages/cli/src/utils/updateEventEmitter.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c69277254852b8201361570d9b8a2d34a9ee05a --- /dev/null +++ b/packages/cli/src/utils/updateEventEmitter.ts @@ -0,0 +1,13 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'node:events'; + +/** + * A shared event emitter for application-wide communication + * between decoupled parts of the CLI. + */ +export const updateEventEmitter = new EventEmitter(); diff --git a/packages/cli/src/utils/userStartupWarnings.ts b/packages/cli/src/utils/userStartupWarnings.ts new file mode 100644 index 0000000000000000000000000000000000000000..28858d56297132284a606d040e0ae6c3ea7b84e8 --- /dev/null +++ b/packages/cli/src/utils/userStartupWarnings.ts @@ -0,0 +1,145 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import { homedir as osHomedir } from 'node:os'; +import path from 'node:path'; +import process from 'node:process'; +import { + getCompatibilityWarnings, + WarningPriority, + type StartupWarning, + isHeadlessMode, + FatalUntrustedWorkspaceError, +} from '@google/gemini-cli-core'; +import type { Settings } from '../config/settingsSchema.js'; +import { + isFolderTrustEnabled, + isWorkspaceTrusted, +} from '../config/trustedFolders.js'; + +type WarningCheck = { + id: string; + check: (workspaceRoot: string, settings: Settings) => Promise; + priority: WarningPriority; +}; + +// Individual warning checks +const homeDirectoryCheck: WarningCheck = { + id: 'home-directory', + priority: WarningPriority.Low, + check: async (workspaceRoot: string, settings: Settings) => { + if (settings.ui?.showHomeDirectoryWarning === false) { + return null; + } + + try { + const [workspaceRealPath, homeRealPath] = await Promise.all([ + fs.realpath(workspaceRoot), + fs.realpath(osHomedir()), + ]); + + if (path.resolve(workspaceRealPath) === path.resolve(homeRealPath)) { + // If folder trust is enabled and the user trusts the home directory, don't show the warning. + if ( + isFolderTrustEnabled(settings) && + isWorkspaceTrusted(settings).isTrusted + ) { + return null; + } + + return 'Warning you are running Gemini CLI in your home directory.\nThis warning can be disabled in /settings'; + } + return null; + } catch { + return 'Could not verify the current directory due to a file system error.'; + } + }, +}; + +const rootDirectoryCheck: WarningCheck = { + id: 'root-directory', + priority: WarningPriority.High, + check: async (workspaceRoot: string, _settings: Settings) => { + try { + const workspaceRealPath = await fs.realpath(workspaceRoot); + const errorMessage = + 'Warning: You are running Gemini CLI in the root directory. Your entire folder structure will be used for context. It is strongly recommended to run in a project-specific directory.'; + + // Check for Unix root directory + if (path.dirname(workspaceRealPath) === workspaceRealPath) { + return errorMessage; + } + + return null; + } catch { + return 'Could not verify the current directory due to a file system error.'; + } + }, +}; + +const folderTrustCheck: WarningCheck = { + id: 'folder-trust', + priority: WarningPriority.High, + check: async (workspaceRoot: string, settings: Settings) => { + if (!isFolderTrustEnabled(settings)) { + return null; + } + + const { isTrusted } = isWorkspaceTrusted(settings, workspaceRoot); + if (isTrusted === true) { + return null; + } + + if (isHeadlessMode()) { + throw new FatalUntrustedWorkspaceError( + 'Gemini CLI is not running in a trusted directory. To proceed, either use `--skip-trust`, ' + + 'set the `GEMINI_CLI_TRUST_WORKSPACE=true` environment variable, or trust this directory in interactive mode. ' + + 'For more details, see https://geminicli.com/docs/cli/trusted-folders/#headless-and-automated-environments', + ); + } + + return null; + }, +}; + +// All warning checks +const WARNING_CHECKS: readonly WarningCheck[] = [ + homeDirectoryCheck, + rootDirectoryCheck, + folderTrustCheck, +]; + +export async function getUserStartupWarnings( + settings: Settings, + workspaceRoot: string = process.cwd(), + options?: { isAlternateBuffer?: boolean }, +): Promise { + const results = await Promise.all( + WARNING_CHECKS.map(async (check) => { + const message = await check.check(workspaceRoot, settings); + if (message) { + return { + id: check.id, + message, + priority: check.priority, + }; + } + return null; + }), + ); + const warnings = results.filter((w): w is StartupWarning => w !== null); + + if (settings.ui?.showCompatibilityWarnings !== false) { + warnings.push( + ...getCompatibilityWarnings({ + isAlternateBuffer: options?.isAlternateBuffer, + }), + ); + } + + return warnings; +} diff --git a/packages/cli/src/utils/windowTitle.ts b/packages/cli/src/utils/windowTitle.ts new file mode 100644 index 0000000000000000000000000000000000000000..1c9ab6dbfb0de84f7b9b613fec47a171ff8c2532 --- /dev/null +++ b/packages/cli/src/utils/windowTitle.ts @@ -0,0 +1,115 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { StreamingState } from '../ui/types.js'; + +export interface TerminalTitleOptions { + streamingState: StreamingState; + thoughtSubject?: string; + isConfirming: boolean; + isSilentWorking: boolean; + folderName: string; + showThoughts: boolean; + useDynamicTitle: boolean; +} + +function truncate(text: string, maxLen: number): string { + if (text.length <= maxLen) { + return text; + } + return text.substring(0, maxLen - 1) + '…'; +} + +/** + * Computes the dynamic terminal window title based on the current CLI state. + * + * @param options - The current state of the CLI and environment context + * @returns A formatted string padded to 80 characters for the terminal title + */ +export function computeTerminalTitle({ + streamingState, + thoughtSubject, + isConfirming, + isSilentWorking, + folderName, + showThoughts, + useDynamicTitle, +}: TerminalTitleOptions): string { + const MAX_LEN = 80; + + // Use CLI_TITLE env var if available, otherwise use the provided folder name + let displayContext = process.env['CLI_TITLE'] || folderName; + + if (!useDynamicTitle) { + const base = 'Gemini CLI '; + // Max context length is 80 - base.length - 2 (for brackets) + const maxContextLen = MAX_LEN - base.length - 2; + displayContext = truncate(displayContext, maxContextLen); + return `${base}(${displayContext})`.padEnd(MAX_LEN, ' '); + } + + // Pre-calculate suffix but keep it flexible + const getSuffix = (context: string) => ` (${context})`; + + let title; + if ( + isConfirming || + streamingState === StreamingState.WaitingForConfirmation + ) { + const base = '✋ Action Required'; + // Max context length is 80 - base.length - 3 (for ' (' and ')') + const maxContextLen = MAX_LEN - base.length - 3; + const context = truncate(displayContext, maxContextLen); + title = `${base}${getSuffix(context)}`; + } else if (isSilentWorking) { + const base = '⏲ Working…'; + // Max context length is 80 - base.length - 3 (for ' (' and ')') + const maxContextLen = MAX_LEN - base.length - 3; + const context = truncate(displayContext, maxContextLen); + title = `${base}${getSuffix(context)}`; + } else if (streamingState === StreamingState.Idle) { + const base = '◇ Ready'; + // Max context length is 80 - base.length - 3 (for ' (' and ')') + const maxContextLen = MAX_LEN - base.length - 3; + const context = truncate(displayContext, maxContextLen); + title = `${base}${getSuffix(context)}`; + } else { + // Active/Working state + const cleanSubject = + showThoughts && thoughtSubject?.replace(/[\r\n]+/g, ' ').trim(); + + // If we have a thought subject and it's too long to fit with the suffix, + // we drop the suffix to maximize space for the thought. + // Otherwise, we keep the suffix. + const suffix = getSuffix(displayContext); + const suffixLen = suffix.length; + const canFitThoughtWithSuffix = cleanSubject + ? cleanSubject.length + suffixLen + 3 <= MAX_LEN + : true; + + let activeSuffix = ''; + let maxStatusLen = MAX_LEN - 3; // Subtract icon prefix "✦ " (3 chars) + + if (!cleanSubject || canFitThoughtWithSuffix) { + activeSuffix = suffix; + maxStatusLen -= activeSuffix.length; + } + + const displayStatus = cleanSubject + ? truncate(cleanSubject, maxStatusLen) + : 'Working…'; + + title = `✦ ${displayStatus}${activeSuffix}`; + } + + // Remove control characters that could cause issues in terminal titles + // eslint-disable-next-line no-control-regex + const safeTitle = title.replace(/[\x00-\x1F\x7F]/g, ''); + + // Pad the title to a fixed width to prevent taskbar icon resizing/jitter. + // We also slice it to ensure it NEVER exceeds MAX_LEN. + return safeTitle.padEnd(MAX_LEN, ' ').substring(0, MAX_LEN); +} diff --git a/packages/cli/src/utils/worktreeSetup.test.ts b/packages/cli/src/utils/worktreeSetup.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..e1bd201a8b9164e8d7dd6ff34c97d69f225d8313 --- /dev/null +++ b/packages/cli/src/utils/worktreeSetup.test.ts @@ -0,0 +1,124 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { setupWorktree } from './worktreeSetup.js'; +import * as coreFunctions from '@google/gemini-cli-core'; + +// Mock dependencies +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + getProjectRootForWorktree: vi.fn(), + createWorktreeService: vi.fn(), + debugLogger: { + log: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + }, + writeToStdout: vi.fn(), + writeToStderr: vi.fn(), + }; +}); + +describe('setupWorktree', () => { + const originalEnv = { ...process.env }; + const originalCwd = process.cwd; + + const mockService = { + setup: vi.fn(), + maybeCleanup: vi.fn(), + }; + + beforeEach(() => { + vi.clearAllMocks(); + process.env = { ...originalEnv }; + + // Mock process.cwd and process.chdir + let currentPath = '/mock/project'; + process.cwd = vi.fn().mockImplementation(() => currentPath); + process.chdir = vi.fn().mockImplementation((newPath) => { + currentPath = newPath; + }); + + // Mock successful execution of core utilities + vi.mocked(coreFunctions.getProjectRootForWorktree).mockResolvedValue( + '/mock/project', + ); + vi.mocked(coreFunctions.createWorktreeService).mockResolvedValue( + mockService as never, + ); + mockService.setup.mockResolvedValue({ + name: 'my-feature', + path: '/mock/project/.gemini/worktrees/my-feature', + baseSha: 'base-sha', + }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + process.cwd = originalCwd; + delete (process as { chdir?: typeof process.chdir }).chdir; + }); + + it('should create and switch to a new worktree', async () => { + await setupWorktree('my-feature'); + + expect(coreFunctions.getProjectRootForWorktree).toHaveBeenCalledWith( + '/mock/project', + ); + expect(coreFunctions.createWorktreeService).toHaveBeenCalledWith( + '/mock/project', + ); + expect(mockService.setup).toHaveBeenCalledWith('my-feature'); + expect(process.chdir).toHaveBeenCalledWith( + '/mock/project/.gemini/worktrees/my-feature', + ); + expect(process.env['GEMINI_CLI_WORKTREE_HANDLED']).toBe('1'); + }); + + it('should generate a name if worktreeName is undefined', async () => { + mockService.setup.mockResolvedValue({ + name: 'generated-name', + path: '/mock/project/.gemini/worktrees/generated-name', + baseSha: 'base-sha', + }); + + await setupWorktree(undefined); + + expect(mockService.setup).toHaveBeenCalledWith(undefined); + }); + + it('should skip worktree creation if GEMINI_CLI_WORKTREE_HANDLED is set', async () => { + process.env['GEMINI_CLI_WORKTREE_HANDLED'] = '1'; + + await setupWorktree('my-feature'); + + expect(coreFunctions.createWorktreeService).not.toHaveBeenCalled(); + expect(process.chdir).not.toHaveBeenCalled(); + }); + + it('should handle errors gracefully and exit', async () => { + const mockExit = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('PROCESS_EXIT'); + }); + + mockService.setup.mockRejectedValue(new Error('Git failure')); + + await expect(setupWorktree('my-feature')).rejects.toThrow('PROCESS_EXIT'); + + expect(coreFunctions.writeToStderr).toHaveBeenCalledWith( + expect.stringContaining( + 'Failed to create or switch to worktree: Git failure', + ), + ); + expect(mockExit).toHaveBeenCalledWith(1); + + mockExit.mockRestore(); + }); +}); diff --git a/packages/cli/src/validateNonInterActiveAuth.test.ts b/packages/cli/src/validateNonInterActiveAuth.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..f50a1f10f4a2d2c889734d393b0ccf19fc6d5372 --- /dev/null +++ b/packages/cli/src/validateNonInterActiveAuth.test.ts @@ -0,0 +1,470 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + describe, + it, + expect, + vi, + beforeEach, + afterEach, + type MockInstance, +} from 'vitest'; +import { validateNonInteractiveAuth } from './validateNonInterActiveAuth.js'; +import { + AuthType, + OutputFormat, + makeFakeConfig, + debugLogger, + ExitCodes, + coreEvents, +} from '@google/gemini-cli-core'; +import type { Config } from '@google/gemini-cli-core'; +import * as auth from './config/auth.js'; +import { type LoadedSettings } from './config/settings.js'; + +function createLocalMockConfig(overrides: Partial = {}): Config { + const config = makeFakeConfig(); + Object.assign(config, overrides); + return config; +} + +describe('validateNonInterActiveAuth', () => { + let originalEnvGeminiApiKey: string | undefined; + let originalEnvVertexAi: string | undefined; + let originalEnvGcp: string | undefined; + let debugLoggerErrorSpy: ReturnType; + let coreEventsEmitFeedbackSpy: MockInstance; + let processExitSpy: MockInstance; + let mockSettings: LoadedSettings; + + beforeEach(() => { + originalEnvGeminiApiKey = process.env['GEMINI_API_KEY']; + originalEnvVertexAi = process.env['GOOGLE_GENAI_USE_VERTEXAI']; + originalEnvGcp = process.env['GOOGLE_GENAI_USE_GCA']; + delete process.env['GEMINI_API_KEY']; + delete process.env['GOOGLE_GENAI_USE_VERTEXAI']; + delete process.env['GOOGLE_GENAI_USE_GCA']; + debugLoggerErrorSpy = vi + .spyOn(debugLogger, 'error') + .mockImplementation(() => {}); + coreEventsEmitFeedbackSpy = vi + .spyOn(coreEvents, 'emitFeedback') + .mockImplementation(() => {}); + processExitSpy = vi + .spyOn(process, 'exit') + .mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`process.exit(${code}) called`); + }); + vi.spyOn(auth, 'validateAuthMethod').mockResolvedValue(null); + mockSettings = { + system: { path: '', settings: {} }, + systemDefaults: { path: '', settings: {} }, + user: { path: '', settings: {} }, + workspace: { path: '', settings: {} }, + errors: [], + setValue: vi.fn(), + merged: { + security: { + auth: { + enforcedType: undefined, + }, + }, + }, + isTrusted: true, + migratedInMemoryScopes: new Set(), + forScope: vi.fn(), + computeMergedSettings: vi.fn(), + } as unknown as LoadedSettings; + }); + + afterEach(() => { + if (originalEnvGeminiApiKey !== undefined) { + process.env['GEMINI_API_KEY'] = originalEnvGeminiApiKey; + } else { + delete process.env['GEMINI_API_KEY']; + } + if (originalEnvVertexAi !== undefined) { + process.env['GOOGLE_GENAI_USE_VERTEXAI'] = originalEnvVertexAi; + } else { + delete process.env['GOOGLE_GENAI_USE_VERTEXAI']; + } + if (originalEnvGcp !== undefined) { + process.env['GOOGLE_GENAI_USE_GCA'] = originalEnvGcp; + } else { + delete process.env['GOOGLE_GENAI_USE_GCA']; + } + vi.restoreAllMocks(); + }); + + it('exits if no auth type is configured or env vars set', async () => { + const nonInteractiveConfig = createLocalMockConfig({ + getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ authType: undefined }), + }); + try { + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect.fail('Should have exited'); + } catch (e) { + expect((e as Error).message).toContain( + `process.exit(${ExitCodes.FATAL_AUTHENTICATION_ERROR}) called`, + ); + } + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Please set an Auth method'), + ); + expect(processExitSpy).toHaveBeenCalledWith( + ExitCodes.FATAL_AUTHENTICATION_ERROR, + ); + }); + + it('uses LOGIN_WITH_GOOGLE if GOOGLE_GENAI_USE_GCA is set', async () => { + process.env['GOOGLE_GENAI_USE_GCA'] = 'true'; + const nonInteractiveConfig = createLocalMockConfig({}); + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect(processExitSpy).not.toHaveBeenCalled(); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + }); + + it('uses USE_GEMINI if GEMINI_API_KEY is set', async () => { + process.env['GEMINI_API_KEY'] = 'fake-key'; + const nonInteractiveConfig = createLocalMockConfig({}); + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect(processExitSpy).not.toHaveBeenCalled(); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + }); + + it('uses USE_VERTEX_AI if GOOGLE_GENAI_USE_VERTEXAI is true (with GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION)', async () => { + process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true'; + process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project'; + process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1'; + const nonInteractiveConfig = createLocalMockConfig({}); + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect(processExitSpy).not.toHaveBeenCalled(); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + }); + + it('uses USE_VERTEX_AI if GOOGLE_GENAI_USE_VERTEXAI is true and GOOGLE_API_KEY is set', async () => { + process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true'; + process.env['GOOGLE_API_KEY'] = 'vertex-api-key'; + const nonInteractiveConfig = createLocalMockConfig({}); + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect(processExitSpy).not.toHaveBeenCalled(); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + }); + + it('uses LOGIN_WITH_GOOGLE if GOOGLE_GENAI_USE_GCA is set, even with other env vars', async () => { + process.env['GOOGLE_GENAI_USE_GCA'] = 'true'; + process.env['GEMINI_API_KEY'] = 'fake-key'; + process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true'; + process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project'; + process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1'; + const nonInteractiveConfig = createLocalMockConfig({}); + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect(processExitSpy).not.toHaveBeenCalled(); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + }); + + it('uses USE_VERTEX_AI if both GEMINI_API_KEY and GOOGLE_GENAI_USE_VERTEXAI are set', async () => { + process.env['GEMINI_API_KEY'] = 'fake-key'; + process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'true'; + process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project'; + process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1'; + const nonInteractiveConfig = createLocalMockConfig({}); + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect(processExitSpy).not.toHaveBeenCalled(); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + }); + + it('uses USE_GEMINI if GOOGLE_GENAI_USE_VERTEXAI is false, GEMINI_API_KEY is set, and project/location are available', async () => { + process.env['GOOGLE_GENAI_USE_VERTEXAI'] = 'false'; + process.env['GEMINI_API_KEY'] = 'fake-key'; + process.env['GOOGLE_CLOUD_PROJECT'] = 'test-project'; + process.env['GOOGLE_CLOUD_LOCATION'] = 'us-central1'; + const nonInteractiveConfig = createLocalMockConfig({}); + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect(processExitSpy).not.toHaveBeenCalled(); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + }); + + it('uses configuredAuthType over environment variables', async () => { + process.env['GEMINI_API_KEY'] = 'fake-key'; + const nonInteractiveConfig = createLocalMockConfig({}); + await validateNonInteractiveAuth( + AuthType.LOGIN_WITH_GOOGLE, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect(processExitSpy).not.toHaveBeenCalled(); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + }); + + it('exits if validateAuthMethod returns error', async () => { + // Mock validateAuthMethod to return error + vi.spyOn(auth, 'validateAuthMethod').mockResolvedValue('Auth error!'); + const nonInteractiveConfig = createLocalMockConfig({ + getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ authType: undefined }), + }); + try { + await validateNonInteractiveAuth( + AuthType.USE_GEMINI, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect.fail('Should have exited'); + } catch (e) { + expect((e as Error).message).toContain( + `process.exit(${ExitCodes.FATAL_AUTHENTICATION_ERROR}) called`, + ); + } + expect(debugLoggerErrorSpy).toHaveBeenCalledWith('Auth error!'); + expect(processExitSpy).toHaveBeenCalledWith( + ExitCodes.FATAL_AUTHENTICATION_ERROR, + ); + }); + + it('skips validation if useExternalAuth is true', async () => { + // Mock validateAuthMethod to return error to ensure it's not being called + const validateAuthMethodSpy = vi + .spyOn(auth, 'validateAuthMethod') + .mockResolvedValue('Auth error!'); + const nonInteractiveConfig = createLocalMockConfig({}); + // Even with an invalid auth type, it should not exit + // because validation is skipped. + await validateNonInteractiveAuth( + 'invalid-auth-type' as AuthType, + true, // useExternalAuth = true + nonInteractiveConfig, + mockSettings, + ); + + expect(validateAuthMethodSpy).not.toHaveBeenCalled(); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + expect(coreEventsEmitFeedbackSpy).not.toHaveBeenCalled(); + expect(processExitSpy).not.toHaveBeenCalled(); + }); + + it('succeeds if effectiveAuthType matches enforcedType', async () => { + mockSettings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; + process.env['GEMINI_API_KEY'] = 'fake-key'; + const nonInteractiveConfig = createLocalMockConfig({}); + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect(processExitSpy).not.toHaveBeenCalled(); + expect(debugLoggerErrorSpy).not.toHaveBeenCalled(); + }); + + it('exits if configuredAuthType does not match enforcedType', async () => { + mockSettings.merged.security.auth.enforcedType = AuthType.LOGIN_WITH_GOOGLE; + const nonInteractiveConfig = createLocalMockConfig({ + getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), + }); + try { + await validateNonInteractiveAuth( + AuthType.USE_GEMINI, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect.fail('Should have exited'); + } catch (e) { + expect((e as Error).message).toContain( + `process.exit(${ExitCodes.FATAL_AUTHENTICATION_ERROR}) called`, + ); + } + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + "The enforced authentication type is 'oauth-personal', but the current type is 'gemini-api-key'. Please re-authenticate with the correct type.", + ); + expect(processExitSpy).toHaveBeenCalledWith( + ExitCodes.FATAL_AUTHENTICATION_ERROR, + ); + }); + + it('exits if auth from env var does not match enforcedType', async () => { + mockSettings.merged.security.auth.enforcedType = AuthType.LOGIN_WITH_GOOGLE; + process.env['GEMINI_API_KEY'] = 'fake-key'; + const nonInteractiveConfig = createLocalMockConfig({ + getOutputFormat: vi.fn().mockReturnValue(OutputFormat.TEXT), + }); + try { + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + mockSettings, + ); + expect.fail('Should have exited'); + } catch (e) { + expect((e as Error).message).toContain( + `process.exit(${ExitCodes.FATAL_AUTHENTICATION_ERROR}) called`, + ); + } + expect(debugLoggerErrorSpy).toHaveBeenCalledWith( + "The enforced authentication type is 'oauth-personal', but the current type is 'gemini-api-key'. Please re-authenticate with the correct type.", + ); + expect(processExitSpy).toHaveBeenCalledWith( + ExitCodes.FATAL_AUTHENTICATION_ERROR, + ); + }); + + describe('JSON output mode', () => { + it(`prints JSON error when no auth is configured and exits with code ${ExitCodes.FATAL_AUTHENTICATION_ERROR}`, async () => { + const nonInteractiveConfig = createLocalMockConfig({ + getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON), + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ authType: undefined }), + }); + + let thrown: Error | undefined; + try { + await validateNonInteractiveAuth( + undefined, + undefined, + nonInteractiveConfig, + mockSettings, + ); + } catch (e) { + thrown = e as Error; + } + + expect(thrown?.message).toBe( + `process.exit(${ExitCodes.FATAL_AUTHENTICATION_ERROR}) called`, + ); + // Checking coreEventsEmitFeedbackSpy arguments + const errorArg = coreEventsEmitFeedbackSpy.mock.calls[0]?.[1] as string; + const payload = JSON.parse(errorArg); + expect(payload.error.type).toBe('Error'); + expect(payload.error.code).toBe(ExitCodes.FATAL_AUTHENTICATION_ERROR); + expect(payload.error.message).toContain( + 'Please set an Auth method in your', + ); + }); + + it(`prints JSON error when enforced auth mismatches current auth and exits with code ${ExitCodes.FATAL_AUTHENTICATION_ERROR}`, async () => { + mockSettings.merged.security.auth.enforcedType = AuthType.USE_GEMINI; + const nonInteractiveConfig = createLocalMockConfig({ + getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON), + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ authType: undefined }), + }); + + let thrown: Error | undefined; + try { + await validateNonInteractiveAuth( + AuthType.LOGIN_WITH_GOOGLE, + undefined, + nonInteractiveConfig, + mockSettings, + ); + } catch (e) { + thrown = e as Error; + } + + expect(thrown?.message).toBe( + `process.exit(${ExitCodes.FATAL_AUTHENTICATION_ERROR}) called`, + ); + { + // Checking coreEventsEmitFeedbackSpy arguments + const errorArg = coreEventsEmitFeedbackSpy.mock.calls[0]?.[1] as string; + const payload = JSON.parse(errorArg); + expect(payload.error.type).toBe('Error'); + expect(payload.error.code).toBe(ExitCodes.FATAL_AUTHENTICATION_ERROR); + expect(payload.error.message).toContain( + "The enforced authentication type is 'gemini-api-key', but the current type is 'oauth-personal'. Please re-authenticate with the correct type.", + ); + } + }); + + it(`prints JSON error when validateAuthMethod fails and exits with code ${ExitCodes.FATAL_AUTHENTICATION_ERROR}`, async () => { + vi.spyOn(auth, 'validateAuthMethod').mockResolvedValue('Auth error!'); + process.env['GEMINI_API_KEY'] = 'fake-key'; + + const nonInteractiveConfig = createLocalMockConfig({ + getOutputFormat: vi.fn().mockReturnValue(OutputFormat.JSON), + getContentGeneratorConfig: vi + .fn() + .mockReturnValue({ authType: undefined }), + }); + + let thrown: Error | undefined; + try { + await validateNonInteractiveAuth( + AuthType.USE_GEMINI, + undefined, + nonInteractiveConfig, + mockSettings, + ); + } catch (e) { + thrown = e as Error; + } + + expect(thrown?.message).toBe( + `process.exit(${ExitCodes.FATAL_AUTHENTICATION_ERROR}) called`, + ); + { + // Checking coreEventsEmitFeedbackSpy arguments + const errorArg = coreEventsEmitFeedbackSpy.mock.calls[0]?.[1] as string; + const payload = JSON.parse(errorArg); + expect(payload.error.type).toBe('Error'); + expect(payload.error.code).toBe(ExitCodes.FATAL_AUTHENTICATION_ERROR); + expect(payload.error.message).toBe('Auth error!'); + } + }); + }); +}); diff --git a/packages/cli/src/validateNonInterActiveAuth.ts b/packages/cli/src/validateNonInterActiveAuth.ts new file mode 100644 index 0000000000000000000000000000000000000000..a15f4f83a20b515e98e69eee3786a22b48441408 --- /dev/null +++ b/packages/cli/src/validateNonInterActiveAuth.ts @@ -0,0 +1,65 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + debugLogger, + OutputFormat, + ExitCodes, + getAuthTypeFromEnv, + type Config, + type AuthType, +} from '@google/gemini-cli-core'; +import { USER_SETTINGS_PATH, type LoadedSettings } from './config/settings.js'; +import { validateAuthMethod } from './config/auth.js'; +import { handleError } from './utils/errors.js'; +import { runExitCleanup } from './utils/cleanup.js'; + +export async function validateNonInteractiveAuth( + configuredAuthType: AuthType | undefined, + useExternalAuth: boolean | undefined, + nonInteractiveConfig: Config, + settings: LoadedSettings, +) { + try { + const effectiveAuthType = configuredAuthType || getAuthTypeFromEnv(); + + const enforcedType = settings.merged.security.auth.enforcedType; + if (enforcedType && effectiveAuthType !== enforcedType) { + const message = effectiveAuthType + ? `The enforced authentication type is '${enforcedType}', but the current type is '${effectiveAuthType}'. Please re-authenticate with the correct type.` + : `The auth type '${enforcedType}' is enforced, but no authentication is configured.`; + throw new Error(message); + } + + if (!effectiveAuthType) { + const message = `Please set an Auth method in your ${USER_SETTINGS_PATH} or specify one of the following environment variables before running: GEMINI_API_KEY, GOOGLE_GENAI_USE_VERTEXAI, GOOGLE_GENAI_USE_GCA`; + throw new Error(message); + } + + const authType: AuthType = effectiveAuthType; + + if (!useExternalAuth) { + const err = await validateAuthMethod(String(authType)); + if (err != null) { + throw new Error(err); + } + } + + return authType; + } catch (error) { + if (nonInteractiveConfig.getOutputFormat() === OutputFormat.JSON) { + handleError( + error instanceof Error ? error : new Error(String(error)), + nonInteractiveConfig, + ExitCodes.FATAL_AUTHENTICATION_ERROR, + ); + } else { + debugLogger.error(error instanceof Error ? error.message : String(error)); + await runExitCleanup(); + process.exit(ExitCodes.FATAL_AUTHENTICATION_ERROR); + } + } +} diff --git a/packages/cli/test-setup.ts b/packages/cli/test-setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..e64e553ede7ed304bd1fa5697d9c4b82670ab4f1 --- /dev/null +++ b/packages/cli/test-setup.ts @@ -0,0 +1,127 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { vi, beforeEach, afterEach } from 'vitest'; +import { format } from 'node:util'; +import { coreEvents, debugLogger } from '@google/gemini-cli-core'; +import { themeManager } from './src/ui/themes/theme-manager.js'; +import { mockInkSpinner } from './src/test-utils/mockSpinner.js'; + +// Globally mock ink-spinner to prevent non-deterministic snapshot/act flakes. +mockInkSpinner(); + +// Unset CI environment variable so that ink renders dynamically as it does in a real terminal +if (process.env.CI !== undefined) { + delete process.env.CI; +} + +global.IS_REACT_ACT_ENVIRONMENT = true; + +// Increase max listeners to avoid warnings in large test suites +coreEvents.setMaxListeners(100); + +// Unset NO_COLOR environment variable to ensure consistent theme behavior between local and CI test runs +if (process.env.NO_COLOR !== undefined) { + delete process.env.NO_COLOR; +} + +// Force true color output for ink so that snapshots always include color information. +process.env.FORCE_COLOR = '3'; + +// Force generic keybinding hints to ensure stable snapshots across different operating systems. +process.env.FORCE_GENERIC_KEYBINDING_HINTS = 'true'; + +// Force generic terminal declaration to ensure stable snapshots across different host environments. +process.env.TERM_PROGRAM = 'generic'; + +// Force true color support for terminal capability checks to ensure stable snapshots across different terminals. +process.env.COLORTERM = 'truecolor'; + +// Mock stdout color depth to ensure true color capability is detected consistently across local and headless CI runners. +if (process.stdout) { + process.stdout.getColorDepth = () => 24; +} + +import './src/test-utils/customMatchers.js'; + +let consoleErrorSpy: vi.SpyInstance; +let actWarnings: Array<{ message: string; stack: string }> = []; + +let logSpy: vi.SpyInstance; +let warnSpy: vi.SpyInstance; +let errorSpy: vi.SpyInstance; +let debugSpy: vi.SpyInstance; + +beforeEach(() => { + // Reset themeManager state to ensure test isolation + themeManager.resetForTesting(); + + // Mock debugLogger to avoid test output noise + logSpy = vi.spyOn(debugLogger, 'log').mockImplementation(() => {}); + warnSpy = vi.spyOn(debugLogger, 'warn').mockImplementation((...args) => { + console.warn(...args); + }); + errorSpy = vi.spyOn(debugLogger, 'error').mockImplementation((...args) => { + console.error(...args); + }); + debugSpy = vi.spyOn(debugLogger, 'debug').mockImplementation(() => {}); + + actWarnings = []; + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => { + const firstArg = args[0]; + if ( + typeof firstArg === 'string' && + firstArg.includes('was not wrapped in act(...)') + ) { + const stackLines = (new Error().stack || '').split('\n'); + let lastReactFrameIndex = -1; + + // Find the index of the last frame that comes from react-reconciler + for (let i = 0; i < stackLines.length; i++) { + if (stackLines[i].includes('react-reconciler')) { + lastReactFrameIndex = i; + } + } + + // If we found react-reconciler frames, start the stack trace after the last one. + // Otherwise, just strip the first line (which is the Error message itself). + const relevantStack = + lastReactFrameIndex !== -1 + ? stackLines.slice(lastReactFrameIndex + 1).join('\n') + : stackLines.slice(1).join('\n'); + + if ( + relevantStack.includes('OverflowContext.tsx') || + relevantStack.includes('useTimedMessage.ts') || + relevantStack.includes('useInlineEditBuffer.ts') + ) { + return; + } + + actWarnings.push({ + message: format(...args), + stack: relevantStack, + }); + } + }); +}); + +afterEach(() => { + consoleErrorSpy.mockRestore(); + + logSpy?.mockRestore(); + warnSpy?.mockRestore(); + errorSpy?.mockRestore(); + debugSpy?.mockRestore(); + + vi.unstubAllEnvs(); + if (actWarnings.length > 0) { + const messages = actWarnings + .map(({ message, stack }) => `${message}\n${stack}`) + .join('\n\n'); + throw new Error(`Failing test due to "act(...)" warnings:\n${messages}`); + } +}); diff --git a/packages/cli/tsconfig.json b/packages/cli/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..7d2614f2e71f8f2d310367c95641f0f9fbec2a95 --- /dev/null +++ b/packages/cli/tsconfig.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "jsx": "react-jsx", + "lib": ["DOM", "DOM.Iterable", "ES2023"], + "types": ["node", "vitest/globals"], + "composite": true + }, + "include": [ + "index.ts", + "src/**/*.ts", + "src/**/*.tsx", + "src/**/*.json", + "./package.json" + ], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../core" }] +} diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..2924300a77e5e10b305b7b2f9013c8bb1c4a76e0 --- /dev/null +++ b/packages/cli/vitest.config.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/// +import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; +import * as path from 'node:path'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + resolve: { + conditions: ['test'], + }, + test: { + include: ['**/*.{test,spec}.{js,ts,jsx,tsx}', 'config.test.ts'], + exclude: ['**/node_modules/**', '**/dist/**', '**/cypress/**'], + environment: 'node', + globals: true, + reporters: ['default', 'junit'], + + outputFile: { + junit: 'junit.xml', + }, + alias: { + react: path.resolve(__dirname, '../../node_modules/react'), + }, + setupFiles: ['./test-setup.ts'], + testTimeout: 60000, + hookTimeout: 60000, + pool: 'forks', + coverage: { + enabled: true, + provider: 'v8', + reportsDirectory: './coverage', + include: ['src/**/*'], + reporter: [ + ['text', { file: 'full-text-summary.txt' }], + 'html', + 'json', + 'lcov', + 'cobertura', + ['json-summary', { outputFile: 'coverage-summary.json' }], + ], + }, + poolOptions: { + threads: { + minThreads: 1, + maxThreads: 4, + }, + }, + server: { + deps: { + inline: [/@google\/gemini-cli-core/], + }, + }, + }, +}); diff --git a/packages/core/GEMINI.md b/packages/core/GEMINI.md new file mode 100644 index 0000000000000000000000000000000000000000..a297aebedbfbd928ac46b5e86111179a1cc8ffcc --- /dev/null +++ b/packages/core/GEMINI.md @@ -0,0 +1,47 @@ +# Gemini CLI Core (`@google/gemini-cli-core`) + +Backend logic for Gemini CLI: API orchestration, prompt construction, tool +execution, and agent management. + +## Architecture + +- `src/agent/` & `src/agents/`: Agent lifecycle and sub-agent management. +- `src/availability/`: Model availability checks. +- `src/billing/`: Billing and usage tracking. +- `src/code_assist/`: Code assistance features. +- `src/commands/`: Built-in CLI command implementations. +- `src/config/`: Configuration management. +- `src/confirmation-bus/`: User confirmation flow for tool execution. +- `src/core/`: Core types and shared logic. +- `src/fallback/`: Fallback and retry strategies. +- `src/hooks/`: Hook system for extensibility. +- `src/ide/`: IDE integration interfaces. +- `src/mcp/`: MCP (Model Context Protocol) client and server integration. +- `src/output/`: Output formatting and rendering. +- `src/policy/`: Policy enforcement (e.g., tool confirmation policies). +- `src/prompts/`: System prompt construction and prompt snippets. +- `src/resources/`: Resource management. +- `src/routing/`: Model routing and selection logic. +- `src/safety/`: Safety filtering and guardrails. +- `src/scheduler/`: Task scheduling. +- `src/services/`: Shared service layer. +- `src/skills/`: Skill discovery and activation. +- `src/telemetry/`: Usage telemetry and logging. +- `src/tools/`: Built-in tool implementations (file system, shell, web, MCP). +- `src/utils/`: Shared utility functions. +- `src/voice/`: Voice input/output support. + +## Coding Conventions + +- **Legacy Snippets:** `src/prompts/snippets.legacy.ts` is a snapshot of an + older system prompt. Avoid changing the prompting verbiage to preserve its + historical behavior; however, structural changes to ensure compilation or + simplify the code are permitted. +- **Style:** Follow existing backend logic patterns. This package has no UI + dependencies — keep it framework-agnostic. + +## Testing + +- Run tests: `npm test -w @google/gemini-cli-core` +- Run a specific test: + `npm test -w @google/gemini-cli-core -- src/path/to/file.test.ts` diff --git a/packages/core/index.ts b/packages/core/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..5cf1bae2b635d4f612fa65bc6a9421b1dffa2e41 --- /dev/null +++ b/packages/core/index.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './src/index.js'; +export { Storage } from './src/config/storage.js'; +export { + DEFAULT_GEMINI_MODEL, + DEFAULT_GEMINI_MODEL_AUTO, + DEFAULT_GEMINI_FLASH_MODEL, + DEFAULT_GEMINI_FLASH_LITE_MODEL, + DEFAULT_GEMINI_EMBEDDING_MODEL, + GEMMA_4_31B_IT_MODEL, + GEMMA_4_26B_A4B_IT_MODEL, +} from './src/config/models.js'; +export { + serializeTerminalToObject, + type AnsiOutput, + type AnsiLine, + type AnsiToken, +} from './src/utils/terminalSerializer.js'; +export { DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD } from './src/config/config.js'; +export { detectIdeFromEnv } from './src/ide/detect-ide.js'; +export { + logExtensionEnable, + logIdeConnection, + logExtensionDisable, +} from './src/telemetry/loggers.js'; + +export { + IdeConnectionEvent, + IdeConnectionType, + ExtensionInstallEvent, + ExtensionDisableEvent, + ExtensionEnableEvent, + ExtensionUninstallEvent, + ExtensionUpdateEvent, + ModelSlashCommandEvent, +} from './src/telemetry/types.js'; +export { makeFakeConfig } from './src/test-utils/config.js'; +export * from './src/utils/pathReader.js'; +export { ClearcutLogger } from './src/telemetry/clearcut-logger/clearcut-logger.js'; +export { logModelSlashCommand } from './src/telemetry/loggers.js'; +export { KeychainTokenStorage } from './src/mcp/token-storage/keychain-token-storage.js'; +export * from './src/utils/googleQuotaErrors.js'; +export type { GoogleApiError } from './src/utils/googleErrors.js'; +export { getCodeAssistServer } from './src/code_assist/codeAssist.js'; +export { getExperiments } from './src/code_assist/experiments/experiments.js'; +export { ExperimentFlags } from './src/code_assist/experiments/flagNames.js'; +export { getErrorStatus, ModelNotFoundError } from './src/utils/httpErrors.js'; diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000000000000000000000000000000000000..5f81e4068178d3a49f8d2706b5705d5955aaf75a --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,120 @@ +{ + "name": "@google/gemini-cli-core", + "version": "0.62.0-nightly.20260915.gae28844fb", + "description": "Gemini CLI Core", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/google-gemini/gemini-cli.git" + }, + "type": "module", + "main": "dist/index.js", + "scripts": { + "bundle:browser-mcp": "node scripts/bundle-browser-mcp.mjs", + "build": "node ../../scripts/build_package.js", + "lint": "eslint . --ext .ts,.tsx", + "format": "prettier --write .", + "test": "vitest run", + "test:ci": "vitest run", + "posttest": "npm run build", + "typecheck": "tsc --noEmit" + }, + "files": [ + "dist" + ], + "dependencies": { + "@a2a-js/sdk": "0.3.11", + "@bufbuild/protobuf": "2.11.0", + "@google-cloud/logging": "11.2.1", + "@google-cloud/opentelemetry-cloud-monitoring-exporter": "0.21.0", + "@google-cloud/opentelemetry-cloud-trace-exporter": "3.0.0", + "@google/genai": "1.30.0", + "@grpc/grpc-js": "1.14.3", + "@iarna/toml": "2.2.5", + "@modelcontextprotocol/sdk": "1.23.0", + "@opentelemetry/api": "1.9.0", + "@opentelemetry/api-logs": "0.218.0", + "@opentelemetry/core": "2.7.1", + "@opentelemetry/exporter-logs-otlp-grpc": "0.218.0", + "@opentelemetry/exporter-logs-otlp-http": "0.218.0", + "@opentelemetry/exporter-metrics-otlp-grpc": "0.218.0", + "@opentelemetry/exporter-metrics-otlp-http": "0.218.0", + "@opentelemetry/exporter-trace-otlp-grpc": "0.218.0", + "@opentelemetry/exporter-trace-otlp-http": "0.218.0", + "@opentelemetry/instrumentation-http": "0.218.0", + "@opentelemetry/otlp-exporter-base": "0.218.0", + "@opentelemetry/resources": "2.7.1", + "@opentelemetry/sdk-logs": "0.218.0", + "@opentelemetry/sdk-metrics": "2.7.1", + "@opentelemetry/sdk-node": "0.218.0", + "@opentelemetry/sdk-trace-base": "2.7.1", + "@opentelemetry/sdk-trace-node": "2.7.1", + "@opentelemetry/semantic-conventions": "1.39.0", + "@types/html-to-text": "9.0.4", + "@xterm/headless": "5.5.0", + "ajv": "8.17.1", + "ajv-formats": "3.0.1", + "chokidar": "5.0.0", + "command-exists": "1.2.9", + "diff": "8.0.3", + "dotenv": "17.2.4", + "dotenv-expand": "12.0.3", + "execa": "9.6.1", + "fast-levenshtein": "2.0.6", + "fdir": "6.4.6", + "fzf": "0.5.2", + "glob": "12.0.0", + "google-auth-library": "10.9.0", + "html-to-text": "9.0.5", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "ignore": "7.0.0", + "ipaddr.js": "1.9.1", + "isbinaryfile": "5.0.7", + "js-yaml": "4.1.1", + "json-stable-stringify": "1.3.0", + "marked": "15.0.12", + "mime": "4.0.7", + "mnemonist": "0.40.3", + "open": "10.1.2", + "picomatch": "4.0.1", + "proper-lockfile": "4.1.2", + "puppeteer-core": "24.0.0", + "read-package-up": "11.0.0", + "shell-quote": "1.8.3", + "simple-git": "3.28.0", + "strip-ansi": "7.1.0", + "strip-json-comments": "3.1.1", + "systeminformation": "5.25.11", + "tree-sitter-bash": "0.25.0", + "undici": "7.10.0", + "uuid": "13.0.0", + "web-tree-sitter": "0.25.10", + "zod": "3.25.76", + "zod-to-json-schema": "3.25.1" + }, + "optionalDependencies": { + "@github/keytar": "7.10.6", + "@lydell/node-pty": "1.1.0", + "@lydell/node-pty-darwin-arm64": "1.1.0", + "@lydell/node-pty-darwin-x64": "1.1.0", + "@lydell/node-pty-linux-x64": "1.1.0", + "@lydell/node-pty-win32-arm64": "1.1.0", + "@lydell/node-pty-win32-x64": "1.1.0", + "node-pty": "1.0.0" + }, + "devDependencies": { + "@google/gemini-cli-test-utils": "file:../test-utils", + "@types/fast-levenshtein": "0.0.4", + "@types/js-yaml": "4.0.9", + "@types/json-stable-stringify": "1.1.0", + "@types/picomatch": "4.0.1", + "chrome-devtools-mcp": "0.19.0", + "msw": "2.3.4", + "typescript": "5.8.3", + "vitest": "3.2.4" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/core/test-setup.ts b/packages/core/test-setup.ts new file mode 100644 index 0000000000000000000000000000000000000000..d73036957800a6bb47dbdf22c6410ec73c0a0aa5 --- /dev/null +++ b/packages/core/test-setup.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +// Unset NO_COLOR environment variable to ensure consistent theme behavior between local and CI test runs +if (process.env.NO_COLOR !== undefined) { + delete process.env.NO_COLOR; +} + +import { setSimulate429 } from './src/utils/testUtils.js'; +import { vi, afterEach } from 'vitest'; +import { coreEvents } from './src/utils/events.js'; + +// Increase max listeners to avoid warnings in large test suites +coreEvents.setMaxListeners(100); + +// Disable 429 simulation globally for all tests +setSimulate429(false); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +// Default mocks for Storage and ProjectRegistry to prevent disk access in most tests. +// These can be overridden in specific tests using vi.unmock(). + +vi.mock('./src/config/projectRegistry.js', async (importOriginal) => { + const actual = + await importOriginal(); + actual.ProjectRegistry.prototype.initialize = vi.fn(() => + Promise.resolve(undefined), + ); + actual.ProjectRegistry.prototype.getShortId = vi.fn(() => + Promise.resolve('project-slug'), + ); + return actual; +}); + +vi.mock('./src/config/storageMigration.js', async (importOriginal) => { + const actual = + await importOriginal(); + actual.StorageMigration.migrateDirectory = vi.fn(() => + Promise.resolve(undefined), + ); + return actual; +}); + +vi.mock('./src/config/storage.js', async (importOriginal) => { + const actual = + await importOriginal(); + actual.Storage.prototype.initialize = vi.fn(() => Promise.resolve(undefined)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (actual.Storage.prototype as any).getProjectIdentifier = vi.fn( + () => 'project-slug', + ); + return actual; +}); diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..7526c379323659470e49f1117f4925487cb900d2 --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,15 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "lib": ["DOM", "DOM.Iterable", "ES2023"], + "composite": true, + "types": ["node", "vitest/globals"], + "baseUrl": ".", + "paths": { + "@google/gemini-cli-core": ["./index.ts"] + } + }, + "include": ["index.ts", "src/**/*.ts", "src/**/*.json"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..065bbfd41e658271405c36d61d829cfe4aab7a6d --- /dev/null +++ b/packages/core/vitest.config.ts @@ -0,0 +1,41 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + reporters: ['default', 'junit'], + testTimeout: 60000, + hookTimeout: 60000, + pool: 'forks', + silent: true, + setupFiles: ['./test-setup.ts'], + outputFile: { + junit: 'junit.xml', + }, + coverage: { + enabled: true, + provider: 'v8', + reportsDirectory: './coverage', + include: ['src/**/*'], + reporter: [ + ['text', { file: 'full-text-summary.txt' }], + 'html', + 'json', + 'lcov', + 'cobertura', + ['json-summary', { outputFile: 'coverage-summary.json' }], + ], + }, + poolOptions: { + threads: { + minThreads: 1, + maxThreads: 4, + }, + }, + }, +}); diff --git a/packages/devtools/GEMINI.md b/packages/devtools/GEMINI.md new file mode 100644 index 0000000000000000000000000000000000000000..7397cedf84db3647f96e7cbd5fc3dc89c0812e22 --- /dev/null +++ b/packages/devtools/GEMINI.md @@ -0,0 +1,86 @@ +# Gemini CLI DevTools + +Integrated Developer Tools for Gemini CLI, providing a Chrome DevTools-like +interface for Network and Console inspection. Launched automatically when the +`general.devtools` setting is enabled. + +## Features + +- **Network Inspector**: Real-time request/response logging with streaming + chunks and duration tracking +- **Console Inspector**: Real-time console log viewing + (log/warn/error/debug/info) +- **Session Management**: Multiple CLI session support with live connection + status +- **Import/Export**: Import JSONL log files, export current session logs + +## How It Works + +When `general.devtools` is enabled, the CLI's `devtoolsService` automatically: + +1. Probes port 25417 for an existing DevTools instance +2. If found, connects as a WebSocket client +3. If not, starts a new DevTools server and connects to it +4. If another instance races for the port, the loser connects to the winner + +No environment variables needed for normal use. + +## Architecture + +``` +gemini.tsx / nonInteractiveCli.ts + │ (dynamic import) + ▼ + devtoolsService.ts ← orchestration + DevTools lifecycle + │ (imports) + ▼ + activityLogger.ts ← pure logging (capture, file, WebSocket transport) + │ (events) + ▼ + DevTools server (:25417) ← this package (HTTP + WebSocket + SSE) + │ (SSE /events) + ▼ + DevTools UI (React) ← client/ compiled by esbuild +``` + +## Environment Variables + +| Variable | Description | +| -------------------------------- | --------------------------------------------- | +| `GEMINI_CLI_ACTIVITY_LOG_TARGET` | File path for JSONL mode (optional, fallback) | + +## API Endpoints + +| Endpoint | Method | Description | +| ----------------------- | --------- | --------------------------------------------------------------------------- | +| `/ws` | WebSocket | Log ingestion from CLI sessions (register, network, console) | +| `/events` | SSE | Pushes snapshot on connect, then incremental network/console/session events | +| `/api/trigger-debugger` | POST | Triggers the Node.js debugger for a specific CLI session via WebSocket | + +## Development + +```bash +# Build everything (client + server) +npm run build + +# Rebuild client only after UI changes +npm run build:client +``` + +### Project Structure + +``` +packages/devtools/ +├── src/ +│ └── index.ts # DevTools server (HTTP, WebSocket, SSE) +├── client/ +│ ├── index.html +│ └── src/ +│ ├── main.tsx # React entry +│ ├── App.tsx # DevTools UI +│ └── hooks.ts # Data fetching hooks +├── esbuild.client.js # Client build script +└── dist/ # Build output + ├── src/index.js # Compiled server + └── client/ # Bundled client assets +``` diff --git a/packages/devtools/client/index.html b/packages/devtools/client/index.html new file mode 100644 index 0000000000000000000000000000000000000000..6c786b94cdfac5fbabf1cf640a4fed3bf159923a --- /dev/null +++ b/packages/devtools/client/index.html @@ -0,0 +1,25 @@ + + + + + + Gemini CLI DevTools + + + +
+ + + diff --git a/packages/devtools/client/src/App.tsx b/packages/devtools/client/src/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7869b93c3ce111b99a856c37ac5987ce441e1009 --- /dev/null +++ b/packages/devtools/client/src/App.tsx @@ -0,0 +1,1957 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { useDevToolsData, type ConsoleLog, type NetworkLog } from './hooks'; + +type ThemeMode = 'light' | 'dark' | null; // null means follow system + +interface ThemeColors { + bg: string; + bgSecondary: string; + bgHover: string; + border: string; + text: string; + textSecondary: string; + accent: string; + consoleBg: string; + rowBorder: string; + errorBg: string; + errorText: string; + warnBg: string; + warnText: string; +} + +export default function App() { + const [activeTab, setActiveTab] = useState<'console' | 'network'>('console'); + const { networkLogs, consoleLogs, connectedSessions } = useDevToolsData(); + const [selectedSessionId, setSelectedSessionId] = useState( + null, + ); + const [importedLogs, setImportedLogs] = useState<{ + network: NetworkLog[]; + console: ConsoleLog[]; + } | null>(null); + const [importedSessionId, setImportedSessionId] = useState( + null, + ); + + // --- Toast Logic --- + const [toastMessage, setToastMessage] = useState(null); + const toastTimeoutRef = useRef | null>(null); + + const showToast = (msg: string) => { + setToastMessage(msg); + if (toastTimeoutRef.current) { + clearTimeout(toastTimeoutRef.current); + } + toastTimeoutRef.current = setTimeout(() => { + setToastMessage(null); + toastTimeoutRef.current = null; + }, 5000); + }; + + // --- Theme Logic --- + const [themeMode, setThemeMode] = useState(() => { + const saved = localStorage.getItem('devtools-theme'); + if (!saved) return null; // Default: follow system + return saved as ThemeMode; + }); + + const [systemIsDark, setSystemIsDark] = useState( + window.matchMedia('(prefers-color-scheme: dark)').matches, + ); + + useEffect(() => { + const media = window.matchMedia('(prefers-color-scheme: dark)'); + const listener = (e: MediaQueryListEvent) => setSystemIsDark(e.matches); + media.addEventListener('change', listener); + return () => media.removeEventListener('change', listener); + }, []); + + const isDark = themeMode === null ? systemIsDark : themeMode === 'dark'; + + const t = useMemo( + () => ({ + bg: isDark ? '#202124' : '#ffffff', + bgSecondary: isDark ? '#292a2d' : '#f3f3f3', + bgHover: isDark ? '#35363a' : '#e8f0fe', + border: isDark ? '#3c4043' : '#ccc', + text: isDark ? '#e8eaed' : '#333', + textSecondary: isDark ? '#9aa0a6' : '#666', + accent: isDark ? '#8ab4f8' : '#1a73e8', + consoleBg: isDark ? '#1e1e1e' : '#fff', + rowBorder: isDark ? '#303134' : '#f0f0f0', + errorBg: isDark ? '#3c1e1e' : '#fff0f0', + errorText: isDark ? '#f28b82' : '#a80000', + warnBg: isDark ? '#302a10' : '#fff3cd', + warnText: isDark ? '#fdd663' : '#7a5d00', + }), + [isDark], + ); + + const toggleTheme = () => { + const nextMode = isDark ? 'light' : 'dark'; + setThemeMode(nextMode); + localStorage.setItem('devtools-theme', nextMode); + }; + + // --- Import Logic --- + const handleImport = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + const reader = new FileReader(); + reader.onload = (event) => { + const content = event.target?.result as string; + try { + const networkMap = new Map(); + const consoleLogs: ConsoleLog[] = []; + + content + .split('\n') + .filter((l) => l.trim()) + .forEach((l) => { + const parsed = JSON.parse(l); + const payload = parsed.payload || {}; + const type = parsed.type; + const timestamp = parsed.timestamp; + + if (type === 'console') { + consoleLogs.push({ + ...payload, + type, + timestamp, + id: payload.id || Math.random().toString(36).substring(2, 11), + }); + } else if (type === 'network') { + const id = payload.id; + if (!id) return; + + if (!networkMap.has(id)) { + networkMap.set(id, { + ...payload, + type, + timestamp, + id, + } as NetworkLog); + } else { + // It's likely a response update + const existing = networkMap.get(id)!; + networkMap.set(id, { + ...existing, + ...payload, + // Ensure we don't overwrite the original timestamp or type + type: existing.type, + timestamp: existing.timestamp, + } as NetworkLog); + } + } + }); + + const importId = `[Imported] ${file.name}`; + const networkLogs = Array.from(networkMap.values()).sort( + (a, b) => a.timestamp - b.timestamp, + ); + + setImportedLogs({ network: networkLogs, console: consoleLogs }); + setImportedSessionId(importId); + setSelectedSessionId(importId); + } catch (err) { + console.error('Import error:', err); + alert('Failed to parse session file. Ensure it is a valid JSONL file.'); + } + }; + reader.readAsText(file); + e.target.value = ''; + }; + + // --- Export Logic --- + const handleExport = () => { + if (!selectedSessionId) return; + + // Collect entries with timestamps, then sort before serializing + const entries: Array<{ timestamp: number; data: object }> = []; + + // Export console logs + filteredConsoleLogs.forEach((log) => { + entries.push({ + timestamp: log.timestamp, + data: { + type: 'console', + payload: { type: log.type, content: log.content }, + sessionId: log.sessionId, + timestamp: log.timestamp, + }, + }); + }); + + // Export network logs + filteredNetworkLogs.forEach((log) => { + entries.push({ + timestamp: log.timestamp, + data: { + type: 'network', + payload: log, + sessionId: log.sessionId, + timestamp: log.timestamp, + }, + }); + }); + + // Sort by timestamp, then serialize + entries.sort((a, b) => a.timestamp - b.timestamp); + + const content = entries.map((e) => JSON.stringify(e.data)).join('\n'); + const blob = new Blob([content], { type: 'application/jsonl' }); + const url = URL.createObjectURL(blob); + + const a = document.createElement('a'); + a.href = url; + a.download = `session-${selectedSessionId}.jsonl`; + a.click(); + + URL.revokeObjectURL(url); + }; + + // --- Session Discovery --- + const sessions = useMemo(() => { + const sessionMap = new Map(); + const updateMap = (l: { sessionId?: string; timestamp: number }) => { + if (!l.sessionId) return; + const currentMax = sessionMap.get(l.sessionId) || 0; + if (l.timestamp > currentMax) sessionMap.set(l.sessionId, l.timestamp); + }; + networkLogs.forEach(updateMap); + consoleLogs.forEach(updateMap); + + const discovered = Array.from(sessionMap.entries()) + .sort((a, b) => b[1] - a[1]) + .map((entry) => entry[0]); + + if (importedSessionId) { + return [importedSessionId, ...discovered]; + } + return discovered; + }, [networkLogs, consoleLogs, importedSessionId]); + + useEffect(() => { + if (sessions.length > 0 && selectedSessionId === null) { + setSelectedSessionId(sessions[0]); + } + }, [sessions, selectedSessionId]); + + const filteredConsoleLogs = useMemo(() => { + if (!selectedSessionId) return []; + if (selectedSessionId === importedSessionId && importedLogs) { + return importedLogs.console; + } + return consoleLogs.filter((l) => l.sessionId === selectedSessionId); + }, [consoleLogs, selectedSessionId, importedSessionId, importedLogs]); + + const filteredNetworkLogs = useMemo(() => { + if (!selectedSessionId) return []; + if (selectedSessionId === importedSessionId && importedLogs) { + return importedLogs.network; + } + return networkLogs.filter((l) => l.sessionId === selectedSessionId); + }, [networkLogs, selectedSessionId, importedSessionId, importedLogs]); + + return ( +
+ + + {/* Toolbar */} +
+
+ setActiveTab('console')} + label="Console" + t={t} + /> + setActiveTab('network')} + label="Network" + t={t} + /> +
+ +
+ {selectedSessionId && + connectedSessions.includes(selectedSessionId) && ( + <> + + + + )} + + + +
+ + Session: + + {sessions.length > 0 ? ( + + ) : ( + + No Sessions + + )} + {selectedSessionId && + !selectedSessionId.startsWith('[Imported]') && ( + + + + {connectedSessions.includes(selectedSessionId) + ? 'Connected' + : 'Disconnected'} + + + )} +
+ + +
+
+ + {/* Content */} +
+ {selectedSessionId ? ( + <> +
+ +
+
+ +
+ + ) : ( +
+ Please start Gemini CLI to begin debugging +
+ )} +
+ + {/* Toast Notification */} + {toastMessage && ( +
+ {toastMessage} +
+ )} + + {/* CSS Animations */} + +
+ ); +} + +function TabButton({ + active, + onClick, + label, + t, +}: { + active: boolean; + onClick: () => void; + label: string; + t: ThemeColors; +}) { + return ( +
+ {label} +
+ ); +} + +// --- Console Components --- + +function ConsoleLogEntry({ log, t }: { log: ConsoleLog; t: ThemeColors }) { + const [isExpanded, setIsExpanded] = useState(false); + const content = log.content || ''; + const lines = content.split('\n'); + const CHAR_LIMIT = 500; + const LINE_LIMIT = 5; + + const isTooLong = content.length > CHAR_LIMIT; + const isTooManyLines = lines.length > LINE_LIMIT; + const needsCollapse = isTooLong || isTooManyLines; + + const isError = log.type === 'error'; + const isWarn = log.type === 'warn'; + const bg = isError ? t.errorBg : isWarn ? t.warnBg : 'transparent'; + const color = isError ? t.errorText : isWarn ? t.warnText : t.text; + const icon = isError ? '❌' : isWarn ? '⚠️' : ' '; + + let displayContent = content; + if (needsCollapse && !isExpanded) { + if (isTooManyLines) { + displayContent = lines.slice(0, LINE_LIMIT).join('\n') + '\n...'; + } else { + displayContent = content.substring(0, CHAR_LIMIT) + '...'; + } + } + + return ( +
+
+ {icon} +
+ +
+
+ {displayContent} +
+
+ +
+ {needsCollapse && ( +
setIsExpanded(!isExpanded)} + style={{ + fontSize: '12px', + + color: t.text, + + cursor: 'pointer', + + fontWeight: 'bold', + + userSelect: 'none', + + width: '20px', + + height: '20px', + + display: 'flex', + + alignItems: 'center', + + justifyContent: 'center', + + borderRadius: '4px', + + border: `1px solid ${t.border}`, + + background: t.bgSecondary, + + transition: 'all 0.1s', + }} + onMouseOver={(e) => { + (e.currentTarget as HTMLDivElement).style.background = t.bgHover; + }} + onMouseOut={(e) => { + (e.currentTarget as HTMLDivElement).style.background = + t.bgSecondary; + }} + title={isExpanded ? 'Collapse' : 'Expand'} + > + {isExpanded ? '−' : '+'} +
+ )} + +
+ {new Date(log.timestamp).toLocaleTimeString([], { + hour12: false, + + hour: '2-digit', + + minute: '2-digit', + + second: '2-digit', + })} +
+
+
+ ); +} + +function ConsoleView({ logs, t }: { logs: ConsoleLog[]; t: ThemeColors }) { + const bottomRef = useRef(null); + + useEffect(() => { + bottomRef.current?.scrollIntoView({ behavior: 'smooth' }); + }, [logs.length]); + + if (logs.length === 0) { + return ( +
+ No console logs in this session +
+ ); + } + + return ( +
+ {logs.map((log) => ( + + ))} + +
+
+ ); +} + +// --- Network Components --- + +function NetworkView({ + logs, + t, + isDark, +}: { + logs: NetworkLog[]; + t: ThemeColors; + isDark: boolean; +}) { + const [selectedId, setSelectedId] = useState(null); + const [filter, setFilter] = useState(''); + const [groupByDomain, setGroupByDomain] = useState(true); + const [expandedGroups, setExpandedGroups] = useState>( + {}, + ); + const [sidebarWidth, setSidebarWidth] = useState(400); + const isResizing = useRef(false); + + useEffect(() => { + const handleMouseMove = (e: MouseEvent) => { + if (!isResizing.current) return; + const newWidth = Math.max( + 200, + Math.min(e.clientX, window.innerWidth - 200), + ); + setSidebarWidth(newWidth); + }; + const handleMouseUp = () => { + isResizing.current = false; + document.body.style.cursor = 'default'; + document.body.style.userSelect = 'auto'; + }; + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + return () => { + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + }, []); + + const startResizing = () => { + isResizing.current = true; + document.body.style.cursor = 'col-resize'; + document.body.style.userSelect = 'none'; + }; + + const filteredLogs = useMemo(() => { + let result = logs; + if (filter) { + const lower = filter.toLowerCase(); + result = logs.filter((l) => l.url.toLowerCase().includes(lower)); + } + return result; + }, [logs, filter]); + + const groupedLogs = useMemo(() => { + if (!groupByDomain) return null; + const groups: Record = {}; + filteredLogs.forEach((log) => { + let groupKey = 'Other'; + try { + const url = new URL(log.url); + const lastSlashIndex = url.pathname.lastIndexOf('/'); + const basePath = + lastSlashIndex !== -1 + ? url.pathname.substring(0, lastSlashIndex + 1) + : '/'; + groupKey = url.hostname + basePath; + } catch { + /* ignore */ + } + if (!groups[groupKey]) groups[groupKey] = []; + groups[groupKey].push(log); + }); + return groups; + }, [filteredLogs, groupByDomain]); + + useEffect(() => { + if (groupedLogs) { + setExpandedGroups((prev) => { + const next = { ...prev }; + Object.keys(groupedLogs).forEach((key) => { + if (next[key] === undefined) { + // Collapse play.googleapis.com by default + next[key] = !key.includes('play.googleapis.com'); + } + }); + return next; + }); + } + }, [groupedLogs]); + + const toggleGroup = (key: string) => { + setExpandedGroups((prev) => ({ ...prev, [key]: !prev[key] })); + }; + + // --- Context Menu --- (reserved for future actions) + + const selectedLog = logs.find((l) => l.id === selectedId); + + const renderLogItem = (log: NetworkLog, nameOverride?: string) => { + const isPending = log.pending; + const status = log.response + ? log.response.status + : log.error + ? 'ERR' + : '...'; + const isError = log.error || (log.response && log.response.status >= 400); + + let name = nameOverride || log.url; + if (!nameOverride) { + try { + const urlObj = new URL(log.url); + name = urlObj.pathname + urlObj.search; + } catch { + /* ignore */ + } + } + + const isSelected = log.id === selectedId; + + return ( +
setSelectedId(log.id)} + style={{ + padding: '8px 12px', + cursor: 'pointer', + borderBottom: `1px solid ${t.rowBorder}`, + display: 'flex', + flexDirection: 'column', + fontSize: '12px', + backgroundColor: isSelected ? t.bgHover : 'transparent', + color: isError ? '#f28b82' : t.text, + paddingLeft: nameOverride ? '24px' : '12px', + }} + > +
+ + {log.method} + + + {name} + + + {isPending ? '⏳' : status} + +
+
+ ); + }; + + if (logs.length === 0) { + return ( +
+ No network activity in this session +
+ ); + } + + return ( +
+ {/* List */} +
+
+ setFilter(e.target.value)} + style={{ + flex: 1, + boxSizing: 'border-box', + padding: '4px 10px', + background: t.bg, + color: t.text, + border: `1px solid ${t.border}`, + borderRadius: '4px', + fontSize: '12px', + }} + /> + +
+
+ {groupByDomain && groupedLogs + ? Object.keys(groupedLogs).map((groupKey) => ( +
+
toggleGroup(groupKey)} + style={{ + padding: '6px 12px', + background: t.bgSecondary, + fontWeight: 'bold', + fontSize: '11px', + borderBottom: `1px solid ${t.rowBorder}`, + wordBreak: 'break-all', + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + userSelect: 'none', + }} + > + + {expandedGroups[groupKey] ? '▼' : '▶'} + + {groupKey} + + {groupedLogs[groupKey].length} + +
+ {expandedGroups[groupKey] && + groupedLogs[groupKey].map((log) => { + let displayName = log.url; + try { + const url = new URL(log.url); + const lastSlashIndex = url.pathname.lastIndexOf('/'); + const suffix = url.pathname.substring( + lastSlashIndex + 1, + ); + displayName = (suffix || '/') + url.search; + } catch { + /* ignore */ + } + return renderLogItem(log, displayName); + })} +
+ )) + : filteredLogs.map((log) => renderLogItem(log))} +
+
+ + {/* Resizer */} +
+ + {/* Detail */} +
+ {selectedLog ? ( + + ) : ( +
+ Select a request to view details +
+ )} +
+
+ ); +} + +type Tab = 'headers' | 'payload' | 'response'; + +function NetworkDetail({ log, t }: { log: NetworkLog; t: ThemeColors }) { + const [activeTab, setActiveTab] = useState('headers'); + const status = log.response + ? log.pending + ? '⏳' + : log.response.status + : log.error + ? 'Error' + : '⏳'; + + return ( +
+
+
+ {log.url} +
+
+ + {log.method} + + • + + {status} + + • + {new Date(log.timestamp).toLocaleTimeString()} + {log.response && ( + <> + • + + {log.response.durationMs}ms + + + )} +
+
+
+ {(['headers', 'payload', 'response'] as const).map((tab) => ( +
setActiveTab(tab)} + style={{ + padding: '8px 16px', + cursor: 'pointer', + fontWeight: 600, + fontSize: '12px', + textTransform: 'capitalize', + borderBottom: + activeTab === tab + ? `2px solid ${t.accent}` + : '2px solid transparent', + color: activeTab === tab ? t.accent : t.textSecondary, + transition: 'all 0.2s', + }} + > + {tab} +
+ ))} +
+
+ {activeTab === 'headers' && ( +
+
+ + + + {log.error && ( + + )} +
+
+ {log.response ? ( + + ) : ( + + (no response yet) + + )} +
+
+ +
+
+ )} + {activeTab === 'payload' && } + {activeTab === 'response' && ( + + )} +
+
+ ); +} + +function Section({ + title, + children, + t, +}: { + title: string; + children: React.ReactNode; + t: ThemeColors; +}) { + const [collapsed, setCollapsed] = useState(false); + return ( +
+
setCollapsed(!collapsed)} + style={{ + padding: '8px 12px', + background: t.bgSecondary, + fontWeight: 'bold', + fontSize: '11px', + cursor: 'pointer', + userSelect: 'none', + display: 'flex', + alignItems: 'center', + gap: '8px', + }} + > + + {collapsed ? '▶' : '▼'} + + {title} +
+ {!collapsed && ( +
{children}
+ )} +
+ ); +} + +function Pair({ + k, + v, + color, + t, +}: { + k: string; + v: string; + color?: string; + t: ThemeColors; +}) { + return ( +
+
+ {k}: +
+
+ {v} +
+
+ ); +} + +function HeadersMap({ + headers, + t, +}: { + headers: Record | undefined; + t: ThemeColors; +}) { + if (!headers) return
(none)
; + return ( + <> + {Object.entries(headers).map(([k, v]) => ( + + ))} + + ); +} + +function BodyView({ + content, + chunks, + t, +}: { + content?: string; + chunks?: Array<{ index: number; data: string; timestamp: number }>; + t: ThemeColors; +}) { + const [mode, setMode] = useState<'json' | 'raw'>('json'); + const hasChunks = chunks && chunks.length > 0; + const safeContent = hasChunks + ? chunks.map((c) => c.data).join('') + : content || ''; + const getFormattedJson = () => { + try { + return JSON.stringify(JSON.parse(safeContent), null, 2); + } catch { + return safeContent; + } + }; + + const copyJson = () => { + navigator.clipboard.writeText(getFormattedJson()).catch(() => { + // Clipboard API unavailable — silently ignore + }); + }; + + const downloadJson = () => { + const blob = new Blob([getFormattedJson()], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = 'body.json'; + a.click(); + URL.revokeObjectURL(url); + }; + + if (!safeContent && !hasChunks) + return ( +
+ (No content) +
+ ); + + const iconBtn = { + background: 'none', + border: 'none', + cursor: 'pointer', + color: t.textSecondary, + padding: '2px', + borderRadius: '4px', + display: 'flex', + alignItems: 'center', + } as const; + + return ( +
+
+ {(['json', 'raw'] as const).map((m) => ( + + ))} +
+ + +
+
+ {hasChunks && mode === 'raw' ? ( +
+ {chunks.map((chunk, i) => ( +
+
+ [ + {new Date(chunk.timestamp).toLocaleTimeString('en-US', { + hour12: false, + })} + .{String(chunk.timestamp % 1000).padStart(3, '0')}] +
+
+                  {chunk.data}
+                
+
+ ))} +
+ ) : mode === 'raw' ? ( +
+            {safeContent}
+          
+ ) : ( + + )} +
+
+ ); +} + +function JsonViewer({ content, t }: { content: string; t: ThemeColors }) { + const safeContent = content || ''; + if (safeContent.includes('data:')) { + const chunks = safeContent + .split(/\n\s*\n/) + .map((eventBlock, i) => ({ + index: i + 1, + jsonStr: eventBlock + .split('\n') + .filter((line) => line.trim().startsWith('data:')) + .map((line) => line.trim().substring(5).trim()) + .join(''), + })) + .filter((c) => c.jsonStr); + if (chunks.length > 0) { + return ( +
+ {chunks.map((chunk) => ( +
+
+ CHUNK {chunk.index} +
+ +
+ ))} +
+ ); + } + } + return ; +} + +function tryParse(str: string) { + try { + return JSON.parse(str); + } catch { + return str; + } +} + +interface JsonLine { + text: string; + foldStart: boolean; + foldEnd: number; // -1 if not a fold start + closingBracket: string; // '}' or ']' for fold starts +} + +function jsonToLines(data: unknown): JsonLine[] { + const str = + typeof data === 'string' && !data.startsWith('{') && !data.startsWith('[') + ? data + : JSON.stringify(data, null, 2); + if (str == null) + return [ + { text: 'undefined', foldStart: false, foldEnd: -1, closingBracket: '' }, + ]; + const raw = str.split('\n'); + const lines: JsonLine[] = raw.map((text) => ({ + text, + foldStart: false, + foldEnd: -1, + closingBracket: '', + })); + // Match opening brackets to closing brackets using a stack + const stack: Array<{ index: number; bracket: string }> = []; + for (let i = 0; i < raw.length; i++) { + const trimmed = raw[i].trimEnd(); + const last = trimmed[trimmed.length - 1]; + if (last === '{' || last === '[') { + stack.push({ index: i, bracket: last === '{' ? '}' : ']' }); + } + // Check for closing bracket (with or without trailing comma) + const stripped = trimmed.replace(/,\s*$/, ''); + const closeChar = stripped[stripped.length - 1]; + if ( + (closeChar === '}' || closeChar === ']') && + stack.length > 0 && + stack[stack.length - 1].bracket === closeChar + ) { + const open = stack.pop()!; + lines[open.index].foldStart = true; + lines[open.index].foldEnd = i; + lines[open.index].closingBracket = open.bracket; + } + } + return lines; +} + +// Tokenize a JSON line for syntax highlighting +const TOKEN_RE = + /("(?:[^"\\]|\\.)*")\s*(?=:)|("(?:[^"\\]|\\.)*")|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)|(\btrue\b|\bfalse\b)|(\bnull\b)|([{}[\]:,])/g; + +function highlightLine(text: string, t: ThemeColors): React.ReactNode { + const parts: React.ReactNode[] = []; + let lastIndex = 0; + let match: RegExpExecArray | null; + TOKEN_RE.lastIndex = 0; + + while ((match = TOKEN_RE.exec(text)) !== null) { + // Push any unmatched text before this token (whitespace/other) + if (match.index > lastIndex) { + parts.push(text.slice(lastIndex, match.index)); + } + const [full, key, str, num, bool, nul] = match; + if (key) { + parts.push( + + {full} + , + ); + } else if (str) { + // Unescape JSON string escapes so \n renders as actual newlines. + // Use JSON.parse to handle all escape sequences correctly in one pass + // (manual chained .replace() can double-unescape e.g. \\n → \). + let unescaped: string; + try { + unescaped = JSON.parse(full) as string; + } catch { + unescaped = full.slice(1, -1); + } + const strLines = unescaped.split('\n'); + if (strLines.length <= 1) { + parts.push( + + {full} + , + ); + } else { + const indent = ' '.repeat(match.index + 1); + parts.push( + , + ); + } + } else if (num) { + parts.push( + + {full} + , + ); + } else if (bool) { + parts.push( + + {full} + , + ); + } else if (nul) { + parts.push( + + {full} + , + ); + } else { + // punctuation + parts.push({full}); + } + lastIndex = match.index + full.length; + } + // Remaining text + if (lastIndex < text.length) { + parts.push(text.slice(lastIndex)); + } + return parts; +} + +const STRING_LINE_THRESHOLD = 20; + +function CollapsibleString({ + lines, + indent, + t, +}: { + lines: string[]; + indent: string; + t: ThemeColors; +}) { + const [expanded, setExpanded] = useState(false); + const needsTruncation = lines.length > STRING_LINE_THRESHOLD; + const displayLines = + needsTruncation && !expanded + ? lines.slice(0, STRING_LINE_THRESHOLD) + : lines; + + return ( + + "{displayLines[0]} + {displayLines.slice(1).map((sl, si) => ( + + {'\n'} + {indent} + {sl} + + ))} + {needsTruncation && ( + <> + {'\n'} + {indent} + setExpanded(!expanded)} + style={{ + color: t.accent, + cursor: 'pointer', + fontStyle: 'italic', + userSelect: 'none', + }} + > + {expanded + ? '▲ collapse' + : `... ${lines.length - STRING_LINE_THRESHOLD} more lines`} + + + )} + " + + ); +} + +function CodeView({ data, t }: { data: unknown; t: ThemeColors }) { + const lines = useMemo(() => jsonToLines(data), [data]); + const [collapsed, setCollapsed] = useState>(() => new Set()); + const contentRef = useRef(null); + + const toggleFold = (lineIndex: number) => { + setCollapsed((prev) => { + const next = new Set(prev); + if (next.has(lineIndex)) { + next.delete(lineIndex); + } else { + next.add(lineIndex); + } + return next; + }); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'a') { + e.preventDefault(); + const sel = window.getSelection(); + if (sel && contentRef.current) { + sel.removeAllRanges(); + const range = document.createRange(); + range.selectNodeContents(contentRef.current); + sel.addRange(range); + } + } + }; + + // Build visible lines, skipping folded regions + const visibleLines: Array<{ + index: number; + content: React.ReactNode; + foldable: boolean; + isCollapsed: boolean; + }> = []; + + let i = 0; + while (i < lines.length) { + const line = lines[i]; + const isCollapsed = collapsed.has(i); + if (line.foldStart && isCollapsed) { + // Show the opening line with collapsed indicator + const indent = line.text.match(/^(\s*)/)?.[1] || ''; + const trimmed = line.text.trimStart(); + visibleLines.push({ + index: i, + content: ( + <> + {indent.length > 0 && {indent}} + {highlightLine(trimmed, t)} + + {' '} + ... {line.closingBracket} + + + ), + foldable: true, + isCollapsed: true, + }); + // Skip to the line after foldEnd + i = line.foldEnd + 1; + } else { + visibleLines.push({ + index: i, + content: highlightLine(line.text, t), + foldable: line.foldStart, + isCollapsed: false, + }); + i++; + } + } + + return ( +
+ {visibleLines.map((vl) => ( + + {/* Gutter cell */} +
toggleFold(vl.index) : undefined} + > + {vl.foldable ? ( + {vl.isCollapsed ? '▶' : '▼'} + ) : ( + '' + )} +
+ {/* Content cell */} +
+ {vl.content} +
+
+ ))} +
+ ); +} diff --git a/packages/devtools/client/src/hooks.ts b/packages/devtools/client/src/hooks.ts new file mode 100644 index 0000000000000000000000000000000000000000..09c94bff62011abdb6ac80aa10dbe15c98a7440f --- /dev/null +++ b/packages/devtools/client/src/hooks.ts @@ -0,0 +1,94 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect } from 'react'; +import type { + NetworkLog, + InspectorConsoleLog as ConsoleLog, +} from '../../src/types.js'; + +export type { NetworkLog }; +export type { InspectorConsoleLog as ConsoleLog } from '../../src/types.js'; + +export function useDevToolsData() { + const [networkLogs, setNetworkLogs] = useState([]); + const [consoleLogs, setConsoleLogs] = useState([]); + const [isConnected, setIsConnected] = useState(false); + const [connectedSessions, setConnectedSessions] = useState([]); + + useEffect(() => { + const evtSource = new EventSource('/events'); + + evtSource.onopen = () => setIsConnected(true); + evtSource.onerror = () => setIsConnected(false); + + evtSource.addEventListener('snapshot', (e) => { + try { + const data = JSON.parse(e.data); + // Merge with existing data to preserve logs across server restarts + setNetworkLogs((prev) => { + if (data.networkLogs.length === 0) return prev; + const merged = new Map(prev.map((l: NetworkLog) => [l.id, l])); + for (const log of data.networkLogs) merged.set(log.id, log); + return Array.from(merged.values()); + }); + setConsoleLogs((prev) => { + if (data.consoleLogs.length === 0) return prev; + const existingIds = new Set(prev.map((l: ConsoleLog) => l.id)); + const newLogs = data.consoleLogs.filter( + (l: ConsoleLog) => !existingIds.has(l.id), + ); + const merged = [...prev, ...newLogs]; + return merged.length > 5000 ? merged.slice(-5000) : merged; + }); + setConnectedSessions(data.sessions); + } catch { + // Malformed snapshot — ignore + } + }); + + evtSource.addEventListener('network', (e) => { + try { + const log = JSON.parse(e.data) as NetworkLog; + setNetworkLogs((prev) => { + const idx = prev.findIndex((l) => l.id === log.id); + if (idx > -1) { + const next = [...prev]; + next[idx] = log; + return next; + } + return [...prev, log]; + }); + } catch { + // Malformed network event — ignore + } + }); + + evtSource.addEventListener('console', (e) => { + try { + const log = JSON.parse(e.data) as ConsoleLog; + setConsoleLogs((prev) => { + const next = [...prev, log]; + return next.length > 5000 ? next.slice(-5000) : next; + }); + } catch { + // Malformed console event — ignore + } + }); + + evtSource.addEventListener('session', (e) => { + try { + setConnectedSessions(JSON.parse(e.data)); + } catch { + // Malformed session event — ignore + } + }); + + return () => evtSource.close(); + }, []); + + return { networkLogs, consoleLogs, isConnected, connectedSessions }; +} diff --git a/packages/devtools/client/src/main.tsx b/packages/devtools/client/src/main.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a0698aa77d27c28ec738f6cfc0ed2177c6a515be --- /dev/null +++ b/packages/devtools/client/src/main.tsx @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import App from './App'; + +ReactDOM.createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/packages/devtools/esbuild.client.js b/packages/devtools/esbuild.client.js new file mode 100644 index 0000000000000000000000000000000000000000..d7d203b435027739eb668449f6caa7aaadfb5a2e --- /dev/null +++ b/packages/devtools/esbuild.client.js @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import esbuild from 'esbuild'; +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; + +mkdirSync('dist/client', { recursive: true }); + +await esbuild.build({ + entryPoints: ['client/src/main.tsx'], + bundle: true, + minify: true, + format: 'esm', + target: 'es2020', + jsx: 'automatic', + outfile: 'dist/client/main.js', + define: { + 'process.env.NODE_ENV': '"production"', + }, +}); + +// Embed client assets as string constants so the devtools server can be +// bundled into the CLI without needing readFileSync + __dirname at runtime. +const indexHtml = readFileSync('client/index.html', 'utf-8'); +const clientJs = readFileSync('dist/client/main.js', 'utf-8'); + +writeFileSync( + 'src/_client-assets.ts', + `/**\n * @license\n * Copyright 2025 Google LLC\n * SPDX-License-Identifier: Apache-2.0\n */\n\n// Auto-generated by esbuild.client.js — do not edit\nexport const INDEX_HTML = ${JSON.stringify(indexHtml)};\nexport const CLIENT_JS = ${JSON.stringify(clientJs)};\n`, +); diff --git a/packages/devtools/package.json b/packages/devtools/package.json new file mode 100644 index 0000000000000000000000000000000000000000..491a26b4e778f4141f9eaeef66825fb634703a5c --- /dev/null +++ b/packages/devtools/package.json @@ -0,0 +1,32 @@ +{ + "name": "@google/gemini-cli-devtools", + "version": "0.62.0-nightly.20260915.gae28844fb", + "license": "Apache-2.0", + "type": "module", + "main": "dist/src/index.js", + "types": "dist/src/index.d.ts", + "exports": { + ".": { + "types": "./dist/src/index.d.ts", + "default": "./dist/src/index.js" + } + }, + "scripts": { + "build": "npm run build:client && tsc -p tsconfig.build.json", + "build:client": "node esbuild.client.js" + }, + "files": [ + "dist", + "client/index.html" + ], + "engines": { + "node": ">=20" + }, + "devDependencies": { + "react": "19.2.4", + "react-dom": "19.2.4" + }, + "dependencies": { + "ws": "8.16.0" + } +} diff --git a/packages/devtools/src/index.ts b/packages/devtools/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd33b2642a3701ee206087729b67651666961d07 --- /dev/null +++ b/packages/devtools/src/index.ts @@ -0,0 +1,394 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; +import { EventEmitter } from 'node:events'; +import { WebSocketServer, type WebSocket } from 'ws'; +import type { + NetworkLog, + ConsoleLogPayload, + InspectorConsoleLog, +} from './types.js'; +import { INDEX_HTML, CLIENT_JS } from './_client-assets.js'; + +export type { + NetworkLog, + ConsoleLogPayload, + InspectorConsoleLog, +} from './types.js'; + +interface IncomingNetworkPayload extends Partial { + chunk?: { + index: number; + data: string; + timestamp: number; + }; +} + +export interface SessionInfo { + sessionId: string; + ws: WebSocket; + lastPing: number; +} + +/** + * DevTools Viewer + * + * Receives logs via WebSocket from CLI sessions. + */ +export class DevTools extends EventEmitter { + private static instance: DevTools | undefined; + private logs: NetworkLog[] = []; + private consoleLogs: InspectorConsoleLog[] = []; + private server: http.Server | null = null; + private wss: WebSocketServer | null = null; + private sessions = new Map(); + private heartbeatTimer: NodeJS.Timeout | null = null; + private port = 25417; + private static readonly DEFAULT_PORT = 25417; + private static readonly MAX_PORT_RETRIES = 10; + + private constructor() { + super(); + // Each SSE client adds 3 listeners; raise the limit to avoid warnings + this.setMaxListeners(50); + } + + static getInstance(): DevTools { + if (!DevTools.instance) { + DevTools.instance = new DevTools(); + } + return DevTools.instance; + } + + addInternalConsoleLog( + payload: ConsoleLogPayload, + sessionId?: string, + timestamp?: number, + ) { + const entry: InspectorConsoleLog = { + ...payload, + id: randomUUID(), + sessionId, + timestamp: timestamp || Date.now(), + }; + this.consoleLogs.push(entry); + if (this.consoleLogs.length > 5000) this.consoleLogs.shift(); + this.emit('console-update', entry); + } + + addInternalNetworkLog( + payload: IncomingNetworkPayload, + sessionId?: string, + timestamp?: number, + ) { + if (!payload.id) return; + const existingIndex = this.logs.findIndex((l) => l.id === payload.id); + if (existingIndex > -1) { + const existing = this.logs[existingIndex]; + + // Handle chunk accumulation + if (payload.chunk) { + const chunks = existing.chunks || []; + chunks.push(payload.chunk); + this.logs[existingIndex] = { + ...existing, + chunks, + sessionId: sessionId || existing.sessionId, + }; + } else { + this.logs[existingIndex] = { + ...existing, + ...payload, + sessionId: sessionId || existing.sessionId, + // Drop chunks once we have the full response body — the data + // is redundant and keeping both can blow past V8's string limit + // when serializing the snapshot. + chunks: payload.response?.body ? undefined : existing.chunks, + response: payload.response + ? { ...existing.response, ...payload.response } + : existing.response, + } as NetworkLog; + } + this.emit('update', this.logs[existingIndex]); + } else if (payload.url) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + const entry = { + ...payload, + sessionId, + timestamp: timestamp || Date.now(), + chunks: payload.chunk ? [payload.chunk] : undefined, + } as NetworkLog; + this.logs.push(entry); + if (this.logs.length > 10) this.logs.shift(); + this.emit('update', entry); + } + } + + getUrl(): string { + return `http://127.0.0.1:${this.port}`; + } + + getPort(): number { + return this.port; + } + + stop(): Promise { + return new Promise((resolve) => { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + if (this.wss) { + this.wss.close(); + this.wss = null; + } + if (this.server) { + this.server.close(() => resolve()); + this.server = null; + } else { + resolve(); + } + // Reset singleton so a fresh start() is possible + DevTools.instance = undefined; + }); + } + + start(): Promise { + return new Promise((resolve, reject) => { + if (this.server) { + resolve(this.getUrl()); + return; + } + this.server = http.createServer((req, res) => { + // Only allow same-origin requests — the client is served from this + // server so cross-origin access is unnecessary and would let arbitrary + // websites exfiltrate logs (which may contain API keys/headers). + const origin = req.headers.origin; + if (origin) { + const allowed = `http://127.0.0.1:${this.port}`; + if (origin === allowed) { + res.setHeader('Access-Control-Allow-Origin', allowed); + } + } + + // API routes + if (req.url === '/api/trigger-debugger' && req.method === 'POST') { + let body = ''; + req.on('data', (chunk) => { + body += chunk; + }); + req.on('end', () => { + try { + const parsed: unknown = JSON.parse(body); + if ( + typeof parsed !== 'object' || + parsed === null || + !('sessionId' in parsed) || + typeof parsed.sessionId !== 'string' + ) { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid request' })); + return; + } + const sessionId = parsed.sessionId; + const session = this.sessions.get(sessionId); + if (session) { + session.ws.send(JSON.stringify({ type: 'trigger-debugger' })); + + res.writeHead(200, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ success: true })); + } else { + res.writeHead(404, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Session not found' })); + } + } catch { + res.writeHead(400, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ error: 'Invalid request' })); + } + }); + } else if (req.url === '/events') { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }); + + // Send full snapshot on connect + const snapshot = JSON.stringify({ + networkLogs: this.logs, + consoleLogs: this.consoleLogs, + sessions: Array.from(this.sessions.keys()), + }); + res.write(`event: snapshot\ndata: ${snapshot}\n\n`); + + // Incremental updates + const onNetwork = (log: NetworkLog) => { + res.write(`event: network\ndata: ${JSON.stringify(log)}\n\n`); + }; + const onConsole = (log: InspectorConsoleLog) => { + res.write(`event: console\ndata: ${JSON.stringify(log)}\n\n`); + }; + const onSession = () => { + const sessions = Array.from(this.sessions.keys()); + res.write(`event: session\ndata: ${JSON.stringify(sessions)}\n\n`); + }; + this.on('update', onNetwork); + this.on('console-update', onConsole); + this.on('session-update', onSession); + req.on('close', () => { + this.off('update', onNetwork); + this.off('console-update', onConsole); + this.off('session-update', onSession); + }); + } else if (req.url === '/' || req.url === '/index.html') { + res.writeHead(200, { 'Content-Type': 'text/html' }); + res.end(INDEX_HTML); + } else if (req.url === '/assets/main.js') { + res.writeHead(200, { 'Content-Type': 'application/javascript' }); + res.end(CLIENT_JS); + } else { + res.writeHead(404); + res.end('Not Found'); + } + }); + this.server.on('error', (e: unknown) => { + if ( + typeof e === 'object' && + e !== null && + 'code' in e && + e.code === 'EADDRINUSE' + ) { + if (this.port - DevTools.DEFAULT_PORT >= DevTools.MAX_PORT_RETRIES) { + reject( + new Error( + `DevTools: all ports ${DevTools.DEFAULT_PORT}–${this.port} in use`, + ), + ); + return; + } + this.port++; + this.server?.listen(this.port, '127.0.0.1'); + } else { + reject(e instanceof Error ? e : new Error(String(e))); + } + }); + this.server.listen(this.port, '127.0.0.1', () => { + this.setupWebSocketServer(); + resolve(this.getUrl()); + }); + }); + } + + private setupWebSocketServer() { + if (!this.server) return; + + this.wss = new WebSocketServer({ server: this.server, path: '/ws' }); + + this.wss.on('connection', (ws: WebSocket) => { + let sessionId: string | null = null; + + ws.on('message', (data: Buffer) => { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const message = JSON.parse(data.toString()); + + // Handle registration first + if (message.type === 'register') { + sessionId = String(message.sessionId); + if (!sessionId) return; + + this.sessions.set(sessionId, { + sessionId, + ws, + lastPing: Date.now(), + }); + + // Notify session update + this.emit('session-update'); + + // Send registration acknowledgement + ws.send( + JSON.stringify({ + type: 'registered', + sessionId, + timestamp: Date.now(), + }), + ); + } else if (sessionId) { + this.handleWebSocketMessage(sessionId, message); + } + } catch { + // Invalid WebSocket message + } + }); + + ws.on('close', () => { + if (sessionId) { + this.sessions.delete(sessionId); + this.emit('session-update'); + } + }); + + ws.on('error', () => { + // WebSocket error — no action needed + }); + }); + + // Heartbeat mechanism + this.heartbeatTimer = setInterval(() => { + const now = Date.now(); + this.sessions.forEach((session, sessionId) => { + if (now - session.lastPing > 30000) { + session.ws.close(); + this.sessions.delete(sessionId); + } else { + // Send ping + session.ws.send(JSON.stringify({ type: 'ping', timestamp: now })); + } + }); + }, 10000); + this.heartbeatTimer.unref(); + } + + private handleWebSocketMessage( + sessionId: string, + message: Record, + ) { + const session = this.sessions.get(sessionId); + if (!session) return; + + switch (message['type']) { + case 'pong': + session.lastPing = Date.now(); + break; + + case 'console': + this.addInternalConsoleLog( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + message['payload'] as ConsoleLogPayload, + sessionId, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + message['timestamp'] as number, + ); + break; + + case 'network': + this.addInternalNetworkLog( + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + message['payload'] as IncomingNetworkPayload, + sessionId, + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + message['timestamp'] as number, + ); + break; + + default: + break; + } + } +} diff --git a/packages/devtools/src/types.ts b/packages/devtools/src/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..ffaf038d675efa14bab68d8326568a8b84f97831 --- /dev/null +++ b/packages/devtools/src/types.ts @@ -0,0 +1,39 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface NetworkLog { + id: string; + sessionId?: string; + timestamp: number; + method: string; + url: string; + headers: Record; + body?: string; + pending?: boolean; + chunks?: Array<{ + index: number; + data: string; + timestamp: number; + }>; + response?: { + status: number; + headers: Record; + body?: string; + durationMs: number; + }; + error?: string; +} + +export interface ConsoleLogPayload { + type: 'log' | 'warn' | 'error' | 'debug' | 'info'; + content: string; +} + +export interface InspectorConsoleLog extends ConsoleLogPayload { + id: string; + sessionId?: string; + timestamp: number; +} diff --git a/packages/devtools/tsconfig.build.json b/packages/devtools/tsconfig.build.json new file mode 100644 index 0000000000000000000000000000000000000000..f90fcb9cac8665db17a703052643582405b8207c --- /dev/null +++ b/packages/devtools/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist/src", + "declaration": true, + "sourceMap": true, + "noEmit": false, + "composite": false, + "incremental": false, + "verbatimModuleSyntax": false + }, + "include": ["src"] +} diff --git a/packages/devtools/tsconfig.json b/packages/devtools/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..fe76dc95a8722d4282729f87114fc7e92d81862c --- /dev/null +++ b/packages/devtools/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "lib": ["DOM", "DOM.Iterable", "ES2023"], + "jsx": "react-jsx", + "allowImportingTsExtensions": true, + "noEmit": true + }, + "include": ["src", "client/src"] +} diff --git a/packages/sdk/GEMINI.md b/packages/sdk/GEMINI.md new file mode 100644 index 0000000000000000000000000000000000000000..d9a8429dfec0a819927108c911b037f2bd77c4ca --- /dev/null +++ b/packages/sdk/GEMINI.md @@ -0,0 +1,18 @@ +# Gemini CLI SDK (`@google/gemini-cli-sdk`) + +Programmatic SDK for embedding Gemini CLI agent capabilities into other +applications. + +## Architecture + +- `src/agent.ts`: Agent creation and management. +- `src/session.ts`: Session lifecycle and state management. +- `src/tool.ts`: Tool definition and execution interface. +- `src/skills.ts`: Skill integration. +- `src/fs.ts` & `src/shell.ts`: File system and shell utilities. +- `src/types.ts`: Public type definitions. + +## Testing + +- Run tests: `npm test -w @google/gemini-cli-sdk` +- Integration tests use `*.integration.test.ts` naming convention. diff --git a/packages/sdk/README.md b/packages/sdk/README.md new file mode 100644 index 0000000000000000000000000000000000000000..5500abc5fffb34169044b743033816d39218a788 --- /dev/null +++ b/packages/sdk/README.md @@ -0,0 +1,36 @@ +# @google/gemini-cli-sdk + +The Gemini CLI SDK provides a programmatic interface to interact with Gemini +models and tools. + +## Installation + +```bash +npm install @google/gemini-cli-sdk +``` + +## Usage + +```typescript +import { GeminiCliAgent } from '@google/gemini-cli-sdk'; + +async function main() { + const agent = new GeminiCliAgent({ + instructions: 'You are a helpful assistant.', + }); + + const controller = new AbortController(); + const signal = controller.signal; + + // Stream responses from the agent + const stream = agent.sendStream('Why is the sky blue?', signal); + + for await (const chunk of stream) { + if (chunk.type === 'content') { + process.stdout.write(chunk.value.text || ''); + } + } +} + +main().catch(console.error); +``` diff --git a/packages/sdk/SDK_DESIGN.md b/packages/sdk/SDK_DESIGN.md new file mode 100644 index 0000000000000000000000000000000000000000..d3031f32b8a1c0b2b344f03886583ba5654ce0ca --- /dev/null +++ b/packages/sdk/SDK_DESIGN.md @@ -0,0 +1,340 @@ +# `Gemini CLI SDK` + +> **Implementation Status:** Core agent loop, tool execution, and session +> context are implemented. Advanced features like hooks, skills, subagents, and +> ACP are currently missing. + +# `Examples` + +## `Simple Example` + +> **Status:** Implemented. `GeminiCliAgent` supports `session()` and +> `resumeSession()`. + +Equivalent to `gemini -p "what does this project do?"`. Loads all workspace and +user settings. + +```ts +import { GeminiCliAgent } from '@google/gemini-cli-sdk'; + +const simpleAgent = new GeminiCliAgent({ + cwd: '/path/to/some/dir', +}); + +// Create a new empty session +const session = simpleAgent.session(); + +// Resume a specific session by ID +// const session = await simpleAgent.resumeSession('some-session-id'); + +for await (const chunk of session.sendStream('what does this project do?')) { + console.log(chunk); // equivalent to JSON streaming chunks +} +``` + +Validation: + +- Model receives call containing "what does this project do?" text. + +## `System Instructions` + +> **Status:** Implemented. Both static string instructions and dynamic functions +> (receiving `SessionContext`) are supported. + +System instructions can be provided by a static string OR dynamically via a +function: + +```ts +import { GeminiCliAgent } from "@google/gemini-cli-sdk"; + +const agent = new GeminiCliAgent({ + instructions: "This is a static string instruction"; // this is valid + instructions: (ctx) => `The current time is ${new Date().toISOString()} in session ${ctx.sessionId}.` +}); +``` + +Validation: + +- Static string instructions show up where GEMINI.md content normally would in + model call +- Dynamic instructions show up and contain dynamic content. + +## `Custom Tools` + +> **Status:** Implemented. `tool()` helper and `GeminiCliAgent` support custom +> tool definitions and execution. + +```ts +import { GeminiCliAgent, tool, z } from "@google/gemini-cli-sdk"; + +const addTool = tool({ + name: 'add', + description: 'add two numbers', + inputSchema: z.object({ + a: z.number().describe('first number to add'), + b: z.number().describe('second number to add'), + }), +}, (({a, b}) => ({result: a + b}),); + +const toolAgent = new GeminiCliAgent({ + tools: [addTool], +}); + +const result = await toolAgent.send("what is 23 + 79?"); +console.log(result.text); +``` + +Validation: + +- Model receives tool definition in prompt +- Model receives tool response after returning tool + +## `Custom Hooks` + +> **Status:** Not Implemented. + +SDK users can provide programmatic custom hooks + +```ts +import { GeminiCliAgent, hook, z } from '@google/gemini-cli-sdk'; +import { reformat } from './reformat.js'; + +const myHook = hook( + { + event: 'AfterTool', + name: 'reformat', + matcher: 'write_file', + }, + (hook, ctx) => { + const filePath = hook.toolInput.path; + + // void return is a no-op + if (!filePath.endsWith('.ts')) return; + + // ctx.fs gives us a filesystem interface that obeys Gemini CLI permissions/sandbox + const reformatted = await reformat(await ctx.fs.read(filePath)); + await ctx.fs.write(filePath, reformatted); + + // hooks return a payload instructing the agent how to proceed + return { + hookSpecificOutput: { + additionalContext: `Reformatted file ${filePath}, read again before modifying further.`, + }, + }; + }, +); +``` + +SDK Hooks can also run as standalone scripts to implement userland "command" +style hooks: + +```ts +import { hook } from "@google/gemini-cli-sdk"; + +// define a hook as above +const myHook = hook({...}, (hook) => {...}); +// calling runAsCommand parses stdin, calls action, uses appropriate exit code +// with output, but you get nice strong typings to guide your impl +myHook.runAsCommand(); +``` + +Validation (these are probably hardest to validate): + +- Test each type of hook and check that model api receives injected content +- Check global halt scenarios +- Check specific return types for each type of hook + +## `Custom Skills` + +> **Status:** Implemented. `skillDir` helper and `GeminiCliAgent` support +> loading skills from filesystem. + +Custom skills can be referenced by individual directories or by "skill roots" +(directories containing many skills). + +### Directory Structure + +``` +skill-dir/ + SKILL.md (Metadata and instructions) + tools/ (Optional directory for tools) + my-tool.js +``` + +### Usage + +```typescript +import { GeminiCliAgent, skillDir } from '@google/gemini-sdk'; + +const agent = new GeminiCliAgent({ + instructions: 'You are a helpful assistant.', + skills: [ + // Load a single skill from a directory + skillDir('./my-skill'), + // Load all skills found in subdirectories of this root + skillDir('./skills-collection'), + ], +}); +``` + +## `Subagents` + +> **Status:** Not Implemented. + +```ts +import { GeminiCliAgent, subagent } from "@google/gemini-cli"; + +const mySubagent = subagent({ + name: "my-subagent", + description: "when the subagent should be used", + + // simple prompt agent with static string or dynamic string + instructions: "the instructions", + instructions (prompt, ctx) => `can also be dynamic with context`, + + // OR (in an ideal world)... + + // pass a full standalone agent + agent: new GeminiCliAgent(...); +}); + +const agent = new GeminiCliAgent({ + subagents: [mySubagent] +}); +``` + +## `Extensions` + +> **Status:** Not Implemented. + +Potentially the most important feature of the Gemini CLI SDK is support for +extensions, which modularly encapsulate all of the primitives listed above: + +```ts +import { GeminiCliAgent, extension } from "@google/gemini-cli-sdk"; + +const myExtension = extension({ + name: "my-extension", + description: "...", + instructions: "THESE ARE CONCATENATED WITH OTHER AGENT +INSTRUCTIONS", + tools: [...], + skills: [...], + hooks: [...], + subagents: [...], +}); +``` + +## `ACP Mode` + +> **Status:** Not Implemented. + +The SDK will include a wrapper utility to interact with the agent via ACP +instead of the SDK's natural API. + +```ts +import { GeminiCliAgent } from "@google/gemini-cli-sdk"; +import { GeminiCliAcpServer } from "@google/gemini-cli-sdk/acp"; + +const server = new GeminiCliAcpServer(new GeminiCliAgent({...})); +server.start(); // calling start runs a stdio ACP server + +const client = server.connect({ + onMessage: (message) => { /* updates etc received here */ }, +}); +client.send({...clientMessage}); // e.g. a "session/prompt" message +``` + +## `Approvals / Policies` + +> **Status:** Not Implemented. + +TODO + +# `Implementation Guidance` + +## `Session Context` + +> **Status:** Implemented. `SessionContext` interface exists and is passed to +> tools. + +Whenever executing a tool, hook, command, or skill, a SessionContext object +should be passed as an additional argument after the arguments/payload. The +interface should look something like: + +```ts +export interface SessionContext { + // translations of existing common hook payload info + sessionId: string; + transcript: Message[]; + cwd: string; + timestamp: string; + + // helpers to access files and run shell commands while adhering to policies/validation + fs: AgentFilesystem; + shell: AgentShell; + // the agent and session are passed as context + agent: GeminiCliAgent; + session: GeminiCliSession; +} + +export interface AgentFilesystem { + readFile(path: string): Promise; + writeFile(path: string, content: string): Promise; + // consider others including delete, globbing, etc but read/write are bare minimum +} + +export interface AgentShell { + // simple promise-based execution that blocks until complete + exec( + cmd: string, + options?: AgentShellOptions, + ): Promise<{ + exitCode: number; + output: string; + stdout: string; + stderr: string; + }>; + start(cmd: string, options?: AgentShellOptions): AgentShellProcess; +} + +export interface AgentShellOptions { + env?: Record; + timeoutSeconds?: number; +} + +export interface AgentShellProcess { + // figure out how to have a streaming shell process here that supports stdin too + // investigate how Gemini CLI already does this +} +``` + +# `Notes` + +- To validate the SDK, it would be useful to have a robust way to mock the + underlying model API so that the tests could be closer to end-to-end but still + deterministic. +- Need to work in both Gemini-CLI-triggered approvals and optional + developer-initiated user prompts / HITL stuff. +- Need to think about how subagents inherit message context \- e.g. do they have + the same session id? +- Presumably the transcript is kept updated in memory and also persisted to disk + by default? + +# `Next Steps` + +Based on the current implementation status, we can proceed with: + +## Feature 3: Custom Hooks Support + +Implement support for loading and registering custom hooks. This involves adding +a `hooks` option to `GeminiCliAgentOptions`. + +**Tasks:** + +1. Define `Hook` interface and helper functions. +2. Add `hooks` option to `GeminiCliAgentOptions`. +3. Implement hook registration logic in `GeminiCliAgent`. + +IMPORTANT: Hook signatures should be strongly typed all the way through. You'll +need to create a mapping of the string event name to the request/response types. diff --git a/packages/sdk/examples/session-context.ts b/packages/sdk/examples/session-context.ts new file mode 100644 index 0000000000000000000000000000000000000000..704353efe089a05d8a62069600dcf4b27b942483 --- /dev/null +++ b/packages/sdk/examples/session-context.ts @@ -0,0 +1,73 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { GeminiCliAgent, tool, z } from '../src/index.js'; + +async function main() { + const getContextTool = tool( + { + name: 'get_context', + description: 'Get information about the current session context.', + inputSchema: z.object({}), + }, + async (_params, context) => { + if (!context) { + return { error: 'Context not available' }; + } + + console.log('Session Context Accessed:'); + console.log(`- Session ID: ${context.sessionId}`); + console.log(`- CWD: ${context.cwd}`); + console.log(`- Timestamp: ${context.timestamp}`); + + let fileContent = null; + try { + // Try to read a file (e.g., package.json in the CWD) + // Note: This relies on the agent running in a directory with package.json + fileContent = await context.fs.readFile('package.json'); + } catch (e) { + console.log(`- Could not read package.json: ${e}`); + } + + let shellOutput = null; + try { + // Try to run a simple shell command + const result = await context.shell.exec('echo "Hello from SDK Shell"'); + shellOutput = result.output.trim(); + } catch (e) { + console.log(`- Could not run shell command: ${e}`); + } + + return { + sessionId: context.sessionId, + cwd: context.cwd, + hasFsAccess: !!context.fs, + hasShellAccess: !!context.shell, + packageJsonExists: !!fileContent, + shellEcho: shellOutput, + }; + }, + ); + + const agent = new GeminiCliAgent({ + instructions: + 'You are a helpful assistant. Use the get_context tool to tell me about my environment.', + tools: [getContextTool], + // Set CWD to the package root so package.json exists + cwd: process.cwd(), + }); + + console.log("Sending prompt: 'What is my current session context?'"); + for await (const chunk of agent.sendStream( + 'What is my current session context?', + )) { + if (chunk.type === 'content') { + process.stdout.write(chunk.value || ''); + } + } +} + +main().catch(console.error); diff --git a/packages/sdk/examples/simple.ts b/packages/sdk/examples/simple.ts new file mode 100644 index 0000000000000000000000000000000000000000..6c2773b0c8a7c5ada003cb8522c4b1bc0be2eb0e --- /dev/null +++ b/packages/sdk/examples/simple.ts @@ -0,0 +1,38 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { GeminiCliAgent, tool, z } from '../src/index.js'; + +async function main() { + const myTool = tool( + { + name: 'add', + description: 'Add two numbers.', + inputSchema: z.object({ + a: z.number().describe('the first number'), + b: z.number().describe('the second number'), + }), + }, + async ({ a, b }) => { + console.log(`Tool 'add' called with a=${a}, b=${b}`); + return { result: a + b }; + }, + ); + + const agent = new GeminiCliAgent({ + instructions: 'Make sure to always talk like a pirate.', + tools: [myTool], + }); + + console.log("Sending prompt: 'add 5 + 6'"); + for await (const chunk of agent.sendStream( + 'add 5 + 6 and tell me a story involving the result', + )) { + console.log(JSON.stringify(chunk, null, 2)); + } +} + +main().catch(console.error); diff --git a/packages/sdk/index.ts b/packages/sdk/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..75f51214139056f9b6c38f44052ab1e93bb15f30 --- /dev/null +++ b/packages/sdk/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './src/index.js'; diff --git a/packages/sdk/package.json b/packages/sdk/package.json new file mode 100644 index 0000000000000000000000000000000000000000..6301cdae12e019cc33dbc2719e0abc4635fc7543 --- /dev/null +++ b/packages/sdk/package.json @@ -0,0 +1,36 @@ +{ + "name": "@google/gemini-cli-sdk", + "version": "0.62.0-nightly.20260915.gae28844fb", + "description": "Gemini CLI SDK", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "git+https://github.com/google-gemini/gemini-cli.git" + }, + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "scripts": { + "build": "node ../../scripts/build_package.js", + "lint": "eslint . --ext .ts,.tsx", + "format": "prettier --write .", + "test": "vitest run", + "test:ci": "vitest run", + "typecheck": "tsc --noEmit" + }, + "files": [ + "dist" + ], + "dependencies": { + "@google/gemini-cli-core": "file:../core", + "zod": "3.25.76", + "zod-to-json-schema": "3.25.1" + }, + "devDependencies": { + "typescript": "5.8.3", + "vitest": "3.2.4" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/sdk/src/agent.integration.test.ts b/packages/sdk/src/agent.integration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..93772ebce395b0693ba9d8420f5501feb3d7db43 --- /dev/null +++ b/packages/sdk/src/agent.integration.test.ts @@ -0,0 +1,172 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { GeminiCliAgent } from './agent.js'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Set this to true locally when you need to update snapshots +const RECORD_MODE = process.env['RECORD_NEW_RESPONSES'] === 'true'; + +const getGoldenPath = (name: string) => + path.resolve(__dirname, '../test-data', `${name}.json`); + +describe('GeminiCliAgent Integration', () => { + beforeEach(() => { + vi.stubEnv('GEMINI_API_KEY', 'test-api-key'); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + it('handles static instructions', async () => { + const goldenFile = getGoldenPath('agent-static-instructions'); + + const agent = new GeminiCliAgent({ + instructions: 'You are a pirate. Respond in pirate speak.', + model: 'gemini-2.0-flash', + recordResponses: RECORD_MODE ? goldenFile : undefined, + fakeResponses: RECORD_MODE ? undefined : goldenFile, + }); + + const session = agent.session(); + expect(session.id).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, + ); + const events = []; + const stream = session.sendStream('Say hello.'); + + for await (const event of stream) { + events.push(event); + } + + const textEvents = events.filter((e) => e.type === 'content'); + const responseText = textEvents + .map((e) => ('value' in e && typeof e.value === 'string' ? e.value : '')) + .join(''); + + // Expect pirate speak + expect(responseText.toLowerCase()).toMatch(/ahoy|matey|arrr/); + }, 30000); + + it('handles dynamic instructions', async () => { + const goldenFile = getGoldenPath('agent-dynamic-instructions'); + + let callCount = 0; + const agent = new GeminiCliAgent({ + instructions: (_ctx) => { + callCount++; + return `You are a helpful assistant. The secret number is ${callCount}. Always mention the secret number when asked.`; + }, + model: 'gemini-2.0-flash', + recordResponses: RECORD_MODE ? goldenFile : undefined, + fakeResponses: RECORD_MODE ? undefined : goldenFile, + }); + + const session = agent.session(); + + // First turn + const events1 = []; + const stream1 = session.sendStream('What is the secret number?'); + for await (const event of stream1) { + events1.push(event); + } + const responseText1 = events1 + .filter((e) => e.type === 'content') + .map((e) => ('value' in e && typeof e.value === 'string' ? e.value : '')) + .join(''); + + expect(responseText1).toContain('1'); + + // Second turn + const events2 = []; + const stream2 = session.sendStream('What is the secret number now?'); + for await (const event of stream2) { + events2.push(event); + } + const responseText2 = events2 + .filter((e) => e.type === 'content') + .map((e) => ('value' in e && typeof e.value === 'string' ? e.value : '')) + .join(''); + + expect(responseText2).toContain('2'); + }, 30000); + + it('resumes a session', async () => { + const goldenFile = getGoldenPath('agent-resume-session'); + + // Create initial session + const agent = new GeminiCliAgent({ + instructions: 'You are a memory test. Remember the word "BANANA".', + model: 'gemini-2.0-flash', + recordResponses: RECORD_MODE ? goldenFile : undefined, + fakeResponses: RECORD_MODE ? undefined : goldenFile, + }); + + const session1 = agent.session({ sessionId: 'resume-test-fixed-id' }); + const sessionId = session1.id; + const stream1 = session1.sendStream('What is the word?'); + for await (const _ of stream1) { + // consume stream + } + + // Resume session + // Allow some time for async writes if any + await new Promise((resolve) => setTimeout(resolve, 500)); + + const session2 = await agent.resumeSession(sessionId); + expect(session2.id).toBe(sessionId); + + const events2 = []; + const stream2 = session2.sendStream('What is the word again?'); + for await (const event of stream2) { + events2.push(event); + } + + const responseText = events2 + .filter((e) => e.type === 'content') + .map((e) => ('value' in e && typeof e.value === 'string' ? e.value : '')) + .join(''); + + expect(responseText).toContain('BANANA'); + }, 30000); + + it('throws on invalid instructions', () => { + // Missing instructions should be fine + // eslint-disable-next-line @typescript-eslint/no-explicit-any + expect(() => new GeminiCliAgent({} as any).session()).not.toThrow(); + + expect(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + new GeminiCliAgent({ instructions: 123 as any }).session(), + ).toThrow('Instructions must be a string or a function.'); + }); + + it('propagates errors from dynamic instructions', async () => { + const goldenFile = getGoldenPath('agent-static-instructions'); + const agent = new GeminiCliAgent({ + instructions: () => { + throw new Error('Dynamic instruction failure'); + }, + model: 'gemini-2.0-flash', + recordResponses: RECORD_MODE ? goldenFile : undefined, + fakeResponses: RECORD_MODE ? undefined : goldenFile, + }); + + const session = agent.session(); + const stream = session.sendStream('Say hello.'); + + await expect(async () => { + for await (const _event of stream) { + // Just consume the stream + } + }).rejects.toThrow('Dynamic instruction failure'); + }, 30000); +}); diff --git a/packages/sdk/src/agent.ts b/packages/sdk/src/agent.ts new file mode 100644 index 0000000000000000000000000000000000000000..d92ffd19f6ed70edfd6d948ed106fa119e74a789 --- /dev/null +++ b/packages/sdk/src/agent.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as path from 'node:path'; +import { + Storage, + createSessionId, + type ResumedSessionData, + type ConversationRecord, + loadConversationRecord, +} from '@google/gemini-cli-core'; + +import { GeminiCliSession } from './session.js'; +import type { GeminiCliAgentOptions } from './types.js'; + +/** + * The main entry point for the Gemini CLI SDK. + * + * An agent encapsulates configuration (instructions, tools, skills, model) + * and can create new sessions or resume existing ones. + * + * @example + * ```typescript + * const agent = new GeminiCliAgent({ + * instructions: 'You are a helpful coding assistant.', + * tools: [myTool], + * }); + * + * const session = agent.session(); + * await session.initialize(); + * + * for await (const event of session.sendStream('Hello!')) { + * console.log(event); + * } + * ``` + */ +export class GeminiCliAgent { + private options: GeminiCliAgentOptions; + + constructor(options: GeminiCliAgentOptions) { + this.options = options; + } + + /** + * Create a new conversation session. + * + * @param options - Optional session configuration. Pass `{ sessionId }` to + * use a specific session ID; otherwise a new one is generated. + * @returns A new {@link GeminiCliSession} instance. + */ + session(options?: { sessionId?: string }): GeminiCliSession { + const sessionId = options?.sessionId || createSessionId(); + return new GeminiCliSession(this.options, sessionId, this); + } + + /** + * Resume a previously created session by its ID. + * + * Looks up the session's conversation history from storage and replays it + * so the agent can continue the conversation. + * + * @param sessionId - The ID of the session to resume. + * @returns A {@link GeminiCliSession} with the prior conversation loaded. + * @throws {Error} If no sessions exist or the specified ID is not found. + */ + async resumeSession(sessionId: string): Promise { + const cwd = this.options.cwd || process.cwd(); + const storage = new Storage(cwd); + await storage.initialize(); + + let conversation: ConversationRecord | undefined; + let filePath: string | undefined; + + const sessions = await storage.listProjectChatFiles(); + + if (sessions.length === 0) { + throw new Error( + `No sessions found in ${path.join(storage.getProjectTempDir(), 'chats')}`, + ); + } + + const truncatedId = sessionId.slice(0, 8); + // Optimization: filenames include first 8 chars of sessionId. + // Filter sessions that might match. + const candidates = sessions.filter((s) => s.filePath.includes(truncatedId)); + + // If optimization fails (e.g. old files), check all? + // Assuming filenames always follow convention if created by this tool. + // But we can fallback to checking all if needed, but let's stick to candidates first. + // If candidates is empty, maybe fallback to all. + const filesToCheck = candidates.length > 0 ? candidates : sessions; + + for (const sessionFile of filesToCheck) { + const absolutePath = path.join( + storage.getProjectTempDir(), + sessionFile.filePath, + ); + const loaded = await loadConversationRecord(absolutePath); + if (loaded && loaded.sessionId === sessionId) { + conversation = loaded; + filePath = path.join(storage.getProjectTempDir(), sessionFile.filePath); + break; + } + } + + if (!conversation || !filePath) { + throw new Error(`Session with ID ${sessionId} not found`); + } + + const resumedData: ResumedSessionData = { + conversation, + filePath, + }; + + return new GeminiCliSession( + this.options, + conversation.sessionId, + this, + resumedData, + ); + } +} diff --git a/packages/sdk/src/fs.ts b/packages/sdk/src/fs.ts new file mode 100644 index 0000000000000000000000000000000000000000..f12d56780bd0b7beaa4fd05f29d4ed282cb0705f --- /dev/null +++ b/packages/sdk/src/fs.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Config as CoreConfig } from '@google/gemini-cli-core'; +import type { AgentFilesystem } from './types.js'; +import fs from 'node:fs/promises'; + +/** + * SDK implementation of {@link AgentFilesystem} that enforces path-based + * access policies from the core Config. + * + * Read operations return `null` when access is denied; write operations + * throw an error. + */ +export class SdkAgentFilesystem implements AgentFilesystem { + constructor(private readonly config: CoreConfig) {} + + async readFile(path: string): Promise { + const error = this.config.validatePathAccess(path, 'read'); + if (error) { + // For now, if access is denied, we can either throw or return null. + // Returning null makes sense for "file not found or readable". + return null; + } + try { + return await fs.readFile(path, 'utf-8'); + } catch { + return null; + } + } + + async writeFile(path: string, content: string): Promise { + const error = this.config.validatePathAccess(path, 'write'); + if (error) { + throw new Error(error); + } + await fs.writeFile(path, content, 'utf-8'); + } +} diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..91e7a080f0c55fad4b2513d2f88409e9216dd6b2 --- /dev/null +++ b/packages/sdk/src/index.ts @@ -0,0 +1,11 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './agent.js'; +export * from './session.js'; +export * from './tool.js'; +export * from './skills.js'; +export * from './types.js'; diff --git a/packages/sdk/src/session.test.ts b/packages/sdk/src/session.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..6148e3e8d3ca6f356f7915c2c7f2b8aaf7502074 --- /dev/null +++ b/packages/sdk/src/session.test.ts @@ -0,0 +1,332 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { GeminiCliSession } from './session.js'; +import type { GeminiCliAgent } from './agent.js'; +import type { GeminiCliAgentOptions } from './types.js'; + +// Mutable mock client so individual tests can override sendMessageStream +const mockClient = { + resumeChat: vi.fn().mockResolvedValue(undefined), + getHistory: vi.fn().mockReturnValue([]), + sendMessageStream: vi.fn().mockReturnValue((async function* () {})()), + updateSystemInstruction: vi.fn(), +}; + +// Mutable mock config so individual tests can spy on setUserMemory etc. +const mockConfig = { + initialize: vi.fn().mockResolvedValue(undefined), + refreshAuth: vi.fn().mockResolvedValue(undefined), + getSkillManager: vi.fn().mockReturnValue({ + getSkills: vi.fn().mockReturnValue([]), + addSkills: vi.fn(), + }), + getToolRegistry: vi.fn().mockReturnValue({ + getTool: vi.fn().mockReturnValue(null), + registerTool: vi.fn(), + unregisterTool: vi.fn(), + }), + getMessageBus: vi.fn().mockReturnValue({}), + getGeminiClient: vi.fn().mockReturnValue(mockClient), + getSessionId: vi.fn().mockReturnValue('mock-session-id'), + getWorkingDir: vi.fn().mockReturnValue('/tmp'), + setUserMemory: vi.fn(), +}; + +// Mock scheduleAgentTools at module level so tests can override it +const mockScheduleAgentTools = vi.fn().mockResolvedValue([]); + +// Mock @google/gemini-cli-core to avoid heavy filesystem/auth/telemetry setup +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + Config: vi.fn().mockImplementation(() => mockConfig), + getAuthTypeFromEnv: vi.fn().mockReturnValue(null), + scheduleAgentTools: (...args: unknown[]) => mockScheduleAgentTools(...args), + loadSkillsFromDir: vi.fn().mockResolvedValue([]), + ActivateSkillTool: class { + static Name = 'activate_skill'; + }, + PolicyDecision: actual.PolicyDecision, + }; +}); + +const mockAgent = {} as unknown as GeminiCliAgent; + +const baseOptions: GeminiCliAgentOptions = { + instructions: 'You are a helpful assistant.', +}; + +beforeEach(() => { + vi.clearAllMocks(); + // Reset sendMessageStream to empty stream by default + mockClient.sendMessageStream.mockReturnValue((async function* () {})()); + mockScheduleAgentTools.mockResolvedValue([]); +}); + +describe('GeminiCliSession constructor', () => { + it('accepts string instructions', () => { + expect( + () => new GeminiCliSession(baseOptions, 'session-1', mockAgent), + ).not.toThrow(); + }); + + it('accepts function instructions', () => { + const options: GeminiCliAgentOptions = { + instructions: async () => 'dynamic instructions', + }; + expect( + () => new GeminiCliSession(options, 'session-2', mockAgent), + ).not.toThrow(); + }); + + it('throws when instructions is an object (not string or function)', () => { + const options = { + instructions: { invalid: true }, + } as unknown as GeminiCliAgentOptions; + expect(() => new GeminiCliSession(options, 'session-3', mockAgent)).toThrow( + 'Instructions must be a string or a function.', + ); + }); + + it('throws when instructions is a number', () => { + const options = { + instructions: 42, + } as unknown as GeminiCliAgentOptions; + expect(() => new GeminiCliSession(options, 'session-4', mockAgent)).toThrow( + 'Instructions must be a string or a function.', + ); + }); + + it('throws when instructions is an array', () => { + const options = { + instructions: ['step1', 'step2'], + } as unknown as GeminiCliAgentOptions; + expect(() => new GeminiCliSession(options, 'session-5', mockAgent)).toThrow( + 'Instructions must be a string or a function.', + ); + }); +}); + +describe('GeminiCliSession id getter', () => { + it('returns the sessionId passed to the constructor', () => { + const session = new GeminiCliSession( + baseOptions, + 'my-session-id', + mockAgent, + ); + expect(session.id).toBe('my-session-id'); + }); + + it('returns different ids for different sessions', () => { + const s1 = new GeminiCliSession(baseOptions, 'session-a', mockAgent); + const s2 = new GeminiCliSession(baseOptions, 'session-b', mockAgent); + expect(s1.id).not.toBe(s2.id); + }); +}); + +describe('GeminiCliSession initialize()', () => { + it('initializes successfully with string instructions', async () => { + const session = new GeminiCliSession( + baseOptions, + 'session-init-1', + mockAgent, + ); + await expect(session.initialize()).resolves.toBeUndefined(); + }); + + it('is idempotent — calling initialize() twice does not throw', async () => { + const session = new GeminiCliSession( + baseOptions, + 'session-init-2', + mockAgent, + ); + await session.initialize(); + await expect(session.initialize()).resolves.toBeUndefined(); + }); + + it('initializes with empty tools array', async () => { + const options: GeminiCliAgentOptions = { ...baseOptions, tools: [] }; + const session = new GeminiCliSession(options, 'session-init-3', mockAgent); + await expect(session.initialize()).resolves.toBeUndefined(); + }); + + it('initializes with empty skills array', async () => { + const options: GeminiCliAgentOptions = { ...baseOptions, skills: [] }; + const session = new GeminiCliSession(options, 'session-init-4', mockAgent); + await expect(session.initialize()).resolves.toBeUndefined(); + }); + + it('initializes with custom model', async () => { + const options: GeminiCliAgentOptions = { + ...baseOptions, + model: 'gemini-2.0-flash', + }; + const session = new GeminiCliSession(options, 'session-init-5', mockAgent); + await expect(session.initialize()).resolves.toBeUndefined(); + }); + + it('initializes with custom cwd', async () => { + const options: GeminiCliAgentOptions = { + ...baseOptions, + cwd: '/custom/working/dir', + }; + const session = new GeminiCliSession(options, 'session-init-6', mockAgent); + await expect(session.initialize()).resolves.toBeUndefined(); + }); +}); + +// TODO(#24999): Mock uses getGeminiClient() method but session.ts expects geminiClient property. +describe.skip('GeminiCliSession sendStream()', () => { + it('auto-initializes if not yet initialized', async () => { + const session = new GeminiCliSession( + baseOptions, + 'session-stream-1', + mockAgent, + ); + const events = []; + for await (const event of session.sendStream('Hello')) { + events.push(event); + } + expect(events).toHaveLength(0); + }); + + it('completes cleanly when model returns no tool calls', async () => { + const session = new GeminiCliSession( + baseOptions, + 'session-stream-2', + mockAgent, + ); + await session.initialize(); + const events = []; + for await (const event of session.sendStream('Hello')) { + events.push(event); + } + expect(events).toHaveLength(0); + }); + + it('accepts an AbortSignal without throwing', async () => { + const session = new GeminiCliSession( + baseOptions, + 'session-stream-3', + mockAgent, + ); + const controller = new AbortController(); + const events = []; + for await (const event of session.sendStream('Hello', controller.signal)) { + events.push(event); + } + expect(events).toHaveLength(0); + }); + + it('executes tool call loop and sends function response back to model', async () => { + const { GeminiEventType } = await import('@google/gemini-cli-core'); + + // First call: yield a ToolCallRequest, then end + // Second call: empty stream (model is done after tool result) + let callCount = 0; + mockClient.sendMessageStream.mockImplementation(() => { + callCount++; + if (callCount === 1) { + return (async function* () { + yield { + type: GeminiEventType.ToolCallRequest, + value: { + callId: 'call-1', + name: 'testTool', + args: { input: 'value' }, + }, + }; + })(); + } + return (async function* () {})(); + }); + + mockScheduleAgentTools.mockResolvedValue([ + { + response: { + responseParts: [ + { + functionResponse: { + name: 'testTool', + response: { result: 'done' }, + }, + }, + ], + }, + }, + ]); + + const session = new GeminiCliSession( + baseOptions, + 'session-stream-4', + mockAgent, + ); + const events = []; + for await (const event of session.sendStream('Use the tool')) { + events.push(event); + } + + // The ToolCallRequest event should have been yielded to the caller + expect(events).toHaveLength(1); + expect(events[0].type).toBe(GeminiEventType.ToolCallRequest); + + // scheduleAgentTools should have been called with the tool call + expect(mockScheduleAgentTools).toHaveBeenCalledOnce(); + + // sendMessageStream called twice: once for prompt, once with tool result + expect(mockClient.sendMessageStream).toHaveBeenCalledTimes(2); + }); + + it('calls setUserMemory and updateSystemInstruction when instructions is a function', async () => { + const dynamicInstructions = vi + .fn() + .mockResolvedValue('updated instructions'); + const options: GeminiCliAgentOptions = { + instructions: dynamicInstructions, + }; + + const session = new GeminiCliSession( + options, + 'session-stream-5', + mockAgent, + ); + for await (const _event of session.sendStream('Hello')) { + // consume stream + } + + // The instructions function should have been called with a SessionContext + expect(dynamicInstructions).toHaveBeenCalledOnce(); + const context = dynamicInstructions.mock.calls[0][0]; + expect(context).toHaveProperty('sessionId'); + expect(context).toHaveProperty('transcript'); + expect(context).toHaveProperty('cwd'); + expect(context).toHaveProperty('timestamp'); + + // Config should have been updated with the new instructions + expect(mockConfig.setUserMemory).toHaveBeenCalledWith( + 'updated instructions', + ); + + // Client system instruction should have been refreshed + expect(mockClient.updateSystemInstruction).toHaveBeenCalledOnce(); + }); + + it('does not call setUserMemory when instructions is a string', async () => { + const session = new GeminiCliSession( + baseOptions, + 'session-stream-6', + mockAgent, + ); + for await (const _event of session.sendStream('Hello')) { + // consume stream + } + expect(mockConfig.setUserMemory).not.toHaveBeenCalled(); + expect(mockClient.updateSystemInstruction).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/sdk/src/session.ts b/packages/sdk/src/session.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c94a9b5c85e889750acb54e96dc113c65573596 --- /dev/null +++ b/packages/sdk/src/session.ts @@ -0,0 +1,316 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type AgentLoopContext, + Config, + type ConfigParameters, + AuthType, + PREVIEW_GEMINI_MODEL_AUTO, + GeminiEventType, + type ToolCallRequestInfo, + type ServerGeminiStreamEvent, + type GeminiClient, + type Content, + scheduleAgentTools, + getAuthTypeFromEnv, + type ToolRegistry, + loadSkillsFromDir, + ActivateSkillTool, + type ResumedSessionData, + PolicyDecision, +} from '@google/gemini-cli-core'; + +import { type Tool, SdkTool } from './tool.js'; +import { SdkAgentFilesystem } from './fs.js'; +import { SdkAgentShell } from './shell.js'; +import type { + SessionContext, + GeminiCliAgentOptions, + SystemInstructions, +} from './types.js'; +import type { SkillReference } from './skills.js'; +import type { GeminiCliAgent } from './agent.js'; + +/** + * Represents an interactive conversation session with a Gemini CLI agent. + * + * A session manages the conversation lifecycle: initialization, sending messages + * via streaming, handling tool calls, and maintaining conversation history. + * + * Create a session via {@link GeminiCliAgent.session} or resume one with + * {@link GeminiCliAgent.resumeSession}. + */ +export class GeminiCliSession { + private readonly config: Config; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly tools: Array>; + private readonly skillRefs: SkillReference[]; + private readonly instructions: SystemInstructions | undefined; + private client: GeminiClient | undefined; + private initialized = false; + + constructor( + options: GeminiCliAgentOptions, + private readonly sessionId: string, + private readonly agent: GeminiCliAgent, + private readonly resumedData?: ResumedSessionData, + ) { + this.instructions = options.instructions; + const cwd = options.cwd || process.cwd(); + this.tools = options.tools || []; + this.skillRefs = options.skills || []; + + let initialMemory = ''; + if (typeof this.instructions === 'string') { + initialMemory = this.instructions; + } else if (this.instructions && typeof this.instructions !== 'function') { + throw new Error('Instructions must be a string or a function.'); + } + + const configParams: ConfigParameters = { + sessionId: this.sessionId, + targetDir: cwd, + cwd, + debugMode: options.debug ?? false, + model: options.model || PREVIEW_GEMINI_MODEL_AUTO, + userMemory: initialMemory, + // Minimal config + enableHooks: false, + mcpEnabled: false, + extensionsEnabled: false, + recordResponses: options.recordResponses, + fakeResponses: options.fakeResponses, + skillsSupport: true, + adminSkillsEnabled: true, + policyEngineConfig: { + // TODO: Revisit this default when we have a mechanism for wiring up approvals + defaultDecision: PolicyDecision.ALLOW, + }, + }; + + this.config = new Config(configParams); + } + + /** + * The unique identifier for this session. + */ + get id(): string { + return this.sessionId; + } + + /** + * Initialize the session by setting up authentication, loading skills, + * and registering tools. Must be called before {@link sendStream}. + * + * This method is idempotent — calling it multiple times has no effect + * after the first successful initialization. + */ + async initialize(): Promise { + if (this.initialized) return; + + const authType = getAuthTypeFromEnv() || AuthType.COMPUTE_ADC; + + await this.config.refreshAuth(authType); + await this.config.initialize(); + + // Load additional skills from options + if (this.skillRefs.length > 0) { + const skillManager = this.config.getSkillManager(); + + const loadPromises = this.skillRefs.map(async (ref) => { + try { + if (ref.type === 'dir') { + return await loadSkillsFromDir(ref.path); + } + } catch (e) { + // TODO: refactor this to use a proper logger interface + // eslint-disable-next-line no-console + console.error(`Failed to load skills from ${ref.path}:`, e); + } + return []; + }); + + const loadedSkills = (await Promise.all(loadPromises)).flat(); + + if (loadedSkills.length > 0) { + skillManager.addSkills(loadedSkills); + } + } + + // Re-register ActivateSkillTool if we have skills + const skillManager = this.config.getSkillManager(); + if (skillManager.getSkills().length > 0) { + const loopContext: AgentLoopContext = this.config; + const registry = loopContext.toolRegistry; + const toolName = ActivateSkillTool.Name; + if (registry.getTool(toolName)) { + registry.unregisterTool(toolName); + } + registry.registerTool( + new ActivateSkillTool(this.config, loopContext.messageBus), + ); + } + + // Register tools + const loopContext2: AgentLoopContext = this.config; + const registry = loopContext2.toolRegistry; + const messageBus = loopContext2.messageBus; + + for (const toolDef of this.tools) { + const sdkTool = new SdkTool(toolDef, messageBus, this.agent, undefined); + registry.registerTool(sdkTool); + } + + this.client = loopContext2.geminiClient; + + if (this.resumedData) { + const history: Content[] = this.resumedData.conversation.messages.map( + (m) => { + const role = m.type === 'gemini' ? 'model' : 'user'; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + let parts: any[] = []; + if (Array.isArray(m.content)) { + parts = m.content; + } else if (m.content) { + parts = [{ text: String(m.content) }]; + } + return { role, parts }; + }, + ); + await this.client.resumeChat(history, this.resumedData); + } + + this.initialized = true; + } + + /** + * Send a prompt to the model and yield streaming events as they arrive. + * + * Handles the full agentic loop: sends the user prompt, streams model + * responses, executes any tool calls the model requests, and continues + * the loop until the model produces a final response with no tool calls. + * + * @param prompt - The user message to send. + * @param signal - Optional {@link AbortSignal} to cancel the stream. + * @yields {@link ServerGeminiStreamEvent} events as they are received from + * the model. + * + * @example + * ```typescript + * for await (const event of session.sendStream('Explain this code')) { + * if (event.type === GeminiEventType.ModelResponse) { + * process.stdout.write(event.value); + * } + * } + * ``` + */ + async *sendStream( + prompt: string, + signal?: AbortSignal, + ): AsyncGenerator { + if (!this.initialized || !this.client) { + await this.initialize(); + } + const client = this.client!; + const abortSignal = signal ?? new AbortController().signal; + const sessionId = this.config.getSessionId(); + + const fs = new SdkAgentFilesystem(this.config); + const shell = new SdkAgentShell(this.config); + + let request: Parameters[0] = [ + { text: prompt }, + ]; + + while (true) { + if (typeof this.instructions === 'function') { + const context: SessionContext = { + sessionId, + transcript: client.getHistory(), + cwd: this.config.getWorkingDir(), + timestamp: new Date().toISOString(), + fs, + shell, + agent: this.agent, + session: this, + }; + const newInstructions = await this.instructions(context); + this.config.setUserMemory(newInstructions); + client.updateSystemInstruction(); + } + + const stream = client.sendMessageStream(request, abortSignal, sessionId); + + const toolCallsToSchedule: ToolCallRequestInfo[] = []; + + for await (const event of stream) { + yield event; + if (event.type === GeminiEventType.ToolCallRequest) { + const toolCall = event.value; + let args = toolCall.args; + if (typeof args === 'string') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + args = JSON.parse(args); + } + toolCallsToSchedule.push({ + ...toolCall, + args, + isClientInitiated: false, + prompt_id: sessionId, + }); + } + } + + if (toolCallsToSchedule.length === 0) { + break; + } + + const transcript: readonly Content[] = client.getHistory(); + const context: SessionContext = { + sessionId, + transcript, + cwd: this.config.getWorkingDir(), + timestamp: new Date().toISOString(), + fs, + shell, + agent: this.agent, + session: this, + }; + + const loopContext: AgentLoopContext = this.config; + const originalRegistry = loopContext.toolRegistry; + const scopedRegistry: ToolRegistry = originalRegistry.clone(); + const originalGetTool = scopedRegistry.getTool.bind(scopedRegistry); + scopedRegistry.getTool = (name: string) => { + const tool = originalGetTool(name); + if (tool instanceof SdkTool) { + return tool.bindContext(context); + } + return tool; + }; + + const completedCalls = await scheduleAgentTools( + this.config, + toolCallsToSchedule, + { + schedulerId: sessionId, + toolRegistry: scopedRegistry, + signal: abortSignal, + }, + ); + + const functionResponses = completedCalls.flatMap( + (call) => call.response.responseParts, + ); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion + request = functionResponses as unknown as Parameters< + GeminiClient['sendMessageStream'] + >[0]; + } + } +} diff --git a/packages/sdk/src/shell.ts b/packages/sdk/src/shell.ts new file mode 100644 index 0000000000000000000000000000000000000000..827941edd88fe5a008cacc164a63bc5d2e16d754 --- /dev/null +++ b/packages/sdk/src/shell.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type AgentLoopContext, + ShellExecutionService, + ShellTool, + type Config as CoreConfig, +} from '@google/gemini-cli-core'; +import type { + AgentShell, + AgentShellResult, + AgentShellOptions, +} from './types.js'; + +/** + * SDK implementation of {@link AgentShell} that executes commands via the + * core ShellExecutionService, subject to the agent's security policies. + * + * Commands that require interactive confirmation will be rejected since + * no interactive session is available in headless SDK mode. + * + * @remarks In this implementation, stderr is combined into stdout by the + * underlying ShellExecutionService. As a result, the stderr field of the + * returned {@link AgentShellResult} will be empty, and both output and + * stdout will contain the combined output. + */ +export class SdkAgentShell implements AgentShell { + constructor(private readonly config: CoreConfig) {} + + async exec( + command: string, + options?: AgentShellOptions, + ): Promise { + const cwd = options?.cwd || this.config.getWorkingDir(); + const abortController = new AbortController(); + + // Use ShellTool to check policy + const loopContext: AgentLoopContext = this.config; + const shellTool = new ShellTool(this.config, loopContext.messageBus); + try { + const invocation = shellTool.build({ + command, + dir_path: cwd, + }); + + const confirmation = await invocation.shouldConfirmExecute( + abortController.signal, + ); + if (confirmation) { + throw new Error( + 'Command execution requires confirmation but no interactive session is available.', + ); + } + } catch (error) { + return { + output: '', + stdout: '', + stderr: '', + exitCode: 1, + error: error instanceof Error ? error : new Error(String(error)), + }; + } + + const handle = await ShellExecutionService.execute( + command, + cwd, + () => {}, // No-op output event handler for now + abortController.signal, + false, // shouldUseNodePty: false for headless execution + this.config.getShellExecutionConfig(), + ); + + const result = await handle.result; + + return { + output: result.output, + stdout: result.output, // ShellExecutionService combines stdout/stderr usually + stderr: '', // ShellExecutionService currently combines, so stderr is empty or mixed + exitCode: result.exitCode, + }; + } +} diff --git a/packages/sdk/src/skills.integration.test.ts b/packages/sdk/src/skills.integration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..caf871b89ee07ac473e78a8e869cb50027bc23e3 --- /dev/null +++ b/packages/sdk/src/skills.integration.test.ts @@ -0,0 +1,92 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { GeminiCliAgent } from './agent.js'; +import { skillDir } from './skills.js'; +import * as path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Set this to true locally when you need to update snapshots +const RECORD_MODE = process.env['RECORD_NEW_RESPONSES'] === 'true'; + +const getGoldenPath = (name: string) => + path.resolve(__dirname, '../test-data', `${name}.json`); + +const SKILL_DIR = path.resolve(__dirname, '../test-data/skills/pirate-skill'); +const SKILL_ROOT = path.resolve(__dirname, '../test-data/skills'); + +describe('GeminiCliAgent Skills Integration', () => { + it('loads and activates a skill from a directory', async () => { + const goldenFile = getGoldenPath('skill-dir-success'); + + const agent = new GeminiCliAgent({ + instructions: 'You are a helpful assistant.', + skills: [skillDir(SKILL_DIR)], + // If recording, use real model + record path. + // If testing, use auto model + fake path. + model: RECORD_MODE ? 'gemini-2.0-flash' : undefined, + recordResponses: RECORD_MODE ? goldenFile : undefined, + fakeResponses: RECORD_MODE ? undefined : goldenFile, + }); + + // 1. Ask to activate the skill + const events = []; + const session = agent.session(); + // The prompt explicitly asks to activate the skill by name + const stream = session.sendStream( + 'Activate the pirate-skill and then tell me a joke.', + ); + + for await (const event of stream) { + events.push(event); + } + + const textEvents = events.filter((e) => e.type === 'content'); + const responseText = textEvents + .map((e) => ('value' in e && typeof e.value === 'string' ? e.value : '')) + .join(''); + + // Expect pirate speak + expect(responseText.toLowerCase()).toContain('arrr'); + }, 120000); + + it('loads and activates a skill from a root', async () => { + const goldenFile = getGoldenPath('skill-root-success'); + + const agent = new GeminiCliAgent({ + instructions: 'You are a helpful assistant.', + skills: [skillDir(SKILL_ROOT)], + // If recording, use real model + record path. + // If testing, use auto model + fake path. + model: RECORD_MODE ? 'gemini-2.0-flash' : undefined, + recordResponses: RECORD_MODE ? goldenFile : undefined, + fakeResponses: RECORD_MODE ? undefined : goldenFile, + }); + + // 1. Ask to activate the skill + const events = []; + const session = agent.session(); + const stream = session.sendStream( + 'Activate the pirate-skill and confirm it is active.', + ); + + for await (const event of stream) { + events.push(event); + } + + const textEvents = events.filter((e) => e.type === 'content'); + const responseText = textEvents + .map((e) => ('value' in e && typeof e.value === 'string' ? e.value : '')) + .join(''); + + // Expect confirmation or pirate speak + expect(responseText.toLowerCase()).toContain('arrr'); + }, 120000); +}); diff --git a/packages/sdk/src/skills.ts b/packages/sdk/src/skills.ts new file mode 100644 index 0000000000000000000000000000000000000000..d1a7e23a8c53a3bb4de197a3417572ba2cd89a4d --- /dev/null +++ b/packages/sdk/src/skills.ts @@ -0,0 +1,22 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A reference to a skill directory that can be loaded by the agent. + * + * Skills extend the agent's capabilities by providing additional prompts, + * tools, and behaviors defined in a directory structure. + */ +export type SkillReference = { type: 'dir'; path: string }; + +/** + * Reference a directory containing skills. + * + * @param path Path to the skill directory + */ +export function skillDir(path: string): SkillReference { + return { type: 'dir', path }; +} diff --git a/packages/sdk/src/tool.integration.test.ts b/packages/sdk/src/tool.integration.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..a681470ab050a8a1be9622b6a847131f23e47512 --- /dev/null +++ b/packages/sdk/src/tool.integration.test.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { GeminiCliAgent } from './agent.js'; +import * as path from 'node:path'; +import { z } from 'zod'; +import { tool, ModelVisibleError } from './tool.js'; +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// Set this to true locally when you need to update snapshots +const RECORD_MODE = process.env['RECORD_NEW_RESPONSES'] === 'true'; + +const getGoldenPath = (name: string) => + path.resolve(__dirname, '../test-data', `${name}.json`); + +describe('GeminiCliAgent Tool Integration', () => { + it('handles tool execution success', async () => { + const goldenFile = getGoldenPath('tool-success'); + + const agent = new GeminiCliAgent({ + instructions: 'You are a helpful assistant.', + // If recording, use real model + record path. + // If testing, use auto model + fake path. + model: RECORD_MODE ? 'gemini-2.0-flash' : undefined, + recordResponses: RECORD_MODE ? goldenFile : undefined, + fakeResponses: RECORD_MODE ? undefined : goldenFile, + tools: [ + tool( + { + name: 'add', + description: 'Adds two numbers', + inputSchema: z.object({ a: z.number(), b: z.number() }), + }, + async ({ a, b }) => a + b, + ), + ], + }); + + const events = []; + const session = agent.session(); + const stream = session.sendStream('What is 5 + 3?'); + + for await (const event of stream) { + events.push(event); + } + + const textEvents = events.filter((e) => e.type === 'content'); + const responseText = textEvents + .map((e) => ('value' in e && typeof e.value === 'string' ? e.value : '')) + .join(''); + + expect(responseText).toContain('8'); + }, 20000); + + it('handles ModelVisibleError correctly', async () => { + const goldenFile = getGoldenPath('tool-error-recovery'); + + const agent = new GeminiCliAgent({ + instructions: 'You are a helpful assistant.', + model: RECORD_MODE ? 'gemini-2.0-flash' : undefined, + recordResponses: RECORD_MODE ? goldenFile : undefined, + fakeResponses: RECORD_MODE ? undefined : goldenFile, + tools: [ + tool( + { + name: 'failVisible', + description: 'Fails with a visible error if input is "fail"', + inputSchema: z.object({ input: z.string() }), + }, + async ({ input }) => { + if (input === 'fail') { + throw new ModelVisibleError('Tool failed visibly'); + } + return 'Success'; + }, + ), + ], + }); + + const events = []; + const session = agent.session(); + // Force the model to trigger the error first, then hopefully recover or at least acknowledge it. + // The prompt is crafted to make the model try 'fail' first. + const stream = session.sendStream( + 'Call the tool with "fail". If it fails, tell me the error message.', + ); + + for await (const event of stream) { + events.push(event); + } + + const textEvents = events.filter((e) => e.type === 'content'); + const responseText = textEvents + .map((e) => ('value' in e && typeof e.value === 'string' ? e.value : '')) + .join(''); + + // The model should see the error "Tool failed visibly" and report it back. + expect(responseText).toContain('Tool failed visibly'); + }, 20000); + + it('handles sendErrorsToModel: true correctly', async () => { + const goldenFile = getGoldenPath('tool-catchall-error'); + + const agent = new GeminiCliAgent({ + instructions: 'You are a helpful assistant.', + model: RECORD_MODE ? 'gemini-2.0-flash' : undefined, + recordResponses: RECORD_MODE ? goldenFile : undefined, + fakeResponses: RECORD_MODE ? undefined : goldenFile, + tools: [ + tool( + { + name: 'checkSystemStatus', + description: 'Checks the current system status', + inputSchema: z.object({}), + sendErrorsToModel: true, + }, + async () => { + throw new Error('Standard error caught'); + }, + ), + ], + }); + + const events = []; + const session = agent.session(); + const stream = session.sendStream( + 'Check the system status and report any errors.', + ); + + for await (const event of stream) { + events.push(event); + } + + const textEvents = events.filter((e) => e.type === 'content'); + const responseText = textEvents + .map((e) => ('value' in e && typeof e.value === 'string' ? e.value : '')) + .join(''); + + // The model should report the caught standard error. + expect(responseText.toLowerCase()).toContain('error'); + }, 20000); +}); diff --git a/packages/sdk/src/tool.test.ts b/packages/sdk/src/tool.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..d26a4835dff9ae8a8c8d550fe94ca009bbfa133c --- /dev/null +++ b/packages/sdk/src/tool.test.ts @@ -0,0 +1,149 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; +import { SdkTool, tool, ModelVisibleError } from './tool.js'; +import type { MessageBus } from '@google/gemini-cli-core'; + +// Mock MessageBus +const mockMessageBus = {} as unknown as MessageBus; + +describe('tool()', () => { + it('creates a tool definition with defaults', () => { + const definition = tool( + { + name: 'testTool', + description: 'A test tool', + inputSchema: z.object({ foo: z.string() }), + }, + async () => 'result', + ); + + expect(definition.name).toBe('testTool'); + expect(definition.description).toBe('A test tool'); + expect(definition.sendErrorsToModel).toBeUndefined(); + }); + + it('creates a tool definition with explicit configuration', () => { + const definition = tool( + { + name: 'testTool', + description: 'A test tool', + inputSchema: z.object({ foo: z.string() }), + sendErrorsToModel: true, + }, + async () => 'result', + ); + + expect(definition.sendErrorsToModel).toBe(true); + }); +}); + +describe('SdkTool Execution', () => { + it('executes successfully', async () => { + const definition = tool( + { + name: 'successTool', + description: 'Always succeeds', + inputSchema: z.object({ val: z.string() }), + }, + async ({ val }) => `Success: ${val}`, + ); + + const sdkTool = new SdkTool(definition, mockMessageBus); + const invocation = sdkTool.createInvocationWithContext( + { val: 'test' }, + mockMessageBus, + undefined, + ); + const result = await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + expect(result.llmContent).toBe('Success: test'); + expect(result.error).toBeUndefined(); + }); + + it('throws standard Error by default', async () => { + const definition = tool( + { + name: 'failTool', + description: 'Always fails', + inputSchema: z.object({}), + }, + async () => { + throw new Error('Standard error'); + }, + ); + + const sdkTool = new SdkTool(definition, mockMessageBus); + const invocation = sdkTool.createInvocationWithContext( + {}, + mockMessageBus, + undefined, + ); + + await expect( + invocation.execute({ abortSignal: new AbortController().signal }), + ).rejects.toThrow('Standard error'); + }); + + it('catches ModelVisibleError and returns ToolResult error', async () => { + const definition = tool( + { + name: 'visibleErrorTool', + description: 'Fails with visible error', + inputSchema: z.object({}), + }, + async () => { + throw new ModelVisibleError('Visible error'); + }, + ); + + const sdkTool = new SdkTool(definition, mockMessageBus); + const invocation = sdkTool.createInvocationWithContext( + {}, + mockMessageBus, + undefined, + ); + const result = await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe('Visible error'); + expect(result.llmContent).toContain('Error: Visible error'); + }); + + it('catches standard Error when sendErrorsToModel is true', async () => { + const definition = tool( + { + name: 'catchAllTool', + description: 'Catches all errors', + inputSchema: z.object({}), + sendErrorsToModel: true, + }, + async () => { + throw new Error('Standard error'); + }, + ); + + const sdkTool = new SdkTool(definition, mockMessageBus); + const invocation = sdkTool.createInvocationWithContext( + {}, + mockMessageBus, + undefined, + ); + const result = await invocation.execute({ + abortSignal: new AbortController().signal, + }); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toBe('Standard error'); + expect(result.llmContent).toContain('Error: Standard error'); + }); +}); diff --git a/packages/sdk/src/tool.ts b/packages/sdk/src/tool.ts new file mode 100644 index 0000000000000000000000000000000000000000..4b7a31935721a93f1b2be474c19feedf1b64d6d5 --- /dev/null +++ b/packages/sdk/src/tool.ts @@ -0,0 +1,235 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; +import { zodToJsonSchema } from 'zod-to-json-schema'; +import { + BaseDeclarativeTool, + BaseToolInvocation, + type ToolResult, + type ToolInvocation, + type ExecuteOptions, + Kind, + type MessageBus, +} from '@google/gemini-cli-core'; +import type { SessionContext } from './types.js'; + +export { z }; + +/** + * An error that, when thrown from a tool's action, will be visible to the + * Gemini model in the conversation. Useful for providing feedback to the + * model about why a tool failed so it can retry or adjust its approach. + */ +export class ModelVisibleError extends Error { + constructor(message: string | Error) { + super(message instanceof Error ? message.message : message); + this.name = 'ModelVisibleError'; + } +} + +/** + * The declarative definition of a tool, including its name, description, + * Zod input schema, and optional error-handling behavior. + * + * @typeParam T - The Zod schema type that validates the tool's input parameters. + */ +export interface ToolDefinition { + /** + * A unique name for the tool, used by the model to invoke it. + */ + name: string; + + /** + * A human-readable description of what the tool does. + * This is sent to the model to help it decide when to use the tool. + */ + description: string; + + /** + * A Zod schema that validates and type-checks the tool's input parameters. + */ + inputSchema: T; + + /** + * When `true`, any errors thrown by the tool's action will be sent back + * to the model as part of the conversation. Defaults to `false`. + */ + sendErrorsToModel?: boolean; +} + +/** + * A complete tool definition that combines a {@link ToolDefinition} with + * an executable action function. + * + * The action receives validated parameters (inferred from the Zod schema) + * and an optional {@link SessionContext}, and returns an arbitrary result + * that will be serialized and sent back to the model. + * + * @typeParam T - The Zod schema type that validates the tool's input parameters. + */ +export interface Tool extends ToolDefinition { + /** + * The function executed when the model invokes this tool. + * + * @param params - The validated input parameters, typed from the Zod schema. + * @param context - Optional session context providing access to filesystem, + * shell, and other session state. + * @returns A promise resolving to the tool's output, which will be + * serialized (to JSON if not already a string) and sent to the model. + */ + action: (params: z.infer, context?: SessionContext) => Promise; +} + +class SdkToolInvocation extends BaseToolInvocation< + z.infer, + ToolResult +> { + constructor( + params: z.infer, + messageBus: MessageBus, + private readonly action: ( + params: z.infer, + context?: SessionContext, + ) => Promise, + private readonly context: SessionContext | undefined, + toolName: string, + private readonly sendErrorsToModel: boolean = false, + ) { + super(params, messageBus, toolName); + } + + getDescription(): string { + return `Executing ${this._toolName}...`; + } + + async execute({ + abortSignal: _abortSignal, + updateOutput: _updateOutput, + }: ExecuteOptions): Promise { + try { + const result = await this.action(this.params, this.context); + const output = + typeof result === 'string' ? result : JSON.stringify(result, null, 2); + return { + llmContent: output, + returnDisplay: output, + }; + } catch (error) { + if (this.sendErrorsToModel || error instanceof ModelVisibleError) { + const errorMessage = + error instanceof Error ? error.message : String(error); + return { + llmContent: `Error: ${errorMessage}`, + returnDisplay: `Error: ${errorMessage}`, + error: { + message: errorMessage, + }, + }; + } + throw error; + } + } +} + +/** + * A wrapper that integrates an SDK {@link Tool} into the core tool registry. + * + * Handles parameter validation, execution, error handling (including + * {@link ModelVisibleError}), and context binding for tool invocations. + * + * @typeParam T - The Zod schema type that validates the tool's input parameters. + */ +export class SdkTool extends BaseDeclarativeTool< + z.infer, + ToolResult +> { + constructor( + private readonly definition: Tool, + messageBus: MessageBus, + _agent?: unknown, + private readonly context?: SessionContext, + ) { + super( + definition.name, + definition.name, + definition.description, + Kind.Other, + zodToJsonSchema(definition.inputSchema), + messageBus, + ); + } + + bindContext(context: SessionContext): SdkTool { + return new SdkTool(this.definition, this.messageBus, undefined, context); + } + + createInvocationWithContext( + params: z.infer, + messageBus: MessageBus, + context: SessionContext | undefined, + toolName?: string, + ): ToolInvocation, ToolResult> { + return new SdkToolInvocation( + params, + messageBus, + this.definition.action, + context || this.context, + toolName || this.name, + this.definition.sendErrorsToModel, + ); + } + + protected createInvocation( + params: z.infer, + messageBus: MessageBus, + toolName?: string, + ): ToolInvocation, ToolResult> { + return new SdkToolInvocation( + params, + messageBus, + this.definition.action, + this.context, + toolName || this.name, + this.definition.sendErrorsToModel, + ); + } +} + +/** + * Helper function to create a {@link Tool} by combining a definition and an action. + * + * @typeParam T - The Zod schema type for the tool's input parameters. + * @param definition - The tool's name, description, and input schema. + * @param action - The async function to execute when the tool is invoked. + * @returns A complete {@link Tool} object ready to be passed to + * {@link GeminiCliAgentOptions.tools}. + * + * @example + * ```typescript + * import { z, tool } from '@google/gemini-cli-sdk'; + * + * const myTool = tool( + * { + * name: 'get_weather', + * description: 'Get the current weather for a location', + * inputSchema: z.object({ city: z.string() }), + * }, + * async (params) => { + * return `Weather in ${params.city}: Sunny, 25°C`; + * }, + * ); + * ``` + */ +export function tool( + definition: ToolDefinition, + action: (params: z.infer, context?: SessionContext) => Promise, +): Tool { + return { + ...definition, + action, + }; +} diff --git a/packages/sdk/src/types.ts b/packages/sdk/src/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..bad8f4dedcf8e665151b5df5caf005b4b3a40d71 --- /dev/null +++ b/packages/sdk/src/types.ts @@ -0,0 +1,241 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Content } from '@google/gemini-cli-core'; +import type { Tool } from './tool.js'; +import type { SkillReference } from './skills.js'; +import type { GeminiCliAgent } from './agent.js'; +import type { GeminiCliSession } from './session.js'; + +/** + * System instructions for a Gemini CLI agent. + * + * Can be either a static string or a function that receives the current + * session context and returns a string (or a promise of one), allowing + * dynamic instructions that change based on conversation state. + * + * @issue-16272/packages/core/coverage/lcov-report/src/utils/security.ts.html WARNING: If using a dynamic function, ensure that any data from the + * session context is sanitized (e.g., removing newlines, ']', and escaping '<', '>') + * before being included in the returned instructions to prevent prompt injection. + */ +export type SystemInstructions = + | string + | ((context: SessionContext) => string | Promise); + +/** + * Configuration options for creating a {@link GeminiCliAgent}. + */ +export interface GeminiCliAgentOptions { + /** + * System instructions that define the agent's behavior. + * Can be a static string or a dynamic function that receives session context. + * + * @issue-16272/packages/core/coverage/lcov-report/src/utils/security.ts.html WARNING: If using a dynamic function, sanitize all input from the + * SessionContext (e.g., removing newlines, ']', and escaping '<', '>') to prevent prompt injection. + */ + instructions: SystemInstructions; + + /** + * Custom tools to register with the agent. + * Each tool is defined using a Zod schema for input validation. + */ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + tools?: Array>; + + /** + * Skill directories to load into the agent's skill set. + */ + skills?: SkillReference[]; + + /** + * The Gemini model to use for this agent. + * Defaults to the auto-selected model if not specified. + */ + model?: string; + + /** + * The working directory for the agent. + * Defaults to `process.cwd()` if not specified. + */ + cwd?: string; + + /** + * Whether to enable debug mode for verbose logging. + * Defaults to `false`. + */ + debug?: boolean; + + /** + * File path to record agent responses to for debugging/replay. + */ + recordResponses?: string; + + /** + * File path to load fake/resimulated responses from for testing. + */ + fakeResponses?: string; +} + +/** + * A virtual filesystem interface available to agents during tool execution. + * + * Provides sandboxed read/write access to files, subject to the agent's + * configured path access policies. + * + * Note: Implementations must internally validate and sanitize file paths to + * prevent path traversal attacks (e.g., checking for '..' or null bytes) + * using robust functions like resolveToRealPath. + */ +export interface AgentFilesystem { + /** + * Read the contents of a file. + * + * @param path - Absolute or relative path to the file. + * @returns The file contents as a UTF-8 string, or `null` if the file + * does not exist or access is denied. + */ + readFile(path: string): Promise; + + /** + * Write content to a file. + * + * @param path - Absolute or relative path to the file. + * @param content - The content to write. + * @throws {Error} If write access is denied by the agent's policy. + */ + writeFile(path: string, content: string): Promise; +} + +/** + * Options for configuring shell command execution via {@link AgentShell.exec}. + */ +export interface AgentShellOptions { + /** + * Environment variables to set for the command execution. + * These are merged with the default environment. + */ + env?: Record; + + /** + * Maximum time in seconds to wait for the command to complete. + */ + timeoutSeconds?: number; + + /** + * Working directory in which to execute the command. + * Defaults to the agent's configured working directory. + */ + cwd?: string; +} + +/** + * The result of a shell command execution. + */ +export interface AgentShellResult { + /** + * The exit code of the process, or `null` if the process was killed + * or did not exit normally. + */ + exitCode: number | null; + + /** + * The combined stdout and stderr output of the command. + */ + output: string; + + /** + * The standard output stream content. + */ + stdout: string; + + /** + * The standard error stream content. + */ + stderr: string; + + /** + * An error object if the command failed to execute or was rejected + * by policy. + */ + error?: Error; +} + +/** + * A shell interface for executing commands within an agent's sandboxed environment. + * + * Commands are subject to the agent's security policies and may be rejected + * if they require interactive confirmation. + */ +export interface AgentShell { + /** + * Execute a shell command. + * + * @issue-16272/packages/core/coverage/lcov-report/src/utils/security.ts.html WARNING: Ensure the command string is properly sanitized and does + * not contain unvalidated user or LLM input to prevent command injection. + * + * @param cmd - The command string to execute. + * @param options - Optional execution configuration. + * @returns A promise resolving to the command result. + */ + exec(cmd: string, options?: AgentShellOptions): Promise; +} + +/** + * Contextual information about the current session, passed to tools and + * dynamic system instruction functions. + * + * Provides access to session metadata, conversation history, filesystem, + * shell, and the parent agent/session instances. + */ +export interface SessionContext { + /** + * Unique identifier for the current session. + */ + sessionId: string; + + /** + * Read-only transcript of the conversation so far, including user + * messages and model responses. + */ + transcript: readonly Content[]; + + /** + * The current working directory of the session. + */ + cwd: string; + + /** + * ISO 8601 timestamp of when this context was created. + */ + timestamp: string; + + /** + * Virtual filesystem for reading and writing files within the agent's + * sandbox. + * + * @issue-16272/packages/core/coverage/lcov-report/src/utils/security.ts.html WARNING: This provides full access to the agent's filesystem. + * Ensure tools using this are trusted and validate their inputs. + */ + fs: AgentFilesystem; + + /** + * Shell interface for executing commands within the agent's sandbox. + * + * @issue-16272/packages/core/coverage/lcov-report/src/utils/security.ts.html WARNING: This provides full access to the agent's shell. + * Any tool receiving this context can execute arbitrary commands. + */ + shell: AgentShell; + + /** + * The parent agent that owns this session. + */ + agent: GeminiCliAgent; + + /** + * The current session instance. + */ + session: GeminiCliSession; +} diff --git a/packages/sdk/test-data/agent-async-instructions.json b/packages/sdk/test-data/agent-async-instructions.json new file mode 100644 index 0000000000000000000000000000000000000000..833467ad84ea48afa1a54aad75b8438a9d5a38a3 --- /dev/null +++ b/packages/sdk/test-data/agent-async-instructions.json @@ -0,0 +1,4 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9831,"totalTokenCount":9831,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9831}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7098,"candidatesTokenCount":8,"totalTokenCount":7106,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7098}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9848,"totalTokenCount":9848,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9848}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7113,"candidatesTokenCount":8,"totalTokenCount":7121,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7113}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9853,"totalTokenCount":9853,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9853}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7120,"candidatesTokenCount":8,"totalTokenCount":7128,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7120}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9870,"totalTokenCount":9870,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9870}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7135,"candidatesTokenCount":8,"totalTokenCount":7143,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7135}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} diff --git a/packages/sdk/test-data/agent-dynamic-instructions.json b/packages/sdk/test-data/agent-dynamic-instructions.json new file mode 100644 index 0000000000000000000000000000000000000000..cbafd272069bcd2ffdd6cb402dd864f543a1daf0 --- /dev/null +++ b/packages/sdk/test-data/agent-dynamic-instructions.json @@ -0,0 +1,24 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9831,"totalTokenCount":9831,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9831}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7098,"candidatesTokenCount":8,"totalTokenCount":7106,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7098}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9848,"totalTokenCount":9848,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9848}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7113,"candidatesTokenCount":8,"totalTokenCount":7121,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7113}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9853,"totalTokenCount":9853,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9853}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7120,"candidatesTokenCount":8,"totalTokenCount":7128,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7120}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9870,"totalTokenCount":9870,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9870}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7135,"candidatesTokenCount":8,"totalTokenCount":7143,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7135}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10028,"totalTokenCount":10028,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10028}]}},{"candidates":[{"content":{"parts":[{"text":" 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7193,"candidatesTokenCount":7,"totalTokenCount":7200,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7193}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10044,"totalTokenCount":10044,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10044}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7207,"candidatesTokenCount":7,"totalTokenCount":7214,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7207}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" secret number is 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" secret number is 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":8,"totalTokenCount":7226,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" secret number is 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10039,"totalTokenCount":10039,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10039}]}},{"candidates":[{"content":{"parts":[{"text":" secret number is 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7204,"candidatesTokenCount":7,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7204}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10055,"totalTokenCount":10055,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10055}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":7,"totalTokenCount":7225,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10123,"totalTokenCount":10123,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10123}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7188,"candidatesTokenCount":8,"totalTokenCount":7196,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7188}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10140,"totalTokenCount":10140,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10140}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7203,"candidatesTokenCount":8,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7203}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10123,"totalTokenCount":10123,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10123}]}},{"candidates":[{"content":{"parts":[{"text":" 1.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7188,"candidatesTokenCount":8,"totalTokenCount":7196,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7188}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10140,"totalTokenCount":10140,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10140}]}},{"candidates":[{"content":{"parts":[{"text":" 2.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7203,"candidatesTokenCount":8,"totalTokenCount":7211,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7203}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10112,"totalTokenCount":10112,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10112}]}},{"candidates":[{"content":{"parts":[{"text":" 1."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7177,"candidatesTokenCount":7,"totalTokenCount":7184,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7177}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The secret number is"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10128,"totalTokenCount":10128,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10128}]}},{"candidates":[{"content":{"parts":[{"text":" 2."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7191,"candidatesTokenCount":7,"totalTokenCount":7198,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7191}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":7}]}}]} diff --git a/packages/sdk/test-data/agent-resume-session.json b/packages/sdk/test-data/agent-resume-session.json new file mode 100644 index 0000000000000000000000000000000000000000..f9d521898edf57920958e309dfd89dcfa6f56c91 --- /dev/null +++ b/packages/sdk/test-data/agent-resume-session.json @@ -0,0 +1,2 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"BAN"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10103,"totalTokenCount":10103,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10103}]}},{"candidates":[{"content":{"parts":[{"text":"ANA\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7168,"candidatesTokenCount":3,"totalTokenCount":7171,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7168}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"BANANA\n"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10124,"totalTokenCount":10124,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10124}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7187,"candidatesTokenCount":3,"totalTokenCount":7190,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7187}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} diff --git a/packages/sdk/test-data/agent-static-instructions.json b/packages/sdk/test-data/agent-static-instructions.json new file mode 100644 index 0000000000000000000000000000000000000000..216960da38273074f41b8f0aa85002f94c7cc9cc --- /dev/null +++ b/packages/sdk/test-data/agent-static-instructions.json @@ -0,0 +1,4 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9828,"totalTokenCount":9828,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9828}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to chart a course through the code?"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7095,"candidatesTokenCount":15,"totalTokenCount":7110,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7095}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":15}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10109,"totalTokenCount":10109,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10109}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to plunder the codebase, are ye?\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7174,"candidatesTokenCount":16,"totalTokenCount":7190,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7174}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":16}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10109,"totalTokenCount":10109,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10109}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to shiver some timbers and get to work?\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7174,"candidatesTokenCount":18,"totalTokenCount":7192,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7174}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":18}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ah"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10098,"totalTokenCount":10098,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10098}]}},{"candidates":[{"content":{"parts":[{"text":"oy, matey! Ready to chart a course through these here files, are we?\n"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10098,"totalTokenCount":10098,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10098}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7163,"candidatesTokenCount":20,"totalTokenCount":7183,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7163}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":20}]}}]} diff --git a/packages/sdk/test-data/skill-dir-success.json b/packages/sdk/test-data/skill-dir-success.json new file mode 100644 index 0000000000000000000000000000000000000000..63a72670939fff02617ee7389a1e524bfeda6a2d --- /dev/null +++ b/packages/sdk/test-data/skill-dir-success.json @@ -0,0 +1,8 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7160,"candidatesTokenCount":8,"totalTokenCount":7168,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7160}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Arrr, why"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10048,"totalTokenCount":10048,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10048}]}},{"candidates":[{"content":{"parts":[{"text":" don't pirates play poker? Because they always have a hidden ace up their sleeves"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10048,"totalTokenCount":10048,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10048}]}},{"candidates":[{"content":{"parts":[{"text":"!\\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7284,"candidatesTokenCount":23,"totalTokenCount":7307,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7284}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":23}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7185,"candidatesTokenCount":8,"totalTokenCount":7193,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7185}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I am"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10179,"totalTokenCount":10179,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10179}]}},{"candidates":[{"content":{"parts":[{"text":" sorry, I cannot activate the skill at this time because it requires user confirmation."}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10179,"totalTokenCount":10179,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10179}]}},{"candidates":[{"content":{"parts":[{"text":" Would you like me to proceed with telling you a joke without activating the skill?"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10179,"totalTokenCount":10179,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10179}]}},{"candidates":[{"content":{"parts":[{"text":"\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7218,"candidatesTokenCount":35,"totalTokenCount":7253,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7218}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":35}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7185,"candidatesTokenCount":8,"totalTokenCount":7193,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7185}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Arrr, why"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10275,"totalTokenCount":10275,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10275}]}},{"candidates":[{"content":{"parts":[{"text":" don't pirates play cards? Because someone's always standin' on the"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10275,"totalTokenCount":10275,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10275}]}},{"candidates":[{"content":{"parts":[{"text":" deck!\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7309,"candidatesTokenCount":24,"totalTokenCount":7333,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7309}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":24}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7174,"candidatesTokenCount":8,"totalTokenCount":7182,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7174}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10264,"totalTokenCount":10264,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10264}]}},{"candidates":[{"content":{"parts":[{"text":"rr, why don't pirates play cards? Because someone always be standin' on the"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10264,"totalTokenCount":10264,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10264}]}},{"candidates":[{"content":{"parts":[{"text":" deck!\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7298,"candidatesTokenCount":23,"totalTokenCount":7321,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7298}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":23}]}}]} diff --git a/packages/sdk/test-data/skill-root-success.json b/packages/sdk/test-data/skill-root-success.json new file mode 100644 index 0000000000000000000000000000000000000000..4fe1e3fe6ea8cfd023afdb7c6fe7ac51efb4ca70 --- /dev/null +++ b/packages/sdk/test-data/skill-root-success.json @@ -0,0 +1,8 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7170,"candidatesTokenCount":8,"totalTokenCount":7178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7170}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10058,"totalTokenCount":10058,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10058}]}},{"candidates":[{"content":{"parts":[{"text":"rr, the pirate skill be active, matey!"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7294,"candidatesTokenCount":12,"totalTokenCount":7306,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7294}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":12}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":8,"totalTokenCount":7192,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10178,"totalTokenCount":10178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10178}]}},{"candidates":[{"content":{"parts":[{"text":" am unable to activate the skill without user confirmation. I will await further instructions.\n"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10178,"totalTokenCount":10178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10178}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7217,"candidatesTokenCount":18,"totalTokenCount":7235,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7217}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":18}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":8,"totalTokenCount":7192,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10274,"totalTokenCount":10274,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10274}]}},{"candidates":[{"content":{"parts":[{"text":"rr, the pirate-skill be active, aye!\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7308,"candidatesTokenCount":13,"totalTokenCount":7321,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7308}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":13}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"activate_skill","args":{"name":"pirate-skill"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7173,"candidatesTokenCount":8,"totalTokenCount":7181,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7173}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":8}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"Ar"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10263,"totalTokenCount":10263,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10263}]}},{"candidates":[{"content":{"parts":[{"text":"rr, pirate skill activated, aye!"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7297,"candidatesTokenCount":9,"totalTokenCount":7306,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7297}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":9}]}}]} diff --git a/packages/sdk/test-data/skills/pirate-skill/SKILL.md b/packages/sdk/test-data/skills/pirate-skill/SKILL.md new file mode 100644 index 0000000000000000000000000000000000000000..6c7100f47e0e7e2ccc54d6725451226397c764f4 --- /dev/null +++ b/packages/sdk/test-data/skills/pirate-skill/SKILL.md @@ -0,0 +1,5 @@ +--- +name: pirate-skill +description: Speak like a pirate. +--- +You are a pirate. Respond to everything in pirate speak. Always mention "Arrr". \ No newline at end of file diff --git a/packages/sdk/test-data/tool-catchall-error.json b/packages/sdk/test-data/tool-catchall-error.json new file mode 100644 index 0000000000000000000000000000000000000000..8572c8be42d7f93014b28db2a322707403cea1d1 --- /dev/null +++ b/packages/sdk/test-data/tool-catchall-error.json @@ -0,0 +1,8 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7070,"candidatesTokenCount":3,"totalTokenCount":7073,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7070}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The system status check"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9850,"totalTokenCount":9850,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9850}]}},{"candidates":[{"content":{"parts":[{"text":" returned an error. It says `Error: Standard error caught`."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7082,"candidatesTokenCount":17,"totalTokenCount":7099,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7082}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":17}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":3,"totalTokenCount":7187,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I am unable to"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10183,"totalTokenCount":10183,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10183}]}},{"candidates":[{"content":{"parts":[{"text":" check the system status because it requires user confirmation in interactive mode. Is there anything else I"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10183,"totalTokenCount":10183,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10183}]}},{"candidates":[{"content":{"parts":[{"text":" can help with?"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7213,"candidatesTokenCount":26,"totalTokenCount":7239,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7213}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":26}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":3,"totalTokenCount":7187,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The system status check"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10164,"totalTokenCount":10164,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10164}]}},{"candidates":[{"content":{"parts":[{"text":" reported an error: \"Standard error caught\".\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7194,"candidatesTokenCount":14,"totalTokenCount":7208,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7194}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":14}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"checkSystemStatus","args":{}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7184,"candidatesTokenCount":3,"totalTokenCount":7187,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7184}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":3}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"There"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10164,"totalTokenCount":10164,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10164}]}},{"candidates":[{"content":{"parts":[{"text":" is an error reported in the system status.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7194,"candidatesTokenCount":11,"totalTokenCount":7205,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7194}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":11}]}}]} diff --git a/packages/sdk/test-data/tool-error-recovery.json b/packages/sdk/test-data/tool-error-recovery.json new file mode 100644 index 0000000000000000000000000000000000000000..9b17bf52d37867aac981cb8f13f6b6b2fcc0e5c4 --- /dev/null +++ b/packages/sdk/test-data/tool-error-recovery.json @@ -0,0 +1,8 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7073,"candidatesTokenCount":4,"totalTokenCount":7077,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7073}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9867,"totalTokenCount":9867,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9867}]}},{"candidates":[{"content":{"parts":[{"text":" tool failed visibly with the error message: \"Error: Tool failed visibly\"."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7085,"candidatesTokenCount":16,"totalTokenCount":7101,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7085}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":16}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7198,"candidatesTokenCount":4,"totalTokenCount":7202,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7198}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10210,"totalTokenCount":10210,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10210}]}},{"candidates":[{"content":{"parts":[{"text":" tool execution for \"failVisible\" requires user confirmation, which is not supported in non-"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10210,"totalTokenCount":10210,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10210}]}},{"candidates":[{"content":{"parts":[{"text":"interactive mode."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7226,"candidatesTokenCount":22,"totalTokenCount":7248,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7226}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":22}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7198,"candidatesTokenCount":4,"totalTokenCount":7202,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7198}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10192,"totalTokenCount":10192,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10192}]}},{"candidates":[{"content":{"parts":[{"text":" tool failed visibly. The error message is \"Tool failed visibly\"."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7208,"candidatesTokenCount":14,"totalTokenCount":7222,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7208}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":14}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"failVisible","args":{"input":"fail"}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7187,"candidatesTokenCount":4,"totalTokenCount":7191,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7187}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":4}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"The tool failed with"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10181,"totalTokenCount":10181,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10181}]}},{"candidates":[{"content":{"parts":[{"text":" the error message \"Tool failed visibly\"."}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7197,"candidatesTokenCount":12,"totalTokenCount":7209,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7197}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":12}]}}]} diff --git a/packages/sdk/test-data/tool-success.json b/packages/sdk/test-data/tool-success.json new file mode 100644 index 0000000000000000000000000000000000000000..d90d09cd0de80e762b5efcadbbfa99df575b50ce --- /dev/null +++ b/packages/sdk/test-data/tool-success.json @@ -0,0 +1,8 @@ +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"a":5,"b":3}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7045,"candidatesTokenCount":5,"totalTokenCount":7050,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7045}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"8"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":9849,"totalTokenCount":9849,"promptTokensDetails":[{"modality":"TEXT","tokenCount":9849}]}},{"candidates":[{"content":{"parts":[{"text":""}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7053,"candidatesTokenCount":1,"totalTokenCount":7054,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7053}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":1}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"b":3,"a":5}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7179,"candidatesTokenCount":5,"totalTokenCount":7184,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7179}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"I"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10204,"totalTokenCount":10204,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10204}]}},{"candidates":[{"content":{"parts":[{"text":" am unable to execute the add tool in non-interactive mode.\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7206,"candidatesTokenCount":15,"totalTokenCount":7221,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7206}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":15}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"a":5,"b":3}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7179,"candidatesTokenCount":5,"totalTokenCount":7184,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7179}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"8"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7187,"candidatesTokenCount":1,"totalTokenCount":7188,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7187}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":1}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"functionCall":{"name":"add","args":{"a":5,"b":3}}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7168,"candidatesTokenCount":5,"totalTokenCount":7173,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7168}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":5}]}}]} +{"method":"generateContentStream","response":[{"candidates":[{"content":{"parts":[{"text":"8"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10174,"totalTokenCount":10174,"promptTokensDetails":[{"modality":"TEXT","tokenCount":10174}]}},{"candidates":[{"content":{"parts":[{"text":"\n"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":7176,"candidatesTokenCount":2,"totalTokenCount":7178,"promptTokensDetails":[{"modality":"TEXT","tokenCount":7176}],"candidatesTokensDetails":[{"modality":"TEXT","tokenCount":2}]}}]} diff --git a/packages/sdk/tsconfig.json b/packages/sdk/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..2cd4d6ea73ede4b5360a78b479c7708607b92ee5 --- /dev/null +++ b/packages/sdk/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "composite": true, + "lib": ["DOM", "DOM.Iterable", "ES2023"], + "types": ["node", "vitest/globals"] + }, + "include": ["index.ts", "src/**/*.ts", "package.json"], + "exclude": ["node_modules", "dist"], + "references": [{ "path": "../core" }] +} diff --git a/packages/sdk/vitest.config.ts b/packages/sdk/vitest.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..08cdff095c48fec35b8e23523aec5952549893db --- /dev/null +++ b/packages/sdk/vitest.config.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + }, +}); diff --git a/packages/test-utils/GEMINI.md b/packages/test-utils/GEMINI.md new file mode 100644 index 0000000000000000000000000000000000000000..f378270fbd0b204bfb5e63196a051caeb22ba0e9 --- /dev/null +++ b/packages/test-utils/GEMINI.md @@ -0,0 +1,68 @@ +# Gemini CLI Test Utils (`@google/gemini-cli-test-utils`) + +Shared test utilities used across the monorepo. This is a private package — not +published to npm. + +## Key Modules + +- `src/test-rig.ts`: The primary test rig for spinning up end-to-end CLI + sessions with mock responses. +- `src/file-system-test-helpers.ts`: Helpers for creating temporary file system + fixtures. +- `src/mock-utils.ts`: Common mock utilities. +- `src/test-mcp-server.ts`: Helper for building test MCP servers for tests. +- `src/test-mcp-server-template.mjs`: Generic template script for running + isolated MCP processes. + +## Test MCP Servers + +The `TestRig` provides a fully isolated, compliant way to test tool triggers and +workflows using local test MCP servers. This isolates your tests from live API +endpoints and rate-limiting. + +### Usage + +1. **Programmatic Builder:** + + ```typescript + import { TestMcpServerBuilder } from '@google/gemini-cli-test-utils'; + + const builder = new TestMcpServerBuilder('weather-server').addTool( + 'get_weather', + 'Get weather', + 'It is rainy', + ); + + rig.addTestMcpServer('weather-server', builder.build()); + ``` + +2. **Predefined configurations via JSON:** Place a configuration file in + `packages/test-utils/assets/test-servers/google-workspace.json` and load it + by title: + + ```typescript + rig.addTestMcpServer('workspace-server', 'google-workspace'); + ``` + + **JSON Format Structure (`TestMcpConfig`):** + + ```json + { + "name": "string (Fallback server name)", + "tools": [ + { + "name": "string (Tool execution name)", + "description": "string (Helpful summary for router)", + "inputSchema": { + "type": "object", + "properties": { ... } + }, + "response": "string | object (The forced reply payload)" + } + ] + } + ``` + +## Usage + +Import from `@google/gemini-cli-test-utils` in test files across the monorepo. diff --git a/packages/test-utils/index.ts b/packages/test-utils/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d69ad16868a11c83aedbe0e9a9379aaf45faa50f --- /dev/null +++ b/packages/test-utils/index.ts @@ -0,0 +1,7 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './src/file-system-test-helpers.js'; diff --git a/packages/test-utils/package.json b/packages/test-utils/package.json new file mode 100644 index 0000000000000000000000000000000000000000..cc7040fd37b7abb4c30e8864e98853d5499296da --- /dev/null +++ b/packages/test-utils/package.json @@ -0,0 +1,25 @@ +{ + "name": "@google/gemini-cli-test-utils", + "version": "0.62.0-nightly.20260915.gae28844fb", + "private": true, + "main": "src/index.ts", + "license": "Apache-2.0", + "type": "module", + "scripts": { + "build": "node ../../scripts/build_package.js", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@google/gemini-cli-core": "file:../core", + "@lydell/node-pty": "1.1.0", + "asciichart": "1.5.25", + "strip-ansi": "7.1.2", + "vitest": "3.2.4" + }, + "devDependencies": { + "typescript": "5.8.3" + }, + "engines": { + "node": ">=20" + } +} diff --git a/packages/test-utils/tsconfig.json b/packages/test-utils/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..ee9b84b1b40177a10b8261b85e5ec3c439662de4 --- /dev/null +++ b/packages/test-utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "dist", + "lib": ["DOM", "DOM.Iterable", "ES2021"], + "composite": true, + "types": ["node"] + }, + "include": ["index.ts", "src/**/*.ts", "src/**/*.json"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/test-utils/vitest.config.ts b/packages/test-utils/vitest.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..9022219f5ad66ef6d3485b3957a912ea568ad2d8 --- /dev/null +++ b/packages/test-utils/vitest.config.ts @@ -0,0 +1,23 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + reporters: ['default', 'junit'], + silent: true, + outputFile: { + junit: 'junit.xml', + }, + poolOptions: { + threads: { + minThreads: 8, + maxThreads: 16, + }, + }, + }, +}); diff --git a/packages/vscode-ide-companion/.vscode/launch.json b/packages/vscode-ide-companion/.vscode/launch.json new file mode 100644 index 0000000000000000000000000000000000000000..ba6ff76ca4a72f16aa9865734b50f0e8c7b9f571 --- /dev/null +++ b/packages/vscode-ide-companion/.vscode/launch.json @@ -0,0 +1,13 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "${defaultBuildTask}" + } + ] +} diff --git a/packages/vscode-ide-companion/.vscode/tasks.json b/packages/vscode-ide-companion/.vscode/tasks.json new file mode 100644 index 0000000000000000000000000000000000000000..34edf970fde1943807ad1be168c9cba16e5ea37a --- /dev/null +++ b/packages/vscode-ide-companion/.vscode/tasks.json @@ -0,0 +1,18 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "watch", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "presentation": { + "reveal": "never" + }, + "group": { + "kind": "build", + "isDefault": true + } + } + ] +} diff --git a/packages/vscode-ide-companion/.vscodeignore b/packages/vscode-ide-companion/.vscodeignore new file mode 100644 index 0000000000000000000000000000000000000000..e74d0536472d185ccf42eb43c0b1cd1687f8e201 --- /dev/null +++ b/packages/vscode-ide-companion/.vscodeignore @@ -0,0 +1,7 @@ +** +!dist/ +../ +../../ +!LICENSE +!NOTICES.txt +!assets/ diff --git a/packages/vscode-ide-companion/GEMINI.md b/packages/vscode-ide-companion/GEMINI.md new file mode 100644 index 0000000000000000000000000000000000000000..6825e1157590a7e4002e6cce938d5b1364de0234 --- /dev/null +++ b/packages/vscode-ide-companion/GEMINI.md @@ -0,0 +1,23 @@ +# Gemini CLI VS Code Companion (`gemini-cli-vscode-ide-companion`) + +VS Code extension that pairs with Gemini CLI, providing direct IDE workspace +access to the CLI agent. + +## Architecture + +- `src/extension.ts`: Extension activation and lifecycle. +- `src/ide-server.ts`: Local server exposing IDE capabilities to the CLI. +- `src/diff-manager.ts`: Diff viewing and application. +- `src/open-files-manager.ts`: Tracks and exposes open editor files. +- `src/utils/`: Shared utility functions. + +## Development + +- Requires VS Code `^1.99.0`. +- Build: `npm run build` (uses esbuild). +- Launch via VS Code's "Run Extension" debug configuration. + +## Testing + +- Run tests: `npm test -w gemini-cli-vscode-ide-companion` +- Tests use standard Vitest patterns alongside VS Code test APIs. diff --git a/packages/vscode-ide-companion/LICENSE b/packages/vscode-ide-companion/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..346b3f959b5dbc759f888655866dc1dcd657d99f --- /dev/null +++ b/packages/vscode-ide-companion/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2025 Google LLC + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt new file mode 100644 index 0000000000000000000000000000000000000000..b5d591229519df9042ed76d1677bcfa6d55b0175 --- /dev/null +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -0,0 +1,2352 @@ +This file contains third-party software notices and license terms. + +============================================================ +@modelcontextprotocol/sdk@1.23.0 +(git+https://github.com/modelcontextprotocol/typescript-sdk.git) + +MIT License + +Copyright (c) 2024 Anthropic, PBC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +ajv@6.14.0 +(https://github.com/ajv-validator/ajv.git) + +The MIT License (MIT) + +Copyright (c) 2015-2017 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + + +============================================================ +fast-deep-equal@3.1.3 +(git+https://github.com/epoberezkin/fast-deep-equal.git) + +MIT License + +Copyright (c) 2017 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +fast-json-stable-stringify@2.1.0 +(git://github.com/epoberezkin/fast-json-stable-stringify.git) + +This software is released under the MIT license: + +Copyright (c) 2017 Evgeny Poberezkin +Copyright (c) 2013 James Halliday + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +json-schema-traverse@0.4.1 +(git+https://github.com/epoberezkin/json-schema-traverse.git) + +MIT License + +Copyright (c) 2017 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +uri-js@4.4.1 +(http://github.com/garycourt/uri-js) + +Copyright 2011 Gary Court. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY GARY COURT "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL GARY COURT OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +The views and conclusions contained in the software and documentation are those of the authors and should not be interpreted as representing official policies, either expressed or implied, of Gary Court. + + +============================================================ +punycode@2.3.1 +(https://github.com/mathiasbynens/punycode.js.git) + +Copyright Mathias Bynens + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +ajv-formats@3.0.1 +(git+https://github.com/ajv-validator/ajv-formats.git) + +MIT License + +Copyright (c) 2020 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +content-type@1.0.5 +(No repository found) + +(The MIT License) + +Copyright (c) 2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +cors@2.8.5 +(No repository found) + +(The MIT License) + +Copyright (c) 2013 Troy Goode + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +object-assign@4.1.1 +(No repository found) + +The MIT License (MIT) + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +============================================================ +vary@1.1.2 +(No repository found) + +(The MIT License) + +Copyright (c) 2014-2017 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +cross-spawn@7.0.6 +(git@github.com:moxystudio/node-cross-spawn.git) + +The MIT License (MIT) + +Copyright (c) 2018 Made With MOXY Lda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +============================================================ +path-key@3.1.1 +(No repository found) + +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +shebang-command@2.0.0 +(No repository found) + +MIT License + +Copyright (c) Kevin Mårtensson (github.com/kevva) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +shebang-regex@3.0.0 +(No repository found) + +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +which@5.0.0 +(git+https://github.com/npm/node-which.git) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +isexe@3.1.5 +(No repository found) + +# Blue Oak Model License + +Version 1.0.0 + +## Purpose + +This license gives everyone as much permission to work with +this software as possible, while protecting contributors +from liability. + +## Acceptance + +In order to receive this license, you must agree to its +rules. The rules of this license are both obligations +under that agreement and conditions to your license. +You must not do anything with this software that triggers +a rule that you cannot or will not follow. + +## Copyright + +Each contributor licenses you to do everything with this +software that would otherwise infringe that contributor's +copyright in it. + +## Notices + +You must ensure that everyone who gets a copy of +any part of this software from you, with or without +changes, also gets the text of this license or a link to +. + +## Excuse + +If anyone notifies you in writing that you have not +complied with [Notices](#notices), you can keep your +license by taking all practical steps to comply within 30 +days after the notice. If you do not do so, your license +ends immediately. + +## Patent + +Each contributor licenses you to do everything with this +software that would otherwise infringe any patent claims +they can license or become able to license. + +## Reliability + +No contributor can revoke this license. + +## No Liability + +***As far as the law allows, this software comes as is, +without any warranty or condition, and no contributor +will be liable to anyone for any damages related to this +software or this license, under any kind of legal claim.*** + + +============================================================ +eventsource@3.0.7 +(git://git@github.com/EventSource/eventsource.git) + +The MIT License + +Copyright (c) EventSource GitHub organisation + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +eventsource-parser@3.0.3 +(git+ssh://git@github.com/rexxars/eventsource-parser.git) + +MIT License + +Copyright (c) 2025 Espen Hovlandsdal + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +express@5.1.0 +(No repository found) + +(The MIT License) + +Copyright (c) 2009-2014 TJ Holowaychuk +Copyright (c) 2013-2014 Roman Shtylman +Copyright (c) 2014-2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +accepts@2.0.0 +(No repository found) + +(The MIT License) + +Copyright (c) 2014 Jonathan Ong +Copyright (c) 2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +mime-types@3.0.2 +(No repository found) + +(The MIT License) + +Copyright (c) 2014 Jonathan Ong +Copyright (c) 2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +mime-db@1.54.0 +(No repository found) + +(The MIT License) + +Copyright (c) 2014 Jonathan Ong +Copyright (c) 2015-2022 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +negotiator@1.0.0 +(No repository found) + +(The MIT License) + +Copyright (c) 2012-2014 Federico Romero +Copyright (c) 2012-2014 Isaac Z. Schlueter +Copyright (c) 2014-2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +body-parser@2.2.2 +(No repository found) + +(The MIT License) + +Copyright (c) 2014 Jonathan Ong +Copyright (c) 2014-2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +bytes@3.1.2 +(No repository found) + +(The MIT License) + +Copyright (c) 2012-2014 TJ Holowaychuk +Copyright (c) 2015 Jed Watson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +debug@4.4.3 +(git://github.com/debug-js/debug.git) + +(The MIT License) + +Copyright (c) 2014-2017 TJ Holowaychuk +Copyright (c) 2018-2021 Josh Junon + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software +and associated documentation files (the 'Software'), to deal in the Software without restriction, +including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, +and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial +portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT +LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + + +============================================================ +ms@2.1.3 +(No repository found) + +The MIT License (MIT) + +Copyright (c) 2020 Vercel, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +http-errors@2.0.1 +(No repository found) + + +The MIT License (MIT) + +Copyright (c) 2014 Jonathan Ong me@jongleberry.com +Copyright (c) 2016 Douglas Christopher Wilson doug@somethingdoug.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +============================================================ +depd@2.0.0 +(No repository found) + +(The MIT License) + +Copyright (c) 2014-2018 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +inherits@2.0.4 +(No repository found) + +The ISC License + +Copyright (c) Isaac Z. Schlueter + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND +FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +PERFORMANCE OF THIS SOFTWARE. + + + +============================================================ +setprototypeof@1.2.0 +(https://github.com/wesleytodd/setprototypeof.git) + +Copyright (c) 2015, Wes Todd + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION +OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN +CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +statuses@2.0.2 +(No repository found) + + +The MIT License (MIT) + +Copyright (c) 2014 Jonathan Ong +Copyright (c) 2016 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +============================================================ +toidentifier@1.0.1 +(No repository found) + +MIT License + +Copyright (c) 2016 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +iconv-lite@0.6.3 +(git://github.com/ashtuchkin/iconv-lite.git) + +Copyright (c) 2011 Alexander Shtuchkin + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + + +============================================================ +safer-buffer@2.1.2 +(git+https://github.com/ChALkeR/safer-buffer.git) + +MIT License + +Copyright (c) 2018 Nikita Skovoroda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +on-finished@2.4.1 +(No repository found) + +(The MIT License) + +Copyright (c) 2013 Jonathan Ong +Copyright (c) 2014 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +ee-first@1.1.1 +(No repository found) + + +The MIT License (MIT) + +Copyright (c) 2014 Jonathan Ong me@jongleberry.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +============================================================ +qs@6.14.2 +(https://github.com/ljharb/qs.git) + +BSD 3-Clause License + +Copyright (c) 2014, Nathan LaFreniere and other [contributors](https://github.com/ljharb/qs/graphs/contributors) +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + +============================================================ +side-channel@1.1.0 +(git+https://github.com/ljharb/side-channel.git) + +MIT License + +Copyright (c) 2019 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +es-errors@1.3.0 +(git+https://github.com/ljharb/es-errors.git) + +MIT License + +Copyright (c) 2024 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +object-inspect@1.13.4 +(git://github.com/inspect-js/object-inspect.git) + +MIT License + +Copyright (c) 2013 James Halliday + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +side-channel-list@1.0.0 +(git+https://github.com/ljharb/side-channel-list.git) + +MIT License + +Copyright (c) 2024 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +side-channel-map@1.0.1 +(git+https://github.com/ljharb/side-channel-map.git) + +MIT License + +Copyright (c) 2024 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +call-bound@1.0.4 +(git+https://github.com/ljharb/call-bound.git) + +MIT License + +Copyright (c) 2024 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +call-bind-apply-helpers@1.0.2 +(git+https://github.com/ljharb/call-bind-apply-helpers.git) + +MIT License + +Copyright (c) 2024 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +function-bind@1.1.2 +(https://github.com/Raynos/function-bind.git) + +Copyright (c) 2013 Raynos. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + + +============================================================ +get-intrinsic@1.3.0 +(git+https://github.com/ljharb/get-intrinsic.git) + +MIT License + +Copyright (c) 2020 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +es-define-property@1.0.1 +(git+https://github.com/ljharb/es-define-property.git) + +MIT License + +Copyright (c) 2024 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +es-object-atoms@1.1.1 +(git+https://github.com/ljharb/es-object-atoms.git) + +MIT License + +Copyright (c) 2024 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +get-proto@1.0.1 +(git+https://github.com/ljharb/get-proto.git) + +MIT License + +Copyright (c) 2025 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +dunder-proto@1.0.1 +(git+https://github.com/es-shims/dunder-proto.git) + +MIT License + +Copyright (c) 2024 ECMAScript Shims + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +gopd@1.2.0 +(git+https://github.com/ljharb/gopd.git) + +MIT License + +Copyright (c) 2022 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +has-symbols@1.1.0 +(git://github.com/inspect-js/has-symbols.git) + +MIT License + +Copyright (c) 2016 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +hasown@2.0.2 +(git+https://github.com/inspect-js/hasOwn.git) + +MIT License + +Copyright (c) Jordan Harband and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +math-intrinsics@1.1.0 +(git+https://github.com/es-shims/math-intrinsics.git) + +MIT License + +Copyright (c) 2024 ECMAScript Shims + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +side-channel-weakmap@1.0.2 +(git+https://github.com/ljharb/side-channel-weakmap.git) + +MIT License + +Copyright (c) 2019 Jordan Harband + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +raw-body@3.0.2 +(No repository found) + +The MIT License (MIT) + +Copyright (c) 2013-2014 Jonathan Ong +Copyright (c) 2014-2022 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +============================================================ +unpipe@1.0.0 +(No repository found) + +(The MIT License) + +Copyright (c) 2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +type-is@2.0.1 +(No repository found) + +(The MIT License) + +Copyright (c) 2014 Jonathan Ong +Copyright (c) 2014-2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +media-typer@1.1.0 +(No repository found) + +(The MIT License) + +Copyright (c) 2014-2017 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +content-disposition@1.0.1 +(No repository found) + +(The MIT License) + +Copyright (c) 2014-2017 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +cookie@0.7.2 +(No repository found) + +(The MIT License) + +Copyright (c) 2012-2014 Roman Shtylman +Copyright (c) 2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + + +============================================================ +cookie-signature@1.2.2 +(https://github.com/visionmedia/node-cookie-signature.git) + +(The MIT License) + +Copyright (c) 2012–2024 LearnBoost and other contributors; + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +encodeurl@2.0.0 +(No repository found) + +(The MIT License) + +Copyright (c) 2016 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +escape-html@1.0.3 +(No repository found) + +(The MIT License) + +Copyright (c) 2012-2013 TJ Holowaychuk +Copyright (c) 2015 Andreas Lubbe +Copyright (c) 2015 Tiancheng "Timothy" Gu + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +etag@1.8.1 +(No repository found) + +(The MIT License) + +Copyright (c) 2014-2016 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +finalhandler@2.1.1 +(No repository found) + +(The MIT License) + +Copyright (c) 2014-2022 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +parseurl@1.3.3 +(No repository found) + + +(The MIT License) + +Copyright (c) 2014 Jonathan Ong +Copyright (c) 2014-2017 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +fresh@2.0.0 +(No repository found) + +(The MIT License) + +Copyright (c) 2012 TJ Holowaychuk +Copyright (c) 2016-2017 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +merge-descriptors@2.0.0 +(No repository found) + +MIT License + +Copyright (c) Jonathan Ong +Copyright (c) Douglas Christopher Wilson +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +once@1.4.0 +(git://github.com/isaacs/once) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +wrappy@1.0.2 +(https://github.com/npm/wrappy) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +proxy-addr@2.0.7 +(No repository found) + +(The MIT License) + +Copyright (c) 2014-2016 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +forwarded@0.2.0 +(No repository found) + +(The MIT License) + +Copyright (c) 2014-2017 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +ipaddr.js@1.9.1 +(No repository found) + +Copyright (C) 2011-2017 whitequark + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +============================================================ +range-parser@1.2.1 +(No repository found) + +(The MIT License) + +Copyright (c) 2012-2014 TJ Holowaychuk +Copyright (c) 2015-2016 Douglas Christopher Wilson { + console.log('[watch] build started'); + }); + build.onEnd((result) => { + result.errors.forEach(({ text, location }) => { + console.error(`✘ [ERROR] ${text}`); + console.error( + ` ${location.file}:${location.line}:${location.column}:`, + ); + }); + console.log('[watch] build finished'); + }); + }, +}; + +async function main() { + const ctx = await esbuild.context({ + entryPoints: ['src/extension.ts'], + bundle: true, + format: 'cjs', + minify: production, + sourcemap: !production, + sourcesContent: false, + platform: 'node', + outfile: 'dist/extension.cjs', + external: ['vscode'], + logLevel: 'silent', + banner: { + js: `const import_meta = { url: require('url').pathToFileURL(__filename).href };`, + }, + define: { + 'import.meta.url': 'import_meta.url', + 'process.env.NODE_ENV': JSON.stringify( + production ? 'production' : 'development', + ), + }, + alias: { + punycode: 'punycode/', + }, + plugins: [ + /* add to the end of plugins array */ + esbuildProblemMatcherPlugin, + ], + loader: { '.node': 'file', '.wasm': 'binary' }, + }); + if (watch) { + await ctx.watch(); + } else { + await ctx.rebuild(); + await ctx.dispose(); + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/packages/vscode-ide-companion/package.json b/packages/vscode-ide-companion/package.json new file mode 100644 index 0000000000000000000000000000000000000000..1647eb46dd8cf6142cd1fa01a544a8b37e100058 --- /dev/null +++ b/packages/vscode-ide-companion/package.json @@ -0,0 +1,145 @@ +{ + "name": "gemini-cli-vscode-ide-companion", + "displayName": "Gemini CLI Companion", + "description": "Enable Gemini CLI with direct access to your IDE workspace.", + "version": "0.62.0-nightly.20260915.gae28844fb", + "publisher": "google", + "icon": "assets/icon.png", + "repository": { + "type": "git", + "url": "https://github.com/google-gemini/gemini-cli.git", + "directory": "packages/vscode-ide-companion" + }, + "engines": { + "vscode": "^1.99.0" + }, + "license": "LICENSE", + "preview": true, + "categories": [ + "AI" + ], + "keywords": [ + "gemini-cli", + "gemini cli", + "gemini", + "gemini code", + "cli", + "ide integration", + "ide companion" + ], + "activationEvents": [ + "onStartupFinished" + ], + "contributes": { + "configuration": { + "title": "Gemini CLI", + "properties": { + "gemini-cli.debug.logging.enabled": { + "type": "boolean", + "default": false, + "description": "Enable detailed logging for debugging the Gemini CLI Companion." + } + } + }, + "languages": [ + { + "id": "gemini-diff-editable" + } + ], + "commands": [ + { + "command": "gemini.diff.accept", + "title": "Gemini CLI: Accept Diff", + "icon": "$(check)" + }, + { + "command": "gemini.diff.cancel", + "title": "Gemini CLI: Close Diff Editor", + "icon": "$(close)" + }, + { + "command": "gemini-cli.runGeminiCLI", + "title": "Gemini CLI: Run" + }, + { + "command": "gemini-cli.showNotices", + "title": "Gemini CLI: View Third-Party Notices" + } + ], + "menus": { + "commandPalette": [ + { + "command": "gemini.diff.accept", + "when": "gemini.diff.isVisible" + }, + { + "command": "gemini.diff.cancel", + "when": "gemini.diff.isVisible" + } + ], + "editor/title": [ + { + "command": "gemini.diff.accept", + "when": "gemini.diff.isVisible", + "group": "navigation" + }, + { + "command": "gemini.diff.cancel", + "when": "gemini.diff.isVisible", + "group": "navigation" + } + ] + }, + "keybindings": [ + { + "command": "gemini.diff.accept", + "key": "ctrl+s", + "when": "gemini.diff.isVisible" + }, + { + "command": "gemini.diff.accept", + "key": "cmd+s", + "when": "gemini.diff.isVisible" + } + ] + }, + "main": "./dist/extension.cjs", + "type": "module", + "scripts": { + "prepackage": "npm run generate:notices && npm run check-types && npm run lint && npm run build:prod", + "build": "npm run build:dev", + "build:dev": "node esbuild.js", + "build:prod": "node esbuild.js --production", + "generate:notices": "node ./scripts/generate-notices.js", + "prepare": "npm run generate:notices", + "check-types": "tsc --noEmit", + "lint": "eslint src", + "watch": "npm-run-all2 -p watch:*", + "watch:esbuild": "node esbuild.js --watch", + "watch:tsc": "tsc --noEmit --watch --project tsconfig.json", + "package": "vsce package --no-dependencies", + "test": "vitest run", + "test:ci": "vitest run --coverage", + "validate:notices": "node ./scripts/validate-notices.js" + }, + "devDependencies": { + "@types/cors": "2.8.19", + "@types/express": "5.0.3", + "@types/node": "20.x", + "@types/vscode": "1.99.0", + "@typescript-eslint/eslint-plugin": "8.31.1", + "@typescript-eslint/parser": "8.31.1", + "@vscode/vsce": "3.6.0", + "esbuild": "0.25.3", + "eslint": "9.25.1", + "npm-run-all2": "8.0.2", + "typescript": "5.8.3", + "vitest": "3.2.4" + }, + "dependencies": { + "@modelcontextprotocol/sdk": "1.23.0", + "cors": "2.8.5", + "express": "5.1.0", + "zod": "3.25.76" + } +} diff --git a/packages/vscode-ide-companion/scripts/check-vscode-release.js b/packages/vscode-ide-companion/scripts/check-vscode-release.js new file mode 100644 index 0000000000000000000000000000000000000000..f2a45d1dd62c66a26797588a6f78a8a3f0a4838c --- /dev/null +++ b/packages/vscode-ide-companion/scripts/check-vscode-release.js @@ -0,0 +1,99 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { execSync } from 'node:child_process'; + +function checkRelease() { + try { + // Step 1: Find the commit hash of the last release + const bucketUri = 'gs://gemini-cli-vscode-extension/release/1p/signed'; + const gcloudOutput = execSync( + `gcloud storage ls --recursive ${bucketUri}`, + { encoding: 'utf-8' }, + ); + const files = gcloudOutput.trim().split('\n'); + const vsixFiles = files.filter((file) => + /signed-gemini-cli-vscode-ide-companion-\d+\.\d+\.\d+-[a-f0-9]{7}\.vsix$/.test( + file, + ), + ); + + if (vsixFiles.length === 0) { + console.error('No .vsix files found in the bucket.'); + process.exit(1); + } + + vsixFiles.sort(); + const latestFile = vsixFiles[vsixFiles.length - 1]; + const fileName = latestFile.split('/').pop(); + const match = + /signed-gemini-cli-vscode-ide-companion-(\d+\.\d+\.\d+)-([a-f0-9]{7})\.vsix$/.exec( + fileName, + ); + + if (!match || !match[1] || !match[2]) { + console.error( + `Could not extract version and commit hash from filename: ${fileName}`, + ); + process.exit(1); + } + const lastReleaseVersion = match[1]; + const lastReleaseCommit = match[2]; + + // Step 2: Check for new commits + execSync('git fetch origin main', { stdio: 'pipe' }); + const gitLog = execSync( + `git log ${lastReleaseCommit}..origin/main --invert-grep --grep="chore(release): bump version to" -- packages/vscode-ide-companion`, + { encoding: 'utf-8' }, + ).trim(); + + // Step 3: Check for dependency changes + const noticesDiff = execSync( + `git diff ${lastReleaseCommit}..origin/main -- packages/vscode-ide-companion/NOTICES.txt`, + { encoding: 'utf-8' }, + ).trim(); + + if (gitLog) { + console.log('\n--- New Commits ---'); + console.log(gitLog); + console.log('-------------------'); + } + + if (noticesDiff) { + console.log('\n--- Dependency Changes ---'); + console.log(noticesDiff); + console.log('------------------------'); + } + + console.log('\n--- Summary ---'); + if (gitLog) { + console.log( + 'New commits found for `packages/vscode-ide-companion` since last release. A new release is needed.', + ); + } else { + console.log( + 'No new commits found since last release. No release is necessary.', + ); + } + + if (noticesDiff) { + console.log( + 'Dependencies have changed. The license review form will require extra details.', + ); + } else { + console.log('No dependency changes found.'); + } + + console.log(`Last release version: ${lastReleaseVersion}`); + console.log(`Last release commit hash: ${lastReleaseCommit}`); + console.log('---------------'); + } catch (error) { + console.error('Error checking for release:', error.message); + process.exit(1); + } +} + +checkRelease(); diff --git a/packages/vscode-ide-companion/scripts/generate-notices.js b/packages/vscode-ide-companion/scripts/generate-notices.js new file mode 100644 index 0000000000000000000000000000000000000000..e1a2dc5e76026a8c4bfcadb4cbcb98cd53a9c527 --- /dev/null +++ b/packages/vscode-ide-companion/scripts/generate-notices.js @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import fs from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const projectRoot = path.resolve( + path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', '..'), +); +const packagePath = path.join(projectRoot, 'packages', 'vscode-ide-companion'); +const noticeFilePath = path.join(packagePath, 'NOTICES.txt'); + +async function getDependencyLicense(depName, depVersion) { + let depPackageJsonPath; + let licenseContent = 'License text not found.'; + let repositoryUrl = 'No repository found'; + + try { + const localDepPath = path.join( + packagePath, + 'node_modules', + depName, + 'package.json', + ); + const rootDepPath = path.join( + projectRoot, + 'node_modules', + depName, + 'package.json', + ); + + if (await fs.stat(localDepPath).catch(() => false)) { + depPackageJsonPath = localDepPath; + } else if (await fs.stat(rootDepPath).catch(() => false)) { + depPackageJsonPath = rootDepPath; + } else { + throw new Error(`Package ${depName} not found in node_modules`); + } + + const depPackageJsonContent = await fs.readFile( + depPackageJsonPath, + 'utf-8', + ); + const depPackageJson = JSON.parse(depPackageJsonContent); + + repositoryUrl = depPackageJson.repository?.url || repositoryUrl; + + const packageDir = path.dirname(depPackageJsonPath); + const licenseFileCandidates = [ + depPackageJson.licenseFile, + 'LICENSE', + 'LICENSE.md', + 'LICENSE.txt', + 'LICENSE-MIT.txt', + 'license.md', + 'license', + ].filter(Boolean); + + let licenseFile; + for (const candidate of licenseFileCandidates) { + const potentialFile = path.join(packageDir, candidate); + if (await fs.stat(potentialFile).catch(() => false)) { + licenseFile = potentialFile; + break; + } + } + + if (licenseFile) { + try { + licenseContent = await fs.readFile(licenseFile, 'utf-8'); + licenseContent = licenseContent.replace(/\r\n/g, '\n'); + } catch (e) { + console.warn( + `Warning: Failed to read license file for ${depName}: ${e.message}`, + ); + } + } else { + console.warn(`Warning: Could not find license file for ${depName}`); + } + } catch (e) { + console.warn( + `Warning: Could not find package.json for ${depName}: ${e.message}`, + ); + } + + return { + name: depName, + version: depVersion, + repository: repositoryUrl, + license: licenseContent, + }; +} + +function collectDependencies( + packageName, + packageLock, + dependenciesMap, + workspaceRelativePath, +) { + if (dependenciesMap.has(packageName)) { + return; + } + + let packageInfo = + packageLock.packages[ + `${workspaceRelativePath}/node_modules/${packageName}` + ]; + if (!packageInfo) { + packageInfo = packageLock.packages[`node_modules/${packageName}`]; + } + + if (!packageInfo) { + console.warn( + `Warning: Could not find package info for ${packageName} in package-lock.json.`, + ); + return; + } + + dependenciesMap.set(packageName, packageInfo.version); + + if (packageInfo.dependencies) { + for (const depName of Object.keys(packageInfo.dependencies)) { + collectDependencies( + depName, + packageLock, + dependenciesMap, + workspaceRelativePath, + ); + } + } +} + +async function main() { + try { + const packageJsonPath = path.join(packagePath, 'package.json'); + const packageJsonContent = await fs.readFile(packageJsonPath, 'utf-8'); + const packageJson = JSON.parse(packageJsonContent); + + const packageLockJsonPath = path.join(projectRoot, 'package-lock.json'); + const packageLockJsonContent = await fs.readFile( + packageLockJsonPath, + 'utf-8', + ); + const packageLockJson = JSON.parse(packageLockJsonContent); + + const workspaceRelativePath = path.relative(projectRoot, packagePath); + const allDependencies = new Map(); + const directDependencies = Object.keys(packageJson.dependencies); + + for (const depName of directDependencies) { + collectDependencies( + depName, + packageLockJson, + allDependencies, + workspaceRelativePath, + ); + } + + const dependencyEntries = Array.from(allDependencies.entries()); + + const licensePromises = dependencyEntries.map(([depName, depVersion]) => + getDependencyLicense(depName, depVersion), + ); + + const dependencyLicenses = await Promise.all(licensePromises); + + let noticeText = + 'This file contains third-party software notices and license terms.\n\n'; + + for (const dep of dependencyLicenses) { + noticeText += + '============================================================\n'; + noticeText += `${dep.name}@${dep.version}\n`; + noticeText += `(${dep.repository})\n\n`; + noticeText += `${dep.license}\n\n`; + } + + await fs.writeFile(noticeFilePath, noticeText); + console.log(`NOTICES.txt generated at ${noticeFilePath}`); + } catch (error) { + console.error('Error generating NOTICES.txt:', error); + process.exit(1); + } +} + +main().catch(console.error); diff --git a/packages/vscode-ide-companion/src/diff-manager.ts b/packages/vscode-ide-companion/src/diff-manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..83cc97984a05968be9743cf151146c5fa8b7a04e --- /dev/null +++ b/packages/vscode-ide-companion/src/diff-manager.ts @@ -0,0 +1,256 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + IdeDiffAcceptedNotificationSchema, + IdeDiffRejectedNotificationSchema, +} from '@google/gemini-cli-core/src/ide/types.js'; +import { type JSONRPCNotification } from '@modelcontextprotocol/sdk/types.js'; +import * as path from 'node:path'; +import * as vscode from 'vscode'; +import { DIFF_SCHEME } from './extension.js'; + +export class DiffContentProvider implements vscode.TextDocumentContentProvider { + private content = new Map(); + private onDidChangeEmitter = new vscode.EventEmitter(); + + get onDidChange(): vscode.Event { + return this.onDidChangeEmitter.event; + } + + provideTextDocumentContent(uri: vscode.Uri): string { + return this.content.get(uri.toString()) ?? ''; + } + + setContent(uri: vscode.Uri, content: string): void { + this.content.set(uri.toString(), content); + this.onDidChangeEmitter.fire(uri); + } + + deleteContent(uri: vscode.Uri): void { + this.content.delete(uri.toString()); + } + + getContent(uri: vscode.Uri): string | undefined { + return this.content.get(uri.toString()); + } +} + +// Information about a diff view that is currently open. +interface DiffInfo { + originalFilePath: string; + newContent: string; + rightDocUri: vscode.Uri; +} + +/** + * Manages the state and lifecycle of diff views within the IDE. + */ +export class DiffManager { + private readonly onDidChangeEmitter = + new vscode.EventEmitter(); + readonly onDidChange = this.onDidChangeEmitter.event; + private diffDocuments = new Map(); + private readonly subscriptions: vscode.Disposable[] = []; + + constructor( + private readonly log: (message: string) => void, + private readonly diffContentProvider: DiffContentProvider, + ) { + this.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor((editor) => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.onActiveEditorChange(editor); + }), + ); + // eslint-disable-next-line @typescript-eslint/no-floating-promises + this.onActiveEditorChange(vscode.window.activeTextEditor); + } + + dispose() { + for (const subscription of this.subscriptions) { + subscription.dispose(); + } + } + + /** + * Creates and shows a new diff view. + */ + async showDiff(filePath: string, newContent: string) { + const fileUri = vscode.Uri.file(filePath); + + const rightDocUri = vscode.Uri.from({ + scheme: DIFF_SCHEME, + path: filePath, + // cache busting + query: `rand=${Math.random()}`, + }); + this.diffContentProvider.setContent(rightDocUri, newContent); + + this.addDiffDocument(rightDocUri, { + originalFilePath: filePath, + newContent, + rightDocUri, + }); + + const diffTitle = `${path.basename(filePath)} ↔ Modified`; + await vscode.commands.executeCommand( + 'setContext', + 'gemini.diff.isVisible', + true, + ); + + let leftDocUri; + try { + await vscode.workspace.fs.stat(fileUri); + leftDocUri = fileUri; + } catch { + // We need to provide an empty document to diff against. + // Using the 'untitled' scheme is one way to do this. + leftDocUri = vscode.Uri.from({ + scheme: 'untitled', + path: filePath, + }); + } + + await vscode.commands.executeCommand( + 'vscode.diff', + leftDocUri, + rightDocUri, + diffTitle, + { + preview: false, + preserveFocus: true, + }, + ); + await vscode.commands.executeCommand( + 'workbench.action.files.setActiveEditorWriteableInSession', + ); + } + + /** + * Closes an open diff view for a specific file. + */ + async closeDiff(filePath: string) { + let uriToClose: vscode.Uri | undefined; + for (const [uriString, diffInfo] of this.diffDocuments.entries()) { + if (diffInfo.originalFilePath === filePath) { + uriToClose = vscode.Uri.parse(uriString); + break; + } + } + + if (uriToClose) { + const rightDoc = await vscode.workspace.openTextDocument(uriToClose); + const modifiedContent = rightDoc.getText() ?? ''; + await this.closeDiffEditor(uriToClose); + return modifiedContent; + } + return; + } + + /** + * User accepts the changes in a diff view. Does not apply changes. + */ + async acceptDiff(rightDocUri: vscode.Uri) { + const diffInfo = this.diffDocuments.get(rightDocUri.toString()); + if (!diffInfo) { + return; + } + + const rightDoc = await vscode.workspace.openTextDocument(rightDocUri); + const modifiedContent = rightDoc.getText() ?? ''; + await this.closeDiffEditor(rightDocUri); + + this.onDidChangeEmitter.fire( + IdeDiffAcceptedNotificationSchema.parse({ + jsonrpc: '2.0', + method: 'ide/diffAccepted', + params: { + filePath: diffInfo.originalFilePath, + content: modifiedContent, + }, + }), + ); + } + + /** + * Called when a user cancels a diff view. + */ + async cancelDiff(rightDocUri: vscode.Uri) { + const diffInfo = this.diffDocuments.get(rightDocUri.toString()); + if (!diffInfo) { + await this.closeDiffEditor(rightDocUri); + return; + } + + const rightDoc = await vscode.workspace.openTextDocument(rightDocUri); + const modifiedContent = rightDoc.getText() ?? ''; + await this.closeDiffEditor(rightDocUri); + + this.onDidChangeEmitter.fire( + IdeDiffRejectedNotificationSchema.parse({ + jsonrpc: '2.0', + method: 'ide/diffRejected', + params: { + filePath: diffInfo.originalFilePath, + content: modifiedContent, + }, + }), + ); + } + + private async onActiveEditorChange(editor: vscode.TextEditor | undefined) { + let isVisible = false; + if (editor) { + isVisible = this.diffDocuments.has(editor.document.uri.toString()); + if (!isVisible) { + for (const document of this.diffDocuments.values()) { + if (document.originalFilePath === editor.document.uri.fsPath) { + isVisible = true; + break; + } + } + } + } + await vscode.commands.executeCommand( + 'setContext', + 'gemini.diff.isVisible', + isVisible, + ); + } + + private addDiffDocument(uri: vscode.Uri, diffInfo: DiffInfo) { + this.diffDocuments.set(uri.toString(), diffInfo); + } + + private async closeDiffEditor(rightDocUri: vscode.Uri) { + const diffInfo = this.diffDocuments.get(rightDocUri.toString()); + await vscode.commands.executeCommand( + 'setContext', + 'gemini.diff.isVisible', + false, + ); + + if (diffInfo) { + this.diffDocuments.delete(rightDocUri.toString()); + this.diffContentProvider.deleteContent(rightDocUri); + } + + // Find and close the tab corresponding to the diff view + for (const tabGroup of vscode.window.tabGroups.all) { + for (const tab of tabGroup.tabs) { + if ( + tab.input instanceof vscode.TabInputTextDiff && + tab.input.modified.toString() === rightDocUri.toString() + ) { + await vscode.window.tabGroups.close(tab); + return; + } + } + } + } +} diff --git a/packages/vscode-ide-companion/src/extension.test.ts b/packages/vscode-ide-companion/src/extension.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..49b1b76d432685e01e74c8ab9f07a0d1c6b5ba6f --- /dev/null +++ b/packages/vscode-ide-companion/src/extension.test.ts @@ -0,0 +1,312 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { activate } from './extension.js'; +import { + IDE_DEFINITIONS, + detectIdeFromEnv, +} from '@google/gemini-cli-core/src/ide/detect-ide.js'; + +vi.mock('@google/gemini-cli-core/src/ide/detect-ide.js', async () => { + const actual = await vi.importActual( + '@google/gemini-cli-core/src/ide/detect-ide.js', + ); + return { + ...actual, + detectIdeFromEnv: vi.fn(() => IDE_DEFINITIONS.vscode), + }; +}); + +vi.mock('vscode', () => ({ + window: { + createOutputChannel: vi.fn(() => ({ + appendLine: vi.fn(), + })), + showInformationMessage: vi.fn(), + createTerminal: vi.fn(() => ({ + show: vi.fn(), + sendText: vi.fn(), + })), + onDidChangeActiveTextEditor: vi.fn(), + activeTextEditor: undefined, + tabGroups: { + all: [], + close: vi.fn(), + }, + showTextDocument: vi.fn(), + showWorkspaceFolderPick: vi.fn(), + }, + workspace: { + workspaceFolders: [], + onDidCloseTextDocument: vi.fn(), + registerTextDocumentContentProvider: vi.fn(), + onDidChangeWorkspaceFolders: vi.fn(), + onDidGrantWorkspaceTrust: vi.fn(), + getConfiguration: vi.fn(() => ({ + get: vi.fn(), + })), + }, + commands: { + registerCommand: vi.fn(), + executeCommand: vi.fn(), + }, + Uri: { + joinPath: vi.fn(), + }, + ExtensionMode: { + Development: 1, + Production: 2, + }, + EventEmitter: vi.fn(() => ({ + event: vi.fn(), + fire: vi.fn(), + dispose: vi.fn(), + })), + extensions: { + getExtension: vi.fn(), + }, +})); + +describe('activate', () => { + let context: vscode.ExtensionContext; + + beforeEach(() => { + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue( + undefined, + ); + context = { + subscriptions: [], + environmentVariableCollection: { + replace: vi.fn(), + }, + globalState: { + get: vi.fn(), + update: vi.fn(), + }, + extensionUri: { + fsPath: '/path/to/extension', + }, + extension: { + packageJSON: { + version: '1.1.0', + }, + }, + } as unknown as vscode.ExtensionContext; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should show the info message on first activation', async () => { + const showInformationMessageMock = vi + .mocked(vscode.window.showInformationMessage) + .mockResolvedValue(undefined as never); + vi.mocked(context.globalState.get).mockReturnValue(undefined); + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + packageJSON: { version: '1.1.0' }, + } as vscode.Extension); + await activate(context); + expect(showInformationMessageMock).toHaveBeenCalledWith( + 'Gemini CLI Companion extension successfully installed.', + ); + }); + + it('should not show the info message on subsequent activations', async () => { + vi.mocked(context.globalState.get).mockReturnValue(true); + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + packageJSON: { version: '1.1.0' }, + } as vscode.Extension); + await activate(context); + expect(vscode.window.showInformationMessage).not.toHaveBeenCalled(); + }); + + it('should register a handler for onDidGrantWorkspaceTrust', async () => { + await activate(context); + expect(vscode.workspace.onDidGrantWorkspaceTrust).toHaveBeenCalled(); + }); + + it('should launch the Gemini CLI when the user clicks the button', async () => { + const showInformationMessageMock = vi + .mocked(vscode.window.showInformationMessage) + .mockResolvedValue('Re-launch Gemini CLI' as never); + vi.mocked(context.globalState.get).mockReturnValue(undefined); + vi.mocked(vscode.extensions.getExtension).mockReturnValue({ + packageJSON: { version: '1.1.0' }, + } as vscode.Extension); + await activate(context); + expect(showInformationMessageMock).toHaveBeenCalledWith( + 'Gemini CLI Companion extension successfully installed.', + ); + }); + + describe('update notification', () => { + beforeEach(() => { + // Prevent the "installed" message from showing + vi.mocked(context.globalState.get).mockReturnValue(true); + }); + + it('should show an update notification if a newer version is available', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { + extensions: [ + { + versions: [{ version: '1.2.0' }], + }, + ], + }, + ], + }), + } as Response); + + const showInformationMessageMock = vi.mocked( + vscode.window.showInformationMessage, + ); + + await activate(context); + + expect(showInformationMessageMock).toHaveBeenCalledWith( + 'A new version (1.2.0) of the Gemini CLI Companion extension is available.', + 'Update to latest version', + ); + }); + + it('should not show an update notification if the version is the same', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { + extensions: [ + { + versions: [{ version: '1.1.0' }], + }, + ], + }, + ], + }), + } as Response); + + const showInformationMessageMock = vi.mocked( + vscode.window.showInformationMessage, + ); + + await activate(context); + + expect(showInformationMessageMock).not.toHaveBeenCalled(); + }); + + it.each([ + { + ide: IDE_DEFINITIONS.cloudshell, + }, + { ide: IDE_DEFINITIONS.firebasestudio }, + ])( + 'does not show install or update messages for $ide.name', + async ({ ide }) => { + vi.mocked(detectIdeFromEnv).mockReturnValue(ide); + vi.mocked(context.globalState.get).mockReturnValue(undefined); + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { + extensions: [ + { + versions: [{ version: '1.2.0' }], + }, + ], + }, + ], + }), + } as Response); + const showInformationMessageMock = vi.mocked( + vscode.window.showInformationMessage, + ); + + await activate(context); + + expect(showInformationMessageMock).not.toHaveBeenCalled(); + }, + ); + + it('should not show an update notification if the version is older', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { + extensions: [ + { + versions: [{ version: '1.0.0' }], + }, + ], + }, + ], + }), + } as Response); + + const showInformationMessageMock = vi.mocked( + vscode.window.showInformationMessage, + ); + + await activate(context); + + expect(showInformationMessageMock).not.toHaveBeenCalled(); + }); + + it('should execute the install command when the user clicks "Update"', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: true, + json: async () => ({ + results: [ + { + extensions: [ + { + versions: [{ version: '1.2.0' }], + }, + ], + }, + ], + }), + } as Response); + vi.mocked(vscode.window.showInformationMessage).mockResolvedValue( + 'Update to latest version' as never, + ); + const executeCommandMock = vi.mocked(vscode.commands.executeCommand); + + await activate(context); + + // Wait for the promise from showInformationMessage.then() to resolve + await new Promise(process.nextTick); + + expect(executeCommandMock).toHaveBeenCalledWith( + 'workbench.extensions.installExtension', + 'Google.gemini-cli-vscode-ide-companion', + ); + }); + + it('should handle fetch errors gracefully', async () => { + vi.spyOn(global, 'fetch').mockResolvedValue({ + ok: false, + statusText: 'Internal Server Error', + } as Response); + + const showInformationMessageMock = vi.mocked( + vscode.window.showInformationMessage, + ); + + await activate(context); + + expect(showInformationMessageMock).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/vscode-ide-companion/src/extension.ts b/packages/vscode-ide-companion/src/extension.ts new file mode 100644 index 0000000000000000000000000000000000000000..456ec6e872e4d1ad19c3a8660281597049fe8fae --- /dev/null +++ b/packages/vscode-ide-companion/src/extension.ts @@ -0,0 +1,237 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { IDEServer } from './ide-server.js'; +import semver from 'semver'; +import { DiffContentProvider, DiffManager } from './diff-manager.js'; +import { createLogger } from './utils/logger.js'; +import { + detectIdeFromEnv, + IDE_DEFINITIONS, + type IdeInfo, +} from '@google/gemini-cli-core/src/ide/detect-ide.js'; + +const CLI_IDE_COMPANION_IDENTIFIER = 'Google.gemini-cli-vscode-ide-companion'; +const INFO_MESSAGE_SHOWN_KEY = 'geminiCliInfoMessageShown'; +export const DIFF_SCHEME = 'gemini-diff'; + +/** + * In these environments the companion extension is installed and managed by the IDE instead of the user. + */ +const MANAGED_EXTENSION_SURFACES: ReadonlySet = new Set([ + IDE_DEFINITIONS.firebasestudio.name, + IDE_DEFINITIONS.cloudshell.name, +]); + +let ideServer: IDEServer; +let logger: vscode.OutputChannel; + +let log: (message: string) => void = () => {}; + +async function checkForUpdates( + context: vscode.ExtensionContext, + log: (message: string) => void, + isManagedExtensionSurface: boolean, +) { + try { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const currentVersion = context.extension.packageJSON.version; + + // Fetch extension details from the VSCode Marketplace. + const response = await fetch( + 'https://marketplace.visualstudio.com/_apis/public/gallery/extensionquery', + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json;api-version=7.1-preview.1', + }, + body: JSON.stringify({ + filters: [ + { + criteria: [ + { + filterType: 7, // Corresponds to ExtensionName + value: CLI_IDE_COMPANION_IDENTIFIER, + }, + ], + }, + ], + // See: https://learn.microsoft.com/en-us/azure/devops/extend/gallery/apis/hyper-linking?view=azure-devops + // 946 = IncludeVersions | IncludeFiles | IncludeCategoryAndTags | + // IncludeShortDescription | IncludePublisher | IncludeStatistics + flags: 946, + }), + }, + ); + + if (!response.ok) { + log( + `Failed to fetch latest version info from marketplace: ${response.statusText}`, + ); + return; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const data = await response.json(); + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const extension = data?.results?.[0]?.extensions?.[0]; + // The versions are sorted by date, so the first one is the latest. + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const latestVersion = extension?.versions?.[0]?.version; + + if ( + !isManagedExtensionSurface && + latestVersion && + semver.gt(latestVersion, currentVersion) + ) { + const selection = await vscode.window.showInformationMessage( + `A new version (${latestVersion}) of the Gemini CLI Companion extension is available.`, + 'Update to latest version', + ); + if (selection === 'Update to latest version') { + // The install command will update the extension if a newer version is found. + await vscode.commands.executeCommand( + 'workbench.extensions.installExtension', + CLI_IDE_COMPANION_IDENTIFIER, + ); + } + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + log(`Error checking for extension updates: ${message}`); + } +} + +export async function activate(context: vscode.ExtensionContext) { + logger = vscode.window.createOutputChannel('Gemini CLI IDE Companion'); + log = createLogger(context, logger); + log('Extension activated'); + + const isManagedExtensionSurface = MANAGED_EXTENSION_SURFACES.has( + detectIdeFromEnv().name, + ); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + checkForUpdates(context, log, isManagedExtensionSurface); + + const diffContentProvider = new DiffContentProvider(); + const diffManager = new DiffManager(log, diffContentProvider); + + context.subscriptions.push( + vscode.workspace.onDidCloseTextDocument((doc) => { + if (doc.uri.scheme === DIFF_SCHEME) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + diffManager.cancelDiff(doc.uri); + } + }), + vscode.workspace.registerTextDocumentContentProvider( + DIFF_SCHEME, + diffContentProvider, + ), + (vscode.commands.registerCommand( + 'gemini.diff.accept', + (uri?: vscode.Uri) => { + const docUri = uri ?? vscode.window.activeTextEditor?.document.uri; + if (docUri && docUri.scheme === DIFF_SCHEME) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + diffManager.acceptDiff(docUri); + } + }, + ), + vscode.commands.registerCommand( + 'gemini.diff.cancel', + (uri?: vscode.Uri) => { + const docUri = uri ?? vscode.window.activeTextEditor?.document.uri; + if (docUri && docUri.scheme === DIFF_SCHEME) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + diffManager.cancelDiff(docUri); + } + }, + )), + ); + + ideServer = new IDEServer(log, diffManager); + try { + await ideServer.start(context); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log(`Failed to start IDE server: ${message}`); + } + + if ( + !context.globalState.get(INFO_MESSAGE_SHOWN_KEY) && + !isManagedExtensionSurface + ) { + void vscode.window.showInformationMessage( + 'Gemini CLI Companion extension successfully installed.', + ); + context.globalState.update(INFO_MESSAGE_SHOWN_KEY, true); + } + + context.subscriptions.push( + (vscode.workspace.onDidChangeWorkspaceFolders(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + ideServer.syncEnvVars(); + }), + vscode.workspace.onDidGrantWorkspaceTrust(() => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + ideServer.syncEnvVars(); + })), + vscode.commands.registerCommand('gemini-cli.runGeminiCLI', async () => { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders || workspaceFolders.length === 0) { + vscode.window.showInformationMessage( + 'No folder open. Please open a folder to run Gemini CLI.', + ); + return; + } + + let selectedFolder: vscode.WorkspaceFolder | undefined; + if (workspaceFolders.length === 1) { + selectedFolder = workspaceFolders[0]; + } else { + selectedFolder = await vscode.window.showWorkspaceFolderPick({ + placeHolder: 'Select a folder to run Gemini CLI in', + }); + } + + if (selectedFolder) { + const geminiCmd = 'gemini'; + const terminal = vscode.window.createTerminal({ + name: `Gemini CLI (${selectedFolder.name})`, + cwd: selectedFolder.uri.fsPath, + }); + terminal.show(); + terminal.sendText(geminiCmd); + } + }), + vscode.commands.registerCommand('gemini-cli.showNotices', async () => { + const noticePath = vscode.Uri.joinPath( + context.extensionUri, + 'NOTICES.txt', + ); + await vscode.window.showTextDocument(noticePath); + }), + ); +} + +export async function deactivate(): Promise { + log('Extension deactivated'); + try { + if (ideServer) { + await ideServer.stop(); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log(`Failed to stop IDE server during deactivation: ${message}`); + } finally { + if (logger) { + logger.dispose(); + } + } +} diff --git a/packages/vscode-ide-companion/src/ide-server.test.ts b/packages/vscode-ide-companion/src/ide-server.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..eb28638a78dada925bca630b7e0251173c17ebc2 --- /dev/null +++ b/packages/vscode-ide-companion/src/ide-server.test.ts @@ -0,0 +1,538 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type * as vscode from 'vscode'; +import * as fs from 'node:fs/promises'; +import type * as os from 'node:os'; +import * as path from 'node:path'; +import * as http from 'node:http'; +import { IDEServer } from './ide-server.js'; +import type { DiffManager } from './diff-manager.js'; + +vi.mock('node:crypto', () => ({ + randomUUID: vi.fn(() => 'test-auth-token'), +})); + +const mocks = vi.hoisted(() => ({ + diffManager: { + onDidChange: vi.fn(() => ({ dispose: vi.fn() })), + } as unknown as DiffManager, +})); + +vi.mock('node:fs/promises', () => ({ + writeFile: vi.fn(() => Promise.resolve(undefined)), + unlink: vi.fn(() => Promise.resolve(undefined)), + chmod: vi.fn(() => Promise.resolve(undefined)), + mkdir: vi.fn(() => Promise.resolve(undefined)), +})); + +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + tmpdir: vi.fn(() => '/tmp'), + }; +}); + +vi.mock('@google/gemini-cli-core', async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + tmpdir: vi.fn(() => '/tmp'), + }; +}); + +const vscodeMock = vi.hoisted(() => ({ + workspace: { + workspaceFolders: [ + { + uri: { + fsPath: '/test/workspace1', + }, + }, + { + uri: { + fsPath: '/test/workspace2', + }, + }, + ], + isTrusted: true, + }, +})); + +vi.mock('vscode', () => vscodeMock); + +vi.mock('./open-files-manager', () => { + const OpenFilesManager = vi.fn(); + OpenFilesManager.prototype.onDidChange = vi.fn(() => ({ dispose: vi.fn() })); + return { OpenFilesManager }; +}); + +const getPortFromMock = ( + replaceMock: ReturnType< + () => vscode.ExtensionContext['environmentVariableCollection']['replace'] + >, +) => { + const port = vi + .mocked(replaceMock) + .mock.calls.find((call) => call[0] === 'GEMINI_CLI_IDE_SERVER_PORT')?.[1]; + + if (port === undefined) { + expect.fail('Port was not set'); + } + return port; +}; + +describe('IDEServer', () => { + let ideServer: IDEServer; + let mockContext: vscode.ExtensionContext; + let mockLog: (message: string) => void; + + beforeEach(() => { + mockLog = vi.fn(); + ideServer = new IDEServer(mockLog, mocks.diffManager); + mockContext = { + subscriptions: [], + environmentVariableCollection: { + replace: vi.fn(), + clear: vi.fn(), + }, + } as unknown as vscode.ExtensionContext; + }); + + afterEach(async () => { + await ideServer.stop(); + vi.restoreAllMocks(); + vscodeMock.workspace.workspaceFolders = [ + { uri: { fsPath: '/test/workspace1' } }, + { uri: { fsPath: '/test/workspace2' } }, + ]; + }); + + it('should set environment variables and workspace path on start with multiple folders', async () => { + await ideServer.start(mockContext); + + const replaceMock = mockContext.environmentVariableCollection.replace; + expect(replaceMock).toHaveBeenCalledTimes(3); + + expect(replaceMock).toHaveBeenNthCalledWith( + 1, + 'GEMINI_CLI_IDE_SERVER_PORT', + expect.any(String), // port is a number as a string + ); + + const expectedWorkspacePaths = [ + '/test/workspace1', + '/test/workspace2', + ].join(path.delimiter); + + expect(replaceMock).toHaveBeenNthCalledWith( + 2, + 'GEMINI_CLI_IDE_WORKSPACE_PATH', + expectedWorkspacePaths, + ); + + expect(replaceMock).toHaveBeenNthCalledWith( + 3, + 'GEMINI_CLI_IDE_AUTH_TOKEN', + 'test-auth-token', + ); + + const port = getPortFromMock(replaceMock); + const expectedPortFile = path.join( + '/tmp', + 'gemini', + 'ide', + `gemini-ide-server-${process.ppid}-${port}.json`, + ); + const expectedContent = JSON.stringify({ + port: parseInt(port, 10), + workspacePath: expectedWorkspacePaths, + authToken: 'test-auth-token', + }); + expect(fs.mkdir).toHaveBeenCalledWith(path.join('/tmp', 'gemini', 'ide'), { + recursive: true, + }); + expect(fs.writeFile).toHaveBeenCalledWith( + expectedPortFile, + expectedContent, + ); + expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); + }); + + it('should set a single folder path', async () => { + vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/foo/bar' } }]; + + await ideServer.start(mockContext); + const replaceMock = mockContext.environmentVariableCollection.replace; + + expect(replaceMock).toHaveBeenCalledWith( + 'GEMINI_CLI_IDE_WORKSPACE_PATH', + '/foo/bar', + ); + + const port = getPortFromMock(replaceMock); + const expectedPortFile = path.join( + '/tmp', + 'gemini', + 'ide', + `gemini-ide-server-${process.ppid}-${port}.json`, + ); + const expectedContent = JSON.stringify({ + port: parseInt(port, 10), + workspacePath: '/foo/bar', + authToken: 'test-auth-token', + }); + expect(fs.writeFile).toHaveBeenCalledWith( + expectedPortFile, + expectedContent, + ); + expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); + }); + + it('should set an empty string if no folders are open', async () => { + vscodeMock.workspace.workspaceFolders = []; + + await ideServer.start(mockContext); + const replaceMock = mockContext.environmentVariableCollection.replace; + + expect(replaceMock).toHaveBeenCalledWith( + 'GEMINI_CLI_IDE_WORKSPACE_PATH', + '', + ); + + const port = getPortFromMock(replaceMock); + const expectedPortFile = path.join( + '/tmp', + 'gemini', + 'ide', + `gemini-ide-server-${process.ppid}-${port}.json`, + ); + const expectedContent = JSON.stringify({ + port: parseInt(port, 10), + workspacePath: '', + authToken: 'test-auth-token', + }); + expect(fs.writeFile).toHaveBeenCalledWith( + expectedPortFile, + expectedContent, + ); + expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); + }); + + it('should update the path when workspace folders change', async () => { + vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/foo/bar' } }]; + await ideServer.start(mockContext); + const replaceMock = mockContext.environmentVariableCollection.replace; + + expect(replaceMock).toHaveBeenCalledWith( + 'GEMINI_CLI_IDE_WORKSPACE_PATH', + '/foo/bar', + ); + + // Simulate adding a folder + vscodeMock.workspace.workspaceFolders = [ + { uri: { fsPath: '/foo/bar' } }, + { uri: { fsPath: '/baz/qux' } }, + ]; + await ideServer.syncEnvVars(); + + const expectedWorkspacePaths = ['/foo/bar', '/baz/qux'].join( + path.delimiter, + ); + expect(replaceMock).toHaveBeenCalledWith( + 'GEMINI_CLI_IDE_WORKSPACE_PATH', + expectedWorkspacePaths, + ); + expect(replaceMock).toHaveBeenCalledWith( + 'GEMINI_CLI_IDE_AUTH_TOKEN', + 'test-auth-token', + ); + + const port = getPortFromMock(replaceMock); + const expectedPortFile = path.join( + '/tmp', + 'gemini', + 'ide', + `gemini-ide-server-${process.ppid}-${port}.json`, + ); + const expectedContent = JSON.stringify({ + port: parseInt(port, 10), + workspacePath: expectedWorkspacePaths, + authToken: 'test-auth-token', + }); + expect(fs.writeFile).toHaveBeenCalledWith( + expectedPortFile, + expectedContent, + ); + expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); + + // Simulate removing a folder + vscodeMock.workspace.workspaceFolders = [{ uri: { fsPath: '/baz/qux' } }]; + await ideServer.syncEnvVars(); + + expect(replaceMock).toHaveBeenCalledWith( + 'GEMINI_CLI_IDE_WORKSPACE_PATH', + '/baz/qux', + ); + const expectedContent2 = JSON.stringify({ + port: parseInt(port, 10), + workspacePath: '/baz/qux', + authToken: 'test-auth-token', + }); + expect(fs.writeFile).toHaveBeenCalledWith( + expectedPortFile, + expectedContent2, + ); + expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); + }); + + it('should clear env vars and delete port file on stop', async () => { + await ideServer.start(mockContext); + const replaceMock = mockContext.environmentVariableCollection.replace; + const port = getPortFromMock(replaceMock); + const portFile = path.join( + '/tmp', + 'gemini', + 'ide', + `gemini-ide-server-${process.ppid}-${port}.json`, + ); + expect(fs.writeFile).toHaveBeenCalledWith(portFile, expect.any(String)); + + await ideServer.stop(); + + expect(mockContext.environmentVariableCollection.clear).toHaveBeenCalled(); + expect(fs.unlink).toHaveBeenCalledWith(portFile); + }); + + it.skipIf(process.platform !== 'win32')( + 'should handle windows paths', + async () => { + vscodeMock.workspace.workspaceFolders = [ + { uri: { fsPath: 'c:\\foo\\bar' } }, + { uri: { fsPath: 'd:\\baz\\qux' } }, + ]; + + await ideServer.start(mockContext); + const replaceMock = mockContext.environmentVariableCollection.replace; + const expectedWorkspacePaths = 'c:\\foo\\bar;d:\\baz\\qux'; + + expect(replaceMock).toHaveBeenCalledWith( + 'GEMINI_CLI_IDE_WORKSPACE_PATH', + expectedWorkspacePaths, + ); + + const port = getPortFromMock(replaceMock); + const expectedPortFile = path.join( + '/tmp', + 'gemini', + 'ide', + `gemini-ide-server-${process.ppid}-${port}.json`, + ); + const expectedContent = JSON.stringify({ + port: parseInt(port, 10), + workspacePath: expectedWorkspacePaths, + authToken: 'test-auth-token', + }); + expect(fs.writeFile).toHaveBeenCalledWith( + expectedPortFile, + expectedContent, + ); + expect(fs.chmod).toHaveBeenCalledWith(expectedPortFile, 0o600); + }, + ); + + describe('auth token', () => { + let port: number; + + beforeEach(async () => { + await ideServer.start(mockContext); + port = (ideServer as unknown as { port: number }).port; + }); + + it('should reject request without auth token', async () => { + const response = await fetch(`http://localhost:${port}/mcp`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'initialize', + params: {}, + id: 1, + }), + }); + expect(response.status).toBe(401); + }); + + it('should allow request with valid auth token', async () => { + const response = await fetch(`http://localhost:${port}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer test-auth-token`, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'initialize', + params: {}, + id: 1, + }), + }); + expect(response.status).not.toBe(401); + }); + + it('should reject request with invalid auth token', async () => { + const response = await fetch(`http://localhost:${port}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: 'Bearer invalid-token', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'initialize', + params: {}, + id: 1, + }), + }); + expect(response.status).toBe(401); + const body = await response.text(); + expect(body).toBe('Unauthorized'); + }); + + it('should reject request with malformed auth token', async () => { + const malformedHeaders = [ + 'Bearer', + 'invalid-token', + 'Bearer token extra', + ]; + + for (const header of malformedHeaders) { + const response = await fetch(`http://localhost:${port}/mcp`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: header, + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'initialize', + params: {}, + id: 1, + }), + }); + expect(response.status, `Failed for header: ${header}`).toBe(401); + const body = await response.text(); + expect(body, `Failed for header: ${header}`).toBe('Unauthorized'); + } + }); + }); +}); + +const request = ( + port: string, + options: http.RequestOptions, + body?: string, +): Promise => + new Promise((resolve, reject) => { + const req = http.request( + { + hostname: '127.0.0.1', + port, + ...options, + }, + (res) => { + res.resume(); // Consume response data to free up memory + resolve(res); + }, + ); + req.on('error', reject); + if (body) { + req.write(body); + } + req.end(); + }); + +describe('IDEServer HTTP endpoints', () => { + let ideServer: IDEServer; + let mockContext: vscode.ExtensionContext; + let mockLog: (message: string) => void; + let port: string; + + beforeEach(async () => { + mockLog = vi.fn(); + ideServer = new IDEServer(mockLog, mocks.diffManager); + mockContext = { + subscriptions: [], + environmentVariableCollection: { + replace: vi.fn(), + clear: vi.fn(), + }, + } as unknown as vscode.ExtensionContext; + await ideServer.start(mockContext); + const replaceMock = mockContext.environmentVariableCollection.replace; + port = getPortFromMock(replaceMock); + }); + + afterEach(async () => { + await ideServer.stop(); + vi.restoreAllMocks(); + }); + + it('should deny requests with an origin header', async () => { + const response = await request( + port, + { + path: '/mcp', + method: 'POST', + headers: { + Host: `localhost:${port}`, + Origin: 'https://evil.com', + 'Content-Type': 'application/json', + }, + }, + JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }), + ); + expect(response.statusCode).toBe(403); + }); + + it('should deny requests with an invalid host header', async () => { + const response = await request( + port, + { + path: '/mcp', + method: 'POST', + headers: { + Host: 'evil.com', + 'Content-Type': 'application/json', + }, + }, + JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }), + ); + expect(response.statusCode).toBe(403); + }); + + it('should allow requests with a valid host header', async () => { + const response = await request( + port, + { + path: '/mcp', + method: 'POST', + headers: { + Host: `localhost:${port}`, + 'Content-Type': 'application/json', + Authorization: 'Bearer test-auth-token', + }, + }, + JSON.stringify({ jsonrpc: '2.0', method: 'initialize' }), + ); + // We expect a 400 here because we are not sending a valid MCP request, + // but it's not a host error, which is what we are testing. + expect(response.statusCode).toBe(400); + }); +}); diff --git a/packages/vscode-ide-companion/src/ide-server.ts b/packages/vscode-ide-companion/src/ide-server.ts new file mode 100644 index 0000000000000000000000000000000000000000..39ef770079dfcf695a3acb77783c8c7632244455 --- /dev/null +++ b/packages/vscode-ide-companion/src/ide-server.ts @@ -0,0 +1,479 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import { + CloseDiffRequestSchema, + IdeContextNotificationSchema, + OpenDiffRequestSchema, +} from '@google/gemini-cli-core/src/ide/types.js'; +import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js'; +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import express, { + type Request, + type Response, + type NextFunction, +} from 'express'; +import cors from 'cors'; +import { randomUUID } from 'node:crypto'; +import { type Server as HTTPServer } from 'node:http'; +import * as path from 'node:path'; +import * as fs from 'node:fs/promises'; +import { tmpdir } from '@google/gemini-cli-core'; +import type { z } from 'zod'; +import type { DiffManager } from './diff-manager.js'; +import { OpenFilesManager } from './open-files-manager.js'; + +class CORSError extends Error { + constructor(message: string) { + super(message); + this.name = 'CORSError'; + } +} + +const MCP_SESSION_ID_HEADER = 'mcp-session-id'; +const IDE_SERVER_PORT_ENV_VAR = 'GEMINI_CLI_IDE_SERVER_PORT'; +const IDE_WORKSPACE_PATH_ENV_VAR = 'GEMINI_CLI_IDE_WORKSPACE_PATH'; +const IDE_AUTH_TOKEN_ENV_VAR = 'GEMINI_CLI_IDE_AUTH_TOKEN'; + +interface WritePortAndWorkspaceArgs { + context: vscode.ExtensionContext; + port: number; + authToken: string; + portFile: string | undefined; + log: (message: string) => void; +} + +async function writePortAndWorkspace({ + context, + port, + portFile, + authToken, + log, +}: WritePortAndWorkspaceArgs): Promise { + const workspaceFolders = vscode.workspace.workspaceFolders; + const workspacePath = + workspaceFolders && workspaceFolders.length > 0 + ? workspaceFolders.map((folder) => folder.uri.fsPath).join(path.delimiter) + : ''; + + context.environmentVariableCollection.replace( + IDE_SERVER_PORT_ENV_VAR, + port.toString(), + ); + context.environmentVariableCollection.replace( + IDE_WORKSPACE_PATH_ENV_VAR, + workspacePath, + ); + context.environmentVariableCollection.replace( + IDE_AUTH_TOKEN_ENV_VAR, + authToken, + ); + + if (!portFile) { + log('Missing portFile, cannot write port and workspace info.'); + return; + } + + const content = JSON.stringify({ + port, + workspacePath, + authToken, + }); + + log(`Writing port file to: ${portFile}`); + + try { + await fs.writeFile(portFile, content).then(() => fs.chmod(portFile, 0o600)); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log(`Failed to write port to file: ${message}`); + } +} + +function sendIdeContextUpdateNotification( + transport: StreamableHTTPServerTransport, + log: (message: string) => void, + openFilesManager: OpenFilesManager, +) { + const ideContext = openFilesManager.state; + + const notification = IdeContextNotificationSchema.parse({ + jsonrpc: '2.0', + method: 'ide/contextUpdate', + params: ideContext, + }); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + transport.send(notification); +} + +function getSessionId(req: Request): string | undefined { + const header = req.headers[MCP_SESSION_ID_HEADER]; + return Array.isArray(header) ? header[0] : header; +} + +export class IDEServer { + private server: HTTPServer | undefined; + private context: vscode.ExtensionContext | undefined; + private log: (message: string) => void; + private portFile: string | undefined; + + private port: number | undefined; + private authToken: string | undefined; + private transports: { [sessionId: string]: StreamableHTTPServerTransport } = + {}; + private openFilesManager: OpenFilesManager | undefined; + diffManager: DiffManager; + + constructor(log: (message: string) => void, diffManager: DiffManager) { + this.log = log; + this.diffManager = diffManager; + } + + start(context: vscode.ExtensionContext): Promise { + return new Promise((resolve) => { + this.context = context; + this.authToken = randomUUID(); + const sessionsWithInitialNotification = new Set(); + + const app = express(); + app.use(express.json({ limit: '10mb' })); + + app.use( + cors({ + origin: (origin, callback) => { + // Only allow non-browser requests with no origin. + if (!origin) { + return callback(null, true); + } + return callback( + new CORSError('Request denied by CORS policy.'), + false, + ); + }, + }), + ); + + app.use((req, res, next) => { + const host = req.headers.host || ''; + const allowedHosts = [ + `localhost:${this.port}`, + `127.0.0.1:${this.port}`, + ]; + if (!allowedHosts.includes(host)) { + return res.status(403).json({ error: 'Invalid Host header' }); + } + next(); + }); + + app.use((req, res, next) => { + const authHeader = req.headers.authorization; + if (!authHeader) { + this.log('Missing Authorization header. Rejecting request.'); + res.status(401).send('Unauthorized'); + return; + } + const parts = authHeader.split(' '); + if (parts.length !== 2 || parts[0] !== 'Bearer') { + this.log('Malformed Authorization header. Rejecting request.'); + res.status(401).send('Unauthorized'); + return; + } + const token = parts[1]; + if (token !== this.authToken) { + this.log('Invalid auth token provided. Rejecting request.'); + res.status(401).send('Unauthorized'); + return; + } + next(); + }); + + const mcpServer = createMcpServer(this.diffManager, this.log); + + this.openFilesManager = new OpenFilesManager(context); + const onDidChangeSubscription = this.openFilesManager.onDidChange(() => { + this.broadcastIdeContextUpdate(); + }); + context.subscriptions.push(onDidChangeSubscription); + const onDidChangeDiffSubscription = this.diffManager.onDidChange( + (notification) => { + for (const transport of Object.values(this.transports)) { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + transport.send(notification); + } + }, + ); + context.subscriptions.push(onDidChangeDiffSubscription); + + app.post('/mcp', async (req: Request, res: Response) => { + const sessionId = getSessionId(req); + let transport: StreamableHTTPServerTransport; + + if (sessionId && this.transports[sessionId]) { + transport = this.transports[sessionId]; + } else if (!sessionId && isInitializeRequest(req.body)) { + transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: () => randomUUID(), + onsessioninitialized: (newSessionId) => { + this.log(`New session initialized: ${newSessionId}`); + this.transports[newSessionId] = transport; + }, + }); + let missedPings = 0; + const keepAlive = setInterval(() => { + const sessionId = transport.sessionId ?? 'unknown'; + transport + .send({ jsonrpc: '2.0', method: 'ping' }) + .then(() => { + missedPings = 0; + }) + .catch((error) => { + missedPings++; + this.log( + `Failed to send keep-alive ping for session ${sessionId}. Missed pings: ${missedPings}. Error: ${error.message}`, + ); + if (missedPings >= 3) { + this.log( + `Session ${sessionId} missed ${missedPings} pings. Closing connection and cleaning up interval.`, + ); + clearInterval(keepAlive); + } + }); + }, 60000); // 60 sec + + transport.onclose = () => { + clearInterval(keepAlive); + if (transport.sessionId) { + this.log(`Session closed: ${transport.sessionId}`); + sessionsWithInitialNotification.delete(transport.sessionId); + delete this.transports[transport.sessionId]; + } + }; + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + mcpServer.connect(transport); + } else { + this.log( + 'Bad Request: No valid session ID provided for non-initialize request.', + ); + res.status(400).json({ + jsonrpc: '2.0', + error: { + code: -32000, + message: + 'Bad Request: No valid session ID provided for non-initialize request.', + }, + id: null, + }); + return; + } + + try { + await transport.handleRequest(req, res, req.body); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + this.log(`Error handling MCP request: ${errorMessage}`); + if (!res.headersSent) { + res.status(500).json({ + jsonrpc: '2.0' as const, + error: { + code: -32603, + message: 'Internal server error', + }, + id: null, + }); + } + } + }); + + const handleSessionRequest = async (req: Request, res: Response) => { + const sessionId = getSessionId(req); + if (!sessionId || !this.transports[sessionId]) { + this.log('Invalid or missing session ID'); + res.status(400).send('Invalid or missing session ID'); + return; + } + + const transport = this.transports[sessionId]; + try { + await transport.handleRequest(req, res); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : 'Unknown error'; + this.log(`Error handling session request: ${errorMessage}`); + if (!res.headersSent) { + res.status(400).send('Bad Request'); + } + } + + if ( + this.openFilesManager && + !sessionsWithInitialNotification.has(sessionId) + ) { + sendIdeContextUpdateNotification( + transport, + this.log.bind(this), + this.openFilesManager, + ); + sessionsWithInitialNotification.add(sessionId); + } + }; + + app.get('/mcp', handleSessionRequest); + + app.use((err: Error, req: Request, res: Response, next: NextFunction) => { + this.log(`Error processing request: ${err.message}`); + this.log(`Stack trace: ${err.stack}`); + if (err instanceof CORSError) { + res.status(403).json({ error: 'Request denied by CORS policy.' }); + } else { + next(err); + } + }); + + this.server = app.listen(0, '127.0.0.1', async () => { + const address = this.server?.address(); + if (address && typeof address !== 'string') { + this.port = address.port; + this.log(`IDE server listening on http://127.0.0.1:${this.port}`); + let portFile: string | undefined; + try { + const portDir = path.join(tmpdir(), 'gemini', 'ide'); + await fs.mkdir(portDir, { recursive: true }); + portFile = path.join( + portDir, + `gemini-ide-server-${process.ppid}-${this.port}.json`, + ); + this.portFile = portFile; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.log(`Failed to create IDE port file: ${message}`); + } + + await writePortAndWorkspace({ + context, + port: this.port, + portFile: this.portFile, + authToken: this.authToken ?? '', + log: this.log, + }); + } + resolve(); + }); + + this.server.on('close', () => { + this.log('IDE server connection closed.'); + }); + + this.server.on('error', (error) => { + this.log(`IDE server error: ${error.message}`); + }); + }); + } + + broadcastIdeContextUpdate() { + if (!this.openFilesManager) { + return; + } + for (const transport of Object.values(this.transports)) { + sendIdeContextUpdateNotification( + transport, + this.log.bind(this), + this.openFilesManager, + ); + } + } + + async syncEnvVars(): Promise { + if (this.context && this.server && this.port && this.authToken) { + await writePortAndWorkspace({ + context: this.context, + port: this.port, + portFile: this.portFile, + authToken: this.authToken, + log: this.log, + }); + this.broadcastIdeContextUpdate(); + } + } + + async stop(): Promise { + if (this.server) { + await new Promise((resolve, reject) => { + this.server!.close((err?: Error) => { + if (err) { + this.log(`Error shutting down IDE server: ${err.message}`); + return reject(err); + } + this.log(`IDE server shut down`); + resolve(); + }); + }); + this.server = undefined; + } + + if (this.context) { + this.context.environmentVariableCollection.clear(); + } + if (this.portFile) { + try { + await fs.unlink(this.portFile); + } catch { + // Ignore errors if the file doesn't exist. + } + } + } +} + +const createMcpServer = ( + diffManager: DiffManager, + log: (message: string) => void, +) => { + const server = new McpServer( + { + name: 'gemini-cli-companion-mcp-server', + version: '1.0.0', + }, + { capabilities: { logging: {} } }, + ); + server.registerTool( + 'openDiff', + { + description: + '(IDE Tool) Open a diff view to create or modify a file. Returns a notification once the diff has been accepted or rejected.', + inputSchema: OpenDiffRequestSchema.shape, + }, + async ({ filePath, newContent }: z.infer) => { + log(`Received openDiff request for filePath: ${filePath}`); + await diffManager.showDiff(filePath, newContent); + return { content: [] }; + }, + ); + server.registerTool( + 'closeDiff', + { + description: '(IDE Tool) Close an open diff view for a specific file.', + inputSchema: CloseDiffRequestSchema.shape, + }, + async ({ filePath }: z.infer) => { + log(`Received closeDiff request for filePath: ${filePath}`); + const content = await diffManager.closeDiff(filePath); + const response = { content: content ?? undefined }; + return { + content: [ + { + type: 'text', + text: JSON.stringify(response), + }, + ], + }; + }, + ); + return server; +}; diff --git a/packages/vscode-ide-companion/src/open-files-manager.test.ts b/packages/vscode-ide-companion/src/open-files-manager.test.ts new file mode 100644 index 0000000000000000000000000000000000000000..b6dc46f6a4b5ae322cc2ee1741db91af63ae83a2 --- /dev/null +++ b/packages/vscode-ide-companion/src/open-files-manager.test.ts @@ -0,0 +1,440 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { OpenFilesManager, MAX_FILES } from './open-files-manager.js'; + +vi.mock('vscode', () => ({ + EventEmitter: vi.fn(() => { + const listeners: Array<(e: void) => unknown> = []; + return { + event: vi.fn((listener) => { + listeners.push(listener); + return { dispose: vi.fn() }; + }), + fire: vi.fn(() => { + listeners.forEach((listener) => listener(undefined)); + }), + dispose: vi.fn(), + }; + }), + window: { + onDidChangeActiveTextEditor: vi.fn(), + onDidChangeTextEditorSelection: vi.fn(), + }, + workspace: { + onDidDeleteFiles: vi.fn(), + onDidCloseTextDocument: vi.fn(), + onDidRenameFiles: vi.fn(), + }, + Uri: { + file: (path: string) => ({ + fsPath: path, + scheme: 'file', + }), + }, + TextEditorSelectionChangeKind: { + Mouse: 2, + }, +})); + +describe('OpenFilesManager', () => { + let context: vscode.ExtensionContext; + let onDidChangeActiveTextEditorListener: ( + editor: vscode.TextEditor | undefined, + ) => void; + let onDidChangeTextEditorSelectionListener: ( + e: vscode.TextEditorSelectionChangeEvent, + ) => void; + let onDidDeleteFilesListener: (e: vscode.FileDeleteEvent) => void; + let onDidCloseTextDocumentListener: (doc: vscode.TextDocument) => void; + let onDidRenameFilesListener: (e: vscode.FileRenameEvent) => void; + + beforeEach(() => { + vi.useFakeTimers(); + + vi.mocked(vscode.window.onDidChangeActiveTextEditor).mockImplementation( + (listener) => { + onDidChangeActiveTextEditorListener = listener; + return { dispose: vi.fn() }; + }, + ); + vi.mocked(vscode.window.onDidChangeTextEditorSelection).mockImplementation( + (listener) => { + onDidChangeTextEditorSelectionListener = listener; + return { dispose: vi.fn() }; + }, + ); + vi.mocked(vscode.workspace.onDidDeleteFiles).mockImplementation( + (listener) => { + onDidDeleteFilesListener = listener; + return { dispose: vi.fn() }; + }, + ); + vi.mocked(vscode.workspace.onDidCloseTextDocument).mockImplementation( + (listener) => { + onDidCloseTextDocumentListener = listener; + return { dispose: vi.fn() }; + }, + ); + vi.mocked(vscode.workspace.onDidRenameFiles).mockImplementation( + (listener) => { + onDidRenameFilesListener = listener; + return { dispose: vi.fn() }; + }, + ); + + context = { + subscriptions: [], + } as unknown as vscode.ExtensionContext; + }); + + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + const getUri = (path: string) => + vscode.Uri.file(path) as unknown as vscode.Uri; + + const addFile = (uri: vscode.Uri) => { + onDidChangeActiveTextEditorListener({ + document: { + uri, + getText: () => '', + }, + selection: { + active: { line: 0, character: 0 }, + }, + } as unknown as vscode.TextEditor); + }; + + it('adds a file to the list', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file1.txt', + ); + }); + + it('moves an existing file to the top', async () => { + const manager = new OpenFilesManager(context); + const uri1 = getUri('/test/file1.txt'); + const uri2 = getUri('/test/file2.txt'); + addFile(uri1); + addFile(uri2); + addFile(uri1); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(2); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file1.txt', + ); + }); + + it('does not exceed the max number of files', async () => { + const manager = new OpenFilesManager(context); + for (let i = 0; i < MAX_FILES + 5; i++) { + const uri = getUri(`/test/file${i}.txt`); + addFile(uri); + } + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(MAX_FILES); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + `/test/file${MAX_FILES + 4}.txt`, + ); + expect(manager.state.workspaceState!.openFiles![MAX_FILES - 1].path).toBe( + `/test/file5.txt`, + ); + }); + + it('fires onDidChange when a file is added', async () => { + const manager = new OpenFilesManager(context); + const onDidChangeSpy = vi.fn(); + manager.onDidChange(onDidChangeSpy); + + const uri = getUri('/test/file1.txt'); + addFile(uri); + + await vi.advanceTimersByTimeAsync(100); + expect(onDidChangeSpy).toHaveBeenCalled(); + }); + + it('removes a file when it is closed', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + + onDidCloseTextDocumentListener({ uri } as vscode.TextDocument); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(0); + }); + + it('fires onDidChange when a file is removed', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + const onDidChangeSpy = vi.fn(); + manager.onDidChange(onDidChangeSpy); + + onDidCloseTextDocumentListener({ uri } as vscode.TextDocument); + await vi.advanceTimersByTimeAsync(100); + + expect(onDidChangeSpy).toHaveBeenCalled(); + }); + + it('removes a file when it is deleted', async () => { + const manager = new OpenFilesManager(context); + const uri1 = getUri('/test/file1.txt'); + const uri2 = getUri('/test/file2.txt'); + addFile(uri1); + addFile(uri2); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(2); + + onDidDeleteFilesListener({ files: [uri1] }); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file2.txt', + ); + }); + + it('fires onDidChange when a file is deleted', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + const onDidChangeSpy = vi.fn(); + manager.onDidChange(onDidChangeSpy); + + onDidDeleteFilesListener({ files: [uri] }); + await vi.advanceTimersByTimeAsync(100); + + expect(onDidChangeSpy).toHaveBeenCalled(); + }); + + it('removes multiple files when they are deleted', async () => { + const manager = new OpenFilesManager(context); + const uri1 = getUri('/test/file1.txt'); + const uri2 = getUri('/test/file2.txt'); + const uri3 = getUri('/test/file3.txt'); + addFile(uri1); + addFile(uri2); + addFile(uri3); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(3); + + onDidDeleteFilesListener({ files: [uri1, uri3] }); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file2.txt', + ); + }); + + it('fires onDidChange only once when adding an existing file', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + const onDidChangeSpy = vi.fn(); + manager.onDidChange(onDidChangeSpy); + + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + expect(onDidChangeSpy).toHaveBeenCalledTimes(1); + }); + + it('updates the file when it is renamed', async () => { + const manager = new OpenFilesManager(context); + const oldUri = getUri('/test/file1.txt'); + const newUri = getUri('/test/file2.txt'); + addFile(oldUri); + await vi.advanceTimersByTimeAsync(100); + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file1.txt', + ); + + onDidRenameFilesListener({ files: [{ oldUri, newUri }] }); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file2.txt', + ); + }); + + it('adds a file when the active editor changes', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(1); + expect(manager.state.workspaceState!.openFiles![0].path).toBe( + '/test/file1.txt', + ); + }); + + it('updates the cursor position on selection change', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + const selection = { + active: { line: 10, character: 20 }, + } as vscode.Selection; + + onDidChangeTextEditorSelectionListener({ + textEditor: { + document: { uri, getText: () => '' }, + selection, + } as vscode.TextEditor, + selections: [selection], + kind: vscode.TextEditorSelectionChangeKind.Mouse, + }); + + await vi.advanceTimersByTimeAsync(100); + + const file = manager.state.workspaceState!.openFiles![0]; + expect(file.cursor).toEqual({ line: 11, character: 21 }); + }); + + it('updates the selected text on selection change', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + const selection = { + active: { line: 10, character: 20 }, + } as vscode.Selection; + + // We need to override the mock for getText for this test + const textEditor = { + document: { + uri, + getText: vi.fn().mockReturnValue('selected text'), + }, + selection, + } as unknown as vscode.TextEditor; + + onDidChangeActiveTextEditorListener(textEditor); + await vi.advanceTimersByTimeAsync(100); + + onDidChangeTextEditorSelectionListener({ + textEditor, + selections: [selection], + kind: vscode.TextEditorSelectionChangeKind.Mouse, + }); + + await vi.advanceTimersByTimeAsync(100); + + const file = manager.state.workspaceState!.openFiles![0]; + expect(file.selectedText).toBe('selected text'); + expect(textEditor.document.getText).toHaveBeenCalledWith(selection); + }); + + it('truncates long selected text', async () => { + const manager = new OpenFilesManager(context); + const uri = getUri('/test/file1.txt'); + const longText = 'a'.repeat(20000); + const truncatedText = longText.substring(0, 16384); + + const selection = { + active: { line: 10, character: 20 }, + } as vscode.Selection; + + const textEditor = { + document: { + uri, + getText: vi.fn().mockReturnValue(longText), + }, + selection, + } as unknown as vscode.TextEditor; + + onDidChangeActiveTextEditorListener(textEditor); + await vi.advanceTimersByTimeAsync(100); + + onDidChangeTextEditorSelectionListener({ + textEditor, + selections: [selection], + kind: vscode.TextEditorSelectionChangeKind.Mouse, + }); + + await vi.advanceTimersByTimeAsync(100); + + const file = manager.state.workspaceState!.openFiles![0]; + expect(file.selectedText).toBe(truncatedText); + }); + + it('deactivates the previously active file', async () => { + const manager = new OpenFilesManager(context); + const uri1 = getUri('/test/file1.txt'); + const uri2 = getUri('/test/file2.txt'); + + addFile(uri1); + await vi.advanceTimersByTimeAsync(100); + + const selection = { + active: { line: 10, character: 20 }, + } as vscode.Selection; + + onDidChangeTextEditorSelectionListener({ + textEditor: { + document: { uri: uri1, getText: () => '' }, + selection, + } as vscode.TextEditor, + selections: [selection], + kind: vscode.TextEditorSelectionChangeKind.Mouse, + }); + await vi.advanceTimersByTimeAsync(100); + + let file1 = manager.state.workspaceState!.openFiles![0]; + expect(file1.isActive).toBe(true); + expect(file1.cursor).toBeDefined(); + + addFile(uri2); + await vi.advanceTimersByTimeAsync(100); + + file1 = manager.state.workspaceState!.openFiles!.find( + (f) => f.path === '/test/file1.txt', + )!; + const file2 = manager.state.workspaceState!.openFiles![0]; + + expect(file1.isActive).toBe(false); + expect(file1.cursor).toBeUndefined(); + expect(file1.selectedText).toBeUndefined(); + expect(file2.path).toBe('/test/file2.txt'); + expect(file2.isActive).toBe(true); + }); + + it('ignores non-file URIs', async () => { + const manager = new OpenFilesManager(context); + const uri = { + fsPath: '/test/file1.txt', + scheme: 'untitled', + } as vscode.Uri; + + addFile(uri); + await vi.advanceTimersByTimeAsync(100); + + expect(manager.state.workspaceState!.openFiles).toHaveLength(0); + }); +}); diff --git a/packages/vscode-ide-companion/src/open-files-manager.ts b/packages/vscode-ide-companion/src/open-files-manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..3fae487ad341be22eab5749fad9708d774ccd359 --- /dev/null +++ b/packages/vscode-ide-companion/src/open-files-manager.ts @@ -0,0 +1,181 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; +import type { + File, + IdeContext, +} from '@google/gemini-cli-core/src/ide/types.js'; + +export const MAX_FILES = 10; +const MAX_SELECTED_TEXT_LENGTH = 16384; // 16 KiB limit + +/** + * Keeps track of the workspace state, including open files, cursor position, and selected text. + */ +export class OpenFilesManager { + private readonly onDidChangeEmitter = new vscode.EventEmitter(); + readonly onDidChange = this.onDidChangeEmitter.event; + private debounceTimer: NodeJS.Timeout | undefined; + private openFiles: File[] = []; + + constructor(private readonly context: vscode.ExtensionContext) { + const editorWatcher = vscode.window.onDidChangeActiveTextEditor( + (editor) => { + if (editor && this.isFileUri(editor.document.uri)) { + this.addOrMoveToFront(editor); + this.fireWithDebounce(); + } + }, + ); + + const selectionWatcher = vscode.window.onDidChangeTextEditorSelection( + (event) => { + if (this.isFileUri(event.textEditor.document.uri)) { + this.updateActiveContext(event.textEditor); + this.fireWithDebounce(); + } + }, + ); + + const closeWatcher = vscode.workspace.onDidCloseTextDocument((document) => { + if (this.isFileUri(document.uri)) { + this.remove(document.uri); + this.fireWithDebounce(); + } + }); + + const deleteWatcher = vscode.workspace.onDidDeleteFiles((event) => { + for (const uri of event.files) { + if (this.isFileUri(uri)) { + this.remove(uri); + } + } + this.fireWithDebounce(); + }); + + const renameWatcher = vscode.workspace.onDidRenameFiles((event) => { + for (const { oldUri, newUri } of event.files) { + if (this.isFileUri(oldUri)) { + if (this.isFileUri(newUri)) { + this.rename(oldUri, newUri); + } else { + // The file was renamed to a non-file URI, so we should remove it. + this.remove(oldUri); + } + } + } + this.fireWithDebounce(); + }); + + context.subscriptions.push( + editorWatcher, + selectionWatcher, + closeWatcher, + deleteWatcher, + renameWatcher, + ); + + // Just add current active file on start-up. + if ( + vscode.window.activeTextEditor && + this.isFileUri(vscode.window.activeTextEditor.document.uri) + ) { + this.addOrMoveToFront(vscode.window.activeTextEditor); + } + } + + private isFileUri(uri: vscode.Uri): boolean { + return uri.scheme === 'file'; + } + + private addOrMoveToFront(editor: vscode.TextEditor) { + // Deactivate previous active file + const currentActive = this.openFiles.find((f) => f.isActive); + if (currentActive) { + currentActive.isActive = false; + currentActive.cursor = undefined; + currentActive.selectedText = undefined; + } + + // Remove if it exists + const index = this.openFiles.findIndex( + (f) => f.path === editor.document.uri.fsPath, + ); + if (index !== -1) { + this.openFiles.splice(index, 1); + } + + // Add to the front as active + this.openFiles.unshift({ + path: editor.document.uri.fsPath, + timestamp: Date.now(), + isActive: true, + }); + + // Enforce max length + if (this.openFiles.length > MAX_FILES) { + this.openFiles.pop(); + } + + this.updateActiveContext(editor); + } + + private remove(uri: vscode.Uri) { + const index = this.openFiles.findIndex((f) => f.path === uri.fsPath); + if (index !== -1) { + this.openFiles.splice(index, 1); + } + } + + private rename(oldUri: vscode.Uri, newUri: vscode.Uri) { + const index = this.openFiles.findIndex((f) => f.path === oldUri.fsPath); + if (index !== -1) { + this.openFiles[index].path = newUri.fsPath; + } + } + + private updateActiveContext(editor: vscode.TextEditor) { + const file = this.openFiles.find( + (f) => f.path === editor.document.uri.fsPath, + ); + if (!file || !file.isActive) { + return; + } + + file.cursor = editor.selection.active + ? { + line: editor.selection.active.line + 1, + character: editor.selection.active.character + 1, + } + : undefined; + + let selectedText: string | undefined = + editor.document.getText(editor.selection) || undefined; + if (selectedText && selectedText.length > MAX_SELECTED_TEXT_LENGTH) { + selectedText = selectedText.substring(0, MAX_SELECTED_TEXT_LENGTH); + } + file.selectedText = selectedText; + } + + private fireWithDebounce() { + if (this.debounceTimer) { + clearTimeout(this.debounceTimer); + } + this.debounceTimer = setTimeout(() => { + this.onDidChangeEmitter.fire(); + }, 50); // 50ms + } + + get state(): IdeContext { + return { + workspaceState: { + openFiles: [...this.openFiles], + isTrusted: vscode.workspace.isTrusted, + }, + }; + } +} diff --git a/packages/vscode-ide-companion/src/utils/logger.ts b/packages/vscode-ide-companion/src/utils/logger.ts new file mode 100644 index 0000000000000000000000000000000000000000..8c405558ca297da68d05b03286d2cd711e343ff8 --- /dev/null +++ b/packages/vscode-ide-companion/src/utils/logger.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as vscode from 'vscode'; + +export function createLogger( + context: vscode.ExtensionContext, + logger: vscode.OutputChannel, +) { + return (message: string) => { + const isDevMode = + context.extensionMode === vscode.ExtensionMode.Development; + const isLoggingEnabled = vscode.workspace + .getConfiguration('gemini-cli.debug') + .get('logging.enabled'); + + if (isDevMode || isLoggingEnabled) { + logger.appendLine(message); + } + }; +} diff --git a/packages/vscode-ide-companion/tsconfig.json b/packages/vscode-ide-companion/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..f1357064852839f7f679ecc6eb0e5b210556aad1 --- /dev/null +++ b/packages/vscode-ide-companion/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "lib": ["ES2022", "dom"], + "sourceMap": true, + /* + * skipLibCheck is necessary because the a2a-server package depends on + * @google-cloud/storage which pulls in @types/request which depends on + * tough-cookie@4.x while jsdom requires tough-cookie@5.x. This causes a + * type checking error in ../../node_modules/@types/request/index.d.ts. + */ + "skipLibCheck": true, + "rootDir": "src", + "strict": true /* enable all strict type-checking options */ + /* Additional Checks */ + // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + } +}