Compare commits
71 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 64676f4ade | |||
| 44aed04fa5 | |||
| c1899414d0 | |||
| e026db4297 | |||
| 766e31f593 | |||
| 6a6cd9157c | |||
| 918c1538e6 | |||
| 93194463e6 | |||
| ea5f6d4990 | |||
| d40ee18a45 | |||
| ac7a6da65c | |||
| 0e6d595d11 | |||
| b168e636b2 | |||
| 3a20340035 | |||
| f5734b1d1b | |||
| 9a3dd07c34 | |||
| 51a6c68633 | |||
| ebcad8bd7a | |||
| 766bfd1680 | |||
| 957db4da1c | |||
| 45de4fa9ac | |||
| a979a49c58 | |||
| 0d6ae041fa | |||
| 4b1e24f0bd | |||
| f98e8a34fa | |||
| 8e850586ff | |||
| 0085c5f50c | |||
| 044b76a337 | |||
| 3f42ea1476 | |||
| 0ede02a29a | |||
| 1b1a48b2f4 | |||
| ba7c31d4b3 | |||
| 926c2c4c0b | |||
| 9e27ef1eb2 | |||
| 7fdb6f127a | |||
| 0f4a5c398c | |||
| 1268e92bdb | |||
| 84670f223a | |||
| 4fac61bfc9 | |||
| 4a866c3269 | |||
| 6823d9a274 | |||
| dd70c70c8d | |||
| ad8fe8c44e | |||
| 43f9ccb106 | |||
| 24509aa5a1 | |||
| fa41970dd9 | |||
| 76b086f291 | |||
| 1dfb124ebd | |||
| e1bed1b78d | |||
| 695ae115a9 | |||
| eef401469b | |||
| 6b25e1e2c5 | |||
| 6e42fb6147 | |||
| 58ab6692ac | |||
| 8f5a977104 | |||
| d6fc29cb4b | |||
| 616d093b9d | |||
| d3fa67fc3a | |||
| 08aa704980 | |||
| 44598d86dc | |||
| 9e3bdbb1c4 | |||
| fc85d332c8 | |||
| c4265f0558 | |||
| 06d09cb6da | |||
| 5af8cd40b2 | |||
| 276c725616 | |||
| 17feeedcf5 | |||
| 1b2ff5328a | |||
| 16a742e614 | |||
| 032c8d015c | |||
| 5cc9125179 |
@@ -0,0 +1,2 @@
|
||||
# Treat the AppIcon bundle's contents as binary and never merge them.
|
||||
ap-web/electron/icons/AppIcon.icon/** binary -merge
|
||||
@@ -5,6 +5,6 @@
|
||||
"description": "Pinned npm CLIs the e2e workflow installs (claude-code, codex).",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/claude-code": "2.1.124",
|
||||
"@openai/codex": "0.128.0-alpha.1"
|
||||
"@openai/codex": "0.139.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run reviewer-assignment unit test
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
# Trusted default branch only (.github sparse). Never the PR head, so no
|
||||
# PR-authored code runs.
|
||||
- name: Check out .github
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout default-branch helper
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github/scripts/pr-template
|
||||
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
@@ -209,7 +209,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
@@ -289,7 +289,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Run duplicate-PR unit test
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
# Trusted default branch only (.github sparse). Pin the ref explicitly so
|
||||
# manual workflow_dispatch runs can't execute a script from another
|
||||
# branch. Never the PR head, so no PR-authored code runs.
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out gate scripts from main
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: main # trusted base; never the PR head
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Check out CI scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# Triggering ref (not main): the script must exist on it, and it
|
||||
# only shards tests -- no secrets exposure, so the PR's copy is fine.
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch || github.ref }}
|
||||
|
||||
@@ -200,7 +200,7 @@ jobs:
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
||||
run: |
|
||||
mkdir -p "${GITHUB_WORKSPACE}/.codex-cli" && cd "${GITHUB_WORKSPACE}/.codex-cli"
|
||||
npm install --ignore-scripts --no-audit --no-fund @openai/codex@0.128.0-alpha.1
|
||||
npm install --ignore-scripts --no-audit --no-fund @openai/codex@0.139.0
|
||||
echo "${GITHUB_WORKSPACE}/.codex-cli/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Configure native-claude/codex gateway provider
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Check out CI scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# Triggering ref (not main): the script must exist on it, and it
|
||||
# only shards tests -- no secrets exposure, so the PR's copy is fine.
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# Same-repo PRs test the merge result (refs/pull/N/merge -- absent
|
||||
# when the PR conflicts, so a conflicted PR fails checkout by design).
|
||||
|
||||
@@ -192,7 +192,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.inputs.target_branch }}
|
||||
|
||||
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.inputs.target_branch }}
|
||||
|
||||
|
||||
@@ -151,7 +151,7 @@ jobs:
|
||||
|
||||
- name: Check out gate scripts from main
|
||||
if: steps.ctx.outputs.is_fork == 'true'
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: main # trusted; never the PR head
|
||||
sparse-checkout: .github/scripts
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Create a GitHub Release entry (the `…/releases` page) when a version tag is
|
||||
# pushed. This is METADATA ONLY — it does NOT build or publish any installable
|
||||
# artifact. PyPI publishing lives in the central secure-release repo
|
||||
# (databricks/secure-public-registry-releases-eng → `omnigent` workflow), on
|
||||
# hardened runners with OIDC Trusted Publishing and a mandatory dependency
|
||||
# scan. Keeping those concerns separate is deliberate (see RELEASING.md):
|
||||
#
|
||||
# * This job runs NO project or third-party code — no build, no `pip
|
||||
# install`/`npm ci`, no tests. Its only action is SHA-pinned
|
||||
# `actions/checkout` plus `gh release create`. A malicious tagged commit
|
||||
# therefore cannot execute anything here.
|
||||
# * It uses the ephemeral `GITHUB_TOKEN` (no stored secret / PAT). The single
|
||||
# elevated scope, `contents: write`, is the minimum GitHub requires to
|
||||
# create a release and nothing else in the job uses it.
|
||||
# * It attaches NO wheels. The release carries only generated notes and the
|
||||
# source tarball GitHub auto-attaches, so PyPI (the scanned, securely
|
||||
# published channel) stays the single source of installable artifacts.
|
||||
# * The release is created as a DRAFT: a human verifies/edits the generated
|
||||
# notes and publishes it (ideally after the prod PyPI publish lands), so a
|
||||
# bot never makes a public release on its own.
|
||||
name: GitHub Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
# Version tags only (v0.2.0, v0.2.0rc1, …) — `v[0-9]*` avoids triggering
|
||||
# on non-release tags like `v-infra-*`.
|
||||
- "v[0-9]*"
|
||||
|
||||
# Least privilege: creating a release requires `contents: write`; nothing here
|
||||
# needs anything more.
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
draft-release:
|
||||
# Inert in forks / mirrors — only the canonical repo should cut releases.
|
||||
if: github.repository == 'omnigent-ai/omnigent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
# Full history so `--generate-notes` can diff against the previous tag.
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Draft release with generated notes
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
# Rerun-safe: if a release for this tag already exists (a rerun, a
|
||||
# deleted-and-re-pushed tag, or a manual release), skip instead of
|
||||
# failing the job. An `if` so this can't trip `set -e`.
|
||||
if gh release view "$TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "Release $TAG already exists — skipping." | tee -a "$GITHUB_STEP_SUMMARY"
|
||||
exit 0
|
||||
fi
|
||||
# rc / dev / alpha / beta tags are flagged as pre-releases.
|
||||
pre=""
|
||||
case "$TAG" in
|
||||
*rc*|*dev*|*a[0-9]*|*b[0-9]*) pre="--prerelease" ;;
|
||||
esac
|
||||
# $pre is intentionally UNQUOTED: it word-splits to nothing when empty,
|
||||
# and is only ever "" or "--prerelease" (set just above, never from
|
||||
# external input). Quoting it would pass an empty positional arg.
|
||||
gh release create "$TAG" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--draft \
|
||||
--verify-tag \
|
||||
--generate-notes \
|
||||
--title "$TAG" \
|
||||
$pre
|
||||
echo "Drafted release $TAG — review/edit the notes and publish from the Releases page." \
|
||||
| tee -a "$GITHUB_STEP_SUMMARY"
|
||||
@@ -62,7 +62,7 @@ jobs:
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
steps:
|
||||
- name: Check out CI scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
# Triggering ref (not pinned to main): the script must exist on the
|
||||
# running ref, and it is not a security gate -- it only selects which
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.inputs.branch || github.ref }}
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
|
||||
- name: Check out repo
|
||||
if: steps.creds.outputs.available == 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out scripts
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: main # trusted gate scripts; never the PR head
|
||||
sparse-checkout: .github/scripts/merge-ready
|
||||
|
||||
@@ -81,7 +81,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||
@@ -243,7 +243,7 @@ jobs:
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up crane
|
||||
uses: imjasonh/setup-crane@59c71e96a00b28651f10369ba3359a6d730740a0 # v0.6
|
||||
|
||||
@@ -42,7 +42,7 @@ jobs:
|
||||
# Checkout main only for load-maintainers.sh; the PR branch is checked
|
||||
# out later (regen job), after authorization passes.
|
||||
- name: Checkout (for the maintainer script)
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Load maintainers from .github/MAINTAINER
|
||||
id: maint
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
# build backends, which must not find a push token on disk. The App token
|
||||
# is minted only after `uv lock` and enters only at the push step.
|
||||
- name: Checkout the PR branch
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ needs.authorize.outputs.head }}
|
||||
persist-credentials: false
|
||||
|
||||
@@ -33,7 +33,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up uv (clean public resolution, no proxy cache)
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
|
||||
- name: Checkout
|
||||
if: steps.gate.outputs.ready == 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
# out untrusted PR code in a privileged workflow.
|
||||
- name: Check out repo
|
||||
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
@@ -181,7 +181,7 @@ jobs:
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
||||
run: |
|
||||
mkdir -p "${GITHUB_WORKSPACE}/.codex-cli" && cd "${GITHUB_WORKSPACE}/.codex-cli"
|
||||
npm install --ignore-scripts --no-audit --no-fund @openai/codex@0.128.0-alpha.1
|
||||
npm install --ignore-scripts --no-audit --no-fund @openai/codex@0.139.0
|
||||
echo "${GITHUB_WORKSPACE}/.codex-cli/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Set LLM credentials
|
||||
@@ -332,8 +332,10 @@ jobs:
|
||||
|
||||
# Strip any sub-agent coordination preamble that leaks before
|
||||
# the actual review. Primary: look for the sentinel we asked the
|
||||
# model to emit. Fallback: first markdown heading or standalone
|
||||
# horizontal rule.
|
||||
# model to emit. Fallback: first markdown heading. If neither is
|
||||
# found the output is intermediate narration (subagents timed out
|
||||
# before synthesis) — write empty string so the post step is skipped
|
||||
# and raw coordination messages are never posted as a PR comment.
|
||||
python3 -c "
|
||||
import re, pathlib
|
||||
raw = pathlib.Path('/tmp/polly_output.txt').read_text()
|
||||
@@ -342,8 +344,8 @@ jobs:
|
||||
if idx >= 0:
|
||||
cleaned = raw[idx + len(sentinel):].lstrip('\n')
|
||||
else:
|
||||
m = re.search(r'^(#{1,6} |---\s*$)', raw, re.MULTILINE)
|
||||
cleaned = raw[m.start():] if m else raw
|
||||
m = re.search(r'^#{1,6} ', raw, re.MULTILINE)
|
||||
cleaned = raw[m.start():] if m else ''
|
||||
pathlib.Path('/tmp/polly_output.txt').write_text(cleaned)
|
||||
"
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Checkout default-branch script
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github/scripts/pr-size
|
||||
|
||||
@@ -64,7 +64,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Set up uv (clean public resolution, no proxy cache)
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
|
||||
@@ -80,10 +80,14 @@ jobs:
|
||||
# load-bearing: the wheel packages on-disk files, so the bundle must
|
||||
# exist before `uv build`. `rm -rf` backstops Vite's emptyOutDir
|
||||
# against stale bundles; `npm ci` installs the exact locked deps.
|
||||
# `--legacy-peer-deps` matches how ap-web's lockfile is generated and
|
||||
# validated everywhere else (lint, e2e-ui, ap-web-tests, the regen
|
||||
# jobs) — required for the React 19 peer conflict; without it `npm ci`
|
||||
# rejects the lockfile ("Missing: yaml@1.10.3 from lock file").
|
||||
- name: Build web UI (clean, fresh)
|
||||
run: |
|
||||
rm -rf omnigent/server/static/web-ui
|
||||
npm --prefix ap-web ci
|
||||
npm --prefix ap-web ci --legacy-peer-deps
|
||||
npm --prefix ap-web run build # Vite outDir -> omnigent/server/static/web-ui
|
||||
|
||||
# 2. Tag-driven: the tag must match the version in all three pyprojects
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
timeout-minutes: 8
|
||||
steps:
|
||||
- name: Check out trust check from main
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: main # trusted; never the PR head
|
||||
sparse-checkout: .github/scripts/security-scan
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
UV_INDEX_URL: https://pypi.org/simple
|
||||
steps:
|
||||
- name: Check out scanner from main
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: main # trusted; never the PR head
|
||||
sparse-checkout: |
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
|
||||
- name: Check out PR head for static analysis
|
||||
if: ${{ steps.gate.outputs.scan == 'true' }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }} # untrusted: only statically scanned
|
||||
path: pr
|
||||
|
||||
@@ -41,7 +41,9 @@ repos:
|
||||
language: system
|
||||
entry: npm --prefix ap-web exec -- prettier --write
|
||||
files: ^ap-web/.*\.(css|html|js|jsx|json|md|mdx|ts|tsx|yaml|yml)$
|
||||
exclude: ^omnigent/server/static/web-ui/assets/
|
||||
# Exclude generated assets: web-ui build output and Apple Icon
|
||||
# Composer `.icon` bundles (machine-formatted; prettier fights the tooling).
|
||||
exclude: ^(omnigent/server/static/web-ui/assets/|ap-web/electron/icons/.*\.icon/)
|
||||
|
||||
# Local `uv` runs rewrite uv.lock's registry to whatever index is
|
||||
# configured on the developer's machine (e.g. the Databricks PyPI
|
||||
@@ -63,7 +65,9 @@ repos:
|
||||
exclude: \.(md|svg)$
|
||||
- id: end-of-file-fixer
|
||||
name: ensure files end with newline
|
||||
exclude: \.(md|svg)$
|
||||
# AppIcon.icon/ is generated by Apple's Icon Composer, which writes
|
||||
# icon.json without a trailing newline — don't "fix" it.
|
||||
exclude: (\.(md|svg)$|/AppIcon\.icon/)
|
||||
- id: check-yaml
|
||||
name: check yaml syntax
|
||||
# docker-compose override files use non-standard tags
|
||||
|
||||
@@ -3,3 +3,6 @@ dist
|
||||
../omnigent/server/static/web-ui
|
||||
src/components/ui
|
||||
package-lock.json
|
||||
|
||||
# Generated Apple Icon Composer bundles (machine-formatted; prettier fights the tooling)
|
||||
electron/icons/**/*.icon
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_26_3760)">
|
||||
<path d="M601.6 0C749.453 0 823.381 0.000134204 879.854 28.7744C929.528 54.085 969.915 94.4718 995.226 144.146C1024 200.619 1024 274.547 1024 422.4V601.6C1024 749.453 1024 823.381 995.226 879.854C969.915 929.528 929.528 969.915 879.854 995.226C823.381 1024 749.453 1024 601.6 1024H422.4C274.547 1024 200.619 1024 144.146 995.226C94.4718 969.915 54.085 929.528 28.7744 879.854C0.000134204 823.381 0 749.453 0 601.6V422.4C0 274.547 0.000134204 200.619 28.7744 144.146C54.085 94.4718 94.4718 54.085 144.146 28.7744C200.619 0.000134204 274.547 0 422.4 0H601.6ZM386.4 60C272.15 60 215.024 59.9997 171.386 82.2344C133.001 101.793 101.793 133.001 82.2344 171.386C59.9997 215.024 60 272.15 60 386.4V637.6C60 751.85 59.9997 808.976 82.2344 852.614C101.793 890.999 133.001 922.207 171.386 941.766C215.024 964 272.15 964 386.4 964H637.6C751.85 964 808.976 964 852.614 941.766C890.999 922.207 922.207 890.999 941.766 852.614C964 808.976 964 751.85 964 637.6V386.4C964 272.15 964 215.024 941.766 171.386C922.207 133.001 890.999 101.793 852.614 82.2344C808.976 59.9997 751.85 60 637.6 60H386.4Z" fill="url(#paint0_linear_26_3760)"/>
|
||||
<rect x="60" y="60" width="904" height="904" rx="204" stroke="#DADADA" stroke-opacity="0.6" stroke-width="5"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_26_3760" x1="147" y1="-98.5" x2="966" y2="1039.5" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="white"/>
|
||||
<stop offset="0.5" stop-color="#939393"/>
|
||||
<stop offset="1" stop-color="#B6B6B6"/>
|
||||
</linearGradient>
|
||||
<clipPath id="clip0_26_3760">
|
||||
<rect width="1024" height="1024" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
@@ -1,73 +1,57 @@
|
||||
{
|
||||
"fill": {
|
||||
"automatic-gradient": "display-p3:0.17673,0.38168,0.68246,1.00000",
|
||||
"orientation": {
|
||||
"start": {
|
||||
"x": 0.5,
|
||||
"y": 0
|
||||
"fill" : {
|
||||
"linear-gradient" : [
|
||||
"display-p3:0.12164,0.15594,0.26582,1.00000",
|
||||
"display-p3:0.11859,0.15173,0.25830,1.00000"
|
||||
],
|
||||
"orientation" : {
|
||||
"start" : {
|
||||
"x" : 0.5,
|
||||
"y" : 0
|
||||
},
|
||||
"stop": {
|
||||
"x": 0.5,
|
||||
"y": 0.7
|
||||
"stop" : {
|
||||
"x" : 0.5,
|
||||
"y" : 0.7
|
||||
}
|
||||
}
|
||||
},
|
||||
"groups": [
|
||||
"groups" : [
|
||||
{
|
||||
"layers": [
|
||||
"layers" : [
|
||||
{
|
||||
"image-name": "SVG Image.svg",
|
||||
"name": "SVG Image",
|
||||
"position": {
|
||||
"scale": 1.05,
|
||||
"translation-in-points": [0, 0]
|
||||
"image-name" : "SVG Image.svg",
|
||||
"name" : "SVG Image",
|
||||
"position" : {
|
||||
"scale" : 1.05,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"position": {
|
||||
"scale": 0.85,
|
||||
"translation-in-points": [0, 0]
|
||||
"position" : {
|
||||
"scale" : 1,
|
||||
"translation-in-points" : [
|
||||
0,
|
||||
0
|
||||
]
|
||||
},
|
||||
"shadow": {
|
||||
"kind": "layer-color",
|
||||
"opacity": 0.5
|
||||
"shadow" : {
|
||||
"kind" : "layer-color",
|
||||
"opacity" : 0.5
|
||||
},
|
||||
"specular": true,
|
||||
"translucency": {
|
||||
"enabled": false,
|
||||
"value": 0.5
|
||||
}
|
||||
},
|
||||
{
|
||||
"blend-mode-specializations": [
|
||||
{
|
||||
"appearance": "dark",
|
||||
"value": "soft-light"
|
||||
},
|
||||
{
|
||||
"appearance": "tinted",
|
||||
"value": "overlay"
|
||||
}
|
||||
],
|
||||
"layers": [
|
||||
{
|
||||
"image-name": "SVG Image 6.svg",
|
||||
"name": "SVG Image 6"
|
||||
}
|
||||
],
|
||||
"shadow": {
|
||||
"kind": "neutral",
|
||||
"opacity": 0.5
|
||||
},
|
||||
"specular": false,
|
||||
"translucency": {
|
||||
"enabled": false,
|
||||
"value": 0.5
|
||||
"specular" : true,
|
||||
"translucency" : {
|
||||
"enabled" : false,
|
||||
"value" : 0.5
|
||||
}
|
||||
}
|
||||
],
|
||||
"supported-platforms": {
|
||||
"circles": ["watchOS"],
|
||||
"squares": "shared"
|
||||
"supported-platforms" : {
|
||||
"circles" : [
|
||||
"watchOS"
|
||||
],
|
||||
"squares" : "shared"
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
Before Width: | Height: | Size: 364 KiB After Width: | Height: | Size: 364 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 638 KiB After Width: | Height: | Size: 450 KiB |
Generated
+3654
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "omnigent-desktop-electron",
|
||||
"productName": "Omnigent",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"description": "Omnigent desktop shell (Electron edition) — a thin native wrapper around the server-served web UI.",
|
||||
"private": true,
|
||||
"main": "src/main.js",
|
||||
|
||||
@@ -1677,6 +1677,9 @@ function registerIpc() {
|
||||
title,
|
||||
body: String(params?.body ?? ""),
|
||||
});
|
||||
// In-app path the SPA wants opened on click (e.g. "/c/conv_abc"). Captured
|
||||
// here so the click handler can tell the renderer where to route.
|
||||
const navigatePath = typeof params?.navigatePath === "string" ? params.navigatePath : "";
|
||||
// Focus the window that fired the notification (so a click lands on the
|
||||
// right one in a multi-window setup), falling back to any open window.
|
||||
notification.on("click", () => {
|
||||
@@ -1685,6 +1688,17 @@ function registerIpc() {
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.focus();
|
||||
}
|
||||
// Route only the originating window (it owns that conversation's state).
|
||||
// isDestroyed() and send() aren't atomic — the window can close between
|
||||
// them — so the try/catch absorbs the benign "Object has been destroyed"
|
||||
// throw instead of crashing the main process from this async callback.
|
||||
if (navigatePath && !event.sender.isDestroyed()) {
|
||||
try {
|
||||
event.sender.send("omnigent:notification-activated", navigatePath);
|
||||
} catch {
|
||||
// Sender went away after the notification was posted; nothing to do.
|
||||
}
|
||||
}
|
||||
});
|
||||
notification.show();
|
||||
signalForeground();
|
||||
|
||||
@@ -27,13 +27,31 @@ contextBridge.exposeInMainWorld("omnigentDesktop", {
|
||||
},
|
||||
/**
|
||||
* Fire an OS notification. Resolves true when shown, false otherwise.
|
||||
* @param {{title: string, body?: string}} params
|
||||
* @param {{title: string, body?: string, navigatePath?: string}} params
|
||||
*/
|
||||
notify: (params) =>
|
||||
ipcRenderer.invoke("omnigent:notify", {
|
||||
title: params?.title,
|
||||
body: params?.body,
|
||||
navigatePath: params?.navigatePath,
|
||||
}),
|
||||
/**
|
||||
* Subscribe to OS-notification clicks. The main process sends the in-app
|
||||
* path the clicked notification carried, which we forward to the SPA so it
|
||||
* can route there. Returns an unsubscribe function.
|
||||
* @param {(path: string) => void} callback
|
||||
* @returns {() => void}
|
||||
*/
|
||||
onNotificationActivated: (callback) => {
|
||||
const listener = (_event, path) => {
|
||||
// Defense-in-depth: only forward in-app, same-origin paths. A leading
|
||||
// "/" rejects absolute/cross-origin URLs and `javascript:` shapes before
|
||||
// the renderer routes on the value, even if main ever sends junk.
|
||||
if (typeof path === "string" && path.startsWith("/")) callback(path);
|
||||
};
|
||||
ipcRenderer.on("omnigent:notification-activated", listener);
|
||||
return () => ipcRenderer.removeListener("omnigent:notification-activated", listener);
|
||||
},
|
||||
/**
|
||||
* Title-bar server picker data: the window's current server origin and the
|
||||
* recently-connected server URLs (most recent first). Resolves null on
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// Cmd/Ctrl+Enter accepts the newest pending accept/decline prompt; skips
|
||||
// already-responded prompts and AskUserQuestion (which needs an explicit
|
||||
// choice); ignores bare Enter and Alt/Shift-modified Enter.
|
||||
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const submitApproval = vi.fn();
|
||||
let blocks: Array<Record<string, unknown>> = [];
|
||||
vi.mock("@/store/chatStore", () => ({
|
||||
useChatStore: { getState: () => ({ blocks, submitApproval }) },
|
||||
}));
|
||||
|
||||
import { useApproveHotkey } from "./useApproveHotkey";
|
||||
|
||||
/** Dispatch a keydown that reaches window from body (default: Cmd+Enter). */
|
||||
function press(
|
||||
mods: Partial<Pick<KeyboardEvent, "metaKey" | "ctrlKey" | "altKey" | "shiftKey">> = {
|
||||
metaKey: true,
|
||||
},
|
||||
key = "Enter",
|
||||
): void {
|
||||
document.body.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key, bubbles: true, cancelable: true, ...mods }),
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
submitApproval.mockClear();
|
||||
blocks = [];
|
||||
});
|
||||
afterEach(() => {
|
||||
blocks = [];
|
||||
});
|
||||
|
||||
describe("useApproveHotkey", () => {
|
||||
const pending = { type: "elicitation", elicitationId: "e1", status: "pending" };
|
||||
|
||||
it("Cmd+Enter accepts the pending approval", () => {
|
||||
blocks = [pending];
|
||||
renderHook(() => useApproveHotkey());
|
||||
press();
|
||||
expect(submitApproval).toHaveBeenCalledWith("e1", "accept");
|
||||
});
|
||||
|
||||
it("Ctrl+Enter also accepts (Win/Linux)", () => {
|
||||
blocks = [pending];
|
||||
renderHook(() => useApproveHotkey());
|
||||
press({ ctrlKey: true });
|
||||
expect(submitApproval).toHaveBeenCalledWith("e1", "accept");
|
||||
});
|
||||
|
||||
it("accepts the most recent pending approval", () => {
|
||||
blocks = [
|
||||
{ type: "elicitation", elicitationId: "old", status: "pending" },
|
||||
{ type: "text" },
|
||||
{ type: "elicitation", elicitationId: "new", status: "pending" },
|
||||
];
|
||||
renderHook(() => useApproveHotkey());
|
||||
press();
|
||||
expect(submitApproval).toHaveBeenCalledWith("new", "accept");
|
||||
});
|
||||
|
||||
it("ignores already-responded prompts", () => {
|
||||
blocks = [{ type: "elicitation", elicitationId: "e1", status: "responded" }];
|
||||
renderHook(() => useApproveHotkey());
|
||||
press();
|
||||
expect(submitApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips AskUserQuestion (needs an explicit choice)", () => {
|
||||
blocks = [{ type: "elicitation", elicitationId: "q1", status: "pending", askUserQuestion: {} }];
|
||||
renderHook(() => useApproveHotkey());
|
||||
press();
|
||||
expect(submitApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores bare Enter and Alt/Shift-modified Enter", () => {
|
||||
blocks = [pending];
|
||||
renderHook(() => useApproveHotkey());
|
||||
press({}); // bare Enter
|
||||
press({ metaKey: true, shiftKey: true });
|
||||
press({ metaKey: true, altKey: true });
|
||||
expect(submitApproval).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
// Cmd+Enter (Ctrl+Enter on Win/Linux) accepts the pending harness approval
|
||||
// prompt — the keyboard equivalent of clicking "Accept" on an ApprovalCard.
|
||||
// Bind ONCE at the app shell.
|
||||
//
|
||||
// Runs in the CAPTURE phase so it can intercept the keystroke before the
|
||||
// composer's own Enter-to-send handler (which fires during bubble and would
|
||||
// otherwise submit the draft first). When it actually accepts an approval it
|
||||
// stops the event so the composer never sees it; when nothing is pending it
|
||||
// leaves the event untouched, so Cmd/Ctrl+Enter keeps whatever meaning it had.
|
||||
//
|
||||
// Only plain accept/decline prompts (command, edit, plan, codex command) are
|
||||
// accepted. AskUserQuestion elicitations are skipped: they require choosing a
|
||||
// specific option, so a blanket "accept" carries no answer and the user must
|
||||
// pick on the card itself.
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
import type { ElicitationBlock } from "@/lib/blocks";
|
||||
import { useChatStore } from "@/store/chatStore";
|
||||
|
||||
export function useApproveHotkey(): void {
|
||||
useEffect(() => {
|
||||
const handler = (e: globalThis.KeyboardEvent): void => {
|
||||
// Cmd/Ctrl, not Alt/Shift (mirrors the session-switch hotkey's guard).
|
||||
if (!(e.metaKey || e.ctrlKey) || e.altKey || e.shiftKey) return;
|
||||
if (e.key !== "Enter") return;
|
||||
|
||||
const { blocks, submitApproval } = useChatStore.getState();
|
||||
// Newest-first: accept the most recent still-pending prompt that takes a
|
||||
// plain verdict. Skip AskUserQuestion (needs an explicit choice).
|
||||
const pending = [...blocks]
|
||||
.reverse()
|
||||
.find(
|
||||
(b): b is ElicitationBlock =>
|
||||
b.type === "elicitation" && b.status === "pending" && !b.askUserQuestion,
|
||||
);
|
||||
if (!pending) return;
|
||||
|
||||
// Intercept before the composer's Enter-to-send handler runs.
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void submitApproval(pending.elicitationId, "accept");
|
||||
};
|
||||
|
||||
window.addEventListener("keydown", handler, true);
|
||||
return () => window.removeEventListener("keydown", handler, true);
|
||||
}, []);
|
||||
}
|
||||
@@ -21,6 +21,9 @@ vi.mock("@/lib/browserNotifications", () => ({
|
||||
vi.mock("@/lib/nativeBridge", () => ({
|
||||
isNativeShell: vi.fn(),
|
||||
setBadgeCount: vi.fn().mockResolvedValue(undefined),
|
||||
// Returns an unsubscribe fn; tests that exercise native click routing
|
||||
// capture the registered callback via this mock's calls.
|
||||
onNativeNotificationActivated: vi.fn().mockReturnValue(() => {}),
|
||||
}));
|
||||
|
||||
// The turn-end notification body is enriched by an async fetch of the agent's
|
||||
@@ -38,7 +41,7 @@ import {
|
||||
requestNotificationPermission,
|
||||
showNotification,
|
||||
} from "@/lib/browserNotifications";
|
||||
import { isNativeShell, setBadgeCount } from "@/lib/nativeBridge";
|
||||
import { isNativeShell, onNativeNotificationActivated, setBadgeCount } from "@/lib/nativeBridge";
|
||||
import { fetchLastAssistantText } from "@/lib/lastAssistantText";
|
||||
import { markConversationSeen } from "@/hooks/useUnseenConversations";
|
||||
import { useIdleNotifications } from "./useIdleNotifications";
|
||||
@@ -48,6 +51,7 @@ const getPermMock = vi.mocked(getNotificationPermission);
|
||||
const requestPermMock = vi.mocked(requestNotificationPermission);
|
||||
const showMock = vi.mocked(showNotification);
|
||||
const isNativeMock = vi.mocked(isNativeShell);
|
||||
const onNativeActivatedMock = vi.mocked(onNativeNotificationActivated);
|
||||
const setBadgeMock = vi.mocked(setBadgeCount);
|
||||
const fetchPreviewMock = vi.mocked(fetchLastAssistantText);
|
||||
|
||||
@@ -95,6 +99,8 @@ beforeEach(() => {
|
||||
showMock.mockReset();
|
||||
requestPermMock.mockReset();
|
||||
setBadgeMock.mockClear();
|
||||
onNativeActivatedMock.mockClear();
|
||||
onNativeActivatedMock.mockReturnValue(() => {});
|
||||
fetchPreviewMock.mockReset();
|
||||
fetchPreviewMock.mockResolvedValue(undefined);
|
||||
getPermMock.mockReturnValue("granted");
|
||||
@@ -159,6 +165,25 @@ describe("useIdleNotifications turn-end transitions", () => {
|
||||
showMock.mock.calls[0][0].onClick?.();
|
||||
// Click routes to the session's chat page.
|
||||
expect(navigateMock).toHaveBeenCalledWith("/c/a");
|
||||
// The desktop shell can't carry the onClick closure across IPC, so the
|
||||
// same destination is also passed as a plain path for the native path.
|
||||
expect(showMock.mock.calls[0][0].navigatePath).toBe("/c/a");
|
||||
});
|
||||
|
||||
it("routes to the conversation when the desktop shell reports a notification click", async () => {
|
||||
isNativeMock.mockReturnValue(true);
|
||||
setConversations([conv("a", "running")]);
|
||||
renderHook(() => useIdleNotifications());
|
||||
|
||||
// The hook registers one native-activation listener; grab its callback
|
||||
// and simulate the shell delivering a clicked notification's path.
|
||||
expect(onNativeActivatedMock).toHaveBeenCalledOnce();
|
||||
const activatedCb = onNativeActivatedMock.mock.calls[0][0];
|
||||
act(() => {
|
||||
activatedCb("/c/a");
|
||||
});
|
||||
|
||||
expect(navigateMock).toHaveBeenCalledWith("/c/a");
|
||||
});
|
||||
|
||||
it("does not notify on a fresh load with already-idle sessions", () => {
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
requestNotificationPermission,
|
||||
showNotification,
|
||||
} from "@/lib/browserNotifications";
|
||||
import { isNativeShell, setBadgeCount } from "@/lib/nativeBridge";
|
||||
import { isNativeShell, onNativeNotificationActivated, setBadgeCount } from "@/lib/nativeBridge";
|
||||
import { fetchLastAssistantText } from "@/lib/lastAssistantText";
|
||||
import {
|
||||
buildElicitationMap,
|
||||
@@ -124,6 +124,21 @@ export function useIdleNotifications(activeConversationId?: string): void {
|
||||
const activeIdRef = useRef<string | undefined>(activeConversationId);
|
||||
activeIdRef.current = activeConversationId;
|
||||
|
||||
// Keep the latest `navigate` readable from the once-mounted native-click
|
||||
// listener below without re-subscribing whenever the router identity changes.
|
||||
const navigateRef = useRef(navigate);
|
||||
navigateRef.current = navigate;
|
||||
|
||||
// Desktop shell only: clicking an OS notification can't run the web
|
||||
// `onClick` closure (it never crosses the IPC boundary), so the shell sends
|
||||
// back the notification's in-app path and we route to it here — making a
|
||||
// native toast click open its conversation, matching the browser path.
|
||||
useEffect(() => {
|
||||
return onNativeNotificationActivated((path) => navigateRef.current(path));
|
||||
// navigateRef is stable; the listener is mounted once for the app's life.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Send the badge count when it differs from the last one sent. No-op in a
|
||||
// plain browser (`setBadgeCount` is inert outside the desktop shell).
|
||||
const pushBadge = (count: number) => {
|
||||
@@ -209,13 +224,18 @@ function notify(
|
||||
body: string,
|
||||
navigate: ReturnType<typeof useNavigate>,
|
||||
): void {
|
||||
const path = `/c/${conversation.id}`;
|
||||
showNotification({
|
||||
title: conversationDisplayLabel(conversation),
|
||||
body,
|
||||
// Tag by id so a later update for the same session replaces its
|
||||
// toast instead of stacking duplicates.
|
||||
tag: `omnigent:session:${conversation.id}`,
|
||||
onClick: () => navigate(`/c/${conversation.id}`),
|
||||
// Browser path: run navigation directly on click. Desktop shell path:
|
||||
// `navigatePath` is forwarded over IPC and routed on click instead, since
|
||||
// this closure can't cross the process boundary.
|
||||
onClick: () => navigate(path),
|
||||
navigatePath: path,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -127,4 +127,29 @@ describe("showNotification", () => {
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
expect(instances[0].close).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("hands off to the native shell (with navigatePath) instead of a web toast", () => {
|
||||
// Under the Electron shell, showNotification routes to the OS notification
|
||||
// and forwards navigatePath (not the onClick closure, which can't cross
|
||||
// IPC) so the shell can open the conversation on click. No web toast.
|
||||
installNotification("granted");
|
||||
const electronNotify = vi.fn().mockResolvedValue(true);
|
||||
(window as unknown as Record<string, unknown>).omnigentDesktop = {
|
||||
kind: "electron",
|
||||
setBadgeCount: vi.fn(),
|
||||
notify: electronNotify,
|
||||
};
|
||||
try {
|
||||
const result = showNotification({ title: "X", body: "done", navigatePath: "/c/a" });
|
||||
expect(result).toBeNull();
|
||||
expect(instances).toHaveLength(0);
|
||||
expect(electronNotify).toHaveBeenCalledWith({
|
||||
title: "X",
|
||||
body: "done",
|
||||
navigatePath: "/c/a",
|
||||
});
|
||||
} finally {
|
||||
delete (window as unknown as Record<string, unknown>).omnigentDesktop;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -55,6 +55,13 @@ export interface ShowNotificationParams {
|
||||
tag?: string;
|
||||
/** Invoked when the user clicks the notification (after focusing). */
|
||||
onClick?: () => void;
|
||||
/**
|
||||
* In-app path to open when the notification is clicked, e.g. `"/c/abc"`.
|
||||
* The browser path runs `onClick` directly; the Electron path can't carry a
|
||||
* JS closure across the process boundary, so it forwards this string to the
|
||||
* shell, which routes to it on click (see `onNativeNotificationActivated`).
|
||||
*/
|
||||
navigatePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,13 +76,16 @@ export function showNotification({
|
||||
body,
|
||||
tag,
|
||||
onClick,
|
||||
navigatePath,
|
||||
}: ShowNotificationParams): Notification | null {
|
||||
// Desktop shell: hand off to the native OS notification and skip the web
|
||||
// toast entirely. `nativeNotify` is async/best-effort; we don't await it
|
||||
// (callers treat this function as fire-and-forget) and return null because
|
||||
// no web `Notification` object is created in the native path.
|
||||
// no web `Notification` object is created in the native path. We forward
|
||||
// `navigatePath` (not the `onClick` closure, which can't cross the IPC
|
||||
// boundary) so the shell can route to the conversation on click.
|
||||
if (isNativeShell()) {
|
||||
void nativeNotify({ title, body });
|
||||
void nativeNotify({ title, body, navigatePath });
|
||||
return null;
|
||||
}
|
||||
if (!isNotificationSupported() || Notification.permission !== "granted") return null;
|
||||
|
||||
@@ -4,20 +4,33 @@ import {
|
||||
isElectronShell,
|
||||
isNativeShell,
|
||||
nativeNotify,
|
||||
onNativeNotificationActivated,
|
||||
setBadgeCount as bridgeSetBadge,
|
||||
} from "./nativeBridge";
|
||||
|
||||
// The Electron preload bridge mock, installed on window.omnigentDesktop.
|
||||
const electronSetBadge = vi.fn();
|
||||
const electronNotify = vi.fn().mockResolvedValue(true);
|
||||
const electronUnsubscribe = vi.fn();
|
||||
const electronOnNotificationActivated = vi.fn().mockReturnValue(electronUnsubscribe);
|
||||
|
||||
/** Simulate running inside / outside the Electron shell via the preload key. */
|
||||
function setElectron(on: boolean): void {
|
||||
/**
|
||||
* Simulate running inside / outside the Electron shell via the preload key.
|
||||
* `withClickRouting` toggles the optional `onNotificationActivated` method so
|
||||
* tests can also exercise a shell too old to support click routing.
|
||||
*/
|
||||
function setElectron(on: boolean, withClickRouting = true): void {
|
||||
if (on) {
|
||||
(window as unknown as Record<string, unknown>).omnigentDesktop = {
|
||||
kind: "electron",
|
||||
setBadgeCount: (...args: unknown[]) => electronSetBadge(...args),
|
||||
notify: (...args: unknown[]) => electronNotify(...args),
|
||||
...(withClickRouting
|
||||
? {
|
||||
onNotificationActivated: (...args: unknown[]) =>
|
||||
electronOnNotificationActivated(...args),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
} else {
|
||||
delete (window as unknown as Record<string, unknown>).omnigentDesktop;
|
||||
@@ -64,7 +77,21 @@ describe("nativeNotify", () => {
|
||||
it("routes the notification through the Electron bridge with title+body", async () => {
|
||||
setElectron(true);
|
||||
await expect(nativeNotify({ title: "Session 1", body: "done" })).resolves.toBe(true);
|
||||
expect(electronNotify).toHaveBeenCalledWith({ title: "Session 1", body: "done" });
|
||||
expect(electronNotify).toHaveBeenCalledWith({
|
||||
title: "Session 1",
|
||||
body: "done",
|
||||
navigatePath: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards navigatePath so the shell can route on click", async () => {
|
||||
setElectron(true);
|
||||
await nativeNotify({ title: "Session 1", body: "done", navigatePath: "/c/a" });
|
||||
expect(electronNotify).toHaveBeenCalledWith({
|
||||
title: "Session 1",
|
||||
body: "done",
|
||||
navigatePath: "/c/a",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns false when the bridge throws", async () => {
|
||||
@@ -74,6 +101,43 @@ describe("nativeNotify", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("onNativeNotificationActivated", () => {
|
||||
it("returns a no-op unsubscribe outside the shell", () => {
|
||||
setElectron(false);
|
||||
const cb = vi.fn();
|
||||
const unsubscribe = onNativeNotificationActivated(cb);
|
||||
// No bridge -> nothing subscribed, and the returned unsubscribe is safe.
|
||||
expect(electronOnNotificationActivated).not.toHaveBeenCalled();
|
||||
expect(() => unsubscribe()).not.toThrow();
|
||||
});
|
||||
|
||||
it("returns a no-op unsubscribe under a shell lacking click routing", () => {
|
||||
setElectron(true, false);
|
||||
const cb = vi.fn();
|
||||
const unsubscribe = onNativeNotificationActivated(cb);
|
||||
expect(electronOnNotificationActivated).not.toHaveBeenCalled();
|
||||
expect(() => unsubscribe()).not.toThrow();
|
||||
});
|
||||
|
||||
it("subscribes through the bridge and returns its unsubscribe", () => {
|
||||
setElectron(true);
|
||||
const cb = vi.fn();
|
||||
const unsubscribe = onNativeNotificationActivated(cb);
|
||||
expect(electronOnNotificationActivated).toHaveBeenCalledWith(cb);
|
||||
unsubscribe();
|
||||
expect(electronUnsubscribe).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("returns a no-op unsubscribe when the bridge throws", () => {
|
||||
setElectron(true);
|
||||
electronOnNotificationActivated.mockImplementationOnce(() => {
|
||||
throw new Error("ipc down");
|
||||
});
|
||||
const unsubscribe = onNativeNotificationActivated(vi.fn());
|
||||
expect(() => unsubscribe()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe("setBadgeCount", () => {
|
||||
it("is a no-op outside the shell", async () => {
|
||||
setElectron(false);
|
||||
|
||||
@@ -33,6 +33,14 @@ interface ElectronDesktopApi {
|
||||
setBadgeCount: (count: number) => void;
|
||||
/** Fire an OS notification; resolves true when it was shown. */
|
||||
notify: (params: NativeNotifyParams) => Promise<boolean>;
|
||||
// Optional: a shell older than this SPA may lack notification-click routing,
|
||||
// in which case clicking a desktop toast only focuses the window (the prior
|
||||
// behavior) instead of also navigating.
|
||||
/**
|
||||
* Subscribe to OS-notification clicks. The main process sends the in-app
|
||||
* path the notification carried (its `navigatePath`); returns an unsubscribe.
|
||||
*/
|
||||
onNotificationActivated?: (callback: (path: string) => void) => () => void;
|
||||
// The server-picker trio is optional: the SPA is server-served and may be
|
||||
// newer than the installed shell, whose preload then lacks these methods.
|
||||
/** Current server origin + recent servers, or null on a foreign page. */
|
||||
@@ -92,6 +100,13 @@ export interface NativeNotifyParams {
|
||||
title: string;
|
||||
/** Secondary line, e.g. "Agent finished and is ready for your input." */
|
||||
body?: string;
|
||||
/**
|
||||
* In-app path the shell should open when the user clicks this notification,
|
||||
* e.g. `"/c/conv_abc123"`. A click closure can't cross the process boundary,
|
||||
* so we forward the destination as a string and route to it on click via
|
||||
* `onNativeNotificationActivated`. Omitted -> click only focuses the window.
|
||||
*/
|
||||
navigatePath?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -102,11 +117,15 @@ export interface NativeNotifyParams {
|
||||
* not running under Electron or anything went wrong (so the caller can fall
|
||||
* back to the Web Notifications API).
|
||||
*/
|
||||
export async function nativeNotify({ title, body }: NativeNotifyParams): Promise<boolean> {
|
||||
export async function nativeNotify({
|
||||
title,
|
||||
body,
|
||||
navigatePath,
|
||||
}: NativeNotifyParams): Promise<boolean> {
|
||||
const electron = electronApi();
|
||||
if (!electron) return false;
|
||||
try {
|
||||
return await electron.notify({ title, body });
|
||||
return await electron.notify({ title, body, navigatePath });
|
||||
} catch (err) {
|
||||
// Only reachable inside the desktop shell. Log rather than swallow so a
|
||||
// broken bridge is visible instead of silently dropping notifications.
|
||||
@@ -115,6 +134,27 @@ export async function nativeNotify({ title, body }: NativeNotifyParams): Promise
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to native notification clicks from the desktop shell. The shell
|
||||
* fires the in-app path the clicked notification carried (its `navigatePath`),
|
||||
* so the renderer can route to it — restoring the in-browser behavior where
|
||||
* clicking a toast opens its conversation.
|
||||
*
|
||||
* Returns an unsubscribe function. A no-op (returning a no-op unsubscribe)
|
||||
* outside the Electron shell or under a shell too old to support click
|
||||
* routing, so callers can register it unconditionally.
|
||||
*/
|
||||
export function onNativeNotificationActivated(callback: (path: string) => void): () => void {
|
||||
const electron = electronApi();
|
||||
if (!electron?.onNotificationActivated) return () => {};
|
||||
try {
|
||||
return electron.onNotificationActivated(callback);
|
||||
} catch (err) {
|
||||
console.warn("[nativeBridge] electron onNotificationActivated failed:", err);
|
||||
return () => {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Paint the dock / taskbar badge with a count (macOS dock badge, Linux Unity
|
||||
* launcher count). Pass `0` (or omit) to clear it.
|
||||
|
||||
@@ -39,15 +39,6 @@ export const NATIVE_CODING_AGENTS = [
|
||||
sortRank: 20,
|
||||
capabilities: ["approvalMode"],
|
||||
},
|
||||
{
|
||||
key: "pi",
|
||||
agentName: "pi-native-ui",
|
||||
harness: "pi-native",
|
||||
wrapperLabel: "pi-native-ui",
|
||||
displayName: "Pi",
|
||||
iconKind: "pi",
|
||||
sortRank: 30,
|
||||
},
|
||||
{
|
||||
key: "cursor",
|
||||
agentName: "cursor-native-ui",
|
||||
@@ -55,6 +46,15 @@ export const NATIVE_CODING_AGENTS = [
|
||||
wrapperLabel: "cursor-native-ui",
|
||||
displayName: "Cursor",
|
||||
iconKind: "cursor",
|
||||
sortRank: 30,
|
||||
},
|
||||
{
|
||||
key: "pi",
|
||||
agentName: "pi-native-ui",
|
||||
harness: "pi-native",
|
||||
wrapperLabel: "pi-native-ui",
|
||||
displayName: "Pi",
|
||||
iconKind: "pi",
|
||||
sortRank: 40,
|
||||
},
|
||||
] as const satisfies readonly NativeCodingAgentSpec[];
|
||||
|
||||
@@ -3448,7 +3448,11 @@ export function Composer({
|
||||
// within the wrapped line. Gating on position 0 / length ensures the
|
||||
// browser gets to move the caret through wrapped lines first; only the
|
||||
// final ArrowUp-at-start / ArrowDown-at-end triggers recall.
|
||||
if (e.key === "ArrowUp" || e.key === "ArrowDown") {
|
||||
// Recall is for UNmodified arrows only. Cmd/Ctrl+↑/↓ (switch session) and
|
||||
// Cmd/Alt+↑/↓ (jump between messages) are global window hotkeys meant to
|
||||
// fire even mid-compose; without this guard the recall below intercepts
|
||||
// them (replacing the draft) and the hotkeys appear broken in the composer.
|
||||
if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
||||
const ta = e.currentTarget;
|
||||
if (e.key === "ArrowUp" && ta.selectionStart === 0) {
|
||||
const recalled = recallPrevious(value);
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useQueryClient } from "@tanstack/react-query";
|
||||
import { Outlet, useParams, useSearchParams } from "@/lib/routing";
|
||||
import { useConversations } from "@/hooks/useConversations";
|
||||
import { useSessionAgent } from "@/hooks/useAgents";
|
||||
import { useApproveHotkey } from "@/hooks/useApproveHotkey";
|
||||
import { AgentInfoContent, agentHasInfo } from "@/components/AgentInfo";
|
||||
import { useIdleNotifications } from "@/hooks/useIdleNotifications";
|
||||
import { readFilesPanelPreferences, writeFilesPanelPreferences } from "@/lib/filesPanelPreferences";
|
||||
@@ -102,6 +103,10 @@ import type { RightRailTab } from "./railTabs";
|
||||
* more than one agent (the root has at least one child).
|
||||
*/
|
||||
export function AppShell() {
|
||||
// Cmd/Ctrl+Enter accepts the pending harness approval prompt. Bound once
|
||||
// here so it works on every chat route, regardless of where focus sits.
|
||||
useApproveHotkey();
|
||||
|
||||
// Read early: the conversationId scopes the per-session workspace state
|
||||
// (rail open/width/tab/open files) used throughout this component.
|
||||
const { conversationId } = useParams<{ conversationId: string }>();
|
||||
|
||||
@@ -611,6 +611,48 @@ describe("NewChatLandingScreen", () => {
|
||||
expect(screen.getByText("No agents")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("orders Cursor above Pi in the built-in agent picker", () => {
|
||||
mockAgents([
|
||||
{
|
||||
id: "a_pi",
|
||||
name: "pi-native-ui",
|
||||
display_name: "Pi",
|
||||
description: null,
|
||||
harness: "pi-native",
|
||||
skills: [],
|
||||
},
|
||||
{
|
||||
id: "a_cursor",
|
||||
name: "cursor-native-ui",
|
||||
display_name: "Cursor",
|
||||
description: null,
|
||||
harness: "cursor-native",
|
||||
skills: [],
|
||||
},
|
||||
{
|
||||
id: "a_codex",
|
||||
name: "codex-native-ui",
|
||||
display_name: "Codex",
|
||||
description: null,
|
||||
harness: "codex-native",
|
||||
skills: [],
|
||||
},
|
||||
{
|
||||
id: "a_claude",
|
||||
name: "claude-native-ui",
|
||||
display_name: "Claude Code",
|
||||
description: null,
|
||||
harness: "claude-native",
|
||||
skills: [],
|
||||
},
|
||||
]);
|
||||
renderLanding();
|
||||
fireEvent.pointerDown(screen.getByTestId("new-chat-landing-agent-select"), { button: 0 });
|
||||
const cursor = screen.getByTestId("new-chat-landing-agent-a_cursor");
|
||||
const pi = screen.getByTestId("new-chat-landing-agent-a_pi");
|
||||
expect(cursor.compareDocumentPosition(pi) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
});
|
||||
|
||||
it("seeds the working directory from the host's most-recent path", async () => {
|
||||
renderLanding();
|
||||
// host_1's recent ("/Users/corey/repo") seeds the field; the chip shows
|
||||
|
||||
@@ -70,7 +70,7 @@ import { AgentRowTooltip } from "@/components/AgentHoverCard";
|
||||
// returns agents newest-registered first (agent_store.list sorts by
|
||||
// created_at desc), so pin the order users expect; any agent not listed
|
||||
// here falls after, in server order.
|
||||
const AGENT_DISPLAY_ORDER = ["Claude Code", "Codex", "Pi", "Cursor", "Polly", "Debby"];
|
||||
const AGENT_DISPLAY_ORDER = ["Claude Code", "Codex", "Cursor", "Pi", "Polly", "Debby"];
|
||||
|
||||
// Built-in agents (by name slug) — the long-lived agents the server
|
||||
// ships out of the box. The picker groups these first, then a divider,
|
||||
|
||||
@@ -64,6 +64,12 @@ deploy/
|
||||
│ ├── modal_app.py
|
||||
│ └── README.md
|
||||
│
|
||||
├── cloudflare/ ← Cloudflare Containers + D1 + R2 (serverless, scale-to-zero)
|
||||
│ ├── Dockerfile server image + D1 dialect
|
||||
│ ├── src/index.js the Worker that fronts the container
|
||||
│ ├── wrangler.jsonc
|
||||
│ └── README.md
|
||||
│
|
||||
├── trycloudflare/ ← Cloudflare quick tunnel (public URL for a LOCAL server)
|
||||
│ └── README.md
|
||||
│
|
||||
@@ -99,6 +105,7 @@ deploy/
|
||||
| Run on any host you already have (VPS, home server, on-prem) | Docker compose | [`docker/README.md`](docker/README.md): copy the compose stack, `./bootstrap.sh`, then `docker compose up -d` |
|
||||
| Deploy to Fly.io | Fly | [`fly/README.md`](fly/README.md): `fly deploy`, SQLite on a volume |
|
||||
| Deploy to Modal (durable artifact Volume) | Modal | [`modal/README.md`](modal/README.md): `modal deploy`, BYO Neon Postgres |
|
||||
| Deploy serverless (scale-to-zero, no VM/Postgres to manage) | Cloudflare Containers + D1 + R2 | [`cloudflare/README.md`](cloudflare/README.md): `wrangler deploy` |
|
||||
| Stand up a quick demo (no DB to provision) | HF Spaces | [`hf-spaces/README.md`](hf-spaces/README.md): Docker Space, SQLite |
|
||||
| Share a server running on your **laptop**: demo it to teammates, or let remote runners & cloud sandboxes connect back to it (nothing to deploy) | Cloudflare quick tunnel | `cloudflared tunnel --url http://localhost:6767` |
|
||||
| Cloud Run / Kubernetes / other | Docker image | [`docker/README.md`](docker/README.md), then point your platform at the image |
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Trim the Docker build context. `wrangler deploy` builds the container image
|
||||
# from this directory, but the Dockerfile only needs sitecustomize.py — keep
|
||||
# node deps, wrangler state, and Python caches out of the context sent to the
|
||||
# Docker daemon.
|
||||
node_modules/
|
||||
.wrangler/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
@@ -0,0 +1,32 @@
|
||||
# Omnigent server on Cloudflare Containers, backed by D1 (database) and R2
|
||||
# (artifact store via omnigent's native S3 backend). Derived from the official
|
||||
# server image; adds the Cloudflare D1 SQLAlchemy dialect + a behavior shim, and
|
||||
# boto3 for the S3/R2 artifact store.
|
||||
#
|
||||
# No FUSE mount: the server writes artifacts straight to R2 over the S3 API,
|
||||
# selected with ``OMNIGENT_ARTIFACT_URI=s3://<bucket>`` (set by the Worker).
|
||||
#
|
||||
# Requires an omnigent-server image that includes the S3 artifact backend —
|
||||
# i.e. the ``deploy/docker/entrypoint.py`` change that ships alongside this
|
||||
# directory. (Until that lands in the published image, build a server image
|
||||
# from this branch.)
|
||||
FROM ghcr.io/omnigent-ai/omnigent-server:latest
|
||||
|
||||
ARG SITE=/opt/venv/lib/python3.12/site-packages
|
||||
USER root
|
||||
|
||||
# Cloudflare D1 SQLAlchemy dialect + boto3 (for the S3/R2 artifact store).
|
||||
RUN /opt/venv/bin/pip install --no-cache-dir \
|
||||
"sqlalchemy-cloudflare-d1==0.3.10" "boto3>=1.30,<2"
|
||||
|
||||
# Shim: re-register the D1 dialect as a proper SQLite subclass (auto-loaded).
|
||||
COPY sitecustomize.py ${SITE}/sitecustomize.py
|
||||
RUN /opt/venv/bin/python -c "\
|
||||
import sitecustomize; \
|
||||
from sqlalchemy import create_engine; \
|
||||
from sqlalchemy.dialects.sqlite.base import SQLiteDialect; \
|
||||
from alembic.ddl.impl import _impls; \
|
||||
e = create_engine('cloudflare_d1://a:b@c'); \
|
||||
assert isinstance(e.dialect, SQLiteDialect); \
|
||||
assert 'cloudflare_d1' in _impls; \
|
||||
print('D1 dialect + shim OK')"
|
||||
@@ -0,0 +1,170 @@
|
||||
# Omnigent on Cloudflare (Containers + D1 + R2)
|
||||
|
||||
Run the Omnigent server on **Cloudflare Containers**, with **D1** as the
|
||||
database and **R2** as the durable artifact store. This is the serverless,
|
||||
scale-to-zero option: no VM or Postgres to manage, a public `*.workers.dev`
|
||||
URL (or your domain), and the container sleeps when idle.
|
||||
|
||||
> [!NOTE]
|
||||
> This is **not** the same as [`deploy/trycloudflare/`](../trycloudflare/),
|
||||
> which is a quick tunnel that exposes a server running on **your laptop**.
|
||||
> Here the server itself runs **on Cloudflare**.
|
||||
|
||||
> [!NOTE]
|
||||
> This path uses a small SQLAlchemy dialect shim (`sitecustomize.py`) because
|
||||
> Cloudflare D1 isn't yet first-class in Omnigent. It works end to end — it's how
|
||||
> this directory was validated — and the normal on-boot migrations run unmodified.
|
||||
> The R2 artifact store, by contrast, already uses a first-class backend
|
||||
> (`S3ArtifactStore`) added alongside this directory.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
HTTPS / WebSocket
|
||||
browser ───────────────► Worker (src/index.js)
|
||||
│ getContainer("singleton").fetch(req)
|
||||
▼
|
||||
Container ──► the omnigent server (port 8000)
|
||||
(1 instance) │ │
|
||||
DATABASE_URL ───────┘ │ S3 API (boto3)
|
||||
cloudflare_d1://… ▼
|
||||
│ OMNIGENT_ARTIFACT_URI
|
||||
▼ s3://omnigent-artifacts
|
||||
Cloudflare D1 │
|
||||
(SQLite, the DB) ▼
|
||||
Cloudflare R2
|
||||
(artifact store)
|
||||
```
|
||||
|
||||
- **Worker** — a thin front that proxies every request to **one** container
|
||||
instance (Omnigent keeps an in-memory runner registry, so it's single-replica).
|
||||
- **Container** — the official `ghcr.io/omnigent-ai/omnigent-server` image plus
|
||||
the D1 SQLAlchemy dialect, a shim that re-registers it as a proper SQLite
|
||||
dialect, and `boto3` (this directory's `Dockerfile`).
|
||||
- **D1** is the database. The server reaches it through the
|
||||
`sqlalchemy-cloudflare-d1` dialect, which speaks D1's HTTP API — so
|
||||
`DATABASE_URL` is `cloudflare_d1://<account>:<api-token>@<database-id>`.
|
||||
- **R2** is the artifact store. Cloudflare container disk is **ephemeral**, so
|
||||
artifacts (agent bundles, user files) go to R2 over its **S3 API** via
|
||||
Omnigent's native `S3ArtifactStore`, selected with
|
||||
`OMNIGENT_ARTIFACT_URI=s3://<bucket>`. No FUSE mount, no sidecar.
|
||||
|
||||
## What's in here
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `Dockerfile` | derived image: server + D1 dialect + shim + boto3 |
|
||||
| `sitecustomize.py` | shim re-registering `cloudflare_d1` as a SQLite dialect (auto-loaded) |
|
||||
| `src/index.js` | the Worker that proxies to the container |
|
||||
| `wrangler.jsonc` | Worker + Container + Durable Object config |
|
||||
| `package.json` | `wrangler` + `@cloudflare/containers` |
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- A Cloudflare account on the **Workers Paid** plan — Containers require it.
|
||||
- **Docker** running locally (`wrangler deploy` builds the image).
|
||||
- **Node** (for `wrangler`).
|
||||
- `wrangler login` (or a `CLOUDFLARE_API_TOKEN`).
|
||||
|
||||
```bash
|
||||
cd deploy/cloudflare
|
||||
npm install
|
||||
npx wrangler login
|
||||
```
|
||||
|
||||
## Deploy
|
||||
|
||||
### 1. Create the D1 database
|
||||
|
||||
```bash
|
||||
npx wrangler d1 create omnigent
|
||||
# note the "database_id" it prints — call it <DATABASE_ID>
|
||||
```
|
||||
|
||||
### 2. Create the R2 bucket
|
||||
|
||||
```bash
|
||||
npx wrangler r2 bucket create omnigent-artifacts
|
||||
```
|
||||
|
||||
### 3. A D1 API token (for `DATABASE_URL`)
|
||||
|
||||
The dialect authenticates to D1's REST API with a Cloudflare **API token**.
|
||||
Create one at **dash.cloudflare.com → My Profile → API Tokens → Create Token →
|
||||
Custom**, with permission **Account → D1 → Edit**. Your `DATABASE_URL` is then:
|
||||
|
||||
```
|
||||
cloudflare_d1://<ACCOUNT_ID>:<D1_API_TOKEN>@<DATABASE_ID>
|
||||
```
|
||||
|
||||
### 4. R2 S3 credentials (for the artifact store)
|
||||
|
||||
The artifact store uses R2's **S3 API**, which needs an Access Key ID + Secret
|
||||
Access Key. Create them at **dash.cloudflare.com → R2 → Manage R2 API Tokens →
|
||||
Create API Token → Object Read & Write**. It shows an **Access Key ID** and
|
||||
**Secret Access Key** once — save both.
|
||||
|
||||
<details>
|
||||
<summary>Alternative: derive S3 keys from an existing API token</summary>
|
||||
|
||||
Any API token with R2 permissions can be used as S3 credentials without minting
|
||||
a separate R2 token ([docs](https://developers.cloudflare.com/r2/api/tokens/)):
|
||||
**Access Key ID** = the token's *id*, **Secret Access Key** = `sha256(token value)`.
|
||||
|
||||
```bash
|
||||
python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.argv[1].encode()).hexdigest())' "<TOKEN_VALUE>"
|
||||
```
|
||||
</details>
|
||||
|
||||
### 5. Configure and set secrets
|
||||
|
||||
In `wrangler.jsonc`, set `AWS_ENDPOINT_URL_S3` to your account's R2 endpoint
|
||||
(`https://<ACCOUNT_ID>.r2.cloudflarestorage.com`). Then set the four secrets:
|
||||
|
||||
```bash
|
||||
# DATABASE_URL — the cloudflare_d1:// string from step 3
|
||||
npx wrangler secret put DATABASE_URL
|
||||
|
||||
# Session cookie secret — any 64-hex string
|
||||
openssl rand -hex 32 | npx wrangler secret put OMNIGENT_ACCOUNTS_COOKIE_SECRET
|
||||
|
||||
# R2 S3 credentials from step 4
|
||||
npx wrangler secret put AWS_ACCESS_KEY_ID
|
||||
npx wrangler secret put AWS_SECRET_ACCESS_KEY
|
||||
```
|
||||
|
||||
### 6. Deploy
|
||||
|
||||
```bash
|
||||
npx wrangler deploy
|
||||
# -> https://omnigent.<your-subdomain>.workers.dev
|
||||
```
|
||||
|
||||
The container cold-starts on the first request (~10s), then stays warm:
|
||||
|
||||
```bash
|
||||
curl https://omnigent.<your-subdomain>.workers.dev/health # {"status":"ok"}
|
||||
```
|
||||
|
||||
On a brand-new D1, the **first** boot runs all migrations before the server
|
||||
starts listening (~1 minute against D1's REST API), so the first few requests
|
||||
may return a 5xx while it migrates — just retry. Later boots are fast.
|
||||
|
||||
### 7. First admin + connect a host
|
||||
|
||||
Open the URL and the Setup screen claims the first admin (username + password).
|
||||
Then connect a machine to actually run agents (the server is just the control
|
||||
plane):
|
||||
|
||||
```bash
|
||||
omnigent login https://omnigent.<your-subdomain>.workers.dev
|
||||
omnigent host --server https://omnigent.<your-subdomain>.workers.dev
|
||||
```
|
||||
|
||||
## Verifying durability
|
||||
|
||||
The point of R2 is that state survives the ephemeral container. To prove it,
|
||||
note your data, force a fresh container (`npx wrangler deploy` again, or let it
|
||||
idle to sleep), and confirm it's still there — agents still load, sessions still
|
||||
exist. The database lives in D1 and the artifacts in R2; the container holds
|
||||
nothing durable.
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "omnigent-cloudflare",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"deploy": "wrangler deploy",
|
||||
"tail": "wrangler tail"
|
||||
},
|
||||
"devDependencies": {
|
||||
"wrangler": "^4.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@cloudflare/containers": "^0.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Auto-loaded shim that makes Omnigent work against Cloudflare D1.
|
||||
|
||||
D1 is SQLite reached over an HTTP REST API. The third-party
|
||||
``sqlalchemy-cloudflare-d1`` dialect subclasses the *generic* ``DefaultDialect``
|
||||
and then hand-reimplements SQLite's SQL compilation and schema reflection —
|
||||
incompletely. That breaks DDL (a composite primary key emits two ``PRIMARY
|
||||
KEY`` clauses, which D1 rejects; reserved words like ``key`` go unquoted) and
|
||||
migrations (``get_unique_constraints`` is unimplemented; ``get_foreign_keys``
|
||||
drops keys reflection needs).
|
||||
|
||||
The fix is to subclass SQLAlchemy's real ``SQLiteDialect`` and keep only the
|
||||
*transport*: the HTTP DBAPI, the URL parser, and the D1 type processors (which
|
||||
base64-encode blobs and ISO-format dates for the JSON REST API — SQLite's file
|
||||
DBAPI does this natively, D1's API does not). Everything above the transport —
|
||||
DDL compiler, type compiler, identifier quoting, and full reflection — is then
|
||||
inherited from SQLite, correctly. This is the change that belongs upstream in
|
||||
the dialect package (just change its base class); until it ships, sitecustomize
|
||||
re-registers a corrected dialect here. Python imports ``sitecustomize`` at
|
||||
interpreter startup, so it runs before Omnigent builds an engine.
|
||||
|
||||
Two small adaptations remain, both expressing facts about D1 rather than SQLite
|
||||
shortcomings:
|
||||
|
||||
* **Alembic.** Its DDL-impl registry (``alembic.ddl.impl._impls``) is keyed by
|
||||
``dialect.name`` with no inheritance fallback, so the (correctly named)
|
||||
``cloudflare_d1`` dialect ``KeyError``s in ``MigrationContext.__init__``.
|
||||
Register SQLite's impl under that name.
|
||||
* **No ``temp`` schema.** D1 exposes a single ``main`` schema and forbids the
|
||||
``temp`` schema (``SQLITE_AUTH``). SQLite's reflection probes ``temp``
|
||||
(``PRAGMA temp.*``, ``sqlite_temp_master``, ``PRAGMA database_list``); the
|
||||
three touchpoints are overridden to read only ``main``.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
# ── Alembic: register a DDL impl for the cloudflare_d1 name ──────────────
|
||||
try:
|
||||
from alembic.ddl.sqlite import SQLiteImpl
|
||||
|
||||
class CloudflareD1Impl(SQLiteImpl): # auto-registers via __dialect__
|
||||
__dialect__ = "cloudflare_d1"
|
||||
except Exception as exc: # noqa: BLE001 -- defensive: never block server startup
|
||||
print(f"[d1-shim] could not register Alembic impl: {exc}", file=sys.stderr)
|
||||
|
||||
# ── Dialect: re-register cloudflare_d1 as a real SQLite subclass ─────────
|
||||
try:
|
||||
from sqlalchemy import exc as _sa_exc
|
||||
from sqlalchemy.dialects import registry
|
||||
from sqlalchemy.dialects.sqlite.base import SQLiteDialect
|
||||
from sqlalchemy_cloudflare_d1.dialect import CloudflareD1Dialect as _UpstreamD1
|
||||
|
||||
# SQLiteDialect.__init__ reads self.dbapi.sqlite_version_info to gate
|
||||
# features. D1 runs a modern SQLite; advertise it (the live version is also
|
||||
# read in _get_server_version_info below).
|
||||
_D1_SQLITE_VERSION = (3, 45, 0)
|
||||
_dbapi = _UpstreamD1.import_dbapi()
|
||||
if not hasattr(_dbapi, "sqlite_version_info"):
|
||||
_dbapi.sqlite_version_info = _D1_SQLITE_VERSION
|
||||
_dbapi.sqlite_version = ".".join(str(p) for p in _D1_SQLITE_VERSION)
|
||||
|
||||
class CloudflareD1Dialect(SQLiteDialect):
|
||||
"""The cloudflare_d1 dialect: SQLite behavior over D1's HTTP transport."""
|
||||
|
||||
name = "cloudflare_d1"
|
||||
driver = "httpx"
|
||||
default_paramstyle = "qmark"
|
||||
supports_statement_cache = True
|
||||
|
||||
# D1's JSON REST API needs the transport type processors (blob->base64,
|
||||
# date/time->ISO); layer them over SQLite's defaults.
|
||||
colspecs = {**SQLiteDialect.colspecs, **_UpstreamD1.colspecs} # noqa: RUF012
|
||||
|
||||
# ── transport (from the upstream dialect) ──
|
||||
@classmethod
|
||||
def import_dbapi(cls):
|
||||
return _UpstreamD1.import_dbapi()
|
||||
|
||||
create_connect_args = _UpstreamD1.create_connect_args
|
||||
|
||||
# D1 has no isolation levels — keep these no-ops so SQLite's
|
||||
# PRAGMA read_uncommitted machinery never runs over the REST API.
|
||||
def get_isolation_level(self, dbapi_connection): # noqa: ARG002
|
||||
return None
|
||||
|
||||
def set_isolation_level(self, dbapi_connection, level):
|
||||
pass
|
||||
|
||||
def _get_server_version_info(self, connection):
|
||||
try:
|
||||
v = connection.exec_driver_sql("SELECT sqlite_version()").scalar()
|
||||
return tuple(int(x) for x in str(v).split("."))
|
||||
except Exception: # noqa: BLE001
|
||||
return _D1_SQLITE_VERSION
|
||||
|
||||
# ── D1 has a single "main" schema and forbids "temp" ──
|
||||
def get_schema_names(self, connection, **kw): # noqa: ARG002
|
||||
return ["main"]
|
||||
|
||||
def _get_table_sql(self, connection, table_name, schema=None, **kw): # noqa: ARG002
|
||||
schema_expr = f"{self.identifier_preparer.quote_identifier(schema)}." if schema else ""
|
||||
s = (
|
||||
f"SELECT sql FROM {schema_expr}sqlite_master "
|
||||
"WHERE name = ? AND type in ('table', 'view')"
|
||||
)
|
||||
value = connection.exec_driver_sql(s, (table_name,)).scalar()
|
||||
if value is None and not self._is_sys_table(table_name):
|
||||
raise _sa_exc.NoSuchTableError(f"{schema_expr}{table_name}")
|
||||
return value
|
||||
|
||||
def _get_table_pragma(self, connection, pragma, table_name, schema=None):
|
||||
quote = self.identifier_preparer.quote_identifier
|
||||
prefix = f"{quote(schema)}." if schema is not None else "main."
|
||||
cursor = connection.exec_driver_sql(f"PRAGMA {prefix}{pragma}({quote(table_name)})")
|
||||
return cursor.fetchall() if not cursor._soft_closed else []
|
||||
|
||||
registry.register("cloudflare_d1", __name__, "CloudflareD1Dialect")
|
||||
except Exception as exc: # noqa: BLE001 -- defensive: never block server startup
|
||||
print(f"[d1-shim] could not register D1 dialect: {exc}", file=sys.stderr)
|
||||
@@ -0,0 +1,41 @@
|
||||
// Worker that fronts the Omnigent container and proxies all HTTP (and
|
||||
// WebSocket) traffic to it. Omnigent needs a SINGLE server instance (in-memory
|
||||
// runner registry), so every request routes to one fixed container instance.
|
||||
import { Container, getContainer } from "@cloudflare/containers";
|
||||
|
||||
export class OmnigentServer extends Container {
|
||||
// Port the omnigent server listens on inside the container.
|
||||
defaultPort = 8000;
|
||||
// Keep the container warm so D1-backed sessions don't cold-start constantly.
|
||||
sleepAfter = "30m";
|
||||
|
||||
constructor(ctx, env) {
|
||||
super(ctx, env);
|
||||
// Env passed into the container. Secrets (DATABASE_URL, the cookie secret,
|
||||
// the AWS_* R2 keys) come from `wrangler secret put`; the rest are plain
|
||||
// vars in wrangler.jsonc.
|
||||
this.envVars = {
|
||||
DATABASE_URL: env.DATABASE_URL,
|
||||
OMNIGENT_ACCOUNTS_COOKIE_SECRET: env.OMNIGENT_ACCOUNTS_COOKIE_SECRET,
|
||||
OMNIGENT_AUTH_ENABLED: "1",
|
||||
OMNIGENT_AUTH_PROVIDER: "accounts",
|
||||
OMNIGENT_ACCOUNTS_AUTO_OPEN: "0",
|
||||
HOST: "0.0.0.0",
|
||||
PORT: "8000",
|
||||
// Artifact store -> R2 over the S3 API (omnigent's native S3 backend).
|
||||
// OMNIGENT_ARTIFACT_URI selects it; AWS_* point boto3 at R2.
|
||||
OMNIGENT_ARTIFACT_URI: env.OMNIGENT_ARTIFACT_URI,
|
||||
AWS_ENDPOINT_URL_S3: env.AWS_ENDPOINT_URL_S3,
|
||||
AWS_DEFAULT_REGION: "auto",
|
||||
AWS_ACCESS_KEY_ID: env.AWS_ACCESS_KEY_ID,
|
||||
AWS_SECRET_ACCESS_KEY: env.AWS_SECRET_ACCESS_KEY,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env) {
|
||||
// One shared instance for the whole app (single-replica requirement).
|
||||
return await getContainer(env.OMNIGENT, "singleton").fetch(request);
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
// Omnigent server on Cloudflare Containers, backed by D1 (database) and
|
||||
// R2 (artifact store, via omnigent's native S3 backend over the R2 S3 API).
|
||||
// Full walkthrough: deploy/cloudflare/README.md.
|
||||
"name": "omnigent",
|
||||
"main": "src/index.js",
|
||||
"compatibility_date": "2026-06-01",
|
||||
|
||||
// Built from ./Dockerfile (omnigent-server + D1 dialect + boto3). Single
|
||||
// replica only (in-memory runner registry) — do NOT raise max_instances.
|
||||
// "basic" = 0.25 vCPU / 1 GiB; bump to "standard" for faster cold starts.
|
||||
"containers": [
|
||||
{
|
||||
"class_name": "OmnigentServer",
|
||||
"image": "./Dockerfile",
|
||||
"instance_type": "basic",
|
||||
"max_instances": 1
|
||||
}
|
||||
],
|
||||
|
||||
"durable_objects": {
|
||||
"bindings": [{ "class_name": "OmnigentServer", "name": "OMNIGENT" }]
|
||||
},
|
||||
"migrations": [
|
||||
{ "tag": "v1", "new_sqlite_classes": ["OmnigentServer"] }
|
||||
],
|
||||
|
||||
// Non-secret config. Replace <YOUR_CLOUDFLARE_ACCOUNT_ID> with your account ID
|
||||
// (it's in the R2 S3 endpoint). DATABASE_URL, OMNIGENT_ACCOUNTS_COOKIE_SECRET,
|
||||
// AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY are SECRETS — set them with
|
||||
// `wrangler secret put` (see README).
|
||||
"vars": {
|
||||
"OMNIGENT_ARTIFACT_URI": "s3://omnigent-artifacts",
|
||||
"AWS_ENDPOINT_URL_S3": "https://<YOUR_CLOUDFLARE_ACCOUNT_ID>.r2.cloudflarestorage.com"
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,8 @@ from typing import TYPE_CHECKING, Any
|
||||
if TYPE_CHECKING:
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent.stores.artifact_store import ArtifactStore
|
||||
|
||||
logging.basicConfig(level=logging.INFO, stream=sys.stderr, force=True)
|
||||
logger = logging.getLogger("omnigent-docker")
|
||||
|
||||
@@ -66,6 +68,10 @@ class _ResolvedConfig:
|
||||
cfg: dict[str, Any]
|
||||
database_url: str
|
||||
artifact_dir: Path
|
||||
# When set (an ``s3://bucket[/prefix]`` URI), the artifact store is remote
|
||||
# (S3/R2) and ``artifact_dir`` is only a local scratch dir (cookie secret,
|
||||
# on-disk cache). ``None`` -> the local filesystem store at ``artifact_dir``.
|
||||
artifact_store_uri: str | None
|
||||
host: str
|
||||
port: int
|
||||
|
||||
@@ -146,12 +152,22 @@ def _resolve_config() -> _ResolvedConfig:
|
||||
port = int(cfg.get("port") or os.environ.get("PORT") or _DEFAULT_PORT)
|
||||
artifact_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Optional remote artifact store (S3 / Cloudflare R2 / MinIO / …). When set,
|
||||
# the artifact STORE is remote and durable; artifact_dir stays local for the
|
||||
# cookie secret and on-disk cache. Mirrors how DATABASE_URL selects the DB.
|
||||
artifact_store_uri = cfg.get("artifact_store_uri") or os.environ.get("OMNIGENT_ARTIFACT_URI")
|
||||
if artifact_store_uri and not artifact_store_uri.startswith("s3://"):
|
||||
raise RuntimeError(
|
||||
"OMNIGENT_ARTIFACT_URI (or `artifact_store_uri:` in config) must be an "
|
||||
f"'s3://bucket[/prefix]' URI, got: {artifact_store_uri!r}"
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Config: HOST=%s PORT=%d DB=%s ARTIFACTS=%s",
|
||||
host,
|
||||
port,
|
||||
database_url.split("@", 1)[-1] if "@" in database_url else database_url,
|
||||
artifact_dir,
|
||||
artifact_store_uri or artifact_dir,
|
||||
)
|
||||
|
||||
# Containerized / remote deploys default to authenticated auth.
|
||||
@@ -209,11 +225,35 @@ def _resolve_config() -> _ResolvedConfig:
|
||||
cfg=cfg,
|
||||
database_url=database_url,
|
||||
artifact_dir=artifact_dir,
|
||||
artifact_store_uri=artifact_store_uri,
|
||||
host=host,
|
||||
port=port,
|
||||
)
|
||||
|
||||
|
||||
def _select_artifact_store(resolved_config: _ResolvedConfig) -> ArtifactStore:
|
||||
"""
|
||||
Pick the artifact store implementation from the resolved config.
|
||||
|
||||
An ``s3://bucket[/prefix]`` ``artifact_store_uri`` selects the remote,
|
||||
durable :class:`~omnigent.stores.artifact_store.s3.S3ArtifactStore` (AWS S3,
|
||||
Cloudflare R2, MinIO, …), which survives an ephemeral or multi-replica
|
||||
deploy. Otherwise the local-filesystem store at ``artifact_dir`` is used.
|
||||
Mirrors how ``DATABASE_URL`` selects the database backend.
|
||||
|
||||
:param resolved_config: The resolved startup configuration.
|
||||
:returns: The selected
|
||||
:class:`~omnigent.stores.artifact_store.ArtifactStore`.
|
||||
"""
|
||||
from omnigent.stores.artifact_store.local import LocalArtifactStore
|
||||
|
||||
if resolved_config.artifact_store_uri:
|
||||
from omnigent.stores.artifact_store.s3 import S3ArtifactStore
|
||||
|
||||
return S3ArtifactStore(resolved_config.artifact_store_uri)
|
||||
return LocalArtifactStore(str(resolved_config.artifact_dir))
|
||||
|
||||
|
||||
def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
|
||||
"""Resolve config if needed, wire the stores, and build the app.
|
||||
|
||||
@@ -238,7 +278,6 @@ def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
|
||||
from omnigent.runtime.caps import RuntimeCaps
|
||||
from omnigent.server.managed_hosts import parse_sandbox_config
|
||||
from omnigent.stores.agent_store.sqlalchemy_store import SqlAlchemyAgentStore
|
||||
from omnigent.stores.artifact_store.local import LocalArtifactStore
|
||||
from omnigent.stores.comment_store.sqlalchemy_store import (
|
||||
SqlAlchemyCommentStore,
|
||||
)
|
||||
@@ -263,7 +302,7 @@ def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
|
||||
# typo should not surface as a runtime 502 on the first managed
|
||||
# session); the startup catch-all below logs it.
|
||||
sandbox_config = parse_sandbox_config(cfg.get("sandbox"))
|
||||
artifact_store = LocalArtifactStore(str(artifact_dir))
|
||||
artifact_store = _select_artifact_store(resolved_config)
|
||||
|
||||
agent_cache = AgentCache(
|
||||
artifact_store=artifact_store,
|
||||
|
||||
@@ -55,3 +55,29 @@ def echo_native_resume_hint(
|
||||
server=server,
|
||||
)
|
||||
click.echo(f"Resume with: {command}", err=True)
|
||||
|
||||
|
||||
def echo_native_cold_resume_hint(
|
||||
*,
|
||||
agent_label: str = "Cursor",
|
||||
) -> None:
|
||||
"""
|
||||
Warn that a cold resume is starting a fresh session, not restoring one.
|
||||
|
||||
Some native wrappers (notably Cursor) cannot reattach to a prior
|
||||
chat once the session terminal has exited: the TUI records no
|
||||
resumable chat id, so ``--resume`` relaunches a *fresh* agent with
|
||||
none of the prior turns. The reattach-to-a-live-terminal path is
|
||||
unaffected; this hint only fires when the terminal is gone and we
|
||||
are cold-starting a new TUI, so the user is not misled into
|
||||
thinking their earlier conversation came back.
|
||||
|
||||
:param agent_label: Human-readable agent name for the message,
|
||||
e.g. ``"Cursor"``.
|
||||
:returns: None.
|
||||
"""
|
||||
click.echo(
|
||||
f"Terminal not running - starting a fresh {agent_label} session "
|
||||
"(prior chat not restored).",
|
||||
err=True,
|
||||
)
|
||||
|
||||
+62
-5
@@ -2248,12 +2248,69 @@ async def _query_sessions_once(
|
||||
if reconciled is not None:
|
||||
return reconciled
|
||||
raise
|
||||
all_text_parts: list[str] = []
|
||||
if result.text:
|
||||
return result.text
|
||||
reconciled = await _persisted_turn_text(client, bound.id)
|
||||
if reconciled is not None:
|
||||
return reconciled
|
||||
# No assistant text for this turn. If the runner persisted a terminal
|
||||
all_text_parts.append(result.text)
|
||||
elif (reconciled := await _persisted_turn_text(client, bound.id)) is not None:
|
||||
all_text_parts.append(reconciled)
|
||||
|
||||
# Multi-turn loop for async orchestrators (e.g. polly) that dispatch
|
||||
# sub-agents and are auto-woken by inbox completions across multiple
|
||||
# turns.
|
||||
#
|
||||
# Fast-exit: refresh() at the TOP of each iteration catches the common
|
||||
# case (single-turn agent, session already idle) with one HTTP round-
|
||||
# trip (~100 ms) instead of waiting up to _PER_TURN_TIMEOUT_S for a
|
||||
# stream subscription to time out.
|
||||
#
|
||||
# Race window: a turn MAY complete in the gap between the top-of-loop
|
||||
# refresh() showing "waiting" and await_turn() opening its subscription.
|
||||
# The window is O(ms) in practice (subagents take seconds). If it fires,
|
||||
# await_turn() times out, the bottom refresh() shows "idle", and we exit
|
||||
# — the only cost is one _PER_TURN_TIMEOUT_S wait and possibly missing
|
||||
# that turn's text.
|
||||
#
|
||||
# Timeouts: 120 s per turn bounds the race-window penalty. A global
|
||||
# 1800 s wall-clock budget caps the loop regardless of turn count.
|
||||
_MAX_EXTRA_TURNS = 30
|
||||
_PER_TURN_TIMEOUT_S = 120.0
|
||||
_LOOP_TIMEOUT_S = 1800.0
|
||||
|
||||
async def _drain_extra_turns() -> None:
|
||||
for _ in range(_MAX_EXTRA_TURNS):
|
||||
# Fast-exit for single-turn agents.
|
||||
await chat.refresh()
|
||||
if chat.status not in ("waiting", "running", "launching"):
|
||||
return
|
||||
# Session still active; subscribe before the next check to
|
||||
# reduce (not eliminate) the race where a turn completes
|
||||
# between refresh and subscribe.
|
||||
extra = await chat.await_turn(timeout=_PER_TURN_TIMEOUT_S)
|
||||
if extra.text:
|
||||
all_text_parts.append(extra.text)
|
||||
await chat.refresh()
|
||||
if chat.status not in ("waiting", "running", "launching"):
|
||||
return
|
||||
logger.warning(
|
||||
"headless -p hit the %d-turn guard for session %s; "
|
||||
"the orchestrator may still be running",
|
||||
_MAX_EXTRA_TURNS,
|
||||
bound.id,
|
||||
)
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(_LOOP_TIMEOUT_S):
|
||||
await _drain_extra_turns()
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning(
|
||||
"headless -p timed out after %.0fs waiting for session %s to complete",
|
||||
_LOOP_TIMEOUT_S,
|
||||
bound.id,
|
||||
)
|
||||
|
||||
if all_text_parts:
|
||||
return "\n\n".join(p for p in all_text_parts if p)
|
||||
# No assistant text at all. If the runner persisted a terminal
|
||||
# ``error`` item (e.g. a harness start failure like the cursor SDK's
|
||||
# invalid-model rejection), surface it instead of returning ``None`` —
|
||||
# otherwise the headless caller renders a failed turn as a silent,
|
||||
|
||||
@@ -185,8 +185,8 @@ def _trusted_parent_for_bridge_dir(target: Path) -> Path:
|
||||
Return the trusted parent for an allowed bridge directory.
|
||||
|
||||
Claude-native files live below the uid-scoped temp bridge root.
|
||||
Codex-native reuses the relay/MCP implementation but keeps bridge
|
||||
files below ``~/.omnigent/codex-native``. Both roots use the same
|
||||
Codex- and Cursor-native reuse the relay/MCP implementation but keep bridge
|
||||
files below their own bridge roots. All roots use the same
|
||||
owner-only ancestor validation; only the trusted anchor differs.
|
||||
|
||||
:param target: Normalized bridge directory path being created or validated,
|
||||
@@ -211,9 +211,15 @@ def _trusted_parent_for_bridge_dir(target: Path) -> Path:
|
||||
trusted_parent = codex_root.parent.parent
|
||||
return _absolute_syntactic_path(trusted_parent)
|
||||
|
||||
from omnigent.cursor_native_bridge import bridge_root as cursor_bridge_root
|
||||
|
||||
cursor_root = _absolute_syntactic_path(cursor_bridge_root())
|
||||
if target.is_relative_to(cursor_root):
|
||||
return _absolute_syntactic_path(cursor_root.parent.parent)
|
||||
|
||||
raise RuntimeError(
|
||||
f"bridge dir {target!s} is not under an allowed bridge root "
|
||||
f"({claude_root!s}, {codex_root!s})"
|
||||
f"({claude_root!s}, {codex_root!s}, {cursor_root!s})"
|
||||
)
|
||||
|
||||
|
||||
@@ -3314,8 +3320,29 @@ def _stdio_jsonrpc_loop(
|
||||
active tool relay.
|
||||
:returns: None when stdin reaches EOF.
|
||||
"""
|
||||
for raw_line in sys.stdin:
|
||||
line = raw_line.strip()
|
||||
use_content_length = False
|
||||
while True:
|
||||
raw_line = sys.stdin.buffer.readline()
|
||||
if raw_line == b"":
|
||||
return
|
||||
if raw_line.lower().startswith(b"content-length:"):
|
||||
use_content_length = True
|
||||
try:
|
||||
length = int(raw_line.decode("ascii", errors="ignore").split(":", 1)[1].strip())
|
||||
except ValueError:
|
||||
continue
|
||||
while True:
|
||||
header = sys.stdin.buffer.readline()
|
||||
if header in {b"\r\n", b"\n", b""}:
|
||||
break
|
||||
if length <= 0:
|
||||
continue
|
||||
raw_payload = sys.stdin.buffer.read(length)
|
||||
if len(raw_payload) != length:
|
||||
return
|
||||
line = raw_payload.decode("utf-8", errors="replace").strip()
|
||||
else:
|
||||
line = raw_line.decode("utf-8", errors="replace").strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
@@ -3350,7 +3377,7 @@ def _stdio_jsonrpc_loop(
|
||||
# -32603 is the JSON-RPC 2.0 "Internal error" code.
|
||||
"error": {"code": -32603, "message": f"internal error: {exc}"},
|
||||
}
|
||||
_write_jsonrpc(response, stdout_lock)
|
||||
_write_jsonrpc(response, stdout_lock, framed=use_content_length)
|
||||
|
||||
|
||||
def _handle_mcp_request(
|
||||
@@ -3651,17 +3678,29 @@ def _build_tools(config: dict[str, Any]) -> tuple[dict[str, Tool], Callable[[],
|
||||
return tools, _close_tools
|
||||
|
||||
|
||||
def _write_jsonrpc(payload: dict[str, Any], stdout_lock: threading.Lock) -> None:
|
||||
def _write_jsonrpc(
|
||||
payload: dict[str, Any],
|
||||
stdout_lock: threading.Lock,
|
||||
*,
|
||||
framed: bool = False,
|
||||
) -> None:
|
||||
"""
|
||||
Write one JSON-RPC message to stdout.
|
||||
|
||||
:param payload: JSON-RPC object to serialize.
|
||||
:param stdout_lock: Lock protecting stdout.
|
||||
:param framed: When ``True``, write MCP ``Content-Length`` framed output.
|
||||
:returns: None.
|
||||
"""
|
||||
raw = json.dumps(payload, separators=(",", ":"))
|
||||
with stdout_lock:
|
||||
print(raw, flush=True)
|
||||
if framed:
|
||||
encoded = raw.encode("utf-8")
|
||||
sys.stdout.buffer.write(f"Content-Length: {len(encoded)}\r\n\r\n".encode("ascii"))
|
||||
sys.stdout.buffer.write(encoded)
|
||||
sys.stdout.buffer.flush()
|
||||
else:
|
||||
print(raw, flush=True)
|
||||
|
||||
|
||||
def _model_from_transcript_entry(entry: dict[str, Any]) -> str | None:
|
||||
|
||||
@@ -10,6 +10,7 @@ import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -1631,7 +1632,7 @@ async def _forward_session_cost(
|
||||
# lower transcript read (e.g. just after a rotation) and suppresses
|
||||
# steady-state churn. The two fields advance independently (policy_cost
|
||||
# moves mid-turn while display_cost/S is frozen).
|
||||
payload: dict[str, float] = {}
|
||||
payload: dict[str, float | str] = {}
|
||||
if display_cost is not None and (
|
||||
dedupe.posted_cost is None or display_cost > dedupe.posted_cost
|
||||
):
|
||||
@@ -1642,6 +1643,17 @@ async def _forward_session_cost(
|
||||
payload["policy_cost_usd"] = policy_cost
|
||||
if not payload:
|
||||
return
|
||||
# Tag a display-cost (S) advance with the active model captured by the
|
||||
# statusLine wrapper (``{"model": "claude-opus-4-8", ...}`` in context.json).
|
||||
# claude-native sends no token counts with its cost, so the server has
|
||||
# nothing to attribute the cost to per-model without this — leaving it out
|
||||
# of the TOKEN USAGE breakdown while the session total still counts it. Sent
|
||||
# only when the display cost moves: that is the value being attributed
|
||||
# (``policy_cost_usd``-only mid-turn posts carry no new display cost).
|
||||
if "cumulative_cost_usd" in payload and isinstance(status_state, dict):
|
||||
model = status_state.get("model")
|
||||
if isinstance(model, str) and model:
|
||||
payload["model"] = model
|
||||
try:
|
||||
await _post_external_session_usage(
|
||||
client,
|
||||
@@ -3159,7 +3171,7 @@ async def _post_external_session_usage(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
usage: dict[str, float] | None,
|
||||
usage: Mapping[str, float | str] | None,
|
||||
context_window: int | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
@@ -3170,7 +3182,9 @@ async def _post_external_session_usage(
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id.
|
||||
:param usage: ``message.usage`` snapshot, or ``None`` to skip.
|
||||
:param usage: ``message.usage`` snapshot, or ``None`` to skip. Values are
|
||||
numeric counters/costs, plus an optional ``model`` string tagging the
|
||||
cost with the active model for per-model attribution.
|
||||
:param context_window: Resolved window in tokens, or ``None`` to
|
||||
leave the server's persisted value untouched.
|
||||
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
||||
|
||||
+185
-21
@@ -57,6 +57,7 @@ if TYPE_CHECKING:
|
||||
from omnigent._runner_startup import RunnerStartupProgress
|
||||
from omnigent.onboarding.ambient import DetectedProvider
|
||||
from omnigent.onboarding.provider_config import ProviderEntry
|
||||
from omnigent.update_check import _InstalledWheelInfo
|
||||
|
||||
|
||||
# Any: YAML configs have heterogeneous value types (str, int, list, etc.)
|
||||
@@ -2522,6 +2523,7 @@ def _start_cli_runner_process(
|
||||
log_dir: str | Path | None = None,
|
||||
prewarm_spec_path: str | Path | None = None,
|
||||
isolate_session: bool = False,
|
||||
extra_env: dict[str, str] | None = None,
|
||||
) -> _CliRunnerProcess:
|
||||
"""Start the out-of-process runner used by CLI server flows.
|
||||
|
||||
@@ -2566,6 +2568,10 @@ def _start_cli_runner_process(
|
||||
enables per-session workspace isolation so each
|
||||
session gets its own subdirectory. ``False`` (default)
|
||||
lets the agent see the project root directly.
|
||||
:param extra_env: Optional mapping of additional environment
|
||||
variables overlaid on top of ``os.environ`` for the runner
|
||||
subprocess. Used by tests to route the runner at a mock LLM
|
||||
server instead of the ambient API endpoint.
|
||||
:returns: The spawned runner process metadata.
|
||||
:raises click.ClickException: If the runner exits immediately.
|
||||
"""
|
||||
@@ -2594,6 +2600,7 @@ def _start_cli_runner_process(
|
||||
resolved_runner_id = token_bound_runner_id(binding_token)
|
||||
env = {
|
||||
**os.environ,
|
||||
**(extra_env or {}),
|
||||
"RUNNER_SERVER_URL": server_url,
|
||||
RUNNER_ID_ENV_VAR: resolved_runner_id,
|
||||
RUNNER_PARENT_PID_ENV_VAR: str(os.getpid()),
|
||||
@@ -3407,6 +3414,130 @@ def _wait_for_local_sessions_to_drain() -> None:
|
||||
last = count
|
||||
|
||||
|
||||
def _drain_and_stop_local_server(*, force: bool) -> None:
|
||||
"""Drain (or force-stop) the local server + daemon before an upgrade.
|
||||
|
||||
Shared by both ``omni upgrade`` paths (registry and git): the running
|
||||
process must stop serving BEFORE its code is swapped, so it never serves
|
||||
half-upgraded modules. The next ``omni`` invocation respawns a fresh
|
||||
server on the new version.
|
||||
|
||||
:param force: When ``False``, wait for in-flight sessions to drain first;
|
||||
when ``True``, stop them immediately.
|
||||
"""
|
||||
if not force:
|
||||
_wait_for_local_sessions_to_drain()
|
||||
if _stop_local_server_and_daemon(force=force):
|
||||
click.echo("Stopped the background server before upgrading.")
|
||||
|
||||
|
||||
def _upgrade_vcs_install(
|
||||
info: _InstalledWheelInfo, *, check_only: bool, force: bool, pre: bool
|
||||
) -> None:
|
||||
"""Update a git/VCS ``omni`` install by re-pulling its tracked ref.
|
||||
|
||||
A git install's version string is frozen at whatever its source branch
|
||||
declares (e.g. ``0.1.0`` on an unbumped ``main``), so it cannot be
|
||||
compared against PyPI — that comparison reports a build *ahead* of the
|
||||
latest release as "behind" and never converges, because reinstalling the
|
||||
ref can't change the version string. Instead, compare the installed commit
|
||||
against the remote ref's HEAD, and after re-pulling verify the commit
|
||||
actually moved rather than asserting a PyPI version the ref can't produce.
|
||||
|
||||
:param info: Installed-distribution metadata, with ``info.vcs_url`` set.
|
||||
:param check_only: Report status only; exit non-zero only when we can
|
||||
positively confirm the install is behind its tracked ref.
|
||||
:param force: Stop in-flight sessions immediately instead of draining.
|
||||
:param pre: Pass the installer's allow-pre-releases flag (no-op for git).
|
||||
"""
|
||||
from omnigent.update_check import (
|
||||
_build_upgrade_suggestion,
|
||||
_probe_installed_distribution,
|
||||
_remote_git_head,
|
||||
_run_upgrade_command,
|
||||
)
|
||||
|
||||
current_sha = info.commit_sha or ""
|
||||
cur_short = current_sha[:9] if current_sha else "unknown"
|
||||
remote_sha = _remote_git_head(info.vcs_url) if info.vcs_url else None
|
||||
remote_short = remote_sha[:9] if remote_sha else ""
|
||||
known_behind = bool(remote_sha and current_sha and remote_sha != current_sha)
|
||||
|
||||
if remote_sha and current_sha and remote_sha == current_sha:
|
||||
click.echo(f"omnigent is up to date (git {cur_short}, tracking {info.vcs_url}).")
|
||||
return
|
||||
if known_behind:
|
||||
click.echo(
|
||||
f"A newer commit is available: {cur_short} → {remote_short} "
|
||||
f"(git install tracking {info.vcs_url})."
|
||||
)
|
||||
else:
|
||||
click.echo(
|
||||
f"This is a git install ({info.vcs_url} @ {cur_short}). The latest "
|
||||
"commit couldn't be determined; re-pulling the tracked ref."
|
||||
)
|
||||
|
||||
if check_only:
|
||||
# Exit non-zero only when we KNOW it's behind, so `--check` stays a
|
||||
# reliable CI gate; an indeterminate remote is not a failure. SystemExit
|
||||
# (not ctx.exit) for the same reason as the PyPI path — main() runs the
|
||||
# group with standalone_mode=False, where ctx.exit's code is dropped.
|
||||
if known_behind:
|
||||
raise SystemExit(1)
|
||||
return
|
||||
|
||||
if pre:
|
||||
# ``--pre`` only steers a PyPI resolve; a git install gets exactly the
|
||||
# commit its ref points at, so say so rather than implying it had effect.
|
||||
click.echo(
|
||||
"Note: --pre has no effect on a git install; the tracked ref decides the commit."
|
||||
)
|
||||
|
||||
suggestion = _build_upgrade_suggestion(info, allow_prerelease=pre)
|
||||
if not suggestion.runnable:
|
||||
raise click.ClickException(
|
||||
f"No automatic upgrade command is known for this install. {suggestion.command}."
|
||||
)
|
||||
|
||||
_drain_and_stop_local_server(force=force)
|
||||
|
||||
console = Console()
|
||||
code = _run_upgrade_command(suggestion.command, console)
|
||||
if code != 0:
|
||||
raise click.ClickException(
|
||||
f"Upgrade command exited with status {code}; your previous install is intact."
|
||||
)
|
||||
|
||||
# Verify by commit, not exit code: a re-pull of a ref that hasn't moved (or
|
||||
# a pinned ref, or a cached reinstall) exits 0 without changing anything.
|
||||
_, new_sha = _probe_installed_distribution()
|
||||
if new_sha and current_sha and new_sha != current_sha:
|
||||
click.echo(
|
||||
f"✓ Updated to git {new_sha[:9]}. Re-run your command — the local "
|
||||
"server will start on the new version."
|
||||
)
|
||||
return
|
||||
if known_behind and new_sha and new_sha == current_sha:
|
||||
# We positively confirmed the ref had advanced, yet the re-pull left the
|
||||
# install on the same commit — a silent no-op that would otherwise
|
||||
# recreate the "still behind" loop. Fail loudly, mirroring the PyPI guard.
|
||||
raise click.ClickException(
|
||||
f"The re-pull ran but the install is still at {cur_short} (the ref is at "
|
||||
f"{remote_short}). The ref may be pinned or the reinstall reused a cached "
|
||||
f"commit; try `uv tool install --reinstall {info.vcs_url}`."
|
||||
)
|
||||
if new_sha and current_sha and new_sha == current_sha:
|
||||
# Remote was indeterminate, so we never claimed it was behind — a
|
||||
# no-change re-pull is fine here.
|
||||
click.echo(
|
||||
f"Already on the latest commit of the tracked ref ({cur_short}); nothing changed."
|
||||
)
|
||||
return
|
||||
# Couldn't read the new commit — the re-pull ran, but don't assert a
|
||||
# result we can't confirm.
|
||||
click.echo("Re-pulled the git ref. Run `omni upgrade --check` to confirm.")
|
||||
|
||||
|
||||
@cli.command("upgrade")
|
||||
@click.option(
|
||||
"--check",
|
||||
@@ -3451,11 +3582,12 @@ def upgrade(check_only: bool, force: bool, pre: bool) -> None:
|
||||
"""
|
||||
import importlib.metadata
|
||||
|
||||
from packaging.version import InvalidVersion, parse
|
||||
|
||||
from omnigent.update_check import (
|
||||
_UPGRADE_INDEX_TIMEOUT_SECONDS,
|
||||
_build_upgrade_suggestion,
|
||||
_find_repo_root,
|
||||
_is_newer,
|
||||
_probe_installed_distribution,
|
||||
_read_installed_wheel_info,
|
||||
_run_upgrade_command,
|
||||
fetch_latest_version,
|
||||
@@ -3478,19 +3610,29 @@ def upgrade(check_only: bool, force: bool, pre: bool) -> None:
|
||||
"This is an editable install — update it with `git pull`, not `omni upgrade`."
|
||||
)
|
||||
|
||||
# A git/VCS install tracks a moving git ref, not a PyPI release. Its
|
||||
# version string (a frozen ``0.1.0`` on an unbumped ``main``, say) is NOT
|
||||
# comparable to the latest PyPI release: comparing them reports a build
|
||||
# that is *ahead* of the release as "behind" and loops forever, because
|
||||
# reinstalling the ref can never change that version string. For these
|
||||
# installs "upgrade" means re-pulling the ref — compared and verified by
|
||||
# commit, not by PyPI version.
|
||||
if info.vcs_url:
|
||||
_upgrade_vcs_install(info, check_only=check_only, force=force, pre=pre)
|
||||
return
|
||||
|
||||
current = importlib.metadata.version("omnigent")
|
||||
latest = fetch_latest_version(include_prereleases=pre)
|
||||
# User-initiated: a more forgiving timeout + one retry so a momentarily slow
|
||||
# mirror doesn't spuriously report the index as unreachable.
|
||||
latest = fetch_latest_version(
|
||||
include_prereleases=pre, timeout=_UPGRADE_INDEX_TIMEOUT_SECONDS, attempts=2
|
||||
)
|
||||
if latest is None:
|
||||
raise click.ClickException(
|
||||
"Couldn't reach the package index to check for a newer release. Check your "
|
||||
"connection (or OMNIGENT_INDEX_URL / your configured index) and try again."
|
||||
)
|
||||
try:
|
||||
is_behind = parse(latest) > parse(current)
|
||||
except InvalidVersion:
|
||||
is_behind = latest != current
|
||||
|
||||
if not is_behind:
|
||||
if not _is_newer(latest, current):
|
||||
click.echo(f"omnigent is up to date (v{current}).")
|
||||
return
|
||||
|
||||
@@ -3508,13 +3650,7 @@ def upgrade(check_only: bool, force: bool, pre: bool) -> None:
|
||||
f"No automatic upgrade command is known for this install. {suggestion.command}."
|
||||
)
|
||||
|
||||
# Drain (or force-stop) the local server + daemon BEFORE swapping the
|
||||
# code, so the running process never serves half-upgraded modules.
|
||||
# The next command respawns a fresh server on the new version.
|
||||
if not force:
|
||||
_wait_for_local_sessions_to_drain()
|
||||
if _stop_local_server_and_daemon(force=force):
|
||||
click.echo("Stopped the background server before upgrading.")
|
||||
_drain_and_stop_local_server(force=force)
|
||||
|
||||
console = Console()
|
||||
code = _run_upgrade_command(suggestion.command, console)
|
||||
@@ -3522,9 +3658,32 @@ def upgrade(check_only: bool, force: bool, pre: bool) -> None:
|
||||
raise click.ClickException(
|
||||
f"Upgrade command exited with status {code}; your previous install is intact."
|
||||
)
|
||||
click.echo(
|
||||
f"✓ Upgraded to v{latest}. Re-run your command — the local server will "
|
||||
"start on the new version."
|
||||
|
||||
# Trust the installed version, not the installer's exit code. The running
|
||||
# process still has the OLD version loaded, so re-read it in a fresh
|
||||
# subprocess. A no-op upgrade (version-pinned spec, a cooldown /
|
||||
# exclude-newer that excludes the new release, or a stale index cache)
|
||||
# exits 0 without moving — claiming "✓ Upgraded" there is exactly the
|
||||
# "I upgraded but it still says an update is available" bug.
|
||||
new_version, _ = _probe_installed_distribution()
|
||||
if new_version is None:
|
||||
click.echo(
|
||||
"Ran the upgrade command, but couldn't confirm the installed version. "
|
||||
"Run `omni upgrade --check` to verify."
|
||||
)
|
||||
return
|
||||
if _is_newer(new_version, current):
|
||||
click.echo(
|
||||
f"✓ Upgraded to v{new_version}. Re-run your command — the local "
|
||||
"server will start on the new version."
|
||||
)
|
||||
return
|
||||
raise click.ClickException(
|
||||
f"The upgrade command ran but omnigent is still v{new_version} (expected "
|
||||
f"v{latest}). The install is likely version-pinned, a cooldown / "
|
||||
"exclude-newer is excluding the new release, or the index cache is stale. "
|
||||
"Reinstall it explicitly — e.g. `uv tool upgrade --reinstall omnigent` or "
|
||||
f"`pip install --force-reinstall 'omnigent=={latest}'`."
|
||||
)
|
||||
|
||||
|
||||
@@ -4434,7 +4593,7 @@ def resume(
|
||||
_HARNESS_CHOICES_HELP = (
|
||||
"'claude' (alias for 'claude-sdk'), 'claude-sdk', 'codex', "
|
||||
"'cursor', "
|
||||
"'openai-agents', 'open-responses', or 'pi'"
|
||||
"'openai-agents', 'open-responses', 'pi', or 'antigravity'"
|
||||
)
|
||||
_HARNESS_HELP = f"Harness to use for a local agent: {_HARNESS_CHOICES_HELP}."
|
||||
_RUN_HARNESS_HELP = (
|
||||
@@ -8200,7 +8359,12 @@ def _set_cursor_api_key() -> str | None:
|
||||
)
|
||||
from omnigent.onboarding.interactive import prompt_text
|
||||
|
||||
detected = os.environ.get("CURSOR_API_KEY")
|
||||
# Strip surrounding whitespace before validating/forwarding so a key
|
||||
# exported with a trailing newline (a common ``export $(…)`` mishap)
|
||||
# validates and resolves cleanly — matching the pasted-key branch's
|
||||
# ``.strip()`` below and the strip in ``resolve_secret``'s ``env:`` branch.
|
||||
raw_detected = os.environ.get("CURSOR_API_KEY")
|
||||
detected = raw_detected.strip() if raw_detected else None
|
||||
if detected and click.confirm(
|
||||
"Detected CURSOR_API_KEY in the environment — use it?", default=True
|
||||
):
|
||||
|
||||
@@ -28,7 +28,7 @@ import click
|
||||
import httpx
|
||||
import yaml
|
||||
|
||||
from omnigent._native_resume_hint import echo_native_resume_hint
|
||||
from omnigent._native_resume_hint import echo_native_cold_resume_hint, echo_native_resume_hint
|
||||
from omnigent._runner_startup import RunnerStartupProgress, runner_startup_progress
|
||||
from omnigent._wrapper_labels import CURSOR_NATIVE_WRAPPER_VALUE as _WRAPPER_LABEL_VALUE
|
||||
from omnigent._wrapper_labels import WRAPPER_LABEL_KEY as _WRAPPER_LABEL_KEY
|
||||
@@ -82,13 +82,31 @@ class LaunchedCursorTerminal:
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedCursorTerminal:
|
||||
"""Prepared native Cursor terminal attachment details."""
|
||||
"""Prepared native Cursor terminal attachment details.
|
||||
|
||||
:param reattached: ``True`` when an existing, still-running session
|
||||
terminal was reused (the live-reattach path: prior chat is
|
||||
intact).
|
||||
:param cold_resumed: ``True`` when resuming an existing Omnigent
|
||||
session whose terminal had already exited, so a *fresh*
|
||||
``cursor-agent`` TUI was launched with none of the prior turns.
|
||||
Cursor records no resumable chat id, so this is genuinely a new
|
||||
chat - distinct from a brand-new session (``resolved_session_id
|
||||
is None``) and from a live reattach. Drives the honest
|
||||
cold-resume stderr hint. Note: cursor deliberately treats
|
||||
``cold_resumed`` and ``reattached`` as mutually exclusive (the
|
||||
cold-resume path leaves ``reattached`` at its ``False`` default)
|
||||
- unlike ``claude_native`` which models them independently. This
|
||||
is safe because cursor never reads ``reattached`` for teardown
|
||||
ownership; do not "fix" the apparent inconsistency.
|
||||
"""
|
||||
|
||||
session_id: str
|
||||
terminal_id: str
|
||||
tmux_socket: Path | None
|
||||
tmux_target: str | None
|
||||
reattached: bool
|
||||
cold_resumed: bool = False
|
||||
|
||||
|
||||
def _configured_cursor_command(env: Mapping[str, str]) -> str:
|
||||
@@ -265,6 +283,8 @@ def _run_with_remote_server(
|
||||
enabled=auto_open_conversation,
|
||||
warn=lambda message: click.echo(message, err=True),
|
||||
)
|
||||
if prepared.cold_resumed:
|
||||
echo_native_cold_resume_hint(agent_label="Cursor")
|
||||
await _attach_terminal_resource(prepared)
|
||||
if resolved_session_id is None:
|
||||
echo_native_resume_hint(
|
||||
@@ -301,7 +321,12 @@ async def _prepare_cursor_terminal_via_daemon(
|
||||
persist_args = list(cursor_args)
|
||||
timeout = httpx.Timeout(30.0, read=120.0)
|
||||
async with httpx.AsyncClient(base_url=base_url, headers=headers, timeout=timeout) as client:
|
||||
reattached = session_id is not None
|
||||
# Resuming an existing session can either reattach to a live
|
||||
# terminal (prior chat intact) or, if that terminal has exited,
|
||||
# cold-start a fresh TUI. We only know which after probing for a
|
||||
# running terminal below, so default both flags off here.
|
||||
reattached = False
|
||||
cold_resumed = False
|
||||
if session_id is None:
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Cursor session requires a session bundle.")
|
||||
@@ -338,6 +363,14 @@ async def _prepare_cursor_terminal_via_daemon(
|
||||
tmux_target=existing_terminal.tmux_target,
|
||||
reattached=True,
|
||||
)
|
||||
# Session exists but its terminal has exited. Cursor records no
|
||||
# resumable chat id, so the launch below starts a fresh TUI with
|
||||
# no prior turns. Flag it so the caller can say so honestly.
|
||||
# Mutually exclusive with the reattach path above: we leave
|
||||
# reattached at False here (unlike claude_native, which treats
|
||||
# cold_resumed/reattached as independent). Safe because cursor
|
||||
# never uses reattached for teardown ownership.
|
||||
cold_resumed = True
|
||||
if persist_args:
|
||||
_update_startup_progress(startup_progress, "Updating Cursor session...")
|
||||
resp = await client.patch(
|
||||
@@ -375,6 +408,7 @@ async def _prepare_cursor_terminal_via_daemon(
|
||||
tmux_socket=terminal.tmux_socket,
|
||||
tmux_target=terminal.tmux_target,
|
||||
reattached=reattached,
|
||||
cold_resumed=cold_resumed,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
@@ -23,11 +25,40 @@ from typing import Any
|
||||
|
||||
#: Env var carrying the bridge dir into the harness executor process.
|
||||
BRIDGE_DIR_ENV_VAR = "HARNESS_CURSOR_NATIVE_BRIDGE_DIR"
|
||||
#: Env var carrying the requesting Omnigent session id into the harness.
|
||||
REQUEST_SESSION_ID_ENV_VAR = "HARNESS_CURSOR_NATIVE_REQUEST_SESSION_ID"
|
||||
|
||||
_BRIDGE_ROOT = Path(os.environ.get("TMPDIR", "/tmp")) / f"omnigent-{os.getuid()}" / "cursor-native"
|
||||
_TMUX_FILE = "tmux.json"
|
||||
_BRIDGE_CONFIG_FILE = "bridge.json"
|
||||
_MCP_CONFIG_FILE = "mcp.json"
|
||||
_MCP_SERVER_NAME = "omnigent"
|
||||
_CURSOR_AUTO_APPROVE_TOOLS = [
|
||||
"list_comments",
|
||||
"sys_add_policy",
|
||||
"sys_agent_download",
|
||||
"sys_agent_get",
|
||||
"sys_agent_list",
|
||||
"sys_call_async",
|
||||
"sys_cancel_async",
|
||||
"sys_cancel_task",
|
||||
"sys_list_models",
|
||||
"sys_os_edit",
|
||||
"sys_os_read",
|
||||
"sys_os_shell",
|
||||
"sys_os_write",
|
||||
"sys_policy_registry",
|
||||
"sys_session_close",
|
||||
"sys_session_create",
|
||||
"sys_session_get_history",
|
||||
"sys_session_get_info",
|
||||
"sys_session_list",
|
||||
"sys_session_send",
|
||||
"sys_terminal_close",
|
||||
"sys_terminal_launch",
|
||||
"sys_terminal_list",
|
||||
"sys_terminal_read",
|
||||
"sys_terminal_send",
|
||||
"update_comment",
|
||||
]
|
||||
_TMUX_READY_TIMEOUT_S = 30.0
|
||||
_TMUX_SEND_TIMEOUT_S = 10.0
|
||||
_POLL_INTERVAL_S = 0.2
|
||||
@@ -49,6 +80,11 @@ def bridge_dir_for_session_id(session_id: str) -> Path:
|
||||
return _BRIDGE_ROOT / digest
|
||||
|
||||
|
||||
def bridge_root() -> Path:
|
||||
"""Return the configured Cursor-native bridge root."""
|
||||
return _BRIDGE_ROOT
|
||||
|
||||
|
||||
def _ensure_dir(path: Path) -> None:
|
||||
"""Create *path* (and parents) with owner-only permissions."""
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
@@ -62,10 +98,152 @@ def build_cursor_native_spawn_env(session_id: str) -> dict[str, str]:
|
||||
_ensure_dir(bridge_dir)
|
||||
return {
|
||||
BRIDGE_DIR_ENV_VAR: str(bridge_dir),
|
||||
REQUEST_SESSION_ID_ENV_VAR: session_id,
|
||||
}
|
||||
|
||||
|
||||
def build_mcp_config(
|
||||
bridge_dir: Path,
|
||||
*,
|
||||
python_executable: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build Cursor's ``.cursor/mcp.json`` for the Omnigent relay server.
|
||||
|
||||
Cursor prompts for MCP tool approval before it sends ``tools/call`` to the
|
||||
server. Omnigent tools already route through the Omnigent ``/mcp`` proxy,
|
||||
where TOOL_CALL policies publish ``response.elicitation_request`` events
|
||||
that the web UI can render. Auto-approving the Cursor-side MCP gate avoids a
|
||||
hidden in-terminal approval prompt blocking the call before Omnigent ever
|
||||
sees it, while preserving Omnigent's own policy/elicitation gate.
|
||||
"""
|
||||
python = python_executable or sys.executable
|
||||
return {
|
||||
"mcpServers": {
|
||||
_MCP_SERVER_NAME: {
|
||||
"command": python,
|
||||
"args": [
|
||||
"-I",
|
||||
"-m",
|
||||
"omnigent.claude_native_bridge",
|
||||
"serve-mcp",
|
||||
"--bridge-dir",
|
||||
str(bridge_dir),
|
||||
],
|
||||
"autoApprove": list(_CURSOR_AUTO_APPROVE_TOOLS),
|
||||
"env": {
|
||||
"TMPDIR": os.environ.get("TMPDIR", "/tmp"),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def write_mcp_bridge_config(bridge_dir: Path) -> None:
|
||||
"""Write the token config required by the shared Omnigent MCP bridge."""
|
||||
_ensure_dir(bridge_dir)
|
||||
config_path = bridge_dir / _BRIDGE_CONFIG_FILE
|
||||
if config_path.exists():
|
||||
return
|
||||
payload = {"token": secrets.token_urlsafe(32)}
|
||||
tmp = bridge_dir / (_BRIDGE_CONFIG_FILE + ".tmp")
|
||||
tmp.write_text(json.dumps(payload, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.replace(tmp, config_path)
|
||||
|
||||
|
||||
def write_mcp_config(
|
||||
workspace: Path,
|
||||
bridge_dir: Path,
|
||||
*,
|
||||
python_executable: str | None = None,
|
||||
) -> Path:
|
||||
"""Write the workspace-scoped Cursor MCP config for Omnigent tools."""
|
||||
write_mcp_bridge_config(bridge_dir)
|
||||
cursor_dir = workspace / ".cursor"
|
||||
cursor_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = cursor_dir / _MCP_CONFIG_FILE
|
||||
payload = build_mcp_config(bridge_dir, python_executable=python_executable)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
enable_mcp_for_workspace(workspace)
|
||||
allow_mcp_tools_in_cli_config()
|
||||
return path
|
||||
|
||||
|
||||
def approve_mcp_server_for_workspace(workspace: Path) -> None:
|
||||
"""Approve the workspace-scoped Omnigent MCP server in Cursor's state.
|
||||
|
||||
Cursor stores per-workspace MCP approvals using a private hash of the
|
||||
concrete server config. Rather than duplicate that implementation here,
|
||||
ask ``cursor-agent mcp enable omnigent`` to write the exact approval entry
|
||||
for this workspace. This is best-effort: the TUI still launches if the
|
||||
installed Cursor CLI cannot run the management subcommand, but when it can,
|
||||
the hidden server-approval gate is cleared before startup.
|
||||
"""
|
||||
try:
|
||||
from omnigent.cursor_native import resolve_cursor_executable
|
||||
|
||||
cursor = resolve_cursor_executable()
|
||||
subprocess.run(
|
||||
[cursor, "mcp", "enable", _MCP_SERVER_NAME],
|
||||
cwd=workspace,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
timeout=15,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError):
|
||||
return
|
||||
|
||||
|
||||
def cursor_project_key(workspace: Path) -> str:
|
||||
"""Return Cursor's project-state directory key for *workspace*."""
|
||||
return str(workspace).strip("/").replace("/", "-") or "root"
|
||||
|
||||
|
||||
def enable_mcp_for_workspace(workspace: Path) -> None:
|
||||
"""Ensure Cursor does not keep the Omnigent MCP disabled for this workspace."""
|
||||
disabled_path = (
|
||||
Path.home() / ".cursor" / "projects" / cursor_project_key(workspace) / "mcp-disabled.json"
|
||||
)
|
||||
try:
|
||||
raw = json.loads(disabled_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
if not isinstance(raw, list) or _MCP_SERVER_NAME not in raw:
|
||||
return
|
||||
updated = [item for item in raw if item != _MCP_SERVER_NAME]
|
||||
tmp = disabled_path.with_suffix(disabled_path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(updated, indent=2) + "\n", encoding="utf-8")
|
||||
os.replace(tmp, disabled_path)
|
||||
|
||||
|
||||
def allow_mcp_tools_in_cli_config() -> None:
|
||||
"""Allow Omnigent MCP tool calls in Cursor's CLI permission config."""
|
||||
path = Path.home() / ".cursor" / "cli-config.json"
|
||||
try:
|
||||
config = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return
|
||||
if not isinstance(config, dict):
|
||||
return
|
||||
permissions = config.setdefault("permissions", {})
|
||||
if not isinstance(permissions, dict):
|
||||
return
|
||||
allow = permissions.setdefault("allow", [])
|
||||
if not isinstance(allow, list):
|
||||
return
|
||||
existing = {item for item in allow if isinstance(item, str)}
|
||||
for tool_name in _CURSOR_AUTO_APPROVE_TOOLS:
|
||||
entry = f"Mcp({_MCP_SERVER_NAME}:{tool_name})"
|
||||
if entry not in existing:
|
||||
allow.append(entry)
|
||||
existing.add(entry)
|
||||
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||
tmp.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
||||
os.replace(tmp, path)
|
||||
|
||||
|
||||
def write_tmux_target(
|
||||
bridge_dir: Path,
|
||||
*,
|
||||
|
||||
@@ -361,6 +361,13 @@ class SqlConversation(Base):
|
||||
# AgentSpec instead of the parent's. Replaces task.agent_name
|
||||
# from the removed task store. None for top-level sessions.
|
||||
sub_agent_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# Monotonic allocator for the next item position in this conversation.
|
||||
# append() reads and advances this instead of scanning
|
||||
# MAX(SqlConversationItem.position) on every write, making position
|
||||
# assignment O(1) and collision-free under the conversation lock. New rows
|
||||
# start at 0 (column default); NULL marks a row created before this column
|
||||
# existed, which append() backfills via a one-time scan on its next write.
|
||||
next_position: Mapped[int | None] = mapped_column(Integer, nullable=True, default=0)
|
||||
external_session_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
# JSON-serialized mutable per-conversation key/value store
|
||||
# used by policy callables to accumulate state across turns.
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""add next_position to conversations
|
||||
|
||||
Revision ID: n1a2b3c4d5e6
|
||||
Revises: m1a2b3c4d5e6
|
||||
Create Date: 2026-06-18 00:00:00.000000
|
||||
|
||||
Adds the maintained item-position allocator to the conversations table:
|
||||
|
||||
- ``next_position``: nullable Integer — the next 0-based position to assign
|
||||
to an appended conversation item. ``append()`` reads and advances this
|
||||
counter instead of scanning ``MAX(conversation_items.position)`` on every
|
||||
write, which keeps position assignment O(1) and collision-free under the
|
||||
conversation lock.
|
||||
|
||||
The column is added nullable with NO server default, so every pre-existing
|
||||
conversation reads ``NULL``. ``append()`` treats ``NULL`` as "not yet
|
||||
populated": it falls back to a one-time ``MAX(position)`` scan and then
|
||||
persists the advanced counter on the conversation row, so the very next
|
||||
append on that conversation is scan-free. New rows created through the ORM
|
||||
start at ``0`` via the model-level column default.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "n1a2b3c4d5e6"
|
||||
down_revision: str | None = "m1a2b3c4d5e6"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
with op.batch_alter_table("conversations") as batch_op:
|
||||
batch_op.add_column(
|
||||
sa.Column("next_position", sa.Integer(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
with op.batch_alter_table("conversations") as batch_op:
|
||||
batch_op.drop_column("next_position")
|
||||
+30
-11
@@ -505,18 +505,37 @@ _CREATE_FTS = text(
|
||||
"item_id UNINDEXED, conversation_id UNINDEXED, search_text)"
|
||||
)
|
||||
|
||||
# Dialects that support SQLite's FTS5 extension. Cloudflare D1 is SQLite
|
||||
# served over HTTP, so it gets full-text search too — gate FTS on the dialect
|
||||
# *family*, not the literal name "sqlite". (The engine-level WAL/PRAGMA path in
|
||||
# ``_create_engine`` stays sqlite-only: those are local-file concerns that D1
|
||||
# neither needs nor supports over the wire.)
|
||||
_FTS5_DIALECTS = frozenset({"sqlite", "cloudflare_d1"})
|
||||
|
||||
|
||||
def _supports_fts5(dialect_name: str) -> bool:
|
||||
"""
|
||||
Whether *dialect_name* is a SQLite-family dialect that supports FTS5.
|
||||
|
||||
:param dialect_name: A SQLAlchemy ``dialect.name``, e.g. ``"sqlite"``,
|
||||
``"cloudflare_d1"``, or ``"postgresql"``.
|
||||
:returns: ``True`` for SQLite and SQLite-over-the-wire dialects (D1),
|
||||
``False`` otherwise.
|
||||
"""
|
||||
return dialect_name in _FTS5_DIALECTS
|
||||
|
||||
|
||||
def ensure_fts_table(engine: Engine) -> None:
|
||||
"""
|
||||
Create the FTS5 virtual table if on SQLite. Idempotent.
|
||||
Create the FTS5 virtual table on SQLite-family dialects. Idempotent.
|
||||
|
||||
On non-SQLite dialects this is a no-op.
|
||||
On dialects without FTS5 (e.g. PostgreSQL) this is a no-op.
|
||||
|
||||
:param engine: The SQLAlchemy engine whose dialect is inspected.
|
||||
If SQLite, the ``conversation_items_fts`` virtual table is
|
||||
created (if it does not already exist).
|
||||
On a SQLite-family dialect (SQLite or Cloudflare D1) the
|
||||
``conversation_items_fts`` virtual table is created if absent.
|
||||
"""
|
||||
if engine.dialect.name == "sqlite":
|
||||
if _supports_fts5(engine.dialect.name):
|
||||
with engine.connect() as conn:
|
||||
conn.execute(_CREATE_FTS)
|
||||
conn.commit()
|
||||
@@ -529,9 +548,9 @@ def insert_fts(
|
||||
search_text: str,
|
||||
) -> None:
|
||||
"""
|
||||
Dual-write a row into the FTS5 table (SQLite only).
|
||||
Dual-write a row into the FTS5 table (SQLite-family dialects only).
|
||||
|
||||
On non-SQLite dialects this is a no-op.
|
||||
On dialects without FTS5 this is a no-op.
|
||||
|
||||
:param session: An active SQLAlchemy session. Its bound engine's
|
||||
dialect is checked to decide whether to write.
|
||||
@@ -542,7 +561,7 @@ def insert_fts(
|
||||
:param search_text: Plain-text content to store in the FTS
|
||||
index for this item.
|
||||
"""
|
||||
if session.bind and session.bind.dialect.name == "sqlite":
|
||||
if session.bind and _supports_fts5(session.bind.dialect.name):
|
||||
session.execute(
|
||||
text(
|
||||
f"INSERT INTO {_FTS_TABLE}"
|
||||
@@ -555,16 +574,16 @@ def insert_fts(
|
||||
|
||||
def delete_fts_by_conversation(session: Session, conversation_id: str) -> None:
|
||||
"""
|
||||
Remove all FTS rows for a conversation (SQLite only).
|
||||
Remove all FTS rows for a conversation (SQLite-family dialects only).
|
||||
|
||||
On non-SQLite dialects this is a no-op.
|
||||
On dialects without FTS5 this is a no-op.
|
||||
|
||||
:param session: An active SQLAlchemy session. Its bound engine's
|
||||
dialect is checked to decide whether to delete.
|
||||
:param conversation_id: The conversation whose FTS rows should be
|
||||
removed, e.g. ``"conv_e4f5a6b7..."``.
|
||||
"""
|
||||
if session.bind and session.bind.dialect.name == "sqlite":
|
||||
if session.bind and _supports_fts5(session.bind.dialect.name):
|
||||
session.execute(
|
||||
text(f"DELETE FROM {_FTS_TABLE} WHERE conversation_id = :cid"),
|
||||
{"cid": conversation_id},
|
||||
|
||||
@@ -48,6 +48,7 @@ from omnigent.host.git_worktree import (
|
||||
remove_worktree,
|
||||
)
|
||||
from omnigent.host.identity import HostIdentity, load_or_create_host_identity
|
||||
from omnigent.onboarding.harness_install import harness_setup_hint
|
||||
from omnigent.onboarding.harness_readiness import (
|
||||
configured_harness_map,
|
||||
harness_is_configured,
|
||||
@@ -710,8 +711,7 @@ class HostProcess:
|
||||
status="failed",
|
||||
error=(
|
||||
f"harness {frame.harness!r} is not configured on host "
|
||||
f"{self._identity.name!r} — run `omnigent setup` on that "
|
||||
"machine to install the CLI and set a default credential"
|
||||
f"{self._identity.name!r} — {harness_setup_hint(frame.harness)}"
|
||||
),
|
||||
error_code=HARNESS_NOT_CONFIGURED_ERROR_CODE,
|
||||
)
|
||||
|
||||
@@ -296,6 +296,8 @@ class _AntigravitySessionState:
|
||||
:param agent_signature: ``(model, system_prompt, tool_signature)`` key; a
|
||||
change forces an agent rebuild. A model change thus resets the SDK
|
||||
conversation — fine since mid-session model switches are rare.
|
||||
:meth:`interrupt_session` clears this to ``None`` so the next turn
|
||||
rebuilds rather than reusing the cancelled conversation.
|
||||
:param pending_tools: Open tool calls keyed by call id, populated on
|
||||
:class:`ToolCallRequest` and drained by the ``PostToolCallHook``.
|
||||
:param active_queue: The current turn's event queue (the
|
||||
@@ -427,6 +429,25 @@ class AntigravityExecutor(Executor):
|
||||
then raises ``AntigravityCancelledError`` or yields a ``CANCELED``
|
||||
step, which the consumer surfaces as :class:`TurnCancelled`.
|
||||
|
||||
A cancelled SDK conversation carries aborted state, so reusing it for
|
||||
the next turn would resume from that broken state. Invalidating the
|
||||
cached agent signature forces the next :meth:`run_turn` through
|
||||
:meth:`_ensure_agent`'s rebuild path — the same teardown the
|
||||
error / signature-change path uses — which closes the stale agent and
|
||||
opens a fresh agent + conversation (re-seeding prior history) before it
|
||||
sends. The close is deferred to that path rather than awaited here so
|
||||
it cannot race the still-running producer task and turn a clean cancel
|
||||
into an :class:`ExecutorError`.
|
||||
|
||||
This deliberately departs from the peer executors, which call
|
||||
``close_session()`` eagerly on interrupt (see
|
||||
:meth:`CursorExecutor.interrupt_session` and
|
||||
:meth:`ClaudeSDKExecutor.interrupt_session`). Antigravity cannot do the
|
||||
same: an eager close would race the still-live turn's producer task and
|
||||
convert a clean :class:`TurnCancelled` into an :class:`ExecutorError`,
|
||||
so we only invalidate the signature here and defer the rebuild (and
|
||||
close) to the next turn.
|
||||
|
||||
:param session_key: The Omnigent session id to interrupt.
|
||||
:returns: ``True`` if a live conversation was asked to cancel,
|
||||
``False`` when the session has no open conversation.
|
||||
@@ -437,6 +458,9 @@ class AntigravityExecutor(Executor):
|
||||
state.interrupt_requested = True
|
||||
with contextlib.suppress(Exception):
|
||||
await state.conversation.cancel()
|
||||
# Drop the cached signature (not the agent itself — _ensure_agent needs
|
||||
# the live agent reference to close it) so the next turn rebuilds.
|
||||
state.agent_signature = None
|
||||
return True
|
||||
|
||||
async def run_turn(
|
||||
@@ -777,8 +801,12 @@ class AntigravityExecutor(Executor):
|
||||
signature = (model, system_prompt, self._tool_signature(tools))
|
||||
state = self._session_states.get(session_key)
|
||||
if state is None:
|
||||
# A brand-new session's state is NOT registered until the agent is
|
||||
# fully built below, so a construction failure (bad creds, a host
|
||||
# without the SDK's required glibc, SDK drift) leaves no dead,
|
||||
# agent-less entry accumulating in ``_session_states`` turn after
|
||||
# turn. A reused session is already registered.
|
||||
state = _AntigravitySessionState()
|
||||
self._session_states[session_key] = state
|
||||
|
||||
if state.agent is not None and state.agent_signature == signature:
|
||||
return state, False
|
||||
@@ -791,9 +819,23 @@ class AntigravityExecutor(Executor):
|
||||
agent = await self._open_agent(
|
||||
state, model=model, system_prompt=system_prompt, tools=tools
|
||||
)
|
||||
# Record the opened agent on the state BEFORE the (failure-prone)
|
||||
# conversation access: ``_open_agent`` has already entered the agent's
|
||||
# async context, which spawns the native ``localharness`` subprocess, so
|
||||
# if anything after this point raises, the agent must still be reachable
|
||||
# by ``close()`` / ``close_session()`` for teardown — otherwise that
|
||||
# subprocess orphans. If wiring the conversation fails, reap it here.
|
||||
state.agent = agent
|
||||
state.conversation = agent.conversation
|
||||
try:
|
||||
state.conversation = agent.conversation
|
||||
except Exception:
|
||||
await self._close_agent(agent)
|
||||
state.agent = None
|
||||
state.conversation = None
|
||||
raise
|
||||
state.agent_signature = signature
|
||||
# Register only once the agent is fully built (see the no-state branch).
|
||||
self._session_states[session_key] = state
|
||||
return state, True
|
||||
|
||||
async def _open_agent(
|
||||
|
||||
@@ -284,6 +284,7 @@ class BwrapSandboxBackend(SandboxBackend):
|
||||
policy: SandboxPolicy,
|
||||
cwd: Path,
|
||||
chdir: Path | None = None,
|
||||
target: str | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build the ``bwrap`` argv that wraps *argv* with the hermetic
|
||||
@@ -308,6 +309,15 @@ class BwrapSandboxBackend(SandboxBackend):
|
||||
set — typically to the per-helper scratch tmpdir for
|
||||
``OSEnvSpec.start_in_scratch`` — the helper starts there
|
||||
instead while *cwd* stays bound for reads.
|
||||
:param target: Absolute path to the binary that the launcher
|
||||
will exec as its final target after the re-exec (e.g. the
|
||||
``claude`` CLI installed under ``node_modules/.bin/``).
|
||||
When set and the path lives outside the default mounts,
|
||||
:func:`_ensure_executable_visible` is called for it so
|
||||
its directory chain is bind-mounted into the namespace —
|
||||
the same treatment ``argv[0]`` (the Python interpreter)
|
||||
already receives. ``None`` when the target is already
|
||||
reachable via the standard mounts.
|
||||
:returns: A complete ``bwrap`` argv ready for
|
||||
``subprocess.Popen`` — never an empty list.
|
||||
|
||||
@@ -348,6 +358,15 @@ class BwrapSandboxBackend(SandboxBackend):
|
||||
# e.g. a pyenv install under ``$HOME/.pyenv``.
|
||||
bwrap_args += _ensure_executable_visible(argv, cwd_resolved)
|
||||
|
||||
# Make sure the final target binary (e.g. the claude CLI at
|
||||
# node_modules/.bin/claude) is also reachable. The launcher
|
||||
# re-execs itself into the bwrap namespace and then runs the
|
||||
# target via subprocess.run — without this bind the target's
|
||||
# directory is invisible inside the namespace and the exec
|
||||
# fails with FileNotFoundError.
|
||||
if target is not None:
|
||||
bwrap_args += _ensure_executable_visible([target], cwd_resolved)
|
||||
|
||||
# cwd bind: writable iff a write_root resolves to cwd.
|
||||
cwd_writable = any(_is_same_path(root, cwd_resolved) for root in policy.write_roots)
|
||||
bwrap_args += [
|
||||
|
||||
@@ -46,8 +46,9 @@ import contextlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from collections.abc import AsyncIterator, Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeAlias
|
||||
|
||||
@@ -66,6 +67,7 @@ from .executor import (
|
||||
ToolCallRequest,
|
||||
ToolCallStatus,
|
||||
ToolSpec,
|
||||
TurnCancelled,
|
||||
TurnComplete,
|
||||
classify_tool_result,
|
||||
)
|
||||
@@ -332,16 +334,28 @@ def _tool_error_payload(text: str) -> dict[str, Any]: # type: ignore[explicit-a
|
||||
def _encode_tool_result(result: Any) -> Any: # type: ignore[explicit-any]
|
||||
"""Encode a bridged-tool result for the SDK custom-tool return.
|
||||
|
||||
A dict carrying a truthy ``error`` or ``blocked`` is a dispatch failure or a
|
||||
policy block (the shapes ``_bridge_one_dispatch`` / the policy layer return):
|
||||
surface it as an ``isError`` payload so the model sees a failure — parity with
|
||||
the claude-sdk handler, which the cursor harness otherwise diverged from by
|
||||
delivering errors as ordinary, apparently-successful results. Everything else
|
||||
returns its text: a ``str`` passthrough (the SDK wraps it as success), else
|
||||
JSON.
|
||||
A result that :func:`classify_tool_result` flags as anything other than
|
||||
SUCCESS — a dispatch failure (``error``), a policy block (``blocked``), a
|
||||
cancellation (``cancelled``), or any of those nested inside a
|
||||
``content`` / ``result`` / ``output`` / ``text`` envelope (or under a list
|
||||
element) — is surfaced as an ``isError`` payload so the model sees a
|
||||
failure. This pins the encoded result to the same ``classify_tool_result``
|
||||
verdict the executor already reports for the observed ``ToolCallComplete``
|
||||
event (see ``_sdk_message_to_events``), rather than the top-level-only
|
||||
``error`` / ``blocked`` check this used to share with the claude-sdk
|
||||
handler. (The claude-sdk handler still uses that narrower top-level check,
|
||||
so this is *not* parity with it.) Everything else returns its text: a
|
||||
``str`` passthrough (the SDK wraps it as success), else JSON.
|
||||
|
||||
Trade-off: because ``classify_tool_result`` maps ``{"cancelled": True}`` to
|
||||
CANCELLED (not SUCCESS), a benign cancellation result — e.g. a successful
|
||||
``sys_cancel_async`` returning ``{"cancelled": True, ...}`` — is encoded as
|
||||
``isError``. This is intentional: a non-SUCCESS verdict is treated as a
|
||||
failure here regardless of how benign the cancellation is.
|
||||
"""
|
||||
if isinstance(result, dict) and (result.get("error") or result.get("blocked")):
|
||||
return _tool_error_payload(json.dumps(result, default=str))
|
||||
if classify_tool_result(result).status != ToolCallStatus.SUCCESS:
|
||||
encoded = result if isinstance(result, str) else json.dumps(result, default=str)
|
||||
return _tool_error_payload(encoded)
|
||||
if isinstance(result, str):
|
||||
return result
|
||||
try:
|
||||
@@ -355,6 +369,63 @@ def _encode_tool_result(result: Any) -> Any: # type: ignore[explicit-any]
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_conversation_id() -> str | None:
|
||||
"""Extract the ``--conversation-id`` value from the CLI args.
|
||||
|
||||
The harness subprocess is launched by :mod:`process_manager` with
|
||||
``--conversation-id conv_<hex>`` on the command line. This is the
|
||||
canonical server-side conversation ID (with ``conv_`` prefix) that
|
||||
the policy evaluation endpoint expects.
|
||||
"""
|
||||
argv = sys.argv
|
||||
for i, arg in enumerate(argv):
|
||||
if arg == "--conversation-id" and i + 1 < len(argv):
|
||||
return argv[i + 1]
|
||||
return None
|
||||
|
||||
|
||||
def _write_cursor_hooks(cwd: str, hook_script_path: str, server_url: str, session_id: str) -> Path:
|
||||
"""Write ``.cursor/hooks.json`` and a wrapper shell script for preToolUse policy enforcement.
|
||||
|
||||
The Cursor SDK hook executor runs the command directly (not via a shell),
|
||||
so inline ``env VAR=val`` doesn't work. Instead we write a tiny shell
|
||||
wrapper that exports the env vars and execs the Python hook script.
|
||||
|
||||
:param cwd: Workspace root directory.
|
||||
:param hook_script_path: Absolute path to ``cursor_policy_hook.py``.
|
||||
:param server_url: Omnigent server URL, e.g. ``"http://127.0.0.1:6767"``.
|
||||
:param session_id: Conversation / session ID for policy evaluation.
|
||||
:returns: The path to the written ``hooks.json`` file.
|
||||
"""
|
||||
hooks_dir = Path(cwd) / ".cursor"
|
||||
hooks_dir.mkdir(parents=True, exist_ok=True)
|
||||
hooks_file = hooks_dir / "hooks.json"
|
||||
|
||||
# Write a wrapper script that sets env vars and execs the hook.
|
||||
wrapper = hooks_dir / "omnigent-hook.sh"
|
||||
wrapper.write_text(
|
||||
f"#!/bin/sh\n"
|
||||
f"export _OMNIGENT_SERVER_URL='{server_url}'\n"
|
||||
f"export _OMNIGENT_SESSION_ID='{session_id}'\n"
|
||||
f"exec '{sys.executable}' '{hook_script_path}'\n"
|
||||
)
|
||||
wrapper.chmod(0o755)
|
||||
command = str(wrapper)
|
||||
config = {
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"preToolUse": [
|
||||
{
|
||||
"command": command,
|
||||
"timeout": 30,
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
hooks_file.write_text(json.dumps(config, indent=2))
|
||||
return hooks_file
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CursorSessionState:
|
||||
"""Per-Omnigent-conversation SDK session state."""
|
||||
@@ -365,6 +436,7 @@ class _CursorSessionState:
|
||||
model: str | None = None
|
||||
tools_fingerprint: str | None = None
|
||||
has_sent_prompt: bool = False
|
||||
hooks_file: Path | None = field(default=None, repr=False)
|
||||
|
||||
|
||||
class CursorExecutor(Executor):
|
||||
@@ -411,6 +483,10 @@ class CursorExecutor(Executor):
|
||||
# pi / claude-sdk use). ``None`` on single-process / pre-turn paths
|
||||
# (then policy is a no-op).
|
||||
self._policy_evaluator: Callable[[str, dict[str, Any]], Awaitable[Any]] | None = None
|
||||
# Installed by the runtime adapter; surfaces ASK verdicts to the
|
||||
# user via the elicitation UI (approval prompt). ``None`` when no
|
||||
# handler is wired (single-process / test paths → fail closed).
|
||||
self._elicitation_handler: Callable[[str, dict[str, Any]], Awaitable[bool]] | None = None
|
||||
|
||||
def supports_streaming(self) -> bool:
|
||||
return True
|
||||
@@ -447,6 +523,10 @@ class CursorExecutor(Executor):
|
||||
"""Evaluate PHASE_TOOL_CALL policy for a Cursor native tool.
|
||||
|
||||
Returns ``{"block": bool, "reason": str}``.
|
||||
|
||||
ASK is treated as DENY (fail-closed) because Cursor native tools
|
||||
execute inside the Cursor process — by the time we observe them the
|
||||
tool has already started, so we cannot pause for human approval.
|
||||
"""
|
||||
evaluator = self._policy_evaluator
|
||||
if evaluator is None:
|
||||
@@ -455,6 +535,34 @@ class CursorExecutor(Executor):
|
||||
action = getattr(verdict, "action", None)
|
||||
if action == "POLICY_ACTION_DENY":
|
||||
return {"block": True, "reason": getattr(verdict, "reason", "") or "blocked by policy"}
|
||||
if action == "POLICY_ACTION_ASK":
|
||||
reason = getattr(verdict, "reason", "") or "approval required by policy"
|
||||
# Cursor native tools have already started by the time we see
|
||||
# them, but we still surface the elicitation UI so the human
|
||||
# can decide whether the *rest of the turn* should continue.
|
||||
handler = self._elicitation_handler
|
||||
if handler is not None:
|
||||
logger.info(
|
||||
"TOOL_CALL policy ASK on native cursor tool %s; "
|
||||
"prompting user (tool already started): %s",
|
||||
name,
|
||||
reason,
|
||||
)
|
||||
approved = await handler(name, args)
|
||||
if approved:
|
||||
return {"block": False, "reason": ""}
|
||||
return {"block": True, "reason": reason}
|
||||
# No handler → fail closed.
|
||||
logger.warning(
|
||||
"TOOL_CALL policy ASK on native cursor tool %s — no elicitation "
|
||||
"handler; treating as DENY: %s",
|
||||
name,
|
||||
reason,
|
||||
)
|
||||
return {
|
||||
"block": True,
|
||||
"reason": f"approval required (auto-denied — no elicitation handler): {reason}",
|
||||
}
|
||||
return {"block": False, "reason": ""}
|
||||
|
||||
# -- custom-tool bridge -------------------------------------------------
|
||||
@@ -526,13 +634,22 @@ class CursorExecutor(Executor):
|
||||
# -- session lifecycle --------------------------------------------------
|
||||
|
||||
async def _ensure_session(
|
||||
self, state: _CursorSessionState, model: str, tools: list[ToolSpec]
|
||||
self,
|
||||
state: _CursorSessionState,
|
||||
model: str,
|
||||
tools: list[ToolSpec],
|
||||
) -> None:
|
||||
"""Launch the local bridge and create the SDK agent if not already live.
|
||||
|
||||
On any bring-up failure the partially-created client is closed before
|
||||
propagating, so a bad ``CURSOR_API_KEY`` / launch error can't orphan a
|
||||
bridge subprocess.
|
||||
|
||||
Before agent creation, writes ``.cursor/hooks.json`` to the workspace
|
||||
with a ``preToolUse`` hook pointing at :mod:`cursor_policy_hook` so
|
||||
PHASE_TOOL_CALL policies are enforced on ALL Cursor native tools --
|
||||
including those that execute silently without emitting ``tool_call``
|
||||
SDK messages.
|
||||
"""
|
||||
if state.agent is not None:
|
||||
return
|
||||
@@ -545,13 +662,30 @@ class CursorExecutor(Executor):
|
||||
) from exc
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
cwd = self._cwd or os.getcwd()
|
||||
cwd = os.path.abspath(self._cwd or os.getcwd())
|
||||
|
||||
# Write .cursor/hooks.json for preToolUse policy enforcement.
|
||||
# RUNNER_SERVER_URL is inherited by the harness subprocess via
|
||||
# _build_harness_spawn_env (process_manager.py).
|
||||
# The conversation_id comes from the --conversation-id CLI arg
|
||||
# passed by the process_manager — NOT from the executor's
|
||||
# session_key (which is an internal UUID without the conv_ prefix).
|
||||
server_url = os.environ.get("RUNNER_SERVER_URL", "")
|
||||
conv_id = _get_conversation_id()
|
||||
if server_url and conv_id:
|
||||
hook_script = str(Path(__file__).with_name("cursor_policy_hook.py"))
|
||||
state.hooks_file = _write_cursor_hooks(cwd, hook_script, server_url, conv_id)
|
||||
|
||||
client = await AsyncClient.launch_bridge(workspace=cwd)
|
||||
try:
|
||||
local = LocalAgentOptions(
|
||||
cwd=cwd,
|
||||
custom_tools=self._make_custom_tools(tools, loop) or None,
|
||||
)
|
||||
local_kwargs: dict[str, Any] = {
|
||||
"cwd": cwd,
|
||||
"custom_tools": self._make_custom_tools(tools, loop) or None,
|
||||
}
|
||||
# Tell the SDK to read project-level settings (including hooks.json).
|
||||
if state.hooks_file is not None:
|
||||
local_kwargs["setting_sources"] = ["project"]
|
||||
local = LocalAgentOptions(**local_kwargs)
|
||||
agent = await AsyncAgent.create(
|
||||
client=client,
|
||||
model=model,
|
||||
@@ -703,12 +837,33 @@ class CursorExecutor(Executor):
|
||||
yield ExecutorError(message=f"cursor-sdk turn failed: {exc}", retryable=True)
|
||||
return
|
||||
|
||||
# RunResult.status is Literal["finished", "error", "cancelled",
|
||||
# "expired"]. Only "finished" should commit a TurnComplete; the other
|
||||
# terminal statuses are surfaced as errors/cancellation and tear the
|
||||
# session down (the agent/bridge may be in an inconsistent state).
|
||||
status = getattr(result, "status", "")
|
||||
if status == "error":
|
||||
await self.close_session(session_key)
|
||||
detail = getattr(result, "result", "") or "cursor-sdk run reported an error"
|
||||
yield ExecutorError(message=f"cursor-sdk run error: {detail}", retryable=True)
|
||||
return
|
||||
if status == "expired":
|
||||
await self.close_session(session_key)
|
||||
detail = getattr(result, "result", "") or "cursor-sdk run expired"
|
||||
yield ExecutorError(message=f"cursor-sdk run expired: {detail}", retryable=True)
|
||||
return
|
||||
if status == "cancelled":
|
||||
await self.close_session(session_key)
|
||||
yield TurnCancelled(reason="cursor-sdk run cancelled")
|
||||
return
|
||||
if status != "finished":
|
||||
await self.close_session(session_key)
|
||||
detail = getattr(result, "result", "") or "cursor-sdk run finished with unknown status"
|
||||
yield ExecutorError(
|
||||
message=f"cursor-sdk run returned non-finished status {status!r}: {detail}",
|
||||
retryable=True,
|
||||
)
|
||||
return
|
||||
|
||||
# Prefer the streamed text we accumulated (which carries the paragraph
|
||||
# breaks inserted above) over the SDK's aggregate ``result`` (which does
|
||||
@@ -742,6 +897,16 @@ class CursorExecutor(Executor):
|
||||
if state.client is not None:
|
||||
await _safe_close(state.client)
|
||||
state.client = None
|
||||
# Best-effort cleanup of hooks.json and the wrapper script.
|
||||
if state.hooks_file is not None:
|
||||
try:
|
||||
state.hooks_file.unlink(missing_ok=True)
|
||||
# Also remove the wrapper shell script alongside hooks.json.
|
||||
wrapper = state.hooks_file.parent / "omnigent-hook.sh"
|
||||
wrapper.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
state.hooks_file = None
|
||||
|
||||
async def close_session(self, session_key: str) -> None:
|
||||
state = self._session_states.pop(session_key, None)
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
"""Cursor preToolUse hook script for Omnigent policy enforcement.
|
||||
|
||||
Standalone script -- no omnigent imports. Runs as a subprocess of the
|
||||
Cursor SDK bridge process, not the harness.
|
||||
|
||||
Reads tool-call info from stdin (Cursor hook protocol), evaluates
|
||||
PHASE_TOOL_CALL policy via the Omnigent server, and returns the
|
||||
verdict on stdout.
|
||||
|
||||
Environment variables (baked into the hooks.json command by the
|
||||
CursorExecutor at session startup):
|
||||
|
||||
_OMNIGENT_SERVER_URL : Base URL of the Omnigent server
|
||||
(e.g. ``http://127.0.0.1:6767``).
|
||||
_OMNIGENT_SESSION_ID : Session / conversation ID for policy
|
||||
evaluation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
|
||||
def main() -> None:
|
||||
server_url = os.environ.get("_OMNIGENT_SERVER_URL", "")
|
||||
session_id = os.environ.get("_OMNIGENT_SESSION_ID", "")
|
||||
|
||||
if not server_url or not session_id:
|
||||
# No server wired -- fail open (allow).
|
||||
json.dump({"permission": "allow"}, sys.stdout)
|
||||
return
|
||||
|
||||
try:
|
||||
payload = json.load(sys.stdin)
|
||||
except (json.JSONDecodeError, EOFError, ValueError):
|
||||
json.dump({"permission": "allow"}, sys.stdout)
|
||||
return
|
||||
|
||||
tool_name = payload.get("tool_name") or payload.get("toolName") or "unknown"
|
||||
tool_input = payload.get("tool_input") or payload.get("toolInput") or {}
|
||||
|
||||
# Build the evaluation request matching the server's EvaluationRequest
|
||||
# schema.
|
||||
eval_body = json.dumps(
|
||||
{
|
||||
"event": {
|
||||
"type": "PHASE_TOOL_CALL",
|
||||
"target": "",
|
||||
"data": {
|
||||
"name": tool_name,
|
||||
"arguments": tool_input if isinstance(tool_input, dict) else {},
|
||||
},
|
||||
"context": {},
|
||||
},
|
||||
}
|
||||
).encode()
|
||||
|
||||
url = f"{server_url.rstrip('/')}/v1/sessions/{session_id}/policies/evaluate"
|
||||
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=eval_body,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
with urllib.request.urlopen(req, timeout=25) as resp:
|
||||
result = json.loads(resp.read())
|
||||
except Exception: # noqa: BLE001 -- fail open on any error
|
||||
# Network error / timeout / server down -- fail open.
|
||||
json.dump({"permission": "allow"}, sys.stdout)
|
||||
return
|
||||
|
||||
action = result.get("result", "POLICY_ACTION_ALLOW")
|
||||
reason = result.get("reason", "")
|
||||
|
||||
if action == "POLICY_ACTION_DENY":
|
||||
out: dict[str, str] = {"permission": "deny"}
|
||||
if reason:
|
||||
out["agent_message"] = f"Tool '{tool_name}' denied by Omnigent policy: {reason}"
|
||||
json.dump(out, sys.stdout)
|
||||
elif action == "POLICY_ACTION_ASK":
|
||||
# ASK means the server already resolved approval (it parks the
|
||||
# HTTP request until the human decides). If we get ASK here it
|
||||
# means the server couldn't resolve it -- fail closed.
|
||||
out = {"permission": "deny"}
|
||||
if reason:
|
||||
out["agent_message"] = f"Tool '{tool_name}' requires approval: {reason}"
|
||||
json.dump(out, sys.stdout)
|
||||
else:
|
||||
# ALLOW or UNSPECIFIED
|
||||
json.dump({"permission": "allow"}, sys.stdout)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -345,11 +345,16 @@ class ExecutorSpec:
|
||||
:param profile: Credentials profile name (typically a
|
||||
``~/.databrickscfg`` profile), e.g. ``"<your-profile>"``.
|
||||
``None`` when no profile override is needed.
|
||||
:param auth: Parsed auth block from the YAML (e.g. api_key +
|
||||
base_url). Carried through so the omnigent spec translator
|
||||
can forward it into the child :class:`ExecutorSpec` without
|
||||
re-reading raw YAML.
|
||||
"""
|
||||
|
||||
model: str | None = None
|
||||
harness: str | None = None
|
||||
profile: str | None = None
|
||||
auth: object | None = None # ApiKeyAuth | DatabricksAuth | None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -623,10 +623,23 @@ def _parse_executor_spec(data: YamlData | str | bool | None) -> ExecutorSpec | N
|
||||
# missing keys map to ``None`` directly. ``data.get`` happens to
|
||||
# already return ``None`` for missing keys, so the assignment
|
||||
# flows through unchanged.
|
||||
#
|
||||
# Parse ``executor.auth`` into a typed auth dataclass so that
|
||||
# inline AgentTool sub-agents can declare auth (e.g. api_key +
|
||||
# base_url for mock LLM routing) and have it flow through to the
|
||||
# child spec's executor. Without this, auth blocks on inline
|
||||
# sub-agent executors are silently dropped.
|
||||
auth = None
|
||||
raw_auth = data.get("auth")
|
||||
if isinstance(raw_auth, dict):
|
||||
from omnigent.spec.parser import _parse_executor_auth
|
||||
|
||||
auth = _parse_executor_auth(data, expand_env=True)
|
||||
return ExecutorSpec(
|
||||
model=data.get("model"),
|
||||
harness=data.get("harness"),
|
||||
profile=data.get("profile"),
|
||||
auth=auth,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
+103
-20
@@ -124,6 +124,37 @@ JsonValue: TypeAlias = None | bool | int | float | str | list["JsonValue"] | dic
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _safe_dumps(response: dict[str, Any], req_id: str | None) -> str: # type: ignore[explicit-any]
|
||||
"""Serialize a tool-server response, never raising on bad payloads.
|
||||
|
||||
Tool callbacks may return values ``json.dumps`` can't encode (a
|
||||
``datetime``, ``set``, ``bytes``, custom object, ...). Encoding happens
|
||||
on the response path *outside* :meth:`_ToolServer._execute`'s try, so an
|
||||
unguarded ``json.dumps`` would propagate and the connection would close
|
||||
with zero bytes written — leaving the JS ``callTool`` promise pending and
|
||||
hanging the entire Pi turn. Mirrors codex's ``_result_text`` guard: on a
|
||||
serialization failure, fall back to an ``{"error": ...}`` envelope so the
|
||||
client always receives a valid frame.
|
||||
|
||||
:param response: The response dict to serialize.
|
||||
:param req_id: The originating request id, echoed back on the error
|
||||
envelope so the client correlates the failure with its call.
|
||||
:returns: A compact JSON string (no trailing newline).
|
||||
"""
|
||||
try:
|
||||
return json.dumps(response, separators=(",", ":"))
|
||||
except (TypeError, ValueError) as exc:
|
||||
# Stringify ``req_id`` so the fallback envelope itself can never raise
|
||||
# on a non-serializable id (the caller passes a ``str`` today, but the
|
||||
# guard must hold for any future caller). ``None`` stays ``None`` so the
|
||||
# client still sees a null id rather than the literal "None".
|
||||
safe_id: str | None = req_id if req_id is None or isinstance(req_id, str) else str(req_id)
|
||||
return json.dumps(
|
||||
{"id": safe_id, "error": f"unserializable tool result: {exc}"},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
|
||||
class _ToolServer:
|
||||
"""Async TCP server that handles tool-call requests from the Pi extension.
|
||||
|
||||
@@ -213,7 +244,14 @@ class _ToolServer:
|
||||
else:
|
||||
response = await self._execute(raw_tool_name, tool_args)
|
||||
response["id"] = raw_req_id
|
||||
out = json.dumps(response, separators=(",", ":")) + "\n"
|
||||
# Serialize defensively: a tool result may carry a value
|
||||
# ``json.dumps`` can't encode (e.g. ``datetime``/``set``).
|
||||
# If it raises here — outside ``_execute``'s try — the frame
|
||||
# is never written and the JS ``callTool`` promise hangs the
|
||||
# whole turn (no ``data``/``error`` event). Always emit a JSON
|
||||
# frame: a serializable error envelope when the response can't
|
||||
# be encoded, mirroring codex's ``_result_text`` guard.
|
||||
out = _safe_dumps(response, raw_req_id) + "\n"
|
||||
writer.write(out.encode("utf-8"))
|
||||
await writer.drain()
|
||||
except (asyncio.CancelledError, ConnectionError):
|
||||
@@ -371,7 +409,17 @@ const TOKEN = {token_json};
|
||||
|
||||
/** Send a tool call request over TCP and return the result. */
|
||||
function callTool(toolName, args) {{
|
||||
return new Promise((resolve, reject) => {{
|
||||
return new Promise((resolve) => {{
|
||||
// Idempotent settle: a tool call must resolve exactly once. Route every
|
||||
// resolve through finish() so a late "close" after a real "data" response
|
||||
// can't clobber the result, and a bare close with no data still resolves
|
||||
// (rather than hanging Pi's agent loop forever).
|
||||
let settled = false;
|
||||
const finish = (v) => {{ if (!settled) {{ settled = true; resolve(v); }} }};
|
||||
const errorResult = (text) => ({{
|
||||
content: [{{ type: "text", text: JSON.stringify({{ error: text }}) }}],
|
||||
isError: true
|
||||
}});
|
||||
const client = net.createConnection({{ port: PORT, host: "127.0.0.1" }}, () => {{
|
||||
const id = Math.random().toString(36).slice(2);
|
||||
const req = JSON.stringify({{ id, token: TOKEN, tool: toolName, args }}) + "\\n";
|
||||
@@ -384,39 +432,33 @@ function callTool(toolName, args) {{
|
||||
const resp = JSON.parse(buf.slice(0, nl));
|
||||
client.end();
|
||||
if (resp.error) {{
|
||||
resolve({{
|
||||
content: [{{ type: "text", text: JSON.stringify({{ error: resp.error }}) }}],
|
||||
isError: true
|
||||
}});
|
||||
finish(errorResult(resp.error));
|
||||
}} else {{
|
||||
const text = typeof resp.result === "string"
|
||||
? resp.result
|
||||
: JSON.stringify(resp.result);
|
||||
const isError = resp.result && (resp.result.error || resp.result.blocked);
|
||||
resolve({{ content: [{{ type: "text", text }}], isError: !!isError }});
|
||||
finish({{ content: [{{ type: "text", text }}], isError: !!isError }});
|
||||
}}
|
||||
}} catch (e) {{
|
||||
client.end();
|
||||
resolve({{
|
||||
content: [{{ type: "text", text: JSON.stringify({{ error: e.message }}) }}],
|
||||
isError: true
|
||||
}});
|
||||
finish(errorResult(e.message));
|
||||
}}
|
||||
}}
|
||||
}});
|
||||
client.on("error", (err) => {{
|
||||
resolve({{
|
||||
content: [{{ type: "text", text: JSON.stringify({{ error: err.message }}) }}],
|
||||
isError: true
|
||||
}});
|
||||
finish(errorResult(err.message));
|
||||
}});
|
||||
// A clean FIN with no bytes (e.g. the server failed to serialize the
|
||||
// result and closed) emits neither "data" nor "error"; without this
|
||||
// the promise would hang forever. finish() is a no-op if already settled.
|
||||
client.on("close", () => {{
|
||||
finish(errorResult("tool server closed connection without a response"));
|
||||
}});
|
||||
client.write(req);
|
||||
}});
|
||||
client.on("error", (err) => {{
|
||||
resolve({{
|
||||
content: [{{ type: "text", text: JSON.stringify({{ error: err.message }}) }}],
|
||||
isError: true
|
||||
}});
|
||||
finish(errorResult(err.message));
|
||||
}});
|
||||
}});
|
||||
}}
|
||||
@@ -582,6 +624,47 @@ _PI_ENV_ALLOW_EXACT: frozenset[str] = frozenset(
|
||||
)
|
||||
_STREAM_READ_CHUNK_SIZE = 65536
|
||||
|
||||
# CLI flags whose values are sensitive (e.g. the full system prompt) and must
|
||||
# not be written to logs verbatim. The value following these flags is replaced
|
||||
# with a length-only placeholder.
|
||||
_REDACTED_ARGV_FLAGS = frozenset({"--append-system-prompt", "--system-prompt"})
|
||||
|
||||
|
||||
def _redact_argv_for_log(args: Sequence[str]) -> list[str]:
|
||||
"""
|
||||
Return a copy of ``args`` with sensitive flag values redacted for logging.
|
||||
|
||||
The system prompt value (e.g. passed via ``--append-system-prompt``) is
|
||||
replaced with a ``[system prompt N chars]`` placeholder so it never leaks
|
||||
into debug logs. Two argv forms are handled:
|
||||
|
||||
* the two-token form ``--append-system-prompt <value>`` (what the current
|
||||
spawn code emits), and
|
||||
* the equals-joined form ``--append-system-prompt=<value>`` (not emitted
|
||||
today, but redacted defensively in case a future refactor switches to it).
|
||||
|
||||
All other tokens are preserved so the command remains useful for debugging.
|
||||
"""
|
||||
redacted: list[str] = []
|
||||
redact_next = False
|
||||
for arg in args:
|
||||
if redact_next:
|
||||
redacted.append(f"[system prompt {len(arg)} chars]")
|
||||
redact_next = False
|
||||
continue
|
||||
if arg in _REDACTED_ARGV_FLAGS:
|
||||
# Two-token form: redact the following value token.
|
||||
redacted.append(arg)
|
||||
redact_next = True
|
||||
continue
|
||||
flag, sep, value = arg.partition("=")
|
||||
if sep and flag in _REDACTED_ARGV_FLAGS:
|
||||
# Equals-joined form: redact the inline value, keep the flag name.
|
||||
redacted.append(f"{flag}=[system prompt {len(value)} chars]")
|
||||
continue
|
||||
redacted.append(arg)
|
||||
return redacted
|
||||
|
||||
|
||||
def _build_models_json(
|
||||
host: str,
|
||||
@@ -812,7 +895,7 @@ class _PiRpcSession:
|
||||
if extra_args:
|
||||
args.extend(extra_args)
|
||||
|
||||
logger.debug("PiExecutor: spawning %s", " ".join(args))
|
||||
logger.debug("PiExecutor: spawning %s", " ".join(_redact_argv_for_log(args)))
|
||||
self.process = await _create_subprocess_exec(
|
||||
*args,
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
|
||||
@@ -284,6 +284,7 @@ class SandboxBackend(ABC):
|
||||
policy: SandboxPolicy,
|
||||
cwd: Path,
|
||||
chdir: Path | None = None,
|
||||
target: str | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Wrap *argv* with whatever launcher the backend needs at spawn
|
||||
@@ -311,10 +312,18 @@ class SandboxBackend(ABC):
|
||||
``None``, the helper starts in *cwd*. When set (e.g. for
|
||||
``OSEnvSpec.start_in_scratch``), the launcher chdirs
|
||||
there on entry. In-process backends may ignore this.
|
||||
:param target: Absolute path to the binary that the launcher
|
||||
will exec as its final target (e.g. the ``claude`` CLI).
|
||||
When set, the backend must ensure this path is reachable
|
||||
inside the sandbox namespace — for bwrap this means
|
||||
bind-mounting the target's directory chain just as it does
|
||||
for ``argv[0]`` (the Python interpreter). ``None`` when
|
||||
the target is already covered by the default mounts (e.g.
|
||||
``/usr/bin/something``).
|
||||
:returns: The (possibly wrapped) argv. The default
|
||||
implementation returns *argv* unchanged.
|
||||
"""
|
||||
del policy, cwd, chdir
|
||||
del policy, cwd, chdir, target
|
||||
return argv
|
||||
|
||||
|
||||
@@ -637,6 +646,7 @@ def run_launcher(encoded_sandbox: str, target_path: str, argv: list[str]) -> int
|
||||
launcher_argv,
|
||||
sandbox,
|
||||
Path(os.getcwd()),
|
||||
target=target_path,
|
||||
)
|
||||
)
|
||||
os.environ[_LAUNCHER_WRAPPED_ENV] = "1"
|
||||
|
||||
@@ -447,6 +447,7 @@ class SeatbeltSandboxBackend(SandboxBackend):
|
||||
policy: SandboxPolicy,
|
||||
cwd: Path,
|
||||
chdir: Path | None = None,
|
||||
target: str | None = None,
|
||||
) -> list[str]:
|
||||
"""
|
||||
Build the ``sandbox-exec`` argv that wraps *argv* under an
|
||||
@@ -512,6 +513,9 @@ class SeatbeltSandboxBackend(SandboxBackend):
|
||||
unsafe ancestor (see :func:`_ensure_executable_visible`).
|
||||
"""
|
||||
del chdir # See docstring — Seatbelt has no --chdir analog.
|
||||
del target # SBPL profile grants read access by subpath rules; the
|
||||
# run_launcher target binary is typically covered by the cwd or default
|
||||
# subpath allows. A targeted seatbelt fix is tracked separately.
|
||||
cwd_resolved = cwd.resolve(strict=False)
|
||||
extra_read_paths = _ensure_executable_visible(
|
||||
argv, cwd_resolved, policy_read_roots=policy.read_roots or []
|
||||
|
||||
@@ -164,6 +164,16 @@ def resolve_cursor_api_key(config: dict[str, object] | None = None) -> str | Non
|
||||
spawn-env builder and the setup readout — can fall back to an inherited
|
||||
``CURSOR_API_KEY`` instead of crashing a run.
|
||||
|
||||
An empty / all-whitespace resolved value also reads as ``None``: the
|
||||
shared ``resolve_secret`` ``env:`` branch only raises on an *unset*
|
||||
variable, so a configured ``env:CURSOR_API_KEY`` pointing at an empty
|
||||
(``CURSOR_API_KEY=""``) or whitespace-only var resolves to ``""``. Folding
|
||||
that to ``None`` here keeps :func:`cursor_api_key_configured` and the
|
||||
spawn-env builder in agreement — both treat such a value as unset rather
|
||||
than reporting "key set" for a credential the runtime won't forward.
|
||||
(``keychain:`` values are stripped at store time, so only the ``env:``
|
||||
path needs this runtime guard; we apply it uniformly for simplicity.)
|
||||
|
||||
:param config: A pre-loaded config mapping; ``None`` loads the global
|
||||
config.
|
||||
:returns: The plaintext Cursor API key, or ``None`` when none is
|
||||
@@ -173,9 +183,10 @@ def resolve_cursor_api_key(config: dict[str, object] | None = None) -> str | Non
|
||||
if ref is None:
|
||||
return None
|
||||
try:
|
||||
return resolve_secret(ref)
|
||||
resolved = resolve_secret(ref)
|
||||
except OmnigentError:
|
||||
return None
|
||||
return resolved if resolved.strip() else None
|
||||
|
||||
|
||||
def cursor_api_key_configured(config: dict[str, object] | None = None) -> bool:
|
||||
|
||||
@@ -132,16 +132,20 @@ _HARNESS_INSTALL: dict[str, HarnessInstallSpec] = {
|
||||
# :data:`_HARNESS_INSTALL` family key. Only the CLI-backed harnesses appear
|
||||
# here — the ones that cannot launch without a binary on ``PATH``:
|
||||
# ``claude-native`` wraps the ``claude`` CLI, ``codex-native`` the ``codex``
|
||||
# CLI, and ``pi`` / ``pi-native`` the ``pi`` CLI.
|
||||
# CLI, ``pi`` / ``pi-native`` the ``pi`` CLI, and ``cursor-native`` /
|
||||
# ``native-cursor`` the ``cursor-agent`` CLI (the native Cursor TUI, installed
|
||||
# via Cursor's curl installer rather than npm — see its ``install_hint``).
|
||||
# SDK-based harnesses run in-process and are deliberately absent, so they
|
||||
# resolve to "no CLI required": ``claude-sdk``, ``codex``, ``openai-agents-sdk``,
|
||||
# and ``cursor`` (which drives the ``cursor-sdk``
|
||||
# Python package over its own bundled bridge, NOT the ``cursor-agent`` CLI).
|
||||
# and the SDK ``cursor`` harness (which drives the ``cursor-sdk`` Python package
|
||||
# over its own bundled bridge, NOT the ``cursor-agent`` CLI).
|
||||
_HARNESS_NAME_TO_KEY: dict[str, str] = {
|
||||
"claude-native": ANTHROPIC_FAMILY,
|
||||
"codex-native": OPENAI_FAMILY,
|
||||
PI_KEY: PI_KEY,
|
||||
"pi-native": PI_KEY,
|
||||
"cursor-native": CURSOR_KEY,
|
||||
"native-cursor": CURSOR_KEY,
|
||||
}
|
||||
|
||||
|
||||
@@ -183,6 +187,35 @@ def missing_harness_cli(harness: str) -> HarnessInstallSpec | None:
|
||||
return spec
|
||||
|
||||
|
||||
def harness_setup_hint(harness: str | None) -> str:
|
||||
"""Return actionable remediation when *harness* can't launch on a machine.
|
||||
|
||||
Most CLI harnesses (``claude``/``codex``/``pi``) install via npm and a
|
||||
model credential, both of which ``omnigent setup`` handles — so they route
|
||||
there. But a harness whose CLI ships out-of-band (``cursor-agent``, via
|
||||
Cursor's own curl installer rather than npm — it carries an ``install_hint``
|
||||
and no ``package``) is **not** installed by ``omnigent setup``: pointing a
|
||||
native-Cursor user there is a dead end, since setup only configures the
|
||||
SDK-based ``cursor`` harness (``cursor-sdk`` + ``CURSOR_API_KEY``). For
|
||||
those, name the vendor installer and the CLI's own login instead.
|
||||
|
||||
:param harness: An executor harness identifier, e.g. ``"cursor-native"``,
|
||||
``"claude-native"``, or ``"codex"``; ``None`` falls back to the
|
||||
``omnigent setup`` hint.
|
||||
:returns: A remediation clause for the "harness not configured" message,
|
||||
e.g. ``"install the cursor-agent CLI on that machine with `curl
|
||||
https://cursor.com/install -fsS | bash`, then run `cursor-agent
|
||||
login`"`` for native Cursor, or the ``omnigent setup`` hint otherwise.
|
||||
"""
|
||||
spec = required_cli_for_harness(harness or "")
|
||||
if spec is not None and spec.package is None and spec.install_hint:
|
||||
login = ""
|
||||
if spec.login_args:
|
||||
login = f", then run `{spec.binary} {' '.join(spec.login_args)}`"
|
||||
return f"install the {spec.binary} CLI on that machine with `{spec.install_hint}`{login}"
|
||||
return "run `omnigent setup` on that machine to install the CLI and set a default credential"
|
||||
|
||||
|
||||
def harness_install_spec(key: str) -> HarnessInstallSpec | None:
|
||||
"""Return the install spec for a family/harness key, or ``None``.
|
||||
|
||||
|
||||
@@ -50,6 +50,14 @@ _SDK_HARNESSES: frozenset[str] = frozenset(
|
||||
# be gated explicitly or they fail open like an unknown harness.
|
||||
_PI_HARNESSES: frozenset[str] = frozenset({PI_SURFACE, "pi-native"})
|
||||
|
||||
# Native Cursor harnesses. These boot the ``cursor-agent`` TUI (``omni cursor``)
|
||||
# and so, like the other native CLI harnesses, can't launch without that binary
|
||||
# on ``PATH`` — gate them on it. Distinct from the SDK ``cursor`` harness
|
||||
# (``CURSOR_KEY`` below), which runs in-process via ``cursor-sdk`` and gates on
|
||||
# a ``CURSOR_API_KEY`` instead. Without these entries they'd fail open like an
|
||||
# unknown harness, letting a binary-less launch die inside the executor.
|
||||
_CURSOR_NATIVE_HARNESSES: frozenset[str] = frozenset({"cursor-native", "native-cursor"})
|
||||
|
||||
|
||||
def _canonical_harness(harness: str) -> str:
|
||||
"""Normalize a harness id to its canonical spelling.
|
||||
@@ -100,6 +108,12 @@ def harness_is_configured(harness: str) -> bool:
|
||||
canonical = _canonical_harness(harness)
|
||||
if canonical in _SDK_HARNESSES:
|
||||
return True
|
||||
if canonical in _CURSOR_NATIVE_HARNESSES:
|
||||
# Native Cursor (``omni cursor``) wraps the ``cursor-agent`` CLI — gate
|
||||
# on that binary, like ``claude-native`` / ``codex-native``. (Login
|
||||
# state surfaces at run time; the daemon gates only on binary presence,
|
||||
# mirroring the other native harnesses.)
|
||||
return harness_cli_installed(CURSOR_KEY)
|
||||
if canonical == CURSOR_KEY:
|
||||
# Cursor runs in-process via ``cursor-sdk`` and authenticates with a
|
||||
# ``CURSOR_API_KEY`` (a ``cursor-agent login`` does not apply). So,
|
||||
@@ -142,5 +156,6 @@ def configured_harness_map() -> dict[str, bool]:
|
||||
spellings.update(_EXECUTOR_TYPE_HARNESS_ALIASES)
|
||||
spellings.update(HARNESS_ALIASES)
|
||||
spellings.update(_PI_HARNESSES)
|
||||
spellings.update(_CURSOR_NATIVE_HARNESSES)
|
||||
spellings.add(CURSOR_KEY)
|
||||
return {spelling: harness_is_configured(spelling) for spelling in spellings}
|
||||
|
||||
@@ -414,7 +414,10 @@ def resolve_secret(ref: str) -> str:
|
||||
f"'env:{var}'. Set the variable in the environment.",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
return value
|
||||
# Strip surrounding whitespace: a key exported with a stray trailing
|
||||
# newline (e.g. ``export KEY=$(cat file)``) must not be forwarded
|
||||
# verbatim to a harness/SDK, where the padding fails auth.
|
||||
return value.strip()
|
||||
# Bare inline reference, e.g. "$ANTHROPIC_API_KEY" or a literal value.
|
||||
expanded = os.path.expandvars(ref)
|
||||
check_unresolved_env_vars(ref, expanded)
|
||||
|
||||
@@ -23,6 +23,12 @@ _SYS_OS_TOOLS = frozenset({"sys_os_read", "sys_os_write", "sys_os_edit", "sys_os
|
||||
# inside the CLI subprocess.
|
||||
_NATIVE_OS_TOOLS = frozenset({"Bash", "Read", "Write", "Edit", "Glob", "Grep"})
|
||||
|
||||
# Cursor SDK native tool names surfaced via the preToolUse hook
|
||||
# (see ``omnigent.inner.cursor_policy_hook``). Cursor uses ``Shell``
|
||||
# for its terminal tool (not ``Bash``). ``Read`` / ``Write`` / ``Edit``
|
||||
# are already in ``_NATIVE_OS_TOOLS`` above.
|
||||
_CURSOR_NATIVE_OS_TOOLS = frozenset({"Shell"})
|
||||
|
||||
# Pi native tool names (lowercase), surfaced via the pi ``tool_call``
|
||||
# extension hook (see ``omnigent.inner.pi_executor._gate_native_tool``).
|
||||
# Pi runs these in-process and routes them through the same TOOL_CALL
|
||||
@@ -79,7 +85,7 @@ def max_tool_calls_per_session(limit: int = 100) -> PolicyCallable:
|
||||
def ask_on_os_tools(event: PolicyEvent) -> PolicyResponse:
|
||||
"""ASK for user approval before any file or shell tool call.
|
||||
|
||||
Covers four tool-name families:
|
||||
Covers five tool-name families:
|
||||
|
||||
- **Omnigent built-in OS tools** (``sys_os_read``,
|
||||
``sys_os_write``, ``sys_os_edit``, ``sys_os_shell``).
|
||||
@@ -88,6 +94,9 @@ def ask_on_os_tools(event: PolicyEvent) -> PolicyResponse:
|
||||
``PreToolUse`` hook contract.
|
||||
- **Codex native tools** — uses the same ``PreToolUse`` hook
|
||||
contract with the same tool names (e.g. ``Bash``).
|
||||
- **Cursor SDK native tools** (``Shell``) — surfaced via the
|
||||
``preToolUse`` hook (see ``cursor_policy_hook.py``). Cursor
|
||||
uses ``Shell`` instead of ``Bash`` for its terminal tool.
|
||||
- **Pi native tools** (``read``, ``bash``, ``write``, ``edit``)
|
||||
— surfaced via the pi ``tool_call`` extension hook. Lowercase
|
||||
and distinct from the Claude/Codex casing.
|
||||
@@ -105,10 +114,13 @@ def ask_on_os_tools(event: PolicyEvent) -> PolicyResponse:
|
||||
if not isinstance(data, dict):
|
||||
return _ALLOW
|
||||
tool = data.get("name", "")
|
||||
if tool in _SYS_OS_TOOLS or tool in _NATIVE_OS_TOOLS or tool in _PI_NATIVE_OS_TOOLS:
|
||||
_all_os_tools = (
|
||||
_SYS_OS_TOOLS | _NATIVE_OS_TOOLS | _CURSOR_NATIVE_OS_TOOLS | _PI_NATIVE_OS_TOOLS
|
||||
)
|
||||
if tool in _all_os_tools:
|
||||
args = data.get("arguments", {})
|
||||
# Build a short preview of what the tool is doing.
|
||||
if tool in ("sys_os_shell", "Bash", "bash"):
|
||||
if tool in ("sys_os_shell", "Bash", "bash", "Shell"):
|
||||
preview = args.get("command", "") if isinstance(args, dict) else ""
|
||||
elif tool in ("Grep", "Glob"):
|
||||
preview = args.get("pattern", "") if isinstance(args, dict) else ""
|
||||
|
||||
@@ -1422,6 +1422,9 @@ class _SessionsChatReplAdapter:
|
||||
if session.agent_name:
|
||||
self._agent_name = session.agent_name
|
||||
self._bound_runner_id = session.runner_id
|
||||
# Don't clobber a runner if it is revived after timeout
|
||||
if self._runner_recover is None and session.runner_id:
|
||||
self._runner_id = session.runner_id
|
||||
self._reasoning_effort = session.reasoning_effort
|
||||
self._model_override = session.model_override
|
||||
self._llm_model = session.llm_model
|
||||
|
||||
@@ -203,12 +203,34 @@ function startInboxPoller(pi, config, handleInterrupt) {
|
||||
deliverAttempts.set(key, attempts);
|
||||
continue;
|
||||
}
|
||||
// Cap reached: surface a failure (a silent drop would be invisible)
|
||||
// and consume the file to stop the spin.
|
||||
// Cap reached: surface the dropped follow-up without faking a turn
|
||||
// failure. The runner treats external_session_status:failed as
|
||||
// terminal for native sub-agents, so use a non-content conversation
|
||||
// error item and consume the file to stop the spin. Include the
|
||||
// message id and a short content preview so an operator can identify
|
||||
// what was lost (data loss; the file is unlinked below).
|
||||
deliverAttempts.delete(key);
|
||||
const droppedId = id ?? "(no id)";
|
||||
const preview =
|
||||
typeof payload.content === "string"
|
||||
? payload.content.length > 80
|
||||
? `${payload.content.slice(0, 80)}…`
|
||||
: payload.content
|
||||
: "";
|
||||
postEvent(config, {
|
||||
type: "external_session_status",
|
||||
data: { status: "failed", response_id: `pi-deliver-failed-${Date.now()}` },
|
||||
type: "external_conversation_item",
|
||||
data: {
|
||||
response_id: `pi-deliver-dropped-${Date.now()}`,
|
||||
item_type: "error",
|
||||
item_data: {
|
||||
source: "execution",
|
||||
code: "pi_followup_delivery_dropped",
|
||||
message:
|
||||
`Omnigent: a queued follow-up message (id ${droppedId}) could ` +
|
||||
`not be delivered to Pi after ${MAX_DELIVER_ATTEMPTS} attempts ` +
|
||||
`and was dropped. Content preview: ${JSON.stringify(preview)}`,
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
fs.unlinkSync(fullPath);
|
||||
@@ -222,9 +244,11 @@ function startInboxPoller(pi, config, handleInterrupt) {
|
||||
// always consume the file (below). If there is no live turn to abort
|
||||
// right now, the interrupt is simply dropped — leaving the file would
|
||||
// re-read it every tick forever and, once a later turn creates an
|
||||
// abortable context, abort that unrelated turn. requestInterrupt arms
|
||||
// the pendingInterrupt window when it does catch a running turn, so a
|
||||
// turn starting just after this still gets aborted via replay.
|
||||
// abortable context, abort that unrelated turn. requestInterrupt only
|
||||
// arms the pendingInterrupt window when it catches a genuinely running
|
||||
// turn (idle interrupts are dropped, not armed — see F18), so a turn
|
||||
// already in flight still gets aborted via replay without poisoning the
|
||||
// next freshly-started turn.
|
||||
if (typeof handleInterrupt === "function") handleInterrupt();
|
||||
}
|
||||
if (id !== null) rememberSeen(id);
|
||||
@@ -240,6 +264,13 @@ module.exports = function (pi) {
|
||||
let sequence = 0;
|
||||
let turnOrdinal = 0;
|
||||
let activeResponseId = null;
|
||||
// Dedicated loop-state flag, set on agent_start / cleared on agent_end. Used
|
||||
// as the no-isIdle() fallback for requestInterrupt instead of
|
||||
// !activeResponseId: agent_start resets activeResponseId to null and only
|
||||
// turn_start assigns it, so an interrupt landing in that gap (after
|
||||
// agent_start, before turn_start) would look idle by activeResponseId yet the
|
||||
// loop is genuinely running — agentRunning arms it correctly. See F18.
|
||||
let agentRunning = false;
|
||||
let latestContext = null;
|
||||
let pendingInterruptUntil = 0;
|
||||
const postedToolCalls = new Set();
|
||||
@@ -270,7 +301,29 @@ module.exports = function (pi) {
|
||||
return true;
|
||||
}
|
||||
|
||||
function safeIsIdle(ctx) {
|
||||
// Returns true/false from the SDK's isIdle(), or null when the signal is
|
||||
// unavailable (older SDK) or throws, so the caller can fall back.
|
||||
// Deliberately returns null (not true) on throw so callers fall back to loop
|
||||
// state (!agentRunning) rather than blindly treating the agent as idle.
|
||||
if (!ctx || typeof ctx.isIdle !== "function") return null;
|
||||
try {
|
||||
return ctx.isIdle();
|
||||
} catch (_err) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function requestInterrupt(ctx) {
|
||||
// ctx.abort() is a silent no-op when the Pi agent is idle (it does NOT
|
||||
// throw), so an interrupt that arrives with no live turn must NOT arm the
|
||||
// replay window — otherwise the 30s window poisons the next legitimately
|
||||
// started turn (F18). Only arm when a turn is genuinely in-flight: prefer
|
||||
// the SDK's isIdle(), and fall back to the agent loop state on SDK versions
|
||||
// that don't expose it.
|
||||
const idle = safeIsIdle(ctx);
|
||||
const turnIsIdle = idle === null ? !agentRunning : idle;
|
||||
if (turnIsIdle) return false;
|
||||
const accepted = interruptActiveContext(ctx);
|
||||
if (!accepted) return false;
|
||||
pendingInterruptUntil = Date.now() + pendingInterruptMs;
|
||||
@@ -396,7 +449,13 @@ module.exports = function (pi) {
|
||||
|
||||
pi.on("agent_start", async (_event, ctx) => {
|
||||
rememberContext(ctx);
|
||||
replayPendingInterrupt(ctx);
|
||||
// A brand-new agent loop must never inherit a replay window armed before it
|
||||
// began (e.g. a spuriously-armed window from an interrupt that landed while
|
||||
// idle). A legitimate interrupt that arrives after this point belongs to
|
||||
// this loop and can still arm/replay; agent_end clears once the loop
|
||||
// completes. See F18.
|
||||
clearPendingInterrupt();
|
||||
agentRunning = true;
|
||||
setOmnigentStatus(config, ctx, "running");
|
||||
activeResponseId = null;
|
||||
turnOrdinal = 0;
|
||||
@@ -416,6 +475,7 @@ module.exports = function (pi) {
|
||||
pi.on("agent_end", async (_event, ctx) => {
|
||||
rememberContext(ctx);
|
||||
clearPendingInterrupt();
|
||||
agentRunning = false;
|
||||
setOmnigentStatus(config, ctx, "idle");
|
||||
activeResponseId = null;
|
||||
await postEvent(config, {
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
// Unit test for the pi-native bridge extension's interrupt / replay logic.
|
||||
//
|
||||
// Regression coverage for F18 (SDK_INTEGRATION_BUG_AUDIT.md): an interrupt that
|
||||
// arrives while Pi is idle used to arm a 30s replay window that aborted the next
|
||||
// legitimately-started turn. ExtensionContext.abort() is a silent no-op when the
|
||||
// agent is idle (it does not throw), so the old requestInterrupt() armed the
|
||||
// window unconditionally and replayPendingInterrupt() then killed the next turn.
|
||||
//
|
||||
// This test drives the real extension through its public surface: it registers
|
||||
// the event handlers with a mock `pi`, and delivers interrupts through the real
|
||||
// inbox poller (a temp inbox directory). No network is used (postEvent fails
|
||||
// closed when config has no serverUrl).
|
||||
//
|
||||
// Run with: node omnigent/resources/pi_native/omnigent_pi_native_extension.test.js
|
||||
//
|
||||
// Manual reproduction of the original bug (for context):
|
||||
// 1. Start a native Pi session linked to Omnigent and let it go idle.
|
||||
// 2. Hit "stop"/interrupt while no turn is running (between turns).
|
||||
// 3. Send a fresh user message within 30 seconds.
|
||||
// Before the fix: the fresh turn is aborted immediately at agent_start /
|
||||
// turn_start (and tool calls are blocked) before producing output. After the
|
||||
// fix: the idle interrupt is dropped and the fresh turn runs normally.
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const EXT_PATH = path.resolve(__dirname, "omnigent_pi_native_extension.js");
|
||||
|
||||
const harnesses = [];
|
||||
|
||||
// Build a fresh extension instance with its own temp inbox directory. Each call
|
||||
// produces independent closure state (activeResponseId, pendingInterruptUntil,
|
||||
// latestContext, ...).
|
||||
function makeHarness() {
|
||||
const inboxDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-native-inbox-"));
|
||||
const configPath = path.join(inboxDir, "config.json");
|
||||
fs.writeFileSync(configPath, JSON.stringify({ inboxDir }));
|
||||
process.env.OMNIGENT_PI_NATIVE_CONFIG = configPath;
|
||||
|
||||
const handlers = {};
|
||||
const pi = {
|
||||
on: (name, fn) => {
|
||||
handlers[name] = fn;
|
||||
},
|
||||
registerCommand: () => {},
|
||||
sendUserMessage: () => {},
|
||||
};
|
||||
|
||||
// Fresh module-function invocation -> fresh closures.
|
||||
delete require.cache[EXT_PATH];
|
||||
const mod = require(EXT_PATH);
|
||||
mod(pi);
|
||||
|
||||
const h = { pi, handlers, inboxDir };
|
||||
harnesses.push(h);
|
||||
return h;
|
||||
}
|
||||
|
||||
// ctx mock. `idle` may be true/false (exposes isIdle()) or undefined (no isIdle
|
||||
// method at all, exercising the activeResponseId fallback path).
|
||||
function makeCtx({ idle } = {}) {
|
||||
const ctx = {
|
||||
abortCount: 0,
|
||||
abort() {
|
||||
this.abortCount += 1;
|
||||
},
|
||||
};
|
||||
if (idle !== undefined) ctx.isIdle = () => idle;
|
||||
return ctx;
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
// Drop an interrupt into the inbox and wait until the poller has consumed it
|
||||
// (the poller unlinks the file after invoking handleInterrupt -> requestInterrupt).
|
||||
async function deliverInterrupt(h) {
|
||||
const file = path.join(h.inboxDir, `int-${Date.now()}-${Math.random().toString(36).slice(2)}.json`);
|
||||
fs.writeFileSync(file, JSON.stringify({ type: "interrupt" }));
|
||||
const deadline = Date.now() + 3000;
|
||||
while (fs.existsSync(file)) {
|
||||
if (Date.now() > deadline) throw new Error("interrupt file was not consumed by poller");
|
||||
await sleep(20);
|
||||
}
|
||||
// The poller runs requestInterrupt synchronously before unlinking, so by the
|
||||
// time the file is gone the interrupt has been processed.
|
||||
}
|
||||
|
||||
function assert(name, cond, detail) {
|
||||
console.log(`${cond ? "PASS" : "FAIL"} ${name}${detail ? " -- " + detail : ""}`);
|
||||
if (!cond) process.exitCode = 1;
|
||||
}
|
||||
|
||||
async function testIdleInterruptDoesNotPoisonNextTurn() {
|
||||
const h = makeHarness();
|
||||
const idleCtx = makeCtx({ idle: true });
|
||||
await h.handlers.session_start({}, idleCtx);
|
||||
|
||||
await deliverInterrupt(h);
|
||||
|
||||
assert(
|
||||
"idle interrupt (isIdle) does not abort the idle context",
|
||||
idleCtx.abortCount === 0,
|
||||
`abortCount=${idleCtx.abortCount}`,
|
||||
);
|
||||
|
||||
// A fresh, legitimate turn starts within the (old) 30s window.
|
||||
const turnCtx = makeCtx({ idle: false });
|
||||
await h.handlers.agent_start({}, turnCtx);
|
||||
await h.handlers.turn_start({ turnIndex: 1 }, turnCtx);
|
||||
const toolResult = await h.handlers.tool_call(
|
||||
{ toolCallId: "t1", toolName: "do_thing", input: {} },
|
||||
turnCtx,
|
||||
);
|
||||
|
||||
assert(
|
||||
"fresh turn after idle interrupt is NOT aborted",
|
||||
turnCtx.abortCount === 0,
|
||||
`abortCount=${turnCtx.abortCount}`,
|
||||
);
|
||||
assert(
|
||||
"fresh turn's tool_call is NOT blocked after idle interrupt",
|
||||
!toolResult || toolResult.block !== true,
|
||||
JSON.stringify(toolResult),
|
||||
);
|
||||
}
|
||||
|
||||
async function testIdleInterruptFallbackNoIsIdle() {
|
||||
// No isIdle() on ctx -> requestInterrupt falls back to !activeResponseId.
|
||||
// Between turns activeResponseId is null, so this must behave as idle.
|
||||
const h = makeHarness();
|
||||
const idleCtx = makeCtx({}); // no isIdle method
|
||||
await h.handlers.session_start({}, idleCtx);
|
||||
|
||||
await deliverInterrupt(h);
|
||||
|
||||
assert(
|
||||
"idle interrupt (activeResponseId fallback) does not arm the window",
|
||||
idleCtx.abortCount === 0,
|
||||
`abortCount=${idleCtx.abortCount}`,
|
||||
);
|
||||
|
||||
const turnCtx = makeCtx({}); // no isIdle method
|
||||
await h.handlers.agent_start({}, turnCtx);
|
||||
await h.handlers.turn_start({ turnIndex: 1 }, turnCtx);
|
||||
const toolResult = await h.handlers.tool_call(
|
||||
{ toolCallId: "t1", toolName: "do_thing", input: {} },
|
||||
turnCtx,
|
||||
);
|
||||
|
||||
assert(
|
||||
"fresh turn after fallback idle interrupt is NOT aborted",
|
||||
turnCtx.abortCount === 0,
|
||||
`abortCount=${turnCtx.abortCount}`,
|
||||
);
|
||||
assert(
|
||||
"fresh turn's tool_call is NOT blocked (fallback)",
|
||||
!toolResult || toolResult.block !== true,
|
||||
JSON.stringify(toolResult),
|
||||
);
|
||||
}
|
||||
|
||||
async function testMidTurnInterruptStillAborts() {
|
||||
// Regression guard: a genuine mid-turn interrupt must still abort and replay.
|
||||
const h = makeHarness();
|
||||
const turnCtx = makeCtx({ idle: false });
|
||||
await h.handlers.session_start({}, turnCtx); // starts the inbox poller
|
||||
await h.handlers.agent_start({}, turnCtx);
|
||||
await h.handlers.turn_start({ turnIndex: 1 }, turnCtx);
|
||||
|
||||
await deliverInterrupt(h);
|
||||
|
||||
assert(
|
||||
"mid-turn interrupt aborts the live turn",
|
||||
turnCtx.abortCount >= 1,
|
||||
`abortCount=${turnCtx.abortCount}`,
|
||||
);
|
||||
|
||||
// Replay must keep aborting within the window and block in-flight tool calls.
|
||||
const toolResult = await h.handlers.tool_call(
|
||||
{ toolCallId: "t1", toolName: "do_thing", input: {} },
|
||||
turnCtx,
|
||||
);
|
||||
assert(
|
||||
"mid-turn interrupt blocks subsequent tool_call (replay)",
|
||||
!!toolResult && toolResult.block === true,
|
||||
JSON.stringify(toolResult),
|
||||
);
|
||||
}
|
||||
|
||||
async function testAgentLoopInterruptFallbackNoIsIdleBeforeTurnStart() {
|
||||
// No isIdle(), and an interrupt lands after agent_start but before
|
||||
// turn_start. Older SDKs without isIdle() still need to treat this as part of
|
||||
// the live agent loop, not as an idle interrupt to drop.
|
||||
const h = makeHarness();
|
||||
const turnCtx = makeCtx({}); // no isIdle method
|
||||
await h.handlers.session_start({}, turnCtx); // starts the inbox poller
|
||||
await h.handlers.agent_start({}, turnCtx);
|
||||
|
||||
await deliverInterrupt(h);
|
||||
|
||||
assert(
|
||||
"agent-loop interrupt aborts before turn_start (active loop fallback)",
|
||||
turnCtx.abortCount >= 1,
|
||||
`abortCount=${turnCtx.abortCount}`,
|
||||
);
|
||||
|
||||
await h.handlers.turn_start({ turnIndex: 1 }, turnCtx);
|
||||
const toolResult = await h.handlers.tool_call(
|
||||
{ toolCallId: "t1", toolName: "do_thing", input: {} },
|
||||
turnCtx,
|
||||
);
|
||||
assert(
|
||||
"agent-loop interrupt before turn_start replays to block tool_call",
|
||||
!!toolResult && toolResult.block === true,
|
||||
JSON.stringify(toolResult),
|
||||
);
|
||||
}
|
||||
|
||||
async function testMidTurnInterruptFallbackNoIsIdle() {
|
||||
// No isIdle() but an agent loop is active -> must still arm.
|
||||
const h = makeHarness();
|
||||
const turnCtx = makeCtx({}); // no isIdle method
|
||||
await h.handlers.session_start({}, turnCtx); // starts the inbox poller
|
||||
await h.handlers.agent_start({}, turnCtx);
|
||||
await h.handlers.turn_start({ turnIndex: 1 }, turnCtx);
|
||||
|
||||
await deliverInterrupt(h);
|
||||
|
||||
assert(
|
||||
"mid-turn interrupt aborts (activeResponseId fallback)",
|
||||
turnCtx.abortCount >= 1,
|
||||
`abortCount=${turnCtx.abortCount}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function testAgentStartClearsStaleWindow() {
|
||||
// Belt-and-suspenders: even if a window is armed during a live turn, a brand
|
||||
// new agent loop must start clean and not abort its first tool call.
|
||||
const h = makeHarness();
|
||||
const turnCtx = makeCtx({ idle: false });
|
||||
await h.handlers.session_start({}, turnCtx); // starts the inbox poller
|
||||
await h.handlers.agent_start({}, turnCtx);
|
||||
await h.handlers.turn_start({ turnIndex: 1 }, turnCtx);
|
||||
await deliverInterrupt(h);
|
||||
assert(
|
||||
"window armed during live turn (precondition)",
|
||||
turnCtx.abortCount >= 1,
|
||||
`abortCount=${turnCtx.abortCount}`,
|
||||
);
|
||||
|
||||
// A new agent loop begins (e.g. the user's next message) within 30s.
|
||||
const nextCtx = makeCtx({ idle: false });
|
||||
await h.handlers.agent_start({}, nextCtx);
|
||||
await h.handlers.turn_start({ turnIndex: 1 }, nextCtx);
|
||||
const toolResult = await h.handlers.tool_call(
|
||||
{ toolCallId: "t2", toolName: "do_thing", input: {} },
|
||||
nextCtx,
|
||||
);
|
||||
|
||||
assert(
|
||||
"new agent loop clears stale window (no abort)",
|
||||
nextCtx.abortCount === 0,
|
||||
`abortCount=${nextCtx.abortCount}`,
|
||||
);
|
||||
assert(
|
||||
"new agent loop's tool_call is NOT blocked",
|
||||
!toolResult || toolResult.block !== true,
|
||||
JSON.stringify(toolResult),
|
||||
);
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
await testIdleInterruptDoesNotPoisonNextTurn();
|
||||
await testIdleInterruptFallbackNoIsIdle();
|
||||
await testMidTurnInterruptStillAborts();
|
||||
await testAgentLoopInterruptFallbackNoIsIdleBeforeTurnStart();
|
||||
await testMidTurnInterruptFallbackNoIsIdle();
|
||||
await testAgentStartClearsStaleWindow();
|
||||
} finally {
|
||||
for (const h of harnesses) {
|
||||
if (h.pi.__omnigentInboxPoller) clearInterval(h.pi.__omnigentInboxPoller);
|
||||
try {
|
||||
fs.rmSync(h.inboxDir, { recursive: true, force: true });
|
||||
} catch (_err) {}
|
||||
}
|
||||
}
|
||||
})();
|
||||
@@ -642,7 +642,11 @@ def create_app(
|
||||
:returns: A runner FastAPI app exposing the harness-contract subset.
|
||||
"""
|
||||
from omnigent.runner.app import create_runner_app
|
||||
from omnigent.runner.identity import RUNNER_ID_ENV_VAR, get_stable_runner_id
|
||||
from omnigent.runner.identity import (
|
||||
OMNIGENT_INTERNAL_WS_ORIGIN,
|
||||
RUNNER_ID_ENV_VAR,
|
||||
get_stable_runner_id,
|
||||
)
|
||||
from omnigent.runtime.harnesses.process_manager import HarnessProcessManager
|
||||
|
||||
server_url = _server_url_from_env()
|
||||
@@ -674,6 +678,13 @@ def create_app(
|
||||
server_client = httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
auth=_RunnerDatabricksAuth(auth_token_factory),
|
||||
# Announce the runner as a first-party non-browser client via the
|
||||
# sentinel Origin. The server's require_trusted_origin CSRF guard on
|
||||
# the multipart routes (POST /v1/sessions bundle create, file upload
|
||||
# — both reached from tool_dispatch over this client) requires a
|
||||
# trusted Origin; the runner sends none otherwise, so the sentinel is
|
||||
# what lets sys_session_create / sys_upload_file through.
|
||||
headers={"Origin": OMNIGENT_INTERNAL_WS_ORIGIN},
|
||||
timeout=httpx.Timeout(5.0, read=None),
|
||||
# NOTE: ``follow_redirects`` deliberately stays False.
|
||||
# ``_RunnerDatabricksAuth.auth_flow`` needs to *see* the
|
||||
|
||||
+81
-8
@@ -108,6 +108,18 @@ _SUBAGENT_DELIVERY_UNTRACKED = "untracked"
|
||||
_SUBAGENT_DELIVERY_MISSING_WORK_ENTRY = "missing_work_entry"
|
||||
_SUBAGENT_DELIVERY_MISSING_PARENT_INBOX = "missing_parent_inbox"
|
||||
_NATIVE_TERMINAL_START_FAILED_CODE = "native_terminal_start_failed"
|
||||
# Read budget for runner→server POSTs that can PARK behind a human-approval
|
||||
# ASK gate: policy evaluation (``_evaluate_policy_via_omnigent``) and sub-agent
|
||||
# wake-notice delivery (``_deliver_subagent_wake_post``). Both are gated at the
|
||||
# recipient's REQUEST/LLM/TOOL phase, which can hold for the deciding policy's
|
||||
# ``ask_timeout`` (default one day). Held at one day (86400s) — matching that
|
||||
# default — so the POST WAITS for the real verdict instead of severing the
|
||||
# parked gate at a short read timeout. A 30s cut previously fail-closed to DENY
|
||||
# (and the wake POST retried into duplicate approval cards). Fast connect (30s)
|
||||
# so an unreachable server still fails out promptly into the caller's
|
||||
# fail-open/retry path. Guarded by tests/test_ask_timeout_infinite.py.
|
||||
_ASK_GATE_DELIVERY_READ_TIMEOUT_S: float = 86400.0
|
||||
_ASK_GATE_DELIVERY_TIMEOUT = httpx.Timeout(_ASK_GATE_DELIVERY_READ_TIMEOUT_S, connect=30.0)
|
||||
# Terminal resource hosting the framework's own TUI (the Omnigent REPL,
|
||||
# ``omnigent attach``) for runner-hosted SDK sessions — the SDK mirror of
|
||||
# the claude-/codex-native embedded terminals. Resource id derives as
|
||||
@@ -726,6 +738,7 @@ async def _auto_create_pi_terminal(
|
||||
publish_event: Callable[[str, dict[str, Any]], None],
|
||||
*,
|
||||
server_client: httpx.AsyncClient | None,
|
||||
agent_spec: AgentSpec | ResolvedSpec | None = None,
|
||||
) -> SessionResourceView:
|
||||
"""
|
||||
Auto-create a Pi terminal for a pi-native session.
|
||||
@@ -799,13 +812,23 @@ async def _auto_create_pi_terminal(
|
||||
cred_env, cred_args = pi_native_provider_launch(bridge_dir / "pi-agent", provider)
|
||||
pi_env.update(cred_env)
|
||||
pi_args.extend(cred_args)
|
||||
# Inherit the agent's os_env so its sandbox (e.g. ``type: none``),
|
||||
# egress_rules and env_passthrough are honoured. Without ``sandbox`` here
|
||||
# and ``parent_os_env`` below, launch_required_terminal falls back to
|
||||
# _default_sandbox_for_platform (linux_bwrap), overriding the YAML config.
|
||||
agent_os_env = _agent_os_env_from_spec(agent_spec)
|
||||
terminal_view = await resource_registry.launch_required_terminal(
|
||||
session_id=session_id,
|
||||
terminal_name="pi",
|
||||
session_key="main",
|
||||
resource_role=PI_NATIVE_TERMINAL_ROLE,
|
||||
parent_os_env=agent_os_env,
|
||||
spec=TerminalEnvSpec(
|
||||
os_env=OSEnvSpec(type="caller_process", cwd=workspace),
|
||||
os_env=OSEnvSpec(
|
||||
type="caller_process",
|
||||
cwd=workspace,
|
||||
sandbox=(agent_os_env.sandbox if agent_os_env is not None else None),
|
||||
),
|
||||
command=pi_command,
|
||||
args=pi_args,
|
||||
env=pi_env,
|
||||
@@ -835,6 +858,7 @@ async def _auto_create_cursor_terminal(
|
||||
publish_event: Callable[[str, dict[str, Any]], None],
|
||||
*,
|
||||
server_client: httpx.AsyncClient | None,
|
||||
ensure_comment_relay: Callable[..., Awaitable[None]] | None = None,
|
||||
) -> SessionResourceView:
|
||||
"""
|
||||
Auto-create the Cursor TUI terminal for a cursor-native session.
|
||||
@@ -865,10 +889,15 @@ async def _auto_create_cursor_terminal(
|
||||
# and drop the prior terminal's stale forward cursor so the new forwarder
|
||||
# can't resume the wrong chat / a stale rowid (mirrors codex's clear_bridge_state).
|
||||
await _cancel_auto_forwarder_task(session_id)
|
||||
from omnigent.cursor_native_bridge import bridge_dir_for_session_id
|
||||
from omnigent.cursor_native_bridge import (
|
||||
approve_mcp_server_for_workspace,
|
||||
bridge_dir_for_session_id,
|
||||
write_mcp_config,
|
||||
)
|
||||
from omnigent.cursor_native_forwarder import clear_cursor_bridge_state
|
||||
|
||||
clear_cursor_bridge_state(bridge_dir_for_session_id(session_id))
|
||||
bridge_dir = bridge_dir_for_session_id(session_id)
|
||||
clear_cursor_bridge_state(bridge_dir)
|
||||
|
||||
# ``_pi_native_launch_config`` is a generic session-snapshot reader
|
||||
# (workspace + terminal_launch_args); reused here, not Pi-specific.
|
||||
@@ -880,8 +909,11 @@ async def _auto_create_cursor_terminal(
|
||||
# cursor TUI's cwd and the forwarder hash the SAME path — cursor keys its
|
||||
# chat store dir on ``md5(cwd)``, and a mismatch would hide the store.
|
||||
workspace = os.path.realpath(str(launch_config.workspace))
|
||||
write_mcp_config(Path(workspace), bridge_dir)
|
||||
cursor_command = resolve_cursor_executable()
|
||||
cursor_args = list(launch_config.terminal_launch_args or [])
|
||||
if "--approve-mcps" not in cursor_args:
|
||||
cursor_args.append("--approve-mcps")
|
||||
terminal_view = await resource_registry.launch_required_terminal(
|
||||
session_id=session_id,
|
||||
terminal_name="cursor",
|
||||
@@ -904,10 +936,10 @@ async def _auto_create_cursor_terminal(
|
||||
if terminal_registry is not None:
|
||||
instance = terminal_registry.get(session_id, "cursor", "main")
|
||||
if instance is not None and instance.running:
|
||||
from omnigent.cursor_native_bridge import bridge_dir_for_session_id, write_tmux_target
|
||||
from omnigent.cursor_native_bridge import write_tmux_target
|
||||
|
||||
write_tmux_target(
|
||||
bridge_dir_for_session_id(session_id),
|
||||
bridge_dir,
|
||||
socket_path=instance.socket_path,
|
||||
tmux_target=instance.tmux_target,
|
||||
)
|
||||
@@ -937,12 +969,20 @@ async def _auto_create_cursor_terminal(
|
||||
|
||||
from omnigent.cursor_native_forwarder import supervise_cursor_forwarder
|
||||
|
||||
if server_client is not None and ensure_comment_relay is not None:
|
||||
await ensure_comment_relay(
|
||||
session_id,
|
||||
explicit_bridge_dir=bridge_dir,
|
||||
await_notify=False,
|
||||
)
|
||||
approve_mcp_server_for_workspace(Path(workspace))
|
||||
|
||||
_forwarder_task = asyncio.create_task(
|
||||
supervise_cursor_forwarder(
|
||||
base_url=server_url,
|
||||
headers={},
|
||||
session_id=session_id,
|
||||
bridge_dir=bridge_dir_for_session_id(session_id),
|
||||
bridge_dir=bridge_dir,
|
||||
agent_name="cursor-native-ui",
|
||||
workspace=workspace,
|
||||
launch_epoch_ms=launch_epoch_ms,
|
||||
@@ -2970,7 +3010,18 @@ async def _evaluate_policy_via_omnigent(
|
||||
"data": data,
|
||||
},
|
||||
},
|
||||
timeout=30.0,
|
||||
# A TOOL_CALL/LLM_REQUEST/REQUEST ASK parks server-side in
|
||||
# ``_hold_native_ask_gate`` until a human resolves it (up to the
|
||||
# deciding policy's ``ask_timeout``, default one day). A 30s read
|
||||
# budget here severed that long-poll after 30s — the server saw an
|
||||
# UPSTREAM DISCONNECT and failed the gate closed (DENY), so the
|
||||
# main (claude-sdk) agent's approval card auto-resolved while
|
||||
# native sub-agents (whose hooks already wait the full day) parked
|
||||
# correctly. Hold the read budget at one day to match the native
|
||||
# hooks' ``_EVALUATE_POLICY_TIMEOUT_S``; the server's ``ask_timeout``
|
||||
# remains the single real cap. Fast connect so an unreachable
|
||||
# server still fails out promptly into the fail-open path below.
|
||||
timeout=_ASK_GATE_DELIVERY_TIMEOUT,
|
||||
)
|
||||
if ap_resp.status_code == 200:
|
||||
result = ap_resp.json()
|
||||
@@ -4016,7 +4067,17 @@ async def _deliver_subagent_wake_post(
|
||||
"content": [{"type": "input_text", "text": notice}],
|
||||
},
|
||||
},
|
||||
timeout=30.0,
|
||||
# The server gates this injected wake at the parent's REQUEST
|
||||
# phase, which can PARK on a human ASK (e.g. session_cost_budget)
|
||||
# for up to the deciding policy's ``ask_timeout`` (default one
|
||||
# day). A 30s read budget severed that park after 30s → the
|
||||
# TimeoutError below retried → each retry re-posted the notice
|
||||
# and parked ANOTHER gate → duplicate approval cards, and the
|
||||
# gate never cleanly blocked. Hold the read budget at one day so
|
||||
# this POST waits for the real verdict (one held connection, one
|
||||
# card); fast connect so an unreachable parent runner still
|
||||
# fails out into the bounded retry below.
|
||||
timeout=_ASK_GATE_DELIVERY_TIMEOUT,
|
||||
)
|
||||
# Treat a non-2xx RESPONSE (e.g. a genuine 503 JSONResponse) as a
|
||||
# failure — httpx does not raise on status by itself.
|
||||
@@ -5613,11 +5674,16 @@ def create_runner_app(
|
||||
if not _has_pi_terminal:
|
||||
_publish_terminal_pending(_publish_event, session_id, True)
|
||||
try:
|
||||
try:
|
||||
_pi_spec = await _resolve_session_agent_spec(session_id)
|
||||
except OmnigentError:
|
||||
_pi_spec = None
|
||||
await _auto_create_pi_terminal(
|
||||
session_id,
|
||||
resource_registry,
|
||||
_publish_event,
|
||||
server_client=server_client,
|
||||
agent_spec=_pi_spec,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.exception(
|
||||
@@ -5650,6 +5716,7 @@ def create_runner_app(
|
||||
resource_registry,
|
||||
_publish_event,
|
||||
server_client=server_client,
|
||||
ensure_comment_relay=_ensure_comment_relay_started,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.exception(
|
||||
@@ -10913,11 +10980,16 @@ def create_runner_app(
|
||||
content=session_resource_view_to_dict(existing),
|
||||
)
|
||||
try:
|
||||
try:
|
||||
_pi_ensure_spec = await _resolve_session_agent_spec(session_id)
|
||||
except OmnigentError:
|
||||
_pi_ensure_spec = None
|
||||
terminal_view = await _auto_create_pi_terminal(
|
||||
session_id,
|
||||
resource_registry,
|
||||
_publish_event,
|
||||
server_client=server_client,
|
||||
agent_spec=_pi_ensure_spec,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.exception(
|
||||
@@ -10952,6 +11024,7 @@ def create_runner_app(
|
||||
resource_registry,
|
||||
_publish_event,
|
||||
server_client=server_client,
|
||||
ensure_comment_relay=_ensure_comment_relay_started,
|
||||
)
|
||||
except Exception as exc:
|
||||
_logger.exception(
|
||||
|
||||
@@ -31,10 +31,16 @@ import asyncio
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
# Default wait budget for a UI verdict, in seconds. Bounded so a
|
||||
# user who walked away doesn't pin a runner task forever; on
|
||||
# timeout the caller treats the elicitation as refused.
|
||||
_DEFAULT_WAIT_SECONDS: float = 120.0
|
||||
# Default wait budget for a UI verdict, in seconds. Held at one day
|
||||
# (86400s) — matching the deciding policy's default ``ask_timeout``: an ASK
|
||||
# is a human-in-the-loop gate and should outlive a user stepping away rather
|
||||
# than auto-refuse on its own. The old 120s default silently refused (treated
|
||||
# as DENY) any prompt a user didn't answer within two minutes — the
|
||||
# runner-side mirror of the cost-policy auto-resolve bug. Callers that resolve
|
||||
# a per-policy ``ask_timeout`` should still pass ``timeout_seconds`` explicitly;
|
||||
# this is only the fallback when none is provided. Headless/unattended agents
|
||||
# that want a fast fail-closed should pass a finite ``timeout_seconds``.
|
||||
_DEFAULT_WAIT_SECONDS: float = 86400.0
|
||||
|
||||
# Module-global registry: elicitation_id → asyncio.Future[bool].
|
||||
# True = approved, False = declined/timed-out. Future is owned by the
|
||||
|
||||
@@ -98,6 +98,15 @@ _SUBAGENT_POLICY_STATUSES = frozenset({"completed", "failed"})
|
||||
_SUBAGENT_INBOX_TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled"})
|
||||
_SUBAGENT_POLICY_FAILURE_OUTPUT = "[Result suppressed by policy: policy evaluation failed]"
|
||||
_SESSION_WRAPPER_LABEL_KEY = "omnigent.wrapper"
|
||||
# Read budget for runner→server message-send POSTs that are gated at the
|
||||
# recipient's REQUEST phase, which can PARK behind a human-approval ASK gate
|
||||
# (e.g. session_cost_budget) for the deciding policy's ``ask_timeout``. Held at
|
||||
# one day (86400s) — matching that default — so the send WAITS for the verdict
|
||||
# instead of severing the parked gate at a short read timeout (a 30s cut
|
||||
# previously fail-closed to DENY). Fast connect (30s) so an unreachable server
|
||||
# still fails out promptly. Guarded by tests/test_ask_timeout_infinite.py.
|
||||
_ASK_GATE_DELIVERY_READ_TIMEOUT_S: float = 86400.0
|
||||
_ASK_GATE_DELIVERY_TIMEOUT = httpx.Timeout(_ASK_GATE_DELIVERY_READ_TIMEOUT_S, connect=30.0)
|
||||
|
||||
# Read timeouts for the two MCP-proxy hops that carry a tool call back to the
|
||||
# runner (runner → Omnigent server → runner). ``sys_os_shell`` accepts caller-provided
|
||||
@@ -1186,7 +1195,12 @@ async def _execute_subagent_tool(
|
||||
"content": [{"type": "input_text", "text": str(message)}],
|
||||
},
|
||||
},
|
||||
timeout=30.0,
|
||||
# This message is gated at the recipient's REQUEST phase, which can
|
||||
# PARK on a human ASK (e.g. session_cost_budget) up to the policy's
|
||||
# ``ask_timeout``. A 30s read budget severed that park → fail-closed
|
||||
# /retry → duplicate cards. Wait for the real verdict (one-day read
|
||||
# budget, fast connect); a non-parking eval still returns immediately.
|
||||
timeout=_ASK_GATE_DELIVERY_TIMEOUT,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
_runner_app.unregister_child_session(child_session_id)
|
||||
@@ -1328,7 +1342,11 @@ async def _send_to_existing_session(
|
||||
"content": [{"type": "input_text", "text": message}],
|
||||
},
|
||||
},
|
||||
timeout=30.0,
|
||||
# Same as the other message-send: gated at the recipient's REQUEST
|
||||
# phase, which can PARK on a human ASK up to the policy's
|
||||
# ``ask_timeout``. Wait for the real verdict (one-day read budget,
|
||||
# fast connect) instead of severing at 30s and retrying into duplicates.
|
||||
timeout=_ASK_GATE_DELIVERY_TIMEOUT,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
_runner_app.unregister_child_session(target_session_id)
|
||||
|
||||
@@ -55,6 +55,7 @@ from omnigent.inner.executor import (
|
||||
TextChunk,
|
||||
ToolCallComplete,
|
||||
ToolCallRequest,
|
||||
TurnCancelled,
|
||||
TurnComplete,
|
||||
)
|
||||
from omnigent.inner.tracing import TracingContext, is_tracing_enabled
|
||||
@@ -440,6 +441,14 @@ class ExecutorAdapter(HarnessApp):
|
||||
tctx.end_agent_span(agent_span, response=response_text)
|
||||
agent_span = None
|
||||
return
|
||||
if isinstance(event, TurnCancelled):
|
||||
ctx.cancelled.set()
|
||||
if tctx is not None and agent_span is not None:
|
||||
from omnigent.runtime.telemetry import record_cancellation
|
||||
|
||||
record_cancellation(agent_span)
|
||||
tctx.end_agent_span(agent_span, response=None, status="ERROR")
|
||||
return
|
||||
if isinstance(event, ExecutorError):
|
||||
if tctx is not None and agent_span is not None:
|
||||
tctx.end_agent_span(
|
||||
|
||||
@@ -89,11 +89,14 @@ _HEARTBEAT_INTERVAL_S = 15.0
|
||||
_SHUTDOWN_GRACE_S = 4.5
|
||||
|
||||
# Timeout for the policy evaluation round-trip (harness → runner →
|
||||
# Omnigent server → runner → harness). Fail-open on expiry so a stalled
|
||||
# round-trip doesn't hang the executor indefinitely. Must be ≥
|
||||
# DEFAULT_POLICY_CLASSIFIER_TIMEOUT (30 s) since PromptPolicy
|
||||
# classifiers make their own LLM call on the Omnigent server side.
|
||||
_POLICY_EVAL_TIMEOUT_S = 35.0
|
||||
# Omnigent server → runner → harness). Held at one day (86400s) — matching
|
||||
# the deciding policy's default ``ask_timeout``: a TOOL_CALL/REQUEST ASK parks
|
||||
# server-side until a human answers, and this gate must block until the
|
||||
# verdict arrives rather than auto-resolve on a short cut (the cost-policy
|
||||
# bug). The server caps the real wait via the policy's ``ask_timeout``. On the
|
||||
# (now rare) expiry the fallback below is phase-aware — TOOL_CALL fails CLOSED
|
||||
# (DENY), advisory LLM/TOOL_RESULT phases fail OPEN (ALLOW).
|
||||
_POLICY_EVAL_TIMEOUT_S = 86400.0
|
||||
|
||||
# Per-turn IDLE watchdog: max gap WITHOUT progress before a wedged
|
||||
# ``run_turn`` becomes ``response.failed`` (vs heartbeating forever).
|
||||
|
||||
@@ -1476,10 +1476,12 @@ def _build_cursor_spawn_env(
|
||||
from omnigent.onboarding.cursor_auth import resolve_cursor_api_key
|
||||
|
||||
stored_key = resolve_cursor_api_key()
|
||||
if stored_key is not None:
|
||||
if stored_key:
|
||||
env["HARNESS_CURSOR_API_KEY"] = stored_key
|
||||
elif os.environ.get("CURSOR_API_KEY"):
|
||||
env["HARNESS_CURSOR_API_KEY"] = os.environ["CURSOR_API_KEY"]
|
||||
else:
|
||||
ambient_key = os.environ.get("CURSOR_API_KEY")
|
||||
if ambient_key and ambient_key.strip():
|
||||
env["HARNESS_CURSOR_API_KEY"] = ambient_key.strip()
|
||||
# Always set so the wrap doesn't fall back to ``"all"`` and override an
|
||||
# explicit ``skills: none`` from the spec (parity with the peer builders).
|
||||
env["HARNESS_CURSOR_SKILLS_FILTER"] = json.dumps(spec.skills_filter)
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""Origin precondition for POST routes that accept ``multipart/form-data``.
|
||||
|
||||
The JSON Content-Type guard in :mod:`omnigent.server.routes._content_type`
|
||||
closes the simple-request CSRF vector for JSON-bodied routes, but it does
|
||||
**not** help routes that legitimately accept ``multipart/form-data`` —
|
||||
that media type is itself CORS-safelisted, so a cross-site ``fetch`` with
|
||||
a ``FormData`` body reaches the handler with no preflight. For those
|
||||
routes the only reliable browser-CSRF signal is the ``Origin`` header.
|
||||
|
||||
This dependency applies the same policy the WebSocket layer already
|
||||
enforces (:func:`omnigent.server.ws_origin.origin_allowed`) so HTTP and
|
||||
WebSocket share exactly one trust boundary:
|
||||
|
||||
- a **missing** ``Origin`` is allowed. Modern browsers attach ``Origin``
|
||||
to every cross-site (and same-site state-changing) request — it is on
|
||||
the forbidden-header list, so page JS cannot strip or forge it — so an
|
||||
absent ``Origin`` is not a browser CSRF vector. Allowing it keeps the
|
||||
project's first-party non-browser clients (the Python SDK, the runner,
|
||||
the REPL) and any older client working without change. Those clients
|
||||
may still announce themselves explicitly with the sentinel ``Origin``
|
||||
below, but are not required to.
|
||||
- the first-party sentinel (:data:`OMNIGENT_INTERNAL_WS_ORIGIN`,
|
||||
``"omnigent://internal"``) and any origin in
|
||||
``OMNIGENT_WS_ALLOWED_ORIGINS`` pass;
|
||||
- in single-user **local mode** (no cookie / proxy auth) a present
|
||||
``Origin`` must be a loopback host — this is where the guard actually
|
||||
bites, since that deployment has no other CSRF defense;
|
||||
- in authenticated (cookie / proxy) modes a present ``Origin`` passes
|
||||
unless an explicit allowlist is configured.
|
||||
|
||||
Like the content-type guard, this is a FastAPI dependency (attached via
|
||||
``dependencies=[Depends(...)]``) that inspects only :attr:`Request.headers`
|
||||
and never reads the body, so the handler's own multipart / JSON parsing is
|
||||
unaffected.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import HTTPException, Request
|
||||
|
||||
from omnigent.server.auth import local_single_user_enabled
|
||||
from omnigent.server.ws_origin import origin_allowed, parse_allowed_origins
|
||||
|
||||
|
||||
def require_trusted_origin(request: Request) -> None:
|
||||
"""Reject a request whose ``Origin`` header is present but untrusted.
|
||||
|
||||
Use as a FastAPI dependency on state-changing routes that accept
|
||||
``multipart/form-data`` (which the JSON Content-Type guard cannot
|
||||
protect, because multipart is CORS-safelisted). The check delegates to
|
||||
the shared :func:`origin_allowed` policy, so a request passes when it
|
||||
has **no** ``Origin`` (modern browsers always send one, so absence is
|
||||
not a browser CSRF vector — this preserves backward compatibility for
|
||||
non-browser and older clients), carries the first-party sentinel or an
|
||||
allowlisted origin, or (in local single-user mode) carries a loopback
|
||||
``Origin``. A present ``Origin`` that is none of those is rejected.
|
||||
|
||||
First-party non-browser clients (the Python SDK, the runner, the REPL)
|
||||
may announce themselves with ``Origin: omnigent://internal``
|
||||
(:data:`OMNIGENT_INTERNAL_WS_ORIGIN`), but are not required to.
|
||||
|
||||
Only the request headers are inspected; the body is never read.
|
||||
|
||||
:param request: The incoming FastAPI request.
|
||||
:returns: None.
|
||||
:raises HTTPException: ``403`` when the ``Origin`` header is present but
|
||||
not trusted for the current auth mode.
|
||||
"""
|
||||
origin = request.headers.get("origin")
|
||||
# TEMPORARY fail-open on a missing Origin: ``origin_allowed`` permits
|
||||
# ``None`` so an absent header passes. This is a backward-compat measure
|
||||
# for clients not yet sending an Origin and is intended to be closed
|
||||
# soon — first-party clients (SDK, runner, the test harness) already
|
||||
# announce themselves with the sentinel Origin. To close it, reject a
|
||||
# ``None`` origin here before delegating.
|
||||
if origin_allowed(
|
||||
origin,
|
||||
local_mode=local_single_user_enabled(),
|
||||
extra_allowed=parse_allowed_origins(),
|
||||
):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
"Forbidden: this endpoint requires a trusted Origin header. "
|
||||
"It accepts multipart uploads, which are CORS-safelisted, so a "
|
||||
"trusted Origin is required to prevent cross-site request forgery."
|
||||
),
|
||||
)
|
||||
@@ -189,6 +189,7 @@ from omnigent.server.routes._content_type import (
|
||||
require_json_or_multipart_content_type,
|
||||
)
|
||||
from omnigent.server.routes._host_worktree import CreatedWorktree
|
||||
from omnigent.server.routes._origin import require_trusted_origin
|
||||
from omnigent.server.schemas import (
|
||||
AgentObject,
|
||||
ChildSessionSummary,
|
||||
@@ -2352,7 +2353,11 @@ def _resolve_llm_model(conv: Conversation | None) -> str | None:
|
||||
agent.id, agent.bundle_location, expand_env=agent.session_id is None
|
||||
)
|
||||
return loaded.spec.llm.model if loaded.spec.llm else None
|
||||
except (KeyError, AttributeError, ValueError, ImportError, OSError):
|
||||
except (KeyError, AttributeError, ValueError, ImportError, OSError, RuntimeError):
|
||||
# ``RuntimeError`` covers ``get_agent_cache()`` before the runtime is
|
||||
# initialized: this is a best-effort display resolver (now also called
|
||||
# on native cost-only broadcasts), so an uninitialized runtime must
|
||||
# degrade to "model unknown" — the cost still records, just unattributed.
|
||||
return None
|
||||
|
||||
|
||||
@@ -2832,14 +2837,32 @@ def _persist_native_cumulative_usage(
|
||||
+ int(current.get("output_tokens", 0))
|
||||
)
|
||||
|
||||
# Resolve the model only when tokens are present — both the token-pricing
|
||||
# branch and the per-model attribution below need it, and both are gated on
|
||||
# tokens. Resolving lazily avoids calling ``_resolve_llm_model`` (which
|
||||
# touches the runtime agent cache) on a cost-only broadcast. Computed once
|
||||
# out of the pricing-only branch so attribution works even on an unpriced
|
||||
# turn. The raw harness model id wins; falls back to the agent spec's model.
|
||||
# Resolve the model for per-model attribution on any broadcast that carries
|
||||
# tokens OR a priced cost — both the token-pricing branch and the per-model
|
||||
# attribution below need it. A cost-only broadcast must resolve it too:
|
||||
# claude-native forwards Claude Code's statusLine total (S) with NO token
|
||||
# counts, so gating model resolution on tokens alone dropped that cost from
|
||||
# ``by_model`` entirely — the per-model TOKEN USAGE view undercounted the
|
||||
# session total by every native (sub-)agent's spend, while the flat
|
||||
# ``total_cost_usd`` (and the Session-cost badge) still included it.
|
||||
# Priority mirrors the relay path's ``_accumulate_session_usage``: the
|
||||
# event's ``model`` (the statusLine's active model, forwarded alongside the
|
||||
# cost) wins, then the session's ``model_override`` (the forwarder mirrors
|
||||
# in-pane /model switches there), then the agent spec's static model.
|
||||
# Computed once out of the pricing-only branch so attribution works even on
|
||||
# an unpriced turn. (The agent-cache lookup in ``_resolve_llm_model`` is
|
||||
# memoized, so resolving on cost-only polls is cheap.)
|
||||
has_tokens = cin is not None or cout is not None
|
||||
model_name = (data.get("model") or _resolve_llm_model(conv)) if has_tokens else None
|
||||
needs_model = has_tokens or cost is not None
|
||||
model_name = (
|
||||
(
|
||||
data.get("model")
|
||||
or (conv.model_override if conv and conv.model_override else None)
|
||||
or _resolve_llm_model(conv)
|
||||
)
|
||||
if needs_model
|
||||
else None
|
||||
)
|
||||
if cost is not None:
|
||||
current["total_cost_usd"] = float(cost)
|
||||
elif has_tokens:
|
||||
@@ -2863,8 +2886,9 @@ def _persist_native_cumulative_usage(
|
||||
# switch the current model absorbs the cumulative (splitting deferred —
|
||||
# keyed on the raw harness model id). Cost mirrors the flat
|
||||
# ``total_cost_usd`` so the per-model cost key is present iff priced.
|
||||
# ``model_name`` is only set when tokens are present, so this is skipped
|
||||
# for cost-only broadcasts (nothing to attribute per-model).
|
||||
# ``model_name`` is set on token-bearing AND cost-bearing broadcasts, so a
|
||||
# claude-native cost-only broadcast attributes its cumulative cost here too
|
||||
# (token buckets stay absent — claude-native reports no token counts).
|
||||
if isinstance(model_name, str) and model_name:
|
||||
bucket = _model_usage_bucket(current, model_name)
|
||||
for key in _MODEL_TOKEN_KEYS:
|
||||
@@ -8861,11 +8885,12 @@ def _same_provider_family(a: Agent, b: Agent) -> bool:
|
||||
def _agent_is_native(agent: Agent) -> bool:
|
||||
"""Return whether an agent runs a native CLI harness.
|
||||
|
||||
Loads the agent's spec to read its ``harness_kind``. A native target
|
||||
(claude-native / codex-native) is the only case where a fork needs the
|
||||
transcript-rebuild carry path — SDK targets replay the Omnigent
|
||||
transcript as context on their own. Returns ``False`` when the bundle
|
||||
can't be loaded (treated as non-native).
|
||||
Loads the agent's spec to read its ``harness_kind``. Native targets run
|
||||
a vendor TUI in a terminal (claude-native / codex-native / pi-native /
|
||||
cursor-native). This is broader than "can replay fork history" — only
|
||||
claude/codex native carry the transcript-rebuild path; use
|
||||
``_agent_carries_native_fork_history`` for that narrower gate. Returns
|
||||
``False`` when the bundle can't be loaded (treated as non-native).
|
||||
|
||||
:param agent: The agent whose harness to classify.
|
||||
:returns: ``True`` for a native CLI harness, else ``False``.
|
||||
@@ -8883,6 +8908,43 @@ def _agent_is_native(agent: Agent) -> bool:
|
||||
return is_native_harness(spec.executor.harness_kind)
|
||||
|
||||
|
||||
# Native harnesses that record a resumable session and can rebuild a fork's
|
||||
# transcript from copied Omnigent items. Both spellings are listed because
|
||||
# canonicalize_harness passes the reversed native ids through unchanged (only
|
||||
# "native-pi" is aliased) — same reasoning as model_override._CLAUDE_FAMILY_HARNESSES.
|
||||
# cursor/pi native are intentionally absent: they can't replay fork history.
|
||||
_FORK_HISTORY_NATIVE_HARNESSES: frozenset[str] = frozenset(
|
||||
{"claude-native", "native-claude", "codex-native", "native-codex"}
|
||||
)
|
||||
|
||||
|
||||
def _agent_carries_native_fork_history(agent: Agent) -> bool:
|
||||
"""Return whether *agent*'s native harness can replay fork history.
|
||||
|
||||
Only claude-native / codex-native record a resumable native session and
|
||||
can rebuild a fork's transcript from the copied Omnigent items. cursor-
|
||||
native and pi-native are native CLIs but cannot carry chat history into a
|
||||
fork (no resumable external_session_id and their TUIs can't import a
|
||||
transcript), so stamping ``carry_history_into_native`` for them would be a
|
||||
false promise — the fork launches fresh regardless. Returns ``False`` when
|
||||
the bundle can't be loaded (treated as non-carrying).
|
||||
|
||||
:param agent: The agent whose harness to classify.
|
||||
:returns: ``True`` only for fork-history-capable native harnesses.
|
||||
"""
|
||||
from omnigent.harness_aliases import canonicalize_harness
|
||||
|
||||
try:
|
||||
spec = (
|
||||
get_agent_cache()
|
||||
.load(agent.id, agent.bundle_location, expand_env=agent.session_id is None)
|
||||
.spec
|
||||
)
|
||||
except Exception: # noqa: BLE001 — unloadable bundle → treat as non-carrying
|
||||
return False
|
||||
return canonicalize_harness(spec.executor.harness_kind) in _FORK_HISTORY_NATIVE_HARNESSES
|
||||
|
||||
|
||||
def _native_coding_agent_for_agent(agent: Agent) -> NativeCodingAgent | None:
|
||||
"""
|
||||
Return native coding-agent metadata for an agent's harness.
|
||||
@@ -12062,7 +12124,14 @@ def create_sessions_router(
|
||||
# CSRF hardening: this route dispatches on Content-Type (JSON vs
|
||||
# multipart bundled-create), so reject text/plain and other simple
|
||||
# types up front while still allowing both legitimate body shapes.
|
||||
dependencies=[Depends(require_json_or_multipart_content_type)],
|
||||
# The multipart shape is CORS-safelisted, so the content-type guard
|
||||
# alone can't stop a cross-site bundle upload — require_trusted_origin
|
||||
# closes that gap (allows absent Origin for non-browser SDK/runner
|
||||
# clients; in local mode a present Origin must be loopback).
|
||||
dependencies=[
|
||||
Depends(require_json_or_multipart_content_type),
|
||||
Depends(require_trusted_origin),
|
||||
],
|
||||
)
|
||||
async def create_session(
|
||||
request: Request,
|
||||
@@ -13559,7 +13628,12 @@ def create_sessions_router(
|
||||
# items (the converters consume Omnigent's normalized item shape, so
|
||||
# the source harness doesn't matter). SDK targets replay the
|
||||
# transcript as context regardless, so the marker is inert for them.
|
||||
carry_history_into_native = await asyncio.to_thread(_agent_is_native, base_agent)
|
||||
# Only claude/codex native can replay fork history; cursor/pi native
|
||||
# can't (no resumable session, TUI can't import a transcript), so don't
|
||||
# stamp a carry-history promise they'd silently break with a fresh launch.
|
||||
carry_history_into_native = await asyncio.to_thread(
|
||||
_agent_carries_native_fork_history, base_agent
|
||||
)
|
||||
# The source's native session id is only resumable by a target in the
|
||||
# SAME provider family — a Claude target can't clone a Codex rollout.
|
||||
# Cross-family, the store must skip the fork-source directive so the
|
||||
@@ -13747,7 +13821,12 @@ def create_sessions_router(
|
||||
copy_model_settings = await asyncio.to_thread(
|
||||
_same_provider_family, current_agent, target_agent
|
||||
)
|
||||
carry_history_into_native = await asyncio.to_thread(_agent_is_native, target_agent)
|
||||
# Only claude/codex native can replay fork history; cursor/pi native
|
||||
# can't (no resumable session, TUI can't import a transcript), so don't
|
||||
# stamp a carry-history promise they'd silently break with a fresh launch.
|
||||
carry_history_into_native = await asyncio.to_thread(
|
||||
_agent_carries_native_fork_history, target_agent
|
||||
)
|
||||
presentation_labels = await asyncio.to_thread(_presentation_labels_for_agent, target_agent)
|
||||
|
||||
# Resolve the built-in the session is leaving so the UI can offer a
|
||||
@@ -15201,6 +15280,12 @@ def create_sessions_router(
|
||||
"/sessions/{session_id}/resources/files",
|
||||
status_code=201,
|
||||
response_model=None,
|
||||
# CSRF hardening: this route only accepts multipart/form-data, which
|
||||
# is CORS-safelisted, so a content-type guard can't stop a cross-site
|
||||
# upload. require_trusted_origin closes the gap (allows absent Origin
|
||||
# for the non-browser SDK/runner clients; in local mode a present
|
||||
# Origin must be loopback).
|
||||
dependencies=[Depends(require_trusted_origin)],
|
||||
)
|
||||
async def upload_session_file(
|
||||
request: Request,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user