mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
Merge remote-tracking branch 'origin/main' into codex/prefix-compaction-prewarm
# Conflicts: # codex-rs/core/src/compact_tests.rs # codex-rs/core/src/session/tests.rs # codex-rs/features/src/tests.rs
This commit is contained in:
+5
-7
@@ -1,8 +1,8 @@
|
||||
module(name = "codex")
|
||||
|
||||
bazel_dep(name = "bazel_skylib", version = "1.8.2")
|
||||
bazel_dep(name = "bazel_skylib", version = "1.9.0")
|
||||
bazel_dep(name = "platforms", version = "1.0.0")
|
||||
bazel_dep(name = "llvm", version = "0.6.8")
|
||||
bazel_dep(name = "llvm", version = "0.7.1")
|
||||
# The upstream LLVM archive contains a few unix-only symlink entries and is
|
||||
# missing a couple of MinGW compatibility archives that windows-gnullvm needs
|
||||
# during extraction and linking, so patch it until upstream grows native support.
|
||||
@@ -78,8 +78,8 @@ use_repo(osx, "macos_sdk")
|
||||
bazel_dep(name = "apple_support", version = "2.1.0")
|
||||
bazel_dep(name = "rules_cc", version = "0.2.16")
|
||||
bazel_dep(name = "rules_platform", version = "0.1.0")
|
||||
bazel_dep(name = "rules_rs", version = "0.0.43")
|
||||
# `rules_rs` 0.0.43 does not model `windows-gnullvm` as a distinct Windows exec
|
||||
bazel_dep(name = "rules_rs", version = "0.0.58")
|
||||
# `rules_rs` still does not model `windows-gnullvm` as a distinct Windows exec
|
||||
# platform, so patch it until upstream grows that support for both x86_64 and
|
||||
# aarch64.
|
||||
single_version_override(
|
||||
@@ -87,10 +87,9 @@ single_version_override(
|
||||
patch_strip = 1,
|
||||
patches = [
|
||||
"//patches:rules_rs_windows_gnullvm_exec.patch",
|
||||
"//patches:rules_rs_delete_git_worktree_pointer.patch",
|
||||
"//patches:rules_rs_windows_exec_linker.patch",
|
||||
],
|
||||
version = "0.0.43",
|
||||
version = "0.0.58",
|
||||
)
|
||||
|
||||
rules_rust = use_extension("@rules_rs//rs/experimental:rules_rust.bzl", "rules_rust")
|
||||
@@ -108,7 +107,6 @@ rules_rust.patch(
|
||||
"//patches:rules_rust_windows_exec_bin_target.patch",
|
||||
"//patches:rules_rust_windows_exec_std.patch",
|
||||
"//patches:rules_rust_windows_exec_rustc_dev_rlib.patch",
|
||||
"//patches:rules_rust_repository_set_exec_constraints.patch",
|
||||
],
|
||||
strip = 1,
|
||||
)
|
||||
|
||||
Generated
+78
-19
File diff suppressed because one or more lines are too long
Generated
+3
@@ -2072,6 +2072,7 @@ dependencies = [
|
||||
"codex-protocol",
|
||||
"codex-skills",
|
||||
"codex-utils-absolute-path",
|
||||
"codex-utils-output-truncation",
|
||||
"codex-utils-plugins",
|
||||
"dirs",
|
||||
"dunce",
|
||||
@@ -2413,6 +2414,7 @@ dependencies = [
|
||||
"async-channel",
|
||||
"codex-async-utils",
|
||||
"codex-config",
|
||||
"codex-exec-server",
|
||||
"codex-login",
|
||||
"codex-otel",
|
||||
"codex-plugin",
|
||||
@@ -2714,6 +2716,7 @@ dependencies = [
|
||||
"axum",
|
||||
"codex-client",
|
||||
"codex-config",
|
||||
"codex-exec-server",
|
||||
"codex-keyring-store",
|
||||
"codex-protocol",
|
||||
"codex-utils-cargo-bin",
|
||||
|
||||
@@ -5,6 +5,13 @@
|
||||
"description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.",
|
||||
"type": "string"
|
||||
},
|
||||
"AddCreditsNudgeCreditType": {
|
||||
"enum": [
|
||||
"credits",
|
||||
"usage_limit"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"ApprovalsReviewer": {
|
||||
"description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `guardian_subagent` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request.",
|
||||
"enum": [
|
||||
@@ -464,6 +471,16 @@
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"detail": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ImageDetail"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"image_url": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -1437,19 +1454,27 @@
|
||||
},
|
||||
"PluginInstallParams": {
|
||||
"properties": {
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, apply the remote plugin change before the local install flow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
},
|
||||
"remoteMarketplaceName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplacePath",
|
||||
"pluginName"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -1465,10 +1490,6 @@
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
@@ -1476,24 +1497,32 @@
|
||||
"PluginReadParams": {
|
||||
"properties": {
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
},
|
||||
"remoteMarketplaceName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplacePath",
|
||||
"pluginName"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PluginUninstallParams": {
|
||||
"properties": {
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, apply the remote plugin change before the local uninstall flow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"pluginId": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -2536,6 +2565,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"SendAddCreditsNudgeEmailParams": {
|
||||
"properties": {
|
||||
"creditType": {
|
||||
"$ref": "#/definitions/AddCreditsNudgeCreditType"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"creditType"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ServiceTier": {
|
||||
"enum": [
|
||||
"fast",
|
||||
@@ -4970,6 +5010,30 @@
|
||||
"title": "Account/rateLimits/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/sendAddCreditsNudgeEmail"
|
||||
],
|
||||
"title": "Account/sendAddCreditsNudgeEmailRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/SendAddCreditsNudgeEmailParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Account/sendAddCreditsNudgeEmailRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
|
||||
@@ -3990,6 +3990,25 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"WarningNotification": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"description": "Concise warning message for the user.",
|
||||
"type": "string"
|
||||
},
|
||||
"threadId": {
|
||||
"description": "Optional thread target when the warning applies to a specific thread.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"WebSearchAction": {
|
||||
"oneOf": [
|
||||
{
|
||||
@@ -4930,6 +4949,26 @@
|
||||
"title": "Model/reroutedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"warning"
|
||||
],
|
||||
"title": "WarningNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/WarningNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "WarningNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
|
||||
@@ -1440,6 +1440,30 @@
|
||||
"title": "Account/rateLimits/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/v2/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/sendAddCreditsNudgeEmail"
|
||||
],
|
||||
"title": "Account/sendAddCreditsNudgeEmailRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/SendAddCreditsNudgeEmailParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Account/sendAddCreditsNudgeEmailRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -4303,6 +4327,26 @@
|
||||
"title": "Model/reroutedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"warning"
|
||||
],
|
||||
"title": "WarningNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/v2/WarningNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "WarningNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
@@ -5083,6 +5127,20 @@
|
||||
"title": "AccountUpdatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"AddCreditsNudgeCreditType": {
|
||||
"enum": [
|
||||
"credits",
|
||||
"usage_limit"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AddCreditsNudgeEmailStatus": {
|
||||
"enum": [
|
||||
"sent",
|
||||
"cooldown_active"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AgentMessageDeltaNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
@@ -7115,6 +7173,16 @@
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"detail": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/ImageDetail"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"image_url": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -10208,19 +10276,27 @@
|
||||
"PluginInstallParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, apply the remote plugin change before the local install flow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
},
|
||||
"remoteMarketplaceName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplacePath",
|
||||
"pluginName"
|
||||
],
|
||||
"title": "PluginInstallParams",
|
||||
@@ -10282,6 +10358,14 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Local composer icon path, resolved from the installed plugin package."
|
||||
},
|
||||
"composerIconUrl": {
|
||||
"description": "Remote composer icon URL from the plugin catalog.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"defaultPrompt": {
|
||||
@@ -10314,6 +10398,14 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Local logo path, resolved from the installed plugin package."
|
||||
},
|
||||
"logoUrl": {
|
||||
"description": "Remote logo URL from the plugin catalog.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"longDescription": {
|
||||
@@ -10328,7 +10420,15 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"screenshotUrls": {
|
||||
"description": "Remote screenshot URLs from the plugin catalog.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"screenshots": {
|
||||
"description": "Local screenshot paths, resolved from the installed plugin package.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
@@ -10355,6 +10455,7 @@
|
||||
},
|
||||
"required": [
|
||||
"capabilities",
|
||||
"screenshotUrls",
|
||||
"screenshots"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -10371,10 +10472,6 @@
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "PluginListParams",
|
||||
@@ -10402,12 +10499,6 @@
|
||||
"$ref": "#/definitions/v2/PluginMarketplaceEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"remoteSyncError": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -10432,7 +10523,15 @@
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Local marketplace file path when the marketplace is backed by a local file. Remote-only catalog marketplaces do not have a local path."
|
||||
},
|
||||
"plugins": {
|
||||
"items": {
|
||||
@@ -10443,7 +10542,6 @@
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"path",
|
||||
"plugins"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -10452,14 +10550,26 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/v2/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
},
|
||||
"remoteMarketplaceName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplacePath",
|
||||
"pluginName"
|
||||
],
|
||||
"title": "PluginReadParams",
|
||||
@@ -10537,6 +10647,23 @@
|
||||
],
|
||||
"title": "GitPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": [
|
||||
"remote"
|
||||
],
|
||||
"title": "RemotePluginSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"title": "RemotePluginSource",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -10588,10 +10715,6 @@
|
||||
"PluginUninstallParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, apply the remote plugin change before the local uninstall flow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"pluginId": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -12192,6 +12315,32 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"SendAddCreditsNudgeEmailParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"creditType": {
|
||||
"$ref": "#/definitions/v2/AddCreditsNudgeCreditType"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"creditType"
|
||||
],
|
||||
"title": "SendAddCreditsNudgeEmailParams",
|
||||
"type": "object"
|
||||
},
|
||||
"SendAddCreditsNudgeEmailResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"status": {
|
||||
"$ref": "#/definitions/v2/AddCreditsNudgeEmailStatus"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"title": "SendAddCreditsNudgeEmailResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ServerRequestResolvedNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
@@ -15775,6 +15924,27 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"WarningNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"message": {
|
||||
"description": "Concise warning message for the user.",
|
||||
"type": "string"
|
||||
},
|
||||
"threadId": {
|
||||
"description": "Optional thread target when the warning applies to a specific thread.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"title": "WarningNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"WebSearchAction": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
+194
-24
@@ -114,6 +114,20 @@
|
||||
"title": "AccountUpdatedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"AddCreditsNudgeCreditType": {
|
||||
"enum": [
|
||||
"credits",
|
||||
"usage_limit"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AddCreditsNudgeEmailStatus": {
|
||||
"enum": [
|
||||
"sent",
|
||||
"cooldown_active"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AgentMessageDeltaNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
@@ -2022,6 +2036,30 @@
|
||||
"title": "Account/rateLimits/readRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/definitions/RequestId"
|
||||
},
|
||||
"method": {
|
||||
"enum": [
|
||||
"account/sendAddCreditsNudgeEmail"
|
||||
],
|
||||
"title": "Account/sendAddCreditsNudgeEmailRequestMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/SendAddCreditsNudgeEmailParams"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "Account/sendAddCreditsNudgeEmailRequest",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"id": {
|
||||
@@ -3712,6 +3750,16 @@
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"detail": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ImageDetail"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"image_url": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -6960,19 +7008,27 @@
|
||||
"PluginInstallParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, apply the remote plugin change before the local install flow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
},
|
||||
"remoteMarketplaceName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplacePath",
|
||||
"pluginName"
|
||||
],
|
||||
"title": "PluginInstallParams",
|
||||
@@ -7034,6 +7090,14 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Local composer icon path, resolved from the installed plugin package."
|
||||
},
|
||||
"composerIconUrl": {
|
||||
"description": "Remote composer icon URL from the plugin catalog.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"defaultPrompt": {
|
||||
@@ -7066,6 +7130,14 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Local logo path, resolved from the installed plugin package."
|
||||
},
|
||||
"logoUrl": {
|
||||
"description": "Remote logo URL from the plugin catalog.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"longDescription": {
|
||||
@@ -7080,7 +7152,15 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"screenshotUrls": {
|
||||
"description": "Remote screenshot URLs from the plugin catalog.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"screenshots": {
|
||||
"description": "Local screenshot paths, resolved from the installed plugin package.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
@@ -7107,6 +7187,7 @@
|
||||
},
|
||||
"required": [
|
||||
"capabilities",
|
||||
"screenshotUrls",
|
||||
"screenshots"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -7123,10 +7204,6 @@
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "PluginListParams",
|
||||
@@ -7154,12 +7231,6 @@
|
||||
"$ref": "#/definitions/PluginMarketplaceEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"remoteSyncError": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
@@ -7184,7 +7255,15 @@
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Local marketplace file path when the marketplace is backed by a local file. Remote-only catalog marketplaces do not have a local path."
|
||||
},
|
||||
"plugins": {
|
||||
"items": {
|
||||
@@ -7195,7 +7274,6 @@
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"path",
|
||||
"plugins"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -7204,14 +7282,26 @@
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
},
|
||||
"remoteMarketplaceName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplacePath",
|
||||
"pluginName"
|
||||
],
|
||||
"title": "PluginReadParams",
|
||||
@@ -7289,6 +7379,23 @@
|
||||
],
|
||||
"title": "GitPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": [
|
||||
"remote"
|
||||
],
|
||||
"title": "RemotePluginSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"title": "RemotePluginSource",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -7340,10 +7447,6 @@
|
||||
"PluginUninstallParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, apply the remote plugin change before the local uninstall flow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"pluginId": {
|
||||
"type": "string"
|
||||
}
|
||||
@@ -8944,6 +9047,32 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"SendAddCreditsNudgeEmailParams": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"creditType": {
|
||||
"$ref": "#/definitions/AddCreditsNudgeCreditType"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"creditType"
|
||||
],
|
||||
"title": "SendAddCreditsNudgeEmailParams",
|
||||
"type": "object"
|
||||
},
|
||||
"SendAddCreditsNudgeEmailResponse": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"status": {
|
||||
"$ref": "#/definitions/AddCreditsNudgeEmailStatus"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"title": "SendAddCreditsNudgeEmailResponse",
|
||||
"type": "object"
|
||||
},
|
||||
"ServerNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"description": "Notification sent from the server to the client.",
|
||||
@@ -9732,6 +9861,26 @@
|
||||
"title": "Model/reroutedNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
"enum": [
|
||||
"warning"
|
||||
],
|
||||
"title": "WarningNotificationMethod",
|
||||
"type": "string"
|
||||
},
|
||||
"params": {
|
||||
"$ref": "#/definitions/WarningNotification"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"method",
|
||||
"params"
|
||||
],
|
||||
"title": "WarningNotification",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"method": {
|
||||
@@ -13619,6 +13768,27 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"WarningNotification": {
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"message": {
|
||||
"description": "Concise warning message for the user.",
|
||||
"type": "string"
|
||||
},
|
||||
"threadId": {
|
||||
"description": "Optional thread target when the warning applies to a specific thread.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"title": "WarningNotification",
|
||||
"type": "object"
|
||||
},
|
||||
"WebSearchAction": {
|
||||
"oneOf": [
|
||||
{
|
||||
|
||||
@@ -7,19 +7,27 @@
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, apply the remote plugin change before the local install flow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
},
|
||||
"remoteMarketplaceName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplacePath",
|
||||
"pluginName"
|
||||
],
|
||||
"title": "PluginInstallParams",
|
||||
|
||||
@@ -16,10 +16,6 @@
|
||||
"array",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, reconcile the official curated marketplace against the remote plugin state before listing marketplaces.",
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"title": "PluginListParams",
|
||||
|
||||
@@ -74,6 +74,14 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Local composer icon path, resolved from the installed plugin package."
|
||||
},
|
||||
"composerIconUrl": {
|
||||
"description": "Remote composer icon URL from the plugin catalog.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"defaultPrompt": {
|
||||
@@ -106,6 +114,14 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Local logo path, resolved from the installed plugin package."
|
||||
},
|
||||
"logoUrl": {
|
||||
"description": "Remote logo URL from the plugin catalog.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"longDescription": {
|
||||
@@ -120,7 +136,15 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"screenshotUrls": {
|
||||
"description": "Remote screenshot URLs from the plugin catalog.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"screenshots": {
|
||||
"description": "Local screenshot paths, resolved from the installed plugin package.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
@@ -147,6 +171,7 @@
|
||||
},
|
||||
"required": [
|
||||
"capabilities",
|
||||
"screenshotUrls",
|
||||
"screenshots"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -167,7 +192,15 @@
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Local marketplace file path when the marketplace is backed by a local file. Remote-only catalog marketplaces do not have a local path."
|
||||
},
|
||||
"plugins": {
|
||||
"items": {
|
||||
@@ -178,7 +211,6 @@
|
||||
},
|
||||
"required": [
|
||||
"name",
|
||||
"path",
|
||||
"plugins"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -242,6 +274,23 @@
|
||||
],
|
||||
"title": "GitPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": [
|
||||
"remote"
|
||||
],
|
||||
"title": "RemotePluginSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"title": "RemotePluginSource",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -311,12 +360,6 @@
|
||||
"$ref": "#/definitions/PluginMarketplaceEntry"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"remoteSyncError": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
|
||||
@@ -8,14 +8,26 @@
|
||||
},
|
||||
"properties": {
|
||||
"marketplacePath": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"pluginName": {
|
||||
"type": "string"
|
||||
},
|
||||
"remoteMarketplaceName": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"marketplacePath",
|
||||
"pluginName"
|
||||
],
|
||||
"title": "PluginReadParams",
|
||||
|
||||
@@ -126,6 +126,14 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Local composer icon path, resolved from the installed plugin package."
|
||||
},
|
||||
"composerIconUrl": {
|
||||
"description": "Remote composer icon URL from the plugin catalog.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"defaultPrompt": {
|
||||
@@ -158,6 +166,14 @@
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"description": "Local logo path, resolved from the installed plugin package."
|
||||
},
|
||||
"logoUrl": {
|
||||
"description": "Remote logo URL from the plugin catalog.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"longDescription": {
|
||||
@@ -172,7 +188,15 @@
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"screenshotUrls": {
|
||||
"description": "Remote screenshot URLs from the plugin catalog.",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"screenshots": {
|
||||
"description": "Local screenshot paths, resolved from the installed plugin package.",
|
||||
"items": {
|
||||
"$ref": "#/definitions/AbsolutePathBuf"
|
||||
},
|
||||
@@ -199,6 +223,7 @@
|
||||
},
|
||||
"required": [
|
||||
"capabilities",
|
||||
"screenshotUrls",
|
||||
"screenshots"
|
||||
],
|
||||
"type": "object"
|
||||
@@ -262,6 +287,23 @@
|
||||
],
|
||||
"title": "GitPluginSource",
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"description": "The plugin is available in the remote catalog. Download metadata is kept server-side and is not exposed through the app-server API.",
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": [
|
||||
"remote"
|
||||
],
|
||||
"title": "RemotePluginSourceType",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"type"
|
||||
],
|
||||
"title": "RemotePluginSource",
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"forceRemoteSync": {
|
||||
"description": "When true, apply the remote plugin change before the local uninstall flow.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"pluginId": {
|
||||
"type": "string"
|
||||
}
|
||||
|
||||
@@ -25,6 +25,16 @@
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"detail": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ImageDetail"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"image_url": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AddCreditsNudgeCreditType": {
|
||||
"enum": [
|
||||
"credits",
|
||||
"usage_limit"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"creditType": {
|
||||
"$ref": "#/definitions/AddCreditsNudgeCreditType"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"creditType"
|
||||
],
|
||||
"title": "SendAddCreditsNudgeEmailParams",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"definitions": {
|
||||
"AddCreditsNudgeEmailStatus": {
|
||||
"enum": [
|
||||
"sent",
|
||||
"cooldown_active"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"properties": {
|
||||
"status": {
|
||||
"$ref": "#/definitions/AddCreditsNudgeEmailStatus"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status"
|
||||
],
|
||||
"title": "SendAddCreditsNudgeEmailResponse",
|
||||
"type": "object"
|
||||
}
|
||||
@@ -83,6 +83,16 @@
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"detail": {
|
||||
"anyOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ImageDetail"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"image_url": {
|
||||
"type": "string"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "http://json-schema.org/draft-07/schema#",
|
||||
"properties": {
|
||||
"message": {
|
||||
"description": "Concise warning message for the user.",
|
||||
"type": "string"
|
||||
},
|
||||
"threadId": {
|
||||
"description": "Optional thread target when the warning applies to a specific thread.",
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"message"
|
||||
],
|
||||
"title": "WarningNotification",
|
||||
"type": "object"
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -1,5 +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 { ImageDetail } from "./ImageDetail";
|
||||
|
||||
export type ContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, } | { "type": "output_text", text: string, };
|
||||
export type ContentItem = { "type": "input_text", text: string, } | { "type": "input_image", image_url: string, detail?: ImageDetail, } | { "type": "output_text", text: string, };
|
||||
|
||||
@@ -54,10 +54,11 @@ import type { TurnCompletedNotification } from "./v2/TurnCompletedNotification";
|
||||
import type { TurnDiffUpdatedNotification } from "./v2/TurnDiffUpdatedNotification";
|
||||
import type { TurnPlanUpdatedNotification } from "./v2/TurnPlanUpdatedNotification";
|
||||
import type { TurnStartedNotification } from "./v2/TurnStartedNotification";
|
||||
import type { WarningNotification } from "./v2/WarningNotification";
|
||||
import type { WindowsSandboxSetupCompletedNotification } from "./v2/WindowsSandboxSetupCompletedNotification";
|
||||
import type { WindowsWorldWritableWarningNotification } from "./v2/WindowsWorldWritableWarningNotification";
|
||||
|
||||
/**
|
||||
* 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": "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": "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": "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": "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 };
|
||||
|
||||
@@ -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 AddCreditsNudgeCreditType = "credits" | "usage_limit";
|
||||
@@ -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 AddCreditsNudgeEmailStatus = "sent" | "cooldown_active";
|
||||
@@ -3,8 +3,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
|
||||
export type PluginInstallParams = { marketplacePath: AbsolutePathBuf, pluginName: string,
|
||||
/**
|
||||
* When true, apply the remote plugin change before the local install flow.
|
||||
*/
|
||||
forceRemoteSync?: boolean, };
|
||||
export type PluginInstallParams = { marketplacePath?: AbsolutePathBuf | null, remoteMarketplaceName?: string | null, pluginName: string, };
|
||||
|
||||
@@ -8,4 +8,28 @@ export type PluginInterface = { displayName: string | null, shortDescription: st
|
||||
* Starter prompts for the plugin. Capped at 3 entries with a maximum of
|
||||
* 128 characters per entry.
|
||||
*/
|
||||
defaultPrompt: Array<string> | null, brandColor: string | null, composerIcon: AbsolutePathBuf | null, logo: AbsolutePathBuf | null, screenshots: Array<AbsolutePathBuf>, };
|
||||
defaultPrompt: Array<string> | null, brandColor: string | null,
|
||||
/**
|
||||
* Local composer icon path, resolved from the installed plugin package.
|
||||
*/
|
||||
composerIcon: AbsolutePathBuf | null,
|
||||
/**
|
||||
* Remote composer icon URL from the plugin catalog.
|
||||
*/
|
||||
composerIconUrl: string | null,
|
||||
/**
|
||||
* Local logo path, resolved from the installed plugin package.
|
||||
*/
|
||||
logo: AbsolutePathBuf | null,
|
||||
/**
|
||||
* Remote logo URL from the plugin catalog.
|
||||
*/
|
||||
logoUrl: string | null,
|
||||
/**
|
||||
* Local screenshot paths, resolved from the installed plugin package.
|
||||
*/
|
||||
screenshots: Array<AbsolutePathBuf>,
|
||||
/**
|
||||
* Remote screenshot URLs from the plugin catalog.
|
||||
*/
|
||||
screenshotUrls: Array<string>, };
|
||||
|
||||
@@ -8,9 +8,4 @@ export type PluginListParams = {
|
||||
* Optional working directories used to discover repo marketplaces. When omitted,
|
||||
* only home-scoped marketplaces and the official curated marketplace are considered.
|
||||
*/
|
||||
cwds?: Array<AbsolutePathBuf> | null,
|
||||
/**
|
||||
* When true, reconcile the official curated marketplace against the remote plugin state
|
||||
* before listing marketplaces.
|
||||
*/
|
||||
forceRemoteSync?: boolean, };
|
||||
cwds?: Array<AbsolutePathBuf> | null, };
|
||||
|
||||
@@ -4,4 +4,4 @@
|
||||
import type { MarketplaceLoadErrorInfo } from "./MarketplaceLoadErrorInfo";
|
||||
import type { PluginMarketplaceEntry } from "./PluginMarketplaceEntry";
|
||||
|
||||
export type PluginListResponse = { marketplaces: Array<PluginMarketplaceEntry>, marketplaceLoadErrors: Array<MarketplaceLoadErrorInfo>, remoteSyncError: string | null, featuredPluginIds: Array<string>, };
|
||||
export type PluginListResponse = { marketplaces: Array<PluginMarketplaceEntry>, marketplaceLoadErrors: Array<MarketplaceLoadErrorInfo>, featuredPluginIds: Array<string>, };
|
||||
|
||||
@@ -5,4 +5,9 @@ import type { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
import type { MarketplaceInterface } from "./MarketplaceInterface";
|
||||
import type { PluginSummary } from "./PluginSummary";
|
||||
|
||||
export type PluginMarketplaceEntry = { name: string, path: AbsolutePathBuf, interface: MarketplaceInterface | null, plugins: Array<PluginSummary>, };
|
||||
export type PluginMarketplaceEntry = { name: string,
|
||||
/**
|
||||
* Local marketplace file path when the marketplace is backed by a local file.
|
||||
* Remote-only catalog marketplaces do not have a local path.
|
||||
*/
|
||||
path: AbsolutePathBuf | null, interface: MarketplaceInterface | null, plugins: Array<PluginSummary>, };
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
|
||||
export type PluginReadParams = { marketplacePath: AbsolutePathBuf, pluginName: string, };
|
||||
export type PluginReadParams = { marketplacePath?: AbsolutePathBuf | null, remoteMarketplaceName?: string | null, pluginName: string, };
|
||||
|
||||
@@ -3,4 +3,4 @@
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
import type { AbsolutePathBuf } from "../AbsolutePathBuf";
|
||||
|
||||
export type PluginSource = { "type": "local", path: AbsolutePathBuf, } | { "type": "git", url: string, path: string | null, refName: string | null, sha: string | null, };
|
||||
export type PluginSource = { "type": "local", path: AbsolutePathBuf, } | { "type": "git", url: string, path: string | null, refName: string | null, sha: string | null, } | { "type": "remote" };
|
||||
|
||||
@@ -2,8 +2,4 @@
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type PluginUninstallParams = { pluginId: string,
|
||||
/**
|
||||
* When true, apply the remote plugin change before the local uninstall flow.
|
||||
*/
|
||||
forceRemoteSync?: boolean, };
|
||||
export type PluginUninstallParams = { pluginId: string, };
|
||||
|
||||
@@ -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 { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType";
|
||||
|
||||
export type SendAddCreditsNudgeEmailParams = { creditType: AddCreditsNudgeCreditType, };
|
||||
@@ -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 { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus";
|
||||
|
||||
export type SendAddCreditsNudgeEmailResponse = { status: AddCreditsNudgeEmailStatus, };
|
||||
@@ -0,0 +1,13 @@
|
||||
// GENERATED CODE! DO NOT MODIFY BY HAND!
|
||||
|
||||
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
|
||||
|
||||
export type WarningNotification = {
|
||||
/**
|
||||
* Optional thread target when the warning applies to a specific thread.
|
||||
*/
|
||||
threadId: string | null,
|
||||
/**
|
||||
* Concise warning message for the user.
|
||||
*/
|
||||
message: string, };
|
||||
@@ -4,6 +4,8 @@ export type { Account } from "./Account";
|
||||
export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedNotification";
|
||||
export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification";
|
||||
export type { AccountUpdatedNotification } from "./AccountUpdatedNotification";
|
||||
export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType";
|
||||
export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus";
|
||||
export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions";
|
||||
export type { AdditionalNetworkPermissions } from "./AdditionalNetworkPermissions";
|
||||
export type { AdditionalPermissionProfile } from "./AdditionalPermissionProfile";
|
||||
@@ -259,6 +261,8 @@ export type { ReviewTarget } from "./ReviewTarget";
|
||||
export type { SandboxMode } from "./SandboxMode";
|
||||
export type { SandboxPolicy } from "./SandboxPolicy";
|
||||
export type { SandboxWorkspaceWrite } from "./SandboxWorkspaceWrite";
|
||||
export type { SendAddCreditsNudgeEmailParams } from "./SendAddCreditsNudgeEmailParams";
|
||||
export type { SendAddCreditsNudgeEmailResponse } from "./SendAddCreditsNudgeEmailResponse";
|
||||
export type { ServerRequestResolvedNotification } from "./ServerRequestResolvedNotification";
|
||||
export type { SessionSource } from "./SessionSource";
|
||||
export type { SkillDependencies } from "./SkillDependencies";
|
||||
@@ -362,6 +366,7 @@ export type { TurnStatus } from "./TurnStatus";
|
||||
export type { TurnSteerParams } from "./TurnSteerParams";
|
||||
export type { TurnSteerResponse } from "./TurnSteerResponse";
|
||||
export type { UserInput } from "./UserInput";
|
||||
export type { WarningNotification } from "./WarningNotification";
|
||||
export type { WebSearchAction } from "./WebSearchAction";
|
||||
export type { WindowsSandboxSetupCompletedNotification } from "./WindowsSandboxSetupCompletedNotification";
|
||||
export type { WindowsSandboxSetupMode } from "./WindowsSandboxSetupMode";
|
||||
|
||||
@@ -524,6 +524,11 @@ client_request_definitions! {
|
||||
response: v2::GetAccountRateLimitsResponse,
|
||||
},
|
||||
|
||||
SendAddCreditsNudgeEmail => "account/sendAddCreditsNudgeEmail" {
|
||||
params: v2::SendAddCreditsNudgeEmailParams,
|
||||
response: v2::SendAddCreditsNudgeEmailResponse,
|
||||
},
|
||||
|
||||
FeedbackUpload => "feedback/upload" {
|
||||
params: v2::FeedbackUploadParams,
|
||||
response: v2::FeedbackUploadResponse,
|
||||
@@ -1029,6 +1034,7 @@ server_notification_definitions! {
|
||||
/// Deprecated: Use `ContextCompaction` item type instead.
|
||||
ContextCompacted => "thread/compacted" (v2::ContextCompactedNotification),
|
||||
ModelRerouted => "model/rerouted" (v2::ModelReroutedNotification),
|
||||
Warning => "warning" (v2::WarningNotification),
|
||||
DeprecationNotice => "deprecationNotice" (v2::DeprecationNoticeNotification),
|
||||
ConfigWarning => "configWarning" (v2::ConfigWarningNotification),
|
||||
FuzzyFileSearchSessionUpdated => "fuzzyFileSearch/sessionUpdated" (FuzzyFileSearchSessionUpdatedNotification),
|
||||
|
||||
@@ -1789,6 +1789,36 @@ pub struct GetAccountRateLimitsResponse {
|
||||
pub rate_limits_by_limit_id: Option<HashMap<String, RateLimitSnapshot>>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct SendAddCreditsNudgeEmailParams {
|
||||
pub credit_type: AddCreditsNudgeCreditType,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export_to = "v2/", rename_all = "snake_case")]
|
||||
pub enum AddCreditsNudgeCreditType {
|
||||
Credits,
|
||||
UsageLimit,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct SendAddCreditsNudgeEmailResponse {
|
||||
pub status: AddCreditsNudgeEmailStatus,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
#[ts(export_to = "v2/", rename_all = "snake_case")]
|
||||
pub enum AddCreditsNudgeEmailStatus {
|
||||
Sent,
|
||||
CooldownActive,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -3459,10 +3489,6 @@ pub struct PluginListParams {
|
||||
/// only home-scoped marketplaces and the official curated marketplace are considered.
|
||||
#[ts(optional = nullable)]
|
||||
pub cwds: Option<Vec<AbsolutePathBuf>>,
|
||||
/// When true, reconcile the official curated marketplace against the remote plugin state
|
||||
/// before listing marketplaces.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub force_remote_sync: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -3472,7 +3498,6 @@ pub struct PluginListResponse {
|
||||
pub marketplaces: Vec<PluginMarketplaceEntry>,
|
||||
#[serde(default)]
|
||||
pub marketplace_load_errors: Vec<MarketplaceLoadErrorInfo>,
|
||||
pub remote_sync_error: Option<String>,
|
||||
#[serde(default)]
|
||||
pub featured_plugin_ids: Vec<String>,
|
||||
}
|
||||
@@ -3489,7 +3514,10 @@ pub struct MarketplaceLoadErrorInfo {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginReadParams {
|
||||
pub marketplace_path: AbsolutePathBuf,
|
||||
#[ts(optional = nullable)]
|
||||
pub marketplace_path: Option<AbsolutePathBuf>,
|
||||
#[ts(optional = nullable)]
|
||||
pub remote_marketplace_name: Option<String>,
|
||||
pub plugin_name: String,
|
||||
}
|
||||
|
||||
@@ -3601,7 +3629,9 @@ pub struct SkillsListEntry {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginMarketplaceEntry {
|
||||
pub name: String,
|
||||
pub path: AbsolutePathBuf,
|
||||
/// Local marketplace file path when the marketplace is backed by a local file.
|
||||
/// Remote-only catalog marketplaces do not have a local path.
|
||||
pub path: Option<AbsolutePathBuf>,
|
||||
pub interface: Option<MarketplaceInterface>,
|
||||
pub plugins: Vec<PluginSummary>,
|
||||
}
|
||||
@@ -3694,9 +3724,18 @@ pub struct PluginInterface {
|
||||
/// 128 characters per entry.
|
||||
pub default_prompt: Option<Vec<String>>,
|
||||
pub brand_color: Option<String>,
|
||||
/// Local composer icon path, resolved from the installed plugin package.
|
||||
pub composer_icon: Option<AbsolutePathBuf>,
|
||||
/// Remote composer icon URL from the plugin catalog.
|
||||
pub composer_icon_url: Option<String>,
|
||||
/// Local logo path, resolved from the installed plugin package.
|
||||
pub logo: Option<AbsolutePathBuf>,
|
||||
/// Remote logo URL from the plugin catalog.
|
||||
pub logo_url: Option<String>,
|
||||
/// Local screenshot paths, resolved from the installed plugin package.
|
||||
pub screenshots: Vec<AbsolutePathBuf>,
|
||||
/// Remote screenshot URLs from the plugin catalog.
|
||||
pub screenshot_urls: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -3715,6 +3754,9 @@ pub enum PluginSource {
|
||||
ref_name: Option<String>,
|
||||
sha: Option<String>,
|
||||
},
|
||||
/// The plugin is available in the remote catalog. Download metadata is
|
||||
/// kept server-side and is not exposed through the app-server API.
|
||||
Remote,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -3741,11 +3783,11 @@ pub struct SkillsConfigWriteResponse {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginInstallParams {
|
||||
pub marketplace_path: AbsolutePathBuf,
|
||||
#[ts(optional = nullable)]
|
||||
pub marketplace_path: Option<AbsolutePathBuf>,
|
||||
#[ts(optional = nullable)]
|
||||
pub remote_marketplace_name: Option<String>,
|
||||
pub plugin_name: String,
|
||||
/// When true, apply the remote plugin change before the local install flow.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub force_remote_sync: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -3761,9 +3803,6 @@ pub struct PluginInstallResponse {
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct PluginUninstallParams {
|
||||
pub plugin_id: String,
|
||||
/// When true, apply the remote plugin change before the local uninstall flow.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub force_remote_sync: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
@@ -6680,6 +6719,16 @@ pub struct DeprecationNoticeNotification {
|
||||
pub details: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
pub struct WarningNotification {
|
||||
/// Optional thread target when the warning applies to a specific thread.
|
||||
pub thread_id: Option<String>,
|
||||
/// Concise warning message for the user.
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export_to = "v2/")]
|
||||
@@ -8581,27 +8630,44 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_list_params_serialization_uses_force_remote_sync() {
|
||||
fn plugin_source_serializes_local_git_and_remote_variants() {
|
||||
let local_path = if cfg!(windows) {
|
||||
r"C:\plugins\linear"
|
||||
} else {
|
||||
"/plugins/linear"
|
||||
};
|
||||
let local_path = AbsolutePathBuf::try_from(PathBuf::from(local_path)).unwrap();
|
||||
let local_path_json = local_path.as_path().display().to_string();
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginListParams {
|
||||
cwds: None,
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::to_value(PluginSource::Local { path: local_path }).unwrap(),
|
||||
json!({
|
||||
"cwds": null,
|
||||
"type": "local",
|
||||
"path": local_path_json,
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginListParams {
|
||||
cwds: None,
|
||||
force_remote_sync: true,
|
||||
serde_json::to_value(PluginSource::Git {
|
||||
url: "https://github.com/openai/example.git".to_string(),
|
||||
path: Some("plugins/example".to_string()),
|
||||
ref_name: Some("main".to_string()),
|
||||
sha: Some("abc123".to_string()),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"cwds": null,
|
||||
"forceRemoteSync": true,
|
||||
"type": "git",
|
||||
"url": "https://github.com/openai/example.git",
|
||||
"path": "plugins/example",
|
||||
"refName": "main",
|
||||
"sha": "abc123",
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginSource::Remote).unwrap(),
|
||||
json!({
|
||||
"type": "remote",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -8638,7 +8704,143 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_install_params_serialization_uses_force_remote_sync() {
|
||||
fn plugin_marketplace_entry_serializes_remote_only_path_as_null() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginMarketplaceEntry {
|
||||
name: "openai-curated".to_string(),
|
||||
path: None,
|
||||
interface: None,
|
||||
plugins: Vec::new(),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"name": "openai-curated",
|
||||
"path": null,
|
||||
"interface": null,
|
||||
"plugins": [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_interface_serializes_local_paths_and_remote_urls_separately() {
|
||||
let composer_icon = if cfg!(windows) {
|
||||
r"C:\plugins\linear\icon.png"
|
||||
} else {
|
||||
"/plugins/linear/icon.png"
|
||||
};
|
||||
let composer_icon = AbsolutePathBuf::try_from(PathBuf::from(composer_icon)).unwrap();
|
||||
let composer_icon_json = composer_icon.as_path().display().to_string();
|
||||
|
||||
let interface = PluginInterface {
|
||||
display_name: Some("Linear".to_string()),
|
||||
short_description: None,
|
||||
long_description: None,
|
||||
developer_name: None,
|
||||
category: Some("Productivity".to_string()),
|
||||
capabilities: Vec::new(),
|
||||
website_url: None,
|
||||
privacy_policy_url: None,
|
||||
terms_of_service_url: None,
|
||||
default_prompt: None,
|
||||
brand_color: None,
|
||||
composer_icon: Some(composer_icon),
|
||||
composer_icon_url: Some("https://example.com/linear/icon.png".to_string()),
|
||||
logo: None,
|
||||
logo_url: Some("https://example.com/linear/logo.png".to_string()),
|
||||
screenshots: Vec::new(),
|
||||
screenshot_urls: vec!["https://example.com/linear/screenshot.png".to_string()],
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(interface).unwrap(),
|
||||
json!({
|
||||
"displayName": "Linear",
|
||||
"shortDescription": null,
|
||||
"longDescription": null,
|
||||
"developerName": null,
|
||||
"category": "Productivity",
|
||||
"capabilities": [],
|
||||
"websiteUrl": null,
|
||||
"privacyPolicyUrl": null,
|
||||
"termsOfServiceUrl": null,
|
||||
"defaultPrompt": null,
|
||||
"brandColor": null,
|
||||
"composerIcon": composer_icon_json,
|
||||
"composerIconUrl": "https://example.com/linear/icon.png",
|
||||
"logo": null,
|
||||
"logoUrl": "https://example.com/linear/logo.png",
|
||||
"screenshots": [],
|
||||
"screenshotUrls": ["https://example.com/linear/screenshot.png"],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_list_params_ignore_removed_force_remote_sync_field() {
|
||||
assert_eq!(
|
||||
serde_json::from_value::<PluginListParams>(json!({
|
||||
"cwds": null,
|
||||
"forceRemoteSync": true,
|
||||
}))
|
||||
.unwrap(),
|
||||
PluginListParams { cwds: None },
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_read_params_serialization_uses_install_source_fields() {
|
||||
let marketplace_path = if cfg!(windows) {
|
||||
r"C:\plugins\marketplace.json"
|
||||
} else {
|
||||
"/plugins/marketplace.json"
|
||||
};
|
||||
let marketplace_path = AbsolutePathBuf::try_from(PathBuf::from(marketplace_path)).unwrap();
|
||||
let marketplace_path_json = marketplace_path.as_path().display().to_string();
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginReadParams {
|
||||
marketplace_path: Some(marketplace_path.clone()),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "gmail".to_string(),
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"marketplacePath": marketplace_path_json,
|
||||
"remoteMarketplaceName": null,
|
||||
"pluginName": "gmail",
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::from_value::<PluginReadParams>(json!({
|
||||
"marketplacePath": marketplace_path_json,
|
||||
"pluginName": "gmail",
|
||||
"forceRemoteSync": true,
|
||||
}))
|
||||
.unwrap(),
|
||||
PluginReadParams {
|
||||
marketplace_path: Some(marketplace_path),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "gmail".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::from_value::<PluginReadParams>(json!({
|
||||
"remoteMarketplaceName": "openai-curated",
|
||||
"pluginName": "gmail",
|
||||
}))
|
||||
.unwrap(),
|
||||
PluginReadParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: Some("openai-curated".to_string()),
|
||||
plugin_name: "gmail".to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_install_params_serialization_omits_force_remote_sync() {
|
||||
let marketplace_path = if cfg!(windows) {
|
||||
r"C:\plugins\marketplace.json"
|
||||
} else {
|
||||
@@ -8648,38 +8850,52 @@ mod tests {
|
||||
let marketplace_path_json = marketplace_path.as_path().display().to_string();
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginInstallParams {
|
||||
marketplace_path: marketplace_path.clone(),
|
||||
marketplace_path: Some(marketplace_path.clone()),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "gmail".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
"marketplacePath": marketplace_path_json,
|
||||
"remoteMarketplaceName": null,
|
||||
"pluginName": "gmail",
|
||||
}),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginInstallParams {
|
||||
marketplace_path,
|
||||
plugin_name: "gmail".to_string(),
|
||||
force_remote_sync: true,
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
serde_json::from_value::<PluginInstallParams>(json!({
|
||||
"marketplacePath": marketplace_path_json,
|
||||
"pluginName": "gmail",
|
||||
"forceRemoteSync": true,
|
||||
}),
|
||||
}))
|
||||
.unwrap(),
|
||||
PluginInstallParams {
|
||||
marketplace_path: Some(marketplace_path),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "gmail".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::from_value::<PluginInstallParams>(json!({
|
||||
"remoteMarketplaceName": "openai-curated",
|
||||
"pluginName": "gmail",
|
||||
"forceRemoteSync": true,
|
||||
}))
|
||||
.unwrap(),
|
||||
PluginInstallParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: Some("openai-curated".to_string()),
|
||||
plugin_name: "gmail".to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_uninstall_params_serialization_uses_force_remote_sync() {
|
||||
fn plugin_uninstall_params_serialization_omits_force_remote_sync() {
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginUninstallParams {
|
||||
plugin_id: "gmail@openai-curated".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
@@ -8688,15 +8904,14 @@ mod tests {
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(PluginUninstallParams {
|
||||
plugin_id: "gmail@openai-curated".to_string(),
|
||||
force_remote_sync: true,
|
||||
})
|
||||
.unwrap(),
|
||||
json!({
|
||||
serde_json::from_value::<PluginUninstallParams>(json!({
|
||||
"pluginId": "gmail@openai-curated",
|
||||
"forceRemoteSync": true,
|
||||
}),
|
||||
}))
|
||||
.unwrap(),
|
||||
PluginUninstallParams {
|
||||
plugin_id: "gmail@openai-curated".to_string(),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,5 +4,9 @@ codex_rust_crate(
|
||||
name = "app-server",
|
||||
crate_name = "codex_app_server",
|
||||
integration_test_timeout = "long",
|
||||
test_shard_counts = {
|
||||
"app-server-all-test": 8,
|
||||
"app-server-unit-tests": 8,
|
||||
},
|
||||
test_tags = ["no-sandbox"],
|
||||
)
|
||||
|
||||
@@ -185,7 +185,7 @@ Example with notification opt-out:
|
||||
- `collaborationMode/list` — list available collaboration mode presets (experimental, no pagination). This response omits built-in developer instructions; clients should either pass `settings.developer_instructions: null` when setting a mode to use Codex's built-in instructions, or provide their own instructions explicitly.
|
||||
- `skills/list` — list skills for one or more `cwd` values (optional `forceReload`).
|
||||
- `marketplace/add` — add a remote plugin marketplace from an HTTP(S) Git URL, SSH Git URL, or GitHub `owner/repo` shorthand, then persist it into the user marketplace config. Returns the installed root path plus whether the marketplace was already present.
|
||||
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category. Pass `forceRemoteSync: true` to refresh curated plugin state before listing (**under development; do not call from production clients yet**).
|
||||
- `plugin/list` — list discovered plugin marketplaces and plugin state, including effective marketplace install/auth policy metadata, fail-open `marketplaceLoadErrors` entries for marketplace files that could not be parsed or loaded, and best-effort `featuredPluginIds` for the official curated marketplace. `interface.category` uses the marketplace category when present; otherwise it falls back to the plugin manifest category (**under development; do not call from production clients yet**).
|
||||
- `plugin/read` — read one plugin by `marketplacePath` plus `pluginName`, returning marketplace info, a list-style `summary`, manifest descriptions/interface metadata, and bundled skills/apps/MCP server names. Returned plugin skills include their current `enabled` state after local config filtering. Plugin app summaries also include `needsAuth` when the server can determine connector accessibility (**under development; do not call from production clients yet**).
|
||||
- `skills/changed` — notification emitted when watched local skill files change.
|
||||
- `app/list` — list available apps.
|
||||
@@ -958,6 +958,10 @@ Event notifications are the server-initiated event stream for thread lifecycles,
|
||||
|
||||
Thread realtime uses a separate thread-scoped notification surface. `thread/realtime/*` notifications are ephemeral transport events, not `ThreadItem`s, and are not returned by `thread/read`, `thread/resume`, or `thread/fork`.
|
||||
|
||||
Recoverable configuration and initialization warnings use the existing `configWarning` notification: `{ summary, details?, path?, range? }`. App-server may emit it during initialization for config parsing and related setup diagnostics.
|
||||
|
||||
Generic runtime warnings use the `warning` notification: `{ threadId?, message }`. App-server emits this for non-fatal warnings from the core event stream, including cases where not all enabled skills are included in the model-visible skills list for a session.
|
||||
|
||||
### Notification opt-out
|
||||
|
||||
Clients can suppress specific notifications per connection by sending exact method names in `initialize.params.capabilities.optOutNotificationMethods`.
|
||||
@@ -1444,6 +1448,7 @@ Codex supports these authentication modes. The current mode is surfaced in `acco
|
||||
- `account/updated` (notify) — emitted whenever auth mode changes (`authMode`: `apikey`, `chatgpt`, or `null`) and includes the current ChatGPT `planType` when available.
|
||||
- `account/rateLimits/read` — fetch ChatGPT rate limits; updates arrive via `account/rateLimits/updated` (notify).
|
||||
- `account/rateLimits/updated` (notify) — emitted whenever a user's ChatGPT rate limits change.
|
||||
- `account/sendAddCreditsNudgeEmail` — ask ChatGPT to email the workspace owner about depleted credits or a reached usage limit.
|
||||
- `mcpServer/oauthLogin/completed` (notify) — emitted after a `mcpServer/oauth/login` flow finishes for a server; payload includes `{ name, success, error? }`.
|
||||
- `mcpServer/startupStatus/updated` (notify) — emitted when a configured MCP server's startup status changes for a loaded thread; payload includes `{ name, status, error }` where `status` is `starting`, `ready`, `failed`, or `cancelled`.
|
||||
|
||||
@@ -1536,7 +1541,7 @@ Field notes:
|
||||
|
||||
```json
|
||||
{ "method": "account/rateLimits/read", "id": 7 }
|
||||
{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null } } }
|
||||
{ "id": 7, "result": { "rateLimits": { "primary": { "usedPercent": 25, "windowDurationMins": 15, "resetsAt": 1730947200 }, "secondary": null, "rateLimitReachedType": null } } }
|
||||
{ "method": "account/rateLimits/updated", "params": { "rateLimits": { … } } }
|
||||
```
|
||||
|
||||
@@ -1545,6 +1550,16 @@ Field notes:
|
||||
- `usedPercent` is current usage within the OpenAI quota window.
|
||||
- `windowDurationMins` is the quota window length.
|
||||
- `resetsAt` is a Unix timestamp (seconds) for the next reset.
|
||||
- `rateLimitReachedType` identifies the backend-classified limit state when one has been reached.
|
||||
|
||||
### 8) Notify a workspace owner about a limit
|
||||
|
||||
```json
|
||||
{ "method": "account/sendAddCreditsNudgeEmail", "id": 8, "params": { "creditType": "credits" } }
|
||||
{ "id": 8, "result": { "status": "sent" } }
|
||||
```
|
||||
|
||||
Use `creditType: "credits"` when workspace credits are depleted, or `creditType: "usage_limit"` when the workspace usage limit has been reached. If the owner was already notified recently, the response status is `cooldown_active`.
|
||||
|
||||
## Experimental API Opt-in
|
||||
|
||||
|
||||
@@ -101,6 +101,7 @@ use codex_app_server_protocol::TurnPlanStep;
|
||||
use codex_app_server_protocol::TurnPlanUpdatedNotification;
|
||||
use codex_app_server_protocol::TurnStartedNotification;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::WarningNotification;
|
||||
use codex_app_server_protocol::build_command_execution_end_item;
|
||||
use codex_app_server_protocol::build_file_change_approval_request_item;
|
||||
use codex_app_server_protocol::build_file_change_begin_item;
|
||||
@@ -268,7 +269,21 @@ pub(crate) async fn apply_bespoke_event_handling(
|
||||
.await;
|
||||
}
|
||||
}
|
||||
EventMsg::Warning(_warning_event) => {}
|
||||
EventMsg::Warning(warning_event) => {
|
||||
if let ApiVersion::V2 = api_version {
|
||||
let notification = WarningNotification {
|
||||
thread_id: Some(conversation_id.to_string()),
|
||||
message: warning_event.message,
|
||||
};
|
||||
if let Some(analytics_events_client) = analytics_events_client.as_ref() {
|
||||
analytics_events_client
|
||||
.track_notification(ServerNotification::Warning(notification.clone()));
|
||||
}
|
||||
outgoing
|
||||
.send_server_notification(ServerNotification::Warning(notification))
|
||||
.await;
|
||||
}
|
||||
}
|
||||
EventMsg::GuardianAssessment(assessment) => {
|
||||
if let ApiVersion::V2 = api_version {
|
||||
let pending_command_execution = match build_item_from_guardian_event(
|
||||
|
||||
@@ -29,6 +29,8 @@ use codex_analytics::TurnSteerRequestError;
|
||||
use codex_app_server_protocol::Account;
|
||||
use codex_app_server_protocol::AccountLoginCompletedNotification;
|
||||
use codex_app_server_protocol::AccountUpdatedNotification;
|
||||
use codex_app_server_protocol::AddCreditsNudgeCreditType;
|
||||
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_app_server_protocol::AppsListParams;
|
||||
use codex_app_server_protocol::AppsListResponse;
|
||||
@@ -117,6 +119,8 @@ use codex_app_server_protocol::ReviewStartParams;
|
||||
use codex_app_server_protocol::ReviewStartResponse;
|
||||
use codex_app_server_protocol::ReviewTarget as ApiReviewTarget;
|
||||
use codex_app_server_protocol::SandboxMode;
|
||||
use codex_app_server_protocol::SendAddCreditsNudgeEmailParams;
|
||||
use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse;
|
||||
use codex_app_server_protocol::ServerNotification;
|
||||
use codex_app_server_protocol::ServerRequestResolvedNotification;
|
||||
use codex_app_server_protocol::SkillSummary;
|
||||
@@ -203,6 +207,7 @@ use codex_app_server_protocol::WindowsSandboxSetupStartParams;
|
||||
use codex_app_server_protocol::WindowsSandboxSetupStartResponse;
|
||||
use codex_app_server_protocol::build_turns_from_rollout_items;
|
||||
use codex_arg0::Arg0DispatchPaths;
|
||||
use codex_backend_client::AddCreditsNudgeCreditType as BackendAddCreditsNudgeCreditType;
|
||||
use codex_backend_client::Client as BackendClient;
|
||||
use codex_chatgpt::connectors;
|
||||
use codex_cloud_requirements::cloud_requirements_loader;
|
||||
@@ -274,6 +279,7 @@ use codex_login::default_client::set_default_client_residency_requirement;
|
||||
use codex_login::login_with_api_key;
|
||||
use codex_login::request_device_code;
|
||||
use codex_login::run_login_server;
|
||||
use codex_mcp::McpRuntimeEnvironment;
|
||||
use codex_mcp::McpServerStatusSnapshot;
|
||||
use codex_mcp::McpSnapshotDetail;
|
||||
use codex_mcp::collect_mcp_server_status_snapshot_with_detail;
|
||||
@@ -1187,6 +1193,10 @@ impl CodexMessageProcessor {
|
||||
self.get_account_rate_limits(to_connection_request_id(request_id))
|
||||
.await;
|
||||
}
|
||||
ClientRequest::SendAddCreditsNudgeEmail { request_id, params } => {
|
||||
self.send_add_credits_nudge_email(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
}
|
||||
ClientRequest::FeedbackUpload { request_id, params } => {
|
||||
self.upload_feedback(to_connection_request_id(request_id), params)
|
||||
.await;
|
||||
@@ -1901,6 +1911,74 @@ impl CodexMessageProcessor {
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_add_credits_nudge_email(
|
||||
&self,
|
||||
request_id: ConnectionRequestId,
|
||||
params: SendAddCreditsNudgeEmailParams,
|
||||
) {
|
||||
match self.send_add_credits_nudge_email_inner(params).await {
|
||||
Ok(status) => {
|
||||
self.outgoing
|
||||
.send_response(request_id, SendAddCreditsNudgeEmailResponse { status })
|
||||
.await;
|
||||
}
|
||||
Err(error) => {
|
||||
self.outgoing.send_error(request_id, error).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_add_credits_nudge_email_inner(
|
||||
&self,
|
||||
params: SendAddCreditsNudgeEmailParams,
|
||||
) -> Result<AddCreditsNudgeEmailStatus, JSONRPCErrorError> {
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
return Err(JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: "codex account authentication required to notify workspace owner"
|
||||
.to_string(),
|
||||
data: None,
|
||||
});
|
||||
};
|
||||
|
||||
if !auth.is_chatgpt_auth() {
|
||||
return Err(JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: "chatgpt authentication required to notify workspace owner".to_string(),
|
||||
data: None,
|
||||
});
|
||||
}
|
||||
|
||||
let client = BackendClient::from_auth(self.config.chatgpt_base_url.clone(), &auth)
|
||||
.map_err(|err| JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to construct backend client: {err}"),
|
||||
data: None,
|
||||
})?;
|
||||
|
||||
match client
|
||||
.send_add_credits_nudge_email(Self::backend_credit_type(params.credit_type))
|
||||
.await
|
||||
{
|
||||
Ok(()) => Ok(AddCreditsNudgeEmailStatus::Sent),
|
||||
Err(err) if err.status().is_some_and(|status| status.as_u16() == 429) => {
|
||||
Ok(AddCreditsNudgeEmailStatus::CooldownActive)
|
||||
}
|
||||
Err(err) => Err(JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to notify workspace owner: {err}"),
|
||||
data: None,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn backend_credit_type(value: AddCreditsNudgeCreditType) -> BackendAddCreditsNudgeCreditType {
|
||||
match value {
|
||||
AddCreditsNudgeCreditType::Credits => BackendAddCreditsNudgeCreditType::Credits,
|
||||
AddCreditsNudgeCreditType::UsageLimit => BackendAddCreditsNudgeCreditType::UsageLimit,
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_account_rate_limits(
|
||||
&self,
|
||||
) -> Result<
|
||||
@@ -4314,6 +4392,7 @@ impl CodexMessageProcessor {
|
||||
thread_id,
|
||||
thread: codex_thread,
|
||||
session_configured,
|
||||
..
|
||||
}) => {
|
||||
let SessionConfiguredEvent { rollout_path, .. } = session_configured;
|
||||
let Some(rollout_path) = rollout_path else {
|
||||
@@ -5646,10 +5725,40 @@ impl CodexMessageProcessor {
|
||||
.to_mcp_config(self.thread_manager.plugins_manager().as_ref())
|
||||
.await;
|
||||
let auth = self.auth_manager.auth().await;
|
||||
let runtime_environment = match self.thread_manager.environment_manager().current().await {
|
||||
Ok(Some(environment)) => {
|
||||
// Status listing has no turn cwd. This fallback is used only
|
||||
// by executor-backed stdio MCPs whose config omits `cwd`.
|
||||
McpRuntimeEnvironment::new(environment, config.cwd.to_path_buf())
|
||||
}
|
||||
Ok(None) => McpRuntimeEnvironment::new(
|
||||
Arc::new(codex_exec_server::Environment::default()),
|
||||
config.cwd.to_path_buf(),
|
||||
),
|
||||
Err(err) => {
|
||||
// TODO(aibrahim): Investigate degrading MCP status listing when
|
||||
// executor environment creation fails.
|
||||
let error = JSONRPCErrorError {
|
||||
code: INTERNAL_ERROR_CODE,
|
||||
message: format!("failed to create environment: {err}"),
|
||||
data: None,
|
||||
};
|
||||
self.outgoing.send_error(request, error).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
tokio::spawn(async move {
|
||||
Self::list_mcp_server_status_task(outgoing, request, params, config, mcp_config, auth)
|
||||
.await;
|
||||
Self::list_mcp_server_status_task(
|
||||
outgoing,
|
||||
request,
|
||||
params,
|
||||
config,
|
||||
mcp_config,
|
||||
auth,
|
||||
runtime_environment,
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5660,6 +5769,7 @@ impl CodexMessageProcessor {
|
||||
config: Config,
|
||||
mcp_config: codex_mcp::McpConfig,
|
||||
auth: Option<CodexAuth>,
|
||||
runtime_environment: McpRuntimeEnvironment,
|
||||
) {
|
||||
let detail = match params.detail.unwrap_or(McpServerStatusDetail::Full) {
|
||||
McpServerStatusDetail::Full => McpSnapshotDetail::Full,
|
||||
@@ -5670,6 +5780,7 @@ impl CodexMessageProcessor {
|
||||
&mcp_config,
|
||||
auth.as_ref(),
|
||||
request_id.request_id.to_string(),
|
||||
runtime_environment,
|
||||
detail,
|
||||
)
|
||||
.await;
|
||||
@@ -6428,55 +6539,19 @@ impl CodexMessageProcessor {
|
||||
|
||||
async fn plugin_list(&self, request_id: ConnectionRequestId, params: PluginListParams) {
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
let PluginListParams {
|
||||
cwds,
|
||||
force_remote_sync,
|
||||
} = params;
|
||||
let PluginListParams { cwds } = params;
|
||||
let roots = cwds.unwrap_or_default();
|
||||
plugins_manager.maybe_start_non_curated_plugin_cache_refresh(&roots);
|
||||
|
||||
let mut config = match self.load_latest_config(/*fallback_cwd*/ None).await {
|
||||
let config = match self.load_latest_config(/*fallback_cwd*/ None).await {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
self.outgoing.send_error(request_id, err).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut remote_sync_error = None;
|
||||
let auth = self.auth_manager.auth().await;
|
||||
|
||||
if force_remote_sync {
|
||||
match plugins_manager
|
||||
.sync_plugins_from_remote(&config, auth.as_ref(), /*additive_only*/ false)
|
||||
.await
|
||||
{
|
||||
Ok(sync_result) => {
|
||||
info!(
|
||||
installed_plugin_ids = ?sync_result.installed_plugin_ids,
|
||||
enabled_plugin_ids = ?sync_result.enabled_plugin_ids,
|
||||
disabled_plugin_ids = ?sync_result.disabled_plugin_ids,
|
||||
uninstalled_plugin_ids = ?sync_result.uninstalled_plugin_ids,
|
||||
"completed plugin/list remote sync"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
error = %err,
|
||||
"plugin/list remote sync failed; returning local marketplace state"
|
||||
);
|
||||
remote_sync_error = Some(err.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
config = match self.load_latest_config(/*fallback_cwd*/ None).await {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
self.outgoing.send_error(request_id, err).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
let config_for_marketplace_listing = config.clone();
|
||||
let plugins_manager_for_marketplace_listing = plugins_manager.clone();
|
||||
let (data, marketplace_load_errors) = match tokio::task::spawn_blocking(move || {
|
||||
@@ -6494,7 +6569,7 @@ impl CodexMessageProcessor {
|
||||
.into_iter()
|
||||
.map(|marketplace| PluginMarketplaceEntry {
|
||||
name: marketplace.name,
|
||||
path: marketplace.path,
|
||||
path: Some(marketplace.path),
|
||||
interface: marketplace.interface.map(|interface| MarketplaceInterface {
|
||||
display_name: interface.display_name,
|
||||
}),
|
||||
@@ -6509,7 +6584,7 @@ impl CodexMessageProcessor {
|
||||
source: marketplace_plugin_source_to_info(plugin.source),
|
||||
install_policy: plugin.policy.installation.into(),
|
||||
auth_policy: plugin.policy.authentication.into(),
|
||||
interface: plugin.interface.map(plugin_interface_to_info),
|
||||
interface: plugin.interface.map(local_plugin_interface_to_info),
|
||||
})
|
||||
.collect(),
|
||||
})
|
||||
@@ -6569,7 +6644,6 @@ impl CodexMessageProcessor {
|
||||
PluginListResponse {
|
||||
marketplaces: data,
|
||||
marketplace_load_errors,
|
||||
remote_sync_error,
|
||||
featured_plugin_ids,
|
||||
},
|
||||
)
|
||||
@@ -6613,8 +6687,40 @@ impl CodexMessageProcessor {
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
let PluginReadParams {
|
||||
marketplace_path,
|
||||
remote_marketplace_name,
|
||||
plugin_name,
|
||||
} = params;
|
||||
let marketplace_path = match (marketplace_path, remote_marketplace_name) {
|
||||
(Some(marketplace_path), None) => marketplace_path,
|
||||
(None, Some(remote_marketplace_name)) => {
|
||||
self.outgoing
|
||||
.send_error(
|
||||
request_id,
|
||||
JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!(
|
||||
"remote plugin read is not supported yet for marketplace {remote_marketplace_name}"
|
||||
),
|
||||
data: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
(Some(_), Some(_)) | (None, None) => {
|
||||
self.outgoing
|
||||
.send_error(
|
||||
request_id,
|
||||
JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: "plugin/read requires exactly one of marketplacePath or remoteMarketplaceName".to_string(),
|
||||
data: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf);
|
||||
|
||||
let config = match self.load_latest_config(config_cwd).await {
|
||||
@@ -6664,7 +6770,7 @@ impl CodexMessageProcessor {
|
||||
enabled: outcome.plugin.enabled,
|
||||
install_policy: outcome.plugin.policy.installation.into(),
|
||||
auth_policy: outcome.plugin.policy.authentication.into(),
|
||||
interface: outcome.plugin.interface.map(plugin_interface_to_info),
|
||||
interface: outcome.plugin.interface.map(local_plugin_interface_to_info),
|
||||
},
|
||||
description: outcome.plugin.description,
|
||||
skills: plugin_skills_to_info(&visible_skills, &outcome.plugin.disabled_skill_paths),
|
||||
@@ -6738,9 +6844,40 @@ impl CodexMessageProcessor {
|
||||
async fn plugin_install(&self, request_id: ConnectionRequestId, params: PluginInstallParams) {
|
||||
let PluginInstallParams {
|
||||
marketplace_path,
|
||||
remote_marketplace_name,
|
||||
plugin_name,
|
||||
force_remote_sync,
|
||||
} = params;
|
||||
let marketplace_path = match (marketplace_path, remote_marketplace_name) {
|
||||
(Some(marketplace_path), None) => marketplace_path,
|
||||
(None, Some(remote_marketplace_name)) => {
|
||||
self.outgoing
|
||||
.send_error(
|
||||
request_id,
|
||||
JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: format!(
|
||||
"remote plugin install is not supported yet for marketplace {remote_marketplace_name}"
|
||||
),
|
||||
data: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
(Some(_), Some(_)) | (None, None) => {
|
||||
self.outgoing
|
||||
.send_error(
|
||||
request_id,
|
||||
JSONRPCErrorError {
|
||||
code: INVALID_REQUEST_ERROR_CODE,
|
||||
message: "plugin/install requires exactly one of marketplacePath or remoteMarketplaceName".to_string(),
|
||||
data: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let config_cwd = marketplace_path.as_path().parent().map(Path::to_path_buf);
|
||||
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
@@ -6749,21 +6886,7 @@ impl CodexMessageProcessor {
|
||||
marketplace_path,
|
||||
};
|
||||
|
||||
let install_result = if force_remote_sync {
|
||||
let config = match self.load_latest_config(config_cwd.clone()).await {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
self.outgoing.send_error(request_id, err).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let auth = self.auth_manager.auth().await;
|
||||
plugins_manager
|
||||
.install_plugin_with_remote_sync(&config, auth.as_ref(), request)
|
||||
.await
|
||||
} else {
|
||||
plugins_manager.install_plugin(request).await
|
||||
};
|
||||
let install_result = plugins_manager.install_plugin(request).await;
|
||||
|
||||
match install_result {
|
||||
Ok(result) => {
|
||||
@@ -6915,27 +7038,10 @@ impl CodexMessageProcessor {
|
||||
request_id: ConnectionRequestId,
|
||||
params: PluginUninstallParams,
|
||||
) {
|
||||
let PluginUninstallParams {
|
||||
plugin_id,
|
||||
force_remote_sync,
|
||||
} = params;
|
||||
let PluginUninstallParams { plugin_id } = params;
|
||||
let plugins_manager = self.thread_manager.plugins_manager();
|
||||
|
||||
let uninstall_result = if force_remote_sync {
|
||||
let config = match self.load_latest_config(/*fallback_cwd*/ None).await {
|
||||
Ok(config) => config,
|
||||
Err(err) => {
|
||||
self.outgoing.send_error(request_id, err).await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let auth = self.auth_manager.auth().await;
|
||||
plugins_manager
|
||||
.uninstall_plugin_with_remote_sync(&config, auth.as_ref(), plugin_id)
|
||||
.await
|
||||
} else {
|
||||
plugins_manager.uninstall_plugin(plugin_id).await
|
||||
};
|
||||
let uninstall_result = plugins_manager.uninstall_plugin(plugin_id).await;
|
||||
|
||||
match uninstall_result {
|
||||
Ok(()) => {
|
||||
@@ -9105,7 +9211,7 @@ fn plugin_skills_to_info(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterface {
|
||||
fn local_plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterface {
|
||||
PluginInterface {
|
||||
display_name: interface.display_name,
|
||||
short_description: interface.short_description,
|
||||
@@ -9119,8 +9225,11 @@ fn plugin_interface_to_info(interface: PluginManifestInterface) -> PluginInterfa
|
||||
default_prompt: interface.default_prompt,
|
||||
brand_color: interface.brand_color,
|
||||
composer_icon: interface.composer_icon,
|
||||
composer_icon_url: None,
|
||||
logo: interface.logo,
|
||||
logo_url: None,
|
||||
screenshots: interface.screenshots,
|
||||
screenshot_urls: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -280,21 +280,16 @@ fn project_config_warning(config: &Config) -> Option<ConfigWarningNotification>
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ true,
|
||||
) {
|
||||
if !matches!(layer.name, ConfigLayerSource::Project { .. })
|
||||
|| layer.disabled_reason.is_none()
|
||||
{
|
||||
let ConfigLayerSource::Project { dot_codex_folder } = &layer.name else {
|
||||
continue;
|
||||
}
|
||||
if let ConfigLayerSource::Project { dot_codex_folder } = &layer.name {
|
||||
disabled_folders.push((
|
||||
dot_codex_folder.as_path().display().to_string(),
|
||||
layer
|
||||
.disabled_reason
|
||||
.as_ref()
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_else(|| "config.toml is disabled.".to_string()),
|
||||
));
|
||||
}
|
||||
};
|
||||
let Some(disabled_reason) = &layer.disabled_reason else {
|
||||
continue;
|
||||
};
|
||||
disabled_folders.push((
|
||||
dot_codex_folder.as_path().display().to_string(),
|
||||
disabled_reason.clone(),
|
||||
));
|
||||
}
|
||||
|
||||
if disabled_folders.is_empty() {
|
||||
@@ -302,8 +297,8 @@ fn project_config_warning(config: &Config) -> Option<ConfigWarningNotification>
|
||||
}
|
||||
|
||||
let mut message = concat!(
|
||||
"Project config.toml files are disabled in the following folders. ",
|
||||
"Settings in those files are ignored, but skills and exec policies still load.\n",
|
||||
"Project-local config, hooks, and exec policies are disabled in the following folders ",
|
||||
"until the project is trusted, but skills still load.\n",
|
||||
)
|
||||
.to_string();
|
||||
for (index, (folder, reason)) in disabled_folders.iter().enumerate() {
|
||||
|
||||
@@ -58,6 +58,7 @@ use codex_app_server_protocol::PluginReadParams;
|
||||
use codex_app_server_protocol::PluginUninstallParams;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::ReviewStartParams;
|
||||
use codex_app_server_protocol::SendAddCreditsNudgeEmailParams;
|
||||
use codex_app_server_protocol::ServerRequest;
|
||||
use codex_app_server_protocol::SkillsListParams;
|
||||
use codex_app_server_protocol::ThreadArchiveParams;
|
||||
@@ -305,6 +306,16 @@ impl McpProcess {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send an `account/sendAddCreditsNudgeEmail` JSON-RPC request.
|
||||
pub async fn send_add_credits_nudge_email_request(
|
||||
&mut self,
|
||||
params: SendAddCreditsNudgeEmailParams,
|
||||
) -> anyhow::Result<i64> {
|
||||
let params = Some(serde_json::to_value(params)?);
|
||||
self.send_request("account/sendAddCreditsNudgeEmail", params)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Send an `account/read` JSON-RPC request.
|
||||
pub async fn send_get_account_request(
|
||||
&mut self,
|
||||
|
||||
@@ -43,6 +43,7 @@ fn preset_to_info(preset: &ModelPreset, priority: i32) -> ModelInfo {
|
||||
supports_parallel_tool_calls: false,
|
||||
supports_image_detail_original: false,
|
||||
context_window: Some(272_000),
|
||||
max_context_window: None,
|
||||
auto_compact_token_limit: None,
|
||||
effective_context_window_percent: 95,
|
||||
experimental_supported_tools: Vec::new(),
|
||||
|
||||
@@ -21,6 +21,7 @@ use codex_app_server_protocol::ThreadStartResponse;
|
||||
use codex_app_server_protocol::TurnStartParams;
|
||||
use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
@@ -477,7 +478,7 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
|
||||
DynamicToolCallOutputContentItem::InputImage { image_url } => {
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url,
|
||||
detail: None,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -535,7 +536,8 @@ async fn dynamic_tool_call_round_trip_sends_content_items_to_model() -> Result<(
|
||||
},
|
||||
{
|
||||
"type": "input_image",
|
||||
"image_url": "data:image/png;base64,AAA"
|
||||
"image_url": "data:image/png;base64,AAA",
|
||||
"detail": "high"
|
||||
}
|
||||
])
|
||||
);
|
||||
|
||||
@@ -98,10 +98,7 @@ async fn external_agent_config_import_sends_completion_notification_for_local_pl
|
||||
assert_eq!(notification.method, "externalAgentConfig/import/completed");
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: None,
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.send_plugin_list_request(PluginListParams { cwds: None })
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
|
||||
@@ -44,12 +44,6 @@ use tempfile::TempDir;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::task::JoinHandle;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
// Plugin install tests wait on connector discovery after the install response path
|
||||
// starts, which is noticeably slower on Windows CI.
|
||||
@@ -82,6 +76,97 @@ async fn plugin_install_rejects_relative_marketplace_paths() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_rejects_missing_install_source() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let err = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(
|
||||
err.error
|
||||
.message
|
||||
.contains("requires exactly one of marketplacePath or remoteMarketplaceName")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_rejects_multiple_install_sources() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path: Some(AbsolutePathBuf::try_from(
|
||||
codex_home.path().join("marketplace.json"),
|
||||
)?),
|
||||
remote_marketplace_name: Some("openai-curated".to_string()),
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let err = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(
|
||||
err.error
|
||||
.message
|
||||
.contains("requires exactly one of marketplacePath or remoteMarketplaceName")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_rejects_remote_marketplace_until_remote_install_is_supported() -> Result<()>
|
||||
{
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: Some("openai-curated".to_string()),
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let err = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(
|
||||
err.error
|
||||
.message
|
||||
.contains("remote plugin install is not supported yet")
|
||||
);
|
||||
assert!(err.error.message.contains("openai-curated"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -90,11 +175,11 @@ async fn plugin_install_returns_invalid_request_for_missing_marketplace_file() -
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
marketplace_path: Some(AbsolutePathBuf::try_from(
|
||||
codex_home.path().join("missing-marketplace.json"),
|
||||
)?,
|
||||
)?),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "missing-plugin".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -131,9 +216,9 @@ async fn plugin_install_returns_invalid_request_for_not_available_plugin() -> Re
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path,
|
||||
marketplace_path: Some(marketplace_path),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -181,9 +266,9 @@ async fn plugin_install_returns_invalid_request_for_disallowed_product_plugin()
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path,
|
||||
marketplace_path: Some(marketplace_path),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -198,76 +283,6 @@ async fn plugin_install_returns_invalid_request_for_disallowed_product_plugin()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_force_remote_sync_enables_remote_plugin_before_local_install() -> Result<()>
|
||||
{
|
||||
let server = MockServer::start().await;
|
||||
let codex_home = TempDir::new()?;
|
||||
write_plugin_remote_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let repo_root = TempDir::new()?;
|
||||
write_plugin_marketplace(
|
||||
repo_root.path(),
|
||||
"debug",
|
||||
"sample-plugin",
|
||||
"./sample-plugin",
|
||||
/*install_policy*/ None,
|
||||
/*auth_policy*/ None,
|
||||
)?;
|
||||
write_plugin_source(repo_root.path(), "sample-plugin", &[])?;
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/plugins/sample-plugin@debug/enable"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(r#"{"id":"sample-plugin@debug","enabled":true}"#),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
force_remote_sync: true,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginInstallResponse = to_response(response)?;
|
||||
assert_eq!(response.apps_needing_auth, Vec::<AppSummary>::new());
|
||||
|
||||
assert!(
|
||||
codex_home
|
||||
.path()
|
||||
.join("plugins/cache/debug/sample-plugin/local/.codex-plugin/plugin.json")
|
||||
.is_file()
|
||||
);
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(config.contains(r#"[plugins."sample-plugin@debug"]"#));
|
||||
assert!(config.contains("enabled = true"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_install_tracks_analytics_event() -> Result<()> {
|
||||
let analytics_server = start_analytics_events_server().await?;
|
||||
@@ -300,9 +315,9 @@ async fn plugin_install_tracks_analytics_event() -> Result<()> {
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path,
|
||||
marketplace_path: Some(marketplace_path),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
@@ -415,9 +430,9 @@ async fn plugin_install_returns_apps_needing_auth() -> Result<()> {
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path,
|
||||
marketplace_path: Some(marketplace_path),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -499,9 +514,9 @@ async fn plugin_install_filters_disallowed_apps_needing_auth() -> Result<()> {
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path,
|
||||
marketplace_path: Some(marketplace_path),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -566,9 +581,9 @@ async fn plugin_install_makes_bundled_mcp_servers_available_to_followup_requests
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_install_request(PluginInstallParams {
|
||||
marketplace_path,
|
||||
marketplace_path: Some(marketplace_path),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
@@ -758,23 +773,6 @@ fn write_analytics_config(codex_home: &std::path::Path, base_url: &str) -> std::
|
||||
)
|
||||
}
|
||||
|
||||
fn write_plugin_remote_sync_config(
|
||||
codex_home: &std::path::Path,
|
||||
base_url: &str,
|
||||
) -> std::io::Result<()> {
|
||||
std::fs::write(
|
||||
codex_home.join("config.toml"),
|
||||
format!(
|
||||
r#"
|
||||
chatgpt_base_url = "{base_url}"
|
||||
|
||||
[features]
|
||||
plugins = true
|
||||
"#
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_plugin_marketplace(
|
||||
repo_root: &std::path::Path,
|
||||
marketplace_name: &str,
|
||||
|
||||
@@ -71,7 +71,6 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -86,7 +85,7 @@ async fn plugin_list_skips_invalid_marketplace_file_and_reports_error() -> Resul
|
||||
response
|
||||
.marketplaces
|
||||
.iter()
|
||||
.all(|marketplace| { marketplace.path != marketplace_path }),
|
||||
.all(|marketplace| { marketplace.path.as_ref() != Some(&marketplace_path) }),
|
||||
"invalid marketplace should be skipped"
|
||||
);
|
||||
assert_eq!(response.marketplace_load_errors.len(), 1);
|
||||
@@ -200,7 +199,6 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
|
||||
AbsolutePathBuf::try_from(valid_repo_root.path())?,
|
||||
AbsolutePathBuf::try_from(invalid_repo_root.path())?,
|
||||
]),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -215,7 +213,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
|
||||
response.marketplaces,
|
||||
vec![PluginMarketplaceEntry {
|
||||
name: "valid-marketplace".to_string(),
|
||||
path: valid_marketplace_path,
|
||||
path: Some(valid_marketplace_path),
|
||||
interface: None,
|
||||
plugins: vec![PluginSummary {
|
||||
id: "valid-plugin@valid-marketplace".to_string(),
|
||||
@@ -243,7 +241,6 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_
|
||||
"unexpected error: {:?}",
|
||||
response.marketplace_load_errors
|
||||
);
|
||||
assert_eq!(response.remote_sync_error, None);
|
||||
assert!(response.featured_plugin_ids.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
@@ -314,7 +311,6 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -329,7 +325,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
|
||||
response.marketplaces,
|
||||
vec![PluginMarketplaceEntry {
|
||||
name: "alternate-marketplace".to_string(),
|
||||
path: marketplace_path,
|
||||
path: Some(marketplace_path),
|
||||
interface: None,
|
||||
plugins: vec![
|
||||
PluginSummary {
|
||||
@@ -355,8 +351,11 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab
|
||||
default_prompt: None,
|
||||
brand_color: None,
|
||||
composer_icon: None,
|
||||
composer_icon_url: None,
|
||||
logo: None,
|
||||
logo_url: None,
|
||||
screenshots: Vec::new(),
|
||||
screenshot_urls: Vec::new(),
|
||||
}),
|
||||
},
|
||||
PluginSummary {
|
||||
@@ -412,10 +411,7 @@ async fn plugin_list_accepts_omitted_cwds() -> Result<()> {
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: None,
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.send_plugin_list_request(PluginListParams { cwds: None })
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
@@ -486,7 +482,6 @@ enabled = false
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -501,11 +496,13 @@ enabled = false
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| {
|
||||
marketplace.path
|
||||
== AbsolutePathBuf::try_from(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
marketplace.path.as_ref()
|
||||
== Some(
|
||||
&AbsolutePathBuf::try_from(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
)
|
||||
.expect("absolute marketplace path"),
|
||||
)
|
||||
.expect("absolute marketplace path")
|
||||
})
|
||||
.expect("expected repo marketplace entry");
|
||||
|
||||
@@ -641,7 +638,6 @@ enabled = false
|
||||
AbsolutePathBuf::try_from(workspace_enabled.path())?,
|
||||
AbsolutePathBuf::try_from(workspace_default.path())?,
|
||||
]),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -725,7 +721,6 @@ async fn plugin_list_returns_plugin_interface_with_absolute_asset_paths() -> Res
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -838,7 +833,6 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> {
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: Some(vec![AbsolutePathBuf::try_from(repo_root.path())?]),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -865,163 +859,6 @@ async fn plugin_list_accepts_legacy_string_default_prompt() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_force_remote_sync_returns_remote_sync_error_on_fail_open() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
write_plugin_sync_config(codex_home.path(), "https://chatgpt.com/backend-api/")?;
|
||||
write_openai_curated_marketplace(codex_home.path(), &["linear"])?;
|
||||
write_installed_plugin(&codex_home, "openai-curated", "linear")?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: None,
|
||||
force_remote_sync: true,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
|
||||
assert!(
|
||||
response
|
||||
.remote_sync_error
|
||||
.as_deref()
|
||||
.is_some_and(|message| message.contains("chatgpt authentication required"))
|
||||
);
|
||||
let curated_marketplace = response
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "openai-curated")
|
||||
.expect("expected openai-curated marketplace entry");
|
||||
assert_eq!(
|
||||
curated_marketplace
|
||||
.plugins
|
||||
.into_iter()
|
||||
.map(|plugin| (plugin.id, plugin.installed, plugin.enabled))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![("linear@openai-curated".to_string(), true, false)]
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_list_force_remote_sync_reconciles_curated_plugin_state() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let server = MockServer::start().await;
|
||||
write_plugin_sync_config(codex_home.path(), &format!("{}/backend-api/", server.uri()))?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
write_openai_curated_marketplace(codex_home.path(), &["linear", "gmail", "calendar"])?;
|
||||
write_installed_plugin(&codex_home, "openai-curated", "linear")?;
|
||||
write_installed_plugin(&codex_home, "openai-curated", "gmail")?;
|
||||
write_installed_plugin(&codex_home, "openai-curated", "calendar")?;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/plugins/list"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_string(
|
||||
r#"[
|
||||
{"id":"1","name":"linear","marketplace_name":"openai-curated","version":"1.0.0","enabled":true},
|
||||
{"id":"2","name":"gmail","marketplace_name":"openai-curated","version":"1.0.0","enabled":false}
|
||||
]"#,
|
||||
))
|
||||
.mount(&server)
|
||||
.await;
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/backend-api/plugins/featured"))
|
||||
.and(query_param("platform", "codex"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(r#"["linear@openai-curated","calendar@openai-curated"]"#),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: None,
|
||||
force_remote_sync: true,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginListResponse = to_response(response)?;
|
||||
assert_eq!(response.remote_sync_error, None);
|
||||
assert_eq!(
|
||||
response.featured_plugin_ids,
|
||||
vec![
|
||||
"linear@openai-curated".to_string(),
|
||||
"calendar@openai-curated".to_string(),
|
||||
]
|
||||
);
|
||||
|
||||
let curated_marketplace = response
|
||||
.marketplaces
|
||||
.into_iter()
|
||||
.find(|marketplace| marketplace.name == "openai-curated")
|
||||
.expect("expected openai-curated marketplace entry");
|
||||
assert_eq!(
|
||||
curated_marketplace
|
||||
.plugins
|
||||
.into_iter()
|
||||
.map(|plugin| (plugin.id, plugin.installed, plugin.enabled))
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
("linear@openai-curated".to_string(), true, true),
|
||||
("gmail@openai-curated".to_string(), false, false),
|
||||
("calendar@openai-curated".to_string(), false, false),
|
||||
]
|
||||
);
|
||||
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(config.contains(r#"[plugins."linear@openai-curated"]"#));
|
||||
assert!(!config.contains(r#"[plugins."gmail@openai-curated"]"#));
|
||||
assert!(!config.contains(r#"[plugins."calendar@openai-curated"]"#));
|
||||
|
||||
assert!(
|
||||
codex_home
|
||||
.path()
|
||||
.join("plugins/cache/openai-curated/linear/local")
|
||||
.is_dir()
|
||||
);
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join("plugins/cache/openai-curated/gmail")
|
||||
.exists()
|
||||
);
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join("plugins/cache/openai-curated/calendar")
|
||||
.exists()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -1069,10 +906,7 @@ async fn app_server_startup_remote_plugin_sync_runs_once() -> Result<()> {
|
||||
wait_for_remote_plugin_request_count(&server, "/plugins/list", /*expected_count*/ 1)
|
||||
.await?;
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: None,
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.send_plugin_list_request(PluginListParams { cwds: None })
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
@@ -1128,10 +962,7 @@ async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Resul
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: None,
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.send_plugin_list_request(PluginListParams { cwds: None })
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
@@ -1145,7 +976,6 @@ async fn plugin_list_fetches_featured_plugin_ids_without_chatgpt_auth() -> Resul
|
||||
response.featured_plugin_ids,
|
||||
vec!["linear@openai-curated".to_string()]
|
||||
);
|
||||
assert_eq!(response.remote_sync_error, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1169,10 +999,7 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() ->
|
||||
wait_for_featured_plugin_request_count(&server, /*expected_count*/ 1).await?;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_list_request(PluginListParams {
|
||||
cwds: None,
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.send_plugin_list_request(PluginListParams { cwds: None })
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
@@ -1186,7 +1013,6 @@ async fn plugin_list_uses_warmed_featured_plugin_ids_cache_on_first_request() ->
|
||||
response.featured_plugin_ids,
|
||||
vec!["linear@openai-curated".to_string()]
|
||||
);
|
||||
assert_eq!(response.remote_sync_error, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,96 @@ use tokio::time::timeout;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_rejects_missing_read_source() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let err = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(
|
||||
err.error
|
||||
.message
|
||||
.contains("requires exactly one of marketplacePath or remoteMarketplaceName")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_rejects_multiple_read_sources() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: Some(AbsolutePathBuf::try_from(
|
||||
codex_home.path().join("marketplace.json"),
|
||||
)?),
|
||||
remote_marketplace_name: Some("openai-curated".to_string()),
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let err = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(
|
||||
err.error
|
||||
.message
|
||||
.contains("requires exactly one of marketplacePath or remoteMarketplaceName")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_rejects_remote_marketplace_until_remote_read_is_supported() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: None,
|
||||
remote_marketplace_name: Some("openai-curated".to_string()),
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let err = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(err.error.code, -32600);
|
||||
assert!(
|
||||
err.error
|
||||
.message
|
||||
.contains("remote plugin read is not supported yet")
|
||||
);
|
||||
assert!(err.error.message.contains("openai-curated"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_returns_plugin_details_with_bundle_contents() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -179,7 +269,8 @@ enabled = true
|
||||
AbsolutePathBuf::try_from(repo_root.path().join(".agents/plugins/marketplace.json"))?;
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: marketplace_path.clone(),
|
||||
marketplace_path: Some(marketplace_path.clone()),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "demo-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
@@ -326,7 +417,8 @@ async fn plugin_read_returns_app_needs_auth() -> Result<()> {
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path,
|
||||
marketplace_path: Some(marketplace_path),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "sample-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
@@ -392,9 +484,10 @@ async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> {
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
marketplace_path: Some(AbsolutePathBuf::try_from(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
)?,
|
||||
)?),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "demo-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
@@ -418,6 +511,76 @@ async fn plugin_read_accepts_legacy_string_default_prompt() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_describes_uninstalled_git_source_without_cloning() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
let repo_root = TempDir::new()?;
|
||||
let missing_remote_repo = repo_root.path().join("missing-remote-plugin-repo");
|
||||
let missing_remote_repo_url = url::Url::from_directory_path(&missing_remote_repo)
|
||||
.unwrap()
|
||||
.to_string();
|
||||
std::fs::create_dir_all(repo_root.path().join(".git"))?;
|
||||
std::fs::create_dir_all(repo_root.path().join(".agents/plugins"))?;
|
||||
std::fs::write(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{{
|
||||
"name": "toolkit",
|
||||
"source": {{
|
||||
"source": "git-subdir",
|
||||
"url": "{missing_remote_repo_url}",
|
||||
"path": "plugins/toolkit"
|
||||
}}
|
||||
}}
|
||||
]
|
||||
}}"#
|
||||
),
|
||||
)?;
|
||||
write_plugins_enabled_config(&codex_home)?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: Some(AbsolutePathBuf::try_from(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
)?),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "toolkit".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginReadResponse = to_response(response)?;
|
||||
|
||||
let expected_description = format!(
|
||||
"This is a cross-repo plugin. Install it to view more detailed information. The source of the plugin is {missing_remote_repo_url}, path `plugins/toolkit`."
|
||||
);
|
||||
assert_eq!(
|
||||
response.plugin.description.as_deref(),
|
||||
Some(expected_description.as_str())
|
||||
);
|
||||
assert!(!response.plugin.summary.installed);
|
||||
assert!(response.plugin.skills.is_empty());
|
||||
assert!(response.plugin.apps.is_empty());
|
||||
assert!(response.plugin.mcp_servers.is_empty());
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join("plugins/.marketplace-plugin-source-staging")
|
||||
.exists()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -446,9 +609,10 @@ async fn plugin_read_returns_invalid_request_when_plugin_is_missing() -> Result<
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
marketplace_path: Some(AbsolutePathBuf::try_from(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
)?,
|
||||
)?),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "missing-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
@@ -498,9 +662,10 @@ async fn plugin_read_returns_invalid_request_when_plugin_manifest_is_missing() -
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_read_request(PluginReadParams {
|
||||
marketplace_path: AbsolutePathBuf::try_from(
|
||||
marketplace_path: Some(AbsolutePathBuf::try_from(
|
||||
repo_root.path().join(".agents/plugins/marketplace.json"),
|
||||
)?,
|
||||
)?),
|
||||
remote_marketplace_name: None,
|
||||
plugin_name: "demo-plugin".to_string(),
|
||||
})
|
||||
.await?;
|
||||
|
||||
@@ -16,12 +16,6 @@ use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::timeout;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
const DEFAULT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
@@ -44,7 +38,6 @@ enabled = true
|
||||
|
||||
let params = PluginUninstallParams {
|
||||
plugin_id: "sample-plugin@debug".to_string(),
|
||||
force_remote_sync: false,
|
||||
};
|
||||
|
||||
let request_id = mcp.send_plugin_uninstall_request(params.clone()).await?;
|
||||
@@ -77,74 +70,6 @@ enabled = true
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_uninstall_force_remote_sync_calls_remote_uninstall_first() -> Result<()> {
|
||||
let server = MockServer::start().await;
|
||||
let codex_home = TempDir::new()?;
|
||||
write_installed_plugin(&codex_home, "debug", "sample-plugin")?;
|
||||
std::fs::write(
|
||||
codex_home.path().join("config.toml"),
|
||||
format!(
|
||||
r#"chatgpt_base_url = "{}/backend-api/"
|
||||
|
||||
[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."sample-plugin@debug"]
|
||||
enabled = true
|
||||
"#,
|
||||
server.uri()
|
||||
),
|
||||
)?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.chatgpt_user_id("user-123")
|
||||
.chatgpt_account_id("account-123"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/plugins/sample-plugin@debug/uninstall"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(r#"{"id":"sample-plugin@debug","enabled":false}"#),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_plugin_uninstall_request(PluginUninstallParams {
|
||||
plugin_id: "sample-plugin@debug".to_string(),
|
||||
force_remote_sync: true,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let response: PluginUninstallResponse = to_response(response)?;
|
||||
assert_eq!(response, PluginUninstallResponse {});
|
||||
|
||||
assert!(
|
||||
!codex_home
|
||||
.path()
|
||||
.join("plugins/cache/debug/sample-plugin")
|
||||
.exists()
|
||||
);
|
||||
let config = std::fs::read_to_string(codex_home.path().join("config.toml"))?;
|
||||
assert!(!config.contains(r#"[plugins."sample-plugin@debug"]"#));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn plugin_uninstall_tracks_analytics_event() -> Result<()> {
|
||||
let analytics_server = start_analytics_events_server().await?;
|
||||
@@ -172,7 +97,6 @@ async fn plugin_uninstall_tracks_analytics_event() -> Result<()> {
|
||||
let request_id = mcp
|
||||
.send_plugin_uninstall_request(PluginUninstallParams {
|
||||
plugin_id: "sample-plugin@debug".to_string(),
|
||||
force_remote_sync: false,
|
||||
})
|
||||
.await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
|
||||
@@ -3,6 +3,8 @@ use app_test_support::ChatGptAuthFixture;
|
||||
use app_test_support::McpProcess;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_chatgpt_auth;
|
||||
use codex_app_server_protocol::AddCreditsNudgeCreditType;
|
||||
use codex_app_server_protocol::AddCreditsNudgeEmailStatus;
|
||||
use codex_app_server_protocol::GetAccountRateLimitsResponse;
|
||||
use codex_app_server_protocol::JSONRPCError;
|
||||
use codex_app_server_protocol::JSONRPCResponse;
|
||||
@@ -11,6 +13,8 @@ use codex_app_server_protocol::RateLimitReachedType;
|
||||
use codex_app_server_protocol::RateLimitSnapshot;
|
||||
use codex_app_server_protocol::RateLimitWindow;
|
||||
use codex_app_server_protocol::RequestId;
|
||||
use codex_app_server_protocol::SendAddCreditsNudgeEmailParams;
|
||||
use codex_app_server_protocol::SendAddCreditsNudgeEmailResponse;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_protocol::account::PlanType as AccountPlanType;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -27,6 +31,7 @@ use wiremock::matchers::path;
|
||||
|
||||
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
||||
const INVALID_REQUEST_ERROR_CODE: i64 = -32600;
|
||||
const INTERNAL_ERROR_CODE: i64 = -32603;
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_account_rate_limits_requires_auth() -> Result<()> {
|
||||
@@ -229,6 +234,209 @@ async fn get_account_rate_limits_returns_snapshot() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_add_credits_nudge_email_requires_auth() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams {
|
||||
credit_type: AddCreditsNudgeCreditType::Credits,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let error: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(error.id, RequestId::Integer(request_id));
|
||||
assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE);
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"codex account authentication required to notify workspace owner"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_add_credits_nudge_email_requires_chatgpt_auth() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
let mut mcp = McpProcess::new(codex_home.path()).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
login_with_api_key(&mut mcp, "sk-test-key").await?;
|
||||
|
||||
let request_id = mcp
|
||||
.send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams {
|
||||
credit_type: AddCreditsNudgeCreditType::UsageLimit,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let error: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(error.id, RequestId::Integer(request_id));
|
||||
assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE);
|
||||
assert_eq!(
|
||||
error.error.message,
|
||||
"chatgpt authentication required to notify workspace owner"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(target_os = "windows", ignore = "covered by Linux and macOS CI")]
|
||||
#[tokio::test]
|
||||
async fn send_add_credits_nudge_email_posts_expected_body() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.plan_type("pro"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let server_url = server.uri();
|
||||
write_chatgpt_base_url(codex_home.path(), &server_url)?;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/codex/accounts/send_add_credits_nudge_email"))
|
||||
.and(header("authorization", "Bearer chatgpt-token"))
|
||||
.and(header("chatgpt-account-id", "account-123"))
|
||||
.and(wiremock::matchers::body_json(json!({
|
||||
"credit_type": "usage_limit",
|
||||
})))
|
||||
.respond_with(ResponseTemplate::new(200))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams {
|
||||
credit_type: AddCreditsNudgeCreditType::UsageLimit,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let received: SendAddCreditsNudgeEmailResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(received.status, AddCreditsNudgeEmailStatus::Sent);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(target_os = "windows", ignore = "covered by Linux and macOS CI")]
|
||||
#[tokio::test]
|
||||
async fn send_add_credits_nudge_email_maps_cooldown() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.plan_type("pro"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let server_url = server.uri();
|
||||
write_chatgpt_base_url(codex_home.path(), &server_url)?;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/codex/accounts/send_add_credits_nudge_email"))
|
||||
.respond_with(ResponseTemplate::new(429))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams {
|
||||
credit_type: AddCreditsNudgeCreditType::Credits,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let response: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
let received: SendAddCreditsNudgeEmailResponse = to_response(response)?;
|
||||
|
||||
assert_eq!(received.status, AddCreditsNudgeEmailStatus::CooldownActive);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg_attr(target_os = "windows", ignore = "covered by Linux and macOS CI")]
|
||||
#[tokio::test]
|
||||
async fn send_add_credits_nudge_email_surfaces_backend_failure() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
write_chatgpt_auth(
|
||||
codex_home.path(),
|
||||
ChatGptAuthFixture::new("chatgpt-token")
|
||||
.account_id("account-123")
|
||||
.plan_type("pro"),
|
||||
AuthCredentialsStoreMode::File,
|
||||
)?;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
let server_url = server.uri();
|
||||
write_chatgpt_base_url(codex_home.path(), &server_url)?;
|
||||
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/api/codex/accounts/send_add_credits_nudge_email"))
|
||||
.respond_with(ResponseTemplate::new(500).set_body_string("boom"))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let mut mcp = McpProcess::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?;
|
||||
timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??;
|
||||
|
||||
let request_id = mcp
|
||||
.send_add_credits_nudge_email_request(SendAddCreditsNudgeEmailParams {
|
||||
credit_type: AddCreditsNudgeCreditType::Credits,
|
||||
})
|
||||
.await?;
|
||||
|
||||
let error: JSONRPCError = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_error_message(RequestId::Integer(request_id)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
assert_eq!(error.id, RequestId::Integer(request_id));
|
||||
assert_eq!(error.error.code, INTERNAL_ERROR_CODE);
|
||||
assert!(
|
||||
error
|
||||
.error
|
||||
.message
|
||||
.contains("failed to notify workspace owner"),
|
||||
"unexpected error message: {}",
|
||||
error.error.message
|
||||
);
|
||||
assert_eq!(error.error.data, None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn login_with_api_key(mcp: &mut McpProcess, api_key: &str) -> Result<()> {
|
||||
let request_id = mcp.send_login_account_api_key_request(api_key).await?;
|
||||
let response: JSONRPCResponse = timeout(
|
||||
|
||||
@@ -21,6 +21,7 @@ use codex_app_server_protocol::ThreadStatus;
|
||||
use codex_app_server_protocol::ThreadStatusChangedNotification;
|
||||
use codex_config::types::AuthCredentialsStoreMode;
|
||||
use codex_core::config::set_project_trust_level;
|
||||
use codex_core::config_loader::project_trust_key;
|
||||
use codex_exec_server::LOCAL_FS;
|
||||
use codex_git_utils::resolve_root_git_project_for_trust;
|
||||
use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR;
|
||||
@@ -722,7 +723,8 @@ model_reasoning_effort = "high"
|
||||
let trusted_root = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &workspace_abs)
|
||||
.await
|
||||
.unwrap_or(workspace_abs);
|
||||
assert!(config_toml.contains(&persisted_trust_path(trusted_root.as_path())));
|
||||
let trusted_root_key = project_trust_key(trusted_root.as_path());
|
||||
assert!(config_toml.contains(&trusted_root_key));
|
||||
assert!(config_toml.contains("trust_level = \"trusted\""));
|
||||
|
||||
Ok(())
|
||||
@@ -761,8 +763,10 @@ async fn thread_start_with_nested_git_cwd_trusts_repo_root() -> Result<()> {
|
||||
let trusted_root = resolve_root_git_project_for_trust(LOCAL_FS.as_ref(), &nested_abs)
|
||||
.await
|
||||
.expect("git root should resolve");
|
||||
assert!(config_toml.contains(&persisted_trust_path(trusted_root.as_path())));
|
||||
assert!(!config_toml.contains(&persisted_trust_path(&nested)));
|
||||
let trusted_root_key = project_trust_key(trusted_root.as_path());
|
||||
let nested_key = project_trust_key(&nested);
|
||||
assert!(config_toml.contains(&trusted_root_key));
|
||||
assert!(!config_toml.contains(&nested_key));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -856,21 +860,6 @@ fn create_config_toml_without_approval_policy(
|
||||
)
|
||||
}
|
||||
|
||||
fn persisted_trust_path(project_path: &Path) -> String {
|
||||
let project_path =
|
||||
std::fs::canonicalize(project_path).unwrap_or_else(|_| project_path.to_path_buf());
|
||||
let project_path = project_path.display().to_string();
|
||||
|
||||
if let Some(project_path) = project_path.strip_prefix(r"\\?\UNC\") {
|
||||
return format!(r"\\{project_path}");
|
||||
}
|
||||
|
||||
project_path
|
||||
.strip_prefix(r"\\?\")
|
||||
.unwrap_or(&project_path)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn create_config_toml_with_optional_approval_policy(
|
||||
codex_home: &Path,
|
||||
server_uri: &str,
|
||||
|
||||
@@ -11,6 +11,7 @@ use app_test_support::create_shell_command_sse_response;
|
||||
use app_test_support::format_with_current_shell_display;
|
||||
use app_test_support::to_response;
|
||||
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
|
||||
use app_test_support::write_models_cache;
|
||||
use codex_app_server::INPUT_TOO_LARGE_ERROR_CODE;
|
||||
use codex_app_server::INVALID_PARAMS_ERROR_CODE;
|
||||
use codex_app_server_protocol::ByteRange;
|
||||
@@ -45,6 +46,7 @@ use codex_app_server_protocol::TurnStartResponse;
|
||||
use codex_app_server_protocol::TurnStartedNotification;
|
||||
use codex_app_server_protocol::TurnStatus;
|
||||
use codex_app_server_protocol::UserInput as V2UserInput;
|
||||
use codex_app_server_protocol::WarningNotification;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_core::personality_migration::PERSONALITY_MIGRATION_FILENAME;
|
||||
use codex_features::FEATURES;
|
||||
@@ -244,6 +246,111 @@ async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills() -> Result<()> {
|
||||
let responses = vec![create_final_assistant_message_sse_response("Done")?];
|
||||
let server = create_mock_responses_server_sequence_unchecked(responses).await;
|
||||
|
||||
let codex_home = TempDir::new()?;
|
||||
create_config_toml(
|
||||
codex_home.path(),
|
||||
&server.uri(),
|
||||
"never",
|
||||
&BTreeMap::from([(Feature::Personality, true)]),
|
||||
)?;
|
||||
write_models_cache(codex_home.path())?;
|
||||
let cache_path = codex_home.path().join("models_cache.json");
|
||||
let mut cache: serde_json::Value =
|
||||
serde_json::from_str(&std::fs::read_to_string(&cache_path)?)?;
|
||||
let models = cache["models"]
|
||||
.as_array_mut()
|
||||
.expect("models_cache.json models should be an array");
|
||||
let entry = models
|
||||
.first_mut()
|
||||
.expect("models cache should not be empty");
|
||||
let model = entry["slug"]
|
||||
.as_str()
|
||||
.expect("model slug should be present")
|
||||
.to_string();
|
||||
entry["context_window"] = serde_json::Value::from(100);
|
||||
std::fs::write(&cache_path, serde_json::to_string_pretty(&cache)?)?;
|
||||
let config_path = codex_home.path().join("config.toml");
|
||||
let config = std::fs::read_to_string(&config_path)?;
|
||||
std::fs::write(
|
||||
&config_path,
|
||||
config.replace("model = \"mock-model\"", &format!("model = \"{model}\"")),
|
||||
)?;
|
||||
write_test_skill(codex_home.path(), "alpha-skill")?;
|
||||
write_test_skill(codex_home.path(), "beta-skill")?;
|
||||
|
||||
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::default())
|
||||
.await?;
|
||||
let thread_resp: JSONRPCResponse = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
|
||||
)
|
||||
.await??;
|
||||
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
|
||||
|
||||
let turn_req = mcp
|
||||
.send_turn_start_request(TurnStartParams {
|
||||
thread_id: thread.id.clone(),
|
||||
input: vec![V2UserInput::Text {
|
||||
text: "Hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let notification = timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("warning"),
|
||||
)
|
||||
.await??;
|
||||
let params = notification.params.expect("warning params");
|
||||
let warning: WarningNotification =
|
||||
serde_json::from_value(params).expect("deserialize warning notification");
|
||||
assert_eq!(warning.thread_id.as_deref(), Some(thread.id.as_str()));
|
||||
assert_eq!(
|
||||
warning.message,
|
||||
"Some enabled skills were not included in the model-visible skills list for this session. Mention a skill by name or path if you need it."
|
||||
);
|
||||
|
||||
timeout(
|
||||
DEFAULT_READ_TIMEOUT,
|
||||
mcp.read_stream_until_notification_message("turn/completed"),
|
||||
)
|
||||
.await??;
|
||||
|
||||
let requests = server
|
||||
.received_requests()
|
||||
.await
|
||||
.expect("failed to fetch received requests");
|
||||
let request = requests
|
||||
.last()
|
||||
.expect("expected at least one model request");
|
||||
assert!(
|
||||
body_contains(request, "## Skills"),
|
||||
"expected outgoing request to include the skills section"
|
||||
);
|
||||
assert!(
|
||||
!body_contains(request, "- alpha-skill:") && !body_contains(request, "- beta-skill:"),
|
||||
"expected trimmed skills to be omitted from the outgoing request body"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn thread_start_omits_empty_instruction_overrides_from_model_request() -> Result<()> {
|
||||
let server = responses::start_mock_server().await;
|
||||
@@ -2902,3 +3009,12 @@ stream_max_retries = 0
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn write_test_skill(codex_home: &Path, name: &str) -> std::io::Result<()> {
|
||||
let skill_dir = codex_home.join("skills").join(name);
|
||||
std::fs::create_dir_all(&skill_dir)?;
|
||||
std::fs::write(
|
||||
skill_dir.join("SKILL.md"),
|
||||
format!("---\nname: {name}\ndescription: {name} description\n---\n\n# Body\n"),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ use reqwest::header::HeaderMap;
|
||||
use reqwest::header::HeaderName;
|
||||
use reqwest::header::HeaderValue;
|
||||
use reqwest::header::USER_AGENT;
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
use std::fmt;
|
||||
|
||||
@@ -81,6 +82,18 @@ impl From<anyhow::Error> for RequestError {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum AddCreditsNudgeCreditType {
|
||||
Credits,
|
||||
UsageLimit,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SendAddCreditsNudgeEmailRequest {
|
||||
credit_type: AddCreditsNudgeCreditType,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum PathStyle {
|
||||
/// /api/codex/…
|
||||
@@ -282,6 +295,21 @@ impl Client {
|
||||
Ok(Self::rate_limit_snapshots_from_payload(payload))
|
||||
}
|
||||
|
||||
pub async fn send_add_credits_nudge_email(
|
||||
&self,
|
||||
credit_type: AddCreditsNudgeCreditType,
|
||||
) -> std::result::Result<(), RequestError> {
|
||||
let url = self.send_add_credits_nudge_email_url();
|
||||
let req = self
|
||||
.http
|
||||
.post(&url)
|
||||
.headers(self.headers())
|
||||
.header(CONTENT_TYPE, HeaderValue::from_static("application/json"))
|
||||
.json(&SendAddCreditsNudgeEmailRequest { credit_type });
|
||||
self.exec_request_detailed(req, "POST", &url).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn list_tasks(
|
||||
&self,
|
||||
limit: Option<i32>,
|
||||
@@ -490,6 +518,21 @@ impl Client {
|
||||
}
|
||||
}
|
||||
|
||||
fn send_add_credits_nudge_email_url(&self) -> String {
|
||||
match self.path_style {
|
||||
PathStyle::CodexApi => format!(
|
||||
"{}/api/codex/accounts/send_add_credits_nudge_email",
|
||||
self.base_url
|
||||
),
|
||||
PathStyle::ChatGptApi => {
|
||||
format!(
|
||||
"{}/wham/accounts/send_add_credits_nudge_email",
|
||||
self.base_url
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn map_rate_limit_window(
|
||||
window: Option<Option<Box<crate::types::RateLimitWindowSnapshot>>>,
|
||||
) -> Option<RateLimitWindow> {
|
||||
@@ -767,4 +810,50 @@ mod tests {
|
||||
let snapshots = Client::rate_limit_snapshots_from_payload(payload);
|
||||
assert_eq!(snapshots[0].rate_limit_reached_type, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_credits_nudge_email_uses_expected_paths_and_bodies() {
|
||||
let codex_client = Client {
|
||||
base_url: "https://example.test".to_string(),
|
||||
http: reqwest::Client::new(),
|
||||
bearer_token: None,
|
||||
user_agent: None,
|
||||
chatgpt_account_id: None,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
path_style: PathStyle::CodexApi,
|
||||
};
|
||||
assert_eq!(
|
||||
codex_client.send_add_credits_nudge_email_url(),
|
||||
"https://example.test/api/codex/accounts/send_add_credits_nudge_email"
|
||||
);
|
||||
|
||||
let chatgpt_client = Client {
|
||||
base_url: "https://chatgpt.com/backend-api".to_string(),
|
||||
http: reqwest::Client::new(),
|
||||
bearer_token: None,
|
||||
user_agent: None,
|
||||
chatgpt_account_id: None,
|
||||
chatgpt_account_is_fedramp: false,
|
||||
path_style: PathStyle::ChatGptApi,
|
||||
};
|
||||
assert_eq!(
|
||||
chatgpt_client.send_add_credits_nudge_email_url(),
|
||||
"https://chatgpt.com/backend-api/wham/accounts/send_add_credits_nudge_email"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
serde_json::to_value(SendAddCreditsNudgeEmailRequest {
|
||||
credit_type: AddCreditsNudgeCreditType::Credits,
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::json!({ "credit_type": "credits" })
|
||||
);
|
||||
assert_eq!(
|
||||
serde_json::to_value(SendAddCreditsNudgeEmailRequest {
|
||||
credit_type: AddCreditsNudgeCreditType::UsageLimit,
|
||||
})
|
||||
.unwrap(),
|
||||
serde_json::json!({ "credit_type": "usage_limit" })
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
mod client;
|
||||
pub(crate) mod types;
|
||||
|
||||
pub use client::AddCreditsNudgeCreditType;
|
||||
pub use client::Client;
|
||||
pub use client::RequestError;
|
||||
pub use types::CodeTaskDetailsResponse;
|
||||
|
||||
@@ -1729,6 +1729,15 @@ mod tests {
|
||||
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_marketplace_remove_parses_under_plugin() {
|
||||
let cli =
|
||||
MultitoolCli::try_parse_from(["codex", "plugin", "marketplace", "remove", "debug"])
|
||||
.expect("parse");
|
||||
|
||||
assert!(matches!(cli.subcommand, Some(Subcommand::Plugin(_))));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marketplace_no_longer_parses_at_top_level() {
|
||||
let add_result =
|
||||
@@ -1738,6 +1747,10 @@ mod tests {
|
||||
let upgrade_result =
|
||||
MultitoolCli::try_parse_from(["codex", "marketplace", "upgrade", "debug"]);
|
||||
assert!(upgrade_result.is_err());
|
||||
|
||||
let remove_result =
|
||||
MultitoolCli::try_parse_from(["codex", "marketplace", "remove", "debug"]);
|
||||
assert!(remove_result.is_err());
|
||||
}
|
||||
|
||||
fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo {
|
||||
|
||||
@@ -5,9 +5,11 @@ use clap::Parser;
|
||||
use codex_core::config::Config;
|
||||
use codex_core::config::find_codex_home;
|
||||
use codex_core::plugins::MarketplaceAddRequest;
|
||||
use codex_core::plugins::MarketplaceRemoveRequest;
|
||||
use codex_core::plugins::PluginMarketplaceUpgradeOutcome;
|
||||
use codex_core::plugins::PluginsManager;
|
||||
use codex_core::plugins::add_marketplace;
|
||||
use codex_core::plugins::remove_marketplace;
|
||||
use codex_utils_cli::CliConfigOverrides;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
@@ -23,6 +25,7 @@ pub struct MarketplaceCli {
|
||||
enum MarketplaceSubcommand {
|
||||
Add(AddMarketplaceArgs),
|
||||
Upgrade(UpgradeMarketplaceArgs),
|
||||
Remove(RemoveMarketplaceArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
@@ -47,6 +50,12 @@ struct UpgradeMarketplaceArgs {
|
||||
marketplace_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
struct RemoveMarketplaceArgs {
|
||||
/// Configured marketplace name to remove.
|
||||
marketplace_name: String,
|
||||
}
|
||||
|
||||
impl MarketplaceCli {
|
||||
pub async fn run(self) -> Result<()> {
|
||||
let MarketplaceCli {
|
||||
@@ -61,6 +70,7 @@ impl MarketplaceCli {
|
||||
match subcommand {
|
||||
MarketplaceSubcommand::Add(args) => run_add(args).await?,
|
||||
MarketplaceSubcommand::Upgrade(args) => run_upgrade(overrides, args).await?,
|
||||
MarketplaceSubcommand::Remove(args) => run_remove(args).await?,
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -120,6 +130,26 @@ async fn run_upgrade(
|
||||
print_upgrade_outcome(&outcome, marketplace_name.as_deref())
|
||||
}
|
||||
|
||||
async fn run_remove(args: RemoveMarketplaceArgs) -> Result<()> {
|
||||
let RemoveMarketplaceArgs { marketplace_name } = args;
|
||||
let codex_home = find_codex_home().context("failed to resolve CODEX_HOME")?;
|
||||
let outcome = remove_marketplace(
|
||||
codex_home.to_path_buf(),
|
||||
MarketplaceRemoveRequest { marketplace_name },
|
||||
)
|
||||
.await?;
|
||||
|
||||
println!("Removed marketplace `{}`.", outcome.marketplace_name);
|
||||
if let Some(installed_root) = outcome.removed_installed_root {
|
||||
println!(
|
||||
"Removed installed marketplace root: {}",
|
||||
installed_root.as_path().display()
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn print_upgrade_outcome(
|
||||
outcome: &PluginMarketplaceUpgradeOutcome,
|
||||
marketplace_name: Option<&str>,
|
||||
@@ -201,4 +231,10 @@ mod tests {
|
||||
let upgrade_one = UpgradeMarketplaceArgs::try_parse_from(["upgrade", "debug"]).unwrap();
|
||||
assert_eq!(upgrade_one.marketplace_name.as_deref(), Some("debug"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_subcommand_parses_marketplace_name() {
|
||||
let remove = RemoveMarketplaceArgs::try_parse_from(["remove", "debug"]).unwrap();
|
||||
assert_eq!(remove.marketplace_name, "debug");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
use anyhow::Result;
|
||||
use codex_config::MarketplaceConfigUpdate;
|
||||
use codex_config::record_user_marketplace;
|
||||
use codex_core::plugins::marketplace_install_root;
|
||||
use predicates::str::contains;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn codex_command(codex_home: &Path) -> Result<assert_cmd::Command> {
|
||||
let mut cmd = assert_cmd::Command::new(codex_utils_cargo_bin::cargo_bin("codex")?);
|
||||
cmd.env("CODEX_HOME", codex_home);
|
||||
Ok(cmd)
|
||||
}
|
||||
|
||||
fn configured_marketplace_update() -> MarketplaceConfigUpdate<'static> {
|
||||
MarketplaceConfigUpdate {
|
||||
last_updated: "2026-04-13T00:00:00Z",
|
||||
last_revision: None,
|
||||
source_type: "git",
|
||||
source: "https://github.com/owner/repo.git",
|
||||
ref_name: Some("main"),
|
||||
sparse_paths: &[],
|
||||
}
|
||||
}
|
||||
|
||||
fn write_installed_marketplace(codex_home: &Path, marketplace_name: &str) -> Result<()> {
|
||||
let root = marketplace_install_root(codex_home).join(marketplace_name);
|
||||
std::fs::create_dir_all(root.join(".agents/plugins"))?;
|
||||
std::fs::write(root.join(".agents/plugins/marketplace.json"), "{}")?;
|
||||
std::fs::write(root.join("marker.txt"), "installed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marketplace_remove_deletes_config_and_installed_root() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
record_user_marketplace(codex_home.path(), "debug", &configured_marketplace_update())?;
|
||||
write_installed_marketplace(codex_home.path(), "debug")?;
|
||||
|
||||
codex_command(codex_home.path())?
|
||||
.args(["plugin", "marketplace", "remove", "debug"])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(contains("Removed marketplace `debug`."));
|
||||
|
||||
let config_path = codex_home.path().join("config.toml");
|
||||
let config = std::fs::read_to_string(config_path)?;
|
||||
assert!(!config.contains("[marketplaces.debug]"));
|
||||
assert!(
|
||||
!marketplace_install_root(codex_home.path())
|
||||
.join("debug")
|
||||
.exists()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn marketplace_remove_rejects_unknown_marketplace() -> Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
codex_command(codex_home.path())?
|
||||
.args(["plugin", "marketplace", "remove", "debug"])
|
||||
.assert()
|
||||
.failure()
|
||||
.stderr(contains(
|
||||
"marketplace `debug` is not configured or installed",
|
||||
));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -55,7 +55,7 @@ async fn list_and_get_render_expected_output() -> Result<()> {
|
||||
.expect("docs server should exist after add");
|
||||
match &mut docs_entry.transport {
|
||||
McpServerTransportConfig::Stdio { env_vars, .. } => {
|
||||
*env_vars = vec!["APP_TOKEN".to_string(), "WORKSPACE_ID".to_string()];
|
||||
*env_vars = vec!["APP_TOKEN".into(), "WORKSPACE_ID".into()];
|
||||
}
|
||||
other => panic!("unexpected transport: {other:?}"),
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ const EXEC_DESCRIPTION_TEMPLATE: &str = r#"Run JavaScript code to orchestrate/co
|
||||
- Global helpers:
|
||||
- `exit()`: Immediately ends the current script successfully (like an early return from the top level).
|
||||
- `text(value: string | number | boolean | undefined | null)`: Appends a text item. Non-string values are stringified with `JSON.stringify(...)` when possible.
|
||||
- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request original detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument.
|
||||
- `image(imageUrlOrItem: string | { image_url: string; detail?: "auto" | "low" | "high" | "original" | null } | ImageContent, detail?: "auto" | "low" | "high" | "original" | null)`: Appends an image item. `image_url` can be an HTTPS URL or a base64-encoded `data:` URL. To forward an MCP tool image, pass an individual `ImageContent` block from `result.content`, for example `image(result.content[0])`. MCP image blocks may request detail with `_meta: { "codex/imageDetail": "original" }`. When provided, the second `detail` argument overrides any detail embedded in the first argument.
|
||||
- `store(key: string, value: any)`: stores a serializable value under a string key for later `exec` calls in the same session.
|
||||
- `load(key: string)`: returns the stored value for a string key, or `undefined` if it is missing.
|
||||
- `notify(value: string | number | boolean | undefined | null)`: immediately injects an extra `custom_tool_call_output` for the current `exec` call. Values are stringified like `text(...)`.
|
||||
|
||||
@@ -15,6 +15,7 @@ pub use description::normalize_code_mode_identifier;
|
||||
pub use description::parse_exec_source;
|
||||
pub use description::render_code_mode_sample;
|
||||
pub use description::render_json_schema_to_typescript;
|
||||
pub use response::DEFAULT_IMAGE_DETAIL;
|
||||
pub use response::FunctionCallOutputContentItem;
|
||||
pub use response::ImageDetail;
|
||||
pub use runtime::DEFAULT_EXEC_YIELD_TIME_MS;
|
||||
|
||||
@@ -10,6 +10,8 @@ pub enum ImageDetail {
|
||||
Original,
|
||||
}
|
||||
|
||||
pub const DEFAULT_IMAGE_DETAIL: ImageDetail = ImageDetail::High;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum FunctionCallOutputContentItem {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use serde_json::Value as JsonValue;
|
||||
|
||||
use crate::response::DEFAULT_IMAGE_DETAIL;
|
||||
use crate::response::FunctionCallOutputContentItem;
|
||||
use crate::response::ImageDetail;
|
||||
|
||||
@@ -81,7 +82,7 @@ pub(super) fn normalize_output_image(
|
||||
}
|
||||
})
|
||||
}
|
||||
None => None,
|
||||
None => Some(DEFAULT_IMAGE_DETAIL),
|
||||
};
|
||||
|
||||
Ok(FunctionCallOutputContentItem::InputImage { image_url, detail })
|
||||
@@ -159,7 +160,7 @@ fn parse_mcp_output_image(
|
||||
.and_then(JsonValue::as_object)
|
||||
.and_then(|meta| meta.get(CODEX_IMAGE_DETAIL_META_KEY))
|
||||
.and_then(JsonValue::as_str)
|
||||
.filter(|detail| *detail == "original")
|
||||
.filter(|detail| matches!(*detail, "auto" | "low" | "high" | "original"))
|
||||
.map(str::to_string);
|
||||
Ok((image_url, detail))
|
||||
}
|
||||
|
||||
@@ -669,7 +669,7 @@ text(JSON.stringify(returnsUndefined));
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "https://example.com/image.jpg".to_string(),
|
||||
detail: None,
|
||||
detail: Some(crate::DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
FunctionCallOutputContentItem::InputText {
|
||||
text: "[true,true,true]".to_string(),
|
||||
|
||||
@@ -89,6 +89,7 @@ async fn models_client_hits_models_endpoint() {
|
||||
supports_parallel_tool_calls: false,
|
||||
supports_image_detail_original: false,
|
||||
context_window: Some(272_000),
|
||||
max_context_window: None,
|
||||
auto_compact_token_limit: None,
|
||||
effective_context_window_percent: 95,
|
||||
experimental_supported_tools: Vec::new(),
|
||||
|
||||
@@ -16,6 +16,7 @@ anyhow = { workspace = true }
|
||||
async-channel = { workspace = true }
|
||||
codex-async-utils = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
codex-login = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-plugin = { workspace = true }
|
||||
|
||||
@@ -38,6 +38,7 @@ pub use mcp_connection_manager::CodexAppsToolsCacheKey;
|
||||
pub use mcp_connection_manager::DEFAULT_STARTUP_TIMEOUT;
|
||||
pub use mcp_connection_manager::MCP_SANDBOX_STATE_META_CAPABILITY;
|
||||
pub use mcp_connection_manager::McpConnectionManager;
|
||||
pub use mcp_connection_manager::McpRuntimeEnvironment;
|
||||
pub use mcp_connection_manager::SandboxState;
|
||||
pub use mcp_connection_manager::ToolInfo;
|
||||
pub use mcp_connection_manager::codex_apps_tools_cache_key;
|
||||
|
||||
@@ -35,6 +35,7 @@ use codex_protocol::protocol::SandboxPolicy;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::mcp_connection_manager::McpRuntimeEnvironment;
|
||||
use crate::mcp_connection_manager::codex_apps_tools_cache_key;
|
||||
pub type McpManager = McpConnectionManager;
|
||||
|
||||
@@ -321,14 +322,23 @@ pub async fn collect_mcp_snapshot(
|
||||
config: &McpConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
submit_id: String,
|
||||
runtime_environment: McpRuntimeEnvironment,
|
||||
) -> McpListToolsResponseEvent {
|
||||
collect_mcp_snapshot_with_detail(config, auth, submit_id, McpSnapshotDetail::Full).await
|
||||
collect_mcp_snapshot_with_detail(
|
||||
config,
|
||||
auth,
|
||||
submit_id,
|
||||
runtime_environment,
|
||||
McpSnapshotDetail::Full,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn collect_mcp_snapshot_with_detail(
|
||||
config: &McpConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
submit_id: String,
|
||||
runtime_environment: McpRuntimeEnvironment,
|
||||
detail: McpSnapshotDetail,
|
||||
) -> McpListToolsResponseEvent {
|
||||
let mcp_servers = effective_mcp_servers(config, auth);
|
||||
@@ -356,6 +366,7 @@ pub async fn collect_mcp_snapshot_with_detail(
|
||||
submit_id,
|
||||
tx_event,
|
||||
SandboxPolicy::new_read_only_policy(),
|
||||
runtime_environment,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth),
|
||||
tool_plugin_provenance,
|
||||
@@ -386,15 +397,23 @@ pub async fn collect_mcp_server_status_snapshot(
|
||||
config: &McpConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
submit_id: String,
|
||||
runtime_environment: McpRuntimeEnvironment,
|
||||
) -> McpServerStatusSnapshot {
|
||||
collect_mcp_server_status_snapshot_with_detail(config, auth, submit_id, McpSnapshotDetail::Full)
|
||||
.await
|
||||
collect_mcp_server_status_snapshot_with_detail(
|
||||
config,
|
||||
auth,
|
||||
submit_id,
|
||||
runtime_environment,
|
||||
McpSnapshotDetail::Full,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn collect_mcp_server_status_snapshot_with_detail(
|
||||
config: &McpConfig,
|
||||
auth: Option<&CodexAuth>,
|
||||
submit_id: String,
|
||||
runtime_environment: McpRuntimeEnvironment,
|
||||
detail: McpSnapshotDetail,
|
||||
) -> McpServerStatusSnapshot {
|
||||
let mcp_servers = effective_mcp_servers(config, auth);
|
||||
@@ -422,6 +441,7 @@ pub async fn collect_mcp_server_status_snapshot_with_detail(
|
||||
submit_id,
|
||||
tx_event,
|
||||
SandboxPolicy::new_read_only_policy(),
|
||||
runtime_environment,
|
||||
config.codex_home.clone(),
|
||||
codex_apps_tools_cache_key(auth),
|
||||
tool_plugin_provenance,
|
||||
|
||||
@@ -36,6 +36,7 @@ use codex_async_utils::CancelErr;
|
||||
use codex_async_utils::OrCancelExt;
|
||||
use codex_config::Constrained;
|
||||
use codex_config::types::OAuthCredentialsStoreMode;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_protocol::ToolName;
|
||||
use codex_protocol::approvals::ElicitationRequest;
|
||||
use codex_protocol::approvals::ElicitationRequestEvent;
|
||||
@@ -50,6 +51,7 @@ use codex_protocol::protocol::McpStartupStatus;
|
||||
use codex_protocol::protocol::McpStartupUpdateEvent;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rmcp_client::ExecutorStdioServerLauncher;
|
||||
use codex_rmcp_client::LocalStdioServerLauncher;
|
||||
use codex_rmcp_client::RmcpClient;
|
||||
use codex_rmcp_client::SendElicitation;
|
||||
@@ -493,6 +495,7 @@ impl AsyncManagedClient {
|
||||
elicitation_requests: ElicitationRequestManager,
|
||||
codex_apps_tools_cache_context: Option<CodexAppsToolsCacheContext>,
|
||||
tool_plugin_provenance: Arc<ToolPluginProvenance>,
|
||||
runtime_environment: McpRuntimeEnvironment,
|
||||
) -> Self {
|
||||
let tool_filter = ToolFilter::from_config(&config);
|
||||
let startup_snapshot = load_startup_cached_codex_apps_tools_snapshot(
|
||||
@@ -509,8 +512,15 @@ impl AsyncManagedClient {
|
||||
return Err(error.into());
|
||||
}
|
||||
|
||||
let client =
|
||||
Arc::new(make_rmcp_client(&server_name, config.transport, store_mode).await?);
|
||||
let client = Arc::new(
|
||||
make_rmcp_client(
|
||||
&server_name,
|
||||
config.clone(),
|
||||
store_mode,
|
||||
runtime_environment,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
match start_server_task(
|
||||
server_name,
|
||||
client,
|
||||
@@ -650,6 +660,37 @@ pub struct McpConnectionManager {
|
||||
elicitation_requests: ElicitationRequestManager,
|
||||
}
|
||||
|
||||
/// Runtime placement information used when starting MCP server transports.
|
||||
///
|
||||
/// `McpConfig` describes what servers exist. This value describes where those
|
||||
/// servers should run for the current caller. Keep it explicit at manager
|
||||
/// construction time so status/snapshot paths and real sessions make the same
|
||||
/// local-vs-remote decision. `fallback_cwd` is not a per-server override; it is
|
||||
/// used only when an executor-backed stdio server omits `cwd` and the executor
|
||||
/// API still needs a concrete process working directory.
|
||||
#[derive(Clone)]
|
||||
pub struct McpRuntimeEnvironment {
|
||||
environment: Arc<Environment>,
|
||||
fallback_cwd: PathBuf,
|
||||
}
|
||||
|
||||
impl McpRuntimeEnvironment {
|
||||
pub fn new(environment: Arc<Environment>, fallback_cwd: PathBuf) -> Self {
|
||||
Self {
|
||||
environment,
|
||||
fallback_cwd,
|
||||
}
|
||||
}
|
||||
|
||||
fn environment(&self) -> Arc<Environment> {
|
||||
Arc::clone(&self.environment)
|
||||
}
|
||||
|
||||
fn fallback_cwd(&self) -> PathBuf {
|
||||
self.fallback_cwd.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl McpConnectionManager {
|
||||
pub fn configured_servers(&self, config: &McpConfig) -> HashMap<String, McpServerConfig> {
|
||||
configured_mcp_servers(config)
|
||||
@@ -710,6 +751,7 @@ impl McpConnectionManager {
|
||||
submit_id: String,
|
||||
tx_event: Sender<Event>,
|
||||
initial_sandbox_policy: SandboxPolicy,
|
||||
runtime_environment: McpRuntimeEnvironment,
|
||||
codex_home: PathBuf,
|
||||
codex_apps_tools_cache_key: CodexAppsToolsCacheKey,
|
||||
tool_plugin_provenance: ToolPluginProvenance,
|
||||
@@ -754,6 +796,7 @@ impl McpConnectionManager {
|
||||
elicitation_requests.clone(),
|
||||
codex_apps_tools_cache_context,
|
||||
Arc::clone(&tool_plugin_provenance),
|
||||
runtime_environment.clone(),
|
||||
);
|
||||
clients.insert(server_name.clone(), async_managed_client.clone());
|
||||
let tx_event = tx_event.clone();
|
||||
@@ -1484,9 +1527,25 @@ struct StartServerTaskParams {
|
||||
|
||||
async fn make_rmcp_client(
|
||||
server_name: &str,
|
||||
transport: McpServerTransportConfig,
|
||||
config: McpServerConfig,
|
||||
store_mode: OAuthCredentialsStoreMode,
|
||||
runtime_environment: McpRuntimeEnvironment,
|
||||
) -> Result<RmcpClient, StartupOutcomeError> {
|
||||
let McpServerConfig {
|
||||
transport,
|
||||
experimental_environment,
|
||||
..
|
||||
} = config;
|
||||
let remote_environment = match experimental_environment.as_deref() {
|
||||
None | Some("local") => false,
|
||||
Some("remote") => true,
|
||||
Some(environment) => {
|
||||
return Err(StartupOutcomeError::from(anyhow!(
|
||||
"unsupported experimental_environment `{environment}` for MCP server `{server_name}`"
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
match transport {
|
||||
McpServerTransportConfig::Stdio {
|
||||
command,
|
||||
@@ -1502,7 +1561,24 @@ async fn make_rmcp_client(
|
||||
.map(|(key, value)| (key.into(), value.into()))
|
||||
.collect::<HashMap<_, _>>()
|
||||
});
|
||||
let launcher = Arc::new(LocalStdioServerLauncher) as Arc<dyn StdioServerLauncher>;
|
||||
let launcher = if remote_environment {
|
||||
let exec_environment = runtime_environment.environment();
|
||||
if !exec_environment.is_remote() {
|
||||
return Err(StartupOutcomeError::from(anyhow!(
|
||||
"remote MCP server `{server_name}` requires a remote executor environment"
|
||||
)));
|
||||
}
|
||||
Arc::new(ExecutorStdioServerLauncher::new(
|
||||
exec_environment.get_exec_backend(),
|
||||
runtime_environment.fallback_cwd(),
|
||||
))
|
||||
} else {
|
||||
Arc::new(LocalStdioServerLauncher) as Arc<dyn StdioServerLauncher>
|
||||
};
|
||||
|
||||
// `RmcpClient` always sees a launched MCP stdio server. The
|
||||
// launcher hides whether that means a local child process or an
|
||||
// executor process whose stdin/stdout bytes cross the process API.
|
||||
RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd, launcher)
|
||||
.await
|
||||
.map_err(|err| StartupOutcomeError::from(anyhow!(err)))
|
||||
@@ -1513,6 +1589,23 @@ async fn make_rmcp_client(
|
||||
env_http_headers,
|
||||
bearer_token_env_var,
|
||||
} => {
|
||||
if remote_environment {
|
||||
if !runtime_environment.environment().is_remote() {
|
||||
return Err(StartupOutcomeError::from(anyhow!(
|
||||
"remote MCP server `{server_name}` requires a remote executor environment"
|
||||
)));
|
||||
}
|
||||
return Err(StartupOutcomeError::from(anyhow!(
|
||||
// Remote HTTP needs the future low-level executor
|
||||
// `network/request` API so reqwest runs on the executor side.
|
||||
// Do not fall back to local HTTP here; the config explicitly
|
||||
// asked for remote placement.
|
||||
"remote streamable HTTP MCP server `{server_name}` is not implemented yet"
|
||||
)));
|
||||
}
|
||||
|
||||
// Local streamable HTTP remains the existing reqwest path from
|
||||
// the orchestrator process.
|
||||
let resolved_bearer_token =
|
||||
match resolve_bearer_token(server_name, bearer_token_env_var.as_deref()) {
|
||||
Ok(token) => token,
|
||||
|
||||
@@ -684,25 +684,21 @@ impl ConfigToml {
|
||||
resolved_cwd: &Path,
|
||||
repo_root: Option<&Path>,
|
||||
) -> Option<ProjectConfig> {
|
||||
let projects = self.projects.clone().unwrap_or_default();
|
||||
let projects = self.projects.as_ref()?;
|
||||
|
||||
let resolved_cwd_key = project_trust_key(resolved_cwd);
|
||||
let resolved_cwd_raw_key = resolved_cwd.to_string_lossy().to_string();
|
||||
if let Some(project_config) = projects
|
||||
.get(&resolved_cwd_key)
|
||||
.or_else(|| projects.get(&resolved_cwd_raw_key))
|
||||
{
|
||||
return Some(project_config.clone());
|
||||
for normalized_cwd in normalized_project_lookup_keys(resolved_cwd) {
|
||||
if let Some(project_config) = project_config_for_lookup_key(projects, &normalized_cwd) {
|
||||
return Some(project_config);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(repo_root) = repo_root {
|
||||
let repo_root_key = project_trust_key(repo_root);
|
||||
let repo_root_raw_key = repo_root.to_string_lossy().to_string();
|
||||
if let Some(project_config_for_root) = projects
|
||||
.get(&repo_root_key)
|
||||
.or_else(|| projects.get(&repo_root_raw_key))
|
||||
{
|
||||
return Some(project_config_for_root.clone());
|
||||
for normalized_repo_root in normalized_project_lookup_keys(repo_root) {
|
||||
if let Some(project_config_for_root) =
|
||||
project_config_for_lookup_key(projects, &normalized_repo_root)
|
||||
{
|
||||
return Some(project_config_for_root);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -734,11 +730,45 @@ impl ConfigToml {
|
||||
/// Canonicalize the path and convert it to a string to be used as a key in the
|
||||
/// projects trust map. On Windows, strips UNC, when possible, to try to ensure
|
||||
/// that different paths that point to the same location have the same key.
|
||||
fn project_trust_key(project_path: &Path) -> String {
|
||||
normalize_for_path_comparison(project_path)
|
||||
.unwrap_or_else(|_| project_path.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
fn normalized_project_lookup_keys(path: &Path) -> Vec<String> {
|
||||
let normalized_path = normalize_project_lookup_key(path.to_string_lossy().to_string());
|
||||
let normalized_canonical_path = normalize_project_lookup_key(
|
||||
normalize_for_path_comparison(path)
|
||||
.unwrap_or_else(|_| path.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
);
|
||||
if normalized_path == normalized_canonical_path {
|
||||
vec![normalized_canonical_path]
|
||||
} else {
|
||||
vec![normalized_canonical_path, normalized_path]
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_project_lookup_key(key: String) -> String {
|
||||
if cfg!(windows) {
|
||||
key.to_ascii_lowercase()
|
||||
} else {
|
||||
key
|
||||
}
|
||||
}
|
||||
|
||||
fn project_config_for_lookup_key(
|
||||
projects: &HashMap<String, ProjectConfig>,
|
||||
lookup_key: &str,
|
||||
) -> Option<ProjectConfig> {
|
||||
if let Some(project_config) = projects.get(lookup_key) {
|
||||
return Some(project_config.clone());
|
||||
}
|
||||
|
||||
let mut normalized_matches: Vec<_> = projects
|
||||
.iter()
|
||||
.filter(|(key, _)| normalize_project_lookup_key((*key).clone()) == lookup_key)
|
||||
.collect();
|
||||
normalized_matches.sort_by(|(left, _), (right, _)| left.cmp(right));
|
||||
normalized_matches
|
||||
.first()
|
||||
.map(|(_, project_config)| (**project_config).clone())
|
||||
}
|
||||
|
||||
pub fn validate_reserved_model_provider_ids(
|
||||
|
||||
@@ -63,12 +63,16 @@ pub use diagnostics::format_config_error_with_source;
|
||||
pub use diagnostics::io_error_from_config_error;
|
||||
pub use fingerprint::version_for_toml;
|
||||
pub use marketplace_edit::MarketplaceConfigUpdate;
|
||||
pub use marketplace_edit::RemoveMarketplaceConfigOutcome;
|
||||
pub use marketplace_edit::record_user_marketplace;
|
||||
pub use marketplace_edit::remove_user_marketplace;
|
||||
pub use marketplace_edit::remove_user_marketplace_config;
|
||||
pub use mcp_edit::ConfigEditsBuilder;
|
||||
pub use mcp_edit::load_global_mcp_servers;
|
||||
pub use mcp_types::AppToolApproval;
|
||||
pub use mcp_types::McpServerConfig;
|
||||
pub use mcp_types::McpServerDisabledReason;
|
||||
pub use mcp_types::McpServerEnvVar;
|
||||
pub use mcp_types::McpServerToolConfig;
|
||||
pub use mcp_types::McpServerTransportConfig;
|
||||
pub use mcp_types::RawMcpServerConfig;
|
||||
|
||||
@@ -19,6 +19,13 @@ pub struct MarketplaceConfigUpdate<'a> {
|
||||
pub sparse_paths: &'a [String],
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RemoveMarketplaceConfigOutcome {
|
||||
Removed,
|
||||
NotFound,
|
||||
NameCaseMismatch { configured_name: String },
|
||||
}
|
||||
|
||||
pub fn record_user_marketplace(
|
||||
codex_home: &Path,
|
||||
marketplace_name: &str,
|
||||
@@ -31,6 +38,36 @@ pub fn record_user_marketplace(
|
||||
fs::write(config_path, doc.to_string())
|
||||
}
|
||||
|
||||
pub fn remove_user_marketplace(codex_home: &Path, marketplace_name: &str) -> std::io::Result<bool> {
|
||||
let outcome = remove_user_marketplace_config(codex_home, marketplace_name)?;
|
||||
Ok(outcome == RemoveMarketplaceConfigOutcome::Removed)
|
||||
}
|
||||
|
||||
pub fn remove_user_marketplace_config(
|
||||
codex_home: &Path,
|
||||
marketplace_name: &str,
|
||||
) -> std::io::Result<RemoveMarketplaceConfigOutcome> {
|
||||
let config_path = codex_home.join(CONFIG_TOML_FILE);
|
||||
let mut doc = match fs::read_to_string(&config_path) {
|
||||
Ok(raw) => raw
|
||||
.parse::<DocumentMut>()
|
||||
.map_err(|err| std::io::Error::new(ErrorKind::InvalidData, err))?,
|
||||
Err(err) if err.kind() == ErrorKind::NotFound => {
|
||||
return Ok(RemoveMarketplaceConfigOutcome::NotFound);
|
||||
}
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
|
||||
let outcome = remove_marketplace(&mut doc, marketplace_name);
|
||||
if outcome != RemoveMarketplaceConfigOutcome::Removed {
|
||||
return Ok(outcome);
|
||||
}
|
||||
|
||||
fs::create_dir_all(codex_home)?;
|
||||
fs::write(config_path, doc.to_string())?;
|
||||
Ok(RemoveMarketplaceConfigOutcome::Removed)
|
||||
}
|
||||
|
||||
fn read_or_create_document(config_path: &Path) -> std::io::Result<DocumentMut> {
|
||||
match fs::read_to_string(config_path) {
|
||||
Ok(raw) => raw
|
||||
@@ -80,8 +117,160 @@ fn upsert_marketplace(
|
||||
marketplaces.insert(marketplace_name, TomlItem::Table(entry));
|
||||
}
|
||||
|
||||
fn remove_marketplace(
|
||||
doc: &mut DocumentMut,
|
||||
marketplace_name: &str,
|
||||
) -> RemoveMarketplaceConfigOutcome {
|
||||
let root = doc.as_table_mut();
|
||||
let Some(marketplaces_item) = root.get_mut("marketplaces") else {
|
||||
return RemoveMarketplaceConfigOutcome::NotFound;
|
||||
};
|
||||
|
||||
let mut remove_marketplaces = false;
|
||||
let outcome = match marketplaces_item {
|
||||
TomlItem::Table(marketplaces) => {
|
||||
let outcome = if marketplaces.remove(marketplace_name).is_some() {
|
||||
RemoveMarketplaceConfigOutcome::Removed
|
||||
} else if let Some(configured_name) =
|
||||
case_mismatched_key(marketplaces.iter().map(|(key, _)| key), marketplace_name)
|
||||
{
|
||||
RemoveMarketplaceConfigOutcome::NameCaseMismatch { configured_name }
|
||||
} else {
|
||||
RemoveMarketplaceConfigOutcome::NotFound
|
||||
};
|
||||
remove_marketplaces = marketplaces.is_empty();
|
||||
outcome
|
||||
}
|
||||
TomlItem::Value(value) => {
|
||||
let Some(marketplaces) = value.as_inline_table_mut() else {
|
||||
return RemoveMarketplaceConfigOutcome::NotFound;
|
||||
};
|
||||
let outcome = if marketplaces.remove(marketplace_name).is_some() {
|
||||
RemoveMarketplaceConfigOutcome::Removed
|
||||
} else if let Some(configured_name) =
|
||||
case_mismatched_key(marketplaces.iter().map(|(key, _)| key), marketplace_name)
|
||||
{
|
||||
RemoveMarketplaceConfigOutcome::NameCaseMismatch { configured_name }
|
||||
} else {
|
||||
RemoveMarketplaceConfigOutcome::NotFound
|
||||
};
|
||||
remove_marketplaces = marketplaces.is_empty();
|
||||
outcome
|
||||
}
|
||||
_ => RemoveMarketplaceConfigOutcome::NotFound,
|
||||
};
|
||||
|
||||
if outcome == RemoveMarketplaceConfigOutcome::Removed && remove_marketplaces {
|
||||
root.remove("marketplaces");
|
||||
}
|
||||
outcome
|
||||
}
|
||||
|
||||
fn case_mismatched_key<'a>(
|
||||
mut keys: impl Iterator<Item = &'a str>,
|
||||
requested_name: &str,
|
||||
) -> Option<String> {
|
||||
keys.find(|key| *key != requested_name && key.eq_ignore_ascii_case(requested_name))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
fn new_implicit_table() -> TomlTable {
|
||||
let mut table = TomlTable::new();
|
||||
table.set_implicit(true);
|
||||
table
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn remove_user_marketplace_removes_requested_entry() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let update = MarketplaceConfigUpdate {
|
||||
last_updated: "2026-04-13T00:00:00Z",
|
||||
last_revision: None,
|
||||
source_type: "git",
|
||||
source: "https://github.com/owner/repo.git",
|
||||
ref_name: Some("main"),
|
||||
sparse_paths: &[],
|
||||
};
|
||||
record_user_marketplace(codex_home.path(), "debug", &update).unwrap();
|
||||
record_user_marketplace(codex_home.path(), "other", &update).unwrap();
|
||||
|
||||
let removed = remove_user_marketplace(codex_home.path(), "debug").unwrap();
|
||||
|
||||
assert!(removed);
|
||||
let config: toml::Value =
|
||||
toml::from_str(&fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).unwrap())
|
||||
.unwrap();
|
||||
let marketplaces = config
|
||||
.get("marketplaces")
|
||||
.and_then(toml::Value::as_table)
|
||||
.unwrap();
|
||||
assert_eq!(marketplaces.len(), 1);
|
||||
assert!(marketplaces.contains_key("other"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_user_marketplace_returns_false_when_missing() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
|
||||
let removed = remove_user_marketplace(codex_home.path(), "debug").unwrap();
|
||||
|
||||
assert!(!removed);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_user_marketplace_config_reports_case_mismatch() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let update = MarketplaceConfigUpdate {
|
||||
last_updated: "2026-04-13T00:00:00Z",
|
||||
last_revision: None,
|
||||
source_type: "git",
|
||||
source: "https://github.com/owner/repo.git",
|
||||
ref_name: Some("main"),
|
||||
sparse_paths: &[],
|
||||
};
|
||||
record_user_marketplace(codex_home.path(), "debug", &update).unwrap();
|
||||
|
||||
let outcome = remove_user_marketplace_config(codex_home.path(), "Debug").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
outcome,
|
||||
RemoveMarketplaceConfigOutcome::NameCaseMismatch {
|
||||
configured_name: "debug".to_string()
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_user_marketplace_config_removes_inline_table_entry() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
marketplaces = {
|
||||
debug = { source_type = "git", source = "https://github.com/owner/repo.git" },
|
||||
other = { source_type = "local", source = "/tmp/marketplace" },
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let outcome = remove_user_marketplace_config(codex_home.path(), "debug").unwrap();
|
||||
|
||||
assert_eq!(outcome, RemoveMarketplaceConfigOutcome::Removed);
|
||||
let config: toml::Value =
|
||||
toml::from_str(&fs::read_to_string(codex_home.path().join(CONFIG_TOML_FILE)).unwrap())
|
||||
.unwrap();
|
||||
let marketplaces = config
|
||||
.get("marketplaces")
|
||||
.and_then(toml::Value::as_table)
|
||||
.unwrap();
|
||||
assert_eq!(marketplaces.len(), 1);
|
||||
assert!(marketplaces.contains_key("other"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ use toml_edit::value;
|
||||
use crate::AppToolApproval;
|
||||
use crate::CONFIG_TOML_FILE;
|
||||
use crate::McpServerConfig;
|
||||
use crate::McpServerEnvVar;
|
||||
use crate::McpServerTransportConfig;
|
||||
|
||||
pub async fn load_global_mcp_servers(
|
||||
@@ -142,7 +143,7 @@ fn serialize_mcp_server(config: &McpServerConfig) -> TomlItem {
|
||||
entry["env"] = table_from_pairs(env.iter());
|
||||
}
|
||||
if !env_vars.is_empty() {
|
||||
entry["env_vars"] = array_from_strings(env_vars);
|
||||
entry["env_vars"] = array_from_env_vars(env_vars);
|
||||
}
|
||||
if let Some(cwd) = cwd {
|
||||
entry["cwd"] = value(cwd.to_string_lossy().to_string());
|
||||
@@ -247,6 +248,24 @@ fn array_from_strings(values: &[String]) -> TomlItem {
|
||||
TomlItem::Value(array.into())
|
||||
}
|
||||
|
||||
fn array_from_env_vars(env_vars: &[McpServerEnvVar]) -> TomlItem {
|
||||
let mut array = toml_edit::Array::new();
|
||||
for env_var in env_vars {
|
||||
match env_var {
|
||||
McpServerEnvVar::Name(name) => array.push(name.clone()),
|
||||
McpServerEnvVar::Config { name, source } => {
|
||||
let mut table = toml_edit::InlineTable::new();
|
||||
table.insert("name", name.clone().into());
|
||||
if let Some(source) = source {
|
||||
table.insert("source", source.clone().into());
|
||||
}
|
||||
array.push(table);
|
||||
}
|
||||
}
|
||||
}
|
||||
TomlItem::Value(array.into())
|
||||
}
|
||||
|
||||
fn table_from_pairs<'a, I>(pairs: I) -> TomlItem
|
||||
where
|
||||
I: IntoIterator<Item = (&'a String, &'a String)>,
|
||||
|
||||
@@ -56,6 +56,64 @@ pub struct McpServerToolConfig {
|
||||
pub approval_mode: Option<AppToolApproval>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[serde(untagged, deny_unknown_fields)]
|
||||
pub enum McpServerEnvVar {
|
||||
Name(String),
|
||||
Config {
|
||||
name: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
source: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
impl McpServerEnvVar {
|
||||
pub fn name(&self) -> &str {
|
||||
match self {
|
||||
McpServerEnvVar::Name(name) => name,
|
||||
McpServerEnvVar::Config { name, .. } => name,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn source(&self) -> Option<&str> {
|
||||
match self {
|
||||
McpServerEnvVar::Name(_) => None,
|
||||
McpServerEnvVar::Config { source, .. } => source.as_deref(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_remote_source(&self) -> bool {
|
||||
self.source() == Some("remote")
|
||||
}
|
||||
|
||||
pub fn validate_source(&self) -> Result<(), String> {
|
||||
match self.source() {
|
||||
None | Some("local") | Some("remote") => Ok(()),
|
||||
Some(source) => Err(format!(
|
||||
"unsupported env_vars source `{source}`; expected `local` or `remote`"
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<String> for McpServerEnvVar {
|
||||
fn from(value: String) -> Self {
|
||||
Self::Name(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for McpServerEnvVar {
|
||||
fn from(value: &str) -> Self {
|
||||
Self::Name(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<str> for McpServerEnvVar {
|
||||
fn as_ref(&self) -> &str {
|
||||
self.name()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Debug, Clone, PartialEq)]
|
||||
pub struct McpServerConfig {
|
||||
#[serde(flatten)]
|
||||
@@ -133,7 +191,7 @@ pub struct RawMcpServerConfig {
|
||||
#[serde(default)]
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
#[serde(default)]
|
||||
pub env_vars: Option<Vec<String>>,
|
||||
pub env_vars: Option<Vec<McpServerEnvVar>>,
|
||||
#[serde(default)]
|
||||
pub cwd: Option<PathBuf>,
|
||||
pub http_headers: Option<HashMap<String, String>>,
|
||||
@@ -235,11 +293,15 @@ impl TryFrom<RawMcpServerConfig> for McpServerConfig {
|
||||
throw_if_set("stdio", "http_headers", http_headers.as_ref())?;
|
||||
throw_if_set("stdio", "env_http_headers", env_http_headers.as_ref())?;
|
||||
throw_if_set("stdio", "oauth_resource", oauth_resource.as_ref())?;
|
||||
let env_vars = env_vars.unwrap_or_default();
|
||||
for env_var in &env_vars {
|
||||
env_var.validate_source()?;
|
||||
}
|
||||
McpServerTransportConfig::Stdio {
|
||||
command,
|
||||
args: args.unwrap_or_default(),
|
||||
env,
|
||||
env_vars: env_vars.unwrap_or_default(),
|
||||
env_vars,
|
||||
cwd,
|
||||
}
|
||||
} else if let Some(url) = url {
|
||||
@@ -303,7 +365,7 @@ pub enum McpServerTransportConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
env: Option<HashMap<String, String>>,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
env_vars: Vec<String>,
|
||||
env_vars: Vec<McpServerEnvVar>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
cwd: Option<PathBuf>,
|
||||
},
|
||||
|
||||
@@ -91,12 +91,65 @@ fn deserialize_stdio_command_server_config_with_env_vars() {
|
||||
command: "echo".to_string(),
|
||||
args: vec![],
|
||||
env: None,
|
||||
env_vars: vec!["FOO".to_string(), "BAR".to_string()],
|
||||
env_vars: vec!["FOO".into(), "BAR".into()],
|
||||
cwd: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_stdio_command_server_config_with_env_var_sources() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
r#"
|
||||
command = "echo"
|
||||
env_vars = [
|
||||
"LEGACY_TOKEN",
|
||||
{ name = "LOCAL_TOKEN", source = "local" },
|
||||
{ name = "REMOTE_TOKEN", source = "remote" },
|
||||
]
|
||||
"#,
|
||||
)
|
||||
.expect("should deserialize command config with sourced env_vars");
|
||||
|
||||
assert_eq!(
|
||||
cfg.transport,
|
||||
McpServerTransportConfig::Stdio {
|
||||
command: "echo".to_string(),
|
||||
args: vec![],
|
||||
env: None,
|
||||
env_vars: vec![
|
||||
McpServerEnvVar::Name("LEGACY_TOKEN".to_string()),
|
||||
McpServerEnvVar::Config {
|
||||
name: "LOCAL_TOKEN".to_string(),
|
||||
source: Some("local".to_string()),
|
||||
},
|
||||
McpServerEnvVar::Config {
|
||||
name: "REMOTE_TOKEN".to_string(),
|
||||
source: Some("remote".to_string()),
|
||||
},
|
||||
],
|
||||
cwd: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_stdio_command_server_config_rejects_unknown_env_var_source() {
|
||||
let err = toml::from_str::<McpServerConfig>(
|
||||
r#"
|
||||
command = "echo"
|
||||
env_vars = [{ name = "TOKEN", source = "elsewhere" }]
|
||||
"#,
|
||||
)
|
||||
.expect_err("unsupported env var source should be rejected");
|
||||
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("unsupported env_vars source `elsewhere`"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deserialize_stdio_command_server_config_with_cwd() {
|
||||
let cfg: McpServerConfig = toml::from_str(
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
pub use crate::mcp_types::AppToolApproval;
|
||||
pub use crate::mcp_types::McpServerConfig;
|
||||
pub use crate::mcp_types::McpServerDisabledReason;
|
||||
pub use crate::mcp_types::McpServerEnvVar;
|
||||
pub use crate::mcp_types::McpServerToolConfig;
|
||||
pub use crate::mcp_types::McpServerTransportConfig;
|
||||
pub use crate::mcp_types::RawMcpServerConfig;
|
||||
@@ -592,7 +593,23 @@ const fn default_true() -> bool {
|
||||
/// Settings for notices we display to users via the tui and app-server clients
|
||||
/// (primarily the Codex IDE extension). NOTE: these are different from
|
||||
/// notifications - notices are warnings, NUX screens, acknowledgements, etc.
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct ExternalConfigMigrationPrompts {
|
||||
/// Tracks whether home-level external config migration prompts are hidden.
|
||||
pub home: Option<bool>,
|
||||
/// Tracks the last time the home-level external config migration prompt was shown.
|
||||
pub home_last_prompted_at: Option<i64>,
|
||||
/// Tracks which project paths have opted out of external config migration prompts.
|
||||
#[serde(default)]
|
||||
pub projects: BTreeMap<String, bool>,
|
||||
/// Tracks the last time a project-level external config migration prompt was shown.
|
||||
#[serde(default)]
|
||||
pub project_last_prompted_at: BTreeMap<String, i64>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct Notice {
|
||||
/// Tracks whether the user has acknowledged the full access warning prompt.
|
||||
pub hide_full_access_warning: Option<bool>,
|
||||
@@ -608,6 +625,9 @@ pub struct Notice {
|
||||
/// Tracks acknowledged model migrations as old->new model slug mappings.
|
||||
#[serde(default)]
|
||||
pub model_migrations: BTreeMap<String, String>,
|
||||
/// Tracks scopes where external config migration prompts should be suppressed.
|
||||
#[serde(default)]
|
||||
pub external_config_migration_prompts: ExternalConfigMigrationPrompts,
|
||||
}
|
||||
|
||||
pub use crate::skills_config::BundledSkillsConfig;
|
||||
|
||||
@@ -129,7 +129,7 @@ pub fn refresh_curated_plugin_cache(
|
||||
plugin_version: &str,
|
||||
configured_curated_plugin_ids: &[PluginId],
|
||||
) -> Result<bool, String> {
|
||||
let store = PluginStore::new(codex_home.to_path_buf());
|
||||
let store = PluginStore::try_new(codex_home.to_path_buf()).map_err(|err| err.to_string())?;
|
||||
let curated_marketplace_path = AbsolutePathBuf::try_from(
|
||||
codex_home
|
||||
.join(".tmp/plugins")
|
||||
@@ -234,7 +234,7 @@ fn refresh_non_curated_plugin_cache_with_mode(
|
||||
.map(PluginId::as_key)
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
let store = PluginStore::new(codex_home.to_path_buf());
|
||||
let store = PluginStore::try_new(codex_home.to_path_buf()).map_err(|err| err.to_string())?;
|
||||
let marketplace_outcome = list_marketplaces(additional_roots)
|
||||
.map_err(|err| format!("failed to discover marketplaces for cache refresh: {err}"))?;
|
||||
let mut plugin_sources = HashMap::<String, MarketplacePluginSource>::new();
|
||||
@@ -742,7 +742,13 @@ pub async fn installed_plugin_telemetry_metadata(
|
||||
codex_home: &Path,
|
||||
plugin_id: &PluginId,
|
||||
) -> PluginTelemetryMetadata {
|
||||
let store = PluginStore::new(codex_home.to_path_buf());
|
||||
let store = match PluginStore::try_new(codex_home.to_path_buf()) {
|
||||
Ok(store) => store,
|
||||
Err(err) => {
|
||||
warn!("failed to resolve plugin cache root: {err}");
|
||||
return PluginTelemetryMetadata::from_plugin_id(plugin_id);
|
||||
}
|
||||
};
|
||||
let Some(plugin_root) = store.active_plugin_root(plugin_id) else {
|
||||
return PluginTelemetryMetadata::from_plugin_id(plugin_id);
|
||||
};
|
||||
|
||||
@@ -28,10 +28,15 @@ pub struct PluginStore {
|
||||
|
||||
impl PluginStore {
|
||||
pub fn new(codex_home: PathBuf) -> Self {
|
||||
Self {
|
||||
root: AbsolutePathBuf::try_from(codex_home.join(PLUGINS_CACHE_DIR))
|
||||
.unwrap_or_else(|err| panic!("plugin cache root should be absolute: {err}")),
|
||||
}
|
||||
Self::try_new(codex_home)
|
||||
.unwrap_or_else(|err| panic!("plugin cache root should be absolute: {err}"))
|
||||
}
|
||||
|
||||
pub fn try_new(codex_home: PathBuf) -> Result<Self, PluginStoreError> {
|
||||
let root = AbsolutePathBuf::from_absolute_path_checked(codex_home.join(PLUGINS_CACHE_DIR))
|
||||
.map_err(|err| PluginStoreError::io("failed to resolve plugin cache root", err))?;
|
||||
|
||||
Ok(Self { root })
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &AbsolutePathBuf {
|
||||
@@ -39,22 +44,13 @@ impl PluginStore {
|
||||
}
|
||||
|
||||
pub fn plugin_base_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(
|
||||
self.root
|
||||
.as_path()
|
||||
.join(&plugin_id.marketplace_name)
|
||||
.join(&plugin_id.plugin_name),
|
||||
)
|
||||
.unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}"))
|
||||
self.root
|
||||
.join(&plugin_id.marketplace_name)
|
||||
.join(&plugin_id.plugin_name)
|
||||
}
|
||||
|
||||
pub fn plugin_root(&self, plugin_id: &PluginId, plugin_version: &str) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(
|
||||
self.plugin_base_root(plugin_id)
|
||||
.as_path()
|
||||
.join(plugin_version),
|
||||
)
|
||||
.unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}"))
|
||||
self.plugin_base_root(plugin_id).join(plugin_version)
|
||||
}
|
||||
|
||||
pub fn active_plugin_version(&self, plugin_id: &PluginId) -> Option<String> {
|
||||
|
||||
@@ -33,6 +33,18 @@ fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn try_new_rejects_relative_codex_home() {
|
||||
let err = PluginStore::try_new(PathBuf::from("relative"))
|
||||
.expect_err("relative codex home should fail");
|
||||
let err = err.to_string().replace('\\', "/");
|
||||
|
||||
assert_eq!(
|
||||
err,
|
||||
"failed to resolve plugin cache root: path is not absolute: relative/plugins/cache"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_copies_plugin_into_default_marketplace() {
|
||||
let tmp = tempdir().unwrap();
|
||||
|
||||
@@ -24,6 +24,7 @@ codex-otel = { workspace = true }
|
||||
codex-protocol = { workspace = true }
|
||||
codex-skills = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-output-truncation = { workspace = true }
|
||||
codex-utils-plugins = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
dunce = { workspace = true }
|
||||
|
||||
@@ -22,4 +22,8 @@ pub use model::SkillLoadOutcome;
|
||||
pub use model::SkillMetadata;
|
||||
pub use model::SkillPolicy;
|
||||
pub use model::filter_skill_load_outcome_for_product;
|
||||
pub use render::RenderedSkillsSection;
|
||||
pub use render::SkillMetadataBudget;
|
||||
pub use render::SkillRenderReport;
|
||||
pub use render::default_skill_metadata_budget;
|
||||
pub use render::render_skills_section;
|
||||
|
||||
@@ -1,22 +1,104 @@
|
||||
use crate::model::SkillMetadata;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_otel::THREAD_SKILLS_ENABLED_TOTAL_METRIC;
|
||||
use codex_otel::THREAD_SKILLS_KEPT_TOTAL_METRIC;
|
||||
use codex_otel::THREAD_SKILLS_TRUNCATED_METRIC;
|
||||
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
|
||||
pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
|
||||
const DEFAULT_SKILL_METADATA_CHAR_BUDGET: usize = 8_000;
|
||||
const SKILL_METADATA_CONTEXT_WINDOW_PERCENT: usize = 2;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SkillMetadataBudget {
|
||||
Tokens(usize),
|
||||
Characters(usize),
|
||||
}
|
||||
|
||||
impl SkillMetadataBudget {
|
||||
fn limit(self) -> usize {
|
||||
match self {
|
||||
Self::Tokens(limit) | Self::Characters(limit) => limit,
|
||||
}
|
||||
}
|
||||
|
||||
fn cost(self, text: &str) -> usize {
|
||||
match self {
|
||||
Self::Tokens(_) => approx_token_count(text),
|
||||
Self::Characters(_) => text.chars().count(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkillRenderReport {
|
||||
pub total_count: usize,
|
||||
pub included_count: usize,
|
||||
pub omitted_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub enum SkillRenderSideEffects<'a> {
|
||||
None,
|
||||
ThreadStart {
|
||||
session_telemetry: &'a SessionTelemetry,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RenderedSkillsSection {
|
||||
pub text: String,
|
||||
pub report: SkillRenderReport,
|
||||
pub emit_warning: bool,
|
||||
}
|
||||
|
||||
pub fn default_skill_metadata_budget(context_window: Option<i64>) -> SkillMetadataBudget {
|
||||
context_window
|
||||
.and_then(|window| usize::try_from(window).ok())
|
||||
.filter(|window| *window > 0)
|
||||
.map(|window| {
|
||||
SkillMetadataBudget::Tokens(
|
||||
window
|
||||
.saturating_mul(SKILL_METADATA_CONTEXT_WINDOW_PERCENT)
|
||||
.saturating_div(100)
|
||||
.max(1),
|
||||
)
|
||||
})
|
||||
.unwrap_or(SkillMetadataBudget::Characters(
|
||||
DEFAULT_SKILL_METADATA_CHAR_BUDGET,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn render_skills_section(
|
||||
skills: &[SkillMetadata],
|
||||
budget: SkillMetadataBudget,
|
||||
side_effects: SkillRenderSideEffects<'_>,
|
||||
) -> Option<RenderedSkillsSection> {
|
||||
if skills.is_empty() {
|
||||
let _ = record_skill_render_side_effects(
|
||||
side_effects,
|
||||
/*total_count*/ 0,
|
||||
/*included_count*/ 0,
|
||||
/*truncated*/ false,
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let (skill_lines, report) = render_skill_lines(skills, budget);
|
||||
let emit_warning = record_skill_render_side_effects(
|
||||
side_effects,
|
||||
report.total_count,
|
||||
report.included_count,
|
||||
report.omitted_count > 0,
|
||||
);
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push("## Skills".to_string());
|
||||
lines.push("A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.".to_string());
|
||||
lines.push("### Available skills".to_string());
|
||||
|
||||
for skill in skills {
|
||||
let path_str = skill.path_to_skills_md.to_string_lossy().replace('\\', "/");
|
||||
let name = skill.name.as_str();
|
||||
let description = skill.description.as_str();
|
||||
lines.push(format!("- {name}: {description} (file: {path_str})"));
|
||||
if !skill_lines.is_empty() {
|
||||
lines.extend(skill_lines);
|
||||
}
|
||||
|
||||
lines.push("### How to use skills".to_string());
|
||||
@@ -42,7 +124,189 @@ pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
|
||||
);
|
||||
|
||||
let body = lines.join("\n");
|
||||
Some(format!(
|
||||
"{SKILLS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{SKILLS_INSTRUCTIONS_CLOSE_TAG}"
|
||||
))
|
||||
Some(RenderedSkillsSection {
|
||||
text: format!("{SKILLS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{SKILLS_INSTRUCTIONS_CLOSE_TAG}"),
|
||||
report,
|
||||
emit_warning,
|
||||
})
|
||||
}
|
||||
|
||||
fn record_skill_render_side_effects(
|
||||
side_effects: SkillRenderSideEffects<'_>,
|
||||
total_count: usize,
|
||||
included_count: usize,
|
||||
truncated: bool,
|
||||
) -> bool {
|
||||
match side_effects {
|
||||
SkillRenderSideEffects::None => false,
|
||||
SkillRenderSideEffects::ThreadStart { session_telemetry } => {
|
||||
session_telemetry.histogram(
|
||||
THREAD_SKILLS_ENABLED_TOTAL_METRIC,
|
||||
i64::try_from(total_count).unwrap_or(i64::MAX),
|
||||
&[],
|
||||
);
|
||||
session_telemetry.histogram(
|
||||
THREAD_SKILLS_KEPT_TOTAL_METRIC,
|
||||
i64::try_from(included_count).unwrap_or(i64::MAX),
|
||||
&[],
|
||||
);
|
||||
session_telemetry.histogram(
|
||||
THREAD_SKILLS_TRUNCATED_METRIC,
|
||||
if truncated { 1 } else { 0 },
|
||||
&[],
|
||||
);
|
||||
truncated
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_skill_lines(
|
||||
skills: &[SkillMetadata],
|
||||
budget: SkillMetadataBudget,
|
||||
) -> (Vec<String>, SkillRenderReport) {
|
||||
let ordered_skills = ordered_skills_for_budget(skills);
|
||||
|
||||
let mut included = Vec::new();
|
||||
let mut used = 0usize;
|
||||
let mut omitted_count = 0usize;
|
||||
|
||||
for skill in ordered_skills {
|
||||
let line = render_skill_line(skill);
|
||||
let line_cost = budget.cost(&format!("{line}\n"));
|
||||
if used.saturating_add(line_cost) <= budget.limit() {
|
||||
used = used.saturating_add(line_cost);
|
||||
included.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
omitted_count = omitted_count.saturating_add(1);
|
||||
}
|
||||
|
||||
let report = SkillRenderReport {
|
||||
total_count: skills.len(),
|
||||
included_count: included.len(),
|
||||
omitted_count,
|
||||
};
|
||||
|
||||
(included, report)
|
||||
}
|
||||
|
||||
fn ordered_skills_for_budget(skills: &[SkillMetadata]) -> Vec<&SkillMetadata> {
|
||||
let mut ordered = skills.iter().collect::<Vec<_>>();
|
||||
ordered.sort_by(|a, b| {
|
||||
prompt_scope_rank(a.scope)
|
||||
.cmp(&prompt_scope_rank(b.scope))
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
.then_with(|| a.path_to_skills_md.cmp(&b.path_to_skills_md))
|
||||
});
|
||||
ordered
|
||||
}
|
||||
|
||||
fn prompt_scope_rank(scope: SkillScope) -> u8 {
|
||||
match scope {
|
||||
SkillScope::System => 0,
|
||||
SkillScope::Admin => 1,
|
||||
SkillScope::Repo => 2,
|
||||
SkillScope::User => 3,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_skill_line(skill: &SkillMetadata) -> String {
|
||||
let path_str = skill.path_to_skills_md.to_string_lossy().replace('\\', "/");
|
||||
let name = skill.name.as_str();
|
||||
let description = skill.description.as_str();
|
||||
format!("- {name}: {description} (file: {path_str})")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use codex_utils_absolute_path::test_support::test_path_buf;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn make_skill(name: &str, scope: SkillScope) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
name: name.to_string(),
|
||||
description: "desc".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: None,
|
||||
policy: None,
|
||||
path_to_skills_md: test_path_buf(&format!("/tmp/{name}/SKILL.md")).abs(),
|
||||
scope,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_budget_uses_two_percent_of_full_context_window() {
|
||||
assert_eq!(
|
||||
default_skill_metadata_budget(Some(200_000)),
|
||||
SkillMetadataBudget::Tokens(4_000)
|
||||
);
|
||||
assert_eq!(
|
||||
default_skill_metadata_budget(Some(99)),
|
||||
SkillMetadataBudget::Tokens(1)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_budget_falls_back_to_characters_without_context_window() {
|
||||
assert_eq!(
|
||||
default_skill_metadata_budget(/*context_window*/ None),
|
||||
SkillMetadataBudget::Characters(DEFAULT_SKILL_METADATA_CHAR_BUDGET)
|
||||
);
|
||||
assert_eq!(
|
||||
default_skill_metadata_budget(Some(-1)),
|
||||
SkillMetadataBudget::Characters(DEFAULT_SKILL_METADATA_CHAR_BUDGET)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budgeted_rendering_preserves_prompt_priority() {
|
||||
let system = make_skill("system-skill", SkillScope::System);
|
||||
let user = make_skill("user-skill", SkillScope::User);
|
||||
let repo = make_skill("repo-skill", SkillScope::Repo);
|
||||
let admin = make_skill("admin-skill", SkillScope::Admin);
|
||||
let system_cost = SkillMetadataBudget::Characters(usize::MAX)
|
||||
.cost(&format!("{}\n", render_skill_line(&system)));
|
||||
let admin_cost = SkillMetadataBudget::Characters(usize::MAX)
|
||||
.cost(&format!("{}\n", render_skill_line(&admin)));
|
||||
let budget = SkillMetadataBudget::Characters(system_cost + admin_cost);
|
||||
|
||||
let rendered = render_skills_section(
|
||||
&[system, user, repo, admin],
|
||||
budget,
|
||||
SkillRenderSideEffects::None,
|
||||
)
|
||||
.expect("skills should render");
|
||||
|
||||
assert_eq!(rendered.report.included_count, 2);
|
||||
assert_eq!(rendered.report.omitted_count, 2);
|
||||
assert!(!rendered.emit_warning);
|
||||
assert!(rendered.text.contains("- system-skill:"));
|
||||
assert!(rendered.text.contains("- admin-skill:"));
|
||||
assert!(!rendered.text.contains("- repo-skill:"));
|
||||
assert!(!rendered.text.contains("- user-skill:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn budgeted_rendering_keeps_scanning_after_oversized_entry() {
|
||||
let mut oversized = make_skill("oversized-system-skill", SkillScope::System);
|
||||
oversized.description = "desc ".repeat(100);
|
||||
let repo = make_skill("repo-skill", SkillScope::Repo);
|
||||
let repo_cost = SkillMetadataBudget::Characters(usize::MAX)
|
||||
.cost(&format!("{}\n", render_skill_line(&repo)));
|
||||
let budget = SkillMetadataBudget::Characters(repo_cost);
|
||||
|
||||
let rendered =
|
||||
render_skills_section(&[oversized, repo], budget, SkillRenderSideEffects::None)
|
||||
.expect("skills render");
|
||||
|
||||
assert_eq!(rendered.report.included_count, 1);
|
||||
assert_eq!(rendered.report.omitted_count, 1);
|
||||
assert!(!rendered.emit_warning);
|
||||
assert!(!rendered.text.contains("- oversized-system-skill:"));
|
||||
assert!(rendered.text.contains("- repo-skill:"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,10 @@ codex_rust_crate(
|
||||
# succeeds without this workaround.
|
||||
"//:AGENTS.md",
|
||||
],
|
||||
test_shard_counts = {
|
||||
"core-all-test": 8,
|
||||
"core-unit-tests": 8,
|
||||
},
|
||||
test_tags = ["no-sandbox"],
|
||||
unit_test_timeout = "long",
|
||||
extra_binaries = [
|
||||
|
||||
@@ -392,6 +392,9 @@
|
||||
"experimental_windows_sandbox": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"external_migration": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"fast_mode": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -639,6 +642,39 @@
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"ExternalConfigMigrationPrompts": {
|
||||
"additionalProperties": false,
|
||||
"description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.",
|
||||
"properties": {
|
||||
"home": {
|
||||
"description": "Tracks whether home-level external config migration prompts are hidden.",
|
||||
"type": "boolean"
|
||||
},
|
||||
"home_last_prompted_at": {
|
||||
"description": "Tracks the last time the home-level external config migration prompt was shown.",
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"project_last_prompted_at": {
|
||||
"additionalProperties": {
|
||||
"format": "int64",
|
||||
"type": "integer"
|
||||
},
|
||||
"default": {},
|
||||
"description": "Tracks the last time a project-level external config migration prompt was shown.",
|
||||
"type": "object"
|
||||
},
|
||||
"projects": {
|
||||
"additionalProperties": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"default": {},
|
||||
"description": "Tracks which project paths have opted out of external config migration prompts.",
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FeatureToml_for_MultiAgentV2ConfigToml": {
|
||||
"anyOf": [
|
||||
{
|
||||
@@ -843,6 +879,28 @@
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"McpServerEnvVar": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
]
|
||||
},
|
||||
"McpServerToolConfig": {
|
||||
"additionalProperties": false,
|
||||
"description": "Per-tool approval settings for a single MCP server tool.",
|
||||
@@ -1156,8 +1214,22 @@
|
||||
"type": "object"
|
||||
},
|
||||
"Notice": {
|
||||
"description": "Settings for notices we display to users via the tui and app-server clients (primarily the Codex IDE extension). NOTE: these are different from notifications - notices are warnings, NUX screens, acknowledgements, etc.",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"external_config_migration_prompts": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/ExternalConfigMigrationPrompts"
|
||||
}
|
||||
],
|
||||
"default": {
|
||||
"home": null,
|
||||
"home_last_prompted_at": null,
|
||||
"project_last_prompted_at": {},
|
||||
"projects": {}
|
||||
},
|
||||
"description": "Tracks scopes where external config migration prompts should be suppressed."
|
||||
},
|
||||
"hide_full_access_warning": {
|
||||
"description": "Tracks whether the user has acknowledged the full access warning prompt.",
|
||||
"type": "boolean"
|
||||
@@ -1523,7 +1595,7 @@
|
||||
"env_vars": {
|
||||
"default": null,
|
||||
"items": {
|
||||
"type": "string"
|
||||
"$ref": "#/definitions/McpServerEnvVar"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
@@ -2291,6 +2363,9 @@
|
||||
"experimental_windows_sandbox": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"external_migration": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"fast_mode": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -2872,4 +2947,4 @@
|
||||
},
|
||||
"title": "ConfigToml",
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_model_provider_info::WireApi;
|
||||
use codex_model_provider_info::create_oss_provider_with_base_url;
|
||||
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
async fn process_compacted_history_with_test_session(
|
||||
@@ -61,6 +62,7 @@ fn content_items_to_text_joins_non_empty_segments() {
|
||||
fn content_items_to_text_ignores_image_only_content() {
|
||||
let items = vec![ContentItem::InputImage {
|
||||
image_url: "file://image.png".to_string(),
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
}];
|
||||
|
||||
let joined = content_items_to_text(&items);
|
||||
|
||||
@@ -4,6 +4,7 @@ use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::config::edit::apply_blocking;
|
||||
use crate::config_loader::RequirementSource;
|
||||
use crate::config_loader::project_trust_key;
|
||||
use crate::plugins::PluginsManager;
|
||||
use assert_matches::assert_matches;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
@@ -31,6 +32,7 @@ use codex_config::types::ApprovalsReviewer;
|
||||
use codex_config::types::BundledSkillsConfig;
|
||||
use codex_config::types::FeedbackConfigToml;
|
||||
use codex_config::types::HistoryPersistence;
|
||||
use codex_config::types::McpServerEnvVar;
|
||||
use codex_config::types::McpServerToolConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_config::types::MemoriesConfig;
|
||||
@@ -2489,7 +2491,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> {
|
||||
command: "docs-server".to_string(),
|
||||
args: Vec::new(),
|
||||
env: None,
|
||||
env_vars: vec!["ALPHA".to_string(), "BETA".to_string()],
|
||||
env_vars: vec!["ALPHA".into(), "BETA".into()],
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
@@ -2525,7 +2527,7 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> {
|
||||
let docs = loaded.get("docs").expect("docs entry");
|
||||
match &docs.transport {
|
||||
McpServerTransportConfig::Stdio { env_vars, .. } => {
|
||||
assert_eq!(env_vars, &vec!["ALPHA".to_string(), "BETA".to_string()]);
|
||||
assert_eq!(env_vars, &vec!["ALPHA".into(), "BETA".into()]);
|
||||
}
|
||||
other => panic!("unexpected transport {other:?}"),
|
||||
}
|
||||
@@ -2533,6 +2535,62 @@ async fn replace_mcp_servers_serializes_env_vars() -> anyhow::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_mcp_servers_serializes_sourced_env_vars() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
|
||||
let servers = BTreeMap::from([(
|
||||
"docs".to_string(),
|
||||
McpServerConfig {
|
||||
transport: McpServerTransportConfig::Stdio {
|
||||
command: "docs-server".to_string(),
|
||||
args: Vec::new(),
|
||||
env: None,
|
||||
env_vars: vec![
|
||||
"LEGACY".into(),
|
||||
McpServerEnvVar::Config {
|
||||
name: "REMOTE_TOKEN".to_string(),
|
||||
source: Some("remote".to_string()),
|
||||
},
|
||||
],
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
default_tools_approval_mode: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
oauth_resource: None,
|
||||
tools: HashMap::new(),
|
||||
},
|
||||
)]);
|
||||
|
||||
apply_blocking(
|
||||
codex_home.path(),
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::ReplaceMcpServers(servers.clone())],
|
||||
)?;
|
||||
|
||||
let config_path = codex_home.path().join(CONFIG_TOML_FILE);
|
||||
let serialized = std::fs::read_to_string(&config_path)?;
|
||||
assert!(
|
||||
serialized
|
||||
.contains(r#"env_vars = ["LEGACY", { name = "REMOTE_TOKEN", source = "remote" }]"#),
|
||||
"serialized config missing sourced env_vars field:\n{serialized}"
|
||||
);
|
||||
|
||||
let loaded = load_global_mcp_servers(codex_home.path()).await?;
|
||||
assert_eq!(loaded, servers);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn replace_mcp_servers_serializes_cwd() -> anyhow::Result<()> {
|
||||
let codex_home = TempDir::new()?;
|
||||
@@ -5449,7 +5507,9 @@ model = "foo""#;
|
||||
|
||||
// Since we created the [projects] table as part of migration, it is kept implicit.
|
||||
// Expect explicit per-project tables, preserving prior entries and appending the new one.
|
||||
let expected = r#"toplevel = "baz"
|
||||
let new_project_key = project_trust_key(new_project);
|
||||
let expected = format!(
|
||||
r#"toplevel = "baz"
|
||||
model = "foo"
|
||||
|
||||
[projects."/Users/mbolin/code/codex4"]
|
||||
@@ -5459,14 +5519,42 @@ foo = "bar"
|
||||
[projects."/Users/mbolin/code/codex3"]
|
||||
trust_level = "trusted"
|
||||
|
||||
[projects."/Users/mbolin/code/codex2"]
|
||||
[projects."{new_project_key}"]
|
||||
trust_level = "trusted"
|
||||
"#;
|
||||
"#
|
||||
);
|
||||
assert_eq!(contents, expected);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn active_project_does_not_match_configured_alias_for_canonical_cwd() -> anyhow::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let project_root = tmp.path().join("project");
|
||||
let alias_root = tmp.path().join("project_alias");
|
||||
std::fs::create_dir_all(&project_root)?;
|
||||
std::os::unix::fs::symlink(&project_root, &alias_root)?;
|
||||
|
||||
let config = ConfigToml {
|
||||
projects: Some(HashMap::from([(
|
||||
alias_root.to_string_lossy().to_string(),
|
||||
ProjectConfig {
|
||||
trust_level: Some(TrustLevel::Trusted),
|
||||
},
|
||||
)])),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
config.get_active_project(&project_root, /*repo_root*/ None),
|
||||
None
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_default_oss_provider() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
|
||||
@@ -43,6 +43,14 @@ pub enum ConfigEdit {
|
||||
SetWindowsWslSetupAcknowledged(bool),
|
||||
/// Toggle the model migration prompt acknowledgement flag.
|
||||
SetNoticeHideModelMigrationPrompt(String, bool),
|
||||
/// Toggle the home external config migration prompt acknowledgement flag.
|
||||
SetNoticeHideExternalConfigMigrationPromptHome(bool),
|
||||
/// Record when the home external config migration prompt was last shown.
|
||||
SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(i64),
|
||||
/// Toggle the project external config migration prompt acknowledgement flag.
|
||||
SetNoticeHideExternalConfigMigrationPromptProject(String, bool),
|
||||
/// Record when the project external config migration prompt was last shown.
|
||||
SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(String, i64),
|
||||
/// Record that a migration prompt was shown for an old->new model mapping.
|
||||
RecordModelMigrationSeen { from: String, to: String },
|
||||
/// Replace the entire `[mcp_servers]` table.
|
||||
@@ -128,6 +136,7 @@ pub fn model_availability_nux_count_edits(shown_count: &HashMap<String, u32>) ->
|
||||
mod document_helpers {
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_config::types::McpServerConfig;
|
||||
use codex_config::types::McpServerEnvVar;
|
||||
use codex_config::types::McpServerToolConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use toml_edit::Array as TomlArray;
|
||||
@@ -190,7 +199,7 @@ mod document_helpers {
|
||||
entry["env"] = table_from_pairs(env.iter());
|
||||
}
|
||||
if !env_vars.is_empty() {
|
||||
entry["env_vars"] = array_from_iter(env_vars.iter().cloned());
|
||||
entry["env_vars"] = array_from_env_vars(env_vars);
|
||||
}
|
||||
if let Some(cwd) = cwd {
|
||||
entry["cwd"] = value(cwd.to_string_lossy().to_string());
|
||||
@@ -340,6 +349,24 @@ mod document_helpers {
|
||||
TomlItem::Value(array.into())
|
||||
}
|
||||
|
||||
fn array_from_env_vars(env_vars: &[McpServerEnvVar]) -> TomlItem {
|
||||
let mut array = TomlArray::new();
|
||||
for env_var in env_vars {
|
||||
match env_var {
|
||||
McpServerEnvVar::Name(name) => array.push(name.clone()),
|
||||
McpServerEnvVar::Config { name, source } => {
|
||||
let mut table = InlineTable::new();
|
||||
table.insert("name", name.clone().into());
|
||||
if let Some(source) = source {
|
||||
table.insert("source", source.clone().into());
|
||||
}
|
||||
array.push(table);
|
||||
}
|
||||
}
|
||||
}
|
||||
TomlItem::Value(array.into())
|
||||
}
|
||||
|
||||
fn table_from_pairs<'a, I>(pairs: I) -> TomlItem
|
||||
where
|
||||
I: IntoIterator<Item = (&'a String, &'a String)>,
|
||||
@@ -421,6 +448,53 @@ impl ConfigDocument {
|
||||
value(*acknowledged),
|
||||
))
|
||||
}
|
||||
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(acknowledged) => Ok(self
|
||||
.write_value(
|
||||
Scope::Global,
|
||||
&[
|
||||
NOTICE_TABLE_KEY,
|
||||
"external_config_migration_prompts",
|
||||
"home",
|
||||
],
|
||||
value(*acknowledged),
|
||||
)),
|
||||
ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(timestamp) => {
|
||||
Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[
|
||||
NOTICE_TABLE_KEY,
|
||||
"external_config_migration_prompts",
|
||||
"home_last_prompted_at",
|
||||
],
|
||||
value(*timestamp),
|
||||
))
|
||||
}
|
||||
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
|
||||
project,
|
||||
acknowledged,
|
||||
) => Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[
|
||||
NOTICE_TABLE_KEY,
|
||||
"external_config_migration_prompts",
|
||||
"projects",
|
||||
project.as_str(),
|
||||
],
|
||||
value(*acknowledged),
|
||||
)),
|
||||
ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(
|
||||
project,
|
||||
timestamp,
|
||||
) => Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[
|
||||
NOTICE_TABLE_KEY,
|
||||
"external_config_migration_prompts",
|
||||
"project_last_prompted_at",
|
||||
project.as_str(),
|
||||
],
|
||||
value(*timestamp),
|
||||
)),
|
||||
ConfigEdit::RecordModelMigrationSeen { from, to } => Ok(self.write_value(
|
||||
Scope::Global,
|
||||
&[NOTICE_TABLE_KEY, "model_migrations", from.as_str()],
|
||||
@@ -919,6 +993,28 @@ impl ConfigEditsBuilder {
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_hide_external_config_migration_prompt_home(mut self, acknowledged: bool) -> Self {
|
||||
self.edits
|
||||
.push(ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(
|
||||
acknowledged,
|
||||
));
|
||||
self
|
||||
}
|
||||
|
||||
pub fn set_hide_external_config_migration_prompt_project(
|
||||
mut self,
|
||||
project: &str,
|
||||
acknowledged: bool,
|
||||
) -> Self {
|
||||
self.edits.push(
|
||||
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
|
||||
project.to_string(),
|
||||
acknowledged,
|
||||
),
|
||||
);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn record_model_migration_seen(mut self, from: &str, to: &str) -> Self {
|
||||
self.edits.push(ConfigEdit::RecordModelMigrationSeen {
|
||||
from: from.to_string(),
|
||||
|
||||
@@ -552,6 +552,130 @@ gpt-5 = "gpt-5.1"
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_set_hide_external_config_migration_prompt_home_preserves_table() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
std::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"[notice]
|
||||
existing = "value"
|
||||
"#,
|
||||
)
|
||||
.expect("seed");
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::SetNoticeHideExternalConfigMigrationPromptHome(
|
||||
true,
|
||||
)],
|
||||
)
|
||||
.expect("persist");
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"[notice]
|
||||
existing = "value"
|
||||
|
||||
[notice.external_config_migration_prompts]
|
||||
home = true
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_set_hide_external_config_migration_prompt_project_preserves_table() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
std::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"[notice]
|
||||
existing = "value"
|
||||
"#,
|
||||
)
|
||||
.expect("seed");
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[
|
||||
ConfigEdit::SetNoticeHideExternalConfigMigrationPromptProject(
|
||||
"/Users/alexsong/code/skills".to_string(),
|
||||
true,
|
||||
),
|
||||
],
|
||||
)
|
||||
.expect("persist");
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"[notice]
|
||||
existing = "value"
|
||||
|
||||
[notice.external_config_migration_prompts.projects]
|
||||
"/Users/alexsong/code/skills" = true
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_set_external_config_migration_prompt_home_last_prompted_at_preserves_table() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
std::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"[notice]
|
||||
existing = "value"
|
||||
"#,
|
||||
)
|
||||
.expect("seed");
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[ConfigEdit::SetNoticeExternalConfigMigrationPromptHomeLastPromptedAt(1_760_000_000)],
|
||||
)
|
||||
.expect("persist");
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"[notice]
|
||||
existing = "value"
|
||||
|
||||
[notice.external_config_migration_prompts]
|
||||
home_last_prompted_at = 1760000000
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_set_external_config_migration_prompt_project_last_prompted_at_preserves_table() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
let codex_home = tmp.path();
|
||||
std::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
r#"[notice]
|
||||
existing = "value"
|
||||
"#,
|
||||
)
|
||||
.expect("seed");
|
||||
apply_blocking(
|
||||
codex_home,
|
||||
/*profile*/ None,
|
||||
&[
|
||||
ConfigEdit::SetNoticeExternalConfigMigrationPromptProjectLastPromptedAt(
|
||||
"/Users/alexsong/code/skills".to_string(),
|
||||
1_760_000_000,
|
||||
),
|
||||
],
|
||||
)
|
||||
.expect("persist");
|
||||
|
||||
let contents = std::fs::read_to_string(codex_home.join(CONFIG_TOML_FILE)).expect("read config");
|
||||
let expected = r#"[notice]
|
||||
existing = "value"
|
||||
|
||||
[notice.external_config_migration_prompts.project_last_prompted_at]
|
||||
"/Users/alexsong/code/skills" = 1760000000
|
||||
"#;
|
||||
assert_eq!(contents, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blocking_replace_mcp_servers_round_trips() {
|
||||
let tmp = tempdir().expect("tmpdir");
|
||||
@@ -572,7 +696,7 @@ fn blocking_replace_mcp_servers_round_trips() {
|
||||
.into_iter()
|
||||
.collect(),
|
||||
),
|
||||
env_vars: vec!["FOO".to_string()],
|
||||
env_vars: vec!["FOO".into()],
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
|
||||
@@ -561,7 +561,9 @@ async fn load_requirements_from_legacy_scheme(
|
||||
struct ProjectTrustContext {
|
||||
project_root: AbsolutePathBuf,
|
||||
project_root_key: String,
|
||||
project_root_lookup_keys: Vec<String>,
|
||||
repo_root_key: Option<String>,
|
||||
repo_root_lookup_keys: Option<Vec<String>>,
|
||||
projects_trust: std::collections::HashMap<String, TrustLevel>,
|
||||
user_config_file: AbsolutePathBuf,
|
||||
}
|
||||
@@ -584,28 +586,39 @@ impl ProjectTrustDecision {
|
||||
|
||||
impl ProjectTrustContext {
|
||||
fn decision_for_dir(&self, dir: &AbsolutePathBuf) -> ProjectTrustDecision {
|
||||
let dir_key = project_trust_key(dir.as_path());
|
||||
if let Some(trust_level) = self.projects_trust.get(&dir_key).copied() {
|
||||
return ProjectTrustDecision {
|
||||
trust_level: Some(trust_level),
|
||||
trust_key: dir_key,
|
||||
};
|
||||
for dir_key in normalized_project_trust_keys(dir.as_path()) {
|
||||
if let Some((trust_key, trust_level)) =
|
||||
project_trust_for_lookup_key(&self.projects_trust, &dir_key)
|
||||
{
|
||||
return ProjectTrustDecision {
|
||||
trust_level: Some(trust_level),
|
||||
trust_key,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(trust_level) = self.projects_trust.get(&self.project_root_key).copied() {
|
||||
return ProjectTrustDecision {
|
||||
trust_level: Some(trust_level),
|
||||
trust_key: self.project_root_key.clone(),
|
||||
};
|
||||
for project_root_key in &self.project_root_lookup_keys {
|
||||
if let Some((trust_key, trust_level)) =
|
||||
project_trust_for_lookup_key(&self.projects_trust, project_root_key)
|
||||
{
|
||||
return ProjectTrustDecision {
|
||||
trust_level: Some(trust_level),
|
||||
trust_key,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(repo_root_key) = self.repo_root_key.as_ref()
|
||||
&& let Some(trust_level) = self.projects_trust.get(repo_root_key).copied()
|
||||
{
|
||||
return ProjectTrustDecision {
|
||||
trust_level: Some(trust_level),
|
||||
trust_key: repo_root_key.clone(),
|
||||
};
|
||||
if let Some(repo_root_lookup_keys) = self.repo_root_lookup_keys.as_ref() {
|
||||
for repo_root_key in repo_root_lookup_keys {
|
||||
if let Some((trust_key, trust_level)) =
|
||||
project_trust_for_lookup_key(&self.projects_trust, repo_root_key)
|
||||
{
|
||||
return ProjectTrustDecision {
|
||||
trust_level: Some(trust_level),
|
||||
trust_key,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ProjectTrustDecision {
|
||||
@@ -617,37 +630,35 @@ impl ProjectTrustContext {
|
||||
}
|
||||
}
|
||||
|
||||
fn disabled_reason_for_dir(&self, dir: &AbsolutePathBuf) -> Option<String> {
|
||||
let decision = self.decision_for_dir(dir);
|
||||
fn disabled_reason_for_decision(&self, decision: &ProjectTrustDecision) -> Option<String> {
|
||||
if decision.is_trusted() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let gated_features = "project-local config, hooks, and exec policies";
|
||||
let trust_key = decision.trust_key.as_str();
|
||||
let user_config_file = self.user_config_file.as_path().display();
|
||||
match decision.trust_level {
|
||||
Some(TrustLevel::Untrusted) => Some(format!(
|
||||
"{trust_key} is marked as untrusted in {user_config_file}. To load config.toml, mark it trusted."
|
||||
"{trust_key} is marked as untrusted in {user_config_file}. To load {gated_features}, mark it trusted."
|
||||
)),
|
||||
_ => Some(format!(
|
||||
"To load config.toml, add {trust_key} as a trusted project in {user_config_file}."
|
||||
"To load {gated_features}, add {trust_key} as a trusted project in {user_config_file}."
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn project_layer_entry(
|
||||
trust_context: &ProjectTrustContext,
|
||||
dot_codex_folder: &AbsolutePathBuf,
|
||||
layer_dir: &AbsolutePathBuf,
|
||||
config: TomlValue,
|
||||
config_toml_exists: bool,
|
||||
disabled_reason: Option<String>,
|
||||
) -> ConfigLayerEntry {
|
||||
let source = ConfigLayerSource::Project {
|
||||
dot_codex_folder: dot_codex_folder.clone(),
|
||||
};
|
||||
|
||||
if config_toml_exists && let Some(reason) = trust_context.disabled_reason_for_dir(layer_dir) {
|
||||
if let Some(reason) = disabled_reason {
|
||||
ConfigLayerEntry::new_disabled(source, config, reason)
|
||||
} else {
|
||||
ConfigLayerEntry::new(source, config)
|
||||
@@ -673,25 +684,30 @@ async fn project_trust_context(
|
||||
let project_root = find_project_root(fs, cwd, project_root_markers).await?;
|
||||
let projects = project_trust_config.projects.unwrap_or_default();
|
||||
|
||||
let project_root_key = project_trust_key(project_root.as_path());
|
||||
let project_root_lookup_keys = normalized_project_trust_keys(project_root.as_path());
|
||||
let project_root_key = project_root_lookup_keys
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| project_trust_key(project_root.as_path()));
|
||||
let repo_root = resolve_root_git_project_for_trust(fs, cwd).await;
|
||||
let repo_root_key = repo_root
|
||||
let repo_root_lookup_keys = repo_root
|
||||
.as_ref()
|
||||
.map(|root| project_trust_key(root.as_path()));
|
||||
.map(|root| normalized_project_trust_keys(root.as_path()));
|
||||
let repo_root_key = repo_root_lookup_keys
|
||||
.as_ref()
|
||||
.and_then(|keys| keys.first().cloned());
|
||||
|
||||
let projects_trust = projects
|
||||
.into_iter()
|
||||
.filter_map(|(key, project)| {
|
||||
project
|
||||
.trust_level
|
||||
.map(|trust_level| (project_trust_key(Path::new(&key)), trust_level))
|
||||
})
|
||||
.filter_map(|(key, project)| project.trust_level.map(|trust_level| (key, trust_level)))
|
||||
.collect();
|
||||
|
||||
Ok(ProjectTrustContext {
|
||||
project_root,
|
||||
project_root_key,
|
||||
project_root_lookup_keys,
|
||||
repo_root_key,
|
||||
repo_root_lookup_keys,
|
||||
projects_trust,
|
||||
user_config_file: user_config_file.clone(),
|
||||
})
|
||||
@@ -700,13 +716,52 @@ async fn project_trust_context(
|
||||
/// Canonicalize the path and convert it to a string to be used as a key in the
|
||||
/// projects trust map. On Windows, strips UNC, when possible, to try to ensure
|
||||
/// that different paths that point to the same location have the same key.
|
||||
pub fn project_trust_key(project_path: &Path) -> String {
|
||||
normalize_path(project_path)
|
||||
.unwrap_or_else(|_| project_path.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
pub fn project_trust_key(path: &Path) -> String {
|
||||
normalized_project_trust_keys(path)
|
||||
.into_iter()
|
||||
.next()
|
||||
.unwrap_or_else(|| normalize_project_trust_lookup_key(path.to_string_lossy().to_string()))
|
||||
}
|
||||
|
||||
fn normalized_project_trust_keys(path: &Path) -> Vec<String> {
|
||||
let normalized_path = normalize_project_trust_lookup_key(path.to_string_lossy().to_string());
|
||||
let normalized_canonical_path = normalize_project_trust_lookup_key(
|
||||
normalize_path(path)
|
||||
.unwrap_or_else(|_| path.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
);
|
||||
if normalized_path == normalized_canonical_path {
|
||||
vec![normalized_canonical_path]
|
||||
} else {
|
||||
vec![normalized_canonical_path, normalized_path]
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_project_trust_lookup_key(key: String) -> String {
|
||||
if cfg!(windows) {
|
||||
key.to_ascii_lowercase()
|
||||
} else {
|
||||
key
|
||||
}
|
||||
}
|
||||
fn project_trust_for_lookup_key(
|
||||
projects_trust: &std::collections::HashMap<String, TrustLevel>,
|
||||
lookup_key: &str,
|
||||
) -> Option<(String, TrustLevel)> {
|
||||
if let Some(trust_level) = projects_trust.get(lookup_key).copied() {
|
||||
return Some((lookup_key.to_string(), trust_level));
|
||||
}
|
||||
|
||||
let mut normalized_matches: Vec<_> = projects_trust
|
||||
.iter()
|
||||
.filter(|(key, _)| normalize_project_trust_lookup_key((*key).clone()) == lookup_key)
|
||||
.collect();
|
||||
normalized_matches.sort_by(|(left, _), (right, _)| left.cmp(right));
|
||||
normalized_matches
|
||||
.first()
|
||||
.map(|(key, trust_level)| ((**key).clone(), **trust_level))
|
||||
}
|
||||
/// Takes a `toml::Value` parsed from a config.toml file and walks through it,
|
||||
/// resolving any `AbsolutePathBuf` fields against `base_dir`, returning a new
|
||||
/// `toml::Value` with the same shape but with paths resolved.
|
||||
@@ -834,6 +889,7 @@ async fn load_project_layers(
|
||||
}
|
||||
|
||||
let decision = trust_context.decision_for_dir(&dir);
|
||||
let disabled_reason = trust_context.disabled_reason_for_decision(&decision);
|
||||
let dot_codex_normalized =
|
||||
normalize_path(dot_codex_abs.as_path()).unwrap_or_else(|_| dot_codex_abs.to_path_buf());
|
||||
if dot_codex_abs == codex_home_abs || dot_codex_normalized == codex_home_normalized {
|
||||
@@ -855,24 +911,16 @@ async fn load_project_layers(
|
||||
));
|
||||
}
|
||||
layers.push(project_layer_entry(
|
||||
trust_context,
|
||||
&dot_codex_abs,
|
||||
&dir,
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
/*config_toml_exists*/ true,
|
||||
disabled_reason.clone(),
|
||||
));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let config =
|
||||
resolve_relative_paths_in_config_toml(config, dot_codex_abs.as_path())?;
|
||||
let entry = project_layer_entry(
|
||||
trust_context,
|
||||
&dot_codex_abs,
|
||||
&dir,
|
||||
config,
|
||||
/*config_toml_exists*/ true,
|
||||
);
|
||||
let entry = project_layer_entry(&dot_codex_abs, config, disabled_reason.clone());
|
||||
layers.push(entry);
|
||||
}
|
||||
Err(err) => {
|
||||
@@ -881,11 +929,9 @@ async fn load_project_layers(
|
||||
// for this project layer, as this may still have subfolders
|
||||
// that are significant in the overall ConfigLayerStack.
|
||||
layers.push(project_layer_entry(
|
||||
trust_context,
|
||||
&dot_codex_abs,
|
||||
&dir,
|
||||
TomlValue::Table(toml::map::Map::new()),
|
||||
/*config_toml_exists*/ false,
|
||||
disabled_reason,
|
||||
));
|
||||
} else {
|
||||
let config_file_display = config_file.as_path().display();
|
||||
@@ -900,7 +946,6 @@ async fn load_project_layers(
|
||||
|
||||
Ok(layers)
|
||||
}
|
||||
|
||||
/// The legacy mechanism for specifying admin-enforced configuration is to read
|
||||
/// from a file like `/etc/codex/managed_config.toml` that has the same
|
||||
/// structure as `config.toml` where fields like `approval_policy` can specify
|
||||
|
||||
@@ -1343,6 +1343,66 @@ async fn project_layers_disabled_when_untrusted_or_unknown() -> std::io::Result<
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn project_trust_does_not_match_configured_alias_for_canonical_cwd() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let project_root = tmp.path().join("project");
|
||||
let alias_root = tmp.path().join("project_alias");
|
||||
tokio::fs::create_dir_all(project_root.join(".codex")).await?;
|
||||
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
|
||||
tokio::fs::write(
|
||||
project_root.join(".codex").join(CONFIG_TOML_FILE),
|
||||
"foo = \"project\"\n",
|
||||
)
|
||||
.await?;
|
||||
std::os::unix::fs::symlink(&project_root, &alias_root)?;
|
||||
|
||||
let codex_home = tmp.path().join("home");
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
tokio::fs::write(
|
||||
codex_home.join(CONFIG_TOML_FILE),
|
||||
toml::to_string(&ConfigToml {
|
||||
projects: Some(HashMap::from([(
|
||||
alias_root.to_string_lossy().to_string(),
|
||||
ProjectConfig {
|
||||
trust_level: Some(TrustLevel::Trusted),
|
||||
},
|
||||
)])),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("serialize config"),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let layers = load_config_layers_state(
|
||||
LOCAL_FS.as_ref(),
|
||||
&codex_home,
|
||||
Some(AbsolutePathBuf::from_absolute_path(&project_root)?),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let project_layers: Vec<_> = layers
|
||||
.get_layers(
|
||||
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
|
||||
/*include_disabled*/ true,
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
|
||||
.collect();
|
||||
assert_eq!(project_layers.len(), 1);
|
||||
assert!(
|
||||
project_layers[0].disabled_reason.is_some(),
|
||||
"configured aliases must not collapse into the canonical project key"
|
||||
);
|
||||
assert_eq!(layers.effective_config().get("foo"), None);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_override_can_update_project_local_mcp_server_when_project_is_trusted()
|
||||
-> std::io::Result<()> {
|
||||
@@ -1507,6 +1567,71 @@ async fn invalid_project_config_ignored_when_untrusted_or_unknown() -> std::io::
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn project_layer_without_config_toml_is_disabled_when_untrusted_or_unknown()
|
||||
-> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
let project_root = tmp.path().join("project");
|
||||
let nested = project_root.join("child");
|
||||
tokio::fs::create_dir_all(nested.join(".codex")).await?;
|
||||
tokio::fs::write(project_root.join(".git"), "gitdir: here").await?;
|
||||
|
||||
let cwd = AbsolutePathBuf::from_absolute_path(&nested)?;
|
||||
let cases = [
|
||||
("untrusted", Some(TrustLevel::Untrusted), true),
|
||||
("unknown", None, true),
|
||||
("trusted", Some(TrustLevel::Trusted), false),
|
||||
];
|
||||
|
||||
for (name, trust_level, expect_disabled) in cases {
|
||||
let codex_home = tmp.path().join(format!("home_no_config_{name}"));
|
||||
tokio::fs::create_dir_all(&codex_home).await?;
|
||||
if let Some(trust_level) = trust_level {
|
||||
make_config_for_test(
|
||||
&codex_home,
|
||||
&project_root,
|
||||
trust_level,
|
||||
/*project_root_markers*/ None,
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let layers = load_config_layers_state(
|
||||
LOCAL_FS.as_ref(),
|
||||
&codex_home,
|
||||
Some(cwd.clone()),
|
||||
&[] as &[(String, TomlValue)],
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await?;
|
||||
let project_layers: Vec<_> = layers
|
||||
.get_layers(
|
||||
super::ConfigLayerStackOrdering::HighestPrecedenceFirst,
|
||||
/*include_disabled*/ true,
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|layer| matches!(layer.name, super::ConfigLayerSource::Project { .. }))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
project_layers.len(),
|
||||
1,
|
||||
"expected one project layer for {name}"
|
||||
);
|
||||
assert_eq!(
|
||||
project_layers[0].disabled_reason.is_some(),
|
||||
expect_disabled,
|
||||
"unexpected disabled state for {name}",
|
||||
);
|
||||
assert_eq!(
|
||||
project_layers[0].config,
|
||||
TomlValue::Table(toml::map::Map::new())
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn cli_overrides_with_relative_paths_do_not_break_trust_check() -> std::io::Result<()> {
|
||||
let tmp = tempdir()?;
|
||||
|
||||
@@ -13,6 +13,7 @@ pub use codex_app_server_protocol::AppInfo;
|
||||
pub use codex_app_server_protocol::AppMetadata;
|
||||
use codex_connectors::AllConnectorsCacheKey;
|
||||
use codex_connectors::DirectoryListResponse;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_login::token_data::TokenData;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_tools::DiscoverableTool;
|
||||
@@ -37,6 +38,7 @@ use codex_login::default_client::create_client;
|
||||
use codex_login::default_client::originator;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::McpConnectionManager;
|
||||
use codex_mcp::McpRuntimeEnvironment;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_mcp::ToolPluginProvenance;
|
||||
use codex_mcp::codex_apps_tools_cache_key;
|
||||
@@ -241,6 +243,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
INITIAL_SUBMIT_ID.to_owned(),
|
||||
tx_event,
|
||||
SandboxPolicy::new_read_only_policy(),
|
||||
McpRuntimeEnvironment::new(Arc::new(Environment::default()), config.cwd.to_path_buf()),
|
||||
config.codex_home.to_path_buf(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
ToolPluginProvenance::default(),
|
||||
|
||||
@@ -649,8 +649,8 @@ fn image_data_url_estimate_adjustment(item: &ResponseItem) -> (i64, i64) {
|
||||
match item {
|
||||
ResponseItem::Message { content, .. } => {
|
||||
for content_item in content {
|
||||
if let ContentItem::InputImage { image_url } = content_item {
|
||||
accumulate(image_url, None);
|
||||
if let ContentItem::InputImage { image_url, detail } = content_item {
|
||||
accumulate(image_url, *detail);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use codex_protocol::AgentPath;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
@@ -382,6 +383,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
|
||||
},
|
||||
ContentItem::InputImage {
|
||||
image_url: "https://example.com/img.png".to_string(),
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: "caption".to_string(),
|
||||
@@ -405,7 +407,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "https://example.com/result.png".to_string(),
|
||||
detail: None,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
]),
|
||||
},
|
||||
@@ -425,7 +427,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "https://example.com/js-repl-result.png".to_string(),
|
||||
detail: None,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
]),
|
||||
},
|
||||
@@ -506,6 +508,7 @@ fn for_prompt_strips_images_when_model_does_not_support_images() {
|
||||
},
|
||||
ContentItem::InputImage {
|
||||
image_url: "https://example.com/img.png".to_string(),
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
],
|
||||
end_turn: None,
|
||||
@@ -715,7 +718,7 @@ fn replace_last_turn_images_replaces_tool_output_images() {
|
||||
body: FunctionCallOutputBody::ContentItems(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,AAA".to_string(),
|
||||
detail: None,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
]),
|
||||
success: Some(true),
|
||||
@@ -752,6 +755,7 @@ fn replace_last_turn_images_does_not_touch_user_images() {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputImage {
|
||||
image_url: "data:image/png;base64,AAA".to_string(),
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
@@ -1680,7 +1684,10 @@ fn image_data_url_payload_does_not_dominate_message_estimate() {
|
||||
ContentItem::InputText {
|
||||
text: "Here is the screenshot".to_string(),
|
||||
},
|
||||
ContentItem::InputImage { image_url },
|
||||
ContentItem::InputImage {
|
||||
image_url,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
@@ -1717,7 +1724,7 @@ fn image_data_url_payload_does_not_dominate_function_call_output_estimate() {
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url,
|
||||
detail: None,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
]),
|
||||
};
|
||||
@@ -1743,7 +1750,7 @@ fn image_data_url_payload_does_not_dominate_custom_tool_call_output_estimate() {
|
||||
},
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url,
|
||||
detail: None,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
]),
|
||||
};
|
||||
@@ -1763,6 +1770,7 @@ fn non_base64_image_urls_are_unchanged() {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputImage {
|
||||
image_url: "https://example.com/foo.png".to_string(),
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
@@ -1772,7 +1780,7 @@ fn non_base64_image_urls_are_unchanged() {
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url: "file:///tmp/foo.png".to_string(),
|
||||
detail: None,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
]),
|
||||
};
|
||||
@@ -1794,6 +1802,7 @@ fn data_url_without_base64_marker_is_unchanged() {
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputImage {
|
||||
image_url: "data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg'/>".to_string(),
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
@@ -1814,7 +1823,7 @@ fn non_image_base64_data_url_is_unchanged() {
|
||||
output: FunctionCallOutputPayload::from_content_items(vec![
|
||||
FunctionCallOutputContentItem::InputImage {
|
||||
image_url,
|
||||
detail: None,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
]),
|
||||
};
|
||||
@@ -1832,7 +1841,10 @@ fn mixed_case_data_url_markers_are_adjusted() {
|
||||
let item = ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputImage { image_url }],
|
||||
content: vec![ContentItem::InputImage {
|
||||
image_url,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
}],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
};
|
||||
@@ -1859,9 +1871,11 @@ fn multiple_inline_images_apply_multiple_fixed_costs() {
|
||||
},
|
||||
ContentItem::InputImage {
|
||||
image_url: image_url_one,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
ContentItem::InputImage {
|
||||
image_url: image_url_two,
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
],
|
||||
end_turn: None,
|
||||
|
||||
@@ -90,7 +90,7 @@ fn parse_user_message(message: &[ContentItem]) -> Option<UserMessageItem> {
|
||||
text_elements: Vec::new(),
|
||||
});
|
||||
}
|
||||
ContentItem::InputImage { image_url } => {
|
||||
ContentItem::InputImage { image_url, .. } => {
|
||||
content.push(UserInput::Image {
|
||||
image_url: image_url.clone(),
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::items::WebSearchItem;
|
||||
use codex_protocol::items::build_hook_prompt_message;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::DEFAULT_IMAGE_DETAIL;
|
||||
use codex_protocol::models::ReasoningItemContent;
|
||||
use codex_protocol::models::ReasoningItemReasoningSummary;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
@@ -26,9 +27,11 @@ fn parses_user_message_with_text_and_two_images() {
|
||||
},
|
||||
ContentItem::InputImage {
|
||||
image_url: img1.clone(),
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
ContentItem::InputImage {
|
||||
image_url: img2.clone(),
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
],
|
||||
end_turn: None,
|
||||
@@ -66,6 +69,7 @@ fn skips_local_image_label_text() {
|
||||
ContentItem::InputText { text: label },
|
||||
ContentItem::InputImage {
|
||||
image_url: image_url.clone(),
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: "</image>".to_string(),
|
||||
@@ -145,6 +149,7 @@ fn skips_unnamed_image_label_text() {
|
||||
ContentItem::InputText { text: label },
|
||||
ContentItem::InputImage {
|
||||
image_url: image_url.clone(),
|
||||
detail: Some(DEFAULT_IMAGE_DETAIL),
|
||||
},
|
||||
ContentItem::InputText {
|
||||
text: codex_protocol::models::image_close_tag_text(),
|
||||
|
||||
@@ -495,6 +495,8 @@ async fn load_exec_policy_with_warning(
|
||||
}
|
||||
|
||||
pub async fn load_exec_policy(config_stack: &ConfigLayerStack) -> Result<Policy, ExecPolicyError> {
|
||||
// Disabled project layers already represent the trust decision, so hooks
|
||||
// and exec-policy loading can reuse the normal trusted-layer view.
|
||||
// Iterate the layers in increasing order of precedence, adding the *.rules
|
||||
// from each layer, so that higher-precedence layers can override
|
||||
// rules defined in lower-precedence ones.
|
||||
|
||||
@@ -10,6 +10,9 @@ use crate::config_loader::RequirementSource;
|
||||
use crate::config_loader::Sourced;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::RequirementsExecPolicy;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::config_toml::ProjectConfig;
|
||||
use codex_protocol::config_types::TrustLevel;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
@@ -67,6 +70,33 @@ fn starlark_string(value: &str) -> String {
|
||||
value.replace('\\', "\\\\").replace('"', "\\\"")
|
||||
}
|
||||
|
||||
async fn write_project_trust_config(
|
||||
codex_home: &Path,
|
||||
trusted_projects: &[(&Path, TrustLevel)],
|
||||
) -> std::io::Result<()> {
|
||||
tokio::fs::write(
|
||||
codex_home.join(codex_config::CONFIG_TOML_FILE),
|
||||
toml::to_string(&ConfigToml {
|
||||
projects: Some(
|
||||
trusted_projects
|
||||
.iter()
|
||||
.map(|(project, trust_level)| {
|
||||
(
|
||||
project.to_string_lossy().to_string(),
|
||||
ProjectConfig {
|
||||
trust_level: Some(*trust_level),
|
||||
},
|
||||
)
|
||||
})
|
||||
.collect::<std::collections::HashMap<_, _>>(),
|
||||
),
|
||||
..Default::default()
|
||||
})
|
||||
.expect("serialize config"),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
fn read_only_file_system_sandbox_policy() -> FileSystemSandboxPolicy {
|
||||
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
@@ -1756,3 +1786,159 @@ async fn assert_exec_approval_requirement_for_command(
|
||||
|
||||
assert_eq!(requirement, expected_requirement);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_policies_only_load_from_trusted_project_layers() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let codex_home = temp.path().join("home_execpolicy_nested");
|
||||
let project_root = temp.path().join("project_execpolicy_nested");
|
||||
let nested = project_root.join("nested");
|
||||
let root_rules = project_root.join(".codex").join(RULES_DIR_NAME);
|
||||
let nested_rules = nested.join(".codex").join(RULES_DIR_NAME);
|
||||
|
||||
fs::create_dir_all(&codex_home)?;
|
||||
fs::create_dir_all(&nested_rules)?;
|
||||
fs::write(project_root.join(".git"), "gitdir: here")?;
|
||||
fs::create_dir_all(&root_rules)?;
|
||||
fs::write(
|
||||
root_rules.join("deny-rm.rules"),
|
||||
r#"prefix_rule(pattern=["rm"], decision="forbidden")"#,
|
||||
)?;
|
||||
fs::write(
|
||||
nested_rules.join("deny-mv.rules"),
|
||||
r#"prefix_rule(pattern=["mv"], decision="forbidden")"#,
|
||||
)?;
|
||||
write_project_trust_config(&codex_home, &[(&nested, TrustLevel::Trusted)]).await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.fallback_cwd(Some(nested))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
let policy = load_exec_policy(&config.config_layer_stack)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
assert_eq!(
|
||||
policy
|
||||
.check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow)
|
||||
.decision,
|
||||
Decision::Allow,
|
||||
);
|
||||
assert_eq!(
|
||||
policy
|
||||
.check_multiple([vec!["mv".to_string()]].iter(), &|_| Decision::Allow)
|
||||
.decision,
|
||||
Decision::Forbidden,
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_policies_require_project_trust_without_config_toml() -> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let project_root = temp.path().join("project_execpolicy");
|
||||
let nested = project_root.join("nested");
|
||||
let rules_dir = project_root.join(".codex").join(RULES_DIR_NAME);
|
||||
fs::create_dir_all(&nested)?;
|
||||
fs::write(project_root.join(".git"), "gitdir: here")?;
|
||||
fs::create_dir_all(&rules_dir)?;
|
||||
fs::write(
|
||||
rules_dir.join("deny-rm.rules"),
|
||||
r#"prefix_rule(pattern=["rm"], decision="forbidden")"#,
|
||||
)?;
|
||||
|
||||
let cases = [
|
||||
(
|
||||
"unknown",
|
||||
Vec::<(&Path, TrustLevel)>::new(),
|
||||
Decision::Allow,
|
||||
),
|
||||
(
|
||||
"untrusted",
|
||||
vec![(&project_root as &Path, TrustLevel::Untrusted)],
|
||||
Decision::Allow,
|
||||
),
|
||||
(
|
||||
"trusted",
|
||||
vec![(&project_root as &Path, TrustLevel::Trusted)],
|
||||
Decision::Forbidden,
|
||||
),
|
||||
];
|
||||
|
||||
for (name, trust_entries, expected_decision) in cases {
|
||||
let codex_home = temp.path().join(format!("home_execpolicy_{name}"));
|
||||
fs::create_dir_all(&codex_home)?;
|
||||
write_project_trust_config(&codex_home, &trust_entries).await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.fallback_cwd(Some(nested.clone()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
let policy = load_exec_policy(&config.config_layer_stack)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
assert_eq!(
|
||||
policy
|
||||
.check_multiple([vec!["rm".to_string()]].iter(), &|_| Decision::Allow)
|
||||
.decision,
|
||||
expected_decision,
|
||||
"unexpected execpolicy decision for {name}",
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn exec_policy_warnings_ignore_untrusted_project_rules_without_config_toml()
|
||||
-> std::io::Result<()> {
|
||||
let temp = tempfile::tempdir()?;
|
||||
let project_root = temp.path().join("project_execpolicy_warning");
|
||||
let nested = project_root.join("nested");
|
||||
let rules_dir = project_root.join(".codex").join(RULES_DIR_NAME);
|
||||
fs::create_dir_all(&nested)?;
|
||||
fs::write(project_root.join(".git"), "gitdir: here")?;
|
||||
fs::create_dir_all(&rules_dir)?;
|
||||
fs::write(rules_dir.join("broken.rules"), "prefix_rule(")?;
|
||||
|
||||
let cases = [
|
||||
("unknown", Vec::<(&Path, TrustLevel)>::new(), false),
|
||||
(
|
||||
"untrusted",
|
||||
vec![(&project_root as &Path, TrustLevel::Untrusted)],
|
||||
false,
|
||||
),
|
||||
(
|
||||
"trusted",
|
||||
vec![(&project_root as &Path, TrustLevel::Trusted)],
|
||||
true,
|
||||
),
|
||||
];
|
||||
|
||||
for (name, trust_entries, expect_warning) in cases {
|
||||
let codex_home = temp.path().join(format!("home_execpolicy_warning_{name}"));
|
||||
fs::create_dir_all(&codex_home)?;
|
||||
write_project_trust_config(&codex_home, &trust_entries).await?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home)
|
||||
.fallback_cwd(Some(nested.clone()))
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
let warning = check_execpolicy_for_warnings(&config.config_layer_stack)
|
||||
.await
|
||||
.map_err(std::io::Error::other)?;
|
||||
assert_eq!(
|
||||
matches!(warning, Some(ExecPolicyError::ParsePolicy { .. })),
|
||||
expect_warning,
|
||||
"unexpected execpolicy warning state for {name}",
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -273,7 +273,7 @@ impl ExternalAgentConfigService {
|
||||
items.push(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
description: format!(
|
||||
"Import {} to {}",
|
||||
"Migrate {} to {}",
|
||||
source_agents_md.display(),
|
||||
target_agents_md.display()
|
||||
),
|
||||
@@ -357,7 +357,7 @@ impl ExternalAgentConfigService {
|
||||
|
||||
Some(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||
description: format!("Import enabled plugins from {}", source_settings.display()),
|
||||
description: format!("Migrate enabled plugins from {}", source_settings.display()),
|
||||
cwd,
|
||||
details: Some(plugin_details),
|
||||
})
|
||||
|
||||
@@ -74,7 +74,7 @@ async fn detect_home_lists_config_skills_and_agents_md() {
|
||||
ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
description: format!(
|
||||
"Import {} to {}",
|
||||
"Migrate {} to {}",
|
||||
external_agent_home.join("CLAUDE.md").display(),
|
||||
codex_home.join("AGENTS.md").display()
|
||||
),
|
||||
@@ -107,7 +107,7 @@ async fn detect_repo_lists_agents_md_for_each_cwd() {
|
||||
ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
description: format!(
|
||||
"Import {} to {}",
|
||||
"Migrate {} to {}",
|
||||
repo_root.join("CLAUDE.md").display(),
|
||||
repo_root.join("AGENTS.md").display(),
|
||||
),
|
||||
@@ -117,7 +117,7 @@ async fn detect_repo_lists_agents_md_for_each_cwd() {
|
||||
ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
description: format!(
|
||||
"Import {} to {}",
|
||||
"Migrate {} to {}",
|
||||
repo_root.join("CLAUDE.md").display(),
|
||||
repo_root.join("AGENTS.md").display(),
|
||||
),
|
||||
@@ -194,7 +194,7 @@ async fn detect_repo_still_reports_non_plugin_items_when_home_config_is_invalid(
|
||||
ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
description: format!(
|
||||
"Import {} to {}",
|
||||
"Migrate {} to {}",
|
||||
repo_root.join(".claude").join("CLAUDE.md").display(),
|
||||
repo_root.join("AGENTS.md").display(),
|
||||
),
|
||||
@@ -566,7 +566,7 @@ async fn detect_repo_prefers_non_empty_external_agent_agents_source() {
|
||||
vec![ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::AgentsMd,
|
||||
description: format!(
|
||||
"Import {} to {}",
|
||||
"Migrate {} to {}",
|
||||
repo_root.join(".claude").join("CLAUDE.md").display(),
|
||||
repo_root.join("AGENTS.md").display(),
|
||||
),
|
||||
@@ -650,7 +650,7 @@ async fn detect_home_lists_enabled_plugins_from_settings() {
|
||||
vec![ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||
description: format!(
|
||||
"Import enabled plugins from {}",
|
||||
"Migrate enabled plugins from {}",
|
||||
external_agent_home.join("settings.json").display()
|
||||
),
|
||||
cwd: None,
|
||||
@@ -710,7 +710,7 @@ enabled = true
|
||||
vec![ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||
description: format!(
|
||||
"Import enabled plugins from {}",
|
||||
"Migrate enabled plugins from {}",
|
||||
repo_root.join(".claude").join("settings.json").display()
|
||||
),
|
||||
cwd: Some(repo_root),
|
||||
@@ -868,7 +868,7 @@ enabled = true
|
||||
vec![ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||
description: format!(
|
||||
"Import enabled plugins from {}",
|
||||
"Migrate enabled plugins from {}",
|
||||
repo_root.join(".claude").join("settings.json").display()
|
||||
),
|
||||
cwd: Some(repo_root),
|
||||
@@ -1048,7 +1048,7 @@ source = "owner/debug-marketplace"
|
||||
vec![ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||
description: format!(
|
||||
"Import enabled plugins from {}",
|
||||
"Migrate enabled plugins from {}",
|
||||
repo_root.join(".claude").join("settings.json").display()
|
||||
),
|
||||
cwd: Some(repo_root),
|
||||
@@ -1275,7 +1275,7 @@ async fn detect_home_supports_relative_external_agent_plugin_marketplace_path()
|
||||
vec![ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||
description: format!(
|
||||
"Import enabled plugins from {}",
|
||||
"Migrate enabled plugins from {}",
|
||||
external_agent_home.join("settings.json").display()
|
||||
),
|
||||
cwd: None,
|
||||
@@ -1426,7 +1426,7 @@ async fn detect_repo_supports_project_relative_external_agent_plugin_marketplace
|
||||
vec![ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||
description: format!(
|
||||
"Import enabled plugins from {}",
|
||||
"Migrate enabled plugins from {}",
|
||||
repo_root.join(".claude").join("settings.json").display()
|
||||
),
|
||||
cwd: Some(repo_root),
|
||||
|
||||
@@ -99,6 +99,7 @@ pub(crate) use skills::build_skill_injections;
|
||||
pub(crate) use skills::build_skill_name_counts;
|
||||
pub(crate) use skills::collect_env_var_dependencies;
|
||||
pub(crate) use skills::collect_explicit_skill_mentions;
|
||||
pub(crate) use skills::default_skill_metadata_budget;
|
||||
pub(crate) use skills::injection;
|
||||
pub(crate) use skills::manager;
|
||||
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user