mirror of
https://github.com/google-gemini/gemini-cli.git
synced 2026-08-11 01:16:28 -07:00
Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bd94d7434c | |||
| b39816c870 | |||
| 157e64b490 | |||
| 761f604c16 | |||
| 63c5b74770 | |||
| 348fc35f17 | |||
| 56f9688b30 | |||
| 6863148728 | |||
| bde504f250 | |||
| b6b41f79eb | |||
| 8b60087673 | |||
| ac42fb0a24 | |||
| f47d6c6f7a | |||
| d55e366f6a | |||
| dc859e8e48 | |||
| 4bb7e93c45 | |||
| 55a31ef909 | |||
| 3499c84f7b |
@@ -172,7 +172,7 @@ runs:
|
||||
--workspace="${INPUTS_CORE_PACKAGE_NAME}" \
|
||||
--tag staging-tmp
|
||||
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
|
||||
npm dist-tag rm ${INPUTS_CORE_PACKAGE_NAME} staging-tmp
|
||||
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'
|
||||
@@ -251,7 +251,7 @@ runs:
|
||||
${PUBLISH_TARGET} \
|
||||
--tag staging-tmp
|
||||
if [[ "${INPUTS_DRY_RUN}" == "false" ]]; then
|
||||
npm dist-tag rm ${INPUTS_CLI_PACKAGE_NAME} staging-tmp
|
||||
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'
|
||||
@@ -278,9 +278,9 @@ runs:
|
||||
--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
|
||||
fi
|
||||
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'
|
||||
|
||||
@@ -18,6 +18,23 @@ on GitHub.
|
||||
| [Preview](preview.md) | Experimental features ready for early feedback. |
|
||||
| [Stable](latest.md) | Stable, recommended for general use. |
|
||||
|
||||
## Announcements: v0.53.0 - 2026-07-28
|
||||
|
||||
- **Caretaker Triage Orchestration:** Implemented an LLM triage orchestrator and
|
||||
container build setup
|
||||
([#28345](https://github.com/google-gemini/gemini-cli/pull/28345) by
|
||||
@chadd28).
|
||||
- **Eval Coverage Reporting:** Introduced a new command for generating
|
||||
evaluation coverage reports
|
||||
([#28169](https://github.com/google-gemini/gemini-cli/pull/28169) by @ved015).
|
||||
- **Security & Loop Mitigations:** Enforced workspace trust and task isolation
|
||||
in the A2A server, aligned macOS Seatbelt profiles with the deny-default
|
||||
model, and mitigated infinite ReAct/prompt injection loops
|
||||
([#28470](https://github.com/google-gemini/gemini-cli/pull/28470) by
|
||||
@luisfelipe-alt,
|
||||
[#28424](https://github.com/google-gemini/gemini-cli/pull/28424) by
|
||||
@ompatel-aiml).
|
||||
|
||||
## Announcements: v0.52.0 - 2026-07-22
|
||||
|
||||
- **Caretaker Triage & Egress Services:** Implemented the core triage worker
|
||||
|
||||
+31
-52
@@ -1,6 +1,6 @@
|
||||
# Latest stable release: v0.52.0
|
||||
# Latest stable release: v0.53.0
|
||||
|
||||
Released: July 22, 2026
|
||||
Released: July 28, 2026
|
||||
|
||||
For most users, our latest stable release is the recommended release. Install
|
||||
the latest stable version with:
|
||||
@@ -11,59 +11,38 @@ npm install -g @google/gemini-cli
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Caretaker Services:** Introduced a new caretaker triage worker including
|
||||
core foundational modules, main worker execution loops, egress action
|
||||
publishers, and octokit GitHub Action handlers.
|
||||
- **Robust File Editing:** Core tools like `write_file` and `replace` now bypass
|
||||
LLM corrections for JSON and IPYNB files to ensure accurate and direct file
|
||||
modifications.
|
||||
- **Plan Mode Improvements:** Simplified plan mode write policies to natively
|
||||
support writing to relative paths, enhancing project directory navigation.
|
||||
- **Enhanced Account Visibility:** Improved clear user-facing messages when the
|
||||
user account does not have a Code Assist tier, and enriched shared project
|
||||
quota limit errors with setup instructions.
|
||||
- **Caretaker Triage Orchestrator:** Implemented an LLM triage orchestrator and
|
||||
container build setup to manage automated caretakers.
|
||||
- **Eval Coverage Reporting:** Introduced a new command for generating
|
||||
evaluation coverage reports to track agent decision logic and testing.
|
||||
- **Security and Sandboxing:** Aligned macOS permissive Seatbelt profiles with
|
||||
the deny-default model, and enforced workspace trust with task isolation in
|
||||
the A2A server.
|
||||
- **Robust Conversation Loops:** Coalesced consecutive message roles and grouped
|
||||
cancelled tool responses to avoid Bad Request errors, and mitigated infinite
|
||||
ReAct loops and prompt injection vulnerabilities.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- Refactor: exclude transient CI configuration files from workspace context by
|
||||
@DavidAPierce in
|
||||
[#28216](https://github.com/google-gemini/gemini-cli/pull/28216)
|
||||
- feat(caretaker-triage): add triage worker core foundational modules by
|
||||
@chadd28 in [#28163](https://github.com/google-gemini/gemini-cli/pull/28163)
|
||||
- feat(caretaker-egress): implement octokit github action handler for egress
|
||||
service by @chadd28 in
|
||||
[#28303](https://github.com/google-gemini/gemini-cli/pull/28303)
|
||||
- chore(release): bump version to 0.52.0-nightly.20260707.g27a3da3e8 by
|
||||
@gemini-cli-robot in
|
||||
[#28323](https://github.com/google-gemini/gemini-cli/pull/28323)
|
||||
- Changelog for v0.51.0-preview.0 by @gemini-cli-robot in
|
||||
[#28320](https://github.com/google-gemini/gemini-cli/pull/28320)
|
||||
- Changelog for v0.50.0 by @gemini-cli-robot in
|
||||
[#28322](https://github.com/google-gemini/gemini-cli/pull/28322)
|
||||
- fix(core-tools): bypass LLM correction for JSON and IPYNB files in write_file
|
||||
and replace by @amelidev in
|
||||
[#28223](https://github.com/google-gemini/gemini-cli/pull/28223)
|
||||
- fix(core): use unambiguous previous intent label in fallback summary by
|
||||
@amelidev in [#28343](https://github.com/google-gemini/gemini-cli/pull/28343)
|
||||
- feat(caretaker-triage): implement main worker execution loop and egress action
|
||||
publisher by @chadd28 in
|
||||
[#28306](https://github.com/google-gemini/gemini-cli/pull/28306)
|
||||
- fix(privacy): show a clear message when the account has no Code Assist tier by
|
||||
@ompatel-aiml in
|
||||
[#28304](https://github.com/google-gemini/gemini-cli/pull/28304)
|
||||
- fix(core): enrich shared project quota limit errors with setup hint by
|
||||
@amelidev in [#28391](https://github.com/google-gemini/gemini-cli/pull/28391)
|
||||
- fix(a2a-server): ensure task cancellation aborts execution loop by
|
||||
- fix(core,a2a): group cancelled tool responses and coalesce consecutive roles
|
||||
to prevent 400 Bad Request by @luisfelipe-alt in
|
||||
[#28407](https://github.com/google-gemini/gemini-cli/pull/28407)
|
||||
- feat(caretaker-triage): implement LLM triage orchestrator and container build
|
||||
by @chadd28 in
|
||||
[#28345](https://github.com/google-gemini/gemini-cli/pull/28345)
|
||||
- refactor(cli): align macOS permissive Seatbelt profiles with deny-default
|
||||
model by @ompatel-aiml in
|
||||
[#28424](https://github.com/google-gemini/gemini-cli/pull/28424)
|
||||
- fix(core): mitigate infinite ReAct loops and prompt injection loops by
|
||||
@amelidev in [#28429](https://github.com/google-gemini/gemini-cli/pull/28429)
|
||||
- fix(a2a-server): enforce workspace trust and task isolation to prevent RCE by
|
||||
@luisfelipe-alt in
|
||||
[#28316](https://github.com/google-gemini/gemini-cli/pull/28316)
|
||||
- fix(core): simplify plan mode write policy to support relative paths by
|
||||
@DavidAPierce in
|
||||
[#28398](https://github.com/google-gemini/gemini-cli/pull/28398)
|
||||
- feat(core): Bump node google-auth-library version to 10.9.0 by @jerrylin3321
|
||||
in [#28385](https://github.com/google-gemini/gemini-cli/pull/28385)
|
||||
- chore/release: bump version to 0.52.0-nightly.20260715.gfa975395b by
|
||||
@gemini-cli-robot in
|
||||
[#28402](https://github.com/google-gemini/gemini-cli/pull/28402)
|
||||
[#28470](https://github.com/google-gemini/gemini-cli/pull/28470)
|
||||
- fix(core): sequentially verify cached credentials and restore
|
||||
GOOGLE_APPLICATION_CREDENTIALS fallback by @luisfelipe-alt in
|
||||
[#28472](https://github.com/google-gemini/gemini-cli/pull/28472)
|
||||
- feat(evals): add eval coverage report command by @ved015 in
|
||||
[#28169](https://github.com/google-gemini/gemini-cli/pull/28169)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.51.0...v0.52.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.52.0...v0.53.0
|
||||
|
||||
+52
-35
@@ -1,6 +1,6 @@
|
||||
# Preview release: v0.53.0-preview.0
|
||||
# Preview release: v0.54.0-preview.0
|
||||
|
||||
Released: July 22, 2026
|
||||
Released: July 28, 2026
|
||||
|
||||
Our preview release includes the latest, new, and experimental features. This
|
||||
release may not be as stable as our [latest weekly release](latest.md).
|
||||
@@ -13,42 +13,59 @@ npm install -g @google/gemini-cli@preview
|
||||
|
||||
## Highlights
|
||||
|
||||
- **Caretaker LLM Triage Orchestrator**: Implemented the LLM triage orchestrator
|
||||
and container build configuration to support caretaker triage workflows.
|
||||
- **Enhanced Workspace Trust & Sandbox Hardening**: Aligned macOS permissive
|
||||
Seatbelt profiles with the deny-default model and enforced workspace trust and
|
||||
task isolation in the Agent-to-Agent (A2A) server to prevent remote code
|
||||
execution (RCE).
|
||||
- **Core Robustness & API Protections**: Mitigated infinite ReAct and prompt
|
||||
injection loops, and prevented 400 Bad Request errors by grouping cancelled
|
||||
tool responses and coalescing consecutive roles.
|
||||
- **Robust Credentials & Fallbacks**: Restored the
|
||||
`GOOGLE_APPLICATION_CREDENTIALS` environment variable fallback and
|
||||
sequentially verified cached credentials.
|
||||
- **Evaluation Coverage Reporting**: Added a new command to generate
|
||||
comprehensive eval coverage reports.
|
||||
- **Antigravity Agent & PR Generator:** Integrated the Antigravity agent runner,
|
||||
Firestore dual-locking for concurrency, prompt templates, and ingestion
|
||||
testing utilities.
|
||||
- **Caretaker Triage & Issue Management:** Enhanced the issue triage workflow by
|
||||
automatically posting a comment before closing issues, and sanitizing and
|
||||
wrapping issue titles in `untrusted_context`.
|
||||
- **Core API & Session Stability:** Enforced HTTPS for
|
||||
GoogleCredentialsAuthProvider to prevent cleartext leakage, rotated session
|
||||
IDs on model fallback to prevent stateful API errors, and refined chat history
|
||||
by filtering out thought parts when context management is disabled.
|
||||
|
||||
## What's Changed
|
||||
|
||||
- fix(core,a2a): group cancelled tool responses and coalesce consecutive roles
|
||||
to prevent 400 Bad Request by @luisfelipe-alt in
|
||||
[#28407](https://github.com/google-gemini/gemini-cli/pull/28407)
|
||||
- feat(caretaker-triage): implement LLM triage orchestrator and container build
|
||||
by @chadd28 in
|
||||
[#28345](https://github.com/google-gemini/gemini-cli/pull/28345)
|
||||
- refactor(cli): align macOS permissive Seatbelt profiles with deny-default
|
||||
model by @ompatel-aiml in
|
||||
[#28424](https://github.com/google-gemini/gemini-cli/pull/28424)
|
||||
- fix(core): mitigate infinite ReAct loops and prompt injection loops by
|
||||
@amelidev in [#28429](https://github.com/google-gemini/gemini-cli/pull/28429)
|
||||
- fix(a2a-server): enforce workspace trust and task isolation to prevent RCE by
|
||||
- Changelog for v0.53.0-preview.0 by @gemini-cli-robot in
|
||||
[#28507](https://github.com/google-gemini/gemini-cli/pull/28507)
|
||||
- Changelog for v0.52.0 by @gemini-cli-robot in
|
||||
[#28508](https://github.com/google-gemini/gemini-cli/pull/28508)
|
||||
- chore(release): bump version to 0.54.0-nightly.20260722.gf743ab579 by
|
||||
@gemini-cli-robot in
|
||||
[#28510](https://github.com/google-gemini/gemini-cli/pull/28510)
|
||||
- fix(caretaker): sanitize and wrap issue title in untrusted_context by @chadd28
|
||||
in [#28352](https://github.com/google-gemini/gemini-cli/pull/28352)
|
||||
- chore(caretaker): update vitest to v3.2.4 and add package-lock.json files by
|
||||
@chadd28 in [#28409](https://github.com/google-gemini/gemini-cli/pull/28409)
|
||||
- fix(core): rotate session ID on model fallback to prevent stateful API errors
|
||||
by @amelidev in
|
||||
[#28469](https://github.com/google-gemini/gemini-cli/pull/28469)
|
||||
- feat(caretaker-triage): post comment before auto-closing issues by @chadd28 in
|
||||
[#28411](https://github.com/google-gemini/gemini-cli/pull/28411)
|
||||
- fix(core): enforce HTTPS for GoogleCredentialsAuthProvider to prevent
|
||||
cleartext leakage by @amelidev in
|
||||
[#28517](https://github.com/google-gemini/gemini-cli/pull/28517)
|
||||
- fix(core): filter out thought parts from getHistoryTurns when context
|
||||
management is disabled by @DavidAPierce in
|
||||
[#28509](https://github.com/google-gemini/gemini-cli/pull/28509)
|
||||
- fix(a2a-server): normalize CRLF line endings to LF in getProposedContent by
|
||||
@luisfelipe-alt in
|
||||
[#28470](https://github.com/google-gemini/gemini-cli/pull/28470)
|
||||
- fix(core): sequentially verify cached credentials and restore
|
||||
GOOGLE_APPLICATION_CREDENTIALS fallback by @luisfelipe-alt in
|
||||
[#28472](https://github.com/google-gemini/gemini-cli/pull/28472)
|
||||
- feat(evals): add eval coverage report command by @ved015 in
|
||||
[#28169](https://github.com/google-gemini/gemini-cli/pull/28169)
|
||||
[#28531](https://github.com/google-gemini/gemini-cli/pull/28531)
|
||||
- fix(core): enforce explicit tag length and validation in file keychain by
|
||||
@luisfelipe-alt in
|
||||
[#28523](https://github.com/google-gemini/gemini-cli/pull/28523)
|
||||
- chore/release: bump version to 0.54.0-nightly.20260728.gbef611950 by
|
||||
@gemini-cli-robot in
|
||||
[#28552](https://github.com/google-gemini/gemini-cli/pull/28552)
|
||||
- feat(pr-generator-db): implement Firestore concurrency dual-locking and test
|
||||
ingestion utilities by @joneba-google in
|
||||
[#28432](https://github.com/google-gemini/gemini-cli/pull/28432)
|
||||
- feat(pr-generator-agent): implement Antigravity agent runner and prompt
|
||||
templates … by @joneba-google in
|
||||
[#28434](https://github.com/google-gemini/gemini-cli/pull/28434)
|
||||
- fix(core): skip merged function-response turns when finding the active loop by
|
||||
@adamfweidman in
|
||||
[#28565](https://github.com/google-gemini/gemini-cli/pull/28565)
|
||||
|
||||
**Full Changelog**:
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.52.0-preview.0...v0.53.0-preview.0
|
||||
https://github.com/google-gemini/gemini-cli/compare/v0.53.0-preview.0...v0.54.0-preview.0
|
||||
|
||||
Generated
+9
-9
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0-preview.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0-preview.2",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -17782,7 +17782,7 @@
|
||||
},
|
||||
"packages/a2a-server": {
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0-preview.2",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
"@google-cloud/storage": "7.19.0",
|
||||
@@ -18242,7 +18242,7 @@
|
||||
},
|
||||
"packages/cli": {
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0-preview.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
@@ -18458,7 +18458,7 @@
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0-preview.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@a2a-js/sdk": "0.3.11",
|
||||
@@ -19131,7 +19131,7 @@
|
||||
},
|
||||
"packages/devtools": {
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0-preview.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"ws": "8.16.0"
|
||||
@@ -19167,7 +19167,7 @@
|
||||
},
|
||||
"packages/sdk": {
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0-preview.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -19506,7 +19506,7 @@
|
||||
},
|
||||
"packages/test-utils": {
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0-preview.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@google/gemini-cli-core": "file:../core",
|
||||
@@ -19524,7 +19524,7 @@
|
||||
},
|
||||
"packages/vscode-ide-companion": {
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0-preview.2",
|
||||
"license": "LICENSE",
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "1.23.0",
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.54.2",
|
||||
"version": "0.55.0-preview.2",
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
@@ -14,7 +14,7 @@
|
||||
"url": "git+https://github.com/google-gemini/gemini-cli.git"
|
||||
},
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.54.1"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.55.0-preview.2"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "cross-env NODE_ENV=development node scripts/start.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-a2a-server",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0-preview.2",
|
||||
"description": "Gemini CLI A2A Server",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -131,9 +131,11 @@ export class Task {
|
||||
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. The 'stop'
|
||||
// intent achieves this.
|
||||
async () => 'stop',
|
||||
// for future requests without retrying the current one.
|
||||
async (failedModel, fallbackModel) => {
|
||||
this.config.activateFallbackMode(fallbackModel, failedModel);
|
||||
return 'stop';
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0-preview.2",
|
||||
"description": "Gemini CLI",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
@@ -27,7 +27,7 @@
|
||||
"dist"
|
||||
],
|
||||
"config": {
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.54.1"
|
||||
"sandboxImageUri": "us-docker.pkg.dev/gemini-code-dev/gemini-cli/sandbox:0.55.0-preview.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "0.16.1",
|
||||
|
||||
@@ -103,7 +103,10 @@ vi.mock('../utils.js', () => ({
|
||||
|
||||
describe('extensions install command', () => {
|
||||
it('should fail if no source is provided', () => {
|
||||
const validationParser = yargs([]).command(installCommand).fail(false);
|
||||
const validationParser = yargs([])
|
||||
.locale('en')
|
||||
.command(installCommand)
|
||||
.fail(false);
|
||||
expect(() => validationParser.parse('install')).toThrow(
|
||||
'Not enough non-option arguments: got 0, need at least 1',
|
||||
);
|
||||
|
||||
@@ -27,7 +27,10 @@ vi.mock('../utils.js', () => ({
|
||||
|
||||
describe('extensions validate command', () => {
|
||||
it('should fail if no path is provided', () => {
|
||||
const validationParser = yargs([]).command(validateCommand).fail(false);
|
||||
const validationParser = yargs([])
|
||||
.locale('en')
|
||||
.command(validateCommand)
|
||||
.fail(false);
|
||||
expect(() => validationParser.parse('validate')).toThrow(
|
||||
'Not enough non-option arguments: got 0, need at least 1',
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('mcp command', () => {
|
||||
});
|
||||
|
||||
it('should show help when no subcommand is provided', async () => {
|
||||
const yargsInstance = yargs();
|
||||
const yargsInstance = yargs().locale('en');
|
||||
(mcpCommand.builder as (y: Argv) => Argv)(yargsInstance);
|
||||
|
||||
const parser = yargsInstance.command(mcpCommand).help();
|
||||
|
||||
@@ -292,6 +292,140 @@ describe('sandbox', () => {
|
||||
await expect(start_sandbox(config)).rejects.toThrow(FatalSandboxError);
|
||||
});
|
||||
|
||||
it('should fall back to embedded profile if the .sb file is missing on disk', async () => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) =>
|
||||
String(p).includes(
|
||||
'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb',
|
||||
),
|
||||
);
|
||||
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'sandbox-exec',
|
||||
image: 'some-image',
|
||||
});
|
||||
|
||||
const onSpy = vi.spyOn(process, 'on');
|
||||
const offSpy = vi.spyOn(process, 'off');
|
||||
|
||||
interface MockProcess extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
}
|
||||
const mockSpawnProcess = new EventEmitter() as MockProcess;
|
||||
mockSpawnProcess.stdout = new EventEmitter();
|
||||
mockSpawnProcess.stderr = new EventEmitter();
|
||||
vi.mocked(spawn).mockReturnValue(
|
||||
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
|
||||
);
|
||||
|
||||
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
||||
|
||||
setTimeout(() => {
|
||||
mockSpawnProcess.emit('close', 0);
|
||||
}, 10);
|
||||
|
||||
await expect(promise).resolves.toBe(0);
|
||||
|
||||
// Verify fs.writeFileSync was called with the temp profile file, content, and 0o600 permissions
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb',
|
||||
),
|
||||
expect.stringContaining('deny default'),
|
||||
expect.objectContaining({
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify spawn was called with the temp profile file
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
'sandbox-exec',
|
||||
expect.arrayContaining([
|
||||
'-f',
|
||||
expect.stringContaining(
|
||||
'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb',
|
||||
),
|
||||
]),
|
||||
expect.objectContaining({ stdio: 'inherit' }),
|
||||
);
|
||||
|
||||
// Verify process on/off hooks were called for exit, SIGINT, and SIGTERM cleanups
|
||||
expect(onSpy).toHaveBeenCalledWith('exit', expect.any(Function));
|
||||
expect(onSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function));
|
||||
expect(onSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
|
||||
|
||||
expect(offSpy).toHaveBeenCalledWith('exit', expect.any(Function));
|
||||
expect(offSpy).toHaveBeenCalledWith('SIGINT', expect.any(Function));
|
||||
expect(offSpy).toHaveBeenCalledWith('SIGTERM', expect.any(Function));
|
||||
|
||||
// Verify fs.unlinkSync was called to clean up the temp file
|
||||
expect(fs.unlinkSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
'gemini-sandbox-macos-permissive-open-a1b2c3d4e5f6.sb',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'permissive-open',
|
||||
'permissive-closed',
|
||||
'permissive-proxied',
|
||||
'restrictive-open',
|
||||
'restrictive-closed',
|
||||
'restrictive-proxied',
|
||||
'strict-open',
|
||||
'strict-proxied',
|
||||
])(
|
||||
'should fall back to embedded content successfully for profile "%s"',
|
||||
async (profile) => {
|
||||
vi.mocked(os.platform).mockReturnValue('darwin');
|
||||
// Mock existsSync to return false for the profile file but true for temp directories
|
||||
vi.mocked(fs.existsSync).mockImplementation((p) =>
|
||||
String(p).includes('gemini-sandbox-macos-'),
|
||||
);
|
||||
|
||||
vi.stubEnv('SEATBELT_PROFILE', profile);
|
||||
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'sandbox-exec',
|
||||
image: 'some-image',
|
||||
});
|
||||
|
||||
interface MockProcess extends EventEmitter {
|
||||
stdout: EventEmitter;
|
||||
stderr: EventEmitter;
|
||||
}
|
||||
const mockSpawnProcess = new EventEmitter() as MockProcess;
|
||||
mockSpawnProcess.stdout = new EventEmitter();
|
||||
mockSpawnProcess.stderr = new EventEmitter();
|
||||
vi.mocked(spawn).mockReturnValue(
|
||||
mockSpawnProcess as unknown as ReturnType<typeof spawn>,
|
||||
);
|
||||
|
||||
const promise = start_sandbox(config, [], undefined, ['arg1']);
|
||||
|
||||
setTimeout(() => {
|
||||
mockSpawnProcess.emit('close', 0);
|
||||
}, 10);
|
||||
|
||||
await expect(promise).resolves.toBe(0);
|
||||
|
||||
// Verify fs.writeFileSync was called with the correct file mode and content for the profile
|
||||
expect(fs.writeFileSync).toHaveBeenCalledWith(
|
||||
expect.stringContaining(`gemini-sandbox-macos-${profile}-`),
|
||||
expect.stringContaining('deny default'),
|
||||
expect.objectContaining({
|
||||
encoding: 'utf8',
|
||||
mode: 0o600,
|
||||
}),
|
||||
);
|
||||
|
||||
vi.unstubAllEnvs();
|
||||
},
|
||||
);
|
||||
|
||||
it('should handle Docker execution', async () => {
|
||||
const config: SandboxConfig = createMockSandboxConfig({
|
||||
command: 'docker',
|
||||
|
||||
+212
-149
@@ -39,6 +39,7 @@ import {
|
||||
SANDBOX_PROXY_NAME,
|
||||
BUILTIN_SEATBELT_PROFILES,
|
||||
} from './sandboxUtils.js';
|
||||
import { BUILTIN_SEATBELT_PROFILE_CONTENTS } from './sandboxBuiltinProfiles.js';
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -56,6 +57,41 @@ export async function start_sandbox(
|
||||
patcher.patch();
|
||||
|
||||
let stopProxy: (() => void) | undefined = undefined;
|
||||
let tempProfileFile: string | null = null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (tempProfileFile && fs.existsSync(tempProfileFile)) {
|
||||
try {
|
||||
fs.unlinkSync(tempProfileFile);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
tempProfileFile = null;
|
||||
}
|
||||
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') {
|
||||
@@ -81,161 +117,193 @@ export async function start_sandbox(
|
||||
profileFile = fs.existsSync(userProfileFile)
|
||||
? userProfileFile
|
||||
: projectProfileFile;
|
||||
}
|
||||
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 args = [
|
||||
'-D',
|
||||
`TARGET_DIR=${fs.realpathSync(process.cwd())}`,
|
||||
'-D',
|
||||
`TMP_DIR=${fs.realpathSync(os.tmpdir())}`,
|
||||
'-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 targetDir = fs.realpathSync(cliConfig?.getTargetDir() || '');
|
||||
const includedDirs: string[] = [];
|
||||
|
||||
if (cliConfig) {
|
||||
const workspaceContext = cliConfig.getWorkspaceContext();
|
||||
const directories = workspaceContext.getDirectories();
|
||||
|
||||
// Filter out TARGET_DIR
|
||||
for (const dir of directories) {
|
||||
const realDir = fs.realpathSync(dir);
|
||||
if (realDir !== targetDir) {
|
||||
includedDirs.push(realDir);
|
||||
} 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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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) {
|
||||
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 args = [
|
||||
'-D',
|
||||
`TARGET_DIR=${fs.realpathSync(process.cwd())}`,
|
||||
'-D',
|
||||
`TMP_DIR=${fs.realpathSync(os.tmpdir())}`,
|
||||
'-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 targetDir = fs.realpathSync(cliConfig?.getTargetDir() || '');
|
||||
const includedDirs: string[] = [];
|
||||
|
||||
if (cliConfig) {
|
||||
const workspaceContext = cliConfig.getWorkspaceContext();
|
||||
const directories = workspaceContext.getDirectories();
|
||||
|
||||
// Filter out TARGET_DIR
|
||||
for (const dir of directories) {
|
||||
const realDir = fs.realpathSync(dir);
|
||||
if (realDir !== targetDir) {
|
||||
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`,
|
||||
`NODE_OPTIONS="${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 };
|
||||
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
|
||||
// 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) {
|
||||
includedDirs.push(realDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
process.on('exit', stopProxy);
|
||||
process.on('SIGINT', stopProxy);
|
||||
process.on('SIGTERM', stopProxy);
|
||||
}
|
||||
|
||||
// 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');
|
||||
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];
|
||||
}
|
||||
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`,
|
||||
|
||||
args.push('-D', `INCLUDE_DIR_${i}=${dirPath}`);
|
||||
}
|
||||
|
||||
const finalArgv = cliArgs;
|
||||
|
||||
args.push(
|
||||
'-f',
|
||||
profileFile,
|
||||
'sh',
|
||||
'-c',
|
||||
[
|
||||
`SANDBOX=sandbox-exec`,
|
||||
'NODE_OPTIONS=' + quote([nodeOptions]),
|
||||
...finalArgv.map((arg) => quote([arg])),
|
||||
].join(' '),
|
||||
);
|
||||
}
|
||||
// spawn child and let it inherit stdio
|
||||
process.stdin.pause();
|
||||
sandboxProcess = spawn(config.command, args, {
|
||||
stdio: 'inherit',
|
||||
});
|
||||
return await new Promise((resolve, reject) => {
|
||||
sandboxProcess?.on('error', reject);
|
||||
sandboxProcess?.on('close', (code) => {
|
||||
process.stdin.resume();
|
||||
resolve(code ?? 1);
|
||||
// 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 };
|
||||
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',
|
||||
});
|
||||
});
|
||||
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') {
|
||||
@@ -768,9 +836,6 @@ export async function start_sandbox(
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
process.on('exit', stopProxy);
|
||||
process.on('SIGINT', stopProxy);
|
||||
process.on('SIGTERM', stopProxy);
|
||||
|
||||
// commented out as it disrupts ink rendering
|
||||
// proxyProcess.stdout?.on('data', (data) => {
|
||||
@@ -821,12 +886,10 @@ export async function start_sandbox(
|
||||
});
|
||||
});
|
||||
} finally {
|
||||
if (stopProxy) {
|
||||
stopProxy();
|
||||
process.off('exit', stopProxy);
|
||||
process.off('SIGINT', stopProxy);
|
||||
process.off('SIGTERM', stopProxy);
|
||||
}
|
||||
process.off('exit', cleanup);
|
||||
process.off('SIGINT', sigintHandler);
|
||||
process.off('SIGTERM', sigtermHandler);
|
||||
cleanup();
|
||||
patcher.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,555 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Google LLC
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
export const BUILTIN_SEATBELT_PROFILE_CONTENTS: Record<string, string> = {
|
||||
'permissive-open': `(version 1)
|
||||
(deny default)
|
||||
(allow file-read*)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target self))
|
||||
(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 file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(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")
|
||||
(literal "/dev/ptmx")
|
||||
(regex #"^/dev/ttys[0-9]*$")
|
||||
)
|
||||
(allow mach-lookup
|
||||
(global-name "com.apple.sysmond")
|
||||
(global-name "com.apple.system.opendirectoryd.libinfo")
|
||||
(global-name "com.apple.system.opendirectoryd.membership")
|
||||
(global-name "com.apple.bsd.dirhelper")
|
||||
(global-name "com.apple.SecurityServer")
|
||||
(global-name "com.apple.networkd")
|
||||
(global-name "com.apple.ocspd")
|
||||
(global-name "com.apple.trustd")
|
||||
(global-name "com.apple.trustd.agent")
|
||||
(global-name "com.apple.mDNSResponder")
|
||||
(global-name "com.apple.mDNSResponderHelper")
|
||||
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
|
||||
(global-name "com.apple.SystemConfiguration.configd")
|
||||
)
|
||||
(allow system-socket
|
||||
(require-all
|
||||
(socket-domain AF_SYSTEM)
|
||||
(socket-protocol 2)
|
||||
)
|
||||
)
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
(allow network-inbound (local ip "*:*"))
|
||||
(allow network-bind (local ip "*:*"))
|
||||
(allow network-outbound)`,
|
||||
|
||||
'permissive-proxied': `(version 1)
|
||||
(deny default)
|
||||
(allow file-read*)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target self))
|
||||
(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 file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(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")
|
||||
(literal "/dev/ptmx")
|
||||
(regex #"^/dev/ttys[0-9]*$")
|
||||
)
|
||||
(allow mach-lookup
|
||||
(global-name "com.apple.sysmond")
|
||||
(global-name "com.apple.system.opendirectoryd.libinfo")
|
||||
(global-name "com.apple.system.opendirectoryd.membership")
|
||||
(global-name "com.apple.bsd.dirhelper")
|
||||
(global-name "com.apple.SecurityServer")
|
||||
(global-name "com.apple.networkd")
|
||||
(global-name "com.apple.ocspd")
|
||||
(global-name "com.apple.trustd")
|
||||
(global-name "com.apple.trustd.agent")
|
||||
(global-name "com.apple.mDNSResponder")
|
||||
(global-name "com.apple.mDNSResponderHelper")
|
||||
(global-name "com.apple.SystemConfiguration.DNSConfiguration")
|
||||
(global-name "com.apple.SystemConfiguration.configd")
|
||||
)
|
||||
(allow system-socket
|
||||
(require-all
|
||||
(socket-domain AF_SYSTEM)
|
||||
(socket-protocol 2)
|
||||
)
|
||||
)
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
(allow network-inbound (local ip "localhost:9229"))
|
||||
(allow network-bind (local ip "*:*"))
|
||||
(allow network-outbound (remote tcp "localhost:8877"))`,
|
||||
|
||||
'restrictive-open': `(version 1)
|
||||
(deny default)
|
||||
(allow file-read*)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target self))
|
||||
(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 file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(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 mach-lookup (global-name "com.apple.sysmond"))
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
(allow network-inbound (local ip "localhost:9229"))
|
||||
(allow network-outbound)`,
|
||||
|
||||
'restrictive-proxied': `(version 1)
|
||||
(deny default)
|
||||
(allow file-read*)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target self))
|
||||
(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 file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(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 mach-lookup (global-name "com.apple.sysmond"))
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
(allow network-inbound (local ip "localhost:9229"))
|
||||
(allow network-outbound (remote tcp "localhost:8877"))`,
|
||||
|
||||
'strict-open': `(version 1)
|
||||
(deny default)
|
||||
(allow file-read*
|
||||
(literal "/")
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(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"))
|
||||
(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"))
|
||||
(subpath "/usr")
|
||||
(subpath "/bin")
|
||||
(subpath "/sbin")
|
||||
(subpath "/Library")
|
||||
(subpath "/System")
|
||||
(subpath "/private")
|
||||
(subpath "/dev")
|
||||
(subpath "/etc")
|
||||
(subpath "/opt")
|
||||
(subpath "/Applications")
|
||||
)
|
||||
(allow file-read-metadata)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target self))
|
||||
(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 file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(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 mach-lookup (global-name "com.apple.sysmond"))
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
(allow network-inbound (local ip "localhost:9229"))
|
||||
(allow network-outbound)`,
|
||||
|
||||
'strict-proxied': `(version 1)
|
||||
(deny default)
|
||||
(allow file-read*
|
||||
(literal "/")
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(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"))
|
||||
(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"))
|
||||
(subpath "/usr")
|
||||
(subpath "/bin")
|
||||
(subpath "/sbin")
|
||||
(subpath "/Library")
|
||||
(subpath "/System")
|
||||
(subpath "/private")
|
||||
(subpath "/dev")
|
||||
(subpath "/etc")
|
||||
(subpath "/opt")
|
||||
(subpath "/Applications")
|
||||
)
|
||||
(allow file-read-metadata)
|
||||
(allow process-exec)
|
||||
(allow process-fork)
|
||||
(allow signal (target self))
|
||||
(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 file-write*
|
||||
(subpath (param "TARGET_DIR"))
|
||||
(subpath (param "TMP_DIR"))
|
||||
(subpath (param "CACHE_DIR"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.gemini"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.npm"))
|
||||
(subpath (string-append (param "HOME_DIR") "/.cache"))
|
||||
(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 mach-lookup (global-name "com.apple.sysmond"))
|
||||
(allow file-ioctl (regex #"^/dev/tty.*"))
|
||||
(allow network-inbound (local ip "localhost:9229"))
|
||||
(allow network-outbound (remote tcp "localhost:8877"))`,
|
||||
};
|
||||
|
||||
// Map standard 'closed' profiles to their strict counterparts for backward compatibility and fallback support
|
||||
BUILTIN_SEATBELT_PROFILE_CONTENTS['permissive-closed'] =
|
||||
BUILTIN_SEATBELT_PROFILE_CONTENTS['strict-open'];
|
||||
BUILTIN_SEATBELT_PROFILE_CONTENTS['restrictive-closed'] =
|
||||
BUILTIN_SEATBELT_PROFILE_CONTENTS['strict-proxied'];
|
||||
@@ -15,8 +15,10 @@ export const SANDBOX_NETWORK_NAME = 'gemini-cli-sandbox';
|
||||
export const SANDBOX_PROXY_NAME = 'gemini-cli-sandbox-proxy';
|
||||
export const BUILTIN_SEATBELT_PROFILES = [
|
||||
'permissive-open',
|
||||
'permissive-closed',
|
||||
'permissive-proxied',
|
||||
'restrictive-open',
|
||||
'restrictive-closed',
|
||||
'restrictive-proxied',
|
||||
'strict-open',
|
||||
'strict-proxied',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-core",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0-preview.2",
|
||||
"description": "Gemini CLI Core",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1938,6 +1938,9 @@ export class Config implements McpContext, AgentLoopContext {
|
||||
}
|
||||
|
||||
activateFallbackMode(model: string, failedModel?: string): void {
|
||||
debugLogger.log(
|
||||
`Model fallback activated: switching from ${failedModel ?? 'unknown'} to ${model}`,
|
||||
);
|
||||
if (this.getActiveModel() !== model) {
|
||||
this.setModel(model, true);
|
||||
}
|
||||
|
||||
@@ -109,6 +109,16 @@ describe('parseAndFormatApiError', () => {
|
||||
expect(result).toContain(vertexMessage);
|
||||
});
|
||||
|
||||
it('should format a StructuredError with status: undefined', () => {
|
||||
const error: StructuredError = {
|
||||
message: 'Rate limit exceeded (simulated 429 error, limit: 0)',
|
||||
status: undefined,
|
||||
};
|
||||
const expected =
|
||||
'[API Error: Rate limit exceeded (simulated 429 error, limit: 0)]';
|
||||
expect(parseAndFormatApiError(error)).toBe(expected);
|
||||
});
|
||||
|
||||
it('should handle an unknown error type', () => {
|
||||
const error = 12345;
|
||||
const expected = '[API Error: An unknown error occurred.]';
|
||||
|
||||
@@ -107,6 +107,36 @@ describe('Retry Utility Fallback Integration', () => {
|
||||
expect(mockApiCall).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should call onPersistent429 immediately on attempt 1 when classifyGoogleError returns TerminalQuotaError', async () => {
|
||||
const mockApiCall = vi
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new TerminalQuotaError('Capacity exhausted', mockGoogleApiError),
|
||||
);
|
||||
|
||||
const mockPersistent429Callback = vi.fn(
|
||||
async () =>
|
||||
// Return null to stop retrying after fallback attempt
|
||||
null,
|
||||
);
|
||||
|
||||
const promise = retryWithBackoff(mockApiCall, {
|
||||
maxAttempts: 10, // High maxAttempts to prove we don't wait for max attempts
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 10,
|
||||
onPersistent429: mockPersistent429Callback,
|
||||
authType: AuthType.LOGIN_WITH_GOOGLE,
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toThrow('Capacity exhausted');
|
||||
expect(mockApiCall).toHaveBeenCalledTimes(1); // Only called once because it's terminal and fallback returned null
|
||||
expect(mockPersistent429Callback).toHaveBeenCalledTimes(1);
|
||||
expect(mockPersistent429Callback).toHaveBeenCalledWith(
|
||||
AuthType.LOGIN_WITH_GOOGLE,
|
||||
expect.any(TerminalQuotaError),
|
||||
);
|
||||
});
|
||||
|
||||
it('should trigger onPersistent429 when HTTP 499 persists through all retry attempts', async () => {
|
||||
let fallbackCalled = false;
|
||||
const mockError: HttpError = new Error('Simulated 499 error');
|
||||
|
||||
@@ -445,4 +445,113 @@ describe('parseGoogleApiError', () => {
|
||||
expect(parsed?.code).toBe(429);
|
||||
expect(parsed?.message).toBe('Quota exceeded');
|
||||
});
|
||||
|
||||
it('should parse an error wrapped inside cause.message by gaxios', () => {
|
||||
const mockError = {
|
||||
code: 429,
|
||||
status: 429,
|
||||
cause: {
|
||||
message: JSON.stringify([
|
||||
{
|
||||
error: {
|
||||
code: 429,
|
||||
message:
|
||||
'No capacity available for model gemini-3.1-pro-preview on the server',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'MODEL_CAPACITY_EXHAUSTED',
|
||||
domain: 'cloudcode-pa.googleapis.com',
|
||||
metadata: { model: 'gemini-3.1-pro-preview' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]),
|
||||
code: 429,
|
||||
status: 'Too Many Requests',
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = parseGoogleApiError(mockError);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.code).toBe(429);
|
||||
expect(parsed?.message).toBe(
|
||||
'No capacity available for model gemini-3.1-pro-preview on the server',
|
||||
);
|
||||
expect(parsed?.details).toHaveLength(1);
|
||||
expect(parsed?.details[0]['@type']).toBe(
|
||||
'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
);
|
||||
});
|
||||
|
||||
it('should parse an error where cause is a plain ErrorShape and propagate outer code', () => {
|
||||
const mockError = {
|
||||
code: 429,
|
||||
cause: {
|
||||
message: 'Quota exceeded on the server',
|
||||
},
|
||||
};
|
||||
|
||||
const parsed = parseGoogleApiError(mockError);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.code).toBe(429);
|
||||
expect(parsed?.message).toBe('Quota exceeded on the server');
|
||||
});
|
||||
|
||||
it('should parse an error where cause is a standard Error object and propagate outer status', () => {
|
||||
const mockError = {
|
||||
status: 503,
|
||||
cause: new Error('Service Unavailable'),
|
||||
};
|
||||
|
||||
const parsed = parseGoogleApiError(mockError);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.code).toBe(503);
|
||||
expect(parsed?.message).toBe('Service Unavailable');
|
||||
});
|
||||
|
||||
it('should defensively parse numeric string status codes from outer error', () => {
|
||||
const mockError = {
|
||||
status: '503',
|
||||
cause: new Error('Service Unavailable'),
|
||||
};
|
||||
|
||||
const parsed = parseGoogleApiError(mockError);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.code).toBe(503);
|
||||
expect(parsed?.message).toBe('Service Unavailable');
|
||||
});
|
||||
|
||||
it('should return null for non-numeric string status codes from outer error', () => {
|
||||
const mockError = {
|
||||
status: 'Too Many Requests',
|
||||
cause: new Error('Quota exceeded'),
|
||||
};
|
||||
|
||||
const parsed = parseGoogleApiError(mockError);
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
|
||||
it('should return null for empty or whitespace-only string status codes from outer error', () => {
|
||||
const mockError = {
|
||||
status: ' ',
|
||||
cause: new Error('Quota exceeded'),
|
||||
};
|
||||
|
||||
const parsed = parseGoogleApiError(mockError);
|
||||
expect(parsed).toBeNull();
|
||||
});
|
||||
|
||||
it('should parse an error where cause is a plain string and propagate outer status', () => {
|
||||
const mockError = {
|
||||
status: 429,
|
||||
cause: 'Quota exceeded on the server',
|
||||
};
|
||||
|
||||
const parsed = parseGoogleApiError(mockError);
|
||||
expect(parsed).not.toBeNull();
|
||||
expect(parsed?.code).toBe(429);
|
||||
expect(parsed?.message).toBe('Quota exceeded on the server');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -153,6 +153,18 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip parsing if the error is already a classified quota error
|
||||
if (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'name' in error &&
|
||||
(error.name === 'TerminalQuotaError' ||
|
||||
error.name === 'RetryableQuotaError' ||
|
||||
error.name === 'ValidationRequiredError')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let errorObj: unknown = error;
|
||||
|
||||
// If error is a string, try to parse it.
|
||||
@@ -174,7 +186,9 @@ export function parseGoogleApiError(error: unknown): GoogleApiError | null {
|
||||
}
|
||||
|
||||
let currentError: ErrorShape | undefined =
|
||||
fromGaxiosError(errorObj) ?? fromApiError(errorObj);
|
||||
fromGaxiosError(errorObj) ??
|
||||
fromApiError(errorObj) ??
|
||||
fromCauseError(errorObj);
|
||||
|
||||
let depth = 0;
|
||||
const maxDepth = 10;
|
||||
@@ -371,3 +385,70 @@ function fromApiError(errorObj: object): ErrorShape | undefined {
|
||||
}
|
||||
return outerError;
|
||||
}
|
||||
|
||||
function fromCauseError(errorObj: object): ErrorShape | undefined {
|
||||
const err = errorObj as {
|
||||
code?: unknown;
|
||||
status?: unknown;
|
||||
cause?: unknown;
|
||||
};
|
||||
if (!err.cause) return undefined;
|
||||
|
||||
const rawCode = err.code ?? err.status;
|
||||
const fallbackCode =
|
||||
typeof rawCode === 'number'
|
||||
? rawCode
|
||||
: typeof rawCode === 'string' &&
|
||||
rawCode.trim() !== '' &&
|
||||
!isNaN(Number(rawCode))
|
||||
? Number(rawCode)
|
||||
: undefined;
|
||||
|
||||
const resolveError = (
|
||||
resolved: ErrorShape | undefined,
|
||||
): ErrorShape | undefined => {
|
||||
if (!resolved) return undefined;
|
||||
const message = resolved.message;
|
||||
const details = resolved.details;
|
||||
const code = resolved.code ?? fallbackCode;
|
||||
return {
|
||||
...(message !== undefined ? { message } : {}),
|
||||
...(details !== undefined ? { details } : {}),
|
||||
...(code !== undefined ? { code } : {}),
|
||||
};
|
||||
};
|
||||
|
||||
if (typeof err.cause === 'object' && err.cause !== null) {
|
||||
if (
|
||||
'error' in err.cause &&
|
||||
err.cause.error &&
|
||||
isErrorShape(err.cause.error)
|
||||
) {
|
||||
return resolveError(err.cause.error);
|
||||
}
|
||||
if ('message' in err.cause && err.cause.message) {
|
||||
if (typeof err.cause.message === 'string') {
|
||||
const parsed = fromApiError({ message: err.cause.message });
|
||||
if (parsed) return resolveError(parsed);
|
||||
} else if (
|
||||
typeof err.cause.message === 'object' &&
|
||||
err.cause.message !== null
|
||||
) {
|
||||
const msgObj = err.cause.message as { error?: unknown };
|
||||
if (msgObj.error && isErrorShape(msgObj.error)) {
|
||||
return resolveError(msgObj.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isErrorShape(err.cause)) {
|
||||
return resolveError(err.cause);
|
||||
}
|
||||
}
|
||||
if (typeof err.cause === 'string' && err.cause.trim() !== '') {
|
||||
const parsed = fromApiError({ message: err.cause }) ?? {
|
||||
message: err.cause,
|
||||
};
|
||||
return resolveError(parsed);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('classifyGoogleError', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('should return RetryableQuotaError with delay for 503 Service Unavailable with RetryInfo', () => {
|
||||
it('should return TerminalQuotaError for MODEL_CAPACITY_EXHAUSTED even with RetryInfo headers', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 503,
|
||||
message:
|
||||
@@ -103,8 +103,7 @@ describe('classifyGoogleError', () => {
|
||||
};
|
||||
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||
const result = classifyGoogleError(new Error());
|
||||
expect(result).toBeInstanceOf(RetryableQuotaError);
|
||||
expect((result as RetryableQuotaError).retryDelayMs).toBe(9000);
|
||||
expect(result).toBeInstanceOf(TerminalQuotaError);
|
||||
});
|
||||
|
||||
it('should return TerminalQuotaError for MODEL_CAPACITY_EXHAUSTED when no retry delay is specified', () => {
|
||||
@@ -126,6 +125,24 @@ describe('classifyGoogleError', () => {
|
||||
expect(result).toBeInstanceOf(TerminalQuotaError);
|
||||
});
|
||||
|
||||
it('should return TerminalQuotaError for structured error with details when message contains capacity exhaustion keywords', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 429,
|
||||
message: 'You have exhausted your capacity on this model.',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.Help',
|
||||
links: [
|
||||
{ description: 'Learn more', url: 'https://support.google.com' },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||
const result = classifyGoogleError(new Error());
|
||||
expect(result).toBeInstanceOf(TerminalQuotaError);
|
||||
});
|
||||
|
||||
it('should return TerminalQuotaError for MODEL_CAPACITY_EXHAUSTED even when the domain is not a Cloud Code domain (domain-agnostic)', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 429,
|
||||
@@ -396,6 +413,28 @@ describe('classifyGoogleError', () => {
|
||||
expect((result as TerminalQuotaError).reason).toBe('RATE_LIMIT_EXCEEDED');
|
||||
});
|
||||
|
||||
it('should return TerminalQuotaError for Cloud Code RATE_LIMIT_EXCEEDED without a specified server delay', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 429,
|
||||
message: 'Rate limit exceeded',
|
||||
details: [
|
||||
{
|
||||
'@type': 'type.googleapis.com/google.rpc.ErrorInfo',
|
||||
reason: 'RATE_LIMIT_EXCEEDED',
|
||||
domain: 'cloudcode-pa.googleapis.com',
|
||||
metadata: {
|
||||
uiMessage: 'true',
|
||||
model: 'gemini-2.5-pro',
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
vi.spyOn(errorParser, 'parseGoogleApiError').mockReturnValue(apiError);
|
||||
const result = classifyGoogleError(new Error());
|
||||
expect(result).toBeInstanceOf(TerminalQuotaError);
|
||||
expect((result as TerminalQuotaError).reason).toBe('RATE_LIMIT_EXCEEDED');
|
||||
});
|
||||
|
||||
it('should return TerminalQuotaError for Cloud Code QUOTA_EXHAUSTED', () => {
|
||||
const apiError: GoogleApiError = {
|
||||
code: 429,
|
||||
|
||||
@@ -28,15 +28,17 @@ enum GoogleApiType {
|
||||
export class TerminalQuotaError extends Error {
|
||||
retryDelayMs?: number;
|
||||
reason?: string;
|
||||
status?: number;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
override readonly cause: GoogleApiError,
|
||||
override readonly cause?: GoogleApiError,
|
||||
retryDelaySeconds?: number,
|
||||
reason?: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'TerminalQuotaError';
|
||||
this.status = cause?.code;
|
||||
this.retryDelayMs = retryDelaySeconds
|
||||
? retryDelaySeconds * 1000
|
||||
: undefined;
|
||||
@@ -53,14 +55,16 @@ export class TerminalQuotaError extends Error {
|
||||
*/
|
||||
export class RetryableQuotaError extends Error {
|
||||
retryDelayMs?: number;
|
||||
status?: number;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
override readonly cause: GoogleApiError,
|
||||
override readonly cause?: GoogleApiError,
|
||||
retryDelaySeconds?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'RetryableQuotaError';
|
||||
this.status = cause?.code;
|
||||
this.retryDelayMs = retryDelaySeconds
|
||||
? retryDelaySeconds * 1000
|
||||
: undefined;
|
||||
@@ -217,6 +221,20 @@ function classifyValidationRequiredError(
|
||||
* @returns A classified error or the original `unknown` error.
|
||||
*/
|
||||
export function classifyGoogleError(error: unknown): unknown {
|
||||
if (
|
||||
error instanceof TerminalQuotaError ||
|
||||
error instanceof RetryableQuotaError ||
|
||||
error instanceof ValidationRequiredError ||
|
||||
(typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'name' in error &&
|
||||
(error.name === 'TerminalQuotaError' ||
|
||||
error.name === 'RetryableQuotaError' ||
|
||||
error.name === 'ValidationRequiredError'))
|
||||
) {
|
||||
return error;
|
||||
}
|
||||
|
||||
const googleApiError = parseGoogleApiError(error);
|
||||
const status = googleApiError?.code ?? getErrorStatus(error);
|
||||
const errorMessage = googleApiError?.message || extractErrorMessage(error);
|
||||
@@ -271,16 +289,23 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
return new RetryableQuotaError(errorMessage, cause, retryDelaySeconds);
|
||||
}
|
||||
} else if (status === 429 || status === 499 || status === 503) {
|
||||
// Fallback: If it is a 429, 499, or 503 but doesn't have a specific "retry in" message,
|
||||
// assume it is a temporary rate limit and retry.
|
||||
return new RetryableQuotaError(
|
||||
errorMessage,
|
||||
googleApiError ?? {
|
||||
code: status,
|
||||
message: errorMessage,
|
||||
details: [],
|
||||
},
|
||||
);
|
||||
const cause = googleApiError ?? {
|
||||
code: status,
|
||||
message: errorMessage,
|
||||
details: [],
|
||||
};
|
||||
|
||||
// If the error message indicates capacity exhaustion, classify as TerminalQuotaError
|
||||
if (
|
||||
/exhausted your capacity|capacity exceeded|MODEL_CAPACITY_EXHAUSTED/i.test(
|
||||
errorMessage,
|
||||
)
|
||||
) {
|
||||
return new TerminalQuotaError(errorMessage, cause);
|
||||
}
|
||||
|
||||
// Fallback: assume it is a temporary rate limit and retry.
|
||||
return new RetryableQuotaError(errorMessage, cause);
|
||||
}
|
||||
|
||||
return error; // Not a retryable error we can handle with structured details or a parsable retry message.
|
||||
@@ -320,6 +345,19 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
}
|
||||
|
||||
if (errorInfo) {
|
||||
// Always treat capacity exhaustion as terminal error to trigger immediate model fallback
|
||||
if (
|
||||
errorInfo.reason === 'MODEL_CAPACITY_EXHAUSTED' ||
|
||||
errorInfo.reason === 'MODEL_CAPACITY_EXCEEDED'
|
||||
) {
|
||||
return new TerminalQuotaError(
|
||||
googleApiError.message,
|
||||
googleApiError,
|
||||
delaySeconds,
|
||||
errorInfo.reason,
|
||||
);
|
||||
}
|
||||
|
||||
// INSUFFICIENT_G1_CREDITS_BALANCE is always terminal, regardless of domain
|
||||
if (errorInfo.reason === 'INSUFFICIENT_G1_CREDITS_BALANCE') {
|
||||
return new TerminalQuotaError(
|
||||
@@ -330,28 +368,19 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
errorInfo.reason === 'MODEL_CAPACITY_EXHAUSTED' ||
|
||||
errorInfo.reason === 'MODEL_CAPACITY_EXCEEDED'
|
||||
) {
|
||||
// If no server backoff delay is specified, treat capacity exhaustion as a terminal error
|
||||
// to trigger immediate model fallback without retrying on the same exhausted model.
|
||||
if (delaySeconds === undefined) {
|
||||
return new TerminalQuotaError(
|
||||
googleApiError.message,
|
||||
googleApiError,
|
||||
delaySeconds,
|
||||
errorInfo.reason,
|
||||
);
|
||||
}
|
||||
// Otherwise, fall through to RetryableQuotaError to honor the server's requested delay.
|
||||
}
|
||||
|
||||
// New Cloud Code API quota handling
|
||||
if (errorInfo.domain) {
|
||||
if (isCloudCodeDomain(errorInfo.domain)) {
|
||||
if (errorInfo.reason === 'RATE_LIMIT_EXCEEDED') {
|
||||
const effectiveDelay = delaySeconds ?? 10;
|
||||
if (delaySeconds === undefined) {
|
||||
return new TerminalQuotaError(
|
||||
googleApiError.message,
|
||||
googleApiError,
|
||||
undefined,
|
||||
errorInfo.reason,
|
||||
);
|
||||
}
|
||||
const effectiveDelay = delaySeconds;
|
||||
if (effectiveDelay > MAX_RETRYABLE_DELAY_SECONDS) {
|
||||
return new TerminalQuotaError(
|
||||
googleApiError.message,
|
||||
@@ -419,6 +448,15 @@ export function classifyGoogleError(error: unknown): unknown {
|
||||
}
|
||||
}
|
||||
|
||||
// If the error message indicates capacity exhaustion, classify as TerminalQuotaError
|
||||
if (
|
||||
/exhausted your capacity|capacity exceeded|MODEL_CAPACITY_EXHAUSTED/i.test(
|
||||
errorMessage,
|
||||
)
|
||||
) {
|
||||
return new TerminalQuotaError(errorMessage, googleApiError);
|
||||
}
|
||||
|
||||
// If we reached this point, the status is 429, 499, or 503 and we have details,
|
||||
// but no specific violation was matched. We return a generic retryable error.
|
||||
return new RetryableQuotaError(errorMessage, googleApiError);
|
||||
|
||||
@@ -41,7 +41,11 @@ export function isStructuredError(error: unknown): error is StructuredError {
|
||||
if (typeof error.message !== 'string') {
|
||||
return false;
|
||||
}
|
||||
if ('status' in error && typeof error.status !== 'number') {
|
||||
if (
|
||||
'status' in error &&
|
||||
error.status !== undefined &&
|
||||
typeof error.status !== 'number'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-devtools",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0-preview.2",
|
||||
"license": "Apache-2.0",
|
||||
"type": "module",
|
||||
"main": "dist/src/index.js",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-sdk",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0-preview.2",
|
||||
"description": "Gemini CLI SDK",
|
||||
"license": "Apache-2.0",
|
||||
"repository": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@google/gemini-cli-test-utils",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0-preview.2",
|
||||
"private": true,
|
||||
"main": "src/index.ts",
|
||||
"license": "Apache-2.0",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "gemini-cli-vscode-ide-companion",
|
||||
"displayName": "Gemini CLI Companion",
|
||||
"description": "Enable Gemini CLI with direct access to your IDE workspace.",
|
||||
"version": "0.54.1",
|
||||
"version": "0.55.0-preview.2",
|
||||
"publisher": "google",
|
||||
"icon": "assets/icon.png",
|
||||
"repository": {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# Dockerfile for Jetski/Antigravity Worker Job using the Python SDK
|
||||
# This container runs the manager script (workflow/worker.py) which orchestrates
|
||||
# the code generation and evaluation in-process using google-antigravity and Firestore synchronization.
|
||||
|
||||
FROM python:3.11-slim
|
||||
|
||||
# 1. Install system utilities (git is required for repository operations)
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Copy Node.js 20 and npm directly from the official node:20-slim image
|
||||
COPY --from=node:20-slim /usr/local/bin/node /usr/local/bin/node
|
||||
COPY --from=node:20-slim /usr/local/lib/node_modules /usr/local/lib/node_modules
|
||||
RUN ln -s /usr/local/lib/node_modules/npm/bin/npm-cli.js /usr/local/bin/npm && \
|
||||
ln -s /usr/local/lib/node_modules/npm/bin/npx-cli.js /usr/local/bin/npx
|
||||
|
||||
# 2. Create non-root system user and establish working directory
|
||||
RUN useradd -m -u 1000 appuser
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 3. Install Python dependencies using requirements.txt for optimized build layer caching
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
|
||||
# 4. Copy the pipeline orchestration script and prompts with non-root ownership
|
||||
COPY --chown=appuser:appuser workflow/ /app/workflow/
|
||||
COPY --chown=appuser:appuser agent_prompts/ /app/agent_prompts/
|
||||
|
||||
# 5. Switch to unprivileged user
|
||||
USER appuser
|
||||
|
||||
# 6. Execute script directly
|
||||
ENTRYPOINT ["python", "/app/workflow/worker.py"]
|
||||
@@ -0,0 +1,40 @@
|
||||
apiVersion: 'run.googleapis.com/v1'
|
||||
kind: 'Job'
|
||||
metadata:
|
||||
labels:
|
||||
cloud.googleapis.com/location: 'us-central1'
|
||||
name: 'pr-gen-job'
|
||||
spec:
|
||||
template:
|
||||
metadata:
|
||||
annotations:
|
||||
run.googleapis.com/client-name: 'gcloud'
|
||||
run.googleapis.com/client-version: '575.0.1'
|
||||
run.googleapis.com/execution-environment: 'gen2'
|
||||
spec:
|
||||
taskCount: 1
|
||||
template:
|
||||
spec:
|
||||
containers:
|
||||
- env:
|
||||
- name: 'GOOGLE_CLOUD_LOCATION'
|
||||
value: 'global'
|
||||
- name: 'MODEL_NAME'
|
||||
value: 'gemini-3.5-flash'
|
||||
- name: 'FIRESTORE_DATABASE'
|
||||
value: 'gcli-db'
|
||||
- name: 'FIRESTORE_COLLECTION'
|
||||
value: 'issues'
|
||||
- name: 'GIT_TOKEN'
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
key: 'latest'
|
||||
name: 'PR_GEN_GITHUB_PUSH_KEY'
|
||||
image: 'us-central1-docker.pkg.dev/gcli-intern-project-2026/pr-gen-repo/jetski-worker:latest'
|
||||
resources:
|
||||
limits:
|
||||
cpu: '2'
|
||||
memory: '8Gi'
|
||||
maxRetries: 2
|
||||
serviceAccountName: 'code-gen-job-execution-sa@gcli-intern-project-2026.iam.gserviceaccount.com'
|
||||
timeoutSeconds: '3600'
|
||||
@@ -0,0 +1,13 @@
|
||||
[pytest]
|
||||
minversion = 8.0
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
asyncio_mode = auto
|
||||
addopts =
|
||||
-v
|
||||
--strict-markers
|
||||
--tb=short
|
||||
--cov=workflow
|
||||
--cov-report=term-missing
|
||||
@@ -0,0 +1,6 @@
|
||||
google-antigravity>=0.1.0
|
||||
protobuf>=7.35.0
|
||||
pydantic
|
||||
google-cloud-firestore>=2.15.0, <3.0.0
|
||||
google-cloud-storage>=2.14.0
|
||||
google-genai>=2.0.0
|
||||
@@ -0,0 +1,4 @@
|
||||
# Copyright 2026 Google LLC
|
||||
# Apache-2.0 License
|
||||
|
||||
"""Tests package for SSR Code Generator workflow modules."""
|
||||
@@ -0,0 +1,26 @@
|
||||
# Copyright 2026 Google LLC
|
||||
# Apache-2.0 License
|
||||
|
||||
"""Shared Pytest Fixtures for Workflow Module Tests."""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
|
||||
# Ensure workflow directory is in sys.path
|
||||
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
WORKFLOW_DIR = os.path.join(BASE_DIR, "workflow")
|
||||
if WORKFLOW_DIR not in sys.path:
|
||||
sys.path.insert(0, WORKFLOW_DIR)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_env(monkeypatch):
|
||||
"""Ensures environment variables are clean and isolated for each test."""
|
||||
monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "test-project-2026")
|
||||
monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
|
||||
monkeypatch.setenv("MODEL_NAME", "gemini-3.5-flash")
|
||||
monkeypatch.setenv("MAX_ATTEMPTS", "5")
|
||||
monkeypatch.setenv("REPO_URL", "https://github.com/test-owner/test-repo.git")
|
||||
monkeypatch.setenv("GIT_TOKEN", "test-github-token-12345")
|
||||
yield
|
||||
@@ -0,0 +1,170 @@
|
||||
# Copyright 2026 Google LLC
|
||||
# Apache-2.0 License
|
||||
|
||||
"""Unit tests for workflow/command_executor.py."""
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from command_executor import (
|
||||
CommandExecutionError,
|
||||
CommandExecutor,
|
||||
sanitize_identifier,
|
||||
sanitize_relative_path,
|
||||
)
|
||||
|
||||
|
||||
# --- Sanitization Unit Tests ---
|
||||
|
||||
def test_sanitize_relative_path_valid():
|
||||
"""Tests that valid relative paths are normalized cleanly."""
|
||||
assert sanitize_relative_path("src/utils/file.ts") == "src/utils/file.ts"
|
||||
assert sanitize_relative_path("a/b/../c/file.ts") == "a/c/file.ts"
|
||||
|
||||
|
||||
def test_sanitize_relative_path_traversal():
|
||||
"""Tests that path traversal attempts returning '..' are rejected."""
|
||||
assert sanitize_relative_path("../secret/passwords.txt") is None
|
||||
assert sanitize_relative_path("a/../../etc/passwd") is None
|
||||
|
||||
|
||||
def test_sanitize_relative_path_absolute():
|
||||
"""Tests that absolute paths are rejected."""
|
||||
assert sanitize_relative_path("/etc/passwd") is None
|
||||
assert sanitize_relative_path("/usr/local/bin") is None
|
||||
|
||||
|
||||
def test_sanitize_relative_path_null_bytes_and_empty():
|
||||
"""Tests stripping of null bytes and handling of empty inputs."""
|
||||
assert sanitize_relative_path("src/utils\x00/file.ts") == "src/utils/file.ts"
|
||||
assert sanitize_relative_path(" \x00 ") is None
|
||||
assert sanitize_relative_path("") is None
|
||||
assert sanitize_relative_path(None) is None
|
||||
|
||||
|
||||
def test_sanitize_identifier_valid():
|
||||
"""Tests sanitization of alphanumeric identifiers with hyphens/underscores."""
|
||||
assert sanitize_identifier("feature-branch_123") == "feature-branch_123"
|
||||
assert sanitize_identifier("v1.0.0") == "v1.0.0"
|
||||
|
||||
|
||||
def test_sanitize_identifier_injection_stripping():
|
||||
"""Tests that special command injection characters are removed."""
|
||||
assert sanitize_identifier("branch; rm -rf /") == "branchrm-rf"
|
||||
assert sanitize_identifier("issue#190$(whoami)") == "issue190whoami"
|
||||
|
||||
|
||||
def test_sanitize_identifier_empty_fallback():
|
||||
"""Tests that empty or invalid inputs fall back to 'default'."""
|
||||
assert sanitize_identifier("") == "default"
|
||||
assert sanitize_identifier(None) == "default"
|
||||
assert sanitize_identifier("!!!") == "default"
|
||||
|
||||
|
||||
# --- CommandExecutionError Tests ---
|
||||
|
||||
def test_command_execution_error_attributes():
|
||||
"""Tests that CommandExecutionError formats exception message and stores attributes."""
|
||||
err = CommandExecutionError(
|
||||
cmd=["git", "status"],
|
||||
returncode=128,
|
||||
stdout="out_data",
|
||||
stderr="err_data",
|
||||
)
|
||||
assert "git status" in str(err)
|
||||
assert "exit code 128" in str(err)
|
||||
assert err.cmd == "git status"
|
||||
assert err.returncode == 128
|
||||
assert err.stdout == "out_data"
|
||||
assert err.stderr == "err_data"
|
||||
|
||||
|
||||
# --- CommandExecutor.run Unit Tests ---
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_list_args_success(mock_subprocess_run):
|
||||
"""Tests successful command execution with a list of argument tokens."""
|
||||
mock_subprocess_run.return_value = MagicMock(
|
||||
returncode=0, stdout="hello world\n", stderr=""
|
||||
)
|
||||
|
||||
output = CommandExecutor.run(["echo", "hello", "world"])
|
||||
assert output == "hello world"
|
||||
mock_subprocess_run.assert_called_once()
|
||||
args, kwargs = mock_subprocess_run.call_args
|
||||
assert args[0] == ["echo", "hello", "world"]
|
||||
assert kwargs["check"] is False
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_string_command_shlex_split(mock_subprocess_run):
|
||||
"""Tests that string commands are tokenized using shlex without shell=True."""
|
||||
mock_subprocess_run.return_value = MagicMock(
|
||||
returncode=0, stdout="diff output\n", stderr=""
|
||||
)
|
||||
|
||||
output = CommandExecutor.run("git diff --stat origin/main")
|
||||
assert output == "diff output"
|
||||
mock_subprocess_run.assert_called_once()
|
||||
args, kwargs = mock_subprocess_run.call_args
|
||||
assert args[0] == ["git", "diff", "--stat", "origin/main"]
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_inline_env_parsing(mock_subprocess_run):
|
||||
"""Tests parsing of inline KEY=VALUE env prefixes in string commands."""
|
||||
mock_subprocess_run.return_value = MagicMock(
|
||||
returncode=0, stdout="installed\n", stderr=""
|
||||
)
|
||||
|
||||
output = CommandExecutor.run('NODE_OPTIONS="--max-old-space-size=4096" npm ci')
|
||||
assert output == "installed"
|
||||
mock_subprocess_run.assert_called_once()
|
||||
args, kwargs = mock_subprocess_run.call_args
|
||||
assert args[0] == ["npm", "ci"]
|
||||
assert kwargs["env"].get("NODE_OPTIONS") == "--max-old-space-size=4096"
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_custom_cwd_and_env(mock_subprocess_run):
|
||||
"""Tests custom CWD and environment variable dict propagation."""
|
||||
mock_subprocess_run.return_value = MagicMock(
|
||||
returncode=0, stdout="ok\n", stderr=""
|
||||
)
|
||||
|
||||
custom_env = {"MY_VAR": "custom_val"}
|
||||
output = CommandExecutor.run(["pwd"], cwd="/tmp/pr", env=custom_env)
|
||||
assert output == "ok"
|
||||
mock_subprocess_run.assert_called_once()
|
||||
args, kwargs = mock_subprocess_run.call_args
|
||||
assert kwargs["cwd"] == "/tmp/pr"
|
||||
assert kwargs["env"].get("MY_VAR") == "custom_val"
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_non_zero_exit_code_raises_error(mock_subprocess_run):
|
||||
"""Tests that a non-zero exit code raises CommandExecutionError."""
|
||||
mock_subprocess_run.return_value = MagicMock(
|
||||
returncode=1, stdout="some stdout", stderr="fatal error"
|
||||
)
|
||||
|
||||
with pytest.raises(CommandExecutionError) as exc_info:
|
||||
CommandExecutor.run(["git", "checkout", "nonexistent"])
|
||||
|
||||
err = exc_info.value
|
||||
assert err.returncode == 1
|
||||
assert err.stdout == "some stdout"
|
||||
assert err.stderr == "fatal error"
|
||||
|
||||
|
||||
@patch("subprocess.run")
|
||||
def test_run_timeout_expired(mock_subprocess_run):
|
||||
"""Tests that subprocess timeout exceptions propagate cleanly."""
|
||||
mock_subprocess_run.side_effect = subprocess.TimeoutExpired(
|
||||
cmd="long_task", timeout=10.0
|
||||
)
|
||||
|
||||
with pytest.raises(subprocess.TimeoutExpired):
|
||||
CommandExecutor.run(["sleep", "100"], timeout=10.0)
|
||||
@@ -0,0 +1,99 @@
|
||||
# Copyright 2026 Google LLC
|
||||
# Apache-2.0 License
|
||||
|
||||
"""Unit tests for workflow/config.py."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
|
||||
from config import Config, ConfigurationError
|
||||
|
||||
|
||||
def test_config_defaults(monkeypatch):
|
||||
"""Tests default configuration fallback values when environment variables are unset."""
|
||||
monkeypatch.delenv("REPO_URL", raising=False)
|
||||
monkeypatch.delenv("GOOGLE_CLOUD_PROJECT", raising=False)
|
||||
monkeypatch.delenv("GOOGLE_CLOUD_LOCATION", raising=False)
|
||||
monkeypatch.delenv("MODEL_NAME", raising=False)
|
||||
monkeypatch.delenv("MAX_ATTEMPTS", raising=False)
|
||||
|
||||
cfg = Config()
|
||||
assert cfg.repo_url == "https://github.com/joneba-google/gemini-cli-clone"
|
||||
assert cfg.project_id == "gcli-intern-project-2026"
|
||||
assert cfg.location == "global"
|
||||
assert cfg.model_name == "gemini-3.5-flash"
|
||||
assert cfg.max_attempts == 5
|
||||
assert cfg.repo_name == "gemini-cli-clone"
|
||||
|
||||
|
||||
def test_config_max_attempts_valid(monkeypatch):
|
||||
"""Tests custom MAX_ATTEMPTS environment variable parsing."""
|
||||
monkeypatch.setenv("MAX_ATTEMPTS", "12")
|
||||
cfg = Config()
|
||||
assert cfg.max_attempts == 12
|
||||
|
||||
|
||||
def test_config_max_attempts_invalid_string(monkeypatch):
|
||||
"""Tests that invalid MAX_ATTEMPTS strings fall back cleanly to 5."""
|
||||
monkeypatch.setenv("MAX_ATTEMPTS", "invalid_string")
|
||||
cfg = Config()
|
||||
assert cfg.max_attempts == 5
|
||||
|
||||
|
||||
def test_config_max_attempts_zero_or_negative(monkeypatch):
|
||||
"""Tests lower bound enforcement (max(val, 1)) for zero or negative values."""
|
||||
monkeypatch.setenv("MAX_ATTEMPTS", "0")
|
||||
cfg1 = Config()
|
||||
assert cfg1.max_attempts == 1
|
||||
|
||||
monkeypatch.setenv("MAX_ATTEMPTS", "-5")
|
||||
cfg2 = Config()
|
||||
assert cfg2.max_attempts == 1
|
||||
|
||||
|
||||
def test_config_repo_name_derivation(monkeypatch):
|
||||
"""Tests repository name parsing from REPO_URL with trailing slashes and .git suffixes."""
|
||||
monkeypatch.setenv("REPO_URL", "https://github.com/my-org/my-custom-repo.git/")
|
||||
cfg = Config()
|
||||
assert cfg.repo_name == "my-custom-repo"
|
||||
assert cfg.pr_repo_path == os.path.join("/tmp/pr", "my-custom-repo")
|
||||
assert cfg.eval_repo_path == os.path.join("/tmp/eval", "my-custom-repo")
|
||||
|
||||
|
||||
def test_load_and_validate_firestore_doc_valid(monkeypatch):
|
||||
"""Tests loading and validating a valid JSON FIRESTORE_DOC string."""
|
||||
valid_doc = {"workable_spec": {"issue_id": "190"}, "status": "PENDING"}
|
||||
monkeypatch.setenv("FIRESTORE_DOC", json.dumps(valid_doc))
|
||||
|
||||
cfg = Config()
|
||||
parsed = cfg.load_and_validate_firestore_doc()
|
||||
assert parsed["workable_spec"]["issue_id"] == "190"
|
||||
assert parsed["status"] == "PENDING"
|
||||
|
||||
|
||||
def test_load_and_validate_firestore_doc_missing(monkeypatch):
|
||||
"""Tests error handling when FIRESTORE_DOC environment variable is missing."""
|
||||
monkeypatch.delenv("FIRESTORE_DOC", raising=False)
|
||||
cfg = Config()
|
||||
with pytest.raises(ConfigurationError) as exc_info:
|
||||
cfg.load_and_validate_firestore_doc()
|
||||
assert "Environment variable 'FIRESTORE_DOC' is required" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_load_and_validate_firestore_doc_invalid_json(monkeypatch):
|
||||
"""Tests error handling when FIRESTORE_DOC is not valid JSON."""
|
||||
monkeypatch.setenv("FIRESTORE_DOC", "{invalid json string")
|
||||
cfg = Config()
|
||||
with pytest.raises(ConfigurationError) as exc_info:
|
||||
cfg.load_and_validate_firestore_doc()
|
||||
assert "Failed to parse 'FIRESTORE_DOC' as JSON" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_load_and_validate_firestore_doc_non_dict(monkeypatch):
|
||||
"""Tests error handling when FIRESTORE_DOC parses to a non-dict JSON structure."""
|
||||
monkeypatch.setenv("FIRESTORE_DOC", '["array_item1", "array_item2"]')
|
||||
cfg = Config()
|
||||
with pytest.raises(ConfigurationError) as exc_info:
|
||||
cfg.load_and_validate_firestore_doc()
|
||||
assert "Firestore document specification must be a JSON object" in str(exc_info.value)
|
||||
@@ -0,0 +1,105 @@
|
||||
# Copyright 2026 Google LLC
|
||||
# Apache-2.0 License
|
||||
|
||||
"""Unit tests for workflow/github_client.py."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import urllib.error
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from github_client import GitHubClient, GitHubClientError
|
||||
|
||||
|
||||
def test_github_client_init():
|
||||
"""Tests GitHubClient initialization and URL construction."""
|
||||
client = GitHubClient(owner="my-owner", repo="my-repo", token="secret-token")
|
||||
assert client.owner == "my-owner"
|
||||
assert client.repo == "my-repo"
|
||||
assert client._token == "secret-token"
|
||||
assert client._base_url == "https://api.github.com/repos/my-owner/my-repo/pulls"
|
||||
|
||||
|
||||
def test_create_pull_request_missing_token():
|
||||
"""Tests that create_pull_request raises GitHubClientError when token is missing."""
|
||||
client = GitHubClient(owner="my-owner", repo="my-repo", token=None)
|
||||
with pytest.raises(GitHubClientError) as exc_info:
|
||||
client.create_pull_request("feature-branch", "Fix bug", "PR description")
|
||||
assert "GitHub token is missing" in str(exc_info.value)
|
||||
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_create_pull_request_success(mock_urlopen):
|
||||
"""Tests successful pull request creation, verifying headers, payload, and PR number return."""
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(
|
||||
{"number": 28, "html_url": "https://github.com/my-owner/my-repo/pull/28"}
|
||||
).encode("utf-8")
|
||||
mock_urlopen.return_value.__enter__.return_value = mock_response
|
||||
|
||||
client = GitHubClient(owner="my-owner", repo="my-repo", token="valid-token")
|
||||
pr_num = client.create_pull_request("feature-branch", "Fix bug", "PR description")
|
||||
|
||||
assert pr_num == "28"
|
||||
mock_urlopen.assert_called_once()
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
assert req.headers["Authorization"] == "Bearer valid-token"
|
||||
assert req.headers["Accept"] == "application/vnd.github+json"
|
||||
assert req.headers["Content-type"] == "application/json"
|
||||
|
||||
data = json.loads(req.data.decode("utf-8"))
|
||||
assert data["title"] == "Fix bug"
|
||||
assert data["body"] == "PR description"
|
||||
assert data["head"] == "feature-branch"
|
||||
assert data["base"] == "main"
|
||||
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_create_pull_request_http_error(mock_urlopen):
|
||||
"""Tests HTTPError handling, verifying status code and response body preservation."""
|
||||
error_body = json.dumps({"message": "Validation Failed", "errors": ["Branch already exists"]})
|
||||
mock_fp = io.BytesIO(error_body.encode("utf-8"))
|
||||
|
||||
http_err = urllib.error.HTTPError(
|
||||
url="https://api.github.com/...",
|
||||
code=422,
|
||||
msg="Unprocessable Entity",
|
||||
hdrs={},
|
||||
fp=mock_fp,
|
||||
)
|
||||
mock_urlopen.side_effect = http_err
|
||||
|
||||
client = GitHubClient(owner="my-owner", repo="my-repo", token="valid-token")
|
||||
with pytest.raises(GitHubClientError) as exc_info:
|
||||
client.create_pull_request("feature-branch", "Fix bug", "PR description")
|
||||
|
||||
err_str = str(exc_info.value)
|
||||
assert "HTTP 422" in err_str
|
||||
assert "Validation Failed" in err_str
|
||||
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_create_pull_request_url_error(mock_urlopen):
|
||||
"""Tests network URLError handling (e.g. DNS failure or connection refused)."""
|
||||
url_err = urllib.error.URLError(reason="Connection refused")
|
||||
mock_urlopen.side_effect = url_err
|
||||
|
||||
client = GitHubClient(owner="my-owner", repo="my-repo", token="valid-token")
|
||||
with pytest.raises(GitHubClientError) as exc_info:
|
||||
client.create_pull_request("feature-branch", "Fix bug", "PR description")
|
||||
|
||||
err_str = str(exc_info.value)
|
||||
assert "Network Error: Connection refused" in err_str
|
||||
|
||||
|
||||
@patch("urllib.request.urlopen")
|
||||
def test_create_pull_request_unexpected_exception(mock_urlopen):
|
||||
"""Tests unexpected runtime exception handling."""
|
||||
mock_urlopen.side_effect = RuntimeError("System socket crash")
|
||||
|
||||
client = GitHubClient(owner="my-owner", repo="my-repo", token="valid-token")
|
||||
with pytest.raises(GitHubClientError) as exc_info:
|
||||
client.create_pull_request("feature-branch", "Fix bug", "PR description")
|
||||
|
||||
assert "Unexpected API client error" in str(exc_info.value)
|
||||
@@ -0,0 +1,12 @@
|
||||
# Copyright 2026 Google LLC
|
||||
# Apache-2.0 License
|
||||
|
||||
"""Unit tests for workflow/__init__.py."""
|
||||
|
||||
import workflow
|
||||
|
||||
|
||||
def test_package_docstring():
|
||||
"""Tests that the workflow package contains a valid docstring."""
|
||||
assert workflow.__doc__ is not None
|
||||
assert "GCLI Orchestrator Package" in workflow.__doc__
|
||||
@@ -0,0 +1,187 @@
|
||||
# Copyright 2026 Google LLC
|
||||
# Apache-2.0 License
|
||||
|
||||
"""Unit tests for workflow/orchestrator.py."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from orchestrator import Orchestrator, OrchestrationError
|
||||
from command_executor import CommandExecutionError
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config():
|
||||
"""Returns a mock Config instance for Orchestrator tests."""
|
||||
config = MagicMock()
|
||||
config.repo_url = "https://github.com/test-owner/test-repo"
|
||||
config.repo_name = "test-repo"
|
||||
config.git_token = "secret-token"
|
||||
config.pr_dir = "/tmp/pr"
|
||||
config.eval_dir = "/tmp/eval"
|
||||
config.pr_repo_path = "/tmp/pr/test-repo"
|
||||
config.eval_repo_path = "/tmp/eval/test-repo"
|
||||
config.max_attempts = 2
|
||||
config.model_name = "gemini-3.5-flash"
|
||||
config.load_and_validate_firestore_doc.return_value = {
|
||||
"github_metadata": {"owner": "test-owner", "repo": "test-repo", "issue_number": 190},
|
||||
"workable_spec": {
|
||||
"issue_id": "190",
|
||||
"title": "Fix issue 190",
|
||||
"description": "Issue description content",
|
||||
},
|
||||
}
|
||||
return config
|
||||
|
||||
|
||||
def test_orchestrator_init(mock_config):
|
||||
"""Tests Orchestrator initialization and component setup."""
|
||||
orc = Orchestrator(mock_config)
|
||||
assert orc.config == mock_config
|
||||
assert hasattr(orc, "agent_runner")
|
||||
|
||||
|
||||
@patch("shutil.rmtree")
|
||||
@patch("os.makedirs")
|
||||
def test_setup_workspace(mock_makedirs, mock_rmtree, mock_config):
|
||||
"""Tests workspace directory setup and cleanup."""
|
||||
orc = Orchestrator(mock_config)
|
||||
orc._setup_workspace()
|
||||
assert mock_makedirs.call_count >= 2
|
||||
|
||||
|
||||
@patch("command_executor.CommandExecutor.run")
|
||||
def test_sync_or_clone_repository(mock_cmd_run, mock_config):
|
||||
"""Tests repository cloning and syncing."""
|
||||
mock_cmd_run.return_value = "git output"
|
||||
orc = Orchestrator(mock_config)
|
||||
|
||||
with patch("os.path.exists", return_value=False):
|
||||
orc._sync_or_clone_repository()
|
||||
assert mock_cmd_run.call_count >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("command_executor.CommandExecutor.run")
|
||||
async def test_run_regression_checks_pass(mock_cmd_run, mock_config):
|
||||
"""Tests _run_regression_checks when npm clean and npm ci succeed."""
|
||||
mock_cmd_run.return_value = "clean ok"
|
||||
orc = Orchestrator(mock_config)
|
||||
|
||||
result = await orc._run_regression_checks()
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("preflight_filter.PreflightFilter.should_ignore_preflight_failure")
|
||||
@patch("command_executor.CommandExecutor.run")
|
||||
async def test_run_regression_checks_bypassed_failure(mock_cmd_run, mock_preflight_filter, mock_config):
|
||||
"""Tests bypassing regression failures when preflight filter approves."""
|
||||
mock_cmd_run.side_effect = CommandExecutionError(
|
||||
cmd="npm run test:ci", returncode=1, stdout="FAIL src/utils/sessionCleanup.test.ts", stderr=""
|
||||
)
|
||||
mock_preflight_filter.return_value = True
|
||||
|
||||
orc = Orchestrator(mock_config)
|
||||
result = await orc._run_regression_checks()
|
||||
assert result is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("preflight_filter.PreflightFilter.should_ignore_preflight_failure")
|
||||
@patch("command_executor.CommandExecutor.run")
|
||||
async def test_run_regression_checks_unapproved_failure(mock_cmd_run, mock_preflight_filter, mock_config):
|
||||
"""Tests handling of unapproved regression failures."""
|
||||
mock_cmd_run.side_effect = CommandExecutionError(
|
||||
cmd="npm run test:ci", returncode=1, stdout="FAIL src/auth/login.test.ts", stderr=""
|
||||
)
|
||||
mock_preflight_filter.return_value = False
|
||||
|
||||
orc = Orchestrator(mock_config)
|
||||
with patch("builtins.open", MagicMock()):
|
||||
result = await orc._run_regression_checks()
|
||||
assert result is False
|
||||
|
||||
|
||||
@patch("shutil.copyfile")
|
||||
@patch("os.path.exists")
|
||||
def test_save_feedback_to_coding_workspace(mock_exists, mock_copyfile, mock_config):
|
||||
"""Tests copying pr_feedback.md from eval workspace to PR workspace."""
|
||||
mock_exists.return_value = True
|
||||
orc = Orchestrator(mock_config)
|
||||
|
||||
orc._save_feedback_to_coding_workspace()
|
||||
mock_copyfile.assert_called_once_with(
|
||||
os.path.join(mock_config.eval_repo_path, "pr_feedback.md"),
|
||||
os.path.join(mock_config.pr_repo_path, "pr_feedback.md"),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("orchestrator.acquire_lock", return_value="CLAIMED")
|
||||
@patch("orchestrator.release_lock", return_value=True)
|
||||
@patch("command_executor.CommandExecutor.run")
|
||||
@patch("orchestrator.Orchestrator._setup_workspace")
|
||||
@patch("orchestrator.Orchestrator._sync_or_clone_repository")
|
||||
@patch("orchestrator.Orchestrator._run_regression_checks")
|
||||
@patch("github_client.GitHubClient.create_pull_request")
|
||||
async def test_run_loop_success_pr_created(
|
||||
mock_create_pr, mock_regression, mock_sync, mock_setup, mock_cmd_run, mock_release_lock, mock_acquire_lock, mock_config
|
||||
):
|
||||
"""Tests complete successful orchestrator run loop resulting in PR creation."""
|
||||
mock_regression.return_value = True
|
||||
mock_create_pr.return_value = "28"
|
||||
|
||||
def cmd_side_effect(cmd, *args, **kwargs):
|
||||
cmd_str = str(cmd)
|
||||
if "diff --stat" in cmd_str:
|
||||
return "1 file changed, 5 insertions(+), 5 deletions(-)"
|
||||
if "diff" in cmd_str:
|
||||
return "diff --git a/file.py b/file.py\n+new line"
|
||||
if "status" in cmd_str:
|
||||
return "modified: file.py"
|
||||
return "ok"
|
||||
|
||||
mock_cmd_run.side_effect = cmd_side_effect
|
||||
|
||||
orc = Orchestrator(mock_config)
|
||||
orc.agent_runner.run_agent = AsyncMock()
|
||||
orc.agent_runner.run_agent.return_value = ("Coding agent completed code changes.", [])
|
||||
|
||||
with patch("os.path.exists", return_value=True), \
|
||||
patch("builtins.open", MagicMock()):
|
||||
with patch.object(orc, "_run_evaluation", AsyncMock(return_value="APPROVED")):
|
||||
await orc.run()
|
||||
|
||||
mock_create_pr.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("orchestrator.acquire_lock", return_value="CLAIMED")
|
||||
@patch("orchestrator.release_lock", return_value=True)
|
||||
@patch("command_executor.CommandExecutor.run")
|
||||
@patch("orchestrator.Orchestrator._setup_workspace")
|
||||
@patch("orchestrator.Orchestrator._sync_or_clone_repository")
|
||||
async def test_run_loop_max_attempts_exceeded(mock_sync, mock_setup, mock_cmd_run, mock_release_lock, mock_acquire_lock, mock_config):
|
||||
"""Tests that run loop finishes and releases lock when max repair attempts are exhausted."""
|
||||
def cmd_side_effect(cmd, *args, **kwargs):
|
||||
cmd_str = str(cmd)
|
||||
if "diff" in cmd_str:
|
||||
return "diff --git a/file.py b/file.py\n+new line"
|
||||
return "ok"
|
||||
|
||||
mock_cmd_run.side_effect = cmd_side_effect
|
||||
|
||||
orc = Orchestrator(mock_config)
|
||||
orc.agent_runner.run_agent = AsyncMock(
|
||||
return_value=("Code generation output", [])
|
||||
)
|
||||
|
||||
with patch("os.path.exists", return_value=False), \
|
||||
patch.object(orc, "_run_evaluation", AsyncMock(return_value="REJECTED")):
|
||||
await orc.run()
|
||||
|
||||
mock_release_lock.assert_called_once()
|
||||
@@ -0,0 +1,64 @@
|
||||
# Copyright 2026 Google LLC
|
||||
# Apache-2.0 License
|
||||
|
||||
"""Unit tests for workflow/preflight_filter.py."""
|
||||
|
||||
from preflight_filter import (
|
||||
ALLOWED_SANDBOX_FAILURES,
|
||||
PreflightFilter,
|
||||
is_preflight_failure_allowed,
|
||||
strip_ansi,
|
||||
)
|
||||
|
||||
|
||||
def test_strip_ansi_color_codes():
|
||||
"""Tests that strip_ansi cleanly removes ANSI terminal escape codes."""
|
||||
colored_text = "\x1b[31mFAIL\x1b[0m \x1b[1msrc/utils/test.ts\x1b[0m"
|
||||
assert strip_ansi(colored_text) == "FAIL src/utils/test.ts"
|
||||
assert PreflightFilter.strip_ansi(colored_text) == "FAIL src/utils/test.ts"
|
||||
|
||||
|
||||
def test_is_preflight_failure_allowed_single_approved_file():
|
||||
"""Tests approval of a single known allowed test file failure."""
|
||||
output = "FAIL src/utils/sessionCleanup.test.ts"
|
||||
assert is_preflight_failure_allowed(output) is True
|
||||
|
||||
|
||||
def test_is_preflight_failure_allowed_multiple_approved_files():
|
||||
"""Tests approval when multiple known allowed test files fail."""
|
||||
output = (
|
||||
"FAIL src/utils/sessionCleanup.test.ts\n"
|
||||
"FAIL src/config/extension-manager-permissions.test.ts"
|
||||
)
|
||||
assert is_preflight_failure_allowed(output) is True
|
||||
|
||||
|
||||
def test_is_preflight_failure_allowed_generic_keyword():
|
||||
"""Tests approval when failure matches generic container/sandbox exception keywords."""
|
||||
output = "FAILED root-privilege-check in sandbox"
|
||||
assert is_preflight_failure_allowed(output) is True
|
||||
|
||||
|
||||
def test_is_preflight_failure_allowed_unapproved_failure():
|
||||
"""Tests rejection when an unapproved test file fails."""
|
||||
output = (
|
||||
"FAIL src/utils/sessionCleanup.test.ts\n"
|
||||
"FAIL src/auth/loginService.test.ts"
|
||||
)
|
||||
assert is_preflight_failure_allowed(output) is False
|
||||
|
||||
|
||||
def test_is_preflight_failure_allowed_no_failures():
|
||||
"""Tests that output with no 'FAIL' or 'FAILED' lines returns False."""
|
||||
output = "PASS src/utils/sessionCleanup.test.ts\nTests: 12 passed, 12 total"
|
||||
assert is_preflight_failure_allowed(output) is False
|
||||
|
||||
|
||||
def test_should_ignore_preflight_failure_concatenates_streams():
|
||||
"""Tests PreflightFilter.should_ignore_preflight_failure stream concatenation."""
|
||||
stdout = "FAIL src/utils/sessionCleanup.test.ts"
|
||||
stderr = ""
|
||||
assert PreflightFilter.should_ignore_preflight_failure(stdout, stderr) is True
|
||||
|
||||
unapproved_stdout = "FAIL src/core/main.test.ts"
|
||||
assert PreflightFilter.should_ignore_preflight_failure(unapproved_stdout, stderr) is False
|
||||
@@ -0,0 +1,89 @@
|
||||
# Copyright 2026 Google LLC
|
||||
# Apache-2.0 License
|
||||
|
||||
"""Unit tests for workflow/worker.py."""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from worker import IgnoreRawWsMsgFilter, main, setup_logging
|
||||
from orchestrator import OrchestrationError
|
||||
|
||||
|
||||
def test_ignore_raw_ws_msg_filter():
|
||||
"""Tests that IgnoreRawWsMsgFilter filters out RAW WS MSG log records."""
|
||||
msg_filter = IgnoreRawWsMsgFilter()
|
||||
|
||||
record_ws = logging.LogRecord(
|
||||
name="test", level=logging.INFO, pathname="", lineno=0,
|
||||
msg="RAW WS MSG: websocket data packet", args=(), exc_info=None
|
||||
)
|
||||
assert msg_filter.filter(record_ws) is False
|
||||
|
||||
record_normal = logging.LogRecord(
|
||||
name="test", level=logging.INFO, pathname="", lineno=0,
|
||||
msg="Normal execution status log", args=(), exc_info=None
|
||||
)
|
||||
assert msg_filter.filter(record_normal) is True
|
||||
|
||||
|
||||
@patch("logging.basicConfig")
|
||||
def test_setup_logging(mock_basic_config):
|
||||
"""Tests that setup_logging configures root logger handlers correctly."""
|
||||
setup_logging()
|
||||
mock_basic_config.assert_called_once()
|
||||
kwargs = mock_basic_config.call_args[1]
|
||||
assert kwargs["level"] == logging.INFO
|
||||
assert len(kwargs["handlers"]) == 1
|
||||
assert isinstance(kwargs["handlers"][0], logging.StreamHandler)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("worker.Config")
|
||||
@patch("worker.Orchestrator")
|
||||
async def test_worker_main_success(mock_orchestrator_cls, mock_config_cls):
|
||||
"""Tests successful worker execution lifecycle."""
|
||||
mock_config = MagicMock()
|
||||
mock_config_cls.return_value = mock_config
|
||||
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_orchestrator.run = AsyncMock(return_value="PR_CREATED")
|
||||
mock_orchestrator_cls.return_value = mock_orchestrator
|
||||
|
||||
await main()
|
||||
|
||||
mock_config_cls.assert_called_once()
|
||||
mock_orchestrator_cls.assert_called_once_with(mock_config)
|
||||
mock_orchestrator.run.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("worker.Config")
|
||||
@patch("worker.Orchestrator")
|
||||
async def test_worker_main_orchestration_error(mock_orchestrator_cls, mock_config_cls):
|
||||
"""Tests that OrchestrationError results in sys.exit(1)."""
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_orchestrator.run = AsyncMock(side_effect=OrchestrationError("Fatal loop limit"))
|
||||
mock_orchestrator_cls.return_value = mock_orchestrator
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
await main()
|
||||
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("worker.Config")
|
||||
@patch("worker.Orchestrator")
|
||||
async def test_worker_main_unexpected_exception(mock_orchestrator_cls, mock_config_cls):
|
||||
"""Tests that unhandled exceptions result in sys.exit(4)."""
|
||||
mock_orchestrator = MagicMock()
|
||||
mock_orchestrator.run = AsyncMock(side_effect=RuntimeError("System crash"))
|
||||
mock_orchestrator_cls.return_value = mock_orchestrator
|
||||
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
await main()
|
||||
|
||||
assert exc_info.value.code == 4
|
||||
@@ -0,0 +1,103 @@
|
||||
# Google Cloud Workflow that triggers Cloud Run Job and updates Firestore on failure.
|
||||
|
||||
main:
|
||||
params: ['event']
|
||||
steps:
|
||||
- init:
|
||||
assign:
|
||||
- project_id: '${sys.get_env("GOOGLE_CLOUD_PROJECT_ID")}'
|
||||
- database_id: '${default(sys.get_env("FIRESTORE_DATABASE"), "gcli-db")}'
|
||||
- collection_name: '${default(sys.get_env("FIRESTORE_COLLECTION"), "issues")}'
|
||||
- job_name: 'pr-gen-job' # The Cloud Run Job name
|
||||
- job_location: 'us-central1'
|
||||
- workflow_execution_id: '${sys.get_env("GOOGLE_CLOUD_WORKFLOW_EXECUTION_ID")}'
|
||||
|
||||
# Decode Pub/Sub message
|
||||
- pubsub_message_bytes: '${base64.decode(event.data.message.data)}'
|
||||
- firestore_doc_str: '${text.decode(pubsub_message_bytes)}'
|
||||
- firestore_doc: '${json.decode(firestore_doc_str)}'
|
||||
- doc_id: '${"github_" + firestore_doc.github_metadata.owner + "_" + firestore_doc.github_metadata.repo + "_" + string(firestore_doc.github_metadata.issue_number)}'
|
||||
- repo_url: '${"https://github.com/" + firestore_doc.github_metadata.owner + "/" + firestore_doc.github_metadata.repo}'
|
||||
- status: 'STARTED'
|
||||
- error_details: null
|
||||
|
||||
- validate_doc_id:
|
||||
switch:
|
||||
- condition: '${not text.match_regex(doc_id, "^[a-zA-Z0-9_.-]+$")}'
|
||||
raise: '${"Security Exception: Invalid or unsafe firestore_id format: " + string(doc_id)}'
|
||||
|
||||
- run_job:
|
||||
try:
|
||||
call: 'googleapis.run.v1.namespaces.jobs.run'
|
||||
args:
|
||||
name: '${"namespaces/" + project_id + "/jobs/" + job_name}'
|
||||
location: '${job_location}'
|
||||
body:
|
||||
overrides:
|
||||
containerOverrides:
|
||||
- env:
|
||||
- name: 'FIRESTORE_DOC'
|
||||
value: '${firestore_doc_str}'
|
||||
- name: 'REPO_URL'
|
||||
value: '${repo_url}'
|
||||
- name: 'USE_ADC'
|
||||
value: 'true'
|
||||
- name: 'EXECUTION_ID'
|
||||
value: '${sys.get_env("GOOGLE_CLOUD_WORKFLOW_EXECUTION_ID")}'
|
||||
- name: 'GOOGLE_CLOUD_WORKFLOW_EXECUTION_ID'
|
||||
value: '${sys.get_env("GOOGLE_CLOUD_WORKFLOW_EXECUTION_ID")}'
|
||||
- name: 'FIRESTORE_ID'
|
||||
value: '${doc_id}'
|
||||
connector_params:
|
||||
timeout: 7200
|
||||
result: 'job_result'
|
||||
except:
|
||||
as: 'e'
|
||||
steps:
|
||||
- handle_job_error:
|
||||
assign:
|
||||
- status: 'NEEDS_HUMAN'
|
||||
- error_details: '${e}'
|
||||
- update_firestore_on_failure:
|
||||
try:
|
||||
call: 'googleapis.firestore.v1.projects.databases.documents.patch'
|
||||
args:
|
||||
name: '${"projects/" + project_id + "/databases/" + database_id + "/documents/" + collection_name + "/" + doc_id}'
|
||||
updateMask:
|
||||
fieldPaths:
|
||||
- 'status'
|
||||
- 'error'
|
||||
- 'lock.holder'
|
||||
- 'lock.expires_at'
|
||||
body:
|
||||
fields:
|
||||
status:
|
||||
stringValue: 'NEEDS_HUMAN'
|
||||
error:
|
||||
stringValue: '${"Workflow Execution " + sys.get_env("GOOGLE_CLOUD_WORKFLOW_EXECUTION_ID") + " failed: " + json.encode_to_string(error_details)}'
|
||||
lock:
|
||||
mapValue:
|
||||
fields:
|
||||
holder:
|
||||
nullValue: 'NULL_VALUE'
|
||||
expires_at:
|
||||
nullValue: 'NULL_VALUE'
|
||||
except:
|
||||
as: 'fs_err'
|
||||
steps:
|
||||
- log_firestore_error:
|
||||
assign:
|
||||
- firestore_error: '${fs_err}'
|
||||
next: 'handle_failure'
|
||||
next: 'mark_completed'
|
||||
|
||||
- handle_failure:
|
||||
return: '${"Failed: " + json.encode_to_string(error_details)}'
|
||||
|
||||
- mark_completed:
|
||||
assign:
|
||||
- status: 'COMPLETED'
|
||||
next: 'finish'
|
||||
|
||||
- finish:
|
||||
return: '${status}'
|
||||
@@ -0,0 +1,10 @@
|
||||
"""GCLI Orchestrator Package.
|
||||
|
||||
This package contains all components of the SSR Agent Orchestrator:
|
||||
- config: Configuration loading and validation.
|
||||
- command_executor: Subprocess execution utility.
|
||||
- github_client: GitHub v3 REST API client.
|
||||
- agent_runner: Google Antigravity SDK wrapper.
|
||||
- preflight_filter: Preflight test verification filtering.
|
||||
- orchestrator: Orchestration state machine coordinating code generation and evaluation.
|
||||
"""
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Command execution and input sanitization module.
|
||||
|
||||
Provides safe subprocess execution utilities, path traversal guards,
|
||||
and input sanitizers to prevent injection attacks and capture process output cleanly.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
|
||||
|
||||
def sanitize_relative_path(path: str | os.PathLike) -> str | None:
|
||||
"""Sanitizes an untrusted relative file path to prevent Path Traversal.
|
||||
|
||||
Strips null bytes, normalizes path separators, and ensures the path does not
|
||||
escape the workspace or refer to an absolute root path.
|
||||
|
||||
Args:
|
||||
path: Untrusted file path string or PathLike object.
|
||||
|
||||
Returns:
|
||||
The normalized safe relative path string, or None if malicious/invalid.
|
||||
"""
|
||||
if not path:
|
||||
return None
|
||||
raw_str = str(path).replace("\x00", "").strip()
|
||||
if not raw_str:
|
||||
return None
|
||||
clean_path = os.path.normpath(raw_str)
|
||||
if clean_path.startswith("..") or os.path.isabs(clean_path):
|
||||
logging.warning("Path traversal attempt or absolute path detected: %s", path)
|
||||
return None
|
||||
return clean_path
|
||||
|
||||
|
||||
def sanitize_identifier(value: str) -> str:
|
||||
"""Sanitizes an untrusted string for use in branch names, tags, or CLI identifiers.
|
||||
|
||||
Strips null bytes and removes any character not in [a-zA-Z0-9._-].
|
||||
|
||||
Args:
|
||||
value: Untrusted identifier string.
|
||||
|
||||
Returns:
|
||||
A sanitized alphanumeric identifier string (defaults to 'default' if empty).
|
||||
"""
|
||||
if not value:
|
||||
return "default"
|
||||
raw_str = str(value).replace("\x00", "")
|
||||
sanitized = re.sub(r"[^a-zA-Z0-9._-]", "", raw_str)
|
||||
return sanitized or "default"
|
||||
|
||||
|
||||
class CommandExecutionError(Exception):
|
||||
"""Raised when a subprocess fails to run or returns a non-zero exit code."""
|
||||
|
||||
def __init__(
|
||||
self, cmd: str | list[str], returncode: int, stdout: str, stderr: str
|
||||
) -> None:
|
||||
"""Initializes the error with command results."""
|
||||
cmd_str = " ".join(cmd) if isinstance(cmd, list) else cmd
|
||||
super().__init__(f"Command '{cmd_str}' failed with exit code {returncode}")
|
||||
self.cmd = cmd_str
|
||||
self.returncode = returncode
|
||||
self.stdout = stdout
|
||||
self.stderr = stderr
|
||||
|
||||
|
||||
class CommandExecutor:
|
||||
"""Utility class to execute system-level commands and handle failures."""
|
||||
|
||||
@staticmethod
|
||||
def run(
|
||||
cmd: str | list[str],
|
||||
cwd: str | None = None,
|
||||
env: dict[str, str] | None = None,
|
||||
timeout: float = 3600.0,
|
||||
) -> str:
|
||||
"""Executes a command safely using direct argument lists without shell invocation.
|
||||
|
||||
Args:
|
||||
cmd: The command string or list of argument tokens to execute.
|
||||
cwd: The directory path in which to run the command. Defaults to CWD.
|
||||
env: Custom environment variable dictionary to pass to the process.
|
||||
timeout: Maximum allowed duration in seconds. Defaults to 3600.0s.
|
||||
|
||||
Returns:
|
||||
The trimmed stdout string from the command process.
|
||||
|
||||
Raises:
|
||||
CommandExecutionError: If the process exits with a non-zero status.
|
||||
"""
|
||||
active_cwd = cwd or os.getcwd()
|
||||
exec_env = os.environ.copy()
|
||||
if env:
|
||||
exec_env.update(env)
|
||||
|
||||
# Convert string commands into argument tokens, parsing inline KEY=VAL env prefixes
|
||||
if isinstance(cmd, str):
|
||||
tokens = shlex.split(cmd)
|
||||
args: list[str] = []
|
||||
for token in tokens:
|
||||
if "=" in token and not args:
|
||||
k, v = token.split("=", 1)
|
||||
exec_env[k] = v
|
||||
else:
|
||||
args.append(token)
|
||||
else:
|
||||
args = list(cmd)
|
||||
|
||||
cmd_str = " ".join(args)
|
||||
logging.info("Executing command: %s (CWD: %s)", cmd_str, active_cwd)
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
args,
|
||||
cwd=active_cwd,
|
||||
env=exec_env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
check=False,
|
||||
)
|
||||
|
||||
stdout_str = result.stdout.strip() if result.stdout else ""
|
||||
stderr_str = result.stderr.strip() if result.stderr else ""
|
||||
|
||||
if result.returncode != 0:
|
||||
logging.error(
|
||||
"Command execution failed: %s (Exit Code: %s)",
|
||||
cmd_str,
|
||||
result.returncode,
|
||||
)
|
||||
if stdout_str:
|
||||
logging.error("Stdout:\n%s", stdout_str)
|
||||
if stderr_str:
|
||||
logging.error("Stderr:\n%s", stderr_str)
|
||||
raise CommandExecutionError(
|
||||
cmd=args,
|
||||
returncode=result.returncode,
|
||||
stdout=stdout_str,
|
||||
stderr=stderr_str,
|
||||
)
|
||||
|
||||
return stdout_str
|
||||
except Exception as e:
|
||||
if not isinstance(e, CommandExecutionError):
|
||||
logging.exception(
|
||||
"An unexpected error occurred during command execution: %s",
|
||||
cmd_str,
|
||||
)
|
||||
raise
|
||||
@@ -0,0 +1,83 @@
|
||||
"""Configuration module for the SSR Agent Orchestrator.
|
||||
|
||||
This module parses, validates, and holds all configuration parameters and path
|
||||
constants needed by the orchestrator. It ensures fast-fail on missing or invalid
|
||||
configurations.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
|
||||
class ConfigurationError(Exception):
|
||||
"""Raised when configuration loading or validation fails."""
|
||||
|
||||
|
||||
class Config:
|
||||
"""Manages environmental inputs, paths, and limits for the orchestrator."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initializes the configuration with environment variables and defaults."""
|
||||
# Target repository configuration
|
||||
self.repo_url: str = os.environ.get(
|
||||
"REPO_URL", "https://github.com/joneba-google/gemini-cli-clone"
|
||||
)
|
||||
self.git_token: str | None = os.environ.pop("GIT_TOKEN", None)
|
||||
self.firestore_doc_raw: str | None = os.environ.get("FIRESTORE_DOC")
|
||||
self.firestore_id: str | None = (
|
||||
os.environ.get("FIRESTORE_ID") or os.environ.get("firestore_id")
|
||||
)
|
||||
self.execution_id: str | None = os.environ.get("EXECUTION_ID")
|
||||
|
||||
# Google Cloud Platform configuration
|
||||
self.project_id: str = os.environ.get(
|
||||
"GOOGLE_CLOUD_PROJECT", "gcli-intern-project-2026"
|
||||
)
|
||||
self.location: str = os.environ.get("GOOGLE_CLOUD_LOCATION", "global")
|
||||
self.model_name: str = os.environ.get("MODEL_NAME", "gemini-3.5-flash")
|
||||
|
||||
# Global runtime settings
|
||||
try:
|
||||
self.max_attempts: int = max(int(os.environ.get("MAX_ATTEMPTS", "5")), 1)
|
||||
except ValueError:
|
||||
self.max_attempts = 5
|
||||
|
||||
# Workspace directory configuration
|
||||
self.tmp_dir: str = "/tmp"
|
||||
self.pr_dir: str = os.path.join(self.tmp_dir, "pr")
|
||||
self.eval_dir: str = os.path.join(self.tmp_dir, "eval")
|
||||
|
||||
self.repo_name: str = (
|
||||
self.repo_url.rstrip("/").split("/")[-1].replace(".git", "")
|
||||
)
|
||||
self.pr_repo_path: str = os.path.join(self.pr_dir, self.repo_name)
|
||||
self.eval_repo_path: str = os.path.join(self.eval_dir, self.repo_name)
|
||||
|
||||
# Global environment variables to trust the CLI
|
||||
os.environ["GEMINI_CLI_WORKSPACE_TRUSTED"] = "true"
|
||||
|
||||
def load_and_validate_firestore_doc(self) -> dict[str, Any]:
|
||||
"""Parses and validates the Firestore JSON input specification.
|
||||
|
||||
Returns:
|
||||
The decoded dictionary of the Firestore document.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If the document is missing or not valid JSON.
|
||||
"""
|
||||
if not self.firestore_doc_raw:
|
||||
raise ConfigurationError(
|
||||
"Environment variable 'FIRESTORE_DOC' is required but was not set."
|
||||
)
|
||||
try:
|
||||
doc_data = json.loads(self.firestore_doc_raw)
|
||||
if not isinstance(doc_data, dict):
|
||||
raise ConfigurationError(
|
||||
"Firestore document specification must be a JSON object."
|
||||
)
|
||||
return doc_data
|
||||
except json.JSONDecodeError as e:
|
||||
raise ConfigurationError(
|
||||
f"Failed to parse 'FIRESTORE_DOC' as JSON: {e}"
|
||||
) from e
|
||||
@@ -0,0 +1,97 @@
|
||||
"""GitHub REST API Client module.
|
||||
|
||||
Handles GitHub pull request creation and branch push operations cleanly using
|
||||
standard urllib to minimize container dependency footprint.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
class GitHubClientError(Exception):
|
||||
"""Raised when a GitHub API request fails or is rejected."""
|
||||
|
||||
|
||||
class GitHubClient:
|
||||
"""Lightweight client for communicating with the GitHub v3 REST API."""
|
||||
|
||||
def __init__(self, owner: str, repo: str, token: str | None = None) -> None:
|
||||
"""Initializes the GitHub REST Client.
|
||||
|
||||
Args:
|
||||
owner: Owner/organization of the repository.
|
||||
repo: Name of the repository.
|
||||
token: Authentication token. If missing, API calls will fail.
|
||||
"""
|
||||
self.owner = owner
|
||||
self.repo = repo
|
||||
self._token = token
|
||||
self._base_url = f"https://api.github.com/repos/{owner}/{repo}/pulls"
|
||||
|
||||
def create_pull_request(
|
||||
self, branch_name: str, title: str, body: str
|
||||
) -> str:
|
||||
"""Submits a POST request to GitHub to create a new Pull Request.
|
||||
|
||||
Args:
|
||||
branch_name: The feature branch to be merged.
|
||||
title: Title of the Pull Request.
|
||||
body: Body description markdown of the Pull Request.
|
||||
|
||||
Returns:
|
||||
The PR number of the successfully created Pull Request as a string.
|
||||
|
||||
Raises:
|
||||
GitHubClientError: If the HTTP request fails or token is missing.
|
||||
"""
|
||||
if not self._token:
|
||||
raise GitHubClientError(
|
||||
"GitHub token is missing. Cannot authorize Pull Request creation."
|
||||
)
|
||||
|
||||
data = {
|
||||
"title": title,
|
||||
"body": body,
|
||||
"head": branch_name,
|
||||
"base": "main",
|
||||
}
|
||||
|
||||
req = urllib.request.Request(
|
||||
self._base_url,
|
||||
data=json.dumps(data).encode("utf-8"),
|
||||
headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {self._token}",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
|
||||
logging.info(
|
||||
"Sending Pull Request creation request for branch: %s", branch_name
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req,timeout=60) as response:
|
||||
response_payload = json.loads(response.read().decode("utf-8"))
|
||||
pr_number: str = str(response_payload.get("number", response_payload.get("html_url", "")))
|
||||
logging.info(
|
||||
"Pull Request created successfully! PR Number: %s", pr_number
|
||||
)
|
||||
return pr_number
|
||||
except urllib.error.URLError as e:
|
||||
if isinstance(e, urllib.error.HTTPError):
|
||||
err_body = e.read().decode("utf-8") if e.fp else "No body content"
|
||||
err_msg = f"HTTP {e.code}: {err_body}"
|
||||
else:
|
||||
err_msg = f"Network Error: {getattr(e, 'reason', e)}"
|
||||
|
||||
logging.error("Failed to create Pull Request: %s", err_msg)
|
||||
raise GitHubClientError(f"GitHub API Error: {err_msg}") from e
|
||||
except Exception as e:
|
||||
logging.exception("Encountered unexpected error during PR creation.")
|
||||
raise GitHubClientError(
|
||||
f"Unexpected API client error: {e}"
|
||||
) from e
|
||||
@@ -0,0 +1,694 @@
|
||||
# Copyright 2026 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.
|
||||
|
||||
"""Iterative Bug-Fixing and Evaluation Orchestrator State Machine.
|
||||
|
||||
Coordinates repository cloning, branch setup, execution of Google Antigravity
|
||||
Coding and Evaluator Agents, ESLint static analysis, and deterministic regression
|
||||
preflight checks. Manages Firestore dual-lock validation and lifecycle state
|
||||
transitions (COMMIT_GENERATION, PR_EVALUATION_PENDING, NEEDS_HUMAN).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from config import Config
|
||||
from command_executor import (
|
||||
CommandExecutor,
|
||||
CommandExecutionError,
|
||||
sanitize_identifier,
|
||||
sanitize_relative_path,
|
||||
)
|
||||
from github_client import GitHubClient, GitHubClientError
|
||||
from agent_runner import AgentRunner, AgentRunnerError
|
||||
from db.db_interface import (
|
||||
acquire_lock,
|
||||
release_lock,
|
||||
mark_pr_created,
|
||||
mark_needs_human,
|
||||
ClaimAction,
|
||||
IssueStatus,
|
||||
)
|
||||
from preflight_filter import PreflightFilter
|
||||
|
||||
|
||||
def _remove_readonly(func: Any, path: str, exc_info: Any) -> None:
|
||||
"""Error handler to change read-only file permissions during rmtree."""
|
||||
try:
|
||||
os.chmod(path, 0o777)
|
||||
func(path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
class OrchestrationError(Exception):
|
||||
"""Raised when the orchestration loop encounters an unrecoverable failure."""
|
||||
|
||||
|
||||
class Orchestrator:
|
||||
"""State machine running the iterative patch generation and evaluation loop."""
|
||||
|
||||
def __init__(self, config: Config) -> None:
|
||||
"""Initializes the state machine.
|
||||
|
||||
Args:
|
||||
config: Initialized Config instance.
|
||||
"""
|
||||
self.config = config
|
||||
orchestrator_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
prompts_dir = os.path.join(os.path.dirname(orchestrator_dir), "agent_prompts")
|
||||
self.agent_runner = AgentRunner(
|
||||
project_id=config.project_id,
|
||||
location=config.location,
|
||||
model_name=config.model_name,
|
||||
script_dir=prompts_dir,
|
||||
)
|
||||
|
||||
def _setup_workspace(self) -> None:
|
||||
"""Ensures that required temporary workspace structures are initialized."""
|
||||
logging.info("Initializing workspace directories...")
|
||||
os.makedirs(self.config.tmp_dir, exist_ok=True)
|
||||
os.makedirs(self.config.pr_dir, exist_ok=True)
|
||||
os.makedirs(self.config.eval_dir, exist_ok=True)
|
||||
|
||||
def _clean_eval_dir(self) -> None:
|
||||
"""Cleans up the evaluation workspace directory to prevent state bleeding."""
|
||||
logging.info("Cleaning up evaluation repository path: %s", self.config.eval_dir)
|
||||
if os.path.exists(self.config.eval_dir):
|
||||
try:
|
||||
shutil.rmtree(self.config.eval_dir, onerror=_remove_readonly)
|
||||
except OSError as e:
|
||||
logging.error("Failed to remove evaluation directory: %s", e)
|
||||
raise OrchestrationError(f"Failed to clean eval directory: {e}") from e
|
||||
os.makedirs(self.config.eval_dir, exist_ok=True)
|
||||
|
||||
def _sync_or_clone_repository(self) -> None:
|
||||
"""Clones the target git repo or synchronizes it back to clean main branch."""
|
||||
git_dir = os.path.join(self.config.pr_repo_path, ".git")
|
||||
repo_exists = os.path.exists(git_dir)
|
||||
|
||||
if repo_exists:
|
||||
logging.info("Repository already exists locally. Syncing with remote origin/main...")
|
||||
try:
|
||||
CommandExecutor.run(["git", "reset", "--hard", "HEAD"], self.config.pr_repo_path)
|
||||
CommandExecutor.run(["git", "clean", "-fd"], self.config.pr_repo_path)
|
||||
CommandExecutor.run(["git", "checkout", "main"], self.config.pr_repo_path)
|
||||
CommandExecutor.run(["git", "pull", "origin", "main"], self.config.pr_repo_path)
|
||||
except CommandExecutionError as e:
|
||||
logging.warning("Repository sync failed: %s. Re-cloning from scratch.", e)
|
||||
try:
|
||||
shutil.rmtree(self.config.pr_repo_path)
|
||||
except OSError as rm_err:
|
||||
logging.error("Failed to remove existing repo path: %s", rm_err)
|
||||
repo_exists = False
|
||||
|
||||
if not repo_exists:
|
||||
logging.info("Cloning repository %s into %s", self.config.repo_url, self.config.pr_dir)
|
||||
try:
|
||||
CommandExecutor.run(["git", "clone", self.config.repo_url, self.config.repo_name], self.config.pr_dir)
|
||||
except CommandExecutionError as e:
|
||||
raise OrchestrationError(f"Failed to clone repository: {e}") from e
|
||||
|
||||
# Establish safe bot git identity
|
||||
CommandExecutor.run(["git", "config", "user.name", "Jetski Bot"], self.config.pr_repo_path)
|
||||
CommandExecutor.run(["git", "config", "user.email", "jetski-bot@google.com"], self.config.pr_repo_path)
|
||||
|
||||
# Ignore agent/orch file writes to prevent pollution of git status
|
||||
exclude_file = os.path.join(self.config.pr_repo_path, ".git", "info", "exclude")
|
||||
os.makedirs(os.path.dirname(exclude_file), exist_ok=True)
|
||||
try:
|
||||
with open(exclude_file, "a", encoding="utf-8") as f:
|
||||
f.write(
|
||||
"\nfirestore_doc.json\npr_feedback.md\nfeedback.md\n"
|
||||
"changes.diff\nverdict.json\npr_details.md\n"
|
||||
)
|
||||
except IOError as io_err:
|
||||
logging.warning("Failed to configure git exclude file: %s", io_err)
|
||||
|
||||
async def run(self) -> None:
|
||||
"""Executes the core state machine pipeline.
|
||||
|
||||
Raises:
|
||||
OrchestrationError: If the bug fix generation or submission fails.
|
||||
"""
|
||||
self._setup_workspace()
|
||||
|
||||
# Load firestore specifications
|
||||
try:
|
||||
firestore_doc = self.config.load_and_validate_firestore_doc()
|
||||
except Exception as e:
|
||||
raise OrchestrationError(f"Failed to load / validate config: {e}") from e
|
||||
|
||||
issue_id = firestore_doc.get("workable_spec", {}).get("issue_id")
|
||||
github_metadata = firestore_doc.get("github_metadata", {})
|
||||
issue_num = github_metadata.get("issue_number")
|
||||
owner = github_metadata.get("owner")
|
||||
repo = github_metadata.get("repo")
|
||||
doc_id = self.config.firestore_id or (
|
||||
f"github_{owner}_{repo}_{issue_num}" if owner and repo and issue_num else None
|
||||
)
|
||||
|
||||
if not issue_num:
|
||||
raise OrchestrationError("Issue number is missing in Firestore metadata.")
|
||||
|
||||
# --- STEP 1, 2, 3: Concurrency Dual-Lock Validation & COMMIT_GENERATION State Update ---
|
||||
execution_id = self.config.execution_id
|
||||
logging.info("Validating concurrency dual-lock in Firestore for issue #%s...", issue_num)
|
||||
claim_action = acquire_lock(
|
||||
lock_holder=execution_id,
|
||||
doc_id=doc_id,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
issue_number=issue_num,
|
||||
lock_duration_sec=900, # 15 minutes
|
||||
target_status=IssueStatus.COMMIT_GENERATION.value,
|
||||
)
|
||||
|
||||
if claim_action == ClaimAction.SKIP:
|
||||
logging.info(
|
||||
"Lock validation: another worker is working on this issue or issue is in terminal state. Exiting cleanly."
|
||||
)
|
||||
return
|
||||
|
||||
if claim_action == ClaimAction.NEEDS_HUMAN:
|
||||
logging.warning(
|
||||
"Generation attempts exceeded maximum allowed limit. Issue moved to NEEDS_HUMAN. Exiting cleanly."
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
branch_name = f"ssr-agent-{sanitize_identifier(str(issue_num))}"
|
||||
|
||||
# Sync repository and check out target branch
|
||||
self._sync_or_clone_repository()
|
||||
try:
|
||||
# TODO: Add logic to fetch and checkout the existing branch if responding to user feedback
|
||||
CommandExecutor.run(["git", "checkout", "-B", branch_name, "origin/main"], self.config.pr_repo_path)
|
||||
except CommandExecutionError as e:
|
||||
raise OrchestrationError(f"Failed to checkout feature branch {branch_name}: {e}") from e
|
||||
|
||||
# Install project NPM dependencies inside PR workspace
|
||||
logging.info("Installing node dependencies inside PR repository workspace...")
|
||||
try:
|
||||
npm_install_cmd = 'NODE_OPTIONS="--max-old-space-size=4096" npm ci --no-audit --no-fund --maxsockets 3'
|
||||
CommandExecutor.run(npm_install_cmd, self.config.pr_repo_path)
|
||||
except CommandExecutionError as e:
|
||||
raise OrchestrationError(f"Failed to install NPM dependencies in PR workspace: {e}") from e
|
||||
|
||||
# Persist the specifications file inside the PR repo workspace
|
||||
spec_pr_path = os.path.join(self.config.pr_repo_path, "firestore_doc.json")
|
||||
try:
|
||||
with open(spec_pr_path, "w", encoding="utf-8") as f:
|
||||
json.dump(firestore_doc, f, indent=2)
|
||||
except IOError as e:
|
||||
raise OrchestrationError(f"Failed to save firestore_doc.json to workspace: {e}") from e
|
||||
|
||||
approved = False
|
||||
loop_count = 0
|
||||
verdict = "NEEDS_REVISION"
|
||||
commit_line_count = 0
|
||||
|
||||
while loop_count < self.config.max_attempts and not approved:
|
||||
loop_count += 1
|
||||
logging.info("=== Starting Iteration %s/%s ===", loop_count, self.config.max_attempts)
|
||||
|
||||
# --- PHASE 1: CODE GENERATION ---
|
||||
await self._run_code_generation(loop_count)
|
||||
|
||||
# Consolidate edits and generate diff
|
||||
diff_content = self._prepare_iteration_commit(issue_num, loop_count)
|
||||
if not diff_content:
|
||||
# No changes detected in code generation
|
||||
continue
|
||||
|
||||
# --- PHASE 2: EVALUATION ---
|
||||
verdict = await self._run_evaluation(diff_content, firestore_doc)
|
||||
|
||||
if verdict in ["APPROVED", "PASS"]:
|
||||
logging.info("Evaluator approved the patch. Launching deterministic regression pre-flights...")
|
||||
approved = await self._run_regression_checks()
|
||||
|
||||
if approved:
|
||||
try:
|
||||
diff_stat = CommandExecutor.run("git diff --stat origin/main", self.config.pr_repo_path)
|
||||
logging.info("Diff Stat summary:\n%s", diff_stat)
|
||||
lines = diff_stat.strip().split("\n")
|
||||
last_line = lines[-1] if lines else ""
|
||||
insertions = re.search(r"(\d+)\s+insertion", last_line)
|
||||
deletions = re.search(r"(\d+)\s+deletion", last_line)
|
||||
|
||||
if insertions:
|
||||
commit_line_count += int(insertions.group(1))
|
||||
if deletions:
|
||||
commit_line_count += int(deletions.group(1))
|
||||
logging.info("Total modifications line count: %s", commit_line_count)
|
||||
except Exception as e:
|
||||
logging.warning("Failed to parse modifications line count: %s", e)
|
||||
|
||||
# If iteration wasn't approved, synchronize feedback to the coding workspace
|
||||
if not approved:
|
||||
self._save_feedback_to_coding_workspace()
|
||||
|
||||
# --- POST LOOP RESOLUTION ---
|
||||
if approved:
|
||||
logging.info("=== PATCH APPROVED ===")
|
||||
if commit_line_count > 500:
|
||||
logging.error(
|
||||
"Verdict: APPROVED but modified line size (%s) exceeds 500 limit. Moving to NEEDS_HUMAN.",
|
||||
commit_line_count,
|
||||
)
|
||||
try:
|
||||
mark_needs_human(
|
||||
lock_holder=execution_id,
|
||||
reason=f"Commit modifications ({commit_line_count} lines) exceed 500 lines limit.",
|
||||
doc_id=doc_id,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
issue_number=issue_num,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error("Failed to update Firestore status to NEEDS_HUMAN: %s", e)
|
||||
return
|
||||
else:
|
||||
pr_number = await self._submit_pull_request(issue_num, issue_id, branch_name)
|
||||
try:
|
||||
mark_pr_created(
|
||||
lock_holder=execution_id,
|
||||
pr_number=pr_number or "",
|
||||
doc_id=doc_id,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
issue_number=issue_num,
|
||||
status=IssueStatus.PR_EVALUATION_PENDING.value,
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error("Failed to update Firestore status to PR_EVALUATION_PENDING: %s", e)
|
||||
else:
|
||||
logging.error(
|
||||
"=== PR REJECTED (Exceeded max loop attempts %s) ===",
|
||||
self.config.max_attempts,
|
||||
)
|
||||
try:
|
||||
release_lock(
|
||||
lock_holder=execution_id,
|
||||
success=False,
|
||||
doc_id=doc_id,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
issue_number=issue_num,
|
||||
status=IssueStatus.NEEDS_HUMAN.value,
|
||||
error=f"PR rejected after exceeding max loop attempts ({self.config.max_attempts}).",
|
||||
)
|
||||
except Exception as e:
|
||||
logging.error("Failed to release Firestore lock on rejection: %s", e)
|
||||
return
|
||||
except Exception as e:
|
||||
logging.error("Orchestrator pipeline failed: %s. Releasing lock.", e)
|
||||
try:
|
||||
release_lock(
|
||||
lock_holder=execution_id,
|
||||
success=False,
|
||||
doc_id=doc_id,
|
||||
owner=owner,
|
||||
repo=repo,
|
||||
issue_number=issue_num,
|
||||
error=str(e),
|
||||
)
|
||||
except Exception as db_err:
|
||||
logging.error("Failed to release lock on error: %s", db_err)
|
||||
raise
|
||||
|
||||
async def _run_code_generation(self, iteration: int) -> None:
|
||||
"""Runs the Google Antigravity Coding Agent to fix the bug."""
|
||||
logging.info("Starting Code Generation Agent...")
|
||||
if iteration == 1:
|
||||
prompt = (
|
||||
"Fix the bug described in firestore_doc.json. "
|
||||
"CRITICAL: You MUST use file editing tools (such as replace_file_content or write_file) "
|
||||
"to apply the code modifications to the target files in implementation_plan.files_to_modify "
|
||||
"and add the requested test assertions to testing_strategy.test_file. "
|
||||
"Do NOT conclude your session after only viewing files or running baseline tests without making edits. "
|
||||
"You are running in a headless sandbox environment. Execute any necessary test commands "
|
||||
"using your run_command tool (e.g. npx vitest run <test_file>). Do NOT ask for permission in the chat."
|
||||
)
|
||||
prompt_file = "bug_fixer_prompt.md"
|
||||
else:
|
||||
prompt = (
|
||||
"Use the feedback in pr_feedback.md to address the remaining issues in the code and tests. "
|
||||
"CRITICAL: You MUST apply file modifications to the codebase using replace_file_content or write_file. "
|
||||
"Original spec is at firestore_doc.json. "
|
||||
"You are running in a headless sandbox environment. Execute any necessary test or build commands "
|
||||
"directly using your run_command tool. Do NOT ask for permission in the chat."
|
||||
)
|
||||
prompt_file = "code_revision_prompt.md"
|
||||
|
||||
try:
|
||||
await self.agent_runner.run_agent(
|
||||
role="Coding Agent",
|
||||
prompt=prompt,
|
||||
repo_path=self.config.pr_repo_path,
|
||||
system_prompt_file=prompt_file,
|
||||
)
|
||||
except AgentRunnerError as e:
|
||||
logging.error("Coding Agent run encountered an error: %s. Transitioning to evaluation...", e)
|
||||
|
||||
def _prepare_iteration_commit(self, issue_num: int | str, iteration: int) -> str | None:
|
||||
"""Consolidates all file edits and stages a soft commit.
|
||||
|
||||
Returns:
|
||||
The raw diff comparison string to origin/main, or None if no changes.
|
||||
"""
|
||||
logging.info("Staging workspace modifications and soft-committing...")
|
||||
try:
|
||||
CommandExecutor.run(["git", "add", "."], self.config.pr_repo_path)
|
||||
CommandExecutor.run(["git", "reset", "--soft", "origin/main"], self.config.pr_repo_path)
|
||||
|
||||
git_status = CommandExecutor.run(["git", "status", "--porcelain"], self.config.pr_repo_path)
|
||||
if git_status:
|
||||
commit_msg = f"[SSR Agent] Issue Fix: issues/{issue_num}"
|
||||
CommandExecutor.run(["git", "commit", "-m", commit_msg, "--allow-empty", "--no-verify"], self.config.pr_repo_path)
|
||||
else:
|
||||
logging.info("No modifications staged against origin/main in this iteration.")
|
||||
if iteration == 1:
|
||||
logging.error("Failed to generate any code changes in the first iteration. Aborting.")
|
||||
raise OrchestrationError("Failed to generate any code changes in the first iteration.")
|
||||
return None
|
||||
|
||||
return CommandExecutor.run(["git", "diff", "origin/main"], self.config.pr_repo_path)
|
||||
except CommandExecutionError as e:
|
||||
logging.error("Failed to stage iteration commit or generate diff: %s", e)
|
||||
return None
|
||||
|
||||
async def _run_evaluation(self, diff_content: str, firestore_doc: dict[str, Any]) -> str:
|
||||
"""Sets up the evaluation sandbox workspace and runs the Evaluator Agent."""
|
||||
logging.info("Starting Evaluation Agent phase...")
|
||||
self._clean_eval_dir()
|
||||
|
||||
# Copy files to evaluation workspace (ignoring node_modules)
|
||||
try:
|
||||
shutil.copytree(
|
||||
self.config.pr_repo_path,
|
||||
self.config.eval_repo_path,
|
||||
dirs_exist_ok=True,
|
||||
ignore=shutil.ignore_patterns("node_modules"),
|
||||
)
|
||||
except OSError as e:
|
||||
logging.error("Failed to sync code into Evaluation workspace: %s", e)
|
||||
return "NEEDS_REVISION"
|
||||
|
||||
# Reuse existing node_modules from PR workspace via symlink to avoid redundant npm ci installs
|
||||
pr_node_modules = os.path.join(self.config.pr_repo_path, "node_modules")
|
||||
eval_node_modules = os.path.join(self.config.eval_repo_path, "node_modules")
|
||||
if os.path.exists(pr_node_modules) and not os.path.exists(eval_node_modules):
|
||||
logging.info("Symlinking node_modules from PR repository to evaluation workspace...")
|
||||
try:
|
||||
os.symlink(pr_node_modules, eval_node_modules)
|
||||
except OSError as e:
|
||||
logging.warning("Failed to symlink node_modules: %s. Falling back to npm install.", e)
|
||||
|
||||
# Fallback to installing node dependencies if node_modules is missing
|
||||
if not os.path.exists(eval_node_modules):
|
||||
logging.info("Installing node dependencies inside evaluation workspace...")
|
||||
try:
|
||||
npm_install_cmd = 'NODE_OPTIONS="--max-old-space-size=4096" npm ci --no-audit --no-fund --maxsockets 3'
|
||||
CommandExecutor.run(npm_install_cmd, self.config.eval_repo_path)
|
||||
except CommandExecutionError as e:
|
||||
logging.error("Failed to install NPM packages in evaluation sandbox: %s", e)
|
||||
return "NEEDS_REVISION"
|
||||
|
||||
# Persist changes diff file
|
||||
diff_eval_path = os.path.join(self.config.eval_repo_path, "changes.diff")
|
||||
try:
|
||||
with open(diff_eval_path, "w", encoding="utf-8") as f:
|
||||
f.write(diff_content)
|
||||
except IOError as e:
|
||||
logging.error("Failed to write changes.diff to evaluation workspace: %s", e)
|
||||
return "NEEDS_REVISION"
|
||||
|
||||
# Run linter checks
|
||||
self._run_eslint_static_check()
|
||||
|
||||
eval_prompt = (
|
||||
"Evaluate the changes in changes.diff against the spec in firestore_doc.json. "
|
||||
"You are running in a headless sandbox environment. "
|
||||
"Do NOT run the linter yourself; the linter has already been run and its results are "
|
||||
"saved in linter_output.txt. You MUST read linter_output.txt to determine if there are lint issues. "
|
||||
"You MUST output verdict.json to verdict.json in the format {\"verdict\": \"APPROVED\" | \"NEEDS_REVISION\"}. "
|
||||
"If approved, create pr_details.md containing the recommended commit message and PR description details "
|
||||
"(explicitly writing 'fixes #<issue_number>' and including the original issue URL https://github.com/<owner>/<repo>/issues/<issue_number>). "
|
||||
"If verification (linter or static inspection) fails or needs revision, output detailed feedback to pr_feedback.md."
|
||||
)
|
||||
|
||||
try:
|
||||
await self.agent_runner.run_agent(
|
||||
role="Evaluator Agent",
|
||||
prompt=eval_prompt,
|
||||
repo_path=self.config.eval_repo_path,
|
||||
system_prompt_file="code_evaluator_prompt.md",
|
||||
)
|
||||
except AgentRunnerError as e:
|
||||
logging.error("Evaluator Agent execution crashed: %s", e)
|
||||
|
||||
# Parse verdict output
|
||||
verdict_file = os.path.join(self.config.eval_repo_path, "verdict.json")
|
||||
if os.path.exists(verdict_file):
|
||||
try:
|
||||
with open(verdict_file, "r", encoding="utf-8") as f:
|
||||
verdict_payload = json.load(f)
|
||||
return str(verdict_payload.get("verdict", "NEEDS_REVISION"))
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logging.error("Failed to decode verdict JSON file: %s", e)
|
||||
else:
|
||||
logging.warning("Verdict JSON file was not generated by the Evaluator Agent.")
|
||||
|
||||
return "NEEDS_REVISION"
|
||||
|
||||
def _run_eslint_static_check(self) -> None:
|
||||
"""Runs ESLint dynamically over modified source files."""
|
||||
logging.info("Executing ESLint code checks...")
|
||||
linter_output_path = os.path.join(self.config.eval_repo_path, "linter_output.txt")
|
||||
|
||||
try:
|
||||
git_diff_cmd = 'git diff origin/main... --name-only --diff-filter=d -- "*.ts" "*.tsx" "*.js" "*.jsx"'
|
||||
changed_files_out = CommandExecutor.run(git_diff_cmd, self.config.eval_repo_path).strip()
|
||||
changed_files = []
|
||||
for f in changed_files_out.split("\n"):
|
||||
safe_path = sanitize_relative_path(f)
|
||||
if safe_path:
|
||||
changed_files.append(safe_path)
|
||||
except CommandExecutionError as e:
|
||||
logging.warning("Failed to retrieve changed files list from git: %s", e)
|
||||
changed_files = []
|
||||
|
||||
if changed_files:
|
||||
logging.info("Targeting ESLint against modified files: %s", changed_files)
|
||||
eslint_cmd = [
|
||||
"npx",
|
||||
"eslint",
|
||||
"--max-warnings",
|
||||
"0",
|
||||
"--no-error-on-unmatched-pattern",
|
||||
"--no-warn-ignored",
|
||||
] + changed_files
|
||||
eslint_env = {**os.environ, "NODE_OPTIONS": "--max-old-space-size=4096"}
|
||||
try:
|
||||
lint_result = CommandExecutor.run(
|
||||
eslint_cmd, self.config.eval_repo_path, env=eslint_env
|
||||
)
|
||||
with open(linter_output_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"ESLint check succeeded. Output:\n{lint_result}")
|
||||
logging.info("ESLint static checks passed. Stored results.")
|
||||
except CommandExecutionError as lint_err:
|
||||
with open(linter_output_path, "w", encoding="utf-8") as f:
|
||||
f.write(f"ESLint check FAILED. Errors found:\n{lint_err.stderr or lint_err.stdout}")
|
||||
logging.warning("ESLint static checks failed. Recorded details inside linter_output.txt.")
|
||||
else:
|
||||
try:
|
||||
with open(linter_output_path, "w", encoding="utf-8") as f:
|
||||
f.write("No TypeScript/JavaScript files were modified. ESLint skipped.")
|
||||
logging.info("ESLint skipped because no JS/TS files were modified.")
|
||||
except IOError as io_err:
|
||||
logging.error("Failed to write empty ESLint output: %s", io_err)
|
||||
|
||||
async def _run_regression_checks(self) -> bool:
|
||||
"""Runs deterministic E2E regression check pipeline.
|
||||
|
||||
Returns:
|
||||
True if all checks pass or bypass is approved, False otherwise.
|
||||
"""
|
||||
logging.info("Executing E2E regression check pipeline...")
|
||||
try:
|
||||
CommandExecutor.run("npm run clean", self.config.eval_repo_path)
|
||||
CommandExecutor.run("npm ci --no-audit --no-fund", self.config.eval_repo_path)
|
||||
|
||||
# Regression steps such as npm run build, npm run typecheck, and npm run test:ci are bypassed.
|
||||
# To run them: CommandExecutor.run("npm run test:ci", self.config.eval_repo_path)
|
||||
logging.info("Deterministic preflight regression checks bypassed.")
|
||||
return True
|
||||
except CommandExecutionError as preflight_error:
|
||||
logging.warning("Regression checks failed: %s", preflight_error)
|
||||
|
||||
# Match bypass rule filter
|
||||
if "test:ci" in preflight_error.cmd and PreflightFilter.should_ignore_preflight_failure(
|
||||
preflight_error.stdout, preflight_error.stderr
|
||||
):
|
||||
logging.info("Bypassing regression failure due to privilege-bypass allowed list rules.")
|
||||
return True
|
||||
|
||||
# If unapproved regression error, save detailed log report to evaluator feedback
|
||||
eval_feedback_file = os.path.join(self.config.eval_repo_path, "pr_feedback.md")
|
||||
try:
|
||||
with open(eval_feedback_file, "w", encoding="utf-8") as f:
|
||||
f.write("# E2E Regression Verification Failure\n\n")
|
||||
f.write(
|
||||
"The Evaluator Agent approved the PR, but the orchestrator's "
|
||||
"deterministic regression testing suite failed.\n\n"
|
||||
)
|
||||
f.write("## Error Details\n")
|
||||
f.write("```\n")
|
||||
f.write(f"Exit Code: {preflight_error.returncode}\n")
|
||||
f.write(f"Stdout:\n{preflight_error.stdout}\n")
|
||||
f.write(f"Stderr:\n{preflight_error.stderr}\n")
|
||||
f.write("```\n\n")
|
||||
f.write("Please analyze the regression and correct the implementation or tests.\n")
|
||||
except IOError as io_err:
|
||||
logging.error("Failed to write feedback report file: %s", io_err)
|
||||
|
||||
return False
|
||||
|
||||
def _save_feedback_to_coding_workspace(self) -> None:
|
||||
"""Copies feedback file back to coding workspace for next loop iteration."""
|
||||
logging.info("Syncing feedback files into Coding workspace...")
|
||||
eval_feedback = os.path.join(self.config.eval_repo_path, "pr_feedback.md")
|
||||
coding_feedback = os.path.join(self.config.pr_repo_path, "pr_feedback.md")
|
||||
|
||||
if os.path.exists(eval_feedback):
|
||||
try:
|
||||
shutil.copyfile(eval_feedback, coding_feedback)
|
||||
logging.info("Successfully loaded loop revision feedback:")
|
||||
with open(coding_feedback, "r", encoding="utf-8") as f:
|
||||
logging.info("\n%s", f.read())
|
||||
except (OSError, IOError) as e:
|
||||
logging.error("Failed to load feedback details: %s", e)
|
||||
else:
|
||||
try:
|
||||
with open(coding_feedback, "w", encoding="utf-8") as f:
|
||||
f.write("Evaluator rejected changes or preflight failed, but did not provide pr_feedback.md.")
|
||||
logging.info("No detailed feedback found. Preloaded fallback message.")
|
||||
except IOError as io_err:
|
||||
logging.error("Failed to write placeholder feedback: %s", io_err)
|
||||
|
||||
async def _submit_pull_request(
|
||||
self, issue_num: int | str, issue_id: str, branch_name: str
|
||||
) -> str | None:
|
||||
"""Amends commit message, pushes feature branch, and publishes a GitHub PR.
|
||||
|
||||
Returns:
|
||||
The HTML URL of the created PR, or None if token is missing.
|
||||
"""
|
||||
logging.info("Proceeding with git push and pull request submission...")
|
||||
|
||||
pr_details_file = os.path.join(self.config.eval_repo_path, "pr_details.md")
|
||||
recommended_commit_msg = None
|
||||
recommended_pr_desc = None
|
||||
|
||||
if os.path.exists(pr_details_file):
|
||||
logging.info("Parsing recommended PR details from evaluator output...")
|
||||
try:
|
||||
with open(pr_details_file, "r", encoding="utf-8") as f:
|
||||
details_content = f.read()
|
||||
|
||||
# Parse recommended Commit Message (case-insensitive)
|
||||
commit_match = re.search(
|
||||
r"##\s*Commit\s*Message\r?\n\s*(.+?)(?=\r?\n##|$)",
|
||||
details_content,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if commit_match:
|
||||
recommended_commit_msg = commit_match.group(1).strip()
|
||||
logging.info("Found recommended commit message: %s", recommended_commit_msg)
|
||||
|
||||
# Parse recommended PR Description (case-insensitive)
|
||||
desc_match = re.search(
|
||||
r"##\s*PR\s*Description\r?\n\s*(.+?)(?=\r?\n##|$)",
|
||||
details_content,
|
||||
re.IGNORECASE | re.DOTALL,
|
||||
)
|
||||
if desc_match:
|
||||
recommended_pr_desc = desc_match.group(1).strip()
|
||||
logging.info("Found recommended PR description.")
|
||||
except Exception as e:
|
||||
logging.warning("Failed to parse recommended PR details: %s. Falling back to default details.", e)
|
||||
|
||||
# Amend current Git commit with the recommended title if available
|
||||
if recommended_commit_msg:
|
||||
try:
|
||||
CommandExecutor.run(["git", "commit", "--amend", "-m", recommended_commit_msg, "--no-verify"], self.config.pr_repo_path)
|
||||
except CommandExecutionError as e:
|
||||
logging.error("Failed to amend git commit message: %s", e)
|
||||
|
||||
# Push branch securely using in-memory auth headers (force pushes are supported to override prior retries)
|
||||
git_env = os.environ.copy()
|
||||
if self.config.git_token:
|
||||
auth_bytes = f"x-access-token:{self.config.git_token}".encode("utf-8")
|
||||
auth_b64 = base64.b64encode(auth_bytes).decode("utf-8")
|
||||
git_env["GIT_CONFIG_COUNT"] = "1"
|
||||
git_env["GIT_CONFIG_KEY_0"] = "http.extraHeader"
|
||||
git_env["GIT_CONFIG_VALUE_0"] = f"AUTHORIZATION: basic {auth_b64}"
|
||||
|
||||
try:
|
||||
CommandExecutor.run(
|
||||
["git", "push", "-f", "origin", f"HEAD:refs/heads/{branch_name}"],
|
||||
cwd=self.config.pr_repo_path,
|
||||
env=git_env,
|
||||
)
|
||||
logging.info("Branch push to remote succeeded.")
|
||||
except CommandExecutionError as e:
|
||||
logging.error("Failed to push git branch: %s", e)
|
||||
raise OrchestrationError(f"Failed to push git branch: {e}") from e
|
||||
|
||||
# Submit Pull Request
|
||||
if self.config.git_token:
|
||||
repo_parts = self.config.repo_url.rstrip("/").split("/")
|
||||
owner = repo_parts[-2]
|
||||
repo_name = self.config.repo_name
|
||||
|
||||
pr_title = recommended_commit_msg if recommended_commit_msg else f"[SSR Agent] Issue Fix: issues/{issue_num}"
|
||||
pr_body = recommended_pr_desc if recommended_pr_desc else (
|
||||
f"This Pull Request was automatically generated by the SSR Code Generator Agent "
|
||||
f"to resolve issue `{issue_id}`.\n\n"
|
||||
f"### Summary of Changes:\n"
|
||||
f"Applied targeted modifications to address the issue, validated with local compilation and unit tests."
|
||||
)
|
||||
|
||||
client = GitHubClient(owner=owner, repo=repo_name, token=self.config.git_token)
|
||||
try:
|
||||
pr_number = client.create_pull_request(
|
||||
branch_name=branch_name,
|
||||
title=pr_title,
|
||||
body=pr_body,
|
||||
)
|
||||
return pr_number
|
||||
except GitHubClientError as e:
|
||||
logging.error("Pull request submission failed: %s", e)
|
||||
raise OrchestrationError(f"Pull request submission failed: {e}") from e
|
||||
else:
|
||||
logging.warning("GitHub token not configured. Skipping PR creation.")
|
||||
return None
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Preflight test and linting validation filter.
|
||||
|
||||
Parses CI tool and unit test terminal outputs to identify and selectively
|
||||
bypass known, acceptable test failures (such as specific container/sandbox
|
||||
privilege test failures).
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
|
||||
_ANSI_ESCAPE_RE = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])")
|
||||
|
||||
ALLOWED_SANDBOX_FAILURES: set[str] = {
|
||||
"src/utils/sessionCleanup.test.ts",
|
||||
"src/config/extension-manager-permissions.test.ts",
|
||||
"root-privilege-check",
|
||||
"container-permission-test",
|
||||
}
|
||||
|
||||
|
||||
def strip_ansi(text: str) -> str:
|
||||
"""Removes ANSI terminal styling escape sequences from a string."""
|
||||
return _ANSI_ESCAPE_RE.sub("", text)
|
||||
|
||||
|
||||
def is_preflight_failure_allowed(
|
||||
test_output: str,
|
||||
allowed_failures: set[str] = ALLOWED_SANDBOX_FAILURES,
|
||||
) -> bool:
|
||||
"""Checks if test failures belong strictly to approved container/sandbox exceptions."""
|
||||
clean_output = strip_ansi(test_output)
|
||||
lines = clean_output.splitlines()
|
||||
|
||||
failing_lines = [
|
||||
line for line in lines if "FAIL" in line or "FAILED" in line
|
||||
]
|
||||
if not failing_lines:
|
||||
return False
|
||||
|
||||
for line in failing_lines:
|
||||
if not any(allowed in line for allowed in allowed_failures):
|
||||
logging.warning("Unapproved preflight test failure detected: %s", line)
|
||||
return False
|
||||
|
||||
logging.info("All detected test failure lines match approved sandbox exceptions.")
|
||||
return True
|
||||
|
||||
|
||||
class PreflightFilter:
|
||||
"""Utility class to filter ANSI characters and analyze test suite results."""
|
||||
|
||||
@staticmethod
|
||||
def strip_ansi(text: str) -> str:
|
||||
"""Removes ANSI terminal styling escape sequences from a string."""
|
||||
return strip_ansi(text)
|
||||
|
||||
@classmethod
|
||||
def should_ignore_preflight_failure(
|
||||
cls,
|
||||
stdout: str | None,
|
||||
stderr: str | None,
|
||||
allowed_failures: set[str] = ALLOWED_SANDBOX_FAILURES,
|
||||
) -> bool:
|
||||
"""Analyzes regression test outputs to see if they can be safely bypassed."""
|
||||
raw_output = (stdout or "") + "\n" + (stderr or "")
|
||||
return is_preflight_failure_allowed(raw_output, allowed_failures)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Entrypoint orchestrator script running as a Cloud Run Job.
|
||||
|
||||
Loads the environment config, configures centralized logging, and executes the
|
||||
iterative bug-fixing orchestrator asynchronously.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import sys
|
||||
|
||||
from config import Config
|
||||
from orchestrator import Orchestrator, OrchestrationError
|
||||
|
||||
|
||||
class IgnoreRawWsMsgFilter(logging.Filter):
|
||||
"""Filter to ignore raw websocket messages in the log output."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
return "RAW WS MSG" not in record.getMessage()
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Sets up the root logger with a standardized format."""
|
||||
handler = logging.StreamHandler(sys.stdout)
|
||||
handler.addFilter(IgnoreRawWsMsgFilter())
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
handlers=[handler],
|
||||
)
|
||||
|
||||
|
||||
async def main() -> None:
|
||||
"""Asynchronous process execution entrypoint."""
|
||||
setup_logging()
|
||||
logging.info("Starting SSR Agent Orchestration Worker...")
|
||||
|
||||
try:
|
||||
config = Config()
|
||||
orchestrator = Orchestrator(config)
|
||||
await orchestrator.run()
|
||||
except OrchestrationError as e:
|
||||
logging.critical("Orchestrator encountered a fatal error: %s", e)
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
logging.exception("An unhandled error occurred in the orchestrator.")
|
||||
sys.exit(4)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except Exception as err:
|
||||
print(f"Fatal crash during startup initialization: {err}", file=sys.stderr)
|
||||
sys.exit(4)
|
||||
Reference in New Issue
Block a user