Merge 9c1e039140 into sapling-pr-archive-bolinfest

This commit is contained in:
Michael Bolin
2026-03-17 15:47:01 -07:00
committed by GitHub
101 changed files with 3536 additions and 1945 deletions
@@ -937,15 +937,6 @@
],
"type": "object"
},
"HazelnutScope": {
"enum": [
"example",
"workspace-shared",
"all-shared",
"personal"
],
"type": "string"
},
"ImageDetail": {
"enum": [
"auto",
@@ -1328,15 +1319,6 @@
],
"type": "object"
},
"ProductSurface": {
"enum": [
"chatgpt",
"codex",
"api",
"atlas"
],
"type": "string"
},
"ReadOnlyAccess": {
"oneOf": [
{
@@ -2410,42 +2392,6 @@
},
"type": "object"
},
"SkillsRemoteReadParams": {
"properties": {
"enabled": {
"default": false,
"type": "boolean"
},
"hazelnutScope": {
"allOf": [
{
"$ref": "#/definitions/HazelnutScope"
}
],
"default": "example"
},
"productSurface": {
"allOf": [
{
"$ref": "#/definitions/ProductSurface"
}
],
"default": "codex"
}
},
"type": "object"
},
"SkillsRemoteWriteParams": {
"properties": {
"hazelnutId": {
"type": "string"
}
},
"required": [
"hazelnutId"
],
"type": "object"
},
"TextElement": {
"properties": {
"byteRange": {
@@ -3802,54 +3748,6 @@
"title": "Plugin/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"skills/remote/list"
],
"title": "Skills/remote/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/SkillsRemoteReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Skills/remote/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"skills/remote/export"
],
"title": "Skills/remote/exportRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/SkillsRemoteWriteParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Skills/remote/exportRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -1694,6 +1694,13 @@
],
"type": "object"
},
"RealtimeConversationVersion": {
"enum": [
"v1",
"v2"
],
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
@@ -2857,10 +2864,14 @@
},
"threadId": {
"type": "string"
},
"version": {
"$ref": "#/definitions/RealtimeConversationVersion"
}
},
"required": [
"threadId"
"threadId",
"version"
],
"type": "object"
},
@@ -667,54 +667,6 @@
"title": "Plugin/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"skills/remote/list"
],
"title": "Skills/remote/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/SkillsRemoteReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Skills/remote/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/v2/RequestId"
},
"method": {
"enum": [
"skills/remote/export"
],
"title": "Skills/remote/exportRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/v2/SkillsRemoteWriteParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Skills/remote/exportRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -7904,15 +7856,6 @@
],
"type": "string"
},
"HazelnutScope": {
"enum": [
"example",
"workspace-shared",
"all-shared",
"personal"
],
"type": "string"
},
"HookCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -9520,15 +9463,6 @@
"title": "PluginUninstallResponse",
"type": "object"
},
"ProductSurface": {
"enum": [
"chatgpt",
"codex",
"api",
"atlas"
],
"type": "string"
},
"ProfileV2": {
"additionalProperties": true,
"properties": {
@@ -9785,6 +9719,13 @@
}
]
},
"RealtimeConversationVersion": {
"enum": [
"v1",
"v2"
],
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
@@ -9986,25 +9927,6 @@
"title": "ReasoningTextDeltaNotification",
"type": "object"
},
"RemoteSkillSummary": {
"properties": {
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"description",
"id",
"name"
],
"type": "object"
},
"RequestId": {
"anyOf": [
{
@@ -11381,79 +11303,6 @@
"title": "SkillsListResponse",
"type": "object"
},
"SkillsRemoteReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"enabled": {
"default": false,
"type": "boolean"
},
"hazelnutScope": {
"allOf": [
{
"$ref": "#/definitions/v2/HazelnutScope"
}
],
"default": "example"
},
"productSurface": {
"allOf": [
{
"$ref": "#/definitions/v2/ProductSurface"
}
],
"default": "codex"
}
},
"title": "SkillsRemoteReadParams",
"type": "object"
},
"SkillsRemoteReadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/v2/RemoteSkillSummary"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "SkillsRemoteReadResponse",
"type": "object"
},
"SkillsRemoteWriteParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"hazelnutId": {
"type": "string"
}
},
"required": [
"hazelnutId"
],
"title": "SkillsRemoteWriteParams",
"type": "object"
},
"SkillsRemoteWriteResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"type": "string"
},
"path": {
"type": "string"
}
},
"required": [
"id",
"path"
],
"title": "SkillsRemoteWriteResponse",
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{
@@ -12925,10 +12774,14 @@
},
"threadId": {
"type": "string"
},
"version": {
"$ref": "#/definitions/v2/RealtimeConversationVersion"
}
},
"required": [
"threadId"
"threadId",
"version"
],
"title": "ThreadRealtimeStartedNotification",
"type": "object"
@@ -1194,54 +1194,6 @@
"title": "Plugin/readRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"skills/remote/list"
],
"title": "Skills/remote/listRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/SkillsRemoteReadParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Skills/remote/listRequest",
"type": "object"
},
{
"properties": {
"id": {
"$ref": "#/definitions/RequestId"
},
"method": {
"enum": [
"skills/remote/export"
],
"title": "Skills/remote/exportRequestMethod",
"type": "string"
},
"params": {
"$ref": "#/definitions/SkillsRemoteWriteParams"
}
},
"required": [
"id",
"method",
"params"
],
"title": "Skills/remote/exportRequest",
"type": "object"
},
{
"properties": {
"id": {
@@ -4648,15 +4600,6 @@
],
"type": "string"
},
"HazelnutScope": {
"enum": [
"example",
"workspace-shared",
"all-shared",
"personal"
],
"type": "string"
},
"HookCompletedNotification": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
@@ -6308,15 +6251,6 @@
"title": "PluginUninstallResponse",
"type": "object"
},
"ProductSurface": {
"enum": [
"chatgpt",
"codex",
"api",
"atlas"
],
"type": "string"
},
"ProfileV2": {
"additionalProperties": true,
"properties": {
@@ -6573,6 +6507,13 @@
}
]
},
"RealtimeConversationVersion": {
"enum": [
"v1",
"v2"
],
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
@@ -6774,25 +6715,6 @@
"title": "ReasoningTextDeltaNotification",
"type": "object"
},
"RemoteSkillSummary": {
"properties": {
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"description",
"id",
"name"
],
"type": "object"
},
"RequestId": {
"anyOf": [
{
@@ -9141,79 +9063,6 @@
"title": "SkillsListResponse",
"type": "object"
},
"SkillsRemoteReadParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"enabled": {
"default": false,
"type": "boolean"
},
"hazelnutScope": {
"allOf": [
{
"$ref": "#/definitions/HazelnutScope"
}
],
"default": "example"
},
"productSurface": {
"allOf": [
{
"$ref": "#/definitions/ProductSurface"
}
],
"default": "codex"
}
},
"title": "SkillsRemoteReadParams",
"type": "object"
},
"SkillsRemoteReadResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"data": {
"items": {
"$ref": "#/definitions/RemoteSkillSummary"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "SkillsRemoteReadResponse",
"type": "object"
},
"SkillsRemoteWriteParams": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"hazelnutId": {
"type": "string"
}
},
"required": [
"hazelnutId"
],
"title": "SkillsRemoteWriteParams",
"type": "object"
},
"SkillsRemoteWriteResponse": {
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"type": "string"
},
"path": {
"type": "string"
}
},
"required": [
"id",
"path"
],
"title": "SkillsRemoteWriteResponse",
"type": "object"
},
"SubAgentSource": {
"oneOf": [
{
@@ -10685,10 +10534,14 @@
},
"threadId": {
"type": "string"
},
"version": {
"$ref": "#/definitions/RealtimeConversationVersion"
}
},
"required": [
"threadId"
"threadId",
"version"
],
"title": "ThreadRealtimeStartedNotification",
"type": "object"
@@ -1,47 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"HazelnutScope": {
"enum": [
"example",
"workspace-shared",
"all-shared",
"personal"
],
"type": "string"
},
"ProductSurface": {
"enum": [
"chatgpt",
"codex",
"api",
"atlas"
],
"type": "string"
}
},
"properties": {
"enabled": {
"default": false,
"type": "boolean"
},
"hazelnutScope": {
"allOf": [
{
"$ref": "#/definitions/HazelnutScope"
}
],
"default": "example"
},
"productSurface": {
"allOf": [
{
"$ref": "#/definitions/ProductSurface"
}
],
"default": "codex"
}
},
"title": "SkillsRemoteReadParams",
"type": "object"
}
@@ -1,37 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"RemoteSkillSummary": {
"properties": {
"description": {
"type": "string"
},
"id": {
"type": "string"
},
"name": {
"type": "string"
}
},
"required": [
"description",
"id",
"name"
],
"type": "object"
}
},
"properties": {
"data": {
"items": {
"$ref": "#/definitions/RemoteSkillSummary"
},
"type": "array"
}
},
"required": [
"data"
],
"title": "SkillsRemoteReadResponse",
"type": "object"
}
@@ -1,13 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"hazelnutId": {
"type": "string"
}
},
"required": [
"hazelnutId"
],
"title": "SkillsRemoteWriteParams",
"type": "object"
}
@@ -1,17 +0,0 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"properties": {
"id": {
"type": "string"
},
"path": {
"type": "string"
}
},
"required": [
"id",
"path"
],
"title": "SkillsRemoteWriteResponse",
"type": "object"
}
@@ -1,5 +1,14 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"definitions": {
"RealtimeConversationVersion": {
"enum": [
"v1",
"v2"
],
"type": "string"
}
},
"description": "EXPERIMENTAL - emitted when thread realtime startup is accepted.",
"properties": {
"sessionId": {
@@ -10,10 +19,14 @@
},
"threadId": {
"type": "string"
},
"version": {
"$ref": "#/definitions/RealtimeConversationVersion"
}
},
"required": [
"threadId"
"threadId",
"version"
],
"title": "ThreadRealtimeStartedNotification",
"type": "object"
@@ -39,8 +39,6 @@ import type { PluginUninstallParams } from "./v2/PluginUninstallParams";
import type { ReviewStartParams } from "./v2/ReviewStartParams";
import type { SkillsConfigWriteParams } from "./v2/SkillsConfigWriteParams";
import type { SkillsListParams } from "./v2/SkillsListParams";
import type { SkillsRemoteReadParams } from "./v2/SkillsRemoteReadParams";
import type { SkillsRemoteWriteParams } from "./v2/SkillsRemoteWriteParams";
import type { ThreadArchiveParams } from "./v2/ThreadArchiveParams";
import type { ThreadCompactStartParams } from "./v2/ThreadCompactStartParams";
import type { ThreadForkParams } from "./v2/ThreadForkParams";
@@ -62,4 +60,4 @@ import type { WindowsSandboxSetupStartParams } from "./v2/WindowsSandboxSetupSta
/**
* 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/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "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": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "skills/remote/list", id: RequestId, params: SkillsRemoteReadParams, } | { "method": "skills/remote/export", id: RequestId, params: SkillsRemoteWriteParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "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": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "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": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "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": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, };
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/unsubscribe", id: RequestId, params: ThreadUnsubscribeParams, } | { "method": "thread/name/set", id: RequestId, params: ThreadSetNameParams, } | { "method": "thread/metadata/update", id: RequestId, params: ThreadMetadataUpdateParams, } | { "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": "plugin/list", id: RequestId, params: PluginListParams, } | { "method": "plugin/read", id: RequestId, params: PluginReadParams, } | { "method": "app/list", id: RequestId, params: AppsListParams, } | { "method": "fs/readFile", id: RequestId, params: FsReadFileParams, } | { "method": "fs/writeFile", id: RequestId, params: FsWriteFileParams, } | { "method": "fs/createDirectory", id: RequestId, params: FsCreateDirectoryParams, } | { "method": "fs/getMetadata", id: RequestId, params: FsGetMetadataParams, } | { "method": "fs/readDirectory", id: RequestId, params: FsReadDirectoryParams, } | { "method": "fs/remove", id: RequestId, params: FsRemoveParams, } | { "method": "fs/copy", id: RequestId, params: FsCopyParams, } | { "method": "skills/config/write", id: RequestId, params: SkillsConfigWriteParams, } | { "method": "plugin/install", id: RequestId, params: PluginInstallParams, } | { "method": "plugin/uninstall", id: RequestId, params: PluginUninstallParams, } | { "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": "windowsSandbox/setupStart", id: RequestId, params: WindowsSandboxSetupStartParams, } | { "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": "command/exec/write", id: RequestId, params: CommandExecWriteParams, } | { "method": "command/exec/terminate", id: RequestId, params: CommandExecTerminateParams, } | { "method": "command/exec/resize", id: RequestId, params: CommandExecResizeParams, } | { "method": "config/read", id: RequestId, params: ConfigReadParams, } | { "method": "externalAgentConfig/detect", id: RequestId, params: ExternalAgentConfigDetectParams, } | { "method": "externalAgentConfig/import", id: RequestId, params: ExternalAgentConfigImportParams, } | { "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": "getConversationSummary", id: RequestId, params: GetConversationSummaryParams, } | { "method": "gitDiffToRemote", id: RequestId, params: GitDiffToRemoteParams, } | { "method": "getAuthStatus", id: RequestId, params: GetAuthStatusParams, } | { "method": "fuzzyFileSearch", id: RequestId, params: FuzzyFileSearchParams, };
@@ -2,4 +2,4 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
export type SkillsRemoteWriteParams = { hazelnutId: string, };
export type RealtimeConversationVersion = "v1" | "v2";
@@ -49,6 +49,7 @@ export type { NetworkPolicyRuleAction } from "./NetworkPolicyRuleAction";
export type { ParsedCommand } from "./ParsedCommand";
export type { Personality } from "./Personality";
export type { PlanType } from "./PlanType";
export type { RealtimeConversationVersion } from "./RealtimeConversationVersion";
export type { ReasoningEffort } from "./ReasoningEffort";
export type { ReasoningItemContent } from "./ReasoningItemContent";
export type { ReasoningItemReasoningSummary } from "./ReasoningItemReasoningSummary";
@@ -1,5 +0,0 @@
// 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 HazelnutScope = "example" | "workspace-shared" | "all-shared" | "personal";
@@ -1,5 +0,0 @@
// 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 ProductSurface = "chatgpt" | "codex" | "api" | "atlas";
@@ -1,5 +0,0 @@
// 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 RemoteSkillSummary = { id: string, name: string, description: string, };
@@ -1,7 +0,0 @@
// 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 { HazelnutScope } from "./HazelnutScope";
import type { ProductSurface } from "./ProductSurface";
export type SkillsRemoteReadParams = { hazelnutScope: HazelnutScope, productSurface: ProductSurface, enabled: boolean, };
@@ -1,6 +0,0 @@
// 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 { RemoteSkillSummary } from "./RemoteSkillSummary";
export type SkillsRemoteReadResponse = { data: Array<RemoteSkillSummary>, };
@@ -1,5 +0,0 @@
// 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 SkillsRemoteWriteResponse = { id: string, path: string, };
@@ -1,8 +1,9 @@
// GENERATED CODE! DO NOT MODIFY BY HAND!
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { RealtimeConversationVersion } from "../RealtimeConversationVersion";
/**
* EXPERIMENTAL - emitted when thread realtime startup is accepted.
*/
export type ThreadRealtimeStartedNotification = { threadId: string, sessionId: string | null, };
export type ThreadRealtimeStartedNotification = { threadId: string, sessionId: string | null, version: RealtimeConversationVersion, };
@@ -119,7 +119,6 @@ export type { GrantedPermissionProfile } from "./GrantedPermissionProfile";
export type { GuardianApprovalReview } from "./GuardianApprovalReview";
export type { GuardianApprovalReviewStatus } from "./GuardianApprovalReviewStatus";
export type { GuardianRiskLevel } from "./GuardianRiskLevel";
export type { HazelnutScope } from "./HazelnutScope";
export type { HookCompletedNotification } from "./HookCompletedNotification";
export type { HookEventName } from "./HookEventName";
export type { HookExecutionMode } from "./HookExecutionMode";
@@ -211,7 +210,6 @@ export type { PluginSource } from "./PluginSource";
export type { PluginSummary } from "./PluginSummary";
export type { PluginUninstallParams } from "./PluginUninstallParams";
export type { PluginUninstallResponse } from "./PluginUninstallResponse";
export type { ProductSurface } from "./ProductSurface";
export type { ProfileV2 } from "./ProfileV2";
export type { RateLimitSnapshot } from "./RateLimitSnapshot";
export type { RateLimitWindow } from "./RateLimitWindow";
@@ -221,7 +219,6 @@ export type { ReasoningEffortOption } from "./ReasoningEffortOption";
export type { ReasoningSummaryPartAddedNotification } from "./ReasoningSummaryPartAddedNotification";
export type { ReasoningSummaryTextDeltaNotification } from "./ReasoningSummaryTextDeltaNotification";
export type { ReasoningTextDeltaNotification } from "./ReasoningTextDeltaNotification";
export type { RemoteSkillSummary } from "./RemoteSkillSummary";
export type { RequestPermissionProfile } from "./RequestPermissionProfile";
export type { ResidencyRequirement } from "./ResidencyRequirement";
export type { ReviewDelivery } from "./ReviewDelivery";
@@ -247,10 +244,6 @@ export type { SkillsListEntry } from "./SkillsListEntry";
export type { SkillsListExtraRootsForCwd } from "./SkillsListExtraRootsForCwd";
export type { SkillsListParams } from "./SkillsListParams";
export type { SkillsListResponse } from "./SkillsListResponse";
export type { SkillsRemoteReadParams } from "./SkillsRemoteReadParams";
export type { SkillsRemoteReadResponse } from "./SkillsRemoteReadResponse";
export type { SkillsRemoteWriteParams } from "./SkillsRemoteWriteParams";
export type { SkillsRemoteWriteResponse } from "./SkillsRemoteWriteResponse";
export type { TerminalInteractionNotification } from "./TerminalInteractionNotification";
export type { TextElement } from "./TextElement";
export type { TextPosition } from "./TextPosition";
@@ -300,14 +300,6 @@ client_request_definitions! {
params: v2::PluginReadParams,
response: v2::PluginReadResponse,
},
SkillsRemoteList => "skills/remote/list" {
params: v2::SkillsRemoteReadParams,
response: v2::SkillsRemoteReadResponse,
},
SkillsRemoteExport => "skills/remote/export" {
params: v2::SkillsRemoteWriteParams,
response: v2::SkillsRemoteWriteResponse,
},
AppsList => "app/list" {
params: v2::AppsListParams,
response: v2::AppsListResponse,
@@ -946,6 +938,7 @@ mod tests {
use codex_protocol::ThreadId;
use codex_protocol::account::PlanType;
use codex_protocol::parse_command::ParsedCommand;
use codex_protocol::protocol::RealtimeConversationVersion;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
@@ -1628,6 +1621,7 @@ mod tests {
ServerNotification::ThreadRealtimeStarted(v2::ThreadRealtimeStartedNotification {
thread_id: "thr_123".to_string(),
session_id: Some("sess_456".to_string()),
version: RealtimeConversationVersion::V1,
});
let reason = crate::experimental_api::ExperimentalApi::experimental_reason(&notification);
assert_eq!(reason, Some("thread/realtime/started"));
@@ -68,6 +68,7 @@ use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RateLimitWindow as CoreRateLimitWindow;
use codex_protocol::protocol::ReadOnlyAccess as CoreReadOnlyAccess;
use codex_protocol::protocol::RealtimeAudioFrame as CoreRealtimeAudioFrame;
use codex_protocol::protocol::RealtimeConversationVersion;
use codex_protocol::protocol::ReviewDecision as CoreReviewDecision;
use codex_protocol::protocol::SessionSource as CoreSessionSource;
use codex_protocol::protocol::SkillDependencies as CoreSkillDependencies;
@@ -3107,73 +3108,6 @@ pub struct PluginReadResponse {
pub plugin: PluginDetail,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SkillsRemoteReadParams {
#[serde(default)]
pub hazelnut_scope: HazelnutScope,
#[serde(default)]
pub product_surface: ProductSurface,
#[serde(default)]
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, Default)]
#[serde(rename_all = "kebab-case")]
#[ts(rename_all = "kebab-case")]
#[ts(export_to = "v2/")]
pub enum HazelnutScope {
#[default]
Example,
WorkspaceShared,
AllShared,
Personal,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS, Default)]
#[serde(rename_all = "camelCase")]
#[ts(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum ProductSurface {
Chatgpt,
#[default]
Codex,
Api,
Atlas,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct RemoteSkillSummary {
pub id: String,
pub name: String,
pub description: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SkillsRemoteReadResponse {
pub data: Vec<RemoteSkillSummary>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SkillsRemoteWriteParams {
pub hazelnut_id: String,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct SkillsRemoteWriteResponse {
pub id: String,
pub path: PathBuf,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
@@ -3775,6 +3709,7 @@ pub struct ThreadRealtimeStopResponse {}
pub struct ThreadRealtimeStartedNotification {
pub thread_id: String,
pub session_id: Option<String>,
pub version: RealtimeConversationVersion,
}
/// EXPERIMENTAL - raw non-audio thread realtime item emitted by the backend.
-2
View File
@@ -167,8 +167,6 @@ Example with notification opt-out:
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**).
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names (**under development; do not call from production clients yet**).
- `skills/changed` — notification emitted when watched local skill files change.
- `skills/remote/list` — list public remote skills (**under development; do not call from production clients yet**).
- `skills/remote/export` — download a remote skill by `hazelnutId` into `skills` under `codex_home` (**under development; do not call from production clients yet**).
- `app/list` — list available apps.
- `skills/config/write` — write user-level skill config by path.
- `plugin/install` — install a plugin from a discovered marketplace entry, rejecting marketplace entries marked unavailable for install, and return the effective plugin auth policy plus any apps that still need auth (**under development; do not call from production clients yet**).
@@ -338,6 +338,7 @@ pub(crate) async fn apply_bespoke_event_handling(
let notification = ThreadRealtimeStartedNotification {
thread_id: conversation_id.to_string(),
session_id: event.session_id,
version: event.version,
};
outgoing
.send_server_notification(ServerNotification::ThreadRealtimeStarted(
@@ -64,7 +64,6 @@ use codex_app_server_protocol::GetConversationSummaryParams;
use codex_app_server_protocol::GetConversationSummaryResponse;
use codex_app_server_protocol::GitDiffToRemoteResponse;
use codex_app_server_protocol::GitInfo as ApiGitInfo;
use codex_app_server_protocol::HazelnutScope as ApiHazelnutScope;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
@@ -95,7 +94,6 @@ use codex_app_server_protocol::PluginSource;
use codex_app_server_protocol::PluginSummary;
use codex_app_server_protocol::PluginUninstallParams;
use codex_app_server_protocol::PluginUninstallResponse;
use codex_app_server_protocol::ProductSurface as ApiProductSurface;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ReviewDelivery as ApiReviewDelivery;
use codex_app_server_protocol::ReviewStartParams;
@@ -109,10 +107,6 @@ use codex_app_server_protocol::SkillsConfigWriteParams;
use codex_app_server_protocol::SkillsConfigWriteResponse;
use codex_app_server_protocol::SkillsListParams;
use codex_app_server_protocol::SkillsListResponse;
use codex_app_server_protocol::SkillsRemoteReadParams;
use codex_app_server_protocol::SkillsRemoteReadResponse;
use codex_app_server_protocol::SkillsRemoteWriteParams;
use codex_app_server_protocol::SkillsRemoteWriteResponse;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadArchiveParams;
use codex_app_server_protocol::ThreadArchiveResponse;
@@ -236,8 +230,6 @@ use codex_core::read_head_for_summary;
use codex_core::read_session_meta_line;
use codex_core::rollout_date_parts;
use codex_core::sandboxing::SandboxPermissions;
use codex_core::skills::remote::export_remote_skill;
use codex_core::skills::remote::list_remote_skills;
use codex_core::state_db::StateDbHandle;
use codex_core::state_db::get_state_db;
use codex_core::state_db::reconcile_rollout;
@@ -267,8 +259,6 @@ use codex_protocol::protocol::McpAuthStatus as CoreMcpAuthStatus;
use codex_protocol::protocol::McpServerRefreshConfig;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RateLimitSnapshot as CoreRateLimitSnapshot;
use codex_protocol::protocol::RemoteSkillHazelnutScope;
use codex_protocol::protocol::RemoteSkillProductSurface;
use codex_protocol::protocol::ReviewDelivery as CoreReviewDelivery;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::ReviewTarget as CoreReviewTarget;
@@ -359,24 +349,6 @@ enum ThreadShutdownResult {
TimedOut,
}
fn convert_remote_scope(scope: ApiHazelnutScope) -> RemoteSkillHazelnutScope {
match scope {
ApiHazelnutScope::WorkspaceShared => RemoteSkillHazelnutScope::WorkspaceShared,
ApiHazelnutScope::AllShared => RemoteSkillHazelnutScope::AllShared,
ApiHazelnutScope::Personal => RemoteSkillHazelnutScope::Personal,
ApiHazelnutScope::Example => RemoteSkillHazelnutScope::Example,
}
}
fn convert_remote_product_surface(product_surface: ApiProductSurface) -> RemoteSkillProductSurface {
match product_surface {
ApiProductSurface::Chatgpt => RemoteSkillProductSurface::Chatgpt,
ApiProductSurface::Codex => RemoteSkillProductSurface::Codex,
ApiProductSurface::Api => RemoteSkillProductSurface::Api,
ApiProductSurface::Atlas => RemoteSkillProductSurface::Atlas,
}
}
impl Drop for ActiveLogin {
fn drop(&mut self) {
self.shutdown_handle.shutdown();
@@ -730,14 +702,6 @@ impl CodexMessageProcessor {
self.plugin_read(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::SkillsRemoteList { request_id, params } => {
self.skills_remote_list(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::SkillsRemoteExport { request_id, params } => {
self.skills_remote_export(to_connection_request_id(request_id), params)
.await;
}
ClientRequest::AppsList { request_id, params } => {
self.apps_list(to_connection_request_id(request_id), params)
.await;
@@ -5570,79 +5534,6 @@ impl CodexMessageProcessor {
.await;
}
async fn skills_remote_list(
&self,
request_id: ConnectionRequestId,
params: SkillsRemoteReadParams,
) {
let hazelnut_scope = convert_remote_scope(params.hazelnut_scope);
let product_surface = convert_remote_product_surface(params.product_surface);
let enabled = if params.enabled { Some(true) } else { None };
let auth = self.auth_manager.auth().await;
match list_remote_skills(
&self.config,
auth.as_ref(),
hazelnut_scope,
product_surface,
enabled,
)
.await
{
Ok(skills) => {
let data = skills
.into_iter()
.map(|skill| codex_app_server_protocol::RemoteSkillSummary {
id: skill.id,
name: skill.name,
description: skill.description,
})
.collect();
self.outgoing
.send_response(request_id, SkillsRemoteReadResponse { data })
.await;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to list remote skills: {err}"),
)
.await;
}
}
}
async fn skills_remote_export(
&self,
request_id: ConnectionRequestId,
params: SkillsRemoteWriteParams,
) {
let SkillsRemoteWriteParams { hazelnut_id } = params;
let auth = self.auth_manager.auth().await;
let response = export_remote_skill(&self.config, auth.as_ref(), hazelnut_id.as_str()).await;
match response {
Ok(downloaded) => {
self.outgoing
.send_response(
request_id,
SkillsRemoteWriteResponse {
id: downloaded.id,
path: downloaded.path,
},
)
.await;
}
Err(err) => {
self.send_internal_error(
request_id,
format!("failed to download remote skill: {err}"),
)
.await;
}
}
}
async fn skills_config_write(
&self,
request_id: ConnectionRequestId,
@@ -25,6 +25,7 @@ use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_core::features::FEATURES;
use codex_core::features::Feature;
use codex_protocol::protocol::RealtimeConversationVersion;
use core_test_support::responses::start_websocket_server;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
@@ -115,6 +116,7 @@ async fn realtime_conversation_streams_v2_notifications() -> Result<()> {
.await?;
assert_eq!(started.thread_id, thread_start.thread.id);
assert!(started.session_id.is_some());
assert_eq!(started.version, RealtimeConversationVersion::V2);
let startup_context_request = realtime_server.wait_for_request(0, 0).await;
assert_eq!(
+8 -8
View File
@@ -1359,6 +1359,13 @@
},
"type": "object"
},
"RealtimeConversationVersion": {
"enum": [
"v1",
"v2"
],
"type": "string"
},
"RealtimeToml": {
"additionalProperties": false,
"properties": {
@@ -1366,7 +1373,7 @@
"$ref": "#/definitions/RealtimeWsMode"
},
"version": {
"$ref": "#/definitions/RealtimeWsVersion"
"$ref": "#/definitions/RealtimeConversationVersion"
}
},
"type": "object"
@@ -1378,13 +1385,6 @@
],
"type": "string"
},
"RealtimeWsVersion": {
"enum": [
"v1",
"v2"
],
"type": "string"
},
"ReasoningEffort": {
"description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning",
"enum": [
+4
View File
@@ -1250,6 +1250,10 @@ impl AuthManager {
.is_some_and(CodexAuth::is_external_chatgpt_tokens)
}
pub fn codex_api_key_env_enabled(&self) -> bool {
self.enable_codex_api_key_env
}
/// Convenience constructor returning an `Arc` wrapper.
pub fn shared(
codex_home: PathBuf,
+86
View File
@@ -0,0 +1,86 @@
use codex_otel::AuthEnvTelemetryMetadata;
use crate::auth::CODEX_API_KEY_ENV_VAR;
use crate::auth::OPENAI_API_KEY_ENV_VAR;
use crate::auth::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
use crate::model_provider_info::ModelProviderInfo;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct AuthEnvTelemetry {
pub(crate) openai_api_key_env_present: bool,
pub(crate) codex_api_key_env_present: bool,
pub(crate) codex_api_key_env_enabled: bool,
pub(crate) provider_env_key_name: Option<String>,
pub(crate) provider_env_key_present: Option<bool>,
pub(crate) refresh_token_url_override_present: bool,
}
impl AuthEnvTelemetry {
pub(crate) fn to_otel_metadata(&self) -> AuthEnvTelemetryMetadata {
AuthEnvTelemetryMetadata {
openai_api_key_env_present: self.openai_api_key_env_present,
codex_api_key_env_present: self.codex_api_key_env_present,
codex_api_key_env_enabled: self.codex_api_key_env_enabled,
provider_env_key_name: self.provider_env_key_name.clone(),
provider_env_key_present: self.provider_env_key_present,
refresh_token_url_override_present: self.refresh_token_url_override_present,
}
}
}
pub(crate) fn collect_auth_env_telemetry(
provider: &ModelProviderInfo,
codex_api_key_env_enabled: bool,
) -> AuthEnvTelemetry {
AuthEnvTelemetry {
openai_api_key_env_present: env_var_present(OPENAI_API_KEY_ENV_VAR),
codex_api_key_env_present: env_var_present(CODEX_API_KEY_ENV_VAR),
codex_api_key_env_enabled,
// Custom provider `env_key` is arbitrary config text, so emit only a safe bucket.
provider_env_key_name: provider.env_key.as_ref().map(|_| "configured".to_string()),
provider_env_key_present: provider.env_key.as_deref().map(env_var_present),
refresh_token_url_override_present: env_var_present(REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR),
}
}
fn env_var_present(name: &str) -> bool {
match std::env::var(name) {
Ok(value) => !value.trim().is_empty(),
Err(std::env::VarError::NotUnicode(_)) => true,
Err(std::env::VarError::NotPresent) => false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn collect_auth_env_telemetry_buckets_provider_env_key_name() {
let provider = ModelProviderInfo {
name: "Custom".to_string(),
base_url: None,
env_key: Some("sk-should-not-leak".to_string()),
env_key_instructions: None,
experimental_bearer_token: None,
wire_api: crate::model_provider_info::WireApi::Responses,
query_params: None,
http_headers: None,
env_http_headers: None,
request_max_retries: None,
stream_max_retries: None,
stream_idle_timeout_ms: None,
websocket_connect_timeout_ms: None,
requires_openai_auth: false,
supports_websockets: false,
};
let telemetry = collect_auth_env_telemetry(&provider, false);
assert_eq!(
telemetry.provider_env_key_name,
Some("configured".to_string())
);
}
}
+98 -68
View File
@@ -34,6 +34,8 @@ use crate::api_bridge::CoreAuthProvider;
use crate::api_bridge::auth_provider_from_auth;
use crate::api_bridge::map_api_error;
use crate::auth::UnauthorizedRecovery;
use crate::auth_env_telemetry::AuthEnvTelemetry;
use crate::auth_env_telemetry::collect_auth_env_telemetry;
use codex_api::CompactClient as ApiCompactClient;
use codex_api::CompactionInput as ApiCompactionInput;
use codex_api::MemoriesClient as ApiMemoriesClient;
@@ -106,7 +108,7 @@ use crate::response_debug_context::telemetry_transport_error_message;
use crate::tools::spec::create_tools_json_for_responses_api;
use crate::util::FeedbackRequestTags;
use crate::util::emit_feedback_auth_recovery_tags;
use crate::util::emit_feedback_request_tags;
use crate::util::emit_feedback_request_tags_with_auth_env;
pub const OPENAI_BETA_HEADER: &str = "OpenAI-Beta";
pub const X_CODEX_TURN_STATE_HEADER: &str = "x-codex-turn-state";
@@ -138,6 +140,7 @@ struct ModelClientState {
auth_manager: Option<Arc<AuthManager>>,
conversation_id: ThreadId,
provider: ModelProviderInfo,
auth_env_telemetry: AuthEnvTelemetry,
session_source: SessionSource,
model_verbosity: Option<VerbosityConfig>,
responses_websockets_enabled_by_feature: bool,
@@ -267,11 +270,16 @@ impl ModelClient {
include_timing_metrics: bool,
beta_features_header: Option<String>,
) -> Self {
let codex_api_key_env_enabled = auth_manager
.as_ref()
.is_some_and(|manager| manager.codex_api_key_env_enabled());
let auth_env_telemetry = collect_auth_env_telemetry(&provider, codex_api_key_env_enabled);
Self {
state: Arc::new(ModelClientState {
auth_manager,
conversation_id,
provider,
auth_env_telemetry,
session_source,
model_verbosity,
responses_websockets_enabled_by_feature,
@@ -362,6 +370,7 @@ impl ModelClient {
PendingUnauthorizedRetry::default(),
),
RequestRouteTelemetry::for_endpoint(RESPONSES_COMPACT_ENDPOINT),
self.state.auth_env_telemetry.clone(),
);
let client =
ApiCompactClient::new(transport, client_setup.api_provider, client_setup.api_auth)
@@ -430,6 +439,7 @@ impl ModelClient {
PendingUnauthorizedRetry::default(),
),
RequestRouteTelemetry::for_endpoint(MEMORIES_SUMMARIZE_ENDPOINT),
self.state.auth_env_telemetry.clone(),
);
let client =
ApiMemoriesClient::new(transport, client_setup.api_provider, client_setup.api_auth)
@@ -474,11 +484,13 @@ impl ModelClient {
session_telemetry: &SessionTelemetry,
auth_context: AuthRequestTelemetryContext,
request_route_telemetry: RequestRouteTelemetry,
auth_env_telemetry: AuthEnvTelemetry,
) -> Arc<dyn RequestTelemetry> {
let telemetry = Arc::new(ApiTelemetry::new(
session_telemetry.clone(),
auth_context,
request_route_telemetry,
auth_env_telemetry,
));
let request_telemetry: Arc<dyn RequestTelemetry> = telemetry;
request_telemetry
@@ -561,6 +573,7 @@ impl ModelClient {
session_telemetry,
auth_context,
request_route_telemetry,
self.state.auth_env_telemetry.clone(),
);
let websocket_connect_timeout = self.state.provider.websocket_connect_timeout();
let start = Instant::now();
@@ -601,27 +614,30 @@ impl ModelClient {
response_debug.auth_error.as_deref(),
response_debug.auth_error_code.as_deref(),
);
emit_feedback_request_tags(&FeedbackRequestTags {
endpoint: request_route_telemetry.endpoint,
auth_header_attached: auth_context.auth_header_attached,
auth_header_name: auth_context.auth_header_name,
auth_mode: auth_context.auth_mode,
auth_retry_after_unauthorized: Some(auth_context.retry_after_unauthorized),
auth_recovery_mode: auth_context.recovery_mode,
auth_recovery_phase: auth_context.recovery_phase,
auth_connection_reused: Some(false),
auth_request_id: response_debug.request_id.as_deref(),
auth_cf_ray: response_debug.cf_ray.as_deref(),
auth_error: response_debug.auth_error.as_deref(),
auth_error_code: response_debug.auth_error_code.as_deref(),
auth_recovery_followup_success: auth_context
.retry_after_unauthorized
.then_some(result.is_ok()),
auth_recovery_followup_status: auth_context
.retry_after_unauthorized
.then_some(status)
.flatten(),
});
emit_feedback_request_tags_with_auth_env(
&FeedbackRequestTags {
endpoint: request_route_telemetry.endpoint,
auth_header_attached: auth_context.auth_header_attached,
auth_header_name: auth_context.auth_header_name,
auth_mode: auth_context.auth_mode,
auth_retry_after_unauthorized: Some(auth_context.retry_after_unauthorized),
auth_recovery_mode: auth_context.recovery_mode,
auth_recovery_phase: auth_context.recovery_phase,
auth_connection_reused: Some(false),
auth_request_id: response_debug.request_id.as_deref(),
auth_cf_ray: response_debug.cf_ray.as_deref(),
auth_error: response_debug.auth_error.as_deref(),
auth_error_code: response_debug.auth_error_code.as_deref(),
auth_recovery_followup_success: auth_context
.retry_after_unauthorized
.then_some(result.is_ok()),
auth_recovery_followup_status: auth_context
.retry_after_unauthorized
.then_some(status)
.flatten(),
},
&self.state.auth_env_telemetry,
);
result
}
@@ -1030,6 +1046,7 @@ impl ModelClientSession {
session_telemetry,
request_auth_context,
RequestRouteTelemetry::for_endpoint(RESPONSES_ENDPOINT),
self.client.state.auth_env_telemetry.clone(),
);
let compression = self.responses_request_compression(client_setup.auth.as_ref());
let options = self.build_responses_options(turn_metadata_header, compression);
@@ -1190,11 +1207,13 @@ impl ModelClientSession {
session_telemetry: &SessionTelemetry,
auth_context: AuthRequestTelemetryContext,
request_route_telemetry: RequestRouteTelemetry,
auth_env_telemetry: AuthEnvTelemetry,
) -> (Arc<dyn RequestTelemetry>, Arc<dyn SseTelemetry>) {
let telemetry = Arc::new(ApiTelemetry::new(
session_telemetry.clone(),
auth_context,
request_route_telemetry,
auth_env_telemetry,
));
let request_telemetry: Arc<dyn RequestTelemetry> = telemetry.clone();
let sse_telemetry: Arc<dyn SseTelemetry> = telemetry;
@@ -1206,11 +1225,13 @@ impl ModelClientSession {
session_telemetry: &SessionTelemetry,
auth_context: AuthRequestTelemetryContext,
request_route_telemetry: RequestRouteTelemetry,
auth_env_telemetry: AuthEnvTelemetry,
) -> Arc<dyn WebsocketTelemetry> {
let telemetry = Arc::new(ApiTelemetry::new(
session_telemetry.clone(),
auth_context,
request_route_telemetry,
auth_env_telemetry,
));
let websocket_telemetry: Arc<dyn WebsocketTelemetry> = telemetry;
websocket_telemetry
@@ -1663,6 +1684,7 @@ struct ApiTelemetry {
session_telemetry: SessionTelemetry,
auth_context: AuthRequestTelemetryContext,
request_route_telemetry: RequestRouteTelemetry,
auth_env_telemetry: AuthEnvTelemetry,
}
impl ApiTelemetry {
@@ -1670,11 +1692,13 @@ impl ApiTelemetry {
session_telemetry: SessionTelemetry,
auth_context: AuthRequestTelemetryContext,
request_route_telemetry: RequestRouteTelemetry,
auth_env_telemetry: AuthEnvTelemetry,
) -> Self {
Self {
session_telemetry,
auth_context,
request_route_telemetry,
auth_env_telemetry,
}
}
}
@@ -1708,29 +1732,32 @@ impl RequestTelemetry for ApiTelemetry {
debug.auth_error.as_deref(),
debug.auth_error_code.as_deref(),
);
emit_feedback_request_tags(&FeedbackRequestTags {
endpoint: self.request_route_telemetry.endpoint,
auth_header_attached: self.auth_context.auth_header_attached,
auth_header_name: self.auth_context.auth_header_name,
auth_mode: self.auth_context.auth_mode,
auth_retry_after_unauthorized: Some(self.auth_context.retry_after_unauthorized),
auth_recovery_mode: self.auth_context.recovery_mode,
auth_recovery_phase: self.auth_context.recovery_phase,
auth_connection_reused: None,
auth_request_id: debug.request_id.as_deref(),
auth_cf_ray: debug.cf_ray.as_deref(),
auth_error: debug.auth_error.as_deref(),
auth_error_code: debug.auth_error_code.as_deref(),
auth_recovery_followup_success: self
.auth_context
.retry_after_unauthorized
.then_some(error.is_none()),
auth_recovery_followup_status: self
.auth_context
.retry_after_unauthorized
.then_some(status)
.flatten(),
});
emit_feedback_request_tags_with_auth_env(
&FeedbackRequestTags {
endpoint: self.request_route_telemetry.endpoint,
auth_header_attached: self.auth_context.auth_header_attached,
auth_header_name: self.auth_context.auth_header_name,
auth_mode: self.auth_context.auth_mode,
auth_retry_after_unauthorized: Some(self.auth_context.retry_after_unauthorized),
auth_recovery_mode: self.auth_context.recovery_mode,
auth_recovery_phase: self.auth_context.recovery_phase,
auth_connection_reused: None,
auth_request_id: debug.request_id.as_deref(),
auth_cf_ray: debug.cf_ray.as_deref(),
auth_error: debug.auth_error.as_deref(),
auth_error_code: debug.auth_error_code.as_deref(),
auth_recovery_followup_success: self
.auth_context
.retry_after_unauthorized
.then_some(error.is_none()),
auth_recovery_followup_status: self
.auth_context
.retry_after_unauthorized
.then_some(status)
.flatten(),
},
&self.auth_env_telemetry,
);
}
}
@@ -1759,29 +1786,32 @@ impl WebsocketTelemetry for ApiTelemetry {
error_message.as_deref(),
connection_reused,
);
emit_feedback_request_tags(&FeedbackRequestTags {
endpoint: self.request_route_telemetry.endpoint,
auth_header_attached: self.auth_context.auth_header_attached,
auth_header_name: self.auth_context.auth_header_name,
auth_mode: self.auth_context.auth_mode,
auth_retry_after_unauthorized: Some(self.auth_context.retry_after_unauthorized),
auth_recovery_mode: self.auth_context.recovery_mode,
auth_recovery_phase: self.auth_context.recovery_phase,
auth_connection_reused: Some(connection_reused),
auth_request_id: debug.request_id.as_deref(),
auth_cf_ray: debug.cf_ray.as_deref(),
auth_error: debug.auth_error.as_deref(),
auth_error_code: debug.auth_error_code.as_deref(),
auth_recovery_followup_success: self
.auth_context
.retry_after_unauthorized
.then_some(error.is_none()),
auth_recovery_followup_status: self
.auth_context
.retry_after_unauthorized
.then_some(status)
.flatten(),
});
emit_feedback_request_tags_with_auth_env(
&FeedbackRequestTags {
endpoint: self.request_route_telemetry.endpoint,
auth_header_attached: self.auth_context.auth_header_attached,
auth_header_name: self.auth_context.auth_header_name,
auth_mode: self.auth_context.auth_mode,
auth_retry_after_unauthorized: Some(self.auth_context.retry_after_unauthorized),
auth_recovery_mode: self.auth_context.recovery_mode,
auth_recovery_phase: self.auth_context.recovery_phase,
auth_connection_reused: Some(connection_reused),
auth_request_id: debug.request_id.as_deref(),
auth_cf_ray: debug.cf_ray.as_deref(),
auth_error: debug.auth_error.as_deref(),
auth_error_code: debug.auth_error_code.as_deref(),
auth_recovery_followup_success: self
.auth_context
.retry_after_unauthorized
.then_some(error.is_none()),
auth_recovery_followup_status: self
.auth_context
.retry_after_unauthorized
.then_some(status)
.flatten(),
},
&self.auth_env_telemetry,
);
}
fn on_ws_event(
+24 -126
View File
@@ -17,6 +17,7 @@ use crate::analytics_client::AppInvocation;
use crate::analytics_client::InvocationType;
use crate::analytics_client::build_track_events_context;
use crate::apps::render_apps_section;
use crate::auth_env_telemetry::collect_auth_env_telemetry;
use crate::commit_attribution::commit_message_trailer_instruction;
use crate::compact;
use crate::compact::InitialContextInjection;
@@ -291,7 +292,6 @@ use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::tools::ToolRouter;
use crate::tools::context::SharedTurnDiffTracker;
use crate::tools::discoverable::DiscoverableTool;
use crate::tools::js_repl::JsReplHandle;
use crate::tools::js_repl::resolve_compatible_node;
use crate::tools::network_approval::NetworkApprovalService;
@@ -370,6 +370,7 @@ pub(crate) struct CodexSpawnArgs {
pub(crate) persist_extended_history: bool,
pub(crate) metrics_service_name: Option<String>,
pub(crate) inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
pub(crate) user_shell_override: Option<shell::Shell>,
pub(crate) parent_trace: Option<W3cTraceContext>,
}
@@ -421,6 +422,7 @@ impl Codex {
persist_extended_history,
metrics_service_name,
inherited_shell_snapshot,
user_shell_override,
parent_trace: _,
} = args;
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
@@ -575,6 +577,7 @@ impl Codex {
dynamic_tools,
persist_extended_history,
inherited_shell_snapshot,
user_shell_override,
};
// Generate a unique ID for the lifetime of this Codex session.
@@ -1037,6 +1040,7 @@ pub(crate) struct SessionConfiguration {
dynamic_tools: Vec<DynamicToolSpec>,
persist_extended_history: bool,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
user_shell_override: Option<shell::Shell>,
}
impl SessionConfiguration {
@@ -1562,6 +1566,10 @@ impl Session {
let originator = crate::default_client::originator().value;
let terminal_type = terminal::user_agent();
let session_model = session_configuration.collaboration_mode.model().to_string();
let auth_env_telemetry = collect_auth_env_telemetry(
&session_configuration.provider,
auth_manager.codex_api_key_env_enabled(),
);
let mut session_telemetry = SessionTelemetry::new(
conversation_id,
session_model.as_str(),
@@ -1573,7 +1581,8 @@ impl Session {
config.otel.log_user_prompt,
terminal_type.clone(),
session_configuration.session_source.clone(),
);
)
.with_auth_env(auth_env_telemetry.to_otel_metadata());
if let Some(service_name) = session_configuration.metrics_service_name.as_deref() {
session_telemetry = session_telemetry.with_metrics_service_name(service_name);
}
@@ -1617,7 +1626,11 @@ impl Session {
);
let use_zsh_fork_shell = config.features.enabled(Feature::ShellZshFork);
let mut default_shell = if use_zsh_fork_shell {
let mut default_shell = if let Some(user_shell_override) =
session_configuration.user_shell_override.clone()
{
user_shell_override
} else if use_zsh_fork_shell {
let zsh_path = config.zsh_path.as_ref().ok_or_else(|| {
anyhow::anyhow!(
"zsh fork feature enabled, but `zsh_path` is not configured; set `zsh_path` in config.toml"
@@ -4229,27 +4242,6 @@ async fn submission_loop(sess: Arc<Session>, config: Arc<Config>, rx_sub: Receiv
handlers::list_skills(&sess, sub.id.clone(), cwds, force_reload).await;
false
}
Op::ListRemoteSkills {
hazelnut_scope,
product_surface,
enabled,
} => {
handlers::list_remote_skills(
&sess,
&config,
sub.id.clone(),
hazelnut_scope,
product_surface,
enabled,
)
.await;
false
}
Op::DownloadRemoteSkill { hazelnut_id } => {
handlers::export_remote_skill(&sess, &config, sub.id.clone(), hazelnut_id)
.await;
false
}
Op::Undo => {
handlers::undo(&sess, sub.id.clone()).await;
false
@@ -4371,14 +4363,9 @@ mod handlers {
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::ListCustomPromptsResponseEvent;
use codex_protocol::protocol::ListRemoteSkillsResponseEvent;
use codex_protocol::protocol::ListSkillsResponseEvent;
use codex_protocol::protocol::McpServerRefreshConfig;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RemoteSkillDownloadedEvent;
use codex_protocol::protocol::RemoteSkillHazelnutScope;
use codex_protocol::protocol::RemoteSkillProductSurface;
use codex_protocol::protocol::RemoteSkillSummary;
use codex_protocol::protocol::ReviewDecision;
use codex_protocol::protocol::ReviewRequest;
use codex_protocol::protocol::RolloutItem;
@@ -4779,96 +4766,6 @@ mod handlers {
sess.send_event_raw(event).await;
}
pub async fn list_remote_skills(
sess: &Session,
config: &Arc<Config>,
sub_id: String,
hazelnut_scope: RemoteSkillHazelnutScope,
product_surface: RemoteSkillProductSurface,
enabled: Option<bool>,
) {
let auth = sess.services.auth_manager.auth().await;
let response = crate::skills::remote::list_remote_skills(
config,
auth.as_ref(),
hazelnut_scope,
product_surface,
enabled,
)
.await
.map(|skills| {
skills
.into_iter()
.map(|skill| RemoteSkillSummary {
id: skill.id,
name: skill.name,
description: skill.description,
})
.collect::<Vec<_>>()
});
match response {
Ok(skills) => {
let event = Event {
id: sub_id,
msg: EventMsg::ListRemoteSkillsResponse(ListRemoteSkillsResponseEvent {
skills,
}),
};
sess.send_event_raw(event).await;
}
Err(err) => {
let event = Event {
id: sub_id,
msg: EventMsg::Error(ErrorEvent {
message: format!("failed to list remote skills: {err}"),
codex_error_info: Some(CodexErrorInfo::Other),
}),
};
sess.send_event_raw(event).await;
}
}
}
pub async fn export_remote_skill(
sess: &Session,
config: &Arc<Config>,
sub_id: String,
hazelnut_id: String,
) {
let auth = sess.services.auth_manager.auth().await;
match crate::skills::remote::export_remote_skill(
config,
auth.as_ref(),
hazelnut_id.as_str(),
)
.await
{
Ok(result) => {
let id = result.id;
let event = Event {
id: sub_id,
msg: EventMsg::RemoteSkillDownloaded(RemoteSkillDownloadedEvent {
id: id.clone(),
name: id,
path: result.path,
}),
};
sess.send_event_raw(event).await;
}
Err(err) => {
let event = Event {
id: sub_id,
msg: EventMsg::Error(ErrorEvent {
message: format!("failed to export remote skill {hazelnut_id}: {err}"),
codex_error_info: Some(CodexErrorInfo::Other),
}),
};
sess.send_event_raw(event).await;
}
}
}
pub async fn undo(sess: &Arc<Session>, sub_id: String) {
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
sess.spawn_task(turn_context, Vec::new(), UndoTask::new())
@@ -6410,11 +6307,14 @@ pub(crate) async fn built_tools(
accessible_connectors.as_slice(),
)
.await
{
Ok(connectors) if connectors.is_empty() => None,
Ok(connectors) => {
Some(connectors.into_iter().map(DiscoverableTool::from).collect())
}
.map(|discoverable_tools| {
crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client(
discoverable_tools,
turn_context.app_server_client_name.as_deref(),
)
}) {
Ok(discoverable_tools) if discoverable_tools.is_empty() => None,
Ok(discoverable_tools) => Some(discoverable_tools),
Err(err) => {
warn!("failed to load discoverable tool suggestions: {err:#}");
None
@@ -6721,8 +6621,6 @@ fn realtime_text_for_event(msg: &EventMsg) -> Option<String> {
| EventMsg::McpListToolsResponse(_)
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::ListRemoteSkillsResponse(_)
| EventMsg::RemoteSkillDownloaded(_)
| EventMsg::SkillsUpdateAvailable
| EventMsg::PlanUpdate(_)
| EventMsg::TurnAborted(_)
+1
View File
@@ -88,6 +88,7 @@ pub(crate) async fn run_codex_thread_interactive(
persist_extended_history: false,
metrics_service_name: None,
inherited_shell_snapshot: None,
user_shell_override: None,
parent_trace: None,
})
.await?;
+6
View File
@@ -1663,6 +1663,7 @@ async fn set_rate_limits_retains_previous_credits() {
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
user_shell_override: None,
};
let mut state = SessionState::new(session_configuration);
@@ -1760,6 +1761,7 @@ async fn set_rate_limits_updates_plan_type_when_present() {
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
user_shell_override: None,
};
let mut state = SessionState::new(session_configuration);
@@ -2115,6 +2117,7 @@ pub(crate) async fn make_session_configuration_for_tests() -> SessionConfigurati
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
user_shell_override: None,
}
}
@@ -2345,6 +2348,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
user_shell_override: None,
};
let (tx_event, _rx_event) = async_channel::unbounded();
@@ -2439,6 +2443,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
dynamic_tools: Vec::new(),
persist_extended_history: false,
inherited_shell_snapshot: None,
user_shell_override: None,
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_info = ModelsManager::construct_model_info_offline_for_tests(
@@ -3230,6 +3235,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
dynamic_tools,
persist_extended_history: false,
inherited_shell_snapshot: None,
user_shell_override: None,
};
let per_turn_config = Session::build_per_turn_config(&session_configuration);
let model_info = ModelsManager::construct_model_info_offline_for_tests(
@@ -452,6 +452,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
persist_extended_history: false,
metrics_service_name: None,
inherited_shell_snapshot: None,
user_shell_override: None,
parent_trace: None,
})
.await
+1 -7
View File
@@ -1535,13 +1535,7 @@ pub enum RealtimeWsMode {
Transcription,
}
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Default, PartialEq, Eq, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum RealtimeWsVersion {
#[default]
V1,
V2,
}
pub use codex_protocol::protocol::RealtimeConversationVersion as RealtimeWsVersion;
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
+32 -23
View File
@@ -42,24 +42,14 @@ use crate::mcp_connection_manager::McpConnectionManager;
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
use crate::plugins::AppConnectorId;
use crate::plugins::PluginsManager;
use crate::plugins::list_tool_suggest_discoverable_plugins;
use crate::token_data::TokenData;
use crate::tools::discoverable::DiscoverablePluginInfo;
use crate::tools::discoverable::DiscoverableTool;
pub use codex_connectors::CONNECTORS_CACHE_TTL;
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
const TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS: &[&str] = &[
"connector_2128aebfecb84f64a069897515042a44",
"connector_68df038e0ba48191908c8434991bbac2",
"asdk_app_69a1d78e929881919bba0dbda1f6436d",
"connector_4964e3b22e3e427e9b4ae1acf2c1fa34",
"connector_9d7cfa34e6654a5f98d3387af34b2e1c",
"connector_6f1ec045b8fa4ced8738e32c7f74514b",
"connector_947e0d954944416db111db556030eea6",
"connector_5f3c8c41a1e54ad7a76272c89e2554fa",
"connector_686fad9b54914a35b75be6d06a0f6f31",
"connector_76869538009648d5b282a4bb21c3d157",
"connector_37316be7febe4224b3d31465bae4dbd7",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct AppToolPolicy {
@@ -116,13 +106,24 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
config: &Config,
auth: Option<&CodexAuth>,
accessible_connectors: &[AppInfo],
) -> anyhow::Result<Vec<AppInfo>> {
) -> anyhow::Result<Vec<DiscoverableTool>> {
let directory_connectors =
list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?;
Ok(filter_tool_suggest_discoverable_tools(
let connector_ids = tool_suggest_connector_ids(config);
let discoverable_connectors = filter_tool_suggest_discoverable_connectors(
directory_connectors,
accessible_connectors,
))
&connector_ids,
)
.into_iter()
.map(DiscoverableTool::from);
let discoverable_plugins = list_tool_suggest_discoverable_plugins(config)?
.into_iter()
.map(DiscoverablePluginInfo::from)
.map(DiscoverableTool::from);
Ok(discoverable_connectors
.chain(discoverable_plugins)
.collect())
}
pub async fn list_cached_accessible_connectors_from_mcp_tools(
@@ -350,24 +351,21 @@ fn write_cached_accessible_connectors(
});
}
fn filter_tool_suggest_discoverable_tools(
fn filter_tool_suggest_discoverable_connectors(
directory_connectors: Vec<AppInfo>,
accessible_connectors: &[AppInfo],
discoverable_connector_ids: &HashSet<String>,
) -> Vec<AppInfo> {
let accessible_connector_ids: HashSet<&str> = accessible_connectors
.iter()
.filter(|connector| connector.is_accessible && connector.is_enabled)
.filter(|connector| connector.is_accessible)
.map(|connector| connector.id.as_str())
.collect();
let allowed_connector_ids: HashSet<&str> = TOOL_SUGGEST_DISCOVERABLE_CONNECTOR_IDS
.iter()
.copied()
.collect();
let mut connectors = filter_disallowed_connectors(directory_connectors)
.into_iter()
.filter(|connector| !accessible_connector_ids.contains(connector.id.as_str()))
.filter(|connector| allowed_connector_ids.contains(connector.id.as_str()))
.filter(|connector| discoverable_connector_ids.contains(connector.id.as_str()))
.collect::<Vec<_>>();
connectors.sort_by(|left, right| {
left.name
@@ -377,6 +375,16 @@ fn filter_tool_suggest_discoverable_tools(
connectors
}
fn tool_suggest_connector_ids(config: &Config) -> HashSet<String> {
PluginsManager::new(config.codex_home.clone())
.plugins_for_config(config)
.capability_summaries()
.iter()
.flat_map(|plugin| plugin.app_connector_ids.iter())
.map(|connector_id| connector_id.0.clone())
.collect()
}
async fn list_directory_connectors_for_tool_suggest_with_auth(
config: &Config,
auth: Option<&CodexAuth>,
@@ -675,6 +683,7 @@ pub(crate) fn codex_app_tool_is_enabled(
const DISALLOWED_CONNECTOR_IDS: &[&str] = &[
"asdk_app_6938a94a61d881918ef32cb999ff937c",
"connector_2b0a9009c9c64bf9933a3dae3f2b1254",
"connector_3f8d1a79f27c4c7ba1a897ab13bf37dc",
"connector_68de829bf7648191acd70a907364c67c",
"connector_68e004f14af881919eb50893d3d9f523",
"connector_69272cb413a081919685ec3c88d1744e",
+16 -12
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use crate::config::types::AppConfig;
use crate::config::types::AppToolConfig;
@@ -13,13 +14,13 @@ use crate::config_loader::ConfigRequirementsToml;
use crate::features::Feature;
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
use crate::mcp_connection_manager::ToolInfo;
use codex_config::CONFIG_TOML_FILE;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use rmcp::model::JsonObject;
use rmcp::model::Tool;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::collections::HashSet;
use std::sync::Arc;
use tempfile::tempdir;
@@ -957,6 +958,7 @@ fn filter_disallowed_connectors_filters_openai_prefix() {
fn filter_disallowed_connectors_filters_disallowed_connector_ids() {
let filtered = filter_disallowed_connectors(vec![
app("asdk_app_6938a94a61d881918ef32cb999ff937c"),
app("connector_3f8d1a79f27c4c7ba1a897ab13bf37dc"),
app("delta"),
]);
assert_eq!(filtered, vec![app("delta")]);
@@ -979,8 +981,8 @@ fn first_party_chat_originator_filters_target_and_openai_prefixed_connectors() {
}
#[test]
fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_apps() {
let filtered = filter_tool_suggest_discoverable_tools(
fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstalled_apps() {
let filtered = filter_tool_suggest_discoverable_connectors(
vec![
named_app(
"connector_2128aebfecb84f64a069897515042a44",
@@ -996,6 +998,10 @@ fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_app
"Google Calendar",
)
}],
&HashSet::from([
"connector_2128aebfecb84f64a069897515042a44".to_string(),
"connector_68df038e0ba48191908c8434991bbac2".to_string(),
]),
);
assert_eq!(
@@ -1008,8 +1014,8 @@ fn filter_tool_suggest_discoverable_tools_keeps_only_allowlisted_uninstalled_app
}
#[test]
fn filter_tool_suggest_discoverable_tools_keeps_disabled_accessible_apps() {
let filtered = filter_tool_suggest_discoverable_tools(
fn filter_tool_suggest_discoverable_connectors_excludes_accessible_apps_even_when_disabled() {
let filtered = filter_tool_suggest_discoverable_connectors(
vec![
named_app(
"connector_2128aebfecb84f64a069897515042a44",
@@ -1031,13 +1037,11 @@ fn filter_tool_suggest_discoverable_tools_keeps_disabled_accessible_apps() {
..named_app("connector_68df038e0ba48191908c8434991bbac2", "Gmail")
},
],
&HashSet::from([
"connector_2128aebfecb84f64a069897515042a44".to_string(),
"connector_68df038e0ba48191908c8434991bbac2".to_string(),
]),
);
assert_eq!(
filtered,
vec![named_app(
"connector_68df038e0ba48191908c8434991bbac2",
"Gmail"
)]
);
assert_eq!(filtered, Vec::<AppInfo>::new());
}
+1 -1
View File
@@ -87,7 +87,7 @@ pub enum Feature {
JsRepl,
/// Enable a minimal JavaScript mode backed by Node's built-in vm runtime.
CodeMode,
/// Restrict model-visible tools to code mode entrypoints (`exec`, `exec_wait`).
/// Restrict model-visible tools to code mode entrypoints (`exec`, `wait`).
CodeModeOnly,
/// Only expose js_repl tools directly to the model.
JsReplToolsOnly,
+1
View File
@@ -11,6 +11,7 @@ mod apply_patch;
mod apps;
mod arc_monitor;
pub mod auth;
mod auth_env_telemetry;
mod client;
mod client_common;
pub mod codex;
+40 -17
View File
@@ -4,6 +4,8 @@ use crate::api_bridge::map_api_error;
use crate::auth::AuthManager;
use crate::auth::AuthMode;
use crate::auth::CodexAuth;
use crate::auth_env_telemetry::AuthEnvTelemetry;
use crate::auth_env_telemetry::collect_auth_env_telemetry;
use crate::config::Config;
use crate::default_client::build_reqwest_client;
use crate::error::CodexErr;
@@ -15,7 +17,7 @@ use crate::models_manager::model_info;
use crate::response_debug_context::extract_response_debug_context;
use crate::response_debug_context::telemetry_transport_error_message;
use crate::util::FeedbackRequestTags;
use crate::util::emit_feedback_request_tags;
use crate::util::emit_feedback_request_tags_with_auth_env;
use codex_api::ModelsClient;
use codex_api::RequestTelemetry;
use codex_api::ReqwestTransport;
@@ -46,6 +48,7 @@ struct ModelsRequestTelemetry {
auth_mode: Option<String>,
auth_header_attached: bool,
auth_header_name: Option<&'static str>,
auth_env: AuthEnvTelemetry,
}
impl RequestTelemetry for ModelsRequestTelemetry {
@@ -74,6 +77,12 @@ impl RequestTelemetry for ModelsRequestTelemetry {
endpoint = MODELS_ENDPOINT,
auth.header_attached = self.auth_header_attached,
auth.header_name = self.auth_header_name,
auth.env_openai_api_key_present = self.auth_env.openai_api_key_env_present,
auth.env_codex_api_key_present = self.auth_env.codex_api_key_env_present,
auth.env_codex_api_key_enabled = self.auth_env.codex_api_key_env_enabled,
auth.env_provider_key_name = self.auth_env.provider_env_key_name.as_deref(),
auth.env_provider_key_present = self.auth_env.provider_env_key_present,
auth.env_refresh_token_url_override_present = self.auth_env.refresh_token_url_override_present,
auth.request_id = response_debug.request_id.as_deref(),
auth.cf_ray = response_debug.cf_ray.as_deref(),
auth.error = response_debug.auth_error.as_deref(),
@@ -92,28 +101,37 @@ impl RequestTelemetry for ModelsRequestTelemetry {
endpoint = MODELS_ENDPOINT,
auth.header_attached = self.auth_header_attached,
auth.header_name = self.auth_header_name,
auth.env_openai_api_key_present = self.auth_env.openai_api_key_env_present,
auth.env_codex_api_key_present = self.auth_env.codex_api_key_env_present,
auth.env_codex_api_key_enabled = self.auth_env.codex_api_key_env_enabled,
auth.env_provider_key_name = self.auth_env.provider_env_key_name.as_deref(),
auth.env_provider_key_present = self.auth_env.provider_env_key_present,
auth.env_refresh_token_url_override_present = self.auth_env.refresh_token_url_override_present,
auth.request_id = response_debug.request_id.as_deref(),
auth.cf_ray = response_debug.cf_ray.as_deref(),
auth.error = response_debug.auth_error.as_deref(),
auth.error_code = response_debug.auth_error_code.as_deref(),
auth.mode = self.auth_mode.as_deref(),
);
emit_feedback_request_tags(&FeedbackRequestTags {
endpoint: MODELS_ENDPOINT,
auth_header_attached: self.auth_header_attached,
auth_header_name: self.auth_header_name,
auth_mode: self.auth_mode.as_deref(),
auth_retry_after_unauthorized: None,
auth_recovery_mode: None,
auth_recovery_phase: None,
auth_connection_reused: None,
auth_request_id: response_debug.request_id.as_deref(),
auth_cf_ray: response_debug.cf_ray.as_deref(),
auth_error: response_debug.auth_error.as_deref(),
auth_error_code: response_debug.auth_error_code.as_deref(),
auth_recovery_followup_success: None,
auth_recovery_followup_status: None,
});
emit_feedback_request_tags_with_auth_env(
&FeedbackRequestTags {
endpoint: MODELS_ENDPOINT,
auth_header_attached: self.auth_header_attached,
auth_header_name: self.auth_header_name,
auth_mode: self.auth_mode.as_deref(),
auth_retry_after_unauthorized: None,
auth_recovery_mode: None,
auth_recovery_phase: None,
auth_connection_reused: None,
auth_request_id: response_debug.request_id.as_deref(),
auth_cf_ray: response_debug.cf_ray.as_deref(),
auth_error: response_debug.auth_error.as_deref(),
auth_error_code: response_debug.auth_error_code.as_deref(),
auth_recovery_followup_success: None,
auth_recovery_followup_status: None,
},
&self.auth_env,
);
}
}
@@ -417,11 +435,16 @@ impl ModelsManager {
let auth_mode = auth.as_ref().map(CodexAuth::auth_mode);
let api_provider = self.provider.to_api_provider(auth_mode)?;
let api_auth = auth_provider_from_auth(auth.clone(), &self.provider)?;
let auth_env = collect_auth_env_telemetry(
&self.provider,
self.auth_manager.codex_api_key_env_enabled(),
);
let transport = ReqwestTransport::new(build_reqwest_client());
let request_telemetry: Arc<dyn RequestTelemetry> = Arc::new(ModelsRequestTelemetry {
auth_mode: auth_mode.map(|mode| TelemetryAuthMode::from(mode).to_string()),
auth_header_attached: api_auth.auth_header_attached(),
auth_header_name: api_auth.auth_header_name(),
auth_env,
});
let client = ModelsClient::new(transport, api_provider, api_auth)
.with_telemetry(Some(request_telemetry));
@@ -3,12 +3,27 @@ use crate::CodexAuth;
use crate::auth::AuthCredentialsStoreMode;
use crate::config::ConfigBuilder;
use crate::model_provider_info::WireApi;
use base64::Engine as _;
use chrono::Utc;
use codex_api::TransportError;
use codex_protocol::openai_models::ModelsResponse;
use core_test_support::responses::mount_models_once;
use http::HeaderMap;
use http::StatusCode;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::Mutex;
use tempfile::tempdir;
use tracing::Event;
use tracing::Subscriber;
use tracing::field::Visit;
use tracing_subscriber::Layer;
use tracing_subscriber::layer::Context;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::registry::LookupSpan;
use tracing_subscriber::util::SubscriberInitExt;
use wiremock::MockServer;
fn remote_model(slug: &str, display: &str, priority: i32) -> ModelInfo {
@@ -77,6 +92,47 @@ fn provider_for(base_url: String) -> ModelProviderInfo {
}
}
#[derive(Default)]
struct TagCollectorVisitor {
tags: BTreeMap<String, String>,
}
impl Visit for TagCollectorVisitor {
fn record_bool(&mut self, field: &tracing::field::Field, value: bool) {
self.tags
.insert(field.name().to_string(), value.to_string());
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
self.tags
.insert(field.name().to_string(), value.to_string());
}
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
self.tags
.insert(field.name().to_string(), format!("{value:?}"));
}
}
#[derive(Clone)]
struct TagCollectorLayer {
tags: Arc<Mutex<BTreeMap<String, String>>>,
}
impl<S> Layer<S> for TagCollectorLayer
where
S: Subscriber + for<'a> LookupSpan<'a>,
{
fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
if event.metadata().target() != "feedback_tags" {
return;
}
let mut visitor = TagCollectorVisitor::default();
event.record(&mut visitor);
self.tags.lock().unwrap().extend(visitor.tags);
}
}
#[tokio::test]
async fn get_model_info_tracks_fallback_usage() {
let codex_home = tempdir().expect("temp dir");
@@ -530,6 +586,104 @@ async fn refresh_available_models_skips_network_without_chatgpt_auth() {
);
}
#[test]
fn models_request_telemetry_emits_auth_env_feedback_tags_on_failure() {
let tags = Arc::new(Mutex::new(BTreeMap::new()));
let _guard = tracing_subscriber::registry()
.with(TagCollectorLayer { tags: tags.clone() })
.set_default();
let telemetry = ModelsRequestTelemetry {
auth_mode: Some(TelemetryAuthMode::Chatgpt.to_string()),
auth_header_attached: true,
auth_header_name: Some("authorization"),
auth_env: crate::auth_env_telemetry::AuthEnvTelemetry {
openai_api_key_env_present: false,
codex_api_key_env_present: false,
codex_api_key_env_enabled: false,
provider_env_key_name: Some("configured".to_string()),
provider_env_key_present: Some(false),
refresh_token_url_override_present: false,
},
};
let mut headers = HeaderMap::new();
headers.insert("x-request-id", "req-models-401".parse().unwrap());
headers.insert("cf-ray", "ray-models-401".parse().unwrap());
headers.insert(
"x-openai-authorization-error",
"missing_authorization_header".parse().unwrap(),
);
headers.insert(
"x-error-json",
base64::engine::general_purpose::STANDARD
.encode(r#"{"error":{"code":"token_expired"}}"#)
.parse()
.unwrap(),
);
telemetry.on_request(
1,
Some(StatusCode::UNAUTHORIZED),
Some(&TransportError::Http {
status: StatusCode::UNAUTHORIZED,
url: Some("https://example.test/models".to_string()),
headers: Some(headers),
body: Some("plain text error".to_string()),
}),
Duration::from_millis(17),
);
let tags = tags.lock().unwrap().clone();
assert_eq!(
tags.get("endpoint").map(String::as_str),
Some("\"/models\"")
);
assert_eq!(
tags.get("auth_mode").map(String::as_str),
Some("\"Chatgpt\"")
);
assert_eq!(
tags.get("auth_request_id").map(String::as_str),
Some("\"req-models-401\"")
);
assert_eq!(
tags.get("auth_error").map(String::as_str),
Some("\"missing_authorization_header\"")
);
assert_eq!(
tags.get("auth_error_code").map(String::as_str),
Some("\"token_expired\"")
);
assert_eq!(
tags.get("auth_env_openai_api_key_present")
.map(String::as_str),
Some("false")
);
assert_eq!(
tags.get("auth_env_codex_api_key_present")
.map(String::as_str),
Some("false")
);
assert_eq!(
tags.get("auth_env_codex_api_key_enabled")
.map(String::as_str),
Some("false")
);
assert_eq!(
tags.get("auth_env_provider_key_name").map(String::as_str),
Some("\"configured\"")
);
assert_eq!(
tags.get("auth_env_provider_key_present")
.map(String::as_str),
Some("\"false\"")
);
assert_eq!(
tags.get("auth_env_refresh_token_url_override_present")
.map(String::as_str),
Some("false")
);
}
#[test]
fn build_available_models_picks_default_after_hiding_hidden_models() {
let codex_home = tempdir().expect("temp dir");
+72
View File
@@ -0,0 +1,72 @@
use anyhow::Context;
use tracing::warn;
use super::OPENAI_CURATED_MARKETPLACE_NAME;
use super::PluginCapabilitySummary;
use super::PluginReadRequest;
use super::PluginsManager;
use crate::config::Config;
use crate::features::Feature;
const TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST: &[&str] = &[
"github@openai-curated",
"notion@openai-curated",
"slack@openai-curated",
"gmail@openai-curated",
"google-calendar@openai-curated",
"google-docs@openai-curated",
"google-drive@openai-curated",
"google-sheets@openai-curated",
"google-slides@openai-curated",
];
pub(crate) fn list_tool_suggest_discoverable_plugins(
config: &Config,
) -> anyhow::Result<Vec<PluginCapabilitySummary>> {
if !config.features.enabled(Feature::Plugins) {
return Ok(Vec::new());
}
let plugins_manager = PluginsManager::new(config.codex_home.clone());
let marketplaces = plugins_manager
.list_marketplaces_for_config(config, &[])
.context("failed to list plugin marketplaces for tool suggestions")?;
let Some(curated_marketplace) = marketplaces
.into_iter()
.find(|marketplace| marketplace.name == OPENAI_CURATED_MARKETPLACE_NAME)
else {
return Ok(Vec::new());
};
let mut discoverable_plugins = Vec::<PluginCapabilitySummary>::new();
for plugin in curated_marketplace.plugins {
if plugin.installed
|| !TOOL_SUGGEST_DISCOVERABLE_PLUGIN_ALLOWLIST.contains(&plugin.id.as_str())
{
continue;
}
let plugin_id = plugin.id.clone();
let plugin_name = plugin.name.clone();
match plugins_manager.read_plugin_for_config(
config,
&PluginReadRequest {
plugin_name,
marketplace_path: curated_marketplace.path.clone(),
},
) {
Ok(plugin) => discoverable_plugins.push(plugin.plugin.into()),
Err(err) => warn!("failed to load curated plugin suggestion {plugin_id}: {err:#}"),
}
}
discoverable_plugins.sort_by(|left, right| {
left.display_name
.cmp(&right.display_name)
.then_with(|| left.config_name.cmp(&right.config_name))
});
Ok(discoverable_plugins)
}
#[cfg(test)]
#[path = "discoverable_tests.rs"]
mod tests;
@@ -0,0 +1,119 @@
use super::*;
use crate::plugins::PluginInstallRequest;
use crate::plugins::test_support::load_plugins_config;
use crate::plugins::test_support::write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use crate::plugins::test_support::write_plugins_feature_config;
use crate::tools::discoverable::DiscoverablePluginInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_uninstalled_curated_plugins() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["sample", "slack"]);
write_plugins_feature_config(codex_home.path());
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
assert_eq!(
discoverable_plugins,
vec![DiscoverablePluginInfo {
id: "slack@openai-curated".to_string(),
name: "slack".to_string(),
description: Some(
"Plugin that includes skills, MCP servers, and app connectors".to_string(),
),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec!["connector_calendar".to_string()],
}]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_feature_disabled() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_normalizes_description() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_plugins_feature_config(codex_home.path());
write_file(
&curated_root.join("plugins/slack/.codex-plugin/plugin.json"),
r#"{
"name": "slack",
"description": " Plugin\n with extra spacing "
}"#,
);
let config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
assert_eq!(
discoverable_plugins,
vec![DiscoverablePluginInfo {
id: "slack@openai-curated".to_string(),
name: "slack".to_string(),
description: Some("Plugin with extra spacing".to_string()),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec!["connector_calendar".to_string()],
}]
);
}
#[tokio::test]
async fn list_tool_suggest_discoverable_plugins_omits_installed_curated_plugins() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["slack"]);
write_curated_plugin_sha(codex_home.path());
write_plugins_feature_config(codex_home.path());
PluginsManager::new(codex_home.path().to_path_buf())
.install_plugin(PluginInstallRequest {
plugin_name: "slack".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
curated_root.join(".agents/plugins/marketplace.json"),
)
.expect("marketplace path"),
})
.await
.expect("plugin should install");
let refreshed_config = load_plugins_config(codex_home.path()).await;
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&refreshed_config)
.unwrap()
.into_iter()
.map(DiscoverablePluginInfo::from)
.collect::<Vec<_>>();
assert_eq!(discoverable_plugins, Vec::<DiscoverablePluginInfo>::new());
}
+14 -1
View File
@@ -68,7 +68,7 @@ use tracing::warn;
const DEFAULT_SKILLS_DIR_NAME: &str = "skills";
const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
@@ -219,6 +219,19 @@ impl PluginCapabilitySummary {
}
}
impl From<PluginDetailSummary> for PluginCapabilitySummary {
fn from(value: PluginDetailSummary) -> Self {
Self {
config_name: value.id,
display_name: value.name,
description: prompt_safe_plugin_description(value.description.as_deref()),
has_skills: !value.skills.is_empty(),
mcp_server_names: value.mcp_server_names,
app_connector_ids: value.apps,
}
}
}
fn prompt_safe_plugin_description(description: Option<&str>) -> Option<String> {
let description = description?
.split_whitespace()
+4 -45
View File
@@ -7,6 +7,10 @@ use crate::config_loader::ConfigLayerEntry;
use crate::config_loader::ConfigLayerStack;
use crate::config_loader::ConfigRequirements;
use crate::config_loader::ConfigRequirementsToml;
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
use crate::plugins::test_support::write_file;
use crate::plugins::test_support::write_openai_curated_marketplace;
use codex_app_server_protocol::ConfigLayerSource;
use pretty_assertions::assert_eq;
use std::fs;
@@ -19,13 +23,6 @@ use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
fn write_file(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
fs::write(path, contents).unwrap();
}
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
let plugin_root = root.join(dir_name);
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
@@ -39,44 +36,6 @@ fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap();
}
fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) {
fs::create_dir_all(root.join(".agents/plugins")).unwrap();
let plugins = plugin_names
.iter()
.map(|plugin_name| {
format!(
r#"{{
"name": "{plugin_name}",
"source": {{
"source": "local",
"path": "./plugins/{plugin_name}"
}}
}}"#
)
})
.collect::<Vec<_>>()
.join(",\n");
fs::write(
root.join(".agents/plugins/marketplace.json"),
format!(
r#"{{
"name": "{OPENAI_CURATED_MARKETPLACE_NAME}",
"plugins": [
{plugins}
]
}}"#
),
)
.unwrap();
for plugin_name in plugin_names {
write_plugin(root, &format!("plugins/{plugin_name}"), plugin_name);
}
}
fn write_curated_plugin_sha(codex_home: &Path, sha: &str) {
write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n"));
}
fn plugin_config_toml(enabled: bool, plugins_feature_enabled: bool) -> String {
let mut root = toml::map::Map::new();
+5
View File
@@ -1,4 +1,5 @@
mod curated_repo;
mod discoverable;
mod injection;
mod manager;
mod manifest;
@@ -6,16 +7,20 @@ mod marketplace;
mod remote;
mod render;
mod store;
#[cfg(test)]
pub(crate) mod test_support;
mod toggles;
pub(crate) use curated_repo::curated_plugins_repo_path;
pub(crate) use curated_repo::read_curated_plugins_sha;
pub(crate) use curated_repo::sync_openai_plugins_repo;
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
pub(crate) use injection::build_plugin_injections;
pub use manager::AppConnectorId;
pub use manager::ConfiguredMarketplacePluginSummary;
pub use manager::ConfiguredMarketplaceSummary;
pub use manager::LoadedPlugin;
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
pub use manager::PluginCapabilitySummary;
pub use manager::PluginDetailSummary;
pub use manager::PluginInstallError;
+109
View File
@@ -0,0 +1,109 @@
use crate::config::CONFIG_TOML_FILE;
use crate::config::ConfigBuilder;
use std::fs;
use std::path::Path;
use super::OPENAI_CURATED_MARKETPLACE_NAME;
pub(crate) const TEST_CURATED_PLUGIN_SHA: &str = "0123456789abcdef0123456789abcdef01234567";
pub(crate) fn write_file(path: &Path, contents: &str) {
fs::create_dir_all(path.parent().expect("file should have a parent")).unwrap();
fs::write(path, contents).unwrap();
}
pub(crate) fn write_curated_plugin(root: &Path, plugin_name: &str) {
let plugin_root = root.join("plugins").join(plugin_name);
write_file(
&plugin_root.join(".codex-plugin/plugin.json"),
&format!(
r#"{{
"name": "{plugin_name}",
"description": "Plugin that includes skills, MCP servers, and app connectors"
}}"#
),
);
write_file(
&plugin_root.join("skills/SKILL.md"),
"---\nname: sample\ndescription: sample\n---\n",
);
write_file(
&plugin_root.join(".mcp.json"),
r#"{
"mcpServers": {
"sample-docs": {
"type": "http",
"url": "https://sample.example/mcp"
}
}
}"#,
);
write_file(
&plugin_root.join(".app.json"),
r#"{
"apps": {
"calendar": {
"id": "connector_calendar"
}
}
}"#,
);
}
pub(crate) fn write_openai_curated_marketplace(root: &Path, plugin_names: &[&str]) {
let plugins = plugin_names
.iter()
.map(|plugin_name| {
format!(
r#"{{
"name": "{plugin_name}",
"source": {{
"source": "local",
"path": "./plugins/{plugin_name}"
}}
}}"#
)
})
.collect::<Vec<_>>()
.join(",\n");
write_file(
&root.join(".agents/plugins/marketplace.json"),
&format!(
r#"{{
"name": "{OPENAI_CURATED_MARKETPLACE_NAME}",
"plugins": [
{plugins}
]
}}"#
),
);
for plugin_name in plugin_names {
write_curated_plugin(root, plugin_name);
}
}
pub(crate) fn write_curated_plugin_sha(codex_home: &Path) {
write_curated_plugin_sha_with(codex_home, TEST_CURATED_PLUGIN_SHA);
}
pub(crate) fn write_curated_plugin_sha_with(codex_home: &Path, sha: &str) {
write_file(&codex_home.join(".tmp/plugins.sha"), &format!("{sha}\n"));
}
pub(crate) fn write_plugins_feature_config(codex_home: &Path) {
write_file(
&codex_home.join(CONFIG_TOML_FILE),
r#"[features]
plugins = true
"#,
);
}
pub(crate) async fn load_plugins_config(codex_home: &Path) -> crate::config::Config {
ConfigBuilder::default()
.codex_home(codex_home.to_path_buf())
.fallback_cwd(Some(codex_home.to_path_buf()))
.build()
.await
.expect("config should load")
}
+3 -1
View File
@@ -371,7 +371,8 @@ pub(crate) async fn handle_start(
format!("{prompt}\n\n{startup_context}")
};
let model = config.experimental_realtime_ws_model.clone();
let event_parser = match config.realtime.version {
let version = config.realtime.version;
let event_parser = match version {
RealtimeWsVersion::V1 => RealtimeEventParser::V1,
RealtimeWsVersion::V2 => RealtimeEventParser::RealtimeV2,
};
@@ -411,6 +412,7 @@ pub(crate) async fn handle_start(
id: sub_id.clone(),
msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent {
session_id: requested_session_id,
version,
}),
})
.await;
-2
View File
@@ -164,8 +164,6 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
| EventMsg::McpStartupComplete(_)
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::ListRemoteSkillsResponse(_)
| EventMsg::RemoteSkillDownloaded(_)
| EventMsg::PlanUpdate(_)
| EventMsg::ShutdownComplete
| EventMsg::DeprecationNotice(_)
+36 -18
View File
@@ -9,17 +9,34 @@ use std::time::Duration;
use crate::auth::CodexAuth;
use crate::config::Config;
use crate::default_client::build_reqwest_client;
use codex_protocol::protocol::RemoteSkillHazelnutScope;
use codex_protocol::protocol::RemoteSkillProductSurface;
const REMOTE_SKILLS_API_TIMEOUT: Duration = Duration::from_secs(30);
fn as_query_hazelnut_scope(scope: RemoteSkillHazelnutScope) -> Option<&'static str> {
// Low-level client for the remote skill API. This is intentionally kept around for
// future wiring, but it is not used yet by any active product surface.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteSkillScope {
WorkspaceShared,
AllShared,
Personal,
Example,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RemoteSkillProductSurface {
Chatgpt,
Codex,
Api,
Atlas,
}
fn as_query_scope(scope: RemoteSkillScope) -> Option<&'static str> {
match scope {
RemoteSkillHazelnutScope::WorkspaceShared => Some("workspace-shared"),
RemoteSkillHazelnutScope::AllShared => Some("all-shared"),
RemoteSkillHazelnutScope::Personal => Some("personal"),
RemoteSkillHazelnutScope::Example => Some("example"),
RemoteSkillScope::WorkspaceShared => Some("workspace-shared"),
RemoteSkillScope::AllShared => Some("all-shared"),
RemoteSkillScope::Personal => Some("personal"),
RemoteSkillScope::Example => Some("example"),
}
}
@@ -34,11 +51,11 @@ fn as_query_product_surface(product_surface: RemoteSkillProductSurface) -> &'sta
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth> {
let Some(auth) = auth else {
anyhow::bail!("chatgpt authentication required for hazelnut scopes");
anyhow::bail!("chatgpt authentication required for remote skill scopes");
};
if !auth.is_chatgpt_auth() {
anyhow::bail!(
"chatgpt authentication required for hazelnut scopes; api key auth is not supported"
"chatgpt authentication required for remote skill scopes; api key auth is not supported"
);
}
Ok(auth)
@@ -59,7 +76,8 @@ pub struct RemoteSkillDownloadResult {
#[derive(Debug, Deserialize)]
struct RemoteSkillsResponse {
hazelnuts: Vec<RemoteSkill>,
#[serde(rename = "hazelnuts")]
skills: Vec<RemoteSkill>,
}
#[derive(Debug, Deserialize)]
@@ -72,7 +90,7 @@ struct RemoteSkill {
pub async fn list_remote_skills(
config: &Config,
auth: Option<&CodexAuth>,
hazelnut_scope: RemoteSkillHazelnutScope,
scope: RemoteSkillScope,
product_surface: RemoteSkillProductSurface,
enabled: Option<bool>,
) -> Result<Vec<RemoteSkillSummary>> {
@@ -82,7 +100,7 @@ pub async fn list_remote_skills(
let url = format!("{base_url}/hazelnuts");
let product_surface = as_query_product_surface(product_surface);
let mut query_params = vec![("product_surface", product_surface)];
if let Some(scope) = as_query_hazelnut_scope(hazelnut_scope) {
if let Some(scope) = as_query_scope(scope) {
query_params.push(("scope", scope));
}
if let Some(enabled) = enabled {
@@ -117,7 +135,7 @@ pub async fn list_remote_skills(
serde_json::from_str(&body).context("Failed to parse skills response")?;
Ok(parsed
.hazelnuts
.skills
.into_iter()
.map(|skill| RemoteSkillSummary {
id: skill.id,
@@ -130,13 +148,13 @@ pub async fn list_remote_skills(
pub async fn export_remote_skill(
config: &Config,
auth: Option<&CodexAuth>,
hazelnut_id: &str,
skill_id: &str,
) -> Result<RemoteSkillDownloadResult> {
let auth = ensure_chatgpt_auth(auth)?;
let client = build_reqwest_client();
let base_url = config.chatgpt_base_url.trim_end_matches('/');
let url = format!("{base_url}/hazelnuts/{hazelnut_id}/export");
let url = format!("{base_url}/hazelnuts/{skill_id}/export");
let mut request = client.get(&url).timeout(REMOTE_SKILLS_API_TIMEOUT);
let token = auth
@@ -163,14 +181,14 @@ pub async fn export_remote_skill(
anyhow::bail!("Downloaded remote skill payload is not a zip archive");
}
let output_dir = config.codex_home.join("skills").join(hazelnut_id);
let output_dir = config.codex_home.join("skills").join(skill_id);
tokio::fs::create_dir_all(&output_dir)
.await
.context("Failed to create downloaded skills directory")?;
let zip_bytes = body.to_vec();
let output_dir_clone = output_dir.clone();
let prefix_candidates = vec![hazelnut_id.to_string()];
let prefix_candidates = vec![skill_id.to_string()];
tokio::task::spawn_blocking(move || {
extract_zip_to_dir(zip_bytes, &output_dir_clone, &prefix_candidates)
})
@@ -178,7 +196,7 @@ pub async fn export_remote_skill(
.context("Zip extraction task failed")??;
Ok(RemoteSkillDownloadResult {
id: hazelnut_id.to_string(),
id: skill_id.to_string(),
path: output_dir,
})
}
+27
View File
@@ -64,6 +64,33 @@ pub fn thread_manager_with_models_provider_and_home(
ThreadManager::with_models_provider_and_home_for_tests(auth, provider, codex_home)
}
pub async fn start_thread_with_user_shell_override(
thread_manager: &ThreadManager,
config: Config,
user_shell_override: crate::shell::Shell,
) -> crate::error::Result<crate::NewThread> {
thread_manager
.start_thread_with_user_shell_override_for_tests(config, user_shell_override)
.await
}
pub async fn resume_thread_from_rollout_with_user_shell_override(
thread_manager: &ThreadManager,
config: Config,
rollout_path: PathBuf,
auth_manager: Arc<AuthManager>,
user_shell_override: crate::shell::Shell,
) -> crate::error::Result<crate::NewThread> {
thread_manager
.resume_thread_from_rollout_with_user_shell_override_for_tests(
config,
rollout_path,
auth_manager,
user_shell_override,
)
.await
}
pub fn models_manager_with_provider(
codex_home: PathBuf,
auth_manager: Arc<AuthManager>,
+51
View File
@@ -381,6 +381,7 @@ impl ThreadManager {
persist_extended_history,
metrics_service_name,
parent_trace,
/*user_shell_override*/ None,
))
.await
}
@@ -420,6 +421,48 @@ impl ThreadManager {
persist_extended_history,
/*metrics_service_name*/ None,
parent_trace,
/*user_shell_override*/ None,
))
.await
}
pub(crate) async fn start_thread_with_user_shell_override_for_tests(
&self,
config: Config,
user_shell_override: crate::shell::Shell,
) -> CodexResult<NewThread> {
Box::pin(self.state.spawn_thread(
config,
InitialHistory::New,
Arc::clone(&self.state.auth_manager),
self.agent_control(),
Vec::new(),
/*persist_extended_history*/ false,
/*metrics_service_name*/ None,
/*parent_trace*/ None,
/*user_shell_override*/ Some(user_shell_override),
))
.await
}
pub(crate) async fn resume_thread_from_rollout_with_user_shell_override_for_tests(
&self,
config: Config,
rollout_path: PathBuf,
auth_manager: Arc<AuthManager>,
user_shell_override: crate::shell::Shell,
) -> CodexResult<NewThread> {
let initial_history = RolloutRecorder::get_rollout_history(&rollout_path).await?;
Box::pin(self.state.spawn_thread(
config,
initial_history,
auth_manager,
self.agent_control(),
Vec::new(),
/*persist_extended_history*/ false,
/*metrics_service_name*/ None,
/*parent_trace*/ None,
/*user_shell_override*/ Some(user_shell_override),
))
.await
}
@@ -505,6 +548,7 @@ impl ThreadManager {
persist_extended_history,
/*metrics_service_name*/ None,
parent_trace,
/*user_shell_override*/ None,
))
.await
}
@@ -590,6 +634,7 @@ impl ThreadManagerState {
metrics_service_name,
inherited_shell_snapshot,
/*parent_trace*/ None,
/*user_shell_override*/ None,
))
.await
}
@@ -614,6 +659,7 @@ impl ThreadManagerState {
/*metrics_service_name*/ None,
inherited_shell_snapshot,
/*parent_trace*/ None,
/*user_shell_override*/ None,
))
.await
}
@@ -638,6 +684,7 @@ impl ThreadManagerState {
/*metrics_service_name*/ None,
inherited_shell_snapshot,
/*parent_trace*/ None,
/*user_shell_override*/ None,
))
.await
}
@@ -654,6 +701,7 @@ impl ThreadManagerState {
persist_extended_history: bool,
metrics_service_name: Option<String>,
parent_trace: Option<W3cTraceContext>,
user_shell_override: Option<crate::shell::Shell>,
) -> CodexResult<NewThread> {
Box::pin(self.spawn_thread_with_source(
config,
@@ -666,6 +714,7 @@ impl ThreadManagerState {
metrics_service_name,
/*inherited_shell_snapshot*/ None,
parent_trace,
user_shell_override,
))
.await
}
@@ -683,6 +732,7 @@ impl ThreadManagerState {
metrics_service_name: Option<String>,
inherited_shell_snapshot: Option<Arc<ShellSnapshot>>,
parent_trace: Option<W3cTraceContext>,
user_shell_override: Option<crate::shell::Shell>,
) -> CodexResult<NewThread> {
let watch_registration = self
.file_watcher
@@ -704,6 +754,7 @@ impl ThreadManagerState {
persist_extended_history,
metrics_service_name,
inherited_shell_snapshot,
user_shell_override,
parent_trace,
})
.await?;
+3 -2
View File
@@ -34,10 +34,11 @@ const CODE_MODE_BRIDGE_SOURCE: &str = include_str!("bridge.js");
const CODE_MODE_DESCRIPTION_TEMPLATE: &str = include_str!("description.md");
const CODE_MODE_WAIT_DESCRIPTION_TEMPLATE: &str = include_str!("wait_description.md");
const CODE_MODE_PRAGMA_PREFIX: &str = "// @exec:";
const CODE_MODE_ONLY_PREFACE: &str = "Use `exec/exec_wait` tool to run all other tools, do not attempt to use any other tools directly";
const CODE_MODE_ONLY_PREFACE: &str =
"Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly";
pub(crate) const PUBLIC_TOOL_NAME: &str = "exec";
pub(crate) const WAIT_TOOL_NAME: &str = "exec_wait";
pub(crate) const WAIT_TOOL_NAME: &str = "wait";
pub(crate) fn is_code_mode_nested_tool(tool_name: &str) -> bool {
tool_name != PUBLIC_TOOL_NAME && tool_name != WAIT_TOOL_NAME
@@ -1,8 +1,8 @@
- Use `exec_wait` only after `exec` returns `Script running with cell ID ...`.
- Use `wait` only after `exec` returns `Script running with cell ID ...`.
- `cell_id` identifies the running `exec` cell to resume.
- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `exec_wait` uses its default wait timeout.
- `yield_time_ms` controls how long to wait for more output before yielding again. If omitted, `wait` uses its default wait timeout.
- `max_tokens` limits how much new output this wait call returns.
- `terminate: true` stops the running cell instead of waiting for more output.
- `exec_wait` returns only the new output since the last yield, or the final completion or termination result for that cell.
- If the cell is still running, `exec_wait` may yield again with the same `cell_id`.
- If the cell has already finished, `exec_wait` returns the completed result and closes the cell.
- `wait` returns only the new output since the last yield, or the final completion or termination result for that cell.
- If the cell is still running, `wait` may yield again with the same `cell_id`.
- If the cell has already finished, `wait` returns the completed result and closes the cell.
+23
View File
@@ -3,6 +3,8 @@ use codex_app_server_protocol::AppInfo;
use serde::Deserialize;
use serde::Serialize;
const TUI_APP_SERVER_CLIENT_NAME: &str = "codex-tui";
#[derive(Clone, Copy, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub(crate) enum DiscoverableToolType {
@@ -69,6 +71,13 @@ impl DiscoverableTool {
Self::Plugin(plugin) => plugin.description.as_deref(),
}
}
pub(crate) fn install_url(&self) -> Option<&str> {
match self {
Self::Connector(connector) => connector.install_url.as_deref(),
Self::Plugin(_) => None,
}
}
}
impl From<AppInfo> for DiscoverableTool {
@@ -83,6 +92,20 @@ impl From<DiscoverablePluginInfo> for DiscoverableTool {
}
}
pub(crate) fn filter_tool_suggest_discoverable_tools_for_client(
discoverable_tools: Vec<DiscoverableTool>,
app_server_client_name: Option<&str>,
) -> Vec<DiscoverableTool> {
if app_server_client_name != Some(TUI_APP_SERVER_CLIENT_NAME) {
return discoverable_tools;
}
discoverable_tools
.into_iter()
.filter(|tool| !matches!(tool, DiscoverableTool::Plugin(_)))
.collect()
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct DiscoverablePluginInfo {
pub(crate) id: String,
@@ -23,6 +23,7 @@ use crate::tools::context::ToolPayload;
use crate::tools::discoverable::DiscoverableTool;
use crate::tools::discoverable::DiscoverableToolAction;
use crate::tools::discoverable::DiscoverableToolType;
use crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client;
use crate::tools::handlers::parse_arguments;
use crate::tools::registry::ToolHandler;
use crate::tools::registry::ToolKind;
@@ -59,7 +60,8 @@ struct ToolSuggestMeta<'a> {
suggest_reason: &'a str,
tool_id: &'a str,
tool_name: &'a str,
install_url: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
install_url: Option<&'a str>,
}
#[async_trait]
@@ -95,15 +97,16 @@ impl ToolHandler for ToolSuggestHandler {
"suggest_reason must not be empty".to_string(),
));
}
if args.tool_type == DiscoverableToolType::Plugin {
return Err(FunctionCallError::RespondToModel(
"plugin tool suggestions are not currently available".to_string(),
));
}
if args.action_type != DiscoverableToolAction::Install {
return Err(FunctionCallError::RespondToModel(
"connector tool suggestions currently support only action_type=\"install\""
.to_string(),
"tool suggestions currently support only action_type=\"install\"".to_string(),
));
}
if args.tool_type == DiscoverableToolType::Plugin
&& turn.app_server_client_name.as_deref() == Some("codex-tui")
{
return Err(FunctionCallError::RespondToModel(
"plugin tool suggestions are not available in codex-tui yet".to_string(),
));
}
@@ -121,11 +124,11 @@ impl ToolHandler for ToolSuggestHandler {
&accessible_connectors,
)
.await
.map(|connectors| {
connectors
.into_iter()
.map(DiscoverableTool::from)
.collect::<Vec<_>>()
.map(|discoverable_tools| {
filter_tool_suggest_discoverable_tools_for_client(
discoverable_tools,
turn.app_server_client_name.as_deref(),
)
})
.map_err(|err| {
FunctionCallError::RespondToModel(format!(
@@ -133,14 +136,9 @@ impl ToolHandler for ToolSuggestHandler {
))
})?;
let connector = discoverable_tools
let tool = discoverable_tools
.into_iter()
.find_map(|tool| match tool {
DiscoverableTool::Connector(connector) if connector.id == args.tool_id => {
Some(*connector)
}
DiscoverableTool::Connector(_) | DiscoverableTool::Plugin(_) => None,
})
.find(|tool| tool.tool_type() == args.tool_type && tool.id() == args.tool_id)
.ok_or_else(|| {
FunctionCallError::RespondToModel(format!(
"tool_id must match one of the discoverable tools exposed by {TOOL_SUGGEST_TOOL_NAME}"
@@ -153,7 +151,7 @@ impl ToolHandler for ToolSuggestHandler {
turn.sub_id.clone(),
&args,
suggest_reason,
&connector,
&tool,
);
let response = session
.request_mcp_server_elicitation(turn.as_ref(), request_id, params)
@@ -163,37 +161,12 @@ impl ToolHandler for ToolSuggestHandler {
.is_some_and(|response| response.action == ElicitationAction::Accept);
let completed = if user_confirmed {
let manager = session.services.mcp_connection_manager.read().await;
match manager.hard_refresh_codex_apps_tools_cache().await {
Ok(mcp_tools) => {
let accessible_connectors = connectors::with_app_enabled_state(
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
&turn.config,
);
connectors::refresh_accessible_connectors_cache_from_mcp_tools(
&turn.config,
auth.as_ref(),
&mcp_tools,
);
verified_connector_suggestion_completed(
args.action_type,
connector.id.as_str(),
&accessible_connectors,
)
}
Err(err) => {
warn!(
"failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}",
connector.id
);
false
}
}
verify_tool_suggestion_completed(&session, &turn, &tool, auth.as_ref()).await
} else {
false
};
if completed {
if completed && let DiscoverableTool::Connector(connector) = &tool {
session
.merge_connector_selection(HashSet::from([connector.id.clone()]))
.await;
@@ -204,8 +177,8 @@ impl ToolHandler for ToolSuggestHandler {
user_confirmed,
tool_type: args.tool_type,
action_type: args.action_type,
tool_id: connector.id,
tool_name: connector.name,
tool_id: tool.id().to_string(),
tool_name: tool.name().to_string(),
suggest_reason: suggest_reason.to_string(),
})
.map_err(|err| {
@@ -223,18 +196,11 @@ fn build_tool_suggestion_elicitation_request(
turn_id: String,
args: &ToolSuggestArgs,
suggest_reason: &str,
connector: &AppInfo,
tool: &DiscoverableTool,
) -> McpServerElicitationRequestParams {
let tool_name = connector.name.clone();
let install_url = connector
.install_url
.clone()
.unwrap_or_else(|| connectors::connector_install_url(&tool_name, &connector.id));
let message = format!(
"{tool_name} could help with this request.\n\n{suggest_reason}\n\nOpen ChatGPT to {} it, then confirm here if you finish.",
args.action_type.as_str()
);
let tool_name = tool.name().to_string();
let install_url = tool.install_url().map(ToString::to_string);
let message = suggest_reason.to_string();
McpServerElicitationRequestParams {
thread_id,
@@ -245,9 +211,9 @@ fn build_tool_suggestion_elicitation_request(
args.tool_type,
args.action_type,
suggest_reason,
connector.id.as_str(),
tool.id(),
tool_name.as_str(),
install_url.as_str(),
install_url.as_deref(),
))),
message,
requested_schema: McpElicitationSchema {
@@ -266,7 +232,7 @@ fn build_tool_suggestion_meta<'a>(
suggest_reason: &'a str,
tool_id: &'a str,
tool_name: &'a str,
install_url: &'a str,
install_url: Option<&'a str>,
) -> ToolSuggestMeta<'a> {
ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
@@ -279,18 +245,74 @@ fn build_tool_suggestion_meta<'a>(
}
}
async fn verify_tool_suggestion_completed(
session: &crate::codex::Session,
turn: &crate::codex::TurnContext,
tool: &DiscoverableTool,
auth: Option<&crate::CodexAuth>,
) -> bool {
match tool {
DiscoverableTool::Connector(connector) => {
let manager = session.services.mcp_connection_manager.read().await;
match manager.hard_refresh_codex_apps_tools_cache().await {
Ok(mcp_tools) => {
let accessible_connectors = connectors::with_app_enabled_state(
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
&turn.config,
);
connectors::refresh_accessible_connectors_cache_from_mcp_tools(
&turn.config,
auth,
&mcp_tools,
);
verified_connector_suggestion_completed(
connector.id.as_str(),
&accessible_connectors,
)
}
Err(err) => {
warn!(
"failed to refresh codex apps tools cache after tool suggestion for {}: {err:#}",
connector.id
);
false
}
}
}
DiscoverableTool::Plugin(plugin) => {
session.reload_user_config_layer().await;
let config = session.get_config().await;
verified_plugin_suggestion_completed(
plugin.id.as_str(),
config.as_ref(),
session.services.plugins_manager.as_ref(),
)
}
}
}
fn verified_connector_suggestion_completed(
action_type: DiscoverableToolAction,
tool_id: &str,
accessible_connectors: &[AppInfo],
) -> bool {
accessible_connectors
.iter()
.find(|connector| connector.id == tool_id)
.is_some_and(|connector| match action_type {
DiscoverableToolAction::Install => connector.is_accessible,
DiscoverableToolAction::Enable => connector.is_accessible && connector.is_enabled,
})
.is_some_and(|connector| connector.is_accessible)
}
fn verified_plugin_suggestion_completed(
tool_id: &str,
config: &crate::config::Config,
plugins_manager: &crate::plugins::PluginsManager,
) -> bool {
plugins_manager
.list_marketplaces_for_config(config, &[])
.ok()
.into_iter()
.flatten()
.flat_map(|marketplace| marketplace.plugins.into_iter())
.any(|plugin| plugin.id == tool_id && plugin.installed)
}
#[cfg(test)]
@@ -1,5 +1,18 @@
use super::*;
use crate::plugins::PluginInstallRequest;
use crate::plugins::PluginsManager;
use crate::plugins::test_support::load_plugins_config;
use crate::plugins::test_support::write_curated_plugin_sha;
use crate::plugins::test_support::write_openai_curated_marketplace;
use crate::plugins::test_support::write_plugins_feature_config;
use crate::tools::discoverable::DiscoverablePluginInfo;
use crate::tools::discoverable::filter_tool_suggest_discoverable_tools_for_client;
use codex_app_server_protocol::AppInfo;
use codex_utils_absolute_path::AbsolutePathBuf;
use pretty_assertions::assert_eq;
use serde_json::json;
use std::collections::BTreeMap;
use tempfile::tempdir;
#[test]
fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
@@ -9,7 +22,7 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
suggest_reason: "Plan and reference events from your calendar".to_string(),
};
let connector = AppInfo {
let connector = DiscoverableTool::Connector(Box::new(AppInfo {
id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
name: "Google Calendar".to_string(),
description: Some("Plan events and schedules.".to_string()),
@@ -26,7 +39,7 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
};
}));
let request = build_tool_suggestion_elicitation_request(
"thread-1".to_string(),
@@ -37,31 +50,86 @@ fn build_tool_suggestion_elicitation_request_uses_expected_shape() {
);
assert_eq!(
request,
McpServerElicitationRequestParams {
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
request: McpServerElicitationRequest::Form {
meta: Some(json!(ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
tool_type: DiscoverableToolType::Connector,
suggest_type: DiscoverableToolAction::Install,
suggest_reason: "Plan and reference events from your calendar",
tool_id: "connector_2128aebfecb84f64a069897515042a44",
tool_name: "Google Calendar",
install_url: "https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44",
})),
message: "Google Calendar could help with this request.\n\nPlan and reference events from your calendar\n\nOpen ChatGPT to install it, then confirm here if you finish.".to_string(),
requested_schema: McpElicitationSchema {
schema_uri: None,
type_: McpElicitationObjectType::Object,
properties: BTreeMap::new(),
required: None,
},
request,
McpServerElicitationRequestParams {
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
request: McpServerElicitationRequest::Form {
meta: Some(json!(ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
tool_type: DiscoverableToolType::Connector,
suggest_type: DiscoverableToolAction::Install,
suggest_reason: "Plan and reference events from your calendar",
tool_id: "connector_2128aebfecb84f64a069897515042a44",
tool_name: "Google Calendar",
install_url: Some(
"https://chatgpt.com/apps/google-calendar/connector_2128aebfecb84f64a069897515042a44"
),
})),
message: "Plan and reference events from your calendar".to_string(),
requested_schema: McpElicitationSchema {
schema_uri: None,
type_: McpElicitationObjectType::Object,
properties: BTreeMap::new(),
required: None,
},
}
);
},
}
);
}
#[test]
fn build_tool_suggestion_elicitation_request_for_plugin_omits_install_url() {
let args = ToolSuggestArgs {
tool_type: DiscoverableToolType::Plugin,
action_type: DiscoverableToolAction::Install,
tool_id: "sample@openai-curated".to_string(),
suggest_reason: "Use the sample plugin's skills and MCP server".to_string(),
};
let plugin = DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
id: "sample@openai-curated".to_string(),
name: "Sample Plugin".to_string(),
description: Some("Includes skills, MCP servers, and apps.".to_string()),
has_skills: true,
mcp_server_names: vec!["sample-docs".to_string()],
app_connector_ids: vec!["connector_calendar".to_string()],
}));
let request = build_tool_suggestion_elicitation_request(
"thread-1".to_string(),
"turn-1".to_string(),
&args,
"Use the sample plugin's skills and MCP server",
&plugin,
);
assert_eq!(
request,
McpServerElicitationRequestParams {
thread_id: "thread-1".to_string(),
turn_id: Some("turn-1".to_string()),
server_name: CODEX_APPS_MCP_SERVER_NAME.to_string(),
request: McpServerElicitationRequest::Form {
meta: Some(json!(ToolSuggestMeta {
codex_approval_kind: TOOL_SUGGEST_APPROVAL_KIND_VALUE,
tool_type: DiscoverableToolType::Plugin,
suggest_type: DiscoverableToolAction::Install,
suggest_reason: "Use the sample plugin's skills and MCP server",
tool_id: "sample@openai-curated",
tool_name: "Sample Plugin",
install_url: None,
})),
message: "Use the sample plugin's skills and MCP server".to_string(),
requested_schema: McpElicitationSchema {
schema_uri: None,
type_: McpElicitationObjectType::Object,
properties: BTreeMap::new(),
required: None,
},
},
}
);
}
#[test]
@@ -72,7 +140,7 @@ fn build_tool_suggestion_meta_uses_expected_shape() {
"Find and reference emails from your inbox",
"connector_68df038e0ba48191908c8434991bbac2",
"Gmail",
"https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2",
Some("https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2"),
);
assert_eq!(
@@ -84,13 +152,63 @@ fn build_tool_suggestion_meta_uses_expected_shape() {
suggest_reason: "Find and reference emails from your inbox",
tool_id: "connector_68df038e0ba48191908c8434991bbac2",
tool_name: "Gmail",
install_url: "https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2",
install_url: Some(
"https://chatgpt.com/apps/gmail/connector_68df038e0ba48191908c8434991bbac2"
),
}
);
}
#[test]
fn verified_connector_suggestion_completed_requires_installed_connector() {
fn filter_tool_suggest_discoverable_tools_for_codex_tui_omits_plugins() {
let discoverable_tools = vec![
DiscoverableTool::Connector(Box::new(AppInfo {
id: "connector_google_calendar".to_string(),
name: "Google Calendar".to_string(),
description: Some("Plan events and schedules.".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/google-calendar".to_string()),
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
})),
DiscoverableTool::Plugin(Box::new(DiscoverablePluginInfo {
id: "slack@openai-curated".to_string(),
name: "Slack".to_string(),
description: Some("Search Slack messages".to_string()),
has_skills: true,
mcp_server_names: vec!["slack".to_string()],
app_connector_ids: vec!["connector_slack".to_string()],
})),
];
assert_eq!(
filter_tool_suggest_discoverable_tools_for_client(discoverable_tools, Some("codex-tui"),),
vec![DiscoverableTool::Connector(Box::new(AppInfo {
id: "connector_google_calendar".to_string(),
name: "Google Calendar".to_string(),
description: Some("Plan events and schedules.".to_string()),
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: Some("https://example.test/google-calendar".to_string()),
is_accessible: false,
is_enabled: true,
plugin_display_names: Vec::new(),
}))]
);
}
#[test]
fn verified_connector_suggestion_completed_requires_accessible_connector() {
let accessible_connectors = vec![AppInfo {
id: "calendar".to_string(),
name: "Google Calendar".to_string(),
@@ -103,65 +221,52 @@ fn verified_connector_suggestion_completed_requires_installed_connector() {
labels: None,
install_url: None,
is_accessible: true,
is_enabled: true,
is_enabled: false,
plugin_display_names: Vec::new(),
}];
assert!(verified_connector_suggestion_completed(
DiscoverableToolAction::Install,
"calendar",
&accessible_connectors,
));
assert!(!verified_connector_suggestion_completed(
DiscoverableToolAction::Install,
"gmail",
&accessible_connectors,
));
}
#[test]
fn verified_connector_suggestion_completed_requires_enabled_connector_for_enable() {
let accessible_connectors = vec![
AppInfo {
id: "calendar".to_string(),
name: "Google Calendar".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: true,
is_enabled: false,
plugin_display_names: Vec::new(),
},
AppInfo {
id: "gmail".to_string(),
name: "Gmail".to_string(),
description: None,
logo_url: None,
logo_url_dark: None,
distribution_channel: None,
branding: None,
app_metadata: None,
labels: None,
install_url: None,
is_accessible: true,
is_enabled: true,
plugin_display_names: Vec::new(),
},
];
#[tokio::test]
async fn verified_plugin_suggestion_completed_requires_installed_plugin() {
let codex_home = tempdir().expect("tempdir should succeed");
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
write_openai_curated_marketplace(&curated_root, &["sample"]);
write_curated_plugin_sha(codex_home.path());
write_plugins_feature_config(codex_home.path());
assert!(!verified_connector_suggestion_completed(
DiscoverableToolAction::Enable,
"calendar",
&accessible_connectors,
let config = load_plugins_config(codex_home.path()).await;
let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf());
assert!(!verified_plugin_suggestion_completed(
"sample@openai-curated",
&config,
&plugins_manager,
));
assert!(verified_connector_suggestion_completed(
DiscoverableToolAction::Enable,
"gmail",
&accessible_connectors,
plugins_manager
.install_plugin(PluginInstallRequest {
plugin_name: "sample".to_string(),
marketplace_path: AbsolutePathBuf::try_from(
curated_root.join(".agents/plugins/marketplace.json"),
)
.expect("marketplace path"),
})
.await
.expect("plugin should install");
let refreshed_config = load_plugins_config(codex_home.path()).await;
assert!(verified_plugin_suggestion_completed(
"sample@openai-curated",
&refreshed_config,
&plugins_manager,
));
}
+3 -3
View File
@@ -772,7 +772,7 @@ fn create_write_stdin_tool() -> ToolSpec {
})
}
fn create_exec_wait_tool() -> ToolSpec {
fn create_wait_tool() -> ToolSpec {
let properties = BTreeMap::from([
(
"cell_id".to_string(),
@@ -1827,7 +1827,7 @@ fn format_discoverable_tools(discoverable_tools: &[DiscoverableTool]) -> String
});
let default_action = match tool.tool_type() {
DiscoverableToolType::Connector => DiscoverableToolAction::Install,
DiscoverableToolType::Plugin => DiscoverableToolAction::Enable,
DiscoverableToolType::Plugin => DiscoverableToolAction::Install,
};
format!(
"- {} (id: `{}`, type: {}, action: {}): {}",
@@ -2597,7 +2597,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
builder.register_handler(PUBLIC_TOOL_NAME, code_mode_handler);
push_tool_spec(
&mut builder,
create_exec_wait_tool(),
create_wait_tool(),
/*supports_parallel_tool_calls*/ false,
config.code_mode_enabled,
);
+5 -4
View File
@@ -2097,7 +2097,8 @@ fn tool_suggest_description_lists_discoverable_tools() {
assert!(description.contains("Sample Plugin"));
assert!(description.contains("Plan events and schedules."));
assert!(description.contains("Find and summarize email threads."));
assert!(description.contains("id: `sample@test`, type: plugin, action: enable"));
assert!(description.contains("id: `sample@test`, type: plugin, action: install"));
assert!(description.contains("`action_type`: `install` or `enable`"));
assert!(
description.contains("skills; MCP servers: sample-docs; app connectors: connector_sample")
);
@@ -2692,7 +2693,7 @@ fn code_mode_only_restricts_model_tools_to_exec_tools() {
"gpt-5.1-codex",
&features,
Some(WebSearchMode::Live),
&["exec", "exec_wait"],
&["exec", "wait"],
);
}
@@ -2723,7 +2724,7 @@ fn code_mode_only_exec_description_includes_full_nested_tool_details() {
assert!(!description.contains("Enabled nested tools:"));
assert!(!description.contains("Nested tool reference:"));
assert!(description.starts_with(
"Use `exec/exec_wait` tool to run all other tools, do not attempt to use any other tools directly"
"Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"
));
assert!(description.contains("### `update_plan` (`update_plan`)"));
assert!(description.contains("### `view_image` (`view_image`)"));
@@ -2753,7 +2754,7 @@ fn code_mode_exec_description_omits_nested_tool_details_when_not_code_mode_only(
};
assert!(!description.starts_with(
"Use `exec/exec_wait` tool to run all other tools, do not attempt to use any other tools directly"
"Use `exec/wait` tool to run all other tools, do not attempt to use any other tools directly"
));
assert!(!description.contains("### `update_plan` (`update_plan`)"));
assert!(!description.contains("### `view_image` (`view_image`)"));
+94 -34
View File
@@ -7,6 +7,7 @@ use rand::Rng;
use tracing::debug;
use tracing::error;
use crate::auth_env_telemetry::AuthEnvTelemetry;
use crate::parse_command::shlex_join;
const INITIAL_DELAY_MS: u64 = 200;
@@ -54,6 +55,23 @@ pub(crate) struct FeedbackRequestTags<'a> {
pub auth_recovery_followup_status: Option<u16>,
}
struct FeedbackRequestSnapshot<'a> {
endpoint: &'a str,
auth_header_attached: bool,
auth_header_name: &'a str,
auth_mode: &'a str,
auth_retry_after_unauthorized: String,
auth_recovery_mode: &'a str,
auth_recovery_phase: &'a str,
auth_connection_reused: String,
auth_request_id: &'a str,
auth_cf_ray: &'a str,
auth_error: &'a str,
auth_error_code: &'a str,
auth_recovery_followup_success: String,
auth_recovery_followup_status: String,
}
struct Auth401FeedbackSnapshot<'a> {
request_id: &'a str,
cf_ray: &'a str,
@@ -77,42 +95,84 @@ impl<'a> Auth401FeedbackSnapshot<'a> {
}
}
impl<'a> FeedbackRequestSnapshot<'a> {
fn from_tags(tags: &'a FeedbackRequestTags<'a>) -> Self {
Self {
endpoint: tags.endpoint,
auth_header_attached: tags.auth_header_attached,
auth_header_name: tags.auth_header_name.unwrap_or(""),
auth_mode: tags.auth_mode.unwrap_or(""),
auth_retry_after_unauthorized: tags
.auth_retry_after_unauthorized
.map_or_else(String::new, |value| value.to_string()),
auth_recovery_mode: tags.auth_recovery_mode.unwrap_or(""),
auth_recovery_phase: tags.auth_recovery_phase.unwrap_or(""),
auth_connection_reused: tags
.auth_connection_reused
.map_or_else(String::new, |value| value.to_string()),
auth_request_id: tags.auth_request_id.unwrap_or(""),
auth_cf_ray: tags.auth_cf_ray.unwrap_or(""),
auth_error: tags.auth_error.unwrap_or(""),
auth_error_code: tags.auth_error_code.unwrap_or(""),
auth_recovery_followup_success: tags
.auth_recovery_followup_success
.map_or_else(String::new, |value| value.to_string()),
auth_recovery_followup_status: tags
.auth_recovery_followup_status
.map_or_else(String::new, |value| value.to_string()),
}
}
}
#[cfg(test)]
pub(crate) fn emit_feedback_request_tags(tags: &FeedbackRequestTags<'_>) {
let auth_header_name = tags.auth_header_name.unwrap_or("");
let auth_mode = tags.auth_mode.unwrap_or("");
let auth_retry_after_unauthorized = tags
.auth_retry_after_unauthorized
.map_or_else(String::new, |value| value.to_string());
let auth_recovery_mode = tags.auth_recovery_mode.unwrap_or("");
let auth_recovery_phase = tags.auth_recovery_phase.unwrap_or("");
let auth_connection_reused = tags
.auth_connection_reused
.map_or_else(String::new, |value| value.to_string());
let auth_request_id = tags.auth_request_id.unwrap_or("");
let auth_cf_ray = tags.auth_cf_ray.unwrap_or("");
let auth_error = tags.auth_error.unwrap_or("");
let auth_error_code = tags.auth_error_code.unwrap_or("");
let auth_recovery_followup_success = tags
.auth_recovery_followup_success
.map_or_else(String::new, |value| value.to_string());
let auth_recovery_followup_status = tags
.auth_recovery_followup_status
.map_or_else(String::new, |value| value.to_string());
let snapshot = FeedbackRequestSnapshot::from_tags(tags);
feedback_tags!(
endpoint = tags.endpoint,
auth_header_attached = tags.auth_header_attached,
auth_header_name = auth_header_name,
auth_mode = auth_mode,
auth_retry_after_unauthorized = auth_retry_after_unauthorized,
auth_recovery_mode = auth_recovery_mode,
auth_recovery_phase = auth_recovery_phase,
auth_connection_reused = auth_connection_reused,
auth_request_id = auth_request_id,
auth_cf_ray = auth_cf_ray,
auth_error = auth_error,
auth_error_code = auth_error_code,
auth_recovery_followup_success = auth_recovery_followup_success,
auth_recovery_followup_status = auth_recovery_followup_status
endpoint = snapshot.endpoint,
auth_header_attached = snapshot.auth_header_attached,
auth_header_name = snapshot.auth_header_name,
auth_mode = snapshot.auth_mode,
auth_retry_after_unauthorized = snapshot.auth_retry_after_unauthorized,
auth_recovery_mode = snapshot.auth_recovery_mode,
auth_recovery_phase = snapshot.auth_recovery_phase,
auth_connection_reused = snapshot.auth_connection_reused,
auth_request_id = snapshot.auth_request_id,
auth_cf_ray = snapshot.auth_cf_ray,
auth_error = snapshot.auth_error,
auth_error_code = snapshot.auth_error_code,
auth_recovery_followup_success = snapshot.auth_recovery_followup_success,
auth_recovery_followup_status = snapshot.auth_recovery_followup_status
);
}
pub(crate) fn emit_feedback_request_tags_with_auth_env(
tags: &FeedbackRequestTags<'_>,
auth_env: &AuthEnvTelemetry,
) {
let snapshot = FeedbackRequestSnapshot::from_tags(tags);
feedback_tags!(
endpoint = snapshot.endpoint,
auth_header_attached = snapshot.auth_header_attached,
auth_header_name = snapshot.auth_header_name,
auth_mode = snapshot.auth_mode,
auth_retry_after_unauthorized = snapshot.auth_retry_after_unauthorized,
auth_recovery_mode = snapshot.auth_recovery_mode,
auth_recovery_phase = snapshot.auth_recovery_phase,
auth_connection_reused = snapshot.auth_connection_reused,
auth_request_id = snapshot.auth_request_id,
auth_cf_ray = snapshot.auth_cf_ray,
auth_error = snapshot.auth_error,
auth_error_code = snapshot.auth_error_code,
auth_recovery_followup_success = snapshot.auth_recovery_followup_success,
auth_recovery_followup_status = snapshot.auth_recovery_followup_status,
auth_env_openai_api_key_present = auth_env.openai_api_key_env_present,
auth_env_codex_api_key_present = auth_env.codex_api_key_env_present,
auth_env_codex_api_key_enabled = auth_env.codex_api_key_env_enabled,
auth_env_provider_key_name = auth_env.provider_env_key_name.as_deref().unwrap_or(""),
auth_env_provider_key_present = auth_env
.provider_env_key_present
.map_or_else(String::new, |value| value.to_string()),
auth_env_refresh_token_url_override_present = auth_env.refresh_token_url_override_present
);
}
+148 -38
View File
@@ -1,4 +1,5 @@
use super::*;
use crate::auth_env_telemetry::AuthEnvTelemetry;
use std::collections::BTreeMap;
use std::sync::Arc;
use std::sync::Mutex;
@@ -68,6 +69,7 @@ impl Visit for TagCollectorVisitor {
#[derive(Clone)]
struct TagCollectorLayer {
tags: Arc<Mutex<BTreeMap<String, String>>>,
event_count: Arc<Mutex<usize>>,
}
impl<S> Layer<S> for TagCollectorLayer
@@ -81,32 +83,49 @@ where
let mut visitor = TagCollectorVisitor::default();
event.record(&mut visitor);
self.tags.lock().unwrap().extend(visitor.tags);
*self.event_count.lock().unwrap() += 1;
}
}
#[test]
fn emit_feedback_request_tags_records_sentry_feedback_fields() {
let tags = Arc::new(Mutex::new(BTreeMap::new()));
let event_count = Arc::new(Mutex::new(0));
let _guard = tracing_subscriber::registry()
.with(TagCollectorLayer { tags: tags.clone() })
.with(TagCollectorLayer {
tags: tags.clone(),
event_count: event_count.clone(),
})
.set_default();
emit_feedback_request_tags(&FeedbackRequestTags {
endpoint: "/responses",
auth_header_attached: true,
auth_header_name: Some("authorization"),
auth_mode: Some("chatgpt"),
auth_retry_after_unauthorized: Some(false),
auth_recovery_mode: Some("managed"),
auth_recovery_phase: Some("refresh_token"),
auth_connection_reused: Some(true),
auth_request_id: Some("req-123"),
auth_cf_ray: Some("ray-123"),
auth_error: Some("missing_authorization_header"),
auth_error_code: Some("token_expired"),
auth_recovery_followup_success: Some(true),
auth_recovery_followup_status: Some(200),
});
let auth_env = AuthEnvTelemetry {
openai_api_key_env_present: true,
codex_api_key_env_present: false,
codex_api_key_env_enabled: true,
provider_env_key_name: Some("configured".to_string()),
provider_env_key_present: Some(true),
refresh_token_url_override_present: true,
};
emit_feedback_request_tags_with_auth_env(
&FeedbackRequestTags {
endpoint: "/responses",
auth_header_attached: true,
auth_header_name: Some("authorization"),
auth_mode: Some("chatgpt"),
auth_retry_after_unauthorized: Some(false),
auth_recovery_mode: Some("managed"),
auth_recovery_phase: Some("refresh_token"),
auth_connection_reused: Some(true),
auth_request_id: Some("req-123"),
auth_cf_ray: Some("ray-123"),
auth_error: Some("missing_authorization_header"),
auth_error_code: Some("token_expired"),
auth_recovery_followup_success: Some(true),
auth_recovery_followup_status: Some(200),
},
&auth_env,
);
let tags = tags.lock().unwrap().clone();
assert_eq!(
@@ -121,6 +140,35 @@ fn emit_feedback_request_tags_records_sentry_feedback_fields() {
tags.get("auth_header_name").map(String::as_str),
Some("\"authorization\"")
);
assert_eq!(
tags.get("auth_env_openai_api_key_present")
.map(String::as_str),
Some("true")
);
assert_eq!(
tags.get("auth_env_codex_api_key_present")
.map(String::as_str),
Some("false")
);
assert_eq!(
tags.get("auth_env_codex_api_key_enabled")
.map(String::as_str),
Some("true")
);
assert_eq!(
tags.get("auth_env_provider_key_name").map(String::as_str),
Some("\"configured\"")
);
assert_eq!(
tags.get("auth_env_provider_key_present")
.map(String::as_str),
Some("\"true\"")
);
assert_eq!(
tags.get("auth_env_refresh_token_url_override_present")
.map(String::as_str),
Some("true")
);
assert_eq!(
tags.get("auth_request_id").map(String::as_str),
Some("\"req-123\"")
@@ -139,13 +187,18 @@ fn emit_feedback_request_tags_records_sentry_feedback_fields() {
.map(String::as_str),
Some("\"200\"")
);
assert_eq!(*event_count.lock().unwrap(), 1);
}
#[test]
fn emit_feedback_auth_recovery_tags_preserves_401_specific_fields() {
let tags = Arc::new(Mutex::new(BTreeMap::new()));
let event_count = Arc::new(Mutex::new(0));
let _guard = tracing_subscriber::registry()
.with(TagCollectorLayer { tags: tags.clone() })
.with(TagCollectorLayer {
tags: tags.clone(),
event_count: event_count.clone(),
})
.set_default();
emit_feedback_auth_recovery_tags(
@@ -175,13 +228,18 @@ fn emit_feedback_auth_recovery_tags_preserves_401_specific_fields() {
tags.get("auth_401_error_code").map(String::as_str),
Some("\"token_expired\"")
);
assert_eq!(*event_count.lock().unwrap(), 1);
}
#[test]
fn emit_feedback_auth_recovery_tags_clears_stale_401_fields() {
let tags = Arc::new(Mutex::new(BTreeMap::new()));
let event_count = Arc::new(Mutex::new(0));
let _guard = tracing_subscriber::registry()
.with(TagCollectorLayer { tags: tags.clone() })
.with(TagCollectorLayer {
tags: tags.clone(),
event_count: event_count.clone(),
})
.set_default();
emit_feedback_auth_recovery_tags(
@@ -217,13 +275,18 @@ fn emit_feedback_auth_recovery_tags_clears_stale_401_fields() {
tags.get("auth_401_error_code").map(String::as_str),
Some("\"\"")
);
assert_eq!(*event_count.lock().unwrap(), 2);
}
#[test]
fn emit_feedback_request_tags_preserves_latest_auth_fields_after_unauthorized() {
let tags = Arc::new(Mutex::new(BTreeMap::new()));
let event_count = Arc::new(Mutex::new(0));
let _guard = tracing_subscriber::registry()
.with(TagCollectorLayer { tags: tags.clone() })
.with(TagCollectorLayer {
tags: tags.clone(),
event_count: event_count.clone(),
})
.set_default();
emit_feedback_request_tags(&FeedbackRequestTags {
@@ -265,31 +328,48 @@ fn emit_feedback_request_tags_preserves_latest_auth_fields_after_unauthorized()
.map(String::as_str),
Some("\"false\"")
);
assert_eq!(*event_count.lock().unwrap(), 1);
}
#[test]
fn emit_feedback_request_tags_clears_stale_latest_auth_fields() {
fn emit_feedback_request_tags_preserves_auth_env_fields_for_legacy_emitters() {
let tags = Arc::new(Mutex::new(BTreeMap::new()));
let event_count = Arc::new(Mutex::new(0));
let _guard = tracing_subscriber::registry()
.with(TagCollectorLayer { tags: tags.clone() })
.with(TagCollectorLayer {
tags: tags.clone(),
event_count: event_count.clone(),
})
.set_default();
emit_feedback_request_tags(&FeedbackRequestTags {
endpoint: "/responses",
auth_header_attached: true,
auth_header_name: Some("authorization"),
auth_mode: Some("chatgpt"),
auth_retry_after_unauthorized: Some(false),
auth_recovery_mode: Some("managed"),
auth_recovery_phase: Some("refresh_token"),
auth_connection_reused: Some(true),
auth_request_id: Some("req-123"),
auth_cf_ray: Some("ray-123"),
auth_error: Some("missing_authorization_header"),
auth_error_code: Some("token_expired"),
auth_recovery_followup_success: Some(true),
auth_recovery_followup_status: Some(200),
});
let auth_env = AuthEnvTelemetry {
openai_api_key_env_present: true,
codex_api_key_env_present: true,
codex_api_key_env_enabled: true,
provider_env_key_name: Some("configured".to_string()),
provider_env_key_present: Some(true),
refresh_token_url_override_present: true,
};
emit_feedback_request_tags_with_auth_env(
&FeedbackRequestTags {
endpoint: "/responses",
auth_header_attached: true,
auth_header_name: Some("authorization"),
auth_mode: Some("chatgpt"),
auth_retry_after_unauthorized: Some(false),
auth_recovery_mode: Some("managed"),
auth_recovery_phase: Some("refresh_token"),
auth_connection_reused: Some(true),
auth_request_id: Some("req-123"),
auth_cf_ray: Some("ray-123"),
auth_error: Some("missing_authorization_header"),
auth_error_code: Some("token_expired"),
auth_recovery_followup_success: Some(true),
auth_recovery_followup_status: Some(200),
},
&auth_env,
);
emit_feedback_request_tags(&FeedbackRequestTags {
endpoint: "/responses",
auth_header_attached: true,
@@ -323,6 +403,35 @@ fn emit_feedback_request_tags_clears_stale_latest_auth_fields() {
tags.get("auth_error_code").map(String::as_str),
Some("\"\"")
);
assert_eq!(
tags.get("auth_env_openai_api_key_present")
.map(String::as_str),
Some("true")
);
assert_eq!(
tags.get("auth_env_codex_api_key_present")
.map(String::as_str),
Some("true")
);
assert_eq!(
tags.get("auth_env_codex_api_key_enabled")
.map(String::as_str),
Some("true")
);
assert_eq!(
tags.get("auth_env_provider_key_name").map(String::as_str),
Some("\"configured\"")
);
assert_eq!(
tags.get("auth_env_provider_key_present")
.map(String::as_str),
Some("\"true\"")
);
assert_eq!(
tags.get("auth_env_refresh_token_url_override_present")
.map(String::as_str),
Some("true")
);
assert_eq!(
tags.get("auth_recovery_followup_success")
.map(String::as_str),
@@ -333,6 +442,7 @@ fn emit_feedback_request_tags_clears_stale_latest_auth_fields() {
.map(String::as_str),
Some("\"\"")
);
assert_eq!(*event_count.lock().unwrap(), 2);
}
#[test]
+44 -3
View File
@@ -13,6 +13,8 @@ use codex_core::built_in_model_providers;
use codex_core::config::Config;
use codex_core::features::Feature;
use codex_core::models_manager::collaboration_mode_presets::CollaborationModesConfig;
use codex_core::shell::Shell;
use codex_core::shell::get_shell_by_model_provided_path;
use codex_protocol::config_types::ServiceTier;
use codex_protocol::openai_models::ModelsResponse;
use codex_protocol::protocol::AskForApproval;
@@ -64,6 +66,7 @@ pub struct TestCodexBuilder {
auth: CodexAuth,
pre_build_hooks: Vec<Box<PreBuildHook>>,
home: Option<Arc<TempDir>>,
user_shell_override: Option<Shell>,
}
impl TestCodexBuilder {
@@ -100,6 +103,19 @@ impl TestCodexBuilder {
self
}
pub fn with_user_shell(mut self, user_shell: Shell) -> Self {
self.user_shell_override = Some(user_shell);
self
}
pub fn with_windows_cmd_shell(self) -> Self {
if cfg!(windows) {
self.with_user_shell(get_shell_by_model_provided_path(&PathBuf::from("cmd.exe")))
} else {
self
}
}
pub async fn build(&mut self, server: &wiremock::MockServer) -> anyhow::Result<TestCodex> {
let home = match self.home.clone() {
Some(home) => home,
@@ -199,9 +215,23 @@ impl TestCodexBuilder {
)
};
let thread_manager = Arc::new(thread_manager);
let user_shell_override = self.user_shell_override.clone();
let new_conversation = match resume_from {
Some(path) => {
let new_conversation = match (resume_from, user_shell_override) {
(Some(path), Some(user_shell_override)) => {
let auth_manager = codex_core::test_support::auth_manager_from_auth(auth);
Box::pin(
codex_core::test_support::resume_thread_from_rollout_with_user_shell_override(
thread_manager.as_ref(),
config.clone(),
path,
auth_manager,
user_shell_override,
),
)
.await?
}
(Some(path), None) => {
let auth_manager = codex_core::test_support::auth_manager_from_auth(auth);
Box::pin(thread_manager.resume_thread_from_rollout(
config.clone(),
@@ -211,7 +241,17 @@ impl TestCodexBuilder {
))
.await?
}
None => Box::pin(thread_manager.start_thread(config.clone())).await?,
(None, Some(user_shell_override)) => {
Box::pin(
codex_core::test_support::start_thread_with_user_shell_override(
thread_manager.as_ref(),
config.clone(),
user_shell_override,
),
)
.await?
}
(None, None) => Box::pin(thread_manager.start_thread(config.clone())).await?,
};
Ok(TestCodex {
@@ -562,6 +602,7 @@ pub fn test_codex() -> TestCodexBuilder {
auth: CodexAuth::from_api_key("dummy"),
pre_build_hooks: vec![],
home: None,
user_shell_override: None,
}
}
+2 -2
View File
@@ -35,7 +35,7 @@ async fn websocket_test_codex_shell_chain() -> Result<()> {
]])
.await;
let mut builder = test_codex();
let mut builder = test_codex().with_windows_cmd_shell();
let test = builder.build_with_websocket_server(&server).await?;
test.submit_turn_with_policy(
@@ -183,7 +183,7 @@ async fn websocket_v2_test_codex_shell_chain() -> Result<()> {
]])
.await;
let mut builder = test_codex().with_config(|config| {
let mut builder = test_codex().with_windows_cmd_shell().with_config(|config| {
config
.features
.enable(Feature::ResponsesWebsocketsV2)
+19 -7
View File
@@ -1,6 +1,8 @@
#![allow(clippy::expect_used)]
use anyhow::Result;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_test_macros::large_stack_test;
use core_test_support::responses::ev_apply_patch_call;
use core_test_support::responses::ev_apply_patch_custom_tool_call;
@@ -740,7 +742,9 @@ async fn apply_patch_shell_command_heredoc_with_cd_updates_relative_workdir() ->
async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result<()> {
skip_if_no_network!(Ok(()));
let harness = apply_patch_harness_with(|builder| builder.with_model("gpt-5.1")).await?;
let harness =
apply_patch_harness_with(|builder| builder.with_model("gpt-5.1").with_windows_cmd_shell())
.await?;
let source_contents = "line1\nnaïve café\nline3\n";
let source_path = harness.path("source.txt");
@@ -786,9 +790,21 @@ async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result
match call_num {
0 => {
let command = if cfg!(windows) {
"Get-Content -Encoding utf8 source.txt"
// Encode the nested PowerShell script so `cmd.exe /c` does not leave the
// read command wrapped in quotes, and suppress progress records so the
// shell tool only returns the file contents back to apply_patch.
let script = "$ProgressPreference = 'SilentlyContinue'; [Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false); [System.IO.File]::ReadAllText('source.txt', [System.Text.UTF8Encoding]::new($false))";
let encoded = BASE64_STANDARD.encode(
script
.encode_utf16()
.flat_map(u16::to_le_bytes)
.collect::<Vec<u8>>(),
);
format!(
"powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand {encoded}"
)
} else {
"cat source.txt"
"cat source.txt".to_string()
};
let args = json!({
"command": command,
@@ -807,9 +823,7 @@ async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result
let body_json: serde_json::Value =
request.body_json().expect("request body should be json");
let read_output = function_call_output_text(&body_json, &self.read_call_id);
eprintln!("read_output: \n{read_output}");
let stdout = stdout_from_shell_output(&read_output);
eprintln!("stdout: \n{stdout}");
let patch_lines = stdout
.lines()
.map(|line| format!("+{line}"))
@@ -819,8 +833,6 @@ async fn apply_patch_cli_can_use_shell_command_output_as_patch_input() -> Result
"*** Begin Patch\n*** Add File: target.txt\n{patch_lines}\n*** End Patch"
);
eprintln!("patch: \n{patch}");
let body = sse(vec![
ev_response_created("resp-2"),
ev_apply_patch_custom_tool_call(&self.apply_call_id, &patch),
+12 -1
View File
@@ -122,7 +122,11 @@ impl ActionKind {
ActionKind::WriteFile { target, content } => {
let (path, _) = target.resolve_for_patch(test);
let _ = fs::remove_file(&path);
let command = format!("printf {content:?} > {path:?} && cat {path:?}");
let path_str = path.display().to_string();
let script = format!(
"from pathlib import Path; path = Path({path_str:?}); content = {content:?}; path.write_text(content, encoding='utf-8'); print(path.read_text(encoding='utf-8'), end='')",
);
let command = format!("python3 -c {script:?}");
let event = shell_event(call_id, &command, 5_000, sandbox_permissions)?;
Ok((event, Some(command)))
}
@@ -1611,6 +1615,9 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
.action
.prepare(&test, &server, call_id, scenario.sandbox_permissions)
.await?;
if let Some(command) = expected_command.as_deref() {
eprintln!("approval scenario {} command: {command}", scenario.name);
}
let _ = mount_sse_once(
&server,
@@ -1692,6 +1699,10 @@ async fn run_scenario(scenario: &ScenarioSpec) -> Result<()> {
let output_item = results_mock.single_request().function_call_output(call_id);
let result = parse_result(&output_item);
eprintln!(
"approval scenario {} result: exit_code={:?} stdout={:?}",
scenario.name, result.exit_code, result.stdout
);
scenario.expectation.verify(&test, &result)?;
Ok(())
+17 -17
View File
@@ -305,7 +305,7 @@ async fn code_mode_only_restricts_prompt_tools() -> Result<()> {
let first_body = resp_mock.single_request().body_json();
assert_eq!(
tool_names(&first_body),
vec!["exec".to_string(), "exec_wait".to_string()]
vec!["exec".to_string(), "wait".to_string()]
);
Ok(())
@@ -539,7 +539,7 @@ Error:\ boom\n
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_can_yield_and_resume_with_exec_wait() -> Result<()> {
async fn code_mode_can_yield_and_resume_with_wait() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
@@ -602,7 +602,7 @@ text("phase 3");
ev_response_created("resp-3"),
responses::ev_function_call(
"call-2",
"exec_wait",
"wait",
&serde_json::to_string(&serde_json::json!({
"cell_id": cell_id.clone(),
"yield_time_ms": 1_000,
@@ -646,7 +646,7 @@ text("phase 3");
ev_response_created("resp-5"),
responses::ev_function_call(
"call-3",
"exec_wait",
"wait",
&serde_json::to_string(&serde_json::json!({
"cell_id": cell_id.clone(),
"yield_time_ms": 1_000,
@@ -742,7 +742,7 @@ while (true) {}
ev_response_created("resp-3"),
responses::ev_function_call(
"call-2",
"exec_wait",
"wait",
&serde_json::to_string(&serde_json::json!({
"cell_id": cell_id.clone(),
"terminate": true,
@@ -869,7 +869,7 @@ text("session b done");
ev_response_created("resp-5"),
responses::ev_function_call(
"call-3",
"exec_wait",
"wait",
&serde_json::to_string(&serde_json::json!({
"cell_id": session_a_id.clone(),
"yield_time_ms": 1_000,
@@ -909,7 +909,7 @@ text("session b done");
ev_response_created("resp-7"),
responses::ev_function_call(
"call-4",
"exec_wait",
"wait",
&serde_json::to_string(&serde_json::json!({
"cell_id": session_b_id.clone(),
"yield_time_ms": 1_000,
@@ -947,7 +947,7 @@ text("session b done");
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_exec_wait_can_terminate_and_continue() -> Result<()> {
async fn code_mode_wait_can_terminate_and_continue() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
@@ -999,7 +999,7 @@ text("phase 2");
ev_response_created("resp-3"),
responses::ev_function_call(
"call-2",
"exec_wait",
"wait",
&serde_json::to_string(&serde_json::json!({
"cell_id": cell_id.clone(),
"terminate": true,
@@ -1073,7 +1073,7 @@ text("after terminate");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_exec_wait_returns_error_for_unknown_session() -> Result<()> {
async fn code_mode_wait_returns_error_for_unknown_session() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
@@ -1088,7 +1088,7 @@ async fn code_mode_exec_wait_returns_error_for_unknown_session() -> Result<()> {
ev_response_created("resp-1"),
responses::ev_function_call(
"call-1",
"exec_wait",
"wait",
&serde_json::to_string(&serde_json::json!({
"cell_id": "999999",
"yield_time_ms": 1_000,
@@ -1134,7 +1134,7 @@ async fn code_mode_exec_wait_returns_error_for_unknown_session() -> Result<()> {
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_exec_wait_terminate_returns_completed_session_if_it_finished_after_yield_control()
async fn code_mode_wait_terminate_returns_completed_session_if_it_finished_after_yield_control()
-> Result<()> {
skip_if_no_network!(Ok(()));
@@ -1229,7 +1229,7 @@ text("session b done");
ev_response_created("resp-5"),
responses::ev_function_call(
"call-3",
"exec_wait",
"wait",
&serde_json::to_string(&serde_json::json!({
"cell_id": session_b_id.clone(),
"yield_time_ms": 1_000,
@@ -1279,7 +1279,7 @@ text("session b done");
ev_response_created("resp-7"),
responses::ev_function_call(
"call-4",
"exec_wait",
"wait",
&serde_json::to_string(&serde_json::json!({
"cell_id": session_a_id.clone(),
"terminate": true,
@@ -1330,7 +1330,7 @@ text("session b done");
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_background_keeps_running_on_later_turn_without_exec_wait() -> Result<()> {
async fn code_mode_background_keeps_running_on_later_turn_without_wait() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
@@ -1423,7 +1423,7 @@ text("after yield");
#[cfg_attr(windows, ignore = "no exec_command on Windows")]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn code_mode_exec_wait_uses_its_own_max_tokens_budget() -> Result<()> {
async fn code_mode_wait_uses_its_own_max_tokens_budget() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
@@ -1476,7 +1476,7 @@ text("token one token two token three token four token five token six token seve
ev_response_created("resp-3"),
responses::ev_function_call(
"call-2",
"exec_wait",
"wait",
&serde_json::to_string(&serde_json::json!({
"cell_id": cell_id.clone(),
"yield_time_ms": 1_000,
@@ -13,6 +13,7 @@ use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::RealtimeAudioFrame;
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
use codex_protocol::protocol::RealtimeConversationVersion;
use codex_protocol::protocol::RealtimeEvent;
use codex_protocol::protocol::SessionSource;
use codex_protocol::user_input::UserInput;
@@ -159,6 +160,7 @@ async fn conversation_start_audio_text_close_round_trip() -> Result<()> {
.await
.unwrap_or_else(|err: ErrorEvent| panic!("conversation start failed: {err:?}"));
assert!(started.session_id.is_some());
assert_eq!(started.version, RealtimeConversationVersion::V1);
let session_updated = wait_for_event_match(&test.codex, |msg| match msg {
EventMsg::RealtimeConversationRealtime(RealtimeConversationRealtimeEvent {
@@ -870,8 +870,6 @@ impl EventProcessor for EventProcessorWithHumanOutput {
| EventMsg::McpListToolsResponse(_)
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::ListRemoteSkillsResponse(_)
| EventMsg::RemoteSkillDownloaded(_)
| EventMsg::RawResponseItem(_)
| EventMsg::UserMessage(_)
| EventMsg::EnteredReviewMode(_)
@@ -1032,8 +1030,6 @@ impl EventProcessorWithHumanOutput {
| EventMsg::McpListToolsResponse(_)
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::ListRemoteSkillsResponse(_)
| EventMsg::RemoteSkillDownloaded(_)
| EventMsg::RawResponseItem(_)
| EventMsg::UserMessage(_)
| EventMsg::EnteredReviewMode(_)
@@ -339,8 +339,6 @@ async fn run_codex_tool_session_inner(
| EventMsg::McpListToolsResponse(_)
| EventMsg::ListCustomPromptsResponse(_)
| EventMsg::ListSkillsResponse(_)
| EventMsg::ListRemoteSkillsResponse(_)
| EventMsg::RemoteSkillDownloaded(_)
| EventMsg::ExecCommandBegin(_)
| EventMsg::TerminalInteraction(_)
| EventMsg::ExecCommandOutputDelta(_)
@@ -62,10 +62,21 @@ const RESPONSES_API_ENGINE_SERVICE_TTFT_FIELD: &str = "engine_service_ttft_total
const RESPONSES_API_ENGINE_IAPI_TBT_FIELD: &str = "engine_iapi_tbt_across_engine_calls_ms";
const RESPONSES_API_ENGINE_SERVICE_TBT_FIELD: &str = "engine_service_tbt_across_engine_calls_ms";
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct AuthEnvTelemetryMetadata {
pub openai_api_key_env_present: bool,
pub codex_api_key_env_present: bool,
pub codex_api_key_env_enabled: bool,
pub provider_env_key_name: Option<String>,
pub provider_env_key_present: Option<bool>,
pub refresh_token_url_override_present: bool,
}
#[derive(Debug, Clone)]
pub struct SessionTelemetryMetadata {
pub(crate) conversation_id: ThreadId,
pub(crate) auth_mode: Option<String>,
pub(crate) auth_env: AuthEnvTelemetryMetadata,
pub(crate) account_id: Option<String>,
pub(crate) account_email: Option<String>,
pub(crate) originator: String,
@@ -86,6 +97,11 @@ pub struct SessionTelemetry {
}
impl SessionTelemetry {
pub fn with_auth_env(mut self, auth_env: AuthEnvTelemetryMetadata) -> Self {
self.metadata.auth_env = auth_env;
self
}
pub fn with_model(mut self, model: &str, slug: &str) -> Self {
self.metadata.model = model.to_owned();
self.metadata.slug = slug.to_owned();
@@ -255,6 +271,7 @@ impl SessionTelemetry {
metadata: SessionTelemetryMetadata {
conversation_id,
auth_mode: auth_mode.map(|m| m.to_string()),
auth_env: AuthEnvTelemetryMetadata::default(),
account_id,
account_email,
originator: sanitize_metric_tag_value(originator.as_str()),
@@ -309,6 +326,12 @@ impl SessionTelemetry {
common: {
event.name = "codex.conversation_starts",
provider_name = %provider_name,
auth.env_openai_api_key_present = self.metadata.auth_env.openai_api_key_env_present,
auth.env_codex_api_key_present = self.metadata.auth_env.codex_api_key_env_present,
auth.env_codex_api_key_enabled = self.metadata.auth_env.codex_api_key_env_enabled,
auth.env_provider_key_name = self.metadata.auth_env.provider_env_key_name.as_deref(),
auth.env_provider_key_present = self.metadata.auth_env.provider_env_key_present,
auth.env_refresh_token_url_override_present = self.metadata.auth_env.refresh_token_url_override_present,
reasoning_effort = reasoning_effort.map(|e| e.to_string()),
reasoning_summary = %reasoning_summary,
context_window = context_window,
@@ -407,6 +430,12 @@ impl SessionTelemetry {
auth.recovery_mode = recovery_mode,
auth.recovery_phase = recovery_phase,
endpoint = endpoint,
auth.env_openai_api_key_present = self.metadata.auth_env.openai_api_key_env_present,
auth.env_codex_api_key_present = self.metadata.auth_env.codex_api_key_env_present,
auth.env_codex_api_key_enabled = self.metadata.auth_env.codex_api_key_env_enabled,
auth.env_provider_key_name = self.metadata.auth_env.provider_env_key_name.as_deref(),
auth.env_provider_key_present = self.metadata.auth_env.provider_env_key_present,
auth.env_refresh_token_url_override_present = self.metadata.auth_env.refresh_token_url_override_present,
auth.request_id = request_id,
auth.cf_ray = cf_ray,
auth.error = auth_error,
@@ -454,6 +483,12 @@ impl SessionTelemetry {
auth.recovery_mode = recovery_mode,
auth.recovery_phase = recovery_phase,
endpoint = endpoint,
auth.env_openai_api_key_present = self.metadata.auth_env.openai_api_key_env_present,
auth.env_codex_api_key_present = self.metadata.auth_env.codex_api_key_env_present,
auth.env_codex_api_key_enabled = self.metadata.auth_env.codex_api_key_env_enabled,
auth.env_provider_key_name = self.metadata.auth_env.provider_env_key_name.as_deref(),
auth.env_provider_key_present = self.metadata.auth_env.provider_env_key_present,
auth.env_refresh_token_url_override_present = self.metadata.auth_env.refresh_token_url_override_present,
auth.connection_reused = connection_reused,
auth.request_id = request_id,
auth.cf_ray = cf_ray,
@@ -489,6 +524,12 @@ impl SessionTelemetry {
duration_ms = %duration.as_millis(),
success = success_str,
error.message = error,
auth.env_openai_api_key_present = self.metadata.auth_env.openai_api_key_env_present,
auth.env_codex_api_key_present = self.metadata.auth_env.codex_api_key_env_present,
auth.env_codex_api_key_enabled = self.metadata.auth_env.codex_api_key_env_enabled,
auth.env_provider_key_name = self.metadata.auth_env.provider_env_key_name.as_deref(),
auth.env_provider_key_present = self.metadata.auth_env.provider_env_key_present,
auth.env_refresh_token_url_override_present = self.metadata.auth_env.refresh_token_url_override_present,
auth.connection_reused = connection_reused,
},
log: {},
+1
View File
@@ -12,6 +12,7 @@ use crate::metrics::Result as MetricsResult;
use serde::Serialize;
use strum_macros::Display;
pub use crate::events::session_telemetry::AuthEnvTelemetryMetadata;
pub use crate::events::session_telemetry::SessionTelemetry;
pub use crate::events::session_telemetry::SessionTelemetryMetadata;
pub use crate::metrics::runtime_metrics::RuntimeMetricTotals;
@@ -1,3 +1,4 @@
use codex_otel::AuthEnvTelemetryMetadata;
use codex_otel::OtelProvider;
use codex_otel::SessionTelemetry;
use codex_otel::TelemetryAuthMode;
@@ -18,6 +19,9 @@ use tracing_subscriber::filter::filter_fn;
use tracing_subscriber::layer::SubscriberExt;
use codex_protocol::ThreadId;
use codex_protocol::config_types::ReasoningSummary;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionSource;
use codex_protocol::user_input::UserInput;
@@ -76,6 +80,17 @@ fn find_span_event_by_name_attr<'a>(
.unwrap_or_else(|| panic!("missing span event: {event_name}"))
}
fn auth_env_metadata() -> AuthEnvTelemetryMetadata {
AuthEnvTelemetryMetadata {
openai_api_key_env_present: true,
codex_api_key_env_present: false,
codex_api_key_env_enabled: true,
provider_env_key_name: Some("configured".to_string()),
provider_env_key_present: Some(true),
refresh_token_url_override_present: true,
}
}
#[test]
fn otel_export_routing_policy_routes_user_prompt_log_and_trace_events() {
let log_exporter = InMemoryLogExporter::default();
@@ -482,9 +497,21 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() {
true,
"tty".to_string(),
SessionSource::Cli,
);
)
.with_auth_env(auth_env_metadata());
let root_span = tracing::info_span!("root");
let _root_guard = root_span.enter();
manager.conversation_starts(
"openai",
None,
ReasoningSummary::Auto,
None,
None,
AskForApproval::Never,
SandboxPolicy::DangerFullAccess,
Vec::new(),
None,
);
manager.record_api_request(
1,
Some(401),
@@ -507,6 +534,20 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() {
tracer_provider.force_flush().expect("flush traces");
let logs = log_exporter.get_emitted_logs().expect("log export");
let conversation_log = find_log_by_event_name(&logs, "codex.conversation_starts");
let conversation_log_attrs = log_attributes(&conversation_log.record);
assert_eq!(
conversation_log_attrs
.get("auth.env_openai_api_key_present")
.map(String::as_str),
Some("true")
);
assert_eq!(
conversation_log_attrs
.get("auth.env_provider_key_name")
.map(String::as_str),
Some("configured")
);
let request_log = find_log_by_event_name(&logs, "codex.api_request");
let request_log_attrs = log_attributes(&request_log.record);
assert_eq!(
@@ -547,8 +588,29 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() {
request_log_attrs.get("auth.error").map(String::as_str),
Some("missing_authorization_header")
);
assert_eq!(
request_log_attrs
.get("auth.env_codex_api_key_enabled")
.map(String::as_str),
Some("true")
);
assert_eq!(
request_log_attrs
.get("auth.env_refresh_token_url_override_present")
.map(String::as_str),
Some("true")
);
let spans = span_exporter.get_finished_spans().expect("span export");
let conversation_trace_event =
find_span_event_by_name_attr(&spans[0].events.events, "codex.conversation_starts");
let conversation_trace_attrs = span_event_attributes(conversation_trace_event);
assert_eq!(
conversation_trace_attrs
.get("auth.env_provider_key_present")
.map(String::as_str),
Some("true")
);
let request_trace_event =
find_span_event_by_name_attr(&spans[0].events.events, "codex.api_request");
let request_trace_attrs = span_event_attributes(request_trace_event);
@@ -574,6 +636,12 @@ fn otel_export_routing_policy_routes_api_request_auth_observability() {
request_trace_attrs.get("endpoint").map(String::as_str),
Some("/responses")
);
assert_eq!(
request_trace_attrs
.get("auth.env_openai_api_key_present")
.map(String::as_str),
Some("true")
);
}
#[test]
@@ -614,7 +682,8 @@ fn otel_export_routing_policy_routes_websocket_connect_auth_observability() {
true,
"tty".to_string(),
SessionSource::Cli,
);
)
.with_auth_env(auth_env_metadata());
let root_span = tracing::info_span!("root");
let _root_guard = root_span.enter();
manager.record_websocket_connect(
@@ -667,6 +736,12 @@ fn otel_export_routing_policy_routes_websocket_connect_auth_observability() {
.map(String::as_str),
Some("false")
);
assert_eq!(
connect_log_attrs
.get("auth.env_provider_key_name")
.map(String::as_str),
Some("configured")
);
let spans = span_exporter.get_finished_spans().expect("span export");
let connect_trace_event =
@@ -678,6 +753,12 @@ fn otel_export_routing_policy_routes_websocket_connect_auth_observability() {
.map(String::as_str),
Some("reload")
);
assert_eq!(
connect_trace_attrs
.get("auth.env_refresh_token_url_override_present")
.map(String::as_str),
Some("true")
);
}
#[test]
@@ -718,7 +799,8 @@ fn otel_export_routing_policy_routes_websocket_request_transport_observability()
true,
"tty".to_string(),
SessionSource::Cli,
);
)
.with_auth_env(auth_env_metadata());
let root_span = tracing::info_span!("root");
let _root_guard = root_span.enter();
manager.record_websocket_request(
@@ -744,6 +826,12 @@ fn otel_export_routing_policy_routes_websocket_request_transport_observability()
request_log_attrs.get("error.message").map(String::as_str),
Some("stream error")
);
assert_eq!(
request_log_attrs
.get("auth.env_openai_api_key_present")
.map(String::as_str),
Some("true")
);
let spans = span_exporter.get_finished_spans().expect("span export");
let request_trace_event =
@@ -755,4 +843,10 @@ fn otel_export_routing_policy_routes_websocket_request_transport_observability()
.map(String::as_str),
Some("true")
);
assert_eq!(
request_trace_attrs
.get("auth.env_provider_key_present")
.map(String::as_str),
Some("true")
);
}
+9 -59
View File
@@ -452,16 +452,6 @@ pub enum Op {
force_reload: bool,
},
/// Request the list of remote skills available via ChatGPT sharing.
ListRemoteSkills {
hazelnut_scope: RemoteSkillHazelnutScope,
product_surface: RemoteSkillProductSurface,
enabled: Option<bool>,
},
/// Download a remote skill by id into the local skills cache.
DownloadRemoteSkill { hazelnut_id: String },
/// Request the agent to summarize the current conversation context.
/// The agent will use its existing context (either conversation history or previous response id)
/// to generate a summary which will be returned as an AgentMessage event.
@@ -532,8 +522,6 @@ impl Op {
Self::ReloadUserConfig => "reload_user_config",
Self::ListCustomPrompts => "list_custom_prompts",
Self::ListSkills { .. } => "list_skills",
Self::ListRemoteSkills { .. } => "list_remote_skills",
Self::DownloadRemoteSkill { .. } => "download_remote_skill",
Self::Compact => "compact",
Self::DropMemories => "drop_memories",
Self::UpdateMemories => "update_memories",
@@ -1299,12 +1287,6 @@ pub enum EventMsg {
/// List of skills available to the agent.
ListSkillsResponse(ListSkillsResponseEvent),
/// List of remote skills available to the agent.
ListRemoteSkillsResponse(ListRemoteSkillsResponseEvent),
/// Remote skill downloaded to local cache.
RemoteSkillDownloaded(RemoteSkillDownloadedEvent),
/// Notification that skill data may have been updated and clients may want to reload.
SkillsUpdateAvailable,
@@ -1446,9 +1428,18 @@ pub struct HookCompletedEvent {
pub run: HookRunSummary,
}
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
pub enum RealtimeConversationVersion {
#[default]
V1,
V2,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
pub struct RealtimeConversationStartedEvent {
pub session_id: Option<String>,
pub version: RealtimeConversationVersion,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, JsonSchema, TS)]
@@ -2917,47 +2908,6 @@ pub struct ListSkillsResponseEvent {
pub skills: Vec<SkillsListEntry>,
}
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct RemoteSkillSummary {
pub id: String,
pub name: String,
pub description: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "kebab-case")]
#[ts(rename_all = "kebab-case")]
pub enum RemoteSkillHazelnutScope {
WorkspaceShared,
AllShared,
Personal,
Example,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "lowercase")]
#[ts(rename_all = "lowercase")]
pub enum RemoteSkillProductSurface {
Chatgpt,
Codex,
Api,
Atlas,
}
/// Response payload for `Op::ListRemoteSkills`.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct ListRemoteSkillsResponseEvent {
pub skills: Vec<RemoteSkillSummary>,
}
/// Response payload for `Op::DownloadRemoteSkill`.
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct RemoteSkillDownloadedEvent {
pub id: String,
pub name: String,
pub path: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)]
#[serde(rename_all = "snake_case")]
#[ts(rename_all = "snake_case")]
@@ -149,7 +149,7 @@ pub(crate) struct ToolSuggestionRequest {
pub(crate) suggest_reason: String,
pub(crate) tool_id: String,
pub(crate) tool_name: String,
pub(crate) install_url: String,
pub(crate) install_url: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -373,8 +373,8 @@ fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option<ToolSuggestionR
tool_name: meta.get(TOOL_NAME_KEY).and_then(Value::as_str)?.to_string(),
install_url: meta
.get(TOOL_SUGGEST_INSTALL_URL_KEY)
.and_then(Value::as_str)?
.to_string(),
.and_then(Value::as_str)
.map(ToString::to_string),
})
}
@@ -1945,7 +1945,39 @@ mod tests {
suggest_reason: "Plan and reference events from your calendar".to_string(),
tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
tool_name: "Google Calendar".to_string(),
install_url: "https://example.test/google-calendar".to_string(),
install_url: Some("https://example.test/google-calendar".to_string()),
})
);
}
#[test]
fn plugin_tool_suggestion_meta_without_install_url_is_parsed_into_request_payload() {
let request = McpServerElicitationFormRequest::from_event(
ThreadId::default(),
form_request(
"Suggest Slack",
empty_object_schema(),
Some(serde_json::json!({
"codex_approval_kind": "tool_suggestion",
"tool_type": "plugin",
"suggest_type": "install",
"suggest_reason": "Install the Slack plugin to search messages",
"tool_id": "slack@openai-curated",
"tool_name": "Slack",
})),
),
)
.expect("expected tool suggestion form");
assert_eq!(
request.tool_suggestion(),
Some(&ToolSuggestionRequest {
tool_type: ToolSuggestionToolType::Plugin,
suggest_type: ToolSuggestionType::Install,
suggest_reason: "Install the Slack plugin to search messages".to_string(),
tool_id: "slack@openai-curated".to_string(),
tool_name: "Slack".to_string(),
install_url: None,
})
);
}
+4 -2
View File
@@ -965,7 +965,9 @@ impl BottomPane {
request
};
if let Some(tool_suggestion) = request.tool_suggestion() {
if let Some(tool_suggestion) = request.tool_suggestion()
&& let Some(install_url) = tool_suggestion.install_url.clone()
{
let suggestion_type = match tool_suggestion.suggest_type {
mcp_server_elicitation::ToolSuggestionType::Install => {
AppLinkSuggestionType::Install
@@ -989,7 +991,7 @@ impl BottomPane {
"Enable this app to use it for the current request.".to_string()
}
},
url: tool_suggestion.install_url.clone(),
url: install_url,
is_installed,
is_enabled: false,
suggest_reason: Some(tool_suggestion.suggest_reason.clone()),
-1
View File
@@ -5354,7 +5354,6 @@ impl ChatWidget {
EventMsg::McpListToolsResponse(ev) => self.on_list_mcp_tools(ev),
EventMsg::ListCustomPromptsResponse(ev) => self.on_list_custom_prompts(ev),
EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev),
EventMsg::ListRemoteSkillsResponse(_) | EventMsg::RemoteSkillDownloaded(_) => {}
EventMsg::SkillsUpdateAvailable => {
self.submit_op(Op::ListSkills {
cwds: Vec::new(),
+44 -2
View File
@@ -4,6 +4,7 @@ use codex_protocol::protocol::RealtimeAudioFrame;
use codex_protocol::protocol::RealtimeConversationClosedEvent;
use codex_protocol::protocol::RealtimeConversationRealtimeEvent;
use codex_protocol::protocol::RealtimeConversationStartedEvent;
use codex_protocol::protocol::RealtimeConversationVersion;
use codex_protocol::protocol::RealtimeEvent;
#[cfg(not(target_os = "linux"))]
use std::sync::atomic::AtomicUsize;
@@ -22,6 +23,7 @@ pub(super) enum RealtimeConversationPhase {
#[derive(Default)]
pub(super) struct RealtimeConversationUiState {
phase: RealtimeConversationPhase,
audio_behavior: RealtimeAudioBehavior,
requested_close: bool,
session_id: Option<String>,
warned_audio_only_submission: bool,
@@ -38,6 +40,35 @@ pub(super) struct RealtimeConversationUiState {
playback_queued_samples: Arc<AtomicUsize>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
enum RealtimeAudioBehavior {
#[default]
Legacy,
PlaybackAware,
}
impl RealtimeAudioBehavior {
fn from_version(version: RealtimeConversationVersion) -> Self {
match version {
RealtimeConversationVersion::V1 => Self::Legacy,
RealtimeConversationVersion::V2 => Self::PlaybackAware,
}
}
#[cfg(not(target_os = "linux"))]
fn input_behavior(
self,
playback_queued_samples: Arc<AtomicUsize>,
) -> crate::voice::RealtimeInputBehavior {
match self {
Self::Legacy => crate::voice::RealtimeInputBehavior::Ungated,
Self::PlaybackAware => crate::voice::RealtimeInputBehavior::PlaybackAware {
playback_queued_samples,
},
}
}
}
impl RealtimeConversationUiState {
pub(super) fn is_live(&self) -> bool {
matches!(
@@ -202,6 +233,7 @@ impl ChatWidget {
self.realtime_conversation.phase = RealtimeConversationPhase::Starting;
self.realtime_conversation.requested_close = false;
self.realtime_conversation.session_id = None;
self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::Legacy;
self.realtime_conversation.warned_audio_only_submission = false;
self.set_footer_hint_override(Some(vec![(
"/realtime".to_string(),
@@ -241,6 +273,7 @@ impl ChatWidget {
self.realtime_conversation.phase = RealtimeConversationPhase::Inactive;
self.realtime_conversation.requested_close = false;
self.realtime_conversation.session_id = None;
self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::Legacy;
self.realtime_conversation.warned_audio_only_submission = false;
}
@@ -255,6 +288,7 @@ impl ChatWidget {
}
self.realtime_conversation.phase = RealtimeConversationPhase::Active;
self.realtime_conversation.session_id = ev.session_id;
self.realtime_conversation.audio_behavior = RealtimeAudioBehavior::from_version(ev.version);
self.realtime_conversation.warned_audio_only_submission = false;
self.set_footer_hint_override(Some(vec![(
"/realtime".to_string(),
@@ -274,7 +308,11 @@ impl ChatWidget {
}
RealtimeEvent::InputAudioSpeechStarted(_) | RealtimeEvent::ResponseCancelled(_) => {
#[cfg(not(target_os = "linux"))]
if let Some(player) = &self.realtime_conversation.audio_player {
if matches!(
self.realtime_conversation.audio_behavior,
RealtimeAudioBehavior::PlaybackAware
) && let Some(player) = &self.realtime_conversation.audio_player
{
// Once the server detects user speech or the current response is cancelled,
// any buffered assistant audio is stale and should stop gating mic input.
player.clear();
@@ -341,7 +379,11 @@ impl ChatWidget {
let capture = match crate::voice::VoiceCapture::start_realtime(
&self.config,
self.app_event_tx.clone(),
Arc::clone(&self.realtime_conversation.playback_queued_samples),
self.realtime_conversation
.audio_behavior
.input_behavior(Arc::clone(
&self.realtime_conversation.playback_queued_samples,
)),
) {
Ok(capture) => capture,
Err(err) => {
+113 -19
View File
@@ -6604,6 +6604,50 @@ fn render_bottom_popup(chat: &ChatWidget, width: u16) -> String {
lines.join("\n")
}
fn selected_permissions_popup_line(popup: &str) -> &str {
popup
.lines()
.find(|line| {
line.contains('›')
&& (line.contains("Default")
|| line.contains("Read Only")
|| line.contains("Guardian Approvals")
|| line.contains("Full Access"))
})
.unwrap_or_else(|| {
panic!("expected permissions popup to have a selected preset row: {popup}")
})
}
fn selected_permissions_popup_name(popup: &str) -> &'static str {
selected_permissions_popup_line(popup)
.trim_start()
.strip_prefix('›')
.map(str::trim_start)
.and_then(|line| line.split_once(". ").map(|(_, rest)| rest))
.and_then(|line| {
["Read Only", "Default", "Guardian Approvals", "Full Access"]
.into_iter()
.find(|label| line.starts_with(label))
})
.unwrap_or_else(|| {
panic!("expected permissions popup row to start with a preset label: {popup}")
})
}
fn move_permissions_popup_selection_to(chat: &mut ChatWidget, label: &str, direction: KeyCode) {
for _ in 0..4 {
let popup = render_bottom_popup(chat, 120);
if selected_permissions_popup_name(&popup) == label {
return;
}
chat.handle_key_event(KeyEvent::from(direction));
}
let popup = render_bottom_popup(chat, 120);
panic!("expected permissions popup to select {label}: {popup}");
}
#[tokio::test]
async fn apps_popup_stays_loading_until_final_snapshot_updates() {
let (mut chat, _rx, _op_rx) = make_chatwidget_manual(None).await;
@@ -8333,7 +8377,25 @@ async fn permissions_selection_emits_history_cell_when_selection_changes() {
chat.config.notices.hide_full_access_warning = Some(true);
chat.open_permissions_popup();
let popup = render_bottom_popup(&chat, 120);
#[cfg(target_os = "windows")]
let expected_initial = "Read Only";
#[cfg(not(target_os = "windows"))]
let expected_initial = "Default";
assert!(
selected_permissions_popup_name(&popup) == expected_initial,
"expected permissions popup to open with {expected_initial} selected: {popup}"
);
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
let popup = render_bottom_popup(&chat, 120);
#[cfg(target_os = "windows")]
let expected_after_one_down = "Default";
#[cfg(not(target_os = "windows"))]
let expected_after_one_down = "Full Access";
assert!(
selected_permissions_popup_name(&popup) == expected_after_one_down,
"expected moving down to select {expected_after_one_down} before confirmation: {popup}"
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let cells = drain_insert_history(&mut rx);
@@ -8360,9 +8422,21 @@ async fn permissions_selection_history_snapshot_after_mode_switch() {
chat.config.notices.hide_full_access_warning = Some(true);
chat.open_permissions_popup();
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
let popup = render_bottom_popup(&chat, 120);
#[cfg(target_os = "windows")]
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
let expected_initial = "Read Only";
#[cfg(not(target_os = "windows"))]
let expected_initial = "Default";
assert!(
selected_permissions_popup_name(&popup) == expected_initial,
"expected permissions popup to open with {expected_initial} selected: {popup}"
);
move_permissions_popup_selection_to(&mut chat, "Full Access", KeyCode::Down);
let popup = render_bottom_popup(&chat, 120);
assert!(
selected_permissions_popup_name(&popup) == "Full Access",
"expected navigation to land on Full Access before confirmation: {popup}"
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let cells = drain_insert_history(&mut rx);
@@ -8395,10 +8469,16 @@ async fn permissions_selection_history_snapshot_full_access_to_default() {
chat.open_permissions_popup();
let popup = render_bottom_popup(&chat, 120);
chat.handle_key_event(KeyEvent::from(KeyCode::Up));
if popup.contains("Guardian Approvals") {
chat.handle_key_event(KeyEvent::from(KeyCode::Up));
}
assert!(
selected_permissions_popup_name(&popup) == "Full Access",
"expected permissions popup to open with Full Access selected: {popup}"
);
move_permissions_popup_selection_to(&mut chat, "Default", KeyCode::Up);
let popup = render_bottom_popup(&chat, 120);
assert!(
selected_permissions_popup_name(&popup) == "Default",
"expected navigation to land on Default before confirmation: {popup}"
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let cells = drain_insert_history(&mut rx);
@@ -8437,6 +8517,11 @@ async fn permissions_selection_emits_history_cell_when_current_is_selected() {
.expect("set sandbox policy");
chat.open_permissions_popup();
let popup = render_bottom_popup(&chat, 120);
assert!(
selected_permissions_popup_name(&popup) == "Default",
"expected permissions popup to open with Default selected: {popup}"
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let cells = drain_insert_history(&mut rx);
@@ -8542,7 +8627,8 @@ async fn permissions_selection_marks_guardian_approvals_current_after_session_co
let popup = render_bottom_popup(&chat, 120);
assert!(
popup.contains("Guardian Approvals (current)"),
selected_permissions_popup_name(&popup) == "Guardian Approvals"
&& selected_permissions_popup_line(&popup).contains("(current)"),
"expected Guardian Approvals to be current after SessionConfigured sync: {popup}"
);
}
@@ -8597,7 +8683,8 @@ async fn permissions_selection_marks_guardian_approvals_current_with_custom_work
let popup = render_bottom_popup(&chat, 120);
assert!(
popup.contains("Guardian Approvals (current)"),
selected_permissions_popup_name(&popup) == "Guardian Approvals"
&& selected_permissions_popup_line(&popup).contains("(current)"),
"expected Guardian Approvals to be current even with custom workspace-write details: {popup}"
);
}
@@ -8622,9 +8709,22 @@ async fn permissions_selection_can_disable_guardian_approvals() {
.sandbox_policy
.set(SandboxPolicy::new_workspace_write_policy())
.expect("set sandbox policy");
chat.set_approvals_reviewer(ApprovalsReviewer::GuardianSubagent);
chat.open_permissions_popup();
chat.handle_key_event(KeyEvent::from(KeyCode::Up));
let popup = render_bottom_popup(&chat, 120);
assert!(
selected_permissions_popup_name(&popup) == "Guardian Approvals"
&& selected_permissions_popup_line(&popup).contains("(current)"),
"expected permissions popup to open with Guardian Approvals selected: {popup}"
);
move_permissions_popup_selection_to(&mut chat, "Default", KeyCode::Up);
let popup = render_bottom_popup(&chat, 120);
assert!(
selected_permissions_popup_name(&popup) == "Default",
"expected one Up from Guardian Approvals to select Default: {popup}"
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let events = std::iter::from_fn(|| rx.try_recv().ok()).collect::<Vec<_>>();
@@ -8668,18 +8768,14 @@ async fn permissions_selection_sends_approvals_reviewer_in_override_turn_context
chat.open_permissions_popup();
let popup = render_bottom_popup(&chat, 120);
assert!(
popup
.lines()
.any(|line| line.contains("(current)") && line.contains('›')),
selected_permissions_popup_line(&popup).contains("(current)"),
"expected permissions popup to open with the current preset selected: {popup}"
);
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
move_permissions_popup_selection_to(&mut chat, "Guardian Approvals", KeyCode::Down);
let popup = render_bottom_popup(&chat, 120);
assert!(
popup
.lines()
.any(|line| line.contains("Guardian Approvals") && line.contains('›')),
selected_permissions_popup_name(&popup) == "Guardian Approvals",
"expected one Down from Default to select Guardian Approvals: {popup}"
);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
@@ -8720,9 +8816,7 @@ async fn permissions_full_access_history_cell_emitted_only_after_confirmation()
chat.config.notices.hide_full_access_warning = None;
chat.open_permissions_popup();
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
#[cfg(target_os = "windows")]
chat.handle_key_event(KeyEvent::from(KeyCode::Down));
move_permissions_popup_selection_to(&mut chat, "Full Access", KeyCode::Down);
chat.handle_key_event(KeyEvent::from(KeyCode::Enter));
let mut open_confirmation_event = None;
+9 -1
View File
@@ -147,6 +147,14 @@ mod voice {
pub(crate) struct RealtimeAudioPlayer;
#[derive(Clone)]
pub(crate) enum RealtimeInputBehavior {
Ungated,
PlaybackAware {
playback_queued_samples: Arc<AtomicUsize>,
},
}
impl VoiceCapture {
pub fn start() -> Result<Self, String> {
Err("voice input is unavailable in this build".to_string())
@@ -155,7 +163,7 @@ mod voice {
pub fn start_realtime(
_config: &Config,
_tx: AppEventSender,
_playback_queued_samples: Arc<AtomicUsize>,
_input_behavior: RealtimeInputBehavior,
) -> Result<Self, String> {
Err("voice input is unavailable in this build".to_string())
}
+69 -14
View File
@@ -44,6 +44,14 @@ const REALTIME_INTERRUPT_INPUT_PEAK_THRESHOLD: u16 = 4_000;
// callbacks so trailing syllables are not chopped up between chunks.
const REALTIME_INTERRUPT_GRACE_PERIOD: Duration = Duration::from_millis(900);
#[derive(Clone)]
pub(crate) enum RealtimeInputBehavior {
Ungated,
PlaybackAware {
playback_queued_samples: Arc<AtomicUsize>,
},
}
struct TranscriptionAuthContext {
mode: AuthMode,
bearer_token: String,
@@ -94,7 +102,7 @@ impl VoiceCapture {
pub fn start_realtime(
config: &Config,
tx: AppEventSender,
playback_queued_samples: Arc<AtomicUsize>,
input_behavior: RealtimeInputBehavior,
) -> Result<Self, String> {
let (device, config) = select_configured_input_device_and_config(config)?;
@@ -110,7 +118,7 @@ impl VoiceCapture {
sample_rate,
channels,
tx,
playback_queued_samples,
input_behavior,
last_peak.clone(),
)?;
stream
@@ -354,7 +362,7 @@ fn build_realtime_input_stream(
sample_rate: u32,
channels: u16,
tx: AppEventSender,
playback_queued_samples: Arc<AtomicUsize>,
input_behavior: RealtimeInputBehavior,
last_peak: Arc<AtomicU16>,
) -> Result<cpal::Stream, String> {
match config.sample_format() {
@@ -362,7 +370,6 @@ fn build_realtime_input_stream(
.build_input_stream(
&config.clone().into(),
{
let playback_queued_samples = Arc::clone(&playback_queued_samples);
let last_peak = Arc::clone(&last_peak);
let tx = tx;
let mut allow_input_until = None;
@@ -370,9 +377,8 @@ fn build_realtime_input_stream(
let peak = peak_f32(input);
if !should_send_realtime_input(
peak,
&playback_queued_samples,
&input_behavior,
&mut allow_input_until,
Instant::now(),
) {
last_peak.store(0, Ordering::Relaxed);
return;
@@ -390,7 +396,6 @@ fn build_realtime_input_stream(
.build_input_stream(
&config.clone().into(),
{
let playback_queued_samples = Arc::clone(&playback_queued_samples);
let last_peak = Arc::clone(&last_peak);
let tx = tx;
let mut allow_input_until = None;
@@ -398,9 +403,8 @@ fn build_realtime_input_stream(
let peak = peak_i16(input);
if !should_send_realtime_input(
peak,
&playback_queued_samples,
&input_behavior,
&mut allow_input_until,
Instant::now(),
) {
last_peak.store(0, Ordering::Relaxed);
return;
@@ -417,7 +421,6 @@ fn build_realtime_input_stream(
.build_input_stream(
&config.clone().into(),
{
let playback_queued_samples = Arc::clone(&playback_queued_samples);
let last_peak = Arc::clone(&last_peak);
let tx = tx;
let mut allow_input_until = None;
@@ -426,9 +429,8 @@ fn build_realtime_input_stream(
let peak = convert_u16_to_i16_and_peak(input, &mut samples);
if !should_send_realtime_input(
peak,
&playback_queued_samples,
&input_behavior,
&mut allow_input_until,
Instant::now(),
) {
last_peak.store(0, Ordering::Relaxed);
return;
@@ -739,10 +741,21 @@ fn fill_output_u16(
/// utterance reaches the server.
fn should_send_realtime_input(
peak: u16,
playback_queued_samples: &Arc<AtomicUsize>,
input_behavior: &RealtimeInputBehavior,
allow_input_until: &mut Option<Instant>,
now: Instant,
) -> bool {
let playback_queued_samples = match input_behavior {
RealtimeInputBehavior::Ungated => {
*allow_input_until = None;
return true;
}
RealtimeInputBehavior::PlaybackAware {
playback_queued_samples,
} => playback_queued_samples,
};
let now = Instant::now();
if playback_queued_samples.load(Ordering::Relaxed) == 0 {
*allow_input_until = None;
return true;
@@ -1021,11 +1034,18 @@ async fn transcribe_bytes(
#[cfg(test)]
mod tests {
use super::RealtimeInputBehavior;
use super::RecordedAudio;
use super::convert_pcm16;
use super::encode_wav_normalized;
use super::should_send_realtime_input;
use pretty_assertions::assert_eq;
use std::io::Cursor;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
use std::time::Instant;
#[test]
fn convert_pcm16_downmixes_and_resamples_for_model_input() {
@@ -1054,4 +1074,39 @@ mod tests {
assert_eq!(spec.sample_rate, 24_000);
assert_eq!(samples, vec![8_426, 29_490]);
}
#[test]
fn ungated_realtime_input_ignores_playback_backlog() {
let mut allow_input_until = Some(Instant::now() + Duration::from_secs(1));
let playback_queued_samples = Arc::new(AtomicUsize::new(1024));
assert!(should_send_realtime_input(
0,
&RealtimeInputBehavior::Ungated,
&mut allow_input_until,
));
assert_eq!(allow_input_until, None);
assert_eq!(playback_queued_samples.load(Ordering::Relaxed), 1024);
}
#[test]
fn playback_aware_realtime_input_requires_an_interrupt_peak() {
let mut allow_input_until = None;
let playback_queued_samples = Arc::new(AtomicUsize::new(1024));
let input_behavior = RealtimeInputBehavior::PlaybackAware {
playback_queued_samples: Arc::clone(&playback_queued_samples),
};
assert!(!should_send_realtime_input(
100,
&input_behavior,
&mut allow_input_until,
));
assert!(should_send_realtime_input(
5_000,
&input_behavior,
&mut allow_input_until,
));
assert!(allow_input_until.is_some());
}
}
+226
View File
@@ -44,7 +44,13 @@ use crate::tui::TuiEvent;
use crate::update_action::UpdateAction;
use crate::version::CODEX_CLI_VERSION;
use codex_ansi_escape::ansi_escape_line;
use codex_app_server_client::AppServerRequestHandle;
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::ConfigLayerSource;
use codex_app_server_protocol::ListMcpServerStatusParams;
use codex_app_server_protocol::ListMcpServerStatusResponse;
use codex_app_server_protocol::McpServerStatus;
use codex_app_server_protocol::RequestId;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
@@ -75,6 +81,8 @@ use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::FinalOutput;
use codex_protocol::protocol::ListSkillsResponseEvent;
#[cfg(test)]
use codex_protocol::protocol::McpAuthStatus;
#[cfg(test)]
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionConfiguredEvent;
@@ -111,6 +119,7 @@ use tokio::sync::mpsc::error::TrySendError;
use tokio::sync::mpsc::unbounded_channel;
use tokio::task::JoinHandle;
use toml::Value as TomlValue;
use uuid::Uuid;
mod agent_navigation;
mod app_server_adapter;
mod app_server_requests;
@@ -1536,6 +1545,72 @@ impl App {
Ok(())
}
/// Spawn a background task that fetches the full MCP server inventory from the
/// app-server via paginated RPCs, then delivers the result back through
/// `AppEvent::McpInventoryLoaded`.
///
/// The spawned task is fire-and-forget: no `JoinHandle` is stored, so a stale
/// result may arrive after the user has moved on. We currently accept that
/// tradeoff because the effect is limited to stale inventory output in history,
/// while request-token invalidation would add cross-cutting async state for a
/// low-severity path.
fn fetch_mcp_inventory(&mut self, app_server: &AppServerSession) {
let request_handle = app_server.request_handle();
let app_event_tx = self.app_event_tx.clone();
tokio::spawn(async move {
let result = fetch_all_mcp_server_statuses(request_handle)
.await
.map_err(|err| err.to_string());
app_event_tx.send(AppEvent::McpInventoryLoaded { result });
});
}
/// Process the completed MCP inventory fetch: clear the loading spinner, then
/// render either the full tool/resource listing or an error into chat history.
///
/// When both the local config and the app-server report zero servers, a special
/// "empty" cell is shown instead of the full table.
fn handle_mcp_inventory_result(&mut self, result: Result<Vec<McpServerStatus>, String>) {
let config = self.chat_widget.config_ref().clone();
self.chat_widget.clear_mcp_inventory_loading();
self.clear_committed_mcp_inventory_loading();
let statuses = match result {
Ok(statuses) => statuses,
Err(err) => {
self.chat_widget
.add_error_message(format!("Failed to load MCP inventory: {err}"));
return;
}
};
if config.mcp_servers.get().is_empty() && statuses.is_empty() {
self.chat_widget
.add_to_history(history_cell::empty_mcp_output());
return;
}
self.chat_widget
.add_to_history(history_cell::new_mcp_tools_output_from_statuses(
&config, &statuses,
));
}
fn clear_committed_mcp_inventory_loading(&mut self) {
let Some(index) = self
.transcript_cells
.iter()
.rposition(|cell| cell.as_any().is::<history_cell::McpInventoryLoadingCell>())
else {
return;
};
self.transcript_cells.remove(index);
if let Some(Overlay::Transcript(overlay)) = &mut self.overlay {
overlay.replace_cells(self.transcript_cells.clone());
}
}
async fn try_submit_active_thread_op_via_app_server(
&mut self,
app_server: &mut AppServerSession,
@@ -3047,6 +3122,12 @@ impl App {
AppEvent::RefreshConnectors { force_refetch } => {
self.chat_widget.refresh_connectors(force_refetch);
}
AppEvent::FetchMcpInventory => {
self.fetch_mcp_inventory(app_server);
}
AppEvent::McpInventoryLoaded { result } => {
self.handle_mcp_inventory_result(result);
}
AppEvent::StartFileSearch(query) => {
self.file_search.on_user_query(query);
}
@@ -4469,6 +4550,80 @@ impl App {
}
}
/// Collect every MCP server status from the app-server by walking the paginated
/// `mcpServerStatus/list` RPC until no `next_cursor` is returned.
///
/// All pages are eagerly gathered into a single `Vec` so the caller can render
/// the inventory atomically. Each page requests up to 100 entries.
async fn fetch_all_mcp_server_statuses(
request_handle: AppServerRequestHandle,
) -> Result<Vec<McpServerStatus>> {
let mut cursor = None;
let mut statuses = Vec::new();
loop {
let request_id = RequestId::String(format!("mcp-inventory-{}", Uuid::new_v4()));
let response: ListMcpServerStatusResponse = request_handle
.request_typed(ClientRequest::McpServerStatusList {
request_id,
params: ListMcpServerStatusParams {
cursor: cursor.clone(),
limit: Some(100),
},
})
.await
.wrap_err("mcpServerStatus/list failed in app-server TUI")?;
statuses.extend(response.data);
if let Some(next_cursor) = response.next_cursor {
cursor = Some(next_cursor);
} else {
break;
}
}
Ok(statuses)
}
/// Convert flat `McpServerStatus` responses into the per-server maps used by the
/// in-process MCP subsystem (tools keyed as `mcp__{server}__{tool}`, plus
/// per-server resource/template/auth maps). Test-only because the app-server TUI
/// renders directly from `McpServerStatus` rather than these maps.
#[cfg(test)]
type McpInventoryMaps = (
HashMap<String, codex_protocol::mcp::Tool>,
HashMap<String, Vec<codex_protocol::mcp::Resource>>,
HashMap<String, Vec<codex_protocol::mcp::ResourceTemplate>>,
HashMap<String, McpAuthStatus>,
);
#[cfg(test)]
fn mcp_inventory_maps_from_statuses(statuses: Vec<McpServerStatus>) -> McpInventoryMaps {
let mut tools = HashMap::new();
let mut resources = HashMap::new();
let mut resource_templates = HashMap::new();
let mut auth_statuses = HashMap::new();
for status in statuses {
let server_name = status.name;
auth_statuses.insert(
server_name.clone(),
match status.auth_status {
codex_app_server_protocol::McpAuthStatus::Unsupported => McpAuthStatus::Unsupported,
codex_app_server_protocol::McpAuthStatus::NotLoggedIn => McpAuthStatus::NotLoggedIn,
codex_app_server_protocol::McpAuthStatus::BearerToken => McpAuthStatus::BearerToken,
codex_app_server_protocol::McpAuthStatus::OAuth => McpAuthStatus::OAuth,
},
);
resources.insert(server_name.clone(), status.resources);
resource_templates.insert(server_name.clone(), status.resource_templates);
for (tool_name, tool) in status.tools {
tools.insert(format!("mcp__{server_name}__{tool_name}"), tool);
}
}
(tools, resources, resource_templates, auth_statuses)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -4500,11 +4655,13 @@ mod tests {
use codex_protocol::config_types::CollaborationModeMask;
use codex_protocol::config_types::ModeKind;
use codex_protocol::config_types::Settings;
use codex_protocol::mcp::Tool;
use codex_protocol::openai_models::ModelAvailabilityNux;
use codex_protocol::protocol::AgentMessageDeltaEvent;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::Event;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::McpAuthStatus;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::protocol::SessionConfiguredEvent;
use codex_protocol::protocol::SessionSource;
@@ -4545,6 +4702,75 @@ mod tests {
Ok(())
}
#[test]
fn mcp_inventory_maps_prefix_tool_names_by_server() {
let statuses = vec![
McpServerStatus {
name: "docs".to_string(),
tools: HashMap::from([(
"list".to_string(),
Tool {
description: None,
name: "list".to_string(),
title: None,
input_schema: serde_json::json!({"type": "object"}),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
)]),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported,
},
McpServerStatus {
name: "disabled".to_string(),
tools: HashMap::new(),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported,
},
];
let (tools, resources, resource_templates, auth_statuses) =
mcp_inventory_maps_from_statuses(statuses);
let mut resource_names = resources.keys().cloned().collect::<Vec<_>>();
resource_names.sort();
let mut template_names = resource_templates.keys().cloned().collect::<Vec<_>>();
template_names.sort();
assert_eq!(
tools.keys().cloned().collect::<Vec<_>>(),
vec!["mcp__docs__list".to_string()]
);
assert_eq!(resource_names, vec!["disabled", "docs"]);
assert_eq!(template_names, vec!["disabled", "docs"]);
assert_eq!(
auth_statuses.get("disabled"),
Some(&McpAuthStatus::Unsupported)
);
}
#[tokio::test]
async fn handle_mcp_inventory_result_clears_committed_loading_cell() {
let mut app = make_test_app().await;
app.transcript_cells
.push(Arc::new(history_cell::new_mcp_inventory_loading(
/*animations_enabled*/ false,
)));
app.handle_mcp_inventory_result(Ok(vec![McpServerStatus {
name: "docs".to_string(),
tools: HashMap::new(),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported,
}]));
assert_eq!(app.transcript_cells.len(), 0);
}
#[test]
fn startup_waiting_gate_is_only_for_fresh_or_exit_session_selection() {
assert_eq!(
@@ -16,9 +16,12 @@ use crate::app_event::AppEvent;
use crate::app_server_session::AppServerSession;
use crate::app_server_session::app_server_rate_limit_snapshot_to_core;
use crate::app_server_session::status_account_display_from_auth_mode;
use crate::local_chatgpt_auth::load_local_chatgpt_auth;
use codex_app_server_client::AppServerEvent;
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
use codex_app_server_protocol::JSONRPCErrorError;
use codex_app_server_protocol::ServerNotification;
use codex_app_server_protocol::ServerRequest;
use codex_app_server_protocol::Thread;
use codex_app_server_protocol::ThreadItem;
use codex_app_server_protocol::Turn;
@@ -90,6 +93,7 @@ impl App {
matches!(
notification.auth_mode,
Some(codex_app_server_protocol::AuthMode::Chatgpt)
| Some(codex_app_server_protocol::AuthMode::ChatgptAuthTokens)
),
);
}
@@ -150,6 +154,15 @@ impl App {
}
}
AppServerEvent::ServerRequest(request) => {
if let ServerRequest::ChatgptAuthTokensRefresh { request_id, params } = request {
self.handle_chatgpt_auth_tokens_refresh_request(
app_server_client,
request_id,
params,
)
.await;
return;
}
if let Some(unsupported) = self
.pending_app_server_requests
.note_server_request(&request)
@@ -181,6 +194,70 @@ impl App {
}
}
async fn handle_chatgpt_auth_tokens_refresh_request(
&mut self,
app_server_client: &AppServerSession,
request_id: codex_app_server_protocol::RequestId,
params: ChatgptAuthTokensRefreshParams,
) {
let config = self.config.clone();
let result = tokio::task::spawn_blocking(move || {
resolve_chatgpt_auth_tokens_refresh_response(
&config.codex_home,
config.cli_auth_credentials_store_mode,
config.forced_chatgpt_workspace_id.as_deref(),
&params,
)
})
.await;
match result {
Ok(Ok(response)) => {
let response = serde_json::to_value(response).map_err(|err| {
format!("failed to serialize chatgpt auth refresh response: {err}")
});
match response {
Ok(response) => {
if let Err(err) = app_server_client
.resolve_server_request(request_id, response)
.await
{
tracing::warn!("failed to resolve chatgpt auth refresh request: {err}");
}
}
Err(err) => {
self.chat_widget.add_error_message(err.clone());
if let Err(reject_err) = self
.reject_app_server_request(app_server_client, request_id, err)
.await
{
tracing::warn!("{reject_err}");
}
}
}
}
Ok(Err(err)) => {
self.chat_widget.add_error_message(err.clone());
if let Err(reject_err) = self
.reject_app_server_request(app_server_client, request_id, err)
.await
{
tracing::warn!("{reject_err}");
}
}
Err(err) => {
let message = format!("chatgpt auth refresh task failed: {err}");
self.chat_widget.add_error_message(message.clone());
if let Err(reject_err) = self
.reject_app_server_request(app_server_client, request_id, message)
.await
{
tracing::warn!("{reject_err}");
}
}
}
}
async fn reject_app_server_request(
&self,
app_server_client: &AppServerSession,
@@ -201,6 +278,28 @@ impl App {
}
}
fn resolve_chatgpt_auth_tokens_refresh_response(
codex_home: &std::path::Path,
auth_credentials_store_mode: codex_core::auth::AuthCredentialsStoreMode,
forced_chatgpt_workspace_id: Option<&str>,
params: &ChatgptAuthTokensRefreshParams,
) -> Result<codex_app_server_protocol::ChatgptAuthTokensRefreshResponse, String> {
let auth = load_local_chatgpt_auth(
codex_home,
auth_credentials_store_mode,
forced_chatgpt_workspace_id,
)?;
if let Some(previous_account_id) = params.previous_account_id.as_deref()
&& previous_account_id != auth.chatgpt_account_id
{
return Err(format!(
"local ChatGPT auth refresh account mismatch: expected `{previous_account_id}`, got `{}`",
auth.chatgpt_account_id
));
}
Ok(auth.to_refresh_response())
}
/// Convert a `Thread` snapshot into a flat sequence of protocol `Event`s
/// suitable for replaying into the TUI event store.
///
@@ -393,6 +492,7 @@ fn server_notification_thread_events(
id: String::new(),
msg: EventMsg::RealtimeConversationStarted(RealtimeConversationStartedEvent {
session_id: notification.session_id,
version: notification.version,
}),
}],
)),
@@ -624,6 +724,113 @@ fn thread_item_to_core(item: &ThreadItem) -> Option<TurnItem> {
}
}
#[cfg(test)]
mod refresh_tests {
use super::*;
use base64::Engine;
use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::AuthDotJson;
use codex_core::auth::save_auth;
use codex_core::token_data::TokenData;
use pretty_assertions::assert_eq;
use serde::Serialize;
use serde_json::json;
use tempfile::TempDir;
fn fake_jwt(account_id: &str, plan_type: &str) -> String {
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let payload = json!({
"email": "user@example.com",
"https://api.openai.com/auth": {
"chatgpt_account_id": account_id,
"chatgpt_plan_type": plan_type,
},
});
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header"));
let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload"));
let signature_b64 = encode(b"sig");
format!("{header_b64}.{payload_b64}.{signature_b64}")
}
fn write_chatgpt_auth(codex_home: &std::path::Path) {
let id_token = fake_jwt("workspace-1", "business");
let access_token = fake_jwt("workspace-1", "business");
save_auth(
codex_home,
&AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(TokenData {
id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&id_token)
.expect("id token should parse"),
access_token,
refresh_token: "refresh-token".to_string(),
account_id: Some("workspace-1".to_string()),
}),
last_refresh: Some(Utc::now()),
},
AuthCredentialsStoreMode::File,
)
.expect("chatgpt auth should save");
}
#[test]
fn refresh_request_uses_local_chatgpt_auth() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path());
let response = resolve_chatgpt_auth_tokens_refresh_response(
codex_home.path(),
AuthCredentialsStoreMode::File,
Some("workspace-1"),
&ChatgptAuthTokensRefreshParams {
reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized,
previous_account_id: Some("workspace-1".to_string()),
},
)
.expect("refresh response should resolve");
assert_eq!(response.chatgpt_account_id, "workspace-1");
assert_eq!(response.chatgpt_plan_type.as_deref(), Some("business"));
assert!(!response.access_token.is_empty());
}
#[test]
fn refresh_request_rejects_account_mismatch() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path());
let err = resolve_chatgpt_auth_tokens_refresh_response(
codex_home.path(),
AuthCredentialsStoreMode::File,
Some("workspace-1"),
&ChatgptAuthTokensRefreshParams {
reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized,
previous_account_id: Some("workspace-2".to_string()),
},
)
.expect_err("mismatched account should fail");
assert_eq!(
err,
"local ChatGPT auth refresh account mismatch: expected `workspace-2`, got `workspace-1`"
);
}
}
fn app_server_web_search_action_to_core(
action: codex_app_server_protocol::WebSearchAction,
) -> Option<codex_protocol::models::WebSearchAction> {
@@ -99,13 +99,7 @@ impl PendingAppServerRequests {
.to_string(),
})
}
ServerRequest::ChatgptAuthTokensRefresh { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
message: "ChatGPT auth token refresh is not available in app-server TUI yet."
.to_string(),
})
}
ServerRequest::ChatgptAuthTokensRefresh { .. } => None,
ServerRequest::ApplyPatchApproval { request_id, .. } => {
Some(UnsupportedAppServerRequest {
request_id: request_id.clone(),
@@ -608,6 +602,22 @@ mod tests {
);
}
#[test]
fn does_not_mark_chatgpt_auth_refresh_as_unsupported() {
let mut pending = PendingAppServerRequests::default();
assert_eq!(
pending.note_server_request(&ServerRequest::ChatgptAuthTokensRefresh {
request_id: AppServerRequestId::Integer(100),
params: codex_app_server_protocol::ChatgptAuthTokensRefreshParams {
reason: codex_app_server_protocol::ChatgptAuthTokensRefreshReason::Unauthorized,
previous_account_id: Some("workspace-1".to_string()),
},
}),
None
);
}
#[test]
fn rejects_invalid_patch_decisions_for_file_change_requests() {
let mut pending = PendingAppServerRequests::default();
+9
View File
@@ -10,6 +10,7 @@
use std::path::PathBuf;
use codex_app_server_protocol::McpServerStatus;
use codex_chatgpt::connectors::AppInfo;
use codex_file_search::FileMatch;
use codex_protocol::ThreadId;
@@ -165,6 +166,14 @@ pub(crate) enum AppEvent {
force_refetch: bool,
},
/// Fetch MCP inventory via app-server RPCs and render it into history.
FetchMcpInventory,
/// Result of fetching MCP inventory via app-server RPCs.
McpInventoryLoaded {
result: Result<Vec<McpServerStatus>, String>,
},
InsertHistoryCell(Box<dyn HistoryCell>),
/// Apply rollback semantics to local transcript cells.
@@ -149,7 +149,7 @@ pub(crate) struct ToolSuggestionRequest {
pub(crate) suggest_reason: String,
pub(crate) tool_id: String,
pub(crate) tool_name: String,
pub(crate) install_url: String,
pub(crate) install_url: Option<String>,
}
#[derive(Clone, Debug, PartialEq)]
@@ -373,8 +373,8 @@ fn parse_tool_suggestion_request(meta: Option<&Value>) -> Option<ToolSuggestionR
tool_name: meta.get(TOOL_NAME_KEY).and_then(Value::as_str)?.to_string(),
install_url: meta
.get(TOOL_SUGGEST_INSTALL_URL_KEY)
.and_then(Value::as_str)?
.to_string(),
.and_then(Value::as_str)
.map(ToString::to_string),
})
}
@@ -1939,7 +1939,39 @@ mod tests {
suggest_reason: "Plan and reference events from your calendar".to_string(),
tool_id: "connector_2128aebfecb84f64a069897515042a44".to_string(),
tool_name: "Google Calendar".to_string(),
install_url: "https://example.test/google-calendar".to_string(),
install_url: Some("https://example.test/google-calendar".to_string()),
})
);
}
#[test]
fn plugin_tool_suggestion_meta_without_install_url_is_parsed_into_request_payload() {
let request = McpServerElicitationFormRequest::from_event(
ThreadId::default(),
form_request(
"Suggest Slack",
empty_object_schema(),
Some(serde_json::json!({
"codex_approval_kind": "tool_suggestion",
"tool_type": "plugin",
"suggest_type": "install",
"suggest_reason": "Install the Slack plugin to search messages",
"tool_id": "slack@openai-curated",
"tool_name": "Slack",
})),
),
)
.expect("expected tool suggestion form");
assert_eq!(
request.tool_suggestion(),
Some(&ToolSuggestionRequest {
tool_type: ToolSuggestionToolType::Plugin,
suggest_type: ToolSuggestionType::Install,
suggest_reason: "Install the Slack plugin to search messages".to_string(),
tool_id: "slack@openai-curated".to_string(),
tool_name: "Slack".to_string(),
install_url: None,
})
);
}
@@ -958,7 +958,9 @@ impl BottomPane {
request
};
if let Some(tool_suggestion) = request.tool_suggestion() {
if let Some(tool_suggestion) = request.tool_suggestion()
&& let Some(install_url) = tool_suggestion.install_url.clone()
{
let suggestion_type = match tool_suggestion.suggest_type {
mcp_server_elicitation::ToolSuggestionType::Install => {
AppLinkSuggestionType::Install
@@ -982,7 +984,7 @@ impl BottomPane {
"Enable this app to use it for the current request.".to_string()
}
},
url: tool_suggestion.install_url.clone(),
url: install_url,
is_installed,
is_enabled: false,
suggest_reason: Some(tool_suggestion.suggest_reason.clone()),
+29 -10
View File
@@ -67,7 +67,6 @@ use codex_core::find_thread_name_by_id;
use codex_core::git_info::current_branch_name;
use codex_core::git_info::get_git_repo_root;
use codex_core::git_info::local_git_branches;
use codex_core::mcp::McpManager;
use codex_core::plugins::PluginsManager;
use codex_core::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
use codex_core::skills::model::SkillMetadata;
@@ -5190,7 +5189,6 @@ impl ChatWidget {
);
}
EventMsg::ListSkillsResponse(ev) => self.on_list_skills(ev),
EventMsg::ListRemoteSkillsResponse(_) | EventMsg::RemoteSkillDownloaded(_) => {}
EventMsg::SkillsUpdateAvailable => {
self.submit_op(AppCommand::list_skills(
Vec::new(),
@@ -8243,18 +8241,39 @@ impl ChatWidget {
PlainHistoryCell::new(vec![line.into()])
}
/// Begin the asynchronous MCP inventory flow: show a loading spinner and
/// request the app-server fetch via `AppEvent::FetchMcpInventory`.
///
/// The spinner lives in `active_cell` and is cleared by
/// [`clear_mcp_inventory_loading`] once the result arrives.
pub(crate) fn add_mcp_output(&mut self) {
let mcp_manager = McpManager::new(Arc::new(PluginsManager::new(
self.config.codex_home.clone(),
self.flush_answer_stream_with_separator();
self.flush_active_cell();
self.active_cell = Some(Box::new(history_cell::new_mcp_inventory_loading(
self.config.animations,
)));
if mcp_manager
.effective_servers(&self.config, /*auth*/ None)
.is_empty()
self.bump_active_cell_revision();
self.request_redraw();
self.app_event_tx.send(AppEvent::FetchMcpInventory);
}
/// Remove the MCP loading spinner if it is still the active cell.
///
/// Uses `Any`-based type checking so that a late-arriving inventory result
/// does not accidentally clear an unrelated cell that was set in the meantime.
pub(crate) fn clear_mcp_inventory_loading(&mut self) {
let Some(active) = self.active_cell.as_ref() else {
return;
};
if !active
.as_any()
.is::<history_cell::McpInventoryLoadingCell>()
{
self.add_to_history(history_cell::empty_mcp_output());
} else {
self.add_app_server_stub_message("MCP tool inventory");
return;
}
self.active_cell = None;
self.bump_active_cell_revision();
self.request_redraw();
}
pub(crate) fn add_connectors_output(&mut self) {
@@ -6042,6 +6042,17 @@ async fn slash_memory_drop_reports_stubbed_feature() {
);
}
#[tokio::test]
async fn slash_mcp_requests_inventory_via_app_server() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
chat.dispatch_command(SlashCommand::Mcp);
assert!(active_blob(&chat).contains("Loading MCP inventory"));
assert_matches!(rx.try_recv(), Ok(AppEvent::FetchMcpInventory));
assert!(op_rx.try_recv().is_err(), "expected no core op to be sent");
}
#[tokio::test]
async fn slash_memory_update_reports_stubbed_feature() {
let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(None).await;
+286
View File
@@ -37,6 +37,7 @@ use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
use crate::wrapping::adaptive_wrap_lines;
use base64::Engine;
use codex_app_server_protocol::McpServerStatus;
use codex_core::config::Config;
use codex_core::config::types::McpServerTransportConfig;
use codex_core::mcp::McpManager;
@@ -1963,6 +1964,179 @@ pub(crate) fn new_mcp_tools_output(
PlainHistoryCell { lines }
}
/// Build the `/mcp` history cell from app-server `McpServerStatus` responses.
///
/// The server list comes directly from the app-server status response, sorted
/// alphabetically. Local config is only used to enrich returned servers with
/// transport details such as command, URL, cwd, and environment display.
///
/// This mirrors the layout of [`new_mcp_tools_output`] but sources data from
/// the paginated RPC response rather than the in-process `McpManager`.
pub(crate) fn new_mcp_tools_output_from_statuses(
config: &Config,
statuses: &[McpServerStatus],
) -> PlainHistoryCell {
let mut lines: Vec<Line<'static>> = vec![
"/mcp".magenta().into(),
"".into(),
vec!["🔌 ".into(), "MCP Tools".bold()].into(),
"".into(),
];
let mut statuses_by_name = HashMap::new();
for status in statuses {
statuses_by_name.insert(status.name.as_str(), status);
}
let mut server_names: Vec<String> = statuses.iter().map(|status| status.name.clone()).collect();
server_names.sort();
let has_any_tools = statuses.iter().any(|status| !status.tools.is_empty());
if !has_any_tools {
lines.push(" • No MCP tools available.".italic().into());
lines.push("".into());
}
for server in server_names {
let cfg = config.mcp_servers.get().get(server.as_str());
let status = statuses_by_name.get(server.as_str()).copied();
let header: Vec<Span<'static>> = vec![" • ".into(), server.clone().into()];
lines.push(header.into());
let auth_status = status
.map(|status| match status.auth_status {
codex_app_server_protocol::McpAuthStatus::Unsupported => McpAuthStatus::Unsupported,
codex_app_server_protocol::McpAuthStatus::NotLoggedIn => McpAuthStatus::NotLoggedIn,
codex_app_server_protocol::McpAuthStatus::BearerToken => McpAuthStatus::BearerToken,
codex_app_server_protocol::McpAuthStatus::OAuth => McpAuthStatus::OAuth,
})
.unwrap_or(McpAuthStatus::Unsupported);
lines.push(vec![" • Auth: ".into(), auth_status.to_string().into()].into());
if let Some(cfg) = cfg {
match &cfg.transport {
McpServerTransportConfig::Stdio {
command,
args,
env,
env_vars,
cwd,
} => {
let args_suffix = if args.is_empty() {
String::new()
} else {
format!(" {}", args.join(" "))
};
let cmd_display = format!("{command}{args_suffix}");
lines.push(vec![" • Command: ".into(), cmd_display.into()].into());
if let Some(cwd) = cwd.as_ref() {
lines.push(
vec![" • Cwd: ".into(), cwd.display().to_string().into()].into(),
);
}
let env_display = format_env_display(env.as_ref(), env_vars.as_slice());
if env_display != "-" {
lines.push(vec![" • Env: ".into(), env_display.into()].into());
}
}
McpServerTransportConfig::StreamableHttp {
url,
http_headers,
env_http_headers,
..
} => {
lines.push(vec![" • URL: ".into(), url.clone().into()].into());
if let Some(headers) = http_headers.as_ref()
&& !headers.is_empty()
{
let mut pairs: Vec<_> = headers.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
let display = pairs
.into_iter()
.map(|(name, _)| format!("{name}=*****"))
.collect::<Vec<_>>()
.join(", ");
lines.push(vec![" • HTTP headers: ".into(), display.into()].into());
}
if let Some(headers) = env_http_headers.as_ref()
&& !headers.is_empty()
{
let mut pairs: Vec<_> = headers.iter().collect();
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
let display = pairs
.into_iter()
.map(|(name, var)| format!("{name}={var}"))
.collect::<Vec<_>>()
.join(", ");
lines.push(vec![" • Env HTTP headers: ".into(), display.into()].into());
}
}
}
}
let mut names = status
.map(|status| status.tools.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
names.sort();
if names.is_empty() {
lines.push(" • Tools: (none)".into());
} else {
lines.push(vec![" • Tools: ".into(), names.join(", ").into()].into());
}
let server_resources = status
.map(|status| status.resources.clone())
.unwrap_or_default();
if server_resources.is_empty() {
lines.push(" • Resources: (none)".into());
} else {
let mut spans: Vec<Span<'static>> = vec![" • Resources: ".into()];
for (idx, resource) in server_resources.iter().enumerate() {
if idx > 0 {
spans.push(", ".into());
}
let label = resource.title.as_ref().unwrap_or(&resource.name);
spans.push(label.clone().into());
spans.push(" ".into());
spans.push(format!("({})", resource.uri).dim());
}
lines.push(spans.into());
}
let server_templates = status
.map(|status| status.resource_templates.clone())
.unwrap_or_default();
if server_templates.is_empty() {
lines.push(" • Resource templates: (none)".into());
} else {
let mut spans: Vec<Span<'static>> = vec![" • Resource templates: ".into()];
for (idx, template) in server_templates.iter().enumerate() {
if idx > 0 {
spans.push(", ".into());
}
let label = template.title.as_ref().unwrap_or(&template.name);
spans.push(label.clone().into());
spans.push(" ".into());
spans.push(format!("({})", template.uri_template).dim());
}
lines.push(spans.into());
}
lines.push(Line::from(""));
}
PlainHistoryCell { lines }
}
pub(crate) fn new_info_event(message: String, hint: Option<String>) -> PlainHistoryCell {
let mut line = vec!["• ".dim(), message.into()];
if let Some(hint) = hint {
@@ -1981,6 +2155,54 @@ pub(crate) fn new_error_event(message: String) -> PlainHistoryCell {
PlainHistoryCell { lines }
}
/// A transient history cell that shows an animated spinner while the MCP
/// inventory RPC is in flight.
///
/// Inserted as the `active_cell` by `ChatWidget::add_mcp_output()` and removed
/// once the fetch completes. The app removes committed copies from transcript
/// history, while `ChatWidget::clear_mcp_inventory_loading()` only clears the
/// in-flight `active_cell`.
#[derive(Debug)]
pub(crate) struct McpInventoryLoadingCell {
start_time: Instant,
animations_enabled: bool,
}
impl McpInventoryLoadingCell {
pub(crate) fn new(animations_enabled: bool) -> Self {
Self {
start_time: Instant::now(),
animations_enabled,
}
}
}
impl HistoryCell for McpInventoryLoadingCell {
fn display_lines(&self, _width: u16) -> Vec<Line<'static>> {
vec![
vec![
spinner(Some(self.start_time), self.animations_enabled),
" ".into(),
"Loading MCP inventory".bold(),
"…".dim(),
]
.into(),
]
}
fn transcript_animation_tick(&self) -> Option<u64> {
if !self.animations_enabled {
return None;
}
Some((self.start_time.elapsed().as_millis() / 50) as u64)
}
}
/// Convenience constructor for [`McpInventoryLoadingCell`].
pub(crate) fn new_mcp_inventory_loading(animations_enabled: bool) -> McpInventoryLoadingCell {
McpInventoryLoadingCell::new(animations_enabled)
}
/// Renders a completed (or interrupted) request_user_input exchange in history.
#[derive(Debug)]
pub(crate) struct RequestUserInputResultCell {
@@ -2542,6 +2764,7 @@ mod tests {
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::types::McpServerConfig;
use codex_core::config::types::McpServerDisabledReason;
use codex_core::config::types::McpServerTransportConfig;
use codex_otel::RuntimeMetricTotals;
use codex_otel::RuntimeMetricsSummary;
@@ -2961,6 +3184,61 @@ mod tests {
insta::assert_snapshot!(rendered);
}
#[tokio::test]
async fn mcp_tools_output_from_statuses_renders_status_only_servers() {
let mut config = test_config().await;
let servers = HashMap::from([(
"plugin_docs".to_string(),
McpServerConfig {
transport: McpServerTransportConfig::Stdio {
command: "docs-server".to_string(),
args: vec!["--stdio".to_string()],
env: None,
env_vars: vec![],
cwd: None,
},
enabled: false,
required: false,
disabled_reason: Some(McpServerDisabledReason::Unknown),
startup_timeout_sec: None,
tool_timeout_sec: None,
enabled_tools: None,
disabled_tools: None,
scopes: None,
oauth_resource: None,
},
)]);
config
.mcp_servers
.set(servers)
.expect("test mcp servers should accept any configuration");
let statuses = vec![McpServerStatus {
name: "plugin_docs".to_string(),
tools: HashMap::from([(
"lookup".to_string(),
Tool {
description: None,
name: "lookup".to_string(),
title: None,
input_schema: serde_json::json!({"type": "object", "properties": {}}),
output_schema: None,
annotations: None,
icons: None,
meta: None,
},
)]),
resources: Vec::new(),
resource_templates: Vec::new(),
auth_status: codex_app_server_protocol::McpAuthStatus::Unsupported,
}];
let cell = new_mcp_tools_output_from_statuses(&config, &statuses);
let rendered = render_lines(&cell.display_lines(120)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn empty_agent_message_cell_transcript() {
let cell = AgentMessageCell::new(vec![Line::default()], false);
@@ -3188,6 +3466,14 @@ mod tests {
insta::assert_snapshot!(rendered);
}
#[test]
fn mcp_inventory_loading_snapshot() {
let cell = new_mcp_inventory_loading(/*animations_enabled*/ true);
let rendered = render_lines(&cell.display_lines(80)).join("\n");
insta::assert_snapshot!(rendered);
}
#[test]
fn completed_mcp_tool_call_success_snapshot() {
let invocation = McpInvocation {
+1
View File
@@ -100,6 +100,7 @@ pub mod insert_history;
mod key_hint;
mod line_truncation;
pub mod live_wrap;
mod local_chatgpt_auth;
mod markdown;
mod markdown_render;
mod markdown_stream;
@@ -0,0 +1,195 @@
use std::path::Path;
use codex_app_server_protocol::AuthMode;
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
use codex_core::auth::AuthCredentialsStoreMode;
use codex_core::auth::load_auth_dot_json;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LocalChatgptAuth {
pub(crate) access_token: String,
pub(crate) chatgpt_account_id: String,
pub(crate) chatgpt_plan_type: Option<String>,
}
impl LocalChatgptAuth {
pub(crate) fn to_refresh_response(&self) -> ChatgptAuthTokensRefreshResponse {
ChatgptAuthTokensRefreshResponse {
access_token: self.access_token.clone(),
chatgpt_account_id: self.chatgpt_account_id.clone(),
chatgpt_plan_type: self.chatgpt_plan_type.clone(),
}
}
}
pub(crate) fn load_local_chatgpt_auth(
codex_home: &Path,
auth_credentials_store_mode: AuthCredentialsStoreMode,
forced_chatgpt_workspace_id: Option<&str>,
) -> Result<LocalChatgptAuth, String> {
let auth = load_auth_dot_json(codex_home, auth_credentials_store_mode)
.map_err(|err| format!("failed to load local auth: {err}"))?
.ok_or_else(|| "no local auth available".to_string())?;
if matches!(auth.auth_mode, Some(AuthMode::ApiKey)) || auth.openai_api_key.is_some() {
return Err("local auth is not a ChatGPT login".to_string());
}
let tokens = auth
.tokens
.ok_or_else(|| "local ChatGPT auth is missing token data".to_string())?;
let access_token = tokens.access_token;
let chatgpt_account_id = tokens
.account_id
.or(tokens.id_token.chatgpt_account_id.clone())
.ok_or_else(|| "local ChatGPT auth is missing chatgpt account id".to_string())?;
if let Some(expected_workspace) = forced_chatgpt_workspace_id
&& chatgpt_account_id != expected_workspace
{
return Err(format!(
"local ChatGPT auth must use workspace {expected_workspace}, but found {chatgpt_account_id:?}"
));
}
let chatgpt_plan_type = tokens
.id_token
.get_chatgpt_plan_type()
.map(|plan_type| plan_type.to_ascii_lowercase());
Ok(LocalChatgptAuth {
access_token,
chatgpt_account_id,
chatgpt_plan_type,
})
}
#[cfg(test)]
mod tests {
use super::*;
use base64::Engine;
use chrono::Utc;
use codex_app_server_protocol::AuthMode;
use codex_core::auth::AuthDotJson;
use codex_core::auth::login_with_chatgpt_auth_tokens;
use codex_core::auth::save_auth;
use codex_core::token_data::TokenData;
use pretty_assertions::assert_eq;
use serde::Serialize;
use serde_json::json;
use tempfile::TempDir;
fn fake_jwt(email: &str, account_id: &str, plan_type: &str) -> String {
#[derive(Serialize)]
struct Header {
alg: &'static str,
typ: &'static str,
}
let header = Header {
alg: "none",
typ: "JWT",
};
let payload = json!({
"email": email,
"https://api.openai.com/auth": {
"chatgpt_account_id": account_id,
"chatgpt_plan_type": plan_type,
},
});
let encode = |bytes: &[u8]| base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes);
let header_b64 = encode(&serde_json::to_vec(&header).expect("serialize header"));
let payload_b64 = encode(&serde_json::to_vec(&payload).expect("serialize payload"));
let signature_b64 = encode(b"sig");
format!("{header_b64}.{payload_b64}.{signature_b64}")
}
fn write_chatgpt_auth(codex_home: &Path) {
let id_token = fake_jwt("user@example.com", "workspace-1", "business");
let access_token = fake_jwt("user@example.com", "workspace-1", "business");
let auth = AuthDotJson {
auth_mode: Some(AuthMode::Chatgpt),
openai_api_key: None,
tokens: Some(TokenData {
id_token: codex_core::token_data::parse_chatgpt_jwt_claims(&id_token)
.expect("id token should parse"),
access_token,
refresh_token: "refresh-token".to_string(),
account_id: Some("workspace-1".to_string()),
}),
last_refresh: Some(Utc::now()),
};
save_auth(codex_home, &auth, AuthCredentialsStoreMode::File)
.expect("chatgpt auth should save");
}
#[test]
fn loads_local_chatgpt_auth_from_managed_auth() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path());
let auth = load_local_chatgpt_auth(
codex_home.path(),
AuthCredentialsStoreMode::File,
Some("workspace-1"),
)
.expect("chatgpt auth should load");
assert_eq!(auth.chatgpt_account_id, "workspace-1");
assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business"));
assert!(!auth.access_token.is_empty());
}
#[test]
fn rejects_missing_local_auth() {
let codex_home = TempDir::new().expect("tempdir");
let err = load_local_chatgpt_auth(codex_home.path(), AuthCredentialsStoreMode::File, None)
.expect_err("missing auth should fail");
assert_eq!(err, "no local auth available");
}
#[test]
fn rejects_api_key_auth() {
let codex_home = TempDir::new().expect("tempdir");
save_auth(
codex_home.path(),
&AuthDotJson {
auth_mode: Some(AuthMode::ApiKey),
openai_api_key: Some("sk-test".to_string()),
tokens: None,
last_refresh: None,
},
AuthCredentialsStoreMode::File,
)
.expect("api key auth should save");
let err = load_local_chatgpt_auth(codex_home.path(), AuthCredentialsStoreMode::File, None)
.expect_err("api key auth should fail");
assert_eq!(err, "local auth is not a ChatGPT login");
}
#[test]
fn prefers_managed_auth_over_external_ephemeral_tokens() {
let codex_home = TempDir::new().expect("tempdir");
write_chatgpt_auth(codex_home.path());
login_with_chatgpt_auth_tokens(
codex_home.path(),
&fake_jwt("user@example.com", "workspace-2", "enterprise"),
"workspace-2",
Some("enterprise"),
)
.expect("external auth should save");
let auth = load_local_chatgpt_auth(
codex_home.path(),
AuthCredentialsStoreMode::File,
Some("workspace-1"),
)
.expect("managed auth should win");
assert_eq!(auth.chatgpt_account_id, "workspace-1");
assert_eq!(auth.chatgpt_plan_type.as_deref(), Some("business"));
}
}
+17 -5
View File
@@ -104,8 +104,6 @@ pub(crate) enum SignInOption {
}
const API_KEY_DISABLED_MESSAGE: &str = "API key login is disabled.";
const APP_SERVER_TUI_UNSUPPORTED_MESSAGE: &str = "Not available in app-server TUI yet.";
fn onboarding_request_id() -> codex_app_server_protocol::RequestId {
codex_app_server_protocol::RequestId::String(Uuid::new_v4().to_string())
}
@@ -741,6 +739,7 @@ impl AuthModeWidget {
if matches!(
self.login_status,
LoginStatus::AuthMode(AppServerAuthMode::Chatgpt)
| LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens)
) {
*self.sign_in_state.write().unwrap() = SignInState::ChatGptSuccess;
self.request_frame.schedule_frame();
@@ -799,9 +798,8 @@ impl AuthModeWidget {
return;
}
self.set_error(Some(APP_SERVER_TUI_UNSUPPORTED_MESSAGE.to_string()));
*self.sign_in_state.write().unwrap() = SignInState::PickMode;
self.request_frame.schedule_frame();
self.set_error(/*message*/ None);
headless_chatgpt_login::start_headless_chatgpt_login(self);
}
pub(crate) fn on_account_login_completed(
@@ -978,6 +976,20 @@ mod tests {
assert_eq!(widget.login_status, LoginStatus::NotAuthenticated);
}
#[tokio::test]
async fn existing_chatgpt_auth_tokens_login_counts_as_signed_in() {
let (mut widget, _tmp) = widget_forced_chatgpt().await;
widget.login_status = LoginStatus::AuthMode(AppServerAuthMode::ChatgptAuthTokens);
let handled = widget.handle_existing_chatgpt_login();
assert_eq!(handled, true);
assert!(matches!(
&*widget.sign_in_state.read().unwrap(),
SignInState::ChatGptSuccess
));
}
/// Collects all buffer cell symbols that contain the OSC 8 open sequence
/// for the given URL. Returns the concatenated "inner" characters.
fn collect_osc8_chars(buf: &Buffer, area: Rect, url: &str) -> String {
@@ -1,6 +1,12 @@
#![allow(dead_code)]
use codex_app_server_protocol::ClientRequest;
use codex_app_server_protocol::LoginAccountParams;
use codex_app_server_protocol::LoginAccountResponse;
use codex_core::auth::CLIENT_ID;
use codex_login::ServerOptions;
use codex_login::complete_device_code_login;
use codex_login::request_device_code;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::prelude::Widget;
@@ -13,17 +19,106 @@ use std::sync::Arc;
use std::sync::RwLock;
use tokio::sync::Notify;
use crate::local_chatgpt_auth::LocalChatgptAuth;
use crate::local_chatgpt_auth::load_local_chatgpt_auth;
use crate::shimmer::shimmer_spans;
use crate::tui::FrameRequester;
use super::AuthModeWidget;
use super::ContinueInBrowserState;
use super::ContinueWithDeviceCodeState;
use super::SignInState;
use super::mark_url_hyperlink;
use super::onboarding_request_id;
pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget, opts: ServerOptions) {
let _ = opts;
let _ = widget;
pub(super) fn start_headless_chatgpt_login(widget: &mut AuthModeWidget) {
let mut opts = ServerOptions::new(
widget.codex_home.clone(),
CLIENT_ID.to_string(),
widget.forced_chatgpt_workspace_id.clone(),
widget.cli_auth_credentials_store_mode,
);
opts.open_browser = false;
let sign_in_state = widget.sign_in_state.clone();
let request_frame = widget.request_frame.clone();
let error = widget.error.clone();
let request_handle = widget.app_server_request_handle.clone();
let codex_home = widget.codex_home.clone();
let cli_auth_credentials_store_mode = widget.cli_auth_credentials_store_mode;
let forced_chatgpt_workspace_id = widget.forced_chatgpt_workspace_id.clone();
let cancel = begin_device_code_attempt(&sign_in_state, &request_frame);
tokio::spawn(async move {
let device_code = match request_device_code(&opts).await {
Ok(device_code) => device_code,
Err(err) => {
if err.kind() == std::io::ErrorKind::NotFound {
fallback_to_browser_login(
request_handle,
sign_in_state,
request_frame,
error,
cancel,
)
.await;
} else {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err.to_string(),
);
}
return;
}
};
if !set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::ChatGptDeviceCode(ContinueWithDeviceCodeState {
device_code: Some(device_code.clone()),
cancel: Some(cancel.clone()),
}),
) {
return;
}
tokio::select! {
_ = cancel.notified() => {}
result = complete_device_code_login(opts, device_code) => {
match result {
Ok(()) => {
let local_auth = load_local_chatgpt_auth(
&codex_home,
cli_auth_credentials_store_mode,
forced_chatgpt_workspace_id.as_deref(),
);
handle_chatgpt_auth_tokens_login_result_for_active_attempt(
request_handle,
sign_in_state,
request_frame,
error,
cancel,
local_auth,
).await;
}
Err(err) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err.to_string(),
);
}
}
}
}
});
}
pub(super) fn render_device_code_login(
@@ -151,6 +246,159 @@ fn set_device_code_success_message_for_active_attempt(
true
}
fn set_device_code_error_for_active_attempt(
sign_in_state: &Arc<RwLock<SignInState>>,
request_frame: &FrameRequester,
error: &Arc<RwLock<Option<String>>>,
cancel: &Arc<Notify>,
message: String,
) -> bool {
if !set_device_code_state_for_active_attempt(
sign_in_state,
request_frame,
cancel,
SignInState::PickMode,
) {
return false;
}
*error.write().unwrap() = Some(message);
request_frame.schedule_frame();
true
}
async fn fallback_to_browser_login(
request_handle: codex_app_server_client::AppServerRequestHandle,
sign_in_state: Arc<RwLock<SignInState>>,
request_frame: FrameRequester,
error: Arc<RwLock<Option<String>>>,
cancel: Arc<Notify>,
) {
let should_fallback = {
let guard = sign_in_state.read().unwrap();
device_code_attempt_matches(&guard, &cancel)
};
if !should_fallback {
return;
}
match request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: onboarding_request_id(),
params: LoginAccountParams::Chatgpt,
})
.await
{
Ok(LoginAccountResponse::Chatgpt { login_id, auth_url }) => {
*error.write().unwrap() = None;
let _updated = set_device_code_state_for_active_attempt(
&sign_in_state,
&request_frame,
&cancel,
SignInState::ChatGptContinueInBrowser(ContinueInBrowserState {
login_id,
auth_url,
}),
);
}
Ok(other) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
format!("Unexpected account/login/start response: {other:?}"),
);
}
Err(err) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err.to_string(),
);
}
}
}
async fn handle_chatgpt_auth_tokens_login_result_for_active_attempt(
request_handle: codex_app_server_client::AppServerRequestHandle,
sign_in_state: Arc<RwLock<SignInState>>,
request_frame: FrameRequester,
error: Arc<RwLock<Option<String>>>,
cancel: Arc<Notify>,
local_auth: Result<LocalChatgptAuth, String>,
) {
let local_auth = match local_auth {
Ok(local_auth) => local_auth,
Err(err) => {
set_device_code_error_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
err,
);
return;
}
};
let result = request_handle
.request_typed::<LoginAccountResponse>(ClientRequest::LoginAccount {
request_id: onboarding_request_id(),
params: LoginAccountParams::ChatgptAuthTokens {
access_token: local_auth.access_token,
chatgpt_account_id: local_auth.chatgpt_account_id,
chatgpt_plan_type: local_auth.chatgpt_plan_type,
},
})
.await;
apply_chatgpt_auth_tokens_login_response_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
result.map_err(|err| err.to_string()),
);
}
fn apply_chatgpt_auth_tokens_login_response_for_active_attempt(
sign_in_state: &Arc<RwLock<SignInState>>,
request_frame: &FrameRequester,
error: &Arc<RwLock<Option<String>>>,
cancel: &Arc<Notify>,
result: Result<LoginAccountResponse, String>,
) {
match result {
Ok(LoginAccountResponse::ChatgptAuthTokens {}) => {
*error.write().unwrap() = None;
let _updated = set_device_code_success_message_for_active_attempt(
sign_in_state,
request_frame,
cancel,
);
}
Ok(other) => {
set_device_code_error_for_active_attempt(
sign_in_state,
request_frame,
error,
cancel,
format!("Unexpected account/login/start response: {other:?}"),
);
}
Err(err) => {
set_device_code_error_for_active_attempt(
sign_in_state,
request_frame,
error,
cancel,
err,
);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -269,4 +517,30 @@ mod tests {
SignInState::ChatGptDeviceCode(_)
));
}
#[test]
fn chatgpt_auth_tokens_success_sets_success_message_without_login_id() {
let sign_in_state = device_code_sign_in_state(Arc::new(Notify::new()));
let request_frame = FrameRequester::test_dummy();
let error = Arc::new(RwLock::new(None));
let cancel = match &*sign_in_state.read().unwrap() {
SignInState::ChatGptDeviceCode(state) => {
state.cancel.as_ref().expect("cancel handle").clone()
}
_ => panic!("expected device-code state"),
};
apply_chatgpt_auth_tokens_login_response_for_active_attempt(
&sign_in_state,
&request_frame,
&error,
&cancel,
Ok(LoginAccountResponse::ChatgptAuthTokens {}),
);
assert!(matches!(
&*sign_in_state.read().unwrap(),
SignInState::ChatGptSuccessMessage
));
}
}
@@ -0,0 +1,6 @@
---
source: tui_app_server/src/history_cell.rs
assertion_line: 3477
expression: rendered
---
• Loading MCP inventory…
@@ -0,0 +1,14 @@
---
source: tui_app_server/src/history_cell.rs
expression: rendered
---
/mcp
🔌 MCP Tools
• plugin_docs
• Auth: Unsupported
• Command: docs-server --stdio
• Tools: lookup
• Resources: (none)
• Resource templates: (none)
@@ -9,6 +9,7 @@
mod proc_thread_attr;
use self::proc_thread_attr::ProcThreadAttributeList;
use crate::desktop::LaunchDesktop;
use crate::winutil::format_last_error;
use crate::winutil::quote_windows_arg;
use crate::winutil::to_wide;
@@ -36,6 +37,7 @@ pub struct ConptyInstance {
pub hpc: HANDLE,
pub input_write: HANDLE,
pub output_read: HANDLE,
_desktop: LaunchDesktop,
}
impl Drop for ConptyInstance {
@@ -74,6 +76,7 @@ pub fn create_conpty(cols: i16, rows: i16) -> Result<ConptyInstance> {
hpc: hpc as HANDLE,
input_write: input_write as HANDLE,
output_read: output_read as HANDLE,
_desktop: LaunchDesktop::prepare(false, None)?,
})
}
@@ -86,6 +89,8 @@ pub fn spawn_conpty_process_as_user(
argv: &[String],
cwd: &Path,
env_map: &HashMap<String, String>,
use_private_desktop: bool,
logs_base_dir: Option<&Path>,
) -> Result<(PROCESS_INFORMATION, ConptyInstance)> {
let cmdline_str = argv
.iter()
@@ -100,8 +105,8 @@ pub fn spawn_conpty_process_as_user(
si.StartupInfo.hStdInput = INVALID_HANDLE_VALUE;
si.StartupInfo.hStdOutput = INVALID_HANDLE_VALUE;
si.StartupInfo.hStdError = INVALID_HANDLE_VALUE;
let desktop = to_wide("Winsta0\\Default");
si.StartupInfo.lpDesktop = desktop.as_ptr() as *mut u16;
let desktop = LaunchDesktop::prepare(use_private_desktop, logs_base_dir)?;
si.StartupInfo.lpDesktop = desktop.startup_info_desktop();
let conpty = create_conpty(80, 24)?;
let mut attrs = ProcThreadAttributeList::new(1)?;
@@ -135,5 +140,7 @@ pub fn spawn_conpty_process_as_user(
env_block.len()
));
}
let mut conpty = conpty;
conpty._desktop = desktop;
Ok((pi, conpty))
}
@@ -13,7 +13,6 @@ use anyhow::Context;
use anyhow::Result;
use codex_windows_sandbox::allow_null_device;
use codex_windows_sandbox::convert_string_sid_to_sid;
use codex_windows_sandbox::create_process_as_user;
use codex_windows_sandbox::create_readonly_token_with_caps_from;
use codex_windows_sandbox::create_workspace_write_token_with_caps_from;
use codex_windows_sandbox::get_current_token_for_restriction;
@@ -37,8 +36,6 @@ use codex_windows_sandbox::PipeSpawnHandles;
use codex_windows_sandbox::SandboxPolicy;
use codex_windows_sandbox::StderrMode;
use codex_windows_sandbox::StdinMode;
use serde::Deserialize;
use std::collections::HashMap;
use std::ffi::c_void;
use std::fs::File;
use std::os::windows::io::FromRawHandle;
@@ -77,22 +74,6 @@ mod cwd_junction;
#[path = "../read_acl_mutex.rs"]
mod read_acl_mutex;
#[derive(Debug, Deserialize)]
struct RunnerRequest {
policy_json_or_preset: String,
codex_home: PathBuf,
real_codex_home: PathBuf,
cap_sids: Vec<String>,
command: Vec<String>,
cwd: PathBuf,
env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
use_private_desktop: bool,
stdin_pipe: String,
stdout_pipe: String,
stderr_pipe: String,
}
const WAIT_TIMEOUT: u32 = 0x0000_0102;
struct IpcSpawnedProcess {
@@ -144,13 +125,6 @@ fn open_pipe(name: &str, access: u32) -> Result<HANDLE> {
Ok(handle)
}
fn read_request_file(req_path: &Path) -> Result<String> {
let content = std::fs::read_to_string(req_path)
.with_context(|| format!("read request file {}", req_path.display()));
let _ = std::fs::remove_file(req_path);
content
}
/// Send an error frame back to the parent process.
fn send_error(writer: &Arc<StdMutex<File>>, code: &str, message: String) -> Result<()> {
let msg = FramedMessage {
@@ -288,6 +262,8 @@ fn spawn_ipc_process(
&req.command,
&effective_cwd,
&req.env,
req.use_private_desktop,
Some(log_dir.as_path()),
)?;
let (hpc, input_write, output_read) = conpty.into_raw();
hpc_handle = Some(hpc);
@@ -318,6 +294,7 @@ fn spawn_ipc_process(
&req.env,
stdin_mode,
StderrMode::Separate,
false,
)?;
(
pipe_handles.process,
@@ -435,175 +412,15 @@ fn spawn_input_loop(
/// Entry point for the Windows command runner process.
pub fn main() -> Result<()> {
let mut request_file = None;
let mut pipe_in = None;
let mut pipe_out = None;
let mut pipe_single = None;
for arg in std::env::args().skip(1) {
if let Some(rest) = arg.strip_prefix("--request-file=") {
request_file = Some(rest.to_string());
} else if let Some(rest) = arg.strip_prefix("--pipe-in=") {
if let Some(rest) = arg.strip_prefix("--pipe-in=") {
pipe_in = Some(rest.to_string());
} else if let Some(rest) = arg.strip_prefix("--pipe-out=") {
pipe_out = Some(rest.to_string());
} else if let Some(rest) = arg.strip_prefix("--pipe=") {
pipe_single = Some(rest.to_string());
}
}
if pipe_in.is_none() && pipe_out.is_none() {
if let Some(single) = pipe_single {
pipe_in = Some(single.clone());
pipe_out = Some(single);
}
}
if let Some(request_file) = request_file {
let req_path = PathBuf::from(request_file);
let input = read_request_file(&req_path)?;
let req: RunnerRequest =
serde_json::from_str(&input).context("parse runner request json")?;
let log_dir = Some(req.codex_home.as_path());
hide_current_user_profile_dir(req.codex_home.as_path());
log_note(
&format!(
"runner start cwd={} cmd={:?} real_codex_home={}",
req.cwd.display(),
req.command,
req.real_codex_home.display()
),
Some(&req.codex_home),
);
let policy =
parse_policy(&req.policy_json_or_preset).context("parse policy_json_or_preset")?;
if !policy.has_full_disk_read_access() {
anyhow::bail!(
"Restricted read-only access is not yet supported by the Windows sandbox backend"
);
}
let mut cap_psids: Vec<*mut c_void> = Vec::new();
for sid in &req.cap_sids {
let Some(psid) = (unsafe { convert_string_sid_to_sid(sid) }) else {
anyhow::bail!("ConvertStringSidToSidW failed for capability SID");
};
cap_psids.push(psid);
}
if cap_psids.is_empty() {
anyhow::bail!("runner: empty capability SID list");
}
let base = unsafe { get_current_token_for_restriction()? };
let token_res: Result<HANDLE> = unsafe {
match &policy {
SandboxPolicy::ReadOnly { .. } => {
create_readonly_token_with_caps_from(base, &cap_psids)
}
SandboxPolicy::WorkspaceWrite { .. } => {
create_workspace_write_token_with_caps_from(base, &cap_psids)
}
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. } => {
unreachable!()
}
}
};
let h_token = token_res?;
unsafe {
CloseHandle(base);
for psid in &cap_psids {
allow_null_device(*psid);
}
for psid in cap_psids {
if !psid.is_null() {
LocalFree(psid as HLOCAL);
}
}
}
let h_stdin = open_pipe(&req.stdin_pipe, FILE_GENERIC_READ)?;
let h_stdout = open_pipe(&req.stdout_pipe, FILE_GENERIC_WRITE)?;
let h_stderr = open_pipe(&req.stderr_pipe, FILE_GENERIC_WRITE)?;
let stdio = Some((h_stdin, h_stdout, h_stderr));
let effective_cwd = effective_cwd(&req.cwd, log_dir);
log_note(
&format!(
"runner: effective cwd={} (requested {})",
effective_cwd.display(),
req.cwd.display()
),
log_dir,
);
let spawn_result = unsafe {
create_process_as_user(
h_token,
&req.command,
&effective_cwd,
&req.env_map,
Some(&req.codex_home),
stdio,
req.use_private_desktop,
)
};
let created = match spawn_result {
Ok(v) => v,
Err(err) => {
log_note(&format!("runner: spawn failed: {err:?}"), log_dir);
unsafe {
CloseHandle(h_stdin);
CloseHandle(h_stdout);
CloseHandle(h_stderr);
CloseHandle(h_token);
}
return Err(err);
}
};
let proc_info = created.process_info;
let h_job = unsafe { create_job_kill_on_close().ok() };
if let Some(job) = h_job {
unsafe {
let _ = AssignProcessToJobObject(job, proc_info.hProcess);
}
}
let wait_res = unsafe {
WaitForSingleObject(
proc_info.hProcess,
req.timeout_ms.map(|ms| ms as u32).unwrap_or(INFINITE),
)
};
let timed_out = wait_res == WAIT_TIMEOUT;
let exit_code: i32;
unsafe {
if timed_out {
let _ = TerminateProcess(proc_info.hProcess, 1);
exit_code = 128 + 64;
} else {
let mut raw_exit: u32 = 1;
GetExitCodeProcess(proc_info.hProcess, &mut raw_exit);
exit_code = raw_exit as i32;
}
if proc_info.hThread != 0 {
CloseHandle(proc_info.hThread);
}
if proc_info.hProcess != 0 {
CloseHandle(proc_info.hProcess);
}
CloseHandle(h_stdin);
CloseHandle(h_stdout);
CloseHandle(h_stderr);
CloseHandle(h_token);
if let Some(job) = h_job {
CloseHandle(job);
}
}
if exit_code != 0 {
eprintln!("runner child exited with code {exit_code}");
}
std::process::exit(exit_code);
}
let Some(pipe_in) = pipe_in else {
anyhow::bail!("runner: no pipe-in provided");
@@ -62,6 +62,8 @@ pub struct SpawnRequest {
pub tty: bool,
#[serde(default)]
pub stdin_open: bool,
#[serde(default)]
pub use_private_desktop: bool,
}
/// Ack from runner after it spawns the child process.
+118 -161
View File
@@ -9,6 +9,13 @@ mod windows_impl {
use crate::helper_materialization::resolve_helper_for_launch;
use crate::helper_materialization::HelperExecutable;
use crate::identity::require_logon_sandbox_creds;
use crate::ipc_framed::decode_bytes;
use crate::ipc_framed::read_frame;
use crate::ipc_framed::write_frame;
use crate::ipc_framed::FramedMessage;
use crate::ipc_framed::Message;
use crate::ipc_framed::OutputStream;
use crate::ipc_framed::SpawnRequest;
use crate::logging::log_failure;
use crate::logging::log_note;
use crate::logging::log_start;
@@ -26,8 +33,9 @@ mod windows_impl {
use rand::SeedableRng;
use std::collections::HashMap;
use std::ffi::c_void;
use std::fs;
use std::fs::File;
use std::io;
use std::os::windows::io::FromRawHandle;
use std::path::Path;
use std::path::PathBuf;
use std::ptr;
@@ -40,15 +48,12 @@ mod windows_impl {
use windows_sys::Win32::System::Diagnostics::Debug::SetErrorMode;
use windows_sys::Win32::System::Pipes::ConnectNamedPipe;
use windows_sys::Win32::System::Pipes::CreateNamedPipeW;
// PIPE_ACCESS_DUPLEX is 0x00000003; not exposed in windows-sys 0.52, so use the value directly.
const PIPE_ACCESS_DUPLEX: u32 = 0x0000_0003;
const PIPE_ACCESS_INBOUND: u32 = 0x0000_0001;
const PIPE_ACCESS_OUTBOUND: u32 = 0x0000_0002;
use windows_sys::Win32::System::Pipes::PIPE_READMODE_BYTE;
use windows_sys::Win32::System::Pipes::PIPE_TYPE_BYTE;
use windows_sys::Win32::System::Pipes::PIPE_WAIT;
use windows_sys::Win32::System::Threading::CreateProcessWithLogonW;
use windows_sys::Win32::System::Threading::GetExitCodeProcess;
use windows_sys::Win32::System::Threading::WaitForSingleObject;
use windows_sys::Win32::System::Threading::INFINITE;
use windows_sys::Win32::System::Threading::LOGON_WITH_PROFILE;
use windows_sys::Win32::System::Threading::PROCESS_INFORMATION;
use windows_sys::Win32::System::Threading::STARTUPINFOW;
@@ -183,24 +188,16 @@ mod windows_impl {
pub use crate::windows_impl::CaptureResult;
#[derive(serde::Serialize)]
struct RunnerPayload {
policy_json_or_preset: String,
sandbox_policy_cwd: PathBuf,
// Writable log dir for sandbox user (.codex in sandbox profile).
codex_home: PathBuf,
// Real user's CODEX_HOME for shared data (caps, config).
real_codex_home: PathBuf,
cap_sids: Vec<String>,
request_file: Option<PathBuf>,
command: Vec<String>,
cwd: PathBuf,
env_map: HashMap<String, String>,
timeout_ms: Option<u64>,
use_private_desktop: bool,
stdin_pipe: String,
stdout_pipe: String,
stderr_pipe: String,
fn read_spawn_ready(pipe_read: &mut File) -> Result<()> {
let msg = read_frame(pipe_read)?
.ok_or_else(|| anyhow::anyhow!("runner pipe closed before spawn_ready"))?;
match msg.message {
Message::SpawnReady { .. } => Ok(()),
Message::Error { payload } => Err(anyhow::anyhow!("runner error: {}", payload.message)),
other => Err(anyhow::anyhow!(
"expected spawn_ready from runner, got {other:?}"
)),
}
}
/// Launches the command runner under the sandbox user and captures its output.
@@ -271,25 +268,10 @@ mod windows_impl {
allow_null_device(psid_to_use);
}
// Prepare named pipes for runner.
let stdin_name = pipe_name("stdin");
let stdout_name = pipe_name("stdout");
let stderr_name = pipe_name("stderr");
let h_stdin_pipe = create_named_pipe(
&stdin_name,
PIPE_ACCESS_DUPLEX | PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
&sandbox_sid,
)?;
let h_stdout_pipe = create_named_pipe(
&stdout_name,
PIPE_ACCESS_DUPLEX | PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
&sandbox_sid,
)?;
let h_stderr_pipe = create_named_pipe(
&stderr_name,
PIPE_ACCESS_DUPLEX | PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
&sandbox_sid,
)?;
let pipe_in_name = pipe_name("in");
let pipe_out_name = pipe_name("out");
let h_pipe_in = create_named_pipe(&pipe_in_name, PIPE_ACCESS_OUTBOUND, &sandbox_sid)?;
let h_pipe_out = create_named_pipe(&pipe_out_name, PIPE_ACCESS_INBOUND, &sandbox_sid)?;
// Launch runner as sandbox user via CreateProcessWithLogonW.
let runner_exe = find_runner_exe(codex_home, logs_base_dir);
@@ -297,40 +279,11 @@ mod windows_impl {
.to_str()
.map(|s| s.to_string())
.unwrap_or_else(|| "codex-command-runner.exe".to_string());
// Write request to a file under the sandbox base dir for the runner to read.
// TODO(iceweasel) - use a different mechanism for invoking the runner.
let base_tmp = sandbox_base.join("requests");
std::fs::create_dir_all(&base_tmp)?;
let mut rng = SmallRng::from_entropy();
let req_file = base_tmp.join(format!("request-{:x}.json", rng.gen::<u128>()));
let payload = RunnerPayload {
policy_json_or_preset: policy_json_or_preset.to_string(),
sandbox_policy_cwd: sandbox_policy_cwd.to_path_buf(),
codex_home: sandbox_base.clone(),
real_codex_home: codex_home.to_path_buf(),
cap_sids: cap_sids.clone(),
request_file: Some(req_file.clone()),
command: command.clone(),
cwd: cwd.to_path_buf(),
env_map: env_map.clone(),
timeout_ms,
use_private_desktop,
stdin_pipe: stdin_name.clone(),
stdout_pipe: stdout_name.clone(),
stderr_pipe: stderr_name.clone(),
};
let payload_json = serde_json::to_string(&payload)?;
if let Err(e) = fs::write(&req_file, &payload_json) {
log_note(
&format!("error writing request file {}: {}", req_file.display(), e),
logs_base_dir,
);
return Err(e.into());
}
let runner_full_cmd = format!(
"{} {}",
"{} {} {}",
quote_windows_arg(&runner_cmdline),
quote_windows_arg(&format!("--request-file={}", req_file.display()))
quote_windows_arg(&format!("--pipe-in={pipe_in_name}")),
quote_windows_arg(&format!("--pipe-out={pipe_out_name}"))
);
let mut cmdline_vec: Vec<u16> = to_wide(&runner_full_cmd);
let exe_w: Vec<u16> = to_wide(&runner_cmdline);
@@ -390,73 +343,99 @@ mod windows_impl {
return Err(anyhow::anyhow!("CreateProcessWithLogonW failed: {}", err));
}
// Pipes are no longer passed as std handles; no stdin payload is sent.
connect_pipe(h_stdin_pipe)?;
connect_pipe(h_stdout_pipe)?;
connect_pipe(h_stderr_pipe)?;
unsafe {
CloseHandle(h_stdin_pipe);
if let Err(err) = connect_pipe(h_pipe_in) {
unsafe {
CloseHandle(h_pipe_in);
CloseHandle(h_pipe_out);
if pi.hThread != 0 {
CloseHandle(pi.hThread);
}
if pi.hProcess != 0 {
CloseHandle(pi.hProcess);
}
}
return Err(err.into());
}
if let Err(err) = connect_pipe(h_pipe_out) {
unsafe {
CloseHandle(h_pipe_in);
CloseHandle(h_pipe_out);
if pi.hThread != 0 {
CloseHandle(pi.hThread);
}
if pi.hProcess != 0 {
CloseHandle(pi.hProcess);
}
}
return Err(err.into());
}
// Read stdout/stderr.
let (tx_out, rx_out) = std::sync::mpsc::channel::<Vec<u8>>();
let (tx_err, rx_err) = std::sync::mpsc::channel::<Vec<u8>>();
let t_out = std::thread::spawn(move || {
let mut buf = Vec::new();
let mut tmp = [0u8; 8192];
loop {
let mut read_bytes: u32 = 0;
let ok = unsafe {
windows_sys::Win32::Storage::FileSystem::ReadFile(
h_stdout_pipe,
tmp.as_mut_ptr(),
tmp.len() as u32,
&mut read_bytes,
std::ptr::null_mut(),
)
};
if ok == 0 || read_bytes == 0 {
break;
}
buf.extend_from_slice(&tmp[..read_bytes as usize]);
}
let _ = tx_out.send(buf);
});
let t_err = std::thread::spawn(move || {
let mut buf = Vec::new();
let mut tmp = [0u8; 8192];
loop {
let mut read_bytes: u32 = 0;
let ok = unsafe {
windows_sys::Win32::Storage::FileSystem::ReadFile(
h_stderr_pipe,
tmp.as_mut_ptr(),
tmp.len() as u32,
&mut read_bytes,
std::ptr::null_mut(),
)
};
if ok == 0 || read_bytes == 0 {
break;
}
buf.extend_from_slice(&tmp[..read_bytes as usize]);
}
let _ = tx_err.send(buf);
});
let result = (|| -> Result<CaptureResult> {
let mut pipe_write = unsafe { File::from_raw_handle(h_pipe_in as _) };
let mut pipe_read = unsafe { File::from_raw_handle(h_pipe_out as _) };
let timeout = timeout_ms.map(|ms| ms as u32).unwrap_or(INFINITE);
let res = unsafe { WaitForSingleObject(pi.hProcess, timeout) };
let timed_out = res == 0x0000_0102;
let mut exit_code_u32: u32 = 1;
if !timed_out {
unsafe {
GetExitCodeProcess(pi.hProcess, &mut exit_code_u32);
let spawn_request = FramedMessage {
version: 1,
message: Message::SpawnRequest {
payload: Box::new(SpawnRequest {
command: command.clone(),
cwd: cwd.to_path_buf(),
env: env_map.clone(),
policy_json_or_preset: policy_json_or_preset.to_string(),
sandbox_policy_cwd: sandbox_policy_cwd.to_path_buf(),
codex_home: sandbox_base.clone(),
real_codex_home: codex_home.to_path_buf(),
cap_sids,
timeout_ms,
tty: false,
stdin_open: false,
use_private_desktop,
}),
},
};
write_frame(&mut pipe_write, &spawn_request)?;
read_spawn_ready(&mut pipe_read)?;
drop(pipe_write);
let mut stdout = Vec::new();
let mut stderr = Vec::new();
let (exit_code, timed_out) = loop {
let msg = read_frame(&mut pipe_read)?
.ok_or_else(|| anyhow::anyhow!("runner pipe closed before exit"))?;
match msg.message {
Message::SpawnReady { .. } => {}
Message::Output { payload } => {
let bytes = decode_bytes(&payload.data_b64)?;
match payload.stream {
OutputStream::Stdout => stdout.extend_from_slice(&bytes),
OutputStream::Stderr => stderr.extend_from_slice(&bytes),
}
}
Message::Exit { payload } => break (payload.exit_code, payload.timed_out),
Message::Error { payload } => {
return Err(anyhow::anyhow!("runner error: {}", payload.message));
}
other => {
return Err(anyhow::anyhow!(
"unexpected runner message during capture: {other:?}"
));
}
}
};
if exit_code == 0 {
log_success(&command, logs_base_dir);
} else {
log_failure(&command, &format!("exit code {}", exit_code), logs_base_dir);
}
} else {
unsafe {
windows_sys::Win32::System::Threading::TerminateProcess(pi.hProcess, 1);
}
}
Ok(CaptureResult {
exit_code,
stdout,
stderr,
timed_out,
})
})();
unsafe {
if pi.hThread != 0 {
@@ -465,31 +444,9 @@ mod windows_impl {
if pi.hProcess != 0 {
CloseHandle(pi.hProcess);
}
CloseHandle(h_stdout_pipe);
CloseHandle(h_stderr_pipe);
}
let _ = t_out.join();
let _ = t_err.join();
let stdout = rx_out.recv().unwrap_or_default();
let stderr = rx_err.recv().unwrap_or_default();
let exit_code = if timed_out {
128 + 64
} else {
exit_code_u32 as i32
};
if exit_code == 0 {
log_success(&command, logs_base_dir);
} else {
log_failure(&command, &format!("exit code {}", exit_code), logs_base_dir);
}
Ok(CaptureResult {
exit_code,
stdout,
stderr,
timed_out,
})
result
}
#[cfg(test)]
+12 -2
View File
@@ -189,6 +189,7 @@ pub fn spawn_process_with_pipes(
env_map: &HashMap<String, String>,
stdin_mode: StdinMode,
stderr_mode: StderrMode,
use_private_desktop: bool,
) -> Result<PipeSpawnHandles> {
let mut in_r: HANDLE = 0;
let mut in_w: HANDLE = 0;
@@ -222,8 +223,17 @@ pub fn spawn_process_with_pipes(
};
let stdio = Some((in_r, out_w, stderr_handle));
let spawn_result =
unsafe { create_process_as_user(h_token, argv, cwd, env_map, None, stdio, false) };
let spawn_result = unsafe {
create_process_as_user(
h_token,
argv,
cwd,
env_map,
None,
stdio,
use_private_desktop,
)
};
let created = match spawn_result {
Ok(v) => v,
Err(err) => {

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