diff --git a/codex-rs/app-server-protocol/schema/json/ServerNotification.json b/codex-rs/app-server-protocol/schema/json/ServerNotification.json index aa320f8b4..38acf1c90 100644 --- a/codex-rs/app-server-protocol/schema/json/ServerNotification.json +++ b/codex-rs/app-server-protocol/schema/json/ServerNotification.json @@ -480,6 +480,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", @@ -2260,6 +2261,34 @@ ], "type": "object" }, + "ModelVerification": { + "enum": [ + "trustedAccessForCyber" + ], + "type": "string" + }, + "ModelVerificationNotification": { + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "verifications": { + "items": { + "$ref": "#/definitions/ModelVerification" + }, + "type": "array" + } + }, + "required": [ + "threadId", + "turnId", + "verifications" + ], + "type": "object" + }, "NetworkApprovalProtocol": { "enum": [ "http", @@ -5339,6 +5368,26 @@ "title": "Model/reroutedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/verification" + ], + "title": "Model/verificationNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelVerificationNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/verificationNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index 5e5269bfb..621385757 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -4424,6 +4424,26 @@ "title": "Model/reroutedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/verification" + ], + "title": "Model/verificationNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/v2/ModelVerificationNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/verificationNotification", + "type": "object" + }, { "properties": { "method": { @@ -5985,6 +6005,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", @@ -10893,6 +10914,36 @@ ], "type": "object" }, + "ModelVerification": { + "enum": [ + "trustedAccessForCyber" + ], + "type": "string" + }, + "ModelVerificationNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "verifications": { + "items": { + "$ref": "#/definitions/v2/ModelVerification" + }, + "type": "array" + } + }, + "required": [ + "threadId", + "turnId", + "verifications" + ], + "title": "ModelVerificationNotification", + "type": "object" + }, "NetworkAccess": { "enum": [ "restricted", diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index aaf085662..2521afda6 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -2565,6 +2565,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", @@ -7628,6 +7629,36 @@ ], "type": "object" }, + "ModelVerification": { + "enum": [ + "trustedAccessForCyber" + ], + "type": "string" + }, + "ModelVerificationNotification": { + "$schema": "http://json-schema.org/draft-07/schema#", + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "verifications": { + "items": { + "$ref": "#/definitions/ModelVerification" + }, + "type": "array" + } + }, + "required": [ + "threadId", + "turnId", + "verifications" + ], + "title": "ModelVerificationNotification", + "type": "object" + }, "NetworkAccess": { "enum": [ "restricted", @@ -10941,6 +10972,26 @@ "title": "Model/reroutedNotification", "type": "object" }, + { + "properties": { + "method": { + "enum": [ + "model/verification" + ], + "title": "Model/verificationNotificationMethod", + "type": "string" + }, + "params": { + "$ref": "#/definitions/ModelVerificationNotification" + } + }, + "required": [ + "method", + "params" + ], + "title": "Model/verificationNotification", + "type": "object" + }, { "properties": { "method": { diff --git a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json index 032743d55..fd55d0876 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ErrorNotification.json @@ -9,6 +9,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ModelVerificationNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ModelVerificationNotification.json new file mode 100644 index 000000000..aea6b628e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/json/v2/ModelVerificationNotification.json @@ -0,0 +1,32 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "definitions": { + "ModelVerification": { + "enum": [ + "trustedAccessForCyber" + ], + "type": "string" + } + }, + "properties": { + "threadId": { + "type": "string" + }, + "turnId": { + "type": "string" + }, + "verifications": { + "items": { + "$ref": "#/definitions/ModelVerification" + }, + "type": "array" + } + }, + "required": [ + "threadId", + "turnId", + "verifications" + ], + "title": "ModelVerificationNotification", + "type": "object" +} \ No newline at end of file diff --git a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json index e8500d7ae..16abcd780 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ReviewStartResponse.json @@ -32,6 +32,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json index f62d60025..47677da41 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -94,6 +94,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json index d3da9b7b4..2f5cbb950 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadListResponse.json @@ -35,6 +35,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json index 33207ca11..b9ae59708 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadMetadataUpdateResponse.json @@ -35,6 +35,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json index 9ba440652..cda474c29 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadReadResponse.json @@ -35,6 +35,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json index 710a701f9..38b0eb0d3 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -94,6 +94,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json index b531ed047..e5339f4e9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadRollbackResponse.json @@ -35,6 +35,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json index 9fe3bc62b..879fa5c68 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -94,6 +94,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json index 165b4a310..774686e46 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartedNotification.json @@ -35,6 +35,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json index 8d4ee72e1..eaa3becde 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadTurnsListResponse.json @@ -32,6 +32,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json index 0b414ef42..64179af7e 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadUnarchiveResponse.json @@ -35,6 +35,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json index 05395f4f6..0739fa31b 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnCompletedNotification.json @@ -32,6 +32,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json index c818dd8c7..bc5917ef1 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartResponse.json @@ -32,6 +32,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json index 3c8eafcdb..22ad85d90 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartedNotification.json @@ -32,6 +32,7 @@ "contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", + "cyberPolicy", "internalServerError", "unauthorized", "badRequest", diff --git a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts index 614dd611d..031527e3a 100644 --- a/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts +++ b/codex-rs/app-server-protocol/schema/typescript/ServerNotification.ts @@ -29,6 +29,7 @@ import type { McpServerOauthLoginCompletedNotification } from "./v2/McpServerOau import type { McpServerStatusUpdatedNotification } from "./v2/McpServerStatusUpdatedNotification"; import type { McpToolCallProgressNotification } from "./v2/McpToolCallProgressNotification"; import type { ModelReroutedNotification } from "./v2/ModelReroutedNotification"; +import type { ModelVerificationNotification } from "./v2/ModelVerificationNotification"; import type { PlanDeltaNotification } from "./v2/PlanDeltaNotification"; import type { RawResponseItemCompletedNotification } from "./v2/RawResponseItemCompletedNotification"; import type { ReasoningSummaryPartAddedNotification } from "./v2/ReasoningSummaryPartAddedNotification"; @@ -63,4 +64,4 @@ import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldW /** * Notification sent from the server to the client. */ -export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; +export type ServerNotification = { "method": "error", "params": ErrorNotification } | { "method": "thread/started", "params": ThreadStartedNotification } | { "method": "thread/status/changed", "params": ThreadStatusChangedNotification } | { "method": "thread/archived", "params": ThreadArchivedNotification } | { "method": "thread/unarchived", "params": ThreadUnarchivedNotification } | { "method": "thread/closed", "params": ThreadClosedNotification } | { "method": "skills/changed", "params": SkillsChangedNotification } | { "method": "thread/name/updated", "params": ThreadNameUpdatedNotification } | { "method": "thread/tokenUsage/updated", "params": ThreadTokenUsageUpdatedNotification } | { "method": "turn/started", "params": TurnStartedNotification } | { "method": "hook/started", "params": HookStartedNotification } | { "method": "turn/completed", "params": TurnCompletedNotification } | { "method": "hook/completed", "params": HookCompletedNotification } | { "method": "turn/diff/updated", "params": TurnDiffUpdatedNotification } | { "method": "turn/plan/updated", "params": TurnPlanUpdatedNotification } | { "method": "item/started", "params": ItemStartedNotification } | { "method": "item/autoApprovalReview/started", "params": ItemGuardianApprovalReviewStartedNotification } | { "method": "item/autoApprovalReview/completed", "params": ItemGuardianApprovalReviewCompletedNotification } | { "method": "item/completed", "params": ItemCompletedNotification } | { "method": "rawResponseItem/completed", "params": RawResponseItemCompletedNotification } | { "method": "item/agentMessage/delta", "params": AgentMessageDeltaNotification } | { "method": "item/plan/delta", "params": PlanDeltaNotification } | { "method": "command/exec/outputDelta", "params": CommandExecOutputDeltaNotification } | { "method": "item/commandExecution/outputDelta", "params": CommandExecutionOutputDeltaNotification } | { "method": "item/commandExecution/terminalInteraction", "params": TerminalInteractionNotification } | { "method": "item/fileChange/outputDelta", "params": FileChangeOutputDeltaNotification } | { "method": "item/fileChange/patchUpdated", "params": FileChangePatchUpdatedNotification } | { "method": "serverRequest/resolved", "params": ServerRequestResolvedNotification } | { "method": "item/mcpToolCall/progress", "params": McpToolCallProgressNotification } | { "method": "mcpServer/oauthLogin/completed", "params": McpServerOauthLoginCompletedNotification } | { "method": "mcpServer/startupStatus/updated", "params": McpServerStatusUpdatedNotification } | { "method": "account/updated", "params": AccountUpdatedNotification } | { "method": "account/rateLimits/updated", "params": AccountRateLimitsUpdatedNotification } | { "method": "app/list/updated", "params": AppListUpdatedNotification } | { "method": "externalAgentConfig/import/completed", "params": ExternalAgentConfigImportCompletedNotification } | { "method": "fs/changed", "params": FsChangedNotification } | { "method": "item/reasoning/summaryTextDelta", "params": ReasoningSummaryTextDeltaNotification } | { "method": "item/reasoning/summaryPartAdded", "params": ReasoningSummaryPartAddedNotification } | { "method": "item/reasoning/textDelta", "params": ReasoningTextDeltaNotification } | { "method": "thread/compacted", "params": ContextCompactedNotification } | { "method": "model/rerouted", "params": ModelReroutedNotification } | { "method": "model/verification", "params": ModelVerificationNotification } | { "method": "warning", "params": WarningNotification } | { "method": "guardianWarning", "params": GuardianWarningNotification } | { "method": "deprecationNotice", "params": DeprecationNoticeNotification } | { "method": "configWarning", "params": ConfigWarningNotification } | { "method": "fuzzyFileSearch/sessionUpdated", "params": FuzzyFileSearchSessionUpdatedNotification } | { "method": "fuzzyFileSearch/sessionCompleted", "params": FuzzyFileSearchSessionCompletedNotification } | { "method": "thread/realtime/started", "params": ThreadRealtimeStartedNotification } | { "method": "thread/realtime/itemAdded", "params": ThreadRealtimeItemAddedNotification } | { "method": "thread/realtime/transcript/delta", "params": ThreadRealtimeTranscriptDeltaNotification } | { "method": "thread/realtime/transcript/done", "params": ThreadRealtimeTranscriptDoneNotification } | { "method": "thread/realtime/outputAudio/delta", "params": ThreadRealtimeOutputAudioDeltaNotification } | { "method": "thread/realtime/sdp", "params": ThreadRealtimeSdpNotification } | { "method": "thread/realtime/error", "params": ThreadRealtimeErrorNotification } | { "method": "thread/realtime/closed", "params": ThreadRealtimeClosedNotification } | { "method": "windows/worldWritableWarning", "params": WindowsWorldWritableWarningNotification } | { "method": "windowsSandbox/setupCompleted", "params": WindowsSandboxSetupCompletedNotification } | { "method": "account/login/completed", "params": AccountLoginCompletedNotification }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts index 20dc3c519..6e975abf4 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/CodexErrorInfo.ts @@ -9,4 +9,4 @@ import type { NonSteerableTurnKind } from "./NonSteerableTurnKind"; * When an upstream HTTP status is available (for example, from the Responses API or a provider), * it is forwarded in `httpStatusCode` on the relevant `codexErrorInfo` variant. */ -export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; +export type CodexErrorInfo = "contextWindowExceeded" | "usageLimitExceeded" | "serverOverloaded" | "cyberPolicy" | { "httpConnectionFailed": { httpStatusCode: number | null, } } | { "responseStreamConnectionFailed": { httpStatusCode: number | null, } } | "internalServerError" | "unauthorized" | "badRequest" | "threadRollbackFailed" | "sandboxError" | { "responseStreamDisconnected": { httpStatusCode: number | null, } } | { "responseTooManyFailedAttempts": { httpStatusCode: number | null, } } | { "activeTurnNotSteerable": { turnKind: NonSteerableTurnKind, } } | "other"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelVerification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelVerification.ts new file mode 100644 index 000000000..00538c090 --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelVerification.ts @@ -0,0 +1,5 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. + +export type ModelVerification = "trustedAccessForCyber"; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ModelVerificationNotification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ModelVerificationNotification.ts new file mode 100644 index 000000000..3af484d0e --- /dev/null +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ModelVerificationNotification.ts @@ -0,0 +1,6 @@ +// GENERATED CODE! DO NOT MODIFY BY HAND! + +// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { ModelVerification } from "./ModelVerification"; + +export type ModelVerificationNotification = { threadId: string, turnId: string, verifications: Array, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index 72a7a0d63..b58dc0248 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -231,6 +231,8 @@ export type { ModelListResponse } from "./ModelListResponse"; export type { ModelRerouteReason } from "./ModelRerouteReason"; export type { ModelReroutedNotification } from "./ModelReroutedNotification"; export type { ModelUpgradeInfo } from "./ModelUpgradeInfo"; +export type { ModelVerification } from "./ModelVerification"; +export type { ModelVerificationNotification } from "./ModelVerificationNotification"; export type { NetworkAccess } from "./NetworkAccess"; export type { NetworkApprovalContext } from "./NetworkApprovalContext"; export type { NetworkApprovalProtocol } from "./NetworkApprovalProtocol"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index a751f11ec..3ca969584 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -1060,6 +1060,7 @@ server_notification_definitions! { /// Deprecated: Use `ContextCompaction` item type instead. ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification), ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification), + ModelVerification => "model/verification" (v2::ModelVerificationNotification), Warning => "warning" (v2::WarningNotification), GuardianWarning => "guardianWarning" (v2::GuardianWarningNotification), DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification), diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 9f1801c64..bd0eee8d1 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -72,6 +72,7 @@ use codex_protocol::protocol::HookRunSummary as CoreHookRunSummary; use codex_protocol::protocol::HookScope as CoreHookScope; use codex_protocol::protocol::HookSource as CoreHookSource; use codex_protocol::protocol::ModelRerouteReason as CoreModelRerouteReason; +use codex_protocol::protocol::ModelVerification as CoreModelVerification; use codex_protocol::protocol::NetworkAccess as CoreNetworkAccess; use codex_protocol::protocol::NonSteerableTurnKind as CoreNonSteerableTurnKind; use codex_protocol::protocol::PatchApplyStatus as CorePatchApplyStatus; @@ -163,6 +164,7 @@ pub enum CodexErrorInfo { ContextWindowExceeded, UsageLimitExceeded, ServerOverloaded, + CyberPolicy, HttpConnectionFailed { #[serde(rename = "httpStatusCode")] #[ts(rename = "httpStatusCode")] @@ -207,6 +209,7 @@ impl From for CodexErrorInfo { CoreCodexErrorInfo::ContextWindowExceeded => CodexErrorInfo::ContextWindowExceeded, CoreCodexErrorInfo::UsageLimitExceeded => CodexErrorInfo::UsageLimitExceeded, CoreCodexErrorInfo::ServerOverloaded => CodexErrorInfo::ServerOverloaded, + CoreCodexErrorInfo::CyberPolicy => CodexErrorInfo::CyberPolicy, CoreCodexErrorInfo::HttpConnectionFailed { http_status_code } => { CodexErrorInfo::HttpConnectionFailed { http_status_code } } @@ -425,6 +428,12 @@ v2_enum_from_core!( } ); +v2_enum_from_core!( + pub enum ModelVerification from CoreModelVerification { + TrustedAccessForCyber + } +); + v2_enum_from_core!( pub enum HookEventName from CoreHookEventName { PreToolUse, PermissionRequest, PostToolUse, SessionStart, UserPromptSubmit, Stop @@ -7350,6 +7359,15 @@ pub struct ModelReroutedNotification { pub reason: ModelRerouteReason, } +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "camelCase")] +#[ts(export_to = "v2/")] +pub struct ModelVerificationNotification { + pub thread_id: String, + pub turn_id: String, + pub verifications: Vec, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -9952,6 +9970,14 @@ mod tests { ); } + #[test] + fn codex_error_info_serializes_cyber_policy_in_camel_case() { + assert_eq!( + serde_json::to_value(CodexErrorInfo::CyberPolicy).unwrap(), + json!("cyberPolicy") + ); + } + #[test] fn codex_error_info_serializes_active_turn_not_steerable_turn_kind_in_camel_case() { let value = CodexErrorInfo::ActiveTurnNotSteerable { diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 8a6ce3b72..2a1630ffa 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -1028,6 +1028,7 @@ The app-server streams JSON-RPC notifications while a turn is running. Each turn - `turn/diff/updated` — `{ threadId, turnId, diff }` represents the up-to-date snapshot of the turn-level unified diff, emitted after every FileChange item. `diff` is the latest aggregated unified diff across every file change in the turn. UIs can render this to show the full "what changed" view without stitching individual `fileChange` items. - `turn/plan/updated` — `{ turnId, explanation?, plan }` whenever the agent shares or changes its plan; each `plan` entry is `{ step, status }` with `status` in `pending`, `inProgress`, or `completed`. - `model/rerouted` — `{ threadId, turnId, fromModel, toModel, reason }` when the backend reroutes a request to a different model (for example, due to high-risk cyber safety checks). +- `model/verification` — `{ threadId, turnId, verifications }` when the backend flags additional account verification, such as `trustedAccessForCyber`. Today both notifications carry an empty `items` array even when item events were streamed; rely on `item/*` notifications for the canonical item list until this is fixed. diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs index 240a070ed..58a8c2fc8 100644 --- a/codex-rs/app-server/src/bespoke_event_handling.rs +++ b/codex-rs/app-server/src/bespoke_event_handling.rs @@ -61,6 +61,7 @@ use codex_app_server_protocol::McpToolCallError; use codex_app_server_protocol::McpToolCallResult; use codex_app_server_protocol::McpToolCallStatus; use codex_app_server_protocol::ModelReroutedNotification; +use codex_app_server_protocol::ModelVerificationNotification; use codex_app_server_protocol::NetworkApprovalContext as V2NetworkApprovalContext; use codex_app_server_protocol::NetworkPolicyAmendment as V2NetworkPolicyAmendment; use codex_app_server_protocol::NetworkPolicyRuleAction as V2NetworkPolicyRuleAction; @@ -399,6 +400,18 @@ pub(crate) async fn apply_bespoke_event_handling( .await; } } + EventMsg::ModelVerification(event) => { + if let ApiVersion::V2 = api_version { + let notification = ModelVerificationNotification { + thread_id: conversation_id.to_string(), + turn_id: event_turn_id.clone(), + verifications: event.verifications.into_iter().map(Into::into).collect(), + }; + outgoing + .send_server_notification(ServerNotification::ModelVerification(notification)) + .await; + } + } EventMsg::RealtimeConversationStarted(event) => { if let ApiVersion::V2 = api_version { let notification = ThreadRealtimeStartedNotification { diff --git a/codex-rs/app-server/src/outgoing_message.rs b/codex-rs/app-server/src/outgoing_message.rs index e3c92f70b..4d073fc5a 100644 --- a/codex-rs/app-server/src/outgoing_message.rs +++ b/codex-rs/app-server/src/outgoing_message.rs @@ -660,6 +660,8 @@ mod tests { use codex_app_server_protocol::GuardianWarningNotification; use codex_app_server_protocol::ModelRerouteReason; use codex_app_server_protocol::ModelReroutedNotification; + use codex_app_server_protocol::ModelVerification; + use codex_app_server_protocol::ModelVerificationNotification; use codex_app_server_protocol::RateLimitSnapshot; use codex_app_server_protocol::RateLimitWindow; use codex_app_server_protocol::ToolRequestUserInputParams; @@ -863,6 +865,30 @@ mod tests { ); } + #[test] + fn verify_model_verification_notification_serialization() { + let notification = ServerNotification::ModelVerification(ModelVerificationNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + verifications: vec![ModelVerification::TrustedAccessForCyber], + }); + + let jsonrpc_notification = OutgoingMessage::AppServerNotification(notification); + assert_eq!( + json!({ + "method": "model/verification", + "params": { + "threadId": "thread-1", + "turnId": "turn-1", + "verifications": ["trustedAccessForCyber"], + }, + }), + serde_json::to_value(jsonrpc_notification) + .expect("ensure the notification serializes correctly"), + "ensure the notification serializes correctly" + ); + } + #[tokio::test] async fn send_response_routes_to_target_connection() { let (tx, mut rx) = mpsc::channel::(4); diff --git a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs index 31973ebda..7c1619e38 100644 --- a/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs +++ b/codex-rs/app-server/tests/suite/v2/safety_check_downgrade.rs @@ -1,12 +1,16 @@ use anyhow::Result; use app_test_support::McpProcess; use app_test_support::to_response; +use codex_app_server_protocol::CodexErrorInfo; +use codex_app_server_protocol::ErrorNotification; use codex_app_server_protocol::ItemCompletedNotification; use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::ModelRerouteReason; use codex_app_server_protocol::ModelReroutedNotification; +use codex_app_server_protocol::ModelVerification; +use codex_app_server_protocol::ModelVerificationNotification; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadItem; use codex_app_server_protocol::ThreadStartParams; @@ -19,10 +23,14 @@ use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; use tempfile::TempDir; use tokio::time::timeout; +use wiremock::ResponseTemplate; const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); const REQUESTED_MODEL: &str = "gpt-5.4"; const SERVER_MODEL: &str = "gpt-5.3-codex"; +const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber"; +const CYBER_POLICY_MESSAGE: &str = + "This request has been flagged for potentially high-risk cyber activity."; #[tokio::test] async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() -> Result<()> { @@ -88,6 +96,75 @@ async fn openai_model_header_mismatch_emits_model_rerouted_notification_v2() -> Ok(()) } +#[tokio::test] +async fn cyber_policy_response_emits_typed_error_notification_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let response = ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": { + "message": CYBER_POLICY_MESSAGE, + "type": "invalid_request", + "param": null, + "code": "cyber_policy" + } + })); + let _response_mock = responses::mount_response_once(&server, response).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some(REQUESTED_MODEL.to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "trigger cyber policy error".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let turn_start: TurnStartResponse = to_response(turn_resp)?; + + let error = collect_cyber_policy_error_and_validate_no_reroute(&mut mcp).await?; + assert_eq!( + error, + ErrorNotification { + error: codex_app_server_protocol::TurnError { + message: CYBER_POLICY_MESSAGE.to_string(), + codex_error_info: Some(CodexErrorInfo::CyberPolicy), + additional_details: None, + }, + will_retry: false, + thread_id: thread.id, + turn_id: turn_start.turn.id, + } + ); + + Ok(()) +} + #[tokio::test] async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when_header_matches_requested() -> Result<()> { @@ -161,6 +238,73 @@ async fn response_model_field_mismatch_emits_model_rerouted_notification_v2_when Ok(()) } +#[tokio::test] +async fn model_verification_emits_typed_notification_and_warning_v2() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_model_verification_metadata( + "resp-1", + vec![TRUSTED_ACCESS_FOR_CYBER_VERIFICATION], + ), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ]); + let response = responses::sse_response(body); + let _response_mock = responses::mount_response_once(&server, response).await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some(REQUESTED_MODEL.to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "trigger model verification".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_req)), + ) + .await??; + let turn_start: TurnStartResponse = to_response(turn_resp)?; + + let verification = + collect_model_verification_notifications_and_validate_no_warning_item(&mut mcp).await?; + assert_eq!( + verification, + ModelVerificationNotification { + thread_id: thread.id, + turn_id: turn_start.turn.id, + verifications: vec![ModelVerification::TrustedAccessForCyber], + } + ); + + Ok(()) +} + async fn collect_turn_notifications_and_validate_no_warning_item( mcp: &mut McpProcess, ) -> Result { @@ -203,6 +347,90 @@ async fn collect_turn_notifications_and_validate_no_warning_item( } } +async fn collect_model_verification_notifications_and_validate_no_warning_item( + mcp: &mut McpProcess, +) -> Result { + let mut verification = None; + + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "model/verification" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("model/verification notifications must include params") + })?; + let payload: ModelVerificationNotification = serde_json::from_value(params)?; + verification = Some(payload); + } + "warning" => { + anyhow::bail!("verification-only response must not emit warning"); + } + "model/rerouted" => { + anyhow::bail!("verification-only response must not emit model/rerouted"); + } + "item/started" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("item/started notifications must include params") + })?; + let payload: ItemStartedNotification = serde_json::from_value(params)?; + assert!(!is_warning_user_message_item(&payload.item)); + } + "item/completed" => { + let params = notification.params.ok_or_else(|| { + anyhow::anyhow!("item/completed notifications must include params") + })?; + let payload: ItemCompletedNotification = serde_json::from_value(params)?; + assert!(!is_warning_user_message_item(&payload.item)); + } + "turn/completed" => { + let verification = verification.ok_or_else(|| { + anyhow::anyhow!( + "expected model/verification notification before turn/completed" + ) + })?; + return Ok(verification); + } + _ => {} + } + } +} + +async fn collect_cyber_policy_error_and_validate_no_reroute( + mcp: &mut McpProcess, +) -> Result { + let mut error = None; + + loop { + let message = timeout(DEFAULT_READ_TIMEOUT, mcp.read_next_message()).await??; + let JSONRPCMessage::Notification(notification) = message else { + continue; + }; + match notification.method.as_str() { + "error" => { + let params = notification + .params + .ok_or_else(|| anyhow::anyhow!("error notifications must include params"))?; + let payload: ErrorNotification = serde_json::from_value(params)?; + if payload.error.codex_error_info == Some(CodexErrorInfo::CyberPolicy) { + error = Some(payload); + } + } + "model/rerouted" => { + anyhow::bail!("cyber policy response must not emit model/rerouted"); + } + "turn/completed" => { + return error.ok_or_else(|| { + anyhow::anyhow!("expected cyber policy error before turn/completed") + }); + } + _ => {} + } + } +} + fn warning_text_from_item(item: &ThreadItem) -> Option<&str> { let ThreadItem::UserMessage { content, .. } = item else { return None; diff --git a/codex-rs/cli/src/responses_cmd.rs b/codex-rs/cli/src/responses_cmd.rs index 9cd27638d..6974198ef 100644 --- a/codex-rs/cli/src/responses_cmd.rs +++ b/codex-rs/cli/src/responses_cmd.rs @@ -69,6 +69,9 @@ fn response_event_to_json(event: codex_api::ResponseEvent) -> serde_json::Value codex_api::ResponseEvent::ServerModel(model) => { json!({ "type": "response.server_model", "model": model }) } + codex_api::ResponseEvent::ModelVerifications(verifications) => { + json!({ "type": "response.model_verifications", "verifications": verifications }) + } codex_api::ResponseEvent::ServerReasoningIncluded(included) => { json!({ "type": "response.server_reasoning_included", "included": included }) } diff --git a/codex-rs/codex-api/src/api_bridge.rs b/codex-rs/codex-api/src/api_bridge.rs index 13c263cbd..4f37c2ff9 100644 --- a/codex-rs/codex-api/src/api_bridge.rs +++ b/codex-rs/codex-api/src/api_bridge.rs @@ -32,6 +32,7 @@ pub fn map_api_error(err: ApiError) -> CodexErr { identity_error_code: None, }), ApiError::InvalidRequest { message } => CodexErr::InvalidRequest(message), + ApiError::CyberPolicy { message } => CodexErr::CyberPolicy { message }, ApiError::Transport(transport) => match transport { TransportError::Http { status, @@ -55,7 +56,19 @@ pub fn map_api_error(err: ApiError) -> CodexErr { } if status == http::StatusCode::BAD_REQUEST { - if body_text + if let Ok(parsed) = serde_json::from_str::(&body_text) + && let Some(error) = parsed.get("error") + && error.get("code").and_then(Value::as_str) + == Some(CYBER_POLICY_ERROR_CODE) + { + let message = error + .get("message") + .and_then(Value::as_str) + .filter(|message| !message.trim().is_empty()) + .map(str::to_string) + .unwrap_or_else(|| CYBER_POLICY_FALLBACK_MESSAGE.to_string()); + CodexErr::CyberPolicy { message } + } else if body_text .contains("The image data you provided does not represent a valid image") { CodexErr::InvalidImageRequest() @@ -125,6 +138,9 @@ const OAI_REQUEST_ID_HEADER: &str = "x-oai-request-id"; const CF_RAY_HEADER: &str = "cf-ray"; const X_OPENAI_AUTHORIZATION_ERROR_HEADER: &str = "x-openai-authorization-error"; const X_ERROR_JSON_HEADER: &str = "x-error-json"; +const CYBER_POLICY_ERROR_CODE: &str = "cyber_policy"; +const CYBER_POLICY_FALLBACK_MESSAGE: &str = + "This request has been flagged for possible cybersecurity risk."; #[cfg(test)] #[path = "api_bridge_tests.rs"] diff --git a/codex-rs/codex-api/src/api_bridge_tests.rs b/codex-rs/codex-api/src/api_bridge_tests.rs index 077691c95..af7e34a64 100644 --- a/codex-rs/codex-api/src/api_bridge_tests.rs +++ b/codex-rs/codex-api/src/api_bridge_tests.rs @@ -26,6 +26,104 @@ fn map_api_error_maps_server_overloaded_from_503_body() { assert!(matches!(err, CodexErr::ServerOverloaded)); } +#[test] +fn map_api_error_maps_cyber_policy_from_400_body() { + let body = serde_json::json!({ + "error": { + "message": "This request has been flagged for potentially high-risk cyber activity.", + "type": "invalid_request", + "param": null, + "code": "cyber_policy" + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::BAD_REQUEST, + url: Some("http://example.com/v1/responses".to_string()), + headers: None, + body: Some(body), + })); + + let CodexErr::CyberPolicy { message } = err else { + panic!("expected CodexErr::CyberPolicy, got {err:?}"); + }; + assert_eq!( + message, + "This request has been flagged for potentially high-risk cyber activity." + ); +} + +#[test] +fn map_api_error_maps_wrapped_websocket_cyber_policy_from_400_body() { + let body = serde_json::json!({ + "type": "error", + "status": 400, + "error": { + "message": "This websocket request was flagged.", + "type": "invalid_request", + "code": "cyber_policy" + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::BAD_REQUEST, + url: Some("ws://example.com/v1/responses".to_string()), + headers: None, + body: Some(body), + })); + + let CodexErr::CyberPolicy { message } = err else { + panic!("expected CodexErr::CyberPolicy, got {err:?}"); + }; + assert_eq!(message, "This websocket request was flagged."); +} + +#[test] +fn map_api_error_uses_cyber_policy_fallback_for_missing_message() { + let body = serde_json::json!({ + "error": { + "code": "cyber_policy" + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::BAD_REQUEST, + url: Some("http://example.com/v1/responses".to_string()), + headers: None, + body: Some(body), + })); + + let CodexErr::CyberPolicy { message } = err else { + panic!("expected CodexErr::CyberPolicy, got {err:?}"); + }; + assert_eq!( + message, + "This request has been flagged for possible cybersecurity risk." + ); +} + +#[test] +fn map_api_error_keeps_unknown_400_errors_generic() { + let body = serde_json::json!({ + "error": { + "message": "Some other bad request.", + "code": "some_other_policy" + } + }) + .to_string(); + let err = map_api_error(ApiError::Transport(TransportError::Http { + status: http::StatusCode::BAD_REQUEST, + url: Some("http://example.com/v1/responses".to_string()), + headers: None, + body: Some(body.clone()), + })); + + let CodexErr::InvalidRequest(message) = err else { + panic!("expected CodexErr::InvalidRequest, got {err:?}"); + }; + assert_eq!(message, body); +} + #[test] fn map_api_error_maps_usage_limit_limit_name_header() { let mut headers = HeaderMap::new(); diff --git a/codex-rs/codex-api/src/common.rs b/codex-rs/codex-api/src/common.rs index 2c0c04bcf..6f118d103 100644 --- a/codex-rs/codex-api/src/common.rs +++ b/codex-rs/codex-api/src/common.rs @@ -3,6 +3,7 @@ use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig; use codex_protocol::config_types::Verbosity as VerbosityConfig; use codex_protocol::models::ResponseItem; use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig; +use codex_protocol::protocol::ModelVerification; use codex_protocol::protocol::RateLimitSnapshot; use codex_protocol::protocol::TokenUsage; use codex_protocol::protocol::W3cTraceContext; @@ -71,6 +72,8 @@ pub enum ResponseEvent { /// Emitted when the server includes `OpenAI-Model` on the stream response. /// This can differ from the requested model when backend safety routing applies. ServerModel(String), + /// Emitted when the server recommends additional account verification. + ModelVerifications(Vec), /// Emitted when `X-Reasoning-Included: true` is present on the response, /// meaning the server already accounted for past reasoning tokens and the /// client should not re-estimate them. diff --git a/codex-rs/codex-api/src/endpoint/responses_websocket.rs b/codex-rs/codex-api/src/endpoint/responses_websocket.rs index aea9b917f..364dc1e9b 100644 --- a/codex-rs/codex-api/src/endpoint/responses_websocket.rs +++ b/codex-rs/codex-api/src/endpoint/responses_websocket.rs @@ -608,6 +608,7 @@ async fn run_websocket_response_stream( continue; } }; + let model_verifications = event.model_verifications(); if event.kind() == "codex.rate_limits" { if let Some(snapshot) = parse_rate_limit_event(&text) { let _ = tx_event.send(Ok(ResponseEvent::RateLimits(snapshot))).await; @@ -622,6 +623,16 @@ async fn run_websocket_response_stream( .await; last_server_model = Some(model); } + if let Some(verifications) = model_verifications + && tx_event + .send(Ok(ResponseEvent::ModelVerifications(verifications))) + .await + .is_err() + { + return Err(ApiError::Stream( + "response event consumer dropped".to_string(), + )); + } match process_responses_event(event) { Ok(Some(event)) => { let is_completed = matches!(event, ResponseEvent::Completed { .. }); diff --git a/codex-rs/codex-api/src/error.rs b/codex-rs/codex-api/src/error.rs index c0a171948..c6cb5fd40 100644 --- a/codex-rs/codex-api/src/error.rs +++ b/codex-rs/codex-api/src/error.rs @@ -27,6 +27,8 @@ pub enum ApiError { RateLimit(String), #[error("invalid request: {message}")] InvalidRequest { message: String }, + #[error("cyber policy: {message}")] + CyberPolicy { message: String }, #[error("server overloaded")] ServerOverloaded, } diff --git a/codex-rs/codex-api/src/sse/responses.rs b/codex-rs/codex-api/src/sse/responses.rs index da7e87461..7b4a4ceab 100644 --- a/codex-rs/codex-api/src/sse/responses.rs +++ b/codex-rs/codex-api/src/sse/responses.rs @@ -7,6 +7,7 @@ use codex_client::ByteStream; use codex_client::StreamResponse; use codex_client::TransportError; use codex_protocol::models::ResponseItem; +use codex_protocol::protocol::ModelVerification; use codex_protocol::protocol::TokenUsage; use eventsource_stream::Eventsource; use futures::StreamExt; @@ -27,6 +28,7 @@ use tracing::trace; const X_REASONING_INCLUDED_HEADER: &str = "x-reasoning-included"; const OPENAI_MODEL_HEADER: &str = "openai-model"; +const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber"; /// Streams SSE events from an on-disk fixture for tests. pub fn stream_from_fixture( @@ -165,6 +167,7 @@ pub struct ResponsesStreamEvent { #[serde(rename = "type")] pub(crate) kind: String, headers: Option, + metadata: Option, response: Option, item: Option, item_id: Option, @@ -183,8 +186,7 @@ impl ResponsesStreamEvent { /// /// Precedence: /// 1. `response.headers` for standard Responses stream events. - /// 2. top-level `headers` for websocket metadata events (for example - /// `codex.response.metadata`). + /// 2. top-level `headers` for websocket metadata events. pub fn response_model(&self) -> Option { let response_headers_model = self .response @@ -200,6 +202,17 @@ impl ResponsesStreamEvent { .and_then(header_openai_model_value_from_json), } } + + pub(crate) fn model_verifications(&self) -> Option> { + if self.kind() != "response.metadata" { + return None; + } + + self.metadata + .as_ref() + .and_then(|metadata| metadata.get("openai_verification_recommendation")) + .and_then(model_verifications_from_json_value) + } } fn header_openai_model_value_from_json(value: &Value) -> Option { @@ -214,6 +227,38 @@ fn header_openai_model_value_from_json(value: &Value) -> Option { }) } +fn model_verifications_from_json_value(value: &Value) -> Option> { + let verifications = value + .as_array() + .map(|items| { + let mut verifications = Vec::new(); + for verification in items + .iter() + .filter_map(Value::as_str) + .filter_map(parse_model_verification) + { + if !verifications.contains(&verification) { + verifications.push(verification); + } + } + verifications + }) + .unwrap_or_default(); + + if verifications.is_empty() { + None + } else { + Some(verifications) + } +} + +fn parse_model_verification(value: &str) -> Option { + match value { + TRUSTED_ACCESS_FOR_CYBER_VERIFICATION => Some(ModelVerification::TrustedAccessForCyber), + _ => None, + } +} + fn json_value_as_string(value: &Value) -> Option { match value { Value::String(value) => Some(value.clone()), @@ -296,6 +341,9 @@ pub fn process_responses_event( response_error = ApiError::QuotaExceeded; } else if is_usage_not_included(&error) { response_error = ApiError::UsageNotIncluded; + } else if is_cyber_policy_error(&error) { + let message = cyber_policy_message(error.message); + response_error = ApiError::CyberPolicy { message }; } else if is_invalid_prompt_error(&error) { let message = error .message @@ -414,6 +462,7 @@ pub async fn process_sse( continue; } }; + let model_verifications = event.model_verifications(); if let Some(model) = event.response_model() && last_server_model.as_deref() != Some(model.as_str()) @@ -427,6 +476,14 @@ pub async fn process_sse( } last_server_model = Some(model); } + if let Some(verifications) = model_verifications + && tx_event + .send(Ok(ResponseEvent::ModelVerifications(verifications))) + .await + .is_err() + { + return; + } match process_responses_event(event) { Ok(Some(event)) => { @@ -488,11 +545,25 @@ fn is_invalid_prompt_error(error: &Error) -> bool { error.code.as_deref() == Some("invalid_prompt") } +fn is_cyber_policy_error(error: &Error) -> bool { + error.code.as_deref() == Some("cyber_policy") +} + fn is_server_overloaded_error(error: &Error) -> bool { error.code.as_deref() == Some("server_is_overloaded") || error.code.as_deref() == Some("slow_down") } +fn cyber_policy_fallback_message() -> String { + "This request has been flagged for possible cybersecurity risk.".to_string() +} + +fn cyber_policy_message(message: Option) -> String { + message + .filter(|message| !message.trim().is_empty()) + .unwrap_or_else(cyber_policy_fallback_message) +} + fn rate_limit_regex() -> &'static regex_lite::Regex { static RE: std::sync::OnceLock = std::sync::OnceLock::new(); #[expect(clippy::unwrap_used)] @@ -841,6 +912,45 @@ mod tests { assert_matches!(events[0], Err(ApiError::QuotaExceeded)); } + #[tokio::test] + async fn cyber_policy_error_is_fatal() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_cyber","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"cyber_policy","message":"This request was flagged for cyber policy."},"incomplete_details":null}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Err(ApiError::CyberPolicy { message }) => { + assert_eq!(message, "This request was flagged for cyber policy."); + } + other => panic!("unexpected event: {other:?}"), + } + } + + #[tokio::test] + async fn cyber_policy_error_uses_fallback_for_empty_message() { + let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_fatal_cyber","object":"response","created_at":1759771626,"status":"failed","background":false,"error":{"code":"cyber_policy","message":" "},"incomplete_details":null}}"#; + + let sse1 = format!("event: response.failed\ndata: {raw_error}\n\n"); + + let events = collect_events(&[sse1.as_bytes()]).await; + + assert_eq!(events.len(), 1); + + match &events[0] { + Err(ApiError::CyberPolicy { message }) => { + assert_eq!( + message, + "This request has been flagged for possible cybersecurity risk." + ); + } + other => panic!("unexpected event: {other:?}"), + } + } + #[tokio::test] async fn invalid_prompt_without_type_is_invalid_request() { let raw_error = r#"{"type":"response.failed","sequence_number":3,"response":{"id":"resp_invalid_prompt_no_type","object":"response","created_at":1759771628,"status":"failed","background":false,"error":{"code":"invalid_prompt","message":"Invalid prompt: we've limited access to this content for safety reasons."},"incomplete_details":null}}"#; @@ -975,6 +1085,43 @@ mod tests { } } + #[tokio::test] + async fn spawn_response_stream_ignores_model_verification_header() { + let mut headers = HeaderMap::new(); + headers.insert( + "openai-verification-recommendation", + HeaderValue::from_static(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION), + ); + let completed = json!({ + "type": "response.completed", + "response": { "id": "resp-1" } + }); + let sse = format!("event: response.completed\ndata: {completed}\n\n"); + let bytes = stream::iter(vec![Ok(Bytes::from(sse))]); + let stream_response = StreamResponse { + status: StatusCode::OK, + headers, + bytes: Box::pin(bytes), + }; + + let mut stream = spawn_response_stream( + stream_response, + idle_timeout(), + /*telemetry*/ None, + /*turn_state*/ None, + ); + let mut events = Vec::new(); + while let Some(event) = stream.rx_event.recv().await { + events.push(event.expect("expected ok event")); + } + + assert!( + !events + .iter() + .any(|event| matches!(event, ResponseEvent::ModelVerifications(_))) + ); + } + #[tokio::test] async fn process_sse_ignores_response_model_field_in_payload() { let events = run_sse(vec![ @@ -1042,10 +1189,44 @@ mod tests { ); } + #[tokio::test] + async fn process_sse_emits_model_verification_field() { + let events = run_sse(vec![ + json!({ + "type": "response.metadata", + "sequence_number": 1, + "response_id": "resp-1", + "metadata": { + "openai_verification_recommendation": [TRUSTED_ACCESS_FOR_CYBER_VERIFICATION] + } + }), + json!({ + "type": "response.completed", + "response": { + "id": "resp-1" + } + }), + ]) + .await; + + assert_matches!( + &events[0], + ResponseEvent::ModelVerifications(verifications) + if verifications == &vec![ModelVerification::TrustedAccessForCyber] + ); + assert_matches!( + &events[1], + ResponseEvent::Completed { + response_id, + token_usage: None + } if response_id == "resp-1" + ); + } + #[test] fn responses_stream_event_response_model_reads_top_level_headers() { let ev: ResponsesStreamEvent = serde_json::from_value(json!({ - "type": "codex.response.metadata", + "type": "response.metadata", "headers": { "openai-model": CYBER_RESTRICTED_MODEL_FOR_TESTS, } @@ -1080,6 +1261,53 @@ mod tests { ); } + #[test] + fn responses_stream_event_model_verification_reads_metadata_field() { + let event = json!({ + "type": "response.metadata", + "sequence_number": 1, + "response_id": "resp-1", + "metadata": { + "openai_verification_recommendation": [TRUSTED_ACCESS_FOR_CYBER_VERIFICATION] + } + }); + let event: ResponsesStreamEvent = + serde_json::from_value(event).expect("expected event to deserialize"); + + assert_eq!( + event.model_verifications(), + Some(vec![ModelVerification::TrustedAccessForCyber]) + ); + } + + #[test] + fn responses_stream_event_model_verification_ignores_unknown_field() { + let event = json!({ + "type": "response.metadata", + "metadata": { + "openai_verification_recommendation": ["unknown"] + } + }); + let event: ResponsesStreamEvent = + serde_json::from_value(event).expect("expected event to deserialize"); + + assert_eq!(event.model_verifications(), None); + } + + #[test] + fn responses_stream_event_model_verification_ignores_non_array_field() { + let event = json!({ + "type": "response.metadata", + "metadata": { + "openai_verification_recommendation": TRUSTED_ACCESS_FOR_CYBER_VERIFICATION + } + }); + let event: ResponsesStreamEvent = + serde_json::from_value(event).expect("expected event to deserialize"); + + assert_eq!(event.model_verifications(), None); + } + #[test] fn test_try_parse_retry_after() { let err = Error { diff --git a/codex-rs/core/src/session/mod.rs b/codex-rs/core/src/session/mod.rs index 360e5cef7..829e8d128 100644 --- a/codex-rs/core/src/session/mod.rs +++ b/codex-rs/core/src/session/mod.rs @@ -331,6 +331,8 @@ use codex_protocol::protocol::InitialHistory; use codex_protocol::protocol::McpServerRefreshConfig; use codex_protocol::protocol::ModelRerouteEvent; use codex_protocol::protocol::ModelRerouteReason; +use codex_protocol::protocol::ModelVerification; +use codex_protocol::protocol::ModelVerificationEvent; use codex_protocol::protocol::NetworkApprovalContext; use codex_protocol::protocol::NonSteerableTurnKind; use codex_protocol::protocol::Op; @@ -2385,6 +2387,18 @@ impl Session { true } + pub(crate) async fn emit_model_verification( + self: &Arc, + turn_context: &Arc, + verifications: Vec, + ) { + self.send_event( + turn_context, + EventMsg::ModelVerification(ModelVerificationEvent { verifications }), + ) + .await; + } + pub(crate) async fn replace_history( &self, items: Vec, diff --git a/codex-rs/core/src/session/review.rs b/codex-rs/core/src/session/review.rs index 62f4c9a87..62befde3e 100644 --- a/codex-rs/core/src/session/review.rs +++ b/codex-rs/core/src/session/review.rs @@ -1,5 +1,6 @@ use super::turn_context::image_generation_tool_auth_allowed; use super::*; +use std::sync::atomic::AtomicBool; /// Spawn a review thread using the given prompt. pub(super) async fn spawn_review_thread( @@ -140,6 +141,8 @@ pub(super) async fn spawn_review_thread( turn_metadata_state, turn_skills: TurnSkillsContext::new(parent_turn_context.turn_skills.outcome.clone()), turn_timing_state: Arc::new(TurnTimingState::default()), + server_model_warning_emitted: AtomicBool::new(false), + model_verification_emitted: AtomicBool::new(false), }; // Seed the child task with the review prompt as the initial user message. diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 15b48f392..54accc3af 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use std::collections::HashSet; use std::sync::Arc; +use std::sync::atomic::Ordering; use crate::SkillInjections; use crate::SkillLoadOutcome; @@ -364,7 +365,6 @@ pub(crate) async fn run_turn( // Although from the perspective of codex.rs, TurnDiffTracker has the lifecycle of a Task which contains // many turns, from the perspective of the user, it is a single turn. let turn_diff_tracker = Arc::new(tokio::sync::Mutex::new(TurnDiffTracker::new())); - let mut server_model_warning_emitted_for_turn = false; // `ModelClientSession` is turn-scoped and caches WebSocket + sticky routing state, so we reuse // one instance across retries within this turn. @@ -455,7 +455,6 @@ pub(crate) async fn run_turn( sampling_request_input, &explicitly_enabled_connectors, skills_outcome, - &mut server_model_warning_emitted_for_turn, cancellation_token.child_token(), ) .await @@ -1022,7 +1021,6 @@ async fn run_sampling_request( input: Vec, explicitly_enabled_connectors: &HashSet, skills_outcome: Option<&SkillLoadOutcome>, - server_model_warning_emitted_for_turn: &mut bool, cancellation_token: CancellationToken, ) -> CodexResult { let router = built_tools( @@ -1076,7 +1074,6 @@ async fn run_sampling_request( client_session, turn_metadata_header, Arc::clone(&turn_diff_tracker), - server_model_warning_emitted_for_turn, &prompt, cancellation_token.child_token(), ) @@ -1490,6 +1487,7 @@ pub(super) fn realtime_text_for_event(msg: &EventMsg) -> Option { | EventMsg::RealtimeConversationRealtime(_) | EventMsg::RealtimeConversationClosed(_) | EventMsg::ModelReroute(_) + | EventMsg::ModelVerification(_) | EventMsg::ContextCompacted(_) | EventMsg::ThreadRolledBack(_) | EventMsg::TurnStarted(_) @@ -1866,7 +1864,6 @@ async fn try_run_sampling_request( client_session: &mut ModelClientSession, turn_metadata_header: Option<&str>, turn_diff_tracker: SharedTurnDiffTracker, - server_model_warning_emitted_for_turn: &mut bool, prompt: &Prompt, cancellation_token: CancellationToken, ) -> CodexResult { @@ -2099,12 +2096,25 @@ async fn try_run_sampling_request( } } ResponseEvent::ServerModel(server_model) => { - if !*server_model_warning_emitted_for_turn + if !turn_context + .server_model_warning_emitted + .load(Ordering::Relaxed) && sess .maybe_warn_on_server_model_mismatch(&turn_context, server_model) .await { - *server_model_warning_emitted_for_turn = true; + turn_context + .server_model_warning_emitted + .store(true, Ordering::Relaxed); + } + } + ResponseEvent::ModelVerifications(verifications) => { + if !turn_context + .model_verification_emitted + .swap(true, Ordering::Relaxed) + { + sess.emit_model_verification(&turn_context, verifications) + .await; } } ResponseEvent::ServerReasoningIncluded(included) => { diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index af52361df..2d547b65a 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -3,6 +3,8 @@ use codex_model_provider::SharedModelProvider; use codex_model_provider::create_model_provider; use codex_protocol::protocol::TurnEnvironmentSelection; use codex_sandboxing::policy_transforms::merge_permission_profiles; +use std::sync::atomic::AtomicBool; +use std::sync::atomic::Ordering; pub(super) fn image_generation_tool_auth_allowed(auth_manager: Option<&AuthManager>) -> bool { matches!( @@ -82,6 +84,8 @@ pub(crate) struct TurnContext { pub(crate) turn_metadata_state: Arc, pub(crate) turn_skills: TurnSkillsContext, pub(crate) turn_timing_state: Arc, + pub(crate) server_model_warning_emitted: AtomicBool, + pub(crate) model_verification_emitted: AtomicBool, } impl TurnContext { pub(crate) fn permission_profile(&self) -> PermissionProfile { @@ -216,6 +220,12 @@ impl TurnContext { turn_metadata_state: self.turn_metadata_state.clone(), turn_skills: self.turn_skills.clone(), turn_timing_state: Arc::clone(&self.turn_timing_state), + server_model_warning_emitted: AtomicBool::new( + self.server_model_warning_emitted.load(Ordering::Relaxed), + ), + model_verification_emitted: AtomicBool::new( + self.model_verification_emitted.load(Ordering::Relaxed), + ), } } @@ -469,6 +479,8 @@ impl Session { turn_metadata_state, turn_skills: TurnSkillsContext::new(skills_outcome), turn_timing_state: Arc::new(TurnTimingState::default()), + server_model_warning_emitted: AtomicBool::new(false), + model_verification_emitted: AtomicBool::new(false), } } diff --git a/codex-rs/core/src/turn_timing.rs b/codex-rs/core/src/turn_timing.rs index 76e52d6c3..f66a066c4 100644 --- a/codex-rs/core/src/turn_timing.rs +++ b/codex-rs/core/src/turn_timing.rs @@ -143,6 +143,7 @@ fn response_event_records_turn_ttft(event: &ResponseEvent) -> bool { | ResponseEvent::ReasoningContentDelta { .. } => true, ResponseEvent::Created | ResponseEvent::ServerModel(_) + | ResponseEvent::ModelVerifications(_) | ResponseEvent::ServerReasoningIncluded(_) | ResponseEvent::ToolCallInputDelta { .. } | ResponseEvent::Completed { .. } diff --git a/codex-rs/core/tests/common/responses.rs b/codex-rs/core/tests/common/responses.rs index 8def3ac6e..171ec3675 100644 --- a/codex-rs/core/tests/common/responses.rs +++ b/codex-rs/core/tests/common/responses.rs @@ -632,6 +632,17 @@ pub fn ev_response_created(id: &str) -> Value { }) } +pub fn ev_model_verification_metadata(id: &str, verifications: Vec<&str>) -> Value { + serde_json::json!({ + "type": "response.metadata", + "sequence_number": 1, + "response_id": id, + "metadata": { + "openai_verification_recommendation": verifications, + } + }) +} + pub fn ev_completed_with_tokens(id: &str, total_tokens: i64) -> Value { serde_json::json!({ "type": "response.completed", diff --git a/codex-rs/core/tests/suite/safety_check_downgrade.rs b/codex-rs/core/tests/suite/safety_check_downgrade.rs index f3211e00d..033875e91 100644 --- a/codex-rs/core/tests/suite/safety_check_downgrade.rs +++ b/codex-rs/core/tests/suite/safety_check_downgrade.rs @@ -2,13 +2,16 @@ use anyhow::Result; use codex_protocol::models::ContentItem; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::AskForApproval; +use codex_protocol::protocol::CodexErrorInfo; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::ModelRerouteReason; +use codex_protocol::protocol::ModelVerification; use codex_protocol::protocol::Op; use codex_protocol::protocol::SandboxPolicy; use codex_protocol::user_input::UserInput; use core_test_support::responses::ev_assistant_message; use core_test_support::responses::ev_function_call; +use core_test_support::responses::ev_model_verification_metadata; use core_test_support::responses::ev_response_created; use core_test_support::responses::mount_response_once; use core_test_support::responses::mount_response_sequence; @@ -20,9 +23,14 @@ use core_test_support::skip_if_no_network; use core_test_support::test_codex::test_codex; use core_test_support::wait_for_event; use pretty_assertions::assert_eq; +use wiremock::ResponseTemplate; const SERVER_MODEL: &str = "gpt-5.2"; const REQUESTED_MODEL: &str = "gpt-5.3-codex"; +const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION: &str = "trusted_access_for_cyber"; + +const CYBER_POLICY_MESSAGE: &str = + "This request has been flagged for potentially high-risk cyber activity."; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> Result<()> { @@ -113,6 +121,57 @@ async fn openai_model_header_mismatch_emits_warning_event_and_warning_item() -> Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cyber_policy_response_emits_typed_error_without_retry() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response = ResponseTemplate::new(400).set_body_json(serde_json::json!({ + "error": { + "message": CYBER_POLICY_MESSAGE, + "type": "invalid_request", + "param": null, + "code": "cyber_policy" + } + })); + let mock = mount_response_once(&server, response).await; + + let mut builder = test_codex().with_model(REQUESTED_MODEL); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserTurn { + environments: None, + items: vec![UserInput::Text { + text: "trigger cyber policy error".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + approvals_reviewer: None, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: REQUESTED_MODEL.to_string(), + effort: test.config.model_reasoning_effort, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + let error = wait_for_event(&test.codex, |event| matches!(event, EventMsg::Error(_))).await; + let EventMsg::Error(error) = error else { + panic!("expected error event"); + }; + assert_eq!(error.message, CYBER_POLICY_MESSAGE); + assert_eq!(error.codex_error_info, Some(CodexErrorInfo::CyberPolicy)); + + mock.single_request(); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn response_model_field_mismatch_emits_warning_when_header_matches_requested() -> Result<()> { skip_if_no_network!(Ok(())); @@ -318,3 +377,150 @@ async fn openai_model_header_casing_only_mismatch_does_not_warn() -> Result<()> Ok(()) } + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_verification_emits_structured_event_without_reroute_or_warning() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let response = sse_response(sse(vec![ + ev_response_created("resp-1"), + ev_model_verification_metadata("resp-1", vec![TRUSTED_ACCESS_FOR_CYBER_VERIFICATION]), + core_test_support::responses::ev_completed("resp-1"), + ])); + let _mock = mount_response_once(&server, response).await; + + let mut builder = test_codex().with_model(REQUESTED_MODEL); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserTurn { + environments: None, + items: vec![UserInput::Text { + text: "trigger model verification".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + approvals_reviewer: None, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: REQUESTED_MODEL.to_string(), + effort: test.config.model_reasoning_effort, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + let mut verification_count = 0; + let mut reroute_count = 0; + let mut warning_count = 0; + let mut warning_item_count = 0; + loop { + let event = wait_for_event(&test.codex, |_| true).await; + match event { + EventMsg::ModelVerification(event) => { + assert_eq!( + event.verifications, + vec![ModelVerification::TrustedAccessForCyber] + ); + verification_count += 1; + } + EventMsg::Warning(_) => warning_count += 1, + EventMsg::ModelReroute(_) => reroute_count += 1, + EventMsg::RawResponseItem(raw) + if matches!( + &raw.item, + ResponseItem::Message { content, .. } + if content.iter().any(|item| matches!( + item, + ContentItem::InputText { text } if text.starts_with("Warning: ") + )) + ) => + { + warning_item_count += 1; + } + EventMsg::TurnComplete(_) => break, + _ => {} + } + } + + assert_eq!(verification_count, 1); + assert_eq!(reroute_count, 0); + assert_eq!(warning_count, 0); + assert_eq!(warning_item_count, 0); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn model_verification_only_emits_once_per_turn() -> Result<()> { + skip_if_no_network!(Ok(())); + + let server = start_mock_server().await; + let tool_args = serde_json::json!({ + "command": "echo hello", + "timeout_ms": 1_000 + }); + + let first_response = sse_response(sse(vec![ + ev_response_created("resp-1"), + ev_function_call( + "call-1", + "shell_command", + &serde_json::to_string(&tool_args)?, + ), + ev_model_verification_metadata("resp-1", vec![TRUSTED_ACCESS_FOR_CYBER_VERIFICATION]), + core_test_support::responses::ev_completed("resp-1"), + ])); + let second_response = sse_response(sse(vec![ + ev_response_created("resp-2"), + ev_model_verification_metadata("resp-2", vec![TRUSTED_ACCESS_FOR_CYBER_VERIFICATION]), + ev_assistant_message("msg-1", "done"), + core_test_support::responses::ev_completed("resp-2"), + ])); + let _mock = mount_response_sequence(&server, vec![first_response, second_response]).await; + + let mut builder = test_codex().with_model(REQUESTED_MODEL); + let test = builder.build(&server).await?; + + test.codex + .submit(Op::UserTurn { + environments: None, + items: vec![UserInput::Text { + text: "trigger follow-up model verification".to_string(), + text_elements: Vec::new(), + }], + final_output_json_schema: None, + cwd: test.cwd_path().to_path_buf(), + approval_policy: AskForApproval::Never, + approvals_reviewer: None, + sandbox_policy: SandboxPolicy::DangerFullAccess, + model: REQUESTED_MODEL.to_string(), + effort: test.config.model_reasoning_effort, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await?; + + let mut verification_count = 0; + loop { + let event = wait_for_event(&test.codex, |_| true).await; + match event { + EventMsg::ModelVerification(_) => verification_count += 1, + EventMsg::Warning(warning) if warning.message.contains("high-risk cyber activity") => { + panic!("model verification should not emit a warning event"); + } + EventMsg::TurnComplete(_) => break, + _ => {} + } + } + + assert_eq!(verification_count, 1); + + Ok(()) +} diff --git a/codex-rs/exec/src/event_processor_with_human_output.rs b/codex-rs/exec/src/event_processor_with_human_output.rs index ca9387727..4060d07da 100644 --- a/codex-rs/exec/src/event_processor_with_human_output.rs +++ b/codex-rs/exec/src/event_processor_with_human_output.rs @@ -292,6 +292,7 @@ impl EventProcessor for EventProcessorWithHumanOutput { ); CodexStatus::Running } + ServerNotification::ModelVerification(_) => CodexStatus::Running, ServerNotification::ThreadTokenUsageUpdated(notification) => { self.last_total_token_usage = Some(notification.token_usage); CodexStatus::Running diff --git a/codex-rs/exec/src/event_processor_with_jsonl_output.rs b/codex-rs/exec/src/event_processor_with_jsonl_output.rs index bf9453188..ba1e8cde2 100644 --- a/codex-rs/exec/src/event_processor_with_jsonl_output.rs +++ b/codex-rs/exec/src/event_processor_with_jsonl_output.rs @@ -489,6 +489,7 @@ impl EventProcessorWithJsonOutput { })); CodexStatus::Running } + ServerNotification::ModelVerification(_) => CodexStatus::Running, ServerNotification::ThreadTokenUsageUpdated(notification) => { self.last_total_token_usage = Some(notification.token_usage); CodexStatus::Running diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 76665803c..24b12f483 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -1138,6 +1138,9 @@ fn should_process_notification( ServerNotification::ModelRerouted(notification) => { notification.thread_id == thread_id && notification.turn_id == turn_id } + ServerNotification::ModelVerification(notification) => { + notification.thread_id == thread_id && notification.turn_id == turn_id + } ServerNotification::ThreadTokenUsageUpdated(notification) => { notification.thread_id == thread_id && notification.turn_id == turn_id } diff --git a/codex-rs/mcp-server/src/codex_tool_runner.rs b/codex-rs/mcp-server/src/codex_tool_runner.rs index 039aab66b..99fffb801 100644 --- a/codex-rs/mcp-server/src/codex_tool_runner.rs +++ b/codex-rs/mcp-server/src/codex_tool_runner.rs @@ -263,7 +263,9 @@ async fn run_codex_tool_session_inner( outgoing.send_response(request_id.clone(), result).await; break; } - EventMsg::Warning(_) | EventMsg::GuardianWarning(_) => { + EventMsg::Warning(_) + | EventMsg::GuardianWarning(_) + | EventMsg::ModelVerification(_) => { continue; } EventMsg::GuardianAssessment(_) => { diff --git a/codex-rs/otel/src/events/session_telemetry.rs b/codex-rs/otel/src/events/session_telemetry.rs index 14ae1cb3e..1ca4a492e 100644 --- a/codex-rs/otel/src/events/session_telemetry.rs +++ b/codex-rs/otel/src/events/session_telemetry.rs @@ -1058,6 +1058,7 @@ impl SessionTelemetry { "reasoning_summary_part_added".into() } ResponseEvent::ServerModel(_) => "server_model".into(), + ResponseEvent::ModelVerifications(_) => "model_verifications".into(), ResponseEvent::ServerReasoningIncluded(_) => "server_reasoning_included".into(), ResponseEvent::RateLimits(_) => "rate_limits".into(), ResponseEvent::ModelsEtag(_) => "models_etag".into(), diff --git a/codex-rs/protocol/src/error.rs b/codex-rs/protocol/src/error.rs index f421db8af..b99a99470 100644 --- a/codex-rs/protocol/src/error.rs +++ b/codex-rs/protocol/src/error.rs @@ -110,6 +110,8 @@ pub enum CodexErr { UsageLimitReached(UsageLimitReachedError), #[error("Selected model is at capacity. Please try a different model.")] ServerOverloaded, + #[error("{message}")] + CyberPolicy { message: String }, #[error("{0}")] ResponseStreamFailed(ResponseStreamFailed), #[error("{0}")] @@ -186,7 +188,8 @@ impl CodexErr { | CodexErr::Spawn | CodexErr::SessionConfiguredNotFirstEvent | CodexErr::UsageLimitReached(_) - | CodexErr::ServerOverloaded => false, + | CodexErr::ServerOverloaded + | CodexErr::CyberPolicy { .. } => false, CodexErr::Stream(..) | CodexErr::Timeout | CodexErr::UnexpectedStatus(_) @@ -217,6 +220,7 @@ impl CodexErr { | CodexErr::QuotaExceeded | CodexErr::UsageNotIncluded => CodexErrorInfo::UsageLimitExceeded, CodexErr::ServerOverloaded => CodexErrorInfo::ServerOverloaded, + CodexErr::CyberPolicy { .. } => CodexErrorInfo::CyberPolicy, CodexErr::RetryLimit(_) => CodexErrorInfo::ResponseTooManyFailedAttempts { http_status_code: self.http_status_code_value(), }, diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index fff783d7a..e087cc027 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1538,6 +1538,9 @@ pub enum EventMsg { /// Model routing changed from the requested model to a different model. ModelReroute(ModelRerouteEvent), + /// Backend recommends additional account verification for this turn. + ModelVerification(ModelVerificationEvent), + /// Conversation history was compacted (either automatically or manually). ContextCompacted(ContextCompactedEvent), @@ -1964,6 +1967,7 @@ pub enum CodexErrorInfo { ContextWindowExceeded, UsageLimitExceeded, ServerOverloaded, + CyberPolicy, HttpConnectionFailed { http_status_code: Option, }, @@ -2000,6 +2004,7 @@ impl CodexErrorInfo { Self::ContextWindowExceeded | Self::UsageLimitExceeded | Self::ServerOverloaded + | Self::CyberPolicy | Self::HttpConnectionFailed { .. } | Self::ResponseStreamConnectionFailed { .. } | Self::InternalServerError @@ -2183,6 +2188,18 @@ pub struct ModelRerouteEvent { pub reason: ModelRerouteReason, } +#[derive(Debug, Clone, Copy, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +#[ts(rename_all = "snake_case")] +pub enum ModelVerification { + TrustedAccessForCyber, +} + +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, JsonSchema, TS)] +pub struct ModelVerificationEvent { + pub verifications: Vec, +} + #[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)] pub struct ContextCompactedEvent; diff --git a/codex-rs/response-debug-context/src/lib.rs b/codex-rs/response-debug-context/src/lib.rs index f7c90b753..d9c186e02 100644 --- a/codex-rs/response-debug-context/src/lib.rs +++ b/codex-rs/response-debug-context/src/lib.rs @@ -81,6 +81,7 @@ pub fn telemetry_api_error_message(error: &ApiError) -> String { ApiError::Retryable { .. } => "retryable error".to_string(), ApiError::RateLimit(_) => "rate limit".to_string(), ApiError::InvalidRequest { .. } => "invalid request".to_string(), + ApiError::CyberPolicy { .. } => "cyber policy".to_string(), ApiError::ServerOverloaded => "server overloaded".to_string(), } } diff --git a/codex-rs/rollout/src/policy.rs b/codex-rs/rollout/src/policy.rs index 897cd0bbf..ddd42e577 100644 --- a/codex-rs/rollout/src/policy.rs +++ b/codex-rs/rollout/src/policy.rs @@ -138,6 +138,7 @@ fn event_msg_persistence_mode(ev: &EventMsg) -> Option { | EventMsg::RealtimeConversationRealtime(_) | EventMsg::RealtimeConversationClosed(_) | EventMsg::ModelReroute(_) + | EventMsg::ModelVerification(_) | EventMsg::AgentMessageDelta(_) | EventMsg::AgentReasoningDelta(_) | EventMsg::AgentReasoningRawContentDelta(_) diff --git a/codex-rs/tui/src/app/app_server_adapter.rs b/codex-rs/tui/src/app/app_server_adapter.rs index f7793a55a..2cfa3c9dd 100644 --- a/codex-rs/tui/src/app/app_server_adapter.rs +++ b/codex-rs/tui/src/app/app_server_adapter.rs @@ -400,6 +400,9 @@ fn server_notification_thread_target( } ServerNotification::ContextCompacted(notification) => Some(notification.thread_id.as_str()), ServerNotification::ModelRerouted(notification) => Some(notification.thread_id.as_str()), + ServerNotification::ModelVerification(notification) => { + Some(notification.thread_id.as_str()) + } ServerNotification::ThreadRealtimeStarted(notification) => { Some(notification.thread_id.as_str()) } diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index b1141e3c2..91a2e7555 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -97,6 +97,7 @@ use codex_app_server_protocol::ItemStartedNotification; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusDetail; use codex_app_server_protocol::McpServerStatusUpdatedNotification; +use codex_app_server_protocol::ModelVerification as AppServerModelVerification; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ThreadItem; @@ -194,6 +195,8 @@ use codex_protocol::protocol::McpStartupStatus; use codex_protocol::protocol::McpStartupUpdateEvent; use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::McpToolCallEndEvent; +#[cfg(test)] +use codex_protocol::protocol::ModelVerification as CoreModelVerification; use codex_protocol::protocol::Op; use codex_protocol::protocol::PatchApplyBeginEvent; use codex_protocol::protocol::RateLimitReachedType; @@ -256,6 +259,7 @@ const MULTI_AGENT_ENABLE_TITLE: &str = "Enable subagents?"; const MULTI_AGENT_ENABLE_YES: &str = "Yes, enable"; const MULTI_AGENT_ENABLE_NO: &str = "Not now"; const MULTI_AGENT_ENABLE_NOTICE: &str = "Subagents will be enabled in the next session."; +const TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING: &str = "Your account was flagged for potentially high-risk cyber activity. Requests may be slower while additional verification is applied. To regain faster access, apply for trusted access: https://chatgpt.com/cyber or learn more: https://developers.openai.com/codex/concepts/cyber-safety"; const MEMORIES_DOC_URL: &str = "https://developers.openai.com/codex/memories"; const MEMORIES_ENABLE_TITLE: &str = "Enable memories?"; const MEMORIES_ENABLE_YES: &str = "Yes, enable"; @@ -651,6 +655,15 @@ fn app_server_rate_limit_error_kind(info: &AppServerCodexErrorInfo) -> Option bool { + matches!(info, CoreCodexErrorInfo::CyberPolicy) +} + +fn is_app_server_cyber_policy_error(info: &AppServerCodexErrorInfo) -> bool { + matches!(info, AppServerCodexErrorInfo::CyberPolicy) +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub(crate) enum ExternalEditorState { #[default] @@ -3040,6 +3053,16 @@ impl ChatWidget { self.maybe_send_next_queued_input(); } + fn on_cyber_policy_error(&mut self) { + self.submit_pending_steers_after_interrupt = false; + self.finalize_turn(); + self.add_to_history(history_cell::new_cyber_policy_error_event()); + self.request_redraw(); + + // After an error ends the turn, try sending the next queued input. + self.maybe_send_next_queued_input(); + } + fn workspace_owner_usage_nudge_enabled(&self) -> bool { self.config .features @@ -3105,6 +3128,11 @@ impl ChatWidget { .as_ref() .is_some_and(|info| self.handle_app_server_steer_rejected_error(info)) { + } else if codex_error_info + .as_ref() + .is_some_and(is_app_server_cyber_policy_error) + { + self.on_cyber_policy_error(); } else if let Some(info) = codex_error_info .as_ref() .and_then(app_server_rate_limit_error_kind) @@ -3125,6 +3153,19 @@ impl ChatWidget { self.request_redraw(); } + #[cfg(test)] + fn on_core_model_verification(&mut self, verifications: &[CoreModelVerification]) { + if verifications.contains(&CoreModelVerification::TrustedAccessForCyber) { + self.on_warning(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING); + } + } + + fn on_app_server_model_verification(&mut self, verifications: &[AppServerModelVerification]) { + if verifications.contains(&AppServerModelVerification::TrustedAccessForCyber) { + self.on_warning(TRUSTED_ACCESS_FOR_CYBER_VERIFICATION_WARNING); + } + } + /// Record one MCP startup update, promoting it into either the active startup /// round or a buffered "next" round. /// @@ -6579,6 +6620,9 @@ impl ChatWidget { self.refresh_skills_for_current_cwd(/*force_reload*/ true); } ServerNotification::ModelRerouted(_) => {} + ServerNotification::ModelVerification(notification) => { + self.on_app_server_model_verification(¬ification.verifications) + } ServerNotification::Warning(notification) => self.on_warning(notification.message), ServerNotification::GuardianWarning(notification) => { self.on_warning(notification.message) @@ -7084,6 +7128,9 @@ impl ChatWidget { | EventMsg::GuardianWarning(WarningEvent { message }) => self.on_warning(message), EventMsg::GuardianAssessment(ev) => self.on_guardian_assessment(ev), EventMsg::ModelReroute(_) => {} + EventMsg::ModelVerification(event) => { + self.on_core_model_verification(&event.verifications) + } EventMsg::Error(ErrorEvent { message, codex_error_info, @@ -7092,6 +7139,11 @@ impl ChatWidget { .as_ref() .is_some_and(|info| self.handle_steer_rejected_error(info)) { + } else if codex_error_info + .as_ref() + .is_some_and(is_core_cyber_policy_error) + { + self.on_cyber_policy_error(); } else if let Some(kind) = codex_error_info .as_ref() .and_then(core_rate_limit_error_kind) diff --git a/codex-rs/tui/src/chatwidget/tests.rs b/codex-rs/tui/src/chatwidget/tests.rs index f001c3afd..487e8a356 100644 --- a/codex-rs/tui/src/chatwidget/tests.rs +++ b/codex-rs/tui/src/chatwidget/tests.rs @@ -70,6 +70,8 @@ pub(super) use codex_app_server_protocol::MarketplaceInterface; pub(super) use codex_app_server_protocol::McpServerStartupState; pub(super) use codex_app_server_protocol::McpServerStatusDetail; pub(super) use codex_app_server_protocol::McpServerStatusUpdatedNotification; +pub(super) use codex_app_server_protocol::ModelVerification as AppServerModelVerification; +pub(super) use codex_app_server_protocol::ModelVerificationNotification; pub(super) use codex_app_server_protocol::PatchApplyStatus as AppServerPatchApplyStatus; pub(super) use codex_app_server_protocol::PatchChangeKind; pub(super) use codex_app_server_protocol::PermissionsRequestApprovalParams as AppServerPermissionsRequestApprovalParams; @@ -147,6 +149,7 @@ pub(super) use codex_protocol::protocol::CodexErrorInfo; pub(super) use codex_protocol::protocol::CollabAgentSpawnBeginEvent; pub(super) use codex_protocol::protocol::CollabAgentSpawnEndEvent; pub(super) use codex_protocol::protocol::CreditsSnapshot; +pub(super) use codex_protocol::protocol::ErrorEvent; pub(super) use codex_protocol::protocol::Event; pub(super) use codex_protocol::protocol::EventMsg; pub(super) use codex_protocol::protocol::ExecApprovalRequestEvent; @@ -169,6 +172,8 @@ pub(super) use codex_protocol::protocol::ItemCompletedEvent; pub(super) use codex_protocol::protocol::McpStartupCompleteEvent; pub(super) use codex_protocol::protocol::McpStartupStatus; pub(super) use codex_protocol::protocol::McpStartupUpdateEvent; +pub(super) use codex_protocol::protocol::ModelVerification as CoreModelVerification; +pub(super) use codex_protocol::protocol::ModelVerificationEvent; pub(super) use codex_protocol::protocol::NonSteerableTurnKind; pub(super) use codex_protocol::protocol::Op; pub(super) use codex_protocol::protocol::PatchApplyBeginEvent; diff --git a/codex-rs/tui/src/chatwidget/tests/app_server.rs b/codex-rs/tui/src/chatwidget/tests/app_server.rs index 06acb16d0..3320b39e5 100644 --- a/codex-rs/tui/src/chatwidget/tests/app_server.rs +++ b/codex-rs/tui/src/chatwidget/tests/app_server.rs @@ -678,6 +678,71 @@ async fn live_app_server_server_overloaded_error_renders_warning() { assert!(!chat.bottom_pane.is_task_running()); } +#[tokio::test] +async fn live_app_server_cyber_policy_error_renders_dedicated_notice() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_server_notification( + ServerNotification::TurnStarted(TurnStartedNotification { + thread_id: "thread-1".to_string(), + turn: AppServerTurn { + id: "turn-1".to_string(), + items: Vec::new(), + status: AppServerTurnStatus::InProgress, + error: None, + started_at: Some(0), + completed_at: None, + duration_ms: None, + }, + }), + /*replay_kind*/ None, + ); + drain_insert_history(&mut rx); + + chat.handle_server_notification( + ServerNotification::Error(ErrorNotification { + error: AppServerTurnError { + message: "server fallback message".to_string(), + codex_error_info: Some(CodexErrorInfo::CyberPolicy.into()), + additional_details: None, + }, + will_retry: false, + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + }), + /*replay_kind*/ None, + ); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1); + let rendered = lines_to_single_string(&cells[0]); + assert!(rendered.contains("This chat was flagged for possible cybersecurity risk")); + assert!(rendered.contains("Trusted Access for Cyber")); + assert!(!rendered.contains("server fallback message")); + assert!(!chat.bottom_pane.is_task_running()); +} + +#[tokio::test] +async fn live_app_server_model_verification_renders_warning() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_server_notification( + ServerNotification::ModelVerification(ModelVerificationNotification { + thread_id: "thread-1".to_string(), + turn_id: "turn-1".to_string(), + verifications: vec![AppServerModelVerification::TrustedAccessForCyber], + }), + /*replay_kind*/ None, + ); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1); + let rendered = lines_to_single_string(&cells[0]); + assert!(rendered.contains("flagged for potentially high-risk cyber activity")); + assert!(rendered.contains("slower while additional verification")); + assert!(rendered.contains("https://chatgpt.com/cyber")); +} + #[tokio::test] async fn live_app_server_invalid_thread_name_update_is_ignored() { let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index bda072927..a01f72318 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -28,6 +28,45 @@ async fn token_count_none_resets_context_indicator() { assert_eq!(chat.bottom_pane.context_window_percent(), None); } +#[tokio::test] +async fn core_cyber_policy_error_renders_dedicated_notice() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_codex_event(Event { + id: "cyber-policy".into(), + msg: EventMsg::Error(ErrorEvent { + message: "server fallback message".to_string(), + codex_error_info: Some(CodexErrorInfo::CyberPolicy), + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1); + let rendered = lines_to_single_string(&cells[0]); + assert!(rendered.contains("This chat was flagged for possible cybersecurity risk")); + assert!(rendered.contains("Trusted Access for Cyber")); + assert!(!rendered.contains("server fallback message")); +} + +#[tokio::test] +async fn core_model_verification_renders_warning() { + let (mut chat, mut rx, _ops) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.handle_codex_event(Event { + id: "model-verification".into(), + msg: EventMsg::ModelVerification(ModelVerificationEvent { + verifications: vec![CoreModelVerification::TrustedAccessForCyber], + }), + }); + + let cells = drain_insert_history(&mut rx); + assert_eq!(cells.len(), 1); + let rendered = lines_to_single_string(&cells[0]); + assert!(rendered.contains("flagged for potentially high-risk cyber activity")); + assert!(rendered.contains("slower while additional verification")); + assert!(rendered.contains("https://chatgpt.com/cyber")); +} + #[tokio::test] async fn context_indicator_shows_used_tokens_when_window_unknown() { let (mut chat, _rx, _ops) = make_chatwidget_manual(Some("unknown-model")).await; diff --git a/codex-rs/tui/src/history_cell.rs b/codex-rs/tui/src/history_cell.rs index 7fa32003e..b6806f88f 100644 --- a/codex-rs/tui/src/history_cell.rs +++ b/codex-rs/tui/src/history_cell.rs @@ -1832,6 +1832,50 @@ pub(crate) fn new_warning_event(message: String) -> PrefixedWrappedHistoryCell { PrefixedWrappedHistoryCell::new(message.yellow(), "⚠ ".yellow(), " ") } +const TRUSTED_ACCESS_FOR_CYBER_URL: &str = "https://chatgpt.com/cyber"; + +#[derive(Debug)] +pub(crate) struct CyberPolicyNoticeCell; + +pub(crate) fn new_cyber_policy_error_event() -> CyberPolicyNoticeCell { + CyberPolicyNoticeCell +} + +impl HistoryCell for CyberPolicyNoticeCell { + fn display_lines(&self, width: u16) -> Vec> { + let mut lines: Vec> = Vec::new(); + lines.push( + vec![ + "ⓘ ".cyan(), + "This chat was flagged for possible cybersecurity risk".bold(), + ] + .into(), + ); + + let wrap_width = width.saturating_sub(2).max(1) as usize; + let body = Line::from(vec![ + " If this seems wrong, try rephrasing your request. To get authorized for security work, join the " + .dim(), + "Trusted Access for Cyber".cyan().underlined(), + " program.".dim(), + ]); + let wrapped = adaptive_wrap_line( + &body, + RtOptions::new(wrap_width).subsequent_indent(" ".into()), + ); + push_owned_lines(&wrapped, &mut lines); + lines.push( + vec![ + " ".into(), + TRUSTED_ACCESS_FOR_CYBER_URL.cyan().underlined(), + ] + .into(), + ); + + lines + } +} + #[derive(Debug)] pub(crate) struct DeprecationNoticeCell { summary: String, @@ -3255,6 +3299,20 @@ mod tests { insta::assert_snapshot!(rendered); } + #[test] + fn cyber_policy_error_event_snapshot() { + let cell = new_cyber_policy_error_event(); + let rendered = render_lines(&cell.display_lines(/*width*/ 80)).join("\n"); + insta::assert_snapshot!(rendered); + } + + #[test] + fn cyber_policy_error_event_narrow_snapshot() { + let cell = new_cyber_policy_error_event(); + let rendered = render_lines(&cell.display_lines(/*width*/ 36)).join("\n"); + insta::assert_snapshot!(rendered); + } + #[test] fn ps_output_long_command_snapshot() { let cell = new_unified_exec_processes_output(vec![UnifiedExecProcessDetails { diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_narrow_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_narrow_snapshot.snap new file mode 100644 index 000000000..9039b196e --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_narrow_snapshot.snap @@ -0,0 +1,12 @@ +--- +source: tui/src/history_cell.rs +assertion_line: 3312 +expression: rendered +--- +ⓘ This chat was flagged for possible cybersecurity risk + If this seems wrong, try + rephrasing your request. To get + authorized for security work, + join the Trusted Access for + Cyber program. + https://chatgpt.com/cyber diff --git a/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_snapshot.snap b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_snapshot.snap new file mode 100644 index 000000000..888a10c37 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__history_cell__tests__cyber_policy_error_event_snapshot.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/history_cell.rs +assertion_line: 3305 +expression: rendered +--- +ⓘ This chat was flagged for possible cybersecurity risk + If this seems wrong, try rephrasing your request. To get authorized for + security work, join the Trusted Access for Cyber program. + https://chatgpt.com/cyber