mirror of
https://github.com/openai/codex.git
synced 2026-11-10 21:00:47 +00:00
Merge 9c1e039140 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -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"
|
||||
|
||||
+12
-159
@@ -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, };
|
||||
|
||||
+1
-1
@@ -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, };
|
||||
+2
-1
@@ -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(¬ification);
|
||||
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.
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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(_)
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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(_)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
¶ms,
|
||||
)
|
||||
})
|
||||
.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();
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
---
|
||||
source: tui_app_server/src/history_cell.rs
|
||||
assertion_line: 3477
|
||||
expression: rendered
|
||||
---
|
||||
• Loading MCP inventory…
|
||||
+14
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user