Merge branch 'main' into codex/viyatb/pr1-network-rule-execpolicy

This commit is contained in:
viyatb-oai
2026-02-06 10:47:10 -08:00
committed by GitHub
203 changed files with 12954 additions and 2391 deletions
+13 -5
View File
@@ -1219,6 +1219,7 @@ dependencies = [
"axum",
"base64 0.22.1",
"chrono",
"clap",
"codex-app-server-protocol",
"codex-arg0",
"codex-backend-client",
@@ -1233,8 +1234,10 @@ dependencies = [
"codex-protocol",
"codex-rmcp-client",
"codex-utils-absolute-path",
"codex-utils-cargo-bin",
"codex-utils-json-to-toml",
"core_test_support",
"futures",
"os_info",
"pretty_assertions",
"rmcp",
@@ -1245,6 +1248,7 @@ dependencies = [
"tempfile",
"time",
"tokio",
"tokio-tungstenite",
"toml 0.9.11+spec-1.1.0",
"tracing",
"tracing-subscriber",
@@ -1917,9 +1921,9 @@ version = "0.0.0"
dependencies = [
"chrono",
"codex-api",
"codex-app-server-protocol",
"codex-protocol",
"codex-utils-absolute-path",
"codex-utils-string",
"eventsource-stream",
"http 1.4.0",
"opentelemetry",
@@ -1927,6 +1931,7 @@ dependencies = [
"opentelemetry-otlp",
"opentelemetry-semantic-conventions",
"opentelemetry_sdk",
"os_info",
"pretty_assertions",
"reqwest",
"serde",
@@ -2244,6 +2249,9 @@ dependencies = [
[[package]]
name = "codex-utils-string"
version = "0.0.0"
dependencies = [
"pretty_assertions",
]
[[package]]
name = "codex-windows-sandbox"
@@ -8784,9 +8792,9 @@ dependencies = [
[[package]]
name = "time"
version = "0.3.46"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9da98b7d9b7dad93488a84b8248efc35352b0b2657397d4167e7ad67e5d535e5"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
@@ -8807,9 +8815,9 @@ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.26"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78cc610bac2dcee56805c99642447d4c5dbde4d01f752ffea0199aee1f601dc4"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
+1 -1
View File
@@ -221,7 +221,7 @@ tempfile = "3.23.0"
test-log = "0.2.19"
textwrap = "0.16.2"
thiserror = "2.0.17"
time = "0.3"
time = "0.3.47"
tiny_http = "0.12"
tokio = "1"
tokio-stream = "0.1.18"
+1
View File
@@ -51,6 +51,7 @@ You can enable notifications by configuring a script that is run whenever the ag
### `codex exec` to run Codex programmatically/non-interactively
To run Codex non-interactively, run `codex exec PROMPT` (you can also pass the prompt via `stdin`) and Codex will work on your task until it decides that it is done and exits. Output is printed to the terminal directly. You can set the `RUST_LOG` environment variable to see more about what's going on.
Use `codex exec --ephemeral ...` to run without persisting session rollout files to disk.
### Experimenting with the Codex Sandbox
@@ -422,6 +422,27 @@
],
"type": "object"
},
"ExperimentalFeatureListParams": {
"properties": {
"cursor": {
"description": "Opaque pagination cursor returned by a previous call.",
"type": [
"string",
"null"
]
},
"limit": {
"description": "Optional page size; defaults to a reasonable server-side value.",
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
}
},
"type": "object"
},
"FeedbackUploadParams": {
"properties": {
"classification": {
@@ -2789,6 +2810,29 @@
],
"type": "object"
},
"TurnSteerParams": {
"properties": {
"expectedTurnId": {
"description": "Required active turn id precondition. The request fails when it does not match the currently active turn.",
"type": "string"
},
"input": {
"items": {
"$ref": "#/definitions/UserInput"
},
"type": "array"
},
"threadId": {
"type": "string"
}
},
"required": [
"expectedTurnId",
"input",
"threadId"
],
"type": "object"
},
"UserInput": {
"oneOf": [
{
@@ -3490,6 +3534,30 @@
"title": "Turn/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"turn/steer"
],
"title": "Turn/steerRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/TurnSteerParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Turn/steerRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -3562,6 +3630,30 @@
"title": "Model/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"experimentalFeature/list"
],
"title": "ExperimentalFeature/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/ExperimentalFeatureListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ExperimentalFeature/listRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -862,6 +862,30 @@
"title": "Turn/startRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"turn/steer"
],
"title": "Turn/steerRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/TurnSteerParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Turn/steerRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -934,6 +958,30 @@
"title": "Model/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"experimentalFeature/list"
],
"title": "ExperimentalFeature/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/ExperimentalFeatureListParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "ExperimentalFeature/listRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -9777,6 +9825,32 @@
},
"type": "object"
},
"AppConfig": {
"properties": {
"disabled_reason": {
"anyOf": [
{
"$ref": "#/definitions/v2/AppDisabledReason"
},
{
"type": "null"
}
]
},
"enabled": {
"default": true,
"type": "boolean"
}
},
"type": "object"
},
"AppDisabledReason": {
"enum": [
"unknown",
"user"
],
"type": "string"
},
"AppInfo": {
"properties": {
"description": {
@@ -9826,6 +9900,9 @@
],
"type": "object"
},
"AppsConfig": {
"type": "object"
},
"AppsListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -10441,6 +10518,17 @@
}
]
},
"apps": {
"anyOf": [
{
"$ref": "#/definitions/v2/AppsConfig"
},
{
"type": "null"
}
],
"default": null
},
"compact_prompt": {
"type": [
"string",
@@ -11204,6 +11292,143 @@
"title": "ErrorNotification",
"type": "object"
},
"ExperimentalFeature": {
"properties": {
"announcement": {
"description": "Announcement copy shown to users when the feature is introduced. Null when this feature is not in beta.",
"type": [
"string",
"null"
]
},
"defaultEnabled": {
"description": "Whether this feature is enabled by default.",
"type": "boolean"
},
"description": {
"description": "Short summary describing what the feature does. Null when this feature is not in beta.",
"type": [
"string",
"null"
]
},
"displayName": {
"description": "User-facing display name shown in the experimental features UI. Null when this feature is not in beta.",
"type": [
"string",
"null"
]
},
"enabled": {
"description": "Whether this feature is currently enabled in the loaded config.",
"type": "boolean"
},
"name": {
"description": "Stable key used in config.toml and CLI flag toggles.",
"type": "string"
},
"stage": {
"allOf": [
{
"$ref": "#/definitions/v2/ExperimentalFeatureStage"
}
],
"description": "Lifecycle stage of this feature flag."
}
},
"required": [
"defaultEnabled",
"enabled",
"name",
"stage"
],
"type": "object"
},
"ExperimentalFeatureListParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cursor": {
"description": "Opaque pagination cursor returned by a previous call.",
"type": [
"string",
"null"
]
},
"limit": {
"description": "Optional page size; defaults to a reasonable server-side value.",
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
}
},
"title": "ExperimentalFeatureListParams",
"type": "object"
},
"ExperimentalFeatureListResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/v2/ExperimentalFeature"
},
"type": "array"
},
"nextCursor": {
"description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.",
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"title": "ExperimentalFeatureListResponse",
"type": "object"
},
"ExperimentalFeatureStage": {
"oneOf": [
{
"description": "Feature is available for user testing and feedback.",
"enum": [
"beta"
],
"type": "string"
},
{
"description": "Feature is still being built and not ready for broad use.",
"enum": [
"underDevelopment"
],
"type": "string"
},
{
"description": "Feature is production-ready.",
"enum": [
"stable"
],
"type": "string"
},
{
"description": "Feature is deprecated and should be avoided.",
"enum": [
"deprecated"
],
"type": "string"
},
{
"description": "Feature flag is retained only for backwards compatibility.",
"enum": [
"removed"
],
"type": "string"
}
]
},
"FeedbackUploadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -15568,6 +15793,44 @@
],
"type": "string"
},
"TurnSteerParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"expectedTurnId": {
"description": "Required active turn id precondition. The request fails when it does not match the currently active turn.",
"type": "string"
},
"input": {
"items": {
"$ref": "#/definitions/v2/UserInput"
},
"type": "array"
},
"threadId": {
"type": "string"
}
},
"required": [
"expectedTurnId",
"input",
"threadId"
],
"title": "TurnSteerParams",
"type": "object"
},
"TurnSteerResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"turnId": {
"type": "string"
}
},
"required": [
"turnId"
],
"title": "TurnSteerResponse",
"type": "object"
},
"UserInput": {
"oneOf": [
{
@@ -17,6 +17,35 @@
},
"type": "object"
},
"AppConfig": {
"properties": {
"disabled_reason": {
"anyOf": [
{
"$ref": "#/definitions/AppDisabledReason"
},
{
"type": "null"
}
]
},
"enabled": {
"default": true,
"type": "boolean"
}
},
"type": "object"
},
"AppDisabledReason": {
"enum": [
"unknown",
"user"
],
"type": "string"
},
"AppsConfig": {
"type": "object"
},
"AskForApproval": {
"enum": [
"untrusted",
@@ -49,6 +78,17 @@
}
]
},
"apps": {
"anyOf": [
{
"$ref": "#/definitions/AppsConfig"
},
{
"type": "null"
}
],
"default": null
},
"compact_prompt": {
"type": [
"string",
@@ -0,0 +1,23 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"cursor": {
"description": "Opaque pagination cursor returned by a previous call.",
"type": [
"string",
"null"
]
},
"limit": {
"description": "Optional page size; defaults to a reasonable server-side value.",
"format": "uint32",
"minimum": 0.0,
"type": [
"integer",
"null"
]
}
},
"title": "ExperimentalFeatureListParams",
"type": "object"
}
@@ -0,0 +1,116 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ExperimentalFeature": {
"properties": {
"announcement": {
"description": "Announcement copy shown to users when the feature is introduced. Null when this feature is not in beta.",
"type": [
"string",
"null"
]
},
"defaultEnabled": {
"description": "Whether this feature is enabled by default.",
"type": "boolean"
},
"description": {
"description": "Short summary describing what the feature does. Null when this feature is not in beta.",
"type": [
"string",
"null"
]
},
"displayName": {
"description": "User-facing display name shown in the experimental features UI. Null when this feature is not in beta.",
"type": [
"string",
"null"
]
},
"enabled": {
"description": "Whether this feature is currently enabled in the loaded config.",
"type": "boolean"
},
"name": {
"description": "Stable key used in config.toml and CLI flag toggles.",
"type": "string"
},
"stage": {
"allOf": [
{
"$ref": "#/definitions/ExperimentalFeatureStage"
}
],
"description": "Lifecycle stage of this feature flag."
}
},
"required": [
"defaultEnabled",
"enabled",
"name",
"stage"
],
"type": "object"
},
"ExperimentalFeatureStage": {
"oneOf": [
{
"description": "Feature is available for user testing and feedback.",
"enum": [
"beta"
],
"type": "string"
},
{
"description": "Feature is still being built and not ready for broad use.",
"enum": [
"underDevelopment"
],
"type": "string"
},
{
"description": "Feature is production-ready.",
"enum": [
"stable"
],
"type": "string"
},
{
"description": "Feature is deprecated and should be avoided.",
"enum": [
"deprecated"
],
"type": "string"
},
{
"description": "Feature flag is retained only for backwards compatibility.",
"enum": [
"removed"
],
"type": "string"
}
]
}
},
"properties": {
"data": {
"items": {
"$ref": "#/definitions/ExperimentalFeature"
},
"type": "array"
},
"nextCursor": {
"description": "Opaque cursor to pass to the next call to continue after the last item. If None, there are no more items to return.",
"type": [
"string",
"null"
]
}
},
"required": [
"data"
],
"title": "ExperimentalFeatureListResponse",
"type": "object"
}
@@ -0,0 +1,189 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"ByteRange": {
"properties": {
"end": {
"format": "uint",
"minimum": 0.0,
"type": "integer"
},
"start": {
"format": "uint",
"minimum": 0.0,
"type": "integer"
}
},
"required": [
"end",
"start"
],
"type": "object"
},
"TextElement": {
"properties": {
"byteRange": {
"allOf": [
{
"$ref": "#/definitions/ByteRange"
}
],
"description": "Byte range in the parent `text` buffer that this element occupies."
},
"placeholder": {
"description": "Optional human-readable placeholder for the element, displayed in the UI.",
"type": [
"string",
"null"
]
}
},
"required": [
"byteRange"
],
"type": "object"
},
"UserInput": {
"oneOf": [
{
"properties": {
"text": {
"type": "string"
},
"text_elements": {
"default": [],
"description": "UI-defined spans within `text` used to render or persist special elements.",
"items": {
"$ref": "#/definitions/TextElement"
},
"type": "array"
},
"type": {
"enum": [
"text"
],
"title": "TextUserInputType",
"type": "string"
}
},
"required": [
"text",
"type"
],
"title": "TextUserInput",
"type": "object"
},
{
"properties": {
"type": {
"enum": [
"image"
],
"title": "ImageUserInputType",
"type": "string"
},
"url": {
"type": "string"
}
},
"required": [
"type",
"url"
],
"title": "ImageUserInput",
"type": "object"
},
{
"properties": {
"path": {
"type": "string"
},
"type": {
"enum": [
"localImage"
],
"title": "LocalImageUserInputType",
"type": "string"
}
},
"required": [
"path",
"type"
],
"title": "LocalImageUserInput",
"type": "object"
},
{
"properties": {
"name": {
"type": "string"
},
"path": {
"type": "string"
},
"type": {
"enum": [
"skill"
],
"title": "SkillUserInputType",
"type": "string"
}
},
"required": [
"name",
"path",
"type"
],
"title": "SkillUserInput",
"type": "object"
},
{
"properties": {
"name": {
"type": "string"
},
"path": {
"type": "string"
},
"type": {
"enum": [
"mention"
],
"title": "MentionUserInputType",
"type": "string"
}
},
"required": [
"name",
"path",
"type"
],
"title": "MentionUserInput",
"type": "object"
}
]
}
},
"properties": {
"expectedTurnId": {
"description": "Required active turn id precondition. The request fails when it does not match the currently active turn.",
"type": "string"
},
"input": {
"items": {
"$ref": "#/definitions/UserInput"
},
"type": "array"
},
"threadId": {
"type": "string"
}
},
"required": [
"expectedTurnId",
"input",
"threadId"
],
"title": "TurnSteerParams",
"type": "object"
}
@@ -0,0 +1,13 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"turnId": {
"type": "string"
}
},
"required": [
"turnId"
],
"title": "TurnSteerResponse",
"type": "object"
}
@@ -27,6 +27,7 @@ import type { CommandExecParams } from "./v2/CommandExecParams";
import type { ConfigBatchWriteParams } from "./v2/ConfigBatchWriteParams";
import type { ConfigReadParams } from "./v2/ConfigReadParams";
import type { ConfigValueWriteParams } from "./v2/ConfigValueWriteParams";
import type { ExperimentalFeatureListParams } from "./v2/ExperimentalFeatureListParams";
import type { FeedbackUploadParams } from "./v2/FeedbackUploadParams";
import type { GetAccountParams } from "./v2/GetAccountParams";
import type { ListMcpServerStatusParams } from "./v2/ListMcpServerStatusParams";
@@ -51,8 +52,9 @@ import type { ThreadStartParams } from "./v2/ThreadStartParams";
import type { ThreadUnarchiveParams } from "./v2/ThreadUnarchiveParams";
import type { TurnInterruptParams } from "./v2/TurnInterruptParams";
import type { TurnStartParams } from "./v2/TurnStartParams";
import type { TurnSteerParams } from "./v2/TurnSteerParams";
/**
* Request from the client to the server.
*/
export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/read", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/write", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "newConversation", id: RequestId, params: NewConversationParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "listConversations", id: RequestId, params: ListConversationsParams, } | { "method": "resumeConversation", id: RequestId, params: ResumeConversationParams, } | { "method": "forkConversation", id: RequestId, params: ForkConversationParams, } | { "method": "archiveConversation", id: RequestId, params: ArchiveConversationParams, } | { "method": "sendUserMessage", id: RequestId, params: SendUserMessageParams, } | { "method": "sendUserTurn", id: RequestId, params: SendUserTurnParams, } | { "method": "interruptConversation", id: RequestId, params: InterruptConversationParams, } | { "method": "addConversationListener", id: RequestId, params: AddConversationListenerParams, } | { "method": "removeConversationListener", id: RequestId, params: RemoveConversationListenerParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "loginApiKey", id: RequestId, params: LoginApiKeyParams, } | { "method": "loginChatGpt", id: RequestId, params: undefined, } | { "method": "cancelLoginChatGpt", id: RequestId, params: CancelLoginChatGptParams, } | { "method": "logoutChatGpt", id: RequestId, params: undefined, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "getUserSavedConfig", id: RequestId, params: undefined, } | { "method": "setDefaultModel", id: RequestId, params: SetDefaultModelParams, } | { "method": "getUserAgent", id: RequestId, params: undefined, } | { "method": "userInfo", id: RequestId, params: undefined, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, } | { "method": "execOneOffCommand", id: RequestId, params: ExecOneOffCommandParams, };
export type ClientRequest ={ "method": "initialize", id: RequestId, params: InitializeParams, } | { "method": "thread/start", id: RequestId, params: ThreadStartParams, } | { "method": "thread/resume", id: RequestId, params: ThreadResumeParams, } | { "method": "thread/fork", id: RequestId, params: ThreadForkParams, } | { "method": "thread/archive", id: RequestId, params: ThreadArchiveParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/unarchive", id: RequestId, params: ThreadUnarchiveParams, } | { "method": "thread/compact/start", id: RequestId, params: ThreadCompactStartParams, } | { "method": "thread/rollback", id: RequestId, params: ThreadRollbackParams, } | { "method": "thread/list", id: RequestId, params: ThreadListParams, } | { "method": "thread/loaded/list", id: RequestId, params: ThreadLoadedListParams, } | { "method": "thread/read", id: RequestId, params: ThreadReadParams, } | { "method": "skills/list", id: RequestId, params: SkillsListParams, } | { "method": "skills/remote/read", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/write", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "turn/start", id: RequestId, params: TurnStartParams, } | { "method": "turn/steer", id: RequestId, params: TurnSteerParams, } | { "method": "turn/interrupt", id: RequestId, params: TurnInterruptParams, } | { "method": "review/start", id: RequestId, params: ReviewStartParams, } | { "method": "model/list", id: RequestId, params: ModelListParams, } | { "method": "experimentalFeature/list", id: RequestId, params: ExperimentalFeatureListParams, } | { "method": "mcpServer/oauth/login", id: RequestId, params: McpServerOauthLoginParams, } | { "method": "config/mcpServer/reload", id: RequestId, params: undefined, } | { "method": "mcpServerStatus/list", id: RequestId, params: ListMcpServerStatusParams, } | { "method": "account/login/start", id: RequestId, params: LoginAccountParams, } | { "method": "account/login/cancel", id: RequestId, params: CancelLoginAccountParams, } | { "method": "account/logout", id: RequestId, params: undefined, } | { "method": "account/rateLimits/read", id: RequestId, params: undefined, } | { "method": "feedback/upload", id: RequestId, params: FeedbackUploadParams, } | { "method": "command/exec", id: RequestId, params: CommandExecParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "config/value/write", id: RequestId, params: ConfigValueWriteParams, } | { "method": "config/batchWrite", id: RequestId, params: ConfigBatchWriteParams, } | { "method": "configRequirements/read", id: RequestId, params: undefined, } | { "method": "account/read", id: RequestId, params: GetAccountParams, } | { "method": "newConversation", id: RequestId, params: NewConversationParams, } | { "method": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "listConversations", id: RequestId, params: ListConversationsParams, } | { "method": "resumeConversation", id: RequestId, params: ResumeConversationParams, } | { "method": "forkConversation", id: RequestId, params: ForkConversationParams, } | { "method": "archiveConversation", id: RequestId, params: ArchiveConversationParams, } | { "method": "sendUserMessage", id: RequestId, params: SendUserMessageParams, } | { "method": "sendUserTurn", id: RequestId, params: SendUserTurnParams, } | { "method": "interruptConversation", id: RequestId, params: InterruptConversationParams, } | { "method": "addConversationListener", id: RequestId, params: AddConversationListenerParams, } | { "method": "removeConversationListener", id: RequestId, params: RemoveConversationListenerParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "loginApiKey", id: RequestId, params: LoginApiKeyParams, } | { "method": "loginChatGpt", id: RequestId, params: undefined, } | { "method": "cancelLoginChatGpt", id: RequestId, params: CancelLoginChatGptParams, } | { "method": "logoutChatGpt", id: RequestId, params: undefined, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "getUserSavedConfig", id: RequestId, params: undefined, } | { "method": "setDefaultModel", id: RequestId, params: SetDefaultModelParams, } | { "method": "getUserAgent", id: RequestId, params: undefined, } | { "method": "userInfo", id: RequestId, params: undefined, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, } | { "method": "execOneOffCommand", id: RequestId, params: ExecOneOffCommandParams, };
@@ -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 AppDisabledReason = "unknown" | "user";
@@ -0,0 +1,6 @@
// 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 { AppDisabledReason } from "./AppDisabledReason";
export type AppsConfig = { [key in string]?: { enabled: boolean, disabled_reason: AppDisabledReason | null, } };
@@ -8,10 +8,11 @@ import type { Verbosity } from "../Verbosity";
import type { WebSearchMode } from "../WebSearchMode";
import type { JsonValue } from "../serde_json/JsonValue";
import type { AnalyticsConfig } from "./AnalyticsConfig";
import type { AppsConfig } from "./AppsConfig";
import type { AskForApproval } from "./AskForApproval";
import type { ProfileV2 } from "./ProfileV2";
import type { SandboxMode } from "./SandboxMode";
import type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite";
import type { ToolsV2 } from "./ToolsV2";
export type Config = { model: string | null, review_model: string | null, model_context_window: bigint | null, model_auto_compact_token_limit: bigint | null, model_provider: string | null, approval_policy: AskForApproval | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, analytics: AnalyticsConfig | null, } & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
export type Config = { model: string | null, review_model: string | null, model_context_window: bigint | null, model_auto_compact_token_limit: bigint | null, model_provider: string | null, approval_policy: AskForApproval | null, sandbox_mode: SandboxMode | null, sandbox_workspace_write: SandboxWorkspaceWrite | null, forced_chatgpt_workspace_id: string | null, forced_login_method: ForcedLoginMethod | null, web_search: WebSearchMode | null, tools: ToolsV2 | null, profile: string | null, profiles: { [key in string]?: ProfileV2 }, instructions: string | null, developer_instructions: string | null, compact_prompt: string | null, model_reasoning_effort: ReasoningEffort | null, model_reasoning_summary: ReasoningSummary | null, model_verbosity: Verbosity | null, analytics: AnalyticsConfig | null, apps: AppsConfig | null, } & ({ [key in string]?: number | string | boolean | Array<JsonValue> | { [key in string]?: JsonValue } | null });
@@ -0,0 +1,37 @@
// 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 { ExperimentalFeatureStage } from "./ExperimentalFeatureStage";
export type ExperimentalFeature = {
/**
* Stable key used in config.toml and CLI flag toggles.
*/
name: string,
/**
* Lifecycle stage of this feature flag.
*/
stage: ExperimentalFeatureStage,
/**
* User-facing display name shown in the experimental features UI.
* Null when this feature is not in beta.
*/
displayName: string | null,
/**
* Short summary describing what the feature does.
* Null when this feature is not in beta.
*/
description: string | null,
/**
* Announcement copy shown to users when the feature is introduced.
* Null when this feature is not in beta.
*/
announcement: string | null,
/**
* Whether this feature is currently enabled in the loaded config.
*/
enabled: boolean,
/**
* Whether this feature is enabled by default.
*/
defaultEnabled: boolean, };
@@ -0,0 +1,13 @@
// 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 ExperimentalFeatureListParams = {
/**
* Opaque pagination cursor returned by a previous call.
*/
cursor?: string | null,
/**
* Optional page size; defaults to a reasonable server-side value.
*/
limit?: number | null, };
@@ -0,0 +1,11 @@
// 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 { ExperimentalFeature } from "./ExperimentalFeature";
export type ExperimentalFeatureListResponse = { data: Array<ExperimentalFeature>,
/**
* Opaque cursor to pass to the next call to continue after the last item.
* If None, there are no more items to return.
*/
nextCursor: string | null, };
@@ -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 ExperimentalFeatureStage = "beta" | "underDevelopment" | "stable" | "deprecated" | "removed";
@@ -0,0 +1,11 @@
// 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 { UserInput } from "./UserInput";
export type TurnSteerParams = { threadId: string, input: Array<UserInput>,
/**
* Required active turn id precondition. The request fails when it does not
* match the currently active turn.
*/
expectedTurnId: string, };
@@ -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 TurnSteerResponse = { turnId: string, };
@@ -6,7 +6,9 @@ export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUp
export type { AccountUpdatedNotification } from "./AccountUpdatedNotification";
export type { AgentMessageDeltaNotification } from "./AgentMessageDeltaNotification";
export type { AnalyticsConfig } from "./AnalyticsConfig";
export type { AppDisabledReason } from "./AppDisabledReason";
export type { AppInfo } from "./AppInfo";
export type { AppsConfig } from "./AppsConfig";
export type { AppsListParams } from "./AppsListParams";
export type { AppsListResponse } from "./AppsListResponse";
export type { AskForApproval } from "./AskForApproval";
@@ -52,6 +54,10 @@ export type { DynamicToolCallResponse } from "./DynamicToolCallResponse";
export type { DynamicToolSpec } from "./DynamicToolSpec";
export type { ErrorNotification } from "./ErrorNotification";
export type { ExecPolicyAmendment } from "./ExecPolicyAmendment";
export type { ExperimentalFeature } from "./ExperimentalFeature";
export type { ExperimentalFeatureListParams } from "./ExperimentalFeatureListParams";
export type { ExperimentalFeatureListResponse } from "./ExperimentalFeatureListResponse";
export type { ExperimentalFeatureStage } from "./ExperimentalFeatureStage";
export type { FeedbackUploadParams } from "./FeedbackUploadParams";
export type { FeedbackUploadResponse } from "./FeedbackUploadResponse";
export type { FileChangeApprovalDecision } from "./FileChangeApprovalDecision";
@@ -176,6 +182,8 @@ export type { TurnStartParams } from "./TurnStartParams";
export type { TurnStartResponse } from "./TurnStartResponse";
export type { TurnStartedNotification } from "./TurnStartedNotification";
export type { TurnStatus } from "./TurnStatus";
export type { TurnSteerParams } from "./TurnSteerParams";
export type { TurnSteerResponse } from "./TurnSteerResponse";
export type { UserInput } from "./UserInput";
export type { WebSearchAction } from "./WebSearchAction";
export type { WindowsWorldWritableWarningNotification } from "./WindowsWorldWritableWarningNotification";
@@ -252,6 +252,10 @@ client_request_definitions! {
params: v2::TurnStartParams,
response: v2::TurnStartResponse,
},
TurnSteer => "turn/steer" {
params: v2::TurnSteerParams,
response: v2::TurnSteerResponse,
},
TurnInterrupt => "turn/interrupt" {
params: v2::TurnInterruptParams,
response: v2::TurnInterruptResponse,
@@ -265,6 +269,10 @@ client_request_definitions! {
params: v2::ModelListParams,
response: v2::ModelListResponse,
},
ExperimentalFeatureList => "experimentalFeature/list" {
params: v2::ExperimentalFeatureListParams,
response: v2::ExperimentalFeatureListResponse,
},
#[experimental("collaborationMode/list")]
/// Lists collaboration mode presets.
CollaborationModeList => "collaborationMode/list" {
@@ -1087,6 +1095,26 @@ mod tests {
Ok(())
}
#[test]
fn serialize_list_experimental_features() -> Result<()> {
let request = ClientRequest::ExperimentalFeatureList {
request_id: RequestId::Integer(8),
params: v2::ExperimentalFeatureListParams::default(),
};
assert_eq!(
json!({
"method": "experimentalFeature/list",
"id": 8,
"params": {
"cursor": null,
"limit": null
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn mock_experimental_method_is_marked_experimental() {
let request = ClientRequest::MockExperimentalMethod {
@@ -374,6 +374,36 @@ pub struct AnalyticsConfig {
pub additional: HashMap<String, JsonValue>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub enum AppDisabledReason {
Unknown,
User,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub struct AppConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
pub disabled_reason: Option<AppDisabledReason>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
pub struct AppsConfig {
#[serde(default, flatten)]
#[schemars(with = "HashMap<String, AppConfig>")]
pub apps: HashMap<String, AppConfig>,
}
const fn default_enabled() -> bool {
true
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(export_to = "v2/")]
@@ -400,6 +430,8 @@ pub struct Config {
pub model_reasoning_summary: Option<ReasoningSummary>,
pub model_verbosity: Option<Verbosity>,
pub analytics: Option<AnalyticsConfig>,
#[serde(default)]
pub apps: Option<AppsConfig>,
#[serde(default, flatten)]
pub additional: HashMap<String, JsonValue>,
}
@@ -1043,6 +1075,67 @@ pub struct CollaborationModeListResponse {
pub data: Vec<CollaborationModeMask>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExperimentalFeatureListParams {
/// Opaque pagination cursor returned by a previous call.
#[ts(optional = nullable)]
pub cursor: Option<String>,
/// Optional page size; defaults to a reasonable server-side value.
#[ts(optional = nullable)]
pub limit: Option<u32>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum ExperimentalFeatureStage {
/// Feature is available for user testing and feedback.
Beta,
/// Feature is still being built and not ready for broad use.
UnderDevelopment,
/// Feature is production-ready.
Stable,
/// Feature is deprecated and should be avoided.
Deprecated,
/// Feature flag is retained only for backwards compatibility.
Removed,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExperimentalFeature {
/// Stable key used in config.toml and CLI flag toggles.
pub name: String,
/// Lifecycle stage of this feature flag.
pub stage: ExperimentalFeatureStage,
/// User-facing display name shown in the experimental features UI.
/// Null when this feature is not in beta.
pub display_name: Option<String>,
/// Short summary describing what the feature does.
/// Null when this feature is not in beta.
pub description: Option<String>,
/// Announcement copy shown to users when the feature is introduced.
/// Null when this feature is not in beta.
pub announcement: Option<String>,
/// Whether this feature is currently enabled in the loaded config.
pub enabled: bool,
/// Whether this feature is enabled by default.
pub default_enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ExperimentalFeatureListResponse {
pub data: Vec<ExperimentalFeature>,
/// Opaque cursor to pass to the next call to continue after the last item.
/// If None, there are no more items to return.
pub next_cursor: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
@@ -2031,6 +2124,24 @@ pub struct TurnStartResponse {
pub turn: Turn,
}
#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct TurnSteerParams {
pub thread_id: String,
pub input: Vec<UserInput>,
/// Required active turn id precondition. The request fails when it does not
/// match the currently active turn.
pub expected_turn_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct TurnSteerResponse {
pub turn_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
+5
View File
@@ -33,6 +33,8 @@ codex-rmcp-client = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-json-to-toml = { workspace = true }
chrono = { workspace = true }
clap = { workspace = true, features = ["derive"] }
futures = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tempfile = { workspace = true }
@@ -45,6 +47,7 @@ tokio = { workspace = true, features = [
"rt-multi-thread",
"signal",
] }
tokio-tungstenite = { workspace = true }
tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
uuid = { workspace = true, features = ["serde", "v7"] }
@@ -59,6 +62,7 @@ axum = { workspace = true, default-features = false, features = [
base64 = { workspace = true }
codex-execpolicy = { workspace = true }
core_test_support = { workspace = true }
codex-utils-cargo-bin = { workspace = true }
os_info = { workspace = true }
pretty_assertions = { workspace = true }
rmcp = { workspace = true, default-features = false, features = [
@@ -66,5 +70,6 @@ rmcp = { workspace = true, default-features = false, features = [
"transport-streamable-http-server",
] }
serial_test = { workspace = true }
tokio-tungstenite = { workspace = true }
wiremock = { workspace = true }
shlex = { workspace = true }
+30 -4
View File
@@ -19,7 +19,14 @@
## Protocol
Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports bidirectional communication, streaming JSONL over stdio. The protocol is JSON-RPC 2.0, though the `"jsonrpc":"2.0"` header is omitted.
Similar to [MCP](https://modelcontextprotocol.io/), `codex app-server` supports bidirectional communication using JSON-RPC 2.0 messages (with the `"jsonrpc":"2.0"` header omitted on the wire).
Supported transports:
- stdio (`--listen stdio://`, default): newline-delimited JSON (JSONL)
- websocket (`--listen ws://IP:PORT`): one JSON-RPC message per websocket text frame (**experimental / unsupported**)
Websocket transport is currently experimental and unsupported. Do not rely on it for production workloads.
## Message Schema
@@ -42,7 +49,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat
## Lifecycle Overview
- Initialize once: Immediately after launching the codex app-server process, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request before this handshake gets rejected.
- Initialize once per connection: Immediately after opening a transport connection, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request on that connection before this handshake gets rejected.
- Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and you’ll also get a `thread/started` notification. If you’re continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history.
- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy, etc. This immediately returns the new turn object and triggers a `turn/started` notification.
- Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes).
@@ -50,7 +57,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat
## Initialization
Clients must send a single `initialize` request before invoking any other method, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls receive an `"Already initialized"` error.
Clients must send a single `initialize` request per transport connection before invoking any other method on that connection, then acknowledge with an `initialized` notification. The server returns the user agent string it will present to upstream services; subsequent requests issued before initialization receive a `"Not initialized"` error, and repeated `initialize` calls on the same connection receive an `"Already initialized"` error.
Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter.
@@ -88,10 +95,12 @@ Example (from OpenAI's official VSCode extension):
- `thread/compact/start` — trigger conversation history compaction for a thread; returns `{}` immediately while progress streams through standard turn/item notifications.
- `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success.
- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications.
- `turn/steer` — add user input to an already in-flight turn without starting a new turn; returns the active `turnId` that accepted the input.
- `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`.
- `review/start` — kick off Codex’s automated reviewer for a thread; responds like `turn/start` and emits `item/started`/`item/completed` notifications with `enteredReviewMode` and `exitedReviewMode` items, plus a final assistant `agentMessage` containing the review.
- `command/exec` — run a single command under the server sandbox without starting a thread/turn (handy for utilities and validation).
- `model/list` — list available models (with reasoning effort options and optional `upgrade` model ids).
- `experimentalFeature/list` — list feature flags with stage metadata (`beta`, `underDevelopment`, `stable`, etc.), enabled/default-enabled state, and cursor pagination. For non-beta flags, `displayName`/`description`/`announcement` are `null`.
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination).
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
- `skills/remote/read` — list public remote skills (**under development; do not call from production clients yet**).
@@ -355,6 +364,22 @@ You can cancel a running Turn with `turn/interrupt`.
The server requests cancellations for running subprocesses, then emits a `turn/completed` event with `status: "interrupted"`. Rely on the `turn/completed` to know when Codex-side cleanup is done.
### Example: Steer an active turn
Use `turn/steer` to append additional user input to the currently active turn. This does not emit
`turn/started` and does not accept turn context overrides.
```json
{ "method": "turn/steer", "id": 32, "params": {
"threadId": "thr_123",
"input": [ { "type": "text", "text": "Actually focus on failing tests first." } ],
"expectedTurnId": "turn_456"
} }
{ "id": 32, "result": { "turnId": "turn_456" } }
```
`expectedTurnId` is required. If there is no active turn (or `expectedTurnId` does not match the active turn), the request fails with an `invalid request` error.
### Example: Request a code review
Use `review/start` to run Codex’s reviewer on the currently checked-out project. The request takes the thread id plus a `target` describing what should be reviewed:
@@ -888,6 +913,7 @@ Examples of descriptor strings:
- `thread/start.mockExperimentalField` (field-level gate)
### For maintainers: Adding experimental fields and methods
Use this checklist when introducing a field/method that should only be available when the client opts into experimental APIs.
At runtime, clients must send `initialize` with `capabilities.experimentalApi = true` to use experimental methods or fields.
@@ -908,7 +934,7 @@ At runtime, clients must send `initialize` with `capabilities.experimentalApi =
# Include experimental API fields/methods in fixtures.
just write-app-server-schema --experimental
```
5. Verify the protocol crate:
```bash
@@ -1093,7 +1093,7 @@ pub(crate) async fn apply_bespoke_event_handling(
),
data: None,
};
outgoing.send_error(request_id, error).await;
outgoing.send_error(request_id.clone(), error).await;
return;
}
}
@@ -1107,7 +1107,7 @@ pub(crate) async fn apply_bespoke_event_handling(
),
data: None,
};
outgoing.send_error(request_id, error).await;
outgoing.send_error(request_id.clone(), error).await;
return;
}
};
@@ -1831,6 +1831,7 @@ async fn construct_mcp_tool_call_end_notification(
mod tests {
use super::*;
use crate::CHANNEL_CAPACITY;
use crate::outgoing_message::OutgoingEnvelope;
use crate::outgoing_message::OutgoingMessage;
use crate::outgoing_message::OutgoingMessageSender;
use anyhow::Result;
@@ -1858,6 +1859,21 @@ mod tests {
Arc::new(Mutex::new(HashMap::new()))
}
async fn recv_broadcast_message(
rx: &mut mpsc::Receiver<OutgoingEnvelope>,
) -> Result<OutgoingMessage> {
let envelope = rx
.recv()
.await
.ok_or_else(|| anyhow!("should send one message"))?;
match envelope {
OutgoingEnvelope::Broadcast { message } => Ok(message),
OutgoingEnvelope::ToConnection { connection_id, .. } => {
bail!("unexpected targeted message for connection {connection_id:?}")
}
}
}
#[test]
fn file_change_accept_for_session_maps_to_approved_for_session() {
let (decision, completion_status) =
@@ -1910,10 +1926,7 @@ mod tests {
)
.await;
let msg = rx
.recv()
.await
.ok_or_else(|| anyhow!("should send one notification"))?;
let msg = recv_broadcast_message(&mut rx).await?;
match msg {
OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => {
assert_eq!(n.turn.id, event_turn_id);
@@ -1952,10 +1965,7 @@ mod tests {
)
.await;
let msg = rx
.recv()
.await
.ok_or_else(|| anyhow!("should send one notification"))?;
let msg = recv_broadcast_message(&mut rx).await?;
match msg {
OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => {
assert_eq!(n.turn.id, event_turn_id);
@@ -1994,10 +2004,7 @@ mod tests {
)
.await;
let msg = rx
.recv()
.await
.ok_or_else(|| anyhow!("should send one notification"))?;
let msg = recv_broadcast_message(&mut rx).await?;
match msg {
OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => {
assert_eq!(n.turn.id, event_turn_id);
@@ -2046,10 +2053,7 @@ mod tests {
)
.await;
let msg = rx
.recv()
.await
.ok_or_else(|| anyhow!("should send one notification"))?;
let msg = recv_broadcast_message(&mut rx).await?;
match msg {
OutgoingMessage::AppServerNotification(ServerNotification::TurnPlanUpdated(n)) => {
assert_eq!(n.thread_id, conversation_id.to_string());
@@ -2117,10 +2121,7 @@ mod tests {
)
.await;
let first = rx
.recv()
.await
.ok_or_else(|| anyhow!("expected usage notification"))?;
let first = recv_broadcast_message(&mut rx).await?;
match first {
OutgoingMessage::AppServerNotification(
ServerNotification::ThreadTokenUsageUpdated(payload),
@@ -2136,10 +2137,7 @@ mod tests {
other => bail!("unexpected notification: {other:?}"),
}
let second = rx
.recv()
.await
.ok_or_else(|| anyhow!("expected rate limit notification"))?;
let second = recv_broadcast_message(&mut rx).await?;
match second {
OutgoingMessage::AppServerNotification(
ServerNotification::AccountRateLimitsUpdated(payload),
@@ -2276,10 +2274,7 @@ mod tests {
.await;
// Verify: A turn 1
let msg = rx
.recv()
.await
.ok_or_else(|| anyhow!("should send first notification"))?;
let msg = recv_broadcast_message(&mut rx).await?;
match msg {
OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => {
assert_eq!(n.turn.id, a_turn1);
@@ -2297,10 +2292,7 @@ mod tests {
}
// Verify: B turn 1
let msg = rx
.recv()
.await
.ok_or_else(|| anyhow!("should send second notification"))?;
let msg = recv_broadcast_message(&mut rx).await?;
match msg {
OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => {
assert_eq!(n.turn.id, b_turn1);
@@ -2318,10 +2310,7 @@ mod tests {
}
// Verify: A turn 2
let msg = rx
.recv()
.await
.ok_or_else(|| anyhow!("should send third notification"))?;
let msg = recv_broadcast_message(&mut rx).await?;
match msg {
OutgoingMessage::AppServerNotification(ServerNotification::TurnCompleted(n)) => {
assert_eq!(n.turn.id, a_turn2);
@@ -2487,10 +2476,7 @@ mod tests {
)
.await;
let msg = rx
.recv()
.await
.ok_or_else(|| anyhow!("should send one notification"))?;
let msg = recv_broadcast_message(&mut rx).await?;
match msg {
OutgoingMessage::AppServerNotification(ServerNotification::TurnDiffUpdated(
notification,
File diff suppressed because it is too large Load Diff
+35 -12
View File
@@ -19,11 +19,16 @@ use codex_core::config_loader::ResidencyRequirement as CoreResidencyRequirement;
use codex_core::config_loader::SandboxModeRequirement as CoreSandboxModeRequirement;
use serde_json::json;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::RwLock;
use toml::Value as TomlValue;
#[derive(Clone)]
pub(crate) struct ConfigApi {
service: ConfigService,
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
}
impl ConfigApi {
@@ -31,30 +36,42 @@ impl ConfigApi {
codex_home: PathBuf,
cli_overrides: Vec<(String, TomlValue)>,
loader_overrides: LoaderOverrides,
cloud_requirements: CloudRequirementsLoader,
cloud_requirements: Arc<RwLock<CloudRequirementsLoader>>,
) -> Self {
Self {
service: ConfigService::new(
codex_home,
cli_overrides,
loader_overrides,
cloud_requirements,
),
codex_home,
cli_overrides,
loader_overrides,
cloud_requirements,
}
}
fn config_service(&self) -> ConfigService {
let cloud_requirements = self
.cloud_requirements
.read()
.map(|guard| guard.clone())
.unwrap_or_default();
ConfigService::new(
self.codex_home.clone(),
self.cli_overrides.clone(),
self.loader_overrides.clone(),
cloud_requirements,
)
}
pub(crate) async fn read(
&self,
params: ConfigReadParams,
) -> Result<ConfigReadResponse, JSONRPCErrorError> {
self.service.read(params).await.map_err(map_error)
self.config_service().read(params).await.map_err(map_error)
}
pub(crate) async fn config_requirements_read(
&self,
) -> Result<ConfigRequirementsReadResponse, JSONRPCErrorError> {
let requirements = self
.service
.config_service()
.read_requirements()
.await
.map_err(map_error)?
@@ -67,14 +84,20 @@ impl ConfigApi {
&self,
params: ConfigValueWriteParams,
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
self.service.write_value(params).await.map_err(map_error)
self.config_service()
.write_value(params)
.await
.map_err(map_error)
}
pub(crate) async fn batch_write(
&self,
params: ConfigBatchWriteParams,
) -> Result<ConfigWriteResponse, JSONRPCErrorError> {
self.service.batch_write(params).await.map_err(map_error)
self.config_service()
.batch_write(params)
.await
.map_err(map_error)
}
}
+104 -72
View File
@@ -8,14 +8,24 @@ use codex_core::config::ConfigBuilder;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::ConfigLayerStackOrdering;
use codex_core::config_loader::LoaderOverrides;
use std::collections::HashMap;
use std::io::ErrorKind;
use std::io::Result as IoResult;
use std::path::PathBuf;
use std::sync::Arc;
use crate::message_processor::MessageProcessor;
use crate::message_processor::MessageProcessorArgs;
use crate::outgoing_message::OutgoingMessage;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingEnvelope;
use crate::outgoing_message::OutgoingMessageSender;
use crate::transport::CHANNEL_CAPACITY;
use crate::transport::ConnectionState;
use crate::transport::TransportEvent;
use crate::transport::has_initialized_connections;
use crate::transport::route_outgoing_envelope;
use crate::transport::start_stdio_connection;
use crate::transport::start_websocket_acceptor;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ConfigWarningNotification;
use codex_app_server_protocol::JSONRPCMessage;
@@ -26,13 +36,9 @@ use codex_core::check_execpolicy_for_warnings;
use codex_core::config_loader::ConfigLoadError;
use codex_core::config_loader::TextRange as CoreTextRange;
use codex_feedback::CodexFeedback;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::io::{self};
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use toml::Value as TomlValue;
use tracing::debug;
use tracing::error;
use tracing::info;
use tracing::warn;
@@ -51,11 +57,9 @@ mod fuzzy_file_search;
mod message_processor;
mod models;
mod outgoing_message;
mod transport;
/// Size of the bounded channels used to communicate between tasks. The value
/// is a balance between throughput and memory usage – 128 messages should be
/// plenty for an interactive CLI.
const CHANNEL_CAPACITY: usize = 128;
pub use crate::transport::AppServerTransport;
fn config_warning_from_error(
summary: impl Into<String>,
@@ -173,32 +177,39 @@ pub async fn run_main(
loader_overrides: LoaderOverrides,
default_analytics_enabled: bool,
) -> IoResult<()> {
// Set up channels.
let (incoming_tx, mut incoming_rx) = mpsc::channel::<JSONRPCMessage>(CHANNEL_CAPACITY);
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingMessage>(CHANNEL_CAPACITY);
run_main_with_transport(
codex_linux_sandbox_exe,
cli_config_overrides,
loader_overrides,
default_analytics_enabled,
AppServerTransport::Stdio,
)
.await
}
// Task: read from stdin, push to `incoming_tx`.
let stdin_reader_handle = tokio::spawn({
async move {
let stdin = io::stdin();
let reader = BufReader::new(stdin);
let mut lines = reader.lines();
pub async fn run_main_with_transport(
codex_linux_sandbox_exe: Option<PathBuf>,
cli_config_overrides: CliConfigOverrides,
loader_overrides: LoaderOverrides,
default_analytics_enabled: bool,
transport: AppServerTransport,
) -> IoResult<()> {
let (transport_event_tx, mut transport_event_rx) =
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingEnvelope>(CHANNEL_CAPACITY);
while let Some(line) = lines.next_line().await.unwrap_or_default() {
match serde_json::from_str::<JSONRPCMessage>(&line) {
Ok(msg) => {
if incoming_tx.send(msg).await.is_err() {
// Receiver gone – nothing left to do.
break;
}
}
Err(e) => error!("Failed to deserialize JSONRPCMessage: {e}"),
}
}
debug!("stdin reader finished (EOF)");
let mut stdio_handles = Vec::<JoinHandle<()>>::new();
let mut websocket_accept_handle = None;
match transport {
AppServerTransport::Stdio => {
start_stdio_connection(transport_event_tx.clone(), &mut stdio_handles).await?;
}
});
AppServerTransport::WebSocket { bind_address } => {
websocket_accept_handle =
Some(start_websocket_acceptor(bind_address, transport_event_tx.clone()).await?);
}
}
let shutdown_when_no_connections = matches!(transport, AppServerTransport::Stdio);
// Parse CLI overrides once and derive the base Config eagerly so later
// components do not need to work with raw TOML values.
@@ -267,9 +278,7 @@ pub async fn run_main(
}
};
if let Ok(Some(err)) =
check_execpolicy_for_warnings(&config.features, &config.config_layer_stack).await
{
if let Ok(Some(err)) = check_execpolicy_for_warnings(&config.config_layer_stack).await {
let (path, range) = exec_policy_warning_location(&err);
let message = ConfigWarningNotification {
summary: "Error parsing rules; custom rules not applied.".to_string(),
@@ -327,15 +336,14 @@ pub async fn run_main(
}
}
// Task: process incoming messages.
let processor_handle = tokio::spawn({
let outgoing_message_sender = OutgoingMessageSender::new(outgoing_tx);
let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(outgoing_tx));
let cli_overrides: Vec<(String, TomlValue)> = cli_kv_overrides.clone();
let loader_overrides = loader_overrides_for_config_api;
let mut processor = MessageProcessor::new(MessageProcessorArgs {
outgoing: outgoing_message_sender,
codex_linux_sandbox_exe,
config: std::sync::Arc::new(config),
config: Arc::new(config),
cli_overrides,
loader_overrides,
cloud_requirements: cloud_requirements.clone(),
@@ -343,25 +351,65 @@ pub async fn run_main(
config_warnings,
});
let mut thread_created_rx = processor.thread_created_receiver();
let mut connections = HashMap::<ConnectionId, ConnectionState>::new();
async move {
let mut listen_for_threads = true;
loop {
tokio::select! {
msg = incoming_rx.recv() => {
let Some(msg) = msg else {
event = transport_event_rx.recv() => {
let Some(event) = event else {
break;
};
match msg {
JSONRPCMessage::Request(r) => processor.process_request(r).await,
JSONRPCMessage::Response(r) => processor.process_response(r).await,
JSONRPCMessage::Notification(n) => processor.process_notification(n).await,
JSONRPCMessage::Error(e) => processor.process_error(e).await,
match event {
TransportEvent::ConnectionOpened { connection_id, writer } => {
connections.insert(connection_id, ConnectionState::new(writer));
}
TransportEvent::ConnectionClosed { connection_id } => {
connections.remove(&connection_id);
if shutdown_when_no_connections && connections.is_empty() {
break;
}
}
TransportEvent::IncomingMessage { connection_id, message } => {
match message {
JSONRPCMessage::Request(request) => {
let Some(connection_state) = connections.get_mut(&connection_id) else {
warn!("dropping request from unknown connection: {:?}", connection_id);
continue;
};
processor
.process_request(
connection_id,
request,
&mut connection_state.session,
)
.await;
}
JSONRPCMessage::Response(response) => {
processor.process_response(response).await;
}
JSONRPCMessage::Notification(notification) => {
processor.process_notification(notification).await;
}
JSONRPCMessage::Error(err) => {
processor.process_error(err).await;
}
}
}
}
}
envelope = outgoing_rx.recv() => {
let Some(envelope) = envelope else {
break;
};
route_outgoing_envelope(&mut connections, envelope).await;
}
created = thread_created_rx.recv(), if listen_for_threads => {
match created {
Ok(thread_id) => {
processor.try_attach_thread_listener(thread_id).await;
if has_initialized_connections(&connections) {
processor.try_attach_thread_listener(thread_id).await;
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
// TODO(jif) handle lag.
@@ -382,33 +430,17 @@ pub async fn run_main(
}
});
// Task: write outgoing messages to stdout.
let stdout_writer_handle = tokio::spawn(async move {
let mut stdout = io::stdout();
while let Some(outgoing_message) = outgoing_rx.recv().await {
let Ok(value) = serde_json::to_value(outgoing_message) else {
error!("Failed to convert OutgoingMessage to JSON value");
continue;
};
match serde_json::to_string(&value) {
Ok(mut json) => {
json.push('\n');
if let Err(e) = stdout.write_all(json.as_bytes()).await {
error!("Failed to write to stdout: {e}");
break;
}
}
Err(e) => error!("Failed to serialize JSONRPCMessage: {e}"),
}
}
drop(transport_event_tx);
info!("stdout writer exited (channel closed)");
});
let _ = processor_handle.await;
// Wait for all tasks to finish. The typical exit path is the stdin reader
// hitting EOF which, once it drops `incoming_tx`, propagates shutdown to
// the processor and then to the stdout task.
let _ = tokio::join!(stdin_reader_handle, processor_handle, stdout_writer_handle);
if let Some(handle) = websocket_accept_handle {
handle.abort();
}
for handle in stdio_handles {
let _ = handle.await;
}
Ok(())
}
+19 -2
View File
@@ -1,4 +1,6 @@
use codex_app_server::run_main;
use clap::Parser;
use codex_app_server::AppServerTransport;
use codex_app_server::run_main_with_transport;
use codex_arg0::arg0_dispatch_or_else;
use codex_common::CliConfigOverrides;
use codex_core::config_loader::LoaderOverrides;
@@ -8,19 +10,34 @@ use std::path::PathBuf;
// managed config file without writing to /etc.
const MANAGED_CONFIG_PATH_ENV_VAR: &str = "CODEX_APP_SERVER_MANAGED_CONFIG_PATH";
#[derive(Debug, Parser)]
struct AppServerArgs {
/// Transport endpoint URL. Supported values: `stdio://` (default),
/// `ws://IP:PORT`.
#[arg(
long = "listen",
value_name = "URL",
default_value = AppServerTransport::DEFAULT_LISTEN_URL
)]
listen: AppServerTransport,
}
fn main() -> anyhow::Result<()> {
arg0_dispatch_or_else(|codex_linux_sandbox_exe| async move {
let args = AppServerArgs::parse();
let managed_config_path = managed_config_path_from_debug_env();
let loader_overrides = LoaderOverrides {
managed_config_path,
..Default::default()
};
let transport = args.listen;
run_main(
run_main_with_transport(
codex_linux_sandbox_exe,
CliConfigOverrides::default(),
loader_overrides,
false,
transport,
)
.await?;
Ok(())
+81 -42
View File
@@ -1,12 +1,13 @@
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::Ordering;
use std::sync::RwLock;
use crate::codex_message_processor::CodexMessageProcessor;
use crate::codex_message_processor::CodexMessageProcessorArgs;
use crate::config_api::ConfigApi;
use crate::error_code::INVALID_REQUEST_ERROR_CODE;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
use crate::outgoing_message::OutgoingMessageSender;
use async_trait::async_trait;
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
@@ -25,7 +26,6 @@ use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequestPayload;
use codex_app_server_protocol::experimental_required_message;
@@ -110,13 +110,17 @@ pub(crate) struct MessageProcessor {
codex_message_processor: CodexMessageProcessor,
config_api: ConfigApi,
config: Arc<Config>,
initialized: bool,
experimental_api_enabled: Arc<AtomicBool>,
config_warnings: Vec<ConfigWarningNotification>,
config_warnings: Arc<Vec<ConfigWarningNotification>>,
}
#[derive(Debug, Default)]
pub(crate) struct ConnectionSessionState {
pub(crate) initialized: bool,
experimental_api_enabled: bool,
}
pub(crate) struct MessageProcessorArgs {
pub(crate) outgoing: OutgoingMessageSender,
pub(crate) outgoing: Arc<OutgoingMessageSender>,
pub(crate) codex_linux_sandbox_exe: Option<PathBuf>,
pub(crate) config: Arc<Config>,
pub(crate) cli_overrides: Vec<(String, TomlValue)>,
@@ -140,8 +144,6 @@ impl MessageProcessor {
feedback,
config_warnings,
} = args;
let outgoing = Arc::new(outgoing);
let experimental_api_enabled = Arc::new(AtomicBool::new(false));
let auth_manager = AuthManager::shared(
config.codex_home.clone(),
false,
@@ -156,6 +158,7 @@ impl MessageProcessor {
auth_manager.clone(),
SessionSource::VSCode,
));
let cloud_requirements = Arc::new(RwLock::new(cloud_requirements));
let codex_message_processor = CodexMessageProcessor::new(CodexMessageProcessorArgs {
auth_manager,
thread_manager,
@@ -178,14 +181,20 @@ impl MessageProcessor {
codex_message_processor,
config_api,
config,
initialized: false,
experimental_api_enabled,
config_warnings,
config_warnings: Arc::new(config_warnings),
}
}
pub(crate) async fn process_request(&mut self, request: JSONRPCRequest) {
let request_id = request.id.clone();
pub(crate) async fn process_request(
&mut self,
connection_id: ConnectionId,
request: JSONRPCRequest,
session: &mut ConnectionSessionState,
) {
let request_id = ConnectionRequestId {
connection_id,
request_id: request.id.clone(),
};
let request_json = match serde_json::to_value(&request) {
Ok(request_json) => request_json,
Err(err) => {
@@ -216,7 +225,11 @@ impl MessageProcessor {
// Handle Initialize internally so CodexMessageProcessor does not have to concern
// itself with the `initialized` bool.
ClientRequest::Initialize { request_id, params } => {
if self.initialized {
let request_id = ConnectionRequestId {
connection_id,
request_id,
};
if session.initialized {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "Already initialized".to_string(),
@@ -225,12 +238,16 @@ impl MessageProcessor {
self.outgoing.send_error(request_id, error).await;
return;
} else {
let experimental_api_enabled = params
// TODO(maxj): Revisit capability scoping for `experimental_api_enabled`.
// Current behavior is per-connection. Reviewer feedback notes this can
// create odd cross-client behavior (for example dynamic tool calls on a
// shared thread when another connected client did not opt into
// experimental API). Proposed direction is instance-global first-write-wins
// with initialize-time mismatch rejection.
session.experimental_api_enabled = params
.capabilities
.as_ref()
.is_some_and(|cap| cap.experimental_api);
self.experimental_api_enabled
.store(experimental_api_enabled, Ordering::Relaxed);
let ClientInfo {
name,
title: _title,
@@ -246,7 +263,7 @@ impl MessageProcessor {
),
data: None,
};
self.outgoing.send_error(request_id, error).await;
self.outgoing.send_error(request_id.clone(), error).await;
return;
}
SetOriginatorError::AlreadyInitialized => {
@@ -267,22 +284,20 @@ impl MessageProcessor {
let response = InitializeResponse { user_agent };
self.outgoing.send_response(request_id, response).await;
self.initialized = true;
if !self.config_warnings.is_empty() {
for notification in self.config_warnings.drain(..) {
self.outgoing
.send_server_notification(ServerNotification::ConfigWarning(
notification,
))
.await;
}
session.initialized = true;
for notification in self.config_warnings.iter().cloned() {
self.outgoing
.send_server_notification(ServerNotification::ConfigWarning(
notification,
))
.await;
}
return;
}
}
_ => {
if !self.initialized {
if !session.initialized {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: "Not initialized".to_string(),
@@ -295,7 +310,7 @@ impl MessageProcessor {
}
if let Some(reason) = codex_request.experimental_reason()
&& !self.experimental_api_enabled.load(Ordering::Relaxed)
&& !session.experimental_api_enabled
{
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
@@ -308,22 +323,49 @@ impl MessageProcessor {
match codex_request {
ClientRequest::ConfigRead { request_id, params } => {
self.handle_config_read(request_id, params).await;
self.handle_config_read(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ConfigValueWrite { request_id, params } => {
self.handle_config_value_write(request_id, params).await;
self.handle_config_value_write(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ConfigBatchWrite { request_id, params } => {
self.handle_config_batch_write(request_id, params).await;
self.handle_config_batch_write(
ConnectionRequestId {
connection_id,
request_id,
},
params,
)
.await;
}
ClientRequest::ConfigRequirementsRead {
request_id,
params: _,
} => {
self.handle_config_requirements_read(request_id).await;
self.handle_config_requirements_read(ConnectionRequestId {
connection_id,
request_id,
})
.await;
}
other => {
self.codex_message_processor.process_request(other).await;
self.codex_message_processor
.process_request(connection_id, other)
.await;
}
}
}
@@ -339,9 +381,6 @@ impl MessageProcessor {
}
pub(crate) async fn try_attach_thread_listener(&mut self, thread_id: ThreadId) {
if !self.initialized {
return;
}
self.codex_message_processor
.try_attach_thread_listener(thread_id)
.await;
@@ -360,7 +399,7 @@ impl MessageProcessor {
self.outgoing.notify_client_error(err.id, err.error).await;
}
async fn handle_config_read(&self, request_id: RequestId, params: ConfigReadParams) {
async fn handle_config_read(&self, request_id: ConnectionRequestId, params: ConfigReadParams) {
match self.config_api.read(params).await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
@@ -369,7 +408,7 @@ impl MessageProcessor {
async fn handle_config_value_write(
&self,
request_id: RequestId,
request_id: ConnectionRequestId,
params: ConfigValueWriteParams,
) {
match self.config_api.write_value(params).await {
@@ -380,7 +419,7 @@ impl MessageProcessor {
async fn handle_config_batch_write(
&self,
request_id: RequestId,
request_id: ConnectionRequestId,
params: ConfigBatchWriteParams,
) {
match self.config_api.batch_write(params).await {
@@ -389,7 +428,7 @@ impl MessageProcessor {
}
}
async fn handle_config_requirements_read(&self, request_id: RequestId) {
async fn handle_config_requirements_read(&self, request_id: ConnectionRequestId) {
match self.config_api.config_requirements_read().await {
Ok(response) => self.outgoing.send_response(request_id, response).await,
Err(error) => self.outgoing.send_error(request_id, error).await,
+153 -15
View File
@@ -19,17 +19,39 @@ use crate::error_code::INTERNAL_ERROR_CODE;
#[cfg(test)]
use codex_protocol::account::PlanType;
/// Stable identifier for a transport connection.
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
pub(crate) struct ConnectionId(pub(crate) u64);
/// Stable identifier for a client request scoped to a transport connection.
#[derive(Clone, Debug, Eq, Hash, PartialEq)]
pub(crate) struct ConnectionRequestId {
pub(crate) connection_id: ConnectionId,
pub(crate) request_id: RequestId,
}
#[derive(Debug, Clone)]
pub(crate) enum OutgoingEnvelope {
ToConnection {
connection_id: ConnectionId,
message: OutgoingMessage,
},
Broadcast {
message: OutgoingMessage,
},
}
/// Sends messages to the client and manages request callbacks.
pub(crate) struct OutgoingMessageSender {
next_request_id: AtomicI64,
sender: mpsc::Sender<OutgoingMessage>,
next_server_request_id: AtomicI64,
sender: mpsc::Sender<OutgoingEnvelope>,
request_id_to_callback: Mutex<HashMap<RequestId, oneshot::Sender<Result>>>,
}
impl OutgoingMessageSender {
pub(crate) fn new(sender: mpsc::Sender<OutgoingMessage>) -> Self {
pub(crate) fn new(sender: mpsc::Sender<OutgoingEnvelope>) -> Self {
Self {
next_request_id: AtomicI64::new(0),
next_server_request_id: AtomicI64::new(0),
sender,
request_id_to_callback: Mutex::new(HashMap::new()),
}
@@ -47,7 +69,7 @@ impl OutgoingMessageSender {
&self,
request: ServerRequestPayload,
) -> (RequestId, oneshot::Receiver<Result>) {
let id = RequestId::Integer(self.next_request_id.fetch_add(1, Ordering::Relaxed));
let id = RequestId::Integer(self.next_server_request_id.fetch_add(1, Ordering::Relaxed));
let outgoing_message_id = id.clone();
let (tx_approve, rx_approve) = oneshot::channel();
{
@@ -57,7 +79,13 @@ impl OutgoingMessageSender {
let outgoing_message =
OutgoingMessage::Request(request.request_with_id(outgoing_message_id.clone()));
if let Err(err) = self.sender.send(outgoing_message).await {
if let Err(err) = self
.sender
.send(OutgoingEnvelope::Broadcast {
message: outgoing_message,
})
.await
{
warn!("failed to send request {outgoing_message_id:?} to client: {err:?}");
let mut request_id_to_callback = self.request_id_to_callback.lock().await;
request_id_to_callback.remove(&outgoing_message_id);
@@ -107,17 +135,31 @@ impl OutgoingMessageSender {
entry.is_some()
}
pub(crate) async fn send_response<T: Serialize>(&self, id: RequestId, response: T) {
pub(crate) async fn send_response<T: Serialize>(
&self,
request_id: ConnectionRequestId,
response: T,
) {
match serde_json::to_value(response) {
Ok(result) => {
let outgoing_message = OutgoingMessage::Response(OutgoingResponse { id, result });
if let Err(err) = self.sender.send(outgoing_message).await {
let outgoing_message = OutgoingMessage::Response(OutgoingResponse {
id: request_id.request_id,
result,
});
if let Err(err) = self
.sender
.send(OutgoingEnvelope::ToConnection {
connection_id: request_id.connection_id,
message: outgoing_message,
})
.await
{
warn!("failed to send response to client: {err:?}");
}
}
Err(err) => {
self.send_error(
id,
request_id,
JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: format!("failed to serialize response: {err}"),
@@ -132,7 +174,9 @@ impl OutgoingMessageSender {
pub(crate) async fn send_server_notification(&self, notification: ServerNotification) {
if let Err(err) = self
.sender
.send(OutgoingMessage::AppServerNotification(notification))
.send(OutgoingEnvelope::Broadcast {
message: OutgoingMessage::AppServerNotification(notification),
})
.await
{
warn!("failed to send server notification to client: {err:?}");
@@ -143,14 +187,34 @@ impl OutgoingMessageSender {
/// [`OutgoingMessage::Notification`] should be removed.
pub(crate) async fn send_notification(&self, notification: OutgoingNotification) {
let outgoing_message = OutgoingMessage::Notification(notification);
if let Err(err) = self.sender.send(outgoing_message).await {
if let Err(err) = self
.sender
.send(OutgoingEnvelope::Broadcast {
message: outgoing_message,
})
.await
{
warn!("failed to send notification to client: {err:?}");
}
}
pub(crate) async fn send_error(&self, id: RequestId, error: JSONRPCErrorError) {
let outgoing_message = OutgoingMessage::Error(OutgoingError { id, error });
if let Err(err) = self.sender.send(outgoing_message).await {
pub(crate) async fn send_error(
&self,
request_id: ConnectionRequestId,
error: JSONRPCErrorError,
) {
let outgoing_message = OutgoingMessage::Error(OutgoingError {
id: request_id.request_id,
error,
});
if let Err(err) = self
.sender
.send(OutgoingEnvelope::ToConnection {
connection_id: request_id.connection_id,
message: outgoing_message,
})
.await
{
warn!("failed to send error to client: {err:?}");
}
}
@@ -190,6 +254,8 @@ pub(crate) struct OutgoingError {
#[cfg(test)]
mod tests {
use std::time::Duration;
use codex_app_server_protocol::AccountLoginCompletedNotification;
use codex_app_server_protocol::AccountRateLimitsUpdatedNotification;
use codex_app_server_protocol::AccountUpdatedNotification;
@@ -200,6 +266,7 @@ mod tests {
use codex_app_server_protocol::RateLimitWindow;
use pretty_assertions::assert_eq;
use serde_json::json;
use tokio::time::timeout;
use uuid::Uuid;
use super::*;
@@ -336,4 +403,75 @@ mod tests {
"ensure the notification serializes correctly"
);
}
#[tokio::test]
async fn send_response_routes_to_target_connection() {
let (tx, mut rx) = mpsc::channel::<OutgoingEnvelope>(4);
let outgoing = OutgoingMessageSender::new(tx);
let request_id = ConnectionRequestId {
connection_id: ConnectionId(42),
request_id: RequestId::Integer(7),
};
outgoing
.send_response(request_id.clone(), json!({ "ok": true }))
.await;
let envelope = timeout(Duration::from_secs(1), rx.recv())
.await
.expect("should receive envelope before timeout")
.expect("channel should contain one message");
match envelope {
OutgoingEnvelope::ToConnection {
connection_id,
message,
} => {
assert_eq!(connection_id, ConnectionId(42));
let OutgoingMessage::Response(response) = message else {
panic!("expected response message");
};
assert_eq!(response.id, request_id.request_id);
assert_eq!(response.result, json!({ "ok": true }));
}
other => panic!("expected targeted response envelope, got: {other:?}"),
}
}
#[tokio::test]
async fn send_error_routes_to_target_connection() {
let (tx, mut rx) = mpsc::channel::<OutgoingEnvelope>(4);
let outgoing = OutgoingMessageSender::new(tx);
let request_id = ConnectionRequestId {
connection_id: ConnectionId(9),
request_id: RequestId::Integer(3),
};
let error = JSONRPCErrorError {
code: INTERNAL_ERROR_CODE,
message: "boom".to_string(),
data: None,
};
outgoing.send_error(request_id.clone(), error.clone()).await;
let envelope = timeout(Duration::from_secs(1), rx.recv())
.await
.expect("should receive envelope before timeout")
.expect("channel should contain one message");
match envelope {
OutgoingEnvelope::ToConnection {
connection_id,
message,
} => {
assert_eq!(connection_id, ConnectionId(9));
let OutgoingMessage::Error(outgoing_error) = message else {
panic!("expected error message");
};
assert_eq!(outgoing_error.id, RequestId::Integer(3));
assert_eq!(outgoing_error.error, error);
}
other => panic!("expected targeted error envelope, got: {other:?}"),
}
}
}
+424
View File
@@ -0,0 +1,424 @@
use crate::message_processor::ConnectionSessionState;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::OutgoingEnvelope;
use crate::outgoing_message::OutgoingMessage;
use codex_app_server_protocol::JSONRPCMessage;
use futures::SinkExt;
use futures::StreamExt;
use std::collections::HashMap;
use std::io::ErrorKind;
use std::io::Result as IoResult;
use std::net::SocketAddr;
use std::str::FromStr;
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use tokio::io::AsyncBufReadExt;
use tokio::io::AsyncWriteExt;
use tokio::io::BufReader;
use tokio::io::{self};
use tokio::net::TcpListener;
use tokio::net::TcpStream;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tokio_tungstenite::accept_async;
use tokio_tungstenite::tungstenite::Message as WebSocketMessage;
use tracing::debug;
use tracing::error;
use tracing::info;
use tracing::warn;
/// Size of the bounded channels used to communicate between tasks. The value
/// is a balance between throughput and memory usage - 128 messages should be
/// plenty for an interactive CLI.
pub(crate) const CHANNEL_CAPACITY: usize = 128;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum AppServerTransport {
Stdio,
WebSocket { bind_address: SocketAddr },
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub enum AppServerTransportParseError {
UnsupportedListenUrl(String),
InvalidWebSocketListenUrl(String),
}
impl std::fmt::Display for AppServerTransportParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
AppServerTransportParseError::UnsupportedListenUrl(listen_url) => write!(
f,
"unsupported --listen URL `{listen_url}`; expected `stdio://` or `ws://IP:PORT`"
),
AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url) => write!(
f,
"invalid websocket --listen URL `{listen_url}`; expected `ws://IP:PORT`"
),
}
}
}
impl std::error::Error for AppServerTransportParseError {}
impl AppServerTransport {
pub const DEFAULT_LISTEN_URL: &'static str = "stdio://";
pub fn from_listen_url(listen_url: &str) -> Result<Self, AppServerTransportParseError> {
if listen_url == Self::DEFAULT_LISTEN_URL {
return Ok(Self::Stdio);
}
if let Some(socket_addr) = listen_url.strip_prefix("ws://") {
let bind_address = socket_addr.parse::<SocketAddr>().map_err(|_| {
AppServerTransportParseError::InvalidWebSocketListenUrl(listen_url.to_string())
})?;
return Ok(Self::WebSocket { bind_address });
}
Err(AppServerTransportParseError::UnsupportedListenUrl(
listen_url.to_string(),
))
}
}
impl FromStr for AppServerTransport {
type Err = AppServerTransportParseError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::from_listen_url(s)
}
}
#[derive(Debug)]
pub(crate) enum TransportEvent {
ConnectionOpened {
connection_id: ConnectionId,
writer: mpsc::Sender<OutgoingMessage>,
},
ConnectionClosed {
connection_id: ConnectionId,
},
IncomingMessage {
connection_id: ConnectionId,
message: JSONRPCMessage,
},
}
pub(crate) struct ConnectionState {
pub(crate) writer: mpsc::Sender<OutgoingMessage>,
pub(crate) session: ConnectionSessionState,
}
impl ConnectionState {
pub(crate) fn new(writer: mpsc::Sender<OutgoingMessage>) -> Self {
Self {
writer,
session: ConnectionSessionState::default(),
}
}
}
pub(crate) async fn start_stdio_connection(
transport_event_tx: mpsc::Sender<TransportEvent>,
stdio_handles: &mut Vec<JoinHandle<()>>,
) -> IoResult<()> {
let connection_id = ConnectionId(0);
let (writer_tx, mut writer_rx) = mpsc::channel::<OutgoingMessage>(CHANNEL_CAPACITY);
transport_event_tx
.send(TransportEvent::ConnectionOpened {
connection_id,
writer: writer_tx,
})
.await
.map_err(|_| std::io::Error::new(ErrorKind::BrokenPipe, "processor unavailable"))?;
let transport_event_tx_for_reader = transport_event_tx.clone();
stdio_handles.push(tokio::spawn(async move {
let stdin = io::stdin();
let reader = BufReader::new(stdin);
let mut lines = reader.lines();
loop {
match lines.next_line().await {
Ok(Some(line)) => {
if !forward_incoming_message(
&transport_event_tx_for_reader,
connection_id,
&line,
)
.await
{
break;
}
}
Ok(None) => break,
Err(err) => {
error!("Failed reading stdin: {err}");
break;
}
}
}
let _ = transport_event_tx_for_reader
.send(TransportEvent::ConnectionClosed { connection_id })
.await;
debug!("stdin reader finished (EOF)");
}));
stdio_handles.push(tokio::spawn(async move {
let mut stdout = io::stdout();
while let Some(outgoing_message) = writer_rx.recv().await {
let Some(mut json) = serialize_outgoing_message(outgoing_message) else {
continue;
};
json.push('\n');
if let Err(err) = stdout.write_all(json.as_bytes()).await {
error!("Failed to write to stdout: {err}");
break;
}
}
info!("stdout writer exited (channel closed)");
}));
Ok(())
}
pub(crate) async fn start_websocket_acceptor(
bind_address: SocketAddr,
transport_event_tx: mpsc::Sender<TransportEvent>,
) -> IoResult<JoinHandle<()>> {
let listener = TcpListener::bind(bind_address).await?;
let local_addr = listener.local_addr()?;
info!("app-server websocket listening on ws://{local_addr}");
let connection_counter = Arc::new(AtomicU64::new(1));
Ok(tokio::spawn(async move {
loop {
match listener.accept().await {
Ok((stream, _peer_addr)) => {
let connection_id =
ConnectionId(connection_counter.fetch_add(1, Ordering::Relaxed));
let transport_event_tx_for_connection = transport_event_tx.clone();
tokio::spawn(async move {
run_websocket_connection(
connection_id,
stream,
transport_event_tx_for_connection,
)
.await;
});
}
Err(err) => {
error!("failed to accept websocket connection: {err}");
}
}
}
}))
}
async fn run_websocket_connection(
connection_id: ConnectionId,
stream: TcpStream,
transport_event_tx: mpsc::Sender<TransportEvent>,
) {
let websocket_stream = match accept_async(stream).await {
Ok(stream) => stream,
Err(err) => {
warn!("failed to complete websocket handshake: {err}");
return;
}
};
let (writer_tx, mut writer_rx) = mpsc::channel::<OutgoingMessage>(CHANNEL_CAPACITY);
if transport_event_tx
.send(TransportEvent::ConnectionOpened {
connection_id,
writer: writer_tx,
})
.await
.is_err()
{
return;
}
let (mut websocket_writer, mut websocket_reader) = websocket_stream.split();
loop {
tokio::select! {
outgoing_message = writer_rx.recv() => {
let Some(outgoing_message) = outgoing_message else {
break;
};
let Some(json) = serialize_outgoing_message(outgoing_message) else {
continue;
};
if websocket_writer.send(WebSocketMessage::Text(json.into())).await.is_err() {
break;
}
}
incoming_message = websocket_reader.next() => {
match incoming_message {
Some(Ok(WebSocketMessage::Text(text))) => {
if !forward_incoming_message(&transport_event_tx, connection_id, &text).await {
break;
}
}
Some(Ok(WebSocketMessage::Ping(payload))) => {
if websocket_writer.send(WebSocketMessage::Pong(payload)).await.is_err() {
break;
}
}
Some(Ok(WebSocketMessage::Pong(_))) => {}
Some(Ok(WebSocketMessage::Close(_))) | None => break,
Some(Ok(WebSocketMessage::Binary(_))) => {
warn!("dropping unsupported binary websocket message");
}
Some(Ok(WebSocketMessage::Frame(_))) => {}
Some(Err(err)) => {
warn!("websocket receive error: {err}");
break;
}
}
}
}
}
let _ = transport_event_tx
.send(TransportEvent::ConnectionClosed { connection_id })
.await;
}
async fn forward_incoming_message(
transport_event_tx: &mpsc::Sender<TransportEvent>,
connection_id: ConnectionId,
payload: &str,
) -> bool {
match serde_json::from_str::<JSONRPCMessage>(payload) {
Ok(message) => transport_event_tx
.send(TransportEvent::IncomingMessage {
connection_id,
message,
})
.await
.is_ok(),
Err(err) => {
error!("Failed to deserialize JSONRPCMessage: {err}");
true
}
}
}
fn serialize_outgoing_message(outgoing_message: OutgoingMessage) -> Option<String> {
let value = match serde_json::to_value(outgoing_message) {
Ok(value) => value,
Err(err) => {
error!("Failed to convert OutgoingMessage to JSON value: {err}");
return None;
}
};
match serde_json::to_string(&value) {
Ok(json) => Some(json),
Err(err) => {
error!("Failed to serialize JSONRPCMessage: {err}");
None
}
}
}
pub(crate) async fn route_outgoing_envelope(
connections: &mut HashMap<ConnectionId, ConnectionState>,
envelope: OutgoingEnvelope,
) {
match envelope {
OutgoingEnvelope::ToConnection {
connection_id,
message,
} => {
let Some(connection_state) = connections.get(&connection_id) else {
warn!(
"dropping message for disconnected connection: {:?}",
connection_id
);
return;
};
if connection_state.writer.send(message).await.is_err() {
connections.remove(&connection_id);
}
}
OutgoingEnvelope::Broadcast { message } => {
let target_connections: Vec<ConnectionId> = connections
.iter()
.filter_map(|(connection_id, connection_state)| {
if connection_state.session.initialized {
Some(*connection_id)
} else {
None
}
})
.collect();
for connection_id in target_connections {
let Some(connection_state) = connections.get(&connection_id) else {
continue;
};
if connection_state.writer.send(message.clone()).await.is_err() {
connections.remove(&connection_id);
}
}
}
}
}
pub(crate) fn has_initialized_connections(
connections: &HashMap<ConnectionId, ConnectionState>,
) -> bool {
connections
.values()
.any(|connection| connection.session.initialized)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn app_server_transport_parses_stdio_listen_url() {
let transport = AppServerTransport::from_listen_url(AppServerTransport::DEFAULT_LISTEN_URL)
.expect("stdio listen URL should parse");
assert_eq!(transport, AppServerTransport::Stdio);
}
#[test]
fn app_server_transport_parses_websocket_listen_url() {
let transport = AppServerTransport::from_listen_url("ws://127.0.0.1:1234")
.expect("websocket listen URL should parse");
assert_eq!(
transport,
AppServerTransport::WebSocket {
bind_address: "127.0.0.1:1234".parse().expect("valid socket address"),
}
);
}
#[test]
fn app_server_transport_rejects_invalid_websocket_listen_url() {
let err = AppServerTransport::from_listen_url("ws://localhost:1234")
.expect_err("hostname bind address should be rejected");
assert_eq!(
err.to_string(),
"invalid websocket --listen URL `ws://localhost:1234`; expected `ws://IP:PORT`"
);
}
#[test]
fn app_server_transport_rejects_unsupported_listen_url() {
let err = AppServerTransport::from_listen_url("http://127.0.0.1:1234")
.expect_err("unsupported scheme should fail");
assert_eq!(
err.to_string(),
"unsupported --listen URL `http://127.0.0.1:1234`; expected `stdio://` or `ws://IP:PORT`"
);
}
}
@@ -22,6 +22,7 @@ use codex_app_server_protocol::CollaborationModeListParams;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigReadParams;
use codex_app_server_protocol::ConfigValueWriteParams;
use codex_app_server_protocol::ExperimentalFeatureListParams;
use codex_app_server_protocol::FeedbackUploadParams;
use codex_app_server_protocol::ForkConversationParams;
use codex_app_server_protocol::GetAccountParams;
@@ -61,6 +62,7 @@ use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadUnarchiveParams;
use codex_app_server_protocol::TurnInterruptParams;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnSteerParams;
use codex_core::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR;
use tokio::process::Command;
@@ -473,6 +475,15 @@ impl McpProcess {
self.send_request("model/list", params).await
}
/// Send an `experimentalFeature/list` JSON-RPC request.
pub async fn send_experimental_feature_list_request(
&mut self,
params: ExperimentalFeatureListParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("experimentalFeature/list", params).await
}
/// Send an `app/list` JSON-RPC request.
pub async fn send_apps_list_request(&mut self, params: AppsListParams) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
@@ -547,6 +558,15 @@ impl McpProcess {
self.send_request("turn/interrupt", params).await
}
/// Send a `turn/steer` JSON-RPC request (v2).
pub async fn send_turn_steer_request(
&mut self,
params: TurnSteerParams,
) -> anyhow::Result<i64> {
let params = Some(serde_json::to_value(params)?);
self.send_request("turn/steer", params).await
}
/// Send a `review/start` JSON-RPC request (v2).
pub async fn send_review_start_request(
&mut self,
@@ -529,6 +529,7 @@ fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()
r#"
model = "mock-model"
approval_policy = "untrusted"
sandbox_mode = "danger-full-access"
model_provider = "mock_provider"
@@ -1,5 +1,7 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_fake_rollout;
use app_test_support::rollout_path;
use app_test_support::to_response;
use codex_app_server_protocol::AddConversationListenerParams;
use codex_app_server_protocol::AddConversationSubscriptionResponse;
@@ -9,18 +11,25 @@ use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::NewConversationParams;
use codex_app_server_protocol::NewConversationResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ResumeConversationParams;
use codex_app_server_protocol::ResumeConversationResponse;
use codex_app_server_protocol::SendUserMessageParams;
use codex_app_server_protocol::SendUserMessageResponse;
use codex_execpolicy::Policy;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::models::ContentItem;
use codex_protocol::models::DeveloperInstructions;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::RawResponseItemEvent;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::TurnContextItem;
use core_test_support::responses;
use pretty_assertions::assert_eq;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use tempfile::TempDir;
@@ -263,6 +272,114 @@ async fn test_send_message_session_not_found() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn resume_with_model_mismatch_appends_model_switch_once() -> Result<()> {
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_sequence(
&server,
vec![
responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]),
responses::sse(vec![
responses::ev_response_created("resp-2"),
responses::ev_assistant_message("msg-2", "Done again"),
responses::ev_completed("resp-2"),
]),
],
)
.await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let filename_ts = "2025-01-02T12-00-00";
let meta_rfc3339 = "2025-01-02T12:00:00Z";
let preview = "Resume me";
let conversation_id = create_fake_rollout(
codex_home.path(),
filename_ts,
meta_rfc3339,
preview,
Some("mock_provider"),
None,
)?;
let rollout_path = rollout_path(codex_home.path(), filename_ts, &conversation_id);
append_rollout_turn_context(&rollout_path, meta_rfc3339, "previous-model")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let resume_id = mcp
.send_resume_conversation_request(ResumeConversationParams {
path: Some(rollout_path.clone()),
conversation_id: None,
history: None,
overrides: Some(NewConversationParams {
model: Some("gpt-5.2-codex".to_string()),
..Default::default()
}),
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("sessionConfigured"),
)
.await??;
let resume_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(resume_id)),
)
.await??;
let ResumeConversationResponse {
conversation_id, ..
} = to_response::<ResumeConversationResponse>(resume_resp)?;
let add_listener_id = mcp
.send_add_conversation_listener_request(AddConversationListenerParams {
conversation_id,
experimental_raw_events: false,
})
.await?;
let add_listener_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(add_listener_id)),
)
.await??;
let AddConversationSubscriptionResponse { subscription_id: _ } =
to_response::<_>(add_listener_resp)?;
send_message("hello after resume", conversation_id, &mut mcp).await?;
send_message("second turn", conversation_id, &mut mcp).await?;
let requests = response_mock.requests();
assert_eq!(requests.len(), 2, "expected two model requests");
let first_developer_texts = requests[0].message_input_texts("developer");
let first_model_switch_count = first_developer_texts
.iter()
.filter(|text| text.contains("<model_switch>"))
.count();
assert!(
first_model_switch_count >= 1,
"expected model switch message on first post-resume turn, got {first_developer_texts:?}"
);
let second_developer_texts = requests[1].message_input_texts("developer");
let second_model_switch_count = second_developer_texts
.iter()
.filter(|text| text.contains("<model_switch>"))
.count();
assert_eq!(
second_model_switch_count, 1,
"did not expect duplicate model switch message on second post-resume turn, got {second_developer_texts:?}"
);
Ok(())
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
@@ -438,3 +555,28 @@ fn content_texts(content: &[ContentItem]) -> Vec<&str> {
})
.collect()
}
fn append_rollout_turn_context(path: &Path, timestamp: &str, model: &str) -> std::io::Result<()> {
let line = RolloutLine {
timestamp: timestamp.to_string(),
item: RolloutItem::TurnContext(TurnContextItem {
cwd: PathBuf::from("/"),
approval_policy: AskForApproval::Never,
sandbox_policy: SandboxPolicy::DangerFullAccess,
model: model.to_string(),
personality: None,
collaboration_mode: None,
effort: None,
summary: ReasoningSummary::Auto,
user_instructions: None,
developer_instructions: None,
final_output_json_schema: None,
truncation_policy: None,
}),
};
let serialized = serde_json::to_string(&line).map_err(std::io::Error::other)?;
std::fs::OpenOptions::new()
.append(true)
.open(path)?
.write_all(format!("{serialized}\n").as_bytes())
}
@@ -29,7 +29,6 @@ use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::features::Feature;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use core_test_support::responses;
@@ -143,12 +142,10 @@ async fn auto_compaction_remote_emits_started_and_completed_items() -> Result<()
.await;
let codex_home = TempDir::new()?;
let mut features = BTreeMap::default();
features.insert(Feature::RemoteCompaction, true);
write_mock_responses_config_toml(
codex_home.path(),
&server.uri(),
&features,
&BTreeMap::default(),
AUTO_COMPACT_LIMIT,
Some(true),
"openai",
@@ -3,6 +3,9 @@ use app_test_support::McpProcess;
use app_test_support::test_path_buf_with_windows;
use app_test_support::test_tmp_path_buf;
use app_test_support::to_response;
use codex_app_server_protocol::AppConfig;
use codex_app_server_protocol::AppDisabledReason;
use codex_app_server_protocol::AppsConfig;
use codex_app_server_protocol::AskForApproval;
use codex_app_server_protocol::ConfigBatchWriteParams;
use codex_app_server_protocol::ConfigEdit;
@@ -146,6 +149,74 @@ view_image = false
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_apps() -> Result<()> {
let codex_home = TempDir::new()?;
write_config(
&codex_home,
r#"
[apps.app1]
enabled = false
disabled_reason = "user"
"#,
)?;
let codex_home_path = codex_home.path().canonicalize()?;
let user_file = AbsolutePathBuf::try_from(codex_home_path.join("config.toml"))?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_config_read_request(ConfigReadParams {
include_layers: true,
cwd: None,
})
.await?;
let resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let ConfigReadResponse {
config,
origins,
layers,
} = to_response(resp)?;
assert_eq!(
config.apps,
Some(AppsConfig {
apps: std::collections::HashMap::from([(
"app1".to_string(),
AppConfig {
enabled: false,
disabled_reason: Some(AppDisabledReason::User),
},
)]),
})
);
assert_eq!(
origins.get("apps.app1.enabled").expect("origin").name,
ConfigLayerSource::User {
file: user_file.clone(),
}
);
assert_eq!(
origins
.get("apps.app1.disabled_reason")
.expect("origin")
.name,
ConfigLayerSource::User {
file: user_file.clone(),
}
);
let layers = layers.expect("layers present");
assert_layers_user_then_optional_system(&layers, user_file)?;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn config_read_includes_project_layers_for_cwd() -> Result<()> {
let codex_home = TempDir::new()?;
@@ -0,0 +1,263 @@
use anyhow::Context;
use anyhow::Result;
use anyhow::bail;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::InitializeParams;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCMessage;
use codex_app_server_protocol::JSONRPCRequest;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use futures::SinkExt;
use futures::StreamExt;
use serde_json::json;
use std::net::SocketAddr;
use std::path::Path;
use std::process::Stdio;
use tempfile::TempDir;
use tokio::io::AsyncBufReadExt;
use tokio::process::Child;
use tokio::process::Command;
use tokio::time::Duration;
use tokio::time::Instant;
use tokio::time::sleep;
use tokio::time::timeout;
use tokio_tungstenite::MaybeTlsStream;
use tokio_tungstenite::WebSocketStream;
use tokio_tungstenite::connect_async;
use tokio_tungstenite::tungstenite::Message as WebSocketMessage;
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(5);
type WsClient = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
#[tokio::test]
async fn websocket_transport_routes_per_connection_handshake_and_responses() -> Result<()> {
let server = create_mock_responses_server_sequence_unchecked(Vec::new()).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let bind_addr = reserve_local_addr()?;
let mut process = spawn_websocket_server(codex_home.path(), bind_addr).await?;
let mut ws1 = connect_websocket(bind_addr).await?;
let mut ws2 = connect_websocket(bind_addr).await?;
send_initialize_request(&mut ws1, 1, "ws_client_one").await?;
let first_init = read_response_for_id(&mut ws1, 1).await?;
assert_eq!(first_init.id, RequestId::Integer(1));
// Initialize responses are request-scoped and must not leak to other
// connections.
assert_no_message(&mut ws2, Duration::from_millis(250)).await?;
send_config_read_request(&mut ws2, 2).await?;
let not_initialized = read_error_for_id(&mut ws2, 2).await?;
assert_eq!(not_initialized.error.message, "Not initialized");
send_initialize_request(&mut ws2, 3, "ws_client_two").await?;
let second_init = read_response_for_id(&mut ws2, 3).await?;
assert_eq!(second_init.id, RequestId::Integer(3));
// Same request-id on different connections must route independently.
send_config_read_request(&mut ws1, 77).await?;
send_config_read_request(&mut ws2, 77).await?;
let ws1_config = read_response_for_id(&mut ws1, 77).await?;
let ws2_config = read_response_for_id(&mut ws2, 77).await?;
assert_eq!(ws1_config.id, RequestId::Integer(77));
assert_eq!(ws2_config.id, RequestId::Integer(77));
assert!(ws1_config.result.get("config").is_some());
assert!(ws2_config.result.get("config").is_some());
process
.kill()
.await
.context("failed to stop websocket app-server process")?;
Ok(())
}
async fn spawn_websocket_server(codex_home: &Path, bind_addr: SocketAddr) -> Result<Child> {
let program = codex_utils_cargo_bin::cargo_bin("codex-app-server")
.context("should find app-server binary")?;
let mut cmd = Command::new(program);
cmd.arg("--listen")
.arg(format!("ws://{bind_addr}"))
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::piped())
.env("CODEX_HOME", codex_home)
.env("RUST_LOG", "debug");
let mut process = cmd
.kill_on_drop(true)
.spawn()
.context("failed to spawn websocket app-server process")?;
if let Some(stderr) = process.stderr.take() {
let mut stderr_reader = tokio::io::BufReader::new(stderr).lines();
tokio::spawn(async move {
while let Ok(Some(line)) = stderr_reader.next_line().await {
eprintln!("[websocket app-server stderr] {line}");
}
});
}
Ok(process)
}
fn reserve_local_addr() -> Result<SocketAddr> {
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
let addr = listener.local_addr()?;
drop(listener);
Ok(addr)
}
async fn connect_websocket(bind_addr: SocketAddr) -> Result<WsClient> {
let url = format!("ws://{bind_addr}");
let deadline = Instant::now() + Duration::from_secs(10);
loop {
match connect_async(&url).await {
Ok((stream, _response)) => return Ok(stream),
Err(err) => {
if Instant::now() >= deadline {
bail!("failed to connect websocket to {url}: {err}");
}
sleep(Duration::from_millis(50)).await;
}
}
}
}
async fn send_initialize_request(stream: &mut WsClient, id: i64, client_name: &str) -> Result<()> {
let params = InitializeParams {
client_info: ClientInfo {
name: client_name.to_string(),
title: Some("WebSocket Test Client".to_string()),
version: "0.1.0".to_string(),
},
capabilities: None,
};
send_request(
stream,
"initialize",
id,
Some(serde_json::to_value(params)?),
)
.await
}
async fn send_config_read_request(stream: &mut WsClient, id: i64) -> Result<()> {
send_request(
stream,
"config/read",
id,
Some(json!({ "includeLayers": false })),
)
.await
}
async fn send_request(
stream: &mut WsClient,
method: &str,
id: i64,
params: Option<serde_json::Value>,
) -> Result<()> {
let message = JSONRPCMessage::Request(JSONRPCRequest {
id: RequestId::Integer(id),
method: method.to_string(),
params,
});
send_jsonrpc(stream, message).await
}
async fn send_jsonrpc(stream: &mut WsClient, message: JSONRPCMessage) -> Result<()> {
let payload = serde_json::to_string(&message)?;
stream
.send(WebSocketMessage::Text(payload.into()))
.await
.context("failed to send websocket frame")
}
async fn read_response_for_id(stream: &mut WsClient, id: i64) -> Result<JSONRPCResponse> {
let target_id = RequestId::Integer(id);
loop {
let message = read_jsonrpc_message(stream).await?;
if let JSONRPCMessage::Response(response) = message
&& response.id == target_id
{
return Ok(response);
}
}
}
async fn read_error_for_id(stream: &mut WsClient, id: i64) -> Result<JSONRPCError> {
let target_id = RequestId::Integer(id);
loop {
let message = read_jsonrpc_message(stream).await?;
if let JSONRPCMessage::Error(err) = message
&& err.id == target_id
{
return Ok(err);
}
}
}
async fn read_jsonrpc_message(stream: &mut WsClient) -> Result<JSONRPCMessage> {
loop {
let frame = timeout(DEFAULT_READ_TIMEOUT, stream.next())
.await
.context("timed out waiting for websocket frame")?
.context("websocket stream ended unexpectedly")?
.context("failed to read websocket frame")?;
match frame {
WebSocketMessage::Text(text) => return Ok(serde_json::from_str(text.as_ref())?),
WebSocketMessage::Ping(payload) => {
stream.send(WebSocketMessage::Pong(payload)).await?;
}
WebSocketMessage::Pong(_) => {}
WebSocketMessage::Close(frame) => {
bail!("websocket closed unexpectedly: {frame:?}")
}
WebSocketMessage::Binary(_) => bail!("unexpected binary websocket frame"),
WebSocketMessage::Frame(_) => {}
}
}
}
async fn assert_no_message(stream: &mut WsClient, wait_for: Duration) -> Result<()> {
match timeout(wait_for, stream.next()).await {
Ok(Some(Ok(frame))) => bail!("unexpected frame while waiting for silence: {frame:?}"),
Ok(Some(Err(err))) => bail!("unexpected websocket read error: {err}"),
Ok(None) => bail!("websocket closed unexpectedly while waiting for silence"),
Err(_) => Ok(()),
}
}
fn create_config_toml(
codex_home: &Path,
server_uri: &str,
approval_policy: &str,
) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "{approval_policy}"
sandbox_mode = "read-only"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}
@@ -0,0 +1,78 @@
use std::time::Duration;
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::ExperimentalFeature;
use codex_app_server_protocol::ExperimentalFeatureListParams;
use codex_app_server_protocol::ExperimentalFeatureListResponse;
use codex_app_server_protocol::ExperimentalFeatureStage;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_core::features::FEATURES;
use codex_core::features::Stage;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
#[tokio::test]
async fn experimental_feature_list_returns_feature_metadata_with_stage() -> Result<()> {
let codex_home = TempDir::new()?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
let request_id = mcp
.send_experimental_feature_list_request(ExperimentalFeatureListParams::default())
.await?;
let response: JSONRPCResponse = timeout(
DEFAULT_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
let actual = to_response::<ExperimentalFeatureListResponse>(response)?;
let expected_data = FEATURES
.iter()
.map(|spec| {
let (stage, display_name, description, announcement) = match spec.stage {
Stage::Experimental {
name,
menu_description,
announcement,
} => (
ExperimentalFeatureStage::Beta,
Some(name.to_string()),
Some(menu_description.to_string()),
Some(announcement.to_string()),
),
Stage::UnderDevelopment => {
(ExperimentalFeatureStage::UnderDevelopment, None, None, None)
}
Stage::Stable => (ExperimentalFeatureStage::Stable, None, None, None),
Stage::Deprecated => (ExperimentalFeatureStage::Deprecated, None, None, None),
Stage::Removed => (ExperimentalFeatureStage::Removed, None, None, None),
};
ExperimentalFeature {
name: spec.key.to_string(),
stage,
display_name,
description,
announcement,
enabled: spec.default_enabled,
default_enabled: spec.default_enabled,
}
})
.collect::<Vec<_>>();
let expected = ExperimentalFeatureListResponse {
data: expected_data,
next_cursor: None,
};
assert_eq!(actual, expected);
Ok(())
}
@@ -4,8 +4,10 @@ mod app_list;
mod collaboration_mode_list;
mod compaction;
mod config_rpc;
mod connection_handling_websocket;
mod dynamic_tools;
mod experimental_api;
mod experimental_feature_list;
mod initialize;
mod model_list;
mod output_schema;
@@ -24,3 +26,4 @@ mod thread_start;
mod thread_unarchive;
mod turn_interrupt;
mod turn_start;
mod turn_steer;
@@ -5,6 +5,7 @@ use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::rollout_path;
use app_test_support::to_response;
use chrono::Utc;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::SessionSource;
@@ -261,6 +262,44 @@ async fn thread_resume_with_overrides_defers_updated_at_until_turn_start() -> Re
Ok(())
}
#[tokio::test]
async fn thread_resume_fails_when_required_mcp_server_fails_to_initialize() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
let rollout = setup_rollout_fixture(codex_home.path(), &server.uri())?;
create_config_toml_with_required_broken_mcp(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let resume_id = mcp
.send_thread_resume_request(ThreadResumeParams {
thread_id: rollout.conversation_id,
..Default::default()
})
.await?;
let err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(resume_id)),
)
.await??;
assert!(
err.error
.message
.contains("required MCP servers failed to initialize"),
"unexpected error message: {}",
err.error.message
);
assert!(
err.error.message.contains("required_broken"),
"unexpected error message: {}",
err.error.message
);
Ok(())
}
#[tokio::test]
async fn thread_resume_prefers_path_over_thread_id() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
@@ -403,7 +442,7 @@ async fn thread_resume_accepts_personality_override() -> Result<()> {
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread.id.clone(),
model: Some("gpt-5.2-codex".to_string()),
personality: Some(Personality::Pragmatic),
personality: Some(Personality::Friendly),
..Default::default()
})
.await?;
@@ -481,6 +520,40 @@ stream_max_retries = 0
)
}
fn create_config_toml_with_required_broken_mcp(
codex_home: &std::path::Path,
server_uri: &str,
) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "gpt-5.2-codex"
approval_policy = "never"
sandbox_mode = "read-only"
model_provider = "mock_provider"
[features]
remote_models = false
personality = true
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
[mcp_servers.required_broken]
command = "codex-definitely-not-a-real-binary"
required = true
"#
),
)
}
fn set_rollout_mtime(path: &Path, updated_at_rfc3339: &str) -> Result<()> {
let parsed = chrono::DateTime::parse_from_rfc3339(updated_at_rfc3339)?.with_timezone(&Utc);
let times = FileTimes::new().set_modified(parsed.into());
@@ -2,6 +2,7 @@ use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_mock_responses_server_repeating_assistant;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
@@ -113,6 +114,42 @@ model_reasoning_effort = "high"
Ok(())
}
#[tokio::test]
async fn thread_start_fails_when_required_mcp_server_fails_to_initialize() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;
let codex_home = TempDir::new()?;
create_config_toml_with_required_broken_mcp(codex_home.path(), &server.uri())?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let req_id = mcp
.send_thread_start_request(ThreadStartParams::default())
.await?;
let err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(req_id)),
)
.await??;
assert!(
err.error
.message
.contains("required MCP servers failed to initialize"),
"unexpected error message: {}",
err.error.message
);
assert!(
err.error.message.contains("required_broken"),
"unexpected error message: {}",
err.error.message
);
Ok(())
}
// Helper to create a config.toml pointing at the mock model server.
fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
@@ -136,3 +173,33 @@ stream_max_retries = 0
),
)
}
fn create_config_toml_with_required_broken_mcp(
codex_home: &Path,
server_uri: &str,
) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "read-only"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
[mcp_servers.required_broken]
command = "codex-definitely-not-a-real-binary"
required = true
"#
),
)
}
@@ -2,6 +2,7 @@ use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_apply_patch_sse_response;
use app_test_support::create_exec_command_sse_response;
use app_test_support::create_fake_rollout;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_mock_responses_server_sequence_unchecked;
@@ -34,8 +35,10 @@ use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnStartedNotification;
use codex_app_server_protocol::TurnStatus;
use codex_app_server_protocol::UserInput as V2UserInput;
use codex_core::config::ConfigToml;
use codex_core::features::FEATURES;
use codex_core::features::Feature;
use codex_core::personality_migration::PERSONALITY_MIGRATION_FILENAME;
use codex_core::protocol_config_types::ReasoningSummary;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::ModeKind;
@@ -52,6 +55,7 @@ use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const TEST_ORIGINATOR: &str = "codex_vscode";
const LOCAL_PRAGMATIC_TEMPLATE: &str = "You are a deeply pragmatic, effective software engineer.";
#[tokio::test]
async fn turn_start_sends_originator_header() -> Result<()> {
@@ -451,7 +455,7 @@ async fn turn_start_accepts_personality_override_v2() -> Result<()> {
text: "Hello".to_string(),
text_elements: Vec::new(),
}],
personality: Some(Personality::Pragmatic),
personality: Some(Personality::Friendly),
..Default::default()
})
.await?;
@@ -556,7 +560,7 @@ async fn turn_start_change_personality_mid_thread_v2() -> Result<()> {
text: "Hello again".to_string(),
text_elements: Vec::new(),
}],
personality: Some(Personality::Pragmatic),
personality: Some(Personality::Friendly),
..Default::default()
})
.await?;
@@ -595,6 +599,96 @@ async fn turn_start_change_personality_mid_thread_v2() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn turn_start_uses_migrated_pragmatic_personality_without_override_v2() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let body = responses::sse(vec![
responses::ev_response_created("resp-1"),
responses::ev_assistant_message("msg-1", "Done"),
responses::ev_completed("resp-1"),
]);
let response_mock = responses::mount_sse_once(&server, body).await;
let codex_home = TempDir::new()?;
create_config_toml(
codex_home.path(),
&server.uri(),
"never",
&BTreeMap::from([(Feature::Personality, true)]),
)?;
create_fake_rollout(
codex_home.path(),
"2025-01-01T00-00-00",
"2025-01-01T00:00:00Z",
"history user message",
Some("mock_provider"),
None,
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let persisted_toml: ConfigToml = toml::from_str(&std::fs::read_to_string(
codex_home.path().join("config.toml"),
)?)?;
assert_eq!(persisted_toml.personality, Some(Personality::Pragmatic));
assert!(
codex_home
.path()
.join(PERSONALITY_MIGRATION_FILENAME)
.exists(),
"expected personality migration marker to be written on startup"
);
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("gpt-5.2-codex".to_string()),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id,
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
text_elements: Vec::new(),
}],
personality: None,
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let _turn: TurnStartResponse = to_response::<TurnStartResponse>(turn_resp)?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let request = response_mock.single_request();
let instructions_text = request.instructions_text();
assert!(
instructions_text.contains(LOCAL_PRAGMATIC_TEMPLATE),
"expected startup-migrated pragmatic personality in model instructions, got: {instructions_text:?}"
);
Ok(())
}
#[tokio::test]
async fn turn_start_accepts_local_image_input() -> Result<()> {
// Two Codex turns hit the mock model (session start + turn/start).
@@ -1622,11 +1716,12 @@ async fn command_execution_notifications_include_process_id() -> Result<()> {
];
let server = create_mock_responses_server_sequence(responses).await;
let codex_home = TempDir::new()?;
create_config_toml(
create_config_toml_with_sandbox(
codex_home.path(),
&server.uri(),
"never",
&BTreeMap::from([(Feature::UnifiedExec, true)]),
"danger-full-access",
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
@@ -1753,6 +1848,22 @@ fn create_config_toml(
server_uri: &str,
approval_policy: &str,
feature_flags: &BTreeMap<Feature, bool>,
) -> std::io::Result<()> {
create_config_toml_with_sandbox(
codex_home,
server_uri,
approval_policy,
feature_flags,
"read-only",
)
}
fn create_config_toml_with_sandbox(
codex_home: &Path,
server_uri: &str,
approval_policy: &str,
feature_flags: &BTreeMap<Feature, bool>,
sandbox_mode: &str,
) -> std::io::Result<()> {
let mut features = BTreeMap::from([(Feature::RemoteModels, false)]);
for (feature, enabled) in feature_flags {
@@ -1777,7 +1888,7 @@ fn create_config_toml(
r#"
model = "mock-model"
approval_policy = "{approval_policy}"
sandbox_mode = "read-only"
sandbox_mode = "{sandbox_mode}"
model_provider = "mock_provider"
@@ -0,0 +1,179 @@
#![cfg(unix)]
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_mock_responses_server_sequence;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::create_shell_command_sse_response;
use app_test_support::to_response;
use codex_app_server_protocol::JSONRPCError;
use codex_app_server_protocol::JSONRPCNotification;
use codex_app_server_protocol::JSONRPCResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::TurnSteerResponse;
use codex_app_server_protocol::UserInput as V2UserInput;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn turn_steer_requires_active_turn() -> Result<()> {
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let server = create_mock_responses_server_sequence(vec![]).await;
create_config_toml(&codex_home, &server.uri())?;
let mut mcp = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let steer_req = mcp
.send_turn_steer_request(TurnSteerParams {
thread_id: thread.id,
input: vec![V2UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
}],
expected_turn_id: "turn-does-not-exist".to_string(),
})
.await?;
let steer_err: JSONRPCError = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_error_message(RequestId::Integer(steer_req)),
)
.await??;
assert_eq!(steer_err.error.code, -32600);
Ok(())
}
#[tokio::test]
async fn turn_steer_returns_active_turn_id() -> Result<()> {
#[cfg(target_os = "windows")]
let shell_command = vec![
"powershell".to_string(),
"-Command".to_string(),
"Start-Sleep -Seconds 10".to_string(),
];
#[cfg(not(target_os = "windows"))]
let shell_command = vec!["sleep".to_string(), "10".to_string()];
let tmp = TempDir::new()?;
let codex_home = tmp.path().join("codex_home");
std::fs::create_dir(&codex_home)?;
let working_directory = tmp.path().join("workdir");
std::fs::create_dir(&working_directory)?;
let server =
create_mock_responses_server_sequence_unchecked(vec![create_shell_command_sse_response(
shell_command.clone(),
Some(&working_directory),
Some(10_000),
"call_sleep",
)?])
.await;
create_config_toml(&codex_home, &server.uri())?;
let mut mcp = McpProcess::new(&codex_home).await?;
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "run sleep".to_string(),
text_elements: Vec::new(),
}],
cwd: Some(working_directory.clone()),
..Default::default()
})
.await?;
let turn_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
let TurnStartResponse { turn } = to_response::<TurnStartResponse>(turn_resp)?;
let _task_started: JSONRPCNotification = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("codex/event/task_started"),
)
.await??;
let steer_req = mcp
.send_turn_steer_request(TurnSteerParams {
thread_id: thread.id,
input: vec![V2UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
}],
expected_turn_id: turn.id.clone(),
})
.await?;
let steer_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(steer_req)),
)
.await??;
let steer: TurnSteerResponse = to_response::<TurnSteerResponse>(steer_resp)?;
assert_eq!(steer.turn_id, turn.id);
Ok(())
}
fn create_config_toml(codex_home: &std::path::Path, server_uri: &str) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "never"
sandbox_mode = "danger-full-access"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}
+2 -6
View File
@@ -1,7 +1,7 @@
use codex_app_server_protocol::AuthMode;
use codex_common::CliConfigOverrides;
use codex_core::CodexAuth;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::AuthMode;
use codex_core::auth::CLIENT_ID;
use codex_core::auth::login_with_api_key;
use codex_core::auth::logout;
@@ -225,7 +225,7 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
let config = load_config_or_exit(cli_config_overrides).await;
match CodexAuth::from_auth_storage(&config.codex_home, config.cli_auth_credentials_store_mode) {
Ok(Some(auth)) => match auth.api_auth_mode() {
Ok(Some(auth)) => match auth.auth_mode() {
AuthMode::ApiKey => match auth.get_token() {
Ok(api_key) => {
eprintln!("Logged in using an API key - {}", safe_format_key(&api_key));
@@ -240,10 +240,6 @@ pub async fn run_login_status(cli_config_overrides: CliConfigOverrides) -> ! {
eprintln!("Logged in using ChatGPT");
std::process::exit(0);
}
AuthMode::ChatgptAuthTokens => {
eprintln!("Logged in using ChatGPT (external tokens)");
std::process::exit(0);
}
},
Ok(None) => {
eprintln!("Not logged in");
+46 -1
View File
@@ -306,6 +306,15 @@ struct AppServerCommand {
#[command(subcommand)]
subcommand: Option<AppServerSubcommand>,
/// Transport endpoint URL. Supported values: `stdio://` (default),
/// `ws://IP:PORT`.
#[arg(
long = "listen",
value_name = "URL",
default_value = codex_app_server::AppServerTransport::DEFAULT_LISTEN_URL
)]
listen: codex_app_server::AppServerTransport,
/// Controls whether analytics are enabled by default.
///
/// Analytics are disabled by default for app-server. Users have to explicitly opt in
@@ -587,11 +596,13 @@ async fn cli_main(codex_linux_sandbox_exe: Option<PathBuf>) -> anyhow::Result<()
}
Some(Subcommand::AppServer(app_server_cli)) => match app_server_cli.subcommand {
None => {
codex_app_server::run_main(
let transport = app_server_cli.listen;
codex_app_server::run_main_with_transport(
codex_linux_sandbox_exe,
root_config_overrides,
codex_core::config_loader::LoaderOverrides::default(),
app_server_cli.analytics_default_enabled,
transport,
)
.await?;
}
@@ -1328,6 +1339,10 @@ mod tests {
fn app_server_analytics_default_disabled_without_flag() {
let app_server = app_server_from_args(["codex", "app-server"].as_ref());
assert!(!app_server.analytics_default_enabled);
assert_eq!(
app_server.listen,
codex_app_server::AppServerTransport::Stdio
);
}
#[test]
@@ -1337,6 +1352,36 @@ mod tests {
assert!(app_server.analytics_default_enabled);
}
#[test]
fn app_server_listen_websocket_url_parses() {
let app_server = app_server_from_args(
["codex", "app-server", "--listen", "ws://127.0.0.1:4500"].as_ref(),
);
assert_eq!(
app_server.listen,
codex_app_server::AppServerTransport::WebSocket {
bind_address: "127.0.0.1:4500".parse().expect("valid socket address"),
}
);
}
#[test]
fn app_server_listen_stdio_url_parses() {
let app_server =
app_server_from_args(["codex", "app-server", "--listen", "stdio://"].as_ref());
assert_eq!(
app_server.listen,
codex_app_server::AppServerTransport::Stdio
);
}
#[test]
fn app_server_listen_invalid_url_fails_to_parse() {
let parse_result =
MultitoolCli::try_parse_from(["codex", "app-server", "--listen", "http://foo"]);
assert!(parse_result.is_err());
}
#[test]
fn features_enable_parses_feature_name() {
let cli = MultitoolCli::try_parse_from(["codex", "features", "enable", "unified_exec"])
+1
View File
@@ -243,6 +243,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
let new_entry = McpServerConfig {
transport: transport.clone(),
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
+173 -19
View File
@@ -14,21 +14,33 @@ use codex_core::AuthManager;
use codex_core::auth::CodexAuth;
use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::ConfigRequirementsToml;
use codex_core::util::backoff;
use codex_protocol::account::PlanType;
use std::sync::Arc;
use std::time::Duration;
use std::time::Instant;
use tokio::time::sleep;
use tokio::time::timeout;
const CLOUD_REQUIREMENTS_TIMEOUT: Duration = Duration::from_secs(15);
const CLOUD_REQUIREMENTS_MAX_ATTEMPTS: usize = 5;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum FetchCloudRequirementsStatus {
BackendClientInit,
Request,
Parse,
}
#[async_trait]
trait RequirementsFetcher: Send + Sync {
/// Returns requirements as a TOML string.
/// Returns `Ok(None)` when there are no cloud requirements for the account.
///
/// TODO(gt): For now, returns an Option. But when we want to make this fail-closed, return a
/// Result.
async fn fetch_requirements(&self, auth: &CodexAuth) -> Option<String>;
/// Returning `Err` indicates cloud requirements could not be fetched.
async fn fetch_requirements(
&self,
auth: &CodexAuth,
) -> Result<Option<String>, FetchCloudRequirementsStatus>;
}
struct BackendRequirementsFetcher {
@@ -43,7 +55,10 @@ impl BackendRequirementsFetcher {
#[async_trait]
impl RequirementsFetcher for BackendRequirementsFetcher {
async fn fetch_requirements(&self, auth: &CodexAuth) -> Option<String> {
async fn fetch_requirements(
&self,
auth: &CodexAuth,
) -> Result<Option<String>, FetchCloudRequirementsStatus> {
let client = BackendClient::from_auth(self.base_url.clone(), auth)
.inspect_err(|err| {
tracing::warn!(
@@ -51,20 +66,22 @@ impl RequirementsFetcher for BackendRequirementsFetcher {
"Failed to construct backend client for cloud requirements"
);
})
.ok()?;
.map_err(|_| FetchCloudRequirementsStatus::BackendClientInit)?;
let response = client
.get_config_requirements_file()
.await
.inspect_err(|err| tracing::warn!(error = %err, "Failed to fetch cloud requirements"))
.ok()?;
.map_err(|_| FetchCloudRequirementsStatus::Request)?;
let Some(contents) = response.contents else {
tracing::warn!("Cloud requirements response missing contents");
return None;
tracing::info!(
"Cloud requirements response missing contents; treating as no requirements"
);
return Ok(None);
};
Some(contents)
Ok(Some(contents))
}
}
@@ -128,11 +145,41 @@ impl CloudRequirementsService {
return None;
}
let contents = self.fetcher.fetch_requirements(&auth).await?;
parse_cloud_requirements(&contents)
.inspect_err(|err| tracing::warn!(error = %err, "Failed to parse cloud requirements"))
.ok()
.flatten()
self.fetch_with_retries(&auth).await
}
async fn fetch_with_retries(&self, auth: &CodexAuth) -> Option<ConfigRequirementsToml> {
for attempt in 1..=CLOUD_REQUIREMENTS_MAX_ATTEMPTS {
let fetch_result = self
.fetcher
.fetch_requirements(auth)
.await
.and_then(|contents| {
contents.map_or(Ok(None), |contents| {
parse_cloud_requirements(&contents).map_err(|err| {
tracing::warn!(error = %err, "Failed to parse cloud requirements");
FetchCloudRequirementsStatus::Parse
})
})
});
match fetch_result {
Ok(requirements) => return requirements,
Err(status) => {
if attempt < CLOUD_REQUIREMENTS_MAX_ATTEMPTS {
tracing::warn!(
status = ?status,
attempt,
max_attempts = CLOUD_REQUIREMENTS_MAX_ATTEMPTS,
"Failed to fetch cloud requirements; retrying"
);
sleep(backoff(attempt as u64)).await;
}
}
}
}
None
}
}
@@ -178,8 +225,11 @@ mod tests {
use codex_protocol::protocol::AskForApproval;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::VecDeque;
use std::future::pending;
use std::path::Path;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use tempfile::tempdir;
fn write_auth_json(codex_home: &Path, value: serde_json::Value) -> std::io::Result<()> {
@@ -246,8 +296,11 @@ mod tests {
#[async_trait::async_trait]
impl RequirementsFetcher for StaticFetcher {
async fn fetch_requirements(&self, _auth: &CodexAuth) -> Option<String> {
self.contents.clone()
async fn fetch_requirements(
&self,
_auth: &CodexAuth,
) -> Result<Option<String>, FetchCloudRequirementsStatus> {
Ok(self.contents.clone())
}
}
@@ -255,9 +308,39 @@ mod tests {
#[async_trait::async_trait]
impl RequirementsFetcher for PendingFetcher {
async fn fetch_requirements(&self, _auth: &CodexAuth) -> Option<String> {
async fn fetch_requirements(
&self,
_auth: &CodexAuth,
) -> Result<Option<String>, FetchCloudRequirementsStatus> {
pending::<()>().await;
None
Ok(None)
}
}
struct SequenceFetcher {
responses:
tokio::sync::Mutex<VecDeque<Result<Option<String>, FetchCloudRequirementsStatus>>>,
request_count: AtomicUsize,
}
impl SequenceFetcher {
fn new(responses: Vec<Result<Option<String>, FetchCloudRequirementsStatus>>) -> Self {
Self {
responses: tokio::sync::Mutex::new(VecDeque::from(responses)),
request_count: AtomicUsize::new(0),
}
}
}
#[async_trait::async_trait]
impl RequirementsFetcher for SequenceFetcher {
async fn fetch_requirements(
&self,
_auth: &CodexAuth,
) -> Result<Option<String>, FetchCloudRequirementsStatus> {
self.request_count.fetch_add(1, Ordering::SeqCst);
let mut responses = self.responses.lock().await;
responses.pop_front().unwrap_or(Ok(None))
}
}
@@ -359,4 +442,75 @@ mod tests {
let result = handle.await.expect("cloud requirements task");
assert!(result.is_none());
}
#[tokio::test(start_paused = true)]
async fn fetch_cloud_requirements_retries_until_success() {
let fetcher = Arc::new(SequenceFetcher::new(vec![
Err(FetchCloudRequirementsStatus::Request),
Ok(Some("allowed_approval_policies = [\"never\"]".to_string())),
]));
let service = CloudRequirementsService::new(
auth_manager_with_plan("business"),
fetcher.clone(),
CLOUD_REQUIREMENTS_TIMEOUT,
);
let handle = tokio::spawn(async move { service.fetch().await });
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(1)).await;
assert_eq!(
handle.await.expect("cloud requirements task"),
Some(ConfigRequirementsToml {
allowed_approval_policies: Some(vec![AskForApproval::Never]),
allowed_sandbox_modes: None,
mcp_servers: None,
rules: None,
enforce_residency: None,
})
);
assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn fetch_cloud_requirements_none_is_success_without_retry() {
let fetcher = Arc::new(SequenceFetcher::new(vec![
Ok(None),
Err(FetchCloudRequirementsStatus::Request),
]));
let service = CloudRequirementsService::new(
auth_manager_with_plan("enterprise"),
fetcher.clone(),
CLOUD_REQUIREMENTS_TIMEOUT,
);
assert!(service.fetch().await.is_none());
assert_eq!(fetcher.request_count.load(Ordering::SeqCst), 1);
}
#[tokio::test(start_paused = true)]
async fn fetch_cloud_requirements_stops_after_max_retries() {
let fetcher = Arc::new(SequenceFetcher::new(vec![
Err(
FetchCloudRequirementsStatus::Request
);
CLOUD_REQUIREMENTS_MAX_ATTEMPTS
]));
let service = CloudRequirementsService::new(
auth_manager_with_plan("enterprise"),
fetcher.clone(),
CLOUD_REQUIREMENTS_TIMEOUT,
);
let handle = tokio::spawn(async move { service.fetch().await });
tokio::task::yield_now().await;
tokio::time::advance(Duration::from_secs(5)).await;
tokio::task::yield_now().await;
assert!(handle.await.expect("cloud requirements task").is_none());
assert_eq!(
fetcher.request_count.load(Ordering::SeqCst),
CLOUD_REQUIREMENTS_MAX_ATTEMPTS
);
}
}
+69 -12
View File
@@ -55,6 +55,40 @@
},
"type": "object"
},
"AppConfig": {
"additionalProperties": false,
"description": "Config values for a single app/connector.",
"properties": {
"disabled_reason": {
"allOf": [
{
"$ref": "#/definitions/AppDisabledReason"
}
],
"description": "Reason this app was disabled."
},
"enabled": {
"default": true,
"description": "When `false`, Codex does not surface this app.",
"type": "boolean"
}
},
"type": "object"
},
"AppDisabledReason": {
"enum": [
"unknown",
"user"
],
"type": "string"
},
"AppsConfigToml": {
"additionalProperties": {
"$ref": "#/definitions/AppConfig"
},
"description": "App/connector settings loaded from `config.toml`.",
"type": "object"
},
"AskForApproval": {
"description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.",
"oneOf": [
@@ -175,9 +209,6 @@
"enable_request_compression": {
"type": "boolean"
},
"exec_policy": {
"type": "boolean"
},
"experimental_use_freeform_apply_patch": {
"type": "boolean"
},
@@ -190,15 +221,15 @@
"include_apply_patch_tool": {
"type": "boolean"
},
"memory_tool": {
"type": "boolean"
},
"personality": {
"type": "boolean"
},
"powershell_utf8": {
"type": "boolean"
},
"remote_compaction": {
"type": "boolean"
},
"remote_models": {
"type": "boolean"
},
@@ -791,6 +822,10 @@
},
"type": "object"
},
"required": {
"default": null,
"type": "boolean"
},
"scopes": {
"default": null,
"items": {
@@ -1046,6 +1081,14 @@
"default": true,
"description": "Show startup tooltips in the TUI welcome screen. Defaults to `true`.",
"type": "boolean"
},
"status_line": {
"default": null,
"description": "Ordered list of status line item identifiers.\n\nWhen set, the TUI renders the selected items as the status line.",
"items": {
"type": "string"
},
"type": "array"
}
},
"type": "object"
@@ -1126,6 +1169,15 @@
],
"description": "Default approval policy for executing commands."
},
"apps": {
"allOf": [
{
"$ref": "#/definitions/AppsConfigToml"
}
],
"default": null,
"description": "Settings for app-specific controls."
},
"chatgpt_base_url": {
"description": "Base URL for requests to ChatGPT (as opposed to the OpenAI API).",
"type": "string"
@@ -1197,9 +1249,6 @@
"enable_request_compression": {
"type": "boolean"
},
"exec_policy": {
"type": "boolean"
},
"experimental_use_freeform_apply_patch": {
"type": "boolean"
},
@@ -1212,15 +1261,15 @@
"include_apply_patch_tool": {
"type": "boolean"
},
"memory_tool": {
"type": "boolean"
},
"personality": {
"type": "boolean"
},
"powershell_utf8": {
"type": "boolean"
},
"remote_compaction": {
"type": "boolean"
},
"remote_models": {
"type": "boolean"
},
@@ -1328,6 +1377,14 @@
"description": "System instructions.",
"type": "string"
},
"log_dir": {
"allOf": [
{
"$ref": "#/definitions/AbsolutePathBuf"
}
],
"description": "Directory where Codex writes log files, for example `codex-tui.log`. Defaults to `$CODEX_HOME/log`."
},
"mcp_oauth_callback_port": {
"description": "Optional fixed port for the local HTTP callback server used during MCP OAuth login. When unset, Codex will bind to an ephemeral port chosen by the OS.",
"format": "uint16",
+1 -1
View File
@@ -8,7 +8,7 @@ use serde::Serialize;
const ORCHESTRATOR_PROMPT: &str = include_str!("../../templates/agents/orchestrator.md");
/// Default model override used.
// TODO(jif) update when we have something smarter.
const EXPLORER_MODEL: &str = "gpt-5.2-codex";
const EXPLORER_MODEL: &str = "gpt-5.1-codex-mini";
/// Enumerated list of all supported agent roles.
const ALL_ROLES: [AgentRole; 3] = [
+19 -11
View File
@@ -16,6 +16,7 @@ use std::sync::Mutex;
use std::sync::RwLock;
use codex_app_server_protocol::AuthMode as ApiAuthMode;
use codex_otel::TelemetryAuthMode;
use codex_protocol::config_types::ForcedLoginMethod;
pub use crate::auth::storage::AuthCredentialsStoreMode;
@@ -47,6 +48,15 @@ pub enum AuthMode {
Chatgpt,
}
impl From<AuthMode> for TelemetryAuthMode {
fn from(mode: AuthMode) -> Self {
match mode {
AuthMode::ApiKey => TelemetryAuthMode::ApiKey,
AuthMode::Chatgpt => TelemetryAuthMode::Chatgpt,
}
}
}
/// Authentication mechanism used by the current user.
#[derive(Debug, Clone)]
pub enum CodexAuth {
@@ -186,7 +196,7 @@ impl CodexAuth {
load_auth(codex_home, false, auth_credentials_store_mode)
}
pub fn internal_auth_mode(&self) -> AuthMode {
pub fn auth_mode(&self) -> AuthMode {
match self {
Self::ApiKey(_) => AuthMode::ApiKey,
Self::Chatgpt(_) | Self::ChatgptAuthTokens(_) => AuthMode::Chatgpt,
@@ -202,14 +212,14 @@ impl CodexAuth {
}
pub fn is_chatgpt_auth(&self) -> bool {
self.internal_auth_mode() == AuthMode::Chatgpt
self.auth_mode() == AuthMode::Chatgpt
}
pub fn is_external_chatgpt_tokens(&self) -> bool {
matches!(self, Self::ChatgptAuthTokens(_))
}
/// Returns `None` is `is_internal_auth_mode() != AuthMode::ApiKey`.
/// Returns `None` if `auth_mode() != AuthMode::ApiKey`.
pub fn api_key(&self) -> Option<&str> {
match self {
Self::ApiKey(auth) => Some(auth.api_key.as_str()),
@@ -434,7 +444,7 @@ pub fn enforce_login_restrictions(config: &Config) -> std::io::Result<()> {
};
if let Some(required_method) = config.forced_login_method {
let method_violation = match (required_method, auth.internal_auth_mode()) {
let method_violation = match (required_method, auth.auth_mode()) {
(ForcedLoginMethod::Api, AuthMode::ApiKey) => None,
(ForcedLoginMethod::Chatgpt, AuthMode::Chatgpt) => None,
(ForcedLoginMethod::Api, AuthMode::Chatgpt) => Some(
@@ -1158,14 +1168,12 @@ impl AuthManager {
Ok(removed)
}
pub fn get_auth_mode(&self) -> Option<ApiAuthMode> {
pub fn get_api_auth_mode(&self) -> Option<ApiAuthMode> {
self.auth_cached().as_ref().map(CodexAuth::api_auth_mode)
}
pub fn get_internal_auth_mode(&self) -> Option<AuthMode> {
self.auth_cached()
.as_ref()
.map(CodexAuth::internal_auth_mode)
pub fn auth_mode(&self) -> Option<AuthMode> {
self.auth_cached().as_ref().map(CodexAuth::auth_mode)
}
async fn refresh_if_stale(&self, auth: &CodexAuth) -> Result<bool, RefreshTokenError> {
@@ -1373,7 +1381,7 @@ mod tests {
.unwrap()
.unwrap();
assert_eq!(None, auth.api_key());
assert_eq!(AuthMode::Chatgpt, auth.internal_auth_mode());
assert_eq!(AuthMode::Chatgpt, auth.auth_mode());
let auth_dot_json = auth
.get_current_auth_json()
@@ -1418,7 +1426,7 @@ mod tests {
let auth = super::load_auth(dir.path(), false, AuthCredentialsStoreMode::File)
.unwrap()
.unwrap();
assert_eq!(auth.internal_auth_mode(), AuthMode::ApiKey);
assert_eq!(auth.auth_mode(), AuthMode::ApiKey);
assert_eq!(auth.api_key(), Some("sk-test-key"));
assert!(auth.get_token_data().is_err());
+35 -37
View File
@@ -4,9 +4,9 @@
//! configuration and state needed to talk to a provider (auth, provider selection, conversation id,
//! and feature-gated request behavior).
//!
//! Per-turn settings (model selection, reasoning controls, telemetry context, web search
//! eligibility, and turn metadata) are passed explicitly to streaming and unary methods so that the
//! turn lifetime is visible at the call site.
//! Per-turn settings (model selection, reasoning controls, telemetry context, and turn metadata)
//! are passed explicitly to streaming and unary methods so that the turn lifetime is visible at the
//! call site.
//!
//! A [`ModelClientSession`] is created per turn and is used to stream one or more Responses API
//! requests during that turn. It caches a Responses WebSocket connection (opened lazily) and
@@ -82,7 +82,8 @@ use crate::model_provider_info::ModelProviderInfo;
use crate::model_provider_info::WireApi;
use crate::tools::spec::create_tools_json_for_responses_api;
pub const WEB_SEARCH_ELIGIBLE_HEADER: &str = "x-oai-web-search-eligible";
pub const OPENAI_BETA_HEADER: &str = "OpenAI-Beta";
pub const OPENAI_BETA_RESPONSES_WEBSOCKETS: &str = "responses_websockets=2026-02-04";
pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
pub const X_CODEX_TURN_METADATA_HEADER: &str = "x-codex-turn-metadata";
pub const X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER: &str =
@@ -115,9 +116,8 @@ struct ModelClientState {
/// WebSocket fallback is session-scoped: once a turn activates the HTTP fallback, subsequent turns
/// will also use HTTP for the remainder of the session.
///
/// Turn-scoped settings (model selection, reasoning controls, telemetry context, web search
/// eligibility, and turn metadata) are passed explicitly to the relevant methods to keep turn
/// lifetime visible at the call site.
/// Turn-scoped settings (model selection, reasoning controls, telemetry context, and turn metadata)
/// are passed explicitly to the relevant methods to keep turn lifetime visible at the call site.
///
/// This type is cheap to clone.
#[derive(Debug, Clone)]
@@ -225,7 +225,7 @@ impl ModelClient {
let api_provider = self
.state
.provider
.to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?;
.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.state.provider)?;
let transport = ReqwestTransport::new(build_reqwest_client());
let request_telemetry = Self::build_request_telemetry(otel_manager);
@@ -271,7 +271,7 @@ impl ModelClient {
let api_provider = self
.state
.provider
.to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?;
.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))?;
let api_auth = auth_provider_from_auth(auth, &self.state.provider)?;
let transport = ReqwestTransport::new(build_reqwest_client());
let request_telemetry = Self::build_request_telemetry(otel_manager);
@@ -349,7 +349,6 @@ impl ModelClientSession {
model_info: &ModelInfo,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
web_search_eligible: bool,
turn_metadata_header: Option<&str>,
compression: Compression,
) -> ApiResponsesOptions {
@@ -404,7 +403,6 @@ impl ModelClientSession {
session_source: Some(self.client.state.session_source.clone()),
extra_headers: build_responses_headers(
self.client.state.beta_features_header.as_deref(),
web_search_eligible,
Some(&self.turn_state),
turn_metadata_header.as_ref(),
),
@@ -481,14 +479,8 @@ impl ModelClientSession {
};
if needs_new {
let mut headers = options.extra_headers.clone();
headers.extend(build_conversation_headers(options.conversation_id.clone()));
if self.client.state.include_timing_metrics {
headers.insert(
X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER,
HeaderValue::from_static("true"),
);
}
let headers =
build_websocket_connect_headers(options, self.client.state.include_timing_metrics);
let websocket_telemetry = Self::build_websocket_telemetry(otel_manager);
let new_conn: ApiWebSocketConnection =
ApiWebSocketResponsesClient::new(api_provider, api_auth)
@@ -529,7 +521,6 @@ impl ModelClientSession {
otel_manager: &OtelManager,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
web_search_eligible: bool,
turn_metadata_header: Option<&str>,
) -> Result<ResponseStream> {
if let Some(path) = &*CODEX_RS_SSE_FIXTURE {
@@ -557,7 +548,7 @@ impl ModelClientSession {
.client
.state
.provider
.to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?;
.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.client.state.provider)?;
let transport = ReqwestTransport::new(build_reqwest_client());
let (request_telemetry, sse_telemetry) = Self::build_streaming_telemetry(otel_manager);
@@ -571,7 +562,6 @@ impl ModelClientSession {
model_info,
effort,
summary,
web_search_eligible,
turn_metadata_header,
compression,
);
@@ -604,7 +594,6 @@ impl ModelClientSession {
otel_manager: &OtelManager,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
web_search_eligible: bool,
turn_metadata_header: Option<&str>,
) -> Result<ResponseStream> {
let auth_manager = self.client.state.auth_manager.clone();
@@ -622,7 +611,7 @@ impl ModelClientSession {
.client
.state
.provider
.to_api_provider(auth.as_ref().map(CodexAuth::internal_auth_mode))?;
.to_api_provider(auth.as_ref().map(CodexAuth::auth_mode))?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.client.state.provider)?;
let compression = self.responses_request_compression(auth.as_ref());
@@ -631,7 +620,6 @@ impl ModelClientSession {
model_info,
effort,
summary,
web_search_eligible,
turn_metadata_header,
compression,
);
@@ -687,9 +675,9 @@ impl ModelClientSession {
/// Streams a single model request within the current turn.
///
/// The caller is responsible for passing per-turn settings explicitly (model selection,
/// reasoning settings, telemetry context, web search eligibility, and turn metadata). This
/// method will prefer the Responses WebSocket transport when enabled and healthy, and will
/// fall back to the HTTP Responses API transport otherwise.
/// reasoning settings, telemetry context, and turn metadata). This method will prefer the
/// Responses WebSocket transport when enabled and healthy, and will fall back to the HTTP
/// Responses API transport otherwise.
pub async fn stream(
&mut self,
prompt: &Prompt,
@@ -697,7 +685,6 @@ impl ModelClientSession {
otel_manager: &OtelManager,
effort: Option<ReasoningEffortConfig>,
summary: ReasoningSummaryConfig,
web_search_eligible: bool,
turn_metadata_header: Option<&str>,
) -> Result<ResponseStream> {
let wire_api = self.client.state.provider.wire_api;
@@ -713,7 +700,6 @@ impl ModelClientSession {
otel_manager,
effort,
summary,
web_search_eligible,
turn_metadata_header,
)
.await
@@ -724,7 +710,6 @@ impl ModelClientSession {
otel_manager,
effort,
summary,
web_search_eligible,
turn_metadata_header,
)
.await
@@ -772,12 +757,10 @@ fn build_api_prompt(prompt: &Prompt, instructions: String, tools_json: Vec<Value
/// These headers implement Codex-specific conventions:
///
/// - `x-codex-beta-features`: comma-separated beta feature keys enabled for the session.
/// - `x-oai-web-search-eligible`: whether this turn is allowed to use web search.
/// - `x-codex-turn-state`: sticky routing token captured earlier in the turn.
/// - `x-codex-turn-metadata`: optional per-turn metadata for observability.
fn build_responses_headers(
beta_features_header: Option<&str>,
web_search_eligible: bool,
turn_state: Option<&Arc<OnceLock<String>>>,
turn_metadata_header: Option<&HeaderValue>,
) -> ApiHeaderMap {
@@ -788,10 +771,6 @@ fn build_responses_headers(
{
headers.insert("x-codex-beta-features", header_value);
}
headers.insert(
WEB_SEARCH_ELIGIBLE_HEADER,
HeaderValue::from_static(if web_search_eligible { "true" } else { "false" }),
);
if let Some(turn_state) = turn_state
&& let Some(state) = turn_state.get()
&& let Ok(header_value) = HeaderValue::from_str(state)
@@ -804,6 +783,25 @@ fn build_responses_headers(
headers
}
fn build_websocket_connect_headers(
options: &ApiResponsesOptions,
include_timing_metrics: bool,
) -> ApiHeaderMap {
let mut headers = options.extra_headers.clone();
headers.extend(build_conversation_headers(options.conversation_id.clone()));
headers.insert(
OPENAI_BETA_HEADER,
HeaderValue::from_static(OPENAI_BETA_RESPONSES_WEBSOCKETS),
);
if include_timing_metrics {
headers.insert(
X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER,
HeaderValue::from_static("true"),
);
}
headers
}
fn map_response_stream<S>(api_stream: S, otel_manager: OtelManager) -> ResponseStream
where
S: futures::Stream<Item = std::result::Result<ResponseEvent, ApiError>>
+390 -73
View File
@@ -24,6 +24,9 @@ use crate::features::FEATURES;
use crate::features::Feature;
use crate::features::Features;
use crate::features::maybe_push_unstable_features_warning;
use crate::hooks::HookEvent;
use crate::hooks::HookEventAfterAgent;
use crate::hooks::Hooks;
use crate::models_manager::manager::ModelsManager;
use crate::parse_command::parse_command;
use crate::parse_turn_item;
@@ -35,7 +38,6 @@ use crate::stream_events_utils::last_assistant_message_from_item;
use crate::terminal;
use crate::truncate::TruncationPolicy;
use crate::turn_metadata::build_turn_metadata_header;
use crate::user_notification::UserNotifier;
use crate::util::error_or_panic;
use async_channel::Receiver;
use async_channel::Sender;
@@ -116,6 +118,13 @@ use crate::error::CodexErr;
use crate::error::Result as CodexResult;
#[cfg(test)]
use crate::exec::StreamOutput;
#[derive(Debug, PartialEq)]
pub enum SteerInputError {
NoActiveTurn(Vec<UserInput>),
ExpectedTurnMismatch { expected: String, actual: String },
EmptyInput,
}
use crate::exec_policy::ExecPolicyUpdateError;
use crate::feedback_tags;
use crate::file_watcher::FileWatcher;
@@ -201,11 +210,11 @@ use crate::tools::spec::ToolsConfig;
use crate::tools::spec::ToolsConfigParams;
use crate::turn_diff_tracker::TurnDiffTracker;
use crate::unified_exec::UnifiedExecProcessManager;
use crate::user_notification::UserNotification;
use crate::util::backoff;
use crate::windows_sandbox::WindowsSandboxLevelExt;
use codex_async_utils::OrCancelExt;
use codex_otel::OtelManager;
use codex_otel::TelemetryAuthMode;
use codex_protocol::config_types::CollaborationMode;
use codex_protocol::config_types::Personality;
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
@@ -282,7 +291,7 @@ impl Codex {
let enabled_skills = loaded_skills.enabled_skills();
let user_instructions = get_user_instructions(&config, Some(&enabled_skills)).await;
let exec_policy = ExecPolicyManager::load(&config.features, &config.config_layer_stack)
let exec_policy = ExecPolicyManager::load(&config.config_layer_stack)
.await
.map_err(|err| CodexErr::Fatal(format!("failed to load rules: {err}")))?;
@@ -453,6 +462,14 @@ impl Codex {
Ok(event)
}
pub async fn steer_input(
&self,
input: Vec<UserInput>,
expected_turn_id: Option<&str>,
) -> Result<String, SteerInputError> {
self.session.steer_input(input, expected_turn_id).await
}
pub(crate) async fn agent_status(&self) -> AgentStatus {
self.agent_status.borrow().clone()
}
@@ -717,7 +734,6 @@ impl Session {
per_turn_config.personality = session_configuration.personality;
per_turn_config.web_search_mode = Some(resolve_web_search_mode_for_turn(
per_turn_config.web_search_mode,
session_configuration.provider.is_azure_responses_endpoint(),
session_configuration.sandbox_policy.get(),
));
per_turn_config.features = config.features.clone();
@@ -945,16 +961,25 @@ impl Session {
}),
});
}
for message in &config.startup_warnings {
post_session_configured_events.push(Event {
id: "".to_owned(),
msg: EventMsg::Warning(WarningEvent {
message: message.clone(),
}),
});
}
maybe_push_unstable_features_warning(&config, &mut post_session_configured_events);
let auth = auth.as_ref();
let auth_mode = auth.map(CodexAuth::auth_mode).map(TelemetryAuthMode::from);
let otel_manager = OtelManager::new(
conversation_id,
session_configuration.collaboration_mode.model(),
session_configuration.collaboration_mode.model(),
auth.and_then(CodexAuth::get_account_id),
auth.and_then(CodexAuth::get_account_email),
auth.map(CodexAuth::api_auth_mode),
auth_mode,
config.otel.log_user_prompt,
terminal::user_agent(),
session_configuration.session_source.clone(),
@@ -1015,7 +1040,7 @@ impl Session {
Arc::clone(&config),
Arc::clone(&auth_manager),
),
notifier: UserNotifier::new(config.notify.clone()),
hooks: Hooks::new(config.as_ref()),
rollout: Mutex::new(rollout_recorder),
user_shell: Arc::new(default_shell),
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
@@ -1090,6 +1115,12 @@ impl Session {
sandbox_cwd: session_configuration.cwd.clone(),
use_linux_sandbox_bwrap: config.features.enabled(Feature::UseLinuxSandboxBwrap),
};
let mut required_mcp_servers: Vec<String> = mcp_servers
.iter()
.filter(|(_, server)| server.enabled && server.required)
.map(|(name, _)| name.clone())
.collect();
required_mcp_servers.sort();
let cancel_token = sess.mcp_startup_cancellation_token().await;
sess.services
@@ -1105,6 +1136,25 @@ impl Session {
sandbox_state,
)
.await;
if !required_mcp_servers.is_empty() {
let failures = sess
.services
.mcp_connection_manager
.read()
.await
.required_startup_failures(&required_mcp_servers)
.await;
if !failures.is_empty() {
let details = failures
.iter()
.map(|failure| format!("{}: {}", failure.server, failure.error))
.collect::<Vec<_>>()
.join("; ");
return Err(anyhow::anyhow!(
"required MCP servers failed to initialize: {details}"
));
}
}
// record_initial_history can emit events. We record only after the SessionConfiguredEvent is emitted.
sess.record_initial_history(initial_history).await;
@@ -1176,32 +1226,26 @@ impl Session {
{
let mut state = self.state.lock().await;
state.initial_context_seeded = false;
state.pending_resume_previous_model = None;
}
// If resuming, warn when the last recorded model differs from the current one.
if let Some(prev) = rollout_items.iter().rev().find_map(|it| {
if let RolloutItem::TurnContext(ctx) = it {
Some(ctx.model.as_str())
} else {
None
}
}) {
let curr = turn_context.model_info.slug.as_str();
if prev != curr {
warn!(
"resuming session with different model: previous={prev}, current={curr}"
);
self.send_event(
&turn_context,
EventMsg::Warning(WarningEvent {
message: format!(
"This session was recorded with model `{prev}` but is resuming with `{curr}`. \
let curr = turn_context.model_info.slug.as_str();
if let Some(prev) = Self::last_model_name(&rollout_items, curr) {
warn!("resuming session with different model: previous={prev}, current={curr}");
self.send_event(
&turn_context,
EventMsg::Warning(WarningEvent {
message: format!(
"This session was recorded with model `{prev}` but is resuming with `{curr}`. \
Consider switching back to `{prev}` as it may affect Codex performance."
),
}),
)
.await;
}
),
}),
)
.await;
let mut state = self.state.lock().await;
state.pending_resume_previous_model = Some(prev.to_string());
}
// Always add response items to conversation history
@@ -1260,6 +1304,21 @@ impl Session {
}
}
fn last_model_name<'a>(rollout_items: &'a [RolloutItem], current: &str) -> Option<&'a str> {
let previous = rollout_items.iter().rev().find_map(|it| {
if let RolloutItem::TurnContext(ctx) = it {
Some(ctx.model.as_str())
} else {
None
}
})?;
if previous == current {
None
} else {
Some(previous)
}
}
fn last_token_info_from_rollout(rollout_items: &[RolloutItem]) -> Option<TokenUsageInfo> {
rollout_items.iter().rev().find_map(|item| match item {
RolloutItem::EventMsg(EventMsg::TokenCount(ev)) => ev.info.clone(),
@@ -1267,6 +1326,11 @@ impl Session {
})
}
async fn take_pending_resume_previous_model(&self) -> Option<String> {
let mut state = self.state.lock().await;
state.pending_resume_previous_model.take()
}
pub(crate) async fn update_settings(
&self,
updates: SessionSettingsUpdate,
@@ -1460,6 +1524,9 @@ impl Session {
return None;
}
let previous = previous?;
if next.model_info.slug != previous.model_info.slug {
return None;
}
// if a personality is specified and it's different from the previous one, build a personality update item
if let Some(personality) = next.personality
@@ -1498,9 +1565,30 @@ impl Session {
}
}
fn build_model_instructions_update_item(
&self,
previous: Option<&Arc<TurnContext>>,
resumed_model: Option<&str>,
next: &TurnContext,
) -> Option<ResponseItem> {
let previous_model =
resumed_model.or_else(|| previous.map(|prev| prev.model_info.slug.as_str()))?;
if previous_model == next.model_info.slug {
return None;
}
let model_instructions = next.model_info.get_model_instructions(next.personality);
if model_instructions.is_empty() {
return None;
}
Some(DeveloperInstructions::model_switch_message(model_instructions).into())
}
fn build_settings_update_items(
&self,
previous_context: Option<&Arc<TurnContext>>,
resumed_model: Option<&str>,
current_context: &TurnContext,
) -> Vec<ResponseItem> {
let mut update_items = Vec::new();
@@ -1519,6 +1607,13 @@ impl Session {
{
update_items.push(collaboration_mode_item);
}
if let Some(model_instructions_item) = self.build_model_instructions_update_item(
previous_context,
resumed_model,
current_context,
) {
update_items.push(model_instructions_item);
}
if let Some(personality_item) =
self.build_personality_update_item(previous_context, current_context)
{
@@ -1611,7 +1706,6 @@ impl Session {
&self,
amendment: &ExecPolicyAmendment,
) -> Result<(), ExecPolicyUpdateError> {
let features = self.features.clone();
let codex_home = self
.state
.lock()
@@ -1620,11 +1714,6 @@ impl Session {
.codex_home()
.clone();
if !features.enabled(Feature::ExecPolicy) {
error!("attempted to append execpolicy rule while execpolicy feature is disabled");
return Err(ExecPolicyUpdateError::FeatureDisabled);
}
self.services
.exec_policy
.append_amendment_and_update(&codex_home, amendment)
@@ -1641,6 +1730,17 @@ impl Session {
.map(|task| Arc::clone(&task.turn_context))
}
async fn active_turn_context_and_cancellation_token(
&self,
) -> Option<(Arc<TurnContext>, CancellationToken)> {
let active = self.active_turn.lock().await;
let (_, task) = active.as_ref()?.tasks.first()?;
Some((
Arc::clone(&task.turn_context),
task.cancellation_token.child_token(),
))
}
pub(crate) async fn record_execpolicy_amendment_message(
&self,
sub_id: &str,
@@ -2091,10 +2191,10 @@ impl Session {
}
pub(crate) async fn recompute_token_usage(&self, turn_context: &TurnContext) {
let Some(estimated_total_tokens) = self
.clone_history()
.await
.estimate_token_count(turn_context)
let history = self.clone_history().await;
let base_instructions = self.get_base_instructions().await;
let Some(estimated_total_tokens) =
history.estimate_token_count_with_base_instructions(&base_instructions)
else {
return;
};
@@ -2269,17 +2369,39 @@ impl Session {
.await;
}
/// Returns the input if there was no task running to inject into
pub async fn inject_input(&self, input: Vec<UserInput>) -> Result<(), Vec<UserInput>> {
let mut active = self.active_turn.lock().await;
match active.as_mut() {
Some(at) => {
let mut ts = at.turn_state.lock().await;
ts.push_pending_input(input.into());
Ok(())
}
None => Err(input),
/// Inject additional user input into the currently active turn.
///
/// Returns the active turn id when accepted.
pub async fn steer_input(
&self,
input: Vec<UserInput>,
expected_turn_id: Option<&str>,
) -> Result<String, SteerInputError> {
if input.is_empty() {
return Err(SteerInputError::EmptyInput);
}
let mut active = self.active_turn.lock().await;
let Some(active_turn) = active.as_mut() else {
return Err(SteerInputError::NoActiveTurn(input));
};
let Some((active_turn_id, _)) = active_turn.tasks.first() else {
return Err(SteerInputError::NoActiveTurn(input));
};
if let Some(expected_turn_id) = expected_turn_id
&& expected_turn_id != active_turn_id
{
return Err(SteerInputError::ExpectedTurnMismatch {
expected: expected_turn_id.to_string(),
actual: active_turn_id.clone(),
});
}
let mut turn_state = active_turn.turn_state.lock().await;
turn_state.push_pending_input(input.into());
Ok(active_turn_id.clone())
}
/// Returns the input if there was no task running to inject into
@@ -2394,8 +2516,8 @@ impl Session {
}
}
pub(crate) fn notifier(&self) -> &UserNotifier {
&self.services.notifier
pub(crate) fn hooks(&self) -> &Hooks {
&self.services.hooks
}
pub(crate) fn user_shell(&self) -> Arc<shell::Shell> {
@@ -2658,6 +2780,7 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
mod handlers {
use crate::codex::Session;
use crate::codex::SessionSettingsUpdate;
use crate::codex::SteerInputError;
use crate::codex::TurnContext;
use crate::codex::spawn_review_thread;
@@ -2671,7 +2794,9 @@ mod handlers {
use crate::tasks::CompactTask;
use crate::tasks::RegularTask;
use crate::tasks::UndoTask;
use crate::tasks::UserShellCommandMode;
use crate::tasks::UserShellCommandTask;
use crate::tasks::execute_user_shell_command;
use codex_protocol::custom_prompts::CustomPrompt;
use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
@@ -2790,11 +2915,15 @@ mod handlers {
};
current_context.otel_manager.user_prompt(&items);
// Attempt to inject input into current task
if let Err(items) = sess.inject_input(items).await {
// Attempt to inject input into current task.
if let Err(SteerInputError::NoActiveTurn(items)) = sess.steer_input(items, None).await {
sess.seed_initial_context_if_needed(&current_context).await;
let update_items =
sess.build_settings_update_items(previous_context.as_ref(), &current_context);
let resumed_model = sess.take_pending_resume_previous_model().await;
let update_items = sess.build_settings_update_items(
previous_context.as_ref(),
resumed_model.as_deref(),
&current_context,
);
if !update_items.is_empty() {
sess.record_conversation_items(&current_context, &update_items)
.await;
@@ -2814,6 +2943,23 @@ mod handlers {
command: String,
previous_context: &mut Option<Arc<TurnContext>>,
) {
if let Some((turn_context, cancellation_token)) =
sess.active_turn_context_and_cancellation_token().await
{
let session = Arc::clone(sess);
tokio::spawn(async move {
execute_user_shell_command(
session,
turn_context,
command,
cancellation_token,
UserShellCommandMode::ActiveTurnAuxiliary,
)
.await;
});
return;
}
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
sess.spawn_task(
Arc::clone(&turn_context),
@@ -3697,14 +3843,21 @@ pub(crate) async fn run_turn(
if !needs_follow_up {
last_agent_message = sampling_request_last_agent_message;
sess.notifier()
.notify(&UserNotification::AgentTurnComplete {
thread_id: sess.conversation_id.to_string(),
turn_id: turn_context.sub_id.clone(),
cwd: turn_context.cwd.display().to_string(),
input_messages: sampling_request_input_messages,
last_assistant_message: last_agent_message.clone(),
});
sess.hooks()
.dispatch(crate::hooks::HookPayload {
session_id: sess.conversation_id,
cwd: turn_context.cwd.clone(),
triggered_at: chrono::Utc::now(),
hook_event: HookEvent::AfterAgent {
event: HookEventAfterAgent {
thread_id: sess.conversation_id,
turn_id: turn_context.sub_id.clone(),
input_messages: sampling_request_input_messages,
last_assistant_message: last_agent_message.clone(),
},
},
})
.await;
break;
}
continue;
@@ -3743,7 +3896,7 @@ pub(crate) async fn run_turn(
}
async fn run_auto_compact(sess: &Arc<Session>, turn_context: &Arc<TurnContext>) {
if should_use_remote_compact_task(sess.as_ref(), &turn_context.provider) {
if should_use_remote_compact_task(&turn_context.provider) {
run_inline_remote_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await;
} else {
run_inline_auto_compact_task(Arc::clone(sess), Arc::clone(turn_context)).await;
@@ -4431,15 +4584,11 @@ async fn try_run_sampling_request(
approval_policy = turn_context.approval_policy,
sandbox_policy = turn_context.sandbox_policy,
effort = turn_context.reasoning_effort,
auth_mode = sess.services.auth_manager.get_auth_mode(),
auth_mode = sess.services.auth_manager.auth_mode(),
features = sess.features.enabled_features(),
);
sess.persist_rollout_items(&[rollout_item]).await;
let web_search_eligible = !matches!(
turn_context.config.web_search_mode,
Some(WebSearchMode::Disabled)
);
let mut stream = client_session
.stream(
prompt,
@@ -4447,7 +4596,6 @@ async fn try_run_sampling_request(
&turn_context.otel_manager,
turn_context.reasoning_effort,
turn_context.reasoning_summary,
web_search_eligible,
turn_metadata_header,
)
.instrument(trace_span!("stream_request"))
@@ -4755,8 +4903,10 @@ mod tests {
use crate::tools::registry::ToolHandler;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_app_server_protocol::AppInfo;
use codex_app_server_protocol::AuthMode;
use codex_otel::TelemetryAuthMode;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use std::path::Path;
use std::time::Duration;
@@ -5035,6 +5185,46 @@ mod tests {
assert_eq!(actual, Some(info2));
}
#[tokio::test]
async fn recompute_token_usage_uses_session_base_instructions() {
let (session, turn_context) = make_session_and_context().await;
let override_instructions = "SESSION_OVERRIDE_INSTRUCTIONS_ONLY".repeat(120);
{
let mut state = session.state.lock().await;
state.session_configuration.base_instructions = override_instructions.clone();
}
let item = user_message("hello");
session
.record_into_history(std::slice::from_ref(&item), &turn_context)
.await;
let history = session.clone_history().await;
let session_base_instructions = BaseInstructions {
text: override_instructions,
};
let expected_tokens = history
.estimate_token_count_with_base_instructions(&session_base_instructions)
.expect("estimate with session base instructions");
let model_estimated_tokens = history
.estimate_token_count(&turn_context)
.expect("estimate with model instructions");
assert_ne!(expected_tokens, model_estimated_tokens);
session.recompute_token_usage(&turn_context).await;
let actual_tokens = session
.state
.lock()
.await
.token_info()
.expect("token info")
.last_token_usage
.total_tokens;
assert_eq!(actual_tokens, expected_tokens.max(0));
}
#[tokio::test]
async fn record_initial_history_reconstructs_forked_transcript() {
let (session, turn_context) = make_session_and_context().await;
@@ -5523,7 +5713,7 @@ mod tests {
model_info.slug.as_str(),
None,
Some("test@test.com".to_string()),
Some(AuthMode::Chatgpt),
Some(TelemetryAuthMode::Chatgpt),
false,
"test".to_string(),
session_source,
@@ -5603,7 +5793,7 @@ mod tests {
Arc::clone(&config),
Arc::clone(&auth_manager),
),
notifier: UserNotifier::new(None),
hooks: Hooks::new(&config),
rollout: Mutex::new(None),
user_shell: Arc::new(default_user_shell()),
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
@@ -5733,7 +5923,7 @@ mod tests {
Arc::clone(&config),
Arc::clone(&auth_manager),
),
notifier: UserNotifier::new(None),
hooks: Hooks::new(&config),
rollout: Mutex::new(None),
user_shell: Arc::new(default_user_shell()),
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
@@ -5954,6 +6144,133 @@ mod tests {
assert!(rx.try_recv().is_err());
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn task_finish_persists_leftover_pending_input() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
sess.spawn_task(
Arc::clone(&tc),
input,
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: false,
},
)
.await;
sess.inject_response_items(vec![ResponseInputItem::Message {
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "late pending input".to_string(),
}],
}])
.await
.expect("inject pending input into active turn");
sess.on_task_finished(Arc::clone(&tc), None).await;
let history = sess.clone_history().await;
let expected = ResponseItem::Message {
id: None,
role: "user".to_string(),
content: vec![ContentItem::InputText {
text: "late pending input".to_string(),
}],
end_turn: None,
phase: None,
};
assert!(
history.raw_items().iter().any(|item| item == &expected),
"expected pending input to be persisted into history on turn completion"
);
}
#[tokio::test]
async fn steer_input_requires_active_turn() {
let (sess, _tc, _rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
}];
let err = sess
.steer_input(input, None)
.await
.expect_err("steering without active turn should fail");
assert!(matches!(err, SteerInputError::NoActiveTurn(_)));
}
#[tokio::test]
async fn steer_input_enforces_expected_turn_id() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
sess.spawn_task(
Arc::clone(&tc),
input,
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: false,
},
)
.await;
let steer_input = vec![UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
}];
let err = sess
.steer_input(steer_input, Some("different-turn-id"))
.await
.expect_err("mismatched expected turn id should fail");
match err {
SteerInputError::ExpectedTurnMismatch { expected, actual } => {
assert_eq!(
(expected, actual),
("different-turn-id".to_string(), tc.sub_id.clone())
);
}
other => panic!("unexpected error: {other:?}"),
}
}
#[tokio::test]
async fn steer_input_returns_active_turn_id() {
let (sess, tc, _rx) = make_session_and_context_with_rx().await;
let input = vec![UserInput::Text {
text: "hello".to_string(),
text_elements: Vec::new(),
}];
sess.spawn_task(
Arc::clone(&tc),
input,
NeverEndingTask {
kind: TaskKind::Regular,
listen_to_cancellation_token: false,
},
)
.await;
let steer_input = vec![UserInput::Text {
text: "steer".to_string(),
text_elements: Vec::new(),
}];
let turn_id = sess
.steer_input(steer_input, Some(&tc.sub_id))
.await
.expect("steering with matching expected turn id should succeed");
assert_eq!(turn_id, tc.sub_id);
assert!(sess.has_pending_input().await);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn abort_review_task_emits_exited_then_aborted_and_records_history() {
let (sess, tc, rx) = make_session_and_context_with_rx().await;
+10
View File
@@ -1,5 +1,6 @@
use crate::agent::AgentStatus;
use crate::codex::Codex;
use crate::codex::SteerInputError;
use crate::error::Result as CodexResult;
use crate::protocol::Event;
use crate::protocol::Op;
@@ -9,6 +10,7 @@ use codex_protocol::openai_models::ReasoningEffort;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::user_input::UserInput;
use std::path::PathBuf;
use tokio::sync::watch;
@@ -45,6 +47,14 @@ impl CodexThread {
self.codex.submit(op).await
}
pub async fn steer_input(
&self,
input: Vec<UserInput>,
expected_turn_id: Option<&str>,
) -> Result<String, SteerInputError> {
self.codex.steer_input(input, expected_turn_id).await
}
/// Use sparingly: this is intended to be removed soon.
pub async fn submit_with_id(&self, sub: Submission) -> CodexResult<()> {
self.codex.submit_with_id(sub).await
+2 -12
View File
@@ -9,7 +9,6 @@ use crate::codex::TurnContext;
use crate::codex::get_last_assistant_message_from_turn;
use crate::error::CodexErr;
use crate::error::Result as CodexResult;
use crate::features::Feature;
use crate::protocol::CompactedItem;
use crate::protocol::EventMsg;
use crate::protocol::TurnContextItem;
@@ -20,7 +19,6 @@ use crate::truncate::TruncationPolicy;
use crate::truncate::approx_token_count;
use crate::truncate::truncate_text;
use crate::util::backoff;
use codex_protocol::config_types::WebSearchMode;
use codex_protocol::items::ContextCompactionItem;
use codex_protocol::items::TurnItem;
use codex_protocol::models::ContentItem;
@@ -35,11 +33,8 @@ pub const SUMMARIZATION_PROMPT: &str = include_str!("../templates/compact/prompt
pub const SUMMARY_PREFIX: &str = include_str!("../templates/compact/summary_prefix.md");
const COMPACT_USER_MESSAGE_MAX_TOKENS: usize = 20_000;
pub(crate) fn should_use_remote_compact_task(
session: &Session,
provider: &ModelProviderInfo,
) -> bool {
provider.is_openai() && session.enabled(Feature::RemoteCompaction)
pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bool {
provider.is_openai()
}
pub(crate) async fn run_inline_auto_compact_task(
@@ -352,10 +347,6 @@ async fn drain_to_completed(
turn_metadata_header: Option<&str>,
prompt: &Prompt,
) -> CodexResult<()> {
let web_search_eligible = !matches!(
turn_context.config.web_search_mode,
Some(WebSearchMode::Disabled)
);
let mut stream = client_session
.stream(
prompt,
@@ -363,7 +354,6 @@ async fn drain_to_completed(
&turn_context.otel_manager,
turn_context.reasoning_effort,
turn_context.reasoning_summary,
web_search_eligible,
turn_metadata_header,
)
.await?;
+10 -4
View File
@@ -12,6 +12,7 @@ use crate::protocol::RolloutItem;
use crate::protocol::TurnStartedEvent;
use codex_protocol::items::ContextCompactionItem;
use codex_protocol::items::TurnItem;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ResponseItem;
use tracing::info;
@@ -49,8 +50,12 @@ async fn run_remote_compact_task_inner_impl(
sess.emit_turn_item_started(turn_context, &compaction_item)
.await;
let mut history = sess.clone_history().await;
let deleted_items =
trim_function_call_history_to_fit_context_window(&mut history, turn_context.as_ref());
let base_instructions = sess.get_base_instructions().await;
let deleted_items = trim_function_call_history_to_fit_context_window(
&mut history,
turn_context.as_ref(),
&base_instructions,
);
if deleted_items > 0 {
info!(
turn_id = %turn_context.sub_id,
@@ -71,7 +76,7 @@ async fn run_remote_compact_task_inner_impl(
input: history.for_prompt(),
tools: vec![],
parallel_tool_calls: false,
base_instructions: sess.get_base_instructions().await,
base_instructions,
personality: turn_context.personality,
output_schema: None,
};
@@ -107,6 +112,7 @@ async fn run_remote_compact_task_inner_impl(
fn trim_function_call_history_to_fit_context_window(
history: &mut ContextManager,
turn_context: &TurnContext,
base_instructions: &BaseInstructions,
) -> usize {
let mut deleted_items = 0usize;
let Some(context_window) = turn_context.model_context_window() else {
@@ -114,7 +120,7 @@ fn trim_function_call_history_to_fit_context_window(
};
while history
.estimate_token_count(turn_context)
.estimate_token_count_with_base_instructions(base_instructions)
.is_some_and(|estimated_tokens| estimated_tokens > context_window)
{
let Some(last_item) = history.raw_items().last() else {
+27
View File
@@ -55,6 +55,24 @@ pub enum ConfigEdit {
ClearPath { segments: Vec<String> },
}
pub fn status_line_items_edit(items: &[String]) -> ConfigEdit {
if items.is_empty() {
return ConfigEdit::ClearPath {
segments: vec!["tui".to_string(), "status_line".to_string()],
};
}
let mut array = toml_edit::Array::new();
for item in items {
array.push(item.clone());
}
ConfigEdit::SetPath {
segments: vec!["tui".to_string(), "status_line".to_string()],
value: TomlItem::Value(array.into()),
}
}
// TODO(jif) move to a dedicated file
mod document_helpers {
use crate::config::types::McpServerConfig;
@@ -151,6 +169,9 @@ mod document_helpers {
if !config.enabled {
entry["enabled"] = value(false);
}
if config.required {
entry["required"] = value(true);
}
if let Some(timeout) = config.startup_timeout_sec {
entry["startup_timeout_sec"] = value(timeout.as_secs_f64());
}
@@ -1373,6 +1394,7 @@ gpt-5 = "gpt-5.1"
cwd: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -1396,6 +1418,7 @@ gpt-5 = "gpt-5.1"
env_http_headers: None,
},
enabled: false,
required: false,
disabled_reason: None,
startup_timeout_sec: Some(std::time::Duration::from_secs(5)),
tool_timeout_sec: None,
@@ -1462,6 +1485,7 @@ foo = { command = "cmd" }
cwd: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -1507,6 +1531,7 @@ foo = { command = "cmd" } # keep me
cwd: None,
},
enabled: false,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -1551,6 +1576,7 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
cwd: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -1596,6 +1622,7 @@ foo = { command = "cmd" }
cwd: None,
},
enabled: false,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
+168 -44
View File
@@ -1,6 +1,7 @@
use crate::auth::AuthCredentialsStoreMode;
use crate::config::edit::ConfigEdit;
use crate::config::edit::ConfigEditsBuilder;
use crate::config::types::AppsConfigToml;
use crate::config::types::DEFAULT_OTEL_ENVIRONMENT;
use crate::config::types::History;
use crate::config::types::McpServerConfig;
@@ -21,6 +22,7 @@ use crate::config::types::UriBasedFileOpener;
use crate::config_loader::CloudRequirementsLoader;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConstrainedWithSource;
use crate::config_loader::LoaderOverrides;
use crate::config_loader::McpServerIdentity;
use crate::config_loader::McpServerRequirement;
@@ -116,6 +118,9 @@ pub struct Config {
/// requirements).
pub config_layer_stack: ConfigLayerStack,
/// Warnings collected during config load that should be shown on startup.
pub startup_warnings: Vec<String>,
/// Optional override of model selection.
pub model: Option<String>,
@@ -224,6 +229,9 @@ pub struct Config {
/// - `never`: Never use alternate screen (inline mode, preserves scrollback).
pub tui_alternate_screen: AltScreenMode,
/// Ordered list of status line item identifiers for the TUI.
pub tui_status_line: Option<Vec<String>>,
/// The directory that should be treated as the current working directory
/// for the session. All relative paths inside the business-logic layer are
/// resolved against this path.
@@ -271,6 +279,9 @@ pub struct Config {
/// overridden by the `CODEX_HOME` environment variable).
pub codex_home: PathBuf,
/// Directory where Codex writes log files (defaults to `$CODEX_HOME/log`).
pub log_dir: PathBuf,
/// Settings that govern if and what will be written to `~/.codex/history.jsonl`.
pub history: History,
@@ -595,6 +606,41 @@ fn constrain_mcp_servers(
})
}
fn apply_requirement_constrained_value<T>(
field_name: &'static str,
configured_value: T,
constrained_value: &mut ConstrainedWithSource<T>,
startup_warnings: &mut Vec<String>,
) -> std::io::Result<()>
where
T: Clone + std::fmt::Debug + Send + Sync,
{
if let Err(err) = constrained_value.set(configured_value) {
let fallback_value = constrained_value.get().clone();
tracing::warn!(
error = %err,
?fallback_value,
requirement_source = ?constrained_value.source,
"configured value is disallowed by requirements; falling back to required value for {field_name}"
);
let message = format!(
"Configured value for `{field_name}` is disallowed by requirements; falling back to required value {fallback_value:?}. Details: {err}"
);
startup_warnings.push(message);
constrained_value.set(fallback_value).map_err(|fallback_err| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"configured value for `{field_name}` is disallowed by requirements ({err}); fallback to a requirement-compliant value also failed ({fallback_err})"
),
)
})?;
}
Ok(())
}
fn mcp_server_matches_requirement(
requirement: &McpServerRequirement,
server: &McpServerConfig,
@@ -896,6 +942,10 @@ pub struct ConfigToml {
#[serde(default)]
pub history: Option<History>,
/// Directory where Codex writes log files, for example `codex-tui.log`.
/// Defaults to `$CODEX_HOME/log`.
pub log_dir: Option<AbsolutePathBuf>,
/// Optional URI-based file opener. If set, citations to files in the model
/// output will be hyperlinked using the specified URI scheme.
pub file_opener: Option<UriBasedFileOpener>,
@@ -975,6 +1025,10 @@ pub struct ConfigToml {
/// Defaults to `true`.
pub feedback: Option<crate::config::types::FeedbackConfigToml>,
/// Settings for app-specific controls.
#[serde(default)]
pub apps: Option<AppsConfigToml>,
/// OTEL configuration.
pub otel: Option<crate::config::types::OtelConfigToml>,
@@ -1278,15 +1332,11 @@ fn resolve_web_search_mode(
pub(crate) fn resolve_web_search_mode_for_turn(
explicit_mode: Option<WebSearchMode>,
is_azure_responses_endpoint: bool,
sandbox_policy: &SandboxPolicy,
) -> WebSearchMode {
if let Some(mode) = explicit_mode {
return mode;
}
if is_azure_responses_endpoint {
return WebSearchMode::Disabled;
}
if matches!(sandbox_policy, SandboxPolicy::DangerFullAccess) {
WebSearchMode::Live
} else {
@@ -1314,6 +1364,7 @@ impl Config {
) -> std::io::Result<Self> {
let requirements = config_layer_stack.requirements().clone();
let user_instructions = Self::load_instructions(Some(&codex_home));
let mut startup_warnings = Vec::new();
// Destructure ConfigOverrides fully to ensure all overrides are applied.
let ConfigOverrides {
@@ -1543,7 +1594,7 @@ impl Config {
.or_else(|| {
features
.enabled(Feature::Personality)
.then_some(Personality::Friendly)
.then_some(Personality::Pragmatic)
});
let experimental_compact_prompt_path = config_profile
@@ -1560,6 +1611,16 @@ impl Config {
let check_for_update_on_startup = cfg.check_for_update_on_startup.unwrap_or(true);
let log_dir = cfg
.log_dir
.as_ref()
.map(AbsolutePathBuf::to_path_buf)
.unwrap_or_else(|| {
let mut p = codex_home.clone();
p.push("log");
p
});
// Ensure that every field of ConfigRequirements is applied to the final
// Config.
let ConfigRequirements {
@@ -1570,12 +1631,18 @@ impl Config {
enforce_residency,
} = requirements;
constrained_approval_policy
.set(approval_policy)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?;
constrained_sandbox_policy
.set(sandbox_policy)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?;
apply_requirement_constrained_value(
"approval_policy",
approval_policy,
&mut constrained_approval_policy,
&mut startup_warnings,
)?;
apply_requirement_constrained_value(
"sandbox_mode",
sandbox_policy,
&mut constrained_sandbox_policy,
&mut startup_warnings,
)?;
let mcp_servers = constrain_mcp_servers(cfg.mcp_servers.clone(), mcp_servers.as_ref())
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, format!("{e}")))?;
@@ -1588,6 +1655,7 @@ impl Config {
model_provider_id,
model_provider,
cwd: resolved_cwd,
startup_warnings,
approval_policy: constrained_approval_policy.value,
sandbox_policy: constrained_sandbox_policy.value,
enforce_residency: enforce_residency.value,
@@ -1626,6 +1694,7 @@ impl Config {
tool_output_token_limit: cfg.tool_output_token_limit,
agent_max_threads,
codex_home,
log_dir,
config_layer_stack,
history,
ephemeral: ephemeral.unwrap_or_default(),
@@ -1694,6 +1763,7 @@ impl Config {
.as_ref()
.map(|t| t.alternate_screen)
.unwrap_or_default(),
tui_status_line: cfg.tui.as_ref().and_then(|t| t.status_line.clone()),
otel: {
let t: OtelConfigToml = cfg.otel.unwrap_or_default();
let log_user_prompt = t.log_user_prompt.unwrap_or(false);
@@ -1816,9 +1886,7 @@ pub fn find_codex_home() -> std::io::Result<PathBuf> {
/// Returns the path to the folder where Codex logs are stored. Does not verify
/// that the directory exists.
pub fn log_dir(cfg: &Config) -> std::io::Result<PathBuf> {
let mut p = cfg.codex_home.clone();
p.push("log");
Ok(p)
Ok(cfg.log_dir.clone())
}
#[cfg(test)]
@@ -1853,6 +1921,7 @@ mod tests {
cwd: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -1871,6 +1940,7 @@ mod tests {
env_http_headers: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -1931,6 +2001,7 @@ persistence = "none"
show_tooltips: true,
experimental_mode: None,
alternate_screen: AltScreenMode::Auto,
status_line: None,
}
);
}
@@ -2392,14 +2463,14 @@ trust_level = "trusted"
#[test]
fn web_search_mode_for_turn_defaults_to_cached_when_unset() {
let mode = resolve_web_search_mode_for_turn(None, false, &SandboxPolicy::ReadOnly);
let mode = resolve_web_search_mode_for_turn(None, &SandboxPolicy::ReadOnly);
assert_eq!(mode, WebSearchMode::Cached);
}
#[test]
fn web_search_mode_for_turn_defaults_to_live_for_danger_full_access() {
let mode = resolve_web_search_mode_for_turn(None, false, &SandboxPolicy::DangerFullAccess);
let mode = resolve_web_search_mode_for_turn(None, &SandboxPolicy::DangerFullAccess);
assert_eq!(mode, WebSearchMode::Live);
}
@@ -2408,20 +2479,12 @@ trust_level = "trusted"
fn web_search_mode_for_turn_prefers_explicit_value() {
let mode = resolve_web_search_mode_for_turn(
Some(WebSearchMode::Cached),
false,
&SandboxPolicy::DangerFullAccess,
);
assert_eq!(mode, WebSearchMode::Cached);
}
#[test]
fn web_search_mode_for_turn_disables_for_azure_responses_endpoint() {
let mode = resolve_web_search_mode_for_turn(None, true, &SandboxPolicy::DangerFullAccess);
assert_eq!(mode, WebSearchMode::Disabled);
}
#[test]
fn profile_legacy_toggles_override_base() -> std::io::Result<()> {
let codex_home = TempDir::new()?;
@@ -2744,6 +2807,7 @@ profile = "project"
cwd: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(3)),
tool_timeout_sec: Some(Duration::from_secs(5)),
@@ -2900,6 +2964,7 @@ bearer_token = "secret"
cwd: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -2970,6 +3035,7 @@ ZIG_VAR = "3"
cwd: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -3020,6 +3086,7 @@ ZIG_VAR = "3"
cwd: Some(cwd_path.clone()),
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -3068,6 +3135,7 @@ ZIG_VAR = "3"
env_http_headers: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
@@ -3132,6 +3200,7 @@ startup_timeout_sec = 2.0
)])),
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
@@ -3208,6 +3277,7 @@ X-Auth = "DOCS_AUTH"
)])),
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
@@ -3237,6 +3307,7 @@ X-Auth = "DOCS_AUTH"
env_http_headers: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -3304,6 +3375,7 @@ url = "https://example.com/mcp"
)])),
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(2)),
tool_timeout_sec: None,
@@ -3323,6 +3395,7 @@ url = "https://example.com/mcp"
cwd: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -3405,6 +3478,7 @@ url = "https://example.com/mcp"
cwd: None,
},
enabled: false,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -3434,6 +3508,51 @@ url = "https://example.com/mcp"
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_serializes_required_flag() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let servers = BTreeMap::from([(
"docs".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: Vec::new(),
env: None,
env_vars: Vec::new(),
cwd: None,
},
enabled: true,
required: true,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
},
)]);
apply_blocking(
codex_home.path(),
None,
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
)?;
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
let serialized = std::fs::read_to_string(&config_path)?;
assert!(
serialized.contains("required = true"),
"serialized config missing required flag:\n{serialized}"
);
let loaded = load_global_mcp_servers(codex_home.path()).await?;
let docs = loaded.get("docs").expect("docs entry");
assert!(docs.required);
Ok(())
}
#[tokio::test]
async fn replace_mcp_servers_serializes_tool_filters() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
@@ -3449,6 +3568,7 @@ url = "https://example.com/mcp"
cwd: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -3842,7 +3962,9 @@ model_verbosity = "high"
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
codex_home: fixture.codex_home(),
log_dir: fixture.codex_home().join("log"),
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
history: History::default(),
ephemeral: false,
file_opener: UriBasedFileOpener::VsCode,
@@ -3853,7 +3975,7 @@ model_verbosity = "high"
model_reasoning_summary: ReasoningSummary::Detailed,
model_supports_reasoning_summaries: None,
model_verbosity: None,
personality: Some(Personality::Friendly),
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
base_instructions: None,
developer_instructions: None,
@@ -3880,6 +4002,7 @@ model_verbosity = "high"
analytics_enabled: Some(true),
feedback_enabled: true,
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
otel: OtelConfig::default(),
},
o3_profile_config
@@ -3927,7 +4050,9 @@ model_verbosity = "high"
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
codex_home: fixture.codex_home(),
log_dir: fixture.codex_home().join("log"),
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
history: History::default(),
ephemeral: false,
file_opener: UriBasedFileOpener::VsCode,
@@ -3938,7 +4063,7 @@ model_verbosity = "high"
model_reasoning_summary: ReasoningSummary::default(),
model_supports_reasoning_summaries: None,
model_verbosity: None,
personality: Some(Personality::Friendly),
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
base_instructions: None,
developer_instructions: None,
@@ -3965,6 +4090,7 @@ model_verbosity = "high"
analytics_enabled: Some(true),
feedback_enabled: true,
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
otel: OtelConfig::default(),
};
@@ -4027,7 +4153,9 @@ model_verbosity = "high"
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
codex_home: fixture.codex_home(),
log_dir: fixture.codex_home().join("log"),
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
history: History::default(),
ephemeral: false,
file_opener: UriBasedFileOpener::VsCode,
@@ -4038,7 +4166,7 @@ model_verbosity = "high"
model_reasoning_summary: ReasoningSummary::default(),
model_supports_reasoning_summaries: None,
model_verbosity: None,
personality: Some(Personality::Friendly),
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
base_instructions: None,
developer_instructions: None,
@@ -4065,6 +4193,7 @@ model_verbosity = "high"
analytics_enabled: Some(false),
feedback_enabled: true,
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
otel: OtelConfig::default(),
};
@@ -4113,7 +4242,9 @@ model_verbosity = "high"
tool_output_token_limit: None,
agent_max_threads: DEFAULT_AGENT_MAX_THREADS,
codex_home: fixture.codex_home(),
log_dir: fixture.codex_home().join("log"),
config_layer_stack: Default::default(),
startup_warnings: Vec::new(),
history: History::default(),
ephemeral: false,
file_opener: UriBasedFileOpener::VsCode,
@@ -4124,7 +4255,7 @@ model_verbosity = "high"
model_reasoning_summary: ReasoningSummary::Detailed,
model_supports_reasoning_summaries: None,
model_verbosity: Some(Verbosity::High),
personality: Some(Personality::Friendly),
personality: Some(Personality::Pragmatic),
chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(),
base_instructions: None,
developer_instructions: None,
@@ -4151,6 +4282,7 @@ model_verbosity = "high"
analytics_enabled: Some(true),
feedback_enabled: true,
tui_alternate_screen: AltScreenMode::Auto,
tui_status_line: None,
otel: OtelConfig::default(),
};
@@ -4664,7 +4796,7 @@ mcp_oauth_callback_port = 5678
}
#[tokio::test]
async fn explicit_sandbox_mode_still_errors_when_disallowed_by_requirements()
async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
@@ -4683,19 +4815,15 @@ mcp_oauth_callback_port = 5678
enforce_residency: None,
};
let err = ConfigBuilder::default()
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(
async move { Some(requirements) },
))
.build()
.await
.expect_err("explicit disallowed mode should still fail");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
let message = err.to_string();
assert!(message.contains("invalid value for `sandbox_mode`"));
assert!(message.contains("set by cloud requirements"));
.await?;
assert_eq!(*config.sandbox_policy.get(), SandboxPolicy::ReadOnly);
Ok(())
}
@@ -4732,7 +4860,7 @@ trust_level = "untrusted"
}
#[tokio::test]
async fn explicit_approval_policy_still_errors_when_disallowed_by_requirements()
async fn explicit_approval_policy_falls_back_when_disallowed_by_requirements()
-> std::io::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
@@ -4741,7 +4869,7 @@ trust_level = "untrusted"
"#,
)?;
let err = ConfigBuilder::default()
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.fallback_cwd(Some(codex_home.path().to_path_buf()))
.cloud_requirements(CloudRequirementsLoader::new(async {
@@ -4751,12 +4879,8 @@ trust_level = "untrusted"
})
}))
.build()
.await
.expect_err("explicit disallowed approval policy should fail");
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
let message = err.to_string();
assert!(message.contains("invalid value for `approval_policy`"));
assert!(message.contains("set by cloud requirements"));
.await?;
assert_eq!(config.approval_policy.value(), AskForApproval::OnRequest);
Ok(())
}
}
+61 -2
View File
@@ -700,6 +700,9 @@ fn find_effective_layer(
mod tests {
use super::*;
use anyhow::Result;
use codex_app_server_protocol::AppConfig;
use codex_app_server_protocol::AppDisabledReason;
use codex_app_server_protocol::AppsConfig;
use codex_app_server_protocol::AskForApproval;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
@@ -774,7 +777,7 @@ unified_exec = true
service
.write_value(ConfigValueWriteParams {
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
key_path: "features.remote_compaction".to_string(),
key_path: "features.remote_models".to_string(),
value: serde_json::json!(true),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
@@ -794,12 +797,68 @@ hide_full_access_warning = true
[features]
unified_exec = true
remote_compaction = true
remote_models = true
"#;
assert_eq!(updated, expected);
Ok(())
}
#[tokio::test]
async fn write_value_supports_nested_app_paths() -> Result<()> {
let tmp = tempdir().expect("tempdir");
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "")?;
let service = ConfigService::new_with_defaults(tmp.path().to_path_buf());
service
.write_value(ConfigValueWriteParams {
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
key_path: "apps".to_string(),
value: serde_json::json!({
"app1": {
"enabled": false,
},
}),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect("write apps succeeds");
service
.write_value(ConfigValueWriteParams {
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
key_path: "apps.app1.disabled_reason".to_string(),
value: serde_json::json!("user"),
merge_strategy: MergeStrategy::Replace,
expected_version: None,
})
.await
.expect("write apps.app1.disabled_reason succeeds");
let read = service
.read(ConfigReadParams {
include_layers: false,
cwd: None,
})
.await
.expect("config read succeeds");
assert_eq!(
read.config.apps,
Some(AppsConfig {
apps: std::collections::HashMap::from([(
"app1".to_string(),
AppConfig {
enabled: false,
disabled_reason: Some(AppDisabledReason::User),
},
)]),
})
);
Ok(())
}
#[tokio::test]
async fn read_includes_origins_and_layers() {
let tmp = tempdir().expect("tempdir");
+67
View File
@@ -50,6 +50,10 @@ pub struct McpServerConfig {
#[serde(default = "default_enabled")]
pub enabled: bool,
/// When `true`, `codex exec` exits with an error if this MCP server fails to initialize.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub required: bool,
/// Reason this server was disabled after applying requirements.
#[serde(skip)]
pub disabled_reason: Option<McpServerDisabledReason>,
@@ -114,6 +118,8 @@ pub(crate) struct RawMcpServerConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub required: Option<bool>,
#[serde(default)]
pub enabled_tools: Option<Vec<String>>,
#[serde(default)]
pub disabled_tools: Option<Vec<String>>,
@@ -138,6 +144,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
};
let tool_timeout_sec = raw.tool_timeout_sec;
let enabled = raw.enabled.unwrap_or_else(default_enabled);
let required = raw.required.unwrap_or_default();
let enabled_tools = raw.enabled_tools.clone();
let disabled_tools = raw.disabled_tools.clone();
let scopes = raw.scopes.clone();
@@ -192,6 +199,7 @@ impl<'de> Deserialize<'de> for McpServerConfig {
startup_timeout_sec,
tool_timeout_sec,
enabled,
required,
disabled_reason: None,
enabled_tools,
disabled_tools,
@@ -332,6 +340,44 @@ pub struct FeedbackConfigToml {
pub enabled: Option<bool>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum AppDisabledReason {
Unknown,
User,
}
impl fmt::Display for AppDisabledReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
AppDisabledReason::Unknown => write!(f, "unknown"),
AppDisabledReason::User => write!(f, "user"),
}
}
}
/// Config values for a single app/connector.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AppConfig {
/// When `false`, Codex does not surface this app.
#[serde(default = "default_enabled")]
pub enabled: bool,
/// Reason this app was disabled.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub disabled_reason: Option<AppDisabledReason>,
}
/// App/connector settings loaded from `config.toml`.
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct AppsConfigToml {
/// Per-app settings keyed by app ID (for example `[apps.google_drive]`).
#[serde(default, flatten)]
pub apps: HashMap<String, AppConfig>,
}
// ===== OTEL configuration =====
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema)]
@@ -486,6 +532,12 @@ pub struct Tui {
/// scrollback in terminal multiplexers like Zellij that follow the xterm spec.
#[serde(default)]
pub alternate_screen: AltScreenMode,
/// Ordered list of status line item identifiers.
///
/// When set, the TUI renders the selected items as the status line.
#[serde(default)]
pub status_line: Option<Vec<String>>,
}
const fn default_true() -> bool {
@@ -691,6 +743,7 @@ mod tests {
}
);
assert!(cfg.enabled);
assert!(!cfg.required);
assert!(cfg.enabled_tools.is_none());
assert!(cfg.disabled_tools.is_none());
}
@@ -797,6 +850,20 @@ mod tests {
.expect("should deserialize disabled server config");
assert!(!cfg.enabled);
assert!(!cfg.required);
}
#[test]
fn deserialize_required_server_config() {
let cfg: McpServerConfig = toml::from_str(
r#"
command = "echo"
required = true
"#,
)
.expect("should deserialize required server config");
assert!(cfg.required);
}
#[test]
+9 -1
View File
@@ -144,7 +144,15 @@ pub async fn load_config_layers_state(
let cli_overrides_layer = if cli_overrides.is_empty() {
None
} else {
Some(overrides::build_cli_overrides_layer(cli_overrides))
let cli_overrides_layer = overrides::build_cli_overrides_layer(cli_overrides);
let base_dir = cwd
.as_ref()
.map(AbsolutePathBuf::as_path)
.unwrap_or(codex_home);
Some(resolve_relative_paths_in_config_toml(
cli_overrides_layer,
base_dir,
)?)
};
// Include an entry for the "system" config folder, loading its config.toml,
+24
View File
@@ -56,6 +56,30 @@ async fn make_config_for_test(
.await
}
#[tokio::test]
async fn cli_overrides_resolve_relative_paths_against_cwd() -> std::io::Result<()> {
let codex_home = tempdir().expect("tempdir");
let cwd_dir = tempdir().expect("tempdir");
let cwd_path = cwd_dir.path().to_path_buf();
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.cli_overrides(vec![(
"log_dir".to_string(),
TomlValue::String("run-logs".to_string()),
)])
.harness_overrides(ConfigOverrides {
cwd: Some(cwd_path.clone()),
..Default::default()
})
.build()
.await?;
let expected = AbsolutePathBuf::resolve_path_against_base("run-logs", cwd_path)?;
assert_eq!(config.log_dir, expected.to_path_buf());
Ok(())
}
#[tokio::test]
async fn returns_config_error_for_invalid_user_config_toml() {
let tmp = tempdir().expect("tempdir");
+13 -2
View File
@@ -9,6 +9,7 @@ use crate::truncate::approx_tokens_from_byte_count;
use crate::truncate::truncate_function_output_items_with_policy;
use crate::truncate::truncate_text;
use crate::user_shell_command::is_user_shell_command_text;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
@@ -88,8 +89,18 @@ impl ContextManager {
pub(crate) fn estimate_token_count(&self, turn_context: &TurnContext) -> Option<i64> {
let model_info = &turn_context.model_info;
let personality = turn_context.personality.or(turn_context.config.personality);
let base_instructions = model_info.get_model_instructions(personality);
let base_tokens = i64::try_from(approx_token_count(&base_instructions)).unwrap_or(i64::MAX);
let base_instructions = BaseInstructions {
text: model_info.get_model_instructions(personality),
};
self.estimate_token_count_with_base_instructions(&base_instructions)
}
pub(crate) fn estimate_token_count_with_base_instructions(
&self,
base_instructions: &BaseInstructions,
) -> Option<i64> {
let base_tokens =
i64::try_from(approx_token_count(&base_instructions.text)).unwrap_or(i64::MAX);
let items_tokens = self.items.iter().fold(0i64, |acc, item| {
acc.saturating_add(estimate_item_token_count(item))
@@ -2,6 +2,7 @@ use super::*;
use crate::truncate;
use crate::truncate::TruncationPolicy;
use codex_git::GhostCommit;
use codex_protocol::models::BaseInstructions;
use codex_protocol::models::ContentItem;
use codex_protocol::models::FunctionCallOutputBody;
use codex_protocol::models::FunctionCallOutputContentItem;
@@ -103,6 +104,10 @@ fn truncate_exec_output(content: &str) -> String {
truncate::truncate_text(content, TruncationPolicy::Tokens(EXEC_FORMAT_MAX_TOKENS))
}
fn approx_token_count_for_text(text: &str) -> i64 {
i64::try_from(text.len().saturating_add(3) / 4).unwrap_or(i64::MAX)
}
#[test]
fn filters_non_api_messages() {
let mut h = ContextManager::default();
@@ -250,6 +255,28 @@ fn get_history_for_prompt_drops_ghost_commits() {
assert_eq!(filtered, vec![]);
}
#[test]
fn estimate_token_count_with_base_instructions_uses_provided_text() {
let history = create_history_with_items(vec![assistant_msg("hello from history")]);
let short_base = BaseInstructions {
text: "short".to_string(),
};
let long_base = BaseInstructions {
text: "x".repeat(1_000),
};
let short_estimate = history
.estimate_token_count_with_base_instructions(&short_base)
.expect("token estimate");
let long_estimate = history
.estimate_token_count_with_base_instructions(&long_base)
.expect("token estimate");
let expected_delta = approx_token_count_for_text(&long_base.text)
- approx_token_count_for_text(&short_base.text);
assert_eq!(long_estimate - short_estimate, expected_delta);
}
#[test]
fn remove_first_item_removes_matching_output_for_function_call() {
let items = vec![
+11
View File
@@ -128,6 +128,17 @@ pub enum SandboxType {
WindowsRestrictedToken,
}
impl SandboxType {
pub(crate) fn as_metric_tag(self) -> &'static str {
match self {
SandboxType::None => "none",
SandboxType::MacosSeatbelt => "seatbelt",
SandboxType::LinuxSeccomp => "seccomp",
SandboxType::WindowsRestrictedToken => "windows_sandbox",
}
}
}
#[derive(Clone)]
pub struct StdoutStream {
pub sub_id: String,
+16 -95
View File
@@ -25,8 +25,6 @@ use tokio::fs;
use tokio::task::spawn_blocking;
use crate::bash::parse_shell_lc_plain_commands;
use crate::features::Feature;
use crate::features::Features;
use crate::sandboxing::SandboxPermissions;
use crate::tools::sandboxing::ExecApprovalRequirement;
use shlex::try_join as shlex_try_join;
@@ -78,9 +76,6 @@ pub enum ExecPolicyUpdateError {
#[from]
source: ExecPolicyRuleError,
},
#[error("cannot append rule because rules feature is disabled")]
FeatureDisabled,
}
pub(crate) struct ExecPolicyManager {
@@ -88,7 +83,6 @@ pub(crate) struct ExecPolicyManager {
}
pub(crate) struct ExecApprovalRequest<'a> {
pub(crate) features: &'a Features,
pub(crate) command: &'a [String],
pub(crate) approval_policy: AskForApproval,
pub(crate) sandbox_policy: &'a SandboxPolicy,
@@ -103,12 +97,8 @@ impl ExecPolicyManager {
}
}
pub(crate) async fn load(
features: &Features,
config_stack: &ConfigLayerStack,
) -> Result<Self, ExecPolicyError> {
let (policy, warning) =
load_exec_policy_for_features_with_warning(features, config_stack).await?;
pub(crate) async fn load(config_stack: &ConfigLayerStack) -> Result<Self, ExecPolicyError> {
let (policy, warning) = load_exec_policy_with_warning(config_stack).await?;
if let Some(err) = warning.as_ref() {
tracing::warn!("failed to parse rules: {err}");
}
@@ -124,7 +114,6 @@ impl ExecPolicyManager {
req: ExecApprovalRequest<'_>,
) -> ExecApprovalRequirement {
let ExecApprovalRequest {
features,
command,
approval_policy,
sandbox_policy,
@@ -144,11 +133,8 @@ impl ExecPolicyManager {
};
let evaluation = exec_policy.check_multiple(commands.iter(), &exec_policy_fallback);
let requested_amendment = derive_requested_execpolicy_amendment(
features,
prefix_rule.as_ref(),
&evaluation.matched_rules,
);
let requested_amendment =
derive_requested_execpolicy_amendment(prefix_rule.as_ref(), &evaluation.matched_rules);
match evaluation.decision {
Decision::Forbidden => ExecApprovalRequirement::Forbidden {
@@ -162,15 +148,11 @@ impl ExecPolicyManager {
} else {
ExecApprovalRequirement::NeedsApproval {
reason: derive_prompt_reason(command, &evaluation),
proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) {
requested_amendment.or_else(|| {
try_derive_execpolicy_amendment_for_prompt_rules(
&evaluation.matched_rules,
)
})
} else {
None
},
proposed_execpolicy_amendment: requested_amendment.or_else(|| {
try_derive_execpolicy_amendment_for_prompt_rules(
&evaluation.matched_rules,
)
}),
}
}
}
@@ -179,11 +161,9 @@ impl ExecPolicyManager {
bypass_sandbox: evaluation.matched_rules.iter().any(|rule_match| {
is_policy_match(rule_match) && rule_match.decision() == Decision::Allow
}),
proposed_execpolicy_amendment: if features.enabled(Feature::ExecPolicy) {
try_derive_execpolicy_amendment_for_allow_rules(&evaluation.matched_rules)
} else {
None
},
proposed_execpolicy_amendment: try_derive_execpolicy_amendment_for_allow_rules(
&evaluation.matched_rules,
),
},
}
}
@@ -221,21 +201,15 @@ impl Default for ExecPolicyManager {
}
pub async fn check_execpolicy_for_warnings(
features: &Features,
config_stack: &ConfigLayerStack,
) -> Result<Option<ExecPolicyError>, ExecPolicyError> {
let (_, warning) = load_exec_policy_for_features_with_warning(features, config_stack).await?;
let (_, warning) = load_exec_policy_with_warning(config_stack).await?;
Ok(warning)
}
async fn load_exec_policy_for_features_with_warning(
features: &Features,
async fn load_exec_policy_with_warning(
config_stack: &ConfigLayerStack,
) -> Result<(Policy, Option<ExecPolicyError>), ExecPolicyError> {
if !features.enabled(Feature::ExecPolicy) {
return Ok((Policy::empty(), None));
}
match load_exec_policy(config_stack).await {
Ok(policy) => Ok((policy, None)),
Err(err @ ExecPolicyError::ParsePolicy { .. }) => Ok((Policy::empty(), Some(err))),
@@ -413,14 +387,9 @@ fn try_derive_execpolicy_amendment_for_allow_rules(
}
fn derive_requested_execpolicy_amendment(
features: &Features,
prefix_rule: Option<&Vec<String>>,
matched_rules: &[RuleMatch],
) -> Option<ExecPolicyAmendment> {
if !features.enabled(Feature::ExecPolicy) {
return None;
}
let prefix_rule = prefix_rule?;
if prefix_rule.is_empty() {
return None;
@@ -589,13 +558,11 @@ mod tests {
}
#[tokio::test]
async fn returns_empty_policy_when_feature_disabled() {
let mut features = Features::with_defaults();
features.disable(Feature::ExecPolicy);
async fn returns_empty_policy_when_no_policy_files_exist() {
let temp_dir = tempdir().expect("create temp dir");
let config_stack = config_stack_for_dot_codex_folder(temp_dir.path());
let manager = ExecPolicyManager::load(&features, &config_stack)
let manager = ExecPolicyManager::load(&config_stack)
.await
.expect("manager result");
let policy = manager.current();
@@ -809,7 +776,6 @@ prefix_rule(pattern=["rm"], decision="forbidden")
let manager = ExecPolicyManager::new(policy);
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &Features::with_defaults(),
command: &forbidden_script,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
@@ -844,7 +810,6 @@ prefix_rule(
let manager = ExecPolicyManager::new(policy);
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &Features::with_defaults(),
command: &[
"rm".to_string(),
"-rf".to_string(),
@@ -878,7 +843,6 @@ prefix_rule(
let manager = ExecPolicyManager::new(policy);
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &Features::with_defaults(),
command: &command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
@@ -909,7 +873,6 @@ prefix_rule(
let manager = ExecPolicyManager::new(policy);
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &Features::with_defaults(),
command: &command,
approval_policy: AskForApproval::Never,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
@@ -933,7 +896,6 @@ prefix_rule(
let manager = ExecPolicyManager::default();
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &Features::with_defaults(),
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::ReadOnly,
@@ -964,7 +926,6 @@ prefix_rule(
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &features,
command: &command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::ReadOnly,
@@ -1002,7 +963,6 @@ prefix_rule(
assert_eq!(
ExecPolicyManager::new(policy)
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &Features::with_defaults(),
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
@@ -1077,7 +1037,6 @@ prefix_rule(
let manager = ExecPolicyManager::default();
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &Features::with_defaults(),
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::ReadOnly,
@@ -1095,34 +1054,6 @@ prefix_rule(
);
}
#[tokio::test]
async fn proposed_execpolicy_amendment_is_disabled_when_execpolicy_feature_disabled() {
let command = vec!["cargo".to_string(), "build".to_string()];
let mut features = Features::with_defaults();
features.disable(Feature::ExecPolicy);
let manager = ExecPolicyManager::default();
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &features,
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::ReadOnly,
sandbox_permissions: SandboxPermissions::UseDefault,
prefix_rule: None,
})
.await;
assert_eq!(
requirement,
ExecApprovalRequirement::NeedsApproval {
reason: None,
proposed_execpolicy_amendment: None,
}
);
}
#[tokio::test]
async fn proposed_execpolicy_amendment_is_omitted_when_policy_prompts() {
let policy_src = r#"prefix_rule(pattern=["rm"], decision="prompt")"#;
@@ -1136,7 +1067,6 @@ prefix_rule(
let manager = ExecPolicyManager::new(policy);
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &Features::with_defaults(),
command: &command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
@@ -1164,7 +1094,6 @@ prefix_rule(
let manager = ExecPolicyManager::default();
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &Features::with_defaults(),
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::ReadOnly,
@@ -1203,7 +1132,6 @@ prefix_rule(
assert_eq!(
ExecPolicyManager::new(policy)
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &Features::with_defaults(),
command: &command,
approval_policy: AskForApproval::UnlessTrusted,
sandbox_policy: &SandboxPolicy::ReadOnly,
@@ -1227,7 +1155,6 @@ prefix_rule(
let manager = ExecPolicyManager::default();
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &Features::with_defaults(),
command: &command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::ReadOnly,
@@ -1258,7 +1185,6 @@ prefix_rule(
let manager = ExecPolicyManager::new(policy);
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &Features::with_defaults(),
command: &command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::ReadOnly,
@@ -1282,7 +1208,6 @@ prefix_rule(
let manager = ExecPolicyManager::default();
let requirement = manager
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &Features::with_defaults(),
command: &command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::DangerFullAccess,
@@ -1316,7 +1241,6 @@ prefix_rule(
}
let policy = ExecPolicyManager::new(Arc::new(Policy::empty()));
let features = Features::with_defaults();
let permissions = SandboxPermissions::UseDefault;
// This command should not be run without user approval unless there is
@@ -1350,7 +1274,6 @@ prefix_rule(
expected_req,
policy
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &features,
command: &sneaky_command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::ReadOnly,
@@ -1374,7 +1297,6 @@ prefix_rule(
},
policy
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &features,
command: &dangerous_command,
approval_policy: AskForApproval::OnRequest,
sandbox_policy: &SandboxPolicy::ReadOnly,
@@ -1394,7 +1316,6 @@ prefix_rule(
},
policy
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &features,
command: &dangerous_command,
approval_policy: AskForApproval::Never,
sandbox_policy: &SandboxPolicy::ReadOnly,
+10 -22
View File
@@ -87,8 +87,6 @@ pub enum Feature {
/// Allow the model to request web searches that fetch cached content.
/// Takes precedence over `WebSearchRequest`.
WebSearchCached,
/// Gate the execpolicy enforcement for shell/unified exec.
ExecPolicy,
/// Use the bubblewrap-based Linux sandbox pipeline.
UseLinuxSandboxBwrap,
/// Allow the model to request approval and propose exec rules.
@@ -97,8 +95,6 @@ pub enum Feature {
WindowsSandbox,
/// Use the elevated Windows sandbox pipeline (setup + runner).
WindowsSandboxElevated,
/// Remote compaction enabled (only for ChatGPT auth)
RemoteCompaction,
/// Refresh remote models and emit AppReady once the list is available.
RemoteModels,
/// Experimental shell snapshotting.
@@ -107,6 +103,8 @@ pub enum Feature {
RuntimeMetrics,
/// Persist rollout metadata to a local SQLite database.
Sqlite,
/// Enable the get_memory tool backed by SQLite thread memories.
MemoryTool,
/// Append additional AGENTS.md guidance to user instructions.
ChildAgentsMd,
/// Enforce UTF8 output in Powershell.
@@ -449,6 +447,12 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::MemoryTool,
key: "memory_tool",
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ChildAgentsMd,
key: "child_agents_md",
@@ -461,12 +465,6 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::ExecPolicy,
key: "exec_policy",
stage: Stage::UnderDevelopment,
default_enabled: true,
},
FeatureSpec {
id: Feature::UseLinuxSandboxBwrap,
key: "use_linux_sandbox_bwrap",
@@ -491,12 +489,6 @@ pub const FEATURES: &[FeatureSpec] = &[
stage: Stage::UnderDevelopment,
default_enabled: false,
},
FeatureSpec {
id: Feature::RemoteCompaction,
key: "remote_compaction",
stage: Stage::UnderDevelopment,
default_enabled: true,
},
FeatureSpec {
id: Feature::RemoteModels,
key: "remote_models",
@@ -556,12 +548,8 @@ pub const FEATURES: &[FeatureSpec] = &[
FeatureSpec {
id: Feature::Steer,
key: "steer",
stage: Stage::Experimental {
name: "Steer conversation",
menu_description: "Enter submits immediately; Tab queues messages when a task is running.",
announcement: "NEW! Try Steer mode: Enter submits immediately, Tab queues. Enable in /experimental!",
},
default_enabled: false,
stage: Stage::Stable,
default_enabled: true,
},
FeatureSpec {
id: Feature::CollaborationModes,
+7
View File
@@ -19,6 +19,7 @@ use tokio::sync::broadcast;
use tokio::sync::mpsc;
use tokio::time::Instant;
use tokio::time::sleep_until;
use tracing::info;
use tracing::warn;
use crate::config::Config;
@@ -162,6 +163,12 @@ impl FileWatcher {
res = raw_rx.recv() => {
match res {
Some(Ok(event)) => {
info!(
event_kind = ?event.kind,
event_paths = ?event.paths,
event_attrs = ?event.attrs,
"file watcher received filesystem event"
);
let skills_paths = classify_event(&event, &state);
let now = Instant::now();
skills.add(skills_paths);
+8
View File
@@ -0,0 +1,8 @@
mod registry;
mod types;
mod user_notification;
pub(crate) use registry::Hooks;
pub(crate) use types::HookEvent;
pub(crate) use types::HookEventAfterAgent;
pub(crate) use types::HookPayload;
+315
View File
@@ -0,0 +1,315 @@
use tokio::process::Command;
use super::types::Hook;
use super::types::HookEvent;
use super::types::HookOutcome;
use super::types::HookPayload;
use super::user_notification::notify_hook;
use crate::config::Config;
#[derive(Default, Clone)]
pub(crate) struct Hooks {
after_agent: Vec<Hook>,
}
fn get_notify_hook(config: &Config) -> Option<Hook> {
config
.notify
.as_ref()
.filter(|argv| !argv.is_empty() && !argv[0].is_empty())
.map(|argv| notify_hook(argv.clone()))
}
// Hooks are arbitrary, user-specified functions that are deterministically
// executed after specific events in the Codex lifecycle.
impl Hooks {
// new creates a new Hooks instance from config.
// For legacy compatibility, if config.notify is set, it will be added to
// the after_agent hooks.
pub(crate) fn new(config: &Config) -> Self {
let after_agent = get_notify_hook(config).into_iter().collect();
Self { after_agent }
}
fn hooks_for_event(&self, hook_event: &HookEvent) -> &[Hook] {
match hook_event {
HookEvent::AfterAgent { .. } => &self.after_agent,
}
}
pub(crate) async fn dispatch(&self, hook_payload: HookPayload) {
// TODO(gt): support interrupting program execution by returning a result here.
for hook in self.hooks_for_event(&hook_payload.hook_event) {
let outcome = hook.execute(&hook_payload).await;
if matches!(outcome, HookOutcome::Stop) {
break;
}
}
}
}
pub(super) fn command_from_argv(argv: &[String]) -> Option<Command> {
let (program, args) = argv.split_first()?;
if program.is_empty() {
return None;
}
let mut command = Command::new(program);
command.args(args);
Some(command)
}
#[cfg(test)]
mod tests {
use std::fs;
use std::path::PathBuf;
use std::process::Stdio;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
use anyhow::Result;
use chrono::TimeZone;
use chrono::Utc;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use serde_json::to_string;
use tempfile::tempdir;
use tokio::time::timeout;
use crate::config::test_config;
use super::super::types::Hook;
use super::super::types::HookEvent;
use super::super::types::HookEventAfterAgent;
use super::super::types::HookOutcome;
use super::super::types::HookPayload;
use super::Hooks;
use super::command_from_argv;
use super::get_notify_hook;
const CWD: &str = "/tmp";
const INPUT_MESSAGE: &str = "hello";
fn hook_payload(label: &str) -> HookPayload {
HookPayload {
session_id: ThreadId::new(),
cwd: PathBuf::from(CWD),
triggered_at: Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp"),
hook_event: HookEvent::AfterAgent {
event: HookEventAfterAgent {
thread_id: ThreadId::new(),
turn_id: format!("turn-{label}"),
input_messages: vec![INPUT_MESSAGE.to_string()],
last_assistant_message: Some("hi".to_string()),
},
},
}
}
fn counting_hook(calls: &Arc<AtomicUsize>, outcome: HookOutcome) -> Hook {
let calls = Arc::clone(calls);
Hook {
func: Arc::new(move |_| {
let calls = Arc::clone(&calls);
Box::pin(async move {
calls.fetch_add(1, Ordering::SeqCst);
outcome
})
}),
}
}
fn hooks_for_after_agent(hooks: Vec<Hook>) -> Hooks {
Hooks { after_agent: hooks }
}
#[test]
fn command_from_argv_returns_none_for_empty_args() {
assert!(command_from_argv(&[]).is_none());
assert!(command_from_argv(&["".to_string()]).is_none());
}
#[tokio::test]
async fn command_from_argv_builds_command() -> Result<()> {
let argv = if cfg!(windows) {
vec![
"cmd".to_string(),
"/C".to_string(),
"echo hello world".to_string(),
]
} else {
vec!["echo".to_string(), "hello".to_string(), "world".to_string()]
};
let mut command = command_from_argv(&argv).ok_or_else(|| anyhow::anyhow!("command"))?;
let output = command.stdout(Stdio::piped()).output().await?;
let stdout = String::from_utf8_lossy(&output.stdout);
let trimmed = stdout.trim_end_matches(['\r', '\n']);
assert_eq!(trimmed, "hello world");
Ok(())
}
#[test]
fn get_notify_hook_requires_program_name() {
let mut config = test_config();
config.notify = Some(vec![]);
assert!(get_notify_hook(&config).is_none());
config.notify = Some(vec!["".to_string()]);
assert!(get_notify_hook(&config).is_none());
config.notify = Some(vec!["notify-send".to_string()]);
assert!(get_notify_hook(&config).is_some());
}
#[tokio::test]
async fn dispatch_executes_hook() {
let calls = Arc::new(AtomicUsize::new(0));
let hooks = hooks_for_after_agent(vec![counting_hook(&calls, HookOutcome::Continue)]);
hooks.dispatch(hook_payload("1")).await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn default_hook_is_noop_and_continues() {
let payload = hook_payload("d");
let outcome = Hook::default().execute(&payload).await;
assert_eq!(outcome, HookOutcome::Continue);
}
#[tokio::test]
async fn dispatch_executes_multiple_hooks_for_same_event() {
let calls = Arc::new(AtomicUsize::new(0));
let hooks = hooks_for_after_agent(vec![
counting_hook(&calls, HookOutcome::Continue),
counting_hook(&calls, HookOutcome::Continue),
]);
hooks.dispatch(hook_payload("2")).await;
assert_eq!(calls.load(Ordering::SeqCst), 2);
}
#[tokio::test]
async fn dispatch_stops_when_hook_returns_stop() {
let calls = Arc::new(AtomicUsize::new(0));
let hooks = hooks_for_after_agent(vec![
counting_hook(&calls, HookOutcome::Stop),
counting_hook(&calls, HookOutcome::Continue),
]);
hooks.dispatch(hook_payload("3")).await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[cfg(not(windows))]
#[tokio::test]
async fn hook_executes_program_with_payload_argument_unix() -> Result<()> {
let temp_dir = tempdir()?;
let payload_path = temp_dir.path().join("payload.json");
let payload_path_arg = payload_path.to_string_lossy().into_owned();
let hook = Hook {
func: Arc::new(move |payload: &HookPayload| {
let payload_path_arg = payload_path_arg.clone();
Box::pin(async move {
let json = to_string(payload).expect("serialize hook payload");
let mut command = command_from_argv(&[
"/bin/sh".to_string(),
"-c".to_string(),
"printf '%s' \"$2\" > \"$1\"".to_string(),
"sh".to_string(),
payload_path_arg,
json,
])
.expect("build command");
command.status().await.expect("run hook command");
HookOutcome::Continue
})
}),
};
let payload = hook_payload("4");
let expected = to_string(&payload)?;
let hooks = hooks_for_after_agent(vec![hook]);
hooks.dispatch(payload).await;
let contents = timeout(Duration::from_secs(2), async {
loop {
if let Ok(contents) = fs::read_to_string(&payload_path)
&& !contents.is_empty()
{
return contents;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
assert_eq!(contents, expected);
Ok(())
}
#[cfg(windows)]
#[tokio::test]
async fn hook_executes_program_with_payload_argument_windows() -> Result<()> {
let temp_dir = tempdir()?;
let payload_path = temp_dir.path().join("payload.json");
let payload_path_arg = payload_path.to_string_lossy().into_owned();
let script_path = temp_dir.path().join("write_payload.ps1");
fs::write(&script_path, "[IO.File]::WriteAllText($args[0], $args[1])")?;
let script_path_arg = script_path.to_string_lossy().into_owned();
let hook = Hook {
func: Arc::new(move |payload: &HookPayload| {
let payload_path_arg = payload_path_arg.clone();
let script_path_arg = script_path_arg.clone();
Box::pin(async move {
let json = to_string(payload).expect("serialize hook payload");
let powershell = crate::powershell::try_find_powershell_executable_blocking()
.map(|path| path.to_string_lossy().into_owned())
.unwrap_or_else(|| "powershell.exe".to_string());
let mut command = command_from_argv(&[
powershell,
"-NoLogo".to_string(),
"-NoProfile".to_string(),
"-ExecutionPolicy".to_string(),
"Bypass".to_string(),
"-File".to_string(),
script_path_arg,
payload_path_arg,
json,
])
.expect("build command");
command.status().await.expect("run hook command");
HookOutcome::Continue
})
}),
};
let payload = hook_payload("4");
let expected = to_string(&payload)?;
let hooks = hooks_for_after_agent(vec![hook]);
hooks.dispatch(payload).await;
let contents = timeout(Duration::from_secs(2), async {
loop {
if let Ok(contents) = fs::read_to_string(&payload_path)
&& !contents.is_empty()
{
return contents;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await?;
assert_eq!(contents, expected);
Ok(())
}
}
+127
View File
@@ -0,0 +1,127 @@
use std::path::PathBuf;
use std::sync::Arc;
use chrono::DateTime;
use chrono::SecondsFormat;
use chrono::Utc;
use codex_protocol::ThreadId;
use futures::future::BoxFuture;
use serde::Serialize;
use serde::Serializer;
pub(crate) type HookFn =
Arc<dyn for<'a> Fn(&'a HookPayload) -> BoxFuture<'a, HookOutcome> + Send + Sync>;
#[derive(Clone)]
pub(crate) struct Hook {
pub(crate) func: HookFn,
}
impl Default for Hook {
fn default() -> Self {
Self {
func: Arc::new(|_| Box::pin(async { HookOutcome::Continue })),
}
}
}
impl Hook {
pub(super) async fn execute(&self, payload: &HookPayload) -> HookOutcome {
(self.func)(payload).await
}
}
#[derive(Debug, Serialize, Clone)]
#[serde(rename_all = "snake_case")]
pub(crate) struct HookPayload {
pub(crate) session_id: ThreadId,
pub(crate) cwd: PathBuf,
#[serde(serialize_with = "serialize_triggered_at")]
pub(crate) triggered_at: DateTime<Utc>,
pub(crate) hook_event: HookEvent,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) struct HookEventAfterAgent {
pub thread_id: ThreadId,
pub turn_id: String,
pub input_messages: Vec<String>,
pub last_assistant_message: Option<String>,
}
fn serialize_triggered_at<S>(value: &DateTime<Utc>, serializer: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
serializer.serialize_str(&value.to_rfc3339_opts(SecondsFormat::Secs, true))
}
#[derive(Debug, Clone, Serialize)]
#[serde(tag = "event_type", rename_all = "snake_case")]
pub(crate) enum HookEvent {
AfterAgent {
#[serde(flatten)]
event: HookEventAfterAgent,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum HookOutcome {
Continue,
#[allow(dead_code)]
Stop,
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use chrono::TimeZone;
use chrono::Utc;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use serde_json::json;
use super::HookEvent;
use super::HookEventAfterAgent;
use super::HookPayload;
#[test]
fn hook_payload_serializes_stable_wire_shape() {
let session_id = ThreadId::new();
let thread_id = ThreadId::new();
let payload = HookPayload {
session_id,
cwd: PathBuf::from("tmp"),
triggered_at: Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp"),
hook_event: HookEvent::AfterAgent {
event: HookEventAfterAgent {
thread_id,
turn_id: "turn-1".to_string(),
input_messages: vec!["hello".to_string()],
last_assistant_message: Some("hi".to_string()),
},
},
};
let actual = serde_json::to_value(payload).expect("serialize hook payload");
let expected = json!({
"session_id": session_id.to_string(),
"cwd": "tmp",
"triggered_at": "2025-01-01T00:00:00Z",
"hook_event": {
"event_type": "after_agent",
"thread_id": thread_id.to_string(),
"turn_id": "turn-1",
"input_messages": ["hello"],
"last_assistant_message": "hi",
},
});
assert_eq!(actual, expected);
}
}
@@ -0,0 +1,132 @@
use std::sync::Arc;
use serde::Serialize;
use std::path::Path;
use std::process::Stdio;
use super::registry::command_from_argv;
use super::types::Hook;
use super::types::HookEvent;
use super::types::HookOutcome;
use super::types::HookPayload;
/// Legacy notify payload appended as the final argv argument for backward compatibility.
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(tag = "type", rename_all = "kebab-case")]
enum UserNotification {
#[serde(rename_all = "kebab-case")]
AgentTurnComplete {
thread_id: String,
turn_id: String,
cwd: String,
/// Messages that the user sent to the agent to initiate the turn.
input_messages: Vec<String>,
/// The last message sent by the assistant in the turn.
last_assistant_message: Option<String>,
},
}
pub(super) fn legacy_notify_json(
hook_event: &HookEvent,
cwd: &Path,
) -> Result<String, serde_json::Error> {
serde_json::to_string(&match hook_event {
HookEvent::AfterAgent { event } => UserNotification::AgentTurnComplete {
thread_id: event.thread_id.to_string(),
turn_id: event.turn_id.clone(),
cwd: cwd.display().to_string(),
input_messages: event.input_messages.clone(),
last_assistant_message: event.last_assistant_message.clone(),
},
})
}
pub(super) fn notify_hook(argv: Vec<String>) -> Hook {
let argv = Arc::new(argv);
Hook {
func: Arc::new(move |payload: &HookPayload| {
let argv = Arc::clone(&argv);
Box::pin(async move {
let mut command = match command_from_argv(&argv) {
Some(command) => command,
None => return HookOutcome::Continue,
};
if let Ok(notify_payload) = legacy_notify_json(&payload.hook_event, &payload.cwd) {
command.arg(notify_payload);
}
// Backwards-compat: match legacy notify behavior (argv + JSON arg, fire-and-forget).
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null());
let _ = command.spawn();
HookOutcome::Continue
})
}),
}
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::*;
use anyhow::Result;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use serde_json::Value;
use serde_json::json;
fn expected_notification_json() -> Value {
json!({
"type": "agent-turn-complete",
"thread-id": "b5f6c1c2-1111-2222-3333-444455556666",
"turn-id": "12345",
"cwd": "/Users/example/project",
"input-messages": ["Rename `foo` to `bar` and update the callsites."],
"last-assistant-message": "Rename complete and verified `cargo build` succeeds.",
})
}
#[test]
fn test_user_notification() -> Result<()> {
let notification = UserNotification::AgentTurnComplete {
thread_id: "b5f6c1c2-1111-2222-3333-444455556666".to_string(),
turn_id: "12345".to_string(),
cwd: "/Users/example/project".to_string(),
input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()],
last_assistant_message: Some(
"Rename complete and verified `cargo build` succeeds.".to_string(),
),
};
let serialized = serde_json::to_string(&notification)?;
let actual: Value = serde_json::from_str(&serialized)?;
assert_eq!(actual, expected_notification_json());
Ok(())
}
#[test]
fn legacy_notify_json_matches_historical_wire_shape() -> Result<()> {
let hook_event = HookEvent::AfterAgent {
event: super::super::types::HookEventAfterAgent {
thread_id: ThreadId::from_string("b5f6c1c2-1111-2222-3333-444455556666")
.expect("valid thread id"),
turn_id: "12345".to_string(),
input_messages: vec!["Rename `foo` to `bar` and update the callsites.".to_string()],
last_assistant_message: Some(
"Rename complete and verified `cargo build` succeeds.".to_string(),
),
},
};
let serialized = legacy_notify_json(&hook_event, Path::new("/Users/example/project"))?;
let actual: Value = serde_json::from_str(&serialized)?;
assert_eq!(actual, expected_notification_json());
Ok(())
}
}
+3 -2
View File
@@ -13,6 +13,7 @@ pub mod bash;
mod client;
mod client_common;
pub mod codex;
pub use codex::SteerInputError;
mod codex_thread;
mod compact_remote;
pub use codex_thread::CodexThread;
@@ -35,6 +36,7 @@ pub mod features;
mod file_watcher;
mod flags;
pub mod git_info;
pub mod hooks;
pub mod instructions;
pub mod landlock;
pub mod mcp;
@@ -110,6 +112,7 @@ pub use rollout::SessionMeta;
pub use rollout::find_archived_thread_path_by_id_str;
#[deprecated(note = "use find_thread_path_by_id_str")]
pub use rollout::find_conversation_path_by_id_str;
pub use rollout::find_thread_name_by_id;
pub use rollout::find_thread_path_by_id_str;
pub use rollout::find_thread_path_by_name_str;
pub use rollout::list::Cursor;
@@ -124,12 +127,10 @@ pub use rollout::session_index::find_thread_names_by_ids;
mod function_tool;
mod state;
mod tasks;
mod user_notification;
mod user_shell_command;
pub mod util;
pub use apply_patch::CODEX_APPLY_PATCH_ARG1;
pub use client::WEB_SEARCH_ELIGIBLE_HEADER;
pub use client::X_CODEX_TURN_METADATA_HEADER;
pub use command_safety::is_dangerous_command;
pub use command_safety::is_safe_command;
+1
View File
@@ -99,6 +99,7 @@ fn codex_apps_mcp_server_config(config: &Config, auth: Option<&CodexAuth>) -> Mc
env_http_headers: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: Some(Duration::from_secs(30)),
tool_timeout_sec: None,
@@ -379,6 +379,7 @@ fn mcp_dependency_to_server_config(
env_http_headers: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -402,6 +403,7 @@ fn mcp_dependency_to_server_config(
cwd: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -455,6 +457,7 @@ mod tests {
env_http_headers: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -502,6 +505,7 @@ mod tests {
env_http_headers: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -474,6 +474,31 @@ impl McpConnectionManager {
}
}
pub(crate) async fn required_startup_failures(
&self,
required_servers: &[String],
) -> Vec<McpStartupFailure> {
let mut failures = Vec::new();
for server_name in required_servers {
let Some(async_managed_client) = self.clients.get(server_name).cloned() else {
failures.push(McpStartupFailure {
server: server_name.clone(),
error: format!("required MCP server `{server_name}` was not initialized"),
});
continue;
};
match async_managed_client.client().await {
Ok(_) => {}
Err(error) => failures.push(McpStartupFailure {
server: server_name.clone(),
error: startup_outcome_error_message(error),
}),
}
}
failures
}
/// Returns a single map that contains all tools. Each key is the
/// fully-qualified name for the tool.
#[instrument(level = "trace", skip_all)]
@@ -1131,6 +1156,13 @@ fn is_mcp_client_startup_timeout_error(error: &StartupOutcomeError) -> bool {
}
}
fn startup_outcome_error_message(error: StartupOutcomeError) -> String {
match error {
StartupOutcomeError::Cancelled => "MCP startup cancelled".to_string(),
StartupOutcomeError::Failed { error } => error,
}
}
#[cfg(test)]
mod mcp_init_error_display_tests {}
@@ -1325,6 +1357,7 @@ mod tests {
env_http_headers: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
@@ -1371,6 +1404,7 @@ mod tests {
env_http_headers: None,
},
enabled: true,
required: false,
disabled_reason: None,
startup_timeout_sec: None,
tool_timeout_sec: None,
-5
View File
@@ -8,7 +8,6 @@
use crate::auth::AuthMode;
use crate::error::EnvVarError;
use codex_api::Provider as ApiProvider;
use codex_api::is_azure_responses_wire_base_url;
use codex_api::provider::RetryConfig as ApiRetryConfig;
use http::HeaderMap;
use http::header::HeaderName;
@@ -174,10 +173,6 @@ impl ModelProviderInfo {
})
}
pub(crate) fn is_azure_responses_endpoint(&self) -> bool {
is_azure_responses_wire_base_url(&self.name, self.base_url.as_deref())
}
/// If `env_key` is Some, returns the API key for this provider if present
/// (and non-empty) in the environment. If `env_key` is required but
/// cannot be found, returns an error.
@@ -1,10 +1,13 @@
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::TUI_VISIBLE_COLLABORATION_MODES;
use codex_protocol::openai_models::ReasoningEffort;
const COLLABORATION_MODE_PLAN: &str = include_str!("../../templates/collaboration_mode/plan.md");
const COLLABORATION_MODE_DEFAULT: &str =
include_str!("../../templates/collaboration_mode/default.md");
const KNOWN_MODE_NAMES_PLACEHOLDER: &str = "{{KNOWN_MODE_NAMES}}";
const REQUEST_USER_INPUT_AVAILABILITY_PLACEHOLDER: &str = "{{REQUEST_USER_INPUT_AVAILABILITY}}";
pub(super) fn builtin_collaboration_mode_presets() -> Vec<CollaborationModeMask> {
vec![plan_preset(), default_preset()]
@@ -17,7 +20,7 @@ pub fn test_builtin_collaboration_mode_presets() -> Vec<CollaborationModeMask> {
fn plan_preset() -> CollaborationModeMask {
CollaborationModeMask {
name: "Plan".to_string(),
name: ModeKind::Plan.display_name().to_string(),
mode: Some(ModeKind::Plan),
model: None,
reasoning_effort: Some(Some(ReasoningEffort::Medium)),
@@ -27,10 +30,74 @@ fn plan_preset() -> CollaborationModeMask {
fn default_preset() -> CollaborationModeMask {
CollaborationModeMask {
name: "Default".to_string(),
name: ModeKind::Default.display_name().to_string(),
mode: Some(ModeKind::Default),
model: None,
reasoning_effort: None,
developer_instructions: Some(Some(COLLABORATION_MODE_DEFAULT.to_string())),
developer_instructions: Some(Some(default_mode_instructions())),
}
}
fn default_mode_instructions() -> String {
let known_mode_names = format_mode_names(&TUI_VISIBLE_COLLABORATION_MODES);
let request_user_input_availability =
request_user_input_availability_message(ModeKind::Default);
COLLABORATION_MODE_DEFAULT
.replace(KNOWN_MODE_NAMES_PLACEHOLDER, &known_mode_names)
.replace(
REQUEST_USER_INPUT_AVAILABILITY_PLACEHOLDER,
&request_user_input_availability,
)
}
fn format_mode_names(modes: &[ModeKind]) -> String {
let mode_names: Vec<&str> = modes.iter().map(|mode| mode.display_name()).collect();
match mode_names.as_slice() {
[] => "none".to_string(),
[mode_name] => (*mode_name).to_string(),
[first, second] => format!("{first} and {second}"),
[..] => mode_names.join(", "),
}
}
fn request_user_input_availability_message(mode: ModeKind) -> String {
let mode_name = mode.display_name();
if mode.allows_request_user_input() {
format!("The `request_user_input` tool is available in {mode_name} mode.")
} else {
format!(
"The `request_user_input` tool is unavailable in {mode_name} mode. If you call it while in {mode_name} mode, it will return an error."
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn preset_names_use_mode_display_names() {
assert_eq!(plan_preset().name, ModeKind::Plan.display_name());
assert_eq!(default_preset().name, ModeKind::Default.display_name());
}
#[test]
fn default_mode_instructions_replace_mode_names_placeholder() {
let default_instructions = default_preset()
.developer_instructions
.expect("default preset should include instructions")
.expect("default instructions should be set");
assert!(!default_instructions.contains(KNOWN_MODE_NAMES_PLACEHOLDER));
assert!(!default_instructions.contains(REQUEST_USER_INPUT_AVAILABILITY_PLACEHOLDER));
let known_mode_names = format_mode_names(&TUI_VISIBLE_COLLABORATION_MODES);
let expected_snippet = format!("Known mode names are {known_mode_names}.");
assert!(default_instructions.contains(&expected_snippet));
let expected_availability_message =
request_user_input_availability_message(ModeKind::Default);
assert!(default_instructions.contains(&expected_availability_message));
}
}
+5 -8
View File
@@ -61,7 +61,7 @@ impl ModelsManager {
let cache_path = codex_home.join(MODEL_CACHE_FILE);
let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL);
Self {
local_models: builtin_model_presets(auth_manager.get_internal_auth_mode()),
local_models: builtin_model_presets(auth_manager.auth_mode()),
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
auth_manager,
etag: RwLock::new(None),
@@ -175,7 +175,7 @@ impl ModelsManager {
refresh_strategy: RefreshStrategy,
) -> CoreResult<()> {
if !config.features.enabled(Feature::RemoteModels)
|| self.auth_manager.get_internal_auth_mode() == Some(AuthMode::ApiKey)
|| self.auth_manager.auth_mode() == Some(AuthMode::ApiKey)
{
return Ok(());
}
@@ -204,7 +204,7 @@ impl ModelsManager {
let _timer =
codex_otel::start_global_timer("codex.remote_models.fetch_update.duration_ms", &[]);
let auth = self.auth_manager.auth().await;
let auth_mode = self.auth_manager.get_internal_auth_mode();
let auth_mode = self.auth_manager.auth_mode();
let api_provider = self.provider.to_api_provider(auth_mode)?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?;
let transport = ReqwestTransport::new(build_reqwest_client());
@@ -275,10 +275,7 @@ impl ModelsManager {
let remote_presets: Vec<ModelPreset> = remote_models.into_iter().map(Into::into).collect();
let existing_presets = self.local_models.clone();
let mut merged_presets = ModelPreset::merge(remote_presets, existing_presets);
let chatgpt_mode = matches!(
self.auth_manager.get_internal_auth_mode(),
Some(AuthMode::Chatgpt)
);
let chatgpt_mode = matches!(self.auth_manager.auth_mode(), Some(AuthMode::Chatgpt));
merged_presets = ModelPreset::filter_by_auth(merged_presets, chatgpt_mode);
for preset in &mut merged_presets {
@@ -322,7 +319,7 @@ impl ModelsManager {
let cache_path = codex_home.join(MODEL_CACHE_FILE);
let cache_manager = ModelsCacheManager::new(cache_path, DEFAULT_MODEL_CACHE_TTL);
Self {
local_models: builtin_model_presets(auth_manager.get_internal_auth_mode()),
local_models: builtin_model_presets(auth_manager.auth_mode()),
remote_models: RwLock::new(Self::load_remote_models_from_file().unwrap_or_default()),
auth_manager,
etag: RwLock::new(None),
+121 -31
View File
@@ -15,9 +15,7 @@ use uuid::Uuid;
use super::ARCHIVED_SESSIONS_SUBDIR;
use super::SESSIONS_SUBDIR;
use crate::instructions::UserInstructions;
use crate::protocol::EventMsg;
use crate::session_prefix::is_session_prefix_content;
use crate::state_db;
use codex_file_search as file_search;
use codex_protocol::ThreadId;
@@ -25,6 +23,7 @@ use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_protocol::protocol::USER_MESSAGE_BEGIN;
/// Returned page of thread (thread) summaries.
#[derive(Debug, Default, PartialEq)]
@@ -44,8 +43,24 @@ pub struct ThreadsPage {
pub struct ThreadItem {
/// Absolute path to the rollout file.
pub path: PathBuf,
/// First up to `HEAD_RECORD_LIMIT` JSONL records parsed as JSON (includes meta line).
pub head: Vec<serde_json::Value>,
/// Thread ID from session metadata.
pub thread_id: Option<ThreadId>,
/// First user message captured for this thread, if any.
pub first_user_message: Option<String>,
/// Working directory from session metadata.
pub cwd: Option<PathBuf>,
/// Git branch from session metadata.
pub git_branch: Option<String>,
/// Git commit SHA from session metadata.
pub git_sha: Option<String>,
/// Git origin URL from session metadata.
pub git_origin_url: Option<String>,
/// Session source from session metadata.
pub source: Option<SessionSource>,
/// Model provider from session metadata.
pub model_provider: Option<String>,
/// CLI version from session metadata.
pub cli_version: Option<String>,
/// RFC3339 timestamp string for when the session was created, if available.
/// created_at comes from the filename timestamp with second precision.
pub created_at: Option<String>,
@@ -63,11 +78,17 @@ pub type ConversationsPage = ThreadsPage;
#[derive(Default)]
struct HeadTailSummary {
head: Vec<serde_json::Value>,
saw_session_meta: bool,
saw_user_event: bool,
thread_id: Option<ThreadId>,
first_user_message: Option<String>,
cwd: Option<PathBuf>,
git_branch: Option<String>,
git_sha: Option<String>,
git_origin_url: Option<String>,
source: Option<SessionSource>,
model_provider: Option<String>,
cli_version: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
}
@@ -674,7 +695,8 @@ async fn build_thread_item(
if !allowed_sources.is_empty()
&& !summary
.source
.is_some_and(|source| allowed_sources.contains(&source))
.as_ref()
.is_some_and(|source| allowed_sources.contains(source))
{
return None;
}
@@ -686,7 +708,15 @@ async fn build_thread_item(
// Apply filters: must have session meta and at least one user message event
if summary.saw_session_meta && summary.saw_user_event {
let HeadTailSummary {
head,
thread_id,
first_user_message,
cwd,
git_branch,
git_sha,
git_origin_url,
source,
model_provider,
cli_version,
created_at,
updated_at: mut summary_updated_at,
..
@@ -696,7 +726,15 @@ async fn build_thread_item(
}
return Some(ThreadItem {
path,
head,
thread_id,
first_user_message,
cwd,
git_branch,
git_sha,
git_origin_url,
source,
model_provider,
cli_version,
created_at,
updated_at: summary_updated_at,
});
@@ -979,31 +1017,29 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
RolloutItem::SessionMeta(session_meta_line) => {
summary.source = Some(session_meta_line.meta.source.clone());
summary.model_provider = session_meta_line.meta.model_provider.clone();
summary.thread_id = Some(session_meta_line.meta.id);
summary.cwd = Some(session_meta_line.meta.cwd.clone());
summary.git_branch = session_meta_line
.git
.as_ref()
.and_then(|git| git.branch.clone());
summary.git_sha = session_meta_line
.git
.as_ref()
.and_then(|git| git.commit_hash.clone());
summary.git_origin_url = session_meta_line
.git
.as_ref()
.and_then(|git| git.repository_url.clone());
summary.cli_version = Some(session_meta_line.meta.cli_version);
summary.created_at = Some(session_meta_line.meta.timestamp.clone());
summary.saw_session_meta = true;
if summary.head.len() < head_limit
&& let Ok(val) = serde_json::to_value(session_meta_line)
{
summary.head.push(val);
}
}
RolloutItem::ResponseItem(item) => {
RolloutItem::ResponseItem(_) => {
summary.created_at = summary
.created_at
.clone()
.or_else(|| Some(rollout_line.timestamp.clone()));
if let codex_protocol::models::ResponseItem::Message { role, content, .. } = &item
&& role == "user"
&& !UserInstructions::is_user_instructions(content.as_slice())
&& !is_session_prefix_content(content.as_slice())
{
summary.saw_user_event = true;
}
if summary.head.len() < head_limit
&& let Ok(val) = serde_json::to_value(item)
{
summary.head.push(val);
}
}
RolloutItem::TurnContext(_) => {
// Not included in `head`; skip.
@@ -1012,8 +1048,14 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
// Not included in `head`; skip.
}
RolloutItem::EventMsg(ev) => {
if matches!(ev, EventMsg::UserMessage(_)) {
if let EventMsg::UserMessage(user) = ev {
summary.saw_user_event = true;
if summary.first_user_message.is_none() {
let message = strip_user_message_prefix(user.message.as_str()).to_string();
if !message.is_empty() {
summary.first_user_message = Some(message);
}
}
}
}
}
@@ -1029,8 +1071,48 @@ async fn read_head_summary(path: &Path, head_limit: usize) -> io::Result<HeadTai
/// Read up to `HEAD_RECORD_LIMIT` records from the start of the rollout file at `path`.
/// This should be enough to produce a summary including the session meta line.
pub async fn read_head_for_summary(path: &Path) -> io::Result<Vec<serde_json::Value>> {
let summary = read_head_summary(path, HEAD_RECORD_LIMIT).await?;
Ok(summary.head)
use tokio::io::AsyncBufReadExt;
let file = tokio::fs::File::open(path).await?;
let reader = tokio::io::BufReader::new(file);
let mut lines = reader.lines();
let mut head = Vec::new();
while head.len() < HEAD_RECORD_LIMIT {
let Some(line) = lines.next_line().await? else {
break;
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if let Ok(rollout_line) = serde_json::from_str::<RolloutLine>(trimmed) {
match rollout_line.item {
RolloutItem::SessionMeta(session_meta_line) => {
if let Ok(value) = serde_json::to_value(session_meta_line) {
head.push(value);
}
}
RolloutItem::ResponseItem(item) => {
if let Ok(value) = serde_json::to_value(item) {
head.push(value);
}
}
RolloutItem::Compacted(_)
| RolloutItem::TurnContext(_)
| RolloutItem::EventMsg(_) => {}
}
}
}
Ok(head)
}
fn strip_user_message_prefix(text: &str) -> &str {
match text.find(USER_MESSAGE_BEGIN) {
Some(idx) => text[idx + USER_MESSAGE_BEGIN.len()..].trim(),
None => text.trim(),
}
}
/// Read the SessionMetaLine from the head of a rollout file for reuse by
@@ -1088,9 +1170,10 @@ async fn find_thread_path_by_id_str_in_subdir(
ARCHIVED_SESSIONS_SUBDIR => Some(true),
_ => None,
};
let thread_id = ThreadId::from_string(id_str).ok();
let state_db_ctx = state_db::open_if_present(codex_home, "").await;
if let Some(state_db_ctx) = state_db_ctx.as_deref()
&& let Ok(thread_id) = ThreadId::from_string(id_str)
&& let Some(thread_id) = thread_id
&& let Some(db_path) = state_db::find_rollout_path_by_id(
Some(state_db_ctx),
thread_id,
@@ -1128,9 +1211,16 @@ async fn find_thread_path_by_id_str_in_subdir(
.map_err(|e| io::Error::other(format!("file search failed: {e}")))?;
let found = results.matches.into_iter().next().map(|m| m.full_path());
if found.is_some() {
if let Some(found_path) = found.as_ref() {
tracing::error!("state db missing rollout path for thread {id_str}");
state_db::record_discrepancy("find_thread_path_by_id_str_in_subdir", "falling_back");
state_db::read_repair_rollout_path(
state_db_ctx.as_deref(),
thread_id,
archived_only,
found_path.as_path(),
)
.await;
}
Ok(found)
+292 -62
View File
@@ -13,14 +13,15 @@ use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_state::BackfillState;
use codex_state::BackfillStats;
use codex_state::BackfillStatus;
use codex_state::DB_ERROR_METRIC;
use codex_state::DB_METRIC_BACKFILL;
use codex_state::DB_METRIC_BACKFILL_DURATION_MS;
use codex_state::ExtractionOutcome;
use codex_state::ThreadMetadataBuilder;
use codex_state::apply_rollout_item;
use std::cmp::Reverse;
use std::path::Path;
use std::path::PathBuf;
use tracing::info;
@@ -28,6 +29,7 @@ use tracing::warn;
const ROLLOUT_PREFIX: &str = "rollout-";
const ROLLOUT_SUFFIX: &str = ".jsonl";
const BACKFILL_BATCH_SIZE: usize = 200;
pub(crate) fn builder_from_session_meta(
session_meta: &SessionMetaLine,
@@ -42,6 +44,7 @@ pub(crate) fn builder_from_session_meta(
);
builder.model_provider = session_meta.meta.model_provider.clone();
builder.cwd = session_meta.meta.cwd.clone();
builder.cli_version = Some(session_meta.meta.cli_version.clone());
builder.sandbox_policy = SandboxPolicy::ReadOnly;
builder.approval_mode = AskForApproval::OnRequest;
if let Some(git) = session_meta.git.as_ref() {
@@ -130,16 +133,52 @@ pub(crate) async fn backfill_sessions(
otel: Option<&OtelManager>,
) {
let timer = otel.and_then(|otel| otel.start_timer(DB_METRIC_BACKFILL_DURATION_MS, &[]).ok());
let mut backfill_state = match runtime.get_backfill_state().await {
Ok(state) => state,
Err(err) => {
warn!(
"failed to read backfill state at {}: {err}",
config.codex_home.display()
);
if let Some(otel) = otel {
otel.counter(DB_ERROR_METRIC, 1, &[("stage", "backfill_state_read")]);
}
BackfillState::default()
}
};
if backfill_state.status == BackfillStatus::Complete {
return;
}
if let Err(err) = runtime.mark_backfill_running().await {
warn!(
"failed to mark backfill running at {}: {err}",
config.codex_home.display()
);
if let Some(otel) = otel {
otel.counter(
DB_ERROR_METRIC,
1,
&[("stage", "backfill_state_mark_running")],
);
}
} else {
backfill_state.status = BackfillStatus::Running;
}
let sessions_root = config.codex_home.join(rollout::SESSIONS_SUBDIR);
let archived_root = config.codex_home.join(rollout::ARCHIVED_SESSIONS_SUBDIR);
let mut rollout_paths: Vec<(PathBuf, bool)> = Vec::new();
let mut rollout_paths: Vec<BackfillRolloutPath> = Vec::new();
for (root, archived) in [(sessions_root, false), (archived_root, true)] {
if !tokio::fs::try_exists(&root).await.unwrap_or(false) {
continue;
}
match collect_rollout_paths(&root).await {
Ok(paths) => {
rollout_paths.extend(paths.into_iter().map(|path| (path, archived)));
rollout_paths.extend(paths.into_iter().map(|path| BackfillRolloutPath {
watermark: backfill_watermark_for_path(config.codex_home.as_path(), &path),
path,
archived,
}));
}
Err(err) => {
warn!(
@@ -149,75 +188,126 @@ pub(crate) async fn backfill_sessions(
}
}
}
rollout_paths.sort_by_key(|(path, _archived)| {
let parsed = path
.file_name()
.and_then(|name| name.to_str())
.and_then(parse_timestamp_uuid_from_filename)
.unwrap_or((time::OffsetDateTime::UNIX_EPOCH, uuid::Uuid::nil()));
(Reverse(parsed.0), Reverse(parsed.1))
});
rollout_paths.sort_by(|a, b| a.watermark.cmp(&b.watermark));
if let Some(last_watermark) = backfill_state.last_watermark.as_deref() {
rollout_paths.retain(|entry| entry.watermark.as_str() > last_watermark);
}
let mut stats = BackfillStats {
scanned: 0,
upserted: 0,
failed: 0,
};
for (path, archived) in rollout_paths {
stats.scanned = stats.scanned.saturating_add(1);
match extract_metadata_from_rollout(&path, config.model_provider_id.as_str(), otel).await {
Ok(outcome) => {
if outcome.parse_errors > 0
&& let Some(otel) = otel
{
otel.counter(
DB_ERROR_METRIC,
outcome.parse_errors as i64,
&[("stage", "backfill_sessions")],
);
}
let mut metadata = outcome.metadata;
if archived && metadata.archived_at.is_none() {
let fallback_archived_at = metadata.updated_at;
metadata.archived_at = file_modified_time_utc(&path)
.await
.or(Some(fallback_archived_at));
}
if let Err(err) = runtime.upsert_thread(&metadata).await {
stats.failed = stats.failed.saturating_add(1);
warn!("failed to upsert rollout {}: {err}", path.display());
} else {
stats.upserted = stats.upserted.saturating_add(1);
if let Ok(meta_line) = rollout::list::read_session_meta_line(&path).await {
if let Err(err) = runtime
.persist_dynamic_tools(
meta_line.meta.id,
meta_line.meta.dynamic_tools.as_deref(),
)
.await
{
if let Some(otel) = otel {
otel.counter(
DB_ERROR_METRIC,
1,
&[("stage", "backfill_dynamic_tools")],
);
}
warn!("failed to backfill dynamic tools {}: {err}", path.display());
}
} else {
warn!(
"failed to read session meta for dynamic tools {}",
path.display()
let mut last_watermark = backfill_state.last_watermark.clone();
for batch in rollout_paths.chunks(BACKFILL_BATCH_SIZE) {
for rollout in batch {
stats.scanned = stats.scanned.saturating_add(1);
match extract_metadata_from_rollout(
&rollout.path,
config.model_provider_id.as_str(),
otel,
)
.await
{
Ok(outcome) => {
if outcome.parse_errors > 0
&& let Some(otel) = otel
{
otel.counter(
DB_ERROR_METRIC,
outcome.parse_errors as i64,
&[("stage", "backfill_sessions")],
);
}
let mut metadata = outcome.metadata;
if rollout.archived && metadata.archived_at.is_none() {
let fallback_archived_at = metadata.updated_at;
metadata.archived_at = file_modified_time_utc(&rollout.path)
.await
.or(Some(fallback_archived_at));
}
if let Err(err) = runtime.upsert_thread(&metadata).await {
stats.failed = stats.failed.saturating_add(1);
warn!("failed to upsert rollout {}: {err}", rollout.path.display());
} else {
stats.upserted = stats.upserted.saturating_add(1);
if let Ok(meta_line) =
rollout::list::read_session_meta_line(&rollout.path).await
{
if let Err(err) = runtime
.persist_dynamic_tools(
meta_line.meta.id,
meta_line.meta.dynamic_tools.as_deref(),
)
.await
{
if let Some(otel) = otel {
otel.counter(
DB_ERROR_METRIC,
1,
&[("stage", "backfill_dynamic_tools")],
);
}
warn!(
"failed to backfill dynamic tools {}: {err}",
rollout.path.display()
);
}
} else {
warn!(
"failed to read session meta for dynamic tools {}",
rollout.path.display()
);
}
}
}
Err(err) => {
stats.failed = stats.failed.saturating_add(1);
warn!(
"failed to extract rollout {}: {err}",
rollout.path.display()
);
}
}
Err(err) => {
stats.failed = stats.failed.saturating_add(1);
warn!("failed to extract rollout {}: {err}", path.display());
}
if let Some(last_entry) = batch.last() {
if let Err(err) = runtime
.checkpoint_backfill(last_entry.watermark.as_str())
.await
{
warn!(
"failed to checkpoint backfill at {}: {err}",
config.codex_home.display()
);
if let Some(otel) = otel {
otel.counter(
DB_ERROR_METRIC,
1,
&[("stage", "backfill_state_checkpoint")],
);
}
} else {
last_watermark = Some(last_entry.watermark.clone());
}
}
}
if let Err(err) = runtime
.mark_backfill_complete(last_watermark.as_deref())
.await
{
warn!(
"failed to mark backfill complete at {}: {err}",
config.codex_home.display()
);
if let Some(otel) = otel {
otel.counter(
DB_ERROR_METRIC,
1,
&[("stage", "backfill_state_mark_complete")],
);
}
}
info!(
"state db backfill scanned={}, upserted={}, failed={}",
@@ -247,6 +337,20 @@ pub(crate) async fn backfill_sessions(
}
}
#[derive(Debug, Clone)]
struct BackfillRolloutPath {
watermark: String,
path: PathBuf,
archived: bool,
}
fn backfill_watermark_for_path(codex_home: &Path, path: &Path) -> String {
path.strip_prefix(codex_home)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
async fn file_modified_time_utc(path: &Path) -> Option<DateTime<Utc>> {
let modified = tokio::fs::metadata(path).await.ok()?.modified().ok()?;
let updated_at: DateTime<Utc> = modified.into();
@@ -276,9 +380,28 @@ async fn collect_rollout_paths(root: &Path) -> std::io::Result<Vec<PathBuf>> {
continue;
}
};
while let Some(entry) = read_dir.next_entry().await? {
loop {
let next_entry = match read_dir.next_entry().await {
Ok(next_entry) => next_entry,
Err(err) => {
warn!(
"failed to read directory entry under {}: {err}",
dir.display()
);
continue;
}
};
let Some(entry) = next_entry else {
break;
};
let path = entry.path();
let file_type = entry.file_type().await?;
let file_type = match entry.file_type().await {
Ok(file_type) => file_type,
Err(err) => {
warn!("failed to read file type for {}: {err}", path.display());
continue;
}
};
if file_type.is_dir() {
stack.push(path);
continue;
@@ -312,10 +435,13 @@ mod tests {
use codex_protocol::protocol::SessionMeta;
use codex_protocol::protocol::SessionMetaLine;
use codex_protocol::protocol::SessionSource;
use codex_state::BackfillStatus;
use codex_state::ThreadMetadataBuilder;
use pretty_assertions::assert_eq;
use std::fs::File;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use tempfile::tempdir;
use uuid::Uuid;
@@ -393,4 +519,108 @@ mod tests {
assert_eq!(builder, expected);
}
#[tokio::test]
async fn backfill_sessions_resumes_from_watermark_and_marks_complete() {
let dir = tempdir().expect("tempdir");
let codex_home = dir.path().to_path_buf();
let first_uuid = Uuid::new_v4();
let second_uuid = Uuid::new_v4();
let first_path = write_rollout_in_sessions(
codex_home.as_path(),
"2026-01-27T12-34-56",
"2026-01-27T12:34:56Z",
first_uuid,
);
let second_path = write_rollout_in_sessions(
codex_home.as_path(),
"2026-01-27T12-35-56",
"2026-01-27T12:35:56Z",
second_uuid,
);
let runtime =
codex_state::StateRuntime::init(codex_home.clone(), "test-provider".to_string(), None)
.await
.expect("initialize runtime");
let first_watermark =
backfill_watermark_for_path(codex_home.as_path(), first_path.as_path());
runtime.mark_backfill_running().await.expect("mark running");
runtime
.checkpoint_backfill(first_watermark.as_str())
.await
.expect("checkpoint first watermark");
let mut config = crate::config::test_config();
config.codex_home = codex_home.clone();
config.model_provider_id = "test-provider".to_string();
backfill_sessions(runtime.as_ref(), &config, None).await;
let first_id = ThreadId::from_string(&first_uuid.to_string()).expect("first thread id");
let second_id = ThreadId::from_string(&second_uuid.to_string()).expect("second thread id");
assert_eq!(
runtime
.get_thread(first_id)
.await
.expect("get first thread"),
None
);
assert!(
runtime
.get_thread(second_id)
.await
.expect("get second thread")
.is_some()
);
let state = runtime
.get_backfill_state()
.await
.expect("get backfill state");
assert_eq!(state.status, BackfillStatus::Complete);
assert_eq!(
state.last_watermark,
Some(backfill_watermark_for_path(
codex_home.as_path(),
second_path.as_path()
))
);
assert!(state.last_success_at.is_some());
}
fn write_rollout_in_sessions(
codex_home: &Path,
filename_ts: &str,
event_ts: &str,
thread_uuid: Uuid,
) -> PathBuf {
let id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id");
let sessions_dir = codex_home.join("sessions");
std::fs::create_dir_all(sessions_dir.as_path()).expect("create sessions dir");
let path = sessions_dir.join(format!("rollout-{filename_ts}-{thread_uuid}.jsonl"));
let session_meta = SessionMeta {
id,
forked_from_id: None,
timestamp: event_ts.to_string(),
cwd: codex_home.to_path_buf(),
originator: "cli".to_string(),
cli_version: "0.0.0".to_string(),
source: SessionSource::default(),
model_provider: Some("test-provider".to_string()),
base_instructions: None,
dynamic_tools: None,
};
let session_meta_line = SessionMetaLine {
meta: session_meta,
git: None,
};
let rollout_line = RolloutLine {
timestamp: event_ts.to_string(),
item: RolloutItem::SessionMeta(session_meta_line),
};
let json = serde_json::to_string(&rollout_line).expect("serialize rollout");
let mut file = File::create(&path).expect("create rollout");
writeln!(file, "{json}").expect("write rollout");
path
}
}
+1
View File
@@ -24,6 +24,7 @@ pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str;
pub use list::rollout_date_parts;
pub use recorder::RolloutRecorder;
pub use recorder::RolloutRecorderParams;
pub use session_index::find_thread_name_by_id;
pub use session_index::find_thread_path_by_name_str;
#[cfg(test)]
+69 -35
View File
@@ -19,6 +19,7 @@ use tokio::sync::mpsc::Sender;
use tokio::sync::mpsc::{self};
use tokio::sync::oneshot;
use tracing::info;
use tracing::trace;
use tracing::warn;
use super::ARCHIVED_SESSIONS_SUBDIR;
@@ -31,7 +32,6 @@ use super::list::ThreadSortKey;
use super::list::ThreadsPage;
use super::list::get_threads;
use super::list::get_threads_in_root;
use super::list::read_head_for_summary;
use super::metadata;
use super::policy::is_persisted_response_item;
use crate::config::Config;
@@ -183,9 +183,7 @@ impl RolloutRecorder {
)
.await
{
let mut page: ThreadsPage = db_page.into();
populate_thread_heads(page.items.as_mut_slice()).await;
return Ok(page);
return Ok(db_page.into());
}
tracing::error!("Falling back on rollout system");
state_db::record_discrepancy("list_threads_with_db_fallback", "falling_back");
@@ -231,9 +229,37 @@ impl RolloutRecorder {
default_provider: &str,
filter_cwd: Option<&Path>,
) -> std::io::Result<Option<PathBuf>> {
let state_db_ctx = state_db::open_if_present(codex_home, default_provider).await;
if state_db_ctx.is_some() {
let mut db_cursor = cursor.cloned();
loop {
let Some(db_page) = state_db::list_threads_db(
state_db_ctx.as_deref(),
codex_home,
page_size,
db_cursor.as_ref(),
sort_key,
allowed_sources,
model_providers,
false,
)
.await
else {
break;
};
if let Some(path) = select_resume_path_from_db_page(&db_page, filter_cwd) {
return Ok(Some(path));
}
db_cursor = db_page.next_anchor.map(Into::into);
if db_cursor.is_none() {
break;
}
}
}
let mut cursor = cursor.cloned();
loop {
let page = Self::list_threads(
let page = get_threads(
codex_home,
page_size,
cursor.as_ref(),
@@ -386,7 +412,7 @@ impl RolloutRecorder {
pub(crate) async fn load_rollout_items(
path: &Path,
) -> std::io::Result<(Vec<RolloutItem>, Option<ThreadId>, usize)> {
info!("Resuming rollout from {path:?}");
trace!("Resuming rollout from {path:?}");
let text = tokio::fs::read_to_string(path).await?;
if text.trim().is_empty() {
return Err(IoError::other("empty session file"));
@@ -433,7 +459,7 @@ impl RolloutRecorder {
}
},
Err(e) => {
warn!("failed to parse rollout line: {e}");
trace!("failed to parse rollout line: {e}");
parse_errors = parse_errors.saturating_add(1);
}
}
@@ -567,6 +593,7 @@ async fn rollout_writer(
default_provider.as_str(),
state_builder.as_ref(),
std::slice::from_ref(&rollout_item),
None,
)
.await;
}
@@ -657,7 +684,18 @@ impl From<codex_state::ThreadsPage> for ThreadsPage {
.into_iter()
.map(|item| ThreadItem {
path: item.rollout_path,
head: Vec::new(),
thread_id: Some(item.id),
first_user_message: item.first_user_message,
cwd: Some(item.cwd),
git_branch: item.git_branch,
git_sha: item.git_sha,
git_origin_url: item.git_origin_url,
source: Some(
serde_json::from_value(Value::String(item.source))
.unwrap_or(SessionSource::Unknown),
),
model_provider: Some(item.model_provider),
cli_version: Some(item.cli_version),
created_at: Some(item.created_at.to_rfc3339_opts(SecondsFormat::Secs, true)),
updated_at: Some(item.updated_at.to_rfc3339_opts(SecondsFormat::Secs, true)),
})
@@ -671,24 +709,14 @@ impl From<codex_state::ThreadsPage> for ThreadsPage {
}
}
async fn populate_thread_heads(items: &mut [ThreadItem]) {
for item in items {
item.head = read_head_for_summary(item.path.as_path())
.await
.unwrap_or_else(|err| {
warn!(
"failed to read rollout head from state db path: {} ({err})",
item.path.display()
);
Vec::new()
});
}
}
fn select_resume_path(page: &ThreadsPage, filter_cwd: Option<&Path>) -> Option<PathBuf> {
match filter_cwd {
Some(cwd) => page.items.iter().find_map(|item| {
if session_cwd_matches(&item.head, cwd) {
if item
.cwd
.as_ref()
.is_some_and(|session_cwd| cwd_matches(session_cwd, cwd))
{
Some(item.path.clone())
} else {
None
@@ -698,22 +726,28 @@ fn select_resume_path(page: &ThreadsPage, filter_cwd: Option<&Path>) -> Option<P
}
}
fn session_cwd_matches(head: &[serde_json::Value], cwd: &Path) -> bool {
let Some(session_cwd) = extract_session_cwd(head) else {
return false;
};
fn select_resume_path_from_db_page(
page: &codex_state::ThreadsPage,
filter_cwd: Option<&Path>,
) -> Option<PathBuf> {
match filter_cwd {
Some(cwd) => page.items.iter().find_map(|item| {
if cwd_matches(item.cwd.as_path(), cwd) {
Some(item.rollout_path.clone())
} else {
None
}
}),
None => page.items.first().map(|item| item.rollout_path.clone()),
}
}
fn cwd_matches(session_cwd: &Path, cwd: &Path) -> bool {
if let (Ok(ca), Ok(cb)) = (
path_utils::normalize_for_path_comparison(&session_cwd),
path_utils::normalize_for_path_comparison(session_cwd),
path_utils::normalize_for_path_comparison(cwd),
) {
return ca == cb;
}
session_cwd == cwd
}
fn extract_session_cwd(head: &[serde_json::Value]) -> Option<PathBuf> {
head.iter().find_map(|value| {
let meta_line = serde_json::from_value::<SessionMetaLine>(value.clone()).ok()?;
Some(meta_line.meta.cwd)
})
}
+180 -138
View File
@@ -23,6 +23,7 @@ use crate::rollout::list::ThreadItem;
use crate::rollout::list::ThreadSortKey;
use crate::rollout::list::ThreadsPage;
use crate::rollout::list::get_threads;
use crate::rollout::list::read_head_for_summary;
use crate::rollout::recorder::RolloutRecorder;
use crate::rollout::rollout_date_parts;
use anyhow::Result;
@@ -47,6 +48,10 @@ fn provider_vec(providers: &[&str]) -> Vec<String> {
.collect()
}
fn thread_id_from_uuid(uuid: Uuid) -> ThreadId {
ThreadId::from_string(&uuid.to_string()).expect("valid thread id")
}
async fn insert_state_db_thread(
home: &Path,
thread_id: ThreadId,
@@ -57,6 +62,10 @@ async fn insert_state_db_thread(
codex_state::StateRuntime::init(home.to_path_buf(), TEST_PROVIDER.to_string(), None)
.await
.expect("state db should initialize");
runtime
.mark_backfill_complete(None)
.await
.expect("backfill should be complete");
let created_at = chrono::Utc
.with_ymd_and_hms(2025, 1, 3, 12, 0, 0)
.single()
@@ -73,7 +82,7 @@ async fn insert_state_db_thread(
builder.archived_at = Some(created_at);
}
let mut metadata = builder.build(TEST_PROVIDER);
metadata.has_user_event = true;
metadata.first_user_message = Some("Hello from user".to_string());
runtime
.upsert_thread(&metadata)
.await
@@ -115,6 +124,12 @@ async fn list_threads_prefers_state_db_when_available() {
assert_eq!(page.items.len(), 1);
assert_eq!(page.items[0].path, db_rollout_path);
assert_eq!(page.items[0].thread_id, Some(db_thread_id));
assert_eq!(page.items[0].cwd, Some(home.to_path_buf()));
assert_eq!(
page.items[0].first_user_message.as_deref(),
Some("Hello from user")
);
}
#[tokio::test]
@@ -250,7 +265,35 @@ async fn find_thread_path_falls_back_when_db_path_is_stale() {
let found = crate::rollout::find_thread_path_by_id_str(home, &uuid.to_string())
.await
.expect("lookup should succeed");
assert_eq!(found, Some(fs_rollout_path));
assert_eq!(found, Some(fs_rollout_path.clone()));
assert_state_db_rollout_path(home, thread_id, Some(fs_rollout_path.as_path())).await;
}
#[tokio::test]
async fn find_thread_path_repairs_missing_db_row_after_filesystem_fallback() {
let temp = TempDir::new().unwrap();
let home = temp.path();
let uuid = Uuid::from_u128(303);
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
let ts = "2025-01-03T13-00-00";
write_session_file(home, ts, uuid, 1, Some(SessionSource::Cli)).unwrap();
let fs_rollout_path = home.join(format!("sessions/2025/01/03/rollout-{ts}-{uuid}.jsonl"));
// Create an empty state DB so lookup takes the DB-first path and then falls back to files.
let _runtime =
codex_state::StateRuntime::init(home.to_path_buf(), TEST_PROVIDER.to_string(), None)
.await
.expect("state db should initialize");
_runtime
.mark_backfill_complete(None)
.await
.expect("backfill should be complete");
let found = crate::rollout::find_thread_path_by_id_str(home, &uuid.to_string())
.await
.expect("lookup should succeed");
assert_eq!(found, Some(fs_rollout_path.clone()));
assert_state_db_rollout_path(home, thread_id, Some(fs_rollout_path.as_path())).await;
}
#[test]
@@ -263,6 +306,22 @@ fn rollout_date_parts_extracts_directory_components() {
);
}
async fn assert_state_db_rollout_path(
home: &Path,
thread_id: ThreadId,
expected_path: Option<&Path>,
) {
let runtime =
codex_state::StateRuntime::init(home.to_path_buf(), TEST_PROVIDER.to_string(), None)
.await
.expect("state db should initialize");
let path = runtime
.find_rollout_path_by_id(thread_id, Some(false))
.await
.expect("state db lookup should succeed");
assert_eq!(path.as_deref(), expected_path);
}
fn write_session_file(
root: &Path,
ts_str: &str,
@@ -519,37 +578,6 @@ async fn test_list_conversations_latest_first() {
.join("01")
.join(format!("rollout-2025-01-01T12-00-00-{u1}.jsonl"));
let head_3 = vec![serde_json::json!({
"id": u3,
"timestamp": "2025-01-03T12-00-00",
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
"base_instructions": null,
})];
let head_2 = vec![serde_json::json!({
"id": u2,
"timestamp": "2025-01-02T12-00-00",
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
"base_instructions": null,
})];
let head_1 = vec![serde_json::json!({
"id": u1,
"timestamp": "2025-01-01T12-00-00",
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
"base_instructions": null,
})];
let updated_times: Vec<Option<String>> =
page.items.iter().map(|i| i.updated_at.clone()).collect();
@@ -557,19 +585,43 @@ async fn test_list_conversations_latest_first() {
items: vec![
ThreadItem {
path: p1,
head: head_3,
thread_id: Some(thread_id_from_uuid(u3)),
first_user_message: Some("Hello from user".to_string()),
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some("2025-01-03T12-00-00".into()),
updated_at: updated_times.first().cloned().flatten(),
},
ThreadItem {
path: p2,
head: head_2,
thread_id: Some(thread_id_from_uuid(u2)),
first_user_message: Some("Hello from user".to_string()),
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some("2025-01-02T12-00-00".into()),
updated_at: updated_times.get(1).cloned().flatten(),
},
ThreadItem {
path: p3,
head: head_1,
thread_id: Some(thread_id_from_uuid(u1)),
first_user_message: Some("Hello from user".to_string()),
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some("2025-01-01T12-00-00".into()),
updated_at: updated_times.get(2).cloned().flatten(),
},
@@ -660,26 +712,6 @@ async fn test_pagination_cursor() {
.join("03")
.join("04")
.join(format!("rollout-2025-03-04T09-00-00-{u4}.jsonl"));
let head_5 = vec![serde_json::json!({
"id": u5,
"timestamp": "2025-03-05T09-00-00",
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
"base_instructions": null,
})];
let head_4 = vec![serde_json::json!({
"id": u4,
"timestamp": "2025-03-04T09-00-00",
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
"base_instructions": null,
})];
let updated_page1: Vec<Option<String>> =
page1.items.iter().map(|i| i.updated_at.clone()).collect();
let expected_cursor1: Cursor =
@@ -688,13 +720,29 @@ async fn test_pagination_cursor() {
items: vec![
ThreadItem {
path: p5,
head: head_5,
thread_id: Some(thread_id_from_uuid(u5)),
first_user_message: Some("Hello from user".to_string()),
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some("2025-03-05T09-00-00".into()),
updated_at: updated_page1.first().cloned().flatten(),
},
ThreadItem {
path: p4,
head: head_4,
thread_id: Some(thread_id_from_uuid(u4)),
first_user_message: Some("Hello from user".to_string()),
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some("2025-03-04T09-00-00".into()),
updated_at: updated_page1.get(1).cloned().flatten(),
},
@@ -728,26 +776,6 @@ async fn test_pagination_cursor() {
.join("03")
.join("02")
.join(format!("rollout-2025-03-02T09-00-00-{u2}.jsonl"));
let head_3 = vec![serde_json::json!({
"id": u3,
"timestamp": "2025-03-03T09-00-00",
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
"base_instructions": null,
})];
let head_2 = vec![serde_json::json!({
"id": u2,
"timestamp": "2025-03-02T09-00-00",
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
"base_instructions": null,
})];
let updated_page2: Vec<Option<String>> =
page2.items.iter().map(|i| i.updated_at.clone()).collect();
let expected_cursor2: Cursor =
@@ -756,13 +784,29 @@ async fn test_pagination_cursor() {
items: vec![
ThreadItem {
path: p3,
head: head_3,
thread_id: Some(thread_id_from_uuid(u3)),
first_user_message: Some("Hello from user".to_string()),
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some("2025-03-03T09-00-00".into()),
updated_at: updated_page2.first().cloned().flatten(),
},
ThreadItem {
path: p2,
head: head_2,
thread_id: Some(thread_id_from_uuid(u2)),
first_user_message: Some("Hello from user".to_string()),
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some("2025-03-02T09-00-00".into()),
updated_at: updated_page2.get(1).cloned().flatten(),
},
@@ -790,22 +834,20 @@ async fn test_pagination_cursor() {
.join("03")
.join("01")
.join(format!("rollout-2025-03-01T09-00-00-{u1}.jsonl"));
let head_1 = vec![serde_json::json!({
"id": u1,
"timestamp": "2025-03-01T09-00-00",
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
"base_instructions": null,
})];
let updated_page3: Vec<Option<String>> =
page3.items.iter().map(|i| i.updated_at.clone()).collect();
let expected_page3 = ThreadsPage {
items: vec![ThreadItem {
path: p1,
head: head_1,
thread_id: Some(thread_id_from_uuid(u1)),
first_user_message: Some("Hello from user".to_string()),
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some("2025-03-01T09-00-00".into()),
updated_at: updated_page3.first().cloned().flatten(),
}],
@@ -873,20 +915,18 @@ async fn test_get_thread_contents() {
.join("04")
.join("01")
.join(format!("rollout-2025-04-01T10-30-00-{uuid}.jsonl"));
let expected_head = vec![serde_json::json!({
"id": uuid,
"timestamp": ts,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
"base_instructions": null,
})];
let expected_page = ThreadsPage {
items: vec![ThreadItem {
path: expected_path,
head: expected_head,
thread_id: Some(thread_id_from_uuid(uuid)),
first_user_message: Some("Hello from user".to_string()),
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some(ts.into()),
updated_at: page.items[0].updated_at.clone(),
}],
@@ -953,13 +993,12 @@ async fn test_base_instructions_missing_in_meta_defaults_to_null() {
.await
.unwrap();
let head = page
.items
.first()
.and_then(|item| item.head.first())
let head = read_head_for_summary(&page.items[0].path)
.await
.expect("session meta head");
let first = head.first().expect("first head entry");
assert_eq!(
head.get("base_instructions"),
first.get("base_instructions"),
Some(&serde_json::Value::Null)
);
}
@@ -997,12 +1036,11 @@ async fn test_base_instructions_present_in_meta_is_preserved() {
.await
.unwrap();
let head = page
.items
.first()
.and_then(|item| item.head.first())
let head = read_head_for_summary(&page.items[0].path)
.await
.expect("session meta head");
let base = head
let first = head.first().expect("first head entry");
let base = first
.get("base_instructions")
.and_then(|value| value.get("text"))
.and_then(serde_json::Value::as_str);
@@ -1182,18 +1220,6 @@ async fn test_stable_ordering_same_second_pagination() {
.join("07")
.join("01")
.join(format!("rollout-2025-07-01T00-00-00-{u2}.jsonl"));
let head = |u: Uuid| -> Vec<serde_json::Value> {
vec![serde_json::json!({
"id": u,
"timestamp": ts,
"cwd": ".",
"originator": "test_originator",
"cli_version": "test_version",
"source": "vscode",
"model_provider": "test-provider",
"base_instructions": null,
})]
};
let updated_page1: Vec<Option<String>> =
page1.items.iter().map(|i| i.updated_at.clone()).collect();
let expected_cursor1: Cursor = serde_json::from_str(&format!("\"{ts}|{u2}\"")).unwrap();
@@ -1201,13 +1227,29 @@ async fn test_stable_ordering_same_second_pagination() {
items: vec![
ThreadItem {
path: p3,
head: head(u3),
thread_id: Some(thread_id_from_uuid(u3)),
first_user_message: Some("Hello from user".to_string()),
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some(ts.to_string()),
updated_at: updated_page1.first().cloned().flatten(),
},
ThreadItem {
path: p2,
head: head(u2),
thread_id: Some(thread_id_from_uuid(u2)),
first_user_message: Some("Hello from user".to_string()),
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some(ts.to_string()),
updated_at: updated_page1.get(1).cloned().flatten(),
},
@@ -1240,7 +1282,15 @@ async fn test_stable_ordering_same_second_pagination() {
let expected_page2 = ThreadsPage {
items: vec![ThreadItem {
path: p1,
head: head(u1),
thread_id: Some(thread_id_from_uuid(u1)),
first_user_message: Some("Hello from user".to_string()),
cwd: Some(Path::new(".").to_path_buf()),
git_branch: None,
git_sha: None,
git_origin_url: None,
source: Some(SessionSource::VSCode),
model_provider: Some(TEST_PROVIDER.to_string()),
cli_version: Some("test_version".to_string()),
created_at: Some(ts.to_string()),
updated_at: updated_page2.first().cloned().flatten(),
}],
@@ -1375,13 +1425,7 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<(
let openai_ids: Vec<_> = openai_sessions
.items
.iter()
.filter_map(|item| {
item.head
.first()
.and_then(|value| value.get("id"))
.and_then(serde_json::Value::as_str)
.map(str::to_string)
})
.filter_map(|item| item.thread_id.as_ref().map(ToString::to_string))
.collect();
assert!(openai_ids.contains(&openai_id_str));
assert!(openai_ids.contains(&none_id_str));
@@ -1402,10 +1446,8 @@ async fn test_model_provider_filter_selects_only_matching_sessions() -> Result<(
let beta_head = beta_sessions
.items
.first()
.and_then(|item| item.head.first())
.and_then(|value| value.get("id"))
.and_then(serde_json::Value::as_str);
assert_eq!(beta_head, Some(beta_id_str.as_str()));
.and_then(|item| item.thread_id.as_ref().map(ToString::to_string));
assert_eq!(beta_head.as_deref(), Some(beta_id_str.as_str()));
let unknown_filter = provider_vec(&["unknown"]);
let unknown_sessions = get_threads(
-11
View File
@@ -1,5 +1,3 @@
use codex_protocol::models::ContentItem;
/// Helpers for identifying model-visible "session prefix" messages.
///
/// A session prefix is a user-role message that carries configuration or state needed by
@@ -15,12 +13,3 @@ pub(crate) fn is_session_prefix(text: &str) -> bool {
let lowered = trimmed.to_ascii_lowercase();
lowered.starts_with(ENVIRONMENT_CONTEXT_OPEN_TAG) || lowered.starts_with(TURN_ABORTED_OPEN_TAG)
}
/// Returns true if `text` starts with a session prefix marker (case-insensitive).
pub(crate) fn is_session_prefix_content(content: &[ContentItem]) -> bool {
if let [ContentItem::InputText { text }] = content {
is_session_prefix(text)
} else {
false
}
}
+2 -2
View File
@@ -7,13 +7,13 @@ use crate::analytics_client::AnalyticsEventsClient;
use crate::client::ModelClient;
use crate::exec_policy::ExecPolicyManager;
use crate::file_watcher::FileWatcher;
use crate::hooks::Hooks;
use crate::mcp_connection_manager::McpConnectionManager;
use crate::models_manager::manager::ModelsManager;
use crate::skills::SkillsManager;
use crate::state_db::StateDbHandle;
use crate::tools::sandboxing::ApprovalStore;
use crate::unified_exec::UnifiedExecProcessManager;
use crate::user_notification::UserNotifier;
use codex_otel::OtelManager;
use tokio::sync::Mutex;
use tokio::sync::RwLock;
@@ -24,7 +24,7 @@ pub(crate) struct SessionServices {
pub(crate) mcp_startup_cancellation_token: Mutex<CancellationToken>,
pub(crate) unified_exec_manager: UnifiedExecProcessManager,
pub(crate) analytics_events_client: AnalyticsEventsClient,
pub(crate) notifier: UserNotifier,
pub(crate) hooks: Hooks,
pub(crate) rollout: Mutex<Option<RolloutRecorder>>,
pub(crate) user_shell: Arc<crate::shell::Shell>,
pub(crate) show_raw_agent_reasoning: bool,
+3
View File
@@ -24,6 +24,8 @@ pub(crate) struct SessionState {
/// TODO(owen): This is a temporary solution to avoid updating a thread's updated_at
/// timestamp when resuming a session. Remove this once SQLite is in place.
pub(crate) initial_context_seeded: bool,
/// Previous rollout model for one-shot model-switch handling on first turn after resume.
pub(crate) pending_resume_previous_model: Option<String>,
}
impl SessionState {
@@ -38,6 +40,7 @@ impl SessionState {
dependency_env: HashMap::new(),
mcp_dependency_prompted: HashSet::new(),
initial_context_seeded: false,
pending_resume_previous_model: None,
}
}
+113 -18
View File
@@ -14,6 +14,7 @@ use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::SessionSource;
use codex_state::DB_METRIC_COMPARE_ERROR;
pub use codex_state::LogEntry;
use codex_state::STATE_DB_VERSION;
use codex_state::ThreadMetadataBuilder;
use serde_json::Value;
use std::path::Path;
@@ -31,11 +32,9 @@ pub(crate) async fn init_if_enabled(
config: &Config,
otel: Option<&OtelManager>,
) -> Option<StateDbHandle> {
let state_path = codex_state::state_db_path(config.codex_home.as_path());
if !config.features.enabled(Feature::Sqlite) {
return None;
}
let existed = tokio::fs::try_exists(&state_path).await.unwrap_or(false);
let runtime = match codex_state::StateRuntime::init(
config.codex_home.clone(),
config.model_provider_id.clone(),
@@ -55,17 +54,23 @@ pub(crate) async fn init_if_enabled(
return None;
}
};
if !existed {
let runtime_for_backfill = Arc::clone(&runtime);
let config_for_backfill = config.clone();
let otel_for_backfill = otel.cloned();
tokio::task::spawn(async move {
metadata::backfill_sessions(
runtime_for_backfill.as_ref(),
&config_for_backfill,
otel_for_backfill.as_ref(),
)
.await;
let backfill_state = match runtime.get_backfill_state().await {
Ok(state) => state,
Err(err) => {
warn!(
"failed to read backfill state at {}: {err}",
config.codex_home.display()
);
return None;
}
};
if backfill_state.status != codex_state::BackfillStatus::Complete {
let runtime_for_backfill = runtime.clone();
let config = config.clone();
let otel = otel.cloned();
tokio::spawn(async move {
metadata::backfill_sessions(runtime_for_backfill.as_ref(), &config, otel.as_ref())
.await;
});
}
Some(runtime)
@@ -79,13 +84,14 @@ pub async fn get_state_db(config: &Config, otel: Option<&OtelManager>) -> Option
{
return None;
}
codex_state::StateRuntime::init(
let runtime = codex_state::StateRuntime::init(
config.codex_home.clone(),
config.model_provider_id.clone(),
otel.cloned(),
)
.await
.ok()
.ok()?;
require_backfill_complete(runtime, config.codex_home.as_path()).await
}
/// Open the state runtime when the SQLite file exists, without feature gating.
@@ -103,7 +109,31 @@ pub async fn open_if_present(codex_home: &Path, default_provider: &str) -> Optio
)
.await
.ok()?;
Some(runtime)
require_backfill_complete(runtime, codex_home).await
}
async fn require_backfill_complete(
runtime: StateDbHandle,
codex_home: &Path,
) -> Option<StateDbHandle> {
match runtime.get_backfill_state().await {
Ok(state) if state.status == codex_state::BackfillStatus::Complete => Some(runtime),
Ok(state) => {
warn!(
"state db backfill not complete at {} (status: {})",
codex_home.display(),
state.status.as_str()
);
None
}
Err(err) => {
warn!(
"failed to read backfill state at {}: {err}",
codex_home.display()
);
None
}
}
}
fn cursor_to_anchor(cursor: Option<&Cursor>) -> Option<codex_state::Anchor> {
@@ -341,6 +371,7 @@ pub async fn reconcile_rollout(
default_provider: &str,
builder: Option<&ThreadMetadataBuilder>,
items: &[RolloutItem],
archived_only: Option<bool>,
) {
let Some(ctx) = context else {
return;
@@ -368,7 +399,17 @@ pub async fn reconcile_rollout(
return;
}
};
if let Err(err) = ctx.upsert_thread(&outcome.metadata).await {
let mut metadata = outcome.metadata;
match archived_only {
Some(true) if metadata.archived_at.is_none() => {
metadata.archived_at = Some(metadata.updated_at);
}
Some(false) => {
metadata.archived_at = None;
}
Some(true) | None => {}
}
if let Err(err) = ctx.upsert_thread(&metadata).await {
warn!(
"state db reconcile_rollout upsert failed {}: {err}",
rollout_path.display()
@@ -391,6 +432,56 @@ pub async fn reconcile_rollout(
}
}
/// Repair a thread's rollout path after filesystem fallback succeeds.
pub async fn read_repair_rollout_path(
context: Option<&codex_state::StateRuntime>,
thread_id: Option<ThreadId>,
archived_only: Option<bool>,
rollout_path: &Path,
) {
let Some(ctx) = context else {
return;
};
if let Some(thread_id) = thread_id
&& let Ok(Some(mut metadata)) = ctx.get_thread(thread_id).await
{
metadata.rollout_path = rollout_path.to_path_buf();
match archived_only {
Some(true) if metadata.archived_at.is_none() => {
metadata.archived_at = Some(metadata.updated_at);
}
Some(false) => {
metadata.archived_at = None;
}
Some(true) | None => {}
}
if let Err(err) = ctx.upsert_thread(&metadata).await {
warn!(
"state db read-repair upsert failed for {}: {err}",
rollout_path.display()
);
} else {
return;
}
}
let default_provider = crate::rollout::list::read_session_meta_line(rollout_path)
.await
.ok()
.and_then(|meta| meta.meta.model_provider)
.unwrap_or_default();
reconcile_rollout(
Some(ctx),
rollout_path,
default_provider.as_str(),
None,
&[],
archived_only,
)
.await;
}
/// Apply rollout items incrementally to SQLite.
pub async fn apply_rollout_items(
context: Option<&codex_state::StateRuntime>,
@@ -435,7 +526,11 @@ pub fn record_discrepancy(stage: &str, reason: &str) {
let _ = metric.counter(
DB_METRIC_COMPARE_ERROR,
1,
&[("stage", stage), ("reason", reason)],
&[
("stage", stage),
("reason", reason),
("version", &STATE_DB_VERSION.to_string()),
],
);
}
}
+1 -1
View File
@@ -25,7 +25,7 @@ impl SessionTask for CompactTask {
_cancellation_token: CancellationToken,
) -> Option<String> {
let session = session.clone_session();
if crate::compact::should_use_remote_compact_task(session.as_ref(), &ctx.provider) {
if crate::compact::should_use_remote_compact_task(&ctx.provider) {
let _ = session.services.otel_manager.counter(
"codex.task.compact",
1,
+20 -5
View File
@@ -31,6 +31,7 @@ use crate::state::ActiveTurn;
use crate::state::RunningTask;
use crate::state::TaskKind;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::user_input::UserInput;
@@ -40,7 +41,9 @@ pub(crate) use ghost_snapshot::GhostSnapshotTask;
pub(crate) use regular::RegularTask;
pub(crate) use review::ReviewTask;
pub(crate) use undo::UndoTask;
pub(crate) use user_shell::UserShellCommandMode;
pub(crate) use user_shell::UserShellCommandTask;
pub(crate) use user_shell::execute_user_shell_command;
const GRACEFULL_INTERRUPTION_TIMEOUT_MS: u64 = 100;
const TURN_ABORTED_INTERRUPTED_GUIDANCE: &str = "The user interrupted the previous turn on purpose. If any tools/commands were aborted, they may have partially executed; verify current state before retrying.";
@@ -187,15 +190,27 @@ impl Session {
last_agent_message: Option<String>,
) {
let mut active = self.active_turn.lock().await;
let should_close_processes = if let Some(at) = active.as_mut()
let mut pending_input = Vec::<ResponseInputItem>::new();
let mut should_close_processes = false;
if let Some(at) = active.as_mut()
&& at.remove_task(&turn_context.sub_id)
{
let mut ts = at.turn_state.lock().await;
pending_input = ts.take_pending_input();
should_close_processes = true;
}
if should_close_processes {
*active = None;
true
} else {
false
};
}
drop(active);
if !pending_input.is_empty() {
let pending_response_items = pending_input
.into_iter()
.map(ResponseItem::from)
.collect::<Vec<_>>();
self.record_conversation_items(turn_context.as_ref(), &pending_response_items)
.await;
}
if should_close_processes {
self.close_unified_exec_processes().await;
}
+241 -180
View File
@@ -32,9 +32,22 @@ use crate::user_shell_command::user_shell_command_record_item;
use super::SessionTask;
use super::SessionTaskContext;
use crate::codex::Session;
use codex_protocol::models::ResponseInputItem;
use codex_protocol::models::ResponseItem;
const USER_SHELL_TIMEOUT_MS: u64 = 60 * 60 * 1000; // 1 hour
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(crate) enum UserShellCommandMode {
/// Executes as an independent turn lifecycle (emits TurnStarted/TurnComplete
/// via task lifecycle plumbing).
StandaloneTurn,
/// Executes while another turn is already active. This mode must not emit a
/// second TurnStarted/TurnComplete pair for the same active turn.
ActiveTurnAuxiliary,
}
#[derive(Clone)]
pub(crate) struct UserShellCommandTask {
command: String,
@@ -59,198 +72,246 @@ impl SessionTask for UserShellCommandTask {
_input: Vec<UserInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
let _ = session
.session
.services
.otel_manager
.counter("codex.task.user_shell", 1, &[]);
execute_user_shell_command(
session.clone_session(),
turn_context,
self.command.clone(),
cancellation_token,
UserShellCommandMode::StandaloneTurn,
)
.await;
None
}
}
pub(crate) async fn execute_user_shell_command(
session: Arc<Session>,
turn_context: Arc<TurnContext>,
command: String,
cancellation_token: CancellationToken,
mode: UserShellCommandMode,
) {
session
.services
.otel_manager
.counter("codex.task.user_shell", 1, &[]);
if mode == UserShellCommandMode::StandaloneTurn {
// Auxiliary mode runs within an existing active turn. That turn already
// emitted TurnStarted, so emitting another TurnStarted here would create
// duplicate turn lifecycle events and confuse clients.
let event = EventMsg::TurnStarted(TurnStartedEvent {
model_context_window: turn_context.model_context_window(),
collaboration_mode_kind: turn_context.collaboration_mode.mode,
});
let session = session.clone_session();
session.send_event(turn_context.as_ref(), event).await;
}
// Execute the user's script under their default shell when known; this
// allows commands that use shell features (pipes, &&, redirects, etc.).
// We do not source rc files or otherwise reformat the script.
let use_login_shell = true;
let session_shell = session.user_shell();
let display_command = session_shell.derive_exec_args(&self.command, use_login_shell);
let exec_command =
maybe_wrap_shell_lc_with_snapshot(&display_command, session_shell.as_ref());
// Execute the user's script under their default shell when known; this
// allows commands that use shell features (pipes, &&, redirects, etc.).
// We do not source rc files or otherwise reformat the script.
let use_login_shell = true;
let session_shell = session.user_shell();
let display_command = session_shell.derive_exec_args(&command, use_login_shell);
let exec_command = maybe_wrap_shell_lc_with_snapshot(&display_command, session_shell.as_ref());
let call_id = Uuid::new_v4().to_string();
let raw_command = self.command.clone();
let cwd = turn_context.cwd.clone();
let call_id = Uuid::new_v4().to_string();
let raw_command = command;
let cwd = turn_context.cwd.clone();
let parsed_cmd = parse_command(&display_command);
session
.send_event(
let parsed_cmd = parse_command(&display_command);
session
.send_event(
turn_context.as_ref(),
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id: call_id.clone(),
process_id: None,
turn_id: turn_context.sub_id.clone(),
command: display_command.clone(),
cwd: cwd.clone(),
parsed_cmd: parsed_cmd.clone(),
source: ExecCommandSource::UserShell,
interaction_input: None,
}),
)
.await;
let exec_env = ExecEnv {
command: exec_command.clone(),
cwd: cwd.clone(),
env: create_env(
&turn_context.shell_environment_policy,
Some(session.conversation_id),
),
// TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we
// should use that instead of an "arbitrarily large" timeout here.
expiration: USER_SHELL_TIMEOUT_MS.into(),
sandbox: SandboxType::None,
windows_sandbox_level: turn_context.windows_sandbox_level,
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,
arg0: None,
};
let stdout_stream = Some(StdoutStream {
sub_id: turn_context.sub_id.clone(),
call_id: call_id.clone(),
tx_event: session.get_tx_event(),
});
let sandbox_policy = SandboxPolicy::DangerFullAccess;
let exec_result = execute_exec_env(exec_env, &sandbox_policy, stdout_stream)
.or_cancel(&cancellation_token)
.await;
match exec_result {
Err(CancelErr::Cancelled) => {
let aborted_message = "command aborted by user".to_string();
let exec_output = ExecToolCallOutput {
exit_code: -1,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(aborted_message.clone()),
aggregated_output: StreamOutput::new(aborted_message.clone()),
duration: Duration::ZERO,
timed_out: false,
};
persist_user_shell_output(
&session,
turn_context.as_ref(),
EventMsg::ExecCommandBegin(ExecCommandBeginEvent {
call_id: call_id.clone(),
process_id: None,
turn_id: turn_context.sub_id.clone(),
command: display_command.clone(),
cwd: cwd.clone(),
parsed_cmd: parsed_cmd.clone(),
source: ExecCommandSource::UserShell,
interaction_input: None,
}),
&raw_command,
&exec_output,
mode,
)
.await;
session
.send_event(
turn_context.as_ref(),
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
process_id: None,
turn_id: turn_context.sub_id.clone(),
command: display_command.clone(),
cwd: cwd.clone(),
parsed_cmd: parsed_cmd.clone(),
source: ExecCommandSource::UserShell,
interaction_input: None,
stdout: String::new(),
stderr: aborted_message.clone(),
aggregated_output: aborted_message.clone(),
exit_code: -1,
duration: Duration::ZERO,
formatted_output: aborted_message,
}),
)
.await;
}
Ok(Ok(output)) => {
session
.send_event(
turn_context.as_ref(),
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: call_id.clone(),
process_id: None,
turn_id: turn_context.sub_id.clone(),
command: display_command.clone(),
cwd: cwd.clone(),
parsed_cmd: parsed_cmd.clone(),
source: ExecCommandSource::UserShell,
interaction_input: None,
stdout: output.stdout.text.clone(),
stderr: output.stderr.text.clone(),
aggregated_output: output.aggregated_output.text.clone(),
exit_code: output.exit_code,
duration: output.duration,
formatted_output: format_exec_output_str(
&output,
turn_context.truncation_policy,
),
}),
)
.await;
persist_user_shell_output(&session, turn_context.as_ref(), &raw_command, &output, mode)
.await;
}
Ok(Err(err)) => {
error!("user shell command failed: {err:?}");
let message = format!("execution error: {err:?}");
let exec_output = ExecToolCallOutput {
exit_code: -1,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(message.clone()),
aggregated_output: StreamOutput::new(message.clone()),
duration: Duration::ZERO,
timed_out: false,
};
session
.send_event(
turn_context.as_ref(),
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
process_id: None,
turn_id: turn_context.sub_id.clone(),
command: display_command,
cwd,
parsed_cmd,
source: ExecCommandSource::UserShell,
interaction_input: None,
stdout: exec_output.stdout.text.clone(),
stderr: exec_output.stderr.text.clone(),
aggregated_output: exec_output.aggregated_output.text.clone(),
exit_code: exec_output.exit_code,
duration: exec_output.duration,
formatted_output: format_exec_output_str(
&exec_output,
turn_context.truncation_policy,
),
}),
)
.await;
persist_user_shell_output(
&session,
turn_context.as_ref(),
&raw_command,
&exec_output,
mode,
)
.await;
let exec_env = ExecEnv {
command: exec_command.clone(),
cwd: cwd.clone(),
env: create_env(
&turn_context.shell_environment_policy,
Some(session.conversation_id),
),
// TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we
// should use that instead of an "arbitrarily large" timeout here.
expiration: USER_SHELL_TIMEOUT_MS.into(),
sandbox: SandboxType::None,
windows_sandbox_level: turn_context.windows_sandbox_level,
sandbox_permissions: SandboxPermissions::UseDefault,
justification: None,
arg0: None,
};
let stdout_stream = Some(StdoutStream {
sub_id: turn_context.sub_id.clone(),
call_id: call_id.clone(),
tx_event: session.get_tx_event(),
});
let sandbox_policy = SandboxPolicy::DangerFullAccess;
let exec_result = execute_exec_env(exec_env, &sandbox_policy, stdout_stream)
.or_cancel(&cancellation_token)
.await;
match exec_result {
Err(CancelErr::Cancelled) => {
let aborted_message = "command aborted by user".to_string();
let exec_output = ExecToolCallOutput {
exit_code: -1,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(aborted_message.clone()),
aggregated_output: StreamOutput::new(aborted_message.clone()),
duration: Duration::ZERO,
timed_out: false,
};
let output_items = [user_shell_command_record_item(
&raw_command,
&exec_output,
&turn_context,
)];
session
.record_conversation_items(turn_context.as_ref(), &output_items)
.await;
session
.send_event(
turn_context.as_ref(),
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
process_id: None,
turn_id: turn_context.sub_id.clone(),
command: display_command.clone(),
cwd: cwd.clone(),
parsed_cmd: parsed_cmd.clone(),
source: ExecCommandSource::UserShell,
interaction_input: None,
stdout: String::new(),
stderr: aborted_message.clone(),
aggregated_output: aborted_message.clone(),
exit_code: -1,
duration: Duration::ZERO,
formatted_output: aborted_message,
}),
)
.await;
}
Ok(Ok(output)) => {
session
.send_event(
turn_context.as_ref(),
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id: call_id.clone(),
process_id: None,
turn_id: turn_context.sub_id.clone(),
command: display_command.clone(),
cwd: cwd.clone(),
parsed_cmd: parsed_cmd.clone(),
source: ExecCommandSource::UserShell,
interaction_input: None,
stdout: output.stdout.text.clone(),
stderr: output.stderr.text.clone(),
aggregated_output: output.aggregated_output.text.clone(),
exit_code: output.exit_code,
duration: output.duration,
formatted_output: format_exec_output_str(
&output,
turn_context.truncation_policy,
),
}),
)
.await;
let output_items = [user_shell_command_record_item(
&raw_command,
&output,
&turn_context,
)];
session
.record_conversation_items(turn_context.as_ref(), &output_items)
.await;
}
Ok(Err(err)) => {
error!("user shell command failed: {err:?}");
let message = format!("execution error: {err:?}");
let exec_output = ExecToolCallOutput {
exit_code: -1,
stdout: StreamOutput::new(String::new()),
stderr: StreamOutput::new(message.clone()),
aggregated_output: StreamOutput::new(message.clone()),
duration: Duration::ZERO,
timed_out: false,
};
session
.send_event(
turn_context.as_ref(),
EventMsg::ExecCommandEnd(ExecCommandEndEvent {
call_id,
process_id: None,
turn_id: turn_context.sub_id.clone(),
command: display_command,
cwd,
parsed_cmd,
source: ExecCommandSource::UserShell,
interaction_input: None,
stdout: exec_output.stdout.text.clone(),
stderr: exec_output.stderr.text.clone(),
aggregated_output: exec_output.aggregated_output.text.clone(),
exit_code: exec_output.exit_code,
duration: exec_output.duration,
formatted_output: format_exec_output_str(
&exec_output,
turn_context.truncation_policy,
),
}),
)
.await;
let output_items = [user_shell_command_record_item(
&raw_command,
&exec_output,
&turn_context,
)];
session
.record_conversation_items(turn_context.as_ref(), &output_items)
.await;
}
}
None
}
}
async fn persist_user_shell_output(
session: &Session,
turn_context: &TurnContext,
raw_command: &str,
exec_output: &ExecToolCallOutput,
mode: UserShellCommandMode,
) {
let output_item = user_shell_command_record_item(raw_command, exec_output, turn_context);
if mode == UserShellCommandMode::StandaloneTurn {
session
.record_conversation_items(turn_context, std::slice::from_ref(&output_item))
.await;
return;
}
let response_input_item = match output_item {
ResponseItem::Message { role, content, .. } => ResponseInputItem::Message { role, content },
_ => unreachable!("user shell command output record should always be a message"),
};
if let Err(items) = session
.inject_response_items(vec![response_input_item])
.await
{
let response_items = items
.into_iter()
.map(ResponseItem::from)
.collect::<Vec<_>>();
session
.record_conversation_items(turn_context, &response_items)
.await;
}
}
+39 -2
View File
@@ -1168,6 +1168,37 @@ mod tests {
#[tokio::test]
async fn build_agent_spawn_config_uses_turn_context_values() {
fn pick_allowed_approval_policy(
constraint: &crate::config::Constrained<AskForApproval>,
base: AskForApproval,
) -> AskForApproval {
let candidates = [
AskForApproval::Never,
AskForApproval::UnlessTrusted,
AskForApproval::OnRequest,
AskForApproval::OnFailure,
];
candidates
.into_iter()
.find(|candidate| *candidate != base && constraint.can_set(candidate).is_ok())
.unwrap_or(base)
}
fn pick_allowed_sandbox_policy(
constraint: &crate::config::Constrained<SandboxPolicy>,
base: SandboxPolicy,
) -> SandboxPolicy {
let candidates = [
SandboxPolicy::new_read_only_policy(),
SandboxPolicy::new_workspace_write_policy(),
SandboxPolicy::DangerFullAccess,
];
candidates
.into_iter()
.find(|candidate| *candidate != base && constraint.can_set(candidate).is_ok())
.unwrap_or(base)
}
let (_session, mut turn) = make_session_and_context().await;
let base_instructions = BaseInstructions {
text: "base".to_string(),
@@ -1181,8 +1212,14 @@ mod tests {
let temp_dir = tempfile::tempdir().expect("temp dir");
turn.cwd = temp_dir.path().to_path_buf();
turn.codex_linux_sandbox_exe = Some(PathBuf::from("/bin/echo"));
turn.approval_policy = AskForApproval::Never;
turn.sandbox_policy = SandboxPolicy::DangerFullAccess;
turn.approval_policy = pick_allowed_approval_policy(
&turn.config.approval_policy,
*turn.config.approval_policy.get(),
);
turn.sandbox_policy = pick_allowed_sandbox_policy(
&turn.config.sandbox_policy,
turn.config.sandbox_policy.get().clone(),
);
let config = build_agent_spawn_config(&base_instructions, &turn, 0).expect("spawn config");
let mut expected = (*turn.config).clone();
@@ -0,0 +1,72 @@
use crate::function_tool::FunctionCallError;
use crate::state_db;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use async_trait::async_trait;
use codex_protocol::ThreadId;
use codex_protocol::models::FunctionCallOutputBody;
use serde::Deserialize;
use serde_json::json;
pub struct GetMemoryHandler;
#[derive(Deserialize)]
struct GetMemoryArgs {
memory_id: String,
}
#[async_trait]
impl ToolHandler for GetMemoryHandler {
fn kind(&self) -> ToolKind {
ToolKind::Function
}
async fn handle(&self, invocation: ToolInvocation) -> Result<ToolOutput, FunctionCallError> {
let ToolInvocation {
session, payload, ..
} = invocation;
let arguments = match payload {
ToolPayload::Function { arguments } => arguments,
_ => {
return Err(FunctionCallError::RespondToModel(
"get_memory handler received unsupported payload".to_string(),
));
}
};
let args: GetMemoryArgs = parse_arguments(&arguments)?;
let thread_id = ThreadId::from_string(args.memory_id.as_str()).map_err(|err| {
FunctionCallError::RespondToModel(format!("memory_id must be a valid thread id: {err}"))
})?;
let state_db_ctx = session.state_db();
let memory =
state_db::get_thread_memory(state_db_ctx.as_deref(), thread_id, "get_memory_tool")
.await
.ok_or_else(|| {
FunctionCallError::RespondToModel(format!(
"memory not found for memory_id={}",
args.memory_id
))
})?;
let content = serde_json::to_string_pretty(&json!({
"memory_id": args.memory_id,
"trace_summary": memory.trace_summary,
"memory_summary": memory.memory_summary,
}))
.map_err(|err| {
FunctionCallError::Fatal(format!("failed to serialize memory payload: {err}"))
})?;
Ok(ToolOutput::Function {
body: FunctionCallOutputBody::Text(content),
success: Some(true),
})
}
}
+2
View File
@@ -1,6 +1,7 @@
pub mod apply_patch;
pub(crate) mod collab;
mod dynamic;
mod get_memory;
mod grep_files;
mod list_dir;
mod mcp;
@@ -20,6 +21,7 @@ use crate::function_tool::FunctionCallError;
pub use apply_patch::ApplyPatchHandler;
pub use collab::CollabHandler;
pub use dynamic::DynamicToolHandler;
pub use get_memory::GetMemoryHandler;
pub use grep_files::GrepFilesHandler;
pub use list_dir::ListDirHandler;
pub use mcp::McpHandler;
@@ -9,27 +9,15 @@ use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::TUI_VISIBLE_COLLABORATION_MODES;
use codex_protocol::request_user_input::RequestUserInputArgs;
const REQUEST_USER_INPUT_ALLOWED_MODES: [ModeKind; 1] = [ModeKind::Plan];
fn request_user_input_mode_name(mode: ModeKind) -> &'static str {
match mode {
ModeKind::Plan => "Plan",
ModeKind::Default => "Default",
ModeKind::Execute => "Execute",
ModeKind::PairProgramming => "Pair Programming",
}
}
fn format_allowed_modes() -> String {
let mut mode_names = Vec::with_capacity(REQUEST_USER_INPUT_ALLOWED_MODES.len());
for mode in REQUEST_USER_INPUT_ALLOWED_MODES {
let name = request_user_input_mode_name(mode);
if !mode_names.contains(&name) {
mode_names.push(name);
}
}
let mode_names: Vec<&str> = TUI_VISIBLE_COLLABORATION_MODES
.into_iter()
.filter(|mode| mode.allows_request_user_input())
.map(ModeKind::display_name)
.collect();
match mode_names.as_slice() {
[] => "no modes".to_string(),
@@ -39,15 +27,11 @@ fn format_allowed_modes() -> String {
}
}
fn request_user_input_is_available_in_mode(mode: ModeKind) -> bool {
REQUEST_USER_INPUT_ALLOWED_MODES.contains(&mode)
}
pub(crate) fn request_user_input_unavailable_message(mode: ModeKind) -> Option<String> {
if request_user_input_is_available_in_mode(mode) {
if mode.allows_request_user_input() {
None
} else {
let mode_name = request_user_input_mode_name(mode);
let mode_name = mode.display_name();
Some(format!(
"request_user_input is unavailable in {mode_name} mode"
))
@@ -134,22 +118,10 @@ mod tests {
#[test]
fn request_user_input_mode_availability_is_plan_only() {
assert_eq!(
request_user_input_is_available_in_mode(ModeKind::Plan),
true
);
assert_eq!(
request_user_input_is_available_in_mode(ModeKind::Default),
false
);
assert_eq!(
request_user_input_is_available_in_mode(ModeKind::Execute),
false
);
assert_eq!(
request_user_input_is_available_in_mode(ModeKind::PairProgramming),
false
);
assert!(ModeKind::Plan.allows_request_user_input());
assert!(!ModeKind::Default.allows_request_user_input());
assert!(!ModeKind::Execute.allows_request_user_input());
assert!(!ModeKind::PairProgramming.allows_request_user_input());
}
#[test]
@@ -298,7 +298,6 @@ impl ShellHandler {
.services
.exec_policy
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &features,
command: &exec_params.command,
approval_policy: turn.approval_policy,
sandbox_policy: &turn.sandbox_policy,
+49 -3
View File
@@ -3,11 +3,15 @@ use std::sync::Arc;
use std::time::Duration;
use crate::client_common::tools::ToolSpec;
use crate::exec::SandboxType;
use crate::function_tool::FunctionCallError;
use crate::protocol::SandboxPolicy;
use crate::safety::get_platform_sandbox;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;
use crate::tools::context::ToolPayload;
use async_trait::async_trait;
use codex_protocol::config_types::WindowsSandboxLevel;
use codex_protocol::models::ResponseInputItem;
use codex_utils_readiness::Readiness;
use tracing::warn;
@@ -73,19 +77,33 @@ impl ToolRegistry {
let otel = invocation.turn.otel_manager.clone();
let payload_for_response = invocation.payload.clone();
let log_payload = payload_for_response.log_payload();
let metric_tags = [
(
"sandbox",
sandbox_tag(
&invocation.turn.sandbox_policy,
invocation.turn.windows_sandbox_level,
),
),
(
"sandbox_policy",
sandbox_policy_tag(&invocation.turn.sandbox_policy),
),
];
let handler = match self.handler(tool_name.as_ref()) {
Some(handler) => handler,
None => {
let message =
unsupported_tool_call_message(&invocation.payload, tool_name.as_ref());
otel.tool_result(
otel.tool_result_with_tags(
tool_name.as_ref(),
&call_id_owned,
log_payload.as_ref(),
Duration::ZERO,
false,
&message,
&metric_tags,
);
return Err(FunctionCallError::RespondToModel(message));
}
@@ -93,13 +111,14 @@ impl ToolRegistry {
if !handler.matches_kind(&invocation.payload) {
let message = format!("tool {tool_name} invoked with incompatible payload");
otel.tool_result(
otel.tool_result_with_tags(
tool_name.as_ref(),
&call_id_owned,
log_payload.as_ref(),
Duration::ZERO,
false,
&message,
&metric_tags,
);
return Err(FunctionCallError::Fatal(message));
}
@@ -107,10 +126,11 @@ impl ToolRegistry {
let output_cell = tokio::sync::Mutex::new(None);
let result = otel
.log_tool_result(
.log_tool_result_with_tags(
tool_name.as_ref(),
&call_id_owned,
log_payload.as_ref(),
&metric_tags,
|| {
let handler = handler.clone();
let output_cell = &output_cell;
@@ -231,3 +251,29 @@ fn unsupported_tool_call_message(payload: &ToolPayload, tool_name: &str) -> Stri
_ => format!("unsupported call: {tool_name}"),
}
}
fn sandbox_tag(policy: &SandboxPolicy, windows_sandbox_level: WindowsSandboxLevel) -> &'static str {
if matches!(policy, SandboxPolicy::DangerFullAccess) {
return "none";
}
if matches!(policy, SandboxPolicy::ExternalSandbox { .. }) {
return "external";
}
if cfg!(target_os = "windows") && matches!(windows_sandbox_level, WindowsSandboxLevel::Elevated)
{
return "windows_elevated";
}
get_platform_sandbox(windows_sandbox_level != WindowsSandboxLevel::Disabled)
.map(SandboxType::as_metric_tag)
.unwrap_or("none")
}
fn sandbox_policy_tag(policy: &SandboxPolicy) -> &'static str {
match policy {
SandboxPolicy::ReadOnly => "read-only",
SandboxPolicy::WorkspaceWrite { .. } => "workspace-write",
SandboxPolicy::DangerFullAccess => "danger-full-access",
SandboxPolicy::ExternalSandbox { .. } => "external-sandbox",
}
}
+59
View File
@@ -31,6 +31,7 @@ pub(crate) struct ToolsConfig {
pub web_search_mode: Option<WebSearchMode>,
pub collab_tools: bool,
pub collaboration_modes_tools: bool,
pub memory_tools: bool,
pub request_rule_enabled: bool,
pub experimental_supported_tools: Vec<String>,
}
@@ -51,6 +52,7 @@ impl ToolsConfig {
let include_apply_patch_tool = features.enabled(Feature::ApplyPatchFreeform);
let include_collab_tools = features.enabled(Feature::Collab);
let include_collaboration_modes_tools = features.enabled(Feature::CollaborationModes);
let include_memory_tools = features.enabled(Feature::MemoryTool);
let request_rule_enabled = features.enabled(Feature::RequestRule);
let shell_type = if !features.enabled(Feature::ShellTool) {
@@ -84,6 +86,7 @@ impl ToolsConfig {
web_search_mode: *web_search_mode,
collab_tools: include_collab_tools,
collaboration_modes_tools: include_collaboration_modes_tools,
memory_tools: include_memory_tools,
request_rule_enabled,
experimental_supported_tools: model_info.experimental_supported_tools.clone(),
}
@@ -634,6 +637,28 @@ fn create_request_user_input_tool() -> ToolSpec {
})
}
fn create_get_memory_tool() -> ToolSpec {
let properties = BTreeMap::from([(
"memory_id".to_string(),
JsonSchema::String {
description: Some(
"Memory ID to fetch. Uses the thread ID as the memory identifier.".to_string(),
),
},
)]);
ToolSpec::Function(ResponsesApiTool {
name: "get_memory".to_string(),
description: "Loads the full stored memory payload for a memory_id.".to_string(),
strict: false,
parameters: JsonSchema::Object {
properties,
required: Some(vec!["memory_id".to_string()]),
additional_properties: Some(false.into()),
},
})
}
fn create_close_agent_tool() -> ToolSpec {
let mut properties = BTreeMap::new();
properties.insert(
@@ -1228,6 +1253,7 @@ pub(crate) fn build_specs(
use crate::tools::handlers::ApplyPatchHandler;
use crate::tools::handlers::CollabHandler;
use crate::tools::handlers::DynamicToolHandler;
use crate::tools::handlers::GetMemoryHandler;
use crate::tools::handlers::GrepFilesHandler;
use crate::tools::handlers::ListDirHandler;
use crate::tools::handlers::McpHandler;
@@ -1249,6 +1275,7 @@ pub(crate) fn build_specs(
let plan_handler = Arc::new(PlanHandler);
let apply_patch_handler = Arc::new(ApplyPatchHandler);
let dynamic_tool_handler = Arc::new(DynamicToolHandler);
let get_memory_handler = Arc::new(GetMemoryHandler);
let view_image_handler = Arc::new(ViewImageHandler);
let mcp_handler = Arc::new(McpHandler);
let mcp_resource_handler = Arc::new(McpResourceHandler);
@@ -1308,6 +1335,11 @@ pub(crate) fn build_specs(
builder.register_handler("request_user_input", request_user_input_handler);
}
if config.memory_tools {
builder.push_spec(create_get_memory_tool());
builder.register_handler("get_memory", get_memory_handler);
}
if let Some(apply_patch_tool_type) = &config.apply_patch_tool_type {
match apply_patch_tool_type {
ApplyPatchToolType::Freeform => {
@@ -1669,6 +1701,33 @@ mod tests {
assert_contains_tool_names(&tools, &["request_user_input"]);
}
#[test]
fn get_memory_requires_memory_tool_feature() {
let config = test_config();
let model_info = ModelsManager::construct_model_info_offline("gpt-5-codex", &config);
let mut features = Features::with_defaults();
features.disable(Feature::MemoryTool);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert!(
!tools.iter().any(|t| t.spec.name() == "get_memory"),
"get_memory should be disabled when memory_tool feature is off"
);
features.enable(Feature::MemoryTool);
let tools_config = ToolsConfig::new(&ToolsConfigParams {
model_info: &model_info,
features: &features,
web_search_mode: Some(WebSearchMode::Cached),
});
let (tools, _) = build_specs(&tools_config, None, &[]).build();
assert_contains_tool_names(&tools, &["get_memory"]);
}
fn assert_model_tools(
model_slug: &str,
features: &Features,
@@ -487,7 +487,6 @@ impl UnifiedExecProcessManager {
&context.turn.shell_environment_policy,
Some(context.session.conversation_id),
));
let features = context.session.features();
let mut orchestrator = ToolOrchestrator::new();
let mut runtime = UnifiedExecRuntime::new(self);
let exec_approval_requirement = context
@@ -495,7 +494,6 @@ impl UnifiedExecProcessManager {
.services
.exec_policy
.create_exec_approval_requirement_for_command(ExecApprovalRequest {
features: &features,
command: &request.command,
approval_policy: context.turn.approval_policy,
sandbox_policy: &context.turn.sandbox_policy,

Some files were not shown because too many files have changed in this diff Show More