mirror of
https://github.com/openai/codex.git
synced 2026-10-19 17:28:46 +00:00
Merge acaba0bf70 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -7,7 +7,7 @@ description: Run a GitHub issue digest for openai/codex by feature-area labels,
|
||||
|
||||
## Objective
|
||||
|
||||
Produce a concise, insight-oriented digest of `openai/codex` issues for the requested feature-area labels over the previous 24 hours by default. Honor a different duration when the user asks for one, for example "past week" or "48 hours".
|
||||
Produce a headline-first, insight-oriented digest of `openai/codex` issues for the requested feature-area labels over the previous 24 hours by default. Honor a different duration when the user asks for one, for example "past week" or "48 hours". Default to a summary-only response; include details only when requested.
|
||||
|
||||
Include only issues that currently have `bug` or `enhancement` plus at least one requested owner label. If the user asks for all areas or all labels, collect `bug`/`enhancement` issues across all labels.
|
||||
|
||||
@@ -29,21 +29,46 @@ python3 .codex/skills/codex-issue-digest/scripts/collect_issue_digest.py --label
|
||||
Use `--window "past week"` or `--window-hours 168` when the user asks for a non-default duration. Use `--all-labels` when the user says all areas or all labels.
|
||||
|
||||
2. Use the JSON as the source of truth. It includes new issues, new issue comments, new reactions/upvotes, current labels, current reaction counts, model-ready `summary_inputs`, and detailed `digest_rows`.
|
||||
3. Start the report with `## Summary`, then `## Details`.
|
||||
4. In `## Summary`, write skim-first headlines:
|
||||
- Lead with the most important fact or judgment. Do not start with aggregate counts unless the aggregate itself is the story.
|
||||
- Make the first 1-3 bullets answer "what should owners pay attention to right now?"
|
||||
- Bold only the critical insight phrase in each high-priority bullet, for example `**GPT-5.5 context is the dominant pressure point**`.
|
||||
- Keep summary bullets short enough to scan in about 20 seconds.
|
||||
- Put broad stats near the end of the summary, after the owner-relevant takeaways.
|
||||
- Say clearly when there is nothing significant to act on.
|
||||
- Call out any areas or themes receiving lots of user attention.
|
||||
3. Choose the output mode from the user's request:
|
||||
- Default mode: start the report with `## Summary` and do not emit `## Details`.
|
||||
- Details-upfront mode: if the user asks for details, a table, a full digest, "include details", or similar, start with `## Summary`, then include `## Details`.
|
||||
- Follow-up details mode: if the user asks for more detail after a summary-only digest, produce `## Details` from the existing collector JSON when it is still available; otherwise rerun the collector.
|
||||
4. In `## Summary`, write a headline-first executive summary:
|
||||
- The first nonblank line under `## Summary` must be a single-line headline or judgment, not a bullet. It should be useful even if the reader stops there.
|
||||
- On quiet days, prefer exactly: `No major issues reported by users.` Use this when there are no elevated rows, no newly repeated theme, and nothing that needs owner action.
|
||||
- When users are surfacing notable issues, make the headline name the count or theme, for example `Two issues are being surfaced by users:`.
|
||||
- Immediately under an active headline, list only the issues or themes driving attention, ordered by importance. Start each line with the row's `attention_marker` when present, then a concise owner-readable description and inline issue refs.
|
||||
- Treat `🔥🔥` as headline-worthy and `🔥` as elevated. Do not add fire emoji yourself; only copy the row's `attention_marker`.
|
||||
- Keep any extra summary detail after the headline to 1-3 terse lines, only when it adds a decision-relevant caveat, repeated theme, or owner action.
|
||||
- Do not include routine counts, broad stats, or low-signal table summaries in `## Summary` unless they change the headline. Put metadata and optional counts in `## Details` or the footer.
|
||||
- In default mode, end the report with a concise prompt such as `Want details? I can expand this into the issue table.` Keep this separate from the summary headline so the headline stays clean.
|
||||
- Cluster and name themes yourself from `summary_inputs`; the collector intentionally does not hard-code issue categories.
|
||||
- Use a cluster only when the issues genuinely share the same product problem. If several issues merely share a broad platform or label, describe them individually.
|
||||
- Do not omit a repeated theme just because its individual issues fall below the details table cutoff. Several similar reports should be called out as a repeated customer concern.
|
||||
- For single-issue rows, summarize the concern directly instead of calling it a cluster.
|
||||
- Use inline numbered issue links from each relevant row's `ref_markdown`.
|
||||
5. In `## Details`, include a compact table only when useful:
|
||||
- Example quiet summary:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
No major issues reported by users.
|
||||
|
||||
Source: collector v4, git `abc123def456`, window `2026-04-27T00:00:00Z` to `2026-04-28T00:00:00Z`.
|
||||
Want details? I can expand this into the issue table.
|
||||
```
|
||||
|
||||
- Example active summary:
|
||||
|
||||
```markdown
|
||||
## Summary
|
||||
Two issues are being surfaced by users:
|
||||
🔥🔥 Terminal launch hangs on startup [1](https://github.com/openai/codex/issues/123)
|
||||
🔥 Resume switches model providers unexpectedly [2](https://github.com/openai/codex/issues/456)
|
||||
|
||||
Source: collector v4, git `abc123def456`, window `2026-04-27T00:00:00Z` to `2026-04-28T00:00:00Z`.
|
||||
Want details? I can expand this into the issue table.
|
||||
```
|
||||
5. In `## Details`, when details are requested, include a compact table only when useful:
|
||||
- Prefer rows from `digest_rows`; include a `Refs` column using each row's `ref_markdown`.
|
||||
- Keep the table short; omit low-signal rows when the summary already covers them.
|
||||
- Use compact columns such as marker, area, type, description, interactions, and refs.
|
||||
@@ -52,7 +77,7 @@ Use `--window "past week"` or `--window-hours 168` when the user asks for a non-
|
||||
6. Use the JSON `attention_marker` exactly. It is empty for normal rows, `🔥` for elevated rows, and `🔥🔥` for very high-attention rows. The actual cutoffs are in `attention_thresholds`.
|
||||
7. Use inline numbered references where a row or bullet points to issues, for example `Compaction bugs [1](https://github.com/openai/codex/issues/123), [2](https://github.com/openai/codex/issues/456)`. Do not add a separate footnotes section.
|
||||
8. Label `interactions` as `Interactions`; it counts posts/comments/reactions during the requested window, not unique people.
|
||||
9. Mention the collector `script_version`, repo checkout `git_head`, and time window in the digest footer or final line.
|
||||
9. Mention the collector `script_version`, repo checkout `git_head`, and time window in one compact source line. In default mode, put this before the details prompt so the final line still asks whether the user wants details. In details-upfront mode, it can be the footer.
|
||||
|
||||
## Reaction Handling
|
||||
|
||||
@@ -64,7 +89,7 @@ GitHub issue search is still seeded by issue `updated_at`, so a purely reaction-
|
||||
|
||||
## Attention Markers
|
||||
|
||||
The collector scales attention markers by the requested time window. The baseline is 10 human user interactions for `🔥` and 20 for `🔥🔥` over 24 hours; longer or shorter windows scale those cutoffs linearly and round up. For example, a one-week report uses 70 and 140 interactions. Human user interactions are human-authored new issue posts, human-authored new comments, and human reactions created during the window, including upvotes. Bot posts and bot reactions are excluded. In prose, explain this as high user interaction rather than naming the emoji.
|
||||
The collector scales attention markers by the requested time window. The baseline is 5 human user interactions for `🔥` and 10 for `🔥🔥` over 24 hours; longer or shorter windows scale those cutoffs linearly and round up. For example, a one-week report uses 35 and 70 interactions. Human user interactions are human-authored new issue posts, human-authored new comments, and human reactions created during the window, including upvotes. Bot posts and bot reactions are excluded. In prose, explain this as high user interaction rather than naming the emoji.
|
||||
|
||||
## Freshness
|
||||
|
||||
|
||||
@@ -11,12 +11,12 @@ from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from urllib.parse import quote
|
||||
|
||||
SCRIPT_VERSION = 2
|
||||
SCRIPT_VERSION = 4
|
||||
QUALIFYING_KIND_LABELS = ("bug", "enhancement")
|
||||
REACTION_KEYS = ("+1", "-1", "laugh", "hooray", "confused", "heart", "rocket", "eyes")
|
||||
BASE_ATTENTION_WINDOW_HOURS = 24.0
|
||||
ONE_ATTENTION_INTERACTION_THRESHOLD = 10
|
||||
TWO_ATTENTION_INTERACTION_THRESHOLD = 20
|
||||
ONE_ATTENTION_INTERACTION_THRESHOLD = 5
|
||||
TWO_ATTENTION_INTERACTION_THRESHOLD = 10
|
||||
ALL_LABEL_PHRASES = {"all", "all areas", "all labels", "all-areas", "all-labels", "*"}
|
||||
|
||||
|
||||
@@ -305,6 +305,7 @@ def search_issue_numbers(queries, limit):
|
||||
numbers = {}
|
||||
for query in queries:
|
||||
page = 1
|
||||
seen_for_query = 0
|
||||
while True:
|
||||
payload = gh_json(
|
||||
[
|
||||
@@ -315,6 +316,10 @@ def search_issue_numbers(queries, limit):
|
||||
"-f",
|
||||
f"q={query}",
|
||||
"-f",
|
||||
"sort=updated",
|
||||
"-f",
|
||||
"order=desc",
|
||||
"-f",
|
||||
"per_page=100",
|
||||
"-f",
|
||||
f"page={page}",
|
||||
@@ -331,7 +336,8 @@ def search_issue_numbers(queries, limit):
|
||||
number = item.get("number")
|
||||
if isinstance(number, int):
|
||||
numbers[number] = str(item.get("updated_at") or "")
|
||||
if len(items) < 100 or len(numbers) >= limit:
|
||||
seen_for_query += 1
|
||||
if len(items) < 100 or seen_for_query >= limit:
|
||||
break
|
||||
page += 1
|
||||
ordered = sorted(
|
||||
|
||||
@@ -51,6 +51,77 @@ def test_normalize_requested_labels_accepts_all_area_phrases():
|
||||
)
|
||||
|
||||
|
||||
def test_search_issue_numbers_requests_updated_sort(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_gh_json(args):
|
||||
calls.append(args)
|
||||
return {
|
||||
"items": [
|
||||
{"number": 1, "updated_at": "2026-04-25T00:00:00Z"},
|
||||
]
|
||||
}
|
||||
|
||||
monkeypatch.setattr(collect_issue_digest, "gh_json", fake_gh_json)
|
||||
|
||||
assert collect_issue_digest.search_issue_numbers(["query"], limit=10) == [1]
|
||||
assert "-f" in calls[0]
|
||||
assert "sort=updated" in calls[0]
|
||||
assert "order=desc" in calls[0]
|
||||
|
||||
|
||||
def test_search_issue_numbers_applies_limit_per_query(monkeypatch):
|
||||
calls = []
|
||||
|
||||
def fake_gh_json(args):
|
||||
calls.append(args)
|
||||
query = next(
|
||||
value.removeprefix("q=") for value in args if value.startswith("q=")
|
||||
)
|
||||
page = int(
|
||||
next(
|
||||
value.removeprefix("page=")
|
||||
for value in args
|
||||
if value.startswith("page=")
|
||||
)
|
||||
)
|
||||
base = 10_000 if query == "first" else 20_000
|
||||
offset = (page - 1) * 100
|
||||
return {
|
||||
"items": [
|
||||
{
|
||||
"number": base + offset + idx,
|
||||
"updated_at": f"2026-04-25T00:{idx:02d}:00Z",
|
||||
}
|
||||
for idx in range(100)
|
||||
]
|
||||
}
|
||||
|
||||
monkeypatch.setattr(collect_issue_digest, "gh_json", fake_gh_json)
|
||||
|
||||
collect_issue_digest.search_issue_numbers(["first", "second"], limit=150)
|
||||
|
||||
queried_pages = [
|
||||
(
|
||||
next(
|
||||
value.removeprefix("q=") for value in args if value.startswith("q=")
|
||||
),
|
||||
next(
|
||||
value.removeprefix("page=")
|
||||
for value in args
|
||||
if value.startswith("page=")
|
||||
),
|
||||
)
|
||||
for args in calls
|
||||
]
|
||||
assert queried_pages == [
|
||||
("first", "1"),
|
||||
("first", "2"),
|
||||
("second", "1"),
|
||||
("second", "2"),
|
||||
]
|
||||
|
||||
|
||||
def test_summarize_issue_keeps_new_comments_and_reaction_signals():
|
||||
since = collect_issue_digest.parse_timestamp("2026-04-25T00:00:00Z", "--since")
|
||||
until = collect_issue_digest.parse_timestamp("2026-04-26T00:00:00Z", "--until")
|
||||
@@ -227,19 +298,19 @@ def test_parse_duration_hours_accepts_common_phrases():
|
||||
|
||||
def test_attention_thresholds_scale_by_window_length():
|
||||
one_day = collect_issue_digest.attention_thresholds_for_window(24)
|
||||
assert one_day["elevated"] == 10
|
||||
assert one_day["very_high"] == 20
|
||||
assert one_day["elevated"] == 5
|
||||
assert one_day["very_high"] == 10
|
||||
|
||||
half_day = collect_issue_digest.attention_thresholds_for_window(12)
|
||||
assert half_day["elevated"] == 5
|
||||
assert half_day["very_high"] == 10
|
||||
assert half_day["elevated"] == 3
|
||||
assert half_day["very_high"] == 5
|
||||
|
||||
week = collect_issue_digest.attention_thresholds_for_window(168)
|
||||
assert week["elevated"] == 70
|
||||
assert week["very_high"] == 140
|
||||
assert collect_issue_digest.attention_marker_for(69, week) == ""
|
||||
assert collect_issue_digest.attention_marker_for(107, week) == "🔥"
|
||||
assert collect_issue_digest.attention_marker_for(140, week) == "🔥🔥"
|
||||
assert week["elevated"] == 35
|
||||
assert week["very_high"] == 70
|
||||
assert collect_issue_digest.attention_marker_for(34, week) == ""
|
||||
assert collect_issue_digest.attention_marker_for(35, week) == "🔥"
|
||||
assert collect_issue_digest.attention_marker_for(70, week) == "🔥🔥"
|
||||
|
||||
|
||||
def test_fetch_comments_uses_since_filter_and_page_cap(monkeypatch):
|
||||
@@ -300,7 +371,7 @@ def test_attention_markers_count_human_user_interactions():
|
||||
"user": {"login": f"user-{idx}"},
|
||||
"body": "same here",
|
||||
}
|
||||
for idx in range(9)
|
||||
for idx in range(4)
|
||||
]
|
||||
comments.append(
|
||||
{
|
||||
@@ -322,8 +393,8 @@ def test_attention_markers_count_human_user_interactions():
|
||||
comment_chars=100,
|
||||
)
|
||||
|
||||
assert summary["user_interactions"] == 10
|
||||
assert summary["activity"]["new_human_comments"] == 9
|
||||
assert summary["user_interactions"] == 5
|
||||
assert summary["activity"]["new_human_comments"] == 4
|
||||
assert summary["attention"] is True
|
||||
assert summary["attention_level"] == 1
|
||||
assert summary["attention_marker"] == "🔥"
|
||||
@@ -337,7 +408,7 @@ def test_attention_markers_count_human_user_interactions():
|
||||
"user": {"login": f"extra-user-{idx}"},
|
||||
"body": "also seeing this",
|
||||
}
|
||||
for idx in range(11)
|
||||
for idx in range(100, 106)
|
||||
)
|
||||
|
||||
summary = collect_issue_digest.summarize_issue(
|
||||
@@ -350,7 +421,7 @@ def test_attention_markers_count_human_user_interactions():
|
||||
comment_chars=100,
|
||||
)
|
||||
|
||||
assert summary["user_interactions"] == 20
|
||||
assert summary["user_interactions"] == 10
|
||||
assert summary["attention_level"] == 2
|
||||
assert summary["attention_marker"] == "🔥🔥"
|
||||
|
||||
|
||||
@@ -2603,6 +2603,33 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RemoteControlConnectionStatus": {
|
||||
"enum": [
|
||||
"disabled",
|
||||
"connecting",
|
||||
"connected",
|
||||
"errored"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RemoteControlStatusChangedNotification": {
|
||||
"description": "Current remote-control connection status and environment id exposed to clients.",
|
||||
"properties": {
|
||||
"environmentId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/RemoteControlConnectionStatus"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"RequestId": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -5342,6 +5369,26 @@
|
||||
"title": "App/list/updatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"remoteControl/status/changed"
|
||||
],
|
||||
"title": "RemoteControl/status/changedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/RemoteControlStatusChangedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "RemoteControl/status/changedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
|
||||
+49
@@ -4348,6 +4348,26 @@
|
||||
"title": "App/list/updatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"remoteControl/status/changed"
|
||||
],
|
||||
"title": "RemoteControl/status/changedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/RemoteControlStatusChangedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "RemoteControl/status/changedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
@@ -12519,6 +12539,35 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RemoteControlConnectionStatus": {
|
||||
"enum": [
|
||||
"disabled",
|
||||
"connecting",
|
||||
"connected",
|
||||
"errored"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RemoteControlStatusChangedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Current remote-control connection status and environment id exposed to clients.",
|
||||
"properties": {
|
||||
"environmentId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/v2/RemoteControlConnectionStatus"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"title": "RemoteControlStatusChangedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"RequestId": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
||||
+49
@@ -9193,6 +9193,35 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RemoteControlConnectionStatus": {
|
||||
"enum": [
|
||||
"disabled",
|
||||
"connecting",
|
||||
"connected",
|
||||
"errored"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"RemoteControlStatusChangedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Current remote-control connection status and environment id exposed to clients.",
|
||||
"properties": {
|
||||
"environmentId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/RemoteControlConnectionStatus"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"title": "RemoteControlStatusChangedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"RequestId": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -10929,6 +10958,26 @@
|
||||
"title": "App/list/updatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"remoteControl/status/changed"
|
||||
],
|
||||
"title": "RemoteControl/status/changedNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/RemoteControlStatusChangedNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "RemoteControl/status/changedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"RemoteControlConnectionStatus": {
|
||||
"enum": [
|
||||
"disabled",
|
||||
"connecting",
|
||||
"connected",
|
||||
"errored"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"description": "Current remote-control connection status and environment id exposed to clients.",
|
||||
"properties": {
|
||||
"environmentId": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"status": {
|
||||
"$ref": "#/definitions/RemoteControlConnectionStatus"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"title": "RemoteControlStatusChangedNotification",
|
||||
"type": "object"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+5
@@ -0,0 +1,5 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type RemoteControlConnectionStatus = "disabled" | "connecting" | "connected" | "errored";
|
||||
Generated
+9
@@ -0,0 +1,9 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { RemoteControlConnectionStatus } from "./RemoteControlConnectionStatus";
|
||||
|
||||
/**
|
||||
* Current remote-control connection status and environment id exposed to clients.
|
||||
*/
|
||||
export type RemoteControlStatusChangedNotification = { status: RemoteControlConnectionStatus, environmentId: string | null, };
|
||||
@@ -283,6 +283,8 @@ export type { ReasoningSummaryTextDeltaNotification } from "./ReasoningSummaryTe
|
||||
export type { ReasoningTextDeltaNotification } from "./ReasoningTextDeltaNotification";
|
||||
export type { RemoteControlClientConnectionAudience } from "./RemoteControlClientConnectionAudience";
|
||||
export type { RemoteControlClientEnrollmentAudience } from "./RemoteControlClientEnrollmentAudience";
|
||||
export type { RemoteControlConnectionStatus } from "./RemoteControlConnectionStatus";
|
||||
export type { RemoteControlStatusChangedNotification } from "./RemoteControlStatusChangedNotification";
|
||||
export type { RequestPermissionProfile } from "./RequestPermissionProfile";
|
||||
export type { ResidencyRequirement } from "./ResidencyRequirement";
|
||||
export type { ReviewDelivery } from "./ReviewDelivery";
|
||||
|
||||
@@ -1258,6 +1258,7 @@ server_notification_definitions! {
|
||||
AccountUpdated => "account/updated" (v2::AccountUpdatedNotification),
|
||||
AccountRateLimitsUpdated => "account/rateLimits/updated" (v2::AccountRateLimitsUpdatedNotification),
|
||||
AppListUpdated => "app/list/updated" (v2::AppListUpdatedNotification),
|
||||
RemoteControlStatusChanged => "remoteControl/status/changed" (v2::RemoteControlStatusChangedNotification),
|
||||
ExternalAgentConfigImportCompleted => "externalAgentConfig/import/completed" (v2::ExternalAgentConfigImportCompletedNotification),
|
||||
FsChanged => "fs/changed" (v2::FsChangedNotification),
|
||||
ReasoningSummaryTextDelta => "item/reasoning/summaryTextDelta" (v2::ReasoningSummaryTextDeltaNotification),
|
||||
|
||||
@@ -2910,6 +2910,25 @@ pub struct DeviceKeyPublicResponse {
|
||||
pub protection_class: DeviceKeyProtectionClass,
|
||||
}
|
||||
|
||||
/// Current remote-control connection status and environment id exposed to clients.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct RemoteControlStatusChangedNotification {
|
||||
pub status: RemoteControlConnectionStatus,
|
||||
pub environment_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(rename_all = "camelCase", export_to = "v2/")]
|
||||
pub enum RemoteControlConnectionStatus {
|
||||
Disabled,
|
||||
Connecting,
|
||||
Connected,
|
||||
Errored,
|
||||
}
|
||||
|
||||
/// Audience for a remote-control client connection device-key proof.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
|
||||
@@ -205,6 +205,7 @@ Example with notification opt-out:
|
||||
- `device/key/create` — create or load a controller-local device signing key for an account/client binding. This local-key API is available only over local transports such as stdio and in-process; remote transports reject it. Hardware-backed providers are the target protection class; an OS-protected non-extractable fallback is allowed only with `protectionPolicy: "allow_os_protected_nonextractable"` and returns the reported `protectionClass`.
|
||||
- `device/key/public` — return a device key's SPKI DER public key as base64 plus its `algorithm` and `protectionClass`.
|
||||
- `device/key/sign` — sign one of the accepted structured payload variants with a controller-local device key. The only accepted payload today is `remoteControlClientConnection`, which binds a server-issued `/client` websocket challenge to the enrolled controller device without signing the bearer token itself; this is intentionally not an arbitrary-byte signing API.
|
||||
- `remoteControl/status/changed` — notification emitted when the remote-control status or client-visible environment id changes. `status` is one of `disabled`, `connecting`, `connected`, or `errored`; `environmentId` is a string when the app-server has a current enrollment and `null` when that enrollment is cleared, invalidated, or remote control is disabled. Newly initialized app-server clients always receive the current status snapshot.
|
||||
- `skills/config/write` — write user-level skill config by name or absolute path.
|
||||
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, install MCPs if any, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
|
||||
- `plugin/uninstall` — uninstall a local plugin by `pluginId` in `<plugin>@<marketplace>` form by removing its cached files and clearing its user-level config entry, or uninstall a remote ChatGPT plugin by backend `pluginId` by forwarding the uninstall to the ChatGPT plugin backend and removing any downloaded remote-plugin cache (**under development; do not call from production clients yet**).
|
||||
|
||||
@@ -40,6 +40,8 @@ use codex_analytics::AppServerRpcTransport;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::TextPosition as AppTextPosition;
|
||||
use codex_app_server_protocol::TextRange as AppTextRange;
|
||||
use codex_config::ConfigLoadError;
|
||||
@@ -724,6 +726,7 @@ pub async fn run_main_with_transport_options(
|
||||
|
||||
let processor_handle = tokio::spawn({
|
||||
let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx));
|
||||
let initialize_notification_sender = outgoing_message_sender.clone();
|
||||
let outbound_control_tx = outbound_control_tx;
|
||||
let auth_manager =
|
||||
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await;
|
||||
@@ -739,12 +742,14 @@ pub async fn run_main_with_transport_options(
|
||||
session_source,
|
||||
auth_manager,
|
||||
rpc_transport: analytics_rpc_transport(&transport),
|
||||
remote_control_handle: Some(remote_control_handle),
|
||||
remote_control_handle: Some(remote_control_handle.clone()),
|
||||
plugin_startup_tasks: runtime_options.plugin_startup_tasks,
|
||||
}));
|
||||
let mut thread_created_rx = processor.thread_created_receiver();
|
||||
let mut running_turn_count_rx = processor.subscribe_running_assistant_turn_count();
|
||||
let mut connections = HashMap::<ConnectionId, ConnectionState>::new();
|
||||
let mut remote_control_status_rx = remote_control_handle.status_receiver();
|
||||
let mut remote_control_status = remote_control_status_rx.borrow().clone();
|
||||
let transport_shutdown_token = transport_shutdown_token.clone();
|
||||
async move {
|
||||
let mut listen_for_threads = true;
|
||||
@@ -884,6 +889,14 @@ pub async fn run_main_with_transport_options(
|
||||
connection_id,
|
||||
)
|
||||
.await;
|
||||
initialize_notification_sender
|
||||
.send_server_notification_to_connections(
|
||||
&[connection_id],
|
||||
ServerNotification::RemoteControlStatusChanged(
|
||||
remote_control_status.clone(),
|
||||
),
|
||||
)
|
||||
.await;
|
||||
processor.connection_initialized(connection_id).await;
|
||||
connection_state
|
||||
.outbound_initialized
|
||||
@@ -915,6 +928,24 @@ pub async fn run_main_with_transport_options(
|
||||
}
|
||||
}
|
||||
}
|
||||
changed = remote_control_status_rx.changed() => {
|
||||
if changed.is_err() {
|
||||
continue;
|
||||
}
|
||||
let status = remote_control_status_rx.borrow().clone();
|
||||
if remote_control_status == status {
|
||||
continue;
|
||||
}
|
||||
remote_control_status = status.clone();
|
||||
initialize_notification_sender
|
||||
.send_server_notification(ServerNotification::RemoteControlStatusChanged(
|
||||
RemoteControlStatusChangedNotification {
|
||||
status: status.status,
|
||||
environment_id: status.environment_id,
|
||||
},
|
||||
))
|
||||
.await;
|
||||
}
|
||||
created = thread_created_rx.recv(), if listen_for_threads => {
|
||||
match created {
|
||||
Ok(thread_id) => {
|
||||
|
||||
@@ -3,6 +3,8 @@ mod enroll;
|
||||
mod protocol;
|
||||
mod websocket;
|
||||
|
||||
use crate::transport::remote_control::websocket::RemoteControlChannels;
|
||||
use crate::transport::remote_control::websocket::RemoteControlStatusPublisher;
|
||||
use crate::transport::remote_control::websocket::RemoteControlWebsocket;
|
||||
|
||||
pub use self::protocol::ClientId;
|
||||
@@ -12,6 +14,8 @@ use self::protocol::normalize_remote_control_url;
|
||||
use super::CHANNEL_CAPACITY;
|
||||
use super::TransportEvent;
|
||||
use super::next_connection_id;
|
||||
use codex_app_server_protocol::RemoteControlConnectionStatus;
|
||||
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
|
||||
use codex_login::AuthManager;
|
||||
use codex_state::StateRuntime;
|
||||
use std::io;
|
||||
@@ -33,6 +37,7 @@ pub(super) struct QueuedServerEnvelope {
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RemoteControlHandle {
|
||||
enabled_tx: Arc<watch::Sender<bool>>,
|
||||
status_tx: Arc<watch::Sender<RemoteControlStatusChangedNotification>>,
|
||||
state_db_available: bool,
|
||||
}
|
||||
|
||||
@@ -49,6 +54,12 @@ impl RemoteControlHandle {
|
||||
changed
|
||||
});
|
||||
}
|
||||
|
||||
pub(crate) fn status_receiver(
|
||||
&self,
|
||||
) -> watch::Receiver<RemoteControlStatusChangedNotification> {
|
||||
self.status_tx.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn start_remote_control(
|
||||
@@ -73,13 +84,26 @@ pub(crate) async fn start_remote_control(
|
||||
};
|
||||
|
||||
let (enabled_tx, enabled_rx) = watch::channel(initial_enabled);
|
||||
let initial_status = RemoteControlStatusChangedNotification {
|
||||
status: if initial_enabled {
|
||||
RemoteControlConnectionStatus::Connecting
|
||||
} else {
|
||||
RemoteControlConnectionStatus::Disabled
|
||||
},
|
||||
environment_id: None,
|
||||
};
|
||||
let (status_tx, _status_rx) = watch::channel(initial_status);
|
||||
let status_publisher = RemoteControlStatusPublisher::new(status_tx.clone());
|
||||
let join_handle = tokio::spawn(async move {
|
||||
RemoteControlWebsocket::new(
|
||||
remote_control_url,
|
||||
remote_control_target,
|
||||
state_db,
|
||||
auth_manager,
|
||||
transport_event_tx,
|
||||
RemoteControlChannels {
|
||||
transport_event_tx,
|
||||
status_publisher,
|
||||
},
|
||||
shutdown_token,
|
||||
enabled_rx,
|
||||
)
|
||||
@@ -91,6 +115,7 @@ pub(crate) async fn start_remote_control(
|
||||
join_handle,
|
||||
RemoteControlHandle {
|
||||
enabled_tx: Arc::new(enabled_tx),
|
||||
status_tx: Arc::new(status_tx),
|
||||
state_db_available,
|
||||
},
|
||||
))
|
||||
|
||||
@@ -18,6 +18,8 @@ use base64::Engine;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::ConfigWarningNotification;
|
||||
use codex_app_server_protocol::JSONRPCMessage;
|
||||
use codex_app_server_protocol::RemoteControlConnectionStatus;
|
||||
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_core::test_support::auth_manager_from_auth;
|
||||
@@ -45,6 +47,7 @@ use tokio::net::TcpListener;
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio::sync::oneshot;
|
||||
use tokio::sync::watch;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
use tokio_tungstenite::WebSocketStream;
|
||||
@@ -115,6 +118,50 @@ fn remote_control_url_for_listener(listener: &TcpListener) -> String {
|
||||
format!("http://{addr}/backend-api/")
|
||||
}
|
||||
|
||||
async fn expect_remote_control_status(
|
||||
status_rx: &mut watch::Receiver<RemoteControlStatusChangedNotification>,
|
||||
expected_status: Option<RemoteControlConnectionStatus>,
|
||||
expected_environment_id: Option<&str>,
|
||||
) {
|
||||
timeout(Duration::from_secs(5), status_rx.changed())
|
||||
.await
|
||||
.expect("remote control status event should arrive in time")
|
||||
.expect("remote control status watch should remain open");
|
||||
let status = status_rx.borrow();
|
||||
if let Some(expected_status) = expected_status {
|
||||
assert_eq!(status.status, expected_status);
|
||||
}
|
||||
assert_eq!(status.environment_id.as_deref(), expected_environment_id);
|
||||
}
|
||||
|
||||
async fn expect_remote_control_status_snapshot(
|
||||
status_rx: &mut watch::Receiver<RemoteControlStatusChangedNotification>,
|
||||
expected_status: RemoteControlStatusChangedNotification,
|
||||
) {
|
||||
if *status_rx.borrow() == expected_status {
|
||||
return;
|
||||
}
|
||||
|
||||
let expected_status_for_wait = expected_status.clone();
|
||||
let result = timeout(Duration::from_secs(5), async {
|
||||
loop {
|
||||
status_rx
|
||||
.changed()
|
||||
.await
|
||||
.expect("remote control status watch should remain open");
|
||||
if *status_rx.borrow() == expected_status_for_wait {
|
||||
return;
|
||||
}
|
||||
}
|
||||
})
|
||||
.await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"remote control status snapshot should arrive in time; expected {expected_status:?}, latest {:?}",
|
||||
status_rx.borrow().clone()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_transport_manages_virtual_clients_and_routes_messages() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
@@ -125,7 +172,7 @@ async fn remote_control_transport_manages_virtual_clients_and_routes_messages()
|
||||
let (transport_event_tx, mut transport_event_rx) =
|
||||
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
|
||||
let shutdown_token = CancellationToken::new();
|
||||
let (remote_task, _remote_handle) = start_remote_control(
|
||||
let (remote_task, remote_handle) = start_remote_control(
|
||||
remote_control_url,
|
||||
Some(remote_control_state_runtime(&codex_home).await),
|
||||
remote_control_auth_manager(),
|
||||
@@ -136,6 +183,7 @@ async fn remote_control_transport_manages_virtual_clients_and_routes_messages()
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
let mut status_rx = remote_handle.status_receiver();
|
||||
let enroll_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
enroll_request.request_line,
|
||||
@@ -147,6 +195,12 @@ async fn remote_control_transport_manages_virtual_clients_and_routes_messages()
|
||||
)
|
||||
.await;
|
||||
let mut websocket = accept_remote_control_connection(&listener).await;
|
||||
expect_remote_control_status(
|
||||
&mut status_rx,
|
||||
/*expected_status*/ None,
|
||||
Some("env_test"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let client_id = ClientId("client-1".to_string());
|
||||
send_client_event(
|
||||
@@ -394,7 +448,7 @@ async fn remote_control_transport_reconnects_after_disconnect() {
|
||||
let (transport_event_tx, mut transport_event_rx) =
|
||||
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
|
||||
let shutdown_token = CancellationToken::new();
|
||||
let (remote_task, _remote_handle) = start_remote_control(
|
||||
let (remote_task, remote_handle) = start_remote_control(
|
||||
remote_control_url,
|
||||
Some(remote_control_state_runtime(&codex_home).await),
|
||||
remote_control_auth_manager(),
|
||||
@@ -405,6 +459,7 @@ async fn remote_control_transport_reconnects_after_disconnect() {
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
let mut status_rx = remote_handle.status_receiver();
|
||||
|
||||
let enroll_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
@@ -424,6 +479,12 @@ async fn remote_control_transport_reconnects_after_disconnect() {
|
||||
drop(first_websocket);
|
||||
|
||||
let mut second_websocket = accept_remote_control_connection(&listener).await;
|
||||
expect_remote_control_status(
|
||||
&mut status_rx,
|
||||
/*expected_status*/ None,
|
||||
Some("env_test"),
|
||||
)
|
||||
.await;
|
||||
send_client_event(
|
||||
&mut second_websocket,
|
||||
ClientEnvelope {
|
||||
@@ -526,7 +587,7 @@ async fn remote_control_start_allows_missing_auth_when_enabled() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn remote_control_start_disables_remote_control_without_state_db() {
|
||||
async fn remote_control_start_reports_missing_state_db_as_disabled_when_enabled() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("listener should bind");
|
||||
@@ -545,6 +606,14 @@ async fn remote_control_start_disables_remote_control_without_state_db() {
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start disabled without sqlite state db");
|
||||
let mut status_rx = remote_handle.status_receiver();
|
||||
assert_eq!(
|
||||
status_rx.borrow().clone(),
|
||||
RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Disabled,
|
||||
environment_id: None,
|
||||
}
|
||||
);
|
||||
|
||||
timeout(Duration::from_millis(100), listener.accept())
|
||||
.await
|
||||
@@ -554,6 +623,9 @@ async fn remote_control_start_disables_remote_control_without_state_db() {
|
||||
timeout(Duration::from_millis(100), listener.accept())
|
||||
.await
|
||||
.expect_err("remote control should remain disabled without sqlite state db");
|
||||
timeout(Duration::from_millis(20), status_rx.changed())
|
||||
.await
|
||||
.expect_err("status should remain disabled without sqlite state db");
|
||||
|
||||
shutdown_token.cancel();
|
||||
timeout(Duration::from_secs(1), remote_task)
|
||||
@@ -583,6 +655,7 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() {
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
let mut status_rx = remote_handle.status_receiver();
|
||||
|
||||
let enroll_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
@@ -595,8 +668,24 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() {
|
||||
)
|
||||
.await;
|
||||
let mut first_websocket = accept_remote_control_connection(&listener).await;
|
||||
expect_remote_control_status_snapshot(
|
||||
&mut status_rx,
|
||||
RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Connected,
|
||||
environment_id: Some("env_test".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
|
||||
remote_handle.set_enabled(/*enabled*/ false);
|
||||
expect_remote_control_status_snapshot(
|
||||
&mut status_rx,
|
||||
RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Disabled,
|
||||
environment_id: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
timeout(Duration::from_secs(1), first_websocket.next())
|
||||
.await
|
||||
.expect("disabling remote control should close the websocket");
|
||||
@@ -605,7 +694,21 @@ async fn remote_control_handle_set_enabled_stops_and_restarts_connections() {
|
||||
.expect_err("disabled remote control should not reconnect");
|
||||
|
||||
remote_handle.set_enabled(/*enabled*/ true);
|
||||
expect_remote_control_status_snapshot(
|
||||
&mut status_rx,
|
||||
RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Connecting,
|
||||
environment_id: Some("env_test".to_string()),
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let mut second_websocket = accept_remote_control_connection(&listener).await;
|
||||
expect_remote_control_status(
|
||||
&mut status_rx,
|
||||
/*expected_status*/ None,
|
||||
Some("env_test"),
|
||||
)
|
||||
.await;
|
||||
second_websocket
|
||||
.close(None)
|
||||
.await
|
||||
@@ -625,7 +728,7 @@ async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() {
|
||||
let (transport_event_tx, mut transport_event_rx) =
|
||||
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
|
||||
let shutdown_token = CancellationToken::new();
|
||||
let (remote_task, _remote_handle) = start_remote_control(
|
||||
let (remote_task, remote_handle) = start_remote_control(
|
||||
remote_control_url,
|
||||
Some(remote_control_state_runtime(&codex_home).await),
|
||||
remote_control_auth_manager(),
|
||||
@@ -636,6 +739,7 @@ async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() {
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
let mut status_rx = remote_handle.status_receiver();
|
||||
|
||||
let enroll_request = accept_http_request(&listener).await;
|
||||
respond_with_json(
|
||||
@@ -644,6 +748,12 @@ async fn remote_control_transport_clears_outgoing_buffer_when_backend_acks() {
|
||||
)
|
||||
.await;
|
||||
let mut first_websocket = accept_remote_control_connection(&listener).await;
|
||||
expect_remote_control_status(
|
||||
&mut status_rx,
|
||||
/*expected_status*/ None,
|
||||
Some("env_test"),
|
||||
)
|
||||
.await;
|
||||
|
||||
let client_id = ClientId("client-1".to_string());
|
||||
let initialize_message = JSONRPCMessage::Request(codex_app_server_protocol::JSONRPCRequest {
|
||||
@@ -793,7 +903,7 @@ async fn remote_control_http_mode_enrolls_before_connecting() {
|
||||
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
|
||||
let expected_server_name = gethostname().to_string_lossy().trim().to_string();
|
||||
let shutdown_token = CancellationToken::new();
|
||||
let (remote_task, _remote_handle) = start_remote_control(
|
||||
let (remote_task, remote_handle) = start_remote_control(
|
||||
remote_control_url,
|
||||
Some(remote_control_state_runtime(&codex_home).await),
|
||||
remote_control_auth_manager(),
|
||||
@@ -804,6 +914,7 @@ async fn remote_control_http_mode_enrolls_before_connecting() {
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
let mut status_rx = remote_handle.status_receiver();
|
||||
|
||||
let enroll_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
@@ -836,6 +947,12 @@ async fn remote_control_http_mode_enrolls_before_connecting() {
|
||||
|
||||
let (handshake_request, mut websocket) =
|
||||
accept_remote_control_backend_connection(&listener).await;
|
||||
expect_remote_control_status(
|
||||
&mut status_rx,
|
||||
/*expected_status*/ None,
|
||||
Some("env_test"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
handshake_request.path,
|
||||
"/backend-api/wham/remote/control/server"
|
||||
@@ -1220,7 +1337,7 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404()
|
||||
let (transport_event_tx, _transport_event_rx) =
|
||||
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
|
||||
let shutdown_token = CancellationToken::new();
|
||||
let (remote_task, _remote_handle) = start_remote_control(
|
||||
let (remote_task, remote_handle) = start_remote_control(
|
||||
remote_control_url,
|
||||
Some(state_db.clone()),
|
||||
remote_control_auth_manager_with_home(&codex_home),
|
||||
@@ -1231,6 +1348,7 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404()
|
||||
)
|
||||
.await
|
||||
.expect("remote control should start");
|
||||
let mut status_rx = remote_handle.status_receiver();
|
||||
|
||||
let websocket_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
@@ -1241,7 +1359,19 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404()
|
||||
websocket_request.headers.get("x-codex-server-id"),
|
||||
Some(&stale_enrollment.server_id)
|
||||
);
|
||||
expect_remote_control_status(
|
||||
&mut status_rx,
|
||||
/*expected_status*/ None,
|
||||
Some("env_stale"),
|
||||
)
|
||||
.await;
|
||||
respond_with_status(websocket_request.stream, "404 Not Found", "").await;
|
||||
expect_remote_control_status(
|
||||
&mut status_rx,
|
||||
/*expected_status*/ None,
|
||||
/*expected_environment_id*/ None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let enroll_request = accept_http_request(&listener).await;
|
||||
assert_eq!(
|
||||
@@ -1258,6 +1388,12 @@ async fn remote_control_http_mode_clears_stale_persisted_enrollment_after_404()
|
||||
.await;
|
||||
|
||||
let (handshake_request, _websocket) = accept_remote_control_backend_connection(&listener).await;
|
||||
expect_remote_control_status(
|
||||
&mut status_rx,
|
||||
/*expected_status*/ None,
|
||||
Some("env_refreshed"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
handshake_request.headers.get("x-codex-server-id"),
|
||||
Some(&refreshed_enrollment.server_id)
|
||||
|
||||
@@ -17,6 +17,8 @@ use super::protocol::ServerEnvelope;
|
||||
use super::protocol::StreamId;
|
||||
use axum::http::HeaderValue;
|
||||
use base64::Engine;
|
||||
use codex_app_server_protocol::RemoteControlConnectionStatus;
|
||||
use codex_app_server_protocol::RemoteControlStatusChangedNotification;
|
||||
use codex_core::util::backoff;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::UnauthorizedRecovery;
|
||||
@@ -117,6 +119,7 @@ pub(crate) struct RemoteControlWebsocket {
|
||||
remote_control_target: Option<RemoteControlTarget>,
|
||||
state_db: Option<Arc<StateRuntime>>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
status_publisher: RemoteControlStatusPublisher,
|
||||
shutdown_token: CancellationToken,
|
||||
reconnect_attempt: u64,
|
||||
enrollment: Option<RemoteControlEnrollment>,
|
||||
@@ -134,20 +137,82 @@ enum ConnectOutcome {
|
||||
Shutdown,
|
||||
}
|
||||
|
||||
pub(super) struct RemoteControlChannels {
|
||||
pub(super) transport_event_tx: mpsc::Sender<TransportEvent>,
|
||||
pub(super) status_publisher: RemoteControlStatusPublisher,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct RemoteControlStatusPublisher {
|
||||
tx: watch::Sender<RemoteControlStatusChangedNotification>,
|
||||
}
|
||||
|
||||
impl RemoteControlStatusPublisher {
|
||||
pub(super) fn new(tx: watch::Sender<RemoteControlStatusChangedNotification>) -> Self {
|
||||
Self { tx }
|
||||
}
|
||||
|
||||
fn publish_status(&self, connection_status: RemoteControlConnectionStatus) {
|
||||
self.tx.send_if_modified(|status| {
|
||||
let next_status = RemoteControlStatusChangedNotification {
|
||||
status: connection_status,
|
||||
environment_id: if connection_status == RemoteControlConnectionStatus::Disabled {
|
||||
None
|
||||
} else {
|
||||
status.environment_id.clone()
|
||||
},
|
||||
};
|
||||
if *status == next_status {
|
||||
return false;
|
||||
}
|
||||
|
||||
*status = next_status;
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
fn publish_environment_id(&self, environment_id: Option<String>) {
|
||||
self.tx.send_if_modified(|status| {
|
||||
if status.status == RemoteControlConnectionStatus::Disabled {
|
||||
return false;
|
||||
}
|
||||
let next_status = RemoteControlStatusChangedNotification {
|
||||
status: status.status,
|
||||
environment_id,
|
||||
};
|
||||
if *status == next_status {
|
||||
return false;
|
||||
}
|
||||
|
||||
*status = next_status;
|
||||
true
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct RemoteControlConnectOptions<'a> {
|
||||
subscribe_cursor: Option<&'a str>,
|
||||
app_server_client_name: Option<&'a str>,
|
||||
}
|
||||
|
||||
impl RemoteControlWebsocket {
|
||||
pub(crate) fn new(
|
||||
remote_control_url: String,
|
||||
remote_control_target: Option<RemoteControlTarget>,
|
||||
state_db: Option<Arc<StateRuntime>>,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
transport_event_tx: mpsc::Sender<TransportEvent>,
|
||||
channels: RemoteControlChannels,
|
||||
shutdown_token: CancellationToken,
|
||||
enabled_rx: watch::Receiver<bool>,
|
||||
) -> Self {
|
||||
let shutdown_token = shutdown_token.child_token();
|
||||
let (server_event_tx, server_event_rx) = mpsc::channel(super::CHANNEL_CAPACITY);
|
||||
let client_tracker =
|
||||
ClientTracker::new(server_event_tx, transport_event_tx, &shutdown_token);
|
||||
let client_tracker = ClientTracker::new(
|
||||
server_event_tx,
|
||||
channels.transport_event_tx,
|
||||
&shutdown_token,
|
||||
);
|
||||
let (outbound_buffer, used_rx) = BoundedOutboundBuffer::new();
|
||||
let auth_recovery = auth_manager.unauthorized_recovery();
|
||||
|
||||
@@ -156,6 +221,7 @@ impl RemoteControlWebsocket {
|
||||
remote_control_target,
|
||||
state_db,
|
||||
auth_manager,
|
||||
status_publisher: channels.status_publisher,
|
||||
shutdown_token,
|
||||
reconnect_attempt: 0,
|
||||
enrollment: None,
|
||||
@@ -202,7 +268,11 @@ impl RemoteControlWebsocket {
|
||||
.await
|
||||
{
|
||||
ConnectOutcome::Connected(websocket_connection) => *websocket_connection,
|
||||
ConnectOutcome::Disabled => continue,
|
||||
ConnectOutcome::Disabled => {
|
||||
self.status_publisher
|
||||
.publish_status(RemoteControlConnectionStatus::Disabled);
|
||||
continue;
|
||||
}
|
||||
ConnectOutcome::Shutdown => break,
|
||||
};
|
||||
|
||||
@@ -243,6 +313,8 @@ impl RemoteControlWebsocket {
|
||||
shutdown_token: &CancellationToken,
|
||||
app_server_client_name: Option<&str>,
|
||||
) -> ConnectOutcome {
|
||||
self.status_publisher
|
||||
.publish_status(RemoteControlConnectionStatus::Connecting);
|
||||
let remote_control_target = match self.remote_control_target.as_ref() {
|
||||
Some(remote_control_target) => remote_control_target.clone(),
|
||||
None => match super::protocol::normalize_remote_control_url(&self.remote_control_url) {
|
||||
@@ -251,6 +323,8 @@ impl RemoteControlWebsocket {
|
||||
remote_control_target
|
||||
}
|
||||
Err(err) => {
|
||||
self.status_publisher
|
||||
.publish_status(RemoteControlConnectionStatus::Errored);
|
||||
warn!("remote control is enabled but the URL is invalid: {err}");
|
||||
tokio::select! {
|
||||
_ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown,
|
||||
@@ -267,6 +341,10 @@ impl RemoteControlWebsocket {
|
||||
|
||||
loop {
|
||||
let subscribe_cursor = self.state.lock().await.subscribe_cursor.clone();
|
||||
let connect_options = RemoteControlConnectOptions {
|
||||
subscribe_cursor: subscribe_cursor.as_deref(),
|
||||
app_server_client_name,
|
||||
};
|
||||
let connect_result = tokio::select! {
|
||||
_ = shutdown_token.cancelled() => return ConnectOutcome::Shutdown,
|
||||
changed = self.enabled_rx.wait_for(|enabled| !*enabled) => {
|
||||
@@ -281,15 +359,20 @@ impl RemoteControlWebsocket {
|
||||
&self.auth_manager,
|
||||
&mut self.auth_recovery,
|
||||
&mut self.enrollment,
|
||||
subscribe_cursor.as_deref(),
|
||||
app_server_client_name,
|
||||
connect_options,
|
||||
&self.status_publisher,
|
||||
) => connect_result,
|
||||
};
|
||||
|
||||
match connect_result {
|
||||
Ok((websocket_connection, response)) => {
|
||||
if !*self.enabled_rx.borrow() {
|
||||
return ConnectOutcome::Disabled;
|
||||
}
|
||||
self.reconnect_attempt = 0;
|
||||
self.auth_recovery = self.auth_manager.unauthorized_recovery();
|
||||
self.status_publisher
|
||||
.publish_status(RemoteControlConnectionStatus::Connected);
|
||||
info!(
|
||||
"connected to app-server remote control websocket: {}, {}",
|
||||
remote_control_target.websocket_url,
|
||||
@@ -298,9 +381,14 @@ impl RemoteControlWebsocket {
|
||||
return ConnectOutcome::Connected(Box::new(websocket_connection));
|
||||
}
|
||||
Err(err) => {
|
||||
if !*self.enabled_rx.borrow() {
|
||||
return ConnectOutcome::Disabled;
|
||||
}
|
||||
let reconnect_delay = if err.kind() == ErrorKind::WouldBlock {
|
||||
REMOTE_CONTROL_ACCOUNT_ID_RETRY_INTERVAL
|
||||
} else {
|
||||
self.status_publisher
|
||||
.publish_status(RemoteControlConnectionStatus::Errored);
|
||||
warn!(
|
||||
"failed to connect to app-server remote control websocket: {}, err: {}",
|
||||
remote_control_target.websocket_url, err
|
||||
@@ -351,9 +439,15 @@ impl RemoteControlWebsocket {
|
||||
let mut enabled_rx = self.enabled_rx.clone();
|
||||
tokio::select! {
|
||||
_ = shutdown_token.cancelled() => {}
|
||||
_ = enabled_rx.wait_for(|enabled| !*enabled) => shutdown_token.cancel(),
|
||||
_ = join_set.join_next() => shutdown_token.cancel(),
|
||||
}
|
||||
changed = enabled_rx.wait_for(|enabled| !*enabled) => {
|
||||
if changed.is_ok() {
|
||||
self.status_publisher
|
||||
.publish_status(RemoteControlConnectionStatus::Disabled);
|
||||
}
|
||||
}
|
||||
_ = join_set.join_next() => {}
|
||||
};
|
||||
shutdown_token.cancel();
|
||||
|
||||
join_set.join_all().await;
|
||||
}
|
||||
@@ -745,8 +839,8 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
auth_recovery: &mut UnauthorizedRecovery,
|
||||
enrollment: &mut Option<RemoteControlEnrollment>,
|
||||
subscribe_cursor: Option<&str>,
|
||||
app_server_client_name: Option<&str>,
|
||||
connect_options: RemoteControlConnectOptions<'_>,
|
||||
status_publisher: &RemoteControlStatusPublisher,
|
||||
) -> io::Result<(
|
||||
WebSocketStream<MaybeTlsStream<TcpStream>>,
|
||||
tungstenite::http::Response<()>,
|
||||
@@ -761,7 +855,16 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
));
|
||||
};
|
||||
|
||||
let auth = load_remote_control_auth(auth_manager).await?;
|
||||
let auth = match load_remote_control_auth(auth_manager).await {
|
||||
Ok(auth) => auth,
|
||||
Err(err) => {
|
||||
if err.kind() == ErrorKind::PermissionDenied {
|
||||
*enrollment = None;
|
||||
status_publisher.publish_environment_id(/*environment_id*/ None);
|
||||
}
|
||||
return Err(err);
|
||||
}
|
||||
};
|
||||
let enrollment_account_id = enrollment.as_ref().map(|enrollment| &enrollment.account_id);
|
||||
if enrollment_account_id.is_some_and(|account_id| account_id != &auth.account_id) {
|
||||
info!(
|
||||
@@ -773,16 +876,25 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
auth.account_id
|
||||
);
|
||||
*enrollment = None;
|
||||
status_publisher.publish_environment_id(/*environment_id*/ None);
|
||||
}
|
||||
|
||||
if let Some(enrollment) = enrollment.as_ref() {
|
||||
status_publisher.publish_environment_id(Some(enrollment.environment_id.clone()));
|
||||
}
|
||||
|
||||
if enrollment.is_none() {
|
||||
*enrollment = load_persisted_remote_control_enrollment(
|
||||
let loaded_enrollment = load_persisted_remote_control_enrollment(
|
||||
Some(state_db),
|
||||
remote_control_target,
|
||||
&auth.account_id,
|
||||
app_server_client_name,
|
||||
connect_options.app_server_client_name,
|
||||
)
|
||||
.await?;
|
||||
if let Some(loaded_enrollment) = loaded_enrollment.as_ref() {
|
||||
status_publisher.publish_environment_id(Some(loaded_enrollment.environment_id.clone()));
|
||||
}
|
||||
*enrollment = loaded_enrollment;
|
||||
}
|
||||
|
||||
if enrollment.is_none() {
|
||||
@@ -807,7 +919,7 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
Some(state_db),
|
||||
remote_control_target,
|
||||
&auth.account_id,
|
||||
app_server_client_name,
|
||||
connect_options.app_server_client_name,
|
||||
Some(&new_enrollment),
|
||||
)
|
||||
.await
|
||||
@@ -823,6 +935,7 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
new_enrollment.server_id,
|
||||
new_enrollment.environment_id
|
||||
);
|
||||
status_publisher.publish_environment_id(Some(new_enrollment.environment_id.clone()));
|
||||
*enrollment = Some(new_enrollment);
|
||||
}
|
||||
|
||||
@@ -833,7 +946,7 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
&remote_control_target.websocket_url,
|
||||
enrollment_ref,
|
||||
&auth,
|
||||
subscribe_cursor,
|
||||
connect_options.subscribe_cursor,
|
||||
)?;
|
||||
|
||||
match connect_async(request).await {
|
||||
@@ -852,7 +965,7 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
Some(state_db),
|
||||
remote_control_target,
|
||||
&auth.account_id,
|
||||
app_server_client_name,
|
||||
connect_options.app_server_client_name,
|
||||
/*enrollment*/ None,
|
||||
)
|
||||
.await
|
||||
@@ -862,6 +975,7 @@ pub(super) async fn connect_remote_control_websocket(
|
||||
);
|
||||
}
|
||||
*enrollment = None;
|
||||
status_publisher.publish_environment_id(/*environment_id*/ None);
|
||||
}
|
||||
tungstenite::Error::Http(response)
|
||||
if matches!(response.status().as_u16(), 401 | 403) =>
|
||||
@@ -968,6 +1082,17 @@ mod tests {
|
||||
#[cfg(not(windows))]
|
||||
const TEST_HTTP_ACCEPT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
|
||||
fn remote_control_status_channel() -> (
|
||||
RemoteControlStatusPublisher,
|
||||
watch::Receiver<RemoteControlStatusChangedNotification>,
|
||||
) {
|
||||
let (status_tx, status_rx) = watch::channel(RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Connecting,
|
||||
environment_id: None,
|
||||
});
|
||||
(RemoteControlStatusPublisher::new(status_tx), status_rx)
|
||||
}
|
||||
|
||||
async fn remote_control_state_runtime(codex_home: &TempDir) -> Arc<StateRuntime> {
|
||||
StateRuntime::init(codex_home.path().to_path_buf(), "test-provider".to_string())
|
||||
.await
|
||||
@@ -1059,6 +1184,7 @@ mod tests {
|
||||
server_id: "srv_e_test".to_string(),
|
||||
server_name: "test-server".to_string(),
|
||||
});
|
||||
let (status_publisher, status_rx) = remote_control_status_channel();
|
||||
|
||||
let err = match connect_remote_control_websocket(
|
||||
&remote_control_target,
|
||||
@@ -1066,8 +1192,11 @@ mod tests {
|
||||
&auth_manager,
|
||||
&mut auth_recovery,
|
||||
&mut enrollment,
|
||||
/*subscribe_cursor*/ None,
|
||||
/*app_server_client_name*/ None,
|
||||
RemoteControlConnectOptions {
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1077,6 +1206,13 @@ mod tests {
|
||||
|
||||
server_task.await.expect("server task should succeed");
|
||||
assert_eq!(err.to_string(), expected_error);
|
||||
assert_eq!(
|
||||
status_rx.borrow().clone(),
|
||||
RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Connecting,
|
||||
environment_id: Some("env_test".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1109,6 +1245,7 @@ mod tests {
|
||||
server_id: "srv_e_test".to_string(),
|
||||
server_name: "test-server".to_string(),
|
||||
});
|
||||
let (status_publisher, status_rx) = remote_control_status_channel();
|
||||
save_auth(
|
||||
codex_home.path(),
|
||||
&remote_control_auth_dot_json("fresh-token"),
|
||||
@@ -1131,13 +1268,23 @@ mod tests {
|
||||
&auth_manager,
|
||||
&mut auth_recovery,
|
||||
&mut enrollment,
|
||||
/*subscribe_cursor*/ None,
|
||||
/*app_server_client_name*/ None,
|
||||
RemoteControlConnectOptions {
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
.await
|
||||
.expect_err("unauthorized response should fail the websocket connect");
|
||||
|
||||
server_task.await.expect("server task should succeed");
|
||||
assert_eq!(
|
||||
status_rx.borrow().clone(),
|
||||
RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Connecting,
|
||||
environment_id: Some("env_test".to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"remote control websocket auth failed with HTTP 401 Unauthorized; retrying after auth recovery"
|
||||
@@ -1187,6 +1334,7 @@ mod tests {
|
||||
.await;
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut enrollment = None;
|
||||
let (status_publisher, status_rx) = remote_control_status_channel();
|
||||
save_auth(
|
||||
codex_home.path(),
|
||||
&remote_control_auth_dot_json("fresh-token"),
|
||||
@@ -1200,13 +1348,21 @@ mod tests {
|
||||
&auth_manager,
|
||||
&mut auth_recovery,
|
||||
&mut enrollment,
|
||||
/*subscribe_cursor*/ None,
|
||||
/*app_server_client_name*/ None,
|
||||
RemoteControlConnectOptions {
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
.await
|
||||
.expect_err("unauthorized enrollment should fail the websocket connect");
|
||||
|
||||
server_task.await.expect("server task should succeed");
|
||||
assert!(
|
||||
!status_rx
|
||||
.has_changed()
|
||||
.expect("remote control status watch should remain open")
|
||||
);
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!(
|
||||
@@ -1236,6 +1392,7 @@ mod tests {
|
||||
server_id: "srv_e_test".to_string(),
|
||||
server_name: "test-server".to_string(),
|
||||
});
|
||||
let (status_publisher, _status_rx) = remote_control_status_channel();
|
||||
|
||||
let err = connect_remote_control_websocket(
|
||||
&remote_control_target,
|
||||
@@ -1243,8 +1400,11 @@ mod tests {
|
||||
&auth_manager,
|
||||
&mut auth_recovery,
|
||||
&mut enrollment,
|
||||
/*subscribe_cursor*/ None,
|
||||
/*app_server_client_name*/ None,
|
||||
RemoteControlConnectOptions {
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
.await
|
||||
.expect_err("missing sqlite state db should fail remote control");
|
||||
@@ -1254,6 +1414,63 @@ mod tests {
|
||||
assert_eq!(enrollment, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn connect_remote_control_websocket_requires_chatgpt_auth() {
|
||||
let remote_control_target = normalize_remote_control_url("http://127.0.0.1:9/backend-api/")
|
||||
.expect("target should parse");
|
||||
let codex_home = TempDir::new().expect("temp dir should create");
|
||||
let state_db = remote_control_state_runtime(&codex_home).await;
|
||||
let auth_manager = AuthManager::shared(
|
||||
codex_home.path().to_path_buf(),
|
||||
/*enable_codex_api_key_env*/ false,
|
||||
AuthCredentialsStoreMode::File,
|
||||
/*chatgpt_base_url*/ None,
|
||||
)
|
||||
.await;
|
||||
let mut auth_recovery = auth_manager.unauthorized_recovery();
|
||||
let mut enrollment = Some(RemoteControlEnrollment {
|
||||
account_id: "account_id".to_string(),
|
||||
environment_id: "env_test".to_string(),
|
||||
server_id: "srv_e_test".to_string(),
|
||||
server_name: "test-server".to_string(),
|
||||
});
|
||||
let (status_publisher, mut status_rx) = remote_control_status_channel();
|
||||
status_publisher.publish_environment_id(Some("env_test".to_string()));
|
||||
status_rx
|
||||
.changed()
|
||||
.await
|
||||
.expect("remote control status watch should remain open");
|
||||
|
||||
let err = connect_remote_control_websocket(
|
||||
&remote_control_target,
|
||||
Some(state_db.as_ref()),
|
||||
&auth_manager,
|
||||
&mut auth_recovery,
|
||||
&mut enrollment,
|
||||
RemoteControlConnectOptions {
|
||||
subscribe_cursor: None,
|
||||
app_server_client_name: None,
|
||||
},
|
||||
&status_publisher,
|
||||
)
|
||||
.await
|
||||
.expect_err("missing auth should fail remote control");
|
||||
|
||||
assert_eq!(err.kind(), ErrorKind::PermissionDenied);
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"remote control requires ChatGPT authentication"
|
||||
);
|
||||
assert_eq!(enrollment, None);
|
||||
assert_eq!(
|
||||
status_rx.borrow().clone(),
|
||||
RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Connecting,
|
||||
environment_id: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_remote_control_websocket_loop_shutdown_cancels_reconnect_backoff() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
@@ -1266,6 +1483,7 @@ mod tests {
|
||||
normalize_remote_control_url(&remote_control_url).expect("target should parse");
|
||||
let (transport_event_tx, transport_event_rx) = mpsc::channel(1);
|
||||
drop(transport_event_rx);
|
||||
let (status_publisher, _status_rx) = remote_control_status_channel();
|
||||
let shutdown_token = CancellationToken::new();
|
||||
let (_enabled_tx, enabled_rx) = watch::channel(true);
|
||||
let websocket_task = tokio::spawn({
|
||||
@@ -1276,7 +1494,10 @@ mod tests {
|
||||
Some(remote_control_target),
|
||||
/*state_db*/ None,
|
||||
remote_control_auth_manager(),
|
||||
transport_event_tx,
|
||||
RemoteControlChannels {
|
||||
transport_event_tx,
|
||||
status_publisher,
|
||||
},
|
||||
shutdown_token,
|
||||
enabled_rx,
|
||||
)
|
||||
@@ -1294,6 +1515,85 @@ mod tests {
|
||||
.expect("websocket task should join");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn publish_status_if_changed_sends_only_status_changes() {
|
||||
let (status_publisher, mut status_rx) = remote_control_status_channel();
|
||||
|
||||
status_publisher.publish_environment_id(/*environment_id*/ None);
|
||||
assert!(
|
||||
timeout(Duration::from_millis(20), status_rx.changed())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
status_publisher.publish_environment_id(Some("env_first".to_string()));
|
||||
status_rx
|
||||
.changed()
|
||||
.await
|
||||
.expect("remote control status watch should remain open");
|
||||
assert_eq!(
|
||||
status_rx.borrow().clone(),
|
||||
RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Connecting,
|
||||
environment_id: Some("env_first".to_string()),
|
||||
}
|
||||
);
|
||||
|
||||
status_publisher.publish_environment_id(Some("env_first".to_string()));
|
||||
assert!(
|
||||
timeout(Duration::from_millis(20), status_rx.changed())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
|
||||
status_publisher.publish_status(RemoteControlConnectionStatus::Connected);
|
||||
status_rx
|
||||
.changed()
|
||||
.await
|
||||
.expect("remote control status watch should remain open");
|
||||
assert_eq!(
|
||||
status_rx.borrow().clone(),
|
||||
RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Connected,
|
||||
environment_id: Some("env_first".to_string()),
|
||||
}
|
||||
);
|
||||
|
||||
status_publisher.publish_environment_id(/*environment_id*/ None);
|
||||
status_rx
|
||||
.changed()
|
||||
.await
|
||||
.expect("remote control status watch should remain open");
|
||||
assert_eq!(
|
||||
status_rx.borrow().clone(),
|
||||
RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Connected,
|
||||
environment_id: None,
|
||||
}
|
||||
);
|
||||
|
||||
status_publisher.publish_environment_id(Some("env_disabled".to_string()));
|
||||
status_publisher.publish_status(RemoteControlConnectionStatus::Disabled);
|
||||
status_rx
|
||||
.changed()
|
||||
.await
|
||||
.expect("remote control status watch should remain open");
|
||||
assert_eq!(
|
||||
status_rx.borrow().clone(),
|
||||
RemoteControlStatusChangedNotification {
|
||||
status: RemoteControlConnectionStatus::Disabled,
|
||||
environment_id: None,
|
||||
}
|
||||
);
|
||||
|
||||
status_publisher.publish_environment_id(Some("env_disabled".to_string()));
|
||||
assert!(
|
||||
timeout(Duration::from_millis(20), status_rx.changed())
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn run_server_writer_inner_sends_periodic_ping_frames() {
|
||||
let (client_stream, mut server_stream) = connected_websocket_pair().await;
|
||||
|
||||
@@ -8,6 +8,8 @@ use app_test_support::ChatGptIdTokenClaims;
|
||||
use app_test_support::encode_id_token;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use app_test_support::write_models_cache;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use chrono::Utc;
|
||||
use codex_app_server_protocol::Account;
|
||||
use codex_app_server_protocol::AuthMode;
|
||||
use codex_app_server_protocol::CancelLoginAccountParams;
|
||||
@@ -17,6 +19,8 @@ use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
|
||||
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
|
||||
use codex_app_server_protocol::GetAccountParams;
|
||||
use codex_app_server_protocol::GetAccountResponse;
|
||||
use codex_app_server_protocol::GetAuthStatusParams;
|
||||
use codex_app_server_protocol::GetAuthStatusResponse;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCErrorError;
|
||||
use codex_app_server_protocol::JSONRPCNotification;
|
||||
@@ -29,6 +33,7 @@ use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::TurnCompletedNotification;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
||||
use codex_login::login_with_api_key;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use core_test_support::responses;
|
||||
@@ -1643,6 +1648,90 @@ async fn get_account_with_chatgpt() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_account_omits_chatgpt_after_permanent_refresh_failure() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
CreateConfigTomlParams {
|
||||
requires_openai_auth: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("stale-access-token")
|
||||
.refresh_token("stale-refresh-token")
|
||||
.account_id("acct_123")
|
||||
.email("user@example.com")
|
||||
.plan_type("pro")
|
||||
.last_refresh(Some(Utc::now() - ChronoDuration::days(9))),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/oauth/token"))
|
||||
.respond_with(ResponseTemplate::new(401).set_body_json(serde_json::json!({
|
||||
"error": {
|
||||
"code": "refresh_token_reused"
|
||||
}
|
||||
})))
|
||||
.expect(1..=2)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let refresh_url = format!("{}/oauth/token", server.uri());
|
||||
let mut mcp = McpProcess::new_with_env(
|
||||
codex_home.path(),
|
||||
&[
|
||||
("OPENAI_API_KEY", None),
|
||||
(
|
||||
REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR,
|
||||
Some(refresh_url.as_str()),
|
||||
),
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let auth_status_request_id = mcp
|
||||
.send_get_auth_status_request(GetAuthStatusParams {
|
||||
include_token: Some(true),
|
||||
refresh_token: Some(true),
|
||||
})
|
||||
.await?;
|
||||
let auth_status_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(auth_status_request_id)),
|
||||
)
|
||||
.await??;
|
||||
let _: GetAuthStatusResponse = to_response(auth_status_resp)?;
|
||||
|
||||
let request_id = mcp
|
||||
.send_get_account_request(GetAccountParams {
|
||||
refresh_token: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let received: GetAccountResponse = to_response(resp)?;
|
||||
|
||||
assert_eq!(
|
||||
received,
|
||||
GetAccountResponse {
|
||||
account: None,
|
||||
requires_openai_auth: true,
|
||||
}
|
||||
);
|
||||
server.verify().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_account_with_chatgpt_missing_plan_claim_returns_unknown() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
@@ -203,6 +203,18 @@ pub enum ShellModelOutput {
|
||||
// UnifiedExec has its own set of tests
|
||||
}
|
||||
|
||||
/// Returns the permission fields required by `Op::UserTurn` for tests that
|
||||
/// construct the op directly.
|
||||
pub fn turn_permission_fields(
|
||||
permission_profile: PermissionProfile,
|
||||
cwd: &Path,
|
||||
) -> (SandboxPolicy, Option<PermissionProfile>) {
|
||||
let sandbox_policy = permission_profile
|
||||
.to_legacy_sandbox_policy(cwd)
|
||||
.unwrap_or_else(|_| SandboxPolicy::new_read_only_policy());
|
||||
(sandbox_policy, Some(permission_profile))
|
||||
}
|
||||
|
||||
pub struct TestCodexBuilder {
|
||||
config_mutators: Vec<Box<ConfigMutator>>,
|
||||
auth: CodexAuth,
|
||||
@@ -710,9 +722,8 @@ impl TestCodex {
|
||||
service_tier: Option<Option<ServiceTier>>,
|
||||
environments: Option<Vec<TurnEnvironmentSelection>>,
|
||||
) -> Result<()> {
|
||||
let sandbox_policy = permission_profile
|
||||
.to_legacy_sandbox_policy(self.config.cwd.as_path())
|
||||
.unwrap_or_else(|_| SandboxPolicy::new_read_only_policy());
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(permission_profile, self.config.cwd.as_path());
|
||||
let session_model = self.session_configured.model.clone();
|
||||
self.codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -726,7 +737,7 @@ impl TestCodex {
|
||||
approval_policy,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy,
|
||||
permission_profile: Some(permission_profile),
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
|
||||
@@ -9,7 +9,6 @@ use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::error::Result;
|
||||
use codex_protocol::exec_output::ExecToolCallOutput;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use codex_sandboxing::get_platform_sandbox;
|
||||
use core_test_support::PathExt;
|
||||
@@ -50,12 +49,9 @@ where
|
||||
arg0: None,
|
||||
};
|
||||
|
||||
let policy = SandboxPolicy::new_read_only_policy();
|
||||
let permission_profile = PermissionProfile::from_legacy_sandbox_policy(&policy);
|
||||
|
||||
process_exec_tool_call(
|
||||
params,
|
||||
&permission_profile,
|
||||
&PermissionProfile::read_only(),
|
||||
&cwd,
|
||||
&None,
|
||||
/*use_legacy_landlock*/ false,
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use anyhow::Context;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
@@ -18,6 +18,7 @@ use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use image::ImageBuffer;
|
||||
use image::Rgba;
|
||||
@@ -108,6 +109,8 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu
|
||||
responses::mount_sse_once(&server, response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd.path());
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -125,8 +128,8 @@ async fn copy_paste_local_image_persists_rollout_request_shape() -> anyhow::Resu
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -196,6 +199,8 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()>
|
||||
responses::mount_sse_once(&server, response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd.path());
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -213,8 +218,8 @@ async fn drag_drop_image_persists_rollout_request_shape() -> anyhow::Result<()>
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
#![cfg(not(target_os = "windows"))]
|
||||
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use responses::ev_assistant_message;
|
||||
@@ -69,6 +70,8 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> {
|
||||
responses::mount_sse_once_match(&server, match_json_text_param, sse1).await;
|
||||
|
||||
let TestCodex { codex, cwd, .. } = test_codex().build(&server).await?;
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd.path());
|
||||
|
||||
// 1) Normal user input – should hit server once.
|
||||
codex
|
||||
@@ -82,8 +85,8 @@ async fn codex_returns_json_result(model: String) -> anyhow::Result<()> {
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
|
||||
@@ -8,10 +8,10 @@ use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
use codex_config::config_toml::ProjectConfig;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ResponsesRequest;
|
||||
@@ -19,6 +19,7 @@ use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use tokio::time::timeout;
|
||||
|
||||
@@ -46,6 +47,8 @@ fn contains_skill_body(request: &ResponsesRequest, skill_body: &str) -> bool {
|
||||
|
||||
async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str) -> Result<()> {
|
||||
let session_model = test.session_configured.model.clone();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, test.cwd_path());
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
environments: None,
|
||||
@@ -63,8 +66,8 @@ async fn submit_skill_turn(test: &TestCodex, skill_path: PathBuf, prompt: &str)
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
|
||||
@@ -9,6 +9,7 @@ use codex_login::CodexAuth;
|
||||
use codex_models_manager::client_version_to_whole;
|
||||
use codex_models_manager::manager::RefreshStrategy;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::openai_models::ModelVisibility;
|
||||
@@ -19,7 +20,6 @@ use codex_protocol::openai_models::TruncationPolicyConfig;
|
||||
use codex_protocol::openai_models::default_input_modalities;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
@@ -28,6 +28,7 @@ use core_test_support::responses::ev_response_created;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::responses::sse_response;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde::Deserialize;
|
||||
@@ -87,6 +88,8 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> {
|
||||
sse_response(response_body).insert_header("X-Models-Etag", ETAG),
|
||||
)
|
||||
.await;
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, test.cwd_path());
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -99,8 +102,8 @@ async fn renews_cache_ttl_on_matching_models_etag() -> Result<()> {
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
approval_policy: codex_protocol::protocol::AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: None,
|
||||
|
||||
@@ -6,11 +6,11 @@ use std::time::Duration;
|
||||
use anyhow::Result;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
@@ -21,6 +21,7 @@ use core_test_support::responses::sse;
|
||||
use core_test_support::responses::sse_response;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event_with_timeout;
|
||||
use pretty_assertions::assert_eq;
|
||||
use wiremock::MockServer;
|
||||
@@ -61,6 +62,9 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch
|
||||
let codex = Arc::clone(&test.codex);
|
||||
let cwd = Arc::clone(&test.cwd);
|
||||
let session_model = test.session_configured.model.clone();
|
||||
let cwd_path = cwd.path().to_path_buf();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path());
|
||||
|
||||
assert_eq!(spawn_models_mock.requests().len(), 1);
|
||||
assert_eq!(spawn_models_mock.single_request_path(), "/v1/models");
|
||||
@@ -107,11 +111,11 @@ async fn refresh_models_on_models_etag_mismatch_and_avoid_duplicate_models_fetch
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
cwd: cwd_path,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
|
||||
@@ -3,11 +3,11 @@ use std::sync::Arc;
|
||||
use codex_core::CodexThread;
|
||||
use codex_protocol::AgentPath;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::context_snapshot;
|
||||
use core_test_support::context_snapshot::ContextSnapshotOptions;
|
||||
@@ -25,6 +25,7 @@ use core_test_support::streaming_sse::StreamingSseServer;
|
||||
use core_test_support::streaming_sse::start_streaming_sse_server;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
@@ -108,6 +109,8 @@ async fn submit_user_input(codex: &CodexThread, text: &str) {
|
||||
}
|
||||
|
||||
async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) {
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
environments: None,
|
||||
@@ -119,8 +122,8 @@ async fn submit_danger_full_access_user_turn(test: &TestCodex, text: &str) {
|
||||
cwd: test.config.cwd.to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: None,
|
||||
|
||||
@@ -6,10 +6,10 @@ use codex_features::Feature;
|
||||
use codex_protocol::config_types::CollaborationMode;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Settings;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::request_user_input::RequestUserInputAnswer;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
@@ -24,6 +24,7 @@ use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -128,6 +129,8 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul
|
||||
let second_mock = responses::mount_sse_once(&server, second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd.path());
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -140,8 +143,8 @@ async fn request_user_input_round_trip_for_mode(mode: ModeKind) -> anyhow::Resul
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -248,6 +251,8 @@ where
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
let collaboration_mode = build_mode(session_model.clone());
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd.path());
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -260,8 +265,8 @@ where
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use anyhow::Result;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ExecCommandBeginEvent;
|
||||
use codex_protocol::protocol::ExecCommandEndEvent;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
@@ -15,6 +15,7 @@ use core_test_support::responses::mount_sse_sequence;
|
||||
use core_test_support::responses::sse;
|
||||
use core_test_support::test_codex::TestCodexHarness;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -154,6 +155,8 @@ async fn run_snapshot_command_with_options(
|
||||
let codex_home = test.home.path().to_path_buf();
|
||||
let session_model = test.session_configured.model.clone();
|
||||
let cwd = test.cwd_path().to_path_buf();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd.as_path());
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -166,8 +169,8 @@ async fn run_snapshot_command_with_options(
|
||||
cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -247,6 +250,8 @@ async fn run_shell_command_snapshot_with_options(
|
||||
let codex_home = test.home.path().to_path_buf();
|
||||
let session_model = test.session_configured.model.clone();
|
||||
let cwd = test.cwd_path().to_path_buf();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd.as_path());
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -259,8 +264,8 @@ async fn run_shell_command_snapshot_with_options(
|
||||
cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -321,6 +326,8 @@ async fn run_tool_turn_on_harness(
|
||||
let codex = test.codex.clone();
|
||||
let session_model = test.session_configured.model.clone();
|
||||
let cwd = test.cwd_path().to_path_buf();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd.as_path());
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
environments: None,
|
||||
@@ -332,8 +339,8 @@ async fn run_tool_turn_on_harness(
|
||||
cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -558,6 +565,8 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> {
|
||||
mount_sse_sequence(harness.server(), responses).await;
|
||||
|
||||
let model = test.session_configured.model.clone();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd.as_path());
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
environments: None,
|
||||
@@ -569,8 +578,8 @@ async fn shell_command_snapshot_still_intercepts_apply_patch() -> Result<()> {
|
||||
cwd: cwd.clone(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
|
||||
@@ -9,9 +9,9 @@ use codex_exec_server::ExecServerRuntimePaths;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_models_manager::collaboration_mode_presets::CollaborationModesConfig;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -24,6 +24,7 @@ use core_test_support::responses::sse;
|
||||
use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -97,6 +98,8 @@ async fn user_turn_includes_skill_instructions() -> Result<()> {
|
||||
.await;
|
||||
|
||||
let session_model = test.session_configured.model.clone();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, test.config.cwd.as_path());
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
environments: None,
|
||||
@@ -114,8 +117,8 @@ async fn user_turn_includes_skill_instructions() -> Result<()> {
|
||||
cwd: test.config.cwd.to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
|
||||
@@ -4,12 +4,12 @@ use codex_config::types::McpServerTransportConfig;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
@@ -26,6 +26,7 @@ use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::stdio_server_bin;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use core_test_support::wait_for_event_match;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -396,6 +397,9 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result<
|
||||
let test = builder.build(&server).await?;
|
||||
let db = test.codex.state_db().expect("state db enabled");
|
||||
let thread_id = test.session_configured.session_id;
|
||||
let cwd = test.cwd_path().to_path_buf();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::read_only(), cwd.as_path());
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -405,11 +409,11 @@ async fn mcp_call_marks_thread_memory_mode_polluted_when_configured() -> Result<
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: test.cwd_path().to_path_buf(),
|
||||
cwd,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: test.session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: None,
|
||||
|
||||
@@ -4,11 +4,11 @@ use std::fs;
|
||||
|
||||
use assert_matches::assert_matches;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::plan_tool::StepStatus;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::assert_regex_match;
|
||||
use core_test_support::responses;
|
||||
@@ -24,6 +24,7 @@ use core_test_support::responses::start_mock_server;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use serde_json::Value;
|
||||
use serde_json::json;
|
||||
@@ -75,6 +76,9 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()>
|
||||
let second_mock = responses::mount_sse_once(&server, second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
let cwd_path = cwd.path().to_path_buf();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path());
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -84,11 +88,11 @@ async fn shell_tool_executes_command_and_streams_output() -> anyhow::Result<()>
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
cwd: cwd_path,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -148,6 +152,9 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> {
|
||||
let second_mock = responses::mount_sse_once(&server, second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
let cwd_path = cwd.path().to_path_buf();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path());
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -157,11 +164,11 @@ async fn update_plan_tool_emits_plan_update_event() -> anyhow::Result<()> {
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
cwd: cwd_path,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -231,6 +238,9 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> {
|
||||
let second_mock = responses::mount_sse_once(&server, second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
let cwd_path = cwd.path().to_path_buf();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path());
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -240,11 +250,11 @@ async fn update_plan_tool_rejects_malformed_payload() -> anyhow::Result<()> {
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
cwd: cwd_path,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -329,6 +339,9 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<()
|
||||
let second_mock = responses::mount_sse_once(&server, second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
let cwd_path = cwd.path().to_path_buf();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path());
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -338,11 +351,11 @@ async fn apply_patch_tool_executes_and_emits_patch_events() -> anyhow::Result<()
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
cwd: cwd_path,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -435,6 +448,9 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> {
|
||||
let second_mock = responses::mount_sse_once(&server, second_response).await;
|
||||
|
||||
let session_model = session_configured.model.clone();
|
||||
let cwd_path = cwd.path().to_path_buf();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd_path.as_path());
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -444,11 +460,11 @@ async fn apply_patch_reports_parse_diagnostics() -> anyhow::Result<()> {
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
final_output_json_schema: None,
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
cwd: cwd_path,
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
|
||||
@@ -5,10 +5,10 @@ use std::fs;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
@@ -24,6 +24,7 @@ use core_test_support::streaming_sse::StreamingSseChunk;
|
||||
use core_test_support::streaming_sse::start_streaming_sse_server;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use core_test_support::wait_for_event;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::Value;
|
||||
@@ -32,6 +33,8 @@ use tokio::sync::oneshot;
|
||||
|
||||
async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> {
|
||||
let session_model = test.session_configured.model.clone();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, test.cwd.path());
|
||||
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -44,8 +47,8 @@ async fn run_turn(test: &TestCodex, prompt: &str) -> anyhow::Result<()> {
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
@@ -352,6 +355,8 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an
|
||||
.await?;
|
||||
|
||||
let session_model = test.session_configured.model.clone();
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, test.cwd.path());
|
||||
test.codex
|
||||
.submit(Op::UserTurn {
|
||||
environments: None,
|
||||
@@ -363,8 +368,8 @@ async fn shell_tools_start_before_response_completed_when_stream_delayed() -> an
|
||||
cwd: test.cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_model,
|
||||
effort: None,
|
||||
summary: None,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
use anyhow::Result;
|
||||
use codex_model_provider_info::WireApi;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::Op;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use core_test_support::responses;
|
||||
use core_test_support::responses::ev_completed;
|
||||
@@ -14,6 +14,7 @@ use core_test_support::responses::sse;
|
||||
use core_test_support::skip_if_no_network;
|
||||
use core_test_support::test_codex::TestCodex;
|
||||
use core_test_support::test_codex::test_codex;
|
||||
use core_test_support::test_codex::turn_permission_fields;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tokio::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
@@ -147,6 +148,8 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result
|
||||
cwd,
|
||||
..
|
||||
} = builder.build(&server).await?;
|
||||
let (sandbox_policy, permission_profile) =
|
||||
turn_permission_fields(PermissionProfile::Disabled, cwd.path());
|
||||
|
||||
codex
|
||||
.submit(Op::UserTurn {
|
||||
@@ -159,8 +162,8 @@ async fn websocket_fallback_hides_first_websocket_retry_stream_error() -> Result
|
||||
cwd: cwd.path().to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::DangerFullAccess,
|
||||
permission_profile: None,
|
||||
sandbox_policy,
|
||||
permission_profile,
|
||||
model: session_configured.model.clone(),
|
||||
effort: None,
|
||||
summary: None,
|
||||
|
||||
@@ -148,7 +148,13 @@ impl ModelProvider for ConfiguredModelProvider {
|
||||
let account = if self.info.requires_openai_auth {
|
||||
self.auth_manager
|
||||
.as_ref()
|
||||
.and_then(|auth_manager| auth_manager.auth_cached())
|
||||
.and_then(|auth_manager| {
|
||||
let auth = auth_manager.auth_cached()?;
|
||||
if auth_manager.refresh_failure_for_auth(&auth).is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(auth)
|
||||
})
|
||||
.map(|auth| match &auth {
|
||||
CodexAuth::ApiKey(_) => Ok(ProviderAccount::ApiKey),
|
||||
CodexAuth::Chatgpt(_)
|
||||
|
||||
@@ -441,6 +441,7 @@ fn server_notification_thread_target(
|
||||
| ServerNotification::AccountUpdated(_)
|
||||
| ServerNotification::AccountRateLimitsUpdated(_)
|
||||
| ServerNotification::AppListUpdated(_)
|
||||
| ServerNotification::RemoteControlStatusChanged(_)
|
||||
| ServerNotification::ExternalAgentConfigImportCompleted(_)
|
||||
| ServerNotification::DeprecationNotice(_)
|
||||
| ServerNotification::ConfigWarning(_)
|
||||
|
||||
@@ -7267,6 +7267,7 @@ impl ChatWidget {
|
||||
| ServerNotification::McpToolCallProgress(_)
|
||||
| ServerNotification::McpServerOauthLoginCompleted(_)
|
||||
| ServerNotification::AppListUpdated(_)
|
||||
| ServerNotification::RemoteControlStatusChanged(_)
|
||||
| ServerNotification::ExternalAgentConfigImportCompleted(_)
|
||||
| ServerNotification::FsChanged(_)
|
||||
| ServerNotification::FuzzyFileSearchSessionUpdated(_)
|
||||
|
||||
Reference in New Issue
Block a user