Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d33863faeb | |||
| 5d7aa85132 | |||
| 243b13abb3 | |||
| d20c465457 | |||
| f443086bd6 | |||
| 203eb3e589 | |||
| 981c4fff3e | |||
| 8103829ef8 | |||
| 03b6ee51f5 | |||
| a8b030b786 | |||
| 0a4a8114ad | |||
| f7861ec494 | |||
| eeec1cedf0 | |||
| 3d537bba59 | |||
| 67f9db8bc8 | |||
| 2c2afac192 | |||
| 733234c303 | |||
| 039661185d | |||
| 4b6779febb | |||
| f914095c1d | |||
| 269dffacb6 | |||
| 55459d5df9 | |||
| 2fbd660f55 | |||
| 275465d862 | |||
| 93ce9a957e | |||
| a101840bbc | |||
| 5689ef33ee | |||
| 9a0e24213b | |||
| 1732faf3f3 | |||
| bb5de03e50 | |||
| fd9a7d94ff | |||
| 8e02d27bc3 |
@@ -21,6 +21,7 @@ REVIEW_LABEL = "waiting-for-review"
|
||||
WAITING_DAYS = 7
|
||||
CANONICAL_REPO = "omnigent-ai/omnigent"
|
||||
MAX_CLOSURES_PER_RUN = 30
|
||||
REVIEW_EVENTS = {"pull_request_review", "pull_request_review_comment"}
|
||||
|
||||
|
||||
def label_names(item: dict[str, Any]) -> list[str]:
|
||||
@@ -114,6 +115,16 @@ class GitHubAPI:
|
||||
pull, _ = self.request("GET", f"/repos/{self.repo}/pulls/{pull_number}")
|
||||
return pull
|
||||
|
||||
def get_review(self, pull_number: int, review_id: int) -> dict[str, Any]:
|
||||
review, _ = self.request(
|
||||
"GET", f"/repos/{self.repo}/pulls/{pull_number}/reviews/{review_id}"
|
||||
)
|
||||
return review
|
||||
|
||||
def get_review_comment(self, comment_id: int) -> dict[str, Any]:
|
||||
comment, _ = self.request("GET", f"/repos/{self.repo}/pulls/comments/{comment_id}")
|
||||
return comment
|
||||
|
||||
def remove_label(self, issue_number: int, label: str) -> bool:
|
||||
quoted = urllib.parse.quote(label, safe="")
|
||||
try:
|
||||
@@ -355,10 +366,14 @@ def apply_waiting_on_maintainer_activity(
|
||||
pull_number = payload["pull_request"]["number"]
|
||||
review = payload.get("review") or {}
|
||||
actor = (review.get("user") or {}).get("login")
|
||||
review_state = (review.get("state") or "").lower()
|
||||
# An approval asks nothing of the author; it means the PR is ready.
|
||||
if (review.get("state") or "").lower() == "approved":
|
||||
if review_state == "approved":
|
||||
print(f"#{pull_number}: approving review, leaving the label alone.")
|
||||
return False
|
||||
if review_state not in {"commented", "changes_requested"}:
|
||||
print(f"#{pull_number}: {review_state or 'unknown'} review, leaving the label alone.")
|
||||
return False
|
||||
if is_slash_command(review.get("body")):
|
||||
return False
|
||||
reason = "a maintainer reviewed"
|
||||
@@ -473,6 +488,42 @@ def close_stale_waiting_prs(api: GitHubAPI, now: datetime | None = None) -> int:
|
||||
return closed
|
||||
|
||||
|
||||
def relay_integer(record: dict[str, Any], field: str) -> int:
|
||||
value = record.get(field)
|
||||
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
||||
raise ValueError(f"Relay field {field!r} must be a positive integer")
|
||||
return value
|
||||
|
||||
|
||||
def hydrate_relay_event(
|
||||
record: dict[str, Any], api: GitHubAPI, repo: str, expected_event: str
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
event_name = record.get("event_name")
|
||||
if event_name not in REVIEW_EVENTS:
|
||||
raise ValueError(f"Unsupported relayed event: {event_name!r}")
|
||||
if event_name != expected_event:
|
||||
raise ValueError(
|
||||
f"Relayed event {event_name!r} does not match workflow event {expected_event!r}"
|
||||
)
|
||||
|
||||
pull_number = relay_integer(record, "pull_number")
|
||||
activity_id = relay_integer(record, "activity_id")
|
||||
pull = api.get_pull(pull_number)
|
||||
base_repo = ((pull.get("base") or {}).get("repo") or {}).get("full_name") or ""
|
||||
if base_repo.lower() != repo.lower():
|
||||
raise ValueError(f"Relayed PR #{pull_number} targets {base_repo!r}, not {repo!r}")
|
||||
|
||||
if event_name == "pull_request_review":
|
||||
review = api.get_review(pull_number, activity_id)
|
||||
return event_name, {"pull_request": pull, "review": review}
|
||||
|
||||
comment = api.get_review_comment(activity_id)
|
||||
expected_url = f"https://api.github.com/repos/{repo}/pulls/{pull_number}"
|
||||
if comment.get("pull_request_url") != expected_url:
|
||||
raise ValueError(f"Review comment {activity_id} does not belong to PR #{pull_number}")
|
||||
return event_name, {"pull_request": pull, "comment": comment}
|
||||
|
||||
|
||||
def run(
|
||||
event_name: str,
|
||||
payload: dict[str, Any],
|
||||
@@ -495,8 +546,7 @@ def run(
|
||||
apply_waiting_on_maintainer_activity(event_name, payload, api)
|
||||
|
||||
|
||||
def load_event_payload() -> dict[str, Any]:
|
||||
path = os.environ.get("GITHUB_EVENT_PATH")
|
||||
def load_json(path: str | None) -> dict[str, Any]:
|
||||
if not path:
|
||||
return {}
|
||||
with open(path, encoding="utf-8") as handle:
|
||||
@@ -509,8 +559,15 @@ def main() -> int:
|
||||
if not token:
|
||||
print("GITHUB_TOKEN is required", file=sys.stderr)
|
||||
return 1
|
||||
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
|
||||
run(event_name, load_event_payload(), GitHubAPI(token, repo), repo)
|
||||
api = GitHubAPI(token, repo)
|
||||
relay_path = os.environ.get("WAITING_ON_AUTHOR_RELAY_PATH")
|
||||
if relay_path:
|
||||
expected_event = os.environ.get("WAITING_ON_AUTHOR_RELAY_EVENT", "")
|
||||
event_name, payload = hydrate_relay_event(load_json(relay_path), api, repo, expected_event)
|
||||
else:
|
||||
event_name = os.environ.get("GITHUB_EVENT_NAME", "")
|
||||
payload = load_json(os.environ.get("GITHUB_EVENT_PATH"))
|
||||
run(event_name, payload, api, repo)
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
@@ -63,6 +63,8 @@ class FakeAPI:
|
||||
issue_comments: dict[int, list[dict[str, Any]]] | None = None,
|
||||
review_comments: dict[int, list[dict[str, Any]]] | None = None,
|
||||
reviews: dict[int, list[dict[str, Any]]] | None = None,
|
||||
review_by_id: dict[tuple[int, int], dict[str, Any]] | None = None,
|
||||
review_comment_by_id: dict[int, dict[str, Any]] | None = None,
|
||||
commits: dict[int, list[dict[str, Any]]] | None = None,
|
||||
writers: list[str] | None = None,
|
||||
):
|
||||
@@ -73,6 +75,8 @@ class FakeAPI:
|
||||
self.issue_comments = issue_comments or {}
|
||||
self.review_comments = review_comments or {}
|
||||
self.reviews = reviews or {}
|
||||
self.review_by_id = review_by_id or {}
|
||||
self.review_comment_by_id = review_comment_by_id or {}
|
||||
self.commits = commits or {}
|
||||
self.removed: list[tuple[int, str]] = []
|
||||
self.closed: list[int] = []
|
||||
@@ -83,6 +87,12 @@ class FakeAPI:
|
||||
def get_pull(self, pull_number: int) -> dict[str, Any]:
|
||||
return self.pull | {"number": pull_number}
|
||||
|
||||
def get_review(self, pull_number: int, review_id: int) -> dict[str, Any]:
|
||||
return self.review_by_id[(pull_number, review_id)]
|
||||
|
||||
def get_review_comment(self, comment_id: int) -> dict[str, Any]:
|
||||
return self.review_comment_by_id[comment_id]
|
||||
|
||||
def remove_label(self, issue_number: int, label: str) -> bool:
|
||||
self.removed.append((issue_number, label))
|
||||
return True
|
||||
@@ -442,6 +452,21 @@ class AutoWaitingOnAuthorTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(api.added, [])
|
||||
|
||||
def test_dismissed_review_leaves_the_label_alone(self) -> None:
|
||||
api = self.dispatch(
|
||||
"pull_request_review",
|
||||
{
|
||||
"pull_request": {"number": 12},
|
||||
"review": {
|
||||
"user": {"login": "maintainer1"},
|
||||
"state": "dismissed",
|
||||
"body": "stale feedback",
|
||||
},
|
||||
},
|
||||
pull=pr(labels=[]),
|
||||
)
|
||||
self.assertEqual(api.added, [])
|
||||
|
||||
def test_commenting_review_applies_the_label(self) -> None:
|
||||
api = self.dispatch(
|
||||
"pull_request_review",
|
||||
@@ -483,6 +508,99 @@ class AutoWaitingOnAuthorTest(unittest.TestCase):
|
||||
)
|
||||
self.assertEqual(api.added, [(12, waiting_on_author.LABEL)])
|
||||
|
||||
def test_relayed_review_rehydrates_trusted_api_data(self) -> None:
|
||||
pull = pr(labels=[]) | {"base": {"repo": {"full_name": waiting_on_author.CANONICAL_REPO}}}
|
||||
api = FakeAPI(
|
||||
pull=pull,
|
||||
review_by_id={
|
||||
(12, 41): {
|
||||
"id": 41,
|
||||
"user": {"login": "maintainer1"},
|
||||
"state": "changes_requested",
|
||||
"body": "please fix",
|
||||
}
|
||||
},
|
||||
)
|
||||
event, payload = waiting_on_author.hydrate_relay_event(
|
||||
{"event_name": "pull_request_review", "pull_number": 12, "activity_id": 41},
|
||||
api,
|
||||
waiting_on_author.CANONICAL_REPO,
|
||||
"pull_request_review",
|
||||
)
|
||||
|
||||
waiting_on_author.run(event, payload, api, waiting_on_author.CANONICAL_REPO)
|
||||
|
||||
self.assertEqual(api.added, [(12, waiting_on_author.LABEL)])
|
||||
|
||||
def test_relayed_review_comment_rehydrates_author_reply(self) -> None:
|
||||
pull = pr(author="alice") | {
|
||||
"base": {"repo": {"full_name": waiting_on_author.CANONICAL_REPO}}
|
||||
}
|
||||
api = FakeAPI(
|
||||
pull=pull,
|
||||
review_comment_by_id={
|
||||
73: {
|
||||
"id": 73,
|
||||
"user": {"login": "alice"},
|
||||
"body": "fixed",
|
||||
"pull_request_url": (
|
||||
"https://api.github.com/repos/omnigent-ai/omnigent/pulls/12"
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
event, payload = waiting_on_author.hydrate_relay_event(
|
||||
{
|
||||
"event_name": "pull_request_review_comment",
|
||||
"pull_number": 12,
|
||||
"activity_id": 73,
|
||||
},
|
||||
api,
|
||||
waiting_on_author.CANONICAL_REPO,
|
||||
"pull_request_review_comment",
|
||||
)
|
||||
|
||||
waiting_on_author.run(event, payload, api, waiting_on_author.CANONICAL_REPO)
|
||||
|
||||
self.assertEqual(api.removed, [(12, waiting_on_author.LABEL)])
|
||||
|
||||
def test_relayed_review_comment_must_match_pull(self) -> None:
|
||||
pull = pr() | {"base": {"repo": {"full_name": waiting_on_author.CANONICAL_REPO}}}
|
||||
api = FakeAPI(
|
||||
pull=pull,
|
||||
review_comment_by_id={
|
||||
73: {
|
||||
"id": 73,
|
||||
"pull_request_url": (
|
||||
"https://api.github.com/repos/omnigent-ai/omnigent/pulls/99"
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "does not belong"):
|
||||
waiting_on_author.hydrate_relay_event(
|
||||
{
|
||||
"event_name": "pull_request_review_comment",
|
||||
"pull_number": 12,
|
||||
"activity_id": 73,
|
||||
},
|
||||
api,
|
||||
waiting_on_author.CANONICAL_REPO,
|
||||
"pull_request_review_comment",
|
||||
)
|
||||
|
||||
def test_relayed_event_must_match_workflow_event(self) -> None:
|
||||
api = FakeAPI()
|
||||
|
||||
with self.assertRaisesRegex(ValueError, "does not match workflow event"):
|
||||
waiting_on_author.hydrate_relay_event(
|
||||
{"event_name": "pull_request_review", "pull_number": 12, "activity_id": 41},
|
||||
api,
|
||||
waiting_on_author.CANONICAL_REPO,
|
||||
"pull_request_review_comment",
|
||||
)
|
||||
|
||||
def test_applying_clears_waiting_for_review(self) -> None:
|
||||
api = self.dispatch(
|
||||
"issue_comment",
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
name: Waiting on Author Review Run
|
||||
|
||||
# Privileged half of the fork-review relay. The artifact contains numeric IDs
|
||||
# only; the script re-fetches the PR and review/comment from GitHub before using
|
||||
# actor identity, review state, or comment content. No PR code is checked out.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: [Waiting on Author Review]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: waiting-on-author-review-run-${{ github.event.workflow_run.head_sha }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
hygiene:
|
||||
if: >-
|
||||
github.repository == 'omnigent-ai/omnigent'
|
||||
&& github.event.workflow_run.conclusion == 'success'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Download recorded review event IDs
|
||||
id: download
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const { owner, repo } = context.repo;
|
||||
const arts = await github.rest.actions.listWorkflowRunArtifacts({
|
||||
owner, repo, run_id: context.payload.workflow_run.id,
|
||||
});
|
||||
const art = arts.data.artifacts.find(
|
||||
a => a.name === 'waiting-on-author-review-event'
|
||||
);
|
||||
if (!art) {
|
||||
core.info('No fork-review artifact; the direct review job handled this event.');
|
||||
core.setOutput('found', 'false');
|
||||
return;
|
||||
}
|
||||
const dl = await github.rest.actions.downloadArtifact({
|
||||
owner, repo, artifact_id: art.id, archive_format: 'zip',
|
||||
});
|
||||
fs.writeFileSync(
|
||||
`${process.env.RUNNER_TEMP}/waiting-on-author-review.zip`,
|
||||
Buffer.from(dl.data)
|
||||
);
|
||||
core.setOutput('found', 'true');
|
||||
|
||||
- name: Check out trusted .github
|
||||
if: steps.download.outputs.found == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github
|
||||
persist-credentials: false
|
||||
|
||||
- name: Apply relayed waiting-on-author state
|
||||
if: steps.download.outputs.found == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
RELAY_ZIP: ${{ runner.temp }}/waiting-on-author-review.zip
|
||||
RELAY_DIR: ${{ runner.temp }}/waiting-on-author-review
|
||||
WAITING_ON_AUTHOR_RELAY_EVENT: ${{ github.event.workflow_run.event }}
|
||||
WAITING_ON_AUTHOR_RELAY_PATH: ${{ runner.temp }}/waiting-on-author-review/event.json
|
||||
run: |
|
||||
mkdir -p "$RELAY_DIR"
|
||||
unzip -q "$RELAY_ZIP" -d "$RELAY_DIR"
|
||||
python3 .github/scripts/waiting_on_author.py
|
||||
@@ -0,0 +1,71 @@
|
||||
name: Waiting on Author Review
|
||||
|
||||
# Review events on fork PRs receive a read-only token. Same-repo reviews run the
|
||||
# hygiene script directly; fork reviews record only GitHub-provided numeric IDs
|
||||
# for the privileged workflow_run consumer. No PR code is checked out.
|
||||
|
||||
on:
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: waiting-on-author-review-${{ github.event.pull_request.number }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
hygiene:
|
||||
if: >-
|
||||
github.repository == 'omnigent-ai/omnigent'
|
||||
&& github.event.pull_request.head.repo.full_name == github.repository
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check out .github
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
sparse-checkout: .github
|
||||
persist-credentials: false
|
||||
- name: Update waiting-on-author state
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: python3 .github/scripts/waiting_on_author.py
|
||||
|
||||
record:
|
||||
if: >-
|
||||
github.repository == 'omnigent-ai/omnigent'
|
||||
&& github.event.pull_request.head.repo.full_name != github.repository
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Record review event IDs
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PULL_NUMBER: ${{ github.event.pull_request.number }}
|
||||
ACTIVITY_ID: ${{ github.event.review.id || github.event.comment.id }}
|
||||
run: |
|
||||
mkdir -p relay
|
||||
jq -n \
|
||||
--arg event_name "$EVENT_NAME" \
|
||||
--argjson pull_number "$PULL_NUMBER" \
|
||||
--argjson activity_id "$ACTIVITY_ID" \
|
||||
'{event_name: $event_name, pull_number: $pull_number, activity_id: $activity_id}' \
|
||||
> relay/event.json
|
||||
- name: Upload review event IDs
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: waiting-on-author-review-event
|
||||
path: relay/event.json
|
||||
retention-days: 1
|
||||
if-no-files-found: error
|
||||
@@ -9,6 +9,8 @@ on:
|
||||
- .github/scripts/waiting_on_author.py
|
||||
- .github/scripts/waiting_on_author_test.py
|
||||
- .github/workflows/waiting-on-author.yml
|
||||
- .github/workflows/waiting-on-author-review.yml
|
||||
- .github/workflows/waiting-on-author-review-run.yml
|
||||
- .github/workflows/waiting-on-author-test.yml
|
||||
workflow_dispatch:
|
||||
|
||||
|
||||
@@ -6,16 +6,14 @@ name: Waiting on Author Hygiene
|
||||
# once a review is submitted). The two labels are mutually exclusive. PRs left
|
||||
# waiting on the author for 7 days are closed. The workflow runs from trusted
|
||||
# default-branch code and never checks out PR-authored files.
|
||||
# Review events use the read-only -> workflow_run relay in
|
||||
# waiting-on-author-review.yml and waiting-on-author-review-run.yml.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [synchronize, labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_review_comment:
|
||||
types: [created]
|
||||
pull_request_review:
|
||||
types: [submitted]
|
||||
schedule:
|
||||
- cron: "0 */12 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -159,6 +159,13 @@ POSTGRES_PASSWORD=change-me-please
|
||||
|
||||
# ── Optional OIDC tuning ─────────────────────────────────
|
||||
# OMNIGENT_OIDC_SESSION_TTL_HOURS=8
|
||||
#
|
||||
# Absolute lifetime (days) of login-issued refresh grants — how long a
|
||||
# host/CLI that logged in with `omnigent login` can keep renewing its
|
||||
# access before a human must log in again. Default 30. Unattended hosts
|
||||
# renew automatically within this window; each host replica needs its
|
||||
# own login (refresh tokens rotate — shared copies revoke each other).
|
||||
# OMNIGENT_GRANT_MAX_LIFETIME_DAYS=30
|
||||
# OMNIGENT_OIDC_LOGOUT_REDIRECT_URI=https://omnigent.example.com/
|
||||
#
|
||||
# Skip the email_verified claim check on id_tokens. Some IdPs (e.g.
|
||||
|
||||
+88
-12
@@ -17,9 +17,11 @@
|
||||
> enforcement in `omnigent/server/auth.py` (`delegated_path_allowed`,
|
||||
> `set_grant_revocation_check`). Wired in `omnigent/server/app.py`,
|
||||
> **opt-in and default-off** via `OMNIGENT_DEVICE_GRANT_ENABLED` (the
|
||||
> `/oauth/*` routes are unmounted unless it is truthy), and then only in
|
||||
> **accounts** mode (OIDC delegates login to the IdP via the cli-ticket
|
||||
> flow and never mounts these routes).
|
||||
> `/oauth/device/*` consent routes are unmounted unless it is truthy), and
|
||||
> then only in **accounts** mode (OIDC delegates login to the IdP via the
|
||||
> cli-ticket flow and never mounts the consent routes). The token/revoke
|
||||
> half (`/oauth/token`, `/oauth/revoke`) mounts **unconditionally** in both
|
||||
> accounts and OIDC modes: login-issued refresh grants (below) need it.
|
||||
> Slack: `integrations/slack/src/omnigent_slack/oauth.py`,
|
||||
> `tokens.py` (Fernet-encrypted `oauth_tokens`), `auth_manager.py`, plus
|
||||
> the bearer/refresh wiring in `omnigent.py` (`ClientAuth`,
|
||||
@@ -182,15 +184,20 @@ approved it.
|
||||
|
||||
### Router `omnigent/server/routes/device_auth.py`
|
||||
|
||||
Mounted in `app.py` only when **`OMNIGENT_DEVICE_GRANT_ENABLED` is truthy**
|
||||
(opt-in, **default-off** — the `/oauth/*` routes are absent otherwise), and
|
||||
then **only in `accounts` mode** (OIDC delegates login to the IdP via the
|
||||
cli-ticket flow and never mounts these routes; header mode has no
|
||||
server-mintable identity — see `create_device_auth_router`, which raises if
|
||||
constructed for any other source). The `device_grants` table is created
|
||||
unconditionally by the migration regardless of the flag; only the router
|
||||
mount is gated. This router **owns** `mint_delegated_token` and
|
||||
`DELEGATED_SCOPE`.
|
||||
The RFC 8628 consent surface (`/oauth/device/*`) is mounted in `app.py`
|
||||
only when **`OMNIGENT_DEVICE_GRANT_ENABLED` is truthy** (opt-in,
|
||||
**default-off**), and then **only in `accounts` mode** (the in-browser
|
||||
consent needs the accounts login page; header mode has no server-mintable
|
||||
identity — see `create_device_auth_router`, which raises if constructed for
|
||||
any other source). The token/revoke half is factored into
|
||||
`create_oauth_token_router` and mounts **unconditionally** in both accounts
|
||||
and OIDC modes — login-issued refresh grants need `/oauth/token` even where
|
||||
the device flow is off; a standalone mount refuses the `device_code` grant
|
||||
type with `unsupported_grant_type`. The `device_grants` table is created
|
||||
unconditionally by the migration regardless of the flag; only the consent
|
||||
mount is gated. This router also **owns** `mint_delegated_token` and
|
||||
`DELEGATED_SCOPE` (moved here from `oidc.py`, which retains only
|
||||
`mint_session_token` / `mint_session_cookie`).
|
||||
|
||||
- `POST /oauth/device/authorize` — **public** (rate-limited). Generates a
|
||||
high-entropy `device_code` (`secrets.token_urlsafe`, stored **hashed**), a
|
||||
@@ -355,3 +362,72 @@ stateless. This added invariant is the main thing for reviewers to scrutinize.
|
||||
- Applying the same delegated grant to other non-browser clients (the CLI could
|
||||
use it too, superseding the in-memory `_cli_tickets` store).
|
||||
- Per-scope consent granularity beyond the single "session APIs, no admin" scope.
|
||||
|
||||
## Login-issued refresh grants
|
||||
|
||||
The fix for unattended hosts dying at session-JWT expiry (a host
|
||||
authenticated via `omnigent login` previously had **no renewal path** —
|
||||
the stored `{token, user_id, expires_at}` record simply lapsed, default
|
||||
8 h, and the next tunnel reconnect got a misleading 403).
|
||||
|
||||
- **Issuance** — both interactive login flows create a grant born
|
||||
`redeemed` (`DeviceGrantStore.create_redeemed_grant`; the interactive
|
||||
login *is* the consent step, so no device-code dance): the OIDC
|
||||
cli-ticket fulfillment always, and accounts `POST /auth/login` when the
|
||||
body carries `issue_refresh: true` (sent by the CLI, never the web
|
||||
form — a browser must not receive refresh material). The raw refresh
|
||||
token rides back once (`/auth/cli-poll` / the login response) as an
|
||||
optional `refresh_token` key — old CLIs ignore it, new CLIs against old
|
||||
servers see it absent. `client_id` is `"omnigent-cli"`.
|
||||
- **Authority** — a refreshed login-grant token carries `grant_id` (so
|
||||
revocation still kills it) but **no `scope` claim**, so the delegated
|
||||
path allowlist does not apply: it renews the session JWT and keeps that
|
||||
same authority. Scoping it like a third-party device grant instead made
|
||||
every non-allowlisted route (`/v1/usage`, `/v1/scheduled-tasks`,
|
||||
`/v1/policy-registry`) 401 after the first refresh. `LOGIN_GRANT_CLIENT_ID`
|
||||
is the marker and is **reserved** — `/oauth/device/authorize` refuses a
|
||||
request naming it, so a device client cannot self-declare into this class.
|
||||
- **No rotation** — login grants return the SAME refresh token on every
|
||||
renewal. Rotation + reuse detection is right for a browser-adjacent
|
||||
third-party client, but for an unattended host it turns any ambiguous
|
||||
network failure (lost response, crash between the server committing and
|
||||
the client persisting) into a permanently revoked grant. Only the
|
||||
short-lived access token is renewed; revocation and the absolute lifetime
|
||||
cap still bound exposure. Device grants keep rotating.
|
||||
- **Renewal** — `omnigent.cli_auth.refresh_stored_token` POSTs
|
||||
`grant_type=refresh_token`, persists the result, and returns the
|
||||
fresh access token. The runner/host auth-token factory
|
||||
calls it when the stored token lapses, and the host tunnel rebuilds
|
||||
headers through that factory on every reconnect — so an unattended
|
||||
host renews itself for the grant's lifetime. Refreshes on one machine
|
||||
are serialized by an advisory file lock with a re-check after acquire,
|
||||
so a losing racer picks up the winner's rotated pair instead of
|
||||
replaying the stale token (which reuse detection would punish by
|
||||
revoking the grant).
|
||||
- **One grant per host replica (recommended)** — since login grants do
|
||||
not rotate, N replicas sharing one token file no longer revoke each
|
||||
other. A per-replica login is still preferred so a single host can be
|
||||
revoked without cutting off the rest.
|
||||
- **Lifetime** — the absolute grant lifetime stays 30 days by default;
|
||||
`OMNIGENT_GRANT_MAX_LIFETIME_DAYS` lets an operator extend it
|
||||
deliberately for long-lived unattended hosts. Invalid values fall back
|
||||
to the default (never fail open to unbounded).
|
||||
- **Client-secret interaction** — `OMNIGENT_DEVICE_CLIENT_SECRET` gates
|
||||
the endpoints that mint from an ephemeral code (device authorize + the
|
||||
`device_code` exchange). Refresh and revoke are NOT gated: the presented
|
||||
refresh/access token is itself the credential, and a CLI renewing its own
|
||||
login has no way to carry that secret (gating it made every automatic
|
||||
refresh 401 in a Slack-serving deployment).
|
||||
- **Housekeeping** — `/oauth/token` opportunistically purges expired and
|
||||
aged-out grants. The device flow purges on `authorize`, which a
|
||||
standalone token-router mount does not have, so login-grant rows would
|
||||
otherwise accumulate one per login.
|
||||
|
||||
### Known limitation
|
||||
|
||||
General CLI commands (`omnigent usage`, session commands, direct
|
||||
remote-URL chat) still read the stored token without attempting a refresh —
|
||||
only the host/runner auth-token factory renews today. An expired login with
|
||||
valid refresh material therefore leaves those commands unauthenticated
|
||||
until something on the host path renews the shared file. Wiring the
|
||||
remaining CLI entrypoints is follow-up work.
|
||||
|
||||
@@ -72,11 +72,22 @@ they still work with no runner or LLM.
|
||||
|
||||
| Journey | Operation timed |
|
||||
| --- | --- |
|
||||
| `native_hook_spawn` | Spawn the per-chunk `MessageDisplay` hook exactly as Claude Code does — isolated interpreter, module entrypoint, JSON payload on stdin |
|
||||
| `native_hook_spawn` | Spawn one **Python** command hook — isolated interpreter, module entrypoint, JSON payload on stdin — and time its whole lifetime |
|
||||
|
||||
Claude Code **blocks its TUI** on command hooks, so one hook subprocess's
|
||||
lifetime is user-visible streaming latency, and the same interpreter+import
|
||||
cost fronts every statusline refresh and per-tool-call policy hook. The
|
||||
lifetime is user-visible latency. Read this number as *"what a hook costs if it
|
||||
is Python"*.
|
||||
|
||||
It is **not** the per-chunk streaming cost, and treating it as one leads
|
||||
straight to wasted work. The hooks that fire per chunk (`MessageDisplay`) and
|
||||
per tool call (`evaluate-policy`) were deliberately moved off the interpreter —
|
||||
a `/bin/sh` appender and a `curl` to the runner's relay — and
|
||||
`test_message_display_shell_command_round_trips` pins that by asserting
|
||||
`"python"` is absent from the installed command. What still pays this number is
|
||||
the per-turn set (`SessionStart` / `Stop` / `UserPromptSubmit` / `PreCompact` /
|
||||
`Task*`), the `PostToolUse` `TodoWrite`+`TaskUpdate` matchers, and the policy
|
||||
hook's Python fallback before the relay is up. So the journey's real job is to
|
||||
keep the argument for staying off the interpreter measurable. The
|
||||
journey needs no server or runner; registering it here rides hook spawn cost
|
||||
on the same nightly/release regression comparison as everything else
|
||||
(`omnigent/__init__` re-exports lazily so this stays ~interpreter-sized). The
|
||||
|
||||
@@ -881,12 +881,21 @@ async def _measure_cli_startup(env: BenchEnvironment, _ctx: JourneyContext) -> N
|
||||
# ── native hook spawn (no server involved) ───────────────────
|
||||
|
||||
# Claude Code blocks its TUI on command hooks, so one hook subprocess's whole
|
||||
# lifetime is user-visible latency: the MessageDisplay hook runs once per
|
||||
# streamed text chunk, and the same interpreter+import cost fronts every
|
||||
# statusline refresh and per-tool-call policy hook. Spawn the per-chunk hook
|
||||
# exactly as Claude Code does — isolated interpreter, module entrypoint, JSON
|
||||
# payload on stdin — and time the full process lifetime. The import-graph side
|
||||
# of this guarantee is pinned by tests/test_claude_native_message_display_hook.
|
||||
# lifetime is user-visible latency. This journey times that lifetime for a
|
||||
# Python hook — isolated interpreter, module entrypoint, JSON payload on stdin —
|
||||
# which is the cost of ANY hook the bridge installs as a `python -m` command.
|
||||
#
|
||||
# It is NOT the per-chunk streaming path. The hooks that fire per chunk
|
||||
# (MessageDisplay) and per tool call (evaluate-policy) were deliberately moved
|
||||
# off the interpreter — a /bin/sh appender and a curl to the runner's relay
|
||||
# respectively — and tests pin that (`test_message_display_shell_command_round_trips`
|
||||
# asserts "python" is absent from the installed command). What still pays this
|
||||
# is the per-turn set (SessionStart / Stop / UserPromptSubmit / PreCompact /
|
||||
# Task*), the PostToolUse TodoWrite+TaskUpdate matchers, and the policy hook's
|
||||
# Python fallback when the relay is not yet up. So read this number as
|
||||
# "what a hook costs if it is Python", and as the standing argument for keeping
|
||||
# the hot paths off it — not as a per-chunk cost. The import-graph side is
|
||||
# pinned by tests/test_claude_native_message_display_hook.py.
|
||||
_HOOK_SPAWN_PAYLOAD = json.dumps(
|
||||
{
|
||||
"hook_event_name": "MessageDisplay",
|
||||
@@ -905,7 +914,7 @@ async def _setup_hook_spawn(env: BenchEnvironment) -> JourneyContext:
|
||||
|
||||
|
||||
async def _measure_hook_spawn(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
"""Spawn the MessageDisplay hook once, as Claude Code does, and wait."""
|
||||
"""Spawn one Python hook subprocess and wait, as Claude Code would."""
|
||||
del env
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
@@ -1082,7 +1091,12 @@ ALL_JOURNEYS: dict[str, Journey] = {
|
||||
measure=_measure_hook_spawn,
|
||||
setup=_setup_hook_spawn,
|
||||
teardown=_teardown_hook_spawn,
|
||||
description="Spawn the per-chunk MessageDisplay hook exactly as Claude Code does.",
|
||||
description=(
|
||||
"Spawn one Python command hook (isolated interpreter, module "
|
||||
"entrypoint) and time its whole lifetime — what any `python -m` "
|
||||
"hook costs Claude's blocked TUI. Not the per-chunk path: that "
|
||||
"one is a /bin/sh appender."
|
||||
),
|
||||
),
|
||||
Journey(
|
||||
name="cli_startup",
|
||||
|
||||
@@ -800,10 +800,13 @@ async def _prepare_antigravity_terminal_via_daemon(
|
||||
_update_progress(startup_progress, "Creating Antigravity session...")
|
||||
bridge_id = _mint_agy_conversation_id()
|
||||
conversation_id = bridge_id
|
||||
session_id = await _create_antigravity_session(
|
||||
client,
|
||||
session_bundle,
|
||||
bridge_id=bridge_id,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_antigravity_session(
|
||||
client,
|
||||
session_bundle,
|
||||
bridge_id=bridge_id,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_progress(startup_progress, "Loading Antigravity session...")
|
||||
@@ -847,7 +850,8 @@ async def _prepare_antigravity_terminal_via_daemon(
|
||||
conversation_id = external if isinstance(external, str) and external else bridge_id
|
||||
resume = isinstance(external, str) and bool(external)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
+19
-14
@@ -1595,22 +1595,23 @@ async def _prepare_chat_session_via_daemon(
|
||||
)
|
||||
from omnigent.native_terminal import bind_session_runner
|
||||
|
||||
try:
|
||||
async def resolve_session() -> tuple[str, bool]:
|
||||
"""Fork, resume, or create the session to bind, and say if it is fresh.
|
||||
|
||||
:returns: The session id and whether it was created just now.
|
||||
:raises click.ClickException: If the server rejects the create/fork.
|
||||
"""
|
||||
async with OmnigentClient(base_url=base_url, headers=headers, auth=auth) as sdk:
|
||||
try:
|
||||
if fork_session_id is not None:
|
||||
fork_result = await sdk.sessions.fork(fork_session_id)
|
||||
session_id = fork_result["id"]
|
||||
fresh_session = False
|
||||
elif resume_conversation_id is not None:
|
||||
session_id = resume_conversation_id
|
||||
fresh_session = False
|
||||
else:
|
||||
created = await sdk.sessions.create(
|
||||
bundle, filename="agent.tar.gz", workspace=workspace
|
||||
)
|
||||
session_id = created.id
|
||||
fresh_session = True
|
||||
return fork_result["id"], False
|
||||
if resume_conversation_id is not None:
|
||||
return resume_conversation_id, False
|
||||
created = await sdk.sessions.create(
|
||||
bundle, filename="agent.tar.gz", workspace=workspace
|
||||
)
|
||||
return created.id, True
|
||||
except ClientOmnigentError as exc:
|
||||
# Any create/fork/resume rejection here is a server-side answer, not
|
||||
# a client bug worth a traceback: a wrong base URL that answers
|
||||
@@ -1621,6 +1622,7 @@ async def _prepare_chat_session_via_daemon(
|
||||
f"Could not start a session on {base_url}: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
# A separate raw httpx client for the host-runner protocol (the daemon
|
||||
# launch helpers operate on httpx, not the SDK), pinned to the host's replica.
|
||||
timeout = httpx.Timeout(30.0, read=120.0)
|
||||
@@ -1629,8 +1631,11 @@ async def _prepare_chat_session_via_daemon(
|
||||
) as client:
|
||||
if progress is not None:
|
||||
progress.update(STARTUP_PHASE_CONNECTING)
|
||||
await wait_for_host_online(
|
||||
client, host_id, timeout_s=_DAEMON_CHAT_HOST_ONLINE_TIMEOUT_S
|
||||
(session_id, fresh_session), _ = await asyncio.gather(
|
||||
resolve_session(),
|
||||
wait_for_host_online(
|
||||
client, host_id, timeout_s=_DAEMON_CHAT_HOST_ONLINE_TIMEOUT_S
|
||||
),
|
||||
)
|
||||
if progress is not None:
|
||||
progress.update(STARTUP_PHASE_LAUNCHING_AGENT)
|
||||
|
||||
@@ -55,6 +55,21 @@ CUSTOM_MODEL_OPTION_ENV_VAR = "ANTHROPIC_CUSTOM_MODEL_OPTION"
|
||||
#: takes — so it is not part of the vocabulary below.
|
||||
CUSTOM_MODEL_OPTION_NAME_ENV_VAR = "ANTHROPIC_CUSTOM_MODEL_OPTION_NAME"
|
||||
|
||||
#: BACK-COMPAT. Omnigent's picker-row id for the custom slot. Named for the
|
||||
#: model the slot first carried (Sonnet 5, which had no family alias of its
|
||||
#: own), but a Smart Routing launch pins ITS model there, so the id does not
|
||||
#: describe the contents — read the slot, never this name. Sessions persist
|
||||
#: it as a model override, so retiring it needs a migration; slated for
|
||||
#: removal in 0.10.0 along with the row, once the ``sonnet`` pin is Sonnet 5.
|
||||
LEGACY_CUSTOM_SLOT_ROW_ID = "sonnet_5"
|
||||
|
||||
#: BACK-COMPAT. Spellings the pre-0.10 substring test read as "this is the
|
||||
#: custom slot's model", kept because :func:`normalized_model_id` does not
|
||||
#: fold a vendor-prefixed ``anthropic/claude-sonnet-5`` onto the catalog id
|
||||
#: the slot holds. Consulted only after an exact match misses; retired with
|
||||
#: :data:`LEGACY_CUSTOM_SLOT_ROW_ID` in 0.10.0.
|
||||
LEGACY_CUSTOM_SLOT_SPELLINGS: tuple[str, ...] = ("sonnet-5", "sonnet_5")
|
||||
|
||||
#: Launch-env keys that define this session's model vocabulary.
|
||||
MODEL_VOCABULARY_ENV_VARS: tuple[str, ...] = (
|
||||
*ALIAS_MODEL_ENV_VARS.values(),
|
||||
@@ -162,6 +177,14 @@ def claude_model_alias(
|
||||
candidate = model.strip().lower()
|
||||
if candidate in CLAUDE_MODEL_ALIASES:
|
||||
return candidate
|
||||
# Bracket variants of the family aliases (``sonnet[1m]``) are settable
|
||||
# aliases in their own right — the harness enumerates them in /model's
|
||||
# usage line and resolves the marker itself (the family pin plus the
|
||||
# marker on a pinned env). Stepping one down to its family would
|
||||
# silently drop the marker; refusing it blocks a switch the pane accepts.
|
||||
base, bracket, marker = candidate.partition("[")
|
||||
if bracket and marker.endswith("]") and base in CLAUDE_MODEL_ALIASES:
|
||||
return candidate
|
||||
pins = alias_pins(env)
|
||||
normalized = normalized_model_id(model)
|
||||
for alias, pinned in pins.items():
|
||||
@@ -202,4 +225,14 @@ def claude_model_command_arg(
|
||||
custom = environ.get(CUSTOM_MODEL_OPTION_ENV_VAR, "").strip()
|
||||
if custom and normalized_model_id(custom) == normalized_model_id(model):
|
||||
return custom
|
||||
candidate = model.strip()
|
||||
if not alias_pins(env) and candidate.lower().startswith("claude-"):
|
||||
# A full Anthropic model id names an EXACT generation, and ``/model``
|
||||
# on an unpinned (canonical-endpoint) session accepts full ids
|
||||
# verbatim — the same spelling the harness's own enumeration
|
||||
# resolves. Stepping down to the family alias here would switch to
|
||||
# claude's CURRENT generation of that family instead (picking
|
||||
# "Opus 4.8 (1M context)" used to type ``/model opus`` and land on
|
||||
# Opus 5).
|
||||
return candidate
|
||||
return claude_model_alias(model, env)
|
||||
|
||||
+570
-53
@@ -81,8 +81,11 @@ from omnigent._wrapper_labels import (
|
||||
)
|
||||
from omnigent.claude_launcher import resolve_claude_launch
|
||||
from omnigent.claude_model_vocabulary import (
|
||||
ALIAS_MODEL_ENV_VARS,
|
||||
CUSTOM_MODEL_OPTION_ENV_VAR,
|
||||
CUSTOM_MODEL_OPTION_NAME_ENV_VAR,
|
||||
LEGACY_CUSTOM_SLOT_ROW_ID,
|
||||
claude_model_alias,
|
||||
)
|
||||
from omnigent.claude_native_bridge import (
|
||||
BRIDGE_ID_LABEL_KEY,
|
||||
@@ -112,7 +115,6 @@ from omnigent.host.daemon_launch import (
|
||||
wait_for_host_online,
|
||||
wait_for_runner_online,
|
||||
)
|
||||
from omnigent.model_fallbacks import static_model_fallback
|
||||
from omnigent.native_coding_agents import native_shell_terminal_spec
|
||||
from omnigent.native_terminal import (
|
||||
DAEMON_HOST_ONLINE_TIMEOUT_S as _DAEMON_HOST_ONLINE_TIMEOUT_S,
|
||||
@@ -132,7 +134,6 @@ from omnigent.native_terminal import (
|
||||
from omnigent.native_terminal import (
|
||||
terminal_attach_url as _attach_url,
|
||||
)
|
||||
from omnigent.onboarding.provider_config import SUBSCRIPTION_KIND
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
WS_CLOSE_TERMINAL_DETACHED,
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND,
|
||||
@@ -193,6 +194,18 @@ _CLAUDE_CODE_NESTED_SESSION_ENV = "CLAUDECODE"
|
||||
_CLAUDE_CODE_API_KEY_HELPER_TTL_ENV = "CLAUDE_CODE_API_KEY_HELPER_TTL_MS"
|
||||
_CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS_ENV = "CLAUDE_CODE_DISABLE_EXPERIMENTAL_BETAS"
|
||||
_CLAUDE_CODE_USE_GATEWAY_ENV = "CLAUDE_CODE_USE_GATEWAY"
|
||||
#: Kill-switch Claude Code treats as covering nonessential startup traffic;
|
||||
#: the probe strips it so speed knobs never mask harness output.
|
||||
_CLAUDE_NONESSENTIAL_TRAFFIC_ENV = "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"
|
||||
_CLAUDE_MODEL_PROBE_TIMEOUT_S = 20.0
|
||||
#: Wall-clock cap for the per-alias resolution fan-out as a whole; aliases
|
||||
#: still unresolved when it expires keep their bare rows (the cache's
|
||||
#: revalidation retries them later). Startup dominates each run and
|
||||
#: stretches with box load (measured 0.7s–17s for the same command), so the
|
||||
#: concurrency covers a whole alias set in one wave and the budget fits one
|
||||
#: slow wave.
|
||||
_CLAUDE_ALIAS_RESOLUTION_BUDGET_S = 30.0
|
||||
_CLAUDE_ALIAS_RESOLUTION_CONCURRENCY = 12
|
||||
_CLAUDE_CODE_ENABLE_TOOL_SEARCH_ENV = "ENABLE_TOOL_SEARCH"
|
||||
_CLAUDE_CODE_CUSTOM_HEADERS_ENV = "ANTHROPIC_CUSTOM_HEADERS"
|
||||
# Claude Code forwards the ANTHROPIC_CUSTOM_HEADERS value verbatim as
|
||||
@@ -233,15 +246,8 @@ _UCODE_CLAUDE_TIER_TO_ENV: dict[str, str] = {
|
||||
# See https://code.claude.com/docs/en/model-config#custom-model-options
|
||||
_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV = CUSTOM_MODEL_OPTION_ENV_VAR
|
||||
_ANTHROPIC_CUSTOM_MODEL_OPTION_NAME_ENV = CUSTOM_MODEL_OPTION_NAME_ENV_VAR
|
||||
_UCODE_CLAUDE_CUSTOM_TIER = "sonnet_5"
|
||||
_UCODE_CLAUDE_CUSTOM_TIER = LEGACY_CUSTOM_SLOT_ROW_ID
|
||||
_UCODE_CLAUDE_CUSTOM_TIER_LABEL = "Sonnet 5"
|
||||
_CLAUDE_NATIVE_STATIC_MODEL_OPTIONS: tuple[tuple[str, str], ...] = (
|
||||
("fable", "Fable"),
|
||||
("opus", "Opus"),
|
||||
("sonnet", "Sonnet 4.6"),
|
||||
(_UCODE_CLAUDE_CUSTOM_TIER, _UCODE_CLAUDE_CUSTOM_TIER_LABEL),
|
||||
("haiku", "Haiku"),
|
||||
)
|
||||
_DEFAULT_UCODE_AUTH_REFRESH_INTERVAL_MS = 900_000
|
||||
_SESSION_LABELS = {
|
||||
"omnigent.ui": "terminal",
|
||||
@@ -409,6 +415,57 @@ def _serves_canonical_anthropic_ids(claude_config: ClaudeNativeUcodeConfig) -> b
|
||||
return host == "anthropic.com" or host.endswith(".anthropic.com")
|
||||
|
||||
|
||||
def _claude_family(token: str) -> str | None:
|
||||
"""
|
||||
The family alias a model id or alias folds onto, bracket markers dropped.
|
||||
|
||||
:param token: A picker id or model id, e.g. ``"opus[1m]"``,
|
||||
``"claude-opus-4-8"``.
|
||||
:returns: The family alias, e.g. ``"opus"``, or ``None`` for none.
|
||||
"""
|
||||
from omnigent.claude_model_vocabulary import claude_model_alias
|
||||
|
||||
alias = claude_model_alias(token, {})
|
||||
return alias.partition("[")[0] if alias else None
|
||||
|
||||
|
||||
def claude_catalog_serves_model(
|
||||
rows: list[dict[str, object]],
|
||||
model: str,
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
) -> bool:
|
||||
"""
|
||||
Whether a launch of *model* is backed by this config's catalog.
|
||||
|
||||
An exact row — a picker id or its wire model — always serves. A canonical
|
||||
Anthropic id no row spells exactly still launches when the endpoint takes
|
||||
canonical spellings (``--model`` passes any string through, and a pane's
|
||||
``/model`` persists exactly this id) and the catalog lists the id's
|
||||
family: the same family fold ``/model`` applies to an unpinned canonical
|
||||
id. A gateway that routes only its own ids, and a family the catalog
|
||||
does not list, refuse — a genuinely stale pick still fails fast.
|
||||
|
||||
:param rows: Catalog rows, e.g.
|
||||
``[{"id": "opus", "model": "claude-opus-5"}]``.
|
||||
:param model: A picker id or model id, e.g. ``"claude-opus-4-8"``.
|
||||
:param claude_config: The resolved launch config, or ``None`` (Claude's
|
||||
own login).
|
||||
:returns: ``True`` when the launch can run *model* against this catalog.
|
||||
"""
|
||||
from omnigent.model_catalog_store import catalog_contains
|
||||
|
||||
if catalog_contains(rows, model):
|
||||
return True
|
||||
if claude_config is not None and not _serves_canonical_anthropic_ids(claude_config):
|
||||
return False
|
||||
if not model.lower().startswith("claude-"):
|
||||
return False
|
||||
family = _claude_family(model)
|
||||
return family is not None and any(
|
||||
_claude_family(str(row.get("id") or row.get("model") or "")) == family for row in rows
|
||||
)
|
||||
|
||||
|
||||
def resolve_claude_native_model_selection(
|
||||
model: str | None,
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
@@ -419,28 +476,21 @@ def resolve_claude_native_model_selection(
|
||||
extra Sonnet 5 row uses Omnigent's ``sonnet_5`` id because it occupies
|
||||
Claude Code's provider-configured custom model slot. Resolve that id to
|
||||
the exact custom option, preserving provider suffixes such as ``[1m]``.
|
||||
Direct Claude logins have no provider config, so they use the canonical
|
||||
Anthropic model id.
|
||||
Direct Claude logins have no provider config, so the pick degrades to
|
||||
the ``sonnet`` family alias, which Claude resolves itself.
|
||||
|
||||
On a gateway/Bedrock endpoint, a family alias with no tier pin (launch env
|
||||
or managed settings) resolves to the provider's default model — Claude
|
||||
Code would canonicalize it to an Anthropic id the endpoint rejects.
|
||||
Every other pick passes through verbatim: picker rows are pin-backed or
|
||||
vouched for by the harness's own probe, so rewriting one switches the
|
||||
pane to a model nobody chose (an unpinned family alias used to degrade
|
||||
to the provider's default model this way — a Fable pick landed on
|
||||
Opus). An out-of-band unpinned alias on a gateway endpoint now fails
|
||||
visibly at inference instead of silently running the default.
|
||||
|
||||
:param model: Persisted picker id, built-in alias, or concrete model id.
|
||||
:param claude_config: Resolved provider config for the terminal.
|
||||
:returns: A model identifier suitable for ``--model`` or ``/model``.
|
||||
"""
|
||||
if model != _UCODE_CLAUDE_CUSTOM_TIER:
|
||||
tier_env = _UCODE_CLAUDE_TIER_TO_ENV.get(model or "")
|
||||
if (
|
||||
tier_env is not None
|
||||
and claude_config is not None
|
||||
and not claude_config.env.get(tier_env)
|
||||
and not _serves_canonical_anthropic_ids(claude_config)
|
||||
):
|
||||
managed = _managed_claude_model_config()
|
||||
if managed is None or not managed.env.get(tier_env):
|
||||
return claude_config.model or model
|
||||
return model
|
||||
if claude_config is not None:
|
||||
custom_model = claude_config.env.get(_ANTHROPIC_CUSTOM_MODEL_OPTION_ENV)
|
||||
@@ -451,26 +501,9 @@ def resolve_claude_native_model_selection(
|
||||
return provider_fallback
|
||||
if claude_config.model:
|
||||
return claude_config.model
|
||||
fallback = static_model_fallback(SUBSCRIPTION_KIND, "claude")
|
||||
if fallback is None:
|
||||
raise ValueError("Claude subscription fallback has no routable Sonnet model")
|
||||
exact_match = next(
|
||||
(
|
||||
model_id
|
||||
for model_id in fallback.model_ids
|
||||
if _claude_model_display_name("sonnet", model_id) == _UCODE_CLAUDE_CUSTOM_TIER_LABEL
|
||||
),
|
||||
None,
|
||||
)
|
||||
if exact_match is not None:
|
||||
return exact_match
|
||||
family_match = next(
|
||||
(model_id for model_id in fallback.model_ids if "claude-sonnet-" in model_id.lower()),
|
||||
None,
|
||||
)
|
||||
if family_match is None:
|
||||
raise ValueError("Claude subscription fallback has no routable Sonnet model")
|
||||
return family_match
|
||||
# No provider config pins the custom slot: hand Claude Code its own
|
||||
# ``sonnet`` alias and let it resolve the current Sonnet itself.
|
||||
return "sonnet"
|
||||
|
||||
|
||||
def claude_config_with_routed_arms_pinned(
|
||||
@@ -705,15 +738,472 @@ def claude_native_model_options(
|
||||
if not model_id:
|
||||
return []
|
||||
return [{"id": model_id, "model": model_id, "displayName": model_id, "isDefault": True}]
|
||||
return [
|
||||
{
|
||||
"id": model_id,
|
||||
"model": model_id,
|
||||
"displayName": label,
|
||||
"isDefault": False,
|
||||
}
|
||||
for model_id, label in _CLAUDE_NATIVE_STATIC_MODEL_OPTIONS
|
||||
# No curated fallback: an unconfigured shape's rows come from the probe
|
||||
# (the harness's own enumeration) or not at all — a hand-written list
|
||||
# here is exactly how a frozen "Sonnet 4.6" once shipped.
|
||||
return []
|
||||
|
||||
|
||||
def _parse_claude_model_aliases(stdout: str) -> list[str]:
|
||||
"""
|
||||
Extract the alias list from ``claude -p "/model"``'s printed usage line.
|
||||
|
||||
The harness prints e.g. ``Usage: /model <name>. Available: sonnet, opus,
|
||||
haiku, fable, best, sonnet[1m], opusplan, default, or a full model ID.``
|
||||
— its own enumeration of every settable alias. Parsing keeps zero model
|
||||
knowledge here: entries are taken verbatim, and only the trailing prose
|
||||
fragment (anything with whitespace) is dropped.
|
||||
|
||||
:param stdout: The probe run's stdout.
|
||||
:returns: Alias tokens in the harness's order; empty when no line parses.
|
||||
"""
|
||||
for line in stdout.splitlines():
|
||||
_, marker, tail = line.partition("Available:")
|
||||
if not marker:
|
||||
continue
|
||||
aliases: list[str] = []
|
||||
for entry in tail.split(","):
|
||||
token = entry.strip().rstrip(".")
|
||||
if token and " " not in token:
|
||||
aliases.append(token)
|
||||
return aliases
|
||||
return []
|
||||
|
||||
|
||||
def _parse_claude_current_model(stdout: str) -> dict[str, str]:
|
||||
"""
|
||||
Extract the resolved model from a stream-json ``/model`` probe run.
|
||||
|
||||
Two harness-owned facts, taken verbatim: the ``init`` event's exact
|
||||
model id, and the printed ``Current model:`` label with only the
|
||||
trailing ``(effort: …)`` suffix stripped — so labels like
|
||||
``Opus 4.8 (1M context)`` survive untouched.
|
||||
|
||||
:param stdout: The run's ``--output-format stream-json`` stdout.
|
||||
:returns: Whichever of ``{"model": …, "label": …}`` parsed.
|
||||
"""
|
||||
resolved: dict[str, str] = {}
|
||||
for line in stdout.splitlines():
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(event, dict):
|
||||
continue
|
||||
if event.get("type") == "system" and event.get("subtype") == "init":
|
||||
model = event.get("model")
|
||||
if isinstance(model, str) and model:
|
||||
resolved["model"] = model
|
||||
if event.get("type") == "result":
|
||||
for text_line in str(event.get("result", "")).splitlines():
|
||||
_, marker, tail = text_line.partition("Current model:")
|
||||
if not marker:
|
||||
continue
|
||||
label = re.sub(r"\s*\(effort:[^)]*\)\s*$", "", tail).strip()
|
||||
if label:
|
||||
resolved["label"] = label
|
||||
break
|
||||
return resolved
|
||||
|
||||
|
||||
def _claude_model_probe_invocation(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
extra_args: Sequence[str] = (),
|
||||
) -> tuple[str, list[str], dict[str, str]]:
|
||||
"""
|
||||
Assemble one headless ``/model`` probe invocation.
|
||||
|
||||
Shared by the alias-enumeration run and the per-alias resolution runs
|
||||
so the two cannot drift: same launch resolution, session env, speed
|
||||
env, and env-unset list.
|
||||
|
||||
:param claude_config: The resolved native launch config, or ``None``.
|
||||
:param extra_args: Appended CLI args (e.g. ``--model <alias>``).
|
||||
:returns: ``(command, launch_args, env)`` ready to exec.
|
||||
"""
|
||||
from omnigent.claude_launcher import resolve_claude_launch
|
||||
|
||||
args = [
|
||||
"-p",
|
||||
"/model",
|
||||
# The probe asks one client-side question; the MCP fleet, session
|
||||
# persistence, and background chatter are irrelevant startup weight.
|
||||
"--strict-mcp-config",
|
||||
"--mcp-config",
|
||||
'{"mcpServers":{}}',
|
||||
"--no-session-persistence",
|
||||
*extra_args,
|
||||
]
|
||||
if claude_config is not None and claude_config.api_key_helper:
|
||||
args.extend(("--settings", json.dumps({"apiKeyHelper": claude_config.api_key_helper})))
|
||||
command, launch_args = resolve_claude_launch("claude", args)
|
||||
env = dict(os.environ)
|
||||
env.update(build_native_claude_terminal_env(claude_config))
|
||||
env.update(
|
||||
{
|
||||
"DISABLE_TELEMETRY": "1",
|
||||
"DISABLE_ERROR_REPORTING": "1",
|
||||
"DISABLE_AUTOUPDATER": "1",
|
||||
}
|
||||
)
|
||||
# Mirrors the native terminal's env-unset list, plus the nonessential-
|
||||
# traffic kill-switch, so speed knobs never mask harness output.
|
||||
env.pop("DATABRICKS_CONFIG_PROFILE", None)
|
||||
env.pop(_CLAUDE_CODE_NESTED_SESSION_ENV, None)
|
||||
env.pop(_CLAUDE_NONESSENTIAL_TRAFFIC_ENV, None)
|
||||
if claude_config is not None and claude_config.api_key_helper:
|
||||
env.pop(_ANTHROPIC_API_KEY_ENV, None)
|
||||
return command, launch_args, env
|
||||
|
||||
|
||||
async def _resolve_claude_model_alias(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
alias: str,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Ask the harness what one printed alias resolves to.
|
||||
|
||||
A ``--model <alias>`` run in stream-json mode carries the exact model
|
||||
id in its init event and the human label in its ``Current model:``
|
||||
line; both are the harness's own resolution, never computed here.
|
||||
|
||||
:param claude_config: The resolved native launch config, or ``None``.
|
||||
:param alias: The alias exactly as the harness printed it.
|
||||
:returns: Whichever of ``{"model": …, "label": …}`` resolved; empty on
|
||||
any failure (the alias keeps its bare row).
|
||||
"""
|
||||
command, launch_args, env = _claude_model_probe_invocation(
|
||||
claude_config,
|
||||
("--model", alias, "--output-format", "stream-json", "--verbose"),
|
||||
)
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
command,
|
||||
*launch_args,
|
||||
cwd=str(Path.home()),
|
||||
env=env,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except OSError:
|
||||
_logger.debug("Claude alias resolution could not launch for %r", alias, exc_info=True)
|
||||
return {}
|
||||
try:
|
||||
async with asyncio.timeout(_CLAUDE_MODEL_PROBE_TIMEOUT_S):
|
||||
stdout, _stderr = await process.communicate()
|
||||
except (TimeoutError, asyncio.CancelledError) as exc:
|
||||
if process.returncode is None:
|
||||
process.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await process.wait()
|
||||
if isinstance(exc, asyncio.CancelledError):
|
||||
raise
|
||||
_logger.debug("Claude alias resolution timed out for %r", alias)
|
||||
return {}
|
||||
if process.returncode != 0:
|
||||
_logger.debug("Claude alias resolution exited %s for %r", process.returncode, alias)
|
||||
return {}
|
||||
return _parse_claude_current_model(stdout.decode(errors="replace"))
|
||||
|
||||
|
||||
async def _resolve_claude_model_aliases(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
aliases: Sequence[str],
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
Resolve every printed alias concurrently, best-effort.
|
||||
|
||||
Bounded fan-out under one overall budget: whatever resolved in time is
|
||||
kept and the rest stay bare, so a hung harness cannot stretch the
|
||||
probe indefinitely.
|
||||
|
||||
:param claude_config: The resolved native launch config, or ``None``.
|
||||
:param aliases: The harness-printed aliases.
|
||||
:returns: Non-empty resolutions keyed by alias.
|
||||
"""
|
||||
if not aliases:
|
||||
return {}
|
||||
semaphore = asyncio.Semaphore(_CLAUDE_ALIAS_RESOLUTION_CONCURRENCY)
|
||||
|
||||
async def _bounded(alias: str) -> dict[str, str]:
|
||||
async with semaphore:
|
||||
return await _resolve_claude_model_alias(claude_config, alias)
|
||||
|
||||
tasks = {alias: asyncio.create_task(_bounded(alias)) for alias in aliases}
|
||||
try:
|
||||
async with asyncio.timeout(_CLAUDE_ALIAS_RESOLUTION_BUDGET_S):
|
||||
await asyncio.gather(*tasks.values())
|
||||
except TimeoutError:
|
||||
for task in tasks.values():
|
||||
task.cancel()
|
||||
await asyncio.gather(*tasks.values(), return_exceptions=True)
|
||||
done = sum(1 for task in tasks.values() if not task.cancelled())
|
||||
_logger.warning(
|
||||
"Claude alias resolution budget expired; keeping %d/%d resolutions",
|
||||
done,
|
||||
len(tasks),
|
||||
)
|
||||
return {
|
||||
alias: task.result()
|
||||
for alias, task in tasks.items()
|
||||
if not task.cancelled() and task.exception() is None and task.result()
|
||||
}
|
||||
|
||||
|
||||
def _claude_alias_row(alias: str, resolution: dict[str, str]) -> dict[str, object]:
|
||||
"""
|
||||
One picker row for a printed alias, shown as its resolution.
|
||||
|
||||
``id`` stays the alias — launches pass it through unchanged — while the
|
||||
display shows only the resolved label. Labels are normalized so every
|
||||
1M-context resolution (a ``[1m]``-suffixed model id) says so; the
|
||||
harness omits the marker for some of them (e.g. ``sonnet[1m]`` prints
|
||||
just "Sonnet 5").
|
||||
|
||||
:param alias: The harness-printed alias.
|
||||
:param resolution: Its resolution, possibly empty.
|
||||
:returns: The picker row.
|
||||
"""
|
||||
label = resolution.get("label")
|
||||
model = resolution.get("model") or alias
|
||||
if label and model.endswith("[1m]") and "1M context" not in label:
|
||||
label = f"{label} (1M context)"
|
||||
return {"id": alias, "model": model, "displayName": label or alias}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ClaudeModelProbe:
|
||||
"""One harness enumeration: picker rows plus the bare-launch default.
|
||||
|
||||
:param alias_rows: The printed aliases as deduplicated picker rows.
|
||||
:param default_model: The model the enumeration run itself launched on
|
||||
(its init event's ``model``) — what a no-pick launch of this config
|
||||
actually runs — or ``None`` when unreadable.
|
||||
:param default_label: The harness's own label for *default_model*, or
|
||||
``None``.
|
||||
"""
|
||||
|
||||
alias_rows: list[dict[str, object]]
|
||||
default_model: str | None = None
|
||||
default_label: str | None = None
|
||||
|
||||
|
||||
def _parse_claude_enumeration_aliases(stdout: str) -> list[str]:
|
||||
"""Extract the alias list from a stream-json enumeration run.
|
||||
|
||||
The ``Available:`` line lives inside the ``result`` event's text on a
|
||||
stream-json run; falls back to scanning the raw output so a plain-text
|
||||
run still parses.
|
||||
|
||||
:param stdout: The enumeration run's decoded stdout.
|
||||
:returns: Alias names, e.g. ``["sonnet", "opus", ...]``.
|
||||
"""
|
||||
for line in stdout.splitlines():
|
||||
line = line.strip()
|
||||
if not line.startswith("{"):
|
||||
continue
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except ValueError:
|
||||
continue
|
||||
if isinstance(event, dict) and event.get("type") == "result":
|
||||
result_text = event.get("result")
|
||||
if isinstance(result_text, str):
|
||||
aliases = _parse_claude_model_aliases(result_text)
|
||||
if aliases:
|
||||
return aliases
|
||||
return _parse_claude_model_aliases(stdout)
|
||||
|
||||
|
||||
async def probe_claude_model_options(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
) -> ClaudeModelProbe | None:
|
||||
"""
|
||||
Ask Claude Code itself which models it would offer, and its default.
|
||||
|
||||
The harness is the source of truth: a short ``claude -p "/model"`` run
|
||||
(stream-json, so its init event also names the model a bare launch of
|
||||
this config actually runs — the truthful "Default") makes Claude Code
|
||||
print its own alias list. Each printed alias is then resolved to its
|
||||
concrete model by a per-alias harness run. All outputs are read
|
||||
verbatim; no selection semantics are replicated here. Runs for every
|
||||
config shape, including the bare subscription launch (``None`` config).
|
||||
|
||||
:param claude_config: The resolved native launch config
|
||||
(:func:`resolve_native_claude_config`), or ``None``.
|
||||
:returns: The probe result, or ``None`` when the probe failed (callers
|
||||
fall back to the configured/static rows).
|
||||
"""
|
||||
command, launch_args, env = _claude_model_probe_invocation(
|
||||
claude_config, ("--output-format", "stream-json", "--verbose")
|
||||
)
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
command,
|
||||
*launch_args,
|
||||
cwd=str(Path.home()),
|
||||
env=env,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except OSError:
|
||||
_logger.warning("Claude model probe could not launch the claude CLI", exc_info=True)
|
||||
return None
|
||||
try:
|
||||
async with asyncio.timeout(_CLAUDE_MODEL_PROBE_TIMEOUT_S):
|
||||
stdout, stderr = await process.communicate()
|
||||
except (TimeoutError, asyncio.CancelledError):
|
||||
if process.returncode is None:
|
||||
process.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await process.wait()
|
||||
_logger.warning("Claude model probe timed out; keeping configured rows only")
|
||||
return None
|
||||
if process.returncode != 0:
|
||||
_logger.warning(
|
||||
"Claude model probe exited %s: %s",
|
||||
process.returncode,
|
||||
stderr.decode(errors="replace").strip()[-500:],
|
||||
)
|
||||
return None
|
||||
text = stdout.decode(errors="replace")
|
||||
aliases = _parse_claude_enumeration_aliases(text)
|
||||
# The enumeration run's own init event names what a bare launch runs —
|
||||
# the harness's truthful Default.
|
||||
default_resolution = _parse_claude_current_model(text)
|
||||
# The picker renders its own top-level Default choice (launch with no
|
||||
# model), which is exactly what the harness's ``default`` alias does —
|
||||
# listing it again would duplicate that row.
|
||||
aliases = [alias for alias in aliases if alias != "default"]
|
||||
resolutions = await _resolve_claude_model_aliases(claude_config, aliases)
|
||||
alias_rows: list[dict[str, object]] = []
|
||||
seen_models: set[object] = set()
|
||||
for alias in aliases:
|
||||
row = _claude_alias_row(alias, resolutions.get(alias, {}))
|
||||
# Distinct aliases can resolve to a model an earlier row already
|
||||
# covers (``best``, ``fable[1m]``, and ``opusplan`` all do today);
|
||||
# repeating the model is picker noise.
|
||||
if row["model"] in seen_models:
|
||||
continue
|
||||
seen_models.add(row["model"])
|
||||
alias_rows.append(row)
|
||||
return ClaudeModelProbe(
|
||||
alias_rows=alias_rows,
|
||||
default_model=default_resolution.get("model"),
|
||||
default_label=default_resolution.get("label"),
|
||||
)
|
||||
|
||||
|
||||
def claude_catalog_fingerprint(claude_config: ClaudeNativeUcodeConfig | None) -> str:
|
||||
"""The launch-config fingerprint keying claude's shared model catalog.
|
||||
|
||||
One formula for every consumer (host boot probe, runner launch, session
|
||||
listing), so they read and write the same catalog file.
|
||||
|
||||
:param claude_config: The resolved launch config, or ``None``.
|
||||
:returns: A stable fingerprint string.
|
||||
"""
|
||||
from omnigent.model_catalog_store import fingerprint_of
|
||||
|
||||
return fingerprint_of(
|
||||
"claude-native",
|
||||
sorted(claude_config.env.items()) if claude_config is not None else None,
|
||||
claude_config.api_key_helper if claude_config is not None else None,
|
||||
claude_config.model if claude_config is not None else None,
|
||||
)
|
||||
|
||||
|
||||
async def claude_model_catalog(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
) -> list[dict[str, object]] | None:
|
||||
"""
|
||||
The harness-truth catalog: probe rows with one truthful ``isDefault``.
|
||||
|
||||
Rows come from the harness's own enumeration alone (no configured/static
|
||||
merge). Servability filtering matches the listing composition: on a
|
||||
non-canonical endpoint, aliases resolving to bare Anthropic ids are
|
||||
dropped. The default marker is what a Default launch of this config
|
||||
actually runs: the config's own launch pin when the provider resolves
|
||||
one (those launches pass ``--model`` explicitly), else the enumeration
|
||||
run's init-event model (a bare subscription launch). It is matched onto
|
||||
its row, or appended as its own row when the catalog lacks it (a
|
||||
``settings.json`` pin, say) and the endpoint can serve it.
|
||||
|
||||
:param claude_config: The resolved launch config, or ``None``.
|
||||
:returns: Catalog rows, or ``None`` when the probe failed.
|
||||
"""
|
||||
probe = await probe_claude_model_options(claude_config)
|
||||
if probe is None:
|
||||
return None
|
||||
rows = list(probe.alias_rows)
|
||||
if claude_config is not None and not _serves_canonical_anthropic_ids(claude_config):
|
||||
rows = [row for row in rows if not str(row.get("model", "")).startswith("claude-")]
|
||||
|
||||
configured_pin = claude_config.model if claude_config is not None else None
|
||||
default_model = configured_pin or probe.default_model
|
||||
marked = False
|
||||
out: list[dict[str, object]] = []
|
||||
for row in rows:
|
||||
is_default = (
|
||||
bool(default_model)
|
||||
and not marked
|
||||
and (row.get("model") == default_model or row.get("id") == default_model)
|
||||
)
|
||||
if is_default:
|
||||
marked = True
|
||||
out.append({**row, "isDefault": True})
|
||||
else:
|
||||
out.append({key: value for key, value in row.items() if key != "isDefault"})
|
||||
if default_model and not marked:
|
||||
# Append the observed default as its own honest row — but never
|
||||
# claim a bare Anthropic id is launchable on an endpoint that
|
||||
# rejects that spelling.
|
||||
servable = (
|
||||
claude_config is None
|
||||
or _serves_canonical_anthropic_ids(claude_config)
|
||||
or not default_model.startswith("claude-")
|
||||
)
|
||||
if servable:
|
||||
# The probe's printed label describes the ENUMERATION run's
|
||||
# model; it only names a config-pinned default when the two are
|
||||
# the same model.
|
||||
label = (
|
||||
probe.default_label
|
||||
if default_model == probe.default_model and probe.default_label
|
||||
else default_model
|
||||
)
|
||||
out.append(
|
||||
{
|
||||
"id": default_model,
|
||||
"model": default_model,
|
||||
"displayName": label,
|
||||
"isDefault": True,
|
||||
}
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
async def claude_launch_catalog(
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
) -> list[dict[str, object]] | None:
|
||||
"""
|
||||
The shared catalog for this launch config: read the store, probe on miss.
|
||||
|
||||
The store read is what keeps launches fast once the host's boot probe
|
||||
(or a previous launch) has run; a cold miss pays one probe and persists
|
||||
the answer for every later consumer.
|
||||
|
||||
:param claude_config: The resolved launch config, or ``None``.
|
||||
:returns: Catalog rows, or ``None`` when no catalog could be obtained.
|
||||
"""
|
||||
from omnigent import model_catalog_store
|
||||
|
||||
fingerprint = claude_catalog_fingerprint(claude_config)
|
||||
return await model_catalog_store.ensure_catalog(
|
||||
"claude-native", fingerprint, lambda: claude_model_catalog(claude_config)
|
||||
)
|
||||
|
||||
|
||||
def build_native_claude_terminal_env(
|
||||
@@ -2234,9 +2724,27 @@ def _provider_config_for_native_claude(entry: ProviderEntry) -> ClaudeNativeUcod
|
||||
family.base_url,
|
||||
family.default_model,
|
||||
)
|
||||
# Pin the alias vocabulary to the entry's declared models, so ``/model``
|
||||
# and the harness probe resolve aliases inside this gateway's routable
|
||||
# set instead of falling back to canonical Anthropic ids the gateway
|
||||
# rejects. The ``models:`` map's flat tier keys (``opus``/``sonnet``/…)
|
||||
# pin their aliases directly; ``models.default`` pins its own family's
|
||||
# alias when nothing else declared that family.
|
||||
pin_env: dict[str, str] = {}
|
||||
for alias, env_var in ALIAS_MODEL_ENV_VARS.items():
|
||||
pinned = family.models.get(alias)
|
||||
if isinstance(pinned, str) and pinned.strip():
|
||||
pin_env[env_var] = pinned.strip()
|
||||
if family.default_model:
|
||||
default_alias = claude_model_alias(family.default_model, env={})
|
||||
base_alias = (default_alias or "").partition("[")[0]
|
||||
default_env_var = ALIAS_MODEL_ENV_VARS.get(base_alias)
|
||||
if default_env_var:
|
||||
pin_env.setdefault(default_env_var, family.default_model)
|
||||
return ClaudeNativeUcodeConfig(
|
||||
env={
|
||||
_UCODE_CLAUDE_BASE_URL_ENV: family.base_url,
|
||||
**pin_env,
|
||||
# Disable beta flags gateways reject (400 "invalid beta flag");
|
||||
# skip when CLAUDE_CODE_USE_GATEWAY=1 to keep tool search enabled.
|
||||
**(
|
||||
@@ -2247,6 +2755,15 @@ def _provider_config_for_native_claude(entry: ProviderEntry) -> ClaudeNativeUcod
|
||||
},
|
||||
api_key_helper=api_key_helper,
|
||||
model=family.default_model,
|
||||
# The declared models are exactly what this entry can route.
|
||||
routable_models=tuple(
|
||||
dict.fromkeys(
|
||||
[
|
||||
*pin_env.values(),
|
||||
*([family.default_model] if family.default_model else []),
|
||||
]
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -179,6 +179,27 @@ _PASTED_PLACEHOLDER_PREFIX = "[Pasted text"
|
||||
# whether the draft is rendered in the input box. Short enough to fit
|
||||
# on the prompt row of a default 80-column detached pane.
|
||||
_DRAFT_NEEDLE_MAX_CHARS = 24
|
||||
# Mode footer Claude Code renders below its input box, keyed by
|
||||
# ``--permission-mode`` value. There is no non-interactive mode command, so
|
||||
# a live switch cycles with shift+tab and reads this footer to know where it
|
||||
# landed. The prompting mode is ``default`` on the CLI, "manual" on screen.
|
||||
_PERMISSION_MODE_FOOTERS: dict[str, str] = {
|
||||
"default": "manual mode on",
|
||||
"acceptEdits": "accept edits on",
|
||||
"plan": "plan mode on",
|
||||
"auto": "auto mode on",
|
||||
}
|
||||
# Modes shift+tab can reach. ``dontAsk`` is never in the cycle and
|
||||
# ``bypassPermissions`` only joins it when launched into, so both are
|
||||
# rejected up front.
|
||||
CYCLEABLE_PERMISSION_MODES = frozenset(_PERMISSION_MODE_FOOTERS)
|
||||
# Cap on shift+tab presses. The cycle is 3-5 modes wide depending on which
|
||||
# optional modes are enabled, so a full lap plus slack proves the target is
|
||||
# unreachable rather than slow.
|
||||
_MODE_CYCLE_MAX_PRESSES = 8
|
||||
# Wait for the footer to repaint after a shift+tab before reading it.
|
||||
_MODE_FOOTER_SETTLE_TIMEOUT_S = 2.0
|
||||
_MODE_FOOTER_POLL_INTERVAL_S = 0.1
|
||||
# Footer Claude Code's interactive ``/model`` picker renders while it is open.
|
||||
# Omnigent never drives that picker — it switches with ``/model <id>`` — but a
|
||||
# picker the person opened by hand covers the input box, so an injection would
|
||||
@@ -1227,6 +1248,48 @@ def read_model_env(bridge_dir: Path) -> dict[str, str]:
|
||||
}
|
||||
|
||||
|
||||
def record_model_vocabulary(
|
||||
bridge_dir: Path,
|
||||
*,
|
||||
launch_env: Mapping[str, str] | None,
|
||||
launch_model: str | None,
|
||||
) -> None:
|
||||
"""
|
||||
Persist the launch's model vocabulary after the bridge dir exists.
|
||||
|
||||
The runner prepares the bridge before it resolves the provider config,
|
||||
so the vocabulary (alias pins + custom slot) and the launch model land
|
||||
here in a second write once known — the same keys
|
||||
:func:`prepare_bridge_dir` records on the CLI path, so
|
||||
:func:`read_model_env` / :func:`read_launch_model` serve both paths
|
||||
identically.
|
||||
|
||||
:param bridge_dir: Bridge directory path.
|
||||
:param launch_env: The resolved launch env (pins + custom slot), or
|
||||
``None`` for a bare subscription launch.
|
||||
:param launch_model: The model the launch pins via ``--model``, or
|
||||
``None``.
|
||||
:returns: None.
|
||||
"""
|
||||
config = _read_json_file(bridge_dir / _CONFIG_FILE)
|
||||
if not isinstance(config, dict):
|
||||
return
|
||||
model_env = {
|
||||
key: launch_env[key]
|
||||
for key in MODEL_VOCABULARY_ENV_VARS
|
||||
if launch_env is not None and launch_env.get(key)
|
||||
}
|
||||
changed = False
|
||||
if model_env and config.get("model_env") != model_env:
|
||||
config["model_env"] = model_env
|
||||
changed = True
|
||||
if launch_model and config.get("launch_model") != launch_model:
|
||||
config["launch_model"] = launch_model
|
||||
changed = True
|
||||
if changed:
|
||||
_write_json_file(bridge_dir / _CONFIG_FILE, config)
|
||||
|
||||
|
||||
def read_bridge_id(bridge_dir: Path) -> str | None:
|
||||
"""
|
||||
Read the opaque bridge id from bridge config.
|
||||
@@ -3370,6 +3433,171 @@ def _confirm_tui_dialog(
|
||||
return False
|
||||
|
||||
|
||||
def _permission_mode_from_pane(pane: str) -> str | None:
|
||||
"""
|
||||
Read Claude Code's current permission mode off a captured pane.
|
||||
|
||||
The footer (``⏵⏵ auto mode on``, ``⏸ plan mode on``, ...) always sits
|
||||
below the input box's closing rule, so the scan starts there rather than
|
||||
at a fixed offset from the bottom: the footer's height scales with
|
||||
concurrent subagents, which a fixed window cannot bound (the same reason
|
||||
:func:`_claude_prompt_rendered` anchors on :func:`_is_box_rule`). Anchoring
|
||||
also excludes transcript text structurally — a mode name Claude echoed
|
||||
while *discussing* modes sits above the box and can't be misread as live.
|
||||
|
||||
:param pane: Captured pane text from :func:`_capture_pane`.
|
||||
:returns: The ``--permission-mode`` value for the rendered footer,
|
||||
e.g. ``"auto"``, or ``None`` when no footer is visible (the
|
||||
pane is mid-repaint, or the mode is one with no footer).
|
||||
"""
|
||||
lines = [line for line in pane.splitlines() if line.strip()]
|
||||
# Below the last box rule is the footer region. With no rule the input box
|
||||
# isn't mounted; fall back to the tail so a footer still reads during boot.
|
||||
last_rule = max((i for i, line in enumerate(lines) if _is_box_rule(line)), default=None)
|
||||
region = lines[last_rule + 1 :] if last_rule is not None else lines[-_PROMPT_SCAN_TAIL_LINES:]
|
||||
for line in reversed(region):
|
||||
for mode, footer in _PERMISSION_MODE_FOOTERS.items():
|
||||
if footer in line:
|
||||
return mode
|
||||
return None
|
||||
|
||||
|
||||
def _read_settled_permission_mode(
|
||||
socket_path: str,
|
||||
tmux_target: str,
|
||||
*,
|
||||
previous: str | None = None,
|
||||
) -> str | None:
|
||||
"""
|
||||
Poll the pane until its permission-mode footer settles.
|
||||
|
||||
A shift+tab repaints the footer asynchronously, so an immediate
|
||||
capture can read nothing — or, worse, still read the PREVIOUS mode
|
||||
and make the cycler believe the keystroke did nothing. Passing
|
||||
*previous* waits for the footer to actually change.
|
||||
|
||||
:param socket_path: Absolute path to the tmux socket.
|
||||
:param tmux_target: tmux pane target string, e.g. ``"main"``.
|
||||
:param previous: Mode read before the keystroke that prompted this
|
||||
read, e.g. ``"plan"``. The pane keeps rendering it until the TUI
|
||||
repaints, so a read that returns it is treated as not-yet-settled
|
||||
and retried; ``None`` accepts the first mode seen (the initial
|
||||
read, where there is nothing to change from).
|
||||
:returns: The rendered mode, or ``None`` if none appeared — or the
|
||||
footer never moved off *previous* — before
|
||||
:data:`_MODE_FOOTER_SETTLE_TIMEOUT_S`.
|
||||
"""
|
||||
deadline = time.monotonic() + _MODE_FOOTER_SETTLE_TIMEOUT_S
|
||||
while True:
|
||||
mode = _permission_mode_from_pane(_capture_pane(socket_path, tmux_target))
|
||||
if mode is not None and mode != previous:
|
||||
return mode
|
||||
if time.monotonic() >= deadline:
|
||||
# Timed out: report the last mode seen so a pane that legitimately
|
||||
# stayed put is distinguished from one with no footer at all.
|
||||
return mode
|
||||
time.sleep(_MODE_FOOTER_POLL_INTERVAL_S)
|
||||
|
||||
|
||||
def set_permission_mode(
|
||||
bridge_dir: Path,
|
||||
*,
|
||||
mode: str,
|
||||
timeout_s: float = _TMUX_READY_TIMEOUT_S,
|
||||
) -> str:
|
||||
"""
|
||||
Switch the running Claude terminal to *mode* by cycling shift+tab.
|
||||
|
||||
Claude Code has no non-interactive way to set a live session's mode
|
||||
(``--permission-mode`` is launch-only, ``/permissions`` is an interactive
|
||||
dialog, settings load at startup), so this drives the TUI's shift+tab
|
||||
cycle, reading the mode footer after each press. The cycle is walked
|
||||
rather than computed: its width varies with which optional modes are
|
||||
enabled, so a fixed press count could land on the wrong mode.
|
||||
|
||||
:param bridge_dir: Bridge directory path, e.g.
|
||||
``/tmp/omnigent/claude-native/<digest>``.
|
||||
:param mode: Target ``--permission-mode`` value, one of
|
||||
:data:`CYCLEABLE_PERMISSION_MODES`, e.g. ``"auto"``.
|
||||
:param timeout_s: Seconds to wait for ``tmux.json`` to be
|
||||
advertised by the runner, e.g. ``30.0``.
|
||||
:returns: The mode now rendered in the pane (== *mode*).
|
||||
:raises ValueError: If *mode* is not cycle-reachable.
|
||||
:raises RuntimeError: If the tmux target is not advertised in time,
|
||||
a ``tmux`` invocation fails, the pane never renders a mode
|
||||
footer, or the target is not reached within
|
||||
:data:`_MODE_CYCLE_MAX_PRESSES` presses (the mode is not in
|
||||
this session's cycle).
|
||||
"""
|
||||
if mode not in CYCLEABLE_PERMISSION_MODES:
|
||||
raise ValueError(
|
||||
f"permission mode {mode!r} cannot be switched on a running session; "
|
||||
f"expected one of {sorted(CYCLEABLE_PERMISSION_MODES)}"
|
||||
)
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||||
socket_path, tmux_target = info["socket_path"], info["tmux_target"]
|
||||
# The footer only renders once the input box is mounted; without
|
||||
# this gate a shift+tab sent mid-boot is dropped and the read below
|
||||
# reports a mode the keystroke never reached.
|
||||
_wait_for_claude_prompt_ready(socket_path, tmux_target, timeout_s=timeout_s)
|
||||
current = _read_settled_permission_mode(socket_path, tmux_target)
|
||||
if current is None:
|
||||
pane = _capture_pane(socket_path, tmux_target)
|
||||
raise RuntimeError(
|
||||
"Claude Code did not render a permission-mode footer, so its current "
|
||||
f"mode could not be read.{_format_terminal_failure_tail(pane)}"
|
||||
)
|
||||
seen = [current]
|
||||
for _ in range(_MODE_CYCLE_MAX_PRESSES):
|
||||
if current == mode:
|
||||
return current
|
||||
# No ``-l``: tmux must interpret ``BTab`` as the shift+tab key.
|
||||
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "BTab")
|
||||
settled = _read_settled_permission_mode(socket_path, tmux_target, previous=current)
|
||||
if settled is not None:
|
||||
current = settled
|
||||
seen.append(current)
|
||||
if current == mode:
|
||||
return current
|
||||
raise RuntimeError(
|
||||
f"Could not switch Claude Code to {mode!r} mode: cycled shift+tab "
|
||||
f"{_MODE_CYCLE_MAX_PRESSES} times and only reached {sorted(set(seen))}. "
|
||||
"The mode is not available in this session's cycle."
|
||||
)
|
||||
|
||||
|
||||
def confirm_dialog_if_open(bridge_dir: Path, *, hint: str) -> bool:
|
||||
"""
|
||||
Accept the *hint* dialog iff it is on screen RIGHT NOW; never blind-Enter.
|
||||
|
||||
Loop-safe building block for watchers that outlive a single injection
|
||||
(a mid-turn ``/model`` queues in Claude's composer and pops its confirm
|
||||
dialog only when the turn settles — minutes later). Unlike
|
||||
:func:`_confirm_tui_dialog` there is no timeout fallback Enter, so
|
||||
calling this every few seconds can never type into a surface that is
|
||||
not the named dialog.
|
||||
|
||||
:param bridge_dir: Bridge directory path.
|
||||
:param hint: Text the dialog renders, e.g.
|
||||
:data:`SWITCH_MODEL_DIALOG_HINT`.
|
||||
:returns: ``True`` when the dialog was on screen and confirmed.
|
||||
"""
|
||||
try:
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=1.0)
|
||||
except (RuntimeError, OSError):
|
||||
return False
|
||||
socket_path = info["socket_path"]
|
||||
tmux_target = info["tmux_target"]
|
||||
try:
|
||||
pane = _capture_pane(socket_path, tmux_target)
|
||||
if hint not in pane:
|
||||
return False
|
||||
_confirm_and_verify_dialog_closed(socket_path, tmux_target, hint=hint)
|
||||
except (RuntimeError, OSError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _confirm_and_verify_dialog_closed(
|
||||
socket_path: str,
|
||||
tmux_target: str,
|
||||
@@ -5086,6 +5314,31 @@ def read_claude_context_state(bridge_dir: Path) -> _JsonObject | None:
|
||||
return parsed
|
||||
|
||||
|
||||
def read_permission_mode(bridge_dir: Path) -> str | None:
|
||||
"""
|
||||
Read the permission mode currently rendered in the Claude pane.
|
||||
|
||||
Non-blocking and best-effort: the forwarder calls this every poll so an
|
||||
in-pane shift+tab switch reaches the web UI, which otherwise never sees it
|
||||
(only UI-driven switches stamp the mode label). Returns ``None`` when the
|
||||
terminal isn't up or the pane shows no mode footer, so a caller can treat
|
||||
"unknown" as "no fresh observation" rather than a change.
|
||||
|
||||
:param bridge_dir: Bridge directory path, e.g.
|
||||
``/tmp/omnigent/claude-native/<digest>``.
|
||||
:returns: The ``--permission-mode`` value rendered in the pane, e.g.
|
||||
``"auto"``, or ``None`` when it cannot be determined.
|
||||
"""
|
||||
payload = _read_json_file(bridge_dir / _TMUX_FILE)
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
socket_path = payload.get("socket_path")
|
||||
tmux_target = payload.get("tmux_target")
|
||||
if not isinstance(socket_path, str) or not isinstance(tmux_target, str):
|
||||
return None
|
||||
return _permission_mode_from_pane(_capture_pane(socket_path, tmux_target))
|
||||
|
||||
|
||||
def read_claude_status_model(bridge_dir: Path) -> str | None:
|
||||
"""
|
||||
Read the active model id from the statusLine snapshot ``context.json``.
|
||||
|
||||
@@ -36,6 +36,7 @@ from omnigent.claude_native_bridge import (
|
||||
read_hook_events_from_offset,
|
||||
read_hook_events_since_with_position,
|
||||
read_message_deltas_from_offset,
|
||||
read_permission_mode,
|
||||
read_transcript_items_from_offset,
|
||||
read_transcript_items_since_with_position,
|
||||
read_transcript_path,
|
||||
@@ -82,6 +83,11 @@ _SUBAGENT_IDLE_QUIESCENCE_S = 5.0
|
||||
# ``agent-<id>.jsonl`` transcript.
|
||||
_SUBAGENT_META_GLOB = "agent-*.meta.json"
|
||||
_DEFAULT_POLL_INTERVAL_S = 0.25
|
||||
# Minimum spacing between permission-mode pane reads. Unlike the model mirror
|
||||
# (which reads a JSON file), this spawns a ``tmux capture-pane`` subprocess, so
|
||||
# it runs well below the poll interval; a mode switch is a human action and 2s
|
||||
# of lag is imperceptible.
|
||||
_PERMISSION_MODE_POLL_INTERVAL_S = 2.0
|
||||
# Hard ceiling on one poll iteration of the forward loop. A silently stalled
|
||||
# await anywhere in the pipeline used to stop mirroring, status and the busy
|
||||
# signal forever; the deadline cancels the stall (the traceback names it) and
|
||||
@@ -490,16 +496,15 @@ class _ForwardDedupeState:
|
||||
:param usage: Last ``message.usage`` snapshot POSTed via
|
||||
``external_session_usage``, or ``None`` if none yet.
|
||||
:param context_window: Last context-window POSTed, or ``None``.
|
||||
:param observed_model: Last tier alias seen in the transcript,
|
||||
sticky across polls (the incremental window often carries no
|
||||
fresh ``message.model``), e.g. ``"opus"``. ``None`` until first
|
||||
seen.
|
||||
:param posted_model: Last tier alias POSTed via
|
||||
``external_model_change``. Seeded from the first observation
|
||||
WITHOUT a POST so a passive spawn default never overwrites a
|
||||
pending silent model handoff; only a later in-TUI switch is
|
||||
mirrored. Left behind ``observed_model`` on a failed POST so the
|
||||
next poll retries. ``None`` until the first observation.
|
||||
:param observed_model: Last VERBATIM model seen (statusLine or
|
||||
transcript), sticky across polls (the incremental window often
|
||||
carries no fresh ``message.model``), e.g.
|
||||
``"claude-opus-4-8[1m]"``. ``None`` until first seen.
|
||||
:param posted_model: Last verbatim model POSTed via
|
||||
``external_model_change``. Every observation posts — the first
|
||||
one is the launch report that seeds the session's
|
||||
``reported_model``. Left behind ``observed_model`` on a failed
|
||||
POST so the next poll retries. ``None`` until the first post.
|
||||
:param posted_cost: Last DISPLAY cost (USD) POSTed as
|
||||
``cumulative_cost_usd`` — the statusLine total ``S`` verbatim.
|
||||
``None`` until the first cost post. Used to dedupe so a steady
|
||||
@@ -543,6 +548,13 @@ class _ForwardDedupeState:
|
||||
# sub-agent spend so the gate can block mid-turn. Separate baseline
|
||||
# because it can advance while ``posted_cost`` (S) is frozen.
|
||||
posted_policy_cost: float | None = None
|
||||
# Last permission mode POSTed as ``external_permission_mode_change`` —
|
||||
# mirrors the launch mode and any in-pane shift+tab switch, neither of
|
||||
# which the web UI can observe on its own.
|
||||
posted_permission_mode: str | None = None
|
||||
# Monotonic deadline before which the next pane read is skipped, so the
|
||||
# subprocess spawn runs at _PERMISSION_MODE_POLL_INTERVAL_S, not every poll.
|
||||
permission_mode_next_read: float = 0.0
|
||||
# Turn-settle latch driving the scheduled-wake boundary. The Stop edge
|
||||
# records the ended turn's id as PENDING; it activates (moves to
|
||||
# ``settled_response_id``) only once a fully-consumed transcript batch
|
||||
@@ -1012,6 +1024,14 @@ async def forward_claude_transcript_to_session(
|
||||
bridge_dir=bridge_dir,
|
||||
dedupe=dedupe,
|
||||
)
|
||||
# Same rationale for the permission mode: a shift+tab in
|
||||
# the pane emits no event, so poll the footer.
|
||||
await _forward_permission_mode_from_pane(
|
||||
client=client,
|
||||
session_id=current_session_id,
|
||||
bridge_dir=bridge_dir,
|
||||
dedupe=dedupe,
|
||||
)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except TimeoutError:
|
||||
@@ -3572,19 +3592,18 @@ async def _forward_available_items(
|
||||
_http_status_for_log(exc),
|
||||
exc_info=True,
|
||||
)
|
||||
# Mirror a TUI-side `/model` switch to the web picker. The transcript
|
||||
# records the resolved concrete id (e.g. "claude-opus-4-8"); collapse
|
||||
# it to the picker's tier alias. This transcript-derived observation
|
||||
# only fires when a turn produces a fresh ``message.model``, so it lags
|
||||
# an in-pane switch by one turn — the per-poll statusLine sync
|
||||
# (:func:`_forward_model_from_status`) is the primary, low-latency
|
||||
# source; this stays as a fallback for cold-resume before the first
|
||||
# statusLine render. Both share ``dedupe`` so neither double-posts.
|
||||
# Report the transcript's model verbatim. This transcript-derived
|
||||
# observation only fires when a turn produces a fresh
|
||||
# ``message.model``, so it lags an in-pane switch by one turn — the
|
||||
# per-poll statusLine sync (:func:`_forward_model_from_status`) is the
|
||||
# primary, low-latency source; this stays as a fallback for cold-resume
|
||||
# before the first statusLine render. Both share ``dedupe`` so neither
|
||||
# double-posts.
|
||||
await _post_model_change_if_new(
|
||||
client,
|
||||
session_id=session_id,
|
||||
dedupe=dedupe,
|
||||
alias=_model_alias_for(result.latest_model),
|
||||
model=result.latest_model,
|
||||
)
|
||||
# Mirror a TUI-side `/rename` to the web session list. Claude writes the
|
||||
# operator's title as a `custom-title` metadata record, which renders no
|
||||
@@ -4177,38 +4196,81 @@ def _gen_ai_usage_tokens(usage: Mapping[str, float | str] | None) -> dict[str, i
|
||||
return tokens
|
||||
|
||||
|
||||
def _model_alias_for(model: str | None) -> str | None:
|
||||
async def _post_external_permission_mode_change(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
mode: str,
|
||||
) -> None:
|
||||
"""
|
||||
Collapse a concrete Claude model id to the picker's tier alias.
|
||||
Post one ``external_permission_mode_change`` event to the Sessions API.
|
||||
|
||||
The web model picker speaks Claude Code's version-agnostic aliases
|
||||
(``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"haiku"``), plus the one
|
||||
extra concrete-id slot ``"sonnet_5"`` (see
|
||||
:data:`omnigent.claude_native._UCODE_CLAUDE_CUSTOM_TIER`) for the newer
|
||||
Sonnet generation offered alongside the default ``"sonnet"`` tier; the
|
||||
transcript records the resolved concrete id (e.g.
|
||||
``"claude-opus-4-8"`` or ``"databricks-claude-sonnet-5"``).
|
||||
Mapping to the tier keeps the mirrored value in the picker's
|
||||
vocabulary and makes a web→TUI round-trip a no-op. The older Sonnet
|
||||
(``sonnet-4-6``) collapses to the generic ``"sonnet"`` alias — it is the
|
||||
default that row is bound to.
|
||||
Lets the web mode picker reflect a shift+tab switch made inside the Claude
|
||||
Code terminal, which Omnigent has no other way to observe.
|
||||
|
||||
:param model: Concrete model id from the transcript, e.g.
|
||||
``"claude-opus-4-8"``; ``None`` when none observed yet.
|
||||
:returns: ``"fable"`` / ``"opus"`` / ``"sonnet"`` / ``"sonnet_5"`` /
|
||||
``"haiku"`` when the id carries a known tier token, else ``None``
|
||||
(the caller skips the post rather than surface an id the picker
|
||||
can't render).
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id, e.g. ``"conv_abc123"``.
|
||||
:param mode: Permission mode the pane now shows, e.g. ``"auto"``.
|
||||
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
||||
"""
|
||||
if not model:
|
||||
return None
|
||||
lowered = model.lower()
|
||||
if "sonnet-5" in lowered or "sonnet_5" in lowered:
|
||||
return "sonnet_5"
|
||||
for tier in ("fable", "opus", "sonnet", "haiku"):
|
||||
if tier in lowered:
|
||||
return tier
|
||||
return None
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{session_id}/events",
|
||||
json={"type": "external_permission_mode_change", "data": {"permission_mode": mode}},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
async def _forward_permission_mode_from_pane(
|
||||
client: httpx.AsyncClient,
|
||||
*,
|
||||
session_id: str,
|
||||
bridge_dir: Path,
|
||||
dedupe: _ForwardDedupeState,
|
||||
) -> None:
|
||||
"""
|
||||
Mirror the pane's permission-mode footer to the session label each poll.
|
||||
|
||||
A shift+tab pressed inside the TUI produces no event Omnigent can see, so
|
||||
without this the web picker shows a stale mode until the next UI-driven
|
||||
switch. Polling the footer is the only signal available: Claude Code emits
|
||||
nothing on a mode change, and hook payloads only arrive on tool use.
|
||||
|
||||
The launch mode is posted too, not just later switches: a session started
|
||||
in manual mode carries no ``--permission-mode`` arg and no mode label, so
|
||||
with nothing posted the web picker has no mode to render and hides itself.
|
||||
Best-effort and idempotent — the server ignores a mode equal to the stored
|
||||
label, an unchanged mode or unreadable pane is a no-op, and a failed POST
|
||||
is retried next poll.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id.
|
||||
:param bridge_dir: Native Claude bridge directory.
|
||||
:param dedupe: Shared per-session dedupe state; mutated in place.
|
||||
"""
|
||||
# Throttled: this spawns a tmux subprocess, unlike the file-backed model
|
||||
# mirror that shares this poll loop.
|
||||
now = time.monotonic()
|
||||
if now < dedupe.permission_mode_next_read:
|
||||
return
|
||||
dedupe.permission_mode_next_read = now + _PERMISSION_MODE_POLL_INTERVAL_S
|
||||
mode = await asyncio.to_thread(read_permission_mode, bridge_dir)
|
||||
if mode is None or mode == dedupe.posted_permission_mode:
|
||||
return
|
||||
try:
|
||||
await _post_external_permission_mode_change(
|
||||
client,
|
||||
session_id=session_id,
|
||||
mode=mode,
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
_logger.debug(
|
||||
"external_permission_mode_change post failed; session=%s mode=%s",
|
||||
session_id,
|
||||
mode,
|
||||
exc_info=True,
|
||||
)
|
||||
return
|
||||
dedupe.posted_permission_mode = mode
|
||||
|
||||
|
||||
async def _post_external_model_change(
|
||||
@@ -4220,13 +4282,15 @@ async def _post_external_model_change(
|
||||
"""
|
||||
Post one ``external_model_change`` event to the Sessions API.
|
||||
|
||||
Lets the web model picker reflect a model switch made inside the
|
||||
Claude Code terminal (a ``/model`` command or the in-TUI picker).
|
||||
Reports the model the pane is actually on — the launch's own model
|
||||
included — so every surface renders the harness's truth.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id, e.g.
|
||||
``"conv_abc123"``.
|
||||
:param model: Tier alias the session is now on, e.g. ``"opus"``.
|
||||
:param model: The harness's VERBATIM model, e.g.
|
||||
``"claude-opus-4-8[1m]"`` — never collapsed to a picker alias
|
||||
(a family word claims a generation the pane may not be on).
|
||||
:raises httpx.HTTPError: If the Omnigent request fails or is rejected.
|
||||
"""
|
||||
resp = await client.post(
|
||||
@@ -4321,40 +4385,36 @@ async def _post_model_change_if_new(
|
||||
*,
|
||||
session_id: str,
|
||||
dedupe: _ForwardDedupeState,
|
||||
alias: str | None,
|
||||
model: str | None,
|
||||
) -> None:
|
||||
"""
|
||||
Mirror an observed model tier alias to ``model_override``, deduped.
|
||||
Report the observed model to ``reported_model``, verbatim and deduped.
|
||||
|
||||
Shared by the transcript-driven path (:func:`_forward_available_items`)
|
||||
and the statusLine-driven per-poll path
|
||||
(:func:`_forward_model_from_status`). The FIRST observation is the
|
||||
session's spawn default, not a switch, so it seeds the dedupe baseline
|
||||
WITHOUT posting (posting it could clobber a pending silent model
|
||||
handoff). Every later change posts ``external_model_change``. Both
|
||||
callers pass the same ``dedupe`` so whichever observes a switch first
|
||||
posts it and the other no-ops. Best-effort: a failed POST leaves
|
||||
``posted_model`` behind ``observed_model`` so the next poll retries.
|
||||
(:func:`_forward_model_from_status`). EVERY observation posts — the
|
||||
first one is the launch report that seeds the session's
|
||||
``reported_model``, so surfaces show the pane's truth within seconds
|
||||
of spawn; the server dedupes by equality, so a steady model costs one
|
||||
POST total. Both callers pass the same ``dedupe`` so whichever
|
||||
observes a change first posts it and the other no-ops. Best-effort: a
|
||||
failed POST leaves ``posted_model`` behind ``observed_model`` so the
|
||||
next poll retries.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id.
|
||||
:param dedupe: Shared per-session dedupe state; mutated in place.
|
||||
:param alias: Tier alias just observed (``"opus"`` / ``"sonnet"`` /
|
||||
…), or ``None`` when this source carried no recognizable model on
|
||||
this poll. ``observed_model`` is sticky across polls, so passing
|
||||
``None`` does NOT clear it — it just means "no fresh observation,"
|
||||
and a previously-observed-but-unposted model is still reconciled
|
||||
(retried) here.
|
||||
:param model: The harness's verbatim model just observed (e.g.
|
||||
``"claude-opus-4-8[1m]"``), or ``None`` when this source carried
|
||||
no model on this poll. ``observed_model`` is sticky across
|
||||
polls, so passing ``None`` does NOT clear it — it just means "no
|
||||
fresh observation," and a previously-observed-but-unposted model
|
||||
is still reconciled (retried) here.
|
||||
"""
|
||||
if alias is not None:
|
||||
dedupe.observed_model = alias
|
||||
if model is not None:
|
||||
dedupe.observed_model = model
|
||||
if dedupe.observed_model is None or dedupe.observed_model == dedupe.posted_model:
|
||||
return
|
||||
if dedupe.posted_model is None:
|
||||
# First observation = the spawn default; seed the baseline without
|
||||
# posting so it can't clobber a pending silent model handoff.
|
||||
dedupe.posted_model = dedupe.observed_model
|
||||
return
|
||||
try:
|
||||
await _post_external_model_change(
|
||||
client,
|
||||
@@ -4380,7 +4440,7 @@ async def _forward_model_from_status(
|
||||
dedupe: _ForwardDedupeState,
|
||||
) -> None:
|
||||
"""
|
||||
Mirror the statusLine-reported active model to ``model_override`` each poll.
|
||||
Report the statusLine's active model to ``reported_model`` each poll.
|
||||
|
||||
Claude Code rewrites the statusLine stdin on every TUI render — including
|
||||
right after an in-pane ``/model`` switch, BEFORE the next turn runs. The
|
||||
@@ -4392,8 +4452,9 @@ async def _forward_model_from_status(
|
||||
turn later, which is what happened when the model was derived solely
|
||||
from the next turn's transcript ``message.model``.
|
||||
|
||||
Best-effort and idempotent: shares ``dedupe`` with the transcript path,
|
||||
so a no-op when the model is unchanged.
|
||||
The value posts VERBATIM — the harness's own spelling, never collapsed
|
||||
to a picker alias. Best-effort and idempotent: shares ``dedupe`` with
|
||||
the transcript path, so a no-op when the model is unchanged.
|
||||
|
||||
:param client: Omnigent HTTP client.
|
||||
:param session_id: Omnigent session/conversation id.
|
||||
@@ -4404,12 +4465,11 @@ async def _forward_model_from_status(
|
||||
if status_state is None:
|
||||
return
|
||||
model = status_state.get("model")
|
||||
alias = _model_alias_for(model if isinstance(model, str) else None)
|
||||
await _post_model_change_if_new(
|
||||
client,
|
||||
session_id=session_id,
|
||||
dedupe=dedupe,
|
||||
alias=alias,
|
||||
model=model.strip() if isinstance(model, str) and model.strip() else None,
|
||||
)
|
||||
|
||||
|
||||
|
||||
+139
-4
@@ -1781,6 +1781,98 @@ def _set_log_to_stderr(
|
||||
return value
|
||||
|
||||
|
||||
def _finish_cli_profile(profiler: Any, output_path: Path) -> None: # type: ignore[explicit-any]
|
||||
"""Stop a requested cProfile run and render a focused bottleneck summary."""
|
||||
import pstats
|
||||
|
||||
profiler.disable()
|
||||
profiler.dump_stats(output_path)
|
||||
stats = pstats.Stats(profiler)
|
||||
stats_state = vars(stats)
|
||||
raw_stats = cast(
|
||||
dict[tuple[str, int, str], tuple[int, int, float, float, object]],
|
||||
stats_state["stats"],
|
||||
)
|
||||
total_time = float(stats_state["total_tt"])
|
||||
total_calls = int(stats_state["total_calls"])
|
||||
primitive_calls = int(stats_state["prim_calls"])
|
||||
package_root = Path(__file__).resolve().parent
|
||||
source_root = package_root.parent
|
||||
|
||||
# (filename, line, function, primitive calls, total calls, self, cumulative)
|
||||
rows = [(*key, cc, nc, tt, ct) for key, (cc, nc, tt, ct, _) in raw_stats.items()]
|
||||
|
||||
def _location(filename: str, line: int, function: str) -> str:
|
||||
path = Path(filename).resolve()
|
||||
try:
|
||||
display = str(path.relative_to(source_root))
|
||||
except ValueError:
|
||||
display = path.name
|
||||
return f"{display}:{line}({function})"
|
||||
|
||||
def _duration(seconds: float) -> str:
|
||||
if seconds < 0.01:
|
||||
return f"{seconds * 1_000:.2f} ms"
|
||||
if seconds < 1:
|
||||
return f"{seconds * 1_000:.1f} ms"
|
||||
return f"{seconds:.3f} s"
|
||||
|
||||
def _print_rows(
|
||||
title: str,
|
||||
selected: list[tuple[str, int, str, int, int, float, float]],
|
||||
) -> None:
|
||||
click.echo(f"\n{title}", err=True)
|
||||
click.echo(f" {'self':>9} {'cumulative':>10} {'calls':>9} function", err=True)
|
||||
for filename, line, function, primitive, calls, self_time, cumulative in selected[:10]:
|
||||
call_count = str(calls) if calls == primitive else f"{calls}/{primitive}"
|
||||
click.echo(
|
||||
f" {_duration(self_time):>9} {_duration(cumulative):>10} "
|
||||
f"{call_count:>9} {_location(filename, line, function)}",
|
||||
err=True,
|
||||
)
|
||||
|
||||
omnigent_rows = [
|
||||
row for row in rows if Path(row[0]).resolve().is_relative_to(package_root) and row[6] > 0
|
||||
]
|
||||
omnigent_rows.sort(key=lambda row: row[6], reverse=True)
|
||||
self_time_rows = [row for row in omnigent_rows if row[5] > 0]
|
||||
self_time_rows.sort(key=lambda row: row[5], reverse=True)
|
||||
|
||||
click.echo(
|
||||
f"\nCLI profile: {_duration(total_time)}, "
|
||||
f"{total_calls:,} calls ({primitive_calls:,} primitive)",
|
||||
err=True,
|
||||
)
|
||||
_print_rows("Top Omnigent call paths", omnigent_rows)
|
||||
_print_rows("Top Omnigent functions by self time", self_time_rows)
|
||||
click.echo(f"\nFull profile data: {output_path}", err=True)
|
||||
|
||||
|
||||
def _start_cli_profile(
|
||||
ctx: click.Context,
|
||||
_param: click.Parameter,
|
||||
value: bool,
|
||||
) -> bool:
|
||||
"""Start cProfile early and finish it after the selected command exits."""
|
||||
if not value:
|
||||
return value
|
||||
|
||||
import cProfile
|
||||
|
||||
timestamp = time.strftime("%Y%m%d-%H%M%S")
|
||||
microseconds = time.time_ns() // 1_000 % 1_000_000
|
||||
profile_dir = data_dir() / "profiles"
|
||||
profile_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = profile_dir / (f"omnigent-cli-{timestamp}-{microseconds:06d}-{os.getpid()}.prof")
|
||||
profiler = cProfile.Profile()
|
||||
profiler.enable()
|
||||
ctx.call_on_close(lambda: _finish_cli_profile(profiler, output_path))
|
||||
# Click closes callbacks last-in-first-out, so measurement stops before
|
||||
# rendering the summary above.
|
||||
ctx.call_on_close(profiler.disable)
|
||||
return value
|
||||
|
||||
|
||||
def _extract_global_logging_flags(argv: list[str]) -> tuple[list[str], bool, bool]:
|
||||
"""Remove global logging flags before run-shorthand rewriting."""
|
||||
debug_logging = False
|
||||
@@ -1801,6 +1893,17 @@ def _extract_global_logging_flags(argv: list[str]) -> tuple[list[str], bool, boo
|
||||
|
||||
|
||||
@click.group(cls=_OmnigentCLI)
|
||||
@click.option(
|
||||
"--profiling",
|
||||
is_flag=True,
|
||||
is_eager=True,
|
||||
expose_value=False,
|
||||
callback=_start_cli_profile,
|
||||
help=(
|
||||
"Profile CLI execution, print a summary, and write a timestamped .prof file. "
|
||||
"Place before COMMAND."
|
||||
),
|
||||
)
|
||||
@click.option(
|
||||
"--debug",
|
||||
"debug_logging",
|
||||
@@ -2031,7 +2134,17 @@ def main() -> None:
|
||||
# intentionally tiny (currently only help/version); runner flags live on
|
||||
# ``run``. Treat a leading non-top-level flag as bare-run shorthand so
|
||||
# users can type the natural no-AGENT launcher form.
|
||||
if argv and argv[0].startswith("-") and argv[0] not in {"--help", "-h", "--version"}:
|
||||
if (
|
||||
argv
|
||||
and argv[0].startswith("-")
|
||||
and argv[0]
|
||||
not in {
|
||||
"--help",
|
||||
"-h",
|
||||
"--version",
|
||||
"--profiling",
|
||||
}
|
||||
):
|
||||
argv = ["run", *argv]
|
||||
|
||||
# Shorthand: ``omnigent myagent.yaml [opts]`` → ``run myagent.yaml [opts]``.
|
||||
@@ -2201,6 +2314,10 @@ def _is_removed_ad_hoc_invocation(argv: list[str]) -> bool:
|
||||
# help listing subcommands, not the legacy argparse help.
|
||||
if argv[0] in {"--help", "-h", "--version"}:
|
||||
return False
|
||||
# A root profiling flag may precede an eager help/version flag or stand
|
||||
# alone. These are valid Click invocations, not removed ad-hoc chat.
|
||||
if all(token in {"--profiling", "--help", "-h", "--version"} for token in argv):
|
||||
return False
|
||||
# Skip leading flags to find the first positional. If all
|
||||
# tokens are flags (e.g. ``omnigent --system-prompt "..."``),
|
||||
# treat it as removed ad-hoc chat rather than handing it to click
|
||||
@@ -2863,6 +2980,7 @@ def _spawn_host_daemon_process(
|
||||
proc = subprocess.Popen(
|
||||
args,
|
||||
env=env,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=log_fh,
|
||||
stderr=log_fh,
|
||||
**_proc.spawn_kwargs(),
|
||||
@@ -10950,6 +11068,12 @@ def _run_databricks_browser_login(workspace_host: str, org_id: str | None = None
|
||||
the workspace rejects it).
|
||||
:raises click.ClickException: When the Databricks CLI binary is
|
||||
missing or the login exits non-zero.
|
||||
|
||||
The login writes to a ``.databrickscfg`` profile named after the
|
||||
workspace's first DNS label (e.g. ``acme`` for
|
||||
``acme.cloud.databricks.com``), keeping distinct workspaces off the
|
||||
shared ``DEFAULT`` profile. The OAuth grant itself stays host-keyed,
|
||||
so :func:`_databricks_workspace_token` still resolves it by host.
|
||||
"""
|
||||
databricks_bin = shutil.which("databricks")
|
||||
if databricks_bin is None:
|
||||
@@ -10958,14 +11082,21 @@ def _run_databricks_browser_login(workspace_host: str, org_id: str | None = None
|
||||
"Install it first: https://docs.databricks.com/dev-tools/cli/install.html"
|
||||
)
|
||||
login_host = _host_with_org(workspace_host, org_id)
|
||||
click.echo(f"Opening browser to log in to {login_host} ...")
|
||||
# Pin the grant to a profile named for the workspace's first DNS label so
|
||||
# distinct workspaces don't clobber each other under the CLI's ``DEFAULT``.
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
split = urlsplit(workspace_host.rstrip("/"))
|
||||
host = split.hostname or split.netloc or split.path
|
||||
profile = host.split(".")[0]
|
||||
click.echo(f"Opening browser to log in to {login_host} (profile {profile}) ...")
|
||||
result = subprocess.run(
|
||||
[databricks_bin, "auth", "login", "--host", login_host],
|
||||
[databricks_bin, "auth", "login", "--host", login_host, "--profile", profile],
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise click.ClickException(
|
||||
f"`databricks auth login --host {login_host}` failed "
|
||||
f"`databricks auth login --host {login_host} --profile {profile}` failed "
|
||||
f"(exit {result.returncode}). If the workspace is unreachable from "
|
||||
"this machine (VPN / IP access lists), resolve that and retry."
|
||||
)
|
||||
@@ -11188,6 +11319,9 @@ def login(server_url: str) -> None:
|
||||
token=token,
|
||||
user_id=user_id,
|
||||
expires_at=_time.time() + expires_in,
|
||||
# Login-issued refresh grant (newer servers) — lets the
|
||||
# host/CLI renew past session expiry unattended.
|
||||
refresh_token=result.get("refresh_token"),
|
||||
)
|
||||
click.echo(f"Logged in as {user_id}")
|
||||
_remember_default_server(server)
|
||||
@@ -11267,6 +11401,7 @@ def _accounts_login(server: str) -> None:
|
||||
token=token,
|
||||
user_id=user_id,
|
||||
expires_at=_time.time() + expires_in,
|
||||
refresh_token=body.get("refresh_token"),
|
||||
)
|
||||
click.echo(f"Logged in as {user_id}.")
|
||||
|
||||
|
||||
+263
-10
@@ -21,11 +21,13 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -35,6 +37,14 @@ if TYPE_CHECKING:
|
||||
_logger = logging.getLogger(__name__)
|
||||
_TOKEN_FILE_NAME = "auth_tokens.json"
|
||||
|
||||
# Treat a stored token with less than this much life left as needing
|
||||
# renewal. Shared by the "is it still usable" read path and the refresh
|
||||
# path's already-renewed check, so a caller that decides to refresh is
|
||||
# never handed back the same near-expiry token it wanted to replace.
|
||||
# Comfortably longer than a WebSocket handshake, far shorter than the
|
||||
# server's 1-hour access-token TTL.
|
||||
REFRESH_MIN_REMAINING_SECONDS = 90.0
|
||||
|
||||
|
||||
def _token_file_path() -> Path:
|
||||
"""Return the path to the auth token storage file.
|
||||
@@ -134,6 +144,10 @@ def _store_entry(server_url: str, entry: dict[str, str | float]) -> None:
|
||||
except (json.JSONDecodeError, OSError):
|
||||
data = {}
|
||||
|
||||
# Corrupt token files (non-dict JSON) read as empty — never crash.
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
|
||||
data[_normalize_server_url(server_url)] = entry
|
||||
|
||||
_write_tokens_file(path, data)
|
||||
@@ -144,6 +158,7 @@ def store_token(
|
||||
token: str,
|
||||
user_id: str,
|
||||
expires_at: float,
|
||||
refresh_token: str | None = None,
|
||||
) -> None:
|
||||
"""Persist a session token for a server.
|
||||
|
||||
@@ -153,15 +168,19 @@ def store_token(
|
||||
:param user_id: The authenticated user's email, e.g.
|
||||
``"alice@example.com"``.
|
||||
:param expires_at: Unix timestamp when the token expires.
|
||||
:param refresh_token: Login-issued refresh grant token, when the
|
||||
server handed one out. Lets :func:`refresh_stored_token` renew
|
||||
the access token past expiry without a human re-running
|
||||
``omnigent login``.
|
||||
"""
|
||||
_store_entry(
|
||||
server_url,
|
||||
{
|
||||
"token": token,
|
||||
"user_id": user_id,
|
||||
"expires_at": expires_at,
|
||||
},
|
||||
)
|
||||
entry: dict[str, str | float] = {
|
||||
"token": token,
|
||||
"user_id": user_id,
|
||||
"expires_at": expires_at,
|
||||
}
|
||||
if refresh_token is not None:
|
||||
entry["refresh_token"] = refresh_token
|
||||
_store_entry(server_url, entry)
|
||||
|
||||
|
||||
def store_databricks_auth(
|
||||
@@ -217,11 +236,15 @@ def _load_entry(server_url: str) -> dict[str, str | float] | None:
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
|
||||
# A token file holding valid JSON of the wrong shape (``[]``, ``null``,
|
||||
# a bare string) is corrupt, not fatal — read as "nothing stored".
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
entry = data.get(_normalize_server_url(server_url))
|
||||
return entry if isinstance(entry, dict) else None
|
||||
|
||||
|
||||
def load_token(server_url: str) -> str | None:
|
||||
def load_token(server_url: str, *, min_remaining_seconds: float = 0.0) -> str | None:
|
||||
"""Load a stored session token for a server.
|
||||
|
||||
Returns ``None`` if no token is stored, the token has expired,
|
||||
@@ -231,6 +254,11 @@ def load_token(server_url: str) -> str | None:
|
||||
|
||||
:param server_url: The server URL, e.g.
|
||||
``"http://localhost:6767"``.
|
||||
:param min_remaining_seconds: Require at least this much remaining
|
||||
lifetime. ``0`` (the default) accepts any not-yet-expired token —
|
||||
the historical behaviour. A caller that can renew passes
|
||||
:data:`REFRESH_MIN_REMAINING_SECONDS` so a token about to lapse
|
||||
mid-handshake is refreshed instead of used.
|
||||
:returns: The session JWT string, or ``None``.
|
||||
"""
|
||||
entry = _load_entry(server_url)
|
||||
@@ -239,13 +267,238 @@ def load_token(server_url: str) -> str | None:
|
||||
|
||||
expires_at = entry.get("expires_at", 0)
|
||||
if isinstance(expires_at, (int, float)) and expires_at < time.time():
|
||||
_logger.debug("Stored token for %s has expired", _normalize_server_url(server_url))
|
||||
_warn_expired_once(server_url, expires_at, has_refresh="refresh_token" in entry)
|
||||
return None
|
||||
# Near-expiry but still valid: decline quietly (no expiry warning — it
|
||||
# has not expired) so the caller can choose to renew.
|
||||
if (
|
||||
min_remaining_seconds > 0
|
||||
and isinstance(expires_at, (int, float))
|
||||
and expires_at - time.time() < min_remaining_seconds
|
||||
):
|
||||
return None
|
||||
|
||||
token = entry.get("token")
|
||||
return token if isinstance(token, str) else None
|
||||
|
||||
|
||||
# Servers already warned about an expired stored token, so a poll/retry
|
||||
# loop doesn't repeat the warning every few seconds.
|
||||
_warned_expired_servers: set[str] = set()
|
||||
|
||||
|
||||
def _warn_expired_once(server_url: str, expires_at: float, *, has_refresh: bool) -> None:
|
||||
"""Warn (once per process per server) that a stored token expired.
|
||||
|
||||
Expiry used to be a DEBUG line, which left the host dialing
|
||||
unauthenticated into a misleading 403 with no breadcrumb — an
|
||||
operator's first actionable signal must name the cause and the
|
||||
remedy.
|
||||
"""
|
||||
normalized = _normalize_server_url(server_url)
|
||||
if normalized in _warned_expired_servers:
|
||||
return
|
||||
_warned_expired_servers.add(normalized)
|
||||
expired_on = time.strftime("%Y-%m-%d %H:%M:%S UTC", time.gmtime(expires_at))
|
||||
if has_refresh:
|
||||
_logger.warning(
|
||||
"Stored login session for %s expired on %s; refresh will be "
|
||||
"attempted on the next command if possible",
|
||||
normalized,
|
||||
expired_on,
|
||||
)
|
||||
else:
|
||||
_logger.warning(
|
||||
"Stored login session for %s expired on %s and holds no refresh "
|
||||
"material. Run `omnigent login %s` to re-authenticate.",
|
||||
normalized,
|
||||
expired_on,
|
||||
normalized,
|
||||
)
|
||||
|
||||
|
||||
def stored_token_status(server_url: str) -> str:
|
||||
"""Classify the stored auth state for a server.
|
||||
|
||||
Lets callers distinguish "never logged in" from "logged in but the
|
||||
session lapsed" — the difference between proceeding unauthenticated
|
||||
(header-mode servers accept that) and surfacing an actionable
|
||||
re-login message.
|
||||
|
||||
:param server_url: The server URL.
|
||||
:returns: ``"ok"`` (valid token stored), ``"expired"`` (entry exists
|
||||
but the token lapsed), or ``"absent"`` (no token entry at all;
|
||||
includes Databricks pointer records, which hold no token).
|
||||
"""
|
||||
entry = _load_entry(server_url)
|
||||
if entry is None or not isinstance(entry.get("token"), str):
|
||||
return "absent"
|
||||
expires_at = entry.get("expires_at", 0)
|
||||
if isinstance(expires_at, (int, float)) and expires_at < time.time():
|
||||
return "expired"
|
||||
return "ok"
|
||||
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _token_file_lock() -> Iterator[None]:
|
||||
"""Exclusive advisory lock over token-file read-modify-write cycles.
|
||||
|
||||
Serializes concurrent refreshes on one machine (host + CLI sharing
|
||||
``auth_tokens.json``) so only one performs the network exchange and
|
||||
the other picks up its result. Best-effort on platforms without
|
||||
``fcntl`` (Windows): the refresh still works, only the local
|
||||
serialization is lost.
|
||||
|
||||
:raises OSError: If the lock file cannot be created or opened — the
|
||||
caller degrades to "cannot refresh" (a state directory we cannot
|
||||
write is one we could not persist the result to either).
|
||||
"""
|
||||
lock_path = _token_file_path().with_suffix(".lock")
|
||||
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
try:
|
||||
import fcntl
|
||||
except ImportError:
|
||||
yield
|
||||
return
|
||||
with open(lock_path, "w") as lock_file:
|
||||
fcntl.flock(lock_file, fcntl.LOCK_EX)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
fcntl.flock(lock_file, fcntl.LOCK_UN)
|
||||
|
||||
|
||||
def refresh_stored_token(server_url: str, *, timeout: float = 10.0) -> str | None:
|
||||
"""Renew the stored access token from its login-issued refresh grant.
|
||||
|
||||
POSTs ``grant_type=refresh_token`` to the server's ``/oauth/token``,
|
||||
persists the result, and returns the fresh access token. Safe to call
|
||||
opportunistically: returns ``None`` when there is nothing to refresh
|
||||
(no entry / no refresh material) or when the server refuses (grant
|
||||
revoked, past its absolute lifetime, or an older server without the
|
||||
endpoint).
|
||||
|
||||
Runs under the token-file lock and re-checks state after acquiring
|
||||
it, so of two concurrent callers only one performs the network
|
||||
refresh and the other returns the already-renewed token.
|
||||
|
||||
:param server_url: The server URL, e.g. ``"http://localhost:6767"``.
|
||||
:param timeout: HTTP timeout in seconds.
|
||||
:returns: A valid access token, or ``None``.
|
||||
"""
|
||||
normalized = _normalize_server_url(server_url)
|
||||
# Cheap pre-check BEFORE touching the lock file: with no refresh
|
||||
# material there is nothing to do, and creating a lock file would
|
||||
# raise on a read-only state directory — masking the caller's other
|
||||
# credential sources (e.g. the Databricks SDK fallback).
|
||||
pre = _load_entry(server_url)
|
||||
if pre is None or not isinstance(pre.get("refresh_token"), str) or not pre["refresh_token"]:
|
||||
return None
|
||||
try:
|
||||
with _token_file_lock():
|
||||
return _refresh_locked(server_url, normalized, timeout)
|
||||
except OSError as exc:
|
||||
# Cannot lock/persist (read-only or full state dir) — a refresh we
|
||||
# could not store is worse than none, so decline and let the caller
|
||||
# fall through to its other credential sources.
|
||||
_logger.debug("Token refresh for %s skipped, state dir unusable: %s", normalized, exc)
|
||||
return None
|
||||
|
||||
|
||||
def _refresh_locked(server_url: str, normalized: str, timeout: float) -> str | None:
|
||||
"""Perform the refresh exchange; caller holds the token-file lock."""
|
||||
entry = _load_entry(server_url)
|
||||
if entry is None:
|
||||
return None
|
||||
# Another process may have refreshed while we waited on the lock. A
|
||||
# freshly minted token is far from expiry, so this cleanly separates
|
||||
# "someone already renewed" from "this is the same near-expiry token".
|
||||
expires_at = entry.get("expires_at", 0)
|
||||
token = entry.get("token")
|
||||
if (
|
||||
isinstance(token, str)
|
||||
and isinstance(expires_at, (int, float))
|
||||
and expires_at - time.time() > REFRESH_MIN_REMAINING_SECONDS
|
||||
):
|
||||
return token
|
||||
refresh_token = entry.get("refresh_token")
|
||||
if not isinstance(refresh_token, str) or not refresh_token:
|
||||
return None
|
||||
|
||||
import httpx
|
||||
|
||||
try:
|
||||
resp = httpx.post(
|
||||
f"{normalized}/oauth/token",
|
||||
data={"grant_type": "refresh_token", "refresh_token": refresh_token},
|
||||
timeout=timeout,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
_logger.warning("Token refresh against %s failed: %s", normalized, exc)
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
_logger.warning(
|
||||
"Token refresh against %s refused (HTTP %d) — run `omnigent login %s` "
|
||||
"to re-authenticate.",
|
||||
normalized,
|
||||
resp.status_code,
|
||||
normalized,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
payload = resp.json()
|
||||
except ValueError:
|
||||
_logger.warning("Token refresh against %s returned a malformed response", normalized)
|
||||
return None
|
||||
if not isinstance(payload, dict):
|
||||
_logger.warning("Token refresh against %s returned a malformed response", normalized)
|
||||
return None
|
||||
access_token = payload.get("access_token")
|
||||
new_refresh = payload.get("refresh_token")
|
||||
# Only overwrite the stored pair with genuinely usable material —
|
||||
# a null/non-string field must never clobber a working credential.
|
||||
if not isinstance(access_token, str) or not access_token:
|
||||
_logger.warning("Token refresh against %s returned no access token", normalized)
|
||||
return None
|
||||
if not isinstance(new_refresh, str) or not new_refresh:
|
||||
# A server that renews without returning refresh material keeps the
|
||||
# one we already hold (login grants deliberately do not rotate).
|
||||
new_refresh = refresh_token
|
||||
expires_in = _coerce_expires_in(payload.get("expires_in"))
|
||||
|
||||
user_id = entry.get("user_id")
|
||||
store_token(
|
||||
server_url,
|
||||
token=access_token,
|
||||
user_id=user_id if isinstance(user_id, str) else "",
|
||||
expires_at=time.time() + expires_in,
|
||||
refresh_token=new_refresh,
|
||||
)
|
||||
# A fresh token means any earlier expiry warning is stale; allow
|
||||
# a new one if this credential ever lapses again.
|
||||
_warned_expired_servers.discard(normalized)
|
||||
_logger.info("Refreshed login session for %s", normalized)
|
||||
return access_token
|
||||
|
||||
|
||||
def _coerce_expires_in(raw: object) -> float:
|
||||
"""Return a sane access-token lifetime in seconds from *raw*.
|
||||
|
||||
Falls back to one hour for anything missing, non-numeric, or
|
||||
non-finite — ``float("NaN")``/``float("Infinity")`` parse happily but
|
||||
would yield an expiry that never compares as expired, pinning a dead
|
||||
token forever.
|
||||
"""
|
||||
default = 3600.0
|
||||
try:
|
||||
value = float(raw) # type: ignore[arg-type]
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def load_databricks_workspace_host(server_url: str) -> str | None:
|
||||
"""Load the workspace host from a Databricks Apps pointer record.
|
||||
|
||||
|
||||
@@ -853,11 +853,14 @@ async def _prepare_codex_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Codex session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Codex session...")
|
||||
session_id = await _create_codex_session(
|
||||
client,
|
||||
session_bundle,
|
||||
bridge_id=None,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_codex_session(
|
||||
client,
|
||||
session_bundle,
|
||||
bridge_id=None,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading Codex session...")
|
||||
@@ -910,7 +913,8 @@ async def _prepare_codex_terminal_via_daemon(
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -30,6 +31,7 @@ if TYPE_CHECKING:
|
||||
from omnigent.onboarding.provider_config import ProviderEntry
|
||||
from omnigent.spec.types import AgentSpec
|
||||
|
||||
from omnigent.codex_model_vocabulary import codex_spawn_model
|
||||
from omnigent.codex_native_bridge import write_policy_hook_config
|
||||
from omnigent.codex_native_process_registry import (
|
||||
CodexNativeProcessOwnerLock,
|
||||
@@ -784,13 +786,18 @@ async def _start_codex_model_discovery_process(
|
||||
listen_url: str,
|
||||
env: dict[str, str],
|
||||
cwd: Path,
|
||||
config_overrides: Sequence[str] = (),
|
||||
) -> asyncio.subprocess.Process:
|
||||
"""Start the isolated Codex process used only for model discovery."""
|
||||
override_args: list[str] = []
|
||||
for override in config_overrides:
|
||||
override_args.extend(("-c", override))
|
||||
return await asyncio.create_subprocess_exec(
|
||||
codex_path,
|
||||
"app-server",
|
||||
"--listen",
|
||||
listen_url,
|
||||
*override_args,
|
||||
stdin=asyncio.subprocess.DEVNULL,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
@@ -828,6 +835,189 @@ async def _wait_for_discovery_listener(
|
||||
raise TimeoutError("Timed out waiting for Codex model discovery app-server")
|
||||
|
||||
|
||||
def _probe_codex_home(config_overrides: Sequence[str]) -> Path:
|
||||
"""
|
||||
Persistent probe ``CODEX_HOME`` for one provider configuration.
|
||||
|
||||
Persistent (unlike the hermetic discovery's temp dir) so Codex's own
|
||||
``models_cache.json`` ETag handling makes repeat probes cheap; keyed by
|
||||
the override set so a provider change never replays another provider's
|
||||
cache. The account's real ``auth.json`` is symlinked in, the same way
|
||||
a session launch links it: the credential decides which models the
|
||||
account's catalog lists (login-gated entries, the account default), so
|
||||
a credential-less probe answers for a catalog no session will see.
|
||||
|
||||
:param config_overrides: The probe's ``-c`` overrides.
|
||||
:returns: The created ``CODEX_HOME`` directory.
|
||||
"""
|
||||
key = hashlib.sha256("\n".join(config_overrides).encode("utf-8")).hexdigest()[:12]
|
||||
home = Path.home() / ".omnigent" / "cache" / "codex-model-probe" / key
|
||||
home.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
real_auth = _codex_home_config_source_from_env() / "auth.json"
|
||||
probe_auth = home / "auth.json"
|
||||
if real_auth.exists():
|
||||
with contextlib.suppress(OSError):
|
||||
if probe_auth.is_symlink() or probe_auth.exists():
|
||||
probe_auth.unlink()
|
||||
probe_auth.symlink_to(real_auth)
|
||||
return home
|
||||
|
||||
|
||||
def mark_launch_default(rows: list[_JsonObject], pinned_model: str | None) -> list[_JsonObject]:
|
||||
"""
|
||||
Reduce ``model/list`` rows to exactly one ``isDefault`` marker.
|
||||
|
||||
The launch-pinned model wins when a row names it (either spelling);
|
||||
otherwise Codex's own first default stands. Rows are otherwise verbatim.
|
||||
|
||||
Codex's own ``isDefault`` is its built-in preference, which says nothing
|
||||
about the model this session launched on, so a picker that trusted it
|
||||
would name a model the pane is not running.
|
||||
|
||||
:param rows: Raw ``model/list`` rows.
|
||||
:param pinned_model: The model the session runs, or ``None``.
|
||||
:returns: The rows with a single default marked.
|
||||
"""
|
||||
from omnigent.codex_model_vocabulary import comparable_model_id
|
||||
|
||||
codex_default_index: int | None = None
|
||||
pinned_index: int | None = None
|
||||
pinned_key = comparable_model_id(pinned_model) if pinned_model else None
|
||||
marked: list[_JsonObject] = []
|
||||
for index, row in enumerate(rows):
|
||||
cleaned = {key: value for key, value in row.items() if key != "isDefault"}
|
||||
marked.append(cleaned)
|
||||
if codex_default_index is None and row.get("isDefault") is True:
|
||||
codex_default_index = index
|
||||
if pinned_index is None and pinned_key is not None:
|
||||
for spelling in (row.get("id"), row.get("model")):
|
||||
if isinstance(spelling, str) and comparable_model_id(spelling) == pinned_key:
|
||||
pinned_index = index
|
||||
break
|
||||
default_index = pinned_index if pinned_index is not None else codex_default_index
|
||||
if default_index is not None:
|
||||
marked[default_index]["isDefault"] = True
|
||||
return marked
|
||||
|
||||
|
||||
async def probe_codex_model_options(*, codex_path: str | None = None) -> list[_JsonObject]:
|
||||
"""
|
||||
Ask a session-configured Codex app-server for its own model list.
|
||||
|
||||
The harness is the source of truth for what a session's ``/model``
|
||||
picker would offer, so the probe boots ``codex app-server`` with the
|
||||
SAME materialization a session launch gets — for every launch shape.
|
||||
A Databricks profile contributes its provider overrides (gateway base
|
||||
URL + minted auth + model pin) and ``DATABRICKS_HOST``; other provider
|
||||
shapes carry their resolved ``-c`` overrides verbatim; the plain
|
||||
Codex-login shape probes bare, which yields the ACCOUNT's visible
|
||||
catalog: the probe home is isolated (never the user's real
|
||||
``~/.codex``) but links the real ``auth.json`` in the way a session
|
||||
launch does, so login-gated entries and the account default match what
|
||||
a live session will offer.
|
||||
|
||||
:param codex_path: Optional Codex executable override.
|
||||
:returns: The probe rows with a single default marked.
|
||||
:raises ImportError: When the Codex CLI is unavailable.
|
||||
:raises OSError: When a Databricks profile resolves no workspace host.
|
||||
:raises RuntimeError: When the probe app-server exits before connecting.
|
||||
:raises TimeoutError: When the probe app-server does not become ready.
|
||||
"""
|
||||
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
|
||||
resolved_codex = codex_path or _find_codex_cli()
|
||||
if not resolved_codex:
|
||||
raise ImportError("Native Codex model probing requires the 'codex' CLI on PATH.")
|
||||
config_overrides = list(launch.config_overrides)
|
||||
pinned_model = launch.model
|
||||
env = _clean_codex_env()
|
||||
if launch.profile is not None:
|
||||
databricks = await asyncio.to_thread(
|
||||
_databricks_launch_materialization, model=launch.model, profile=launch.profile
|
||||
)
|
||||
config_overrides.extend(databricks.config_overrides)
|
||||
env["DATABRICKS_HOST"] = databricks.host
|
||||
pinned_model = databricks.model
|
||||
codex_home = await asyncio.to_thread(_probe_codex_home, config_overrides)
|
||||
env["CODEX_HOME"] = str(codex_home)
|
||||
port = _allocate_loopback_port()
|
||||
listen_url = f"ws://127.0.0.1:{port}"
|
||||
process = await _start_codex_model_discovery_process(
|
||||
codex_path=resolved_codex,
|
||||
listen_url=listen_url,
|
||||
env=env,
|
||||
cwd=codex_home,
|
||||
config_overrides=config_overrides,
|
||||
)
|
||||
client: CodexAppServerClient | None = None
|
||||
try:
|
||||
await _wait_for_discovery_listener(process, port)
|
||||
client = CodexAppServerClient(
|
||||
ws_url=listen_url,
|
||||
client_name="omnigent-codex-model-probe",
|
||||
)
|
||||
await client.connect()
|
||||
rows = await list_codex_model_options(client)
|
||||
finally:
|
||||
if client is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await client.close()
|
||||
_proc.terminate_tree(process)
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=5.0)
|
||||
except TimeoutError:
|
||||
_proc.kill_tree(process)
|
||||
await process.wait()
|
||||
return mark_launch_default(rows, pinned_model)
|
||||
|
||||
|
||||
def codex_catalog_fingerprint(launch: NativeCodexLaunch) -> str:
|
||||
"""The launch-config fingerprint keying codex's shared model catalog.
|
||||
|
||||
One formula for every consumer (host boot probe, runner launch, live
|
||||
write-back), so they read and write the same catalog file. Callers
|
||||
fingerprint the SHAPE — a ``model=None`` resolution — so per-session
|
||||
picks never fragment the catalog.
|
||||
|
||||
:param launch: The resolved launch (``resolve_native_codex_launch``).
|
||||
:returns: A stable fingerprint string.
|
||||
"""
|
||||
from omnigent.model_catalog_store import fingerprint_of
|
||||
|
||||
return fingerprint_of(
|
||||
"codex-native", launch.profile, launch.model, tuple(launch.config_overrides)
|
||||
)
|
||||
|
||||
|
||||
async def codex_launch_catalog(*, codex_path: str | None = None) -> list[_JsonObject] | None:
|
||||
"""
|
||||
The shared codex catalog for this host's default shape: store, then probe.
|
||||
|
||||
Reads the on-disk catalog for the ``model=None`` launch shape; a miss
|
||||
pays one session-shaped probe (real auth linked in) and persists the
|
||||
answer for every later consumer.
|
||||
|
||||
:param codex_path: Optional Codex executable override.
|
||||
:returns: Catalog rows, or ``None`` when no catalog could be obtained.
|
||||
"""
|
||||
from omnigent import model_catalog_store
|
||||
|
||||
try:
|
||||
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
|
||||
except Exception: # noqa: BLE001 — a broken provider config means no catalog
|
||||
_logger.warning("codex catalog: launch shape resolution failed", exc_info=True)
|
||||
return None
|
||||
fingerprint = codex_catalog_fingerprint(launch)
|
||||
|
||||
async def _probe() -> list[_JsonObject] | None:
|
||||
try:
|
||||
return await probe_codex_model_options(codex_path=codex_path)
|
||||
except Exception: # noqa: BLE001 — probe failure means "no catalog", never a crash
|
||||
_logger.warning("codex catalog probe failed", exc_info=True)
|
||||
return None
|
||||
|
||||
return await model_catalog_store.ensure_catalog("codex-native", fingerprint, _probe)
|
||||
|
||||
|
||||
def _build_native_codex_app_server_argv(
|
||||
*,
|
||||
tagged_argv0: str,
|
||||
@@ -1776,6 +1966,63 @@ def _trust_codex_project(codex_home: Path, cwd: Path) -> None:
|
||||
config_path.write_text(tomlkit.dumps(document), encoding="utf-8")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _DatabricksLaunchMaterialization:
|
||||
"""
|
||||
The Databricks-profile pieces of a Codex launch, shared by the real
|
||||
app-server build and the model-options probe so the two cannot drift.
|
||||
|
||||
:param config_overrides: ``-c`` overrides routing Codex through the
|
||||
profile's AI Gateway (provider block + auth command + model pin).
|
||||
:param model: The model the overrides pin, e.g. ``"databricks-gpt-5-4"``
|
||||
— the explicit *model* when given, else the catalog default.
|
||||
:param host: The profile's workspace origin for ``DATABRICKS_HOST``.
|
||||
"""
|
||||
|
||||
config_overrides: list[str]
|
||||
model: str
|
||||
host: str
|
||||
|
||||
|
||||
def _databricks_launch_materialization(
|
||||
*, model: str | None, profile: str
|
||||
) -> _DatabricksLaunchMaterialization:
|
||||
"""
|
||||
Resolve the Databricks-profile routing pieces of a Codex launch.
|
||||
|
||||
Uses the profile's own host so the gateway base URL matches the token
|
||||
the profile-pinned auth command mints; a ``DATABRICKS_HOST`` override in
|
||||
the runner env must not point the base URL at another workspace.
|
||||
|
||||
:param model: Optional explicit model pin; ``None`` resolves the
|
||||
catalog default.
|
||||
:param profile: ``~/.databrickscfg`` profile name, e.g. ``"oss"``.
|
||||
:returns: The materialized overrides, pinned model, and host.
|
||||
:raises OSError: When the profile resolves no workspace host.
|
||||
"""
|
||||
host = _databricks_gateway_host(profile)
|
||||
if not host:
|
||||
raise OSError(
|
||||
f"Native Codex with Databricks profile {profile!r} (from your "
|
||||
"provider config) requires a matching ~/.databrickscfg section "
|
||||
"with a host visible to the runner process."
|
||||
)
|
||||
host = host.rstrip("/")
|
||||
# Resolve against what the workspace actually serves (live UC listing →
|
||||
# ucode state → bundled catalog), never the bundled catalog alone — its
|
||||
# legacy ``databricks-`` spellings can 501 on today's gateway.
|
||||
resolved_model = _resolve_databricks_codex_model(host, profile, model)
|
||||
return _DatabricksLaunchMaterialization(
|
||||
config_overrides=_databricks_codex_config_overrides(
|
||||
model=resolved_model,
|
||||
base_url=_databricks_codex_base_url(host),
|
||||
auth_command=_databricks_codex_auth_command(host, profile),
|
||||
),
|
||||
model=resolved_model,
|
||||
host=host,
|
||||
)
|
||||
|
||||
|
||||
# DATABRICKS-PATCH(codex-live-model-discovery)
|
||||
def _resolve_databricks_codex_model(host: str, profile: str, requested: str | None) -> str:
|
||||
"""Resolve the codex launch model against what the workspace serves.
|
||||
@@ -1910,26 +2157,18 @@ def build_codex_native_server(
|
||||
)
|
||||
env = _clean_codex_env()
|
||||
config_overrides: list[str] = []
|
||||
pinned_model = model
|
||||
if profile is not None:
|
||||
# Use the profile's own host so the gateway base URL matches the token
|
||||
# the profile-pinned auth command mints; a DATABRICKS_HOST override in
|
||||
# the runner env must not point the base URL at another workspace.
|
||||
host = _databricks_gateway_host(profile)
|
||||
if not host:
|
||||
raise OSError(
|
||||
f"Native Codex with Databricks profile {profile!r} (from your "
|
||||
"provider config) requires a matching ~/.databrickscfg section "
|
||||
"with a host visible to the runner process."
|
||||
)
|
||||
host = host.rstrip("/")
|
||||
config_overrides.extend(
|
||||
_databricks_codex_config_overrides(
|
||||
model=_resolve_databricks_codex_model(host, profile, model),
|
||||
base_url=_databricks_codex_base_url(host),
|
||||
auth_command=_databricks_codex_auth_command(host, profile),
|
||||
)
|
||||
)
|
||||
env["DATABRICKS_HOST"] = host
|
||||
databricks = _databricks_launch_materialization(model=model, profile=profile)
|
||||
config_overrides.extend(databricks.config_overrides)
|
||||
env["DATABRICKS_HOST"] = databricks.host
|
||||
# A launch that names no model still routes through the profile's
|
||||
# resolved model via ``-c model=``, which outranks the config.toml
|
||||
# copied from the user's shared home. Pin that model in codex's own
|
||||
# spelling — the vocabulary config.toml and its readers use — so the
|
||||
# forwarder mirror and cost gate report the model this session runs
|
||||
# instead of whatever the shared file was last left on.
|
||||
pinned_model = codex_spawn_model(databricks.model) or databricks.model
|
||||
if extra_config_overrides:
|
||||
config_overrides.extend(extra_config_overrides)
|
||||
if bypass_sandbox:
|
||||
@@ -1943,6 +2182,15 @@ def build_codex_native_server(
|
||||
'sandbox_mode="danger-full-access"',
|
||||
]
|
||||
)
|
||||
# Every launch is explicit: the resolved model rides argv (``-c model=``)
|
||||
# AND the private config copy's ``model =`` line (pinned in ``start``),
|
||||
# both written from this one value — so a stale line copied from the
|
||||
# user's shared config can never govern a session, and the two artifacts
|
||||
# cannot drift.
|
||||
if pinned_model and not any(
|
||||
override.split("=", 1)[0] == "model" for override in config_overrides
|
||||
):
|
||||
config_overrides.append(f"model={json.dumps(pinned_model)}")
|
||||
return CodexNativeAppServer(
|
||||
codex_path=resolved_codex,
|
||||
socket_path=socket_path,
|
||||
@@ -1955,7 +2203,7 @@ def build_codex_native_server(
|
||||
ap_server_url=ap_server_url,
|
||||
ap_auth_headers=ap_auth_headers,
|
||||
python_executable=python_executable,
|
||||
pinned_model=model,
|
||||
pinned_model=pinned_model,
|
||||
trust_project=trust_project,
|
||||
)
|
||||
|
||||
|
||||
@@ -342,9 +342,23 @@ def read_codex_config_model(bridge_dir: Path) -> str | None:
|
||||
:returns: The top-level ``model`` from ``config.toml`` (e.g.
|
||||
``"gpt-5.4"``), or ``None`` when undeterminable.
|
||||
"""
|
||||
config_path = codex_home_for_bridge_dir(bridge_dir) / "config.toml"
|
||||
return read_codex_home_config_model(codex_home_for_bridge_dir(bridge_dir))
|
||||
|
||||
|
||||
def read_codex_home_config_model(codex_home: Path) -> str | None:
|
||||
"""
|
||||
Read the active model straight from a session's ``CODEX_HOME``.
|
||||
|
||||
Same value and fail-safe behaviour as :func:`read_codex_config_model`,
|
||||
for callers that hold the ``CODEX_HOME`` path (e.g. a live bridge
|
||||
state) rather than the bridge directory.
|
||||
|
||||
:param codex_home: The session's private ``CODEX_HOME`` directory.
|
||||
:returns: The top-level ``model`` from ``config.toml`` (e.g.
|
||||
``"gpt-5.4"``), or ``None`` when undeterminable.
|
||||
"""
|
||||
try:
|
||||
data = tomllib.loads(config_path.read_text())
|
||||
data = tomllib.loads((codex_home / "config.toml").read_text())
|
||||
except (OSError, tomllib.TOMLDecodeError):
|
||||
return None
|
||||
model = data.get("model")
|
||||
|
||||
@@ -511,10 +511,13 @@ async def _prepare_cursor_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Cursor session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Cursor session...")
|
||||
session_id = await _create_cursor_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_cursor_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
# Persist the model pin before the runner binds and launches the
|
||||
# TUI (it reads model_override from the snapshot to build --model).
|
||||
@@ -575,7 +578,8 @@ async def _prepare_cursor_terminal_via_daemon(
|
||||
_update_startup_progress(startup_progress, "Updating Cursor session...")
|
||||
await _patch_cursor_session(client, session_id, patch)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
@@ -1455,6 +1455,9 @@ class SqlScheduledTask(OmnigentBase):
|
||||
# mirror the matching conversations.* override columns.
|
||||
model_override: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
reasoning_effort: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
# Per-firing cost budget in USD. When set, the fire path attaches a
|
||||
# cost_budget policy to each spawned session. NULL = no per-firing cap.
|
||||
max_cost_usd: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
workspace: Mapped[str | None] = mapped_column(String(2048), nullable=True)
|
||||
# Git base ref a firing branches from when it creates a worktree at fire
|
||||
# time (mirrors session-create's git.base_branch input). None when unset.
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
"""add max_cost_usd column to scheduled_tasks
|
||||
|
||||
Revision ID: za1b2c3d4e5f
|
||||
Revises: za2b3c4d5e6f
|
||||
Create Date: 2026-08-13 00:00:00.000000
|
||||
|
||||
Adds an optional ``max_cost_usd`` (FLOAT, nullable) column to
|
||||
``scheduled_tasks``. When set, the fire path attaches a ``cost_budget``
|
||||
policy to each spawned session capping cumulative spend at this limit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "za1b2c3d4e5f"
|
||||
down_revision: str | None = "za2b3c4d5e6f"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add max_cost_usd to scheduled_tasks."""
|
||||
op.add_column(
|
||||
"scheduled_tasks",
|
||||
sa.Column("max_cost_usd", sa.Float(), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove max_cost_usd from scheduled_tasks."""
|
||||
with op.batch_alter_table("scheduled_tasks") as batch_op:
|
||||
batch_op.drop_column("max_cost_usd")
|
||||
@@ -103,9 +103,15 @@ class Conversation:
|
||||
(alongside the runner-binding primitive of the Alpha
|
||||
runner-state design). Both paths validate the value against
|
||||
the supported set; invalid values fail with ``invalid_input``.
|
||||
:param model_override: Per-session LLM model override,
|
||||
e.g. ``"claude-opus-4-7"``. ``None`` means use the agent
|
||||
default from the spec's ``llm.model``. Mutable via
|
||||
:param reported_model: The model the harness last REPORTED the
|
||||
session is actually on, verbatim in the harness's own
|
||||
spelling, e.g. ``"claude-opus-4-8[1m]"``. Written only by
|
||||
harness reports (``external_model_change``); never by user
|
||||
picks. The only model value UI surfaces display. ``None``
|
||||
means no report has arrived yet.
|
||||
:param model_override: Per-session LLM model override — the user's
|
||||
REQUEST, e.g. ``"claude-opus-4-7"``. ``None`` means use the
|
||||
agent default from the spec's ``llm.model``. Mutable via
|
||||
``PATCH /v1/sessions/{id}`` and the REPL's ``/model``
|
||||
command. Mirrors the persistence shape of
|
||||
``reasoning_effort`` so the web UI and the TUI stay
|
||||
@@ -221,6 +227,7 @@ class Conversation:
|
||||
session_usage: dict[str, Any] = field(default_factory=dict)
|
||||
reasoning_effort: str | None = None
|
||||
model_override: str | None = None
|
||||
reported_model: str | None = None
|
||||
cost_control_mode_override: str | None = None
|
||||
subagent_routing_override: str | None = None
|
||||
harness_override: str | None = None
|
||||
|
||||
@@ -45,6 +45,11 @@ class ScheduledTask:
|
||||
``"claude-opus-4-7"``. ``None`` means use the agent default.
|
||||
:param reasoning_effort: Per-task reasoning-effort hint, e.g. ``"high"``.
|
||||
``None`` means use the agent default.
|
||||
:param max_cost_usd: Optional per-firing cost budget in USD. When set, the
|
||||
fire path attaches a ``cost_budget`` policy to each spawned session that
|
||||
blocks all models once cumulative spend reaches this limit. ``None``
|
||||
means no per-firing cost cap (the session runs unconstrained unless the
|
||||
agent spec or server-wide defaults impose one).
|
||||
:param workspace: Absolute existing path where a fired session's connected
|
||||
host runner should start. ``None`` only for legacy or invalid rows.
|
||||
:param base_branch: Reserved legacy column; scheduled tasks currently do
|
||||
@@ -74,6 +79,7 @@ class ScheduledTask:
|
||||
workspace_id: int = 0
|
||||
model_override: str | None = None
|
||||
reasoning_effort: str | None = None
|
||||
max_cost_usd: float | None = None
|
||||
workspace: str | None = None
|
||||
base_branch: str | None = None
|
||||
execution_target: str = "connected_host"
|
||||
|
||||
@@ -326,10 +326,13 @@ async def _prepare_goose_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Goose session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Goose session...")
|
||||
session_id = await _create_goose_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_goose_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading Goose session...")
|
||||
@@ -372,7 +375,8 @@ async def _prepare_goose_terminal_via_daemon(
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
@@ -324,10 +324,13 @@ async def _prepare_hermes_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Hermes session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Hermes session...")
|
||||
session_id = await _create_hermes_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_hermes_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading Hermes session...")
|
||||
@@ -370,7 +373,8 @@ async def _prepare_hermes_terminal_via_daemon(
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
+227
-127
@@ -29,7 +29,7 @@ from websockets.exceptions import ConnectionClosed, InvalidStatus, InvalidURI
|
||||
from omnigent._platform import IS_POSIX, WINDOWS_ENV_PASSTHROUGH
|
||||
from omnigent.env_credentials import env_names_with_omnigent_prefix
|
||||
from omnigent.gateway_inference import gateway_inference_map
|
||||
from omnigent.harness_aliases import canonicalize_harness
|
||||
from omnigent.harness_aliases import canonicalize_harness, is_claude_sdk_harness_name
|
||||
from omnigent.harness_availability import HARNESS_BINARY_MISSING, HarnessAvailability
|
||||
from omnigent.host import HOST_FATAL_EXIT_CODE
|
||||
from omnigent.host.frames import (
|
||||
@@ -327,15 +327,24 @@ def _connection_refused(exc: BaseException) -> bool:
|
||||
|
||||
|
||||
_RECONNECT_BASE_S = 0.5
|
||||
_RECONNECT_CAP_S = 10.0
|
||||
_RECONNECT_CAP_S = 3.0
|
||||
_RECONNECT_JITTER = 0.5
|
||||
# Keep first startup tolerant of a cold server, but do not spend the library's
|
||||
# full default timeout on each reconnect after an established tunnel drops.
|
||||
_INITIAL_CONNECT_OPEN_TIMEOUT_S = 10.0
|
||||
_RECONNECT_OPEN_TIMEOUT_S = 3.0
|
||||
# Fresh hosts get a short auth-retry window for Databricks OAuth refreshes.
|
||||
# Established hosts retry auth failures indefinitely to preserve sessions.
|
||||
_MAX_CONSECUTIVE_AUTH_ERRORS = 3
|
||||
# Consecutive connection-refused failures against a loopback server before the
|
||||
# host exits (~5 minutes at the backoff cap). Refused on loopback means no
|
||||
# process listens on the port — the local server is gone, not unreachable.
|
||||
_LOOPBACK_REFUSED_FATAL_ATTEMPTS = 30
|
||||
_LOOPBACK_REFUSED_FATAL_ATTEMPTS = 100
|
||||
|
||||
# Consecutive post-connect 401/403 rejections (~5 min at the backoff cap)
|
||||
# before the retry loop escalates from "check your VPN" to a re-auth prompt.
|
||||
# Operator-facing only — the host keeps retrying and never exits.
|
||||
_AUTH_REJECT_ESCALATE_ATTEMPTS = 30
|
||||
|
||||
# Consecutive accepted-then-silent connections (upgrade completed, then the
|
||||
# socket died without one inbound frame) before the reconnect loop treats the
|
||||
@@ -774,6 +783,19 @@ def _paginate_list_dir(
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ModelOptionsResult:
|
||||
"""One resolved model listing: picker rows + the settable-but-unlisted ids.
|
||||
|
||||
:param models: Verbatim catalog rows (id/model/displayName/isDefault…).
|
||||
:param routable_models: Ids a launch can pin that the picker does not
|
||||
list (older generations the endpoint still serves).
|
||||
"""
|
||||
|
||||
models: list[dict[str, object]]
|
||||
routable_models: list[str]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RunnerHandle:
|
||||
"""A spawned runner subprocess and where its output lands.
|
||||
@@ -1254,18 +1276,37 @@ class HostProcess:
|
||||
self._auth_retry_streak < _MAX_CONSECUTIVE_AUTH_ERRORS
|
||||
)
|
||||
if should_retry:
|
||||
_logger.warning("%s Retrying — check your VPN/network.", cause)
|
||||
if should_retry and self._auth_retry_streak == 1:
|
||||
# The warning above lands only in the CLI log file; print once
|
||||
# per outage so a foreground `omnigent host` isn't silent.
|
||||
print(
|
||||
f"⚠ {cause} Retrying — this usually means the VPN or "
|
||||
"network dropped. It will reconnect automatically once "
|
||||
"connectivity returns.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
if should_retry:
|
||||
# A sustained streak (vs. a brief VPN blip) means the credential
|
||||
# is very likely permanently rejected: escalate the operator
|
||||
# signal and name re-auth, but keep retrying so a real outage
|
||||
# self-heals.
|
||||
if (
|
||||
self._auth_retry_streak >= _AUTH_REJECT_ESCALATE_ATTEMPTS
|
||||
and self._auth_retry_streak % _AUTH_REJECT_ESCALATE_ATTEMPTS == 0
|
||||
):
|
||||
escalated = (
|
||||
f"{cause} The server has rejected it "
|
||||
f"{self._auth_retry_streak} times in a row — this is no "
|
||||
"longer a transient network blip. If it persists, the "
|
||||
"stored credential is likely no longer valid: run "
|
||||
f"`omnigent login {self._server_url}` and restart the "
|
||||
"host. Still retrying."
|
||||
)
|
||||
_logger.warning("%s", escalated)
|
||||
print(f"⚠ {escalated}", file=sys.stderr, flush=True)
|
||||
else:
|
||||
_logger.warning("%s Retrying — check your VPN/network.", cause)
|
||||
if self._auth_retry_streak == 1:
|
||||
# The warning above lands only in the CLI log file;
|
||||
# print once per outage so a foreground `omnigent host`
|
||||
# isn't silent.
|
||||
print(
|
||||
f"⚠ {cause} Retrying — this usually means the VPN or "
|
||||
"network dropped. It will reconnect automatically "
|
||||
"once connectivity returns.",
|
||||
file=sys.stderr,
|
||||
flush=True,
|
||||
)
|
||||
return None
|
||||
if status == 401:
|
||||
return HostConnectError(
|
||||
@@ -1276,6 +1317,20 @@ class HostProcess:
|
||||
+ self._login_fix_hint()
|
||||
)
|
||||
if status == 403:
|
||||
# An expired stored login is the common way to land here: the
|
||||
# token loader yields nothing, the dial goes out
|
||||
# unauthenticated, and the server's refusal looks like an
|
||||
# authorization or version-skew problem. Name the real cause.
|
||||
from omnigent.cli_auth import stored_token_status
|
||||
|
||||
if stored_token_status(self._server_url) == "expired":
|
||||
return HostConnectError(
|
||||
"Connection refused (HTTP 403): your stored login "
|
||||
f"session for {self._server_url} has EXPIRED, so the "
|
||||
"tunnel was dialed without credentials. Run `omnigent "
|
||||
f"login {self._server_url}` to re-authenticate, then "
|
||||
"restart the host."
|
||||
)
|
||||
return HostConnectError(
|
||||
"Connection refused (HTTP 403): the server repeatedly rejected the host "
|
||||
"tunnel. Either your "
|
||||
@@ -2226,6 +2281,70 @@ class HostProcess:
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
async def _prewarm_model_options(self) -> None:
|
||||
"""
|
||||
Fill the on-disk model catalogs for the probing harnesses at boot.
|
||||
|
||||
Runs both harness probes CONCURRENTLY, as detached background work —
|
||||
nothing (the tunnel, registration, readiness reporting, launches)
|
||||
ever waits on this. A picker request racing the boot probe joins the
|
||||
same single-flight probe through the shared store instead of
|
||||
starting a second one.
|
||||
|
||||
:returns: None. Probe failures are absorbed by the probe wrappers.
|
||||
"""
|
||||
await asyncio.gather(
|
||||
self._probed_codex_model_options(),
|
||||
self._probed_claude_model_options(),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
async def _probed_codex_model_options(self) -> ModelOptionsResult | None:
|
||||
"""
|
||||
Store-backed harness-truth Codex listing, or ``None`` on failure.
|
||||
|
||||
Every launch shape is answered from the shared on-disk catalog
|
||||
(probed from the configured Codex binary on a miss). There is no
|
||||
curated fallback: no catalog means an honest empty answer.
|
||||
|
||||
:returns: The catalog listing, or ``None`` when unavailable.
|
||||
"""
|
||||
from omnigent.codex_native_app_server import codex_launch_catalog
|
||||
|
||||
try:
|
||||
rows = await codex_launch_catalog()
|
||||
except Exception: # noqa: BLE001 — no catalog, never a crash
|
||||
_logger.warning("Codex model catalog unavailable", exc_info=True)
|
||||
return None
|
||||
if rows is None:
|
||||
return None
|
||||
routable = [row["id"] for row in rows if isinstance(row.get("id"), str) and row["id"]]
|
||||
return ModelOptionsResult(models=rows, routable_models=routable)
|
||||
|
||||
async def _probed_claude_model_options(self) -> ModelOptionsResult | None:
|
||||
"""
|
||||
Store-backed harness-truth Claude listing, or ``None`` on failure.
|
||||
|
||||
The shared catalog is keyed by the resolved launch config's
|
||||
fingerprint — the same file the runner reads at launch and serves in
|
||||
the session gear, so the pre-launch picker and the session cannot
|
||||
drift.
|
||||
|
||||
:returns: The catalog listing, or ``None`` when unavailable.
|
||||
"""
|
||||
from omnigent.claude_native import claude_launch_catalog, resolve_native_claude_config
|
||||
|
||||
try:
|
||||
config = await asyncio.to_thread(resolve_native_claude_config, spec=None)
|
||||
rows = await claude_launch_catalog(config)
|
||||
except Exception: # noqa: BLE001 — no catalog, never a crash
|
||||
_logger.warning("Claude model catalog unavailable", exc_info=True)
|
||||
return None
|
||||
if rows is None:
|
||||
return None
|
||||
routable = list(config.routable_models) if config is not None else []
|
||||
return ModelOptionsResult(models=rows, routable_models=routable)
|
||||
|
||||
async def _handle_model_options(
|
||||
self,
|
||||
frame: HostModelOptionsFrame,
|
||||
@@ -2240,107 +2359,23 @@ class HostProcess:
|
||||
"""
|
||||
harness = canonicalize_harness(frame.harness) or frame.harness
|
||||
if harness == "codex-native":
|
||||
try:
|
||||
from omnigent.codex_native_app_server import (
|
||||
discover_codex_model_options,
|
||||
resolve_native_codex_launch,
|
||||
)
|
||||
from omnigent.model_catalog import (
|
||||
is_direct_openai_provider,
|
||||
list_models_for_worker,
|
||||
resolve_catalog_model,
|
||||
resolve_model_provider,
|
||||
)
|
||||
from omnigent.spec.types import AgentSpec, ExecutorSpec
|
||||
|
||||
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
|
||||
spec = AgentSpec(
|
||||
spec_version=1,
|
||||
name="codex-native-prelaunch",
|
||||
executor=ExecutorSpec(
|
||||
type="omnigent",
|
||||
config={
|
||||
"harness": "codex-native",
|
||||
**({"profile": launch.profile} if launch.profile else {}),
|
||||
},
|
||||
),
|
||||
)
|
||||
listing = await asyncio.to_thread(list_models_for_worker, spec, "codex-native")
|
||||
default_model = launch.model
|
||||
if default_model is None and launch.profile is not None:
|
||||
default_model = (
|
||||
await asyncio.to_thread(
|
||||
resolve_catalog_model,
|
||||
"databricks",
|
||||
family="openai",
|
||||
)
|
||||
).model_id
|
||||
default_id = (
|
||||
default_model if default_model in {m.id for m in listing.models} else None
|
||||
)
|
||||
provider = (
|
||||
resolve_model_provider(spec, "codex-native")
|
||||
if listing.source == "openai-compatible"
|
||||
else None
|
||||
)
|
||||
models: list[dict[str, object]]
|
||||
if provider is not None and is_direct_openai_provider(provider):
|
||||
available_ids = {model.id for model in listing.models}
|
||||
models = []
|
||||
seen: set[str] = set()
|
||||
selected_default = False
|
||||
try:
|
||||
codex_options = await discover_codex_model_options()
|
||||
except Exception:
|
||||
_logger.exception("Failed to discover Codex-compatible pre-launch models")
|
||||
codex_options = []
|
||||
for option in codex_options:
|
||||
raw_id = option.get("model") or option.get("id")
|
||||
if (
|
||||
not isinstance(raw_id, str)
|
||||
or raw_id not in available_ids
|
||||
or raw_id in seen
|
||||
):
|
||||
continue
|
||||
seen.add(raw_id)
|
||||
display_name = option.get("displayName")
|
||||
is_default = raw_id == default_id or (
|
||||
default_model is None
|
||||
and not selected_default
|
||||
and option.get("isDefault") is True
|
||||
)
|
||||
selected_default = selected_default or is_default
|
||||
models.append(
|
||||
{
|
||||
"id": raw_id,
|
||||
"displayName": (
|
||||
display_name
|
||||
if isinstance(display_name, str) and display_name
|
||||
else raw_id
|
||||
),
|
||||
**({"isDefault": True} if is_default else {}),
|
||||
}
|
||||
)
|
||||
else:
|
||||
models = [
|
||||
{
|
||||
"id": model.id,
|
||||
"displayName": model.id,
|
||||
**({"isDefault": True} if model.id == default_id else {}),
|
||||
}
|
||||
for model in listing.models
|
||||
]
|
||||
except Exception:
|
||||
_logger.exception("Failed to resolve pre-launch Codex model options")
|
||||
# Harness-truth lane: every launch shape is answered from the
|
||||
# shared catalog, probed from the configured Codex binary itself.
|
||||
# No curated fallback and no serving-endpoints listing — a probe
|
||||
# that cannot run yields an honest empty answer with the reason.
|
||||
probed = await self._probed_codex_model_options()
|
||||
if probed is not None:
|
||||
return HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="failed",
|
||||
error="failed to resolve Codex model options",
|
||||
status="ok",
|
||||
models=probed.models,
|
||||
routable_models=probed.routable_models,
|
||||
)
|
||||
return HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="ok",
|
||||
models=models,
|
||||
models=[],
|
||||
error="the codex model probe failed — see the host log",
|
||||
)
|
||||
|
||||
if harness == "pi-native":
|
||||
@@ -2361,34 +2396,67 @@ class HostProcess:
|
||||
models=pi_models,
|
||||
)
|
||||
|
||||
if is_claude_sdk_harness_name(harness):
|
||||
# SDK-mode Claude is a pass-through client with no model catalog
|
||||
# of its own, so the endpoint listing IS the harness truth — the
|
||||
# ids are already in the exact spelling the SDK sends.
|
||||
try:
|
||||
from omnigent.model_catalog import list_models_for_worker
|
||||
from omnigent.spec.types import AgentSpec, ExecutorSpec
|
||||
|
||||
sdk_spec = AgentSpec(
|
||||
spec_version=1,
|
||||
name="claude-sdk-prelaunch",
|
||||
executor=ExecutorSpec(
|
||||
type="omnigent",
|
||||
config={"harness": "claude-sdk"},
|
||||
),
|
||||
)
|
||||
listing = await asyncio.to_thread(list_models_for_worker, sdk_spec, "claude-sdk")
|
||||
except Exception:
|
||||
_logger.exception("Failed to resolve pre-launch Claude SDK model options")
|
||||
return HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="failed",
|
||||
error="failed to resolve Claude SDK model options",
|
||||
)
|
||||
if not listing.models:
|
||||
# Subscription / CLI-login providers list nothing endpoint-side.
|
||||
# The SDK drives the claude CLI, so the CLI's own probed rows
|
||||
# (its aliases resolve inside the harness) are the truth here.
|
||||
probed = await self._probed_claude_model_options()
|
||||
if probed is not None:
|
||||
return HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="ok",
|
||||
models=probed.models,
|
||||
routable_models=probed.routable_models,
|
||||
)
|
||||
return HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="ok",
|
||||
models=[{"id": model.id, "displayName": model.id} for model in listing.models],
|
||||
routable_models=[model.id for model in listing.models],
|
||||
)
|
||||
if harness != "claude-native":
|
||||
return HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="failed",
|
||||
error=f"model options are unsupported for harness {frame.harness!r}",
|
||||
)
|
||||
try:
|
||||
from omnigent.claude_native import (
|
||||
claude_native_model_options,
|
||||
resolve_native_claude_config,
|
||||
)
|
||||
|
||||
config = await asyncio.to_thread(resolve_native_claude_config, spec=None)
|
||||
models = await asyncio.to_thread(claude_native_model_options, config)
|
||||
except Exception:
|
||||
_logger.exception("Failed to resolve pre-launch Claude model options")
|
||||
probed = await self._probed_claude_model_options()
|
||||
if probed is not None:
|
||||
return HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="failed",
|
||||
error="failed to resolve Claude model options",
|
||||
status="ok",
|
||||
models=probed.models,
|
||||
routable_models=probed.routable_models,
|
||||
)
|
||||
return HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="ok",
|
||||
models=models,
|
||||
# The picker names the newest model of each family; the endpoint
|
||||
# serves older generations too, and a launch takes an exact id.
|
||||
routable_models=list(config.routable_models) if config is not None else [],
|
||||
models=[],
|
||||
error="the claude model probe failed — see the host log",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -2913,6 +2981,11 @@ class HostProcess:
|
||||
additional_headers=headers,
|
||||
max_size=100 * 1024 * 1024,
|
||||
ssl=ssl_ctx,
|
||||
open_timeout=(
|
||||
_RECONNECT_OPEN_TIMEOUT_S
|
||||
if self._ever_connected
|
||||
else _INITIAL_CONNECT_OPEN_TIMEOUT_S
|
||||
),
|
||||
# Align the host->server tunnel's protocol keepalive to the same
|
||||
# 90 s app-level budget as the runner tunnel (not the 20 s library
|
||||
# default that drops a busy-but-healthy tunnel with 1011 — #1116).
|
||||
@@ -3073,7 +3146,18 @@ class HostProcess:
|
||||
flush=True,
|
||||
)
|
||||
|
||||
# Readiness refresh runs in its own task, never on this receive loop:
|
||||
# a harness probe that blocks (a hung CLI ``--version`` / ``auth
|
||||
# status``) must not delay ``ws.recv()`` or the inline keepalive pong
|
||||
# the server's watchdog counts as liveness, or it closes the tunnel
|
||||
# with ``4003 ping timeout``.
|
||||
readiness_task = asyncio.create_task(self._harness_readiness_loop(ws))
|
||||
# Warm the pre-launch model listings once a server can actually ask
|
||||
# for them, so the first picker open is served from cache instead of
|
||||
# waiting on a harness probe. Cache-fresh reconnects are a no-op.
|
||||
prewarm_task = asyncio.create_task(
|
||||
self._prewarm_model_options(), name="host-model-options-prewarm"
|
||||
)
|
||||
try:
|
||||
while True:
|
||||
raw = await ws.recv()
|
||||
@@ -3094,6 +3178,9 @@ class HostProcess:
|
||||
# _runner_lifecycle_lock in _dispatch_host_frame.
|
||||
self._start_frame_task(ws, raw)
|
||||
finally:
|
||||
prewarm_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await prewarm_task
|
||||
readiness_task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError, Exception):
|
||||
await readiness_task
|
||||
@@ -3302,7 +3389,20 @@ class HostProcess:
|
||||
fs_result = await asyncio.to_thread(self._handle_fs_request, frame)
|
||||
await ws.send(encode_host_frame(fs_result))
|
||||
elif isinstance(frame, HostModelOptionsFrame):
|
||||
await ws.send(encode_host_frame(await self._handle_model_options(frame)))
|
||||
# Every dispatched frame already runs on its own task (see
|
||||
# _start_frame_task), so a cold harness probe here cannot stall
|
||||
# the receive loop — answer inline, with a crash converted to an
|
||||
# honest failed frame so the server's request future settles.
|
||||
try:
|
||||
options_result = await self._handle_model_options(frame)
|
||||
except Exception:
|
||||
_logger.exception("Model options resolution crashed for %r", frame.harness)
|
||||
options_result = HostModelOptionsResultFrame(
|
||||
request_id=frame.request_id,
|
||||
status="failed",
|
||||
error=f"model options resolution crashed for {frame.harness!r}",
|
||||
)
|
||||
await ws.send(encode_host_frame(options_result))
|
||||
|
||||
|
||||
def run_host_process(
|
||||
|
||||
@@ -2123,6 +2123,9 @@ class _CodexAppServerSession:
|
||||
# on the next ``turn/completed`` so each TurnComplete carries the
|
||||
# usage for the turn that just finished.
|
||||
self._last_turn_usage: dict[str, object] | None = None
|
||||
# Serialize concurrent writes to the subprocess stdin so that parallel
|
||||
# tool-call responses don't interleave bytes on the pipe.
|
||||
self._stdin_lock = asyncio.Lock()
|
||||
|
||||
async def start(self) -> None:
|
||||
if self._started:
|
||||
@@ -2947,8 +2950,9 @@ class _CodexAppServerSession:
|
||||
|
||||
async def _send_message(self, payload: CodexMessage) -> None:
|
||||
assert self._proc is not None and self._proc.stdin is not None
|
||||
self._proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
|
||||
await self._proc.stdin.drain()
|
||||
async with self._stdin_lock:
|
||||
self._proc.stdin.write((json.dumps(payload) + "\n").encode("utf-8"))
|
||||
await self._proc.stdin.drain()
|
||||
|
||||
@staticmethod
|
||||
async def _iter_stream_chunks(stream: asyncio.StreamReader) -> AsyncIterator[bytes]:
|
||||
|
||||
@@ -40,7 +40,11 @@ from omnigent.inner.native_attachments import (
|
||||
parse_data_uri,
|
||||
unresolved_attachment_marker,
|
||||
)
|
||||
from omnigent.reasoning_effort import CODEX_EFFORTS, effort_for_model_switch, validate_effort
|
||||
from omnigent.reasoning_effort import (
|
||||
CODEX_NATIVE_EFFORTS,
|
||||
effort_for_model_switch,
|
||||
validate_effort,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -379,7 +383,7 @@ def _model_effort_overrides(config: ExecutorConfig | None) -> dict[str, object]:
|
||||
overrides["model"] = model
|
||||
raw_effort = config.extra.get("reasoning_effort")
|
||||
try:
|
||||
effort = validate_effort(raw_effort, "codex", CODEX_EFFORTS)
|
||||
effort = validate_effort(raw_effort, "codex", CODEX_NATIVE_EFFORTS)
|
||||
except ValueError:
|
||||
# A bad effort must not sink the turn — drop it and keep Codex's
|
||||
# current effort rather than failing the whole dispatch.
|
||||
|
||||
@@ -294,8 +294,9 @@ def _tmux_input_option_commands(scrollback: int) -> list[list[str]]:
|
||||
Build tmux options for scrollback and pane input behavior.
|
||||
|
||||
``history-limit`` is generated per terminal because it comes from
|
||||
``TerminalEnvSpec.scrollback``. ``mouse on`` makes the attached web
|
||||
terminal scrollable. ``focus-events on`` lets interactive programs
|
||||
``TerminalEnvSpec.scrollback``. ``set-clipboard external`` exports tmux
|
||||
copy-mode selections without trusting pane OSC 52 requests. ``mouse on``
|
||||
makes the attached web terminal scrollable. ``focus-events on`` lets interactive programs
|
||||
observe pane focus changes. ``extended-keys`` with CSI-u formatting
|
||||
lets programs inside tmux receive Kitty Keyboard Protocol keys such
|
||||
as Shift+Enter when the attached terminal supports them. Terminals
|
||||
@@ -311,6 +312,9 @@ def _tmux_input_option_commands(scrollback: int) -> list[list[str]]:
|
||||
["set-option", "-g", "history-limit", str(scrollback)],
|
||||
["set-option", "-sq", "extended-keys", "on"],
|
||||
["set-option", "-sq", "extended-keys-format", "csi-u"],
|
||||
# Export tmux copy-mode selections to attached terminals without letting
|
||||
# pane applications create tmux buffers through OSC 52.
|
||||
["set-option", "-sq", "set-clipboard", "external"],
|
||||
["set-option", "-g", "mouse", "on"],
|
||||
["set-option", "-g", "focus-events", "on"],
|
||||
["set-option", "-g", "escape-time", "0"],
|
||||
|
||||
@@ -333,10 +333,13 @@ async def _prepare_kimi_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Kimi session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Kimi session...")
|
||||
session_id = await _create_kimi_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_kimi_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading Kimi session...")
|
||||
@@ -385,7 +388,8 @@ async def _prepare_kimi_terminal_via_daemon(
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
@@ -364,10 +364,13 @@ async def _prepare_kiro_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Kiro session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Kiro session...")
|
||||
session_id = await _create_kiro_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_kiro_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading Kiro session...")
|
||||
@@ -409,7 +412,8 @@ async def _prepare_kiro_terminal_via_daemon(
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
+15
-29
@@ -46,7 +46,6 @@ from cachetools import TTLCache
|
||||
from omnigent._platform import default_shell_argv
|
||||
from omnigent.json_types import JsonObject as _JsonObject
|
||||
from omnigent.llms.anthropic_model_metadata import parse_anthropic_model_metadata
|
||||
from omnigent.model_fallbacks import StaticModelFallback, static_model_fallback
|
||||
from omnigent.model_metadata import (
|
||||
ModelCapability,
|
||||
ModelCostTier,
|
||||
@@ -206,15 +205,12 @@ class ModelListing:
|
||||
:param models: The enumerated models, e.g.
|
||||
``(ModelEntry(id="databricks-gpt-5-4", family="openai"),)``.
|
||||
:param note: Human-readable provenance / failure explanation.
|
||||
:param static_fallback: Ownership metadata for a release-curated fallback;
|
||||
``None`` for live or empty listings.
|
||||
"""
|
||||
|
||||
source: str
|
||||
verified: bool
|
||||
models: tuple[ModelEntry, ...]
|
||||
note: str
|
||||
static_fallback: StaticModelFallback | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -897,8 +893,7 @@ def _listing_payload(listing: ModelListing) -> _JsonObject:
|
||||
"""Serialize a :class:`ModelListing` into the tool's JSON row shape.
|
||||
|
||||
:param listing: The listing to serialize.
|
||||
:returns: Row dict; ``context_window`` and ``static_fallback`` appear only
|
||||
when known.
|
||||
:returns: Row dict; ``context_window`` appears only when known.
|
||||
"""
|
||||
models: list[_JsonObject] = []
|
||||
for entry in listing.models:
|
||||
@@ -934,12 +929,6 @@ def _listing_payload(listing: ModelListing) -> _JsonObject:
|
||||
"models": models,
|
||||
"note": listing.note,
|
||||
}
|
||||
if listing.static_fallback is not None:
|
||||
payload["static_fallback"] = {
|
||||
"owner": listing.static_fallback.owner,
|
||||
"provenance": listing.static_fallback.provenance,
|
||||
"discovery_gap": listing.static_fallback.discovery_gap,
|
||||
}
|
||||
return payload
|
||||
|
||||
|
||||
@@ -1061,23 +1050,24 @@ def _fetch_cursor_cli_listing(provider: ResolvedModelProvider) -> ModelListing:
|
||||
|
||||
|
||||
def _static_subscription_listing(provider: ResolvedModelProvider) -> ModelListing:
|
||||
"""Build the curated static listing for a subscription CLI login.
|
||||
"""Build the (empty) pre-launch listing for a subscription CLI login.
|
||||
|
||||
Subscription logins expose no model-listing API, and the curated
|
||||
stand-ins this used to serve are gone — the live harness probes are the
|
||||
source of truth, so a path that cannot probe reports nothing rather
|
||||
than a plausible-but-stale list.
|
||||
|
||||
:param provider: A ``kind="subscription"`` provider descriptor.
|
||||
:returns: A ``source="static"`` listing with ``verified=False``.
|
||||
:returns: A ``source="static"`` listing with no models.
|
||||
"""
|
||||
fallback = static_model_fallback(SUBSCRIPTION_KIND, provider.cli or "")
|
||||
ids = fallback.model_ids if fallback is not None else ()
|
||||
return ModelListing(
|
||||
source="static",
|
||||
verified=False,
|
||||
models=tuple(ModelEntry(id=i, family=model_family_token(i)) for i in ids),
|
||||
models=(),
|
||||
note=(
|
||||
f"curated aliases for the {provider.cli or 'unknown'} CLI login "
|
||||
"(subscription logins expose no model-listing API; availability "
|
||||
"depends on the logged-in plan)"
|
||||
f"the {provider.cli or 'unknown'} CLI login exposes no model-listing "
|
||||
"API before launch; the live listing comes from probing the harness"
|
||||
),
|
||||
static_fallback=fallback,
|
||||
)
|
||||
|
||||
|
||||
@@ -1091,20 +1081,16 @@ def _static_cli_config_listing(provider: ResolvedModelProvider) -> ModelListing:
|
||||
resolve — not a "no credentials" preflight failure.
|
||||
|
||||
:param provider: A ``kind="cli-config"`` provider descriptor.
|
||||
:returns: A ``source="static"`` listing with ``verified=False``.
|
||||
:returns: A ``source="static"`` listing with no models.
|
||||
"""
|
||||
fallback = static_model_fallback(CLI_CONFIG_KIND, provider.cli or "")
|
||||
ids = fallback.model_ids if fallback is not None else ()
|
||||
return ModelListing(
|
||||
source="static",
|
||||
verified=False,
|
||||
models=tuple(ModelEntry(id=i, family=model_family_token(i)) for i in ids),
|
||||
models=(),
|
||||
note=(
|
||||
f"curated ids for {provider.detail}; its credential lives in the "
|
||||
"CLI's own config file and is resolved by the CLI at launch, so "
|
||||
"it cannot be verified from here"
|
||||
f"{provider.detail} enumerates its models only from the CLI's own "
|
||||
"config at launch; the live listing comes from probing the harness"
|
||||
),
|
||||
static_fallback=fallback,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
"""The shared on-disk model-catalog store (model-flows-design.md §1.2).
|
||||
|
||||
One probe result, many consumers: whoever ran a harness's ``list_models``
|
||||
(the host at boot, the runner at launch when the file is absent, a live
|
||||
codex session writing back) persists the catalog here, keyed by harness and
|
||||
a launch-config fingerprint, and every surface — the pre-launch picker, the
|
||||
in-session gear, launch resolution and validation — reads the same bytes.
|
||||
Because writer and readers share one file, host/runner drift and
|
||||
probe-vs-session mismatch are impossible by construction.
|
||||
|
||||
The store holds only verbatim harness answers; nothing else ever writes it.
|
||||
A fingerprint mismatch is a miss (never a "close enough" hit), so an answer
|
||||
probed under one config can never serve another.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fingerprint_of(*parts: object) -> str:
|
||||
"""
|
||||
Stable fingerprint of a resolved harness configuration.
|
||||
|
||||
:param parts: Hashable configuration facets — resolved overrides, env
|
||||
pairs, binary identity. Stringified in order.
|
||||
:returns: A short hex digest.
|
||||
"""
|
||||
digest = hashlib.sha256()
|
||||
for part in parts:
|
||||
digest.update(repr(part).encode("utf-8"))
|
||||
digest.update(b"\x00")
|
||||
return digest.hexdigest()[:16]
|
||||
|
||||
|
||||
#: Catalog entries older than this get a background refresh on read (the
|
||||
#: readers decide; the store only reports staleness).
|
||||
CATALOG_STALE_AFTER_S = 3600.0
|
||||
|
||||
|
||||
def _data_dir() -> Path:
|
||||
"""Return the omnigent data dir (must stay in lock-step with
|
||||
``omnigent.host.local_server._local_data_dir`` /
|
||||
``omnigent.chat._omnigent_persistent_dir``).
|
||||
|
||||
:returns: ``$OMNIGENT_DATA_DIR`` when set, else ``~/.omnigent``.
|
||||
"""
|
||||
value = os.environ.get("OMNIGENT_DATA_DIR")
|
||||
if value:
|
||||
return Path(value).expanduser()
|
||||
return Path.home() / ".omnigent"
|
||||
|
||||
|
||||
def catalog_path(harness: str, fingerprint: str) -> Path:
|
||||
"""Return the catalog file path for one (harness, fingerprint).
|
||||
|
||||
:param harness: Canonical harness name, e.g. ``"claude-native"``.
|
||||
:param fingerprint: The launch-config fingerprint (:func:`fingerprint_of`).
|
||||
:returns: ``<data-dir>/cache/model-catalogs/<harness>-<fingerprint>.json``.
|
||||
"""
|
||||
return _data_dir() / "cache" / "model-catalogs" / f"{harness}-{fingerprint}.json"
|
||||
|
||||
|
||||
def read_catalog(harness: str, fingerprint: str) -> list[dict[str, Any]] | None:
|
||||
"""Read the stored catalog rows for one (harness, fingerprint).
|
||||
|
||||
:param harness: Canonical harness name.
|
||||
:param fingerprint: The launch-config fingerprint.
|
||||
:returns: The verbatim rows, or ``None`` on a miss / damaged file.
|
||||
"""
|
||||
path = catalog_path(harness, fingerprint)
|
||||
try:
|
||||
payload = json.loads(path.read_text())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
rows = payload.get("models") if isinstance(payload, dict) else None
|
||||
if not isinstance(rows, list):
|
||||
return None
|
||||
return [row for row in rows if isinstance(row, dict) and row.get("id")]
|
||||
|
||||
|
||||
def catalog_age_s(harness: str, fingerprint: str) -> float | None:
|
||||
"""Age of the stored catalog in seconds, or ``None`` on a miss."""
|
||||
path = catalog_path(harness, fingerprint)
|
||||
try:
|
||||
return max(0.0, time.time() - path.stat().st_mtime)
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def write_catalog(harness: str, fingerprint: str, rows: list[dict[str, Any]]) -> None:
|
||||
"""Persist catalog rows atomically (best-effort; failures only log).
|
||||
|
||||
:param harness: Canonical harness name.
|
||||
:param fingerprint: The launch-config fingerprint.
|
||||
:param rows: Verbatim harness rows to persist.
|
||||
"""
|
||||
path = catalog_path(harness, fingerprint)
|
||||
payload = {
|
||||
"harness": harness,
|
||||
"fingerprint": fingerprint,
|
||||
"written_at": time.time(),
|
||||
"models": rows,
|
||||
}
|
||||
try:
|
||||
path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
handle, tmp_name = tempfile.mkstemp(dir=str(path.parent), suffix=".tmp")
|
||||
try:
|
||||
with os.fdopen(handle, "w") as tmp:
|
||||
json.dump(payload, tmp, separators=(",", ":"))
|
||||
os.replace(tmp_name, path)
|
||||
except BaseException:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_name)
|
||||
raise
|
||||
except OSError:
|
||||
_logger.warning("could not persist the %s model catalog", harness, exc_info=True)
|
||||
|
||||
|
||||
#: In-flight probes, keyed (harness, fingerprint) — the thin single-flight
|
||||
#: wrapper the design keeps process-side: concurrent misses join one probe
|
||||
#: instead of each spawning CLI processes.
|
||||
_inflight: dict[tuple[str, str], asyncio.Task[list[dict[str, Any]] | None]] = {}
|
||||
|
||||
|
||||
async def ensure_catalog(
|
||||
harness: str,
|
||||
fingerprint: str,
|
||||
resolve: Callable[[], Awaitable[list[dict[str, Any]] | None]],
|
||||
) -> list[dict[str, Any]] | None:
|
||||
"""Store-first catalog access with a single probe in flight per key.
|
||||
|
||||
A hit serves immediately; a miss runs *resolve* once (concurrent
|
||||
callers join it), persists a non-empty answer, and returns it.
|
||||
|
||||
:param harness: Canonical harness name.
|
||||
:param fingerprint: The launch-config fingerprint.
|
||||
:param resolve: Probe coroutine factory producing verbatim rows.
|
||||
:returns: Catalog rows, or ``None`` when no catalog could be obtained.
|
||||
"""
|
||||
cached = read_catalog(harness, fingerprint)
|
||||
if cached is not None:
|
||||
return cached
|
||||
key = (harness, fingerprint)
|
||||
task = _inflight.get(key)
|
||||
if task is None or task.done():
|
||||
|
||||
async def _run() -> list[dict[str, Any]] | None:
|
||||
try:
|
||||
rows = await resolve()
|
||||
finally:
|
||||
_inflight.pop(key, None)
|
||||
if rows:
|
||||
write_catalog(harness, fingerprint, rows)
|
||||
return rows
|
||||
|
||||
task = asyncio.create_task(_run(), name=f"model-catalog-{harness}")
|
||||
_inflight[key] = task
|
||||
return await asyncio.shield(task)
|
||||
|
||||
|
||||
def default_row(rows: list[dict[str, Any]]) -> dict[str, Any] | None:
|
||||
"""Return the catalog's single ``isDefault`` row, if any.
|
||||
|
||||
:param rows: Catalog rows.
|
||||
:returns: The default row, or ``None``.
|
||||
"""
|
||||
return next((row for row in rows if row.get("isDefault") is True), None)
|
||||
|
||||
|
||||
def catalog_contains(rows: list[dict[str, Any]], token: str) -> bool:
|
||||
"""Whether *token* names a catalog row (by ``id`` or wire ``model``).
|
||||
|
||||
:param rows: Catalog rows.
|
||||
:param token: A picker row id or wire model id.
|
||||
:returns: ``True`` when some row's ``id`` or ``model`` equals *token*.
|
||||
"""
|
||||
return any(row.get("id") == token or row.get("model") == token for row in rows)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CATALOG_STALE_AFTER_S",
|
||||
"catalog_age_s",
|
||||
"catalog_contains",
|
||||
"catalog_path",
|
||||
"default_row",
|
||||
"ensure_catalog",
|
||||
"fingerprint_of",
|
||||
"read_catalog",
|
||||
"write_catalog",
|
||||
]
|
||||
+37
-43
@@ -1,10 +1,16 @@
|
||||
"""Owned static model fallbacks for CLI surfaces without discovery."""
|
||||
"""Owned static model tables for Smart Routing.
|
||||
|
||||
Pre-launch picker listings carry no static stand-ins anymore — the live
|
||||
harness probes (see ``omnigent.host.connect``) are their source of truth.
|
||||
What remains here is the router's operational data: rankings, arm menus,
|
||||
and probed exclusions that no discovery API can provide.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from omnigent.onboarding.provider_config import CLI_CONFIG_KIND, SUBSCRIPTION_KIND
|
||||
from omnigent.onboarding.provider_config import SUBSCRIPTION_KIND
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -17,56 +23,44 @@ class StaticModelFallback:
|
||||
discovery_gap: str
|
||||
|
||||
|
||||
_CLAUDE_SUBSCRIPTION_MODELS = (
|
||||
"claude-fable-5",
|
||||
"claude-opus-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-5",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
#: Curated preference ORDER for codex's current arms — a ranking hint only
|
||||
#: (preferred first), consumed by the Databricks live-discovery ranker to
|
||||
#: sort servable ids. It never invents picker rows: ids absent from the live
|
||||
#: listing are simply not ranked by it.
|
||||
_CODEX_ARM_PREFERENCE = StaticModelFallback(
|
||||
model_ids=("gpt-5.6-sol", "gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.5"),
|
||||
owner="Databricks model discovery (omnigent.databricks_model_discovery)",
|
||||
provenance="Omnigent's release-curated Codex arm ordering",
|
||||
discovery_gap="a workspace listing ranks models by neither recency nor capability",
|
||||
)
|
||||
|
||||
#: Codex's own model slugs, which spell the version with a DOT
|
||||
#: (``gpt-5.6-sol``). These reach codex's ChatGPT-account backend directly, so
|
||||
#: the Databricks serving spelling (``databricks-gpt-5-6-sol``, hyphens only)
|
||||
#: is rejected here with a 400 — unlike the gateway catalogs below, which are
|
||||
#: correctly hyphenated. Ordered cheapest-safe default first.
|
||||
_CODEX_MODELS = ("gpt-5.6-sol", "gpt-5.6-luna", "gpt-5.6-terra", "gpt-5.5")
|
||||
|
||||
_STATIC_MODEL_FALLBACKS = {
|
||||
(SUBSCRIPTION_KIND, "claude"): StaticModelFallback(
|
||||
model_ids=_CLAUDE_SUBSCRIPTION_MODELS,
|
||||
owner="Claude subscription adapter",
|
||||
provenance="Omnigent's release-curated Claude Code alias catalog",
|
||||
discovery_gap="Claude subscription logins expose no model-listing API",
|
||||
),
|
||||
(SUBSCRIPTION_KIND, "codex"): StaticModelFallback(
|
||||
model_ids=_CODEX_MODELS,
|
||||
owner="Codex subscription adapter",
|
||||
provenance="Omnigent's release-curated Codex alias catalog",
|
||||
discovery_gap="Codex subscription availability is not exposed before launch",
|
||||
),
|
||||
(CLI_CONFIG_KIND, "codex"): StaticModelFallback(
|
||||
model_ids=_CODEX_MODELS,
|
||||
owner="Codex CLI-config adapter",
|
||||
provenance="Omnigent's release-curated Codex alias catalog",
|
||||
discovery_gap=(
|
||||
"Custom model_provider entries live in Codex config.toml and cannot "
|
||||
"be enumerated on this catalog path"
|
||||
),
|
||||
),
|
||||
_STATIC_MODEL_FALLBACKS: dict[tuple[str, str], StaticModelFallback] = {
|
||||
(SUBSCRIPTION_KIND, "codex"): _CODEX_ARM_PREFERENCE,
|
||||
}
|
||||
|
||||
|
||||
def static_model_fallback(provider_kind: str, cli: str) -> StaticModelFallback | None:
|
||||
"""Return the owned fallback for a provider kind and CLI, if registered."""
|
||||
"""Return the owned fallback table for a provider kind and CLI, if registered."""
|
||||
return _STATIC_MODEL_FALLBACKS.get((provider_kind, cli))
|
||||
|
||||
|
||||
#: Codex's launch default when nothing else names a model. The bundled OpenAI
|
||||
#: catalog's newest row is a bare family alias (``gpt-5.6``) that codex rejects,
|
||||
#: so a codex launch defaults to a concrete variant from its own catalog.
|
||||
CODEX_DEFAULT_MODEL = _STATIC_MODEL_FALLBACKS[(SUBSCRIPTION_KIND, "codex")].model_ids[0]
|
||||
#: Codex's launch default when nothing else names a model. The bundled
|
||||
#: OpenAI catalog's newest row is a bare family alias (``gpt-5.6``) that
|
||||
#: codex rejects, so a codex launch defaults to a concrete variant from
|
||||
#: codex's own catalog — dotted spelling, since the Databricks hyphenated
|
||||
#: form 400s against codex's own backend.
|
||||
_CODEX_LAUNCH_DEFAULT = StaticModelFallback(
|
||||
model_ids=("gpt-5.6-sol",),
|
||||
owner="Codex native launch (omnigent.inner.codex_executor)",
|
||||
provenance="codex's own catalog slug for the cheapest current arm",
|
||||
discovery_gap=(
|
||||
"the launch default is resolved before any app-server probe can "
|
||||
"answer, and codex rejects the bundled catalog's newest row (a bare "
|
||||
"family alias)"
|
||||
),
|
||||
)
|
||||
|
||||
CODEX_DEFAULT_MODEL = _CODEX_LAUNCH_DEFAULT.model_ids[0]
|
||||
|
||||
|
||||
# ── Smart Routing ───────────────────────────────────────────────────────────
|
||||
|
||||
@@ -295,8 +295,11 @@ async def _prepare_opencode_terminal_via_daemon( # pragma: no cover
|
||||
"Creating an OpenCode session requires a session bundle."
|
||||
)
|
||||
_update_startup_progress(startup_progress, "Creating OpenCode session...")
|
||||
session_id = await _create_opencode_session(
|
||||
client, session_bundle, terminal_launch_args=persist_args or None
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_opencode_session(
|
||||
client, session_bundle, terminal_launch_args=persist_args or None
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading OpenCode session...")
|
||||
@@ -337,7 +340,8 @@ async def _prepare_opencode_terminal_via_daemon( # pragma: no cover
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
@@ -377,10 +377,13 @@ async def _prepare_pi_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a Pi session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating Pi session...")
|
||||
session_id = await _create_pi_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_pi_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading Pi session...")
|
||||
@@ -421,7 +424,8 @@ async def _prepare_pi_terminal_via_daemon(
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
@@ -324,10 +324,13 @@ async def _prepare_qwen_terminal_via_daemon(
|
||||
if session_bundle is None:
|
||||
raise click.ClickException("Creating a qwen session requires a session bundle.")
|
||||
_update_startup_progress(startup_progress, "Creating qwen session...")
|
||||
session_id = await _create_qwen_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
session_id, _ = await asyncio.gather(
|
||||
_create_qwen_session(
|
||||
client,
|
||||
session_bundle,
|
||||
terminal_launch_args=persist_args or None,
|
||||
),
|
||||
wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S),
|
||||
)
|
||||
else:
|
||||
_update_startup_progress(startup_progress, "Loading qwen session...")
|
||||
@@ -370,7 +373,8 @@ async def _prepare_qwen_terminal_via_daemon(
|
||||
f"({resp.status_code}): {error_text(resp)}"
|
||||
)
|
||||
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
if not fresh_session:
|
||||
await wait_for_host_online(client, host_id, timeout_s=_DAEMON_HOST_ONLINE_TIMEOUT_S)
|
||||
_update_startup_progress(startup_progress, "Starting runner...")
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
|
||||
@@ -8,23 +8,29 @@ from types import MappingProxyType
|
||||
|
||||
from omnigent.llms.errors import PermanentLLMError
|
||||
|
||||
EFFORT_VALUES = frozenset({"none", "minimal", "low", "medium", "high", "xhigh", "max"})
|
||||
EFFORT_VALUES = frozenset({"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"})
|
||||
EFFORT_CLEAR_VALUES = frozenset({"default", "off", "reset"})
|
||||
|
||||
# Deprecated / vendor-written effort values mapped to the canonical value to
|
||||
# use instead. The ChatGPT desktop app writes ``model_reasoning_effort =
|
||||
# "ultra"`` into ``~/.codex/config.toml``, and the codex CLI forwards it as
|
||||
# the retired ``max`` wire value — the OpenAI Responses API accepts neither
|
||||
# (its ladder tops out at ``xhigh``). ``validate_effort`` coerces an alias
|
||||
# only when the raw value is unsupported but the canonical value IS
|
||||
# supported, so providers that genuinely support ``max`` (Anthropic) keep it
|
||||
# unchanged.
|
||||
# Fold a value to a canonical one, but only where the target ladder lacks it:
|
||||
# ``validate_effort`` applies an alias only when the raw value is unsupported
|
||||
# but the canonical one is. On the SDK/Responses codex ladder (``CODEX_EFFORTS``,
|
||||
# capped at ``xhigh``) the ChatGPT app's ``ultra`` / retired ``max`` fold to
|
||||
# ``xhigh``; ladders that carry them (codex-native ``CODEX_NATIVE_EFFORTS``,
|
||||
# Anthropic's ``max``) keep them unchanged.
|
||||
EFFORT_ALIASES: dict[str, str] = {"ultra": "xhigh", "max": "xhigh"}
|
||||
|
||||
OPENAI_EFFORTS = frozenset({"none", "minimal", "low", "medium", "high", "xhigh"})
|
||||
ANTHROPIC_EFFORTS = frozenset({"low", "medium", "high", "xhigh", "max"})
|
||||
CLAUDE_EFFORTS = ANTHROPIC_EFFORTS
|
||||
CODEX_EFFORTS = OPENAI_EFFORTS
|
||||
# Codex-native drives the real codex process, which is the per-model authority
|
||||
# on reasoning levels — it advertises them via ``model/list`` and validates the
|
||||
# pairing itself. Sol reaches ``ultra``; the picker already gates which levels a
|
||||
# model offers, so accept codex's full ladder here rather than re-clamping a
|
||||
# valid pick down to ``xhigh``.
|
||||
CODEX_NATIVE_EFFORTS = frozenset(
|
||||
{"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"}
|
||||
)
|
||||
OPENAI_AGENTS_EFFORTS = OPENAI_EFFORTS
|
||||
GEMINI_EFFORTS = frozenset({"low", "medium", "high"})
|
||||
ANTIGRAVITY_EFFORTS = GEMINI_EFFORTS
|
||||
@@ -36,7 +42,7 @@ COPILOT_EFFORTS = frozenset({"low", "medium", "high", "xhigh"})
|
||||
|
||||
def format_supported(values: Iterable[str]) -> str:
|
||||
"""Return a stable comma-separated supported-values string."""
|
||||
order = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]
|
||||
order = ["none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"]
|
||||
values_set = set(values)
|
||||
return ", ".join(value for value in order if value in values_set)
|
||||
|
||||
@@ -61,8 +67,10 @@ def unsupported_effort_message(effort: str, provider: str, supported: Iterable[s
|
||||
# above stay frozen: they are the wire APIs' own vocabularies.
|
||||
_MODEL_EFFORT_FALLBACK: Mapping[str, str] = MappingProxyType({"glm-5-2": "medium"})
|
||||
# Efforts a fallback model cannot accept, so a pinned high value coerces down.
|
||||
# GLM tops out at ``high``, so every rung above it (``xhigh``/``max``/``ultra``)
|
||||
# is unsupported.
|
||||
_MODEL_EFFORT_UNSUPPORTED: Mapping[str, frozenset[str]] = MappingProxyType(
|
||||
{"glm-5-2": frozenset({"xhigh", "max"})}
|
||||
{"glm-5-2": frozenset({"xhigh", "max", "ultra"})}
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4814,7 +4814,7 @@ async def _cmd_theme(
|
||||
host.output(_build_preview(selected.name))
|
||||
|
||||
|
||||
_EFFORT_VALUES = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
|
||||
_EFFORT_VALUES = ("none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra")
|
||||
_EFFORT_CLEAR_ALIASES = {"default", "off", "reset"}
|
||||
|
||||
|
||||
@@ -4880,7 +4880,8 @@ async def _cmd_effort(
|
||||
host.output(
|
||||
Text.from_markup(
|
||||
" [bold red]Invalid effort: "
|
||||
f"{value} · expected none, minimal, low, medium, high, xhigh, max, or default[/]"
|
||||
f"{value} · expected none, minimal, low, medium, high, "
|
||||
"xhigh, max, ultra, or default[/]"
|
||||
)
|
||||
)
|
||||
return
|
||||
|
||||
+14
-11
@@ -221,17 +221,20 @@ def _dispatch_by_runtime(
|
||||
"""
|
||||
from omnigent.db.db_models import InvalidUuidError, uuid_to_bytes
|
||||
|
||||
# Resolve the id the argument contains, then canonicalize to bare hex
|
||||
# before any lookup: a paste drags punctuation along (trailing period,
|
||||
# wrapping quotes or backticks), and none of it can ever be part of a
|
||||
# valid id — so strip it and resume rather than erroring. A malformed
|
||||
# id would otherwise surface as a raw StatementError traceback from
|
||||
# the local store's Uuid16 bind, and downstream consumers key
|
||||
# sessions on the bare spelling.
|
||||
try:
|
||||
target = uuid_to_bytes(target.strip(_PASTE_PUNCTUATION)).hex()
|
||||
except InvalidUuidError as exc:
|
||||
raise click.ClickException("Invalid session id.") from exc
|
||||
# Paste punctuation (trailing period, wrapping quotes/backticks) is never
|
||||
# part of an id, so strip it. Only the local path binds the id to the sqlite
|
||||
# store's Uuid16 column, so it must be a real uuid — reject a malformed one
|
||||
# loudly rather than surfacing a raw StatementError. The remote server owns
|
||||
# its id space (a managed deployment keys sessions on non-uuid ids) and
|
||||
# validates the id itself, so forward it untouched, like the runner and SDK.
|
||||
stripped = target.strip(_PASTE_PUNCTUATION)
|
||||
if server is None:
|
||||
try:
|
||||
target = uuid_to_bytes(stripped).hex()
|
||||
except InvalidUuidError as exc:
|
||||
raise click.ClickException("Invalid session id.") from exc
|
||||
else:
|
||||
target = stripped
|
||||
|
||||
if server is not None:
|
||||
wrapper = _read_wrapper_label_remote(server=server, conv_id=target)
|
||||
|
||||
@@ -613,11 +613,34 @@ def _make_auth_token_factory(
|
||||
"""
|
||||
# Check stored OIDC token first.
|
||||
if resolved_server_url:
|
||||
from omnigent.cli_auth import load_token
|
||||
from omnigent.cli_auth import (
|
||||
REFRESH_MIN_REMAINING_SECONDS,
|
||||
load_token,
|
||||
refresh_stored_token,
|
||||
)
|
||||
|
||||
oidc_token = load_token(resolved_server_url)
|
||||
# Require enough remaining life that the token cannot lapse
|
||||
# mid-handshake; a token inside that window falls through to
|
||||
# the renewal path below rather than being used and rejected.
|
||||
oidc_token = load_token(
|
||||
resolved_server_url,
|
||||
min_remaining_seconds=REFRESH_MIN_REMAINING_SECONDS,
|
||||
)
|
||||
if oidc_token:
|
||||
return oidc_token
|
||||
# Expired or near-lapse: renew from the login-issued refresh
|
||||
# grant when one exists. This is what keeps an unattended host
|
||||
# alive past session-JWT expiry — the tunnel rebuilds headers
|
||||
# through this factory on every reconnect.
|
||||
refreshed = refresh_stored_token(resolved_server_url)
|
||||
if refreshed:
|
||||
return refreshed
|
||||
# Nothing to renew with: a near-expiry token that has NOT
|
||||
# actually lapsed still authenticates, so prefer it over
|
||||
# falling through to no credential at all.
|
||||
still_valid = load_token(resolved_server_url)
|
||||
if still_valid:
|
||||
return still_valid
|
||||
return _sdk_token()
|
||||
|
||||
# Probe once to check if a user credential is available.
|
||||
|
||||
+365
-28
@@ -170,6 +170,25 @@ from omnigent.tools.builtins.load_skill import (
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Claude-native session model listing: how long one request waits inline for
|
||||
# the probe before answering 503-pending, and how long the probe may stay
|
||||
# pending before the configured rows are served instead. Module-level so
|
||||
# tests can patch the pacing.
|
||||
_CLAUDE_MODEL_OPTIONS_INLINE_WAIT_S = 2.5
|
||||
|
||||
# Claude-native model switch confirmation: how long to watch the pane's
|
||||
# statusLine snapshot for the switched model after typing ``/model``, and how
|
||||
# often to re-read it. Module-level so tests can patch the pacing.
|
||||
_CLAUDE_MODEL_CONFIRM_TIMEOUT_S = 10.0
|
||||
_CLAUDE_MODEL_CONFIRM_POLL_S = 0.25
|
||||
|
||||
# How long the detached watcher keeps answering a /model confirm dialog that
|
||||
# pops after the active turn settles (a mid-turn switch queues in the
|
||||
# composer), and how often it looks. Long turns are common; the watch is
|
||||
# cheap (one tmux capture per poll) and never types blind.
|
||||
_CLAUDE_MODEL_LATE_DIALOG_BUDGET_S = 1200.0
|
||||
_CLAUDE_MODEL_LATE_DIALOG_POLL_S = 2.0
|
||||
|
||||
|
||||
def _warn_unresolved_sub_agent(session_id: str | None, sub_agent_name: str) -> None:
|
||||
"""
|
||||
@@ -2070,6 +2089,10 @@ def create_runner_app(
|
||||
_active_turns: dict[str, asyncio.Task[None] | None] = {}
|
||||
app.state.active_turns = _active_turns
|
||||
_native_pane_status: dict[str, str] = {}
|
||||
app.state.native_pane_status = _native_pane_status
|
||||
# Detached watchers answering a /model confirm dialog that pops after
|
||||
# the active turn settles (a mid-turn switch queues in the composer).
|
||||
_model_dialog_watchers: set[asyncio.Task[None]] = set()
|
||||
_session_message_buffers: dict[str, list[dict[str, Any]]] = {}
|
||||
app.state.session_message_buffers = _session_message_buffers
|
||||
_author_attribution_sessions: set[str] = set()
|
||||
@@ -2092,6 +2115,7 @@ def create_runner_app(
|
||||
app.state.desync_terminalized = _desync_terminalized
|
||||
_background_tasks: set[asyncio.Task[Any]] = set()
|
||||
_subagent_wake_pending: set[str] = set()
|
||||
_last_rewake_notice: dict[str, str] = {}
|
||||
|
||||
_session_histories = _session_histories_ref
|
||||
_last_server_item_id: dict[str, str] = {}
|
||||
@@ -2273,6 +2297,8 @@ def create_runner_app(
|
||||
app.state.session_resource_registry = resource_registry
|
||||
|
||||
def _publish_terminal_activity(session_id: str, terminal_id: str) -> None:
|
||||
if process_manager is not None:
|
||||
process_manager.note_activity(session_id)
|
||||
_publish_event(
|
||||
session_id,
|
||||
{
|
||||
@@ -3501,6 +3527,7 @@ def create_runner_app(
|
||||
_session_event_queues.pop(session_id, None)
|
||||
_session_inboxes.pop(session_id, None)
|
||||
_subagent_wake_pending.discard(session_id)
|
||||
_last_rewake_notice.pop(session_id, None)
|
||||
_session_sub_agent_names.pop(session_id, None)
|
||||
unregister_child_session(session_id)
|
||||
unregister_subagent_work_for_session(session_id)
|
||||
@@ -4075,7 +4102,16 @@ def create_runner_app(
|
||||
return Response(status_code=204)
|
||||
state = await _codex_native_bridge_state_for_session(conv_id, action="settings update")
|
||||
if state is None:
|
||||
return Response(status_code=204)
|
||||
# No loaded Codex bridge means nothing applied the settings; a
|
||||
# silent 204 here would let the caller claim a switch the
|
||||
# app-server never saw.
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "codex_native_settings_update_failed",
|
||||
"detail": "Codex-native settings update requires a loaded Codex bridge.",
|
||||
},
|
||||
)
|
||||
|
||||
codex_client = client_for_transport(
|
||||
state.socket_path,
|
||||
@@ -4126,7 +4162,13 @@ def create_runner_app(
|
||||
if resp.status_code == 200:
|
||||
snapshot = resp.json()
|
||||
if isinstance(snapshot, dict):
|
||||
raw_model = snapshot.get("model_override") or snapshot.get("llm_model")
|
||||
# ``llm_model`` is the harness's own report — the model
|
||||
# the pane is actually on. ``model_override`` is only a
|
||||
# request and may predate a relaunch or an unconfirmed
|
||||
# switch, so it is the fallback, not the lead: a
|
||||
# plan-mode toggle must re-assert the pane's real
|
||||
# model, never resurrect a stale ask.
|
||||
raw_model = snapshot.get("llm_model") or snapshot.get("model_override")
|
||||
if isinstance(raw_model, str) and raw_model.strip():
|
||||
model = raw_model.strip()
|
||||
raw_effort = snapshot.get("reasoning_effort")
|
||||
@@ -4188,7 +4230,9 @@ def create_runner_app(
|
||||
from omnigent.codex_native_app_server import (
|
||||
client_for_transport,
|
||||
list_codex_model_options,
|
||||
mark_launch_default,
|
||||
)
|
||||
from omnigent.codex_native_bridge import read_codex_home_config_model
|
||||
|
||||
state = await _codex_native_bridge_state_for_session(
|
||||
conv_id,
|
||||
@@ -4204,10 +4248,48 @@ def create_runner_app(
|
||||
)
|
||||
try:
|
||||
await codex_client.connect()
|
||||
return await list_codex_model_options(codex_client)
|
||||
rows = await list_codex_model_options(codex_client)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
await codex_client.close()
|
||||
active_model = await asyncio.to_thread(
|
||||
read_codex_home_config_model,
|
||||
Path(state.codex_home),
|
||||
)
|
||||
marked = mark_launch_default(rows, active_model)
|
||||
# Write the live account rows back to the shared catalog store so the
|
||||
# pre-launch picker converges to account truth after the first
|
||||
# session — keeping the SHAPE's stored default (a session's own pin
|
||||
# must not become the host-wide default).
|
||||
asyncio.get_running_loop().create_task(
|
||||
_write_back_codex_catalog([dict(row) for row in rows])
|
||||
)
|
||||
return marked
|
||||
|
||||
async def _write_back_codex_catalog(rows: list[_JsonObject]) -> None:
|
||||
try:
|
||||
from omnigent import model_catalog_store
|
||||
from omnigent.codex_native_app_server import (
|
||||
codex_catalog_fingerprint,
|
||||
mark_launch_default,
|
||||
resolve_native_codex_launch,
|
||||
)
|
||||
|
||||
launch = await asyncio.to_thread(resolve_native_codex_launch, model=None)
|
||||
fingerprint = codex_catalog_fingerprint(launch)
|
||||
stored = model_catalog_store.read_catalog("codex-native", fingerprint)
|
||||
stored_default = next(
|
||||
(row.get("id") for row in stored or [] if row.get("isDefault") is True),
|
||||
None,
|
||||
)
|
||||
shaped = mark_launch_default(
|
||||
rows, stored_default if isinstance(stored_default, str) else None
|
||||
)
|
||||
await asyncio.to_thread(
|
||||
model_catalog_store.write_catalog, "codex-native", fingerprint, shaped
|
||||
)
|
||||
except Exception: # noqa: BLE001 — write-back is best-effort
|
||||
_logger.debug("codex model-catalog write-back skipped", exc_info=True)
|
||||
|
||||
async def _handle_pi_native_model_change(
|
||||
conv_id: str,
|
||||
@@ -4267,6 +4349,50 @@ def create_runner_app(
|
||||
publish_event=_publish_event,
|
||||
)
|
||||
|
||||
async def _handle_claude_native_permission_mode_change(
|
||||
conv_id: str,
|
||||
mode: str | None,
|
||||
) -> Response:
|
||||
"""
|
||||
Switch a live claude-native session's permission mode.
|
||||
|
||||
Claude Code can only set the mode at launch (``--permission-mode``)
|
||||
or from its own shift+tab cycle, so the bridge drives that cycle
|
||||
and verifies the pane landed on *mode*. A 200 carries the mode now
|
||||
rendered, which the Omnigent server persists as the session's
|
||||
current mode.
|
||||
"""
|
||||
from omnigent.claude_native_bridge import (
|
||||
bridge_dir_for_bridge_id,
|
||||
set_permission_mode,
|
||||
)
|
||||
|
||||
if mode is None or not mode.strip():
|
||||
return Response(status_code=204)
|
||||
bridge_id = await _claude_native_bridge_id_for_session(
|
||||
server_client=server_client,
|
||||
session_id=conv_id,
|
||||
)
|
||||
bridge_dir = bridge_dir_for_bridge_id(bridge_id)
|
||||
try:
|
||||
settled = await asyncio.to_thread(
|
||||
set_permission_mode,
|
||||
bridge_dir,
|
||||
mode=mode.strip(),
|
||||
timeout_s=1.0,
|
||||
)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "claude_native_permission_mode_failed",
|
||||
"detail": _client_safe_error_detail(
|
||||
exc, context="claude-native permission mode change"
|
||||
),
|
||||
},
|
||||
)
|
||||
return JSONResponse(status_code=200, content={"permission_mode": settled})
|
||||
|
||||
async def _handle_claude_native_effort_change(
|
||||
conv_id: str,
|
||||
effort: str | None,
|
||||
@@ -4310,6 +4436,48 @@ def create_runner_app(
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
async def _watch_late_model_dialog(
|
||||
conv_id: str,
|
||||
bridge_dir: Path,
|
||||
expected: set[str],
|
||||
) -> None:
|
||||
"""Answer a ``/model`` confirm dialog that pops after the active turn.
|
||||
|
||||
A mid-turn switch queues in Claude's composer; the confirm dialog
|
||||
renders only when the turn settles — potentially minutes after the
|
||||
injection's own watch and the request's confirm window. This watcher
|
||||
presses Enter ONLY when the model dialog is verifiably on screen
|
||||
(never blind), stops as soon as the statusLine reports one of the
|
||||
expected spellings, and gives up quietly after its budget — the
|
||||
persisted request and the forwarder's verbatim report remain the
|
||||
authoritative record either way.
|
||||
"""
|
||||
from omnigent.claude_native_bridge import (
|
||||
SWITCH_MODEL_DIALOG_HINT,
|
||||
confirm_dialog_if_open,
|
||||
read_claude_status_model,
|
||||
)
|
||||
|
||||
deadline = time.monotonic() + _CLAUDE_MODEL_LATE_DIALOG_BUDGET_S
|
||||
while time.monotonic() < deadline:
|
||||
try:
|
||||
current = await asyncio.to_thread(read_claude_status_model, bridge_dir)
|
||||
if current and current in expected:
|
||||
return
|
||||
await asyncio.to_thread(
|
||||
confirm_dialog_if_open, bridge_dir, hint=SWITCH_MODEL_DIALOG_HINT
|
||||
)
|
||||
except Exception: # noqa: BLE001 — best-effort; the report reconciles
|
||||
_logger.debug(
|
||||
"late model-dialog watch errored for session=%s", conv_id, exc_info=True
|
||||
)
|
||||
return
|
||||
await asyncio.sleep(_CLAUDE_MODEL_LATE_DIALOG_POLL_S)
|
||||
_logger.info(
|
||||
"late model-dialog watch for session=%s ended without a confirmed switch",
|
||||
conv_id,
|
||||
)
|
||||
|
||||
async def _handle_claude_native_model_change(
|
||||
conv_id: str,
|
||||
model: str | None,
|
||||
@@ -4321,7 +4489,9 @@ def create_runner_app(
|
||||
from omnigent.claude_native_bridge import (
|
||||
SWITCH_MODEL_DIALOG_HINT,
|
||||
bridge_dir_for_bridge_id,
|
||||
confirm_dialog_if_open,
|
||||
inject_slash_command,
|
||||
read_claude_status_model,
|
||||
read_model_env,
|
||||
)
|
||||
|
||||
@@ -4362,6 +4532,7 @@ def create_runner_app(
|
||||
},
|
||||
)
|
||||
command = f"/model {model_arg}"
|
||||
baseline = await asyncio.to_thread(read_claude_status_model, bridge_dir)
|
||||
try:
|
||||
# Accepted trade-off: ``/model <id>`` also saves the pick as the
|
||||
# person's global default in ``~/.claude/settings.json``. Driving
|
||||
@@ -4383,7 +4554,80 @@ def create_runner_app(
|
||||
"detail": _client_safe_error_detail(exc, context="claude-native model change"),
|
||||
},
|
||||
)
|
||||
return Response(status_code=204)
|
||||
# Verify against the statusLine snapshot the forwarder already polls:
|
||||
# Claude rewrites it on every render, including right after ``/model``.
|
||||
# Expected spellings come from this session's own catalog rows (every
|
||||
# row's ``model`` is the harness's own resolution), plus the typed arg
|
||||
# and its selection mapping. Success replies only after the pane
|
||||
# actually switched; the swallowed-dialog case answers non-2xx so the
|
||||
# server surfaces it instead of the row silently claiming the pick.
|
||||
expected = {value for value in (resolved_model, model_arg) if value}
|
||||
for row in _claude_model_options_rows.get(conv_id) or []:
|
||||
if row.get("id") in (selected_model, resolved_model) or row.get("model") in (
|
||||
selected_model,
|
||||
resolved_model,
|
||||
):
|
||||
row_model = row.get("model")
|
||||
if isinstance(row_model, str) and row_model:
|
||||
expected.add(row_model)
|
||||
deadline = time.monotonic() + _CLAUDE_MODEL_CONFIRM_TIMEOUT_S
|
||||
while True:
|
||||
current = await asyncio.to_thread(read_claude_status_model, bridge_dir)
|
||||
if current and (current in expected or (baseline and current != baseline)):
|
||||
# The pane switched. When it landed somewhere other than the
|
||||
# expected spelling, the forwarder's verbatim report is the
|
||||
# truth the UI will settle on — the command still took effect.
|
||||
return Response(status_code=204)
|
||||
if baseline is None and current is None:
|
||||
# No statusLine snapshot on either side of the injection: a
|
||||
# live wrapper-managed pane writes one on every render, so
|
||||
# this is a shape without the wrapper — the switch is
|
||||
# unverifiable, not failed. Report success and leave the
|
||||
# forwarder to reconcile the row.
|
||||
_logger.warning(
|
||||
"claude-native model change for session=%s could not be verified: "
|
||||
"no statusLine snapshot in %s",
|
||||
conv_id,
|
||||
bridge_dir,
|
||||
)
|
||||
return Response(status_code=204)
|
||||
# The confirm dialog can render well after the injection's own
|
||||
# short watch (a warm repaint, or a queued command surfacing) —
|
||||
# answer it whenever it shows inside the window.
|
||||
await asyncio.to_thread(
|
||||
confirm_dialog_if_open, bridge_dir, hint=SWITCH_MODEL_DIALOG_HINT
|
||||
)
|
||||
if time.monotonic() >= deadline:
|
||||
break
|
||||
await asyncio.sleep(_CLAUDE_MODEL_CONFIRM_POLL_S)
|
||||
if _native_pane_status.get(conv_id) in ("running", "waiting"):
|
||||
# Mid-turn switch: Claude queues the typed command and applies it
|
||||
# when the turn settles — its confirm dialog can pop minutes from
|
||||
# now. Not a failure: answer success, keep a detached watcher on
|
||||
# the late dialog, and let the forwarder's report settle the
|
||||
# picker when the switch actually lands.
|
||||
watcher = asyncio.create_task(
|
||||
_watch_late_model_dialog(conv_id, bridge_dir, expected),
|
||||
name=f"claude-model-dialog-{conv_id}",
|
||||
)
|
||||
_model_dialog_watchers.add(watcher)
|
||||
watcher.add_done_callback(_model_dialog_watchers.discard)
|
||||
_logger.info(
|
||||
"claude-native model change for session=%s is queued behind an active "
|
||||
"turn; watching for the late confirm dialog",
|
||||
conv_id,
|
||||
)
|
||||
return Response(status_code=204)
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "claude_native_model_unconfirmed",
|
||||
"detail": (
|
||||
f"the terminal did not confirm the switch to {model_arg} within "
|
||||
f"{_CLAUDE_MODEL_CONFIRM_TIMEOUT_S:.0f}s — a dialog may be open in the pane"
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
async def _apply_claude_native_plan_verdict(
|
||||
conv_id: str,
|
||||
@@ -5386,12 +5630,20 @@ def create_runner_app(
|
||||
_cond.notify_all()
|
||||
|
||||
async def _post_subagent_wake_notice(
|
||||
parent_id: str, notice: str, child_id: str, created_by: str | None
|
||||
parent_id: str,
|
||||
notice: str,
|
||||
child_id: str,
|
||||
created_by: str | None,
|
||||
*,
|
||||
is_rewake: bool = False,
|
||||
) -> None:
|
||||
delivered = await _deliver_subagent_wake_post(
|
||||
server_client, parent_id, notice, created_by=created_by
|
||||
)
|
||||
if not delivered:
|
||||
if delivered:
|
||||
if is_rewake:
|
||||
_last_rewake_notice[parent_id] = notice
|
||||
else:
|
||||
_subagent_wake_pending.discard(parent_id)
|
||||
_logger.warning(
|
||||
"Sub-agent wake POST failed for parent=%s child=%s after %d attempt(s); "
|
||||
@@ -5401,7 +5653,7 @@ def create_runner_app(
|
||||
_WAKE_POST_MAX_ATTEMPTS,
|
||||
)
|
||||
|
||||
def _schedule_subagent_wake(entry: _SubagentWorkEntry) -> None:
|
||||
def _schedule_subagent_wake(entry: _SubagentWorkEntry, *, is_rewake: bool = False) -> None:
|
||||
if entry.parent_session_id == entry.child_session_id:
|
||||
return
|
||||
inbox = _session_inboxes.get(entry.parent_session_id)
|
||||
@@ -5413,30 +5665,39 @@ def create_runner_app(
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
return
|
||||
_subagent_wake_pending.add(entry.parent_session_id)
|
||||
notice = _format_subagent_wake_notice(
|
||||
agent=entry.agent,
|
||||
title=entry.title,
|
||||
status=entry.status,
|
||||
pending=inbox.qsize(),
|
||||
)
|
||||
if is_rewake and notice == _last_rewake_notice.get(entry.parent_session_id):
|
||||
return
|
||||
_subagent_wake_pending.add(entry.parent_session_id)
|
||||
_wake_task = loop.create_task(
|
||||
_post_subagent_wake_notice(
|
||||
entry.parent_session_id,
|
||||
notice,
|
||||
entry.child_session_id,
|
||||
entry.created_by,
|
||||
is_rewake=is_rewake,
|
||||
)
|
||||
)
|
||||
_wake_task.add_done_callback(_background_tasks.discard)
|
||||
_background_tasks.add(_wake_task)
|
||||
|
||||
def _rewake_parent_if_inbox_stranded(parent_session_id: str) -> None:
|
||||
inbox = _session_inboxes.get(parent_session_id)
|
||||
drained = inbox is None or inbox.empty()
|
||||
if drained:
|
||||
# A drained inbox ends the stranding episode, so the recorded
|
||||
# re-wake no longer describes outstanding work; forget it or a
|
||||
# later episode's matching notice is wrongly deduped.
|
||||
_last_rewake_notice.pop(parent_session_id, None)
|
||||
if parent_session_id not in _subagent_wake_pending:
|
||||
return
|
||||
_subagent_wake_pending.discard(parent_session_id)
|
||||
inbox = _session_inboxes.get(parent_session_id)
|
||||
if inbox is None or inbox.empty():
|
||||
if drained:
|
||||
return
|
||||
entries = list_subagent_work(parent_session_id)
|
||||
if not entries:
|
||||
@@ -5445,7 +5706,7 @@ def create_runner_app(
|
||||
entries,
|
||||
key=lambda entry: entry.completed_at if entry.completed_at is not None else 0.0,
|
||||
)
|
||||
_schedule_subagent_wake(latest)
|
||||
_schedule_subagent_wake(latest, is_rewake=True)
|
||||
|
||||
def _mark_subagent_terminal_and_wake(
|
||||
child_session_id: str, *, status: str, output: str | None
|
||||
@@ -6939,6 +7200,24 @@ def create_runner_app(
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
if body_type == "permission_mode_change":
|
||||
harness = _session_harness_name(conversation_id)
|
||||
if harness == "claude-native":
|
||||
mode = body.get("permission_mode") if isinstance(body, dict) else None
|
||||
if mode is not None and not isinstance(mode, str):
|
||||
return JSONResponse(
|
||||
status_code=400,
|
||||
content={
|
||||
"error": "invalid_input",
|
||||
"detail": "Body 'permission_mode' must be a string or null",
|
||||
},
|
||||
)
|
||||
return await _handle_claude_native_permission_mode_change(
|
||||
conversation_id,
|
||||
mode,
|
||||
)
|
||||
return Response(status_code=204)
|
||||
|
||||
codex_goal_response = await codex_goal_runner.handle_event(
|
||||
conversation_id,
|
||||
body_type,
|
||||
@@ -7824,18 +8103,23 @@ def create_runner_app(
|
||||
override=transport,
|
||||
spec_transport=entry.instance.terminal_transport,
|
||||
)
|
||||
bridge = (
|
||||
bridge_tmux_control_to_websocket
|
||||
if resolved_transport == TERMINAL_TRANSPORT_CONTROL
|
||||
else bridge_tmux_pty_to_websocket
|
||||
)
|
||||
await bridge(
|
||||
websocket,
|
||||
socket_path=str(entry.instance.socket_path),
|
||||
tmux_target=entry.instance.tmux_target,
|
||||
read_only=read_only,
|
||||
on_client_interaction=entry.instance.note_client_interaction,
|
||||
)
|
||||
if resolved_transport == TERMINAL_TRANSPORT_CONTROL:
|
||||
await bridge_tmux_control_to_websocket(
|
||||
websocket,
|
||||
socket_path=str(entry.instance.socket_path),
|
||||
tmux_target=entry.instance.tmux_target,
|
||||
read_only=read_only,
|
||||
on_client_interaction=entry.instance.note_client_interaction,
|
||||
)
|
||||
else:
|
||||
await bridge_tmux_pty_to_websocket(
|
||||
websocket,
|
||||
socket_path=str(entry.instance.socket_path),
|
||||
tmux_target=entry.instance.tmux_target,
|
||||
read_only=read_only,
|
||||
on_client_interaction=entry.instance.note_client_interaction,
|
||||
allow_osc52_clipboard=not entry.instance.tmux_allow_passthrough,
|
||||
)
|
||||
|
||||
# Reused by the loopback direct-attach listener (see
|
||||
# ``omnigent.runner.direct_attach``): same attach handler served on a
|
||||
@@ -8538,10 +8822,23 @@ def create_runner_app(
|
||||
}
|
||||
return JSONResponse(status_code=200, content={"models": models})
|
||||
|
||||
# Claude's session listing IS the shared launch catalog: the same
|
||||
# fingerprint-keyed store file the launch resolved against and the
|
||||
# host's pre-launch picker serves — identical by construction, no
|
||||
# separate composition. Cached per session for its lifetime (the launch
|
||||
# config cannot change under it). A cold store pays one probe: a short
|
||||
# inline wait answers a warm one, past that the endpoint answers 503
|
||||
# (the server's fetch retries those) while the store's single-flight
|
||||
# probe completes in the background.
|
||||
_claude_model_options_rows: dict[str, list[dict[str, object]]] = {}
|
||||
|
||||
@app.get("/v1/sessions/{session_id}/claude-model-options")
|
||||
async def get_session_claude_model_options(session_id: str) -> JSONResponse:
|
||||
if _session_harness_name(session_id) != "claude-native":
|
||||
return JSONResponse(status_code=200, content={"models": []})
|
||||
cached = _claude_model_options_rows.get(session_id)
|
||||
if cached is not None:
|
||||
return JSONResponse(status_code=200, content={"models": cached})
|
||||
try:
|
||||
claude_config = await _resolve_session_claude_launch_config(session_id)
|
||||
except click.ClickException as exc:
|
||||
@@ -8573,12 +8870,52 @@ def create_runner_app(
|
||||
),
|
||||
},
|
||||
)
|
||||
from omnigent.claude_native import claude_native_model_options
|
||||
from omnigent.claude_native import claude_launch_catalog
|
||||
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={"models": claude_native_model_options(claude_config)},
|
||||
)
|
||||
rows: list[dict[str, object]] | None
|
||||
try:
|
||||
# The store's single-flight probe survives this wait expiring
|
||||
# (ensure_catalog shields it), so a 503 here is genuinely
|
||||
# "pending", not "restarted".
|
||||
async with asyncio.timeout(_CLAUDE_MODEL_OPTIONS_INLINE_WAIT_S):
|
||||
rows = await claude_launch_catalog(claude_config)
|
||||
except TimeoutError:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "claude_native_model_options_pending",
|
||||
"detail": "the harness model probe is still resolving",
|
||||
},
|
||||
)
|
||||
if not rows:
|
||||
return JSONResponse(
|
||||
status_code=503,
|
||||
content={
|
||||
"error": "claude_native_model_options_failed",
|
||||
"detail": "the harness model probe failed; retrying",
|
||||
},
|
||||
)
|
||||
_claude_model_options_rows[session_id] = rows
|
||||
return JSONResponse(status_code=200, content={"models": rows})
|
||||
|
||||
@app.get("/v1/sessions/{session_id}/model-options")
|
||||
async def get_session_model_options(session_id: str) -> JSONResponse:
|
||||
"""One route for every harness family's session model listing.
|
||||
|
||||
The runner derives the harness from the session — the four
|
||||
harness-named routes above/below remain as compatibility aliases
|
||||
for older servers (deprecated; remove in 0.11.0).
|
||||
"""
|
||||
harness = _session_harness_name(session_id)
|
||||
if harness == "claude-native":
|
||||
return await get_session_claude_model_options(session_id)
|
||||
if harness in ("codex-native", "opencode-native"):
|
||||
return await get_session_codex_model_options(session_id)
|
||||
if harness == "cursor-native":
|
||||
return await get_session_cursor_model_options(session_id)
|
||||
if harness == "kiro-native":
|
||||
return await get_session_kiro_model_options(session_id)
|
||||
return JSONResponse(status_code=200, content={"models": []})
|
||||
|
||||
@app.post("/v1/sessions/{session_id}/skills/resolve")
|
||||
async def resolve_session_skill(session_id: str, request: Request) -> JSONResponse:
|
||||
|
||||
@@ -3768,6 +3768,37 @@ async def _auto_create_codex_terminal(
|
||||
from omnigent.inner.codex_executor import _find_codex_cli
|
||||
|
||||
_codex_cli_path = _find_codex_cli()
|
||||
# Explicit launches (model-flows design §4): validate an explicit request
|
||||
# against the shared catalog, and give a Default launch on codex's own
|
||||
# login the ACCOUNT's real default — so the ``model =`` line copied from
|
||||
# the user's shared config can never govern a session (the stale-gpt-5.4
|
||||
# 400 class). Profile-backed shapes already resolve their default at
|
||||
# materialization time and are left alone.
|
||||
if launch_config.model_override or (
|
||||
_codex_launch.model is None and _codex_launch.profile is None
|
||||
):
|
||||
from dataclasses import replace as _dataclass_replace
|
||||
|
||||
from omnigent.codex_native_app_server import codex_launch_catalog
|
||||
from omnigent.model_catalog_store import catalog_contains, default_row
|
||||
|
||||
_codex_catalog = await codex_launch_catalog(codex_path=_codex_cli_path)
|
||||
if launch_config.model_override and _codex_catalog:
|
||||
if not catalog_contains(_codex_catalog, launch_config.model_override):
|
||||
raise click.ClickException(
|
||||
f"the requested model {launch_config.model_override!r} is not in "
|
||||
"this host's current model list — it may have changed since the "
|
||||
"pick. Pick again from the model menu."
|
||||
)
|
||||
if _codex_launch.model is None and _codex_launch.profile is None and _codex_catalog:
|
||||
_catalog_default = default_row(_codex_catalog)
|
||||
_default_id = (
|
||||
str(_catalog_default.get("id") or _catalog_default.get("model") or "") or None
|
||||
if _catalog_default is not None
|
||||
else None
|
||||
)
|
||||
if _default_id:
|
||||
_codex_launch = _dataclass_replace(_codex_launch, model=_default_id)
|
||||
# Cancel any surviving forwarder first so its teardown closes the OLD app-server,
|
||||
# not the one registered below — and so it can't mirror alongside the new one.
|
||||
await _cancel_auto_forwarder_task(session_id)
|
||||
@@ -6450,6 +6481,43 @@ async def _auto_create_claude_terminal(
|
||||
or (claude_config.model if claude_config is not None else None),
|
||||
claude_config,
|
||||
)
|
||||
# Explicit launches (model-flows design §4): consult the shared catalog
|
||||
# only when it can change the outcome — to validate an explicit request,
|
||||
# or to resolve a Default launch that would otherwise pass no ``--model``
|
||||
# and leave the model to invisible CLI-private state.
|
||||
if session_model_override or launch_model is None:
|
||||
from omnigent.claude_native import claude_catalog_serves_model, claude_launch_catalog
|
||||
from omnigent.model_catalog_store import default_row
|
||||
|
||||
launch_catalog: list[dict[str, object]] | None = None
|
||||
try:
|
||||
launch_catalog = await claude_launch_catalog(claude_config)
|
||||
except Exception: # noqa: BLE001 — no catalog means no validation/default
|
||||
_logger.warning(
|
||||
"claude launch catalog unavailable for session=%s", session_id, exc_info=True
|
||||
)
|
||||
if session_model_override and launch_catalog:
|
||||
resolved_request = (
|
||||
resolve_claude_native_model_selection(session_model_override, claude_config)
|
||||
or session_model_override
|
||||
)
|
||||
# A pane's ``/model`` persists the exact id it runs; the catalog
|
||||
# may spell that model only by its family alias.
|
||||
if not (
|
||||
claude_catalog_serves_model(launch_catalog, session_model_override, claude_config)
|
||||
or claude_catalog_serves_model(launch_catalog, resolved_request, claude_config)
|
||||
):
|
||||
raise click.ClickException(
|
||||
f"the requested model {session_model_override!r} is not in this "
|
||||
"host's current model list — it may have changed since the pick. "
|
||||
"Pick again from the model menu."
|
||||
)
|
||||
if launch_model is None and launch_catalog:
|
||||
catalog_default = default_row(launch_catalog)
|
||||
if catalog_default is not None:
|
||||
launch_model = (
|
||||
str(catalog_default.get("model") or catalog_default.get("id") or "") or None
|
||||
)
|
||||
# Give an exact launch model (a Smart Routing pick is resolved before the
|
||||
# terminal exists) a spelling of its own in the picker, so a later
|
||||
# ``/model`` can return to it instead of stepping onto whatever the family
|
||||
@@ -6461,6 +6529,18 @@ async def _auto_create_claude_terminal(
|
||||
claude_config = claude_config_with_launch_model_pinned(claude_config, launch_model)
|
||||
if record_launch_config is not None:
|
||||
record_launch_config(session_id, claude_config)
|
||||
# Persist the vocabulary + launch model onto the bridge so mid-session
|
||||
# ``/model`` conversion reads THIS session's pins, not the runner's
|
||||
# ambient env (the CLI path records these at prepare time; the runner
|
||||
# resolves its config only after the bridge exists).
|
||||
from omnigent.claude_native_bridge import record_model_vocabulary
|
||||
|
||||
await asyncio.to_thread(
|
||||
record_model_vocabulary,
|
||||
bridge_dir,
|
||||
launch_env=claude_config.env if claude_config is not None else None,
|
||||
launch_model=launch_model,
|
||||
)
|
||||
_logger.info(
|
||||
"Claude terminal provider config resolved: session=%s configured=%s "
|
||||
"env_keys=%s api_key_helper_set=%s model_set=%s launch_model=%s",
|
||||
|
||||
@@ -989,6 +989,20 @@ class HarnessProcessManager:
|
||||
"""
|
||||
return conversation_id in self._in_flight_response_ids
|
||||
|
||||
def note_activity(self, conversation_id: str) -> None:
|
||||
"""Refresh the idle lease for an existing harness subprocess.
|
||||
|
||||
Native terminal turns do not pass through ``proxy_stream``, so their
|
||||
terminal activity calls this method instead. No-op when the
|
||||
conversation has no registered subprocess.
|
||||
|
||||
:param conversation_id: AP-allocated conversation id,
|
||||
e.g. ``"conv_abc123"``.
|
||||
"""
|
||||
entry = self._entries.get(conversation_id)
|
||||
if entry is not None:
|
||||
entry.last_used_at = time.monotonic()
|
||||
|
||||
def mark_in_flight(self, conversation_id: str, response_id: str) -> None:
|
||||
"""
|
||||
Record that *conversation_id* has a live harness turn.
|
||||
|
||||
+38
-10
@@ -1268,6 +1268,7 @@ def create_app(
|
||||
agent_store=agent_store,
|
||||
conversation_store=conversation_store,
|
||||
permission_store=permission_store,
|
||||
policy_store=policy_store,
|
||||
host_store=host_store,
|
||||
host_registry=host_registry,
|
||||
agent_cache=agent_cache,
|
||||
@@ -2720,6 +2721,22 @@ def create_app(
|
||||
# auth routes and ``/v1/me`` share one roster. Consulted on each login
|
||||
# to promote listed identities — the only admin path for OIDC, and an
|
||||
# additive convenience for accounts.
|
||||
# Login-issued refresh grants: both server-mintable providers
|
||||
# (accounts, oidc) get a grant store so `omnigent login` can hand
|
||||
# the CLI refresh material — without it, an unattended host dies
|
||||
# permanently at session-JWT expiry (default 8 h). The store also
|
||||
# backs the opt-in RFC 8628 device flow below.
|
||||
device_grant_store = None
|
||||
if (
|
||||
isinstance(auth_provider, UnifiedAuthProvider)
|
||||
and auth_provider._source in ("accounts", "oidc")
|
||||
and permission_store is not None
|
||||
):
|
||||
from omnigent.server.device_grant_store import DeviceGrantStore
|
||||
|
||||
device_grant_store = DeviceGrantStore(permission_store.storage_location)
|
||||
auth_provider.set_grant_revocation_check(device_grant_store.is_revoked)
|
||||
|
||||
if (
|
||||
isinstance(auth_provider, UnifiedAuthProvider)
|
||||
and auth_provider._source == "accounts"
|
||||
@@ -2731,7 +2748,10 @@ def create_app(
|
||||
|
||||
app.include_router(
|
||||
create_accounts_auth_router(
|
||||
auth_provider, account_store, admin_list, permission_store
|
||||
auth_provider,
|
||||
account_store,
|
||||
admin_list,
|
||||
permission_store,
|
||||
),
|
||||
prefix="/auth",
|
||||
tags=["auth"],
|
||||
@@ -2762,6 +2782,7 @@ def create_app(
|
||||
admin_list,
|
||||
oidc_account_store,
|
||||
allowed_domains=frozenset(allowed_domains or ()) or None,
|
||||
device_grant_store=device_grant_store,
|
||||
),
|
||||
prefix="/auth",
|
||||
tags=["auth"],
|
||||
@@ -2773,24 +2794,20 @@ def create_app(
|
||||
)
|
||||
|
||||
# Device Authorization Grant (RFC 8628): opt-in, default-off via
|
||||
# OMNIGENT_DEVICE_GRANT_ENABLED, and accounts-mode only. OIDC delegates
|
||||
# login to the IdP (cli-ticket flow), so it neither needs nor mounts
|
||||
# these routes. Wires the revocation lookup into the auth provider so
|
||||
# revoking a grant immediately rejects its delegated access tokens.
|
||||
# See designs/DEVICE_AUTH.md.
|
||||
# OMNIGENT_DEVICE_GRANT_ENABLED, and accounts-mode only (the
|
||||
# in-browser consent flow needs the accounts login page). Wires
|
||||
# the full /oauth/* surface including the token endpoint. See
|
||||
# designs/DEVICE_AUTH.md.
|
||||
from omnigent.server.auth import env_var_is_truthy
|
||||
|
||||
if (
|
||||
env_var_is_truthy("OMNIGENT_DEVICE_GRANT_ENABLED", default=False)
|
||||
and isinstance(auth_provider, UnifiedAuthProvider)
|
||||
and auth_provider._source == "accounts"
|
||||
and permission_store is not None
|
||||
and device_grant_store is not None
|
||||
):
|
||||
from omnigent.server.device_grant_store import DeviceGrantStore
|
||||
from omnigent.server.routes.device_auth import create_device_auth_router
|
||||
|
||||
device_grant_store = DeviceGrantStore(permission_store.storage_location)
|
||||
auth_provider.set_grant_revocation_check(device_grant_store.is_revoked)
|
||||
app.include_router(
|
||||
create_device_auth_router(auth_provider, device_grant_store),
|
||||
tags=["oauth"],
|
||||
@@ -2811,6 +2828,17 @@ def create_app(
|
||||
"the server and its trusted client(s) to restrict initiation "
|
||||
"to authorized clients. See designs/DEVICE_AUTH.md.",
|
||||
)
|
||||
elif isinstance(auth_provider, UnifiedAuthProvider) and device_grant_store is not None:
|
||||
# No device flow, but login-issued refresh grants still need
|
||||
# their token/revoke endpoints — in OIDC mode and in accounts
|
||||
# mode without the flag alike.
|
||||
from omnigent.server.routes.device_auth import create_oauth_token_router
|
||||
|
||||
app.include_router(
|
||||
create_oauth_token_router(auth_provider, device_grant_store),
|
||||
tags=["oauth"],
|
||||
)
|
||||
_logger.info("login-grant: /oauth/token + /oauth/revoke enabled")
|
||||
|
||||
# Mount the built web SPA at "/" if a build is present. The SPA is
|
||||
# built into ``omnigent/server/static/web-ui/`` by ``web/``'s Vite
|
||||
|
||||
+15
-6
@@ -53,6 +53,9 @@ _TRUTHY_STRINGS = ("1", "true", "yes")
|
||||
# any path not covered here, so it can never touch admin / user-management
|
||||
# endpoints (``/auth/users``, ``/auth/invite``, ``/auth/setup`` …) even if
|
||||
# its underlying identity is an admin. Delegated clients only need these.
|
||||
# First-party login-grant tokens carry no ``scope`` and are NOT restricted
|
||||
# here — they renew the session JWT and keep its authority (see
|
||||
# ``_check_cookie`` and ``routes/device_auth.LOGIN_GRANT_CLIENT_ID``).
|
||||
_DELEGATED_ALLOWED_PREFIXES = (
|
||||
"/health",
|
||||
"/v1/agents",
|
||||
@@ -601,18 +604,24 @@ class UnifiedAuthProvider(AuthProvider):
|
||||
if not isinstance(user_id, str) or not user_id or user_id in _RESERVED_USERS:
|
||||
return None
|
||||
|
||||
# Delegated (device-grant) tokens carry a ``grant_id`` claim.
|
||||
# They get two extra, request-scoped checks — a fail-closed path
|
||||
# allowlist and a live revocation lookup — so they are never
|
||||
# served from the plain user-id cache (which would skip both).
|
||||
# Grant-derived tokens carry a ``grant_id`` claim. They get
|
||||
# request-scoped checks — a live revocation lookup, plus (for
|
||||
# restricted tokens) a fail-closed path allowlist — so they are
|
||||
# never served from the plain user-id cache (which would skip both).
|
||||
grant_id = payload.get("grant_id")
|
||||
if grant_id is not None:
|
||||
if not isinstance(grant_id, str):
|
||||
return None
|
||||
if not delegated_path_allowed(request.url.path):
|
||||
return None
|
||||
if self._grant_revoked is not None and self._grant_revoked(grant_id):
|
||||
return None
|
||||
# The allowlist restricts DELEGATED tokens — a third-party
|
||||
# client (e.g. Slack) acting on a user's behalf, marked by the
|
||||
# ``scope`` claim. A first-party login grant carries no scope:
|
||||
# its bearer is the user's own CLI/host, and the token renews
|
||||
# the session JWT it replaced, so it keeps that same authority
|
||||
# (still revocable via ``grant_id`` above).
|
||||
if payload.get("scope") is not None and not delegated_path_allowed(request.url.path):
|
||||
return None
|
||||
return user_id
|
||||
|
||||
# Cache for remaining lifetime of the token.
|
||||
|
||||
@@ -25,6 +25,7 @@ from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import secrets
|
||||
from typing import cast
|
||||
|
||||
from sqlalchemy import and_, delete, or_, update
|
||||
@@ -135,6 +136,56 @@ class DeviceGrantStore:
|
||||
session.flush()
|
||||
return _to_device_grant(row)
|
||||
|
||||
def create_redeemed_grant(
|
||||
self,
|
||||
grant_id: str,
|
||||
*,
|
||||
user_id: str,
|
||||
client_id: str | None,
|
||||
refresh_token_hash: str,
|
||||
created_at: int,
|
||||
) -> DeviceGrant:
|
||||
"""Persist a grant born ``redeemed`` — no device-code consent step.
|
||||
|
||||
Backs login-issued refresh grants: the user just proved their
|
||||
identity interactively (IdP browser flow or password prompt), so
|
||||
the RFC 8628 pending → approved dance would re-ask for consent
|
||||
already given. The row starts ``redeemed`` with its refresh-token
|
||||
digest set, exactly as if it had completed the device flow.
|
||||
|
||||
The device_code/user_code columns are filled with discarded
|
||||
random material: no client ever polls with them, and ``pending``
|
||||
purge conditions never match a ``redeemed`` row.
|
||||
|
||||
:param grant_id: Opaque grant id (public — travels in JWTs).
|
||||
:param user_id: The authenticated identity (the token ``sub``).
|
||||
:param client_id: Public client name for audit, e.g.
|
||||
``"omnigent-cli"``.
|
||||
:param refresh_token_hash: HMAC digest of the initial refresh
|
||||
token. The store never sees the raw token.
|
||||
:param created_at: Unix epoch seconds; also ``approved_at``, the
|
||||
anchor for the grant's absolute lifetime.
|
||||
:returns: The created :class:`DeviceGrant`.
|
||||
"""
|
||||
with self._session("insert_redeemed_device_grant") as session:
|
||||
row = SqlDeviceGrant(
|
||||
id=grant_id,
|
||||
device_code_hash=secrets.token_urlsafe(32),
|
||||
user_code=secrets.token_urlsafe(16),
|
||||
status=encode_device_grant_status("redeemed"),
|
||||
client_id=client_id,
|
||||
user_id=user_id,
|
||||
refresh_token_hash=refresh_token_hash,
|
||||
prev_refresh_token_hash=None,
|
||||
created_at=created_at,
|
||||
expires_at=created_at,
|
||||
approved_at=created_at,
|
||||
last_polled_at=None,
|
||||
)
|
||||
session.add(row)
|
||||
session.flush()
|
||||
return _to_device_grant(row)
|
||||
|
||||
def get_by_user_code(self, user_code: str) -> DeviceGrant | None:
|
||||
"""Look up a grant by its short verification code.
|
||||
|
||||
|
||||
@@ -129,6 +129,9 @@ _EXTERNAL_SESSION_USAGE_TYPE: str = "external_session_usage"
|
||||
_EXTERNAL_MODEL_CHANGE_TYPE: str = "external_model_change"
|
||||
|
||||
|
||||
_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE: str = "external_permission_mode_change"
|
||||
|
||||
|
||||
_EXTERNAL_SESSION_TITLE_TYPE: str = "external_session_title"
|
||||
|
||||
|
||||
@@ -189,6 +192,21 @@ _EXTERNAL_CODEX_APPROVAL_MODE_CHANGE_TYPE: str = "external_codex_approval_mode_c
|
||||
_CODEX_NATIVE_COLLABORATION_MODES: frozenset[str] = frozenset({"default", "plan"})
|
||||
|
||||
|
||||
# Current permission mode of a live claude-native session.
|
||||
# ``terminal_launch_args`` records only the launch mode, so this label is what
|
||||
# the web UI reads back after a reload.
|
||||
_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY = "omnigent.claude_native.permission_mode"
|
||||
|
||||
|
||||
# Permission modes switchable on a running session — the ones Claude
|
||||
# Code's shift+tab cycle can reach. Mirrors
|
||||
# ``claude_native_bridge.CYCLEABLE_PERMISSION_MODES``; ``dontAsk`` and
|
||||
# ``bypassPermissions`` are launch-only and rejected on PATCH.
|
||||
_CLAUDE_NATIVE_PERMISSION_MODES: frozenset[str] = frozenset(
|
||||
{"default", "acceptEdits", "plan", "auto"}
|
||||
)
|
||||
|
||||
|
||||
_CODEX_NATIVE_SUBAGENT_DISPLAY_FALLBACK = "Codex"
|
||||
|
||||
|
||||
@@ -419,6 +437,7 @@ _ALLOWED_EVENT_TYPES: frozenset[str] = frozenset(ITEM_TYPE_TO_DATA_CLS.keys()) |
|
||||
_EXTERNAL_MCP_STARTUP_TYPE,
|
||||
_EXTERNAL_MODEL_CHANGE_TYPE,
|
||||
_EXTERNAL_MODEL_OPTIONS_TYPE,
|
||||
_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE,
|
||||
_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE,
|
||||
_EXTERNAL_SESSION_TITLE_TYPE,
|
||||
_EXTERNAL_SESSION_TODOS_TYPE,
|
||||
@@ -797,6 +816,8 @@ __all__ = [
|
||||
"_CLAUDE_NATIVE_MESSAGE_TIMEOUT_S",
|
||||
"_CLAUDE_NATIVE_MODEL",
|
||||
"_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S",
|
||||
"_CLAUDE_NATIVE_PERMISSION_MODES",
|
||||
"_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY",
|
||||
"_CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS",
|
||||
"_CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY",
|
||||
"_CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE",
|
||||
@@ -842,6 +863,7 @@ __all__ = [
|
||||
"_EXTERNAL_MODEL_OPTIONS_TYPE",
|
||||
"_EXTERNAL_OUTPUT_REASONING_DELTA_TYPE",
|
||||
"_EXTERNAL_OUTPUT_TEXT_DELTA_TYPE",
|
||||
"_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE",
|
||||
"_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE",
|
||||
"_EXTERNAL_SESSION_INTERRUPTED_TYPE",
|
||||
"_EXTERNAL_SESSION_STATUS_TYPE",
|
||||
|
||||
@@ -136,6 +136,8 @@ from omnigent.server.routes._sessions.common import ( # noqa: F401
|
||||
_CLAUDE_NATIVE_DESCRIPTION_LABEL_KEY,
|
||||
_CLAUDE_NATIVE_EDIT_TOOLS,
|
||||
_CLAUDE_NATIVE_HARNESS,
|
||||
_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY,
|
||||
_CLAUDE_NATIVE_PERMISSION_MODES,
|
||||
_CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS,
|
||||
_CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY,
|
||||
_CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE,
|
||||
@@ -249,6 +251,7 @@ from omnigent.server.schemas import (
|
||||
SessionMcpStartupEvent,
|
||||
SessionModelEvent,
|
||||
SessionModelOptionsEvent,
|
||||
SessionPermissionModeEvent,
|
||||
SessionReasoningEffortEvent,
|
||||
SessionResourceListPage,
|
||||
SessionResourcePaginatedList,
|
||||
@@ -311,6 +314,22 @@ def _publish_collaboration_mode(session_id: str, mode: str) -> None:
|
||||
session_stream.publish(session_id, event.model_dump())
|
||||
|
||||
|
||||
def _publish_permission_mode(session_id: str, mode: str) -> None:
|
||||
"""
|
||||
Publish the live claude-native permission mode for a session.
|
||||
|
||||
:param session_id: Session/conversation identifier, e.g. ``"conv_abc123"``.
|
||||
:param mode: The active permission mode, e.g. ``"auto"``.
|
||||
:returns: None.
|
||||
"""
|
||||
event = SessionPermissionModeEvent(
|
||||
type="session.permission_mode",
|
||||
conversation_id=session_id,
|
||||
permission_mode=mode,
|
||||
)
|
||||
session_stream.publish(session_id, event.model_dump())
|
||||
|
||||
|
||||
def _publish_policy_denied(session_id: str, reason: str, phase: str) -> None:
|
||||
"""
|
||||
Publish a native policy-DENY signal on the session stream.
|
||||
@@ -2126,28 +2145,32 @@ async def _persist_external_model_change(
|
||||
conversation_store: ConversationStore,
|
||||
) -> None:
|
||||
"""
|
||||
Persist and broadcast a model switch made inside the terminal.
|
||||
Persist and broadcast the model the harness reports it is running.
|
||||
|
||||
Mirrors a ``/model`` change typed into a claude-native session's
|
||||
Claude Code pane (or picked via its in-TUI model picker) onto the
|
||||
Omnigent session: writes ``model_override`` so the value survives reload
|
||||
and publishes a ``session.model`` SSE event so the web picker
|
||||
updates live. Unlike the PATCH path
|
||||
(:func:`update_session`), this deliberately does NOT forward a
|
||||
``model_change`` back to the runner — the terminal is already on
|
||||
the model, so re-injecting ``/model`` would loop.
|
||||
Mirrors a harness-side model report — the launch's own model, or a
|
||||
``/model`` change made inside the pane — onto the Omnigent session:
|
||||
writes ``reported_model`` VERBATIM (the harness's own spelling,
|
||||
never collapsed to a picker alias) so the value survives reload,
|
||||
and publishes a ``session.model`` SSE event so every surface
|
||||
re-renders from it. The user's request (``model_override``) is
|
||||
deliberately untouched: requests and reports are separate roles,
|
||||
and only reports are ever displayed. Unlike the PATCH path
|
||||
(:func:`update_session`), this does NOT forward a ``model_change``
|
||||
back to the runner — the terminal is already on the model, so
|
||||
re-injecting ``/model`` would loop.
|
||||
|
||||
No-ops (no write, no event) when the observed model already equals
|
||||
the persisted ``model_override`` — the common case on the web→TUI
|
||||
round-trip where the web PATCH set the override moments earlier.
|
||||
No-ops (no write, no event) when the reported model already equals
|
||||
the persisted ``reported_model`` — the steady state between real
|
||||
changes, since forwarders re-observe on every poll.
|
||||
|
||||
:param session_id: Session/conversation identifier, e.g.
|
||||
``"conv_abc123"``.
|
||||
:param conv: Conversation row for ``session_id`` (read at the route
|
||||
boundary); ``conv.model_override`` is the dedupe baseline.
|
||||
boundary); ``conv.reported_model`` is the dedupe baseline.
|
||||
:param body: External model-change event body. ``data.model`` must
|
||||
be a non-empty string tier alias, e.g. ``"opus"``.
|
||||
:param conversation_store: Store used to upsert ``model_override``.
|
||||
be a non-empty string — the harness's verbatim model, e.g.
|
||||
``"claude-opus-4-8[1m]"`` or ``"gpt-5.6-luna"``.
|
||||
:param conversation_store: Store used to upsert ``reported_model``.
|
||||
:raises OmnigentError: If ``data.model`` is missing or not a
|
||||
non-empty string.
|
||||
"""
|
||||
@@ -2158,12 +2181,12 @@ async def _persist_external_model_change(
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
model = raw_model.strip()
|
||||
if conv.model_override == model:
|
||||
if conv.reported_model == model:
|
||||
return
|
||||
await asyncio.to_thread(
|
||||
conversation_store.update_conversation,
|
||||
session_id,
|
||||
model_override=model,
|
||||
reported_model=model,
|
||||
)
|
||||
event = SessionModelEvent(
|
||||
type="session.model",
|
||||
@@ -2425,6 +2448,51 @@ async def _persist_external_codex_collaboration_mode_change(
|
||||
_publish_collaboration_mode(session_id, mode)
|
||||
|
||||
|
||||
async def _persist_external_permission_mode_change(
|
||||
session_id: str,
|
||||
conv: Conversation,
|
||||
body: SessionEventInput,
|
||||
conversation_store: ConversationStore,
|
||||
) -> None:
|
||||
"""
|
||||
Persist a pane-observed claude-native permission mode as a session label.
|
||||
|
||||
The forwarder posts this when the pane's mode footer differs from what it
|
||||
last reported — i.e. the user pressed shift+tab in the TUI. Unlike the
|
||||
PATCH path this needs no runner confirmation: the pane IS the source, so
|
||||
the mode is already in effect.
|
||||
|
||||
:param session_id: Session/conversation identifier, e.g. ``"conv_abc123"``.
|
||||
:param conv: Conversation row for ``session_id`` at the route boundary.
|
||||
:param body: Event body; ``data.permission_mode`` must be a switchable mode.
|
||||
:param conversation_store: Store used to upsert the mode label.
|
||||
:returns: None.
|
||||
:raises OmnigentError: If ``data.permission_mode`` is missing or unsupported.
|
||||
"""
|
||||
raw_mode = body.data.get("permission_mode")
|
||||
if not isinstance(raw_mode, str) or not raw_mode.strip():
|
||||
raise OmnigentError(
|
||||
"external_permission_mode_change requires data.permission_mode "
|
||||
"to be a non-empty string",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
mode = raw_mode.strip()
|
||||
if mode not in _CLAUDE_NATIVE_PERMISSION_MODES:
|
||||
raise OmnigentError(
|
||||
"external_permission_mode_change requires data.permission_mode in "
|
||||
f"{sorted(_CLAUDE_NATIVE_PERMISSION_MODES)}; got {mode!r}",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
if conv.labels.get(_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY) == mode:
|
||||
return
|
||||
await asyncio.to_thread(
|
||||
conversation_store.set_labels,
|
||||
session_id,
|
||||
{_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY: mode},
|
||||
)
|
||||
_publish_permission_mode(session_id, mode)
|
||||
|
||||
|
||||
async def _persist_external_codex_approval_mode_change(
|
||||
session_id: str,
|
||||
conv: Conversation,
|
||||
@@ -3725,6 +3793,58 @@ def _require_collaboration_mode_forward(
|
||||
)
|
||||
|
||||
|
||||
def _require_permission_mode_forward(
|
||||
session_id: str,
|
||||
mode: str,
|
||||
runner_result: _RunnerForwardResult | None,
|
||||
) -> str:
|
||||
"""
|
||||
Fail when a live claude-native permission-mode switch wasn't applied.
|
||||
|
||||
The mode lives in the running TUI, so persisting the label without a
|
||||
confirmed 2xx forward would let the UI claim auto mode while Claude still
|
||||
prompts on every edit. Returns the mode the runner actually reached, so
|
||||
the caller stores what the pane shows rather than what was asked for.
|
||||
|
||||
:param session_id: Session/conversation identifier, e.g.
|
||||
``"conv_abc123"``.
|
||||
:param mode: Requested permission mode, e.g. ``"auto"``.
|
||||
:param runner_result: HTTP result returned by the runner, or ``None``
|
||||
when no runner could be reached.
|
||||
:returns: The mode the runner reports the pane is now in — the
|
||||
requested *mode* when the runner didn't echo one back.
|
||||
:raises OmnigentError: If no runner was reachable or the runner could
|
||||
not switch the session into *mode*.
|
||||
"""
|
||||
if runner_result is None:
|
||||
raise OmnigentError(
|
||||
f"Could not switch to {mode} mode: no live Claude runner is available "
|
||||
f"for session {session_id!r}. Reconnect the session and try again.",
|
||||
code=ErrorCode.RUNNER_UNAVAILABLE,
|
||||
)
|
||||
if not 200 <= runner_result.status_code < 300:
|
||||
# The runner's body carries why the cycle failed (e.g. the mode
|
||||
# isn't in this session's cycle); surface it so the UI banner
|
||||
# explains the failure instead of showing a bare status code.
|
||||
detail = ""
|
||||
try:
|
||||
payload = json.loads(runner_result.body)
|
||||
except (TypeError, ValueError):
|
||||
payload = None
|
||||
if isinstance(payload, dict) and isinstance(payload.get("detail"), str):
|
||||
detail = f" {payload['detail']}"
|
||||
raise OmnigentError(
|
||||
f"Could not switch to {mode} mode for session {session_id!r}.{detail}",
|
||||
code=ErrorCode.RUNNER_UNAVAILABLE,
|
||||
)
|
||||
try:
|
||||
body = json.loads(runner_result.body)
|
||||
except (TypeError, ValueError):
|
||||
return mode
|
||||
settled = body.get("permission_mode") if isinstance(body, dict) else None
|
||||
return settled if isinstance(settled, str) and settled else mode
|
||||
|
||||
|
||||
def _publish_status(
|
||||
session_id: str,
|
||||
status: str,
|
||||
@@ -5222,6 +5342,13 @@ def _surface_model_change_forward_failure(
|
||||
if 200 <= runner_result.status_code < 300:
|
||||
return
|
||||
reason = f"the runner returned status {runner_result.status_code}"
|
||||
# The runner's own detail names the concrete cause (e.g. "a dialog may
|
||||
# be open in the pane"); carry it into the visible notice when present.
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
parsed_body = json.loads(runner_result.body)
|
||||
detail = parsed_body.get("detail") if isinstance(parsed_body, dict) else None
|
||||
if isinstance(detail, str) and detail.strip():
|
||||
reason = f"{reason} ({detail.strip()})"
|
||||
_logger.warning(
|
||||
"Model change not applied to the terminal for session=%s model=%r: %s (body=%s)",
|
||||
session_id,
|
||||
@@ -9251,14 +9378,19 @@ async def _load_model_options(
|
||||
runner_client: httpx.AsyncClient,
|
||||
session_id: str,
|
||||
path: str,
|
||||
fallback_path: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Background single-flight fetch of a session's native model catalog.
|
||||
|
||||
:param runner_client: HTTP client pointed at the bound runner.
|
||||
:param session_id: Session/conversation identifier, e.g. ``"conv_abc"``.
|
||||
:param path: Runner route to query, e.g.
|
||||
``"/v1/sessions/conv_abc/cursor-model-options"``.
|
||||
:param path: Runner route to query — the unified
|
||||
``"/v1/sessions/conv_abc/model-options"``.
|
||||
:param fallback_path: Legacy harness-named route to fall back to when
|
||||
*path* 404s (an older runner without the unified route), e.g.
|
||||
``"/v1/sessions/conv_abc/cursor-model-options"``. ``None`` disables
|
||||
the fallback.
|
||||
"""
|
||||
# Read the retry schedule off the facade so tests patching
|
||||
# ``sessions._MODEL_OPTIONS_RETRY_DELAYS_S`` reach this impl.
|
||||
@@ -9272,6 +9404,11 @@ async def _load_model_options(
|
||||
_logger.debug("Runner model-options query failed for %s", session_id)
|
||||
return
|
||||
if resp.status_code != 200:
|
||||
# An older runner has no unified route; drop to the harness-named
|
||||
# one without consuming a retry.
|
||||
if resp.status_code == 404 and fallback_path and path != fallback_path:
|
||||
path = fallback_path
|
||||
continue
|
||||
# 503 means the native backend (Codex app-server bridge / cursor
|
||||
# login) is still booting. Keep the background single-flight alive
|
||||
# so the web picker fills without a second manual refresh.
|
||||
@@ -9401,7 +9538,10 @@ def prefetch_session_routing_catalogs(
|
||||
options_task = asyncio.create_task(
|
||||
_run_catalog_prefetch(
|
||||
_load_model_options(
|
||||
runner_client, session_id, f"/v1/sessions/{session_id}/{endpoint}"
|
||||
runner_client,
|
||||
session_id,
|
||||
f"/v1/sessions/{session_id}/model-options",
|
||||
fallback_path=f"/v1/sessions/{session_id}/{endpoint}",
|
||||
),
|
||||
session_id,
|
||||
)
|
||||
@@ -9599,6 +9739,7 @@ __all__ = [
|
||||
"_persist_external_codex_collaboration_mode_change",
|
||||
"_persist_external_model_change",
|
||||
"_persist_external_model_options",
|
||||
"_persist_external_permission_mode_change",
|
||||
"_persist_external_reasoning_effort_change",
|
||||
"_persist_external_session_title",
|
||||
"_persist_external_subagent_start",
|
||||
@@ -9635,6 +9776,7 @@ __all__ = [
|
||||
"_publish_interrupted",
|
||||
"_publish_mcp_startup",
|
||||
"_publish_model_options",
|
||||
"_publish_permission_mode",
|
||||
"_publish_policy_denied",
|
||||
"_publish_policy_deny",
|
||||
"_publish_runner_skills",
|
||||
@@ -9660,6 +9802,7 @@ __all__ = [
|
||||
"_require_declared_subagent",
|
||||
"_require_external_status_forward",
|
||||
"_require_host_conn_for_worktree",
|
||||
"_require_permission_mode_forward",
|
||||
"_reset_runner_resources_after_switch",
|
||||
"_reset_runner_resources_after_switch_impl",
|
||||
"_resolve_harness",
|
||||
|
||||
@@ -1221,7 +1221,13 @@ def _accumulate_session_usage(
|
||||
llm_model = (
|
||||
usage_model
|
||||
if isinstance(usage_model, str) and usage_model
|
||||
else (conv.model_override if conv and conv.model_override else _resolve_llm_model(conv))
|
||||
else (
|
||||
conv.reported_model
|
||||
if conv and conv.reported_model
|
||||
else (
|
||||
conv.model_override if conv and conv.model_override else _resolve_llm_model(conv)
|
||||
)
|
||||
)
|
||||
)
|
||||
if llm_model:
|
||||
if isinstance(provider_cost, (int, float)):
|
||||
@@ -4253,7 +4259,12 @@ async def _refresh_stale_native_model_options(
|
||||
if inflight is None:
|
||||
endpoint = _MODEL_OPTIONS_ENDPOINT_BY_WRAPPER[_CLAUDE_NATIVE_WRAPPER_LABEL_VALUE]
|
||||
inflight = asyncio.create_task(
|
||||
_load_model_options(runner_client, session_id, f"/v1/sessions/{session_id}/{endpoint}")
|
||||
_load_model_options(
|
||||
runner_client,
|
||||
session_id,
|
||||
f"/v1/sessions/{session_id}/model-options",
|
||||
fallback_path=f"/v1/sessions/{session_id}/{endpoint}",
|
||||
)
|
||||
)
|
||||
_model_options_inflight[session_id] = inflight
|
||||
inflight.add_done_callback(
|
||||
@@ -8977,8 +8988,16 @@ async def _fetch_model_options(
|
||||
if cached is not None and session_id not in _model_options_stale:
|
||||
return cached
|
||||
if session_id not in _model_options_inflight:
|
||||
path = f"/v1/sessions/{session_id}/{endpoint}"
|
||||
task = asyncio.create_task(_load_model_options(runner_client, session_id, path))
|
||||
# Unified route first; the harness-named route is the fallback for
|
||||
# an older runner (deprecated aliases, removed in 0.11.0).
|
||||
task = asyncio.create_task(
|
||||
_load_model_options(
|
||||
runner_client,
|
||||
session_id,
|
||||
f"/v1/sessions/{session_id}/model-options",
|
||||
fallback_path=f"/v1/sessions/{session_id}/{endpoint}",
|
||||
)
|
||||
)
|
||||
_model_options_inflight[session_id] = task
|
||||
|
||||
def _clear_runner_options_inflight(_task: asyncio.Task[None]) -> None:
|
||||
@@ -9209,6 +9228,12 @@ async def _get_session_snapshot(
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
# The harness's own report is the display authority: when a session has
|
||||
# a verbatim ``reported_model``, it supersedes the spec-derived value on
|
||||
# the wire's ``llm_model`` field (the web renders and highlights only
|
||||
# from this).
|
||||
if conv.reported_model:
|
||||
llm_model = conv.reported_model
|
||||
# Skills are runner-owned: the bound runner discovers them against its
|
||||
# own filesystem (bundled skills + host skills under the session's
|
||||
# workspace and ``~/.claude/skills/``) — the host where the harness
|
||||
|
||||
@@ -306,11 +306,15 @@ def create_accounts_auth_router(
|
||||
# against a row that exists. Defensive-coding the dereference
|
||||
# would only mask a SqlAlchemy bug, which we want to surface.
|
||||
assert user is not None
|
||||
body_payload = {
|
||||
body_payload: dict[str, object] = {
|
||||
"token": session_jwt,
|
||||
"expires_in": _session_max_age,
|
||||
"user": {"id": user.id, "is_admin": user.is_admin},
|
||||
}
|
||||
# Browser login must never receive refresh material — only CLI/device
|
||||
# flows do (via /auth/cli-poll or device-grant callback). This is
|
||||
# enforced server-side so an XSS or form-hijack cannot obtain
|
||||
# long-lived unattended credentials.
|
||||
resp = JSONResponse(status_code=200, content=body_payload)
|
||||
_set_session_cookie(
|
||||
resp,
|
||||
|
||||
@@ -30,6 +30,7 @@ from omnigent.server.auth import (
|
||||
_RESERVED_USERS,
|
||||
UnifiedAuthProvider,
|
||||
)
|
||||
from omnigent.server.device_grant_store import DeviceGrantStore
|
||||
from omnigent.server.oidc import (
|
||||
_GITHUB_EMAILS_ENDPOINT,
|
||||
derive_code_challenge,
|
||||
@@ -37,6 +38,7 @@ from omnigent.server.oidc import (
|
||||
mint_session_cookie,
|
||||
)
|
||||
from omnigent.server.oidc_access import OidcAdmissionPolicy, resolve_allowed_domains_path
|
||||
from omnigent.server.routes.device_auth import issue_login_grant
|
||||
from omnigent.stores.permission_store import PermissionStore
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
@@ -67,11 +69,16 @@ class _CliTicket:
|
||||
fulfills the ticket. ``None`` while pending.
|
||||
:param user_id: The authenticated user's email, set when
|
||||
fulfilled. ``None`` while pending.
|
||||
:param refresh_token: Login-issued refresh grant material, set at
|
||||
fulfillment when a grant store is wired. ``None`` while pending
|
||||
or when grants are unavailable. Handed to the CLI exactly once
|
||||
by the poll response.
|
||||
"""
|
||||
|
||||
created_at: float = field(default_factory=time.time)
|
||||
token: str | None = None
|
||||
user_id: str | None = None
|
||||
refresh_token: str | None = None
|
||||
|
||||
|
||||
def create_auth_router(
|
||||
@@ -80,6 +87,7 @@ def create_auth_router(
|
||||
admin_list: AdminList,
|
||||
account_store: SqlAlchemyAccountStore | None = None,
|
||||
allowed_domains: frozenset[str] | None = None,
|
||||
device_grant_store: DeviceGrantStore | None = None,
|
||||
) -> APIRouter:
|
||||
"""Create an :class:`APIRouter` with OIDC login/callback/logout routes.
|
||||
|
||||
@@ -100,6 +108,12 @@ def create_auth_router(
|
||||
``allowed_domains:`` key, union'd with
|
||||
``OMNIGENT_OIDC_ALLOWED_DOMAINS`` and the runtime-editable file
|
||||
in the admission policy.
|
||||
:param device_grant_store: When set, a CLI-ticket login also issues
|
||||
a refresh grant (see
|
||||
:func:`omnigent.server.routes.device_auth.issue_login_grant`)
|
||||
so hosts and CLIs can renew without a human re-running
|
||||
``omnigent login``. ``None`` keeps the legacy
|
||||
session-JWT-only response.
|
||||
:returns: A FastAPI router with ``/login``, ``/callback``,
|
||||
``/logout`` (and ``/invite`` when invites are enabled).
|
||||
"""
|
||||
@@ -367,6 +381,19 @@ def create_auth_router(
|
||||
ticket = _cli_tickets[ticket_id]
|
||||
ticket.token = session_jwt
|
||||
ticket.user_id = email
|
||||
# A CLI login is a long-lived unattended credential holder
|
||||
# (hosts especially) — issue a refresh grant so it can renew
|
||||
# instead of dying at session-JWT expiry. Best-effort: a
|
||||
# grant-store failure must not break login itself.
|
||||
if device_grant_store is not None:
|
||||
try:
|
||||
ticket.refresh_token = issue_login_grant(
|
||||
device_grant_store,
|
||||
user_id=email,
|
||||
cookie_secret=config.cookie_secret,
|
||||
)
|
||||
except Exception:
|
||||
_logger.exception("cli-login: refresh grant issuance failed")
|
||||
# Return a simple HTML page — the CLI is polling
|
||||
# /auth/cli-poll and will pick up the token.
|
||||
import html as _html
|
||||
@@ -560,15 +587,18 @@ def create_auth_router(
|
||||
# Fulfilled — return the token and clean up.
|
||||
token = ticket.token
|
||||
user_id = ticket.user_id
|
||||
refresh_token = ticket.refresh_token
|
||||
del _cli_tickets[ticket_id]
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"token": token,
|
||||
"user_id": user_id,
|
||||
"expires_in": config.session_ttl_hours * 3600,
|
||||
},
|
||||
)
|
||||
content: dict[str, object] = {
|
||||
"token": token,
|
||||
"user_id": user_id,
|
||||
"expires_in": config.session_ttl_hours * 3600,
|
||||
}
|
||||
# Only present when a grant store is wired — old CLIs ignore the
|
||||
# extra key, new CLIs against old servers see it absent.
|
||||
if refresh_token is not None:
|
||||
content["refresh_token"] = refresh_token
|
||||
return JSONResponse(status_code=200, content=content)
|
||||
|
||||
# ── Admin: read-only user list ────────────────────────────────
|
||||
|
||||
|
||||
@@ -53,6 +53,7 @@ import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
|
||||
import jwt
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
@@ -80,6 +81,26 @@ _CLIENT_SECRET_HEADER = "X-Omnigent-Client-Secret"
|
||||
# refuses admin / user-management paths for a token carrying this scope.
|
||||
DELEGATED_SCOPE = "sessions"
|
||||
|
||||
# Reserved ``client_id`` for a first-party login grant (issued by
|
||||
# ``omnigent login``, not the RFC 8628 device flow). It is a
|
||||
# security-decision key here, so the device-authorize endpoint REFUSES a
|
||||
# request that names it — a third-party device client can never obtain a
|
||||
# grant tagged this way, and a refresh of such a grant is therefore safe
|
||||
# to treat as first-party. See :func:`issue_login_grant`,
|
||||
# ``_is_login_grant``, and the reservation guard in ``device_authorize``.
|
||||
LOGIN_GRANT_CLIENT_ID = "omnigent-cli"
|
||||
|
||||
|
||||
def _is_login_grant(client_id: str | None) -> bool:
|
||||
"""Return True for a first-party login grant (vs. a device grant).
|
||||
|
||||
Trustworthy because :data:`LOGIN_GRANT_CLIENT_ID` is reserved: the
|
||||
device-authorize path rejects it, so only the server-side login flow
|
||||
can create a grant carrying it.
|
||||
"""
|
||||
return client_id == LOGIN_GRANT_CLIENT_ID
|
||||
|
||||
|
||||
# RFC 8628 timings.
|
||||
_DEVICE_CODE_TTL_SECONDS = 600 # 10 min — bounds the unapproved window.
|
||||
_POLL_INTERVAL_SECONDS = 5 # minimum client poll interval.
|
||||
@@ -91,6 +112,41 @@ _ACCESS_TOKEN_TTL_SECONDS = 3600
|
||||
# re-consent through the flow. Bounds the blast radius of a leaked/phished
|
||||
# grant to this window even if revocation is never called.
|
||||
_GRANT_MAX_LIFETIME_SECONDS = 30 * 24 * 3600 # 30 days
|
||||
# Operator override for the grant lifetime, in whole days. Deployments
|
||||
# running unattended hosts can extend the re-consent window deliberately;
|
||||
# the 30-day default stays the safe posture.
|
||||
_GRANT_MAX_LIFETIME_ENV = "OMNIGENT_GRANT_MAX_LIFETIME_DAYS"
|
||||
|
||||
|
||||
def _grant_max_lifetime_seconds() -> int:
|
||||
"""Return the grant's absolute lifetime, honoring the env override.
|
||||
|
||||
Invalid or non-positive values fall back to the default — auth
|
||||
lifetimes must never fail open to "unbounded" on a typo.
|
||||
"""
|
||||
raw = os.environ.get(_GRANT_MAX_LIFETIME_ENV, "").strip()
|
||||
if raw:
|
||||
try:
|
||||
days = int(raw)
|
||||
except ValueError:
|
||||
_logger.warning(
|
||||
"%s=%r is not an integer — using the %d-day default",
|
||||
_GRANT_MAX_LIFETIME_ENV,
|
||||
raw,
|
||||
_GRANT_MAX_LIFETIME_SECONDS // 86400,
|
||||
)
|
||||
else:
|
||||
if days > 0:
|
||||
return days * 86400
|
||||
_logger.warning(
|
||||
"%s=%r must be positive — using the %d-day default",
|
||||
_GRANT_MAX_LIFETIME_ENV,
|
||||
raw,
|
||||
_GRANT_MAX_LIFETIME_SECONDS // 86400,
|
||||
)
|
||||
return _GRANT_MAX_LIFETIME_SECONDS
|
||||
|
||||
|
||||
# user_code alphabet excludes easily-confused chars (0/O, 1/I/L).
|
||||
_USER_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
||||
|
||||
@@ -106,10 +162,16 @@ def _client_id(body: dict[str, object]) -> str | None:
|
||||
|
||||
A public string naming the requesting application (e.g. Slack passes
|
||||
``"slack"``), the same for every grant that application initiates.
|
||||
Display + audit only — never an authorization key.
|
||||
Display + audit only for device grants — but see
|
||||
:data:`LOGIN_GRANT_CLIENT_ID`, which is reserved and refused here.
|
||||
|
||||
Non-string values (``{"client_id": 123}``) read as absent rather than
|
||||
raising, so a malformed body is a clean 400 and not a 500.
|
||||
"""
|
||||
value = body.get("client_id")
|
||||
return value.strip() or None if isinstance(value, str) else None
|
||||
raw = body.get("client_id")
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
return raw.strip() or None
|
||||
|
||||
|
||||
def _mint_refresh_token() -> str:
|
||||
@@ -126,34 +188,38 @@ def mint_delegated_token(
|
||||
grant_id: str,
|
||||
client_id: str,
|
||||
jti: str,
|
||||
scope: str = DELEGATED_SCOPE,
|
||||
scope: str | None = DELEGATED_SCOPE,
|
||||
) -> str:
|
||||
"""Mint a delegated access token for a device-authorization grant.
|
||||
"""Mint a grant-derived access token.
|
||||
|
||||
Same HS256 shape as
|
||||
:func:`omnigent.server.oidc.mint_session_token` (so
|
||||
:meth:`UnifiedAuthProvider._check_cookie` validates it unchanged),
|
||||
plus four delegated-only claims:
|
||||
plus grant claims:
|
||||
|
||||
- ``scope`` — restricts the token to the session APIs; the auth
|
||||
layer rejects admin endpoints when this claim is present.
|
||||
- ``grant_id`` — the device grant this token was issued from,
|
||||
checked against the revocation denylist so revoking the grant
|
||||
immediately kills the token.
|
||||
- ``scope`` — present ⇒ the auth layer restricts the token to the
|
||||
session APIs and refuses admin endpoints. ``None`` omits the claim,
|
||||
giving the token the SAME authority as the session JWT it renews —
|
||||
used ONLY for first-party login grants, whose bearer is the
|
||||
authenticated user's own CLI/host, not a third-party client.
|
||||
- ``grant_id`` — the grant this token was issued from, checked against
|
||||
the revocation denylist so revoking the grant immediately kills the
|
||||
token. Carried regardless of scope.
|
||||
- ``jti`` — unique token id, for audit/log correlation.
|
||||
- ``act`` — provenance (RFC 8693 style), ``{"client_id": "<app>"}``,
|
||||
naming the application that obtained the grant so every delegated
|
||||
action is attributable to it.
|
||||
naming the application that obtained the grant so every action is
|
||||
attributable to it.
|
||||
|
||||
:param user_id: The Omnigent identity the token acts as (``sub``).
|
||||
:param cookie_secret: HMAC key for HS256 signing.
|
||||
:param ttl_seconds: Token lifetime in seconds (kept short — ≤ 1 h).
|
||||
:param provider: Identity provider name (informational claim).
|
||||
:param grant_id: The device grant id.
|
||||
:param client_id: The RFC 8628 client id (the requesting application,
|
||||
:param grant_id: The grant id.
|
||||
:param client_id: The client id (the requesting application,
|
||||
e.g. ``"slack"``); recorded in the ``act`` claim for audit.
|
||||
:param jti: Unique token id.
|
||||
:param scope: Granted scope; defaults to :data:`DELEGATED_SCOPE`.
|
||||
:param scope: Granted scope, or ``None`` for a full-authority
|
||||
(login-grant) token. Defaults to :data:`DELEGATED_SCOPE`.
|
||||
:returns: An HS256-signed JWT string.
|
||||
"""
|
||||
now = int(time.time())
|
||||
@@ -162,11 +228,12 @@ def mint_delegated_token(
|
||||
"iat": now,
|
||||
"exp": now + ttl_seconds,
|
||||
"provider": provider,
|
||||
"scope": scope,
|
||||
"grant_id": grant_id,
|
||||
"jti": jti,
|
||||
"act": {"client_id": client_id},
|
||||
}
|
||||
if scope is not None:
|
||||
payload["scope"] = scope
|
||||
return jwt.encode(payload, cookie_secret, algorithm="HS256")
|
||||
|
||||
|
||||
@@ -257,6 +324,308 @@ class _SlidingWindowRateLimiter:
|
||||
return True
|
||||
|
||||
|
||||
def _resolve_signing_config(auth_provider: UnifiedAuthProvider) -> tuple[bytes, str]:
|
||||
"""Return ``(cookie_secret, provider_name)`` for token minting.
|
||||
|
||||
Works for both server-mintable providers: ``accounts`` and ``oidc``.
|
||||
Header mode has no server-held signing identity, so grant routes
|
||||
cannot be built for it.
|
||||
"""
|
||||
if auth_provider._source == "accounts":
|
||||
config = auth_provider._accounts_config
|
||||
assert config is not None, "accounts mode must have an accounts config"
|
||||
return config.cookie_secret, auth_provider._source
|
||||
if auth_provider._source == "oidc":
|
||||
oidc_config = auth_provider._oidc_config
|
||||
assert oidc_config is not None, "oidc mode must have an oidc config"
|
||||
return oidc_config.cookie_secret, auth_provider._source
|
||||
raise RuntimeError(
|
||||
f"grant routes require accounts or oidc auth (got {auth_provider._source!r})"
|
||||
)
|
||||
|
||||
|
||||
def _make_client_secret_gate() -> Callable[[Request], bool]:
|
||||
"""Build the optional shared-secret check for client-facing endpoints.
|
||||
|
||||
Reads the env once at mount (toggling requires a restart, consistent
|
||||
with the other auth env vars). Open when no secret is configured.
|
||||
"""
|
||||
client_secret = os.environ.get(_CLIENT_SECRET_ENV, "").strip() or None
|
||||
if client_secret is not None:
|
||||
_logger.info("device-auth: client-secret enforcement enabled")
|
||||
|
||||
def _client_secret_ok(request: Request) -> bool:
|
||||
if client_secret is None:
|
||||
return True
|
||||
# Compare on bytes: compare_digest raises TypeError on non-ASCII str
|
||||
# operands, and ASGI decodes header bytes as latin-1, so a crafted
|
||||
# non-ASCII header would otherwise 500 instead of cleanly failing.
|
||||
presented = request.headers.get(_CLIENT_SECRET_HEADER, "")
|
||||
return hmac.compare_digest(presented.encode("utf-8"), client_secret.encode("utf-8"))
|
||||
|
||||
return _client_secret_ok
|
||||
|
||||
|
||||
def issue_login_grant(
|
||||
device_grant_store: DeviceGrantStore,
|
||||
*,
|
||||
user_id: str,
|
||||
cookie_secret: bytes,
|
||||
) -> str:
|
||||
"""Create a redeemed refresh grant for an interactive login.
|
||||
|
||||
Called by the login flows (OIDC cli-ticket fulfillment, accounts
|
||||
``/auth/login``) so the CLI walks away with refresh material and can
|
||||
renew its access without a human re-running ``omnigent login``. The
|
||||
interactive login *is* the consent step, so the grant is born
|
||||
``redeemed`` and tagged with the reserved
|
||||
:data:`LOGIN_GRANT_CLIENT_ID` — which the device-authorize path
|
||||
refuses, so this authority class can only originate here.
|
||||
|
||||
:param device_grant_store: Grant persistence.
|
||||
:param user_id: The just-authenticated identity.
|
||||
:param cookie_secret: HMAC key for hashing the refresh token.
|
||||
:returns: The raw refresh token to hand to the client (stored hashed).
|
||||
"""
|
||||
refresh_token = _mint_refresh_token()
|
||||
device_grant_store.create_redeemed_grant(
|
||||
secrets.token_urlsafe(24),
|
||||
user_id=user_id,
|
||||
client_id=LOGIN_GRANT_CLIENT_ID,
|
||||
refresh_token_hash=hash_secret(refresh_token, cookie_secret),
|
||||
created_at=int(time.time()),
|
||||
)
|
||||
return refresh_token
|
||||
|
||||
|
||||
def create_oauth_token_router(
|
||||
auth_provider: UnifiedAuthProvider,
|
||||
device_grant_store: DeviceGrantStore,
|
||||
*,
|
||||
handle_device_code: Callable[[str], Response] | None = None,
|
||||
client_secret_ok: Callable[[Request], bool] | None = None,
|
||||
) -> APIRouter:
|
||||
"""Build the ``/oauth/token`` + ``/oauth/revoke`` router.
|
||||
|
||||
The refresh/revoke half of the grant machinery, mountable on its own:
|
||||
login-issued refresh grants (see :func:`issue_login_grant`) need these
|
||||
endpoints in **both** accounts and OIDC modes, independent of the
|
||||
RFC 8628 device-code consent flow (which stays accounts-only behind
|
||||
``OMNIGENT_DEVICE_GRANT_ENABLED``).
|
||||
|
||||
:param auth_provider: The active provider — ``accounts`` or ``oidc``.
|
||||
:param device_grant_store: Persistence for grants.
|
||||
:param handle_device_code: Optional device-code grant handler.
|
||||
:func:`create_device_auth_router` passes its polling closure so
|
||||
the full flow keeps one token endpoint; standalone mounts leave
|
||||
it ``None`` and ``device_code`` exchanges get
|
||||
``unsupported_grant_type``.
|
||||
:param client_secret_ok: Optional client-secret gate (callable that validates
|
||||
the request). When ``None`` (standalone mounts), builds the gate from
|
||||
the ``OMNIGENT_DEVICE_CLIENT_SECRET`` env var. When provided
|
||||
(from :func:`create_device_auth_router`), reuses the gate to avoid
|
||||
duplication.
|
||||
:returns: APIRouter to mount at the app root.
|
||||
"""
|
||||
cookie_secret, provider_name = _resolve_signing_config(auth_provider)
|
||||
_client_secret_ok = client_secret_ok or _make_client_secret_gate()
|
||||
# Resolve grant max lifetime once at mount time (not on every refresh).
|
||||
_grant_max_lifetime = _grant_max_lifetime_seconds()
|
||||
router = APIRouter()
|
||||
# Throttle for the opportunistic grant purge (bounds the table without a
|
||||
# separate scheduler). Mutable one-field dict so the closures can update
|
||||
# it. ``0.0`` forces a purge on the first refresh after boot.
|
||||
_last_purge = {"at": 0.0}
|
||||
|
||||
def _maybe_purge() -> None:
|
||||
"""Purge expired/aged grants at most once per interval.
|
||||
|
||||
The device flow purges on ``/oauth/device/authorize``, but the
|
||||
standalone token router (OIDC, or accounts without the device flow)
|
||||
has no authorize route — so login grants would otherwise accumulate
|
||||
one row per login. Piggyback the purge on refresh instead.
|
||||
"""
|
||||
now_wall = time.time()
|
||||
if now_wall - _last_purge["at"] < _PURGE_MIN_INTERVAL_SECONDS:
|
||||
return
|
||||
_last_purge["at"] = now_wall
|
||||
try:
|
||||
device_grant_store.purge_expired(
|
||||
int(now_wall), max_lifetime_seconds=_grant_max_lifetime
|
||||
)
|
||||
except Exception: # noqa: BLE001 — housekeeping must never fail a refresh
|
||||
_logger.debug("oauth/token: opportunistic grant purge failed", exc_info=True)
|
||||
|
||||
def _issue_access_token(grant_id: str, user_id: str, client_id: str) -> str:
|
||||
# A first-party login grant renews with the SAME authority as the
|
||||
# session JWT it replaces (scope=None); a third-party device grant
|
||||
# stays restricted to the delegated allowlist.
|
||||
scope = None if _is_login_grant(client_id) else DELEGATED_SCOPE
|
||||
return mint_delegated_token(
|
||||
user_id,
|
||||
cookie_secret,
|
||||
_ACCESS_TOKEN_TTL_SECONDS,
|
||||
provider_name,
|
||||
grant_id=grant_id,
|
||||
client_id=client_id or "",
|
||||
jti=secrets.token_urlsafe(16),
|
||||
scope=scope,
|
||||
)
|
||||
|
||||
@router.post("/oauth/token", dependencies=[])
|
||||
async def token(request: Request) -> Response:
|
||||
"""Exchange a device_code or refresh_token for an access token.
|
||||
|
||||
RFC 8628 / 6749 error shapes: ``authorization_pending``,
|
||||
``slow_down``, ``expired_token``, ``access_denied``,
|
||||
``invalid_grant``, ``unsupported_grant_type``.
|
||||
"""
|
||||
form = await request.form()
|
||||
grant_type = str(form.get("grant_type") or "")
|
||||
|
||||
if grant_type == "urn:ietf:params:oauth:grant-type:device_code":
|
||||
# The device-code exchange mints from the ephemeral device_code
|
||||
# alone, so it stays behind the client-secret gate. Refresh
|
||||
# presents the refresh token itself as the credential, so it is
|
||||
# not additionally gated (a CLI/host renewing its own login has
|
||||
# no way to carry the device client secret).
|
||||
if not _client_secret_ok(request):
|
||||
return _oauth_error("invalid_client", status_code=401)
|
||||
if handle_device_code is None:
|
||||
return _oauth_error("unsupported_grant_type")
|
||||
return handle_device_code(str(form.get("device_code") or ""))
|
||||
if grant_type == "refresh_token":
|
||||
return _handle_refresh_grant(str(form.get("refresh_token") or ""))
|
||||
return _oauth_error("unsupported_grant_type")
|
||||
|
||||
def _handle_refresh_grant(refresh_token: str) -> Response:
|
||||
if not refresh_token:
|
||||
return _oauth_error("invalid_request")
|
||||
# Opportunistic housekeeping so login-grant rows (one per login)
|
||||
# don't accumulate where no device-authorize purge runs.
|
||||
_maybe_purge()
|
||||
presented_hash = hash_secret(refresh_token, cookie_secret)
|
||||
# A refresh token doesn't name its grant, so locate it by digest.
|
||||
# Only a live (redeemed, non-revoked) grant holds a matching hash.
|
||||
grant = device_grant_store.get_by_refresh_hash(presented_hash)
|
||||
if grant is None:
|
||||
# Not the current token. If it matches a grant's *previous*
|
||||
# token, a stale token was replayed — reuse/theft. Revoke the
|
||||
# whole grant so the attacker's freshly-rotated token dies too.
|
||||
# (Login grants don't rotate, so they never populate the prev
|
||||
# hash and can't reach this revoke.)
|
||||
stale = device_grant_store.get_by_prev_refresh_hash(presented_hash)
|
||||
if stale is not None:
|
||||
device_grant_store.revoke(stale.id)
|
||||
_logger.warning(
|
||||
"oauth/token: refresh reuse detected on grant %s — revoked", stale.id
|
||||
)
|
||||
return _oauth_error("invalid_grant")
|
||||
# Refuse to refresh a grant past its absolute lifetime — the user
|
||||
# must re-consent. Checked before rotating so an aged grant simply
|
||||
# stops working (it is NOT reuse, so it must not revoke/oscillate).
|
||||
if grant.approved_at is not None and (
|
||||
int(time.time()) - grant.approved_at >= _grant_max_lifetime
|
||||
):
|
||||
return _oauth_error("expired_token")
|
||||
if grant.user_id is None:
|
||||
return _oauth_error("invalid_grant")
|
||||
|
||||
if _is_login_grant(grant.client_id):
|
||||
# First-party login grants do NOT rotate. The bearer is an
|
||||
# unattended host/CLI, so a lost refresh response (network blip,
|
||||
# crash between the server committing and the client persisting)
|
||||
# must not brick the grant via reuse detection. The same refresh
|
||||
# token stays valid for the grant lifetime; only the short-lived
|
||||
# access token is renewed. Revocation + the absolute lifetime cap
|
||||
# bound the exposure.
|
||||
access_token = _issue_access_token(grant.id, grant.user_id, grant.client_id or "")
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"access_token": access_token,
|
||||
"refresh_token": refresh_token,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": _ACCESS_TOKEN_TTL_SECONDS,
|
||||
},
|
||||
)
|
||||
|
||||
new_refresh = _mint_refresh_token()
|
||||
rotated = device_grant_store.rotate_refresh_token(
|
||||
grant.id,
|
||||
expected_hash=presented_hash,
|
||||
new_hash=hash_secret(new_refresh, cookie_secret),
|
||||
now_epoch_seconds=int(time.time()),
|
||||
max_lifetime_seconds=_grant_max_lifetime,
|
||||
)
|
||||
if rotated is None:
|
||||
# Lost a concurrent rotation race, or the grant aged out between
|
||||
# the check above and here — reject without revoking (this is not
|
||||
# a reuse signal, so the grant must not be killed/oscillate).
|
||||
return _oauth_error("invalid_grant")
|
||||
if rotated.user_id is None:
|
||||
return _oauth_error("invalid_grant")
|
||||
access_token = _issue_access_token(
|
||||
rotated.id,
|
||||
rotated.user_id,
|
||||
rotated.client_id or "",
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"access_token": access_token,
|
||||
"refresh_token": new_refresh,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": _ACCESS_TOKEN_TTL_SECONDS,
|
||||
},
|
||||
)
|
||||
|
||||
# ── Revocation ────────────────────────────────────────────────
|
||||
|
||||
@router.post("/oauth/revoke", dependencies=[])
|
||||
async def revoke(request: Request) -> Response:
|
||||
"""Revoke a grant by refresh token or by the caller's access token.
|
||||
|
||||
Backs ``/omnigent logout``. Accepts a ``refresh_token`` form
|
||||
field; falls back to the ``grant_id`` on the caller's own
|
||||
delegated access token so a client with only its access token
|
||||
can still log out. Not behind the device client-secret gate: the
|
||||
presented refresh/access token IS the credential, and a CLI
|
||||
logging out its own login grant cannot carry that secret.
|
||||
"""
|
||||
form = await request.form()
|
||||
refresh_token = str(form.get("refresh_token") or "")
|
||||
grant = None
|
||||
if refresh_token:
|
||||
grant = device_grant_store.get_by_refresh_hash(
|
||||
hash_secret(refresh_token, cookie_secret)
|
||||
)
|
||||
if grant is None:
|
||||
grant_id = _grant_id_from_bearer(request)
|
||||
if grant_id is not None:
|
||||
grant = device_grant_store.get_by_id(grant_id)
|
||||
if grant is None:
|
||||
# Idempotent: nothing to revoke is still "revoked" from the
|
||||
# caller's perspective. Don't leak which tokens exist.
|
||||
return JSONResponse(status_code=200, content={"revoked": True})
|
||||
device_grant_store.revoke(grant.id)
|
||||
_logger.info("oauth/revoke: revoked grant %s", grant.id)
|
||||
return JSONResponse(status_code=200, content={"revoked": True})
|
||||
|
||||
def _grant_id_from_bearer(request: Request) -> str | None:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(auth_header[7:], cookie_secret, algorithms=["HS256"])
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
grant_id = payload.get("grant_id")
|
||||
return grant_id if isinstance(grant_id, str) else None
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def create_device_auth_router(
|
||||
auth_provider: UnifiedAuthProvider,
|
||||
device_grant_store: DeviceGrantStore,
|
||||
@@ -278,28 +647,9 @@ def create_device_auth_router(
|
||||
base_url = cookie_config.base_url
|
||||
provider_name = auth_provider._source
|
||||
|
||||
# Read the optional client secret once at mount. When set, the
|
||||
# client-facing endpoints require a matching header; when unset they
|
||||
# stay public. Captured here (not per-request) so toggling it needs a
|
||||
# restart — consistent with the other auth env vars.
|
||||
client_secret = os.environ.get(_CLIENT_SECRET_ENV, "").strip() or None
|
||||
if client_secret is not None:
|
||||
_logger.info("device-auth: client-secret enforcement enabled")
|
||||
|
||||
def _client_secret_ok(request: Request) -> bool:
|
||||
"""Return True if the request may use the client-facing endpoints.
|
||||
|
||||
Open when no secret is configured; otherwise requires the presented
|
||||
header to match, compared in constant time to avoid leaking the
|
||||
secret through timing.
|
||||
"""
|
||||
if client_secret is None:
|
||||
return True
|
||||
# Compare on bytes: compare_digest raises TypeError on non-ASCII str
|
||||
# operands, and ASGI decodes header bytes as latin-1, so a crafted
|
||||
# non-ASCII header would otherwise 500 instead of cleanly failing.
|
||||
presented = request.headers.get(_CLIENT_SECRET_HEADER, "")
|
||||
return hmac.compare_digest(presented.encode("utf-8"), client_secret.encode("utf-8"))
|
||||
_client_secret_ok = _make_client_secret_gate()
|
||||
# Resolve grant max lifetime once at mount (not on every purge).
|
||||
_grant_max_lifetime = _grant_max_lifetime_seconds()
|
||||
|
||||
router = APIRouter()
|
||||
_rate_limiter = _SlidingWindowRateLimiter(
|
||||
@@ -347,7 +697,7 @@ def create_device_auth_router(
|
||||
_last_purge["at"] = now_wall
|
||||
try:
|
||||
device_grant_store.purge_expired(
|
||||
int(now_wall), max_lifetime_seconds=_GRANT_MAX_LIFETIME_SECONDS
|
||||
int(now_wall), max_lifetime_seconds=_grant_max_lifetime
|
||||
)
|
||||
except Exception: # housekeeping must never fail a request
|
||||
_logger.exception("device grant purge failed")
|
||||
@@ -359,6 +709,15 @@ def create_device_auth_router(
|
||||
if not isinstance(body, dict):
|
||||
body = {}
|
||||
client_id = _client_id(body)
|
||||
# LOGIN_GRANT_CLIENT_ID marks a first-party login grant, whose
|
||||
# refreshed tokens carry full session authority. It is reserved:
|
||||
# a device client must never be able to self-declare into that
|
||||
# class by naming it here.
|
||||
if _is_login_grant(client_id):
|
||||
_logger.warning(
|
||||
"device/authorize: refused reserved client_id %r", LOGIN_GRANT_CLIENT_ID
|
||||
)
|
||||
return _oauth_error("invalid_request")
|
||||
|
||||
device_code = secrets.token_urlsafe(32)
|
||||
grant_id = secrets.token_urlsafe(16)
|
||||
@@ -543,26 +902,10 @@ def create_device_auth_router(
|
||||
device_grant_store.deny(grant.id)
|
||||
return HTMLResponse(_consent_html(denied=True), status_code=200)
|
||||
|
||||
# ── Token endpoint (client polling + refresh) ─────────────────
|
||||
|
||||
@router.post("/oauth/token", dependencies=[])
|
||||
async def token(request: Request) -> Response:
|
||||
"""Exchange a device_code or refresh_token for an access token.
|
||||
|
||||
RFC 8628 / 6749 error shapes: ``authorization_pending``,
|
||||
``slow_down``, ``expired_token``, ``access_denied``,
|
||||
``invalid_grant``, ``unsupported_grant_type``.
|
||||
"""
|
||||
if not _client_secret_ok(request):
|
||||
return _oauth_error("invalid_client", status_code=401)
|
||||
form = await request.form()
|
||||
grant_type = str(form.get("grant_type") or "")
|
||||
|
||||
if grant_type == "urn:ietf:params:oauth:grant-type:device_code":
|
||||
return _handle_device_code_grant(str(form.get("device_code") or ""))
|
||||
if grant_type == "refresh_token":
|
||||
return _handle_refresh_grant(str(form.get("refresh_token") or ""))
|
||||
return _oauth_error("unsupported_grant_type")
|
||||
# ── Token + revocation endpoints ──────────────────────────────
|
||||
# Shared with the standalone login-grant mount: the device flow's
|
||||
# only addition is the device_code grant handler, injected here so
|
||||
# /oauth/token stays a single registration either way.
|
||||
|
||||
def _handle_device_code_grant(device_code: str) -> Response:
|
||||
if not device_code:
|
||||
@@ -613,103 +956,14 @@ def create_device_auth_router(
|
||||
},
|
||||
)
|
||||
|
||||
def _handle_refresh_grant(refresh_token: str) -> Response:
|
||||
if not refresh_token:
|
||||
return _oauth_error("invalid_request")
|
||||
presented_hash = hash_secret(refresh_token, cookie_secret)
|
||||
# A refresh token doesn't name its grant, so locate it by digest.
|
||||
# Only a live (redeemed, non-revoked) grant holds a matching hash.
|
||||
grant = device_grant_store.get_by_refresh_hash(presented_hash)
|
||||
if grant is None:
|
||||
# Not the current token. If it matches a grant's *previous*
|
||||
# token, a stale token was replayed — reuse/theft. Revoke the
|
||||
# whole grant so the attacker's freshly-rotated token dies too.
|
||||
stale = device_grant_store.get_by_prev_refresh_hash(presented_hash)
|
||||
if stale is not None:
|
||||
device_grant_store.revoke(stale.id)
|
||||
_logger.warning(
|
||||
"oauth/token: refresh reuse detected on grant %s — revoked", stale.id
|
||||
)
|
||||
return _oauth_error("invalid_grant")
|
||||
# Refuse to refresh a grant past its absolute lifetime — the user
|
||||
# must re-consent. Checked before rotating so an aged grant simply
|
||||
# stops working (it is NOT reuse, so it must not revoke/oscillate).
|
||||
if grant.approved_at is not None and (
|
||||
int(time.time()) - grant.approved_at >= _GRANT_MAX_LIFETIME_SECONDS
|
||||
):
|
||||
return _oauth_error("expired_token")
|
||||
new_refresh = _mint_refresh_token()
|
||||
rotated = device_grant_store.rotate_refresh_token(
|
||||
grant.id,
|
||||
expected_hash=presented_hash,
|
||||
new_hash=hash_secret(new_refresh, cookie_secret),
|
||||
now_epoch_seconds=int(time.time()),
|
||||
max_lifetime_seconds=_GRANT_MAX_LIFETIME_SECONDS,
|
||||
router.include_router(
|
||||
create_oauth_token_router(
|
||||
auth_provider,
|
||||
device_grant_store,
|
||||
handle_device_code=_handle_device_code_grant,
|
||||
client_secret_ok=_client_secret_ok,
|
||||
)
|
||||
if rotated is None:
|
||||
# Lost a concurrent rotation race, or the grant aged out between
|
||||
# the check above and here — reject without revoking (this is not
|
||||
# a reuse signal, so the grant must not be killed/oscillate).
|
||||
return _oauth_error("invalid_grant")
|
||||
if rotated.user_id is None:
|
||||
return _oauth_error("invalid_grant")
|
||||
access_token = _issue_access_token(
|
||||
rotated.id,
|
||||
rotated.user_id,
|
||||
rotated.client_id or "",
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=200,
|
||||
content={
|
||||
"access_token": access_token,
|
||||
"refresh_token": new_refresh,
|
||||
"token_type": "Bearer",
|
||||
"expires_in": _ACCESS_TOKEN_TTL_SECONDS,
|
||||
},
|
||||
)
|
||||
|
||||
# ── Revocation ────────────────────────────────────────────────
|
||||
|
||||
@router.post("/oauth/revoke", dependencies=[])
|
||||
async def revoke(request: Request) -> Response:
|
||||
"""Revoke a grant by refresh token or by the caller's access token.
|
||||
|
||||
Backs ``/omnigent logout``. Accepts a ``refresh_token`` form
|
||||
field; falls back to the ``grant_id`` on the caller's own
|
||||
delegated access token so a client with only its access token
|
||||
can still log out.
|
||||
"""
|
||||
if not _client_secret_ok(request):
|
||||
return _oauth_error("invalid_client", status_code=401)
|
||||
form = await request.form()
|
||||
refresh_token = str(form.get("refresh_token") or "")
|
||||
grant = None
|
||||
if refresh_token:
|
||||
grant = device_grant_store.get_by_refresh_hash(
|
||||
hash_secret(refresh_token, cookie_secret)
|
||||
)
|
||||
if grant is None:
|
||||
grant_id = _grant_id_from_bearer(request)
|
||||
if grant_id is not None:
|
||||
grant = device_grant_store.get_by_id(grant_id)
|
||||
if grant is None:
|
||||
# Idempotent: nothing to revoke is still "revoked" from the
|
||||
# caller's perspective. Don't leak which tokens exist.
|
||||
return JSONResponse(status_code=200, content={"revoked": True})
|
||||
device_grant_store.revoke(grant.id)
|
||||
_logger.info("oauth/revoke: revoked grant %s", grant.id)
|
||||
return JSONResponse(status_code=200, content={"revoked": True})
|
||||
|
||||
def _grant_id_from_bearer(request: Request) -> str | None:
|
||||
auth_header = request.headers.get("Authorization", "")
|
||||
if not auth_header.startswith("Bearer "):
|
||||
return None
|
||||
try:
|
||||
payload = jwt.decode(auth_header[7:], cookie_secret, algorithms=["HS256"])
|
||||
except jwt.InvalidTokenError:
|
||||
return None
|
||||
grant_id = payload.get("grant_id")
|
||||
return grant_id if isinstance(grant_id, str) else None
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@@ -681,7 +681,7 @@ def create_hosts_router(
|
||||
request: Request,
|
||||
host_id: str,
|
||||
harness: str,
|
||||
) -> dict[str, list[dict[str, Any]]]:
|
||||
) -> dict[str, list[Any]]:
|
||||
"""Return pre-launch model choices resolved by the selected host.
|
||||
|
||||
A preview of the host's ambient default catalog, not a binding
|
||||
@@ -709,7 +709,22 @@ def create_hosts_router(
|
||||
detail=str(result.get("error") or "host model-options lookup failed"),
|
||||
)
|
||||
models = result.get("models")
|
||||
return {"models": models if isinstance(models, list) else []}
|
||||
routable = result.get("routable_models")
|
||||
payload: dict[str, Any] = {
|
||||
"models": models if isinstance(models, list) else [],
|
||||
# Every id the harness's endpoint routes: the picker names one
|
||||
# row per model, while a launch takes an exact id.
|
||||
"routable_models": (
|
||||
[m for m in routable if isinstance(m, str)] if isinstance(routable, list) else []
|
||||
),
|
||||
}
|
||||
# An honest empty answer carries the reason (e.g. "the codex model
|
||||
# probe failed — see the host log") so the picker can say WHY it is
|
||||
# empty instead of a generic "Models unavailable".
|
||||
error = result.get("error")
|
||||
if isinstance(error, str) and error:
|
||||
payload["error"] = error
|
||||
return payload
|
||||
|
||||
@router.post("/hosts/{host_id}/runners")
|
||||
async def launch_runner(
|
||||
|
||||
@@ -52,6 +52,7 @@ class CreateScheduledTaskRequest(BaseModel):
|
||||
timezone: str = "UTC"
|
||||
model_override: str | None = None
|
||||
reasoning_effort: str | None = None
|
||||
max_cost_usd: float | None = Field(default=None, gt=0)
|
||||
# Optional: no PINNED host/workspace. When both are unset the fire path
|
||||
# resolves the owner's online host at fire time and defaults the workspace to
|
||||
# that host's home directory (a failed run is recorded if none is online) —
|
||||
@@ -74,6 +75,7 @@ class UpdateScheduledTaskRequest(BaseModel):
|
||||
timezone: str | None = None
|
||||
model_override: str | None = None
|
||||
reasoning_effort: str | None = None
|
||||
max_cost_usd: float | None = Field(default=None, gt=0) # null clears the cap
|
||||
workspace: str | None = Field(default=None, min_length=1)
|
||||
host_id: str | None = Field(default=None, min_length=1)
|
||||
state: str | None = None
|
||||
@@ -121,6 +123,7 @@ def _to_response(
|
||||
"created_at": task.created_at,
|
||||
"model_override": task.model_override,
|
||||
"reasoning_effort": task.reasoning_effort,
|
||||
"max_cost_usd": task.max_cost_usd,
|
||||
"workspace": task.workspace,
|
||||
"host_id": task.host_id,
|
||||
"state": task.state,
|
||||
@@ -311,6 +314,7 @@ def create_scheduled_tasks_router(
|
||||
timezone=body.timezone,
|
||||
model_override=model_override,
|
||||
reasoning_effort=reasoning_effort,
|
||||
max_cost_usd=body.max_cost_usd,
|
||||
workspace=workspace,
|
||||
host_id=body.host_id,
|
||||
)
|
||||
|
||||
@@ -187,6 +187,8 @@ from omnigent.server.routes._sessions.common import (
|
||||
_CLAUDE_NATIVE_MESSAGE_TIMEOUT_S as _CLAUDE_NATIVE_MESSAGE_TIMEOUT_S,
|
||||
_CLAUDE_NATIVE_MODEL as _CLAUDE_NATIVE_MODEL,
|
||||
_CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S as _CLAUDE_NATIVE_PERMISSION_HOOK_TIMEOUT_S,
|
||||
_CLAUDE_NATIVE_PERMISSION_MODES as _CLAUDE_NATIVE_PERMISSION_MODES,
|
||||
_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY as _CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY,
|
||||
_CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS as _CLAUDE_NATIVE_REMEMBER_INELIGIBLE_TOOLS,
|
||||
_CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY as _CLAUDE_NATIVE_SUBAGENT_ID_LABEL_KEY,
|
||||
_CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE as _CLAUDE_NATIVE_SUBAGENT_WRAPPER_LABEL_VALUE,
|
||||
@@ -495,6 +497,7 @@ from omnigent.server.routes._sessions.helpers import (
|
||||
_require_declared_subagent as _require_declared_subagent,
|
||||
_require_external_status_forward as _require_external_status_forward,
|
||||
_require_host_conn_for_worktree as _require_host_conn_for_worktree,
|
||||
_require_permission_mode_forward as _require_permission_mode_forward,
|
||||
_reset_runner_resources_after_switch_impl as _reset_runner_resources_after_switch_impl,
|
||||
_resolve_llm_model as _resolve_llm_model,
|
||||
_resolve_skill_meta_text_via_runner as _resolve_skill_meta_text_via_runner,
|
||||
|
||||
@@ -94,9 +94,12 @@ from omnigent.server.routes._content_type import (
|
||||
from omnigent.server.routes._errors import session_not_found as _session_not_found
|
||||
from omnigent.server.routes._origin import require_trusted_origin
|
||||
from omnigent.server.routes._sessions.common import (
|
||||
_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY,
|
||||
_CLAUDE_NATIVE_PERMISSION_MODES,
|
||||
_CLAUDE_NATIVE_UI_LABEL_KEY,
|
||||
_CLAUDE_NATIVE_UI_LABEL_VALUE,
|
||||
_CLAUDE_NATIVE_WRAPPER_LABEL_KEY,
|
||||
_CLAUDE_NATIVE_WRAPPER_LABEL_VALUE,
|
||||
_CODEX_NATIVE_COLLABORATION_MODE_LABEL_KEY,
|
||||
_CODEX_NATIVE_COLLABORATION_MODES,
|
||||
_CODEX_NATIVE_WRAPPER_LABEL_VALUE,
|
||||
@@ -125,12 +128,14 @@ from omnigent.server.routes._sessions.helpers import (
|
||||
_presentation_labels_for_agent,
|
||||
_prune_session_read_state,
|
||||
_publish_collaboration_mode,
|
||||
_publish_permission_mode,
|
||||
_publish_sandbox_status,
|
||||
_publish_terminal_pending,
|
||||
_reject_reserved_cost_control_label_seed,
|
||||
_reject_server_reserved_label_seed,
|
||||
_require_collaboration_mode_forward,
|
||||
_require_cost_control_label_authority,
|
||||
_require_permission_mode_forward,
|
||||
_reset_runner_resources_after_switch,
|
||||
_same_provider_family,
|
||||
_session_status_from_cache,
|
||||
@@ -1598,6 +1603,34 @@ def register_core_routes(
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
requested_codex_collaboration_mode = body.collaboration_mode
|
||||
permission_mode_requested = "permission_mode" in body.model_fields_set
|
||||
requested_claude_permission_mode: str | None = None
|
||||
if permission_mode_requested:
|
||||
if body.permission_mode is None:
|
||||
raise OmnigentError(
|
||||
"permission_mode must be a non-empty string",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
if body.permission_mode not in _CLAUDE_NATIVE_PERMISSION_MODES:
|
||||
raise OmnigentError(
|
||||
f"permission_mode must be one of {sorted(_CLAUDE_NATIVE_PERMISSION_MODES)}",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
conv_for_permission_mode = await asyncio.to_thread(
|
||||
conversation_store.get_conversation,
|
||||
session_id,
|
||||
)
|
||||
if conv_for_permission_mode is None:
|
||||
raise _session_not_found()
|
||||
if (
|
||||
conv_for_permission_mode.labels.get(_CLAUDE_NATIVE_WRAPPER_LABEL_KEY)
|
||||
!= _CLAUDE_NATIVE_WRAPPER_LABEL_VALUE
|
||||
):
|
||||
raise OmnigentError(
|
||||
"permission_mode is only supported for claude-native sessions",
|
||||
code=ErrorCode.INVALID_INPUT,
|
||||
)
|
||||
requested_claude_permission_mode = body.permission_mode
|
||||
labels_to_set = dict(body.labels or {})
|
||||
# Pins are per-user. The client writes the canonical ``omnigent.pinned``
|
||||
# key; rewrite it to the caller's per-user key so one user's pin doesn't
|
||||
@@ -1876,6 +1909,24 @@ def register_core_routes(
|
||||
_codex_plan_enabled,
|
||||
_runner_result,
|
||||
)
|
||||
if requested_claude_permission_mode is not None and live_forward:
|
||||
_mode_result = await _forward_session_change_to_runner(
|
||||
session_id,
|
||||
runner_router,
|
||||
{
|
||||
"type": "permission_mode_change",
|
||||
"permission_mode": requested_claude_permission_mode,
|
||||
},
|
||||
)
|
||||
# Raises unless the runner confirms the switch, so the label can
|
||||
# never claim a mode Claude isn't in. Stores the mode it reached.
|
||||
labels_to_set[_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY] = (
|
||||
_require_permission_mode_forward(
|
||||
session_id,
|
||||
requested_claude_permission_mode,
|
||||
_mode_result,
|
||||
)
|
||||
)
|
||||
# Some labels are cleared by DELETE, not by upserting an empty value:
|
||||
# the project membership (empty = "remove from project") and the pinned
|
||||
# flag (empty = "unpin"). Split any empty-valued clear keys out before
|
||||
@@ -1889,6 +1940,13 @@ def register_core_routes(
|
||||
await asyncio.to_thread(conversation_store.delete_label, session_id, _clear_key)
|
||||
if labels_to_set:
|
||||
await asyncio.to_thread(conversation_store.set_labels, session_id, labels_to_set)
|
||||
# Only when the switch was forwarded: a silent PATCH writes no label,
|
||||
# and an unconfirmed mode must not reach the picker.
|
||||
if _CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY in labels_to_set:
|
||||
_publish_permission_mode(
|
||||
session_id,
|
||||
labels_to_set[_CLAUDE_NATIVE_PERMISSION_MODE_LABEL_KEY],
|
||||
)
|
||||
# Archiving means "get this out of my way", which contradicts a pin
|
||||
# ("keep it at the top"), so drop the archiver's own pin — otherwise the
|
||||
# session lingers as a pinned row if later unarchived. Runs after the
|
||||
|
||||
@@ -93,6 +93,7 @@ from omnigent.server.routes._sessions.common import (
|
||||
_EXTERNAL_MODEL_OPTIONS_TYPE,
|
||||
_EXTERNAL_OUTPUT_REASONING_DELTA_TYPE,
|
||||
_EXTERNAL_OUTPUT_TEXT_DELTA_TYPE,
|
||||
_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE,
|
||||
_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE,
|
||||
_EXTERNAL_SESSION_INTERRUPTED_TYPE,
|
||||
_EXTERNAL_SESSION_STATUS_TYPE,
|
||||
@@ -140,6 +141,7 @@ from omnigent.server.routes._sessions.helpers import (
|
||||
_persist_external_codex_collaboration_mode_change,
|
||||
_persist_external_model_change,
|
||||
_persist_external_model_options,
|
||||
_persist_external_permission_mode_change,
|
||||
_persist_external_reasoning_effort_change,
|
||||
_persist_external_session_title,
|
||||
_persist_external_subagent_start,
|
||||
@@ -538,6 +540,7 @@ def register_events_routes(
|
||||
_EXTERNAL_MCP_STARTUP_TYPE,
|
||||
_EXTERNAL_MODEL_CHANGE_TYPE,
|
||||
_EXTERNAL_MODEL_OPTIONS_TYPE,
|
||||
_EXTERNAL_PERMISSION_MODE_CHANGE_TYPE,
|
||||
_EXTERNAL_REASONING_EFFORT_CHANGE_TYPE,
|
||||
_EXTERNAL_SESSION_TITLE_TYPE,
|
||||
_EXTERNAL_SESSION_TODOS_TYPE,
|
||||
@@ -1186,6 +1189,14 @@ def register_events_routes(
|
||||
conversation_store,
|
||||
)
|
||||
return {"queued": False}
|
||||
if body.type == _EXTERNAL_PERMISSION_MODE_CHANGE_TYPE:
|
||||
await _persist_external_permission_mode_change(
|
||||
session_id,
|
||||
conv,
|
||||
body,
|
||||
conversation_store,
|
||||
)
|
||||
return {"queued": False}
|
||||
if body.type == _EXTERNAL_SESSION_TITLE_TYPE:
|
||||
await _persist_external_session_title(
|
||||
session_id,
|
||||
|
||||
@@ -32,10 +32,12 @@ to resolving the terminal in the local registry.
|
||||
Wire protocol on the WebSocket
|
||||
------------------------------
|
||||
|
||||
- **Server → client**: every PTY read is forwarded as a *binary*
|
||||
WebSocket frame. xterm.js's ``term.write()`` accepts ``Uint8Array``
|
||||
directly and runs it through its ANSI parser, so colors, cursor
|
||||
motion, alternate screen, mouse modes all work transparently.
|
||||
- **Server → client**: terminal output is forwarded as *binary* WebSocket
|
||||
frames. xterm.js's ``term.write()`` accepts ``Uint8Array`` directly and runs
|
||||
it through its ANSI parser, so colors, cursor motion, alternate screen, and
|
||||
mouse modes work transparently. Control-mode attaches may also send a text
|
||||
``clipboard-write`` JSON frame after tmux reports a copy-mode selection; PTY
|
||||
attaches advertise whether OSC 52 is safe before terminal output begins.
|
||||
- **Client → server**:
|
||||
- **Text frames** are JSON control messages:
|
||||
``{"type": "resize", "cols": N, "rows": M}``. Parsed and applied
|
||||
@@ -274,17 +276,21 @@ def create_terminal_attach_router(
|
||||
"terminal.transport": resolved_transport,
|
||||
},
|
||||
):
|
||||
bridge = (
|
||||
bridge_tmux_control_to_websocket
|
||||
if resolved_transport == TERMINAL_TRANSPORT_CONTROL
|
||||
else bridge_tmux_pty_to_websocket
|
||||
)
|
||||
await bridge(
|
||||
websocket,
|
||||
socket_path=str(entry.instance.socket_path),
|
||||
tmux_target=entry.instance.tmux_target,
|
||||
read_only=read_only,
|
||||
)
|
||||
if resolved_transport == TERMINAL_TRANSPORT_CONTROL:
|
||||
await bridge_tmux_control_to_websocket(
|
||||
websocket,
|
||||
socket_path=str(entry.instance.socket_path),
|
||||
tmux_target=entry.instance.tmux_target,
|
||||
read_only=read_only,
|
||||
)
|
||||
else:
|
||||
await bridge_tmux_pty_to_websocket(
|
||||
websocket,
|
||||
socket_path=str(entry.instance.socket_path),
|
||||
tmux_target=entry.instance.tmux_target,
|
||||
read_only=read_only,
|
||||
allow_osc52_clipboard=not entry.instance.tmux_allow_passthrough,
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@@ -120,6 +120,7 @@ class FireDeps:
|
||||
permission_store: Any | None
|
||||
host_store: Any | None
|
||||
host_registry: Any | None
|
||||
policy_store: Any | None = None
|
||||
agent_cache: Any | None = None
|
||||
runner_router: Any | None = None
|
||||
tunnel_registry: Any | None = None
|
||||
@@ -425,6 +426,8 @@ async def _run_fire_for_task(
|
||||
)
|
||||
return
|
||||
|
||||
await _attach_cost_budget(deps, task, conv.id)
|
||||
|
||||
try:
|
||||
await _grant_owner(deps, task, conv.id)
|
||||
except Exception:
|
||||
@@ -607,6 +610,38 @@ async def _create_session(deps: FireDeps, task: ScheduledTask) -> Conversation:
|
||||
return conv
|
||||
|
||||
|
||||
_COST_BUDGET_HANDLER = "omnigent.policies.builtins.cost.cost_budget"
|
||||
_COST_BUDGET_POLICY_NAME = "__scheduled_task_cost_budget"
|
||||
|
||||
|
||||
async def _attach_cost_budget(deps: FireDeps, task: ScheduledTask, conversation_id: str) -> None:
|
||||
"""Attach a cost_budget policy to a session spawned by a scheduled task.
|
||||
|
||||
Non-fatal: a failure logs a warning but does not fail the fire — an
|
||||
uncapped session is better than a dead run.
|
||||
"""
|
||||
if task.max_cost_usd is None or deps.policy_store is None:
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(
|
||||
deps.policy_store.create,
|
||||
policy_id=_new_id(),
|
||||
session_id=conversation_id,
|
||||
name=_COST_BUDGET_POLICY_NAME,
|
||||
type="python",
|
||||
handler=_COST_BUDGET_HANDLER,
|
||||
factory_params={"max_cost_usd": task.max_cost_usd},
|
||||
enabled=True,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
_logger.warning(
|
||||
"scheduled fire: failed to attach cost budget for task %s (session %s)",
|
||||
task.id,
|
||||
conversation_id,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
|
||||
async def _grant_owner(deps: FireDeps, task: ScheduledTask, conversation_id: str) -> None:
|
||||
"""Write the LEVEL_OWNER grant so the run is visible to its owner.
|
||||
|
||||
|
||||
+54
-17
@@ -1485,7 +1485,7 @@ class SessionCreateMetadata(BaseModel):
|
||||
:param reasoning_effort: Optional per-session reasoning-effort
|
||||
hint. Accepted metadata values are ``"none"``,
|
||||
``"minimal"``, ``"low"``, ``"medium"``, ``"high"``,
|
||||
``"xhigh"``, and ``"max"``. Provider-specific support is
|
||||
``"xhigh"``, ``"max"``, and ``"ultra"``. Provider-specific support is
|
||||
validated when a turn executes. ``None`` means use the agent
|
||||
default.
|
||||
:param host_id: Optional host to launch the runner on, e.g.
|
||||
@@ -1720,10 +1720,13 @@ class SessionResponse(BaseModel):
|
||||
permission level on this session: ``1`` = read, ``2`` =
|
||||
edit, ``3`` = manage. ``None`` when permissions are
|
||||
disabled (single-user mode without a permission store).
|
||||
:param llm_model: The LLM model identifier from the bound
|
||||
agent's spec, e.g. ``"anthropic/claude-sonnet-4-6"``.
|
||||
``None`` when the agent has no explicit ``llm:`` block or
|
||||
the agent cannot be looked up.
|
||||
:param llm_model: The model this session is actually on. When the
|
||||
harness has reported one (``reported_model``, written by
|
||||
``external_model_change``), that verbatim value serves here
|
||||
and is the only model value clients display; otherwise the
|
||||
bound agent spec's model, e.g.
|
||||
``"anthropic/claude-sonnet-4-6"``. ``None`` when neither
|
||||
exists.
|
||||
:param harness: The bound agent's canonical harness, e.g.
|
||||
``"claude-sdk"`` or ``"openai-agents"``. Lets the client
|
||||
render the active credential for the correct provider
|
||||
@@ -1977,6 +1980,15 @@ class UpdateSessionRequest(BaseModel):
|
||||
``"plan"`` enters Plan mode and ``"default"`` returns to Default
|
||||
mode for subsequent Codex turns. Only valid for sessions stamped
|
||||
with the codex-native wrapper label. Omitted leaves unchanged.
|
||||
:param permission_mode: Claude-native permission mode to switch a
|
||||
running session to, e.g. ``"auto"``. Only the modes Claude Code's
|
||||
shift+tab cycle can reach are accepted (``default``,
|
||||
``acceptEdits``, ``plan``, ``auto``) — ``dontAsk`` and
|
||||
``bypassPermissions`` are launch-only. Only valid for sessions
|
||||
stamped with the claude-native wrapper label. Unlike the other
|
||||
fields here the switch is applied by the live TUI, so a failure
|
||||
to reach the mode is surfaced as an error rather than persisted.
|
||||
Omitted leaves unchanged.
|
||||
:param cost_control_mode_override: Per-session cost-control
|
||||
switch: ``"on"`` activates the spec's configured cost-control
|
||||
mode, ``"off"`` disables cost control for this session.
|
||||
@@ -2034,6 +2046,7 @@ class UpdateSessionRequest(BaseModel):
|
||||
reasoning_effort: str | None = None
|
||||
model_override: str | None = None
|
||||
collaboration_mode: str | None = None
|
||||
permission_mode: str | None = None
|
||||
cost_control_mode_override: str | None = None
|
||||
subagent_routing_override: str | None = None
|
||||
external_session_id: str | None = None
|
||||
@@ -2725,24 +2738,24 @@ class SessionUsageEvent(_SSEEventBase):
|
||||
|
||||
class SessionModelEvent(_SSEEventBase):
|
||||
"""
|
||||
Active-model update from a terminal-backed integration.
|
||||
Active-model report from a terminal-backed integration.
|
||||
|
||||
Emitted after an ``external_model_change`` POST from the
|
||||
``omnigent claude`` transcript forwarder when the model is
|
||||
switched inside the Claude Code terminal (a ``/model`` command or
|
||||
the in-TUI picker). Lets the web model picker reflect a TUI-side
|
||||
switch without a reload.
|
||||
Emitted after an ``external_model_change`` POST from a native
|
||||
forwarder — the launch's own model report, or a switch made inside
|
||||
the pane (a ``/model`` command or the in-TUI picker). Every surface
|
||||
re-renders its model display from this.
|
||||
|
||||
:param type: Always ``"session.model"``.
|
||||
:param conversation_id: Session identifier, e.g. ``"conv_abc123"``.
|
||||
:param model: Tier alias the session is now on, e.g. ``"opus"`` —
|
||||
Claude Code's version-agnostic alias, matching the picker's
|
||||
vocabulary (not a pinned ``"claude-opus-4-8"`` id).
|
||||
:param model: The model the harness reports the session is on,
|
||||
VERBATIM in the harness's own spelling, e.g.
|
||||
``"claude-opus-4-8[1m]"`` or ``"gpt-5.6-luna"`` — never
|
||||
collapsed to a picker alias.
|
||||
|
||||
Category: **transient** (SSE-only). The server also writes
|
||||
``model_override`` on the conversation, so on reconnect clients
|
||||
restore the selection from the snapshot's ``model_override`` rather
|
||||
than from a replayed event.
|
||||
``reported_model`` on the conversation (served on the snapshot's
|
||||
``llm_model``), so on reconnect clients restore the display from
|
||||
the snapshot rather than from a replayed event.
|
||||
"""
|
||||
|
||||
type: Literal["session.model"]
|
||||
@@ -2821,6 +2834,29 @@ class SessionCollaborationModeEvent(_SSEEventBase):
|
||||
mode: str
|
||||
|
||||
|
||||
class SessionPermissionModeEvent(_SSEEventBase):
|
||||
"""
|
||||
Active permission-mode update from a claude-native session.
|
||||
|
||||
Emitted after the web UI switches the mode, and after the Claude forwarder
|
||||
observes a different mode in the pane footer — a shift+tab pressed inside
|
||||
the TUI, which Omnigent has no other way to see. Lets the composer's mode
|
||||
picker track the pane without a reload.
|
||||
|
||||
:param type: Always ``"session.permission_mode"``.
|
||||
:param conversation_id: Session identifier, e.g. ``"conv_abc123"``.
|
||||
:param permission_mode: The active mode, e.g. ``"auto"`` or ``"plan"``.
|
||||
|
||||
Category: **transient** (SSE-only). The server also writes
|
||||
``omnigent.claude_native.permission_mode`` on the conversation labels, so
|
||||
reconnecting clients restore the same state from the session snapshot.
|
||||
"""
|
||||
|
||||
type: Literal["session.permission_mode"]
|
||||
conversation_id: str
|
||||
permission_mode: str
|
||||
|
||||
|
||||
class SessionAgentChangedEvent(_SSEEventBase):
|
||||
"""
|
||||
Bound-agent change on a live session.
|
||||
@@ -4213,6 +4249,7 @@ ServerStreamEvent = Annotated[
|
||||
| SessionTitleEvent
|
||||
| SessionReasoningEffortEvent
|
||||
| SessionCollaborationModeEvent
|
||||
| SessionPermissionModeEvent
|
||||
| SessionAgentChangedEvent
|
||||
| SessionTodosEvent
|
||||
| SessionTerminalPendingEvent
|
||||
|
||||
@@ -776,6 +776,7 @@ class ConversationStore(ABC):
|
||||
_unset_harness_override: bool = False,
|
||||
terminal_launch_args: list[str] | None = None,
|
||||
archived: bool | None = None,
|
||||
reported_model: str | None = None,
|
||||
) -> Conversation | None:
|
||||
"""
|
||||
Update mutable fields on a conversation.
|
||||
@@ -785,7 +786,9 @@ class ConversationStore(ABC):
|
||||
and ``harness_override``,
|
||||
``None`` means "leave unchanged". To explicitly clear them
|
||||
back to ``None``, pass
|
||||
the matching ``_unset_*`` flag.
|
||||
the matching ``_unset_*`` flag. ``reported_model`` (the model
|
||||
the harness last reported, verbatim) has no ``_unset`` variant:
|
||||
reports only ever move forward.
|
||||
|
||||
:param conversation_id: Unique conversation identifier,
|
||||
e.g. ``"conv_abc123"``.
|
||||
|
||||
@@ -118,6 +118,7 @@ class _RowCountResult(Protocol):
|
||||
_SESSION_OVERRIDE_KEYS = (
|
||||
"reasoning_effort",
|
||||
"model_override",
|
||||
"reported_model",
|
||||
"cost_control_mode_override",
|
||||
"subagent_routing_override",
|
||||
"harness_override",
|
||||
@@ -209,6 +210,7 @@ def _to_conversation(
|
||||
session_usage=session_usage,
|
||||
reasoning_effort=overrides["reasoning_effort"],
|
||||
model_override=overrides["model_override"],
|
||||
reported_model=overrides["reported_model"],
|
||||
cost_control_mode_override=overrides["cost_control_mode_override"],
|
||||
subagent_routing_override=overrides["subagent_routing_override"],
|
||||
harness_override=overrides["harness_override"],
|
||||
@@ -2697,6 +2699,7 @@ class SqlAlchemyConversationStore(ConversationStore):
|
||||
_unset_harness_override: bool = False,
|
||||
terminal_launch_args: list[str] | None = None,
|
||||
archived: bool | None = None,
|
||||
reported_model: str | None = None,
|
||||
) -> Conversation | None:
|
||||
"""
|
||||
Update mutable fields on a conversation.
|
||||
@@ -2708,10 +2711,15 @@ class SqlAlchemyConversationStore(ConversationStore):
|
||||
e.g. ``"high"``. ``None`` leaves unchanged.
|
||||
:param _unset_reasoning_effort: When ``True``, clear
|
||||
``reasoning_effort`` to ``None``.
|
||||
:param model_override: Per-session LLM model override,
|
||||
e.g. ``"claude-opus-4-7"``. ``None`` leaves unchanged.
|
||||
:param model_override: Per-session LLM model override — the
|
||||
user's request, e.g. ``"claude-opus-4-7"``. ``None``
|
||||
leaves unchanged.
|
||||
:param _unset_model_override: When ``True``, clear
|
||||
``model_override`` to ``None``.
|
||||
:param reported_model: The model the harness last reported the
|
||||
session is actually on, verbatim, e.g.
|
||||
``"claude-opus-4-8[1m]"``. ``None`` leaves unchanged.
|
||||
No ``_unset`` variant — reports only ever move forward.
|
||||
:param cost_control_mode_override: Per-session cost-control
|
||||
switch, ``"on"`` or ``"off"``. ``None`` leaves unchanged.
|
||||
:param _unset_cost_control_mode_override: When ``True``, clear
|
||||
@@ -2764,6 +2772,9 @@ class SqlAlchemyConversationStore(ConversationStore):
|
||||
elif model_override is not None:
|
||||
overrides["model_override"] = model_override
|
||||
overrides_changed = True
|
||||
if reported_model is not None:
|
||||
overrides["reported_model"] = reported_model
|
||||
overrides_changed = True
|
||||
if _unset_cost_control_mode_override:
|
||||
overrides["cost_control_mode_override"] = None
|
||||
overrides_changed = True
|
||||
|
||||
@@ -50,6 +50,7 @@ class ScheduledTaskStore(ABC):
|
||||
*,
|
||||
model_override: str | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
max_cost_usd: float | None = None,
|
||||
workspace: str | None = None,
|
||||
host_id: str | None = None,
|
||||
state: str = "active",
|
||||
@@ -68,6 +69,7 @@ class ScheduledTaskStore(ABC):
|
||||
:param timezone: IANA timezone the trigger is evaluated in.
|
||||
:param model_override: Optional LLM model override.
|
||||
:param reasoning_effort: Optional reasoning-effort hint.
|
||||
:param max_cost_usd: Optional per-firing cost budget in USD.
|
||||
:param workspace: Runner start path (source repo / working dir).
|
||||
:param host_id: The connected host to pin the run to.
|
||||
:param state: Lifecycle state — ``active``/``paused``/``deleted``.
|
||||
@@ -131,6 +133,7 @@ class ScheduledTaskStore(ABC):
|
||||
timezone: str | None = None,
|
||||
model_override: str | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
max_cost_usd: float | None = _UNSET,
|
||||
workspace: str | None = None,
|
||||
host_id: str | None = _UNSET,
|
||||
state: str | None = None,
|
||||
|
||||
@@ -53,6 +53,7 @@ def _to_entity(row: SqlScheduledTask) -> ScheduledTask:
|
||||
rrule=row.rrule,
|
||||
model_override=row.model_override,
|
||||
reasoning_effort=row.reasoning_effort,
|
||||
max_cost_usd=row.max_cost_usd,
|
||||
workspace=row.workspace,
|
||||
base_branch=row.base_branch,
|
||||
execution_target=decode_scheduled_task_execution_target(row.execution_target),
|
||||
@@ -127,6 +128,7 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
|
||||
*,
|
||||
model_override: str | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
max_cost_usd: float | None = None,
|
||||
workspace: str | None = None,
|
||||
host_id: str | None = None,
|
||||
state: str = "active",
|
||||
@@ -142,6 +144,7 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
|
||||
timezone=timezone,
|
||||
model_override=model_override,
|
||||
reasoning_effort=reasoning_effort,
|
||||
max_cost_usd=max_cost_usd,
|
||||
workspace=workspace,
|
||||
base_branch=None,
|
||||
execution_target=encode_scheduled_task_execution_target("connected_host"),
|
||||
@@ -246,6 +249,7 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
|
||||
timezone: str | None = None,
|
||||
model_override: str | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
max_cost_usd: float | None = _UNSET,
|
||||
workspace: str | None = None,
|
||||
host_id: str | None = _UNSET,
|
||||
state: str | None = None,
|
||||
@@ -254,11 +258,11 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
|
||||
) -> ScheduledTask | None:
|
||||
"""Update mutable fields.
|
||||
|
||||
``None`` leaves most fields unchanged. For ``host_id`` and
|
||||
``last_run_conversation_id``, the sentinel default means "not provided
|
||||
/ leave unchanged"; passing ``None`` explicitly sets the column to NULL.
|
||||
Passing ``rrule`` updates the recurring trigger; ``None``
|
||||
leaves it unchanged.
|
||||
``None`` leaves most fields unchanged. For ``host_id``,
|
||||
``max_cost_usd``, and ``last_run_conversation_id``, the sentinel
|
||||
default means "not provided / leave unchanged"; passing ``None``
|
||||
explicitly sets the column to NULL. Passing ``rrule`` updates the
|
||||
recurring trigger; ``None`` leaves it unchanged.
|
||||
"""
|
||||
with self._session("update_task") as session:
|
||||
row = session.get(SqlScheduledTask, (current_workspace_id(), scheduled_task_id))
|
||||
@@ -283,6 +287,9 @@ class SqlAlchemyScheduledTaskStore(ScheduledTaskStore):
|
||||
if reasoning_effort is not None and row.reasoning_effort != reasoning_effort:
|
||||
row.reasoning_effort = reasoning_effort
|
||||
changed = True
|
||||
if max_cost_usd is not _UNSET and row.max_cost_usd != max_cost_usd:
|
||||
row.max_cost_usd = max_cost_usd
|
||||
changed = True
|
||||
if workspace is not None and row.workspace != workspace:
|
||||
row.workspace = workspace
|
||||
changed = True
|
||||
|
||||
@@ -28,10 +28,11 @@ Design notes learned from the protocol (see ``control_bridge`` spike):
|
||||
the client exits; the hex channel is byte-exact for ESC sequences, control
|
||||
chars, and UTF-8 multibyte alike.
|
||||
|
||||
The browser-facing wire protocol is identical to the PTY bridge (binary frames
|
||||
out = raw pane bytes; text frames in = JSON ``{"type":"resize",...}``; binary
|
||||
frames in = input bytes), so the two transports are interchangeable behind the
|
||||
same ``/attach`` WebSocket and a client cannot tell which one served it.
|
||||
The browser-facing terminal stream matches the PTY bridge (binary frames out =
|
||||
raw pane bytes; text frames in = JSON ``{"type":"resize",...}``; binary frames
|
||||
in = input bytes). Control mode additionally sends a typed text JSON frame when
|
||||
tmux reports a copied paste buffer, because its outer-client OSC 52 is not part
|
||||
of ``%output``. Both remain interchangeable behind the same ``/attach`` URL.
|
||||
|
||||
Known limitation vs the PTY bridge: tmux's own overlays (``display-popup``,
|
||||
copy-mode, status line) are NOT delivered to a control-mode client, so the
|
||||
@@ -47,6 +48,7 @@ attach transport, so they behave identically under either bridge.
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import contextlib
|
||||
import json
|
||||
import logging
|
||||
@@ -117,6 +119,21 @@ _CONTROL_STDOUT_BUFFER_LIMIT: Final[int] = 16 * 1024 * 1024
|
||||
# stuck-slow client can't hang the close; a normal drain completes well within.
|
||||
_FORWARD_DRAIN_TIMEOUT_S: Final[float] = 5.0
|
||||
|
||||
# tmux emits this control notification after copy-mode stores a selection in a
|
||||
# paste buffer. Only default-style, shell-safe names are accepted; copy-mode's
|
||||
# generated names (for example ``buffer0``) are covered without letting an
|
||||
# untrusted protocol line select an arbitrary command target.
|
||||
_CLIPBOARD_BUFFER_CHANGED_PREFIX: Final = b"%paste-buffer-changed "
|
||||
_CLIPBOARD_BUFFER_NAME_RE: Final = re.compile(rb"[A-Za-z0-9_.:-]{1,128}\Z")
|
||||
# Browser clipboard writes should stay text-sized. Bound the raw buffer before
|
||||
# base64/JSON expansion so a huge tmux buffer cannot become a websocket DoS.
|
||||
_CLIPBOARD_MAX_BYTES: Final[int] = 1024 * 1024
|
||||
_CLIPBOARD_READ_TIMEOUT_S: Final[float] = 2.0
|
||||
# A copy-mode commit follows the initiating key or mouse release immediately.
|
||||
# Correlating the notification with this client's recent input prevents one
|
||||
# attached browser from overwriting every other viewer's local clipboard.
|
||||
_CLIPBOARD_RECENT_INPUT_WINDOW_S: Final[float] = 5.0
|
||||
|
||||
|
||||
def unescape_control_output(value: bytes) -> bytes:
|
||||
"""Un-escape a ``%output`` value back to raw pane bytes.
|
||||
@@ -133,6 +150,79 @@ def unescape_control_output(value: bytes) -> bytes:
|
||||
return _OCTAL_ESCAPE_RE.sub(lambda m: bytes([int(m.group(1), 8)]), value)
|
||||
|
||||
|
||||
async def _read_tmux_buffer(
|
||||
tmux: str,
|
||||
socket_path: str,
|
||||
buffer_name: str,
|
||||
) -> bytes | None:
|
||||
"""Read one named tmux buffer exactly, rejecting failures and oversized data.
|
||||
|
||||
``save-buffer ... -`` writes the raw bytes without ``show-buffer``'s display
|
||||
formatting. ``readexactly(limit + 1)`` distinguishes an in-range buffer
|
||||
(EOF with a partial result) from an oversized one without first buffering
|
||||
an unbounded subprocess result in Python.
|
||||
|
||||
:param tmux: Absolute tmux executable path.
|
||||
:param socket_path: Private tmux server socket.
|
||||
:param buffer_name: Validated tmux buffer name, e.g. ``"buffer0"``.
|
||||
:returns: Raw buffer bytes, or ``None`` when unavailable/oversized.
|
||||
"""
|
||||
try:
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
tmux,
|
||||
"-S",
|
||||
socket_path,
|
||||
"save-buffer",
|
||||
"-b",
|
||||
buffer_name,
|
||||
"-",
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.DEVNULL,
|
||||
)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
assert proc.stdout is not None
|
||||
try:
|
||||
try:
|
||||
data = await asyncio.wait_for(
|
||||
proc.stdout.readexactly(_CLIPBOARD_MAX_BYTES + 1),
|
||||
timeout=_CLIPBOARD_READ_TIMEOUT_S,
|
||||
)
|
||||
oversized = True
|
||||
except asyncio.IncompleteReadError as exc:
|
||||
data = exc.partial
|
||||
oversized = False
|
||||
if oversized:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
await asyncio.wait_for(proc.wait(), timeout=_CLIPBOARD_READ_TIMEOUT_S)
|
||||
except asyncio.CancelledError:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await asyncio.shield(proc.wait())
|
||||
raise
|
||||
except (asyncio.TimeoutError, OSError):
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
proc.kill()
|
||||
with contextlib.suppress(Exception):
|
||||
await proc.wait()
|
||||
return None
|
||||
if oversized or proc.returncode != 0:
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _clipboard_buffer_name(line: bytes) -> str | None:
|
||||
"""Extract a safe buffer name from a tmux clipboard notification."""
|
||||
if not line.startswith(_CLIPBOARD_BUFFER_CHANGED_PREFIX):
|
||||
return None
|
||||
raw_name = line[len(_CLIPBOARD_BUFFER_CHANGED_PREFIX) :]
|
||||
if _CLIPBOARD_BUFFER_NAME_RE.fullmatch(raw_name) is None:
|
||||
return None
|
||||
return raw_name.decode("ascii")
|
||||
|
||||
|
||||
def _hex_send_keys_commands(target: str, data: bytes) -> list[bytes]:
|
||||
"""Build ``send-keys -H`` control-mode command line(s) for raw input bytes.
|
||||
|
||||
@@ -404,7 +494,8 @@ async def bridge_tmux_control_to_websocket(
|
||||
|
||||
Drop-in alternative to
|
||||
:func:`omnigent.terminals.ws_bridge.bridge_tmux_pty_to_websocket` with the
|
||||
same signature and browser wire protocol. Caller must have called
|
||||
same signature and terminal byte stream. Control mode additionally emits
|
||||
server-to-browser clipboard JSON frames. Caller must have called
|
||||
``websocket.accept()``. On exit (any branch) the control client is torn
|
||||
down and the websocket closed best-effort with the shared 4404/4405 codes.
|
||||
|
||||
@@ -476,9 +567,16 @@ async def bridge_tmux_control_to_websocket(
|
||||
# one bounded ``send_bytes``, so when the browser send lags tmux's firehose
|
||||
# a backlog of tiny per-line payloads collapses into a few large frames.
|
||||
output_chunks: asyncio.Queue[bytes | None] = asyncio.Queue()
|
||||
# Clipboard notifications are handled outside the raw output hot path: each
|
||||
# item names the immutable tmux buffer created by copy-mode; None is EOF.
|
||||
clipboard_buffers: asyncio.Queue[str | None] = asyncio.Queue()
|
||||
# Terminal bytes and clipboard JSON have separate producer tasks but one
|
||||
# websocket. Serialize sends so ASGI never sees concurrent send calls.
|
||||
ws_send_lock = asyncio.Lock()
|
||||
# Monotonic stamp of the last forwarded browser input; the forwarder reads
|
||||
# it to shrink the frame cap right after a keystroke (keeps the echo on
|
||||
# xterm's synchronous paint path — see the PTY bridge).
|
||||
# xterm's synchronous paint path — see the PTY bridge) and clipboard
|
||||
# forwarding uses it to identify which attached client initiated a copy.
|
||||
last_client_input_at: float | None = None
|
||||
|
||||
def _current_ws_coalesce_limit() -> int:
|
||||
@@ -512,6 +610,15 @@ async def bridge_tmux_control_to_websocket(
|
||||
if len(parts) == 3:
|
||||
output_chunks.put_nowait(unescape_control_output(parts[2]))
|
||||
return True
|
||||
buffer_name = _clipboard_buffer_name(line)
|
||||
if buffer_name is not None:
|
||||
if (
|
||||
not read_only
|
||||
and last_client_input_at is not None
|
||||
and _monotonic() - last_client_input_at <= _CLIPBOARD_RECENT_INPUT_WINDOW_S
|
||||
):
|
||||
clipboard_buffers.put_nowait(buffer_name)
|
||||
return True
|
||||
if line.startswith(b"%exit"):
|
||||
return False
|
||||
if line.startswith(b"%window-close"):
|
||||
@@ -549,9 +656,49 @@ async def bridge_tmux_control_to_websocket(
|
||||
return
|
||||
finally:
|
||||
output_chunks.put_nowait(None)
|
||||
clipboard_buffers.put_nowait(None)
|
||||
if reader_done is not None:
|
||||
reader_done.set()
|
||||
|
||||
async def _forward_clipboard_updates() -> None:
|
||||
"""Read copied tmux buffers and send bounded clipboard control frames."""
|
||||
while True:
|
||||
buffer_name = await clipboard_buffers.get()
|
||||
if buffer_name is None:
|
||||
return
|
||||
|
||||
# When several copies arrive before the subprocess starts, only the
|
||||
# newest clipboard value matters. Preserve an EOF sentinel so the
|
||||
# task exits after forwarding that final value.
|
||||
eof_seen = False
|
||||
while True:
|
||||
try:
|
||||
next_name = clipboard_buffers.get_nowait()
|
||||
except asyncio.QueueEmpty:
|
||||
break
|
||||
if next_name is None:
|
||||
eof_seen = True
|
||||
break
|
||||
buffer_name = next_name
|
||||
|
||||
data = await _read_tmux_buffer(tmux, socket_path, buffer_name)
|
||||
if data is not None:
|
||||
message = json.dumps(
|
||||
{
|
||||
"type": "clipboard-write",
|
||||
"encoding": "base64",
|
||||
"data": base64.b64encode(data).decode("ascii"),
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
try:
|
||||
async with ws_send_lock:
|
||||
await websocket.send_text(message)
|
||||
except (RuntimeError, WebSocketDisconnect):
|
||||
return
|
||||
if eof_seen:
|
||||
return
|
||||
|
||||
async def _ws_to_control() -> None:
|
||||
"""Read browser frames; resize via refresh-client -C, input via -H hex."""
|
||||
nonlocal last_client_input_at
|
||||
@@ -600,10 +747,16 @@ async def bridge_tmux_control_to_websocket(
|
||||
read_task = asyncio.create_task(_read_control(), name="tmux-control-read")
|
||||
forward_task = asyncio.create_task(
|
||||
_forward_pty_to_ws(
|
||||
websocket, output_chunks, max_coalesce_bytes=_current_ws_coalesce_limit
|
||||
websocket,
|
||||
output_chunks,
|
||||
max_coalesce_bytes=_current_ws_coalesce_limit,
|
||||
send_lock=ws_send_lock,
|
||||
),
|
||||
name="tmux-control-forward",
|
||||
)
|
||||
clipboard_task = asyncio.create_task(
|
||||
_forward_clipboard_updates(), name="tmux-control-clipboard"
|
||||
)
|
||||
if forward_done is not None:
|
||||
forward_task.add_done_callback(lambda _task: forward_done.set())
|
||||
ws_task = asyncio.create_task(_ws_to_control(), name="tmux-ws-to-control")
|
||||
@@ -612,6 +765,9 @@ async def bridge_tmux_control_to_websocket(
|
||||
# finishing is downstream (it drains, then sees the EOF sentinel).
|
||||
control_ended_first = False
|
||||
try:
|
||||
# The clipboard task is intentionally not a FIRST_COMPLETED trigger: it
|
||||
# may finish after the reader's EOF sentinel, but the reader itself is
|
||||
# the authoritative control-side completion signal.
|
||||
done, pending = await asyncio.wait(
|
||||
{read_task, forward_task, ws_task}, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
@@ -636,13 +792,18 @@ async def bridge_tmux_control_to_websocket(
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(forward_task), timeout=_FORWARD_DRAIN_TIMEOUT_S
|
||||
)
|
||||
for task in pending:
|
||||
if control_ended_first and not clipboard_task.done():
|
||||
with contextlib.suppress(Exception):
|
||||
await asyncio.wait_for(
|
||||
asyncio.shield(clipboard_task), timeout=_FORWARD_DRAIN_TIMEOUT_S
|
||||
)
|
||||
for task in {*pending, clipboard_task}:
|
||||
if task.done():
|
||||
continue
|
||||
task.cancel()
|
||||
with contextlib.suppress(asyncio.CancelledError):
|
||||
await task
|
||||
for task in {read_task, forward_task, ws_task}:
|
||||
for task in {read_task, forward_task, clipboard_task, ws_task}:
|
||||
if task.done() and not task.cancelled():
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
|
||||
@@ -16,7 +16,9 @@ Used by:
|
||||
|
||||
Wire protocol (same as the original server route):
|
||||
|
||||
- **Server → client**: every PTY read becomes a *binary* WS frame.
|
||||
- **Server → client**: every PTY read becomes a *binary* WS frame. Production
|
||||
attaches first send a text OSC 52 capability frame so the browser can reject
|
||||
clipboard escapes when tmux passthrough weakens the normal trust boundary.
|
||||
- **Client → server**:
|
||||
- **Text frames** are JSON control messages. Currently only
|
||||
``{"type": "resize", "cols": N, "rows": M}`` (applied via
|
||||
@@ -417,6 +419,7 @@ async def _forward_pty_to_ws(
|
||||
pty_chunks: asyncio.Queue[bytes | None],
|
||||
*,
|
||||
max_coalesce_bytes: int | Callable[[], int] = _WS_COALESCE_MAX_BYTES,
|
||||
send_lock: asyncio.Lock | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Forward queued PTY output to *websocket*, coalescing ready chunks.
|
||||
@@ -440,6 +443,9 @@ async def _forward_pty_to_ws(
|
||||
:data:`_WS_COALESCE_MAX_BYTES`; ``bridge_tmux_pty_to_websocket``
|
||||
supplies a callable so recently-typed redraws can use the smaller
|
||||
interactive cap while normal output keeps the larger flood cap.
|
||||
:param send_lock: Optional lock shared with another server-to-browser
|
||||
sender. Control mode uses it to serialize terminal bytes with clipboard
|
||||
control frames; PTY mode has only this sender and leaves it unset.
|
||||
:returns: None on EOF or websocket disconnect.
|
||||
"""
|
||||
pending = bytearray()
|
||||
@@ -468,7 +474,11 @@ async def _forward_pty_to_ws(
|
||||
frame = bytes(pending[:limit])
|
||||
del pending[:limit]
|
||||
try:
|
||||
await websocket.send_bytes(frame)
|
||||
if send_lock is None:
|
||||
await websocket.send_bytes(frame)
|
||||
else:
|
||||
async with send_lock:
|
||||
await websocket.send_bytes(frame)
|
||||
except (RuntimeError, WebSocketDisconnect):
|
||||
return
|
||||
if eof_seen:
|
||||
@@ -517,6 +527,7 @@ async def bridge_tmux_pty_to_websocket(
|
||||
tmux_target: str,
|
||||
read_only: bool,
|
||||
on_client_interaction: Callable[[], None] | None = None,
|
||||
allow_osc52_clipboard: bool | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Bridge a tmux attach PTY to an already-accepted *websocket*.
|
||||
@@ -545,12 +556,31 @@ async def bridge_tmux_pty_to_websocket(
|
||||
mis-reading them as agent activity. ``None`` (e.g. the
|
||||
server-direct attach path, which is out-of-process from the
|
||||
watcher) disables that attribution.
|
||||
:param allow_osc52_clipboard: Whether the browser may honor OSC 52 from this
|
||||
PTY. Production passes ``False`` when tmux passthrough is enabled, since
|
||||
pane output could otherwise bypass ``set-clipboard external``. ``None``
|
||||
omits the capability frame for low-level test compatibility.
|
||||
"""
|
||||
# Attaching is itself a client interaction: tmux resizes the window to
|
||||
# the new client, which reflows the pane. Stamp it before the bridge
|
||||
# starts so that reflow is discounted.
|
||||
if on_client_interaction is not None:
|
||||
on_client_interaction()
|
||||
|
||||
if allow_osc52_clipboard is not None:
|
||||
try:
|
||||
await websocket.send_text(
|
||||
json.dumps(
|
||||
{
|
||||
"type": "osc52-clipboard-capability",
|
||||
"enabled": allow_osc52_clipboard and not read_only,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
)
|
||||
)
|
||||
except (RuntimeError, WebSocketDisconnect):
|
||||
return
|
||||
|
||||
argv = ["tmux", "-S", socket_path, "attach"]
|
||||
if read_only:
|
||||
argv.append("-r")
|
||||
|
||||
@@ -93,6 +93,14 @@ class SysScheduledTaskCreateTool(Tool):
|
||||
"type": "string",
|
||||
"description": "Optional per-run reasoning-effort hint, e.g. 'high'.",
|
||||
},
|
||||
"max_cost_usd": {
|
||||
"type": "number",
|
||||
"description": (
|
||||
"Optional per-firing cost budget in USD. When set, each "
|
||||
"fired session is capped at this spend — all models are "
|
||||
"blocked once the limit is reached. Omit for no cap."
|
||||
),
|
||||
},
|
||||
"workspace": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
@@ -188,6 +196,12 @@ class SysScheduledTaskUpdateTool(Tool):
|
||||
"type": "string",
|
||||
"description": "New reasoning-effort hint.",
|
||||
},
|
||||
"max_cost_usd": {
|
||||
"type": "number",
|
||||
"description": (
|
||||
"New per-firing cost budget in USD. Null clears the cap."
|
||||
),
|
||||
},
|
||||
"workspace": {
|
||||
"type": "string",
|
||||
"description": "New existing absolute runner start path.",
|
||||
|
||||
+60
-7
@@ -3695,6 +3695,7 @@
|
||||
"session.mcp_startup": "#/components/schemas/SessionMcpStartupEvent",
|
||||
"session.model": "#/components/schemas/SessionModelEvent",
|
||||
"session.model_options": "#/components/schemas/SessionModelOptionsEvent",
|
||||
"session.permission_mode": "#/components/schemas/SessionPermissionModeEvent",
|
||||
"session.presence": "#/components/schemas/SessionPresenceEvent",
|
||||
"session.reasoning_effort": "#/components/schemas/SessionReasoningEffortEvent",
|
||||
"session.resource.created": "#/components/schemas/SessionResourceCreatedEvent",
|
||||
@@ -3734,6 +3735,9 @@
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionCollaborationModeEvent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionPermissionModeEvent"
|
||||
},
|
||||
{
|
||||
"$ref": "#/components/schemas/SessionAgentChangedEvent"
|
||||
},
|
||||
@@ -4775,7 +4779,7 @@
|
||||
"type": "object"
|
||||
},
|
||||
"SessionModelEvent": {
|
||||
"description": "Active-model update from a terminal-backed integration.\n\nEmitted after an `external_model_change` POST from the\n`omnigent claude` transcript forwarder when the model is\nswitched inside the Claude Code terminal (a `/model` command or\nthe in-TUI picker). Lets the web model picker reflect a TUI-side\nswitch without a reload.",
|
||||
"description": "Active-model report from a terminal-backed integration.\n\nEmitted after an `external_model_change` POST from a native\nforwarder \u2014 the launch's own model report, or a switch made inside\nthe pane (a `/model` command or the in-TUI picker). Every surface\nre-renders its model display from this.",
|
||||
"properties": {
|
||||
"conversation_id": {
|
||||
"description": "Session identifier, e.g. `\"conv_abc123\"`.",
|
||||
@@ -4783,7 +4787,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"model": {
|
||||
"description": "Tier alias the session is now on, e.g. `\"opus\"` \u2014 Claude Code's version-agnostic alias, matching the picker's vocabulary (not a pinned `\"claude-opus-4-8\"` id). Category: **transient** (SSE-only). The server also writes `model_override` on the conversation, so on reconnect clients restore the selection from the snapshot's `model_override` rather than from a replayed event.",
|
||||
"description": "The model the harness reports the session is on, VERBATIM in the harness's own spelling, e.g. `\"claude-opus-4-8[1m]\"` or `\"gpt-5.6-luna\"` \u2014 never collapsed to a picker alias. Category: **transient** (SSE-only). The server also writes `reported_model` on the conversation (served on the snapshot's `llm_model`), so on reconnect clients restore the display from the snapshot rather than from a replayed event.",
|
||||
"title": "Model",
|
||||
"type": "string"
|
||||
},
|
||||
@@ -4848,6 +4852,46 @@
|
||||
"title": "SessionModelOptionsEvent",
|
||||
"type": "object"
|
||||
},
|
||||
"SessionPermissionModeEvent": {
|
||||
"description": "Active permission-mode update from a claude-native session.\n\nEmitted after the web UI switches the mode, and after the Claude forwarder\nobserves a different mode in the pane footer \u2014 a shift+tab pressed inside\nthe TUI, which Omnigent has no other way to see. Lets the composer's mode\npicker track the pane without a reload.",
|
||||
"properties": {
|
||||
"conversation_id": {
|
||||
"description": "Session identifier, e.g. `\"conv_abc123\"`.",
|
||||
"title": "Conversation Id",
|
||||
"type": "string"
|
||||
},
|
||||
"permission_mode": {
|
||||
"description": "The active mode, e.g. `\"auto\"` or `\"plan\"`. Category: **transient** (SSE-only). The server also writes `omnigent.claude_native.permission_mode` on the conversation labels, so reconnecting clients restore the same state from the session snapshot.",
|
||||
"title": "Permission Mode",
|
||||
"type": "string"
|
||||
},
|
||||
"sequence_number": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"title": "Sequence Number"
|
||||
},
|
||||
"type": {
|
||||
"const": "session.permission_mode",
|
||||
"description": "Always `\"session.permission_mode\"`.",
|
||||
"title": "Type",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type",
|
||||
"conversation_id",
|
||||
"permission_mode"
|
||||
],
|
||||
"title": "SessionPermissionModeEvent",
|
||||
"type": "object"
|
||||
},
|
||||
"SessionPresenceEvent": {
|
||||
"description": "The session's viewer list changed \u2014 full state, not a delta.\n\nEmitted on `GET /v1/sessions/{id}/stream` whenever a user\njoins, leaves (after the server-side grace window absorbs\nreconnect churn), or flips their idle aggregate, and once to\neach newly-connected stream as a snapshot-on-connect. Every\nevent carries the COMPLETE viewer list so clients replace their\nstate wholesale \u2014 missed events self-heal on the next event or\nreconnect. Viewers are scoped to the session *tree* (the root\nconversation and every sub-agent conversation under it), so a\nuser on a sub-agent page and a user on the root page appear in\neach other's lists. See `omnigent/server/presence.py` and\n`designs/UI/PRESENCE.md`.",
|
||||
"properties": {
|
||||
@@ -5376,7 +5420,7 @@
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "The LLM model identifier from the bound agent's spec, e.g. `\"anthropic/claude-sonnet-4-6\"`. `None` when the agent has no explicit `llm:` block or the agent cannot be looked up.",
|
||||
"description": "The model this session is actually on. When the harness has reported one (`reported_model`, written by `external_model_change`), that verbatim value serves here and is the only model value clients display; otherwise the bound agent spec's model, e.g. `\"anthropic/claude-sonnet-4-6\"`. `None` when neither exists.",
|
||||
"title": "Llm Model"
|
||||
},
|
||||
"mcp_startup": {
|
||||
@@ -7005,6 +7049,18 @@
|
||||
"description": "Per-session LLM model override, e.g. `\"claude-opus-4-7\"`. The value is forwarded as-is to the executor at turn start; the server does not enumerate valid models. Clear aliases such as `\"default\"`, `\"off\"`, or `\"reset\"` remove the override (matching the REPL's `/model` semantics). `None` leaves unchanged.",
|
||||
"title": "Model Override"
|
||||
},
|
||||
"permission_mode": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Claude-native permission mode to switch a running session to, e.g. `\"auto\"`. Only the modes Claude Code's shift+tab cycle can reach are accepted (`default`, `acceptEdits`, `plan`, `auto`) \u2014 `dontAsk` and `bypassPermissions` are launch-only. Only valid for sessions stamped with the claude-native wrapper label. Unlike the other fields here the switch is applied by the live TUI, so a failure to reach the mode is surfaced as an error rather than persisted. Omitted leaves unchanged.",
|
||||
"title": "Permission Mode"
|
||||
},
|
||||
"project_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -8207,10 +8263,7 @@
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"additionalProperties": {
|
||||
"items": {
|
||||
"additionalProperties": true,
|
||||
"type": "object"
|
||||
},
|
||||
"items": {},
|
||||
"type": "array"
|
||||
},
|
||||
"title": "Response Get Host Model Options V1 Hosts Host Id Harnesses Harness Model Options Get",
|
||||
|
||||
+1
-6
@@ -246,12 +246,6 @@ cursor = ["cursor-sdk>=0.1.7"]
|
||||
# _reflect). The tools import the client lazily, so only users who enable a
|
||||
# Hindsight memory tool need this extra.
|
||||
hindsight = ["hindsight-client>=0.4.0"]
|
||||
# Backwards-compatibility alias: this extra was renamed to `hindsight` (see
|
||||
# above) in #2605. Keep `memory` pulling the same client so existing
|
||||
# `omnigent[memory]` / `--extra memory` invocations keep working.
|
||||
# TODO(0.70): remove this `memory` alias extra — it exists only to keep the
|
||||
# pre-rename install command working during the deprecation window.
|
||||
memory = ["hindsight-client>=0.4.0"]
|
||||
# Nimble research runs (the `nimble_research` builtin). The tool imports the
|
||||
# nimble-python client lazily, so only users who enable nimble_research need
|
||||
# this extra. nimble_extract talks raw httpx and needs no extra.
|
||||
@@ -489,6 +483,7 @@ markers = [
|
||||
"mock_only: tests/integration test that only works in mock-LLM mode (no --llm-api-key). Skipped by tests/integration/conftest.py when a real --llm-api-key is supplied (the real-LLM Integration jobs). Use for tests whose mock LLM is scripted with a fixed tool-call sequence — a real LLM cannot reproduce the scripted markers.",
|
||||
"visual: UI diff visual-regression snapshot (pytest-playwright-visual-snapshot). Runs only in the pinned-runner gate (.github/workflows/ui-snapshot.yml); the main e2e_ui suite excludes it via -m 'not visual' since it runs on the unpinned ubuntu-latest.",
|
||||
"smart_routing: end-to-end Smart Routing CUJ (tests/e2e/routing). Opt-in via OMNIGENT_E2E_SMART_ROUTING=1: launches a real omnigent host plus real claude/codex TUIs against a gateway. The routing service itself is an in-test mock, so no AI-Gateway task_v1 deployment is needed.",
|
||||
"live_model_flows: end-to-end model-flow CUJs (tests/e2e/omnigent/test_model_flows_live.py). Opt-in via OMNIGENT_E2E_MODEL_FLOWS=1: boots a real omnigent server + host from a checkout (OMNIGENT_E2E_MODEL_FLOWS_REPO overrides which one, enabling the red-on-main matrix) with real claude/codex logins, drives the real SPA in a browser, and asserts pane truth via tmux. Never runs in CI by accident; each landing PR runs it locally per docs/model-flows test plan.",
|
||||
"posix_only: test relies on POSIX-only behaviour (fork, PTY, tmux, Unix sockets, signals); auto-skipped on Windows by tests/conftest.py.",
|
||||
"windows_only: test relies on Windows-only behaviour (Job Objects, cmd.exe); auto-skipped on POSIX by tests/conftest.py.",
|
||||
]
|
||||
|
||||
+93
-1
@@ -120,6 +120,39 @@ def _restore_logging_state() -> Iterator[None]:
|
||||
logger.propagate = propagate
|
||||
|
||||
|
||||
def test_global_profiling_writes_summary_and_timestamped_stats(tmp_path: Path) -> None:
|
||||
"""The real entry point profiles any command selected after the root flag."""
|
||||
repo_root = Path(__file__).resolve().parents[2]
|
||||
pythonpath = os.pathsep.join(
|
||||
part for part in (str(repo_root), os.environ.get("PYTHONPATH")) if part
|
||||
)
|
||||
result = subprocess.run(
|
||||
[sys.executable, "-m", "omnigent", "--profiling", "version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
cwd=tmp_path,
|
||||
env={
|
||||
**os.environ,
|
||||
"PYTHONPATH": pythonpath,
|
||||
"OMNIGENT_DATA_DIR": str(tmp_path / "data"),
|
||||
},
|
||||
)
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "CLI profile:" in result.stderr
|
||||
assert "Top Omnigent call paths" in result.stderr
|
||||
assert "Top Omnigent functions by self time" in result.stderr
|
||||
self_time_table = result.stderr.split("Top Omnigent functions by self time", 1)[1]
|
||||
assert "<built-in method" not in self_time_table
|
||||
assert "Full profile data:" in result.stderr
|
||||
[profile_path] = list((tmp_path / "data" / "profiles").glob("omnigent-cli-*.prof"))
|
||||
|
||||
import pstats
|
||||
|
||||
assert pstats.Stats(str(profile_path)).total_calls > 0
|
||||
|
||||
|
||||
def test_python_module_entrypoint_uses_unified_click_cli() -> None:
|
||||
"""
|
||||
``python -m omnigent`` must dispatch through the same click CLI
|
||||
@@ -191,6 +224,7 @@ def test_wrapper_guard_bypass_reaches_cli_end_to_end() -> None:
|
||||
(["run", "tests/resources/examples/hello_world.yaml"], False),
|
||||
(["attach", "tests/resources/examples/hello_world.yaml"], False),
|
||||
(["--help"], False),
|
||||
(["--profiling", "--help"], False),
|
||||
(["what does this repo do?"], True),
|
||||
(["--system-prompt", "You are terse"], True),
|
||||
# A single command-shaped word is an unknown subcommand, not
|
||||
@@ -1042,10 +1076,11 @@ def test_kiro_command_is_registered_in_click_help() -> None:
|
||||
|
||||
|
||||
def test_help_groups_harnesses_and_other_commands() -> None:
|
||||
"""``--help`` lists a ``Harnesses`` section separate from ``Commands``."""
|
||||
"""``--help`` lists global options and separates command categories."""
|
||||
result = CliRunner().invoke(cli, ["--help"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "--profiling" in result.output
|
||||
assert "Harnesses:" in result.output
|
||||
assert "Commands:" in result.output
|
||||
# A harness launcher lands under Harnesses; a management command
|
||||
@@ -3458,6 +3493,63 @@ def test_run_profile_sets_databricks_config_profile_env(
|
||||
assert seen["value"] == "my-sp"
|
||||
|
||||
|
||||
def test_bare_run_profile_shorthand_still_selects_databricks_profile(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The new root flag must not consume the historical bare-run spelling."""
|
||||
from omnigent.cli import main
|
||||
|
||||
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
||||
seen = _capture_profile_env_at_dispatch(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
sys,
|
||||
"argv",
|
||||
[
|
||||
"omnigent",
|
||||
"--profile",
|
||||
"my-sp",
|
||||
"--server",
|
||||
"https://example.com",
|
||||
"-p",
|
||||
"hi",
|
||||
],
|
||||
)
|
||||
|
||||
main()
|
||||
|
||||
assert seen["value"] == "my-sp"
|
||||
|
||||
|
||||
def test_global_profiling_coexists_with_run_databricks_profile(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Root profiling and ``run --profile NAME`` keep distinct semantics."""
|
||||
monkeypatch.setenv("OMNIGENT_DATA_DIR", str(tmp_path / "data"))
|
||||
monkeypatch.delenv("DATABRICKS_CONFIG_PROFILE", raising=False)
|
||||
seen = _capture_profile_env_at_dispatch(monkeypatch)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli,
|
||||
[
|
||||
"--profiling",
|
||||
"run",
|
||||
"--server",
|
||||
"https://example.com",
|
||||
"--profile",
|
||||
"my-sp",
|
||||
"-p",
|
||||
"hi",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert seen["value"] == "my-sp"
|
||||
assert "Top Omnigent call paths" in result.stderr
|
||||
profiles = tmp_path / "data" / "profiles"
|
||||
assert len(list(profiles.glob("omnigent-cli-*.prof"))) == 1
|
||||
|
||||
|
||||
def test_run_profile_wins_over_preset_env(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
|
||||
@@ -6,6 +6,7 @@ by ``omnigent login``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import time
|
||||
|
||||
import pytest
|
||||
@@ -526,3 +527,261 @@ def test_databricks_record_overwrites_jwt_record(token_dir) -> None:
|
||||
load_databricks_workspace_host("https://server.example.com")
|
||||
== "https://example.databricks.com"
|
||||
)
|
||||
|
||||
|
||||
# ── Login-issued refresh grants (client side) ─────────────────────
|
||||
|
||||
|
||||
def test_store_token_persists_refresh_material(token_dir) -> None:
|
||||
"""A refresh token stored at login survives the round trip."""
|
||||
import json
|
||||
|
||||
from omnigent.cli_auth import store_token
|
||||
|
||||
store_token(
|
||||
"http://localhost:6767",
|
||||
token="jwt",
|
||||
user_id="a@x",
|
||||
expires_at=time.time() + 3600,
|
||||
refresh_token="refresh-1",
|
||||
)
|
||||
data = json.loads((token_dir / "auth_tokens.json").read_text())
|
||||
assert data["http://localhost:6767"]["refresh_token"] == "refresh-1"
|
||||
|
||||
|
||||
def test_stored_token_status_classification(token_dir) -> None:
|
||||
"""absent / expired / ok are distinguished — the host uses this to say
|
||||
"your login expired" instead of dialing into a misleading 403."""
|
||||
from omnigent.cli_auth import store_token, stored_token_status
|
||||
|
||||
assert stored_token_status("http://localhost:6767") == "absent"
|
||||
store_token("http://localhost:6767", token="jwt", user_id="a@x", expires_at=time.time() - 10)
|
||||
assert stored_token_status("http://localhost:6767") == "expired"
|
||||
store_token("http://localhost:6767", token="jwt", user_id="a@x", expires_at=time.time() + 3600)
|
||||
assert stored_token_status("http://localhost:6767") == "ok"
|
||||
|
||||
|
||||
def test_refresh_stored_token_renews_and_rotates(token_dir, monkeypatch) -> None:
|
||||
"""An expired entry with refresh material renews via /oauth/token and
|
||||
persists the rotated pair."""
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from omnigent.cli_auth import load_token, refresh_stored_token, store_token
|
||||
|
||||
store_token(
|
||||
"http://localhost:6767",
|
||||
token="stale",
|
||||
user_id="a@x",
|
||||
expires_at=time.time() - 10,
|
||||
refresh_token="refresh-1",
|
||||
)
|
||||
assert load_token("http://localhost:6767") is None # expired
|
||||
|
||||
posted: dict[str, object] = {}
|
||||
|
||||
def _fake_post(url, *, data=None, timeout=None):
|
||||
posted["url"] = url
|
||||
posted["data"] = data
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"access_token": "fresh",
|
||||
"refresh_token": "refresh-2",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
},
|
||||
request=httpx.Request("POST", url),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(httpx, "post", _fake_post)
|
||||
assert refresh_stored_token("http://localhost:6767") == "fresh"
|
||||
assert posted["url"] == "http://localhost:6767/oauth/token"
|
||||
assert posted["data"] == {"grant_type": "refresh_token", "refresh_token": "refresh-1"}
|
||||
# Rotated pair persisted; the fresh token now loads normally.
|
||||
data = json.loads((token_dir / "auth_tokens.json").read_text())
|
||||
entry = data["http://localhost:6767"]
|
||||
assert entry["token"] == "fresh" and entry["refresh_token"] == "refresh-2"
|
||||
assert load_token("http://localhost:6767") == "fresh"
|
||||
|
||||
|
||||
def test_refresh_stored_token_no_material_is_none(token_dir) -> None:
|
||||
"""Nothing to refresh (no entry, or no refresh token) → None, no I/O."""
|
||||
from omnigent.cli_auth import refresh_stored_token, store_token
|
||||
|
||||
assert refresh_stored_token("http://localhost:6767") is None
|
||||
store_token("http://localhost:6767", token="jwt", user_id="a@x", expires_at=time.time() - 10)
|
||||
assert refresh_stored_token("http://localhost:6767") is None
|
||||
|
||||
|
||||
def test_refresh_stored_token_refused_leaves_entry(token_dir, monkeypatch) -> None:
|
||||
"""A server refusal (revoked / aged-out grant / old server 404) returns
|
||||
None and leaves the stored entry untouched."""
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from omnigent.cli_auth import refresh_stored_token, store_token
|
||||
|
||||
store_token(
|
||||
"http://localhost:6767",
|
||||
token="stale",
|
||||
user_id="a@x",
|
||||
expires_at=time.time() - 10,
|
||||
refresh_token="refresh-1",
|
||||
)
|
||||
|
||||
def _fake_post(url, *, data=None, timeout=None):
|
||||
return httpx.Response(
|
||||
400, json={"error": "invalid_grant"}, request=httpx.Request("POST", url)
|
||||
)
|
||||
|
||||
monkeypatch.setattr(httpx, "post", _fake_post)
|
||||
assert refresh_stored_token("http://localhost:6767") is None
|
||||
entry = json.loads((token_dir / "auth_tokens.json").read_text())["http://localhost:6767"]
|
||||
assert entry["refresh_token"] == "refresh-1"
|
||||
|
||||
|
||||
def test_refresh_stored_token_skips_when_already_fresh(token_dir, monkeypatch) -> None:
|
||||
"""A concurrent refresher already renewed → return the valid token
|
||||
without a network call (the lock-then-recheck path)."""
|
||||
import httpx
|
||||
|
||||
from omnigent.cli_auth import refresh_stored_token, store_token
|
||||
|
||||
store_token(
|
||||
"http://localhost:6767",
|
||||
token="already-fresh",
|
||||
user_id="a@x",
|
||||
expires_at=time.time() + 3600,
|
||||
refresh_token="refresh-1",
|
||||
)
|
||||
|
||||
def _boom(*args, **kwargs):
|
||||
raise AssertionError("no network call expected")
|
||||
|
||||
monkeypatch.setattr(httpx, "post", _boom)
|
||||
assert refresh_stored_token("http://localhost:6767") == "already-fresh"
|
||||
|
||||
|
||||
def test_refresh_no_material_does_not_touch_lock_file(token_dir, monkeypatch) -> None:
|
||||
"""With no refresh material, the refresh must not create a lock file.
|
||||
|
||||
Regression: creating the lock before checking raised OSError on a
|
||||
read-only state directory, which the runner's auth factory caught —
|
||||
skipping its Databricks SDK fallback and leaving valid credentials
|
||||
unused.
|
||||
"""
|
||||
from omnigent.cli_auth import refresh_stored_token, store_token
|
||||
|
||||
store_token("http://localhost:6767", token="jwt", user_id="a@x", expires_at=time.time() - 10)
|
||||
|
||||
def _boom(*_a, **_kw):
|
||||
raise AssertionError("lock file must not be created when nothing to refresh")
|
||||
|
||||
monkeypatch.setattr("builtins.open", _boom)
|
||||
assert refresh_stored_token("http://localhost:6767") is None
|
||||
assert not (token_dir / "auth_tokens.lock").exists()
|
||||
|
||||
|
||||
def test_refresh_survives_unwritable_state_dir(token_dir, monkeypatch) -> None:
|
||||
"""A lock/persist failure degrades to None instead of raising, so the
|
||||
caller can still fall back to its other credential sources."""
|
||||
import omnigent.cli_auth as ca
|
||||
|
||||
ca.store_token(
|
||||
"http://localhost:6767",
|
||||
token="stale",
|
||||
user_id="a@x",
|
||||
expires_at=time.time() - 10,
|
||||
refresh_token="refresh-1",
|
||||
)
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _unwritable():
|
||||
raise OSError("read-only file system")
|
||||
yield # pragma: no cover
|
||||
|
||||
monkeypatch.setattr(ca, "_token_file_lock", _unwritable)
|
||||
assert ca.refresh_stored_token("http://localhost:6767") is None
|
||||
|
||||
|
||||
def test_load_token_min_remaining_declines_near_expiry(token_dir) -> None:
|
||||
"""A token inside the renewal window reads as unusable so the caller
|
||||
refreshes instead of sending one that lapses mid-handshake."""
|
||||
from omnigent.cli_auth import REFRESH_MIN_REMAINING_SECONDS, load_token, store_token
|
||||
|
||||
store_token(
|
||||
"http://localhost:6767",
|
||||
token="jwt",
|
||||
user_id="a@x",
|
||||
expires_at=time.time() + (REFRESH_MIN_REMAINING_SECONDS / 2),
|
||||
)
|
||||
# Default (0) still accepts a not-yet-expired token — unchanged behaviour.
|
||||
assert load_token("http://localhost:6767") == "jwt"
|
||||
# The renewal-aware caller declines it.
|
||||
assert (
|
||||
load_token("http://localhost:6767", min_remaining_seconds=REFRESH_MIN_REMAINING_SECONDS)
|
||||
is None
|
||||
)
|
||||
|
||||
|
||||
def test_load_entry_tolerates_wrong_shaped_json(token_dir) -> None:
|
||||
"""A token file holding valid JSON of the wrong shape reads as empty
|
||||
rather than raising AttributeError into the caller."""
|
||||
from omnigent.cli_auth import load_token, stored_token_status
|
||||
|
||||
for bad in ("[]", "null", '"a string"'):
|
||||
(token_dir / "auth_tokens.json").write_text(bad)
|
||||
assert load_token("http://localhost:6767") is None
|
||||
assert stored_token_status("http://localhost:6767") == "absent"
|
||||
|
||||
|
||||
def test_refresh_rejects_unusable_response_fields(token_dir, monkeypatch) -> None:
|
||||
"""A 200 with null/non-string tokens or a non-finite expires_in must not
|
||||
clobber the working stored credential."""
|
||||
import json
|
||||
|
||||
import httpx
|
||||
|
||||
from omnigent.cli_auth import refresh_stored_token, store_token
|
||||
|
||||
def _seed():
|
||||
store_token(
|
||||
"http://localhost:6767",
|
||||
token="stale",
|
||||
user_id="a@x",
|
||||
expires_at=time.time() - 10,
|
||||
refresh_token="refresh-1",
|
||||
)
|
||||
|
||||
# null access_token → decline, stored pair untouched.
|
||||
_seed()
|
||||
monkeypatch.setattr(
|
||||
httpx,
|
||||
"post",
|
||||
lambda url, **_kw: httpx.Response(
|
||||
200,
|
||||
json={"access_token": None, "refresh_token": "r2"},
|
||||
request=httpx.Request("POST", url),
|
||||
),
|
||||
)
|
||||
assert refresh_stored_token("http://localhost:6767") is None
|
||||
entry = json.loads((token_dir / "auth_tokens.json").read_text())["http://localhost:6767"]
|
||||
assert entry["token"] == "stale" and entry["refresh_token"] == "refresh-1"
|
||||
|
||||
# Non-finite expires_in → falls back to a sane lifetime, not "never expires".
|
||||
_seed()
|
||||
monkeypatch.setattr(
|
||||
httpx,
|
||||
"post",
|
||||
lambda url, **_kw: httpx.Response(
|
||||
200,
|
||||
json={"access_token": "fresh", "refresh_token": "r2", "expires_in": "NaN"},
|
||||
request=httpx.Request("POST", url),
|
||||
),
|
||||
)
|
||||
assert refresh_stored_token("http://localhost:6767") == "fresh"
|
||||
entry = json.loads((token_dir / "auth_tokens.json").read_text())["http://localhost:6767"]
|
||||
assert entry["expires_at"] < time.time() + 4000
|
||||
|
||||
@@ -28,6 +28,8 @@ cli_group = cli_mod.cli
|
||||
|
||||
_APPS_URL = "https://myapp-1234.aws.databricksapps.com"
|
||||
_WORKSPACE = "https://example.databricks.com"
|
||||
# The login pins ``--profile`` to the workspace's first DNS label.
|
||||
_PROFILE = "example"
|
||||
_APPS_REDIRECT = f"{_WORKSPACE}/oidc/oauth2/v2.0/authorize?client_id=abc&response_type=code"
|
||||
_WORKSPACE_API_URL = f"{_WORKSPACE}/api/2.0/omnigent"
|
||||
|
||||
@@ -245,8 +247,11 @@ def test_login_runs_databricks_auth_login_when_no_cached_grant(
|
||||
) -> None:
|
||||
"""No cached host-keyed grant → ``databricks auth login --host <ws>`` runs.
|
||||
|
||||
The login is host-keyed (no ``--profile`` / profile name anywhere);
|
||||
after it succeeds the token resolves and the record is stored.
|
||||
The login pins ``--profile`` to the workspace's first DNS label so a
|
||||
second workspace doesn't clobber the shared ``DEFAULT`` profile;
|
||||
resolution stays host-matched (name-agnostic), so the profile name
|
||||
isn't consulted anywhere. After login the token resolves and the
|
||||
record is stored.
|
||||
"""
|
||||
from omnigent.cli_auth import load_databricks_workspace_host
|
||||
|
||||
@@ -266,10 +271,9 @@ def test_login_runs_databricks_auth_login_when_no_cached_grant(
|
||||
result = CliRunner().invoke(cli_group, ["login", _APPS_URL])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# Exactly one browser login, host-keyed, with no profile flag — a
|
||||
# `--profile` here would recreate the named-profile coupling this
|
||||
# flow exists to remove.
|
||||
assert login_calls == [f"auth login --host {_WORKSPACE}"]
|
||||
# Exactly one browser login, pinned to the per-workspace profile so
|
||||
# ``DEFAULT`` is left alone; ``?o=`` stays only on ``--host``.
|
||||
assert login_calls == [f"auth login --host {_WORKSPACE} --profile {_PROFILE}"]
|
||||
assert load_databricks_workspace_host(_APPS_URL) == _WORKSPACE
|
||||
|
||||
|
||||
@@ -354,7 +358,7 @@ def test_login_stale_cached_grant_triggers_fresh_login_and_retry(
|
||||
assert result.exit_code == 0, result.output
|
||||
# Exactly one forced re-login — a second rejection must fail loud,
|
||||
# not loop the browser flow.
|
||||
assert login_calls == [f"auth login --host {_WORKSPACE}"]
|
||||
assert login_calls == [f"auth login --host {_WORKSPACE} --profile {_PROFILE}"]
|
||||
# The retry verify presented the freshly minted token, not the stale one.
|
||||
assert fake.requests[-1]["authorization"] == "Bearer tok-fresh"
|
||||
assert load_databricks_workspace_host(_APPS_URL) == _WORKSPACE
|
||||
@@ -389,7 +393,7 @@ def test_foreign_subprocess_calls_stay_out_of_the_login_recorder(
|
||||
result = CliRunner().invoke(cli_group, ["login", _APPS_URL])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert login_calls == [f"auth login --host {_WORKSPACE}"]
|
||||
assert login_calls == [f"auth login --host {_WORKSPACE} --profile {_PROFILE}"]
|
||||
# Delegated to the real runner rather than stubbed out from under it.
|
||||
assert probe.returncode == 0
|
||||
assert b"git version" in probe.stdout
|
||||
@@ -477,8 +481,9 @@ def test_login_threads_org_id_through_workspace_login_and_verify(
|
||||
result = CliRunner().invoke(cli_group, ["login", _SELECTOR_URL])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# The browser login carries the selector so the profile is workspace-scoped.
|
||||
assert login_calls == [f"auth login --host {_WORKSPACE}/?o={_ORG_ID}"]
|
||||
# The browser login carries the selector on --host so the grant is
|
||||
# workspace-scoped; the profile name stays the bare first DNS label.
|
||||
assert login_calls == [f"auth login --host {_WORKSPACE}/?o={_ORG_ID} --profile {_PROFILE}"]
|
||||
# The verify request routes to the workspace via ?o= (and used the token).
|
||||
assert fake.requests[-1]["params"] == {"o": _ORG_ID}
|
||||
assert fake.requests[-1]["authorization"] == "Bearer tok-fresh"
|
||||
|
||||
@@ -60,6 +60,7 @@ def test_scheduled_tasks_columns(db_engine: Engine) -> None:
|
||||
"agent_id",
|
||||
"model_override",
|
||||
"reasoning_effort",
|
||||
"max_cost_usd",
|
||||
"workspace",
|
||||
"base_branch",
|
||||
"execution_target",
|
||||
|
||||
@@ -0,0 +1,946 @@
|
||||
"""Sandbox rig for the live model-flows CUJs (design: model-flows-design.md §10.1).
|
||||
|
||||
Boots a real ``omnigent server`` + ``omnigent host`` from a chosen checkout —
|
||||
``OMNIGENT_E2E_MODEL_FLOWS_REPO`` selects which, so the identical tests can run
|
||||
against unmodified main (the red-on-main matrix) and against this branch — with
|
||||
the developer's real ``$HOME`` (the claude/codex logins cannot be relocated) but
|
||||
an isolated ``OMNIGENT_CONFIG_HOME`` / ``OMNIGENT_DATA_DIR``. Provider *shapes*
|
||||
(which provider entry is the default for each model family) are rewritten into
|
||||
the sandbox config per test group, mirroring how ``omnigent setup`` flips the
|
||||
``default:`` claims.
|
||||
|
||||
Every test drives the product the way a person uses it: the browser drives the
|
||||
rig server's real SPA (Playwright sync API), and harness truth is read where a
|
||||
user reads it — the tmux pane and the harness's own on-disk state. REST reads
|
||||
are secondary probes only, and REST writes are reserved for the rows whose
|
||||
actor is not the browser (a REPL ``/model``, a routing pin, an API client).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
from tests.e2e.routing._helpers import wait_for
|
||||
|
||||
#: Opt-in gate: this suite launches real claude/codex TUIs and does real
|
||||
#: inference, so it never runs in CI by accident.
|
||||
RUN_GATE_ENV = "OMNIGENT_E2E_MODEL_FLOWS"
|
||||
|
||||
#: Checkout to boot the rig from. Defaults to this test file's repo. Point it
|
||||
#: at an unmodified main checkout to produce the red-on-main matrix; the rig
|
||||
#: prefers that checkout's own ``.venv`` interpreter so its dependency set
|
||||
#: matches its code.
|
||||
RIG_REPO_ENV = "OMNIGENT_E2E_MODEL_FLOWS_REPO"
|
||||
|
||||
#: Optional prebuilt SPA dist for the rig server (``OMNIGENT_WEB_UI_DIST``
|
||||
#: passthrough) — needed when the target checkout's packaged SPA is stale
|
||||
#: relative to its web/ sources.
|
||||
SPA_DIST_ENV = "OMNIGENT_E2E_MODEL_FLOWS_WEB_DIST"
|
||||
|
||||
_THIS_REPO = Path(__file__).resolve().parents[3]
|
||||
|
||||
_SERVER_HEALTH_TIMEOUT_S = 60.0
|
||||
_HOST_REGISTER_TIMEOUT_S = 60.0
|
||||
#: Session create → pane visible. Real CLI boots take a while on a cold disk.
|
||||
PANE_TIMEOUT_S = 90.0
|
||||
|
||||
|
||||
def require_opt_in() -> None:
|
||||
"""Skip unless the live model-flows suite was asked for explicitly."""
|
||||
if os.environ.get(RUN_GATE_ENV) != "1":
|
||||
pytest.skip(
|
||||
f"set {RUN_GATE_ENV}=1 to run the live model-flow CUJs "
|
||||
"(they launch real claude/codex TUIs and a real host daemon)"
|
||||
)
|
||||
|
||||
|
||||
def require_clis(*clis: str) -> None:
|
||||
"""Skip when a needed CLI is not on PATH.
|
||||
|
||||
:param clis: Binary names, e.g. ``"claude"``, ``"codex"``, ``"tmux"``.
|
||||
"""
|
||||
for cli in clis:
|
||||
if shutil.which(cli) is None:
|
||||
pytest.skip(f"{cli!r} is not on PATH; this CUJ cannot launch")
|
||||
|
||||
|
||||
def rig_repo() -> Path:
|
||||
"""Return the checkout the rig boots from.
|
||||
|
||||
:returns: Absolute repo root, e.g. ``~/omnigent`` for a red-on-main run.
|
||||
"""
|
||||
override = os.environ.get(RIG_REPO_ENV)
|
||||
return Path(override).expanduser().resolve() if override else _THIS_REPO
|
||||
|
||||
|
||||
def _rig_python(repo: Path) -> str:
|
||||
"""Interpreter for rig subprocesses: the checkout's venv when it has one.
|
||||
|
||||
:param repo: The checkout to boot.
|
||||
:returns: Path to a python executable.
|
||||
"""
|
||||
venv_python = repo / ".venv" / "bin" / "python3"
|
||||
return str(venv_python) if venv_python.exists() else sys.executable
|
||||
|
||||
|
||||
def developer_providers() -> dict[str, Any]:
|
||||
"""Read the developer's real ``providers:`` block.
|
||||
|
||||
The live CUJs exercise the machine's actual provider entries (subscription
|
||||
logins, the gateway entry, the databricks profile); the sandbox copies the
|
||||
block and only flips ``default:`` claims per shape.
|
||||
|
||||
:returns: The ``providers`` mapping from ``~/.omnigent/config.yaml``.
|
||||
"""
|
||||
path = Path.home() / ".omnigent" / "config.yaml"
|
||||
if not path.is_file():
|
||||
pytest.skip("no ~/.omnigent/config.yaml; the live model-flow CUJs need real providers")
|
||||
parsed = yaml.safe_load(path.read_text()) or {}
|
||||
providers = parsed.get("providers")
|
||||
if not isinstance(providers, dict) or not providers:
|
||||
pytest.skip("~/.omnigent/config.yaml has no providers block")
|
||||
return {name: dict(body) for name, body in providers.items() if isinstance(body, dict)}
|
||||
|
||||
|
||||
def _entry_of_kind(providers: dict[str, Any], kind: str, *, cli: str | None = None) -> str | None:
|
||||
"""Find a provider entry name by ``kind`` (and optional ``cli``).
|
||||
|
||||
:param providers: The providers mapping.
|
||||
:param kind: e.g. ``"subscription"``, ``"gateway"``, ``"databricks"``.
|
||||
:param cli: Restrict subscription entries to this CLI, e.g. ``"claude"``.
|
||||
:returns: The entry name, or ``None``.
|
||||
"""
|
||||
for name, body in providers.items():
|
||||
if body.get("kind") != kind:
|
||||
continue
|
||||
if cli is not None and body.get("cli") != cli:
|
||||
continue
|
||||
return name
|
||||
return None
|
||||
|
||||
|
||||
def shape_providers(shape: str) -> dict[str, Any]:
|
||||
"""Return a providers block with ``default:`` claims flipped for *shape*.
|
||||
|
||||
Shapes mirror the analysis doc's live matrix:
|
||||
|
||||
- ``claude-subscription`` — claude's subscription entry claims anthropic.
|
||||
- ``claude-gateway`` — a ``kind: gateway`` entry with an anthropic family
|
||||
claims anthropic (the isaac-style hand-written gateway entry).
|
||||
- ``codex-databricks`` — the ``kind: databricks`` entry claims openai.
|
||||
- ``codex-subscription`` — codex's subscription entry claims openai.
|
||||
|
||||
Skips when the developer's config lacks the entry a shape needs.
|
||||
|
||||
:param shape: One of the four shape names above.
|
||||
:returns: A deep-copied providers mapping with defaults rewritten.
|
||||
"""
|
||||
providers = developer_providers()
|
||||
# Strip every existing default claim; each shape sets exactly what it needs.
|
||||
for body in providers.values():
|
||||
body.pop("default", None)
|
||||
|
||||
def _need(name: str | None, what: str) -> str:
|
||||
if name is None:
|
||||
pytest.skip(f"developer config has no {what}; shape {shape!r} cannot run")
|
||||
return name
|
||||
|
||||
if shape == "claude-subscription":
|
||||
name = _need(
|
||||
_entry_of_kind(providers, "subscription", cli="claude"), "claude subscription entry"
|
||||
)
|
||||
providers[name]["default"] = True
|
||||
elif shape == "claude-gateway":
|
||||
gateway = next(
|
||||
(
|
||||
n
|
||||
for n, b in providers.items()
|
||||
if b.get("kind") == "gateway" and isinstance(b.get("anthropic"), dict)
|
||||
),
|
||||
None,
|
||||
)
|
||||
name = _need(gateway, "anthropic-family gateway entry")
|
||||
providers[name]["default"] = True
|
||||
elif shape == "codex-databricks":
|
||||
name = _need(_entry_of_kind(providers, "databricks"), "databricks entry")
|
||||
providers[name]["default"] = "openai"
|
||||
elif shape == "codex-subscription":
|
||||
name = _need(
|
||||
_entry_of_kind(providers, "subscription", cli="codex"), "codex subscription entry"
|
||||
)
|
||||
providers[name]["default"] = "openai"
|
||||
else: # pragma: no cover - test-authoring error
|
||||
raise ValueError(f"unknown shape {shape!r}")
|
||||
return providers
|
||||
|
||||
|
||||
@dataclass
|
||||
class ModelFlowsRig:
|
||||
"""A booted sandbox server (+ optionally a host) from one checkout."""
|
||||
|
||||
repo: Path
|
||||
root: Path
|
||||
base_url: str = ""
|
||||
_server: subprocess.Popen[bytes] | None = None
|
||||
_host: subprocess.Popen[bytes] | None = None
|
||||
_logs: list[Any] = field(default_factory=list)
|
||||
host_id: str = ""
|
||||
|
||||
@property
|
||||
def server_log(self) -> Path:
|
||||
"""The rig server's log path."""
|
||||
return self.root / "server.log"
|
||||
|
||||
@property
|
||||
def host_log(self) -> Path:
|
||||
"""The rig host's log path."""
|
||||
return self.root / "host.log"
|
||||
|
||||
@property
|
||||
def data_dir(self) -> Path:
|
||||
"""The sandbox ``OMNIGENT_DATA_DIR``."""
|
||||
return self.root / "data"
|
||||
|
||||
def _env(self) -> dict[str, str]:
|
||||
env = {
|
||||
**os.environ,
|
||||
"OMNIGENT_CONFIG_HOME": str(self.root / "config-home"),
|
||||
"OMNIGENT_DATA_DIR": str(self.data_dir),
|
||||
"PYTHONPATH": str(self.repo),
|
||||
"OMNIGENT_LOG_TO_STDERR": "1",
|
||||
}
|
||||
# Claude Code refuses nested sessions; the agent driving this suite
|
||||
# may export the marker. And when this suite itself runs inside an
|
||||
# omnigent-managed session, the inherited runner identity would point
|
||||
# spawned runners at the WRONG server — scrub it.
|
||||
env.pop("CLAUDECODE", None)
|
||||
env.pop("RUNNER_SERVER_URL", None)
|
||||
env.pop("OMNIGENT", None)
|
||||
for key in [k for k in env if k.startswith(("OMNIGENT_RUNNER", "OMNIGENT_PROCESS"))]:
|
||||
env.pop(key, None)
|
||||
# tests/conftest.py exports OMNIGENT_DISABLE_CATALOG_LOOKUP=1 for the
|
||||
# whole pytest process (hermetic suites must not hit the network). This
|
||||
# suite is the OPPOSITE: a live rig whose databricks shapes need the
|
||||
# real provider catalog, and the spawned server/host inherit our env —
|
||||
# so drop the kill switch for them.
|
||||
env.pop("OMNIGENT_DISABLE_CATALOG_LOOKUP", None)
|
||||
spa_dist = os.environ.get(SPA_DIST_ENV)
|
||||
if spa_dist:
|
||||
env["OMNIGENT_WEB_UI_DIST"] = spa_dist
|
||||
return env
|
||||
|
||||
def start_server(self) -> None:
|
||||
"""Boot the rig server on a free port and wait for /health."""
|
||||
import socket
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
sock.bind(("127.0.0.1", 0))
|
||||
port = int(sock.getsockname()[1])
|
||||
(self.root / "config-home").mkdir(parents=True, exist_ok=True)
|
||||
self.data_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_handle = open(self.server_log, "w") # noqa: SIM115 — subprocess lifetime
|
||||
self._logs.append(log_handle)
|
||||
self._server = subprocess.Popen(
|
||||
[
|
||||
_rig_python(self.repo),
|
||||
"-m",
|
||||
"omnigent.cli",
|
||||
"server",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
"--database-uri",
|
||||
f"sqlite:///{self.root / 'rig.db'}",
|
||||
"--artifact-location",
|
||||
str(self.root / "artifacts"),
|
||||
],
|
||||
env=self._env(),
|
||||
cwd=str(self.repo),
|
||||
stdout=log_handle,
|
||||
stderr=subprocess.STDOUT,
|
||||
)
|
||||
self.base_url = f"http://127.0.0.1:{port}"
|
||||
|
||||
def _healthy() -> bool | None:
|
||||
if self._server is not None and self._server.poll() is not None:
|
||||
raise RuntimeError(
|
||||
f"rig server exited early; log tail:\n{self._tail(self.server_log)}"
|
||||
)
|
||||
try:
|
||||
return httpx.get(f"{self.base_url}/health", timeout=2).status_code == 200 or None
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
|
||||
wait_for(_healthy, timeout=_SERVER_HEALTH_TIMEOUT_S, what="the rig server /health")
|
||||
|
||||
def start_host(self, providers: dict[str, Any]) -> str:
|
||||
"""Boot (or reboot) the rig host with *providers*; return its host id.
|
||||
|
||||
:param providers: The sandbox ``providers:`` block for this shape.
|
||||
:returns: The registered host id.
|
||||
"""
|
||||
self.stop_host()
|
||||
config_home = self.root / "config-home"
|
||||
config_home.mkdir(parents=True, exist_ok=True)
|
||||
(config_home / "config.yaml").write_text(
|
||||
yaml.safe_dump({"providers": providers}, sort_keys=False)
|
||||
)
|
||||
log_handle = open(self.host_log, "w") # noqa: SIM115 — subprocess lifetime
|
||||
self._logs.append(log_handle)
|
||||
self._host = subprocess.Popen(
|
||||
[
|
||||
_rig_python(self.repo),
|
||||
"-m",
|
||||
"omnigent.host._daemon_entry",
|
||||
"--server",
|
||||
self.base_url,
|
||||
],
|
||||
env=self._env(),
|
||||
cwd=str(self.repo),
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=log_handle,
|
||||
)
|
||||
|
||||
def _online() -> str | None:
|
||||
if self._host is not None and self._host.poll() is not None:
|
||||
raise RuntimeError(
|
||||
f"rig host exited early; log tail:\n{self._tail(self.host_log)}"
|
||||
)
|
||||
try:
|
||||
resp = httpx.get(f"{self.base_url}/v1/hosts", timeout=5)
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
if resp.status_code != 200:
|
||||
return None
|
||||
online = [h for h in resp.json().get("hosts", []) if h.get("status") == "online"]
|
||||
return str(online[0]["host_id"]) if online else None
|
||||
|
||||
self.host_id = wait_for(
|
||||
_online, timeout=_HOST_REGISTER_TIMEOUT_S, what="the rig host to register"
|
||||
)
|
||||
return self.host_id
|
||||
|
||||
def _tail(self, path: Path, limit: int = 3000) -> str:
|
||||
return path.read_text(errors="replace")[-limit:] if path.exists() else ""
|
||||
|
||||
def stop_host(self) -> None:
|
||||
"""Stop the host subprocess if one is running."""
|
||||
self._stop(self._host)
|
||||
self._host = None
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Tear the whole rig down."""
|
||||
self.stop_host()
|
||||
self._stop(self._server)
|
||||
self._server = None
|
||||
for handle in self._logs:
|
||||
handle.close()
|
||||
self._logs.clear()
|
||||
|
||||
@staticmethod
|
||||
def _stop(proc: subprocess.Popen[bytes] | None) -> None:
|
||||
if proc is None or proc.poll() is not None:
|
||||
return
|
||||
proc.send_signal(signal.SIGTERM)
|
||||
try:
|
||||
proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired:
|
||||
proc.kill()
|
||||
proc.wait(timeout=5)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def booted_rig(tmp_root: Path) -> Iterator[ModelFlowsRig]:
|
||||
"""Context manager: a rig with its server booted (no host yet).
|
||||
|
||||
Snapshots the developer's ``~/.claude/settings.json`` and restores it on
|
||||
exit: the suite's real ``/model`` switches run under the real ``$HOME``
|
||||
and Claude Code persists every switch as the person's global default —
|
||||
without the restore, a test run would rewrite the developer's model.
|
||||
|
||||
:param tmp_root: Directory for the sandbox (config home, data, logs, db).
|
||||
:yields: The rig; call :meth:`ModelFlowsRig.start_host` per shape.
|
||||
"""
|
||||
settings_path = Path.home() / ".claude" / "settings.json"
|
||||
settings_before: bytes | None
|
||||
try:
|
||||
settings_before = settings_path.read_bytes()
|
||||
except OSError:
|
||||
settings_before = None
|
||||
rig = ModelFlowsRig(repo=rig_repo(), root=tmp_root)
|
||||
rig.start_server()
|
||||
try:
|
||||
yield rig
|
||||
finally:
|
||||
rig.stop()
|
||||
if settings_before is not None:
|
||||
try:
|
||||
if settings_path.read_bytes() != settings_before:
|
||||
settings_path.write_bytes(settings_before)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pane truth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _terminal_socket_dirs() -> set[Path]:
|
||||
"""Return the omnigent terminal tmux socket dirs currently on disk."""
|
||||
tmp = Path(tempfile.gettempdir())
|
||||
return {p for p in tmp.glob("omnigent-terminal-*") if (p / "tmux.sock").exists()}
|
||||
|
||||
|
||||
@dataclass
|
||||
class PaneWatcher:
|
||||
"""Discover the tmux pane a session launch creates and read its truth.
|
||||
|
||||
Snapshot the terminal-socket set before creating a session; the first new
|
||||
live socket afterwards belongs to that session's terminal (the rig is the
|
||||
only thing creating sessions inside the sandbox, but other daemons on the
|
||||
machine may create panes too — the ``since`` timestamp filters those by
|
||||
directory mtime).
|
||||
"""
|
||||
|
||||
before: set[Path] = field(default_factory=set)
|
||||
since: float = 0.0
|
||||
socket: Path | None = None
|
||||
|
||||
def arm(self) -> None:
|
||||
"""Record the pre-create socket set."""
|
||||
self.before = _terminal_socket_dirs()
|
||||
self.since = time.time()
|
||||
|
||||
def wait_for_pane(self, timeout: float = PANE_TIMEOUT_S) -> Path:
|
||||
"""Wait for the session's tmux socket to appear.
|
||||
|
||||
:param timeout: Seconds to wait.
|
||||
:returns: The tmux socket path.
|
||||
"""
|
||||
|
||||
def _found() -> Path | None:
|
||||
fresh = [
|
||||
d
|
||||
for d in _terminal_socket_dirs() - self.before
|
||||
if d.stat().st_mtime >= self.since - 1
|
||||
]
|
||||
for d in sorted(fresh, key=lambda p: p.stat().st_mtime):
|
||||
sock = d / "tmux.sock"
|
||||
probe = subprocess.run(
|
||||
["tmux", "-S", str(sock), "list-panes", "-a"],
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
if probe.returncode == 0 and probe.stdout.strip():
|
||||
return sock
|
||||
return None
|
||||
|
||||
self.socket = wait_for(_found, timeout=timeout, what="the session's tmux pane")
|
||||
return self.socket
|
||||
|
||||
def capture(self) -> str:
|
||||
"""Return the pane's current visible text."""
|
||||
assert self.socket is not None, "call wait_for_pane first"
|
||||
out = subprocess.run(
|
||||
["tmux", "-S", str(self.socket), "capture-pane", "-p", "-t", "main"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10,
|
||||
)
|
||||
return out.stdout
|
||||
|
||||
def wait_for_text(self, pattern: str, timeout: float = PANE_TIMEOUT_S) -> str:
|
||||
"""Wait until the pane's visible text matches *pattern* (regex).
|
||||
|
||||
:param pattern: Regex searched against the captured pane text.
|
||||
:param timeout: Seconds to wait.
|
||||
:returns: The matching captured text.
|
||||
"""
|
||||
|
||||
def _match() -> str | None:
|
||||
text = self.capture()
|
||||
return text if re.search(pattern, text) else None
|
||||
|
||||
return wait_for(_match, timeout=timeout, what=f"pane text matching {pattern!r}")
|
||||
|
||||
def type_line(self, text: str) -> None:
|
||||
"""Type *text* into the pane as one bracketed paste, then Enter."""
|
||||
assert self.socket is not None, "call wait_for_pane first"
|
||||
subprocess.run(
|
||||
["tmux", "-S", str(self.socket), "load-buffer", "-b", "omni-mf-e2e", "-"],
|
||||
input=text.encode("utf-8"),
|
||||
check=True,
|
||||
timeout=15,
|
||||
)
|
||||
subprocess.run(
|
||||
[
|
||||
"tmux",
|
||||
"-S",
|
||||
str(self.socket),
|
||||
"paste-buffer",
|
||||
"-p",
|
||||
"-b",
|
||||
"omni-mf-e2e",
|
||||
"-t",
|
||||
"main",
|
||||
],
|
||||
check=True,
|
||||
timeout=15,
|
||||
)
|
||||
time.sleep(1.0)
|
||||
subprocess.run(
|
||||
["tmux", "-S", str(self.socket), "send-keys", "-t", "main", "Enter"],
|
||||
check=True,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
def send_key(self, key: str) -> None:
|
||||
"""Send one raw tmux key (e.g. ``"Escape"``, ``"Enter"``)."""
|
||||
assert self.socket is not None, "call wait_for_pane first"
|
||||
subprocess.run(
|
||||
["tmux", "-S", str(self.socket), "send-keys", "-t", "main", key],
|
||||
check=True,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
|
||||
def kill_pane(pane: PaneWatcher) -> None:
|
||||
"""
|
||||
End the session's pane the way an idle reap or a host restart does.
|
||||
|
||||
Kills the pane's whole tmux server, so the harness process goes with it
|
||||
and the runner's next turn has to re-create the terminal (its cold-resume
|
||||
launch path).
|
||||
|
||||
:param pane: The session's discovered pane.
|
||||
"""
|
||||
assert pane.socket is not None, "call wait_for_pane first"
|
||||
subprocess.run(
|
||||
["tmux", "-S", str(pane.socket), "kill-server"],
|
||||
capture_output=True,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
|
||||
def codex_config_copy_model(session_id: str) -> str | None:
|
||||
"""Return the ``model =`` line of a codex session's private config copy.
|
||||
|
||||
The per-session ``CODEX_HOME`` lives under the real home dir regardless of
|
||||
``OMNIGENT_DATA_DIR`` (it is harness state, not omnigent state) — but its
|
||||
directory is named by a runner-GENERATED bridge id, recorded only in the
|
||||
bridge's own ``state.json`` (as ``session_id``). Resolve by scanning.
|
||||
|
||||
:param session_id: The session/conversation id.
|
||||
:returns: The pinned model string, or ``None``.
|
||||
"""
|
||||
root = Path.home() / ".omnigent" / "codex-native"
|
||||
for state_path in root.glob("*/state.json"):
|
||||
try:
|
||||
state = json.loads(state_path.read_text())
|
||||
except (OSError, ValueError):
|
||||
continue
|
||||
if state.get("session_id") != session_id:
|
||||
continue
|
||||
config = state_path.parent / "codex-home" / "config.toml"
|
||||
if not config.exists():
|
||||
return None
|
||||
for line in config.read_text().splitlines():
|
||||
match = re.match(r'^model\s*=\s*"(?P<model>[^"]+)"', line.strip())
|
||||
if match:
|
||||
return match.group("model")
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REST driving (the SPA's own calls, for rows whose actor is not the browser)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def rest_create_session(
|
||||
base_url: str,
|
||||
*,
|
||||
agent_name: str,
|
||||
host_id: str,
|
||||
workspace: Path,
|
||||
terminal_launch_args: list[str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a host-spawned native session with the create call the SPA makes.
|
||||
|
||||
:param base_url: Rig server base URL.
|
||||
:param agent_name: Built-in wrapper agent, e.g. ``"claude-native-ui"``.
|
||||
:param host_id: Host to launch on.
|
||||
:param workspace: Absolute workspace path on that host.
|
||||
:param terminal_launch_args: Pass-through CLI args, e.g. :func:`bypass_args`.
|
||||
:returns: The new session id.
|
||||
"""
|
||||
agents = httpx.get(f"{base_url}/v1/agents", timeout=30)
|
||||
agents.raise_for_status()
|
||||
agent_id = next((a["id"] for a in agents.json()["data"] if a["name"] == agent_name), None)
|
||||
assert agent_id is not None, f"{agent_name!r} is not registered on the rig server"
|
||||
body: dict[str, Any] = {"agent_id": agent_id, "host_id": host_id, "workspace": str(workspace)}
|
||||
if terminal_launch_args:
|
||||
body["terminal_launch_args"] = list(terminal_launch_args)
|
||||
resp = httpx.post(f"{base_url}/v1/sessions", json=body, timeout=120)
|
||||
assert resp.status_code < 400, f"create failed {resp.status_code}: {resp.text[:2000]}"
|
||||
return str(resp.json()["id"])
|
||||
|
||||
|
||||
def rest_patch_session(base_url: str, session_id: str, **fields: Any) -> dict[str, Any]:
|
||||
"""
|
||||
PATCH session fields — a REPL ``/model`` and an API client write this way.
|
||||
|
||||
A native model change is forwarded to the pane and answered only once the
|
||||
harness confirmed it, so the call may take a while.
|
||||
|
||||
:param base_url: Rig server base URL.
|
||||
:param session_id: Session id.
|
||||
:param fields: Wire fields, e.g. ``model_override="claude-opus-4-8"``.
|
||||
:returns: The updated session payload.
|
||||
"""
|
||||
resp = httpx.patch(f"{base_url}/v1/sessions/{session_id}", json=fields, timeout=180)
|
||||
assert resp.status_code < 400, (
|
||||
f"PATCH {sorted(fields)} failed {resp.status_code}: {resp.text[:2000]}"
|
||||
)
|
||||
return dict(resp.json())
|
||||
|
||||
|
||||
def rest_post_user_message(base_url: str, session_id: str, text: str) -> None:
|
||||
"""
|
||||
POST a user message the way the composer does.
|
||||
|
||||
:param base_url: Rig server base URL.
|
||||
:param session_id: Session id.
|
||||
:param text: The message text.
|
||||
"""
|
||||
resp = httpx.post(
|
||||
f"{base_url}/v1/sessions/{session_id}/events",
|
||||
json={
|
||||
"type": "message",
|
||||
"data": {"role": "user", "content": [{"type": "input_text", "text": text}]},
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
assert resp.status_code < 400, f"message POST failed {resp.status_code}: {resp.text[:1000]}"
|
||||
|
||||
|
||||
def assistant_message_count(snapshot: dict[str, Any]) -> int:
|
||||
"""
|
||||
Count the assistant messages a session snapshot carries.
|
||||
|
||||
:param snapshot: A ``GET /v1/sessions/{id}`` payload.
|
||||
:returns: The number of assistant ``message`` items.
|
||||
"""
|
||||
count = 0
|
||||
for item in snapshot.get("items") or []:
|
||||
if not isinstance(item, dict) or item.get("type") != "message":
|
||||
continue
|
||||
data = item.get("data") if isinstance(item.get("data"), dict) else item
|
||||
if data.get("role") == "assistant":
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Browser driving (sync Playwright over the rig server's real SPA)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Ui:
|
||||
"""Thin user-perspective driver over the rig's SPA."""
|
||||
|
||||
def __init__(self, page: Any, base_url: str) -> None:
|
||||
self.page = page
|
||||
self.base_url = base_url
|
||||
|
||||
def open_landing(self) -> None:
|
||||
"""Open the new-chat landing screen."""
|
||||
self.page.goto(self.base_url, wait_until="domcontentloaded")
|
||||
self.page.get_by_test_id("new-chat-landing-input").wait_for(
|
||||
state="visible", timeout=30_000
|
||||
)
|
||||
|
||||
def pick_agent(self, label: str) -> None:
|
||||
"""Select the agent whose dropdown label contains *label*."""
|
||||
select = self.page.get_by_test_id("new-chat-landing-agent-select")
|
||||
if label.lower() in select.inner_text().strip().lower():
|
||||
return
|
||||
select.click()
|
||||
self.page.wait_for_timeout(400)
|
||||
for item in self.page.get_by_role("menuitem").all():
|
||||
if label.lower() in item.inner_text().lower():
|
||||
item.click()
|
||||
self.page.wait_for_timeout(800)
|
||||
return
|
||||
raise AssertionError(f"agent {label!r} not offered on the landing screen")
|
||||
|
||||
def open_landing_config(self) -> None:
|
||||
"""Open the landing config modal (gear)."""
|
||||
self.page.get_by_test_id("new-chat-landing-config-gear").click()
|
||||
self.page.get_by_test_id("new-chat-landing-config-model").wait_for(
|
||||
state="visible", timeout=15_000
|
||||
)
|
||||
|
||||
def landing_model_label(self) -> str:
|
||||
"""Visible text of the landing model select trigger."""
|
||||
return self.page.get_by_test_id("new-chat-landing-config-model").inner_text().strip()
|
||||
|
||||
def wait_landing_model_label(self, pattern: str, timeout_s: float = 90.0) -> str:
|
||||
"""Wait until the landing model trigger matches *pattern* (regex).
|
||||
|
||||
The trigger renders a bare sentinel while the host's boot probe is
|
||||
still warming (the web retries the fetch with backoff), so give the
|
||||
label the same warm-up wait a person makes.
|
||||
"""
|
||||
|
||||
def _match() -> str | None:
|
||||
text = self.landing_model_label()
|
||||
return text if re.search(pattern, text) else None
|
||||
|
||||
return wait_for(_match, timeout=timeout_s, what=f"landing model label {pattern!r}")
|
||||
|
||||
def open_model_dropdown(self, test_id: str, warmup_timeout_s: float = 90.0) -> list[str]:
|
||||
"""Open a model select and return its visible option texts.
|
||||
|
||||
A dropdown opened while the host's boot probe is still warming shows
|
||||
only the loading/error note and the sentinels; the web retries the
|
||||
fetch with backoff, so keep the dropdown open and re-read until model
|
||||
rows (or the settled error) appear — the same wait a person makes.
|
||||
|
||||
:param test_id: Trigger test id (landing or composer variant).
|
||||
:param warmup_timeout_s: How long to allow the boot probe to warm.
|
||||
:returns: Option texts, whitespace-flattened.
|
||||
"""
|
||||
self.page.get_by_test_id(test_id).click()
|
||||
deadline = time.monotonic() + warmup_timeout_s
|
||||
|
||||
def _texts() -> list[str]:
|
||||
return [
|
||||
re.sub(r"\s+", " ", opt.inner_text().strip())
|
||||
for opt in self.page.get_by_role("option").all()
|
||||
]
|
||||
|
||||
while True:
|
||||
self.page.wait_for_timeout(500)
|
||||
texts = _texts()
|
||||
if any(not t.lower().startswith(("default", "smart routing")) for t in texts):
|
||||
return texts
|
||||
if time.monotonic() >= deadline:
|
||||
return texts
|
||||
|
||||
def pick_dropdown_option(self, needle: str) -> None:
|
||||
"""Click the open dropdown's first option containing *needle*."""
|
||||
for opt in self.page.get_by_role("option").all():
|
||||
if needle.lower() in opt.inner_text().lower():
|
||||
opt.click()
|
||||
self.page.wait_for_timeout(300)
|
||||
return
|
||||
raise AssertionError(f"dropdown option containing {needle!r} not found")
|
||||
|
||||
def close_dropdown(self) -> None:
|
||||
"""Dismiss an open dropdown without touching the modal."""
|
||||
self.page.keyboard.press("Escape")
|
||||
self.page.wait_for_timeout(200)
|
||||
|
||||
def save_landing_config(self) -> None:
|
||||
"""Save the landing config modal."""
|
||||
self.page.get_by_test_id("new-chat-landing-config-save").click()
|
||||
self.page.wait_for_timeout(400)
|
||||
|
||||
def submit_new_chat(self, prompt: str, timeout_s: float = 150.0) -> str:
|
||||
"""Type *prompt*, submit, and wait for the session route.
|
||||
|
||||
The wait must be a PLAYWRIGHT wait (`wait_for_url`), not a
|
||||
sleep-poll of ``page.url``: the sync API pumps its event dispatch
|
||||
only inside playwright calls, so a plain ``time.sleep`` loop reads a
|
||||
URL frozen at the landing route forever — the navigation this waits
|
||||
for was landing within seconds while the poll never saw it. The
|
||||
budget still covers a COLD create (host launch + harness boot).
|
||||
|
||||
:returns: The new session/conversation id.
|
||||
"""
|
||||
self.page.get_by_test_id("new-chat-landing-input").fill(prompt)
|
||||
self.page.get_by_test_id("new-chat-landing-submit").click()
|
||||
self.page.wait_for_url(re.compile(r"/c/[a-z0-9_-]+", re.I), timeout=timeout_s * 1000)
|
||||
match = re.search(r"/c/([a-z0-9_-]+)", self.page.url, re.I)
|
||||
assert match is not None, f"no session id in {self.page.url!r}"
|
||||
return match.group(1)
|
||||
|
||||
def open_session(self, session_id: str) -> None:
|
||||
"""Navigate to a session's chat page."""
|
||||
self.page.goto(f"{self.base_url}/c/{session_id}", wait_until="domcontentloaded")
|
||||
self.page.get_by_test_id("composer-config-gear").wait_for(state="visible", timeout=30_000)
|
||||
|
||||
def composer_label(self) -> str:
|
||||
"""Visible composer model/effort chip text ("" when absent)."""
|
||||
label = self.page.get_by_test_id("composer-model-effort-label")
|
||||
if label.count() == 0:
|
||||
return ""
|
||||
return re.sub(r"\s+", " ", label.first.inner_text().strip())
|
||||
|
||||
def wait_composer_label(self, pattern: str, timeout_s: float = 60.0) -> str:
|
||||
"""Wait until the composer chip matches *pattern* (regex)."""
|
||||
|
||||
def _match() -> str | None:
|
||||
text = self.composer_label()
|
||||
return text if re.search(pattern, text) else None
|
||||
|
||||
return wait_for(_match, timeout=timeout_s, what=f"composer label {pattern!r}")
|
||||
|
||||
def open_gear(self) -> None:
|
||||
"""Open the in-session config gear modal."""
|
||||
self.page.get_by_test_id("composer-config-gear").click()
|
||||
self.page.get_by_test_id("composer-config-model").wait_for(state="visible", timeout=15_000)
|
||||
|
||||
def gear_model_label(self) -> str:
|
||||
"""Visible text of the gear's model select trigger."""
|
||||
return self.page.get_by_test_id("composer-config-model").inner_text().strip()
|
||||
|
||||
def gear_rows(self) -> list[dict[str, str]]:
|
||||
"""Open the gear model dropdown and return its catalog rows.
|
||||
|
||||
:returns: ``[{"id": ..., "text": ..., "active": ...}, ...]`` for every
|
||||
``data-model-id`` row (sentinels excluded).
|
||||
"""
|
||||
self.page.get_by_test_id("composer-config-model").click()
|
||||
self.page.wait_for_timeout(500)
|
||||
rows = []
|
||||
for opt in self.page.locator('[role="option"][data-model-id]').all():
|
||||
rows.append(
|
||||
{
|
||||
"id": opt.get_attribute("data-model-id") or "",
|
||||
"text": re.sub(r"\s+", " ", opt.inner_text().strip()),
|
||||
"active": opt.get_attribute("data-active") or "false",
|
||||
}
|
||||
)
|
||||
return rows
|
||||
|
||||
def save_gear(self) -> None:
|
||||
"""Save the gear modal."""
|
||||
self.page.get_by_test_id("composer-config-save").click()
|
||||
self.page.wait_for_timeout(400)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def browser_ui(base_url: str) -> Iterator[Ui]:
|
||||
"""Launch a headless browser over the rig SPA.
|
||||
|
||||
:param base_url: The rig server base URL.
|
||||
:yields: The :class:`Ui` driver.
|
||||
"""
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
with sync_playwright() as pw:
|
||||
browser = pw.chromium.launch()
|
||||
page = browser.new_context(viewport={"width": 1440, "height": 950}).new_page()
|
||||
if os.environ.get("OMNIGENT_E2E_MODEL_FLOWS_TRACE") == "1":
|
||||
# Debug tap: print API traffic, console errors, and failed
|
||||
# requests so a stalled flow can be attributed from the test log.
|
||||
page.on(
|
||||
"response",
|
||||
lambda resp: (
|
||||
print(f"[trace] {resp.status} {resp.request.method} {resp.url}")
|
||||
if "/v1/" in resp.url
|
||||
else None
|
||||
),
|
||||
)
|
||||
page.on(
|
||||
"requestfailed",
|
||||
lambda req: print(f"[trace] REQFAIL {req.method} {req.url} :: {req.failure}"),
|
||||
)
|
||||
page.on(
|
||||
"console",
|
||||
lambda msg: (
|
||||
print(f"[trace] CONSOLE[{msg.type}] {msg.text[:200]}")
|
||||
if msg.type == "error"
|
||||
else None
|
||||
),
|
||||
)
|
||||
page.on(
|
||||
"framenavigated",
|
||||
lambda frame: (
|
||||
print(f"[trace] NAV {frame.url}") if frame == page.main_frame else None
|
||||
),
|
||||
)
|
||||
try:
|
||||
yield Ui(page, base_url)
|
||||
finally:
|
||||
browser.close()
|
||||
|
||||
|
||||
def session_snapshot(base_url: str, session_id: str) -> dict[str, Any]:
|
||||
"""Secondary probe: the session's REST snapshot.
|
||||
|
||||
:param base_url: Rig server base URL.
|
||||
:param session_id: Session id.
|
||||
:returns: The parsed snapshot.
|
||||
"""
|
||||
resp = httpx.get(f"{base_url}/v1/sessions/{session_id}", timeout=30)
|
||||
resp.raise_for_status()
|
||||
return dict(resp.json())
|
||||
|
||||
|
||||
def host_model_options(base_url: str, host_id: str, harness: str) -> dict[str, Any]:
|
||||
"""Secondary probe: the pre-launch model options for a harness.
|
||||
|
||||
:returns: The parsed ``{"models": [...], ...}`` payload.
|
||||
"""
|
||||
resp = httpx.get(
|
||||
f"{base_url}/v1/hosts/{host_id}/harnesses/{harness}/model-options", timeout=60
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return dict(resp.json())
|
||||
|
||||
|
||||
def dismiss_blocking_dialogs(pane: PaneWatcher, attempts: int = 3) -> None:
|
||||
"""Best-effort Escape past first-run dialogs so the input box is usable.
|
||||
|
||||
:param pane: The session's pane.
|
||||
:param attempts: Escape presses.
|
||||
"""
|
||||
for _ in range(attempts):
|
||||
text = pane.capture()
|
||||
if "Esc to cancel" in text or "Enter to confirm" in text or "to change usage" in text:
|
||||
pane.send_key("Escape")
|
||||
time.sleep(1.0)
|
||||
else:
|
||||
return
|
||||
|
||||
|
||||
def bypass_args(harness: str) -> list[str]:
|
||||
"""First-run bypass launch args per harness (mirrors the routing suite).
|
||||
|
||||
:param harness: ``"claude-native"`` or ``"codex-native"``.
|
||||
:returns: ``terminal_launch_args`` for the create call.
|
||||
"""
|
||||
if harness == "claude-native":
|
||||
return ["--dangerously-skip-permissions"]
|
||||
return ["--dangerously-bypass-approvals-and-sandbox"]
|
||||
|
||||
|
||||
def read_json(path: Path) -> dict[str, Any] | None:
|
||||
"""Parse a JSON file, returning ``None`` on absence or damage."""
|
||||
try:
|
||||
return dict(json.loads(path.read_text()))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
@@ -0,0 +1,725 @@
|
||||
"""Live model-flow CUJs (model-flows-design.md §10.1, the "UI-live" tier).
|
||||
|
||||
Every test drives the product the way a person uses it — the browser drives the
|
||||
rig server's real SPA (or, for the rows whose actor is a REPL, a routing pin,
|
||||
or an API client, the same REST calls), and harness truth is read from the
|
||||
tmux pane — with REST snapshots as secondary probes only. Assertions encode the DESIGN's target
|
||||
behavior, so this suite is red on unmodified main (and partially red on the PR
|
||||
branch) in exactly the ways `model-flows-report.md` cites, and goes green as
|
||||
the landing-order steps land. Run one row's red twin with::
|
||||
|
||||
OMNIGENT_E2E_MODEL_FLOWS=1 OMNIGENT_E2E_MODEL_FLOWS_REPO=~/omnigent \\
|
||||
pytest tests/e2e/omnigent/test_model_flows_live.py -k row1 -x
|
||||
|
||||
Rows are numbered after the design's §10.1 table.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from omnigent.native_coding_agents import CLAUDE_NATIVE_AGENT_NAME, CODEX_NATIVE_AGENT_NAME
|
||||
from tests.e2e.omnigent._model_flows_rig import (
|
||||
ModelFlowsRig,
|
||||
PaneWatcher,
|
||||
Ui,
|
||||
assistant_message_count,
|
||||
booted_rig,
|
||||
browser_ui,
|
||||
bypass_args,
|
||||
codex_config_copy_model,
|
||||
dismiss_blocking_dialogs,
|
||||
host_model_options,
|
||||
kill_pane,
|
||||
require_clis,
|
||||
require_opt_in,
|
||||
rest_create_session,
|
||||
rest_patch_session,
|
||||
rest_post_user_message,
|
||||
session_snapshot,
|
||||
shape_providers,
|
||||
)
|
||||
from tests.e2e.routing._helpers import wait_for
|
||||
|
||||
pytestmark = [pytest.mark.live_model_flows, pytest.mark.posix_only]
|
||||
|
||||
#: A version-free family word is not enough — resolved names carry versions.
|
||||
_VERSIONED = re.compile(r"\d")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, scope="module")
|
||||
def _opt_in() -> None:
|
||||
require_opt_in()
|
||||
require_clis("tmux")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def rig(tmp_path_factory: pytest.TempPathFactory) -> Iterator[ModelFlowsRig]:
|
||||
"""One rig server for the module; hosts restart per shape."""
|
||||
with booted_rig(tmp_path_factory.mktemp("model_flows_rig")) as booted:
|
||||
yield booted
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def shaped_host(rig: ModelFlowsRig) -> Callable[[str], str]:
|
||||
"""Return ``ensure(shape) -> host_id``, restarting the host on change."""
|
||||
current: dict[str, str] = {}
|
||||
|
||||
def _ensure(shape: str) -> str:
|
||||
if current.get("shape") != shape:
|
||||
rig.start_host(shape_providers(shape))
|
||||
current["shape"] = shape
|
||||
return rig.host_id
|
||||
|
||||
return _ensure
|
||||
|
||||
|
||||
def _create_session(
|
||||
ui: Ui,
|
||||
agent_label: str,
|
||||
*,
|
||||
pick: str | None = None,
|
||||
prompt: str = "Reply with exactly: ok. Nothing else.",
|
||||
) -> tuple[str, PaneWatcher]:
|
||||
"""Create a session through the landing screen, watching for its pane.
|
||||
|
||||
:param ui: The browser driver, already on the landing screen.
|
||||
:param agent_label: Agent dropdown label ("Claude Code" / "Codex").
|
||||
:param pick: Optional model-dropdown entry substring to pick; ``None``
|
||||
leaves Default.
|
||||
:returns: ``(session_id, pane)`` with the pane discovered.
|
||||
"""
|
||||
ui.pick_agent(agent_label)
|
||||
if pick is not None:
|
||||
ui.open_landing_config()
|
||||
ui.open_model_dropdown("new-chat-landing-config-model")
|
||||
ui.pick_dropdown_option(pick)
|
||||
ui.save_landing_config()
|
||||
pane = PaneWatcher()
|
||||
pane.arm()
|
||||
session_id = ui.submit_new_chat(prompt)
|
||||
pane.wait_for_pane()
|
||||
return session_id, pane
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shape: claude · subscription
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_row1_claude_picker_lists_resolved_versioned_names(
|
||||
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
|
||||
) -> None:
|
||||
"""New chat → claude dropdown shows resolved names, never "Sonnet 4.6"."""
|
||||
require_clis("claude")
|
||||
shaped_host("claude-subscription")
|
||||
with browser_ui(rig.base_url) as ui:
|
||||
ui.open_landing()
|
||||
ui.pick_agent("Claude Code")
|
||||
ui.open_landing_config()
|
||||
options = ui.open_model_dropdown("new-chat-landing-config-model")
|
||||
|
||||
rows = [opt for opt in options if not opt.lower().startswith("default")]
|
||||
assert rows, "the claude model dropdown offered no rows at all"
|
||||
assert not any("4.6" in opt for opt in options), (
|
||||
f"a frozen 'Sonnet 4.6' label leaked into the picker: {options}"
|
||||
)
|
||||
versioned = [opt for opt in rows if _VERSIONED.search(opt)]
|
||||
assert versioned, (
|
||||
f"no row carries a resolved version — the static alias table is still "
|
||||
f"serving the picker: {options}"
|
||||
)
|
||||
assert any("1M context" in opt for opt in rows), (
|
||||
f"the 1M-context variants are missing from the probed catalog: {options}"
|
||||
)
|
||||
|
||||
|
||||
def test_row6_claude_default_label_names_the_true_default(
|
||||
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
|
||||
) -> None:
|
||||
"""The claude picker reads "Default (X)" where X is what a bare launch runs."""
|
||||
require_clis("claude")
|
||||
shaped_host("claude-subscription")
|
||||
with browser_ui(rig.base_url) as ui:
|
||||
ui.open_landing()
|
||||
ui.pick_agent("Claude Code")
|
||||
ui.open_landing_config()
|
||||
label = ui.wait_landing_model_label(r"Default \(.+\)|Models unavailable")
|
||||
|
||||
assert re.fullmatch(r"Default \(.+\)", label), (
|
||||
f"claude's Default entry reads {label!r} — the truthful default name is "
|
||||
"missing (the web discards isDefault, or no row carries it)"
|
||||
)
|
||||
|
||||
|
||||
def test_row7_default_create_chip_equals_pane_truth(
|
||||
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
|
||||
) -> None:
|
||||
"""A Default create's composer chip appears and matches the pane footer."""
|
||||
require_clis("claude")
|
||||
shaped_host("claude-subscription")
|
||||
with browser_ui(rig.base_url) as ui:
|
||||
ui.open_landing()
|
||||
session_id, pane = _create_session(ui, "Claude Code")
|
||||
chip = ui.wait_composer_label(r"\S", timeout_s=90)
|
||||
pane_text = pane.wait_for_text(r"│")
|
||||
|
||||
# The pane footer names the model on its status line; the chip must carry
|
||||
# the same family AND version (e.g. footer "Opus 4.8 (1M context)" must not
|
||||
# pair with a chip claiming "Opus 5 ...").
|
||||
footer_line = next(
|
||||
(line for line in pane_text.splitlines() if "│" in line and "context" in line.lower()),
|
||||
None,
|
||||
) or next((line for line in pane_text.splitlines() if "│" in line), "")
|
||||
footer_model = footer_line.split("│")[0].strip()
|
||||
assert footer_model, f"could not read a model from the pane footer: {pane_text[-400:]}"
|
||||
family = footer_model.split()[0]
|
||||
assert family.lower() in chip.lower(), (
|
||||
f"composer chip {chip!r} does not carry the pane's model family {footer_model!r}"
|
||||
)
|
||||
version = re.search(r"\d+(\.\d+)?", footer_model)
|
||||
if version:
|
||||
assert version.group(0) in chip, (
|
||||
f"composer chip {chip!r} claims a different version than the pane "
|
||||
f"footer {footer_model!r} (session {session_id})"
|
||||
)
|
||||
|
||||
|
||||
def test_row13_reported_model_renders_verbatim_not_family_collapsed(
|
||||
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
|
||||
) -> None:
|
||||
"""The gear highlights exactly the model the pane reports — generation too.
|
||||
|
||||
On this machine a bare subscription launch runs the settings-file pin
|
||||
(Opus 4.8 (1M)); the picker must render that exact model as selected (as
|
||||
its own appended row when the alias catalog lacks it), never relabel it as
|
||||
the alias catalog's Opus 5.
|
||||
"""
|
||||
require_clis("claude")
|
||||
shaped_host("claude-subscription")
|
||||
with browser_ui(rig.base_url) as ui:
|
||||
ui.open_landing()
|
||||
session_id, pane = _create_session(ui, "Claude Code")
|
||||
pane_text = pane.wait_for_text(r"│")
|
||||
footer_line = next((line for line in pane_text.splitlines() if "│" in line), "")
|
||||
footer_model = footer_line.split("│")[0].strip()
|
||||
version = re.search(r"\d+(\.\d+)?", footer_model)
|
||||
ui.wait_composer_label(r"\S", timeout_s=90)
|
||||
ui.open_gear()
|
||||
rows = ui.gear_rows()
|
||||
|
||||
active = [row for row in rows if row["active"] == "true"]
|
||||
assert active, f"no gear row is highlighted (rows: {rows}, session {session_id})"
|
||||
if version:
|
||||
assert version.group(0) in active[0]["text"], (
|
||||
f"the highlighted row {active[0]} claims a different generation than "
|
||||
f"the pane's {footer_model!r} — the mirror family-collapsed the report"
|
||||
)
|
||||
|
||||
|
||||
def test_row16_terminal_switch_mirrors_exactly(
|
||||
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
|
||||
) -> None:
|
||||
"""Typing /model haiku in the pane highlights exactly Haiku 4.5 in the web."""
|
||||
require_clis("claude")
|
||||
shaped_host("claude-subscription")
|
||||
with browser_ui(rig.base_url) as ui:
|
||||
ui.open_landing()
|
||||
session_id, pane = _create_session(ui, "Claude Code")
|
||||
pane.wait_for_text(r"│")
|
||||
dismiss_blocking_dialogs(pane)
|
||||
pane.type_line("/model haiku")
|
||||
pane.wait_for_text(r"Haiku", timeout=30)
|
||||
chip = ui.wait_composer_label(r"[Hh]aiku", timeout_s=30)
|
||||
|
||||
assert "haiku" in chip.lower(), f"chip never mirrored the terminal switch: {chip!r}"
|
||||
snapshot = session_snapshot(rig.base_url, session_id)
|
||||
reported = snapshot.get("llm_model") or ""
|
||||
assert "haiku" in str(reported).lower(), (
|
||||
f"the session record never learned the terminal-side switch "
|
||||
f"(llm_model={reported!r}, model_override={snapshot.get('model_override')!r})"
|
||||
)
|
||||
|
||||
|
||||
def test_row14_web_switch_confirms_against_the_pane(
|
||||
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
|
||||
) -> None:
|
||||
"""A gear pick flips the chip only once the pane actually switched."""
|
||||
require_clis("claude")
|
||||
shaped_host("claude-subscription")
|
||||
with browser_ui(rig.base_url) as ui:
|
||||
ui.open_landing()
|
||||
session_id, pane = _create_session(ui, "Claude Code")
|
||||
pane.wait_for_text(r"│")
|
||||
dismiss_blocking_dialogs(pane)
|
||||
ui.wait_composer_label(r"\S", timeout_s=90)
|
||||
ui.open_gear()
|
||||
ui.gear_rows()
|
||||
ui.pick_dropdown_option("Sonnet 5")
|
||||
ui.save_gear()
|
||||
chip = ui.wait_composer_label(r"[Ss]onnet", timeout_s=45)
|
||||
pane_text = pane.wait_for_text(r"Sonnet", timeout=45)
|
||||
|
||||
assert "sonnet" in chip.lower(), f"the chip never settled on the pick: {chip!r}"
|
||||
assert "Sonnet" in pane_text, (
|
||||
f"the pane never switched although the web claims it did (session {session_id})"
|
||||
)
|
||||
|
||||
|
||||
def test_row15_swallowed_injection_surfaces_an_error(
|
||||
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
|
||||
) -> None:
|
||||
"""A pane dialog eats /model → the web surfaces failure instead of lying.
|
||||
|
||||
The pane is parked inside claude's own interactive /model picker dialog, so
|
||||
the injected switch command lands in that dialog and never executes. The
|
||||
design's contract: the record must not silently claim the new model — the
|
||||
web either surfaces a model_change_not_applied error or keeps reporting the
|
||||
pane's real model.
|
||||
"""
|
||||
require_clis("claude")
|
||||
shaped_host("claude-subscription")
|
||||
with browser_ui(rig.base_url) as ui:
|
||||
ui.open_landing()
|
||||
session_id, pane = _create_session(ui, "Claude Code")
|
||||
pane.wait_for_text(r"│")
|
||||
dismiss_blocking_dialogs(pane)
|
||||
baseline = ui.wait_composer_label(r"\S", timeout_s=90)
|
||||
# Park the pane in claude's own model-picker dialog.
|
||||
pane.type_line("/model")
|
||||
time.sleep(3.0)
|
||||
ui.open_gear()
|
||||
ui.gear_rows()
|
||||
ui.pick_dropdown_option("Haiku")
|
||||
ui.save_gear()
|
||||
time.sleep(10.0)
|
||||
chip_after = ui.composer_label()
|
||||
pane_after = pane.capture()
|
||||
pane.send_key("Escape")
|
||||
|
||||
pane_switched = "Haiku" in pane_after
|
||||
if not pane_switched:
|
||||
assert "haiku" not in chip_after.lower(), (
|
||||
f"the pane never switched (dialog swallowed /model) but the web now "
|
||||
f"claims Haiku — silent record/pane divergence (was {baseline!r}, "
|
||||
f"session {session_id})"
|
||||
)
|
||||
|
||||
|
||||
def test_row17_model_and_effort_apply_together(
|
||||
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
|
||||
) -> None:
|
||||
"""One gear save changing model + effort applies both, in order."""
|
||||
require_clis("claude")
|
||||
shaped_host("claude-subscription")
|
||||
with browser_ui(rig.base_url) as ui:
|
||||
ui.open_landing()
|
||||
session_id, pane = _create_session(ui, "Claude Code")
|
||||
pane.wait_for_text(r"│")
|
||||
dismiss_blocking_dialogs(pane)
|
||||
ui.wait_composer_label(r"\S", timeout_s=90)
|
||||
ui.open_gear()
|
||||
ui.gear_rows()
|
||||
# Selecting an option closes the Radix dropdown itself; pressing
|
||||
# Escape afterwards would close the whole gear modal.
|
||||
ui.pick_dropdown_option("Sonnet 5")
|
||||
ui.page.get_by_test_id("composer-config-effort").click()
|
||||
# Pick a level that DIFFERS from the ambient default (this
|
||||
# machine runs "high" globally): the save skips unchanged knobs.
|
||||
ui.pick_dropdown_option("Medium")
|
||||
ui.save_gear()
|
||||
pane_text = pane.wait_for_text(r"Sonnet", timeout=45)
|
||||
# The save serializes its legs IN THE PAGE and the model leg holds
|
||||
# until the pane CONFIRMS the switch (the design's step-6 contract),
|
||||
# so the effort PATCH is sent by the browser seconds after "Sonnet"
|
||||
# appears — the browser must stay open while we poll for it.
|
||||
effort = wait_for(
|
||||
lambda: session_snapshot(rig.base_url, session_id).get("reasoning_effort"),
|
||||
timeout=45.0,
|
||||
what="the effort override to persist",
|
||||
)
|
||||
|
||||
assert "Sonnet" in pane_text
|
||||
assert str(effort).lower() == "medium", f"the effort half of the save was lost: {effort!r}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shape: claude · gateway kind
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_row4_claude_gateway_rows_are_labeled_not_raw(
|
||||
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
|
||||
) -> None:
|
||||
"""The gateway picker shows harness-labeled rows, never a raw wire id."""
|
||||
require_clis("claude")
|
||||
shaped_host("claude-gateway")
|
||||
with browser_ui(rig.base_url) as ui:
|
||||
ui.open_landing()
|
||||
ui.pick_agent("Claude Code")
|
||||
ui.open_landing_config()
|
||||
options = ui.open_model_dropdown("new-chat-landing-config-model")
|
||||
|
||||
rows = [opt for opt in options if not opt.lower().startswith("default")]
|
||||
assert rows, "the gateway claude picker offered no rows"
|
||||
raw = [opt for opt in rows if "system.ai." in opt or "databricks-" in opt]
|
||||
assert not raw, f"raw wire spellings render as display names on the gateway shape: {raw}"
|
||||
|
||||
|
||||
def test_row8_explicit_pick_runs_exactly_the_pick(
|
||||
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
|
||||
) -> None:
|
||||
"""Creating with an explicit pick launches the pane on exactly that model."""
|
||||
require_clis("claude")
|
||||
shaped_host("claude-gateway")
|
||||
with browser_ui(rig.base_url) as ui:
|
||||
ui.open_landing()
|
||||
ui.pick_agent("Claude Code")
|
||||
ui.open_landing_config()
|
||||
options = ui.open_model_dropdown("new-chat-landing-config-model")
|
||||
rows = [opt for opt in options if not opt.lower().startswith("default")]
|
||||
assert rows, "no rows to pick from"
|
||||
ui.pick_dropdown_option(rows[0])
|
||||
ui.save_landing_config()
|
||||
pane = PaneWatcher()
|
||||
pane.arm()
|
||||
session_id = ui.submit_new_chat("Reply with exactly: ok. Nothing else.")
|
||||
pane.wait_for_pane()
|
||||
pane_text = pane.wait_for_text(r"│")
|
||||
|
||||
footer_line = next((line for line in pane_text.splitlines() if "│" in line), "")
|
||||
footer_model = footer_line.split("│")[0].strip()
|
||||
picked_version = re.search(r"\d+(\.\d+)?", rows[0])
|
||||
if picked_version:
|
||||
assert picked_version.group(0) in footer_model, (
|
||||
f"picked {rows[0]!r} but the pane runs {footer_model!r} "
|
||||
f"(session {session_id}) — the launch substituted another model"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shape: codex · databricks kind (gateway)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_row2_codex_databricks_picker_is_populated_and_decorated(
|
||||
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
|
||||
) -> None:
|
||||
"""The codex gateway dropdown is non-empty with decorated names + default."""
|
||||
require_clis("codex")
|
||||
shaped_host("codex-databricks")
|
||||
with browser_ui(rig.base_url) as ui:
|
||||
ui.open_landing()
|
||||
ui.pick_agent("Codex")
|
||||
ui.open_landing_config()
|
||||
label = ui.wait_landing_model_label(r"Default \(.+\)|Models unavailable")
|
||||
options = ui.open_model_dropdown("new-chat-landing-config-model")
|
||||
|
||||
rows = [opt for opt in options if not opt.lower().startswith("default")]
|
||||
assert rows, "the codex databricks-kind picker is EMPTY — finding C's failure mode"
|
||||
assert any("GPT" in opt for opt in rows), (
|
||||
f"rows are not decorated with codex's display names: {options}"
|
||||
)
|
||||
assert re.fullmatch(r"Default \(.+\)", label), (
|
||||
f"no truthful default label on the codex gateway shape: {label!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_row5_host_boot_is_warm_and_nonblocking(
|
||||
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
|
||||
) -> None:
|
||||
"""Host boot: online immediately; picker rows present with no session."""
|
||||
require_clis("codex", "claude")
|
||||
started = time.monotonic()
|
||||
host_id = shaped_host("codex-databricks")
|
||||
online_after = time.monotonic() - started
|
||||
# Registration must not wait on the probes (claude's alone is ~6s+).
|
||||
assert online_after < 45.0, f"host took {online_after:.1f}s to register"
|
||||
|
||||
def _codex_rows() -> list | None:
|
||||
import httpx
|
||||
|
||||
try:
|
||||
payload = host_model_options(rig.base_url, host_id, "codex-native")
|
||||
except httpx.HTTPError:
|
||||
# A failed/erroring answer is a not-yet (or never) — keep polling;
|
||||
# the timeout records the red.
|
||||
return None
|
||||
return payload.get("models") or None
|
||||
|
||||
rows = wait_for(_codex_rows, timeout=120.0, what="boot-warmed codex rows")
|
||||
assert rows, "the boot probe never produced codex rows"
|
||||
|
||||
|
||||
def test_row11_gear_rows_equal_new_chat_rows(
|
||||
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
|
||||
) -> None:
|
||||
"""Picker↔gear parity: the in-session gear offers the new-chat rows."""
|
||||
require_clis("codex")
|
||||
host_id = shaped_host("codex-databricks")
|
||||
prelaunch = {
|
||||
row["id"]
|
||||
for row in host_model_options(rig.base_url, host_id, "codex-native").get("models", [])
|
||||
if isinstance(row, dict) and row.get("id")
|
||||
}
|
||||
with browser_ui(rig.base_url) as ui:
|
||||
ui.open_landing()
|
||||
session_id, pane = _create_session(ui, "Codex")
|
||||
pane.wait_for_text(r"›")
|
||||
ui.wait_composer_label(r"\S", timeout_s=90)
|
||||
ui.open_gear()
|
||||
gear = {row["id"] for row in ui.gear_rows()}
|
||||
|
||||
assert prelaunch, "pre-launch rows empty; parity is unmeasurable"
|
||||
missing = prelaunch - gear
|
||||
assert not missing, f"gear lacks pre-launch rows {missing} (gear={gear}, session {session_id})"
|
||||
|
||||
|
||||
def test_row7_codex_chip_matches_config_copy_and_pane(
|
||||
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
|
||||
) -> None:
|
||||
"""Codex Default create: chip, config-copy pin, and pane footer agree."""
|
||||
require_clis("codex")
|
||||
shaped_host("codex-databricks")
|
||||
with browser_ui(rig.base_url) as ui:
|
||||
ui.open_landing()
|
||||
session_id, pane = _create_session(ui, "Codex")
|
||||
pane_text = pane.wait_for_text(r"›")
|
||||
chip = ui.wait_composer_label(r"\S", timeout_s=90)
|
||||
|
||||
footer = next(
|
||||
(line.strip() for line in reversed(pane_text.splitlines()) if "default" in line.lower()),
|
||||
"",
|
||||
)
|
||||
pinned = codex_config_copy_model(session_id)
|
||||
assert pinned, "the launch left no model = pin in the private config copy"
|
||||
comparable = pinned.replace("databricks-", "").replace("-", "").replace(".", "").lower()
|
||||
chip_flat = chip.replace("-", "").replace(".", "").lower()
|
||||
assert comparable in chip_flat or chip_flat in comparable, (
|
||||
f"chip {chip!r} disagrees with the config-copy pin {pinned!r} "
|
||||
f"(pane footer: {footer!r}, session {session_id})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shape: codex · subscription
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_row3_codex_subscription_rows_come_from_the_account(
|
||||
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
|
||||
) -> None:
|
||||
"""The subscription picker shows the account's own catalog, marked default."""
|
||||
require_clis("codex")
|
||||
shaped_host("codex-subscription")
|
||||
with browser_ui(rig.base_url) as ui:
|
||||
ui.open_landing()
|
||||
ui.pick_agent("Codex")
|
||||
ui.open_landing_config()
|
||||
label = ui.landing_model_label()
|
||||
options = ui.open_model_dropdown("new-chat-landing-config-model")
|
||||
|
||||
rows = [opt for opt in options if not opt.lower().startswith("default")]
|
||||
assert rows, "the codex subscription picker is empty"
|
||||
assert any("GPT" in opt for opt in rows), (
|
||||
f"subscription rows are undecorated ids — the frozen tuple, not the "
|
||||
f"account catalog: {options}"
|
||||
)
|
||||
assert re.fullmatch(r"Default \(.+\)", label), (
|
||||
f"no truthful default label on the subscription shape: {label!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_row9_codex_subscription_default_first_turn_succeeds(
|
||||
rig: ModelFlowsRig, shaped_host: Callable[[str], str]
|
||||
) -> None:
|
||||
"""A Default subscription create's first turn returns a reply, not a 400."""
|
||||
require_clis("codex")
|
||||
shaped_host("codex-subscription")
|
||||
with browser_ui(rig.base_url) as ui:
|
||||
ui.open_landing()
|
||||
session_id, pane = _create_session(ui, "Codex")
|
||||
|
||||
def _turn_settled() -> str | None:
|
||||
text = pane.capture()
|
||||
if "invalid_request_error" in text or "not supported" in text:
|
||||
return text
|
||||
if re.search(r"^\s*•", text, re.M):
|
||||
return text
|
||||
return None
|
||||
|
||||
settled = wait_for(_turn_settled, timeout=120.0, what="the first turn to settle")
|
||||
|
||||
assert "invalid_request_error" not in settled and "not supported" not in settled, (
|
||||
f"the Default launch pinned a model the account cannot serve — the "
|
||||
f"stale-config-line class (finding B). Pane:\n{settled[-600:]}\n"
|
||||
f"(config-copy pin: {codex_config_copy_model(session_id)!r})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cold resume: the persisted pick survives the pane (claude + codex)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Host shape, wrapper agent, and pane-ready marker per harness.
|
||||
_COLD_RESUME_HARNESSES: dict[str, tuple[str, str, str]] = {
|
||||
"claude-native": ("claude-subscription", CLAUDE_NATIVE_AGENT_NAME, r"│"),
|
||||
"codex-native": ("codex-subscription", CODEX_NATIVE_AGENT_NAME, r"›"),
|
||||
}
|
||||
|
||||
|
||||
def _resume_override(harness: str, rows: list[dict[str, Any]]) -> str | None:
|
||||
"""
|
||||
The model to persist, spelled the way the harness itself reports it.
|
||||
|
||||
claude: a canonical Anthropic id no row spells exactly — the plain twin of
|
||||
a listed ``[1m]`` model. The endpoint serves it (the 1M marker is a request
|
||||
flag on the same model) and the pane reports exactly that id after a
|
||||
``/model`` to it; only the catalog's alias rows know its family.
|
||||
|
||||
codex: a non-default row id — codex has no alias layer, so the harness's
|
||||
own report IS a catalog id.
|
||||
|
||||
:param harness: ``"claude-native"`` or ``"codex-native"``.
|
||||
:param rows: The host's pre-launch catalog rows.
|
||||
:returns: The override to persist, or ``None`` when the catalog offers
|
||||
no such spelling.
|
||||
"""
|
||||
listed = {str(v) for row in rows for v in (row.get("id"), row.get("model")) if v}
|
||||
if harness == "claude-native":
|
||||
for row in rows:
|
||||
model = str(row.get("model") or "")
|
||||
if model.startswith("claude-") and model.endswith("[1m]") and model[:-4] not in listed:
|
||||
return model[:-4]
|
||||
return None
|
||||
for row in rows:
|
||||
if row.get("isDefault") is True or row.get("hidden") is True:
|
||||
continue
|
||||
row_id = row.get("id")
|
||||
if isinstance(row_id, str) and row_id:
|
||||
return row_id
|
||||
return None
|
||||
|
||||
|
||||
def _same_model(reported: object, wanted: str) -> bool:
|
||||
"""
|
||||
Whether a harness report names *wanted* (a dated suffix allowed)."""
|
||||
if not isinstance(reported, str) or not reported:
|
||||
return False
|
||||
lhs, rhs = reported.lower(), wanted.lower()
|
||||
return lhs == rhs or lhs.startswith(f"{rhs}-")
|
||||
|
||||
|
||||
@pytest.mark.timeout(900)
|
||||
@pytest.mark.parametrize("harness", sorted(_COLD_RESUME_HARNESSES))
|
||||
def test_row18_cold_resume_honors_the_persisted_model_override(
|
||||
rig: ModelFlowsRig,
|
||||
shaped_host: Callable[[str], str],
|
||||
harness: str,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
A session resumes on the persisted model the harness was already running.
|
||||
|
||||
The pane exits (idle reap, host restart) and the next message re-creates
|
||||
the terminal. The relaunch must pass the persisted model straight through
|
||||
— the host demonstrably served it moments earlier — instead of refusing
|
||||
it as "not in this host's current model list" because the catalog spells
|
||||
the same model differently (an alias row carries the family, the override
|
||||
the canonical id).
|
||||
"""
|
||||
shape, agent_name, ready = _COLD_RESUME_HARNESSES[harness]
|
||||
require_clis(harness.split("-")[0])
|
||||
host_id = shaped_host(shape)
|
||||
|
||||
def _rows() -> list[dict[str, Any]] | None:
|
||||
try:
|
||||
payload = host_model_options(rig.base_url, host_id, harness)
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
return payload.get("models") or None
|
||||
|
||||
rows = wait_for(_rows, timeout=180.0, what=f"the boot-warmed {harness} catalog")
|
||||
override = _resume_override(harness, rows)
|
||||
if override is None:
|
||||
pytest.skip(f"this {harness} catalog offers no spelling to exercise: {rows}")
|
||||
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
(workspace / "README.md").write_text("# cold resume workspace\n")
|
||||
pane = PaneWatcher()
|
||||
pane.arm()
|
||||
session_id = rest_create_session(
|
||||
rig.base_url,
|
||||
agent_name=agent_name,
|
||||
host_id=host_id,
|
||||
workspace=workspace,
|
||||
terminal_launch_args=bypass_args(harness),
|
||||
)
|
||||
rest_post_user_message(rig.base_url, session_id, "Reply with exactly: ok. Nothing else.")
|
||||
pane.wait_for_pane()
|
||||
pane.wait_for_text(ready)
|
||||
dismiss_blocking_dialogs(pane)
|
||||
|
||||
def _replies(at_least: int) -> Callable[[], int | None]:
|
||||
def _count() -> int | None:
|
||||
snapshot = session_snapshot(rig.base_url, session_id)
|
||||
assert snapshot.get("status") != "failed", (
|
||||
f"session {session_id} failed: "
|
||||
f"{snapshot.get('last_task_error') or snapshot.get('error')}"
|
||||
)
|
||||
count = assistant_message_count(snapshot)
|
||||
return count if count >= at_least else None
|
||||
|
||||
return _count
|
||||
|
||||
def _reported_override() -> str | None:
|
||||
reported = session_snapshot(rig.base_url, session_id).get("llm_model")
|
||||
return str(reported) if _same_model(reported, override) else None
|
||||
|
||||
wait_for(_replies(1), timeout=150.0, what="the first reply")
|
||||
|
||||
# The actor is a REPL ``/model``, a routing pin, or an API client: they
|
||||
# persist the harness's own spelling. The pane switches live — this host
|
||||
# serves the model — and reports it back verbatim.
|
||||
rest_patch_session(rig.base_url, session_id, model_override=override)
|
||||
rest_post_user_message(rig.base_url, session_id, "Reply with exactly: switched. Nothing else.")
|
||||
wait_for(_replies(2), timeout=150.0, what="the reply on the switched model")
|
||||
wait_for(_reported_override, timeout=60.0, what=f"the harness to report {override!r}")
|
||||
|
||||
# The pane exits; the next message is the resume.
|
||||
kill_pane(pane)
|
||||
resumed = PaneWatcher()
|
||||
resumed.arm()
|
||||
rest_post_user_message(rig.base_url, session_id, "Reply with exactly: back. Nothing else.")
|
||||
wait_for(_replies(3), timeout=240.0, what="the reply after the cold resume")
|
||||
|
||||
snapshot = session_snapshot(rig.base_url, session_id)
|
||||
assert snapshot.get("model_override") == override, (
|
||||
f"the resume rewrote the persisted pick: {snapshot.get('model_override')!r}"
|
||||
)
|
||||
assert _same_model(snapshot.get("llm_model"), override), (
|
||||
f"the resumed pane runs {snapshot.get('llm_model')!r}, not the persisted "
|
||||
f"{override!r} (session {session_id})"
|
||||
)
|
||||
resumed.wait_for_pane()
|
||||
pane_text = resumed.wait_for_text(ready)
|
||||
if harness == "codex-native":
|
||||
pinned = codex_config_copy_model(session_id)
|
||||
assert pinned == override, (
|
||||
f"the relaunch pinned {pinned!r} in the config copy, not {override!r}"
|
||||
)
|
||||
else:
|
||||
family = override.removeprefix("claude-").split("-")[0]
|
||||
footer = next((line for line in pane_text.splitlines() if "│" in line), "")
|
||||
assert family in footer.lower(), (
|
||||
f"the resumed pane footer {footer!r} does not name the {family} family "
|
||||
f"of {override!r} (session {session_id})"
|
||||
)
|
||||
@@ -352,7 +352,7 @@ def test_managed_runner_callback_authenticates_end_to_end(
|
||||
|
||||
monkeypatch.setenv("RUNNER_SERVER_URL", base_url)
|
||||
monkeypatch.setenv(RUNNER_TUNNEL_BINDING_TOKEN_ENV_VAR, _BINDING_TOKEN)
|
||||
monkeypatch.setattr("omnigent.cli_auth.load_token", lambda _url: None)
|
||||
monkeypatch.setattr("omnigent.cli_auth.load_token", lambda _url, **_kw: None)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.inner.databricks_executor._resolve_databricks_auth",
|
||||
_no_databricks_creds,
|
||||
@@ -423,7 +423,7 @@ def test_managed_runner_survives_mint_403_after_token_expiry(
|
||||
monkeypatch.setenv(RUNNER_TUNNEL_BINDING_TOKEN_ENV_VAR, _BINDING_TOKEN)
|
||||
monkeypatch.setenv(RUNNER_INITIAL_AUTH_TOKEN_ENV_VAR, owner_cookie)
|
||||
monkeypatch.setenv(RUNNER_DELEGATED_AUTH_ENV_VAR, "1")
|
||||
monkeypatch.setattr("omnigent.cli_auth.load_token", lambda _url: owner_cookie)
|
||||
monkeypatch.setattr("omnigent.cli_auth.load_token", lambda _url, **_kw: owner_cookie)
|
||||
|
||||
factory = _make_auth_token_factory()
|
||||
assert isinstance(factory, _InitialAuthTokenFactory)
|
||||
|
||||
@@ -722,14 +722,15 @@ def test_repl_approve_always_caches_for_later_turns(
|
||||
_read_pending(child, seconds=1.5)
|
||||
|
||||
# Turn 2: the auto-approved audit line must appear
|
||||
# AND the banner must NOT. After .expect() lands on
|
||||
# the elapsed-time marker, ``child.before`` holds the
|
||||
# full span from the last expect up to (but not
|
||||
# including) the match. That's the whole turn 2
|
||||
# output — banner (if any) + auto-approved line (if
|
||||
# any) + LLM response + elapsed-time prefix.
|
||||
# AND the banner must NOT. Sync on the scripted reply text
|
||||
# (deterministic content) instead of the racy ``· ready``
|
||||
# toolbar marker, which can match prematurely before the
|
||||
# turn's output renders. The scripted response only appears
|
||||
# after the LLM call completes, proving the full turn has
|
||||
# rendered (including the "auto-approved" audit line that
|
||||
# must precede it).
|
||||
child.send("follow up please" + "\r")
|
||||
_wait_for_turn_complete(child, timeout=45)
|
||||
child.expect("Following up as requested", timeout=45)
|
||||
turn_two_raw = child.before or ""
|
||||
if isinstance(turn_two_raw, bytes):
|
||||
turn_two_raw = turn_two_raw.decode("utf-8", errors="replace")
|
||||
|
||||
@@ -48,6 +48,7 @@ def _patch_session_as_claude_native(
|
||||
model_options: list[dict] | None = None,
|
||||
llm_model: str = "system.ai.claude-sonnet-5",
|
||||
host_asleep: bool = False,
|
||||
permission_mode: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Patch the browser's session snapshot into a claude-native response.
|
||||
|
||||
@@ -66,10 +67,14 @@ def _patch_session_as_claude_native(
|
||||
:param host_asleep: Shape the snapshot like a dormant resumable managed
|
||||
host (host-bound, resumable, aged past the startup grace); pair with
|
||||
:func:`_force_asleep_liveness` to drive the ``host_asleep`` state.
|
||||
:param permission_mode: Initial ``omnigent.claude_native.permission_mode``
|
||||
label value; required for the permission-mode picker to appear.
|
||||
:returns: Captured PATCH request bodies.
|
||||
"""
|
||||
latest_payload: dict | None = None
|
||||
patch_bodies: list[dict] = []
|
||||
# Mutable so the PATCH handler can update it without rebinding the name.
|
||||
cur_permission_mode: list[str | None] = [permission_mode]
|
||||
|
||||
def _handle(route: Route) -> None:
|
||||
nonlocal latest_payload
|
||||
@@ -90,13 +95,19 @@ def _patch_session_as_claude_native(
|
||||
payload = dict(latest_payload or {})
|
||||
if "model_override" in request_body:
|
||||
payload["model_override"] = request_body["model_override"]
|
||||
if "permission_mode" in request_body:
|
||||
cur_permission_mode[0] = request_body["permission_mode"]
|
||||
else:
|
||||
route.continue_()
|
||||
return
|
||||
|
||||
extra_labels: dict[str, str] = {}
|
||||
if cur_permission_mode[0] is not None:
|
||||
extra_labels["omnigent.claude_native.permission_mode"] = cur_permission_mode[0]
|
||||
payload["labels"] = {
|
||||
**payload.get("labels", {}),
|
||||
"omnigent.wrapper": "claude-code-native-ui",
|
||||
**extra_labels,
|
||||
}
|
||||
payload["harness"] = "claude"
|
||||
payload["llm_model"] = llm_model
|
||||
@@ -167,7 +178,13 @@ def test_claude_native_picker_updates_after_delayed_catalog(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""A live catalog event fills the modal and applies a compatible sticky alias."""
|
||||
"""A live catalog event fills the modal; the sticky stays a preference.
|
||||
|
||||
The catalog's arrival populates the picker rows and lets the label
|
||||
resolve the reported model to its display name. The cross-session
|
||||
sticky pick is never silently PATCHed onto the session — a request
|
||||
exists only when the user explicitly picks.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
catalog_state = {"ready": False}
|
||||
patch_bodies = _patch_session_as_claude_native(
|
||||
@@ -221,8 +238,10 @@ def test_claude_native_picker_updates_after_delayed_catalog(
|
||||
{"sessionId": session_id},
|
||||
)
|
||||
|
||||
expect(label).to_contain_text("Opus 4.10", timeout=10_000)
|
||||
assert {"model_override": "opus", "silent": True} in patch_bodies
|
||||
# The catalog labels the reported model; the sticky ("opus") is never
|
||||
# silently written as a request.
|
||||
expect(label).to_contain_text("Sonnet 5", timeout=10_000)
|
||||
assert not any("model_override" in body for body in patch_bodies)
|
||||
page.get_by_test_id("composer-config-gear").click()
|
||||
page.get_by_test_id("composer-config-model").click()
|
||||
expect(page.locator('[role="option"][data-model-id]')).to_have_count(len(_EXPECTED_ROWS))
|
||||
@@ -232,7 +251,11 @@ def test_claude_native_alias_selection_persists(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""Picking Opus PATCHes its alias and the label shows the live name.
|
||||
"""Picking Opus PATCHes its row id; the label keeps the reported model.
|
||||
|
||||
The pick is a REQUEST — it persists verbatim as ``model_override`` —
|
||||
while the composer label keeps rendering the harness's reported model
|
||||
until a confirmation report arrives.
|
||||
|
||||
:param page: Playwright page fixture.
|
||||
:param seeded_session: ``(base_url, session_id)`` for a real server-backed
|
||||
@@ -261,8 +284,9 @@ def test_claude_native_alias_selection_persists(
|
||||
page.get_by_test_id("composer-config-save").click()
|
||||
|
||||
assert patch_bodies[-1] == {"model_override": "opus"}
|
||||
# The read-only composer label reflects the new pick.
|
||||
expect(page.get_by_test_id("composer-model-effort-label")).to_contain_text("Opus 4.10")
|
||||
# The read-only composer label keeps the reported model — a request is
|
||||
# not truth until the harness confirms it.
|
||||
expect(page.get_by_test_id("composer-model-effort-label")).to_contain_text("Sonnet 5")
|
||||
|
||||
|
||||
def _force_asleep_liveness(page: Page, session_id: str) -> None:
|
||||
@@ -608,11 +632,183 @@ def test_composer_model_label_never_shows_the_previous_sessions_model(
|
||||
)
|
||||
|
||||
|
||||
def test_claude_native_picker_prefers_session_override_over_sticky_model(
|
||||
def test_claude_model_label_never_claims_a_version_the_catalog_didnt_give(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""The active row follows the session override, not another session's pick."""
|
||||
"""The label renders the reported model — raw before the catalog labels it.
|
||||
|
||||
The chip renders only the harness's reported model: the raw wire id
|
||||
until the catalog can name it, the catalog's display name after — and
|
||||
at no point a version the catalog didn't give (the old fallback said
|
||||
"Sonnet 4.6" while the catalog resolves to Sonnet 5). Every label the
|
||||
page ever paints is recorded, so a transient wrong label can't hide
|
||||
from a retrying ``expect()``.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
catalog_state = {"ready": False}
|
||||
one_m_catalog = [
|
||||
*_MODEL_OPTIONS,
|
||||
{
|
||||
"id": "sonnet[1m]",
|
||||
"model": "system.ai.claude-sonnet-5[1m]",
|
||||
"displayName": "Sonnet 5 (1M context)",
|
||||
"isDefault": False,
|
||||
},
|
||||
]
|
||||
_patch_session_as_claude_native(
|
||||
page,
|
||||
session_id,
|
||||
model_override="sonnet[1m]",
|
||||
catalog_state=catalog_state,
|
||||
model_options=one_m_catalog,
|
||||
llm_model="system.ai.claude-sonnet-5[1m]",
|
||||
)
|
||||
stream_script = """
|
||||
(() => {
|
||||
const sessionId = __SESSION_ID__;
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const streamPath = `/v1/sessions/${sessionId}/stream`;
|
||||
if (new URL(url, window.location.origin).pathname === streamPath) {
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
window.__claudeModelStreamController = controller;
|
||||
},
|
||||
});
|
||||
return Promise.resolve(new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}));
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
})()
|
||||
""".replace("__SESSION_ID__", json.dumps(session_id))
|
||||
page.add_init_script(stream_script)
|
||||
page.add_init_script(_LABEL_RECORDER)
|
||||
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
|
||||
# Pre-catalog: the reported wire id renders raw — honest over pretty.
|
||||
label = page.get_by_test_id("composer-model-effort-label")
|
||||
expect(label).to_contain_text("system.ai.claude-sonnet-5[1m]", timeout=15_000)
|
||||
page.wait_for_function("window.__claudeModelStreamController !== undefined")
|
||||
|
||||
# The catalog lands: its display name supersedes the fallback.
|
||||
catalog_state["ready"] = True
|
||||
page.evaluate(
|
||||
"""
|
||||
({ sessionId }) => {
|
||||
const frame = `event: session.model_options\ndata: ${JSON.stringify({
|
||||
conversation_id: sessionId,
|
||||
})}\n\n`;
|
||||
window.__claudeModelStreamController.enqueue(new TextEncoder().encode(frame));
|
||||
}
|
||||
""",
|
||||
{"sessionId": session_id},
|
||||
)
|
||||
expect(label).to_contain_text("Sonnet 5 (1M context)", timeout=10_000)
|
||||
|
||||
log = page.evaluate("window.__modelLabelLog")
|
||||
labels = [entry["text"] for entry in log if entry["text"]]
|
||||
assert labels, "the recorder never saw a composer label"
|
||||
offending = [text for text in labels if "4.6" in text]
|
||||
assert not offending, (
|
||||
f"the composer painted a version the catalog didn't give: {offending} "
|
||||
f"(full label sequence: {labels}). Labels render the reported model — "
|
||||
"raw before the catalog names it, the catalog's name after — never an "
|
||||
"invented version."
|
||||
)
|
||||
_screenshot(page, "one-m-label-settled")
|
||||
|
||||
|
||||
def test_union_catalog_pick_patches_the_row_id_verbatim(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""Picking a probe-contributed row saves exactly that row's id.
|
||||
|
||||
A gateway session's catalog is the configured∪probe union: pinned
|
||||
family rows carrying gateway model ids next to probe rows like
|
||||
``sonnet[1m]``. Picking the probe row must PATCH its id verbatim —
|
||||
the id is the launch contract the runner types into the pane, so any
|
||||
client-side rewrite here switches the session to a model the user
|
||||
did not choose (the bug this guards showed a Fable pick landing on
|
||||
Opus; the web layer was innocent, and must stay that way).
|
||||
|
||||
:param page: Playwright page fixture.
|
||||
:param seeded_session: ``(base_url, session_id)`` for a real
|
||||
server-backed session; the browser snapshot is patched to a
|
||||
claude-native shape with the union catalog.
|
||||
:returns: None.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
union_catalog = [
|
||||
{
|
||||
"id": "opus",
|
||||
"model": "databricks-claude-opus-5",
|
||||
"displayName": "Opus 5",
|
||||
"isDefault": True,
|
||||
},
|
||||
{
|
||||
"id": "sonnet",
|
||||
"model": "databricks-claude-sonnet-5",
|
||||
"displayName": "Sonnet 5",
|
||||
"isDefault": False,
|
||||
},
|
||||
{
|
||||
"id": "sonnet[1m]",
|
||||
"model": "databricks-claude-sonnet-5[1m]",
|
||||
"displayName": "Sonnet 5 (1M context)",
|
||||
},
|
||||
]
|
||||
# No fixture-pinned model_override: the route fake would force it back
|
||||
# onto every response, including the PATCH echo the store adopts. The
|
||||
# bound ``llm_model`` already implicitly selects the sonnet row.
|
||||
patch_bodies = _patch_session_as_claude_native(
|
||||
page,
|
||||
session_id,
|
||||
model_options=union_catalog,
|
||||
llm_model="databricks-claude-sonnet-5",
|
||||
)
|
||||
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
|
||||
gear = page.get_by_test_id("composer-config-gear")
|
||||
expect(gear).to_be_visible(timeout=15_000)
|
||||
gear.click()
|
||||
page.get_by_test_id("composer-config-model").click()
|
||||
bracket_row = page.locator('[role="option"][data-model-id="sonnet[1m]"]')
|
||||
expect(bracket_row).to_contain_text("Sonnet 5 (1M context)")
|
||||
bracket_row.click()
|
||||
with page.expect_response(
|
||||
lambda response: (
|
||||
response.request.method == "PATCH"
|
||||
and urlparse(response.url).path == f"/v1/sessions/{session_id}"
|
||||
and response.status == 200
|
||||
)
|
||||
):
|
||||
page.get_by_test_id("composer-config-save").click()
|
||||
|
||||
assert patch_bodies[-1] == {"model_override": "sonnet[1m]"}
|
||||
# The label keeps the reported model ("Sonnet 5" — the bound
|
||||
# databricks-claude-sonnet-5); the request flips nothing until the
|
||||
# harness confirms.
|
||||
expect(page.get_by_test_id("composer-model-effort-label")).to_contain_text("Sonnet 5")
|
||||
|
||||
|
||||
def test_claude_native_picker_highlights_the_reported_model(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""The active row follows the reported model — not the request or sticky.
|
||||
|
||||
The session carries a pending request ("opus") and another session's
|
||||
sticky pick ("haiku"), but the pane reports Sonnet 5: only its row may
|
||||
read as active.
|
||||
"""
|
||||
page.add_init_script("window.localStorage.setItem('omnigent.picker.model', 'haiku')")
|
||||
base_url, session_id = seeded_session
|
||||
_patch_session_as_claude_native(page, session_id, model_override="opus")
|
||||
@@ -624,9 +820,60 @@ def test_claude_native_picker_prefers_session_override_over_sticky_model(
|
||||
gear.click()
|
||||
page.get_by_test_id("composer-config-model").click()
|
||||
|
||||
expect(page.locator('[role="option"][data-model-id="opus"]')).to_have_attribute(
|
||||
expect(page.locator('[role="option"][data-model-id="sonnet"]')).to_have_attribute(
|
||||
"data-active", "true"
|
||||
)
|
||||
expect(page.locator('[role="option"][data-model-id="opus"]')).not_to_have_attribute(
|
||||
"data-active", "true"
|
||||
)
|
||||
expect(page.locator('[role="option"][data-model-id="haiku"]')).not_to_have_attribute(
|
||||
"data-active", "true"
|
||||
)
|
||||
|
||||
|
||||
def test_claude_native_permission_mode_switch_persists(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""Picking a permission mode in the gear modal PATCHes the server.
|
||||
|
||||
Selecting "Auto" sends ``{"permission_mode": "auto"}`` to
|
||||
``PATCH /v1/sessions/{id}``, exercising the new in-chat permission-mode
|
||||
control introduced by the claude-web-auto-mode feature.
|
||||
|
||||
:param page: Playwright page fixture.
|
||||
:param seeded_session: ``(base_url, session_id)`` for a real server-backed
|
||||
session; the browser snapshot is patched to a claude-native session
|
||||
already in ``default`` (Manual) mode.
|
||||
:returns: None.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
patch_bodies = _patch_session_as_claude_native(page, session_id, permission_mode="default")
|
||||
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
|
||||
gear = page.get_by_test_id("composer-config-gear")
|
||||
expect(gear).to_be_visible(timeout=15_000)
|
||||
gear.click()
|
||||
|
||||
# The permission-mode picker is visible for claude-native sessions whose
|
||||
# current mode is known (non-empty label).
|
||||
perm = page.get_by_test_id("composer-config-permission-mode")
|
||||
expect(perm).to_be_visible()
|
||||
perm.click()
|
||||
|
||||
# Located by data attribute, not accessible name: each option renders its
|
||||
# label and description together, so the name is never the bare label.
|
||||
page.locator('[role="option"][data-permission-mode="auto"]').click()
|
||||
|
||||
# Save commits the draft and fires the PATCH.
|
||||
with page.expect_response(
|
||||
lambda response: (
|
||||
response.request.method == "PATCH"
|
||||
and urlparse(response.url).path == f"/v1/sessions/{session_id}"
|
||||
and response.status == 200
|
||||
)
|
||||
):
|
||||
page.get_by_test_id("composer-config-save").click()
|
||||
|
||||
assert patch_bodies[-1] == {"permission_mode": "auto"}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from playwright.sync_api import Page, Route, expect
|
||||
@@ -190,3 +191,136 @@ def test_codex_native_plan_mode_toggle_uses_codex_session_patch(
|
||||
expect(plan_toggle).to_have_attribute("aria-label", "Enter Plan mode")
|
||||
expect(plan_toggle).to_have_attribute("aria-pressed", "false")
|
||||
expect(page.get_by_test_id("composer-plan-mode")).to_have_count(0)
|
||||
|
||||
|
||||
_PRE_CATALOG_HOST_ID = "host_pre_catalog_probe"
|
||||
_HOST_PROBE_ROWS = [
|
||||
{
|
||||
"id": "gpt-5.6-luna",
|
||||
"model": "gpt-5.6-luna",
|
||||
"displayName": "GPT-5.6-Luna",
|
||||
"defaultReasoningEffort": "medium",
|
||||
"supportedReasoningEfforts": [
|
||||
{"reasoningEffort": "low", "description": "Low"},
|
||||
{"reasoningEffort": "medium", "description": "Medium"},
|
||||
{"reasoningEffort": "xhigh", "description": "Extra high"},
|
||||
],
|
||||
"isDefault": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def _patch_precatalog_codex_session_on_host(page: Page, session_id: str) -> None:
|
||||
"""Shape ``session_id`` as a host-bound codex session with no catalog yet.
|
||||
|
||||
The snapshot's ``model_options`` stay empty for the whole test — the
|
||||
state a fresh session is in while codex app-server boots — while the
|
||||
host's pre-launch probe route serves cached rows. Liveness is pinned
|
||||
online so the gear stays enabled despite the fake host id.
|
||||
|
||||
:param page: Playwright page before navigation.
|
||||
:param session_id: Session id to patch.
|
||||
"""
|
||||
|
||||
def _patch_snapshot(route: Route) -> None:
|
||||
request = route.request
|
||||
if request.method != "GET" or urlparse(request.url).path != f"/v1/sessions/{session_id}":
|
||||
route.continue_()
|
||||
return
|
||||
response = route.fetch()
|
||||
payload = response.json()
|
||||
payload["labels"] = {
|
||||
**payload.get("labels", {}),
|
||||
"omnigent.wrapper": "codex-native-ui",
|
||||
}
|
||||
payload["harness"] = "codex"
|
||||
payload["llm_model"] = "gpt-5.6-luna"
|
||||
payload["model_options"] = []
|
||||
payload["host_id"] = _PRE_CATALOG_HOST_ID
|
||||
route.fulfill(
|
||||
status=200,
|
||||
headers={**response.headers, "content-type": "application/json"},
|
||||
body=json.dumps(payload),
|
||||
)
|
||||
|
||||
def _serve_host_probe(route: Route) -> None:
|
||||
route.fulfill(
|
||||
status=200,
|
||||
headers={"content-type": "application/json"},
|
||||
body=json.dumps({"models": _HOST_PROBE_ROWS}),
|
||||
)
|
||||
|
||||
def _force_online_health(route: Route) -> None:
|
||||
request = route.request
|
||||
if request.method != "GET" or urlparse(request.url).path != "/health":
|
||||
route.continue_()
|
||||
return
|
||||
response = route.fetch()
|
||||
payload = response.json()
|
||||
online = {"runner_online": True, "host_online": True}
|
||||
if isinstance(payload.get("sessions"), dict):
|
||||
payload["sessions"][session_id] = online
|
||||
if isinstance(payload.get("session"), dict):
|
||||
payload["session"] = {**payload["session"], **online}
|
||||
route.fulfill(
|
||||
status=200,
|
||||
headers={**response.headers, "content-type": "application/json"},
|
||||
body=json.dumps(payload),
|
||||
)
|
||||
|
||||
page.route("**/v1/sessions/**", _patch_snapshot)
|
||||
page.route(
|
||||
f"**/v1/hosts/{_PRE_CATALOG_HOST_ID}/harnesses/codex-native/model-options",
|
||||
_serve_host_probe,
|
||||
)
|
||||
page.route(re.compile(r"/health(\?|$)"), _force_online_health)
|
||||
|
||||
|
||||
def test_codex_gear_offers_host_probe_rows_before_the_session_catalog(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""Model and Effort never wait on the session's own catalog resolving.
|
||||
|
||||
A fresh codex session's catalog only arrives once codex app-server
|
||||
answers ``model/list`` (seconds to ~15s cold) — until then the gear used
|
||||
to show a sparse Model row and no Effort row at all. With the session
|
||||
catalog empty, the gear rides the host's cached pre-launch probe rows:
|
||||
the Model menu lists them and the Effort menu offers their reasoning
|
||||
efforts immediately. The session's own catalog supersedes them when it
|
||||
lands (covered by the raw-metadata test above).
|
||||
|
||||
:param page: Playwright page fixture.
|
||||
:param seeded_session: ``(base_url, session_id)`` for a real
|
||||
server-backed session; the browser view is patched to a host-bound
|
||||
codex-native shape whose catalog never resolves.
|
||||
:returns: None.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
_patch_precatalog_codex_session_on_host(page, session_id)
|
||||
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
|
||||
gear = page.get_by_test_id("composer-config-gear")
|
||||
expect(gear).to_be_visible(timeout=15_000)
|
||||
gear.click()
|
||||
expect(page.get_by_test_id("composer-config-modal")).to_be_visible()
|
||||
|
||||
# The Effort row is present although the session catalog is still empty.
|
||||
effort_trigger = page.get_by_test_id("composer-config-effort")
|
||||
expect(effort_trigger).to_be_visible(timeout=10_000)
|
||||
|
||||
# The Model menu lists the host probe row under its display name.
|
||||
page.get_by_test_id("composer-config-model").click()
|
||||
model_row = page.locator('[role="option"][data-model-id="gpt-5.6-luna"]')
|
||||
expect(model_row).to_be_visible()
|
||||
expect(model_row).to_contain_text("GPT-5.6-Luna")
|
||||
# Re-select the current model to close the listbox without sending
|
||||
# Escape to the surrounding dialog.
|
||||
model_row.click()
|
||||
expect(model_row).to_be_hidden()
|
||||
|
||||
# The Effort menu offers exactly the host row's reasoning efforts.
|
||||
effort_trigger.click()
|
||||
for level in ("low", "medium", "xhigh"):
|
||||
expect(page.locator(f'[role="option"][data-effort-level="{level}"]')).to_be_visible()
|
||||
|
||||
@@ -0,0 +1,244 @@
|
||||
"""E2E (hermetic): every native harness renders its session without crashing.
|
||||
|
||||
A crash-safety matrix over ALL native model-picker harnesses. Each case shapes
|
||||
the browser's view of one seeded session into that harness's snapshot — using
|
||||
the harness's realistic ``model_options`` shape, including rows with an
|
||||
explicit ``model: null`` (what cursor/kiro/opencode actually send on the wire,
|
||||
typed ``model?: string``) — then drives the real SPA and asserts:
|
||||
|
||||
* the composer renders (the page does not blank),
|
||||
* no uncaught page error / null-deref console error fires, and
|
||||
* opening the gear renders the model control over those rows.
|
||||
|
||||
Why this exists: a null-``model`` cursor row once reached a model-id fold that
|
||||
called ``.trim()`` on null and blanked the whole chat page. The earlier
|
||||
per-harness tests used rows with ``model`` OMITTED (``undefined``), which a
|
||||
``!== undefined`` guard tolerated, so the ``null`` shape slipped through. This
|
||||
matrix pins the crash-safe render for every harness, with the null shape.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
from playwright.sync_api import Page, Route, expect
|
||||
|
||||
from tests.e2e_ui.chat.test_model_flows_contract import _install_stream_controller
|
||||
|
||||
|
||||
def _patch_session_as_harness(
|
||||
page: Page,
|
||||
session_id: str,
|
||||
*,
|
||||
wrapper: str,
|
||||
harness: str,
|
||||
llm_model: str,
|
||||
model_options: list[dict],
|
||||
model_override: str | None = None,
|
||||
) -> None:
|
||||
"""Shape the browser's view of *session_id* into a *harness* snapshot.
|
||||
|
||||
Patches only ``GET`` / ``PATCH /v1/sessions/{session_id}`` as the browser
|
||||
sees it (the same route-patch idiom as ``test_claude_model_picker.py``),
|
||||
injecting the harness wrapper label plus its ``model_options`` verbatim.
|
||||
|
||||
:param page: Playwright page before navigation.
|
||||
:param session_id: Seeded session id to reshape.
|
||||
:param wrapper: ``omnigent.wrapper`` label, e.g. ``"cursor-native-ui"``.
|
||||
:param harness: Harness family, e.g. ``"cursor"``.
|
||||
:param llm_model: Reported model id for the session.
|
||||
:param model_options: Native picker rows exposed on the snapshot.
|
||||
:param model_override: Optional session-scoped model override to expose.
|
||||
"""
|
||||
latest_payload: dict | None = None
|
||||
|
||||
def _handle(route: Route) -> None:
|
||||
nonlocal latest_payload
|
||||
request = route.request
|
||||
if urlparse(request.url).path != f"/v1/sessions/{session_id}":
|
||||
route.continue_()
|
||||
return
|
||||
headers = {"content-type": "application/json"}
|
||||
if request.method == "GET":
|
||||
response = route.fetch()
|
||||
payload = response.json()
|
||||
headers = {**response.headers, **headers}
|
||||
elif request.method == "PATCH":
|
||||
request_body = json.loads(request.post_data or "{}")
|
||||
payload = dict(latest_payload or {})
|
||||
if "model_override" in request_body:
|
||||
payload["model_override"] = request_body["model_override"]
|
||||
else:
|
||||
route.continue_()
|
||||
return
|
||||
payload["labels"] = {**payload.get("labels", {}), "omnigent.wrapper": wrapper}
|
||||
payload["harness"] = harness
|
||||
payload["llm_model"] = llm_model
|
||||
payload["model_options"] = model_options
|
||||
if model_override is not None:
|
||||
payload["model_override"] = model_override
|
||||
latest_payload = dict(payload)
|
||||
route.fulfill(status=200, headers=headers, body=json.dumps(payload))
|
||||
|
||||
page.route("**/v1/sessions/**", _handle)
|
||||
|
||||
|
||||
# One case per native model-picker harness. The vendor-owns-model harnesses
|
||||
# (cursor / kiro / opencode) carry rows with an explicit ``model: null`` — the
|
||||
# exact wire shape that once blanked the page — plus, for cursor, a hostile
|
||||
# ``id: None`` row to lock the fold's null-guards on both fields.
|
||||
_HARNESS_CASES = [
|
||||
pytest.param(
|
||||
"claude-code-native-ui",
|
||||
"claude",
|
||||
"claude-opus-4-8[1m]",
|
||||
None,
|
||||
[
|
||||
{
|
||||
"id": "opus",
|
||||
"model": "claude-opus-4-8[1m]",
|
||||
"displayName": "Opus 4.8 (1M context)",
|
||||
"isDefault": True,
|
||||
},
|
||||
{"id": "sonnet", "model": "claude-sonnet-5", "displayName": "Sonnet 5"},
|
||||
],
|
||||
id="claude",
|
||||
),
|
||||
pytest.param(
|
||||
"codex-native-ui",
|
||||
"codex",
|
||||
"gpt-5.6-terra",
|
||||
None,
|
||||
[
|
||||
{
|
||||
"id": "gpt-5.6-terra",
|
||||
"model": "gpt-5.6-terra",
|
||||
"displayName": "GPT-5.6-Terra",
|
||||
"isDefault": True,
|
||||
"defaultReasoningEffort": "medium",
|
||||
"supportedReasoningEfforts": [
|
||||
{"reasoningEffort": "low", "description": "Low"},
|
||||
{"reasoningEffort": "medium", "description": "Medium"},
|
||||
],
|
||||
},
|
||||
{"id": "gpt-5.6-luna", "model": "gpt-5.6-luna", "displayName": "GPT-5.6-Luna"},
|
||||
],
|
||||
id="codex",
|
||||
),
|
||||
pytest.param(
|
||||
"cursor-native-ui",
|
||||
"cursor",
|
||||
"default",
|
||||
"databricks-claude-opus-4-8",
|
||||
[
|
||||
{"id": "auto", "model": None, "displayName": "Auto"},
|
||||
{"id": "gpt-5.3-codex", "model": None, "displayName": "Codex 5.3"},
|
||||
{"id": "claude-opus-4-8", "model": None, "displayName": "Claude Opus 4.8"},
|
||||
{"id": "composer-2.5", "model": None, "displayName": "Composer 2.5"},
|
||||
# Hostile: both id and model null must not throw in the fold.
|
||||
{"id": None, "model": None, "displayName": "Broken row"},
|
||||
],
|
||||
id="cursor",
|
||||
),
|
||||
pytest.param(
|
||||
"kiro-native-ui",
|
||||
"kiro",
|
||||
"auto",
|
||||
"databricks-claude-haiku-4-5",
|
||||
[
|
||||
{"id": "auto", "model": None, "displayName": "Auto", "isDefault": True},
|
||||
{"id": "claude-haiku-4.5", "model": None, "displayName": "Claude Haiku 4.5"},
|
||||
],
|
||||
id="kiro",
|
||||
),
|
||||
pytest.param(
|
||||
"opencode-native-ui",
|
||||
"opencode",
|
||||
"anthropic/claude-sonnet-4-6",
|
||||
"databricks-claude-opus-4-8",
|
||||
[
|
||||
{
|
||||
"id": "anthropic/claude-sonnet-4-6",
|
||||
"model": None,
|
||||
"displayName": "anthropic/claude-sonnet-4-6",
|
||||
},
|
||||
{"id": "openai/gpt-5", "model": None, "displayName": "openai/gpt-5"},
|
||||
],
|
||||
id="opencode",
|
||||
),
|
||||
pytest.param(
|
||||
"pi-native-ui",
|
||||
"pi",
|
||||
"omnigent-openai/system.ai.gpt-5-6-sol",
|
||||
None,
|
||||
[
|
||||
{
|
||||
"id": "omnigent-openai/system.ai.gpt-5-6-sol",
|
||||
"model": "omnigent-openai/system.ai.gpt-5-6-sol",
|
||||
"displayName": "system.ai.gpt-5-6-sol",
|
||||
}
|
||||
],
|
||||
id="pi",
|
||||
),
|
||||
]
|
||||
|
||||
_CRASH_MARKERS = ("Cannot read properties of null", "reading 'trim'", "is not a function")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("wrapper", "harness", "llm_model", "model_override", "model_options"), _HARNESS_CASES
|
||||
)
|
||||
def test_harness_session_renders_without_crashing(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
wrapper: str,
|
||||
harness: str,
|
||||
llm_model: str,
|
||||
model_override: str | None,
|
||||
model_options: list[dict],
|
||||
) -> None:
|
||||
"""Every native harness renders its session + gear without a page crash.
|
||||
|
||||
Loads a session shaped as *harness* (with its realistic rows, including
|
||||
explicit ``model: null``), then asserts the composer renders, no uncaught
|
||||
error fires, and the gear's model control renders over those rows.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
errors: list[str] = []
|
||||
page.on("pageerror", lambda exc: errors.append(str(exc)))
|
||||
page.on(
|
||||
"console",
|
||||
lambda msg: errors.append(msg.text) if msg.type == "error" else None,
|
||||
)
|
||||
|
||||
_install_stream_controller(page, session_id)
|
||||
_patch_session_as_harness(
|
||||
page,
|
||||
session_id,
|
||||
wrapper=wrapper,
|
||||
harness=harness,
|
||||
llm_model=llm_model,
|
||||
model_options=model_options,
|
||||
model_override=model_override,
|
||||
)
|
||||
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
|
||||
# The composer renders → the page did not blank on a render throw.
|
||||
expect(page.get_by_test_id("composer-config-gear")).to_be_visible(timeout=15_000)
|
||||
# The status label runs the model-id fold over the rows.
|
||||
expect(page.get_by_test_id("composer-model-effort-label")).to_have_count(1)
|
||||
|
||||
# Opening the gear renders the model control, folding every row (the exact
|
||||
# path the null-``model`` cursor crash took).
|
||||
page.get_by_test_id("composer-config-gear").click()
|
||||
expect(page.get_by_test_id("composer-config-modal")).to_be_visible(timeout=10_000)
|
||||
expect(page.get_by_test_id("composer-config-model")).to_be_visible()
|
||||
page.get_by_test_id("composer-config-model").click()
|
||||
# The option list renders without throwing (rows folded into the picker).
|
||||
expect(page.locator('[role="option"]').first).to_be_visible(timeout=10_000)
|
||||
|
||||
crash = [e for e in errors if any(m in e for m in _CRASH_MARKERS)]
|
||||
assert not crash, f"{harness}: render crashed with {crash!r} (all errors: {errors!r})"
|
||||
@@ -0,0 +1,274 @@
|
||||
"""E2E (hermetic): in-session model contract per model-flows-design.md §10.1.
|
||||
|
||||
Rows 12–15's hermetic halves. Every test drives the real SPA over the spawned
|
||||
server, with the browser's view of ONE session shaped into a claude-native
|
||||
snapshot (the same route-patch idiom as ``test_claude_model_picker.py``) and
|
||||
SSE frames injected through a captured stream controller — the harness
|
||||
boundary, not the driven surface.
|
||||
|
||||
The assertions encode the DESIGN's target behavior:
|
||||
|
||||
- Row 12 (guard): the gear renders its model rows from the already-held
|
||||
snapshot; no click-time fetch is load-bearing.
|
||||
- Row 13: an off-catalog reported model renders as its own appended raw row,
|
||||
highlighted; catalog rows highlight only on exact match. Red until step 3.
|
||||
- Row 14: a pick renders as pending — the chip must NOT flip before the
|
||||
harness's own report (the ``session.model`` event) confirms. Red until
|
||||
step 6.
|
||||
- Row 15: a failed switch surfaces the ``model_change_not_applied`` error and
|
||||
the chip keeps reporting the pane's real model. Red until step 6.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from playwright.sync_api import Page, Route, expect
|
||||
|
||||
from tests.e2e_ui.chat.test_claude_model_picker import (
|
||||
_MODEL_OPTIONS,
|
||||
_patch_session_as_claude_native,
|
||||
)
|
||||
|
||||
_STREAM_CONTROLLER = """
|
||||
(() => {
|
||||
const sessionId = __SESSION_ID__;
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = (input, init) => {
|
||||
const url = typeof input === "string" ? input : input.url;
|
||||
const streamPath = `/v1/sessions/${sessionId}/stream`;
|
||||
if (new URL(url, window.location.origin).pathname === streamPath) {
|
||||
const body = new ReadableStream({
|
||||
start(controller) {
|
||||
window.__mfStreamController = controller;
|
||||
},
|
||||
});
|
||||
return Promise.resolve(new Response(body, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
}));
|
||||
}
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
})()
|
||||
"""
|
||||
|
||||
|
||||
def _install_stream_controller(page: Page, session_id: str) -> None:
|
||||
"""Capture the session's SSE stream so tests can push frames."""
|
||||
page.add_init_script(_STREAM_CONTROLLER.replace("__SESSION_ID__", json.dumps(session_id)))
|
||||
|
||||
|
||||
def _push_sse(page: Page, event: str, payload: dict) -> None:
|
||||
"""Push one SSE frame through the captured stream controller."""
|
||||
page.wait_for_function("window.__mfStreamController !== undefined")
|
||||
page.evaluate(
|
||||
"""
|
||||
({ event, payload }) => {
|
||||
const frame = `event: ${event}\\ndata: ${JSON.stringify(payload)}\\n\\n`;
|
||||
window.__mfStreamController.enqueue(new TextEncoder().encode(frame));
|
||||
}
|
||||
""",
|
||||
{"event": event, "payload": payload},
|
||||
)
|
||||
|
||||
|
||||
def _open_gear_model_dropdown(page: Page) -> None:
|
||||
gear = page.get_by_test_id("composer-config-gear")
|
||||
expect(gear).to_be_visible(timeout=15_000)
|
||||
gear.click()
|
||||
page.get_by_test_id("composer-config-model").click()
|
||||
|
||||
|
||||
def test_row12_gear_rows_render_without_any_click_time_fetch(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""The gear's model rows come from the held snapshot, not a click fetch.
|
||||
|
||||
After the page settles, EVERY further ``/v1`` request is aborted; opening
|
||||
the gear must still list the catalog rows. (A regression guard — green on
|
||||
the current code — pinning the design's "clicking the gear fetches
|
||||
nothing load-bearing".)
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
_patch_session_as_claude_native(page, session_id)
|
||||
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
expect(page.get_by_test_id("composer-config-gear")).to_be_visible(timeout=15_000)
|
||||
# Let the initial snapshot/queries settle before cutting the network.
|
||||
page.wait_for_timeout(1_000)
|
||||
|
||||
def _abort_api(route: Route) -> None:
|
||||
if "/v1/" in urlparse(route.request.url).path:
|
||||
route.abort()
|
||||
else:
|
||||
route.continue_()
|
||||
|
||||
page.route("**/v1/**", _abort_api)
|
||||
|
||||
_open_gear_model_dropdown(page)
|
||||
rows = page.locator('[role="option"][data-model-id]')
|
||||
expect(rows).to_have_count(len(_MODEL_OPTIONS))
|
||||
|
||||
|
||||
def test_row13_off_catalog_reported_model_appends_and_highlights_exactly(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""An off-catalog reported model is its own highlighted raw row.
|
||||
|
||||
The session reports ``claude-opus-4-8[1m]`` (a settings-file pin) while
|
||||
the catalog holds only alias rows resolving to other models. The design:
|
||||
the picker appends the reported value as its own row, highlights it, and
|
||||
highlights NO catalog row — never relabeling the report onto a
|
||||
same-family row of a different generation. The chip shows the raw
|
||||
reported value.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
reported = "claude-opus-4-8[1m]"
|
||||
_patch_session_as_claude_native(
|
||||
page,
|
||||
session_id,
|
||||
llm_model=reported,
|
||||
)
|
||||
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
|
||||
chip = page.get_by_test_id("composer-model-effort-label")
|
||||
expect(chip).to_contain_text(reported, timeout=15_000)
|
||||
|
||||
_open_gear_model_dropdown(page)
|
||||
appended = page.locator(f'[role="option"][data-model-id="{reported}"]')
|
||||
expect(appended).to_have_count(1)
|
||||
expect(appended).to_have_attribute("data-active", "true")
|
||||
# The same-family catalog row (Opus 4.10) must NOT claim the highlight.
|
||||
expect(page.locator('[role="option"][data-model-id="opus"]')).not_to_have_attribute(
|
||||
"data-active", "true"
|
||||
)
|
||||
|
||||
|
||||
def test_row13_exact_match_highlights_the_catalog_row(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""A reported model exactly matching a row's ``model`` highlights that row."""
|
||||
base_url, session_id = seeded_session
|
||||
_patch_session_as_claude_native(
|
||||
page,
|
||||
session_id,
|
||||
llm_model="system.ai.claude-sonnet-5",
|
||||
)
|
||||
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
_open_gear_model_dropdown(page)
|
||||
expect(page.locator('[role="option"][data-model-id="sonnet"]')).to_have_attribute(
|
||||
"data-active", "true"
|
||||
)
|
||||
|
||||
|
||||
def test_row14_pick_stays_pending_until_the_harness_confirms(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""The chip flips only on the harness's own report, never on the pick.
|
||||
|
||||
Saving a pick PATCHes the request, but the composer chip must keep the
|
||||
reported model (with a pending indicator) until a ``session.model`` event
|
||||
carries the harness's confirmation; then it flips to the confirmed row's
|
||||
name.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
_install_stream_controller(page, session_id)
|
||||
_patch_session_as_claude_native(
|
||||
page,
|
||||
session_id,
|
||||
llm_model="system.ai.claude-sonnet-5",
|
||||
)
|
||||
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
chip = page.get_by_test_id("composer-model-effort-label")
|
||||
expect(chip).to_contain_text("Sonnet 5", timeout=15_000)
|
||||
|
||||
_open_gear_model_dropdown(page)
|
||||
page.locator('[role="option"][data-model-id="opus"]').click()
|
||||
page.get_by_test_id("composer-config-save").click()
|
||||
|
||||
# Unconfirmed: the chip keeps the reported model and a pending indicator
|
||||
# shows. (The PATCH round-trip completes; confirmation has not arrived.)
|
||||
page.wait_for_timeout(800)
|
||||
expect(chip).to_contain_text("Sonnet 5")
|
||||
expect(chip).not_to_contain_text("Opus")
|
||||
expect(page.get_by_test_id("composer-model-pending")).to_be_visible()
|
||||
|
||||
# The harness confirms: the report names the model the pane now runs.
|
||||
_push_sse(
|
||||
page,
|
||||
"session.model",
|
||||
{"conversation_id": session_id, "model": "system.ai.claude-opus-4-10"},
|
||||
)
|
||||
expect(chip).to_contain_text("Opus 4.10", timeout=10_000)
|
||||
expect(page.get_by_test_id("composer-model-pending")).to_have_count(0)
|
||||
|
||||
|
||||
def test_row15_failed_switch_surfaces_error_and_keeps_the_reported_model(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""A swallowed switch shows the not-applied error; the chip never lies.
|
||||
|
||||
The runner reports failure (the server publishes the
|
||||
``model_change_not_applied`` error event) and no confirmation ever
|
||||
arrives: the visible error must name the failure, and the chip must keep
|
||||
the pane's real model instead of claiming the pick.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
_install_stream_controller(page, session_id)
|
||||
_patch_session_as_claude_native(
|
||||
page,
|
||||
session_id,
|
||||
llm_model="system.ai.claude-sonnet-5",
|
||||
)
|
||||
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
chip = page.get_by_test_id("composer-model-effort-label")
|
||||
expect(chip).to_contain_text("Sonnet 5", timeout=15_000)
|
||||
|
||||
_open_gear_model_dropdown(page)
|
||||
page.locator('[role="option"][data-model-id="haiku"]').click()
|
||||
page.get_by_test_id("composer-config-save").click()
|
||||
|
||||
_push_sse(
|
||||
page,
|
||||
"response.error",
|
||||
{
|
||||
"source": "execution",
|
||||
"error": {
|
||||
"code": "model_change_not_applied",
|
||||
"message": (
|
||||
"The terminal was not switched to haiku: the runner returned "
|
||||
"status 503. It is still running on its previous model."
|
||||
),
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
# The failure surfaces as the app's standard error pill: a headline is
|
||||
# always visible, and its collapsed detail carries the specific reason.
|
||||
headline = page.get_by_test_id("error-headline").first
|
||||
expect(headline).to_be_visible(timeout=10_000)
|
||||
# Expand to read the detail. The click can land before the disclosure
|
||||
# handler is wired under suite load, so retry until the detail shows.
|
||||
detail = page.get_by_text("was not switched", exact=False).first
|
||||
for _ in range(5):
|
||||
headline.click()
|
||||
try:
|
||||
expect(detail).to_be_visible(timeout=2_000)
|
||||
break
|
||||
except AssertionError:
|
||||
continue
|
||||
expect(detail).to_be_visible(timeout=5_000)
|
||||
# The chip never claimed the pick: it keeps the pane's reported model.
|
||||
expect(chip).to_contain_text("Sonnet 5")
|
||||
expect(chip).not_to_contain_text("Haiku")
|
||||
@@ -200,7 +200,7 @@ def test_routed_session_config_modal_names_the_routed_model(
|
||||
:returns: None.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
_patch_session_as_claude_native(page, session_id, model_override=_ROUTED_MODEL)
|
||||
_patch_session_as_claude_native(page, session_id, llm_model=_ROUTED_MODEL)
|
||||
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
|
||||
|
||||
@@ -123,8 +123,9 @@ def test_recent_server_connect_and_copy_actions_are_independent(page: Page) -> N
|
||||
_open_setup_page(page, [recent_url])
|
||||
|
||||
recent = page.locator(".recent-btn")
|
||||
expect(recent).to_have_text(recent_url)
|
||||
expect(recent).to_have_attribute("title", recent_url)
|
||||
label = "dbc-x.cloud.databricks.com/?o=12345678901234567890"
|
||||
expect(recent).to_have_text(label)
|
||||
expect(recent).to_have_attribute("title", label)
|
||||
|
||||
page.click(".recent-copy")
|
||||
page.wait_for_function("() => window.__copiedTexts.length === 1")
|
||||
@@ -148,13 +149,15 @@ def test_shared_url_module_defaults_scheme_in_browser(page: Page) -> None:
|
||||
"""
|
||||
_open_setup_page(page)
|
||||
|
||||
# Remote host → https root; the main process then probes and appends the
|
||||
# canonical /omnigent workspace mount when this is a Databricks workspace.
|
||||
# Remote host → https root while retaining the Databricks organization;
|
||||
# the main process then probes and appends the canonical /omnigent mount.
|
||||
assert (
|
||||
page.evaluate(
|
||||
"() => window.omnigentUrl.normalizeUrl('dbc-x.cloud.databricks.com/omnigent')"
|
||||
"""() => window.omnigentUrl.normalizeUrl(
|
||||
'dbc-x.cloud.databricks.com/omnigent?ignored=yes&o=1965859176160743#page'
|
||||
)"""
|
||||
)
|
||||
== "https://dbc-x.cloud.databricks.com/"
|
||||
== "https://dbc-x.cloud.databricks.com/?o=1965859176160743"
|
||||
)
|
||||
# Loopback stays http for local dev.
|
||||
assert (
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""E2E coverage for the iOS system-browser OIDC session handoff.
|
||||
|
||||
The iOS shell and Safari have isolated cookie stores. The native shell starts
|
||||
the production browser-ticket flow, Safari completes OIDC, and the shell polls
|
||||
the ticket before installing the returned session JWT in its WebView.
|
||||
|
||||
Playwright cannot execute UIKit or ``WKNavigationDelegate`` on Linux CI, so this
|
||||
test covers the shared production contract at the browser boundary: real auth
|
||||
routes, a browser-driven IdP redirect, a separately polled ticket, and an
|
||||
isolated WebView-like context that becomes authenticated only after receiving
|
||||
the polled session.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
from fastapi import FastAPI, Request
|
||||
from playwright.sync_api import Browser, Playwright, expect
|
||||
from starlette.responses import HTMLResponse
|
||||
|
||||
from omnigent.server.admin_list import AdminList
|
||||
from omnigent.server.auth import UnifiedAuthProvider
|
||||
from omnigent.server.oidc import OIDCConfig
|
||||
from omnigent.server.routes import auth as auth_routes
|
||||
from omnigent.server.routes.auth import create_auth_router
|
||||
from tests.e2e_ui.conftest import _find_free_port
|
||||
|
||||
_COOKIE_SECRET = b"i" * 32
|
||||
_IDP_TOKEN_URL = "https://idp.example.test/token"
|
||||
_IDP_USERINFO_URL = "https://idp.example.test/user"
|
||||
_TEST_EMAIL = "ios-user@example.test"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OidcHandoffServer:
|
||||
"""A live auth router used by the browser handoff test."""
|
||||
|
||||
base_url: str
|
||||
|
||||
|
||||
def _oidc_config(base_url: str) -> OIDCConfig:
|
||||
"""Build a GitHub-shaped config whose browser endpoint is intercepted."""
|
||||
return OIDCConfig(
|
||||
issuer="https://idp.example.test",
|
||||
client_id="ios-e2e-client",
|
||||
client_secret="ios-e2e-secret",
|
||||
redirect_uri=f"{base_url}/auth/callback",
|
||||
cookie_secret=_COOKIE_SECRET,
|
||||
scopes="read:user user:email",
|
||||
session_ttl_hours=8,
|
||||
logout_redirect_uri=None,
|
||||
allowed_domains=None,
|
||||
provider_type="github",
|
||||
authorization_endpoint=(
|
||||
f"{base_url.replace('127.0.0.1', 'localhost')}/test-idp/authorize"
|
||||
),
|
||||
token_endpoint=_IDP_TOKEN_URL,
|
||||
jwks_uri=None,
|
||||
userinfo_endpoint=_IDP_USERINFO_URL,
|
||||
allow_invites=False,
|
||||
)
|
||||
|
||||
|
||||
def _mock_idp_client() -> AsyncMock:
|
||||
"""Return the async client used by the callback's token and email calls."""
|
||||
token_response = MagicMock()
|
||||
token_response.status_code = 200
|
||||
token_response.json.return_value = {
|
||||
"access_token": "ios-e2e-access-token",
|
||||
"token_type": "bearer",
|
||||
}
|
||||
token_response.text = "token response"
|
||||
|
||||
email_response = MagicMock()
|
||||
email_response.status_code = 200
|
||||
email_response.json.return_value = [{"email": _TEST_EMAIL, "primary": True, "verified": True}]
|
||||
|
||||
client = AsyncMock()
|
||||
client.post.return_value = token_response
|
||||
client.get.return_value = email_response
|
||||
|
||||
context_manager = AsyncMock()
|
||||
context_manager.__aenter__.return_value = client
|
||||
context_manager.__aexit__.return_value = False
|
||||
return context_manager
|
||||
|
||||
|
||||
def _build_auth_app(base_url: str, admin_list_path: Path) -> FastAPI:
|
||||
"""Build the real OIDC auth router plus one protected browser page."""
|
||||
auth_provider = UnifiedAuthProvider(source="oidc", oidc_config=_oidc_config(base_url))
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
create_auth_router(
|
||||
auth_provider=auth_provider,
|
||||
permission_store=None,
|
||||
admin_list=AdminList(admin_list_path),
|
||||
),
|
||||
prefix="/auth",
|
||||
)
|
||||
|
||||
@app.get("/test-idp/authorize")
|
||||
async def test_idp_authorize(request: Request) -> HTMLResponse:
|
||||
callback_url = request.query_params["redirect_uri"]
|
||||
callback_query = urlencode(
|
||||
{
|
||||
"code": "ios-e2e-authorization-code",
|
||||
"state": request.query_params["state"],
|
||||
}
|
||||
)
|
||||
continue_url = f"{callback_url}?{callback_query}"
|
||||
return HTMLResponse(
|
||||
"<html><body>"
|
||||
"<h1>Test identity provider</h1>"
|
||||
f'<a href="{html.escape(continue_url, quote=True)}">Continue as test user</a>'
|
||||
"</body></html>"
|
||||
)
|
||||
|
||||
@app.get("/")
|
||||
async def protected_page(request: Request) -> HTMLResponse:
|
||||
user_id = auth_provider.get_user_id(request)
|
||||
if user_id is None:
|
||||
return HTMLResponse("<h1>Authentication required</h1>", status_code=401)
|
||||
return HTMLResponse(f"<h1>Authenticated WebView</h1><p>{html.escape(user_id)}</p>")
|
||||
|
||||
return app
|
||||
|
||||
|
||||
def _wait_for_server(server: uvicorn.Server, base_url: str, timeout: float = 10.0) -> None:
|
||||
"""Wait until the local uvicorn server reports that it has started."""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if server.started:
|
||||
return
|
||||
time.sleep(0.05)
|
||||
raise RuntimeError(f"OIDC handoff test server did not start at {base_url}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def oidc_handoff_server(tmp_path: Path) -> Iterator[OidcHandoffServer]:
|
||||
"""Serve a deterministic production OIDC router for one browser test."""
|
||||
port = _find_free_port()
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
server = uvicorn.Server(
|
||||
uvicorn.Config(
|
||||
_build_auth_app(base_url, tmp_path / "admins"),
|
||||
host="127.0.0.1",
|
||||
port=port,
|
||||
log_level="warning",
|
||||
access_log=False,
|
||||
)
|
||||
)
|
||||
server_thread = threading.Thread(target=server.run, daemon=True)
|
||||
|
||||
with patch.object(
|
||||
auth_routes.httpx, # type: ignore[attr-defined]
|
||||
"AsyncClient",
|
||||
return_value=_mock_idp_client(),
|
||||
):
|
||||
server_thread.start()
|
||||
_wait_for_server(server, base_url)
|
||||
try:
|
||||
yield OidcHandoffServer(base_url=base_url)
|
||||
finally:
|
||||
server.should_exit = True
|
||||
server_thread.join(timeout=10)
|
||||
if server_thread.is_alive():
|
||||
server.force_exit = True
|
||||
server_thread.join(timeout=5)
|
||||
|
||||
|
||||
def test_system_browser_session_is_bridged_to_isolated_webview(
|
||||
browser: Browser,
|
||||
playwright: Playwright,
|
||||
oidc_handoff_server: OidcHandoffServer,
|
||||
) -> None:
|
||||
"""Complete browser OIDC, poll its ticket, and authenticate the WebView."""
|
||||
base_url = oidc_handoff_server.base_url
|
||||
native_request_context = playwright.request.new_context(base_url=base_url)
|
||||
system_browser_context = browser.new_context()
|
||||
webview_context = browser.new_context()
|
||||
try:
|
||||
ticket_response = native_request_context.post("/auth/cli-login")
|
||||
assert ticket_response.ok, ticket_response.text()
|
||||
ticket_payload = ticket_response.json()
|
||||
ticket = ticket_payload["ticket"]
|
||||
login_url = f"{base_url}{ticket_payload['login_url']}"
|
||||
|
||||
pending_response = native_request_context.get(
|
||||
"/auth/cli-poll",
|
||||
params={"ticket": ticket},
|
||||
)
|
||||
assert pending_response.status == 202
|
||||
assert pending_response.json() == {"status": "pending"}
|
||||
|
||||
system_browser_page = system_browser_context.new_page()
|
||||
system_browser_page.goto(login_url)
|
||||
expect(
|
||||
system_browser_page.get_by_role("heading", name="Test identity provider")
|
||||
).to_be_visible()
|
||||
|
||||
system_browser_page.get_by_role("link", name="Continue as test user").click()
|
||||
expect(system_browser_page.get_by_role("heading", name="Login successful")).to_be_visible()
|
||||
expect(system_browser_page.get_by_text(_TEST_EMAIL)).to_be_visible()
|
||||
|
||||
poll_response = native_request_context.get(
|
||||
"/auth/cli-poll",
|
||||
params={"ticket": ticket},
|
||||
)
|
||||
assert poll_response.ok, poll_response.text()
|
||||
session_token = poll_response.json()["token"]
|
||||
|
||||
webview_page = webview_context.new_page()
|
||||
unauthenticated_response = webview_page.goto(base_url)
|
||||
assert unauthenticated_response is not None
|
||||
assert unauthenticated_response.status == 401
|
||||
expect(webview_page.get_by_role("heading", name="Authentication required")).to_be_visible()
|
||||
|
||||
webview_context.add_cookies(
|
||||
[{"name": "ap_session", "value": session_token, "url": base_url}]
|
||||
)
|
||||
webview_page.reload()
|
||||
expect(webview_page.get_by_role("heading", name="Authenticated WebView")).to_be_visible()
|
||||
expect(webview_page.get_by_text(_TEST_EMAIL)).to_be_visible()
|
||||
|
||||
consumed_response = native_request_context.get(
|
||||
"/auth/cli-poll",
|
||||
params={"ticket": ticket},
|
||||
)
|
||||
assert consumed_response.status == 410
|
||||
finally:
|
||||
webview_context.close()
|
||||
system_browser_context.close()
|
||||
native_request_context.dispose()
|
||||
@@ -0,0 +1,32 @@
|
||||
"""E2E: Ctrl+N opens the new-session composer from focused chat input."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
|
||||
from playwright.sync_api import Page, expect
|
||||
|
||||
_COMPOSER = "Ask the agent anything…"
|
||||
|
||||
|
||||
def test_new_session_hotkey_from_focused_composer(
|
||||
page: Page,
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""Ctrl+N follows the command-palette action to a clean, focused composer."""
|
||||
base_url, session_id = seeded_session
|
||||
page.goto(f"{base_url}/c/{session_id}")
|
||||
|
||||
composer = page.get_by_placeholder(_COMPOSER)
|
||||
expect(composer).to_be_visible(timeout=30_000)
|
||||
composer.fill("draft that belongs to the existing session")
|
||||
expect(composer).to_be_focused()
|
||||
|
||||
modifier = "Meta" if sys.platform == "darwin" else "Control"
|
||||
page.keyboard.press(f"{modifier}+n")
|
||||
|
||||
expect(page).to_have_url(f"{base_url}/", timeout=10_000)
|
||||
new_session_composer = page.get_by_placeholder("Describe a task to start a new session…")
|
||||
expect(new_session_composer).to_be_visible()
|
||||
expect(new_session_composer).to_be_focused()
|
||||
expect(new_session_composer).to_have_value("")
|
||||
@@ -0,0 +1,110 @@
|
||||
"""E2E (hermetic): pre-launch model picker rows per model-flows-design.md §10.1.
|
||||
|
||||
Row 6's hermetic half: with a host catalog whose rows carry ``isDefault``, the
|
||||
new-chat model select must read "Default (X)" for BOTH harnesses — X being the
|
||||
default row's display name. Codex already renders this; the claude branch of
|
||||
the landing screen historically discarded ``isDefault``, so its select read a
|
||||
bare "Default" no matter what the host said. This test encodes the design's
|
||||
target behavior and is red until landing-order step 7.
|
||||
|
||||
The driving surface is the real SPA in a browser; only the server edges the
|
||||
landing screen consults (hosts, agents, model-options) are faked, exactly like
|
||||
the sibling tests in ``test_start_session.py``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from playwright.async_api import Route, async_playwright, expect
|
||||
|
||||
from tests.e2e_ui.start_session.test_start_session import (
|
||||
_HOST_ID,
|
||||
_open_entry_config,
|
||||
_register_common_routes,
|
||||
_run_in_fresh_loop,
|
||||
)
|
||||
|
||||
_CLAUDE_HOST_ROWS = [
|
||||
{
|
||||
"id": "sonnet",
|
||||
"model": "claude-sonnet-5",
|
||||
"displayName": "Sonnet 5",
|
||||
"isDefault": False,
|
||||
},
|
||||
{
|
||||
"id": "opus[1m]",
|
||||
"model": "claude-opus-4-8[1m]",
|
||||
"displayName": "Opus 4.8 (1M context)",
|
||||
"isDefault": True,
|
||||
},
|
||||
{
|
||||
"id": "haiku",
|
||||
"model": "claude-haiku-4-5-20251001",
|
||||
"displayName": "Haiku 4.5",
|
||||
"isDefault": False,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def test_claude_default_entry_names_the_true_default(
|
||||
seeded_session: tuple[str, str],
|
||||
) -> None:
|
||||
"""Row 6: the claude model select reads "Default (Opus 4.8 (1M context))".
|
||||
|
||||
:param seeded_session: ``(base_url, session_id)`` from the spawned server.
|
||||
"""
|
||||
base_url, session_id = seeded_session
|
||||
_run_in_fresh_loop(_drive_claude_default_label(base_url, session_id))
|
||||
|
||||
|
||||
async def _drive_claude_default_label(base_url: str, session_id: str) -> None:
|
||||
async with async_playwright() as pw:
|
||||
browser = await pw.chromium.launch()
|
||||
page = await browser.new_page()
|
||||
try:
|
||||
create_bodies: list[dict[str, Any]] = []
|
||||
await _register_common_routes(
|
||||
page, created_session_id=session_id, create_bodies=create_bodies
|
||||
)
|
||||
|
||||
async def handle_agent_scan(route: Route) -> None:
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"data": []}),
|
||||
)
|
||||
|
||||
async def handle_model_options(route: Route) -> None:
|
||||
await route.fulfill(
|
||||
status=200,
|
||||
content_type="application/json",
|
||||
body=json.dumps({"models": _CLAUDE_HOST_ROWS}),
|
||||
)
|
||||
|
||||
import re as _re
|
||||
|
||||
await page.route(_re.compile(r"/v1/sessions\?.*kind=any"), handle_agent_scan)
|
||||
await page.route(
|
||||
f"**/v1/hosts/{_HOST_ID}/harnesses/claude-native/model-options",
|
||||
handle_model_options,
|
||||
)
|
||||
await page.add_init_script(
|
||||
f"""window.localStorage.setItem(
|
||||
"omnigent:recent-workspaces",
|
||||
JSON.stringify({{ {_HOST_ID}: ["/work/repo"] }})
|
||||
);"""
|
||||
)
|
||||
|
||||
await page.goto(f"{base_url}/")
|
||||
await page.get_by_test_id("new-chat-landing-input").wait_for(
|
||||
state="visible", timeout=30_000
|
||||
)
|
||||
await _open_entry_config(page, "ag_claude_e2e")
|
||||
model = page.get_by_test_id("new-chat-landing-config-model")
|
||||
# The design's row 6: the untouched select names the model a
|
||||
# Default launch truly runs, for claude exactly as for codex.
|
||||
await expect(model).to_contain_text("Default (Opus 4.8 (1M context))")
|
||||
finally:
|
||||
await browser.close()
|
||||
@@ -583,7 +583,7 @@ async def _drive_permission_mode(base_url: str, session_id: str) -> None:
|
||||
await expect(perm).to_be_visible()
|
||||
await perm.click()
|
||||
perm_labels = (
|
||||
"Default",
|
||||
"Manual",
|
||||
"Auto",
|
||||
"Accept edits",
|
||||
"Plan",
|
||||
@@ -1730,8 +1730,12 @@ async def _drive_codex_model(base_url: str, session_id: str) -> None:
|
||||
)
|
||||
await _open_entry_config(page, "ag_codex_e2e")
|
||||
model = page.get_by_test_id("new-chat-landing-config-model")
|
||||
await expect(model).to_contain_text("Default (gpt-live-default)")
|
||||
await _pick_config_select(page, "new-chat-landing-config-model", "gpt-live-fast")
|
||||
# The Default row names the catalog's default by its DISPLAY name —
|
||||
# the same shared labeling the in-session gear uses.
|
||||
await expect(model).to_contain_text("Default (GPT Live Default)")
|
||||
# Codex options render decorated display names (same as claude),
|
||||
# so pick by the display name; the create still sends the id.
|
||||
await _pick_config_select(page, "new-chat-landing-config-model", "GPT Live Fast")
|
||||
await _save_config(page)
|
||||
|
||||
await page.get_by_test_id("new-chat-landing-input").fill("set up the project")
|
||||
|
||||
+362
-395
@@ -72,6 +72,22 @@ from omnigent.runner.identity import (
|
||||
pytestmark = pytest.mark.asyncio
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_model_catalog_store(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
|
||||
) -> None:
|
||||
"""Point the shared model-catalog store at a per-test directory.
|
||||
|
||||
The model-options lanes read and write the on-disk catalog store; a
|
||||
test must never touch (or be poisoned by) the developer's real
|
||||
``~/.omnigent`` cache. Only the store's directory seam is redirected —
|
||||
``OMNIGENT_DATA_DIR`` itself stays untouched so log-path tests keep
|
||||
seeing the real default layout.
|
||||
"""
|
||||
store_dir = tmp_path_factory.mktemp("model_catalog_store")
|
||||
monkeypatch.setattr("omnigent.model_catalog_store._data_dir", lambda: store_dir)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_zygote(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep these tests from forking a real runner zygote.
|
||||
@@ -88,24 +104,97 @@ def _no_real_zygote(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setenv(ZYGOTE_ENABLED_ENV_VAR, "0")
|
||||
|
||||
|
||||
async def test_handle_model_options_uses_host_claude_configuration(
|
||||
async def test_handle_model_options_serves_the_claude_catalog(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The launch picker is resolved on the host that will start Claude."""
|
||||
"""The launch picker is the harness-probed catalog, resolved on the host.
|
||||
|
||||
The probe's rows pass through with the harness's own default marked,
|
||||
the endpoint's routable set rides along, and the second request is
|
||||
served from the fingerprint store — the harness is probed once.
|
||||
"""
|
||||
from omnigent import claude_native
|
||||
|
||||
monkeypatch.setattr(claude_native, "resolve_native_claude_config", lambda *, spec: None)
|
||||
config = claude_native.ClaudeNativeUcodeConfig(
|
||||
env={"ANTHROPIC_BASE_URL": "https://gw.example"},
|
||||
routable_models=(
|
||||
"system.ai.claude-sonnet-5",
|
||||
"system.ai.claude-sonnet-5[1m]",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
claude_native,
|
||||
"claude_native_model_options",
|
||||
lambda config: [
|
||||
"resolve_native_claude_config",
|
||||
lambda *, spec, refresh_models=True: config,
|
||||
)
|
||||
probe_calls: list[int] = []
|
||||
|
||||
async def _fake_probe(_config: object) -> claude_native.ClaudeModelProbe:
|
||||
probe_calls.append(1)
|
||||
return claude_native.ClaudeModelProbe(
|
||||
alias_rows=[
|
||||
{
|
||||
"id": "sonnet",
|
||||
"model": "system.ai.claude-sonnet-5",
|
||||
"displayName": "Sonnet 5",
|
||||
}
|
||||
],
|
||||
default_model="system.ai.claude-sonnet-5",
|
||||
default_label="Sonnet 5",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(claude_native, "probe_claude_model_options", _fake_probe)
|
||||
host = _make_host_process()
|
||||
|
||||
first = await host._handle_model_options(
|
||||
HostModelOptionsFrame(request_id="req_1", harness="claude-native"),
|
||||
)
|
||||
second = await host._handle_model_options(
|
||||
HostModelOptionsFrame(request_id="req_2", harness="claude-native"),
|
||||
)
|
||||
|
||||
assert first == HostModelOptionsResultFrame(
|
||||
request_id="req_1",
|
||||
status="ok",
|
||||
models=[
|
||||
{
|
||||
"id": "sonnet",
|
||||
"model": "system.ai.claude-sonnet-4-6[1m]",
|
||||
"displayName": "Sonnet 4.6",
|
||||
"model": "system.ai.claude-sonnet-5",
|
||||
"displayName": "Sonnet 5",
|
||||
"isDefault": True,
|
||||
}
|
||||
],
|
||||
routable_models=[
|
||||
"system.ai.claude-sonnet-5",
|
||||
"system.ai.claude-sonnet-5[1m]",
|
||||
],
|
||||
)
|
||||
assert second.models == first.models
|
||||
assert probe_calls == [1]
|
||||
_cleanup_host(host)
|
||||
|
||||
|
||||
async def test_handle_model_options_claude_probe_failure_is_an_honest_empty(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""No Claude catalog means an empty answer that says why.
|
||||
|
||||
There is no configured/static fallback lane left: a probe that cannot
|
||||
run yields an honest empty listing with the reason, never invented
|
||||
rows.
|
||||
"""
|
||||
from omnigent import claude_native
|
||||
|
||||
monkeypatch.setattr(
|
||||
claude_native,
|
||||
"resolve_native_claude_config",
|
||||
lambda *, spec, refresh_models=True: None,
|
||||
)
|
||||
|
||||
async def _failed_probe(_config: object) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(claude_native, "probe_claude_model_options", _failed_probe)
|
||||
host = _make_host_process()
|
||||
|
||||
result = await host._handle_model_options(
|
||||
@@ -115,14 +204,10 @@ async def test_handle_model_options_uses_host_claude_configuration(
|
||||
assert result == HostModelOptionsResultFrame(
|
||||
request_id="req_models",
|
||||
status="ok",
|
||||
models=[
|
||||
{
|
||||
"id": "sonnet",
|
||||
"model": "system.ai.claude-sonnet-4-6[1m]",
|
||||
"displayName": "Sonnet 4.6",
|
||||
}
|
||||
],
|
||||
models=[],
|
||||
error="the claude model probe failed — see the host log",
|
||||
)
|
||||
_cleanup_host(host)
|
||||
|
||||
|
||||
async def test_handle_model_options_uses_host_pi_configuration(
|
||||
@@ -161,267 +246,24 @@ async def test_handle_model_options_uses_host_pi_configuration(
|
||||
)
|
||||
|
||||
|
||||
async def test_handle_model_options_uses_codex_provider_catalog(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@pytest.mark.parametrize("failure", ["raises", "resolves_nothing"])
|
||||
async def test_handle_model_options_codex_probe_failure_is_an_honest_empty(
|
||||
monkeypatch: pytest.MonkeyPatch, failure: str
|
||||
) -> None:
|
||||
"""The Codex launch picker comes from the host's resolved provider catalog."""
|
||||
"""No Codex catalog means an empty answer that says why.
|
||||
|
||||
There is no curated/provider fallback lane left: whether the catalog
|
||||
machinery raises or resolves nothing, the picker gets an honest empty
|
||||
listing with the reason, never invented rows.
|
||||
"""
|
||||
from omnigent import codex_native_app_server
|
||||
from omnigent.model_catalog import ModelEntry, ModelListing
|
||||
|
||||
def _fake_list_models_for_worker(spec: object, harness: str) -> ModelListing:
|
||||
assert harness == "codex-native"
|
||||
assert spec.executor.config["profile"] == "oss"
|
||||
return ModelListing(
|
||||
source="static",
|
||||
verified=False,
|
||||
models=(
|
||||
ModelEntry(id="gpt-live-default", family="openai"),
|
||||
ModelEntry(id="gpt-live-fast", family="openai"),
|
||||
),
|
||||
note="test catalog",
|
||||
)
|
||||
async def _no_catalog(**_kwargs: object) -> list[dict[str, object]] | None:
|
||||
if failure == "raises":
|
||||
raise RuntimeError("codex probe unavailable")
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
"omnigent.model_catalog.list_models_for_worker",
|
||||
_fake_list_models_for_worker,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
codex_native_app_server,
|
||||
"resolve_native_codex_launch",
|
||||
lambda *, model: codex_native_app_server.NativeCodexLaunch(
|
||||
config_overrides=[],
|
||||
model="gpt-live-fast",
|
||||
profile="oss",
|
||||
),
|
||||
)
|
||||
host = _make_host_process()
|
||||
|
||||
result = await host._handle_model_options(
|
||||
HostModelOptionsFrame(request_id="req_models", harness="codex-native"),
|
||||
)
|
||||
|
||||
assert result == HostModelOptionsResultFrame(
|
||||
request_id="req_models",
|
||||
status="ok",
|
||||
models=[
|
||||
{"id": "gpt-live-default", "displayName": "gpt-live-default"},
|
||||
{"id": "gpt-live-fast", "displayName": "gpt-live-fast", "isDefault": True},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
async def test_handle_model_options_does_not_invent_codex_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A catalog entry is not a default unless Codex resolves it as one."""
|
||||
from omnigent import codex_native_app_server
|
||||
from omnigent.model_catalog import ModelEntry, ModelListing
|
||||
|
||||
monkeypatch.setattr(
|
||||
"omnigent.model_catalog.list_models_for_worker",
|
||||
lambda spec, harness: ModelListing(
|
||||
source="static",
|
||||
verified=False,
|
||||
models=(ModelEntry(id="gpt-live", family="openai"),),
|
||||
note="test catalog",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
codex_native_app_server,
|
||||
"resolve_native_codex_launch",
|
||||
lambda *, model: codex_native_app_server.NativeCodexLaunch(
|
||||
config_overrides=[],
|
||||
model=None,
|
||||
profile=None,
|
||||
),
|
||||
)
|
||||
host = _make_host_process()
|
||||
|
||||
result = await host._handle_model_options(
|
||||
HostModelOptionsFrame(request_id="req_models", harness="codex-native"),
|
||||
)
|
||||
|
||||
assert result.models == [{"id": "gpt-live", "displayName": "gpt-live"}]
|
||||
|
||||
|
||||
async def test_handle_model_options_uses_databricks_catalog_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The Databricks profile path labels its effective catalog default."""
|
||||
from omnigent import codex_native_app_server
|
||||
from omnigent.model_catalog import ModelEntry, ModelListing
|
||||
|
||||
monkeypatch.setattr(
|
||||
"omnigent.model_catalog.list_models_for_worker",
|
||||
lambda spec, harness: ModelListing(
|
||||
source="gateway",
|
||||
verified=True,
|
||||
models=(
|
||||
ModelEntry(id="databricks-gpt-default", family="openai"),
|
||||
ModelEntry(id="databricks-gpt-fast", family="openai"),
|
||||
),
|
||||
note="test catalog",
|
||||
),
|
||||
)
|
||||
|
||||
def _fake_resolve_catalog_model(provider: str, *, family: str) -> SimpleNamespace:
|
||||
assert provider == "databricks"
|
||||
assert family == "openai"
|
||||
return SimpleNamespace(model_id="databricks-gpt-default")
|
||||
|
||||
monkeypatch.setattr(
|
||||
"omnigent.model_catalog.resolve_catalog_model",
|
||||
_fake_resolve_catalog_model,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
codex_native_app_server,
|
||||
"resolve_native_codex_launch",
|
||||
lambda *, model: codex_native_app_server.NativeCodexLaunch(
|
||||
config_overrides=[],
|
||||
model=None,
|
||||
profile="oss",
|
||||
),
|
||||
)
|
||||
host = _make_host_process()
|
||||
|
||||
result = await host._handle_model_options(
|
||||
HostModelOptionsFrame(request_id="req_models", harness="codex-native"),
|
||||
)
|
||||
|
||||
assert result.models == [
|
||||
{
|
||||
"id": "databricks-gpt-default",
|
||||
"displayName": "databricks-gpt-default",
|
||||
"isDefault": True,
|
||||
},
|
||||
{"id": "databricks-gpt-fast", "displayName": "databricks-gpt-fast"},
|
||||
]
|
||||
|
||||
|
||||
async def test_handle_model_options_filters_direct_openai_through_codex_catalog(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Direct OpenAI availability is intersected with Codex compatibility."""
|
||||
from omnigent import codex_native_app_server
|
||||
from omnigent.model_catalog import ModelEntry, ModelListing, ResolvedModelProvider
|
||||
|
||||
monkeypatch.setattr(
|
||||
"omnigent.model_catalog.list_models_for_worker",
|
||||
lambda spec, harness: ModelListing(
|
||||
source="openai-compatible",
|
||||
verified=True,
|
||||
models=tuple(
|
||||
ModelEntry(id=model_id, family="openai")
|
||||
for model_id in (
|
||||
"coding-compatible",
|
||||
"audio-preview",
|
||||
"realtime-preview",
|
||||
"image-preview",
|
||||
"embedding-preview",
|
||||
"moderation-preview",
|
||||
)
|
||||
),
|
||||
note="test OpenAI catalog",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.model_catalog.resolve_model_provider",
|
||||
lambda spec, harness: ResolvedModelProvider(
|
||||
kind="key",
|
||||
family="openai",
|
||||
base_url="https://api.openai.com",
|
||||
detail="test OpenAI key",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
codex_native_app_server,
|
||||
"resolve_native_codex_launch",
|
||||
lambda *, model: codex_native_app_server.NativeCodexLaunch(
|
||||
config_overrides=[],
|
||||
model=None,
|
||||
profile=None,
|
||||
),
|
||||
)
|
||||
|
||||
async def _fake_codex_options() -> list[dict[str, object]]:
|
||||
return [
|
||||
{
|
||||
"id": "coding-compatible",
|
||||
"model": "coding-compatible",
|
||||
"displayName": "Coding Compatible",
|
||||
"isDefault": True,
|
||||
},
|
||||
{
|
||||
"id": "coding-unavailable",
|
||||
"model": "coding-unavailable",
|
||||
"displayName": "Coding Unavailable",
|
||||
"isDefault": False,
|
||||
},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
codex_native_app_server,
|
||||
"discover_codex_model_options",
|
||||
_fake_codex_options,
|
||||
)
|
||||
host = _make_host_process()
|
||||
|
||||
result = await host._handle_model_options(
|
||||
HostModelOptionsFrame(request_id="req_models", harness="codex-native"),
|
||||
)
|
||||
|
||||
assert result.models == [
|
||||
{
|
||||
"id": "coding-compatible",
|
||||
"displayName": "Coding Compatible",
|
||||
"isDefault": True,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
async def test_handle_model_options_tolerates_codex_discovery_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Discovery failures keep the implicit default without unsafe model rows."""
|
||||
from omnigent import codex_native_app_server
|
||||
from omnigent.model_catalog import ModelEntry, ModelListing, ResolvedModelProvider
|
||||
|
||||
monkeypatch.setattr(
|
||||
"omnigent.model_catalog.list_models_for_worker",
|
||||
lambda spec, harness: ModelListing(
|
||||
source="openai-compatible",
|
||||
verified=True,
|
||||
models=(ModelEntry(id="unverified-model", family="openai"),),
|
||||
note="test OpenAI catalog",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.model_catalog.resolve_model_provider",
|
||||
lambda spec, harness: ResolvedModelProvider(
|
||||
kind="key",
|
||||
family="openai",
|
||||
base_url="https://api.openai.com",
|
||||
detail="test OpenAI key",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
codex_native_app_server,
|
||||
"resolve_native_codex_launch",
|
||||
lambda *, model: codex_native_app_server.NativeCodexLaunch(
|
||||
config_overrides=[],
|
||||
model=None,
|
||||
profile=None,
|
||||
),
|
||||
)
|
||||
|
||||
async def _failed_codex_options() -> list[dict[str, object]]:
|
||||
raise TimeoutError("test discovery timeout")
|
||||
|
||||
monkeypatch.setattr(
|
||||
codex_native_app_server,
|
||||
"discover_codex_model_options",
|
||||
_failed_codex_options,
|
||||
)
|
||||
monkeypatch.setattr(codex_native_app_server, "codex_launch_catalog", _no_catalog)
|
||||
host = _make_host_process()
|
||||
|
||||
result = await host._handle_model_options(
|
||||
@@ -432,124 +274,9 @@ async def test_handle_model_options_tolerates_codex_discovery_failure(
|
||||
request_id="req_models",
|
||||
status="ok",
|
||||
models=[],
|
||||
error="the codex model probe failed — see the host log",
|
||||
)
|
||||
|
||||
|
||||
async def test_handle_model_options_marks_only_first_codex_default(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Malformed Codex catalogs cannot mark multiple picker rows as default."""
|
||||
from omnigent import codex_native_app_server
|
||||
from omnigent.model_catalog import ModelEntry, ModelListing, ResolvedModelProvider
|
||||
|
||||
model_ids = ("coding-first", "coding-second")
|
||||
monkeypatch.setattr(
|
||||
"omnigent.model_catalog.list_models_for_worker",
|
||||
lambda spec, harness: ModelListing(
|
||||
source="openai-compatible",
|
||||
verified=True,
|
||||
models=tuple(ModelEntry(id=model_id, family="openai") for model_id in model_ids),
|
||||
note="test OpenAI catalog",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.model_catalog.resolve_model_provider",
|
||||
lambda spec, harness: ResolvedModelProvider(
|
||||
kind="key",
|
||||
family="openai",
|
||||
base_url="https://api.openai.com",
|
||||
detail="test OpenAI key",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
codex_native_app_server,
|
||||
"resolve_native_codex_launch",
|
||||
lambda *, model: codex_native_app_server.NativeCodexLaunch(
|
||||
config_overrides=[],
|
||||
model=None,
|
||||
profile=None,
|
||||
),
|
||||
)
|
||||
|
||||
async def _multiple_codex_defaults() -> list[dict[str, object]]:
|
||||
return [
|
||||
{"model": model_id, "displayName": model_id, "isDefault": True}
|
||||
for model_id in model_ids
|
||||
]
|
||||
|
||||
monkeypatch.setattr(
|
||||
codex_native_app_server,
|
||||
"discover_codex_model_options",
|
||||
_multiple_codex_defaults,
|
||||
)
|
||||
host = _make_host_process()
|
||||
|
||||
result = await host._handle_model_options(
|
||||
HostModelOptionsFrame(request_id="req_models", harness="codex-native"),
|
||||
)
|
||||
|
||||
assert result.models == [
|
||||
{"id": "coding-first", "displayName": "coding-first", "isDefault": True},
|
||||
{"id": "coding-second", "displayName": "coding-second"},
|
||||
]
|
||||
|
||||
|
||||
async def test_handle_model_options_keeps_custom_gateway_catalog(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Custom gateway ids remain selectable without Codex alias filtering."""
|
||||
from omnigent import codex_native_app_server
|
||||
from omnigent.model_catalog import ModelEntry, ModelListing, ResolvedModelProvider
|
||||
|
||||
monkeypatch.setattr(
|
||||
"omnigent.model_catalog.list_models_for_worker",
|
||||
lambda spec, harness: ModelListing(
|
||||
source="openai-compatible",
|
||||
verified=True,
|
||||
models=(ModelEntry(id="gateway-coding-model", family="openai"),),
|
||||
note="test gateway catalog",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.model_catalog.resolve_model_provider",
|
||||
lambda spec, harness: ResolvedModelProvider(
|
||||
kind="gateway",
|
||||
family="openai",
|
||||
base_url="https://gateway.example/v1",
|
||||
detail="test gateway",
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
codex_native_app_server,
|
||||
"resolve_native_codex_launch",
|
||||
lambda *, model: codex_native_app_server.NativeCodexLaunch(
|
||||
config_overrides=[],
|
||||
model="gateway-coding-model",
|
||||
profile=None,
|
||||
),
|
||||
)
|
||||
|
||||
async def _unexpected_codex_options() -> list[dict[str, object]]:
|
||||
raise AssertionError("custom gateways must not use the OpenAI compatibility filter")
|
||||
|
||||
monkeypatch.setattr(
|
||||
codex_native_app_server,
|
||||
"discover_codex_model_options",
|
||||
_unexpected_codex_options,
|
||||
)
|
||||
host = _make_host_process()
|
||||
|
||||
result = await host._handle_model_options(
|
||||
HostModelOptionsFrame(request_id="req_models", harness="codex-native"),
|
||||
)
|
||||
|
||||
assert result.models == [
|
||||
{
|
||||
"id": "gateway-coding-model",
|
||||
"displayName": "gateway-coding-model",
|
||||
"isDefault": True,
|
||||
}
|
||||
]
|
||||
_cleanup_host(host)
|
||||
|
||||
|
||||
async def test_handle_model_options_rejects_unsupported_harness() -> None:
|
||||
@@ -576,17 +303,21 @@ async def test_handle_model_options_reports_the_endpoints_wider_catalog(
|
||||
monkeypatch.setattr(
|
||||
claude_native,
|
||||
"resolve_native_claude_config",
|
||||
lambda *, spec: claude_native.ClaudeNativeUcodeConfig(
|
||||
lambda *, spec, refresh_models=True: claude_native.ClaudeNativeUcodeConfig(
|
||||
env={"ANTHROPIC_DEFAULT_OPUS_MODEL": "system.ai.claude-opus-5"},
|
||||
model="system.ai.claude-opus-5",
|
||||
routable_models=("system.ai.claude-opus-5", "system.ai.claude-opus-4-8"),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
claude_native,
|
||||
"claude_native_model_options",
|
||||
lambda config: [{"id": "opus", "model": "system.ai.claude-opus-5"}],
|
||||
)
|
||||
|
||||
async def _fake_probe(_config: object) -> claude_native.ClaudeModelProbe:
|
||||
return claude_native.ClaudeModelProbe(
|
||||
alias_rows=[{"id": "opus", "model": "system.ai.claude-opus-5"}],
|
||||
default_model="system.ai.claude-opus-5",
|
||||
default_label="Opus",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(claude_native, "probe_claude_model_options", _fake_probe)
|
||||
host = _make_host_process()
|
||||
|
||||
result = await host._handle_model_options(
|
||||
@@ -597,6 +328,7 @@ async def test_handle_model_options_reports_the_endpoints_wider_catalog(
|
||||
"system.ai.claude-opus-5",
|
||||
"system.ai.claude-opus-4-8",
|
||||
]
|
||||
_cleanup_host(host)
|
||||
|
||||
|
||||
def _make_host_process() -> HostProcess:
|
||||
@@ -3349,16 +3081,18 @@ class _ConnectSpy:
|
||||
"""
|
||||
self._exceptions = exceptions
|
||||
self.call_count = 0
|
||||
self.calls: list[dict[str, object]] = []
|
||||
|
||||
def __call__(self, url: str, **kwargs: object) -> _HandshakeFailingConnect | _AcceptingConnect:
|
||||
"""Return an async-CM scripting the handshake for this call.
|
||||
|
||||
:param url: Tunnel URL passed by production (ignored).
|
||||
:param kwargs: Connect kwargs passed by production (ignored).
|
||||
:param kwargs: Connect kwargs passed by production (recorded).
|
||||
:returns: A context manager whose ``__aenter__`` raises the
|
||||
queued exception, or completes the handshake for a ``None``
|
||||
entry.
|
||||
"""
|
||||
self.calls.append(kwargs)
|
||||
exc = self._exceptions[min(self.call_count, len(self._exceptions) - 1)]
|
||||
self.call_count += 1
|
||||
if exc is None or isinstance(exc, int):
|
||||
@@ -3896,6 +3630,22 @@ async def test_run_reconnects_on_transient_upgrade_failure(
|
||||
assert spy.call_count == 2
|
||||
|
||||
|
||||
async def test_reconnect_uses_shorter_handshake_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""Only reconnects use the shorter open timeout; cold startup stays tolerant."""
|
||||
monkeypatch.setattr("omnigent.host.connect._RECONNECT_BASE_S", 0.0)
|
||||
monkeypatch.setattr("omnigent.host.connect.configured_harness_map", dict)
|
||||
monkeypatch.setattr("omnigent.host.connect.gateway_inference_map", dict)
|
||||
spy = _ConnectSpy([None, asyncio.CancelledError()])
|
||||
_patch_connect(monkeypatch, spy)
|
||||
host = _host()
|
||||
|
||||
await host.run()
|
||||
|
||||
assert [call["open_timeout"] for call in spy.calls] == [10.0, 3.0]
|
||||
|
||||
|
||||
def _refused_exc() -> ConnectionRefusedError:
|
||||
"""A single-stack connection-refused, as asyncio raises it.
|
||||
|
||||
@@ -4145,6 +3895,189 @@ async def test_launch_cancelled_midspawn_does_not_leak_untracked_runner(
|
||||
assert spawned[0].poll() is not None, "abandoned runner was leaked, still alive"
|
||||
|
||||
|
||||
async def test_handle_model_options_serves_codex_probe_rows_and_caches(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A Databricks-routed Codex request is answered by the harness probe.
|
||||
|
||||
The probe rows pass through verbatim with their ids as the routable
|
||||
set, and the second request is served from the fingerprint cache —
|
||||
the harness is booted once.
|
||||
"""
|
||||
from omnigent import codex_native_app_server
|
||||
|
||||
monkeypatch.setattr(
|
||||
codex_native_app_server,
|
||||
"resolve_native_codex_launch",
|
||||
lambda *, model: codex_native_app_server.NativeCodexLaunch(
|
||||
config_overrides=[],
|
||||
model="databricks-gpt-5-4",
|
||||
profile="oss",
|
||||
),
|
||||
)
|
||||
probe_calls: list[int] = []
|
||||
|
||||
async def _fake_probe(**_kwargs: object) -> list[dict[str, object]]:
|
||||
probe_calls.append(1)
|
||||
return [
|
||||
{"id": "gpt-5.6-sol", "displayName": "GPT-5.6-Sol"},
|
||||
{"id": "gpt-5.4", "displayName": "gpt-5.4", "isDefault": True},
|
||||
]
|
||||
|
||||
monkeypatch.setattr(codex_native_app_server, "probe_codex_model_options", _fake_probe)
|
||||
host = _make_host_process()
|
||||
|
||||
first = await host._handle_model_options(
|
||||
HostModelOptionsFrame(request_id="req_1", harness="codex-native"),
|
||||
)
|
||||
second = await host._handle_model_options(
|
||||
HostModelOptionsFrame(request_id="req_2", harness="codex-native"),
|
||||
)
|
||||
|
||||
assert first == HostModelOptionsResultFrame(
|
||||
request_id="req_1",
|
||||
status="ok",
|
||||
models=[
|
||||
{"id": "gpt-5.6-sol", "displayName": "GPT-5.6-Sol"},
|
||||
{"id": "gpt-5.4", "displayName": "gpt-5.4", "isDefault": True},
|
||||
],
|
||||
routable_models=["gpt-5.6-sol", "gpt-5.4"],
|
||||
)
|
||||
assert second.models == first.models
|
||||
assert probe_calls == [1]
|
||||
_cleanup_host(host)
|
||||
|
||||
|
||||
async def test_handle_model_options_serves_claude_sdk_endpoint_listing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""SDK-mode Claude is a pass-through client, so the endpoint listing is
|
||||
the harness truth — served in the exact wire spelling the SDK sends."""
|
||||
from omnigent.model_catalog import ModelEntry, ModelListing
|
||||
|
||||
def _fake_listing(spec: object, harness: str) -> ModelListing:
|
||||
assert harness == "claude-sdk"
|
||||
return ModelListing(
|
||||
source="gateway",
|
||||
verified=True,
|
||||
models=(
|
||||
ModelEntry(id="databricks-claude-sonnet-5", family="claude"),
|
||||
ModelEntry(id="databricks-claude-opus-4-8", family="claude"),
|
||||
),
|
||||
note="test catalog",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("omnigent.model_catalog.list_models_for_worker", _fake_listing)
|
||||
host = _make_host_process()
|
||||
|
||||
result = await host._handle_model_options(
|
||||
HostModelOptionsFrame(request_id="req_sdk", harness="claude-sdk"),
|
||||
)
|
||||
|
||||
assert result == HostModelOptionsResultFrame(
|
||||
request_id="req_sdk",
|
||||
status="ok",
|
||||
models=[
|
||||
{"id": "databricks-claude-sonnet-5", "displayName": "databricks-claude-sonnet-5"},
|
||||
{"id": "databricks-claude-opus-4-8", "displayName": "databricks-claude-opus-4-8"},
|
||||
],
|
||||
routable_models=["databricks-claude-sonnet-5", "databricks-claude-opus-4-8"],
|
||||
)
|
||||
_cleanup_host(host)
|
||||
|
||||
|
||||
async def test_handle_model_options_claude_sdk_rides_the_probe_when_endpoints_list_nothing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A subscription SDK launch serves the claude CLI's probed rows.
|
||||
|
||||
Subscription providers list nothing endpoint-side (the curated
|
||||
stand-ins are gone), and the SDK drives the claude CLI — so the CLI's
|
||||
probed listing is the truth for this lane too.
|
||||
"""
|
||||
from omnigent.host.connect import ModelOptionsResult
|
||||
from omnigent.model_catalog import ModelListing
|
||||
|
||||
def _fake_listing(spec: object, harness: str) -> ModelListing:
|
||||
assert harness == "claude-sdk"
|
||||
return ModelListing(
|
||||
source="static",
|
||||
verified=False,
|
||||
models=(),
|
||||
note="the claude CLI login exposes no model-listing API before launch",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("omnigent.model_catalog.list_models_for_worker", _fake_listing)
|
||||
host = _make_host_process()
|
||||
|
||||
async def _fake_probed() -> ModelOptionsResult:
|
||||
return ModelOptionsResult(
|
||||
models=[{"id": "sonnet", "model": "claude-sonnet-5", "displayName": "Sonnet 5"}],
|
||||
routable_models=[],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(host, "_probed_claude_model_options", _fake_probed)
|
||||
|
||||
result = await host._handle_model_options(
|
||||
HostModelOptionsFrame(request_id="req_sdk_sub", harness="claude-sdk"),
|
||||
)
|
||||
|
||||
assert result == HostModelOptionsResultFrame(
|
||||
request_id="req_sdk_sub",
|
||||
status="ok",
|
||||
models=[{"id": "sonnet", "model": "claude-sonnet-5", "displayName": "Sonnet 5"}],
|
||||
routable_models=[],
|
||||
)
|
||||
_cleanup_host(host)
|
||||
|
||||
|
||||
async def test_model_options_frame_replies_off_the_receive_loop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A slow probe must not stall the tunnel receive loop.
|
||||
|
||||
``_handle_raw_message`` returns while the probe is still blocked; the
|
||||
reply frame arrives from the dispatched task once the probe finishes.
|
||||
"""
|
||||
from omnigent import codex_native_app_server
|
||||
from omnigent.host.frames import encode_host_frame
|
||||
|
||||
monkeypatch.setattr(
|
||||
codex_native_app_server,
|
||||
"resolve_native_codex_launch",
|
||||
lambda *, model: codex_native_app_server.NativeCodexLaunch(
|
||||
config_overrides=[],
|
||||
model=None,
|
||||
profile="oss",
|
||||
),
|
||||
)
|
||||
release_probe = asyncio.Event()
|
||||
|
||||
async def _slow_probe(**_kwargs: object) -> list[dict[str, object]]:
|
||||
await release_probe.wait()
|
||||
return [{"id": "gpt-5.6-sol", "displayName": "GPT-5.6-Sol"}]
|
||||
|
||||
monkeypatch.setattr(codex_native_app_server, "probe_codex_model_options", _slow_probe)
|
||||
host = _make_host_process()
|
||||
ws = _RecordingWS()
|
||||
raw = encode_host_frame(HostModelOptionsFrame(request_id="req_slow", harness="codex-native"))
|
||||
|
||||
# Starting the frame task returns immediately — the receive loop is free
|
||||
# while the probe blocks; the reply arrives from the frame's own task.
|
||||
host._start_frame_task(ws, raw) # type: ignore[arg-type] — duck-typed ws
|
||||
await asyncio.sleep(0.05)
|
||||
assert ws.sent == []
|
||||
|
||||
release_probe.set()
|
||||
await asyncio.wait_for(ws.first_send.wait(), timeout=2.0)
|
||||
reply = decode_host_frame(ws.sent[0])
|
||||
assert isinstance(reply, HostModelOptionsResultFrame)
|
||||
assert reply.request_id == "req_slow"
|
||||
assert reply.status == "ok"
|
||||
assert reply.models == [{"id": "gpt-5.6-sol", "displayName": "GPT-5.6-Sol"}]
|
||||
_cleanup_host(host)
|
||||
|
||||
|
||||
async def test_silent_connect_streak_escalates_and_slows_reconnects(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
@@ -4776,3 +4709,37 @@ async def test_run_reconnects_promptly_after_suspend(
|
||||
# The disconnect was attributed to the resume, and the flag was consumed.
|
||||
assert any("resumed from suspend" in r.message for r in caplog.records)
|
||||
assert host._woke_from_suspend is False
|
||||
|
||||
|
||||
def test_post_connect_auth_rejection_escalates_without_going_fatal(
|
||||
capsys: pytest.CaptureFixture[str],
|
||||
) -> None:
|
||||
"""A 401/403 AFTER the host has connected retries forever (never fatal),
|
||||
but a sustained streak escalates the operator message from a transient-
|
||||
network hint to a re-auth prompt that names ``omnigent login`` — so a
|
||||
permanently-rejected credential surfaces instead of looping silently.
|
||||
"""
|
||||
from omnigent.host.connect import _AUTH_REJECT_ESCALATE_ATTEMPTS
|
||||
|
||||
host = _make_host_process()
|
||||
host._ever_connected = True
|
||||
|
||||
# First rejection: retryable (None), framed as a transient network blip.
|
||||
assert host._classify_http_status(403) is None
|
||||
first = capsys.readouterr().err
|
||||
assert "network dropped" in first
|
||||
assert "omnigent login" not in first
|
||||
|
||||
# Streak climbs toward — but not to — the escalation threshold: stays quiet
|
||||
# so a brief VPN outage never raises a false re-auth alarm.
|
||||
for _ in range(2, _AUTH_REJECT_ESCALATE_ATTEMPTS):
|
||||
assert host._classify_http_status(403) is None
|
||||
assert "omnigent login" not in capsys.readouterr().err
|
||||
|
||||
# Crossing the threshold escalates — names the real remedy — and is STILL
|
||||
# retryable (no fatal error, so a recoverable daemon is never killed).
|
||||
assert host._classify_http_status(403) is None
|
||||
escalated = capsys.readouterr().err
|
||||
assert "omnigent login http://localhost:8000" in escalated
|
||||
assert "no longer a transient network blip" in escalated
|
||||
assert host._auth_retry_streak == _AUTH_REJECT_ESCALATE_ATTEMPTS
|
||||
|
||||
@@ -973,6 +973,40 @@ def test_settings_update_drops_invalid_effort_keeps_model(
|
||||
assert "effort" not in params
|
||||
|
||||
|
||||
@pytest.mark.parametrize("effort", ["ultra", "max"])
|
||||
def test_settings_update_forwards_codex_high_reasoning_levels(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
effort: str,
|
||||
) -> None:
|
||||
"""
|
||||
Sol's ``max``/``ultra`` reach the wire instead of coercing to ``xhigh``.
|
||||
|
||||
Codex advertises these as per-model reasoning levels and honors a turn at
|
||||
them (Sol's ``ultra`` runs subagents), so a web-picked level must ride
|
||||
through on ``thread/settings/update`` unchanged rather than being clamped.
|
||||
"""
|
||||
_FakeCodexNativeClient.requests = []
|
||||
_FakeCodexNativeClient.created = []
|
||||
_FakeCodexNativeClient.next_turn = 1
|
||||
monkeypatch.setattr(
|
||||
"omnigent.codex_native_app_server.CodexAppServerClient",
|
||||
_FakeCodexNativeClient,
|
||||
)
|
||||
_start_state(tmp_path)
|
||||
executor = CodexNativeExecutor(bridge_dir=tmp_path)
|
||||
|
||||
_run_turn_with_config(
|
||||
executor,
|
||||
"hi",
|
||||
ExecutorConfig(model="gpt-5.6-sol", extra={"reasoning_effort": effort}),
|
||||
)
|
||||
|
||||
method, params = _FakeCodexNativeClient.requests[0]
|
||||
assert method == "thread/settings/update"
|
||||
assert params["effort"] == effort
|
||||
|
||||
|
||||
def test_run_turn_surfaces_recorded_startup_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -605,6 +605,12 @@ async def test_launch_enables_csi_u_extended_keys_quietly(
|
||||
cmd,
|
||||
["set-option", "-sq", "extended-keys-format", "csi-u"],
|
||||
)
|
||||
# tmux copy-mode may export selections to an attached terminal, but pane
|
||||
# applications must not be allowed to create paste buffers through OSC 52.
|
||||
assert contains_subsequence(
|
||||
cmd,
|
||||
["set-option", "-sq", "set-clipboard", "external"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -14,16 +14,47 @@ import httpx
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
|
||||
from omnigent import claude_native
|
||||
from omnigent.process_logging import PROCESS_LOG_FILE_ENV_VAR
|
||||
from omnigent.runner import create_runner_app
|
||||
from omnigent.runner.mcp_manager import McpSchemasResult
|
||||
from omnigent.spec.types import AgentSpec, ExecutorSpec, MCPServerConfig
|
||||
from tests.runner.helpers import NullServerClient
|
||||
|
||||
# The real store-backed catalog resolver, captured before the autouse fixture
|
||||
# below stubs it: a test that exercises the catalog path re-patches the module
|
||||
# attribute back to this. (An assignment, not an alias import, so lint
|
||||
# autofixes can't strip it as unused.)
|
||||
REAL_CLAUDE_LAUNCH_CATALOG = claude_native.claude_launch_catalog
|
||||
|
||||
# Project root: two parents up from this conftest (tests/runner/ → repo root).
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolated_model_catalog_store(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path_factory: pytest.TempPathFactory
|
||||
) -> None:
|
||||
"""Keep launch-path catalog consults off the developer's machine.
|
||||
|
||||
The native launch paths consult the shared model-catalog store and, on
|
||||
a miss, probe the REAL harness CLIs — which a unit test must never do
|
||||
(a real ``claude`` boot takes ~6 s and writes the developer's real
|
||||
``~/.omnigent`` store). Redirect the store's directory seam per test
|
||||
and stub both launch-catalog resolvers to "no catalog" (the
|
||||
pre-catalog behavior); a test exercising catalogs re-patches them
|
||||
explicitly.
|
||||
"""
|
||||
store_dir = tmp_path_factory.mktemp("model_catalog_store")
|
||||
monkeypatch.setattr("omnigent.model_catalog_store._data_dir", lambda: store_dir)
|
||||
|
||||
async def _no_catalog(*_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("omnigent.claude_native.claude_launch_catalog", _no_catalog)
|
||||
monkeypatch.setattr("omnigent.codex_native_app_server.codex_launch_catalog", _no_catalog)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _ensure_subprocess_pythonpath(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Put the project root on ``PYTHONPATH`` for spawned harness children.
|
||||
@@ -218,6 +249,7 @@ class _FakeProcessManager:
|
||||
# idle reaper's guard is actually populated for a live turn.
|
||||
self.marked_in_flight: list[tuple[str, str]] = []
|
||||
self.cleared_in_flight: list[str] = []
|
||||
self.activity_noted: list[str] = []
|
||||
|
||||
async def get_client(
|
||||
self, conversation_id: str, harness: str, env: Any = None
|
||||
@@ -235,6 +267,10 @@ class _FakeProcessManager:
|
||||
"""Check if a turn is marked active for this conversation."""
|
||||
return conversation_id in self._active_turns
|
||||
|
||||
def note_activity(self, conversation_id: str) -> None:
|
||||
"""Record an activity lease refresh for a conversation."""
|
||||
self.activity_noted.append(conversation_id)
|
||||
|
||||
def mark_turn_active(self, conversation_id: str) -> None:
|
||||
"""Mark a conversation as having an active turn (test helper)."""
|
||||
self._active_turns.add(conversation_id)
|
||||
|
||||
@@ -249,6 +249,57 @@ async def test_events_codex_native_settings_change_uses_thread_settings_update(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_codex_native_model_change_without_bridge_fails_loud(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A model ask with no loaded Codex bridge answers 503, never 204.
|
||||
|
||||
Nothing applied the settings, so a silent success would let the row
|
||||
claim a switch the app-server never saw — the server surfaces the 503
|
||||
as the visible not-applied error instead.
|
||||
"""
|
||||
from omnigent.spec.types import ExecutorSpec
|
||||
|
||||
conv_id = "624fe55f9d5a7f66fec5c5401a930b85"
|
||||
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
||||
|
||||
codex_native_spec = AgentSpec(
|
||||
spec_version=1,
|
||||
name="t",
|
||||
executor=ExecutorSpec(
|
||||
type="omnigent",
|
||||
config={"harness": "codex-native", "model": "gpt-5.4"},
|
||||
),
|
||||
)
|
||||
|
||||
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
||||
del agent_id, session_id
|
||||
return codex_native_spec
|
||||
|
||||
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
||||
app = create_runner_app(
|
||||
process_manager=pm, # type: ignore[arg-type]
|
||||
spec_resolver=_resolver,
|
||||
server_client=NullServerClient(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
async with _runner_client(app) as client:
|
||||
create_resp = await client.post(
|
||||
"/v1/sessions",
|
||||
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{conv_id}/events",
|
||||
json={"type": "model_change", "model": "gpt-5.6-terra"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 503, resp.text
|
||||
assert resp.json()["error"] == "codex_native_settings_update_failed"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_kiro_native_model_options_use_cli_catalog(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -570,9 +621,18 @@ async def test_codex_native_model_options_returns_503_until_bridge_state_exists(
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("config_model", "expected_default"),
|
||||
[
|
||||
pytest.param(None, "gpt-5.5", id="unset-keeps-codex-default"),
|
||||
pytest.param("gpt-5.4-mini", "gpt-5.4-mini", id="launch-model-wins"),
|
||||
],
|
||||
)
|
||||
async def test_codex_native_model_options_query_model_list(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
config_model: str | None,
|
||||
expected_default: str,
|
||||
) -> None:
|
||||
"""
|
||||
Runner model-options endpoint queries Codex ``model/list``.
|
||||
@@ -580,7 +640,10 @@ async def test_codex_native_model_options_query_model_list(
|
||||
The Web UI must not carry its own Codex model / effort catalog. The
|
||||
runner is the process that can reach the session's Codex app-server, so
|
||||
this endpoint should ask Codex for models and return those model objects
|
||||
unchanged for the AP snapshot.
|
||||
for the AP snapshot, changing only which one is marked default. Codex's
|
||||
own ``isDefault`` is its built-in preference and says nothing about this
|
||||
session, so the model named by the session's ``config.toml`` — the one
|
||||
the pane launched on — wins when the list offers it.
|
||||
"""
|
||||
from omnigent import codex_native_app_server
|
||||
from omnigent.spec.types import ExecutorSpec
|
||||
@@ -588,13 +651,46 @@ async def test_codex_native_model_options_query_model_list(
|
||||
conv_id = "68ba0a62ebe928d26adf37c8974ce1eb"
|
||||
monkeypatch.setattr(codex_native_bridge, "_BRIDGE_ROOT", tmp_path / "codex-bridge")
|
||||
bridge_dir = codex_native_bridge.bridge_dir_for_bridge_id(conv_id)
|
||||
codex_home = tmp_path / "codex-home"
|
||||
codex_home.mkdir()
|
||||
if config_model is not None:
|
||||
(codex_home / "config.toml").write_text(f'model = "{config_model}"\n')
|
||||
|
||||
async def _fake_auto_create_codex(
|
||||
session_id: str,
|
||||
resource_registry: Any,
|
||||
publish_event: Any,
|
||||
**kwargs: Any,
|
||||
) -> SessionResourceView:
|
||||
"""Stand in for the codex launch, leaving the seeded bridge dir alone."""
|
||||
del resource_registry, publish_event, kwargs
|
||||
return SessionResourceView(
|
||||
id="terminal_codex_main",
|
||||
type="terminal",
|
||||
session_id=session_id,
|
||||
name="codex:main",
|
||||
metadata={"terminal_name": "codex", "session_key": "main", "running": True},
|
||||
)
|
||||
|
||||
# Session create launches Codex for real, and that launch owns the bridge
|
||||
# dir: it calls clear_bridge_state, and its forwarder task rewrites both
|
||||
# the state and CODEX_HOME/config.toml after the response is returned. On
|
||||
# a machine where Codex and a Databricks profile resolve, that wipes the
|
||||
# state seeded below no matter which side of create seeds it. The endpoint
|
||||
# under test stays real: it still reads bridge state and CODEX_HOME off
|
||||
# disk and still queries Codex through the fake app-server client.
|
||||
monkeypatch.setattr(
|
||||
"omnigent.runner.native.orchestration._auto_create_codex_terminal",
|
||||
_fake_auto_create_codex,
|
||||
)
|
||||
|
||||
codex_native_bridge.write_bridge_state(
|
||||
bridge_dir,
|
||||
codex_native_bridge.CodexNativeBridgeState(
|
||||
session_id=conv_id,
|
||||
socket_path="ws://127.0.0.1:43210",
|
||||
thread_id="thread_codex",
|
||||
codex_home=str(tmp_path / "codex-home"),
|
||||
codex_home=str(codex_home),
|
||||
active_turn_id=None,
|
||||
),
|
||||
)
|
||||
@@ -692,31 +788,31 @@ async def test_codex_native_model_options_query_model_list(
|
||||
resp = await client.get(f"/v1/sessions/{conv_id}/codex-model-options")
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == {
|
||||
"models": [
|
||||
{
|
||||
"id": "gpt-5.5",
|
||||
"model": "databricks-gpt-5-5",
|
||||
"displayName": "GPT-5.5",
|
||||
"defaultReasoningEffort": "high",
|
||||
"supportedReasoningEfforts": [
|
||||
{"reasoningEffort": "low", "description": "Low"},
|
||||
{"reasoningEffort": "medium", "description": "Medium"},
|
||||
],
|
||||
"isDefault": True,
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.4-mini",
|
||||
"model": "databricks-gpt-5-4-mini",
|
||||
"displayName": "GPT-5.4 mini",
|
||||
"defaultReasoningEffort": "medium",
|
||||
"supportedReasoningEfforts": [
|
||||
{"reasoningEffort": "minimal", "description": "Minimal"}
|
||||
],
|
||||
"isDefault": False,
|
||||
},
|
||||
]
|
||||
}
|
||||
expected_models: list[dict[str, object]] = [
|
||||
{
|
||||
"id": "gpt-5.5",
|
||||
"model": "databricks-gpt-5-5",
|
||||
"displayName": "GPT-5.5",
|
||||
"defaultReasoningEffort": "high",
|
||||
"supportedReasoningEfforts": [
|
||||
{"reasoningEffort": "low", "description": "Low"},
|
||||
{"reasoningEffort": "medium", "description": "Medium"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "gpt-5.4-mini",
|
||||
"model": "databricks-gpt-5-4-mini",
|
||||
"displayName": "GPT-5.4 mini",
|
||||
"defaultReasoningEffort": "medium",
|
||||
"supportedReasoningEfforts": [
|
||||
{"reasoningEffort": "minimal", "description": "Minimal"}
|
||||
],
|
||||
},
|
||||
]
|
||||
for model_row in expected_models:
|
||||
if model_row["id"] == expected_default:
|
||||
model_row["isDefault"] = True
|
||||
assert resp.json() == {"models": expected_models}
|
||||
assert fake_client.requests == [
|
||||
("model/list", {"includeHidden": False}),
|
||||
("model/list", {"includeHidden": False, "cursor": "next-page"}),
|
||||
@@ -729,9 +825,16 @@ async def test_codex_native_model_options_query_model_list(
|
||||
async def test_claude_native_model_options_use_session_launch_catalog(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The runner exposes friendly aliases from one cached Claude config."""
|
||||
from omnigent.claude_native import ClaudeNativeUcodeConfig
|
||||
"""The session listing is the launch catalog from one cached Claude config.
|
||||
|
||||
The harness probe's rows are served with the harness's own default
|
||||
marked, both reads agree (the second is the session cache), and the
|
||||
launch-time config resolution is shared — the spec resolves once.
|
||||
"""
|
||||
from omnigent.claude_native import ClaudeModelProbe, ClaudeNativeUcodeConfig
|
||||
from tests.runner.conftest import REAL_CLAUDE_LAUNCH_CATALOG
|
||||
|
||||
monkeypatch.setattr("omnigent.claude_native.claude_launch_catalog", REAL_CLAUDE_LAUNCH_CATALOG)
|
||||
conv_id = "6a416804870ed618cc8908f5cebab937"
|
||||
claude_spec = AgentSpec(
|
||||
spec_version=1,
|
||||
@@ -758,6 +861,29 @@ async def test_claude_native_model_options_use_session_launch_catalog(
|
||||
return config
|
||||
|
||||
monkeypatch.setattr("omnigent.claude_native.resolve_native_claude_config", _resolve)
|
||||
probe_calls: list[int] = []
|
||||
|
||||
async def _probe(claude_config: object) -> ClaudeModelProbe:
|
||||
del claude_config
|
||||
probe_calls.append(1)
|
||||
return ClaudeModelProbe(
|
||||
alias_rows=[
|
||||
{
|
||||
"id": "opus",
|
||||
"model": "system.ai.claude-opus-4-10",
|
||||
"displayName": "Opus 4.10",
|
||||
},
|
||||
{
|
||||
"id": "haiku",
|
||||
"model": "system.ai.claude-haiku-4-5",
|
||||
"displayName": "Haiku 4.5",
|
||||
},
|
||||
],
|
||||
default_model="system.ai.claude-opus-4-10",
|
||||
default_label="Opus 4.10",
|
||||
)
|
||||
|
||||
monkeypatch.setattr("omnigent.claude_native.probe_claude_model_options", _probe)
|
||||
|
||||
async def _fake_auto_create(
|
||||
session_id: str,
|
||||
@@ -809,15 +935,131 @@ async def test_claude_native_model_options_use_session_launch_catalog(
|
||||
"id": "haiku",
|
||||
"model": "system.ai.claude-haiku-4-5",
|
||||
"displayName": "Haiku 4.5",
|
||||
"isDefault": False,
|
||||
},
|
||||
]
|
||||
}
|
||||
assert first.status_code == 200
|
||||
assert first.json() == expected
|
||||
assert second.json() == expected
|
||||
# Auto-create and both UI reads shared one launch-time live query.
|
||||
# Auto-create and both UI reads shared one launch-time live query, and
|
||||
# the store's fingerprint cache kept the probe to a single boot.
|
||||
assert resolved_specs == [claude_spec]
|
||||
assert probe_calls == [1]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claude_native_model_options_serves_probe_rows_after_pending(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A slow probe answers 503-pending, then the probed rows, then the cache.
|
||||
|
||||
The server's fetch retries 503s, so an in-flight probe holds the
|
||||
catalog back rather than serving an invented list; once the harness
|
||||
answers, its rows ARE the catalog and the session serves them for its
|
||||
lifetime. The store's single-flight probe survives the inline wait
|
||||
expiring — the second read joins it instead of restarting it.
|
||||
"""
|
||||
from omnigent.claude_native import ClaudeNativeUcodeConfig
|
||||
from omnigent.runner import app as runner_app_module
|
||||
from tests.runner.conftest import REAL_CLAUDE_LAUNCH_CATALOG
|
||||
|
||||
monkeypatch.setattr("omnigent.claude_native.claude_launch_catalog", REAL_CLAUDE_LAUNCH_CATALOG)
|
||||
|
||||
conv_id = "9c527915981fe729dd9a19a6dfcbca49"
|
||||
claude_spec = AgentSpec(
|
||||
spec_version=1,
|
||||
name="t",
|
||||
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
||||
)
|
||||
|
||||
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
||||
del agent_id, session_id
|
||||
return claude_spec
|
||||
|
||||
config = ClaudeNativeUcodeConfig(
|
||||
env={"ANTHROPIC_DEFAULT_OPUS_MODEL": "system.ai.claude-opus-4-10"},
|
||||
api_key_helper="printf token",
|
||||
model="system.ai.claude-opus-4-10",
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.claude_native.resolve_native_claude_config",
|
||||
lambda *, spec: config,
|
||||
)
|
||||
release = asyncio.Event()
|
||||
|
||||
async def _slow_probe(claude_config: object) -> object:
|
||||
del claude_config
|
||||
await release.wait()
|
||||
from omnigent.claude_native import ClaudeModelProbe
|
||||
|
||||
return ClaudeModelProbe(
|
||||
alias_rows=[{"id": "sonnet[1m]", "model": "claude-sonnet-5[1m]"}],
|
||||
default_model=None,
|
||||
default_label=None,
|
||||
)
|
||||
|
||||
monkeypatch.setattr("omnigent.claude_native.probe_claude_model_options", _slow_probe)
|
||||
monkeypatch.setattr(runner_app_module, "_CLAUDE_MODEL_OPTIONS_INLINE_WAIT_S", 0.01)
|
||||
|
||||
async def _fake_auto_create(
|
||||
session_id: str,
|
||||
resource_registry: Any,
|
||||
publish_event: Any,
|
||||
**kwargs: Any,
|
||||
) -> SessionResourceView:
|
||||
del resource_registry, publish_event
|
||||
resolver = kwargs.get("resolve_launch_config")
|
||||
recorder = kwargs.get("record_launch_config")
|
||||
assert callable(resolver)
|
||||
assert callable(recorder)
|
||||
recorder(session_id, await resolver())
|
||||
return SessionResourceView(
|
||||
id="terminal_claude_main",
|
||||
type="terminal",
|
||||
session_id=session_id,
|
||||
name="claude:main",
|
||||
metadata={"terminal_name": "claude", "session_key": "main", "running": True},
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
"omnigent.runner.native.orchestration._auto_create_claude_terminal", _fake_auto_create
|
||||
)
|
||||
app = create_runner_app(
|
||||
process_manager=_FakeProcessManager(_ScriptedHarnessClient([])), # type: ignore[arg-type]
|
||||
spec_resolver=_resolver,
|
||||
server_client=NullServerClient(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
async with _runner_client(app) as client:
|
||||
create_resp = await client.post(
|
||||
"/v1/sessions",
|
||||
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
pending = await client.get(f"/v1/sessions/{conv_id}/claude-model-options")
|
||||
assert pending.status_code == 503
|
||||
assert pending.json()["error"] == "claude_native_model_options_pending"
|
||||
release.set()
|
||||
resolved = await client.get(f"/v1/sessions/{conv_id}/claude-model-options")
|
||||
cached = await client.get(f"/v1/sessions/{conv_id}/claude-model-options")
|
||||
|
||||
assert resolved.status_code == 200
|
||||
# The harness's probed rows are the catalog — no configured or static
|
||||
# rows are merged in — plus the config's launch pin appended as the
|
||||
# marked default: a Default launch on this shape passes it as --model,
|
||||
# so it is the row a Default launch truly runs.
|
||||
assert resolved.json() == {
|
||||
"models": [
|
||||
{"id": "sonnet[1m]", "model": "claude-sonnet-5[1m]"},
|
||||
{
|
||||
"id": "system.ai.claude-opus-4-10",
|
||||
"model": "system.ai.claude-opus-4-10",
|
||||
"displayName": "system.ai.claude-opus-4-10",
|
||||
"isDefault": True,
|
||||
},
|
||||
]
|
||||
}
|
||||
assert cached.json() == resolved.json()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -34,12 +34,11 @@ from tests.runner.helpers import NullServerClient
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"effort_value",
|
||||
# ``EFFORT_VALUES`` is a superset of ``CLAUDE_EFFORTS``:
|
||||
# PATCH accepts {none, minimal, low, medium, high, xhigh, max}
|
||||
# but Claude Code's ``/effort`` slash only accepts the last five.
|
||||
# ``none`` and ``minimal`` must skip injection (typing ``/effort
|
||||
# none`` would land as a TUI error). ``None`` (clear) must skip
|
||||
# too — Claude has no slash form for "use spawn default".
|
||||
# ``EFFORT_VALUES`` is a superset of ``CLAUDE_EFFORTS``: PATCH accepts the
|
||||
# full effort vocabulary, but Claude Code's ``/effort`` slash only accepts
|
||||
# low/medium/high/xhigh/max. ``none`` and ``minimal`` must skip injection
|
||||
# (typing ``/effort none`` would land as a TUI error). ``None`` (clear) must
|
||||
# skip too — Claude has no slash form for "use spawn default".
|
||||
["none", "minimal", None],
|
||||
)
|
||||
async def test_events_effort_change_on_native_session_skips_inject_for_unsupported_level(
|
||||
@@ -272,6 +271,189 @@ async def test_events_effort_change_on_non_native_session_is_204_noop(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_permission_mode_change_on_native_session_switches_and_echoes_mode(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
``permission_mode_change`` cycles the pane and returns the settled mode.
|
||||
|
||||
Claude Code's ``--permission-mode`` is launch-only, so the runner drives
|
||||
the TUI's shift+tab cycle via the bridge. The 200 body echoes the mode the
|
||||
pane actually landed on — the Omnigent server persists that value, so a
|
||||
regression returning 204 (or dropping the body) would leave the web UI
|
||||
showing a mode the session isn't in.
|
||||
"""
|
||||
from omnigent.spec.types import ExecutorSpec
|
||||
|
||||
calls: list[str] = []
|
||||
|
||||
def _fake_set_mode(bridge_dir: Any, *, mode: str, timeout_s: float) -> str:
|
||||
"""Record the requested mode; report it as reached."""
|
||||
del bridge_dir, timeout_s
|
||||
calls.append(mode)
|
||||
return mode
|
||||
|
||||
monkeypatch.setattr(claude_native_bridge, "set_permission_mode", _fake_set_mode)
|
||||
|
||||
native_spec = AgentSpec(
|
||||
spec_version=1,
|
||||
name="t",
|
||||
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
||||
)
|
||||
|
||||
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
||||
"""Return the native spec for any agent_id."""
|
||||
del agent_id
|
||||
return native_spec
|
||||
|
||||
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
||||
app = create_runner_app(
|
||||
process_manager=pm, # type: ignore[arg-type]
|
||||
spec_resolver=_resolver,
|
||||
server_client=NullServerClient(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
async with _runner_client(app) as client:
|
||||
create_resp = await client.post(
|
||||
"/v1/sessions",
|
||||
json={
|
||||
"session_id": "2f77519bd9daa4e9bc2df649fe468500",
|
||||
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
|
||||
resp = await client.post(
|
||||
"/v1/sessions/2f77519bd9daa4e9bc2df649fe468500/events",
|
||||
json={"type": "permission_mode_change", "permission_mode": "auto"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200, resp.text
|
||||
assert resp.json() == {"permission_mode": "auto"}
|
||||
assert calls == ["auto"], f"Expected one switch to auto, got {calls!r}"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_permission_mode_change_returns_503_when_mode_unreachable(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
A failed switch surfaces 503 so the label is never persisted.
|
||||
|
||||
``auto`` is only in the shift+tab cycle for accounts that have the mode.
|
||||
The Omnigent server treats a non-2xx as "the pane did not move" and skips
|
||||
persisting the label, so this must not report success.
|
||||
"""
|
||||
from omnigent.spec.types import ExecutorSpec
|
||||
|
||||
def _fake_set_mode(bridge_dir: Any, *, mode: str, timeout_s: float) -> str:
|
||||
"""Fail the way an unreachable mode does."""
|
||||
del bridge_dir, mode, timeout_s
|
||||
raise RuntimeError("The mode is not available in this session's cycle.")
|
||||
|
||||
monkeypatch.setattr(claude_native_bridge, "set_permission_mode", _fake_set_mode)
|
||||
|
||||
native_spec = AgentSpec(
|
||||
spec_version=1,
|
||||
name="t",
|
||||
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
||||
)
|
||||
|
||||
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
||||
"""Return the native spec for any agent_id."""
|
||||
del agent_id
|
||||
return native_spec
|
||||
|
||||
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
||||
app = create_runner_app(
|
||||
process_manager=pm, # type: ignore[arg-type]
|
||||
spec_resolver=_resolver,
|
||||
server_client=NullServerClient(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
async with _runner_client(app) as client:
|
||||
create_resp = await client.post(
|
||||
"/v1/sessions",
|
||||
json={
|
||||
"session_id": "3f88519bd9daa4e9bc2df649fe468511",
|
||||
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
|
||||
resp = await client.post(
|
||||
"/v1/sessions/3f88519bd9daa4e9bc2df649fe468511/events",
|
||||
json={"type": "permission_mode_change", "permission_mode": "auto"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 503, resp.text
|
||||
body = resp.json()
|
||||
# The error CODE names the failure category; the runner deliberately
|
||||
# redacts exception text from client-facing details (the cause is logged
|
||||
# server-side instead), so the detail is the fixed safe string.
|
||||
assert body.get("error") == "claude_native_permission_mode_failed", body
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_permission_mode_change_on_non_native_session_is_204_noop(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
Only claude-native sessions act on ``permission_mode_change``.
|
||||
|
||||
No other harness has Claude's shift+tab cycle, so the dispatch must
|
||||
short-circuit before reaching the bridge.
|
||||
"""
|
||||
from omnigent.spec.types import ExecutorSpec
|
||||
|
||||
def _fake_set_mode(bridge_dir: Any, *, mode: str, timeout_s: float) -> str:
|
||||
"""Fail the test if a non-native session reaches the bridge."""
|
||||
del bridge_dir, mode, timeout_s
|
||||
raise AssertionError(
|
||||
"set_permission_mode must never be called for non-claude-native sessions."
|
||||
)
|
||||
|
||||
monkeypatch.setattr(claude_native_bridge, "set_permission_mode", _fake_set_mode)
|
||||
|
||||
default_spec = AgentSpec(
|
||||
spec_version=1,
|
||||
name="t",
|
||||
executor=ExecutorSpec(type="omnigent", config={}),
|
||||
)
|
||||
|
||||
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
||||
"""Return the default spec for any agent_id."""
|
||||
del agent_id
|
||||
return default_spec
|
||||
|
||||
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
||||
app = create_runner_app(
|
||||
process_manager=pm, # type: ignore[arg-type]
|
||||
spec_resolver=_resolver,
|
||||
server_client=NullServerClient(), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
async with _runner_client(app) as client:
|
||||
create_resp = await client.post(
|
||||
"/v1/sessions",
|
||||
json={
|
||||
"session_id": "4f99519bd9daa4e9bc2df649fe468522",
|
||||
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
|
||||
resp = await client.post(
|
||||
"/v1/sessions/4f99519bd9daa4e9bc2df649fe468522/events",
|
||||
json={"type": "permission_mode_change", "permission_mode": "auto"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 204, (
|
||||
f"Non-native permission_mode_change must 204 no-op; got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_compact_on_native_session_types_slash_command(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -1936,6 +2118,264 @@ async def test_events_model_change_on_native_session_types_slash_command(
|
||||
)
|
||||
|
||||
|
||||
async def _post_model_change_with_status_sequence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
status_values: list[str | None],
|
||||
pane_status: str | None = None,
|
||||
) -> Any:
|
||||
"""Run one claude-native ``model_change`` with a scripted status file.
|
||||
|
||||
``status_values`` are successive ``read_claude_status_model`` answers
|
||||
(the first is the pre-injection baseline); the last value repeats once
|
||||
the script is exhausted. Injection is stubbed; the confirm pacing is
|
||||
tightened so the unconfirmed path stays fast.
|
||||
|
||||
:returns: The ``/events`` HTTP response.
|
||||
"""
|
||||
from omnigent.runner import app as runner_app_module
|
||||
from omnigent.spec.types import ExecutorSpec
|
||||
|
||||
def _fake_inject(
|
||||
bridge_dir: Any,
|
||||
*,
|
||||
command: str,
|
||||
timeout_s: float,
|
||||
auto_confirm: bool = False,
|
||||
confirm_hint: str | None = None,
|
||||
) -> None:
|
||||
del bridge_dir, command, timeout_s, auto_confirm, confirm_hint
|
||||
|
||||
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
|
||||
monkeypatch.setattr(
|
||||
claude_native_bridge,
|
||||
"read_model_env",
|
||||
lambda _bridge_dir: {"ANTHROPIC_CUSTOM_MODEL_OPTION": "claude-opus-4-7"},
|
||||
)
|
||||
script = list(status_values)
|
||||
|
||||
def _scripted_status(_bridge_dir: Any) -> str | None:
|
||||
return script.pop(0) if len(script) > 1 else script[0]
|
||||
|
||||
monkeypatch.setattr(claude_native_bridge, "read_claude_status_model", _scripted_status)
|
||||
# No tmux behind these tests: the in-loop dialog check must not spend a
|
||||
# real 1 s tmux-info wait per poll.
|
||||
monkeypatch.setattr(claude_native_bridge, "confirm_dialog_if_open", lambda _b, *, hint: False)
|
||||
monkeypatch.setattr(runner_app_module, "_CLAUDE_MODEL_CONFIRM_TIMEOUT_S", 0.3)
|
||||
monkeypatch.setattr(runner_app_module, "_CLAUDE_MODEL_CONFIRM_POLL_S", 0.01)
|
||||
|
||||
native_spec = AgentSpec(
|
||||
spec_version=1,
|
||||
name="t",
|
||||
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
||||
)
|
||||
|
||||
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
||||
del agent_id, session_id
|
||||
return native_spec
|
||||
|
||||
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
||||
app = create_runner_app(
|
||||
process_manager=pm, # type: ignore[arg-type]
|
||||
spec_resolver=_resolver,
|
||||
server_client=NullServerClient(), # type: ignore[arg-type]
|
||||
)
|
||||
async with _runner_client(app) as client:
|
||||
create_resp = await client.post(
|
||||
"/v1/sessions",
|
||||
json={
|
||||
"session_id": "68c7c1acc5eeec3978c5e62043da51a5",
|
||||
"agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb",
|
||||
},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
if pane_status is not None:
|
||||
app.state.native_pane_status["68c7c1acc5eeec3978c5e62043da51a5"] = pane_status
|
||||
return await client.post(
|
||||
"/v1/sessions/68c7c1acc5eeec3978c5e62043da51a5/events",
|
||||
json={"type": "model_change", "model": "claude-opus-4-7"},
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_model_change_confirms_against_the_status_file(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The switch replies success only after the pane's status shows it.
|
||||
|
||||
The statusLine snapshot starts on the old model and flips to the picked
|
||||
one after the injection — the design's confirmed-switch contract: the
|
||||
reply follows the pane, not the keystroke.
|
||||
"""
|
||||
resp = await _post_model_change_with_status_sequence(
|
||||
monkeypatch,
|
||||
["claude-opus-4-6", "claude-opus-4-6", "claude-opus-4-7"],
|
||||
)
|
||||
assert resp.status_code == 204, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_model_change_unconfirmed_switch_answers_503(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A pane that never switches makes the ask fail loud, not pass silent.
|
||||
|
||||
The statusLine snapshot keeps reporting the old model for the whole
|
||||
confirmation budget on an IDLE pane (the swallowed-dialog case): the
|
||||
runner must answer non-2xx so the server surfaces the divergence
|
||||
instead of the row claiming the pick.
|
||||
"""
|
||||
resp = await _post_model_change_with_status_sequence(
|
||||
monkeypatch,
|
||||
["claude-opus-4-6"],
|
||||
)
|
||||
assert resp.status_code == 503, resp.text
|
||||
body = resp.json()
|
||||
assert body["error"] == "claude_native_model_unconfirmed"
|
||||
assert "did not confirm" in body["detail"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_model_change_mid_turn_defers_instead_of_failing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""A switch during an active turn is deferred, never a visible failure.
|
||||
|
||||
Claude queues a mid-turn ``/model`` and applies it when the turn
|
||||
settles — possibly well past the confirmation budget. With the pane
|
||||
reporting ``running``, the timeout answers success (a detached watcher
|
||||
keeps answering the late confirm dialog) so the user does not get a
|
||||
"was not switched" error for a switch that is still on its way; the
|
||||
harness's report settles the picker when it lands.
|
||||
"""
|
||||
resp = await _post_model_change_with_status_sequence(
|
||||
monkeypatch,
|
||||
["claude-opus-4-6"],
|
||||
pane_status="running",
|
||||
)
|
||||
assert resp.status_code == 204, resp.text
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("pins", "picked", "expected_command"),
|
||||
[
|
||||
# The reported bug: a gateway config pins the three families but not
|
||||
# fable; picking the probed ``fable`` row injected ``/model opus`` —
|
||||
# the resolver swapped the alias for the provider default and the
|
||||
# vocabulary re-spelled that as its pinned alias.
|
||||
pytest.param(
|
||||
{
|
||||
"ANTHROPIC_BASE_URL": "https://example.databricks.com/ai-gateway/anthropic",
|
||||
"ANTHROPIC_DEFAULT_OPUS_MODEL": "databricks-claude-opus-5",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "databricks-claude-sonnet-5",
|
||||
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "databricks-claude-haiku-4-5",
|
||||
},
|
||||
"fable",
|
||||
"/model fable",
|
||||
id="gateway-unpinned-family-is-never-swapped-for-the-default",
|
||||
),
|
||||
# Bracket aliases are the harness's own /model vocabulary. On a
|
||||
# pinned env the old vocabulary had no spelling for them (503);
|
||||
pytest.param(
|
||||
{
|
||||
"ANTHROPIC_BASE_URL": "https://example.databricks.com/ai-gateway/anthropic",
|
||||
"ANTHROPIC_DEFAULT_SONNET_MODEL": "databricks-claude-sonnet-5",
|
||||
},
|
||||
"sonnet[1m]",
|
||||
"/model sonnet[1m]",
|
||||
id="pinned-bracket-alias-passes-through",
|
||||
),
|
||||
# on a bare login it stepped down to the family alias, silently
|
||||
# dropping the 1M-context marker.
|
||||
pytest.param(
|
||||
{},
|
||||
"sonnet[1m]",
|
||||
"/model sonnet[1m]",
|
||||
id="bare-login-bracket-alias-keeps-its-context-marker",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_events_model_change_applies_the_picked_alias_verbatim(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
pins: dict[str, str],
|
||||
picked: str,
|
||||
expected_command: str,
|
||||
) -> None:
|
||||
"""
|
||||
A picker alias reaches the pane as itself, never as another model.
|
||||
|
||||
The harness enumerated these aliases itself (they are its ``/model``
|
||||
vocabulary), so the injected command must carry the pick verbatim and
|
||||
leave resolution to Claude — anything else switches the pane to a
|
||||
model the user did not choose.
|
||||
"""
|
||||
from omnigent.claude_native import ClaudeNativeUcodeConfig
|
||||
|
||||
captured: list[str] = []
|
||||
|
||||
def _fake_inject(
|
||||
bridge_dir: Any,
|
||||
*,
|
||||
command: str,
|
||||
timeout_s: float,
|
||||
auto_confirm: bool = False,
|
||||
confirm_hint: str | None = None,
|
||||
) -> None:
|
||||
"""Record the injected command without touching tmux."""
|
||||
del bridge_dir, timeout_s, auto_confirm, confirm_hint
|
||||
captured.append(command)
|
||||
|
||||
monkeypatch.setattr(claude_native_bridge, "inject_slash_command", _fake_inject)
|
||||
monkeypatch.setattr(claude_native_bridge, "read_model_env", lambda _bridge_dir: dict(pins))
|
||||
monkeypatch.setattr("omnigent.claude_native._CLAUDE_CODE_MANAGED_SETTINGS_PATHS", ())
|
||||
config = (
|
||||
ClaudeNativeUcodeConfig(env=dict(pins), model=pins.get("ANTHROPIC_DEFAULT_OPUS_MODEL"))
|
||||
if pins
|
||||
else None
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"omnigent.claude_native.resolve_native_claude_config", lambda *, spec: config
|
||||
)
|
||||
|
||||
native_spec = AgentSpec(
|
||||
spec_version=1,
|
||||
name="t",
|
||||
executor=ExecutorSpec(type="omnigent", config={"harness": "claude-native"}),
|
||||
)
|
||||
|
||||
async def _resolver(agent_id: str, session_id: str | None = None) -> AgentSpec:
|
||||
"""Return the native spec for any agent_id."""
|
||||
del agent_id, session_id
|
||||
return native_spec
|
||||
|
||||
pm = _FakeProcessManager(_ScriptedHarnessClient([]))
|
||||
app = create_runner_app(
|
||||
process_manager=pm, # type: ignore[arg-type]
|
||||
spec_resolver=_resolver,
|
||||
server_client=NullServerClient(), # type: ignore[arg-type]
|
||||
)
|
||||
conv_id = uuid.uuid4().hex
|
||||
|
||||
async with _runner_client(app) as client:
|
||||
create_resp = await client.post(
|
||||
"/v1/sessions",
|
||||
json={"session_id": conv_id, "agent_id": "880b5afda28ad55ff74cbeb9b5fc67fb"},
|
||||
)
|
||||
assert create_resp.status_code == 201, create_resp.text
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{conv_id}/events",
|
||||
json={"type": "model_change", "model": picked},
|
||||
)
|
||||
|
||||
assert resp.status_code == 204, (
|
||||
f"model_change for {picked!r} must apply; got {resp.status_code}: {resp.text}"
|
||||
)
|
||||
assert captured == [expected_command], (
|
||||
f"picking {picked!r} must inject {expected_command!r}; injecting anything else "
|
||||
f"switches the pane to a model the user did not choose (got {captured!r})"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_events_model_change_rejects_a_model_the_picker_cannot_spell(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
@@ -1831,6 +1832,343 @@ async def test_parent_idle_with_stuck_wake_flag_and_drained_inbox_clears_flag()
|
||||
assert delivered_c["output"] == "C_DONE"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_repeat_identical_recovery_wake_is_suppressed() -> None:
|
||||
"""
|
||||
A recovery wake identical to the previous one is skipped, not re-posted.
|
||||
|
||||
The stranded-inbox recovery wake (``_rewake_parent_if_inbox_stranded``)
|
||||
re-nudges a parent that idled without draining. In a steady-state
|
||||
homogeneous fan-out — many same-named children, the parent draining at
|
||||
roughly the completion rate so the inbox count plateaus — the latest
|
||||
child's label and the pending count don't change between turn boundaries,
|
||||
so the recovery wake would repeat the *same* ``[System: ...]`` line every
|
||||
round. That verbatim repeat is pure spam: the parent already holds that
|
||||
exact instruction. The fix records the last delivered re-wake per parent
|
||||
and skips a follow-up re-wake that matches it.
|
||||
|
||||
The critical companion guarantee — the FIRST recovery wake still fires
|
||||
even when it matches an earlier *completion* wake — is pinned by
|
||||
``test_parent_idle_with_stuck_wake_flag_posts_recovery_wake``; only
|
||||
repeat *re-wakes* are deduped, never completion notices.
|
||||
|
||||
Sequence (wake counts bracketed): children share the ``gp/fanout`` label so
|
||||
the notices can match. (1) child A completes idle → wake [1]; parent turn
|
||||
T1 starts (clears flag); (2) child B completes mid-T1 → completion wake [2],
|
||||
re-arms flag; (3) T1 ends → recovery wake [3] ``gp/fanout … 2 results``
|
||||
(recorded as the last re-wake); (4) parent turn T2 starts (clears flag),
|
||||
the test drains one inbox item then child C completes mid-T2, bringing the
|
||||
count back to 2 → completion wake [4] (a fresh completion always posts);
|
||||
(5) T2 ends → the recovery wake would again be ``gp/fanout … 2 results`` —
|
||||
identical to step 3 — so it is SKIPPED (count stays [4]). Without the fix
|
||||
step 5 posts a 5th, duplicate wake — the discriminator.
|
||||
"""
|
||||
from omnigent.runner import app as runner_app
|
||||
|
||||
parent_id = "b3d5c9f1a26e4708b1f0c4d29e7a6f13"
|
||||
child_a = "5f2a1c8b90d34e6fa7c1b2d3e4f50617"
|
||||
child_b = "6a3b2d9c81e45f70b8d2c3e4f5061728"
|
||||
child_c = "7b4c3e0d92f560810c9e4d5a6b172839"
|
||||
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
||||
server_client = _WakeRecordingServerClient(parent_id)
|
||||
gate = asyncio.Event()
|
||||
harness_client = _BlockingHarnessClient(
|
||||
[
|
||||
_sse({"type": "response.created", "response": {"id": "resp_dedupe"}}),
|
||||
_sse({"type": "response.completed", "response": {"id": "resp_dedupe"}}),
|
||||
],
|
||||
gate,
|
||||
)
|
||||
pm = _FakeProcessManager(harness_client) # type: ignore[arg-type]
|
||||
app = create_runner_app(
|
||||
process_manager=pm, # type: ignore[arg-type]
|
||||
server_client=server_client, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
runner_app._session_inboxes_ref[parent_id] = session_inbox
|
||||
for child in (child_a, child_b, child_c):
|
||||
runner_app.register_subagent_work(
|
||||
parent_session_id=parent_id,
|
||||
child_session_id=child,
|
||||
agent="gp",
|
||||
title="fanout",
|
||||
)
|
||||
|
||||
async def _start_blocking_parent_turn() -> None:
|
||||
"""Post a parent message and wait for the (blocking) turn to start.
|
||||
|
||||
``post_seen`` resolves only after ``_run_turn_bg`` clears the wake
|
||||
flag, so callers know the flag is clear before completing a child.
|
||||
"""
|
||||
harness_client.post_seen.clear()
|
||||
parent_resp = await client.post(
|
||||
f"/v1/sessions/{parent_id}/events",
|
||||
json={
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"agent_id": "c1d2e3f4a5b60718293a4b5c6d7e8f90",
|
||||
"model": "test-agent",
|
||||
"harness": "openai-agents",
|
||||
"content": [{"type": "input_text", "text": "wake notice"}],
|
||||
},
|
||||
)
|
||||
assert parent_resp.status_code == 202, parent_resp.text
|
||||
await asyncio.wait_for(harness_client.post_seen.wait(), timeout=5.0)
|
||||
|
||||
async def _complete_child(child_id: str, output: str) -> None:
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{child_id}/events",
|
||||
json={"type": "external_session_status", "data": {"status": "idle", "output": output}},
|
||||
)
|
||||
assert resp.status_code == 204, resp.text
|
||||
|
||||
async def _wait_turn_idle() -> None:
|
||||
deadline = asyncio.get_running_loop().time() + 5.0
|
||||
while app.state.has_active_work():
|
||||
if asyncio.get_running_loop().time() > deadline:
|
||||
raise AssertionError("parent turn did not end within 5s")
|
||||
await asyncio.sleep(0.01)
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
try:
|
||||
async with _runner_client(app) as client:
|
||||
# 1. Child A completes idle → first wake.
|
||||
await _complete_child(child_a, "A_DONE")
|
||||
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
||||
server_client.wake_seen.clear()
|
||||
|
||||
# 2. Start T1, then child B completes mid-turn → completion wake,
|
||||
# re-arms the flag with no later turn to clear it.
|
||||
await _start_blocking_parent_turn()
|
||||
await _complete_child(child_b, "B_DONE")
|
||||
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
||||
server_client.wake_seen.clear()
|
||||
assert len(server_client.wake_posts) == 2, (
|
||||
f"Expected A + B wakes before T1 ends, got {len(server_client.wake_posts)}."
|
||||
)
|
||||
|
||||
# 3. End T1 → recovery wake (the first re-wake). It matches child
|
||||
# B's completion wake verbatim yet must still fire — that
|
||||
# contract is guarded elsewhere; here it seeds the last-re-wake
|
||||
# record used to dedupe step 5.
|
||||
gate.set()
|
||||
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
||||
server_client.wake_seen.clear()
|
||||
assert len(server_client.wake_posts) == 3, (
|
||||
f"Expected the recovery wake to bring the count to 3, got "
|
||||
f"{len(server_client.wake_posts)}."
|
||||
)
|
||||
recovery_text = server_client.wake_posts[2]["data"]["content"][0]["text"]
|
||||
assert "sub-agent gp/fanout finished (completed)" in recovery_text
|
||||
assert "2 results waiting in inbox" in recovery_text
|
||||
await _wait_turn_idle()
|
||||
|
||||
# 4. Start T2 (clears the flag). Drain one inbox item, then child C
|
||||
# completes mid-T2 — the count returns to 2, so C's completion
|
||||
# wake is identical text to the step-3 recovery wake. A fresh
|
||||
# completion always posts (never deduped), so this is wake [4].
|
||||
gate.clear()
|
||||
await _start_blocking_parent_turn()
|
||||
assert not session_inbox.empty()
|
||||
session_inbox.get_nowait()
|
||||
await _complete_child(child_c, "C_DONE")
|
||||
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
||||
server_client.wake_seen.clear()
|
||||
assert len(server_client.wake_posts) == 4, (
|
||||
f"Expected child C's completion wake as the 4th, got "
|
||||
f"{len(server_client.wake_posts)}."
|
||||
)
|
||||
|
||||
# 5. End T2 → the recovery wake would repeat "gp/fanout … 2 results"
|
||||
# verbatim (identical to step 3), so it must be SUPPRESSED. Give a
|
||||
# wrongly-scheduled 5th wake room to land before asserting.
|
||||
gate.set()
|
||||
await _wait_turn_idle()
|
||||
finally:
|
||||
gate.set()
|
||||
for child in (child_a, child_b, child_c):
|
||||
runner_app.unregister_subagent_work(child)
|
||||
runner_app._session_inboxes_ref.pop(parent_id, None)
|
||||
|
||||
# Exactly 4 wakes: A + B + recovery + C's completion. A 5th would be the
|
||||
# duplicate recovery wake this fix suppresses; 3 would mean C's genuine
|
||||
# completion notice was wrongly swallowed.
|
||||
assert len(server_client.wake_posts) == 4, (
|
||||
f"Expected the repeat recovery wake to be suppressed (4 wakes total), "
|
||||
f"got {len(server_client.wake_posts)}."
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recovery_wake_fires_again_for_an_episode_after_a_full_drain() -> None:
|
||||
"""
|
||||
A full inbox drain ends the stranding episode, so dedupe must not carry over.
|
||||
|
||||
``_last_rewake_notice`` suppresses a recovery wake that repeats the previous
|
||||
one verbatim (see
|
||||
``test_repeat_identical_recovery_wake_is_suppressed``). That record describes
|
||||
*outstanding* work, so it goes stale the moment the parent drains: a later
|
||||
fan-out round that happens to produce the same label and pending count is a
|
||||
genuinely new stranding episode and is still owed its nudge. Without
|
||||
clearing the record on drain the parent is left holding undelivered results
|
||||
with the wake flag cleared — nothing re-arms it once every child has
|
||||
finished, which is the exact strand ``_rewake_parent_if_inbox_stranded``
|
||||
exists to break.
|
||||
|
||||
Sequence (wake counts bracketed), all children sharing the ``gp/fanout``
|
||||
label so the notices can match. Episode 1: (1) child A completes idle →
|
||||
wake [1]; (2) parent turn T1 starts, child B completes mid-T1 → wake [2],
|
||||
re-arming the flag; (3) T1 ends → recovery wake [3] ``… 2 results``,
|
||||
recorded. (4) Turn T2 drains BOTH items and ends with the inbox empty —
|
||||
episode over. Episode 2: (5) child C completes → wake [4]; (6) turn T3
|
||||
starts, child D completes mid-T3 → wake [5], count back to 2. (7) T3 ends →
|
||||
the recovery wake reads ``… 2 results``, matching step 3, but the drain in
|
||||
step 4 cleared the record, so it MUST still fire → wake [6]. Carrying the
|
||||
record across the drain yields 5 and a parent stranded on 2 results — the
|
||||
discriminator.
|
||||
"""
|
||||
from omnigent.runner import app as runner_app
|
||||
|
||||
parent_id = "a1b2c3d4e5f60718293a4b5c6d7e8f01"
|
||||
child_a = "11112c8b90d34e6fa7c1b2d3e4f50617"
|
||||
child_b = "22222d9c81e45f70b8d2c3e4f5061728"
|
||||
child_c = "33333e0d92f560810c9e4d5a6b172839"
|
||||
child_d = "44444f1e03a671920dae5e6b7c28394a"
|
||||
children = (child_a, child_b, child_c, child_d)
|
||||
session_inbox: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
|
||||
server_client = _WakeRecordingServerClient(parent_id)
|
||||
gate = asyncio.Event()
|
||||
harness_client = _BlockingHarnessClient(
|
||||
[
|
||||
_sse({"type": "response.created", "response": {"id": "resp_drain"}}),
|
||||
_sse({"type": "response.completed", "response": {"id": "resp_drain"}}),
|
||||
],
|
||||
gate,
|
||||
)
|
||||
pm = _FakeProcessManager(harness_client) # type: ignore[arg-type]
|
||||
app = create_runner_app(
|
||||
process_manager=pm, # type: ignore[arg-type]
|
||||
server_client=server_client, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
runner_app._session_inboxes_ref[parent_id] = session_inbox
|
||||
for child in children:
|
||||
runner_app.register_subagent_work(
|
||||
parent_session_id=parent_id,
|
||||
child_session_id=child,
|
||||
agent="gp",
|
||||
title="fanout",
|
||||
)
|
||||
|
||||
async def _start_blocking_parent_turn() -> None:
|
||||
"""Post a parent message and wait for the (blocking) turn to start.
|
||||
|
||||
``post_seen`` resolves only after ``_run_turn_bg`` clears the wake flag,
|
||||
so callers know the flag is clear before completing a child.
|
||||
"""
|
||||
harness_client.post_seen.clear()
|
||||
parent_resp = await client.post(
|
||||
f"/v1/sessions/{parent_id}/events",
|
||||
json={
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"agent_id": "c1d2e3f4a5b60718293a4b5c6d7e8f90",
|
||||
"model": "test-agent",
|
||||
"harness": "openai-agents",
|
||||
"content": [{"type": "input_text", "text": "wake notice"}],
|
||||
},
|
||||
)
|
||||
assert parent_resp.status_code == 202, parent_resp.text
|
||||
await asyncio.wait_for(harness_client.post_seen.wait(), timeout=5.0)
|
||||
|
||||
async def _complete_child(child_id: str, output: str) -> None:
|
||||
resp = await client.post(
|
||||
f"/v1/sessions/{child_id}/events",
|
||||
json={"type": "external_session_status", "data": {"status": "idle", "output": output}},
|
||||
)
|
||||
assert resp.status_code == 204, resp.text
|
||||
|
||||
async def _wait_turn_idle() -> None:
|
||||
deadline = asyncio.get_running_loop().time() + 5.0
|
||||
while app.state.has_active_work():
|
||||
if asyncio.get_running_loop().time() > deadline:
|
||||
raise AssertionError("parent turn did not end within 5s")
|
||||
await asyncio.sleep(0.01)
|
||||
for _ in range(5):
|
||||
await asyncio.sleep(0)
|
||||
|
||||
try:
|
||||
async with _runner_client(app) as client:
|
||||
# Episode 1, steps 1-3: strand the inbox at 2 and take the recovery
|
||||
# wake, which records "… 2 results" as the last re-wake.
|
||||
await _complete_child(child_a, "A_DONE")
|
||||
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
||||
server_client.wake_seen.clear()
|
||||
await _start_blocking_parent_turn()
|
||||
await _complete_child(child_b, "B_DONE")
|
||||
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
||||
server_client.wake_seen.clear()
|
||||
gate.set()
|
||||
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
||||
server_client.wake_seen.clear()
|
||||
await _wait_turn_idle()
|
||||
assert len(server_client.wake_posts) == 3, (
|
||||
f"Expected A + B + recovery wakes to end episode 1, got "
|
||||
f"{len(server_client.wake_posts)}."
|
||||
)
|
||||
assert (
|
||||
"2 results waiting in inbox"
|
||||
in (server_client.wake_posts[2]["data"]["content"][0]["text"])
|
||||
)
|
||||
|
||||
# Step 4: the parent fully drains during T2, ending the episode.
|
||||
gate.clear()
|
||||
await _start_blocking_parent_turn()
|
||||
while not session_inbox.empty():
|
||||
session_inbox.get_nowait()
|
||||
gate.set()
|
||||
await _wait_turn_idle()
|
||||
assert len(server_client.wake_posts) == 3, (
|
||||
f"A drained inbox owes no wake, got {len(server_client.wake_posts)}."
|
||||
)
|
||||
|
||||
# Episode 2, steps 5-6: two new children bring the count back to 2,
|
||||
# so the pending recovery notice matches episode 1's verbatim.
|
||||
gate.clear()
|
||||
await _complete_child(child_c, "C_DONE")
|
||||
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
||||
server_client.wake_seen.clear()
|
||||
await _start_blocking_parent_turn()
|
||||
await _complete_child(child_d, "D_DONE")
|
||||
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
||||
server_client.wake_seen.clear()
|
||||
assert len(server_client.wake_posts) == 5, (
|
||||
f"Expected C + D completion wakes in episode 2, got "
|
||||
f"{len(server_client.wake_posts)}."
|
||||
)
|
||||
|
||||
# Step 7: T3 ends → the new episode's recovery wake must fire even
|
||||
# though its text matches episode 1's. Swallow the wait timeout so a
|
||||
# missing wake surfaces as the count assertion below, not a
|
||||
# TimeoutError.
|
||||
gate.set()
|
||||
with contextlib.suppress(TimeoutError):
|
||||
await asyncio.wait_for(server_client.wake_seen.wait(), timeout=5.0)
|
||||
await _wait_turn_idle()
|
||||
finally:
|
||||
gate.set()
|
||||
for child in children:
|
||||
runner_app.unregister_subagent_work(child)
|
||||
runner_app._session_inboxes_ref.pop(parent_id, None)
|
||||
|
||||
assert len(server_client.wake_posts) == 6, (
|
||||
f"Expected episode 2's recovery wake to fire after the drain (6 wakes "
|
||||
f"total), got {len(server_client.wake_posts)}."
|
||||
)
|
||||
assert not session_inbox.empty(), "The stranded results should still be waiting."
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_replayed_idle_status_after_inbox_drain_is_acknowledged() -> None:
|
||||
"""
|
||||
|
||||
@@ -10,6 +10,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
@@ -3182,3 +3183,122 @@ def test_routed_spawn_launch_args_need_a_router() -> None:
|
||||
assert note and tools
|
||||
assert _routed_spawn_launch_args(True, router_started=False) == (None, ())
|
||||
assert _routed_spawn_launch_args(False) == (None, ())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("endpoint", ["subscription", "gateway"])
|
||||
async def test_auto_create_claude_terminal_launch_gate_folds_a_canonical_override(
|
||||
endpoint: str,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
A persisted canonical id the catalog lists only by family still launches.
|
||||
|
||||
A live ``/model`` persists the pane's exact id (``claude-opus-4-8``) while
|
||||
the catalog spells that family as alias rows and the 1M default. On a
|
||||
canonical endpoint the relaunch must pass the id through as ``--model``
|
||||
rather than refuse the resume; a gateway, which routes only its own
|
||||
spellings, keeps refusing it.
|
||||
"""
|
||||
from omnigent.claude_native import ClaudeNativeUcodeConfig
|
||||
|
||||
monkeypatch.setattr(claude_native_bridge, "_TRUSTED_PARENT", tmp_path)
|
||||
monkeypatch.setattr(claude_native_bridge, "_BRIDGE_ROOT", tmp_path / "root")
|
||||
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
|
||||
|
||||
async def _no_op_forwarder(**kwargs: Any) -> None:
|
||||
del kwargs
|
||||
|
||||
monkeypatch.setattr(
|
||||
"omnigent.claude_native_forwarder.supervise_forwarder",
|
||||
_no_op_forwarder,
|
||||
)
|
||||
prefix = "" if endpoint == "subscription" else "system.ai."
|
||||
catalog = [
|
||||
{"id": "opus", "model": f"{prefix}claude-opus-5", "displayName": "Opus 5"},
|
||||
{
|
||||
"id": f"{prefix}claude-opus-4-8[1m]",
|
||||
"model": f"{prefix}claude-opus-4-8[1m]",
|
||||
"displayName": "Opus 4.8 (1M context)",
|
||||
"isDefault": True,
|
||||
},
|
||||
]
|
||||
|
||||
async def _catalog(config: object) -> list[dict[str, object]]:
|
||||
del config
|
||||
return catalog
|
||||
|
||||
monkeypatch.setattr("omnigent.claude_native.claude_launch_catalog", _catalog)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class _FakeResourceRegistry:
|
||||
"""Captures the launched terminal spec."""
|
||||
|
||||
terminal_registry = None
|
||||
|
||||
async def launch_required_terminal(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
terminal_name: str,
|
||||
session_key: str,
|
||||
spec: Any,
|
||||
resource_role: str | None = None,
|
||||
parent_os_env: Any = None,
|
||||
) -> SessionResourceView:
|
||||
"""Record the spec and return a terminal resource view."""
|
||||
del terminal_name, session_key
|
||||
captured["spec"] = spec
|
||||
return SessionResourceView(
|
||||
id="terminal_claude_main",
|
||||
type="terminal",
|
||||
session_id=session_id,
|
||||
name="claude:main",
|
||||
metadata={"terminal_name": "claude", "session_key": "main", "running": True},
|
||||
)
|
||||
|
||||
def _handle_request(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"model_override": "claude-opus-4-8", "labels": {}})
|
||||
|
||||
fake_client = httpx.AsyncClient(
|
||||
base_url="http://test-server",
|
||||
transport=httpx.MockTransport(_handle_request),
|
||||
)
|
||||
config = (
|
||||
None
|
||||
if endpoint == "subscription"
|
||||
else ClaudeNativeUcodeConfig(
|
||||
env={"ANTHROPIC_BASE_URL": "https://gateway.example/anthropic"},
|
||||
api_key_helper="printf %s sk-gateway",
|
||||
model="system.ai.claude-opus-5",
|
||||
)
|
||||
)
|
||||
|
||||
async def _resolve() -> ClaudeNativeUcodeConfig | None:
|
||||
return config
|
||||
|
||||
session_id = "0f2d3d5c9a6b4e1f8c7d6e5f4a3b2c1d"
|
||||
if endpoint == "subscription":
|
||||
await _auto_create_claude_terminal(
|
||||
session_id,
|
||||
_FakeResourceRegistry(),
|
||||
lambda _sid, _evt: None,
|
||||
server_client=fake_client,
|
||||
resolve_launch_config=_resolve,
|
||||
)
|
||||
args = captured["spec"].args
|
||||
assert args[args.index("--model") + 1] == "claude-opus-4-8"
|
||||
else:
|
||||
with pytest.raises(click.ClickException, match="not in this host's current model list"):
|
||||
await _auto_create_claude_terminal(
|
||||
session_id,
|
||||
_FakeResourceRegistry(),
|
||||
lambda _sid, _evt: None,
|
||||
server_client=fake_client,
|
||||
resolve_launch_config=_resolve,
|
||||
)
|
||||
assert "spec" not in captured, "a refused launch must not start a terminal"
|
||||
|
||||
await fake_client.aclose()
|
||||
|
||||
@@ -3907,8 +3907,10 @@ async def test_sys_list_models_dispatches_locally_with_static_provider(
|
||||
|
||||
With a subscription default (static — no HTTP), the payload must
|
||||
carry one row per declared sub-agent plus ``self``, each in the
|
||||
documented ``{source, verified, models, note}`` shape with the
|
||||
curated claude ids surviving the claude-family filter.
|
||||
documented ``{source, verified, models, note}`` shape. Subscription
|
||||
listings enumerate nothing pre-launch (the curated stand-ins are
|
||||
gone; live harness probes are the source of truth), so the row is
|
||||
an honest empty listing, not a failure shape.
|
||||
|
||||
:param monkeypatch: Pytest monkeypatch fixture.
|
||||
:param tmp_path: Per-test temp dir for the isolated provider config.
|
||||
@@ -3931,17 +3933,10 @@ async def test_sys_list_models_dispatches_locally_with_static_provider(
|
||||
worker = payload["worker"]
|
||||
assert worker["source"] == "static"
|
||||
assert worker["verified"] is False
|
||||
# The curated claude aliases survive the claude-family filter — the
|
||||
# exact ids an orchestrator may pass back as args.model.
|
||||
assert [m["id"] for m in worker["models"]] == [
|
||||
"claude-fable-5",
|
||||
"claude-opus-5",
|
||||
"claude-opus-4-8",
|
||||
"claude-sonnet-5",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
]
|
||||
assert worker["note"]
|
||||
# No curated stand-ins: a path that cannot probe reports nothing
|
||||
# rather than a plausible-but-stale list.
|
||||
assert worker["models"] == []
|
||||
assert "probing the harness" in worker["note"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user