Merge branch 'main' into akkr/subagents

This commit is contained in:
AK
2026-03-02 19:16:47 -08:00
committed by GitHub
28 changed files with 2179 additions and 65 deletions
+13 -2
View File
@@ -290,6 +290,7 @@ jobs:
with: with:
ref: '${{ needs.parse_run_context.outputs.sha }}' ref: '${{ needs.parse_run_context.outputs.sha }}'
repository: '${{ needs.parse_run_context.outputs.repository }}' repository: '${{ needs.parse_run_context.outputs.repository }}'
fetch-depth: 0
- name: 'Set up Node.js 20.x' - name: 'Set up Node.js 20.x'
uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4 uses: 'actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020' # ratchet:actions-node@v4
@@ -302,7 +303,14 @@ jobs:
- name: 'Build project' - name: 'Build project'
run: 'npm run build' run: 'npm run build'
- name: 'Run Evals (ALWAYS_PASSING)' - name: 'Check if evals should run'
id: 'check_evals'
run: |
SHOULD_RUN=$(node scripts/changed_prompt.js)
echo "should_run=$SHOULD_RUN" >> "$GITHUB_OUTPUT"
- name: 'Run Evals (Required to pass)'
if: "${{ steps.check_evals.outputs.should_run == 'true' }}"
env: env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}' GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
run: 'npm run test:always_passing_evals' run: 'npm run test:always_passing_evals'
@@ -315,6 +323,7 @@ jobs:
- 'e2e_linux' - 'e2e_linux'
- 'e2e_mac' - 'e2e_mac'
- 'e2e_windows' - 'e2e_windows'
- 'evals'
- 'merge_queue_skipper' - 'merge_queue_skipper'
runs-on: 'gemini-cli-ubuntu-16-core' runs-on: 'gemini-cli-ubuntu-16-core'
steps: steps:
@@ -322,7 +331,8 @@ jobs:
run: | run: |
if [[ ${NEEDS_E2E_LINUX_RESULT} != 'success' || \ if [[ ${NEEDS_E2E_LINUX_RESULT} != 'success' || \
${NEEDS_E2E_MAC_RESULT} != 'success' || \ ${NEEDS_E2E_MAC_RESULT} != 'success' || \
${NEEDS_E2E_WINDOWS_RESULT} != 'success' ]]; then ${NEEDS_E2E_WINDOWS_RESULT} != 'success' || \
${NEEDS_EVALS_RESULT} != 'success' ]]; then
echo "One or more E2E jobs failed." echo "One or more E2E jobs failed."
exit 1 exit 1
fi fi
@@ -331,6 +341,7 @@ jobs:
NEEDS_E2E_LINUX_RESULT: '${{ needs.e2e_linux.result }}' NEEDS_E2E_LINUX_RESULT: '${{ needs.e2e_linux.result }}'
NEEDS_E2E_MAC_RESULT: '${{ needs.e2e_mac.result }}' NEEDS_E2E_MAC_RESULT: '${{ needs.e2e_mac.result }}'
NEEDS_E2E_WINDOWS_RESULT: '${{ needs.e2e_windows.result }}' NEEDS_E2E_WINDOWS_RESULT: '${{ needs.e2e_windows.result }}'
NEEDS_EVALS_RESULT: '${{ needs.evals.result }}'
set_workflow_status: set_workflow_status:
runs-on: 'gemini-cli-ubuntu-16-core' runs-on: 'gemini-cli-ubuntu-16-core'
+160
View File
@@ -0,0 +1,160 @@
name: 'Test Build Binary'
on:
workflow_dispatch:
permissions:
contents: 'read'
defaults:
run:
shell: 'bash'
jobs:
build-node-binary:
name: 'Build Binary (${{ matrix.os }})'
runs-on: '${{ matrix.os }}'
strategy:
fail-fast: false
matrix:
include:
- os: 'ubuntu-latest'
platform_name: 'linux-x64'
arch: 'x64'
- os: 'windows-latest'
platform_name: 'win32-x64'
arch: 'x64'
- os: 'macos-latest' # Apple Silicon (ARM64)
platform_name: 'darwin-arm64'
arch: 'arm64'
- os: 'macos-latest' # Intel (x64) running on ARM via Rosetta
platform_name: 'darwin-x64'
arch: 'x64'
steps:
- name: 'Checkout'
uses: 'actions/checkout@v4'
- name: 'Optimize Windows Performance'
if: "matrix.os == 'windows-latest'"
run: |
Set-MpPreference -DisableRealtimeMonitoring $true
Stop-Service -Name "wsearch" -Force -ErrorAction SilentlyContinue
Set-Service -Name "wsearch" -StartupType Disabled
Stop-Service -Name "SysMain" -Force -ErrorAction SilentlyContinue
Set-Service -Name "SysMain" -StartupType Disabled
shell: 'powershell'
- name: 'Set up Node.js'
uses: 'actions/setup-node@v4'
with:
node-version-file: '.nvmrc'
architecture: '${{ matrix.arch }}'
cache: 'npm'
- name: 'Install dependencies'
run: 'npm ci'
- name: 'Check Secrets'
id: 'check_secrets'
run: |
echo "has_win_cert=${{ secrets.WINDOWS_PFX_BASE64 != '' }}" >> "$GITHUB_OUTPUT"
echo "has_mac_cert=${{ secrets.MACOS_CERT_P12_BASE64 != '' }}" >> "$GITHUB_OUTPUT"
- name: 'Setup Windows SDK (Windows)'
if: "matrix.os == 'windows-latest'"
uses: 'microsoft/setup-msbuild@v2'
- name: 'Add Signtool to Path (Windows)'
if: "matrix.os == 'windows-latest'"
run: |
$signtoolPath = Get-ChildItem -Path "C:\Program Files (x86)\Windows Kits\10\bin" -Recurse -Filter "signtool.exe" | Sort-Object FullName -Descending | Select-Object -First 1 -ExpandProperty DirectoryName
echo "Found signtool at: $signtoolPath"
echo "$signtoolPath" >> $env:GITHUB_PATH
shell: 'pwsh'
- name: 'Setup macOS Keychain'
if: "startsWith(matrix.os, 'macos') && steps.check_secrets.outputs.has_mac_cert == 'true' && github.event_name != 'pull_request'"
env:
BUILD_CERTIFICATE_BASE64: '${{ secrets.MACOS_CERT_P12_BASE64 }}'
P12_PASSWORD: '${{ secrets.MACOS_CERT_PASSWORD }}'
KEYCHAIN_PASSWORD: 'temp-password'
run: |
# Create the P12 file
echo "$BUILD_CERTIFICATE_BASE64" | base64 --decode > certificate.p12
# Create a temporary keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p "$KEYCHAIN_PASSWORD" build.keychain
# Import the certificate
security import certificate.p12 -k build.keychain -P "$P12_PASSWORD" -T /usr/bin/codesign
# Allow codesign to access it
security set-key-partition-list -S apple-tool:,apple: -s -k "$KEYCHAIN_PASSWORD" build.keychain
# Set Identity for build script
echo "APPLE_IDENTITY=${{ secrets.MACOS_CERT_IDENTITY }}" >> "$GITHUB_ENV"
- name: 'Setup Windows Certificate'
if: "matrix.os == 'windows-latest' && steps.check_secrets.outputs.has_win_cert == 'true' && github.event_name != 'pull_request'"
env:
PFX_BASE64: '${{ secrets.WINDOWS_PFX_BASE64 }}'
PFX_PASSWORD: '${{ secrets.WINDOWS_PFX_PASSWORD }}'
run: |
$pfx_cert_byte = [System.Convert]::FromBase64String("$env:PFX_BASE64")
$certPath = Join-Path (Get-Location) "cert.pfx"
[IO.File]::WriteAllBytes($certPath, $pfx_cert_byte)
echo "WINDOWS_PFX_FILE=$certPath" >> $env:GITHUB_ENV
echo "WINDOWS_PFX_PASSWORD=$env:PFX_PASSWORD" >> $env:GITHUB_ENV
shell: 'pwsh'
- name: 'Build Binary'
run: 'npm run build:binary'
- name: 'Build Core Package'
run: 'npm run build -w @google/gemini-cli-core'
- name: 'Verify Output Exists'
run: |
if [ -f "dist/${{ matrix.platform_name }}/gemini" ]; then
echo "Binary found at dist/${{ matrix.platform_name }}/gemini"
elif [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then
echo "Binary found at dist/${{ matrix.platform_name }}/gemini.exe"
else
echo "Error: Binary not found in dist/${{ matrix.platform_name }}/"
ls -R dist/
exit 1
fi
- name: 'Smoke Test Binary'
run: |
echo "Running binary smoke test..."
if [ -f "dist/${{ matrix.platform_name }}/gemini.exe" ]; then
"./dist/${{ matrix.platform_name }}/gemini.exe" --version
else
"./dist/${{ matrix.platform_name }}/gemini" --version
fi
- name: 'Run Integration Tests'
if: "github.event_name != 'pull_request'"
env:
GEMINI_API_KEY: '${{ secrets.GEMINI_API_KEY }}'
run: |
echo "Running integration tests with binary..."
if [[ "${{ matrix.os }}" == 'windows-latest' ]]; then
BINARY_PATH="$(cygpath -m "$(pwd)/dist/${{ matrix.platform_name }}/gemini.exe")"
else
BINARY_PATH="$(pwd)/dist/${{ matrix.platform_name }}/gemini"
fi
echo "Using binary at $BINARY_PATH"
export INTEGRATION_TEST_GEMINI_BINARY_PATH="$BINARY_PATH"
npm run test:integration:sandbox:none -- --testTimeout=600000
- name: 'Upload Artifact'
uses: 'actions/upload-artifact@v4'
with:
name: 'gemini-cli-${{ matrix.platform_name }}'
path: 'dist/${{ matrix.platform_name }}/'
retention-days: 5
+3 -2
View File
@@ -109,8 +109,9 @@ structure, and consultation level are proportional to the task's complexity:
- **Iterate:** Provide feedback to refine the plan. - **Iterate:** Provide feedback to refine the plan.
- **Refine manually:** Press **Ctrl + X** to open the plan file in your - **Refine manually:** Press **Ctrl + X** to open the plan file in your
[preferred external editor]. This allows you to manually refine the plan [preferred external editor]. This allows you to manually refine the plan
steps before approval. The CLI will automatically refresh and show the steps before approval. If you make any changes and save the file, the CLI
updated plan after you save and close the editor. will automatically send the updated plan back to the agent for review and
iteration.
For more complex or specialized planning tasks, you can For more complex or specialized planning tasks, you can
[customize the planning workflow with skills](#customizing-planning-with-skills). [customize the planning workflow with skills](#customizing-planning-with-skills).
+3
View File
@@ -88,6 +88,9 @@ const cliConfig = {
outfile: 'bundle/gemini.js', outfile: 'bundle/gemini.js',
define: { define: {
'process.env.CLI_VERSION': JSON.stringify(pkg.version), 'process.env.CLI_VERSION': JSON.stringify(pkg.version),
'process.env.GEMINI_SANDBOX_IMAGE_DEFAULT': JSON.stringify(
pkg.config?.sandboxImageUri,
),
}, },
plugins: createWasmPlugins(), plugins: createWasmPlugins(),
alias: { alias: {
+44 -1
View File
@@ -3,7 +3,8 @@
Behavioral evaluations (evals) are tests designed to validate the agent's Behavioral evaluations (evals) are tests designed to validate the agent's
behavior in response to specific prompts. They serve as a critical feedback loop behavior in response to specific prompts. They serve as a critical feedback loop
for changes to system prompts, tool definitions, and other model-steering for changes to system prompts, tool definitions, and other model-steering
mechanisms. mechanisms, and as a tool for assessing feature reliability by model, and
preventing regressions.
## Why Behavioral Evals? ## Why Behavioral Evals?
@@ -30,6 +31,48 @@ CLI's features.
those that are generally reliable but might occasionally vary those that are generally reliable but might occasionally vary
(`USUALLY_PASSES`). (`USUALLY_PASSES`).
## Best Practices
When designing behavioral evals, aim for scenarios that accurately reflect
real-world usage while remaining small and maintainable.
- **Realistic Complexity**: Evals should be complicated enough to be
"realistic." They should operate on actual files and a source directory,
mirroring how a real agent interacts with a workspace. Remember that the agent
may behave differently in a larger codebase, so we want to avoid scenarios
that are too simple to be realistic.
- _Good_: An eval that provides a small, functional React component and asks
the agent to add a specific feature, requiring it to read the file,
understand the context, and write the correct changes.
- _Bad_: An eval that simply asks the agent a trivia question or asks it to
write a generic script without providing any local workspace context.
- **Maintainable Size**: Evals should be small enough to reason about and
maintain. We probably can't check in an entire repo as a test case, though
over time we will want these evals to mature into more and more realistic
scenarios.
- _Good_: A test setup with 2-3 files (e.g., a source file, a config file, and
a test file) that isolates the specific behavior being evaluated.
- _Bad_: A test setup containing dozens of files from a complex framework
where the setup logic itself is prone to breaking.
- **Unambiguous and Reliable Assertions**: Assertions must be clear and specific
to ensure the test passes for the right reason.
- _Good_: Checking that a modified file contains a specific AST node or exact
string, or verifying that a tool was called with with the right parameters.
- _Bad_: Only checking for a tool call, which could happen for an unrelated
reason. Expecting specific LLM output.
- **Fail First**: Have tests that failed before your prompt or tool change. We
want to be sure the test fails before your "fix". It's pretty easy to
accidentally create a passing test that asserts behaviors we get for free. In
general, every eval should be accompanied by prompt change, and most prompt
changes should be accompanied by an eval.
- _Good_: Observing a failure, writing an eval that reliably reproduces the
failure, modifying the prompt/tool, and then verifying the eval passes.
- _Bad_: Writing an eval that passes on the first run and assuming your new
prompt change was responsible.
- **Less is More**: Prefer fewer, more realistic tests that assert the major
paths vs. more tests that are more unit-test like. These are evals, so the
value is in testing how the agent works in a semi-realistic scenario.
## Creating an Evaluation ## Creating an Evaluation
Evaluations are located in the `evals` directory. Each evaluation is a Vitest Evaluations are located in the `evals` directory. Each evaluation is a Vitest
+3 -2
View File
@@ -165,14 +165,15 @@ describe('Hooks Agent Flow', () => {
// BeforeModel hook to track message counts across LLM calls // BeforeModel hook to track message counts across LLM calls
const messageCountFile = join(rig.testDir!, 'message-counts.json'); const messageCountFile = join(rig.testDir!, 'message-counts.json');
const escapedPath = JSON.stringify(messageCountFile);
const beforeModelScript = ` const beforeModelScript = `
const fs = require('fs'); const fs = require('fs');
const input = JSON.parse(fs.readFileSync(0, 'utf-8')); const input = JSON.parse(fs.readFileSync(0, 'utf-8'));
const messageCount = input.llm_request?.contents?.length || 0; const messageCount = input.llm_request?.contents?.length || 0;
let counts = []; let counts = [];
try { counts = JSON.parse(fs.readFileSync(${JSON.stringify(messageCountFile)}, 'utf-8')); } catch (e) {} try { counts = JSON.parse(fs.readFileSync(${escapedPath}, 'utf-8')); } catch (e) {}
counts.push(messageCount); counts.push(messageCount);
fs.writeFileSync(${JSON.stringify(messageCountFile)}, JSON.stringify(counts)); fs.writeFileSync(${escapedPath}, JSON.stringify(counts));
console.log(JSON.stringify({ decision: 'allow' })); console.log(JSON.stringify({ decision: 'allow' }));
`; `;
const beforeModelScriptPath = rig.createScript( const beforeModelScriptPath = rig.createScript(
+6 -1
View File
@@ -18,6 +18,7 @@ const { shell } = getShellConfiguration();
function getLineCountCommand(): { command: string; tool: string } { function getLineCountCommand(): { command: string; tool: string } {
switch (shell) { switch (shell) {
case 'powershell': case 'powershell':
return { command: `Measure-Object -Line`, tool: 'Measure-Object' };
case 'cmd': case 'cmd':
return { command: `find /c /v`, tool: 'find' }; return { command: `find /c /v`, tool: 'find' };
case 'bash': case 'bash':
@@ -238,8 +239,12 @@ describe('run_shell_command', () => {
}); });
it('should succeed in yolo mode', async () => { it('should succeed in yolo mode', async () => {
const isWindows = process.platform === 'win32';
await rig.setup('should succeed in yolo mode', { await rig.setup('should succeed in yolo mode', {
settings: { tools: { core: ['run_shell_command'] } }, settings: {
tools: { core: ['run_shell_command'] },
shell: isWindows ? { enableInteractiveShell: false } : undefined,
},
}); });
const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n'); const testFile = rig.createFile('test.txt', 'Lorem\nIpsum\nDolor\n');
+79 -5
View File
@@ -5464,6 +5464,13 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT",
"peer": true
},
"node_modules/array-includes": { "node_modules/array-includes": {
"version": "3.1.9", "version": "3.1.9",
"resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz",
@@ -6563,6 +6570,10 @@
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
"integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==", "integrity": "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": {
"safe-buffer": "5.2.1"
},
"engines": { "engines": {
"node": ">=18" "node": ">=18"
}, },
@@ -8539,6 +8550,36 @@
"express": ">= 4.11" "express": ">= 4.11"
} }
}, },
"node_modules/express/node_modules/cookie": {
"version": "0.7.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/express/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
},
"node_modules/extend": { "node_modules/extend": {
"version": "3.0.2", "version": "3.0.2",
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
@@ -8790,11 +8831,34 @@
"statuses": "^2.0.1" "statuses": "^2.0.1"
}, },
"engines": { "engines": {
"node": ">= 18.0.0" "node": ">= 0.8"
}, }
"funding": { },
"type": "opencollective", "node_modules/finalhandler/node_modules/debug": {
"url": "https://opencollective.com/express" "version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"license": "MIT",
"peer": true,
"dependencies": {
"ms": "2.0.0"
}
},
"node_modules/finalhandler/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT",
"peer": true
},
"node_modules/finalhandler/node_modules/statuses": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
} }
}, },
"node_modules/find-up": { "node_modules/find-up": {
@@ -16222,6 +16286,16 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.4.0"
}
},
"node_modules/uuid": { "node_modules/uuid": {
"version": "9.0.1", "version": "9.0.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz",
+4 -2
View File
@@ -37,10 +37,12 @@
"build:all": "npm run build && npm run build:sandbox && npm run build:vscode", "build:all": "npm run build && npm run build:sandbox && npm run build:vscode",
"build:packages": "npm run build --workspaces", "build:packages": "npm run build --workspaces",
"build:sandbox": "node scripts/build_sandbox.js", "build:sandbox": "node scripts/build_sandbox.js",
"build:binary": "node scripts/build_binary.js",
"bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && node esbuild.config.js && node scripts/copy_bundle_assets.js", "bundle": "npm run generate && npm run build --workspace=@google/gemini-cli-devtools && node esbuild.config.js && node scripts/copy_bundle_assets.js",
"test": "npm run test --workspaces --if-present", "test": "npm run test --workspaces --if-present && npm run test:sea-launch",
"test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts", "test:ci": "npm run test:ci --workspaces --if-present && npm run test:scripts && npm run test:sea-launch",
"test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts", "test:scripts": "vitest run --config ./scripts/tests/vitest.config.ts",
"test:sea-launch": "vitest run sea/sea-launch.test.js",
"test:always_passing_evals": "vitest run --config evals/vitest.config.ts", "test:always_passing_evals": "vitest run --config evals/vitest.config.ts",
"test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts", "test:all_evals": "cross-env RUN_EVALS=1 vitest run --config evals/vitest.config.ts",
"test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none", "test:e2e": "cross-env VERBOSE=true KEEP_OUTPUT=true npm run test:integration:sandbox:none",
+3 -1
View File
@@ -102,7 +102,9 @@ export async function loadSandboxConfig(
const packageJson = await getPackageJson(__dirname); const packageJson = await getPackageJson(__dirname);
const image = const image =
process.env['GEMINI_SANDBOX_IMAGE'] ?? packageJson?.config?.sandboxImageUri; process.env['GEMINI_SANDBOX_IMAGE'] ??
process.env['GEMINI_SANDBOX_IMAGE_DEFAULT'] ??
packageJson?.config?.sandboxImageUri;
return command && image ? { command, image } : undefined; return command && image ? { command, image } : undefined;
} }
@@ -11,7 +11,6 @@ import { waitFor } from '../../test-utils/async.js';
import { ExitPlanModeDialog } from './ExitPlanModeDialog.js'; import { ExitPlanModeDialog } from './ExitPlanModeDialog.js';
import { useKeypress } from '../hooks/useKeypress.js'; import { useKeypress } from '../hooks/useKeypress.js';
import { keyMatchers, Command } from '../keyMatchers.js'; import { keyMatchers, Command } from '../keyMatchers.js';
import { openFileInEditor } from '../utils/editorUtils.js';
import { import {
ApprovalMode, ApprovalMode,
validatePlanContent, validatePlanContent,
@@ -41,10 +40,6 @@ vi.mock('node:fs', async (importOriginal) => {
...actual, ...actual,
existsSync: vi.fn(), existsSync: vi.fn(),
realpathSync: vi.fn((p) => p), realpathSync: vi.fn((p) => p),
promises: {
...actual.promises,
readFile: vi.fn(),
},
}; };
}); });
@@ -546,7 +541,7 @@ Implement a comprehensive authentication system with multiple providers.
expect(onFeedback).not.toHaveBeenCalled(); expect(onFeedback).not.toHaveBeenCalled();
}); });
it('opens plan in external editor when Ctrl+X is pressed', async () => { it('automatically submits feedback when Ctrl+X is used to edit the plan', async () => {
const { stdin, lastFrame } = renderDialog({ useAlternateBuffer }); const { stdin, lastFrame } = renderDialog({ useAlternateBuffer });
await act(async () => { await act(async () => {
@@ -557,27 +552,16 @@ Implement a comprehensive authentication system with multiple providers.
expect(lastFrame()).toContain('Add user authentication'); expect(lastFrame()).toContain('Add user authentication');
}); });
// Reset the mock to track the second call during refresh
vi.mocked(processSingleFileContent).mockClear();
// Press Ctrl+X // Press Ctrl+X
await act(async () => { await act(async () => {
writeKey(stdin, '\x18'); // Ctrl+X writeKey(stdin, '\x18'); // Ctrl+X
}); });
await waitFor(() => { await waitFor(() => {
expect(openFileInEditor).toHaveBeenCalledWith( expect(onFeedback).toHaveBeenCalledWith(
mockPlanFullPath, 'I have edited the plan or annotated it with feedback. Review the edited plan, update if necessary, and present it again for approval.',
expect.anything(),
expect.anything(),
undefined,
); );
}); });
// Verify that content is refreshed (processSingleFileContent called again)
await waitFor(() => {
expect(processSingleFileContent).toHaveBeenCalled();
});
}); });
}, },
); );
@@ -156,11 +156,15 @@ export const ExitPlanModeDialog: React.FC<ExitPlanModeDialogProps> = ({
const handleOpenEditor = useCallback(async () => { const handleOpenEditor = useCallback(async () => {
try { try {
await openFileInEditor(planPath, stdin, setRawMode, getPreferredEditor()); await openFileInEditor(planPath, stdin, setRawMode, getPreferredEditor());
onFeedback(
'I have edited the plan or annotated it with feedback. Review the edited plan, update if necessary, and present it again for approval.',
);
refresh(); refresh();
} catch (err) { } catch (err) {
debugLogger.error('Failed to open plan in editor:', err); debugLogger.error('Failed to open plan in editor:', err);
} }
}, [planPath, stdin, setRawMode, getPreferredEditor, refresh]); }, [planPath, stdin, setRawMode, getPreferredEditor, refresh, onFeedback]);
useKeypress( useKeypress(
(key) => { (key) => {
+58 -7
View File
@@ -1892,11 +1892,16 @@ ${JSON.stringify(
); );
}); });
it('should recursively call sendMessageStream with "Please continue." when InvalidStream event is received', async () => { it('should recursively call sendMessageStream with "Please continue." when InvalidStream event is received for Gemini 2 models', async () => {
vi.spyOn(client['config'], 'getContinueOnFailedApiCall').mockReturnValue( vi.spyOn(client['config'], 'getContinueOnFailedApiCall').mockReturnValue(
true, true,
); );
// Arrange // Arrange - router must return a Gemini 2 model for retry to trigger
mockRouterService.route.mockResolvedValue({
model: 'gemini-2.0-flash',
reason: 'test',
});
const mockStream1 = (async function* () { const mockStream1 = (async function* () {
yield { type: GeminiEventType.InvalidStream }; yield { type: GeminiEventType.InvalidStream };
})(); })();
@@ -1926,7 +1931,7 @@ ${JSON.stringify(
// Assert // Assert
expect(events).toEqual([ expect(events).toEqual([
{ type: GeminiEventType.ModelInfo, value: 'default-routed-model' }, { type: GeminiEventType.ModelInfo, value: 'gemini-2.0-flash' },
{ type: GeminiEventType.InvalidStream }, { type: GeminiEventType.InvalidStream },
{ type: GeminiEventType.Content, value: 'Continued content' }, { type: GeminiEventType.Content, value: 'Continued content' },
]); ]);
@@ -1937,7 +1942,7 @@ ${JSON.stringify(
// First call with original request // First call with original request
expect(mockTurnRunFn).toHaveBeenNthCalledWith( expect(mockTurnRunFn).toHaveBeenNthCalledWith(
1, 1,
{ model: 'default-routed-model', isChatModel: true }, { model: 'gemini-2.0-flash', isChatModel: true },
initialRequest, initialRequest,
expect.any(AbortSignal), expect.any(AbortSignal),
undefined, undefined,
@@ -1946,7 +1951,7 @@ ${JSON.stringify(
// Second call with "Please continue." // Second call with "Please continue."
expect(mockTurnRunFn).toHaveBeenNthCalledWith( expect(mockTurnRunFn).toHaveBeenNthCalledWith(
2, 2,
{ model: 'default-routed-model', isChatModel: true }, { model: 'gemini-2.0-flash', isChatModel: true },
[{ text: 'System: Please continue.' }], [{ text: 'System: Please continue.' }],
expect.any(AbortSignal), expect.any(AbortSignal),
undefined, undefined,
@@ -1990,11 +1995,57 @@ ${JSON.stringify(
expect(mockTurnRunFn).toHaveBeenCalledTimes(1); expect(mockTurnRunFn).toHaveBeenCalledTimes(1);
}); });
it('should not retry with "Please continue." when InvalidStream event is received for non-Gemini-2 models', async () => {
vi.spyOn(client['config'], 'getContinueOnFailedApiCall').mockReturnValue(
true,
);
// Arrange - router returns a non-Gemini-2 model
mockRouterService.route.mockResolvedValue({
model: 'gemini-3.0-pro',
reason: 'test',
});
const mockStream1 = (async function* () {
yield { type: GeminiEventType.InvalidStream };
})();
mockTurnRunFn.mockReturnValueOnce(mockStream1);
const mockChat: Partial<GeminiChat> = {
addHistory: vi.fn(),
setTools: vi.fn(),
getHistory: vi.fn().mockReturnValue([]),
getLastPromptTokenCount: vi.fn(),
};
client['chat'] = mockChat as GeminiChat;
const initialRequest = [{ text: 'Hi' }];
const promptId = 'prompt-id-invalid-stream-non-g2';
const signal = new AbortController().signal;
// Act
const stream = client.sendMessageStream(initialRequest, signal, promptId);
const events = await fromAsync(stream);
// Assert
expect(events).toEqual([
{ type: GeminiEventType.ModelInfo, value: 'gemini-3.0-pro' },
{ type: GeminiEventType.InvalidStream },
]);
// Verify that turn.run was called only once (no retry)
expect(mockTurnRunFn).toHaveBeenCalledTimes(1);
});
it('should stop recursing after one retry when InvalidStream events are repeatedly received', async () => { it('should stop recursing after one retry when InvalidStream events are repeatedly received', async () => {
vi.spyOn(client['config'], 'getContinueOnFailedApiCall').mockReturnValue( vi.spyOn(client['config'], 'getContinueOnFailedApiCall').mockReturnValue(
true, true,
); );
// Arrange // Arrange - router must return a Gemini 2 model for retry to trigger
mockRouterService.route.mockResolvedValue({
model: 'gemini-2.0-flash',
reason: 'test',
});
// Always return a new invalid stream // Always return a new invalid stream
mockTurnRunFn.mockImplementation(() => mockTurnRunFn.mockImplementation(() =>
(async function* () { (async function* () {
@@ -2025,7 +2076,7 @@ ${JSON.stringify(
events events
.filter((e) => e.type === GeminiEventType.ModelInfo) .filter((e) => e.type === GeminiEventType.ModelInfo)
.map((e) => e.value), .map((e) => e.value),
).toEqual(['default-routed-model']); ).toEqual(['gemini-2.0-flash']);
// Verify that turn.run was called twice // Verify that turn.run was called twice
expect(mockTurnRunFn).toHaveBeenCalledTimes(2); expect(mockTurnRunFn).toHaveBeenCalledTimes(2);
+5 -2
View File
@@ -60,7 +60,7 @@ import {
applyModelSelection, applyModelSelection,
createAvailabilityContextProvider, createAvailabilityContextProvider,
} from '../availability/policyHelpers.js'; } from '../availability/policyHelpers.js';
import { resolveModel } from '../config/models.js'; import { resolveModel, isGemini2Model } from '../config/models.js';
import type { RetryAvailabilityContext } from '../utils/retry.js'; import type { RetryAvailabilityContext } from '../utils/retry.js';
import { partToString } from '../utils/partUtils.js'; import { partToString } from '../utils/partUtils.js';
import { coreEvents, CoreEvent } from '../utils/events.js'; import { coreEvents, CoreEvent } from '../utils/events.js';
@@ -725,7 +725,10 @@ export class GeminiClient {
} }
if (isInvalidStream) { if (isInvalidStream) {
if (this.config.getContinueOnFailedApiCall()) { if (
this.config.getContinueOnFailedApiCall() &&
isGemini2Model(modelToUse)
) {
if (isInvalidStreamRetry) { if (isInvalidStreamRetry) {
logContentRetryFailure( logContentRetryFailure(
this.config, this.config,
@@ -315,6 +315,27 @@ describe('LoggingContentGenerator', () => {
}); });
}); });
}); });
it('should NOT log error on AbortError (user cancellation)', async () => {
const req = {
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
model: 'gemini-pro',
};
const userPromptId = 'prompt-123';
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
vi.mocked(wrapped.generateContent).mockRejectedValue(abortError);
await expect(
loggingContentGenerator.generateContent(
req,
userPromptId,
LlmRole.MAIN,
),
).rejects.toThrow(abortError);
expect(logApiError).not.toHaveBeenCalled();
});
}); });
describe('generateContentStream', () => { describe('generateContentStream', () => {
@@ -462,6 +483,67 @@ describe('LoggingContentGenerator', () => {
expect(errorEvent.duration_ms).toBe(1000); expect(errorEvent.duration_ms).toBe(1000);
}); });
it('should NOT log error on AbortError during connection phase', async () => {
const req = {
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
model: 'gemini-pro',
};
const userPromptId = 'prompt-123';
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
vi.mocked(wrapped.generateContentStream).mockRejectedValue(abortError);
await expect(
loggingContentGenerator.generateContentStream(
req,
userPromptId,
LlmRole.MAIN,
),
).rejects.toThrow(abortError);
expect(logApiError).not.toHaveBeenCalled();
});
it('should NOT log error on AbortError during stream iteration', async () => {
const req = {
contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
model: 'gemini-pro',
};
const userPromptId = 'prompt-123';
const abortError = new Error('Aborted');
abortError.name = 'AbortError';
async function* createAbortingGenerator() {
yield {
candidates: [],
text: undefined,
functionCalls: undefined,
executableCode: undefined,
codeExecutionResult: undefined,
data: undefined,
} as unknown as GenerateContentResponse;
throw abortError;
}
vi.mocked(wrapped.generateContentStream).mockResolvedValue(
createAbortingGenerator(),
);
const stream = await loggingContentGenerator.generateContentStream(
req,
userPromptId,
LlmRole.MAIN,
);
await expect(async () => {
for await (const _ of stream) {
// consume stream
}
}).rejects.toThrow(abortError);
expect(logApiError).not.toHaveBeenCalled();
});
it('should set latest API request in config for main agent requests', async () => { it('should set latest API request in config for main agent requests', async () => {
const req = { const req = {
contents: [{ role: 'user', parts: [{ text: 'hello' }] }], contents: [{ role: 'user', parts: [{ text: 'hello' }] }],
@@ -36,7 +36,7 @@ import { toContents } from '../code_assist/converter.js';
import { isStructuredError } from '../utils/quotaErrorDetection.js'; import { isStructuredError } from '../utils/quotaErrorDetection.js';
import { runInDevTraceSpan, type SpanMetadata } from '../telemetry/trace.js'; import { runInDevTraceSpan, type SpanMetadata } from '../telemetry/trace.js';
import { debugLogger } from '../utils/debugLogger.js'; import { debugLogger } from '../utils/debugLogger.js';
import { getErrorType } from '../utils/errors.js'; import { isAbortError, getErrorType } from '../utils/errors.js';
import { import {
GeminiCliOperation, GeminiCliOperation,
GEN_AI_PROMPT_NAME, GEN_AI_PROMPT_NAME,
@@ -310,6 +310,10 @@ export class LoggingContentGenerator implements ContentGenerator {
generationConfig?: GenerateContentConfig, generationConfig?: GenerateContentConfig,
serverDetails?: ServerDetails, serverDetails?: ServerDetails,
): void { ): void {
if (isAbortError(error)) {
// Don't log aborted requests (e.g., user cancellation, internal timeouts) as API errors.
return;
}
const errorMessage = error instanceof Error ? error.message : String(error); const errorMessage = error instanceof Error ? error.message : String(error);
const errorType = getErrorType(error); const errorType = getErrorType(error);
+28 -4
View File
@@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0 * SPDX-License-Identifier: Apache-2.0
*/ */
import { spawn } from 'node:child_process'; import { spawn, execSync } from 'node:child_process';
import type { import type {
HookConfig, HookConfig,
CommandHookConfig, CommandHookConfig,
@@ -331,12 +331,17 @@ export class HookRunner {
let timedOut = false; let timedOut = false;
const shellConfig = getShellConfiguration(); const shellConfig = getShellConfiguration();
const command = this.expandCommand( let command = this.expandCommand(
hookConfig.command, hookConfig.command,
input, input,
shellConfig.shell, shellConfig.shell,
); );
if (shellConfig.shell === 'powershell') {
// Append exit code check to ensure the exit code of the command is propagated
command = `${command}; if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }`;
}
// Set up environment variables // Set up environment variables
const env = { const env = {
...sanitizeEnvironment(process.env, this.config.sanitizationConfig), ...sanitizeEnvironment(process.env, this.config.sanitizationConfig),
@@ -359,12 +364,31 @@ export class HookRunner {
// Set up timeout // Set up timeout
const timeoutHandle = setTimeout(() => { const timeoutHandle = setTimeout(() => {
timedOut = true; timedOut = true;
child.kill('SIGTERM');
if (process.platform === 'win32' && child.pid) {
try {
execSync(`taskkill /pid ${child.pid} /f /t`, { timeout: 2000 });
} catch (_e) {
// Ignore errors if process is already dead or access denied
debugLogger.debug(`Taskkill failed: ${_e}`);
}
} else {
child.kill('SIGTERM');
}
// Force kill after 5 seconds // Force kill after 5 seconds
setTimeout(() => { setTimeout(() => {
if (!child.killed) { if (!child.killed) {
child.kill('SIGKILL'); if (process.platform === 'win32' && child.pid) {
try {
execSync(`taskkill /pid ${child.pid} /f /t`, { timeout: 2000 });
} catch (_e) {
// Ignore
debugLogger.debug(`Taskkill failed: ${_e}`);
}
} else {
child.kill('SIGKILL');
}
} }
}, 5000); }, 5000);
}, timeout); }, timeout);
@@ -74,16 +74,41 @@ async function main() {
}); });
if (zipProcess.error || zipProcess.status !== 0) { if (zipProcess.error || zipProcess.status !== 0) {
// Fallback to tar --format=zip if zip is not available (common on Windows) if (process.platform === 'win32') {
console.log('zip command not found, falling back to tar...'); // Fallback to PowerShell Compress-Archive on Windows
zipProcess = spawnSync( // Note: Compress-Archive only supports .zip extension, so we zip to .zip and rename
'tar', console.log('zip command not found, falling back to PowerShell...');
['-a', '-c', '--format=zip', '-f', outputFilename, '.'], const tempZip = outputFilename + '.zip';
{ // Escape single quotes for PowerShell (replace ' with '') and use single quotes for the path
cwd: skillPath, const safeTempZip = tempZip.replace(/'/g, "''");
stdio: 'inherit', zipProcess = spawnSync(
}, 'powershell.exe',
); [
'-NoProfile',
'-Command',
`Compress-Archive -Path .\\* -DestinationPath '${safeTempZip}' -Force`,
],
{
cwd: skillPath,
stdio: 'inherit',
},
);
if (zipProcess.status === 0 && require('node:fs').existsSync(tempZip)) {
require('node:fs').renameSync(tempZip, outputFilename);
}
} else {
// Fallback to tar on Unix-like systems
console.log('zip command not found, falling back to tar...');
zipProcess = spawnSync(
'tar',
['-a', '-c', '--format=zip', '-f', outputFilename, '.'],
{
cwd: skillPath,
stdio: 'inherit',
},
);
}
} }
if (zipProcess.error) { if (zipProcess.error) {
+24
View File
@@ -7,6 +7,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { import {
isAuthenticationError, isAuthenticationError,
isAbortError,
UnauthorizedError, UnauthorizedError,
toFriendlyError, toFriendlyError,
BadRequestError, BadRequestError,
@@ -48,6 +49,29 @@ describe('getErrorMessage', () => {
}); });
}); });
describe('isAbortError', () => {
it('should return true for AbortError', () => {
const error = new Error('Aborted');
error.name = 'AbortError';
expect(isAbortError(error)).toBe(true);
});
it('should return true for DOMException AbortError', () => {
const error = new DOMException('Aborted', 'AbortError');
expect(isAbortError(error)).toBe(true);
});
it('should return false for other errors', () => {
expect(isAbortError(new Error('Other error'))).toBe(false);
});
it('should return false for non-error objects', () => {
expect(isAbortError({ name: 'AbortError' })).toBe(false);
expect(isAbortError(null)).toBe(false);
expect(isAbortError('AbortError')).toBe(false);
});
});
describe('isAuthenticationError', () => { describe('isAuthenticationError', () => {
it('should detect error with code: 401 property (MCP SDK style)', () => { it('should detect error with code: 401 property (MCP SDK style)', () => {
const error = { code: 401, message: 'Unauthorized' }; const error = { code: 401, message: 'Unauthorized' };
+7
View File
@@ -26,6 +26,13 @@ export function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error; return error instanceof Error && 'code' in error;
} }
/**
* Checks if an error is an AbortError.
*/
export function isAbortError(error: unknown): boolean {
return error instanceof Error && error.name === 'AbortError';
}
export function getErrorMessage(error: unknown): string { export function getErrorMessage(error: unknown): string {
const friendlyError = toFriendlyError(error); const friendlyError = toFriendlyError(error);
if (friendlyError instanceof Error) { if (friendlyError instanceof Error) {
+10 -4
View File
@@ -498,13 +498,19 @@ export class TestRig {
command: string; command: string;
initialArgs: string[]; initialArgs: string[];
} { } {
const binaryPath = env['INTEGRATION_TEST_GEMINI_BINARY_PATH'];
const isNpmReleaseTest = const isNpmReleaseTest =
env['INTEGRATION_TEST_USE_INSTALLED_GEMINI'] === 'true'; env['INTEGRATION_TEST_USE_INSTALLED_GEMINI'] === 'true';
const geminiCommand = os.platform() === 'win32' ? 'gemini.cmd' : 'gemini'; const geminiCommand = os.platform() === 'win32' ? 'gemini.cmd' : 'gemini';
const command = isNpmReleaseTest ? geminiCommand : 'node'; let command = 'node';
const initialArgs = isNpmReleaseTest let initialArgs = [BUNDLE_PATH, ...extraInitialArgs];
? extraInitialArgs if (binaryPath) {
: [BUNDLE_PATH, ...extraInitialArgs]; command = binaryPath;
initialArgs = extraInitialArgs;
} else if (isNpmReleaseTest) {
command = geminiCommand;
initialArgs = extraInitialArgs;
}
if (this.fakeResponsesPath) { if (this.fakeResponsesPath) {
if (process.env['REGENERATE_MODEL_GOLDENS'] === 'true') { if (process.env['REGENERATE_MODEL_GOLDENS'] === 'true') {
initialArgs.push('--record-responses', this.fakeResponsesPath); initialArgs.push('--record-responses', this.fakeResponsesPath);
+27
View File
@@ -1676,6 +1676,33 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
============================================================
safe-buffer@5.2.1
(git://github.com/feross/safe-buffer.git)
The MIT License (MIT)
Copyright (c) Feross Aboukhadijeh
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 cookie@0.7.2
(No repository found) (No repository found)
+424
View File
@@ -0,0 +1,424 @@
/**
* @license
* Copyright 2025 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'node:child_process';
import {
cpSync,
rmSync,
mkdirSync,
existsSync,
copyFileSync,
writeFileSync,
readFileSync,
} from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import process from 'node:process';
import { globSync } from 'glob';
import { createHash } from 'node:crypto';
const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const distDir = join(root, 'dist');
const bundleDir = join(root, 'bundle');
const stagingDir = join(bundleDir, 'native_modules');
const seaConfigPath = join(root, 'sea-config.json');
const manifestPath = join(bundleDir, 'manifest.json');
const entitlementsPath = join(root, 'scripts/entitlements.plist');
// --- Helper Functions ---
/**
* Safely executes a command using spawnSync.
* @param {string} command
* @param {string[]} args
* @param {object} options
*/
function runCommand(command, args, options = {}) {
let finalCommand = command;
let useShell = options.shell || false;
// On Windows, npm/npx are batch files and need a shell
if (
process.platform === 'win32' &&
(command === 'npm' || command === 'npx')
) {
finalCommand = `${command}.cmd`;
useShell = true;
}
const finalOptions = {
stdio: 'inherit',
cwd: root,
shell: useShell,
...options,
};
const result = spawnSync(finalCommand, args, finalOptions);
if (result.status !== 0) {
if (result.error) {
throw result.error;
}
throw new Error(
`Command failed with exit code ${result.status}: ${command}`,
);
}
return result;
}
/**
* Removes existing digital signatures from a binary.
* @param {string} filePath
*/
function removeSignature(filePath) {
console.log(`Removing signature from ${filePath}...`);
const platform = process.platform;
try {
if (platform === 'darwin') {
spawnSync('codesign', ['--remove-signature', filePath], {
stdio: 'ignore',
});
} else if (platform === 'win32') {
spawnSync('signtool', ['remove', '/s', filePath], {
stdio: 'ignore',
});
}
} catch {
// Best effort: Ignore failures
}
}
/**
* Signs a binary using hardcoded tools for the platform.
* @param {string} filePath
*/
function signFile(filePath) {
const platform = process.platform;
if (platform === 'darwin') {
const identity = process.env.APPLE_IDENTITY || '-';
console.log(`Signing ${filePath} (Identity: ${identity})...`);
const args = [
'--sign',
identity,
'--force',
'--timestamp',
'--options',
'runtime',
];
if (existsSync(entitlementsPath)) {
args.push('--entitlements', entitlementsPath);
}
args.push(filePath);
runCommand('codesign', args);
} else if (platform === 'win32') {
const args = ['sign'];
if (process.env.WINDOWS_PFX_FILE && process.env.WINDOWS_PFX_PASSWORD) {
args.push(
'/f',
process.env.WINDOWS_PFX_FILE,
'/p',
process.env.WINDOWS_PFX_PASSWORD,
);
} else {
args.push('/a');
}
args.push(
'/fd',
'SHA256',
'/td',
'SHA256',
'/tr',
'http://timestamp.digicert.com',
filePath,
);
console.log(`Signing ${filePath}...`);
try {
runCommand('signtool', args, { stdio: 'pipe' });
} catch (e) {
let msg = e.message;
if (process.env.WINDOWS_PFX_PASSWORD) {
msg = msg.replaceAll(process.env.WINDOWS_PFX_PASSWORD, '******');
}
throw new Error(msg);
}
} else if (platform === 'linux') {
console.log(`Skipping signing for ${filePath} on Linux.`);
}
}
console.log('Build Binary Script Started...');
// 1. Clean dist
if (existsSync(distDir)) {
console.log('Cleaning dist directory...');
rmSync(distDir, { recursive: true, force: true });
}
mkdirSync(distDir, { recursive: true });
// 2. Build Bundle
console.log('Running npm clean, install, and bundle...');
try {
runCommand('npm', ['run', 'clean']);
runCommand('npm', ['install']);
runCommand('npm', ['run', 'bundle']);
} catch (e) {
console.error('Build step failed:', e.message);
process.exit(1);
}
// 3. Stage & Sign Native Modules
const includeNativeModules = process.env.BUNDLE_NATIVE_MODULES !== 'false';
console.log(`Include Native Modules: ${includeNativeModules}`);
if (includeNativeModules) {
console.log('Staging and signing native modules...');
// Prepare staging
if (existsSync(stagingDir))
rmSync(stagingDir, { recursive: true, force: true });
mkdirSync(stagingDir, { recursive: true });
// Copy @lydell/node-pty to staging
const lydellSrc = join(root, 'node_modules/@lydell');
const lydellStaging = join(stagingDir, 'node_modules/@lydell');
if (existsSync(lydellSrc)) {
mkdirSync(dirname(lydellStaging), { recursive: true });
cpSync(lydellSrc, lydellStaging, { recursive: true });
} else {
console.warn(
'Warning: @lydell/node-pty not found in node_modules. Native terminal features may fail.',
);
}
// Sign Staged .node files
try {
const nodeFiles = globSync('**/*.node', {
cwd: stagingDir,
absolute: true,
});
for (const file of nodeFiles) {
signFile(file);
}
} catch (e) {
console.warn('Warning: Failed to sign native modules:', e.code);
}
} else {
console.log('Skipping native modules bundling (BUNDLE_NATIVE_MODULES=false)');
}
// 4. Generate SEA Configuration and Manifest
console.log('Generating SEA configuration and manifest...');
const packageJson = JSON.parse(
readFileSync(join(root, 'package.json'), 'utf8'),
);
// Helper to calc hash
const sha256 = (content) => createHash('sha256').update(content).digest('hex');
// Read Main Bundle
const geminiBundlePath = join(root, 'bundle/gemini.js');
const geminiContent = readFileSync(geminiBundlePath);
const geminiHash = sha256(geminiContent);
const assets = {
'gemini.mjs': geminiBundlePath, // Use .js source but map to .mjs for runtime ESM
'manifest.json': 'bundle/manifest.json',
};
const manifest = {
main: 'gemini.mjs',
mainHash: geminiHash,
version: packageJson.version,
files: [],
};
// Helper to recursively find files from STAGING
function addAssetsFromDir(baseDir, runtimePrefix) {
const fullDir = join(stagingDir, baseDir);
if (!existsSync(fullDir)) return;
const items = globSync('**/*', { cwd: fullDir, nodir: true });
for (const item of items) {
const relativePath = join(runtimePrefix, item);
const assetKey = `files:${relativePath}`;
const fsPath = join(fullDir, item);
// Calc hash
const content = readFileSync(fsPath);
const hash = sha256(content);
assets[assetKey] = fsPath;
manifest.files.push({ key: assetKey, path: relativePath, hash: hash });
}
}
// Add sb files
const sbFiles = globSync('sandbox-macos-*.sb', { cwd: bundleDir });
for (const sbFile of sbFiles) {
const fsPath = join(bundleDir, sbFile);
const content = readFileSync(fsPath);
const hash = sha256(content);
assets[sbFile] = fsPath;
manifest.files.push({ key: sbFile, path: sbFile, hash: hash });
}
// Add policy files
const policyDir = join(bundleDir, 'policies');
if (existsSync(policyDir)) {
const policyFiles = globSync('*.toml', { cwd: policyDir });
for (const policyFile of policyFiles) {
const fsPath = join(policyDir, policyFile);
const relativePath = join('policies', policyFile);
const content = readFileSync(fsPath);
const hash = sha256(content);
// Use a unique key to avoid collision if filenames overlap (though unlikely here)
// But sea-launch writes to 'path', so key is just for lookup.
const assetKey = `policies:${policyFile}`;
assets[assetKey] = fsPath;
manifest.files.push({ key: assetKey, path: relativePath, hash: hash });
}
}
// Add assets from Staging
if (includeNativeModules) {
addAssetsFromDir('node_modules/@lydell', 'node_modules/@lydell');
}
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2));
const seaConfig = {
main: 'sea/sea-launch.cjs',
output: 'dist/sea-prep.blob',
disableExperimentalSEAWarning: true,
assets: assets,
};
writeFileSync(seaConfigPath, JSON.stringify(seaConfig, null, 2));
console.log(`Configured ${Object.keys(assets).length} embedded assets.`);
// 5. Generate SEA Blob
console.log('Generating SEA blob...');
try {
runCommand('node', ['--experimental-sea-config', 'sea-config.json']);
} catch (e) {
console.error('Failed to generate SEA blob:', e.message);
// Cleanup
if (existsSync(seaConfigPath)) rmSync(seaConfigPath);
if (existsSync(manifestPath)) rmSync(manifestPath);
if (existsSync(stagingDir))
rmSync(stagingDir, { recursive: true, force: true });
process.exit(1);
}
// Check blob existence
const blobPath = join(distDir, 'sea-prep.blob');
if (!existsSync(blobPath)) {
console.error('Error: sea-prep.blob not found in dist/');
process.exit(1);
}
// 6. Identify Target & Prepare Binary
const platform = process.platform;
const arch = process.arch;
const targetName = `${platform}-${arch}`;
console.log(`Targeting: ${targetName}`);
const targetDir = join(distDir, targetName);
mkdirSync(targetDir, { recursive: true });
const nodeBinary = process.execPath;
const binaryName = platform === 'win32' ? 'gemini.exe' : 'gemini';
const targetBinaryPath = join(targetDir, binaryName);
console.log(`Copying node binary from ${nodeBinary} to ${targetBinaryPath}...`);
copyFileSync(nodeBinary, targetBinaryPath);
// Remove existing signature using helper
removeSignature(targetBinaryPath);
// Copy standard bundle assets (policies, .sb files)
console.log('Copying additional resources...');
if (existsSync(bundleDir)) {
cpSync(bundleDir, targetDir, { recursive: true });
}
// Clean up source JS files from output (we only want embedded)
const filesToRemove = [
'gemini.js',
'gemini.mjs',
'gemini.js.map',
'gemini.mjs.map',
'gemini-sea.cjs',
'sea-launch.cjs',
'manifest.json',
'native_modules',
'policies',
];
filesToRemove.forEach((f) => {
const p = join(targetDir, f);
if (existsSync(p)) rmSync(p, { recursive: true, force: true });
});
// Remove .sb files from targetDir
const sbFilesToRemove = globSync('sandbox-macos-*.sb', { cwd: targetDir });
for (const f of sbFilesToRemove) {
rmSync(join(targetDir, f));
}
// 7. Inject Blob
console.log('Injecting SEA blob...');
const sentinelFuse = 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2';
try {
const args = [
'postject',
targetBinaryPath,
'NODE_SEA_BLOB',
blobPath,
'--sentinel-fuse',
sentinelFuse,
];
if (platform === 'darwin') {
args.push('--macho-segment-name', 'NODE_SEA');
}
runCommand('npx', args);
console.log('Injection successful.');
} catch (e) {
console.error('Postject failed:', e.message);
process.exit(1);
}
// 8. Final Signing
console.log('Signing final executable...');
try {
signFile(targetBinaryPath);
} catch (e) {
console.warn('Warning: Final signing failed:', e.code);
console.warn('Continuing without signing...');
}
// 9. Cleanup
console.log('Cleaning up artifacts...');
rmSync(blobPath);
if (existsSync(seaConfigPath)) rmSync(seaConfigPath);
if (existsSync(manifestPath)) rmSync(manifestPath);
if (existsSync(stagingDir))
rmSync(stagingDir, { recursive: true, force: true });
console.log(`Binary built successfully in ${targetDir}`);
+49
View File
@@ -0,0 +1,49 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { execSync } from 'node:child_process';
const EVALS_FILE_PREFIXES = [
'packages/core/src/prompts/',
'packages/core/src/tools/',
'evals/',
];
function main() {
const targetBranch = process.env.GITHUB_BASE_REF || 'main';
try {
// Fetch target branch from origin.
execSync(`git fetch origin ${targetBranch}`, {
stdio: 'ignore',
});
// Find the merge base with the target branch.
const mergeBase = execSync('git merge-base HEAD FETCH_HEAD', {
encoding: 'utf-8',
}).trim();
// Get changed files
const changedFiles = execSync(`git diff --name-only ${mergeBase} HEAD`, {
encoding: 'utf-8',
})
.split('\n')
.filter(Boolean);
const shouldRun = changedFiles.some((file) =>
EVALS_FILE_PREFIXES.some((prefix) => file.startsWith(prefix)),
);
console.log(shouldRun ? 'true' : 'false');
} catch (error) {
// If anything fails (e.g., no git history), run evals to be safe
console.warn(
'Warning: Failed to determine if evals should run. Defaulting to true.',
);
console.error(error);
console.log('true');
}
}
main();
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Allow JIT compilation (Required for Node.js/V8) -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<!-- Allow executable memory modification (Required for Node.js/V8) -->
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<!-- Allow loading unsigned libraries (Helpful for native modules extracted to temp) -->
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<!-- Allow access to environment variables (Standard for CLI tools) -->
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
+278
View File
@@ -0,0 +1,278 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
const { getAsset } = require('node:sea');
const process = require('node:process');
const nodeModule = require('node:module');
const path = require('node:path');
const { pathToFileURL } = require('node:url');
const fs = require('node:fs');
const os = require('node:os');
const crypto = require('node:crypto');
// --- Helper Functions ---
/**
* Strips the "ghost" argument that Node SEA sometimes injects (argv[2] == argv[0]).
* @param {string[]} argv
* @param {string} execPath
* @param {function} resolveFn
* @returns {boolean} True if an argument was removed.
*/
function sanitizeArgv(argv, execPath, resolveFn = path.resolve) {
if (argv.length > 2) {
const binaryAbs = execPath;
const arg2Abs = resolveFn(argv[2]);
if (binaryAbs === arg2Abs) {
argv.splice(2, 1);
return true;
}
}
return false;
}
/**
* Sanitizes a string for use in file paths.
* @param {string} name
* @returns {string}
*/
function getSafeName(name) {
return (name || 'unknown').toString().replace(/[^a-zA-Z0-9.-]/g, '_');
}
/**
* Verifies the integrity of the runtime directory against the manifest.
* @param {string} dir
* @param {object} manifest
* @param {object} fsMod
* @param {object} cryptoMod
* @returns {boolean}
*/
function verifyIntegrity(dir, manifest, fsMod = fs, cryptoMod = crypto) {
try {
const calculateHash = (filePath) => {
const hash = cryptoMod.createHash('sha256');
const fd = fsMod.openSync(filePath, 'r');
const buffer = new Uint8Array(65536); // 64KB
try {
let bytesRead = 0;
while (
(bytesRead = fsMod.readSync(fd, buffer, 0, buffer.length, null)) !== 0
) {
hash.update(buffer.subarray(0, bytesRead));
}
} finally {
fsMod.closeSync(fd);
}
return hash.digest('hex');
};
if (calculateHash(path.join(dir, 'gemini.mjs')) !== manifest.mainHash)
return false;
if (manifest.files) {
for (const file of manifest.files) {
if (calculateHash(path.join(dir, file.path)) !== file.hash)
return false;
}
}
return true;
} catch (_e) {
return false;
}
}
/**
* Prepares the runtime directory, extracting assets if necessary.
* @param {object} manifest
* @param {function} getAssetFn
* @param {object} deps Dependencies (fs, os, path, processEnv)
* @returns {string} The path to the prepared runtime directory.
*/
function prepareRuntime(manifest, getAssetFn, deps = {}) {
const fsMod = deps.fs || fs;
const osMod = deps.os || os;
const pathMod = deps.path || path;
const processEnv = deps.processEnv || process.env;
const processPid = deps.processPid || process.pid;
const processUid =
deps.processUid || (process.getuid ? process.getuid() : 'unknown');
const version = manifest.version || '0.0.0';
const safeVersion = getSafeName(version);
const userInfo = osMod.userInfo();
const username =
userInfo.username || processEnv.USER || processUid || 'unknown';
const safeUsername = getSafeName(username);
let tempBase = osMod.tmpdir();
if (process.platform === 'win32' && processEnv.LOCALAPPDATA) {
const appDir = pathMod.join(processEnv.LOCALAPPDATA, 'Google', 'GeminiCLI');
try {
if (!fsMod.existsSync(appDir)) {
fsMod.mkdirSync(appDir, { recursive: true, mode: 0o700 });
}
tempBase = appDir;
} catch (_) {
// Fallback to tmpdir
}
}
const finalRuntimeDir = pathMod.join(
tempBase,
`gemini-runtime-${safeVersion}-${safeUsername}`,
);
let runtimeDir;
let useExisting = false;
const isSecure = (dir) => {
try {
const stat = fsMod.lstatSync(dir);
if (!stat.isDirectory()) return false;
if (processUid !== 'unknown' && stat.uid !== processUid) return false;
// Skip strict permission check on Windows as it's unreliable with standard fs.stat
if (process.platform !== 'win32' && (stat.mode & 0o777) !== 0o700)
return false;
return true;
} catch (_) {
return false;
}
};
if (fsMod.existsSync(finalRuntimeDir)) {
if (isSecure(finalRuntimeDir)) {
if (
verifyIntegrity(finalRuntimeDir, manifest, fsMod, deps.crypto || crypto)
) {
runtimeDir = finalRuntimeDir;
useExisting = true;
} else {
try {
fsMod.rmSync(finalRuntimeDir, { recursive: true, force: true });
} catch (_) {}
}
} else {
try {
fsMod.rmSync(finalRuntimeDir, { recursive: true, force: true });
} catch (_) {}
}
}
if (!useExisting) {
const setupDir = pathMod.join(
tempBase,
`gemini-setup-${processPid}-${Date.now()}`,
);
try {
fsMod.mkdirSync(setupDir, { recursive: true, mode: 0o700 });
const writeToSetup = (assetKey, relPath) => {
const content = getAssetFn(assetKey);
if (!content) return;
const destPath = pathMod.join(setupDir, relPath);
const destDir = pathMod.dirname(destPath);
if (!fsMod.existsSync(destDir))
fsMod.mkdirSync(destDir, { recursive: true, mode: 0o700 });
fsMod.writeFileSync(destPath, new Uint8Array(content), {
mode: 0o755,
});
};
writeToSetup('gemini.mjs', 'gemini.mjs');
if (manifest.files) {
for (const file of manifest.files) {
writeToSetup(file.key, file.path);
}
}
try {
fsMod.renameSync(setupDir, finalRuntimeDir);
runtimeDir = finalRuntimeDir;
} catch (renameErr) {
if (
fsMod.existsSync(finalRuntimeDir) &&
isSecure(finalRuntimeDir) &&
verifyIntegrity(
finalRuntimeDir,
manifest,
fsMod,
deps.crypto || crypto,
)
) {
runtimeDir = finalRuntimeDir;
try {
fsMod.rmSync(setupDir, { recursive: true, force: true });
} catch (_) {}
} else {
throw renameErr;
}
}
} catch (e) {
console.error(
'Fatal Error: Failed to setup secure runtime. Please try running again and if error persists please reinstall.',
e,
);
try {
fsMod.rmSync(setupDir, { recursive: true, force: true });
} catch (_) {}
process.exit(1);
}
}
return runtimeDir;
}
// --- Main Execution ---
async function main(getAssetFn = getAsset) {
process.env.IS_BINARY = 'true';
if (nodeModule.enableCompileCache) {
nodeModule.enableCompileCache();
}
process.noDeprecation = true;
sanitizeArgv(process.argv, process.execPath);
const manifestJson = getAssetFn('manifest.json', 'utf8');
if (!manifestJson) {
console.error('Fatal Error: Corrupted binary. Please reinstall.');
process.exit(1);
}
const manifest = JSON.parse(manifestJson);
const runtimeDir = prepareRuntime(manifest, getAssetFn, {
fs,
os,
path,
processEnv: process.env,
crypto,
});
const mainPath = path.join(runtimeDir, 'gemini.mjs');
await import(pathToFileURL(mainPath).href).catch((err) => {
console.error('Fatal Error: Failed to launch. Please reinstall.', err);
console.error(err);
process.exit(1);
});
}
// Only execute if this is the main module (standard Node behavior)
// or if explicitly running as the SEA entry point (heuristic).
if (require.main === module) {
main().catch((err) => {
console.error('Unhandled error in sea-launch:', err);
process.exit(1);
});
}
module.exports = {
sanitizeArgv,
getSafeName,
verifyIntegrity,
prepareRuntime,
main,
};
+799
View File
@@ -0,0 +1,799 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import * as path from 'node:path';
import { Buffer } from 'node:buffer';
import process from 'node:process';
import {
sanitizeArgv,
getSafeName,
verifyIntegrity,
prepareRuntime,
main,
} from './sea-launch.cjs';
// Mocking fs and os
// We need to use vi.mock factory for ESM mocking of built-in modules in Vitest
vi.mock('node:fs', async () => {
const fsMock = {
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
existsSync: vi.fn(),
renameSync: vi.fn(),
rmSync: vi.fn(),
readFileSync: vi.fn().mockReturnValue('content'),
lstatSync: vi.fn(),
statSync: vi.fn(),
openSync: vi.fn(),
readSync: vi.fn(),
closeSync: vi.fn(),
};
return {
default: fsMock,
...fsMock,
};
});
vi.mock('fs', async () => {
const fsMock = {
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
existsSync: vi.fn(),
renameSync: vi.fn(),
rmSync: vi.fn(),
readFileSync: vi.fn().mockReturnValue('content'),
lstatSync: vi.fn(),
statSync: vi.fn(),
openSync: vi.fn(),
readSync: vi.fn(),
closeSync: vi.fn(),
};
return {
default: fsMock,
...fsMock,
};
});
vi.mock('node:os', async () => {
const osMock = {
userInfo: () => ({ username: 'user' }),
tmpdir: () => '/tmp',
};
return {
default: osMock,
...osMock,
};
});
vi.mock('os', async () => {
const osMock = {
userInfo: () => ({ username: 'user' }),
tmpdir: () => '/tmp',
};
return {
default: osMock,
...osMock,
};
});
describe('sea-launch', () => {
describe('main', () => {
it('executes main logic', async () => {
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(globalThis.console, 'error')
.mockImplementation(() => {});
const mockGetAsset = vi.fn((key) => {
if (key === 'manifest.json')
return JSON.stringify({ version: '1.0.0', mainHash: 'h1' });
return Buffer.from('content');
});
await main(mockGetAsset);
expect(consoleSpy).toHaveBeenCalled();
expect(exitSpy).toHaveBeenCalled();
exitSpy.mockRestore();
consoleSpy.mockRestore();
});
});
describe('sanitizeArgv', () => {
it('removes ghost argument when argv[2] matches execPath', () => {
const execPath = '/bin/node';
const argv = ['/bin/node', '/app/script.js', '/bin/node', 'arg1'];
const resolveFn = (p) => p;
const removed = sanitizeArgv(argv, execPath, resolveFn);
expect(removed).toBe(true);
expect(argv).toEqual(['/bin/node', '/app/script.js', 'arg1']);
});
it('does nothing if argv[2] does not match execPath', () => {
const execPath = '/bin/node';
const argv = ['/bin/node', '/app/script.js', 'command', 'arg1'];
const resolveFn = (p) => p;
const removed = sanitizeArgv(argv, execPath, resolveFn);
expect(removed).toBe(false);
expect(argv).toHaveLength(4);
});
it('handles resolving relative paths', () => {
const execPath = '/bin/node';
const argv = ['/bin/node', '/app/script.js', './node', 'arg1'];
const resolveFn = (p) => (p === './node' ? '/bin/node' : p);
const removed = sanitizeArgv(argv, execPath, resolveFn);
expect(removed).toBe(true);
});
});
describe('getSafeName', () => {
it('sanitizes strings', () => {
expect(getSafeName('user@name')).toBe('user_name');
expect(getSafeName('../path')).toBe('.._path');
expect(getSafeName('valid-1.2')).toBe('valid-1.2');
expect(getSafeName(undefined)).toBe('unknown');
});
});
describe('verifyIntegrity', () => {
it('returns true for matching hashes', () => {
const dir = '/tmp/test';
const manifest = {
mainHash: 'hash1',
files: [{ path: 'file.txt', hash: 'hash2' }],
};
const mockFs = {
openSync: vi.fn((p) => {
if (p.endsWith('gemini.mjs')) return 10;
if (p.endsWith('file.txt')) return 20;
throw new Error('Not found');
}),
readSync: vi.fn((fd, buffer) => {
let content = '';
if (fd === 10) content = 'content1';
if (fd === 20) content = 'content2';
// Simulate simple read: write content to buffer and return length once, then return 0
if (!buffer._readDone) {
const buf = Buffer.from(content);
buf.copy(buffer);
buffer._readDone = true;
return buf.length;
} else {
buffer._readDone = false; // Reset for next file
return 0;
}
}),
closeSync: vi.fn(),
};
const mockCrypto = {
createHash: vi.fn(() => ({
update: vi.fn(function (content) {
this._content =
(this._content || '') + Buffer.from(content).toString();
return this;
}),
digest: vi.fn(function () {
if (this._content === 'content1') return 'hash1';
if (this._content === 'content2') return 'hash2';
return 'wrong';
}),
})),
};
expect(verifyIntegrity(dir, manifest, mockFs, mockCrypto)).toBe(true);
});
it('returns false for mismatched hashes', () => {
const dir = '/tmp/test';
const manifest = { mainHash: 'hash1' };
const mockFs = {
openSync: vi.fn(() => 10),
readSync: vi.fn((fd, buffer) => {
if (!buffer._readDone) {
const buf = Buffer.from('content_wrong');
buf.copy(buffer);
buffer._readDone = true;
return buf.length;
}
return 0;
}),
closeSync: vi.fn(),
};
const mockCrypto = {
createHash: vi.fn(() => ({
update: vi.fn(function (content) {
this._content =
(this._content || '') + Buffer.from(content).toString();
return this;
}),
digest: vi.fn(function () {
return 'hash_wrong';
}),
})),
};
expect(verifyIntegrity(dir, manifest, mockFs, mockCrypto)).toBe(false);
});
it('returns false when fs throws error', () => {
const dir = '/tmp/test';
const manifest = { mainHash: 'hash1' };
const mockFs = {
openSync: vi.fn(() => {
throw new Error('FS Error');
}),
};
const mockCrypto = { createHash: vi.fn() };
expect(verifyIntegrity(dir, manifest, mockFs, mockCrypto)).toBe(false);
});
});
describe('prepareRuntime', () => {
const mockManifest = {
version: '1.0.0',
mainHash: 'h1',
files: [{ key: 'f1', path: 'p1', hash: 'h1' }],
};
const mockGetAsset = vi.fn();
const S_IFDIR = 0o40000;
const MODE_700 = 0o700;
it('reuses existing runtime if secure and valid', () => {
const deps = {
fs: {
existsSync: vi.fn(() => true),
rmSync: vi.fn(),
readFileSync: vi.fn(),
openSync: vi.fn(() => 1),
readSync: vi.fn((fd, buffer) => {
if (!buffer._readDone) {
buffer._readDone = true;
return 1;
}
return 0;
}),
closeSync: vi.fn(),
lstatSync: vi.fn(() => ({
isDirectory: () => true,
uid: 1000,
mode: S_IFDIR | MODE_700,
})),
},
os: {
userInfo: () => ({ username: 'user' }),
tmpdir: () => '/tmp',
},
path: path,
processEnv: {},
crypto: {
createHash: vi.fn(() => {
const hash = {
update: vi.fn().mockReturnThis(),
digest: vi.fn(() => 'h1'),
};
return hash;
}),
},
processUid: 1000,
};
deps.fs.readFileSync.mockReturnValue('content');
const runtime = prepareRuntime(mockManifest, mockGetAsset, deps);
expect(runtime).toContain('gemini-runtime-1.0.0-user');
expect(deps.fs.rmSync).not.toHaveBeenCalled();
});
it('recreates runtime if existing has wrong owner', () => {
const deps = {
fs: {
existsSync: vi.fn().mockReturnValueOnce(true).mockReturnValue(false),
rmSync: vi.fn(),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
renameSync: vi.fn(),
readFileSync: vi.fn().mockReturnValue('content'),
openSync: vi.fn(() => 1),
readSync: vi.fn((fd, buffer) => {
if (!buffer._readDone) {
buffer._readDone = true;
return 1;
}
return 0;
}),
closeSync: vi.fn(),
lstatSync: vi.fn(() => ({
isDirectory: () => true,
uid: 999, // Wrong UID
mode: S_IFDIR | MODE_700,
})),
},
os: {
userInfo: () => ({ username: 'user' }),
tmpdir: () => '/tmp',
},
path: path,
processEnv: {},
crypto: {
createHash: vi.fn(() => {
const hash = {
update: vi.fn().mockReturnThis(),
digest: vi.fn(() => 'h1'),
};
return hash;
}),
},
processUid: 1000,
processPid: 123,
};
mockGetAsset.mockReturnValue(Buffer.from('asset_content'));
prepareRuntime(mockManifest, mockGetAsset, deps);
expect(deps.fs.rmSync).toHaveBeenCalledWith(
expect.stringContaining('gemini-runtime'),
expect.anything(),
);
expect(deps.fs.mkdirSync).toHaveBeenCalledWith(
expect.stringContaining('gemini-setup'),
expect.anything(),
);
});
it('recreates runtime if existing has wrong permissions', () => {
const deps = {
fs: {
existsSync: vi.fn().mockReturnValueOnce(true).mockReturnValue(false),
rmSync: vi.fn(),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
renameSync: vi.fn(),
readFileSync: vi.fn().mockReturnValue('content'),
openSync: vi.fn(() => 1),
readSync: vi.fn((fd, buffer) => {
if (!buffer._readDone) {
buffer._readDone = true;
return 1;
}
return 0;
}),
closeSync: vi.fn(),
lstatSync: vi.fn(() => ({
isDirectory: () => true,
uid: 1000,
mode: S_IFDIR | 0o777, // Too open
})),
},
os: {
userInfo: () => ({ username: 'user' }),
tmpdir: () => '/tmp',
},
path: path,
processEnv: {},
crypto: {
createHash: vi.fn(() => {
const hash = {
update: vi.fn().mockReturnThis(),
digest: vi.fn(() => 'h1'),
};
return hash;
}),
},
processUid: 1000,
processPid: 123,
};
mockGetAsset.mockReturnValue(Buffer.from('asset_content'));
prepareRuntime(mockManifest, mockGetAsset, deps);
expect(deps.fs.rmSync).toHaveBeenCalledWith(
expect.stringContaining('gemini-runtime'),
expect.anything(),
);
});
it('creates new runtime if existing is invalid (integrity check)', () => {
const deps = {
fs: {
existsSync: vi.fn().mockReturnValueOnce(true).mockReturnValue(false),
rmSync: vi.fn(),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
renameSync: vi.fn(),
readFileSync: vi.fn().mockReturnValue('wrong_content'),
openSync: vi.fn(() => 1),
readSync: vi.fn((fd, buffer) => {
if (!buffer._readDone) {
buffer._readDone = true;
return 1;
}
return 0;
}),
closeSync: vi.fn(),
lstatSync: vi.fn(() => ({
isDirectory: () => true,
uid: 1000,
mode: S_IFDIR | MODE_700,
})),
},
os: {
userInfo: () => ({ username: 'user' }),
tmpdir: () => '/tmp',
},
path: path,
processEnv: {},
crypto: {
createHash: vi.fn(() => {
const hash = {
update: vi.fn().mockReturnThis(),
digest: vi.fn(() => 'hash_calculated'),
};
return hash;
}),
},
processUid: 1000,
processPid: 123,
};
mockGetAsset.mockReturnValue(Buffer.from('asset_content'));
prepareRuntime(mockManifest, mockGetAsset, deps);
expect(deps.fs.rmSync).toHaveBeenCalledWith(
expect.stringContaining('gemini-runtime'),
expect.anything(),
);
expect(deps.fs.mkdirSync).toHaveBeenCalledWith(
expect.stringContaining('gemini-setup'),
expect.anything(),
);
});
it('handles rename race condition: uses target if secure and valid', () => {
const deps = {
fs: {
existsSync: vi.fn(),
rmSync: vi.fn(),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
renameSync: vi.fn(() => {
throw new Error('Rename failed');
}),
readFileSync: vi.fn().mockReturnValue('content'),
openSync: vi.fn(() => 1),
readSync: vi.fn((fd, buffer) => {
if (!buffer._readDone) {
buffer._readDone = true;
return 1;
}
return 0;
}),
closeSync: vi.fn(),
lstatSync: vi.fn(() => ({
isDirectory: () => true,
uid: 1000,
mode: S_IFDIR | MODE_700,
})),
},
os: {
userInfo: () => ({ username: 'user' }),
tmpdir: () => '/tmp',
},
path: path,
processEnv: {},
crypto: {
createHash: vi.fn(() => {
const hash = {
update: vi.fn().mockReturnThis(),
digest: vi.fn(() => 'h1'),
};
return hash;
}),
},
processUid: 1000,
processPid: 123,
};
// 1. Initial exists check -> false
// 2. mkdir checks (destDir) -> false
// 3. renameSync -> throws
// 4. existsSync (race check) -> true
deps.fs.existsSync
.mockReturnValueOnce(false)
.mockReturnValueOnce(false)
.mockReturnValue(true);
mockGetAsset.mockReturnValue(Buffer.from('asset_content'));
const runtime = prepareRuntime(mockManifest, mockGetAsset, deps);
expect(deps.fs.renameSync).toHaveBeenCalled();
expect(runtime).toContain('gemini-runtime');
expect(deps.fs.rmSync).toHaveBeenCalledWith(
expect.stringContaining('gemini-setup'),
expect.anything(),
);
});
it('handles rename race condition: fails if target is insecure', () => {
const deps = {
fs: {
existsSync: vi.fn(),
rmSync: vi.fn(),
mkdirSync: vi.fn(),
writeFileSync: vi.fn(),
renameSync: vi.fn(() => {
throw new Error('Rename failed');
}),
readFileSync: vi.fn().mockReturnValue('content'),
openSync: vi.fn(() => 1),
readSync: vi.fn((fd, buffer) => {
if (!buffer._readDone) {
buffer._readDone = true;
return 1;
}
return 0;
}),
closeSync: vi.fn(),
lstatSync: vi.fn(() => ({
isDirectory: () => true,
uid: 999, // Wrong UID
mode: S_IFDIR | MODE_700,
})),
},
os: {
userInfo: () => ({ username: 'user' }),
tmpdir: () => '/tmp',
},
path: path,
processEnv: {},
crypto: {
createHash: vi.fn(() => {
const hash = {
update: vi.fn().mockReturnThis(),
digest: vi.fn(() => 'h1'),
};
return hash;
}),
},
processUid: 1000,
processPid: 123,
};
deps.fs.existsSync
.mockReturnValueOnce(false)
.mockReturnValueOnce(false)
.mockReturnValue(true);
mockGetAsset.mockReturnValue(Buffer.from('asset_content'));
// Mock process.exit and console.error
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {});
const consoleSpy = vi
.spyOn(globalThis.console, 'error')
.mockImplementation(() => {});
prepareRuntime(mockManifest, mockGetAsset, deps);
expect(exitSpy).toHaveBeenCalledWith(1);
exitSpy.mockRestore();
consoleSpy.mockRestore();
});
it('uses LOCALAPPDATA on Windows if available', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'win32',
configurable: true,
});
const deps = {
fs: {
existsSync: vi.fn().mockReturnValue(false),
mkdirSync: vi.fn(),
rmSync: vi.fn(),
writeFileSync: vi.fn(),
renameSync: vi.fn(),
readFileSync: vi.fn().mockReturnValue('content'),
openSync: vi.fn(() => 1),
readSync: vi.fn((fd, buffer) => {
if (!buffer._readDone) {
buffer._readDone = true;
return 1;
}
return 0;
}),
closeSync: vi.fn(),
lstatSync: vi.fn(() => ({
isDirectory: () => true,
uid: 0,
mode: S_IFDIR | MODE_700,
})),
},
os: {
userInfo: () => ({ username: 'user' }),
tmpdir: () => 'C:\\Temp',
},
path: {
join: (...args) => args.join('\\'),
dirname: (p) => p.split('\\').slice(0, -1).join('\\'),
resolve: (p) => p,
},
processEnv: {
LOCALAPPDATA: 'C:\\Users\\User\\AppData\\Local',
},
crypto: {
createHash: vi.fn(() => {
const hash = {
update: vi.fn().mockReturnThis(),
digest: vi.fn(() => 'h1'),
};
return hash;
}),
},
processUid: 'unknown',
};
prepareRuntime(mockManifest, mockGetAsset, deps);
expect(deps.fs.mkdirSync).toHaveBeenCalledWith(
'C:\\Users\\User\\AppData\\Local\\Google\\GeminiCLI',
expect.objectContaining({ recursive: true }),
);
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
});
});
it('falls back to tmpdir on Windows if LOCALAPPDATA is missing', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'win32',
configurable: true,
});
const deps = {
fs: {
existsSync: vi.fn().mockReturnValue(false),
mkdirSync: vi.fn(),
rmSync: vi.fn(),
writeFileSync: vi.fn(),
renameSync: vi.fn(),
readFileSync: vi.fn().mockReturnValue('content'),
openSync: vi.fn(() => 1),
readSync: vi.fn((fd, buffer) => {
if (!buffer._readDone) {
buffer._readDone = true;
return 1;
}
return 0;
}),
closeSync: vi.fn(),
lstatSync: vi.fn(() => ({
isDirectory: () => true,
uid: 0,
mode: S_IFDIR | MODE_700,
})),
},
os: {
userInfo: () => ({ username: 'user' }),
tmpdir: () => 'C:\\Temp',
},
path: {
join: (...args) => args.join('\\'),
dirname: (p) => p.split('\\').slice(0, -1).join('\\'),
resolve: (p) => p,
},
processEnv: {}, // Missing LOCALAPPDATA
crypto: {
createHash: vi.fn(() => {
const hash = {
update: vi.fn().mockReturnThis(),
digest: vi.fn(() => 'h1'),
};
return hash;
}),
},
processUid: 'unknown',
};
const runtime = prepareRuntime(mockManifest, mockGetAsset, deps);
// Should use tmpdir
expect(runtime).toContain('C:\\Temp');
expect(runtime).not.toContain('Google\\GeminiCLI');
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
});
});
it('falls back to tmpdir on Windows if mkdir fails', () => {
const originalPlatform = process.platform;
Object.defineProperty(process, 'platform', {
value: 'win32',
configurable: true,
});
const deps = {
fs: {
existsSync: vi.fn().mockReturnValue(false),
mkdirSync: vi.fn((p) => {
if (typeof p === 'string' && p.includes('Google\\GeminiCLI')) {
throw new Error('Permission denied');
}
}),
rmSync: vi.fn(),
writeFileSync: vi.fn(),
renameSync: vi.fn(),
readFileSync: vi.fn().mockReturnValue('content'),
openSync: vi.fn(() => 1),
readSync: vi.fn((fd, buffer) => {
if (!buffer._readDone) {
buffer._readDone = true;
return 1;
}
return 0;
}),
closeSync: vi.fn(),
lstatSync: vi.fn(() => ({
isDirectory: () => true,
uid: 0,
mode: S_IFDIR | MODE_700,
})),
},
os: {
userInfo: () => ({ username: 'user' }),
tmpdir: () => 'C:\\Temp',
},
path: {
join: (...args) => args.join('\\'),
dirname: (p) => p.split('\\').slice(0, -1).join('\\'),
resolve: (p) => p,
},
processEnv: {
LOCALAPPDATA: 'C:\\Users\\User\\AppData\\Local',
},
crypto: {
createHash: vi.fn(() => {
const hash = {
update: vi.fn().mockReturnThis(),
digest: vi.fn(() => 'h1'),
};
return hash;
}),
},
processUid: 'unknown',
};
const runtime = prepareRuntime(mockManifest, mockGetAsset, deps);
// Should use tmpdir
expect(runtime).toContain('C:\\Temp');
expect(deps.fs.mkdirSync).toHaveBeenCalledWith(
expect.stringContaining('Google\\GeminiCLI'),
expect.anything(),
);
Object.defineProperty(process, 'platform', {
value: originalPlatform,
configurable: true,
});
});
});
});